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
97 lines
4.3 KiB
Python
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),
|
|
)
|