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: 10 additions & 1 deletion .github/workflows/manual-strategy-switch.yml
Original file line number Diff line number Diff line change
Expand Up @@ -72,7 +72,7 @@ on:
required: false
type: string
extra_variables_json:
description: "Optional JSON object of non-secret extra variables. DCA profiles may include dca_mode and dca_base_investment_usd control fields. Research-only option overlays are rejected."
description: "Optional JSON object of non-secret extra variables. DCA profiles may include dca_mode and dca_base_investment_usd control fields. cash_only_execution_mode may be current, enabled, or disabled. Research-only option overlays are rejected."
required: false
type: string
reserved_cash_ratio:
Expand Down Expand Up @@ -399,6 +399,15 @@ jobs:
"account_scope": runtime_target["account_scope"],
"service_name": runtime_target["service_name"],
}
extra_variables_json = os.environ.get("EXTRA_VARIABLES_JSON", "").strip()
if extra_variables_json:
try:
extra_variables = json.loads(extra_variables_json)
except json.JSONDecodeError:
extra_variables = {}
cash_only_mode = str(extra_variables.get("cash_only_execution_mode") or "current").strip().lower()
if cash_only_mode in {"enabled", "disabled"}:
payload["cash_only_execution_mode"] = cash_only_mode
payload.update(runtime_settings.extract_account_sync_controls(target))
if github.get("environment"):
payload["github_environment"] = github["environment"]
Expand Down
41 changes: 40 additions & 1 deletion scripts/build_runtime_switch.py
Original file line number Diff line number Diff line change
Expand Up @@ -105,6 +105,12 @@
"ibkr": "IBKR_MIN_RESERVED_CASH_USD",
"firstrade": "FIRSTRADE_MIN_RESERVED_CASH_USD",
}
PLATFORM_CASH_ONLY_EXECUTION_VARIABLES = {
"schwab": "SCHWAB_CASH_ONLY_EXECUTION",
"longbridge": "LONGBRIDGE_CASH_ONLY_EXECUTION",
"ibkr": "IBKR_CASH_ONLY_EXECUTION",
"firstrade": "FIRSTRADE_CASH_ONLY_EXECUTION",
}
INCOME_LAYER_VARIABLES = (
"INCOME_LAYER_ENABLED",
"INCOME_LAYER_START_USD",
Expand Down Expand Up @@ -132,6 +138,8 @@
for suffix in MARKET_SIGNAL_RUNTIME_SUFFIXES
)
CASH_ONLY_EXECUTION_VARIABLE = "CASH_ONLY_EXECUTION"
CASH_ONLY_EXECUTION_MODES = frozenset({"current", "enabled", "disabled"})
CASH_ONLY_EXECUTION_CONTROL_FIELD = "cash_only_execution_mode"
LEGACY_INCOME_LAYER_VARIABLES = (
"INCOME_THRESHOLD_USD",
"QQQI_INCOME_RATIO",
Expand Down Expand Up @@ -398,6 +406,25 @@ def _normalize_symbol_text(value: str, *, field_name: str) -> str:
return text


def _cash_only_extra_variables(args: argparse.Namespace, platform: str) -> dict[str, str]:
mode = str(getattr(args, "cash_only_execution_mode", None) or "current").strip().lower()
if mode not in CASH_ONLY_EXECUTION_MODES:
raise ValueError("cash_only_execution_mode must be current, enabled, or disabled")
if mode == "current":
return {}
variable = PLATFORM_CASH_ONLY_EXECUTION_VARIABLES.get(platform)
if not variable:
return {}
return {variable: env_string(mode == "enabled")}


def _extract_cash_only_control_fields(extra_variables: dict[str, Any]) -> dict[str, Any]:
controls: dict[str, Any] = {}
if CASH_ONLY_EXECUTION_CONTROL_FIELD in extra_variables:
controls[CASH_ONLY_EXECUTION_CONTROL_FIELD] = extra_variables.pop(CASH_ONLY_EXECUTION_CONTROL_FIELD)
return controls


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):
Expand Down Expand Up @@ -826,9 +853,10 @@ def _preserve_reserved_cash_fields(
for variable in (
PLATFORM_MIN_RESERVED_CASH_VARIABLES.get(platform),
PLATFORM_RESERVED_CASH_RATIO_VARIABLES.get(platform),
PLATFORM_CASH_ONLY_EXECUTION_VARIABLES.get(platform),
CASH_ONLY_EXECUTION_VARIABLE,
*INCOME_LAYER_VARIABLES,
*OPTION_OVERLAY_VARIABLES,
CASH_ONLY_EXECUTION_VARIABLE,
*RUNTIME_TARGET_VARIABLES,
*DCA_RUNTIME_VARIABLES,
*IBIT_ZSCORE_EXIT_RUNTIME_VARIABLES,
Expand Down Expand Up @@ -898,8 +926,13 @@ def build_switch_target(args: argparse.Namespace) -> dict[str, Any]:
runtime_target["scheduler"] = _scheduler_plan_for_strategy(runtime_target["strategy_profile"], mounts)
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)
cash_only_controls = _extract_cash_only_control_fields(extra_variables)
dca_controls = _extract_dca_control_fields(extra_variables)
ibit_zscore_exit_controls = _extract_ibit_zscore_exit_control_fields(extra_variables)
if cash_only_controls.get(CASH_ONLY_EXECUTION_CONTROL_FIELD):
args.cash_only_execution_mode = str(
cash_only_controls[CASH_ONLY_EXECUTION_CONTROL_FIELD]
).strip().lower()
_reject_direct_dca_extra_variables(extra_variables)
_reject_direct_ibit_zscore_exit_extra_variables(extra_variables)
_reject_research_only_extra_variables(extra_variables)
Expand All @@ -914,6 +947,7 @@ def build_switch_target(args: argparse.Namespace) -> dict[str, Any]:
extra_variables["INCOME_LAYER_START_USD"] = args.income_layer_start_usd
if args.income_layer_max_ratio:
extra_variables["INCOME_LAYER_MAX_RATIO"] = args.income_layer_max_ratio
extra_variables.update(_cash_only_extra_variables(args, platform))
extra_variables.update(_option_overlay_extra_variables(args, runtime_target["strategy_profile"]))
extra_variables.update(_dca_extra_variables(args, runtime_target["strategy_profile"], dca_controls))
extra_variables.update(
Expand Down Expand Up @@ -985,6 +1019,11 @@ def build_parser() -> argparse.ArgumentParser:
parser.add_argument("--extra-variable", action="append", default=[], help="NAME=VALUE non-secret extra variable")
parser.add_argument("--reserved-cash-ratio", default="")
parser.add_argument("--min-reserved-cash-usd", default="")
parser.add_argument(
"--cash-only-execution-mode",
choices=sorted(CASH_ONLY_EXECUTION_MODES),
default="current",
)
parser.add_argument("--income-layer-start-usd", default="")
parser.add_argument("--income-layer-max-ratio", default="")
parser.add_argument("--option-overlay-mode", choices=sorted(OPTION_OVERLAY_MODES), default="current")
Expand Down
61 changes: 61 additions & 0 deletions tests/strategy_switch_worker_validation.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -38,6 +38,14 @@ assert.ok(indexHtml.includes('id="option-overlay-mode-select"'));
assert.ok(indexHtml.includes('optionOverlayMode: "期权层状态"'));
assert.ok(indexHtml.includes('optionOverlayMode: "Option layer"'));
assert.ok(indexHtml.includes("optionOverlayDefaultsFromProfileItem"));
assert.ok(indexHtml.includes('id="cash-only-execution-mode-select"'));
assert.ok(indexHtml.includes('class="form-section cash-only-section"'));
assert.ok(indexHtml.includes('cashOnlyExecutionMode: "允许融资"'));
assert.ok(indexHtml.includes('cashOnlyExecutionValueYes: "允许融资:是"'));
assert.ok(indexHtml.includes('cashOnlyExecutionMode: "Allow margin"'));
assert.ok(indexHtml.includes('el("cash-only-execution-mode-select").addEventListener("change"'));
assert.ok(indexHtml.includes("function pendingCashOnlyExecution("));
assert.ok(indexHtml.includes("function syncCashOnlyExecutionForAccount("));
assert.equal(indexHtml.includes('id="option-growth-overlay'), false);
assert.equal(indexHtml.includes('id="option-income-overlay'), false);
assert.ok(indexHtml.includes('id="dca-mode-select"'));
Expand Down Expand Up @@ -460,6 +468,34 @@ assert.equal(normalizedReservedCashInputs.min_reserved_cash_usd, "150");
assert.equal(normalizedReservedCashInputs.income_layer_start_usd, "250000");
assert.equal(normalizedReservedCashInputs.income_layer_max_ratio, "0.55");
assert.equal(normalizedReservedCashInputs.option_overlay_mode, "enabled");
const normalizedCashOnlyInputs = __test.normalizeSwitchInputs({
platform: "ibkr",
target_name: "ibkr-primary",
strategy_profile: "tqqq_growth_income",
execution_mode: "live",
cash_only_execution_mode: "disabled",
});
assert.equal(normalizedCashOnlyInputs.cash_only_execution_mode, undefined);
assert.deepEqual(JSON.parse(normalizedCashOnlyInputs.extra_variables_json), {
cash_only_execution_mode: "disabled",
});
assert.equal("cash_only_execution_mode" in normalizedCashOnlyInputs, false);

const workflowYaml = readFileSync(resolve(root, ".github/workflows/manual-strategy-switch.yml"), "utf8");
const workflowInputs = [...workflowYaml.matchAll(/^ ([A-Za-z0-9_]+):\n description:/gm)].map((match) => match[1]);
const dispatchInputs = __test.normalizeSwitchInputs({
platform: "ibkr",
target_name: "ibkr-primary",
strategy_profile: "tqqq_growth_income",
execution_mode: "live",
cash_only_execution_mode: "enabled",
apply: "true",
trigger_platform_sync: "true",
confirm_apply: "APPLY_AND_SYNC",
});
for (const key of Object.keys(dispatchInputs)) {
assert.ok(workflowInputs.includes(key), `workflow input missing for dispatch field: ${key}`);
}
const normalizedPluginInputs = __test.normalizeSwitchInputs({
platform: "ibkr",
target_name: "ibkr-primary",
Expand Down Expand Up @@ -581,6 +617,15 @@ assert.equal(
IBKR_RESERVED_CASH_RATIO: "",
}),
);
assert.throws(
() => __test.normalizeSwitchInputs({
platform: "ibkr",
target_name: "ibkr-primary",
strategy_profile: "tqqq_growth_income",
extra_variables_json: JSON.stringify({ IBKR_CASH_ONLY_EXECUTION: "true" }),
}),
/cash_only_execution_mode instead of CASH_ONLY_EXECUTION/,
);
assert.throws(
() => __test.normalizeSwitchInputs({
platform: "ibkr",
Expand Down Expand Up @@ -659,6 +704,22 @@ const updatedOptionOverlayModeOptions = __test.updateAccountOptionsDefaultStrate
assert.equal(updatedOptionOverlayModeOptions.changed, true);
assert.equal(updatedOptionOverlayModeOptions.options.longbridge[1].option_overlay_mode, "disabled");

const updatedCashOnlyModeOptions = __test.updateAccountOptionsDefaultStrategy(
accountOptions,
{
platform: "longbridge",
target_name: "sg",
account_selector: "SG",
strategy_profile: "tqqq_growth_income",
execution_mode: "live",
variable_scope: "default",
plugin_mode: "auto",
cash_only_execution_mode: "enabled",
},
);
assert.equal(updatedCashOnlyModeOptions.changed, true);
assert.equal(updatedCashOnlyModeOptions.options.longbridge[1].cash_only_execution_mode, "enabled");

const updatedIbitZscoreModeOptions = __test.updateAccountOptionsDefaultStrategy(
{
...accountOptions,
Expand Down
41 changes: 41 additions & 0 deletions tests/test_runtime_settings.py
Original file line number Diff line number Diff line change
Expand Up @@ -174,6 +174,27 @@ def test_manual_switch_account_default_sync_is_warning_only(self):
self.assertIn("raise SystemExit(0)", workflow)
self.assertIn('"variable_scope": "default"', workflow)
self.assertIn("runtime_settings.extract_account_sync_controls(target)", workflow)
self.assertIn('extra_variables.get("cash_only_execution_mode")', workflow)

def test_build_switch_target_sets_cash_only_execution_from_control_field(self):
parser = build_runtime_switch.build_parser()
args = parser.parse_args(
[
"--platform",
"ibkr",
"--target-name",
"ibkr-primary",
"--strategy-profile",
"tqqq_growth_income",
"--extra-variables-json",
'{"cash_only_execution_mode":"enabled"}',
]
)

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

self.assertEqual(assignments["IBKR_CASH_ONLY_EXECUTION"], "true")

def test_extract_account_sync_controls_reads_ibkr_service_targets(self):
target = {
Expand Down Expand Up @@ -792,6 +813,26 @@ def test_build_switch_target_can_disable_option_overlay(self):
self.assertEqual(assignments["OPTION_GROWTH_OVERLAY_RECIPE"], "")
self.assertEqual(assignments["OPTION_INCOME_OVERLAY_ENABLED"], "false")

def test_build_switch_target_sets_platform_cash_only_execution(self):
parser = build_runtime_switch.build_parser()
args = parser.parse_args(
[
"--platform",
"ibkr",
"--target-name",
"ibkr-primary",
"--strategy-profile",
"tqqq_growth_income",
"--cash-only-execution-mode",
"disabled",
]
)

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

self.assertEqual(assignments["IBKR_CASH_ONLY_EXECUTION"], "false")

def test_build_switch_target_rejects_enabled_option_overlay_without_profile_defaults(self):
parser = build_runtime_switch.build_parser()
args = parser.parse_args(
Expand Down
Loading
Loading