diff --git a/application/execution_service.py b/application/execution_service.py index 74ad862..884d390 100644 --- a/application/execution_service.py +++ b/application/execution_service.py @@ -134,6 +134,39 @@ class ExecutionCycleResult: DEFAULT_SAFE_HAVEN_CASH_SUBSTITUTE_THRESHOLD_USD = 1000.0 SMALL_ACCOUNT_SAFE_HAVEN_CASH_SUBSTITUTE_LIMIT_USD = 2000.0 SMALL_ACCOUNT_EXISTING_WHOLE_SHARE_RETENTION_SYMBOLS = frozenset({"TQQQ", "SOXL"}) +SMALL_ACCOUNT_EXISTING_WHOLE_SHARE_RETENTION_MIN_TARGET_SHARE_RATIO_BY_SYMBOL = { + "SOXX": 0.90, +} +SMALL_ACCOUNT_WHOLE_SHARE_BOOTSTRAP_MIN_TARGET_SHARE_RATIO_BY_SYMBOL = { + "TQQQ": 0.90, + "SOXL": 0.90, + "SOXX": 0.90, +} + + +def _limit_buy_premium_for_symbol(symbol, default_premium, premium_by_symbol=None) -> float: + normalized_symbol = str(symbol or "").strip().upper() + try: + fallback = float(default_premium) + except (TypeError, ValueError): + fallback = 1.005 + if not isinstance(premium_by_symbol, dict): + return fallback + raw_value = premium_by_symbol.get(normalized_symbol) + if raw_value is None: + return fallback + try: + premium = float(raw_value) + except (TypeError, ValueError): + return fallback + return premium if premium > 0.0 else fallback + + +def _limit_buy_price(symbol, price, default_premium, premium_by_symbol=None) -> float: + return round( + float(price) * _limit_buy_premium_for_symbol(symbol, default_premium, premium_by_symbol), + 2, + ) def _floor_quantity(quantity: float) -> int: @@ -210,6 +243,35 @@ def substitute_small_safe_haven_targets_with_cash( return adjusted_plan +def _should_retain_existing_whole_share(symbol, *, target_value, price) -> bool: + normalized_symbol = str(symbol or "").strip().upper() + if normalized_symbol in SMALL_ACCOUNT_EXISTING_WHOLE_SHARE_RETENTION_SYMBOLS: + return True + + min_target_share_ratio = ( + SMALL_ACCOUNT_EXISTING_WHOLE_SHARE_RETENTION_MIN_TARGET_SHARE_RATIO_BY_SYMBOL.get(normalized_symbol) + ) + if min_target_share_ratio is None: + return False + quote_price = max(0.0, float(price or 0.0)) + if quote_price <= 0.0: + return False + return max(0.0, float(target_value or 0.0)) >= quote_price * float(min_target_share_ratio) + + +def _should_bootstrap_whole_share_buy(symbol, *, target_value, limit_price) -> bool: + normalized_symbol = str(symbol or "").strip().upper() + min_target_share_ratio = ( + SMALL_ACCOUNT_WHOLE_SHARE_BOOTSTRAP_MIN_TARGET_SHARE_RATIO_BY_SYMBOL.get(normalized_symbol) + ) + if min_target_share_ratio is None: + return False + effective_limit_price = max(0.0, float(limit_price or 0.0)) + if effective_limit_price <= 0.0: + return False + return max(0.0, float(target_value or 0.0)) >= effective_limit_price * float(min_target_share_ratio) + + def _quote_price(market_data_port: MarketDataPort, symbol: str) -> float | None: try: price = float(market_data_port.get_quote(symbol).last_price) @@ -222,6 +284,8 @@ def _apply_small_account_whole_share_compatibility( plan: dict[str, Any], *, market_data_port: MarketDataPort, + limit_buy_premium: float = 1.005, + limit_buy_premium_by_symbol: dict[str, float] | None = None, ) -> dict[str, Any]: adjusted_plan = dict(plan or {}) allocation = dict(adjusted_plan.get("allocation") or {}) @@ -248,6 +312,7 @@ def _apply_small_account_whole_share_compatibility( if price is not None: prices[str(symbol).strip().upper()] = price retained_symbols = [] + bootstrap_symbols = [] quantities = { str(symbol or "").strip().upper(): float(quantity or 0.0) for symbol, quantity in dict(portfolio.get("quantities") or {}).items() @@ -257,13 +322,33 @@ def _apply_small_account_whole_share_compatibility( for symbol, value in targets.items() } for symbol in candidate_symbols: - if symbol not in SMALL_ACCOUNT_EXISTING_WHOLE_SHARE_RETENTION_SYMBOLS: - continue target_value = max(0.0, float(compatibility_targets.get(symbol, 0.0) or 0.0)) price = max(0.0, float(prices.get(symbol, 0.0) or 0.0)) + limit_price = ( + _limit_buy_price(symbol, price, limit_buy_premium, limit_buy_premium_by_symbol) + if price > 0.0 + else 0.0 + ) + if not _should_retain_existing_whole_share(symbol, target_value=target_value, price=price): + if ( + quantities.get(symbol, 0.0) <= 0.0 + and 0.0 < target_value < limit_price + and _should_bootstrap_whole_share_buy(symbol, target_value=target_value, limit_price=limit_price) + ): + compatibility_targets[symbol] = limit_price + bootstrap_symbols.append(symbol) + continue if price > 0.0 and 0.0 < target_value < price and quantities.get(symbol, 0.0) >= 1.0: compatibility_targets[symbol] = price retained_symbols.append(symbol) + continue + if ( + quantities.get(symbol, 0.0) <= 0.0 + and 0.0 < target_value < limit_price + and _should_bootstrap_whole_share_buy(symbol, target_value=target_value, limit_price=limit_price) + ): + compatibility_targets[symbol] = limit_price + bootstrap_symbols.append(symbol) safe_haven_symbols = _safe_haven_cash_symbols(portfolio=portfolio, allocation=allocation) compatibility = apply_small_account_cash_compatibility( compatibility_targets, @@ -285,6 +370,10 @@ def _apply_small_account_whole_share_compatibility( allocation["small_account_existing_whole_share_retained_symbols"] = tuple( dict.fromkeys(retained_symbols) ) + if bootstrap_symbols: + allocation["small_account_whole_share_bootstrap_symbols"] = tuple( + dict.fromkeys(bootstrap_symbols) + ) if compatibility.cash_substitution_notes: allocation["small_account_whole_share_cash_notes"] = tuple(compatibility.cash_substitution_notes) adjusted_plan["allocation"] = allocation @@ -334,6 +423,7 @@ def execute_value_target_plan( dry_run_only: bool, limit_sell_discount: float = 0.995, limit_buy_premium: float = 1.005, + limit_buy_premium_by_symbol: dict[str, float] | None = None, max_order_notional_usd: float | None = None, safe_haven_cash_substitute_threshold_usd: float = DEFAULT_SAFE_HAVEN_CASH_SUBSTITUTE_THRESHOLD_USD, ) -> ExecutionCycleResult: @@ -345,6 +435,8 @@ def execute_value_target_plan( plan = _apply_small_account_whole_share_compatibility( plan, market_data_port=market_data_port, + limit_buy_premium=limit_buy_premium, + limit_buy_premium_by_symbol=limit_buy_premium_by_symbol, ) allocation = dict(plan.get("allocation") or {}) portfolio = dict(plan.get("portfolio") or {}) @@ -437,16 +529,17 @@ def execute_value_target_plan( buy_budget = min(float(delta_value), investable_cash) if order_notional_cap is not None: buy_budget = min(buy_budget, order_notional_cap) - quantity = _floor_quantity(buy_budget / price) + limit_price = _limit_buy_price(symbol, price, limit_buy_premium, limit_buy_premium_by_symbol) + quantity = _floor_quantity(buy_budget / limit_price) if limit_price > 0 else 0 if quantity <= 0: - if order_notional_cap is None and investable_cash < price: + if order_notional_cap is None and investable_cash < limit_price: skipped.append( { "symbol": symbol, "reason": "insufficient_cash_for_whole_share", - "price": round(price, 2), + "price": round(limit_price, 2), "investable_cash": round(investable_cash, 2), - "required_cash_for_one_share": round(price, 2), + "required_cash_for_one_share": round(limit_price, 2), } ) else: @@ -468,11 +561,11 @@ def execute_value_target_plan( symbol=symbol, side="buy", quantity=quantity, - limit_price=price * float(limit_buy_premium), + limit_price=limit_price, max_notional_usd=max_order_notional_usd, ) ) - investable_cash = max(0.0, investable_cash - (quantity * price)) + investable_cash = max(0.0, investable_cash - (quantity * limit_price)) return ExecutionCycleResult( submitted_orders=tuple(submitted), diff --git a/application/rebalance_service.py b/application/rebalance_service.py index ad226d8..465333a 100644 --- a/application/rebalance_service.py +++ b/application/rebalance_service.py @@ -66,6 +66,40 @@ LIMIT_SELL_DISCOUNT = 0.995 LIMIT_BUY_PREMIUM = 1.005 +DEFAULT_LIMIT_BUY_PREMIUM_BY_SYMBOL = {"SOXL": 1.015, "TQQQ": 1.010} + + +def _load_limit_buy_premium_by_symbol(*env_names: str) -> dict[str, float]: + raw_value = "" + for env_name in env_names: + value = os.getenv(env_name) + if value and value.strip(): + raw_value = value.strip() + break + if not raw_value: + return dict(DEFAULT_LIMIT_BUY_PREMIUM_BY_SYMBOL) + try: + payload = json.loads(raw_value) + except json.JSONDecodeError as exc: + raise ValueError(f"Invalid limit buy premium map JSON: {raw_value!r}") from exc + if not isinstance(payload, dict): + raise ValueError("Limit buy premium map must be a JSON object keyed by symbol.") + parsed: dict[str, float] = {} + for symbol, premium in payload.items(): + symbol_text = str(symbol or "").strip().upper() + if not symbol_text: + continue + premium_value = float(premium) + if premium_value <= 0.0: + raise ValueError(f"Limit buy premium for {symbol_text} must be positive.") + parsed[symbol_text] = premium_value + return parsed + + +LIMIT_BUY_PREMIUM_BY_SYMBOL = _load_limit_buy_premium_by_symbol( + "FIRSTRADE_LIMIT_BUY_PREMIUM_BY_SYMBOL_JSON", + "LIMIT_BUY_PREMIUM_BY_SYMBOL_JSON", +) def _utcnow() -> datetime: @@ -512,6 +546,7 @@ def log_message(message: str) -> None: dry_run_only=settings.dry_run_only, limit_sell_discount=LIMIT_SELL_DISCOUNT, limit_buy_premium=LIMIT_BUY_PREMIUM, + limit_buy_premium_by_symbol=LIMIT_BUY_PREMIUM_BY_SYMBOL, max_order_notional_usd=settings.max_order_notional_usd, safe_haven_cash_substitute_threshold_usd=settings.safe_haven_cash_substitute_threshold_usd, ) diff --git a/notifications/telegram.py b/notifications/telegram.py index 7f5eaeb..7664521 100644 --- a/notifications/telegram.py +++ b/notifications/telegram.py @@ -111,6 +111,46 @@ def format_small_account_cash_substitution_notes( return tuple(messages) +def _format_symbol_with_suffix(symbol, *, suffix=".US") -> str: + normalized = str(symbol or "").strip().upper() + if not normalized: + return normalized + if "." in normalized: + return normalized + normalized_suffix = str(suffix or "").strip().upper() + return f"{normalized}{normalized_suffix}" if normalized_suffix else normalized + + +def format_small_account_whole_share_bootstrap_notes( + symbols, + *, + translator, + symbol_suffix=".US", +) -> tuple[str, ...]: + normalized_symbols = tuple( + dict.fromkeys( + _format_symbol_with_suffix(symbol, suffix=symbol_suffix) + for symbol in tuple(symbols or ()) + if str(symbol or "").strip() + ) + ) + if not normalized_symbols: + return () + try: + message = translator( + "buy_lifted_small_account_whole_share", + symbols=", ".join(normalized_symbols), + ) + except Exception: + message = "" + if not message or message == "buy_lifted_small_account_whole_share": + message = ( + f"ℹ️ [买入说明] {', '.join(normalized_symbols)} 目标金额接近 1 股;" + "小账户整数股兼容,本轮允许按 1 股下单" + ) + return (message,) + + SEPARATOR = "━━━━━━━━━━━━━━━━━━" _DETAIL_FIELD_SPLIT_RE = re.compile(r",\s*(?=[A-Za-z_][\w-]*\s*=)") @@ -138,7 +178,15 @@ def format_small_account_cash_substitution_notes( "quantity_share": "{quantity}股", "quantity_shares": "{quantity}股", "signal_label": "信号", - "strategy_plugin_line": "🧩 插件:{plugin} | 状态:{route} | 提醒:{action}", + "strategy_plugin_line": "🧩 插件:{plugin} | 启用:{enabled} | 状态:{route} | 提醒:{action}", + "strategy_plugin_enabled_true": "是", + "strategy_plugin_enabled_false": "否", + "strategy_plugin_consumption_auto": "🧩 插件消费:已按策略规则参与本轮仓位计算", + "strategy_plugin_consumption_auto_defend": "🧩 插件消费:已按策略规则参与本轮仓位计算;风险仓位按防守规则处理", + "strategy_plugin_consumption_auto_delever": "🧩 插件消费:已按策略规则参与本轮仓位计算;杠杆仓位按降档规则缩放", + "strategy_plugin_consumption_loaded_not_applied": "🧩 插件消费:已加载但未改写仓位;当前策略未启用该状态的自动消费", + "strategy_plugin_consumption_review_only": "🧩 插件消费:仅通知复核,未参与自动仓位计算", + "strategy_plugin_consumption_unavailable": "🧩 插件消费:未消费插件信号", "strategy_plugin_alert_subject": "🚨 策略插件告警:{plugin} | {route}", "strategy_plugin_alert_title": "🚨 【策略插件告警】", "strategy_plugin_alert_context": "运行环境:{context}", @@ -153,7 +201,7 @@ def format_small_account_cash_substitution_notes( "strategy_plugin_alert_scope": "仅作人工复核提醒;插件不会自动下单或改仓位", "strategy_plugin_name_crisis_response_shadow": "危机观察通知", "strategy_plugin_name_macro_risk_governor": "宏观风险控制通知", - "strategy_plugin_name_market_regime_control": "市场状态控制通知", + "strategy_plugin_name_market_regime_control": "市场状态控制", "strategy_plugin_name_panic_reversal_shadow": "恐慌反转观察通知", "strategy_plugin_name_taco_rebound_shadow": "TACO 反弹观察通知", "strategy_plugin_mode_shadow": "影子观察", @@ -196,7 +244,7 @@ def format_small_account_cash_substitution_notes( "target_diff_summary": "调仓变化: {details}", "order_logs_title": "🧾 执行明细", "dry_run_order": "🧪 模拟{order_type}{side} {symbol}: {quantity}{price}", - "submitted_order": "{icon} 已提交{order_type}{side} {symbol}: {quantity}{price}{order_id}", + "submitted_order": "{icon} 已提交{order_type}{side} {symbol}: {quantity}{price}{order_id}(尚未确认成交;限价单可能未成交或取消)", "order_type_limit": "限价", "order_type_market": "市价", "side_buy": "买入", @@ -212,6 +260,7 @@ def format_small_account_cash_substitution_notes( "no_executable_orders": "无可执行订单", "buy_deferred": "ℹ️ [买入说明] {detail}", "buy_deferred_small_account_cash_substitution": "{symbol} 目标金额 ${diff} 低于 1 股价格 ${price};为避免超过目标仓位,小账户本轮保留现金,不回补 {cash_symbols}", + "buy_lifted_small_account_whole_share": "ℹ️ [买入说明] {symbols} 目标金额接近 1 股;小账户整数股兼容,本轮允许按 1 股下单", "signal_state_hold": "趋势持有", "signal_state_entry": "入场信号", "signal_state_reduce": "减仓信号", @@ -288,7 +337,15 @@ def format_small_account_cash_substitution_notes( "quantity_share": "{quantity} share", "quantity_shares": "{quantity} shares", "signal_label": "Signal", - "strategy_plugin_line": "🧩 Plugin: {plugin} | status: {route} | notice: {action}", + "strategy_plugin_line": "🧩 Plugin: {plugin} | enabled: {enabled} | status: {route} | notice: {action}", + "strategy_plugin_enabled_true": "yes", + "strategy_plugin_enabled_false": "no", + "strategy_plugin_consumption_auto": "🧩 Plugin consumption: included in this cycle's position calculation under strategy rules", + "strategy_plugin_consumption_auto_defend": "🧩 Plugin consumption: included in this cycle's position calculation; risk exposure follows defensive rules", + "strategy_plugin_consumption_auto_delever": "🧩 Plugin consumption: included in this cycle's position calculation; leveraged exposure follows de-risking rules", + "strategy_plugin_consumption_loaded_not_applied": "🧩 Plugin consumption: loaded but did not rewrite positions; this strategy does not enable automatic consumption for this state", + "strategy_plugin_consumption_review_only": "🧩 Plugin consumption: review-only notice, not used for automatic position calculation", + "strategy_plugin_consumption_unavailable": "🧩 Plugin consumption: no plugin signal consumed", "strategy_plugin_alert_subject": "🚨 Strategy plugin alert: {plugin} | {route}", "strategy_plugin_alert_title": "🚨 【Strategy Plugin Alert】", "strategy_plugin_alert_context": "Context: {context}", @@ -303,7 +360,7 @@ def format_small_account_cash_substitution_notes( "strategy_plugin_alert_scope": "Manual review notice only; the plugin does not place orders or change allocations", "strategy_plugin_name_crisis_response_shadow": "Crisis Watch Notice", "strategy_plugin_name_macro_risk_governor": "Macro Risk Governor Notice", - "strategy_plugin_name_market_regime_control": "Market Regime Control Notice", + "strategy_plugin_name_market_regime_control": "Market Regime Control", "strategy_plugin_name_panic_reversal_shadow": "Panic Reversal Watch Notice", "strategy_plugin_name_taco_rebound_shadow": "TACO Rebound Watch Notice", "strategy_plugin_mode_shadow": "shadow", @@ -346,7 +403,7 @@ def format_small_account_cash_substitution_notes( "target_diff_summary": "Target changes: {details}", "order_logs_title": "🧾 Execution details", "dry_run_order": "🧪 Dry-run {order_type} {side} {symbol}: {quantity}{price}", - "submitted_order": "{icon} Submitted {order_type} {side} {symbol}: {quantity}{price}{order_id}", + "submitted_order": "{icon} Submitted {order_type} {side} {symbol}: {quantity}{price}{order_id} (fill not confirmed; a limit order may remain unfilled or be canceled)", "order_type_limit": "limit", "order_type_market": "market", "side_buy": "buy", @@ -362,6 +419,7 @@ def format_small_account_cash_substitution_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}", + "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", "signal_state_reduce": "Reduce Signal", @@ -422,7 +480,13 @@ def format_small_account_cash_substitution_notes( } if _merge_strategy_plugin_i18n is not None: - I18N = _merge_strategy_plugin_i18n(I18N) + _PLATFORM_I18N = {locale: dict(values) for locale, values in I18N.items()} + try: + I18N = _merge_strategy_plugin_i18n(I18N, shared_wins=False) + except TypeError: + I18N = _merge_strategy_plugin_i18n(I18N) + for locale, values in _PLATFORM_I18N.items(): + I18N.setdefault(locale, {}).update(values) def build_translator(lang: str | None) -> Callable[..., str]: @@ -1085,6 +1149,12 @@ 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_whole_share_bootstrap_notes( + allocation.get("small_account_whole_share_bootstrap_symbols") or (), + 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/tests/test_execution_service.py b/tests/test_execution_service.py index 66a85ae..65f4000 100644 --- a/tests/test_execution_service.py +++ b/tests/test_execution_service.py @@ -159,6 +159,7 @@ def test_execute_value_target_plan_has_no_default_order_notional_cap(): market_data_port=FakeMarketDataPort({"SPY": 100.0}), execution_port=execution_port, dry_run_only=True, + limit_buy_premium=1.0, ) assert result.action_done is True @@ -183,6 +184,7 @@ def test_execute_value_target_plan_reports_insufficient_cash_for_whole_share(): market_data_port=FakeMarketDataPort({"SPY": 100.0}), execution_port=execution_port, dry_run_only=True, + limit_buy_premium=1.0, ) assert result.action_done is False @@ -219,6 +221,7 @@ def test_execute_value_target_plan_leaves_small_safe_haven_target_as_cash(): market_data_port=FakeMarketDataPort({"AAA": 100.0, "BOXX": 100.0}), execution_port=execution_port, dry_run_only=True, + limit_buy_premium=1.0, max_order_notional_usd=2500.0, safe_haven_cash_substitute_threshold_usd=1000.0, ) @@ -266,6 +269,114 @@ def test_execute_value_target_plan_projects_unbuyable_value_target_to_zero(): ] +def test_execute_value_target_plan_retains_near_one_share_soxx_delever_target(): + execution_port = FakeExecutionPort() + result = execute_value_target_plan( + plan={ + "allocation": { + "strategy_symbols": ("SOXL", "SOXX", "BOXX"), + "risk_symbols": ("SOXL", "SOXX"), + "safe_haven_symbols": ("BOXX",), + "targets": {"SOXL": 357.21, "SOXX": 561.33, "BOXX": 102.06}, + }, + "portfolio": { + "market_values": {"SOXL": 0.0, "SOXX": 605.17, "BOXX": 0.0}, + "quantities": {"SOXL": 0.0, "SOXX": 1.0, "BOXX": 0.0}, + "sellable_quantities": {"SOXX": 1.0}, + "liquid_cash": 519.54, + "cash_sweep_symbol": "BOXX", + }, + "execution": {"current_min_trade": 11.71, "investable_cash": 369.54}, + }, + market_data_port=FakeMarketDataPort({"SOXL": 232.99, "SOXX": 605.17, "BOXX": 117.06}), + execution_port=execution_port, + dry_run_only=True, + max_order_notional_usd=1000.0, + safe_haven_cash_substitute_threshold_usd=1000.0, + ) + + assert result.action_done is True + assert [(order.side, order.symbol, order.quantity) for order in execution_port.orders] == [ + ("buy", "SOXL", 1.0), + ] + + +def test_execute_value_target_plan_bootstraps_close_to_one_share_core_target(): + execution_port = FakeExecutionPort() + result = execute_value_target_plan( + plan={ + "allocation": { + "strategy_symbols": ("SOXL", "SOXX", "BOXX"), + "risk_symbols": ("SOXL", "SOXX"), + "safe_haven_symbols": ("BOXX",), + "targets": {"SOXL": 218.19, "SOXX": 342.86, "BOXX": 62.34}, + }, + "portfolio": { + "market_values": {"SOXL": 0.0, "SOXX": 0.0, "BOXX": 0.0}, + "quantities": {"SOXL": 0.0, "SOXX": 0.0, "BOXX": 0.0}, + "sellable_quantities": {"SOXL": 0.0, "SOXX": 0.0, "BOXX": 0.0}, + "liquid_cash": 623.39, + "cash_sweep_symbol": "BOXX", + }, + "execution": {"current_min_trade": 6.23, "investable_cash": 473.39}, + }, + market_data_port=FakeMarketDataPort({"SOXL": 229.73, "SOXX": 603.0, "BOXX": 100.0}), + execution_port=execution_port, + dry_run_only=True, + limit_buy_premium=1.005, + limit_buy_premium_by_symbol={"SOXL": 1.015}, + max_order_notional_usd=1000.0, + safe_haven_cash_substitute_threshold_usd=1000.0, + ) + + assert result.action_done is True + 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 == ( + { + "symbol": "SOXX", + "target_value": 342.86, + "price": 603.0, + "cash_symbols": (), + }, + ) + + +def test_execute_value_target_plan_uses_symbol_specific_limit_buy_premium_for_budget(): + execution_port = FakeExecutionPort() + result = execute_value_target_plan( + plan={ + "allocation": { + "strategy_symbols": ("SOXL",), + "risk_symbols": ("SOXL",), + "safe_haven_symbols": (), + "targets": {"SOXL": 1000.0}, + }, + "portfolio": { + "market_values": {"SOXL": 0.0}, + "quantities": {"SOXL": 0.0}, + "sellable_quantities": {"SOXL": 0.0}, + "liquid_cash": 1000.0, + "cash_sweep_symbol": "", + }, + "execution": {"current_min_trade": 10.0, "investable_cash": 1000.0}, + }, + market_data_port=FakeMarketDataPort({"SOXL": 100.0}), + execution_port=execution_port, + dry_run_only=True, + limit_buy_premium=1.005, + limit_buy_premium_by_symbol={"SOXL": 1.015}, + max_order_notional_usd=1000.0, + safe_haven_cash_substitute_threshold_usd=1000.0, + ) + + assert result.action_done is True + assert [(order.side, order.symbol, order.quantity, order.limit_price) for order in execution_port.orders] == [ + ("buy", "SOXL", 9.0, 101.5), + ] + + def test_execute_value_target_plan_keeps_safe_haven_cash_when_only_risk_target_is_unbuyable(): execution_port = FakeExecutionPort() result = execute_value_target_plan( @@ -352,6 +463,7 @@ def test_execute_value_target_plan_keeps_safe_haven_when_mixed_case_risk_target_ market_data_port=FakeMarketDataPort({"SOXL": 100.0, "SOXX": 525.0, "BOXX": 100.0}), execution_port=execution_port, dry_run_only=True, + limit_buy_premium=1.0, max_order_notional_usd=2000.0, safe_haven_cash_substitute_threshold_usd=1000.0, ) diff --git a/tests/test_rebalance_service.py b/tests/test_rebalance_service.py index 32eb2c5..e56d8d4 100644 --- a/tests/test_rebalance_service.py +++ b/tests/test_rebalance_service.py @@ -162,11 +162,12 @@ def test_notification_i18n_keys_are_aligned(): zh( "strategy_plugin_line", plugin=zh("strategy_plugin_name_market_regime_control"), + enabled=zh("strategy_plugin_enabled_true"), mode=zh("strategy_plugin_mode_shadow"), route=zh("strategy_plugin_route_risk_reduced"), action=zh("strategy_plugin_action_delever"), ) - == "🧩 插件:市场状态控制 | 状态:风险降低 | 提醒:降杠杆" + == "🧩 插件:市场状态控制 | 启用:是 | 状态:风险降低 | 提醒:降杠杆" ) assert "策略侧已批准" in zh("strategy_plugin_guidance_market_regime_control_risk_reduced_delever") en = build_translator("en") @@ -887,6 +888,41 @@ def test_render_cycle_summary_includes_small_account_cash_note_zh(): assert "小账户本轮保留现金,不回补 BOXX.US" in message +def test_render_cycle_summary_includes_small_account_bootstrap_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": 623.39, + "liquid_cash": 623.39, + "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": 233.18, "SOXX": 0.0, "BOXX": 0.0}, + "small_account_whole_share_bootstrap_symbols": ("SOXL",), + }, + "execution": { + "reserved_cash": 150.0, + "investable_cash": 473.39, + "signal_date": "2026-06-23", + "effective_date": "2026-06-24", + "execution_timing_contract": "next_trading_day", + }, + "submitted_orders": [], + "skipped_orders": [], + }, + lang="zh", + ) + + assert "ℹ️ [买入说明] SOXL.US 目标金额接近 1 股" in message + assert "小账户整数股兼容,本轮允许按 1 股下单" in message + + def test_render_cycle_summary_formats_skipped_orders_in_unified_english_template(): message = render_cycle_summary( {