Skip to content

Commit bad5157

Browse files
committed
feat(paper-trading): 分市场投资比例 + 市场切换刷新统计
- 账户新增 market_allocations(A/港/美 各占总资金比例,0=不投入), 迁移 118 加列并由旧 excluded_markets 回填 - 引擎建仓门槛从单一资金池改为按市场子池:比例 0 跳过, 额度按 总资金×比例+已实现盈亏−持仓成本 逐笔扣减;老仓不受影响 - account/metrics/positions/trades 四接口支持 ?market= 过滤, 抽出 _account_summary/_build_equity_curve/_strategy_performance 按市场口径计算 - settings 接受 market_allocations(校验合计≤100%) 与 initial_capital(增减资) - 盘前计划市场排除改为按比例派生,0% 市场不再出现在候选 - 前端:分段单选 全部/A股/港股/美股 切换即刷新;新增资金配置弹窗 - 新增 tests/test_paper_trading_allocation.py 纯函数单测
1 parent d1389b6 commit bad5157

8 files changed

Lines changed: 721 additions & 195 deletions

File tree

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

Lines changed: 26 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -14,10 +14,17 @@ export interface PaperTradingAccountResponse {
1414
peak_capital: number
1515
enabled: boolean
1616
excluded_markets: string[]
17+
/** 各市场投资比例 {CN/HK/US: 0~1} */
18+
market_allocations: Record<string, number>
19+
/** 仅按单市场口径返回时存在 */
20+
market?: string
21+
allocation_ratio?: number
1722
created_at: string
1823
updated_at: string
1924
}
2025

26+
export type MarketView = 'ALL' | 'CN' | 'HK' | 'US'
27+
2128
export interface PaperTradingPositionItem {
2229
id: number
2330
stock_symbol: string
@@ -108,19 +115,25 @@ export interface PaperTradingNotifySettings {
108115
}
109116

110117
export const paperTradingApi = {
111-
getAccount: () =>
112-
fetchAPI<PaperTradingAccountResponse>('/paper-trading/account'),
118+
getAccount: (market?: string) =>
119+
fetchAPI<PaperTradingAccountResponse>(
120+
`/paper-trading/account${market && market !== 'ALL' ? `?market=${encodeURIComponent(market)}` : ''}`
121+
),
113122

114-
listPositions: (status = 'open') =>
115-
fetchAPI<PaperTradingPositionItem[]>(`/paper-trading/positions?status=${encodeURIComponent(status)}`),
123+
listPositions: (status = 'open', market?: string) =>
124+
fetchAPI<PaperTradingPositionItem[]>(
125+
`/paper-trading/positions?status=${encodeURIComponent(status)}${market && market !== 'ALL' ? `&market=${encodeURIComponent(market)}` : ''}`
126+
),
116127

117-
listTrades: (limit = 50, offset = 0) =>
128+
listTrades: (limit = 50, offset = 0, market?: string) =>
118129
fetchAPI<PaperTradingTradesResponse>(
119-
`/paper-trading/trades?limit=${encodeURIComponent(String(limit))}&offset=${encodeURIComponent(String(offset))}`
130+
`/paper-trading/trades?limit=${encodeURIComponent(String(limit))}&offset=${encodeURIComponent(String(offset))}${market && market !== 'ALL' ? `&market=${encodeURIComponent(market)}` : ''}`
120131
),
121132

122-
getMetrics: () =>
123-
fetchAPI<PaperTradingMetricsResponse>('/paper-trading/metrics'),
133+
getMetrics: (market?: string) =>
134+
fetchAPI<PaperTradingMetricsResponse>(
135+
`/paper-trading/metrics${market && market !== 'ALL' ? `?market=${encodeURIComponent(market)}` : ''}`
136+
),
124137

125138
toggleAccount: (enabled: boolean) =>
126139
fetchAPI<PaperTradingAccountResponse>('/paper-trading/account/toggle', {
@@ -138,7 +151,11 @@ export const paperTradingApi = {
138151
method: 'POST',
139152
}),
140153

141-
updateSettings: (settings: { excluded_markets?: string[] }) =>
154+
updateSettings: (settings: {
155+
excluded_markets?: string[]
156+
market_allocations?: Record<string, number>
157+
initial_capital?: number
158+
}) =>
142159
fetchAPI<PaperTradingAccountResponse>('/paper-trading/account/settings', {
143160
method: 'POST',
144161
body: JSON.stringify(settings),

frontend/src/pages/PaperTrading.tsx

Lines changed: 153 additions & 36 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
import { useEffect, useState, useCallback } from 'react'
2-
import { RefreshCw, Power, RotateCcw, X, TrendingUp, TrendingDown, Trophy, BarChart3, Wallet, Activity, Play, Bell } from 'lucide-react'
2+
import { RefreshCw, Power, RotateCcw, X, TrendingUp, TrendingDown, Trophy, BarChart3, Wallet, Activity, Play, Bell, SlidersHorizontal } from 'lucide-react'
33
import {
44
paperTradingApi,
55
type PaperTradingAccountResponse,
@@ -8,6 +8,7 @@ import {
88
type EquityCurvePoint,
99
type StrategyPerformanceItem,
1010
type NotifyChannelItem,
11+
type MarketView,
1112
} from '@panwatch/api'
1213
import { Button } from '@panwatch/base-ui/components/ui/button'
1314
import { Switch } from '@panwatch/base-ui/components/ui/switch'
@@ -115,6 +116,15 @@ export default function PaperTradingPage() {
115116
const [tradesPage, setTradesPage] = useState(0)
116117
const tradesPageSize = 20
117118

119+
// 市场视图(分段单选,切换即按该市场口径刷新统计)
120+
const [marketView, setMarketView] = useState<MarketView>('ALL')
121+
122+
// 资金配置
123+
const [configOpen, setConfigOpen] = useState(false)
124+
const [cfgTotal, setCfgTotal] = useState('')
125+
const [cfgRatios, setCfgRatios] = useState<{ CN: string; HK: string; US: string }>({ CN: '', HK: '', US: '' })
126+
const [cfgSaving, setCfgSaving] = useState(false)
127+
118128
// 通知设置
119129
const [tradesOpen, setTradesOpen] = useState(false)
120130
const [notifyOpen, setNotifyOpen] = useState(false)
@@ -130,11 +140,12 @@ export default function PaperTradingPage() {
130140
const loadData = useCallback(async () => {
131141
setLoading(true)
132142
try {
143+
const mkt = marketView === 'ALL' ? undefined : marketView
133144
const [acc, pos, tradeData, metrics] = await Promise.all([
134-
paperTradingApi.getAccount(),
135-
paperTradingApi.listPositions('open'),
136-
paperTradingApi.listTrades(tradesPageSize, tradesPage * tradesPageSize),
137-
paperTradingApi.getMetrics(),
145+
paperTradingApi.getAccount(mkt),
146+
paperTradingApi.listPositions('open', mkt),
147+
paperTradingApi.listTrades(tradesPageSize, tradesPage * tradesPageSize, mkt),
148+
paperTradingApi.getMetrics(mkt),
138149
])
139150
setAccount(acc)
140151
setPositions(pos)
@@ -147,7 +158,7 @@ export default function PaperTradingPage() {
147158
} finally {
148159
setLoading(false)
149160
}
150-
}, [tradesPage])
161+
}, [tradesPage, marketView])
151162

152163
useEffect(() => { loadData() }, [loadData])
153164

@@ -196,6 +207,52 @@ export default function PaperTradingPage() {
196207
}
197208
}
198209

210+
const handleOpenConfig = async () => {
211+
setConfigOpen(true)
212+
try {
213+
// 以"全部"口径取总资金与各市场比例
214+
const acc = await paperTradingApi.getAccount()
215+
setCfgTotal(String(Math.round(acc.initial_capital)))
216+
const a = acc.market_allocations || {}
217+
setCfgRatios({
218+
CN: String(Math.round((a.CN ?? 0) * 100)),
219+
HK: String(Math.round((a.HK ?? 0) * 100)),
220+
US: String(Math.round((a.US ?? 0) * 100)),
221+
})
222+
} catch {
223+
toast('加载配置失败', 'error')
224+
}
225+
}
226+
227+
const handleSaveConfig = async () => {
228+
const total = Number(cfgTotal)
229+
const cn = Number(cfgRatios.CN) || 0
230+
const hk = Number(cfgRatios.HK) || 0
231+
const us = Number(cfgRatios.US) || 0
232+
if (!(total > 0)) {
233+
toast('总资金需大于 0', 'error')
234+
return
235+
}
236+
if (cn + hk + us > 100) {
237+
toast('比例合计不能超过 100%', 'error')
238+
return
239+
}
240+
setCfgSaving(true)
241+
try {
242+
await paperTradingApi.updateSettings({
243+
initial_capital: total,
244+
market_allocations: { CN: cn / 100, HK: hk / 100, US: us / 100 },
245+
})
246+
toast('资金配置已保存', 'success')
247+
setConfigOpen(false)
248+
loadData()
249+
} catch {
250+
toast('保存失败', 'error')
251+
} finally {
252+
setCfgSaving(false)
253+
}
254+
}
255+
199256
const loadNotifySettings = async () => {
200257
try {
201258
const data = await paperTradingApi.getNotifySettings()
@@ -260,6 +317,7 @@ export default function PaperTradingPage() {
260317
}
261318

262319
const totalPages = Math.ceil(tradesTotal / tradesPageSize)
320+
const ratioSum = (Number(cfgRatios.CN) || 0) + (Number(cfgRatios.HK) || 0) + (Number(cfgRatios.US) || 0)
263321

264322
return (
265323
<div className="space-y-5">
@@ -308,37 +366,37 @@ export default function PaperTradingPage() {
308366
</div>
309367
</div>
310368

311-
{/* Market Filter */}
369+
{/* Market View Filter + 资金配置 */}
312370
{account && (
313-
<div className="flex items-center gap-2 text-sm">
314-
<span className="text-muted-foreground text-xs">交易市场:</span>
315-
{(['CN', 'HK', 'US'] as const).map(market => {
316-
const excluded = account.excluded_markets || []
317-
const isEnabled = !excluded.includes(market)
318-
const label = market === 'CN' ? 'A股' : market === 'HK' ? '港股' : '美股'
319-
return (
320-
<button
321-
key={market}
322-
onClick={async () => {
323-
const current = account.excluded_markets || []
324-
const next = isEnabled
325-
? [...current, market]
326-
: current.filter((m: string) => m !== market)
327-
try {
328-
const res = await paperTradingApi.updateSettings({ excluded_markets: next })
329-
setAccount(res)
330-
} catch { /* ignore */ }
331-
}}
332-
className={`px-2.5 py-1 rounded-lg text-xs font-medium transition-all ${
333-
isEnabled
334-
? 'bg-primary/10 text-primary ring-1 ring-primary/20'
335-
: 'bg-muted/50 text-muted-foreground line-through'
336-
}`}
337-
>
338-
{label}
339-
</button>
340-
)
341-
})}
371+
<div className="flex items-center justify-between gap-2">
372+
<div className="flex items-center gap-2 text-sm">
373+
<span className="text-muted-foreground text-xs">交易市场:</span>
374+
{(['ALL', 'CN', 'HK', 'US'] as const).map(m => {
375+
const label = m === 'ALL' ? '全部' : m === 'CN' ? 'A股' : m === 'HK' ? '港股' : '美股'
376+
const active = marketView === m
377+
const ratio = m !== 'ALL' ? account.market_allocations?.[m] : undefined
378+
const isOff = m !== 'ALL' && (ratio ?? 0) <= 0
379+
return (
380+
<button
381+
key={m}
382+
onClick={() => setMarketView(m)}
383+
className={`px-2.5 py-1 rounded-lg text-xs font-medium transition-all ${
384+
active
385+
? 'bg-primary text-primary-foreground'
386+
: isOff
387+
? 'bg-muted/50 text-muted-foreground'
388+
: 'bg-primary/10 text-primary ring-1 ring-primary/20'
389+
}`}
390+
>
391+
{label}{m !== 'ALL' && ratio != null ? ` ${Math.round(ratio * 100)}%` : ''}
392+
</button>
393+
)
394+
})}
395+
</div>
396+
<Button variant="outline" size="sm" className="h-8" onClick={handleOpenConfig}>
397+
<SlidersHorizontal className="w-3.5 h-3.5" />
398+
<span className="hidden sm:inline ml-1">资金配置</span>
399+
</Button>
342400
</div>
343401
)}
344402

@@ -566,6 +624,65 @@ export default function PaperTradingPage() {
566624
</DialogContent>
567625
</Dialog>
568626

627+
{/* 资金配置对话框 */}
628+
<Dialog open={configOpen} onOpenChange={setConfigOpen}>
629+
<DialogContent>
630+
<DialogHeader>
631+
<DialogTitle>资金配置</DialogTitle>
632+
<DialogDescription>设置总资金与各市场投资比例,比例为 0 则不投入该市场(已有持仓不受影响,仅停止新建仓)</DialogDescription>
633+
</DialogHeader>
634+
635+
<div className="space-y-4">
636+
<div>
637+
<div className="text-sm font-medium mb-1">总资金</div>
638+
<input
639+
type="number"
640+
value={cfgTotal}
641+
onChange={e => setCfgTotal(e.target.value)}
642+
className="w-full h-9 px-3 rounded-lg border border-border bg-background text-sm"
643+
placeholder="如 1000000"
644+
/>
645+
</div>
646+
647+
<div className="space-y-2">
648+
<div className="flex items-center justify-between text-sm font-medium">
649+
<span>各市场投资比例</span>
650+
<span className={`text-xs ${ratioSum > 100 ? 'text-destructive' : 'text-muted-foreground'}`}>
651+
合计 {ratioSum}%{ratioSum > 100 ? '(超过 100%)' : ''}
652+
</span>
653+
</div>
654+
{(['CN', 'HK', 'US'] as const).map(m => {
655+
const label = m === 'CN' ? 'A股' : m === 'HK' ? '港股' : '美股'
656+
const pct = Number(cfgRatios[m]) || 0
657+
const amount = ((Number(cfgTotal) || 0) * pct) / 100
658+
return (
659+
<div key={m} className="flex items-center gap-3">
660+
<span className="w-12 text-sm">{label}</span>
661+
<input
662+
type="number"
663+
min={0}
664+
max={100}
665+
value={cfgRatios[m]}
666+
onChange={e => setCfgRatios(prev => ({ ...prev, [m]: e.target.value }))}
667+
className="w-20 h-9 px-2 rounded-lg border border-border bg-background text-sm text-right"
668+
/>
669+
<span className="text-sm text-muted-foreground">%</span>
670+
<span className="text-xs text-muted-foreground ml-auto">{formatCurrency(amount)}</span>
671+
</div>
672+
)
673+
})}
674+
<div className="text-xs text-muted-foreground">合计可小于 100%,余下为闲置不投入的资金。</div>
675+
</div>
676+
677+
<div className="flex items-center gap-2 pt-1">
678+
<Button size="sm" onClick={handleSaveConfig} disabled={cfgSaving || ratioSum > 100}>
679+
{cfgSaving ? '保存中...' : '保存'}
680+
</Button>
681+
</div>
682+
</div>
683+
</DialogContent>
684+
</Dialog>
685+
569686
{/* 跟单通知设置对话框 */}
570687
<Dialog open={notifyOpen} onOpenChange={setNotifyOpen}>
571688
<DialogContent>

0 commit comments

Comments
 (0)