diff --git a/application/execution_service.py b/application/execution_service.py index 67e7f38..06603b3 100644 --- a/application/execution_service.py +++ b/application/execution_service.py @@ -31,9 +31,7 @@ def estimate_cash_sweep_sale_quantity_to_fund_buy( quote_price = float(ask_price or 0.0) if needed_value <= 0.0 or quote_price <= 0.0: continue - max_buy_quantity = int(needed_value // quote_price) - if max_buy_quantity <= 0: - continue + max_buy_quantity = max(1, int(needed_value // quote_price)) required_buying_power = max_buy_quantity * quote_price if current_buying_power >= required_buying_power: return 0 @@ -529,6 +527,53 @@ def cash_sweep_sale_quantity_to_fund_buy(max_quantity, candidate_symbols): sell_submitted = True cash_sweep_sold_this_cycle = True + if ( + not sell_submitted + and funding_buy_candidates + and cash_sweep_symbol + and sellable_quantities.get(cash_sweep_symbol, 0.0) > 0.0 + ): + sweep_quantity = cash_sweep_sale_quantity_to_fund_buy( + int(sellable_quantities[cash_sweep_symbol]), + funding_buy_candidates, + ) + if sweep_quantity <= 0: + sweep_quantity = 1 + sweep_price = safe_quote_last_price( + f"{cash_sweep_symbol}.US", + market_data_port=market_data_port, + notify_issue=notify_issue, + ) + if sweep_price is not None and sweep_price > 0.0: + quantity_text = format_quantity(sweep_quantity) + if dry_run_only: + submitted = record_dry_run( + f"{cash_sweep_symbol}.US", + "sell", + quantity_text, + round(sweep_price, 2), + order_type="market", + ) + if submitted: + dry_run_sale_proceeds += float(sweep_quantity) * round(sweep_price, 2) + else: + submitted = submit_order_via_port( + f"{cash_sweep_symbol}.US", + "market", + "sell", + sweep_quantity, + translator( + "market_sell", + symbol=cash_sweep_symbol, + qty=quantity_text, + price=round(sweep_price, 2), + ), + ) + if submitted: + action_done = True + sell_submitted = True + cash_sweep_sold_this_cycle = True + if sell_submitted: if dry_run_only and dry_run_sale_proceeds > 0.0: simulated_cash = float(dry_run_sale_proceeds) diff --git a/requirements.txt b/requirements.txt index 67c1c0d..56bd26b 100644 --- a/requirements.txt +++ b/requirements.txt @@ -1,7 +1,7 @@ flask gunicorn -quant-platform-kit @ git+https://github.com/QuantStrategyLab/QuantPlatformKit.git@8769362096227320bc05c791b5244d4b3e88db50 -us-equity-strategies @ git+https://github.com/QuantStrategyLab/UsEquityStrategies.git@ed55a6af0245323dbed82060e89be96d8f77f756 +quant-platform-kit @ git+https://github.com/QuantStrategyLab/QuantPlatformKit.git@663e80be60b0da80e81513b711c579d221a2111d +us-equity-strategies @ git+https://github.com/QuantStrategyLab/UsEquityStrategies.git@3c8262d1df7d11e47e5ffbff83784544d10f4b9b pandas requests pytz diff --git a/tests/test_rebalance_service.py b/tests/test_rebalance_service.py index f391183..0c9768a 100644 --- a/tests/test_rebalance_service.py +++ b/tests/test_rebalance_service.py @@ -577,7 +577,8 @@ def test_fractional_strategy_target_is_skipped_by_whole_share_execution_layer(se self.assertIn("🔔 【调仓指令】", sent_messages[0]) self.assertIn("SOXX.US 目标差额 $163.14", sent_messages[0]) self.assertIn("不足买入 1 股", sent_messages[0]) - self.assertIn("尾部回补", sent_messages[0]) + self.assertIn("市价卖出] BOXX", sent_messages[0]) + self.assertIn("买入说明", sent_messages[0]) self.assertNotIn("限价买入] SOXX", sent_messages[0]) def test_fractional_strategy_target_buy_floors_to_cash_backed_whole_shares(self): @@ -1071,6 +1072,64 @@ def test_cash_sweep_symbol_can_fund_buy_when_investable_cash_is_positive_but_sho self.assertIn("限价买入", sent_messages[0]) self.assertIn("SOXL", sent_messages[0]) + def test_cash_sweep_symbol_sells_even_when_underweight_is_below_one_share(self): + initial_plan = _build_plan( + strategy_symbols=("SOXL", "SOXX", "BOXX"), + risk_symbols=("SOXL", "SOXX"), + safe_haven_symbols=("BOXX",), + targets={"SOXL": 167.79, "SOXX": 0.0, "BOXX": 1000.0}, + market_values={"SOXL": 0.0, "SOXX": 0.0, "BOXX": 1000.0}, + sellable_quantities={"SOXL": 0, "SOXX": 0, "BOXX": 1}, + quantities={"SOXL": 0, "SOXX": 0, "BOXX": 1}, + current_min_trade=100.0, + trade_threshold_value=100.0, + investable_cash=14.46, + market_status="🧯 过热降档(SOXX)", + deploy_ratio_text="15.0%", + income_ratio_text="0.0%", + income_locked_ratio_text="0.0%", + signal_message="SOXX 仍在 140 日门槛线上方,但触发过热降档,目标仓位 SOXL 15.0%", + available_cash=14.46, + total_strategy_equity=1000.0, + portfolio_rows=(("SOXL", "SOXX"), ("BOXX",)), + ) + refreshed_plan = _build_plan( + strategy_symbols=("SOXL", "SOXX", "BOXX"), + risk_symbols=("SOXL", "SOXX"), + safe_haven_symbols=("BOXX",), + targets={"SOXL": 167.79, "SOXX": 0.0, "BOXX": 1000.0}, + market_values={"SOXL": 0.0, "SOXX": 0.0, "BOXX": 900.0}, + sellable_quantities={"SOXL": 0, "SOXX": 0, "BOXX": 0}, + quantities={"SOXL": 0, "SOXX": 0, "BOXX": 0}, + current_min_trade=100.0, + trade_threshold_value=100.0, + investable_cash=114.46, + market_status="🧯 过热降档(SOXX)", + deploy_ratio_text="15.0%", + income_ratio_text="0.0%", + income_locked_ratio_text="0.0%", + signal_message="SOXX 仍在 140 日门槛线上方,但触发过热降档,目标仓位 SOXL 15.0%", + available_cash=114.46, + total_strategy_equity=1000.0, + portfolio_rows=(("SOXL", "SOXX"), ("BOXX",)), + ) + before_sell_snapshot = _build_snapshot(initial_plan, phase="before_cash_sweep_small_gap") + after_sell_snapshot = _build_snapshot(refreshed_plan, phase="after_cash_sweep_small_gap") + sent_messages, observed_snapshots, observed_plan_inputs = self._run_strategy( + initial_plan, + refreshed_plan=refreshed_plan, + portfolio_snapshots=[before_sell_snapshot, after_sell_snapshot], + prices={"SOXL.US": 167.79, "SOXX.US": 200.0, "BOXX.US": 100.0}, + estimate_max_purchase_quantity_value=10, + ) + + self.assertEqual(observed_snapshots, [before_sell_snapshot, after_sell_snapshot]) + self.assertEqual(len(observed_plan_inputs), 2) + self.assertEqual(len(sent_messages), 1) + self.assertIn("BOXX", sent_messages[0]) + self.assertIn("市价卖出", sent_messages[0]) + self.assertNotIn("买入跳过", sent_messages[0]) + def test_dry_run_cash_sweep_can_simulate_buy_after_sell_settlement(self): initial_plan = _build_plan( strategy_symbols=("SOXL", "SOXX", "BOXX"),