using System; using System.Collections.Generic; using NT8.Core.Common.Interfaces; using NT8.Core.Common.Models; using NT8.Core.Indicators; using NT8.Core.Intelligence; using NT8.Core.Logging; namespace NT8.Strategies.Examples { /// /// Opening Range Breakout strategy with Phase 4 confluence and grade-aware intent metadata. /// public class SimpleORBStrategy : IStrategy { private readonly object _lock = new object(); private readonly int _openingRangeMinutes; private readonly double _stdDevMultiplier; private ILogger _logger; private StrategyConfig _config; private ConfluenceScorer _scorer; private GradeFilter _gradeFilter; private RiskModeManager _riskModeManager; private AVWAPCalculator _avwapCalculator; private VolumeProfileAnalyzer _volumeProfileAnalyzer; private List _factorCalculators; private DateTime _currentSessionDate; private DateTime _openingRangeStart; private DateTime _openingRangeEnd; private double _openingRangeHigh; private double _openingRangeLow; private bool _openingRangeReady; private bool _tradeTaken; private int _consecutiveWins; private int _consecutiveLosses; /// /// Gets strategy metadata. /// public StrategyMetadata Metadata { get; private set; } /// /// Creates a new strategy with default ORB configuration. /// public SimpleORBStrategy() : this(30, 1.0) { } /// /// Creates a new strategy with custom ORB configuration. /// /// Opening range period in minutes. /// Breakout volatility multiplier. public SimpleORBStrategy(int openingRangeMinutes, double stdDevMultiplier) { if (openingRangeMinutes <= 0) throw new ArgumentException("openingRangeMinutes must be greater than zero", "openingRangeMinutes"); if (stdDevMultiplier <= 0.0) throw new ArgumentException("stdDevMultiplier must be greater than zero", "stdDevMultiplier"); _openingRangeMinutes = openingRangeMinutes; _stdDevMultiplier = stdDevMultiplier; _currentSessionDate = DateTime.MinValue; _openingRangeStart = DateTime.MinValue; _openingRangeEnd = DateTime.MinValue; _openingRangeHigh = Double.MinValue; _openingRangeLow = Double.MaxValue; _openingRangeReady = false; _tradeTaken = false; _consecutiveWins = 0; _consecutiveLosses = 0; Metadata = new StrategyMetadata( "Simple ORB", "Opening Range Breakout strategy with confluence scoring", "2.0", "NT8 SDK Team", new string[] { "ES", "NQ", "YM" }, 20); } /// /// Initializes strategy dependencies. /// /// Strategy configuration. /// Market data provider. /// Logger instance. public void Initialize(StrategyConfig config, IMarketDataProvider dataProvider, ILogger logger) { if (logger == null) throw new ArgumentNullException("logger"); try { _logger = logger; _config = config; _scorer = new ConfluenceScorer(_logger, 500); _gradeFilter = new GradeFilter(); _riskModeManager = new RiskModeManager(_logger); _avwapCalculator = new AVWAPCalculator(AVWAPAnchorMode.Day, DateTime.UtcNow); _volumeProfileAnalyzer = new VolumeProfileAnalyzer(); _factorCalculators = new List(); _factorCalculators.Add(new OrbSetupFactorCalculator()); _factorCalculators.Add(new TrendAlignmentFactorCalculator()); _factorCalculators.Add(new VolatilityRegimeFactorCalculator()); _factorCalculators.Add(new TimeInSessionFactorCalculator()); _factorCalculators.Add(new ExecutionQualityFactorCalculator()); _factorCalculators.Add(new NarrowRangeFactorCalculator(_logger)); _factorCalculators.Add(new OrbRangeVsAtrFactorCalculator(_logger)); _factorCalculators.Add(new GapDirectionAlignmentCalculator(_logger)); _factorCalculators.Add(new BreakoutVolumeStrengthCalculator(_logger)); _factorCalculators.Add(new PriorDayCloseStrengthCalculator(_logger)); _logger.LogInformation( "SimpleORBStrategy initialized with OR period {0} minutes and multiplier {1:F2}", _openingRangeMinutes, _stdDevMultiplier); } catch (Exception ex) { if (_logger != null) _logger.LogError("SimpleORBStrategy Initialize failed: {0}", ex.Message); throw; } } /// /// Processes bar data and returns a trade intent when breakout and confluence criteria pass. /// /// Current bar. /// Strategy context. /// Trade intent when signal is accepted; otherwise null. public StrategyIntent OnBar(BarData bar, StrategyContext context) { if (bar == null) throw new ArgumentNullException("bar"); if (context == null) throw new ArgumentNullException("context"); try { lock (_lock) { EnsureInitialized(); UpdateRiskMode(context); UpdateConfluenceInputs(bar, context); DateTime thisSessionStart = context.Session != null ? context.Session.SessionStart : context.CurrentTime.Date.AddHours(9.5); // Guard: only reset when the TRADING DATE has changed, not just the session // start timestamp. Using trading date prevents mid-session resets caused by // _openingRangeStart holding a stale value from a previous day. DateTime thisTradingDate = thisSessionStart.Date; TimeSpan sessionStartTime = thisSessionStart.TimeOfDay; bool isValidRthSessionStart = sessionStartTime >= new TimeSpan(8, 0, 0) && sessionStartTime <= new TimeSpan(10, 30, 0); if (isValidRthSessionStart && thisTradingDate != _currentSessionDate) { ResetSession(thisSessionStart); if (context.CustomData != null && context.CustomData.ContainsKey("session_open_price")) context.CustomData.Remove("session_open_price"); } // Only trade during RTH if (context.Session == null || !context.Session.IsRth) return null; if (bar.Time <= _openingRangeEnd) { UpdateOpeningRange(bar); return null; } if (!_openingRangeReady) { if (_openingRangeHigh > _openingRangeLow) _openingRangeReady = true; else return null; } if (_logger != null && _openingRangeReady) { _logger.LogDebug( "ORB ready: High={0:F2} Low={1:F2} Range={2:F2} TradeTaken={3}", _openingRangeHigh, _openingRangeLow, _openingRangeHigh - _openingRangeLow, _tradeTaken); } if (_tradeTaken) { if (_logger != null) _logger.LogDebug( "SimpleORBStrategy skip: trade already taken for session {0:yyyy-MM-dd}; bar={1:yyyy-MM-dd HH:mm}; symbol={2}", _currentSessionDate, bar.Time, context.Symbol); return null; } var openingRange = _openingRangeHigh - _openingRangeLow; var volatilityBuffer = openingRange * (_stdDevMultiplier - 1.0); if (volatilityBuffer < 0.0) volatilityBuffer = 0.0; var longTrigger = _openingRangeHigh + volatilityBuffer; var shortTrigger = _openingRangeLow - volatilityBuffer; StrategyIntent candidate = null; if (bar.Close > longTrigger) candidate = CreateIntent(context.Symbol, OrderSide.Buy, openingRange, bar.Close); else if (bar.Close < shortTrigger) candidate = CreateIntent(context.Symbol, OrderSide.Sell, openingRange, bar.Close); if (candidate == null) return null; AttachDailyBarContext(candidate, bar, context); // Hard veto: Trend against trade direction is a disqualifying condition. // AVWAP trend alignment is checked before confluence scoring to prevent // high scores from other factors masking a directionally broken setup. if (_config != null && _config.Parameters != null) { if (context.CustomData != null && context.CustomData.ContainsKey("avwap_slope")) { var slope = context.CustomData["avwap_slope"]; if (slope is double) { double slopeVal = (double)slope; bool longTrade = candidate.Side == OrderSide.Buy; bool trendAgainst = (longTrade && slopeVal < 0) || (!longTrade && slopeVal > 0); if (trendAgainst) { if (_logger != null) _logger.LogInformation("Trade vetoed: AVWAP slope {0:F4} against {1} direction", slopeVal, candidate.Side); return null; } } } } var score = _scorer.CalculateScore(candidate, context, bar, _factorCalculators); var mode = _riskModeManager.GetCurrentMode(); int minGradeValue = 5; if (_config != null && _config.Parameters != null && _config.Parameters.ContainsKey("MinTradeGrade")) { var mgv = _config.Parameters["MinTradeGrade"]; if (mgv is int) minGradeValue = (int)mgv; } TradeGrade minGrade = (TradeGrade)minGradeValue; if ((int)score.Grade < (int)minGrade) { if (_logger != null) _logger.LogInformation( "SimpleORBStrategy filtered by grade: Score={0:F3} Grade={1} MinGrade={2}", score.WeightedScore, score.Grade, minGrade); return null; } var gradeMultiplier = _gradeFilter.GetSizeMultiplier(score.Grade, mode); var modeConfig = _riskModeManager.GetModeConfig(mode); var combinedMultiplier = gradeMultiplier * modeConfig.SizeMultiplier; candidate.Confidence = score.WeightedScore; candidate.Reason = string.Format("{0}; grade={1}; mode={2}", candidate.Reason, score.Grade, mode); candidate.Metadata["confluence_score"] = score; candidate.Metadata["confluence_weighted_score"] = score.WeightedScore; candidate.Metadata["trade_grade"] = score.Grade.ToString(); candidate.Metadata["risk_mode"] = mode.ToString(); candidate.Metadata["grade_multiplier"] = gradeMultiplier; candidate.Metadata["mode_multiplier"] = modeConfig.SizeMultiplier; candidate.Metadata["combined_multiplier"] = combinedMultiplier; _tradeTaken = true; if (_logger != null) _logger.LogDebug( "SimpleORBStrategy flag set: tradeTaken={0} session={1:yyyy-MM-dd}; bar={2:yyyy-MM-dd HH:mm}; side={3}; symbol={4}", _tradeTaken, _currentSessionDate, bar.Time, candidate.Side, candidate.Symbol); if (_logger != null && score.Factors != null) { System.Text.StringBuilder sb = new System.Text.StringBuilder(); sb.Append("Factors: "); foreach (ConfluenceFactor f in score.Factors) sb.Append(string.Format("{0}={1:F2}({2}) ", f.Type, f.Score, f.Weight.ToString("F2"))); _logger.LogInformation("Confluence detail: {0}", sb.ToString().TrimEnd()); } _logger.LogInformation( "SimpleORBStrategy accepted intent for {0}: Side={1}, Grade={2}, Mode={3}, Score={4:F3}, Mult={5:F2}", candidate.Symbol, candidate.Side, score.Grade, mode, score.WeightedScore, combinedMultiplier); return candidate; } } catch (Exception ex) { if (_logger != null) _logger.LogError("SimpleORBStrategy OnBar failed: {0}", ex.Message); throw; } } /// /// Processes tick data. This strategy does not use tick-level logic. /// /// Tick data. /// Strategy context. /// Always null for this strategy. public StrategyIntent OnTick(TickData tick, StrategyContext context) { return null; } /// /// Returns current strategy parameters. /// /// Parameter map. public Dictionary GetParameters() { lock (_lock) { var parameters = new Dictionary(); parameters.Add("opening_range_minutes", _openingRangeMinutes); parameters.Add("std_dev_multiplier", _stdDevMultiplier); return parameters; } } /// /// Updates strategy parameters. /// /// Parameter map. public void SetParameters(Dictionary parameters) { if (parameters == null) return; // force_session_reset: clear _tradeTaken and ORB state so a fresh live session // can trade even if historical replay set _tradeTaken before going realtime. if (parameters.ContainsKey("force_session_reset")) { var val = parameters["force_session_reset"]; if (val is bool && (bool)val) { lock (_lock) { _tradeTaken = false; _openingRangeReady = false; _openingRangeHigh = Double.MinValue; _openingRangeLow = Double.MaxValue; if (_logger != null) _logger.LogInformation("ForceSessionReset: _tradeTaken cleared, ORB state reset for live session"); } } } } private void EnsureInitialized() { if (_logger == null) throw new InvalidOperationException("Strategy must be initialized before OnBar processing"); if (_scorer == null || _gradeFilter == null || _riskModeManager == null) throw new InvalidOperationException("Intelligence components are not initialized"); } private void UpdateRiskMode(StrategyContext context) { var dailyPnl = 0.0; if (context.Account != null) dailyPnl = context.Account.DailyPnL; _riskModeManager.UpdateRiskMode(dailyPnl, _consecutiveWins, _consecutiveLosses); } private void UpdateConfluenceInputs(BarData bar, StrategyContext context) { _avwapCalculator.Update(bar.Close, bar.Volume); var avwap = _avwapCalculator.GetCurrentValue(); var avwapSlope = _avwapCalculator.GetSlope(10); if (context.CustomData == null) context.CustomData = new Dictionary(); // Use pre-calculated intraday average from daily bar context when available. // Fall back to current bar volume only if daily context is not yet populated. double avgVol = (double)bar.Volume; double normalAtr = bar.High - bar.Low; if (_config != null && _config.Parameters != null && _config.Parameters.ContainsKey("daily_bars")) { var src = _config.Parameters["daily_bars"]; if (src is DailyBarContext) { DailyBarContext dc = (DailyBarContext)src; if (dc.AvgIntradayBarVolume > 0.0) avgVol = dc.AvgIntradayBarVolume; // Use 10-day average daily range as the volatility baseline. if (dc.Count >= 5 && dc.Highs != null && dc.Lows != null) { double sumRanges = 0.0; int lookback = Math.Min(10, dc.Count - 1); int start = dc.Count - 1 - lookback; int end = dc.Count - 2; for (int i = start; i <= end; i++) sumRanges += dc.Highs[i] - dc.Lows[i]; if (lookback > 0) normalAtr = sumRanges / lookback; } } } context.CustomData["current_bar"] = bar; context.CustomData["avwap"] = avwap; context.CustomData["avwap_slope"] = avwapSlope; context.CustomData["trend_confirm"] = avwapSlope > 0.0 ? 1.0 : 0.0; context.CustomData["current_atr"] = Math.Max(0.01, bar.High - bar.Low); context.CustomData["normal_atr"] = Math.Max(0.01, normalAtr); context.CustomData["recent_execution_quality"] = 0.8; context.CustomData["avg_volume"] = avgVol; // Track the first bar open of the RTH session as the session open price. // Only set once per session (when session_open_price is not yet in custom data). if (!context.CustomData.ContainsKey("session_open_price")) { context.CustomData["session_open_price"] = bar.Open; } } private void ResetSession(DateTime sessionStart) { _currentSessionDate = sessionStart.Date; _openingRangeStart = sessionStart; _openingRangeEnd = sessionStart.AddMinutes(_openingRangeMinutes); _openingRangeHigh = Double.MinValue; _openingRangeLow = Double.MaxValue; _openingRangeReady = false; _tradeTaken = false; if (_logger != null) _logger.LogInformation( "Session reset: Date={0:yyyy-MM-dd} ORB window={1:HH:mm}-{2:HH:mm}", _currentSessionDate, _openingRangeStart, _openingRangeEnd); } private void UpdateOpeningRange(BarData bar) { if (bar.High > _openingRangeHigh) _openingRangeHigh = bar.High; if (bar.Low < _openingRangeLow) _openingRangeLow = bar.Low; } private StrategyIntent CreateIntent(string symbol, OrderSide side, double openingRange, double lastPrice) { var stopTicks = _config != null && _config.Parameters.ContainsKey("StopTicks") ? (int)_config.Parameters["StopTicks"] : 8; int baseTargetTicks = _config != null && _config.Parameters.ContainsKey("TargetTicks") ? (int)_config.Parameters["TargetTicks"] : 16; var metadata = new Dictionary(); metadata.Add("orb_high", _openingRangeHigh); metadata.Add("orb_low", _openingRangeLow); metadata.Add("orb_range", openingRange); double tickSize = 0.25; if (_config != null && _config.Parameters != null && _config.Parameters.ContainsKey("TickSize")) { var tickValue = _config.Parameters["TickSize"]; if (tickValue is double) tickSize = (double)tickValue; else if (tickValue is decimal) tickSize = (double)(decimal)tickValue; else if (tickValue is float) tickSize = (double)(float)tickValue; } if (tickSize <= 0.0) tickSize = 0.25; // Scale target dynamically based on ORB range vs average daily range. // Tight ORBs (< 20% of daily ATR) leave the most room — extend target. // Wide ORBs (> 50% of daily ATR) have consumed range — keep target tight. // Requires daily bar context with at least 5 bars; falls back to base target otherwise. int targetTicks = baseTargetTicks; if (_config != null && _config.Parameters != null && _config.Parameters.ContainsKey("daily_bars")) { var dailySrc = _config.Parameters["daily_bars"]; if (dailySrc is DailyBarContext) { DailyBarContext dc = (DailyBarContext)dailySrc; if (dc.Count >= 5 && dc.Highs != null && dc.Lows != null && tickSize > 0.0) { double sumAtr = 0.0; int lookback = Math.Min(10, dc.Count - 1); int start = dc.Count - 1 - lookback; int end = dc.Count - 2; for (int i = start; i <= end; i++) sumAtr += dc.Highs[i] - dc.Lows[i]; double avgDailyRange = lookback > 0 ? sumAtr / lookback : 0.0; double orbRangePoints = openingRange; double ratio = avgDailyRange > 0.0 ? orbRangePoints / avgDailyRange : 0.5; // Ratio tiers map to target multipliers: // <= 0.20 (tight ORB) → 1.75x (28 ticks on 16-tick base) // <= 0.30 → 1.50x (24 ticks) // <= 0.45 → 1.25x (20 ticks) // <= 0.60 → 1.00x (16 ticks — base, no change) // > 0.60 (wide ORB) → 0.75x (12 ticks — tighten) double multiplier; if (ratio <= 0.20) multiplier = 1.75; else if (ratio <= 0.30) multiplier = 1.50; else if (ratio <= 0.45) multiplier = 1.25; else if (ratio <= 0.60) multiplier = 1.00; else multiplier = 0.75; targetTicks = (int)Math.Round(baseTargetTicks * multiplier); // Enforce hard floor of stopTicks + 4 (minimum 1:1 R plus 4 ticks) int minTarget = stopTicks + 4; if (targetTicks < minTarget) targetTicks = minTarget; } } } if (_logger != null && targetTicks != baseTargetTicks) _logger.LogInformation( "Dynamic target: base={0} adjusted={1} (ORB/ATR scaling)", baseTargetTicks, targetTicks); var orbRangeTicks = openingRange / tickSize; metadata.Add("orb_range_ticks", orbRangeTicks); metadata.Add("trigger_price", lastPrice); metadata.Add("multiplier", _stdDevMultiplier); metadata.Add("opening_range_start", _openingRangeStart); metadata.Add("opening_range_end", _openingRangeEnd); metadata.Add("base_target_ticks", baseTargetTicks); metadata.Add("dynamic_target_ticks", targetTicks); return new StrategyIntent( symbol, side, OrderType.Market, null, stopTicks, targetTicks, 0.75, "ORB breakout signal", metadata); } private void AttachDailyBarContext(StrategyIntent intent, BarData bar, StrategyContext context) { if (intent == null || intent.Metadata == null) return; if (_config == null || _config.Parameters == null || !_config.Parameters.ContainsKey("daily_bars")) return; var source = _config.Parameters["daily_bars"]; if (!(source is DailyBarContext)) return; DailyBarContext baseContext = (DailyBarContext)source; DailyBarContext daily = baseContext; daily.TradeDirection = intent.Side == OrderSide.Buy ? 1 : -1; daily.BreakoutBarVolume = (double)bar.Volume; double todayOpen = bar.Open; if (context != null && context.CustomData != null && context.CustomData.ContainsKey("session_open_price")) { object sop = context.CustomData["session_open_price"]; if (sop is double) todayOpen = (double)sop; } daily.TodayOpen = todayOpen; if (context != null && context.CustomData != null && context.CustomData.ContainsKey("avg_volume")) { var avg = context.CustomData["avg_volume"]; if (avg is double) daily.AvgIntradayBarVolume = (double)avg; else if (avg is float) daily.AvgIntradayBarVolume = (double)(float)avg; else if (avg is int) daily.AvgIntradayBarVolume = (double)(int)avg; else if (avg is long) daily.AvgIntradayBarVolume = (double)(long)avg; } // orb_range_ticks is set in CreateIntent() and preserved here. intent.Metadata["daily_bars"] = daily; } } }