Skip to content

Commit 1334892

Browse files
Pigbibicodex
andcommitted
fix: add research input quality gates
Co-Authored-By: Codex <noreply@openai.com>
1 parent 0fdf582 commit 1334892

11 files changed

Lines changed: 196 additions & 3 deletions

.github/workflows/monthly_advisory_review.yml

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -67,11 +67,13 @@ jobs:
6767
with:
6868
path: advisor
6969
- name: Checkout political event repository
70+
id: checkout_political_events
7071
uses: actions/checkout@v6
7172
with:
7273
repository: QuantStrategyLab/PoliticalEventTrackingResearch
7374
path: political-events
7475
- name: Checkout signal context repository
76+
id: checkout_signal_context
7577
uses: actions/checkout@v6
7678
with:
7779
repository: QuantStrategyLab/ResearchSignalContextPipelines
@@ -111,6 +113,8 @@ jobs:
111113
--cadence monthly
112114
--political-events "../political-events/${POLITICAL_EVENTS_PATH}"
113115
--political-watchlist "../political-events/${POLITICAL_WATCHLIST_PATH}"
116+
--upstream-repo-sha "QuantStrategyLab/PoliticalEventTrackingResearch=${{ steps.checkout_political_events.outputs.commit }}"
117+
--upstream-repo-sha "QuantStrategyLab/ResearchSignalContextPipelines=${{ steps.checkout_signal_context.outputs.commit }}"
114118
--ai-signal "../research-signal-context/${AI_SIGNAL_PATH}"
115119
--output-dir data/output/monthly_advisory_review
116120
--monthly-review

.github/workflows/publish_advisory_site.yml

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -73,11 +73,13 @@ jobs:
7373
with:
7474
path: advisor
7575
- name: Checkout political event repository
76+
id: checkout_political_events
7677
uses: actions/checkout@v6
7778
with:
7879
repository: QuantStrategyLab/PoliticalEventTrackingResearch
7980
path: political-events
8081
- name: Checkout signal context repository
82+
id: checkout_signal_context
8183
uses: actions/checkout@v6
8284
with:
8385
repository: QuantStrategyLab/ResearchSignalContextPipelines
@@ -122,6 +124,8 @@ jobs:
122124
--cadence weekly
123125
--political-events "../political-events/${POLITICAL_EVENTS_PATH}"
124126
--political-watchlist "../political-events/${POLITICAL_WATCHLIST_PATH}"
127+
--upstream-repo-sha "QuantStrategyLab/PoliticalEventTrackingResearch=${{ steps.checkout_political_events.outputs.commit }}"
128+
--upstream-repo-sha "QuantStrategyLab/ResearchSignalContextPipelines=${{ steps.checkout_signal_context.outputs.commit }}"
125129
--ai-signal "../research-signal-context/${AI_SIGNAL_PATH}"
126130
--output-dir data/output/published
127131
--site-output-dir site

.github/workflows/weekly_advisory_review.yml

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -62,11 +62,13 @@ jobs:
6262
with:
6363
path: advisor
6464
- name: Checkout political event repository
65+
id: checkout_political_events
6566
uses: actions/checkout@v6
6667
with:
6768
repository: QuantStrategyLab/PoliticalEventTrackingResearch
6869
path: political-events
6970
- name: Checkout signal context repository
71+
id: checkout_signal_context
7072
uses: actions/checkout@v6
7173
with:
7274
repository: QuantStrategyLab/ResearchSignalContextPipelines
@@ -105,6 +107,8 @@ jobs:
105107
--cadence weekly
106108
--political-events "../political-events/${POLITICAL_EVENTS_PATH}"
107109
--political-watchlist "../political-events/${POLITICAL_WATCHLIST_PATH}"
110+
--upstream-repo-sha "QuantStrategyLab/PoliticalEventTrackingResearch=${{ steps.checkout_political_events.outputs.commit }}"
111+
--upstream-repo-sha "QuantStrategyLab/ResearchSignalContextPipelines=${{ steps.checkout_signal_context.outputs.commit }}"
108112
--ai-signal "../research-signal-context/${AI_SIGNAL_PATH}"
109113
--output-dir data/output/weekly_advisory_review
110114
--market-cache-dir .cache/market-data

docs/advisory_contract.md

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -270,6 +270,12 @@ policy
270270
generated_at
271271
```
272272

273+
`source_artifacts` also records each existing input's SHA-256, point-in-time
274+
`as_of`/`generated_at`/`expires_at`, and schema/header metadata. Scheduled
275+
workflows pass the checked-out commit SHA for each upstream repository in
276+
`upstream_repositories`; missing or stale context is excluded from scoring and
277+
reported in `summary.data_quality_warnings`.
278+
273279
## Source Mode
274280

275281
`summary.source_mode` is:

src/quant_advisor_research/advisory_report.py

Lines changed: 68 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -53,6 +53,8 @@
5353
}
5454
SHORT_PRIMARY_MIN_SCORE_EDGE = 0.05
5555
THEME_MOMENTUM_ARTIFACT_TYPE = "medium_horizon_theme_context"
56+
AI_SIGNAL_MAX_AGE_DAYS = 31
57+
THEME_MOMENTUM_MAX_AGE_DAYS = 7
5658

5759
CADENCE_LABELS_ZH = {
5860
"daily": "日度",
@@ -207,6 +209,8 @@ class MarketConfirmation:
207209
market_score: float | None = None
208210
data_source: str = ""
209211
price_observation_count: int = 0
212+
confirmation_quality: str = ""
213+
warnings: str = ""
210214

211215

212216
def parse_date(value: str) -> dt.date:
@@ -265,6 +269,62 @@ def load_ai_signal(path: str | Path | None) -> dict[str, Any] | None:
265269
return payload
266270

267271

272+
def input_freshness(payload: dict[str, Any], *, name: str, as_of: dt.date, max_age_days: int) -> tuple[bool, str | None]:
273+
try:
274+
artifact_as_of = parse_date(str(payload.get("as_of", "")))
275+
except ValueError:
276+
return False, f"{name}_missing_or_invalid_as_of"
277+
if artifact_as_of > as_of:
278+
return False, f"{name}_as_of_in_future"
279+
if (as_of - artifact_as_of).days > max_age_days:
280+
return False, f"{name}_stale"
281+
generated_at = str(payload.get("generated_at", "")).strip()
282+
if generated_at:
283+
try:
284+
dt.datetime.fromisoformat(generated_at.replace("Z", "+00:00"))
285+
except ValueError:
286+
return False, f"{name}_invalid_generated_at"
287+
expires_at = str(payload.get("expires_at", "")).strip()
288+
if expires_at:
289+
try:
290+
if parse_date(expires_at[:10]) < as_of:
291+
return False, f"{name}_expired"
292+
except ValueError:
293+
return False, f"{name}_invalid_expires_at"
294+
return True, None
295+
296+
297+
def apply_input_freshness(
298+
*, ai_signal: dict[str, Any] | None, theme_momentum: dict[str, Any] | None, as_of: dt.date
299+
) -> tuple[dict[str, Any] | None, dict[str, Any] | None, list[str]]:
300+
warnings: list[str] = []
301+
if ai_signal:
302+
valid, warning = input_freshness(ai_signal, name="ai_signal", as_of=as_of, max_age_days=AI_SIGNAL_MAX_AGE_DAYS)
303+
if not valid:
304+
warnings.append(warning or "ai_signal_not_fresh")
305+
ai_signal = None
306+
if theme_momentum:
307+
valid, warning = input_freshness(
308+
theme_momentum, name="theme_momentum", as_of=as_of, max_age_days=THEME_MOMENTUM_MAX_AGE_DAYS
309+
)
310+
if not valid:
311+
warnings.append(warning or "theme_momentum_not_fresh")
312+
theme_momentum = None
313+
return ai_signal, theme_momentum, warnings
314+
extreme = any(
315+
abs(as_float(item.get("return_3m"))) > 2.0
316+
for theme in theme_momentum.get("theme_ranks", [])
317+
if isinstance(theme, dict)
318+
for item in theme.get("top_symbols", [])
319+
if isinstance(item, dict)
320+
)
321+
if extreme:
322+
warnings.append("theme_momentum_extreme_return_3m")
323+
theme_momentum = None
324+
return ai_signal, theme_momentum, warnings
325+
return ai_signal, theme_momentum, warnings
326+
327+
268328

269329
def load_theme_momentum(path: str | Path | None) -> dict[str, Any] | None:
270330
if path is None:
@@ -364,6 +424,8 @@ def load_market_confirmation(path: str | Path | None, as_of: dt.date) -> dict[st
364424
market_score=as_float(row.get("market_score")) if str(row.get("market_score", "")).strip() else None,
365425
data_source=str(row.get("data_source", "")),
366426
price_observation_count=int(as_float(row.get("price_observation_count"))),
427+
confirmation_quality=str(row.get("confirmation_quality", "")),
428+
warnings=str(row.get("warnings", "")),
367429
)
368430
return confirmations
369431

@@ -959,6 +1021,8 @@ def market_confirmation_score(market: MarketConfirmation | None) -> float | None
9591021
return None
9601022
if market.data_source == "theme_momentum_fallback":
9611023
return None
1024+
if market.confirmation_quality == "anomalous" or "extreme_return_63d" in market.warnings or "stale_price" in market.warnings:
1025+
return None
9621026
if market.market_score is not None:
9631027
return round(clamp(market.market_score, 0, 1), 3)
9641028
relative_20d = clamp(market.relative_return_20d / 0.20, -1, 1)
@@ -1437,6 +1501,9 @@ def build_advisory_report(
14371501
events = load_events(political_events_path, as_of_date)
14381502
ai_signal = load_ai_signal(ai_signal_path)
14391503
theme_momentum = load_theme_momentum(theme_momentum_path)
1504+
ai_signal, theme_momentum, freshness_warnings = apply_input_freshness(
1505+
ai_signal=ai_signal, theme_momentum=theme_momentum, as_of=as_of_date
1506+
)
14401507
market_confirmations = load_market_confirmation(market_confirmation_path, as_of_date)
14411508
theme_momentum_summary = summarize_theme_momentum(theme_momentum)
14421509
source_mode, data_quality_warnings = source_mode_for_paths(
@@ -1446,6 +1513,7 @@ def build_advisory_report(
14461513
theme_momentum_path,
14471514
market_confirmation_path,
14481515
)
1516+
data_quality_warnings.extend(freshness_warnings)
14491517

14501518
events_by_symbol: dict[str, list[Event]] = defaultdict(list)
14511519
for event in events:

src/quant_advisor_research/artifacts.py

Lines changed: 35 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@
22

33
import hashlib
44
import json
5+
import csv
56
from datetime import datetime, timezone
67
from pathlib import Path
78
from typing import Any, Mapping
@@ -37,6 +38,8 @@ def write_report_manifest(
3738
git_sha: str | None = None,
3839
run_id: str | None = None,
3940
run_attempt: str | None = None,
41+
upstream_repo_shas: Mapping[str, str] | None = None,
42+
input_paths: Mapping[str, str | Path] | None = None,
4043
) -> Path:
4144
resolved_report = Path(report_path)
4245
resolved_markdown = Path(markdown_path)
@@ -59,6 +62,36 @@ def write_report_manifest(
5962
if run_attempt:
6063
version_parts.append(f"attempt-{run_attempt}")
6164

65+
source_artifacts = {}
66+
for name, raw_path in (input_paths or {}).items():
67+
if raw_path is None:
68+
continue
69+
path = Path(raw_path)
70+
if not path.exists():
71+
continue
72+
metadata: dict[str, Any] = {"path": str(path), "sha256": sha256_file(path)}
73+
if path.suffix.lower() == ".csv":
74+
with path.open(encoding="utf-8", newline="") as handle:
75+
reader = csv.DictReader(handle)
76+
metadata["schema"] = ",".join(reader.fieldnames or [])
77+
as_of_values = sorted({str(row.get("as_of", "")).strip() for row in reader if row.get("as_of")})
78+
if as_of_values:
79+
metadata["as_of"] = as_of_values[-1]
80+
elif path.suffix.lower() == ".json":
81+
try:
82+
source = json.loads(path.read_text(encoding="utf-8"))
83+
metadata.update(
84+
{
85+
"as_of": source.get("as_of", ""),
86+
"generated_at": source.get("generated_at", ""),
87+
"expires_at": source.get("expires_at", ""),
88+
"schema": str(source.get("schema_version", "")),
89+
}
90+
)
91+
except (OSError, json.JSONDecodeError):
92+
metadata["schema"] = "invalid_json"
93+
source_artifacts[name] = metadata
94+
6295
payload = {
6396
"manifest_type": "model_recommendation_report",
6497
"artifact_type": "model_recommendations",
@@ -76,7 +109,8 @@ def write_report_manifest(
76109
"github_run_id": run_id or "",
77110
"github_run_attempt": run_attempt or "",
78111
},
79-
"source_artifacts": dict(report.get("source_artifacts") or {}),
112+
"source_artifacts": source_artifacts or dict(report.get("source_artifacts") or {}),
113+
"upstream_repositories": dict(upstream_repo_shas or {}),
80114
"summary": dict(report.get("summary") or {}),
81115
"artifacts": {
82116
"json": {

src/quant_advisor_research/build_pipeline.py

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -203,6 +203,7 @@ def build_advisory_artifacts(
203203
site_url: str = DEFAULT_SITE_URL,
204204
feed_title: str = DEFAULT_FEED_TITLE,
205205
recover_site_archive: bool = False,
206+
upstream_repo_shas: dict[str, str] | None = None,
206207
) -> BuildPipelineResult:
207208
output = Path(output_dir)
208209
output.mkdir(parents=True, exist_ok=True)
@@ -253,6 +254,14 @@ def build_advisory_artifacts(
253254
git_sha=os.environ.get("GITHUB_SHA"),
254255
run_id=os.environ.get("GITHUB_RUN_ID"),
255256
run_attempt=os.environ.get("GITHUB_RUN_ATTEMPT"),
257+
upstream_repo_shas=upstream_repo_shas,
258+
input_paths={
259+
"political_events": political_events_path,
260+
"political_watchlist": political_watchlist_path,
261+
"ai_signal": resolved_ai_signal,
262+
"theme_momentum": resolved_theme_momentum,
263+
"market_confirmation": market_path,
264+
},
256265
)
257266

258267
monthly_review_json: Path | None = None
@@ -357,6 +366,7 @@ def build_arg_parser() -> argparse.ArgumentParser:
357366
parser.add_argument("--site-url", default=DEFAULT_SITE_URL)
358367
parser.add_argument("--feed-title", default=DEFAULT_FEED_TITLE)
359368
parser.add_argument("--recover-site-archive", action="store_true", help="Recover prior report JSONs from published site index.")
369+
parser.add_argument("--upstream-repo-sha", action="append", default=[], metavar="REPO=SHA")
360370
return parser
361371

362372

@@ -388,6 +398,7 @@ def main(argv: list[str] | None = None) -> None:
388398
site_url=args.site_url,
389399
feed_title=args.feed_title,
390400
recover_site_archive=args.recover_site_archive,
401+
upstream_repo_shas={key: value for item in args.upstream_repo_sha for key, value in [item.split("=", 1)] if key and value},
391402
)
392403
print(
393404
"advisory_artifacts_built "

src/quant_advisor_research/market_confirmation.py

Lines changed: 8 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -50,6 +50,7 @@
5050
"XLE",
5151
"XLK",
5252
}
53+
EXTREME_RETURN_63D_THRESHOLD = 2.0
5354

5455

5556
@dataclass(frozen=True)
@@ -295,7 +296,12 @@ def compute_market_confirmation(
295296
volatility_21d=volatility_21d,
296297
)
297298
price_age_days = max(((requested_as_of or bars[-1].date) - bars[-1].date).days, 0)
298-
confirmation_quality = "price_observed" if price_age_days <= 7 else "stale_price"
299+
warnings = [item for item in (warning.split(";") if warning else []) if item]
300+
if abs(return_63d) > EXTREME_RETURN_63D_THRESHOLD:
301+
warnings.append("extreme_return_63d")
302+
if price_age_days > 7:
303+
warnings.append("stale_price")
304+
confirmation_quality = "anomalous" if "extreme_return_63d" in warnings else "price_observed" if price_age_days <= 7 else "stale_price"
299305
return MarketConfirmationRow(
300306
symbol=symbol.upper(),
301307
as_of=bars[-1].date,
@@ -312,7 +318,7 @@ def compute_market_confirmation(
312318
price_observation_count=len(bars),
313319
price_age_days=price_age_days,
314320
confirmation_quality=confirmation_quality,
315-
warnings=warning,
321+
warnings=";".join(dict.fromkeys(warnings)),
316322
)
317323

318324

tests/test_advisory_report.py

Lines changed: 41 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -34,6 +34,47 @@ def test_build_advisory_report_blocks_execution_and_allocation() -> None:
3434
assert report["recommendations"]
3535

3636

37+
def test_expired_ai_signal_is_excluded_from_long_context_and_warns(tmp_path: Path) -> None:
38+
signal = json.loads((ROOT / "examples/research_signal_context.example.json").read_text(encoding="utf-8"))
39+
signal["expires_at"] = "2026-05-01"
40+
signal_path = tmp_path / "expired_signal.json"
41+
signal_path.write_text(json.dumps(signal), encoding="utf-8")
42+
43+
report = build_advisory_report(
44+
as_of="2026-05-30",
45+
cadence="weekly",
46+
political_events_path=ROOT / "examples/political_events.example.csv",
47+
political_watchlist_path=ROOT / "examples/political_watchlist.example.csv",
48+
ai_signal_path=signal_path,
49+
)
50+
51+
assert any("ai_signal_expired" in warning for warning in report["summary"]["data_quality_warnings"])
52+
assert report["summary"]["long_context_available"] is False
53+
54+
55+
def test_manifest_records_input_hash_and_upstream_metadata(tmp_path: Path) -> None:
56+
report_path = tmp_path / "report.json"
57+
markdown_path = tmp_path / "report.md"
58+
report_path.write_text("{}\n", encoding="utf-8")
59+
markdown_path.write_text("# report\n", encoding="utf-8")
60+
input_path = ROOT / "examples/political_events.example.csv"
61+
report = {"as_of": "2026-05-30", "cadence": "weekly", "schema_version": "5", "mode": "model_recommendations"}
62+
63+
manifest_path = write_report_manifest(
64+
report=report,
65+
report_path=report_path,
66+
markdown_path=markdown_path,
67+
manifest_path=tmp_path / "report.manifest.json",
68+
upstream_repo_shas={"QuantStrategyLab/PoliticalEventTrackingResearch": "abc123"},
69+
input_paths={"political_events": input_path},
70+
)
71+
72+
manifest = json.loads(manifest_path.read_text(encoding="utf-8"))
73+
assert manifest["upstream_repositories"]["QuantStrategyLab/PoliticalEventTrackingResearch"] == "abc123"
74+
assert manifest["source_artifacts"]["political_events"]["sha256"]
75+
assert manifest["source_artifacts"]["political_events"]["schema"].startswith("event_id,")
76+
77+
3778
def test_low_confidence_events_remain_verify_source_until_verified() -> None:
3879
report = build_advisory_report(
3980
as_of="2026-05-30",

tests/test_market_confirmation.py

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -58,6 +58,18 @@ def test_compute_market_confirmation_uses_relative_strength_volume_drawdown_and_
5858
assert row.confirmation_quality == "price_observed"
5959

6060

61+
def test_extreme_return_is_marked_as_data_quality_anomaly() -> None:
62+
start = dt.date(2026, 1, 1)
63+
symbol_bars = make_bars(start, [100.0] * 69 + [350.0])
64+
benchmark_bars = make_bars(start, [100.0] * 70)
65+
66+
row = compute_market_confirmation("MU", symbol_bars, benchmark_bars, data_source="unit_test")
67+
68+
assert row is not None
69+
assert row.confirmation_quality == "anomalous"
70+
assert "extreme_return_63d" in row.warnings
71+
72+
6173
def test_market_confirmation_falls_back_to_theme_momentum_without_network(tmp_path: Path) -> None:
6274
theme_payload = {
6375
"as_of": "2026-05-29",

0 commit comments

Comments
 (0)