"""Turn a broker's asset name into the names a headline would actually use.
Issue #6 measured that 4 of 26 `ollama_news` BUY decisions scored
`relevant=0/10` — ten fresh headlines, not one of which named the company —
and that supplying plain company aliases moved all four to 5, 6, 7 and 4, with
the median across all 26 BUYs going 2 -> 5 and no row left at 0. The gap was a
missing config value, not a missing feature: `company_aliases` existed and was
never set, so relevance matched on the bare ticker alone.
A static list in `config/strategies.yaml` would have closed it for
`fixed_tickers` only. Two of the fifteen rows that improved (NNE, NVDA) were
symbols *discovery* found, which arrive with no entry in any hand-written map —
and a symbol nobody has written an alias for is exactly the 0/10 case. So the
name is resolved per symbol instead, from a field the app already receives and
was discarding: Alpaca's asset payload carries `name` ("Apple Inc. Common
Stock"), and every evaluated symbol already goes through `get_asset`.
**This feeds `relevance_count` and nothing else.** Aliases never reach the
prompt, so nothing here can change a trading decision — it makes a recorded
measurement honest, which is what issue #6 asked for. Filtering on relevance is
a separate question and deliberately not answered here.
"""
from __future__ import annotations
import logging
import re
from collections.abc import Callable, Sequence
_logger = logging.getLogger("trader.news.aliases")
#: Instrument and share-class boilerplate, and everything after it. Alpaca
#: names carry the security's description, not just the issuer's name —
#: "Apple Inc. Common Stock", "Palantir Technologies Inc. Class A Common
#: Stock". A headline never writes that, so it is cut before anything else.
_INSTRUMENT_TAIL = re.compile(
r"\s*\b(?:class\s+[a-z0-9]+\s+)?"
r"(?:new\s+)?"
# `capital` is here because of a live payload, not a guess: Alpaca calls
# GOOG "Alphabet Inc. Class C **Capital** Stock", and without it only the
# trailing " Stock" was cut, leaving the junk alias
# "Alphabet Inc. Class C Capital". Measured 2026-08-14 against the paper
# account.
r"(?:common|ordinary|preferred|depositary|depository|registered|capital)?\s*"
r"(?:stock|shares?|units?|warrants?|rights?|receipts?)\b.*$",
re.IGNORECASE,
)
#: Legal-form suffixes, stripped repeatedly from the end. "Holdings", "Group"
#: and "Trust" are in here because they are legal-form noise in exactly the
#: same way — "SPDR S&P 500 ETF Trust" should match a headline saying "SPDR
#: S&P 500". They are stripped only from the *end*, so a company whose name
#: genuinely leads with one keeps it.
_LEGAL_SUFFIXES = frozenset(
{
"ab",
"ag",
"asa",
"co",
"company",
"corp",
"corporation",
"etf",
"fund",
"group",
"holding",
"holdings",
"inc",
"incorporated",
"limited",
"llc",
"llp",
"lp",
"ltd",
"nv",
"oyj",
"plc",
"sa",
"sarl",
"se",
"spa",
"trust",
}
)
#: A leading word too generic to stand alone as a company alias. Without this,
#: "Energy Fuels Inc." would contribute the alias "Energy" and count every
#: energy-sector headline as news about UUUU — inflating precisely the number
#: this module exists to make trustworthy. Judged against the measured
#: universe: it is better to under-count relevance than to manufacture it.
_GENERIC_LEADING_WORDS = frozenset(
{
"advanced",
"american",
"applied",
"atlantic",
"capital",
"cloud",
"consumer",
"data",
"digital",
"eastern",
"energy",
"financial",
"first",
"general",
"global",
"health",
"industrial",
"industries",
"international",
"medical",
"national",
"northern",
"pacific",
"premier",
"resources",
"security",
"southern",
"standard",
"technologies",
"united",
"western",
}
)
#: Below this, a derived alias is more likely to be an abbreviation that
#: collides with an ordinary English word than a company name. `relevance_count`
#: matches case-insensitively on word boundaries, so a two-letter alias is a
#: liability rather than a signal.
_MIN_ALIAS_LENGTH = 3
#: A leading word is only split out as its own alias above this length, which
#: is stricter than `_MIN_ALIAS_LENGTH` on purpose: the full cleaned name is
#: evidence the broker gave us, whereas the leading word is our inference from
#: it. "Palantir Technologies" -> "Palantir" is worth having; "Bank of America"
#: -> "Bank" and "Nano Nuclear Energy" -> "Nano" are not.
_MIN_LEADING_WORD_LENGTH = 5
#: A cleaned name is only split into "issuer + descriptor" at exactly this many
#: words. See the comment at the split itself for the live payload that forced
#: it — "State Street SPDR S&P 500 ETF Trust" is five words and its first one
#: is not the company.
_SPLITTABLE_WORD_COUNT = 2
[docs]
def derive_aliases(name: str | None) -> tuple[str, ...]:
"""Company aliases implied by a broker asset name.
Returns the cleaned issuer name and, where it is safely separable, the
leading word: `"Palantir Technologies Inc. Class A Common Stock"` yields
`("Palantir Technologies", "Palantir")`.
Returns an empty tuple for `None`, for blank input, and whenever cleaning
leaves nothing usable. An empty tuple is the honest answer — it degrades
relevance to the bare-ticker matching that existed before this module, and
never invents a needle.
What it cannot do: derive a name the asset record does not contain.
Alpaca calls GOOG "Alphabet Inc. Class C Common Stock", so "Google" is not
derivable from it, and the measured GOOG improvement needed both. That is
what the `company_aliases` config override remains for; the two are unioned
rather than one replacing the other.
"""
if not name:
return ()
cleaned = _INSTRUMENT_TAIL.sub("", str(name)).strip()
words = cleaned.split()
# Iteratively, not once: "SPDR S&P 500 ETF Trust" carries two.
while words and words[-1].strip(".,").casefold() in _LEGAL_SUFFIXES:
words.pop()
cleaned = " ".join(words).strip(" ,.")
if len(cleaned) < _MIN_ALIAS_LENGTH:
return ()
aliases = [cleaned]
# **Exactly** two words, not "more than one". A two-word name is almost
# always issuer plus descriptor ("Palantir Technologies", "Integra
# Resources"), so the first word stands alone. A longer one usually is not,
# and the live payload proved it: SPY is "State Street SPDR S&P 500 ETF
# Trust", whose first word under the old `> 1` rule became the alias
# "State" — five letters, not in any generic list, and matching "state",
# "states" and "United States" in headlines. That single needle would have
# inflated SPY's relevance with pure noise, corrupting the exact number
# this module exists to make trustworthy. Measured 2026-08-14 against the
# paper account; it is why real names were fetched before trusting this.
if len(words) == _SPLITTABLE_WORD_COUNT:
leading = words[0].strip(".,")
# `_LEGAL_SUFFIXES` is consulted here as well as at the tail: suffixes
# are only *stripped* from the end, so "Trust Financial Corporation"
# rightly keeps its leading "Trust" — but "Trust" standing alone as a
# needle would match half the market's headlines.
if (
len(leading) >= _MIN_LEADING_WORD_LENGTH
and leading.casefold() not in _GENERIC_LEADING_WORDS
and leading.casefold() not in _LEGAL_SUFFIXES
):
aliases.append(leading)
return tuple(aliases)
[docs]
class CompanyAliasResolver:
"""Resolves a symbol to company aliases, once per symbol per process.
Wraps a name lookup — in production, the broker's `get_asset` — and caches
the derived aliases, because `relevance_count` runs for every symbol every
cycle and the answer does not change between cycles.
**Never raises.** A lookup failure yields no aliases and lets relevance fall
back to bare-ticker matching, the same discipline the LLM path applies to
everything else: a broker hiccup must degrade a recorded measurement, not
abort a cycle that is about to place or ratchet protective orders.
"""
def __init__(self, fetch_name: Callable[[str], str | None]) -> None:
self._fetch_name = fetch_name
self._cache: dict[str, tuple[str, ...]] = {}
def __call__(self, symbol: str) -> tuple[str, ...]:
key = symbol.strip().upper()
cached = self._cache.get(key)
if cached is not None:
return cached
try:
name = self._fetch_name(key)
except Exception as exc: # noqa: BLE001 - a lookup failure is not fatal
# Deliberately NOT cached. A transient outage would otherwise pin an
# empty result for the life of the process — and the daemon's
# process life is measured in days.
_logger.warning("Could not resolve a company name for %s: %s", key, exc)
return ()
aliases = derive_aliases(name)
# A genuinely nameless asset *is* cached: it will not grow a name, and
# re-asking every cycle would spend a broker call to learn nothing.
self._cache[key] = aliases
return aliases
[docs]
def merge_aliases(configured: Sequence[str], resolved: Sequence[str]) -> tuple[str, ...]:
"""Configured aliases first, then resolved ones, de-duplicated.
Order is by specificity, not preference: both sets end up in the same
alternation inside one regex, so the result is a union and the ordering
matters only for readability of what gets recorded. Configured entries come
first because an operator naming "Google" for GOOG is making a claim the
asset record cannot.
De-duplication is case-insensitive, matching how `relevance_count` searches.
"""
seen: set[str] = set()
merged: list[str] = []
for alias in (*configured, *resolved):
if not alias:
continue
key = alias.casefold()
if key in seen:
continue
seen.add(key)
merged.append(alias)
return tuple(merged)