feat: company names for tickers (Alpaca backfill + subtle display)
Deploy / lint (push) Successful in 6s
Deploy / test (push) Successful in 55s
Deploy / deploy (push) Successful in 32s

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:
2026-07-01 10:50:40 +02:00
parent a9f4686157
commit da0bb3367e
14 changed files with 169 additions and 2 deletions
+6
View File
@@ -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>
))}
+16
View File
@@ -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() {
+14
View File
@@ -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();
+1
View File
@@ -532,6 +532,7 @@ export interface EMACrossResult {
export interface Ticker {
id: number;
symbol: string;
name: string | null;
created_at: string;
}
+7
View File
@@ -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>
+5
View File
@@ -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>