Files
signal-platform/frontend/src/pages/MarketPage.tsx
T
dennisthiessenandClaude Fable 5 126c3b3c17
Deploy / lint (push) Successful in 5s
Deploy / test (push) Successful in 32s
Deploy / deploy (push) Successful in 22s
Add DeepSeek/xAI/OpenAI-compatible sentiment providers; custom dark dropdown
Providers (admin-switchable, no redeploy):
- DeepSeek and any OpenAI-compatible endpoint (OpenRouter, Together,
  Groq, local Ollama) via a generic Chat Completions adapter + base_url
- xAI Grok with Live Search (search_parameters web+X, citations) —
  grounded tier alongside OpenAI and Gemini
- DeepSeek / generic compatible endpoints are ungrounded (no web
  search); UI shows an amber warning and labels each provider's grounding
- Optional env fallbacks DEEPSEEK_API_KEY / XAI_API_KEY

UI: replace native <select> (unstyleable white popup on Windows) with a
custom dark Dropdown component everywhere — sentiment provider, scanner
filters, market sort, indicators, admin universe, user role.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-13 12:42:04 +02:00

142 lines
4.5 KiB
TypeScript

import { useMemo, useState } from 'react';
import { useSearchParams } from 'react-router-dom';
import { useWatchlist } from '../hooks/useWatchlist';
import { useRankings } from '../hooks/useScores';
import { WatchlistTable } from '../components/watchlist/WatchlistTable';
import { AddTickerForm } from '../components/watchlist/AddTickerForm';
import { RankingsTable } from '../components/rankings/RankingsTable';
import { WeightsForm } from '../components/rankings/WeightsForm';
import { Callout } from '../components/ui/Callout';
import { Disclosure } from '../components/ui/Disclosure';
import { Dropdown } from '../components/ui/Dropdown';
import { PageHeader } from '../components/ui/PageHeader';
import { SkeletonTable } from '../components/ui/Skeleton';
import { Tabs } from '../components/ui/Tabs';
import type { WatchlistEntry } from '../lib/types';
const tabs = ['Watchlist', 'Rankings'] as const;
type Tab = (typeof tabs)[number];
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;
}
function WatchlistPanel() {
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-3">
{isLoading && <SkeletonTable rows={6} cols={8} />}
{isError && <Callout variant="error">{error?.message || 'Failed to load watchlist'}</Callout>}
{data && (
<>
<div className="flex items-center justify-between gap-4">
<AddTickerForm />
<label className="flex items-center gap-2 text-xs text-gray-400">
<span>Sort by</span>
<Dropdown
value={sortMode}
onChange={(v) => setSortMode(v as SortMode)}
className="w-44"
options={[
{ value: 'score_desc', label: 'Score (high → low)' },
{ value: 'score_asc', label: 'Score (low → high)' },
{ value: 'name_asc', label: 'Name (A → Z)' },
{ value: 'name_desc', label: 'Name (Z → A)' },
]}
/>
</label>
</div>
<WatchlistTable entries={sortedEntries} />
</>
)}
</div>
);
}
function RankingsPanel() {
const { data, isLoading, isError, error } = useRankings();
return (
<div className="space-y-4">
{isLoading && <SkeletonTable rows={8} cols={6} />}
{isError && (
<Callout variant="error">Failed to load rankings: {(error as Error).message}</Callout>
)}
{data && (
<>
<Disclosure summary="Tune scoring weights">
<WeightsForm weights={data.weights} />
</Disclosure>
<RankingsTable rankings={data.rankings} />
</>
)}
</div>
);
}
export default function MarketPage() {
const [searchParams, setSearchParams] = useSearchParams();
const activeTab: Tab = searchParams.get('tab') === 'rankings' ? 'Rankings' : 'Watchlist';
const setTab = (tab: Tab) => {
setSearchParams(tab === 'Rankings' ? { tab: 'rankings' } : {}, { replace: true });
};
return (
<div className="space-y-6 animate-slide-up">
<PageHeader
title="Market"
subtitle="Your watchlist and the full composite-score leaderboard"
/>
<Tabs tabs={tabs} active={activeTab} onChange={setTab} />
<div className="animate-fade-in" key={activeTab}>
{activeTab === 'Watchlist' ? <WatchlistPanel /> : <RankingsPanel />}
</div>
</div>
);
}