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:
@@ -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
@@ -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,
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -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})
|
||||||
@@ -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
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -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);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -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'}`);
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -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 @@
|
|||||||
{"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