Files
momentum-system/strategy/risk/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

97 lines
4.3 KiB
Python

"""Position sizing logic."""
from pydantic import BaseModel, Field
class PositionSizingConfig(BaseModel):
"""Configuration for position sizing."""
risk_per_trade_pct: float = Field(
default=0.01, description="Risk per trade as percent of equity"
)
default_equity: float = Field(
default=100000.0, description="Default equity for sizing"
)
class PositionSizeResult(BaseModel):
"""Result of position sizing calculation."""
position_size: int = Field(description="Position size in shares")
dollar_amount: float = Field(description="Dollar amount of position")
risk_amount: float = Field(description="Dollar risk amount")
def rank_risk_factor(rank: int, top_n: int) -> float:
"""Risk scale for concentrate-by-rank: trade fewer, better names with more
size on the best one instead of spraying equal size across the board.
rank-1 -> 1.0 (full budget), ranks 2..top_n -> 0.5, beyond top_n -> 0.0 (skip).
top_n <= 0 (or rank <= 0) disables concentration -> 1.0."""
if top_n <= 0 or rank <= 0:
return 1.0
if rank > top_n:
return 0.0
return 1.0 if rank == 1 else 0.5
def calculate_position_size(
entry_price: float,
stop_loss_price: float,
equity: float | None = None,
config: PositionSizingConfig = PositionSizingConfig(),
max_position_value: float | None = None,
max_shares: int | None = None,
max_risk_dollars: float | None = None,
) -> PositionSizeResult:
"""Shares to risk ``risk_per_trade_pct`` of equity, capped by buying power
and liquidity.
- risk-based: shares = (equity * risk%) / (entry - stop)
- ``max_risk_dollars``: HARD cap on the trade's dollar risk regardless of
equity/stop width — fixed-fractional with a ceiling. Without it the
percentage budget alone let wide-stop names risk ~3x the median (the
BTBT/WKSP/LNKS ~-$1k losers); this makes every trade risk
min(equity*risk%, max_risk_dollars).
- ``max_position_value``: cap the position's DOLLAR value (buying-power aware
— essential for a small account so one trade can't exceed available cash)
- ``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
"""
# `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.
if max_risk_dollars is not None and max_risk_dollars > 0:
risk_amount = min(risk_amount, max_risk_dollars)
risk_per_share = abs(entry_price - stop_loss_price)
if risk_per_share <= 0 or entry_price <= 0:
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:
# 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))
position_size = max(0, position_size)
return PositionSizeResult(
position_size=position_size,
dollar_amount=position_size * entry_price,
# the ACTUAL dollar risk of the sized position (after every cap), so the
# caller/audit sees what's truly at stake, not just the budget.
risk_amount=round(position_size * risk_per_share, 2),
)