diff --git a/src/quant_advisor_research/advisory_report.py b/src/quant_advisor_research/advisory_report.py index 09c405b..9a9886a 100644 --- a/src/quant_advisor_research/advisory_report.py +++ b/src/quant_advisor_research/advisory_report.py @@ -465,6 +465,20 @@ def event_has_company_entity_acceptance(event: Event) -> bool: ) + +def dedupe_events_for_scoring(events: list[Event]) -> list[Event]: + """Keep one row per news URL (or event_id) before score accumulation.""" + + seen: set[str] = set() + unique: list[Event] = [] + for event in events: + key = event.source_url.strip() or event.event_id + if key in seen: + continue + seen.add(key) + unique.append(event) + return unique + def accepted_entity_events(events: list[Event]) -> list[Event]: return [event for event in events if event_has_company_entity_acceptance(event)] @@ -1074,7 +1088,7 @@ def build_recommendation( ai_signal: dict[str, Any] | None, as_of: dt.date, ) -> dict[str, Any]: - accepted_events = accepted_entity_events(events) + accepted_events = dedupe_events_for_scoring(accepted_entity_events(events)) rejected_events = [event for event in events if event not in accepted_events] ai_bias, ai_bias_source_themes, ai_bias_confidence = resolve_ai_bias(symbol, ai_signal) ai_confidence = ( diff --git a/src/quant_advisor_research/contracts.py b/src/quant_advisor_research/contracts.py index 73594c0..18dbf67 100644 --- a/src/quant_advisor_research/contracts.py +++ b/src/quant_advisor_research/contracts.py @@ -1,5 +1,7 @@ from __future__ import annotations +import math + import datetime as dt import re from collections.abc import Mapping, Sequence @@ -224,6 +226,8 @@ def _validate_v6_freshness( def _require_number_0_1(value: Any, name: str) -> None: if not isinstance(value, (int, float)) or isinstance(value, bool): raise AdvisoryValidationError(f"{name} must be numeric") + if not math.isfinite(value): + raise AdvisoryValidationError(f"{name} must be a finite number between 0 and 1") if value < 0 or value > 1: raise AdvisoryValidationError(f"{name} must be between 0 and 1") diff --git a/src/quant_advisor_research/notifications.py b/src/quant_advisor_research/notifications.py index d0626c7..cc7d32c 100644 --- a/src/quant_advisor_research/notifications.py +++ b/src/quant_advisor_research/notifications.py @@ -1,5 +1,7 @@ from __future__ import annotations +import datetime as dt + import json from typing import Any from urllib.parse import quote @@ -7,6 +9,7 @@ from .advisory_report import display_number, display_percent, theme_label from .publisher import cadence_label, report_filename +from .time_contract import is_report_expired def notification_locale(value: object | None = None) -> str: @@ -155,11 +158,14 @@ def format_telegram_message( max_recommendations: int = 8, max_themes: int = 5, lang: str | None = None, + now: dt.datetime | None = None, ) -> str: locale = notification_locale(lang) + expired = is_report_expired(report, now=now) if locale == "en": lines = [ f"Quant Advisor Research | {_english_cadence_label(report)} | {report.get('as_of', '')}", + *(["EXPIRED — this report is past expires_at and is not a current public recommendation."] if expired else []), "", *_format_final_decisions_en(report), "", @@ -169,6 +175,7 @@ def format_telegram_message( lines = [ f"智慧投顾研究系统 | {cadence_label(report)} | {report.get('as_of', '')}", + *(["【已过期】该报告已超过 expires_at,不再作为当前公开推荐。"] if expired else []), "", *_format_final_decisions(report), "", diff --git a/src/quant_advisor_research/publisher.py b/src/quant_advisor_research/publisher.py index d5ccc7e..a257187 100644 --- a/src/quant_advisor_research/publisher.py +++ b/src/quant_advisor_research/publisher.py @@ -14,6 +14,7 @@ from .advisory_report import display_number, display_percent, sector_label, theme_label from .contracts import AdvisoryValidationError, validate_advisory_report +from .time_contract import is_report_expired from .period_contract import CanonicalPeriod, PeriodContractError, canonical_period_identity @@ -952,9 +953,12 @@ def render_horizon_snapshot(report: dict[str, Any], *, linked: bool = False) -> return f'
{"".join(columns)}
' -def render_index_html(reports: list[dict[str, Any]]) -> str: +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) - latest = sorted_reports[0] if sorted_reports else None + 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 = "" if latest: latest_filename = report_filename(latest) @@ -963,9 +967,9 @@ def render_index_html(reports: list[dict[str, Any]]) -> str: latest_block = f"""
-

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)