5353}
5454SHORT_PRIMARY_MIN_SCORE_EDGE = 0.05
5555THEME_MOMENTUM_ARTIFACT_TYPE = "medium_horizon_theme_context"
56+ AI_SIGNAL_MAX_AGE_DAYS = 31
57+ THEME_MOMENTUM_MAX_AGE_DAYS = 7
5658
5759CADENCE_LABELS_ZH = {
5860 "daily" : "日度" ,
@@ -207,6 +209,8 @@ class MarketConfirmation:
207209 market_score : float | None = None
208210 data_source : str = ""
209211 price_observation_count : int = 0
212+ confirmation_quality : str = ""
213+ warnings : str = ""
210214
211215
212216def parse_date (value : str ) -> dt .date :
@@ -265,6 +269,62 @@ def load_ai_signal(path: str | Path | None) -> dict[str, Any] | None:
265269 return payload
266270
267271
272+ def input_freshness (payload : dict [str , Any ], * , name : str , as_of : dt .date , max_age_days : int ) -> tuple [bool , str | None ]:
273+ try :
274+ artifact_as_of = parse_date (str (payload .get ("as_of" , "" )))
275+ except ValueError :
276+ return False , f"{ name } _missing_or_invalid_as_of"
277+ if artifact_as_of > as_of :
278+ return False , f"{ name } _as_of_in_future"
279+ if (as_of - artifact_as_of ).days > max_age_days :
280+ return False , f"{ name } _stale"
281+ generated_at = str (payload .get ("generated_at" , "" )).strip ()
282+ if generated_at :
283+ try :
284+ dt .datetime .fromisoformat (generated_at .replace ("Z" , "+00:00" ))
285+ except ValueError :
286+ return False , f"{ name } _invalid_generated_at"
287+ expires_at = str (payload .get ("expires_at" , "" )).strip ()
288+ if expires_at :
289+ try :
290+ if parse_date (expires_at [:10 ]) < as_of :
291+ return False , f"{ name } _expired"
292+ except ValueError :
293+ return False , f"{ name } _invalid_expires_at"
294+ return True , None
295+
296+
297+ def apply_input_freshness (
298+ * , ai_signal : dict [str , Any ] | None , theme_momentum : dict [str , Any ] | None , as_of : dt .date
299+ ) -> tuple [dict [str , Any ] | None , dict [str , Any ] | None , list [str ]]:
300+ warnings : list [str ] = []
301+ if ai_signal :
302+ valid , warning = input_freshness (ai_signal , name = "ai_signal" , as_of = as_of , max_age_days = AI_SIGNAL_MAX_AGE_DAYS )
303+ if not valid :
304+ warnings .append (warning or "ai_signal_not_fresh" )
305+ ai_signal = None
306+ if theme_momentum :
307+ valid , warning = input_freshness (
308+ theme_momentum , name = "theme_momentum" , as_of = as_of , max_age_days = THEME_MOMENTUM_MAX_AGE_DAYS
309+ )
310+ if not valid :
311+ warnings .append (warning or "theme_momentum_not_fresh" )
312+ theme_momentum = None
313+ return ai_signal , theme_momentum , warnings
314+ extreme = any (
315+ abs (as_float (item .get ("return_3m" ))) > 2.0
316+ for theme in theme_momentum .get ("theme_ranks" , [])
317+ if isinstance (theme , dict )
318+ for item in theme .get ("top_symbols" , [])
319+ if isinstance (item , dict )
320+ )
321+ if extreme :
322+ warnings .append ("theme_momentum_extreme_return_3m" )
323+ theme_momentum = None
324+ return ai_signal , theme_momentum , warnings
325+ return ai_signal , theme_momentum , warnings
326+
327+
268328
269329def load_theme_momentum (path : str | Path | None ) -> dict [str , Any ] | None :
270330 if path is None :
@@ -364,6 +424,8 @@ def load_market_confirmation(path: str | Path | None, as_of: dt.date) -> dict[st
364424 market_score = as_float (row .get ("market_score" )) if str (row .get ("market_score" , "" )).strip () else None ,
365425 data_source = str (row .get ("data_source" , "" )),
366426 price_observation_count = int (as_float (row .get ("price_observation_count" ))),
427+ confirmation_quality = str (row .get ("confirmation_quality" , "" )),
428+ warnings = str (row .get ("warnings" , "" )),
367429 )
368430 return confirmations
369431
@@ -959,6 +1021,8 @@ def market_confirmation_score(market: MarketConfirmation | None) -> float | None
9591021 return None
9601022 if market .data_source == "theme_momentum_fallback" :
9611023 return None
1024+ if market .confirmation_quality == "anomalous" or "extreme_return_63d" in market .warnings or "stale_price" in market .warnings :
1025+ return None
9621026 if market .market_score is not None :
9631027 return round (clamp (market .market_score , 0 , 1 ), 3 )
9641028 relative_20d = clamp (market .relative_return_20d / 0.20 , - 1 , 1 )
@@ -1437,6 +1501,9 @@ def build_advisory_report(
14371501 events = load_events (political_events_path , as_of_date )
14381502 ai_signal = load_ai_signal (ai_signal_path )
14391503 theme_momentum = load_theme_momentum (theme_momentum_path )
1504+ ai_signal , theme_momentum , freshness_warnings = apply_input_freshness (
1505+ ai_signal = ai_signal , theme_momentum = theme_momentum , as_of = as_of_date
1506+ )
14401507 market_confirmations = load_market_confirmation (market_confirmation_path , as_of_date )
14411508 theme_momentum_summary = summarize_theme_momentum (theme_momentum )
14421509 source_mode , data_quality_warnings = source_mode_for_paths (
@@ -1446,6 +1513,7 @@ def build_advisory_report(
14461513 theme_momentum_path ,
14471514 market_confirmation_path ,
14481515 )
1516+ data_quality_warnings .extend (freshness_warnings )
14491517
14501518 events_by_symbol : dict [str , list [Event ]] = defaultdict (list )
14511519 for event in events :
0 commit comments