Skip to content

Commit 7c093d2

Browse files
committed
Polish recommendations homepage
1 parent dfafc8c commit 7c093d2

2 files changed

Lines changed: 169 additions & 18 deletions

File tree

src/quant_advisor_research/publisher.py

Lines changed: 162 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -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+
299332
def 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>

tests/test_publisher.py

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -72,6 +72,13 @@ def test_publish_reports_writes_site_files(tmp_path: Path) -> None:
7272
assert "来源:" not in index_html
7373
assert "主要信号" in index_html
7474
assert "最终推荐" not in index_html
75+
assert "Latest briefing" in index_html
76+
assert "latest-panel" in index_html
77+
assert "snapshot-grid" in index_html
78+
assert "<p class=\"snapshot-label\">长线</p>" in index_html
79+
assert "<p class=\"snapshot-label\">中线</p>" in index_html
80+
assert "<p class=\"snapshot-label\">短线</p>" in index_html
81+
assert "打开最新报告" in index_html
7582

7683

7784
def test_render_report_html_does_not_show_fixture_warning_for_live_paths(tmp_path: Path) -> None:

0 commit comments

Comments
 (0)