add Telegram alerts: qualified setups, S/R proximity, score drops, daily digest
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:
@@ -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 ticker’s 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 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>
|
||||
);
|
||||
}
|
||||
@@ -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'],
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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 />
|
||||
|
||||
Reference in New Issue
Block a user