Skip to content

Commit 0195daf

Browse files
Pigbibicodex
andcommitted
fix: isolate malformed inputs and gate legacy replay
Co-Authored-By: Codex <noreply@openai.com>
1 parent f61d14a commit 0195daf

4 files changed

Lines changed: 107 additions & 8 deletions

File tree

docs/advisory_contract.md

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -277,6 +277,11 @@ workflows pass the checked-out commit SHA for each upstream repository in
277277
`upstream_repositories`; missing or stale context is excluded from scoring and
278278
reported in `summary.data_quality_warnings`.
279279

280+
Legacy market-confirmation CSVs remain readable for audit but have `score=None`
281+
by default. Explicit historical/replay compatibility mode may reconstruct a
282+
quality gate from legacy fields and records `compatibility_used`, reason, and
283+
provenance; scheduled/live workflows never enable this mode.
284+
280285
## Source Mode
281286

282287
`summary.source_mode` is:

src/quant_advisor_research/advisory_report.py

Lines changed: 60 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -211,6 +211,9 @@ class MarketConfirmation:
211211
price_observation_count: int = 0
212212
confirmation_quality: str = ""
213213
warnings: str = ""
214+
compatibility_used: bool = False
215+
compatibility_reason: str = ""
216+
compatibility_provenance: str = ""
214217

215218

216219
def parse_date(value: str) -> dt.date:
@@ -316,13 +319,23 @@ def apply_input_freshness(
316319
warnings.append("theme_momentum_invalid_theme_ranks")
317320
theme_momentum = None
318321
return ai_signal, theme_momentum, warnings
319-
if any(
320-
not isinstance(theme, dict) or not isinstance(theme.get("top_symbols"), list)
321-
for theme in theme_ranks
322-
):
322+
if any(not isinstance(theme, dict) or not isinstance(theme.get("top_symbols"), list) for theme in theme_ranks):
323323
warnings.append("theme_momentum_invalid_top_symbols")
324324
theme_momentum = None
325325
return ai_signal, theme_momentum, warnings
326+
sanitized_ranks: list[dict[str, Any]] = []
327+
invalid_symbol_count = 0
328+
for theme in theme_ranks:
329+
symbols = []
330+
for item in theme["top_symbols"]:
331+
if isinstance(item, dict) and str(item.get("symbol", "")).strip():
332+
symbols.append(item)
333+
else:
334+
invalid_symbol_count += 1
335+
sanitized_ranks.append({**theme, "top_symbols": symbols})
336+
if invalid_symbol_count:
337+
warnings.append(f"theme_momentum_symbols_excluded:{invalid_symbol_count}")
338+
theme_momentum = {**theme_momentum, "theme_ranks": sanitized_ranks}
326339
extreme = any(
327340
abs(as_float(item.get("return_3m"))) > 2.0
328341
for theme in theme_ranks
@@ -408,7 +421,9 @@ def optional_date(value: Any) -> dt.date | None:
408421
return parse_date(text)
409422

410423

411-
def load_market_confirmation(path: str | Path | None, as_of: dt.date) -> dict[str, MarketConfirmation]:
424+
def load_market_confirmation(
425+
path: str | Path | None, as_of: dt.date, *, compatibility_mode: bool = False
426+
) -> dict[str, MarketConfirmation]:
412427
if path is None:
413428
return {}
414429
confirmations: dict[str, MarketConfirmation] = {}
@@ -423,6 +438,20 @@ def load_market_confirmation(path: str | Path | None, as_of: dt.date) -> dict[st
423438
if current and current.as_of and row_as_of and row_as_of < current.as_of:
424439
continue
425440
quality_metadata_present = "confirmation_quality" in row and "warnings" in row
441+
compatibility_used = False
442+
compatibility_reason = ""
443+
compatibility_provenance = ""
444+
reconstructed_quality = ""
445+
if not quality_metadata_present and compatibility_mode:
446+
compatibility_used = True
447+
compatibility_reason = "legacy_csv_quality_reconstructed"
448+
compatibility_provenance = "legacy_csv_fields:as_of,return_63d,data_source,price_age_days"
449+
if row_as_of is None or (as_of - row_as_of).days > 7:
450+
reconstructed_quality = "stale_price"
451+
elif abs(as_float(row.get("return_63d"))) > 2.0:
452+
reconstructed_quality = "anomalous"
453+
else:
454+
reconstructed_quality = "price_observed"
426455
confirmations[symbol] = MarketConfirmation(
427456
symbol=symbol,
428457
as_of=row_as_of,
@@ -439,8 +468,12 @@ def load_market_confirmation(path: str | Path | None, as_of: dt.date) -> dict[st
439468
price_observation_count=int(as_float(row.get("price_observation_count"))),
440469
confirmation_quality=(str(row.get("confirmation_quality", "")).strip() or "missing_quality_metadata")
441470
if quality_metadata_present
442-
else "missing_quality_metadata",
443-
warnings=str(row.get("warnings", "")),
471+
else reconstructed_quality or "missing_quality_metadata",
472+
warnings=str(row.get("warnings", ""))
473+
or ("legacy_market_confirmation_compatibility_used" if compatibility_used else ""),
474+
compatibility_used=compatibility_used,
475+
compatibility_reason=compatibility_reason,
476+
compatibility_provenance=compatibility_provenance,
444477
)
445478
return confirmations
446479

@@ -1063,6 +1096,8 @@ def market_confirmation_score(market: MarketConfirmation | None) -> float | None
10631096
def market_quality_warnings(confirmations: dict[str, MarketConfirmation]) -> list[str]:
10641097
warnings: list[str] = []
10651098
for symbol, market in sorted(confirmations.items()):
1099+
if market.compatibility_used:
1100+
warnings.append(f"market_confirmation_compatibility_used:{symbol}:{market.compatibility_reason}")
10661101
if market.confirmation_quality == "price_observed":
10671102
continue
10681103
quality = market.confirmation_quality or "missing_quality_metadata"
@@ -1520,6 +1555,7 @@ def build_advisory_report(
15201555
theme_momentum_path: str | Path | None = None,
15211556
market_confirmation_path: str | Path | None = None,
15221557
max_candidates: int = 12,
1558+
market_compatibility_mode: bool = False,
15231559
) -> dict[str, Any]:
15241560
if cadence not in ALLOWED_CADENCES:
15251561
raise ValueError(f"cadence must be one of: {', '.join(sorted(ALLOWED_CADENCES))}")
@@ -1531,7 +1567,9 @@ def build_advisory_report(
15311567
ai_signal, theme_momentum, freshness_warnings = apply_input_freshness(
15321568
ai_signal=ai_signal, theme_momentum=theme_momentum, as_of=as_of_date
15331569
)
1534-
market_confirmations = load_market_confirmation(market_confirmation_path, as_of_date)
1570+
market_confirmations = load_market_confirmation(
1571+
market_confirmation_path, as_of_date, compatibility_mode=market_compatibility_mode
1572+
)
15351573
theme_momentum_summary = summarize_theme_momentum(theme_momentum)
15361574
source_mode, data_quality_warnings = source_mode_for_paths(
15371575
political_events_path,
@@ -1598,6 +1636,18 @@ def build_advisory_report(
15981636
"top_theme_ids": [theme["theme_id"] for theme in theme_momentum_summary["top_themes"]],
15991637
"theme_first_candidate_count": len(theme_first_candidates),
16001638
"market_confirmation_count": len(market_confirmations),
1639+
"market_confirmation_compatibility_used": any(
1640+
item.compatibility_used for item in market_confirmations.values()
1641+
),
1642+
"market_confirmation_compatibility": [
1643+
{
1644+
"symbol": symbol,
1645+
"reason": item.compatibility_reason,
1646+
"provenance": item.compatibility_provenance,
1647+
}
1648+
for symbol, item in sorted(market_confirmations.items())
1649+
if item.compatibility_used
1650+
],
16011651
"long_context_available": bool(long_context_symbols),
16021652
"long_context_symbol_count": len(long_context_symbols),
16031653
"long_context_symbols": long_context_symbols[:12],
@@ -1749,6 +1799,7 @@ def build_arg_parser() -> argparse.ArgumentParser:
17491799
parser.add_argument("--ai-signal", help="Saved AI shadow signal JSON.")
17501800
parser.add_argument("--theme-momentum", help="Saved theme momentum snapshot JSON.")
17511801
parser.add_argument("--market-confirmation", help="Optional point-in-time market confirmation CSV.")
1802+
parser.add_argument("--market-compatibility-mode", action="store_true", help="Explicit historical/replay mode for legacy market CSVs.")
17521803
parser.add_argument("--max-items", "--max-candidates", dest="max_candidates", type=int, default=12)
17531804
parser.add_argument("--output-json", required=True, help="Output JSON artifact path.")
17541805
parser.add_argument("--output-md", required=True, help="Output Markdown report path.")
@@ -1766,6 +1817,7 @@ def main(argv: list[str] | None = None) -> None:
17661817
ai_signal_path=args.ai_signal,
17671818
theme_momentum_path=args.theme_momentum,
17681819
market_confirmation_path=args.market_confirmation,
1820+
market_compatibility_mode=args.market_compatibility_mode,
17691821
max_candidates=args.max_candidates,
17701822
)
17711823
write_json(args.output_json, report)

src/quant_advisor_research/build_pipeline.py

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -204,6 +204,7 @@ def build_advisory_artifacts(
204204
feed_title: str = DEFAULT_FEED_TITLE,
205205
recover_site_archive: bool = False,
206206
upstream_repo_shas: dict[str, str] | None = None,
207+
market_compatibility_mode: bool = False,
207208
) -> BuildPipelineResult:
208209
output = Path(output_dir)
209210
output.mkdir(parents=True, exist_ok=True)
@@ -242,6 +243,7 @@ def build_advisory_artifacts(
242243
theme_momentum_path=resolved_theme_momentum,
243244
market_confirmation_path=market_path,
244245
max_candidates=max_candidates,
246+
market_compatibility_mode=market_compatibility_mode,
245247
)
246248
write_json(report_json, report)
247249
write_text(report_md, render_markdown(report))
@@ -348,6 +350,7 @@ def build_arg_parser() -> argparse.ArgumentParser:
348350
parser.add_argument("--ai-signal", help="Research signal context JSON.")
349351
parser.add_argument("--theme-momentum", help="Theme momentum snapshot JSON. Missing files are skipped.")
350352
parser.add_argument("--market-confirmation", help="Optional prebuilt market confirmation CSV.")
353+
parser.add_argument("--market-compatibility-mode", action="store_true", help="Explicit historical/replay mode for legacy market CSVs.")
351354
parser.add_argument("--output-dir", required=True, help="Output artifact directory.")
352355
parser.add_argument("--max-items", "--max-candidates", dest="max_candidates", type=int, default=12)
353356
parser.add_argument("--market-benchmark", default="SPY")
@@ -399,6 +402,7 @@ def main(argv: list[str] | None = None) -> None:
399402
feed_title=args.feed_title,
400403
recover_site_archive=args.recover_site_archive,
401404
upstream_repo_shas={key: value for item in args.upstream_repo_sha for key, value in [item.split("=", 1)] if key and value},
405+
market_compatibility_mode=args.market_compatibility_mode,
402406
)
403407
print(
404408
"advisory_artifacts_built "

tests/test_advisory_report.py

Lines changed: 38 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -70,6 +70,17 @@ def test_legacy_market_confirmation_without_quality_metadata_cannot_score(tmp_pa
7070
assert report["final_decisions"]["recommendations"] == []
7171
assert any("market_confirmation_excluded:MU:missing_quality_metadata" in warning for warning in report["summary"]["data_quality_warnings"])
7272

73+
replay = build_advisory_report(
74+
as_of="2026-05-30",
75+
cadence="weekly",
76+
political_events_path=ROOT / "examples/political_events.example.csv",
77+
political_watchlist_path=ROOT / "examples/political_watchlist.example.csv",
78+
market_confirmation_path=market_path,
79+
market_compatibility_mode=True,
80+
)
81+
assert replay["summary"]["market_confirmation_compatibility_used"] is True
82+
assert any("compatibility_used:MU" in warning for warning in replay["summary"]["data_quality_warnings"])
83+
7384

7485
def test_invalid_theme_top_symbols_fails_closed_with_warning(tmp_path: Path) -> None:
7586
theme_path = tmp_path / "invalid_theme.json"
@@ -98,6 +109,33 @@ def test_invalid_theme_top_symbols_fails_closed_with_warning(tmp_path: Path) ->
98109
assert "theme_momentum_invalid_top_symbols" in report["summary"]["data_quality_warnings"]
99110

100111

112+
def test_invalid_theme_symbol_isolated_without_dropping_valid_symbol(tmp_path: Path) -> None:
113+
theme_path = tmp_path / "mixed_theme.json"
114+
theme_path.write_text(
115+
json.dumps(
116+
{
117+
"schema_version": "1",
118+
"as_of": "2026-05-30",
119+
"generated_at": "2026-05-30T00:00:00Z",
120+
"mode": "theme_momentum_snapshot",
121+
"policy": {"execution_allowed": False},
122+
"theme_ranks": [{"theme_id": "ok", "top_symbols": [None, {"symbol": "MU", "momentum_score": 0.8}]}],
123+
}
124+
),
125+
encoding="utf-8",
126+
)
127+
report = build_advisory_report(
128+
as_of="2026-05-30",
129+
cadence="weekly",
130+
political_events_path=ROOT / "examples/political_events.example.csv",
131+
political_watchlist_path=ROOT / "examples/political_watchlist.example.csv",
132+
theme_momentum_path=theme_path,
133+
)
134+
135+
assert report["theme_momentum"]["top_themes"][0]["top_symbols"] == ["MU"]
136+
assert "theme_momentum_symbols_excluded:1" in report["summary"]["data_quality_warnings"]
137+
138+
101139
def test_manifest_records_input_hash_and_upstream_metadata(tmp_path: Path) -> None:
102140
report_path = tmp_path / "report.json"
103141
markdown_path = tmp_path / "report.md"

0 commit comments

Comments
 (0)