@@ -296,20 +296,76 @@ def render_report_html(report: dict[str, Any]) -> str:
296296"""
297297
298298
299+ def format_horizon_summary (report : dict [str , Any ]) -> list [tuple [str , str , str , list [str ]]]:
300+ decisions = report .get ("final_decisions" , {})
301+ buckets = decisions .get ("horizon_buckets" , {}) if isinstance (decisions , dict ) else {}
302+ return [
303+ (horizon , label , window , [str (symbol ) for symbol in buckets .get (horizon , [])])
304+ for horizon , label , window in HORIZON_COLUMNS
305+ ]
306+
307+
308+ def render_symbol_tags (symbols : list [str ], * , empty_label : str = "暂无" ) -> str :
309+ if not symbols :
310+ return f'<span class="symbol-tag empty">{ html .escape (empty_label )} </span>'
311+ return "" .join (f'<span class="symbol-tag">{ html .escape (symbol )} </span>' for symbol in symbols )
312+
313+
314+ def render_horizon_snapshot (report : dict [str , Any ], * , linked : bool = False ) -> str :
315+ columns = []
316+ href = report_filename (report )
317+ for horizon , label , window , symbols in format_horizon_summary (report ):
318+ tags = render_symbol_tags (symbols )
319+ body = f'<a class="horizon-link" href="{ html .escape (href )} ">{ tags } </a>' if linked else tags
320+ columns .append (
321+ f"""
322+ <section class="snapshot-column snapshot-{ html .escape (horizon )} ">
323+ <p class="snapshot-label">{ html .escape (label )} </p>
324+ <p class="snapshot-window">{ html .escape (window )} </p>
325+ <div class="symbol-strip">{ body } </div>
326+ </section>
327+ """
328+ )
329+ return f'<div class="snapshot-grid">{ "" .join (columns )} </div>'
330+
331+
299332def render_index_html (reports : list [dict [str , Any ]]) -> str :
333+ sorted_reports = sorted (reports , key = lambda item : item ["as_of" ], reverse = True )
334+ latest = sorted_reports [0 ] if sorted_reports else None
335+ latest_block = ""
336+ if latest :
337+ latest_filename = report_filename (latest )
338+ top_themes = format_theme_ids (latest ["summary" ].get ("top_theme_ids" , []))
339+ top_symbols = latest ["summary" ].get ("top_recommended_symbols" , [])
340+ latest_block = f"""
341+ <section class="latest-panel">
342+ <div class="latest-copy">
343+ <p class="eyebrow">Latest briefing</p>
344+ <h2>{ html .escape (latest ['as_of' ])} { html .escape (cadence_label (latest ))} 模型推荐</h2>
345+ <p class="lead">用主题动量、市场确认和事件证据生成的非个性化研究页面。</p>
346+ <div class="theme-line"><span>主要信号</span>{ html .escape (top_themes or '无' )} </div>
347+ <div class="symbol-strip hero-symbols">{ render_symbol_tags ([str (symbol ) for symbol in top_symbols ])} </div>
348+ <a class="primary-action" href="{ html .escape (latest_filename )} ">打开最新报告</a>
349+ </div>
350+ { render_horizon_snapshot (latest )}
351+ </section>
352+ """
300353 items = []
301- for report in sorted ( reports , key = lambda item : item [ "as_of" ], reverse = True ) :
354+ for report in sorted_reports :
302355 filename = report_filename (report )
303- top_symbols = ", " .join (report ["summary" ].get ("top_recommended_symbols" , []))
304356 top_themes = format_theme_ids (report ["summary" ].get ("top_theme_ids" , []))
357+ top_symbols = [str (symbol ) for symbol in report ["summary" ].get ("top_recommended_symbols" , [])]
305358 items .append (
306359 f"""
307- <li>
308- <a href="{ html .escape (filename )} ">{ html .escape (report ['as_of' ])} { html .escape (cadence_label (report ))} 复盘</a>
309- <span>主要信号:{ html .escape (top_themes or '无' )} ;推荐:{ html .escape (top_symbols or '无' )} </span>
310- </li>
360+ <article class="archive-card">
361+ <a class="archive-title" href="{ html .escape (filename )} ">{ html .escape (report ['as_of' ])} { html .escape (cadence_label (report ))} 复盘</a>
362+ <p>主要信号:{ html .escape (top_themes or '无' )} </p>
363+ <div class="archive-symbols">{ render_symbol_tags (top_symbols )} </div>
364+ { render_horizon_snapshot (report , linked = True )}
365+ </article>
311366 """
312367 )
368+ archive = "" .join (items ) or '<p class="empty-archive">暂无报告。</p>'
313369 return f"""<!doctype html>
314370<html lang="zh-CN">
315371<head>
@@ -318,22 +374,110 @@ def render_index_html(reports: list[dict[str, Any]]) -> str:
318374 <title>量化模型推荐</title>
319375 <link rel="alternate" type="application/rss+xml" title="量化模型推荐 RSS" href="feed.xml">
320376 <style>
321- body {{ margin: 0; font-family: Inter, ui-sans-serif, system-ui, sans-serif; background: #f6f7f9; color: #1b1f24; }}
322- main {{ max-width: 900px; margin: 0 auto; padding: 40px 20px; }}
323- h1 {{ margin-bottom: 8px; }}
324- p {{ color: #57606a; }}
325- ul {{ list-style: none; padding: 0; }}
326- li {{ background: #fff; border: 1px solid #d8dee4; border-radius: 8px; padding: 14px 16px; margin: 12px 0; }}
327- a {{ color: #0969da; font-weight: 700; text-decoration: none; }}
328- span {{ display: block; color: #57606a; margin-top: 6px; }}
377+ :root {{
378+ color-scheme: light;
379+ --ink: #172033;
380+ --muted: #667085;
381+ --line: #d9e2ef;
382+ --paper: #fffaf0;
383+ --panel: rgba(255, 255, 255, .78);
384+ --blue: #1e4dd8;
385+ --cyan: #00a6c8;
386+ --gold: #d99b2b;
387+ --green: #0f8b62;
388+ font-family: "Avenir Next", "Gill Sans", ui-sans-serif, system-ui, sans-serif;
389+ }}
390+ * {{ box-sizing: border-box; }}
391+ body {{
392+ margin: 0;
393+ color: var(--ink);
394+ background:
395+ radial-gradient(circle at 12% 8%, rgba(0,166,200,.18), transparent 28rem),
396+ radial-gradient(circle at 88% 0%, rgba(217,155,43,.18), transparent 24rem),
397+ linear-gradient(135deg, #f8fafc 0%, #eef4fb 52%, #fff7e6 100%);
398+ min-height: 100vh;
399+ }}
400+ body::before {{
401+ content: "";
402+ position: fixed;
403+ inset: 0;
404+ pointer-events: none;
405+ background-image:
406+ linear-gradient(rgba(23,32,51,.055) 1px, transparent 1px),
407+ linear-gradient(90deg, rgba(23,32,51,.045) 1px, transparent 1px);
408+ background-size: 44px 44px;
409+ mask-image: linear-gradient(to bottom, rgba(0,0,0,.75), transparent 78%);
410+ }}
411+ main {{ max-width: 1180px; margin: 0 auto; padding: 34px 20px 64px; position: relative; }}
412+ .hero {{ display: grid; grid-template-columns: 1fr auto; gap: 24px; align-items: end; padding: 22px 0 28px; }}
413+ .eyebrow {{ margin: 0 0 10px; color: var(--blue); font-weight: 800; letter-spacing: .16em; text-transform: uppercase; font-size: .78rem; }}
414+ h1 {{ margin: 0; max-width: 780px; font-family: "Iowan Old Style", Georgia, ui-serif, serif; font-size: clamp(2.55rem, 7vw, 5.7rem); line-height: .92; letter-spacing: -.07em; }}
415+ .hero p {{ margin: 18px 0 0; max-width: 680px; color: var(--muted); font-size: 1.06rem; line-height: 1.7; }}
416+ .rss-card {{ justify-self: end; min-width: 170px; padding: 14px 16px; border: 1px solid var(--line); border-radius: 18px; background: rgba(255,255,255,.66); box-shadow: 0 18px 45px rgba(31,45,61,.08); }}
417+ .rss-card a {{ color: var(--ink); text-decoration: none; font-weight: 800; }}
418+ .rss-card span {{ display: block; color: var(--muted); margin-top: 5px; font-size: .88rem; }}
419+ .latest-panel {{ position: relative; overflow: hidden; display: grid; grid-template-columns: minmax(0, 1.05fr) minmax(360px, .95fr); gap: 22px; border: 1px solid rgba(30,77,216,.16); border-radius: 30px; padding: 26px; background: linear-gradient(135deg, rgba(255,255,255,.9), rgba(255,250,240,.76)); box-shadow: 0 26px 70px rgba(31,45,61,.13); }}
420+ .latest-panel::after {{ content: ""; position: absolute; right: -80px; top: -110px; width: 260px; height: 260px; border-radius: 999px; background: rgba(0,166,200,.16); filter: blur(2px); }}
421+ .latest-copy {{ position: relative; z-index: 1; }}
422+ h2 {{ margin: 0; font-size: clamp(1.7rem, 3vw, 2.55rem); letter-spacing: -.04em; }}
423+ .lead {{ color: var(--muted); line-height: 1.7; max-width: 620px; }}
424+ .theme-line {{ display: inline-flex; flex-wrap: wrap; gap: 8px; align-items: center; margin: 8px 0 14px; color: var(--ink); }}
425+ .theme-line span {{ color: var(--blue); font-weight: 800; }}
426+ .symbol-strip {{ display: flex; flex-wrap: wrap; gap: 8px; align-items: center; }}
427+ .hero-symbols {{ margin-bottom: 20px; }}
428+ .symbol-tag {{ display: inline-flex; align-items: center; min-height: 30px; padding: 6px 10px; border-radius: 999px; border: 1px solid rgba(30,77,216,.22); background: #fff; color: var(--ink); font-weight: 800; box-shadow: 0 6px 16px rgba(31,45,61,.06); }}
429+ .symbol-tag.empty {{ color: var(--muted); font-weight: 700; }}
430+ .primary-action {{ display: inline-flex; align-items: center; justify-content: center; min-height: 44px; padding: 0 18px; border-radius: 999px; background: var(--ink); color: #fff; text-decoration: none; font-weight: 900; box-shadow: 0 12px 24px rgba(23,32,51,.22); }}
431+ .primary-action:hover {{ transform: translateY(-1px); }}
432+ .snapshot-grid {{ display: grid; grid-template-columns: repeat(3, minmax(0, 1fr)); gap: 12px; align-self: stretch; position: relative; z-index: 1; }}
433+ .snapshot-column {{ padding: 16px; border: 1px solid var(--line); border-radius: 22px; background: rgba(255,255,255,.72); backdrop-filter: blur(10px); min-height: 168px; }}
434+ .snapshot-label {{ margin: 0; font-size: 1.28rem; font-weight: 900; letter-spacing: -.03em; }}
435+ .snapshot-window {{ margin: 4px 0 14px; color: var(--muted); font-size: .9rem; }}
436+ .snapshot-long {{ border-top: 4px solid var(--green); }}
437+ .snapshot-medium {{ border-top: 4px solid var(--blue); }}
438+ .snapshot-short {{ border-top: 4px solid var(--gold); }}
439+ .horizon-link {{ color: inherit; text-decoration: none; }}
440+ .section-title {{ display: flex; align-items: end; justify-content: space-between; gap: 16px; margin: 34px 0 14px; }}
441+ .section-title h2 {{ font-size: 1.45rem; }}
442+ .section-title p {{ margin: 0; color: var(--muted); }}
443+ .archive-grid {{ display: grid; grid-template-columns: repeat(auto-fit, minmax(320px, 1fr)); gap: 16px; }}
444+ .archive-card {{ border: 1px solid var(--line); border-radius: 24px; padding: 18px; background: var(--panel); box-shadow: 0 12px 34px rgba(31,45,61,.08); }}
445+ .archive-title {{ color: var(--ink); font-size: 1.08rem; font-weight: 900; text-decoration: none; }}
446+ .archive-card p {{ margin: 10px 0; color: var(--muted); line-height: 1.55; }}
447+ .archive-symbols {{ margin: 0 0 14px; }}
448+ .archive-card .snapshot-grid {{ grid-template-columns: 1fr; gap: 8px; }}
449+ .archive-card .snapshot-column {{ min-height: auto; padding: 12px; border-radius: 16px; }}
450+ .archive-card .snapshot-label {{ font-size: 1rem; }}
451+ .archive-card .snapshot-window {{ margin-bottom: 8px; }}
452+ .empty-archive {{ color: var(--muted); }}
453+ @media (max-width: 880px) {{
454+ .hero, .latest-panel {{ grid-template-columns: 1fr; }}
455+ .rss-card {{ justify-self: start; }}
456+ .snapshot-grid {{ grid-template-columns: 1fr; }}
457+ }}
329458 </style>
330459</head>
331460<body>
332461 <main>
333- <h1>量化模型推荐</h1>
334- <p>展示非个性化模型推荐、理由、周期和风险提示。不包含下单、仓位配置或个性化建议。</p>
335- <p><a href="feed.xml">RSS 订阅</a></p>
336- <ul>{ '' .join (items )} </ul>
462+ <section class="hero">
463+ <div>
464+ <p class="eyebrow">QuantStrategyLab</p>
465+ <h1>量化模型推荐</h1>
466+ <p>把主题动量、市场确认和政策/新闻证据合成为非个性化研究结论。页面只展示推荐、周期、背景、理由和风险。</p>
467+ </div>
468+ <aside class="rss-card">
469+ <a href="feed.xml">RSS 订阅</a>
470+ <span>周度更新 · 静态页面</span>
471+ </aside>
472+ </section>
473+ { latest_block }
474+ <section class="archive">
475+ <div class="section-title">
476+ <h2>历史报告</h2>
477+ <p>按发布日期倒序</p>
478+ </div>
479+ <div class="archive-grid">{ archive } </div>
480+ </section>
337481 </main>
338482</body>
339483</html>
0 commit comments