Skip to content

Commit b387ea4

Browse files
Pigbibicodex
andcommitted
fix: preserve non-live catalog access
Co-Authored-By: Codex <noreply@openai.com>
1 parent a21793d commit b387ea4

9 files changed

Lines changed: 147 additions & 42 deletions

File tree

.github/workflows/deploy-strategy-switch-console.yml

Lines changed: 78 additions & 34 deletions
Original file line numberDiff line numberDiff line change
@@ -131,20 +131,44 @@ jobs:
131131
exit 2
132132
fi
133133
134-
expected_count="$(python3 - <<'PY'
134+
expected_profiles="$(mktemp)"
135+
sync_body="$(mktemp)"
136+
live_body="$(mktemp)"
137+
cleanup() { rm -f "$expected_profiles" "$sync_body" "$live_body"; }
138+
trap cleanup EXIT
139+
140+
expected_count="$(python3 - "$expected_profiles" <<'PY'
135141
import json
142+
import sys
136143
from pathlib import Path
137144
138145
profiles = json.loads(Path("web/strategy-switch-console/strategy-profiles.example.json").read_text())
139146
if not isinstance(profiles, list):
140147
raise SystemExit("strategy-profiles.example.json must contain a JSON list")
141-
print(len(profiles))
148+
catalog_fields = (
149+
"profile",
150+
"runtime_enabled",
151+
"lifecycle_stage",
152+
"can_switch_live",
153+
"allowed_execution_modes",
154+
"blocked_live_reason",
155+
)
156+
157+
def project(item):
158+
projected = {field: item.get(field) for field in catalog_fields}
159+
projected["lifecycle_stage"] = projected["lifecycle_stage"] or ""
160+
projected["allowed_execution_modes"] = projected["allowed_execution_modes"] or []
161+
projected["blocked_live_reason"] = projected["blocked_live_reason"] or ""
162+
return projected
163+
164+
expected_profiles = sorted((project(item) for item in profiles), key=lambda item: item["profile"])
165+
Path(sys.argv[1]).write_text(
166+
json.dumps(expected_profiles, ensure_ascii=False, separators=(",", ":")),
167+
encoding="utf-8",
168+
)
169+
print(len(expected_profiles))
142170
PY
143171
)"
144-
sync_body="$(mktemp)"
145-
live_body="$(mktemp)"
146-
cleanup() { rm -f "$sync_body" "$live_body"; }
147-
trap cleanup EXIT
148172
149173
for attempt in 1 2 3 4 5 6; do
150174
curl --fail --show-error --silent \
@@ -157,42 +181,62 @@ jobs:
157181
"${STRATEGY_SWITCH_CONSOLE_URL%/}/api/internal/sync-strategy-profiles"
158182
python3 -m json.tool "$sync_body"
159183
160-
actual_count="$(python3 - "$sync_body" <<'PY'
184+
curl --fail --show-error --silent \
185+
--header "Cache-Control: no-cache" \
186+
--header "Pragma: no-cache" \
187+
--output "$live_body" \
188+
"${STRATEGY_SWITCH_CONSOLE_URL%/}/api/strategy-profiles?catalog_readback=${GITHUB_SHA}-${attempt}-$(date +%s%N)"
189+
if live_digest="$(python3 - "$expected_profiles" "$live_body" <<'PY'
190+
import hashlib
161191
import json
162192
import sys
163193
164-
payload = json.load(open(sys.argv[1], encoding="utf-8"))
165-
print(payload.get("strategy_profiles_count", ""))
194+
expected_profiles = json.load(open(sys.argv[1], encoding="utf-8"))
195+
payload = json.load(open(sys.argv[2], encoding="utf-8"))
196+
profiles = payload.get("strategyProfiles")
197+
if not isinstance(profiles, list):
198+
raise SystemExit("/api/strategy-profiles did not return strategyProfiles list")
199+
catalog_fields = (
200+
"profile",
201+
"runtime_enabled",
202+
"lifecycle_stage",
203+
"can_switch_live",
204+
"allowed_execution_modes",
205+
"blocked_live_reason",
206+
)
207+
208+
def project(item):
209+
projected = {field: item.get(field) for field in catalog_fields}
210+
projected["lifecycle_stage"] = projected["lifecycle_stage"] or ""
211+
projected["allowed_execution_modes"] = projected["allowed_execution_modes"] or []
212+
projected["blocked_live_reason"] = projected["blocked_live_reason"] or ""
213+
return projected
214+
215+
actual_profiles = sorted((project(item) for item in profiles), key=lambda item: item["profile"])
216+
if actual_profiles != expected_profiles:
217+
expected_by_profile = {item["profile"]: item for item in expected_profiles}
218+
actual_by_profile = {item["profile"]: item for item in actual_profiles}
219+
changed_profiles = sorted(
220+
profile
221+
for profile in expected_by_profile.keys() | actual_by_profile.keys()
222+
if expected_by_profile.get(profile) != actual_by_profile.get(profile)
223+
)
224+
print(
225+
"Live strategy profile catalog mismatch for: " + ", ".join(changed_profiles or ["unknown"]),
226+
file=sys.stderr,
227+
)
228+
raise SystemExit(1)
229+
canonical = json.dumps(actual_profiles, ensure_ascii=False, separators=(",", ":"))
230+
print(hashlib.sha256(canonical.encode("utf-8")).hexdigest())
166231
PY
167-
)"
168-
if [ "$actual_count" = "$expected_count" ]; then
232+
)"; then
233+
echo "Strategy profile KV sync verified with $expected_count profiles (catalog sha256: $live_digest)."
169234
break
170235
fi
171236
if [ "$attempt" = "6" ]; then
172-
echo "Synced strategy profile count ${actual_count:-unknown}; expected $expected_count." >&2
237+
echo "Live strategy profile catalog did not match the expected $expected_count-profile payload." >&2
173238
exit 2
174239
fi
175-
echo "Synced strategy profile count ${actual_count:-unknown}; expected $expected_count. Waiting for deployed Worker propagation..." >&2
240+
echo "Live strategy profile catalog is stale. Waiting for deployed Worker propagation..." >&2
176241
sleep 5
177242
done
178-
179-
curl --fail --show-error --silent \
180-
--header "Cache-Control: no-cache" \
181-
--output "$live_body" \
182-
"${STRATEGY_SWITCH_CONSOLE_URL%/}/api/strategy-profiles"
183-
live_count="$(python3 - "$live_body" <<'PY'
184-
import json
185-
import sys
186-
187-
payload = json.load(open(sys.argv[1], encoding="utf-8"))
188-
profiles = payload.get("strategyProfiles")
189-
if not isinstance(profiles, list):
190-
raise SystemExit("/api/strategy-profiles did not return strategyProfiles list")
191-
print(len(profiles))
192-
PY
193-
)"
194-
if [ "$live_count" != "$expected_count" ]; then
195-
echo "Live strategy profile count $live_count; expected $expected_count." >&2
196-
exit 2
197-
fi
198-
echo "Strategy profile KV sync verified with $live_count profiles."

platform-config.json

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -363,6 +363,7 @@
363363
"lifecycle_stage": "research_backtest_only",
364364
"can_switch_live": false,
365365
"allowed_execution_modes": [
366+
"paper",
366367
"dry_run"
367368
],
368369
"blocked_live_reason": "missing_current_promotion_evidence_and_human_acceptance",
@@ -401,6 +402,7 @@
401402
"lifecycle_stage": "research_backtest_only",
402403
"can_switch_live": false,
403404
"allowed_execution_modes": [
405+
"paper",
404406
"dry_run"
405407
],
406408
"blocked_live_reason": "missing_current_promotion_evidence_and_human_acceptance",
@@ -440,6 +442,7 @@
440442
"lifecycle_stage": "research_backtest_only",
441443
"can_switch_live": false,
442444
"allowed_execution_modes": [
445+
"paper",
443446
"dry_run"
444447
],
445448
"blocked_live_reason": "missing_current_promotion_evidence_and_human_acceptance",
@@ -466,6 +469,7 @@
466469
"lifecycle_stage": "research_backtest_only",
467470
"can_switch_live": false,
468471
"allowed_execution_modes": [
472+
"paper",
469473
"dry_run"
470474
],
471475
"blocked_live_reason": "missing_current_promotion_evidence_and_human_acceptance",
@@ -533,6 +537,7 @@
533537
"lifecycle_stage": "research_backtest_only",
534538
"can_switch_live": false,
535539
"allowed_execution_modes": [
540+
"paper",
536541
"dry_run"
537542
],
538543
"blocked_live_reason": "missing_current_promotion_evidence_and_human_acceptance",
@@ -693,6 +698,7 @@
693698
"lifecycle_stage": "research_backtest_only",
694699
"can_switch_live": false,
695700
"allowed_execution_modes": [
701+
"paper",
696702
"dry_run"
697703
],
698704
"blocked_live_reason": "missing_current_promotion_evidence_and_human_acceptance",
@@ -735,6 +741,7 @@
735741
"lifecycle_stage": "research_backtest_only",
736742
"can_switch_live": false,
737743
"allowed_execution_modes": [
744+
"paper",
738745
"dry_run"
739746
],
740747
"blocked_live_reason": "missing_current_promotion_evidence_and_human_acceptance",
@@ -901,6 +908,7 @@
901908
"lifecycle_stage": "research_backtest_only",
902909
"can_switch_live": false,
903910
"allowed_execution_modes": [
911+
"paper",
904912
"dry_run"
905913
],
906914
"blocked_live_reason": "missing_current_promotion_evidence_and_human_acceptance",

python/tests/test_runtime_settings.py

Lines changed: 47 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -235,7 +235,6 @@ def test_preflight_ibkr_switch_uses_candidate_inventory_without_printing_plan(se
235235
)
236236
target_path = temp / "target.json"
237237
target = build_runtime_switch.build_switch_target(args)
238-
target["runtime_target"]["execution_mode"] = "dry_run"
239238
target_path.write_text(
240239
runtime_settings.compact_json(target),
241240
encoding="utf-8",
@@ -593,7 +592,7 @@ def test_not_evidenced_profiles_are_catalog_demoted_fail_closed(self):
593592
"runtime_enabled": False,
594593
"can_switch_live": False,
595594
"lifecycle_stage": "research_backtest_only",
596-
"allowed_execution_modes": ["dry_run"],
595+
"allowed_execution_modes": ["paper", "dry_run"],
597596
"blocked_live_reason": "missing_current_promotion_evidence_and_human_acceptance",
598597
}
599598
config = build_config.load_config()["strategies"]
@@ -639,6 +638,39 @@ def test_not_evidenced_profiles_are_catalog_demoted_fail_closed(self):
639638
self.assertIn(f"runtime_target.strategy_profile {profile} is not runtime_enabled", errors)
640639
self.assertIn(f"runtime_target.strategy_profile {profile} cannot switch live", errors)
641640

641+
errors = []
642+
with patch.object(runtime_settings, "load_platform_config", return_value=actual_config):
643+
runtime_settings.validate_runtime_target_strategy_policy(
644+
{
645+
"platform_id": platform_by_domain[config[profile]["domain"]],
646+
"strategy_profile": profile,
647+
"execution_mode": "paper",
648+
"dry_run_only": True,
649+
},
650+
errors,
651+
)
652+
self.assertEqual(errors, [])
653+
654+
def test_strategy_switch_console_normalizes_dry_run_and_keeps_non_live_profiles_selectable(self):
655+
source = (ROOT / "web" / "strategy-switch-console" / "app.js").read_text(encoding="utf-8")
656+
normalize = re.search(
657+
r"function normalizeExecutionMode\(.*?\n }",
658+
source,
659+
re.DOTALL,
660+
)
661+
eligibility = re.search(
662+
r"function strategyAllowedForAccount\(.*?\n }",
663+
source,
664+
re.DOTALL,
665+
)
666+
667+
self.assertIsNotNone(normalize)
668+
self.assertIsNotNone(eligibility)
669+
self.assertIn('mode === "dry_run"', normalize.group(0))
670+
self.assertIn('return "paper"', normalize.group(0))
671+
self.assertNotIn("catalogEntry.runtime_enabled !== true", eligibility.group(0))
672+
self.assertIn('if (mode === "live") return strategyCanSwitchLive(catalogEntry);', eligibility.group(0))
673+
642674
def test_build_platform_config_build_strategy_profile_entries_defaults_gate_fields(self):
643675
payload = build_platform_config.build_strategy_profile_entries({
644676
"strategies": {
@@ -842,6 +874,19 @@ def test_strategy_switch_console_deploy_workflow_syncs_bundled_profiles(self):
842874
self.assertIn("CLOUDFLARE_WRANGLER_CONFIG_TOML", workflow)
843875
self.assertIn("STRATEGY_SWITCH_CONFIG_KV_NAMESPACE_ID", workflow)
844876
self.assertIn("python/scripts/sync_strategy_switch_page_asset.py", workflow)
877+
self.assertIn("expected_profiles", workflow)
878+
self.assertIn("actual_profiles", workflow)
879+
self.assertIn("actual_profiles != expected_profiles", workflow)
880+
self.assertIn("catalog_readback=", workflow)
881+
for field in (
882+
"profile",
883+
"runtime_enabled",
884+
"lifecycle_stage",
885+
"can_switch_live",
886+
"allowed_execution_modes",
887+
"blocked_live_reason",
888+
):
889+
self.assertIn(field, workflow)
845890

846891
def test_plugin_mount_schema_version_must_be_non_empty_string(self):
847892
_, target = self.load_target("examples/targets/schwab/live.example.json")

web/strategy-switch-console/app.js

Lines changed: 9 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -181,6 +181,7 @@
181181
"lifecycle_stage": "research_backtest_only",
182182
"can_switch_live": false,
183183
"allowed_execution_modes": [
184+
"paper",
184185
"dry_run"
185186
],
186187
"blocked_live_reason": "missing_current_promotion_evidence_and_human_acceptance",
@@ -213,6 +214,7 @@
213214
"lifecycle_stage": "research_backtest_only",
214215
"can_switch_live": false,
215216
"allowed_execution_modes": [
217+
"paper",
216218
"dry_run"
217219
],
218220
"blocked_live_reason": "missing_current_promotion_evidence_and_human_acceptance",
@@ -245,6 +247,7 @@
245247
"lifecycle_stage": "research_backtest_only",
246248
"can_switch_live": false,
247249
"allowed_execution_modes": [
250+
"paper",
248251
"dry_run"
249252
],
250253
"blocked_live_reason": "missing_current_promotion_evidence_and_human_acceptance",
@@ -265,6 +268,7 @@
265268
"lifecycle_stage": "research_backtest_only",
266269
"can_switch_live": false,
267270
"allowed_execution_modes": [
271+
"paper",
268272
"dry_run"
269273
],
270274
"blocked_live_reason": "missing_current_promotion_evidence_and_human_acceptance",
@@ -318,6 +322,7 @@
318322
"lifecycle_stage": "research_backtest_only",
319323
"can_switch_live": false,
320324
"allowed_execution_modes": [
325+
"paper",
321326
"dry_run"
322327
],
323328
"blocked_live_reason": "missing_current_promotion_evidence_and_human_acceptance",
@@ -456,6 +461,7 @@
456461
"lifecycle_stage": "research_backtest_only",
457462
"can_switch_live": false,
458463
"allowed_execution_modes": [
464+
"paper",
459465
"dry_run"
460466
],
461467
"blocked_live_reason": "missing_current_promotion_evidence_and_human_acceptance"
@@ -491,6 +497,7 @@
491497
"lifecycle_stage": "research_backtest_only",
492498
"can_switch_live": false,
493499
"allowed_execution_modes": [
500+
"paper",
494501
"dry_run"
495502
],
496503
"blocked_live_reason": "missing_current_promotion_evidence_and_human_acceptance"
@@ -646,6 +653,7 @@
646653
"lifecycle_stage": "research_backtest_only",
647654
"can_switch_live": false,
648655
"allowed_execution_modes": [
656+
"paper",
649657
"dry_run"
650658
],
651659
"blocked_live_reason": "missing_current_promotion_evidence_and_human_acceptance"
@@ -1600,7 +1608,6 @@
16001608
const cleanProfile = cleanStrategyProfile(profile);
16011609
const catalogEntry = strategyCatalogEntry(cleanProfile);
16021610
if (!catalogEntry.profile) return false;
1603-
if (catalogEntry.runtime_enabled !== true) return false;
16041611
if (dcaConfigForStrategy(cleanProfile) && !platformSupportsDca(platform)) return false;
16051612
if (!supportedDomainsForAccount(platform, account).includes(catalogEntry.domain)) return false;
16061613
const mode = normalizeExecutionMode(executionMode, false);
@@ -2023,6 +2030,7 @@
20232030
function normalizeExecutionMode(value, dryRunOnly) {
20242031
const mode = String(value || "").trim().toLowerCase();
20252032
if (mode === "live" || mode === "paper") return mode;
2033+
if (mode === "dry_run" || mode === "dry-run") return "paper";
20262034
if (dryRunOnly === true || dryRunOnly === "true" || dryRunOnly === "1" || dryRunOnly === 1) return "paper";
20272035
if (dryRunOnly === false || dryRunOnly === "false" || dryRunOnly === "0" || dryRunOnly === 0) return "live";
20282036
return "";

web/strategy-switch-console/app_js.js

Lines changed: 1 addition & 1 deletion
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

0 commit comments

Comments
 (0)