33import argparse
44import datetime as dt
55import json
6+ import statistics
67from pathlib import Path
78from typing import Any
89
2021 "medium" : "中线" ,
2122 "long" : "长线" ,
2223}
24+ MIN_MATURITY_TRADING_DAYS = {"short" : 10 , "medium" : 10 , "long" : 252 }
2325
2426
2527def utc_now_iso () -> str :
@@ -61,13 +63,22 @@ def return_between(start: PriceBar, end: PriceBar) -> float:
6163 return round (end .close / start .close - 1 , 6 )
6264
6365
64- def outcome_label (relative_return : float | None , * , elapsed_days : int , has_price_data : bool ) -> str :
66+ def outcome_label (
67+ relative_return : float | None ,
68+ * ,
69+ elapsed_days : int ,
70+ has_price_data : bool ,
71+ horizon : str ,
72+ trading_observations : int ,
73+ ) -> str :
6574 if elapsed_days <= 0 :
6675 return "pending"
6776 if not has_price_data :
6877 return "insufficient_price_data"
6978 if relative_return is None :
7079 return "insufficient_price_data"
80+ if trading_observations < MIN_MATURITY_TRADING_DAYS .get (horizon , MIN_MATURITY_TRADING_DAYS ["medium" ]):
81+ return "in_progress"
7182 if relative_return >= 0.02 :
7283 return "outperforming"
7384 if relative_return <= - 0.02 :
@@ -131,21 +142,39 @@ def build_review_item(
131142 relative_return = round (absolute_return - benchmark_return , 6 )
132143
133144 elapsed_days = (review_as_of - report_as_of ).days
145+ horizon = str (pick .get ("primary_horizon" , "" ))
146+ maturity_days = MIN_MATURITY_TRADING_DAYS .get (horizon , MIN_MATURITY_TRADING_DAYS ["medium" ])
147+ if elapsed_days <= 0 :
148+ maturity_status = "pending"
149+ elif not has_price_data :
150+ maturity_status = "insufficient_price_data"
151+ elif trading_observations < maturity_days :
152+ maturity_status = "in_progress"
153+ else :
154+ maturity_status = "matured"
134155 return {
135156 "report_as_of" : report_as_of .isoformat (),
136157 "review_as_of" : review_as_of .isoformat (),
137158 "symbol" : symbol ,
138159 "name" : str (pick .get ("name" , "" )),
139- "primary_horizon" : str ( pick . get ( "primary_horizon" , "" )) ,
160+ "primary_horizon" : horizon ,
140161 "primary_horizon_label" : str (pick .get ("primary_horizon_label" , "" )),
141162 "start_price_date" : start_date ,
142163 "end_price_date" : end_date ,
143164 "elapsed_calendar_days" : elapsed_days ,
144165 "trading_observations" : trading_observations ,
166+ "maturity_required_trading_days" : maturity_days ,
167+ "maturity_status" : maturity_status ,
145168 "absolute_return" : absolute_return ,
146169 "benchmark_return" : benchmark_return ,
147170 "relative_return" : relative_return ,
148- "outcome" : outcome_label (relative_return , elapsed_days = elapsed_days , has_price_data = has_price_data ),
171+ "outcome" : outcome_label (
172+ relative_return ,
173+ elapsed_days = elapsed_days ,
174+ has_price_data = has_price_data ,
175+ horizon = horizon ,
176+ trading_observations = trading_observations ,
177+ ),
149178 "market_data_source" : data_source if has_price_data else "" ,
150179 "combined_score" : pick .get ("combined_score" ),
151180 "source_score" : pick .get ("source_score" ),
@@ -157,24 +186,49 @@ def average(values: list[float]) -> float | None:
157186 return round (sum (values ) / len (values ), 6 ) if values else None
158187
159188
189+ def median (values : list [float ]) -> float | None :
190+ return round (float (statistics .median (values )), 6 ) if values else None
191+
192+
160193def summarize_items (items : list [dict [str , Any ]]) -> dict [str , Any ]:
161- evaluated = [item for item in items if isinstance (item .get ("relative_return" ), (int , float ))]
194+ evaluated = [
195+ item
196+ for item in items
197+ if item .get ("maturity_status" ) == "matured" and isinstance (item .get ("relative_return" ), (int , float ))
198+ ]
162199 by_horizon : dict [str , dict [str , Any ]] = {}
163200 for horizon in ("short" , "medium" , "long" ):
164201 horizon_items = [item for item in items if item .get ("primary_horizon" ) == horizon ]
165- horizon_evaluated = [item for item in horizon_items if isinstance (item .get ("relative_return" ), (int , float ))]
202+ horizon_evaluated = [item for item in evaluated if item .get ("primary_horizon" ) == horizon ]
203+ horizon_returns = [float (item ["relative_return" ]) for item in horizon_evaluated ]
204+ horizon_ranked = sorted (
205+ [item for item in horizon_evaluated if item .get ("outcome" ) == "outperforming" ],
206+ key = lambda item : (float (item .get ("relative_return" , 0 )), str (item .get ("symbol" , "" ))),
207+ reverse = True ,
208+ )
166209 by_horizon [horizon ] = {
167210 "label" : HORIZON_LABELS_ZH [horizon ],
168211 "item_count" : len (horizon_items ),
169212 "evaluated_count" : len (horizon_evaluated ),
213+ "sample_size" : len (horizon_evaluated ),
170214 "pending_count" : sum (1 for item in horizon_items if item .get ("outcome" ) == "pending" ),
215+ "in_progress_count" : sum (1 for item in horizon_items if item .get ("outcome" ) == "in_progress" ),
216+ "matured_count" : sum (1 for item in horizon_items if item .get ("maturity_status" ) == "matured" ),
171217 "insufficient_price_data_count" : sum (
172218 1 for item in horizon_items if item .get ("outcome" ) == "insufficient_price_data"
173219 ),
174- "average_relative_return" : average ([float (item ["relative_return" ]) for item in horizon_evaluated ]),
220+ "average_relative_return" : average (horizon_returns ),
221+ "median_relative_return" : median (horizon_returns ),
222+ "hit_rate" : round (
223+ sum (1 for item in horizon_evaluated if item .get ("outcome" ) == "outperforming" ) / len (horizon_evaluated ),
224+ 6 ,
225+ )
226+ if horizon_evaluated
227+ else None ,
228+ "top_outperformers" : unique_symbols (horizon_ranked ),
175229 }
176230 ranked = sorted (
177- evaluated ,
231+ [ item for item in evaluated if item . get ( "outcome" ) == "outperforming" ] ,
178232 key = lambda item : (float (item .get ("relative_return" , 0 )), str (item .get ("symbol" , "" ))),
179233 reverse = True ,
180234 )
@@ -183,12 +237,24 @@ def summarize_items(items: list[dict[str, Any]]) -> dict[str, Any]:
183237 "evaluated_count" : len (evaluated ),
184238 "pending_count" : sum (1 for item in items if item .get ("outcome" ) == "pending" ),
185239 "insufficient_price_data_count" : sum (1 for item in items if item .get ("outcome" ) == "insufficient_price_data" ),
186- "average_relative_return" : average ([float (item ["relative_return" ]) for item in evaluated ]),
240+ # Do not pool short-, medium-, and long-horizon performance into one statistic.
241+ "average_relative_return" : None ,
242+ "median_relative_return" : None ,
243+ "hit_rate" : None ,
187244 "by_horizon" : by_horizon ,
188- "top_outperformers" : [ item [ "symbol" ] for item in ranked [:5 ] ],
245+ "top_outperformers" : unique_symbols ( ranked ) [:5 ],
189246 }
190247
191248
249+ def unique_symbols (items : list [dict [str , Any ]]) -> list [str ]:
250+ symbols : list [str ] = []
251+ for item in items :
252+ symbol = str (item .get ("symbol" , "" ))
253+ if symbol and symbol not in symbols :
254+ symbols .append (symbol )
255+ return symbols [:5 ]
256+
257+
192258def build_recommendation_review (
193259 * ,
194260 report_paths : list [str | Path ],
@@ -245,7 +311,7 @@ def build_recommendation_review(
245311 )
246312
247313 return {
248- "schema_version" : "1 " ,
314+ "schema_version" : "2 " ,
249315 "mode" : "recommendation_review" ,
250316 "as_of" : as_of .isoformat (),
251317 "generated_at" : utc_now_iso (),
@@ -274,9 +340,9 @@ def render_recommendation_review_markdown(review: dict[str, Any]) -> str:
274340 f"- 复盘条目:{ summary .get ('item_count' , 0 )} " ,
275341 f"- 已可评估:{ summary .get ('evaluated_count' , 0 )} " ,
276342 f"- 待观察:{ summary .get ('pending_count' , 0 )} " ,
343+ f"- 进行中:{ sum (item .get ('in_progress_count' , 0 ) for item in summary .get ('by_horizon' , {}).values ())} " ,
277344 f"- 缺少价格数据:{ summary .get ('insufficient_price_data_count' , 0 )} " ,
278- f"- 平均相对收益:{ display_percent (summary .get ('average_relative_return' ))} " ,
279- f"- 领先标的:{ ', ' .join (summary .get ('top_outperformers' , [])) or '暂无' } " ,
345+ "- 不同持有期不合并计算平均收益;以下按周期分别统计。" ,
280346 "" ,
281347 "## 周期分布" ,
282348 "" ,
@@ -286,7 +352,9 @@ def render_recommendation_review_markdown(review: dict[str, Any]) -> str:
286352 item = summary .get ("by_horizon" , {}).get (horizon , {})
287353 lines .append (
288354 f"- { item .get ('label' , horizon )} :{ item .get ('evaluated_count' , 0 )} /{ item .get ('item_count' , 0 )} 已评估,"
289- f"平均相对收益 { display_percent (item .get ('average_relative_return' ))} "
355+ f"样本量 { item .get ('sample_size' , 0 )} ,平均 { display_percent (item .get ('average_relative_return' ))} ,"
356+ f"中位数 { display_percent (item .get ('median_relative_return' ))} ,命中率 { display_percent (item .get ('hit_rate' ))} ,"
357+ f"领先标的 { ', ' .join (item .get ('top_outperformers' , [])) or '暂无' } "
290358 )
291359 warnings = review .get ("data_quality_warnings" , [])
292360 if warnings :
@@ -302,6 +370,8 @@ def render_recommendation_review_markdown(review: dict[str, Any]) -> str:
302370 f"- 价格区间:{ item .get ('start_price_date' ) or '无' } 到 { item .get ('end_price_date' ) or '无' } " ,
303371 f"- 绝对收益:{ display_percent (item .get ('absolute_return' ))} " ,
304372 f"- 相对 { review .get ('benchmark' )} :{ display_percent (item .get ('relative_return' ))} " ,
373+ f"- 成熟度:{ item .get ('maturity_status' )} (需要 { item .get ('maturity_required_trading_days' , 0 )} 个交易日,"
374+ f"当前 { item .get ('trading_observations' , 0 )} 个)" ,
305375 f"- 状态:{ item .get ('outcome' )} " ,
306376 "" ,
307377 ]
0 commit comments