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
18 changes: 1 addition & 17 deletions .github/workflows/manual-strategy-switch.yml
Original file line number Diff line number Diff line change
Expand Up @@ -71,7 +71,7 @@ on:
required: false
type: string
extra_variables_json:
description: "Optional JSON object of non-secret extra GitHub variables or target env fields."
description: "Optional JSON object of non-secret extra variables. DCA profiles may include dca_mode and dca_base_investment_usd control fields."
required: false
type: string
reserved_cash_ratio:
Expand All @@ -98,14 +98,6 @@ on:
description: "Optional TQQQ QQQI income ratio override."
required: false
type: string
dca_mode:
description: "Optional DCA mode for DCA profiles only: fixed or smart."
required: false
type: string
dca_base_investment_usd:
description: "Optional base DCA investment amount for DCA profiles only."
required: false
type: string
service_targets_mode:
description: "auto patches IBKR CLOUD_RUN_SERVICE_TARGETS_JSON when it exists."
required: true
Expand Down Expand Up @@ -167,8 +159,6 @@ jobs:
INCOME_LAYER_MAX_RATIO: ${{ inputs.income_layer_max_ratio }}
INCOME_THRESHOLD_USD: ${{ inputs.income_threshold_usd }}
QQQI_INCOME_RATIO: ${{ inputs.qqqi_income_ratio }}
DCA_MODE: ${{ inputs.dca_mode }}
DCA_BASE_INVESTMENT_USD: ${{ inputs.dca_base_investment_usd }}
SERVICE_TARGETS_MODE: ${{ inputs.service_targets_mode }}
APPLY_SWITCH: ${{ inputs.apply }}
TRIGGER_PLATFORM_SYNC: ${{ inputs.trigger_platform_sync }}
Expand Down Expand Up @@ -313,12 +303,6 @@ jobs:
if [ -n "${QQQI_INCOME_RATIO:-}" ]; then
args+=(--qqqi-income-ratio "${QQQI_INCOME_RATIO}")
fi
if [ -n "${DCA_MODE:-}" ]; then
args+=(--dca-mode "${DCA_MODE}")
fi
if [ -n "${DCA_BASE_INVESTMENT_USD:-}" ]; then
args+=(--dca-base-investment-usd "${DCA_BASE_INVESTMENT_USD}")
fi
if [ -s "${EXISTING_SERVICE_TARGETS_JSON_FILE:-}" ]; then
args+=(--existing-service-targets-json-file "${EXISTING_SERVICE_TARGETS_JSON_FILE}")
fi
Expand Down
38 changes: 31 additions & 7 deletions scripts/build_runtime_switch.py
Original file line number Diff line number Diff line change
Expand Up @@ -108,6 +108,8 @@
DCA_MODE_VARIABLE,
DCA_BASE_INVESTMENT_VARIABLE,
)
DCA_MODE_CONTROL_FIELD = "dca_mode"
DCA_BASE_INVESTMENT_CONTROL_FIELD = "dca_base_investment_usd"
DEFAULT_VARIABLE_SCOPE = {
"longbridge": "environment",
"ibkr": "repository",
Expand Down Expand Up @@ -236,21 +238,40 @@ def _normalize_positive_decimal(value: str, *, field_name: str) -> str:
return text


def _dca_extra_variables(args: argparse.Namespace, strategy_profile: str) -> dict[str, Any]:
def _extract_dca_control_fields(extra_variables: dict[str, Any]) -> dict[str, Any]:
controls: dict[str, Any] = {}
for field_name in (DCA_MODE_CONTROL_FIELD, DCA_BASE_INVESTMENT_CONTROL_FIELD):
if field_name in extra_variables:
controls[field_name] = extra_variables.pop(field_name)
return controls


def _dca_extra_variables(
args: argparse.Namespace,
strategy_profile: str,
controls: dict[str, Any] | None = None,
) -> dict[str, Any]:
controls = dict(controls or {})
is_dca_profile = strategy_profile in DCA_PROFILES
has_dca_mode = bool(str(args.dca_mode or "").strip())
has_dca_base = bool(str(args.dca_base_investment_usd or "").strip())
dca_mode = args.dca_mode if str(args.dca_mode or "").strip() else controls.get(DCA_MODE_CONTROL_FIELD, "")
dca_base_investment_usd = (
args.dca_base_investment_usd
if str(args.dca_base_investment_usd or "").strip()
else controls.get(DCA_BASE_INVESTMENT_CONTROL_FIELD, "")
)
has_dca_mode = bool(str(dca_mode or "").strip())
has_dca_base = bool(str(dca_base_investment_usd or "").strip())
if not is_dca_profile:
if has_dca_mode or has_dca_base:
raise ValueError("DCA settings are only supported for DCA strategy profiles")
return {variable: "" for variable in DCA_RUNTIME_VARIABLES}

extra_variables: dict[str, Any] = {}
if has_dca_mode:
extra_variables[DCA_MODE_VARIABLE] = _normalize_dca_mode(args.dca_mode)
extra_variables[DCA_MODE_VARIABLE] = _normalize_dca_mode(dca_mode)
if has_dca_base:
extra_variables[DCA_BASE_INVESTMENT_VARIABLE] = _normalize_positive_decimal(
args.dca_base_investment_usd,
dca_base_investment_usd,
field_name="dca_base_investment_usd",
)
return extra_variables
Expand All @@ -264,7 +285,9 @@ def _reject_direct_dca_extra_variables(extra_variables: dict[str, Any]) -> None:
]
if provided:
names = ", ".join(provided)
raise ValueError(f"use --dca-mode and --dca-base-investment-usd instead of extra_variables_json for {names}")
raise ValueError(
f"use dca_mode and dca_base_investment_usd control fields instead of extra_variables_json for {names}"
)


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

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

service_targets = _load_json_from_file(
args.existing_service_targets_json_file,
Expand Down
25 changes: 22 additions & 3 deletions tests/strategy_switch_worker_validation.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -374,8 +374,27 @@ const normalizedDcaInputs = __test.normalizeSwitchInputs({
dca_mode: "smart",
dca_base_investment_usd: "500",
});
assert.equal(normalizedDcaInputs.dca_mode, "smart");
assert.equal(normalizedDcaInputs.dca_base_investment_usd, "500");
assert.equal(normalizedDcaInputs.dca_mode, undefined);
assert.equal(normalizedDcaInputs.dca_base_investment_usd, undefined);
assert.deepEqual(JSON.parse(normalizedDcaInputs.extra_variables_json), {
dca_mode: "smart",
dca_base_investment_usd: "500",
});
const normalizedDcaJsonInputs = __test.normalizeSwitchInputs({
platform: "ibkr",
target_name: "ibkr-primary",
strategy_profile: "nasdaq_sp500_smart_dca",
execution_mode: "live",
plugin_mode: "auto",
extra_variables_json: JSON.stringify({
dca_mode: "smart",
dca_base_investment_usd: "500",
}),
});
assert.deepEqual(JSON.parse(normalizedDcaJsonInputs.extra_variables_json), {
dca_mode: "smart",
dca_base_investment_usd: "500",
});
assert.throws(
() => __test.normalizeSwitchInputs({
platform: "ibkr",
Expand All @@ -402,7 +421,7 @@ assert.throws(
strategy_profile: "tqqq_growth_income",
extra_variables_json: JSON.stringify({ DCA_MODE: "smart" }),
}),
/instead of extra_variables_json/,
/control fields/,
);
const normalizedReserveClearInputs = __test.normalizeSwitchInputs({
platform: "ibkr",
Expand Down
46 changes: 45 additions & 1 deletion tests/test_runtime_settings.py
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@
import importlib.util
import json
import os
import re
import sys
import unittest
from pathlib import Path
Expand All @@ -26,6 +27,24 @@


class RuntimeSettingsTest(unittest.TestCase):
def test_manual_strategy_switch_workflow_stays_within_dispatch_input_limit(self):
workflow = (ROOT / ".github/workflows/manual-strategy-switch.yml").read_text(encoding="utf-8")
input_names: list[str] = []
in_inputs = False
for line in workflow.splitlines():
if line.strip() == "inputs:":
in_inputs = True
continue
if in_inputs and line.startswith("concurrency:"):
break
match = re.match(r" ([A-Za-z0-9_]+):$", line)
if in_inputs and match:
input_names.append(match.group(1))

self.assertLessEqual(len(input_names), 25)
self.assertNotIn("dca_mode", input_names)
self.assertNotIn("dca_base_investment_usd", input_names)

def load_target(self, relative_path: str):
path = ROOT / relative_path
return path, runtime_settings.load_target(path)
Expand Down Expand Up @@ -382,6 +401,31 @@ def test_build_switch_target_sets_dca_settings_for_dca_profile(self):
self.assertEqual(assignments["DCA_MODE"], "smart")
self.assertEqual(assignments["DCA_BASE_INVESTMENT_USD"], "500")

def test_build_switch_target_accepts_dca_control_fields_from_extra_variables_json(self):
parser = build_runtime_switch.build_parser()
args = parser.parse_args(
[
"--platform",
"ibkr",
"--target-name",
"dca",
"--strategy-profile",
"nasdaq_sp500_smart_dca",
"--plugin-mode",
"none",
"--extra-variables-json",
'{"dca_mode":"smart","dca_base_investment_usd":"500"}',
]
)

target = build_runtime_switch.build_switch_target(args)
assignments = {item.name: item.value for item in runtime_settings.build_assignments(target)}

self.assertEqual(assignments["DCA_MODE"], "smart")
self.assertEqual(assignments["DCA_BASE_INVESTMENT_USD"], "500")
self.assertNotIn("dca_mode", target["extra_variables"])
self.assertNotIn("dca_base_investment_usd", target["extra_variables"])

def test_build_switch_target_rejects_dca_settings_for_non_dca_profile(self):
parser = build_runtime_switch.build_parser()
args = parser.parse_args(
Expand Down Expand Up @@ -415,7 +459,7 @@ def test_build_switch_target_rejects_direct_dca_extra_variables(self):
]
)

with self.assertRaisesRegex(ValueError, "use --dca-mode"):
with self.assertRaisesRegex(ValueError, "control fields"):
build_runtime_switch.build_switch_target(args)

def test_build_switch_target_preserves_dca_fields_in_service_targets_when_omitted(self):
Expand Down
41 changes: 30 additions & 11 deletions web/strategy-switch-console/worker.js
Original file line number Diff line number Diff line change
Expand Up @@ -836,16 +836,17 @@ function updateAccountOptionsDefaultStrategy(accountOptions, inputs) {
optionChanged = true;
}
}
const dcaControls = dcaControlsFromInputs(inputs);
if (isDcaProfile(inputs.strategy_profile)) {
if (inputs.dca_mode && nextOption.dca_mode !== inputs.dca_mode) {
nextOption.dca_mode = inputs.dca_mode;
if (dcaControls.dca_mode && nextOption.dca_mode !== dcaControls.dca_mode) {
nextOption.dca_mode = dcaControls.dca_mode;
optionChanged = true;
}
if (
inputs.dca_base_investment_usd &&
nextOption.dca_base_investment_usd !== inputs.dca_base_investment_usd
dcaControls.dca_base_investment_usd &&
nextOption.dca_base_investment_usd !== dcaControls.dca_base_investment_usd
) {
nextOption.dca_base_investment_usd = inputs.dca_base_investment_usd;
nextOption.dca_base_investment_usd = dcaControls.dca_base_investment_usd;
optionChanged = true;
}
} else {
Expand Down Expand Up @@ -886,8 +887,9 @@ function normalizeSwitchInputs(raw) {
extraVariables[name] !== undefined && String(extraVariables[name] || "").trim() !== "",
);
if (directDcaVariables.length) {
throw new Error("use dca_mode and dca_base_investment_usd instead of extra_variables_json for DCA settings");
throw new Error("use dca_mode and dca_base_investment_usd control fields instead of DCA_MODE variables");
}
const dcaExtraControls = dcaPayloadFromObject(extraVariables);

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Validate JSON DCA controls before dispatch

When callers use the new extra_variables_json route for DCA controls, this sanitizing helper drops invalid values instead of preserving that a control field was supplied; for example {"dca_mode":"smart","dca_base_investment_usd":"0"} on a DCA profile passes normalizeSwitchInputs and gets dispatched, but build_runtime_switch.py later rejects the same payload. Please validate the raw dca_mode/dca_base_investment_usd JSON fields here so the switch API fails synchronously instead of creating a workflow run that fails after dispatch.

Useful? React with 👍 / 👎.


const inputs = {
platform,
Expand All @@ -913,18 +915,27 @@ function normalizeSwitchInputs(raw) {
addOptional(inputs, "min_reserved_cash_usd", raw.min_reserved_cash_usd, cleanNonNegativeNumber);
addOptional(inputs, "income_layer_start_usd", raw.income_layer_start_usd, cleanNonNegativeNumber);
addOptional(inputs, "income_layer_max_ratio", raw.income_layer_max_ratio, cleanRatio);
const hasDcaMode = raw.dca_mode !== undefined && raw.dca_mode !== null && String(raw.dca_mode).trim() !== "";
const hasDcaBase = raw.dca_base_investment_usd !== undefined &&
const rawHasDcaMode = raw.dca_mode !== undefined && raw.dca_mode !== null && String(raw.dca_mode).trim() !== "";
const rawHasDcaBase = raw.dca_base_investment_usd !== undefined &&
raw.dca_base_investment_usd !== null &&
String(raw.dca_base_investment_usd).trim() !== "";
const dcaModeValue = rawHasDcaMode ? raw.dca_mode : dcaExtraControls.dca_mode;
const dcaBaseInvestmentValue = rawHasDcaBase
? raw.dca_base_investment_usd
: dcaExtraControls.dca_base_investment_usd;
const hasDcaMode = Boolean(String(dcaModeValue || "").trim());
const hasDcaBase = Boolean(String(dcaBaseInvestmentValue || "").trim());
if (!isDcaProfile(strategyProfile) && (hasDcaMode || hasDcaBase)) {
throw new Error("DCA settings are only supported for DCA strategy profiles");
}
if (isDcaProfile(strategyProfile)) {
addOptional(inputs, "dca_mode", raw.dca_mode, cleanDcaMode);
addOptional(inputs, "dca_base_investment_usd", raw.dca_base_investment_usd, cleanPositiveNumber);
if (hasDcaMode) extraVariables.dca_mode = cleanDcaMode(dcaModeValue);
if (hasDcaBase) extraVariables.dca_base_investment_usd = cleanPositiveNumber(
dcaBaseInvestmentValue,
"dca_base_investment_usd",
);
}
if (extraVariablesJson) inputs.extra_variables_json = extraVariablesJson;
if (Object.keys(extraVariables).length) inputs.extra_variables_json = JSON.stringify(extraVariables);
return inputs;
}

Expand Down Expand Up @@ -1494,6 +1505,14 @@ function dcaPayloadFromValues(modeValue, baseInvestmentValue) {
return result;
}

function dcaControlsFromInputs(inputs) {
const payload = inputs?.extra_variables_json ? JSON.parse(inputs.extra_variables_json) : {};
return {
...dcaPayloadFromObject(payload),
...dcaPayloadFromObject(inputs),
};
}

function dcaPayloadForProfile(profile, payload) {
return isDcaProfile(profile) ? payload : {};
}
Expand Down