Add DeepSeek/xAI/OpenAI-compatible sentiment providers; custom dark dropdown
Deploy / lint (push) Successful in 5s
Deploy / test (push) Successful in 32s
Deploy / deploy (push) Successful in 22s

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>
This commit is contained in:
2026-06-13 12:42:04 +02:00
parent d53ed972d1
commit 126c3b3c17
16 changed files with 521 additions and 98 deletions
@@ -4,7 +4,7 @@ import {
useUpdateSentimentSettings,
useTestSentimentProvider,
} from '../../hooks/useAdmin';
import { Select } from '../ui/Field';
import { Dropdown, type DropdownOption } from '../ui/Dropdown';
import { SkeletonTable } from '../ui/Skeleton';
import type { SentimentTestResult } from '../../lib/types';
@@ -14,6 +14,18 @@ const SOURCE_LABEL: Record<string, string> = {
none: 'not configured',
};
const PROVIDER_LABELS: Record<string, string> = {
openai: 'OpenAI — web search',
gemini: 'Google Gemini — web search',
deepseek: 'DeepSeek — cheap, no web search',
xai: 'xAI Grok — Live Search',
openai_compatible: 'OpenAI-compatible — custom URL',
};
function providerLabel(p: string): string {
return PROVIDER_LABELS[p] ?? p;
}
export function SentimentProviderSettings() {
const { data, isLoading, isError, error } = useSentimentSettings();
const update = useUpdateSentimentSettings();
@@ -22,12 +34,14 @@ export function SentimentProviderSettings() {
const [provider, setProvider] = useState('openai');
const [model, setModel] = useState('');
const [apiKey, setApiKey] = useState('');
const [baseUrl, setBaseUrl] = useState('');
const [testResult, setTestResult] = useState<SentimentTestResult | null>(null);
useEffect(() => {
if (data) {
setProvider(data.provider);
setModel(data.model);
setBaseUrl(data.base_url ?? '');
}
}, [data]);
@@ -35,10 +49,17 @@ export function SentimentProviderSettings() {
if (isError) return <p className="text-sm text-red-400">{(error as Error)?.message || 'Failed to load sentiment settings'}</p>;
if (!data) return null;
const grounded = data.web_search_providers ?? ['openai', 'gemini'];
const needsBaseUrl = (data.custom_base_url_providers ?? ['openai_compatible']).includes(provider);
const isGrounded = grounded.includes(provider);
const providerOptions: DropdownOption[] = data.valid_providers.map((p) => ({
value: p,
label: providerLabel(p),
}));
const onProviderChange = (next: string) => {
setProvider(next);
// Auto-fill the model with the new provider's default unless the user has a
// custom value that isn't the previous provider's default.
const defaults = data.default_models;
if (!model || Object.values(defaults).includes(model)) {
setModel(defaults[next] ?? '');
@@ -50,6 +71,7 @@ export function SentimentProviderSettings() {
update.mutate({
provider,
model,
...(needsBaseUrl ? { base_url: baseUrl } : {}),
...(apiKey ? { api_key: apiKey } : {}),
});
setApiKey('');
@@ -74,11 +96,7 @@ export function SentimentProviderSettings() {
<div className="grid gap-4 md:grid-cols-2">
<label className="block space-y-1">
<span className="text-xs text-gray-400">Provider</span>
<Select value={provider} onChange={(e) => onProviderChange(e.target.value)} className="w-full !py-2">
{data.valid_providers.map((p) => (
<option key={p} value={p}>{p}</option>
))}
</Select>
<Dropdown value={provider} onChange={onProviderChange} options={providerOptions} ariaLabel="Sentiment provider" />
</label>
<label className="block space-y-1">
@@ -87,12 +105,33 @@ export function SentimentProviderSettings() {
type="text"
value={model}
onChange={(e) => setModel(e.target.value)}
placeholder={data.default_models[provider] ?? ''}
placeholder={data.default_models[provider] || 'model id'}
className="w-full input-glass px-3 py-2 text-sm"
/>
</label>
</div>
{needsBaseUrl && (
<label className="block space-y-1">
<span className="text-xs text-gray-400">Base URL</span>
<input
type="text"
value={baseUrl}
onChange={(e) => setBaseUrl(e.target.value)}
placeholder="https://openrouter.ai/api/v1"
className="w-full input-glass px-3 py-2 text-sm"
/>
<span className="text-[11px] text-gray-600">OpenAI-compatible Chat Completions endpoint (OpenRouter, Together, Groq, local Ollama).</span>
</label>
)}
{!isGrounded && (
<div className="rounded-lg border border-amber-500/20 bg-amber-500/10 px-4 py-2.5 text-xs text-amber-300">
No live web search. This provider scores sentiment from the model's training knowledge,
not current news — cheaper, but not real-time. OpenAI and Gemini are grounded in live search.
</div>
)}
<label className="block space-y-1">
<span className="text-xs text-gray-400">API Key</span>
<input
@@ -5,6 +5,7 @@ import {
useUpdateTickerUniverseSetting,
} from '../../hooks/useAdmin';
import type { TickerUniverse } from '../../lib/types';
import { Dropdown } from '../ui/Dropdown';
const UNIVERSE_OPTIONS: Array<{ value: TickerUniverse; label: string }> = [
{ value: 'sp500', label: 'S&P 500' },
@@ -50,18 +51,11 @@ export function TickerUniverseBootstrap() {
<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
<Dropdown
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>
onChange={(v) => setUniverse(v as TickerUniverse)}
options={UNIVERSE_OPTIONS.map((o) => ({ value: o.value, label: o.label }))}
/>
</label>
<label className="flex items-end gap-2 pb-2">
+10 -4
View File
@@ -1,6 +1,7 @@
import { useState } from 'react';
import { useUsers, useCreateUser, useUpdateAccess, useResetPassword } from '../../hooks/useAdmin';
import { SkeletonTable } from '../ui/Skeleton';
import { Dropdown } from '../ui/Dropdown';
import type { AdminUser } from '../../lib/types';
export function UserTable() {
@@ -53,10 +54,15 @@ export function UserTable() {
</div>
<div className="flex flex-col gap-1.5">
<label className="text-xs text-gray-400">Role</label>
<select value={newRole} onChange={(e) => setNewRole(e.target.value)} className="input-glass px-3 py-2 text-sm">
<option value="user">User</option>
<option value="admin">Admin</option>
</select>
<Dropdown
value={newRole}
onChange={setNewRole}
className="w-32"
options={[
{ value: 'user', label: 'User' },
{ value: 'admin', label: 'Admin' },
]}
/>
</div>
<label className="flex items-center gap-2 text-sm text-gray-300 pb-1">
<input type="checkbox" checked={newAccess} onChange={(e) => setNewAccess(e.target.checked)}
+23 -18
View File
@@ -9,7 +9,8 @@ import { useToast } from '../ui/Toast';
import { Button } from '../ui/Button';
import { Callout } from '../ui/Callout';
import { Disclosure } from '../ui/Disclosure';
import { Field, Input, Select } from '../ui/Field';
import { Field, Input } from '../ui/Field';
import { Dropdown } from '../ui/Dropdown';
import { triggerJob } from '../../api/admin';
import type { TradeSetup } from '../../lib/types';
import { RECOMMENDATION_ACTION_GLOSSARY, RECOMMENDATION_ACTION_LABELS } from '../../lib/recommendation';
@@ -178,15 +179,17 @@ export function SetupsPanel() {
</div>
</Field>
<Field label="Direction" htmlFor="direction">
<Select
<Dropdown
id="direction"
value={directionFilter}
onChange={(e) => setDirectionFilter(e.target.value as DirectionFilter)}
>
<option value="both">Both</option>
<option value="long">Long</option>
<option value="short">Short</option>
</Select>
onChange={(v) => setDirectionFilter(v as DirectionFilter)}
className="w-32"
options={[
{ value: 'both', label: 'Both' },
{ value: 'long', label: 'Long' },
{ value: 'short', label: 'Short' },
]}
/>
</Field>
<Field label="Min Confidence" htmlFor="min-confidence">
<Input
@@ -201,18 +204,20 @@ export function SetupsPanel() {
/>
</Field>
<Field label="Recommended Action" htmlFor="action">
<Select
<Dropdown
id="action"
value={actionFilter}
onChange={(e) => setActionFilter(e.target.value as ActionFilter)}
>
<option value="all">All</option>
<option value="LONG_HIGH">{RECOMMENDATION_ACTION_LABELS.LONG_HIGH}</option>
<option value="LONG_MODERATE">{RECOMMENDATION_ACTION_LABELS.LONG_MODERATE}</option>
<option value="SHORT_HIGH">{RECOMMENDATION_ACTION_LABELS.SHORT_HIGH}</option>
<option value="SHORT_MODERATE">{RECOMMENDATION_ACTION_LABELS.SHORT_MODERATE}</option>
<option value="NEUTRAL">{RECOMMENDATION_ACTION_LABELS.NEUTRAL}</option>
</Select>
onChange={(v) => setActionFilter(v as ActionFilter)}
className="w-56"
options={[
{ value: 'all', label: 'All' },
{ value: 'LONG_HIGH', label: RECOMMENDATION_ACTION_LABELS.LONG_HIGH },
{ value: 'LONG_MODERATE', label: RECOMMENDATION_ACTION_LABELS.LONG_MODERATE },
{ value: 'SHORT_HIGH', label: RECOMMENDATION_ACTION_LABELS.SHORT_HIGH },
{ value: 'SHORT_MODERATE', label: RECOMMENDATION_ACTION_LABELS.SHORT_MODERATE },
{ value: 'NEUTRAL', label: RECOMMENDATION_ACTION_LABELS.NEUTRAL },
]}
/>
</Field>
<div className="ml-auto">
<Button onClick={() => scanMutation.mutate()} loading={scanMutation.isPending}>
@@ -1,7 +1,7 @@
import { useState } from 'react';
import { useQuery } from '@tanstack/react-query';
import { getIndicator, getEMACross } from '../../api/indicators';
import { Select } from '../ui/Field';
import { Dropdown } from '../ui/Dropdown';
import type { IndicatorResult, EMACrossResult } from '../../lib/types';
const INDICATOR_TYPES = ['ADX', 'EMA', 'RSI', 'ATR', 'volume_profile', 'pivot_points'] as const;
@@ -86,16 +86,12 @@ export function IndicatorSelector({ symbol }: IndicatorSelectorProps) {
<h3 className="mb-3 text-xs font-medium uppercase tracking-widest text-gray-500">Indicators</h3>
<div className="mb-4">
<Select
<Dropdown
value={selectedType}
onChange={(e) => setSelectedType(e.target.value)}
className="w-full !py-2.5"
>
<option value="">Select indicator</option>
{INDICATOR_TYPES.map((type) => (
<option key={type} value={type}>{type}</option>
))}
</Select>
onChange={setSelectedType}
placeholder="Select indicator…"
options={INDICATOR_TYPES.map((type) => ({ value: type, label: type }))}
/>
</div>
{selectedType && indicatorQuery.isLoading && (
+116
View File
@@ -0,0 +1,116 @@
import { useEffect, useRef, useState } from 'react';
export interface DropdownOption {
value: string;
label: string;
}
interface DropdownProps {
value: string;
onChange: (value: string) => void;
options: DropdownOption[];
id?: string;
className?: string;
ariaLabel?: string;
placeholder?: string;
}
/**
* Fully dark, custom dropdown — replaces native <select>, whose popup list
* can't be reliably styled (white background on Windows). Button + absolutely
* positioned menu, click-outside and Escape to close.
*/
export function Dropdown({
value,
onChange,
options,
id,
className = '',
ariaLabel,
placeholder = 'Select…',
}: DropdownProps) {
const [open, setOpen] = useState(false);
const rootRef = useRef<HTMLDivElement>(null);
const selected = options.find((o) => o.value === value);
useEffect(() => {
if (!open) return;
const onDocClick = (e: MouseEvent) => {
if (rootRef.current && !rootRef.current.contains(e.target as Node)) setOpen(false);
};
const onKey = (e: KeyboardEvent) => {
if (e.key === 'Escape') setOpen(false);
};
document.addEventListener('mousedown', onDocClick);
document.addEventListener('keydown', onKey);
return () => {
document.removeEventListener('mousedown', onDocClick);
document.removeEventListener('keydown', onKey);
};
}, [open]);
return (
<div ref={rootRef} className={`relative ${className}`}>
<button
type="button"
id={id}
aria-haspopup="listbox"
aria-expanded={open}
aria-label={ariaLabel}
onClick={() => setOpen((v) => !v)}
className="input-glass flex w-full items-center justify-between gap-2 px-3 py-1.5 text-left text-sm"
>
<span className={selected ? 'text-gray-200' : 'text-gray-500'}>
{selected ? selected.label : placeholder}
</span>
<svg
className={`h-4 w-4 shrink-0 text-gray-500 transition-transform duration-150 ${open ? 'rotate-180' : ''}`}
viewBox="0 0 20 20"
fill="none"
stroke="currentColor"
>
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={1.8} d="M6 8l4 4 4-4" />
</svg>
</button>
{open && (
<ul
role="listbox"
className="absolute z-50 mt-1 max-h-64 w-full overflow-auto rounded-lg border border-white/[0.1] bg-[#151911] p-1 shadow-2xl shadow-black/50"
>
{options.map((opt) => {
const isSelected = opt.value === value;
return (
<li key={opt.value} role="option" aria-selected={isSelected}>
<button
type="button"
onClick={() => {
onChange(opt.value);
setOpen(false);
}}
className={`flex w-full items-center justify-between rounded-md px-3 py-2 text-left text-sm transition-colors duration-100 ${
isSelected
? 'bg-blue-400/15 text-blue-200'
: 'text-gray-300 hover:bg-white/[0.06] hover:text-gray-100'
}`}
>
<span>{opt.label}</span>
{isSelected && (
<svg className="h-3.5 w-3.5 text-blue-300" viewBox="0 0 20 20" fill="currentColor">
<path
fillRule="evenodd"
d="M16.7 5.3a1 1 0 010 1.4l-7.5 7.5a1 1 0 01-1.4 0L3.3 9.7a1 1 0 011.4-1.4l3.3 3.3 6.8-6.8a1 1 0 011.4 0z"
clipRule="evenodd"
/>
</svg>
)}
</button>
</li>
);
})}
</ul>
)}
</div>
);
}
+1 -9
View File
@@ -1,4 +1,4 @@
import type { InputHTMLAttributes, ReactNode, SelectHTMLAttributes } from 'react';
import type { InputHTMLAttributes, ReactNode } from 'react';
interface FieldProps {
label: string;
@@ -21,11 +21,3 @@ export function Field({ label, htmlFor, children }: FieldProps) {
export function Input({ className = '', ...rest }: InputHTMLAttributes<HTMLInputElement>) {
return <input className={`input-glass px-3 py-1.5 text-sm ${className}`} {...rest} />;
}
export function Select({ className = '', children, ...rest }: SelectHTMLAttributes<HTMLSelectElement>) {
return (
<select className={`input-glass px-3 py-1.5 text-sm [&>option]:bg-[#151911] ${className}`} {...rest}>
{children}
</select>
);
}
+4
View File
@@ -165,10 +165,14 @@ export interface ActivationConfig {
export interface SentimentProviderConfig {
provider: string;
model: string;
base_url: string;
api_key_configured: boolean;
api_key_source: 'database' | 'environment' | 'none';
web_search: boolean;
valid_providers: string[];
default_models: Record<string, string>;
web_search_providers: string[];
custom_base_url_providers: string[];
}
export interface SentimentTestResult {
+11 -10
View File
@@ -8,7 +8,7 @@ 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 { Select } from '../components/ui/Field';
import { Dropdown } from '../components/ui/Dropdown';
import { PageHeader } from '../components/ui/PageHeader';
import { SkeletonTable } from '../components/ui/Skeleton';
import { Tabs } from '../components/ui/Tabs';
@@ -72,16 +72,17 @@ function WatchlistPanel() {
<AddTickerForm />
<label className="flex items-center gap-2 text-xs text-gray-400">
<span>Sort by</span>
<Select
<Dropdown
value={sortMode}
onChange={(event) => setSortMode(event.target.value as SortMode)}
className="!py-1 !text-xs"
>
<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>
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>