Fix stop/target ordering, P&L wiring, dynamic targets, confluence wiring

- Move SetStopLoss/SetProfitTarget before EnterLong/EnterShort in
  SubmitOrderToNT8(). NT8 requires stop/target pre-registered to the
  signal name before entry fires — calling after caused silent fallback
  to strategy defaults in backtest, nullifying all dynamic target scaling.
  Add [dynamic] tag to SUBMIT log line for observability.

- Wire OnPositionUpdate() in NT8StrategyBase to call
  _riskManager.OnPnLUpdate() and PortfolioRiskManager.ReportPnL() on
  every position change. Daily loss limit checks were evaluating against
   PnL permanently — safety net now live.

- Fix BasicRiskManager.CheckEmergencyConditions() hardcoded 1000 limit.
  Add dailyLossLimit constructor overload; emergency halt now fires at
  90% of the configured DailyLossLimit, not a hardcoded .

- Add AVWAP slope hard veto in SimpleORBStrategy.OnBar(). Trend=0.00
  (AVWAP working against trade direction) can no longer pass grade
  filter. Pre-scoring veto prevents high gap/volume scores from masking
  a directionally broken setup.

- Raise default MinTradeGrade from 4 (B) to 5 (A) in both
  SimpleORBStrategy and SimpleORBNT8 defaults.

- Scale TargetTicks dynamically in CreateIntent() based on ORB/ATR
  ratio (0.75x-1.75x of base target). Tight ORBs get extended targets;
  wide ORBs get tightened. Hard floor at stopTicks+4.

- Add NarrowRange, OrbRangeVsAtr, GapDirectionAlignment,
  BreakoutVolumeStrength, PriorDayCloseStrength default weights in
  ConfluenceScorer constructor. All 10 factors now registered.

- Fix session_open_price tracking for GapDirectionAlignment factor.
  TodayOpen in DailyBarContext now reflects first RTH bar open, not
  the breakout bar open.

- Log all 10 factor scores at signal acceptance for observability.
This commit is contained in:
2026-03-23 22:43:21 -04:00
parent ae8ac05017
commit 8e87b9af98
4 changed files with 183 additions and 16 deletions

View File

@@ -426,6 +426,46 @@ namespace NinjaTrader.NinjaScript.Strategies
_executionAdapter.ProcessExecution(orderId, executionId, price, quantity, time);
}
protected override void OnPositionUpdate(
Position position,
double averagePrice,
int quantity,
MarketPosition marketPosition)
{
if (!_sdkInitialized || _riskManager == null)
return;
try
{
double dayPnL = 0.0;
if (Account != null)
{
try
{
dayPnL = Account.Get(AccountItem.GainLoss, Currency.UsDollar);
}
catch
{
dayPnL = 0.0;
}
}
_riskManager.OnPnLUpdate(dayPnL, dayPnL);
PortfolioRiskManager.Instance.ReportPnL(Name, dayPnL);
if (EnableVerboseLogging)
Print(string.Format("[SDK] P&L update: DayPnL={0:C} Position={1} Qty={2}",
dayPnL, marketPosition, quantity));
FileLog(string.Format("PNL_UPDATE DayPnL={0:C} Position={1} Qty={2}",
dayPnL, marketPosition, quantity));
}
catch (Exception ex)
{
Print(string.Format("[SDK] OnPositionUpdate error: {0}", ex.Message));
}
}
/// <summary>
/// Handles broker connection status changes. Halts new orders on disconnect,
/// logs reconnect, and resets the connection flag when restored.
@@ -571,7 +611,7 @@ namespace NinjaTrader.NinjaScript.Strategies
_riskConfig,
_sizingConfig);
_riskManager = new BasicRiskManager(_logger);
_riskManager = new BasicRiskManager(_logger, DailyLossLimit);
_positionSizer = new BasicPositionSizer(_logger);
_circuitBreaker = new ExecutionCircuitBreaker(
_logger,
@@ -842,15 +882,25 @@ namespace NinjaTrader.NinjaScript.Strategies
FileLog(string.Format("SIGNAL {0} | Grade={1} | Score={2}", intent.Side, grade, score));
if (!string.IsNullOrEmpty(factors))
FileLog(string.Format(" Factors: {0}", factors));
FileLog(string.Format("SUBMIT {0} {1} @ Market | Stop={2} Target={3}",
FileLog(string.Format("SUBMIT {0} {1} @ Market | Stop={2} Target={3}{4}",
intent.Side,
request.Quantity,
intent.StopTicks,
intent.TargetTicks));
intent.TargetTicks,
intent.Metadata != null && intent.Metadata.ContainsKey("dynamic_target_ticks") ? " [dynamic]" : ""));
}
_executionAdapter.SubmitOrder(request, orderName);
// Register stop and target BEFORE submitting the entry order.
// NT8 requires stop/target to be pre-registered to the signal name
// so they are applied correctly in both backtest and live/SIM modes.
if (intent.StopTicks > 0)
SetStopLoss(orderName, CalculationMode.Ticks, (int)intent.StopTicks, false);
if (intent.TargetTicks.HasValue && intent.TargetTicks.Value > 0)
SetProfitTarget(orderName, CalculationMode.Ticks, (int)intent.TargetTicks.Value);
if (request.Side == OmsOrderSide.Buy)
{
if (request.Type == OmsOrderType.Market)
@@ -870,12 +920,6 @@ namespace NinjaTrader.NinjaScript.Strategies
EnterShortStopMarket(request.Quantity, (double)request.StopPrice.Value, orderName);
}
if (intent.StopTicks > 0)
SetStopLoss(orderName, CalculationMode.Ticks, (int)intent.StopTicks, false);
if (intent.TargetTicks.HasValue && intent.TargetTicks.Value > 0)
SetProfitTarget(orderName, CalculationMode.Ticks, (int)intent.TargetTicks.Value);
if (_circuitBreaker != null)
_circuitBreaker.OnSuccess();
}

View File

@@ -110,6 +110,11 @@ namespace NT8.Core.Intelligence
_factorWeights.Add(FactorType.Volatility, 1.0);
_factorWeights.Add(FactorType.Timing, 1.0);
_factorWeights.Add(FactorType.ExecutionQuality, 1.0);
_factorWeights.Add(FactorType.NarrowRange, 1.0);
_factorWeights.Add(FactorType.OrbRangeVsAtr, 1.0);
_factorWeights.Add(FactorType.GapDirectionAlignment, 1.0);
_factorWeights.Add(FactorType.BreakoutVolumeStrength, 1.0);
_factorWeights.Add(FactorType.PriorDayCloseStrength, 1.0);
}
/// <summary>

View File

@@ -20,6 +20,7 @@ namespace NT8.Core.Risk
private double _dailyPnL;
private double _maxDrawdown;
private bool _tradingHalted;
private double _configuredDailyLossLimit;
private DateTime _lastUpdate = DateTime.UtcNow;
private readonly Dictionary<string, double> _symbolExposure = new Dictionary<string, double>();
@@ -27,6 +28,15 @@ namespace NT8.Core.Risk
{
if (logger == null) throw new ArgumentNullException("logger");
_logger = logger;
_configuredDailyLossLimit = 1000.0;
}
public BasicRiskManager(ILogger logger, double dailyLossLimit)
{
if (logger == null) throw new ArgumentNullException("logger");
if (dailyLossLimit <= 0.0) throw new ArgumentException("dailyLossLimit must be positive", "dailyLossLimit");
_logger = logger;
_configuredDailyLossLimit = dailyLossLimit;
}
public RiskDecision ValidateOrder(StrategyIntent intent, StrategyContext context, RiskConfig config)
@@ -216,10 +226,8 @@ namespace NT8.Core.Risk
private void CheckEmergencyConditions(double dayPnL)
{
// Emergency halt if daily loss exceeds 90% of limit
// Using a default limit of 1000 as this method doesn't have access to config
// In Phase 1, this should be improved to use the actual config value
if (dayPnL <= -(1000 * 0.9) && !_tradingHalted)
// Emergency halt if daily loss exceeds 90% of configured limit
if (dayPnL <= -(_configuredDailyLossLimit * 0.9) && !_tradingHalted)
{
_tradingHalted = true;
_logger.LogCritical("Emergency halt triggered at 90% of daily loss limit: {0:C}", dayPnL);

View File

@@ -169,6 +169,9 @@ namespace NT8.Strategies.Examples
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
@@ -221,10 +224,33 @@ namespace NT8.Strategies.Examples
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 = 4;
int minGradeValue = 5;
if (_config != null && _config.Parameters != null && _config.Parameters.ContainsKey("MinTradeGrade"))
{
var mgv = _config.Parameters["MinTradeGrade"];
@@ -260,6 +286,15 @@ namespace NT8.Strategies.Examples
_tradeTaken = true;
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,
@@ -380,6 +415,13 @@ namespace NT8.Strategies.Examples
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)
@@ -414,7 +456,7 @@ namespace NT8.Strategies.Examples
var stopTicks = _config != null && _config.Parameters.ContainsKey("StopTicks")
? (int)_config.Parameters["StopTicks"]
: 8;
var targetTicks = _config != null && _config.Parameters.ContainsKey("TargetTicks")
int baseTargetTicks = _config != null && _config.Parameters.ContainsKey("TargetTicks")
? (int)_config.Parameters["TargetTicks"]
: 16;
@@ -438,12 +480,71 @@ namespace NT8.Strategies.Examples
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,
@@ -474,7 +575,15 @@ namespace NT8.Strategies.Examples
daily.TradeDirection = intent.Side == OrderSide.Buy ? 1 : -1;
daily.BreakoutBarVolume = (double)bar.Volume;
daily.TodayOpen = bar.Open;
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"))
{
@@ -489,6 +598,7 @@ namespace NT8.Strategies.Examples
daily.AvgIntradayBarVolume = (double)(long)avg;
}
// orb_range_ticks is set in CreateIntent() and preserved here.
intent.Metadata["daily_bars"] = daily;
}
}