diff --git a/VisualHFT.Plugins/Studies.MarketResilience/MarketResilienceBiasStudy.cs b/VisualHFT.Plugins/Studies.MarketResilience/MarketResilienceBiasStudy.cs index c45268c..39fea22 100644 --- a/VisualHFT.Plugins/Studies.MarketResilience/MarketResilienceBiasStudy.cs +++ b/VisualHFT.Plugins/Studies.MarketResilience/MarketResilienceBiasStudy.cs @@ -47,22 +47,22 @@ public class MarketResilienceBiasStudy : BasePluginStudy "How It Works:
" + "1. Detection: Identifies large trades (≥2σ above average) that cause order book depth depletion
" + - "2. Recovery Tracking: Watches whether each depleted side regains 90% of its immediacy-weighted depth before the window closes. Only the side that was depleted counts; the other side growing is a price move, not a recovery
" + + "2. Recovery Tracking: Watches whether each depleted side regains 90% of its immediacy-weighted depth before the window closes, AND comes back at its price — within one typical spread of where it was quoted just before the depletion. Only the side that was depleted counts; the other side growing is a price move, not a recovery
" + "3. Bias Classification: Reads direction from the side that FAILED to come back

" + "Signal Interpretation:
" + "• ↑ Bullish (+1): The depleted ask failed to redeploy — sellers could not re-offer
" + "• ↓ Bearish (-1): The depleted bid failed to redeploy — buyers could not re-bid
" + - "• — Neutral (0): The depleted side came back, or both sides failed together, or resilience was not poor enough (MR > 0.30)

" + + "• — Neutral (0): The depleted side came back at its price, or came back at a BETTER price, or both sides failed together

" + "Activation Requirements:
" + "• Large trade detected (more than 2 dispersions above the recent size mean)
" + "• Depth depletion confirmed (3 median absolute deviations below the usual immediacy-weighted depth)
" + "• Market Resilience (MR) score ≤ 0.30 (poor resilience)
" + - "• A depleted side still short of 90% of its depth when the timeout window closes (default: 5 seconds)

" + + "• A depleted side still short of 90% of its depth — or back in size but not back at its price — when the timeout window closes (default: 5 seconds)

" + "Hysteresis Behavior:
" + - "MRB activates when MR ≤ 0.30 and deactivates when MR ≥ 0.50, preventing signal oscillation during moderate resilience.

" + + "MRB activates when MR ≤ 0.30 and deactivates when MR ≥ 0.50, preventing signal oscillation during moderate resilience. Between those levels the arrow keeps whatever it was last showing: while it is inactive it publishes nothing rather than resetting to Neutral, so a reading persists until resilience recovers to 0.50 or a new event replaces it.

" + "Practical Use:
" + "Use MRB to identify which side (buyers/sellers) gains control after market shocks. Persistent directional bias may indicate institutional order flow or liquidity imbalances."; diff --git a/VisualHFT.Plugins/Studies.MarketResilience/MarketResilienceStudy.cs b/VisualHFT.Plugins/Studies.MarketResilience/MarketResilienceStudy.cs index 1cef917..fe4cae4 100644 --- a/VisualHFT.Plugins/Studies.MarketResilience/MarketResilienceStudy.cs +++ b/VisualHFT.Plugins/Studies.MarketResilience/MarketResilienceStudy.cs @@ -58,6 +58,7 @@ public class MarketResilienceStudy : BasePluginStudy "3. Depth recovery (50%): how fast the depleted side climbed back to 90% of its pre-shock depth, against this session's history.
" + "4. Spread magnitude (10%): how wide the shock spread was relative to the usual spread.

" + "Only the depleted side counts as recovery. If it does not regain 90% of its depth before the Max Shock Timeout, the event is scored as a non-recovery and the depth component reads 0 at its full weight.
" + + "The price has to come back too. A side counts as recovered only if its best price returns to within one typical spread of where it was quoted just before the depletion. Size that reappears further away than that is the market repricing, not the book recovering, and it is scored as a non-recovery.
" + "The first recovery of a session is not published; it seeds the history the next one is compared to.

" + "Warm-up: the depth baseline needs 200 book updates before a depletion can be detected. The tile is flagged stale until then.

" + "Reading the score. It is relative to this instrument's own recent behaviour, not an absolute percentage. 0.7 means this recovery was faster than this book's own recent average — it does not mean 70% of the liquidity came back.
" + diff --git a/VisualHFT.Plugins/Studies.MarketResilience/Model/MarketResilienceCalculator.cs b/VisualHFT.Plugins/Studies.MarketResilience/Model/MarketResilienceCalculator.cs index 9fca57f..a10c232 100644 --- a/VisualHFT.Plugins/Studies.MarketResilience/Model/MarketResilienceCalculator.cs +++ b/VisualHFT.Plugins/Studies.MarketResilience/Model/MarketResilienceCalculator.cs @@ -67,6 +67,13 @@ public class MarketResilienceCalculator : IDisposable // ----- ACTIVE DEPTH EVENT STATE ----- private ActiveDepthEvent? _activeDepth = null; + // The touch of the PREVIOUS book update, carried forward so the frame that detects a + // depletion can still see the book as it stood before it. Depth that comes back only at a + // worse price is not a recovery, and the price it has to come back to is the last one + // quoted while the side was still whole. + private decimal? _prevBidPrice; + private decimal? _prevAskPrice; + private struct ActiveDepthEvent { public eLOBSIDE DepletedSide; // which side(s) triggered depletion @@ -76,6 +83,10 @@ private struct ActiveDepthEvent // Baselines (at t0) and troughs (worst since t0) for each side public double DBaseBid, DBaseAsk; // immediacy depth baseline per side public double DTroughBid, DTroughAsk; + + // The touch on the frame before the depletion was detected: the price each side has to + // come back to. Null when no earlier frame was seen. + public decimal? AnchorBid, AnchorAsk; } // ----- CONFIG ----- @@ -228,6 +239,12 @@ public void OnOrderBookUpdate(OrderBookSnapshot orderBook) recentSpreads.Add(currentSpread); _lastMidPrice = (decimal?)orderBook.MidPrice; + + // Carry the outgoing touch forward before it is overwritten. The depletion below is + // detected on the frame that already shows the damage, so the price the side has to + // come back to is the one quoted on the frame before it. + _prevBidPrice = _lastBidPrice; + _prevAskPrice = _lastAskPrice; _lastBidPrice = (decimal?)orderBook.Bids[0]?.Price; _lastAskPrice = (decimal?)orderBook.Asks[0]?.Price; @@ -657,10 +674,14 @@ Prevents noisy triggers during the first few hundred updates or in ultra-thin st } internal void ActivateDepthEvent(in OrderBookSnapshot lob, eLOBSIDE side) { - // Baselines at t0: use current robust medians if available, else current values + // Baselines at t0: use current robust medians if available, else current values. + // The cold-start fallback matches IsLOBDepleted's: this book's own spread when it has + // one, and only then the unit of last resort. Flooring at 1.0 regardless measured the + // event on a different scale than the detector that admitted it, and on an instrument + // quoting below 1.0 that unit is larger than the whole price. double spreadBase = _samplesSpread >= WARMUP_MIN_SAMPLES ? _qSpreadMed.Estimate - : Math.Max(lob.Spread, 1.0); + : (lob.Spread > 0 ? lob.Spread : 1.0); double dBidNow = ImmediacyDepthBid(lob, spreadBase); double dAskNow = ImmediacyDepthAsk(lob, spreadBase); @@ -673,7 +694,9 @@ internal void ActivateDepthEvent(in OrderBookSnapshot lob, eLOBSIDE side) DBaseBid = (_samplesDepth >= WARMUP_MIN_SAMPLES ? _qBidDMed.Estimate : dBidNow), DBaseAsk = (_samplesDepth >= WARMUP_MIN_SAMPLES ? _qAskDMed.Estimate : dAskNow), DTroughBid = dBidNow, // initialize troughs at current, will update downward - DTroughAsk = dAskNow + DTroughAsk = dAskNow, + AnchorBid = _prevBidPrice, + AnchorAsk = _prevAskPrice }; } @@ -700,9 +723,11 @@ internal eLOBSIDE IsLOBRecovered(in OrderBookSnapshot lob) double dBidNow = ImmediacyDepthBid(lob, spreadBase); if (dBidNow < ev.DTroughBid) ev.DTroughBid = dBidNow; - // Recovery is how far the side has climbed from its trough toward its baseline. + // Recovery is how far the side has climbed from its trough toward its baseline, + // and it only counts if it happened at a price the pre-event book would recognise. double denomBid = Math.Max(ev.DBaseBid - ev.DTroughBid, EPS); - if (Clamp01((dBidNow - ev.DTroughBid) / denomBid) >= RECOVERY_TARGET) + if (Clamp01((dBidNow - ev.DTroughBid) / denomBid) >= RECOVERY_TARGET + && IsTouchWithinAnchor(BestPrice(lob.Bids), ev.AnchorBid, ev.SBase, higherIsBetter: true)) ev.RecoveredSides |= eLOBSIDE.BID; } @@ -712,7 +737,8 @@ internal eLOBSIDE IsLOBRecovered(in OrderBookSnapshot lob) if (dAskNow < ev.DTroughAsk) ev.DTroughAsk = dAskNow; double denomAsk = Math.Max(ev.DBaseAsk - ev.DTroughAsk, EPS); - if (Clamp01((dAskNow - ev.DTroughAsk) / denomAsk) >= RECOVERY_TARGET) + if (Clamp01((dAskNow - ev.DTroughAsk) / denomAsk) >= RECOVERY_TARGET + && IsTouchWithinAnchor(BestPrice(lob.Asks), ev.AnchorAsk, ev.SBase, higherIsBetter: false)) ev.RecoveredSides |= eLOBSIDE.ASK; } @@ -728,6 +754,43 @@ internal eLOBSIDE IsLOBRecovered(in OrderBookSnapshot lob) } + /// Best price of a side, or null when the side is empty or unpriced. + [MethodImpl(MethodImplOptions.AggressiveInlining)] + private static double? BestPrice(ReadOnlySpan levels) + { + if (levels.Length == 0 || levels[0] == null) + return null; + return levels[0].Price; + } + + /// + /// Whether a side's touch has come back to the price it was quoted at before the event. + /// Size returning at a worse price is not resilience: the depth is there, the price that was + /// quoted is gone. The literature measures recovery at the pre-event best, and the arrow + /// reads a side that failed to redeploy as the direction the market is likely to take. + /// + /// The tolerance is one spread baseline, inclusive, taken from the event itself. It is the + /// instrument's own typical distance, so it carries no tick size and no price scale: routine + /// requoting inside the spread still counts as a recovery, and a touch that moved further + /// than the instrument's own spread does not. + /// + /// Two cases skip the test and credit the recovery: no anchor (the print arrived before any + /// book) and no measurable spread baseline. Both mean the comparison cannot be made, and + /// staying silent is the safe direction for a tile that only speaks about failures. + /// + private static bool IsTouchWithinAnchor(double? touch, decimal? anchor, double tolerance, bool higherIsBetter) + { + if (anchor == null || tolerance <= EPS) + return true; + if (touch == null) + return false; + + double limit = (double)anchor.Value; + return higherIsBetter + ? touch.Value >= limit - tolerance + : touch.Value <= limit + tolerance; + } + [MethodImpl(MethodImplOptions.AggressiveInlining)] private static double InvSquareWeight(double d) // w = 1 / (1 + d)^2 {