"Market Regime" and "Regime Monitor" sat next to each other in Admin -> Jobs
(pipeline steps 4 and 5) reading as the same job. They are unrelated, and the
names had it backwards: "Market Regime" is the SPY 50/200 guard that drives the
TopBar trend dot and the counter-trend warning on setups, so it changes what a
setup shows; "Regime Monitor" is the observational AI/Tech thermometer that
explicitly feeds no trades. The more consequential job had the vaguer name.
market_regime "Market Regime" -> "Market Trend (SPY)"
regime_monitor "Regime Monitor" -> "AI/Tech Risk Monitor"
Display strings only. The job *ids* are persisted -- they key the pipeline step
list, cron config, runtime tracking and run history -- so they are untouched,
as is the /regime route, which keeps existing links working.
The label the admin UI renders comes from JOB_LABELS in admin_service (via
routers/jobs.py), not from the scheduler's APScheduler `name=`. Both are updated;
only the former is user-visible.
Carries the vocabulary through the rest of the surface so it does not half-land:
page title, nav ("Regime" -> "Risk"), the empty-state instruction that names the
job to run, the quadrant alert toggle, the morning-pipeline hint, and the
Telegram alert headline ("Regime quadrant change" -> "AI/Tech risk quadrant
change"). No test asserts any of these strings.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
187 lines
7.0 KiB
TypeScript
187 lines
7.0 KiB
TypeScript
import { useEffect, useState } from 'react';
|
||
import { useAlertSettings, useUpdateAlertSettings, useTestAlert } from '../../hooks/useAdmin';
|
||
import { SkeletonTable } from '../ui/Skeleton';
|
||
|
||
const SOURCE_LABEL: Record<string, string> = {
|
||
database: 'configured here',
|
||
environment: 'from environment (.env)',
|
||
none: 'not configured',
|
||
};
|
||
|
||
type TriggerKey =
|
||
| 'qualified_enabled'
|
||
| 'sr_proximity_enabled'
|
||
| 'score_drop_enabled'
|
||
| 'digest_enabled'
|
||
| 'regime_quadrant_enabled'
|
||
| 'trade_closed_enabled';
|
||
|
||
const TRIGGERS: { key: TriggerKey; label: string; hint: string }[] = [
|
||
{ key: 'qualified_enabled', label: 'Qualified setups', hint: 'a setup newly clears the activation gate' },
|
||
{ key: 'sr_proximity_enabled', label: 'Watchlist S/R proximity', hint: 'a watched ticker nears a strong support/resistance' },
|
||
{ key: 'score_drop_enabled', label: 'Score deterioration', hint: 'a watched ticker’s composite drops sharply' },
|
||
{ key: 'digest_enabled', label: 'Daily digest', hint: 'end-of-day summary incl. open trades + trailing stops' },
|
||
{ key: 'regime_quadrant_enabled', label: 'Risk quadrant change', hint: 'the AI/Tech risk monitor shifts quadrant (hysteresis + cooldown)' },
|
||
{ key: 'trade_closed_enabled', label: 'Trade closed', hint: 'a paper trade auto-closes (trailing/target/stop) — incl. losses' },
|
||
];
|
||
|
||
function Toggle({ checked, onChange, label, hint }: {
|
||
checked: boolean;
|
||
onChange: (v: boolean) => void;
|
||
label: string;
|
||
hint: string;
|
||
}) {
|
||
return (
|
||
<label className="flex items-start gap-2.5 cursor-pointer">
|
||
<input
|
||
type="checkbox"
|
||
checked={checked}
|
||
onChange={(e) => onChange(e.target.checked)}
|
||
className="mt-0.5 h-4 w-4 cursor-pointer accent-blue-400"
|
||
/>
|
||
<span>
|
||
<span className="text-sm text-gray-200">{label}</span>
|
||
<span className="block text-[11px] text-gray-500">{hint}</span>
|
||
</span>
|
||
</label>
|
||
);
|
||
}
|
||
|
||
export function AlertSettings() {
|
||
const { data, isLoading, isError, error } = useAlertSettings();
|
||
const update = useUpdateAlertSettings();
|
||
const test = useTestAlert();
|
||
|
||
const [enabled, setEnabled] = useState(false);
|
||
const [chatId, setChatId] = useState('');
|
||
const [botToken, setBotToken] = useState('');
|
||
const [triggers, setTriggers] = useState<Record<TriggerKey, boolean>>({
|
||
qualified_enabled: true,
|
||
sr_proximity_enabled: true,
|
||
score_drop_enabled: true,
|
||
digest_enabled: true,
|
||
regime_quadrant_enabled: true,
|
||
trade_closed_enabled: true,
|
||
});
|
||
|
||
useEffect(() => {
|
||
if (data) {
|
||
setEnabled(data.enabled);
|
||
setChatId(data.telegram_chat_id ?? '');
|
||
setTriggers({
|
||
qualified_enabled: data.qualified_enabled,
|
||
sr_proximity_enabled: data.sr_proximity_enabled,
|
||
score_drop_enabled: data.score_drop_enabled,
|
||
digest_enabled: data.digest_enabled,
|
||
regime_quadrant_enabled: data.regime_quadrant_enabled,
|
||
trade_closed_enabled: data.trade_closed_enabled,
|
||
});
|
||
}
|
||
}, [data]);
|
||
|
||
if (isLoading) return <SkeletonTable rows={4} cols={2} />;
|
||
if (isError) return <p className="text-sm text-red-400">{(error as Error)?.message || 'Failed to load alert settings'}</p>;
|
||
if (!data) return null;
|
||
|
||
const onSave = () => {
|
||
update.mutate({
|
||
enabled,
|
||
telegram_chat_id: chatId,
|
||
...triggers,
|
||
...(botToken ? { bot_token: botToken } : {}),
|
||
});
|
||
setBotToken('');
|
||
};
|
||
|
||
const tokenConfigured = data.bot_token_configured;
|
||
|
||
return (
|
||
<div className="glass p-5 space-y-4">
|
||
<div>
|
||
<h3 className="text-sm font-semibold text-gray-200">Telegram Alerts</h3>
|
||
<p className="mt-1 text-xs text-gray-500">
|
||
Push actionable signals to Telegram so you don’t have to keep checking the dashboard.
|
||
The dispatcher runs hourly; each trigger respects a cooldown so you’re not spammed.
|
||
</p>
|
||
</div>
|
||
|
||
<label className="flex items-center gap-2.5 cursor-pointer">
|
||
<input
|
||
type="checkbox"
|
||
checked={enabled}
|
||
onChange={(e) => setEnabled(e.target.checked)}
|
||
className="h-4 w-4 cursor-pointer accent-blue-400"
|
||
/>
|
||
<span className="text-sm text-gray-200">Alerts enabled</span>
|
||
</label>
|
||
|
||
<div className="grid gap-4 md:grid-cols-2">
|
||
<label className="block space-y-1">
|
||
<span className="text-xs text-gray-400">Bot Token</span>
|
||
<input
|
||
type="password"
|
||
value={botToken}
|
||
onChange={(e) => setBotToken(e.target.value)}
|
||
autoComplete="new-password"
|
||
placeholder={tokenConfigured ? '•••••••••• (leave blank to keep current)' : 'Paste bot token from @BotFather…'}
|
||
className="w-full input-glass px-3 py-2 text-sm"
|
||
/>
|
||
<span className="text-[11px] text-gray-500">
|
||
Status:{' '}
|
||
<span className={tokenConfigured ? 'text-emerald-400' : 'text-amber-400'}>
|
||
{SOURCE_LABEL[data.bot_token_source] ?? data.bot_token_source}
|
||
</span>
|
||
{' '}· write-only, never displayed
|
||
</span>
|
||
</label>
|
||
|
||
<label className="block space-y-1">
|
||
<span className="text-xs text-gray-400">Chat ID</span>
|
||
<input
|
||
type="text"
|
||
value={chatId}
|
||
onChange={(e) => setChatId(e.target.value)}
|
||
placeholder="e.g. 123456789"
|
||
className="w-full input-glass px-3 py-2 text-sm"
|
||
/>
|
||
<span className="text-[11px] text-gray-600">Your numeric chat ID (message @userinfobot to find it).</span>
|
||
</label>
|
||
</div>
|
||
|
||
<div className="space-y-2">
|
||
<p className="text-xs text-gray-400">Triggers</p>
|
||
<div className="grid gap-3 md:grid-cols-2">
|
||
{TRIGGERS.map((t) => (
|
||
<Toggle
|
||
key={t.key}
|
||
label={t.label}
|
||
hint={t.hint}
|
||
checked={triggers[t.key]}
|
||
onChange={(v) => setTriggers((prev) => ({ ...prev, [t.key]: v }))}
|
||
/>
|
||
))}
|
||
</div>
|
||
</div>
|
||
|
||
<div className="flex flex-wrap items-center gap-2">
|
||
<button className="btn-primary px-4 py-2 text-sm" onClick={onSave} disabled={update.isPending}>
|
||
{update.isPending ? 'Saving…' : 'Save Alerts'}
|
||
</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={() => test.mutate()}
|
||
disabled={test.isPending}
|
||
>
|
||
{test.isPending ? 'Sending…' : 'Send Test'}
|
||
</button>
|
||
<span className="text-[11px] text-gray-500">Save first, then Send Test to verify the bot reaches you.</span>
|
||
</div>
|
||
|
||
<div className="rounded-lg border border-white/[0.06] bg-white/[0.02] px-4 py-2.5 text-[11px] text-gray-500">
|
||
Setup: 1) message <span className="text-gray-300">@BotFather</span>, send <span className="text-gray-300">/newbot</span>, copy the token.
|
||
2) send your new bot any message. 3) get your chat ID from <span className="text-gray-300">@userinfobot</span>. Paste both above.
|
||
</div>
|
||
</div>
|
||
);
|
||
}
|