fix(MarketResilience): score non-recoveries, count only the depleted side, drop wall-clock reads - #79
Merged
Conversation
…side, drop wall-clock reads The tile reported maximum resilience on the worst possible market. An event whose liquidity never came back was never scored at all, so the score stayed at its cold-start 1.0 while the book sat empty. Behaviour fixes: - A depth or spread window that elapses without recovery is now scored as a non-recovery: the component reads 0 at its full weight and is not written to the recovery history. Previously the state was cleared and nothing was published. - Only replenishment of the DEPLETED side counts as recovery. Growth on the untouched side is a price move, and the check that credited it was degenerate: the untouched side's baseline and floor were set equal at activation, so the denominator collapsed to 1e-9 and any uptick cleared the 90% bar. - The large print is now purely the attribution gate. Its expiry no longer clears a live, recovering event; the recovery deadline belongs to the depletion, which is what has to come back. This also removes a depth-sampling stall during expiry. - A spread recovery no longer closes the whole event ahead of the depth outcome. - The first scored event of a session seeds the history and publishes nothing. Previously it compared itself against itself and always produced 0.5 on both recovery components, so a 50ms and a 700ms recovery published the same number. - Removed the absolute 0.0001 spread floor, which clamped the magnitude component to 1 on any instrument quoting below it. The component is omitted when the average spread is not positive. This platform serves crypto, equities, futures and FX, so no price-scale constant belongs here. - No wall-clock reads remain in the plugin. The depth timeout ran on machine time while everything else ran on the venue clock, so replay scored differently from live. - Warm-up is disclosed: the calculator exposes its progress and the tile is flagged stale until the depth baseline holds 200 book updates. - The bias tile reads direction from the depleted side that FAILS to be rebuilt, which follows from the depleted-side-only rule above. - The bias tile's hysteresis latch is now evaluated on every scored event, not only on one that produced a depth outcome. A spread that widened and came back scores without depleting anything; the latch was checked after the depth guard returned, so the arrow stayed armed through a recovery that should have cleared it, while the tooltip promised it clears. Supporting changes: - RollingWindow gains AddWithEviction, which returns the item pushed out when the window is at capacity. The calculator needs the evicted value to maintain running sums without re-walking the window. - StatisticalHelper drops LINQ from its mean and variance paths. Enumerating IEnumerable<T> boxed an enumerator on every update. Documentation. The tooltip described a "Time Recovery" component that has never existed in the code, a three-component model that has been four for a long time, an absolute 0-to-1 reading of a scale that is relative to the instrument's own recent recoveries, and a same-side scoring adjustment that was never built. All corrected. The plugin Description string is deliberately unchanged: it is hashed into the plugin id that keys saved settings, so editing it would orphan existing users' configuration. Tests. The existing suite is rewritten where it asserted the old behaviour: tests that fed one event to a fresh calculator and expected a published score now seed a prior recovery first, and tests that expected an opposite-side improvement to close an event now let the window close. No test was deleted. Three were renamed because their names described behaviour that no longer exists, and one was added to cover the depth timeout scoring zero at full weight. The rewritten tests drive time through the platform's time provider instead of sleeping, so they are deterministic.
There was a problem hiding this comment.
Copilot review overview
🟡 Changes recommended
Empty books can bypass timeout scoring, spread fallback state can be corrupted, and running decimal statistics can overflow on valid large trade sizes.
Get a fresh assessment by requesting another Copilot review.
Review effort: Lite
Findings: 1
Open (3)
What changed in this PR
Updates Market Resilience scoring to handle non-recoveries, depleted-side recovery, deterministic timing, warm-up transparency, and lower callback overhead.
Changes:
- Reworks resilience and bias scoring lifecycle.
- Adds efficient rolling-window statistics.
- Updates tooltips and deterministic tests.
| File | Description |
|---|---|
PluginSettingsView.xaml |
Fixes timeout-label spelling. |
StatisticalHelper.cs |
Replaces LINQ statistics with loops. |
MarketResilienceCalculator.cs |
Implements scoring, timing, recovery, and performance changes. |
MarketResilienceWithBias.cs |
Updates bias direction and hysteresis handling. |
MarketResilienceStudy.cs |
Adds tooltip, stale-state, aggregation, and lifecycle updates. |
MarketResilienceBiasStudy.cs |
Updates bias tooltip, filtering, and lifecycle handling. |
RollingWindow.cs |
Adds eviction reporting for running statistics. |
MarketResilienceWithBiasTests.cs |
Rewrites bias scenarios with deterministic timing. |
MarketResilienceCalculatorTests.cs |
Covers new scoring and timeout behavior. |
MarketResilienceCalculatorNumericalStabilityTests.cs |
Updates numerical-stability expectations. |
💡 Add a code-review agent skill or configure MCP servers for context-aware, tailored reviews. Learn more in the docs.
Comment on lines
+187
to
+188
| _tradeSizeSum += trade.Size; | ||
| _tradeSizeSumSq += trade.Size * trade.Size; |
Comment on lines
+249
to
+252
| if (HasElapsed(ShockDepth.Timestamp, now)) | ||
| { | ||
| // Timeout expired - clear state | ||
| // The window closed before every depleted side came back. Whatever this late | ||
| // frame shows is not credited; the sides that did make it in time are kept |
| // 6) Advance previous snapshot and return | ||
| _previousLOB = lob; | ||
| // 6) Carry this book's spread forward for the next update's locked-book fallback | ||
| _previousSpread = lob.Spread; |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.


The defect
The Market Resilience tile reported maximum resilience on the worst possible market.
The score has a single write site, reached only when a recovery completes. An event whose liquidity never came back never completed, so nothing was ever written and the tile sat at its cold-start value of
1.0while the book stayed empty. A book that recovered read0.31; a book that never came back read1.00.What changed
Scoring
0at its full weight and is not written to the recovery history. Previously the state was cleared and nothing published.1e-9and any uptick cleared the 90% bar.0.5on both recovery components regardless of what happened — so a 50 ms and a 700 ms recovery published the same number.Market-agnostic correctness
0.0001spread floor, which clamped the magnitude component to1on any instrument quoting below it. The component is now omitted when the average spread is not positive. This platform serves crypto, equities, futures and FX; no price-scale constant belongs here.Determinism
Transparency
0-to-1reading of a scale that is relative to the instrument's own recent recoveries, and a same-side scoring adjustment that was never built. All corrected.Bias tile
Supporting changes
RollingWindowgainsAddWithEviction, returning the item pushed out when the window is at capacity, so running sums can be maintained without re-walking the window.StatisticalHelperdrops LINQ from its mean and variance paths. EnumeratingIEnumerable<T>boxed an enumerator on every update.Performance
A book update where nothing is happening — the overwhelmingly common case — measured on an Intel Core Ultra 9 285H:
The per-callback budget for the shared dispatch thread is 10,000 ns. The cause was the rolling spread statistics running on every update; they now run only while a shock is live.
Deliberately NOT changed
The plugin
Descriptionstrings. They are hashed into the plugin id that keys saved settings, so editing one orphans every existing user's symbol and provider selection and leaves the tile dead on launch. The corrected wording lives in the tooltip, which is not hashed.Tests
The existing suite is rewritten where it asserted the old behaviour:
No test was deleted. Three were renamed because their names described behaviour that no longer exists, and one was added covering the depth timeout scoring zero at full weight.
Reviewer note
tests/Unit/Studies.MarketResilience.Testexecutes zero tests on this repository as it stands —dotnet testreportsZero tests ranfor every test project here, including ones this PR does not touch. That is pre-existing and independent of this change, but it means CI cannot currently demonstrate these tests passing. The likely cause is thatglobal.jsondoes not pin an SDK version. Worth fixing separately.