Skip to content

Commit 05d873f

Browse files
committed
feat: 添加模拟盘跟单通知功能
- 新增 paper_trading_notifier 模块,支持建仓/平仓实时推送、盘前计划(09:00)、日终摘要(15:30) - 引擎层收集建仓/平仓事件,扫描完成后异步发送通知 - 调度器新增盘前计划和日终摘要定时任务 - 后端新增通知配置读写接口和测试通知接口 - 前端新增通知设置对话框,支持开关、渠道选择、模式配置
1 parent 8862886 commit 05d873f

6 files changed

Lines changed: 720 additions & 21 deletions

File tree

frontend/packages/api/src/paper-trading.ts

Lines changed: 32 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -89,6 +89,24 @@ export interface PaperTradingMetricsResponse {
8989
strategy_performance: StrategyPerformanceItem[]
9090
}
9191

92+
export interface NotifyChannelItem {
93+
id: number
94+
name: string
95+
type: string
96+
is_default: boolean
97+
}
98+
99+
export interface PaperTradingNotifySettings {
100+
settings: {
101+
pt_notify_enabled: string
102+
pt_notify_channel_ids: string
103+
pt_notify_realtime: string
104+
pt_notify_premarket: string
105+
pt_notify_summary: string
106+
}
107+
channels: NotifyChannelItem[]
108+
}
109+
92110
export const paperTradingApi = {
93111
getAccount: () =>
94112
fetchAPI<PaperTradingAccountResponse>('/paper-trading/account'),
@@ -131,4 +149,18 @@ export const paperTradingApi = {
131149
method: 'POST',
132150
timeoutMs: 30000,
133151
}),
152+
153+
getNotifySettings: () =>
154+
fetchAPI<PaperTradingNotifySettings>('/paper-trading/notify-settings'),
155+
156+
updateNotifySettings: (settings: Record<string, string>) =>
157+
fetchAPI<PaperTradingNotifySettings>('/paper-trading/notify-settings', {
158+
method: 'POST',
159+
body: JSON.stringify(settings),
160+
}),
161+
162+
testNotify: () =>
163+
fetchAPI<{ ok: boolean }>('/paper-trading/notify-test', {
164+
method: 'POST',
165+
}),
134166
}

frontend/src/pages/PaperTrading.tsx

Lines changed: 172 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,14 +1,17 @@
11
import { useEffect, useState, useCallback } from 'react'
2-
import { RefreshCw, Power, RotateCcw, X, TrendingUp, TrendingDown, Trophy, BarChart3, Wallet, Activity, Play } from 'lucide-react'
2+
import { RefreshCw, Power, RotateCcw, X, TrendingUp, TrendingDown, Trophy, BarChart3, Wallet, Activity, Play, Bell } from 'lucide-react'
33
import {
44
paperTradingApi,
55
type PaperTradingAccountResponse,
66
type PaperTradingPositionItem,
77
type PaperTradingTradeItem,
88
type EquityCurvePoint,
99
type StrategyPerformanceItem,
10+
type NotifyChannelItem,
1011
} from '@panwatch/api'
1112
import { Button } from '@panwatch/base-ui/components/ui/button'
13+
import { Switch } from '@panwatch/base-ui/components/ui/switch'
14+
import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogDescription } from '@panwatch/base-ui/components/ui/dialog'
1215
import { useToast } from '@panwatch/base-ui/components/ui/toast'
1316

1417
const EXIT_REASON_MAP: Record<string, string> = {
@@ -112,6 +115,17 @@ export default function PaperTradingPage() {
112115
const [tradesPage, setTradesPage] = useState(0)
113116
const tradesPageSize = 20
114117

118+
// 通知设置
119+
const [notifyOpen, setNotifyOpen] = useState(false)
120+
const [notifyEnabled, setNotifyEnabled] = useState(false)
121+
const [notifyRealtime, setNotifyRealtime] = useState(true)
122+
const [notifyPremarket, setNotifyPremarket] = useState(true)
123+
const [notifySummary, setNotifySummary] = useState(true)
124+
const [notifyChannels, setNotifyChannels] = useState<NotifyChannelItem[]>([])
125+
const [selectedChannelIds, setSelectedChannelIds] = useState<Set<number>>(new Set())
126+
const [notifySaving, setNotifySaving] = useState(false)
127+
const [notifyTesting, setNotifyTesting] = useState(false)
128+
115129
const loadData = useCallback(async () => {
116130
setLoading(true)
117131
try {
@@ -181,6 +195,69 @@ export default function PaperTradingPage() {
181195
}
182196
}
183197

198+
const loadNotifySettings = async () => {
199+
try {
200+
const data = await paperTradingApi.getNotifySettings()
201+
const s = data.settings
202+
setNotifyEnabled(s.pt_notify_enabled === 'true')
203+
setNotifyRealtime(s.pt_notify_realtime === 'true')
204+
setNotifyPremarket(s.pt_notify_premarket === 'true')
205+
setNotifySummary(s.pt_notify_summary === 'true')
206+
setNotifyChannels(data.channels)
207+
const ids = s.pt_notify_channel_ids
208+
? new Set(s.pt_notify_channel_ids.split(',').map(Number).filter(Boolean))
209+
: new Set<number>()
210+
setSelectedChannelIds(ids)
211+
} catch {
212+
toast('加载通知配置失败', 'error')
213+
}
214+
}
215+
216+
const handleOpenNotify = async () => {
217+
setNotifyOpen(true)
218+
await loadNotifySettings()
219+
}
220+
221+
const handleSaveNotify = async () => {
222+
setNotifySaving(true)
223+
try {
224+
await paperTradingApi.updateNotifySettings({
225+
pt_notify_enabled: notifyEnabled ? 'true' : 'false',
226+
pt_notify_channel_ids: Array.from(selectedChannelIds).join(','),
227+
pt_notify_realtime: notifyRealtime ? 'true' : 'false',
228+
pt_notify_premarket: notifyPremarket ? 'true' : 'false',
229+
pt_notify_summary: notifySummary ? 'true' : 'false',
230+
})
231+
toast('通知配置已保存', 'success')
232+
setNotifyOpen(false)
233+
} catch {
234+
toast('保存失败', 'error')
235+
} finally {
236+
setNotifySaving(false)
237+
}
238+
}
239+
240+
const handleTestNotify = async () => {
241+
setNotifyTesting(true)
242+
try {
243+
await paperTradingApi.testNotify()
244+
toast('测试通知已发送', 'success')
245+
} catch {
246+
toast('测试通知发送失败', 'error')
247+
} finally {
248+
setNotifyTesting(false)
249+
}
250+
}
251+
252+
const toggleChannel = (id: number) => {
253+
setSelectedChannelIds(prev => {
254+
const next = new Set(prev)
255+
if (next.has(id)) next.delete(id)
256+
else next.add(id)
257+
return next
258+
})
259+
}
260+
184261
const totalPages = Math.ceil(tradesTotal / tradesPageSize)
185262

186263
return (
@@ -199,6 +276,10 @@ export default function PaperTradingPage() {
199276
)}
200277
</div>
201278
<div className="flex items-center gap-2">
279+
<Button variant="outline" size="sm" className="h-8" onClick={handleOpenNotify}>
280+
<Bell className="w-3.5 h-3.5" />
281+
<span className="hidden sm:inline ml-1">通知</span>
282+
</Button>
202283
<Button variant="outline" size="sm" className="h-8" onClick={handleScan} disabled={scanning}>
203284
<Play className="w-3.5 h-3.5 mr-1" />
204285
<span className="hidden sm:inline">{scanning ? '扫描中...' : '立即扫描'}</span>
@@ -471,6 +552,96 @@ export default function PaperTradingPage() {
471552
</>
472553
)}
473554
</div>
555+
556+
{/* 跟单通知设置对话框 */}
557+
<Dialog open={notifyOpen} onOpenChange={setNotifyOpen}>
558+
<DialogContent>
559+
<DialogHeader>
560+
<DialogTitle>跟单通知设置</DialogTitle>
561+
<DialogDescription>配置模拟盘交易通知,实时跟踪建仓/平仓动作</DialogDescription>
562+
</DialogHeader>
563+
564+
<div className="space-y-5">
565+
{/* 总开关 */}
566+
<div className="flex items-center justify-between">
567+
<div>
568+
<div className="text-sm font-medium">启用通知</div>
569+
<div className="text-xs text-muted-foreground">开启后将通过所选渠道推送交易通知</div>
570+
</div>
571+
<Switch checked={notifyEnabled} onCheckedChange={setNotifyEnabled} />
572+
</div>
573+
574+
{notifyEnabled && (
575+
<>
576+
{/* 通知渠道选择 */}
577+
<div>
578+
<div className="text-sm font-medium mb-2">通知渠道</div>
579+
{notifyChannels.length === 0 ? (
580+
<div className="text-xs text-muted-foreground">暂无可用渠道,请先在设置中配置通知渠道</div>
581+
) : (
582+
<div className="flex flex-wrap gap-2">
583+
{notifyChannels.map(ch => (
584+
<button
585+
key={ch.id}
586+
onClick={() => toggleChannel(ch.id)}
587+
className={`px-2.5 py-1 rounded-lg text-xs font-medium transition-all ${
588+
selectedChannelIds.has(ch.id)
589+
? 'bg-primary/10 text-primary ring-1 ring-primary/20'
590+
: 'bg-muted/50 text-muted-foreground'
591+
}`}
592+
>
593+
{ch.name}
594+
</button>
595+
))}
596+
</div>
597+
)}
598+
{selectedChannelIds.size === 0 && notifyChannels.length > 0 && (
599+
<div className="text-xs text-muted-foreground mt-1">未选择渠道时将使用默认渠道</div>
600+
)}
601+
</div>
602+
603+
{/* 通知模式 */}
604+
<div className="space-y-3">
605+
<div className="text-sm font-medium">通知模式</div>
606+
<div className="flex items-center justify-between">
607+
<div>
608+
<div className="text-sm">实时交易信号</div>
609+
<div className="text-xs text-muted-foreground">建仓/平仓时立即推送</div>
610+
</div>
611+
<Switch checked={notifyRealtime} onCheckedChange={setNotifyRealtime} />
612+
</div>
613+
<div className="flex items-center justify-between">
614+
<div>
615+
<div className="text-sm">盘前计划</div>
616+
<div className="text-xs text-muted-foreground">每天 09:00 推送当日候选列表</div>
617+
</div>
618+
<Switch checked={notifyPremarket} onCheckedChange={setNotifyPremarket} />
619+
</div>
620+
<div className="flex items-center justify-between">
621+
<div>
622+
<div className="text-sm">日终摘要</div>
623+
<div className="text-xs text-muted-foreground">每天 15:30 推送当日操作汇总</div>
624+
</div>
625+
<Switch checked={notifySummary} onCheckedChange={setNotifySummary} />
626+
</div>
627+
</div>
628+
</>
629+
)}
630+
631+
{/* 操作按钮 */}
632+
<div className="flex items-center gap-2 pt-2">
633+
<Button size="sm" onClick={handleSaveNotify} disabled={notifySaving}>
634+
{notifySaving ? '保存中...' : '保存'}
635+
</Button>
636+
{notifyEnabled && (
637+
<Button variant="outline" size="sm" onClick={handleTestNotify} disabled={notifyTesting}>
638+
{notifyTesting ? '发送中...' : '测试通知'}
639+
</Button>
640+
)}
641+
</div>
642+
</div>
643+
</DialogContent>
644+
</Dialog>
474645
</div>
475646
)
476647
}

0 commit comments

Comments
 (0)