Skip to content

Commit 2feffaf

Browse files
Pigbibiclaude
andauthored
feat(sync): auto-derive strategy env vars from platform-config.json (#210)
Co-authored-by: Claude <noreply@anthropic.com>
1 parent 4265fa7 commit 2feffaf

1 file changed

Lines changed: 150 additions & 1 deletion

File tree

scripts/build_cloud_run_env_sync_plan.py

Lines changed: 150 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -60,7 +60,7 @@ def _should_add_local_src(candidate: Path) -> bool:
6060
}
6161
)
6262
REQUIRED_ENV = ("NOTIFY_LANG",)
63-
OPTIONAL_TARGET_ENV = (
63+
PLATFORM_GENERIC_ENV = (
6464
"GLOBAL_TELEGRAM_CHAT_ID",
6565
"ACCOUNT_PREFIX",
6666
"ACCOUNT_REGION",
@@ -160,8 +160,135 @@ def _should_add_local_src(candidate: Path) -> bool:
160160
"precheck_time": "CLOUD_SCHEDULER_PRECHECK_TIME",
161161
}
162162

163+
# Strategy-derived vars: auto-populated from platform-config.json defaults.
164+
def _derive_strategy_env_defaults(strategy_config: dict) -> dict[str, str]:
165+
"""Derive env var defaults from a strategy's platform-config.json entry."""
166+
if not strategy_config:
167+
return {}
168+
features = strategy_config.get("features", {})
169+
income = strategy_config.get("income_layer_defaults", {})
170+
options = strategy_config.get("option_overlay_defaults", {})
171+
dca = strategy_config.get("dca_defaults", {})
172+
defaults: dict[str, str] = {}
173+
174+
# --- Features ---
175+
for feat_key, env_key in (
176+
("income_layer", "INCOME_LAYER_ENABLED"),
177+
("option_overlay", "OPTION_OVERLAY_ENABLED"),
178+
):
179+
if feat_key in features:
180+
defaults[env_key] = str(features[feat_key]).lower()
181+
182+
# --- Income-layer defaults ---
183+
for cfg_key, env_key in (
184+
("start_usd", "INCOME_LAYER_START_USD"),
185+
("max_ratio", "INCOME_LAYER_MAX_RATIO"),
186+
):
187+
if cfg_key in income and income[cfg_key] is not None:
188+
defaults[env_key] = str(income[cfg_key])
189+
190+
allocations = income.get("allocations") if isinstance(income, dict) else None
191+
if isinstance(allocations, dict):
192+
for symbol in ("QQQI", "SPYI"):
193+
weight = allocations.get(symbol)
194+
if weight is not None:
195+
defaults[f"{symbol}_INCOME_RATIO"] = str(weight)
196+
197+
# --- Option-overlay defaults ---
198+
for cfg_key, env_key in (
199+
("growth_enabled", "OPTION_GROWTH_OVERLAY_ENABLED"),
200+
("income_enabled", "OPTION_INCOME_OVERLAY_ENABLED"),
201+
("income_recipe", "OPTION_INCOME_OVERLAY_RECIPE"),
202+
("income_start_usd", "OPTION_INCOME_OVERLAY_START_USD"),
203+
("nav_risk_ratio", "OPTION_INCOME_OVERLAY_NAV_RISK_RATIO"),
204+
("growth_recipe", "OPTION_GROWTH_OVERLAY_RECIPE"),
205+
("growth_start_usd", "OPTION_GROWTH_OVERLAY_START_USD"),
206+
("nav_budget_ratio", "OPTION_GROWTH_OVERLAY_NAV_BUDGET_RATIO"),
207+
):
208+
if cfg_key in options and options[cfg_key] is not None:
209+
value = options[cfg_key]
210+
defaults[env_key] = str(value).lower() if isinstance(value, bool) else str(value)
211+
212+
# --- DCA defaults ---
213+
for cfg_key, env_key in (
214+
("default_mode", "DCA_MODE"),
215+
("default_base_investment_usd", "DCA_BASE_INVESTMENT_USD"),
216+
):
217+
if cfg_key in dca and dca[cfg_key] is not None:
218+
defaults[env_key] = str(dca[cfg_key])
219+
220+
return defaults
221+
222+
223+
# All vars that can appear as env values (union of platform-generic + strategy-derived).
224+
OPTIONAL_TARGET_ENV = PLATFORM_GENERIC_ENV + (
225+
"INCOME_LAYER_ENABLED",
226+
"INCOME_LAYER_START_USD",
227+
"INCOME_LAYER_MAX_RATIO",
228+
"INCOME_THRESHOLD_USD",
229+
"QQQI_INCOME_RATIO",
230+
"SPYI_INCOME_RATIO",
231+
"OPTION_OVERLAY_ENABLED",
232+
"OPTION_GROWTH_OVERLAY_ENABLED",
233+
"OPTION_INCOME_OVERLAY_ENABLED",
234+
"OPTION_INCOME_OVERLAY_RECIPE",
235+
"OPTION_INCOME_OVERLAY_START_USD",
236+
"OPTION_INCOME_OVERLAY_NAV_RISK_RATIO",
237+
"OPTION_GROWTH_OVERLAY_RECIPE",
238+
"OPTION_GROWTH_OVERLAY_START_USD",
239+
"OPTION_GROWTH_OVERLAY_NAV_BUDGET_RATIO",
240+
"DCA_MODE",
241+
"DCA_BASE_INVESTMENT_USD",
242+
"IBIT_ZSCORE_EXIT_ENABLED",
243+
"IBIT_ZSCORE_EXIT_MODE",
244+
"IBIT_ZSCORE_EXIT_PARKING_SYMBOL",
245+
"IBIT_ZSCORE_EXIT_RISK_REDUCED_EXPOSURE",
246+
"IBIT_ZSCORE_EXIT_RISK_OFF_EXPOSURE",
247+
"IBIT_ZSCORE_EXIT_ALLOW_OUTSIDE_EXECUTION_WINDOW",
248+
)
249+
250+
251+
252+
253+
PLATFORM_CONFIG_ENV = "PLATFORM_CONFIG_JSON"
254+
255+
256+
def _load_platform_config(env: Mapping[str, str]) -> dict:
257+
"""Load platform-config.json, preferring explicit env var, falling back to a
258+
bundled copy or fetching from the QuantRuntimeSettings repo."""
259+
raw = str(env.get(PLATFORM_CONFIG_ENV, "") or "").strip()
260+
if raw:
261+
try:
262+
return json.loads(raw)
263+
except json.JSONDecodeError:
264+
pass
265+
path = Path(raw)
266+
if path.exists():
267+
return json.loads(path.read_text(encoding="utf-8"))
268+
raise FileNotFoundError(f"PLATFORM_CONFIG_JSON file not found: {raw}")
269+
270+
bundled = ROOT / "platform-config.json"
271+
if bundled.exists():
272+
return json.loads(bundled.read_text(encoding="utf-8"))
273+
274+
import subprocess
275+
try:
276+
result = subprocess.run(
277+
["gh", "api",
278+
"repos/QuantStrategyLab/QuantRuntimeSettings/contents/platform-config.json",
279+
"--jq", ".content"],
280+
capture_output=True, text=True, timeout=15,
281+
)
282+
if result.returncode == 0 and result.stdout.strip():
283+
import base64
284+
return json.loads(base64.b64decode(result.stdout.strip()).decode("utf-8"))
285+
except Exception:
286+
pass
287+
288+
return {}
163289

164290
def build_sync_plan(env: Mapping[str, str] = os.environ) -> dict[str, object]:
291+
platform_config = _load_platform_config(env)
165292
target_entries, defaults, per_service_mode = _load_target_entries(env)
166293
status_rows = {
167294
str(row["canonical_profile"]): {
@@ -180,6 +307,7 @@ def build_sync_plan(env: Mapping[str, str] = os.environ) -> dict[str, object]:
180307
env=env,
181308
status_rows=status_rows,
182309
per_service_mode=per_service_mode,
310+
platform_config=platform_config,
183311
)
184312
for target in target_entries
185313
]
@@ -229,6 +357,7 @@ def _build_target_plan(
229357
env: Mapping[str, str],
230358
status_rows: Mapping[str, Mapping[str, object]],
231359
per_service_mode: bool,
360+
platform_config: dict,
232361
) -> dict[str, object]:
233362
service_name = _first_non_empty(
234363
_target_field(target, defaults, "service"),
@@ -266,6 +395,17 @@ def _build_target_plan(
266395
f"STRATEGY_PROFILE={raw_profile!r} is not eligible/enabled for {service_name}: {status}"
267396
)
268397

398+
# Resolve strategy defaults from platform-config.json
399+
strategies_cfg = platform_config.get("strategies", {}) if platform_config else {}
400+
strategy_config = strategies_cfg.get(canonical_profile, {})
401+
strategy_defaults = _derive_strategy_env_defaults(strategy_config)
402+
403+
# Overrides from RUNTIME_TARGET_JSON take precedence over strategy defaults
404+
overrides = runtime_target.get("overrides") if isinstance(runtime_target, Mapping) else None
405+
if isinstance(overrides, Mapping):
406+
for key, value in overrides.items():
407+
strategy_defaults[str(key).upper()] = _coerce_env_value(value) or ""
408+
269409
env_values: dict[str, str] = {}
270410
missing: list[str] = []
271411
for name in REQUIRED_ENV:
@@ -303,6 +443,15 @@ def _build_target_plan(
303443
dry_run_value = runtime_target.get("dry_run_only")
304444
if dry_run_value is not None:
305445
value = _coerce_env_value(dry_run_value)
446+
# 3. Strategy default from platform-config.json
447+
if value is None:
448+
value = strategy_defaults.get(name)
449+
# 4. Override from RUNTIME_TARGET_JSON.overrides
450+
if value is None and isinstance(overrides, Mapping):
451+
override_value = overrides.get(name) or overrides.get(name.lower())
452+
if override_value is not None:
453+
value = _coerce_env_value(override_value)
454+
306455
if value is None:
307456
remove_env_vars.append(name)
308457
else:

0 commit comments

Comments
 (0)