Source code for trader.news.aliases

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