diff --git a/application/execution_service.py b/application/execution_service.py index 0c089da..678c151 100644 --- a/application/execution_service.py +++ b/application/execution_service.py @@ -68,6 +68,42 @@ def should_sell_cash_sweep_to_fund_whole_share_buy( if current_buying_power + sweep_capacity >= quote_price: return True return False +try: + from quant_platform_kit.common.small_account_compatibility import ( + project_unbuyable_value_targets_to_cash, + ) +except ImportError: # pragma: no cover - compatibility with older pinned shared wheels + def project_unbuyable_value_targets_to_cash( + target_values, + prices, + *, + symbols=None, + quantity_step=1.0, + ): + adjusted = { + str(symbol or "").strip().upper(): float(value or 0.0) + for symbol, value in dict(target_values or {}).items() + } + step = max(0.0, float(quantity_step or 0.0)) + if step <= 0.0: + return adjusted, () + candidate_symbols = ( + tuple(adjusted) + if symbols is None + else tuple(dict.fromkeys(str(symbol or "").strip().upper() for symbol in 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: + 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)) from quant_platform_kit.common.quantity import ( floor_to_quantity_step, format_quantity, @@ -219,6 +255,49 @@ def safe_quote_last_price(symbol, *, market_data_port, notify_issue): return None +def _apply_small_account_whole_share_compatibility( + *, + plan, + allocation, + strategy_assets, + market_data_port, + notify_issue, +) -> tuple[dict, dict]: + target_values = dict(allocation.get("targets") or {}) + candidate_symbols = tuple( + dict.fromkeys( + tuple(allocation.get("risk_symbols", ())) + + tuple(allocation.get("income_symbols", ())) + ) + ) + if not candidate_symbols: + safe_haven_symbols = set(allocation.get("safe_haven_symbols", ())) + candidate_symbols = tuple( + symbol for symbol in target_values if symbol not in safe_haven_symbols + ) + quote_prices = {} + for symbol in candidate_symbols: + try: + price = float(market_data_port.get_quote(f"{symbol}.US").last_price) + except Exception: + 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, + ) + adjusted_allocation = {**dict(allocation or {}), "targets": adjusted_targets} + if substituted: + adjusted_allocation["small_account_whole_share_substituted_symbols"] = substituted + adjusted_plan = dict(plan or {}) + if substituted: + adjusted_plan["allocation"] = adjusted_allocation + return adjusted_plan, adjusted_allocation + + def estimate_cash_buy_quantity_safe( trade_context, symbol, @@ -314,8 +393,15 @@ def execute_rebalance_cycle( allocation=allocation, threshold_usd=safe_haven_cash_substitute_threshold_usd, ) - target_values = dict(allocation["targets"]) cash_sweep_symbol = str(portfolio.get("cash_sweep_symbol") or "").strip().upper() + plan, allocation = _apply_small_account_whole_share_compatibility( + plan=plan, + allocation=allocation, + strategy_assets=strategy_assets, + market_data_port=market_data_port, + notify_issue=notify_issue, + ) + target_values = dict(allocation["targets"]) available_cash = float(portfolio["liquid_cash"]) cash_by_currency = _normalize_cash_by_currency(portfolio.get("cash_by_currency")) investable_cash = float(execution["investable_cash"]) @@ -589,6 +675,13 @@ def record_dry_run(symbol, side, quantity, price, *, order_type): allocation=allocation, threshold_usd=safe_haven_cash_substitute_threshold_usd, ) + plan, allocation = _apply_small_account_whole_share_compatibility( + plan=plan, + allocation=allocation, + strategy_assets=tuple(allocation["strategy_symbols"]), + market_data_port=market_data_port, + notify_issue=notify_issue, + ) threshold_value = float(execution["trade_threshold_value"]) limit_order_symbols = set( allocation.get("risk_symbols", ()) + allocation.get("income_symbols", ()) diff --git a/tests/test_rebalance_service.py b/tests/test_rebalance_service.py index 5c6b46f..032d676 100644 --- a/tests/test_rebalance_service.py +++ b/tests/test_rebalance_service.py @@ -211,6 +211,101 @@ def test_safe_haven_target_below_cash_substitute_threshold_stays_cash(self): self.assertEqual(submitted_orders, []) self.assertEqual(result.allocation["targets"]["BOXX"], 0.0) + def test_small_account_whole_share_layer_sells_unbuyable_soxx_sleeve(self): + submitted_orders = [] + prices = {"SOXL": 191.15, "SOXX": 536.88, "BOXX": 100.0} + plan = _build_plan( + strategy_symbols=("SOXL", "SOXX", "BOXX"), + risk_symbols=("SOXL", "SOXX"), + safe_haven_symbols=("BOXX",), + targets={"SOXL": 541.58, "SOXX": 154.74, "BOXX": 77.37}, + market_values={"SOXL": 0.0, "SOXX": 536.88, "BOXX": 0.0}, + sellable_quantities={"SOXL": 0, "SOXX": 1, "BOXX": 0}, + quantities={"SOXL": 0, "SOXX": 1, "BOXX": 0}, + current_min_trade=7.74, + trade_threshold_value=7.74, + investable_cash=213.60, + market_status="Risk on", + deploy_ratio_text="90.0%", + income_ratio_text="0.0%", + income_locked_ratio_text="0.0%", + signal_message="SOXX above gate", + available_cash=236.81, + total_strategy_equity=773.69, + portfolio_rows=(("SOXL", "SOXX"), ("BOXX",)), + ) + refreshed_plan = _build_plan( + strategy_symbols=("SOXL", "SOXX", "BOXX"), + risk_symbols=("SOXL", "SOXX"), + safe_haven_symbols=("BOXX",), + targets={"SOXL": 541.58, "SOXX": 154.74, "BOXX": 77.37}, + market_values={"SOXL": 0.0, "SOXX": 0.0, "BOXX": 0.0}, + sellable_quantities={"SOXL": 0, "SOXX": 0, "BOXX": 0}, + quantities={"SOXL": 0, "SOXX": 0, "BOXX": 0}, + current_min_trade=7.74, + trade_threshold_value=7.74, + investable_cash=750.48, + market_status="Risk on", + deploy_ratio_text="90.0%", + income_ratio_text="0.0%", + income_locked_ratio_text="0.0%", + signal_message="SOXX above gate", + available_cash=773.69, + total_strategy_equity=773.69, + portfolio_rows=(("SOXL", "SOXX"), ("BOXX",)), + ) + + result = execute_rebalance_cycle( + trade_context=object(), + plan=plan, + portfolio=plan["portfolio"], + execution=plan["execution"], + allocation=plan["allocation"], + fetch_replanned_state=lambda: ( + refreshed_plan, + refreshed_plan["portfolio"], + refreshed_plan["execution"], + refreshed_plan["allocation"], + ), + market_data_port=CallableMarketDataPort( + quote_loader=lambda symbol: QuoteSnapshot( + symbol=symbol, + as_of="2026-05-22", + last_price=prices[str(symbol).replace(".US", "")], + ) + ), + estimate_max_purchase_quantity=lambda *_args, **_kwargs: 10, + execution_port=CallableExecutionPort( + lambda order_intent: ( + submitted_orders.append(order_intent), + ExecutionReport( + symbol=order_intent.symbol, + side=order_intent.side, + quantity=order_intent.quantity, + status="accepted", + broker_order_id=f"order-{len(submitted_orders)}", + ), + )[-1] + ), + notify_issue=lambda _title, _detail: None, + translator=build_translator("zh"), + with_prefix=lambda message: message, + limit_sell_discount=1.0, + limit_buy_premium=1.0, + safe_haven_cash_substitute_threshold_usd=1000.0, + ) + + self.assertTrue(result.action_done) + self.assertEqual(result.allocation["targets"]["SOXX"], 0.0) + self.assertEqual( + result.allocation["small_account_whole_share_substituted_symbols"], + ("SOXX",), + ) + self.assertEqual([(order.symbol, order.side, order.quantity) for order in submitted_orders], [ + ("SOXX.US", "sell", 1), + ("SOXL.US", "buy", 2), + ]) + def test_run_strategy_prefers_portfolio_port_runtime_path(self): sent_messages = [] observed = {} @@ -629,13 +724,12 @@ def test_strategy_target_rebuys_cash_sweep_symbol_after_buy_skip(self): self.assertEqual(len(sent_messages), 1) self.assertIn("🔔 【调仓指令】", sent_messages[0]) - self.assertIn("SOXX.US 目标差额 $163.14", sent_messages[0]) - self.assertIn("SOXX.US 目标差额 $163.14 未超过 1 股价格 $504.60", sent_messages[0]) + self.assertNotIn("SOXX.US 目标差额 $163.14", sent_messages[0]) self.assertNotIn("可投资现金 $1191.03 不足买入 1 股", sent_messages[0]) self.assertNotIn("市价卖出] BOXX", sent_messages[0]) self.assertNotIn("市价买入] SOXX", sent_messages[0]) self.assertIn("市价买入] BOXX: 10股", sent_messages[0]) - self.assertIn("买入说明", sent_messages[0]) + self.assertIn("BOXX.US 目标差额 $524.92", sent_messages[0]) self.assertNotIn("限价买入] SOXX", sent_messages[0]) def test_target_gap_below_one_share_does_not_report_cash_shortage(self):