@@ -55,6 +55,8 @@ def _should_add_local_src(candidate: Path) -> bool:
5555
5656
5757TARGETS_JSON_ENV = "CLOUD_RUN_SERVICE_TARGETS_JSON"
58+ PLATFORM_CONFIG_ENV = "PLATFORM_CONFIG_JSON"
59+
5860SHARED_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+
117199SCHEDULER_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+
129250def 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:
461596def 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