Skip to content

Commit f060a41

Browse files
Pigbibicursoragent
andcommitted
Restrict DCA strategy profiles to Firstrade in switch tooling and console.
Co-authored-by: Cursor <cursoragent@cursor.com>
1 parent 26239b9 commit f060a41

6 files changed

Lines changed: 113 additions & 39 deletions

File tree

scripts/build_runtime_switch.py

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -177,6 +177,7 @@
177177
"ibit_smart_dca",
178178
}
179179
)
180+
DCA_SUPPORTED_PLATFORMS = frozenset({"firstrade"})
180181
DCA_MODES = frozenset({"fixed", "smart"})
181182
DCA_MODE_VARIABLE = "DCA_MODE"
182183
DCA_BASE_INVESTMENT_VARIABLE = "DCA_BASE_INVESTMENT_USD"
@@ -329,6 +330,15 @@ def _normalize_dca_mode(value: str) -> str:
329330
return mode
330331

331332

333+
def _validate_dca_platform(platform: str, strategy_profile: str) -> None:
334+
profile = str(strategy_profile or "").strip().lower()
335+
if profile in DCA_PROFILES and platform not in DCA_SUPPORTED_PLATFORMS:
336+
raise ValueError(
337+
"DCA strategy profiles are only supported on firstrade; "
338+
f"got platform={platform!r}, strategy_profile={profile!r}"
339+
)
340+
341+
332342
def _normalize_positive_decimal(value: str, *, field_name: str) -> str:
333343
text = str(value or "").strip()
334344
if not text or not re.fullmatch(r"(?:\d+|\d*\.\d+)", text):
@@ -810,6 +820,7 @@ def _build_runtime_target(args: argparse.Namespace) -> dict[str, Any]:
810820
account_selector = _split_csv(args.account_selector) or _account_selector_default(platform, account_scope)
811821
service_name = args.service_name.strip() if args.service_name else _default_service_name(platform, target_name)
812822
strategy_profile = args.strategy_profile.strip().lower()
823+
_validate_dca_platform(platform, strategy_profile)
813824
runtime_target: dict[str, Any] = {
814825
"platform_id": platform,
815826
"strategy_profile": strategy_profile,

tests/strategy_switch_worker_validation.mjs

Lines changed: 32 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -541,8 +541,8 @@ const normalizedPluginInputs = __test.normalizeSwitchInputs({
541541
});
542542
assert.equal(normalizedPluginInputs.plugin_mode, "none");
543543
const normalizedDcaInputs = __test.normalizeSwitchInputs({
544-
platform: "ibkr",
545-
target_name: "ibkr-primary",
544+
platform: "firstrade",
545+
target_name: "default",
546546
strategy_profile: "nasdaq_sp500_smart_dca",
547547
execution_mode: "live",
548548
plugin_mode: "auto",
@@ -555,9 +555,21 @@ assert.deepEqual(JSON.parse(normalizedDcaInputs.extra_variables_json), {
555555
dca_mode: "smart",
556556
dca_base_investment_usd: "500",
557557
});
558+
assert.throws(
559+
() => __test.normalizeSwitchInputs({
560+
platform: "ibkr",
561+
target_name: "ibkr-primary",
562+
strategy_profile: "nasdaq_sp500_smart_dca",
563+
execution_mode: "live",
564+
plugin_mode: "auto",
565+
dca_mode: "smart",
566+
dca_base_investment_usd: "500",
567+
}),
568+
/DCA strategy profiles are only supported on firstrade/,
569+
);
558570
const normalizedDcaJsonInputs = __test.normalizeSwitchInputs({
559-
platform: "ibkr",
560-
target_name: "ibkr-primary",
571+
platform: "firstrade",
572+
target_name: "default",
561573
strategy_profile: "nasdaq_sp500_smart_dca",
562574
execution_mode: "live",
563575
plugin_mode: "auto",
@@ -571,8 +583,8 @@ assert.deepEqual(JSON.parse(normalizedDcaJsonInputs.extra_variables_json), {
571583
dca_base_investment_usd: "500",
572584
});
573585
const normalizedIbitZscoreInputs = __test.normalizeSwitchInputs({
574-
platform: "ibkr",
575-
target_name: "ibit-primary",
586+
platform: "firstrade",
587+
target_name: "default",
576588
strategy_profile: "ibit_smart_dca",
577589
execution_mode: "live",
578590
plugin_mode: "auto",
@@ -581,14 +593,25 @@ const normalizedIbitZscoreInputs = __test.normalizeSwitchInputs({
581593
assert.deepEqual(JSON.parse(normalizedIbitZscoreInputs.extra_variables_json), {
582594
ibit_zscore_exit_mode: "live",
583595
});
596+
assert.throws(
597+
() => __test.normalizeSwitchInputs({
598+
platform: "ibkr",
599+
target_name: "ibit-primary",
600+
strategy_profile: "ibit_smart_dca",
601+
execution_mode: "live",
602+
plugin_mode: "auto",
603+
ibit_zscore_exit_mode: "live",
604+
}),
605+
/DCA strategy profiles are only supported on firstrade/,
606+
);
584607
assert.throws(
585608
() => __test.normalizeSwitchInputs({
586609
platform: "ibkr",
587610
target_name: "ibkr-primary",
588611
strategy_profile: "nasdaq_sp500_smart_dca",
589612
ibit_zscore_exit_mode: "live",
590613
}),
591-
/IBIT Z-Score exit settings/,
614+
/DCA strategy profiles are only supported on firstrade/,
592615
);
593616
assert.throws(
594617
() => __test.normalizeSwitchInputs({
@@ -617,8 +640,8 @@ assert.throws(
617640
);
618641
assert.throws(
619642
() => __test.normalizeSwitchInputs({
620-
platform: "ibkr",
621-
target_name: "ibkr-primary",
643+
platform: "firstrade",
644+
target_name: "default",
622645
strategy_profile: "nasdaq_sp500_smart_dca",
623646
dca_mode: "smart",
624647
dca_base_investment_usd: "0",

tests/test_runtime_settings.py

Lines changed: 43 additions & 25 deletions
Original file line numberDiff line numberDiff line change
@@ -542,7 +542,7 @@ def test_build_switch_target_uses_dca_monthly_scheduler_window(self):
542542
args = parser.parse_args(
543543
[
544544
"--platform",
545-
"ibkr",
545+
"firstrade",
546546
"--target-name",
547547
"dca",
548548
"--strategy-profile",
@@ -569,7 +569,7 @@ def test_build_switch_target_uses_daily_scheduler_when_ibit_zscore_plugin_is_aut
569569
args = parser.parse_args(
570570
[
571571
"--platform",
572-
"ibkr",
572+
"firstrade",
573573
"--target-name",
574574
"ibit",
575575
"--strategy-profile",
@@ -579,7 +579,7 @@ def test_build_switch_target_uses_daily_scheduler_when_ibit_zscore_plugin_is_aut
579579

580580
target = build_runtime_switch.build_switch_target(args)
581581
assignments = {item.name: item.value for item in runtime_settings.build_assignments(target)}
582-
plugin_payload = json.loads(assignments["IBKR_STRATEGY_PLUGIN_MOUNTS_JSON"])
582+
plugin_payload = json.loads(assignments["FIRSTRADE_STRATEGY_PLUGIN_MOUNTS_JSON"])
583583

584584
self.assertEqual(
585585
target["runtime_target"]["scheduler"],
@@ -601,7 +601,7 @@ def test_build_switch_target_sets_ibit_zscore_exit_runtime_controls(self):
601601
args = parser.parse_args(
602602
[
603603
"--platform",
604-
"ibkr",
604+
"firstrade",
605605
"--target-name",
606606
"ibit",
607607
"--strategy-profile",
@@ -625,7 +625,7 @@ def test_build_switch_target_disables_ibit_zscore_exit_when_plugins_are_disabled
625625
args = parser.parse_args(
626626
[
627627
"--platform",
628-
"ibkr",
628+
"firstrade",
629629
"--target-name",
630630
"ibit",
631631
"--strategy-profile",
@@ -647,7 +647,7 @@ def test_build_switch_target_rejects_ibit_zscore_controls_for_other_profiles(sel
647647
args = parser.parse_args(
648648
[
649649
"--platform",
650-
"ibkr",
650+
"firstrade",
651651
"--target-name",
652652
"dca",
653653
"--strategy-profile",
@@ -665,7 +665,7 @@ def test_build_switch_target_sets_dca_settings_for_dca_profile(self):
665665
args = parser.parse_args(
666666
[
667667
"--platform",
668-
"ibkr",
668+
"firstrade",
669669
"--target-name",
670670
"dca",
671671
"--strategy-profile",
@@ -690,7 +690,7 @@ def test_build_switch_target_accepts_dca_control_fields_from_extra_variables_jso
690690
args = parser.parse_args(
691691
[
692692
"--platform",
693-
"ibkr",
693+
"firstrade",
694694
"--target-name",
695695
"dca",
696696
"--strategy-profile",
@@ -728,7 +728,7 @@ def test_build_switch_target_rejects_dca_settings_for_non_dca_profile(self):
728728
with self.assertRaisesRegex(ValueError, "DCA settings are only supported"):
729729
build_runtime_switch.build_switch_target(args)
730730

731-
def test_build_switch_target_rejects_direct_dca_extra_variables(self):
731+
def test_build_switch_target_rejects_dca_profile_on_non_firstrade_platform(self):
732732
parser = build_runtime_switch.build_parser()
733733
args = parser.parse_args(
734734
[
@@ -738,6 +738,24 @@ def test_build_switch_target_rejects_direct_dca_extra_variables(self):
738738
"dca",
739739
"--strategy-profile",
740740
"nasdaq_sp500_smart_dca",
741+
"--plugin-mode",
742+
"none",
743+
]
744+
)
745+
746+
with self.assertRaisesRegex(ValueError, "DCA strategy profiles are only supported on firstrade"):
747+
build_runtime_switch.build_switch_target(args)
748+
749+
def test_build_switch_target_rejects_direct_dca_extra_variables(self):
750+
parser = build_runtime_switch.build_parser()
751+
args = parser.parse_args(
752+
[
753+
"--platform",
754+
"firstrade",
755+
"--target-name",
756+
"dca",
757+
"--strategy-profile",
758+
"nasdaq_sp500_smart_dca",
741759
"--extra-variables-json",
742760
'{"DCA_MODE":"smart"}',
743761
]
@@ -799,7 +817,7 @@ def test_build_switch_target_can_disable_option_overlay(self):
799817
"--target-name",
800818
"live",
801819
"--strategy-profile",
802-
"nasdaq_sp500_smart_dca",
820+
"tqqq_growth_income",
803821
"--option-overlay-mode",
804822
"disabled",
805823
]
@@ -838,9 +856,9 @@ def test_build_switch_target_rejects_enabled_option_overlay_without_profile_defa
838856
args = parser.parse_args(
839857
[
840858
"--platform",
841-
"schwab",
859+
"firstrade",
842860
"--target-name",
843-
"live",
861+
"default",
844862
"--strategy-profile",
845863
"nasdaq_sp500_smart_dca",
846864
"--option-overlay-mode",
@@ -873,18 +891,18 @@ def test_build_switch_target_preserves_dca_fields_in_service_targets_when_omitte
873891
existing = {
874892
"targets": [
875893
{
876-
"service": "interactive-brokers-demo-ibkr-dca-service",
877-
"ACCOUNT_GROUP": "demo-ibkr-dca",
894+
"service": "firstrade-quant-service",
895+
"ACCOUNT_GROUP": "firstrade",
878896
"DCA_MODE": "smart",
879897
"DCA_BASE_INVESTMENT_USD": "500",
880898
"runtime_target": {
881-
"platform_id": "ibkr",
899+
"platform_id": "firstrade",
882900
"strategy_profile": "nasdaq_sp500_smart_dca",
883901
"dry_run_only": False,
884-
"deployment_selector": "demo-ibkr-dca",
885-
"account_selector": ["DEMO_IBKR_DCA"],
886-
"account_scope": "demo-ibkr-dca",
887-
"service_name": "interactive-brokers-demo-ibkr-dca-service",
902+
"deployment_selector": "firstrade",
903+
"account_selector": ["firstrade"],
904+
"account_scope": "US",
905+
"service_name": "firstrade-quant-service",
888906
"execution_mode": "live",
889907
},
890908
},
@@ -897,15 +915,15 @@ def test_build_switch_target_preserves_dca_fields_in_service_targets_when_omitte
897915
args = parser.parse_args(
898916
[
899917
"--platform",
900-
"ibkr",
918+
"firstrade",
901919
"--target-name",
902-
"demo-ibkr-dca",
920+
"default",
903921
"--strategy-profile",
904922
"nasdaq_sp500_smart_dca",
905923
"--account-selector",
906-
"DEMO_IBKR_DCA",
924+
"firstrade",
907925
"--service-name",
908-
"interactive-brokers-demo-ibkr-dca-service",
926+
"firstrade-quant-service",
909927
"--plugin-mode",
910928
"none",
911929
"--existing-service-targets-json-file",
@@ -954,7 +972,7 @@ def test_build_switch_target_preserves_market_signal_fields_in_service_targets_w
954972
"--target-name",
955973
"demo-ibkr-dca",
956974
"--strategy-profile",
957-
"ibit_smart_dca",
975+
"tqqq_growth_income",
958976
"--account-selector",
959977
"DEMO_IBKR_DCA",
960978
"--service-name",
@@ -970,7 +988,7 @@ def test_build_switch_target_preserves_market_signal_fields_in_service_targets_w
970988
assignments = {item.name: item.value for item in runtime_settings.build_assignments(target)}
971989
selected = json.loads(assignments["CLOUD_RUN_SERVICE_TARGETS_JSON"])["targets"][0]
972990

973-
self.assertEqual(selected["runtime_target"]["strategy_profile"], "ibit_smart_dca")
991+
self.assertEqual(selected["runtime_target"]["strategy_profile"], "tqqq_growth_income")
974992
self.assertEqual(selected["IBKR_MARKET_SIGNAL_HANDOFF_INDEX_URI"], "gs://signals/index.json")
975993
self.assertEqual(selected["IBKR_MARKET_SIGNAL_REQUIRED"], "true")
976994
self.assertEqual(selected["IBKR_MARKET_SIGNAL_FALLBACK_MODE"], "last_valid")

web/strategy-switch-console/index.html

Lines changed: 15 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1711,6 +1711,7 @@ <h2 data-i18n="summary">切换摘要</h2>
17111711
dcaModeMeta: "仅定投策略可配置。",
17121712
dcaDefaultMeta: "默认:{mode},基准金额 {amount}。",
17131713
dcaNotSupported: "该策略不是定投策略",
1714+
dcaPlatformNotSupported: "定投策略仅支持 Firstrade",
17141715
currentDca: "当前定投设置",
17151716
pendingDca: "待提交定投设置",
17161717
dcaText: "{mode},基准金额 {amount}",
@@ -1876,6 +1877,7 @@ <h2 data-i18n="summary">切换摘要</h2>
18761877
dcaModeMeta: "Only DCA strategies can use this.",
18771878
dcaDefaultMeta: "Default: {mode}, base amount {amount}.",
18781879
dcaNotSupported: "This is not a DCA strategy",
1880+
dcaPlatformNotSupported: "DCA strategies are Firstrade-only",
18791881
currentDca: "Current DCA settings",
18801882
pendingDca: "Pending DCA settings",
18811883
dcaText: "{mode}, base amount {amount}",
@@ -2268,10 +2270,15 @@ <h2 data-i18n="summary">切换摘要</h2>
22682270
return supportedDomainsForAccount(platform, account).map(domainLabel).join(" / ");
22692271
}
22702272

2273+
function platformSupportsDca(platform = state.selected) {
2274+
return platform === "firstrade";
2275+
}
2276+
22712277
function strategyAllowedForAccount(platform, account, profile) {
22722278
const cleanProfile = cleanStrategyProfile(profile);
22732279
const catalogEntry = strategyCatalog[cleanProfile];
22742280
if (!catalogEntry || catalogEntry.runtime_enabled !== true) return false;
2281+
if (dcaConfigForStrategy(cleanProfile) && !platformSupportsDca(platform)) return false;
22752282
return supportedDomainsForAccount(platform, account).includes(catalogEntry.domain);
22762283
}
22772284

@@ -2898,7 +2905,7 @@ <h2 data-i18n="summary">切换摘要</h2>
28982905

28992906
function hasValidDcaPolicy(platform = state.selected) {
29002907
const form = state.forms[platform];
2901-
if (!dcaSupported(form?.strategy)) return true;
2908+
if (!dcaSupported(form?.strategy) || !platformSupportsDca(platform)) return true;
29022909
return Boolean(dcaModes.includes(normalizeDcaMode(form?.dcaMode)) && cleanDisplayPositiveNumber(form?.dcaBaseInvestmentUsd));
29032910
}
29042911

@@ -2996,7 +3003,7 @@ <h2 data-i18n="summary">切换摘要</h2>
29963003
}
29973004

29983005
function dcaOverrideForForm(form) {
2999-
if (!dcaSupported(form?.strategy)) return null;
3006+
if (!dcaSupported(form?.strategy) || !platformSupportsDca(state.selected)) return null;
30003007
const mode = normalizeDcaMode(form?.dcaMode);
30013008
const baseInvestmentUsd = cleanDisplayPositiveNumber(form?.dcaBaseInvestmentUsd);
30023009
if (!baseInvestmentUsd) return null;
@@ -3843,7 +3850,8 @@ <h2 data-i18n="summary">切换摘要</h2>
38433850

38443851
const dcaDefaults = dcaConfigForStrategy(form.strategy);
38453852
dcaModeSelect.replaceChildren();
3846-
if (dcaDefaults) {
3853+
const dcaAllowed = Boolean(dcaDefaults) && platformSupportsDca(platform);
3854+
if (dcaAllowed) {
38473855
dcaModeSelect.disabled = false;
38483856
for (const mode of dcaModes) {
38493857
dcaModeSelect.append(new Option(dcaModeLabel(mode), mode, false, mode === normalizeDcaMode(form.dcaMode)));
@@ -3859,7 +3867,10 @@ <h2 data-i18n="summary">切换摘要</h2>
38593867
el("dca-base-meta").textContent = t("dcaModeMeta");
38603868
} else {
38613869
dcaModeSelect.disabled = true;
3862-
dcaModeSelect.append(new Option(t("dcaNotSupported"), "fixed"));
3870+
dcaModeSelect.append(new Option(
3871+
dcaDefaults && !platformSupportsDca(platform) ? t("dcaPlatformNotSupported") : t("dcaNotSupported"),
3872+
"fixed",
3873+
));
38633874
dcaBaseInvestmentUsdInput.disabled = true;
38643875
dcaBaseInvestmentUsdInput.value = "";
38653876
el("dca-mode-meta").textContent = t("dcaModeMeta");

web/strategy-switch-console/page_asset.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)