Skip to content

Commit b3175ec

Browse files
committed
Add small-account allocation drift notes
1 parent 486c025 commit b3175ec

2 files changed

Lines changed: 263 additions & 0 deletions

File tree

src/quant_platform_kit/common/small_account_compatibility.py

Lines changed: 193 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,8 @@
99
__all__ = [
1010
"SmallAccountCashCompatibilityResult",
1111
"apply_small_account_cash_compatibility",
12+
"build_small_account_allocation_drift_notes",
13+
"format_small_account_allocation_drift_notes",
1214
"format_small_account_cash_substitution_notes",
1315
"project_unbuyable_value_targets_to_cash",
1416
]
@@ -26,6 +28,14 @@ def _normalize_symbol(value: object) -> str:
2628
return str(value or "").strip().upper()
2729

2830

31+
def _normalize_trade_symbol(value: object, *, symbol_suffix: str = ".US") -> str:
32+
symbol = _normalize_symbol(value)
33+
suffix = str(symbol_suffix or "").strip().upper()
34+
if suffix and symbol.endswith(suffix):
35+
return symbol[: -len(suffix)]
36+
return symbol
37+
38+
2939
def _positive_target_total(targets: Mapping[str, object]) -> float:
3040
total = 0.0
3141
for value in dict(targets or {}).values():
@@ -51,6 +61,189 @@ def _format_symbol(symbol: str, *, suffix: str) -> str:
5161
return normalized
5262

5363

64+
def _coerce_float(value: object, default: float = 0.0) -> float:
65+
try:
66+
return float(value or 0.0)
67+
except (TypeError, ValueError):
68+
return default
69+
70+
71+
def _coerce_order_price(order: Mapping[str, object], prices: Mapping[str, float], symbol: str) -> float:
72+
for key in ("average_fill_price", "filled_price", "limit_price", "price", "submitted_price"):
73+
price = _coerce_float(order.get(key), 0.0)
74+
if price > 0.0:
75+
return price
76+
return max(0.0, float(prices.get(symbol, 0.0) or 0.0))
77+
78+
79+
def _format_weight(value: float) -> str:
80+
return f"{float(value or 0.0):.1%}"
81+
82+
83+
def _format_weight_drift(value: float) -> str:
84+
return f"{float(value or 0.0) * 100:+.1f}pp"
85+
86+
87+
def build_small_account_allocation_drift_notes(
88+
*,
89+
target_values: Mapping[str, object] | None = None,
90+
target_weights: Mapping[str, object] | None = None,
91+
current_values: Mapping[str, object] | None = None,
92+
current_quantities: Mapping[str, object] | None = None,
93+
prices: Mapping[str, object] | None = None,
94+
submitted_orders: Iterable[Mapping[str, object]] = (),
95+
total_value: float | None = None,
96+
cash_value: float = 0.0,
97+
symbol_suffix: str = ".US",
98+
min_abs_weight_drift: float = 0.005,
99+
small_account_max_total_value: float = 5000.0,
100+
max_notes: int = 5,
101+
) -> tuple[dict[str, object], ...]:
102+
"""Estimate target drift after whole-share orders fully fill.
103+
104+
The estimate is intentionally simple and side-effect free: it uses current
105+
values/quantities, order quantities, and reference prices to explain the
106+
integer-share gap a small account may see if the submitted orders all fill.
107+
"""
108+
109+
normalized_prices = {
110+
_normalize_trade_symbol(symbol, symbol_suffix=symbol_suffix): _coerce_float(price)
111+
for symbol, price in dict(prices or {}).items()
112+
}
113+
normalized_current_values = {
114+
_normalize_trade_symbol(symbol, symbol_suffix=symbol_suffix): _coerce_float(value)
115+
for symbol, value in dict(current_values or {}).items()
116+
}
117+
normalized_current_quantities = {
118+
_normalize_trade_symbol(symbol, symbol_suffix=symbol_suffix): _coerce_float(quantity)
119+
for symbol, quantity in dict(current_quantities or {}).items()
120+
}
121+
for symbol, quantity in normalized_current_quantities.items():
122+
if symbol not in normalized_current_values:
123+
normalized_current_values[symbol] = quantity * max(0.0, normalized_prices.get(symbol, 0.0))
124+
125+
denominator = _coerce_float(total_value, 0.0)
126+
if denominator <= 0.0:
127+
denominator = sum(max(0.0, value) for value in normalized_current_values.values()) + max(
128+
0.0,
129+
_coerce_float(cash_value, 0.0),
130+
)
131+
if denominator <= 0.0:
132+
return ()
133+
if denominator > max(0.0, _coerce_float(small_account_max_total_value, 0.0)):
134+
return ()
135+
136+
normalized_target_values: dict[str, float] = {}
137+
if target_values:
138+
normalized_target_values.update(
139+
{
140+
_normalize_trade_symbol(symbol, symbol_suffix=symbol_suffix): _coerce_float(value)
141+
for symbol, value in dict(target_values or {}).items()
142+
}
143+
)
144+
if target_weights:
145+
for symbol, weight in dict(target_weights or {}).items():
146+
normalized_target_values[_normalize_trade_symbol(symbol, symbol_suffix=symbol_suffix)] = (
147+
denominator * _coerce_float(weight)
148+
)
149+
150+
if not normalized_target_values:
151+
return ()
152+
153+
projected_values = dict(normalized_current_values)
154+
projected_quantities = dict(normalized_current_quantities)
155+
for raw_order in tuple(submitted_orders or ()):
156+
if not isinstance(raw_order, Mapping):
157+
continue
158+
symbol = _normalize_trade_symbol(raw_order.get("symbol"), symbol_suffix=symbol_suffix)
159+
if not symbol:
160+
continue
161+
side = str(raw_order.get("side") or "").strip().lower()
162+
quantity = _coerce_float(raw_order.get("quantity"), 0.0)
163+
if quantity <= 0.0 or side not in {"buy", "sell"}:
164+
continue
165+
price = _coerce_order_price(raw_order, normalized_prices, symbol)
166+
if price <= 0.0:
167+
continue
168+
signed_quantity = quantity if side == "buy" else -quantity
169+
projected_quantities[symbol] = projected_quantities.get(symbol, 0.0) + signed_quantity
170+
projected_values[symbol] = max(0.0, projected_values.get(symbol, 0.0) + signed_quantity * price)
171+
normalized_prices.setdefault(symbol, price)
172+
173+
notes: list[dict[str, object]] = []
174+
symbols = sorted(set(normalized_target_values))
175+
for symbol in symbols:
176+
target_value = max(0.0, normalized_target_values.get(symbol, 0.0))
177+
projected_value = max(0.0, projected_values.get(symbol, 0.0))
178+
if target_value <= 0.0 and projected_value <= 0.0:
179+
continue
180+
target_weight = target_value / denominator
181+
projected_weight = projected_value / denominator
182+
drift_weight = projected_weight - target_weight
183+
if abs(drift_weight) < max(0.0, _coerce_float(min_abs_weight_drift, 0.0)):
184+
continue
185+
notes.append(
186+
{
187+
"kind": "small_account_allocation_drift",
188+
"symbol": symbol,
189+
"target_value": round(target_value, 2),
190+
"projected_value": round(projected_value, 2),
191+
"target_weight": target_weight,
192+
"projected_weight": projected_weight,
193+
"drift_weight": drift_weight,
194+
"drift_value": round(projected_value - target_value, 2),
195+
"projected_quantity": projected_quantities.get(symbol),
196+
}
197+
)
198+
199+
notes.sort(key=lambda note: abs(float(note.get("drift_weight") or 0.0)), reverse=True)
200+
return tuple(notes[: max(0, int(max_notes or 0))])
201+
202+
203+
def format_small_account_allocation_drift_notes(
204+
notes: Iterable[Mapping[str, object]],
205+
*,
206+
translator,
207+
wrapper_key: str = "small_account_allocation_drift",
208+
detail_key: str = "small_account_allocation_drift_detail",
209+
symbol_suffix: str = ".US",
210+
) -> tuple[str, ...]:
211+
"""Render small-account projected allocation drift notes."""
212+
213+
details: list[str] = []
214+
seen_symbols: set[str] = set()
215+
for note in tuple(notes or ()):
216+
if not isinstance(note, Mapping):
217+
continue
218+
if str(note.get("kind") or "") != "small_account_allocation_drift":
219+
continue
220+
symbol = _normalize_symbol(note.get("symbol"))
221+
if not symbol or symbol in seen_symbols:
222+
continue
223+
seen_symbols.add(symbol)
224+
detail = translator(
225+
detail_key,
226+
symbol=_format_symbol(symbol, suffix=symbol_suffix),
227+
projected_weight=_format_weight(_coerce_float(note.get("projected_weight"))),
228+
target_weight=_format_weight(_coerce_float(note.get("target_weight"))),
229+
drift_weight=_format_weight_drift(_coerce_float(note.get("drift_weight"))),
230+
)
231+
if not detail or detail == detail_key:
232+
detail = (
233+
f"{_format_symbol(symbol, suffix=symbol_suffix)} projected "
234+
f"{_format_weight(_coerce_float(note.get('projected_weight')))} vs target "
235+
f"{_format_weight(_coerce_float(note.get('target_weight')))} "
236+
f"({_format_weight_drift(_coerce_float(note.get('drift_weight')))})"
237+
)
238+
details.append(str(detail))
239+
if not details:
240+
return ()
241+
message = translator(wrapper_key, details="; ".join(details))
242+
if not message or message == wrapper_key:
243+
message = f"Small-account integer-share drift: {'; '.join(details)}"
244+
return (message,)
245+
246+
54247
def project_unbuyable_value_targets_to_cash(
55248
target_values: Mapping[str, object],
56249
prices: Mapping[str, object],

tests/test_small_account_compatibility.py

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

33
from quant_platform_kit.common.small_account_compatibility import (
44
apply_small_account_cash_compatibility,
5+
build_small_account_allocation_drift_notes,
6+
format_small_account_allocation_drift_notes,
57
format_small_account_cash_substitution_notes,
68
project_unbuyable_value_targets_to_cash,
79
)
@@ -108,6 +110,74 @@ def test_formats_cash_substitution_notes_through_i18n(self):
108110
),
109111
)
110112

113+
def test_builds_projected_allocation_drift_notes_from_submitted_orders(self):
114+
notes = build_small_account_allocation_drift_notes(
115+
target_values={"SOXL": 218.19, "SOXX": 342.86},
116+
current_values={"SOXL": 0.0, "SOXX": 0.0},
117+
current_quantities={"SOXL": 0.0, "SOXX": 0.0},
118+
prices={"SOXL": 229.73, "SOXX": 603.0},
119+
submitted_orders=(
120+
{"symbol": "SOXL.US", "side": "buy", "quantity": 1, "limit_price": 233.18},
121+
),
122+
total_value=623.39,
123+
min_abs_weight_drift=0.005,
124+
)
125+
126+
self.assertEqual(notes[0]["symbol"], "SOXX")
127+
self.assertAlmostEqual(notes[0]["target_weight"], 342.86 / 623.39)
128+
self.assertAlmostEqual(notes[0]["projected_weight"], 0.0)
129+
self.assertEqual(notes[1]["symbol"], "SOXL")
130+
self.assertAlmostEqual(notes[1]["projected_weight"], 233.18 / 623.39)
131+
132+
def test_skips_drift_notes_for_larger_accounts_by_default(self):
133+
notes = build_small_account_allocation_drift_notes(
134+
target_values={"AAA": 5000.0},
135+
current_values={"AAA": 0.0},
136+
prices={"AAA": 100.0},
137+
submitted_orders=({"symbol": "AAA", "side": "buy", "quantity": 49, "limit_price": 100.0},),
138+
total_value=50_000.0,
139+
)
140+
141+
self.assertEqual(notes, ())
142+
143+
def test_drift_notes_ignore_symbols_outside_reference_targets(self):
144+
notes = build_small_account_allocation_drift_notes(
145+
target_values={"SOXL": 500.0},
146+
current_values={"SOXL": 0.0, "BOXX": 1000.0},
147+
current_quantities={"SOXL": 0.0, "BOXX": 10.0},
148+
prices={"SOXL": 100.0, "BOXX": 100.0},
149+
submitted_orders=(
150+
{"symbol": "BOXX.US", "side": "buy", "quantity": 1, "limit_price": 100.0},
151+
),
152+
total_value=1000.0,
153+
)
154+
155+
self.assertEqual([note["symbol"] for note in notes], ["SOXL"])
156+
157+
def test_formats_projected_allocation_drift_notes_through_i18n(self):
158+
messages = format_small_account_allocation_drift_notes(
159+
(
160+
{
161+
"kind": "small_account_allocation_drift",
162+
"symbol": "SOXL",
163+
"projected_weight": 0.3740,
164+
"target_weight": 0.3500,
165+
"drift_weight": 0.0240,
166+
},
167+
),
168+
translator=lambda key, **kwargs: {
169+
"small_account_allocation_drift": "📏 整数股偏离:若本轮订单全部成交,{details}",
170+
"small_account_allocation_drift_detail": (
171+
"{symbol} 预计 {projected_weight} vs 目标 {target_weight}({drift_weight})"
172+
),
173+
}.get(key, key).format(**kwargs),
174+
)
175+
176+
self.assertEqual(
177+
messages,
178+
("📏 整数股偏离:若本轮订单全部成交,SOXL.US 预计 37.4% vs 目标 35.0%(+2.4pp)",),
179+
)
180+
111181

112182
if __name__ == "__main__":
113183
unittest.main()

0 commit comments

Comments
 (0)