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:
1 parent
c7249b22e2
commit
f0fa104511
2 files changed
+58
No files matched your search
@@ -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
|
||||
@@ -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 %
|
||||
|
||||
Reference in new issue
Block a user