Manual Strategy Switch #75
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| name: Manual Strategy Switch | |
| on: | |
| workflow_dispatch: | |
| inputs: | |
| platform: | |
| description: "Target platform." | |
| required: true | |
| type: choice | |
| options: | |
| - longbridge | |
| - ibkr | |
| - schwab | |
| - firstrade | |
| - qmt | |
| - binance | |
| target_name: | |
| description: "Target name, e.g. sg, live, live-u1599-tqqq." | |
| required: true | |
| type: string | |
| strategy_profile: | |
| description: "Canonical strategy profile to switch to." | |
| required: true | |
| type: string | |
| execution_mode: | |
| description: "live is gated; dry_run is the universal no-order path. paper is retained only as a legacy alias for dry_run, not P4 paper trading." | |
| required: true | |
| type: choice | |
| default: live | |
| options: | |
| - live | |
| - dry_run | |
| - paper | |
| live_continuity_state: | |
| description: "Optional frozen legacy live baseline state. This cannot create a new live permission or increase risk." | |
| required: true | |
| type: choice | |
| default: NONE | |
| options: | |
| - NONE | |
| - ACTIVE_LKG | |
| - ACTIVE_REDUCED | |
| - RECONCILE_ONLY | |
| - RISK_REDUCTION_ONLY | |
| - PAUSED | |
| - ROLLBACK_LKG | |
| live_continuity_baseline_id: | |
| description: "Required only with a continuity state: audited identifier of the existing authorised baseline." | |
| required: false | |
| type: string | |
| live_continuity_captured_at: | |
| description: "Required only with a continuity state: ISO date when the exact legacy target was captured." | |
| required: false | |
| type: string | |
| variable_scope: | |
| description: "Where GitHub variables are written. blank = platform default." | |
| required: false | |
| type: choice | |
| default: default | |
| options: | |
| - default | |
| - repository | |
| - environment | |
| github_environment: | |
| description: "Environment name when variable_scope=environment. blank = platform default." | |
| required: false | |
| type: string | |
| deployment_selector: | |
| description: "Runtime deployment_selector. blank = derived from target_name." | |
| required: false | |
| type: string | |
| account_selector: | |
| description: "Comma-separated broker account selectors. blank = account_scope." | |
| required: false | |
| type: string | |
| account_scope: | |
| description: "Runtime account_scope/account group. blank = deployment_selector." | |
| required: false | |
| type: string | |
| service_name: | |
| description: "Runtime service name. blank = platform default." | |
| required: false | |
| type: string | |
| plugin_mode: | |
| description: "none disables mounts. current preserves the selected strategy's existing mounts exactly; it cannot add or change a plugin." | |
| required: true | |
| type: choice | |
| default: none | |
| options: | |
| - none | |
| - current | |
| extra_variables_json: | |
| description: "Optional JSON object of non-secret extra variables. DCA profiles may include dca_mode and dca_base_investment_usd control fields. cash_only_execution_mode may be current, enabled, or disabled. Research-only option overlays are rejected." | |
| required: false | |
| type: string | |
| reserved_cash_ratio: | |
| description: "Optional platform reserved-cash ratio override." | |
| required: false | |
| type: string | |
| min_reserved_cash_usd: | |
| description: "Optional platform minimum reserved cash override." | |
| required: false | |
| type: string | |
| income_layer_start_usd: | |
| description: "Optional income layer activation asset amount." | |
| required: false | |
| type: string | |
| income_layer_max_ratio: | |
| description: "Optional income layer maximum allocation ratio." | |
| required: false | |
| type: string | |
| option_overlay_mode: | |
| description: "Option overlay policy: current preserves variables, enabled applies profile defaults, disabled clears it." | |
| required: true | |
| type: choice | |
| default: current | |
| options: | |
| - current | |
| - enabled | |
| - disabled | |
| service_targets_mode: | |
| description: "auto updates an existing multi-service target when configured; allow_create explicitly permits a new target." | |
| required: true | |
| type: choice | |
| default: auto | |
| options: | |
| - auto | |
| - allow_create | |
| - off | |
| apply: | |
| description: "Actually write GitHub variables. false = preview only." | |
| required: true | |
| type: boolean | |
| default: false | |
| trigger_platform_sync: | |
| description: "After apply, dispatch a supported Cloud Run environment sync workflow." | |
| required: true | |
| type: boolean | |
| default: false | |
| confirm_apply: | |
| description: "Required for writes. Use APPLY for variable writes, APPLY_AND_SYNC when trigger_platform_sync=true." | |
| required: false | |
| type: string | |
| platform_sync_workflow: | |
| description: "Cloud Run target platform workflow filename." | |
| required: false | |
| type: string | |
| default: sync-cloud-run-env.yml | |
| permissions: | |
| contents: read | |
| concurrency: | |
| group: runtime-strategy-switch-${{ inputs.platform }}-${{ inputs.target_name }} | |
| cancel-in-progress: false | |
| jobs: | |
| switch: | |
| name: Build and apply runtime switch | |
| runs-on: ubuntu-latest | |
| timeout-minutes: 15 | |
| environment: runtime-strategy-switch | |
| permissions: | |
| contents: read | |
| env: | |
| GH_TOKEN: ${{ secrets.RUNTIME_SETTINGS_GH_TOKEN }} | |
| PLATFORM: ${{ inputs.platform }} | |
| TARGET_NAME: ${{ inputs.target_name }} | |
| STRATEGY_PROFILE: ${{ inputs.strategy_profile }} | |
| EXECUTION_MODE: ${{ inputs.execution_mode }} | |
| LIVE_CONTINUITY_STATE: ${{ inputs.live_continuity_state }} | |
| LIVE_CONTINUITY_BASELINE_ID: ${{ inputs.live_continuity_baseline_id }} | |
| LIVE_CONTINUITY_CAPTURED_AT: ${{ inputs.live_continuity_captured_at }} | |
| VARIABLE_SCOPE: ${{ inputs.variable_scope }} | |
| GITHUB_ENVIRONMENT_NAME: ${{ inputs.github_environment }} | |
| DEPLOYMENT_SELECTOR: ${{ inputs.deployment_selector }} | |
| ACCOUNT_SELECTOR: ${{ inputs.account_selector }} | |
| ACCOUNT_SCOPE: ${{ inputs.account_scope }} | |
| SERVICE_NAME: ${{ inputs.service_name }} | |
| PLUGIN_MODE: ${{ inputs.plugin_mode }} | |
| EXTRA_VARIABLES_JSON: ${{ inputs.extra_variables_json }} | |
| RESERVED_CASH_RATIO: ${{ inputs.reserved_cash_ratio }} | |
| MIN_RESERVED_CASH_USD: ${{ inputs.min_reserved_cash_usd }} | |
| INCOME_LAYER_START_USD: ${{ inputs.income_layer_start_usd }} | |
| INCOME_LAYER_MAX_RATIO: ${{ inputs.income_layer_max_ratio }} | |
| OPTION_OVERLAY_MODE: ${{ inputs.option_overlay_mode }} | |
| SERVICE_TARGETS_MODE: ${{ inputs.service_targets_mode }} | |
| APPLY_SWITCH: ${{ inputs.apply }} | |
| TRIGGER_PLATFORM_SYNC: ${{ inputs.trigger_platform_sync }} | |
| CONFIRM_APPLY: ${{ inputs.confirm_apply }} | |
| PLATFORM_SYNC_WORKFLOW: ${{ inputs.platform_sync_workflow }} | |
| STRATEGY_SWITCH_CONSOLE_URL: ${{ vars.STRATEGY_SWITCH_CONSOLE_URL }} | |
| STRATEGY_SWITCH_SYNC_TOKEN: ${{ secrets.STRATEGY_SWITCH_SYNC_TOKEN || secrets.RUNTIME_SETTINGS_GH_TOKEN }} | |
| RUNTIME_SETTINGS_PLATFORM_REPOSITORIES_JSON: ${{ vars.RUNTIME_SETTINGS_PLATFORM_REPOSITORIES_JSON }} | |
| RUNTIME_SETTINGS_LONGBRIDGE_REPO: ${{ vars.RUNTIME_SETTINGS_LONGBRIDGE_REPO }} | |
| RUNTIME_SETTINGS_IBKR_REPO: ${{ vars.RUNTIME_SETTINGS_IBKR_REPO }} | |
| RUNTIME_SETTINGS_SCHWAB_REPO: ${{ vars.RUNTIME_SETTINGS_SCHWAB_REPO }} | |
| RUNTIME_SETTINGS_FIRSTRADE_REPO: ${{ vars.RUNTIME_SETTINGS_FIRSTRADE_REPO }} | |
| RUNTIME_SETTINGS_QMT_REPO: ${{ vars.RUNTIME_SETTINGS_QMT_REPO }} | |
| RUNTIME_SETTINGS_BINANCE_REPO: ${{ vars.RUNTIME_SETTINGS_BINANCE_REPO }} | |
| steps: | |
| - name: Checkout | |
| uses: actions/checkout@d23441a48e516b6c34aea4fa41551a30e30af803 # v6.1.0 | |
| - name: Enforce write safety gates | |
| run: | | |
| set -euo pipefail | |
| if [ "${APPLY_SWITCH}" != "true" ] && [ "${TRIGGER_PLATFORM_SYNC}" = "true" ]; then | |
| echo "trigger_platform_sync=true is invalid unless apply=true." >&2 | |
| exit 2 | |
| fi | |
| settings_activation="$( | |
| python3 python/scripts/runtime_settings.py settings-activation "${PLATFORM}" | |
| )" | |
| if [ "${TRIGGER_PLATFORM_SYNC}" = "true" ]; then | |
| case "${settings_activation}" in | |
| cloud_run_sync_workflow) | |
| ;; | |
| next_runtime_workflow_dispatch) | |
| echo "Oracle/VPS runtime activates settings on its next externally scheduled GitHub Actions dispatch; trigger_platform_sync is unsupported for ${PLATFORM}." >&2 | |
| exit 2 | |
| ;; | |
| not_wired) | |
| echo "QMT has no live runtime configuration; trigger_platform_sync is unsupported." >&2 | |
| exit 2 | |
| ;; | |
| *) | |
| echo "Unsupported settings activation mode for ${PLATFORM}: ${settings_activation}" >&2 | |
| exit 2 | |
| ;; | |
| esac | |
| fi | |
| if [ "${APPLY_SWITCH}" = "true" ]; then | |
| if [ -z "${GH_TOKEN:-}" ]; then | |
| echo "RUNTIME_SETTINGS_GH_TOKEN is required for apply=true." >&2 | |
| exit 2 | |
| fi | |
| if [ "${TRIGGER_PLATFORM_SYNC}" = "true" ]; then | |
| if [ "${CONFIRM_APPLY:-}" != "APPLY_AND_SYNC" ]; then | |
| echo "Set confirm_apply=APPLY_AND_SYNC when trigger_platform_sync=true." >&2 | |
| exit 2 | |
| fi | |
| elif [ "${CONFIRM_APPLY:-}" != "APPLY" ]; then | |
| echo "Set confirm_apply=APPLY when apply=true." >&2 | |
| exit 2 | |
| fi | |
| fi | |
| case "${PLATFORM}" in | |
| longbridge|ibkr|schwab|firstrade) | |
| if [ "${SERVICE_TARGETS_MODE}" != "off" ] && [ -z "${GH_TOKEN:-}" ]; then | |
| echo "RUNTIME_SETTINGS_GH_TOKEN is required for multi-service preview because the workflow must read and patch CLOUD_RUN_SERVICE_TARGETS_JSON." >&2 | |
| exit 2 | |
| fi | |
| ;; | |
| *) | |
| if [ "${SERVICE_TARGETS_MODE}" = "allow_create" ]; then | |
| echo "service_targets_mode=allow_create is not supported for ${PLATFORM}." >&2 | |
| exit 2 | |
| fi | |
| ;; | |
| esac | |
| - name: Resolve platform repository | |
| id: platform | |
| run: | | |
| set -euo pipefail | |
| repo="$(python3 python/scripts/runtime_settings.py repository "${PLATFORM}")" | |
| echo "repository=${repo}" >> "$GITHUB_OUTPUT" | |
| - name: Fetch existing service targets | |
| if: >- | |
| (env.APPLY_SWITCH == 'true' || env.SERVICE_TARGETS_MODE != 'off') && | |
| contains(fromJSON('["longbridge","ibkr","schwab","firstrade"]'), env.PLATFORM) | |
| env: | |
| TARGET_REPOSITORY: ${{ steps.platform.outputs.repository }} | |
| run: | | |
| set -euo pipefail | |
| output_file="${RUNNER_TEMP}/existing-service-targets.json" | |
| target_environment="" | |
| if [ "${PLATFORM}" = "ibkr" ] && [ "${VARIABLE_SCOPE}" = "environment" ]; then | |
| target_environment="${GITHUB_ENVIRONMENT_NAME:-${TARGET_NAME}}" | |
| fi | |
| python - <<'PY' \ | |
| "${TARGET_REPOSITORY}" \ | |
| "${output_file}" \ | |
| "${target_environment}" \ | |
| "${PLATFORM}" \ | |
| "${SERVICE_TARGETS_MODE}" | |
| import json | |
| import os | |
| import subprocess | |
| import sys | |
| repo, output_path, environment, platform, mode = sys.argv[1:6] | |
| command = ["gh", "variable", "list", "--repo", repo, "--json", "name,value"] | |
| if environment: | |
| command.extend(["--env", environment]) | |
| raw = subprocess.check_output( | |
| command, | |
| text=True, | |
| ) | |
| variables = json.loads(raw) | |
| value = "" | |
| for item in variables: | |
| if item.get("name") == "CLOUD_RUN_SERVICE_TARGETS_JSON": | |
| value = str(item.get("value") or "").strip() | |
| break | |
| if not value: | |
| if (platform == "ibkr" and mode != "off") or mode == "allow_create": | |
| with open(output_path, "w", encoding="utf-8") as handle: | |
| handle.write("{}") | |
| else: | |
| try: | |
| os.unlink(output_path) | |
| except FileNotFoundError: | |
| pass | |
| raise SystemExit(0) | |
| try: | |
| payload = json.loads(value) | |
| except json.JSONDecodeError: | |
| payload = json.loads(value.replace("\\n", "\n")) | |
| with open(output_path, "w", encoding="utf-8") as handle: | |
| json.dump(payload, handle, ensure_ascii=False, separators=(",", ":")) | |
| PY | |
| if [ -f "${output_file}" ]; then | |
| echo "EXISTING_SERVICE_TARGETS_JSON_FILE=${output_file}" >> "$GITHUB_ENV" | |
| fi | |
| - name: Reject unsafe service inventory bypass | |
| if: env.APPLY_SWITCH == 'true' && env.SERVICE_TARGETS_MODE == 'off' | |
| run: | | |
| set -euo pipefail | |
| if [ -f "${EXISTING_SERVICE_TARGETS_JSON_FILE:-}" ]; then | |
| echo "existing CLOUD_RUN_SERVICE_TARGETS_JSON requires service_targets_mode=patch or allow_create" >&2 | |
| exit 2 | |
| fi | |
| - name: Fetch current plugin mounts for continuity | |
| if: env.PLUGIN_MODE == 'current' | |
| env: | |
| TARGET_REPOSITORY: ${{ steps.platform.outputs.repository }} | |
| run: | | |
| set -euo pipefail | |
| case "${PLATFORM}" in | |
| longbridge) mounts_variable="LONGBRIDGE_STRATEGY_PLUGIN_MOUNTS_JSON" ;; | |
| ibkr) mounts_variable="IBKR_STRATEGY_PLUGIN_MOUNTS_JSON" ;; | |
| schwab) mounts_variable="SCHWAB_STRATEGY_PLUGIN_MOUNTS_JSON" ;; | |
| firstrade) mounts_variable="FIRSTRADE_STRATEGY_PLUGIN_MOUNTS_JSON" ;; | |
| qmt) mounts_variable="QMT_STRATEGY_PLUGIN_MOUNTS_JSON" ;; | |
| binance) mounts_variable="BINANCE_STRATEGY_PLUGIN_MOUNTS_JSON" ;; | |
| *) echo "Unsupported platform: ${PLATFORM}" >&2; exit 2 ;; | |
| esac | |
| target_environment="" | |
| if [ "${VARIABLE_SCOPE}" = "environment" ]; then | |
| target_environment="${GITHUB_ENVIRONMENT_NAME:-${TARGET_NAME}}" | |
| elif [ "${VARIABLE_SCOPE}" = "default" ] && [ "${PLATFORM}" = "longbridge" ]; then | |
| target_environment="longbridge-${TARGET_NAME}" | |
| fi | |
| output_file="${RUNNER_TEMP}/current-plugin-mounts.json" | |
| python - <<'PY' "${TARGET_REPOSITORY}" "${mounts_variable}" "${target_environment}" "${output_file}" | |
| import json | |
| import subprocess | |
| import sys | |
| repo, variable, environment, output_path = sys.argv[1:5] | |
| command = ["gh", "variable", "list", "--repo", repo, "--json", "name,value"] | |
| if environment: | |
| command.extend(["--env", environment]) | |
| values = json.loads(subprocess.check_output(command, text=True)) | |
| raw = next((item.get("value") for item in values if item.get("name") == variable), None) | |
| if not isinstance(raw, str) or not raw.strip(): | |
| raise SystemExit(f"{variable} is required when plugin_mode=current") | |
| try: | |
| payload = json.loads(raw) | |
| except json.JSONDecodeError as exc: | |
| raise SystemExit(f"{variable} must contain valid JSON") from exc | |
| if not isinstance(payload, dict) or not isinstance(payload.get("strategy_plugins"), list): | |
| raise SystemExit(f"{variable} must contain a strategy_plugins array") | |
| with open(output_path, "w", encoding="utf-8") as handle: | |
| json.dump(payload, handle, ensure_ascii=False, separators=(",", ":")) | |
| PY | |
| echo "CURRENT_PLUGIN_MOUNTS_JSON_FILE=${output_file}" >> "$GITHUB_ENV" | |
| - name: Build switch target | |
| run: | | |
| set -euo pipefail | |
| target_file="${RUNNER_TEMP}/runtime-switch-target.json" | |
| args=( | |
| --platform "${PLATFORM}" | |
| --target-name "${TARGET_NAME}" | |
| --strategy-profile "${STRATEGY_PROFILE}" | |
| --execution-mode "${EXECUTION_MODE}" | |
| --plugin-mode "${PLUGIN_MODE}" | |
| --option-overlay-mode "${OPTION_OVERLAY_MODE}" | |
| --output "${target_file}" | |
| ) | |
| if [ "${VARIABLE_SCOPE}" != "default" ]; then | |
| args+=(--variable-scope "${VARIABLE_SCOPE}") | |
| fi | |
| if [ -n "${GITHUB_ENVIRONMENT_NAME:-}" ]; then | |
| args+=(--github-environment "${GITHUB_ENVIRONMENT_NAME}") | |
| fi | |
| if [ -n "${DEPLOYMENT_SELECTOR:-}" ]; then | |
| args+=(--deployment-selector "${DEPLOYMENT_SELECTOR}") | |
| fi | |
| if [ -n "${ACCOUNT_SELECTOR:-}" ]; then | |
| args+=(--account-selector "${ACCOUNT_SELECTOR}") | |
| fi | |
| if [ -n "${ACCOUNT_SCOPE:-}" ]; then | |
| args+=(--account-scope "${ACCOUNT_SCOPE}") | |
| fi | |
| if [ -n "${SERVICE_NAME:-}" ]; then | |
| args+=(--service-name "${SERVICE_NAME}") | |
| fi | |
| if [ "${PLUGIN_MODE}" = "current" ]; then | |
| args+=(--current-plugin-mounts-json-file "${CURRENT_PLUGIN_MOUNTS_JSON_FILE}") | |
| fi | |
| if [ "${LIVE_CONTINUITY_STATE}" != "NONE" ]; then | |
| args+=(--live-continuity-state "${LIVE_CONTINUITY_STATE}") | |
| args+=(--live-continuity-baseline-id "${LIVE_CONTINUITY_BASELINE_ID}") | |
| args+=(--live-continuity-captured-at "${LIVE_CONTINUITY_CAPTURED_AT}") | |
| fi | |
| if [ -n "${EXTRA_VARIABLES_JSON:-}" ]; then | |
| args+=(--extra-variables-json "${EXTRA_VARIABLES_JSON}") | |
| fi | |
| if [ -n "${RESERVED_CASH_RATIO:-}" ]; then | |
| args+=(--reserved-cash-ratio "${RESERVED_CASH_RATIO}") | |
| fi | |
| if [ -n "${MIN_RESERVED_CASH_USD:-}" ]; then | |
| args+=(--min-reserved-cash-usd "${MIN_RESERVED_CASH_USD}") | |
| fi | |
| if [ -n "${INCOME_LAYER_START_USD:-}" ]; then | |
| args+=(--income-layer-start-usd "${INCOME_LAYER_START_USD}") | |
| fi | |
| if [ -n "${INCOME_LAYER_MAX_RATIO:-}" ]; then | |
| args+=(--income-layer-max-ratio "${INCOME_LAYER_MAX_RATIO}") | |
| fi | |
| if [ -f "${EXISTING_SERVICE_TARGETS_JSON_FILE:-}" ]; then | |
| args+=(--existing-service-targets-json-file "${EXISTING_SERVICE_TARGETS_JSON_FILE}") | |
| fi | |
| if [ "${SERVICE_TARGETS_MODE}" = "allow_create" ]; then | |
| args+=(--allow-create-service-target) | |
| fi | |
| python3 python/scripts/build_runtime_switch.py "${args[@]}" | |
| python3 python/scripts/runtime_settings.py validate "${target_file}" | |
| echo "TARGET_FILE=${target_file}" >> "$GITHUB_ENV" | |
| - name: Preview assignments | |
| run: | | |
| set -euo pipefail | |
| python3 python/scripts/runtime_settings.py render "${TARGET_FILE}" --format json --redact-values > "${RUNNER_TEMP}/assignments.json" | |
| python - <<'PY' "${TARGET_FILE}" "${RUNNER_TEMP}/assignments.json" >> "$GITHUB_STEP_SUMMARY" | |
| import json | |
| import sys | |
| target = json.load(open(sys.argv[1], encoding="utf-8")) | |
| assignments = json.load(open(sys.argv[2], encoding="utf-8")) | |
| print("## Runtime switch preview") | |
| print() | |
| print(f"- platform: `{target['runtime_target']['platform_id']}`") | |
| print("- target_id: `<redacted>`") | |
| print("- repository: `<redacted>`") | |
| print(f"- variable_scope: `{target['github']['variable_scope']}`") | |
| if target["github"].get("environment"): | |
| print("- environment: `<redacted>`") | |
| print("- strategy_profile: `<redacted>`") | |
| print("- service_name: `<redacted>`") | |
| print(f"- execution_mode: `{target['runtime_target']['execution_mode']}`") | |
| continuity = target["runtime_target"].get("live_continuity") | |
| if isinstance(continuity, dict): | |
| print(f"- live_continuity_state: `{continuity['state']}`") | |
| print(f"- live_continuity_baseline_kind: `{continuity['baseline_kind']}`") | |
| print("- values: `<redacted>`") | |
| print() | |
| print("### Variables") | |
| for assignment in assignments: | |
| print(f"- `{assignment['name']}` = `{assignment['value']}`") | |
| PY | |
| - name: Preflight IBKR deployment plan | |
| if: env.PLATFORM == 'ibkr' && env.SERVICE_TARGETS_MODE != 'off' | |
| 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}" | |
| 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 | |
| - name: Dispatch platform sync workflow | |
| if: env.APPLY_SWITCH == 'true' && env.TRIGGER_PLATFORM_SYNC == 'true' | |
| env: | |
| TARGET_REPOSITORY: ${{ steps.platform.outputs.repository }} | |
| run: | | |
| set -euo pipefail | |
| workflow="${PLATFORM_SYNC_WORKFLOW:-sync-cloud-run-env.yml}" | |
| case "${PLATFORM}" in | |
| longbridge|ibkr) | |
| gh workflow run "${workflow}" --repo "${TARGET_REPOSITORY}" --ref main -f target=configured | |
| ;; | |
| schwab|firstrade) | |
| gh workflow run "${workflow}" --repo "${TARGET_REPOSITORY}" --ref main | |
| ;; | |
| *) | |
| echo "No platform sync dispatch rule for ${PLATFORM}" >&2 | |
| exit 2 | |
| ;; | |
| esac | |
| echo "Dispatched ${workflow} in ${TARGET_REPOSITORY}." | |
| - name: Sync strategy switch account defaults | |
| if: env.APPLY_SWITCH == 'true' && env.STRATEGY_SWITCH_CONSOLE_URL != '' | |
| run: | | |
| set -euo pipefail | |
| python - <<'PY' | |
| import json | |
| import os | |
| import sys | |
| import urllib.error | |
| import urllib.request | |
| root = os.environ.get("GITHUB_WORKSPACE") or os.getcwd() | |
| sys.path.insert(0, os.path.join(root, "python", "scripts")) | |
| import runtime_settings | |
| base_url = os.environ["STRATEGY_SWITCH_CONSOLE_URL"].rstrip("/") | |
| token = os.environ["STRATEGY_SWITCH_SYNC_TOKEN"] | |
| if not token: | |
| print("::warning::STRATEGY_SWITCH_SYNC_TOKEN is missing; account default sync skipped") | |
| raise SystemExit(0) | |
| with open(os.environ["TARGET_FILE"], encoding="utf-8") as handle: | |
| target = json.load(handle) | |
| runtime_target = target["runtime_target"] | |
| github = target["github"] | |
| payload = { | |
| "platform": runtime_target["platform_id"], | |
| "target_name": target["target_id"].split("/", 1)[1], | |
| "strategy_profile": runtime_target["strategy_profile"], | |
| "execution_mode": runtime_target["execution_mode"], | |
| "variable_scope": "default", | |
| "plugin_mode": os.environ["PLUGIN_MODE"], | |
| "option_overlay_mode": os.environ.get("OPTION_OVERLAY_MODE", "current"), | |
| "deployment_selector": runtime_target["deployment_selector"], | |
| "account_selector": ",".join(runtime_target["account_selector"]), | |
| "account_scope": runtime_target["account_scope"], | |
| "service_name": runtime_target["service_name"], | |
| } | |
| extra_variables_json = os.environ.get("EXTRA_VARIABLES_JSON", "").strip() | |
| if extra_variables_json: | |
| try: | |
| extra_variables = json.loads(extra_variables_json) | |
| except json.JSONDecodeError: | |
| extra_variables = {} | |
| cash_only_mode = str(extra_variables.get("cash_only_execution_mode") or "current").strip().lower() | |
| if cash_only_mode in {"enabled", "disabled"}: | |
| payload["cash_only_execution_mode"] = cash_only_mode | |
| payload.update(runtime_settings.extract_account_sync_controls(target)) | |
| if github.get("environment"): | |
| payload["github_environment"] = github["environment"] | |
| request = urllib.request.Request( | |
| f"{base_url}/api/internal/sync-account-default", | |
| data=json.dumps(payload, separators=(",", ":")).encode("utf-8"), | |
| headers={ | |
| "Authorization": f"Bearer {token}", | |
| "Content-Type": "application/json", | |
| "User-Agent": "QuantRuntimeSettings-strategy-switch-sync", | |
| }, | |
| method="POST", | |
| ) | |
| try: | |
| with urllib.request.urlopen(request, timeout=20) as response: | |
| body = response.read().decode("utf-8") | |
| except urllib.error.HTTPError as exc: | |
| body = exc.read().decode("utf-8", errors="replace") | |
| print(f"::warning::Strategy switch account default sync failed: HTTP {exc.code}: {body[:1000]}") | |
| raise SystemExit(0) | |
| except Exception as exc: # noqa: BLE001 | |
| print(f"::warning::Strategy switch account default sync failed: {exc}") | |
| raise SystemExit(0) | |
| print(body) | |
| PY |