Add ORB confluence factors (NR4/NR7, gap alignment, breakout volume, prior close) + session file logger
Some checks failed
Build and Test / build (push) Has been cancelled

This commit is contained in:
2026-03-19 12:16:39 -04:00
parent ee4da1b607
commit 498f298975
11 changed files with 1569 additions and 76 deletions

View File

@@ -43,6 +43,31 @@ namespace NT8.Core.Intelligence
/// </summary>
Risk = 6,
/// <summary>
/// Narrow range contraction quality (NR4/NR7 concepts).
/// </summary>
NarrowRange = 7,
/// <summary>
/// Opening range size relative to average daily ATR/range.
/// </summary>
OrbRangeVsAtr = 8,
/// <summary>
/// Alignment between overnight gap direction and trade direction.
/// </summary>
GapDirectionAlignment = 9,
/// <summary>
/// Breakout bar volume strength relative to intraday average volume.
/// </summary>
BreakoutVolumeStrength = 10,
/// <summary>
/// Prior day close location strength in prior day range.
/// </summary>
PriorDayCloseStrength = 11,
/// <summary>
/// Additional custom factor.
/// </summary>

View File

@@ -1,6 +1,7 @@
using System;
using System.Collections.Generic;
using NT8.Core.Common.Models;
using NT8.Core.Logging;
namespace NT8.Core.Intelligence
{
@@ -398,4 +399,625 @@ namespace NT8.Core.Intelligence
return defaultValue;
}
}
/// <summary>
/// Daily bar data passed to ORB-specific factor calculators.
/// Contains a lookback window of recent daily bars in chronological order,
/// oldest first, with index [Count-1] being the most recent completed day.
/// </summary>
public struct DailyBarContext
{
/// <summary>Daily high prices, oldest first.</summary>
public double[] Highs;
/// <summary>Daily low prices, oldest first.</summary>
public double[] Lows;
/// <summary>Daily close prices, oldest first.</summary>
public double[] Closes;
/// <summary>Daily open prices, oldest first.</summary>
public double[] Opens;
/// <summary>Daily volume values, oldest first.</summary>
public long[] Volumes;
/// <summary>Number of valid bars populated.</summary>
public int Count;
/// <summary>Today's RTH open price.</summary>
public double TodayOpen;
/// <summary>Volume of the breakout bar (current intraday bar).</summary>
public double BreakoutBarVolume;
/// <summary>Average intraday volume per bar for today's session so far.</summary>
public double AvgIntradayBarVolume;
/// <summary>Trade direction: 1 for long, -1 for short.</summary>
public int TradeDirection;
}
/// <summary>
/// Scores the setup based on narrow range day concepts.
/// An NR7 (range is the narrowest of the last 7 days) scores highest,
/// indicating volatility contraction and likely expansion on breakout.
/// Requires at least 7 completed daily bars in DailyBarContext.
/// </summary>
public class NarrowRangeFactorCalculator : IFactorCalculator
{
private readonly ILogger _logger;
/// <summary>
/// Initializes a new instance of the NarrowRangeFactorCalculator class.
/// </summary>
/// <param name="logger">Logger instance.</param>
public NarrowRangeFactorCalculator(ILogger logger)
{
if (logger == null)
throw new ArgumentNullException("logger");
_logger = logger;
}
/// <summary>
/// Gets the factor type identifier.
/// </summary>
public FactorType Type
{
get { return FactorType.NarrowRange; }
}
/// <summary>
/// Calculates narrow range score. Expects DailyBarContext in
/// intent.Metadata["daily_bars"]. Returns 0.3 if context is missing.
/// </summary>
/// <param name="intent">Current strategy intent.</param>
/// <param name="context">Current strategy context.</param>
/// <param name="bar">Current bar data.</param>
/// <returns>Calculated confluence factor.</returns>
public ConfluenceFactor Calculate(StrategyIntent intent, StrategyContext context, BarData bar)
{
double score = 0.3;
string reason = "No daily bar context available";
if (intent != null && intent.Metadata != null && intent.Metadata.ContainsKey("daily_bars"))
{
DailyBarContext daily = (DailyBarContext)intent.Metadata["daily_bars"];
if (daily.Count >= 7 && daily.Highs != null && daily.Lows != null)
{
double todayRange = daily.Highs[daily.Count - 1] - daily.Lows[daily.Count - 1];
bool isNR4 = true;
int start4 = daily.Count - 4;
int end = daily.Count - 2;
for (int i = start4; i <= end; i++)
{
double r = daily.Highs[i] - daily.Lows[i];
if (todayRange >= r)
{
isNR4 = false;
break;
}
}
bool isNR7 = true;
int start7 = daily.Count - 7;
for (int i = start7; i <= end; i++)
{
double r = daily.Highs[i] - daily.Lows[i];
if (todayRange >= r)
{
isNR7 = false;
break;
}
}
if (isNR7)
{
score = 1.0;
reason = "NR7: Narrowest range in 7 days — strong volatility contraction";
}
else if (isNR4)
{
score = 0.75;
reason = "NR4: Narrowest range in 4 days — moderate volatility contraction";
}
else
{
double sumRanges = 0.0;
int lookback = Math.Min(7, daily.Count - 1);
int start = daily.Count - 1 - lookback;
int finish = daily.Count - 2;
for (int i = start; i <= finish; i++)
sumRanges += daily.Highs[i] - daily.Lows[i];
double avgRange = lookback > 0 ? sumRanges / lookback : todayRange;
double ratio = avgRange > 0.0 ? todayRange / avgRange : 1.0;
if (ratio <= 0.7)
{
score = 0.6;
reason = "Range below 70% of avg — mild contraction";
}
else if (ratio <= 0.9)
{
score = 0.45;
reason = "Range near avg — no significant contraction";
}
else
{
score = 0.2;
reason = "Range above avg — expansion day, low NR score";
}
}
}
else
{
reason = String.Format("Insufficient daily bars: {0} of 7 required", daily.Count);
}
}
return new ConfluenceFactor(
FactorType.NarrowRange,
"Narrow Range (NR4/NR7)",
score,
0.20,
reason,
new Dictionary<string, object>());
}
}
/// <summary>
/// Scores the ORB range relative to average daily range.
/// Prevents trading when the ORB has already consumed most of the
/// day's expected range, leaving little room for continuation.
/// </summary>
public class OrbRangeVsAtrFactorCalculator : IFactorCalculator
{
private readonly ILogger _logger;
/// <summary>
/// Initializes a new instance of the OrbRangeVsAtrFactorCalculator class.
/// </summary>
/// <param name="logger">Logger instance.</param>
public OrbRangeVsAtrFactorCalculator(ILogger logger)
{
if (logger == null)
throw new ArgumentNullException("logger");
_logger = logger;
}
/// <summary>
/// Gets the factor type identifier.
/// </summary>
public FactorType Type
{
get { return FactorType.OrbRangeVsAtr; }
}
/// <summary>
/// Calculates ORB range vs ATR score. Expects DailyBarContext in
/// intent.Metadata["daily_bars"] and double in intent.Metadata["orb_range_ticks"].
/// </summary>
/// <param name="intent">Current strategy intent.</param>
/// <param name="context">Current strategy context.</param>
/// <param name="bar">Current bar data.</param>
/// <returns>Calculated confluence factor.</returns>
public ConfluenceFactor Calculate(StrategyIntent intent, StrategyContext context, BarData bar)
{
double score = 0.5;
string reason = "No daily bar context available";
if (intent != null && intent.Metadata != null &&
intent.Metadata.ContainsKey("daily_bars") &&
intent.Metadata.ContainsKey("orb_range_ticks"))
{
DailyBarContext daily = (DailyBarContext)intent.Metadata["daily_bars"];
double orbRangeTicks = ToDouble(intent.Metadata["orb_range_ticks"], 0.0);
if (daily.Count >= 5 && daily.Highs != null && daily.Lows != null)
{
double sumAtr = 0.0;
int lookback = Math.Min(10, daily.Count - 1);
int start = daily.Count - 1 - lookback;
int end = daily.Count - 2;
for (int i = start; i <= end; i++)
sumAtr += daily.Highs[i] - daily.Lows[i];
double avgDailyRange = lookback > 0 ? sumAtr / lookback : 0.0;
double orbRangePoints = orbRangeTicks / 4.0;
double ratio = avgDailyRange > 0.0 ? orbRangePoints / avgDailyRange : 0.5;
if (ratio <= 0.20)
{
score = 1.0;
reason = String.Format("ORB is {0:P0} of daily ATR — tight range, high expansion potential", ratio);
}
else if (ratio <= 0.35)
{
score = 0.80;
reason = String.Format("ORB is {0:P0} of daily ATR — good room to run", ratio);
}
else if (ratio <= 0.50)
{
score = 0.60;
reason = String.Format("ORB is {0:P0} of daily ATR — moderate room remaining", ratio);
}
else if (ratio <= 0.70)
{
score = 0.35;
reason = String.Format("ORB is {0:P0} of daily ATR — limited room, caution", ratio);
}
else
{
score = 0.10;
reason = String.Format("ORB is {0:P0} of daily ATR — range nearly exhausted", ratio);
}
}
}
return new ConfluenceFactor(
FactorType.OrbRangeVsAtr,
"ORB Range vs ATR",
score,
0.15,
reason,
new Dictionary<string, object>());
}
private static double ToDouble(object value, double defaultValue)
{
if (value == null)
return defaultValue;
if (value is double)
return (double)value;
if (value is float)
return (double)(float)value;
if (value is int)
return (double)(int)value;
if (value is long)
return (double)(long)value;
return defaultValue;
}
}
/// <summary>
/// Scores alignment between today's overnight gap direction and the
/// trade direction. A gap-and-go setup (gap up + long trade) scores
/// highest. A gap fade setup penalizes the score.
/// </summary>
public class GapDirectionAlignmentCalculator : IFactorCalculator
{
private readonly ILogger _logger;
/// <summary>
/// Initializes a new instance of the GapDirectionAlignmentCalculator class.
/// </summary>
/// <param name="logger">Logger instance.</param>
public GapDirectionAlignmentCalculator(ILogger logger)
{
if (logger == null)
throw new ArgumentNullException("logger");
_logger = logger;
}
/// <summary>
/// Gets the factor type identifier.
/// </summary>
public FactorType Type
{
get { return FactorType.GapDirectionAlignment; }
}
/// <summary>
/// Calculates gap alignment score. Expects DailyBarContext in
/// intent.Metadata["daily_bars"] with TodayOpen and TradeDirection populated.
/// </summary>
/// <param name="intent">Current strategy intent.</param>
/// <param name="context">Current strategy context.</param>
/// <param name="bar">Current bar data.</param>
/// <returns>Calculated confluence factor.</returns>
public ConfluenceFactor Calculate(StrategyIntent intent, StrategyContext context, BarData bar)
{
double score = 0.5;
string reason = "No daily bar context available";
if (intent != null && intent.Metadata != null && intent.Metadata.ContainsKey("daily_bars"))
{
DailyBarContext daily = (DailyBarContext)intent.Metadata["daily_bars"];
if (daily.Count >= 2 && daily.Closes != null)
{
double prevClose = daily.Closes[daily.Count - 2];
double todayOpen = daily.TodayOpen;
double gapPoints = todayOpen - prevClose;
int gapDirection = gapPoints > 0.25 ? 1 : (gapPoints < -0.25 ? -1 : 0);
int tradeDir = daily.TradeDirection;
if (gapDirection == 0)
{
score = 0.55;
reason = "Flat open — no gap bias, neutral score";
}
else if (gapDirection == tradeDir)
{
double gapSize = Math.Abs(gapPoints);
if (gapSize >= 5.0)
{
score = 1.0;
reason = String.Format("Large gap {0:+0.00;-0.00} aligns with trade — strong gap-and-go", gapPoints);
}
else if (gapSize >= 2.0)
{
score = 0.85;
reason = String.Format("Moderate gap {0:+0.00;-0.00} aligns with trade", gapPoints);
}
else
{
score = 0.65;
reason = String.Format("Small gap {0:+0.00;-0.00} aligns with trade", gapPoints);
}
}
else
{
double gapSize = Math.Abs(gapPoints);
if (gapSize >= 5.0)
{
score = 0.10;
reason = String.Format("Large gap {0:+0.00;-0.00} opposes trade — high fade risk", gapPoints);
}
else if (gapSize >= 2.0)
{
score = 0.25;
reason = String.Format("Moderate gap {0:+0.00;-0.00} opposes trade", gapPoints);
}
else
{
score = 0.40;
reason = String.Format("Small gap {0:+0.00;-0.00} opposes trade — minor headwind", gapPoints);
}
}
}
}
return new ConfluenceFactor(
FactorType.GapDirectionAlignment,
"Gap Direction Alignment",
score,
0.15,
reason,
new Dictionary<string, object>());
}
}
/// <summary>
/// Scores the volume of the breakout bar relative to the average
/// volume of bars seen so far in today's session.
/// A volume surge on the breakout bar strongly confirms the move.
/// </summary>
public class BreakoutVolumeStrengthCalculator : IFactorCalculator
{
private readonly ILogger _logger;
/// <summary>
/// Initializes a new instance of the BreakoutVolumeStrengthCalculator class.
/// </summary>
/// <param name="logger">Logger instance.</param>
public BreakoutVolumeStrengthCalculator(ILogger logger)
{
if (logger == null)
throw new ArgumentNullException("logger");
_logger = logger;
}
/// <summary>
/// Gets the factor type identifier.
/// </summary>
public FactorType Type
{
get { return FactorType.BreakoutVolumeStrength; }
}
/// <summary>
/// Calculates breakout volume score. Expects DailyBarContext in
/// intent.Metadata["daily_bars"] with BreakoutBarVolume and
/// AvgIntradayBarVolume populated.
/// </summary>
/// <param name="intent">Current strategy intent.</param>
/// <param name="context">Current strategy context.</param>
/// <param name="bar">Current bar data.</param>
/// <returns>Calculated confluence factor.</returns>
public ConfluenceFactor Calculate(StrategyIntent intent, StrategyContext context, BarData bar)
{
double score = 0.4;
string reason = "No daily bar context available";
if (intent != null && intent.Metadata != null && intent.Metadata.ContainsKey("daily_bars"))
{
DailyBarContext daily = (DailyBarContext)intent.Metadata["daily_bars"];
double breakoutVol = daily.BreakoutBarVolume;
double avgVol = daily.AvgIntradayBarVolume;
if (avgVol > 0.0)
{
double ratio = breakoutVol / avgVol;
if (ratio >= 3.0)
{
score = 1.0;
reason = String.Format("Breakout volume {0:F1}x avg — exceptional surge", ratio);
}
else if (ratio >= 2.0)
{
score = 0.85;
reason = String.Format("Breakout volume {0:F1}x avg — strong confirmation", ratio);
}
else if (ratio >= 1.5)
{
score = 0.70;
reason = String.Format("Breakout volume {0:F1}x avg — solid confirmation", ratio);
}
else if (ratio >= 1.0)
{
score = 0.50;
reason = String.Format("Breakout volume {0:F1}x avg — average, neutral", ratio);
}
else if (ratio >= 0.7)
{
score = 0.25;
reason = String.Format("Breakout volume {0:F1}x avg — below avg, low conviction", ratio);
}
else
{
score = 0.10;
reason = String.Format("Breakout volume {0:F1}x avg — weak breakout, high false-break risk", ratio);
}
}
else
{
reason = "Avg intraday volume not available";
}
}
return new ConfluenceFactor(
FactorType.BreakoutVolumeStrength,
"Breakout Volume Strength",
score,
0.20,
reason,
new Dictionary<string, object>());
}
}
/// <summary>
/// Scores where the prior day closed within its own range.
/// A strong prior close (top 25% for longs, bottom 25% for shorts)
/// indicates momentum continuation into today's session.
/// </summary>
public class PriorDayCloseStrengthCalculator : IFactorCalculator
{
private readonly ILogger _logger;
/// <summary>
/// Initializes a new instance of the PriorDayCloseStrengthCalculator class.
/// </summary>
/// <param name="logger">Logger instance.</param>
public PriorDayCloseStrengthCalculator(ILogger logger)
{
if (logger == null)
throw new ArgumentNullException("logger");
_logger = logger;
}
/// <summary>
/// Gets the factor type identifier.
/// </summary>
public FactorType Type
{
get { return FactorType.PriorDayCloseStrength; }
}
/// <summary>
/// Calculates prior close strength score. Expects DailyBarContext in
/// intent.Metadata["daily_bars"] with at least 2 completed bars and
/// TradeDirection populated.
/// </summary>
/// <param name="intent">Current strategy intent.</param>
/// <param name="context">Current strategy context.</param>
/// <param name="bar">Current bar data.</param>
/// <returns>Calculated confluence factor.</returns>
public ConfluenceFactor Calculate(StrategyIntent intent, StrategyContext context, BarData bar)
{
double score = 0.5;
string reason = "No daily bar context available";
if (intent != null && intent.Metadata != null && intent.Metadata.ContainsKey("daily_bars"))
{
DailyBarContext daily = (DailyBarContext)intent.Metadata["daily_bars"];
if (daily.Count >= 2 && daily.Highs != null && daily.Lows != null && daily.Closes != null)
{
int prev = daily.Count - 2;
double prevHigh = daily.Highs[prev];
double prevLow = daily.Lows[prev];
double prevClose = daily.Closes[prev];
double prevRange = prevHigh - prevLow;
int tradeDir = daily.TradeDirection;
if (prevRange > 0.0)
{
double closePosition = (prevClose - prevLow) / prevRange;
if (tradeDir == 1)
{
if (closePosition >= 0.75)
{
score = 1.0;
reason = String.Format("Prior close in top {0:P0} — strong bullish close", 1.0 - closePosition);
}
else if (closePosition >= 0.50)
{
score = 0.70;
reason = "Prior close in upper half — moderate bullish bias";
}
else if (closePosition >= 0.25)
{
score = 0.40;
reason = "Prior close in lower half — weak prior close for long";
}
else
{
score = 0.15;
reason = "Prior close near low — bearish close, headwind for long";
}
}
else
{
if (closePosition <= 0.25)
{
score = 1.0;
reason = String.Format("Prior close in bottom {0:P0} — strong bearish close", closePosition);
}
else if (closePosition <= 0.50)
{
score = 0.70;
reason = "Prior close in lower half — moderate bearish bias";
}
else if (closePosition <= 0.75)
{
score = 0.40;
reason = "Prior close in upper half — weak prior close for short";
}
else
{
score = 0.15;
reason = "Prior close near high — bullish close, headwind for short";
}
}
}
else
{
reason = "Prior day range is zero — cannot score";
}
}
}
return new ConfluenceFactor(
FactorType.PriorDayCloseStrength,
"Prior Day Close Strength",
score,
0.15,
reason,
new Dictionary<string, object>());
}
}
}