diff --git a/run_live_paper.py b/run_live_paper.py index 536d761..18418ed 100644 --- a/run_live_paper.py +++ b/run_live_paper.py @@ -484,6 +484,7 @@ def main(argv: list[str] | None = None) -> int: "trigger": hi, "stop": stop, "range_pct": ((hi - lo) / hi) if (hi and lo and hi > 0) else 0.0, + "cum_volume": g.cumulative_volume, "complete": complete, }) # pre-open / before any gappers form: surface the queued watchlist so the @@ -536,7 +537,8 @@ def main(argv: list[str] | None = None) -> int: pass for t in book.fires(): res = rt["execution"].submit_breakout_now( - t.symbol, t.trigger, t.stop, last_price=t.price + t.symbol, t.trigger, t.stop, last_price=t.price, + cum_volume=t.cum_volume, ) if res.get("ok"): book.mark_fired(t.symbol) diff --git a/runtime/triggers.py b/runtime/triggers.py index 15e2f85..d394955 100644 --- a/runtime/triggers.py +++ b/runtime/triggers.py @@ -58,6 +58,7 @@ class ArmedTrigger: stop: float | None = None # opening-range low, cushioned (protective stop) range_pct: float = 0.0 # (high-low)/high — how wide the opening range is price: float | None = None # latest live trade price + cum_volume: float = 0.0 # session cumulative volume (for liquidity sizing) state: str = WAITING rank: int = 0 @@ -137,6 +138,7 @@ class ArmedTriggerBook: t.trigger = c.get("trigger") t.stop = c.get("stop") t.range_pct = float(c.get("range_pct") or 0.0) + t.cum_volume = float(c.get("cum_volume") or 0.0) t.rank = rank if not c.get("complete") or t.trigger is None: t.state = WAITING diff --git a/strategy/risk/position_sizing.py b/strategy/risk/position_sizing.py index 150b514..404e5a7 100644 --- a/strategy/risk/position_sizing.py +++ b/strategy/risk/position_sizing.py @@ -27,25 +27,34 @@ def calculate_position_size( stop_loss_price: float, equity: float | None = None, config: PositionSizingConfig = PositionSizingConfig(), + max_position_value: float | None = None, + max_shares: int | None = None, ) -> PositionSizeResult: - """Calculate position size based on risk per trade.""" + """Shares to risk ``risk_per_trade_pct`` of equity, capped by buying power + and liquidity. + + - risk-based: shares = (equity * risk%) / (entry - stop) + - ``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 + """ account_equity = equity or config.default_equity risk_amount = account_equity * config.risk_per_trade_pct - risk_per_share = abs(entry_price - stop_loss_price) - if risk_per_share <= 0: - return PositionSizeResult( - position_size=0, - dollar_amount=0.0, - risk_amount=risk_amount, - ) + if risk_per_share <= 0 or entry_price <= 0: + return PositionSizeResult(position_size=0, dollar_amount=0.0, risk_amount=risk_amount) position_size = int(risk_amount / risk_per_share) - dollar_amount = position_size * entry_price + if max_position_value is not None and max_position_value > 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=dollar_amount, + dollar_amount=position_size * entry_price, risk_amount=risk_amount, ) diff --git a/tests/unit/test_position_sizing.py b/tests/unit/test_position_sizing.py new file mode 100644 index 0000000..2ed6df4 --- /dev/null +++ b/tests/unit/test_position_sizing.py @@ -0,0 +1,35 @@ +"""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 + +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 diff --git a/trading_execution.py b/trading_execution.py index ab00efc..64817aa 100644 --- a/trading_execution.py +++ b/trading_execution.py @@ -96,6 +96,13 @@ class ExecutionSettings: # cap the marketable limit so a breakout FILLS on a runner instead of # resting forever at the trigger, while still bounding slippage. trigger_slippage_pct: float = 0.004 + # --- account-aware sizing (matters most on a small REAL account) ------- + # one position's dollar value <= this fraction of equity, so a single trade + # can't exceed buying power (critical at $300: 3 positions must fit, so ~1/3). + max_position_pct: float = 0.33 + # liquidity cap: shares <= this fraction of the symbol's day volume so at + # SIZE you don't move the market. 0 = off (irrelevant at small size). + liquidity_max_volume_pct: float = 0.0 @classmethod def from_env(cls, env: dict[str, str] | None = None) -> "ExecutionSettings": @@ -130,6 +137,10 @@ class ExecutionSettings: trigger_slippage_pct=float( values.get("TRADING_TRIGGER_SLIP_PCT", "0.004") ), + max_position_pct=float(values.get("TRADING_MAX_POSITION_PCT", "0.33")), + liquidity_max_volume_pct=float( + values.get("TRADING_LIQUIDITY_MAX_VOLUME_PCT", "0.0") + ), ) @@ -207,6 +218,24 @@ class TradingExecutionService: if p.get("symbol") } + def _current_equity(self) -> float: + """Size off the REAL broker equity, not the $100k default. + + On a $300 live account the default would size $1,000 positions and get + rejected; this reads actual equity (the get_account read is cached, so + it's cheap) and caches it on the service for fallback. + """ + client = getattr(self.executor, "client", None) + if client is not None and hasattr(client, "get_account"): + try: + eq = float((client.get_account() or {}).get("equity") or 0.0) + if eq > 0: + self.equity = eq + return eq + except Exception: # noqa: BLE001 + pass + return self.equity + def _broker_held_symbols(self) -> set[str] | None: """Symbols the broker reports as OPEN POSITIONS right now (truth). @@ -413,14 +442,16 @@ class TradingExecutionService: if not entry or not stop or stop >= entry: continue + eq = self._current_equity() sizing = calculate_position_size( float(entry), float(stop), - equity=self.equity, + equity=eq, config=PositionSizingConfig( risk_per_trade_pct=self.settings.risk_per_trade_pct, default_equity=self.settings.default_equity, ), + max_position_value=eq * self.settings.max_position_pct, ) if sizing.position_size <= 0: continue @@ -519,6 +550,7 @@ class TradingExecutionService: stop: float, last_price: float | None = None, reason: str = "orb_live_break", + cum_volume: float = 0.0, ) -> dict: """Fire a breakout entry immediately on a LIVE price cross. @@ -550,14 +582,19 @@ class TradingExecutionService: if stop_f >= entry_ref or trigger_f <= 0: return {"ok": False, "skipped": "bad_geometry"} + eq = self._current_equity() + liq = self.settings.liquidity_max_volume_pct + max_shares = int(liq * cum_volume) if (liq > 0 and cum_volume > 0) else None sizing = calculate_position_size( entry_ref, stop_f, - equity=self.equity, + equity=eq, config=PositionSizingConfig( risk_per_trade_pct=self.settings.risk_per_trade_pct, default_equity=self.settings.default_equity, ), + max_position_value=eq * self.settings.max_position_pct, + max_shares=max_shares, ) if sizing.position_size <= 0: return {"ok": False, "skipped": "zero_size"}