You cannot select more than 25 topics
Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
352 lines
12 KiB
Python
352 lines
12 KiB
Python
"""
|
|
Entity Resolver — maps ticker symbols to canonical company entities.
|
|
|
|
Resolution pipeline:
|
|
1. Look up company name from `companies` table
|
|
2. Normalize name (strip legal suffixes) → canonical_name + aliases
|
|
3. Validate against Wikipedia Search API → best wiki_title + confidence
|
|
4. Build GDELT query from canonical_name + aliases
|
|
5. Upsert into company_entity_map (respects is_manual_override flag)
|
|
"""
|
|
|
|
import logging
|
|
import re
|
|
from typing import Optional
|
|
|
|
import httpx
|
|
from sqlalchemy import select, update
|
|
from sqlalchemy.dialects.postgresql import insert as pg_insert
|
|
from sqlalchemy.ext.asyncio import AsyncSession
|
|
|
|
from app.models.attention import CompanyEntityMap
|
|
from app.models.financial import Company
|
|
|
|
logger = logging.getLogger(__name__)
|
|
|
|
# Legal suffixes to strip for canonical name derivation
|
|
_SUFFIX_PATTERN = re.compile(
|
|
r",?\s+\b(Inc\.?|Corp\.?|Corporation|Holdings?|Ltd\.?|Limited|LLC|L\.L\.C\.|"
|
|
r"Group|Co\.?|Company|Technologies|Technology|International|Industries|"
|
|
r"Pharmaceuticals?|Therapeutics?|Sciences?|Bancorp|Financial|Holding|"
|
|
r"Acquisition|Acquisitions|Capital|Partners|Trust|"
|
|
r"Com)\s*$", # "Com" catches SEC-style ".com" artifacts (e.g. "AMAZON COM")
|
|
re.IGNORECASE,
|
|
)
|
|
|
|
_COMPANY_KEYWORDS = {
|
|
"company", "corporation", "inc", "corp", "ltd", "llc", "holdings",
|
|
"stock", "shares", "nasdaq", "nyse", "ticker", "finance", "financial",
|
|
"investor", "business", "enterprise", "industries",
|
|
}
|
|
|
|
|
|
def _normalize_name(raw_name: str) -> tuple[str, list[str]]:
|
|
"""Return (canonical_name, aliases).
|
|
|
|
Strips common legal suffixes iteratively to produce a short canonical name.
|
|
Preserves the original and intermediate forms as aliases.
|
|
"""
|
|
aliases = []
|
|
current = raw_name.strip()
|
|
|
|
for _ in range(5): # max 5 iterations to avoid infinite loops
|
|
stripped = _SUFFIX_PATTERN.sub("", current).strip().rstrip(",").strip()
|
|
if stripped == current or not stripped:
|
|
break
|
|
aliases.append(current)
|
|
current = stripped
|
|
|
|
canonical = current
|
|
# Also add the fully original name if not already captured
|
|
if raw_name.strip() != canonical and raw_name.strip() not in aliases:
|
|
aliases.insert(0, raw_name.strip())
|
|
|
|
# Deduplicate while preserving order
|
|
seen = set()
|
|
unique_aliases = []
|
|
for a in aliases:
|
|
if a not in seen and a != canonical:
|
|
seen.add(a)
|
|
unique_aliases.append(a)
|
|
|
|
return canonical, unique_aliases
|
|
|
|
|
|
_WIKI_HEADERS = {
|
|
"User-Agent": "StockOracleBot/1.0 (attention-subsystem; contact@stockoracle.internal)"
|
|
}
|
|
|
|
_SEC_HEADERS = {
|
|
"User-Agent": "Stock Oracle contact@stockoracle.internal",
|
|
"Accept": "application/json",
|
|
}
|
|
|
|
# Module-level cache: populated on first SEC fetch, maps ticker.upper() → company title
|
|
_SEC_TICKER_MAP: dict[str, str] = {}
|
|
|
|
|
|
def _is_placeholder_name(ticker: str, canonical_name: str) -> bool:
|
|
"""Detect if the company name is just the ticker symbol repeated."""
|
|
return canonical_name.upper() == ticker.upper()
|
|
|
|
|
|
async def _fetch_sec_company_name(ticker: str) -> Optional[str]:
|
|
"""Fetch real company name from SEC company_tickers.json.
|
|
|
|
Caches the full ticker→name mapping on first call (~13K entries).
|
|
Returns the SEC `title` field for the given ticker, or None if not found.
|
|
"""
|
|
global _SEC_TICKER_MAP
|
|
if not _SEC_TICKER_MAP:
|
|
url = "https://www.sec.gov/files/company_tickers.json"
|
|
try:
|
|
async with httpx.AsyncClient(timeout=15.0, headers=_SEC_HEADERS) as client:
|
|
resp = await client.get(url)
|
|
resp.raise_for_status()
|
|
data = resp.json()
|
|
_SEC_TICKER_MAP = {
|
|
v["ticker"].upper(): v["title"]
|
|
for v in data.values()
|
|
if "ticker" in v and "title" in v
|
|
}
|
|
logger.info("Loaded %d tickers from SEC company_tickers.json", len(_SEC_TICKER_MAP))
|
|
except Exception as exc:
|
|
logger.warning("Failed to fetch SEC company_tickers.json: %s", exc)
|
|
return None
|
|
|
|
title = _SEC_TICKER_MAP.get(ticker.upper())
|
|
if title and title == title.upper() and not title.isnumeric():
|
|
# SEC stores many names in ALL-CAPS (e.g. "AMAZON COM INC") — title-case them
|
|
title = title.title()
|
|
return title
|
|
|
|
|
|
async def _search_wikipedia(query: str) -> list[dict]:
|
|
"""Call Wikipedia Search API and return raw results list."""
|
|
url = "https://en.wikipedia.org/w/api.php"
|
|
params = {
|
|
"action": "query",
|
|
"list": "search",
|
|
"srsearch": query,
|
|
"srlimit": 5,
|
|
"format": "json",
|
|
}
|
|
try:
|
|
async with httpx.AsyncClient(timeout=10.0, headers=_WIKI_HEADERS) as client:
|
|
resp = await client.get(url, params=params)
|
|
resp.raise_for_status()
|
|
data = resp.json()
|
|
return data.get("query", {}).get("search", [])
|
|
except Exception as exc:
|
|
logger.warning("Wikipedia search failed for %r: %s", query, exc)
|
|
return []
|
|
|
|
|
|
def _score_wiki_result(result: dict, canonical_name: str, aliases: list[str]) -> float:
|
|
"""Score a Wikipedia search result [0, 1] for company relevance.
|
|
|
|
Scoring strategy:
|
|
- Direct title match (title == canonical or alias): 0.9+
|
|
- Title starts with canonical: 0.7+
|
|
- Title contains canonical: 0.5 base
|
|
- No canonical in title: 0.0
|
|
- Non-company signals (album, film, etc.): capped at 0.1
|
|
"""
|
|
raw_title = result.get("title", "")
|
|
title = raw_title.lower()
|
|
snippet = result.get("snippet", "").lower()
|
|
combined = title + " " + snippet
|
|
|
|
# Penalize obvious non-company pages immediately
|
|
non_company_signals = ["album", "film", "television", "televisión", "song", "band",
|
|
"musician", "footballer", "athlete", "politician", "novel",
|
|
"book", "discography", "filmography", "radio station",
|
|
"broadcasting", "tv channel", "telev"]
|
|
if any(sig in combined for sig in non_company_signals):
|
|
return 0.1
|
|
|
|
# Require snippet to have at least one finance keyword for non-exact-match titles
|
|
finance_signals = ["company", "corporation", "stock", "nasdaq", "nyse", "shares",
|
|
"investor", "business", "enterprise", "holdings", "inc."]
|
|
if not any(sig in combined for sig in finance_signals):
|
|
return 0.15
|
|
|
|
canonical_lower = canonical_name.lower()
|
|
all_names = [canonical_name] + aliases
|
|
all_names_lower = [n.lower() for n in all_names]
|
|
|
|
# Check for exact title match (e.g. "Apple Inc." == alias "Apple Inc.")
|
|
raw_title_stripped = raw_title.strip()
|
|
for name in all_names:
|
|
if raw_title_stripped.lower() == name.lower():
|
|
return 0.95 # exact match
|
|
|
|
# Check if title starts with canonical name
|
|
title_starts_with_canonical = title.startswith(canonical_lower)
|
|
title_contains_canonical = canonical_lower in title
|
|
|
|
# Also check aliases
|
|
title_starts_with_alias = any(title.startswith(n) for n in all_names_lower)
|
|
title_contains_alias = any(n in title for n in all_names_lower)
|
|
|
|
if not (title_contains_canonical or title_contains_alias):
|
|
return 0.0
|
|
|
|
# Check for pages that are ABOUT the company (vs. lists, histories, etc.)
|
|
# Penalize titles that suggest subsidiary/list/history pages
|
|
secondary_patterns = ["list of", "history of", "acquisition", "merger",
|
|
"subsidiary", "criticism", "controversy"]
|
|
if any(pat in title for pat in secondary_patterns):
|
|
return 0.25
|
|
|
|
# Base score
|
|
if title_starts_with_canonical or title_starts_with_alias:
|
|
base = 0.7
|
|
elif title_contains_canonical or title_contains_alias:
|
|
base = 0.5
|
|
else:
|
|
base = 0.0
|
|
|
|
# Reward company/finance keywords in title or snippet
|
|
keyword_hits = sum(1 for kw in _COMPANY_KEYWORDS if kw in combined)
|
|
keyword_score = min(keyword_hits / 3, 1.0)
|
|
|
|
return min(base + keyword_score * 0.3, 1.0)
|
|
|
|
|
|
async def _resolve_wiki(
|
|
canonical_name: str,
|
|
aliases: list[str],
|
|
) -> tuple[Optional[str], float]:
|
|
"""Return (wiki_title, confidence). wiki_title is None if confidence < 0.5."""
|
|
# Try multiple search strategies, prefer more specific queries first
|
|
search_queries = []
|
|
# Try full original name first (e.g. "Apple Inc.")
|
|
for alias in aliases[:2]:
|
|
search_queries.append(alias)
|
|
# Then canonical
|
|
search_queries.append(f"{canonical_name} company")
|
|
|
|
best_title: Optional[str] = None
|
|
best_score = 0.0
|
|
|
|
for query in search_queries:
|
|
results = await _search_wikipedia(query)
|
|
for result in results:
|
|
score = _score_wiki_result(result, canonical_name, aliases)
|
|
if score > best_score:
|
|
best_score = score
|
|
best_title = result["title"]
|
|
if best_score >= 0.85:
|
|
break # good enough — no need to try more queries
|
|
|
|
if best_score < 0.5:
|
|
return None, best_score
|
|
|
|
return best_title, best_score
|
|
|
|
|
|
def _build_gdelt_query(canonical_name: str, aliases: list[str]) -> str:
|
|
"""Build a GDELT DOC API query string using quoted phrase OR logic."""
|
|
terms = [canonical_name] + [a for a in aliases if a != canonical_name]
|
|
quoted = [f'"{t}"' for t in terms[:4]] # cap at 4 terms
|
|
return " OR ".join(quoted)
|
|
|
|
|
|
async def resolve_entity(
|
|
db: AsyncSession,
|
|
ticker: str,
|
|
) -> CompanyEntityMap:
|
|
"""Resolve ticker → entity and upsert into company_entity_map.
|
|
|
|
Returns the upserted CompanyEntityMap row.
|
|
Raises ValueError if the ticker cannot be resolved.
|
|
"""
|
|
ticker = ticker.upper()
|
|
|
|
# Check if manual override already exists — skip re-resolution
|
|
existing_result = await db.execute(
|
|
select(CompanyEntityMap).where(CompanyEntityMap.ticker == ticker)
|
|
)
|
|
existing = existing_result.scalars().first()
|
|
if existing and existing.is_manual_override:
|
|
logger.info("Skipping resolution for %s: manual override active", ticker)
|
|
return existing
|
|
|
|
# Look up company name from companies table
|
|
company_result = await db.execute(
|
|
select(Company).where(Company.ticker == ticker)
|
|
)
|
|
company = company_result.scalars().first()
|
|
if not company:
|
|
raise ValueError(f"Ticker {ticker!r} not found in companies table")
|
|
|
|
raw_name = company.name
|
|
canonical_name, aliases = _normalize_name(raw_name)
|
|
|
|
# Detect placeholder names like "AMZN Corporation" → canonical becomes "AMZN"
|
|
if _is_placeholder_name(ticker, canonical_name):
|
|
logger.info(
|
|
"Detected placeholder name for %s (%r) — querying SEC for real name",
|
|
ticker, raw_name,
|
|
)
|
|
sec_name = await _fetch_sec_company_name(ticker)
|
|
if sec_name:
|
|
logger.info("SEC fallback for %s: %r → %r", ticker, raw_name, sec_name)
|
|
# Update the DB so next resolve skips this path
|
|
await db.execute(
|
|
update(Company).where(Company.ticker == ticker).values(name=sec_name)
|
|
)
|
|
canonical_name, aliases = _normalize_name(sec_name)
|
|
else:
|
|
logger.warning("SEC fallback found no name for %s, proceeding with placeholder", ticker)
|
|
|
|
logger.info("Resolving entity for %s: canonical=%r aliases=%r", ticker, canonical_name, aliases)
|
|
|
|
wiki_title, confidence = await _resolve_wiki(canonical_name, aliases)
|
|
gdelt_query = _build_gdelt_query(canonical_name, aliases)
|
|
|
|
logger.info(
|
|
"Entity resolved: %s → wiki_title=%r confidence=%.2f",
|
|
ticker, wiki_title, confidence,
|
|
)
|
|
|
|
# Upsert into company_entity_map
|
|
stmt = (
|
|
pg_insert(CompanyEntityMap)
|
|
.values(
|
|
ticker=ticker,
|
|
canonical_name=canonical_name,
|
|
wiki_title=wiki_title,
|
|
gdelt_query=gdelt_query,
|
|
aliases_json=aliases,
|
|
resolver_confidence=confidence,
|
|
is_manual_override=False,
|
|
)
|
|
.on_conflict_do_update(
|
|
index_elements=["ticker"],
|
|
set_=dict(
|
|
canonical_name=canonical_name,
|
|
wiki_title=wiki_title,
|
|
gdelt_query=gdelt_query,
|
|
aliases_json=aliases,
|
|
resolver_confidence=confidence,
|
|
),
|
|
where=CompanyEntityMap.is_manual_override == False, # noqa: E712
|
|
)
|
|
.returning(CompanyEntityMap)
|
|
)
|
|
|
|
result = await db.execute(stmt)
|
|
await db.commit()
|
|
|
|
row = result.scalars().first()
|
|
if row is None:
|
|
# Manual override prevented update — return existing
|
|
existing_result2 = await db.execute(
|
|
select(CompanyEntityMap).where(CompanyEntityMap.ticker == ticker)
|
|
)
|
|
row = existing_result2.scalars().first()
|
|
|
|
return row
|