Big refactoring
Some checks failed
Deploy / lint (push) Failing after 21s
Deploy / test (push) Has been skipped
Deploy / deploy (push) Has been skipped

This commit is contained in:
Dennis Thiessen
2026-03-03 15:20:18 +01:00
parent 181cfe6588
commit 0a011d4ce9
55 changed files with 6898 additions and 544 deletions

View File

@@ -1,5 +1,13 @@
import apiClient from './client';
import type { AdminUser, SystemSetting } from '../lib/types';
import type {
AdminUser,
PipelineReadiness,
RecommendationConfig,
SystemSetting,
TickerUniverse,
TickerUniverseBootstrapResult,
TickerUniverseSetting,
} from '../lib/types';
// Users
export function listUsers() {
@@ -48,6 +56,41 @@ export function updateRegistration(enabled: boolean) {
.then((r) => r.data);
}
export function getRecommendationSettings() {
return apiClient
.get<RecommendationConfig>('admin/settings/recommendations')
.then((r) => r.data);
}
export function updateRecommendationSettings(payload: Partial<RecommendationConfig>) {
return apiClient
.put<RecommendationConfig>('admin/settings/recommendations', payload)
.then((r) => r.data);
}
export function getTickerUniverseSetting() {
return apiClient
.get<TickerUniverseSetting>('admin/settings/ticker-universe')
.then((r) => r.data);
}
export function updateTickerUniverseSetting(universe: TickerUniverse) {
return apiClient
.put<TickerUniverseSetting>('admin/settings/ticker-universe', { universe })
.then((r) => r.data);
}
export function bootstrapTickers(universe: TickerUniverse, pruneMissing: boolean) {
return apiClient
.post<TickerUniverseBootstrapResult>('admin/tickers/bootstrap', null, {
params: {
universe,
prune_missing: pruneMissing,
},
})
.then((r) => r.data);
}
// Jobs
export interface JobStatus {
name: string;
@@ -55,12 +98,31 @@ export interface JobStatus {
enabled: boolean;
next_run_at: string | null;
registered: boolean;
running?: boolean;
runtime_status?: string | null;
runtime_processed?: number | null;
runtime_total?: number | null;
runtime_progress_pct?: number | null;
runtime_current_ticker?: string | null;
runtime_started_at?: string | null;
runtime_finished_at?: string | null;
runtime_message?: string | null;
}
export interface TriggerJobResponse {
job: string;
status: 'triggered' | 'busy' | 'blocked' | 'not_found';
message: string;
}
export function listJobs() {
return apiClient.get<JobStatus[]>('admin/jobs').then((r) => r.data);
}
export function getPipelineReadiness() {
return apiClient.get<PipelineReadiness[]>('admin/pipeline/readiness').then((r) => r.data);
}
export function toggleJob(jobName: string, enabled: boolean) {
return apiClient
.put<{ message: string }>(`admin/jobs/${jobName}/toggle`, { enabled })
@@ -69,7 +131,7 @@ export function toggleJob(jobName: string, enabled: boolean) {
export function triggerJob(jobName: string) {
return apiClient
.post<{ message: string }>(`admin/jobs/${jobName}/trigger`)
.post<TriggerJobResponse>(`admin/jobs/${jobName}/trigger`)
.then((r) => r.data);
}

View File

@@ -1,7 +1,20 @@
import apiClient from './client';
export interface IngestionSourceResult {
status: 'ok' | 'error' | 'skipped';
message?: string | null;
records?: number;
classification?: string;
confidence?: number;
}
export interface FetchDataResult {
symbol: string;
sources: Record<string, IngestionSourceResult>;
}
export function fetchData(symbol: string) {
return apiClient
.post<{ message: string }>(`ingestion/fetch/${symbol}`)
.post<FetchDataResult>(`ingestion/fetch/${symbol}`)
.then((r) => r.data);
}

View File

@@ -1,6 +1,20 @@
import apiClient from './client';
import type { TradeSetup } from '../lib/types';
export function list() {
return apiClient.get<TradeSetup[]>('trades').then((r) => r.data);
export interface TradeListParams {
direction?: 'long' | 'short';
min_confidence?: number;
recommended_action?: 'LONG_HIGH' | 'LONG_MODERATE' | 'SHORT_HIGH' | 'SHORT_MODERATE' | 'NEUTRAL';
}
export function list(params?: TradeListParams) {
return apiClient.get<TradeSetup[]>('trades', { params }).then((r) => r.data);
}
export function bySymbol(symbol: string) {
return apiClient.get<TradeSetup[]>(`trades/${symbol.toUpperCase()}`).then((r) => r.data);
}
export function history(symbol: string) {
return apiClient.get<TradeSetup[]>(`trades/${symbol.toUpperCase()}/history`).then((r) => r.data);
}

View File

@@ -17,11 +17,79 @@ export function JobControls() {
const { data: jobs, isLoading } = useJobs();
const toggleJob = useToggleJob();
const triggerJob = useTriggerJob();
const anyJobRunning = (jobs ?? []).some((job) => job.running);
const runningJob = jobs?.find((job) => job.running);
const pausedJob = jobs?.find((job) => !job.running && job.runtime_status === 'rate_limited');
const runningJobLabel = runningJob?.label;
if (isLoading) return <SkeletonTable rows={4} cols={3} />;
return (
<div className="space-y-3">
{runningJob && (
<div className="rounded-xl border border-blue-400/30 bg-blue-500/10 px-4 py-3">
<div className="flex flex-wrap items-center justify-between gap-3">
<div>
<div className="text-xs font-semibold text-blue-300">
Active job: {runningJob.label}
</div>
<div className="mt-0.5 text-[11px] text-blue-100/80">
Manual triggers are blocked until this run finishes.
</div>
</div>
<div className="text-[11px] text-blue-200">
{runningJob.runtime_processed ?? 0}
{typeof runningJob.runtime_total === 'number'
? ` / ${runningJob.runtime_total}`
: ''}
</div>
</div>
<div className="mt-2 h-1.5 w-full rounded-full bg-slate-700/80 overflow-hidden">
<div
className="h-full bg-blue-400 transition-all duration-500"
style={{
width: `${
typeof runningJob.runtime_progress_pct === 'number'
? Math.max(5, Math.min(100, runningJob.runtime_progress_pct))
: 30
}%`,
}}
/>
</div>
{runningJob.runtime_current_ticker && (
<div className="mt-1 text-[11px] text-blue-100/80">
Current: {runningJob.runtime_current_ticker}
</div>
)}
{runningJob.runtime_message && (
<div className="mt-1 text-[11px] text-blue-100/80">
{runningJob.runtime_message}
</div>
)}
</div>
)}
{!runningJob && pausedJob && (
<div className="rounded-xl border border-amber-400/30 bg-amber-500/10 px-4 py-3">
<div className="flex flex-wrap items-center justify-between gap-3">
<div>
<div className="text-xs font-semibold text-amber-300">
Last run paused: {pausedJob.label}
</div>
<div className="mt-0.5 text-[11px] text-amber-100/90">
{pausedJob.runtime_message || 'Rate limit hit. The collector stopped early and will resume from last progress on the next run.'}
</div>
</div>
<div className="text-[11px] text-amber-200">
{pausedJob.runtime_processed ?? 0}
{typeof pausedJob.runtime_total === 'number'
? ` / ${pausedJob.runtime_total}`
: ''}
</div>
</div>
</div>
)}
{jobs?.map((job) => (
<div key={job.name} className="glass p-4 glass-hover">
<div className="flex flex-wrap items-center justify-between gap-4">
@@ -29,7 +97,9 @@ export function JobControls() {
{/* Status dot */}
<span
className={`inline-block h-2.5 w-2.5 rounded-full shrink-0 ${
job.enabled
job.running
? 'bg-blue-400 shadow-lg shadow-blue-400/40'
: job.enabled
? 'bg-emerald-400 shadow-lg shadow-emerald-400/40'
: 'bg-gray-500'
}`}
@@ -37,8 +107,28 @@ export function JobControls() {
<div>
<span className="text-sm font-medium text-gray-200">{job.label}</span>
<div className="flex items-center gap-3 mt-0.5">
<span className={`text-[11px] font-medium ${job.enabled ? 'text-emerald-400' : 'text-gray-500'}`}>
{job.enabled ? 'Active' : 'Inactive'}
<span
className={`text-[11px] font-medium ${
job.running
? 'text-blue-300'
: job.runtime_status === 'rate_limited'
? 'text-amber-300'
: job.runtime_status === 'error'
? 'text-red-300'
: job.enabled
? 'text-emerald-400'
: 'text-gray-500'
}`}
>
{job.running
? 'Running'
: job.runtime_status === 'rate_limited'
? 'Paused (rate-limited)'
: job.runtime_status === 'error'
? 'Last run error'
: job.enabled
? 'Active'
: 'Inactive'}
</span>
{job.enabled && job.next_run_at && (
<span className="text-[11px] text-gray-500">
@@ -49,6 +139,35 @@ export function JobControls() {
<span className="text-[11px] text-red-400">Not registered</span>
)}
</div>
{job.running && (
<div className="mt-2 space-y-1.5">
<div className="flex items-center justify-between text-[11px] text-gray-400">
<span>
{job.runtime_processed ?? 0}
{typeof job.runtime_total === 'number' ? ` / ${job.runtime_total}` : ''}
{' '}processed
</span>
{typeof job.runtime_progress_pct === 'number' && (
<span>{Math.max(0, Math.min(100, job.runtime_progress_pct)).toFixed(0)}%</span>
)}
</div>
<div className="h-1.5 w-56 rounded-full bg-slate-700/80 overflow-hidden">
<div
className="h-full bg-blue-400 transition-all duration-500"
style={{
width: `${
typeof job.runtime_progress_pct === 'number'
? Math.max(5, Math.min(100, job.runtime_progress_pct))
: 30
}%`,
}}
/>
</div>
{job.runtime_current_ticker && (
<div className="text-[11px] text-gray-500">Current: {job.runtime_current_ticker}</div>
)}
</div>
)}
</div>
</div>
@@ -68,13 +187,26 @@ export function JobControls() {
<button
type="button"
onClick={() => triggerJob.mutate(job.name)}
disabled={triggerJob.isPending || !job.enabled}
disabled={triggerJob.isPending || !job.enabled || anyJobRunning}
className="btn-gradient px-3 py-1.5 text-xs disabled:opacity-50 disabled:cursor-not-allowed"
>
<span>{triggerJob.isPending ? 'Triggering…' : 'Trigger Now'}</span>
<span>
{job.running
? 'Running…'
: triggerJob.isPending
? 'Triggering…'
: anyJobRunning
? 'Blocked'
: 'Trigger Now'}
</span>
</button>
</div>
</div>
{anyJobRunning && !job.running && (
<div className="mt-2 text-[11px] text-gray-500">
Manual trigger blocked while {runningJobLabel ?? 'another job'} is running.
</div>
)}
</div>
))}
</div>

View File

@@ -0,0 +1,105 @@
import { useQueryClient } from '@tanstack/react-query';
import { usePipelineReadiness } from '../../hooks/useAdmin';
import { useFetchSymbolData } from '../../hooks/useFetchSymbolData';
import { formatDateTime } from '../../lib/format';
import { SkeletonTable } from '../ui/Skeleton';
function scoreBadge(score: number | null) {
if (score === null) return <span className="text-[11px] text-gray-500"></span>;
const cls = score >= 60 ? 'text-emerald-400' : score >= 40 ? 'text-amber-400' : 'text-red-400';
return <span className={`text-[11px] font-medium ${cls}`}>{score.toFixed(0)}</span>;
}
export function PipelineReadinessPanel() {
const queryClient = useQueryClient();
const { data, isLoading, isError, error, isFetching } = usePipelineReadiness();
const fetchMutation = useFetchSymbolData({
includeSymbolPrefix: true,
invalidatePipelineReadiness: true,
});
if (isLoading) return <SkeletonTable rows={6} cols={6} />;
if (isError) return <p className="text-sm text-red-400">{(error as Error)?.message || 'Failed to load pipeline readiness'}</p>;
const rows = data ?? [];
return (
<div className="glass p-4 space-y-3">
<div className="flex items-center justify-between gap-3">
<div>
<h3 className="text-sm font-semibold text-gray-200">Pipeline Readiness</h3>
<p className="text-xs text-gray-500">Shows why tickers may be missing in scanner/rankings and what is incomplete.</p>
</div>
<button
type="button"
className="rounded border border-white/[0.12] px-3 py-1.5 text-xs text-gray-300 hover:text-white disabled:opacity-50"
onClick={() => queryClient.invalidateQueries({ queryKey: ['admin', 'pipeline-readiness'] })}
disabled={isFetching}
>
{isFetching ? 'Refreshing…' : 'Refresh'}
</button>
</div>
{rows.length === 0 ? (
<p className="text-sm text-gray-500">No tickers available.</p>
) : (
<div className="overflow-x-auto">
<table className="w-full text-left text-xs">
<thead>
<tr className="border-b border-white/[0.08] text-gray-500 uppercase tracking-wider">
<th className="px-2 py-2">Symbol</th>
<th className="px-2 py-2">OHLCV</th>
<th className="px-2 py-2">Dims</th>
<th className="px-2 py-2">S/R</th>
<th className="px-2 py-2">Scanner</th>
<th className="px-2 py-2">Missing Reasons</th>
<th className="px-2 py-2">Action</th>
</tr>
</thead>
<tbody>
{rows.map((row) => (
<tr key={row.symbol} className="border-b border-white/[0.05] align-top">
<td className="px-2 py-2 font-medium text-gray-200">{row.symbol}</td>
<td className="px-2 py-2 text-gray-300">
<div>{row.ohlcv_bars} bars</div>
<div className="text-[11px] text-gray-500">{row.ohlcv_last_date ? formatDateTime(row.ohlcv_last_date) : '—'}</div>
</td>
<td className="px-2 py-2 text-gray-300">
<div className="grid grid-cols-5 gap-1">
{scoreBadge(row.dimensions.technical)}
{scoreBadge(row.dimensions.sr_quality)}
{scoreBadge(row.dimensions.sentiment)}
{scoreBadge(row.dimensions.fundamental)}
{scoreBadge(row.dimensions.momentum)}
</div>
<div className="mt-1 text-[10px] text-gray-500">T SR S F M</div>
</td>
<td className="px-2 py-2 text-gray-300">{row.sr_level_count}</td>
<td className="px-2 py-2">
<span className={`inline-block rounded px-2 py-0.5 text-[11px] ${row.ready_for_scanner ? 'bg-emerald-500/20 text-emerald-300' : 'bg-amber-500/20 text-amber-300'}`}>
{row.ready_for_scanner ? 'Ready' : 'Blocked'}
</span>
<div className="mt-1 text-[11px] text-gray-500">setups: {row.trade_setup_count}</div>
</td>
<td className="px-2 py-2 text-[11px] text-amber-300">
{row.missing_reasons.length ? row.missing_reasons.join(', ') : <span className="text-emerald-300">none</span>}
</td>
<td className="px-2 py-2">
<button
type="button"
className="rounded border border-white/[0.12] px-2.5 py-1 text-[11px] text-gray-300 hover:text-white disabled:opacity-50"
onClick={() => fetchMutation.mutate(row.symbol)}
disabled={fetchMutation.isPending}
>
{fetchMutation.isPending && fetchMutation.variables === row.symbol ? 'Fetching…' : 'Fetch Data'}
</button>
</td>
</tr>
))}
</tbody>
</table>
</div>
)}
</div>
);
}

View File

@@ -0,0 +1,101 @@
import { useEffect, useState } from 'react';
import type { RecommendationConfig } from '../../lib/types';
import { useRecommendationSettings, useUpdateRecommendationSettings } from '../../hooks/useAdmin';
import { SkeletonTable } from '../ui/Skeleton';
const DEFAULTS: RecommendationConfig = {
high_confidence_threshold: 70,
moderate_confidence_threshold: 50,
confidence_diff_threshold: 20,
signal_alignment_weight: 0.15,
sr_strength_weight: 0.2,
distance_penalty_factor: 0.1,
momentum_technical_divergence_threshold: 30,
fundamental_technical_divergence_threshold: 40,
};
function NumberInput({
label,
value,
min,
max,
step,
onChange,
}: {
label: string;
value: number;
min: number;
max: number;
step?: number;
onChange: (v: number) => void;
}) {
return (
<label className="block space-y-1">
<span className="text-xs text-gray-400">{label}</span>
<input
type="number"
min={min}
max={max}
step={step}
value={value}
onChange={(e) => onChange(Number(e.target.value))}
className="w-full input-glass px-3 py-2 text-sm"
/>
</label>
);
}
export function RecommendationSettings() {
const { data, isLoading, isError, error } = useRecommendationSettings();
const update = useUpdateRecommendationSettings();
const [form, setForm] = useState<RecommendationConfig>(DEFAULTS);
useEffect(() => {
if (data) setForm(data);
}, [data]);
const setField = (field: keyof RecommendationConfig, value: number) => {
setForm((prev) => ({ ...prev, [field]: value }));
};
const onSave = () => {
update.mutate(form as unknown as Record<string, number>);
};
const onReset = () => {
setForm(DEFAULTS);
update.mutate(DEFAULTS as unknown as Record<string, number>);
};
if (isLoading) return <SkeletonTable rows={6} cols={2} />;
if (isError) return <p className="text-sm text-red-400">{(error as Error)?.message || 'Failed to load recommendation settings'}</p>;
return (
<div className="glass p-5 space-y-4">
<h3 className="text-sm font-semibold text-gray-200">Recommendation Configuration</h3>
<div className="grid gap-4 md:grid-cols-3">
<NumberInput label="High Confidence Threshold (%)" value={form.high_confidence_threshold} min={0} max={100} onChange={(v) => setField('high_confidence_threshold', v)} />
<NumberInput label="Moderate Confidence Threshold (%)" value={form.moderate_confidence_threshold} min={0} max={100} onChange={(v) => setField('moderate_confidence_threshold', v)} />
<NumberInput label="Confidence Difference Threshold (%)" value={form.confidence_diff_threshold} min={0} max={100} onChange={(v) => setField('confidence_diff_threshold', v)} />
<NumberInput label="Signal Alignment Weight" value={form.signal_alignment_weight} min={0} max={1} step={0.01} onChange={(v) => setField('signal_alignment_weight', v)} />
<NumberInput label="S/R Strength Weight" value={form.sr_strength_weight} min={0} max={1} step={0.01} onChange={(v) => setField('sr_strength_weight', v)} />
<NumberInput label="Distance Penalty Factor" value={form.distance_penalty_factor} min={0} max={1} step={0.01} onChange={(v) => setField('distance_penalty_factor', v)} />
<NumberInput label="Momentum-Technical Divergence Threshold" value={form.momentum_technical_divergence_threshold} min={0} max={100} onChange={(v) => setField('momentum_technical_divergence_threshold', v)} />
<NumberInput label="Fundamental-Technical Divergence Threshold" value={form.fundamental_technical_divergence_threshold} min={0} max={100} onChange={(v) => setField('fundamental_technical_divergence_threshold', v)} />
</div>
<div className="flex items-center gap-2">
<button className="btn-gradient px-4 py-2 text-sm" onClick={onSave} disabled={update.isPending}>
{update.isPending ? 'Saving…' : 'Save Configuration'}
</button>
<button className="px-4 py-2 text-sm rounded border border-white/[0.1] text-gray-300 hover:text-white" onClick={onReset} disabled={update.isPending}>
Reset to Defaults
</button>
</div>
</div>
);
}

View File

@@ -0,0 +1,97 @@
import { useEffect, useState } from 'react';
import {
useBootstrapTickers,
useTickerUniverseSetting,
useUpdateTickerUniverseSetting,
} from '../../hooks/useAdmin';
import type { TickerUniverse } from '../../lib/types';
const UNIVERSE_OPTIONS: Array<{ value: TickerUniverse; label: string }> = [
{ value: 'sp500', label: 'S&P 500' },
{ value: 'nasdaq100', label: 'NASDAQ 100' },
{ value: 'nasdaq_all', label: 'NASDAQ All' },
];
export function TickerUniverseBootstrap() {
const { data, isLoading, isError, error } = useTickerUniverseSetting();
const updateDefault = useUpdateTickerUniverseSetting();
const bootstrap = useBootstrapTickers();
const [universe, setUniverse] = useState<TickerUniverse>('sp500');
const [pruneMissing, setPruneMissing] = useState(false);
useEffect(() => {
if (data?.universe) {
setUniverse(data.universe);
}
}, [data]);
const onSaveDefault = () => {
updateDefault.mutate(universe);
};
const onBootstrap = () => {
bootstrap.mutate({ universe, pruneMissing });
};
return (
<div className="glass p-5 space-y-4">
<h3 className="text-sm font-semibold text-gray-200">Ticker Universe Discovery</h3>
<p className="text-xs text-gray-500">
Auto-discover tickers from a predefined universe and keep your registry updated.
</p>
{isError && (
<p className="text-sm text-red-400">
{(error as Error)?.message || 'Failed to load ticker universe setting'}
</p>
)}
<div className="grid gap-4 md:grid-cols-3">
<label className="block space-y-1 md:col-span-2">
<span className="text-xs text-gray-400">Default Universe</span>
<select
value={universe}
onChange={(e) => setUniverse(e.target.value as TickerUniverse)}
className="w-full input-glass px-3 py-2 text-sm"
disabled={isLoading || updateDefault.isPending || bootstrap.isPending}
>
{UNIVERSE_OPTIONS.map((option) => (
<option key={option.value} value={option.value}>
{option.label}
</option>
))}
</select>
</label>
<label className="flex items-end gap-2 pb-2">
<input
type="checkbox"
checked={pruneMissing}
onChange={(e) => setPruneMissing(e.target.checked)}
disabled={bootstrap.isPending}
className="h-4 w-4 rounded border-white/20 bg-transparent"
/>
<span className="text-xs text-gray-400">Prune removed symbols</span>
</label>
</div>
<div className="flex flex-wrap gap-2">
<button
className="btn-gradient px-4 py-2 text-sm disabled:opacity-50"
onClick={onSaveDefault}
disabled={isLoading || updateDefault.isPending || bootstrap.isPending}
>
{updateDefault.isPending ? 'Saving…' : 'Save Default Universe'}
</button>
<button
className="px-4 py-2 text-sm rounded border border-white/[0.1] text-gray-300 hover:text-white disabled:opacity-50"
onClick={onBootstrap}
disabled={isLoading || updateDefault.isPending || bootstrap.isPending}
>
{bootstrap.isPending ? 'Bootstrapping…' : 'Bootstrap Now'}
</button>
</div>
</div>
);
}

View File

@@ -1,8 +1,9 @@
import { Link } from 'react-router-dom';
import type { TradeSetup } from '../../lib/types';
import { formatPrice, formatPercent, formatDateTime } from '../../lib/format';
import { recommendationActionDirection, recommendationActionLabel } from '../../lib/recommendation';
export type SortColumn = 'symbol' | 'direction' | 'entry_price' | 'stop_loss' | 'target' | 'risk_amount' | 'reward_amount' | 'rr_ratio' | 'stop_pct' | 'target_pct' | 'composite_score' | 'detected_at';
export type SortColumn = 'symbol' | 'direction' | 'recommended_action' | 'confidence_score' | 'entry_price' | 'stop_loss' | 'target' | 'best_target_probability' | 'risk_amount' | 'reward_amount' | 'rr_ratio' | 'stop_pct' | 'target_pct' | 'risk_level' | 'composite_score' | 'detected_at';
export type SortDirection = 'asc' | 'desc';
interface TradeTableProps {
@@ -14,15 +15,19 @@ interface TradeTableProps {
const columns: { key: SortColumn; label: string }[] = [
{ key: 'symbol', label: 'Symbol' },
{ key: 'recommended_action', label: 'Recommended Action' },
{ key: 'confidence_score', label: 'Confidence' },
{ key: 'direction', label: 'Direction' },
{ key: 'entry_price', label: 'Entry' },
{ key: 'stop_loss', label: 'Stop Loss' },
{ key: 'target', label: 'Target' },
{ key: 'best_target_probability', label: 'Best Target' },
{ key: 'risk_amount', label: 'Risk $' },
{ key: 'reward_amount', label: 'Reward $' },
{ key: 'rr_ratio', label: 'R:R' },
{ key: 'stop_pct', label: '% to Stop' },
{ key: 'target_pct', label: '% to Target' },
{ key: 'risk_level', label: 'Risk' },
{ key: 'composite_score', label: 'Score' },
{ key: 'detected_at', label: 'Detected' },
];
@@ -53,6 +58,19 @@ function sortIndicator(column: SortColumn, active: SortColumn, dir: SortDirectio
return dir === 'asc' ? ' ▲' : ' ▼';
}
function riskLevelClass(riskLevel: TradeSetup['risk_level']) {
if (riskLevel === 'Low') return 'text-emerald-400';
if (riskLevel === 'Medium') return 'text-amber-400';
if (riskLevel === 'High') return 'text-red-400';
return 'text-gray-400';
}
function bestTargetText(trade: TradeSetup) {
if (!trade.targets || trade.targets.length === 0) return '—';
const best = [...trade.targets].sort((a, b) => b.probability - a.probability)[0];
return `${formatPrice(best.price)} (${best.probability.toFixed(0)}%)`;
}
export function TradeTable({ trades, sortColumn, sortDirection, onSort }: TradeTableProps) {
if (trades.length === 0) {
return <p className="py-8 text-center text-sm text-gray-500">No trade setups match the current filters.</p>;
@@ -84,6 +102,17 @@ export function TradeTable({ trades, sortColumn, sortDirection, onSort }: TradeT
{trade.symbol}
</Link>
</td>
<td className="px-4 py-3.5">
<div className="space-y-0.5">
<span className="text-xs font-semibold text-indigo-300">{recommendationActionLabel(trade.recommended_action)}</span>
{recommendationActionDirection(trade.recommended_action) !== 'neutral' && recommendationActionDirection(trade.recommended_action) !== trade.direction && (
<div className="text-[10px] text-amber-400">Alternative setup (not preferred)</div>
)}
</div>
</td>
<td className="px-4 py-3.5">
<span className="font-mono text-gray-200">{trade.confidence_score === null ? '—' : `${trade.confidence_score.toFixed(1)}%`}</span>
</td>
<td className="px-4 py-3.5">
<span className={trade.direction === 'long' ? 'font-medium text-emerald-400' : 'font-medium text-red-400'}>
{trade.direction}
@@ -92,11 +121,13 @@ export function TradeTable({ trades, sortColumn, sortDirection, onSort }: TradeT
<td className="px-4 py-3.5 font-mono text-gray-200">{formatPrice(trade.entry_price)}</td>
<td className="px-4 py-3.5 font-mono text-gray-200">{formatPrice(trade.stop_loss)}</td>
<td className="px-4 py-3.5 font-mono text-gray-200">{formatPrice(trade.target)}</td>
<td className="px-4 py-3.5 font-mono text-gray-200">{bestTargetText(trade)}</td>
<td className="px-4 py-3.5 font-mono text-gray-200">{formatPrice(analysis.risk_amount)}</td>
<td className="px-4 py-3.5 font-mono text-gray-200">{formatPrice(analysis.reward_amount)}</td>
<td className={`px-4 py-3.5 font-mono font-semibold ${rrColorClass(trade.rr_ratio)}`}>{trade.rr_ratio.toFixed(2)}</td>
<td className="px-4 py-3.5 font-mono text-gray-200">{formatPercent(analysis.stop_pct)}</td>
<td className="px-4 py-3.5 font-mono text-gray-200">{formatPercent(analysis.target_pct)}</td>
<td className={`px-4 py-3.5 font-semibold ${riskLevelClass(trade.risk_level)}`}>{trade.risk_level ?? '—'}</td>
<td className="px-4 py-3.5">
<span className={`font-semibold ${trade.composite_score > 70 ? 'text-emerald-400' : trade.composite_score >= 40 ? 'text-amber-400' : 'text-red-400'}`}>
{Math.round(trade.composite_score)}

View File

@@ -0,0 +1,168 @@
import type { TradeSetup } from '../../lib/types';
import { formatPrice, formatPercent } from '../../lib/format';
import { recommendationActionDirection, recommendationActionLabel } from '../../lib/recommendation';
interface RecommendationPanelProps {
symbol: string;
longSetup?: TradeSetup;
shortSetup?: TradeSetup;
}
function riskClass(risk: TradeSetup['risk_level']) {
if (risk === 'Low') return 'text-emerald-400';
if (risk === 'Medium') return 'text-amber-400';
if (risk === 'High') return 'text-red-400';
return 'text-gray-400';
}
function isRecommended(setup: TradeSetup | undefined, action: TradeSetup['recommended_action'] | undefined) {
if (!setup || !action) return false;
if (setup.direction === 'long') return action.startsWith('LONG');
return action.startsWith('SHORT');
}
function TargetTable({ setup }: { setup: TradeSetup }) {
if (!setup.targets || setup.targets.length === 0) {
return <p className="text-xs text-gray-500">No target probabilities available.</p>;
}
return (
<div className="overflow-x-auto">
<table className="w-full text-xs">
<thead>
<tr className="text-left text-gray-500 border-b border-white/[0.06]">
<th className="py-2 pr-3">Classification</th>
<th className="py-2 pr-3">Price</th>
<th className="py-2 pr-3">Distance</th>
<th className="py-2 pr-3">R:R</th>
<th className="py-2">Probability</th>
</tr>
</thead>
<tbody>
{setup.targets.map((target) => (
<tr key={`${setup.id}-${target.sr_level_id}-${target.price}`} className="border-b border-white/[0.04]">
<td className="py-2 pr-3 text-gray-300">{target.classification}</td>
<td className="py-2 pr-3 font-mono text-gray-200">{formatPrice(target.price)}</td>
<td className="py-2 pr-3 font-mono text-gray-200">{formatPercent((target.distance_from_entry / setup.entry_price) * 100)}</td>
<td className="py-2 pr-3 font-mono text-gray-200">{target.rr_ratio.toFixed(2)}</td>
<td className="py-2 font-mono text-gray-200">{target.probability.toFixed(1)}%</td>
</tr>
))}
</tbody>
</table>
</div>
);
}
function SetupCard({ setup, action }: { setup?: TradeSetup; action?: TradeSetup['recommended_action'] }) {
if (!setup) {
return (
<div className="glass-sm p-4 text-xs text-gray-500">
Setup unavailable for this direction.
</div>
);
}
const recommended = isRecommended(setup, action);
return (
<div
data-direction={setup.direction}
className={`glass-sm p-4 space-y-3 ${recommended ? 'border border-emerald-500/40' : 'opacity-80'}`}
>
<div className="flex items-center justify-between">
<h4 className={`text-sm font-semibold ${setup.direction === 'long' ? 'text-emerald-400' : 'text-red-400'}`}>
{setup.direction.toUpperCase()}
</h4>
<span className="text-xs text-gray-300">{setup.confidence_score?.toFixed(1) ?? '—'}%</span>
</div>
{!recommended && recommendationActionDirection(action ?? null) !== 'neutral' && (
<p className="text-[11px] text-amber-400">Alternative setup (ticker bias currently favors the opposite direction).</p>
)}
<div className="grid grid-cols-2 gap-2 text-xs">
<div className="text-gray-500">Entry</div><div className="font-mono text-gray-200">{formatPrice(setup.entry_price)}</div>
<div className="text-gray-500">Stop</div><div className="font-mono text-gray-200">{formatPrice(setup.stop_loss)}</div>
<div className="text-gray-500">Primary Target</div><div className="font-mono text-gray-200">{formatPrice(setup.target)}</div>
<div className="text-gray-500">R:R</div><div className="font-mono text-gray-200">{setup.rr_ratio.toFixed(2)}</div>
</div>
<TargetTable setup={setup} />
{setup.conflict_flags.length > 0 && (
<div className="rounded border border-amber-500/30 bg-amber-500/10 p-2 text-[11px] text-amber-300">
{setup.conflict_flags.join(' • ')}
</div>
)}
</div>
);
}
export function RecommendationPanel({ symbol, longSetup, shortSetup }: RecommendationPanelProps) {
const summary = longSetup?.recommendation_summary ?? shortSetup?.recommendation_summary;
const action = (summary?.action ?? 'NEUTRAL') as TradeSetup['recommended_action'];
const preferredDirection = recommendationActionDirection(action);
const preferredSetup =
preferredDirection === 'long'
? longSetup
: preferredDirection === 'short'
? shortSetup
: undefined;
const alternativeSetup =
preferredDirection === 'long'
? shortSetup
: preferredDirection === 'short'
? longSetup
: undefined;
if (!longSetup && !shortSetup) {
return null;
}
return (
<section>
<h2 className="mb-3 text-xs font-medium uppercase tracking-widest text-gray-500">Recommendation</h2>
<div className="glass p-5 space-y-4">
<div className="flex flex-wrap items-center gap-4">
<span className="text-sm font-semibold text-indigo-300">{recommendationActionLabel(action)}</span>
<span className={`text-sm font-semibold ${riskClass(summary?.risk_level ?? null)}`}>
Risk: {summary?.risk_level ?? '—'}
</span>
<span className="text-sm text-gray-300">Composite: {summary?.composite_score?.toFixed(1) ?? '—'}</span>
<span className="text-xs text-gray-500">{symbol.toUpperCase()}</span>
</div>
<p className="text-xs text-gray-500">Recommended Action is the ticker-level bias. The preferred setup is shown first; the opposite side is available under Alternative scenario.</p>
{summary?.reasoning && (
<p className="text-sm text-gray-300">{summary.reasoning}</p>
)}
{preferredDirection !== 'neutral' && preferredSetup ? (
<div className="space-y-3">
<SetupCard setup={preferredSetup} action={action} />
{alternativeSetup && (
<details className="glass-sm p-3">
<summary className="cursor-pointer text-xs font-medium text-gray-300">
Alternative scenario ({alternativeSetup.direction.toUpperCase()})
</summary>
<div className="mt-3">
<SetupCard setup={alternativeSetup} action={action} />
</div>
</details>
)}
</div>
) : (
<div className="grid gap-4 lg:grid-cols-2">
<SetupCard setup={longSetup} action={action} />
<SetupCard setup={shortSetup} action={action} />
</div>
)}
</div>
</section>
);
}

View File

@@ -1,6 +1,7 @@
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
import * as adminApi from '../api/admin';
import { useToast } from '../components/ui/Toast';
import type { TickerUniverse } from '../lib/types';
// ── Users ──
@@ -89,13 +90,93 @@ export function useUpdateSetting() {
});
}
export function useRecommendationSettings() {
return useQuery({
queryKey: ['admin', 'recommendation-settings'],
queryFn: () => adminApi.getRecommendationSettings(),
});
}
export function useUpdateRecommendationSettings() {
const qc = useQueryClient();
const { addToast } = useToast();
return useMutation({
mutationFn: (payload: Record<string, number>) =>
adminApi.updateRecommendationSettings(payload),
onSuccess: () => {
qc.invalidateQueries({ queryKey: ['admin', 'recommendation-settings'] });
addToast('success', 'Recommendation settings updated');
},
onError: (error: Error) => {
addToast('error', error.message || 'Failed to update recommendation settings');
},
});
}
export function useTickerUniverseSetting() {
return useQuery({
queryKey: ['admin', 'ticker-universe'],
queryFn: () => adminApi.getTickerUniverseSetting(),
});
}
export function useUpdateTickerUniverseSetting() {
const qc = useQueryClient();
const { addToast } = useToast();
return useMutation({
mutationFn: (universe: TickerUniverse) => adminApi.updateTickerUniverseSetting(universe),
onSuccess: () => {
qc.invalidateQueries({ queryKey: ['admin', 'ticker-universe'] });
addToast('success', 'Default ticker universe updated');
},
onError: (error: Error) => {
addToast('error', error.message || 'Failed to update default ticker universe');
},
});
}
export function useBootstrapTickers() {
const qc = useQueryClient();
const { addToast } = useToast();
return useMutation({
mutationFn: ({ universe, pruneMissing }: { universe: TickerUniverse; pruneMissing: boolean }) =>
adminApi.bootstrapTickers(universe, pruneMissing),
onSuccess: (result) => {
qc.invalidateQueries({ queryKey: ['tickers'] });
qc.invalidateQueries({ queryKey: ['admin', 'ticker-universe'] });
addToast(
'success',
`Bootstrap done: +${result.added}, existing ${result.already_tracked}, deleted ${result.deleted}`,
);
},
onError: (error: Error) => {
addToast('error', error.message || 'Failed to bootstrap tickers');
},
});
}
// ── Jobs ──
export function useJobs() {
return useQuery({
queryKey: ['admin', 'jobs'],
queryFn: () => adminApi.listJobs(),
refetchInterval: 15_000,
refetchInterval: (query) => {
const jobs = (query.state.data ?? []) as adminApi.JobStatus[];
const hasRunning = jobs.some((job) => job.running);
return hasRunning ? 2_000 : 15_000;
},
});
}
export function usePipelineReadiness() {
return useQuery({
queryKey: ['admin', 'pipeline-readiness'],
queryFn: () => adminApi.getPipelineReadiness(),
refetchInterval: 20_000,
});
}
@@ -121,9 +202,13 @@ export function useTriggerJob() {
return useMutation({
mutationFn: (jobName: string) => adminApi.triggerJob(jobName),
onSuccess: () => {
onSuccess: (result) => {
qc.invalidateQueries({ queryKey: ['admin', 'jobs'] });
addToast('success', 'Job triggered successfully');
if (result.status === 'triggered') {
addToast('success', result.message || 'Job triggered successfully');
return;
}
addToast('info', result.message || 'Job could not be triggered');
},
onError: (error: Error) => {
addToast('error', error.message || 'Failed to trigger job');

View File

@@ -0,0 +1,42 @@
import { useMutation, useQueryClient } from '@tanstack/react-query';
import { fetchData, type FetchDataResult } from '../api/ingestion';
import { useToast } from '../components/ui/Toast';
import { summarizeIngestionResult } from '../lib/ingestionStatus';
interface UseFetchSymbolDataOptions {
includeSymbolPrefix?: boolean;
invalidatePipelineReadiness?: boolean;
}
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) => {
const normalized = symbol.toUpperCase();
const summary = summarizeIngestionResult(result, normalized);
const toastMessage = includeSymbolPrefix
? `${normalized}: ${summary.message}`
: summary.message;
addToast(summary.toastType, toastMessage);
queryClient.invalidateQueries({ queryKey: ['ohlcv', symbol] });
queryClient.invalidateQueries({ queryKey: ['sentiment', symbol] });
queryClient.invalidateQueries({ queryKey: ['fundamentals', symbol] });
queryClient.invalidateQueries({ queryKey: ['sr-levels', symbol] });
queryClient.invalidateQueries({ queryKey: ['scores', symbol] });
if (invalidatePipelineReadiness) {
queryClient.invalidateQueries({ queryKey: ['admin', 'pipeline-readiness'] });
}
},
onError: (err: Error, symbol: string) => {
const normalized = symbol.toUpperCase();
const prefix = includeSymbolPrefix ? `${normalized}: ` : '';
addToast('error', `${prefix}${err.message || 'Failed to fetch data'}`);
},
});
}

View File

@@ -38,8 +38,8 @@ export function useTickerDetail(symbol: string) {
});
const trades = useQuery({
queryKey: ['trades'],
queryFn: () => tradesApi.list(),
queryKey: ['trades', symbol],
queryFn: () => tradesApi.bySymbol(symbol),
enabled: !!symbol,
});

View File

@@ -0,0 +1,42 @@
import type { FetchDataResult, IngestionSourceResult } from '../api/ingestion';
export type IngestionToastType = 'success' | 'error' | 'info';
export interface IngestionStatusSummary {
toastType: IngestionToastType;
message: string;
}
export function summarizeIngestionResult(
result: FetchDataResult | null | undefined,
fallbackLabel: string,
): IngestionStatusSummary {
const sources = result?.sources;
if (!sources) {
return {
toastType: 'success',
message: `Data fetched for ${fallbackLabel}`,
};
}
const entries = Object.entries(sources) as [string, IngestionSourceResult][];
const parts = entries.map(([name, info]) => {
const label = name.charAt(0).toUpperCase() + name.slice(1);
if (info.status === 'ok') {
return `${label}`;
}
if (info.status === 'skipped') {
return `${label}: skipped${info.message ? ` (${info.message})` : ''}`;
}
return `${label}${info.message ? `: ${info.message}` : ''}`;
});
const hasError = entries.some(([, source]) => source.status === 'error');
const hasSkip = entries.some(([, source]) => source.status === 'skipped');
const toastType: IngestionToastType = hasError ? 'error' : hasSkip ? 'info' : 'success';
return {
toastType,
message: parts.join(' · '),
};
}

View File

@@ -0,0 +1,46 @@
import type { TradeSetup } from './types';
export type RecommendationAction = NonNullable<TradeSetup['recommended_action']>;
export const RECOMMENDATION_ACTION_LABELS: Record<RecommendationAction, string> = {
LONG_HIGH: 'LONG (High Confidence)',
LONG_MODERATE: 'LONG (Moderate Confidence)',
SHORT_HIGH: 'SHORT (High Confidence)',
SHORT_MODERATE: 'SHORT (Moderate Confidence)',
NEUTRAL: 'NEUTRAL (Conflicting Signals)',
};
export const RECOMMENDATION_ACTION_GLOSSARY: Array<{ action: RecommendationAction; description: string }> = [
{
action: 'LONG_HIGH',
description: 'Ticker bias favors LONG strongly. LONG confidence is above the high threshold and clearly above SHORT.',
},
{
action: 'LONG_MODERATE',
description: 'Ticker bias favors LONG, but with moderate conviction.',
},
{
action: 'SHORT_HIGH',
description: 'Ticker bias favors SHORT strongly. SHORT confidence is above the high threshold and clearly above LONG.',
},
{
action: 'SHORT_MODERATE',
description: 'Ticker bias favors SHORT, but with moderate conviction.',
},
{
action: 'NEUTRAL',
description: 'No strong directional edge. Signals are mixed or confidence gap is too small.',
},
];
export function recommendationActionLabel(action: TradeSetup['recommended_action']): string {
if (!action) return RECOMMENDATION_ACTION_LABELS.NEUTRAL;
return RECOMMENDATION_ACTION_LABELS[action] ?? RECOMMENDATION_ACTION_LABELS.NEUTRAL;
}
export function recommendationActionDirection(action: TradeSetup['recommended_action']): 'long' | 'short' | 'neutral' {
if (!action || action === 'NEUTRAL') return 'neutral';
if (action.startsWith('LONG')) return 'long';
if (action.startsWith('SHORT')) return 'short';
return 'neutral';
}

View File

@@ -121,6 +121,32 @@ export interface TradeSetup {
rr_ratio: number;
composite_score: number;
detected_at: string;
confidence_score: number | null;
targets: TradeTarget[];
conflict_flags: string[];
recommended_action: 'LONG_HIGH' | 'LONG_MODERATE' | 'SHORT_HIGH' | 'SHORT_MODERATE' | 'NEUTRAL' | null;
reasoning: string | null;
risk_level: 'Low' | 'Medium' | 'High' | null;
actual_outcome: string | null;
recommendation_summary?: RecommendationSummary;
}
export interface TradeTarget {
price: number;
distance_from_entry: number;
distance_atr_multiple: number;
rr_ratio: number;
probability: number;
classification: 'Conservative' | 'Moderate' | 'Aggressive';
sr_level_id: number;
sr_strength: number;
}
export interface RecommendationSummary {
action: string;
reasoning: string | null;
risk_level: 'Low' | 'Medium' | 'High' | null;
composite_score: number;
}
// S/R Levels
@@ -214,3 +240,51 @@ export interface SystemSetting {
value: string;
updated_at: string | null;
}
export interface RecommendationConfig {
high_confidence_threshold: number;
moderate_confidence_threshold: number;
confidence_diff_threshold: number;
signal_alignment_weight: number;
sr_strength_weight: number;
distance_penalty_factor: number;
momentum_technical_divergence_threshold: number;
fundamental_technical_divergence_threshold: number;
}
export type TickerUniverse = 'sp500' | 'nasdaq100' | 'nasdaq_all';
export interface TickerUniverseSetting {
universe: TickerUniverse;
}
export interface TickerUniverseBootstrapResult {
universe: TickerUniverse;
total_universe_symbols: number;
added: number;
already_tracked: number;
deleted: number;
}
export interface PipelineReadiness {
symbol: string;
ohlcv_bars: number;
ohlcv_last_date: string | null;
dimensions: {
technical: number | null;
sr_quality: number | null;
sentiment: number | null;
fundamental: number | null;
momentum: number | null;
};
sentiment_count: number;
sentiment_last_at: string | null;
has_fundamentals: boolean;
fundamentals_fetched_at: string | null;
sr_level_count: number;
has_composite: boolean;
composite_stale: boolean | null;
trade_setup_count: number;
missing_reasons: string[];
ready_for_scanner: boolean;
}

View File

@@ -1,8 +1,11 @@
import { useState } from 'react';
import { DataCleanup } from '../components/admin/DataCleanup';
import { JobControls } from '../components/admin/JobControls';
import { PipelineReadinessPanel } from '../components/admin/PipelineReadinessPanel';
import { RecommendationSettings } from '../components/admin/RecommendationSettings';
import { SettingsForm } from '../components/admin/SettingsForm';
import { TickerManagement } from '../components/admin/TickerManagement';
import { TickerUniverseBootstrap } from '../components/admin/TickerUniverseBootstrap';
import { UserTable } from '../components/admin/UserTable';
const tabs = ['Users', 'Tickers', 'Settings', 'Jobs', 'Cleanup'] as const;
@@ -39,8 +42,19 @@ export default function AdminPage() {
<div className="animate-fade-in">
{activeTab === 'Users' && <UserTable />}
{activeTab === 'Tickers' && <TickerManagement />}
{activeTab === 'Settings' && <SettingsForm />}
{activeTab === 'Jobs' && <JobControls />}
{activeTab === 'Settings' && (
<div className="space-y-4">
<TickerUniverseBootstrap />
<RecommendationSettings />
<SettingsForm />
</div>
)}
{activeTab === 'Jobs' && (
<div className="space-y-4">
<PipelineReadinessPanel />
<JobControls />
</div>
)}
{activeTab === 'Cleanup' && <DataCleanup />}
</div>
</div>

View File

@@ -6,17 +6,23 @@ import { SkeletonTable } from '../components/ui/Skeleton';
import { useToast } from '../components/ui/Toast';
import { triggerJob } from '../api/admin';
import type { TradeSetup } from '../lib/types';
import { RECOMMENDATION_ACTION_GLOSSARY, RECOMMENDATION_ACTION_LABELS } from '../lib/recommendation';
type DirectionFilter = 'both' | 'long' | 'short';
type ActionFilter = 'all' | 'LONG_HIGH' | 'LONG_MODERATE' | 'SHORT_HIGH' | 'SHORT_MODERATE' | 'NEUTRAL';
function filterTrades(
trades: TradeSetup[],
minRR: number,
direction: DirectionFilter,
minConfidence: number,
action: ActionFilter,
): TradeSetup[] {
return trades.filter((t) => {
if (t.rr_ratio < minRR) return false;
if (direction !== 'both' && t.direction !== direction) return false;
if (minConfidence > 0 && (t.confidence_score ?? 0) < minConfidence) return false;
if (action !== 'all' && t.recommended_action !== action) return false;
return true;
});
}
@@ -28,6 +34,14 @@ function getComputedValue(trade: TradeSetup, column: SortColumn): number {
case 'reward_amount': return analysis.reward_amount;
case 'stop_pct': return analysis.stop_pct;
case 'target_pct': return analysis.target_pct;
case 'confidence_score': return trade.confidence_score ?? -1;
case 'best_target_probability':
return trade.targets?.length ? Math.max(...trade.targets.map((t) => t.probability)) : -1;
case 'risk_level':
if (trade.risk_level === 'Low') return 1;
if (trade.risk_level === 'Medium') return 2;
if (trade.risk_level === 'High') return 3;
return 0;
default: return 0;
}
}
@@ -46,6 +60,9 @@ function sortTrades(
case 'direction':
cmp = a.direction.localeCompare(b.direction);
break;
case 'recommended_action':
cmp = (a.recommended_action ?? '').localeCompare(b.recommended_action ?? '');
break;
case 'detected_at':
cmp = new Date(a.detected_at).getTime() - new Date(b.detected_at).getTime();
break;
@@ -53,6 +70,9 @@ function sortTrades(
case 'reward_amount':
case 'stop_pct':
case 'target_pct':
case 'confidence_score':
case 'best_target_probability':
case 'risk_level':
cmp = getComputedValue(a, column) - getComputedValue(b, column);
break;
case 'entry_price':
@@ -75,6 +95,8 @@ export default function ScannerPage() {
const [minRR, setMinRR] = useState(0);
const [directionFilter, setDirectionFilter] = useState<DirectionFilter>('both');
const [minConfidence, setMinConfidence] = useState(0);
const [actionFilter, setActionFilter] = useState<ActionFilter>('all');
const [sortColumn, setSortColumn] = useState<SortColumn>('rr_ratio');
const [sortDirection, setSortDirection] = useState<SortDirection>('desc');
@@ -100,9 +122,9 @@ export default function ScannerPage() {
const processed = useMemo(() => {
if (!trades) return [];
const filtered = filterTrades(trades, minRR, directionFilter);
const filtered = filterTrades(trades, minRR, directionFilter, minConfidence, actionFilter);
return sortTrades(filtered, sortColumn, sortDirection);
}, [trades, minRR, directionFilter, sortColumn, sortDirection]);
}, [trades, minRR, directionFilter, minConfidence, actionFilter, sortColumn, sortDirection]);
return (
<div className="space-y-6">
@@ -160,6 +182,51 @@ export default function ScannerPage() {
<option value="short">Short</option>
</select>
</div>
<div>
<label htmlFor="min-confidence" className="mb-1 block text-xs text-gray-400">
Min Confidence
</label>
<input
id="min-confidence"
type="number"
min={0}
max={100}
step={1}
value={minConfidence}
onChange={(e) => setMinConfidence(Number(e.target.value) || 0)}
className="w-24 rounded border border-gray-700 bg-gray-800 px-3 py-1.5 text-sm text-gray-200 focus:border-blue-500 focus:outline-none transition-colors duration-150"
/>
</div>
<div>
<label htmlFor="action" className="mb-1 block text-xs text-gray-400">
Recommended Action
</label>
<select
id="action"
value={actionFilter}
onChange={(e) => setActionFilter(e.target.value as ActionFilter)}
className="rounded border border-gray-700 bg-gray-800 px-3 py-1.5 text-sm text-gray-200 focus:border-blue-500 focus:outline-none transition-colors duration-150"
>
<option value="all">All</option>
<option value="LONG_HIGH">LONG_HIGH</option>
<option value="LONG_MODERATE">LONG_MODERATE</option>
<option value="SHORT_HIGH">SHORT_HIGH</option>
<option value="SHORT_MODERATE">SHORT_MODERATE</option>
<option value="NEUTRAL">NEUTRAL</option>
</select>
</div>
</div>
<div className="rounded-lg border border-white/[0.08] bg-white/[0.02] px-4 py-3">
<p className="text-xs uppercase tracking-wider text-gray-500 mb-2">Recommended Action Glossary (Ticker-Level Bias)</p>
<div className="grid gap-1 md:grid-cols-2">
{RECOMMENDATION_ACTION_GLOSSARY.map((item) => (
<p key={item.action} className="text-xs text-gray-300">
<span className="font-semibold text-indigo-300">{RECOMMENDATION_ACTION_LABELS[item.action]}:</span>{' '}
{item.description}
</p>
))}
</div>
</div>
{/* Content */}

View File

@@ -1,15 +1,14 @@
import { useMemo, useEffect } from 'react';
import { useParams } from 'react-router-dom';
import { useMutation, useQueryClient } from '@tanstack/react-query';
import { useTickerDetail } from '../hooks/useTickerDetail';
import { useFetchSymbolData } from '../hooks/useFetchSymbolData';
import { CandlestickChart } from '../components/charts/CandlestickChart';
import { ScoreCard } from '../components/ui/ScoreCard';
import { SkeletonCard } from '../components/ui/Skeleton';
import { SentimentPanel } from '../components/ticker/SentimentPanel';
import { FundamentalsPanel } from '../components/ticker/FundamentalsPanel';
import { IndicatorSelector } from '../components/ticker/IndicatorSelector';
import { useToast } from '../components/ui/Toast';
import { fetchData } from '../api/ingestion';
import { RecommendationPanel } from '../components/ticker/RecommendationPanel';
import { formatPrice } from '../lib/format';
import type { TradeSetup } from '../lib/types';
@@ -67,43 +66,7 @@ function DataFreshnessBar({ items }: { items: DataStatusItem[] }) {
export default function TickerDetailPage() {
const { symbol = '' } = useParams<{ symbol: string }>();
const { ohlcv, scores, srLevels, sentiment, fundamentals, trades } = useTickerDetail(symbol);
const queryClient = useQueryClient();
const { addToast } = useToast();
const ingestion = useMutation({
mutationFn: () => fetchData(symbol),
onSuccess: (result: any) => {
// Show per-source status breakdown
const sources = result?.sources;
if (sources) {
const parts: string[] = [];
for (const [name, info] of Object.entries(sources) as [string, any][]) {
const label = name.charAt(0).toUpperCase() + name.slice(1);
if (info.status === 'ok') {
parts.push(`${label}`);
} else if (info.status === 'skipped') {
parts.push(`${label}: skipped (${info.message})`);
} else {
parts.push(`${label} ✗: ${info.message}`);
}
}
const hasError = Object.values(sources).some((s: any) => s.status === 'error');
const hasSkip = Object.values(sources).some((s: any) => s.status === 'skipped');
const toastType = hasError ? 'error' : hasSkip ? 'info' : 'success';
addToast(toastType, parts.join(' · '));
} else {
addToast('success', `Data fetched for ${symbol.toUpperCase()}`);
}
queryClient.invalidateQueries({ queryKey: ['ohlcv', symbol] });
queryClient.invalidateQueries({ queryKey: ['sentiment', symbol] });
queryClient.invalidateQueries({ queryKey: ['fundamentals', symbol] });
queryClient.invalidateQueries({ queryKey: ['sr-levels', symbol] });
queryClient.invalidateQueries({ queryKey: ['scores', symbol] });
},
onError: (err: Error) => {
addToast('error', err.message || 'Failed to fetch data');
},
});
const ingestion = useFetchSymbolData();
const dataStatus: DataStatusItem[] = useMemo(() => [
{
@@ -140,18 +103,28 @@ export default function TickerDetailPage() {
}
}, [trades.error]);
// Pick the latest trade setup for the current symbol
const tradeSetup: TradeSetup | undefined = useMemo(() => {
if (trades.error || !trades.data) return undefined;
const matching = trades.data.filter(
(t) => t.symbol.toUpperCase() === symbol.toUpperCase(),
);
if (matching.length === 0) return undefined;
return matching.reduce((latest, t) =>
new Date(t.detected_at) > new Date(latest.detected_at) ? t : latest,
);
const setupsForSymbol: TradeSetup[] = useMemo(() => {
if (trades.error || !trades.data) return [];
return trades.data.filter((t) => t.symbol.toUpperCase() === symbol.toUpperCase());
}, [trades.data, trades.error, symbol]);
const longSetup = useMemo(
() => setupsForSymbol?.find((s) => s.direction === 'long'),
[setupsForSymbol],
);
const shortSetup = useMemo(
() => setupsForSymbol?.find((s) => s.direction === 'short'),
[setupsForSymbol],
);
// Use the highest-confidence setup for chart overlay fallback.
const tradeSetup: TradeSetup | undefined = useMemo(() => {
const candidates = [longSetup, shortSetup].filter(Boolean) as TradeSetup[];
if (candidates.length === 0) return undefined;
return candidates.sort((a, b) => (b.confidence_score ?? 0) - (a.confidence_score ?? 0))[0];
}, [longSetup, shortSetup]);
// Sort visible S/R levels by strength for the table (only levels within chart zones)
const sortedLevels = useMemo(() => {
if (!srLevels.data?.visible_levels) return [];
@@ -167,7 +140,7 @@ export default function TickerDetailPage() {
<p className="text-sm text-gray-500 mt-0.5">Ticker Detail</p>
</div>
<button
onClick={() => ingestion.mutate()}
onClick={() => ingestion.mutate(symbol)}
disabled={ingestion.isPending}
className="btn-gradient inline-flex items-center gap-2 px-5 py-2.5 text-sm disabled:opacity-60 disabled:cursor-not-allowed"
>
@@ -184,6 +157,8 @@ export default function TickerDetailPage() {
{/* Data freshness bar */}
<DataFreshnessBar items={dataStatus} />
<RecommendationPanel symbol={symbol} longSetup={longSetup} shortSetup={shortSetup} />
{/* Chart Section */}
<section>
<h2 className="mb-3 text-xs font-medium uppercase tracking-widest text-gray-500">Price Chart</h2>
@@ -204,39 +179,6 @@ export default function TickerDetailPage() {
)}
</section>
{/* Trade Setup Summary Card */}
{tradeSetup && (
<section>
<h2 className="mb-3 text-xs font-medium uppercase tracking-widest text-gray-500">Trade Setup</h2>
<div className="glass p-5">
<div className="flex flex-wrap items-center gap-6">
<div className="flex items-center gap-2">
<span className="text-xs text-gray-500">Direction</span>
<span className={`text-sm font-semibold ${tradeSetup.direction === 'long' ? 'text-emerald-400' : 'text-red-400'}`}>
{tradeSetup.direction.toUpperCase()}
</span>
</div>
<div className="flex items-center gap-2">
<span className="text-xs text-gray-500">Entry</span>
<span className="text-sm font-mono text-blue-300">{formatPrice(tradeSetup.entry_price)}</span>
</div>
<div className="flex items-center gap-2">
<span className="text-xs text-gray-500">Stop</span>
<span className="text-sm font-mono text-red-400">{formatPrice(tradeSetup.stop_loss)}</span>
</div>
<div className="flex items-center gap-2">
<span className="text-xs text-gray-500">Target</span>
<span className="text-sm font-mono text-emerald-400">{formatPrice(tradeSetup.target)}</span>
</div>
<div className="flex items-center gap-2">
<span className="text-xs text-gray-500">R:R</span>
<span className="text-sm font-semibold text-gray-200">{tradeSetup.rr_ratio.toFixed(2)}</span>
</div>
</div>
</div>
</section>
)}
{/* Scores + Side Panels */}
<div className="grid gap-6 lg:grid-cols-3">
<section>

View File

@@ -1,10 +1,52 @@
import { useMemo, useState } from 'react';
import { useWatchlist } from '../hooks/useWatchlist';
import { WatchlistTable } from '../components/watchlist/WatchlistTable';
import { AddTickerForm } from '../components/watchlist/AddTickerForm';
import { SkeletonTable } from '../components/ui/Skeleton';
import type { WatchlistEntry } from '../lib/types';
type SortMode = 'name_asc' | 'name_desc' | 'score_desc' | 'score_asc';
function sortEntries(entries: WatchlistEntry[], mode: SortMode): WatchlistEntry[] {
const sorted = [...entries];
if (mode === 'name_asc') {
sorted.sort((a, b) => a.symbol.localeCompare(b.symbol));
return sorted;
}
if (mode === 'name_desc') {
sorted.sort((a, b) => b.symbol.localeCompare(a.symbol));
return sorted;
}
if (mode === 'score_desc') {
sorted.sort((a, b) => {
const aScore = a.composite_score ?? Number.NEGATIVE_INFINITY;
const bScore = b.composite_score ?? Number.NEGATIVE_INFINITY;
if (aScore === bScore) return a.symbol.localeCompare(b.symbol);
return bScore - aScore;
});
return sorted;
}
sorted.sort((a, b) => {
const aScore = a.composite_score ?? Number.POSITIVE_INFINITY;
const bScore = b.composite_score ?? Number.POSITIVE_INFINITY;
if (aScore === bScore) return a.symbol.localeCompare(b.symbol);
return aScore - bScore;
});
return sorted;
}
export default function WatchlistPage() {
const { data, isLoading, isError, error } = useWatchlist();
const [sortMode, setSortMode] = useState<SortMode>('score_desc');
const sortedEntries = useMemo(
() => (data ? sortEntries(data, sortMode) : []),
[data, sortMode],
);
return (
<div className="space-y-6 animate-slide-up">
@@ -24,7 +66,27 @@ export default function WatchlistPage() {
</div>
)}
{data && <WatchlistTable entries={data} />}
{data && (
<div className="space-y-3">
<div className="flex justify-end">
<label className="flex items-center gap-2 text-xs text-gray-400">
<span>Sort by</span>
<select
value={sortMode}
onChange={(event) => setSortMode(event.target.value as SortMode)}
className="rounded-lg border border-white/10 bg-white/[0.03] px-2 py-1.5 text-xs text-gray-200 outline-none focus:border-blue-500/40"
>
<option value="score_desc">Score (high low)</option>
<option value="score_asc">Score (low high)</option>
<option value="name_asc">Name (A Z)</option>
<option value="name_desc">Name (Z A)</option>
</select>
</label>
</div>
<WatchlistTable entries={sortedEntries} />
</div>
)}
</div>
);
}

View File

@@ -1 +1 @@
{"root":["./src/app.tsx","./src/main.tsx","./src/vite-env.d.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/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/datacleanup.tsx","./src/components/admin/jobcontrols.tsx","./src/components/admin/settingsform.tsx","./src/components/admin/tickermanagement.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/ticker/dimensionbreakdownpanel.tsx","./src/components/ticker/fundamentalspanel.tsx","./src/components/ticker/indicatorselector.tsx","./src/components/ticker/sroverlay.tsx","./src/components/ticker/sentimentpanel.tsx","./src/components/ui/badge.tsx","./src/components/ui/confirmdialog.tsx","./src/components/ui/scorecard.tsx","./src/components/ui/skeleton.tsx","./src/components/ui/toast.tsx","./src/components/watchlist/addtickerform.tsx","./src/components/watchlist/watchlisttable.tsx","./src/hooks/useadmin.ts","./src/hooks/useauth.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/types.ts","./src/pages/adminpage.tsx","./src/pages/loginpage.tsx","./src/pages/rankingspage.tsx","./src/pages/registerpage.tsx","./src/pages/scannerpage.tsx","./src/pages/tickerdetailpage.tsx","./src/pages/watchlistpage.tsx","./src/stores/authstore.ts"],"version":"5.6.3"}
{"root":["./src/app.tsx","./src/main.tsx","./src/vite-env.d.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/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/datacleanup.tsx","./src/components/admin/jobcontrols.tsx","./src/components/admin/pipelinereadinesspanel.tsx","./src/components/admin/recommendationsettings.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/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/confirmdialog.tsx","./src/components/ui/scorecard.tsx","./src/components/ui/skeleton.tsx","./src/components/ui/toast.tsx","./src/components/watchlist/addtickerform.tsx","./src/components/watchlist/watchlisttable.tsx","./src/hooks/useadmin.ts","./src/hooks/useauth.ts","./src/hooks/usefetchsymboldata.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/recommendation.ts","./src/lib/types.ts","./src/pages/adminpage.tsx","./src/pages/loginpage.tsx","./src/pages/rankingspage.tsx","./src/pages/registerpage.tsx","./src/pages/scannerpage.tsx","./src/pages/tickerdetailpage.tsx","./src/pages/watchlistpage.tsx","./src/stores/authstore.ts"],"version":"5.6.3"}