Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
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
14 changes: 8 additions & 6 deletions platform-config.json
Original file line number Diff line number Diff line change
Expand Up @@ -697,17 +697,19 @@
}
},
"global_etf_rotation": {
"frozen": true,
"frozen": false,
"label": "全球ETF轮动",
"label_en": "Global ETF Rotation",
"domain": "us_equity",
"runtime_enabled": false,
"lifecycle_stage": "research_active",
"can_switch_live": false,
"runtime_enabled": true,
"lifecycle_stage": "runtime_enabled",
"can_switch_live": true,
"allowed_execution_modes": [
"dry_run"
"paper",
"dry_run",
"live"
],
"blocked_live_reason": "research_backtest_only_requires_evidence_package",
"blocked_live_reason": null,
"live_continuity": {
"eligible": true,
"allowed_platforms": [
Expand Down
28 changes: 22 additions & 6 deletions python/tests/test_frozen_selection.py
Original file line number Diff line number Diff line change
Expand Up @@ -21,15 +21,11 @@ def load(name):
class FrozenSelectionTest(unittest.TestCase):
def test_full_target_validation_rejects_retired_profile_spellings(self):
cases = (
('examples/targets/ibkr/us_combo.example.json', 'global_etf_rotation'),
('python/tests/fixtures/retired_hk_combo_target.json', 'hk_global_etf_tactical_rotation'),
('python/tests/fixtures/retired_cn_combo_target.json', 'cn_industry_etf_rotation'),
)
for filename, canonical in cases:
profiles = [canonical, canonical.replace('_', '-'), canonical.upper().replace('_', '-')]
if canonical == 'global_etf_rotation':
profiles.extend(('global_macro_etf_rotation', 'global-macro-etf-rotation',
'global_etf_confidence_vol_gate', 'global-etf-confidence-vol-gate'))
for profile in profiles:
with self.subTest(profile=profile):
target = json.loads((ROOT / filename).read_text())
Expand All @@ -40,7 +36,7 @@ def test_full_target_validation_rejects_retired_profile_spellings(self):
self.assertEqual(target, original)

def test_core_profile_spellings_keep_existing_dry_run_policy(self):
cores = ('tqqq_growth_income', 'soxl_soxx_trend_income',
cores = ('global_etf_rotation', 'tqqq_growth_income', 'soxl_soxx_trend_income',
'russell_top50_leader_rotation', 'ibit_smart_dca', 'nasdaq_sp500_smart_dca')
for canonical in cores:
for profile in (canonical, canonical.replace('_', '-')):
Expand All @@ -50,6 +46,24 @@ def test_core_profile_spellings_keep_existing_dry_run_policy(self):
{'platform_id': 'ibkr', 'strategy_profile': profile, 'execution_mode': 'dry_run'}, errors)
self.assertEqual(errors, [])

def test_global_etf_new_targets_accept_live_without_legacy_continuity(self):
profiles = ('global_etf_rotation', 'global-etf-rotation',
'global_macro_etf_rotation', 'global-etf-confidence-vol-gate')
for platform in ('ibkr', 'longbridge', 'schwab', 'firstrade'):
for profile in profiles:
for mode in ('live', 'dry_run'):
with self.subTest(platform=platform, profile=profile, mode=mode):
target = {'platform_id': platform, 'strategy_profile': profile, 'execution_mode': mode}
original = copy.deepcopy(target)
errors = []
runtime.validate_runtime_target_strategy_policy(target, errors)
self.assertEqual(errors, [])
self.assertEqual(target, original)
errors = []
runtime.validate_runtime_target_strategy_policy(
{'platform_id': 'binance', 'strategy_profile': 'global_etf_rotation', 'execution_mode': 'live'}, errors)
self.assertTrue(any('domain us_equity is not supported' in error for error in errors))

def test_display_alias_cannot_skip_strategy_policy(self):
errors = []
runtime.validate_runtime_target_strategy_policy(
Expand Down Expand Up @@ -79,14 +93,16 @@ def test_ssot_projection_and_boolean_validation(self):
config = build.load_config()
entries = {p['profile']: p for p in build.strategy_to_json_compat(config['strategies'])}
frozen = {p for p, item in config['strategies'].items() if item.get('frozen') is True}
self.assertEqual(len(frozen), 14)
self.assertEqual(len(frozen), 13)
self.assertTrue(all(entries[p]['frozen'] is True for p in frozen))
self.assertTrue(all(not entries[p].get('frozen', False) for p in ('tqqq_growth_income', 'soxl_soxx_trend_income', 'russell_top50_leader_rotation', 'ibit_smart_dca', 'nasdaq_sp500_smart_dca', 'us_equity_combo', 'crypto_btc_dca')))
config['strategies']['global_etf_rotation']['frozen'] = 'true'
self.assertIn('strategy global_etf_rotation: frozen must be boolean', build.validate(config))

def test_new_frozen_selection_and_valid_risk_reducing_continuity(self):
config = build.load_config()
# Synthetic freeze keeps coverage of the independent safety policy.
config['strategies']['global_etf_rotation']['frozen'] = True
with patch.object(runtime, 'load_platform_config', return_value=config):
for profile in ('global_etf_rotation', 'global_macro_etf_rotation', 'global_etf_confidence_vol_gate'):
for mode in ('dry_run', 'paper', 'live'):
Expand Down
17 changes: 10 additions & 7 deletions python/tests/test_runtime_settings.py
Original file line number Diff line number Diff line change
Expand Up @@ -688,12 +688,12 @@ def test_platform_health_report_summarizes_current_config(self):

self.assertEqual(report["status"], "attention_required")
self.assertEqual(report["schema_version"], "platform_health_report.v1")
self.assertEqual(report["summary"]["runtime_enabled_switchable_count"], 4)
self.assertEqual(report["summary"]["runtime_enabled_switchable_count"], 5)
self.assertIn("codex_repair_context", report)
self.assertIn("automation_registry", report)
self.assertIn("automation_lane_counts", report["summary"])
self.assertEqual(report["summary"]["dry_run_uncovered_strategy_count"], 0)
self.assertEqual(report["summary"]["dry_run_covered_strategy_count"], 13)
self.assertEqual(report["summary"]["dry_run_covered_strategy_count"], 14)
self.assertGreater(report["summary"]["dry_run_route_count"], 0)
self.assertGreater(
report["summary"]["declared_dry_run_route_count"],
Expand Down Expand Up @@ -735,7 +735,7 @@ def test_strategy_platform_dry_run_coverage_uses_verified_snapshot_artifact(self
self.assertEqual(route["blocked_reason"], "frozen_for_new_selection")
self.assertNotIn("hk_low_vol_dividend_quality_snapshot", coverage["artifact_blocked_profiles"])
self.assertEqual(coverage["summary"]["declared_dry_run_route_count"], 63)
self.assertEqual(coverage["summary"]["buildable_dry_run_route_count"], 40)
self.assertEqual(coverage["summary"]["buildable_dry_run_route_count"], 44)

def test_feature_snapshot_platform_coverage_matches_runtime_injection(self):
self.assertEqual(
Expand Down Expand Up @@ -914,6 +914,7 @@ def test_confirmed_legacy_ibkr_profiles_are_continuity_eligible(self):

strategy = build_config.load_config()["strategies"][profile]
restored_live = {
"global_etf_rotation",
"tqqq_growth_income",
"russell_top50_leader_rotation",
}
Expand Down Expand Up @@ -1067,7 +1068,7 @@ def test_runtime_catalog_projection_is_exact_and_never_claims_observed_runtime(s
projection["summary"]["strategy_profile_count"],
len(config["strategies"]),
)
self.assertEqual(projection["summary"]["live_switchable_count"], 4)
self.assertEqual(projection["summary"]["live_switchable_count"], 5)
self.assertEqual(
projection["source"]["content_sha256"],
build_platform_config._config_content_sha256(config),
Expand All @@ -1084,7 +1085,7 @@ def test_historical_lifecycle_inventory_is_explicitly_non_authoritative(self):
self.assertEqual(matrix["superseded_by"]["candidate_lifecycle"], "GET /api/control-plane")
self.assertEqual(matrix["superseded_by"]["target_execution_evidence"], "GET /api/execution-evidence")

def test_global_and_hk_global_etf_rotation_profiles_are_research_only(self):
def test_hk_global_etf_rotation_profile_remains_research_only(self):
expected = {
"lifecycle_stage": "research_active",
"runtime_enabled": False,
Expand All @@ -1100,7 +1101,7 @@ def test_global_and_hk_global_etf_rotation_profiles_are_research_only(self):
)
}

for profile in ("global_etf_rotation", "hk_global_etf_tactical_rotation"):
for profile in ("hk_global_etf_tactical_rotation",):
with self.subTest(profile=profile):
self.assertEqual(
{field: config["strategies"][profile][field] for field in expected},
Expand Down Expand Up @@ -3802,7 +3803,9 @@ def disabled_profile_target_pair(self, variable_scope="environment"):
return [old_target], [new_target]

def test_frozen_existing_disabled_target_can_move_to_core_only_after_disabled_readback(self):
self.enterContext(patch.object(runtime_settings, "load_platform_config", return_value=build_config.load_config()))
config = build_config.load_config()
config["strategies"]["global_etf_rotation"]["frozen"] = True
self.enterContext(patch.object(runtime_settings, "load_platform_config", return_value=config))
args = build_runtime_switch.build_parser().parse_args([
"--platform", "longbridge", "--target-name", "paper", "--strategy-profile", "tqqq_growth_income",
"--execution-mode", "dry_run", "--variable-scope", "environment", "--github-environment", "longbridge-paper",
Expand Down
9 changes: 8 additions & 1 deletion scripts/console_storage_compat_old_build.mjs
Original file line number Diff line number Diff line change
@@ -1,12 +1,19 @@
// Reconstruct the exact previously qualified production candidate offline.
// The prerequisite fixed 54e and 946 evidence builders run in the same job.
import { createHash } from 'node:crypto';
import { execFileSync } from 'node:child_process';
import { createRequire } from 'node:module';
import { mkdir, readFile, writeFile } from 'node:fs/promises';
import { join } from 'node:path';
import { verify946OnFixed54e } from './console_946_54e_patch.mjs';
import { buildFreezeCandidate, assertFrozenPolicySource } from './console_958_freeze_candidate.mjs';
import { DEFAULT_STRATEGY_PROFILES } from '../web/strategy-switch-console/strategy_profiles_asset.js';

// Reconstruct the historical freeze policy from its immutable Git blob. The
// current catalog restores Global ETF and is not the old candidate's policy.
const policySource = execFileSync('git', ['cat-file', 'blob', '390c7be888059e95793f066ebf4a38ca7234b8f4'], {
cwd: new URL('..', import.meta.url),
});
const { DEFAULT_STRATEGY_PROFILES } = await import(`data:text/javascript;base64,${policySource.toString('base64')}`);

const root = process.env.RUNNER_TEMP;
if (!root) throw Error('RUNNER_TEMP is required');
Expand Down
2 changes: 1 addition & 1 deletion tests/account_settings_worker_validation.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -1047,7 +1047,7 @@ assert.equal(missedRead.body.draft.overrides.reserved_cash_ratio, "0.1");
assert.equal((await accountAudit()).length, auditCountBeforeMiss);

const catalogIds = missedRead.body.strategy_options.map((item) => item.profile);
assert.equal(catalogIds.includes("global_etf_rotation"), false);
assert.equal(catalogIds.includes("global_etf_rotation"), true);
assert.equal(catalogIds.some((id) => String(id).startsWith("hk_")), false);
assert.equal(catalogIds.includes("russell_top50_leader_rotation"), true);
assert.equal(missedRead.body.strategy_options.find((item) => item.profile === "soxl_soxx_core_only_p2_v7_longterm_compounding_cash_reserve").option_overlay_enabled, false);
Expand Down
8 changes: 6 additions & 2 deletions tests/console_958_freeze_candidate.test.mjs
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import {test} from 'node:test';
import assert from 'node:assert/strict';
import {createRequire} from 'node:module';
import {execFileSync} from 'node:child_process';
import {runInNewContext} from 'node:vm';
import {buildFreezeCandidate,assertFrozenPolicySource,FROZEN_POLICY} from '../scripts/console_958_freeze_candidate.mjs';
import {pathToFileURL} from 'node:url';
Expand Down Expand Up @@ -56,9 +57,12 @@ test('modified historic freeze function or duplicate helper is refused',()=>{
assert.throws(()=>buildFreezeCandidate(ts,original,Buffer.from(original.toString().replace('normalizeSwitchInputs(rawInput)','normalizeSwitchInputs({})'))));
});

test('frozen set equals fixed main asset including all 14 entries',async()=>{
const policyUrl=process.env.QRS_TEST_POLICY ? pathToFileURL(process.env.QRS_TEST_POLICY).href : new URL('../web/strategy-switch-console/strategy_profiles_asset.js',import.meta.url).href;
test('frozen set equals the historical asset including all 14 entries',async()=>{
const policyUrl=process.env.QRS_TEST_POLICY ? pathToFileURL(process.env.QRS_TEST_POLICY).href : `data:text/javascript;base64,${execFileSync('git',['cat-file','blob','390c7be888059e95793f066ebf4a38ca7234b8f4'],{cwd:new URL('..',import.meta.url)}).toString('base64')}`;
const {DEFAULT_STRATEGY_PROFILES}=await import(policyUrl);
assert.equal(FROZEN_POLICY.length,14);assertFrozenPolicySource(DEFAULT_STRATEGY_PROFILES);
assert.throws(()=>assertFrozenPolicySource(DEFAULT_STRATEGY_PROFILES.filter(x=>x.profile!==FROZEN_POLICY[0])));
const {DEFAULT_STRATEGY_PROFILES: current}=await import('../web/strategy-switch-console/strategy_profiles_asset.js');
assert.equal(current.find(x=>x.profile==='global_etf_rotation').frozen,false);
assert.throws(()=>assertFrozenPolicySource(current));
});
48 changes: 47 additions & 1 deletion tests/frozen_strategy_selection_validation.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@ import { DEFAULT_STRATEGY_PROFILES } from '../web/strategy-switch-console/strate
import { PLATFORM_CONFIG } from '../web/strategy-switch-console/config.js';

const frozen = DEFAULT_STRATEGY_PROFILES.filter(p => p.frozen === true);
assert.equal(frozen.length, 14);
assert.equal(frozen.length, 13);
for (const item of frozen) {
const platform = item.domain === 'cn_equity' ? 'qmt' : 'ibkr';
const account = {key:'synthetic', supported_domains:[item.domain]};
Expand All @@ -13,6 +13,28 @@ for (const item of frozen) {
assert.equal(__test.accountDraftStrategyChoices(platform, account, stale).length, 0);
assert.throws(() => __test.assertStrategyAllowedForAccount({platform, strategy_profile:item.profile, execution_mode:'dry_run'}, account, stale), /frozen/);
}
// The bundled Global ETFs policy permits new compatible targets without continuity.
const globalPolicy = DEFAULT_STRATEGY_PROFILES.find(p => p.profile === 'global_etf_rotation');
const restored = __test.normalizeStrategyProfilesPayload([globalPolicy]);
assert.equal(restored[0].runtime_enabled, true);
assert.equal(restored[0].can_switch_live, true);
assert.notEqual(restored[0].frozen, true);
for (const platform of ['ibkr', 'longbridge', 'schwab', 'firstrade']) {
const account = {key:'synthetic-new', supported_domains:['us_equity']};
assert(__test.accountDraftStrategyChoices(platform, account, restored)
.some(p => p.profile === 'global_etf_rotation'));
for (const execution_mode of ['live', 'dry_run']) {
assert.doesNotThrow(() => __test.assertStrategyAllowedForAccount(
{platform, strategy_profile:'global_etf_rotation', execution_mode}, account, restored));
}
}
assert.throws(() => __test.assertStrategyAllowedForAccount(
{platform:'binance', strategy_profile:'global_etf_rotation', execution_mode:'live'},
{key:'synthetic-crypto', supported_domains:['crypto']}, restored), /domain/);

// A synthetic freeze retains the independent stale-catalog and continuity tests.
const originalGlobalFrozen = globalPolicy.frozen;
globalPolicy.frozen = true;
const retired = ['global_etf_rotation', 'global_macro_etf_rotation', 'global_etf_confidence_vol_gate'];
const core = ['tqqq_growth_income', 'soxl_soxx_trend_income', 'russell_top50_leader_rotation', 'ibit_smart_dca', 'nasdaq_sp500_smart_dca'];
const stale = __test.normalizeStrategyProfilesPayload([...retired, ...core].map(profile => ({profile, domain: 'us_equity', allowed_execution_modes: ['paper', 'dry_run']})));
Expand Down Expand Up @@ -94,6 +116,30 @@ for (const [canonical, platform, alias] of [
assert.equal(result.status, 409, alias);
assert.equal(aliasWrites, 0, alias);
}
globalPolicy.frozen = originalGlobalFrozen;
// A registered target can select Global ETFs without legacy continuity.
const restoredKv = new Map([
['strategy_profiles', JSON.stringify(DEFAULT_STRATEGY_PROFILES)],
['account_options', JSON.stringify({ibkr:[]})],
]);
const restoredEnv = {STRATEGY_SWITCH_SYNC_TOKEN:'synthetic', STRATEGY_SWITCH_CONFIG:{
get:async key => restoredKv.get(key) || null,
put:async (key, value) => {restoredKv.set(key, value);},
}};
const newTarget = {...payload, target_name:'synthetic-new', account_selector:'SYNTHETIC_NEW',
deployment_selector:'synthetic-new', account_scope:'synthetic-new', service_name:'synthetic-new-service'};
for (const key of ['live_continuity_state', 'live_continuity_baseline_id', 'live_continuity_captured_at']) {
delete newTarget[key];
}
restoredKv.set('account_options', JSON.stringify({ibkr:[{
key:'synthetic-new', label:'Synthetic new target', target_name:newTarget.target_name,
account_selector:newTarget.account_selector, deployment_selector:newTarget.deployment_selector,
account_scope:newTarget.account_scope, service_name:newTarget.service_name,
supported_domains:['us_equity'], default_strategy_profile:'tqqq_growth_income',
}]}));
const newTargetResponse = await worker.fetch(request(newTarget), restoredEnv);
assert.equal(newTargetResponse.status, 200, JSON.stringify(await newTargetResponse.clone().json()));
assert.equal((await newTargetResponse.json()).account_options_sync.synced, true);
const unchanged = __test.normalizeStrategyProfilesPayload([
{profile:'tqqq-growth-income', domain:'us_equity', allowed_execution_modes:['dry_run']},
{profile:'synthetic-unknown', domain:'us_equity', allowed_execution_modes:['dry_run']},
Expand Down
24 changes: 13 additions & 11 deletions web/strategy-switch-console/config.js

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

Loading
Loading