-
Notifications
You must be signed in to change notification settings - Fork 0
fix: fail closed on generic political entity matches #21
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -18,6 +18,12 @@ class Event: | |
| confidence: str | ||
| source_url: str | ||
| notes: str | ||
| entity_match_type: str = "unverified" | ||
|
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
Adding the evidence type here does not stop Useful? React with 👍 / 👎. |
||
| match_evidence: str = "" | ||
| relationship_type: str = "unverified" | ||
| legacy_compatibility: bool = False | ||
| compatibility_reason: str = "" | ||
| legacy_provenance: str = "" | ||
|
|
||
|
|
||
| @dataclass(frozen=True) | ||
|
|
@@ -35,6 +41,9 @@ class EventReturn: | |
| benchmark_symbol: str | ||
| benchmark_return_pct: float | None | ||
| abnormal_return_pct: float | None | ||
| compatibility_used: bool = False | ||
| compatibility_reason: str = "" | ||
| legacy_provenance: str = "" | ||
|
|
||
| def to_row(self) -> dict[str, object]: | ||
| return { | ||
|
|
@@ -51,19 +60,28 @@ def to_row(self) -> dict[str, object]: | |
| "benchmark_symbol": self.benchmark_symbol, | ||
| "benchmark_return_pct": "" if self.benchmark_return_pct is None else f"{self.benchmark_return_pct:.6f}", | ||
| "abnormal_return_pct": "" if self.abnormal_return_pct is None else f"{self.abnormal_return_pct:.6f}", | ||
| "compatibility_used": str(self.compatibility_used).lower(), | ||
| "compatibility_reason": self.compatibility_reason, | ||
| "legacy_provenance": self.legacy_provenance, | ||
| } | ||
|
|
||
|
|
||
| PriceTable = dict[str, dict[date, float]] | ||
| VERIFIED_RELATIONSHIPS = frozenset({"issuer", "direct_beneficiary"}) | ||
|
|
||
|
|
||
| def parse_date(value: str) -> date: | ||
| return date.fromisoformat(value.strip()) | ||
|
|
||
|
|
||
| def load_events(path: str | Path) -> list[Event]: | ||
| def load_events(path: str | Path, historical_compatibility: bool = False, compatibility_reason: str = "") -> list[Event]: | ||
| events: list[Event] = [] | ||
| for row in read_csv_rows(path): | ||
| rows = read_csv_rows(path) | ||
| legacy_schema = bool(rows) and "relationship_type" not in rows[0] | ||
| if historical_compatibility and not compatibility_reason.strip(): | ||
| raise ValueError("compatibility_reason is required for historical compatibility") | ||
| for row in rows: | ||
| legacy_compatibility = historical_compatibility and legacy_schema | ||
| events.append( | ||
| Event( | ||
| event_id=row["event_id"], | ||
|
|
@@ -74,6 +92,12 @@ def load_events(path: str | Path) -> list[Event]: | |
| confidence=row.get("confidence", ""), | ||
| source_url=row.get("source_url", ""), | ||
| notes=row.get("notes", ""), | ||
| entity_match_type=row.get("entity_match_type", "unverified") or "unverified", | ||
| match_evidence=row.get("match_evidence", ""), | ||
| relationship_type=row.get("relationship_type", "unverified") or "unverified", | ||
| legacy_compatibility=legacy_compatibility, | ||
| compatibility_reason=compatibility_reason if legacy_compatibility else "", | ||
| legacy_provenance=str(path) if legacy_compatibility else "", | ||
| ) | ||
| ) | ||
| return events | ||
|
|
@@ -123,11 +147,15 @@ def compute_event_returns( | |
| prices: PriceTable, | ||
| windows: tuple[int, ...] = (1, 5, 20), | ||
| benchmark_symbol: str = "SPY", | ||
| historical_compatibility: bool = False, | ||
| ) -> list[EventReturn]: | ||
| results: list[EventReturn] = [] | ||
| benchmark_symbol = benchmark_symbol.upper() | ||
|
|
||
| for event in events: | ||
| use_legacy = historical_compatibility and event.legacy_compatibility | ||
| if event.relationship_type not in VERIFIED_RELATIONSHIPS and not use_legacy: | ||
| continue | ||
| symbol_dates = sorted_dates(prices, event.symbol) | ||
| base_date = first_trading_date_on_or_after(symbol_dates, event.event_date) | ||
| if base_date is None: | ||
|
|
@@ -166,6 +194,9 @@ def compute_event_returns( | |
| benchmark_symbol=benchmark_symbol, | ||
| benchmark_return_pct=benchmark_return, | ||
| abnormal_return_pct=abnormal_return, | ||
| compatibility_used=use_legacy, | ||
| compatibility_reason=event.compatibility_reason if use_legacy else "", | ||
| legacy_provenance=event.legacy_provenance if use_legacy else "", | ||
| ) | ||
| ) | ||
|
|
||
|
|
@@ -187,10 +218,18 @@ def run_event_study( | |
| output_path: str | Path, | ||
| windows: tuple[int, ...] = (1, 5, 20), | ||
| benchmark_symbol: str = "SPY", | ||
| historical_compatibility: bool = False, | ||
| compatibility_reason: str = "", | ||
| ) -> list[EventReturn]: | ||
| events = load_events(events_path) | ||
| events = load_events(events_path, historical_compatibility=historical_compatibility, compatibility_reason=compatibility_reason) | ||
| prices = load_prices(prices_path) | ||
| results = compute_event_returns(events, prices, windows=windows, benchmark_symbol=benchmark_symbol) | ||
| results = compute_event_returns( | ||
| events, | ||
| prices, | ||
| windows=windows, | ||
| benchmark_symbol=benchmark_symbol, | ||
| historical_compatibility=historical_compatibility, | ||
| ) | ||
| write_csv_rows( | ||
| output_path, | ||
| [ | ||
|
|
@@ -207,6 +246,9 @@ def run_event_study( | |
| "benchmark_symbol", | ||
| "benchmark_return_pct", | ||
| "abnormal_return_pct", | ||
| "compatibility_used", | ||
| "compatibility_reason", | ||
| "legacy_provenance", | ||
| ], | ||
| [result.to_row() for result in results], | ||
| ) | ||
|
|
@@ -220,6 +262,8 @@ def build_arg_parser() -> argparse.ArgumentParser: | |
| parser.add_argument("--output", required=True, help="Output CSV path.") | ||
| parser.add_argument("--windows", default="1,5,20", help="Comma-separated trading-day offsets.") | ||
| parser.add_argument("--benchmark", default="SPY", help="Benchmark symbol for abnormal returns.") | ||
| parser.add_argument("--historical-compatibility", action="store_true", help="Explicitly analyze legacy event CSVs.") | ||
| parser.add_argument("--compatibility-reason", default="", help="Required audit reason for legacy compatibility.") | ||
| return parser | ||
|
|
||
|
|
||
|
|
@@ -231,6 +275,8 @@ def main(argv: list[str] | None = None) -> None: | |
| output_path=args.output, | ||
| windows=parse_windows(args.windows), | ||
| benchmark_symbol=args.benchmark, | ||
| historical_compatibility=args.historical_compatibility, | ||
| compatibility_reason=args.compatibility_reason, | ||
| ) | ||
|
|
||
|
|
||
|
|
||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -10,6 +10,31 @@ | |
| from .official_event_import import OfficialRecord, normalize_records | ||
|
|
||
|
|
||
| GENERIC_ALIAS_DENYLIST = frozenset( | ||
| { | ||
| "advanced nuclear", | ||
| "bitcoin treasury", | ||
| "chips act", | ||
| "crypto assets", | ||
| "cybersecurity", | ||
| "digital assets", | ||
|
Comment on lines
+18
to
+20
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
The core COIN aliases include Useful? React with 👍 / 👎. |
||
| "energy-related infrastructure", | ||
|
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
The checked alias config includes the unhyphenated Useful? React with 👍 / 👎. |
||
| "foundry", | ||
|
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
The core alias config uses Useful? React with 👍 / 👎. |
||
| "nuclear energy", | ||
| "nuclear reactor", | ||
| "semiconductor manufacturing", | ||
| "strategy", | ||
| "tokenization", | ||
| } | ||
| ) | ||
| RELATIONSHIP_PRIORITY = { | ||
| "unverified": 0, | ||
| "industry_context": 1, | ||
| "issuer": 2, | ||
| "direct_beneficiary": 3, | ||
| } | ||
|
|
||
|
|
||
| @dataclass(frozen=True) | ||
| class RawSourceItem: | ||
| item_id: str | ||
|
|
@@ -24,6 +49,7 @@ class RawSourceItem: | |
| class MentionAlias: | ||
| symbol: str | ||
| aliases: tuple[str, ...] | ||
| name: str = "" | ||
|
|
||
|
|
||
| def load_raw_items(path: str | Path) -> list[RawSourceItem]: | ||
|
|
@@ -51,10 +77,13 @@ def load_aliases(path: str | Path) -> list[MentionAlias]: | |
| records: list[MentionAlias] = [] | ||
| for row in read_csv_rows(path): | ||
| symbol = row["symbol"].upper() | ||
| name = row.get("name", "").strip() | ||
| aliases = split_aliases(row.get("aliases", "")) | ||
| if name and name.casefold() not in {alias.casefold() for alias in aliases}: | ||
| aliases = (name, *aliases) | ||
| if symbol not in aliases: | ||
| aliases = (symbol, *aliases) | ||
| records.append(MentionAlias(symbol=symbol, aliases=aliases)) | ||
| records.append(MentionAlias(symbol=symbol, aliases=aliases, name=name)) | ||
| return records | ||
|
|
||
|
|
||
|
|
@@ -88,6 +117,36 @@ def match_symbols(text: str, aliases: list[MentionAlias]) -> list[str]: | |
| return sorted(dict.fromkeys(matches)) | ||
|
|
||
|
|
||
| def _is_generic_alias(alias_record: MentionAlias, alias: str) -> bool: | ||
| normalized = normalize_match_text(alias).strip() | ||
| if normalized.upper() == alias_record.symbol.upper(): | ||
|
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
Useful? React with 👍 / 👎. |
||
| return False | ||
| return normalized.casefold() in GENERIC_ALIAS_DENYLIST | ||
|
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
The shipped INTC aliases include Useful? React with 👍 / 👎. |
||
|
|
||
|
|
||
| def match_evidence(text: str, alias_record: MentionAlias, source_type: str = "") -> tuple[str, str] | None: | ||
| normalized_text = normalize_match_text(text) | ||
| matches: list[tuple[str, str]] = [] | ||
| for alias in alias_record.aliases: | ||
| if not alias_pattern(alias).search(normalized_text): | ||
| continue | ||
| is_canonical_name = bool(alias_record.name) and alias.casefold() == alias_record.name.casefold() | ||
| if is_canonical_name and source_type == "issuer_release": | ||
| matches.append(("issuer", normalize_match_text(alias))) | ||
| elif _is_generic_alias(alias_record, alias): | ||
| matches.append(("industry_context", normalize_match_text(alias))) | ||
| continue | ||
| relationship = "direct_beneficiary" if re.search( | ||
| rf"(?:awarded to|contract with|funding for|benefit(?:s|ed)? from)\s+(?:the\s+)?{re.escape(normalize_match_text(alias))}", | ||
| normalized_text, | ||
| re.IGNORECASE, | ||
| ) else "issuer" | ||
| matches.append((relationship, normalize_match_text(alias))) | ||
| if not matches: | ||
| return None | ||
| return max(matches, key=lambda match: (RELATIONSHIP_PRIORITY[match[0]], match[1].casefold())) | ||
|
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
When a source item contains only the generic phrase Useful? React with 👍 / 👎. |
||
|
|
||
|
|
||
| def infer_event_type(item: RawSourceItem) -> str: | ||
| source_type = item.source_type | ||
| text = item.text.lower() | ||
|
|
@@ -133,24 +192,42 @@ def extract_source_records(raw_items_path: str | Path, aliases_path: str | Path, | |
| aliases = load_aliases(aliases_path) | ||
| records: list[OfficialRecord] = [] | ||
| for item in raw_items: | ||
| symbols = match_symbols(item.text, aliases) | ||
| for symbol in symbols: | ||
| for alias_record in aliases: | ||
| evidence = match_evidence(item.text, alias_record, item.source_type) | ||
| if evidence is None: | ||
| continue | ||
| entity_match_type, matched_text = evidence | ||
| records.append( | ||
| OfficialRecord( | ||
| record_id=f"{item.item_id}-{symbol.lower()}", | ||
| record_id=f"{item.item_id}-{alias_record.symbol.lower()}", | ||
| record_date=item_date(item), | ||
| symbol=symbol, | ||
| symbol=alias_record.symbol, | ||
| source_type=item.source_type, | ||
| event_type=infer_event_type(item), | ||
| direction=infer_direction(item.text), | ||
| source_url=item.source_url, | ||
| summary=f"{item.author}: {item.text}".strip(": "), | ||
| entity_match_type=entity_match_type, | ||
| match_evidence=matched_text, | ||
| relationship_type=entity_match_type, | ||
| ) | ||
| ) | ||
| rows = normalize_records(records) | ||
| write_csv_rows( | ||
| output_path, | ||
| ["event_id", "event_date", "symbol", "event_type", "direction", "confidence", "source_url", "notes"], | ||
| [ | ||
| "event_id", | ||
| "event_date", | ||
| "symbol", | ||
| "event_type", | ||
| "direction", | ||
| "confidence", | ||
| "source_url", | ||
| "notes", | ||
| "entity_match_type", | ||
| "match_evidence", | ||
| "relationship_type", | ||
| ], | ||
| rows, | ||
| ) | ||
| return rows | ||
|
|
||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
This new runbook says
source_events.csvhas the evidence columns, but the committeddata/live/source_events.csvanddata/live/political_events.csvin this same commit still have the legacy header anddata/live/source_tracker.csvstill reflects the pre-filter generic scores. In a fresh checkout, the new loader defaults those live events tounverified, so recomputing tracker/event-study drops all existing live events, while users reading the checked tracker see stale triggers; regenerate or migrate the live artifacts with the schema change.Useful? React with 👍 / 👎.