Skip to content

Commit 86c0173

Browse files
Pigbibicodex
andcommitted
fix: block small-account strategy buy top-ups
Co-Authored-By: Codex <noreply@openai.com>
1 parent ab53352 commit 86c0173

3 files changed

Lines changed: 74 additions & 1 deletion

File tree

application/execution_service.py

Lines changed: 15 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1485,6 +1485,8 @@ def record_quote_snapshot(symbol, snapshot) -> None:
14851485
"small_account_safe_haven_cash_substituted_symbols": [],
14861486
"small_account_whole_share_cash_notes": [],
14871487
"small_account_allocation_drift_notes": [],
1488+
"small_account_buy_blocked": False,
1489+
"small_account_buy_block_reason": None,
14881490
"residual_cash_estimate": float(account_values.get("buying_power", 0.0) or 0.0),
14891491
"projected_sell_release_value": 0.0,
14901492
"current_stock_weight": 0.0,
@@ -2128,7 +2130,19 @@ def cash_sweep_sale_quantity_to_fund_buy(max_quantity: int, candidate_symbols: t
21282130
if current_mv.get(symbol, 0.0) < float(target or 0.0) - threshold
21292131
]
21302132
buys_blocked_reason = None
2131-
if cash_only_execution and pending_sell_release_symbols and buy_needed_symbols:
2133+
if bool(signal_metadata.get("small_account_warning")) and buy_needed_symbols:
2134+
buys_blocked_reason = "small_account_below_recommended_equity"
2135+
execution_summary["small_account_buy_blocked"] = True
2136+
execution_summary["small_account_buy_block_reason"] = buys_blocked_reason
2137+
execution_summary["skipped_reasons"].append(buys_blocked_reason)
2138+
trade_logs.append(
2139+
translator(
2140+
"buy_deferred_small_account",
2141+
portfolio_equity=f"{float(equity or 0.0):,.2f}",
2142+
min_recommended_equity=f"{float(signal_metadata.get('min_recommended_equity_usd') or 0.0):,.2f}",
2143+
)
2144+
)
2145+
if buys_blocked_reason is None and cash_only_execution and pending_sell_release_symbols and buy_needed_symbols:
21322146
if _rotation_guard_should_block_buys(
21332147
pending_sell_release_symbols=pending_sell_release_symbols,
21342148
buy_needed_symbols=buy_needed_symbols,

notifications/telegram.py

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -68,6 +68,7 @@ def _break_telegram_market_symbol_auto_links(value) -> str:
6868
"no_order_plan_reason": "未下单: {reason}",
6969
"buy_deferred": "ℹ️ [买入说明] {detail}",
7070
"buy_deferred_small_account_cash_substitution": "{symbol} 目标金额 ${diff} 低于 1 股价格 ${price};为避免超过目标仓位,小账户本轮保留现金,不回补 {cash_symbols}",
71+
"buy_deferred_small_account": "ℹ️ [买入保护] 账户净值 ${portfolio_equity} 低于策略建议 ${min_recommended_equity};本轮禁止新增或加仓,允许策略正常减仓",
7172
"small_account_allocation_drift": "📏 整数股偏离:若本轮订单全部成交,{details}",
7273
"small_account_allocation_drift_detail": "{symbol} 预计 {projected_weight} vs 目标 {target_weight}({drift_weight})",
7374
"buy_lifted_small_account_whole_share": "ℹ️ [买入说明] {symbols} 目标金额接近 1 股;小账户整数股兼容,本轮允许按 1 股下单",
@@ -286,6 +287,7 @@ def _break_telegram_market_symbol_auto_links(value) -> str:
286287
"no_order_plan_reason": "No order submitted: {reason}",
287288
"buy_deferred": "ℹ️ [Buy note] {detail}",
288289
"buy_deferred_small_account_cash_substitution": "{symbol} target ${diff} is below the 1-share price ${price}; to avoid exceeding the target allocation, this small account keeps cash this cycle and does not rebuy {cash_symbols}",
290+
"buy_deferred_small_account": "ℹ️ [Buy guard] Portfolio equity ${portfolio_equity} is below the strategy recommendation ${min_recommended_equity}; new buys and top-ups are blocked while strategy-driven reductions remain allowed",
289291
"small_account_allocation_drift": "📏 Integer-share drift: if this cycle's orders fully fill, {details}",
290292
"small_account_allocation_drift_detail": "{symbol} projected {projected_weight} vs target {target_weight} ({drift_weight})",
291293
"buy_lifted_small_account_whole_share": "ℹ️ [Buy note] {symbols} target is close to one share; small-account whole-share compatibility allows a 1-share order this cycle",

tests/test_execution_service.py

Lines changed: 57 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -62,6 +62,9 @@ def translate(key, **kwargs):
6262
"{symbol} 目标金额 ${diff} 低于 1 股价格 ${price};"
6363
"为避免超过目标仓位,小账户本轮保留现金,不回补 {cash_symbols}"
6464
),
65+
"buy_deferred_small_account": (
66+
"small_account_buy_blocked equity={portfolio_equity} minimum={min_recommended_equity}"
67+
),
6568
"small_account_allocation_drift": "📏 整数股偏离:若本轮订单全部成交,{details}",
6669
"small_account_allocation_drift_detail": (
6770
"{symbol} 预计 {projected_weight} vs 目标 {target_weight}({drift_weight})"
@@ -849,6 +852,60 @@ def accountValues(self):
849852
assert boxx_row["target_weight"] == 0.0
850853

851854

855+
def test_execute_rebalance_blocks_small_account_buy_but_keeps_strategy_reductions_available(monkeypatch, tmp_path):
856+
class FakeIB:
857+
def openTrades(self):
858+
return []
859+
860+
def fills(self):
861+
return []
862+
863+
def accountValues(self):
864+
return [SimpleNamespace(tag="AvailableFunds", currency="USD", value="500.00")]
865+
866+
submitted = []
867+
monkeypatch.setattr("application.execution_service.time.sleep", lambda _seconds: None)
868+
869+
trade_logs, summary = execute_rebalance(
870+
FakeIB(),
871+
{},
872+
{},
873+
{"equity": 500.0, "buying_power": 500.0},
874+
fetch_quote_snapshots=lambda _ib, symbols: {
875+
symbol: SimpleNamespace(last_price=100.0) for symbol in symbols
876+
},
877+
submit_order_intent=lambda _ib, intent: submitted.append(intent),
878+
order_intent_cls=OrderIntent,
879+
translator=translate,
880+
strategy_symbols=["VOO", "BOXX"],
881+
strategy_profile="soxl_soxx_trend_income",
882+
signal_metadata=_signal_metadata(
883+
{"VOO": 0.8, "BOXX": 0.2},
884+
risk_symbols=("VOO",),
885+
safe_haven_symbols=("BOXX",),
886+
trade_date="2026-08-24",
887+
small_account_warning=True,
888+
min_recommended_equity_usd=1000.0,
889+
),
890+
dry_run_only=False,
891+
cash_reserve_ratio=0.0,
892+
rebalance_threshold_ratio=0.02,
893+
limit_buy_premium=1.0,
894+
sell_settle_delay_sec=0,
895+
execution_lock_dir=tmp_path,
896+
return_summary=True,
897+
)
898+
899+
assert submitted == []
900+
assert summary["small_account_buy_blocked"] is True
901+
assert summary["small_account_buy_block_reason"] == "small_account_below_recommended_equity"
902+
assert {order["reason"] for order in summary["orders_skipped"]} == {
903+
"small_account_below_recommended_equity"
904+
}
905+
assert "small_account_below_recommended_equity" in summary["skipped_reasons"]
906+
assert any(log.startswith("small_account_buy_blocked") for log in trade_logs)
907+
908+
852909
def test_execute_rebalance_routes_order_to_single_account_id(monkeypatch, tmp_path):
853910
class FakeIB:
854911
def openTrades(self):

0 commit comments

Comments
 (0)