Skip to content

Commit b2afbeb

Browse files
Pigbibicodex
andcommitted
fix strategy switch DCA-derived IBIT zscore settings
Co-Authored-By: Codex <noreply@openai.com>
1 parent a33f86e commit b2afbeb

13 files changed

Lines changed: 159 additions & 445 deletions

platform-config.json

Lines changed: 2 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -240,7 +240,7 @@
240240
"reserved_cash": false,
241241
"income_layer": false,
242242
"option_overlay": false,
243-
"dca": true
243+
"dca": false
244244
},
245245
"default_account": {
246246
"key": "default",
@@ -573,12 +573,8 @@
573573
"features": {
574574
"income_layer": false,
575575
"option_overlay": false,
576-
"dca": true,
576+
"dca": false,
577577
"combo": false
578-
},
579-
"dca_defaults": {
580-
"default_mode": "fixed",
581-
"default_base_investment_usd": "100"
582578
}
583579
},
584580
"crypto_trend_rotation": {

python/scripts/build_runtime_switch.py

Lines changed: 32 additions & 85 deletions
Original file line numberDiff line numberDiff line change
@@ -394,25 +394,6 @@ def _normalize_option_recipe(value: str, *, field_name: str) -> str:
394394
return text
395395

396396

397-
def _normalize_ibit_zscore_exit_mode(value: str) -> str:
398-
mode = str(value or "").strip().lower()
399-
aliases = {
400-
"off": "disabled",
401-
"none": "disabled",
402-
"false": "disabled",
403-
"0": "disabled",
404-
"disable": "disabled",
405-
"enabled": "live",
406-
"shadow": "paper",
407-
"dry_run": "paper",
408-
"dry-run": "paper",
409-
}
410-
mode = aliases.get(mode, mode)
411-
if mode not in {"disabled", "paper", "live"}:
412-
raise ValueError("ibit_zscore_exit_mode must be disabled, paper, or live")
413-
return mode
414-
415-
416397
def _normalize_symbol_text(value: str, *, field_name: str) -> str:
417398
text = str(value or "").strip().upper().removesuffix(".US")
418399
if not text or not re.fullmatch(r"[A-Z0-9.-]{1,12}", text):
@@ -573,6 +554,18 @@ def _dca_extra_variables(
573554
return extra_variables
574555

575556

557+
def _effective_dca_mode(
558+
args: argparse.Namespace,
559+
strategy_profile: str,
560+
controls: dict[str, Any] | None = None,
561+
) -> str:
562+
if strategy_profile not in DCA_PROFILES:
563+
return ""
564+
controls = dict(controls or {})
565+
raw_mode = args.dca_mode if str(args.dca_mode or "").strip() else controls.get(DCA_MODE_CONTROL_FIELD, "")
566+
return _normalize_dca_mode(raw_mode) if str(raw_mode or "").strip() else "fixed"
567+
568+
576569
def _reject_direct_dca_extra_variables(extra_variables: dict[str, Any]) -> None:
577570
provided = [
578571
variable
@@ -594,7 +587,10 @@ def _reject_direct_ibit_zscore_exit_extra_variables(extra_variables: dict[str, A
594587
]
595588
if provided:
596589
names = ", ".join(provided)
597-
raise ValueError(f"use ibit_zscore_exit_* control fields instead of extra_variables_json for {names}")
590+
raise ValueError(
591+
"IBIT_ZSCORE_EXIT variables are derived from ibit_smart_dca smart DCA mode; "
592+
f"do not set them directly: {names}"
593+
)
598594

599595

600596
def _reject_research_only_extra_variables(extra_variables: dict[str, Any]) -> None:
@@ -620,81 +616,36 @@ def _ibit_zscore_exit_extra_variables(
620616
args: argparse.Namespace,
621617
strategy_profile: str,
622618
plugin_mode: str,
623-
controls: dict[str, Any] | None = None,
619+
dca_mode: str,
624620
) -> dict[str, Any]:
625-
controls = dict(controls or {})
626-
cli_mode = str(getattr(args, "ibit_zscore_exit_mode", "") or "").strip()
627-
mode_value = cli_mode or controls.get("ibit_zscore_exit_mode", "")
628-
has_controls = bool(mode_value) or any(
629-
str(controls.get(field, "") or "").strip()
630-
for field in IBIT_ZSCORE_EXIT_CONTROL_FIELDS
631-
if field != "ibit_zscore_exit_mode"
632-
)
633-
has_cli_controls = any(
634-
str(getattr(args, attr, "") or "").strip()
635-
for attr in (
636-
"ibit_zscore_exit_parking_symbol",
637-
"ibit_zscore_exit_risk_reduced_exposure",
638-
"ibit_zscore_exit_risk_off_exposure",
639-
"ibit_zscore_exit_allow_outside_execution_window",
640-
)
641-
)
642621
is_ibit_profile = strategy_profile == IBIT_ZSCORE_EXIT_STRATEGY_PROFILE
643622
if not is_ibit_profile:
644-
if has_controls or has_cli_controls:
645-
raise ValueError("IBIT Z-Score exit settings are only supported for ibit_smart_dca")
646623
return {variable: "" for variable in IBIT_ZSCORE_EXIT_RUNTIME_VARIABLES}
647624

648-
if not mode_value:
649-
mode = "disabled" if plugin_mode == "none" else "live"
650-
else:
651-
mode = _normalize_ibit_zscore_exit_mode(mode_value)
652-
if plugin_mode == "none" and mode != "disabled":
653-
raise ValueError("IBIT Z-Score exit live/paper modes require plugin_mode auto or custom")
654-
655-
parking_symbol = (
656-
getattr(args, "ibit_zscore_exit_parking_symbol", "")
657-
or controls.get("ibit_zscore_exit_parking_symbol")
658-
or "BOXX"
659-
)
660-
risk_reduced_exposure = (
661-
getattr(args, "ibit_zscore_exit_risk_reduced_exposure", "")
662-
or controls.get("ibit_zscore_exit_risk_reduced_exposure")
663-
or "0.50"
664-
)
665-
risk_off_exposure = (
666-
getattr(args, "ibit_zscore_exit_risk_off_exposure", "")
667-
or controls.get("ibit_zscore_exit_risk_off_exposure")
668-
or "0.25"
669-
)
670-
allow_outside_window = (
671-
getattr(args, "ibit_zscore_exit_allow_outside_execution_window", "")
672-
or controls.get("ibit_zscore_exit_allow_outside_execution_window")
673-
or "true"
674-
)
625+
mode = "live" if plugin_mode != "none" and dca_mode == "smart" else "disabled"
675626
return {
676627
IBIT_ZSCORE_EXIT_ENABLED_VARIABLE: "true" if mode != "disabled" else "false",
677628
IBIT_ZSCORE_EXIT_MODE_VARIABLE: "paper" if mode == "disabled" else mode,
678629
IBIT_ZSCORE_EXIT_PARKING_SYMBOL_VARIABLE: _normalize_symbol_text(
679-
parking_symbol,
630+
"BOXX",
680631
field_name="ibit_zscore_exit_parking_symbol",
681632
),
682633
IBIT_ZSCORE_EXIT_RISK_REDUCED_EXPOSURE_VARIABLE: _normalize_ratio_decimal(
683-
risk_reduced_exposure,
634+
"0.50",
684635
field_name="ibit_zscore_exit_risk_reduced_exposure",
685636
),
686637
IBIT_ZSCORE_EXIT_RISK_OFF_EXPOSURE_VARIABLE: _normalize_ratio_decimal(
687-
risk_off_exposure,
638+
"0.25",
688639
field_name="ibit_zscore_exit_risk_off_exposure",
689640
),
690641
IBIT_ZSCORE_EXIT_ALLOW_OUTSIDE_WINDOW_VARIABLE: _normalize_optional_bool_text(
691-
allow_outside_window,
642+
"true",
692643
field_name="ibit_zscore_exit_allow_outside_execution_window",
693644
),
694645
}
695646

696647

697-
def _auto_plugin_mounts(strategy_profile: str, artifact_bucket_uri: str) -> list[dict[str, Any]]:
648+
def _auto_plugin_mounts(strategy_profile: str, artifact_bucket_uri: str, dca_mode: str = "") -> list[dict[str, Any]]:
698649
prefix = artifact_bucket_uri.rstrip("/")
699650
mounts: list[dict[str, Any]] = []
700651
if strategy_profile in MARKET_REGIME_CONTROL_PROFILES:
@@ -711,7 +662,7 @@ def _auto_plugin_mounts(strategy_profile: str, artifact_bucket_uri: str) -> list
711662
"expected_schema_version": "market_regime_control.v1",
712663
}
713664
)
714-
if strategy_profile == IBIT_ZSCORE_EXIT_STRATEGY_PROFILE:
665+
if strategy_profile == IBIT_ZSCORE_EXIT_STRATEGY_PROFILE and dca_mode == "smart":
715666
mounts.append(
716667
{
717668
"strategy": strategy_profile,
@@ -743,12 +694,12 @@ def _custom_plugin_mounts(raw_json: str) -> list[dict[str, Any]]:
743694
return [dict(item) for item in payload]
744695

745696

746-
def _plugin_mounts(args: argparse.Namespace, strategy_profile: str) -> list[dict[str, Any]]:
697+
def _plugin_mounts(args: argparse.Namespace, strategy_profile: str, dca_mode: str = "") -> list[dict[str, Any]]:
747698
mode = str(args.plugin_mode or "auto").strip().lower()
748699
if mode == "none":
749700
return []
750701
if mode == "auto":
751-
return _auto_plugin_mounts(strategy_profile, args.artifact_bucket_uri)
702+
return _auto_plugin_mounts(strategy_profile, args.artifact_bucket_uri, dca_mode)
752703
if mode == "custom":
753704
return _custom_plugin_mounts(args.custom_plugin_mounts_json)
754705
raise ValueError(f"unsupported plugin_mode {args.plugin_mode!r}")
@@ -935,13 +886,14 @@ def build_switch_target(args: argparse.Namespace) -> dict[str, Any]:
935886
raise ValueError("variable_scope must be repository or environment")
936887
github_environment = args.github_environment or _default_github_environment(platform, target_name, variable_scope)
937888
runtime_target = _build_runtime_target(args)
938-
mounts = _plugin_mounts(args, runtime_target["strategy_profile"])
939-
runtime_target["scheduler"] = _scheduler_plan_for_strategy(runtime_target["strategy_profile"], mounts)
940-
mounts_variable = f"{SUPPORTED_PLATFORMS[platform]['plugin_mounts_prefix']}STRATEGY_PLUGIN_MOUNTS_JSON"
941889
extra_variables = _parse_extra_variables(args.extra_variable, args.extra_variables_json)
942890
cash_only_controls = _extract_cash_only_control_fields(extra_variables)
943891
dca_controls = _extract_dca_control_fields(extra_variables)
944-
ibit_zscore_exit_controls = _extract_ibit_zscore_exit_control_fields(extra_variables)
892+
_extract_ibit_zscore_exit_control_fields(extra_variables)
893+
effective_dca_mode = _effective_dca_mode(args, runtime_target["strategy_profile"], dca_controls)
894+
mounts = _plugin_mounts(args, runtime_target["strategy_profile"], effective_dca_mode)
895+
runtime_target["scheduler"] = _scheduler_plan_for_strategy(runtime_target["strategy_profile"], mounts)
896+
mounts_variable = f"{SUPPORTED_PLATFORMS[platform]['plugin_mounts_prefix']}STRATEGY_PLUGIN_MOUNTS_JSON"
945897
if cash_only_controls.get(CASH_ONLY_EXECUTION_CONTROL_FIELD):
946898
args.cash_only_execution_mode = str(cash_only_controls[CASH_ONLY_EXECUTION_CONTROL_FIELD]).strip().lower()
947899
_reject_direct_dca_extra_variables(extra_variables)
@@ -966,7 +918,7 @@ def build_switch_target(args: argparse.Namespace) -> dict[str, Any]:
966918
args,
967919
runtime_target["strategy_profile"],
968920
str(args.plugin_mode or "auto").strip().lower(),
969-
ibit_zscore_exit_controls,
921+
effective_dca_mode,
970922
)
971923
)
972924

@@ -1040,11 +992,6 @@ def build_parser() -> argparse.ArgumentParser:
1040992
parser.add_argument("--option-overlay-mode", choices=sorted(OPTION_OVERLAY_MODES), default="current")
1041993
parser.add_argument("--dca-mode", default="")
1042994
parser.add_argument("--dca-base-investment-usd", default="")
1043-
parser.add_argument("--ibit-zscore-exit-mode", choices=("disabled", "paper", "live"), default="")
1044-
parser.add_argument("--ibit-zscore-exit-parking-symbol", default="")
1045-
parser.add_argument("--ibit-zscore-exit-risk-reduced-exposure", default="")
1046-
parser.add_argument("--ibit-zscore-exit-risk-off-exposure", default="")
1047-
parser.add_argument("--ibit-zscore-exit-allow-outside-execution-window", default="")
1048995
parser.add_argument("--existing-service-targets-json-file", default="")
1049996
parser.add_argument("--no-platform-dry-run-variable", dest="set_platform_dry_run_variable", action="store_false")
1050997
parser.set_defaults(set_platform_dry_run_variable=True)

python/scripts/runtime_settings.py

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -714,7 +714,6 @@ def command_repository(args: argparse.Namespace) -> int:
714714
ACCOUNT_SYNC_CONTROL_FIELDS = {
715715
"DCA_MODE": "dca_mode",
716716
"DCA_BASE_INVESTMENT_USD": "dca_base_investment_usd",
717-
"IBIT_ZSCORE_EXIT_MODE": "ibit_zscore_exit_mode",
718717
}
719718

720719

python/tests/test_runtime_settings.py

Lines changed: 38 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -247,7 +247,6 @@ def test_extract_account_sync_controls_reads_ibkr_service_targets(self):
247247
{
248248
"dca_mode": "smart",
249249
"dca_base_investment_usd": "500",
250-
"ibit_zscore_exit_mode": "paper",
251250
},
252251
)
253252

@@ -609,7 +608,7 @@ def test_build_switch_target_uses_dca_monthly_scheduler_window(self):
609608
},
610609
)
611610

612-
def test_build_switch_target_uses_daily_scheduler_when_ibit_zscore_plugin_is_auto_mounted(self):
611+
def test_build_switch_target_uses_daily_scheduler_when_ibit_smart_dca_is_smart(self):
613612
parser = build_runtime_switch.build_parser()
614613
args = parser.parse_args(
615614
[
@@ -619,6 +618,8 @@ def test_build_switch_target_uses_daily_scheduler_when_ibit_zscore_plugin_is_aut
619618
"ibit",
620619
"--strategy-profile",
621620
"ibit_smart_dca",
621+
"--dca-mode",
622+
"smart",
622623
]
623624
)
624625

@@ -641,7 +642,7 @@ def test_build_switch_target_uses_daily_scheduler_when_ibit_zscore_plugin_is_aut
641642
self.assertEqual(assignments["IBIT_ZSCORE_EXIT_ENABLED"], "true")
642643
self.assertEqual(assignments["IBIT_ZSCORE_EXIT_MODE"], "live")
643644

644-
def test_build_switch_target_sets_ibit_zscore_exit_runtime_controls(self):
645+
def test_build_switch_target_ignores_legacy_ibit_zscore_controls(self):
645646
parser = build_runtime_switch.build_parser()
646647
args = parser.parse_args(
647648
[
@@ -651,6 +652,8 @@ def test_build_switch_target_sets_ibit_zscore_exit_runtime_controls(self):
651652
"ibit",
652653
"--strategy-profile",
653654
"ibit_smart_dca",
655+
"--dca-mode",
656+
"smart",
654657
"--extra-variables-json",
655658
'{"ibit_zscore_exit_mode":"live","ibit_zscore_exit_parking_symbol":"SGOV"}',
656659
]
@@ -661,10 +664,32 @@ def test_build_switch_target_sets_ibit_zscore_exit_runtime_controls(self):
661664

662665
self.assertEqual(assignments["IBIT_ZSCORE_EXIT_ENABLED"], "true")
663666
self.assertEqual(assignments["IBIT_ZSCORE_EXIT_MODE"], "live")
664-
self.assertEqual(assignments["IBIT_ZSCORE_EXIT_PARKING_SYMBOL"], "SGOV")
667+
self.assertEqual(assignments["IBIT_ZSCORE_EXIT_PARKING_SYMBOL"], "BOXX")
665668
self.assertNotIn("ibit_zscore_exit_mode", target["extra_variables"])
666669
self.assertNotIn("ibit_zscore_exit_parking_symbol", target["extra_variables"])
667670

671+
def test_build_switch_target_disables_ibit_zscore_exit_for_fixed_dca(self):
672+
parser = build_runtime_switch.build_parser()
673+
args = parser.parse_args(
674+
[
675+
"--platform",
676+
"firstrade",
677+
"--target-name",
678+
"ibit",
679+
"--strategy-profile",
680+
"ibit_smart_dca",
681+
]
682+
)
683+
684+
target = build_runtime_switch.build_switch_target(args)
685+
assignments = {item.name: item.value for item in runtime_settings.build_assignments(target)}
686+
plugin_payload = json.loads(assignments["FIRSTRADE_STRATEGY_PLUGIN_MOUNTS_JSON"])
687+
688+
self.assertEqual(target["runtime_target"]["scheduler"], build_runtime_switch.US_DCA_SCHEDULER)
689+
self.assertEqual(plugin_payload["strategy_plugins"], [])
690+
self.assertEqual(assignments["IBIT_ZSCORE_EXIT_ENABLED"], "false")
691+
self.assertEqual(assignments["IBIT_ZSCORE_EXIT_MODE"], "paper")
692+
668693
def test_build_switch_target_disables_ibit_zscore_exit_when_plugins_are_disabled(self):
669694
parser = build_runtime_switch.build_parser()
670695
args = parser.parse_args(
@@ -675,6 +700,8 @@ def test_build_switch_target_disables_ibit_zscore_exit_when_plugins_are_disabled
675700
"ibit",
676701
"--strategy-profile",
677702
"ibit_smart_dca",
703+
"--dca-mode",
704+
"smart",
678705
"--plugin-mode",
679706
"none",
680707
]
@@ -687,7 +714,7 @@ def test_build_switch_target_disables_ibit_zscore_exit_when_plugins_are_disabled
687714
self.assertEqual(assignments["IBIT_ZSCORE_EXIT_ENABLED"], "false")
688715
self.assertEqual(assignments["IBIT_ZSCORE_EXIT_MODE"], "paper")
689716

690-
def test_build_switch_target_rejects_ibit_zscore_controls_for_other_profiles(self):
717+
def test_build_switch_target_ignores_legacy_ibit_zscore_controls_for_other_profiles(self):
691718
parser = build_runtime_switch.build_parser()
692719
args = parser.parse_args(
693720
[
@@ -702,8 +729,12 @@ def test_build_switch_target_rejects_ibit_zscore_controls_for_other_profiles(sel
702729
]
703730
)
704731

705-
with self.assertRaisesRegex(ValueError, "IBIT Z-Score exit settings"):
706-
build_runtime_switch.build_switch_target(args)
732+
target = build_runtime_switch.build_switch_target(args)
733+
assignments = {item.name: item.value for item in runtime_settings.build_assignments(target)}
734+
735+
self.assertEqual(assignments["IBIT_ZSCORE_EXIT_ENABLED"], "")
736+
self.assertEqual(assignments["IBIT_ZSCORE_EXIT_MODE"], "")
737+
self.assertNotIn("ibit_zscore_exit_mode", target["extra_variables"])
707738

708739
def test_build_switch_target_sets_dca_settings_for_dca_profile(self):
709740
parser = build_runtime_switch.build_parser()

0 commit comments

Comments
 (0)