add Telegram alerts: qualified setups, S/R proximity, score drops, daily digest
Deploy / lint (push) Successful in 5s
Deploy / test (push) Successful in 35s
Deploy / deploy (push) Successful in 23s

Closes the action loop — instead of polling the dashboard, the platform pushes
actionable signals to Telegram. New hourly 'alerts' job dispatches four
toggleable triggers, deduped via a new alert_log table (cooldown-based for
qualified/S-R/digest, watermark-based for score deterioration). Admin → Settings
gains a Telegram panel (write-only bot token, chat ID, per-trigger toggles, Send
Test). Credentials follow DB > env precedence (TELEGRAM_BOT_TOKEN / _CHAT_ID).

Backend: alert_service + AlertLog model + migration 005, scheduler job, admin
endpoints/schema. Frontend: AlertSettings panel, hooks, api, types.

Deploy: run alembic upgrade (new alert_log table).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
2026-06-14 19:42:18 +02:00
parent 9d0bef369f
commit 5d41ccac1c
17 changed files with 976 additions and 2 deletions
+28
View File
@@ -2,6 +2,8 @@ import apiClient from './client';
import type {
ActivationConfig,
AdminUser,
AlertConfig,
AlertTestResult,
PipelineReadiness,
RecommendationConfig,
SentimentProviderConfig,
@@ -105,6 +107,32 @@ export function testSentimentSettings(ticker: string) {
.then((r) => r.data);
}
export function getAlertSettings() {
return apiClient
.get<AlertConfig>('admin/settings/alerts')
.then((r) => r.data);
}
export function updateAlertSettings(payload: {
enabled?: boolean;
bot_token?: string;
telegram_chat_id?: string;
qualified_enabled?: boolean;
sr_proximity_enabled?: boolean;
score_drop_enabled?: boolean;
digest_enabled?: boolean;
}) {
return apiClient
.put<AlertConfig>('admin/settings/alerts', payload)
.then((r) => r.data);
}
export function testAlertSettings() {
return apiClient
.post<AlertTestResult>('admin/settings/alerts/test')
.then((r) => r.data);
}
export function getTickerUniverseSetting() {
return apiClient
.get<TickerUniverseSetting>('admin/settings/ticker-universe')
@@ -0,0 +1,178 @@
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';
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 tickers composite drops sharply' },
{ key: 'digest_enabled', label: 'Daily digest', hint: 'one end-of-day summary of qualified setups' },
];
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,
});
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,
});
}
}, [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 dont have to keep checking the dashboard.
The dispatcher runs hourly; each trigger respects a cooldown so youre 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>
);
}
+39
View File
@@ -175,6 +175,45 @@ export function useTestSentimentProvider() {
});
}
export function useAlertSettings() {
return useQuery({
queryKey: ['admin', 'alert-settings'],
queryFn: () => adminApi.getAlertSettings(),
});
}
export function useUpdateAlertSettings() {
const qc = useQueryClient();
const { addToast } = useToast();
return useMutation({
mutationFn: (payload: Parameters<typeof adminApi.updateAlertSettings>[0]) =>
adminApi.updateAlertSettings(payload),
onSuccess: () => {
qc.invalidateQueries({ queryKey: ['admin', 'alert-settings'] });
addToast('success', 'Alert settings updated');
},
onError: (error: Error) => {
addToast('error', error.message || 'Failed to update alert settings');
},
});
}
export function useTestAlert() {
const { addToast } = useToast();
return useMutation({
mutationFn: () => adminApi.testAlertSettings(),
onSuccess: (result) => {
if (result.ok) addToast('success', 'Test alert sent — check Telegram.');
else addToast('error', result.error || 'Test alert failed');
},
onError: (error: Error) => {
addToast('error', error.message || 'Test alert failed');
},
});
}
export function useTickerUniverseSetting() {
return useQuery({
queryKey: ['admin', 'ticker-universe'],
+16
View File
@@ -179,6 +179,22 @@ export interface SentimentProviderConfig {
custom_base_url_providers: string[];
}
export interface AlertConfig {
enabled: boolean;
telegram_chat_id: string;
bot_token_configured: boolean;
bot_token_source: 'database' | 'environment' | 'none';
qualified_enabled: boolean;
sr_proximity_enabled: boolean;
score_drop_enabled: boolean;
digest_enabled: boolean;
}
export interface AlertTestResult {
ok: boolean;
error?: string;
}
export interface SentimentTestResult {
ok: boolean;
provider: string;
+2
View File
@@ -1,5 +1,6 @@
import { useState } from 'react';
import { ActivationSettings } from '../components/admin/ActivationSettings';
import { AlertSettings } from '../components/admin/AlertSettings';
import { SentimentProviderSettings } from '../components/admin/SentimentProviderSettings';
import { DataCleanup } from '../components/admin/DataCleanup';
import { JobControls } from '../components/admin/JobControls';
@@ -31,6 +32,7 @@ export default function AdminPage() {
{activeTab === 'Settings' && (
<div className="space-y-4">
<ActivationSettings />
<AlertSettings />
<SentimentProviderSettings />
<TickerUniverseBootstrap />
<RecommendationSettings />