From 8bc39ad9b959eb00de6591d4afdcf3afb0f3cc93 Mon Sep 17 00:00:00 2001 From: Pigbibi <20649888+Pigbibi@users.noreply.github.com> Date: Wed, 24 Jun 2026 14:10:21 +0800 Subject: [PATCH 1/3] Add small-account allocation drift notes --- application/execution_service.py | 51 ++++++++++++++++++++++++++++++++ notifications/telegram.py | 9 ++++++ pyproject.toml | 2 +- requirements.txt | 2 +- tests/test_execution_service.py | 13 +++++--- tests/test_rebalance_service.py | 10 ++++++- 6 files changed, 80 insertions(+), 7 deletions(-) diff --git a/application/execution_service.py b/application/execution_service.py index 884d390..065f496 100644 --- a/application/execution_service.py +++ b/application/execution_service.py @@ -10,6 +10,7 @@ try: from quant_platform_kit.common.small_account_compatibility import ( apply_small_account_cash_compatibility, + build_small_account_allocation_drift_notes, ) except ImportError: # pragma: no cover - compatibility with older pinned shared wheels @dataclass(frozen=True) @@ -123,6 +124,9 @@ def apply_small_account_cash_compatibility( cash_substitution_notes=tuple(notes), ) + def build_small_account_allocation_drift_notes(**_kwargs): + return () + @dataclass(frozen=True) class ExecutionCycleResult: submitted_orders: tuple[dict[str, Any], ...] @@ -204,6 +208,30 @@ def _safe_haven_cash_symbols(*, portfolio: dict[str, Any], allocation: dict[str, return tuple(dict.fromkeys(symbols)) +def _small_account_drift_reference_targets( + allocation: dict[str, Any], + *, + portfolio: dict[str, Any] | None = None, +) -> dict[str, float]: + allocation = dict(allocation or {}) + targets = { + str(symbol or "").strip().upper(): float(value or 0.0) + for symbol, value in dict(allocation.get("targets") or {}).items() + } + candidate_symbols = tuple( + dict.fromkeys( + str(symbol or "").strip().upper() + for symbol in tuple(allocation.get("risk_symbols", ())) + + tuple(allocation.get("income_symbols", ())) + if str(symbol or "").strip() + ) + ) + if not candidate_symbols: + safe_haven_symbols = set(_safe_haven_cash_symbols(portfolio=dict(portfolio or {}), allocation=allocation)) + candidate_symbols = tuple(symbol for symbol in targets if symbol not in safe_haven_symbols) + return {symbol: targets.get(symbol, 0.0) for symbol in candidate_symbols if symbol in targets} + + def _positive_target_total(targets: dict[str, Any]) -> float: total = 0.0 for value in dict(targets or {}).values(): @@ -432,6 +460,11 @@ def execute_value_target_plan( plan, threshold_usd=safe_haven_cash_substitute_threshold_usd, ) + plan_portfolio = dict(plan.get("portfolio") or {}) + small_account_reference_target_values = _small_account_drift_reference_targets( + dict(plan.get("allocation") or {}), + portfolio=plan_portfolio, + ) plan = _apply_small_account_whole_share_compatibility( plan, market_data_port=market_data_port, @@ -451,6 +484,10 @@ def execute_value_target_plan( str(k).upper(): float(v or 0.0) for k, v in dict(portfolio.get("sellable_quantities") or {}).items() } + current_quantities = { + str(k).upper(): float(v or 0.0) + for k, v in dict(portfolio.get("quantities") or sellable_quantities).items() + } threshold = float( execution.get("current_min_trade") or execution.get("trade_threshold_value") @@ -468,6 +505,7 @@ def execute_value_target_plan( submitted: list[dict[str, Any]] = [] skipped: list[dict[str, Any]] = [] + reference_prices: dict[str, float] = {} tradable_deltas: list[tuple[str, float, float]] = [] for symbol in sorted(set(targets) | set(market_values)): @@ -487,6 +525,7 @@ def execute_value_target_plan( if price is None: skipped.append({"symbol": symbol, "reason": "quote_unavailable"}) continue + reference_prices[symbol] = price tradable_deltas.append((symbol, delta_value, price)) 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( ) investable_cash = max(0.0, investable_cash - (quantity * limit_price)) + total_value = float(portfolio.get("total_equity") or portfolio.get("total_strategy_equity") or 0.0) + drift_notes = build_small_account_allocation_drift_notes( + target_values=small_account_reference_target_values, + current_values=market_values, + current_quantities=current_quantities, + prices=reference_prices, + submitted_orders=submitted, + total_value=total_value, + cash_value=float(portfolio.get("liquid_cash") or 0.0), + ) + execution_notes = tuple(execution_notes) + tuple(drift_notes) + return ExecutionCycleResult( submitted_orders=tuple(submitted), skipped_orders=tuple(skipped), diff --git a/notifications/telegram.py b/notifications/telegram.py index 7664521..c1215d3 100644 --- a/notifications/telegram.py +++ b/notifications/telegram.py @@ -62,9 +62,13 @@ def _localize_price_source_label(value, *, translator=None, locale=None): try: from quant_platform_kit.common.small_account_compatibility import ( + format_small_account_allocation_drift_notes, format_small_account_cash_substitution_notes, ) except ImportError: # pragma: no cover - compatibility with older pinned shared wheels + def format_small_account_allocation_drift_notes(_notes, *, translator, **_kwargs): + return () + def format_small_account_cash_substitution_notes( notes, *, @@ -260,6 +264,8 @@ def format_small_account_whole_share_bootstrap_notes( "no_executable_orders": "无可执行订单", "buy_deferred": "ℹ️ [买入说明] {detail}", "buy_deferred_small_account_cash_substitution": "{symbol} 目标金额 ${diff} 低于 1 股价格 ${price};为避免超过目标仓位,小账户本轮保留现金,不回补 {cash_symbols}", + "small_account_allocation_drift": "📏 整数股偏离:若本轮订单全部成交,{details}", + "small_account_allocation_drift_detail": "{symbol} 预计 {projected_weight} vs 目标 {target_weight}({drift_weight})", "buy_lifted_small_account_whole_share": "ℹ️ [买入说明] {symbols} 目标金额接近 1 股;小账户整数股兼容,本轮允许按 1 股下单", "signal_state_hold": "趋势持有", "signal_state_entry": "入场信号", @@ -419,6 +425,8 @@ def format_small_account_whole_share_bootstrap_notes( "no_executable_orders": "no executable orders", "buy_deferred": "ℹ️ [Buy note] {detail}", "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}", + "small_account_allocation_drift": "📏 Integer-share drift: if this cycle's orders fully fill, {details}", + "small_account_allocation_drift_detail": "{symbol} projected {projected_weight} vs target {target_weight} ({drift_weight})", "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", "signal_state_hold": "Trend Hold", "signal_state_entry": "Entry Signal", @@ -1149,6 +1157,7 @@ def render_cycle_summary(result: Mapping[str, Any], *, lang: str = "en") -> str: ) execution_notes = tuple(result.get("execution_notes") or allocation.get("small_account_whole_share_cash_notes") or ()) lines.extend(format_small_account_cash_substitution_notes(execution_notes, translator=translator)) + lines.extend(format_small_account_allocation_drift_notes(execution_notes, translator=translator)) lines.extend( format_small_account_whole_share_bootstrap_notes( allocation.get("small_account_whole_share_bootstrap_symbols") or (), diff --git a/pyproject.toml b/pyproject.toml index 0fdf0d9..99d7a29 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -14,7 +14,7 @@ authors = [ ] dependencies = [ "firstrade==0.0.39", - "quant-platform-kit @ git+https://github.com/QuantStrategyLab/QuantPlatformKit.git@46ca4ea3de8f98a58e2dd86158e7f2070d085cd1", + "quant-platform-kit @ git+https://github.com/QuantStrategyLab/QuantPlatformKit.git@b821e8c318e15d40f925c84a007ae335a3415cd5", "us-equity-strategies @ git+https://github.com/QuantStrategyLab/UsEquityStrategies.git@a8e8385c6d7daf2510888cdb40d1daffdc13fb72", "google-cloud-storage", "requests", diff --git a/requirements.txt b/requirements.txt index 23d8c2f..f0dcb90 100644 --- a/requirements.txt +++ b/requirements.txt @@ -1,7 +1,7 @@ flask gunicorn firstrade==0.0.39 -quant-platform-kit @ git+https://github.com/QuantStrategyLab/QuantPlatformKit.git@46ca4ea3de8f98a58e2dd86158e7f2070d085cd1 +quant-platform-kit @ git+https://github.com/QuantStrategyLab/QuantPlatformKit.git@b821e8c318e15d40f925c84a007ae335a3415cd5 us-equity-strategies @ git+https://github.com/QuantStrategyLab/UsEquityStrategies.git@a8e8385c6d7daf2510888cdb40d1daffdc13fb72 google-cloud-storage google-auth diff --git a/tests/test_execution_service.py b/tests/test_execution_service.py index 65f4000..40fcb35 100644 --- a/tests/test_execution_service.py +++ b/tests/test_execution_service.py @@ -333,14 +333,17 @@ def test_execute_value_target_plan_bootstraps_close_to_one_share_core_target(): assert [(order.side, order.symbol, order.quantity, order.limit_price) for order in execution_port.orders] == [ ("buy", "SOXL", 1.0, 233.18), ] - assert result.execution_notes == ( + assert result.execution_notes[0] == ( { "symbol": "SOXX", "target_value": 342.86, "price": 603.0, "cash_symbols": (), - }, + } ) + drift_notes = [note for note in result.execution_notes if note.get("kind") == "small_account_allocation_drift"] + assert drift_notes[0]["symbol"] == "SOXX" + assert drift_notes[1]["symbol"] == "SOXL" 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 assert result.action_done is False assert execution_port.orders == [] - assert result.execution_notes == ( + assert result.execution_notes[0] == ( { "symbol": "SOXX", "target_value": 194.10, "price": 525.0, "cash_symbols": ("BOXX",), - }, + } ) + drift_notes = [note for note in result.execution_notes if note.get("kind") == "small_account_allocation_drift"] + assert [note["symbol"] for note in drift_notes] == ["SOXX"] def test_execute_value_target_plan_uses_cash_sweep_symbol_for_small_safe_haven_cash(): diff --git a/tests/test_rebalance_service.py b/tests/test_rebalance_service.py index e56d8d4..0c6616e 100644 --- a/tests/test_rebalance_service.py +++ b/tests/test_rebalance_service.py @@ -878,7 +878,14 @@ def test_render_cycle_summary_includes_small_account_cash_note_zh(): "target_value": 194.10, "price": 525.0, "cash_symbols": ("BOXX",), - } + }, + { + "kind": "small_account_allocation_drift", + "symbol": "SOXX", + "target_weight": 0.15, + "projected_weight": 0.0, + "drift_weight": -0.15, + }, ], }, lang="zh", @@ -886,6 +893,7 @@ def test_render_cycle_summary_includes_small_account_cash_note_zh(): assert "ℹ️ [买入说明] SOXX.US 目标金额 $194.10 低于 1 股价格 $525.00" in message assert "小账户本轮保留现金,不回补 BOXX.US" in message + assert "📏 整数股偏离:若本轮订单全部成交,SOXX.US 预计 0.0% vs 目标 15.0%(-15.0pp)" in message def test_render_cycle_summary_includes_small_account_bootstrap_note_zh(): From 000cb018992e7a8021dd62605268a0c9b62d59cd Mon Sep 17 00:00:00 2001 From: Pigbibi <20649888+Pigbibi@users.noreply.github.com> Date: Wed, 24 Jun 2026 14:17:51 +0800 Subject: [PATCH 2/3] Update strategy dependency pins --- pyproject.toml | 2 +- requirements.txt | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/pyproject.toml b/pyproject.toml index 99d7a29..a294675 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -15,7 +15,7 @@ authors = [ dependencies = [ "firstrade==0.0.39", "quant-platform-kit @ git+https://github.com/QuantStrategyLab/QuantPlatformKit.git@b821e8c318e15d40f925c84a007ae335a3415cd5", - "us-equity-strategies @ git+https://github.com/QuantStrategyLab/UsEquityStrategies.git@a8e8385c6d7daf2510888cdb40d1daffdc13fb72", + "us-equity-strategies @ git+https://github.com/QuantStrategyLab/UsEquityStrategies.git@0cbdacc95fb9041f590472254aef8f1cea35adf8", "google-cloud-storage", "requests", ] diff --git a/requirements.txt b/requirements.txt index f0dcb90..76da6ff 100644 --- a/requirements.txt +++ b/requirements.txt @@ -2,7 +2,7 @@ flask gunicorn firstrade==0.0.39 quant-platform-kit @ git+https://github.com/QuantStrategyLab/QuantPlatformKit.git@b821e8c318e15d40f925c84a007ae335a3415cd5 -us-equity-strategies @ git+https://github.com/QuantStrategyLab/UsEquityStrategies.git@a8e8385c6d7daf2510888cdb40d1daffdc13fb72 +us-equity-strategies @ git+https://github.com/QuantStrategyLab/UsEquityStrategies.git@0cbdacc95fb9041f590472254aef8f1cea35adf8 google-cloud-storage google-auth requests From 48df4fa1e2dae4b2165cb9f9fd4650abb102f649 Mon Sep 17 00:00:00 2001 From: Pigbibi <20649888+Pigbibi@users.noreply.github.com> Date: Wed, 24 Jun 2026 14:21:05 +0800 Subject: [PATCH 3/3] Update plugin consumption test expectation --- tests/test_rebalance_service.py | 1 + 1 file changed, 1 insertion(+) diff --git a/tests/test_rebalance_service.py b/tests/test_rebalance_service.py index 0c6616e..7a62935 100644 --- a/tests/test_rebalance_service.py +++ b/tests/test_rebalance_service.py @@ -453,6 +453,7 @@ def test_run_strategy_cycle_strategy_plugin_load_error_is_non_blocking(monkeypat assert result["strategy_plugin_error"].startswith("JSONDecodeError:") assert result["strategy_plugin_error_lines"] == ( "⚠️ Plugin signal failed to load: invalid plugin mount JSON; this run falls back to built-in strategy rules", + "🧩 Plugin consumption: no plugin signal consumed", ) assert result["strategy_plugin_alert_email_sent_count"] == 0 assert result["strategy_plugin_alert_sms_sent_count"] == 0