Files
nt8-sdk/src/NT8.Strategies/Examples/SimpleORBStrategy.cs
mo 9a28a49292
Some checks failed
Build and Test / build (push) Has been cancelled
Pre-cleanup baseline snapshot
2026-04-05 16:50:18 -04:00

643 lines
28 KiB
C#

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
{
/// <summary>
/// Opening Range Breakout strategy with Phase 4 confluence and grade-aware intent metadata.
/// </summary>
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<IFactorCalculator> _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;
/// <summary>
/// Gets strategy metadata.
/// </summary>
public StrategyMetadata Metadata { get; private set; }
/// <summary>
/// Creates a new strategy with default ORB configuration.
/// </summary>
public SimpleORBStrategy()
: this(30, 1.0)
{
}
/// <summary>
/// Creates a new strategy with custom ORB configuration.
/// </summary>
/// <param name="openingRangeMinutes">Opening range period in minutes.</param>
/// <param name="stdDevMultiplier">Breakout volatility multiplier.</param>
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);
}
/// <summary>
/// Initializes strategy dependencies.
/// </summary>
/// <param name="config">Strategy configuration.</param>
/// <param name="dataProvider">Market data provider.</param>
/// <param name="logger">Logger instance.</param>
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<IFactorCalculator>();
_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;
}
}
/// <summary>
/// Processes bar data and returns a trade intent when breakout and confluence criteria pass.
/// </summary>
/// <param name="bar">Current bar.</param>
/// <param name="context">Strategy context.</param>
/// <returns>Trade intent when signal is accepted; otherwise null.</returns>
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;
}
}
/// <summary>
/// Processes tick data. This strategy does not use tick-level logic.
/// </summary>
/// <param name="tick">Tick data.</param>
/// <param name="context">Strategy context.</param>
/// <returns>Always null for this strategy.</returns>
public StrategyIntent OnTick(TickData tick, StrategyContext context)
{
return null;
}
/// <summary>
/// Returns current strategy parameters.
/// </summary>
/// <returns>Parameter map.</returns>
public Dictionary<string, object> GetParameters()
{
lock (_lock)
{
var parameters = new Dictionary<string, object>();
parameters.Add("opening_range_minutes", _openingRangeMinutes);
parameters.Add("std_dev_multiplier", _stdDevMultiplier);
return parameters;
}
}
/// <summary>
/// Updates strategy parameters.
/// </summary>
/// <param name="parameters">Parameter map.</param>
public void SetParameters(Dictionary<string, object> 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<string, object>();
// 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<string, object>();
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;
}
}
}