diff --git a/alembic/versions/014_add_ticker_name.py b/alembic/versions/014_add_ticker_name.py new file mode 100644 index 0000000..810800e --- /dev/null +++ b/alembic/versions/014_add_ticker_name.py @@ -0,0 +1,29 @@ +"""add name to tickers + +Company name (e.g. "Biogen Inc."), backfilled from Alpaca so the UI can show which +company is behind a symbol. Nullable — symbols Alpaca doesn't cover stay name-less. + +Revision ID: 014 +Revises: 013 +Create Date: 2026-07-01 00:00:00.000000 + +""" +from typing import Sequence, Union + +from alembic import op +import sqlalchemy as sa + + +# revision identifiers, used by Alembic. +revision: str = "014" +down_revision: Union[str, None] = "013" +branch_labels: Union[str, Sequence[str], None] = None +depends_on: Union[str, Sequence[str], None] = None + + +def upgrade() -> None: + op.add_column("tickers", sa.Column("name", sa.String(length=120), nullable=True)) + + +def downgrade() -> None: + op.drop_column("tickers", "name") diff --git a/app/models/ticker.py b/app/models/ticker.py index 30120a2..67e9be8 100644 --- a/app/models/ticker.py +++ b/app/models/ticker.py @@ -11,6 +11,9 @@ class Ticker(Base): id: Mapped[int] = mapped_column(primary_key=True) symbol: Mapped[str] = mapped_column(String(10), unique=True, nullable=False) + # Company name (e.g. "Biogen Inc."); backfilled from Alpaca, nullable for + # symbols Alpaca doesn't know. + name: Mapped[str | None] = mapped_column(String(120), nullable=True) created_at: Mapped[datetime] = mapped_column( DateTime(timezone=True), default=datetime.utcnow, nullable=False ) diff --git a/app/routers/admin.py b/app/routers/admin.py index b2a0ddd..ab43c65 100644 --- a/app/routers/admin.py +++ b/app/routers/admin.py @@ -315,6 +315,16 @@ async def bootstrap_tickers( return APIEnvelope(status="success", data=result) +@router.post("/admin/tickers/backfill-names", response_model=APIEnvelope) +async def backfill_ticker_names( + _admin: User = Depends(require_admin), + db: AsyncSession = Depends(get_db), +): + """Fill in company names for tracked tickers (one Alpaca call).""" + result = await ticker_universe_service.backfill_ticker_names(db) + return APIEnvelope(status="success", data=result) + + # --------------------------------------------------------------------------- # Data cleanup # --------------------------------------------------------------------------- diff --git a/app/schemas/ticker.py b/app/schemas/ticker.py index 7f412f2..966da06 100644 --- a/app/schemas/ticker.py +++ b/app/schemas/ticker.py @@ -12,6 +12,7 @@ class TickerCreate(BaseModel): class TickerResponse(BaseModel): id: int symbol: str + name: str | None = None created_at: datetime model_config = {"from_attributes": True} diff --git a/app/services/ticker_universe_service.py b/app/services/ticker_universe_service.py index f8ef731..10659b0 100644 --- a/app/services/ticker_universe_service.py +++ b/app/services/ticker_universe_service.py @@ -6,6 +6,7 @@ well-known universes (S&P 500, NASDAQ-100, NASDAQ All). from __future__ import annotations +import asyncio import json import logging import os @@ -357,6 +358,55 @@ async def fetch_universe_symbols(db: AsyncSession, universe: str) -> list[str]: raise ProviderError(f"Universe '{normalised_universe}' returned no valid symbols. Attempts: {reason}") +async def _fetch_alpaca_asset_names() -> dict[str, str]: + """One Alpaca Trading-API call → {internal_symbol: company_name} for all US + equities. Tries paper and live endpoints so it works with either key type.""" + if not settings.alpaca_api_key or not settings.alpaca_api_secret: + raise ValidationError("Alpaca API credentials are required to backfill names") + + from alpaca.trading.client import TradingClient + from alpaca.trading.enums import AssetClass, AssetStatus + from alpaca.trading.requests import GetAssetsRequest + + req = GetAssetsRequest(status=AssetStatus.ACTIVE, asset_class=AssetClass.US_EQUITY) + last_err: Exception | None = None + for paper in (True, False): + try: + client = TradingClient(settings.alpaca_api_key, settings.alpaca_api_secret, paper=paper) + assets = await asyncio.to_thread(client.get_all_assets, req) + names: dict[str, str] = {} + for asset in assets: + sym = getattr(asset, "symbol", None) + nm = getattr(asset, "name", None) + if sym and nm: + names[sym.replace(".", "-").upper()] = nm # BRK.B → BRK-B + if names: + return names + except Exception as exc: # noqa: BLE001 — try the other endpoint + last_err = exc + + raise ProviderError(f"Failed to fetch asset names from Alpaca: {last_err}") + + +async def backfill_ticker_names(db: AsyncSession, *, only_missing: bool = True) -> dict[str, int]: + """Fill Ticker.name from Alpaca in a single request for the whole universe.""" + result = await db.execute(select(Ticker)) + tickers = list(result.scalars().all()) + targets = [t for t in tickers if not t.name] if only_missing else tickers + if not targets: + return {"updated": 0, "checked": 0, "unmatched": 0} + + names = await _fetch_alpaca_asset_names() + updated = 0 + for ticker in targets: + nm = names.get(ticker.symbol.upper()) + if nm and nm != ticker.name: + ticker.name = nm[:120] + updated += 1 + await db.commit() + return {"updated": updated, "checked": len(targets), "unmatched": len(targets) - updated} + + async def bootstrap_universe( db: AsyncSession, universe: str, @@ -387,6 +437,13 @@ async def bootstrap_universe( await db.commit() + # Best-effort: fill company names for any tickers still missing one. Never let + # a name-fetch hiccup fail the bootstrap itself. + try: + await backfill_ticker_names(db, only_missing=True) + except Exception: # noqa: BLE001 + logger.warning("Ticker name backfill failed during bootstrap", exc_info=True) + return { "universe": normalised_universe, "total_universe_symbols": len(symbols), diff --git a/frontend/src/api/admin.ts b/frontend/src/api/admin.ts index 8af388e..4a422da 100644 --- a/frontend/src/api/admin.ts +++ b/frontend/src/api/admin.ts @@ -171,6 +171,12 @@ export function bootstrapTickers(universe: TickerUniverse, pruneMissing: boolean .then((r) => r.data); } +export function backfillTickerNames() { + return apiClient + .post<{ updated: number; checked: number; unmatched: number }>('admin/tickers/backfill-names') + .then((r) => r.data); +} + // Jobs export interface JobStatus { name: string; diff --git a/frontend/src/components/admin/TickerUniverseBootstrap.tsx b/frontend/src/components/admin/TickerUniverseBootstrap.tsx index 4038b04..b9555cd 100644 --- a/frontend/src/components/admin/TickerUniverseBootstrap.tsx +++ b/frontend/src/components/admin/TickerUniverseBootstrap.tsx @@ -1,5 +1,6 @@ import { useEffect, useState } from 'react'; import { + useBackfillTickerNames, useBootstrapTickers, useTickerUniverseSetting, useUpdateTickerUniverseSetting, @@ -17,6 +18,7 @@ export function TickerUniverseBootstrap() { const { data, isLoading, isError, error } = useTickerUniverseSetting(); const updateDefault = useUpdateTickerUniverseSetting(); const bootstrap = useBootstrapTickers(); + const backfillNames = useBackfillTickerNames(); const [universe, setUniverse] = useState('sp500'); const [pruneMissing, setPruneMissing] = useState(false); @@ -85,6 +87,14 @@ export function TickerUniverseBootstrap() { > {bootstrap.isPending ? 'Bootstrapping…' : 'Bootstrap Now'} + ); diff --git a/frontend/src/components/dashboard/OpenTradesPanel.tsx b/frontend/src/components/dashboard/OpenTradesPanel.tsx index ce5271f..b0a65ee 100644 --- a/frontend/src/components/dashboard/OpenTradesPanel.tsx +++ b/frontend/src/components/dashboard/OpenTradesPanel.tsx @@ -1,6 +1,7 @@ import { useMemo } from 'react'; import { Link } from 'react-router-dom'; import { usePaperTrades, useClosePaperTrade, useExitPolicy } from '../../hooks/usePaperTrades'; +import { useTickerNames } from '../../hooks/useTickers'; import { tradePnl } from '../../lib/paperTrade'; import { formatPrice } from '../../lib/format'; import { Section } from '../ui/Section'; @@ -19,6 +20,7 @@ function pnlColor(v: number): string { export function OpenTradesPanel() { const { data: trades, isLoading } = usePaperTrades('open'); const { data: policy } = useExitPolicy(); + const tickerNames = useTickerNames(); const close = useClosePaperTrade(); const exitLabel = policy @@ -84,6 +86,11 @@ export function OpenTradesPanel() { {t.symbol} + {tickerNames.get(t.symbol.toUpperCase()) && ( +
+ {tickerNames.get(t.symbol.toUpperCase())} +
+ )} diff --git a/frontend/src/components/layout/TickerSearch.tsx b/frontend/src/components/layout/TickerSearch.tsx index 29ac71f..3de31e2 100644 --- a/frontend/src/components/layout/TickerSearch.tsx +++ b/frontend/src/components/layout/TickerSearch.tsx @@ -81,11 +81,12 @@ export default function TickerSearch({ onNavigate }: { onNavigate?: () => void } type="button" onMouseEnter={() => setActive(i)} onClick={() => go(t.symbol)} - className={`flex w-full items-center px-3 py-1.5 text-left text-sm transition-colors ${ + className={`flex w-full items-baseline gap-2 px-3 py-1.5 text-left text-sm transition-colors ${ i === active ? 'bg-blue-400/[0.12] text-blue-200' : 'text-gray-300 hover:bg-white/[0.04]' }`} > - {t.symbol} + {t.symbol} + {t.name && {t.name}} ))} diff --git a/frontend/src/hooks/useAdmin.ts b/frontend/src/hooks/useAdmin.ts index bb84417..341f5fe 100644 --- a/frontend/src/hooks/useAdmin.ts +++ b/frontend/src/hooks/useAdmin.ts @@ -283,6 +283,22 @@ export function useBootstrapTickers() { }); } +export function useBackfillTickerNames() { + const qc = useQueryClient(); + const { addToast } = useToast(); + + return useMutation({ + mutationFn: () => adminApi.backfillTickerNames(), + onSuccess: (result) => { + qc.invalidateQueries({ queryKey: ['tickers'] }); + addToast('success', `Company names: +${result.updated} filled (${result.unmatched} unmatched)`); + }, + onError: (error: Error) => { + addToast('error', error.message || 'Failed to backfill company names'); + }, + }); +} + // ── Jobs ── export function useJobs() { diff --git a/frontend/src/hooks/useTickers.ts b/frontend/src/hooks/useTickers.ts index 32f3bfd..45a42d6 100644 --- a/frontend/src/hooks/useTickers.ts +++ b/frontend/src/hooks/useTickers.ts @@ -1,3 +1,4 @@ +import { useMemo } from 'react'; import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'; import * as tickersApi from '../api/tickers'; import { useToast } from '../components/ui/Toast'; @@ -9,6 +10,19 @@ export function useTickers() { }); } +/** symbol (upper) → company name, from the tracked-ticker list. Shared lookup so + * any view can show the company behind a symbol without its own request. */ +export function useTickerNames(): Map { + const { data } = useTickers(); + return useMemo(() => { + const map = new Map(); + for (const t of data ?? []) { + if (t.name) map.set(t.symbol.toUpperCase(), t.name); + } + return map; + }, [data]); +} + export function useAddTicker() { const qc = useQueryClient(); const { addToast } = useToast(); diff --git a/frontend/src/lib/types.ts b/frontend/src/lib/types.ts index 1087609..9437e97 100644 --- a/frontend/src/lib/types.ts +++ b/frontend/src/lib/types.ts @@ -532,6 +532,7 @@ export interface EMACrossResult { export interface Ticker { id: number; symbol: string; + name: string | null; created_at: string; } diff --git a/frontend/src/pages/DashboardPage.tsx b/frontend/src/pages/DashboardPage.tsx index b4fd98a..59c8c21 100644 --- a/frontend/src/pages/DashboardPage.tsx +++ b/frontend/src/pages/DashboardPage.tsx @@ -4,6 +4,7 @@ import { useActivation } from '../hooks/useActivation'; import { useTrades } from '../hooks/useTrades'; import { useWatchlist } from '../hooks/useWatchlist'; import { usePaperTrades } from '../hooks/usePaperTrades'; +import { useTickerNames } from '../hooks/useTickers'; import { useMarketRegime } from '../hooks/useMarketRegime'; import { regimeColor, regimeDot, regimeHeadline } from '../lib/regime'; import { Callout } from '../components/ui/Callout'; @@ -62,6 +63,7 @@ function DirectionTag({ direction }: { direction: string }) { export default function DashboardPage() { const trades = useTrades(); const watchlist = useWatchlist(); + const tickerNames = useTickerNames(); const activation = useActivation(); const openTrades = usePaperTrades('open'); const regime = useMarketRegime(); @@ -237,6 +239,11 @@ export default function DashboardPage() { )} + {tickerNames.get(setup.symbol.toUpperCase()) && ( +
+ {tickerNames.get(setup.symbol.toUpperCase())} +
+ )} {formatPrice(setup.entry_price)} diff --git a/frontend/src/pages/TickerDetailPage.tsx b/frontend/src/pages/TickerDetailPage.tsx index dbc8919..2f91697 100644 --- a/frontend/src/pages/TickerDetailPage.tsx +++ b/frontend/src/pages/TickerDetailPage.tsx @@ -10,6 +10,7 @@ import { topPickSymbol, qualifiesSetup } from '../lib/qualification'; import type { FetchSelector } from '../api/ingestion'; import { CandlestickChart } from '../components/charts/CandlestickChart'; import { ScoreCard } from '../components/ui/ScoreCard'; +import { useTickerNames } from '../hooks/useTickers'; import { SkeletonCard } from '../components/ui/Skeleton'; import { SentimentPanel } from '../components/ticker/SentimentPanel'; import { FundamentalsPanel } from '../components/ticker/FundamentalsPanel'; @@ -120,6 +121,7 @@ function DataFreshnessBar({ export default function TickerDetailPage() { const { symbol = '' } = useParams<{ symbol: string }>(); + const companyName = useTickerNames().get(symbol.toUpperCase()); const { ohlcv, scores, srLevels, sentiment, fundamentals, trades } = useTickerDetail(symbol); const ingestion = useFetchSymbolData(); const watchlist = useWatchlist(); @@ -274,6 +276,9 @@ export default function TickerDetailPage() {

{symbol.toUpperCase()}

+ {companyName && ( + {companyName} + )} {priceInfo && (
{formatPrice(priceInfo.price)}