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
11 changes: 11 additions & 0 deletions scripts/build_runtime_switch.py
Original file line number Diff line number Diff line change
Expand Up @@ -177,6 +177,7 @@
"ibit_smart_dca",
}
)
DCA_SUPPORTED_PLATFORMS = frozenset({"firstrade"})
DCA_MODES = frozenset({"fixed", "smart"})
DCA_MODE_VARIABLE = "DCA_MODE"
DCA_BASE_INVESTMENT_VARIABLE = "DCA_BASE_INVESTMENT_USD"
Expand Down Expand Up @@ -329,6 +330,15 @@ def _normalize_dca_mode(value: str) -> str:
return mode


def _validate_dca_platform(platform: str, strategy_profile: str) -> None:
profile = str(strategy_profile or "").strip().lower()
if profile in DCA_PROFILES and platform not in DCA_SUPPORTED_PLATFORMS:
raise ValueError(
"DCA strategy profiles are only supported on firstrade; "
f"got platform={platform!r}, strategy_profile={profile!r}"
)


def _normalize_positive_decimal(value: str, *, field_name: str) -> str:
text = str(value or "").strip()
if not text or not re.fullmatch(r"(?:\d+|\d*\.\d+)", text):
Expand Down Expand Up @@ -810,6 +820,7 @@ def _build_runtime_target(args: argparse.Namespace) -> dict[str, Any]:
account_selector = _split_csv(args.account_selector) or _account_selector_default(platform, account_scope)
service_name = args.service_name.strip() if args.service_name else _default_service_name(platform, target_name)
strategy_profile = args.strategy_profile.strip().lower()
_validate_dca_platform(platform, strategy_profile)
runtime_target: dict[str, Any] = {
"platform_id": platform,
"strategy_profile": strategy_profile,
Expand Down
41 changes: 32 additions & 9 deletions tests/strategy_switch_worker_validation.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -541,8 +541,8 @@ const normalizedPluginInputs = __test.normalizeSwitchInputs({
});
assert.equal(normalizedPluginInputs.plugin_mode, "none");
const normalizedDcaInputs = __test.normalizeSwitchInputs({
platform: "ibkr",
target_name: "ibkr-primary",
platform: "firstrade",
target_name: "default",
strategy_profile: "nasdaq_sp500_smart_dca",
execution_mode: "live",
plugin_mode: "auto",
Expand All @@ -555,9 +555,21 @@ assert.deepEqual(JSON.parse(normalizedDcaInputs.extra_variables_json), {
dca_mode: "smart",
dca_base_investment_usd: "500",
});
assert.throws(
() => __test.normalizeSwitchInputs({
platform: "ibkr",
target_name: "ibkr-primary",
strategy_profile: "nasdaq_sp500_smart_dca",
execution_mode: "live",
plugin_mode: "auto",
dca_mode: "smart",
dca_base_investment_usd: "500",
}),
/DCA strategy profiles are only supported on firstrade/,
);
const normalizedDcaJsonInputs = __test.normalizeSwitchInputs({
platform: "ibkr",
target_name: "ibkr-primary",
platform: "firstrade",
target_name: "default",
strategy_profile: "nasdaq_sp500_smart_dca",
execution_mode: "live",
plugin_mode: "auto",
Expand All @@ -571,8 +583,8 @@ assert.deepEqual(JSON.parse(normalizedDcaJsonInputs.extra_variables_json), {
dca_base_investment_usd: "500",
});
const normalizedIbitZscoreInputs = __test.normalizeSwitchInputs({
platform: "ibkr",
target_name: "ibit-primary",
platform: "firstrade",
target_name: "default",
strategy_profile: "ibit_smart_dca",
execution_mode: "live",
plugin_mode: "auto",
Expand All @@ -581,14 +593,25 @@ const normalizedIbitZscoreInputs = __test.normalizeSwitchInputs({
assert.deepEqual(JSON.parse(normalizedIbitZscoreInputs.extra_variables_json), {
ibit_zscore_exit_mode: "live",
});
assert.throws(
() => __test.normalizeSwitchInputs({
platform: "ibkr",
target_name: "ibit-primary",
strategy_profile: "ibit_smart_dca",
execution_mode: "live",
plugin_mode: "auto",
ibit_zscore_exit_mode: "live",
}),
/DCA strategy profiles are only supported on firstrade/,
);
assert.throws(
() => __test.normalizeSwitchInputs({
platform: "ibkr",
target_name: "ibkr-primary",
strategy_profile: "nasdaq_sp500_smart_dca",
ibit_zscore_exit_mode: "live",
}),
/IBIT Z-Score exit settings/,
/DCA strategy profiles are only supported on firstrade/,
);
assert.throws(
() => __test.normalizeSwitchInputs({
Expand Down Expand Up @@ -617,8 +640,8 @@ assert.throws(
);
assert.throws(
() => __test.normalizeSwitchInputs({
platform: "ibkr",
target_name: "ibkr-primary",
platform: "firstrade",
target_name: "default",
strategy_profile: "nasdaq_sp500_smart_dca",
dca_mode: "smart",
dca_base_investment_usd: "0",
Expand Down
68 changes: 43 additions & 25 deletions tests/test_runtime_settings.py
Original file line number Diff line number Diff line change
Expand Up @@ -542,7 +542,7 @@ def test_build_switch_target_uses_dca_monthly_scheduler_window(self):
args = parser.parse_args(
[
"--platform",
"ibkr",
"firstrade",
"--target-name",
"dca",
"--strategy-profile",
Expand All @@ -569,7 +569,7 @@ def test_build_switch_target_uses_daily_scheduler_when_ibit_zscore_plugin_is_aut
args = parser.parse_args(
[
"--platform",
"ibkr",
"firstrade",
"--target-name",
"ibit",
"--strategy-profile",
Expand All @@ -579,7 +579,7 @@ def test_build_switch_target_uses_daily_scheduler_when_ibit_zscore_plugin_is_aut

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

self.assertEqual(
target["runtime_target"]["scheduler"],
Expand All @@ -601,7 +601,7 @@ def test_build_switch_target_sets_ibit_zscore_exit_runtime_controls(self):
args = parser.parse_args(
[
"--platform",
"ibkr",
"firstrade",
"--target-name",
"ibit",
"--strategy-profile",
Expand All @@ -625,7 +625,7 @@ def test_build_switch_target_disables_ibit_zscore_exit_when_plugins_are_disabled
args = parser.parse_args(
[
"--platform",
"ibkr",
"firstrade",
"--target-name",
"ibit",
"--strategy-profile",
Expand All @@ -647,7 +647,7 @@ def test_build_switch_target_rejects_ibit_zscore_controls_for_other_profiles(sel
args = parser.parse_args(
[
"--platform",
"ibkr",
"firstrade",
"--target-name",
"dca",
"--strategy-profile",
Expand All @@ -665,7 +665,7 @@ def test_build_switch_target_sets_dca_settings_for_dca_profile(self):
args = parser.parse_args(
[
"--platform",
"ibkr",
"firstrade",
"--target-name",
"dca",
"--strategy-profile",
Expand All @@ -690,7 +690,7 @@ def test_build_switch_target_accepts_dca_control_fields_from_extra_variables_jso
args = parser.parse_args(
[
"--platform",
"ibkr",
"firstrade",
"--target-name",
"dca",
"--strategy-profile",
Expand Down Expand Up @@ -728,7 +728,7 @@ def test_build_switch_target_rejects_dca_settings_for_non_dca_profile(self):
with self.assertRaisesRegex(ValueError, "DCA settings are only supported"):
build_runtime_switch.build_switch_target(args)

def test_build_switch_target_rejects_direct_dca_extra_variables(self):
def test_build_switch_target_rejects_dca_profile_on_non_firstrade_platform(self):
parser = build_runtime_switch.build_parser()
args = parser.parse_args(
[
Expand All @@ -738,6 +738,24 @@ def test_build_switch_target_rejects_direct_dca_extra_variables(self):
"dca",
"--strategy-profile",
"nasdaq_sp500_smart_dca",
"--plugin-mode",
"none",
]
)

with self.assertRaisesRegex(ValueError, "DCA strategy profiles are only supported on firstrade"):
build_runtime_switch.build_switch_target(args)

def test_build_switch_target_rejects_direct_dca_extra_variables(self):
parser = build_runtime_switch.build_parser()
args = parser.parse_args(
[
"--platform",
"firstrade",
"--target-name",
"dca",
"--strategy-profile",
"nasdaq_sp500_smart_dca",
"--extra-variables-json",
'{"DCA_MODE":"smart"}',
]
Expand Down Expand Up @@ -799,7 +817,7 @@ def test_build_switch_target_can_disable_option_overlay(self):
"--target-name",
"live",
"--strategy-profile",
"nasdaq_sp500_smart_dca",
"tqqq_growth_income",
"--option-overlay-mode",
"disabled",
]
Expand Down Expand Up @@ -838,9 +856,9 @@ def test_build_switch_target_rejects_enabled_option_overlay_without_profile_defa
args = parser.parse_args(
[
"--platform",
"schwab",
"firstrade",
"--target-name",
"live",
"default",
"--strategy-profile",
"nasdaq_sp500_smart_dca",
"--option-overlay-mode",
Expand Down Expand Up @@ -873,18 +891,18 @@ def test_build_switch_target_preserves_dca_fields_in_service_targets_when_omitte
existing = {
"targets": [
{
"service": "interactive-brokers-demo-ibkr-dca-service",
"ACCOUNT_GROUP": "demo-ibkr-dca",
"service": "firstrade-quant-service",
"ACCOUNT_GROUP": "firstrade",
"DCA_MODE": "smart",
"DCA_BASE_INVESTMENT_USD": "500",
"runtime_target": {
"platform_id": "ibkr",
"platform_id": "firstrade",
"strategy_profile": "nasdaq_sp500_smart_dca",
"dry_run_only": False,
"deployment_selector": "demo-ibkr-dca",
"account_selector": ["DEMO_IBKR_DCA"],
"account_scope": "demo-ibkr-dca",
"service_name": "interactive-brokers-demo-ibkr-dca-service",
"deployment_selector": "firstrade",
"account_selector": ["firstrade"],
"account_scope": "US",
"service_name": "firstrade-quant-service",
"execution_mode": "live",
},
},
Expand All @@ -897,15 +915,15 @@ def test_build_switch_target_preserves_dca_fields_in_service_targets_when_omitte
args = parser.parse_args(
[
"--platform",
"ibkr",
"firstrade",
"--target-name",
"demo-ibkr-dca",
"default",
"--strategy-profile",
"nasdaq_sp500_smart_dca",
"--account-selector",
"DEMO_IBKR_DCA",
"firstrade",
"--service-name",
"interactive-brokers-demo-ibkr-dca-service",
"firstrade-quant-service",
"--plugin-mode",
"none",
"--existing-service-targets-json-file",
Expand Down Expand Up @@ -954,7 +972,7 @@ def test_build_switch_target_preserves_market_signal_fields_in_service_targets_w
"--target-name",
"demo-ibkr-dca",
"--strategy-profile",
"ibit_smart_dca",
"tqqq_growth_income",
"--account-selector",
"DEMO_IBKR_DCA",
"--service-name",
Expand All @@ -970,7 +988,7 @@ def test_build_switch_target_preserves_market_signal_fields_in_service_targets_w
assignments = {item.name: item.value for item in runtime_settings.build_assignments(target)}
selected = json.loads(assignments["CLOUD_RUN_SERVICE_TARGETS_JSON"])["targets"][0]

self.assertEqual(selected["runtime_target"]["strategy_profile"], "ibit_smart_dca")
self.assertEqual(selected["runtime_target"]["strategy_profile"], "tqqq_growth_income")
self.assertEqual(selected["IBKR_MARKET_SIGNAL_HANDOFF_INDEX_URI"], "gs://signals/index.json")
self.assertEqual(selected["IBKR_MARKET_SIGNAL_REQUIRED"], "true")
self.assertEqual(selected["IBKR_MARKET_SIGNAL_FALLBACK_MODE"], "last_valid")
Expand Down
19 changes: 15 additions & 4 deletions web/strategy-switch-console/index.html
Original file line number Diff line number Diff line change
Expand Up @@ -1711,6 +1711,7 @@ <h2 data-i18n="summary">切换摘要</h2>
dcaModeMeta: "仅定投策略可配置。",
dcaDefaultMeta: "默认:{mode},基准金额 {amount}。",
dcaNotSupported: "该策略不是定投策略",
dcaPlatformNotSupported: "定投策略仅支持 Firstrade",
currentDca: "当前定投设置",
pendingDca: "待提交定投设置",
dcaText: "{mode},基准金额 {amount}",
Expand Down Expand Up @@ -1876,6 +1877,7 @@ <h2 data-i18n="summary">切换摘要</h2>
dcaModeMeta: "Only DCA strategies can use this.",
dcaDefaultMeta: "Default: {mode}, base amount {amount}.",
dcaNotSupported: "This is not a DCA strategy",
dcaPlatformNotSupported: "DCA strategies are Firstrade-only",
currentDca: "Current DCA settings",
pendingDca: "Pending DCA settings",
dcaText: "{mode}, base amount {amount}",
Expand Down Expand Up @@ -2268,10 +2270,15 @@ <h2 data-i18n="summary">切换摘要</h2>
return supportedDomainsForAccount(platform, account).map(domainLabel).join(" / ");
}

function platformSupportsDca(platform = state.selected) {
return platform === "firstrade";
}

function strategyAllowedForAccount(platform, account, profile) {
const cleanProfile = cleanStrategyProfile(profile);
const catalogEntry = strategyCatalog[cleanProfile];
if (!catalogEntry || catalogEntry.runtime_enabled !== true) return false;
if (dcaConfigForStrategy(cleanProfile) && !platformSupportsDca(platform)) return false;
return supportedDomainsForAccount(platform, account).includes(catalogEntry.domain);
}

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

function hasValidDcaPolicy(platform = state.selected) {
const form = state.forms[platform];
if (!dcaSupported(form?.strategy)) return true;
if (!dcaSupported(form?.strategy) || !platformSupportsDca(platform)) return true;
return Boolean(dcaModes.includes(normalizeDcaMode(form?.dcaMode)) && cleanDisplayPositiveNumber(form?.dcaBaseInvestmentUsd));
}

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

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

const dcaDefaults = dcaConfigForStrategy(form.strategy);
dcaModeSelect.replaceChildren();
if (dcaDefaults) {
const dcaAllowed = Boolean(dcaDefaults) && platformSupportsDca(platform);
if (dcaAllowed) {
dcaModeSelect.disabled = false;
for (const mode of dcaModes) {
dcaModeSelect.append(new Option(dcaModeLabel(mode), mode, false, mode === normalizeDcaMode(form.dcaMode)));
Expand All @@ -3859,7 +3867,10 @@ <h2 data-i18n="summary">切换摘要</h2>
el("dca-base-meta").textContent = t("dcaModeMeta");
} else {
dcaModeSelect.disabled = true;
dcaModeSelect.append(new Option(t("dcaNotSupported"), "fixed"));
dcaModeSelect.append(new Option(
dcaDefaults && !platformSupportsDca(platform) ? t("dcaPlatformNotSupported") : t("dcaNotSupported"),
"fixed",
));
dcaBaseInvestmentUsdInput.disabled = true;
dcaBaseInvestmentUsdInput.value = "";
el("dca-mode-meta").textContent = t("dcaModeMeta");
Expand Down
2 changes: 1 addition & 1 deletion web/strategy-switch-console/page_asset.js

Large diffs are not rendered by default.

Loading
Loading