Skip to content

Commit bdcd995

Browse files
authored
add controlled option overlay switch (#74)
1 parent 4345c95 commit bdcd995

10 files changed

Lines changed: 710 additions & 15 deletions

File tree

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

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -90,6 +90,15 @@ on:
9090
description: "Optional income layer maximum allocation ratio."
9191
required: false
9292
type: string
93+
option_overlay_mode:
94+
description: "Option overlay policy: current preserves variables, enabled applies profile defaults, disabled clears it."
95+
required: true
96+
type: choice
97+
default: current
98+
options:
99+
- current
100+
- enabled
101+
- disabled
93102
service_targets_mode:
94103
description: "auto patches IBKR CLOUD_RUN_SERVICE_TARGETS_JSON when it exists."
95104
required: true
@@ -149,6 +158,7 @@ jobs:
149158
MIN_RESERVED_CASH_USD: ${{ inputs.min_reserved_cash_usd }}
150159
INCOME_LAYER_START_USD: ${{ inputs.income_layer_start_usd }}
151160
INCOME_LAYER_MAX_RATIO: ${{ inputs.income_layer_max_ratio }}
161+
OPTION_OVERLAY_MODE: ${{ inputs.option_overlay_mode }}
152162
SERVICE_TARGETS_MODE: ${{ inputs.service_targets_mode }}
153163
APPLY_SWITCH: ${{ inputs.apply }}
154164
TRIGGER_PLATFORM_SYNC: ${{ inputs.trigger_platform_sync }}
@@ -249,6 +259,7 @@ jobs:
249259
--strategy-profile "${STRATEGY_PROFILE}"
250260
--execution-mode "${EXECUTION_MODE}"
251261
--plugin-mode "${PLUGIN_MODE}"
262+
--option-overlay-mode "${OPTION_OVERLAY_MODE}"
252263
--output "${target_file}"
253264
)
254265
if [ "${VARIABLE_SCOPE}" != "default" ]; then
@@ -374,6 +385,7 @@ jobs:
374385
"execution_mode": runtime_target["execution_mode"],
375386
"variable_scope": github["variable_scope"],
376387
"plugin_mode": os.environ["PLUGIN_MODE"],
388+
"option_overlay_mode": os.environ.get("OPTION_OVERLAY_MODE", "current"),
377389
"deployment_selector": runtime_target["deployment_selector"],
378390
"account_selector": ",".join(runtime_target["account_selector"]),
379391
"account_scope": runtime_target["account_scope"],

scripts/build_runtime_switch.py

Lines changed: 120 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,7 @@
1313
SCRIPT_DIR = Path(__file__).resolve().parent
1414
if str(SCRIPT_DIR) not in sys.path:
1515
sys.path.insert(0, str(SCRIPT_DIR))
16+
ROOT = SCRIPT_DIR.parent
1617

1718
from runtime_settings import ( # noqa: E402
1819
SUPPORTED_PLATFORMS,
@@ -118,6 +119,8 @@
118119
"option_income_overlay_nav_risk_ratio",
119120
)
120121
OPTION_OVERLAY_VARIABLES = tuple(field.upper() for field in OPTION_OVERLAY_CONTROL_FIELDS)
122+
OPTION_OVERLAY_MODES = frozenset({"current", "enabled", "disabled"})
123+
OPTION_OVERLAY_PROFILE_PATH = ROOT / "web" / "strategy-switch-console" / "strategy-profiles.example.json"
121124
RUNTIME_TARGET_VARIABLES = (
122125
"RUNTIME_TARGET_ENABLED",
123126
)
@@ -285,6 +288,16 @@ def _normalize_positive_decimal(value: str, *, field_name: str) -> str:
285288
return text
286289

287290

291+
def _normalize_nonnegative_decimal(value: str, *, field_name: str) -> str:
292+
text = str(value or "").strip()
293+
if not text or not re.fullmatch(r"(?:\d+|\d*\.\d+)", text):
294+
raise ValueError(f"{field_name} must be a non-negative decimal number")
295+
numeric = float(text)
296+
if numeric < 0:
297+
raise ValueError(f"{field_name} must be non-negative")
298+
return text
299+
300+
288301
def _normalize_ratio_decimal(value: str, *, field_name: str) -> str:
289302
text = str(value or "").strip()
290303
if not text or not re.fullmatch(r"(?:\d+|\d*\.\d+)", text):
@@ -296,14 +309,28 @@ def _normalize_ratio_decimal(value: str, *, field_name: str) -> str:
296309

297310

298311
def _normalize_optional_bool_text(value: str, *, field_name: str) -> str:
299-
text = str(value or "").strip().lower()
312+
text = str(value if value is not None else "").strip().lower()
300313
if text in {"1", "true", "yes", "y", "on"}:
301314
return "true"
302315
if text in {"0", "false", "no", "n", "off"}:
303316
return "false"
304317
raise ValueError(f"{field_name} must be true or false")
305318

306319

320+
def _normalize_option_overlay_mode(value: str) -> str:
321+
mode = str(value or "current").strip().lower()
322+
if mode not in OPTION_OVERLAY_MODES:
323+
raise ValueError("option_overlay_mode must be current, enabled, or disabled")
324+
return mode
325+
326+
327+
def _normalize_option_recipe(value: str, *, field_name: str) -> str:
328+
text = str(value or "").strip()
329+
if not text or not re.fullmatch(r"[A-Za-z0-9._=-]{1,120}", text):
330+
raise ValueError(f"{field_name} must be a recipe slug")
331+
return text
332+
333+
307334
def _normalize_ibit_zscore_exit_mode(value: str) -> str:
308335
mode = str(value or "").strip().lower()
309336
aliases = {
@@ -346,6 +373,95 @@ def _extract_ibit_zscore_exit_control_fields(extra_variables: dict[str, Any]) ->
346373
return controls
347374

348375

376+
def _disabled_option_overlay_extra_variables() -> dict[str, str]:
377+
values = {variable: "" for variable in OPTION_OVERLAY_VARIABLES}
378+
values["OPTION_OVERLAY_ENABLED"] = "false"
379+
values["OPTION_GROWTH_OVERLAY_ENABLED"] = "false"
380+
values["OPTION_INCOME_OVERLAY_ENABLED"] = "false"
381+
return values
382+
383+
384+
def _profile_bool(item: dict[str, Any], field_name: str, *, default: bool = False) -> bool:
385+
if item.get(field_name) is None or str(item.get(field_name)).strip() == "":
386+
return default
387+
return _normalize_optional_bool_text(item[field_name], field_name=field_name) == "true"
388+
389+
390+
def _option_family_defaults(item: dict[str, Any], family: str) -> dict[str, str]:
391+
control_prefix = f"option_{family}_overlay"
392+
env_prefix = f"OPTION_{family.upper()}_OVERLAY"
393+
enabled = _profile_bool(item, f"{control_prefix}_enabled", default=False)
394+
values = {
395+
f"{env_prefix}_ENABLED": "true" if enabled else "false",
396+
f"{env_prefix}_RECIPE": "",
397+
f"{env_prefix}_START_USD": "",
398+
}
399+
if family == "growth":
400+
ratio_field = "option_growth_overlay_nav_budget_ratio"
401+
ratio_variable = "OPTION_GROWTH_OVERLAY_NAV_BUDGET_RATIO"
402+
else:
403+
ratio_field = "option_income_overlay_nav_risk_ratio"
404+
ratio_variable = "OPTION_INCOME_OVERLAY_NAV_RISK_RATIO"
405+
values[ratio_variable] = ""
406+
if not enabled:
407+
return values
408+
409+
values[f"{env_prefix}_RECIPE"] = _normalize_option_recipe(
410+
item.get(f"{control_prefix}_recipe"),
411+
field_name=f"{control_prefix}_recipe",
412+
)
413+
values[f"{env_prefix}_START_USD"] = _normalize_nonnegative_decimal(
414+
item.get(f"{control_prefix}_start_usd"),
415+
field_name=f"{control_prefix}_start_usd",
416+
)
417+
values[ratio_variable] = _normalize_ratio_decimal(item.get(ratio_field), field_name=ratio_field)
418+
return values
419+
420+
421+
def _load_option_overlay_profile_defaults() -> dict[str, dict[str, str]]:
422+
try:
423+
payload = json.loads(OPTION_OVERLAY_PROFILE_PATH.read_text(encoding="utf-8"))
424+
except OSError as exc:
425+
raise ValueError(f"cannot read {OPTION_OVERLAY_PROFILE_PATH}") from exc
426+
except json.JSONDecodeError as exc:
427+
raise ValueError(f"{OPTION_OVERLAY_PROFILE_PATH} must be valid JSON") from exc
428+
if not isinstance(payload, list):
429+
raise ValueError(f"{OPTION_OVERLAY_PROFILE_PATH} must contain a strategy profile list")
430+
431+
defaults: dict[str, dict[str, str]] = {}
432+
for item in payload:
433+
if not isinstance(item, dict):
434+
continue
435+
profile = str(item.get("profile") or item.get("strategy_profile") or "").strip().lower()
436+
if not profile:
437+
continue
438+
if not _profile_bool(item, "option_overlay_enabled", default=False):
439+
continue
440+
values = _disabled_option_overlay_extra_variables()
441+
values["OPTION_OVERLAY_ENABLED"] = "true"
442+
values.update(_option_family_defaults(item, "growth"))
443+
values.update(_option_family_defaults(item, "income"))
444+
if values["OPTION_GROWTH_OVERLAY_ENABLED"] != "true" and values["OPTION_INCOME_OVERLAY_ENABLED"] != "true":
445+
raise ValueError(f"{profile} option overlay is enabled without a growth or income family")
446+
defaults[profile] = values
447+
return defaults
448+
449+
450+
def _option_overlay_extra_variables(args: argparse.Namespace, strategy_profile: str) -> dict[str, str]:
451+
mode = _normalize_option_overlay_mode(getattr(args, "option_overlay_mode", "current"))
452+
if mode == "current":
453+
return {}
454+
if mode == "disabled":
455+
return _disabled_option_overlay_extra_variables()
456+
457+
defaults = _load_option_overlay_profile_defaults().get(strategy_profile)
458+
if not defaults:
459+
raise ValueError(
460+
"option_overlay_mode enabled is only supported for strategies with option overlay defaults"
461+
)
462+
return dict(defaults)
463+
464+
349465
def _dca_extra_variables(
350466
args: argparse.Namespace,
351467
strategy_profile: str,
@@ -670,6 +786,7 @@ def _preserve_reserved_cash_fields(
670786
PLATFORM_MIN_RESERVED_CASH_VARIABLES.get(platform),
671787
PLATFORM_RESERVED_CASH_RATIO_VARIABLES.get(platform),
672788
*INCOME_LAYER_VARIABLES,
789+
*OPTION_OVERLAY_VARIABLES,
673790
*RUNTIME_TARGET_VARIABLES,
674791
*DCA_RUNTIME_VARIABLES,
675792
*IBIT_ZSCORE_EXIT_RUNTIME_VARIABLES,
@@ -754,6 +871,7 @@ def build_switch_target(args: argparse.Namespace) -> dict[str, Any]:
754871
extra_variables["INCOME_LAYER_START_USD"] = args.income_layer_start_usd
755872
if args.income_layer_max_ratio:
756873
extra_variables["INCOME_LAYER_MAX_RATIO"] = args.income_layer_max_ratio
874+
extra_variables.update(_option_overlay_extra_variables(args, runtime_target["strategy_profile"]))
757875
extra_variables.update(_dca_extra_variables(args, runtime_target["strategy_profile"], dca_controls))
758876
extra_variables.update(
759877
_ibit_zscore_exit_extra_variables(
@@ -826,6 +944,7 @@ def build_parser() -> argparse.ArgumentParser:
826944
parser.add_argument("--min-reserved-cash-usd", default="")
827945
parser.add_argument("--income-layer-start-usd", default="")
828946
parser.add_argument("--income-layer-max-ratio", default="")
947+
parser.add_argument("--option-overlay-mode", choices=sorted(OPTION_OVERLAY_MODES), default="current")
829948
parser.add_argument("--dca-mode", default="")
830949
parser.add_argument("--dca-base-investment-usd", default="")
831950
parser.add_argument("--ibit-zscore-exit-mode", choices=("disabled", "paper", "live"), default="")

scripts/runtime_settings.py

Lines changed: 95 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@
66
import argparse
77
import json
88
import os
9+
import re
910
import shlex
1011
import subprocess
1112
import sys
@@ -68,7 +69,32 @@
6869
"OPTION_INCOME_OVERLAY_NAV_RISK_RATIO",
6970
}
7071
)
71-
RESEARCH_ONLY_EXTRA_VARIABLES = LEGACY_INCOME_LAYER_VARIABLES | OPTION_OVERLAY_VARIABLES
72+
OPTION_OVERLAY_ENABLED_VARIABLES = frozenset(
73+
{
74+
"OPTION_OVERLAY_ENABLED",
75+
"OPTION_GROWTH_OVERLAY_ENABLED",
76+
"OPTION_INCOME_OVERLAY_ENABLED",
77+
}
78+
)
79+
OPTION_OVERLAY_RECIPE_VARIABLES = frozenset(
80+
{
81+
"OPTION_GROWTH_OVERLAY_RECIPE",
82+
"OPTION_INCOME_OVERLAY_RECIPE",
83+
}
84+
)
85+
OPTION_OVERLAY_AMOUNT_VARIABLES = frozenset(
86+
{
87+
"OPTION_GROWTH_OVERLAY_START_USD",
88+
"OPTION_INCOME_OVERLAY_START_USD",
89+
}
90+
)
91+
OPTION_OVERLAY_RATIO_VARIABLES = frozenset(
92+
{
93+
"OPTION_GROWTH_OVERLAY_NAV_BUDGET_RATIO",
94+
"OPTION_INCOME_OVERLAY_NAV_RISK_RATIO",
95+
}
96+
)
97+
RESEARCH_ONLY_EXTRA_VARIABLES = LEGACY_INCOME_LAYER_VARIABLES
7298
PLATFORM_DRY_RUN_VARIABLES = {
7399
"schwab": "SCHWAB_DRY_RUN_ONLY",
74100
"longbridge": "LONGBRIDGE_DRY_RUN_ONLY",
@@ -429,6 +455,72 @@ def validate_plugin_mounts(target: dict[str, Any], errors: list[str]) -> None:
429455
errors.append(f"{strategy_profile} requires an enabled {plugin} plugin mount")
430456

431457

458+
def option_bool_value(value: Any) -> bool | None:
459+
text = str(value if value is not None else "").strip().lower()
460+
if text in {"1", "true", "yes", "y", "on"}:
461+
return True
462+
if text in {"0", "false", "no", "n", "off"}:
463+
return False
464+
return None
465+
466+
467+
def validate_option_overlay_variables(extra_variables: dict[str, Any], errors: list[str]) -> None:
468+
if not any(name in extra_variables for name in OPTION_OVERLAY_VARIABLES):
469+
return
470+
471+
values = {
472+
name: str(extra_variables.get(name) if extra_variables.get(name) is not None else "").strip()
473+
for name in OPTION_OVERLAY_VARIABLES
474+
}
475+
for name in OPTION_OVERLAY_ENABLED_VARIABLES:
476+
if values[name] and option_bool_value(values[name]) is None:
477+
errors.append(f"extra_variables.{name} must be true or false")
478+
for name in OPTION_OVERLAY_RECIPE_VARIABLES:
479+
if values[name] and not re.fullmatch(r"[A-Za-z0-9._=-]{1,120}", values[name]):
480+
errors.append(f"extra_variables.{name} must be a recipe slug")
481+
for name in OPTION_OVERLAY_AMOUNT_VARIABLES:
482+
if values[name] and not re.fullmatch(r"(?:\d+|\d*\.\d+)", values[name]):
483+
errors.append(f"extra_variables.{name} must be a non-negative decimal")
484+
for name in OPTION_OVERLAY_RATIO_VARIABLES:
485+
if values[name]:
486+
if not re.fullmatch(r"(?:\d+|\d*\.\d+)", values[name]):
487+
errors.append(f"extra_variables.{name} must be a ratio between 0 and 1")
488+
continue
489+
numeric = float(values[name])
490+
if numeric < 0 or numeric > 1:
491+
errors.append(f"extra_variables.{name} must be a ratio between 0 and 1")
492+
493+
overlay_enabled = option_bool_value(values["OPTION_OVERLAY_ENABLED"]) if values["OPTION_OVERLAY_ENABLED"] else None
494+
family_enabled: dict[str, bool | None] = {}
495+
family_fields = {
496+
"GROWTH": (
497+
"OPTION_GROWTH_OVERLAY_ENABLED",
498+
"OPTION_GROWTH_OVERLAY_RECIPE",
499+
"OPTION_GROWTH_OVERLAY_START_USD",
500+
"OPTION_GROWTH_OVERLAY_NAV_BUDGET_RATIO",
501+
),
502+
"INCOME": (
503+
"OPTION_INCOME_OVERLAY_ENABLED",
504+
"OPTION_INCOME_OVERLAY_RECIPE",
505+
"OPTION_INCOME_OVERLAY_START_USD",
506+
"OPTION_INCOME_OVERLAY_NAV_RISK_RATIO",
507+
),
508+
}
509+
for family, (enabled_name, recipe_name, start_name, ratio_name) in family_fields.items():
510+
enabled = option_bool_value(values[enabled_name]) if values[enabled_name] else None
511+
family_enabled[family] = enabled
512+
family_payload = [values[recipe_name], values[start_name], values[ratio_name]]
513+
if enabled is True and not all(family_payload):
514+
errors.append(f"extra_variables.{enabled_name} requires recipe, start_usd, and ratio fields")
515+
if enabled is False and any(family_payload):
516+
errors.append(f"extra_variables.{enabled_name} is false but {family.lower()} overlay fields are still set")
517+
518+
if overlay_enabled is True and not any(value is True for value in family_enabled.values()):
519+
errors.append("extra_variables.OPTION_OVERLAY_ENABLED is true but no option overlay family is enabled")
520+
if overlay_enabled is False and any(value is True for value in family_enabled.values()):
521+
errors.append("extra_variables.OPTION_OVERLAY_ENABLED is false but an option overlay family is enabled")
522+
523+
432524
def validate_extra_variables(target: dict[str, Any], errors: list[str]) -> None:
433525
extra_variables = target.get("extra_variables", {})
434526
if not isinstance(extra_variables, dict):
@@ -452,6 +544,8 @@ def validate_extra_variables(target: dict[str, Any], errors: list[str]) -> None:
452544
if isinstance(value, str) and "\n" in value:
453545
errors.append(f"extra_variables.{name} must be a single-line value")
454546

547+
validate_option_overlay_variables(extra_variables, errors)
548+
455549
runtime_target = target.get("runtime_target") if isinstance(target.get("runtime_target"), dict) else {}
456550
dry_run_only = runtime_target.get("dry_run_only")
457551
platform_id = runtime_target.get("platform_id")

0 commit comments

Comments
 (0)