Skip to content

Commit b31a48b

Browse files
committed
Fix weekly advisory duplicate publish
1 parent 01a4903 commit b31a48b

6 files changed

Lines changed: 83 additions & 6 deletions

File tree

.github/workflows/publish_advisory_site.yml

Lines changed: 6 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,7 @@ on:
44
workflow_dispatch:
55
inputs:
66
as_of:
7-
description: "Report date. Defaults to current UTC date when empty."
7+
description: "Report date. Defaults to the most recent Saturday when empty."
88
required: false
99
type: string
1010
site_url:
@@ -111,7 +111,11 @@ jobs:
111111
MARKET_DATA_PROXY_POOL_URL: ${{ github.event.inputs.market_data_proxy_pool_url || vars.MARKET_DATA_PROXY_POOL_URL || '' }}
112112
run: |
113113
set -euo pipefail
114-
AS_OF="${INPUT_AS_OF:-$(date -u +%F)}"
114+
if [ -n "${INPUT_AS_OF}" ]; then
115+
AS_OF="${INPUT_AS_OF}"
116+
else
117+
AS_OF="$(python -c 'from quant_advisor_research.build_pipeline import default_weekly_as_of; print(default_weekly_as_of().isoformat())')"
118+
fi
115119
mkdir -p .cache/market-data
116120
ARGS=(
117121
--as-of "${AS_OF}"

src/quant_advisor_research/archive_backfill.py

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -7,7 +7,7 @@
77
from typing import Any
88

99
from .build_pipeline import DEFAULT_FEED_TITLE, DEFAULT_SITE_URL, copy_if_different
10-
from .publisher import publish_reports
10+
from .publisher import publish_reports, unique_report_paths_by_content
1111

1212

1313
REPORT_JSON_PATTERN = re.compile(r"advisory_report_\d{4}-\d{2}-\d{2}\.json")
@@ -58,6 +58,7 @@ def backfill_site_archive(
5858
) -> list[Path]:
5959
if not report_paths:
6060
raise ValueError("No advisory_report_YYYY-MM-DD.json files found for backfill.")
61+
report_paths = unique_report_paths_by_content(report_paths)
6162
output = Path(output_dir)
6263
output.mkdir(parents=True, exist_ok=True)
6364
written = publish_reports(report_paths, output, site_url=site_url, feed_title=feed_title)

src/quant_advisor_research/build_pipeline.py

Lines changed: 8 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -8,7 +8,6 @@
88
import shutil
99
from dataclasses import dataclass
1010
from pathlib import Path
11-
from typing import Any
1211
from urllib.parse import quote
1312
from urllib.request import urlopen
1413

@@ -22,7 +21,7 @@
2221
write_market_confirmation_csv,
2322
)
2423
from .monthly_review import build_monthly_review, render_monthly_review_markdown
25-
from .publisher import publish_reports
24+
from .publisher import publish_reports, unique_report_paths_by_content
2625
from .recommendation_review import build_recommendation_review, render_recommendation_review_markdown
2726

2827

@@ -51,6 +50,12 @@ def parse_date(value: str) -> dt.date:
5150
return dt.date.fromisoformat(value.strip())
5251

5352

53+
def default_weekly_as_of(today: dt.date | None = None) -> dt.date:
54+
current = today or dt.datetime.now(dt.UTC).date()
55+
days_since_saturday = (current.weekday() - 5) % 7
56+
return current - dt.timedelta(days=days_since_saturday)
57+
58+
5459
def existing_optional_path(value: str | Path | None) -> Path | None:
5560
if value in {None, ""}:
5661
return None
@@ -280,6 +285,7 @@ def build_advisory_artifacts(
280285
report_paths = [report_json, *recovered_report_paths]
281286
else:
282287
report_paths = [report_json]
288+
report_paths = unique_report_paths_by_content(report_paths)
283289

284290
recommendation_review_json: Path | None = None
285291
recommendation_review_md: Path | None = None

src/quant_advisor_research/publisher.py

Lines changed: 41 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -59,6 +59,46 @@ def load_report(path: str | Path) -> dict[str, Any]:
5959
return json.load(handle)
6060

6161

62+
def report_content_fingerprint(report: dict[str, Any]) -> str:
63+
ignored_top_level_keys = {"as_of", "generated_at", "source_artifacts"}
64+
normalized = {key: value for key, value in report.items() if key not in ignored_top_level_keys}
65+
return json.dumps(normalized, ensure_ascii=False, sort_keys=True, separators=(",", ":"))
66+
67+
68+
def report_as_of_date(report: dict[str, Any]) -> dt.date | None:
69+
try:
70+
return dt.date.fromisoformat(str(report.get("as_of", "")))
71+
except ValueError:
72+
return None
73+
74+
75+
def is_same_period_duplicate(report: dict[str, Any], previous_report: dict[str, Any]) -> bool:
76+
if str(report.get("cadence", "")) != str(previous_report.get("cadence", "")):
77+
return False
78+
as_of = report_as_of_date(report)
79+
previous_as_of = report_as_of_date(previous_report)
80+
if as_of is None or previous_as_of is None:
81+
return True
82+
if str(report.get("cadence", "")) == "weekly":
83+
return abs((as_of - previous_as_of).days) < 7
84+
return as_of == previous_as_of
85+
86+
87+
def unique_report_paths_by_content(report_paths: list[str | Path]) -> list[Path]:
88+
unique_paths: list[Path] = []
89+
seen_reports_by_fingerprint: dict[str, list[dict[str, Any]]] = {}
90+
for report_path in report_paths:
91+
path = Path(report_path)
92+
report = load_report(path)
93+
fingerprint = report_content_fingerprint(report)
94+
previous_reports = seen_reports_by_fingerprint.setdefault(fingerprint, [])
95+
if any(is_same_period_duplicate(report, previous) for previous in previous_reports):
96+
continue
97+
previous_reports.append(report)
98+
unique_paths.append(path)
99+
return unique_paths
100+
101+
62102
def format_datetime(value: str) -> str:
63103
normalized = value[:-1] + "+00:00" if value.endswith("Z") else value
64104
parsed = dt.datetime.fromisoformat(normalized)
@@ -1191,6 +1231,7 @@ def render_feed_xml(reports: list[dict[str, Any]], *, site_url: str, feed_title:
11911231
def publish_reports(report_paths: list[str | Path], output_dir: str | Path, *, site_url: str, feed_title: str) -> list[Path]:
11921232
output = Path(output_dir)
11931233
output.mkdir(parents=True, exist_ok=True)
1234+
report_paths = unique_report_paths_by_content(report_paths)
11941235
reports = [load_report(path) for path in report_paths]
11951236
written: list[Path] = []
11961237
for report in reports:

tests/test_build_pipeline.py

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,7 @@
55
from pathlib import Path
66

77
from quant_advisor_research.archive_backfill import backfill_site_archive, discover_report_paths
8-
from quant_advisor_research.build_pipeline import build_advisory_artifacts
8+
from quant_advisor_research.build_pipeline import build_advisory_artifacts, default_weekly_as_of
99
from quant_advisor_research.cross_repo_smoke import run_cross_repo_smoke
1010

1111

@@ -40,6 +40,12 @@ def build_fixture_report(tmp_path: Path, as_of: dt.date) -> Path:
4040
return result.report_json
4141

4242

43+
def test_default_weekly_as_of_uses_most_recent_saturday() -> None:
44+
assert default_weekly_as_of(dt.date(2026, 6, 20)) == dt.date(2026, 6, 20)
45+
assert default_weekly_as_of(dt.date(2026, 6, 21)) == dt.date(2026, 6, 20)
46+
assert default_weekly_as_of(dt.date(2026, 6, 24)) == dt.date(2026, 6, 20)
47+
48+
4349
def test_build_advisory_artifacts_builds_market_report_and_site(tmp_path: Path) -> None:
4450
result = build_advisory_artifacts(
4551
as_of=dt.date(2026, 5, 31),

tests/test_publisher.py

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,7 @@
1111
render_index_html,
1212
render_report_html,
1313
render_reports_index_json,
14+
unique_report_paths_by_content,
1415
)
1516

1617

@@ -134,6 +135,24 @@ def make_report_for_date(base_report: dict, as_of: str) -> dict:
134135
return report
135136

136137

138+
def test_unique_report_paths_by_content_keeps_first_duplicate_report(tmp_path: Path) -> None:
139+
base = build_sample_report()
140+
current = make_report_for_date(base, "2026-06-20")
141+
duplicate = make_report_for_date(base, "2026-06-21")
142+
older = make_report_for_date(base, "2026-06-13")
143+
144+
current_path = tmp_path / "advisory_report_2026-06-20.json"
145+
duplicate_path = tmp_path / "advisory_report_2026-06-21.json"
146+
older_path = tmp_path / "advisory_report_2026-06-13.json"
147+
write_json(current_path, current)
148+
write_json(duplicate_path, duplicate)
149+
write_json(older_path, older)
150+
151+
reports = unique_report_paths_by_content([current_path, duplicate_path, older_path])
152+
153+
assert reports == [current_path, older_path]
154+
155+
137156
def test_index_limits_recent_history_and_archive_keeps_all_reports() -> None:
138157
base = build_sample_report()
139158
reports = [make_report_for_date(base, f"2026-05-{day:02d}") for day in range(1, 23)]

0 commit comments

Comments
 (0)