-
Latest advisory
-
{html.escape(latest['as_of'])} {html.escape(cadence_label(latest))}智慧投顾研究
-
结合主题动量、市场确认和事件证据,生成普通投资者更容易阅读的研究结论。
+
{"已过期报告" if is_report_expired(latest, now=reference_now) else "Latest advisory"}
+
{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])}
打开最新报告
@@ -974,7 +978,7 @@ def render_index_html(reports: list[dict[str, Any]]) -> str:
"""
items = []
- recent_reports = sorted_reports[1 : INDEX_HISTORY_LIMIT + 1]
+ 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", []))
diff --git a/src/quant_advisor_research/recommendation_review.py b/src/quant_advisor_research/recommendation_review.py
index a42c0eb..1403042 100644
--- a/src/quant_advisor_research/recommendation_review.py
+++ b/src/quant_advisor_research/recommendation_review.py
@@ -124,6 +124,23 @@ def load_review_bars(
)
+
+def publicly_available_date(report: dict[str, Any]) -> dt.date:
+ """Return the first date the recommendation was publicly available.
+
+ Review returns and maturity must start from publication time (`generated_at`),
+ not the research cutoff (`as_of`), to avoid scoring look-ahead before release.
+ """
+
+ generated_at = str(report.get("generated_at") or "").strip()
+ if generated_at:
+ normalized = generated_at.replace("Z", "+00:00")
+ try:
+ return dt.datetime.fromisoformat(normalized).date()
+ except ValueError:
+ pass
+ return parse_date(str(report.get("as_of", "")))
+
def build_review_item(
*,
pick: dict[str, Any],
@@ -298,7 +315,7 @@ def build_recommendation_review(
data_quality_warnings.append(f"Benchmark {benchmark} price bars are unavailable; relative returns may be missing.")
for report in reports:
- report_as_of = parse_date(str(report.get("as_of", "")))
+ report_as_of = publicly_available_date(report)
for pick in final_recommendations(report):
symbol = str(pick.get("symbol", "")).upper()
if symbol not in bars_by_symbol:
diff --git a/src/quant_advisor_research/time_contract.py b/src/quant_advisor_research/time_contract.py
index 81cf9a9..1211751 100644
--- a/src/quant_advisor_research/time_contract.py
+++ b/src/quant_advisor_research/time_contract.py
@@ -159,3 +159,23 @@ def schema_for_contract_version(contract_version: str) -> str:
if version == contract_version:
return schema
raise TimeContractError(f"unsupported contract_version: {contract_version}")
+
+def is_report_expired(
+ report: Mapping[str, Any],
+ *,
+ now: dt.datetime | None = None,
+) -> bool:
+ """True when expires_at is present and strictly before the reference instant."""
+
+ expires_text = str(report.get("expires_at") or "").strip()
+ if not expires_text:
+ return False
+ try:
+ expires_at = normalize_aware_datetime(expires_text)
+ except TimeContractError:
+ return False
+ reference = now or dt.datetime.now(dt.UTC)
+ if reference.tzinfo is None:
+ reference = reference.replace(tzinfo=dt.UTC)
+ return reference > expires_at
+
diff --git a/tests/test_advisory_report.py b/tests/test_advisory_report.py
index 4eb91de..bf31878 100644
--- a/tests/test_advisory_report.py
+++ b/tests/test_advisory_report.py
@@ -684,3 +684,79 @@ def test_contract_rejects_final_decision_section_action_mismatch() -> None:
with pytest.raises(AdvisoryValidationError, match="watchlist.*action must be watch"):
validate_advisory_report(report)
+
+
+def test_duplicate_news_rows_do_not_inflate_rating_before_dedupe() -> None:
+ item = WatchlistItem(
+ symbol="AAA",
+ name="Aaa Corp",
+ bucket="named_mentioned",
+ research_status="active",
+ thesis="Named in coverage.",
+ source_url="",
+ )
+ duplicate_events = [
+ Event(
+ event_id="news-1",
+ event_date=dt.date(2026, 1, 10),
+ symbol="AAA",
+ event_type="market_reaction",
+ direction="bullish",
+ confidence="medium",
+ source_url="https://example.com/same-news",
+ notes="Duplicate row A",
+ entity_match_type="issuer",
+ match_evidence="AAA is named in the release.",
+ relationship_type="issuer",
+ ),
+ Event(
+ event_id="news-2",
+ event_date=dt.date(2026, 1, 10),
+ symbol="AAA",
+ event_type="market_reaction",
+ direction="bullish",
+ confidence="medium",
+ source_url="https://example.com/same-news",
+ notes="Duplicate row B",
+ entity_match_type="issuer",
+ match_evidence="AAA is named in the release.",
+ relationship_type="issuer",
+ ),
+ ]
+
+ single = build_recommendation("AAA", item, duplicate_events[:1], None, dt.date(2026, 1, 20))
+ duplicated = build_recommendation("AAA", item, duplicate_events, None, dt.date(2026, 1, 20))
+
+ assert single["rating"] == "watch"
+ assert duplicated["rating"] == single["rating"]
+ assert duplicated["evidence_score"] == single["evidence_score"]
+
+
+def test_official_event_entity_fields_roundtrip_into_recommendation_evidence(tmp_path: Path) -> None:
+ events_path = tmp_path / "events.csv"
+ events_path.write_text(
+ "event_id,event_date,symbol,event_type,direction,confidence,source_url,notes,"
+ "entity_match_type,match_evidence,relationship_type\n"
+ "official-1,2026-01-10,EVT1,disclosure_buy,bullish,high,https://www.sec.gov/example/1,"
+ "Official filing.,issuer,SEC filing names EVT1,issuer\n",
+ encoding="utf-8",
+ )
+ watchlist_path = tmp_path / "watchlist.csv"
+ watchlist_path.write_text(
+ "symbol,name,bucket,research_status,thesis,source_url\n"
+ "EVT1,Event One,named_mentioned,active,Thesis,https://example.com/evt1\n",
+ encoding="utf-8",
+ )
+
+ report = build_advisory_report(
+ as_of="2026-01-20",
+ cadence="weekly",
+ political_events_path=events_path,
+ political_watchlist_path=watchlist_path,
+ )
+ rec = next(item for item in report["recommendations"] if item["symbol"] == "EVT1")
+ entity = rec["entity_evidence"][0]
+ assert entity["entity_match_type"] == "issuer"
+ assert entity["match_evidence"] == "SEC filing names EVT1"
+ assert entity["relationship_type"] == "issuer"
+ assert entity["accepted"] is True
diff --git a/tests/test_contracts_finite_0_1.py b/tests/test_contracts_finite_0_1.py
new file mode 100644
index 0000000..bb2b667
--- /dev/null
+++ b/tests/test_contracts_finite_0_1.py
@@ -0,0 +1,16 @@
+from __future__ import annotations
+
+import pytest
+
+from quant_advisor_research.contracts import AdvisoryValidationError, _require_number_0_1
+
+
+def test_require_number_0_1_rejects_nan() -> None:
+ with pytest.raises(AdvisoryValidationError, match="finite|between 0 and 1|0 and 1"):
+ _require_number_0_1(float("nan"), "score")
+
+
+def test_require_number_0_1_still_accepts_bounds() -> None:
+ _require_number_0_1(0, "score")
+ _require_number_0_1(1, "score")
+ _require_number_0_1(0.5, "score")
diff --git a/tests/test_publisher.py b/tests/test_publisher.py
index d23e4e1..b598777 100644
--- a/tests/test_publisher.py
+++ b/tests/test_publisher.py
@@ -1,5 +1,7 @@
from __future__ import annotations
+import datetime as dt
+
from copy import deepcopy
from pathlib import Path
import re
@@ -528,3 +530,43 @@ def test_format_telegram_message_can_render_an_english_summary_without_chinese_p
assert "Rationale and source-language background" in message
assert "Full report: https://example.com/advisor/2026-05-30-weekly-model-recommendations.html" in message
assert not re.search(r"[\u4e00-\u9fff]", message)
+
+
+def test_render_index_html_prefers_non_expired_report_as_latest() -> None:
+ fresh = build_sample_report()
+ fresh["as_of"] = "2026-05-20"
+ fresh["expires_at"] = "2099-01-01T00:00:00Z"
+ expired = build_sample_report()
+ expired["as_of"] = "2026-05-30"
+ 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]
+
+ assert "2026-05-20" in latest_section
+ assert "2026-05-30" not in latest_section
+ assert "打开最新报告" in latest_section
+
+
+def test_render_index_html_marks_expired_when_only_expired_reports_remain() -> None:
+ expired = build_sample_report()
+ expired["as_of"] = "2026-05-30"
+ expired["expires_at"] = "2020-01-01T00:00:00Z"
+
+ html = render_index_html([expired], now=dt.datetime(2026, 6, 1, tzinfo=dt.UTC))
+ latest_section = html.split('class="latest-panel"', 1)[1]
+
+ assert "过期" in latest_section
+
+
+def test_format_telegram_message_marks_expired_report() -> None:
+ from quant_advisor_research.notifications import format_telegram_message
+
+ report = build_sample_report()
+ report["expires_at"] = "2020-01-01T00:00:00Z"
+ message = format_telegram_message(
+ report,
+ site_url="https://example.com/advisor",
+ now=dt.datetime(2026, 6, 1, tzinfo=dt.UTC),
+ )
+ assert "过期" in message
diff --git a/tests/test_recommendation_review.py b/tests/test_recommendation_review.py
index 75e6fae..d5d0749 100644
--- a/tests/test_recommendation_review.py
+++ b/tests/test_recommendation_review.py
@@ -4,6 +4,8 @@
import json
from pathlib import Path
+import pytest
+
from quant_advisor_research.market_confirmation import PriceBar, write_cached_bars
from quant_advisor_research.recommendation_review import (
build_recommendation_review,
@@ -197,3 +199,58 @@ def test_recommendation_review_marks_same_day_report_as_pending(tmp_path: Path)
assert review["review_items"][0]["outcome"] == "pending"
assert review["summary"]["pending_count"] == 1
assert review["summary"]["insufficient_price_data_count"] == 0
+
+
+def test_recommendation_review_uses_generated_at_as_public_availability_start(tmp_path: Path) -> None:
+ cache_dir = tmp_path / "market-cache"
+ # Prices move sharply between as_of (Jan 5) and public availability (Jan 15).
+ write_cached_bars(
+ "MU",
+ make_bars(dt.date(2026, 1, 5), [100, 101, 102, 103, 104, 105, 106, 107, 108, 109, 150, 151, 152, 153, 154, 155]),
+ cache_dir=cache_dir,
+ )
+ write_cached_bars(
+ "SPY",
+ make_bars(dt.date(2026, 1, 5), [100] * 16),
+ cache_dir=cache_dir,
+ )
+ report_path = tmp_path / "advisory_report_2026-01-05.json"
+ report_path.write_text(
+ json.dumps(
+ {
+ "as_of": "2026-01-05",
+ "generated_at": "2026-01-15T12:00:00Z",
+ "cadence": "weekly",
+ "final_decisions": {
+ "recommendations": [
+ {
+ "symbol": "MU",
+ "name": "Micron Technology",
+ "primary_horizon": "short",
+ "primary_horizon_label": "短线",
+ "combined_score": 0.84,
+ "source_score": 0.2,
+ "momentum_score": 0.9,
+ }
+ ]
+ },
+ }
+ ),
+ encoding="utf-8",
+ )
+
+ review = build_recommendation_review(
+ report_paths=[report_path],
+ as_of=dt.date(2026, 1, 20),
+ benchmark="SPY",
+ cache_dir=cache_dir,
+ cache_max_age_days=30,
+ use_network=False,
+ )
+
+ item = review["review_items"][0]
+ assert item["report_as_of"] == "2026-01-15"
+ assert item["start_price_date"] == "2026-01-15"
+ assert item["elapsed_calendar_days"] == 5
+ # From 150 -> 155 is ~3.3%, not the 55% jump from as_of close 100.
+ assert item["absolute_return"] == pytest.approx(155 / 150 - 1, abs=1e-6)