@@ -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
139265def 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