Skip to content

Commit e427af4

Browse files
Pigbibiclaude
andcommitted
feat(sync): auto-derive strategy env vars from platform-config.json
Strategy-specific variables (INCOME_LAYER_*, OPTION_*, DCA_*) are now auto-populated from the strategy's defaults in platform-config.json. RUNTIME_TARGET_JSON gains an optional 'overrides' field for custom values. Switching strategies now only requires changing RUNTIME_TARGET_JSON. Co-Authored-By: Claude <noreply@anthropic.com>
1 parent 5eb00ce commit e427af4

1 file changed

Lines changed: 169 additions & 26 deletions

File tree

scripts/build_cloud_run_env_sync_plan.py

Lines changed: 169 additions & 26 deletions
Original file line numberDiff line numberDiff line change
@@ -55,6 +55,8 @@ def _should_add_local_src(candidate: Path) -> bool:
5555

5656

5757
TARGETS_JSON_ENV = "CLOUD_RUN_SERVICE_TARGETS_JSON"
58+
PLATFORM_CONFIG_ENV = "PLATFORM_CONFIG_JSON"
59+
5860
SHARED_TARGET_FALLBACK_ENV = frozenset(
5961
{
6062
"GLOBAL_TELEGRAM_CHAT_ID",
@@ -72,7 +74,9 @@ def _should_add_local_src(candidate: Path) -> bool:
7274
"NOTIFY_LANG",
7375
"ACCOUNT_PREFIX",
7476
)
75-
OPTIONAL_TARGET_ENV = (
77+
78+
# Platform-generic vars: always sourced from GitHub variables / target config.
79+
PLATFORM_GENERIC_ENV = (
7680
"LONGBRIDGE_DRY_RUN_ONLY",
7781
"LONGBRIDGE_MARKET",
7882
"LONGBRIDGE_MARKET_CALENDAR",
@@ -90,11 +94,98 @@ def _should_add_local_src(candidate: Path) -> bool:
9094
"LONGBRIDGE_RESERVED_CASH_RATIO",
9195
"LONGBRIDGE_CASH_ONLY_EXECUTION",
9296
"LONGBRIDGE_SAFE_HAVEN_CASH_SUBSTITUTE_THRESHOLD_USD",
97+
"LONGBRIDGE_MARKET_SIGNAL_HANDOFF_INDEX_URI",
98+
"LONGBRIDGE_MARKET_SIGNAL_HANDOFF_MANIFEST_URI",
99+
"LONGBRIDGE_MARKET_SIGNAL_CONSUMPTION_AUDIT_URI",
100+
"LONGBRIDGE_MARKET_SIGNAL_CACHE_DIR",
101+
"LONGBRIDGE_MARKET_SIGNAL_REQUIRED",
102+
"LONGBRIDGE_MARKET_SIGNAL_FALLBACK_MODE",
103+
"LONGBRIDGE_MARKET_SIGNAL_MAX_STALE_DAYS",
104+
"CASH_ONLY_EXECUTION",
105+
"RUNTIME_TARGET_ENABLED",
106+
"EXECUTION_REPORT_GCS_URI",
107+
)
108+
109+
# Strategy-derived vars: auto-populated from platform-config.json defaults.
110+
# Each entry maps a platform-config.json path to (env_var_name, default_factory).
111+
# The factory receives the strategy's config dict and returns a string value.
112+
def _derive_strategy_env_defaults(strategy_config: dict) -> dict[str, str]:
113+
"""Derive env var defaults from a strategy's platform-config.json entry."""
114+
if not strategy_config:
115+
return {}
116+
features = strategy_config.get("features", {})
117+
income = strategy_config.get("income_layer_defaults", {})
118+
options = strategy_config.get("option_overlay_defaults", {})
119+
dca = strategy_config.get("dca_defaults", {})
120+
defaults: dict[str, str] = {}
121+
122+
# --- Features ---
123+
for feat_key, env_key in (
124+
("income_layer", "INCOME_LAYER_ENABLED"),
125+
("option_overlay", "OPTION_OVERLAY_ENABLED"),
126+
):
127+
if feat_key in features:
128+
defaults[env_key] = str(features[feat_key]).lower()
129+
130+
# --- Income-layer defaults ---
131+
for cfg_key, env_key in (
132+
("start_usd", "INCOME_LAYER_START_USD"),
133+
("max_ratio", "INCOME_LAYER_MAX_RATIO"),
134+
):
135+
if cfg_key in income and income[cfg_key] is not None:
136+
defaults[env_key] = str(income[cfg_key])
137+
138+
# Income allocations → individual ratio vars
139+
allocations = income.get("allocations") if isinstance(income, dict) else None
140+
if isinstance(allocations, dict):
141+
for symbol in ("QQQI", "SPYI"):
142+
weight = allocations.get(symbol)
143+
if weight is not None:
144+
defaults[f"{symbol}_INCOME_RATIO"] = str(weight)
145+
146+
# --- Option-overlay defaults ---
147+
for cfg_key, env_key in (
148+
("growth_enabled", "OPTION_GROWTH_OVERLAY_ENABLED"),
149+
("income_enabled", "OPTION_INCOME_OVERLAY_ENABLED"),
150+
("income_recipe", "OPTION_INCOME_OVERLAY_RECIPE"),
151+
("income_start_usd", "OPTION_INCOME_OVERLAY_START_USD"),
152+
("nav_risk_ratio", "OPTION_INCOME_OVERLAY_NAV_RISK_RATIO"),
153+
("growth_recipe", "OPTION_GROWTH_OVERLAY_RECIPE"),
154+
("growth_start_usd", "OPTION_GROWTH_OVERLAY_START_USD"),
155+
("nav_budget_ratio", "OPTION_GROWTH_OVERLAY_NAV_BUDGET_RATIO"),
156+
):
157+
if cfg_key in options and options[cfg_key] is not None:
158+
value = options[cfg_key]
159+
defaults[env_key] = str(value).lower() if isinstance(value, bool) else str(value)
160+
161+
# --- DCA defaults ---
162+
for cfg_key, env_key in (
163+
("default_mode", "DCA_MODE"),
164+
("default_base_investment_usd", "DCA_BASE_INVESTMENT_USD"),
165+
):
166+
if cfg_key in dca and dca[cfg_key] is not None:
167+
defaults[env_key] = str(dca[cfg_key])
168+
169+
return defaults
170+
171+
172+
# All vars that can appear as env values (union of platform-generic + strategy-derived).
173+
OPTIONAL_TARGET_ENV = PLATFORM_GENERIC_ENV + (
93174
"INCOME_LAYER_ENABLED",
94175
"INCOME_LAYER_START_USD",
95176
"INCOME_LAYER_MAX_RATIO",
96177
"INCOME_THRESHOLD_USD",
97178
"QQQI_INCOME_RATIO",
179+
"SPYI_INCOME_RATIO",
180+
"OPTION_OVERLAY_ENABLED",
181+
"OPTION_GROWTH_OVERLAY_ENABLED",
182+
"OPTION_INCOME_OVERLAY_ENABLED",
183+
"OPTION_INCOME_OVERLAY_RECIPE",
184+
"OPTION_INCOME_OVERLAY_START_USD",
185+
"OPTION_INCOME_OVERLAY_NAV_RISK_RATIO",
186+
"OPTION_GROWTH_OVERLAY_RECIPE",
187+
"OPTION_GROWTH_OVERLAY_START_USD",
188+
"OPTION_GROWTH_OVERLAY_NAV_BUDGET_RATIO",
98189
"DCA_MODE",
99190
"DCA_BASE_INVESTMENT_USD",
100191
"IBIT_ZSCORE_EXIT_ENABLED",
@@ -103,17 +194,8 @@ def _should_add_local_src(candidate: Path) -> bool:
103194
"IBIT_ZSCORE_EXIT_RISK_REDUCED_EXPOSURE",
104195
"IBIT_ZSCORE_EXIT_RISK_OFF_EXPOSURE",
105196
"IBIT_ZSCORE_EXIT_ALLOW_OUTSIDE_EXECUTION_WINDOW",
106-
"LONGBRIDGE_MARKET_SIGNAL_HANDOFF_INDEX_URI",
107-
"LONGBRIDGE_MARKET_SIGNAL_HANDOFF_MANIFEST_URI",
108-
"LONGBRIDGE_MARKET_SIGNAL_CONSUMPTION_AUDIT_URI",
109-
"LONGBRIDGE_MARKET_SIGNAL_CACHE_DIR",
110-
"LONGBRIDGE_MARKET_SIGNAL_REQUIRED",
111-
"LONGBRIDGE_MARKET_SIGNAL_FALLBACK_MODE",
112-
"LONGBRIDGE_MARKET_SIGNAL_MAX_STALE_DAYS",
113-
"CASH_ONLY_EXECUTION",
114-
"RUNTIME_TARGET_ENABLED",
115-
"EXECUTION_REPORT_GCS_URI",
116197
)
198+
117199
SCHEDULER_TIME_DEFAULTS = {
118200
"main_time": "45 15",
119201
"probe_time": "35 9,15",
@@ -126,7 +208,47 @@ def _should_add_local_src(candidate: Path) -> bool:
126208
}
127209

128210

211+
def _load_platform_config(env: Mapping[str, str]) -> dict:
212+
"""Load platform-config.json, preferring explicit env var, falling back to a
213+
bundled copy or fetching from the QuantRuntimeSettings repo."""
214+
raw = str(env.get(PLATFORM_CONFIG_ENV, "") or "").strip()
215+
if raw:
216+
try:
217+
return json.loads(raw)
218+
except json.JSONDecodeError:
219+
pass
220+
path = Path(raw)
221+
if path.exists():
222+
return json.loads(path.read_text(encoding="utf-8"))
223+
raise FileNotFoundError(f"PLATFORM_CONFIG_JSON file not found: {raw}")
224+
225+
# Fallback: look for a bundled copy in the repo
226+
bundled = ROOT / "platform-config.json"
227+
if bundled.exists():
228+
return json.loads(bundled.read_text(encoding="utf-8"))
229+
230+
# Last resort: fetch from QuantRuntimeSettings via gh CLI
231+
import subprocess
232+
try:
233+
result = subprocess.run(
234+
[
235+
"gh", "api",
236+
"repos/QuantStrategyLab/QuantRuntimeSettings/contents/platform-config.json",
237+
"--jq", ".content",
238+
],
239+
capture_output=True, text=True, timeout=15,
240+
)
241+
if result.returncode == 0 and result.stdout.strip():
242+
import base64
243+
return json.loads(base64.b64decode(result.stdout.strip()).decode("utf-8"))
244+
except Exception:
245+
pass
246+
247+
return {}
248+
249+
129250
def build_sync_plan(env: Mapping[str, str] = os.environ) -> dict[str, object]:
251+
platform_config = _load_platform_config(env)
130252
target_entries, defaults, per_service_mode = _load_target_entries(env)
131253
status_rows = {
132254
str(row["canonical_profile"]): {
@@ -145,6 +267,7 @@ def build_sync_plan(env: Mapping[str, str] = os.environ) -> dict[str, object]:
145267
env=env,
146268
status_rows=status_rows,
147269
per_service_mode=per_service_mode,
270+
platform_config=platform_config,
148271
)
149272
for target in target_entries
150273
]
@@ -194,6 +317,7 @@ def _build_target_plan(
194317
env: Mapping[str, str],
195318
status_rows: Mapping[str, Mapping[str, object]],
196319
per_service_mode: bool,
320+
platform_config: dict,
197321
) -> dict[str, object]:
198322
service_name = _first_non_empty(
199323
_target_field(target, defaults, "service"),
@@ -231,14 +355,22 @@ def _build_target_plan(
231355
f"STRATEGY_PROFILE={raw_profile!r} is not eligible/enabled for {service_name}: {status}"
232356
)
233357

358+
# Resolve strategy defaults from platform-config.json
359+
strategies_cfg = platform_config.get("strategies", {}) if platform_config else {}
360+
strategy_config = strategies_cfg.get(canonical_profile, {})
361+
strategy_defaults = _derive_strategy_env_defaults(strategy_config)
362+
363+
# Overrides from RUNTIME_TARGET_JSON take precedence over strategy defaults
364+
overrides = runtime_target.get("overrides") if isinstance(runtime_target, Mapping) else None
365+
if isinstance(overrides, Mapping):
366+
for key, value in overrides.items():
367+
strategy_defaults[str(key).upper()] = _coerce_env_value(value) or ""
368+
234369
env_values: dict[str, str] = {}
235370
missing: list[str] = []
236371
for name in REQUIRED_ENV:
237372
value = _target_env_value(
238-
target,
239-
defaults,
240-
env,
241-
name,
373+
target, defaults, env, name,
242374
per_service_mode=per_service_mode,
243375
allow_shared_fallback=name in SHARED_TARGET_FALLBACK_ENV,
244376
)
@@ -256,25 +388,31 @@ def _build_target_plan(
256388

257389
env_values["STRATEGY_PROFILE"] = canonical_profile
258390
env_values["RUNTIME_TARGET_JSON"] = json.dumps(
259-
runtime_target,
260-
separators=(",", ":"),
261-
sort_keys=True,
391+
runtime_target, separators=(",", ":"), sort_keys=True,
262392
)
263393

264394
remove_env_vars: list[str] = []
265395
for name in OPTIONAL_TARGET_ENV:
396+
# 1. Explicit GitHub variable
266397
value = _target_env_value(
267-
target,
268-
defaults,
269-
env,
270-
name,
398+
target, defaults, env, name,
271399
per_service_mode=per_service_mode,
272400
allow_shared_fallback=name in SHARED_TARGET_FALLBACK_ENV,
273401
)
402+
# 2. runtime_target-derived
274403
if value is None and name == "LONGBRIDGE_DRY_RUN_ONLY":
275404
dry_run_value = runtime_target.get("dry_run_only")
276405
if dry_run_value is not None:
277406
value = _coerce_env_value(dry_run_value)
407+
# 3. Strategy default from platform-config.json
408+
if value is None:
409+
value = strategy_defaults.get(name)
410+
# 4. Override from RUNTIME_TARGET_JSON.overrides
411+
if value is None and isinstance(overrides, Mapping):
412+
override_value = overrides.get(name) or overrides.get(name.lower())
413+
if override_value is not None:
414+
value = _coerce_env_value(override_value)
415+
278416
if value is None:
279417
remove_env_vars.append(name)
280418
else:
@@ -331,10 +469,7 @@ def _build_scheduler_plan(
331469
scheduler = {"timezone": timezone}
332470
for key, env_name in SCHEDULER_TIME_ENV.items():
333471
configured_value = _target_env_value(
334-
target,
335-
defaults,
336-
env,
337-
env_name,
472+
target, defaults, env, env_name,
338473
per_service_mode=per_service_mode,
339474
allow_shared_fallback=True,
340475
)
@@ -461,8 +596,16 @@ def _first_non_empty(*values: object | None) -> object | None:
461596
def main() -> int:
462597
parser = argparse.ArgumentParser()
463598
parser.add_argument("--json", action="store_true", help="Print compact JSON.")
599+
parser.add_argument(
600+
"--platform-config",
601+
help="Path or inline JSON for platform-config.json. "
602+
"Also read from PLATFORM_CONFIG_JSON env var.",
603+
)
464604
args = parser.parse_args()
465605

606+
if args.platform_config:
607+
os.environ[PLATFORM_CONFIG_ENV] = args.platform_config
608+
466609
try:
467610
plan = build_sync_plan()
468611
except Exception as exc:

0 commit comments

Comments
 (0)