From f0fa10451183b91fbfc987deae74084ac6d326c9 Mon Sep 17 00:00:00 2001 From: momentum dev Date: Sat, 1 Aug 2026 09:21:55 -0500 Subject: [PATCH] feat: cap entry notional at broker buying power (cash-account GFV guard) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit On the Tradier live cash account buying_power maps to cash_available — SETTLED cash. Capping every entry's notional at it means the bot never spends today's sale proceeds today, which is the entire good-faith -violation rule. That replaces the blunt 1-trade/day cap: the live account now trades like paper (cap back to 5/day), throughput self-limits at ~settled/position-size sequential trades and refreshes each morning at T+1. On margin accounts buying power is ~4x equity so the cap never binds — one universal rule, no broker branches. Fail-open when the field is absent. Co-Authored-By: Claude Fable 5 --- tests/unit/test_trading_execution.py | 32 ++++++++++++++++++++++++++++ trading_execution.py | 26 ++++++++++++++++++++++ 2 files changed, 58 insertions(+) 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 %