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 e6e7868aa..42c191579 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,78 @@ 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 +655,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" 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..f8303c5b6 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..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,13 +129,31 @@ let sum_short_notional (portfolio : Portfolio.t) market_prices : float = | Some price -> acc +. (Float.abs qty *. price) else acc) +(* 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 + 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) : 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 +178,12 @@ 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 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 + 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..561c9488c --- /dev/null +++ b/trading/trading/portfolio/lib/short_margin_tiers.ml @@ -0,0 +1,22 @@ +(* 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 _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 = + (* 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 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..2baecf06f --- /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. *) 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..f19124640 100644 --- a/trading/trading/portfolio/test/test_margin_accounting.ml +++ b/trading/trading/portfolio/test/test_margin_accounting.ml @@ -450,6 +450,138 @@ 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 +828,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..038a81bba --- /dev/null +++ b/trading/trading/portfolio/test/test_short_margin_tiers.ml @@ -0,0 +1,61 @@ +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..e4ae312e6 100644 --- a/trading/trading/weinstein/strategy/lib/entry_assembly.ml +++ b/trading/trading/weinstein/strategy/lib/entry_assembly.ml @@ -12,5 +12,10 @@ 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..3b4805bd1 100644 --- a/trading/trading/weinstein/strategy/lib/entry_assembly.mli +++ b/trading/trading/weinstein/strategy/lib/entry_assembly.mli @@ -3,9 +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). - 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..b6930b960 --- /dev/null +++ b/trading/trading/weinstein/strategy/lib/short_borrow_gate.ml @@ -0,0 +1,26 @@ +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..2fd1ab3b0 --- /dev/null +++ b/trading/trading/weinstein/strategy/lib/short_borrow_gate.mli @@ -0,0 +1,58 @@ +(** 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.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 7e2fe0be4..682a7ceec 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}. *) @@ -314,6 +319,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..606c299f0 100644 --- a/trading/trading/weinstein/strategy/lib/weinstein_strategy_config.ml +++ b/trading/trading/weinstein/strategy/lib/weinstein_strategy_config.ml @@ -38,6 +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]. *) suppress_warmup_trading : bool; [@sexp.default true] (** See [.mli]. *) stop_update_cadence : Stops_runner.stop_update_cadence; [@sexp.default Stops_runner.Daily] @@ -134,6 +135,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..4b37be914 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], 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..710adbcc2 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,48 @@ 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 +246,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..71b784d4e --- /dev/null +++ b/trading/trading/weinstein/strategy/test/test_short_borrow_gate.ml @@ -0,0 +1,98 @@ +(** 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; + ])