Skip to content

Commit eef1893

Browse files
authored
Merge branch 'main' into fix/sync-uv-lock-qpk-pin
2 parents e3886b1 + 2d36205 commit eef1893

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
@@ -78,7 +78,7 @@ def _should_add_local_src(candidate: Path) -> bool:
7878
"ACCOUNT_GROUP",
7979
"IB_ACCOUNT_GROUP_CONFIG_SECRET_NAME",
8080
)
81-
OPTIONAL_TARGET_ENV = (
81+
PLATFORM_GENERIC_ENV = (
8282
"IB_GATEWAY_ZONE",
8383
"IB_GATEWAY_IP_MODE",
8484
"IBKR_EXECUTION_BACKEND",
@@ -135,8 +135,135 @@ def _should_add_local_src(candidate: Path) -> bool:
135135
"precheck_time": "CLOUD_SCHEDULER_PRECHECK_TIME",
136136
}
137137

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

139265
def build_sync_plan(env: Mapping[str, str] = os.environ) -> dict[str, object]:
266+
platform_config = _load_platform_config(env)
140267
target_entries, defaults, per_service_mode = _load_target_entries(env)
141268
status_rows = {
142269
str(row["canonical_profile"]): {
@@ -155,6 +282,7 @@ def build_sync_plan(env: Mapping[str, str] = os.environ) -> dict[str, object]:
155282
env=env,
156283
status_rows=status_rows,
157284
per_service_mode=per_service_mode,
285+
platform_config=platform_config,
158286
)
159287
for target in target_entries
160288
]
@@ -204,6 +332,7 @@ def _build_target_plan(
204332
env: Mapping[str, str],
205333
status_rows: Mapping[str, Mapping[str, object]],
206334
per_service_mode: bool,
335+
platform_config: dict,
207336
) -> dict[str, object]:
208337
service_name = _first_non_empty(
209338
_target_field(target, defaults, "service"),
@@ -241,6 +370,17 @@ def _build_target_plan(
241370
f"STRATEGY_PROFILE={raw_profile!r} is not eligible/enabled for {service_name}: {status}"
242371
)
243372

373+
# Resolve strategy defaults from platform-config.json
374+
strategies_cfg = platform_config.get("strategies", {}) if platform_config else {}
375+
strategy_config = strategies_cfg.get(canonical_profile, {})
376+
strategy_defaults = _derive_strategy_env_defaults(strategy_config)
377+
378+
# Overrides from RUNTIME_TARGET_JSON take precedence over strategy defaults
379+
overrides = runtime_target.get("overrides") if isinstance(runtime_target, Mapping) else None
380+
if isinstance(overrides, Mapping):
381+
for key, value in overrides.items():
382+
strategy_defaults[str(key).upper()] = _coerce_env_value(value) or ""
383+
244384
env_values: dict[str, str] = {}
245385
missing: list[str] = []
246386
for name in REQUIRED_ENV:
@@ -283,6 +423,15 @@ def _build_target_plan(
283423
dry_run_value = runtime_target.get("dry_run_only")
284424
if dry_run_value is not None:
285425
value = _coerce_env_value(dry_run_value)
426+
# 3. Strategy default from platform-config.json
427+
if value is None:
428+
value = strategy_defaults.get(name)
429+
# 4. Override from RUNTIME_TARGET_JSON.overrides
430+
if value is None and isinstance(overrides, Mapping):
431+
override_value = overrides.get(name) or overrides.get(name.lower())
432+
if override_value is not None:
433+
value = _coerce_env_value(override_value)
434+
286435
if value is None:
287436
remove_env_vars.append(name)
288437
else:

0 commit comments

Comments
 (0)