diff --git a/VisualHFT.Commons/Helpers/HelperOrderBook.cs b/VisualHFT.Commons/Helpers/HelperOrderBook.cs index 726a30ce..d9374df6 100644 --- a/VisualHFT.Commons/Helpers/HelperOrderBook.cs +++ b/VisualHFT.Commons/Helpers/HelperOrderBook.cs @@ -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)); }); - throw; + // deliberately NO rethrow — continue to the next subscriber. } } } diff --git a/VisualHFT.Commons/Helpers/HelperTrade.cs b/VisualHFT.Commons/Helpers/HelperTrade.cs index 569677b9..c023bb72 100644 --- a/VisualHFT.Commons/Helpers/HelperTrade.cs +++ b/VisualHFT.Commons/Helpers/HelperTrade.cs @@ -11,6 +11,8 @@ public class HelperTrade private static readonly HelperTrade instance = new HelperTrade(); public static HelperTrade Instance => instance; + public event Action OnException; + public void Subscribe(Action processor) { @@ -38,6 +40,19 @@ public void Unsubscribe(Action processor) } } + public void Reset() + { + _lockObj.EnterWriteLock(); + try + { + _subscribers.Clear(); + } + finally + { + _lockObj.ExitWriteLock(); + } + } + private void DispatchToSubscribers(Trade trade) { _lockObj.EnterReadLock(); @@ -45,7 +60,30 @@ private void DispatchToSubscribers(Trade trade) { 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)); + }); + // deliberately NO rethrow - continue to the next subscriber. + } } } finally diff --git a/VisualHFT.Commons/PluginManager/BasePluginStudy.cs b/VisualHFT.Commons/PluginManager/BasePluginStudy.cs index c7cbbdc1..5ab2968d 100644 --- a/VisualHFT.Commons/PluginManager/BasePluginStudy.cs +++ b/VisualHFT.Commons/PluginManager/BasePluginStudy.cs @@ -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; } @@ -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) { @@ -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(); diff --git a/VisualHFT.Plugins/Studies.MarketResilience/Model/MarketResilienceCalculator.cs b/VisualHFT.Plugins/Studies.MarketResilience/Model/MarketResilienceCalculator.cs index e5583411..03ecf38c 100644 --- a/VisualHFT.Plugins/Studies.MarketResilience/Model/MarketResilienceCalculator.cs +++ b/VisualHFT.Plugins/Studies.MarketResilience/Model/MarketResilienceCalculator.cs @@ -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; @@ -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); } // ─────────────────────────────────────────────────────────────── @@ -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); } // ─────────────────────────────────────────────────────────────── @@ -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); } // ─────────────────────────────────────────────────────────────── diff --git a/tests/Unit/Studies.MarketResilience.Test/MarketResilienceCalculatorNumericalStabilityTests.cs b/tests/Unit/Studies.MarketResilience.Test/MarketResilienceCalculatorNumericalStabilityTests.cs new file mode 100644 index 00000000..f2cb7604 --- /dev/null +++ b/tests/Unit/Studies.MarketResilience.Test/MarketResilienceCalculatorNumericalStabilityTests.cs @@ -0,0 +1,622 @@ +using System; +using System.Linq; +using System.Threading; +using Studies.MarketResilience.Model; +using VisualHFT; +using VisualHFT.Commons.Model; +using VisualHFT.Model; +using VisualHFT.Studies.MarketResilience.Model; +using Xunit; + +namespace Studies.MarketResilience.Tests +{ + /// + /// Numerical-stability contract for . + /// + /// The calculator publishes CurrentMRScore as a resilience score that is documented and + /// consumed as a value in [0,1]. Two properties must hold for EVERY input sequence: + /// 1. no public entry point (OnTrade, OnOrderBookUpdate) may throw; + /// 2. CurrentMRScore must never leave [0,1]. + /// + /// Three arithmetic hazards sit behind those properties, and each has its own facts here: + /// - the shock trade is anchored when it is flagged as large, but its z-score is recomputed + /// later against whatever the rolling window holds at trigger time. The anchored size can + /// by then be far BELOW the window mean, making the z-score large and negative, so the + /// trade score has to be clamped at BOTH ends, not only at zero; + /// - a trade window with dispersion vanishingly small relative to its mean gives a z-score + /// that carries no information, so the component must be omitted rather than scored; + /// - the spread- and depth-recovery scores are avgHistory / (avgHistory + duration), + /// which is 0/0 = NaN when a shock and its recovery land on the same clock reading. NaN + /// survives a clamp untouched, and casting it to decimal throws, so the component is + /// omitted when the denominator is zero and the final cast is guarded by a finiteness + /// check. + /// + /// The last group of facts are hand-computed worked examples: a normal shock and recovery, a + /// near-constant trade window, a zero-duration recovery against an empty history, and a cycle + /// in which every component is omitted (which must leave the previously published score alone + /// rather than invent one). + /// + public class MarketResilienceCalculatorNumericalStabilityTests + { + private static PlugInSettings Settings(int timeoutMs) => new PlugInSettings { MaxShockMsTimeout = timeoutMs }; + + /// Single-level book; the spread is simply ask - bid. + private static OrderBookSnapshot Book(decimal bidPrice, decimal askPrice, double size = 100d) + { + var ob = new OrderBook(); + ob.LoadData( + new[] { new BookItem { Price = (double)askPrice, Size = size, IsBid = false, LocalTimeStamp = DateTime.Now, ServerTimeStamp = DateTime.Now } }, + new[] { new BookItem { Price = (double)bidPrice, Size = size, IsBid = true, LocalTimeStamp = DateTime.Now, ServerTimeStamp = DateTime.Now } }); + var snapshot = new OrderBookSnapshot(); + snapshot.UpdateFrom(ob); + return snapshot; + } + + /// Multi-level book, best-first on each side. + private static OrderBookSnapshot MultiLevelBook((decimal px, double sz)[] asks, (decimal px, double sz)[] bids) + { + var ob = new OrderBook(); + ob.LoadData( + asks.Select(a => new BookItem { Price = (double)a.px, Size = a.sz, IsBid = false, LocalTimeStamp = DateTime.Now, ServerTimeStamp = DateTime.Now }).ToArray(), + bids.Select(b => new BookItem { Price = (double)b.px, Size = b.sz, IsBid = true, LocalTimeStamp = DateTime.Now, ServerTimeStamp = DateTime.Now }).ToArray()); + var snapshot = new OrderBookSnapshot(); + snapshot.UpdateFrom(ob); + return snapshot; + } + + /// + /// Long warm-up with per-level micro-noise: enough frames to train the depth detector's + /// median/deviation baselines, plus the trade baseline. Mirrors the warm-up the existing + /// depth tests use. + /// + private static void WarmUpWithDepth(MarketResilienceCalculator calc, int frames = 300) + { + var random = new Random(42); + double Size() => Math.Max(95, 100d * (1.0 + ((random.NextDouble() - 0.5) * 0.05))); + decimal Px(decimal basePx) => basePx + ((decimal)(random.NextDouble() - 0.5) * 0.01m); + + for (int i = 0; i < frames; i++) + { + calc.OnOrderBookUpdate(MultiLevelBook( + asks: new[] { (Px(100.50m), Size()), (Px(100.51m), Size()), (Px(100.52m), Size()) }, + bids: new[] { (Px(100.49m), Size()), (Px(100.48m), Size()), (Px(100.47m), Size()) })); + calc.OnTrade(new Trade { Size = 100m * ((i % 3) + 1), Price = 100.49m, Timestamp = DateTime.Now }); + } + } + + /// + /// Feeds a quiet book (spread fixed at 0.5) plus trades cycling 100/200/300 shares, so the + /// trade baseline has a known mean (200) and a healthy, non-degenerate spread of sizes. + /// Deliberately short: the depth detector needs far more samples to warm up, so no depth + /// component participates and the weighting stays trade + spread-recovery + magnitude. + /// + private static decimal[] WarmUp(MarketResilienceCalculator calc, int frames = 30) + { + var sizes = new decimal[frames]; + for (int i = 0; i < frames; i++) + { + calc.OnOrderBookUpdate(Book(500m, 500.5m)); + sizes[i] = 100m * ((i % 3) + 1); + calc.OnTrade(new Trade { Size = sizes[i], Price = 500.25m, Timestamp = DateTime.Now }); + } + return sizes; + } + + // (a) The published score leaves [0,1] because the anchored shock trade is scored against a + // window that has moved on underneath it. + // + // Mechanism, step by step: + // 1. the baseline window holds small prints (100-300 shares), so a 5,000-share print is + // flagged as a shock and anchored; + // 2. a burst of block prints (40,000 / 40,001 shares) then fills the whole 500-item + // window. Those two sizes differ by one share, so the true standard deviation is 0.5; + // 3. when the spread shock recovers, the trade component is computed with the CURRENT + // window: z = (5,000 - 40,000.5) / 0.5 = -70,001; + // 4. tradeScore = 1 - z/6 = 11,668 — unclamped above — so the published score is ~7,000. + // The smaller the residual standard deviation, the larger the published score; the variant + // that throws outright is covered by the next test. + [Fact] + public void OnTrade_WhenTheWindowMeanOvertakesTheAnchoredShockTrade_KeepsScoreWithinZeroAndOne() + { + using var calc = new MarketResilienceCalculator(Settings(5000)); + WarmUp(calc); + + // 1. anchor the shock on a print that is large RELATIVE TO THE BASELINE. + calc.OnTrade(new Trade { Size = 5000m, Price = 500.25m, Timestamp = DateTime.Now }); + + // 2. the tape turns to near-identical block prints; the window mean overtakes the anchor. + for (int i = 0; i < 500; i++) + { + calc.OnTrade(new Trade { Size = i % 2 == 0 ? 40000m : 40001m, Price = 500.25m, Timestamp = DateTime.Now }); + } + + // 3. spread shock, then a recovery a measurable number of milliseconds later (so the + // recovery duration is strictly positive and this test isolates the z-score defect). + var duringShock = Record.Exception(() => calc.OnOrderBookUpdate(Book(495m, 500m))); + Thread.Sleep(30); + var duringRecovery = Record.Exception(() => calc.OnOrderBookUpdate(Book(500m, 500.5m))); + + // 4. the next ordinary print must also be safe: when the calculation throws, the + // calculator never clears its shock state, so every later trade re-enters it. + var duringNextTrade = Record.Exception(() => calc.OnTrade(new Trade { Size = 100m, Price = 500.25m, Timestamp = DateTime.Now })); + + Assert.Null(duringShock); + Assert.Null(duringRecovery); + Assert.Null(duringNextTrade); + Assert.InRange(calc.CurrentMRScore, 0m, 1m); + } + + // (a, throwing variant) A shock and its recovery observed on the SAME clock reading make the + // recovery duration 0. On the first calculation there is no recovery history yet, so the + // historical average is that same 0, giving 0/(0+0) = NaN. NaN survives the Min/Max clamp, + // reaches the final cast to decimal, and throws OverflowException — the exception text and + // the conversion frame reported from the field. + // + // The clock used for these durations is the shared time provider, which runs on wall time + // for a live feed but is DRIVEN BY THE DATA when a recorded session is replayed: it is set + // once per message and simply does not advance between two updates that carry the same + // source timestamp. That is the condition reproduced here. The wall clock cannot reproduce + // it because it has sub-microsecond resolution. + // + // Because the throw happens BEFORE the calculator clears its shock state, the state is + // never cleared, so the very next trade re-enters the same calculation and throws again — + // one bad recovery turns into a repeating fault on the market-data thread. + [Fact] + public void ShockAndRecoveryUnderADataDrivenClock_DoesNotThrowAndKeepsScoreWithinZeroAndOne() + { + HelperTimeProvider.SetFixedTime(new DateTime(2024, 3, 1, 14, 31, 0, DateTimeKind.Local)); + try + { + using var calc = new MarketResilienceCalculator(Settings(5000)); + WarmUp(calc); + + calc.OnTrade(new Trade { Size = 5000m, Price = 500.25m, Timestamp = HelperTimeProvider.Now }); + + var duringShock = Record.Exception(() => calc.OnOrderBookUpdate(Book(480m, 500m))); + var duringRecovery = Record.Exception(() => calc.OnOrderBookUpdate(Book(500m, 500.5m))); + var duringNextTrade = Record.Exception(() => calc.OnTrade(new Trade { Size = 100m, Price = 500.25m, Timestamp = HelperTimeProvider.Now })); + + Assert.Null(duringShock); + Assert.Null(duringRecovery); + Assert.Null(duringNextTrade); + Assert.InRange(calc.CurrentMRScore, 0m, 1m); + } + finally + { + HelperTimeProvider.ResetToSystemTime(); + } + } + + // The depth-recovery component divides the same way and fails the same way, independently of + // the spread component. Here the book thins WITHOUT widening — sizes collapse at unchanged + // prices — so no spread shock is ever raised and the depth term is the only recovery term in + // the calculation. Under a data-driven clock its duration is 0 against an empty history. + [Fact] + public void DepthShockAndRecoveryUnderADataDrivenClock_DoesNotThrowAndKeepsScoreWithinZeroAndOne() + { + HelperTimeProvider.SetFixedTime(new DateTime(2024, 3, 1, 14, 31, 0, DateTimeKind.Local)); + try + { + using var calc = new MarketResilienceCalculator(Settings(5000)); + WarmUpWithDepth(calc); + + calc.OnTrade(new Trade { Size = 5000m, Price = 100.49m, Timestamp = HelperTimeProvider.Now }); + + // Same prices, a fraction of the size: an immediacy collapse with an unchanged spread. + var thinned = MultiLevelBook( + asks: new[] { (100.50m, 100.0), (100.51m, 100.0), (100.52m, 100.0) }, + bids: new[] { (100.49m, 5.0), (100.48m, 5.0), (100.47m, 5.0) }); + var restored = MultiLevelBook( + asks: new[] { (100.50m, 100.0), (100.51m, 100.0), (100.52m, 100.0) }, + bids: new[] { (100.49m, 100.0), (100.48m, 100.0), (100.47m, 100.0) }); + + var duringThinning = Record.Exception(() => calc.OnOrderBookUpdate(thinned)); + var duringRestore = Record.Exception(() => calc.OnOrderBookUpdate(restored)); + var duringNextTrade = Record.Exception(() => calc.OnTrade(new Trade { Size = 100m, Price = 100.49m, Timestamp = HelperTimeProvider.Now })); + + Assert.Null(duringThinning); + Assert.Null(duringRestore); + Assert.Null(duringNextTrade); + Assert.InRange(calc.CurrentMRScore, 0m, 1m); + } + finally + { + HelperTimeProvider.ResetToSystemTime(); + } + } + + // One zero-duration recovery must not poison the calculator. Today it does, twice over: + // the failed calculation never clears the shock state (the clear runs after it), so the + // NEXT trade walks straight back into the same divide and throws again; and the zero + // duration is recorded into the recovery history before the failure, so the historical + // average stays pinned at zero afterwards. A later, ordinary shock and recovery — with a + // clock that has advanced normally — must still produce a finite, in-range score. + [Fact] + public void AfterAZeroDurationRecovery_ALaterNormalCycleStillProducesAnInRangeScore() + { + HelperTimeProvider.SetFixedTime(new DateTime(2024, 3, 1, 14, 31, 0, DateTimeKind.Local)); + try + { + using var calc = new MarketResilienceCalculator(Settings(5000)); + WarmUp(calc); + + // First cycle: shock and recovery on the same clock reading. Whatever it does is + // the subject of the fact above; this one is about what happens AFTERWARDS. + calc.OnTrade(new Trade { Size = 5000m, Price = 500.25m, Timestamp = HelperTimeProvider.Now }); + Record.Exception(() => calc.OnOrderBookUpdate(Book(480m, 500m))); + Record.Exception(() => calc.OnOrderBookUpdate(Book(500m, 500.5m))); + + // The clock moves on, as it does when the next messages carry later timestamps. + HelperTimeProvider.IncrementByMilliseconds(10_000); + + // Second cycle: an ordinary shock with a measurable 100 ms recovery. + var duringTrade = Record.Exception(() => calc.OnTrade(new Trade { Size = 5000m, Price = 500.25m, Timestamp = HelperTimeProvider.Now })); + var duringShock = Record.Exception(() => calc.OnOrderBookUpdate(Book(480m, 500m))); + HelperTimeProvider.IncrementByMilliseconds(100); + var duringRecovery = Record.Exception(() => calc.OnOrderBookUpdate(Book(500m, 500.5m))); + + Assert.Null(duringTrade); + Assert.Null(duringShock); + Assert.Null(duringRecovery); + Assert.InRange(calc.CurrentMRScore, 0m, 1m); + Assert.NotEqual(1m, calc.CurrentMRScore); // a score was actually produced + } + finally + { + HelperTimeProvider.ResetToSystemTime(); + } + } + + // (b) Property: over random equity-shaped tape (round lots, occasional blocks) interleaved + // with spread shocks and recoveries, the calculator never throws and the score never leaves + // [0,1]. Same two defects, reached from randomised input instead of a hand-built sequence. + [Theory] + [InlineData(1)] + [InlineData(7)] + [InlineData(13)] + [InlineData(101)] + [InlineData(2029)] + public void RandomEquityTapeWithShocks_NeverThrowsAndKeepsScoreWithinZeroAndOne(int seed) + { + var random = new Random(seed); + using var calc = new MarketResilienceCalculator(Settings(5000)); + WarmUp(calc, 40); + int cyclesThatScored = 0; + + for (int cycle = 0; cycle < 8; cycle++) + { + decimal scoreBefore = calc.CurrentMRScore; + + // A print large enough to stand out against the CURRENT baseline gets anchored. + decimal shockSize = 100m * random.Next(50, 120); + var duringShockTrade = Record.Exception(() => calc.OnTrade(new Trade { Size = shockSize, Price = 500.25m, Timestamp = DateTime.Now })); + Assert.Null(duringShockTrade); + + // Then the tape runs long enough to turn the whole 500-print window over. Two + // regimes, both ordinary on an equity open: retail-sized round lots, or an algo + // slicing near-identical clips (which is what collapses the size variance). + int prints = random.Next(520, 900); + bool algoClips = random.Next(2) == 0; + decimal clip = 100m * random.Next(100, 500); + for (int i = 0; i < prints; i++) + { + decimal size = algoClips + ? clip + (i % 2) // near-identical clips, one share apart + : 100m * random.Next(1, 30); // ordinary round lots + var duringTrade = Record.Exception(() => calc.OnTrade(new Trade { Size = size, Price = 500.25m, Timestamp = DateTime.Now })); + Assert.Null(duringTrade); + Assert.InRange(calc.CurrentMRScore, 0m, 1m); + } + + decimal widened = 5m + random.Next(0, 40); + var duringWiden = Record.Exception(() => calc.OnOrderBookUpdate(Book(500m - widened, 500m))); + Thread.Sleep(2); + var duringRecover = Record.Exception(() => calc.OnOrderBookUpdate(Book(500m, 500.5m))); + + Assert.Null(duringWiden); + Assert.Null(duringRecover); + Assert.InRange(calc.CurrentMRScore, 0m, 1m); + + if (calc.CurrentMRScore != scoreBefore) + cyclesThatScored++; + } + + // Guard against a vacuous pass: the scenario must actually have produced scores. + Assert.True(cyclesThatScored > 0, "No shock/recovery cycle produced a score, so nothing was exercised."); + } + + // (c) Regression guard for the numerics themselves. The same shock/recovery cycle is scored + // by hand from the known window contents, with the variance computed the exact (two-pass) + // way, and the published score must match. The weights mirrored here are the ones the + // component-weight test already asserts: trade 0.30, spread recovery 0.10, magnitude 0.10, + // normalised by the total weight actually used. On the FIRST recovery the historical + // average equals the measured duration, so the recovery term is exactly 0.5. + [Fact] + public void ShockAndRecovery_ScoreMatchesTwoPassVarianceComputedByHand() + { + const decimal shockTradeSize = 500m; // above baseline mean + 2 sigma, below mean + 6 sigma + const decimal shockSpread = 5m; // bid 495 / ask 500 + const int warmUpFrames = 30; + + using var calc = new MarketResilienceCalculator(Settings(5000)); + decimal[] window = WarmUp(calc, warmUpFrames); + + calc.OnTrade(new Trade { Size = shockTradeSize, Price = 500.25m, Timestamp = DateTime.Now }); + calc.OnOrderBookUpdate(Book(495m, 500m)); + Thread.Sleep(30); // keep the recovery duration strictly positive + calc.OnOrderBookUpdate(Book(500m, 500.5m)); + + // --- trade severity (weight 0.30), variance computed the exact two-pass way --- + decimal mean = window.Sum() / window.Length; + decimal sumSquaredDeviations = 0m; + foreach (decimal size in window) + { + decimal deviation = size - mean; + sumSquaredDeviations += deviation * deviation; + } + decimal variance = sumSquaredDeviations / window.Length; + decimal standardDeviation = (decimal)Math.Sqrt((double)variance); + double tradeZ = (double)((shockTradeSize - mean) / standardDeviation); + double tradeScore = Math.Max(0, 1.0 - (tradeZ / 6.0)); + + // --- spread recovery (weight 0.10): first recovery scores exactly 0.5 --- + const double spreadRecoveryScore = 0.5; + + // --- spread shock magnitude (weight 0.10) --- + // The spread history at trigger time is: warm-up frames at 0.5, the shock, the recovery. + decimal averageSpread = ((warmUpFrames * 0.5m) + shockSpread + 0.5m) / (warmUpFrames + 2); + double magnitudeRatio = (double)(shockSpread / Math.Max(averageSpread, 0.0001m)); + double magnitudeScore = Math.Max(0, Math.Min(1, 1.0 / magnitudeRatio)); + + double expected = ((0.30 * tradeScore) + (0.10 * spreadRecoveryScore) + (0.10 * magnitudeScore)) / 0.50; + + Assert.InRange(tradeScore, 0.0, 1.0); // guards the scenario: the z-score must be in the scored band + Assert.Equal((decimal)expected, calc.CurrentMRScore, 6); + } + + // --------------------------------------------------------------------------------- + // WORKED EXAMPLES - hand-computed expected scores. + // + // Component weights as declared in the calculator: trade severity 0.30, spread recovery + // 0.10, depth recovery 0.50, spread-shock magnitude 0.10. A component whose evidence is + // unusable is OMITTED from both the weighted sum and the total weight, so the published + // score is the weighted average over the components that actually participated. + // + // All three run under a fixed clock, so every recovery duration is exact rather than + // whatever the machine happened to measure. The warm-up is deliberately short (30 book + // frames): the depth detector needs 200 samples before it reports anything, so the depth + // component never participates in these examples and the arithmetic stays checkable by + // hand. + // --------------------------------------------------------------------------------- + + // WORKED EXAMPLE 1 - a healthy shock/recovery cycle: a strictly positive recovery + // duration measured against an existing recovery history. Every component that has + // evidence participates. + // + // trade window ...... 30 prints cycling 100/200/300 -> mean 200, + // population sd sqrt(200000/30) = 81.6496580927726 + // shock print ....... 500 -> z = (500 - 200) / 81.6496580927726 = 3.674234614 + // score = 1 - z/6 = 0.387627564 (weight 0.30) + // spread recovery ... history holds one 50 ms sample; this recovery took 100 ms + // score = 50 / (50 + 100) = 0.333333333 (weight 0.10) + // magnitude ......... spread window = 30x0.5, 5, 0.5, 5, 0.5 -> mean 26/34 = 0.764705882 + // score = mean / shock = 0.764705882 / 5 = 0.152941176 + // (weight 0.10) + // depth recovery .... no evidence (detector not warmed) -> omitted + // + // score = (0.30*0.387627564 + 0.10*0.333333333 + 0.10*0.152941176) / 0.50 + // = 0.164915720 / 0.50 + // = 0.329831441 + // + // This example is GREEN both before and after the omission rule: it is the baseline that + // proves a healthy score did not move. + [Fact] + public void WorkedExample_NormalShockAndRecoveryWithHistory_ScoresTradeRecoveryAndMagnitude() + { + HelperTimeProvider.SetFixedTime(new DateTime(2024, 3, 1, 14, 31, 0, DateTimeKind.Local)); + try + { + using var calc = new MarketResilienceCalculator(Settings(5000)); + WarmUp(calc, 30); + + // First cycle: a 50 ms recovery, which becomes the single sample of recovery history. + calc.OnTrade(new Trade { Size = 500m, Price = 500.25m, Timestamp = HelperTimeProvider.Now }); + calc.OnOrderBookUpdate(Book(495m, 500m)); + HelperTimeProvider.IncrementByMilliseconds(50); + calc.OnOrderBookUpdate(Book(500m, 500.5m)); + + // Well clear of the shock timeout, so the second cycle starts from a clean state. + HelperTimeProvider.IncrementByMilliseconds(10_000); + + // Second cycle: the same shape, recovering in 100 ms. + calc.OnTrade(new Trade { Size = 500m, Price = 500.25m, Timestamp = HelperTimeProvider.Now }); + calc.OnOrderBookUpdate(Book(495m, 500m)); + HelperTimeProvider.IncrementByMilliseconds(100); + calc.OnOrderBookUpdate(Book(500m, 500.5m)); + + decimal mrScore = calc.CurrentMRScore; + Assert.InRange(mrScore, 0.329831441m - 0.01m, 0.329831441m + 0.01m); + } + finally + { + HelperTimeProvider.ResetToSystemTime(); + } + } + + // WORKED EXAMPLE 2 - a near-constant trade window. The dispersion is real and strictly + // positive, but negligible relative to the mean, so a z-score measured against it carries + // no information about the shock print and the trade component is omitted. + // + // trade window ...... 30 prints alternating 1,000,000 and 1,000,000.0001 + // mean 1,000,000.00005, population sd 0.00005 + // 0.00005 < 1e-6 * 1,000,000.00005 = 1.00000000005 -> OMITTED + // spread recovery ... empty history, 100 ms recovery + // score = 100 / (100 + 100) = 0.5 (weight 0.10) + // magnitude ......... spread window = 30x0.5, 5, 0.5 -> mean 20.5/32 = 0.640625 + // score = 0.640625 / 5 = 0.128125 (weight 0.10) + // depth recovery .... no evidence -> omitted + // + // score = (0.10*0.5 + 0.10*0.128125) / 0.20 = 0.0628125 / 0.20 = 0.3140625 + // + // RED before the relative-dispersion floor (the trade component participated at weight + // 0.30 with a score of 0, publishing 0.1256); GREEN after. + [Fact] + public void WorkedExample_NearConstantTradeWindow_OmitsTheTradeComponent() + { + HelperTimeProvider.SetFixedTime(new DateTime(2024, 3, 1, 14, 31, 0, DateTimeKind.Local)); + try + { + using var calc = new MarketResilienceCalculator(Settings(5000)); + + // Quiet book, and an algo slicing near-identical clips one ten-thousandth apart. + const decimal clip = 1_000_000m; + for (int i = 0; i < 30; i++) + { + calc.OnOrderBookUpdate(Book(500m, 500.5m)); + calc.OnTrade(new Trade + { + Size = i % 2 == 0 ? clip : clip + 0.0001m, + Price = 500.25m, + Timestamp = HelperTimeProvider.Now + }); + } + + calc.OnTrade(new Trade { Size = 1_100_000m, Price = 500.25m, Timestamp = HelperTimeProvider.Now }); + calc.OnOrderBookUpdate(Book(495m, 500m)); + HelperTimeProvider.IncrementByMilliseconds(100); + calc.OnOrderBookUpdate(Book(500m, 500.5m)); + + decimal mrScore = calc.CurrentMRScore; + Assert.InRange(mrScore, 0.3140625m - 0.01m, 0.3140625m + 0.01m); + } + finally + { + HelperTimeProvider.ResetToSystemTime(); + } + } + + // WORKED EXAMPLE 3 - a shock and its recovery observed on the same clock reading, against + // an empty recovery history. The recovery score would be 0/(0+0): an instantaneous + // recovery measured against no history is an absence of evidence, so the spread-recovery + // component is omitted. The components that remain are trade severity (0.30) and + // spread-shock magnitude (0.10). + // + // trade ............. as in worked example 1 -> 0.387627564 (weight 0.30) + // spread recovery ... duration 0 against an empty history -> OMITTED + // magnitude ......... spread window = 30x0.5, 5, 0.5 -> mean 20.5/32 = 0.640625 + // score = 0.640625 / 5 = 0.128125 (weight 0.10) + // depth recovery .... no evidence -> omitted + // + // score = (0.30*0.387627564 + 0.10*0.128125) / 0.40 = 0.129100769 / 0.40 = 0.322751923 + // + // RED before the omission rule: the division produced NaN, the clamp propagated it and the + // final cast to decimal threw OverflowException. GREEN after. + [Fact] + public void WorkedExample_ZeroDurationRecoveryAgainstEmptyHistory_OmitsTheRecoveryComponent() + { + HelperTimeProvider.SetFixedTime(new DateTime(2024, 3, 1, 14, 31, 0, DateTimeKind.Local)); + try + { + using var calc = new MarketResilienceCalculator(Settings(5000)); + WarmUp(calc, 30); + + calc.OnTrade(new Trade { Size = 500m, Price = 500.25m, Timestamp = HelperTimeProvider.Now }); + calc.OnOrderBookUpdate(Book(495m, 500m)); + + // No clock movement between the shock and its recovery. + calc.OnOrderBookUpdate(Book(500m, 500.5m)); + + decimal mrScore = calc.CurrentMRScore; + Assert.InRange(mrScore, 0.322751923m - 0.01m, 0.322751923m + 0.01m); + } + finally + { + HelperTimeProvider.ResetToSystemTime(); + } + } + + // A cycle in which EVERY component is omitted carries no evidence at all, so it must not + // move the published score. Publishing a fixed value there would be worse than publishing + // nothing: the fixed value the calculator uses for "no data" is 1.0, the TOP of the scale, + // so an all-omitted cycle during a real depth depletion would report maximum resilience at + // the exact moment liquidity vanished. + // + // Reaching a zero total weight takes a depth-only cycle: a spread shock always contributes + // the magnitude component, so anything involving the spread carries weight by construction. + // The trade window is flushed to a constant size so the trade component is omitted too, and + // the depth recovery is instantaneous against an empty depth history so that component is + // omitted as well. + // + // The second half of this fact is the guard against a vacuous pass: the same depth cycle + // run with a MEASURABLE duration must move the score. If it does not, the depth pair was + // never completing and the first assertion proved nothing. + [Fact] + public void AnAllOmittedCycle_LeavesThePreviouslyPublishedScoreUnchanged() + { + HelperTimeProvider.SetFixedTime(new DateTime(2024, 3, 1, 14, 31, 0, DateTimeKind.Local)); + try + { + using var calc = new MarketResilienceCalculator(Settings(5000)); + + // 1. A normal spread cycle on a single-level book, which publishes a real score. + // The depth detector stays cold here, so the depth history is still empty. + WarmUp(calc, 30); + calc.OnTrade(new Trade { Size = 500m, Price = 500.25m, Timestamp = HelperTimeProvider.Now }); + calc.OnOrderBookUpdate(Book(495m, 500m)); + HelperTimeProvider.IncrementByMilliseconds(100); + calc.OnOrderBookUpdate(Book(500m, 500.5m)); + + decimal publishedScore = calc.CurrentMRScore; + Assert.InRange(publishedScore, 0m, 1m); + Assert.NotEqual(1m, publishedScore); // a real score, distinguishable from the no-data value + + // 2. Warm the depth detector, and flush the trade window to a single constant size + // so the trade component has no dispersion left to score. + var random = new Random(42); + double Size() => Math.Max(95, 100d * (1.0 + ((random.NextDouble() - 0.5) * 0.05))); + decimal Px(decimal basePx) => basePx + ((decimal)(random.NextDouble() - 0.5) * 0.01m); + for (int i = 0; i < 500; i++) + { + calc.OnOrderBookUpdate(MultiLevelBook( + asks: new[] { (Px(100.50m), Size()), (Px(100.51m), Size()), (Px(100.52m), Size()) }, + bids: new[] { (Px(100.49m), Size()), (Px(100.48m), Size()), (Px(100.47m), Size()) })); + calc.OnTrade(new Trade { Size = 100m, Price = 100.49m, Timestamp = HelperTimeProvider.Now }); + } + + var thinned = MultiLevelBook( + asks: new[] { (100.50m, 100.0), (100.51m, 100.0), (100.52m, 100.0) }, + bids: new[] { (100.49m, 5.0), (100.48m, 5.0), (100.47m, 5.0) }); + var restored = MultiLevelBook( + asks: new[] { (100.50m, 100.0), (100.51m, 100.0), (100.52m, 100.0) }, + bids: new[] { (100.49m, 100.0), (100.48m, 100.0), (100.47m, 100.0) }); + + // 3. The all-omitted cycle: an instantaneous depth recovery, no spread shock, and a + // constant trade window. Nothing carries weight, so nothing may be published. + calc.OnTrade(new Trade { Size = 5000m, Price = 100.49m, Timestamp = HelperTimeProvider.Now }); + calc.OnOrderBookUpdate(thinned); + calc.OnOrderBookUpdate(restored); + + decimal afterOmittedCycle = calc.CurrentMRScore; + Assert.Equal(publishedScore, afterOmittedCycle); + + // 4. The vacuity guard: the same cycle with a measurable recovery DOES score. + calc.OnTrade(new Trade { Size = 5000m, Price = 100.49m, Timestamp = HelperTimeProvider.Now }); + calc.OnOrderBookUpdate(MultiLevelBook( + asks: new[] { (100.50m, 100.0), (100.51m, 100.0), (100.52m, 100.0) }, + bids: new[] { (100.49m, 5.0), (100.48m, 5.0), (100.47m, 5.0) })); + HelperTimeProvider.IncrementByMilliseconds(100); + calc.OnOrderBookUpdate(MultiLevelBook( + asks: new[] { (100.50m, 100.0), (100.51m, 100.0), (100.52m, 100.0) }, + bids: new[] { (100.49m, 100.0), (100.48m, 100.0), (100.47m, 100.0) })); + + decimal afterScoringCycle = calc.CurrentMRScore; + Assert.InRange(afterScoringCycle, 0m, 1m); + Assert.NotEqual(publishedScore, afterScoringCycle); + } + finally + { + HelperTimeProvider.ResetToSystemTime(); + } + } + } +} diff --git a/tests/Unit/Studies.MarketResilience.Test/Studies.MarketResilience.Test.csproj b/tests/Unit/Studies.MarketResilience.Test/Studies.MarketResilience.Test.csproj index 3b3c3035..db216c71 100644 --- a/tests/Unit/Studies.MarketResilience.Test/Studies.MarketResilience.Test.csproj +++ b/tests/Unit/Studies.MarketResilience.Test/Studies.MarketResilience.Test.csproj @@ -26,6 +26,10 @@ + + + + diff --git a/tests/Unit/Studies.MarketResilience.Test/xunit.runner.json b/tests/Unit/Studies.MarketResilience.Test/xunit.runner.json new file mode 100644 index 00000000..dd80f43a --- /dev/null +++ b/tests/Unit/Studies.MarketResilience.Test/xunit.runner.json @@ -0,0 +1,5 @@ +{ + "$schema": "https://xunit.net/schema/current/xunit.runner.schema.json", + "parallelizeAssembly": false, + "parallelizeTestCollections": false +} diff --git a/tests/Unit/VisualHFT.Commons.Tests/BasePluginStudyHelperExceptionWiringTests.cs b/tests/Unit/VisualHFT.Commons.Tests/BasePluginStudyHelperExceptionWiringTests.cs new file mode 100644 index 00000000..cca4fd2d --- /dev/null +++ b/tests/Unit/VisualHFT.Commons.Tests/BasePluginStudyHelperExceptionWiringTests.cs @@ -0,0 +1,132 @@ +using System; +using System.Threading; +using System.Threading.Tasks; +using VisualHFT.Commons.PluginManager; +using VisualHFT.Enums; +using VisualHFT.Helpers; +using VisualHFT.Model; +using VisualHFT.PluginManager; +using VisualHFT.UserSettings; +using Xunit; + +namespace VisualHFT.Commons.Tests +{ + /// + /// The market-data helpers isolate a faulting subscriber and raise OnException instead + /// of letting the exception reach the producer thread. Isolating is only half the contract: + /// the study that faulted must also be STOPPED and marked failed, otherwise it silently stops + /// producing while still looking healthy in the UI. + /// + /// BasePluginStudy is the consumer of that signal. When the faulting subscriber belongs + /// to this study instance, it stops the study and sets STOPPED_FAILED. These facts + /// assert that this happens for BOTH streams - a study can subscribe to either one, and a + /// fault on the trade stream must have the same consequence as one on the order-book stream. + /// + /// Shared-state note: both helpers are process-wide singletons, so every fact resets the + /// subscriber list in a finally, otherwise subscribers leak into sibling tests. + /// + public class BasePluginStudyHelperExceptionWiringTests + { + /// + /// Both handlers are METHODS ON THE STUDY, not lambdas: the helper reports + /// subscriber.Target as the fault context, and the study only reacts to a fault whose + /// context is itself. A closure would carry the wrong target and the check would not fire — + /// which is exactly how a real study subscribes. + /// + private sealed class FaultingProbeStudy : BasePluginStudy + { + public override event EventHandler OnAlertTriggered + { + add { } + remove { } + } + + public override string Name { get; set; } = "FaultingProbe"; + public override string Version { get; set; } = "1.0"; + public override string Description { get; set; } = "Probe that throws on incoming data"; + public override string Author { get; set; } = "Test"; + public override ISetting Settings { get; set; } + public override Action CloseSettingWindow { get; set; } = () => { }; + public override string TileTitle { get; set; } = "Probe"; + public override string TileToolTip { get; set; } = "Probe that throws on incoming data"; + + public void OnTradeReceived(Trade trade) => throw new InvalidOperationException("faulting study"); + + public void OnOrderBookReceived(OrderBook book) => throw new InvalidOperationException("faulting study"); + + protected override void LoadSettings() => Settings = new ProbeStudySetting(); + + protected override void SaveSettings() { } + + protected override void InitializeDefaultSettings() => Settings = new ProbeStudySetting(); + + public override object GetUISettings() => null; + } + + private sealed class ProbeStudySetting : ISetting + { + public string Symbol { get; set; } = "TEST"; + public Provider Provider { get; set; } = new Provider { ProviderCode = 1, ProviderName = "Test" }; + public AggregationLevel AggregationLevel { get; set; } = AggregationLevel.Ms100; + } + + /// The helper raises OnException on the thread pool, so the status change is not + /// observable on the calling thread the instant UpdateData returns. + private static bool WaitForStatus(BasePluginStudy study, ePluginStatus expected, int timeoutMs = 5000) + { + var deadline = Environment.TickCount64 + timeoutMs; + while (Environment.TickCount64 < deadline) + { + if (study.Status == expected) + return true; + Thread.Sleep(10); + } + return study.Status == expected; + } + + // The order-book path, as the control: it proves this probe shape really does reach the + // handler, so a red on the trade fact below is about the wiring and not about the fixture. + [Fact] + public void WhenAStudyThrowsOnAnOrderBookUpdate_TheStudyIsStoppedAndMarkedFailed() + { + HelperOrderBook.Instance.Reset(); + using var study = new FaultingProbeStudy(); + try + { + HelperOrderBook.Instance.Subscribe(study.OnOrderBookReceived); + + HelperOrderBook.Instance.UpdateData(new OrderBook()); + + Assert.True(WaitForStatus(study, ePluginStatus.STOPPED_FAILED), + $"The study was left at {study.Status} after faulting on an order-book update."); + } + finally + { + HelperOrderBook.Instance.Reset(); + } + } + + // The trade path must behave identically. Without the trade helper's OnException wired into + // the study, the fault is logged and raised but nothing consumes it: the study keeps its + // previous status and goes on presenting itself as healthy while receiving no trade data. + [Fact] + public void WhenAStudyThrowsOnATrade_TheStudyIsStoppedAndMarkedFailed() + { + HelperTrade.Instance.Reset(); + using var study = new FaultingProbeStudy(); + try + { + HelperTrade.Instance.Subscribe(study.OnTradeReceived); + + HelperTrade.Instance.UpdateData(new Trade { Size = 100m, Price = 500.25m, Timestamp = DateTime.Now }); + + Assert.True(WaitForStatus(study, ePluginStatus.STOPPED_FAILED), + $"The study was left at {study.Status} after faulting on a trade."); + } + finally + { + HelperTrade.Instance.Reset(); + } + } + } +} diff --git a/tests/Unit/VisualHFT.Commons.Tests/HelperTradeDispatchIsolationTests.cs b/tests/Unit/VisualHFT.Commons.Tests/HelperTradeDispatchIsolationTests.cs new file mode 100644 index 00000000..50d70512 --- /dev/null +++ b/tests/Unit/VisualHFT.Commons.Tests/HelperTradeDispatchIsolationTests.cs @@ -0,0 +1,121 @@ +using System; +using System.Collections.Generic; +using System.Threading; +using VisualHFT.Helpers; +using VisualHFT.Model; +using Xunit; + +namespace VisualHFT.Commons.Tests +{ + /// + /// Dispatch contract for HelperTrade. + /// + /// A subscriber on this stream is study-shaped code running on the market connector's + /// producer thread, at a call site that does not guard itself. Two things follow, and each is + /// a fact below: + /// 1. an exception that escapes UpdateData reaches a non-UI thread unhandled, which + /// terminates the process with no dialog and no log entry; + /// 2. an escaping exception also unwinds the dispatch loop, so ONE faulting subscriber + /// starves every subscriber after it of trade data. + /// + /// A subscriber throwing is a normal operating condition on a hot path, not a reason to kill + /// the host. Dispatch isolates each subscriber and continues to the next. Isolating must not + /// mean silencing, so the fault is raised on OnException - the last fact group covers + /// that, because swallowing would trade a crash for an invisible data outage. + /// + /// Shared-state note: HelperTrade is a process-wide singleton, so every fact resets the + /// subscriber list before and in a finally, otherwise subscribers leak into sibling tests. + /// + public class HelperTradeDispatchIsolationTests + { + private static Trade NewTrade() => new Trade { Size = 100m, Price = 500.25m, Timestamp = DateTime.Now }; + + [Fact] + public void UpdateData_WhenASubscriberThrows_DoesNotPropagateToTheProducerThread() + { + // The producer here stands in for the market connector's unguarded call site: a throw + // that reaches it takes the process down in production. + HelperTrade.Instance.Reset(); + try + { + HelperTrade.Instance.Subscribe(_ => throw new InvalidOperationException("faulting study")); + + Exception escaped = Record.Exception(() => HelperTrade.Instance.UpdateData(NewTrade())); + + Assert.Null(escaped); + } + finally + { + HelperTrade.Instance.Reset(); + } + } + + [Fact] + public void UpdateData_WhenAnEarlySubscriberThrows_StillDispatchesToTheRemainingSubscribers() + { + HelperTrade.Instance.Reset(); + try + { + var delivered = new List(); + HelperTrade.Instance.Subscribe(_ => delivered.Add("first")); + HelperTrade.Instance.Subscribe(_ => throw new InvalidOperationException("faulting study")); + HelperTrade.Instance.Subscribe(_ => delivered.Add("third")); + + HelperTrade.Instance.UpdateData(NewTrade()); + + // "third" is the assertion that matters: it is the subscriber the foreach-unwind + // starves today. + Assert.Contains("first", delivered); + Assert.Contains("third", delivered); + } + finally + { + HelperTrade.Instance.Reset(); + } + } + + [Fact] + public void UpdateData_WhenASubscriberThrows_StillSurfacesTheFaultViaOnException() + { + // Isolating the fault must not SILENCE it — swallowing would trade a crash for an + // invisible data outage. + HelperTrade.Instance.Reset(); + var raised = new ManualResetEventSlim(false); + Action handler = _ => raised.Set(); + HelperTrade.Instance.OnException += handler; + try + { + HelperTrade.Instance.Subscribe(_ => throw new InvalidOperationException("faulting study")); + + HelperTrade.Instance.UpdateData(NewTrade()); + + Assert.True(raised.Wait(TimeSpan.FromSeconds(5)), "OnException was never raised for the faulting subscriber."); + } + finally + { + HelperTrade.Instance.OnException -= handler; + HelperTrade.Instance.Reset(); + raised.Dispose(); + } + } + + [Fact] + public void UpdateData_WhenEverySubscriberThrows_StillDoesNotPropagate() + { + HelperTrade.Instance.Reset(); + try + { + HelperTrade.Instance.Subscribe(_ => throw new InvalidOperationException("study A")); + HelperTrade.Instance.Subscribe(_ => throw new InvalidOperationException("study B")); + + Exception escaped = Record.Exception(() => HelperTrade.Instance.UpdateData(NewTrade())); + + Assert.Null(escaped); + } + finally + { + HelperTrade.Instance.Reset(); + } + } + } +}