feat: cap entry notional at broker buying power (cash-account GFV guard)

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 <noreply@anthropic.com>
This commit is contained in:
momentum devandClaude Fable 5 committed 2026-08-01 09:21:55 -05:00
1 parent c7249b22e2
commit f0fa104511
2 files changed
+58

No files matched your search

+32
View File
@@ -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
+26
View File
@@ -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 %