Files
momentum-system/tests/unit/test_position_sizing.py
T
momentum devandClaude Opus 5 9034839661 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
2026-09-13 13:34:53 -05:00

114 lines
4.7 KiB
Python

"""Account-aware position sizing: risk-based, capped by buying power + liquidity.
Critical for a small REAL account ($300) — sizing must come off real equity and
no single position can exceed buying power.
"""
from strategy.risk.position_sizing import (
PositionSizingConfig,
calculate_position_size,
rank_risk_factor,
)
CFG = PositionSizingConfig(risk_per_trade_pct=0.01)
def test_sizes_off_small_equity():
# $300 equity, 1% = $3 risk; entry 4.00 stop 3.80 (risk 0.20) -> 14 shares
# (3/0.20 = 14.999.. truncates down — conservative, never over-risk)
r = calculate_position_size(4.0, 3.80, equity=300, config=CFG)
assert r.position_size == 14
assert r.dollar_amount <= 300 * CFG.risk_per_trade_pct / 0.20 * 4.0 + 1e-6
def test_value_cap_binds_on_tight_stop():
# tight stop -> risk-based wants 150 sh ($600); cap at $120 -> 30 shares
r = calculate_position_size(4.0, 3.98, equity=300, config=CFG, max_position_value=120)
assert r.position_size == 30
assert r.dollar_amount <= 120 + 1e-6
def test_liquidity_cap_binds():
r = calculate_position_size(4.0, 3.80, equity=300, config=CFG, max_shares=5)
assert r.position_size == 5
def test_default_equity_when_unset():
# no equity passed -> falls back to config default (not zero)
r = calculate_position_size(4.0, 3.80, config=PositionSizingConfig(default_equity=100000))
assert r.position_size > 0
def test_max_risk_dollars_caps_wide_stop():
# $90k, 1% = $900 budget; entry 2.00 stop 1.69 (0.31/sh wide stop)
uncapped = calculate_position_size(2.00, 1.69, equity=90000, config=CFG)
assert abs(uncapped.risk_amount - 900) < 2 # risks the full % budget
capped = calculate_position_size(2.00, 1.69, equity=90000, config=CFG, max_risk_dollars=500)
assert capped.position_size < uncapped.position_size # sized down
assert 490 < capped.risk_amount <= 500.5 # hard $ cap honored (~$500)
def test_max_risk_dollars_noop_when_budget_smaller():
# small account: 1% of $300 = $3 budget << the $500 cap -> cap doesn't bind
r = calculate_position_size(4.0, 3.80, equity=300, config=CFG, max_risk_dollars=500)
assert r.position_size == 14 # unchanged from the % budget
def test_risk_amount_reports_actual_not_budget():
# after a binding value cap, risk_amount = the SIZED position's risk, not the budget
r = calculate_position_size(4.0, 3.98, equity=300, config=CFG, max_position_value=120)
assert r.position_size == 30
assert abs(r.risk_amount - 30 * 0.02) < 1e-6 # ~$0.60 actual, not the $3 budget
def test_rank_risk_factor_concentrates():
assert rank_risk_factor(1, 0) == 1.0 # concentration OFF -> full
assert rank_risk_factor(5, 0) == 1.0
assert rank_risk_factor(1, 2) == 1.0 # rank-1 full budget
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