Big refactoring
This commit is contained in:
@@ -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);
|
||||
}
|
||||
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
|
||||
105
frontend/src/components/admin/PipelineReadinessPanel.tsx
Normal file
105
frontend/src/components/admin/PipelineReadinessPanel.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
101
frontend/src/components/admin/RecommendationSettings.tsx
Normal file
101
frontend/src/components/admin/RecommendationSettings.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
97
frontend/src/components/admin/TickerUniverseBootstrap.tsx
Normal file
97
frontend/src/components/admin/TickerUniverseBootstrap.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@@ -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)}
|
||||
|
||||
168
frontend/src/components/ticker/RecommendationPanel.tsx
Normal file
168
frontend/src/components/ticker/RecommendationPanel.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@@ -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');
|
||||
|
||||
42
frontend/src/hooks/useFetchSymbolData.ts
Normal file
42
frontend/src/hooks/useFetchSymbolData.ts
Normal 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'}`);
|
||||
},
|
||||
});
|
||||
}
|
||||
@@ -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,
|
||||
});
|
||||
|
||||
|
||||
42
frontend/src/lib/ingestionStatus.ts
Normal file
42
frontend/src/lib/ingestionStatus.ts
Normal 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(' · '),
|
||||
};
|
||||
}
|
||||
46
frontend/src/lib/recommendation.ts
Normal file
46
frontend/src/lib/recommendation.ts
Normal 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';
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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 */}
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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"}
|
||||
Reference in New Issue
Block a user