Skip to content
Merged
Show file tree
Hide file tree
Changes from 1 commit
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
25 changes: 25 additions & 0 deletions .github/workflows/manual-strategy-switch.yml
Original file line number Diff line number Diff line change
Expand Up @@ -407,6 +407,31 @@ jobs:
print(f"- `{assignment['name']}` = `{assignment['value']}`")
PY

- name: Preflight IBKR deployment plan
if: env.PLATFORM == 'ibkr' && env.SERVICE_TARGETS_MODE != 'off'
Comment on lines +410 to +411

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Badge Preflight IBKR switches in service-targets-off mode

When service_targets_mode=off is used against an IBKR repository that has no existing CLOUD_RUN_SERVICE_TARGETS_JSON, the earlier bypass guard permits the operation, but this condition skips the deployment planner entirely and apply=true proceeds directly to GitHub variable writes. This leaves the supported single-service/no-inventory path outside the new fail-closed contract and can publish live settings that the IBKR planner would reject; either synthesize a candidate inventory for this path or reject IBKR writes when no planner can run.

Useful? React with 👍 / 👎.

env:
TARGET_REPOSITORY: ${{ steps.platform.outputs.repository }}
run: |
set -euo pipefail
platform_root="${RUNNER_TEMP}/interactive-brokers-platform"
repository_variables_file="${RUNNER_TEMP}/ibkr-repository-variables.json"
trap 'rm -f "${repository_variables_file}"' EXIT

gh auth setup-git
git clone --quiet --depth 1 --branch main \
"https://github.com/${TARGET_REPOSITORY}.git" \
"${platform_root}"
python3 -m pip install --quiet uv
uv sync --frozen --no-dev --quiet --project "${platform_root}"
gh variable list \
--repo "${TARGET_REPOSITORY}" \
--json name,value \
> "${repository_variables_file}"
Comment on lines +426 to +429

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Merge environment-scoped variables into the IBKR preflight

When an IBKR switch uses the supported variable_scope=environment path, the earlier inventory step reads CLOUD_RUN_SERVICE_TARGETS_JSON from that GitHub Environment, but this step fetches only repository variables. _candidate_environment therefore runs the planner without any unchanged environment-scoped settings, so it can reject a valid deployment or approve a state different from the one the sync workflow will receive. Fetch the selected environment's variables as well and merge them over repository variables before applying the candidate assignments.

Useful? React with 👍 / 👎.

python3 python/scripts/preflight_ibkr_switch.py \
--target-file "${TARGET_FILE}" \
--platform-root "${platform_root}" \
--repository-variables-file "${repository_variables_file}"

- name: Apply GitHub variable updates
if: env.APPLY_SWITCH == 'true'
run: python3 python/scripts/runtime_settings.py apply "${TARGET_FILE}" --yes
Expand Down
48 changes: 33 additions & 15 deletions platform-config.json
Original file line number Diff line number Diff line change
Expand Up @@ -62,27 +62,27 @@
"profiles": {
"us_daily": {
"timezone": "America/New_York",
"main_time": "45 15 * * *",
"probe_time": "35 9,15 * * *",
"precheck_time": "45 9 * * *"
"main_time": "45 15 * * 1-5",
"probe_time": "35 9,15 * * 1-5",
"precheck_time": "45 9 * * 1-5"
},
"us_dca_month_end": {
"timezone": "America/New_York",
"main_time": "45 15 25-29 * *",
"probe_time": "35 9,15 25-29 * *",
"precheck_time": "45 9 25-29 * *"
"main_time": "45 15 * * 1-5",
"probe_time": "35 9,15 * * 1-5",
"precheck_time": "45 9 * * 1-5"
},
"us_snapshot_month_start": {
"timezone": "America/New_York",
"main_time": "45 15 1-7 * *",
"probe_time": "35 9,15 1-7 * *",
"precheck_time": "45 9 1-7 * *"
"main_time": "45 15 * * 1-5",
"probe_time": "35 9,15 * * 1-5",
"precheck_time": "45 9 * * 1-5"
},
"hk_daily": {
"timezone": "Asia/Hong_Kong",
"main_time": "45 15 * * *",
"probe_time": "35 9,15 * * *",
"precheck_time": "45 9 * * *"
"main_time": "45 15 * * 1-5",
"probe_time": "35 9,15 * * 1-5",
"precheck_time": "45 9 * * 1-5"
},
"hk_snapshot_month_start": {
"timezone": "Asia/Hong_Kong",
Expand Down Expand Up @@ -501,6 +501,13 @@
"dry_run"
],
"blocked_live_reason": "",
"runtime_artifacts": {
"feature_snapshot": {
"required": true,
"path": "gs://qsl-runtime-logs-shared/strategy-artifacts/us_equity/global_etf_rotation/global_etf_rotation_feature_snapshot_latest.csv",
"manifest_path": "gs://qsl-runtime-logs-shared/strategy-artifacts/us_equity/global_etf_rotation/global_etf_rotation_feature_snapshot_latest.csv.manifest.json"
}
},
"features": {
"income_layer": true,
"option_overlay": true,
Expand Down Expand Up @@ -541,6 +548,13 @@
"dry_run"
],
"blocked_live_reason": "",
"runtime_artifacts": {
"feature_snapshot": {
"required": true,
"path": "gs://qsl-runtime-logs-shared/strategy-artifacts/us_equity/russell_top50_leader_rotation_staging/russell_top50_leader_rotation_feature_snapshot_latest.csv",
"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"
}
},
"features": {
"income_layer": true,
"option_overlay": true,
Expand Down Expand Up @@ -691,13 +705,17 @@
"scheduler_profile": "hk_snapshot_month_start",
"runtime_enabled": true,
"lifecycle_stage": "runtime_enabled",
"can_switch_live": true,
"can_switch_live": false,
"allowed_execution_modes": [
"live",
"paper",
"dry_run"
Comment on lines 709 to 711

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Make the paper snapshot profile switchable from the console

The catalog exposes hk_low_vol_dividend_quality_snapshot as paper-capable, so the console permits that selection, but its generated switch inputs have no snapshot-path controls and therefore omit both artifact variables. Because the same profile is marked feature_snapshot.required=true without catalog paths, build_runtime_switch.py rejects every such console dispatch with requires feature snapshot path and manifest path. Supply a configured paper artifact, add console inputs for the pair, or stop advertising paper mode until the route exists.

Useful? React with 👍 / 👎.

],
"blocked_live_reason": "",
"blocked_live_reason": "production_snapshot_artifact_route_not_configured",
"runtime_artifacts": {
"feature_snapshot": {
"required": true
}
},
"features": {
"income_layer": false,
"option_overlay": false,
Expand Down
58 changes: 58 additions & 0 deletions python/scripts/build_config.py
Original file line number Diff line number Diff line change
Expand Up @@ -39,6 +39,7 @@
}
SCHEDULER_FIELDS = {"timezone", "main_time", "probe_time", "precheck_time"}
MARKET_FIELDS = {"market", "market_calendar", "market_timezone"}
FEATURE_SNAPSHOT_FIELDS = {"required", "path", "manifest_path"}
RUNTIME_MODELS = {"cloud_run", "oracle_vps_self_hosted", "not_configured"}
SETTINGS_ACTIVATION_MODES = {
"cloud_run_sync_workflow",
Expand Down Expand Up @@ -79,6 +80,13 @@ def validate(config: dict) -> list[str]:
errors.append(
f"scheduler profile {profile}: {field} must have 2 time fields or 5 cron fields"
)
continue
if profile.startswith("us_"):
cron = value.split()
if len(cron) != 5 or cron[2] != "*" or cron[4] != "1-5":
errors.append(
f"scheduler profile {profile}: {field} must be Mon-Fri cron with day-of-month '*'"
)
for pid, pdata in config.get("platforms", {}).items():
if "capabilities" not in pdata:
errors.append(f"platform {pid}: missing capabilities")
Expand Down Expand Up @@ -220,6 +228,56 @@ def validate(config: dict) -> list[str]:
f"{scheduler_timezone!r} must match market_timezone "
f"{market_timezone!r}"
)
runtime_artifacts = sdata.get("runtime_artifacts")
if runtime_artifacts is None:
continue
if not isinstance(runtime_artifacts, dict):
errors.append(f"strategy {sid}: runtime_artifacts must be an object")
continue
unsupported_artifacts = sorted(set(runtime_artifacts) - {"feature_snapshot"})
if unsupported_artifacts:
errors.append(
f"strategy {sid}: unsupported runtime_artifacts {unsupported_artifacts}"
)
feature_snapshot = runtime_artifacts.get("feature_snapshot")
if feature_snapshot is None:
continue
if not isinstance(feature_snapshot, dict):
errors.append(
f"strategy {sid}: runtime_artifacts.feature_snapshot must be an object"
)
continue
unsupported_fields = sorted(set(feature_snapshot) - FEATURE_SNAPSHOT_FIELDS)
if unsupported_fields:
errors.append(
f"strategy {sid}: unsupported feature snapshot fields {unsupported_fields}"
)
required = feature_snapshot.get("required")
if not isinstance(required, bool):
errors.append(
f"strategy {sid}: runtime_artifacts.feature_snapshot.required must be boolean"
)
snapshot_path = feature_snapshot.get("path")
manifest_path = feature_snapshot.get("manifest_path")
for field, value in (("path", snapshot_path), ("manifest_path", manifest_path)):
if value is not None and (
not isinstance(value, str) or not value.startswith("gs://")
):
errors.append(
f"strategy {sid}: runtime_artifacts.feature_snapshot.{field} must be a gs:// URI"
)
has_snapshot_path = isinstance(snapshot_path, str) and bool(snapshot_path.strip())
has_manifest_path = isinstance(manifest_path, str) and bool(manifest_path.strip())
if has_snapshot_path != has_manifest_path:
errors.append(
f"strategy {sid}: feature snapshot path and manifest_path must be configured together"
)
if required is True and sdata.get("can_switch_live") is True and not (
has_snapshot_path and has_manifest_path
):
errors.append(
f"strategy {sid}: live feature snapshot requires path and manifest_path"
)
return errors


Expand Down
70 changes: 70 additions & 0 deletions python/scripts/build_runtime_switch.py
Original file line number Diff line number Diff line change
Expand Up @@ -64,6 +64,18 @@
"ibkr": "IBKR_CASH_ONLY_EXECUTION",
"firstrade": "FIRSTRADE_CASH_ONLY_EXECUTION",
}
PLATFORM_FEATURE_SNAPSHOT_VARIABLES = {
"schwab": ("SCHWAB_FEATURE_SNAPSHOT_PATH", "SCHWAB_FEATURE_SNAPSHOT_MANIFEST_PATH"),
"longbridge": (
"LONGBRIDGE_FEATURE_SNAPSHOT_PATH",
"LONGBRIDGE_FEATURE_SNAPSHOT_MANIFEST_PATH",
),
"ibkr": ("IBKR_FEATURE_SNAPSHOT_PATH", "IBKR_FEATURE_SNAPSHOT_MANIFEST_PATH"),
"firstrade": (
"FIRSTRADE_FEATURE_SNAPSHOT_PATH",
"FIRSTRADE_FEATURE_SNAPSHOT_MANIFEST_PATH",
),
}
INCOME_LAYER_VARIABLES = (
"INCOME_LAYER_ENABLED",
"INCOME_LAYER_START_USD",
Expand Down Expand Up @@ -747,6 +759,57 @@ def _market_plan_for_strategy(strategy_profile: str) -> dict[str, str]:
return market


def _feature_snapshot_extra_variables(
platform: str,
strategy_profile: str,
extra_variables: dict[str, Any],
) -> dict[str, str]:
variable_names = PLATFORM_FEATURE_SNAPSHOT_VARIABLES.get(platform)
if not variable_names:
return {}
snapshot_variable, manifest_variable = variable_names

config = _load_platform_config()
strategies = config.get("strategies")
strategy = strategies.get(strategy_profile) if isinstance(strategies, dict) else None
if not isinstance(strategy, dict):
raise ValueError(f"strategy {strategy_profile!r} is missing from the runtime artifact catalog")
runtime_artifacts = strategy.get("runtime_artifacts") or {}
if not isinstance(runtime_artifacts, dict):
raise ValueError(f"strategy {strategy_profile!r} runtime_artifacts must be an object")
feature_snapshot = runtime_artifacts.get("feature_snapshot") or {}
if not isinstance(feature_snapshot, dict):
raise ValueError(
f"strategy {strategy_profile!r} runtime_artifacts.feature_snapshot must be an object"
)

explicit = snapshot_variable in extra_variables or manifest_variable in extra_variables
if explicit:
snapshot_path = extra_variables.get(snapshot_variable)
manifest_path = extra_variables.get(manifest_variable)
else:
snapshot_path = feature_snapshot.get("path")
manifest_path = feature_snapshot.get("manifest_path")
snapshot_path = snapshot_path.strip() if isinstance(snapshot_path, str) else ""
manifest_path = manifest_path.strip() if isinstance(manifest_path, str) else ""
if explicit and not feature_snapshot and (snapshot_path or manifest_path):
raise ValueError(
f"strategy {strategy_profile!r} does not accept feature snapshot artifacts"
)
if bool(snapshot_path) != bool(manifest_path):
Comment on lines +793 to +799

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Validate explicit snapshot overrides as GCS URIs

For a snapshot strategy, explicit values such as /tmp/snapshot.csv and /tmp/snapshot.csv.manifest.json pass this path because only presence is checked. This bypasses the new catalog contract in build_config.validate, which requires snapshot artifacts to be gs:// URIs, and non-IBKR platforms such as LongBridge have no planner preflight to catch the invalid route before it is written. Apply the same URI validation to explicit overrides.

Useful? React with 👍 / 👎.

raise ValueError(
f"strategy {strategy_profile!r} feature snapshot path and manifest path must be configured together"
)
if feature_snapshot.get("required") is True and not (snapshot_path and manifest_path):
raise ValueError(
f"strategy {strategy_profile!r} requires feature snapshot path and manifest path"
)
return {
snapshot_variable: snapshot_path,
manifest_variable: manifest_path,
}


def _build_runtime_target(args: argparse.Namespace) -> dict[str, Any]:
platform = _normalize_platform(args.platform)
target_name = _normalize_target_name(args.target_name)
Expand Down Expand Up @@ -903,6 +966,13 @@ def build_switch_target(args: argparse.Namespace) -> dict[str, Any]:
_reject_direct_dca_extra_variables(extra_variables)
_reject_direct_ibit_zscore_exit_extra_variables(extra_variables)
_reject_research_only_extra_variables(extra_variables)
extra_variables.update(
_feature_snapshot_extra_variables(
platform,
runtime_target["strategy_profile"],
extra_variables,
)
)

if args.set_platform_dry_run_variable:
extra_variables[PLATFORM_DRY_RUN_VARIABLES[platform]] = env_string(runtime_target["dry_run_only"])
Expand Down
Loading
Loading