Skip to content

Commit 8f1601d

Browse files
Pigbibicodex
andcommitted
Support leveraged combo shadow runtime
Co-Authored-By: Codex <noreply@openai.com>
1 parent e7049bd commit 8f1601d

4 files changed

Lines changed: 294 additions & 2 deletions

File tree

scripts/print_strategy_switch_env_plan.py

Lines changed: 12 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -245,10 +245,16 @@ def build_switch_plan(
245245
[
246246
"IBKR_FEATURE_SNAPSHOT_PATH",
247247
"IBKR_FEATURE_SNAPSHOT_MANIFEST_PATH",
248-
"IBKR_STRATEGY_CONFIG_PATH",
249248
"IBKR_RECONCILIATION_OUTPUT_PATH",
250249
]
251250
)
251+
if config_source_policy == "env_only":
252+
optional_env.append("IBKR_STRATEGY_CONFIG_PATH")
253+
notes.append(
254+
"IBKR_STRATEGY_CONFIG_PATH is optional for this profile; set it only for an explicit shadow/runtime config."
255+
)
256+
else:
257+
remove_if_present.append("IBKR_STRATEGY_CONFIG_PATH")
252258

253259
hints: dict[str, str] = {}
254260
if requires_feature_snapshot:
@@ -260,6 +266,11 @@ def build_switch_plan(
260266
hints["feature_snapshot_manifest_filename"] = manifest_filename
261267
if artifact_paths.bundled_config_path is not None:
262268
hints["bundled_strategy_config_path"] = str(artifact_paths.bundled_config_path)
269+
if definition.profile == "us_equity_combo_leveraged":
270+
hints["shadow_352045_strategy_config_path"] = (
271+
"package://us_equity_strategies/configs/"
272+
"us_equity_combo_leveraged_shadow_352045.json"
273+
)
263274

264275
return {
265276
"platform": IBKR_PLATFORM,

strategy_runtime.py

Lines changed: 145 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -47,6 +47,7 @@
4747
DCA_PROFILES = frozenset({"nasdaq_sp500_smart_dca", "ibit_smart_dca"})
4848
IBIT_ZSCORE_EXIT_PROFILE = "ibit_smart_dca"
4949
_MARKET_HISTORY_INPUT = "market_history"
50+
_MARKET_DATA_INPUT = "market_data"
5051
_BENCHMARK_HISTORY_INPUT = "benchmark_history"
5152
_DERIVED_INDICATORS_INPUT = "derived_indicators"
5253
_PORTFOLIO_SNAPSHOT_INPUT = "portfolio_snapshot"
@@ -459,6 +460,61 @@ def _build_market_history_inputs(
459460
}
460461
}
461462

463+
def _build_direct_market_data_inputs(
464+
self,
465+
ib,
466+
historical_close_loader: Callable[..., Any],
467+
) -> Mapping[str, Any]:
468+
if self.profile != "us_equity_combo_leveraged":
469+
raise ValueError(f"Unsupported market_data strategy profile {self.profile!r}")
470+
471+
trend_symbol = str(self.merged_runtime_config.get("market_data_trend_symbol") or "SPY").strip().upper()
472+
ma_window = int(
473+
self.merged_runtime_config.get(
474+
"market_data_ma_window",
475+
self.merged_runtime_config.get("sma_period", 200),
476+
)
477+
)
478+
if ma_window <= 0:
479+
raise ValueError("market_data_ma_window must be positive")
480+
history = historical_close_loader(
481+
ib,
482+
trend_symbol,
483+
duration=str(self.merged_runtime_config.get("market_data_history_duration") or "2 Y"),
484+
bar_size=str(self.merged_runtime_config.get("market_data_history_bar_size") or "1 day"),
485+
)
486+
if isinstance(history, pd.DataFrame):
487+
if "close" in history.columns:
488+
close_values = history["close"]
489+
else:
490+
close_values = history.iloc[:, 0]
491+
elif isinstance(history, pd.Series):
492+
close_values = history
493+
else:
494+
close_values = [
495+
item.get("close") if isinstance(item, Mapping) else getattr(item, "close", item)
496+
for item in (history or ())
497+
]
498+
close_series = pd.to_numeric(pd.Series(close_values), errors="coerce").dropna()
499+
close_series = close_series[close_series > 0]
500+
if len(close_series) < ma_window:
501+
raise ValueError(
502+
f"{self.profile} requires at least {ma_window} positive {trend_symbol} closes, "
503+
f"got {len(close_series)}"
504+
)
505+
trend_price = float(close_series.iloc[-1])
506+
trend_ma = float(close_series.tail(ma_window).mean())
507+
return {
508+
_MARKET_DATA_INPUT: {
509+
"spy_above_ma200": trend_price > trend_ma,
510+
"trend_symbol": trend_symbol,
511+
"trend_price": trend_price,
512+
"trend_ma": trend_ma,
513+
"trend_ma_window": ma_window,
514+
"history_observation_count": int(len(close_series)),
515+
}
516+
}
517+
462518
def _build_strategy_context(
463519
self,
464520
*,
@@ -517,6 +573,16 @@ def evaluate(
517573
pacing_sec=pacing_sec,
518574
strategy_plugin_signals=strategy_plugin_signals,
519575
)
576+
if _MARKET_DATA_INPUT in self.required_inputs:
577+
return self._evaluate_direct_market_data_strategy(
578+
ib=ib,
579+
current_holdings=current_holdings,
580+
historical_close_loader=historical_close_loader,
581+
run_as_of=run_as_of,
582+
translator=translator,
583+
pacing_sec=pacing_sec,
584+
strategy_plugin_signals=strategy_plugin_signals,
585+
)
520586
if _MARKET_HISTORY_INPUT in self.required_inputs:
521587
return self._evaluate_market_data_strategy(
522588
ib=ib,
@@ -547,6 +613,81 @@ def evaluate(
547613
f"{', '.join(sorted(self.required_inputs)) or '<none>'}"
548614
)
549615

616+
def _evaluate_direct_market_data_strategy(
617+
self,
618+
*,
619+
ib,
620+
current_holdings,
621+
historical_close_loader: Callable[..., Any],
622+
run_as_of: pd.Timestamp,
623+
translator: Callable[[str], str],
624+
pacing_sec: float,
625+
strategy_plugin_signals=(),
626+
) -> StrategyEvaluationResult:
627+
runtime_config = dict(self.runtime_config)
628+
runtime_config.setdefault("translator", translator)
629+
runtime_config.setdefault("pacing_sec", float(pacing_sec))
630+
apply_runtime_policy_to_runtime_config(runtime_config, self.runtime_adapter)
631+
portfolio_snapshot = self._fetch_portfolio_snapshot_for_context(
632+
ib,
633+
required=False,
634+
)
635+
portfolio_snapshot = self._project_portfolio_snapshot(
636+
portfolio_snapshot,
637+
self._configured_strategy_symbols(include_ranking_pool=True),
638+
)
639+
portfolio_snapshot = self._attach_strategy_plugin_metadata(portfolio_snapshot, strategy_plugin_signals)
640+
option_chains = self._fetch_option_chains_for_runtime(ib, runtime_config, portfolio_snapshot)
641+
if option_chains:
642+
runtime_config["option_chains"] = option_chains
643+
ctx = self._build_strategy_context(
644+
runtime_adapter=self.runtime_adapter,
645+
as_of=run_as_of,
646+
market_inputs=self._build_direct_market_data_inputs(ib, historical_close_loader),
647+
portfolio_snapshot=portfolio_snapshot,
648+
runtime_config=runtime_config,
649+
current_holdings=current_holdings,
650+
ib=ib,
651+
)
652+
decision = self.entrypoint.evaluate(ctx)
653+
managed_symbols = tuple(
654+
dict.fromkeys(
655+
str(position.symbol).strip().upper()
656+
for position in decision.positions
657+
if str(position.symbol or "").strip()
658+
)
659+
)
660+
price_fallbacks = self._build_historical_close_map(
661+
ib,
662+
historical_close_loader,
663+
self._build_price_fallback_symbol_list(
664+
decision,
665+
managed_symbols=managed_symbols,
666+
current_holdings=current_holdings,
667+
),
668+
)
669+
metadata = {
670+
"strategy_profile": self.profile,
671+
"managed_symbols": managed_symbols,
672+
"status_icon": self.status_icon,
673+
"dry_run_only": self.runtime_settings.dry_run_only,
674+
**build_execution_timing_metadata(
675+
signal_date=run_as_of,
676+
signal_effective_after_trading_days=(
677+
self.runtime_adapter.runtime_policy.signal_effective_after_trading_days
678+
),
679+
),
680+
}
681+
if portfolio_snapshot is not None:
682+
metadata = self._enrich_portfolio_metadata(metadata, portfolio_snapshot)
683+
if "BOXX" in managed_symbols:
684+
metadata["safe_haven_symbol"] = "BOXX"
685+
if price_fallbacks:
686+
metadata["price_fallbacks"] = price_fallbacks
687+
metadata["dry_run_price_fallbacks"] = price_fallbacks
688+
metadata["price_fallback_source"] = "historical_close"
689+
return StrategyEvaluationResult(decision=decision, metadata=metadata)
690+
550691
def _evaluate_market_data_strategy(
551692
self,
552693
*,
@@ -942,7 +1083,10 @@ def load_strategy_runtime(
9421083
logger=logger,
9431084
)
9441085
runtime_config: dict[str, Any] = {}
945-
if _FEATURE_SNAPSHOT_INPUT in frozenset(entrypoint.manifest.required_inputs):
1086+
if (
1087+
_FEATURE_SNAPSHOT_INPUT in frozenset(entrypoint.manifest.required_inputs)
1088+
or runtime_settings.strategy_config_path
1089+
):
9461090
runtime_config = runtime.load_runtime_parameters()
9471091
runtime_config.update(_build_runtime_overrides(runtime_settings))
9481092

tests/test_runtime_config_support.py

Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -834,6 +834,10 @@ def test_print_strategy_profile_status_json_matches_registry():
834834
assert by_profile["global_etf_rotation"]["requires_strategy_config_path"] is False
835835
assert "nasdaq_sp500_smart_dca" in by_profile
836836
assert "ibit_smart_dca" in by_profile
837+
assert by_profile["us_equity_combo_leveraged"]["profile_group"] == "direct_runtime_inputs"
838+
assert by_profile["us_equity_combo_leveraged"]["input_mode"] == "market_data"
839+
assert by_profile["us_equity_combo_leveraged"]["requires_strategy_config_path"] is False
840+
assert by_profile["us_equity_combo_leveraged"]["config_source_policy"] == "env_only"
837841
assert "tech_communication_pullback_enhancement" not in by_profile
838842
assert by_profile["russell_top50_leader_rotation"]["profile_group"] == "snapshot_backed"
839843
assert by_profile["russell_top50_leader_rotation"]["display_name_zh"] == "罗素Top50领涨"
@@ -927,6 +931,28 @@ def test_print_strategy_switch_env_plan_for_tqqq_growth_income():
927931
assert "IBKR_FEATURE_SNAPSHOT_PATH" in plan["remove_if_present"]
928932

929933

934+
def test_print_strategy_switch_env_plan_keeps_optional_config_for_leveraged_combo_shadow():
935+
result = subprocess.run(
936+
[sys.executable, str(SWITCH_PLAN_SCRIPT_PATH), "--profile", "us_equity_combo_leveraged", "--json"],
937+
check=True,
938+
capture_output=True,
939+
text=True,
940+
)
941+
942+
plan = json.loads(result.stdout)
943+
assert plan["canonical_profile"] == "us_equity_combo_leveraged"
944+
assert plan["profile_group"] == "direct_runtime_inputs"
945+
assert plan["input_mode"] == "market_data"
946+
assert plan["requires_strategy_config_path"] is False
947+
assert plan["config_source_policy"] == "env_only"
948+
assert "IBKR_STRATEGY_CONFIG_PATH" in plan["optional_env"]
949+
assert "IBKR_STRATEGY_CONFIG_PATH" not in plan["remove_if_present"]
950+
assert "IBKR_FEATURE_SNAPSHOT_PATH" in plan["remove_if_present"]
951+
assert plan["hints"]["shadow_352045_strategy_config_path"].startswith(
952+
"package://us_equity_strategies/"
953+
)
954+
955+
930956
def test_print_strategy_switch_env_plan_for_hk_global_etf_dry_run():
931957
result = subprocess.run(
932958
[

tests/test_strategy_runtime.py

Lines changed: 111 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -321,6 +321,117 @@ def evaluate(self, ctx):
321321
assert runtime.status_icon == "🧲"
322322

323323

324+
def test_load_strategy_runtime_loads_explicit_config_for_market_data_profile(monkeypatch):
325+
class FakeEntrypoint:
326+
manifest = StrategyManifest(
327+
profile="us_equity_combo_leveraged",
328+
domain="quant_combo",
329+
display_name="US Equity Combo Leveraged",
330+
description="test",
331+
required_inputs=frozenset({"market_data"}),
332+
default_config={"dynamic": True},
333+
)
334+
335+
def evaluate(self, ctx):
336+
return StrategyDecision()
337+
338+
monkeypatch.setattr(
339+
strategy_runtime_module,
340+
"load_strategy_definition",
341+
lambda raw_profile: SimpleNamespace(profile="us_equity_combo_leveraged"),
342+
)
343+
monkeypatch.setattr(
344+
strategy_runtime_module,
345+
"load_strategy_entrypoint_for_profile",
346+
lambda raw_profile: FakeEntrypoint(),
347+
)
348+
monkeypatch.setattr(
349+
strategy_runtime_module,
350+
"load_strategy_runtime_adapter_for_profile",
351+
lambda raw_profile: StrategyRuntimeAdapter(
352+
runtime_parameter_loader=lambda **_kwargs: {
353+
"tqqq_weight": 0.35,
354+
"soxl_weight": 0.20,
355+
"boxx_weight": 0.45,
356+
"hard_defense_risk_exposure": 0.0,
357+
},
358+
),
359+
)
360+
361+
runtime = strategy_runtime_module.load_strategy_runtime(
362+
"us_equity_combo_leveraged",
363+
runtime_settings=_build_runtime_settings(profile="us_equity_combo_leveraged"),
364+
logger=lambda _message: None,
365+
)
366+
367+
assert runtime.runtime_config["tqqq_weight"] == 0.35
368+
assert runtime.runtime_config["hard_defense_risk_exposure"] == 0.0
369+
assert runtime.merged_runtime_config["dynamic"] is True
370+
assert runtime.merged_runtime_config["boxx_weight"] == 0.45
371+
372+
373+
def test_direct_market_data_strategy_builds_spy_ma200_context():
374+
captured = {}
375+
376+
class FakeEntrypoint:
377+
manifest = StrategyManifest(
378+
profile="us_equity_combo_leveraged",
379+
domain="quant_combo",
380+
display_name="US Equity Combo Leveraged",
381+
description="test",
382+
required_inputs=frozenset({"market_data"}),
383+
default_config={},
384+
)
385+
386+
def evaluate(self, ctx):
387+
captured["market_data"] = dict(ctx.market_data)
388+
captured["runtime_config"] = dict(ctx.runtime_config)
389+
return StrategyDecision(
390+
positions=(
391+
PositionTarget(symbol="TQQQ", target_weight=0.35),
392+
PositionTarget(symbol="SOXL", target_weight=0.20),
393+
PositionTarget(symbol="BOXX", target_weight=0.45),
394+
)
395+
)
396+
397+
runtime = strategy_runtime_module.LoadedStrategyRuntime(
398+
entrypoint=FakeEntrypoint(),
399+
runtime_settings=_build_runtime_settings(profile="us_equity_combo_leveraged"),
400+
runtime_adapter=StrategyRuntimeAdapter(
401+
status_icon="🇺🇸",
402+
available_inputs=frozenset({"market_data"}),
403+
runtime_policy=StrategyRuntimePolicy(signal_effective_after_trading_days=0),
404+
),
405+
runtime_config={"market_data_ma_window": 200},
406+
merged_runtime_config={"market_data_ma_window": 200},
407+
status_icon="🇺🇸",
408+
logger=lambda _message: None,
409+
)
410+
411+
def fake_close_loader(_ib, symbol, **_kwargs):
412+
if symbol == "SPY":
413+
return strategy_runtime_module.pd.Series([100.0] * 200 + [120.0])
414+
return strategy_runtime_module.pd.Series([10.0] * 201)
415+
416+
result = runtime.evaluate(
417+
ib=None,
418+
current_holdings=set(),
419+
historical_close_loader=fake_close_loader,
420+
historical_candle_loader=None,
421+
run_as_of=strategy_runtime_module.pd.Timestamp("2026-07-02"),
422+
translator=lambda value, **_kwargs: value,
423+
pacing_sec=0.0,
424+
)
425+
426+
market_data = captured["market_data"]["market_data"]
427+
assert market_data["spy_above_ma200"] is True
428+
assert market_data["trend_symbol"] == "SPY"
429+
assert market_data["trend_ma_window"] == 200
430+
assert captured["runtime_config"]["market_data_ma_window"] == 200
431+
assert result.metadata["managed_symbols"] == ("TQQQ", "SOXL", "BOXX")
432+
assert result.metadata["safe_haven_symbol"] == "BOXX"
433+
434+
324435
def test_dca_overrides_apply_to_runtime_config():
325436
settings = _build_runtime_settings(
326437
profile="nasdaq_sp500_smart_dca",

0 commit comments

Comments
 (0)