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
6 changes: 4 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -38,6 +38,7 @@ The mainline runtime now follows one path only:
- `soxl_soxx_trend_income`
- `tech_communication_pullback_enhancement`
- `mega_cap_leader_rotation_top50_balanced`
- `nasdaq_sp500_smart_dca`


**IBKR profile status**
Expand All @@ -50,6 +51,7 @@ The mainline runtime now follows one path only:
| `soxl_soxx_trend_income` | SOXL/SOXX Semiconductor Trend Income | Yes | Yes | `us_equity` | current IBKR live line |
| `tech_communication_pullback_enhancement` | Tech/Communication Pullback Enhancement | Yes | Yes | `us_equity` | enabled feature-snapshot alternative |
| `mega_cap_leader_rotation_top50_balanced` | Mega Cap Leader Rotation Top50 Balanced | Yes | Yes | `us_equity` | enabled balanced Top50 leader rotation |
| `nasdaq_sp500_smart_dca` | Nasdaq/S&P 500 Smart DCA | Yes | Yes | `us_equity` | buy-only cash-deployment profile |

Check the current matrix locally:

Expand Down Expand Up @@ -99,7 +101,7 @@ For IBKR, keep `paper` as a single account-group entry. If you later add live ac
| `IBKR_CONNECT_ATTEMPTS` | No | Number of IBKR connection attempts before failing the cycle. Defaults to `3`. |
| `IBKR_CONNECT_RETRY_DELAY_SECONDS` | No | Delay between failed IBKR connection attempts. Defaults to `5`. |
| `IBKR_CLIENT_ID_RETRY_OFFSET` | No | Offset added to the configured `ib_client_id` on each retry, so a timed-out API handshake can retry with a fresh client id. Defaults to `100`. |
| `STRATEGY_PROFILE` | Yes | Strategy profile selector. Supported `us_equity` values: `global_etf_rotation`, `russell_1000_multi_factor_defensive`, `tqqq_growth_income`, `soxl_soxx_trend_income`, `tech_communication_pullback_enhancement`, `mega_cap_leader_rotation_top50_balanced` |
| `STRATEGY_PROFILE` | Yes | Strategy profile selector. Supported `us_equity` values: `global_etf_rotation`, `russell_1000_multi_factor_defensive`, `tqqq_growth_income`, `soxl_soxx_trend_income`, `tech_communication_pullback_enhancement`, `mega_cap_leader_rotation_top50_balanced`, `nasdaq_sp500_smart_dca` |
| `ACCOUNT_GROUP` | Yes | Account-group selector. Set explicitly for each deployment. |
| `IBKR_FEATURE_SNAPSHOT_PATH` | Conditionally required | Required for snapshot-backed profiles such as `russell_1000_multi_factor_defensive`, `tech_communication_pullback_enhancement`, and `mega_cap_leader_rotation_top50_balanced`. Path to the latest feature snapshot file (`.csv`, `.json`, `.jsonl`, `.parquet`). |
| `IBKR_STRATEGY_PLUGIN_MOUNTS_JSON` | No | Optional IBKR-side strategy plugin mount JSON. The plugin artifact controls mode; platform config must not set `mode`. |
Expand Down Expand Up @@ -426,7 +428,7 @@ IBKR 账户
| `IBKR_CONNECT_ATTEMPTS` | 否 | IBKR 连接失败前最多尝试次数。默认 `3`。 |
| `IBKR_CONNECT_RETRY_DELAY_SECONDS` | 否 | IBKR 连接重试间隔,单位秒。默认 `5`。 |
| `IBKR_CLIENT_ID_RETRY_OFFSET` | 否 | 每次重试时加到 `ib_client_id` 上的偏移量,用新的 client id 避开超时握手留下的卡住会话。默认 `100`。 |
| `STRATEGY_PROFILE` | 是 | 策略档位选择。当前可用的 `us_equity` 值:`global_etf_rotation`、`russell_1000_multi_factor_defensive`、`tqqq_growth_income`、`soxl_soxx_trend_income`、`tech_communication_pullback_enhancement`、`mega_cap_leader_rotation_top50_balanced` |
| `STRATEGY_PROFILE` | 是 | 策略档位选择。当前可用的 `us_equity` 值:`global_etf_rotation`、`russell_1000_multi_factor_defensive`、`tqqq_growth_income`、`soxl_soxx_trend_income`、`tech_communication_pullback_enhancement`、`mega_cap_leader_rotation_top50_balanced`、`nasdaq_sp500_smart_dca` |
| `ACCOUNT_GROUP` | 是 | 账号组选择器,每个部署都要显式设置。 |
| `IBKR_FEATURE_SNAPSHOT_PATH` | 条件必填 | `russell_1000_multi_factor_defensive`、`tech_communication_pullback_enhancement`、`mega_cap_leader_rotation_top50_balanced` 等快照策略需要。指向最新特征快照文件(`.csv`、`.json`、`.jsonl`、`.parquet`)。 |
| `IBKR_STRATEGY_PLUGIN_MOUNTS_JSON` | 否 | 可选的 IBKR 侧策略插件挂载 JSON。插件 artifact 自带模式;平台配置不要设置 `mode`。 |
Expand Down
2 changes: 1 addition & 1 deletion main.py
Original file line number Diff line number Diff line change
Expand Up @@ -250,7 +250,7 @@ def _env_flag(name: str) -> bool:

CASH_RESERVE_RATIO = STRATEGY_RUNTIME.cash_reserve_ratio
CASH_RESERVE_FLOOR_USD = getattr(STRATEGY_RUNTIME, "cash_reserve_floor_usd", 0.0)
REBALANCE_THRESHOLD_RATIO = 0.02
REBALANCE_THRESHOLD_RATIO = STRATEGY_RUNTIME.rebalance_threshold_ratio
LIMIT_BUY_PREMIUM = 1.005
SELL_SETTLE_DELAY_SEC = 3
HIST_DATA_PACING_SEC = 0.5
Expand Down
2 changes: 2 additions & 0 deletions notifications/telegram.py
Original file line number Diff line number Diff line change
Expand Up @@ -134,6 +134,7 @@
"strategy_name_tech_communication_pullback_enhancement": "科技通信回调增强",
"strategy_name_qqq_tech_enhancement": "科技通信回调增强",
"strategy_name_mega_cap_leader_rotation_top50_balanced": "Mega Cap Top50 平衡龙头轮动",
"strategy_name_nasdaq_sp500_smart_dca": "纳斯达克 / 标普智能定投",
"strategy_name_tqqq_growth_income": "TQQQ 增长收益",
"strategy_name_soxl_soxx_trend_income": "SOXL/SOXX 半导体趋势收益",
},
Expand Down Expand Up @@ -267,6 +268,7 @@
"strategy_name_tech_communication_pullback_enhancement": "Tech/Communication Pullback Enhancement",
"strategy_name_qqq_tech_enhancement": "Tech/Communication Pullback Enhancement",
"strategy_name_mega_cap_leader_rotation_top50_balanced": "Mega Cap Leader Rotation Top50 Balanced",
"strategy_name_nasdaq_sp500_smart_dca": "Nasdaq/S&P 500 Smart DCA",
"strategy_name_tqqq_growth_income": "TQQQ Growth Income",
"strategy_name_soxl_soxx_trend_income": "SOXL/SOXX Semiconductor Trend Income",
},
Expand Down
2 changes: 1 addition & 1 deletion requirements.txt
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
flask
gunicorn
quant-platform-kit @ git+https://github.com/QuantStrategyLab/QuantPlatformKit.git@f176f5d1f208724381278c253941cbc6d0a1c964
us-equity-strategies @ git+https://github.com/QuantStrategyLab/UsEquityStrategies.git@f206ae7a5f2772873c8e3907daa8d753f616348c
us-equity-strategies @ git+https://github.com/QuantStrategyLab/UsEquityStrategies.git@b704350bc7e7b00f16a1d71d70f138fd1ba0a567
pandas
numpy
requests
Expand Down
26 changes: 24 additions & 2 deletions strategy_runtime.py
Original file line number Diff line number Diff line change
Expand Up @@ -37,6 +37,7 @@


DEFAULT_CASH_RESERVE_RATIO = 0.03
DEFAULT_REBALANCE_THRESHOLD_RATIO = 0.02
_FEATURE_SNAPSHOT_INPUT = "feature_snapshot"
_MARKET_HISTORY_INPUT = "market_history"
_BENCHMARK_HISTORY_INPUT = "benchmark_history"
Expand All @@ -59,6 +60,7 @@ class LoadedStrategyRuntime:
merged_runtime_config: Mapping[str, Any] = field(default_factory=dict)
status_icon: str = "🐤"
cash_reserve_ratio: float = DEFAULT_CASH_RESERVE_RATIO
rebalance_threshold_ratio: float = DEFAULT_REBALANCE_THRESHOLD_RATIO
cash_reserve_floor_usd: float = 0.0
logger: Callable[[str], None] = print

Expand Down Expand Up @@ -293,7 +295,14 @@ def _evaluate_market_data_strategy(
runtime_config.setdefault("translator", translator)
runtime_config.setdefault("pacing_sec", float(pacing_sec))
apply_runtime_policy_to_runtime_config(runtime_config, self.runtime_adapter)
portfolio_snapshot = self._fetch_portfolio_snapshot_for_context(ib, required=False)
requires_portfolio = (
_PORTFOLIO_SNAPSHOT_INPUT in self.required_inputs
or self.runtime_adapter.portfolio_input_name == _PORTFOLIO_SNAPSHOT_INPUT
)
portfolio_snapshot = self._fetch_portfolio_snapshot_for_context(
ib,
required=requires_portfolio,
)
ctx = self._build_strategy_context(
runtime_adapter=self.runtime_adapter,
as_of=run_as_of,
Expand All @@ -305,8 +314,11 @@ def _evaluate_market_data_strategy(
)
decision = self.entrypoint.evaluate(ctx)
safe_haven_symbol = str(self.merged_runtime_config.get("safe_haven") or "").strip().upper() or None
managed_config_symbols = tuple(
str(symbol) for symbol in self.merged_runtime_config.get("managed_symbols", ())
)
ranking_pool = tuple(str(symbol) for symbol in self.merged_runtime_config.get("ranking_pool", ()))
managed_candidates = list(ranking_pool)
managed_candidates = [*managed_config_symbols, *ranking_pool]
if safe_haven_symbol:
managed_candidates.append(safe_haven_symbol)
managed_symbols = tuple(dict.fromkeys(managed_candidates))
Expand Down Expand Up @@ -638,6 +650,15 @@ def load_strategy_runtime(
DEFAULT_CASH_RESERVE_RATIO,
)
)
strategy_rebalance_threshold_ratio = float(
merged_runtime_config.get(
"execution_rebalance_threshold_ratio",
merged_runtime_config.get(
"rebalance_threshold_ratio",
DEFAULT_REBALANCE_THRESHOLD_RATIO,
),
)
)
platform_cash_reserve_ratio = runtime_settings.reserved_cash_ratio
if platform_cash_reserve_ratio is not None:
strategy_cash_reserve_ratio = max(
Expand All @@ -652,6 +673,7 @@ def load_strategy_runtime(
merged_runtime_config=merged_runtime_config,
status_icon=runtime_adapter.status_icon,
cash_reserve_ratio=strategy_cash_reserve_ratio,
rebalance_threshold_ratio=max(0.0, strategy_rebalance_threshold_ratio),
cash_reserve_floor_usd=float(runtime_settings.reserved_cash_floor_usd or 0.0),
logger=logger,
)
1 change: 1 addition & 0 deletions tests/test_connect_timeout_alert.py
Original file line number Diff line number Diff line change
Expand Up @@ -92,6 +92,7 @@ def run(self, *args, **kwargs):
available_inputs=frozenset({"derived_indicators", "portfolio_snapshot"})
),
cash_reserve_ratio=0.03,
rebalance_threshold_ratio=0.02,
evaluate=lambda **_kwargs: None,
)

Expand Down
2 changes: 2 additions & 0 deletions tests/test_notifications.py
Original file line number Diff line number Diff line change
Expand Up @@ -82,6 +82,8 @@ def test_strategy_display_name_translates_new_live_profiles():
assert en_name("global_etf_confidence_vol_gate") == "Global ETF Confidence Vol Gate"
assert zh_name("mega_cap_leader_rotation_top50_balanced") == "Mega Cap Top50 平衡龙头轮动"
assert en_name("mega_cap_leader_rotation_top50_balanced") == "Mega Cap Leader Rotation Top50 Balanced"
assert zh_name("nasdaq_sp500_smart_dca") == "纳斯达克 / 标普智能定投"
assert en_name("nasdaq_sp500_smart_dca") == "Nasdaq/S&P 500 Smart DCA"


def test_supported_strategy_profiles_have_translated_names():
Expand Down
21 changes: 21 additions & 0 deletions tests/test_runtime_config_support.py
Original file line number Diff line number Diff line change
Expand Up @@ -463,6 +463,7 @@ def test_platform_supported_profiles_are_filtered_by_registry():
"tech_communication_pullback_enhancement",
"global_etf_rotation",
"mega_cap_leader_rotation_top50_balanced",
"nasdaq_sp500_smart_dca",
"russell_1000_multi_factor_defensive",
}
)
Expand All @@ -476,6 +477,7 @@ def test_platform_eligible_profiles_are_exposed_by_capability_matrix():
"tech_communication_pullback_enhancement",
"global_etf_rotation",
"mega_cap_leader_rotation_top50_balanced",
"nasdaq_sp500_smart_dca",
"russell_1000_multi_factor_defensive",
}
)
Expand Down Expand Up @@ -528,6 +530,18 @@ def test_load_platform_runtime_settings_accepts_tqqq_growth_income(monkeypatch):
assert settings.strategy_target_mode == "value"


def test_load_platform_runtime_settings_accepts_nasdaq_sp500_smart_dca(monkeypatch):
monkeypatch.setenv("RUNTIME_TARGET_JSON", runtime_target_json("nasdaq_sp500_smart_dca"))
monkeypatch.setenv("ACCOUNT_GROUP", "paper")
monkeypatch.setenv("IB_ACCOUNT_GROUP_CONFIG_JSON", MINIMAL_GROUP_JSON)

settings = load_platform_runtime_settings(project_id_resolver=lambda: "project-1")

assert settings.strategy_profile == "nasdaq_sp500_smart_dca"
assert settings.strategy_display_name == "Nasdaq/S&P 500 Smart DCA"
assert settings.strategy_target_mode == "value"


def test_load_platform_runtime_settings_rejects_legacy_qqq_tech_alias(monkeypatch):
monkeypatch.setenv("RUNTIME_TARGET_JSON", runtime_target_json("tech_pullback_cash_buffer"))
monkeypatch.setenv("ACCOUNT_GROUP", "paper")
Expand Down Expand Up @@ -557,6 +571,7 @@ def test_platform_profile_status_matrix_matches_current_ibkr_rollout():
"tqqq_growth_income",
"tech_communication_pullback_enhancement",
"mega_cap_leader_rotation_top50_balanced",
"nasdaq_sp500_smart_dca",
}
assert by_profile["global_etf_rotation"] == {
"canonical_profile": "global_etf_rotation",
Expand All @@ -572,6 +587,9 @@ def test_platform_profile_status_matrix_matches_current_ibkr_rollout():
assert by_profile["tqqq_growth_income"]["display_name"] == "TQQQ Growth Income"
assert by_profile["tqqq_growth_income"]["eligible"] is True
assert by_profile["tqqq_growth_income"]["enabled"] is True
assert by_profile["nasdaq_sp500_smart_dca"]["display_name"] == "Nasdaq/S&P 500 Smart DCA"
assert by_profile["nasdaq_sp500_smart_dca"]["eligible"] is True
assert by_profile["nasdaq_sp500_smart_dca"]["enabled"] is True


def test_print_strategy_profile_status_json_matches_registry():
Expand Down Expand Up @@ -602,6 +620,9 @@ def test_print_strategy_profile_status_json_matches_registry():
assert by_profile["global_etf_rotation"]["input_mode"] == "market_history"
assert by_profile["global_etf_rotation"]["requires_snapshot_artifacts"] is False
assert by_profile["global_etf_rotation"]["requires_strategy_config_path"] is False
assert by_profile["nasdaq_sp500_smart_dca"]["profile_group"] == "direct_runtime_inputs"
assert by_profile["nasdaq_sp500_smart_dca"]["input_mode"] == "market_history+portfolio_snapshot"
assert by_profile["nasdaq_sp500_smart_dca"]["requires_snapshot_artifacts"] is False
assert by_profile["tech_communication_pullback_enhancement"]["profile_group"] == "snapshot_backed"
assert by_profile["tech_communication_pullback_enhancement"]["input_mode"] == "feature_snapshot"
assert by_profile["tech_communication_pullback_enhancement"]["requires_snapshot_artifacts"] is True
Expand Down
82 changes: 81 additions & 1 deletion tests/test_strategy_runtime.py
Original file line number Diff line number Diff line change
Expand Up @@ -117,7 +117,11 @@ class FakeEntrypoint:
display_name="Tech/Communication Pullback Enhancement",
description="test",
required_inputs=frozenset({"feature_snapshot"}),
default_config={"safe_haven": "BOXX", "benchmark_symbol": "QQQ"},
default_config={
"safe_haven": "BOXX",
"benchmark_symbol": "QQQ",
"execution_rebalance_threshold_ratio": 0.0,
},
)

def evaluate(self, ctx):
Expand Down Expand Up @@ -156,6 +160,7 @@ def evaluate(self, ctx):
assert runtime.merged_runtime_config["safe_haven"] == "BOXX"
assert runtime.merged_runtime_config["benchmark_symbol"] == "SPY"
assert runtime.merged_runtime_config["rebalance_months"] == (1, 4, 7, 10)
assert runtime.rebalance_threshold_ratio == 0.0
assert runtime.status_icon == "🧲"


Expand Down Expand Up @@ -376,6 +381,81 @@ def loader(*_args, **_kwargs):
assert result.metadata["execution_timing_contract"] == "next_trading_day"


def test_market_history_value_runtime_requires_portfolio_snapshot(monkeypatch):
captured = {}

class FakeEntrypoint:
manifest = StrategyManifest(
profile="nasdaq_sp500_smart_dca",
domain="us_equity",
display_name="Nasdaq/S&P 500 Smart DCA",
description="test",
required_inputs=frozenset({"market_history", "portfolio_snapshot"}),
default_config={"managed_symbols": ("QQQM", "SPLG")},
)

def evaluate(self, ctx):
captured["market_data"] = dict(ctx.market_data)
captured["portfolio"] = ctx.portfolio
captured["runtime_config"] = dict(ctx.runtime_config)
return StrategyDecision(
positions=(
PositionTarget(symbol="QQQM", target_value=1500.0),
PositionTarget(symbol="SPLG", target_value=1700.0),
)
)

runtime = strategy_runtime_module.LoadedStrategyRuntime(
entrypoint=FakeEntrypoint(),
runtime_adapter=StrategyRuntimeAdapter(
status_icon="🧺",
portfolio_input_name="portfolio_snapshot",
runtime_policy=StrategyRuntimePolicy(signal_effective_after_trading_days=0),
),
runtime_settings=_build_runtime_settings(
profile="nasdaq_sp500_smart_dca",
display_name="Nasdaq/S&P 500 Smart DCA",
target_mode="value",
),
runtime_config={},
merged_runtime_config={"managed_symbols": ("QQQM", "SPLG")},
status_icon="🧺",
logger=lambda _message: None,
)
portfolio_snapshot = SimpleNamespace(total_equity=10000.0)

def fetch_snapshot(ib):
assert ib == "fake-ib"
return portfolio_snapshot

close_loader_symbols = []

def close_loader(_ib, symbol, duration="2 Y", bar_size="1 day"):
close_loader_symbols.append((symbol, duration, bar_size))
return strategy_runtime_module.pd.Series([100.0, 101.0])

monkeypatch.setattr(strategy_runtime_module, "fetch_portfolio_snapshot", fetch_snapshot)

result = runtime.evaluate(
ib="fake-ib",
current_holdings=(),
historical_close_loader=close_loader,
run_as_of=strategy_runtime_module.pd.Timestamp("2026-05-26"),
translator=lambda key, **_kwargs: key,
pacing_sec=0.0,
)

assert captured["market_data"]["market_history"] is close_loader
assert captured["portfolio"] is portfolio_snapshot
assert captured["runtime_config"]["signal_effective_after_trading_days"] == 0
assert result.metadata["portfolio_total_equity"] == 10000.0
assert result.metadata["managed_symbols"] == ("QQQM", "SPLG")
assert result.metadata["signal_date"] == "2026-05-26"
assert result.metadata["effective_date"] == "2026-05-26"
assert result.metadata["execution_timing_contract"] == "same_trading_day"
assert close_loader_symbols == [("QQQM", "10 D", "1 day"), ("SPLG", "10 D", "1 day")]


def test_feature_snapshot_runtime_fail_closes_on_entrypoint_exception(monkeypatch):
class ExplodingEntrypoint:
manifest = StrategyManifest(
Expand Down