From 58b6bf7f49d355b0d2780bc93414765265b2081b Mon Sep 17 00:00:00 2001 From: Pigbibi <20649888+Pigbibi@users.noreply.github.com> Date: Tue, 13 Oct 2026 04:20:28 +0900 Subject: [PATCH] frontend: match research homepage to approved UI audit page 9 --- src/quant_advisor_research/publisher.py | 171 +++++++++++++++++------- tests/test_publisher.py | 24 ++-- tests/test_publisher_homepage_design.py | 113 ++++++++++++++++ 3 files changed, 250 insertions(+), 58 deletions(-) create mode 100644 tests/test_publisher_homepage_design.py diff --git a/src/quant_advisor_research/publisher.py b/src/quant_advisor_research/publisher.py index 60d0574..df6fe12 100644 --- a/src/quant_advisor_research/publisher.py +++ b/src/quant_advisor_research/publisher.py @@ -29,7 +29,7 @@ ("medium", "中线", "2-12周"), ("short", "短线", "1-10个交易日"), ) -INDEX_HISTORY_LIMIT = 12 +INDEX_HISTORY_LIMIT = 5 RSS_ITEM_LIMIT = 20 SITE_ICON_FILENAME = "favicon.svg" @@ -605,20 +605,30 @@ def format_candidate_theme_ids(candidate: dict[str, Any]) -> str: """ INDEX_PAGE_CSS = """ + :root { --paper: #f7f6f2; --sheet: #fff; --ink: #202124; --ink-soft: #595f66; --muted: #737980; --rule: #dfddd5; --accent: #24587f; --container: 1250px; } + @media (prefers-color-scheme: dark) { + :root { --paper: #11151b; --sheet: #171c23; --ink: #e5e7ea; --ink-soft: #c5cad2; --muted: #98a0ad; --rule: #2b323c; --accent: #8cbcdf; } + } + .site-header { background: var(--sheet); border-bottom: 1px solid var(--rule); } + .site-header-inner { border: 0; } + .brand-link { font-family: var(--font-sans); } + .site-nav a { color: var(--accent); font-size: 1rem; text-decoration: underline; } main { max-width: var(--container); margin: 0 auto; padding: 0 var(--gutter) 96px; } - .hero { padding: clamp(40px, 7vw, 80px) 0 clamp(28px, 4vw, 44px); } + .hero { padding: clamp(40px, 5vw, 52px) 0 32px; border-bottom: 2px solid var(--ink); } .hero-copy { display: grid; grid-template-columns: minmax(0, 1.15fr) minmax(0, 1fr); gap: 16px clamp(28px, 5vw, 72px); align-items: end; } h1 { margin: 0; font-family: var(--font-display); font-size: clamp(2.375rem, 5.6vw, 4rem); font-weight: 700; line-height: 1.08; letter-spacing: .005em; } - .hero p { margin: 0; max-width: 30em; padding-left: 18px; border-left: 2px solid var(--accent); color: var(--ink-soft); font-size: 1rem; line-height: 1.85; } + .hero p { margin: 0; max-width: 32em; color: var(--ink-soft); font-size: 1rem; line-height: 1.85; } + .risk-notice { display: flex; gap: 14px; align-items: center; margin: 40px 0; padding: 12px 18px; border: 1px solid #e9c77c; border-radius: 8px; background: #fff8e6; color: #705a24; font-size: 1rem; } + .risk-notice svg { width: 20px; height: 20px; flex: 0 0 auto; } + .index-content { display: grid; grid-template-columns: minmax(0, 1.94fr) minmax(0, 1fr); gap: 26px; align-items: start; } - /* Latest issue: lead story set against the paper, not a boxed card. */ + /* Homepage composition follows the approved report overview design. */ .latest-panel { - display: grid; - grid-template-columns: minmax(0, 1fr) minmax(0, 1.2fr); - gap: clamp(28px, 5vw, 72px); - padding: 28px 0 36px; - border-top: 4px solid var(--rule-strong); - border-bottom: 1px solid var(--rule); + min-width: 0; + padding: 32px; + background: var(--sheet); + border: 1px solid var(--rule); + border-radius: 11px; } h2 { margin: 0; font-family: var(--font-display); font-size: clamp(1.5rem, 2.8vw, 2.125rem); font-weight: 700; line-height: 1.28; } .lead { margin: 14px 0 0; max-width: 32em; color: var(--ink-soft); line-height: 1.85; } @@ -640,6 +650,24 @@ def format_candidate_theme_ids(candidate: dict[str, Any]) -> str: } .primary-action:hover { color: var(--accent); } .latest-panel > .snapshot-grid { align-self: start; } + .latest-meta { display: flex; align-items: center; gap: 12px; margin: 0 0 22px; color: var(--muted); font-family: var(--font-figures); font-size: .875rem; } + .latest-meta strong { background: var(--ink); color: var(--sheet); font-family: var(--font-sans); padding: 1px 9px; border-radius: 3px; } + .latest-panel .snapshot-grid { grid-template-columns: repeat(3, minmax(0, 1fr)); gap: 13px; margin: 22px 0; border: 0; } + .latest-panel .snapshot-column { display: block; padding: 15px; background: var(--paper); border: 1px solid var(--rule); border-radius: 8px; } + .latest-panel .snapshot-label { display: inline; font-family: var(--font-sans); font-size: .9375rem; } + .latest-panel .snapshot-window { display: inline; font-size: .875rem; white-space: normal; } + .latest-panel .symbol-strip { margin-top: 4px; color: var(--ink-soft); font-size: .9375rem; } + .snapshot-reason { margin: 4px 0 0; color: var(--muted); font-size: .8125rem; line-height: 1.6; } + .empty-conclusion { margin: 20px 0; color: var(--ink-soft); font-size: .9375rem; } + .latest-panel .primary-action { padding: 10px 20px; background: var(--accent); color: var(--sheet); border: 0; border-radius: 6px; font-family: var(--font-sans); font-size: .9375rem; } + .latest-panel .primary-action:hover { opacity: .9; } + .recent-reports h2 { font-family: var(--font-sans); font-size: 1.0625rem; padding-bottom: 10px; } + .recent-list { border-top: 1px solid var(--rule); } + .recent-report { display: flex; justify-content: space-between; gap: 14px; padding: 8px 0; border-bottom: 1px solid var(--rule); color: var(--ink-soft); text-decoration: none; font-size: .875rem; } + .recent-report time, .recent-report small { display: block; } + .recent-report small, .recent-count { color: var(--muted); font-size: .8125rem; } + .recent-count { align-self: center; } + .recent-reports .archive-action { margin-top: 12px; font-size: .9375rem; font-weight: 400; } .archive { margin-top: clamp(48px, 7vw, 80px); } .section-title { display: flex; align-items: flex-end; justify-content: space-between; gap: 16px; margin-bottom: 0; padding-bottom: 14px; } @@ -647,9 +675,11 @@ def format_candidate_theme_ids(candidate: dict[str, Any]) -> str: .section-title p { margin: 4px 0 0; color: var(--muted); font-size: .875rem; } .archive-action { display: inline-flex; align-items: center; min-height: 44px; font-weight: 600; white-space: nowrap; text-decoration: underline; } @media (max-width: 960px) { - .hero-copy, .latest-panel { grid-template-columns: minmax(0, 1fr); } + .hero-copy, .index-content { grid-template-columns: minmax(0, 1fr); } } @media (max-width: 640px) { + .latest-panel { padding: 20px; } + .latest-panel .snapshot-grid { grid-template-columns: minmax(0, 1fr); } .section-title { flex-direction: column; align-items: flex-start; gap: 4px; } } """ @@ -1024,44 +1054,94 @@ def render_horizon_snapshot(report: dict[str, Any], *, linked: bool = False) -> return f'
{"".join(columns)}
' +def index_inputs_verified(report: dict[str, Any]) -> bool: + """Missing legacy/unknown freshness never proves a complete set of inputs.""" + freshness = report.get("freshness") + return isinstance(freshness, dict) and not report.get("summary", {}).get("data_quality_warnings") and all( + isinstance(freshness.get(name), dict) + and freshness[name].get("present") is True + and freshness[name].get("valid") is True + and freshness[name].get("reason") == "fresh" + for name in ("ai_signal", "theme_momentum") + ) + + +def index_threshold_unmet(report: dict[str, Any], horizon: str) -> bool: + decisions = report.get("final_decisions", {}) + rankings = decisions.get("horizon_rankings", {}) if isinstance(decisions, dict) else {} + candidates = rankings.get(horizon) if isinstance(rankings, dict) else None + return index_inputs_verified(report) and isinstance(candidates, list) and bool(candidates) and all( + isinstance(item, dict) and item.get("action") in {"watch", "skip"} for item in candidates + ) + + +def render_index_horizons(report: dict[str, Any]) -> str: + """Show final report conclusions without promoting candidates into recommendations.""" + columns = [] + for horizon, label, window in HORIZON_COLUMNS: + symbols = displayed_horizon_symbols(report, horizon) + conclusion = html.escape(join_zh_items(symbols)) if symbols else "本期无推荐" + reason = "" + if not symbols: + reason = ( + "候选未达到推荐门槛。" + if index_threshold_unmet(report, horizon) + else "具体原因见完整报告。" + ) + columns.append(f'''
+

{label}

{window.replace('-', '–').replace('年', ' 年').replace('周', ' 周').replace('个', ' 个')}

+
{conclusion}
+ {f'

{reason}

' if reason else ''} +
''') + return f'
{"".join(columns)}
' + + +def render_index_empty_conclusion(report: dict[str, Any]) -> str: + if any(displayed_horizon_symbols(report, horizon) for horizon, _, _ in HORIZON_COLUMNS): + return "" + thresholds_checked = all(index_threshold_unmet(report, horizon) for horizon, _, _ in HORIZON_COLUMNS) + explanation = ( + "空结果也是结论:说明信号未达门槛,而不是数据缺失。" + if thresholds_checked + else "输入数据状态和未达门槛的原因请见完整报告。" + ) + return f'

本期三个周期均未产生合格推荐。{explanation}

' + + def render_index_html(reports: list[dict[str, Any]], *, now: dt.datetime | None = None) -> str: sorted_reports = sorted(reports, key=lambda item: item["as_of"], reverse=True) reference_now = now or dt.datetime.now(dt.UTC) latest = next((item for item in sorted_reports if not is_report_expired(item, now=reference_now)), None) if latest is None and sorted_reports: latest = sorted_reports[0] - latest_block = "" + latest_block = '

暂无报告

' if latest: latest_filename = report_filename(latest) - top_themes = format_theme_ids(latest["summary"].get("top_theme_ids", [])) - top_symbols = latest["summary"].get("top_recommended_symbols", []) + expired = is_report_expired(latest, now=reference_now) latest_block = f""" -
-
- {'

已过期报告

' if is_report_expired(latest, now=reference_now) else ""} -

{html.escape(latest['as_of'])} {html.escape(cadence_label(latest))}智慧投顾研究{"(已过期)" if is_report_expired(latest, now=reference_now) else ""}

-

{"该报告已过期,不再作为当前公开推荐。" if is_report_expired(latest, now=reference_now) else "结合主题动量、市场确认和事件证据,生成普通投资者更容易阅读的研究结论。"}

-
主要信号{html.escape(top_themes or '无')}
-
{render_symbol_tags([str(symbol) for symbol in top_symbols])}
- 打开最新报告 -
- {render_horizon_snapshot(latest)} +
+

{"已过期" if expired else "最新"} · {html.escape(cadence_label(latest))} +

+

{html.escape(cadence_label(latest))}智慧投顾研究{"(已过期)" if expired else ""}

+ {'

该报告已过期,不再作为当前公开推荐。

' if expired else ''} + {render_index_horizons(latest)} + {render_index_empty_conclusion(latest)} + 阅读完整报告
""" items = [] recent_reports = [item for item in sorted_reports if item is not latest][:INDEX_HISTORY_LIMIT] for report in recent_reports: filename = report_filename(report) - top_themes = format_theme_ids(report["summary"].get("top_theme_ids", [])) - top_symbols = [str(symbol) for symbol in report["summary"].get("top_recommended_symbols", [])] + decisions = report.get("final_decisions", {}) + count = len(decisions.get("recommendations", [])) if isinstance(decisions, dict) else 0 items.append( f""" -
- {html.escape(report['as_of'])} {html.escape(cadence_label(report))}复盘 -

主要信号:{html.escape(top_themes or '无')}

-
{render_symbol_tags(top_symbols)}
- {render_horizon_snapshot(report, linked=True)} -
+ + + {html.escape(cadence_label(report))} + {count} 个推荐 + """ ) archive = "".join(items) or '

暂无更多历史报告。

' @@ -1079,12 +1159,13 @@ def render_index_html(reports: list[dict[str, Any]], *, now: dt.datetime | None @@ -1092,20 +1173,18 @@ def render_index_html(reports: list[dict[str, Any]], *, now: dt.datetime | None

智慧投顾研究系统

-

把主题动量、市场确认和政策/新闻证据整理成普通投资者能读懂的研究结论。投资有风险,不构成投资建议。

+

把主题动量、市场确认和政策 / 新闻证据整理成普通投资者能读懂的研究结论。

- {latest_block} -
-
-
-

近期历史报告

-

首页最多显示最近 {INDEX_HISTORY_LIMIT} 期,完整记录保留在归档页

-
- 查看全部 -
-
{archive}
-
+

投资有风险。本站内容为模型研究输出,不构成投资建议。

+
+ {latest_block} + +
diff --git a/tests/test_publisher.py b/tests/test_publisher.py index 9a4e9c9..bae8a1f 100644 --- a/tests/test_publisher.py +++ b/tests/test_publisher.py @@ -107,17 +107,17 @@ def test_publish_reports_writes_site_files(tmp_path: Path) -> None: archive_html = (tmp_path / "site" / "archive.html").read_text(encoding="utf-8") favicon = (tmp_path / "site" / "favicon.svg").read_text(encoding="utf-8") assert "来源:" not in index_html - assert "主要信号" in index_html + assert "阅读完整报告" in index_html assert "最终推荐" not in index_html assert 'rel="icon" type="image/svg+xml" href="favicon.svg"' in index_html assert "site-mark" in index_html assert " None: assert "

长线

" in index_html assert "

中线

" in index_html assert "

短线

" in index_html - assert "打开最新报告" in index_html + assert "阅读完整报告" in index_html def make_report_for_date(base_report: dict, as_of: str) -> dict: @@ -168,11 +168,11 @@ def test_index_limits_recent_history_and_archive_keeps_all_reports() -> None: feed = render_feed_xml(reports, site_url="https://example.com/advisor", feed_title="Test Feed") report_index = render_reports_index_json(reports) - assert "2026-05-22 周度智慧投顾研究" in index_html - assert index_html.count('class="archive-card"') == 12 - assert "2026-05-21 周度复盘" in index_html - assert "2026-05-10 周度复盘" in index_html - assert "2026-05-09 周度复盘" not in index_html + assert 'datetime="2026-05-22"' in index_html + assert index_html.count('class="recent-report"') == 5 + assert 'datetime="2026-05-21"' in index_html + assert 'datetime="2026-05-17"' in index_html + assert 'datetime="2026-05-16"' not in index_html assert "查看全部" in index_html assert "archive.html" in index_html assert archive_html.count('class="archive-card"') == 22 @@ -541,11 +541,11 @@ def test_render_index_html_prefers_non_expired_report_as_latest() -> None: expired["expires_at"] = "2020-01-01T00:00:00Z" html = render_index_html([expired, fresh], now=dt.datetime(2026, 6, 1, tzinfo=dt.UTC)) - latest_section = html.split('class="latest-panel"', 1)[1].split('class="archive"', 1)[0] + latest_section = html.split('class="latest-panel"', 1)[1].split(' None: diff --git a/tests/test_publisher_homepage_design.py b/tests/test_publisher_homepage_design.py new file mode 100644 index 0000000..3d6c22e --- /dev/null +++ b/tests/test_publisher_homepage_design.py @@ -0,0 +1,113 @@ +from copy import deepcopy + +import pytest +from test_publisher import build_sample_report + +from quant_advisor_research.contracts import validate_advisory_report +from quant_advisor_research.publisher import render_index_html + + +def empty_report(): + report = deepcopy(build_sample_report()) + report['final_decisions']['recommendations'] = [] + report['final_decisions']['horizon_buckets'] = {'long': [], 'medium': [], 'short': []} + report['final_decisions']['horizon_rankings'] = { + name: [{'symbol': 'CANDIDATE', 'action': 'watch', 'score': 0.1}] + for name in ('long', 'medium', 'short') + } + report['summary']['data_quality_warnings'] = [] + report['generated_at'] = '2026-05-31T12:00:00Z' + report['expires_at'] = '2026-06-07T12:00:00Z' + report['source_artifacts']['ai_signal'] = 'synthetic/ai_signal.json' + report['source_artifacts']['theme_momentum'] = 'synthetic/theme_momentum.json' + report['freshness'] = { + name: {'present': True, 'valid': True, 'reason': 'fresh', + 'as_of': '2026-05-30', 'generated_at': '2026-05-30T00:00:00Z', + 'expires_at': '2026-06-06T00:00:00Z'} + for name in ('ai_signal', 'theme_momentum') + } + validate_advisory_report(report) + return report + + +def test_complete_empty_report_explains_threshold_without_promoting_watch_candidates(): + page = render_index_html([empty_report()]) + assert page.count('本期无推荐') == 3 + assert page.count('候选未达到推荐门槛。') == 3 + assert '空结果也是结论:说明信号未达门槛,而不是数据缺失。' in page + assert 'CANDIDATE' not in page + assert '最新' in page + assert '>方法说明' in page + assert '>RSS' in page + + +def test_empty_result_with_data_warning_does_not_claim_complete_inputs(): + report = empty_report() + report['summary']['data_quality_warnings'] = ['missing_market_confirmation'] + page = render_index_html([report]) + assert '不是数据缺失' not in page + assert '输入数据状态和未达门槛的原因请见完整报告。' in page + assert '候选未达到推荐门槛' not in page + report['summary']['data_quality_warnings'] = [] + report['freshness'] = {'ai_signal': {'valid': False, 'reason': 'not_provided'}} + assert '不是数据缺失' not in render_index_html([report]) + + +@pytest.mark.parametrize('freshness', [{}, None, {'ai_signal': {}}, + {'ai_signal': {'present': True, 'reason': 'fresh'}, + 'theme_momentum': {'present': True, 'valid': True, 'reason': 'fresh'}}]) +def test_unknown_freshness_never_asserts_complete_inputs_or_threshold(freshness): + report = empty_report() + report['freshness'] = freshness + page = render_index_html([report]) + assert '不是数据缺失' not in page + assert '候选未达到推荐门槛' not in page + assert page.count('具体原因见完整报告。') == 3 + + +def test_valid_legacy_schema_with_no_freshness_keeps_reason_unknown(): + report = empty_report() + report.update(schema_version='5', contract_version='model_recommendations.v5') + for key in ('reference_time', 'expires_at', 'freshness', 'input_digest'): + report.pop(key) + validate_advisory_report(report) + page = render_index_html([report]) + assert '不是数据缺失' not in page + assert '候选未达到推荐门槛' not in page + + +@pytest.mark.parametrize('rankings', [None, [], {'long': [None]}, {'long': [{'action': None}]}]) +def test_unvalidated_ranking_metadata_cannot_invent_a_reason_or_break_the_page(rankings): + report = empty_report() + report['final_decisions']['horizon_rankings'] = rankings + validate_advisory_report(report) + page = render_index_html([report]) + assert '不是数据缺失' not in page + assert '候选未达到推荐门槛' not in page + + +def test_nonempty_report_keeps_actual_symbols_and_does_not_claim_all_empty(): + page = render_index_html([build_sample_report()]) + assert 'MU' in page and 'NVDA' in page + assert '本期三个周期均未产生合格推荐' not in page + + +def test_missing_ranking_evidence_does_not_invent_a_threshold_reason(): + report = empty_report() + report['final_decisions']['horizon_rankings'] = {} + page = render_index_html([report]) + assert '不是数据缺失' not in page + assert '候选未达到推荐门槛' not in page + assert page.count('具体原因见完整报告。') == 3 + + +def test_report_symbols_are_escaped_and_missing_reports_have_no_fake_date(): + report = build_sample_report() + report['final_decisions']['recommendations'][0]['symbol'] = '' + page = render_index_html([report]) + assert '' not in page + assert '<script>' in page + empty = render_index_html([]) + assert '暂无报告' in empty + assert '