Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
13 changes: 12 additions & 1 deletion VisualHFT.Commons/Helpers/HelperOrderBook.cs
Original file line number Diff line number Diff line change
Expand Up @@ -61,12 +61,23 @@ private void DispatchToSubscribers(OrderBook book)
}
catch (Exception ex)
{
// This runs on the market connector's producer thread, at a call site that
// does not guard itself. An unhandled exception on a non-UI thread
// terminates the process, and an escaping exception would also unwind this
// loop, so one faulting subscriber would starve every subscriber after it
// of order-book data.
//
// A study plugin throwing is a normal operating condition on a hot path, not
// grounds for killing the host. Each subscriber is isolated and dispatch
// continues to the next. Isolating must not mean hiding a data outage, so the
// fault is logged and published on OnException, carrying the subscriber that
// raised it so a listener can tell whose fault it was.
Task.Run(() =>
{
log.Error(ex);
OnException?.Invoke(new VisualHFT.Commons.Model.ErrorEventArgs(ex, subscriber.Target));
});
Comment on lines 75 to 79
throw;
// deliberately NO rethrow — continue to the next subscriber.
}
}
}
Expand Down
40 changes: 39 additions & 1 deletion VisualHFT.Commons/Helpers/HelperTrade.cs
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,8 @@ public class HelperTrade
private static readonly HelperTrade instance = new HelperTrade();
public static HelperTrade Instance => instance;

public event Action<VisualHFT.Commons.Model.ErrorEventArgs> OnException;


public void Subscribe(Action<Trade> processor)
{
Expand Down Expand Up @@ -38,14 +40,50 @@ public void Unsubscribe(Action<Trade> processor)
}
}

public void Reset()
{
_lockObj.EnterWriteLock();
try
{
_subscribers.Clear();
}
finally
{
_lockObj.ExitWriteLock();
}
}

private void DispatchToSubscribers(Trade trade)
{
_lockObj.EnterReadLock();
try
{
foreach (var subscriber in _subscribers)
{
subscriber(trade);
try
{
subscriber(trade);
}
catch (Exception ex)
{
// This runs on the market connector's producer thread, at a call site that
// does not guard itself. An unhandled exception on a non-UI thread
// terminates the process, and an escaping exception would also unwind this
// loop, so one faulting subscriber would starve every subscriber after it
// of trade data.
//
// A subscriber throwing is a normal operating condition on a hot path, not
// grounds for killing the host. Each one is isolated and dispatch continues
// to the next. Isolating must not mean hiding a data outage, so the fault is
// logged and published on OnException, carrying the subscriber that raised
// it so a listener can tell whose fault it was.
Task.Run(() =>
{
log.Error(ex);
OnException?.Invoke(new VisualHFT.Commons.Model.ErrorEventArgs(ex, subscriber.Target));
});
Comment on lines +80 to +84
// deliberately NO rethrow - continue to the next subscriber.
}
}
}
finally
Expand Down
10 changes: 7 additions & 3 deletions VisualHFT.Commons/PluginManager/BasePluginStudy.cs
Original file line number Diff line number Diff line change
Expand Up @@ -84,7 +84,10 @@ public BasePluginStudy()
throw new InvalidOperationException($"{Name} plugin settings has not been loaded.");
HelperProvider.Instance.OnStatusChanged += Provider_OnStatusChanged;
HelperProvider.Instance.OnProviderStale += Provider_OnProviderStale; //when no data for 30 seconds is received.
HelperOrderBook.Instance.OnException += HelperOrderBookInstance_OnException;//subscribe and hear for exceptions on this Plugin
// Both market-data streams report a faulting subscriber the same way, and a fault on
// either one is fatal to this plugin, so both are heard here.
HelperOrderBook.Instance.OnException += MarketDataHelper_OnException;
HelperTrade.Instance.OnException += MarketDataHelper_OnException;
Status = ePluginStatus.LOADED;
}

Expand Down Expand Up @@ -229,7 +232,7 @@ private void HandleMaxReconnectionAttempts()
HelperNotificationManager.Instance.AddNotification(this.Name, msg, HelprNorificationManagerTypes.ERROR, HelprNorificationManagerCategories.PLUGINS);
}

private void HelperOrderBookInstance_OnException(Model.ErrorEventArgs obj)
private void MarketDataHelper_OnException(Model.ErrorEventArgs obj)
{
if (obj.Context is BasePluginStudy study && study == this)
{
Expand Down Expand Up @@ -375,7 +378,8 @@ protected virtual void Dispose(bool disposing)
_disposed = true;
HelperProvider.Instance.OnStatusChanged -= Provider_OnStatusChanged;
HelperProvider.Instance.OnProviderStale -= Provider_OnProviderStale;
HelperOrderBook.Instance.OnException -= HelperOrderBookInstance_OnException; ; //subscribe and hear for exceptions on this Plugin
HelperOrderBook.Instance.OnException -= MarketDataHelper_OnException;
HelperTrade.Instance.OnException -= MarketDataHelper_OnException;

_QUEUE?.Dispose();
_AGG_DATA?.Dispose();
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -366,19 +366,30 @@ private void TriggerMRCalculation()
// COMPONENT 0: TRADE SHOCK SEVERITY (30% weight)
// ───────────────────────────────────────────────────────────────
const double W_TRADE = 0.3;

// Dispersion must be measurable RELATIVE to the mean for a z-score to mean anything.
// The factor is dimensionless on purpose: it carries no unit, no tick size and no lot
// size, so it reads the same for a fraction of a coin and for a hundred shares.
const decimal REL_EPS = 1e-6m;

if (ShockTrade != null && recentTradeSizes.Any())
{
decimal avgSize = recentTradeSizes.Average();
decimal stdSize = recentTradeSizes.StandardDeviation();

if (stdSize > 0)
// No scale to measure against, or a dispersion too small relative to that scale, and
// the z-score carries no information about the shock print. Leave the component out
// of the weighting entirely rather than publish a fabricated value at 30% weight.
if (avgSize > 0 && stdSize >= REL_EPS * avgSize)
{
// Z-score of trade size (how many std devs above mean)
double tradeZ = (double)((ShockTrade.Value - avgSize) / stdSize);

// Convert to resilience score (0..1)
// z=3 → score=0.5, z=6 → score=0
double tradeScore = Math.Max(0, 1.0 - (tradeZ / 6.0));
// Clamped at BOTH ends, like every other component: a negative z-score would
// otherwise push this above 1 and carry the published score out of range.
double tradeScore = Math.Clamp(1.0 - (tradeZ / 6.0), 0.0, 1.0);

weightedScore += W_TRADE * tradeScore;
totalWeight += W_TRADE;
Expand All @@ -397,14 +408,24 @@ private void TriggerMRCalculation()
? spreadRecoveryTimes.Average()
: spreadRecoveryDurationMs;

double spreadRecoveryScore = avgSpreadHistoricalRecoveryMs /
(avgSpreadHistoricalRecoveryMs + spreadRecoveryDurationMs);
spreadRecoveryScore = Math.Max(0, Math.Min(1, spreadRecoveryScore));
// A zero denominator means an instantaneous recovery with nothing to compare it
// to. That is an absence of evidence, not a perfect recovery and not a failed one,
// so the component is omitted and the normalisation below reweights what remains.
double spreadRecoveryDenominatorMs = avgSpreadHistoricalRecoveryMs + spreadRecoveryDurationMs;
if (spreadRecoveryDenominatorMs > 0.0)
{
double spreadRecoveryScore = Math.Clamp(
avgSpreadHistoricalRecoveryMs / spreadRecoveryDenominatorMs, 0.0, 1.0);

weightedScore += W_SPREAD * spreadRecoveryScore;
totalWeight += W_SPREAD;
weightedScore += W_SPREAD * spreadRecoveryScore;
totalWeight += W_SPREAD;
}

spreadRecoveryTimes.Add(spreadRecoveryDurationMs); // ✅ Only add real data
// Only a measured recovery joins the history. A zero sample would pull the
// historical baseline down, and that baseline is the numerator above, so every
// later genuine recovery would score lower for the rest of the session.
if (spreadRecoveryDurationMs > 0.0)
spreadRecoveryTimes.Add(spreadRecoveryDurationMs);
}

// ───────────────────────────────────────────────────────────────
Expand All @@ -419,14 +440,20 @@ private void TriggerMRCalculation()
? depletionRecoveryTimes.Average()
: depletionRecoveryDurationMs;

double depletionRecoveryScore = avgDepletionHistoricalRecoveryMs /
(avgDepletionHistoricalRecoveryMs + depletionRecoveryDurationMs);
depletionRecoveryScore = Math.Max(0, Math.Min(1, depletionRecoveryScore));
// Same rule as the spread component above: a zero denominator is no evidence, so
// the component is omitted rather than scored with an invented value at 50% weight.
double depletionRecoveryDenominatorMs = avgDepletionHistoricalRecoveryMs + depletionRecoveryDurationMs;
if (depletionRecoveryDenominatorMs > 0.0)
{
double depletionRecoveryScore = Math.Clamp(
avgDepletionHistoricalRecoveryMs / depletionRecoveryDenominatorMs, 0.0, 1.0);

weightedScore += W_DEPTH * depletionRecoveryScore;
totalWeight += W_DEPTH;
weightedScore += W_DEPTH * depletionRecoveryScore;
totalWeight += W_DEPTH;
}

depletionRecoveryTimes.Add(depletionRecoveryDurationMs); // ✅ Only add real data
if (depletionRecoveryDurationMs > 0.0)
depletionRecoveryTimes.Add(depletionRecoveryDurationMs);
}

// ───────────────────────────────────────────────────────────────
Expand All @@ -451,17 +478,23 @@ private void TriggerMRCalculation()
// ───────────────────────────────────────────────────────────────
// FINAL SCORE NORMALIZATION
// ───────────────────────────────────────────────────────────────
// ✅ KEY CHANGE: Normalize by actual total weight
// This ensures score is always in [0, 1] regardless of missing components
// The published score is the weighted average over the components that actually had
// usable evidence, so an omitted component reweights the rest instead of skewing the
// result. The clamp bounds the value to [0, 1]; the finiteness check is what keeps the
// cast safe, because a non-finite quotient survives a clamp untouched and then throws
// on conversion to decimal.
//
// A cycle that produced no usable evidence at all publishes NOTHING: the last score
// stands until something is actually measured. Substituting a stand-in would state
// something the data does not support, and the only stand-in available here is the top
// of the scale - the worst possible reading to emit during a depletion, which is one of
// the ways a cycle ends up with no evidence in the first place.

if (totalWeight > 0)
{
CurrentMRScore = (decimal)(weightedScore / totalWeight);
}
else
{
// Fallback: no evidence = baseline resilience
CurrentMRScore = 1.0m;
double normalizedScore = weightedScore / totalWeight;
if (double.IsFinite(normalizedScore))
CurrentMRScore = (decimal)Math.Clamp(normalizedScore, 0.0, 1.0);
}

// ───────────────────────────────────────────────────────────────
Expand Down
Loading