From b9a46fa3c462f99cc919d7ad4fbaf347d2408633 Mon Sep 17 00:00:00 2001 From: Pigbibi <20649888+Pigbibi@users.noreply.github.com> Date: Tue, 7 Apr 2026 02:39:22 +0800 Subject: [PATCH 1/4] refactor: switch longbridge to unified strategy runtime --- application/rebalance_service.py | 92 ++------------------------- decision_mapper.py | 82 ++++++++++++++++++++++++ main.py | 83 ++++++++---------------- strategy/allocation.py | 17 ----- strategy_loader.py | 21 ++++-- strategy_runtime.py | 63 ++++++++++++++++++ tests/test_decision_mapper.py | 53 +++++++++++++++ tests/test_rebalance_service.py | 88 ++++++++++++------------- tests/test_request_handling.py | 57 +++++++++++++++++ tests/test_shared_chat_id_fallback.py | 57 +++++++++++++++++ tests/test_strategy_loader.py | 21 +++--- tests/test_strategy_runtime.py | 55 ++++++++++++++++ 12 files changed, 470 insertions(+), 219 deletions(-) create mode 100644 decision_mapper.py delete mode 100644 strategy/allocation.py create mode 100644 strategy_runtime.py create mode 100644 tests/test_decision_mapper.py create mode 100644 tests/test_strategy_runtime.py diff --git a/application/rebalance_service.py b/application/rebalance_service.py index c44b0db..0089dab 100644 --- a/application/rebalance_service.py +++ b/application/rebalance_service.py @@ -6,8 +6,6 @@ import traceback from datetime import datetime -from strategy.allocation import build_rebalance_plan - def record_skip_log(skip_logs, *, translator, with_prefix, kind, detail): message = translator(kind, detail=detail) @@ -15,65 +13,13 @@ def record_skip_log(skip_logs, *, translator, with_prefix, kind, detail): print(with_prefix(message), flush=True) -def resolve_rebalance_plan( - *, - indicators, - account_state, - trend_ma_window, - translator, - cash_reserve_ratio, - min_trade_ratio, - min_trade_floor, - rebalance_threshold_ratio, - small_account_deploy_ratio, - mid_account_deploy_ratio, - large_account_deploy_ratio, - trade_layer_decay_coeff, - income_layer_start_usd, - income_layer_max_ratio, - income_layer_qqqi_weight, - income_layer_spyi_weight, -): - return build_rebalance_plan( - indicators, - account_state, - trend_ma_window=trend_ma_window, - translator=translator, - cash_reserve_ratio=cash_reserve_ratio, - min_trade_ratio=min_trade_ratio, - min_trade_floor=min_trade_floor, - rebalance_threshold_ratio=rebalance_threshold_ratio, - small_account_deploy_ratio=small_account_deploy_ratio, - mid_account_deploy_ratio=mid_account_deploy_ratio, - large_account_deploy_ratio=large_account_deploy_ratio, - trade_layer_decay_coeff=trade_layer_decay_coeff, - income_layer_start_usd=income_layer_start_usd, - income_layer_max_ratio=income_layer_max_ratio, - income_layer_qqqi_weight=income_layer_qqqi_weight, - income_layer_spyi_weight=income_layer_spyi_weight, - ) - - def run_strategy( *, project_id, secret_name, - trend_ma_window, token_refresh_threshold_days, - cash_reserve_ratio, - min_trade_ratio, - min_trade_floor, - rebalance_threshold_ratio, limit_sell_discount, limit_buy_premium, - small_account_deploy_ratio, - mid_account_deploy_ratio, - large_account_deploy_ratio, - trade_layer_decay_coeff, - income_layer_start_usd, - income_layer_max_ratio, - income_layer_qqqi_weight, - income_layer_spyi_weight, separator, translator, with_prefix, @@ -82,8 +28,9 @@ def run_strategy( fetch_token_from_secret, refresh_token_if_needed, build_contexts, - calculate_rotation_indicators, + calculate_strategy_indicators, fetch_strategy_account_state, + resolve_rebalance_plan, fetch_last_price, estimate_max_purchase_quantity, submit_order_with_alert, @@ -102,29 +49,14 @@ def run_strategy( app_secret = os.getenv("LONGPORT_APP_SECRET", "") quote_context, trade_context = build_contexts(app_key, app_secret, token) - indicators = calculate_rotation_indicators(quote_context, trend_window=trend_ma_window) + indicators = calculate_strategy_indicators(quote_context) if indicators is None: raise Exception("Quote data missing or API limited; cannot compute indicators") - strategy_assets = ["SOXL", "SOXX", "BOXX", "QQQI", "SPYI"] - account_state = fetch_strategy_account_state(quote_context, trade_context, strategy_assets) + account_state = fetch_strategy_account_state(quote_context, trade_context) plan = resolve_rebalance_plan( indicators=indicators, account_state=account_state, - trend_ma_window=trend_ma_window, - translator=translator, - cash_reserve_ratio=cash_reserve_ratio, - min_trade_ratio=min_trade_ratio, - min_trade_floor=min_trade_floor, - rebalance_threshold_ratio=rebalance_threshold_ratio, - small_account_deploy_ratio=small_account_deploy_ratio, - mid_account_deploy_ratio=mid_account_deploy_ratio, - large_account_deploy_ratio=large_account_deploy_ratio, - trade_layer_decay_coeff=trade_layer_decay_coeff, - income_layer_start_usd=income_layer_start_usd, - income_layer_max_ratio=income_layer_max_ratio, - income_layer_qqqi_weight=income_layer_qqqi_weight, - income_layer_spyi_weight=income_layer_spyi_weight, ) logs = [] @@ -190,24 +122,10 @@ def run_strategy( ) if sell_submitted: - account_state = fetch_strategy_account_state(quote_context, trade_context, strategy_assets) + account_state = fetch_strategy_account_state(quote_context, trade_context) plan = resolve_rebalance_plan( indicators=indicators, account_state=account_state, - trend_ma_window=trend_ma_window, - translator=translator, - cash_reserve_ratio=cash_reserve_ratio, - min_trade_ratio=min_trade_ratio, - min_trade_floor=min_trade_floor, - rebalance_threshold_ratio=rebalance_threshold_ratio, - small_account_deploy_ratio=small_account_deploy_ratio, - mid_account_deploy_ratio=mid_account_deploy_ratio, - large_account_deploy_ratio=large_account_deploy_ratio, - trade_layer_decay_coeff=trade_layer_decay_coeff, - income_layer_start_usd=income_layer_start_usd, - income_layer_max_ratio=income_layer_max_ratio, - income_layer_qqqi_weight=income_layer_qqqi_weight, - income_layer_spyi_weight=income_layer_spyi_weight, ) threshold_value = plan["threshold_value"] limit_order_symbols = set(plan["limit_order_symbols"]) diff --git a/decision_mapper.py b/decision_mapper.py new file mode 100644 index 0000000..88cd665 --- /dev/null +++ b/decision_mapper.py @@ -0,0 +1,82 @@ +from __future__ import annotations + +from collections.abc import Mapping +from typing import Any + +from quant_platform_kit.strategy_contracts import StrategyDecision + + +def _target_values(decision: StrategyDecision) -> dict[str, float]: + target_values: dict[str, float] = {} + for position in decision.positions: + if position.target_value is None: + raise ValueError( + "LongBridge decision mapper requires target_value positions; " + f"position {position.symbol!r} is missing target_value" + ) + target_values[position.symbol] = float(position.target_value) + return target_values + + +def _symbols_by_role(decision: StrategyDecision) -> tuple[list[str], list[str], list[str]]: + risk_symbols: list[str] = [] + income_symbols: list[str] = [] + safe_haven_symbols: list[str] = [] + target_values = _target_values(decision) + for position in decision.positions: + if position.role == "safe_haven": + safe_haven_symbols.append(position.symbol) + elif position.role == "income": + income_symbols.append(position.symbol) + else: + risk_symbols.append(position.symbol) + risk_symbols = sorted(dict.fromkeys(risk_symbols)) + income_symbols = sorted( + dict.fromkeys(income_symbols), + key=lambda symbol: (-target_values.get(symbol, 0.0), symbol), + ) + safe_haven_symbols = sorted(dict.fromkeys(safe_haven_symbols)) + return risk_symbols, income_symbols, safe_haven_symbols + + +def map_strategy_decision_to_plan( + decision: StrategyDecision, + *, + account_state: Mapping[str, Any], + strategy_profile: str, +) -> dict[str, Any]: + diagnostics = dict(decision.diagnostics) + target_values = _target_values(decision) + risk_symbols, income_symbols, safe_haven_symbols = _symbols_by_role(decision) + strategy_assets = tuple(risk_symbols + safe_haven_symbols + income_symbols) + portfolio_rows = tuple( + row + for row in ( + tuple(risk_symbols), + tuple(income_symbols), + tuple(safe_haven_symbols), + ) + if row + ) + + return { + "strategy_profile": strategy_profile, + "strategy_assets": strategy_assets, + "limit_order_symbols": tuple(risk_symbols + income_symbols), + "portfolio_rows": portfolio_rows, + "available_cash": float(account_state["available_cash"]), + "market_values": dict(account_state["market_values"]), + "quantities": dict(account_state["quantities"]), + "sellable_quantities": dict(account_state["sellable_quantities"]), + "total_strategy_equity": float(account_state["total_strategy_equity"]), + "current_min_trade": float(diagnostics["current_min_trade"]), + "targets": target_values, + "market_status": diagnostics["market_status"], + "signal_message": diagnostics["signal_message"], + "deploy_ratio_text": diagnostics["deploy_ratio_text"], + "income_ratio_text": diagnostics["income_ratio_text"], + "income_locked_ratio_text": diagnostics["income_locked_ratio_text"], + "active_risk_asset": diagnostics.get("active_risk_asset"), + "investable_cash": float(diagnostics["investable_cash"]), + "threshold_value": float(diagnostics["threshold_value"]), + } diff --git a/main.py b/main.py index e42452a..0507b70 100644 --- a/main.py +++ b/main.py @@ -14,6 +14,7 @@ from application.rebalance_service import run_strategy as run_rebalance_cycle from entrypoints.cloud_run import is_market_open_now from runtime_config_support import load_platform_runtime_settings +from decision_mapper import map_strategy_decision_to_plan from notifications.order_alerts import ( is_filled_status as notifications_is_filled_status, is_partial_filled_status as notifications_is_partial_filled_status, @@ -39,10 +40,7 @@ refresh_token_if_needed, submit_order, ) -from strategy.allocation import ( - get_dynamic_allocation as strategy_get_dynamic_allocation, - get_income_layer_ratio as strategy_get_income_layer_ratio, -) +from strategy_runtime import load_strategy_runtime app = Flask(__name__) @@ -66,12 +64,9 @@ def get_project_id(): NOTIFY_LANG = RUNTIME_SETTINGS.notify_lang TG_TOKEN = RUNTIME_SETTINGS.tg_token TG_CHAT_ID = RUNTIME_SETTINGS.tg_chat_id - -# Execution: reserve ratio, minimum trade size (ratio of equity and absolute floor) -CASH_RESERVE_RATIO = 0.03 -MIN_TRADE_RATIO = 0.01 -MIN_TRADE_FLOOR = 100.0 -REBALANCE_THRESHOLD_RATIO = 0.01 # 1% of equity to trigger rebalance +STRATEGY_RUNTIME = load_strategy_runtime(STRATEGY_PROFILE) +STRATEGY_RUNTIME_CONFIG = dict(STRATEGY_RUNTIME.merged_runtime_config) +MANAGED_SYMBOLS = STRATEGY_RUNTIME.managed_symbols # Order pricing: limit order discount/premium relative to last price LIMIT_SELL_DISCOUNT = 0.995 # sell limit at 0.5% below last @@ -84,19 +79,6 @@ def get_project_id(): # Token refresh: days before expiry to trigger refresh TOKEN_REFRESH_THRESHOLD_DAYS = 30 -# Trading layer: SOXL 150d MA for trend; deploy ratio by account size, log decay above 180k -TREND_MA_WINDOW = 150 -SMALL_ACCOUNT_DEPLOY_RATIO = 0.60 -MID_ACCOUNT_DEPLOY_RATIO = 0.57 -LARGE_ACCOUNT_DEPLOY_RATIO = 0.50 -TRADE_LAYER_DECAY_COEFF = 0.04 - -# Income layer: starts at INCOME_LAYER_START_USD, caps at INCOME_LAYER_MAX_RATIO; QQQI/SPYI weights -INCOME_LAYER_START_USD = 150000.0 -INCOME_LAYER_MAX_RATIO = 0.15 -INCOME_LAYER_QQQI_WEIGHT = 0.70 -INCOME_LAYER_SPYI_WEIGHT = 0.30 - SEPARATOR = "━━━━━━━━━━━━━━━━━━" def t(key, **kwargs): @@ -200,27 +182,30 @@ def submit_order_with_alert(t_ctx, symbol, order_type, side, quantity, logs, log ) # --------------------------------------------------------------------------- -# Allocation: fraction of core equity to deploy to SOXL/SOXX (rest in BOXX) +# Strategy: NYSE hours check, indicators, balance/positions, target allocation, sell then buy # --------------------------------------------------------------------------- -def get_dynamic_allocation(total_equity_usd): - return strategy_get_dynamic_allocation( - total_equity_usd, - small_account_deploy_ratio=SMALL_ACCOUNT_DEPLOY_RATIO, - mid_account_deploy_ratio=MID_ACCOUNT_DEPLOY_RATIO, - large_account_deploy_ratio=LARGE_ACCOUNT_DEPLOY_RATIO, - trade_layer_decay_coeff=TRADE_LAYER_DECAY_COEFF, - ) +def calculate_strategy_indicators(quote_context): + trend_ma_window = int(STRATEGY_RUNTIME_CONFIG.get("trend_ma_window", 150)) + return calculate_rotation_indicators(quote_context, trend_window=trend_ma_window) + + +def fetch_managed_account_state(quote_context, trade_context): + return fetch_strategy_account_state(quote_context, trade_context, list(MANAGED_SYMBOLS)) -def get_income_layer_ratio(total_equity_usd): - return strategy_get_income_layer_ratio( - total_equity_usd, - income_layer_start_usd=INCOME_LAYER_START_USD, - income_layer_max_ratio=INCOME_LAYER_MAX_RATIO, + +def resolve_rebalance_plan(*, indicators, account_state): + evaluation = STRATEGY_RUNTIME.evaluate( + indicators=indicators, + account_state=account_state, + translator=t, + ) + return map_strategy_decision_to_plan( + evaluation.decision, + account_state=account_state, + strategy_profile=STRATEGY_PROFILE, ) -# --------------------------------------------------------------------------- -# Strategy: NYSE hours check, indicators, balance/positions, target allocation, sell then buy -# --------------------------------------------------------------------------- + def run_strategy(): try: print(with_prefix(f"[{datetime.now()}] Starting strategy..."), flush=True) @@ -235,22 +220,9 @@ def run_strategy(): run_rebalance_cycle( project_id=PROJECT_ID, secret_name=SECRET_NAME, - trend_ma_window=TREND_MA_WINDOW, token_refresh_threshold_days=TOKEN_REFRESH_THRESHOLD_DAYS, - cash_reserve_ratio=CASH_RESERVE_RATIO, - min_trade_ratio=MIN_TRADE_RATIO, - min_trade_floor=MIN_TRADE_FLOOR, - rebalance_threshold_ratio=REBALANCE_THRESHOLD_RATIO, limit_sell_discount=LIMIT_SELL_DISCOUNT, limit_buy_premium=LIMIT_BUY_PREMIUM, - small_account_deploy_ratio=SMALL_ACCOUNT_DEPLOY_RATIO, - mid_account_deploy_ratio=MID_ACCOUNT_DEPLOY_RATIO, - large_account_deploy_ratio=LARGE_ACCOUNT_DEPLOY_RATIO, - trade_layer_decay_coeff=TRADE_LAYER_DECAY_COEFF, - income_layer_start_usd=INCOME_LAYER_START_USD, - income_layer_max_ratio=INCOME_LAYER_MAX_RATIO, - income_layer_qqqi_weight=INCOME_LAYER_QQQI_WEIGHT, - income_layer_spyi_weight=INCOME_LAYER_SPYI_WEIGHT, separator=SEPARATOR, translator=t, with_prefix=with_prefix, @@ -259,8 +231,9 @@ def run_strategy(): fetch_token_from_secret=fetch_token_from_secret, refresh_token_if_needed=refresh_token_if_needed, build_contexts=build_contexts, - calculate_rotation_indicators=calculate_rotation_indicators, - fetch_strategy_account_state=fetch_strategy_account_state, + calculate_strategy_indicators=calculate_strategy_indicators, + fetch_strategy_account_state=fetch_managed_account_state, + resolve_rebalance_plan=resolve_rebalance_plan, fetch_last_price=fetch_last_price, estimate_max_purchase_quantity=estimate_max_purchase_quantity, submit_order_with_alert=submit_order_with_alert, diff --git a/strategy/allocation.py b/strategy/allocation.py deleted file mode 100644 index 16937e3..0000000 --- a/strategy/allocation.py +++ /dev/null @@ -1,17 +0,0 @@ -"""Allocation and plan helpers for LongBridgePlatform.""" - -import os - -from strategy_loader import load_allocation_module - -_ALLOCATION_MODULE = load_allocation_module(os.getenv("STRATEGY_PROFILE")) - -build_rebalance_plan = _ALLOCATION_MODULE.build_rebalance_plan -get_dynamic_allocation = _ALLOCATION_MODULE.get_dynamic_allocation -get_income_layer_ratio = _ALLOCATION_MODULE.get_income_layer_ratio - -__all__ = [ - "build_rebalance_plan", - "get_dynamic_allocation", - "get_income_layer_ratio", -] diff --git a/strategy_loader.py b/strategy_loader.py index 6a603e0..ca5d479 100644 --- a/strategy_loader.py +++ b/strategy_loader.py @@ -1,18 +1,25 @@ from __future__ import annotations -from types import ModuleType - -from quant_platform_kit.common.strategies import load_strategy_component_module +from quant_platform_kit.common.strategies import ( + StrategyDefinition, + load_strategy_entrypoint, +) +from quant_platform_kit.strategy_contracts import StrategyEntrypoint from strategy_registry import LONGBRIDGE_PLATFORM, resolve_strategy_definition -def load_allocation_module(raw_profile: str | None) -> ModuleType: - definition = resolve_strategy_definition( +def load_strategy_definition(raw_profile: str | None) -> StrategyDefinition: + return resolve_strategy_definition( raw_profile, platform_id=LONGBRIDGE_PLATFORM, ) - return load_strategy_component_module( + + +def load_strategy_entrypoint_for_profile(raw_profile: str | None) -> StrategyEntrypoint: + definition = load_strategy_definition(raw_profile) + return load_strategy_entrypoint( definition, - component_name="allocation", + platform_id=LONGBRIDGE_PLATFORM, + available_inputs=("indicators", "account_state"), ) diff --git a/strategy_runtime.py b/strategy_runtime.py new file mode 100644 index 0000000..2642138 --- /dev/null +++ b/strategy_runtime.py @@ -0,0 +1,63 @@ +from __future__ import annotations + +from dataclasses import dataclass, field +from datetime import datetime, timezone +from typing import Any, Callable, Mapping + +from quant_platform_kit.strategy_contracts import StrategyContext, StrategyDecision, StrategyEntrypoint + +from strategy_loader import load_strategy_entrypoint_for_profile + + +@dataclass(frozen=True) +class StrategyEvaluationResult: + decision: StrategyDecision + metadata: Mapping[str, Any] = field(default_factory=dict) + + +@dataclass(frozen=True) +class LoadedStrategyRuntime: + entrypoint: StrategyEntrypoint + runtime_overrides: Mapping[str, Any] = field(default_factory=dict) + merged_runtime_config: Mapping[str, Any] = field(default_factory=dict) + + @property + def profile(self) -> str: + return self.entrypoint.manifest.profile + + @property + def managed_symbols(self) -> tuple[str, ...]: + configured = self.merged_runtime_config.get("managed_symbols", ()) + return tuple(str(symbol) for symbol in configured) + + def evaluate( + self, + *, + indicators, + account_state, + translator: Callable[[str], str], + ) -> StrategyEvaluationResult: + runtime_config = dict(self.runtime_overrides) + runtime_config.setdefault("translator", translator) + ctx = StrategyContext( + as_of=datetime.now(timezone.utc), + market_data={ + "indicators": indicators, + "account_state": account_state, + }, + runtime_config=runtime_config, + ) + decision = self.entrypoint.evaluate(ctx) + return StrategyEvaluationResult( + decision=decision, + metadata={"strategy_profile": self.profile}, + ) + + +def load_strategy_runtime(raw_profile: str | None) -> LoadedStrategyRuntime: + entrypoint = load_strategy_entrypoint_for_profile(raw_profile) + merged_runtime_config = dict(entrypoint.manifest.default_config) + return LoadedStrategyRuntime( + entrypoint=entrypoint, + merged_runtime_config=merged_runtime_config, + ) diff --git a/tests/test_decision_mapper.py b/tests/test_decision_mapper.py new file mode 100644 index 0000000..e7087ed --- /dev/null +++ b/tests/test_decision_mapper.py @@ -0,0 +1,53 @@ +import unittest + +from quant_platform_kit.strategy_contracts import PositionTarget, StrategyDecision + +from decision_mapper import map_strategy_decision_to_plan + + +class DecisionMapperTests(unittest.TestCase): + def test_maps_semiconductor_strategy_decision_to_execution_plan(self): + decision = StrategyDecision( + positions=( + PositionTarget(symbol="SOXL", target_value=30000.0), + PositionTarget(symbol="SOXX", target_value=0.0), + PositionTarget(symbol="BOXX", target_value=15000.0, role="safe_haven"), + PositionTarget(symbol="QQQI", target_value=3500.0, role="income"), + PositionTarget(symbol="SPYI", target_value=1500.0, role="income"), + ), + diagnostics={ + "market_status": "🚀 RISK-ON (SOXL)", + "signal_message": "signal", + "deploy_ratio_text": "60.0%", + "income_ratio_text": "10.0%", + "income_locked_ratio_text": "10.0%", + "active_risk_asset": "SOXL", + "investable_cash": 9000.0, + "threshold_value": 500.0, + "current_min_trade": 100.0, + "total_strategy_equity": 50000.0, + }, + ) + account_state = { + "available_cash": 10000.0, + "market_values": {"SOXL": 0.0, "SOXX": 0.0, "BOXX": 5000.0, "QQQI": 1000.0, "SPYI": 1000.0}, + "quantities": {"SOXL": 0, "SOXX": 0, "BOXX": 50, "QQQI": 10, "SPYI": 10}, + "sellable_quantities": {"SOXL": 0, "SOXX": 0, "BOXX": 50, "QQQI": 10, "SPYI": 10}, + "total_strategy_equity": 50000.0, + } + + plan = map_strategy_decision_to_plan( + decision, + account_state=account_state, + strategy_profile="semiconductor_rotation_income", + ) + + self.assertEqual(plan["strategy_assets"], ("SOXL", "SOXX", "BOXX", "QQQI", "SPYI")) + self.assertEqual(plan["limit_order_symbols"], ("SOXL", "SOXX", "QQQI", "SPYI")) + self.assertEqual(plan["portfolio_rows"], (("SOXL", "SOXX"), ("QQQI", "SPYI"), ("BOXX",))) + self.assertEqual(plan["targets"]["BOXX"], 15000.0) + self.assertEqual(plan["threshold_value"], 500.0) + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/test_rebalance_service.py b/tests/test_rebalance_service.py index a621149..f67f8fc 100644 --- a/tests/test_rebalance_service.py +++ b/tests/test_rebalance_service.py @@ -14,8 +14,15 @@ if str(US_EQUITY_STRATEGIES_SRC) not in sys.path: sys.path.insert(0, str(US_EQUITY_STRATEGIES_SRC)) -from application import rebalance_service -from notifications.telegram import build_translator +import types + + +requests_stub = types.ModuleType("requests") +requests_stub.post = lambda *args, **kwargs: None + +with patch.dict(sys.modules, {"requests": requests_stub}): + from application import rebalance_service + from notifications.telegram import build_translator class RebalanceServiceNotificationTests(unittest.TestCase): @@ -53,53 +60,47 @@ def fake_submit_order_with_alert( return True plan_side_effect = [plan, refreshed_plan or plan] + observed_plan_inputs = [] account_state_values = list(account_states or [{}, {}]) - def fake_fetch_strategy_account_state(quote_context, trade_context, strategy_assets): - del quote_context, trade_context, strategy_assets + def fake_fetch_strategy_account_state(quote_context, trade_context): + del quote_context, trade_context if not account_state_values: raise AssertionError("unexpected extra fetch_strategy_account_state call") value = account_state_values.pop(0) observed_account_states.append(value) return value - with patch.object(rebalance_service, "build_rebalance_plan", side_effect=plan_side_effect): - rebalance_service.run_strategy( - project_id="project-1", - secret_name="secret-1", - trend_ma_window=150, - token_refresh_threshold_days=30, - cash_reserve_ratio=0.03, - min_trade_ratio=0.01, - min_trade_floor=100.0, - rebalance_threshold_ratio=0.01, - limit_sell_discount=0.995, - limit_buy_premium=1.005, - small_account_deploy_ratio=0.60, - mid_account_deploy_ratio=0.57, - large_account_deploy_ratio=0.50, - trade_layer_decay_coeff=0.04, - income_layer_start_usd=150000.0, - income_layer_max_ratio=0.15, - income_layer_qqqi_weight=0.70, - income_layer_spyi_weight=0.30, - separator="━━━━━━━━━━━━━━━━━━", - translator=build_translator("zh"), - with_prefix=lambda message: f"[HK/LongBridgeQuant] {message}", - send_tg_message=fake_send_tg_message, - notify_issue=fake_notify_issue, - fetch_token_from_secret=lambda project_id, secret_name: "refresh-token", - refresh_token_if_needed=lambda *args, **kwargs: "live-token", - build_contexts=lambda app_key, app_secret, token: ("quote-context", "trade-context"), - calculate_rotation_indicators=lambda quote_context, trend_window: {"soxl": {"price": 1, "ma_trend": 2}}, - fetch_strategy_account_state=fake_fetch_strategy_account_state, - fetch_last_price=lambda quote_context, symbol: prices[symbol], - estimate_max_purchase_quantity=lambda *args, **kwargs: estimate_max_purchase_quantity_value, - submit_order_with_alert=fake_submit_order_with_alert, - ) - - return sent_messages, observed_account_states + def fake_resolve_rebalance_plan(*, indicators, account_state): + observed_plan_inputs.append((indicators, account_state)) + if not plan_side_effect: + raise AssertionError("unexpected extra resolve_rebalance_plan call") + return plan_side_effect.pop(0) + + rebalance_service.run_strategy( + project_id="project-1", + secret_name="secret-1", + token_refresh_threshold_days=30, + limit_sell_discount=0.995, + limit_buy_premium=1.005, + separator="━━━━━━━━━━━━━━━━━━", + translator=build_translator("zh"), + with_prefix=lambda message: f"[HK/LongBridgeQuant] {message}", + send_tg_message=fake_send_tg_message, + notify_issue=fake_notify_issue, + fetch_token_from_secret=lambda project_id, secret_name: "refresh-token", + refresh_token_if_needed=lambda *args, **kwargs: "live-token", + build_contexts=lambda app_key, app_secret, token: ("quote-context", "trade-context"), + calculate_strategy_indicators=lambda quote_context: {"soxl": {"price": 1, "ma_trend": 2}}, + fetch_strategy_account_state=fake_fetch_strategy_account_state, + resolve_rebalance_plan=fake_resolve_rebalance_plan, + fetch_last_price=lambda quote_context, symbol: prices[symbol], + estimate_max_purchase_quantity=lambda *args, **kwargs: estimate_max_purchase_quantity_value, + submit_order_with_alert=fake_submit_order_with_alert, + ) + + return sent_messages, observed_account_states, observed_plan_inputs def test_sell_then_buy_skip_is_sent_in_single_summary_message(self): plan = { @@ -121,7 +122,7 @@ def test_sell_then_buy_skip_is_sent_in_single_summary_message(self): "total_strategy_equity": 60000.0, "portfolio_rows": (("SOXL", "SOXX"),), } - sent_messages, _ = self._run_strategy( + sent_messages, _, _ = self._run_strategy( plan, prices={"SOXL.US": 45.94, "SOXX.US": 322.74}, ) @@ -152,7 +153,7 @@ def test_buy_skip_without_orders_is_sent_in_single_heartbeat_message(self): "total_strategy_equity": 60000.0, "portfolio_rows": (("SOXX",),), } - sent_messages, _ = self._run_strategy( + sent_messages, _, _ = self._run_strategy( plan, prices={"SOXX.US": 322.74}, ) @@ -191,7 +192,7 @@ def test_refreshes_account_state_after_sell_and_can_place_followup_buy(self): "investable_cash": 40000.0, "available_cash": 40000.0, } - sent_messages, observed_account_states = self._run_strategy( + sent_messages, observed_account_states, observed_plan_inputs = self._run_strategy( initial_plan, refreshed_plan=refreshed_plan, account_states=[{"phase": "before_sell"}, {"phase": "after_sell"}], @@ -205,6 +206,7 @@ def test_refreshes_account_state_after_sell_and_can_place_followup_buy(self): self.assertIn("限价卖出", sent_messages[0]) self.assertIn("限价买入", sent_messages[0]) self.assertNotIn("买入跳过", sent_messages[0]) + self.assertEqual(len(observed_plan_inputs), 2) if __name__ == "__main__": diff --git a/tests/test_request_handling.py b/tests/test_request_handling.py index 0c1cada..dfb92bb 100644 --- a/tests/test_request_handling.py +++ b/tests/test_request_handling.py @@ -18,6 +18,51 @@ @contextmanager def install_stub_modules(): + flask_module = types.ModuleType("flask") + + class Flask: + def __init__(self, _name): + self._routes = {} + + def route(self, path, methods=None): + def decorator(func): + self._routes[(path, tuple(methods or []))] = func + return func + + return decorator + + def test_request_context(self, *_args, **_kwargs): + class _Context: + def __enter__(self_inner): + return self_inner + + def __exit__(self_inner, exc_type, exc, tb): + return False + + return _Context() + + def run(self, *args, **kwargs): + return None + + flask_module.Flask = Flask + + requests_module = types.ModuleType("requests") + requests_module.post = lambda *args, **kwargs: None + + cloud_run_module = types.ModuleType("entrypoints.cloud_run") + cloud_run_module.is_market_open_now = lambda: True + + qpk_longbridge_module = types.ModuleType("quant_platform_kit.longbridge") + qpk_longbridge_module.build_contexts = lambda *args, **kwargs: ("quote-context", "trade-context") + qpk_longbridge_module.calculate_rotation_indicators = lambda *args, **kwargs: {} + qpk_longbridge_module.estimate_max_purchase_quantity = lambda *args, **kwargs: 0 + qpk_longbridge_module.fetch_last_price = lambda *args, **kwargs: 0.0 + qpk_longbridge_module.fetch_order_status = lambda *args, **kwargs: None + qpk_longbridge_module.fetch_strategy_account_state = lambda *args, **kwargs: {} + qpk_longbridge_module.fetch_token_from_secret = lambda *args, **kwargs: "token" + qpk_longbridge_module.refresh_token_if_needed = lambda *args, **kwargs: "token" + qpk_longbridge_module.submit_order = lambda *args, **kwargs: None + google_module = types.ModuleType("google") google_module.__path__ = [] @@ -33,6 +78,13 @@ def install_stub_modules(): pandas_market_calendars = types.ModuleType("pandas_market_calendars") + strategy_runtime_module = types.ModuleType("strategy_runtime") + strategy_runtime_module.load_strategy_runtime = lambda *_args, **_kwargs: types.SimpleNamespace( + merged_runtime_config={"trend_ma_window": 150}, + managed_symbols=("SOXL", "SOXX", "BOXX", "QQQI", "SPYI"), + evaluate=lambda **_kwargs: None, + ) + longport_module = types.ModuleType("longport") longport_module.__path__ = [] openapi_module = types.ModuleType("longport.openapi") @@ -50,11 +102,16 @@ def install_stub_modules(): setattr(openapi_module, name, type(name, (), {})) modules = { + "flask": flask_module, + "requests": requests_module, + "entrypoints.cloud_run": cloud_run_module, + "quant_platform_kit.longbridge": qpk_longbridge_module, "google": google_module, "google.auth": google_auth_module, "google.cloud": google_cloud_module, "google.cloud.secretmanager_v1": google_secretmanager_module, "pandas_market_calendars": pandas_market_calendars, + "strategy_runtime": strategy_runtime_module, "longport": longport_module, "longport.openapi": openapi_module, } diff --git a/tests/test_shared_chat_id_fallback.py b/tests/test_shared_chat_id_fallback.py index d8e2be5..eef4a88 100644 --- a/tests/test_shared_chat_id_fallback.py +++ b/tests/test_shared_chat_id_fallback.py @@ -18,6 +18,51 @@ @contextmanager def install_stub_modules(): + flask_module = types.ModuleType("flask") + + class Flask: + def __init__(self, _name): + self._routes = {} + + def route(self, path, methods=None): + def decorator(func): + self._routes[(path, tuple(methods or []))] = func + return func + + return decorator + + def test_request_context(self, *_args, **_kwargs): + class _Context: + def __enter__(self_inner): + return self_inner + + def __exit__(self_inner, exc_type, exc, tb): + return False + + return _Context() + + def run(self, *args, **kwargs): + return None + + flask_module.Flask = Flask + + requests_module = types.ModuleType("requests") + requests_module.post = lambda *args, **kwargs: None + + cloud_run_module = types.ModuleType("entrypoints.cloud_run") + cloud_run_module.is_market_open_now = lambda: True + + qpk_longbridge_module = types.ModuleType("quant_platform_kit.longbridge") + qpk_longbridge_module.build_contexts = lambda *args, **kwargs: ("quote-context", "trade-context") + qpk_longbridge_module.calculate_rotation_indicators = lambda *args, **kwargs: {} + qpk_longbridge_module.estimate_max_purchase_quantity = lambda *args, **kwargs: 0 + qpk_longbridge_module.fetch_last_price = lambda *args, **kwargs: 0.0 + qpk_longbridge_module.fetch_order_status = lambda *args, **kwargs: None + qpk_longbridge_module.fetch_strategy_account_state = lambda *args, **kwargs: {} + qpk_longbridge_module.fetch_token_from_secret = lambda *args, **kwargs: "token" + qpk_longbridge_module.refresh_token_if_needed = lambda *args, **kwargs: "token" + qpk_longbridge_module.submit_order = lambda *args, **kwargs: None + google_module = types.ModuleType("google") google_module.__path__ = [] @@ -33,6 +78,13 @@ def install_stub_modules(): pandas_market_calendars = types.ModuleType("pandas_market_calendars") + strategy_runtime_module = types.ModuleType("strategy_runtime") + strategy_runtime_module.load_strategy_runtime = lambda *_args, **_kwargs: types.SimpleNamespace( + merged_runtime_config={"trend_ma_window": 150}, + managed_symbols=("SOXL", "SOXX", "BOXX", "QQQI", "SPYI"), + evaluate=lambda **_kwargs: None, + ) + longport_module = types.ModuleType("longport") longport_module.__path__ = [] openapi_module = types.ModuleType("longport.openapi") @@ -50,11 +102,16 @@ def install_stub_modules(): setattr(openapi_module, name, type(name, (), {})) modules = { + "flask": flask_module, + "requests": requests_module, + "entrypoints.cloud_run": cloud_run_module, + "quant_platform_kit.longbridge": qpk_longbridge_module, "google": google_module, "google.auth": google_auth_module, "google.cloud": google_cloud_module, "google.cloud.secretmanager_v1": google_secretmanager_module, "pandas_market_calendars": pandas_market_calendars, + "strategy_runtime": strategy_runtime_module, "longport": longport_module, "longport.openapi": openapi_module, } diff --git a/tests/test_strategy_loader.py b/tests/test_strategy_loader.py index 059b0e8..a7a0c60 100644 --- a/tests/test_strategy_loader.py +++ b/tests/test_strategy_loader.py @@ -2,34 +2,35 @@ class StrategyLoaderTests(unittest.TestCase): - def test_load_allocation_module_resolves_semiconductor_rotation_income(self): + def test_load_strategy_entrypoint_resolves_semiconductor_rotation_income(self): try: - from strategy_loader import load_allocation_module + from strategy_loader import load_strategy_entrypoint_for_profile - module = load_allocation_module("semiconductor_rotation_income") + entrypoint = load_strategy_entrypoint_for_profile("semiconductor_rotation_income") except ModuleNotFoundError as exc: if exc.name in {"numpy", "pandas"}: self.skipTest(f"{exc.name} is not installed") raise + self.assertEqual(entrypoint.manifest.profile, "semiconductor_rotation_income") self.assertEqual( - module.__name__, - "us_equity_strategies.strategies.semiconductor_rotation_income", + entrypoint.manifest.default_config["managed_symbols"], + ("SOXL", "SOXX", "BOXX", "QQQI", "SPYI"), ) - def test_load_allocation_module_resolves_semiconductor_rotation_income_alias(self): + def test_load_strategy_entrypoint_resolves_semiconductor_rotation_income_alias(self): try: - from strategy_loader import load_allocation_module + from strategy_loader import load_strategy_entrypoint_for_profile - module = load_allocation_module("semiconductor_trend_income") + entrypoint = load_strategy_entrypoint_for_profile("semiconductor_trend_income") except ModuleNotFoundError as exc: if exc.name in {"numpy", "pandas"}: self.skipTest(f"{exc.name} is not installed") raise self.assertEqual( - module.__name__, - "us_equity_strategies.strategies.semiconductor_rotation_income", + entrypoint.manifest.profile, + "semiconductor_rotation_income", ) diff --git a/tests/test_strategy_runtime.py b/tests/test_strategy_runtime.py new file mode 100644 index 0000000..8e1a147 --- /dev/null +++ b/tests/test_strategy_runtime.py @@ -0,0 +1,55 @@ +import unittest +from unittest.mock import patch + +import strategy_runtime as strategy_runtime_module +from quant_platform_kit.strategy_contracts import StrategyDecision + + +class _FakeEntrypoint: + def __init__(self): + self.manifest = type( + "Manifest", + (), + { + "profile": "semiconductor_rotation_income", + "default_config": {"managed_symbols": ("SOXL", "SOXX", "BOXX", "QQQI", "SPYI")}, + }, + )() + + def evaluate(self, ctx): + self.ctx = ctx + return StrategyDecision(diagnostics={"signal_message": "ok"}) + + +class StrategyRuntimeTests(unittest.TestCase): + def test_runtime_exposes_managed_symbols_and_injects_translator(self): + entrypoint = _FakeEntrypoint() + runtime = strategy_runtime_module.LoadedStrategyRuntime( + entrypoint=entrypoint, + merged_runtime_config={"managed_symbols": ("SOXL", "SOXX", "BOXX", "QQQI", "SPYI")}, + ) + + result = runtime.evaluate( + indicators={"soxl": {"price": 1.0, "ma_trend": 2.0}}, + account_state={"available_cash": 100.0}, + translator=lambda key, **_kwargs: key, + ) + + self.assertEqual(runtime.managed_symbols, ("SOXL", "SOXX", "BOXX", "QQQI", "SPYI")) + self.assertEqual(entrypoint.ctx.market_data["account_state"]["available_cash"], 100.0) + self.assertIn("translator", entrypoint.ctx.runtime_config) + self.assertEqual(result.metadata["strategy_profile"], "semiconductor_rotation_income") + + def test_load_strategy_runtime_uses_entrypoint_default_config(self): + entrypoint = _FakeEntrypoint() + + with patch.object(strategy_runtime_module, "load_strategy_entrypoint_for_profile", return_value=entrypoint) as mock_loader: + runtime = strategy_runtime_module.load_strategy_runtime("semiconductor_rotation_income") + + mock_loader.assert_called_once_with("semiconductor_rotation_income") + self.assertIs(runtime.entrypoint, entrypoint) + self.assertEqual(runtime.managed_symbols, ("SOXL", "SOXX", "BOXX", "QQQI", "SPYI")) + + +if __name__ == "__main__": + unittest.main() From e00c4a79354b18510d4a328fc1ffea1f912fd9fc Mon Sep 17 00:00:00 2001 From: Pigbibi <20649888+Pigbibi@users.noreply.github.com> Date: Tue, 7 Apr 2026 02:51:06 +0800 Subject: [PATCH 2/4] ci: use branch-aware internal deps in actions --- .github/workflows/ci.yml | 35 +++++++++++++++++++++++++++++++++++ 1 file changed, 35 insertions(+) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 1e2e0e3..c47c24d 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -12,6 +12,40 @@ jobs: - name: Checkout uses: actions/checkout@v6 + - name: Resolve QuantPlatformKit ref + id: quant-platform-kit-ref + run: | + set -euo pipefail + ref="main" + if [ -n "${GITHUB_HEAD_REF:-}" ] && git ls-remote --exit-code --heads https://github.com/QuantStrategyLab/QuantPlatformKit.git "${GITHUB_HEAD_REF}" >/dev/null 2>&1; then + ref="${GITHUB_HEAD_REF}" + fi + echo "ref=${ref}" >> "$GITHUB_OUTPUT" + + - name: Resolve UsEquityStrategies ref + id: us-equity-strategies-ref + run: | + set -euo pipefail + ref="main" + if [ -n "${GITHUB_HEAD_REF:-}" ] && git ls-remote --exit-code --heads https://github.com/QuantStrategyLab/UsEquityStrategies.git "${GITHUB_HEAD_REF}" >/dev/null 2>&1; then + ref="${GITHUB_HEAD_REF}" + fi + echo "ref=${ref}" >> "$GITHUB_OUTPUT" + + - name: Checkout QuantPlatformKit + uses: actions/checkout@v6 + with: + repository: QuantStrategyLab/QuantPlatformKit + ref: ${{ steps.quant-platform-kit-ref.outputs.ref }} + path: external/QuantPlatformKit + + - name: Checkout UsEquityStrategies + uses: actions/checkout@v6 + with: + repository: QuantStrategyLab/UsEquityStrategies + ref: ${{ steps.us-equity-strategies-ref.outputs.ref }} + path: external/UsEquityStrategies + - name: Setup Python uses: actions/setup-python@v6 with: @@ -23,6 +57,7 @@ jobs: python -m pip install --upgrade pip python -m pip install -r requirements.txt python -m pip install ruff + python -m pip install -e external/QuantPlatformKit -e external/UsEquityStrategies - name: Run ruff run: | From 91efa7032bc6a87510d50a8085de0e1a3d7fd6e1 Mon Sep 17 00:00:00 2001 From: Pigbibi <20649888+Pigbibi@users.noreply.github.com> Date: Tue, 7 Apr 2026 02:55:10 +0800 Subject: [PATCH 3/4] ci: fix workflow dependency resolution --- .github/workflows/ci.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index c47c24d..11e4084 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -57,7 +57,7 @@ jobs: python -m pip install --upgrade pip python -m pip install -r requirements.txt python -m pip install ruff - python -m pip install -e external/QuantPlatformKit -e external/UsEquityStrategies + python -m pip install --no-deps -e external/QuantPlatformKit -e external/UsEquityStrategies - name: Run ruff run: | From faa3bda375520f2f4d845c062813eff0a2b6bace Mon Sep 17 00:00:00 2001 From: Pigbibi <20649888+Pigbibi@users.noreply.github.com> Date: Tue, 7 Apr 2026 03:09:34 +0800 Subject: [PATCH 4/4] build: pin strategy dependencies to merged main shas --- requirements.txt | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/requirements.txt b/requirements.txt index c1ef411..f84e45e 100644 --- a/requirements.txt +++ b/requirements.txt @@ -1,7 +1,7 @@ flask gunicorn -quant-platform-kit @ git+https://github.com/QuantStrategyLab/QuantPlatformKit.git@6e8cc058b821aea8a54015d4b39e02fbdd3dc198 -us-equity-strategies @ git+https://github.com/QuantStrategyLab/UsEquityStrategies.git@8b0c59f2ecd7c74cc2d350f1870f124a2560b205 +quant-platform-kit @ git+https://github.com/QuantStrategyLab/QuantPlatformKit.git@5174d9e40f79fffae47450a42e26434145d28b31 +us-equity-strategies @ git+https://github.com/QuantStrategyLab/UsEquityStrategies.git@84da76d1ed17e9cf8bec33d9f1f8020f61362a78 pandas requests pytz