feat: company names for tickers (Alpaca backfill + subtle display)
Store an optional company name on Ticker (migration 014) and backfill it from Alpaca's asset list in a single Trading-API call for the whole universe — no per-ticker fetch. Runs automatically at the end of universe bootstrap and via a manual "Backfill Names" button (admin) / POST /admin/tickers/backfill-names. The name ships on /tickers; a shared symbol→name map (useTickerNames) lets any view show it without its own request. Displayed subtly next to the symbol — in the global search, the ticker header, and as a small muted line under the symbol in Top Setups and Open Trades (no extra column, truncated so it never widens the table). Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
@@ -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")
|
||||
@@ -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
|
||||
)
|
||||
|
||||
@@ -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
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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<TickerUniverse>('sp500');
|
||||
const [pruneMissing, setPruneMissing] = useState(false);
|
||||
@@ -85,6 +87,14 @@ export function TickerUniverseBootstrap() {
|
||||
>
|
||||
{bootstrap.isPending ? 'Bootstrapping…' : 'Bootstrap Now'}
|
||||
</button>
|
||||
<button
|
||||
className="px-4 py-2 text-sm rounded border border-white/[0.1] text-gray-300 hover:text-white disabled:opacity-50"
|
||||
onClick={() => backfillNames.mutate()}
|
||||
disabled={backfillNames.isPending}
|
||||
title="Fill in company names from Alpaca (one request for all tickers)"
|
||||
>
|
||||
{backfillNames.isPending ? 'Backfilling…' : 'Backfill Names'}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -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() {
|
||||
<Link to={`/ticker/${t.symbol}`} className="font-medium text-blue-300 hover:text-blue-200">
|
||||
{t.symbol}
|
||||
</Link>
|
||||
{tickerNames.get(t.symbol.toUpperCase()) && (
|
||||
<div className="max-w-[150px] truncate text-[11px] text-gray-500">
|
||||
{tickerNames.get(t.symbol.toUpperCase())}
|
||||
</div>
|
||||
)}
|
||||
</td>
|
||||
<td className="px-4 py-3">
|
||||
<span className={`num text-[10px] font-semibold uppercase ${t.direction === 'long' ? 'text-emerald-400' : 'text-red-400'}`}>
|
||||
|
||||
@@ -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}
|
||||
<span className="font-medium">{t.symbol}</span>
|
||||
{t.name && <span className="truncate text-xs text-gray-500">{t.name}</span>}
|
||||
</button>
|
||||
</li>
|
||||
))}
|
||||
|
||||
@@ -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() {
|
||||
|
||||
@@ -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<string, string> {
|
||||
const { data } = useTickers();
|
||||
return useMemo(() => {
|
||||
const map = new Map<string, string>();
|
||||
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();
|
||||
|
||||
@@ -532,6 +532,7 @@ export interface EMACrossResult {
|
||||
export interface Ticker {
|
||||
id: number;
|
||||
symbol: string;
|
||||
name: string | null;
|
||||
created_at: string;
|
||||
}
|
||||
|
||||
|
||||
@@ -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() {
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
{tickerNames.get(setup.symbol.toUpperCase()) && (
|
||||
<div className="max-w-[160px] truncate text-[11px] text-gray-500">
|
||||
{tickerNames.get(setup.symbol.toUpperCase())}
|
||||
</div>
|
||||
)}
|
||||
</td>
|
||||
<td className="px-4 py-3"><DirectionTag direction={setup.direction} /></td>
|
||||
<td className="num px-4 py-3 text-right text-gray-200">{formatPrice(setup.entry_price)}</td>
|
||||
|
||||
@@ -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() {
|
||||
<div className="flex flex-wrap items-center justify-between gap-4">
|
||||
<div className="flex items-baseline gap-4">
|
||||
<h1 className="text-3xl font-semibold text-gray-100">{symbol.toUpperCase()}</h1>
|
||||
{companyName && (
|
||||
<span className="max-w-[240px] truncate text-sm text-gray-500">{companyName}</span>
|
||||
)}
|
||||
{priceInfo && (
|
||||
<div className="flex items-baseline gap-2">
|
||||
<span className="num text-2xl font-semibold text-gray-100">{formatPrice(priceInfo.price)}</span>
|
||||
|
||||
Reference in New Issue
Block a user