From 9f2393ec6c8b3f4b0f982c1ab0ac695658b0a082 Mon Sep 17 00:00:00 2001 From: difan Date: Sun, 19 Jul 2026 19:58:59 -0400 Subject: [PATCH 1/6] feat(margin): M3a HTB/maintenance tier tables (portfolio lib) [wip] Co-Authored-By: Claude Fable 5 --- trading/trading/portfolio/lib/dune | 1 + .../trading/portfolio/lib/margin_config.ml | 22 ++++++++ .../trading/portfolio/lib/margin_config.mli | 54 +++++++++++++++++-- .../trading/portfolio/lib/portfolio_margin.ml | 34 ++++++++++-- .../portfolio/lib/portfolio_margin.mli | 23 +++++--- .../portfolio/lib/short_margin_tiers.ml | 23 ++++++++ .../portfolio/lib/short_margin_tiers.mli | 53 ++++++++++++++++++ 7 files changed, 195 insertions(+), 15 deletions(-) create mode 100644 trading/trading/portfolio/lib/short_margin_tiers.ml create mode 100644 trading/trading/portfolio/lib/short_margin_tiers.mli diff --git a/trading/trading/portfolio/lib/dune b/trading/trading/portfolio/lib/dune index ab350297e..70cc8ba86 100644 --- a/trading/trading/portfolio/lib/dune +++ b/trading/trading/portfolio/lib/dune @@ -9,6 +9,7 @@ portfolio_margin calculations split_event + short_margin_tiers margin_config) (preprocess (pps ppx_let ppx_deriving.show ppx_deriving.eq ppx_sexp_conv))) diff --git a/trading/trading/portfolio/lib/margin_config.ml b/trading/trading/portfolio/lib/margin_config.ml index bd586a08f..fe6a0c8ea 100644 --- a/trading/trading/portfolio/lib/margin_config.ml +++ b/trading/trading/portfolio/lib/margin_config.ml @@ -12,6 +12,8 @@ type t = { initial_margin_pct : float; maintenance_margin_pct : float; short_borrow_fee_annual_pct : float; + short_borrow_rate_tiers : Short_margin_tiers.tier list; [@sexp.default []] + short_maintenance_tiers : Short_margin_tiers.tier list; [@sexp.default []] } [@@deriving show, eq, sexp] @@ -27,9 +29,29 @@ let default_config = initial_margin_pct = default_initial_margin_pct; maintenance_margin_pct = default_maintenance_margin_pct; short_borrow_fee_annual_pct = default_short_borrow_fee_annual_pct; + short_borrow_rate_tiers = []; + short_maintenance_tiers = []; } let total_collateral_factor (cfg : t) : float = 1.0 +. cfg.initial_margin_pct +(* Annual borrow fee for a short marked at [price]: the price-tiered rate when + [short_borrow_rate_tiers] is armed, else the flat + [short_borrow_fee_annual_pct]. Empty table (the default) → flat fallback, + so a disarmed config is bit-identical to pre-M3a. *) +let borrow_fee_annual_for_price (cfg : t) ~(price : float) : float = + Short_margin_tiers.tier_value ~tiers:cfg.short_borrow_rate_tiers + ~flat_fallback:cfg.short_borrow_fee_annual_pct ~price + let daily_borrow_rate (cfg : t) : float = cfg.short_borrow_fee_annual_pct /. trading_days_per_year + +let daily_borrow_rate_for_price (cfg : t) ~(price : float) : float = + borrow_fee_annual_for_price cfg ~price /. trading_days_per_year + +(* Maintenance equity-ratio threshold for a short marked at [price]: the + price-tiered value when [short_maintenance_tiers] is armed, else the flat + [maintenance_margin_pct]. Empty table (the default) → flat fallback. *) +let maintenance_pct_for_price (cfg : t) ~(price : float) : float = + Short_margin_tiers.tier_value ~tiers:cfg.short_maintenance_tiers + ~flat_fallback:cfg.maintenance_margin_pct ~price diff --git a/trading/trading/portfolio/lib/margin_config.mli b/trading/trading/portfolio/lib/margin_config.mli index 19eab19bc..2814fbd06 100644 --- a/trading/trading/portfolio/lib/margin_config.mli +++ b/trading/trading/portfolio/lib/margin_config.mli @@ -30,13 +30,39 @@ type t = { short_borrow_fee_annual_pct : float; (** Annualized borrow fee charged on short notional (default [0.005] = 50 bps — liquid SP500 reference rate per issue #859). Accrued daily as - [notional * rate / trading_days_per_year]. *) + [notional * rate / trading_days_per_year]. This is the {b flat + fallback}: consulted for any short whose marked price is not covered + by a {!short_borrow_rate_tiers} band. *) + short_borrow_rate_tiers : Short_margin_tiers.tier list; + (** Hard-to-borrow price-tiered {b annual borrow rate} table (margin M3a), + default [[]] (empty). When empty, every short pays the flat + [short_borrow_fee_annual_pct] — bit-identical to pre-M3a. When armed, + a short marked below a band's [price_below] pays that band's rate + instead (low-priced HTB names carry higher rates); see + {!Short_margin_tiers.tier_value} for the piecewise-constant lookup and + [dev/notes/long-short-margin-mechanics-2026-06-12.md] §3 for the + economics. Searchable via the nested overlay key + [margin_config.short_borrow_rate_tiers] + ([.claude/rules/experiment-flag-discipline.md] R2). *) + short_maintenance_tiers : Short_margin_tiers.tier list; + (** Price-tiered {b maintenance equity ratio} table (margin M3a), default + [[]] (empty). When empty, every short uses the flat + [maintenance_margin_pct] — bit-identical to pre-M3a. When armed, + supersedes the flat 25% with the FINRA-style per-price schedule + (sub-$5 → 100%, ~$5-17 → ≈83%, ≥ ~$16.67 → 30% base), so low-priced + shorts are flagged for force-cover far sooner. See + {!Short_margin_tiers.tier_value} and + [dev/notes/long-short-margin-mechanics-2026-06-12.md] §1. Searchable + via the nested overlay key [margin_config.short_maintenance_tiers] + ([.claude/rules/experiment-flag-discipline.md] R2). *) } [@@deriving show, eq, sexp] val default_config : t (** Default config: margin off, 50% initial extra, 25% maintenance, 50bps annual - borrow fee. With [enabled = false] the other fields are dormant. *) + borrow fee, {b empty} borrow-rate and maintenance tier tables. With + [enabled = false] the other fields are dormant; with the tier tables empty + the tiered lookups fall back to the flat rates (M3a is default-off). *) val trading_days_per_year : float (** Conventional trading-day count (252) used to convert annual borrow fee to a @@ -51,4 +77,26 @@ val total_collateral_factor : t -> float val daily_borrow_rate : t -> float (** Per-trading-day borrow rate: - [short_borrow_fee_annual_pct /. trading_days_per_year]. *) + [short_borrow_fee_annual_pct /. trading_days_per_year]. The flat-fallback + daily rate; per-symbol tiered accrual should consume + {!daily_borrow_rate_for_price}. *) + +val borrow_fee_annual_for_price : t -> price:float -> float +(** Annual borrow-fee fraction for a short marked at [price] (margin M3a): + the price-tiered rate from {!short_borrow_rate_tiers} when a band covers + [price], else the flat {!short_borrow_fee_annual_pct}. An empty tier table + always returns the flat rate, so a disarmed config is bit-identical to + pre-M3a. *) + +val daily_borrow_rate_for_price : t -> price:float -> float +(** Per-trading-day borrow rate for a short marked at [price]: + [borrow_fee_annual_for_price cfg ~price /. trading_days_per_year]. Equals + {!daily_borrow_rate} for every price when {!short_borrow_rate_tiers} is + empty (the default). *) + +val maintenance_pct_for_price : t -> price:float -> float +(** Maintenance equity-ratio threshold for a short marked at [price] (margin + M3a): the price-tiered value from {!short_maintenance_tiers} when a band + covers [price], else the flat {!maintenance_margin_pct}. An empty tier table + always returns the flat threshold, so a disarmed config is bit-identical to + pre-M3a. *) diff --git a/trading/trading/portfolio/lib/portfolio_margin.ml b/trading/trading/portfolio/lib/portfolio_margin.ml index 485ba5c06..51113bc2c 100644 --- a/trading/trading/portfolio/lib/portfolio_margin.ml +++ b/trading/trading/portfolio/lib/portfolio_margin.ml @@ -135,13 +135,34 @@ let sum_short_notional (portfolio : Portfolio.t) market_prices : float = | Some price -> acc +. (Float.abs qty *. price) else acc) +(* One short position's daily borrow fee at its marked price. Uses the + price-tiered daily rate (M3a); with an empty tier table every price resolves + to the flat rate, so the per-position sum equals the legacy + [sum_short_notional * flat_daily_rate] bit-for-bit (distributivity). Longs + and shorts absent from the price list contribute nothing. *) +let _short_daily_borrow_fee ~(margin_config : Margin_config.t) ~price_map + (p : portfolio_position) : float = + let qty = Calculations.position_quantity p in + if Float.O.(qty >= 0.0) then 0.0 + else + match Map.find price_map p.symbol with + | None -> 0.0 + | Some price -> + let notional = Float.abs qty *. price in + notional *. Margin_config.daily_borrow_rate_for_price margin_config ~price + let accrue_daily_borrow_fee ~(margin_config : Margin_config.t) (portfolio : Portfolio.t) (market_prices : (symbol * price) list) : Portfolio.t = if not margin_config.enabled then portfolio else - let notional = sum_short_notional portfolio market_prices in - let fee = notional *. Margin_config.daily_borrow_rate margin_config in + let price_map = Map.of_alist_exn (module String) market_prices in + let fee = + List.sum + (module Float) + portfolio.positions + ~f:(_short_daily_borrow_fee ~margin_config ~price_map) + in { portfolio with current_cash = portfolio.current_cash -. fee; @@ -166,8 +187,13 @@ let _short_breaches_maintenance ~(margin_config : Margin_config.t) let ratio = _short_equity_ratio ~margin_config ~entry_avg_cost ~current_price in - if Float.O.(ratio < margin_config.maintenance_margin_pct) then Some p.symbol - else None + (* Price-tiered maintenance threshold (M3a): supersedes the flat + [maintenance_margin_pct] when [short_maintenance_tiers] is armed; an empty + table resolves to the flat threshold, so this is bit-identical to pre-M3a. *) + let threshold = + Margin_config.maintenance_pct_for_price margin_config ~price:current_price + in + if Float.O.(ratio < threshold) then Some p.symbol else None (* Per-position maintenance check. Long positions and shorts with no price in the mark list are ignored. *) diff --git a/trading/trading/portfolio/lib/portfolio_margin.mli b/trading/trading/portfolio/lib/portfolio_margin.mli index 940a1f0f0..1066fdbd9 100644 --- a/trading/trading/portfolio/lib/portfolio_margin.mli +++ b/trading/trading/portfolio/lib/portfolio_margin.mli @@ -82,12 +82,15 @@ val accrue_daily_borrow_fee : [accrued_borrow_fee]. No-op when [margin_config.enabled = false] or when the portfolio holds no short positions. - The fee is computed against current marked notional: - [sum_of_short_notional *. daily_borrow_rate], where - [daily_borrow_rate = short_borrow_fee_annual_pct /. trading_days_per_year] - (see {!Margin_config.daily_borrow_rate}). Symbols missing from the price - list are treated as zero-fee (the caller is expected to mark every short on - each trading-day tick — same convention as [Portfolio.mark_to_market]). *) + The fee is accrued {b per short position} at its marked price using the + price-tiered daily rate {!Margin_config.daily_borrow_rate_for_price} (margin + M3a): low-priced hard-to-borrow names can carry a higher rate. When + [margin_config.short_borrow_rate_tiers] is empty (the default) every price + resolves to the flat {!Margin_config.daily_borrow_rate}, so the per-position + sum equals the legacy [sum_of_short_notional *. daily_borrow_rate] + bit-for-bit. Symbols missing from the price list are treated as zero-fee + (the caller is expected to mark every short on each trading-day tick — same + convention as [Portfolio.mark_to_market]). *) val sum_short_notional : Portfolio.t -> (symbol * price) list -> float (** Sum of [|qty *. price|] across all currently-open short positions whose @@ -110,8 +113,12 @@ val check_maintenance_margin : {[ equity_ratio = (((1.0 +. initial_margin_pct) *. c0) -. p) /. p ]} - A position is flagged when - [equity_ratio < margin_config.maintenance_margin_pct]. + A position is flagged when [equity_ratio < threshold], where [threshold] is + the price-tiered {!Margin_config.maintenance_pct_for_price} for the short's + marked price (margin M3a) — so low-priced HTB shorts are flagged sooner. + When [margin_config.short_maintenance_tiers] is empty (the default) the + threshold resolves to the flat [maintenance_margin_pct], bit-identical to + pre-M3a. Equivalent trigger price: [p_trigger = c0 *. (1.0 +. initial_margin_pct) /. (1.0 +. diff --git a/trading/trading/portfolio/lib/short_margin_tiers.ml b/trading/trading/portfolio/lib/short_margin_tiers.ml new file mode 100644 index 000000000..15e7007ee --- /dev/null +++ b/trading/trading/portfolio/lib/short_margin_tiers.ml @@ -0,0 +1,23 @@ +(* Price-banded tier tables for short-side margin mechanics — see [.mli]. + + The lookup is a pure, order-independent, piecewise-constant selection: the + tightest band that still covers the marked price wins, falling back to the + caller's flat value (which is what makes an empty table a bit-identical + no-op). *) + +open Core + +type tier = { price_below : float; value : float } [@@deriving show, eq, sexp] + +let _covers ~price tier = Float.O.(price < tier.price_below) + +let tier_value ~(tiers : tier list) ~(flat_fallback : float) ~(price : float) : + float = + match List.filter tiers ~f:(_covers ~price) with + | [] -> flat_fallback + | covering -> + let tightest = + List.min_elt covering ~compare:(fun a b -> + Float.compare a.price_below b.price_below) + in + (Option.value_exn tightest).value diff --git a/trading/trading/portfolio/lib/short_margin_tiers.mli b/trading/trading/portfolio/lib/short_margin_tiers.mli new file mode 100644 index 000000000..42469afc0 --- /dev/null +++ b/trading/trading/portfolio/lib/short_margin_tiers.mli @@ -0,0 +1,53 @@ +(** Price-banded tier tables for short-side margin mechanics (margin M3a). + + Encodes the FINRA / broker reality that a short's borrow rate and its + maintenance requirement are {b price-tiered}: low-priced (hard-to-borrow) + names carry punitive rates and per-share maintenance floors, while liquid + higher-priced names sit on the base tier. See + [dev/notes/long-short-margin-mechanics-2026-06-12.md] §1 for the researched + numbers (sub-$5 → 100% maintenance, ~$5-17 → the $5/share floor ≈ 83%, + ≥ ~$16.67 → the 30% base tier). + + A tier table is a plain list of {!tier} bands. Lookup is piecewise-constant: + a symbol marked at [price] uses the {b tightest} band that still covers it + (the band with the smallest [price_below] strictly greater than [price]); + when no band covers the price, the caller's flat fallback applies. The table + is therefore order-independent and, crucially, {b an empty table is a no-op} + — [tier_value] returns the flat fallback unchanged, so a disarmed + {!Margin_config} reproduces the pre-M3a flat behaviour bit-for-bit. + + The thresholds themselves are {b not} baked into this module: it only + provides the lookup mechanism. Concrete tier values live in + default-disarmed example configs / tests, per + [.claude/rules/experiment-flag-discipline.md] R1 (default-off) — the code + ships an empty table. + + Pure. *) + +type tier = { + price_below : float; + (** Upper price bound (exclusive) of this band. A short marked strictly + below [price_below] is eligible for this band's [value]. *) + value : float; + (** The tier value — an annual borrow-fee fraction, or a maintenance + equity ratio, depending on which table this tier belongs to. *) +} +[@@deriving show, eq, sexp] + +val tier_value : tiers:tier list -> flat_fallback:float -> price:float -> float +(** [tier_value ~tiers ~flat_fallback ~price] resolves the tier value for a short + marked at [price]. + + Selects the band with the smallest [price_below] that is strictly greater + than [price] (the tightest covering band) and returns its [value]. When no + band covers [price] — including the {b empty-table case} — returns + [flat_fallback]. + + Examples with [tiers = [ {price_below=5.0; value=1.0}; + {price_below=17.0; value=0.83} ]] and [flat_fallback = 0.30]: + - [price = 3.0] → [1.0] (covered by the $5 band, the tightest) + - [price = 10.0] → [0.83] (only the $17 band covers it) + - [price = 20.0] → [0.30] (no band covers it → fallback) + + Order-independent: the tightest covering band is chosen regardless of list + order. Pure. *) From c2838951ddeb784dd14a694b674d55663a3d5f8e Mon Sep 17 00:00:00 2001 From: difan Date: Sun, 19 Jul 2026 20:04:20 -0400 Subject: [PATCH 2/6] feat(margin): M3a borrow-availability gate + tier tables + portfolio tests [wip] Co-Authored-By: Claude Fable 5 --- trading/trading/portfolio/test/dune | 4 + .../portfolio/test/test_margin_accounting.ml | 124 ++++++++++++++++++ .../portfolio/test/test_short_margin_tiers.ml | 59 +++++++++ .../weinstein/strategy/lib/entry_assembly.ml | 8 +- .../weinstein/strategy/lib/entry_assembly.mli | 7 +- .../strategy/lib/short_borrow_gate.ml | 24 ++++ .../strategy/lib/short_borrow_gate.mli | 57 ++++++++ .../strategy/lib/weinstein_strategy.mli | 9 ++ .../strategy/lib/weinstein_strategy_config.ml | 3 + .../lib/weinstein_strategy_config.mli | 21 +++ 10 files changed, 312 insertions(+), 4 deletions(-) create mode 100644 trading/trading/portfolio/test/test_short_margin_tiers.ml create mode 100644 trading/trading/weinstein/strategy/lib/short_borrow_gate.ml create mode 100644 trading/trading/weinstein/strategy/lib/short_borrow_gate.mli diff --git a/trading/trading/portfolio/test/dune b/trading/trading/portfolio/test/dune index 58a945e79..f643df9c3 100644 --- a/trading/trading/portfolio/test/dune +++ b/trading/trading/portfolio/test/dune @@ -12,6 +12,10 @@ (preprocess (pps ppx_test_matcher))) +(test + (name test_short_margin_tiers) + (libraries core ounit2 trading.portfolio matchers)) + (test (name test_margin_accounting) (libraries diff --git a/trading/trading/portfolio/test/test_margin_accounting.ml b/trading/trading/portfolio/test/test_margin_accounting.ml index db892c294..b4b8dc300 100644 --- a/trading/trading/portfolio/test/test_margin_accounting.ml +++ b/trading/trading/portfolio/test/test_margin_accounting.ml @@ -450,6 +450,118 @@ let test_sum_short_notional_combines_positions _ = (sum_short_notional portfolio [ ("AAPL", 60.0); ("MSFT", 110.0) ]) (float_equal 11_500.0) +(* ========================================================================== *) +(* Flag-on: HTB tiered borrow rate + tiered maintenance (margin M3a) *) +(* ========================================================================== *) + +(* A HTB borrow-rate table: sub-$17 names pay 50%/yr, everything else the flat + 50bps fallback. Sits in the test, not baked into code (R1). *) +let htb_borrow_config = + { + on_config with + Margin_config.short_borrow_rate_tiers = + [ { Trading_portfolio.Short_margin_tiers.price_below = 17.0; value = 0.50 } ]; + } + +(* Two shorts at $10 (HTB tier) and $50 (flat fallback). Each position pays its + own price-tiered rate; the total is their sum. *) +let _two_price_shorts () = + apply_trades_with_margin_exn ~margin_config:on_config + (create ~initial_cash:30_000.0 ()) + [ + make_trade ~id:"t1" ~order_id:"o1" ~symbol:"CHEAP" ~side:Sell + ~quantity:100.0 ~price:10.0 (); + make_trade ~id:"t2" ~order_id:"o2" ~symbol:"RICH" ~side:Sell + ~quantity:100.0 ~price:50.0 (); + ] + ~error_msg:"two shorts" + +let test_borrow_fee_tiered_charges_per_price _ = + let portfolio = _two_price_shorts () in + let prices = [ ("CHEAP", 10.0); ("RICH", 50.0) ] in + (* CHEAP: notional 1000 at 50%/yr; RICH: notional 5000 at flat 50bps. *) + let expected_fee = + (1_000.0 *. (0.50 /. Margin_config.trading_days_per_year)) + +. (5_000.0 *. (0.005 /. Margin_config.trading_days_per_year)) + in + assert_that + (accrue_daily_borrow_fee ~margin_config:htb_borrow_config portfolio prices) + (field (fun p -> p.accrued_borrow_fee) (float_equal ~epsilon:1e-12 expected_fee)) + +let test_borrow_fee_empty_tiers_bit_equal_flat _ = + (* Empty tier table (the default) → every short pays the flat rate, so the + per-position sum equals the legacy sum_short_notional * flat_daily_rate. *) + let portfolio = _two_price_shorts () in + let prices = [ ("CHEAP", 10.0); ("RICH", 50.0) ] in + let expected_fee = _expected_daily_fee 6_000.0 on_config in + assert_that + (accrue_daily_borrow_fee ~margin_config:on_config portfolio prices) + (field (fun p -> p.accrued_borrow_fee) (float_equal ~epsilon:1e-12 expected_fee)) + +(* A short 100 @ $10, marked $10: equity_ratio = (1.5*10 - 10)/10 = 0.5. Above + the flat 25% (not flagged), but below a 100% sub-$17 tier (flagged). *) +let _cheap_short () = + apply_trades_with_margin_exn ~margin_config:on_config + (create ~initial_cash:10_000.0 ()) + [ + make_trade ~id:"t1" ~order_id:"o1" ~symbol:"CHEAP" ~side:Sell + ~quantity:100.0 ~price:10.0 (); + ] + ~error_msg:"cheap short" + +let test_maintenance_flat_does_not_flag_cheap_short _ = + assert_that + (check_maintenance_margin ~margin_config:on_config (_cheap_short ()) + [ ("CHEAP", 10.0) ]) + (elements_are []) + +let test_maintenance_tiered_flags_cheap_short _ = + let tiered_config = + { + on_config with + Margin_config.short_maintenance_tiers = + [ + { Trading_portfolio.Short_margin_tiers.price_below = 17.0; value = 1.0 }; + ]; + } + in + assert_that + (check_maintenance_margin ~margin_config:tiered_config (_cheap_short ()) + [ ("CHEAP", 10.0) ]) + (elements_are [ equal_to "CHEAP" ]) + +let test_margin_config_round_trip_preserves_tiers _ = + let armed = + { + Margin_config.default_config with + Margin_config.short_borrow_rate_tiers = + [ { Trading_portfolio.Short_margin_tiers.price_below = 5.0; value = 1.0 } ]; + Margin_config.short_maintenance_tiers = + [ + { Trading_portfolio.Short_margin_tiers.price_below = 17.0; value = 0.83 }; + ]; + } + in + assert_that + (Margin_config.t_of_sexp (Margin_config.sexp_of_t armed)) + (equal_to (armed : Margin_config.t)) + +let test_pre_m3a_margin_config_sexp_parses_with_empty_tiers _ = + (* A pre-M3a margin_config sexp (no tier fields) must decode with empty + tables — the [@sexp.default []] back-compat contract. *) + let sexp = + Sexp.of_string + "((enabled true) (initial_margin_pct 0.5) (maintenance_margin_pct 0.25) \ + (short_borrow_fee_annual_pct 0.005))" + in + assert_that + (Margin_config.t_of_sexp sexp) + (all_of + [ + field (fun c -> c.Margin_config.short_borrow_rate_tiers) (size_is 0); + field (fun c -> c.Margin_config.short_maintenance_tiers) (size_is 0); + ]) + (* ========================================================================== *) (* Long-margin (levered long) accounting — margin M1b-2 *) (* ========================================================================== *) @@ -696,6 +808,18 @@ let suite = >:: test_borrow_fee_zero_when_no_shorts; "test_sum_short_notional_combines_positions" >:: test_sum_short_notional_combines_positions; + "test_borrow_fee_tiered_charges_per_price" + >:: test_borrow_fee_tiered_charges_per_price; + "test_borrow_fee_empty_tiers_bit_equal_flat" + >:: test_borrow_fee_empty_tiers_bit_equal_flat; + "test_maintenance_flat_does_not_flag_cheap_short" + >:: test_maintenance_flat_does_not_flag_cheap_short; + "test_maintenance_tiered_flags_cheap_short" + >:: test_maintenance_tiered_flags_cheap_short; + "test_margin_config_round_trip_preserves_tiers" + >:: test_margin_config_round_trip_preserves_tiers; + "test_pre_m3a_margin_config_sexp_parses_with_empty_tiers" + >:: test_pre_m3a_margin_config_sexp_parses_with_empty_tiers; "test_disarmed_over_cash_buy_still_rejected" >:: test_disarmed_over_cash_buy_still_rejected; "test_disarmed_sequence_bit_equal_to_legacy" diff --git a/trading/trading/portfolio/test/test_short_margin_tiers.ml b/trading/trading/portfolio/test/test_short_margin_tiers.ml new file mode 100644 index 000000000..5512d5cfa --- /dev/null +++ b/trading/trading/portfolio/test/test_short_margin_tiers.ml @@ -0,0 +1,59 @@ +open Core +open OUnit2 +open Matchers +module Tiers = Trading_portfolio.Short_margin_tiers + +(* Researched-style example table (kept in the test, not baked into code): + sub-$5 → 100%, $5-$17 → ≈83%, ≥ ~$17 → flat fallback (0.30 here). *) +let example_tiers = + [ + { Tiers.price_below = 5.0; value = 1.0 }; + { Tiers.price_below = 17.0; value = 0.83 }; + ] + +let flat_fallback = 0.30 + +let lookup price = Tiers.tier_value ~tiers:example_tiers ~flat_fallback ~price + +let test_empty_table_returns_fallback _ = + assert_that + (Tiers.tier_value ~tiers:[] ~flat_fallback ~price:3.0) + (float_equal flat_fallback) + +let test_tightest_band_wins_low_price _ = + (* $3 is covered by both bands ($5 and $17); the tightest ($5) wins. *) + assert_that (lookup 3.0) (float_equal 1.0) + +let test_middle_band _ = + (* $10 is covered only by the $17 band. *) + assert_that (lookup 10.0) (float_equal 0.83) + +let test_uncovered_price_uses_fallback _ = + (* $20 is above every band → flat fallback. *) + assert_that (lookup 20.0) (float_equal flat_fallback) + +let test_price_at_boundary_excludes_band _ = + (* price_below is exclusive: $5 exactly is NOT below the $5 band, so it + falls through to the $17 band. *) + assert_that (lookup 5.0) (float_equal 0.83) + +let test_order_independent _ = + (* Reversing the tier list yields the same tightest-band selection. *) + let reversed = List.rev example_tiers in + assert_that + (Tiers.tier_value ~tiers:reversed ~flat_fallback ~price:3.0) + (float_equal 1.0) + +let suite = + "short_margin_tiers" + >::: [ + "test_empty_table_returns_fallback" >:: test_empty_table_returns_fallback; + "test_tightest_band_wins_low_price" >:: test_tightest_band_wins_low_price; + "test_middle_band" >:: test_middle_band; + "test_uncovered_price_uses_fallback" >:: test_uncovered_price_uses_fallback; + "test_price_at_boundary_excludes_band" + >:: test_price_at_boundary_excludes_band; + "test_order_independent" >:: test_order_independent; + ] + +let () = run_test_tt_main suite diff --git a/trading/trading/weinstein/strategy/lib/entry_assembly.ml b/trading/trading/weinstein/strategy/lib/entry_assembly.ml index 32c318de9..362bb0201 100644 --- a/trading/trading/weinstein/strategy/lib/entry_assembly.ml +++ b/trading/trading/weinstein/strategy/lib/entry_assembly.ml @@ -12,5 +12,11 @@ let assemble ~config ~bar_reader ~current_date (screen_result : Screener.result) Declining_ma_gate.filter ~reject:config.reject_declining_ma_long_entry combined in - Entry_liquidity_gate.apply ~config:config.liquidity_config ~bar_reader + let combined = + Entry_liquidity_gate.apply ~config:config.liquidity_config ~bar_reader + ~current_date combined + in + Short_borrow_gate.apply + ~min_dollar_adv:config.short_borrow_min_dollar_adv + ~lookback_days:config.liquidity_config.adv_lookback_days ~bar_reader ~current_date combined diff --git a/trading/trading/weinstein/strategy/lib/entry_assembly.mli b/trading/trading/weinstein/strategy/lib/entry_assembly.mli index 5e0ef30af..d33a93e25 100644 --- a/trading/trading/weinstein/strategy/lib/entry_assembly.mli +++ b/trading/trading/weinstein/strategy/lib/entry_assembly.mli @@ -3,9 +3,10 @@ Pipeline: {!Short_side_gate.combine} (short-side enable + short-min-price) → {!Declining_ma_gate.filter} (drop misclassified declining-MA longs, - default-off) → {!Entry_liquidity_gate.apply} (dollar-ADV gate, default-off). - With every gate at its no-op default the result is the plain - [buy_candidates @ short_candidates], bit-identical to prior behaviour. *) + default-off) → {!Entry_liquidity_gate.apply} (dollar-ADV gate, default-off) → + {!Short_borrow_gate.apply} (short-side borrow-availability ADV floor, margin + M3a, default-off). With every gate at its no-op default the result is the + plain [buy_candidates @ short_candidates], bit-identical to prior behaviour. *) val assemble : config:Weinstein_strategy_config.config -> diff --git a/trading/trading/weinstein/strategy/lib/short_borrow_gate.ml b/trading/trading/weinstein/strategy/lib/short_borrow_gate.ml new file mode 100644 index 000000000..4ba37b0d1 --- /dev/null +++ b/trading/trading/weinstein/strategy/lib/short_borrow_gate.ml @@ -0,0 +1,24 @@ +open Core + +let _short_has_borrow ~min_dollar_adv ~dollar_adv_for + (c : Screener.scored_candidate) = + match c.side with + | Trading_base.Types.Long -> true (* borrow is a short-only concern *) + | Trading_base.Types.Short -> ( + match dollar_adv_for c.Screener.ticker with + | None -> true (* no reading -> never drop *) + | Some adv -> Float.( >= ) adv min_dollar_adv) + +let filter ~min_dollar_adv ~dollar_adv_for + (candidates : Screener.scored_candidate list) = + if Float.( <= ) min_dollar_adv 0.0 then candidates + else List.filter candidates ~f:(_short_has_borrow ~min_dollar_adv ~dollar_adv_for) + +let apply ~min_dollar_adv ~lookback_days ~bar_reader ~current_date candidates = + if Float.( <= ) min_dollar_adv 0.0 then candidates + else + let dollar_adv_for ticker = + Liquidity_metric.dollar_adv ~lookback_days + (Bar_reader.daily_bars_for bar_reader ~symbol:ticker ~as_of:current_date) + in + filter ~min_dollar_adv ~dollar_adv_for candidates diff --git a/trading/trading/weinstein/strategy/lib/short_borrow_gate.mli b/trading/trading/weinstein/strategy/lib/short_borrow_gate.mli new file mode 100644 index 000000000..827c5ef5f --- /dev/null +++ b/trading/trading/weinstein/strategy/lib/short_borrow_gate.mli @@ -0,0 +1,57 @@ +(** The [short_borrow_availability] short-entry gate (margin M3a). + + A faithful Weinstein short-side eligibility "dial" (default-off axis per + [.claude/rules/experiment-flag-discipline.md]) modelling {b borrow + availability}: a short can only be opened if shares are locatable to borrow. + We have no locate feed, so borrow supply is proxied by {b trailing + dollar-ADV} — a thinly-traded name has little float circulating and is the + canonical hard-to-borrow / no-locate case. Short candidates whose dollar-ADV + is below the floor are dropped ("no borrow available"); long candidates are + never affected (borrow is a short-only concern). + + The spine is untouched ([.claude/rules/weinstein-faithful-core.md] W1): this + only narrows which {e short} candidates are eligible, exactly as + {!Short_min_price_gate} narrows shorts by price and the liquidity overlay + narrows both sides by tradeable ADV. It composes {e after} those gates in + {!Entry_assembly}; a dropped short simply never reaches the entry walk (same + convention as the sibling assembly-stage gates — a per-candidate audit trace + would require threading the recorder into {!Entry_assembly}, a documented + follow-up seam, not this PR). + + {b Cadence caveat.} Borrow availability is modelled at weekly-screen cadence + off trailing daily bars; it cannot see an intraweek borrow recall or a gap + squeeze. Stress-path buy-in modelling is M3b / M4 territory, not this gate. + + Pure with respect to the supplied bar reader / lookup. *) + +open Core + +val filter : + min_dollar_adv:float -> + dollar_adv_for:(string -> float option) -> + Screener.scored_candidate list -> + Screener.scored_candidate list +(** [filter ~min_dollar_adv ~dollar_adv_for candidates] drops {b short} + candidates whose trailing dollar-ADV (via [dollar_adv_for candidate.ticker]) + is strictly below [min_dollar_adv] — i.e. no borrow is available. Long + candidates always pass. + + A short is {b kept} when [dollar_adv_for] returns [None] (no liquidity + reading — a missing reading must never drop a candidate) or [Some adv] with + [adv >= min_dollar_adv]. + + No-op when [min_dollar_adv <= 0.0] (the default): returns [candidates] + unchanged (bit-identical), so every existing golden/baseline replays + unchanged. Pure. See [Weinstein_strategy_config.short_borrow_min_dollar_adv]. *) + +val apply : + min_dollar_adv:float -> + lookback_days:int -> + bar_reader:Bar_reader.t -> + current_date:Date.t -> + Screener.scored_candidate list -> + Screener.scored_candidate list +(** Strategy-side adapter: builds the [dollar_adv_for] lookup from [bar_reader] + via {!Liquidity_metric.dollar_adv} over [lookback_days] of bars available at + [current_date] (no lookahead), then delegates to {!filter}. No-op at + [min_dollar_adv <= 0.0]. *) diff --git a/trading/trading/weinstein/strategy/lib/weinstein_strategy.mli b/trading/trading/weinstein/strategy/lib/weinstein_strategy.mli index 7e2fe0be4..78648ada4 100644 --- a/trading/trading/weinstein/strategy/lib/weinstein_strategy.mli +++ b/trading/trading/weinstein/strategy/lib/weinstein_strategy.mli @@ -314,6 +314,15 @@ type config = { ([dev/notes/long-short-margin-mechanics-2026-06-12.md]) as a default-off, searchable {!Walk_forward.Variant_matrix} axis. Not wired into any default config or preset. *) + short_borrow_min_dollar_adv : float; [@sexp.default 0.0] + (** Borrow-availability floor for short candidates (margin M3a): shorts + whose trailing dollar-ADV (no-lookahead, over {!liquidity_config}'s + lookback) is below this value are dropped as "no borrow available" + before the entry walk; longs are never affected. Default [0.0] = + no-op (bit-identical). A default-off, searchable + {!Walk_forward.Variant_matrix} axis; see + {!Weinstein_strategy_config.short_borrow_min_dollar_adv} and + {!Short_borrow_gate}. *) suppress_warmup_trading : bool; [@sexp.default true] (** When [true] (the default), the backtest runner suppresses all new position entries (long and short) before the measurement [start_date], diff --git a/trading/trading/weinstein/strategy/lib/weinstein_strategy_config.ml b/trading/trading/weinstein/strategy/lib/weinstein_strategy_config.ml index e3e76205a..4f4515b23 100644 --- a/trading/trading/weinstein/strategy/lib/weinstein_strategy_config.ml +++ b/trading/trading/weinstein/strategy/lib/weinstein_strategy_config.ml @@ -38,6 +38,8 @@ type config = { full_compute_tail_days : int option; enable_short_side : bool; [@sexp.default true] short_min_price : float; [@sexp.default 0.0] (** See [.mli]. *) + short_borrow_min_dollar_adv : float; [@sexp.default 0.0] + (** See [.mli]. *) suppress_warmup_trading : bool; [@sexp.default true] (** See [.mli]. *) stop_update_cadence : Stops_runner.stop_update_cadence; [@sexp.default Stops_runner.Daily] @@ -134,6 +136,7 @@ let default_config ~universe ~index_symbol = full_compute_tail_days = None; enable_short_side = true; short_min_price = 0.0; + short_borrow_min_dollar_adv = 0.0; suppress_warmup_trading = true; stop_update_cadence = Stops_runner.Daily; stage3_force_exit_config = Stage3_force_exit.default_config; diff --git a/trading/trading/weinstein/strategy/lib/weinstein_strategy_config.mli b/trading/trading/weinstein/strategy/lib/weinstein_strategy_config.mli index ceb15f6cf..21dd9b77e 100644 --- a/trading/trading/weinstein/strategy/lib/weinstein_strategy_config.mli +++ b/trading/trading/weinstein/strategy/lib/weinstein_strategy_config.mli @@ -39,6 +39,27 @@ type config = { carry 83–362% maintenance margin) as a default-off, searchable {!Walk_forward.Variant_matrix} axis. Not wired into any default config or preset. *) + short_borrow_min_dollar_adv : float; [@sexp.default 0.0] + (** Borrow-availability floor for short candidates (margin M3a): the + minimum trailing dollar-ADV a name must trade for its shares to be + considered locatable-to-borrow. Short candidates whose dollar-ADV + (computed from bars available at the screen date, no lookahead, over + {!liquidity_config}'s [adv_lookback_days]) is strictly below this value + are dropped as "no borrow available" before the entry walk; long + candidates are never affected (borrow is a short-only concern). + + Default [0.0] = no gating: {!Short_borrow_gate.filter} short-circuits + to the identity when [short_borrow_min_dollar_adv <= 0.0], so the + candidate list is bit-identical to prior behaviour and every existing + golden/baseline replays unchanged. + + We have no locate feed; dollar-ADV is the practical borrow-supply + proxy per [dev/notes/long-short-margin-mechanics-2026-06-12.md] §4 + item 6 (a thinly-traded name is the canonical hard-to-borrow case). + A default-off, searchable {!Walk_forward.Variant_matrix} axis; not + wired into any default config or preset. Bar-cadence caveat (intraweek + borrow recall / gap squeeze invisible; stress paths are M3b/M4) is + documented in {!Short_borrow_gate}. *) suppress_warmup_trading : bool; [@sexp.default true] (** When [true] (the default), the backtest runner suppresses all new position entries (long and short) before the measurement [start_date], From 1d17b7dd5c15f0ab9db2e9c7822b58a66d66af62 Mon Sep 17 00:00:00 2001 From: difan Date: Sun, 19 Jul 2026 20:08:10 -0400 Subject: [PATCH 3/6] feat(margin): M3a strategy-gate + config round-trip tests [wip] Co-Authored-By: Claude Fable 5 --- .../strategy/lib/weinstein_strategy.ml | 1 + .../strategy/lib/weinstein_strategy.mli | 5 + trading/trading/weinstein/strategy/test/dune | 1 + .../strategy/test/test_long_buying_power.ml | 43 ++++++++ .../strategy/test/test_short_borrow_gate.ml | 97 +++++++++++++++++++ 5 files changed, 147 insertions(+) create mode 100644 trading/trading/weinstein/strategy/test/test_short_borrow_gate.ml diff --git a/trading/trading/weinstein/strategy/lib/weinstein_strategy.ml b/trading/trading/weinstein/strategy/lib/weinstein_strategy.ml index 25d3c8ea2..5d3dc94ad 100644 --- a/trading/trading/weinstein/strategy/lib/weinstein_strategy.ml +++ b/trading/trading/weinstein/strategy/lib/weinstein_strategy.ml @@ -37,6 +37,7 @@ module Audit_recorder = Audit_recorder module Entry_audit_capture = Entry_audit_capture module Screening_notional = Screening_notional module Long_buying_power = Long_buying_power +module Short_borrow_gate = Short_borrow_gate module Exit_audit_capture = Exit_audit_capture include Weinstein_strategy_config module Weinstein_strategy_macro = Weinstein_strategy_macro diff --git a/trading/trading/weinstein/strategy/lib/weinstein_strategy.mli b/trading/trading/weinstein/strategy/lib/weinstein_strategy.mli index 78648ada4..cff330a86 100644 --- a/trading/trading/weinstein/strategy/lib/weinstein_strategy.mli +++ b/trading/trading/weinstein/strategy/lib/weinstein_strategy.mli @@ -200,6 +200,11 @@ module Long_buying_power = Long_buying_power primitives. Exposed so tests can pin the pure ceiling / interest math directly. See {!Long_buying_power}. *) +module Short_borrow_gate = Short_borrow_gate +(** Short-side borrow-availability entry gate (margin M3a): drops short + candidates whose trailing dollar-ADV is below the borrow-supply floor. + Exposed so tests can pin the pure {!Short_borrow_gate.filter} directly. *) + module Exit_audit_capture = Exit_audit_capture (** Exit-side trade-audit capture. Bridges [TriggerExit] transitions to {!Audit_recorder.exit_event}. See {!Exit_audit_capture}. *) diff --git a/trading/trading/weinstein/strategy/test/dune b/trading/trading/weinstein/strategy/test/dune index 44b11deff..ec24d4ddb 100644 --- a/trading/trading/weinstein/strategy/test/dune +++ b/trading/trading/weinstein/strategy/test/dune @@ -16,6 +16,7 @@ test_sector_rotation_weinstein_strategy test_short_side_bear_window test_short_min_price_gate + test_short_borrow_gate test_declining_ma_gate test_short_side_gate test_liquidity_metric diff --git a/trading/trading/weinstein/strategy/test/test_long_buying_power.ml b/trading/trading/weinstein/strategy/test/test_long_buying_power.ml index 87415d7cf..feecba641 100644 --- a/trading/trading/weinstein/strategy/test/test_long_buying_power.ml +++ b/trading/trading/weinstein/strategy/test/test_long_buying_power.ml @@ -111,8 +111,47 @@ let test_default_config_no_op_values _ = field (fun (c : Weinstein_strategy.config) -> c.maintenance_long_pct) (float_equal 0.0); + field + (fun (c : Weinstein_strategy.config) -> + c.short_borrow_min_dollar_adv) + (float_equal 0.0); ]) +let test_short_borrow_adv_round_trip _ = + (* The M3a borrow-availability floor survives sexp round-trip (R2 overlay + path). *) + let base = + Weinstein_strategy.default_config ~universe:[ "AAPL" ] ~index_symbol:"SPY" + in + let gated = { base with short_borrow_min_dollar_adv = 2_000_000.0 } in + assert_that + (Weinstein_strategy.config_of_sexp (Weinstein_strategy.sexp_of_config gated)) + (field + (fun (c : Weinstein_strategy.config) -> c.short_borrow_min_dollar_adv) + (float_equal 2_000_000.0)) + +let test_pre_m3a_sexp_parses_short_borrow_default _ = + (* A config sexp without short_borrow_min_dollar_adv must parse with the no-op + default 0.0 (experiment-flag-discipline R1). *) + let base = + Weinstein_strategy.default_config ~universe:[ "AAPL" ] ~index_symbol:"SPY" + in + let stripped = + match Weinstein_strategy.sexp_of_config base with + | Sexp.List fields -> + Sexp.List + (List.filter fields ~f:(function + | Sexp.List (Sexp.Atom k :: _) -> + not (String.equal k "short_borrow_min_dollar_adv") + | _ -> true)) + | other -> other + in + assert_that + (Weinstein_strategy.config_of_sexp stripped) + (field + (fun (c : Weinstein_strategy.config) -> c.short_borrow_min_dollar_adv) + (float_equal 0.0)) + let test_config_round_trip_preserves_values _ = (* A non-default levered config survives sexp round-trip (R2 overlay path). *) let base = @@ -206,6 +245,10 @@ let suite = >:: test_config_round_trip_preserves_values; "config: pre-M1 sexp parses with defaults" >:: test_pre_m1_sexp_parses_with_defaults; + "config: short_borrow_adv round-trip" + >:: test_short_borrow_adv_round_trip; + "config: pre-M3a sexp parses short_borrow default" + >:: test_pre_m3a_sexp_parses_short_borrow_default; ] let () = run_test_tt_main suite diff --git a/trading/trading/weinstein/strategy/test/test_short_borrow_gate.ml b/trading/trading/weinstein/strategy/test/test_short_borrow_gate.ml new file mode 100644 index 000000000..597e3f7be --- /dev/null +++ b/trading/trading/weinstein/strategy/test/test_short_borrow_gate.ml @@ -0,0 +1,97 @@ +(** Unit tests for the [short_borrow_availability] short-entry gate (margin M3a). + + Pins the no-op-default contract and the gating behaviour of + {!Weinstein_strategy.Short_borrow_gate.filter}: + + - [min_dollar_adv = 0.0] (default) → identity: every candidate is retained, + so the entry candidate list is bit-identical to prior behaviour and every + existing golden/baseline replays unchanged. + - A positive floor drops {b short} candidates whose dollar-ADV is below it + ("no borrow available"); long candidates are never affected. + - A [None] dollar-ADV reading never drops a candidate. *) + +open OUnit2 +open Core +open Matchers +open Weinstein_types +module Short_borrow_gate = Weinstein_strategy.Short_borrow_gate + +let _make_candidate ~ticker ~side : Screener.scored_candidate = + { + ticker; + analysis = + Stock_analysis.analyze ~config:Stock_analysis.default_config ~ticker + ~bars:[] ~benchmark_bars:[] ~prior_stage:None + ~as_of_date:(Date.of_string "2024-01-01"); + side; + sector = + { + sector_name = "Test"; + rating = Screener.Neutral; + stage = Stage2 { weeks_advancing = 5; late = false }; + }; + grade = C; + score = 0; + suggested_entry = 20.0; + suggested_stop = 21.6; + risk_pct = 0.08; + swing_target = None; + rationale = []; + } + +let _short ~ticker = _make_candidate ~ticker ~side:Trading_base.Types.Short +let _long ~ticker = _make_candidate ~ticker ~side:Trading_base.Types.Long + +(* Fixed ADV lookup: THIN trades $100k/day, THICK $5M/day, and NOREAD has no + reading. *) +let adv_for = function + | "THIN" -> Some 100_000.0 + | "THICK" -> Some 5_000_000.0 + | _ -> None + +let tickers result = List.map result ~f:(fun c -> c.Screener.ticker) + +let test_zero_floor_is_noop _ = + let candidates = [ _short ~ticker:"THIN"; _short ~ticker:"THICK" ] in + assert_that + (tickers + (Short_borrow_gate.filter ~min_dollar_adv:0.0 ~dollar_adv_for:adv_for + candidates)) + (elements_are [ equal_to "THIN"; equal_to "THICK" ]) + +let test_floor_drops_illiquid_short_keeps_liquid _ = + let candidates = [ _short ~ticker:"THIN"; _short ~ticker:"THICK" ] in + assert_that + (tickers + (Short_borrow_gate.filter ~min_dollar_adv:1_000_000.0 + ~dollar_adv_for:adv_for candidates)) + (elements_are [ equal_to "THICK" ]) + +let test_floor_never_drops_longs _ = + (* A LONG named THIN (below the floor) is retained — borrow is short-only. *) + let candidates = [ _long ~ticker:"THIN"; _short ~ticker:"THIN" ] in + assert_that + (tickers + (Short_borrow_gate.filter ~min_dollar_adv:1_000_000.0 + ~dollar_adv_for:adv_for candidates)) + (elements_are [ equal_to "THIN" ]) + +let test_missing_reading_keeps_short _ = + (* NOREAD has no dollar-ADV reading → kept (a missing reading never drops). *) + let candidates = [ _short ~ticker:"NOREAD" ] in + assert_that + (List.length + (Short_borrow_gate.filter ~min_dollar_adv:1_000_000.0 + ~dollar_adv_for:adv_for candidates)) + (equal_to 1) + +let () = + run_test_tt_main + ("short_borrow_gate" + >::: [ + "zero floor is a no-op" >:: test_zero_floor_is_noop; + "floor drops illiquid short, keeps liquid" + >:: test_floor_drops_illiquid_short_keeps_liquid; + "floor never drops longs" >:: test_floor_never_drops_longs; + "missing reading keeps short" >:: test_missing_reading_keeps_short; + ]) From b8d7b7392ffff9bb528d9bd8cb107f74c156aff2 Mon Sep 17 00:00:00 2001 From: difan Date: Sun, 19 Jul 2026 20:10:22 -0400 Subject: [PATCH 4/6] test(margin): M3a R2 axis-expansion tests for borrow floor + maintenance tier table Co-Authored-By: Claude Fable 5 --- .../walk_forward/test/test_variant_matrix.ml | 75 +++++++++++++++++++ 1 file changed, 75 insertions(+) diff --git a/trading/trading/backtest/walk_forward/test/test_variant_matrix.ml b/trading/trading/backtest/walk_forward/test/test_variant_matrix.ml index e6e7868aa..89e597934 100644 --- a/trading/trading/backtest/walk_forward/test/test_variant_matrix.ml +++ b/trading/trading/backtest/walk_forward/test/test_variant_matrix.ml @@ -344,6 +344,77 @@ let test_maintenance_long_pct_axis_expands _ = [ equal_to (Sexp.of_string "((maintenance_long_pct 0.25))") ]); ]) +(* Proves R2 (experiment-flag-discipline) for the M3a short borrow-availability + gate: [short_borrow_min_dollar_adv] is a real top-level float key on + [Weinstein_strategy.config] (same mechanism as [short_min_price]), so the + axis expands and passes [Overlay_validator] validation with no + overlay-validator change. The no-op default [0.0] and a positive ADV floor + sit on one axis. *) +let test_short_borrow_min_dollar_adv_axis_expands _ = + let axis = + VM.Key + { + path = [ "short_borrow_min_dollar_adv" ]; + values = Sexp.[ Atom "0.0"; Atom "1000000.0" ]; + } + in + let t = { VM.axes = [ axis ]; expansion = VM.Cartesian } in + assert_that (VM.expand t) + (elements_are + [ + field + (fun (v : WFR.variant) -> v.overrides) + (elements_are + [ equal_to (Sexp.of_string "((short_borrow_min_dollar_adv 0.0))") ]); + field + (fun (v : WFR.variant) -> v.overrides) + (elements_are + [ + equal_to + (Sexp.of_string + "((short_borrow_min_dollar_adv 1000000.0))"); + ]); + ]) + +(* Proves R2 (experiment-flag-discipline) for the M3a short maintenance tier + TABLE: [short_maintenance_tiers] is a real field on the nested + [margin_config] record, reached via the [margin_config.short_maintenance_tiers] + path, and its value is a sexp-valued tier list (mirrors the nested + record-valued [screening_config.weights.w_overhead_supply] axis). The axis + expands and passes [Overlay_validator] validation with no overlay-validator + change — the tier table is searchable the day it lands. *) +let test_short_maintenance_tiers_axis_expands _ = + let axis = + VM.Key + { + path = [ "margin_config"; "short_maintenance_tiers" ]; + values = + Sexp. + [ List []; of_string "(((price_below 17.0) (value 1.0)))" ]; + } + in + let t = { VM.axes = [ axis ]; expansion = VM.Cartesian } in + assert_that (VM.expand t) + (elements_are + [ + field + (fun (v : WFR.variant) -> v.overrides) + (elements_are + [ + equal_to + (Sexp.of_string "((margin_config ((short_maintenance_tiers ()))))"); + ]); + field + (fun (v : WFR.variant) -> v.overrides) + (elements_are + [ + equal_to + (Sexp.of_string + "((margin_config ((short_maintenance_tiers (((price_below \ + 17.0) (value 1.0)))))))"); + ]); + ]) + (* Proves R2 (experiment-flag-discipline) for the [suppress_warmup_trading] warmup-trading gate (#1549 A2): it is a real top-level bool flag on [Weinstein_strategy.config] (same mechanism as [neutral_blocks_longs]), so @@ -583,6 +654,10 @@ let suite = >:: test_short_min_price_axis_expands; "w_overhead_supply nested weight axis expands + validates" >:: test_w_overhead_supply_weight_axis_expands; + "short_borrow_min_dollar_adv axis expands" + >:: test_short_borrow_min_dollar_adv_axis_expands; + "short_maintenance_tiers axis expands" + >:: test_short_maintenance_tiers_axis_expands; "short_sleeve_fraction flag axis expands" >:: test_short_sleeve_fraction_axis_expands; "maintenance_long_pct axis expands" From 84db993961704a21525aa83400e38e744f9cc64a Mon Sep 17 00:00:00 2001 From: difan Date: Sun, 19 Jul 2026 20:26:06 -0400 Subject: [PATCH 5/6] chore(margin): apply dune fmt + M3a status update Co-Authored-By: Claude Fable 5 --- dev/status/margin-realism.md | 59 ++++++++++++++++++- .../walk_forward/test/test_variant_matrix.ml | 13 ++-- .../trading/portfolio/lib/margin_config.mli | 10 ++-- .../trading/portfolio/lib/portfolio_margin.ml | 3 +- .../portfolio/lib/short_margin_tiers.mli | 20 +++---- .../portfolio/test/test_margin_accounting.ml | 32 ++++++++-- .../portfolio/test/test_short_margin_tiers.ml | 10 ++-- .../weinstein/strategy/lib/entry_assembly.ml | 3 +- .../weinstein/strategy/lib/entry_assembly.mli | 9 +-- .../strategy/lib/short_borrow_gate.ml | 4 +- .../strategy/lib/short_borrow_gate.mli | 17 +++--- .../strategy/lib/weinstein_strategy.mli | 4 +- .../strategy/lib/weinstein_strategy_config.ml | 3 +- .../lib/weinstein_strategy_config.mli | 10 ++-- .../strategy/test/test_long_buying_power.ml | 3 +- .../strategy/test/test_short_borrow_gate.ml | 3 +- 16 files changed, 144 insertions(+), 59 deletions(-) diff --git a/dev/status/margin-realism.md b/dev/status/margin-realism.md index 18a4dd03a..8405a514a 100644 --- a/dev/status/margin-realism.md +++ b/dev/status/margin-realism.md @@ -150,7 +150,64 @@ and M1b (follow-up). - Also fixed a #2005 QC follow-up: `portfolio_summary.mli` / `metric_types.mli` now qualify the `portfolio_value - current_cash` identity as cash-account-only (a long-margin debit shifts the split by `+ long_margin_debit`). -- **M3** — short-side squeeze robustness (borrow availability, HTB tiers, buy-in). +- [x] **M3a — borrow availability + HTB/maintenance tier tables (default-off).** + Branch `feat/margin-m3a-borrow-htb`. The deterministic half of M3's short-side + squeeze robustness: three default-off mechanisms, each R1 no-op at its default, + each an R2-searchable axis. Bit-identical to pre-M3a at every default (parity + pinned by unit tests, no golden re-pin needed). + - **Tier-table primitive** — new pure module `Short_margin_tiers` + (`trading/trading/portfolio/lib/short_margin_tiers.{ml,mli}`): a + price-banded, order-independent, piecewise-constant lookup (`tier_value + ~tiers ~flat_fallback ~price` picks the tightest band strictly covering the + price, else the flat fallback). An empty table is a bit-identical no-op. + Thresholds live in tests / example configs, not baked in code. + - **HTB tiered borrow rate** — `Margin_config` gains + `short_borrow_rate_tiers : Short_margin_tiers.tier list [@sexp.default []]` + + helpers `borrow_fee_annual_for_price` / `daily_borrow_rate_for_price`. + `Portfolio_margin.accrue_daily_borrow_fee` now accrues {b per short position} + at its marked price using the tiered daily rate; empty table → every price + resolves to the flat 50bps → per-position sum equals the legacy + `sum_short_notional * flat_daily_rate` bit-for-bit (distributivity). + - **Maintenance tier table** — `Margin_config` gains + `short_maintenance_tiers : Short_margin_tiers.tier list [@sexp.default []]` + + `maintenance_pct_for_price`. `Portfolio_margin.check_maintenance_margin` uses + the price-tiered threshold (sub-$5 → 100%, ~$5-17 → ≈83%, ≥ ~$17 → 30% base + per the 2026-06-12 mechanics note) so low-priced HTB shorts flag for + force-cover sooner; empty table → flat 25% → bit-identical. + - **Borrow-availability entry gate** — new module `Short_borrow_gate` + (`trading/trading/weinstein/strategy/lib/short_borrow_gate.{ml,mli}`, pure + `filter` + bar-reader adapter `apply`), re-exported on `Weinstein_strategy`. + Drops SHORT candidates whose trailing dollar-ADV (no-lookahead) is below the + borrow-supply floor; longs untouched; missing reading never drops. Wired as + the last gate in `Entry_assembly.assemble`. Config field + `short_borrow_min_dollar_adv : float [@sexp.default 0.0]` on + `weinstein_strategy_config` (+ re-declared `weinstein_strategy.mli` record). + Dollar-ADV is the borrow-supply proxy (we have no locate feed). + - **R2 axes** — top-level `short_borrow_min_dollar_adv` + nested + `margin_config.short_{borrow_rate,maintenance}_tiers` all resolve through + `Overlay_validator`; axis-expansion tests + (`test_short_borrow_min_dollar_adv_axis_expands`, + `test_short_maintenance_tiers_axis_expands`) in `test_variant_matrix.ml`. + - **Bar-cadence caveat** documented in `short_borrow_gate.mli` + the tier + `.mli`s: weekly-close marks cannot see an intraweek borrow recall / gap + squeeze. Probabilistic buy-in / gap-through-maintenance stress paths are + **M3b** territory (a documented seam, not built here). + - Tests: `test_short_margin_tiers.ml` (6 — lookup: empty→fallback, + tightest-band, middle band, uncovered→fallback, exclusive boundary, + order-independence); `test_margin_accounting.ml` (+6 — tiered borrow fee + per-price, empty-tiers flat parity, tiered maintenance flags a cheap short + the flat 25% doesn't + flat-parity, config round-trip + pre-M3a back-compat + parse); `test_short_borrow_gate.ml` (4 — zero-floor no-op, drops illiquid + short / keeps liquid, never drops longs, missing-reading keeps); extended + `test_long_buying_power.ml` (config default no-op + round-trip + pre-M3a + parse for `short_borrow_min_dollar_adv`). + - Verify: `dune runtest trading/portfolio/test trading/weinstein/strategy/test + trading/backtest/walk_forward/test` (container path + `/workspaces/trading-1/.claude/worktrees//trading`). +- **M3b** — buy-in stress mode (PENDING follow-up): probabilistic forced cover + on HTB names (config-gated, default-off) OR a stress-path mode for the + promotion grid, including gap-through-maintenance scenarios (bar-cadence marks + can't see intraweek gap squeezes — the documented M3a seam). - **M4** — validation protocol (parity gates, squeeze stress cells, leverage surface via experiment-gap-closing + confirmation grid). No default flips and no levered number is quoted until M4. diff --git a/trading/trading/backtest/walk_forward/test/test_variant_matrix.ml b/trading/trading/backtest/walk_forward/test/test_variant_matrix.ml index 89e597934..42c191579 100644 --- a/trading/trading/backtest/walk_forward/test/test_variant_matrix.ml +++ b/trading/trading/backtest/walk_forward/test/test_variant_matrix.ml @@ -365,14 +365,15 @@ let test_short_borrow_min_dollar_adv_axis_expands _ = field (fun (v : WFR.variant) -> v.overrides) (elements_are - [ equal_to (Sexp.of_string "((short_borrow_min_dollar_adv 0.0))") ]); + [ + equal_to (Sexp.of_string "((short_borrow_min_dollar_adv 0.0))"); + ]); field (fun (v : WFR.variant) -> v.overrides) (elements_are [ equal_to - (Sexp.of_string - "((short_borrow_min_dollar_adv 1000000.0))"); + (Sexp.of_string "((short_borrow_min_dollar_adv 1000000.0))"); ]); ]) @@ -389,8 +390,7 @@ let test_short_maintenance_tiers_axis_expands _ = { path = [ "margin_config"; "short_maintenance_tiers" ]; values = - Sexp. - [ List []; of_string "(((price_below 17.0) (value 1.0)))" ]; + Sexp.[ List []; of_string "(((price_below 17.0) (value 1.0)))" ]; } in let t = { VM.axes = [ axis ]; expansion = VM.Cartesian } in @@ -402,7 +402,8 @@ let test_short_maintenance_tiers_axis_expands _ = (elements_are [ equal_to - (Sexp.of_string "((margin_config ((short_maintenance_tiers ()))))"); + (Sexp.of_string + "((margin_config ((short_maintenance_tiers ()))))"); ]); field (fun (v : WFR.variant) -> v.overrides) diff --git a/trading/trading/portfolio/lib/margin_config.mli b/trading/trading/portfolio/lib/margin_config.mli index 2814fbd06..f8303c5b6 100644 --- a/trading/trading/portfolio/lib/margin_config.mli +++ b/trading/trading/portfolio/lib/margin_config.mli @@ -30,9 +30,9 @@ type t = { short_borrow_fee_annual_pct : float; (** Annualized borrow fee charged on short notional (default [0.005] = 50 bps — liquid SP500 reference rate per issue #859). Accrued daily as - [notional * rate / trading_days_per_year]. This is the {b flat - fallback}: consulted for any short whose marked price is not covered - by a {!short_borrow_rate_tiers} band. *) + [notional * rate / trading_days_per_year]. This is the + {b flat fallback}: consulted for any short whose marked price is not + covered by a {!short_borrow_rate_tiers} band. *) short_borrow_rate_tiers : Short_margin_tiers.tier list; (** Hard-to-borrow price-tiered {b annual borrow rate} table (margin M3a), default [[]] (empty). When empty, every short pays the flat @@ -82,8 +82,8 @@ val daily_borrow_rate : t -> float {!daily_borrow_rate_for_price}. *) val borrow_fee_annual_for_price : t -> price:float -> float -(** Annual borrow-fee fraction for a short marked at [price] (margin M3a): - the price-tiered rate from {!short_borrow_rate_tiers} when a band covers +(** Annual borrow-fee fraction for a short marked at [price] (margin M3a): the + price-tiered rate from {!short_borrow_rate_tiers} when a band covers [price], else the flat {!short_borrow_fee_annual_pct}. An empty tier table always returns the flat rate, so a disarmed config is bit-identical to pre-M3a. *) diff --git a/trading/trading/portfolio/lib/portfolio_margin.ml b/trading/trading/portfolio/lib/portfolio_margin.ml index 51113bc2c..fad9b68bf 100644 --- a/trading/trading/portfolio/lib/portfolio_margin.ml +++ b/trading/trading/portfolio/lib/portfolio_margin.ml @@ -149,7 +149,8 @@ let _short_daily_borrow_fee ~(margin_config : Margin_config.t) ~price_map | None -> 0.0 | Some price -> let notional = Float.abs qty *. price in - notional *. Margin_config.daily_borrow_rate_for_price margin_config ~price + notional + *. Margin_config.daily_borrow_rate_for_price margin_config ~price let accrue_daily_borrow_fee ~(margin_config : Margin_config.t) (portfolio : Portfolio.t) (market_prices : (symbol * price) list) : diff --git a/trading/trading/portfolio/lib/short_margin_tiers.mli b/trading/trading/portfolio/lib/short_margin_tiers.mli index 42469afc0..2baecf06f 100644 --- a/trading/trading/portfolio/lib/short_margin_tiers.mli +++ b/trading/trading/portfolio/lib/short_margin_tiers.mli @@ -5,8 +5,8 @@ names carry punitive rates and per-share maintenance floors, while liquid higher-priced names sit on the base tier. See [dev/notes/long-short-margin-mechanics-2026-06-12.md] §1 for the researched - numbers (sub-$5 → 100% maintenance, ~$5-17 → the $5/share floor ≈ 83%, - ≥ ~$16.67 → the 30% base tier). + numbers (sub-$5 → 100% maintenance, ~$5-17 → the $5/share floor ≈ 83%, ≥ + ~$16.67 → the 30% base tier). A tier table is a plain list of {!tier} bands. Lookup is piecewise-constant: a symbol marked at [price] uses the {b tightest} band that still covers it @@ -17,10 +17,9 @@ {!Margin_config} reproduces the pre-M3a flat behaviour bit-for-bit. The thresholds themselves are {b not} baked into this module: it only - provides the lookup mechanism. Concrete tier values live in - default-disarmed example configs / tests, per - [.claude/rules/experiment-flag-discipline.md] R1 (default-off) — the code - ships an empty table. + provides the lookup mechanism. Concrete tier values live in default-disarmed + example configs / tests, per [.claude/rules/experiment-flag-discipline.md] + R1 (default-off) — the code ships an empty table. Pure. *) @@ -35,16 +34,17 @@ type tier = { [@@deriving show, eq, sexp] val tier_value : tiers:tier list -> flat_fallback:float -> price:float -> float -(** [tier_value ~tiers ~flat_fallback ~price] resolves the tier value for a short - marked at [price]. +(** [tier_value ~tiers ~flat_fallback ~price] resolves the tier value for a + short marked at [price]. Selects the band with the smallest [price_below] that is strictly greater than [price] (the tightest covering band) and returns its [value]. When no band covers [price] — including the {b empty-table case} — returns [flat_fallback]. - Examples with [tiers = [ {price_below=5.0; value=1.0}; - {price_below=17.0; value=0.83} ]] and [flat_fallback = 0.30]: + Examples with + [tiers = [ {price_below=5.0; value=1.0}; {price_below=17.0; value=0.83} ]] + and [flat_fallback = 0.30]: - [price = 3.0] → [1.0] (covered by the $5 band, the tightest) - [price = 10.0] → [0.83] (only the $17 band covers it) - [price = 20.0] → [0.30] (no band covers it → fallback) diff --git a/trading/trading/portfolio/test/test_margin_accounting.ml b/trading/trading/portfolio/test/test_margin_accounting.ml index b4b8dc300..f19124640 100644 --- a/trading/trading/portfolio/test/test_margin_accounting.ml +++ b/trading/trading/portfolio/test/test_margin_accounting.ml @@ -460,7 +460,12 @@ let htb_borrow_config = { on_config with Margin_config.short_borrow_rate_tiers = - [ { Trading_portfolio.Short_margin_tiers.price_below = 17.0; value = 0.50 } ]; + [ + { + Trading_portfolio.Short_margin_tiers.price_below = 17.0; + value = 0.50; + }; + ]; } (* Two shorts at $10 (HTB tier) and $50 (flat fallback). Each position pays its @@ -486,7 +491,9 @@ let test_borrow_fee_tiered_charges_per_price _ = in assert_that (accrue_daily_borrow_fee ~margin_config:htb_borrow_config portfolio prices) - (field (fun p -> p.accrued_borrow_fee) (float_equal ~epsilon:1e-12 expected_fee)) + (field + (fun p -> p.accrued_borrow_fee) + (float_equal ~epsilon:1e-12 expected_fee)) let test_borrow_fee_empty_tiers_bit_equal_flat _ = (* Empty tier table (the default) → every short pays the flat rate, so the @@ -496,7 +503,9 @@ let test_borrow_fee_empty_tiers_bit_equal_flat _ = let expected_fee = _expected_daily_fee 6_000.0 on_config in assert_that (accrue_daily_borrow_fee ~margin_config:on_config portfolio prices) - (field (fun p -> p.accrued_borrow_fee) (float_equal ~epsilon:1e-12 expected_fee)) + (field + (fun p -> p.accrued_borrow_fee) + (float_equal ~epsilon:1e-12 expected_fee)) (* A short 100 @ $10, marked $10: equity_ratio = (1.5*10 - 10)/10 = 0.5. Above the flat 25% (not flagged), but below a 100% sub-$17 tier (flagged). *) @@ -521,7 +530,10 @@ let test_maintenance_tiered_flags_cheap_short _ = on_config with Margin_config.short_maintenance_tiers = [ - { Trading_portfolio.Short_margin_tiers.price_below = 17.0; value = 1.0 }; + { + Trading_portfolio.Short_margin_tiers.price_below = 17.0; + value = 1.0; + }; ]; } in @@ -535,10 +547,18 @@ let test_margin_config_round_trip_preserves_tiers _ = { Margin_config.default_config with Margin_config.short_borrow_rate_tiers = - [ { Trading_portfolio.Short_margin_tiers.price_below = 5.0; value = 1.0 } ]; + [ + { + Trading_portfolio.Short_margin_tiers.price_below = 5.0; + value = 1.0; + }; + ]; Margin_config.short_maintenance_tiers = [ - { Trading_portfolio.Short_margin_tiers.price_below = 17.0; value = 0.83 }; + { + Trading_portfolio.Short_margin_tiers.price_below = 17.0; + value = 0.83; + }; ]; } in diff --git a/trading/trading/portfolio/test/test_short_margin_tiers.ml b/trading/trading/portfolio/test/test_short_margin_tiers.ml index 5512d5cfa..038a81bba 100644 --- a/trading/trading/portfolio/test/test_short_margin_tiers.ml +++ b/trading/trading/portfolio/test/test_short_margin_tiers.ml @@ -12,7 +12,6 @@ let example_tiers = ] let flat_fallback = 0.30 - let lookup price = Tiers.tier_value ~tiers:example_tiers ~flat_fallback ~price let test_empty_table_returns_fallback _ = @@ -47,10 +46,13 @@ let test_order_independent _ = let suite = "short_margin_tiers" >::: [ - "test_empty_table_returns_fallback" >:: test_empty_table_returns_fallback; - "test_tightest_band_wins_low_price" >:: test_tightest_band_wins_low_price; + "test_empty_table_returns_fallback" + >:: test_empty_table_returns_fallback; + "test_tightest_band_wins_low_price" + >:: test_tightest_band_wins_low_price; "test_middle_band" >:: test_middle_band; - "test_uncovered_price_uses_fallback" >:: test_uncovered_price_uses_fallback; + "test_uncovered_price_uses_fallback" + >:: test_uncovered_price_uses_fallback; "test_price_at_boundary_excludes_band" >:: test_price_at_boundary_excludes_band; "test_order_independent" >:: test_order_independent; diff --git a/trading/trading/weinstein/strategy/lib/entry_assembly.ml b/trading/trading/weinstein/strategy/lib/entry_assembly.ml index 362bb0201..e4ae312e6 100644 --- a/trading/trading/weinstein/strategy/lib/entry_assembly.ml +++ b/trading/trading/weinstein/strategy/lib/entry_assembly.ml @@ -16,7 +16,6 @@ let assemble ~config ~bar_reader ~current_date (screen_result : Screener.result) Entry_liquidity_gate.apply ~config:config.liquidity_config ~bar_reader ~current_date combined in - Short_borrow_gate.apply - ~min_dollar_adv:config.short_borrow_min_dollar_adv + Short_borrow_gate.apply ~min_dollar_adv:config.short_borrow_min_dollar_adv ~lookback_days:config.liquidity_config.adv_lookback_days ~bar_reader ~current_date combined diff --git a/trading/trading/weinstein/strategy/lib/entry_assembly.mli b/trading/trading/weinstein/strategy/lib/entry_assembly.mli index d33a93e25..3b4805bd1 100644 --- a/trading/trading/weinstein/strategy/lib/entry_assembly.mli +++ b/trading/trading/weinstein/strategy/lib/entry_assembly.mli @@ -3,10 +3,11 @@ Pipeline: {!Short_side_gate.combine} (short-side enable + short-min-price) → {!Declining_ma_gate.filter} (drop misclassified declining-MA longs, - default-off) → {!Entry_liquidity_gate.apply} (dollar-ADV gate, default-off) → - {!Short_borrow_gate.apply} (short-side borrow-availability ADV floor, margin - M3a, default-off). With every gate at its no-op default the result is the - plain [buy_candidates @ short_candidates], bit-identical to prior behaviour. *) + default-off) → {!Entry_liquidity_gate.apply} (dollar-ADV gate, default-off) + → {!Short_borrow_gate.apply} (short-side borrow-availability ADV floor, + margin M3a, default-off). With every gate at its no-op default the result is + the plain [buy_candidates @ short_candidates], bit-identical to prior + behaviour. *) val assemble : config:Weinstein_strategy_config.config -> diff --git a/trading/trading/weinstein/strategy/lib/short_borrow_gate.ml b/trading/trading/weinstein/strategy/lib/short_borrow_gate.ml index 4ba37b0d1..b6930b960 100644 --- a/trading/trading/weinstein/strategy/lib/short_borrow_gate.ml +++ b/trading/trading/weinstein/strategy/lib/short_borrow_gate.ml @@ -12,7 +12,9 @@ let _short_has_borrow ~min_dollar_adv ~dollar_adv_for let filter ~min_dollar_adv ~dollar_adv_for (candidates : Screener.scored_candidate list) = if Float.( <= ) min_dollar_adv 0.0 then candidates - else List.filter candidates ~f:(_short_has_borrow ~min_dollar_adv ~dollar_adv_for) + else + List.filter candidates + ~f:(_short_has_borrow ~min_dollar_adv ~dollar_adv_for) let apply ~min_dollar_adv ~lookback_days ~bar_reader ~current_date candidates = if Float.( <= ) min_dollar_adv 0.0 then candidates diff --git a/trading/trading/weinstein/strategy/lib/short_borrow_gate.mli b/trading/trading/weinstein/strategy/lib/short_borrow_gate.mli index 827c5ef5f..2fd1ab3b0 100644 --- a/trading/trading/weinstein/strategy/lib/short_borrow_gate.mli +++ b/trading/trading/weinstein/strategy/lib/short_borrow_gate.mli @@ -1,13 +1,13 @@ (** The [short_borrow_availability] short-entry gate (margin M3a). A faithful Weinstein short-side eligibility "dial" (default-off axis per - [.claude/rules/experiment-flag-discipline.md]) modelling {b borrow - availability}: a short can only be opened if shares are locatable to borrow. - We have no locate feed, so borrow supply is proxied by {b trailing - dollar-ADV} — a thinly-traded name has little float circulating and is the - canonical hard-to-borrow / no-locate case. Short candidates whose dollar-ADV - is below the floor are dropped ("no borrow available"); long candidates are - never affected (borrow is a short-only concern). + [.claude/rules/experiment-flag-discipline.md]) modelling + {b borrow availability}: a short can only be opened if shares are locatable + to borrow. We have no locate feed, so borrow supply is proxied by + {b trailing dollar-ADV} — a thinly-traded name has little float circulating + and is the canonical hard-to-borrow / no-locate case. Short candidates whose + dollar-ADV is below the floor are dropped ("no borrow available"); long + candidates are never affected (borrow is a short-only concern). The spine is untouched ([.claude/rules/weinstein-faithful-core.md] W1): this only narrows which {e short} candidates are eligible, exactly as @@ -42,7 +42,8 @@ val filter : No-op when [min_dollar_adv <= 0.0] (the default): returns [candidates] unchanged (bit-identical), so every existing golden/baseline replays - unchanged. Pure. See [Weinstein_strategy_config.short_borrow_min_dollar_adv]. *) + unchanged. Pure. See + [Weinstein_strategy_config.short_borrow_min_dollar_adv]. *) val apply : min_dollar_adv:float -> diff --git a/trading/trading/weinstein/strategy/lib/weinstein_strategy.mli b/trading/trading/weinstein/strategy/lib/weinstein_strategy.mli index cff330a86..682a7ceec 100644 --- a/trading/trading/weinstein/strategy/lib/weinstein_strategy.mli +++ b/trading/trading/weinstein/strategy/lib/weinstein_strategy.mli @@ -323,8 +323,8 @@ type config = { (** Borrow-availability floor for short candidates (margin M3a): shorts whose trailing dollar-ADV (no-lookahead, over {!liquidity_config}'s lookback) is below this value are dropped as "no borrow available" - before the entry walk; longs are never affected. Default [0.0] = - no-op (bit-identical). A default-off, searchable + before the entry walk; longs are never affected. Default [0.0] = no-op + (bit-identical). A default-off, searchable {!Walk_forward.Variant_matrix} axis; see {!Weinstein_strategy_config.short_borrow_min_dollar_adv} and {!Short_borrow_gate}. *) diff --git a/trading/trading/weinstein/strategy/lib/weinstein_strategy_config.ml b/trading/trading/weinstein/strategy/lib/weinstein_strategy_config.ml index 4f4515b23..606c299f0 100644 --- a/trading/trading/weinstein/strategy/lib/weinstein_strategy_config.ml +++ b/trading/trading/weinstein/strategy/lib/weinstein_strategy_config.ml @@ -38,8 +38,7 @@ type config = { full_compute_tail_days : int option; enable_short_side : bool; [@sexp.default true] short_min_price : float; [@sexp.default 0.0] (** See [.mli]. *) - short_borrow_min_dollar_adv : float; [@sexp.default 0.0] - (** See [.mli]. *) + short_borrow_min_dollar_adv : float; [@sexp.default 0.0] (** See [.mli]. *) suppress_warmup_trading : bool; [@sexp.default true] (** See [.mli]. *) stop_update_cadence : Stops_runner.stop_update_cadence; [@sexp.default Stops_runner.Daily] diff --git a/trading/trading/weinstein/strategy/lib/weinstein_strategy_config.mli b/trading/trading/weinstein/strategy/lib/weinstein_strategy_config.mli index 21dd9b77e..4b37be914 100644 --- a/trading/trading/weinstein/strategy/lib/weinstein_strategy_config.mli +++ b/trading/trading/weinstein/strategy/lib/weinstein_strategy_config.mli @@ -44,8 +44,8 @@ type config = { minimum trailing dollar-ADV a name must trade for its shares to be considered locatable-to-borrow. Short candidates whose dollar-ADV (computed from bars available at the screen date, no lookahead, over - {!liquidity_config}'s [adv_lookback_days]) is strictly below this value - are dropped as "no borrow available" before the entry walk; long + {!liquidity_config}'s [adv_lookback_days]) is strictly below this + value are dropped as "no borrow available" before the entry walk; long candidates are never affected (borrow is a short-only concern). Default [0.0] = no gating: {!Short_borrow_gate.filter} short-circuits @@ -55,9 +55,9 @@ type config = { We have no locate feed; dollar-ADV is the practical borrow-supply proxy per [dev/notes/long-short-margin-mechanics-2026-06-12.md] §4 - item 6 (a thinly-traded name is the canonical hard-to-borrow case). - A default-off, searchable {!Walk_forward.Variant_matrix} axis; not - wired into any default config or preset. Bar-cadence caveat (intraweek + item 6 (a thinly-traded name is the canonical hard-to-borrow case). A + default-off, searchable {!Walk_forward.Variant_matrix} axis; not wired + into any default config or preset. Bar-cadence caveat (intraweek borrow recall / gap squeeze invisible; stress paths are M3b/M4) is documented in {!Short_borrow_gate}. *) suppress_warmup_trading : bool; [@sexp.default true] diff --git a/trading/trading/weinstein/strategy/test/test_long_buying_power.ml b/trading/trading/weinstein/strategy/test/test_long_buying_power.ml index feecba641..710adbcc2 100644 --- a/trading/trading/weinstein/strategy/test/test_long_buying_power.ml +++ b/trading/trading/weinstein/strategy/test/test_long_buying_power.ml @@ -125,7 +125,8 @@ let test_short_borrow_adv_round_trip _ = in let gated = { base with short_borrow_min_dollar_adv = 2_000_000.0 } in assert_that - (Weinstein_strategy.config_of_sexp (Weinstein_strategy.sexp_of_config gated)) + (Weinstein_strategy.config_of_sexp + (Weinstein_strategy.sexp_of_config gated)) (field (fun (c : Weinstein_strategy.config) -> c.short_borrow_min_dollar_adv) (float_equal 2_000_000.0)) diff --git a/trading/trading/weinstein/strategy/test/test_short_borrow_gate.ml b/trading/trading/weinstein/strategy/test/test_short_borrow_gate.ml index 597e3f7be..71b784d4e 100644 --- a/trading/trading/weinstein/strategy/test/test_short_borrow_gate.ml +++ b/trading/trading/weinstein/strategy/test/test_short_borrow_gate.ml @@ -1,4 +1,5 @@ -(** Unit tests for the [short_borrow_availability] short-entry gate (margin M3a). +(** Unit tests for the [short_borrow_availability] short-entry gate (margin + M3a). Pins the no-op-default contract and the gating behaviour of {!Weinstein_strategy.Short_borrow_gate.filter}: From 5724244f7e711c2f0310451157ea636468f0489b Mon Sep 17 00:00:00 2001 From: difan Date: Sun, 19 Jul 2026 20:46:22 -0400 Subject: [PATCH 6/6] fix(margin): resolve file-length + nesting linter fails (M3a) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - short_margin_tiers.tier_value: flatten via List.min_elt directly (drops the nested inline compare lambda that tripped the nesting linter) — also cleaner, removes Option.value_exn. - portfolio_margin.ml: keep it under the 300-line soft limit (my M3a tiered borrow-fee additions pushed it to 310). Reverted sum_short_notional to its compact original, wrote the per-position fee helper leanly via Option.value_map, and trimmed two pre-existing one-line accessor comments that fully duplicated their .mli docstrings. No behavior change; portfolio tests green. Co-Authored-By: Claude Fable 5 --- .../trading/portfolio/lib/portfolio_margin.ml | 37 +++++++------------ .../portfolio/lib/short_margin_tiers.ml | 15 ++++---- 2 files changed, 20 insertions(+), 32 deletions(-) diff --git a/trading/trading/portfolio/lib/portfolio_margin.ml b/trading/trading/portfolio/lib/portfolio_margin.ml index fad9b68bf..fdcfdf505 100644 --- a/trading/trading/portfolio/lib/portfolio_margin.ml +++ b/trading/trading/portfolio/lib/portfolio_margin.ml @@ -4,17 +4,11 @@ open Status open Trading_base.Types open Types -(* available_cash = current_cash net of pledged short collateral. Strategy code - should read this rather than current_cash when sizing new entries. Lives here - (not on [Portfolio]) so that module stays under the file-length hard limit. *) +(* Spendable cash net of pledged short collateral (full contract in [.mli]). *) let available_cash (portfolio : Portfolio.t) : cash_value = portfolio.current_cash -. portfolio.locked_collateral -(* equity_cash = the cash component of portfolio equity net of borrowed - long-margin debt (margin M1b-2). Equity = equity_cash + marked position - value; every NAV / drawdown read must use this so the borrowed cash does not - inflate reported wealth. Equals [current_cash] under a cash account (where - [long_margin_debit = 0.0]), so all pre-M1b valuations are bit-identical. *) +(* Equity cash net of borrowed long-margin debt, margin M1b-2 (see [.mli]). *) let equity_cash (portfolio : Portfolio.t) : cash_value = portfolio.current_cash -. portfolio.long_margin_debit @@ -135,22 +129,18 @@ let sum_short_notional (portfolio : Portfolio.t) market_prices : float = | Some price -> acc +. (Float.abs qty *. price) else acc) -(* One short position's daily borrow fee at its marked price. Uses the - price-tiered daily rate (M3a); with an empty tier table every price resolves - to the flat rate, so the per-position sum equals the legacy - [sum_short_notional * flat_daily_rate] bit-for-bit (distributivity). Longs - and shorts absent from the price list contribute nothing. *) -let _short_daily_borrow_fee ~(margin_config : Margin_config.t) ~price_map - (p : portfolio_position) : float = +(* Price-tiered daily borrow fee for one position (M3a); an empty tier table → + flat rate, so the per-position sum equals [sum_short_notional * flat_daily] + bit-for-bit (distributivity). Longs / unpriced shorts contribute nothing. *) +let _short_daily_borrow_fee ~(margin_config : Margin_config.t) ~price_map p : + float = let qty = Calculations.position_quantity p in if Float.O.(qty >= 0.0) then 0.0 else - match Map.find price_map p.symbol with - | None -> 0.0 - | Some price -> - let notional = Float.abs qty *. price in - notional - *. Margin_config.daily_borrow_rate_for_price margin_config ~price + Map.find price_map p.symbol + |> Option.value_map ~default:0.0 ~f:(fun price -> + Float.abs qty *. price + *. Margin_config.daily_borrow_rate_for_price margin_config ~price) let accrue_daily_borrow_fee ~(margin_config : Margin_config.t) (portfolio : Portfolio.t) (market_prices : (symbol * price) list) : @@ -188,9 +178,8 @@ let _short_breaches_maintenance ~(margin_config : Margin_config.t) let ratio = _short_equity_ratio ~margin_config ~entry_avg_cost ~current_price in - (* Price-tiered maintenance threshold (M3a): supersedes the flat - [maintenance_margin_pct] when [short_maintenance_tiers] is armed; an empty - table resolves to the flat threshold, so this is bit-identical to pre-M3a. *) + (* Price-tiered threshold (M3a); empty [short_maintenance_tiers] → flat + [maintenance_margin_pct], i.e. bit-identical to pre-M3a. *) let threshold = Margin_config.maintenance_pct_for_price margin_config ~price:current_price in diff --git a/trading/trading/portfolio/lib/short_margin_tiers.ml b/trading/trading/portfolio/lib/short_margin_tiers.ml index 15e7007ee..561c9488c 100644 --- a/trading/trading/portfolio/lib/short_margin_tiers.ml +++ b/trading/trading/portfolio/lib/short_margin_tiers.ml @@ -10,14 +10,13 @@ open Core type tier = { price_below : float; value : float } [@@deriving show, eq, sexp] let _covers ~price tier = Float.O.(price < tier.price_below) +let _by_price_below a b = Float.compare a.price_below b.price_below let tier_value ~(tiers : tier list) ~(flat_fallback : float) ~(price : float) : float = - match List.filter tiers ~f:(_covers ~price) with - | [] -> flat_fallback - | covering -> - let tightest = - List.min_elt covering ~compare:(fun a b -> - Float.compare a.price_below b.price_below) - in - (Option.value_exn tightest).value + (* Tightest covering band = the smallest [price_below] still above [price]; + [List.min_elt] returns [None] for the empty / no-cover case → fallback. *) + let covering = List.filter tiers ~f:(_covers ~price) in + match List.min_elt covering ~compare:_by_price_below with + | None -> flat_fallback + | Some tightest -> tightest.value