Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
16 changes: 15 additions & 1 deletion src/quant_advisor_research/advisory_report.py
Original file line number Diff line number Diff line change
Expand Up @@ -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)]

Expand Down Expand Up @@ -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 = (
Expand Down
4 changes: 4 additions & 0 deletions src/quant_advisor_research/contracts.py
Original file line number Diff line number Diff line change
@@ -1,5 +1,7 @@
from __future__ import annotations

import math

import datetime as dt
import re
from collections.abc import Mapping, Sequence
Expand Down Expand Up @@ -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")

Expand Down
7 changes: 7 additions & 0 deletions src/quant_advisor_research/notifications.py
Original file line number Diff line number Diff line change
@@ -1,12 +1,15 @@
from __future__ import annotations

import datetime as dt

import json
from typing import Any
from urllib.parse import quote
from urllib.request import Request, urlopen

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:
Expand Down Expand Up @@ -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),
"",
Expand All @@ -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),
"",
Expand Down
16 changes: 10 additions & 6 deletions src/quant_advisor_research/publisher.py
Original file line number Diff line number Diff line change
Expand Up @@ -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


Expand Down Expand Up @@ -952,9 +953,12 @@ def render_horizon_snapshot(report: dict[str, Any], *, linked: bool = False) ->
return f'<div class="snapshot-grid">{"".join(columns)}</div>'


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)
Expand All @@ -963,9 +967,9 @@ def render_index_html(reports: list[dict[str, Any]]) -> str:
latest_block = f"""
<section class="latest-panel">
<div class="latest-copy">
<p class="eyebrow">Latest advisory</p>
<h2>{html.escape(latest['as_of'])} {html.escape(cadence_label(latest))}智慧投顾研究</h2>
<p class="lead">结合主题动量、市场确认和事件证据,生成普通投资者更容易阅读的研究结论。</p>
<p class="eyebrow">{"已过期报告" if is_report_expired(latest, now=reference_now) else "Latest advisory"}</p>
<h2>{html.escape(latest['as_of'])} {html.escape(cadence_label(latest))}智慧投顾研究{"(已过期)" if is_report_expired(latest, now=reference_now) else ""}</h2>
<p class="lead">{"该报告已过期,不再作为当前公开推荐。" if is_report_expired(latest, now=reference_now) else "结合主题动量、市场确认和事件证据,生成普通投资者更容易阅读的研究结论。"}</p>
<div class="theme-line"><span>主要信号</span>{html.escape(top_themes or '无')}</div>
<div class="symbol-strip hero-symbols">{render_symbol_tags([str(symbol) for symbol in top_symbols])}</div>
<a class="primary-action" href="{html.escape(latest_filename)}">打开最新报告</a>
Expand All @@ -974,7 +978,7 @@ def render_index_html(reports: list[dict[str, Any]]) -> str:
</section>
"""
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", []))
Expand Down
19 changes: 18 additions & 1 deletion src/quant_advisor_research/recommendation_review.py
Original file line number Diff line number Diff line change
Expand Up @@ -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],
Expand Down Expand Up @@ -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:
Expand Down
20 changes: 20 additions & 0 deletions src/quant_advisor_research/time_contract.py
Original file line number Diff line number Diff line change
Expand Up @@ -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

76 changes: 76 additions & 0 deletions tests/test_advisory_report.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
16 changes: 16 additions & 0 deletions tests/test_contracts_finite_0_1.py
Original file line number Diff line number Diff line change
@@ -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")
42 changes: 42 additions & 0 deletions tests/test_publisher.py
Original file line number Diff line number Diff line change
@@ -1,5 +1,7 @@
from __future__ import annotations

import datetime as dt

from copy import deepcopy
from pathlib import Path
import re
Expand Down Expand Up @@ -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
Loading
Loading