Big refactoring
This commit is contained in:
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user