Skip to content

Commit 7178a06

Browse files
committed
Route DCA workflow settings through extra variables
1 parent 4ecf40b commit 7178a06

5 files changed

Lines changed: 129 additions & 39 deletions

File tree

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

Lines changed: 1 addition & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -71,7 +71,7 @@ on:
7171
required: false
7272
type: string
7373
extra_variables_json:
74-
description: "Optional JSON object of non-secret extra GitHub variables or target env fields."
74+
description: "Optional JSON object of non-secret extra variables. DCA profiles may include dca_mode and dca_base_investment_usd control fields."
7575
required: false
7676
type: string
7777
reserved_cash_ratio:
@@ -98,14 +98,6 @@ 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
109101
service_targets_mode:
110102
description: "auto patches IBKR CLOUD_RUN_SERVICE_TARGETS_JSON when it exists."
111103
required: true
@@ -167,8 +159,6 @@ jobs:
167159
INCOME_LAYER_MAX_RATIO: ${{ inputs.income_layer_max_ratio }}
168160
INCOME_THRESHOLD_USD: ${{ inputs.income_threshold_usd }}
169161
QQQI_INCOME_RATIO: ${{ inputs.qqqi_income_ratio }}
170-
DCA_MODE: ${{ inputs.dca_mode }}
171-
DCA_BASE_INVESTMENT_USD: ${{ inputs.dca_base_investment_usd }}
172162
SERVICE_TARGETS_MODE: ${{ inputs.service_targets_mode }}
173163
APPLY_SWITCH: ${{ inputs.apply }}
174164
TRIGGER_PLATFORM_SYNC: ${{ inputs.trigger_platform_sync }}
@@ -313,12 +303,6 @@ jobs:
313303
if [ -n "${QQQI_INCOME_RATIO:-}" ]; then
314304
args+=(--qqqi-income-ratio "${QQQI_INCOME_RATIO}")
315305
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
322306
if [ -s "${EXISTING_SERVICE_TARGETS_JSON_FILE:-}" ]; then
323307
args+=(--existing-service-targets-json-file "${EXISTING_SERVICE_TARGETS_JSON_FILE}")
324308
fi

scripts/build_runtime_switch.py

Lines changed: 31 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -108,6 +108,8 @@
108108
DCA_MODE_VARIABLE,
109109
DCA_BASE_INVESTMENT_VARIABLE,
110110
)
111+
DCA_MODE_CONTROL_FIELD = "dca_mode"
112+
DCA_BASE_INVESTMENT_CONTROL_FIELD = "dca_base_investment_usd"
111113
DEFAULT_VARIABLE_SCOPE = {
112114
"longbridge": "environment",
113115
"ibkr": "repository",
@@ -236,21 +238,40 @@ def _normalize_positive_decimal(value: str, *, field_name: str) -> str:
236238
return text
237239

238240

239-
def _dca_extra_variables(args: argparse.Namespace, strategy_profile: str) -> dict[str, Any]:
241+
def _extract_dca_control_fields(extra_variables: dict[str, Any]) -> dict[str, Any]:
242+
controls: dict[str, Any] = {}
243+
for field_name in (DCA_MODE_CONTROL_FIELD, DCA_BASE_INVESTMENT_CONTROL_FIELD):
244+
if field_name in extra_variables:
245+
controls[field_name] = extra_variables.pop(field_name)
246+
return controls
247+
248+
249+
def _dca_extra_variables(
250+
args: argparse.Namespace,
251+
strategy_profile: str,
252+
controls: dict[str, Any] | None = None,
253+
) -> dict[str, Any]:
254+
controls = dict(controls or {})
240255
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())
256+
dca_mode = args.dca_mode if str(args.dca_mode or "").strip() else controls.get(DCA_MODE_CONTROL_FIELD, "")
257+
dca_base_investment_usd = (
258+
args.dca_base_investment_usd
259+
if str(args.dca_base_investment_usd or "").strip()
260+
else controls.get(DCA_BASE_INVESTMENT_CONTROL_FIELD, "")
261+
)
262+
has_dca_mode = bool(str(dca_mode or "").strip())
263+
has_dca_base = bool(str(dca_base_investment_usd or "").strip())
243264
if not is_dca_profile:
244265
if has_dca_mode or has_dca_base:
245266
raise ValueError("DCA settings are only supported for DCA strategy profiles")
246267
return {variable: "" for variable in DCA_RUNTIME_VARIABLES}
247268

248269
extra_variables: dict[str, Any] = {}
249270
if has_dca_mode:
250-
extra_variables[DCA_MODE_VARIABLE] = _normalize_dca_mode(args.dca_mode)
271+
extra_variables[DCA_MODE_VARIABLE] = _normalize_dca_mode(dca_mode)
251272
if has_dca_base:
252273
extra_variables[DCA_BASE_INVESTMENT_VARIABLE] = _normalize_positive_decimal(
253-
args.dca_base_investment_usd,
274+
dca_base_investment_usd,
254275
field_name="dca_base_investment_usd",
255276
)
256277
return extra_variables
@@ -264,7 +285,9 @@ def _reject_direct_dca_extra_variables(extra_variables: dict[str, Any]) -> None:
264285
]
265286
if provided:
266287
names = ", ".join(provided)
267-
raise ValueError(f"use --dca-mode and --dca-base-investment-usd instead of extra_variables_json for {names}")
288+
raise ValueError(
289+
f"use dca_mode and dca_base_investment_usd control fields instead of extra_variables_json for {names}"
290+
)
268291

269292

270293
def _auto_plugin_mounts(strategy_profile: str, artifact_bucket_uri: str) -> list[dict[str, Any]]:
@@ -463,6 +486,7 @@ def build_switch_target(args: argparse.Namespace) -> dict[str, Any]:
463486
mounts = _plugin_mounts(args, runtime_target["strategy_profile"])
464487
mounts_variable = f"{SUPPORTED_PLATFORMS[platform]['plugin_mounts_prefix']}STRATEGY_PLUGIN_MOUNTS_JSON"
465488
extra_variables = _parse_extra_variables(args.extra_variable, args.extra_variables_json)
489+
dca_controls = _extract_dca_control_fields(extra_variables)
466490
_reject_direct_dca_extra_variables(extra_variables)
467491

468492
if args.set_platform_dry_run_variable:
@@ -479,7 +503,7 @@ def build_switch_target(args: argparse.Namespace) -> dict[str, Any]:
479503
extra_variables["INCOME_THRESHOLD_USD"] = args.income_threshold_usd
480504
if args.qqqi_income_ratio:
481505
extra_variables["QQQI_INCOME_RATIO"] = args.qqqi_income_ratio
482-
extra_variables.update(_dca_extra_variables(args, runtime_target["strategy_profile"]))
506+
extra_variables.update(_dca_extra_variables(args, runtime_target["strategy_profile"], dca_controls))
483507

484508
service_targets = _load_json_from_file(
485509
args.existing_service_targets_json_file,

tests/strategy_switch_worker_validation.mjs

Lines changed: 22 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -374,8 +374,27 @@ const normalizedDcaInputs = __test.normalizeSwitchInputs({
374374
dca_mode: "smart",
375375
dca_base_investment_usd: "500",
376376
});
377-
assert.equal(normalizedDcaInputs.dca_mode, "smart");
378-
assert.equal(normalizedDcaInputs.dca_base_investment_usd, "500");
377+
assert.equal(normalizedDcaInputs.dca_mode, undefined);
378+
assert.equal(normalizedDcaInputs.dca_base_investment_usd, undefined);
379+
assert.deepEqual(JSON.parse(normalizedDcaInputs.extra_variables_json), {
380+
dca_mode: "smart",
381+
dca_base_investment_usd: "500",
382+
});
383+
const normalizedDcaJsonInputs = __test.normalizeSwitchInputs({
384+
platform: "ibkr",
385+
target_name: "ibkr-primary",
386+
strategy_profile: "nasdaq_sp500_smart_dca",
387+
execution_mode: "live",
388+
plugin_mode: "auto",
389+
extra_variables_json: JSON.stringify({
390+
dca_mode: "smart",
391+
dca_base_investment_usd: "500",
392+
}),
393+
});
394+
assert.deepEqual(JSON.parse(normalizedDcaJsonInputs.extra_variables_json), {
395+
dca_mode: "smart",
396+
dca_base_investment_usd: "500",
397+
});
379398
assert.throws(
380399
() => __test.normalizeSwitchInputs({
381400
platform: "ibkr",
@@ -402,7 +421,7 @@ assert.throws(
402421
strategy_profile: "tqqq_growth_income",
403422
extra_variables_json: JSON.stringify({ DCA_MODE: "smart" }),
404423
}),
405-
/instead of extra_variables_json/,
424+
/control fields/,
406425
);
407426
const normalizedReserveClearInputs = __test.normalizeSwitchInputs({
408427
platform: "ibkr",

tests/test_runtime_settings.py

Lines changed: 45 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@
33
import importlib.util
44
import json
55
import os
6+
import re
67
import sys
78
import unittest
89
from pathlib import Path
@@ -26,6 +27,24 @@
2627

2728

2829
class RuntimeSettingsTest(unittest.TestCase):
30+
def test_manual_strategy_switch_workflow_stays_within_dispatch_input_limit(self):
31+
workflow = (ROOT / ".github/workflows/manual-strategy-switch.yml").read_text(encoding="utf-8")
32+
input_names: list[str] = []
33+
in_inputs = False
34+
for line in workflow.splitlines():
35+
if line.strip() == "inputs:":
36+
in_inputs = True
37+
continue
38+
if in_inputs and line.startswith("concurrency:"):
39+
break
40+
match = re.match(r" ([A-Za-z0-9_]+):$", line)
41+
if in_inputs and match:
42+
input_names.append(match.group(1))
43+
44+
self.assertLessEqual(len(input_names), 25)
45+
self.assertNotIn("dca_mode", input_names)
46+
self.assertNotIn("dca_base_investment_usd", input_names)
47+
2948
def load_target(self, relative_path: str):
3049
path = ROOT / relative_path
3150
return path, runtime_settings.load_target(path)
@@ -382,6 +401,31 @@ def test_build_switch_target_sets_dca_settings_for_dca_profile(self):
382401
self.assertEqual(assignments["DCA_MODE"], "smart")
383402
self.assertEqual(assignments["DCA_BASE_INVESTMENT_USD"], "500")
384403

404+
def test_build_switch_target_accepts_dca_control_fields_from_extra_variables_json(self):
405+
parser = build_runtime_switch.build_parser()
406+
args = parser.parse_args(
407+
[
408+
"--platform",
409+
"ibkr",
410+
"--target-name",
411+
"dca",
412+
"--strategy-profile",
413+
"nasdaq_sp500_smart_dca",
414+
"--plugin-mode",
415+
"none",
416+
"--extra-variables-json",
417+
'{"dca_mode":"smart","dca_base_investment_usd":"500"}',
418+
]
419+
)
420+
421+
target = build_runtime_switch.build_switch_target(args)
422+
assignments = {item.name: item.value for item in runtime_settings.build_assignments(target)}
423+
424+
self.assertEqual(assignments["DCA_MODE"], "smart")
425+
self.assertEqual(assignments["DCA_BASE_INVESTMENT_USD"], "500")
426+
self.assertNotIn("dca_mode", target["extra_variables"])
427+
self.assertNotIn("dca_base_investment_usd", target["extra_variables"])
428+
385429
def test_build_switch_target_rejects_dca_settings_for_non_dca_profile(self):
386430
parser = build_runtime_switch.build_parser()
387431
args = parser.parse_args(
@@ -415,7 +459,7 @@ def test_build_switch_target_rejects_direct_dca_extra_variables(self):
415459
]
416460
)
417461

418-
with self.assertRaisesRegex(ValueError, "use --dca-mode"):
462+
with self.assertRaisesRegex(ValueError, "control fields"):
419463
build_runtime_switch.build_switch_target(args)
420464

421465
def test_build_switch_target_preserves_dca_fields_in_service_targets_when_omitted(self):

web/strategy-switch-console/worker.js

Lines changed: 30 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -836,16 +836,17 @@ function updateAccountOptionsDefaultStrategy(accountOptions, inputs) {
836836
optionChanged = true;
837837
}
838838
}
839+
const dcaControls = dcaControlsFromInputs(inputs);
839840
if (isDcaProfile(inputs.strategy_profile)) {
840-
if (inputs.dca_mode && nextOption.dca_mode !== inputs.dca_mode) {
841-
nextOption.dca_mode = inputs.dca_mode;
841+
if (dcaControls.dca_mode && nextOption.dca_mode !== dcaControls.dca_mode) {
842+
nextOption.dca_mode = dcaControls.dca_mode;
842843
optionChanged = true;
843844
}
844845
if (
845-
inputs.dca_base_investment_usd &&
846-
nextOption.dca_base_investment_usd !== inputs.dca_base_investment_usd
846+
dcaControls.dca_base_investment_usd &&
847+
nextOption.dca_base_investment_usd !== dcaControls.dca_base_investment_usd
847848
) {
848-
nextOption.dca_base_investment_usd = inputs.dca_base_investment_usd;
849+
nextOption.dca_base_investment_usd = dcaControls.dca_base_investment_usd;
849850
optionChanged = true;
850851
}
851852
} else {
@@ -886,8 +887,9 @@ function normalizeSwitchInputs(raw) {
886887
extraVariables[name] !== undefined && String(extraVariables[name] || "").trim() !== "",
887888
);
888889
if (directDcaVariables.length) {
889-
throw new Error("use dca_mode and dca_base_investment_usd instead of extra_variables_json for DCA settings");
890+
throw new Error("use dca_mode and dca_base_investment_usd control fields instead of DCA_MODE variables");
890891
}
892+
const dcaExtraControls = dcaPayloadFromObject(extraVariables);
891893

892894
const inputs = {
893895
platform,
@@ -913,18 +915,27 @@ function normalizeSwitchInputs(raw) {
913915
addOptional(inputs, "min_reserved_cash_usd", raw.min_reserved_cash_usd, cleanNonNegativeNumber);
914916
addOptional(inputs, "income_layer_start_usd", raw.income_layer_start_usd, cleanNonNegativeNumber);
915917
addOptional(inputs, "income_layer_max_ratio", raw.income_layer_max_ratio, cleanRatio);
916-
const hasDcaMode = raw.dca_mode !== undefined && raw.dca_mode !== null && String(raw.dca_mode).trim() !== "";
917-
const hasDcaBase = raw.dca_base_investment_usd !== undefined &&
918+
const rawHasDcaMode = raw.dca_mode !== undefined && raw.dca_mode !== null && String(raw.dca_mode).trim() !== "";
919+
const rawHasDcaBase = raw.dca_base_investment_usd !== undefined &&
918920
raw.dca_base_investment_usd !== null &&
919921
String(raw.dca_base_investment_usd).trim() !== "";
922+
const dcaModeValue = rawHasDcaMode ? raw.dca_mode : dcaExtraControls.dca_mode;
923+
const dcaBaseInvestmentValue = rawHasDcaBase
924+
? raw.dca_base_investment_usd
925+
: dcaExtraControls.dca_base_investment_usd;
926+
const hasDcaMode = Boolean(String(dcaModeValue || "").trim());
927+
const hasDcaBase = Boolean(String(dcaBaseInvestmentValue || "").trim());
920928
if (!isDcaProfile(strategyProfile) && (hasDcaMode || hasDcaBase)) {
921929
throw new Error("DCA settings are only supported for DCA strategy profiles");
922930
}
923931
if (isDcaProfile(strategyProfile)) {
924-
addOptional(inputs, "dca_mode", raw.dca_mode, cleanDcaMode);
925-
addOptional(inputs, "dca_base_investment_usd", raw.dca_base_investment_usd, cleanPositiveNumber);
932+
if (hasDcaMode) extraVariables.dca_mode = cleanDcaMode(dcaModeValue);
933+
if (hasDcaBase) extraVariables.dca_base_investment_usd = cleanPositiveNumber(
934+
dcaBaseInvestmentValue,
935+
"dca_base_investment_usd",
936+
);
926937
}
927-
if (extraVariablesJson) inputs.extra_variables_json = extraVariablesJson;
938+
if (Object.keys(extraVariables).length) inputs.extra_variables_json = JSON.stringify(extraVariables);
928939
return inputs;
929940
}
930941

@@ -1494,6 +1505,14 @@ function dcaPayloadFromValues(modeValue, baseInvestmentValue) {
14941505
return result;
14951506
}
14961507

1508+
function dcaControlsFromInputs(inputs) {
1509+
const payload = inputs?.extra_variables_json ? JSON.parse(inputs.extra_variables_json) : {};
1510+
return {
1511+
...dcaPayloadFromObject(payload),
1512+
...dcaPayloadFromObject(inputs),
1513+
};
1514+
}
1515+
14971516
function dcaPayloadForProfile(profile, payload) {
14981517
return isDcaProfile(profile) ? payload : {};
14991518
}

0 commit comments

Comments
 (0)