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) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01A2LepdJfPCfK5Q8uznjFBM
This commit is contained in:
momentum devandClaude Opus 5 committed 2026-09-13 13:34:53 -05:00
1 parent 0910e29cc5
commit 9034839661
4 files changed
+67 -2

No files matched your search

+5
View File
@@ -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
+14 -2
View File
@@ -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))
+43
View File
@@ -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
+5
View File
@@ -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