Skip to content

Commit e6df24c

Browse files
Pigbibicodex
andcommitted
fix: harden IBKR strategy switch contract
Co-Authored-By: Codex <noreply@openai.com>
1 parent cf59986 commit e6df24c

11 files changed

Lines changed: 709 additions & 29 deletions

.github/workflows/manual-strategy-switch.yml

Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -407,6 +407,31 @@ jobs:
407407
print(f"- `{assignment['name']}` = `{assignment['value']}`")
408408
PY
409409
410+
- name: Preflight IBKR deployment plan
411+
if: env.PLATFORM == 'ibkr' && env.SERVICE_TARGETS_MODE != 'off'
412+
env:
413+
TARGET_REPOSITORY: ${{ steps.platform.outputs.repository }}
414+
run: |
415+
set -euo pipefail
416+
platform_root="${RUNNER_TEMP}/interactive-brokers-platform"
417+
repository_variables_file="${RUNNER_TEMP}/ibkr-repository-variables.json"
418+
trap 'rm -f "${repository_variables_file}"' EXIT
419+
420+
gh auth setup-git
421+
git clone --quiet --depth 1 --branch main \
422+
"https://github.com/${TARGET_REPOSITORY}.git" \
423+
"${platform_root}"
424+
python3 -m pip install --quiet uv
425+
uv sync --frozen --no-dev --quiet --project "${platform_root}"
426+
gh variable list \
427+
--repo "${TARGET_REPOSITORY}" \
428+
--json name,value \
429+
> "${repository_variables_file}"
430+
python3 python/scripts/preflight_ibkr_switch.py \
431+
--target-file "${TARGET_FILE}" \
432+
--platform-root "${platform_root}" \
433+
--repository-variables-file "${repository_variables_file}"
434+
410435
- name: Apply GitHub variable updates
411436
if: env.APPLY_SWITCH == 'true'
412437
run: python3 python/scripts/runtime_settings.py apply "${TARGET_FILE}" --yes

platform-config.json

Lines changed: 33 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -62,27 +62,27 @@
6262
"profiles": {
6363
"us_daily": {
6464
"timezone": "America/New_York",
65-
"main_time": "45 15 * * *",
66-
"probe_time": "35 9,15 * * *",
67-
"precheck_time": "45 9 * * *"
65+
"main_time": "45 15 * * 1-5",
66+
"probe_time": "35 9,15 * * 1-5",
67+
"precheck_time": "45 9 * * 1-5"
6868
},
6969
"us_dca_month_end": {
7070
"timezone": "America/New_York",
71-
"main_time": "45 15 25-29 * *",
72-
"probe_time": "35 9,15 25-29 * *",
73-
"precheck_time": "45 9 25-29 * *"
71+
"main_time": "45 15 * * 1-5",
72+
"probe_time": "35 9,15 * * 1-5",
73+
"precheck_time": "45 9 * * 1-5"
7474
},
7575
"us_snapshot_month_start": {
7676
"timezone": "America/New_York",
77-
"main_time": "45 15 1-7 * *",
78-
"probe_time": "35 9,15 1-7 * *",
79-
"precheck_time": "45 9 1-7 * *"
77+
"main_time": "45 15 * * 1-5",
78+
"probe_time": "35 9,15 * * 1-5",
79+
"precheck_time": "45 9 * * 1-5"
8080
},
8181
"hk_daily": {
8282
"timezone": "Asia/Hong_Kong",
83-
"main_time": "45 15 * * *",
84-
"probe_time": "35 9,15 * * *",
85-
"precheck_time": "45 9 * * *"
83+
"main_time": "45 15 * * 1-5",
84+
"probe_time": "35 9,15 * * 1-5",
85+
"precheck_time": "45 9 * * 1-5"
8686
},
8787
"hk_snapshot_month_start": {
8888
"timezone": "Asia/Hong_Kong",
@@ -501,6 +501,13 @@
501501
"dry_run"
502502
],
503503
"blocked_live_reason": "",
504+
"runtime_artifacts": {
505+
"feature_snapshot": {
506+
"required": true,
507+
"path": "gs://qsl-runtime-logs-shared/strategy-artifacts/us_equity/global_etf_rotation/global_etf_rotation_feature_snapshot_latest.csv",
508+
"manifest_path": "gs://qsl-runtime-logs-shared/strategy-artifacts/us_equity/global_etf_rotation/global_etf_rotation_feature_snapshot_latest.csv.manifest.json"
509+
}
510+
},
504511
"features": {
505512
"income_layer": true,
506513
"option_overlay": true,
@@ -541,6 +548,13 @@
541548
"dry_run"
542549
],
543550
"blocked_live_reason": "",
551+
"runtime_artifacts": {
552+
"feature_snapshot": {
553+
"required": true,
554+
"path": "gs://qsl-runtime-logs-shared/strategy-artifacts/us_equity/russell_top50_leader_rotation_staging/russell_top50_leader_rotation_feature_snapshot_latest.csv",
555+
"manifest_path": "gs://qsl-runtime-logs-shared/strategy-artifacts/us_equity/russell_top50_leader_rotation_staging/russell_top50_leader_rotation_feature_snapshot_latest.csv.manifest.json"
556+
}
557+
},
544558
"features": {
545559
"income_layer": true,
546560
"option_overlay": true,
@@ -691,13 +705,17 @@
691705
"scheduler_profile": "hk_snapshot_month_start",
692706
"runtime_enabled": true,
693707
"lifecycle_stage": "runtime_enabled",
694-
"can_switch_live": true,
708+
"can_switch_live": false,
695709
"allowed_execution_modes": [
696-
"live",
697710
"paper",
698711
"dry_run"
699712
],
700-
"blocked_live_reason": "",
713+
"blocked_live_reason": "production_snapshot_artifact_route_not_configured",
714+
"runtime_artifacts": {
715+
"feature_snapshot": {
716+
"required": true
717+
}
718+
},
701719
"features": {
702720
"income_layer": false,
703721
"option_overlay": false,

python/scripts/build_config.py

Lines changed: 58 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -39,6 +39,7 @@
3939
}
4040
SCHEDULER_FIELDS = {"timezone", "main_time", "probe_time", "precheck_time"}
4141
MARKET_FIELDS = {"market", "market_calendar", "market_timezone"}
42+
FEATURE_SNAPSHOT_FIELDS = {"required", "path", "manifest_path"}
4243
RUNTIME_MODELS = {"cloud_run", "oracle_vps_self_hosted", "not_configured"}
4344
SETTINGS_ACTIVATION_MODES = {
4445
"cloud_run_sync_workflow",
@@ -79,6 +80,13 @@ def validate(config: dict) -> list[str]:
7980
errors.append(
8081
f"scheduler profile {profile}: {field} must have 2 time fields or 5 cron fields"
8182
)
83+
continue
84+
if profile.startswith("us_"):
85+
cron = value.split()
86+
if len(cron) != 5 or cron[2] != "*" or cron[4] != "1-5":
87+
errors.append(
88+
f"scheduler profile {profile}: {field} must be Mon-Fri cron with day-of-month '*'"
89+
)
8290
for pid, pdata in config.get("platforms", {}).items():
8391
if "capabilities" not in pdata:
8492
errors.append(f"platform {pid}: missing capabilities")
@@ -220,6 +228,56 @@ def validate(config: dict) -> list[str]:
220228
f"{scheduler_timezone!r} must match market_timezone "
221229
f"{market_timezone!r}"
222230
)
231+
runtime_artifacts = sdata.get("runtime_artifacts")
232+
if runtime_artifacts is None:
233+
continue
234+
if not isinstance(runtime_artifacts, dict):
235+
errors.append(f"strategy {sid}: runtime_artifacts must be an object")
236+
continue
237+
unsupported_artifacts = sorted(set(runtime_artifacts) - {"feature_snapshot"})
238+
if unsupported_artifacts:
239+
errors.append(
240+
f"strategy {sid}: unsupported runtime_artifacts {unsupported_artifacts}"
241+
)
242+
feature_snapshot = runtime_artifacts.get("feature_snapshot")
243+
if feature_snapshot is None:
244+
continue
245+
if not isinstance(feature_snapshot, dict):
246+
errors.append(
247+
f"strategy {sid}: runtime_artifacts.feature_snapshot must be an object"
248+
)
249+
continue
250+
unsupported_fields = sorted(set(feature_snapshot) - FEATURE_SNAPSHOT_FIELDS)
251+
if unsupported_fields:
252+
errors.append(
253+
f"strategy {sid}: unsupported feature snapshot fields {unsupported_fields}"
254+
)
255+
required = feature_snapshot.get("required")
256+
if not isinstance(required, bool):
257+
errors.append(
258+
f"strategy {sid}: runtime_artifacts.feature_snapshot.required must be boolean"
259+
)
260+
snapshot_path = feature_snapshot.get("path")
261+
manifest_path = feature_snapshot.get("manifest_path")
262+
for field, value in (("path", snapshot_path), ("manifest_path", manifest_path)):
263+
if value is not None and (
264+
not isinstance(value, str) or not value.startswith("gs://")
265+
):
266+
errors.append(
267+
f"strategy {sid}: runtime_artifacts.feature_snapshot.{field} must be a gs:// URI"
268+
)
269+
has_snapshot_path = isinstance(snapshot_path, str) and bool(snapshot_path.strip())
270+
has_manifest_path = isinstance(manifest_path, str) and bool(manifest_path.strip())
271+
if has_snapshot_path != has_manifest_path:
272+
errors.append(
273+
f"strategy {sid}: feature snapshot path and manifest_path must be configured together"
274+
)
275+
if required is True and sdata.get("can_switch_live") is True and not (
276+
has_snapshot_path and has_manifest_path
277+
):
278+
errors.append(
279+
f"strategy {sid}: live feature snapshot requires path and manifest_path"
280+
)
223281
return errors
224282

225283

python/scripts/build_runtime_switch.py

Lines changed: 70 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -64,6 +64,18 @@
6464
"ibkr": "IBKR_CASH_ONLY_EXECUTION",
6565
"firstrade": "FIRSTRADE_CASH_ONLY_EXECUTION",
6666
}
67+
PLATFORM_FEATURE_SNAPSHOT_VARIABLES = {
68+
"schwab": ("SCHWAB_FEATURE_SNAPSHOT_PATH", "SCHWAB_FEATURE_SNAPSHOT_MANIFEST_PATH"),
69+
"longbridge": (
70+
"LONGBRIDGE_FEATURE_SNAPSHOT_PATH",
71+
"LONGBRIDGE_FEATURE_SNAPSHOT_MANIFEST_PATH",
72+
),
73+
"ibkr": ("IBKR_FEATURE_SNAPSHOT_PATH", "IBKR_FEATURE_SNAPSHOT_MANIFEST_PATH"),
74+
"firstrade": (
75+
"FIRSTRADE_FEATURE_SNAPSHOT_PATH",
76+
"FIRSTRADE_FEATURE_SNAPSHOT_MANIFEST_PATH",
77+
),
78+
}
6779
INCOME_LAYER_VARIABLES = (
6880
"INCOME_LAYER_ENABLED",
6981
"INCOME_LAYER_START_USD",
@@ -747,6 +759,57 @@ def _market_plan_for_strategy(strategy_profile: str) -> dict[str, str]:
747759
return market
748760

749761

762+
def _feature_snapshot_extra_variables(
763+
platform: str,
764+
strategy_profile: str,
765+
extra_variables: dict[str, Any],
766+
) -> dict[str, str]:
767+
variable_names = PLATFORM_FEATURE_SNAPSHOT_VARIABLES.get(platform)
768+
if not variable_names:
769+
return {}
770+
snapshot_variable, manifest_variable = variable_names
771+
772+
config = _load_platform_config()
773+
strategies = config.get("strategies")
774+
strategy = strategies.get(strategy_profile) if isinstance(strategies, dict) else None
775+
if not isinstance(strategy, dict):
776+
raise ValueError(f"strategy {strategy_profile!r} is missing from the runtime artifact catalog")
777+
runtime_artifacts = strategy.get("runtime_artifacts") or {}
778+
if not isinstance(runtime_artifacts, dict):
779+
raise ValueError(f"strategy {strategy_profile!r} runtime_artifacts must be an object")
780+
feature_snapshot = runtime_artifacts.get("feature_snapshot") or {}
781+
if not isinstance(feature_snapshot, dict):
782+
raise ValueError(
783+
f"strategy {strategy_profile!r} runtime_artifacts.feature_snapshot must be an object"
784+
)
785+
786+
explicit = snapshot_variable in extra_variables or manifest_variable in extra_variables
787+
if explicit:
788+
snapshot_path = extra_variables.get(snapshot_variable)
789+
manifest_path = extra_variables.get(manifest_variable)
790+
else:
791+
snapshot_path = feature_snapshot.get("path")
792+
manifest_path = feature_snapshot.get("manifest_path")
793+
snapshot_path = snapshot_path.strip() if isinstance(snapshot_path, str) else ""
794+
manifest_path = manifest_path.strip() if isinstance(manifest_path, str) else ""
795+
if explicit and not feature_snapshot and (snapshot_path or manifest_path):
796+
raise ValueError(
797+
f"strategy {strategy_profile!r} does not accept feature snapshot artifacts"
798+
)
799+
if bool(snapshot_path) != bool(manifest_path):
800+
raise ValueError(
801+
f"strategy {strategy_profile!r} feature snapshot path and manifest path must be configured together"
802+
)
803+
if feature_snapshot.get("required") is True and not (snapshot_path and manifest_path):
804+
raise ValueError(
805+
f"strategy {strategy_profile!r} requires feature snapshot path and manifest path"
806+
)
807+
return {
808+
snapshot_variable: snapshot_path,
809+
manifest_variable: manifest_path,
810+
}
811+
812+
750813
def _build_runtime_target(args: argparse.Namespace) -> dict[str, Any]:
751814
platform = _normalize_platform(args.platform)
752815
target_name = _normalize_target_name(args.target_name)
@@ -903,6 +966,13 @@ def build_switch_target(args: argparse.Namespace) -> dict[str, Any]:
903966
_reject_direct_dca_extra_variables(extra_variables)
904967
_reject_direct_ibit_zscore_exit_extra_variables(extra_variables)
905968
_reject_research_only_extra_variables(extra_variables)
969+
extra_variables.update(
970+
_feature_snapshot_extra_variables(
971+
platform,
972+
runtime_target["strategy_profile"],
973+
extra_variables,
974+
)
975+
)
906976

907977
if args.set_platform_dry_run_variable:
908978
extra_variables[PLATFORM_DRY_RUN_VARIABLES[platform]] = env_string(runtime_target["dry_run_only"])

0 commit comments

Comments
 (0)