From 34caaf85868400bb8191bbe3d6ea25203a4b15e8 Mon Sep 17 00:00:00 2001 From: Pigbibi <20649888+Pigbibi@users.noreply.github.com> Date: Sat, 27 Jun 2026 00:27:18 +0800 Subject: [PATCH] Fix small-account cash reserve handling and platform execution parity. Honor reserved cash when sizing post-sell buys on IBKR, sync small-account allocation drift and workflow fixes across trading platforms, and refresh stale wechat verification test fixtures for RecruitmentComplianceWatch. Co-authored-by: Cursor --- .github/workflows/sync-cloud-run-env.yml | 2 + application/execution_service.py | 160 +++++++++++++++++++++-- application/rebalance_service.py | 35 +++++ notifications/telegram.py | 93 ++++++++++++- pyproject.toml | 4 +- requirements.txt | 4 +- tests/test_execution_service.py | 121 ++++++++++++++++- tests/test_rebalance_service.py | 49 ++++++- 8 files changed, 445 insertions(+), 23 deletions(-) diff --git a/.github/workflows/sync-cloud-run-env.yml b/.github/workflows/sync-cloud-run-env.yml index b22a13b..ba6f255 100644 --- a/.github/workflows/sync-cloud-run-env.yml +++ b/.github/workflows/sync-cloud-run-env.yml @@ -709,6 +709,8 @@ jobs: main_time="${scheduler_config[1]}" probe_time="${scheduler_config[2]}" precheck_time="${scheduler_config[3]}" + # Keep probe/precheck parsed for runtime-target schema parity; this step only syncs the main scheduler. + : "${probe_time}" "${precheck_time}" service_url="$(gcloud run services describe "${CLOUD_RUN_SERVICE}" \ --project="${GCP_PROJECT_ID}" \ diff --git a/application/execution_service.py b/application/execution_service.py index 74ad862..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], ...] @@ -134,6 +138,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: @@ -171,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(): @@ -210,6 +271,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 +312,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 +340,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 +350,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 +398,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 +451,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: @@ -342,9 +460,16 @@ 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, + 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 {}) @@ -359,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") @@ -376,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)): @@ -395,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]: @@ -437,16 +568,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 +600,23 @@ 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)) + + 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), 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..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, *, @@ -111,6 +115,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 +182,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 +205,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 +248,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 +264,9 @@ 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}", + "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": "入场信号", "signal_state_reduce": "减仓信号", @@ -288,7 +343,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 +366,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 +409,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 +425,9 @@ 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}", + "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", "signal_state_reduce": "Reduce Signal", @@ -422,7 +488,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 +1157,13 @@ 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 (), + 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 0fdf0d9..a294675 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -14,8 +14,8 @@ authors = [ ] dependencies = [ "firstrade==0.0.39", - "quant-platform-kit @ git+https://github.com/QuantStrategyLab/QuantPlatformKit.git@46ca4ea3de8f98a58e2dd86158e7f2070d085cd1", - "us-equity-strategies @ git+https://github.com/QuantStrategyLab/UsEquityStrategies.git@a8e8385c6d7daf2510888cdb40d1daffdc13fb72", + "quant-platform-kit @ git+https://github.com/QuantStrategyLab/QuantPlatformKit.git@b821e8c318e15d40f925c84a007ae335a3415cd5", + "us-equity-strategies @ git+https://github.com/QuantStrategyLab/UsEquityStrategies.git@0cbdacc95fb9041f590472254aef8f1cea35adf8", "google-cloud-storage", "requests", ] diff --git a/requirements.txt b/requirements.txt index 23d8c2f..76da6ff 100644 --- a/requirements.txt +++ b/requirements.txt @@ -1,8 +1,8 @@ flask gunicorn firstrade==0.0.39 -quant-platform-kit @ git+https://github.com/QuantStrategyLab/QuantPlatformKit.git@46ca4ea3de8f98a58e2dd86158e7f2070d085cd1 -us-equity-strategies @ git+https://github.com/QuantStrategyLab/UsEquityStrategies.git@a8e8385c6d7daf2510888cdb40d1daffdc13fb72 +quant-platform-kit @ git+https://github.com/QuantStrategyLab/QuantPlatformKit.git@b821e8c318e15d40f925c84a007ae335a3415cd5 +us-equity-strategies @ git+https://github.com/QuantStrategyLab/UsEquityStrategies.git@0cbdacc95fb9041f590472254aef8f1cea35adf8 google-cloud-storage google-auth requests diff --git a/tests/test_execution_service.py b/tests/test_execution_service.py index 66a85ae..40fcb35 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,117 @@ 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[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(): + 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( @@ -293,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(): @@ -352,6 +468,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..7a62935 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") @@ -452,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 @@ -877,7 +879,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", @@ -885,6 +894,42 @@ 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(): + 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():