Skip to content

Commit f8fa16d

Browse files
Pigbibicodex
andcommitted
fix: add bounded legacy event compatibility
Co-Authored-By: Codex <noreply@openai.com>
1 parent 953660f commit f8fa16d

6 files changed

Lines changed: 97 additions & 9 deletions

File tree

docs/free_source_setup.md

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,11 @@ evidence; industry vocabulary is retained for auditability but is not scored as
1818
an issuer event. Rows without a verifiable entity match are not emitted by the
1919
live extractor. Legacy imported rows default to `unverified` and therefore fail
2020
closed in downstream company-event scoring.
21+
22+
Historical event-study compatibility is opt-in only. Use
23+
`--historical-compatibility --compatibility-reason "..."` for a pre-schema CSV.
24+
Rows remain `unverified`; output records include `compatibility_used`,
25+
`compatibility_reason`, and `legacy_provenance`.
2126
- `data/live/political_events.csv`: stable Advisor input, refreshed by RSS/source pipeline or maintained after manual review.
2227
- `data/live/source_tracker.csv`: merged watchlist and event tracker.
2328

docs/free_source_setup.zh-CN.md

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,10 @@
1616
只有前两类可作为公司级证据;行业通用词会保留用于审计,但不会进入公司事件评分。
1717
live 抽取器不会输出无法验证实体关联的行;旧格式导入行默认为
1818
`unverified`,下游公司事件评分会 fail-closed。
19+
历史 event study 兼容模式必须显式启用并提供原因:
20+
`--historical-compatibility --compatibility-reason "..."`。旧行仍保持
21+
`unverified`,输出记录 `compatibility_used``compatibility_reason`
22+
`legacy_provenance`,不会自动升级为 verified。
1923
- `data/live/political_events.csv`:Advisor 稳定读取的真实事件输入,由 RSS/source pipeline 刷新,也可以人工核验后维护。
2024
- `data/live/source_tracker.csv`:观察池与事件合并后的 tracker。
2125

src/political_event_tracking_research/event_study.py

Lines changed: 42 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,9 @@ class Event:
2121
entity_match_type: str = "unverified"
2222
match_evidence: str = ""
2323
relationship_type: str = "unverified"
24+
legacy_compatibility: bool = False
25+
compatibility_reason: str = ""
26+
legacy_provenance: str = ""
2427

2528

2629
@dataclass(frozen=True)
@@ -38,6 +41,9 @@ class EventReturn:
3841
benchmark_symbol: str
3942
benchmark_return_pct: float | None
4043
abnormal_return_pct: float | None
44+
compatibility_used: bool = False
45+
compatibility_reason: str = ""
46+
legacy_provenance: str = ""
4147

4248
def to_row(self) -> dict[str, object]:
4349
return {
@@ -54,6 +60,9 @@ def to_row(self) -> dict[str, object]:
5460
"benchmark_symbol": self.benchmark_symbol,
5561
"benchmark_return_pct": "" if self.benchmark_return_pct is None else f"{self.benchmark_return_pct:.6f}",
5662
"abnormal_return_pct": "" if self.abnormal_return_pct is None else f"{self.abnormal_return_pct:.6f}",
63+
"compatibility_used": str(self.compatibility_used).lower(),
64+
"compatibility_reason": self.compatibility_reason,
65+
"legacy_provenance": self.legacy_provenance,
5766
}
5867

5968

@@ -65,9 +74,14 @@ def parse_date(value: str) -> date:
6574
return date.fromisoformat(value.strip())
6675

6776

68-
def load_events(path: str | Path) -> list[Event]:
77+
def load_events(path: str | Path, historical_compatibility: bool = False, compatibility_reason: str = "") -> list[Event]:
6978
events: list[Event] = []
70-
for row in read_csv_rows(path):
79+
rows = read_csv_rows(path)
80+
legacy_schema = bool(rows) and "relationship_type" not in rows[0]
81+
if historical_compatibility and not compatibility_reason.strip():
82+
raise ValueError("compatibility_reason is required for historical compatibility")
83+
for row in rows:
84+
legacy_compatibility = historical_compatibility and legacy_schema
7185
events.append(
7286
Event(
7387
event_id=row["event_id"],
@@ -81,6 +95,9 @@ def load_events(path: str | Path) -> list[Event]:
8195
entity_match_type=row.get("entity_match_type", "unverified") or "unverified",
8296
match_evidence=row.get("match_evidence", ""),
8397
relationship_type=row.get("relationship_type", "unverified") or "unverified",
98+
legacy_compatibility=legacy_compatibility,
99+
compatibility_reason=compatibility_reason if legacy_compatibility else "",
100+
legacy_provenance=str(path) if legacy_compatibility else "",
84101
)
85102
)
86103
return events
@@ -130,12 +147,14 @@ def compute_event_returns(
130147
prices: PriceTable,
131148
windows: tuple[int, ...] = (1, 5, 20),
132149
benchmark_symbol: str = "SPY",
150+
historical_compatibility: bool = False,
133151
) -> list[EventReturn]:
134152
results: list[EventReturn] = []
135153
benchmark_symbol = benchmark_symbol.upper()
136154

137155
for event in events:
138-
if event.relationship_type not in VERIFIED_RELATIONSHIPS:
156+
use_legacy = historical_compatibility and event.legacy_compatibility
157+
if event.relationship_type not in VERIFIED_RELATIONSHIPS and not use_legacy:
139158
continue
140159
symbol_dates = sorted_dates(prices, event.symbol)
141160
base_date = first_trading_date_on_or_after(symbol_dates, event.event_date)
@@ -175,6 +194,9 @@ def compute_event_returns(
175194
benchmark_symbol=benchmark_symbol,
176195
benchmark_return_pct=benchmark_return,
177196
abnormal_return_pct=abnormal_return,
197+
compatibility_used=use_legacy,
198+
compatibility_reason=event.compatibility_reason if use_legacy else "",
199+
legacy_provenance=event.legacy_provenance if use_legacy else "",
178200
)
179201
)
180202

@@ -196,10 +218,18 @@ def run_event_study(
196218
output_path: str | Path,
197219
windows: tuple[int, ...] = (1, 5, 20),
198220
benchmark_symbol: str = "SPY",
221+
historical_compatibility: bool = False,
222+
compatibility_reason: str = "",
199223
) -> list[EventReturn]:
200-
events = load_events(events_path)
224+
events = load_events(events_path, historical_compatibility=historical_compatibility, compatibility_reason=compatibility_reason)
201225
prices = load_prices(prices_path)
202-
results = compute_event_returns(events, prices, windows=windows, benchmark_symbol=benchmark_symbol)
226+
results = compute_event_returns(
227+
events,
228+
prices,
229+
windows=windows,
230+
benchmark_symbol=benchmark_symbol,
231+
historical_compatibility=historical_compatibility,
232+
)
203233
write_csv_rows(
204234
output_path,
205235
[
@@ -216,6 +246,9 @@ def run_event_study(
216246
"benchmark_symbol",
217247
"benchmark_return_pct",
218248
"abnormal_return_pct",
249+
"compatibility_used",
250+
"compatibility_reason",
251+
"legacy_provenance",
219252
],
220253
[result.to_row() for result in results],
221254
)
@@ -229,6 +262,8 @@ def build_arg_parser() -> argparse.ArgumentParser:
229262
parser.add_argument("--output", required=True, help="Output CSV path.")
230263
parser.add_argument("--windows", default="1,5,20", help="Comma-separated trading-day offsets.")
231264
parser.add_argument("--benchmark", default="SPY", help="Benchmark symbol for abnormal returns.")
265+
parser.add_argument("--historical-compatibility", action="store_true", help="Explicitly analyze legacy event CSVs.")
266+
parser.add_argument("--compatibility-reason", default="", help="Required audit reason for legacy compatibility.")
232267
return parser
233268

234269

@@ -240,6 +275,8 @@ def main(argv: list[str] | None = None) -> None:
240275
output_path=args.output,
241276
windows=parse_windows(args.windows),
242277
benchmark_symbol=args.benchmark,
278+
historical_compatibility=args.historical_compatibility,
279+
compatibility_reason=args.compatibility_reason,
243280
)
244281

245282

src/political_event_tracking_research/source_mention_extract.py

Lines changed: 6 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -124,13 +124,16 @@ def _is_generic_alias(alias_record: MentionAlias, alias: str) -> bool:
124124
return normalized.casefold() in GENERIC_ALIAS_DENYLIST
125125

126126

127-
def match_evidence(text: str, alias_record: MentionAlias) -> tuple[str, str] | None:
127+
def match_evidence(text: str, alias_record: MentionAlias, source_type: str = "") -> tuple[str, str] | None:
128128
normalized_text = normalize_match_text(text)
129129
matches: list[tuple[str, str]] = []
130130
for alias in alias_record.aliases:
131131
if not alias_pattern(alias).search(normalized_text):
132132
continue
133-
if _is_generic_alias(alias_record, alias):
133+
is_canonical_name = bool(alias_record.name) and alias.casefold() == alias_record.name.casefold()
134+
if is_canonical_name and source_type == "issuer_release":
135+
matches.append(("issuer", normalize_match_text(alias)))
136+
elif _is_generic_alias(alias_record, alias):
134137
matches.append(("industry_context", normalize_match_text(alias)))
135138
continue
136139
relationship = "direct_beneficiary" if re.search(
@@ -190,7 +193,7 @@ def extract_source_records(raw_items_path: str | Path, aliases_path: str | Path,
190193
records: list[OfficialRecord] = []
191194
for item in raw_items:
192195
for alias_record in aliases:
193-
evidence = match_evidence(item.text, alias_record)
196+
evidence = match_evidence(item.text, alias_record, item.source_type)
194197
if evidence is None:
195198
continue
196199
entity_match_type, matched_text = evidence

tests/test_event_study.py

Lines changed: 20 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,7 @@
44

55
import pytest
66

7-
from political_event_tracking_research.event_study import Event, compute_event_returns
7+
from political_event_tracking_research.event_study import Event, compute_event_returns, load_events
88

99

1010
def test_compute_event_returns_uses_next_available_trading_date_and_benchmark() -> None:
@@ -70,3 +70,22 @@ def test_compute_event_returns_excludes_non_company_relationships() -> None:
7070
prices = {"ABC": {date(2026, 1, 5): 100.0, date(2026, 1, 6): 110.0}}
7171

7272
assert compute_event_returns(events, prices, windows=(1,)) == []
73+
74+
75+
def test_legacy_event_csv_requires_explicit_compatibility_and_provenance(tmp_path) -> None:
76+
path = tmp_path / "legacy_events.csv"
77+
path.write_text(
78+
"event_id,event_date,symbol,event_type,direction,confidence,source_url,notes\n"
79+
"legacy-1,2026-01-02,ABC,public_mention,bullish,high,https://example.com,legacy\n",
80+
encoding="utf-8",
81+
)
82+
prices = {"ABC": {date(2026, 1, 5): 100.0, date(2026, 1, 6): 110.0}}
83+
84+
legacy = load_events(path, historical_compatibility=True, compatibility_reason="reproduce 2026 baseline")
85+
assert legacy[0].relationship_type == "unverified"
86+
assert compute_event_returns(legacy, prices, windows=(1,)) == []
87+
results = compute_event_returns(legacy, prices, windows=(1,), historical_compatibility=True)
88+
89+
assert results[0].compatibility_used is True
90+
assert results[0].compatibility_reason == "reproduce 2026 baseline"
91+
assert results[0].legacy_provenance == str(path)

tests/test_source_mention_extract.py

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -100,6 +100,26 @@ def test_explicit_issuer_match_is_company_level(tmp_path: Path) -> None:
100100
assert rows[0]["match_evidence"] == "Coinbase"
101101

102102

103+
def test_canonical_name_on_trusted_issuer_release_beats_generic_denylist(tmp_path: Path) -> None:
104+
raw_items = tmp_path / "source_items.csv"
105+
raw_items.write_text(
106+
"item_id,published_at,source_type,source_url,author,text\n"
107+
"issuer-strategy,2026-04-01T00:00:00Z,issuer_release,https://example.com/strategy,Strategy,"
108+
'Strategy announced a new product.\n'
109+
"policy-strategy,2026-04-01T00:00:00Z,government_policy,https://www.nist.gov/strategy,NIST,"
110+
'Strategy guidance was published.\n',
111+
encoding="utf-8",
112+
)
113+
aliases = tmp_path / "aliases.csv"
114+
aliases.write_text("symbol,name,aliases\nMSTR,Strategy,Strategy|MSTR\n", encoding="utf-8")
115+
116+
rows = extract_source_records(raw_items, aliases, tmp_path / "events.csv")
117+
by_id = {row["event_id"]: row for row in rows}
118+
119+
assert by_id["official-issuer-release-issuer-strategy-mstr"]["entity_match_type"] == "issuer"
120+
assert by_id["official-government-policy-policy-strategy-mstr"]["entity_match_type"] == "industry_context"
121+
122+
103123
def test_proper_noun_alternate_alias_remains_company_level(tmp_path: Path) -> None:
104124
raw_items = tmp_path / "source_items.csv"
105125
raw_items.write_text(

0 commit comments

Comments
 (0)