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
+2
View File
@@ -82,6 +82,7 @@ from app.routers.watchlist import router as watchlist_router
from app.routers.sentiment import router as sentiment_router from app.routers.sentiment import router as sentiment_router
from app.routers.sr_levels import router as sr_levels_router from app.routers.sr_levels import router as sr_levels_router
from app.routers.tickers import router as tickers_router from app.routers.tickers import router as tickers_router
from app.routers.jobs import router as jobs_router
def _configure_logging() -> None: def _configure_logging() -> None:
@@ -158,3 +159,4 @@ app.include_router(fundamentals_router, prefix="/api/v1")
app.include_router(scores_router, prefix="/api/v1") app.include_router(scores_router, prefix="/api/v1")
app.include_router(trades_router, prefix="/api/v1") app.include_router(trades_router, prefix="/api/v1")
app.include_router(watchlist_router, prefix="/api/v1") app.include_router(watchlist_router, prefix="/api/v1")
app.include_router(jobs_router, prefix="/api/v1")
+55 -23
View File
@@ -39,6 +39,9 @@ logger = logging.getLogger(__name__)
router = APIRouter(tags=["ingestion"]) router = APIRouter(tags=["ingestion"])
_PROVIDER_SOURCES = {"ohlcv", "sentiment", "fundamentals"}
def _get_provider() -> AlpacaOHLCVProvider: def _get_provider() -> AlpacaOHLCVProvider:
"""Build the OHLCV provider from current settings.""" """Build the OHLCV provider from current settings."""
if not settings.alpaca_api_key or not settings.alpaca_api_secret: if not settings.alpaca_api_key or not settings.alpaca_api_secret:
@@ -46,26 +49,47 @@ def _get_provider() -> AlpacaOHLCVProvider:
return AlpacaOHLCVProvider(settings.alpaca_api_key, settings.alpaca_api_secret) return AlpacaOHLCVProvider(settings.alpaca_api_key, settings.alpaca_api_secret)
def _parse_requested_sources(sources: str | None) -> set[str]:
"""Which provider sources to fetch. None/'all' → every provider source.
Anything else is parsed as a comma list; an empty/recompute-only request
fetches no providers (just refreshes the free derived pipeline).
"""
if sources is None:
return set(_PROVIDER_SOURCES)
parts = {p.strip().lower() for p in sources.split(",") if p.strip()}
if "all" in parts:
return set(_PROVIDER_SOURCES)
return parts & _PROVIDER_SOURCES
@router.post("/ingestion/fetch/{symbol}", response_model=APIEnvelope) @router.post("/ingestion/fetch/{symbol}", response_model=APIEnvelope)
async def fetch_symbol( async def fetch_symbol(
symbol: str, symbol: str,
start_date: date | None = Query(None, description="Start date (YYYY-MM-DD)"), start_date: date | None = Query(None, description="Start date (YYYY-MM-DD)"),
end_date: date | None = Query(None, description="End date (YYYY-MM-DD)"), end_date: date | None = Query(None, description="End date (YYYY-MM-DD)"),
force_refetch: bool = Query(False, description="Delete existing OHLCV data and re-fetch split-adjusted history"), force_refetch: bool = Query(False, description="Delete existing OHLCV data and re-fetch split-adjusted history"),
sources: str | None = Query(
None,
description="Comma list of provider sources to fetch (ohlcv,sentiment,fundamentals). "
"Omit for all. The derived pipeline (S/R, scores, scanner) always recomputes — it's free.",
),
_user: User = Depends(require_access), _user: User = Depends(require_access),
db: AsyncSession = Depends(get_db), db: AsyncSession = Depends(get_db),
): ):
"""Fetch all data sources for a ticker: OHLCV, sentiment, and fundamentals. """Fetch selected data sources for a ticker, then recompute derived data.
Returns a per-source breakdown so the frontend can show exactly what Provider calls (which may cost money/quota — sentiment especially) are
succeeded and what failed. limited to ``sources``; the free derived pipeline (S/R, scores, scanner)
always runs so everything stays consistent. Returns a per-source breakdown.
""" """
symbol_upper = symbol.strip().upper() symbol_upper = symbol.strip().upper()
sources: dict[str, dict] = {} requested = _parse_requested_sources(sources)
sources_out: dict[str, dict] = {}
# If force_refetch is requested, clear old OHLCV and ingestion progress # If force_refetch is requested, clear old OHLCV and ingestion progress
# so the backfill logic pulls a full year of fresh split-adjusted data. # so the backfill logic pulls a full year of fresh split-adjusted data.
if force_refetch: if force_refetch and "ohlcv" in requested:
try: try:
result = await db.execute( result = await db.execute(
select(Ticker).where(Ticker.symbol == symbol_upper) select(Ticker).where(Ticker.symbol == symbol_upper)
@@ -84,26 +108,28 @@ async def fetch_symbol(
logger.error("force_refetch cleanup failed for %s: %s", symbol_upper, exc) logger.error("force_refetch cleanup failed for %s: %s", symbol_upper, exc)
# --- OHLCV --- # --- OHLCV ---
if "ohlcv" in requested:
try: try:
provider = _get_provider() provider = _get_provider()
result = await ingestion_service.fetch_and_ingest( result = await ingestion_service.fetch_and_ingest(
db, provider, symbol_upper, start_date, end_date db, provider, symbol_upper, start_date, end_date
) )
sources["ohlcv"] = { sources_out["ohlcv"] = {
"status": "ok" if result.status in ("complete", "partial") else "error", "status": "ok" if result.status in ("complete", "partial") else "error",
"records": result.records_ingested, "records": result.records_ingested,
"message": result.message, "message": result.message,
} }
except Exception as exc: except Exception as exc:
logger.error("OHLCV fetch failed for %s: %s", symbol_upper, exc) logger.error("OHLCV fetch failed for %s: %s", symbol_upper, exc)
sources["ohlcv"] = {"status": "error", "records": 0, "message": str(exc)} sources_out["ohlcv"] = {"status": "error", "records": 0, "message": str(exc)}
# --- Sentiment --- # --- Sentiment ---
if "sentiment" in requested:
try: try:
sent_provider = await build_sentiment_provider(db) sent_provider = await build_sentiment_provider(db)
except ProviderError as exc: except ProviderError as exc:
sent_provider = None sent_provider = None
sources["sentiment"] = {"status": "skipped", "message": str(exc)} sources_out["sentiment"] = {"status": "skipped", "message": str(exc)}
if sent_provider is not None: if sent_provider is not None:
try: try:
@@ -118,7 +144,7 @@ async def fetch_symbol(
reasoning=data.reasoning, reasoning=data.reasoning,
citations=data.citations, citations=data.citations,
) )
sources["sentiment"] = { sources_out["sentiment"] = {
"status": "ok", "status": "ok",
"classification": data.classification, "classification": data.classification,
"confidence": data.confidence, "confidence": data.confidence,
@@ -126,9 +152,10 @@ async def fetch_symbol(
} }
except Exception as exc: except Exception as exc:
logger.error("Sentiment fetch failed for %s: %s", symbol_upper, exc) logger.error("Sentiment fetch failed for %s: %s", symbol_upper, exc)
sources["sentiment"] = {"status": "error", "message": str(exc)} sources_out["sentiment"] = {"status": "error", "message": str(exc)}
# --- Fundamentals --- # --- Fundamentals ---
if "fundamentals" in requested:
if settings.fmp_api_key or settings.finnhub_api_key or settings.alpha_vantage_api_key: if settings.fmp_api_key or settings.finnhub_api_key or settings.alpha_vantage_api_key:
try: try:
fundamentals_provider = build_fundamental_provider_chain() fundamentals_provider = build_fundamental_provider_chain()
@@ -142,32 +169,37 @@ async def fetch_symbol(
market_cap=fdata.market_cap, market_cap=fdata.market_cap,
unavailable_fields=fdata.unavailable_fields, unavailable_fields=fdata.unavailable_fields,
) )
sources["fundamentals"] = {"status": "ok", "message": None} sources_out["fundamentals"] = {"status": "ok", "message": None}
except Exception as exc: except Exception as exc:
logger.error("Fundamentals fetch failed for %s: %s", symbol_upper, exc) logger.error("Fundamentals fetch failed for %s: %s", symbol_upper, exc)
sources["fundamentals"] = {"status": "error", "message": str(exc)} sources_out["fundamentals"] = {"status": "error", "message": str(exc)}
else: else:
sources["fundamentals"] = { sources_out["fundamentals"] = {
"status": "skipped", "status": "skipped",
"message": "No fundamentals provider key configured", "message": "No fundamentals provider key configured",
} }
# --- Derived pipeline: S/R levels --- # --- Derived pipeline: S/R levels (free, always) ---
try: try:
levels = await sr_service.recalculate_sr_levels(db, symbol_upper) levels = await sr_service.recalculate_sr_levels(db, symbol_upper)
sources["sr_levels"] = { sources_out["sr_levels"] = {
"status": "ok", "status": "ok",
"count": len(levels), "count": len(levels),
"message": None, "message": None,
} }
except Exception as exc: except Exception as exc:
logger.error("S/R recalc failed for %s: %s", symbol_upper, exc) logger.error("S/R recalc failed for %s: %s", symbol_upper, exc)
sources["sr_levels"] = {"status": "error", "message": str(exc)} sources_out["sr_levels"] = {"status": "error", "message": str(exc)}
# --- Derived pipeline: scores --- # --- Derived pipeline: scores (free, always) ---
# Force a full recompute — fetched data doesn't mark old scores stale, so
# get_score alone would keep returning the previously computed values.
try: try:
await scoring_service.compute_all_dimensions(db, symbol_upper)
await scoring_service.compute_composite_score(db, symbol_upper)
await db.commit()
score_payload = await scoring_service.get_score(db, symbol_upper) score_payload = await scoring_service.get_score(db, symbol_upper)
sources["scores"] = { sources_out["scores"] = {
"status": "ok", "status": "ok",
"composite_score": score_payload.get("composite_score"), "composite_score": score_payload.get("composite_score"),
"missing_dimensions": score_payload.get("missing_dimensions", []), "missing_dimensions": score_payload.get("missing_dimensions", []),
@@ -175,27 +207,27 @@ async def fetch_symbol(
} }
except Exception as exc: except Exception as exc:
logger.error("Score recompute failed for %s: %s", symbol_upper, exc) logger.error("Score recompute failed for %s: %s", symbol_upper, exc)
sources["scores"] = {"status": "error", "message": str(exc)} sources_out["scores"] = {"status": "error", "message": str(exc)}
# --- Derived pipeline: scanner --- # --- Derived pipeline: scanner (free, always) ---
try: try:
setups = await scan_ticker( setups = await scan_ticker(
db, db,
symbol_upper, symbol_upper,
rr_threshold=settings.default_rr_threshold, rr_threshold=settings.default_rr_threshold,
) )
sources["scanner"] = { sources_out["scanner"] = {
"status": "ok", "status": "ok",
"setups_found": len(setups), "setups_found": len(setups),
"message": None, "message": None,
} }
except Exception as exc: except Exception as exc:
logger.error("Scanner run failed for %s: %s", symbol_upper, exc) logger.error("Scanner run failed for %s: %s", symbol_upper, exc)
sources["scanner"] = {"status": "error", "message": str(exc)} sources_out["scanner"] = {"status": "error", "message": str(exc)}
# Always return success — per-source breakdown tells the full story # Always return success — per-source breakdown tells the full story
return APIEnvelope( return APIEnvelope(
status="success", status="success",
data={"symbol": symbol_upper, "sources": sources}, data={"symbol": symbol_upper, "sources": sources_out},
error=None, error=None,
) )
+37
View File
@@ -0,0 +1,37 @@
"""Lightweight job status for any authenticated user.
The admin Jobs page has full control; this exposes only which scheduled jobs
are currently running (name + progress) so the UI can show a live activity
indicator without admin rights.
"""
from __future__ import annotations
from fastapi import APIRouter, Depends
from app.dependencies import require_access
from app.schemas.common import APIEnvelope
from app.services.admin_service import JOB_LABELS
router = APIRouter(tags=["jobs"])
@router.get("/jobs/running", response_model=APIEnvelope)
async def list_running_jobs(_user=Depends(require_access)) -> APIEnvelope:
"""Return scheduled jobs that are currently running, with progress."""
from app.scheduler import get_job_runtime_snapshot
snapshot = get_job_runtime_snapshot()
running = []
for name, meta in snapshot.items():
if meta.get("running"):
running.append({
"name": name,
"label": JOB_LABELS.get(name, name),
"progress_pct": meta.get("progress_pct"),
"processed": meta.get("processed"),
"total": meta.get("total"),
"current_ticker": meta.get("current_ticker"),
})
running.sort(key=lambda j: j["name"])
return APIEnvelope(status="success", data={"running": running})
+12
View File
@@ -244,6 +244,18 @@ async def scan_all_tickers(
all_setups: list[TradeSetup] = [] all_setups: list[TradeSetup] = []
for ticker in tickers: for ticker in tickers:
try: try:
# Refresh scores first so the scheduled scan works off current data.
# Nothing else marks scores stale, so without this they'd never
# update for tickers the user doesn't manually fetch.
try:
from app.services import scoring_service
await scoring_service.compute_all_dimensions(db, ticker.symbol)
await scoring_service.compute_composite_score(db, ticker.symbol)
await db.commit()
except Exception:
logger.exception("Error refreshing scores for %s", ticker.symbol)
setups = await scan_ticker( setups = await scan_ticker(
db, ticker.symbol, rr_threshold, atr_multiplier db, ticker.symbol, rr_threshold, atr_multiplier
) )
+11 -2
View File
@@ -13,8 +13,17 @@ export interface FetchDataResult {
sources: Record<string, IngestionSourceResult>; 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 return apiClient
.post<FetchDataResult>(`ingestion/fetch/${symbol}`) .post<FetchDataResult>(`ingestion/fetch/${symbol}`, null, { params })
.then((r) => r.data); .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 { useQuery } from '@tanstack/react-query';
import { useAuthStore } from '../../stores/authStore'; import { useAuthStore } from '../../stores/authStore';
import { check as healthCheck } from '../../api/health'; import { check as healthCheck } from '../../api/health';
import { getRunningJobs } from '../../api/jobs';
const navItems = [ const navItems = [
{ to: '/', label: 'Overview', index: '01', end: true }, { to: '/', label: 'Overview', index: '01', end: true },
@@ -28,6 +29,16 @@ export default function Sidebar() {
const isBackendUp = health.isSuccess; 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 ( 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"> <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 */} {/* Brand */}
@@ -69,6 +80,23 @@ export default function Sidebar() {
{isBackendUp ? 'Backend online' : 'Backend offline'} {isBackendUp ? 'Backend online' : 'Backend offline'}
</span> </span>
</div> </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 && ( {username && (
<p className="text-xs text-gray-500 truncate px-1">Signed in as {username}</p> <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 { 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 { useToast } from '../components/ui/Toast';
import { summarizeIngestionResult } from '../lib/ingestionStatus'; import { summarizeIngestionResult } from '../lib/ingestionStatus';
@@ -8,14 +8,26 @@ interface UseFetchSymbolDataOptions {
invalidatePipelineReadiness?: boolean; 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 = {}) { export function useFetchSymbolData(options: UseFetchSymbolDataOptions = {}) {
const { includeSymbolPrefix = false, invalidatePipelineReadiness = false } = options; const { includeSymbolPrefix = false, invalidatePipelineReadiness = false } = options;
const queryClient = useQueryClient(); const queryClient = useQueryClient();
const { addToast } = useToast(); const { addToast } = useToast();
return useMutation({ return useMutation({
mutationFn: (symbol: string) => fetchData(symbol), // Accepts either a bare symbol (fetch all) or { symbol, sources } (granular)
onSuccess: (result: FetchDataResult, symbol: string) => { 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 normalized = symbol.toUpperCase();
const summary = summarizeIngestionResult(result, normalized); const summary = summarizeIngestionResult(result, normalized);
const toastMessage = includeSymbolPrefix const toastMessage = includeSymbolPrefix
@@ -33,8 +45,8 @@ export function useFetchSymbolData(options: UseFetchSymbolDataOptions = {}) {
queryClient.invalidateQueries({ queryKey: ['admin', 'pipeline-readiness'] }); queryClient.invalidateQueries({ queryKey: ['admin', 'pipeline-readiness'] });
} }
}, },
onError: (err: Error, symbol: string) => { onError: (err: Error, arg: FetchArg) => {
const normalized = symbol.toUpperCase(); const normalized = argSymbol(arg).toUpperCase();
const prefix = includeSymbolPrefix ? `${normalized}: ` : ''; const prefix = includeSymbolPrefix ? `${normalized}: ` : '';
addToast('error', `${prefix}${err.message || 'Failed to fetch data'}`); 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 { useParams } from 'react-router-dom';
import { useTickerDetail } from '../hooks/useTickerDetail'; import { useTickerDetail } from '../hooks/useTickerDetail';
import { useFetchSymbolData } from '../hooks/useFetchSymbolData'; import { useFetchSymbolData } from '../hooks/useFetchSymbolData';
import type { FetchSelector } from '../api/ingestion';
import { CandlestickChart } from '../components/charts/CandlestickChart'; import { CandlestickChart } from '../components/charts/CandlestickChart';
import { ScoreCard } from '../components/ui/ScoreCard'; import { ScoreCard } from '../components/ui/ScoreCard';
import { SkeletonCard } from '../components/ui/Skeleton'; import { SkeletonCard } from '../components/ui/Skeleton';
@@ -42,23 +43,51 @@ interface DataStatusItem {
label: string; label: string;
available: boolean; available: boolean;
timestamp?: string | null; 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 ( 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) => ( {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 ${ <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' item.available ? 'bg-emerald-400 shadow-lg shadow-emerald-400/40' : 'bg-gray-600'
}`} /> }`} />
<span className="text-xs text-gray-400">{item.label}</span> <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> <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> <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>
))} ))}
</div> </div>
@@ -70,35 +99,52 @@ export default function TickerDetailPage() {
const { ohlcv, scores, srLevels, sentiment, fundamentals, trades } = useTickerDetail(symbol); const { ohlcv, scores, srLevels, sentiment, fundamentals, trades } = useTickerDetail(symbol);
const ingestion = useFetchSymbolData(); const ingestion = useFetchSymbolData();
const [activeTab, setActiveTab] = useState<DetailTab>('Analysis'); const [activeTab, setActiveTab] = useState<DetailTab>('Analysis');
const [refreshingLabel, setRefreshingLabel] = useState<string | null>(null);
const dataStatus: DataStatusItem[] = useMemo(() => [ const dataStatus: DataStatusItem[] = useMemo(() => [
{ {
label: 'OHLCV', label: 'OHLCV',
available: !!ohlcv.data && ohlcv.data.length > 0, available: !!ohlcv.data && ohlcv.data.length > 0,
timestamp: ohlcv.data?.[ohlcv.data.length - 1]?.created_at, timestamp: ohlcv.data?.[ohlcv.data.length - 1]?.created_at,
selector: ['ohlcv'] as FetchSelector,
paid: true,
}, },
{ {
label: 'Sentiment', label: 'Sentiment',
available: !!sentiment.data && sentiment.data.count > 0, available: !!sentiment.data && sentiment.data.count > 0,
timestamp: sentiment.data?.scores?.[0]?.timestamp, timestamp: sentiment.data?.scores?.[0]?.timestamp,
selector: ['sentiment'] as FetchSelector,
paid: true,
}, },
{ {
label: 'Fundamentals', label: 'Fundamentals',
available: !!fundamentals.data && fundamentals.data.fetched_at !== null, available: !!fundamentals.data && fundamentals.data.fetched_at !== null,
timestamp: fundamentals.data?.fetched_at, timestamp: fundamentals.data?.fetched_at,
selector: ['fundamentals'] as FetchSelector,
paid: true,
}, },
{ {
label: 'S/R Levels', label: 'S/R Levels',
available: !!srLevels.data && srLevels.data.count > 0, available: !!srLevels.data && srLevels.data.count > 0,
timestamp: srLevels.data?.levels?.[0]?.created_at, timestamp: srLevels.data?.levels?.[0]?.created_at,
selector: 'recompute' as FetchSelector,
}, },
{ {
label: 'Scores', label: 'Scores',
available: !!scores.data && scores.data.composite_score !== null, available: !!scores.data && scores.data.composite_score !== null,
timestamp: scores.data?.computed_at, timestamp: scores.data?.computed_at,
selector: 'recompute' as FetchSelector,
}, },
], [ohlcv.data, sentiment.data, fundamentals.data, srLevels.data, scores.data]); ], [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 // Log trades API errors but don't disrupt the page
useEffect(() => { useEffect(() => {
if (trades.error) { if (trades.error) {
@@ -170,13 +216,21 @@ export default function TickerDetailPage() {
</div> </div>
)} )}
</div> </div>
<Button onClick={() => ingestion.mutate(symbol)} loading={ingestion.isPending}> <Button
{ingestion.isPending ? 'Fetching…' : 'Fetch Data'} onClick={() => { setRefreshingLabel(null); ingestion.mutate(symbol); }}
loading={ingestion.isPending}
>
{ingestion.isPending ? 'Fetching…' : 'Fetch All'}
</Button> </Button>
</div> </div>
{/* Data freshness bar */} {/* Data freshness bar */}
<DataFreshnessBar items={dataStatus} /> <DataFreshnessBar
items={dataStatus}
onRefresh={handleRefresh}
pendingLabel={refreshingLabel}
busy={ingestion.isPending}
/>
<RecommendationPanel <RecommendationPanel
symbol={symbol} symbol={symbol}
+1 -1
View File
@@ -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"}