diff --git a/application/execution_service.py b/application/execution_service.py index 68a54b6..9a1f7fd 100644 --- a/application/execution_service.py +++ b/application/execution_service.py @@ -70,14 +70,22 @@ def should_sell_cash_sweep_to_fund_whole_share_buy( return False try: from quant_platform_kit.common.small_account_compatibility import ( - project_unbuyable_value_targets_to_cash, + apply_small_account_cash_compatibility, + format_small_account_cash_substitution_notes, ) 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 + whole_share_substituted_symbols: tuple[str, ...] + safe_haven_cash_substituted_symbols: tuple[str, ...] + cash_substitution_notes: tuple[dict, ...] + + def _project_unbuyable_value_targets_to_cash( target_values, prices, *, - symbols=None, + candidate_symbols=None, quantity_step=1.0, ): adjusted = { @@ -87,23 +95,140 @@ 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): adjusted[symbol] = 0.0 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), + ) + + 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) from quant_platform_kit.common.quantity import ( floor_to_quantity_step, format_quantity, @@ -224,6 +349,25 @@ def record_note_log(note_logs, *, translator, with_prefix, kind, **kwargs): print(with_prefix(message), flush=True) +def record_small_account_cash_substitution_notes( + note_logs, + *, + allocation, + translator, + with_prefix, + seen_keys, +): + for message in format_small_account_cash_substitution_notes( + allocation.get("small_account_whole_share_cash_notes") or (), + translator=translator, + ): + if message in seen_keys: + continue + seen_keys.add(message) + note_logs.append(message) + print(with_prefix(message), flush=True) + + def _floor_whole_share_quantity(quantity): return normalize_order_quantity(floor_to_quantity_step(quantity, 1.0)) @@ -300,36 +444,29 @@ def _apply_small_account_whole_share_compatibility( continue if price > 0.0: quote_prices[symbol] = price - adjusted_targets, substituted = project_unbuyable_value_targets_to_cash( - target_values, - quote_prices, - symbols=candidate_symbols, - quantity_step=1.0, - ) safe_haven_symbols = _safe_haven_cash_symbols( portfolio=dict((plan or {}).get("portfolio") or {}), 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) + compatibility = apply_small_account_cash_compatibility( + target_values, + quote_prices, + 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, + ) + adjusted_targets = compatibility.targets + substituted = compatibility.whole_share_substituted_symbols + safe_haven_substituted = compatibility.safe_haven_cash_substituted_symbols adjusted_allocation = {**dict(allocation or {}), "targets": adjusted_targets} + adjusted_allocation.pop("small_account_whole_share_cash_notes", None) if substituted: adjusted_allocation["small_account_whole_share_substituted_symbols"] = substituted if safe_haven_substituted: adjusted_allocation["small_account_safe_haven_cash_substituted_symbols"] = tuple(safe_haven_substituted) + if compatibility.cash_substitution_notes: + adjusted_allocation["small_account_whole_share_cash_notes"] = tuple(compatibility.cash_substitution_notes) adjusted_plan = dict(plan or {}) if substituted or safe_haven_substituted: adjusted_plan["allocation"] = adjusted_allocation @@ -414,6 +551,7 @@ def execute_rebalance_cycle( logs: list[str] = [] skip_logs: list[str] = [] note_logs: list[str] = [] + small_account_cash_note_keys: set[str] = set() action_done = False sell_submitted = False threshold_value = float(execution["trade_threshold_value"]) @@ -439,6 +577,13 @@ def execute_rebalance_cycle( market_data_port=market_data_port, notify_issue=notify_issue, ) + record_small_account_cash_substitution_notes( + note_logs, + allocation=allocation, + translator=translator, + with_prefix=with_prefix, + seen_keys=small_account_cash_note_keys, + ) target_values = dict(allocation["targets"]) available_cash = float(portfolio["liquid_cash"]) cash_by_currency = _normalize_cash_by_currency(portfolio.get("cash_by_currency")) @@ -720,6 +865,13 @@ def record_dry_run(symbol, side, quantity, price, *, order_type): market_data_port=market_data_port, notify_issue=notify_issue, ) + record_small_account_cash_substitution_notes( + note_logs, + allocation=allocation, + translator=translator, + with_prefix=with_prefix, + seen_keys=small_account_cash_note_keys, + ) threshold_value = float(execution["trade_threshold_value"]) limit_order_symbols = set( allocation.get("risk_symbols", ()) + allocation.get("income_symbols", ()) diff --git a/notifications/telegram.py b/notifications/telegram.py index 8c8bd28..07bb591 100644 --- a/notifications/telegram.py +++ b/notifications/telegram.py @@ -62,6 +62,7 @@ "buy_deferred_no_investable_cash": "账户现金 ${available} 低于策略保留阈值,可投资现金为 ${investable},本轮不发起买单", "buy_deferred_non_usd_cash": "检测到非 USD 现金({currencies}),但美股策略可用 USD 现金为 ${available}、可投资现金为 ${investable};请先换汇或入金 USD 后再买入", "buy_deferred_small_target_gap": "{symbol} 目标差额 ${diff} 未超过 1 股价格 ${price};为避免超过目标仓位,本轮不买入", + "buy_deferred_small_account_cash_substitution": "{symbol} 目标金额 ${diff} 低于 1 股价格 ${price};为避免超过目标仓位,小账户本轮保留现金,不回补 {cash_symbols}", "buy_deferred_small_cash": "{symbol} 目标差额 ${diff},但可投资现金 ${investable} 不足买入 1 股(价格 ${price})", "buy_deferred_cash_limit": "{symbol} 目标差额 ${diff},预算可买 {budget_qty} 股,但券商估算可买数量为 0;可能有未完成挂单、结算或购买力占用", "cash_sweep_rebuy": "🏦 [尾部回补] 剩余可投资现金回补 {symbol}: {qty}股 @ ${price}", @@ -177,6 +178,7 @@ "buy_deferred_no_investable_cash": "Account cash ${available} is below the strategy reserve threshold, investable cash is ${investable}; no buy order this cycle", "buy_deferred_non_usd_cash": "Non-USD cash is present ({currencies}), but this US-equity strategy has USD cash ${available} and investable cash ${investable}; convert or deposit USD before buying", "buy_deferred_small_target_gap": "{symbol} target gap ${diff} does not exceed the 1-share price ${price}; skipped to avoid exceeding the target allocation", + "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_deferred_small_cash": "{symbol} target gap ${diff}, but investable cash ${investable} is not enough for 1 share at ${price}", "buy_deferred_cash_limit": "{symbol} target gap ${diff}, budget supports {budget_qty} shares, but broker estimate returned 0; an open order, settlement, or buying-power hold may still be blocking funds", "cash_sweep_rebuy": "🏦 [tail rebuy] residual investable cash rebought {symbol}: {qty} shares @ ${price}", diff --git a/requirements.txt b/requirements.txt index 81e193b..f50c8c4 100644 --- a/requirements.txt +++ b/requirements.txt @@ -1,7 +1,7 @@ flask gunicorn -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 pandas requests pytz diff --git a/tests/test_rebalance_service.py b/tests/test_rebalance_service.py index 9d96386..e880a7e 100644 --- a/tests/test_rebalance_service.py +++ b/tests/test_rebalance_service.py @@ -724,7 +724,9 @@ def test_strategy_target_keeps_cash_when_only_risk_target_is_unbuyable(self): self.assertEqual(len(sent_messages), 1) self.assertIn("🔔 【调仓指令】", sent_messages[0]) - self.assertNotIn("SOXX.US 目标差额 $163.14", sent_messages[0]) + self.assertIn("SOXX.US 目标金额 $163.14 低于 1 股价格 $504.60", sent_messages[0]) + self.assertIn("小账户本轮保留现金", sent_messages[0]) + self.assertIn("不回补 BOXX.US", sent_messages[0]) self.assertNotIn("可投资现金 $1191.03 不足买入 1 股", sent_messages[0]) self.assertIn("市价卖出] BOXX: 6股", sent_messages[0]) self.assertNotIn("市价买入] SOXX", sent_messages[0])