Skip to content

fix(MarketResilience): score non-recoveries, count only the depleted side, drop wall-clock reads - #79

Merged
silahian merged 1 commit into
masterfrom
port/market-resilience-behaviour-fixes
Sep 21, 2026
Merged

silahian merged 1 commit into
masterfrom
port/market-resilience-behaviour-fixes

Conversation

@silahian

Copy link
Copy Markdown
Collaborator

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.0 while the book stayed empty. A book that recovered read 0.31; a book that never came back read 1.00.

What changed

Scoring

  • 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 published.
  • Only replenishment of the depleted side counts as recovery. Growth on the untouched side is a price move, not resilience. The check that credited it was also degenerate: at activation the untouched side's baseline and floor were set equal, so the denominator collapsed to 1e-9 and any uptick cleared the 90% bar.
  • The anchoring trade is now purely the attribution gate. Its expiry no longer clears a live, recovering event — the recovery deadline belongs to the depletion, which is the thing that has to come back. This also removes a depth-sampling stall that occurred 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, producing exactly 0.5 on both recovery components regardless of what happened — so a 50 ms and a 700 ms recovery published the same number.

Market-agnostic correctness

  • Removed the absolute 0.0001 spread floor, which clamped the magnitude component to 1 on 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

  • 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.

Transparency

  • Warm-up is disclosed: the calculator exposes its progress and the tile is flagged stale until the depth baseline holds 200 book updates.
  • 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.

Bias tile

  • Direction now comes from the depleted side that fails to be rebuilt, which follows from the depleted-side rule above.
  • The hysteresis latch is evaluated on every scored event, not only 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 early, so the arrow stayed armed through a recovery that should have cleared it.

Supporting changes

  • RollingWindow gains AddWithEviction, returning the item pushed out when the window is at capacity, so running sums can be maintained without re-walking the window.
  • StatisticalHelper drops LINQ from its mean and variance paths. Enumerating IEnumerable<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:

before after
depth 10 12,755 ns / 48 B 172 ns / 0 B
depth 25 14,107 ns / 48 B 225 ns / 0 B

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 Description strings. 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:

  • Tests that fed one event to a fresh calculator and expected a published score now seed a prior recovery first.
  • Tests that expected an opposite-side improvement to close an event now let the window close.
  • The rewritten tests drive time through the platform's time provider instead of sleeping, so they are deterministic.

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.Test executes zero tests on this repository as it stands — dotnet test reports Zero tests ran for 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 that global.json does not pin an SDK version. Worth fixing separately.

…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.
Copilot AI lite review requested due to automatic review settings September 21, 2026 00:53

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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 High severity · 2 Medium severity

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;
@silahian
silahian merged commit 90f2f01 into master Sep 21, 2026
1 check passed
@silahian
silahian deleted the port/market-resilience-behaviour-fixes branch September 21, 2026 02:32
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants