From 038c71324a207f5483e50a5b4d8772546e664d94 Mon Sep 17 00:00:00 2001 From: Pigbibi <20649888+Pigbibi@users.noreply.github.com> Date: Wed, 27 May 2026 22:32:40 +0800 Subject: [PATCH] Explain small-account cash substitution --- application/execution_service.py | 125 +++++++++++++++++++----- application/rebalance_service.py | 3 + application/strategy_run_persistence.py | 2 + notifications/telegram.py | 57 +++++++++++ pyproject.toml | 4 +- requirements.txt | 4 +- tests/test_execution_service.py | 8 ++ tests/test_rebalance_service.py | 40 ++++++++ 8 files changed, 213 insertions(+), 30 deletions(-) diff --git a/application/execution_service.py b/application/execution_service.py index 2dd1477..59dbc80 100644 --- a/application/execution_service.py +++ b/application/execution_service.py @@ -9,14 +9,21 @@ from quant_platform_kit.common.ports import ExecutionPort, MarketDataPort try: from quant_platform_kit.common.small_account_compatibility import ( - project_unbuyable_value_targets_to_cash, + apply_small_account_cash_compatibility, ) except ImportError: # pragma: no cover - compatibility with older pinned shared wheels - def project_unbuyable_value_targets_to_cash( + @dataclass(frozen=True) + class _SmallAccountCashCompatibilityResult: + targets: dict[str, float] + whole_share_substituted_symbols: tuple[str, ...] + safe_haven_cash_substituted_symbols: tuple[str, ...] + cash_substitution_notes: tuple[dict[str, Any], ...] + + def _project_unbuyable_value_targets_to_cash( target_values, prices, *, - symbols=None, + candidate_symbols=None, quantity_step=1.0, ): adjusted = { @@ -26,17 +33,17 @@ def project_unbuyable_value_targets_to_cash( step = max(0.0, float(quantity_step or 0.0)) if step <= 0.0: return adjusted, () - candidate_symbols = ( + normalized_candidates = ( tuple(adjusted) - if symbols is None - else tuple(dict.fromkeys(str(symbol or "").strip().upper() for symbol in symbols)) + if candidate_symbols is None + else tuple(dict.fromkeys(str(symbol or "").strip().upper() for symbol in candidate_symbols)) ) normalized_prices = { str(symbol or "").strip().upper(): float(price or 0.0) for symbol, price in dict(prices or {}).items() } substituted = [] - for symbol in candidate_symbols: + for symbol in normalized_candidates: target_value = max(0.0, float(adjusted.get(symbol, 0.0) or 0.0)) price = max(0.0, float(normalized_prices.get(symbol, 0.0) or 0.0)) if price > 0.0 and 0.0 < target_value < (price * step): @@ -44,12 +51,84 @@ def project_unbuyable_value_targets_to_cash( substituted.append(symbol) return adjusted, tuple(dict.fromkeys(substituted)) + def apply_small_account_cash_compatibility( + target_values, + prices, + *, + candidate_symbols=None, + safe_haven_cash_symbols=(), + quantity_step=1.0, + cash_substitute_limit_usd=2000.0, + ): + adjusted_targets, substituted = _project_unbuyable_value_targets_to_cash( + target_values, + prices, + candidate_symbols=candidate_symbols, + quantity_step=quantity_step, + ) + normalized_candidates = ( + tuple(adjusted_targets) + if candidate_symbols is None + else tuple(dict.fromkeys(str(symbol or "").strip().upper() for symbol in candidate_symbols)) + ) + remaining_non_safe_targets = [ + symbol + for symbol in normalized_candidates + if float(adjusted_targets.get(str(symbol or "").strip().upper(), 0.0) or 0.0) > 0.0 + ] + safe_haven_symbols = tuple( + dict.fromkeys( + str(symbol or "").strip().upper() + for symbol in safe_haven_cash_symbols + if str(symbol or "").strip() + ) + ) + safe_haven_substituted = [] + if ( + substituted + and not remaining_non_safe_targets + and _positive_target_total(adjusted_targets) <= max(0.0, float(cash_substitute_limit_usd or 0.0)) + ): + for symbol in safe_haven_symbols: + if float(adjusted_targets.get(symbol, 0.0) or 0.0) > 0.0: + adjusted_targets[symbol] = 0.0 + safe_haven_substituted.append(symbol) + normalized_targets = { + str(symbol or "").strip().upper(): float(value or 0.0) + for symbol, value in dict(target_values or {}).items() + } + normalized_prices = { + str(symbol or "").strip().upper(): float(price or 0.0) + for symbol, price in dict(prices or {}).items() + } + notes = [] + if safe_haven_substituted: + for symbol in substituted: + target_value = max(0.0, float(normalized_targets.get(symbol, 0.0) or 0.0)) + price = max(0.0, float(normalized_prices.get(symbol, 0.0) or 0.0)) + if target_value <= 0.0 or price <= 0.0: + continue + notes.append( + { + "symbol": symbol, + "target_value": target_value, + "price": price, + "cash_symbols": tuple(safe_haven_substituted), + } + ) + return _SmallAccountCashCompatibilityResult( + targets=adjusted_targets, + whole_share_substituted_symbols=substituted, + safe_haven_cash_substituted_symbols=tuple(safe_haven_substituted), + cash_substitution_notes=tuple(notes), + ) @dataclass(frozen=True) class ExecutionCycleResult: submitted_orders: tuple[dict[str, Any], ...] skipped_orders: tuple[dict[str, Any], ...] action_done: bool + execution_notes: tuple[dict[str, Any], ...] = () DEFAULT_SAFE_HAVEN_CASH_SUBSTITUTE_THRESHOLD_USD = 1000.0 @@ -167,33 +246,25 @@ def _apply_small_account_whole_share_compatibility( price = _quote_price(market_data_port, str(symbol).strip().upper()) if price is not None: prices[str(symbol).strip().upper()] = price - adjusted_targets, substituted = project_unbuyable_value_targets_to_cash( + safe_haven_symbols = _safe_haven_cash_symbols(portfolio=portfolio, allocation=allocation) + compatibility = apply_small_account_cash_compatibility( targets, prices, - symbols=candidate_symbols, + candidate_symbols=candidate_symbols, + safe_haven_cash_symbols=safe_haven_symbols, quantity_step=1.0, + cash_substitute_limit_usd=SMALL_ACCOUNT_SAFE_HAVEN_CASH_SUBSTITUTE_LIMIT_USD, ) - safe_haven_symbols = _safe_haven_cash_symbols(portfolio=portfolio, allocation=allocation) - remaining_non_safe_targets = [ - symbol - for symbol in candidate_symbols - if float(adjusted_targets.get(str(symbol or "").strip().upper(), 0.0) or 0.0) > 0.0 - ] - safe_haven_substituted: list[str] = [] - if ( - substituted - and not remaining_non_safe_targets - and _positive_target_total(adjusted_targets) <= SMALL_ACCOUNT_SAFE_HAVEN_CASH_SUBSTITUTE_LIMIT_USD - ): - for symbol in safe_haven_symbols: - if float(adjusted_targets.get(symbol, 0.0) or 0.0) > 0.0: - adjusted_targets[symbol] = 0.0 - safe_haven_substituted.append(symbol) - allocation["targets"] = adjusted_targets + allocation["targets"] = compatibility.targets + substituted = compatibility.whole_share_substituted_symbols + safe_haven_substituted = compatibility.safe_haven_cash_substituted_symbols + allocation.pop("small_account_whole_share_cash_notes", None) if substituted: allocation["small_account_whole_share_substituted_symbols"] = substituted if safe_haven_substituted: allocation["small_account_safe_haven_cash_substituted_symbols"] = tuple(safe_haven_substituted) + if compatibility.cash_substitution_notes: + allocation["small_account_whole_share_cash_notes"] = tuple(compatibility.cash_substitution_notes) adjusted_plan["allocation"] = allocation return adjusted_plan @@ -256,6 +327,7 @@ def execute_value_target_plan( allocation = dict(plan.get("allocation") or {}) portfolio = dict(plan.get("portfolio") or {}) execution = dict(plan.get("execution") or {}) + execution_notes = tuple(allocation.get("small_account_whole_share_cash_notes") or ()) targets = {str(k).upper(): float(v or 0.0) for k, v in dict(allocation.get("targets") or {}).items()} market_values = { str(k).upper(): float(v or 0.0) @@ -384,4 +456,5 @@ def execute_value_target_plan( submitted_orders=tuple(submitted), skipped_orders=tuple(skipped), action_done=bool(submitted), + execution_notes=execution_notes, ) diff --git a/application/rebalance_service.py b/application/rebalance_service.py index 211b6c1..dd7ae34 100644 --- a/application/rebalance_service.py +++ b/application/rebalance_service.py @@ -448,6 +448,7 @@ def run_strategy_cycle( ) submitted_orders = list(execution_result.submitted_orders) skipped_orders = list(execution_result.skipped_orders) + execution_notes = list(execution_result.execution_notes) blocking_skips = filter_execution_blocking_skips(skipped_orders) execution_blocked = bool(blocking_skips) funding_blocked = is_terminal_funding_block(blocking_skips) @@ -475,6 +476,7 @@ def run_strategy_cycle( "execution": plan.get("execution", {}), "submitted_orders": submitted_orders, "skipped_orders": skipped_orders, + "execution_notes": execution_notes, "action_done": execution_result.action_done, } if execution_blocked: @@ -513,6 +515,7 @@ def run_strategy_cycle( plan=plan, submitted_orders=list(execution_result.submitted_orders), skipped_orders=list(execution_result.skipped_orders), + execution_notes=list(execution_result.execution_notes), action_done=execution_result.action_done, now=now, ) diff --git a/application/strategy_run_persistence.py b/application/strategy_run_persistence.py index 2eb5397..a1cb657 100644 --- a/application/strategy_run_persistence.py +++ b/application/strategy_run_persistence.py @@ -143,6 +143,7 @@ def build_strategy_run_state( plan: Mapping[str, Any] | None = None, submitted_orders: list[dict[str, Any]] | tuple[dict[str, Any], ...] = (), skipped_orders: list[dict[str, Any]] | tuple[dict[str, Any], ...] = (), + execution_notes: list[dict[str, Any]] | tuple[dict[str, Any], ...] = (), action_done: bool = False, error: str | None = None, now: datetime | None = None, @@ -163,6 +164,7 @@ def build_strategy_run_state( "plan": dict(plan or {}), "submitted_orders": list(submitted_orders), "skipped_orders": list(skipped_orders), + "execution_notes": list(execution_notes), "action_done": action_done, } if error: diff --git a/notifications/telegram.py b/notifications/telegram.py index 677a0be..3af4121 100644 --- a/notifications/telegram.py +++ b/notifications/telegram.py @@ -9,6 +9,55 @@ from quant_platform_kit.common.notification_localization import ( localize_notification_text as _base_localize_notification_text, ) +try: + from quant_platform_kit.common.small_account_compatibility import ( + format_small_account_cash_substitution_notes, + ) +except ImportError: # pragma: no cover - compatibility with older pinned shared wheels + def format_small_account_cash_substitution_notes( + notes, + *, + translator, + wrapper_key="buy_deferred", + detail_key="buy_deferred_small_account_cash_substitution", + cash_label_key="cash_label", + symbol_suffix=".US", + ): + messages = [] + seen_keys = set() + for note in tuple(notes or ()): + if not isinstance(note, Mapping): + continue + symbol = str(note.get("symbol") or "").strip().upper() + if not symbol: + continue + target_value = max(0.0, float(note.get("target_value") or 0.0)) + price = max(0.0, float(note.get("price") or 0.0)) + if target_value <= 0.0 or price <= 0.0: + continue + cash_symbols = tuple( + dict.fromkeys( + str(cash_symbol or "").strip().upper() + for cash_symbol in tuple(note.get("cash_symbols") or ()) + if str(cash_symbol or "").strip() + ) + ) + cash_symbols_text = ", ".join(f"{cash_symbol}{symbol_suffix}" for cash_symbol in cash_symbols) + if not cash_symbols_text: + cash_symbols_text = translator(cash_label_key) + note_key = (symbol, f"{target_value:.2f}", cash_symbols_text) + if note_key in seen_keys: + continue + seen_keys.add(note_key) + detail = translator( + detail_key, + symbol=f"{symbol}{symbol_suffix}", + diff=f"{target_value:.2f}", + price=f"{price:.2f}", + cash_symbols=cash_symbols_text, + ) + messages.append(translator(wrapper_key, detail=detail)) + return tuple(messages) SEPARATOR = "━━━━━━━━━━━━━━━━━━" @@ -31,6 +80,7 @@ "buying_power": "购买力", "reserved_cash": "预留现金", "investable_cash": "可投资现金", + "cash_label": "现金", "holdings_title": "💼 策略持仓", "holding_line": "{symbol}: {market_value} / {quantity}", "quantity_share": "{quantity}股", @@ -90,6 +140,8 @@ "no_rebalance_needed": "✅ 无需调仓", "no_trades": "✅ 无需调仓", "no_executable_orders": "无可执行订单", + "buy_deferred": "ℹ️ [买入说明] {detail}", + "buy_deferred_small_account_cash_substitution": "{symbol} 目标金额 ${diff} 低于 1 股价格 ${price};为避免超过目标仓位,小账户本轮保留现金,不回补 {cash_symbols}", "signal_state_hold": "趋势持有", "signal_state_entry": "入场信号", "signal_state_reduce": "减仓信号", @@ -143,6 +195,7 @@ "buying_power": "Buying power", "reserved_cash": "Reserved cash", "investable_cash": "Investable cash", + "cash_label": "Cash", "holdings_title": "💼 Strategy Holdings", "holding_line": "{symbol}: {market_value} / {quantity}", "quantity_share": "{quantity} share", @@ -202,6 +255,8 @@ "no_rebalance_needed": "✅ No rebalance needed", "no_trades": "✅ No rebalance needed", "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}", "signal_state_hold": "Trend Hold", "signal_state_entry": "Entry Signal", "signal_state_reduce": "Reduce Signal", @@ -637,6 +692,8 @@ def render_cycle_summary(result: Mapping[str, Any], *, lang: str = "en") -> str: lines.extend(_format_signal_lines(execution, translator=translator)) lines.append(SEPARATOR) lines.extend(target_diff_lines) + 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)) if submitted: lines.append(translator("order_logs_title")) lines.extend(_format_order_lines(submitted, dry_run_only=dry_run_only, translator=translator)) diff --git a/pyproject.toml b/pyproject.toml index 7527c8d..ab0047f 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -14,8 +14,8 @@ authors = [ ] dependencies = [ "firstrade==0.0.38", - "quant-platform-kit @ git+https://github.com/QuantStrategyLab/QuantPlatformKit.git@f176f5d1f208724381278c253941cbc6d0a1c964", - "us-equity-strategies @ git+https://github.com/QuantStrategyLab/UsEquityStrategies.git@f206ae7a5f2772873c8e3907daa8d753f616348c", + "quant-platform-kit @ git+https://github.com/QuantStrategyLab/QuantPlatformKit.git@ceb84a366ed1bf9a53292ff2c73e06b4baac05e2", + "us-equity-strategies @ git+https://github.com/QuantStrategyLab/UsEquityStrategies.git@f2ebae8aacd8c70292c5b6115a80c6657e64ad1f", "google-cloud-storage", "requests", ] diff --git a/requirements.txt b/requirements.txt index 2798abb..5feff74 100644 --- a/requirements.txt +++ b/requirements.txt @@ -1,8 +1,8 @@ flask gunicorn firstrade==0.0.38 -quant-platform-kit @ git+https://github.com/QuantStrategyLab/QuantPlatformKit.git@f176f5d1f208724381278c253941cbc6d0a1c964 -us-equity-strategies @ git+https://github.com/QuantStrategyLab/UsEquityStrategies.git@f206ae7a5f2772873c8e3907daa8d753f616348c +quant-platform-kit @ git+https://github.com/QuantStrategyLab/QuantPlatformKit.git@ceb84a366ed1bf9a53292ff2c73e06b4baac05e2 +us-equity-strategies @ git+https://github.com/QuantStrategyLab/UsEquityStrategies.git@f2ebae8aacd8c70292c5b6115a80c6657e64ad1f google-cloud-storage requests pytest diff --git a/tests/test_execution_service.py b/tests/test_execution_service.py index e9dd45b..6ba3e79 100644 --- a/tests/test_execution_service.py +++ b/tests/test_execution_service.py @@ -267,6 +267,14 @@ 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 == ( + { + "symbol": "SOXX", + "target_value": 194.10, + "price": 525.0, + "cash_symbols": ("BOXX",), + }, + ) 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 9814e4c..e40cd2d 100644 --- a/tests/test_rebalance_service.py +++ b/tests/test_rebalance_service.py @@ -628,6 +628,46 @@ def test_render_cycle_summary_localizes_strategy_signal_codes(): assert "small_account_warning_note" not in message +def test_render_cycle_summary_includes_small_account_cash_note_zh(): + message = render_cycle_summary( + { + "account": "****1234", + "strategy_profile": "soxl_soxx_trend_income", + "strategy_display_name": "SOXL/SOXX 半导体趋势收益", + "dry_run_only": False, + "portfolio": { + "total_equity": 1294.0, + "liquid_cash": 1294.0, + "portfolio_rows": (("SOXL", "SOXX"), ("BOXX",)), + "market_values": {"SOXL": 0.0, "SOXX": 0.0, "BOXX": 0.0}, + "quantities": {"SOXL": 0, "SOXX": 0, "BOXX": 0}, + }, + "allocation": {"targets": {"SOXL": 0.0, "SOXX": 0.0, "BOXX": 0.0}}, + "execution": { + "reserved_cash": 38.82, + "investable_cash": 1255.18, + "signal_date": "2026-05-26", + "effective_date": "2026-05-27", + "execution_timing_contract": "next_trading_day", + }, + "submitted_orders": [], + "skipped_orders": [], + "execution_notes": [ + { + "symbol": "SOXX", + "target_value": 194.10, + "price": 525.0, + "cash_symbols": ("BOXX",), + } + ], + }, + lang="zh", + ) + + assert "ℹ️ [买入说明] SOXX.US 目标金额 $194.10 低于 1 股价格 $525.00" in message + assert "小账户本轮保留现金,不回补 BOXX.US" in message + + def test_render_cycle_summary_formats_skipped_orders_in_unified_english_template(): message = render_cycle_summary( {