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
21 changes: 21 additions & 0 deletions .github/workflows/manual-strategy-switch.yml
Original file line number Diff line number Diff line change
Expand Up @@ -98,6 +98,14 @@ on:
description: "Optional TQQQ QQQI income ratio override."
required: false
type: string
dca_mode:
description: "Optional DCA mode for DCA profiles only: fixed or smart."
required: false
type: string
dca_base_investment_usd:
description: "Optional base DCA investment amount for DCA profiles only."
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 @@ -159,6 +167,8 @@ jobs:
INCOME_LAYER_MAX_RATIO: ${{ inputs.income_layer_max_ratio }}
INCOME_THRESHOLD_USD: ${{ inputs.income_threshold_usd }}
QQQI_INCOME_RATIO: ${{ inputs.qqqi_income_ratio }}
DCA_MODE: ${{ inputs.dca_mode }}
DCA_BASE_INVESTMENT_USD: ${{ inputs.dca_base_investment_usd }}
SERVICE_TARGETS_MODE: ${{ inputs.service_targets_mode }}
APPLY_SWITCH: ${{ inputs.apply }}
TRIGGER_PLATFORM_SYNC: ${{ inputs.trigger_platform_sync }}
Expand Down Expand Up @@ -303,6 +313,12 @@ jobs:
if [ -n "${QQQI_INCOME_RATIO:-}" ]; then
args+=(--qqqi-income-ratio "${QQQI_INCOME_RATIO}")
fi
if [ -n "${DCA_MODE:-}" ]; then
args+=(--dca-mode "${DCA_MODE}")
fi
if [ -n "${DCA_BASE_INVESTMENT_USD:-}" ]; then
args+=(--dca-base-investment-usd "${DCA_BASE_INVESTMENT_USD}")
fi
if [ -s "${EXISTING_SERVICE_TARGETS_JSON_FILE:-}" ]; then
args+=(--existing-service-targets-json-file "${EXISTING_SERVICE_TARGETS_JSON_FILE}")
fi
Expand Down Expand Up @@ -395,6 +411,11 @@ jobs:
"account_scope": runtime_target["account_scope"],
"service_name": runtime_target["service_name"],
}
extra_variables = target.get("extra_variables") or {}
if extra_variables.get("DCA_MODE"):
payload["dca_mode"] = extra_variables["DCA_MODE"]
if extra_variables.get("DCA_BASE_INVESTMENT_USD"):
payload["dca_base_investment_usd"] = extra_variables["DCA_BASE_INVESTMENT_USD"]
if github.get("environment"):
payload["github_environment"] = github["environment"]

Expand Down
73 changes: 73 additions & 0 deletions scripts/build_runtime_switch.py
Original file line number Diff line number Diff line change
Expand Up @@ -95,6 +95,19 @@
RUNTIME_TARGET_VARIABLES = (
"RUNTIME_TARGET_ENABLED",
)
DCA_PROFILES = frozenset(
{
"nasdaq_sp500_smart_dca",
"ibit_smart_dca",
}
)
DCA_MODES = frozenset({"fixed", "smart"})
DCA_MODE_VARIABLE = "DCA_MODE"
DCA_BASE_INVESTMENT_VARIABLE = "DCA_BASE_INVESTMENT_USD"
DCA_RUNTIME_VARIABLES = (
DCA_MODE_VARIABLE,
DCA_BASE_INVESTMENT_VARIABLE,
)
DEFAULT_VARIABLE_SCOPE = {
"longbridge": "environment",
"ibkr": "repository",
Expand Down Expand Up @@ -199,6 +212,61 @@ def _parse_extra_variables(pairs: list[str], raw_json: str) -> dict[str, Any]:
return extras


def _normalize_dca_mode(value: str) -> str:
mode = str(value or "").strip().lower()
aliases = {
"ordinary": "fixed",
"ordinary_dca": "fixed",
"fixed_dca": "fixed",
"smart_dca": "smart",
}
mode = aliases.get(mode, mode)
if mode not in DCA_MODES:
raise ValueError("dca_mode must be fixed or smart")
return mode


def _normalize_positive_decimal(value: str, *, field_name: str) -> str:
text = str(value or "").strip()
if not text or not re.fullmatch(r"(?:\d+|\d*\.\d+)", text):
raise ValueError(f"{field_name} must be a positive decimal number")
numeric = float(text)
if not numeric > 0:
raise ValueError(f"{field_name} must be greater than 0")
return text


def _dca_extra_variables(args: argparse.Namespace, strategy_profile: str) -> dict[str, Any]:
is_dca_profile = strategy_profile in DCA_PROFILES
has_dca_mode = bool(str(args.dca_mode or "").strip())
has_dca_base = bool(str(args.dca_base_investment_usd or "").strip())
if not is_dca_profile:
if has_dca_mode or has_dca_base:
raise ValueError("DCA settings are only supported for DCA strategy profiles")
return {variable: "" for variable in DCA_RUNTIME_VARIABLES}

extra_variables: dict[str, Any] = {}
if has_dca_mode:
extra_variables[DCA_MODE_VARIABLE] = _normalize_dca_mode(args.dca_mode)
if has_dca_base:
extra_variables[DCA_BASE_INVESTMENT_VARIABLE] = _normalize_positive_decimal(
args.dca_base_investment_usd,
field_name="dca_base_investment_usd",
)
return extra_variables


def _reject_direct_dca_extra_variables(extra_variables: dict[str, Any]) -> None:
provided = [
variable
for variable in DCA_RUNTIME_VARIABLES
if variable in extra_variables and str(extra_variables.get(variable) or "").strip()
]
if provided:
names = ", ".join(provided)
raise ValueError(f"use --dca-mode and --dca-base-investment-usd instead of extra_variables_json for {names}")


def _auto_plugin_mounts(strategy_profile: str, artifact_bucket_uri: str) -> list[dict[str, Any]]:
if strategy_profile not in MARKET_REGIME_CONTROL_PROFILES:
return []
Expand Down Expand Up @@ -330,6 +398,7 @@ def _preserve_reserved_cash_fields(
PLATFORM_RESERVED_CASH_RATIO_VARIABLES.get(platform),
*INCOME_LAYER_VARIABLES,
*RUNTIME_TARGET_VARIABLES,
*DCA_RUNTIME_VARIABLES,
):
if variable and variable not in replacement and variable in current_entry:
replacement[variable] = current_entry[variable]
Expand Down Expand Up @@ -394,6 +463,7 @@ def build_switch_target(args: argparse.Namespace) -> dict[str, Any]:
mounts = _plugin_mounts(args, runtime_target["strategy_profile"])
mounts_variable = f"{SUPPORTED_PLATFORMS[platform]['plugin_mounts_prefix']}STRATEGY_PLUGIN_MOUNTS_JSON"
extra_variables = _parse_extra_variables(args.extra_variable, args.extra_variables_json)
_reject_direct_dca_extra_variables(extra_variables)

if args.set_platform_dry_run_variable:
extra_variables[PLATFORM_DRY_RUN_VARIABLES[platform]] = env_string(runtime_target["dry_run_only"])
Expand All @@ -409,6 +479,7 @@ def build_switch_target(args: argparse.Namespace) -> dict[str, Any]:
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"]))

service_targets = _load_json_from_file(
args.existing_service_targets_json_file,
Expand Down Expand Up @@ -474,6 +545,8 @@ def build_parser() -> argparse.ArgumentParser:
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("--existing-service-targets-json-file", default="")
parser.add_argument("--no-platform-dry-run-variable", dest="set_platform_dry_run_variable", action="store_false")
parser.set_defaults(set_platform_dry_run_variable=True)
Expand Down
89 changes: 83 additions & 6 deletions tests/strategy_switch_worker_validation.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -31,14 +31,24 @@ 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('id="dca-mode-select"'));
assert.ok(indexHtml.includes('id="dca-base-investment-usd-input"'));
assert.ok(indexHtml.includes('dcaMode: "定投模式"'));
assert.ok(indexHtml.includes('dcaModeFixed: "定额定投"'));
assert.ok(indexHtml.includes('dcaModeSmart: "智能定投"'));
assert.ok(indexHtml.includes('dcaMode: "DCA mode"'));
assert.ok(indexHtml.includes('dcaProfileDefaults'));
assert.ok(indexHtml.includes('el("income-layer-mode-select").addEventListener("change"'));
assert.ok(indexHtml.includes('el("income-layer-start-usd-input").addEventListener("input"'));
assert.ok(indexHtml.includes('el("income-layer-max-ratio-input").addEventListener("input"'));
assert.ok(indexHtml.includes('label_zh: "纳指100 / 标普500 智能定投"'));
assert.ok(indexHtml.includes('el("dca-mode-select").addEventListener("change"'));
assert.ok(indexHtml.includes('el("dca-base-investment-usd-input").addEventListener("input"'));
assert.ok(indexHtml.includes('label_zh: "纳指100 / 标普500 定投"'));
assert.ok(indexHtml.includes('class="form-section income-layer-section"'));
assert.ok(indexHtml.includes('class="form-section dca-section"'));
assert.ok(indexHtml.includes('class="control-block reserve-policy-block section-wide"'));
assert.ok(indexHtml.includes('profile: "ibit_smart_dca"'));
assert.ok(indexHtml.includes('IBIT 比特币 ETF 智能定投'));
assert.ok(indexHtml.includes('IBIT 比特币定投'));
assert.ok(indexHtml.includes('localStrategyLabels'));
assert.ok(indexHtml.includes('function strategyLabelSet('));
assert.ok(indexHtml.includes("account-block"));
Expand Down Expand Up @@ -221,11 +231,21 @@ const strategyProfiles = __test.normalizeStrategyProfilesPayload(
domain: "hk_equity",
runtime_enabled: true,
},
{
profile: "nasdaq_sp500_smart_dca",
label: "Nasdaq 100 / S&P 500 DCA",
label_zh: "纳指100 / 标普500 定投",
domain: "us_equity",
runtime_enabled: true,
},
],
"test_strategy_profiles",
);
assert.equal(strategyProfiles[0].label_en, "TQQQ Growth Income");
assert.equal(strategyProfiles[0].label_zh, "TQQQ 增长收益");
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");

const accountOptions = __test.normalizeAccountOptionsPayload(
{
Expand Down Expand Up @@ -345,6 +365,45 @@ const normalizedPluginInputs = __test.normalizeSwitchInputs({
plugin_mode: "none",
});
assert.equal(normalizedPluginInputs.plugin_mode, "none");
const normalizedDcaInputs = __test.normalizeSwitchInputs({
platform: "ibkr",
target_name: "ibkr-primary",
strategy_profile: "nasdaq_sp500_smart_dca",
execution_mode: "live",
plugin_mode: "auto",
dca_mode: "smart",
dca_base_investment_usd: "500",
});
assert.equal(normalizedDcaInputs.dca_mode, "smart");
assert.equal(normalizedDcaInputs.dca_base_investment_usd, "500");
assert.throws(
() => __test.normalizeSwitchInputs({
platform: "ibkr",
target_name: "ibkr-primary",
strategy_profile: "tqqq_growth_income",
dca_mode: "smart",
}),
/DCA settings are only supported/,
);
assert.throws(
() => __test.normalizeSwitchInputs({
platform: "ibkr",
target_name: "ibkr-primary",
strategy_profile: "nasdaq_sp500_smart_dca",
dca_mode: "smart",
dca_base_investment_usd: "0",
}),
/dca_base_investment_usd must be greater than 0/,
);
assert.throws(
() => __test.normalizeSwitchInputs({
platform: "ibkr",
target_name: "ibkr-primary",
strategy_profile: "tqqq_growth_income",
extra_variables_json: JSON.stringify({ DCA_MODE: "smart" }),
}),
/instead of extra_variables_json/,
);
const normalizedReserveClearInputs = __test.normalizeSwitchInputs({
platform: "ibkr",
target_name: "ibkr-primary",
Expand Down Expand Up @@ -466,11 +525,23 @@ globalThis.fetch = async (url) => {
headers: { "Content-Type": "application/json" },
});
}
if (requestUrl.endsWith("/DCA_MODE")) {
return new Response(JSON.stringify({ value: "smart" }), {
status: 200,
headers: { "Content-Type": "application/json" },
});
}
if (requestUrl.endsWith("/DCA_BASE_INVESTMENT_USD")) {
return new Response(JSON.stringify({ value: "500" }), {
status: 200,
headers: { "Content-Type": "application/json" },
});
}
if (requestUrl.endsWith("/RUNTIME_TARGET_JSON")) {
return new Response(JSON.stringify({
value: JSON.stringify({
platform_id: "schwab",
strategy_profile: "soxl_soxx_trend_income",
strategy_profile: "nasdaq_sp500_smart_dca",
dry_run_only: false,
account_scope: "schwab",
service_name: "charles-schwab-quant-service",
Expand All @@ -485,13 +556,15 @@ try {
{ schwab: accountOptions.schwab },
{ RUNTIME_SETTINGS_DISPATCH_TOKEN: "test-token" },
);
assert.equal(currentStrategies.schwab.default.strategy_profile, "soxl_soxx_trend_income");
assert.equal(currentStrategies.schwab.default.strategy_profile, "nasdaq_sp500_smart_dca");
assert.equal(currentStrategies.schwab.default.execution_mode, "live");
assert.equal(currentStrategies.schwab.default.min_reserved_cash_usd, "150");
assert.equal(currentStrategies.schwab.default.reserved_cash_ratio, "0.03");
assert.equal(currentStrategies.schwab.default.income_layer_start_usd, "150000");
assert.equal(currentStrategies.schwab.default.income_layer_max_ratio, "0.95");
assert.equal(currentStrategies.schwab.default.runtime_target_enabled, false);
assert.equal(currentStrategies.schwab.default.dca_mode, "smart");
assert.equal(currentStrategies.schwab.default.dca_base_investment_usd, "500");
assert.equal(currentStrategies.schwab.default.source, "RUNTIME_TARGET_JSON");
} finally {
globalThis.fetch = originalFetch;
Expand All @@ -511,9 +584,11 @@ globalThis.fetch = async (url) => {
INCOME_LAYER_START_USD: "250000",
INCOME_LAYER_MAX_RATIO: "0.55",
RUNTIME_TARGET_ENABLED: "false",
DCA_MODE: "smart",
DCA_BASE_INVESTMENT_USD: "700",
runtime_target: {
platform_id: "ibkr",
strategy_profile: "tqqq_growth_income",
strategy_profile: "ibit_smart_dca",
dry_run_only: false,
account_scope: "demo-ibkr-tqqq",
service_name: "interactive-brokers-demo-ibkr-tqqq-service",
Expand All @@ -531,12 +606,14 @@ try {
{ ibkr: accountOptions.ibkr },
{ RUNTIME_SETTINGS_DISPATCH_TOKEN: "test-token" },
);
assert.equal(currentStrategies.ibkr["ibkr-primary"].strategy_profile, "tqqq_growth_income");
assert.equal(currentStrategies.ibkr["ibkr-primary"].strategy_profile, "ibit_smart_dca");
assert.equal(currentStrategies.ibkr["ibkr-primary"].min_reserved_cash_usd, "150");
assert.equal(currentStrategies.ibkr["ibkr-primary"].reserved_cash_ratio, "0.03");
assert.equal(currentStrategies.ibkr["ibkr-primary"].income_layer_start_usd, "250000");
assert.equal(currentStrategies.ibkr["ibkr-primary"].income_layer_max_ratio, "0.55");
assert.equal(currentStrategies.ibkr["ibkr-primary"].runtime_target_enabled, false);
assert.equal(currentStrategies.ibkr["ibkr-primary"].dca_mode, "smart");
assert.equal(currentStrategies.ibkr["ibkr-primary"].dca_base_investment_usd, "700");
assert.equal(currentStrategies.ibkr["ibkr-primary"].source, "CLOUD_RUN_SERVICE_TARGETS_JSON");
} finally {
globalThis.fetch = originalFetch;
Expand Down
Loading