Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
18 changes: 1 addition & 17 deletions .github/workflows/manual-strategy-switch.yml
Original file line number Diff line number Diff line change
Expand Up @@ -71,7 +71,7 @@ on:
required: false
type: string
extra_variables_json:
description: "Optional JSON object of non-secret extra variables. DCA profiles may include dca_mode and dca_base_investment_usd control fields."
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."
required: false
type: string
reserved_cash_ratio:
Expand All @@ -90,14 +90,6 @@ on:
description: "Optional income layer maximum allocation ratio."
required: false
type: string
income_threshold_usd:
description: "Optional TQQQ income threshold override."
required: false
type: string
qqqi_income_ratio:
description: "Optional TQQQ QQQI income ratio override."
required: false
type: string
service_targets_mode:
description: "auto patches IBKR CLOUD_RUN_SERVICE_TARGETS_JSON when it exists."
required: true
Expand Down Expand Up @@ -157,8 +149,6 @@ jobs:
MIN_RESERVED_CASH_USD: ${{ inputs.min_reserved_cash_usd }}
INCOME_LAYER_START_USD: ${{ inputs.income_layer_start_usd }}
INCOME_LAYER_MAX_RATIO: ${{ inputs.income_layer_max_ratio }}
INCOME_THRESHOLD_USD: ${{ inputs.income_threshold_usd }}
QQQI_INCOME_RATIO: ${{ inputs.qqqi_income_ratio }}
SERVICE_TARGETS_MODE: ${{ inputs.service_targets_mode }}
APPLY_SWITCH: ${{ inputs.apply }}
TRIGGER_PLATFORM_SYNC: ${{ inputs.trigger_platform_sync }}
Expand Down Expand Up @@ -297,12 +287,6 @@ jobs:
if [ -n "${INCOME_LAYER_MAX_RATIO:-}" ]; then
args+=(--income-layer-max-ratio "${INCOME_LAYER_MAX_RATIO}")
fi
if [ -n "${INCOME_THRESHOLD_USD:-}" ]; then
args+=(--income-threshold-usd "${INCOME_THRESHOLD_USD}")
fi
if [ -n "${QQQI_INCOME_RATIO:-}" ]; then
args+=(--qqqi-income-ratio "${QQQI_INCOME_RATIO}")
fi
if [ -s "${EXISTING_SERVICE_TARGETS_JSON_FILE:-}" ]; then
args+=(--existing-service-targets-json-file "${EXISTING_SERVICE_TARGETS_JSON_FILE}")
fi
Expand Down
50 changes: 44 additions & 6 deletions scripts/build_runtime_switch.py
Original file line number Diff line number Diff line change
Expand Up @@ -94,6 +94,30 @@
"INCOME_LAYER_START_USD",
"INCOME_LAYER_MAX_RATIO",
)
LEGACY_INCOME_LAYER_VARIABLES = (
"INCOME_THRESHOLD_USD",
"QQQI_INCOME_RATIO",
"INCOME_LAYER_QQQI_WEIGHT",
"INCOME_LAYER_SPYI_WEIGHT",
)
LEGACY_INCOME_LAYER_CONTROL_FIELDS = (
"income_threshold_usd",
"qqqi_income_ratio",
"income_layer_qqqi_weight",
"income_layer_spyi_weight",
)
OPTION_OVERLAY_CONTROL_FIELDS = (
"option_overlay_enabled",
"option_growth_overlay_enabled",
"option_growth_overlay_recipe",
"option_growth_overlay_start_usd",
"option_growth_overlay_nav_budget_ratio",
"option_income_overlay_enabled",
"option_income_overlay_recipe",
"option_income_overlay_start_usd",
"option_income_overlay_nav_risk_ratio",
)
OPTION_OVERLAY_VARIABLES = tuple(field.upper() for field in OPTION_OVERLAY_CONTROL_FIELDS)
RUNTIME_TARGET_VARIABLES = (
"RUNTIME_TARGET_ENABLED",
)
Expand Down Expand Up @@ -380,6 +404,25 @@ def _reject_direct_ibit_zscore_exit_extra_variables(extra_variables: dict[str, A
)


def _reject_research_only_extra_variables(extra_variables: dict[str, Any]) -> None:
blocked = [
name
for name in (
*OPTION_OVERLAY_CONTROL_FIELDS,
*OPTION_OVERLAY_VARIABLES,
*LEGACY_INCOME_LAYER_CONTROL_FIELDS,
*LEGACY_INCOME_LAYER_VARIABLES,
)
if name in extra_variables
]
if blocked:
names = ", ".join(blocked)
raise ValueError(
"direct option overlay settings and legacy income controls are research-only "
f"and are not supported by live strategy switch settings: {names}"
)


def _ibit_zscore_exit_extra_variables(
args: argparse.Namespace,
strategy_profile: str,
Expand Down Expand Up @@ -699,6 +742,7 @@ def build_switch_target(args: argparse.Namespace) -> dict[str, Any]:
ibit_zscore_exit_controls = _extract_ibit_zscore_exit_control_fields(extra_variables)
_reject_direct_dca_extra_variables(extra_variables)
_reject_direct_ibit_zscore_exit_extra_variables(extra_variables)
_reject_research_only_extra_variables(extra_variables)

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Reject nested research-only variables in service-target patches

This rejection only inspects the top-level extra_variables parsed from the new request. In the IBKR auto path, an existing CLOUD_RUN_SERVICE_TARGETS_JSON is loaded below and _patch_service_targets() writes {**entry, **replacement}, so any existing INCOME_THRESHOLD_USD/OPTION_* fields inside the matched service-target entry are carried into the new target without validation; I verified a target with existing INCOME_THRESHOLD_USD still builds and renders it under CLOUD_RUN_SERVICE_TARGETS_JSON. That leaves the research-only settings stored live despite this new gate, so the nested service-target entry needs to be filtered or validated too.

Useful? React with 👍 / 👎.


if args.set_platform_dry_run_variable:
extra_variables[PLATFORM_DRY_RUN_VARIABLES[platform]] = env_string(runtime_target["dry_run_only"])
Expand All @@ -710,10 +754,6 @@ def build_switch_target(args: argparse.Namespace) -> dict[str, Any]:
extra_variables["INCOME_LAYER_START_USD"] = args.income_layer_start_usd
if args.income_layer_max_ratio:
extra_variables["INCOME_LAYER_MAX_RATIO"] = args.income_layer_max_ratio
if args.income_threshold_usd:
extra_variables["INCOME_THRESHOLD_USD"] = args.income_threshold_usd
if args.qqqi_income_ratio:
extra_variables["QQQI_INCOME_RATIO"] = args.qqqi_income_ratio
extra_variables.update(_dca_extra_variables(args, runtime_target["strategy_profile"], dca_controls))
extra_variables.update(
_ibit_zscore_exit_extra_variables(
Expand Down Expand Up @@ -786,8 +826,6 @@ def build_parser() -> argparse.ArgumentParser:
parser.add_argument("--min-reserved-cash-usd", default="")
parser.add_argument("--income-layer-start-usd", default="")
parser.add_argument("--income-layer-max-ratio", default="")
parser.add_argument("--income-threshold-usd", default="")
parser.add_argument("--qqqi-income-ratio", default="")
parser.add_argument("--dca-mode", default="")
parser.add_argument("--dca-base-investment-usd", default="")
parser.add_argument("--ibit-zscore-exit-mode", choices=("disabled", "paper", "live"), default="")
Expand Down
26 changes: 26 additions & 0 deletions scripts/runtime_settings.py
Original file line number Diff line number Diff line change
Expand Up @@ -47,6 +47,28 @@
SCHEDULER_FIELDS = frozenset({"timezone", "main_time", "probe_time", "precheck_time"})
GENERATED_VARIABLES = {"RUNTIME_TARGET_JSON", "STRATEGY_PROFILE"}
SECRET_MARKERS = ("PASSWORD", "PRIVATE_KEY", "TOKEN", "API_KEY", "ACCESS_KEY", "CLIENT_SECRET", "SECRET")
LEGACY_INCOME_LAYER_VARIABLES = frozenset(
{
"INCOME_THRESHOLD_USD",
"QQQI_INCOME_RATIO",
"INCOME_LAYER_QQQI_WEIGHT",
"INCOME_LAYER_SPYI_WEIGHT",
}
)
OPTION_OVERLAY_VARIABLES = frozenset(
{
"OPTION_OVERLAY_ENABLED",
"OPTION_GROWTH_OVERLAY_ENABLED",
"OPTION_GROWTH_OVERLAY_RECIPE",
"OPTION_GROWTH_OVERLAY_START_USD",
"OPTION_GROWTH_OVERLAY_NAV_BUDGET_RATIO",
"OPTION_INCOME_OVERLAY_ENABLED",
"OPTION_INCOME_OVERLAY_RECIPE",
"OPTION_INCOME_OVERLAY_START_USD",
"OPTION_INCOME_OVERLAY_NAV_RISK_RATIO",
}
)
RESEARCH_ONLY_EXTRA_VARIABLES = LEGACY_INCOME_LAYER_VARIABLES | OPTION_OVERLAY_VARIABLES
PLATFORM_DRY_RUN_VARIABLES = {
"schwab": "SCHWAB_DRY_RUN_ONLY",
"longbridge": "LONGBRIDGE_DRY_RUN_ONLY",
Expand Down Expand Up @@ -421,6 +443,10 @@ def validate_extra_variables(target: dict[str, Any], errors: list[str]) -> None:
for name, value in extra_variables.items():
if name in generated_names:
errors.append(f"extra_variables.{name} duplicates a generated variable")
if name in RESEARCH_ONLY_EXTRA_VARIABLES:
errors.append(
f"extra_variables.{name} is research-only and must not be stored in live switch settings"
)
if is_secret_variable_name(name):
errors.append(f"extra_variables.{name} looks like a secret and must not be stored here")
if isinstance(value, str) and "\n" in value:
Expand Down
52 changes: 52 additions & 0 deletions tests/strategy_switch_worker_validation.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,10 @@ assert.ok(indexHtml.includes('id="income-layer-start-usd-input"'));
assert.ok(indexHtml.includes('incomeLayerStartUsd: "收入层起始金额"'));
assert.ok(indexHtml.includes('incomeLayerStartUsd: "Income layer start amount"'));
assert.ok(indexHtml.includes('incomeLayerStartUsdVariable = "INCOME_LAYER_START_USD"'));
assert.ok(indexHtml.includes("fallbackIncomeLayerDefaults"));
assert.ok(indexHtml.includes("incomeLayerDefaultsFromProfileItem"));
assert.equal(indexHtml.includes('id="option-overlay'), false);
assert.equal(indexHtml.includes("option_growth_overlay"), false);
assert.ok(indexHtml.includes('id="dca-mode-select"'));
assert.ok(indexHtml.includes('id="dca-base-investment-usd-input"'));
assert.ok(indexHtml.includes('dcaMode: "定投模式"'));
Expand Down Expand Up @@ -245,6 +249,18 @@ const strategyProfiles = __test.normalizeStrategyProfilesPayload(
label_zh: "TQQQ 增长收益",
domain: "us_equity",
runtime_enabled: true,
income_layer_enabled: true,
income_layer_start_usd: "250000",
income_layer_max_ratio: "0.55",
income_layer_allocations: { SCHD: 0.3, DGRO: 0.2, SGOV: 0.4, SPYI: 0.08, QQQI: 0.02 },
option_overlay_enabled: true,
option_overlay_live_gate: "promotion_required",
option_overlay_live_status: "research_only",
option_growth_overlay_enabled: true,
option_growth_overlay_recipe: "tqqq_leaps_growth_v1",
option_growth_overlay_start_usd: "250000",
option_growth_overlay_nav_budget_ratio: "0.03",
option_income_overlay_enabled: false,
},
{
profile: "hk_low_vol_dividend_quality_snapshot",
Expand All @@ -264,6 +280,24 @@ const strategyProfiles = __test.normalizeStrategyProfilesPayload(
);
assert.equal(strategyProfiles[0].label_en, "TQQQ Growth Income");
assert.equal(strategyProfiles[0].label_zh, "TQQQ 增长收益");
assert.equal(strategyProfiles[0].income_layer_enabled, true);
assert.equal(strategyProfiles[0].income_layer_start_usd, "250000");
assert.equal(strategyProfiles[0].income_layer_max_ratio, "0.55");
assert.deepEqual(strategyProfiles[0].income_layer_allocations, {
SCHD: 0.3,
DGRO: 0.2,
SGOV: 0.4,
SPYI: 0.08,
QQQI: 0.02,
});
assert.equal(strategyProfiles[0].option_overlay_enabled, true);
assert.equal(strategyProfiles[0].option_overlay_live_gate, "promotion_required");
assert.equal(strategyProfiles[0].option_overlay_live_status, "research_only");
assert.equal(strategyProfiles[0].option_growth_overlay_enabled, true);
assert.equal(strategyProfiles[0].option_growth_overlay_recipe, "tqqq_leaps_growth_v1");
assert.equal(strategyProfiles[0].option_growth_overlay_start_usd, "250000");
assert.equal(strategyProfiles[0].option_growth_overlay_nav_budget_ratio, "0.03");
assert.equal(strategyProfiles[0].option_income_overlay_enabled, false);
assert.equal(strategyProfiles[2].dca_enabled, true);
assert.equal(strategyProfiles[2].dca_default_mode, "fixed");
assert.equal(strategyProfiles[2].dca_default_base_investment_usd, "1000");
Expand Down Expand Up @@ -506,6 +540,24 @@ assert.throws(
}),
/control fields/,
);
assert.throws(
() => __test.normalizeSwitchInputs({
platform: "ibkr",
target_name: "ibkr-primary",
strategy_profile: "tqqq_growth_income",
extra_variables_json: JSON.stringify({ option_growth_overlay_enabled: "true" }),
}),
/research-only/,
);
assert.throws(
() => __test.normalizeSwitchInputs({
platform: "ibkr",
target_name: "ibkr-primary",
strategy_profile: "tqqq_growth_income",
extra_variables_json: JSON.stringify({ INCOME_THRESHOLD_USD: "250000" }),
}),
/research-only/,
);
const normalizedReserveClearInputs = __test.normalizeSwitchInputs({
platform: "ibkr",
target_name: "ibkr-primary",
Expand Down
56 changes: 56 additions & 0 deletions tests/test_runtime_settings.py
Original file line number Diff line number Diff line change
Expand Up @@ -44,6 +44,8 @@ def test_manual_strategy_switch_workflow_stays_within_dispatch_input_limit(self)
self.assertLessEqual(len(input_names), 25)
self.assertNotIn("dca_mode", input_names)
self.assertNotIn("dca_base_investment_usd", input_names)
self.assertNotIn("income_threshold_usd", input_names)
self.assertNotIn("qqqi_income_ratio", input_names)

def load_target(self, relative_path: str):
path = ROOT / relative_path
Expand Down Expand Up @@ -203,6 +205,24 @@ def test_generated_variables_cannot_be_overridden(self):
runtime_settings.validate_target(target),
)

def test_research_only_option_overlay_variables_are_rejected(self):
_, target = self.load_target("examples/targets/schwab/live.example.json")
target["extra_variables"] = {"OPTION_GROWTH_OVERLAY_ENABLED": "true"}

self.assertIn(
"extra_variables.OPTION_GROWTH_OVERLAY_ENABLED is research-only and must not be stored in live switch settings",
runtime_settings.validate_target(target),
)

def test_legacy_income_layer_variables_are_rejected(self):
_, target = self.load_target("examples/targets/schwab/live.example.json")
target["extra_variables"] = {"INCOME_THRESHOLD_USD": "250000"}

self.assertIn(
"extra_variables.INCOME_THRESHOLD_USD is research-only and must not be stored in live switch settings",
runtime_settings.validate_target(target),
)

def test_extra_variables_reject_secret_values_but_allow_secret_pointers(self):
_, target = self.load_target("examples/targets/schwab/live.example.json")
target["extra_variables"] = {
Expand Down Expand Up @@ -596,6 +616,42 @@ def test_build_switch_target_rejects_direct_dca_extra_variables(self):
with self.assertRaisesRegex(ValueError, "control fields"):
build_runtime_switch.build_switch_target(args)

def test_build_switch_target_rejects_research_only_option_overlay_extra_variables(self):
parser = build_runtime_switch.build_parser()
args = parser.parse_args(
[
"--platform",
"ibkr",
"--target-name",
"live",
"--strategy-profile",
"tqqq_growth_income",
"--extra-variables-json",
'{"option_growth_overlay_enabled":"true"}',
]
)

with self.assertRaisesRegex(ValueError, "research-only"):
build_runtime_switch.build_switch_target(args)

def test_build_switch_target_rejects_legacy_income_extra_variables(self):
parser = build_runtime_switch.build_parser()
args = parser.parse_args(
[
"--platform",
"ibkr",
"--target-name",
"live",
"--strategy-profile",
"tqqq_growth_income",
"--extra-variables-json",
'{"INCOME_THRESHOLD_USD":"250000"}',
]
)

with self.assertRaisesRegex(ValueError, "legacy income"):
build_runtime_switch.build_switch_target(args)

def test_build_switch_target_preserves_dca_fields_in_service_targets_when_omitted(self):
existing = {
"targets": [
Expand Down
2 changes: 2 additions & 0 deletions web/strategy-switch-console/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -120,6 +120,8 @@ For signed-in users, `/api/config` also reads the target repositories' current G

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`.

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.

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.

## Strategy Profile Alignment
Expand Down
2 changes: 2 additions & 0 deletions web/strategy-switch-console/README.zh-CN.md
Original file line number Diff line number Diff line change
Expand Up @@ -127,6 +127,8 @@ Worker 会校验 dispatch 参数必须匹配这里的某个账号项,也会校

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

收入层控件来自 `strategy-profiles.example.json` 里的 live 验证策略元数据。切换页可以沿用当前配置、按 profile 默认起始金额和最高比例开启收入层,或关闭收入层。策略 profile 目录也可以携带默认期权层元数据,例如 `option_overlay_enabled`、默认 recipe、起始金额和预算;这些值只表达策略仓库的默认配置和 promotion gate 状态。手工切换请求仍不能通过 `extra_variables_json` 覆盖直接期权 overlay / LEAPS 字段,在有单独证据晋级前,Worker、构建脚本和 target 校验都会拒绝这些直接覆盖项。

策略切换成功后也会把当前账号的 `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 保持一致。

## 策略 Profile 对齐规范
Expand Down
Loading