diff --git a/application/rebalance_service.py b/application/rebalance_service.py index cc6bebe..64472a8 100644 --- a/application/rebalance_service.py +++ b/application/rebalance_service.py @@ -58,6 +58,10 @@ def _plan_allocation(plan): return dict(plan.get("allocation") or {}) +def _noop_sleep(_seconds): + return None + + def _has_text(value): return bool(str(value or "").strip()) @@ -167,6 +171,9 @@ def run_strategy( submit_order_with_alert, dry_run_only=False, strategy_display_name="", + post_sell_refresh_attempts=1, + post_sell_refresh_interval_sec=0.0, + sleeper=_noop_sleep, ): print(with_prefix(f"[{datetime.now()}] Starting strategy..."), flush=True) @@ -186,16 +193,23 @@ def run_strategy( if indicators is None: raise Exception("Quote data missing or API limited; cannot compute indicators") - account_state = fetch_strategy_account_state(quote_context, trade_context) - plan = resolve_rebalance_plan( - indicators=indicators, - account_state=account_state, - ) - portfolio = _plan_portfolio(plan) - execution = _plan_execution(plan) - allocation = _plan_allocation(plan) - if allocation.get("target_mode") != "value": - raise ValueError("LongBridgePlatform requires allocation.target_mode=value") + def load_plan(current_account_state): + current_plan = resolve_rebalance_plan( + indicators=indicators, + account_state=current_account_state, + ) + current_portfolio = _plan_portfolio(current_plan) + current_execution = _plan_execution(current_plan) + current_allocation = _plan_allocation(current_plan) + if current_allocation.get("target_mode") != "value": + raise ValueError("LongBridgePlatform requires allocation.target_mode=value") + return current_plan, current_portfolio, current_execution, current_allocation + + def fetch_replanned_state(): + current_account_state = fetch_strategy_account_state(quote_context, trade_context) + return load_plan(current_account_state) + + plan, portfolio, execution, allocation = fetch_replanned_state() logs = [] skip_logs = [] @@ -307,16 +321,24 @@ def record_dry_run(symbol, side, quantity, price, *, order_type): ) if sell_submitted: - account_state = fetch_strategy_account_state(quote_context, trade_context) - plan = resolve_rebalance_plan( - indicators=indicators, - account_state=account_state, - ) - portfolio = _plan_portfolio(plan) - execution = _plan_execution(plan) - allocation = _plan_allocation(plan) - if allocation.get("target_mode") != "value": - raise ValueError("LongBridgePlatform requires allocation.target_mode=value") + previous_investable_cash = investable_cash + refresh_attempts = max(1, int(post_sell_refresh_attempts or 1)) + refresh_interval = max(0.0, float(post_sell_refresh_interval_sec or 0.0)) + best_refreshed_state = None + best_investable_cash = previous_investable_cash + for attempt in range(refresh_attempts): + if attempt > 0: + sleeper(refresh_interval) + refreshed_state = fetch_replanned_state() + refreshed_execution = refreshed_state[2] + refreshed_investable_cash = float(refreshed_execution["investable_cash"]) + if best_refreshed_state is None or refreshed_investable_cash > best_investable_cash: + best_refreshed_state = refreshed_state + best_investable_cash = refreshed_investable_cash + if refreshed_investable_cash > previous_investable_cash: + best_refreshed_state = refreshed_state + break + plan, portfolio, execution, allocation = best_refreshed_state threshold_value = float(execution["trade_threshold_value"]) limit_order_symbols = set( allocation.get("risk_symbols", ()) + allocation.get("income_symbols", ()) diff --git a/main.py b/main.py index ff0b1a0..628caca 100644 --- a/main.py +++ b/main.py @@ -419,6 +419,9 @@ def run_strategy(): submit_order_with_alert=submit_order_with_alert, dry_run_only=RUNTIME_SETTINGS.dry_run_only, strategy_display_name=strategy_display_name, + post_sell_refresh_attempts=ORDER_POLL_MAX_ATTEMPTS, + post_sell_refresh_interval_sec=ORDER_POLL_INTERVAL_SEC, + sleeper=time.sleep, ) finalize_runtime_report(report, status="ok") log_runtime_event( diff --git a/tests/test_rebalance_service.py b/tests/test_rebalance_service.py index 3e1bd43..dad9b25 100644 --- a/tests/test_rebalance_service.py +++ b/tests/test_rebalance_service.py @@ -141,9 +141,11 @@ def _run_strategy( estimate_max_purchase_quantity_value=0, dry_run_only=False, strategy_display_name="SOXL/SOXX 半导体趋势收益", + post_sell_refresh_attempts=1, ): sent_messages = [] observed_account_states = [] + observed_sleeps = [] def fake_send_tg_message(message): sent_messages.append(message) @@ -166,7 +168,10 @@ def fake_submit_order_with_alert( logs.append(f"{log_message} (订单号: test-order)") return True - plan_side_effect = [plan, refreshed_plan or plan] + if isinstance(refreshed_plan, (list, tuple)): + plan_side_effect = [plan, *refreshed_plan] + else: + plan_side_effect = [plan, refreshed_plan or plan] observed_plan_inputs = [] account_state_values = list(account_states or [{}, {}]) @@ -207,6 +212,9 @@ def fake_resolve_rebalance_plan(*, indicators, account_state): submit_order_with_alert=fake_submit_order_with_alert, dry_run_only=dry_run_only, strategy_display_name=strategy_display_name, + post_sell_refresh_attempts=post_sell_refresh_attempts, + post_sell_refresh_interval_sec=0.0, + sleeper=observed_sleeps.append, ) return sent_messages, observed_account_states, observed_plan_inputs @@ -395,6 +403,100 @@ def test_refreshes_account_state_after_sell_and_can_place_followup_buy(self): self.assertNotIn("买入跳过", sent_messages[0]) self.assertEqual(len(observed_plan_inputs), 2) + def test_retries_account_refresh_after_sell_until_buying_power_updates(self): + initial_plan = _build_plan( + strategy_profile="tqqq_growth_income", + strategy_symbols=("TQQQ", "BOXX"), + risk_symbols=("TQQQ",), + safe_haven_symbols=("BOXX",), + targets={"TQQQ": 900.0, "BOXX": 100.0}, + market_values={"TQQQ": 0.0, "BOXX": 1000.0}, + sellable_quantities={"TQQQ": 0, "BOXX": 10}, + quantities={"TQQQ": 0, "BOXX": 10}, + current_min_trade=10.0, + trade_threshold_value=10.0, + investable_cash=101.95, + market_status="", + deploy_ratio_text="", + income_ratio_text="", + income_locked_ratio_text="", + signal_message="🚀 入场信号", + available_cash=101.95, + total_strategy_equity=1200.0, + portfolio_rows=(("TQQQ", "BOXX"),), + ) + stale_refreshed_plan = _build_plan( + strategy_profile="tqqq_growth_income", + strategy_symbols=("TQQQ", "BOXX"), + risk_symbols=("TQQQ",), + safe_haven_symbols=("BOXX",), + targets={"TQQQ": 900.0, "BOXX": 100.0}, + market_values={"TQQQ": 0.0, "BOXX": 1000.0}, + sellable_quantities={"TQQQ": 0, "BOXX": 10}, + quantities={"TQQQ": 0, "BOXX": 10}, + current_min_trade=10.0, + trade_threshold_value=10.0, + investable_cash=101.95, + market_status="", + deploy_ratio_text="", + income_ratio_text="", + income_locked_ratio_text="", + signal_message="🚀 入场信号", + available_cash=101.95, + total_strategy_equity=1200.0, + portfolio_rows=(("TQQQ", "BOXX"),), + ) + settled_refreshed_plan = _build_plan( + strategy_profile="tqqq_growth_income", + strategy_symbols=("TQQQ", "BOXX"), + risk_symbols=("TQQQ",), + safe_haven_symbols=("BOXX",), + targets={"TQQQ": 900.0, "BOXX": 100.0}, + market_values={"TQQQ": 0.0, "BOXX": 100.0}, + sellable_quantities={"TQQQ": 0, "BOXX": 1}, + quantities={"TQQQ": 0, "BOXX": 1}, + current_min_trade=10.0, + trade_threshold_value=10.0, + investable_cash=1001.95, + market_status="", + deploy_ratio_text="", + income_ratio_text="", + income_locked_ratio_text="", + signal_message="🚀 入场信号", + available_cash=1001.95, + total_strategy_equity=1200.0, + portfolio_rows=(("TQQQ", "BOXX"),), + ) + + sent_messages, observed_account_states, observed_plan_inputs = self._run_strategy( + initial_plan, + refreshed_plan=[stale_refreshed_plan, settled_refreshed_plan], + account_states=[ + {"phase": "before_sell"}, + {"phase": "stale_after_sell"}, + {"phase": "settled_after_sell"}, + ], + prices={"TQQQ.US": 50.0, "BOXX.US": 100.0}, + estimate_max_purchase_quantity_value=200, + strategy_display_name="TQQQ 增长收益", + post_sell_refresh_attempts=2, + ) + + self.assertEqual( + observed_account_states, + [ + {"phase": "before_sell"}, + {"phase": "stale_after_sell"}, + {"phase": "settled_after_sell"}, + ], + ) + self.assertEqual(len(observed_plan_inputs), 3) + self.assertEqual(len(sent_messages), 1) + self.assertIn("市价卖出", sent_messages[0]) + self.assertIn("限价买入", sent_messages[0]) + self.assertIn("TQQQ", sent_messages[0]) + self.assertNotIn("买入说明", sent_messages[0]) + def test_dry_run_replaces_real_order_submission_with_summary_lines(self): initial_plan = _build_plan( strategy_symbols=("SOXL", "SOXX"),