Big refactoring
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
+16 -2
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>
+69 -2
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 */}
+26 -84
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>
+63 -1
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>
);
}