Skip to content

Commit 10945b2

Browse files
authored
Add IBIT zscore switch controls (#69)
1 parent e6f40c1 commit 10945b2

7 files changed

Lines changed: 728 additions & 30 deletions

File tree

internal_dependency_matrix.json

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -62,7 +62,7 @@
6262
"path": "pyproject.toml",
6363
"package": "us-equity-strategies",
6464
"source_repo": "UsEquityStrategies",
65-
"ref": "31406abfb15507270992e62ead8d1068c03328d0"
65+
"ref": "ced1f78827e6112292af24d32dfe0e0f009e2833"
6666
},
6767
{
6868
"consumer_repo": "FirstradePlatform",
@@ -76,7 +76,7 @@
7676
"path": "requirements.txt",
7777
"package": "us-equity-strategies",
7878
"source_repo": "UsEquityStrategies",
79-
"ref": "31406abfb15507270992e62ead8d1068c03328d0"
79+
"ref": "ced1f78827e6112292af24d32dfe0e0f009e2833"
8080
},
8181
{
8282
"consumer_repo": "HkEquityStrategies",
@@ -97,7 +97,7 @@
9797
"path": "requirements.txt",
9898
"package": "us-equity-strategies",
9999
"source_repo": "UsEquityStrategies",
100-
"ref": "31406abfb15507270992e62ead8d1068c03328d0"
100+
"ref": "ced1f78827e6112292af24d32dfe0e0f009e2833"
101101
},
102102
{
103103
"consumer_repo": "InteractiveBrokersPlatform",
@@ -118,7 +118,7 @@
118118
"path": "requirements.txt",
119119
"package": "us-equity-strategies",
120120
"source_repo": "UsEquityStrategies",
121-
"ref": "31406abfb15507270992e62ead8d1068c03328d0"
121+
"ref": "ced1f78827e6112292af24d32dfe0e0f009e2833"
122122
},
123123
{
124124
"consumer_repo": "LongBridgePlatform",

scripts/build_runtime_switch.py

Lines changed: 240 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -31,6 +31,8 @@
3131
"russell_top50_leader_rotation",
3232
}
3333
)
34+
IBIT_ZSCORE_EXIT_STRATEGY_PROFILE = "ibit_smart_dca"
35+
IBIT_ZSCORE_EXIT_PLUGIN = "ibit_zscore_exit"
3436
US_DAILY_SCHEDULER = {
3537
"timezone": "America/New_York",
3638
"main_time": "45 15 * * *",
@@ -108,6 +110,27 @@
108110
)
109111
DCA_MODE_CONTROL_FIELD = "dca_mode"
110112
DCA_BASE_INVESTMENT_CONTROL_FIELD = "dca_base_investment_usd"
113+
IBIT_ZSCORE_EXIT_ENABLED_VARIABLE = "IBIT_ZSCORE_EXIT_ENABLED"
114+
IBIT_ZSCORE_EXIT_MODE_VARIABLE = "IBIT_ZSCORE_EXIT_MODE"
115+
IBIT_ZSCORE_EXIT_PARKING_SYMBOL_VARIABLE = "IBIT_ZSCORE_EXIT_PARKING_SYMBOL"
116+
IBIT_ZSCORE_EXIT_RISK_REDUCED_EXPOSURE_VARIABLE = "IBIT_ZSCORE_EXIT_RISK_REDUCED_EXPOSURE"
117+
IBIT_ZSCORE_EXIT_RISK_OFF_EXPOSURE_VARIABLE = "IBIT_ZSCORE_EXIT_RISK_OFF_EXPOSURE"
118+
IBIT_ZSCORE_EXIT_ALLOW_OUTSIDE_WINDOW_VARIABLE = "IBIT_ZSCORE_EXIT_ALLOW_OUTSIDE_EXECUTION_WINDOW"
119+
IBIT_ZSCORE_EXIT_RUNTIME_VARIABLES = (
120+
IBIT_ZSCORE_EXIT_ENABLED_VARIABLE,
121+
IBIT_ZSCORE_EXIT_MODE_VARIABLE,
122+
IBIT_ZSCORE_EXIT_PARKING_SYMBOL_VARIABLE,
123+
IBIT_ZSCORE_EXIT_RISK_REDUCED_EXPOSURE_VARIABLE,
124+
IBIT_ZSCORE_EXIT_RISK_OFF_EXPOSURE_VARIABLE,
125+
IBIT_ZSCORE_EXIT_ALLOW_OUTSIDE_WINDOW_VARIABLE,
126+
)
127+
IBIT_ZSCORE_EXIT_CONTROL_FIELDS = (
128+
"ibit_zscore_exit_mode",
129+
"ibit_zscore_exit_parking_symbol",
130+
"ibit_zscore_exit_risk_reduced_exposure",
131+
"ibit_zscore_exit_risk_off_exposure",
132+
"ibit_zscore_exit_allow_outside_execution_window",
133+
)
111134
DEFAULT_VARIABLE_SCOPE = {
112135
"longbridge": "environment",
113136
"ibkr": "repository",
@@ -236,6 +259,51 @@ def _normalize_positive_decimal(value: str, *, field_name: str) -> str:
236259
return text
237260

238261

262+
def _normalize_ratio_decimal(value: str, *, field_name: str) -> str:
263+
text = str(value or "").strip()
264+
if not text or not re.fullmatch(r"(?:\d+|\d*\.\d+)", text):
265+
raise ValueError(f"{field_name} must be a decimal number")
266+
numeric = float(text)
267+
if numeric < 0 or numeric > 1:
268+
raise ValueError(f"{field_name} must be between 0 and 1")
269+
return text
270+
271+
272+
def _normalize_optional_bool_text(value: str, *, field_name: str) -> str:
273+
text = str(value or "").strip().lower()
274+
if text in {"1", "true", "yes", "y", "on"}:
275+
return "true"
276+
if text in {"0", "false", "no", "n", "off"}:
277+
return "false"
278+
raise ValueError(f"{field_name} must be true or false")
279+
280+
281+
def _normalize_ibit_zscore_exit_mode(value: str) -> str:
282+
mode = str(value or "").strip().lower()
283+
aliases = {
284+
"off": "disabled",
285+
"none": "disabled",
286+
"false": "disabled",
287+
"0": "disabled",
288+
"disable": "disabled",
289+
"enabled": "live",
290+
"shadow": "paper",
291+
"dry_run": "paper",
292+
"dry-run": "paper",
293+
}
294+
mode = aliases.get(mode, mode)
295+
if mode not in {"disabled", "paper", "live"}:
296+
raise ValueError("ibit_zscore_exit_mode must be disabled, paper, or live")
297+
return mode
298+
299+
300+
def _normalize_symbol_text(value: str, *, field_name: str) -> str:
301+
text = str(value or "").strip().upper().removesuffix(".US")
302+
if not text or not re.fullmatch(r"[A-Z0-9.-]{1,12}", text):
303+
raise ValueError(f"{field_name} must be a symbol")
304+
return text
305+
306+
239307
def _extract_dca_control_fields(extra_variables: dict[str, Any]) -> dict[str, Any]:
240308
controls: dict[str, Any] = {}
241309
for field_name in (DCA_MODE_CONTROL_FIELD, DCA_BASE_INVESTMENT_CONTROL_FIELD):
@@ -244,6 +312,14 @@ def _extract_dca_control_fields(extra_variables: dict[str, Any]) -> dict[str, An
244312
return controls
245313

246314

315+
def _extract_ibit_zscore_exit_control_fields(extra_variables: dict[str, Any]) -> dict[str, Any]:
316+
controls: dict[str, Any] = {}
317+
for field_name in IBIT_ZSCORE_EXIT_CONTROL_FIELDS:
318+
if field_name in extra_variables:
319+
controls[field_name] = extra_variables.pop(field_name)
320+
return controls
321+
322+
247323
def _dca_extra_variables(
248324
args: argparse.Namespace,
249325
strategy_profile: str,
@@ -288,23 +364,130 @@ def _reject_direct_dca_extra_variables(extra_variables: dict[str, Any]) -> None:
288364
)
289365

290366

367+
def _reject_direct_ibit_zscore_exit_extra_variables(extra_variables: dict[str, Any]) -> None:
368+
provided = [
369+
variable
370+
for variable in IBIT_ZSCORE_EXIT_RUNTIME_VARIABLES
371+
if variable in extra_variables and str(extra_variables.get(variable) or "").strip()
372+
]
373+
if provided:
374+
names = ", ".join(provided)
375+
raise ValueError(
376+
"use ibit_zscore_exit_* control fields instead of extra_variables_json "
377+
f"for {names}"
378+
)
379+
380+
381+
def _ibit_zscore_exit_extra_variables(
382+
args: argparse.Namespace,
383+
strategy_profile: str,
384+
plugin_mode: str,
385+
controls: dict[str, Any] | None = None,
386+
) -> dict[str, Any]:
387+
controls = dict(controls or {})
388+
cli_mode = str(getattr(args, "ibit_zscore_exit_mode", "") or "").strip()
389+
mode_value = cli_mode or controls.get("ibit_zscore_exit_mode", "")
390+
has_controls = bool(mode_value) or any(
391+
str(controls.get(field, "") or "").strip()
392+
for field in IBIT_ZSCORE_EXIT_CONTROL_FIELDS
393+
if field != "ibit_zscore_exit_mode"
394+
)
395+
has_cli_controls = any(
396+
str(getattr(args, attr, "") or "").strip()
397+
for attr in (
398+
"ibit_zscore_exit_parking_symbol",
399+
"ibit_zscore_exit_risk_reduced_exposure",
400+
"ibit_zscore_exit_risk_off_exposure",
401+
"ibit_zscore_exit_allow_outside_execution_window",
402+
)
403+
)
404+
is_ibit_profile = strategy_profile == IBIT_ZSCORE_EXIT_STRATEGY_PROFILE
405+
if not is_ibit_profile:
406+
if has_controls or has_cli_controls:
407+
raise ValueError("IBIT Z-Score exit settings are only supported for ibit_smart_dca")
408+
return {variable: "" for variable in IBIT_ZSCORE_EXIT_RUNTIME_VARIABLES}
409+
410+
if not mode_value:
411+
mode = "disabled" if plugin_mode == "none" else "live"
412+
else:
413+
mode = _normalize_ibit_zscore_exit_mode(mode_value)
414+
if plugin_mode == "none" and mode != "disabled":
415+
raise ValueError("IBIT Z-Score exit live/paper modes require plugin_mode auto or custom")
416+
417+
parking_symbol = (
418+
getattr(args, "ibit_zscore_exit_parking_symbol", "")
419+
or controls.get("ibit_zscore_exit_parking_symbol")
420+
or "BOXX"
421+
)
422+
risk_reduced_exposure = (
423+
getattr(args, "ibit_zscore_exit_risk_reduced_exposure", "")
424+
or controls.get("ibit_zscore_exit_risk_reduced_exposure")
425+
or "0.50"
426+
)
427+
risk_off_exposure = (
428+
getattr(args, "ibit_zscore_exit_risk_off_exposure", "")
429+
or controls.get("ibit_zscore_exit_risk_off_exposure")
430+
or "0.25"
431+
)
432+
allow_outside_window = (
433+
getattr(args, "ibit_zscore_exit_allow_outside_execution_window", "")
434+
or controls.get("ibit_zscore_exit_allow_outside_execution_window")
435+
or "true"
436+
)
437+
return {
438+
IBIT_ZSCORE_EXIT_ENABLED_VARIABLE: "true" if mode != "disabled" else "false",
439+
IBIT_ZSCORE_EXIT_MODE_VARIABLE: "paper" if mode == "disabled" else mode,
440+
IBIT_ZSCORE_EXIT_PARKING_SYMBOL_VARIABLE: _normalize_symbol_text(
441+
parking_symbol,
442+
field_name="ibit_zscore_exit_parking_symbol",
443+
),
444+
IBIT_ZSCORE_EXIT_RISK_REDUCED_EXPOSURE_VARIABLE: _normalize_ratio_decimal(
445+
risk_reduced_exposure,
446+
field_name="ibit_zscore_exit_risk_reduced_exposure",
447+
),
448+
IBIT_ZSCORE_EXIT_RISK_OFF_EXPOSURE_VARIABLE: _normalize_ratio_decimal(
449+
risk_off_exposure,
450+
field_name="ibit_zscore_exit_risk_off_exposure",
451+
),
452+
IBIT_ZSCORE_EXIT_ALLOW_OUTSIDE_WINDOW_VARIABLE: _normalize_optional_bool_text(
453+
allow_outside_window,
454+
field_name="ibit_zscore_exit_allow_outside_execution_window",
455+
),
456+
}
457+
458+
291459
def _auto_plugin_mounts(strategy_profile: str, artifact_bucket_uri: str) -> list[dict[str, Any]]:
292-
if strategy_profile not in MARKET_REGIME_CONTROL_PROFILES:
293-
return []
294460
prefix = artifact_bucket_uri.rstrip("/")
295-
return [
296-
{
297-
"strategy": strategy_profile,
298-
"plugin": "market_regime_control",
299-
"signal_path": (
300-
f"{prefix}/strategy-artifacts/us_equity/{strategy_profile}"
301-
"/plugins/market_regime_control/latest_signal.json"
302-
),
303-
"enabled": True,
304-
"expected_mode": "shadow",
305-
"expected_schema_version": "market_regime_control.v1",
306-
}
307-
]
461+
mounts: list[dict[str, Any]] = []
462+
if strategy_profile in MARKET_REGIME_CONTROL_PROFILES:
463+
mounts.append(
464+
{
465+
"strategy": strategy_profile,
466+
"plugin": "market_regime_control",
467+
"signal_path": (
468+
f"{prefix}/strategy-artifacts/us_equity/{strategy_profile}"
469+
"/plugins/market_regime_control/latest_signal.json"
470+
),
471+
"enabled": True,
472+
"expected_mode": "shadow",
473+
"expected_schema_version": "market_regime_control.v1",
474+
}
475+
)
476+
if strategy_profile == IBIT_ZSCORE_EXIT_STRATEGY_PROFILE:
477+
mounts.append(
478+
{
479+
"strategy": strategy_profile,
480+
"plugin": IBIT_ZSCORE_EXIT_PLUGIN,
481+
"signal_path": (
482+
f"{prefix}/strategy-artifacts/us_equity/{strategy_profile}"
483+
f"/plugins/{IBIT_ZSCORE_EXIT_PLUGIN}/latest_signal.json"
484+
),
485+
"enabled": True,
486+
"expected_mode": "shadow",
487+
"expected_schema_version": "ibit_zscore_exit.v1",
488+
}
489+
)
490+
return mounts
308491

309492

310493
def _custom_plugin_mounts(raw_json: str) -> list[dict[str, Any]]:
@@ -342,8 +525,32 @@ def _execution_mode_and_dry_run(raw_mode: str) -> tuple[str, bool]:
342525
raise ValueError("execution_mode must be live or paper")
343526

344527

345-
def _scheduler_plan_for_strategy(strategy_profile: str) -> dict[str, str]:
528+
def _has_enabled_plugin_mount(
529+
mounts: list[dict[str, Any]] | tuple[dict[str, Any], ...],
530+
*,
531+
strategy_profile: str,
532+
plugin: str,
533+
) -> bool:
534+
return any(
535+
isinstance(mount, dict)
536+
and mount.get("strategy") == strategy_profile
537+
and mount.get("plugin") == plugin
538+
and mount.get("enabled") is True
539+
for mount in mounts
540+
)
541+
542+
543+
def _scheduler_plan_for_strategy(
544+
strategy_profile: str,
545+
plugin_mounts: list[dict[str, Any]] | tuple[dict[str, Any], ...] = (),
546+
) -> dict[str, str]:
346547
profile = str(strategy_profile or "").strip().lower()
548+
if profile == IBIT_ZSCORE_EXIT_STRATEGY_PROFILE and _has_enabled_plugin_mount(
549+
plugin_mounts,
550+
strategy_profile=profile,
551+
plugin=IBIT_ZSCORE_EXIT_PLUGIN,
552+
):
553+
return dict(US_DAILY_SCHEDULER)
347554
scheduler = STRATEGY_SCHEDULER_PROFILES.get(profile)
348555
if scheduler is None:
349556
scheduler = HK_DAILY_SCHEDULER if profile.startswith("hk_") else US_DAILY_SCHEDULER
@@ -420,6 +627,7 @@ def _preserve_reserved_cash_fields(
420627
*INCOME_LAYER_VARIABLES,
421628
*RUNTIME_TARGET_VARIABLES,
422629
*DCA_RUNTIME_VARIABLES,
630+
*IBIT_ZSCORE_EXIT_RUNTIME_VARIABLES,
423631
):
424632
if variable and variable not in replacement and variable in current_entry:
425633
replacement[variable] = current_entry[variable]
@@ -482,10 +690,13 @@ def build_switch_target(args: argparse.Namespace) -> dict[str, Any]:
482690
github_environment = args.github_environment or _default_github_environment(platform, target_name, variable_scope)
483691
runtime_target = _build_runtime_target(args)
484692
mounts = _plugin_mounts(args, runtime_target["strategy_profile"])
693+
runtime_target["scheduler"] = _scheduler_plan_for_strategy(runtime_target["strategy_profile"], mounts)
485694
mounts_variable = f"{SUPPORTED_PLATFORMS[platform]['plugin_mounts_prefix']}STRATEGY_PLUGIN_MOUNTS_JSON"
486695
extra_variables = _parse_extra_variables(args.extra_variable, args.extra_variables_json)
487696
dca_controls = _extract_dca_control_fields(extra_variables)
697+
ibit_zscore_exit_controls = _extract_ibit_zscore_exit_control_fields(extra_variables)
488698
_reject_direct_dca_extra_variables(extra_variables)
699+
_reject_direct_ibit_zscore_exit_extra_variables(extra_variables)
489700

490701
if args.set_platform_dry_run_variable:
491702
extra_variables[PLATFORM_DRY_RUN_VARIABLES[platform]] = env_string(runtime_target["dry_run_only"])
@@ -502,6 +713,14 @@ def build_switch_target(args: argparse.Namespace) -> dict[str, Any]:
502713
if args.qqqi_income_ratio:
503714
extra_variables["QQQI_INCOME_RATIO"] = args.qqqi_income_ratio
504715
extra_variables.update(_dca_extra_variables(args, runtime_target["strategy_profile"], dca_controls))
716+
extra_variables.update(
717+
_ibit_zscore_exit_extra_variables(
718+
args,
719+
runtime_target["strategy_profile"],
720+
str(args.plugin_mode or "auto").strip().lower(),
721+
ibit_zscore_exit_controls,
722+
)
723+
)
505724

506725
service_targets = _load_json_from_file(
507726
args.existing_service_targets_json_file,
@@ -569,6 +788,11 @@ def build_parser() -> argparse.ArgumentParser:
569788
parser.add_argument("--qqqi-income-ratio", default="")
570789
parser.add_argument("--dca-mode", default="")
571790
parser.add_argument("--dca-base-investment-usd", default="")
791+
parser.add_argument("--ibit-zscore-exit-mode", choices=("disabled", "paper", "live"), default="")
792+
parser.add_argument("--ibit-zscore-exit-parking-symbol", default="")
793+
parser.add_argument("--ibit-zscore-exit-risk-reduced-exposure", default="")
794+
parser.add_argument("--ibit-zscore-exit-risk-off-exposure", default="")
795+
parser.add_argument("--ibit-zscore-exit-allow-outside-execution-window", default="")
572796
parser.add_argument("--existing-service-targets-json-file", default="")
573797
parser.add_argument("--no-platform-dry-run-variable", dest="set_platform_dry_run_variable", action="store_false")
574798
parser.set_defaults(set_platform_dry_run_variable=True)

0 commit comments

Comments
 (0)