Skip to content

Commit 4ecf40b

Browse files
authored
Add DCA strategy switch controls (#57)
1 parent eddeddf commit 4ecf40b

9 files changed

Lines changed: 699 additions & 26 deletions

File tree

.github/workflows/manual-strategy-switch.yml

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -98,6 +98,14 @@ on:
9898
description: "Optional TQQQ QQQI income ratio override."
9999
required: false
100100
type: string
101+
dca_mode:
102+
description: "Optional DCA mode for DCA profiles only: fixed or smart."
103+
required: false
104+
type: string
105+
dca_base_investment_usd:
106+
description: "Optional base DCA investment amount for DCA profiles only."
107+
required: false
108+
type: string
101109
service_targets_mode:
102110
description: "auto patches IBKR CLOUD_RUN_SERVICE_TARGETS_JSON when it exists."
103111
required: true
@@ -159,6 +167,8 @@ jobs:
159167
INCOME_LAYER_MAX_RATIO: ${{ inputs.income_layer_max_ratio }}
160168
INCOME_THRESHOLD_USD: ${{ inputs.income_threshold_usd }}
161169
QQQI_INCOME_RATIO: ${{ inputs.qqqi_income_ratio }}
170+
DCA_MODE: ${{ inputs.dca_mode }}
171+
DCA_BASE_INVESTMENT_USD: ${{ inputs.dca_base_investment_usd }}
162172
SERVICE_TARGETS_MODE: ${{ inputs.service_targets_mode }}
163173
APPLY_SWITCH: ${{ inputs.apply }}
164174
TRIGGER_PLATFORM_SYNC: ${{ inputs.trigger_platform_sync }}
@@ -303,6 +313,12 @@ jobs:
303313
if [ -n "${QQQI_INCOME_RATIO:-}" ]; then
304314
args+=(--qqqi-income-ratio "${QQQI_INCOME_RATIO}")
305315
fi
316+
if [ -n "${DCA_MODE:-}" ]; then
317+
args+=(--dca-mode "${DCA_MODE}")
318+
fi
319+
if [ -n "${DCA_BASE_INVESTMENT_USD:-}" ]; then
320+
args+=(--dca-base-investment-usd "${DCA_BASE_INVESTMENT_USD}")
321+
fi
306322
if [ -s "${EXISTING_SERVICE_TARGETS_JSON_FILE:-}" ]; then
307323
args+=(--existing-service-targets-json-file "${EXISTING_SERVICE_TARGETS_JSON_FILE}")
308324
fi
@@ -395,6 +411,11 @@ jobs:
395411
"account_scope": runtime_target["account_scope"],
396412
"service_name": runtime_target["service_name"],
397413
}
414+
extra_variables = target.get("extra_variables") or {}
415+
if extra_variables.get("DCA_MODE"):
416+
payload["dca_mode"] = extra_variables["DCA_MODE"]
417+
if extra_variables.get("DCA_BASE_INVESTMENT_USD"):
418+
payload["dca_base_investment_usd"] = extra_variables["DCA_BASE_INVESTMENT_USD"]
398419
if github.get("environment"):
399420
payload["github_environment"] = github["environment"]
400421

scripts/build_runtime_switch.py

Lines changed: 73 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -95,6 +95,19 @@
9595
RUNTIME_TARGET_VARIABLES = (
9696
"RUNTIME_TARGET_ENABLED",
9797
)
98+
DCA_PROFILES = frozenset(
99+
{
100+
"nasdaq_sp500_smart_dca",
101+
"ibit_smart_dca",
102+
}
103+
)
104+
DCA_MODES = frozenset({"fixed", "smart"})
105+
DCA_MODE_VARIABLE = "DCA_MODE"
106+
DCA_BASE_INVESTMENT_VARIABLE = "DCA_BASE_INVESTMENT_USD"
107+
DCA_RUNTIME_VARIABLES = (
108+
DCA_MODE_VARIABLE,
109+
DCA_BASE_INVESTMENT_VARIABLE,
110+
)
98111
DEFAULT_VARIABLE_SCOPE = {
99112
"longbridge": "environment",
100113
"ibkr": "repository",
@@ -199,6 +212,61 @@ def _parse_extra_variables(pairs: list[str], raw_json: str) -> dict[str, Any]:
199212
return extras
200213

201214

215+
def _normalize_dca_mode(value: str) -> str:
216+
mode = str(value or "").strip().lower()
217+
aliases = {
218+
"ordinary": "fixed",
219+
"ordinary_dca": "fixed",
220+
"fixed_dca": "fixed",
221+
"smart_dca": "smart",
222+
}
223+
mode = aliases.get(mode, mode)
224+
if mode not in DCA_MODES:
225+
raise ValueError("dca_mode must be fixed or smart")
226+
return mode
227+
228+
229+
def _normalize_positive_decimal(value: str, *, field_name: str) -> str:
230+
text = str(value or "").strip()
231+
if not text or not re.fullmatch(r"(?:\d+|\d*\.\d+)", text):
232+
raise ValueError(f"{field_name} must be a positive decimal number")
233+
numeric = float(text)
234+
if not numeric > 0:
235+
raise ValueError(f"{field_name} must be greater than 0")
236+
return text
237+
238+
239+
def _dca_extra_variables(args: argparse.Namespace, strategy_profile: str) -> dict[str, Any]:
240+
is_dca_profile = strategy_profile in DCA_PROFILES
241+
has_dca_mode = bool(str(args.dca_mode or "").strip())
242+
has_dca_base = bool(str(args.dca_base_investment_usd or "").strip())
243+
if not is_dca_profile:
244+
if has_dca_mode or has_dca_base:
245+
raise ValueError("DCA settings are only supported for DCA strategy profiles")
246+
return {variable: "" for variable in DCA_RUNTIME_VARIABLES}
247+
248+
extra_variables: dict[str, Any] = {}
249+
if has_dca_mode:
250+
extra_variables[DCA_MODE_VARIABLE] = _normalize_dca_mode(args.dca_mode)
251+
if has_dca_base:
252+
extra_variables[DCA_BASE_INVESTMENT_VARIABLE] = _normalize_positive_decimal(
253+
args.dca_base_investment_usd,
254+
field_name="dca_base_investment_usd",
255+
)
256+
return extra_variables
257+
258+
259+
def _reject_direct_dca_extra_variables(extra_variables: dict[str, Any]) -> None:
260+
provided = [
261+
variable
262+
for variable in DCA_RUNTIME_VARIABLES
263+
if variable in extra_variables and str(extra_variables.get(variable) or "").strip()
264+
]
265+
if provided:
266+
names = ", ".join(provided)
267+
raise ValueError(f"use --dca-mode and --dca-base-investment-usd instead of extra_variables_json for {names}")
268+
269+
202270
def _auto_plugin_mounts(strategy_profile: str, artifact_bucket_uri: str) -> list[dict[str, Any]]:
203271
if strategy_profile not in MARKET_REGIME_CONTROL_PROFILES:
204272
return []
@@ -330,6 +398,7 @@ def _preserve_reserved_cash_fields(
330398
PLATFORM_RESERVED_CASH_RATIO_VARIABLES.get(platform),
331399
*INCOME_LAYER_VARIABLES,
332400
*RUNTIME_TARGET_VARIABLES,
401+
*DCA_RUNTIME_VARIABLES,
333402
):
334403
if variable and variable not in replacement and variable in current_entry:
335404
replacement[variable] = current_entry[variable]
@@ -394,6 +463,7 @@ def build_switch_target(args: argparse.Namespace) -> dict[str, Any]:
394463
mounts = _plugin_mounts(args, runtime_target["strategy_profile"])
395464
mounts_variable = f"{SUPPORTED_PLATFORMS[platform]['plugin_mounts_prefix']}STRATEGY_PLUGIN_MOUNTS_JSON"
396465
extra_variables = _parse_extra_variables(args.extra_variable, args.extra_variables_json)
466+
_reject_direct_dca_extra_variables(extra_variables)
397467

398468
if args.set_platform_dry_run_variable:
399469
extra_variables[PLATFORM_DRY_RUN_VARIABLES[platform]] = env_string(runtime_target["dry_run_only"])
@@ -409,6 +479,7 @@ def build_switch_target(args: argparse.Namespace) -> dict[str, Any]:
409479
extra_variables["INCOME_THRESHOLD_USD"] = args.income_threshold_usd
410480
if args.qqqi_income_ratio:
411481
extra_variables["QQQI_INCOME_RATIO"] = args.qqqi_income_ratio
482+
extra_variables.update(_dca_extra_variables(args, runtime_target["strategy_profile"]))
412483

413484
service_targets = _load_json_from_file(
414485
args.existing_service_targets_json_file,
@@ -474,6 +545,8 @@ def build_parser() -> argparse.ArgumentParser:
474545
parser.add_argument("--income-layer-max-ratio", default="")
475546
parser.add_argument("--income-threshold-usd", default="")
476547
parser.add_argument("--qqqi-income-ratio", default="")
548+
parser.add_argument("--dca-mode", default="")
549+
parser.add_argument("--dca-base-investment-usd", default="")
477550
parser.add_argument("--existing-service-targets-json-file", default="")
478551
parser.add_argument("--no-platform-dry-run-variable", dest="set_platform_dry_run_variable", action="store_false")
479552
parser.set_defaults(set_platform_dry_run_variable=True)

tests/strategy_switch_worker_validation.mjs

Lines changed: 83 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -31,14 +31,24 @@ assert.ok(indexHtml.includes('id="income-layer-start-usd-input"'));
3131
assert.ok(indexHtml.includes('incomeLayerStartUsd: "收入层起始金额"'));
3232
assert.ok(indexHtml.includes('incomeLayerStartUsd: "Income layer start amount"'));
3333
assert.ok(indexHtml.includes('incomeLayerStartUsdVariable = "INCOME_LAYER_START_USD"'));
34+
assert.ok(indexHtml.includes('id="dca-mode-select"'));
35+
assert.ok(indexHtml.includes('id="dca-base-investment-usd-input"'));
36+
assert.ok(indexHtml.includes('dcaMode: "定投模式"'));
37+
assert.ok(indexHtml.includes('dcaModeFixed: "定额定投"'));
38+
assert.ok(indexHtml.includes('dcaModeSmart: "智能定投"'));
39+
assert.ok(indexHtml.includes('dcaMode: "DCA mode"'));
40+
assert.ok(indexHtml.includes('dcaProfileDefaults'));
3441
assert.ok(indexHtml.includes('el("income-layer-mode-select").addEventListener("change"'));
3542
assert.ok(indexHtml.includes('el("income-layer-start-usd-input").addEventListener("input"'));
3643
assert.ok(indexHtml.includes('el("income-layer-max-ratio-input").addEventListener("input"'));
37-
assert.ok(indexHtml.includes('label_zh: "纳指100 / 标普500 智能定投"'));
44+
assert.ok(indexHtml.includes('el("dca-mode-select").addEventListener("change"'));
45+
assert.ok(indexHtml.includes('el("dca-base-investment-usd-input").addEventListener("input"'));
46+
assert.ok(indexHtml.includes('label_zh: "纳指100 / 标普500 定投"'));
3847
assert.ok(indexHtml.includes('class="form-section income-layer-section"'));
48+
assert.ok(indexHtml.includes('class="form-section dca-section"'));
3949
assert.ok(indexHtml.includes('class="control-block reserve-policy-block section-wide"'));
4050
assert.ok(indexHtml.includes('profile: "ibit_smart_dca"'));
41-
assert.ok(indexHtml.includes('IBIT 比特币 ETF 智能定投'));
51+
assert.ok(indexHtml.includes('IBIT 比特币定投'));
4252
assert.ok(indexHtml.includes('localStrategyLabels'));
4353
assert.ok(indexHtml.includes('function strategyLabelSet('));
4454
assert.ok(indexHtml.includes("account-block"));
@@ -221,11 +231,21 @@ const strategyProfiles = __test.normalizeStrategyProfilesPayload(
221231
domain: "hk_equity",
222232
runtime_enabled: true,
223233
},
234+
{
235+
profile: "nasdaq_sp500_smart_dca",
236+
label: "Nasdaq 100 / S&P 500 DCA",
237+
label_zh: "纳指100 / 标普500 定投",
238+
domain: "us_equity",
239+
runtime_enabled: true,
240+
},
224241
],
225242
"test_strategy_profiles",
226243
);
227244
assert.equal(strategyProfiles[0].label_en, "TQQQ Growth Income");
228245
assert.equal(strategyProfiles[0].label_zh, "TQQQ 增长收益");
246+
assert.equal(strategyProfiles[2].dca_enabled, true);
247+
assert.equal(strategyProfiles[2].dca_default_mode, "fixed");
248+
assert.equal(strategyProfiles[2].dca_default_base_investment_usd, "1000");
229249

230250
const accountOptions = __test.normalizeAccountOptionsPayload(
231251
{
@@ -345,6 +365,45 @@ const normalizedPluginInputs = __test.normalizeSwitchInputs({
345365
plugin_mode: "none",
346366
});
347367
assert.equal(normalizedPluginInputs.plugin_mode, "none");
368+
const normalizedDcaInputs = __test.normalizeSwitchInputs({
369+
platform: "ibkr",
370+
target_name: "ibkr-primary",
371+
strategy_profile: "nasdaq_sp500_smart_dca",
372+
execution_mode: "live",
373+
plugin_mode: "auto",
374+
dca_mode: "smart",
375+
dca_base_investment_usd: "500",
376+
});
377+
assert.equal(normalizedDcaInputs.dca_mode, "smart");
378+
assert.equal(normalizedDcaInputs.dca_base_investment_usd, "500");
379+
assert.throws(
380+
() => __test.normalizeSwitchInputs({
381+
platform: "ibkr",
382+
target_name: "ibkr-primary",
383+
strategy_profile: "tqqq_growth_income",
384+
dca_mode: "smart",
385+
}),
386+
/DCA settings are only supported/,
387+
);
388+
assert.throws(
389+
() => __test.normalizeSwitchInputs({
390+
platform: "ibkr",
391+
target_name: "ibkr-primary",
392+
strategy_profile: "nasdaq_sp500_smart_dca",
393+
dca_mode: "smart",
394+
dca_base_investment_usd: "0",
395+
}),
396+
/dca_base_investment_usd must be greater than 0/,
397+
);
398+
assert.throws(
399+
() => __test.normalizeSwitchInputs({
400+
platform: "ibkr",
401+
target_name: "ibkr-primary",
402+
strategy_profile: "tqqq_growth_income",
403+
extra_variables_json: JSON.stringify({ DCA_MODE: "smart" }),
404+
}),
405+
/instead of extra_variables_json/,
406+
);
348407
const normalizedReserveClearInputs = __test.normalizeSwitchInputs({
349408
platform: "ibkr",
350409
target_name: "ibkr-primary",
@@ -466,11 +525,23 @@ globalThis.fetch = async (url) => {
466525
headers: { "Content-Type": "application/json" },
467526
});
468527
}
528+
if (requestUrl.endsWith("/DCA_MODE")) {
529+
return new Response(JSON.stringify({ value: "smart" }), {
530+
status: 200,
531+
headers: { "Content-Type": "application/json" },
532+
});
533+
}
534+
if (requestUrl.endsWith("/DCA_BASE_INVESTMENT_USD")) {
535+
return new Response(JSON.stringify({ value: "500" }), {
536+
status: 200,
537+
headers: { "Content-Type": "application/json" },
538+
});
539+
}
469540
if (requestUrl.endsWith("/RUNTIME_TARGET_JSON")) {
470541
return new Response(JSON.stringify({
471542
value: JSON.stringify({
472543
platform_id: "schwab",
473-
strategy_profile: "soxl_soxx_trend_income",
544+
strategy_profile: "nasdaq_sp500_smart_dca",
474545
dry_run_only: false,
475546
account_scope: "schwab",
476547
service_name: "charles-schwab-quant-service",
@@ -485,13 +556,15 @@ try {
485556
{ schwab: accountOptions.schwab },
486557
{ RUNTIME_SETTINGS_DISPATCH_TOKEN: "test-token" },
487558
);
488-
assert.equal(currentStrategies.schwab.default.strategy_profile, "soxl_soxx_trend_income");
559+
assert.equal(currentStrategies.schwab.default.strategy_profile, "nasdaq_sp500_smart_dca");
489560
assert.equal(currentStrategies.schwab.default.execution_mode, "live");
490561
assert.equal(currentStrategies.schwab.default.min_reserved_cash_usd, "150");
491562
assert.equal(currentStrategies.schwab.default.reserved_cash_ratio, "0.03");
492563
assert.equal(currentStrategies.schwab.default.income_layer_start_usd, "150000");
493564
assert.equal(currentStrategies.schwab.default.income_layer_max_ratio, "0.95");
494565
assert.equal(currentStrategies.schwab.default.runtime_target_enabled, false);
566+
assert.equal(currentStrategies.schwab.default.dca_mode, "smart");
567+
assert.equal(currentStrategies.schwab.default.dca_base_investment_usd, "500");
495568
assert.equal(currentStrategies.schwab.default.source, "RUNTIME_TARGET_JSON");
496569
} finally {
497570
globalThis.fetch = originalFetch;
@@ -511,9 +584,11 @@ globalThis.fetch = async (url) => {
511584
INCOME_LAYER_START_USD: "250000",
512585
INCOME_LAYER_MAX_RATIO: "0.55",
513586
RUNTIME_TARGET_ENABLED: "false",
587+
DCA_MODE: "smart",
588+
DCA_BASE_INVESTMENT_USD: "700",
514589
runtime_target: {
515590
platform_id: "ibkr",
516-
strategy_profile: "tqqq_growth_income",
591+
strategy_profile: "ibit_smart_dca",
517592
dry_run_only: false,
518593
account_scope: "demo-ibkr-tqqq",
519594
service_name: "interactive-brokers-demo-ibkr-tqqq-service",
@@ -531,12 +606,14 @@ try {
531606
{ ibkr: accountOptions.ibkr },
532607
{ RUNTIME_SETTINGS_DISPATCH_TOKEN: "test-token" },
533608
);
534-
assert.equal(currentStrategies.ibkr["ibkr-primary"].strategy_profile, "tqqq_growth_income");
609+
assert.equal(currentStrategies.ibkr["ibkr-primary"].strategy_profile, "ibit_smart_dca");
535610
assert.equal(currentStrategies.ibkr["ibkr-primary"].min_reserved_cash_usd, "150");
536611
assert.equal(currentStrategies.ibkr["ibkr-primary"].reserved_cash_ratio, "0.03");
537612
assert.equal(currentStrategies.ibkr["ibkr-primary"].income_layer_start_usd, "250000");
538613
assert.equal(currentStrategies.ibkr["ibkr-primary"].income_layer_max_ratio, "0.55");
539614
assert.equal(currentStrategies.ibkr["ibkr-primary"].runtime_target_enabled, false);
615+
assert.equal(currentStrategies.ibkr["ibkr-primary"].dca_mode, "smart");
616+
assert.equal(currentStrategies.ibkr["ibkr-primary"].dca_base_investment_usd, "700");
540617
assert.equal(currentStrategies.ibkr["ibkr-primary"].source, "CLOUD_RUN_SERVICE_TARGETS_JSON");
541618
} finally {
542619
globalThis.fetch = originalFetch;

0 commit comments

Comments
 (0)