Skip to content

Commit 970b5da

Browse files
committed
Expose live-gated option overlay profile metadata
1 parent a6a25ba commit 970b5da

12 files changed

Lines changed: 448 additions & 35 deletions

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

Lines changed: 1 addition & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -71,7 +71,7 @@ on:
7171
required: false
7272
type: string
7373
extra_variables_json:
74-
description: "Optional JSON object of non-secret extra variables. DCA profiles may include dca_mode and dca_base_investment_usd control fields."
74+
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."
7575
required: false
7676
type: string
7777
reserved_cash_ratio:
@@ -90,14 +90,6 @@ on:
9090
description: "Optional income layer maximum allocation ratio."
9191
required: false
9292
type: string
93-
income_threshold_usd:
94-
description: "Optional TQQQ income threshold override."
95-
required: false
96-
type: string
97-
qqqi_income_ratio:
98-
description: "Optional TQQQ QQQI income ratio override."
99-
required: false
100-
type: string
10193
service_targets_mode:
10294
description: "auto patches IBKR CLOUD_RUN_SERVICE_TARGETS_JSON when it exists."
10395
required: true
@@ -157,8 +149,6 @@ jobs:
157149
MIN_RESERVED_CASH_USD: ${{ inputs.min_reserved_cash_usd }}
158150
INCOME_LAYER_START_USD: ${{ inputs.income_layer_start_usd }}
159151
INCOME_LAYER_MAX_RATIO: ${{ inputs.income_layer_max_ratio }}
160-
INCOME_THRESHOLD_USD: ${{ inputs.income_threshold_usd }}
161-
QQQI_INCOME_RATIO: ${{ inputs.qqqi_income_ratio }}
162152
SERVICE_TARGETS_MODE: ${{ inputs.service_targets_mode }}
163153
APPLY_SWITCH: ${{ inputs.apply }}
164154
TRIGGER_PLATFORM_SYNC: ${{ inputs.trigger_platform_sync }}
@@ -297,12 +287,6 @@ jobs:
297287
if [ -n "${INCOME_LAYER_MAX_RATIO:-}" ]; then
298288
args+=(--income-layer-max-ratio "${INCOME_LAYER_MAX_RATIO}")
299289
fi
300-
if [ -n "${INCOME_THRESHOLD_USD:-}" ]; then
301-
args+=(--income-threshold-usd "${INCOME_THRESHOLD_USD}")
302-
fi
303-
if [ -n "${QQQI_INCOME_RATIO:-}" ]; then
304-
args+=(--qqqi-income-ratio "${QQQI_INCOME_RATIO}")
305-
fi
306290
if [ -s "${EXISTING_SERVICE_TARGETS_JSON_FILE:-}" ]; then
307291
args+=(--existing-service-targets-json-file "${EXISTING_SERVICE_TARGETS_JSON_FILE}")
308292
fi

scripts/build_runtime_switch.py

Lines changed: 44 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -94,6 +94,30 @@
9494
"INCOME_LAYER_START_USD",
9595
"INCOME_LAYER_MAX_RATIO",
9696
)
97+
LEGACY_INCOME_LAYER_VARIABLES = (
98+
"INCOME_THRESHOLD_USD",
99+
"QQQI_INCOME_RATIO",
100+
"INCOME_LAYER_QQQI_WEIGHT",
101+
"INCOME_LAYER_SPYI_WEIGHT",
102+
)
103+
LEGACY_INCOME_LAYER_CONTROL_FIELDS = (
104+
"income_threshold_usd",
105+
"qqqi_income_ratio",
106+
"income_layer_qqqi_weight",
107+
"income_layer_spyi_weight",
108+
)
109+
OPTION_OVERLAY_CONTROL_FIELDS = (
110+
"option_overlay_enabled",
111+
"option_growth_overlay_enabled",
112+
"option_growth_overlay_recipe",
113+
"option_growth_overlay_start_usd",
114+
"option_growth_overlay_nav_budget_ratio",
115+
"option_income_overlay_enabled",
116+
"option_income_overlay_recipe",
117+
"option_income_overlay_start_usd",
118+
"option_income_overlay_nav_risk_ratio",
119+
)
120+
OPTION_OVERLAY_VARIABLES = tuple(field.upper() for field in OPTION_OVERLAY_CONTROL_FIELDS)
97121
RUNTIME_TARGET_VARIABLES = (
98122
"RUNTIME_TARGET_ENABLED",
99123
)
@@ -380,6 +404,25 @@ def _reject_direct_ibit_zscore_exit_extra_variables(extra_variables: dict[str, A
380404
)
381405

382406

407+
def _reject_research_only_extra_variables(extra_variables: dict[str, Any]) -> None:
408+
blocked = [
409+
name
410+
for name in (
411+
*OPTION_OVERLAY_CONTROL_FIELDS,
412+
*OPTION_OVERLAY_VARIABLES,
413+
*LEGACY_INCOME_LAYER_CONTROL_FIELDS,
414+
*LEGACY_INCOME_LAYER_VARIABLES,
415+
)
416+
if name in extra_variables
417+
]
418+
if blocked:
419+
names = ", ".join(blocked)
420+
raise ValueError(
421+
"direct option overlay settings and legacy income controls are research-only "
422+
f"and are not supported by live strategy switch settings: {names}"
423+
)
424+
425+
383426
def _ibit_zscore_exit_extra_variables(
384427
args: argparse.Namespace,
385428
strategy_profile: str,
@@ -699,6 +742,7 @@ def build_switch_target(args: argparse.Namespace) -> dict[str, Any]:
699742
ibit_zscore_exit_controls = _extract_ibit_zscore_exit_control_fields(extra_variables)
700743
_reject_direct_dca_extra_variables(extra_variables)
701744
_reject_direct_ibit_zscore_exit_extra_variables(extra_variables)
745+
_reject_research_only_extra_variables(extra_variables)
702746

703747
if args.set_platform_dry_run_variable:
704748
extra_variables[PLATFORM_DRY_RUN_VARIABLES[platform]] = env_string(runtime_target["dry_run_only"])
@@ -710,10 +754,6 @@ def build_switch_target(args: argparse.Namespace) -> dict[str, Any]:
710754
extra_variables["INCOME_LAYER_START_USD"] = args.income_layer_start_usd
711755
if args.income_layer_max_ratio:
712756
extra_variables["INCOME_LAYER_MAX_RATIO"] = args.income_layer_max_ratio
713-
if args.income_threshold_usd:
714-
extra_variables["INCOME_THRESHOLD_USD"] = args.income_threshold_usd
715-
if args.qqqi_income_ratio:
716-
extra_variables["QQQI_INCOME_RATIO"] = args.qqqi_income_ratio
717757
extra_variables.update(_dca_extra_variables(args, runtime_target["strategy_profile"], dca_controls))
718758
extra_variables.update(
719759
_ibit_zscore_exit_extra_variables(
@@ -786,8 +826,6 @@ def build_parser() -> argparse.ArgumentParser:
786826
parser.add_argument("--min-reserved-cash-usd", default="")
787827
parser.add_argument("--income-layer-start-usd", default="")
788828
parser.add_argument("--income-layer-max-ratio", default="")
789-
parser.add_argument("--income-threshold-usd", default="")
790-
parser.add_argument("--qqqi-income-ratio", default="")
791829
parser.add_argument("--dca-mode", default="")
792830
parser.add_argument("--dca-base-investment-usd", default="")
793831
parser.add_argument("--ibit-zscore-exit-mode", choices=("disabled", "paper", "live"), default="")

scripts/runtime_settings.py

Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -47,6 +47,28 @@
4747
SCHEDULER_FIELDS = frozenset({"timezone", "main_time", "probe_time", "precheck_time"})
4848
GENERATED_VARIABLES = {"RUNTIME_TARGET_JSON", "STRATEGY_PROFILE"}
4949
SECRET_MARKERS = ("PASSWORD", "PRIVATE_KEY", "TOKEN", "API_KEY", "ACCESS_KEY", "CLIENT_SECRET", "SECRET")
50+
LEGACY_INCOME_LAYER_VARIABLES = frozenset(
51+
{
52+
"INCOME_THRESHOLD_USD",
53+
"QQQI_INCOME_RATIO",
54+
"INCOME_LAYER_QQQI_WEIGHT",
55+
"INCOME_LAYER_SPYI_WEIGHT",
56+
}
57+
)
58+
OPTION_OVERLAY_VARIABLES = frozenset(
59+
{
60+
"OPTION_OVERLAY_ENABLED",
61+
"OPTION_GROWTH_OVERLAY_ENABLED",
62+
"OPTION_GROWTH_OVERLAY_RECIPE",
63+
"OPTION_GROWTH_OVERLAY_START_USD",
64+
"OPTION_GROWTH_OVERLAY_NAV_BUDGET_RATIO",
65+
"OPTION_INCOME_OVERLAY_ENABLED",
66+
"OPTION_INCOME_OVERLAY_RECIPE",
67+
"OPTION_INCOME_OVERLAY_START_USD",
68+
"OPTION_INCOME_OVERLAY_NAV_RISK_RATIO",
69+
}
70+
)
71+
RESEARCH_ONLY_EXTRA_VARIABLES = LEGACY_INCOME_LAYER_VARIABLES | OPTION_OVERLAY_VARIABLES
5072
PLATFORM_DRY_RUN_VARIABLES = {
5173
"schwab": "SCHWAB_DRY_RUN_ONLY",
5274
"longbridge": "LONGBRIDGE_DRY_RUN_ONLY",
@@ -421,6 +443,10 @@ def validate_extra_variables(target: dict[str, Any], errors: list[str]) -> None:
421443
for name, value in extra_variables.items():
422444
if name in generated_names:
423445
errors.append(f"extra_variables.{name} duplicates a generated variable")
446+
if name in RESEARCH_ONLY_EXTRA_VARIABLES:
447+
errors.append(
448+
f"extra_variables.{name} is research-only and must not be stored in live switch settings"
449+
)
424450
if is_secret_variable_name(name):
425451
errors.append(f"extra_variables.{name} looks like a secret and must not be stored here")
426452
if isinstance(value, str) and "\n" in value:

tests/strategy_switch_worker_validation.mjs

Lines changed: 52 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -32,6 +32,10 @@ assert.ok(indexHtml.includes('id="income-layer-start-usd-input"'));
3232
assert.ok(indexHtml.includes('incomeLayerStartUsd: "收入层起始金额"'));
3333
assert.ok(indexHtml.includes('incomeLayerStartUsd: "Income layer start amount"'));
3434
assert.ok(indexHtml.includes('incomeLayerStartUsdVariable = "INCOME_LAYER_START_USD"'));
35+
assert.ok(indexHtml.includes("fallbackIncomeLayerDefaults"));
36+
assert.ok(indexHtml.includes("incomeLayerDefaultsFromProfileItem"));
37+
assert.equal(indexHtml.includes('id="option-overlay'), false);
38+
assert.equal(indexHtml.includes("option_growth_overlay"), false);
3539
assert.ok(indexHtml.includes('id="dca-mode-select"'));
3640
assert.ok(indexHtml.includes('id="dca-base-investment-usd-input"'));
3741
assert.ok(indexHtml.includes('dcaMode: "定投模式"'));
@@ -245,6 +249,18 @@ const strategyProfiles = __test.normalizeStrategyProfilesPayload(
245249
label_zh: "TQQQ 增长收益",
246250
domain: "us_equity",
247251
runtime_enabled: true,
252+
income_layer_enabled: true,
253+
income_layer_start_usd: "250000",
254+
income_layer_max_ratio: "0.55",
255+
income_layer_allocations: { SCHD: 0.3, DGRO: 0.2, SGOV: 0.4, SPYI: 0.08, QQQI: 0.02 },
256+
option_overlay_enabled: true,
257+
option_overlay_live_gate: "promotion_required",
258+
option_overlay_live_status: "research_only",
259+
option_growth_overlay_enabled: true,
260+
option_growth_overlay_recipe: "tqqq_leaps_growth_v1",
261+
option_growth_overlay_start_usd: "250000",
262+
option_growth_overlay_nav_budget_ratio: "0.03",
263+
option_income_overlay_enabled: false,
248264
},
249265
{
250266
profile: "hk_low_vol_dividend_quality_snapshot",
@@ -264,6 +280,24 @@ const strategyProfiles = __test.normalizeStrategyProfilesPayload(
264280
);
265281
assert.equal(strategyProfiles[0].label_en, "TQQQ Growth Income");
266282
assert.equal(strategyProfiles[0].label_zh, "TQQQ 增长收益");
283+
assert.equal(strategyProfiles[0].income_layer_enabled, true);
284+
assert.equal(strategyProfiles[0].income_layer_start_usd, "250000");
285+
assert.equal(strategyProfiles[0].income_layer_max_ratio, "0.55");
286+
assert.deepEqual(strategyProfiles[0].income_layer_allocations, {
287+
SCHD: 0.3,
288+
DGRO: 0.2,
289+
SGOV: 0.4,
290+
SPYI: 0.08,
291+
QQQI: 0.02,
292+
});
293+
assert.equal(strategyProfiles[0].option_overlay_enabled, true);
294+
assert.equal(strategyProfiles[0].option_overlay_live_gate, "promotion_required");
295+
assert.equal(strategyProfiles[0].option_overlay_live_status, "research_only");
296+
assert.equal(strategyProfiles[0].option_growth_overlay_enabled, true);
297+
assert.equal(strategyProfiles[0].option_growth_overlay_recipe, "tqqq_leaps_growth_v1");
298+
assert.equal(strategyProfiles[0].option_growth_overlay_start_usd, "250000");
299+
assert.equal(strategyProfiles[0].option_growth_overlay_nav_budget_ratio, "0.03");
300+
assert.equal(strategyProfiles[0].option_income_overlay_enabled, false);
267301
assert.equal(strategyProfiles[2].dca_enabled, true);
268302
assert.equal(strategyProfiles[2].dca_default_mode, "fixed");
269303
assert.equal(strategyProfiles[2].dca_default_base_investment_usd, "1000");
@@ -506,6 +540,24 @@ assert.throws(
506540
}),
507541
/control fields/,
508542
);
543+
assert.throws(
544+
() => __test.normalizeSwitchInputs({
545+
platform: "ibkr",
546+
target_name: "ibkr-primary",
547+
strategy_profile: "tqqq_growth_income",
548+
extra_variables_json: JSON.stringify({ option_growth_overlay_enabled: "true" }),
549+
}),
550+
/research-only/,
551+
);
552+
assert.throws(
553+
() => __test.normalizeSwitchInputs({
554+
platform: "ibkr",
555+
target_name: "ibkr-primary",
556+
strategy_profile: "tqqq_growth_income",
557+
extra_variables_json: JSON.stringify({ INCOME_THRESHOLD_USD: "250000" }),
558+
}),
559+
/research-only/,
560+
);
509561
const normalizedReserveClearInputs = __test.normalizeSwitchInputs({
510562
platform: "ibkr",
511563
target_name: "ibkr-primary",

tests/test_runtime_settings.py

Lines changed: 56 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -44,6 +44,8 @@ def test_manual_strategy_switch_workflow_stays_within_dispatch_input_limit(self)
4444
self.assertLessEqual(len(input_names), 25)
4545
self.assertNotIn("dca_mode", input_names)
4646
self.assertNotIn("dca_base_investment_usd", input_names)
47+
self.assertNotIn("income_threshold_usd", input_names)
48+
self.assertNotIn("qqqi_income_ratio", input_names)
4749

4850
def load_target(self, relative_path: str):
4951
path = ROOT / relative_path
@@ -203,6 +205,24 @@ def test_generated_variables_cannot_be_overridden(self):
203205
runtime_settings.validate_target(target),
204206
)
205207

208+
def test_research_only_option_overlay_variables_are_rejected(self):
209+
_, target = self.load_target("examples/targets/schwab/live.example.json")
210+
target["extra_variables"] = {"OPTION_GROWTH_OVERLAY_ENABLED": "true"}
211+
212+
self.assertIn(
213+
"extra_variables.OPTION_GROWTH_OVERLAY_ENABLED is research-only and must not be stored in live switch settings",
214+
runtime_settings.validate_target(target),
215+
)
216+
217+
def test_legacy_income_layer_variables_are_rejected(self):
218+
_, target = self.load_target("examples/targets/schwab/live.example.json")
219+
target["extra_variables"] = {"INCOME_THRESHOLD_USD": "250000"}
220+
221+
self.assertIn(
222+
"extra_variables.INCOME_THRESHOLD_USD is research-only and must not be stored in live switch settings",
223+
runtime_settings.validate_target(target),
224+
)
225+
206226
def test_extra_variables_reject_secret_values_but_allow_secret_pointers(self):
207227
_, target = self.load_target("examples/targets/schwab/live.example.json")
208228
target["extra_variables"] = {
@@ -596,6 +616,42 @@ def test_build_switch_target_rejects_direct_dca_extra_variables(self):
596616
with self.assertRaisesRegex(ValueError, "control fields"):
597617
build_runtime_switch.build_switch_target(args)
598618

619+
def test_build_switch_target_rejects_research_only_option_overlay_extra_variables(self):
620+
parser = build_runtime_switch.build_parser()
621+
args = parser.parse_args(
622+
[
623+
"--platform",
624+
"ibkr",
625+
"--target-name",
626+
"live",
627+
"--strategy-profile",
628+
"tqqq_growth_income",
629+
"--extra-variables-json",
630+
'{"option_growth_overlay_enabled":"true"}',
631+
]
632+
)
633+
634+
with self.assertRaisesRegex(ValueError, "research-only"):
635+
build_runtime_switch.build_switch_target(args)
636+
637+
def test_build_switch_target_rejects_legacy_income_extra_variables(self):
638+
parser = build_runtime_switch.build_parser()
639+
args = parser.parse_args(
640+
[
641+
"--platform",
642+
"ibkr",
643+
"--target-name",
644+
"live",
645+
"--strategy-profile",
646+
"tqqq_growth_income",
647+
"--extra-variables-json",
648+
'{"INCOME_THRESHOLD_USD":"250000"}',
649+
]
650+
)
651+
652+
with self.assertRaisesRegex(ValueError, "legacy income"):
653+
build_runtime_switch.build_switch_target(args)
654+
599655
def test_build_switch_target_preserves_dca_fields_in_service_targets_when_omitted(self):
600656
existing = {
601657
"targets": [

web/strategy-switch-console/README.md

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -120,6 +120,8 @@ For signed-in users, `/api/config` also reads the target repositories' current G
120120

121121
The switch form also accepts optional reserved-cash overrides: minimum reserved cash in the selected account currency and reserved-cash ratio. Set `cash_currency` to `USD` or `HKD` in account config when the account has a fixed cash currency; otherwise the page infers HKD for HK-equity strategy selections and USD for US-equity selections. Keeping the current policy leaves existing platform variables unchanged; when no explicit platform variables are configured, the platform source default is no extra reserve (`0` in the account currency and `0%`). When set, the Worker passes the values to `manual-strategy-switch.yml`, which writes the platform-specific variables such as `IBKR_MIN_RESERVED_CASH_USD` and `IBKR_RESERVED_CASH_RATIO`.
122122

123+
Income-layer controls are sourced from `strategy-profiles.example.json` metadata for live-validated US equity strategies. The switch form can keep the current setting, enable the income layer with the profile default start amount and cap, or disable it. The strategy profile catalog can also carry default option-overlay metadata such as `option_overlay_enabled`, the default recipe, start amount, and budget; those values describe the strategy repo defaults and promotion-gate status. Manual switch requests still cannot override direct option overlay or LEAPS fields through `extra_variables_json`; the Worker, build script, and target validation reject those direct overrides until separate evidence promotes a recipe.
124+
123125
Successful strategy switches also sync the selected account's `default_strategy_profile` back to the KV `account_options` key. The web endpoint does this immediately after dispatching the workflow, and the manual GitHub workflow calls the Worker's internal sync endpoint after applying platform variables when the `runtime-strategy-switch` environment variable `STRATEGY_SWITCH_CONSOLE_URL` is set. For that workflow callback, set the GitHub environment secret `STRATEGY_SWITCH_SYNC_TOKEN` to the same value as the Worker secret with that name.
124126

125127
## Strategy Profile Alignment

web/strategy-switch-console/README.zh-CN.md

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -127,6 +127,8 @@ Worker 会校验 dispatch 参数必须匹配这里的某个账号项,也会校
127127

128128
切换表单也支持可选的预留现金覆盖项:所选账号币种下的最小预留现金和预留现金比例。如果账号现金币种固定,可以在账号配置里把 `cash_currency` 设为 `USD``HKD`;否则页面会按所选策略推断,港股策略显示 HKD,美股策略显示 USD。沿用当前策略会保留平台现有变量;如果平台没有显式配置预留现金变量,源码默认是不额外预留(账号币种 `0`、比例 `0%`)。填写后,Worker 会把它们传给 `manual-strategy-switch.yml`,由 workflow 写入平台对应变量,例如 `IBKR_MIN_RESERVED_CASH_USD``IBKR_RESERVED_CASH_RATIO`
129129

130+
收入层控件来自 `strategy-profiles.example.json` 里的 live 验证策略元数据。切换页可以沿用当前配置、按 profile 默认起始金额和最高比例开启收入层,或关闭收入层。策略 profile 目录也可以携带默认期权层元数据,例如 `option_overlay_enabled`、默认 recipe、起始金额和预算;这些值只表达策略仓库的默认配置和 promotion gate 状态。手工切换请求仍不能通过 `extra_variables_json` 覆盖直接期权 overlay / LEAPS 字段,在有单独证据晋级前,Worker、构建脚本和 target 校验都会拒绝这些直接覆盖项。
131+
130132
策略切换成功后也会把当前账号的 `default_strategy_profile` 同步回 KV 的 `account_options` key。网页接口会在触发 workflow 成功后立即同步;如果 `runtime-strategy-switch` 环境变量里配置了 `STRATEGY_SWITCH_CONSOLE_URL`,手动 GitHub workflow 在写入平台变量后也会回调 Worker 内部接口同步。这个 workflow 回调需要 GitHub 环境 secret `STRATEGY_SWITCH_SYNC_TOKEN`,值要和 Worker 里同名 secret 保持一致。
131133

132134
## 策略 Profile 对齐规范

0 commit comments

Comments
 (0)