Skip to content

Commit 8bc39ad

Browse files
committed
Add small-account allocation drift notes
1 parent 3f0d0db commit 8bc39ad

6 files changed

Lines changed: 80 additions & 7 deletions

File tree

application/execution_service.py

Lines changed: 51 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@
1010
try:
1111
from quant_platform_kit.common.small_account_compatibility import (
1212
apply_small_account_cash_compatibility,
13+
build_small_account_allocation_drift_notes,
1314
)
1415
except ImportError: # pragma: no cover - compatibility with older pinned shared wheels
1516
@dataclass(frozen=True)
@@ -123,6 +124,9 @@ def apply_small_account_cash_compatibility(
123124
cash_substitution_notes=tuple(notes),
124125
)
125126

127+
def build_small_account_allocation_drift_notes(**_kwargs):
128+
return ()
129+
126130
@dataclass(frozen=True)
127131
class ExecutionCycleResult:
128132
submitted_orders: tuple[dict[str, Any], ...]
@@ -204,6 +208,30 @@ def _safe_haven_cash_symbols(*, portfolio: dict[str, Any], allocation: dict[str,
204208
return tuple(dict.fromkeys(symbols))
205209

206210

211+
def _small_account_drift_reference_targets(
212+
allocation: dict[str, Any],
213+
*,
214+
portfolio: dict[str, Any] | None = None,
215+
) -> dict[str, float]:
216+
allocation = dict(allocation or {})
217+
targets = {
218+
str(symbol or "").strip().upper(): float(value or 0.0)
219+
for symbol, value in dict(allocation.get("targets") or {}).items()
220+
}
221+
candidate_symbols = tuple(
222+
dict.fromkeys(
223+
str(symbol or "").strip().upper()
224+
for symbol in tuple(allocation.get("risk_symbols", ()))
225+
+ tuple(allocation.get("income_symbols", ()))
226+
if str(symbol or "").strip()
227+
)
228+
)
229+
if not candidate_symbols:
230+
safe_haven_symbols = set(_safe_haven_cash_symbols(portfolio=dict(portfolio or {}), allocation=allocation))
231+
candidate_symbols = tuple(symbol for symbol in targets if symbol not in safe_haven_symbols)
232+
return {symbol: targets.get(symbol, 0.0) for symbol in candidate_symbols if symbol in targets}
233+
234+
207235
def _positive_target_total(targets: dict[str, Any]) -> float:
208236
total = 0.0
209237
for value in dict(targets or {}).values():
@@ -432,6 +460,11 @@ def execute_value_target_plan(
432460
plan,
433461
threshold_usd=safe_haven_cash_substitute_threshold_usd,
434462
)
463+
plan_portfolio = dict(plan.get("portfolio") or {})
464+
small_account_reference_target_values = _small_account_drift_reference_targets(
465+
dict(plan.get("allocation") or {}),
466+
portfolio=plan_portfolio,
467+
)
435468
plan = _apply_small_account_whole_share_compatibility(
436469
plan,
437470
market_data_port=market_data_port,
@@ -451,6 +484,10 @@ def execute_value_target_plan(
451484
str(k).upper(): float(v or 0.0)
452485
for k, v in dict(portfolio.get("sellable_quantities") or {}).items()
453486
}
487+
current_quantities = {
488+
str(k).upper(): float(v or 0.0)
489+
for k, v in dict(portfolio.get("quantities") or sellable_quantities).items()
490+
}
454491
threshold = float(
455492
execution.get("current_min_trade")
456493
or execution.get("trade_threshold_value")
@@ -468,6 +505,7 @@ def execute_value_target_plan(
468505

469506
submitted: list[dict[str, Any]] = []
470507
skipped: list[dict[str, Any]] = []
508+
reference_prices: dict[str, float] = {}
471509

472510
tradable_deltas: list[tuple[str, float, float]] = []
473511
for symbol in sorted(set(targets) | set(market_values)):
@@ -487,6 +525,7 @@ def execute_value_target_plan(
487525
if price is None:
488526
skipped.append({"symbol": symbol, "reason": "quote_unavailable"})
489527
continue
528+
reference_prices[symbol] = price
490529
tradable_deltas.append((symbol, delta_value, price))
491530

492531
for symbol, delta_value, price in [item for item in tradable_deltas if item[1] < 0]:
@@ -567,6 +606,18 @@ def execute_value_target_plan(
567606
)
568607
investable_cash = max(0.0, investable_cash - (quantity * limit_price))
569608

609+
total_value = float(portfolio.get("total_equity") or portfolio.get("total_strategy_equity") or 0.0)
610+
drift_notes = build_small_account_allocation_drift_notes(
611+
target_values=small_account_reference_target_values,
612+
current_values=market_values,
613+
current_quantities=current_quantities,
614+
prices=reference_prices,
615+
submitted_orders=submitted,
616+
total_value=total_value,
617+
cash_value=float(portfolio.get("liquid_cash") or 0.0),
618+
)
619+
execution_notes = tuple(execution_notes) + tuple(drift_notes)
620+
570621
return ExecutionCycleResult(
571622
submitted_orders=tuple(submitted),
572623
skipped_orders=tuple(skipped),

notifications/telegram.py

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -62,9 +62,13 @@ def _localize_price_source_label(value, *, translator=None, locale=None):
6262

6363
try:
6464
from quant_platform_kit.common.small_account_compatibility import (
65+
format_small_account_allocation_drift_notes,
6566
format_small_account_cash_substitution_notes,
6667
)
6768
except ImportError: # pragma: no cover - compatibility with older pinned shared wheels
69+
def format_small_account_allocation_drift_notes(_notes, *, translator, **_kwargs):
70+
return ()
71+
6872
def format_small_account_cash_substitution_notes(
6973
notes,
7074
*,
@@ -260,6 +264,8 @@ def format_small_account_whole_share_bootstrap_notes(
260264
"no_executable_orders": "无可执行订单",
261265
"buy_deferred": "ℹ️ [买入说明] {detail}",
262266
"buy_deferred_small_account_cash_substitution": "{symbol} 目标金额 ${diff} 低于 1 股价格 ${price};为避免超过目标仓位,小账户本轮保留现金,不回补 {cash_symbols}",
267+
"small_account_allocation_drift": "📏 整数股偏离:若本轮订单全部成交,{details}",
268+
"small_account_allocation_drift_detail": "{symbol} 预计 {projected_weight} vs 目标 {target_weight}({drift_weight})",
263269
"buy_lifted_small_account_whole_share": "ℹ️ [买入说明] {symbols} 目标金额接近 1 股;小账户整数股兼容,本轮允许按 1 股下单",
264270
"signal_state_hold": "趋势持有",
265271
"signal_state_entry": "入场信号",
@@ -419,6 +425,8 @@ def format_small_account_whole_share_bootstrap_notes(
419425
"no_executable_orders": "no executable orders",
420426
"buy_deferred": "ℹ️ [Buy note] {detail}",
421427
"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}",
428+
"small_account_allocation_drift": "📏 Integer-share drift: if this cycle's orders fully fill, {details}",
429+
"small_account_allocation_drift_detail": "{symbol} projected {projected_weight} vs target {target_weight} ({drift_weight})",
422430
"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",
423431
"signal_state_hold": "Trend Hold",
424432
"signal_state_entry": "Entry Signal",
@@ -1149,6 +1157,7 @@ def render_cycle_summary(result: Mapping[str, Any], *, lang: str = "en") -> str:
11491157
)
11501158
execution_notes = tuple(result.get("execution_notes") or allocation.get("small_account_whole_share_cash_notes") or ())
11511159
lines.extend(format_small_account_cash_substitution_notes(execution_notes, translator=translator))
1160+
lines.extend(format_small_account_allocation_drift_notes(execution_notes, translator=translator))
11521161
lines.extend(
11531162
format_small_account_whole_share_bootstrap_notes(
11541163
allocation.get("small_account_whole_share_bootstrap_symbols") or (),

pyproject.toml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -14,7 +14,7 @@ authors = [
1414
]
1515
dependencies = [
1616
"firstrade==0.0.39",
17-
"quant-platform-kit @ git+https://github.com/QuantStrategyLab/QuantPlatformKit.git@46ca4ea3de8f98a58e2dd86158e7f2070d085cd1",
17+
"quant-platform-kit @ git+https://github.com/QuantStrategyLab/QuantPlatformKit.git@b821e8c318e15d40f925c84a007ae335a3415cd5",
1818
"us-equity-strategies @ git+https://github.com/QuantStrategyLab/UsEquityStrategies.git@a8e8385c6d7daf2510888cdb40d1daffdc13fb72",
1919
"google-cloud-storage",
2020
"requests",

requirements.txt

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
flask
22
gunicorn
33
firstrade==0.0.39
4-
quant-platform-kit @ git+https://github.com/QuantStrategyLab/QuantPlatformKit.git@46ca4ea3de8f98a58e2dd86158e7f2070d085cd1
4+
quant-platform-kit @ git+https://github.com/QuantStrategyLab/QuantPlatformKit.git@b821e8c318e15d40f925c84a007ae335a3415cd5
55
us-equity-strategies @ git+https://github.com/QuantStrategyLab/UsEquityStrategies.git@a8e8385c6d7daf2510888cdb40d1daffdc13fb72
66
google-cloud-storage
77
google-auth

tests/test_execution_service.py

Lines changed: 9 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -333,14 +333,17 @@ def test_execute_value_target_plan_bootstraps_close_to_one_share_core_target():
333333
assert [(order.side, order.symbol, order.quantity, order.limit_price) for order in execution_port.orders] == [
334334
("buy", "SOXL", 1.0, 233.18),
335335
]
336-
assert result.execution_notes == (
336+
assert result.execution_notes[0] == (
337337
{
338338
"symbol": "SOXX",
339339
"target_value": 342.86,
340340
"price": 603.0,
341341
"cash_symbols": (),
342-
},
342+
}
343343
)
344+
drift_notes = [note for note in result.execution_notes if note.get("kind") == "small_account_allocation_drift"]
345+
assert drift_notes[0]["symbol"] == "SOXX"
346+
assert drift_notes[1]["symbol"] == "SOXL"
344347

345348

346349
def test_execute_value_target_plan_uses_symbol_specific_limit_buy_premium_for_budget():
@@ -404,14 +407,16 @@ def test_execute_value_target_plan_keeps_safe_haven_cash_when_only_risk_target_i
404407

405408
assert result.action_done is False
406409
assert execution_port.orders == []
407-
assert result.execution_notes == (
410+
assert result.execution_notes[0] == (
408411
{
409412
"symbol": "SOXX",
410413
"target_value": 194.10,
411414
"price": 525.0,
412415
"cash_symbols": ("BOXX",),
413-
},
416+
}
414417
)
418+
drift_notes = [note for note in result.execution_notes if note.get("kind") == "small_account_allocation_drift"]
419+
assert [note["symbol"] for note in drift_notes] == ["SOXX"]
415420

416421

417422
def test_execute_value_target_plan_uses_cash_sweep_symbol_for_small_safe_haven_cash():

tests/test_rebalance_service.py

Lines changed: 9 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -878,14 +878,22 @@ def test_render_cycle_summary_includes_small_account_cash_note_zh():
878878
"target_value": 194.10,
879879
"price": 525.0,
880880
"cash_symbols": ("BOXX",),
881-
}
881+
},
882+
{
883+
"kind": "small_account_allocation_drift",
884+
"symbol": "SOXX",
885+
"target_weight": 0.15,
886+
"projected_weight": 0.0,
887+
"drift_weight": -0.15,
888+
},
882889
],
883890
},
884891
lang="zh",
885892
)
886893

887894
assert "ℹ️ [买入说明] SOXX.US 目标金额 $194.10 低于 1 股价格 $525.00" in message
888895
assert "小账户本轮保留现金,不回补 BOXX.US" in message
896+
assert "📏 整数股偏离:若本轮订单全部成交,SOXX.US 预计 0.0% vs 目标 15.0%(-15.0pp)" in message
889897

890898

891899
def test_render_cycle_summary_includes_small_account_bootstrap_note_zh():

0 commit comments

Comments
 (0)