4d0d4cbfa3
* feat: add session tracking and multi-leg spread backtesting Add SessionTracker for trading session management: - Market hours detection (pre-open, trading, squareoff, post-close) - Session boundary tracking with configurable timezone - Squareoff time support for intraday strategies - Session high/low/open price tracking Add SpreadBacktest for multi-leg options strategies: - Support for straddles, strangles, vertical spreads, iron condors - Coordinated entry/exit across all legs - Net premium P&L calculation with max loss/target profit exits - Helper functions for common spread configurations Extend StreamingMetrics for backtest integration: - Add equity and drawdown tracking (update_equity, current_drawdown_pct) - Add trade recording (record_trade, record_fees) - Add finalize() method to produce BacktestMetrics - Add with_initial_capital() constructor Bump version to 0.2.0. * chore: bump up version to 0.2.0 * feat: update version to 0.2.1 and add rolling min/max indicators * fix: formatting
398 lines
13 KiB
Rust
398 lines
13 KiB
Rust
//! Session tracking for intraday strategies.
|
|
//!
|
|
//! Handles:
|
|
//! - Session boundary detection (market open/close)
|
|
//! - Squareoff time enforcement
|
|
//! - Session high/low tracking for ORB and session-based indicators
|
|
//! - Timezone handling for IST (India Standard Time)
|
|
|
|
use serde::{Deserialize, Serialize};
|
|
|
|
/// Session configuration for trading hours.
|
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
|
pub struct SessionConfig {
|
|
/// Market open hour (24-hour format).
|
|
pub market_open_hour: u32,
|
|
/// Market open minute.
|
|
pub market_open_minute: u32,
|
|
/// Market close hour (24-hour format).
|
|
pub market_close_hour: u32,
|
|
/// Market close minute.
|
|
pub market_close_minute: u32,
|
|
/// Squareoff minutes before market close.
|
|
pub squareoff_minutes_before_close: u32,
|
|
/// Timezone offset in hours from UTC (5 for IST = UTC+5:30).
|
|
pub timezone_offset_hours: i32,
|
|
/// Timezone offset minutes (30 for IST).
|
|
pub timezone_offset_minutes: i32,
|
|
}
|
|
|
|
impl Default for SessionConfig {
|
|
fn default() -> Self {
|
|
// Default: NSE equity session (9:15 - 15:30 IST, squareoff at 15:25)
|
|
Self {
|
|
market_open_hour: 9,
|
|
market_open_minute: 15,
|
|
market_close_hour: 15,
|
|
market_close_minute: 30,
|
|
squareoff_minutes_before_close: 5,
|
|
timezone_offset_hours: 5,
|
|
timezone_offset_minutes: 30,
|
|
}
|
|
}
|
|
}
|
|
|
|
impl SessionConfig {
|
|
/// Create NSE equity session config (9:15 - 15:30).
|
|
pub fn nse_equity() -> Self {
|
|
Self::default()
|
|
}
|
|
|
|
/// Create MCX commodity session config (9:00 - 23:30).
|
|
pub fn mcx_commodity() -> Self {
|
|
Self {
|
|
market_open_hour: 9,
|
|
market_open_minute: 0,
|
|
market_close_hour: 23,
|
|
market_close_minute: 30,
|
|
squareoff_minutes_before_close: 5,
|
|
timezone_offset_hours: 5,
|
|
timezone_offset_minutes: 30,
|
|
}
|
|
}
|
|
|
|
/// Create CDS currency session config (9:00 - 17:00).
|
|
pub fn cds_currency() -> Self {
|
|
Self {
|
|
market_open_hour: 9,
|
|
market_open_minute: 0,
|
|
market_close_hour: 17,
|
|
market_close_minute: 0,
|
|
squareoff_minutes_before_close: 5,
|
|
timezone_offset_hours: 5,
|
|
timezone_offset_minutes: 30,
|
|
}
|
|
}
|
|
|
|
/// Get market open time in minutes from midnight.
|
|
pub fn market_open_minutes(&self) -> u32 {
|
|
self.market_open_hour * 60 + self.market_open_minute
|
|
}
|
|
|
|
/// Get market close time in minutes from midnight.
|
|
pub fn market_close_minutes(&self) -> u32 {
|
|
self.market_close_hour * 60 + self.market_close_minute
|
|
}
|
|
|
|
/// Get squareoff time in minutes from midnight.
|
|
pub fn squareoff_minutes(&self) -> u32 {
|
|
self.market_close_minutes().saturating_sub(self.squareoff_minutes_before_close)
|
|
}
|
|
|
|
/// Get timezone offset in seconds.
|
|
pub fn timezone_offset_seconds(&self) -> i64 {
|
|
(self.timezone_offset_hours as i64 * 3600) + (self.timezone_offset_minutes as i64 * 60)
|
|
}
|
|
}
|
|
|
|
/// Session tracker for managing intraday session state.
|
|
#[derive(Debug, Clone)]
|
|
pub struct SessionTracker {
|
|
config: SessionConfig,
|
|
/// Current session date (days since epoch in local timezone).
|
|
current_session_date: i64,
|
|
/// Session high price.
|
|
session_high: f64,
|
|
/// Session low price.
|
|
session_low: f64,
|
|
/// Session open price.
|
|
session_open: f64,
|
|
/// Bar index at session start.
|
|
session_start_idx: usize,
|
|
/// Whether we're currently in a trading session.
|
|
in_session: bool,
|
|
/// Whether squareoff has been triggered today.
|
|
squareoff_triggered: bool,
|
|
}
|
|
|
|
impl SessionTracker {
|
|
/// Create a new session tracker.
|
|
pub fn new(config: SessionConfig) -> Self {
|
|
Self {
|
|
config,
|
|
current_session_date: -1,
|
|
session_high: f64::NEG_INFINITY,
|
|
session_low: f64::INFINITY,
|
|
session_open: 0.0,
|
|
session_start_idx: 0,
|
|
in_session: false,
|
|
squareoff_triggered: false,
|
|
}
|
|
}
|
|
|
|
/// Convert nanosecond timestamp to local time components.
|
|
fn timestamp_to_local(&self, timestamp_ns: i64) -> (i64, u32, u32, u32) {
|
|
// Convert to seconds
|
|
let timestamp_s = timestamp_ns / 1_000_000_000;
|
|
|
|
// Apply timezone offset
|
|
let local_s = timestamp_s + self.config.timezone_offset_seconds();
|
|
|
|
// Calculate date (days since epoch)
|
|
let days = local_s / 86400;
|
|
|
|
// Calculate time within day
|
|
let time_in_day = (local_s % 86400) as u32;
|
|
let hours = time_in_day / 3600;
|
|
let minutes = (time_in_day % 3600) / 60;
|
|
let seconds = time_in_day % 60;
|
|
|
|
(days, hours, minutes, seconds)
|
|
}
|
|
|
|
/// Get minutes from midnight for a timestamp.
|
|
fn get_minutes_from_midnight(&self, timestamp_ns: i64) -> u32 {
|
|
let (_, hours, minutes, _) = self.timestamp_to_local(timestamp_ns);
|
|
hours * 60 + minutes
|
|
}
|
|
|
|
/// Check if timestamp is within trading hours.
|
|
pub fn is_within_trading_hours(&self, timestamp_ns: i64) -> bool {
|
|
let minutes = self.get_minutes_from_midnight(timestamp_ns);
|
|
minutes >= self.config.market_open_minutes() && minutes < self.config.market_close_minutes()
|
|
}
|
|
|
|
/// Check if it's squareoff time.
|
|
pub fn is_squareoff_time(&self, timestamp_ns: i64) -> bool {
|
|
let minutes = self.get_minutes_from_midnight(timestamp_ns);
|
|
minutes >= self.config.squareoff_minutes()
|
|
}
|
|
|
|
/// Check if this bar starts a new session.
|
|
pub fn is_session_start(&self, prev_ts_ns: i64, curr_ts_ns: i64) -> bool {
|
|
let (prev_date, prev_h, prev_m, _) = self.timestamp_to_local(prev_ts_ns);
|
|
let (curr_date, curr_h, curr_m, _) = self.timestamp_to_local(curr_ts_ns);
|
|
|
|
// New day
|
|
if curr_date != prev_date {
|
|
let curr_minutes = curr_h * 60 + curr_m;
|
|
return curr_minutes >= self.config.market_open_minutes();
|
|
}
|
|
|
|
// Same day, but crossed market open
|
|
let prev_minutes = prev_h * 60 + prev_m;
|
|
let curr_minutes = curr_h * 60 + curr_m;
|
|
|
|
prev_minutes < self.config.market_open_minutes()
|
|
&& curr_minutes >= self.config.market_open_minutes()
|
|
}
|
|
|
|
/// Check if this bar ends the session.
|
|
pub fn is_session_end(&self, curr_ts_ns: i64, next_ts_ns: Option<i64>) -> bool {
|
|
let (curr_date, curr_h, curr_m, _) = self.timestamp_to_local(curr_ts_ns);
|
|
let curr_minutes = curr_h * 60 + curr_m;
|
|
|
|
// At or past market close
|
|
if curr_minutes >= self.config.market_close_minutes() {
|
|
return true;
|
|
}
|
|
|
|
// Check if next bar is in a new session
|
|
if let Some(next_ts) = next_ts_ns {
|
|
let (next_date, _, _, _) = self.timestamp_to_local(next_ts);
|
|
if next_date != curr_date {
|
|
return true;
|
|
}
|
|
}
|
|
|
|
false
|
|
}
|
|
|
|
/// Update session state for a new bar.
|
|
///
|
|
/// Returns tuple of (is_new_session, is_squareoff_time, is_session_end).
|
|
pub fn update(
|
|
&mut self,
|
|
idx: usize,
|
|
timestamp_ns: i64,
|
|
open: f64,
|
|
high: f64,
|
|
low: f64,
|
|
_close: f64,
|
|
prev_timestamp_ns: Option<i64>,
|
|
next_timestamp_ns: Option<i64>,
|
|
) -> (bool, bool, bool) {
|
|
let (date, hours, minutes, _) = self.timestamp_to_local(timestamp_ns);
|
|
let time_minutes = hours * 60 + minutes;
|
|
|
|
// Check for new session
|
|
let is_new_session = if self.current_session_date != date {
|
|
// New date - check if within trading hours
|
|
if time_minutes >= self.config.market_open_minutes()
|
|
&& time_minutes < self.config.market_close_minutes()
|
|
{
|
|
self.reset_session(idx, date, open);
|
|
true
|
|
} else {
|
|
false
|
|
}
|
|
} else if let Some(prev_ts) = prev_timestamp_ns {
|
|
if self.is_session_start(prev_ts, timestamp_ns) {
|
|
self.reset_session(idx, date, open);
|
|
true
|
|
} else {
|
|
false
|
|
}
|
|
} else {
|
|
// First bar - start session if within hours
|
|
if time_minutes >= self.config.market_open_minutes()
|
|
&& time_minutes < self.config.market_close_minutes()
|
|
{
|
|
self.reset_session(idx, date, open);
|
|
true
|
|
} else {
|
|
false
|
|
}
|
|
};
|
|
|
|
// Update session high/low
|
|
if self.in_session {
|
|
if high > self.session_high {
|
|
self.session_high = high;
|
|
}
|
|
if low < self.session_low {
|
|
self.session_low = low;
|
|
}
|
|
}
|
|
|
|
// Check squareoff time
|
|
let is_squareoff =
|
|
if time_minutes >= self.config.squareoff_minutes() && !self.squareoff_triggered {
|
|
self.squareoff_triggered = true;
|
|
self.in_session
|
|
} else {
|
|
false
|
|
};
|
|
|
|
// Check session end
|
|
let is_session_end = self.is_session_end(timestamp_ns, next_timestamp_ns);
|
|
if is_session_end {
|
|
self.in_session = false;
|
|
}
|
|
|
|
(is_new_session, is_squareoff, is_session_end)
|
|
}
|
|
|
|
/// Reset session state for a new trading day.
|
|
fn reset_session(&mut self, idx: usize, date: i64, open_price: f64) {
|
|
self.current_session_date = date;
|
|
self.session_start_idx = idx;
|
|
self.session_open = open_price;
|
|
self.session_high = open_price;
|
|
self.session_low = open_price;
|
|
self.in_session = true;
|
|
self.squareoff_triggered = false;
|
|
}
|
|
|
|
/// Get current session high.
|
|
pub fn session_high(&self) -> f64 {
|
|
self.session_high
|
|
}
|
|
|
|
/// Get current session low.
|
|
pub fn session_low(&self) -> f64 {
|
|
self.session_low
|
|
}
|
|
|
|
/// Get current session open.
|
|
pub fn session_open(&self) -> f64 {
|
|
self.session_open
|
|
}
|
|
|
|
/// Get session start index.
|
|
pub fn session_start_idx(&self) -> usize {
|
|
self.session_start_idx
|
|
}
|
|
|
|
/// Check if currently in a trading session.
|
|
pub fn in_session(&self) -> bool {
|
|
self.in_session
|
|
}
|
|
|
|
/// Get opening range (high - low) for the session.
|
|
pub fn opening_range(&self) -> f64 {
|
|
self.session_high - self.session_low
|
|
}
|
|
}
|
|
|
|
#[cfg(test)]
|
|
mod tests {
|
|
use super::*;
|
|
|
|
fn make_timestamp(year: i32, month: u32, day: u32, hour: u32, minute: u32) -> i64 {
|
|
// Simplified: calculate seconds from 1970-01-01 and convert to nanoseconds
|
|
// This is approximate for testing
|
|
let days_since_epoch = (year - 1970) as i64 * 365 + (month - 1) as i64 * 30 + day as i64;
|
|
let seconds = days_since_epoch * 86400 + hour as i64 * 3600 + minute as i64 * 60;
|
|
// Subtract IST offset to get UTC
|
|
let utc_seconds = seconds - (5 * 3600 + 30 * 60);
|
|
utc_seconds * 1_000_000_000
|
|
}
|
|
|
|
#[test]
|
|
fn test_session_config_defaults() {
|
|
let config = SessionConfig::default();
|
|
assert_eq!(config.market_open_hour, 9);
|
|
assert_eq!(config.market_open_minute, 15);
|
|
assert_eq!(config.market_close_hour, 15);
|
|
assert_eq!(config.market_close_minute, 30);
|
|
assert_eq!(config.squareoff_minutes_before_close, 5);
|
|
}
|
|
|
|
#[test]
|
|
fn test_squareoff_minutes() {
|
|
let config = SessionConfig::default();
|
|
// 15:30 - 5 minutes = 15:25 = 925 minutes
|
|
assert_eq!(config.squareoff_minutes(), 925);
|
|
}
|
|
|
|
#[test]
|
|
fn test_mcx_session() {
|
|
let config = SessionConfig::mcx_commodity();
|
|
assert_eq!(config.market_open_hour, 9);
|
|
assert_eq!(config.market_close_hour, 23);
|
|
assert_eq!(config.market_close_minute, 30);
|
|
}
|
|
|
|
#[test]
|
|
fn test_session_tracker_new_session() {
|
|
let config = SessionConfig::default();
|
|
let mut tracker = SessionTracker::new(config);
|
|
|
|
// Simulate market open at 9:15 IST
|
|
let ts = make_timestamp(2024, 1, 15, 9, 15);
|
|
let (is_new, _, _) = tracker.update(0, ts, 100.0, 101.0, 99.0, 100.5, None, None);
|
|
|
|
assert!(is_new);
|
|
assert!(tracker.in_session());
|
|
assert_eq!(tracker.session_open(), 100.0);
|
|
}
|
|
|
|
#[test]
|
|
fn test_session_high_low() {
|
|
let config = SessionConfig::default();
|
|
let mut tracker = SessionTracker::new(config);
|
|
|
|
// First bar
|
|
let ts1 = make_timestamp(2024, 1, 15, 9, 15);
|
|
tracker.update(0, ts1, 100.0, 105.0, 95.0, 102.0, None, None);
|
|
|
|
// Second bar
|
|
let ts2 = make_timestamp(2024, 1, 15, 9, 30);
|
|
tracker.update(1, ts2, 102.0, 110.0, 100.0, 108.0, Some(ts1), None);
|
|
|
|
assert_eq!(tracker.session_high(), 110.0);
|
|
assert_eq!(tracker.session_low(), 95.0);
|
|
}
|
|
}
|