Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
59 changes: 58 additions & 1 deletion dev/status/margin-realism.md
Original file line number Diff line number Diff line change
Expand Up @@ -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/<ws>/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.
76 changes: 76 additions & 0 deletions trading/trading/backtest/walk_forward/test/test_variant_matrix.ml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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"
Expand Down
1 change: 1 addition & 0 deletions trading/trading/portfolio/lib/dune
Original file line number Diff line number Diff line change
Expand Up @@ -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)))
22 changes: 22 additions & 0 deletions trading/trading/portfolio/lib/margin_config.ml
Original file line number Diff line number Diff line change
Expand Up @@ -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]

Expand All @@ -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
54 changes: 51 additions & 3 deletions trading/trading/portfolio/lib/margin_config.mli
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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. *)
40 changes: 28 additions & 12 deletions trading/trading/portfolio/lib/portfolio_margin.ml
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down Expand Up @@ -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;
Expand All @@ -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. *)
Expand Down
23 changes: 15 additions & 8 deletions trading/trading/portfolio/lib/portfolio_margin.mli
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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 +.
Expand Down
Loading
Loading