Skip to content

Commit 5083ad2

Browse files
committed
Add authenticated strategy switch console
1 parent 739317e commit 5083ad2

23 files changed

Lines changed: 3634 additions & 13 deletions
Lines changed: 358 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,358 @@
1+
name: Manual Strategy Switch
2+
3+
on:
4+
workflow_dispatch:
5+
inputs:
6+
platform:
7+
description: "Target platform."
8+
required: true
9+
type: choice
10+
options:
11+
- longbridge
12+
- ibkr
13+
- schwab
14+
- firstrade
15+
target_name:
16+
description: "Target name, e.g. sg, live, live-u1599-tqqq."
17+
required: true
18+
type: string
19+
strategy_profile:
20+
description: "Canonical strategy profile to switch to."
21+
required: true
22+
type: string
23+
execution_mode:
24+
description: "live writes live mode; paper sets dry_run_only=true and execution_mode=paper."
25+
required: true
26+
type: choice
27+
default: live
28+
options:
29+
- live
30+
- paper
31+
variable_scope:
32+
description: "Where GitHub variables are written. blank = platform default."
33+
required: false
34+
type: choice
35+
default: default
36+
options:
37+
- default
38+
- repository
39+
- environment
40+
github_environment:
41+
description: "Environment name when variable_scope=environment. blank = platform default."
42+
required: false
43+
type: string
44+
deployment_selector:
45+
description: "Runtime deployment_selector. blank = derived from target_name."
46+
required: false
47+
type: string
48+
account_selector:
49+
description: "Comma-separated broker account selectors. blank = account_scope."
50+
required: false
51+
type: string
52+
account_scope:
53+
description: "Runtime account_scope/account group. blank = deployment_selector."
54+
required: false
55+
type: string
56+
service_name:
57+
description: "Cloud Run service name. blank = platform default."
58+
required: false
59+
type: string
60+
plugin_mode:
61+
description: "auto mounts known strategy plugin artifacts; custom uses custom_plugin_mounts_json."
62+
required: true
63+
type: choice
64+
default: auto
65+
options:
66+
- auto
67+
- none
68+
- custom
69+
custom_plugin_mounts_json:
70+
description: "JSON list or {strategy_plugins:[...]} when plugin_mode=custom."
71+
required: false
72+
type: string
73+
extra_variables_json:
74+
description: "Optional JSON object of non-secret extra GitHub variables or target env fields."
75+
required: false
76+
type: string
77+
reserved_cash_ratio:
78+
description: "Optional platform reserved-cash ratio override."
79+
required: false
80+
type: string
81+
min_reserved_cash_usd:
82+
description: "Optional platform minimum reserved cash override."
83+
required: false
84+
type: string
85+
income_threshold_usd:
86+
description: "Optional TQQQ income threshold override."
87+
required: false
88+
type: string
89+
qqqi_income_ratio:
90+
description: "Optional TQQQ QQQI income ratio override."
91+
required: false
92+
type: string
93+
service_targets_mode:
94+
description: "auto patches IBKR CLOUD_RUN_SERVICE_TARGETS_JSON when it exists."
95+
required: true
96+
type: choice
97+
default: auto
98+
options:
99+
- auto
100+
- off
101+
apply:
102+
description: "Actually write GitHub variables. false = preview only."
103+
required: true
104+
type: boolean
105+
default: false
106+
trigger_platform_sync:
107+
description: "After apply, dispatch the target platform sync-cloud-run-env workflow."
108+
required: true
109+
type: boolean
110+
default: false
111+
confirm_apply:
112+
description: "Required for writes. Use APPLY for variable writes, APPLY_AND_SYNC when trigger_platform_sync=true."
113+
required: false
114+
type: string
115+
platform_sync_workflow:
116+
description: "Target platform workflow filename."
117+
required: false
118+
type: string
119+
default: sync-cloud-run-env.yml
120+
121+
concurrency:
122+
group: runtime-strategy-switch-${{ inputs.platform }}-${{ inputs.target_name }}
123+
cancel-in-progress: false
124+
125+
jobs:
126+
switch:
127+
name: Build and apply runtime switch
128+
runs-on: ubuntu-latest
129+
environment: runtime-strategy-switch
130+
permissions:
131+
contents: read
132+
env:
133+
GH_TOKEN: ${{ secrets.RUNTIME_SETTINGS_GH_TOKEN }}
134+
PLATFORM: ${{ inputs.platform }}
135+
TARGET_NAME: ${{ inputs.target_name }}
136+
STRATEGY_PROFILE: ${{ inputs.strategy_profile }}
137+
EXECUTION_MODE: ${{ inputs.execution_mode }}
138+
VARIABLE_SCOPE: ${{ inputs.variable_scope }}
139+
GITHUB_ENVIRONMENT_NAME: ${{ inputs.github_environment }}
140+
DEPLOYMENT_SELECTOR: ${{ inputs.deployment_selector }}
141+
ACCOUNT_SELECTOR: ${{ inputs.account_selector }}
142+
ACCOUNT_SCOPE: ${{ inputs.account_scope }}
143+
SERVICE_NAME: ${{ inputs.service_name }}
144+
PLUGIN_MODE: ${{ inputs.plugin_mode }}
145+
CUSTOM_PLUGIN_MOUNTS_JSON: ${{ inputs.custom_plugin_mounts_json }}
146+
EXTRA_VARIABLES_JSON: ${{ inputs.extra_variables_json }}
147+
RESERVED_CASH_RATIO: ${{ inputs.reserved_cash_ratio }}
148+
MIN_RESERVED_CASH_USD: ${{ inputs.min_reserved_cash_usd }}
149+
INCOME_THRESHOLD_USD: ${{ inputs.income_threshold_usd }}
150+
QQQI_INCOME_RATIO: ${{ inputs.qqqi_income_ratio }}
151+
SERVICE_TARGETS_MODE: ${{ inputs.service_targets_mode }}
152+
APPLY_SWITCH: ${{ inputs.apply }}
153+
TRIGGER_PLATFORM_SYNC: ${{ inputs.trigger_platform_sync }}
154+
CONFIRM_APPLY: ${{ inputs.confirm_apply }}
155+
PLATFORM_SYNC_WORKFLOW: ${{ inputs.platform_sync_workflow }}
156+
steps:
157+
- name: Checkout
158+
uses: actions/checkout@v6
159+
160+
- name: Enforce write safety gates
161+
run: |
162+
set -euo pipefail
163+
164+
if [ "${APPLY_SWITCH}" != "true" ] && [ "${TRIGGER_PLATFORM_SYNC}" = "true" ]; then
165+
echo "trigger_platform_sync=true is invalid unless apply=true." >&2
166+
exit 2
167+
fi
168+
169+
if [ "${APPLY_SWITCH}" = "true" ]; then
170+
if [ -z "${GH_TOKEN:-}" ]; then
171+
echo "RUNTIME_SETTINGS_GH_TOKEN is required for apply=true." >&2
172+
exit 2
173+
fi
174+
if [ "${TRIGGER_PLATFORM_SYNC}" = "true" ]; then
175+
if [ "${CONFIRM_APPLY:-}" != "APPLY_AND_SYNC" ]; then
176+
echo "Set confirm_apply=APPLY_AND_SYNC when trigger_platform_sync=true." >&2
177+
exit 2
178+
fi
179+
elif [ "${CONFIRM_APPLY:-}" != "APPLY" ]; then
180+
echo "Set confirm_apply=APPLY when apply=true." >&2
181+
exit 2
182+
fi
183+
fi
184+
185+
if [ "${PLATFORM}" = "ibkr" ] \
186+
&& [ "${SERVICE_TARGETS_MODE}" = "auto" ] \
187+
&& [ -z "${GH_TOKEN:-}" ]; then
188+
echo "RUNTIME_SETTINGS_GH_TOKEN is required for IBKR service-target preview because the workflow must read and patch CLOUD_RUN_SERVICE_TARGETS_JSON." >&2
189+
exit 2
190+
fi
191+
192+
- name: Resolve platform repository
193+
id: platform
194+
run: |
195+
set -euo pipefail
196+
case "${PLATFORM}" in
197+
longbridge)
198+
repo="QuantStrategyLab/LongBridgePlatform"
199+
;;
200+
ibkr)
201+
repo="QuantStrategyLab/InteractiveBrokersPlatform"
202+
;;
203+
schwab)
204+
repo="QuantStrategyLab/CharlesSchwabPlatform"
205+
;;
206+
firstrade)
207+
repo="QuantStrategyLab/FirstradePlatform"
208+
;;
209+
*)
210+
echo "Unsupported platform: ${PLATFORM}" >&2
211+
exit 2
212+
;;
213+
esac
214+
echo "repository=${repo}" >> "$GITHUB_OUTPUT"
215+
216+
- name: Fetch existing service targets
217+
if: env.SERVICE_TARGETS_MODE == 'auto' && env.PLATFORM == 'ibkr'
218+
env:
219+
TARGET_REPOSITORY: ${{ steps.platform.outputs.repository }}
220+
run: |
221+
set -euo pipefail
222+
output_file="${RUNNER_TEMP}/existing-service-targets.json"
223+
python - <<'PY' "${TARGET_REPOSITORY}" "${output_file}"
224+
import json
225+
import subprocess
226+
import sys
227+
228+
repo, output_path = sys.argv[1], sys.argv[2]
229+
raw = subprocess.check_output(
230+
["gh", "variable", "list", "--repo", repo, "--json", "name,value"],
231+
text=True,
232+
)
233+
variables = json.loads(raw)
234+
value = ""
235+
for item in variables:
236+
if item.get("name") == "CLOUD_RUN_SERVICE_TARGETS_JSON":
237+
value = str(item.get("value") or "").strip()
238+
break
239+
if not value:
240+
open(output_path, "w", encoding="utf-8").close()
241+
raise SystemExit(0)
242+
try:
243+
payload = json.loads(value)
244+
except json.JSONDecodeError:
245+
payload = json.loads(value.replace("\\n", "\n"))
246+
with open(output_path, "w", encoding="utf-8") as handle:
247+
json.dump(payload, handle, ensure_ascii=False, separators=(",", ":"))
248+
PY
249+
echo "EXISTING_SERVICE_TARGETS_JSON_FILE=${output_file}" >> "$GITHUB_ENV"
250+
251+
- name: Build switch target
252+
run: |
253+
set -euo pipefail
254+
target_file="${RUNNER_TEMP}/runtime-switch-target.json"
255+
args=(
256+
--platform "${PLATFORM}"
257+
--target-name "${TARGET_NAME}"
258+
--strategy-profile "${STRATEGY_PROFILE}"
259+
--execution-mode "${EXECUTION_MODE}"
260+
--plugin-mode "${PLUGIN_MODE}"
261+
--output "${target_file}"
262+
)
263+
if [ "${VARIABLE_SCOPE}" != "default" ]; then
264+
args+=(--variable-scope "${VARIABLE_SCOPE}")
265+
fi
266+
if [ -n "${GITHUB_ENVIRONMENT_NAME:-}" ]; then
267+
args+=(--github-environment "${GITHUB_ENVIRONMENT_NAME}")
268+
fi
269+
if [ -n "${DEPLOYMENT_SELECTOR:-}" ]; then
270+
args+=(--deployment-selector "${DEPLOYMENT_SELECTOR}")
271+
fi
272+
if [ -n "${ACCOUNT_SELECTOR:-}" ]; then
273+
args+=(--account-selector "${ACCOUNT_SELECTOR}")
274+
fi
275+
if [ -n "${ACCOUNT_SCOPE:-}" ]; then
276+
args+=(--account-scope "${ACCOUNT_SCOPE}")
277+
fi
278+
if [ -n "${SERVICE_NAME:-}" ]; then
279+
args+=(--service-name "${SERVICE_NAME}")
280+
fi
281+
if [ -n "${CUSTOM_PLUGIN_MOUNTS_JSON:-}" ]; then
282+
args+=(--custom-plugin-mounts-json "${CUSTOM_PLUGIN_MOUNTS_JSON}")
283+
fi
284+
if [ -n "${EXTRA_VARIABLES_JSON:-}" ]; then
285+
args+=(--extra-variables-json "${EXTRA_VARIABLES_JSON}")
286+
fi
287+
if [ -n "${RESERVED_CASH_RATIO:-}" ]; then
288+
args+=(--reserved-cash-ratio "${RESERVED_CASH_RATIO}")
289+
fi
290+
if [ -n "${MIN_RESERVED_CASH_USD:-}" ]; then
291+
args+=(--min-reserved-cash-usd "${MIN_RESERVED_CASH_USD}")
292+
fi
293+
if [ -n "${INCOME_THRESHOLD_USD:-}" ]; then
294+
args+=(--income-threshold-usd "${INCOME_THRESHOLD_USD}")
295+
fi
296+
if [ -n "${QQQI_INCOME_RATIO:-}" ]; then
297+
args+=(--qqqi-income-ratio "${QQQI_INCOME_RATIO}")
298+
fi
299+
if [ -s "${EXISTING_SERVICE_TARGETS_JSON_FILE:-}" ]; then
300+
args+=(--existing-service-targets-json-file "${EXISTING_SERVICE_TARGETS_JSON_FILE}")
301+
fi
302+
python3 scripts/build_runtime_switch.py "${args[@]}"
303+
python3 scripts/runtime_settings.py validate "${target_file}"
304+
echo "TARGET_FILE=${target_file}" >> "$GITHUB_ENV"
305+
306+
- name: Preview assignments
307+
run: |
308+
set -euo pipefail
309+
python3 scripts/runtime_settings.py render "${TARGET_FILE}" --format env
310+
python3 scripts/runtime_settings.py render "${TARGET_FILE}" --format json > "${RUNNER_TEMP}/assignments.json"
311+
python - <<'PY' "${TARGET_FILE}" "${RUNNER_TEMP}/assignments.json" >> "$GITHUB_STEP_SUMMARY"
312+
import json
313+
import sys
314+
315+
target = json.load(open(sys.argv[1], encoding="utf-8"))
316+
assignments = json.load(open(sys.argv[2], encoding="utf-8"))
317+
print("## Runtime switch preview")
318+
print()
319+
print(f"- target_id: `{target['target_id']}`")
320+
print(f"- repository: `{target['github']['repository']}`")
321+
print(f"- variable_scope: `{target['github']['variable_scope']}`")
322+
if target["github"].get("environment"):
323+
print(f"- environment: `{target['github']['environment']}`")
324+
print(f"- strategy_profile: `{target['runtime_target']['strategy_profile']}`")
325+
print(f"- service_name: `{target['runtime_target']['service_name']}`")
326+
print(f"- execution_mode: `{target['runtime_target']['execution_mode']}`")
327+
print()
328+
print("### Variables")
329+
for assignment in assignments:
330+
value = str(assignment["value"])
331+
preview = value if len(value) <= 220 else value[:220] + "..."
332+
print(f"- `{assignment['name']}` = `{preview}`")
333+
PY
334+
335+
- name: Apply GitHub variable updates
336+
if: env.APPLY_SWITCH == 'true'
337+
run: python3 scripts/runtime_settings.py apply "${TARGET_FILE}" --yes
338+
339+
- name: Dispatch platform sync workflow
340+
if: env.APPLY_SWITCH == 'true' && env.TRIGGER_PLATFORM_SYNC == 'true'
341+
env:
342+
TARGET_REPOSITORY: ${{ steps.platform.outputs.repository }}
343+
run: |
344+
set -euo pipefail
345+
workflow="${PLATFORM_SYNC_WORKFLOW:-sync-cloud-run-env.yml}"
346+
case "${PLATFORM}" in
347+
longbridge|ibkr)
348+
gh workflow run "${workflow}" --repo "${TARGET_REPOSITORY}" --ref main -f target=configured
349+
;;
350+
schwab|firstrade)
351+
gh workflow run "${workflow}" --repo "${TARGET_REPOSITORY}" --ref main
352+
;;
353+
*)
354+
echo "No platform sync dispatch rule for ${PLATFORM}" >&2
355+
exit 2
356+
;;
357+
esac
358+
echo "Dispatched ${workflow} in ${TARGET_REPOSITORY}."

.github/workflows/validate.yml

Lines changed: 11 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -12,8 +12,18 @@ jobs:
1212
- uses: actions/setup-python@v6
1313
with:
1414
python-version: "3.12"
15+
- uses: actions/setup-node@v6
16+
with:
17+
node-version: "22"
1518
- name: Validate runtime targets
1619
run: python3 scripts/runtime_settings.py validate
1720
- name: Run unit tests
1821
run: python3 -m unittest discover -s tests -v
19-
22+
- name: Validate strategy switch web assets
23+
run: |
24+
set -euo pipefail
25+
python3 scripts/sync_strategy_switch_page_asset.py
26+
git diff --exit-code -- web/strategy-switch-console/page_asset.js
27+
sed -n '/<script>/,/<\/script>/p' docs/index.html | sed '1d;$d' | node --check --input-type=commonjs
28+
node --check --input-type=module < web/strategy-switch-console/page_asset.js
29+
node --check --input-type=module < web/strategy-switch-console/worker.js

0 commit comments

Comments
 (0)