Skip to content

Commit d8729f0

Browse files
authored
Explain small-account cash substitution (#113)
1 parent e913e04 commit d8729f0

4 files changed

Lines changed: 177 additions & 29 deletions

File tree

application/execution_service.py

Lines changed: 155 additions & 26 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,8 @@
66
import json
77
import tempfile
88
import time
9+
from collections.abc import Mapping
10+
from dataclasses import dataclass
911
from datetime import datetime
1012
from pathlib import Path
1113
from typing import Any
@@ -42,14 +44,22 @@ def should_sell_cash_sweep_to_fund_whole_share_buy(
4244
return False
4345
try:
4446
from quant_platform_kit.common.small_account_compatibility import (
45-
project_unbuyable_value_targets_to_cash,
47+
apply_small_account_cash_compatibility,
48+
format_small_account_cash_substitution_notes,
4649
)
4750
except ImportError: # pragma: no cover - compatibility with older pinned shared wheels
48-
def project_unbuyable_value_targets_to_cash(
51+
@dataclass(frozen=True)
52+
class _SmallAccountCashCompatibilityResult:
53+
targets: dict[str, float]
54+
whole_share_substituted_symbols: tuple[str, ...]
55+
safe_haven_cash_substituted_symbols: tuple[str, ...]
56+
cash_substitution_notes: tuple[dict[str, Any], ...]
57+
58+
def _project_unbuyable_value_targets_to_cash(
4959
target_values,
5060
prices,
5161
*,
52-
symbols=None,
62+
candidate_symbols=None,
5363
quantity_step=1.0,
5464
):
5565
adjusted = {
@@ -59,23 +69,140 @@ def project_unbuyable_value_targets_to_cash(
5969
step = max(0.0, float(quantity_step or 0.0))
6070
if step <= 0.0:
6171
return adjusted, ()
62-
candidate_symbols = (
72+
normalized_candidates = (
6373
tuple(adjusted)
64-
if symbols is None
65-
else tuple(dict.fromkeys(str(symbol or "").strip().upper() for symbol in symbols))
74+
if candidate_symbols is None
75+
else tuple(dict.fromkeys(str(symbol or "").strip().upper() for symbol in candidate_symbols))
6676
)
6777
normalized_prices = {
6878
str(symbol or "").strip().upper(): float(price or 0.0)
6979
for symbol, price in dict(prices or {}).items()
7080
}
7181
substituted = []
72-
for symbol in candidate_symbols:
82+
for symbol in normalized_candidates:
7383
target_value = max(0.0, float(adjusted.get(symbol, 0.0) or 0.0))
7484
price = max(0.0, float(normalized_prices.get(symbol, 0.0) or 0.0))
7585
if price > 0.0 and 0.0 < target_value < (price * step):
7686
adjusted[symbol] = 0.0
7787
substituted.append(symbol)
7888
return adjusted, tuple(dict.fromkeys(substituted))
89+
90+
def apply_small_account_cash_compatibility(
91+
target_values,
92+
prices,
93+
*,
94+
candidate_symbols=None,
95+
safe_haven_cash_symbols=(),
96+
quantity_step=1.0,
97+
cash_substitute_limit_usd=2000.0,
98+
):
99+
adjusted_targets, substituted = _project_unbuyable_value_targets_to_cash(
100+
target_values,
101+
prices,
102+
candidate_symbols=candidate_symbols,
103+
quantity_step=quantity_step,
104+
)
105+
normalized_candidates = (
106+
tuple(adjusted_targets)
107+
if candidate_symbols is None
108+
else tuple(dict.fromkeys(str(symbol or "").strip().upper() for symbol in candidate_symbols))
109+
)
110+
remaining_non_safe_targets = [
111+
symbol
112+
for symbol in normalized_candidates
113+
if float(adjusted_targets.get(str(symbol or "").strip().upper(), 0.0) or 0.0) > 0.0
114+
]
115+
safe_haven_symbols = tuple(
116+
dict.fromkeys(
117+
str(symbol or "").strip().upper()
118+
for symbol in safe_haven_cash_symbols
119+
if str(symbol or "").strip()
120+
)
121+
)
122+
safe_haven_substituted = []
123+
if (
124+
substituted
125+
and not remaining_non_safe_targets
126+
and _positive_target_total(adjusted_targets) <= max(0.0, float(cash_substitute_limit_usd or 0.0))
127+
):
128+
for symbol in safe_haven_symbols:
129+
if float(adjusted_targets.get(symbol, 0.0) or 0.0) > 0.0:
130+
adjusted_targets[symbol] = 0.0
131+
safe_haven_substituted.append(symbol)
132+
normalized_targets = {
133+
str(symbol or "").strip().upper(): float(value or 0.0)
134+
for symbol, value in dict(target_values or {}).items()
135+
}
136+
normalized_prices = {
137+
str(symbol or "").strip().upper(): float(price or 0.0)
138+
for symbol, price in dict(prices or {}).items()
139+
}
140+
notes = []
141+
if safe_haven_substituted:
142+
for symbol in substituted:
143+
target_value = max(0.0, float(normalized_targets.get(symbol, 0.0) or 0.0))
144+
price = max(0.0, float(normalized_prices.get(symbol, 0.0) or 0.0))
145+
if target_value <= 0.0 or price <= 0.0:
146+
continue
147+
notes.append(
148+
{
149+
"symbol": symbol,
150+
"target_value": target_value,
151+
"price": price,
152+
"cash_symbols": tuple(safe_haven_substituted),
153+
}
154+
)
155+
return _SmallAccountCashCompatibilityResult(
156+
targets=adjusted_targets,
157+
whole_share_substituted_symbols=substituted,
158+
safe_haven_cash_substituted_symbols=tuple(safe_haven_substituted),
159+
cash_substitution_notes=tuple(notes),
160+
)
161+
162+
def format_small_account_cash_substitution_notes(
163+
notes,
164+
*,
165+
translator,
166+
wrapper_key="buy_deferred",
167+
detail_key="buy_deferred_small_account_cash_substitution",
168+
cash_label_key="cash_label",
169+
symbol_suffix=".US",
170+
):
171+
messages = []
172+
seen_keys = set()
173+
for note in tuple(notes or ()):
174+
if not isinstance(note, Mapping):
175+
continue
176+
symbol = str(note.get("symbol") or "").strip().upper()
177+
if not symbol:
178+
continue
179+
target_value = max(0.0, float(note.get("target_value") or 0.0))
180+
price = max(0.0, float(note.get("price") or 0.0))
181+
if target_value <= 0.0 or price <= 0.0:
182+
continue
183+
cash_symbols = tuple(
184+
dict.fromkeys(
185+
str(cash_symbol or "").strip().upper()
186+
for cash_symbol in tuple(note.get("cash_symbols") or ())
187+
if str(cash_symbol or "").strip()
188+
)
189+
)
190+
cash_symbols_text = ", ".join(f"{cash_symbol}{symbol_suffix}" for cash_symbol in cash_symbols)
191+
if not cash_symbols_text:
192+
cash_symbols_text = translator(cash_label_key)
193+
note_key = (symbol, f"{target_value:.2f}", cash_symbols_text)
194+
if note_key in seen_keys:
195+
continue
196+
seen_keys.add(note_key)
197+
detail = translator(
198+
detail_key,
199+
symbol=f"{symbol}{symbol_suffix}",
200+
diff=f"{target_value:.2f}",
201+
price=f"{price:.2f}",
202+
cash_symbols=cash_symbols_text,
203+
)
204+
messages.append(translator(wrapper_key, detail=detail))
205+
return tuple(messages)
79206
from quant_platform_kit.common.quantity import (
80207
floor_to_quantity_step,
81208
format_quantity,
@@ -663,6 +790,7 @@ def execute_rebalance(
663790
"safe_haven_cash_substituted_symbols": [],
664791
"small_account_whole_share_substituted_symbols": [],
665792
"small_account_safe_haven_cash_substituted_symbols": [],
793+
"small_account_whole_share_cash_notes": [],
666794
"residual_cash_estimate": float(account_values.get("buying_power", 0.0) or 0.0),
667795
"current_stock_weight": 0.0,
668796
"current_safe_haven_weight": 0.0,
@@ -750,31 +878,23 @@ def execute_rebalance(
750878
for symbol in target_mv
751879
if str(symbol or "").strip().upper() not in safe_haven_symbols
752880
)
753-
target_mv, small_account_substituted_symbols = project_unbuyable_value_targets_to_cash(
881+
small_account_compatibility = apply_small_account_cash_compatibility(
754882
target_mv,
755883
prices,
756-
symbols=small_account_candidate_symbols,
884+
candidate_symbols=small_account_candidate_symbols,
885+
safe_haven_cash_symbols=safe_haven_symbols,
757886
quantity_step=order_quantity_step,
887+
cash_substitute_limit_usd=SMALL_ACCOUNT_SAFE_HAVEN_CASH_SUBSTITUTE_LIMIT_USD,
758888
)
889+
target_mv = small_account_compatibility.targets
890+
small_account_substituted_symbols = small_account_compatibility.whole_share_substituted_symbols
759891
for symbol in small_account_substituted_symbols:
760892
target_weights[symbol] = 0.0
761-
remaining_non_safe_targets = [
762-
symbol
763-
for symbol in small_account_candidate_symbols
764-
if float(target_mv.get(str(symbol or "").strip().upper(), 0.0) or 0.0) > 0.0
765-
]
766-
small_account_safe_haven_cash_substituted_symbols: list[str] = []
767-
if (
768-
small_account_substituted_symbols
769-
and not remaining_non_safe_targets
770-
and _positive_target_total(target_mv) <= SMALL_ACCOUNT_SAFE_HAVEN_CASH_SUBSTITUTE_LIMIT_USD
771-
):
772-
for symbol in safe_haven_symbols:
773-
normalized_symbol = str(symbol or "").strip().upper()
774-
if float(target_mv.get(normalized_symbol, 0.0) or 0.0) > 0.0:
775-
target_mv[normalized_symbol] = 0.0
776-
target_weights[normalized_symbol] = 0.0
777-
small_account_safe_haven_cash_substituted_symbols.append(normalized_symbol)
893+
small_account_safe_haven_cash_substituted_symbols = list(
894+
small_account_compatibility.safe_haven_cash_substituted_symbols
895+
)
896+
for symbol in small_account_safe_haven_cash_substituted_symbols:
897+
target_weights[symbol] = 0.0
778898
if safe_haven_symbols:
779899
execution_summary["realized_safe_haven_weight"] = float(
780900
sum(
@@ -788,7 +908,16 @@ def execute_rebalance(
788908
execution_summary["small_account_safe_haven_cash_substituted_symbols"] = (
789909
small_account_safe_haven_cash_substituted_symbols
790910
)
911+
execution_summary["small_account_whole_share_cash_notes"] = list(
912+
small_account_compatibility.cash_substitution_notes
913+
)
791914
trade_logs = []
915+
trade_logs.extend(
916+
format_small_account_cash_substitution_notes(
917+
small_account_compatibility.cash_substitution_notes,
918+
translator=translator,
919+
)
920+
)
792921
target_hash = _build_target_hash(target_weights)
793922
execution_summary["target_vs_current"] = _build_target_diff_rows(target_weights, current_mv, equity)
794923
if equity > 0:

notifications/telegram.py

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,7 @@
1717
"buying_power": "购买力",
1818
"reserved_cash": "预留现金",
1919
"investable_cash": "可投资现金",
20+
"cash_label": "现金",
2021
"empty_positions": " (空仓)",
2122
"empty_target_weights": " (无目标持仓)",
2223
"account_summary_title": "📊 账户摘要",
@@ -40,6 +41,8 @@
4041
"price_fallback_prices": "📌 执行估价: 使用最近收盘价 {count}个标的 ({symbols})",
4142
"target_diff_summary": "调仓变化: {details}",
4243
"no_order_plan_reason": "未下单: {reason}",
44+
"buy_deferred": "ℹ️ [买入说明] {detail}",
45+
"buy_deferred_small_account_cash_substitution": "{symbol} 目标金额 ${diff} 低于 1 股价格 ${price};为避免超过目标仓位,小账户本轮保留现金,不回补 {cash_symbols}",
4346
"trade_date_detail": "交易日={value}",
4447
"target_diff": "目标差异 {symbol}: 当前={current} 目标={target} 变化={delta}",
4548
"pending_orders_detected": "检测到未完成订单: 策略={profile} 标的={symbols}",
@@ -151,6 +154,7 @@
151154
"buying_power": "Buying Power",
152155
"reserved_cash": "Reserved Cash",
153156
"investable_cash": "Investable Cash",
157+
"cash_label": "Cash",
154158
"empty_positions": " (No positions)",
155159
"empty_target_weights": " (No target positions)",
156160
"account_summary_title": "📊 Account Summary",
@@ -174,6 +178,8 @@
174178
"price_fallback_prices": "📌 execution pricing: recent close for {count} symbols ({symbols})",
175179
"target_diff_summary": "Target changes: {details}",
176180
"no_order_plan_reason": "No order submitted: {reason}",
181+
"buy_deferred": "ℹ️ [Buy note] {detail}",
182+
"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}",
177183
"trade_date_detail": "trade_date={value}",
178184
"target_diff": "target_diff {symbol}: current={current} target={target} delta={delta}",
179185
"pending_orders_detected": "pending_orders_detected profile={profile} symbols={symbols}",

requirements.txt

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
flask
22
gunicorn
3-
quant-platform-kit @ git+https://github.com/QuantStrategyLab/QuantPlatformKit.git@190edb21c5fe0c8e0efba66bfd8bacfdd00b3f77
4-
us-equity-strategies @ git+https://github.com/QuantStrategyLab/UsEquityStrategies.git@ead0c075f5b47ec1d05d3a45cfcdda58217fe086
3+
quant-platform-kit @ git+https://github.com/QuantStrategyLab/QuantPlatformKit.git@ceb84a366ed1bf9a53292ff2c73e06b4baac05e2
4+
us-equity-strategies @ git+https://github.com/QuantStrategyLab/UsEquityStrategies.git@f2ebae8aacd8c70292c5b6115a80c6657e64ad1f
55
pandas
66
numpy
77
requests

tests/test_execution_service.py

Lines changed: 14 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -55,6 +55,12 @@ def translate(key, **kwargs):
5555
"dry_run_snapshot_prices": "dry_run_snapshot_prices count={count} symbols={symbols}",
5656
"price_fallback_prices": "price_fallback_prices count={count} symbols={symbols}",
5757
"no_equity": "❌ No equity",
58+
"cash_label": "现金",
59+
"buy_deferred": "ℹ️ [买入说明] {detail}",
60+
"buy_deferred_small_account_cash_substitution": (
61+
"{symbol} 目标金额 ${diff} 低于 1 股价格 ${price};"
62+
"为避免超过目标仓位,小账户本轮保留现金,不回补 {cash_symbols}"
63+
),
5864
}
5965
template = templates[key]
6066
return template.format(**kwargs) if kwargs else template
@@ -238,7 +244,7 @@ def accountValues(self):
238244
submitted = []
239245
monkeypatch.setattr("application.execution_service.time.sleep", lambda _seconds: None)
240246

241-
_trade_logs, summary = execute_rebalance(
247+
trade_logs, summary = execute_rebalance(
242248
FakeIB(),
243249
{},
244250
{},
@@ -273,6 +279,13 @@ def accountValues(self):
273279
assert submitted == []
274280
assert summary["small_account_whole_share_substituted_symbols"] == ["SOXX"]
275281
assert summary["small_account_safe_haven_cash_substituted_symbols"] == ["BOXX"]
282+
assert len(summary["small_account_whole_share_cash_notes"]) == 1
283+
cash_note = summary["small_account_whole_share_cash_notes"][0]
284+
assert cash_note["symbol"] == "SOXX"
285+
assert round(cash_note["target_value"], 3) == 188.277
286+
assert cash_note["price"] == 525.0
287+
assert cash_note["cash_symbols"] == ("BOXX",)
288+
assert any("SOXX.US 目标金额 $188.28 低于 1 股价格 $525.00" in log for log in trade_logs)
276289
assert summary["realized_safe_haven_weight"] == 0.0
277290
boxx_row = next(row for row in summary["target_vs_current"] if row["symbol"] == "BOXX")
278291
assert boxx_row["target_weight"] == 0.0

0 commit comments

Comments
 (0)