Skip to content

Commit b7e89fd

Browse files
committed
Add small-account cash substitution notes
1 parent 190edb2 commit b7e89fd

3 files changed

Lines changed: 249 additions & 6 deletions

File tree

pyproject.toml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
44

55
[project]
66
name = "quant-platform-kit"
7-
version = "0.7.33"
7+
version = "0.7.34"
88
description = "Shared broker adapters, domain models, execution ports, and notification utilities for QuantStrategyLab strategies."
99
readme = "README.md"
1010
requires-python = ">=3.9"

src/quant_platform_kit/common/small_account_compatibility.py

Lines changed: 178 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -3,15 +3,54 @@
33
from __future__ import annotations
44

55
from collections.abc import Iterable, Mapping
6+
from dataclasses import dataclass
67

78

8-
__all__ = ["project_unbuyable_value_targets_to_cash"]
9+
__all__ = [
10+
"SmallAccountCashCompatibilityResult",
11+
"apply_small_account_cash_compatibility",
12+
"format_small_account_cash_substitution_notes",
13+
"project_unbuyable_value_targets_to_cash",
14+
]
15+
16+
17+
@dataclass(frozen=True)
18+
class SmallAccountCashCompatibilityResult:
19+
targets: dict[str, float]
20+
whole_share_substituted_symbols: tuple[str, ...]
21+
safe_haven_cash_substituted_symbols: tuple[str, ...]
22+
cash_substitution_notes: tuple[dict[str, object], ...]
923

1024

1125
def _normalize_symbol(value: object) -> str:
1226
return str(value or "").strip().upper()
1327

1428

29+
def _positive_target_total(targets: Mapping[str, object]) -> float:
30+
total = 0.0
31+
for value in dict(targets or {}).values():
32+
try:
33+
total += max(0.0, float(value or 0.0))
34+
except (TypeError, ValueError):
35+
continue
36+
return total
37+
38+
39+
def _normalize_prices(prices: Mapping[str, object]) -> dict[str, float]:
40+
return {
41+
_normalize_symbol(symbol): float(price or 0.0)
42+
for symbol, price in dict(prices or {}).items()
43+
}
44+
45+
46+
def _format_symbol(symbol: str, *, suffix: str) -> str:
47+
normalized = _normalize_symbol(symbol)
48+
normalized_suffix = str(suffix or "").strip()
49+
if normalized_suffix and not normalized.endswith(normalized_suffix.upper()):
50+
return f"{normalized}{normalized_suffix}"
51+
return normalized
52+
53+
1554
def project_unbuyable_value_targets_to_cash(
1655
target_values: Mapping[str, object],
1756
prices: Mapping[str, object],
@@ -40,10 +79,7 @@ def project_unbuyable_value_targets_to_cash(
4079
candidate_symbols = tuple(dict.fromkeys(_normalize_symbol(symbol) for symbol in symbols))
4180

4281
substituted: list[str] = []
43-
normalized_prices = {
44-
_normalize_symbol(symbol): float(price or 0.0)
45-
for symbol, price in dict(prices or {}).items()
46-
}
82+
normalized_prices = _normalize_prices(prices)
4783
for symbol in candidate_symbols:
4884
if not symbol:
4985
continue
@@ -56,3 +92,140 @@ def project_unbuyable_value_targets_to_cash(
5692
substituted.append(symbol)
5793

5894
return adjusted, tuple(dict.fromkeys(substituted))
95+
96+
97+
def apply_small_account_cash_compatibility(
98+
target_values: Mapping[str, object],
99+
prices: Mapping[str, object],
100+
*,
101+
candidate_symbols: Iterable[str] | None = None,
102+
safe_haven_cash_symbols: Iterable[str] = (),
103+
quantity_step: float = 1.0,
104+
cash_substitute_limit_usd: float = 2000.0,
105+
) -> SmallAccountCashCompatibilityResult:
106+
"""Apply whole-share small-account projection and cash-safe-haven fallback.
107+
108+
If every risk/income target that remains positive is below one tradable unit,
109+
and the remaining positive safe-haven/cash-sweep sleeve is still small, the
110+
safe-haven target is also projected to cash. The returned notes preserve the
111+
original target and price so platform notifications can explain why no risk
112+
or safe-haven rebuy was submitted.
113+
"""
114+
115+
adjusted_targets, substituted = project_unbuyable_value_targets_to_cash(
116+
target_values,
117+
prices,
118+
symbols=candidate_symbols,
119+
quantity_step=quantity_step,
120+
)
121+
normalized_candidates = (
122+
tuple(adjusted_targets)
123+
if candidate_symbols is None
124+
else tuple(dict.fromkeys(_normalize_symbol(symbol) for symbol in candidate_symbols))
125+
)
126+
remaining_non_safe_targets = [
127+
symbol
128+
for symbol in normalized_candidates
129+
if float(adjusted_targets.get(_normalize_symbol(symbol), 0.0) or 0.0) > 0.0
130+
]
131+
safe_haven_symbols = tuple(
132+
dict.fromkeys(
133+
_normalize_symbol(symbol)
134+
for symbol in safe_haven_cash_symbols
135+
if _normalize_symbol(symbol)
136+
)
137+
)
138+
safe_haven_substituted: list[str] = []
139+
if (
140+
substituted
141+
and not remaining_non_safe_targets
142+
and _positive_target_total(adjusted_targets) <= max(0.0, float(cash_substitute_limit_usd or 0.0))
143+
):
144+
for symbol in safe_haven_symbols:
145+
if float(adjusted_targets.get(symbol, 0.0) or 0.0) > 0.0:
146+
adjusted_targets[symbol] = 0.0
147+
safe_haven_substituted.append(symbol)
148+
149+
notes: list[dict[str, object]] = []
150+
if safe_haven_substituted:
151+
normalized_targets = {
152+
_normalize_symbol(symbol): float(value or 0.0)
153+
for symbol, value in dict(target_values or {}).items()
154+
}
155+
normalized_prices = _normalize_prices(prices)
156+
for symbol in substituted:
157+
target_value = max(0.0, float(normalized_targets.get(symbol, 0.0) or 0.0))
158+
price = max(0.0, float(normalized_prices.get(symbol, 0.0) or 0.0))
159+
if target_value <= 0.0 or price <= 0.0:
160+
continue
161+
notes.append(
162+
{
163+
"symbol": symbol,
164+
"target_value": target_value,
165+
"price": price,
166+
"cash_symbols": tuple(safe_haven_substituted),
167+
}
168+
)
169+
170+
return SmallAccountCashCompatibilityResult(
171+
targets=adjusted_targets,
172+
whole_share_substituted_symbols=substituted,
173+
safe_haven_cash_substituted_symbols=tuple(safe_haven_substituted),
174+
cash_substitution_notes=tuple(notes),
175+
)
176+
177+
178+
def format_small_account_cash_substitution_notes(
179+
notes: Iterable[Mapping[str, object]],
180+
*,
181+
translator,
182+
wrapper_key: str = "buy_deferred",
183+
detail_key: str = "buy_deferred_small_account_cash_substitution",
184+
cash_label_key: str = "cash_label",
185+
symbol_suffix: str = ".US",
186+
) -> tuple[str, ...]:
187+
"""Render small-account cash substitution notes through platform i18n."""
188+
189+
messages: list[str] = []
190+
seen_keys: set[tuple[str, str, str]] = set()
191+
for note in tuple(notes or ()):
192+
if not isinstance(note, Mapping):
193+
continue
194+
symbol = _normalize_symbol(note.get("symbol"))
195+
if not symbol:
196+
continue
197+
target_value = max(0.0, float(note.get("target_value") or 0.0))
198+
price = max(0.0, float(note.get("price") or 0.0))
199+
if target_value <= 0.0 or price <= 0.0:
200+
continue
201+
cash_symbols = tuple(
202+
dict.fromkeys(
203+
_normalize_symbol(cash_symbol)
204+
for cash_symbol in tuple(note.get("cash_symbols") or ())
205+
if _normalize_symbol(cash_symbol)
206+
)
207+
)
208+
cash_symbols_text = ", ".join(
209+
_format_symbol(cash_symbol, suffix=symbol_suffix)
210+
for cash_symbol in cash_symbols
211+
)
212+
if not cash_symbols_text:
213+
cash_symbols_text = str(translator(cash_label_key)).strip()
214+
if not cash_symbols_text or cash_symbols_text == cash_label_key:
215+
cash_symbols_text = "cash"
216+
note_key = (symbol, f"{target_value:.2f}", cash_symbols_text)
217+
if note_key in seen_keys:
218+
continue
219+
seen_keys.add(note_key)
220+
detail = translator(
221+
detail_key,
222+
symbol=_format_symbol(symbol, suffix=symbol_suffix),
223+
diff=f"{target_value:.2f}",
224+
price=f"{price:.2f}",
225+
cash_symbols=cash_symbols_text,
226+
)
227+
message = translator(wrapper_key, detail=detail)
228+
if not message or message == wrapper_key:
229+
message = detail
230+
messages.append(message)
231+
return tuple(messages)

tests/test_small_account_compatibility.py

Lines changed: 70 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,8 @@
11
import unittest
22

33
from quant_platform_kit.common.small_account_compatibility import (
4+
apply_small_account_cash_compatibility,
5+
format_small_account_cash_substitution_notes,
46
project_unbuyable_value_targets_to_cash,
57
)
68

@@ -28,6 +30,74 @@ def test_keeps_targets_that_can_buy_one_quantity_step(self):
2830
self.assertEqual(adjusted["BBB"], 0.0)
2931
self.assertEqual(substituted, ("BBB",))
3032

33+
def test_projects_safe_haven_to_cash_when_only_risk_target_is_unbuyable(self):
34+
result = apply_small_account_cash_compatibility(
35+
{"SOXX": 163.14, "BOXX": 1224.46},
36+
{"SOXX": 504.60, "BOXX": 116.59},
37+
candidate_symbols=("SOXX",),
38+
safe_haven_cash_symbols=("BOXX",),
39+
cash_substitute_limit_usd=2000.0,
40+
)
41+
42+
self.assertEqual(result.targets["SOXX"], 0.0)
43+
self.assertEqual(result.targets["BOXX"], 0.0)
44+
self.assertEqual(result.whole_share_substituted_symbols, ("SOXX",))
45+
self.assertEqual(result.safe_haven_cash_substituted_symbols, ("BOXX",))
46+
self.assertEqual(
47+
result.cash_substitution_notes,
48+
(
49+
{
50+
"symbol": "SOXX",
51+
"target_value": 163.14,
52+
"price": 504.60,
53+
"cash_symbols": ("BOXX",),
54+
},
55+
),
56+
)
57+
58+
def test_keeps_safe_haven_when_cash_projection_exceeds_small_account_limit(self):
59+
result = apply_small_account_cash_compatibility(
60+
{"SOXX": 163.14, "BOXX": 5000.0},
61+
{"SOXX": 504.60, "BOXX": 116.59},
62+
candidate_symbols=("SOXX",),
63+
safe_haven_cash_symbols=("BOXX",),
64+
cash_substitute_limit_usd=2000.0,
65+
)
66+
67+
self.assertEqual(result.targets["SOXX"], 0.0)
68+
self.assertEqual(result.targets["BOXX"], 5000.0)
69+
self.assertEqual(result.whole_share_substituted_symbols, ("SOXX",))
70+
self.assertEqual(result.safe_haven_cash_substituted_symbols, ())
71+
self.assertEqual(result.cash_substitution_notes, ())
72+
73+
def test_formats_cash_substitution_notes_through_i18n(self):
74+
messages = format_small_account_cash_substitution_notes(
75+
(
76+
{
77+
"symbol": "SOXX",
78+
"target_value": 163.14,
79+
"price": 504.60,
80+
"cash_symbols": ("BOXX",),
81+
},
82+
),
83+
translator=lambda key, **kwargs: {
84+
"cash_label": "现金",
85+
"buy_deferred": "ℹ️ [买入说明] {detail}",
86+
"buy_deferred_small_account_cash_substitution": (
87+
"{symbol} 目标金额 ${diff} 低于 1 股价格 ${price};"
88+
"为避免超过目标仓位,小账户本轮保留现金,不回补 {cash_symbols}"
89+
),
90+
}.get(key, key).format(**kwargs),
91+
)
92+
93+
self.assertEqual(
94+
messages,
95+
(
96+
"ℹ️ [买入说明] SOXX.US 目标金额 $163.14 低于 1 股价格 $504.60;"
97+
"为避免超过目标仓位,小账户本轮保留现金,不回补 BOXX.US",
98+
),
99+
)
100+
31101

32102
if __name__ == "__main__":
33103
unittest.main()

0 commit comments

Comments
 (0)