Fix score refresh, add granular fetch and live job status
Deploy / lint (push) Successful in 6s
Deploy / test (push) Successful in 35s
Deploy / deploy (push) Successful in 22s

Scores never updated ("101d ago"): get_score only recomputes stale/
missing dimensions, but nothing marked them stale on new data, and there
was no scheduled scoring job.
- Fetch endpoint force-recomputes dimensions + composite.
- Scheduled scan (scan_all_tickers) refreshes scores per ticker, so
  scores stay current globally, not just on manual fetch.

Granular fetch: /ingestion/fetch accepts a sources filter; the freshness
bar gets a per-row refresh button (OHLCV/Sentiment/Fundamentals fetch
that provider only — marked paid; S/R/Scores recompute for free). Header
button is now "Fetch All".

Job visibility: GET /jobs/running (any user) + sidebar live indicator
showing running scheduled jobs with progress, polled every 10s.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
2026-06-14 13:10:15 +02:00
parent 3aebfd72d3
commit 316226096b
10 changed files with 296 additions and 94 deletions
+11 -2
View File
@@ -13,8 +13,17 @@ export interface FetchDataResult {
sources: Record<string, IngestionSourceResult>;
}
export function fetchData(symbol: string) {
/** Provider sources that cost an API call/quota. */
export type FetchSource = 'ohlcv' | 'sentiment' | 'fundamentals';
/** Source selector: omit → fetch all; array → those providers; 'recompute' → derived only (free). */
export type FetchSelector = FetchSource[] | 'recompute';
export function fetchData(symbol: string, sources?: FetchSelector) {
let sourcesParam: string | undefined;
if (sources === 'recompute') sourcesParam = 'recompute';
else if (sources && sources.length) sourcesParam = sources.join(',');
const params = sourcesParam ? { sources: sourcesParam } : undefined;
return apiClient
.post<FetchDataResult>(`ingestion/fetch/${symbol}`)
.post<FetchDataResult>(`ingestion/fetch/${symbol}`, null, { params })
.then((r) => r.data);
}
+16
View File
@@ -0,0 +1,16 @@
import apiClient from './client';
export interface RunningJob {
name: string;
label: string;
progress_pct: number | null;
processed: number | null;
total: number | null;
current_ticker: string | null;
}
export function getRunningJobs() {
return apiClient
.get<{ running: RunningJob[] }>('jobs/running')
.then((r) => r.data);
}
@@ -2,6 +2,7 @@ import { NavLink } from 'react-router-dom';
import { useQuery } from '@tanstack/react-query';
import { useAuthStore } from '../../stores/authStore';
import { check as healthCheck } from '../../api/health';
import { getRunningJobs } from '../../api/jobs';
const navItems = [
{ to: '/', label: 'Overview', index: '01', end: true },
@@ -28,6 +29,16 @@ export default function Sidebar() {
const isBackendUp = health.isSuccess;
const jobs = useQuery({
queryKey: ['jobs', 'running'],
queryFn: getRunningJobs,
refetchInterval: 10_000,
retry: 1,
enabled: isBackendUp,
});
const running = jobs.data?.running ?? [];
return (
<aside className="hidden lg:flex lg:flex-col lg:w-64 h-screen sticky top-0 glass border-r border-white/[0.06] rounded-none border-l-0 border-t-0 border-b-0">
{/* Brand */}
@@ -69,6 +80,23 @@ export default function Sidebar() {
{isBackendUp ? 'Backend online' : 'Backend offline'}
</span>
</div>
{/* Live background-job activity */}
{running.length > 0 && (
<div className="px-1 space-y-1">
{running.map((job) => (
<div key={job.name} className="flex items-center gap-2">
<span className="inline-block h-1.5 w-1.5 rounded-full bg-blue-400 animate-signal-pulse shrink-0" />
<span className="text-[11px] text-gray-400 truncate">
{job.label}
{job.progress_pct != null && (
<span className="num text-gray-500"> {Math.round(job.progress_pct)}%</span>
)}
</span>
</div>
))}
</div>
)}
{username && (
<p className="text-xs text-gray-500 truncate px-1">Signed in as {username}</p>
)}
+17 -5
View File
@@ -1,5 +1,5 @@
import { useMutation, useQueryClient } from '@tanstack/react-query';
import { fetchData, type FetchDataResult } from '../api/ingestion';
import { fetchData, type FetchDataResult, type FetchSelector } from '../api/ingestion';
import { useToast } from '../components/ui/Toast';
import { summarizeIngestionResult } from '../lib/ingestionStatus';
@@ -8,14 +8,26 @@ interface UseFetchSymbolDataOptions {
invalidatePipelineReadiness?: boolean;
}
export interface FetchVars {
symbol: string;
sources?: FetchSelector;
}
type FetchArg = string | FetchVars;
const argSymbol = (arg: FetchArg): string => (typeof arg === 'string' ? arg : arg.symbol);
export function useFetchSymbolData(options: UseFetchSymbolDataOptions = {}) {
const { includeSymbolPrefix = false, invalidatePipelineReadiness = false } = options;
const queryClient = useQueryClient();
const { addToast } = useToast();
return useMutation({
mutationFn: (symbol: string) => fetchData(symbol),
onSuccess: (result: FetchDataResult, symbol: string) => {
// Accepts either a bare symbol (fetch all) or { symbol, sources } (granular)
mutationFn: (arg: FetchArg) =>
typeof arg === 'string' ? fetchData(arg) : fetchData(arg.symbol, arg.sources),
onSuccess: (result: FetchDataResult, arg: FetchArg) => {
const symbol = argSymbol(arg);
const normalized = symbol.toUpperCase();
const summary = summarizeIngestionResult(result, normalized);
const toastMessage = includeSymbolPrefix
@@ -33,8 +45,8 @@ export function useFetchSymbolData(options: UseFetchSymbolDataOptions = {}) {
queryClient.invalidateQueries({ queryKey: ['admin', 'pipeline-readiness'] });
}
},
onError: (err: Error, symbol: string) => {
const normalized = symbol.toUpperCase();
onError: (err: Error, arg: FetchArg) => {
const normalized = argSymbol(arg).toUpperCase();
const prefix = includeSymbolPrefix ? `${normalized}: ` : '';
addToast('error', `${prefix}${err.message || 'Failed to fetch data'}`);
},
+64 -10
View File
@@ -2,6 +2,7 @@ import { useMemo, useEffect, useState } from 'react';
import { useParams } from 'react-router-dom';
import { useTickerDetail } from '../hooks/useTickerDetail';
import { useFetchSymbolData } from '../hooks/useFetchSymbolData';
import type { FetchSelector } from '../api/ingestion';
import { CandlestickChart } from '../components/charts/CandlestickChart';
import { ScoreCard } from '../components/ui/ScoreCard';
import { SkeletonCard } from '../components/ui/Skeleton';
@@ -42,23 +43,51 @@ interface DataStatusItem {
label: string;
available: boolean;
timestamp?: string | null;
selector: FetchSelector; // what a refresh of this row fetches
paid?: boolean; // provider call that may cost money/quota
}
function DataFreshnessBar({ items }: { items: DataStatusItem[] }) {
function RefreshIcon({ spinning }: { spinning: boolean }) {
return (
<div className="glass-sm p-3 flex flex-wrap gap-4">
<svg className={`h-3.5 w-3.5 ${spinning ? 'animate-spin' : ''}`} viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth={2}>
<path strokeLinecap="round" strokeLinejoin="round" d="M4 4v5h5M20 20v-5h-5M4 9a8 8 0 0114-3M20 15a8 8 0 01-14 3" />
</svg>
);
}
function DataFreshnessBar({
items,
onRefresh,
pendingLabel,
busy,
}: {
items: DataStatusItem[];
onRefresh: (item: DataStatusItem) => void;
pendingLabel: string | null;
busy: boolean;
}) {
return (
<div className="glass-sm p-3 flex flex-wrap gap-x-5 gap-y-2">
{items.map((item) => (
<div key={item.label} className="flex items-center gap-2">
<div key={item.label} className="flex items-center gap-1.5">
<span className={`inline-block h-2 w-2 rounded-full shrink-0 ${
item.available ? 'bg-emerald-400 shadow-lg shadow-emerald-400/40' : 'bg-gray-600'
}`} />
<span className="text-xs text-gray-400">{item.label}</span>
{item.available && item.timestamp && (
{item.available && item.timestamp ? (
<span className="text-[10px] text-gray-500">{timeAgo(item.timestamp)}</span>
)}
{!item.available && (
) : !item.available ? (
<span className="text-[10px] text-gray-600">no data</span>
)}
) : null}
<button
onClick={() => onRefresh(item)}
disabled={busy}
title={item.paid ? `Fetch ${item.label} (uses provider quota)` : `Recompute ${item.label}`}
className="ml-0.5 text-gray-500 hover:text-blue-300 disabled:opacity-40 transition-colors"
>
<RefreshIcon spinning={pendingLabel === item.label} />
</button>
{item.paid && <span className="text-[9px] text-amber-500/70" title="Uses a paid/quota provider call">$</span>}
</div>
))}
</div>
@@ -70,35 +99,52 @@ export default function TickerDetailPage() {
const { ohlcv, scores, srLevels, sentiment, fundamentals, trades } = useTickerDetail(symbol);
const ingestion = useFetchSymbolData();
const [activeTab, setActiveTab] = useState<DetailTab>('Analysis');
const [refreshingLabel, setRefreshingLabel] = useState<string | null>(null);
const dataStatus: DataStatusItem[] = useMemo(() => [
{
label: 'OHLCV',
available: !!ohlcv.data && ohlcv.data.length > 0,
timestamp: ohlcv.data?.[ohlcv.data.length - 1]?.created_at,
selector: ['ohlcv'] as FetchSelector,
paid: true,
},
{
label: 'Sentiment',
available: !!sentiment.data && sentiment.data.count > 0,
timestamp: sentiment.data?.scores?.[0]?.timestamp,
selector: ['sentiment'] as FetchSelector,
paid: true,
},
{
label: 'Fundamentals',
available: !!fundamentals.data && fundamentals.data.fetched_at !== null,
timestamp: fundamentals.data?.fetched_at,
selector: ['fundamentals'] as FetchSelector,
paid: true,
},
{
label: 'S/R Levels',
available: !!srLevels.data && srLevels.data.count > 0,
timestamp: srLevels.data?.levels?.[0]?.created_at,
selector: 'recompute' as FetchSelector,
},
{
label: 'Scores',
available: !!scores.data && scores.data.composite_score !== null,
timestamp: scores.data?.computed_at,
selector: 'recompute' as FetchSelector,
},
], [ohlcv.data, sentiment.data, fundamentals.data, srLevels.data, scores.data]);
const handleRefresh = (item: DataStatusItem) => {
setRefreshingLabel(item.label);
ingestion.mutate(
{ symbol, sources: item.selector },
{ onSettled: () => setRefreshingLabel(null) },
);
};
// Log trades API errors but don't disrupt the page
useEffect(() => {
if (trades.error) {
@@ -170,13 +216,21 @@ export default function TickerDetailPage() {
</div>
)}
</div>
<Button onClick={() => ingestion.mutate(symbol)} loading={ingestion.isPending}>
{ingestion.isPending ? 'Fetching…' : 'Fetch Data'}
<Button
onClick={() => { setRefreshingLabel(null); ingestion.mutate(symbol); }}
loading={ingestion.isPending}
>
{ingestion.isPending ? 'Fetching…' : 'Fetch All'}
</Button>
</div>
{/* Data freshness bar */}
<DataFreshnessBar items={dataStatus} />
<DataFreshnessBar
items={dataStatus}
onRefresh={handleRefresh}
pendingLabel={refreshingLabel}
busy={ingestion.isPending}
/>
<RecommendationPanel
symbol={symbol}