Skip to content

Commit fd34a3c

Browse files
Pigbibicursoragent
andauthored
Add per-platform cash-only execution controls to strategy switch console. (#89)
Route cash_only_execution_mode through extra_variables_json to avoid undeclared workflow dispatch inputs, sync account defaults after apply, and expose allow-margin yes/no UI. Co-authored-by: Cursor <cursoragent@cursor.com>
1 parent 4843be4 commit fd34a3c

7 files changed

Lines changed: 376 additions & 3 deletions

File tree

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

Lines changed: 10 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -72,7 +72,7 @@ on:
7272
required: false
7373
type: string
7474
extra_variables_json:
75-
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."
75+
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."
7676
required: false
7777
type: string
7878
reserved_cash_ratio:
@@ -399,6 +399,15 @@ jobs:
399399
"account_scope": runtime_target["account_scope"],
400400
"service_name": runtime_target["service_name"],
401401
}
402+
extra_variables_json = os.environ.get("EXTRA_VARIABLES_JSON", "").strip()
403+
if extra_variables_json:
404+
try:
405+
extra_variables = json.loads(extra_variables_json)
406+
except json.JSONDecodeError:
407+
extra_variables = {}
408+
cash_only_mode = str(extra_variables.get("cash_only_execution_mode") or "current").strip().lower()
409+
if cash_only_mode in {"enabled", "disabled"}:
410+
payload["cash_only_execution_mode"] = cash_only_mode
402411
payload.update(runtime_settings.extract_account_sync_controls(target))
403412
if github.get("environment"):
404413
payload["github_environment"] = github["environment"]

scripts/build_runtime_switch.py

Lines changed: 40 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -105,6 +105,12 @@
105105
"ibkr": "IBKR_MIN_RESERVED_CASH_USD",
106106
"firstrade": "FIRSTRADE_MIN_RESERVED_CASH_USD",
107107
}
108+
PLATFORM_CASH_ONLY_EXECUTION_VARIABLES = {
109+
"schwab": "SCHWAB_CASH_ONLY_EXECUTION",
110+
"longbridge": "LONGBRIDGE_CASH_ONLY_EXECUTION",
111+
"ibkr": "IBKR_CASH_ONLY_EXECUTION",
112+
"firstrade": "FIRSTRADE_CASH_ONLY_EXECUTION",
113+
}
108114
INCOME_LAYER_VARIABLES = (
109115
"INCOME_LAYER_ENABLED",
110116
"INCOME_LAYER_START_USD",
@@ -132,6 +138,8 @@
132138
for suffix in MARKET_SIGNAL_RUNTIME_SUFFIXES
133139
)
134140
CASH_ONLY_EXECUTION_VARIABLE = "CASH_ONLY_EXECUTION"
141+
CASH_ONLY_EXECUTION_MODES = frozenset({"current", "enabled", "disabled"})
142+
CASH_ONLY_EXECUTION_CONTROL_FIELD = "cash_only_execution_mode"
135143
LEGACY_INCOME_LAYER_VARIABLES = (
136144
"INCOME_THRESHOLD_USD",
137145
"QQQI_INCOME_RATIO",
@@ -398,6 +406,25 @@ def _normalize_symbol_text(value: str, *, field_name: str) -> str:
398406
return text
399407

400408

409+
def _cash_only_extra_variables(args: argparse.Namespace, platform: str) -> dict[str, str]:
410+
mode = str(getattr(args, "cash_only_execution_mode", None) or "current").strip().lower()
411+
if mode not in CASH_ONLY_EXECUTION_MODES:
412+
raise ValueError("cash_only_execution_mode must be current, enabled, or disabled")
413+
if mode == "current":
414+
return {}
415+
variable = PLATFORM_CASH_ONLY_EXECUTION_VARIABLES.get(platform)
416+
if not variable:
417+
return {}
418+
return {variable: env_string(mode == "enabled")}
419+
420+
421+
def _extract_cash_only_control_fields(extra_variables: dict[str, Any]) -> dict[str, Any]:
422+
controls: dict[str, Any] = {}
423+
if CASH_ONLY_EXECUTION_CONTROL_FIELD in extra_variables:
424+
controls[CASH_ONLY_EXECUTION_CONTROL_FIELD] = extra_variables.pop(CASH_ONLY_EXECUTION_CONTROL_FIELD)
425+
return controls
426+
427+
401428
def _extract_dca_control_fields(extra_variables: dict[str, Any]) -> dict[str, Any]:
402429
controls: dict[str, Any] = {}
403430
for field_name in (DCA_MODE_CONTROL_FIELD, DCA_BASE_INVESTMENT_CONTROL_FIELD):
@@ -826,9 +853,10 @@ def _preserve_reserved_cash_fields(
826853
for variable in (
827854
PLATFORM_MIN_RESERVED_CASH_VARIABLES.get(platform),
828855
PLATFORM_RESERVED_CASH_RATIO_VARIABLES.get(platform),
856+
PLATFORM_CASH_ONLY_EXECUTION_VARIABLES.get(platform),
857+
CASH_ONLY_EXECUTION_VARIABLE,
829858
*INCOME_LAYER_VARIABLES,
830859
*OPTION_OVERLAY_VARIABLES,
831-
CASH_ONLY_EXECUTION_VARIABLE,
832860
*RUNTIME_TARGET_VARIABLES,
833861
*DCA_RUNTIME_VARIABLES,
834862
*IBIT_ZSCORE_EXIT_RUNTIME_VARIABLES,
@@ -898,8 +926,13 @@ def build_switch_target(args: argparse.Namespace) -> dict[str, Any]:
898926
runtime_target["scheduler"] = _scheduler_plan_for_strategy(runtime_target["strategy_profile"], mounts)
899927
mounts_variable = f"{SUPPORTED_PLATFORMS[platform]['plugin_mounts_prefix']}STRATEGY_PLUGIN_MOUNTS_JSON"
900928
extra_variables = _parse_extra_variables(args.extra_variable, args.extra_variables_json)
929+
cash_only_controls = _extract_cash_only_control_fields(extra_variables)
901930
dca_controls = _extract_dca_control_fields(extra_variables)
902931
ibit_zscore_exit_controls = _extract_ibit_zscore_exit_control_fields(extra_variables)
932+
if cash_only_controls.get(CASH_ONLY_EXECUTION_CONTROL_FIELD):
933+
args.cash_only_execution_mode = str(
934+
cash_only_controls[CASH_ONLY_EXECUTION_CONTROL_FIELD]
935+
).strip().lower()
903936
_reject_direct_dca_extra_variables(extra_variables)
904937
_reject_direct_ibit_zscore_exit_extra_variables(extra_variables)
905938
_reject_research_only_extra_variables(extra_variables)
@@ -914,6 +947,7 @@ def build_switch_target(args: argparse.Namespace) -> dict[str, Any]:
914947
extra_variables["INCOME_LAYER_START_USD"] = args.income_layer_start_usd
915948
if args.income_layer_max_ratio:
916949
extra_variables["INCOME_LAYER_MAX_RATIO"] = args.income_layer_max_ratio
950+
extra_variables.update(_cash_only_extra_variables(args, platform))
917951
extra_variables.update(_option_overlay_extra_variables(args, runtime_target["strategy_profile"]))
918952
extra_variables.update(_dca_extra_variables(args, runtime_target["strategy_profile"], dca_controls))
919953
extra_variables.update(
@@ -985,6 +1019,11 @@ def build_parser() -> argparse.ArgumentParser:
9851019
parser.add_argument("--extra-variable", action="append", default=[], help="NAME=VALUE non-secret extra variable")
9861020
parser.add_argument("--reserved-cash-ratio", default="")
9871021
parser.add_argument("--min-reserved-cash-usd", default="")
1022+
parser.add_argument(
1023+
"--cash-only-execution-mode",
1024+
choices=sorted(CASH_ONLY_EXECUTION_MODES),
1025+
default="current",
1026+
)
9881027
parser.add_argument("--income-layer-start-usd", default="")
9891028
parser.add_argument("--income-layer-max-ratio", default="")
9901029
parser.add_argument("--option-overlay-mode", choices=sorted(OPTION_OVERLAY_MODES), default="current")

tests/strategy_switch_worker_validation.mjs

Lines changed: 61 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -38,6 +38,14 @@ assert.ok(indexHtml.includes('id="option-overlay-mode-select"'));
3838
assert.ok(indexHtml.includes('optionOverlayMode: "期权层状态"'));
3939
assert.ok(indexHtml.includes('optionOverlayMode: "Option layer"'));
4040
assert.ok(indexHtml.includes("optionOverlayDefaultsFromProfileItem"));
41+
assert.ok(indexHtml.includes('id="cash-only-execution-mode-select"'));
42+
assert.ok(indexHtml.includes('class="form-section cash-only-section"'));
43+
assert.ok(indexHtml.includes('cashOnlyExecutionMode: "允许融资"'));
44+
assert.ok(indexHtml.includes('cashOnlyExecutionValueYes: "允许融资:是"'));
45+
assert.ok(indexHtml.includes('cashOnlyExecutionMode: "Allow margin"'));
46+
assert.ok(indexHtml.includes('el("cash-only-execution-mode-select").addEventListener("change"'));
47+
assert.ok(indexHtml.includes("function pendingCashOnlyExecution("));
48+
assert.ok(indexHtml.includes("function syncCashOnlyExecutionForAccount("));
4149
assert.equal(indexHtml.includes('id="option-growth-overlay'), false);
4250
assert.equal(indexHtml.includes('id="option-income-overlay'), false);
4351
assert.ok(indexHtml.includes('id="dca-mode-select"'));
@@ -460,6 +468,34 @@ assert.equal(normalizedReservedCashInputs.min_reserved_cash_usd, "150");
460468
assert.equal(normalizedReservedCashInputs.income_layer_start_usd, "250000");
461469
assert.equal(normalizedReservedCashInputs.income_layer_max_ratio, "0.55");
462470
assert.equal(normalizedReservedCashInputs.option_overlay_mode, "enabled");
471+
const normalizedCashOnlyInputs = __test.normalizeSwitchInputs({
472+
platform: "ibkr",
473+
target_name: "ibkr-primary",
474+
strategy_profile: "tqqq_growth_income",
475+
execution_mode: "live",
476+
cash_only_execution_mode: "disabled",
477+
});
478+
assert.equal(normalizedCashOnlyInputs.cash_only_execution_mode, undefined);
479+
assert.deepEqual(JSON.parse(normalizedCashOnlyInputs.extra_variables_json), {
480+
cash_only_execution_mode: "disabled",
481+
});
482+
assert.equal("cash_only_execution_mode" in normalizedCashOnlyInputs, false);
483+
484+
const workflowYaml = readFileSync(resolve(root, ".github/workflows/manual-strategy-switch.yml"), "utf8");
485+
const workflowInputs = [...workflowYaml.matchAll(/^ ([A-Za-z0-9_]+):\n description:/gm)].map((match) => match[1]);
486+
const dispatchInputs = __test.normalizeSwitchInputs({
487+
platform: "ibkr",
488+
target_name: "ibkr-primary",
489+
strategy_profile: "tqqq_growth_income",
490+
execution_mode: "live",
491+
cash_only_execution_mode: "enabled",
492+
apply: "true",
493+
trigger_platform_sync: "true",
494+
confirm_apply: "APPLY_AND_SYNC",
495+
});
496+
for (const key of Object.keys(dispatchInputs)) {
497+
assert.ok(workflowInputs.includes(key), `workflow input missing for dispatch field: ${key}`);
498+
}
463499
const normalizedPluginInputs = __test.normalizeSwitchInputs({
464500
platform: "ibkr",
465501
target_name: "ibkr-primary",
@@ -581,6 +617,15 @@ assert.equal(
581617
IBKR_RESERVED_CASH_RATIO: "",
582618
}),
583619
);
620+
assert.throws(
621+
() => __test.normalizeSwitchInputs({
622+
platform: "ibkr",
623+
target_name: "ibkr-primary",
624+
strategy_profile: "tqqq_growth_income",
625+
extra_variables_json: JSON.stringify({ IBKR_CASH_ONLY_EXECUTION: "true" }),
626+
}),
627+
/cash_only_execution_mode instead of CASH_ONLY_EXECUTION/,
628+
);
584629
assert.throws(
585630
() => __test.normalizeSwitchInputs({
586631
platform: "ibkr",
@@ -659,6 +704,22 @@ const updatedOptionOverlayModeOptions = __test.updateAccountOptionsDefaultStrate
659704
assert.equal(updatedOptionOverlayModeOptions.changed, true);
660705
assert.equal(updatedOptionOverlayModeOptions.options.longbridge[1].option_overlay_mode, "disabled");
661706

707+
const updatedCashOnlyModeOptions = __test.updateAccountOptionsDefaultStrategy(
708+
accountOptions,
709+
{
710+
platform: "longbridge",
711+
target_name: "sg",
712+
account_selector: "SG",
713+
strategy_profile: "tqqq_growth_income",
714+
execution_mode: "live",
715+
variable_scope: "default",
716+
plugin_mode: "auto",
717+
cash_only_execution_mode: "enabled",
718+
},
719+
);
720+
assert.equal(updatedCashOnlyModeOptions.changed, true);
721+
assert.equal(updatedCashOnlyModeOptions.options.longbridge[1].cash_only_execution_mode, "enabled");
722+
662723
const updatedIbitZscoreModeOptions = __test.updateAccountOptionsDefaultStrategy(
663724
{
664725
...accountOptions,

tests/test_runtime_settings.py

Lines changed: 41 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -174,6 +174,27 @@ def test_manual_switch_account_default_sync_is_warning_only(self):
174174
self.assertIn("raise SystemExit(0)", workflow)
175175
self.assertIn('"variable_scope": "default"', workflow)
176176
self.assertIn("runtime_settings.extract_account_sync_controls(target)", workflow)
177+
self.assertIn('extra_variables.get("cash_only_execution_mode")', workflow)
178+
179+
def test_build_switch_target_sets_cash_only_execution_from_control_field(self):
180+
parser = build_runtime_switch.build_parser()
181+
args = parser.parse_args(
182+
[
183+
"--platform",
184+
"ibkr",
185+
"--target-name",
186+
"ibkr-primary",
187+
"--strategy-profile",
188+
"tqqq_growth_income",
189+
"--extra-variables-json",
190+
'{"cash_only_execution_mode":"enabled"}',
191+
]
192+
)
193+
194+
target = build_runtime_switch.build_switch_target(args)
195+
assignments = {item.name: item.value for item in runtime_settings.build_assignments(target)}
196+
197+
self.assertEqual(assignments["IBKR_CASH_ONLY_EXECUTION"], "true")
177198

178199
def test_extract_account_sync_controls_reads_ibkr_service_targets(self):
179200
target = {
@@ -792,6 +813,26 @@ def test_build_switch_target_can_disable_option_overlay(self):
792813
self.assertEqual(assignments["OPTION_GROWTH_OVERLAY_RECIPE"], "")
793814
self.assertEqual(assignments["OPTION_INCOME_OVERLAY_ENABLED"], "false")
794815

816+
def test_build_switch_target_sets_platform_cash_only_execution(self):
817+
parser = build_runtime_switch.build_parser()
818+
args = parser.parse_args(
819+
[
820+
"--platform",
821+
"ibkr",
822+
"--target-name",
823+
"ibkr-primary",
824+
"--strategy-profile",
825+
"tqqq_growth_income",
826+
"--cash-only-execution-mode",
827+
"disabled",
828+
]
829+
)
830+
831+
target = build_runtime_switch.build_switch_target(args)
832+
assignments = {item.name: item.value for item in runtime_settings.build_assignments(target)}
833+
834+
self.assertEqual(assignments["IBKR_CASH_ONLY_EXECUTION"], "false")
835+
795836
def test_build_switch_target_rejects_enabled_option_overlay_without_profile_defaults(self):
796837
parser = build_runtime_switch.build_parser()
797838
args = parser.parse_args(

0 commit comments

Comments
 (0)