[OQI-003] feat: Add AlertsPanel component for price alerts management

- Create AlertsPanel component with full CRUD functionality
- Add alerts API functions to trading.service.ts
- Integrate AlertsPanel into Trading.tsx with toggle button
- Support for above/below/crosses conditions
- Push and email notification options
- Recurring alerts support
- Filter by active/inactive status

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
Adrian Flores Cortes 2026-01-25 09:41:05 -06:00
parent 639c70587a
commit 49d6492c91
3 changed files with 661 additions and 0 deletions

View File

@ -0,0 +1,457 @@
/**
* AlertsPanel Component
* Manages price alerts for trading symbols
*/
import React, { useEffect, useState, useCallback } from 'react';
import {
BellIcon,
BellAlertIcon,
PlusIcon,
TrashIcon,
ArrowPathIcon,
CheckCircleIcon,
XCircleIcon,
ArrowUpIcon,
ArrowDownIcon,
} from '@heroicons/react/24/solid';
import {
getAlerts,
createAlert,
deleteAlert,
enableAlert,
disableAlert,
getAlertStats,
type PriceAlert,
type AlertCondition,
type AlertStats,
type CreateAlertInput,
} from '../../../services/trading.service';
interface AlertsPanelProps {
symbol: string;
currentPrice?: number;
}
export const AlertsPanel: React.FC<AlertsPanelProps> = ({ symbol, currentPrice }) => {
const [alerts, setAlerts] = useState<PriceAlert[]>([]);
const [stats, setStats] = useState<AlertStats | null>(null);
const [loading, setLoading] = useState(false);
const [error, setError] = useState<string | null>(null);
const [showForm, setShowForm] = useState(false);
const [filterActive, setFilterActive] = useState<boolean | undefined>(undefined);
// Form state
const [formPrice, setFormPrice] = useState<string>('');
const [formCondition, setFormCondition] = useState<AlertCondition>('above');
const [formNote, setFormNote] = useState('');
const [formNotifyPush, setFormNotifyPush] = useState(true);
const [formNotifyEmail, setFormNotifyEmail] = useState(false);
const [formIsRecurring, setFormIsRecurring] = useState(false);
const [submitting, setSubmitting] = useState(false);
// Fetch alerts
const fetchAlerts = useCallback(async () => {
setLoading(true);
setError(null);
try {
const [alertsData, statsData] = await Promise.all([
getAlerts({ isActive: filterActive }),
getAlertStats(),
]);
setAlerts(alertsData);
setStats(statsData);
} catch (err) {
setError('Failed to fetch alerts');
console.error('Alerts fetch error:', err);
} finally {
setLoading(false);
}
}, [filterActive]);
useEffect(() => {
fetchAlerts();
}, [fetchAlerts]);
// Set current price when showing form
useEffect(() => {
if (showForm && currentPrice) {
setFormPrice(currentPrice.toFixed(2));
}
}, [showForm, currentPrice]);
// Handle create alert
const handleCreateAlert = async (e: React.FormEvent) => {
e.preventDefault();
if (!formPrice || isNaN(parseFloat(formPrice))) return;
setSubmitting(true);
try {
const input: CreateAlertInput = {
symbol: symbol.toUpperCase(),
condition: formCondition,
price: parseFloat(formPrice),
note: formNote || undefined,
notifyPush: formNotifyPush,
notifyEmail: formNotifyEmail,
isRecurring: formIsRecurring,
};
await createAlert(input);
setShowForm(false);
setFormPrice('');
setFormNote('');
setFormCondition('above');
setFormIsRecurring(false);
fetchAlerts();
} catch (err) {
setError('Failed to create alert');
} finally {
setSubmitting(false);
}
};
// Handle toggle alert
const handleToggleAlert = async (alert: PriceAlert) => {
try {
if (alert.isActive) {
await disableAlert(alert.id);
} else {
await enableAlert(alert.id);
}
fetchAlerts();
} catch (err) {
setError('Failed to toggle alert');
}
};
// Handle delete alert
const handleDeleteAlert = async (alertId: string) => {
try {
await deleteAlert(alertId);
fetchAlerts();
} catch (err) {
setError('Failed to delete alert');
}
};
// Get condition display
const getConditionDisplay = (condition: AlertCondition) => {
switch (condition) {
case 'above':
return { text: 'Price Above', icon: ArrowUpIcon, color: 'text-green-400' };
case 'below':
return { text: 'Price Below', icon: ArrowDownIcon, color: 'text-red-400' };
case 'crosses_above':
return { text: 'Crosses Above', icon: ArrowUpIcon, color: 'text-green-400' };
case 'crosses_below':
return { text: 'Crosses Below', icon: ArrowDownIcon, color: 'text-red-400' };
}
};
// Filter alerts for current symbol
const symbolAlerts = alerts.filter((a) => a.symbol === symbol.toUpperCase());
const otherAlerts = alerts.filter((a) => a.symbol !== symbol.toUpperCase());
return (
<div className="card p-4 space-y-4 h-full overflow-y-auto">
{/* Header */}
<div className="flex items-center justify-between">
<div className="flex items-center gap-2">
<BellIcon className="w-5 h-5 text-yellow-400" />
<h3 className="text-lg font-semibold text-white">Price Alerts</h3>
</div>
<div className="flex items-center gap-2">
<button
onClick={fetchAlerts}
disabled={loading}
className="p-1.5 text-gray-400 hover:text-white hover:bg-gray-700 rounded transition-colors disabled:opacity-50"
title="Refresh alerts"
>
<ArrowPathIcon className={`w-4 h-4 ${loading ? 'animate-spin' : ''}`} />
</button>
<button
onClick={() => setShowForm(!showForm)}
className="flex items-center gap-1 px-2 py-1 text-sm bg-yellow-600 hover:bg-yellow-700 text-white rounded transition-colors"
>
<PlusIcon className="w-4 h-4" />
<span>New</span>
</button>
</div>
</div>
{/* Stats */}
{stats && (
<div className="grid grid-cols-3 gap-2">
<div className="text-center p-2 bg-gray-800 rounded">
<p className="text-xs text-gray-400">Total</p>
<p className="text-lg font-bold text-white">{stats.total}</p>
</div>
<div className="text-center p-2 bg-green-900/30 rounded">
<p className="text-xs text-green-400">Active</p>
<p className="text-lg font-bold text-green-400">{stats.active}</p>
</div>
<div className="text-center p-2 bg-yellow-900/30 rounded">
<p className="text-xs text-yellow-400">Triggered</p>
<p className="text-lg font-bold text-yellow-400">{stats.triggered}</p>
</div>
</div>
)}
{/* Error message */}
{error && (
<div className="flex items-center gap-2 p-3 bg-red-900/20 border border-red-800 rounded-lg">
<XCircleIcon className="w-5 h-5 text-red-400" />
<span className="text-sm text-red-400">{error}</span>
</div>
)}
{/* Create Alert Form */}
{showForm && (
<form onSubmit={handleCreateAlert} className="space-y-3 p-3 bg-gray-800 rounded-lg border border-gray-700">
<div className="flex items-center justify-between">
<h4 className="text-sm font-medium text-white">Create Alert for {symbol}</h4>
<button
type="button"
onClick={() => setShowForm(false)}
className="text-gray-400 hover:text-white"
>
<XCircleIcon className="w-5 h-5" />
</button>
</div>
<div className="grid grid-cols-2 gap-2">
<div>
<label className="block text-xs text-gray-400 mb-1">Condition</label>
<select
value={formCondition}
onChange={(e) => setFormCondition(e.target.value as AlertCondition)}
className="w-full px-2 py-1.5 bg-gray-700 border border-gray-600 rounded text-white text-sm focus:outline-none focus:border-yellow-500"
>
<option value="above">Price Above</option>
<option value="below">Price Below</option>
<option value="crosses_above">Crosses Above</option>
<option value="crosses_below">Crosses Below</option>
</select>
</div>
<div>
<label className="block text-xs text-gray-400 mb-1">Target Price</label>
<input
type="number"
step="any"
value={formPrice}
onChange={(e) => setFormPrice(e.target.value)}
placeholder="0.00"
className="w-full px-2 py-1.5 bg-gray-700 border border-gray-600 rounded text-white text-sm focus:outline-none focus:border-yellow-500"
required
/>
</div>
</div>
<div>
<label className="block text-xs text-gray-400 mb-1">Note (optional)</label>
<input
type="text"
value={formNote}
onChange={(e) => setFormNote(e.target.value)}
placeholder="Reminder note..."
className="w-full px-2 py-1.5 bg-gray-700 border border-gray-600 rounded text-white text-sm focus:outline-none focus:border-yellow-500"
/>
</div>
<div className="flex flex-wrap gap-4 text-sm">
<label className="flex items-center gap-2 cursor-pointer">
<input
type="checkbox"
checked={formNotifyPush}
onChange={(e) => setFormNotifyPush(e.target.checked)}
className="w-4 h-4 rounded bg-gray-700 border-gray-600 text-yellow-500 focus:ring-yellow-500"
/>
<span className="text-gray-300">Push notification</span>
</label>
<label className="flex items-center gap-2 cursor-pointer">
<input
type="checkbox"
checked={formNotifyEmail}
onChange={(e) => setFormNotifyEmail(e.target.checked)}
className="w-4 h-4 rounded bg-gray-700 border-gray-600 text-yellow-500 focus:ring-yellow-500"
/>
<span className="text-gray-300">Email</span>
</label>
<label className="flex items-center gap-2 cursor-pointer">
<input
type="checkbox"
checked={formIsRecurring}
onChange={(e) => setFormIsRecurring(e.target.checked)}
className="w-4 h-4 rounded bg-gray-700 border-gray-600 text-yellow-500 focus:ring-yellow-500"
/>
<span className="text-gray-300">Recurring</span>
</label>
</div>
<button
type="submit"
disabled={submitting || !formPrice}
className="w-full py-2 bg-yellow-600 hover:bg-yellow-700 disabled:bg-gray-600 text-white rounded transition-colors font-medium"
>
{submitting ? 'Creating...' : 'Create Alert'}
</button>
</form>
)}
{/* Filter Tabs */}
<div className="flex gap-1 border-b border-gray-700 pb-2">
<button
onClick={() => setFilterActive(undefined)}
className={`px-3 py-1 text-sm rounded ${
filterActive === undefined ? 'bg-gray-700 text-white' : 'text-gray-400 hover:text-white'
}`}
>
All
</button>
<button
onClick={() => setFilterActive(true)}
className={`px-3 py-1 text-sm rounded ${
filterActive === true ? 'bg-green-700 text-white' : 'text-gray-400 hover:text-white'
}`}
>
Active
</button>
<button
onClick={() => setFilterActive(false)}
className={`px-3 py-1 text-sm rounded ${
filterActive === false ? 'bg-gray-700 text-white' : 'text-gray-400 hover:text-white'
}`}
>
Inactive
</button>
</div>
{/* Alerts for Current Symbol */}
{symbolAlerts.length > 0 && (
<div className="space-y-2">
<h4 className="text-xs font-medium text-gray-400 uppercase">{symbol} Alerts</h4>
{symbolAlerts.map((alert) => {
const condition = getConditionDisplay(alert.condition);
const ConditionIcon = condition.icon;
return (
<div
key={alert.id}
className={`p-3 rounded-lg border ${
alert.isActive
? 'bg-gray-800 border-gray-700'
: 'bg-gray-800/50 border-gray-700/50 opacity-60'
}`}
>
<div className="flex items-center justify-between mb-2">
<div className="flex items-center gap-2">
<ConditionIcon className={`w-4 h-4 ${condition.color}`} />
<span className="text-sm text-gray-300">{condition.text}</span>
<span className="font-mono font-bold text-white">${alert.price.toFixed(2)}</span>
</div>
<div className="flex items-center gap-1">
<button
onClick={() => handleToggleAlert(alert)}
className={`p-1 rounded ${
alert.isActive
? 'text-green-400 hover:bg-green-900/30'
: 'text-gray-500 hover:bg-gray-700'
}`}
title={alert.isActive ? 'Disable' : 'Enable'}
>
{alert.isActive ? (
<CheckCircleIcon className="w-4 h-4" />
) : (
<XCircleIcon className="w-4 h-4" />
)}
</button>
<button
onClick={() => handleDeleteAlert(alert.id)}
className="p-1 text-red-400 hover:bg-red-900/30 rounded"
title="Delete"
>
<TrashIcon className="w-4 h-4" />
</button>
</div>
</div>
{alert.note && (
<p className="text-xs text-gray-400 mb-1">{alert.note}</p>
)}
<div className="flex items-center gap-2 text-xs text-gray-500">
{alert.isRecurring && (
<span className="px-1.5 py-0.5 bg-blue-900/30 text-blue-400 rounded">Recurring</span>
)}
{alert.triggeredAt && (
<span className="flex items-center gap-1">
<BellAlertIcon className="w-3 h-3 text-yellow-400" />
Triggered at ${alert.triggeredPrice?.toFixed(2)}
</span>
)}
</div>
</div>
);
})}
</div>
)}
{/* Other Alerts */}
{otherAlerts.length > 0 && (
<div className="space-y-2">
<h4 className="text-xs font-medium text-gray-400 uppercase">Other Symbols</h4>
{otherAlerts.map((alert) => {
const condition = getConditionDisplay(alert.condition);
const ConditionIcon = condition.icon;
return (
<div
key={alert.id}
className={`p-2 rounded-lg border ${
alert.isActive
? 'bg-gray-800/70 border-gray-700'
: 'bg-gray-800/30 border-gray-700/50 opacity-50'
}`}
>
<div className="flex items-center justify-between">
<div className="flex items-center gap-2">
<span className="text-xs font-medium text-gray-300">{alert.symbol}</span>
<ConditionIcon className={`w-3 h-3 ${condition.color}`} />
<span className="font-mono text-sm text-white">${alert.price.toFixed(2)}</span>
</div>
<div className="flex items-center gap-1">
<button
onClick={() => handleToggleAlert(alert)}
className={`p-1 rounded ${
alert.isActive ? 'text-green-400' : 'text-gray-500'
}`}
>
{alert.isActive ? (
<CheckCircleIcon className="w-3 h-3" />
) : (
<XCircleIcon className="w-3 h-3" />
)}
</button>
<button
onClick={() => handleDeleteAlert(alert.id)}
className="p-1 text-red-400 rounded"
>
<TrashIcon className="w-3 h-3" />
</button>
</div>
</div>
</div>
);
})}
</div>
)}
{/* Empty State */}
{alerts.length === 0 && !loading && (
<div className="text-center py-8 text-gray-500">
<BellIcon className="w-10 h-10 mx-auto mb-3 opacity-50" />
<p className="text-sm">No price alerts yet</p>
<p className="text-xs mt-1">Create an alert to get notified when price reaches your target</p>
</div>
)}
</div>
);
};
export default AlertsPanel;

View File

@ -5,6 +5,7 @@ import CandlestickChartWithML from '../components/CandlestickChartWithML';
import WatchlistSidebar from '../components/WatchlistSidebar';
import PaperTradingPanel from '../components/PaperTradingPanel';
import MLSignalsPanel from '../components/MLSignalsPanel';
import AlertsPanel from '../components/AlertsPanel';
import type { Interval, CrosshairData } from '../../../types/trading.types';
import type { MLSignal } from '../../../services/mlService';
@ -14,6 +15,7 @@ export default function Trading() {
const [isSidebarOpen, setIsSidebarOpen] = useState(true);
const [isMLSignalsOpen, setIsMLSignalsOpen] = useState(true);
const [isPaperTradingOpen, setIsPaperTradingOpen] = useState(true);
const [isAlertsOpen, setIsAlertsOpen] = useState(false);
// ML Overlay states
const [enableMLOverlays, setEnableMLOverlays] = useState(true);
@ -239,6 +241,25 @@ export default function Trading() {
/>
</svg>
</button>
<button
onClick={() => setIsAlertsOpen(!isAlertsOpen)}
className={`hidden lg:flex items-center justify-center w-8 h-8 rounded transition-colors ${
isAlertsOpen
? 'text-yellow-400 bg-yellow-900/30'
: 'text-gray-400 hover:text-white hover:bg-gray-800'
}`}
aria-label="Toggle alerts"
title="Toggle Price Alerts"
>
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={2}
d="M15 17h5l-1.405-1.405A2.032 2.032 0 0118 14.158V11a6.002 6.002 0 00-4-5.659V5a2 2 0 10-4 0v.341C7.67 6.165 6 8.388 6 11v3.159c0 .538-.214 1.055-.595 1.436L4 17h5m6 0v1a3 3 0 11-6 0v-1m6 0H9"
/>
</svg>
</button>
<button
onClick={() => setIsPaperTradingOpen(!isPaperTradingOpen)}
className="hidden lg:flex items-center justify-center w-8 h-8 text-gray-400 hover:text-white hover:bg-gray-800 rounded transition-colors"
@ -368,6 +389,18 @@ export default function Trading() {
)}
</div>
{/* Price Alerts Panel - Desktop */}
<div className={`hidden lg:block transition-all duration-300 ${isAlertsOpen ? 'w-80' : 'w-0 overflow-hidden'}`}>
{isAlertsOpen && (
<div className="h-[600px]">
<AlertsPanel
symbol={selectedSymbol}
currentPrice={currentTicker?.price}
/>
</div>
)}
</div>
{/* Paper Trading Panel - Desktop */}
<div className={`hidden lg:block transition-all duration-300 ${isPaperTradingOpen ? 'w-80' : 'w-0 overflow-hidden'}`}>
{isPaperTradingOpen && (

View File

@ -789,6 +789,167 @@ export async function getLLMAgentHealth(): Promise<boolean> {
}
}
// ============================================================================
// Price Alerts API
// ============================================================================
export type AlertCondition = 'above' | 'below' | 'crosses_above' | 'crosses_below';
export interface PriceAlert {
id: string;
userId: string;
symbol: string;
condition: AlertCondition;
price: number;
note?: string;
isActive: boolean;
triggeredAt?: string;
triggeredPrice?: number;
notifyEmail: boolean;
notifyPush: boolean;
isRecurring: boolean;
createdAt: string;
}
export interface CreateAlertInput {
symbol: string;
condition: AlertCondition;
price: number;
note?: string;
notifyEmail?: boolean;
notifyPush?: boolean;
isRecurring?: boolean;
}
export interface UpdateAlertInput {
price?: number;
note?: string;
notifyEmail?: boolean;
notifyPush?: boolean;
isRecurring?: boolean;
isActive?: boolean;
}
export interface AlertsFilter {
isActive?: boolean;
symbol?: string;
condition?: AlertCondition;
}
export interface AlertStats {
total: number;
active: number;
triggered: number;
}
/**
* Get user's price alerts
*/
export async function getAlerts(filter: AlertsFilter = {}): Promise<PriceAlert[]> {
try {
const params: Record<string, string> = {};
if (filter.isActive !== undefined) params.isActive = String(filter.isActive);
if (filter.symbol) params.symbol = filter.symbol;
if (filter.condition) params.condition = filter.condition;
const response = await api.get('/trading/alerts', { params });
return response.data.data || response.data;
} catch (error) {
console.error('Failed to fetch alerts:', error);
throw new Error('Failed to fetch alerts');
}
}
/**
* Get alert by ID
*/
export async function getAlertById(alertId: string): Promise<PriceAlert> {
try {
const response = await api.get(`/trading/alerts/${alertId}`);
return response.data.data || response.data;
} catch (error) {
console.error('Failed to fetch alert:', error);
throw new Error('Failed to fetch alert');
}
}
/**
* Create a new price alert
*/
export async function createAlert(input: CreateAlertInput): Promise<PriceAlert> {
try {
const response = await api.post('/trading/alerts', input);
return response.data.data || response.data;
} catch (error) {
console.error('Failed to create alert:', error);
throw new Error('Failed to create alert');
}
}
/**
* Update an alert
*/
export async function updateAlert(alertId: string, updates: UpdateAlertInput): Promise<PriceAlert> {
try {
const response = await api.patch(`/trading/alerts/${alertId}`, updates);
return response.data.data || response.data;
} catch (error) {
console.error('Failed to update alert:', error);
throw new Error('Failed to update alert');
}
}
/**
* Delete an alert
*/
export async function deleteAlert(alertId: string): Promise<void> {
try {
await api.delete(`/trading/alerts/${alertId}`);
} catch (error) {
console.error('Failed to delete alert:', error);
throw new Error('Failed to delete alert');
}
}
/**
* Enable an alert
*/
export async function enableAlert(alertId: string): Promise<PriceAlert> {
try {
const response = await api.post(`/trading/alerts/${alertId}/enable`);
return response.data.data || response.data;
} catch (error) {
console.error('Failed to enable alert:', error);
throw new Error('Failed to enable alert');
}
}
/**
* Disable an alert
*/
export async function disableAlert(alertId: string): Promise<PriceAlert> {
try {
const response = await api.post(`/trading/alerts/${alertId}/disable`);
return response.data.data || response.data;
} catch (error) {
console.error('Failed to disable alert:', error);
throw new Error('Failed to disable alert');
}
}
/**
* Get alert statistics
*/
export async function getAlertStats(): Promise<AlertStats> {
try {
const response = await api.get('/trading/alerts/stats');
return response.data.data || response.data;
} catch (error) {
console.error('Failed to fetch alert stats:', error);
throw new Error('Failed to fetch alert stats');
}
}
// ============================================================================
// Export
// ============================================================================
@ -842,6 +1003,16 @@ export const tradingService = {
modifyMT4Position,
calculatePositionSize,
getLLMAgentHealth,
// Price Alerts
getAlerts,
getAlertById,
createAlert,
updateAlert,
deleteAlert,
enableAlert,
disableAlert,
getAlertStats,
};
export default tradingService;