diff --git a/tests/unit/test_trading_execution.py b/tests/unit/test_trading_execution.py index d5c7d7d..db974de 100644 --- a/tests/unit/test_trading_execution.py +++ b/tests/unit/test_trading_execution.py @@ -1068,3 +1068,35 @@ def test_boot_reconcile_off_by_flag(store): session_id="t", ) assert cli.cancelled == [] + + +def test_settled_buying_power_caps_position(store): + """Cash-account GFV guard: entry notional never exceeds broker buying_power + (settled cash on Tradier live). Margin accounts (bp >> equity) unaffected.""" + from alpaca_paper.execution import AlpacaPaperExecutor + + class _Cli: + def __init__(self, bp): + self.bp = bp + def get_positions(self, fresh=False): return [] + def get_account(self): return {"equity": "300", "buying_power": self.bp} + def get_orders(self, **kw): return [] + + svc = TradingExecutionService( + store, executor=AlpacaPaperExecutor(store, client=_Cli("40")), + settings=ExecutionSettings(auto_approve=True), session_id="t", + ) + assert svc._settled_buying_power() == 40.0 + # only $40 settled: a $10 entry sizes to <= 4 shares regardless of risk budget + from strategy.risk.position_sizing import calculate_position_size, PositionSizingConfig + pos_cap = min(300 * 0.16, svc._settled_buying_power()) + sizing = calculate_position_size(10.0, 9.0, equity=300.0, + config=PositionSizingConfig(risk_per_trade_pct=0.05), + max_position_value=pos_cap) + assert sizing.position_size == 4 + + svc2 = TradingExecutionService( + store, executor=AlpacaPaperExecutor(store, client=_Cli(None)), + settings=ExecutionSettings(auto_approve=True), session_id="t", + ) + assert svc2._settled_buying_power() is None # missing field -> no cap diff --git a/trading_execution.py b/trading_execution.py index ee574d9..20ad639 100644 --- a/trading_execution.py +++ b/trading_execution.py @@ -607,6 +607,26 @@ class TradingExecutionService: pass return self.equity + def _settled_buying_power(self) -> float | None: + """Broker buying power, used as an entry-notional ceiling. + + On the Tradier LIVE cash account this is ``cash_available`` — SETTLED + cash only. Capping every buy at it means the bot never spends today's + sale proceeds today, which is the entire good-faith-violation rule — + so the per-day entry cap can match paper instead of a blunt 1/day. + Throughput self-limits (~settled/position-size trades) and refreshes + at T+1 settlement each morning. On margin accounts (Alpaca paper: + ~4x equity) this never binds. None = field unavailable -> no cap.""" + client = getattr(self.executor, "client", None) + if client is not None and hasattr(client, "get_account"): + try: + bp = float((client.get_account() or {}).get("buying_power") or 0.0) + if bp > 0: + return bp + except Exception: # noqa: BLE001 + pass + return None + def _remaining_gross_budget(self, equity: float) -> float: """$ of new notional still allowed under the portfolio gross-notional cap (cap*equity minus the market value of currently-open positions). Inf when @@ -1322,6 +1342,9 @@ class TradingExecutionService: # remaining gross-notional budget (so the book can't pile into ~100%). pos_cap = min(eq * self.settings.max_position_pct, self._remaining_gross_budget(eq)) + _bp = self._settled_buying_power() + if _bp is not None: + pos_cap = min(pos_cap, _bp) # cash account: settled funds only (GFV guard) # 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 @@ -1562,6 +1585,9 @@ class TradingExecutionService: # per-position cap AND remaining portfolio gross-notional budget pos_cap = min(eq * self.settings.max_position_pct, self._remaining_gross_budget(eq)) + _bp = self._settled_buying_power() + if _bp is not None: + pos_cap = min(pos_cap, _bp) # cash account: settled funds only (GFV guard) if pos_cap <= 0: return {"ok": False, "skipped": "gross_notional_cap"} # scale the dollar risk by rank (rank-1 full, rank-2+ half) — both the %