@@ -60,7 +60,7 @@ def _should_add_local_src(candidate: Path) -> bool:
6060 }
6161)
6262REQUIRED_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
164290def 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