From aa8cb0850564e28baf62f3f8cc02c69027dea118 Mon Sep 17 00:00:00 2001 From: difan Date: Sun, 19 Jul 2026 18:32:40 -0400 Subject: [PATCH 1/2] feat(resistance-v2): age-banded supply histogram (lever f, default-off) Replace the age-blind 130-week Res_hist histogram with a 20-price-bucket x 4-age-band histogram (0-26w / 26-78w / 78-130w / 130-520w), 80 band-major Res_hist columns. Age decay is applied at SCORE time via four Resistance_supply.config band-weight fields (Overlay_validator axes), not baked in at build time. Default weights [1;1;1;0] reproduce the pre-lever-f age-blind 130w histogram bit-identically. The warehouse reader detects v3 (20-col) vs v4 (80-col) width and packs v3 into the youngest band, so existing v3 warehouses keep scoring identically with no rebuild. Warehouse v4 rebuild deferred pending the bundle verdict. Co-Authored-By: Claude Fable 5 --- dev/status/resistance-v2.md | 12 ++ .../resistance/lib/resistance_supply.ml | 78 ++++++++-- .../resistance/lib/resistance_supply.mli | 65 ++++++-- .../resistance/test/test_resistance_supply.ml | 143 +++++++++++++++++- .../lib/resistance_sketch.ml | 48 ++++-- .../lib/resistance_sketch.mli | 26 ++-- .../test/test_resistance_sketch.ml | 42 ++++- .../test/test_stock_analysis.ml | 8 +- .../snapshot/lib/snapshot_schema.ml | 4 +- .../snapshot/lib/snapshot_schema.mli | 49 ++++-- .../data_panel/snapshot/test/test_snapshot.ml | 4 +- .../snapshot/test/test_snapshot_schema.ml | 18 ++- .../gen/lib/live_resistance_sketch.ml | 16 +- .../gen/test/test_live_resistance_sketch.ml | 5 +- .../strategy/lib/resistance_sketch_reader.ml | 35 ++++- .../strategy/lib/resistance_sketch_reader.mli | 20 ++- .../test/test_resistance_sketch_reader.ml | 79 ++++++++-- 17 files changed, 552 insertions(+), 100 deletions(-) diff --git a/dev/status/resistance-v2.md b/dev/status/resistance-v2.md index cabf62330..15ff7a3d2 100644 --- a/dev/status/resistance-v2.md +++ b/dev/status/resistance-v2.md @@ -198,6 +198,18 @@ load-bearing; binary grade → searchable weight; kill the 5h armed-run wall). bump + one full warehouse rebuild. Gate to build: lever (c)'s floor surface first; build (f) only if the floor verdict shows the mechanism wants real age structure (e.g. optimal floors regime-unstable). + **CODE LANDED 2026-07-19 (PR `feat/resistance-v2-age-bands`, default-off):** + schema `Res_hist` is now 80 band-major cells (4 age bands × 20 price + buckets, `Snapshot_schema.n_age_bands`/`n_hist_cells`); the pipeline + accumulates the 520-week histogram into age bands; `Resistance_supply` + collapses bands via four `config.band_weight_*` fields + (`[@sexp.default 1/1/1/0]`, Overlay_validator axes) at score time — default + weights `[1;1;1;0]` reproduce the pre-lever-f age-blind 130w histogram + bit-identically (pinned by `test_default_collapse_sums_recent_bands`). The + warehouse reader detects v3 (20-column) vs v4 (80-column) width and packs + v3 into the youngest band, so **existing v3 warehouses keep scoring + identically with NO rebuild** (`hist_bands_of_legacy`). The v4 warehouse + rebuild is DEFERRED pending the bundle verdict — no rebuild in this PR. 4. dedup-v2 warehouse deletable (v3 certified bit-identical: `scenarios-2026-07-16-131756` baseline = Run D to 13 decimals). diff --git a/trading/analysis/weinstein/resistance/lib/resistance_supply.ml b/trading/analysis/weinstein/resistance/lib/resistance_supply.ml index 93db065ec..95f3af36c 100644 --- a/trading/analysis/weinstein/resistance/lib/resistance_supply.ml +++ b/trading/analysis/weinstein/resistance/lib/resistance_supply.ml @@ -1,12 +1,19 @@ open Core open Weinstein_types +(* Age bands (lever f), youngest first, locked to [Snapshot_schema] band + boundaries [0-26w / 26-78w / 78-130w / 130-520w]. Bands 0..[_n_recent_bands-1] + union to the pre-lever-f trailing-130w histogram window; band 3 is the + 130-520w extension, weighted 0 by default so scoring is unchanged. *) +let n_age_bands = 4 +let _n_recent_bands = 3 + type sketch = { max_high_130w : float; max_high_260w : float; max_high_520w : float; bars_seen : float; - hist : float array; + hist_bands : float array array; anchor_close : float; } @@ -20,6 +27,10 @@ type config = { insufficient_score : float; heavy_resistance_bars : int; moderate_resistance_bars : int; + band_weight_0_26w : float; [@sexp.default 1.0] + band_weight_26_78w : float; [@sexp.default 1.0] + band_weight_78_130w : float; [@sexp.default 1.0] + band_weight_130_520w : float; [@sexp.default 0.0] } [@@deriving sexp] @@ -35,8 +46,49 @@ let default_config = insufficient_score = 0.5; heavy_resistance_bars = 8; moderate_resistance_bars = 3; + (* No-op defaults (experiment-flag-discipline R1): the three 0-130w bands + weighted 1.0 and the 130-520w band 0.0 collapses the age-banded histogram + back to the pre-lever-f age-blind 130w histogram, bit-identically. *) + band_weight_0_26w = 1.0; + band_weight_26_78w = 1.0; + band_weight_78_130w = 1.0; + band_weight_130_520w = 0.0; } +let _band_weights (config : config) = + [| + config.band_weight_0_26w; + config.band_weight_26_78w; + config.band_weight_78_130w; + config.band_weight_130_520w; + |] + +(* Pack a legacy age-blind histogram (the v3 warehouse / pre-lever-f shape) into + the age-banded layout: all mass in the youngest band, the rest zero. With + [default_config] band weights ([1;1;1;0]) the collapsed effective histogram + equals [flat] exactly, so a v3 warehouse scores bit-identically. *) +let hist_bands_of_legacy flat = + let n = Array.length flat in + Array.init n_age_bands ~f:(fun b -> + if b = 0 then Array.copy flat else Array.create ~len:n 0.0) + +(* Collapse the age-banded histogram into one effective per-bucket vector by + applying the per-band config weights: [effective.(k) = Σ_b w_b * bands_b.(k)]. + Non-finite cells count as 0 (same guard the per-bucket scorer applies). *) +let _effective_hist ~(config : config) ~hist_bands = + let weights = _band_weights config in + let n_buckets = + Array.fold hist_bands ~init:0 ~f:(fun acc band -> + Int.max acc (Array.length band)) + in + let eff = Array.create ~len:n_buckets 0.0 in + Array.iteri hist_bands ~f:(fun b band -> + let w = if b < Array.length weights then weights.(b) else 0.0 in + Array.iteri band ~f:(fun k c -> + let c = if Float.is_finite c then c else 0.0 in + eff.(k) <- eff.(k) +. (w *. c))); + eff + type result = { score : float; recent_weighted_bars : float; @@ -110,11 +162,12 @@ let _insufficient ~(config : config) = } let _scored ~(config : config) ~(sketch : sketch) ~breakout_price = + let effective = _effective_hist ~config ~hist_bands:sketch.hist_bands in let k_min = - _first_bucket ~n_buckets:(Array.length sketch.hist) + _first_bucket ~n_buckets:(Array.length effective) ~anchor:sketch.anchor_close ~breakout:breakout_price in - let weighted, max_bucket = _recent_supply ~config ~hist:sketch.hist ~k_min in + let weighted, max_bucket = _recent_supply ~config ~hist:effective ~k_min in let quality = _quality_of ~config ~sketch ~breakout_price ~max_bucket in let score = (* When the histogram holds mass at/above the breakout it speaks for @@ -145,12 +198,19 @@ let is_virgin ~(sketch : sketch) ~breakout_price = && Float.is_finite breakout_price && Float.(breakout_price >= sketch.max_high_520w) -(* Closing-basis "new high ground": no weekly bar in the trailing histogram - window sits at/above the current close (every [hist] bin is 0). Robust to - the own-week-high artifact that makes [is_virgin] unsatisfiable on a - close-anchored breakout price (see .mli — AXTI 2026-01-06). A non-finite bin - fails the [= 0.0] test, so no explicit finiteness check on [hist] is needed. *) +(* Closing-basis "new high ground": no weekly bar in the trailing 130-week + histogram window (age bands 0..[_n_recent_bands-1], which union to that + window) sits at/above the current close — every recent-band bin is 0. The + 130-520w band is ignored, keeping this predicate bit-identical to its + pre-lever-f 130w semantics (and to a v3 warehouse whose mass all lands in + band 0). Weight-independent by design — a structural emptiness test, not a + scoring measure. Robust to the own-week-high artifact that makes [is_virgin] + unsatisfiable on a close-anchored breakout price (see .mli — AXTI + 2026-01-06). A non-finite bin fails the [= 0.0] test, so no explicit + finiteness check on the bins is needed. *) let is_clear_of_supply ~(sketch : sketch) = _sketch_is_finite sketch && Float.(sketch.bars_seen > 0.0) - && Array.for_all sketch.hist ~f:(fun count -> Float.(count = 0.0)) + && Array.for_alli sketch.hist_bands ~f:(fun b band -> + b >= _n_recent_bands + || Array.for_all band ~f:(fun count -> Float.(count = 0.0))) diff --git a/trading/analysis/weinstein/resistance/lib/resistance_supply.mli b/trading/analysis/weinstein/resistance/lib/resistance_supply.mli index ff262baa5..9871a3565 100644 --- a/trading/analysis/weinstein/resistance/lib/resistance_supply.mli +++ b/trading/analysis/weinstein/resistance/lib/resistance_supply.mli @@ -18,16 +18,34 @@ type sketch = { max_high_260w : float; (** Same, 260 weekly bars. *) max_high_520w : float; (** Same, 520 weekly bars. *) bars_seen : float; (** True weekly-bar count available (capped 520). *) - hist : float array; - (** Trailing-130w histogram: [hist.(k)] counts weekly bars whose - mid-price lies in [anchor * 2^(k/n), anchor * 2^((k+1)/n)) where - [n = Array.length hist] buckets span one doubling. *) + hist_bands : float array array; + (** Age-banded histogram (lever f): [hist_bands.(b).(k)] counts weekly + bars of age band [b] (0..{!n_age_bands}-1, youngest first — + [0-26w / 26-78w / 78-130w / 130-520w]) whose mid-price lies in + [anchor * 2^(k/n), anchor * 2^((k+1)/n)) where [n] is the per-band + bucket count. {!analyze} collapses the bands into one effective + histogram via the [config] band weights at score time. A v3 warehouse + / age-blind histogram maps to the youngest band with the rest zero — + see {!hist_bands_of_legacy}. *) anchor_close : float; (** The raw close the histogram was anchored at (the sketch row's own close). *) } (** One symbol-day's sketch cells, as stored in the warehouse columns. *) +val n_age_bands : int +(** [n_age_bands] is the number of {!sketch.hist_bands} age bands (4). Locked to + [Snapshot_schema.n_age_bands] (the pure scoring layer cannot depend on the + snapshot layer, so the two are pinned equal by test). *) + +val hist_bands_of_legacy : float array -> float array array +(** [hist_bands_of_legacy flat] packs a legacy age-blind histogram (the v3 + warehouse / pre-lever-f 130-week shape) into the {!n_age_bands}-band layout: + all mass in the youngest band, the remaining bands zero. Under + {!default_config} band weights ([1;1;1;0]) the effective histogram + {!analyze} collapses equals [flat] bit-for-bit, so a v3 warehouse scores + identically to before lever f. *) + type config = { proximity_decay : float; (** Multiplicative weight decay per bucket above the breakout: supply [j] @@ -54,6 +72,21 @@ type config = { (** Grade derivation: max single-bucket count at/above the breakout that classifies as [Heavy_resistance] (mirrors v1). *) moderate_resistance_bars : int; (** Same for [Moderate_resistance]. *) + band_weight_0_26w : float; [@sexp.default 1.0] + (** Lever f: score-time weight for the 0-26w age band. Defaults to 1.0 + (the pre-lever-f no-op). *) + band_weight_26_78w : float; [@sexp.default 1.0] + (** Score-time weight for the 26-78w age band. Default 1.0. *) + band_weight_78_130w : float; [@sexp.default 1.0] + (** Score-time weight for the 78-130w age band. Default 1.0. *) + band_weight_130_520w : float; [@sexp.default 0.0] + (** Score-time weight for the 130-520w age band. Defaults to 0.0 so the + band is inert; the three 0-130w bands at weight 1.0 reproduce the + pre-lever-f age-blind 130w histogram exactly. Each band weight is an + [Overlay_validator] axis (a real [config] sub-field), so the age decay + is searchable without a warehouse rebuild (experiment-flag-discipline + R1/R2). The four fields carry [@sexp.default]s so a config sexp + written before lever f (without them) still round-trips. *) } [@@deriving sexp] (** [@@deriving sexp] so this config can be nested (as an [option]) inside @@ -64,7 +97,8 @@ type config = { val default_config : config (** No-op-adjacent defaults: decay 0.7, saturation 8 bars, floors 0.4 / 0.25 / 0.1, [min_history_bars = 0], insufficient score 0.5, grade thresholds 8 / 3 - (v1 parity). All searchable; none hardcoded at use sites. *) + (v1 parity), age-band weights 1 / 1 / 1 / 0 (collapses to the pre-lever-f + age-blind 130w histogram). All searchable; none hardcoded at use sites. *) type result = { score : float; @@ -85,9 +119,12 @@ val analyze : config:config -> sketch:sketch -> breakout_price:float -> result (** [analyze ~config ~sketch ~breakout_price] scores the overhead supply a breakout at [breakout_price] faces. - Score composition (plan §D5): - - recent component: proximity-weighted histogram mass at/above the breakout, - saturated at [saturation_bars]; + Score composition (plan §D5). The age bands are first collapsed into one + effective per-bucket histogram by the [config] band weights + ([effective.(k) = Σ_b w_b * hist_bands.(b).(k)]); the rest scores that + effective histogram exactly as before lever f: + - recent component: proximity-weighted effective-histogram mass at/above the + breakout, saturated at [saturation_bars]; - horizon floors: ONLY when the histogram holds no mass at/above the breakout but a max-high proves overhead exists, the score falls back to [recent_far_floor] / [stale_mid_floor] / [stale_old_floor] by the age of @@ -126,10 +163,14 @@ val is_virgin : sketch:sketch -> breakout_price:float -> bool val is_clear_of_supply : sketch:sketch -> bool (** [is_clear_of_supply ~sketch] is [true] iff the sketch is finite, - [bars_seen > 0], and EVERY [hist] bin is [0] — i.e. zero measured overhead - mass: no prior (finalized) weekly bar in the trailing 130-week histogram - window has a high above the current close whose mid-price - ([(high + low) / 2]) falls at or above it. The histogram producer gates on + [bars_seen > 0], and EVERY bin of the {b 0-130w age bands} (bands [0..2], + which union to the trailing-130w window) is [0] — i.e. zero measured + overhead mass: no prior (finalized) weekly bar in the trailing 130-week + histogram window has a high above the current close whose mid-price + ([(high + low) / 2]) falls at or above it. The 130-520w band is ignored so + this predicate keeps its exact pre-lever-f 130w semantics (and a v3 + warehouse, whose mass all lands in band 0, gives the same answer); + weight-independent by design. The histogram producer gates on [weekly_high > anchor] and buckets by the mid-price (see [Snapshot_pipeline.Resistance_sketch] and its .mli — the source of truth). This is exactly the recent-overhead mass {!analyze} scores as its histogram diff --git a/trading/analysis/weinstein/resistance/test/test_resistance_supply.ml b/trading/analysis/weinstein/resistance/test/test_resistance_supply.ml index d2800dc06..048d9f595 100644 --- a/trading/analysis/weinstein/resistance/test/test_resistance_supply.ml +++ b/trading/analysis/weinstein/resistance/test/test_resistance_supply.ml @@ -6,6 +6,13 @@ module Resistance_sketch = Snapshot_pipeline.Resistance_sketch let _config = Resistance_supply.default_config let _n_buckets = 20 +let _n_bands = Resistance_supply.n_age_bands + +(* A single 20-bucket price histogram with [counts] placed. *) +let _band counts = + let a = Array.create ~len:_n_buckets 0.0 in + List.iter counts ~f:(fun (k, c) -> a.(k) <- c); + a (* A sketch with no overhead anywhere: every max-high below the breakout. *) let _virgin_sketch = @@ -14,19 +21,28 @@ let _virgin_sketch = max_high_260w = 95.0; max_high_520w = 99.0; bars_seen = 520.0; - hist = Array.create ~len:_n_buckets 0.0; + hist_bands = Resistance_supply.hist_bands_of_legacy (_band []); anchor_close = 100.0; } +(* Legacy age-blind histogram (all mass in the youngest band) at [max_high]. *) let _with_hist ?(max_high = 150.0) counts = - let hist = Array.create ~len:_n_buckets 0.0 in - List.iter counts ~f:(fun (k, c) -> hist.(k) <- c); { _virgin_sketch with max_high_130w = max_high; max_high_260w = max_high; max_high_520w = max_high; - hist; + hist_bands = Resistance_supply.hist_bands_of_legacy (_band counts); + } + +(* A sketch with an explicit 4-band histogram (each element a 20-bucket band). *) +let _with_bands ?(max_high = 150.0) bands = + { + _virgin_sketch with + max_high_130w = max_high; + max_high_260w = max_high; + max_high_520w = max_high; + hist_bands = bands; } let test_virgin_scores_zero _ = @@ -216,6 +232,7 @@ let _v2_quality bars ~breakout_price = let weekly_prefix = Weekly_prefix.build bars_arr in let sketch = Resistance_sketch.compute ~weekly_prefix ~bars_arr in let i = Array.length bars_arr - 1 in + let n_buckets = Array.length sketch.hist / _n_bands in let result = Resistance_supply.analyze ~config:_config ~sketch: @@ -224,7 +241,10 @@ let _v2_quality bars ~breakout_price = max_high_260w = sketch.max_high_260w.(i); max_high_520w = sketch.max_high_520w.(i); bars_seen = sketch.bars_seen.(i); - hist = Array.map sketch.hist ~f:(fun row -> row.(i)); + hist_bands = + Array.init _n_bands ~f:(fun b -> + Array.init n_buckets ~f:(fun bucket -> + sketch.hist.((b * n_buckets) + bucket).(i))); anchor_close = bars_arr.(i).Types.Daily_price.close_price; } ~breakout_price @@ -328,6 +348,112 @@ let test_own_week_high_divergence _ = Resistance_supply.is_clear_of_supply ~sketch:axti_shape ) (equal_to (false, true)) +(* ------------------------------------------------------------------ *) +(* Age-banded histogram (lever f): default bit-identical + the lever. *) +(* ------------------------------------------------------------------ *) + +(* Default parity: at default weights ([1;1;1;0]) the effective histogram is the + SUM of the three 0-130w bands. 3 bars in bucket 0 split one-per-recent-band + score identically to the same 3 bars packed into the youngest band (the v3 + layout) — both the moderate 3/8 score. This is the "v3-shaped sketch scores + identically" parity: summing bands 0-2 = the pre-lever-f age-blind hist. *) +let test_default_collapse_sums_recent_bands _ = + let split = + [| _band [ (0, 1.0) ]; _band [ (0, 1.0) ]; _band [ (0, 1.0) ]; _band [] |] + in + let legacy = Resistance_supply.hist_bands_of_legacy (_band [ (0, 3.0) ]) in + let r_split = + Resistance_supply.analyze ~config:_config ~sketch:(_with_bands split) + ~breakout_price:100.0 + in + let r_legacy = + Resistance_supply.analyze ~config:_config ~sketch:(_with_bands legacy) + ~breakout_price:100.0 + in + assert_that (r_split, r_legacy) + (all_of + [ + field + (fun ((s : Resistance_supply.result), _) -> s.score) + (float_equal 0.375); + field + (fun (_, (l : Resistance_supply.result)) -> l.score) + (float_equal 0.375); + field + (fun ((s : Resistance_supply.result), _) -> s.quality) + (equal_to Weinstein_types.Moderate_resistance); + ]) + +(* Default inert 130-520w band: 8 bars living ONLY in the stale band contribute + nothing to the effective histogram (weight 0), so the score falls to the + horizon floor proven by the max-highs, not the saturated 1.0. *) +let test_stale_band_inert_at_default _ = + let bands = [| _band []; _band []; _band []; _band [ (0, 8.0) ] |] in + let result = + Resistance_supply.analyze ~config:_config ~sketch:(_with_bands bands) + ~breakout_price:100.0 + in + assert_that result + (all_of + [ + field + (fun (r : Resistance_supply.result) -> r.recent_weighted_bars) + (float_equal 0.0); + field + (fun (r : Resistance_supply.result) -> r.score) + (float_equal _config.recent_far_floor); + ]) + +(* The lever works: arming the 130-520w band weight makes its 8 bars saturate + the score to 1.0 (Heavy), proving the age decay is a real score-time knob. *) +let test_stale_band_weight_activates _ = + let bands = [| _band []; _band []; _band []; _band [ (0, 8.0) ] |] in + let config = { _config with band_weight_130_520w = 1.0 } in + let result = + Resistance_supply.analyze ~config ~sketch:(_with_bands bands) + ~breakout_price:100.0 + in + assert_that result + (all_of + [ + field (fun (r : Resistance_supply.result) -> r.score) (float_equal 1.0); + field + (fun (r : Resistance_supply.result) -> r.quality) + (equal_to Weinstein_types.Heavy_resistance); + ]) + +(* R1/R2 sexp back-compat: a config sexp written before lever f (lacking the + four band-weight fields) still parses, the [@sexp.default]s filling in the + no-op weights (1 / 1 / 1 / 0). *) +let test_config_parses_without_band_weights _ = + let stripped = + match Resistance_supply.sexp_of_config _config with + | Sexp.List fields -> + Sexp.List + (List.filter fields ~f:(function + | Sexp.List (Sexp.Atom k :: _) -> + not (String.is_prefix k ~prefix:"band_weight") + | _ -> true)) + | other -> other + in + let parsed = Resistance_supply.config_of_sexp stripped in + assert_that + (parsed.band_weight_0_26w, parsed.band_weight_130_520w) + (all_of + [ + field (fun (a, _) -> a) (float_equal 1.0); + field (fun (_, b) -> b) (float_equal 0.0); + ]) + +(* [is_clear_of_supply] ignores the 130-520w band: mass there alone is still + "clear" (recent overhead empty), keeping the virgin-readmission lever's 130w + semantics unchanged. *) +let test_is_clear_ignores_stale_band _ = + let bands = [| _band []; _band []; _band []; _band [ (0, 8.0) ] |] in + assert_that + (Resistance_supply.is_clear_of_supply ~sketch:(_with_bands bands)) + (equal_to true) + let suite = "Resistance_supply tests" >::: [ @@ -347,6 +473,13 @@ let suite = "is_virgin agrees with analyze" >:: test_is_virgin_agrees_with_analyze; "is_clear_of_supply" >:: test_is_clear_of_supply; "own-week-high divergence" >:: test_own_week_high_divergence; + "default collapse sums recent bands" + >:: test_default_collapse_sums_recent_bands; + "stale band inert at default" >:: test_stale_band_inert_at_default; + "stale band weight activates" >:: test_stale_band_weight_activates; + "config parses without band weights" + >:: test_config_parses_without_band_weights; + "is_clear ignores stale band" >:: test_is_clear_ignores_stale_band; ] let () = run_test_tt_main suite diff --git a/trading/analysis/weinstein/snapshot_pipeline/lib/resistance_sketch.ml b/trading/analysis/weinstein/snapshot_pipeline/lib/resistance_sketch.ml index 974a7f6f1..fd4b6b15f 100644 --- a/trading/analysis/weinstein/snapshot_pipeline/lib/resistance_sketch.ml +++ b/trading/analysis/weinstein/snapshot_pipeline/lib/resistance_sketch.ml @@ -15,10 +15,31 @@ type t = { let _horizon_130_weeks = 130 let _horizon_260_weeks = 260 let _horizon_520_weeks = 520 -let _hist_lookback_weeks = 130 + +(* Age-banded histogram (lever f): the histogram now looks back the full 520 + weekly bars, split into four age bands by a weekly bar's age relative to the + row (the partial current week is age 0, the most recent finalized week age 1, + ...). Band boundaries are half-open: [0,26) / [26,78) / [78,130) / [130,520). + The three 0-130w bands union to the pre-lever-f trailing-130w window, so + summing them reproduces the old age-blind histogram exactly. *) +let _hist_lookback_weeks = 520 +let _age_break_26_weeks = 26 +let _age_break_78_weeks = 78 +let _age_break_130_weeks = 130 let _bars_seen_cap = 520 let _ln2 = Float.log 2.0 +(* Age band index (0..n_age_bands-1) for a weekly bar of [age] weeks. *) +let _age_band_of ~age = + if age < _age_break_26_weeks then 0 + else if age < _age_break_78_weeks then 1 + else if age < _age_break_130_weeks then 2 + else 3 + +(* Column index of price bucket [bucket] within age band [band] in the + band-major [Res_hist] layout (matches [Snapshot_schema] cell ordering). *) +let _cell_index ~band ~bucket = (band * Snapshot_schema.n_hist_buckets) + bucket + (* Pop deque entries whose high is <= the incoming one, then push [j]. Keeps [dq] a decreasing-highs monotonic deque of finalized indices. *) let _push_monotonic dq (highs : float array) j = @@ -71,15 +92,17 @@ let _bucket_of ~anchor ~mid = in if Float.is_finite k then Some (Int.of_float k) else None -(* Count one weekly bar into [hist] at day [i] when it sits above [anchor] - and its mid lands in a canonical bucket. Mirrors the v1 mapper's - accumulation rule: [high > breakout] gates, the mid-price buckets. *) -let _accumulate_hist ~hist ~i ~anchor ~weekly_high ~weekly_low = +(* Count one weekly bar of age [band] into [hist] at day [i] when it sits above + [anchor] and its mid lands in a canonical bucket. Mirrors the v1 mapper's + accumulation rule: [high > breakout] gates, the mid-price buckets; the age + band selects which of the four band-major column groups the count lands in. *) +let _accumulate_hist ~hist ~i ~band ~anchor ~weekly_high ~weekly_low = if Float.(weekly_high > anchor) then let mid = (weekly_high +. weekly_low) /. 2.0 in match _bucket_of ~anchor ~mid with - | Some k when k >= 0 && k < Snapshot_schema.n_hist_buckets -> - hist.(k).(i) <- hist.(k).(i) +. 1.0 + | Some bucket when bucket >= 0 && bucket < Snapshot_schema.n_hist_buckets -> + let cell = _cell_index ~band ~bucket in + hist.(cell).(i) <- hist.(cell).(i) +. 1.0 | _ -> () let _hist_for_day ~hist ~(weekly_prefix : Weekly_prefix.t) ~i ~anchor = @@ -87,12 +110,16 @@ let _hist_for_day ~hist ~(weekly_prefix : Weekly_prefix.t) ~i ~anchor = let fc = weekly_prefix.finalized_count_at_day.(i) in let m_fin = Int.min (_hist_lookback_weeks - 1) fc in for j = fc - m_fin to fc - 1 do - _accumulate_hist ~hist ~i ~anchor + (* Finalized week [j] is [fc - j] weeks before the current partial week. *) + let band = _age_band_of ~age:(fc - j) in + _accumulate_hist ~hist ~i ~band ~anchor ~weekly_high:fin.(j).Types.Daily_price.high_price ~weekly_low:fin.(j).Types.Daily_price.low_price done; + (* The partial (current) week is age 0 -> youngest band. *) let p = weekly_prefix.partial_per_day.(i) in - _accumulate_hist ~hist ~i ~anchor ~weekly_high:p.Types.Daily_price.high_price + _accumulate_hist ~hist ~i ~band:0 ~anchor + ~weekly_high:p.Types.Daily_price.high_price ~weekly_low:p.Types.Daily_price.low_price (* Corrupt-anchor day: every sketch cell for day [i] degrades to NaN. *) @@ -119,8 +146,7 @@ let _bars_seen_column ~fc_at_day = Float.of_int (Int.min (fc + 1) _bars_seen_cap)) let _empty_hist ~n = - Array.init Snapshot_schema.n_hist_buckets ~f:(fun _ -> - Array.create ~len:n 0.0) + Array.init Snapshot_schema.n_hist_cells ~f:(fun _ -> Array.create ~len:n 0.0) let compute ~(weekly_prefix : Weekly_prefix.t) ~(bars_arr : Types.Daily_price.t array) = diff --git a/trading/analysis/weinstein/snapshot_pipeline/lib/resistance_sketch.mli b/trading/analysis/weinstein/snapshot_pipeline/lib/resistance_sketch.mli index fd5ada218..2724f9030 100644 --- a/trading/analysis/weinstein/snapshot_pipeline/lib/resistance_sketch.mli +++ b/trading/analysis/weinstein/snapshot_pipeline/lib/resistance_sketch.mli @@ -20,12 +20,19 @@ 130/260/520 weekly bars ending at day [i] (partial week included). - [bars_seen].(i): true weekly-bar count available at day [i], capped at 520 — the honest [Insufficient_history] input. - - [hist].(k).(i): count of weekly bars among the trailing 130 whose - mid-price [(high + low) / 2] lies in the log band - [C * 2^(k/20), C * 2^((k+1)/20)) above the day's raw close [C], and - whose high exceeds [C]. Buckets past - [Snapshot_schema.n_hist_buckets - 1] (supply more than 2x above [C]) - are dropped. + - [hist].(cell).(i): {b age-banded} count at daily index [i]. The + [Snapshot_schema.n_hist_cells] rows are band-major: cell + [band * n_hist_buckets + bucket] holds age band [band] and price bucket + [bucket]. The four age bands cover a weekly bar's age relative to day [i] + (age 0 = the partial current week, age 1 = the most recent finalized + week, ...): [0-26w / 26-78w / 78-130w / 130-520w] (half-open). Within a + band, [bucket] counts weekly bars whose mid-price [(high + low) / 2] lies + in the log band [C * 2^(bucket/20), C * 2^((bucket+1)/20)) above the day's + raw close [C] and whose high exceeds [C]. Bucket indices past + [Snapshot_schema.n_hist_buckets - 1] (supply more than 2x above [C]) are + dropped. Summing the three 0-130w bands reproduces the pre-lever-f + age-blind trailing-130w histogram exactly; the 130-520w band measures + older supply the horizon max-highs previously only floored. Corrupt-bar guard: when day [i]'s raw close is non-positive or non-finite, every sketch cell at [i] is [Float.nan]. @@ -41,9 +48,10 @@ type t = { max_high_520w : float array; bars_seen : float array; hist : float array array; - (** [hist.(k).(i)] = bucket [k]'s count at daily index [i]; the first - dimension has {!Data_panel_snapshot.Snapshot_schema.n_hist_buckets} - rows. *) + (** [hist.(cell).(i)] = band-major cell [cell]'s count at daily index [i]; + the first dimension has + {!Data_panel_snapshot.Snapshot_schema.n_hist_cells} rows (cell + [band * n_hist_buckets + bucket]). *) } (** Per-day sketch arrays, each aligned to the daily bar array (index [i] = day [i]). *) diff --git a/trading/analysis/weinstein/snapshot_pipeline/test/test_resistance_sketch.ml b/trading/analysis/weinstein/snapshot_pipeline/test/test_resistance_sketch.ml index 045631bcc..22e7fd3d5 100644 --- a/trading/analysis/weinstein/snapshot_pipeline/test/test_resistance_sketch.ml +++ b/trading/analysis/weinstein/snapshot_pipeline/test/test_resistance_sketch.ml @@ -170,9 +170,15 @@ let test_histogram_buckets _ = in let sketch, _, _ = _compute bars in let last = (5 * 5) - 1 in + (* All five weeks are age < 130 at the final day, so per-bucket the SUM over + age bands reproduces the pre-lever-f age-blind histogram. *) let counts = - List.init Snapshot_schema.n_hist_buckets ~f:(fun k -> - sketch.hist.(k).(last)) + List.init Snapshot_schema.n_hist_buckets ~f:(fun bucket -> + List.sum + (module Float) + (List.init Snapshot_schema.n_age_bands ~f:Fn.id) + ~f:(fun band -> + sketch.hist.((band * Snapshot_schema.n_hist_buckets) + bucket).(last))) in let expected = List.init Snapshot_schema.n_hist_buckets ~f:(fun k -> @@ -180,6 +186,37 @@ let test_histogram_buckets _ = in assert_that counts (elements_are expected) +(* Age banding (lever f): a resistance spike at week 0 of a 200-week series is + ~200 weeks old at the final day, so it lands in the 130-520w age band (band + 3) — the histogram now MEASURES old supply the pre-lever-f 130w window + dropped. Non-spike weeks have high = close (5), so they are gated out + ([weekly_high > anchor] fails). Recent bands (0-2) are therefore empty and + band 3 holds exactly the one spike bar. *) +let test_age_bands_separate_old_supply _ = + let week_shape w = + if w = 0 then Some (7.0, 6.0) (* mid 6.5 over anchor 5 -> bucket 7 *) + else Some (5.0, 4.0) + (* high = anchor -> not counted *) + in + let sketch, _, bars_arr = _compute (_weeks_bars ~n_weeks:200 ~week_shape) in + let last = Array.length bars_arr - 1 in + let n_buckets = Snapshot_schema.n_hist_buckets in + let band_total band = + List.sum + (module Float) + (List.init n_buckets ~f:Fn.id) + ~f:(fun bucket -> sketch.hist.((band * n_buckets) + bucket).(last)) + in + let recent_total = List.sum (module Float) [ 0; 1; 2 ] ~f:band_total in + assert_that + (recent_total, band_total 3, sketch.hist.((3 * n_buckets) + 7).(last)) + (all_of + [ + field (fun (r, _, _) -> r) (float_equal 0.0); + field (fun (_, s, _) -> s) (float_equal 1.0); + field (fun (_, _, b) -> b) (float_equal 1.0); + ]) + let test_corrupt_close_degrades_to_nan _ = let bars = List.init 5 ~f:(fun d -> @@ -412,6 +449,7 @@ let suite = >:: test_rolling_max_windows_and_eviction; "bars_seen counts weeks" >:: test_bars_seen_counts_weeks; "histogram buckets" >:: test_histogram_buckets; + "age bands separate old supply" >:: test_age_bands_separate_old_supply; "corrupt close degrades to NaN" >:: test_corrupt_close_degrades_to_nan; "virgin parity with v1 mapper" >:: test_virgin_parity_with_v1_mapper; "pipeline populates sketch columns" diff --git a/trading/analysis/weinstein/stock_analysis/test/test_stock_analysis.ml b/trading/analysis/weinstein/stock_analysis/test/test_stock_analysis.ml index 2bd2d1ee0..288f166bf 100644 --- a/trading/analysis/weinstein/stock_analysis/test/test_stock_analysis.ml +++ b/trading/analysis/weinstein/stock_analysis/test/test_stock_analysis.ml @@ -605,7 +605,8 @@ let make_sketch () : Resistance_supply.sketch = max_high_260w = 200.0; max_high_520w = 200.0; bars_seen = 200.0; - hist = Array.create ~len:20 0.0; + hist_bands = + Resistance_supply.hist_bands_of_legacy (Array.create ~len:20 0.0); anchor_close = 100.0; } @@ -663,7 +664,10 @@ let virgin_sketch () : Resistance_supply.sketch = let overhead_sketch () : Resistance_supply.sketch = let hist = Array.create ~len:20 0.0 in hist.(0) <- 5.0; - { (make_sketch ()) with hist } + { + (make_sketch ()) with + hist_bands = Resistance_supply.hist_bands_of_legacy hist; + } (** Run [analyze_with_callbacks] over rising bars with [config] and a [get_sketch] closure returning [sketch_opt]; return [t.virgin_readmission]. diff --git a/trading/trading/data_panel/snapshot/lib/snapshot_schema.ml b/trading/trading/data_panel/snapshot/lib/snapshot_schema.ml index 4906e41fa..adf558ebd 100644 --- a/trading/trading/data_panel/snapshot/lib/snapshot_schema.ml +++ b/trading/trading/data_panel/snapshot/lib/snapshot_schema.ml @@ -22,6 +22,8 @@ type field = [@@deriving sexp, compare, equal, show] let n_hist_buckets = 20 +let n_age_bands = 4 +let n_hist_cells = n_age_bands * n_hist_buckets let all_fields = [ @@ -43,7 +45,7 @@ let all_fields = Res_max_high_520w; Res_bars_seen; ] - @ List.init n_hist_buckets ~f:(fun k -> Res_hist k) + @ List.init n_hist_cells ~f:(fun k -> Res_hist k) let field_name = function | EMA_50 -> "EMA_50" diff --git a/trading/trading/data_panel/snapshot/lib/snapshot_schema.mli b/trading/trading/data_panel/snapshot/lib/snapshot_schema.mli index c6ce4f675..2e68431e6 100644 --- a/trading/trading/data_panel/snapshot/lib/snapshot_schema.mli +++ b/trading/trading/data_panel/snapshot/lib/snapshot_schema.mli @@ -64,9 +64,11 @@ Precomputed point-in-time overhead-supply sketches, appended after [Adjusted_close] (same append discipline as the OHLCV addition; schema - width grows from 13 to 37). All values are weekly-cadence aggregates + width grows from 13 to 97: 4 scalar sketch columns + [n_hist_cells = 80] + age-banded histogram columns). All values are weekly-cadence aggregates computed causally from bars up to and including the row's day — see - [dev/plans/resistance-v2-supply-sketches-2026-07-15.md] §D1-D4: + [dev/plans/resistance-v2-supply-sketches-2026-07-15.md] §D1-D4 and the + age-banded histogram (lever f, sketch v3): - {!Res_max_high_130w} / {!Res_max_high_260w} / {!Res_max_high_520w}: maximum raw weekly high over the trailing 130/260/520 weekly bars @@ -79,13 +81,24 @@ - {!Res_bars_seen}: true count of weekly bars available up to the row's day, capped at 520 — the honest [Insufficient_history] input (a window-starved warehouse can no longer masquerade as virgin history). - - {!Res_hist k} for [k = 0 .. n_hist_buckets - 1]: trailing 130-weekly-bar - log-price histogram anchored at the row's raw close [C]. Bucket [k] - counts weekly bars whose mid-price [(high + low) / 2] falls in - [C * 2^(k/20), C * 2^((k+1)/20)) and whose high exceeds [C] — i.e. - supply sitting 0..100% above the row's price, ~3.5% per band. Bars - more than 2x above [C] are dropped (proximity-negligible; the max-high - family still detects non-virgin at any distance). + - {!Res_hist k} for [k = 0 .. n_hist_cells - 1]: {b age-banded} + log-price histogram anchored at the row's raw close [C]. The [n_hist_cells] + columns are laid out band-major: cell [k] holds age band + [k / n_hist_buckets] and price bucket [k mod n_hist_buckets]. The four + age bands (youngest first) cover a weekly-bar age relative to the row of + [0-26w / 26-78w / 78-130w / 130-520w] (half-open; the partial current week + is age 0). Within every band price bucket [b] counts weekly bars whose + mid-price [(high + low) / 2] falls in + [C * 2^(b/20), C * 2^((b+1)/20)) and whose high exceeds [C] — i.e. supply + sitting 0..100% above the row's price, ~3.5% per band. Bars more than 2x + above [C] are dropped (proximity-negligible; the max-high family still + detects non-virgin at any distance). Age decay is applied at SCORE time + by [Resistance_supply] per-band config weights, NOT baked in at build + time, so the decay is an [Overlay_validator] axis family (no warehouse + rebuild per value). Summing the three 0-130w bands reproduces the + pre-lever-f age-blind 130-weekly-bar histogram exactly, and the 130-520w + band makes older supply MEASURED rather than only floored by the max-high + horizons. Sketch cells are [Float.nan] when the row's raw close is non-positive or non-finite (corrupt bar guard). *) @@ -111,9 +124,19 @@ type field = [@@deriving sexp, compare, equal, show] val n_hist_buckets : int -(** [n_hist_buckets] is the number of {!Res_hist} bucket columns in the - canonical schema (20). [Res_hist k] is canonical only for - [0 <= k < n_hist_buckets]; {!all_fields} enumerates exactly that range. *) +(** [n_hist_buckets] is the number of log-price buckets per age band in the + {!Res_hist} histogram (20). *) + +val n_age_bands : int +(** [n_age_bands] is the number of weekly-bar age bands the {!Res_hist} + histogram is split into (4): [0-26w / 26-78w / 78-130w / 130-520w]. *) + +val n_hist_cells : int +(** [n_hist_cells = n_age_bands * n_hist_buckets] is the total number of + {!Res_hist} columns in the canonical schema (80). [Res_hist k] is canonical + only for [0 <= k < n_hist_cells], laid out band-major (cell [k] is age band + [k / n_hist_buckets], price bucket [k mod n_hist_buckets]); {!all_fields} + enumerates exactly that range. *) val all_fields : field list (** [all_fields] enumerates every variant of {!field} in declaration order. Used @@ -144,7 +167,7 @@ val create : fields:field list -> t well-defined) but produces a schema that no real snapshot can match. *) val default : t -(** [default] is the canonical 37-field schema: every field in {!all_fields} +(** [default] is the canonical 97-field schema: every field in {!all_fields} order. The single source of truth for snapshots produced by the offline pipeline. *) diff --git a/trading/trading/data_panel/snapshot/test/test_snapshot.ml b/trading/trading/data_panel/snapshot/test/test_snapshot.ml index a143af24a..84380d6a4 100644 --- a/trading/trading/data_panel/snapshot/test/test_snapshot.ml +++ b/trading/trading/data_panel/snapshot/test/test_snapshot.ml @@ -21,7 +21,9 @@ let test_create_ok _ = field (fun s -> Date.to_string s.Snapshot.date) (equal_to "2024-01-02"); - field (fun s -> Array.length s.Snapshot.values) (equal_to 37); + field + (fun s -> Array.length s.Snapshot.values) + (equal_to (Snapshot_schema.n_fields Snapshot_schema.default)); ])) let test_create_rejects_width_mismatch _ = diff --git a/trading/trading/data_panel/snapshot/test/test_snapshot_schema.ml b/trading/trading/data_panel/snapshot/test/test_snapshot_schema.ml index 6b7d77bd0..6cc47f1f6 100644 --- a/trading/trading/data_panel/snapshot/test/test_snapshot_schema.ml +++ b/trading/trading/data_panel/snapshot/test/test_snapshot_schema.ml @@ -48,10 +48,19 @@ let test_default_schema_locks_in_canonical_fields _ = equal_to Res_max_high_520w; equal_to Res_bars_seen; ] - @ List.init n_hist_buckets ~f:(fun k -> equal_to (Res_hist k)))) + @ List.init n_hist_cells ~f:(fun k -> equal_to (Res_hist k)))) let test_default_schema_n_fields _ = - assert_that (Snapshot_schema.n_fields Snapshot_schema.default) (equal_to 37) + assert_that (Snapshot_schema.n_fields Snapshot_schema.default) (equal_to 97) + +(* The age-banded histogram (lever f) is 4 age bands x 20 price buckets = 80 + Res_hist columns, laid out band-major. *) +let test_hist_layout_constants _ = + assert_that + ( Snapshot_schema.n_hist_buckets, + Snapshot_schema.n_age_bands, + Snapshot_schema.n_hist_cells ) + (equal_to (20, 4, 80)) (* The Phase A → Phase A.1 OHLCV addition deliberately bumps the schema hash — it is content-addressable, set-sensitive by construction. Pin both the @@ -104,7 +113,7 @@ let test_field_name_round_trip _ = equal_to "Res_max_high_520w"; equal_to "Res_bars_seen"; ] - @ List.init Snapshot_schema.n_hist_buckets ~f:(fun k -> + @ List.init Snapshot_schema.n_hist_cells ~f:(fun k -> equal_to (Printf.sprintf "Res_hist_%02d" k)))) let suite = @@ -116,7 +125,8 @@ let suite = >:: test_compute_hash_field_set_sensitive; "default schema locks in canonical fields" >:: test_default_schema_locks_in_canonical_fields; - "default schema n_fields = 37" >:: test_default_schema_n_fields; + "default schema n_fields = 97" >:: test_default_schema_n_fields; + "hist layout constants (20/4/80)" >:: test_hist_layout_constants; "default schema hash differs from pre-OHLCV" >:: test_default_schema_hash_pinned_for_canonical_set; "index_of present and absent" >:: test_index_of_present_and_absent; diff --git a/trading/trading/weinstein/snapshot/gen/lib/live_resistance_sketch.ml b/trading/trading/weinstein/snapshot/gen/lib/live_resistance_sketch.ml index 11adfbccb..684b0869d 100644 --- a/trading/trading/weinstein/snapshot/gen/lib/live_resistance_sketch.ml +++ b/trading/trading/weinstein/snapshot/gen/lib/live_resistance_sketch.ml @@ -2,18 +2,24 @@ open Core module Resistance_sketch = Snapshot_pipeline.Resistance_sketch (* Extract one day's scalar sketch (day index [i]) from the per-day sketch - columns. The column-major histogram [arrays.hist.(k).(i)] is transposed to - the per-bucket vector [Resistance_supply.sketch.hist], and the anchor is the - bar's raw close (the same value the snapshot [Close] column stores — see - [Resistance_sketch_reader]). *) + columns. The column-major band-major histogram [arrays.hist.(cell).(i)] is + transposed into the [Resistance_supply.sketch.hist_bands] age-band matrix + ([hist_bands.(band).(bucket)], cell [band * n_buckets + bucket]), and the + anchor is the bar's raw close (the same value the snapshot [Close] column + stores — see [Resistance_sketch_reader]). *) let _sketch_at ~(arrays : Resistance_sketch.t) ~(bars : Types.Daily_price.t array) ~i : Resistance_supply.sketch = + let n_bands = Resistance_supply.n_age_bands in + let n_buckets = Array.length arrays.hist / n_bands in { Resistance_supply.max_high_130w = arrays.max_high_130w.(i); max_high_260w = arrays.max_high_260w.(i); max_high_520w = arrays.max_high_520w.(i); bars_seen = arrays.bars_seen.(i); - hist = Array.map arrays.hist ~f:(fun col -> col.(i)); + hist_bands = + Array.init n_bands ~f:(fun band -> + Array.init n_buckets ~f:(fun bucket -> + arrays.hist.((band * n_buckets) + bucket).(i))); anchor_close = bars.(i).Types.Daily_price.close_price; } diff --git a/trading/trading/weinstein/snapshot/gen/test/test_live_resistance_sketch.ml b/trading/trading/weinstein/snapshot/gen/test/test_live_resistance_sketch.ml index bebbdf24f..d98933693 100644 --- a/trading/trading/weinstein/snapshot/gen/test/test_live_resistance_sketch.ml +++ b/trading/trading/weinstein/snapshot/gen/test/test_live_resistance_sketch.ml @@ -66,11 +66,12 @@ let test_sketch_pins_known_cells _ = (fun (s : Resistance_supply.sketch) -> s.anchor_close) (float_equal 120.0); field - (fun (s : Resistance_supply.sketch) -> s.hist.(0)) + (fun (s : Resistance_supply.sketch) -> s.hist_bands.(0).(0)) (float_equal 1.0); field (fun (s : Resistance_supply.sketch) -> - Array.fold s.hist ~init:0.0 ~f:( +. )) + Array.fold s.hist_bands ~init:0.0 ~f:(fun acc band -> + acc +. Array.fold band ~init:0.0 ~f:( +. ))) (float_equal 1.0); field (fun (s : Resistance_supply.sketch) -> s.bars_seen) diff --git a/trading/trading/weinstein/strategy/lib/resistance_sketch_reader.ml b/trading/trading/weinstein/strategy/lib/resistance_sketch_reader.ml index 23285fca1..83d3711f6 100644 --- a/trading/trading/weinstein/strategy/lib/resistance_sketch_reader.ml +++ b/trading/trading/weinstein/strategy/lib/resistance_sketch_reader.ml @@ -3,6 +3,33 @@ module Snapshot_bar_views = Snapshot_runtime.Snapshot_bar_views module Snapshot_callbacks = Snapshot_runtime.Snapshot_callbacks module Snapshot_schema = Data_panel_snapshot.Snapshot_schema +(* Reshape a flat band-major cell vector ([band * n_buckets + bucket]) into the + [Resistance_supply.sketch.hist_bands] age-band matrix. *) +let _bands_of_flat cells ~n_buckets = + Array.init Snapshot_schema.n_age_bands ~f:(fun band -> + Array.init n_buckets ~f:(fun bucket -> + cells.((band * n_buckets) + bucket))) + +(* Read the histogram, detecting the warehouse width. A v4 (age-banded) + warehouse carries [n_hist_cells] columns; a v3 warehouse carries only the + [n_hist_buckets] age-blind columns (its trailing [Res_hist] cells are absent, + so the probe read fails). The v3 histogram maps to the youngest age band via + [hist_bands_of_legacy], scoring bit-identically under default band weights — + so existing v3 warehouses keep working with no rebuild. *) +let _read_hist_bands ~read = + let n_buckets = Snapshot_schema.n_hist_buckets in + let n_cells = Snapshot_schema.n_hist_cells in + let read_cells n = + List.init n ~f:(fun k -> read (Snapshot_schema.Res_hist k)) |> Option.all + in + match read (Snapshot_schema.Res_hist (n_cells - 1)) with + | Some _ -> + Option.map (read_cells n_cells) ~f:(fun cells -> + _bands_of_flat (Array.of_list cells) ~n_buckets) + | None -> + Option.map (read_cells n_buckets) ~f:(fun flat -> + Resistance_supply.hist_bands_of_legacy (Array.of_list flat)) + let read_sketch ~(cb : Snapshot_callbacks.t) ~symbol ~as_of : Resistance_supply.sketch option = let read field = @@ -16,18 +43,14 @@ let read_sketch ~(cb : Snapshot_callbacks.t) ~symbol ~as_of : let%bind max_high_520w = read Snapshot_schema.Res_max_high_520w in let%bind bars_seen = read Snapshot_schema.Res_bars_seen in let%bind anchor_close = read Snapshot_schema.Close in - let%map hist = - List.init Snapshot_schema.n_hist_buckets ~f:(fun k -> - read (Snapshot_schema.Res_hist k)) - |> Option.all - in + let%map hist_bands = _read_hist_bands ~read in Resistance_supply. { max_high_130w; max_high_260w; max_high_520w; bars_seen; - hist = Array.of_list hist; + hist_bands; anchor_close; } diff --git a/trading/trading/weinstein/strategy/lib/resistance_sketch_reader.mli b/trading/trading/weinstein/strategy/lib/resistance_sketch_reader.mli index fbe7e6ae0..4c47b796c 100644 --- a/trading/trading/weinstein/strategy/lib/resistance_sketch_reader.mli +++ b/trading/trading/weinstein/strategy/lib/resistance_sketch_reader.mli @@ -14,11 +14,21 @@ val read_sketch : as_of:Core.Date.t -> Resistance_supply.sketch option (** [read_sketch ~cb ~symbol ~as_of] reads the sketch columns - ([Res_max_high_130/260/520w], [Res_bars_seen], [Res_hist k] for - [k = 0 .. n_hist_buckets - 1], and [Close] as the histogram anchor) at - [(symbol, as_of)]. Returns [None] if ANY required cell read fails (missing - row, a schema without the sketch columns, or a decode error) — a partial - read never fabricates a sketch. *) + ([Res_max_high_130/260/520w], [Res_bars_seen], the [Res_hist] histogram, and + [Close] as the histogram anchor) at [(symbol, as_of)]. Returns [None] if ANY + required scalar cell read fails (missing row, a schema without the sketch + columns, or a decode error) — a partial read never fabricates a sketch. + + {b Warehouse-width detection (v3 back-compat).} A v4 (age-banded) warehouse + carries [Snapshot_schema.n_hist_cells] histogram columns; the reader reads + all of them and reshapes into the {!Resistance_supply.sketch.hist_bands} + age-band matrix. An older v3 warehouse carries only the [n_hist_buckets] + age-blind columns (the trailing [Res_hist] cells are absent, so a probe read + of the last v4 cell fails); the reader falls back to reading those + [n_hist_buckets] cells and packs them into the youngest age band via + {!Resistance_supply.hist_bands_of_legacy}, which under default band weights + scores bit-identically to before lever f. Existing v3 warehouses therefore + keep working with no rebuild. *) val closure : ?snapshot_cb:Snapshot_callbacks.t -> diff --git a/trading/trading/weinstein/strategy/test/test_resistance_sketch_reader.ml b/trading/trading/weinstein/strategy/test/test_resistance_sketch_reader.ml index e10bac78a..e1159b8bb 100644 --- a/trading/trading/weinstein/strategy/test/test_resistance_sketch_reader.ml +++ b/trading/trading/weinstein/strategy/test/test_resistance_sketch_reader.ml @@ -5,9 +5,10 @@ bypass by injecting [get_sketch] directly: - [read_sketch]: every required cell [Ok] -> [Some] sketch with the read - field values ([anchor_close] from the raw [Close] column; [hist] length - [= Snapshot_schema.n_hist_buckets]); ANY required cell read failing - (scalar or a histogram bucket via [Option.all]) -> [None] — a partial read + field values ([anchor_close] from the raw [Close] column; the age-banded + histogram reshaped into [n_age_bands] bands of [n_hist_buckets], with the + v3-width fallback packing the 20 age-blind columns into the youngest + band); ANY required scalar cell read failing -> [None] — a partial read never fabricates a sketch (the read-layer expression of "a window-starved warehouse can no longer masquerade as virgin"). - [closure]: a [fun () -> None] thunk when [snapshot_cb] / [stock_symbol] is @@ -36,14 +37,23 @@ let stub_value : Snapshot_schema.field -> float = function | _ -> 0.0 (* A stub [Snapshot_callbacks.t] whose [read_field] returns [Ok (stub_value - field)], except any field in [fail_on] returns [Error NotFound]. The two - remaining closures are unused by [read_sketch] and fail loudly if touched. *) -let stub_cb ?(fail_on = []) () : Snapshot_callbacks.t = + field)], except any field in [fail_on] returns [Error NotFound]. [hist_width] + models a warehouse's [Res_hist] column count: reads of [Res_hist k] for + [k >= hist_width] return [Error] (a v3 warehouse has [hist_width = + n_hist_buckets], a v4 has [n_hist_cells]). The two remaining closures are + unused by [read_sketch] and fail loudly if touched. *) +let stub_cb ?(fail_on = []) ?(hist_width = Snapshot_schema.n_hist_cells) () : + Snapshot_callbacks.t = { Snapshot_callbacks.read_field = (fun ~symbol:_ ~date:_ ~field -> - if List.mem fail_on field ~equal:Snapshot_schema.equal_field then - Status.error_not_found "stub: field forced to fail" + let absent = + match field with + | Snapshot_schema.Res_hist k -> k >= hist_width + | _ -> false + in + if absent || List.mem fail_on field ~equal:Snapshot_schema.equal_field + then Status.error_not_found "stub: field forced to fail" else Ok (stub_value field)); read_field_history = (fun ~symbol:_ ~from:_ ~until:_ ~field:_ -> @@ -73,8 +83,10 @@ let empty_view : Snapshot_bar_views.weekly_view = n = 0; } -(* Happy path: all cells Ok -> Some sketch whose fields equal the stubbed reads, - incl. anchor_close from the raw Close column and hist length = n_hist_buckets. *) +(* Happy path (v4 warehouse): all 80 cells Ok -> Some sketch whose fields equal + the stubbed reads. The band-major histogram reshapes into [n_age_bands] bands + of [n_hist_buckets] each: cell [k] -> band [k / 20], bucket [k mod 20], so + [hist_bands.(0).(3)] = cell 3 = 3.0 and [hist_bands.(1).(0)] = cell 20 = 20. *) let test_read_sketch_all_ok_reads_fields _ = assert_that (Reader.read_sketch ~cb:(stub_cb ()) ~symbol:"AAPL" ~as_of) @@ -97,11 +109,50 @@ let test_read_sketch_all_ok_reads_fields _ = (fun (s : Resistance_supply.sketch) -> s.anchor_close) (float_equal 150.0); field - (fun (s : Resistance_supply.sketch) -> Array.length s.hist) + (fun (s : Resistance_supply.sketch) -> Array.length s.hist_bands) + (equal_to Snapshot_schema.n_age_bands); + field + (fun (s : Resistance_supply.sketch) -> + Array.length s.hist_bands.(0)) (equal_to Snapshot_schema.n_hist_buckets); field - (fun (s : Resistance_supply.sketch) -> s.hist.(3)) + (fun (s : Resistance_supply.sketch) -> s.hist_bands.(0).(3)) + (float_equal 3.0); + field + (fun (s : Resistance_supply.sketch) -> s.hist_bands.(1).(0)) + (float_equal 20.0); + field + (fun (s : Resistance_supply.sketch) -> s.hist_bands.(3).(0)) + (float_equal 60.0); + ])) + +(* v3 back-compat: a warehouse with only the 20 age-blind [Res_hist] columns + (the trailing v4 cells absent) reads via the width-detection fallback — the + 20 cells pack into the youngest age band, the rest zero. [hist_bands.(0).(3)] + = cell 3 = 3.0; the older bands are empty (a NaN/absent cell never + fabricated). This is the "v3-shaped sketch reads with no rebuild" gate. *) +let test_read_sketch_v3_width_packs_youngest_band _ = + assert_that + (Reader.read_sketch + ~cb:(stub_cb ~hist_width:Snapshot_schema.n_hist_buckets ()) + ~symbol:"AAPL" ~as_of) + (is_some_and + (all_of + [ + field + (fun (s : Resistance_supply.sketch) -> Array.length s.hist_bands) + (equal_to Snapshot_schema.n_age_bands); + field + (fun (s : Resistance_supply.sketch) -> s.hist_bands.(0).(3)) (float_equal 3.0); + field + (fun (s : Resistance_supply.sketch) -> + Array.count s.hist_bands.(1) ~f:(fun c -> Float.(c <> 0.0))) + (equal_to 0); + field + (fun (s : Resistance_supply.sketch) -> + Array.count s.hist_bands.(3) ~f:(fun c -> Float.(c <> 0.0))) + (equal_to 0); ])) (* A failing scalar cell collapses the whole read to None. *) @@ -154,8 +205,10 @@ let test_closure_reads_sketch_at_last_bar _ = let suite = "Resistance_sketch_reader" >::: [ - "read_sketch all Ok reads fields" + "read_sketch all Ok reads fields (v4)" >:: test_read_sketch_all_ok_reads_fields; + "read_sketch v3 width packs youngest band" + >:: test_read_sketch_v3_width_packs_youngest_band; "read_sketch scalar cell error -> None" >:: test_read_sketch_scalar_cell_error_none; "read_sketch hist cell error -> None" From 175512ff015a87101bdb41146b8ba235f159feb4 Mon Sep 17 00:00:00 2001 From: dayfine Date: Sun, 19 Jul 2026 19:08:09 -0400 Subject: [PATCH 2/2] =?UTF-8?q?fix(review):=20address=20QC=20rework=20iter?= =?UTF-8?q?ation=201=20=E2=80=94=20extract=20histogram=20reshape=20helper?= =?UTF-8?q?=20(nesting=20depth)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../gen/lib/live_resistance_sketch.ml | 25 +++++++++++-------- 1 file changed, 14 insertions(+), 11 deletions(-) diff --git a/trading/trading/weinstein/snapshot/gen/lib/live_resistance_sketch.ml b/trading/trading/weinstein/snapshot/gen/lib/live_resistance_sketch.ml index 684b0869d..43230301f 100644 --- a/trading/trading/weinstein/snapshot/gen/lib/live_resistance_sketch.ml +++ b/trading/trading/weinstein/snapshot/gen/lib/live_resistance_sketch.ml @@ -1,25 +1,28 @@ open Core module Resistance_sketch = Snapshot_pipeline.Resistance_sketch +(* Transpose day [i]'s band-major histogram columns [arrays.hist.(cell).(i)] + (cell [band * n_buckets + bucket]) into the [Resistance_supply.sketch] + age-band matrix [hist_bands.(band).(bucket)]. Extracted from [_sketch_at] to + keep that record literal within the nesting limit. *) +let _hist_bands_at ~(arrays : Resistance_sketch.t) ~i = + let n_bands = Resistance_supply.n_age_bands in + let n_buckets = Array.length arrays.hist / n_bands in + let cell ~band ~bucket = arrays.hist.((band * n_buckets) + bucket).(i) in + Array.init n_bands ~f:(fun band -> + Array.init n_buckets ~f:(fun bucket -> cell ~band ~bucket)) + (* Extract one day's scalar sketch (day index [i]) from the per-day sketch - columns. The column-major band-major histogram [arrays.hist.(cell).(i)] is - transposed into the [Resistance_supply.sketch.hist_bands] age-band matrix - ([hist_bands.(band).(bucket)], cell [band * n_buckets + bucket]), and the - anchor is the bar's raw close (the same value the snapshot [Close] column - stores — see [Resistance_sketch_reader]). *) + columns. The anchor is the bar's raw close (the same value the snapshot + [Close] column stores — see [Resistance_sketch_reader]). *) let _sketch_at ~(arrays : Resistance_sketch.t) ~(bars : Types.Daily_price.t array) ~i : Resistance_supply.sketch = - let n_bands = Resistance_supply.n_age_bands in - let n_buckets = Array.length arrays.hist / n_bands in { Resistance_supply.max_high_130w = arrays.max_high_130w.(i); max_high_260w = arrays.max_high_260w.(i); max_high_520w = arrays.max_high_520w.(i); bars_seen = arrays.bars_seen.(i); - hist_bands = - Array.init n_bands ~f:(fun band -> - Array.init n_buckets ~f:(fun bucket -> - arrays.hist.((band * n_buckets) + bucket).(i))); + hist_bands = _hist_bands_at ~arrays ~i; anchor_close = bars.(i).Types.Daily_price.close_price; }