Fix score refresh, add granular fetch and live job status
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:
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
)}
|
||||
|
||||
@@ -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'}`);
|
||||
},
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -1 +1 @@
|
||||
{"root":["./src/app.tsx","./src/main.tsx","./src/vite-env.d.ts","./src/api/activation.ts","./src/api/admin.ts","./src/api/auth.ts","./src/api/client.ts","./src/api/fundamentals.ts","./src/api/health.ts","./src/api/indicators.ts","./src/api/ingestion.ts","./src/api/ohlcv.ts","./src/api/performance.ts","./src/api/scores.ts","./src/api/sentiment.ts","./src/api/sr-levels.ts","./src/api/tickers.ts","./src/api/trades.ts","./src/api/watchlist.ts","./src/components/admin/activationsettings.tsx","./src/components/admin/datacleanup.tsx","./src/components/admin/jobcontrols.tsx","./src/components/admin/pipelinereadinesspanel.tsx","./src/components/admin/recommendationsettings.tsx","./src/components/admin/sentimentprovidersettings.tsx","./src/components/admin/settingsform.tsx","./src/components/admin/tickermanagement.tsx","./src/components/admin/tickeruniversebootstrap.tsx","./src/components/admin/usertable.tsx","./src/components/auth/protectedroute.tsx","./src/components/charts/candlestickchart.tsx","./src/components/layout/appshell.tsx","./src/components/layout/mobilenav.tsx","./src/components/layout/sidebar.tsx","./src/components/rankings/rankingstable.tsx","./src/components/rankings/weightsform.tsx","./src/components/scanner/tradetable.tsx","./src/components/signals/setupspanel.tsx","./src/components/signals/trackrecordpanel.tsx","./src/components/ticker/dimensionbreakdownpanel.tsx","./src/components/ticker/fundamentalspanel.tsx","./src/components/ticker/indicatorselector.tsx","./src/components/ticker/recommendationpanel.tsx","./src/components/ticker/sroverlay.tsx","./src/components/ticker/sentimentpanel.tsx","./src/components/ui/badge.tsx","./src/components/ui/button.tsx","./src/components/ui/callout.tsx","./src/components/ui/confirmdialog.tsx","./src/components/ui/disclosure.tsx","./src/components/ui/dropdown.tsx","./src/components/ui/field.tsx","./src/components/ui/pageheader.tsx","./src/components/ui/scorecard.tsx","./src/components/ui/section.tsx","./src/components/ui/skeleton.tsx","./src/components/ui/tabs.tsx","./src/components/ui/toast.tsx","./src/components/watchlist/addtickerform.tsx","./src/components/watchlist/watchlisttable.tsx","./src/hooks/useactivation.ts","./src/hooks/useadmin.ts","./src/hooks/useauth.ts","./src/hooks/usefetchsymboldata.ts","./src/hooks/useperformance.ts","./src/hooks/usescores.ts","./src/hooks/usetickerdetail.ts","./src/hooks/usetickers.ts","./src/hooks/usetrades.ts","./src/hooks/usewatchlist.ts","./src/lib/format.ts","./src/lib/ingestionstatus.ts","./src/lib/qualification.ts","./src/lib/recommendation.ts","./src/lib/types.ts","./src/pages/adminpage.tsx","./src/pages/dashboardpage.tsx","./src/pages/loginpage.tsx","./src/pages/marketpage.tsx","./src/pages/registerpage.tsx","./src/pages/signalspage.tsx","./src/pages/tickerdetailpage.tsx","./src/stores/authstore.ts"],"version":"5.6.3"}
|
||||
{"root":["./src/app.tsx","./src/main.tsx","./src/vite-env.d.ts","./src/api/activation.ts","./src/api/admin.ts","./src/api/auth.ts","./src/api/client.ts","./src/api/fundamentals.ts","./src/api/health.ts","./src/api/indicators.ts","./src/api/ingestion.ts","./src/api/jobs.ts","./src/api/ohlcv.ts","./src/api/performance.ts","./src/api/scores.ts","./src/api/sentiment.ts","./src/api/sr-levels.ts","./src/api/tickers.ts","./src/api/trades.ts","./src/api/watchlist.ts","./src/components/admin/activationsettings.tsx","./src/components/admin/datacleanup.tsx","./src/components/admin/jobcontrols.tsx","./src/components/admin/pipelinereadinesspanel.tsx","./src/components/admin/recommendationsettings.tsx","./src/components/admin/sentimentprovidersettings.tsx","./src/components/admin/settingsform.tsx","./src/components/admin/tickermanagement.tsx","./src/components/admin/tickeruniversebootstrap.tsx","./src/components/admin/usertable.tsx","./src/components/auth/protectedroute.tsx","./src/components/charts/candlestickchart.tsx","./src/components/layout/appshell.tsx","./src/components/layout/mobilenav.tsx","./src/components/layout/sidebar.tsx","./src/components/rankings/rankingstable.tsx","./src/components/rankings/weightsform.tsx","./src/components/scanner/tradetable.tsx","./src/components/signals/setupspanel.tsx","./src/components/signals/trackrecordpanel.tsx","./src/components/ticker/dimensionbreakdownpanel.tsx","./src/components/ticker/fundamentalspanel.tsx","./src/components/ticker/indicatorselector.tsx","./src/components/ticker/recommendationpanel.tsx","./src/components/ticker/sroverlay.tsx","./src/components/ticker/sentimentpanel.tsx","./src/components/ui/badge.tsx","./src/components/ui/button.tsx","./src/components/ui/callout.tsx","./src/components/ui/confirmdialog.tsx","./src/components/ui/disclosure.tsx","./src/components/ui/dropdown.tsx","./src/components/ui/field.tsx","./src/components/ui/pageheader.tsx","./src/components/ui/scorecard.tsx","./src/components/ui/section.tsx","./src/components/ui/skeleton.tsx","./src/components/ui/tabs.tsx","./src/components/ui/toast.tsx","./src/components/watchlist/addtickerform.tsx","./src/components/watchlist/watchlisttable.tsx","./src/hooks/useactivation.ts","./src/hooks/useadmin.ts","./src/hooks/useauth.ts","./src/hooks/usefetchsymboldata.ts","./src/hooks/useperformance.ts","./src/hooks/usescores.ts","./src/hooks/usetickerdetail.ts","./src/hooks/usetickers.ts","./src/hooks/usetrades.ts","./src/hooks/usewatchlist.ts","./src/lib/format.ts","./src/lib/ingestionstatus.ts","./src/lib/qualification.ts","./src/lib/recommendation.ts","./src/lib/types.ts","./src/pages/adminpage.tsx","./src/pages/dashboardpage.tsx","./src/pages/loginpage.tsx","./src/pages/marketpage.tsx","./src/pages/registerpage.tsx","./src/pages/signalspage.tsx","./src/pages/tickerdetailpage.tsx","./src/stores/authstore.ts"],"version":"5.6.3"}
|
||||
Reference in New Issue
Block a user