Skip to content

Commit 8d2dd29

Browse files
authored
Merge pull request #130 from QuantStrategyLab/fix/cash-only-equity-no-margin
Cash-only equity display and anti-margin execution guards
2 parents 2772250 + 9fd6506 commit 8d2dd29

6 files changed

Lines changed: 104 additions & 39 deletions

File tree

application/execution_service.py

Lines changed: 44 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -497,6 +497,7 @@ def execute_value_target_plan(
497497
0.0,
498498
float(execution.get("investable_cash") or portfolio.get("liquid_cash") or 0.0),
499499
)
500+
raw_liquid_cash = float(portfolio.get("liquid_cash") or 0.0)
500501
order_notional_cap = (
501502
max(0.0, float(max_order_notional_usd))
502503
if max_order_notional_usd is not None and float(max_order_notional_usd) > 0.0
@@ -506,6 +507,7 @@ def execute_value_target_plan(
506507
submitted: list[dict[str, Any]] = []
507508
skipped: list[dict[str, Any]] = []
508509
reference_prices: dict[str, float] = {}
510+
pending_sell_release_symbols: list[str] = []
509511

510512
tradable_deltas: list[tuple[str, float, float]] = []
511513
for symbol in sorted(set(targets) | set(market_values)):
@@ -540,6 +542,7 @@ def execute_value_target_plan(
540542
)
541543
quantity = _floor_quantity(sell_budget / price)
542544
if quantity <= 0:
545+
pending_sell_release_symbols.append(symbol)
543546
skipped.append(
544547
{
545548
"symbol": symbol,
@@ -552,19 +555,58 @@ def execute_value_target_plan(
552555
}
553556
)
554557
continue
558+
sell_limit_price = price * float(limit_sell_discount)
555559
submitted.append(
556560
_submit_order(
557561
execution_port,
558562
symbol=symbol,
559563
side="sell",
560564
quantity=quantity,
561-
limit_price=price * float(limit_sell_discount),
565+
limit_price=sell_limit_price,
562566
max_notional_usd=max_order_notional_usd,
563567
)
564568
)
569+
investable_cash += quantity * sell_limit_price
565570
continue
566571

567-
for symbol, delta_value, price in [item for item in tradable_deltas if item[1] > 0]:
572+
buy_deltas = [item for item in tradable_deltas if item[1] > 0]
573+
buys_blocked_reason: str | None = None
574+
if buy_deltas and pending_sell_release_symbols:
575+
estimated_buy_cost = 0.0
576+
for symbol, delta_value, price in buy_deltas:
577+
buy_budget = min(float(delta_value), investable_cash)
578+
if order_notional_cap is not None:
579+
buy_budget = min(buy_budget, order_notional_cap)
580+
limit_price = _limit_buy_price(
581+
symbol, price, limit_buy_premium, limit_buy_premium_by_symbol
582+
)
583+
quantity = _floor_quantity(buy_budget / limit_price) if limit_price > 0 else 0
584+
if quantity > 0:
585+
estimated_buy_cost += quantity * limit_price
586+
if estimated_buy_cost > investable_cash:
587+
buys_blocked_reason = "pending_sell_release"
588+
for symbol, _delta_value, _price in buy_deltas:
589+
skipped.append(
590+
{
591+
"symbol": symbol,
592+
"reason": "pending_sell_release",
593+
"pending_sell_symbols": list(pending_sell_release_symbols),
594+
}
595+
)
596+
buy_deltas = []
597+
elif buy_deltas and raw_liquid_cash < 0.0:
598+
buys_blocked_reason = "negative_cash"
599+
for symbol, _delta_value, _price in buy_deltas:
600+
skipped.append(
601+
{
602+
"symbol": symbol,
603+
"reason": "negative_cash",
604+
"liquid_cash": round(raw_liquid_cash, 2),
605+
}
606+
)
607+
buy_deltas = []
608+
609+
for symbol, delta_value, price in buy_deltas:
568610
buy_budget = min(float(delta_value), investable_cash)
569611
if order_notional_cap is not None:
570612
buy_budget = min(buy_budget, order_notional_cap)

application/runtime_broker_adapters.py

Lines changed: 10 additions & 20 deletions
Original file line numberDiff line numberDiff line change
@@ -91,32 +91,22 @@ def _resolve_total_equity(
9191
position_market_value: float,
9292
prefer_cash_plus_positions: bool = False,
9393
) -> tuple[float, str]:
94-
resolved_cash = _positive_or_none(cash_balance)
95-
if resolved_cash is not None:
96-
combined_value = resolved_cash + max(0.0, float(position_market_value))
97-
if prefer_cash_plus_positions and combined_value > 0.0:
94+
del buying_power # Cash-only: never use margin buying power for strategy equity.
95+
if cash_balance is not None:
96+
combined_value = float(cash_balance) + max(0.0, float(position_market_value))
97+
if prefer_cash_plus_positions:
9898
return combined_value, "cash_plus_positions"
99-
else:
100-
combined_value = None
101-
102-
balance_total = _positive_or_none(
103-
_first_numeric_by_keyword_groups(balances, _TOTAL_EQUITY_KEYWORD_GROUPS)
104-
)
105-
if balance_total is not None:
106-
return balance_total, "balance_total"
107-
108-
if combined_value is not None:
10999
if combined_value > 0.0:
110100
return combined_value, "cash_plus_positions"
111101

102+
balance_total = _first_numeric_by_keyword_groups(balances, _TOTAL_EQUITY_KEYWORD_GROUPS)
103+
if balance_total is not None:
104+
return float(balance_total), "balance_total"
105+
112106
positive_position_value = _positive_or_none(position_market_value)
113107
if positive_position_value is not None:
114108
return positive_position_value, "positions"
115109

116-
positive_buying_power = _positive_or_none(buying_power)
117-
if positive_buying_power is not None:
118-
return positive_buying_power, "buying_power_fallback"
119-
120110
return 0.0, "unresolved"
121111

122112

@@ -256,8 +246,8 @@ def build_portfolio_snapshot(self) -> PortfolioSnapshot:
256246
account_id=mask_account_id(self.account),
257247
)
258248
)
259-
buying_power = _first_numeric_by_keyword_groups(balances, _BUYING_POWER_KEYWORD_GROUPS)
260249
cash_balance = _first_numeric_by_keyword_groups(balances, _CASH_BALANCE_KEYWORD_GROUPS)
250+
buying_power = cash_balance
261251
position_market_value = sum(position.market_value for position in positions)
262252
total_equity, total_equity_source = _resolve_total_equity(
263253
balances=balances,
@@ -269,7 +259,7 @@ def build_portfolio_snapshot(self) -> PortfolioSnapshot:
269259
return PortfolioSnapshot(
270260
as_of=self.clock(),
271261
total_equity=float(total_equity),
272-
buying_power=buying_power,
262+
buying_power=float(cash_balance or 0.0),
273263
cash_balance=cash_balance,
274264
positions=tuple(positions),
275265
metadata={

decision_mapper.py

Lines changed: 4 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -4,13 +4,15 @@
44
from dataclasses import replace
55
from typing import Any
66

7+
from us_equity_strategies.cash_only_equity import (
8+
build_cash_only_portfolio_inputs_from_snapshot,
9+
)
710
from quant_platform_kit.strategy_contracts import (
811
PositionTarget,
912
StrategyContractValidationError,
1013
StrategyDecision,
1114
ValueTargetExecutionAnnotations,
1215
build_value_target_execution_annotations,
13-
build_value_target_portfolio_inputs_from_snapshot,
1416
build_value_target_runtime_plan,
1517
resolve_decision_target_mode,
1618
translate_decision_to_target_mode,
@@ -403,10 +405,9 @@ def map_strategy_decision_to_plan(
403405
runtime_metadata: Mapping[str, Any] | None = None,
404406
) -> dict[str, Any]:
405407
canonical_profile = resolve_canonical_profile(strategy_profile)
406-
portfolio_inputs = build_value_target_portfolio_inputs_from_snapshot(
408+
portfolio_inputs = build_cash_only_portfolio_inputs_from_snapshot(
407409
snapshot,
408410
include_sellable_quantities=True,
409-
liquid_cash=float(snapshot.buying_power or snapshot.cash_balance or 0.0),
410411
)
411412
normalized_decision, translated_annotations = _normalize_to_value_decision(
412413
decision,

notifications/telegram.py

Lines changed: 38 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -185,7 +185,7 @@ def format_small_account_whole_share_bootstrap_notes(
185185
"account_overview_title": "📌 策略账户概览",
186186
"equity": "净值",
187187
"total_assets": "总资产(策略标的+现金)",
188-
"buying_power": "购买力",
188+
"buying_power": "可用现金",
189189
"reserved_cash": "预留现金",
190190
"investable_cash": "可投资现金",
191191
"cash_label": "现金",
@@ -332,10 +332,14 @@ def format_small_account_whole_share_bootstrap_notes(
332332
"strategy_name_ibit_smart_dca": "IBIT 比特币 ETF 智能定投",
333333
"skip_reason_below_trade_threshold": "低于调仓阈值",
334334
"skip_reason_quote_unavailable": "无法获取报价",
335-
"skip_reason_sell_quantity_zero": "卖出股数为0",
336-
"skip_reason_buy_quantity_zero": "买入股数为0",
335+
"skip_reason_sell_quantity_zero": "整数股不足 1 股,无需下单",
336+
"skip_reason_pending_sell_release": "需先减仓但整数股卖单未成交,现金不足以对应买入,跳过以避免融资",
337+
"skip_reason_negative_cash": "账户现金为负,跳过买入以避免额外融资",
338+
"skip_reason_buy_quantity_zero": "整数股不足 1 股,无需下单",
337339
"skip_reason_insufficient_cash_for_whole_share": "现金不足以买入一整股",
338340
"skip_reason_unknown": "未知原因",
341+
"deferred_orders_line": "ℹ️ [本轮跳过] {details}",
342+
"skip_symbols_reason": "{symbols}({reason})",
339343
},
340344
"en": {
341345
"rebalance_title": "🔔 【Rebalance Instruction】",
@@ -347,7 +351,7 @@ def format_small_account_whole_share_bootstrap_notes(
347351
"account_overview_title": "📌 Strategy Account",
348352
"equity": "Equity",
349353
"total_assets": "Total assets",
350-
"buying_power": "Buying power",
354+
"buying_power": "Available cash",
351355
"reserved_cash": "Reserved cash",
352356
"investable_cash": "Investable cash",
353357
"cash_label": "Cash",
@@ -494,10 +498,14 @@ def format_small_account_whole_share_bootstrap_notes(
494498
"strategy_name_ibit_smart_dca": "IBIT Smart DCA",
495499
"skip_reason_below_trade_threshold": "below trade threshold",
496500
"skip_reason_quote_unavailable": "quote unavailable",
497-
"skip_reason_sell_quantity_zero": "sell quantity rounds to 0",
498-
"skip_reason_buy_quantity_zero": "buy quantity rounds to 0",
501+
"skip_reason_sell_quantity_zero": "whole-share quantity rounds to 0; no order needed",
502+
"skip_reason_pending_sell_release": "trim still pending but whole-share sell rounded to 0; buy skipped this cycle to avoid margin",
503+
"skip_reason_negative_cash": "account cash is negative; buy skipped to avoid additional margin",
504+
"skip_reason_buy_quantity_zero": "whole-share quantity rounds to 0; no order needed",
499505
"skip_reason_insufficient_cash_for_whole_share": "insufficient cash for one whole share",
500506
"skip_reason_unknown": "unknown reason",
507+
"deferred_orders_line": "ℹ️ [Skipped this cycle] {details}",
508+
"skip_symbols_reason": "{symbols} ({reason})",
501509
},
502510
}
503511

@@ -705,9 +713,9 @@ def _format_generated_dashboard_lines(
705713
total_equity = _safe_float(portfolio.get("total_equity"))
706714
if total_equity is not None:
707715
lines.append(f" - {translator('total_assets')}: {_format_money(total_equity)}")
708-
buying_power = _safe_float(portfolio.get("buying_power"))
716+
buying_power = _safe_float(portfolio.get("liquid_cash"))
709717
if buying_power is None:
710-
buying_power = _safe_float(portfolio.get("liquid_cash"))
718+
buying_power = _safe_float(portfolio.get("buying_power"))
711719
if buying_power is not None:
712720
lines.append(f" - {translator('buying_power')}: {_format_money(buying_power)}")
713721
reserved_cash = _safe_float(execution.get("reserved_cash"))
@@ -1115,7 +1123,16 @@ def _format_skipped_reason(skipped: list[Mapping[str, Any]], *, translator: Call
11151123
grouped[reason].append(symbol)
11161124
parts = []
11171125
for reason, symbols in grouped.items():
1118-
parts.append(f"{reason}:{','.join(symbols)}" if symbols else reason)
1126+
if symbols:
1127+
parts.append(
1128+
translator(
1129+
"skip_symbols_reason",
1130+
symbols=",".join(symbols),
1131+
reason=reason,
1132+
)
1133+
)
1134+
else:
1135+
parts.append(reason)
11191136
return ", ".join(parts) if parts else translator("no_executable_orders")
11201137

11211138

@@ -1185,6 +1202,18 @@ def render_cycle_summary(result: Mapping[str, Any], *, lang: str = "en") -> str:
11851202
if submitted:
11861203
lines.append(translator("order_logs_title"))
11871204
lines.extend(_format_order_lines(submitted, dry_run_only=dry_run_only, translator=translator))
1205+
meaningful_skipped = [
1206+
item
1207+
for item in skipped
1208+
if str(item.get("reason") or "") != "below_trade_threshold"
1209+
]
1210+
if meaningful_skipped:
1211+
lines.append(
1212+
translator(
1213+
"deferred_orders_line",
1214+
details=_format_skipped_reason(meaningful_skipped, translator=translator),
1215+
)
1216+
)
11881217
elif skipped and has_rebalance_attempt:
11891218
lines.append(translator("order_logs_title"))
11901219
reason = _format_skipped_reason(skipped, translator=translator)

requirements.txt

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,7 @@ flask
22
gunicorn
33
firstrade==0.0.39
44
quant-platform-kit @ git+https://github.com/QuantStrategyLab/QuantPlatformKit.git@b821e8c318e15d40f925c84a007ae335a3415cd5
5-
us-equity-strategies @ git+https://github.com/QuantStrategyLab/UsEquityStrategies.git@0cbdacc95fb9041f590472254aef8f1cea35adf8
5+
us-equity-strategies @ git+https://github.com/QuantStrategyLab/UsEquityStrategies.git@6772cb1
66
google-cloud-storage
77
google-auth
88
requests

tests/test_rebalance_service.py

Lines changed: 7 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -797,7 +797,7 @@ def test_render_cycle_summary_formats_skipped_orders_in_unified_chinese_template
797797
assert "🎯 信号: 入场信号" in message
798798
assert "调仓变化: BOXX +7.89 USD, QQQ +44.39 USD, TQQQ +44.39 USD" in message
799799
assert "🧾 执行明细" in message
800-
assert "未下单: 原因=买入股数为0:TQQQ,QQQ, 低于调仓阈值:BOXX" in message
800+
assert "未下单: 原因=TQQQ,QQQ(整数股不足 1 股,无需下单), BOXX(低于调仓阈值)" in message
801801
assert "profile:" not in message
802802
assert "targets:" not in message
803803

@@ -972,7 +972,10 @@ def test_render_cycle_summary_formats_skipped_orders_in_unified_english_template
972972
assert "🎯 Signal: Entry Signal" in message
973973
assert "Target changes: BOXX +7.89 USD, QQQ +44.39 USD, TQQQ +44.39 USD" in message
974974
assert "🧾 Execution details" in message
975-
assert "No order submitted: reason=buy quantity rounds to 0:TQQQ,QQQ, below trade threshold:BOXX" in message
975+
assert (
976+
"No order submitted: reason=TQQQ,QQQ (whole-share quantity rounds to 0; no order needed), "
977+
"BOXX (below trade threshold)"
978+
) in message
976979
assert "账户" not in message
977980
assert "信号" not in message
978981
assert "profile:" not in message
@@ -1009,7 +1012,7 @@ def test_render_cycle_summary_shows_funding_blocked_banner():
10091012
lang="zh",
10101013
)
10111014

1012-
assert "⚠️ 资金不足,本周期不再自动重试: 现金不足以买入一整股:NVDA" in message
1015+
assert "⚠️ 资金不足,本周期不再自动重试: NVDA(现金不足以买入一整股)" in message
10131016

10141017

10151018
def test_render_cycle_summary_shows_retryable_execution_blocked_banner():
@@ -1041,4 +1044,4 @@ def test_render_cycle_summary_shows_retryable_execution_blocked_banner():
10411044
lang="en",
10421045
)
10431046

1044-
assert "⚠️ Execution blocked; retryable within window: quote unavailable:NVDA" in message
1047+
assert "⚠️ Execution blocked; retryable within window: NVDA (quote unavailable)" in message

0 commit comments

Comments
 (0)