Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
14 changes: 14 additions & 0 deletions docs/free_source_setup.md
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,20 @@
- `data/live/political_watchlist.csv`: initial watchlist for the live publish path.
- `data/live/source_items.csv`: latest raw text pulled by the scheduled RSS pipeline.
- `data/live/source_events.csv`: events extracted deterministically from `source_items.csv`.

`source_events.csv` keeps the original columns and adds the following compatible
evidence columns: `entity_match_type`, `match_evidence`, and
`relationship_type`. Values are `issuer`, `direct_beneficiary`,
Comment on lines +13 to +15

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Regenerate committed live artifacts for the new schema

This new runbook says source_events.csv has the evidence columns, but the committed data/live/source_events.csv and data/live/political_events.csv in this same commit still have the legacy header and data/live/source_tracker.csv still reflects the pre-filter generic scores. In a fresh checkout, the new loader defaults those live events to unverified, 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 👍 / 👎.

`industry_context`, or `unverified`. Only the first two are company-level
evidence; industry vocabulary is retained for auditability but is not scored as
an issuer event. Rows without a verifiable entity match are not emitted by the
live extractor. Legacy imported rows default to `unverified` and therefore fail
closed in downstream company-event scoring.

Historical event-study compatibility is opt-in only. Use
`--historical-compatibility --compatibility-reason "..."` for a pre-schema CSV.
Rows remain `unverified`; output records include `compatibility_used`,
`compatibility_reason`, and `legacy_provenance`.
- `data/live/political_events.csv`: stable Advisor input, refreshed by RSS/source pipeline or maintained after manual review.
- `data/live/source_tracker.csv`: merged watchlist and event tracker.

Expand Down
11 changes: 11 additions & 0 deletions docs/free_source_setup.zh-CN.md
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,17 @@
- `data/live/political_watchlist.csv`:真实发布链路的初始观察池。
- `data/live/source_items.csv`:定时 RSS pipeline 最近一次拉取的公开原始文本。
- `data/live/source_events.csv`:从 `source_items.csv` 确定性抽取出的事件。

`source_events.csv` 保留原有字段,并增加兼容字段:
`entity_match_type`、`match_evidence`、`relationship_type`。关系值为
`issuer`、`direct_beneficiary`、`industry_context` 或 `unverified`。
只有前两类可作为公司级证据;行业通用词会保留用于审计,但不会进入公司事件评分。
live 抽取器不会输出无法验证实体关联的行;旧格式导入行默认为
`unverified`,下游公司事件评分会 fail-closed。
历史 event study 兼容模式必须显式启用并提供原因:
`--historical-compatibility --compatibility-reason "..."`。旧行仍保持
`unverified`,输出记录 `compatibility_used`、`compatibility_reason` 和
`legacy_provenance`,不会自动升级为 verified。
- `data/live/political_events.csv`:Advisor 稳定读取的真实事件输入,由 RSS/source pipeline 刷新,也可以人工核验后维护。
- `data/live/source_tracker.csv`:观察池与事件合并后的 tracker。

Expand Down
54 changes: 50 additions & 4 deletions src/political_event_tracking_research/event_study.py
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,12 @@ class Event:
confidence: str
source_url: str
notes: str
entity_match_type: str = "unverified"

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Exclude context-only matches from event studies

Adding the evidence type here does not stop run_event_study from processing context-only rows; compute_event_returns still iterates every loaded event. Since the source workflows copy extractor output, which now retains industry_context rows, into political_events.csv, running the event-study script on that output will produce returns for generic CHIPS/cybersecurity/tokenization hits that are explicitly not company-level evidence.

Useful? React with 👍 / 👎.

match_evidence: str = ""
relationship_type: str = "unverified"
legacy_compatibility: bool = False
compatibility_reason: str = ""
legacy_provenance: str = ""


@dataclass(frozen=True)
Expand All @@ -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 {
Expand All @@ -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"],
Expand All @@ -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
Expand Down Expand Up @@ -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:
Expand Down Expand Up @@ -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 "",
)
)

Expand All @@ -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,
[
Expand All @@ -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],
)
Expand All @@ -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


Expand All @@ -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,
)


Expand Down
29 changes: 28 additions & 1 deletion src/political_event_tracking_research/official_event_import.py
Original file line number Diff line number Diff line change
Expand Up @@ -45,6 +45,9 @@ class OfficialRecord:
direction: str
source_url: str
summary: str
entity_match_type: str = "unverified"
match_evidence: str = ""
relationship_type: str = "unverified"


def slug(value: str) -> str:
Expand Down Expand Up @@ -76,6 +79,9 @@ def load_official_records(path: str | Path) -> list[OfficialRecord]:
direction=row.get("direction", ""),
source_url=row["source_url"],
summary=row.get("summary", ""),
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",
)
)
return records
Expand All @@ -87,6 +93,12 @@ def validate_record(record: OfficialRecord) -> None:
raise ValueError("record_id is required")
if not record.symbol.strip():
raise ValueError(f"{record.record_id}: symbol is required")
if record.entity_match_type not in {"issuer", "direct_beneficiary", "industry_context", "unverified"}:
raise ValueError(f"{record.record_id}: unsupported entity_match_type {record.entity_match_type!r}")
if record.relationship_type not in {"issuer", "direct_beneficiary", "industry_context", "unverified"}:
raise ValueError(f"{record.record_id}: unsupported relationship_type {record.relationship_type!r}")
if record.entity_match_type != record.relationship_type:
raise ValueError(f"{record.record_id}: entity_match_type and relationship_type must agree")
if record.event_type not in ALLOWED_EVENT_TYPES:
raise ValueError(f"{record.record_id}: unsupported event_type {record.event_type!r}")
if record.source_type in GOVERNMENT_SOURCE_TYPES:
Expand Down Expand Up @@ -125,6 +137,9 @@ def normalize_records(records: list[OfficialRecord]) -> list[dict[str, str]]:
"confidence": confidence_for_source(record),
"source_url": record.source_url,
"notes": record.summary,
"entity_match_type": record.entity_match_type,
"match_evidence": record.match_evidence,
"relationship_type": record.relationship_type,
}
)
rows.sort(key=lambda row: (row["event_date"], row["symbol"], row["event_id"]))
Expand All @@ -136,7 +151,19 @@ def import_official_events(input_path: str | Path, output_path: str | Path) -> l
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
Expand Down
89 changes: 83 additions & 6 deletions src/political_event_tracking_research/source_mention_extract.py
Original file line number Diff line number Diff line change
Expand Up @@ -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

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Treat generic crypto-exchange aliases as context

The core COIN aliases include crypto exchange and crypto asset transactions, but only crypto assets/digital assets are denied here. A regulatory or RSS item that says only crypto exchanges or crypto asset transactions therefore emits an issuer relationship for Coinbase and passes the new tracker/event-study company filters, even though it is the same kind of industry vocabulary this change is trying to retain only for auditability.

Useful? React with 👍 / 👎.

"energy-related infrastructure",

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Normalize generic energy aliases before scoring

The checked alias config includes the unhyphenated energy related infrastructure variant for CVX/NEE/VRT/XOM, but this denylist only contains the hyphenated spelling and _is_generic_alias does not collapse spaces versus hyphens. In RSS/source pipeline runs where an item uses the unhyphenated phrase, match_evidence classifies all four symbols as issuer, so the new tracker/event-study filters still score a generic industry mention as company-level evidence; add the unhyphenated variant or canonicalize punctuation/spacing before the denylist lookup.

Useful? React with 👍 / 👎.

"foundry",

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Exempt symbol-specific product aliases from the generic denylist

The core alias config uses Foundry as a PLTR product alias, but this global denylist labels every foundry match as industry_context. For articles or official records that mention Palantir's Foundry product without also saying Palantir, the extractor emits only a context row for PLTR and the tracker/event-study filters discard it; the denylist needs symbol-specific handling or an exemption for product aliases.

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
Expand All @@ -24,6 +49,7 @@ class RawSourceItem:
class MentionAlias:
symbol: str
aliases: tuple[str, ...]
name: str = ""


def load_raw_items(path: str | Path) -> list[RawSourceItem]:
Expand Down Expand Up @@ -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


Expand Down Expand Up @@ -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():

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Handle generic ticker acronyms before issuer scoring

SMR is both a shipped symbol alias (config/core_us_equity_aliases.csv:35) and a common abbreviation for small modular reactor. With this blanket symbol exemption, a government-policy item that says only SMR and not NuScale is classified as issuer, so it passes the new verified-event filters and gets scored/studied as NuScale company evidence; add symbol-specific ambiguity handling, such as requiring a cashtag/company name or classifying this acronym as context, before returning false here.

Useful? React with 👍 / 👎.

return False
return normalized.casefold() in GENERIC_ALIAS_DENYLIST

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Classify advanced packaging as context-only

The shipped INTC aliases include advanced packaging (config/core_us_equity_aliases.csv:16), and the checked live NIST NAPMP item is about the National Advanced Packaging Manufacturing Program without naming Intel (data/live/source_items.csv:25). Since this denylist membership is the only generic check, that row extracts as relationship_type=issuer and build_tracker scores INTC as policy_triggered; add this industry phrase, or normalize all config context aliases, so generic CHIPS R&D items stay audit-only.

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()))

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Avoid promoting nested generic Bitcoin matches

When a source item contains only the generic phrase Bitcoin treasury, the loop records that alias as industry_context but also matches the shorter Bitcoin alias in the same span; this max then chooses the higher-priority issuer match. Because the core MSTR aliases include both phrases, generic Bitcoin-treasury policy/news items still pass the new company-level filters and get scored as MSTR issuer events.

Useful? React with 👍 / 👎.



def infer_event_type(item: RawSourceItem) -> str:
source_type = item.source_type
text = item.text.lower()
Expand Down Expand Up @@ -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
Expand Down
Loading
Loading