From 903483966191320929b9eafad01e565c1546965a Mon Sep 17 00:00:00 2001 From: momentum dev Date: Sun, 13 Sep 2026 13:34:53 -0500 Subject: [PATCH] fix: a zero equity reading sized off the $100k default Tradier returns total_equity 0.0 during its overnight maintenance window -- sixteen times in the last fourteen days, all around 01:06. Three defects compounded so that a zero could size a position off default_equity: account_equity = equity or config.default_equity `or` cannot tell "not supplied" from "read as zero", so a real 0.0 became 100000. Then the position-value cap, derived from the same equity, was itself 0 -- and the guard if max_position_value is not None and max_position_value > 0: SKIPPED the cap precisely when the cap said "no room". Finally the auto path (~96% of approval requests) had no `pos_cap <= 0` early-out, unlike the manual path at request_entry_approval. Net effect on a ~$250 account: risk 5% of 100k = $5,000, capped only by liquidity. The broker would have rejected it for buying power, which is luck rather than design. Never fired in live hours -- every zero read landed with the market closed, and _current_equity caches the last good value -- so this is latent, not a realised loss. The reachable path is a boot that coincides with the maintenance window, when no good read has been cached yet. Distinguish None from zero, refuse to size on non-positive equity, treat a zero cap as "no room" rather than "no cap", and mirror the manual path's early-out. TRADING_DEFAULT_EQUITY is set to 300 on the live stack so even the untested fallback cannot oversize. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01A2LepdJfPCfK5Q8uznjFBM --- deploy/docker/overrides.live.seed.env | 5 ++++ strategy/risk/position_sizing.py | 16 ++++++++-- tests/unit/test_position_sizing.py | 43 +++++++++++++++++++++++++++ trading_execution.py | 5 ++++ 4 files changed, 67 insertions(+), 2 deletions(-) diff --git a/deploy/docker/overrides.live.seed.env b/deploy/docker/overrides.live.seed.env index 8842117..cc1683c 100644 --- a/deploy/docker/overrides.live.seed.env +++ b/deploy/docker/overrides.live.seed.env @@ -65,6 +65,11 @@ TRADING_MAX_FRESH_ENTRIES_PER_DAY=10 TRADING_MAX_GROSS_NOTIONAL_PCT=0.60 TRADING_MAX_ORDERS_PER_TICK=1 TRADING_MAX_POSITION_PCT=0.16 +# Boot-time sizing fallback. calculate_position_size falls back to this only when +# equity was never readable; at the old 100000 a boot inside Tradier's ~01:06 +# maintenance window (equity reads 0.0 there — 16 times in 14 days) would have +# sized 5% of 100k on a ~$250 account. Keep it near real account size. +TRADING_DEFAULT_EQUITY=300 TRADING_MAX_RISK_DOLLARS=50000 TRADING_MIN_DAY_VOLUME=300000 TRADING_MIN_ENTRY_GAP_PCT=0.10 diff --git a/strategy/risk/position_sizing.py b/strategy/risk/position_sizing.py index 977caa6..764b818 100644 --- a/strategy/risk/position_sizing.py +++ b/strategy/risk/position_sizing.py @@ -57,7 +57,13 @@ def calculate_position_size( - ``max_shares``: cap shares for LIQUIDITY (e.g. a % of the symbol's recent volume) so at size you don't move the market against yourself """ - account_equity = equity or config.default_equity + # `equity or default` treated a REAL 0.0 reading as "not supplied" and sized + # off default_equity (100k). The broker does return 0.0 — Tradier reported it + # 16 times in 14 days during its ~01:06 maintenance window — so distinguish + # "not passed" (None) from "read as zero", and refuse to size on the latter. + account_equity = config.default_equity if equity is None else float(equity) + if account_equity <= 0: + return PositionSizeResult(position_size=0, dollar_amount=0.0, risk_amount=0.0) risk_amount = account_equity * config.risk_per_trade_pct # fixed-fractional with a hard ceiling: the dollar risk used to SIZE is the # smaller of the % budget and the absolute cap. @@ -69,7 +75,13 @@ def calculate_position_size( return PositionSizeResult(position_size=0, dollar_amount=0.0, risk_amount=0.0) position_size = int(risk_amount / risk_per_share) - if max_position_value is not None and max_position_value > 0: + if max_position_value is not None: + # A cap of exactly 0 means "no room" (zero equity, exhausted gross budget, + # no settled buying power). The old `> 0` test skipped the cap in precisely + # that case instead of enforcing it — the cap silently vanished when it + # mattered most. + if max_position_value <= 0: + return PositionSizeResult(position_size=0, dollar_amount=0.0, risk_amount=0.0) position_size = min(position_size, int(max_position_value / entry_price)) if max_shares is not None and max_shares >= 0: position_size = min(position_size, int(max_shares)) diff --git a/tests/unit/test_position_sizing.py b/tests/unit/test_position_sizing.py index 31924c0..a287c87 100644 --- a/tests/unit/test_position_sizing.py +++ b/tests/unit/test_position_sizing.py @@ -68,3 +68,46 @@ def test_rank_risk_factor_concentrates(): assert rank_risk_factor(2, 2) == 0.5 # rank-2 half assert rank_risk_factor(3, 2) == 0.0 # outside top-2 -> skip assert rank_risk_factor(0, 2) == 1.0 # unranked -> not concentrated + + +# --- zero-equity guards (2026-09-13) ----------------------------------------- +# Tradier returned total_equity 0.0 sixteen times in fourteen days, all inside +# its ~01:06 maintenance window. `equity or default` read that real zero as +# "not supplied" and sized off default_equity (100k) — on a $241 account that +# is a position risking 5% of 100k. Two independent holes made it reachable: +# the falsy-zero fallback, and a `max_position_value > 0` test that SKIPPED the +# cap exactly when the cap was zero. + +def test_zero_equity_refuses_to_size(): + """A real 0.0 reading must size nothing — never fall back to default_equity.""" + r = calculate_position_size( + 4.0, 3.80, equity=0.0, + config=PositionSizingConfig(risk_per_trade_pct=0.05, default_equity=100000), + ) + assert r.position_size == 0 + assert r.dollar_amount == 0.0 + + +def test_negative_equity_refuses_to_size(): + r = calculate_position_size(4.0, 3.80, equity=-5.0, config=CFG) + assert r.position_size == 0 + + +def test_none_equity_still_uses_the_default(): + """The documented fallback must survive: not-passed is not the same as zero.""" + r = calculate_position_size( + 4.0, 3.80, equity=None, + config=PositionSizingConfig(risk_per_trade_pct=0.01, default_equity=100000), + ) + assert r.position_size > 0 + + +def test_zero_position_cap_blocks_the_trade(): + """A cap of 0 means no room (no settled cash / gross budget spent), not 'no cap'.""" + r = calculate_position_size(4.0, 3.80, equity=300, config=CFG, max_position_value=0.0) + assert r.position_size == 0 + + +def test_positive_cap_still_caps(): + r = calculate_position_size(4.0, 3.80, equity=300, config=CFG, max_position_value=40.0) + assert r.position_size == 10 # 40 / 4.00 diff --git a/trading_execution.py b/trading_execution.py index 20ad639..3ccce29 100644 --- a/trading_execution.py +++ b/trading_execution.py @@ -1345,6 +1345,11 @@ class TradingExecutionService: _bp = self._settled_buying_power() if _bp is not None: pos_cap = min(pos_cap, _bp) # cash account: settled funds only (GFV guard) + # Mirror the manual path's guard: no room means no trade. Without this + # the auto path — which raises ~96% of approval requests — carried a + # pos_cap of 0 into sizing, where it was silently ignored. + if pos_cap <= 0: + continue # Liquidity cap (unification): on the auto path too, cap shares to a # fraction of the name's cumulative volume so a thin cheap name can't be # oversized into heavy slippage (the VTAK-style stop that filled far past