Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
62 changes: 42 additions & 20 deletions application/rebalance_service.py
Original file line number Diff line number Diff line change
Expand Up @@ -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())

Expand Down Expand Up @@ -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)

Expand All @@ -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 = []
Expand Down Expand Up @@ -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", ())
Expand Down
3 changes: 3 additions & 0 deletions main.py
Original file line number Diff line number Diff line change
Expand Up @@ -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(
Expand Down
104 changes: 103 additions & 1 deletion tests/test_rebalance_service.py
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand All @@ -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 [{}, {}])
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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"),
Expand Down