feat: 添加市场大单监听策略(Whale Monitor Strategy)

支持按市场分类/体育联赛筛选并选择市场,实时监听 Activity WebSocket 交易流,
在滑动窗口内聚合 BUY 成交额,达到阈值后自动下 FAK 单。

后端:策略 CRUD、WS 监听、滑动窗口聚合、订单执行、触发记录
前端:策略列表/创建/编辑、市场搜索选择(分类+联赛)、触发记录查看

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
WrBug
2026-05-27 04:34:31 +08:00
co-authored by Claude Opus 4.7
parent 83a415fb7e
commit b3872aa8f2
32 changed files with 3738 additions and 7 deletions
+4
View File
@@ -37,6 +37,8 @@ import BacktestList from './pages/BacktestList'
import BacktestDetail from './pages/BacktestDetail'
import CryptoTailStrategyList from './pages/CryptoTailStrategyList'
import CryptoTailMonitor from './pages/CryptoTailMonitor'
import WhaleMonitorStrategyList from './pages/WhaleMonitorStrategyList'
import WhaleMonitorMarketSelect from './pages/WhaleMonitorMarketSelect'
import { wsManager } from './services/websocket'
import type { OrderPushMessage } from './types'
import { apiService } from './services/api'
@@ -273,6 +275,8 @@ function App() {
<Route path="/copy-trading" element={<ProtectedRoute><CopyTradingList /></ProtectedRoute>} />
<Route path="/crypto-tail-strategy" element={<ProtectedRoute><CryptoTailStrategyList /></ProtectedRoute>} />
<Route path="/crypto-tail-monitor" element={<ProtectedRoute><CryptoTailMonitor /></ProtectedRoute>} />
<Route path="/whale-monitor-strategy" element={<ProtectedRoute><WhaleMonitorStrategyList /></ProtectedRoute>} />
<Route path="/whale-monitor-strategy/markets" element={<ProtectedRoute><WhaleMonitorMarketSelect /></ProtectedRoute>} />
<Route path="/copy-trading/statistics/:copyTradingId" element={<ProtectedRoute><CopyTradingStatistics /></ProtectedRoute>} />
{/* 保留旧路由以保持向后兼容 */}
<Route path="/copy-trading/orders/buy/:copyTradingId" element={<ProtectedRoute><CopyTradingBuyOrders /></ProtectedRoute>} />
+22 -3
View File
@@ -23,7 +23,8 @@ import {
NotificationOutlined,
LineChartOutlined,
RocketOutlined,
DashboardOutlined
DashboardOutlined,
EyeOutlined
} from '@ant-design/icons'
import type { MenuProps } from 'antd'
import type { ReactNode } from 'react'
@@ -81,14 +82,17 @@ const Layout: React.FC<LayoutProps> = ({ children }) => {
if (path.startsWith('/crypto-tail-strategy') || path.startsWith('/crypto-tail-monitor')) {
keys.push('/crypto-tail-management')
}
if (path.startsWith('/whale-monitor-strategy')) {
keys.push('/whale-monitor-management')
}
if (path.startsWith('/system-settings')) {
keys.push('/system-settings')
}
return keys
}
const [openKeys, setOpenKeys] = useState<string[]>(getInitialOpenKeys())
// 当路径变化时,自动打开对应的父菜单
useEffect(() => {
const path = location.pathname
@@ -99,6 +103,9 @@ const Layout: React.FC<LayoutProps> = ({ children }) => {
if (path.startsWith('/crypto-tail-strategy') || path.startsWith('/crypto-tail-monitor')) {
keys.push('/crypto-tail-management')
}
if (path.startsWith('/whale-monitor-strategy')) {
keys.push('/whale-monitor-management')
}
if (path.startsWith('/system-settings')) {
keys.push('/system-settings')
}
@@ -183,6 +190,18 @@ const Layout: React.FC<LayoutProps> = ({ children }) => {
}
]
},
{
key: '/whale-monitor-management',
icon: <EyeOutlined />,
label: t('menu.whaleMonitorStrategy'),
children: [
{
key: '/whale-monitor-strategy',
icon: <DashboardOutlined />,
label: t('menu.whaleMonitorStrategyConfig')
}
]
},
{
key: '/positions',
icon: <UnorderedListOutlined />,
@@ -0,0 +1,169 @@
import { useMemo } from 'react'
import { Checkbox, Collapse, Divider, Typography } from 'antd'
import { useTranslation } from 'react-i18next'
import { groupMarketsBySection, type WhaleMonitorMarketGroup } from '../constants/whaleMonitor'
import type { WhaleMonitorMarketItem } from '../types'
import WhaleMonitorMarketListItem from './WhaleMonitorMarketListItem'
import WhaleMonitorMarketThumbnail from './WhaleMonitorMarketThumbnail'
const { Text, Title } = Typography
interface WhaleMonitorMarketGroupedListProps {
markets: WhaleMonitorMarketItem[]
selectedMap: Map<string, WhaleMonitorMarketItem>
isMobile: boolean
onToggleMarket: (market: WhaleMonitorMarketItem, checked: boolean) => void
onToggleGroup: (markets: WhaleMonitorMarketItem[], checked: boolean) => void
}
const renderMarketGroups = (
groups: WhaleMonitorMarketGroup[],
selectedMap: Map<string, WhaleMonitorMarketItem>,
isMobile: boolean,
onToggleMarket: (market: WhaleMonitorMarketItem, checked: boolean) => void,
onToggleGroup: (markets: WhaleMonitorMarketItem[], checked: boolean) => void,
t: (key: string, options?: Record<string, number>) => string
) => {
const showAsGroups = groups.length > 1 || (groups.length === 1 && groups[0].key.startsWith('event:'))
if (!showAsGroups) {
const flatMarkets = groups.flatMap(g => g.markets)
return (
<div style={{ display: 'flex', flexDirection: 'column', gap: isMobile ? 4 : 8 }}>
{flatMarkets.map(market => (
<WhaleMonitorMarketListItem
key={market.conditionId}
market={market}
checked={selectedMap.has(market.conditionId)}
isMobile={isMobile}
hideEventTitle
onToggle={checked => onToggleMarket(market, checked)}
/>
))}
</div>
)
}
const collapseItems = groups.map(group => {
const selectedInGroup = group.markets.filter(m => selectedMap.has(m.conditionId)).length
const allSelected = selectedInGroup === group.markets.length && group.markets.length > 0
const indeterminate = selectedInGroup > 0 && !allSelected
return {
key: group.key,
label: (
<div
style={{
display: 'flex',
alignItems: 'center',
justifyContent: 'space-between',
gap: 8,
width: '100%',
paddingRight: 8
}}
>
<div style={{ display: 'flex', alignItems: 'center', gap: 10, flex: 1, minWidth: 0 }}>
<WhaleMonitorMarketThumbnail src={group.imageUrl} size={32} alt={group.title} />
<div style={{ flex: 1, minWidth: 0 }}>
<Text strong style={{ wordBreak: 'break-word' }}>
{group.title}
</Text>
<Text type="secondary" style={{ fontSize: 12, marginLeft: 0, display: 'block' }}>
{t('whaleMonitorStrategy.marketSelect.marketsInGroup', { count: group.markets.length })}
</Text>
</div>
</div>
<div onClick={e => e.stopPropagation()} onKeyDown={e => e.stopPropagation()}>
<Checkbox
checked={allSelected}
indeterminate={indeterminate}
onChange={e => onToggleGroup(group.markets, e.target.checked)}
>
<span style={{ fontSize: 12 }}>{t('whaleMonitorStrategy.marketSelect.selectGroup')}</span>
</Checkbox>
</div>
</div>
),
children: (
<div style={{ display: 'flex', flexDirection: 'column', gap: isMobile ? 4 : 6 }}>
{group.markets.map(market => (
<WhaleMonitorMarketListItem
key={market.conditionId}
market={market}
checked={selectedMap.has(market.conditionId)}
isMobile={isMobile}
hideEventTitle
compact
onToggle={checked => onToggleMarket(market, checked)}
/>
))}
</div>
)
}
})
return (
<Collapse
bordered={false}
defaultActiveKey={groups.map(g => g.key)}
items={collapseItems}
style={{ background: 'transparent' }}
/>
)
}
const WhaleMonitorMarketGroupedList: React.FC<WhaleMonitorMarketGroupedListProps> = ({
markets,
selectedMap,
isMobile,
onToggleMarket,
onToggleGroup
}) => {
const { t } = useTranslation()
const sections = useMemo(
() =>
groupMarketsBySection(markets, {
game: t('whaleMonitorStrategy.marketSelect.sectionGame'),
season: t('whaleMonitorStrategy.marketSelect.sectionSeason'),
ungrouped: t('whaleMonitorStrategy.marketSelect.ungrouped')
}),
[markets, t]
)
if (sections.length === 1) {
const section = sections[0]
const showSectionTitle =
markets.some(m => m.marketType === 'game' || m.marketType === 'season') ||
section.groups.some(g => g.key.startsWith('event:'))
return (
<>
{showSectionTitle && (
<Title level={5} style={{ marginTop: 0, marginBottom: 12 }}>
{section.title}
</Title>
)}
{renderMarketGroups(section.groups, selectedMap, isMobile, onToggleMarket, onToggleGroup, t)}
</>
)
}
return (
<div>
{sections.map((section, index) => (
<div key={section.key}>
{index > 0 && <Divider style={{ margin: '16px 0' }} />}
<Title level={5} style={{ marginTop: index === 0 ? 0 : undefined, marginBottom: 12 }}>
{section.title}
<Text type="secondary" style={{ fontSize: 13, fontWeight: 400, marginLeft: 8 }}>
({section.groups.reduce((sum, g) => sum + g.markets.length, 0)})
</Text>
</Title>
{renderMarketGroups(section.groups, selectedMap, isMobile, onToggleMarket, onToggleGroup, t)}
</div>
))}
</div>
)
}
export default WhaleMonitorMarketGroupedList
@@ -0,0 +1,140 @@
import { Checkbox, Tag, Typography } from 'antd'
import { LinkOutlined } from '@ant-design/icons'
import { useTranslation } from 'react-i18next'
import { parseMarketOutcomes, pickMarketImageUrl } from '../constants/whaleMonitor'
import type { WhaleMonitorMarketItem } from '../types'
import { formatUSDC } from '../utils'
import WhaleMonitorMarketThumbnail from './WhaleMonitorMarketThumbnail'
const { Text } = Typography
interface WhaleMonitorMarketListItemProps {
market: WhaleMonitorMarketItem
checked: boolean
isMobile: boolean
hideEventTitle?: boolean
compact?: boolean
onToggle: (checked: boolean) => void
}
const formatConditionId = (id: string): string =>
id.length > 20 ? `${id.slice(0, 10)}...${id.slice(-6)}` : id
const WhaleMonitorMarketListItem: React.FC<WhaleMonitorMarketListItemProps> = ({
market,
checked,
isMobile,
hideEventTitle = false,
compact = false,
onToggle
}) => {
const { t } = useTranslation()
const outcomeLabels = parseMarketOutcomes(market.outcomes)
const volumeDisplay =
market.volume && parseFloat(market.volume) > 0 ? formatUSDC(market.volume) : null
const polymarketUrl = market.slug ? `https://polymarket.com/event/${market.slug}` : null
const imageUrl = pickMarketImageUrl(market)
const thumbSize = compact ? 36 : isMobile ? 40 : 44
const handleOpenLink = (e: React.MouseEvent) => {
e.stopPropagation()
if (polymarketUrl) {
window.open(polymarketUrl, '_blank', 'noopener,noreferrer')
}
}
return (
<div
role="button"
tabIndex={0}
onClick={() => onToggle(!checked)}
onKeyDown={e => {
if (e.key === 'Enter' || e.key === ' ') {
e.preventDefault()
onToggle(!checked)
}
}}
style={{
display: 'flex',
alignItems: 'flex-start',
gap: 12,
padding: compact ? (isMobile ? '8px 8px' : '8px 10px') : isMobile ? '12px 8px' : '10px 12px',
borderRadius: 8,
cursor: 'pointer',
minHeight: 44,
background: checked ? 'rgba(22, 119, 255, 0.06)' : undefined,
border: checked ? '1px solid rgba(22, 119, 255, 0.3)' : '1px solid transparent'
}}
>
<Checkbox
checked={checked}
onClick={e => e.stopPropagation()}
onChange={e => onToggle(e.target.checked)}
style={{ marginTop: 2 }}
/>
<WhaleMonitorMarketThumbnail src={imageUrl} size={thumbSize} />
<div style={{ flex: 1, minWidth: 0 }}>
{market.eventTitle && !hideEventTitle && (
<Text type="secondary" style={{ fontSize: 12, display: 'block', marginBottom: 2 }}>
{market.eventTitle}
</Text>
)}
<div style={{ fontWeight: 500, wordBreak: 'break-word', lineHeight: 1.4 }}>{market.title}</div>
<div
style={{
display: 'flex',
flexWrap: 'wrap',
alignItems: 'center',
gap: 6,
marginTop: 6
}}
>
{market.category && (
<Tag style={{ margin: 0 }}>{market.category}</Tag>
)}
{volumeDisplay && (
<Text type="secondary" style={{ fontSize: 12 }}>
{t('whaleMonitorStrategy.marketSelect.volume')}: ${volumeDisplay}
</Text>
)}
</div>
{outcomeLabels.length > 0 && (
<div style={{ display: 'flex', flexWrap: 'wrap', gap: 4, marginTop: 6 }}>
{outcomeLabels.map(label => (
<Tag key={label} bordered={false} style={{ margin: 0, fontSize: 11 }}>
{label}
</Tag>
))}
</div>
)}
<div
style={{
display: 'flex',
flexWrap: 'wrap',
alignItems: 'center',
gap: 8,
marginTop: 6
}}
>
<Text type="secondary" style={{ fontSize: 11 }}>
{formatConditionId(market.conditionId)}
</Text>
{polymarketUrl && (
<a
href={polymarketUrl}
target="_blank"
rel="noopener noreferrer"
onClick={handleOpenLink}
style={{ fontSize: 12, display: 'inline-flex', alignItems: 'center', gap: 4 }}
>
<LinkOutlined />
{t('whaleMonitorStrategy.marketSelect.viewMarket')}
</a>
)}
</div>
</div>
</div>
)
}
export default WhaleMonitorMarketListItem
@@ -0,0 +1,36 @@
interface WhaleMonitorMarketThumbnailProps {
src?: string
size?: number
alt?: string
}
const WhaleMonitorMarketThumbnail: React.FC<WhaleMonitorMarketThumbnailProps> = ({
src,
size = 40,
alt = ''
}) => {
if (!src?.trim()) return null
return (
<img
src={src}
alt={alt}
loading="lazy"
decoding="async"
style={{
width: size,
height: size,
borderRadius: 6,
objectFit: 'cover',
flexShrink: 0,
background: '#f5f5f5'
}}
onError={e => {
const target = e.currentTarget
target.style.display = 'none'
}}
/>
)
}
export default WhaleMonitorMarketThumbnail
+208
View File
@@ -0,0 +1,208 @@
import type { WhaleMonitorFormDraft, WhaleMonitorMarketItem } from '../types'
export const WHALE_MONITOR_FORM_DRAFT_KEY = 'whale_monitor_form_draft'
export function saveWhaleMonitorFormDraft(draft: WhaleMonitorFormDraft): void {
sessionStorage.setItem(WHALE_MONITOR_FORM_DRAFT_KEY, JSON.stringify(draft))
}
export function loadWhaleMonitorFormDraft(): WhaleMonitorFormDraft | null {
const raw = sessionStorage.getItem(WHALE_MONITOR_FORM_DRAFT_KEY)
if (!raw) return null
try {
return JSON.parse(raw) as WhaleMonitorFormDraft
} catch {
return null
}
}
export function clearWhaleMonitorFormDraft(): void {
sessionStorage.removeItem(WHALE_MONITOR_FORM_DRAFT_KEY)
}
export function conditionIdsToMarkets(
conditionIds: string[],
known: WhaleMonitorMarketItem[] = []
): WhaleMonitorMarketItem[] {
return conditionIds.map(id => {
const found = known.find(m => m.conditionId === id)
return found ?? { conditionId: id, title: id }
})
}
/** 解析 Gamma 返回的 outcomes JSON 字符串 */
export function parseMarketOutcomes(outcomes?: string): string[] {
if (!outcomes?.trim()) return []
try {
const parsed = JSON.parse(outcomes) as unknown
if (Array.isArray(parsed)) {
return parsed.filter((item): item is string => typeof item === 'string' && item.length > 0)
}
} catch {
return outcomes.split(',').map(s => s.trim()).filter(Boolean)
}
return []
}
export interface WhaleMonitorMarketGroup {
key: string
title: string
markets: WhaleMonitorMarketItem[]
imageUrl?: string
}
/** 市场行展示图:image > icon > eventImage */
export function pickMarketImageUrl(market?: WhaleMonitorMarketItem | null): string | undefined {
if (!market) return undefined
return market.image?.trim() || market.icon?.trim() || market.eventImage?.trim() || undefined
}
/** 赛事分组头图:优先 eventImage,否则取组内首个市场图 */
export function pickGroupImageUrl(markets: WhaleMonitorMarketItem[]): string | undefined {
if (markets.length === 0) return undefined
const eventImg = markets[0].eventImage?.trim()
if (eventImg) return eventImg
return pickMarketImageUrl(markets[0])
}
export interface WhaleMonitorMarketSection {
key: 'game' | 'season'
title: string
groups: WhaleMonitorMarketGroup[]
}
export interface WhaleMonitorMarketSectionLabels {
game: string
season: string
ungrouped: string
}
/** 先按单场/长期分块,再在块内按赛事或分类分组 */
export function groupMarketsBySection(
markets: WhaleMonitorMarketItem[],
labels: WhaleMonitorMarketSectionLabels
): WhaleMonitorMarketSection[] {
if (markets.length === 0) return []
const hasTyped = markets.some(m => m.marketType === 'game' || m.marketType === 'season')
if (!hasTyped) {
const groups = groupMarketsForDisplay(markets, labels.ungrouped)
if (groups.length === 0) return []
return [{ key: 'season', title: labels.ungrouped, groups }]
}
const sections: WhaleMonitorMarketSection[] = []
const games = markets.filter(m => m.marketType === 'game')
const seasons = markets.filter(m => m.marketType === 'season')
if (games.length > 0) {
sections.push({
key: 'game',
title: labels.game,
groups: groupMarketsForDisplay(games, labels.ungrouped)
})
}
if (seasons.length > 0) {
sections.push({
key: 'season',
title: labels.season,
groups: groupMarketsForDisplay(seasons, labels.ungrouped)
})
}
return sections
}
/** 按赛事/分类分组展示市场列表 */
export function groupMarketsForDisplay(
markets: WhaleMonitorMarketItem[],
ungroupedLabel: string
): WhaleMonitorMarketGroup[] {
if (markets.length === 0) return []
const eventMap = new Map<string, WhaleMonitorMarketItem[]>()
const miscMarkets: WhaleMonitorMarketItem[] = []
for (const market of markets) {
if (market.marketType === 'season') {
miscMarkets.push(market)
continue
}
const eventTitle = market.eventTitle?.trim()
if (eventTitle) {
const list = eventMap.get(eventTitle) ?? []
list.push(market)
eventMap.set(eventTitle, list)
} else {
miscMarkets.push(market)
}
}
if (eventMap.size > 0) {
const groups: WhaleMonitorMarketGroup[] = Array.from(eventMap.entries())
.map(([title, items]) => ({
key: `event:${title}`,
title,
markets: items,
imageUrl: pickGroupImageUrl(items)
}))
.sort((a, b) => a.title.localeCompare(b.title))
if (miscMarkets.length > 0) {
groups.push({
key: 'misc',
title: ungroupedLabel,
markets: miscMarkets,
imageUrl: pickMarketImageUrl(miscMarkets[0])
})
}
return groups
}
const categoryMap = new Map<string, WhaleMonitorMarketItem[]>()
for (const market of markets) {
const category = market.category?.trim() || ungroupedLabel
const list = categoryMap.get(category) ?? []
list.push(market)
categoryMap.set(category, list)
}
if (categoryMap.size <= 1) {
return [{ key: 'all', title: ungroupedLabel, markets }]
}
return Array.from(categoryMap.entries())
.map(([title, items]) => ({
key: `cat:${title}`,
title,
markets: items,
imageUrl: pickMarketImageUrl(items[0])
}))
.sort((a, b) => a.title.localeCompare(b.title))
}
export function toWhaleMonitorMarketItem(m: {
conditionId: string
title: string
slug?: string
category?: string
volume?: string
outcomes?: string
eventTitle?: string
marketType?: 'game' | 'season'
image?: string
icon?: string
eventImage?: string
}): WhaleMonitorMarketItem {
return {
conditionId: m.conditionId,
title: m.title,
slug: m.slug,
category: m.category,
volume: m.volume,
outcomes: m.outcomes,
eventTitle: m.eventTitle,
marketType: m.marketType,
image: m.image,
icon: m.icon,
eventImage: m.eventImage
}
}
+109
View File
@@ -317,6 +317,8 @@
"cryptoSpreadStrategy": "Crypto Spread Strategy",
"cryptoTailStrategy": "Strategy Config",
"cryptoTailMonitor": "Real-time Monitor",
"whaleMonitorStrategy": "Whale Monitor",
"whaleMonitorStrategyConfig": "Strategy Config",
"positions": "Position Management",
"backtest": "Backtest",
"statistics": "Statistics",
@@ -1846,6 +1848,113 @@
"periodChanged": "Period has changed, popup closed"
}
},
"whaleMonitorStrategy": {
"list": {
"title": "Whale Monitor Strategy",
"addStrategy": "New Strategy",
"strategyName": "Strategy Name",
"account": "Account",
"markets": "Markets",
"marketCount": "Markets",
"window": "Window",
"threshold": "Threshold",
"orderAmount": "Order Amount",
"priceRange": "Price Range",
"cooldown": "Cooldown",
"enabled": "Enabled",
"lastTrigger": "Last Trigger",
"triggerCount": "Triggers",
"actions": "Actions",
"edit": "Edit",
"enable": "Enable",
"disable": "Disable",
"delete": "Delete",
"viewTriggers": "Trigger Records",
"deleteConfirm": "Are you sure you want to delete this strategy?",
"fetchFailed": "Failed to fetch strategy list",
"seconds": "sec",
"conditionId": "ConditionId"
},
"form": {
"strategyName": "Strategy Name",
"strategyNamePlaceholder": "Optional, auto-generated if empty",
"selectAccount": "Select Account",
"conditionIds": "Monitored Markets",
"conditionIdsPlaceholder": "Search market name or enter conditionId",
"selectMarket": "Select a market",
"sportLeague": "Select League",
"selectSportFirst": "Select a league first",
"tagFilter": "Category",
"tagSports": "Sports",
"tagCrypto": "Crypto",
"tagPolitics": "Politics",
"tagPopCulture": "Pop Culture",
"windowSeconds": "Aggregation Window (sec)",
"thresholdAmount": "Threshold Amount",
"orderAmount": "Fixed Order Amount",
"minPrice": "Min Price",
"maxPrice": "Max Price",
"priceTip": "Range 0~1, max 2 decimal places",
"cooldownSeconds": "Cooldown (sec)",
"enabled": "Enable Strategy",
"create": "Create",
"update": "Update",
"selectMarkets": "Select Markets",
"marketsSelected": "{{count}} market(s) selected"
},
"marketSelect": {
"title": "Select Markets",
"subtitle": "Filter by category or search by name. Multiple selection supported.",
"back": "Back",
"searchPlaceholder": "Search market name (min. 2 characters)",
"searchHint": "Enter keywords or pick a category above",
"sportsGamesHint": "Sports leagues show individual game markets; search by team name",
"sportsGamesEmpty": "No active game markets for this league. Try searching a team name or check back later.",
"empty": "No markets found",
"selectedTitle": "Selected ({{count}})",
"selectedCount": "{{count}} selected",
"confirm": "Confirm",
"confirmWithCount": "Confirm ({{count}})",
"volume": "Volume",
"viewMarket": "View market",
"sectionGame": "Individual games",
"sectionSeason": "Long-term markets",
"ungrouped": "Other markets",
"selectGroup": "Select all",
"marketsInGroup": "{{count}} market(s)",
"tag": {
"all": "All",
"sports": "Sports",
"politics": "Politics",
"crypto": "Crypto",
"popCulture": "Pop Culture"
}
},
"triggerRecords": {
"title": "Trigger Records",
"timeRange": "Time Range",
"startDate": "Start Date",
"endDate": "End Date",
"successTab": "Success",
"failTab": "Failed",
"triggerTime": "Trigger Time",
"conditionId": "Market",
"tokenId": "Token ID",
"side": "Side",
"triggerVolume": "Trigger Volume",
"orderPrice": "Order Price",
"orderSize": "Order Size",
"orderAmount": "Order Amount",
"orderId": "Order ID",
"status": "Status",
"success": "Success",
"fail": "Failed",
"failReason": "Fail Reason",
"emptySuccess": "No successful records",
"emptyFail": "No failed records",
"totalCount": "{{count}} records in total"
}
},
"clobMigration": {
"title": "CLOB 2.0 Migration Notice",
"description": "Polymarket CLOB has been upgraded to V2. Please go to the Accounts page to complete the USDC migration to ensure trading functions properly.",
+109
View File
@@ -317,6 +317,8 @@
"cryptoSpreadStrategy": "加密价差策略",
"cryptoTailStrategy": "策略配置",
"cryptoTailMonitor": "实时监控",
"whaleMonitorStrategy": "大单监听",
"whaleMonitorStrategyConfig": "策略配置",
"positions": "仓位管理",
"backtest": "回测",
"statistics": "统计信息",
@@ -1846,6 +1848,113 @@
"periodChanged": "周期已切换,弹窗已关闭"
}
},
"whaleMonitorStrategy": {
"list": {
"title": "大单监听策略",
"addStrategy": "新建策略",
"strategyName": "策略名称",
"account": "绑定账户",
"markets": "监听市场",
"marketCount": "市场数",
"window": "聚合窗口",
"threshold": "触发阈值",
"orderAmount": "下单金额",
"priceRange": "价格区间",
"cooldown": "冷却时间",
"enabled": "启用",
"lastTrigger": "最近触发",
"triggerCount": "触发次数",
"actions": "操作",
"edit": "编辑",
"enable": "启用",
"disable": "停用",
"delete": "删除",
"viewTriggers": "触发记录",
"deleteConfirm": "确定要删除该策略吗?",
"fetchFailed": "获取策略列表失败",
"seconds": "秒",
"conditionId": "ConditionId"
},
"form": {
"strategyName": "策略名称",
"strategyNamePlaceholder": "可选,留空自动生成",
"selectAccount": "选择账户",
"conditionIds": "监听市场",
"conditionIdsPlaceholder": "搜索市场名称或输入 conditionId",
"selectMarket": "请选择市场",
"sportLeague": "选择联赛",
"selectSportFirst": "请先选择联赛",
"tagFilter": "市场分类",
"tagSports": "体育",
"tagCrypto": "加密货币",
"tagPolitics": "政治",
"tagPopCulture": "流行文化",
"windowSeconds": "聚合窗口(秒)",
"thresholdAmount": "触发阈值金额",
"orderAmount": "固定下单金额",
"minPrice": "最低价格",
"maxPrice": "最高价格",
"priceTip": "范围 0~1,最多两位小数",
"cooldownSeconds": "冷却时间(秒)",
"enabled": "启用策略",
"create": "创建",
"update": "更新",
"selectMarkets": "选择监听市场",
"marketsSelected": "已选 {{count}} 个市场"
},
"marketSelect": {
"title": "选择市场",
"subtitle": "按分类筛选或搜索市场名称,可多选",
"back": "返回",
"searchPlaceholder": "搜索市场名称(至少 2 个字符)",
"searchHint": "请输入关键词搜索,或选择上方分类筛选",
"sportsGamesHint": "体育联赛将展示单场比赛盘口;也可输入队名搜索",
"sportsGamesEmpty": "该联赛当前暂无进行中的单场比赛盘口,可尝试搜索队名或稍后再试",
"empty": "未找到匹配的市场",
"selectedTitle": "已选市场({{count}}",
"selectedCount": "已选 {{count}} 个",
"confirm": "确认选择",
"confirmWithCount": "确认({{count}}",
"volume": "成交量",
"viewMarket": "查看市场",
"sectionGame": "单场比赛",
"sectionSeason": "长期市场",
"ungrouped": "其他市场",
"selectGroup": "全选",
"marketsInGroup": "{{count}} 个盘口",
"tag": {
"all": "全部",
"sports": "体育",
"politics": "政治",
"crypto": "加密货币",
"popCulture": "流行文化"
}
},
"triggerRecords": {
"title": "触发记录",
"timeRange": "时间范围",
"startDate": "开始日期",
"endDate": "结束日期",
"successTab": "成功",
"failTab": "失败",
"triggerTime": "触发时间",
"conditionId": "市场",
"tokenId": "Token ID",
"side": "方向",
"triggerVolume": "触发金额",
"orderPrice": "下单价格",
"orderSize": "下单数量",
"orderAmount": "下单金额",
"orderId": "订单ID",
"status": "状态",
"success": "成功",
"fail": "失败",
"failReason": "失败原因",
"emptySuccess": "暂无成功记录",
"emptyFail": "暂无失败记录",
"totalCount": "共 {{count}} 条记录"
}
},
"clobMigration": {
"title": "CLOB 2.0 迁移提醒",
"description": "Polymarket CLOB 已升级至 V2 版本,您需要前往账户页完成 USDC 迁移操作,以确保交易功能正常使用。",
+109
View File
@@ -317,6 +317,8 @@
"cryptoSpreadStrategy": "加密價差策略",
"cryptoTailStrategy": "策略配置",
"cryptoTailMonitor": "即時監控",
"whaleMonitorStrategy": "大單監聽",
"whaleMonitorStrategyConfig": "策略配置",
"positions": "倉位管理",
"backtest": "回測",
"statistics": "統計信息",
@@ -1846,6 +1848,113 @@
"periodChanged": "週期已切換,彈窗已關閉"
}
},
"whaleMonitorStrategy": {
"list": {
"title": "大單監聽策略",
"addStrategy": "新建策略",
"strategyName": "策略名稱",
"account": "綁定賬戶",
"markets": "監聽市場",
"marketCount": "市場數",
"window": "聚合窗口",
"threshold": "觸發閾值",
"orderAmount": "下單金額",
"priceRange": "價格區間",
"cooldown": "冷卻時間",
"enabled": "啟用",
"lastTrigger": "最近觸發",
"triggerCount": "觸發次數",
"actions": "操作",
"edit": "編輯",
"enable": "啟用",
"disable": "停用",
"delete": "刪除",
"viewTriggers": "觸發記錄",
"deleteConfirm": "確定要刪除該策略嗎?",
"fetchFailed": "獲取策略列表失敗",
"seconds": "秒",
"conditionId": "ConditionId"
},
"form": {
"strategyName": "策略名稱",
"strategyNamePlaceholder": "可選,留空自動生成",
"selectAccount": "選擇賬戶",
"conditionIds": "監聯市場",
"conditionIdsPlaceholder": "搜索市場名稱或輸入 conditionId",
"selectMarket": "請選擇市場",
"sportLeague": "選擇聯賽",
"selectSportFirst": "請先選擇聯賽",
"tagFilter": "市場分類",
"tagSports": "體育",
"tagCrypto": "加密貨幣",
"tagPolitics": "政治",
"tagPopCulture": "流行文化",
"windowSeconds": "聚合窗口(秒)",
"thresholdAmount": "觸發閾值金額",
"orderAmount": "固定下單金額",
"minPrice": "最低價格",
"maxPrice": "最高價格",
"priceTip": "範圍 0~1,最多兩位小數",
"cooldownSeconds": "冷卻時間(秒)",
"enabled": "啟用策略",
"create": "創建",
"update": "更新",
"selectMarkets": "選擇監聽市場",
"marketsSelected": "已選 {{count}} 個市場"
},
"marketSelect": {
"title": "選擇市場",
"subtitle": "按分類篩選或搜索市場名稱,可多選",
"back": "返回",
"searchPlaceholder": "搜索市場名稱(至少 2 個字符)",
"searchHint": "請輸入關鍵詞搜索,或選擇上方分類篩選",
"sportsGamesHint": "體育聯賽將展示單場比賽盤口;也可輸入隊名搜索",
"sportsGamesEmpty": "該聯賽當前暫無進行中的單場比賽盤口,可嘗試搜索隊名或稍後再試",
"empty": "未找到匹配的市場",
"selectedTitle": "已選市場({{count}}",
"selectedCount": "已選 {{count}} 個",
"confirm": "確認選擇",
"confirmWithCount": "確認({{count}}",
"volume": "成交量",
"viewMarket": "查看市場",
"sectionGame": "單場比賽",
"sectionSeason": "長期市場",
"ungrouped": "其他市場",
"selectGroup": "全選",
"marketsInGroup": "{{count}} 個盤口",
"tag": {
"all": "全部",
"sports": "體育",
"politics": "政治",
"crypto": "加密貨幣",
"popCulture": "流行文化"
}
},
"triggerRecords": {
"title": "觸發記錄",
"timeRange": "時間範圍",
"startDate": "開始日期",
"endDate": "結束日期",
"successTab": "成功",
"failTab": "失敗",
"triggerTime": "觸發時間",
"conditionId": "市場",
"tokenId": "Token ID",
"side": "方向",
"triggerVolume": "觸發金額",
"orderPrice": "下單價格",
"orderSize": "下單數量",
"orderAmount": "下單金額",
"orderId": "訂單ID",
"status": "狀態",
"success": "成功",
"fail": "失敗",
"failReason": "失敗原因",
"emptySuccess": "暫無成功記錄",
"emptyFail": "暫無失敗記錄",
"totalCount": "共 {{count}} 條記錄"
}
},
"clobMigration": {
"title": "CLOB 2.0 遷移提醒",
"description": "Polymarket CLOB 已升級至 V2 版本,您需要前往帳戶頁完成 USDC 遷移操作,以確保交易功能正常使用。",
@@ -0,0 +1,330 @@
import { useCallback, useEffect, useMemo, useState } from 'react'
import { useNavigate, useLocation } from 'react-router-dom'
import {
Button,
Card,
Empty,
Input,
Select,
Segmented,
Spin,
Tag,
Typography
} from 'antd'
import { ArrowLeftOutlined, SearchOutlined } from '@ant-design/icons'
import { useTranslation } from 'react-i18next'
import { useMediaQuery } from 'react-responsive'
import { apiService } from '../services/api'
import WhaleMonitorMarketGroupedList from '../components/WhaleMonitorMarketGroupedList'
import WhaleMonitorMarketThumbnail from '../components/WhaleMonitorMarketThumbnail'
import { toWhaleMonitorMarketItem } from '../constants/whaleMonitor'
import type { WhaleMonitorMarketItem, WhaleMonitorMarketSelectLocationState } from '../types'
const { Title, Text } = Typography
const TAG_OPTIONS = [
{ key: 'all', value: '' },
{ key: 'sports', value: '1' },
{ key: 'politics', value: '2' },
{ key: 'crypto', value: '21' },
{ key: 'popCulture', value: '100639' }
] as const
const WhaleMonitorMarketSelect: React.FC = () => {
const { t } = useTranslation()
const navigate = useNavigate()
const location = useLocation()
const isMobile = useMediaQuery({ maxWidth: 768 })
const locationState = (location.state as WhaleMonitorMarketSelectLocationState | null) ?? null
const initialSelected = locationState?.selectedMarkets ?? []
const [keyword, setKeyword] = useState('')
const [debouncedKeyword, setDebouncedKeyword] = useState('')
const [tagId, setTagId] = useState<string>('')
const [sportSubSeriesId, setSportSubSeriesId] = useState<string | undefined>(undefined)
const [sportSubCategories, setSportSubCategories] = useState<
Array<{ id: number; slug: string; label: string; tagId: string; seriesId: string; image?: string }>
>([])
const [marketList, setMarketList] = useState<WhaleMonitorMarketItem[]>([])
const [loading, setLoading] = useState(false)
const [selectedMap, setSelectedMap] = useState<Map<string, WhaleMonitorMarketItem>>(() => {
const map = new Map<string, WhaleMonitorMarketItem>()
initialSelected.forEach(m => map.set(m.conditionId, m))
return map
})
const tagSegmentOptions = useMemo(
() =>
TAG_OPTIONS.map(opt => ({
label: t(`whaleMonitorStrategy.marketSelect.tag.${opt.key}`),
value: opt.value
})),
[t]
)
useEffect(() => {
const timer = setTimeout(() => setDebouncedKeyword(keyword.trim()), 400)
return () => clearTimeout(timer)
}, [keyword])
useEffect(() => {
if (tagId === '1') {
fetchSportSubCategories()
setSportSubSeriesId(undefined)
} else {
setSportSubCategories([])
setSportSubSeriesId(undefined)
}
}, [tagId])
const fetchSportSubCategories = async () => {
try {
const res = await apiService.markets.sportsCategories()
if (res.data.code === 0 && res.data.data) {
setSportSubCategories(
res.data.data.filter((s): s is typeof s & { seriesId: string } => Boolean(s.seriesId))
)
} else {
setSportSubCategories([])
}
} catch {
setSportSubCategories([])
}
}
const fetchMarkets = useCallback(async () => {
const selectedSport = sportSubCategories.find(s => s.seriesId === sportSubSeriesId)
const seriesId = sportSubSeriesId
const sportSlug = selectedSport?.slug
const effectiveTagId = tagId && tagId !== '1' ? tagId : undefined
const searchKeyword = debouncedKeyword
if (tagId === '1' && !sportSubSeriesId && searchKeyword.length < 2) {
setMarketList([])
return
}
if (!seriesId && !effectiveTagId && searchKeyword.length < 2) {
setMarketList([])
return
}
setLoading(true)
try {
const res = await apiService.markets.search({
keyword: searchKeyword.length >= 2 ? searchKeyword : '',
seriesId: seriesId || undefined,
sportSlug: seriesId ? sportSlug : undefined,
tagId: seriesId ? undefined : effectiveTagId,
limit: 200
})
if (res.data.code === 0 && res.data.data) {
setMarketList(res.data.data.map(m => toWhaleMonitorMarketItem(m)))
} else {
setMarketList([])
}
} catch {
setMarketList([])
} finally {
setLoading(false)
}
}, [debouncedKeyword, tagId, sportSubSeriesId, sportSubCategories])
useEffect(() => {
fetchMarkets()
}, [fetchMarkets])
const selectedList = useMemo(() => Array.from(selectedMap.values()), [selectedMap])
const toggleMarket = (market: WhaleMonitorMarketItem, checked: boolean) => {
setSelectedMap(prev => {
const next = new Map(prev)
if (checked) {
next.set(market.conditionId, market)
} else {
next.delete(market.conditionId)
}
return next
})
}
const toggleGroupMarkets = (markets: WhaleMonitorMarketItem[], checked: boolean) => {
setSelectedMap(prev => {
const next = new Map(prev)
for (const market of markets) {
if (checked) {
next.set(market.conditionId, market)
} else {
next.delete(market.conditionId)
}
}
return next
})
}
const handleConfirm = () => {
navigate('/whale-monitor-strategy', {
state: { selectedMarkets: selectedList }
})
}
const handleBack = () => {
navigate('/whale-monitor-strategy')
}
const showSelectSportHint = tagId === '1' && !sportSubSeriesId && debouncedKeyword.length < 2
const showSportsGamesEmpty =
tagId === '1' && !!sportSubSeriesId && marketList.length === 0 && !loading && debouncedKeyword.length < 2
const showSearchHint = !tagId && debouncedKeyword.length < 2
return (
<div style={{ padding: isMobile ? 12 : 24, paddingBottom: isMobile ? 88 : 24 }}>
<div style={{ marginBottom: 16 }}>
<Button
icon={<ArrowLeftOutlined />}
onClick={handleBack}
style={{ marginBottom: 12, minHeight: 44 }}
>
{t('whaleMonitorStrategy.marketSelect.back')}
</Button>
<Title level={isMobile ? 4 : 3} style={{ margin: 0 }}>
{t('whaleMonitorStrategy.marketSelect.title')}
</Title>
<Text type="secondary" style={{ display: 'block', marginTop: 4 }}>
{t('whaleMonitorStrategy.marketSelect.subtitle')}
</Text>
</div>
<Card size="small" style={{ marginBottom: 12 }}>
<Input
allowClear
prefix={<SearchOutlined />}
placeholder={t('whaleMonitorStrategy.marketSelect.searchPlaceholder')}
value={keyword}
onChange={e => setKeyword(e.target.value)}
size={isMobile ? 'large' : 'middle'}
style={{ marginBottom: 12 }}
/>
<Segmented
block={isMobile}
options={tagSegmentOptions}
value={tagId}
onChange={val => setTagId(val as string)}
style={{ marginBottom: tagId === '1' ? 12 : 0 }}
/>
{tagId === '1' && sportSubCategories.length > 0 && (
<>
<Select
allowClear
showSearch
placeholder={t('whaleMonitorStrategy.form.sportLeague')}
style={{ width: '100%', marginTop: 12 }}
value={sportSubSeriesId}
onChange={setSportSubSeriesId}
optionFilterProp="label"
size={isMobile ? 'large' : 'middle'}
options={sportSubCategories.map(s => ({
label: s.label,
value: s.seriesId,
image: s.image
}))}
optionRender={option => (
<div style={{ display: 'flex', alignItems: 'center', gap: 10 }}>
<WhaleMonitorMarketThumbnail src={option.data.image as string | undefined} size={28} />
<span>{option.label}</span>
</div>
)}
labelRender={props => {
const sport = sportSubCategories.find(s => s.seriesId === props.value)
if (!sport) return props.label
return (
<div style={{ display: 'flex', alignItems: 'center', gap: 8 }}>
<WhaleMonitorMarketThumbnail src={sport.image} size={22} />
<span>{sport.label}</span>
</div>
)
}}
/>
{sportSubSeriesId && (
<Text type="secondary" style={{ fontSize: 12, display: 'block', marginTop: 8 }}>
{t('whaleMonitorStrategy.marketSelect.sportsGamesHint')}
</Text>
)}
</>
)}
</Card>
{selectedList.length > 0 && (
<Card size="small" title={t('whaleMonitorStrategy.marketSelect.selectedTitle', { count: selectedList.length })} style={{ marginBottom: 12 }}>
<div style={{ display: 'flex', flexWrap: 'wrap', gap: 6 }}>
{selectedList.map(m => (
<Tag
key={m.conditionId}
closable
onClose={() => toggleMarket(m, false)}
style={{ margin: 0, maxWidth: '100%' }}
>
<span style={{ wordBreak: 'break-all' }}>{m.title}</span>
</Tag>
))}
</div>
</Card>
)}
<Card size="small" bodyStyle={{ padding: isMobile ? 8 : 16 }}>
<Spin spinning={loading}>
{showSearchHint ? (
<Empty description={t('whaleMonitorStrategy.marketSelect.searchHint')} />
) : showSelectSportHint ? (
<Empty description={t('whaleMonitorStrategy.form.selectSportFirst')} />
) : showSportsGamesEmpty ? (
<Empty description={t('whaleMonitorStrategy.marketSelect.sportsGamesEmpty')} />
) : marketList.length === 0 && !loading ? (
<Empty description={t('whaleMonitorStrategy.marketSelect.empty')} />
) : (
<WhaleMonitorMarketGroupedList
markets={marketList}
selectedMap={selectedMap}
isMobile={isMobile}
onToggleMarket={toggleMarket}
onToggleGroup={toggleGroupMarkets}
/>
)}
</Spin>
</Card>
<div
style={{
position: isMobile ? 'fixed' : 'sticky',
bottom: 0,
left: 0,
right: 0,
padding: isMobile ? '12px 16px' : '16px 0 0',
background: isMobile ? '#fff' : 'transparent',
borderTop: isMobile ? '1px solid #f0f0f0' : undefined,
zIndex: 10,
display: 'flex',
gap: 12,
alignItems: 'center',
justifyContent: 'flex-end'
}}
>
{!isMobile && (
<Text type="secondary">
{t('whaleMonitorStrategy.marketSelect.selectedCount', { count: selectedList.length })}
</Text>
)}
<Button onClick={handleBack} style={{ minHeight: 44 }}>
{t('common.cancel')}
</Button>
<Button type="primary" onClick={handleConfirm} style={{ minHeight: 44 }}>
{isMobile
? t('whaleMonitorStrategy.marketSelect.confirmWithCount', { count: selectedList.length })
: t('whaleMonitorStrategy.marketSelect.confirm')}
</Button>
</div>
</div>
)
}
export default WhaleMonitorMarketSelect
@@ -0,0 +1,650 @@
import { useEffect, useRef, useState } from 'react'
import { Card, Table, Button, Space, Tag, Popconfirm, Switch, message, Select, Modal, Form, Input, InputNumber, Tabs, Empty, Tooltip } from 'antd'
import { PlusOutlined, EditOutlined, UnorderedListOutlined, DeleteOutlined, RightOutlined } from '@ant-design/icons'
import { useNavigate, useLocation } from 'react-router-dom'
import { useTranslation } from 'react-i18next'
import { useMediaQuery } from 'react-responsive'
import { apiService } from '../services/api'
import { useAccountStore } from '../store/accountStore'
import {
clearWhaleMonitorFormDraft,
conditionIdsToMarkets,
loadWhaleMonitorFormDraft,
saveWhaleMonitorFormDraft
} from '../constants/whaleMonitor'
import type {
WhaleMonitorStrategyDto,
WhaleMonitorTriggerDto,
WhaleMonitorMarketItem,
WhaleMonitorStrategyListLocationState,
Account
} from '../types'
import { formatUSDC } from '../utils'
const WhaleMonitorStrategyList: React.FC = () => {
const { t } = useTranslation()
const navigate = useNavigate()
const location = useLocation()
const isMobile = useMediaQuery({ maxWidth: 768 })
const { accounts, fetchAccounts } = useAccountStore()
const [strategies, setStrategies] = useState<WhaleMonitorStrategyDto[]>([])
const [loading, setLoading] = useState(false)
const [filterAccountId, setFilterAccountId] = useState<number | undefined>()
const [filterEnabled, setFilterEnabled] = useState<boolean | undefined>()
const [formVisible, setFormVisible] = useState(false)
const [editingStrategy, setEditingStrategy] = useState<WhaleMonitorStrategyDto | null>(null)
const [form] = Form.useForm()
const [submitting, setSubmitting] = useState(false)
const [selectedMarkets, setSelectedMarkets] = useState<WhaleMonitorMarketItem[]>([])
const [pendingFormDraft, setPendingFormDraft] = useState<ReturnType<typeof loadWhaleMonitorFormDraft>>(null)
const draftRestoreStarted = useRef(false)
const [triggerVisible, setTriggerVisible] = useState(false)
const [triggerStrategyId, setTriggerStrategyId] = useState<number>(0)
const [triggerRecords, setTriggerRecords] = useState<WhaleMonitorTriggerDto[]>([])
const [triggerTotal, setTriggerTotal] = useState(0)
const [triggerPage, setTriggerPage] = useState(1)
const [triggerTab, setTriggerTab] = useState<string>('success')
const [triggerLoading, setTriggerLoading] = useState(false)
useEffect(() => {
fetchAccounts()
}, [fetchAccounts])
useEffect(() => {
fetchList()
}, [filterAccountId, filterEnabled])
useEffect(() => {
if (draftRestoreStarted.current) return
const draft = loadWhaleMonitorFormDraft()
const state = location.state as WhaleMonitorStrategyListLocationState | null
if (!draft && !state?.selectedMarkets) return
draftRestoreStarted.current = true
if (draft) clearWhaleMonitorFormDraft()
if (state?.selectedMarkets) {
navigate(location.pathname, { replace: true, state: null })
}
if (draft) {
setPendingFormDraft({
...draft,
selectedMarkets: state?.selectedMarkets ?? draft.selectedMarkets
})
}
}, [location.pathname, location.state, navigate])
useEffect(() => {
if (!pendingFormDraft) return
if (pendingFormDraft.editingStrategyId && loading) return
if (pendingFormDraft.editingStrategyId) {
const strategy = strategies.find(s => s.id === pendingFormDraft.editingStrategyId)
if (!strategy) {
setPendingFormDraft(null)
return
}
setEditingStrategy(strategy)
} else {
setEditingStrategy(null)
}
setSelectedMarkets(pendingFormDraft.selectedMarkets)
form.setFieldsValue(pendingFormDraft.formValues)
setFormVisible(true)
setPendingFormDraft(null)
}, [pendingFormDraft, strategies, loading, form])
const fetchList = async () => {
setLoading(true)
try {
const params: { accountId?: number; enabled?: boolean } = {}
if (filterAccountId) params.accountId = filterAccountId
if (filterEnabled !== undefined) params.enabled = filterEnabled
const res = await apiService.whaleMonitorStrategy.list(params)
if (res.data.code === 0 && res.data.data) {
setStrategies(res.data.data.list || [])
} else {
message.error(t('whaleMonitorStrategy.list.fetchFailed'))
}
} catch (_e) {
message.error(t('whaleMonitorStrategy.list.fetchFailed'))
} finally {
setLoading(false)
}
}
const handleToggleEnabled = async (strategy: WhaleMonitorStrategyDto) => {
try {
const res = await apiService.whaleMonitorStrategy.update({
strategyId: strategy.id,
enabled: !strategy.enabled
})
if (res.data.code === 0) {
message.success(strategy.enabled ? t('whaleMonitorStrategy.list.disable') : t('whaleMonitorStrategy.list.enable'))
fetchList()
} else {
message.error(res.data.msg)
}
} catch (_e) {
message.error('Error')
}
}
const handleDelete = async (strategyId: number) => {
try {
const res = await apiService.whaleMonitorStrategy.delete({ strategyId })
if (res.data.code === 0) {
message.success('OK')
fetchList()
} else {
message.error(res.data.msg)
}
} catch (_e) {
message.error('Error')
}
}
const goToMarketSelect = () => {
saveWhaleMonitorFormDraft({
formValues: form.getFieldsValue(),
selectedMarkets,
editingStrategyId: editingStrategy?.id
})
navigate('/whale-monitor-strategy/markets', {
state: { selectedMarkets }
})
}
const showCreateModal = () => {
setEditingStrategy(null)
setSelectedMarkets([])
form.resetFields()
form.setFieldsValue({
windowSeconds: 10,
minPrice: 0,
maxPrice: 1,
cooldownSeconds: 60,
enabled: true
})
setFormVisible(true)
}
const showEditModal = (strategy: WhaleMonitorStrategyDto) => {
setEditingStrategy(strategy)
setSelectedMarkets(conditionIdsToMarkets(strategy.conditionIds))
form.setFieldsValue({
accountId: strategy.accountId,
name: strategy.name,
windowSeconds: strategy.windowSeconds,
thresholdAmount: strategy.thresholdAmount,
orderAmount: strategy.orderAmount,
minPrice: parseFloat(strategy.minPrice),
maxPrice: parseFloat(strategy.maxPrice),
cooldownSeconds: strategy.cooldownSeconds,
enabled: strategy.enabled
})
setFormVisible(true)
}
const handleSubmit = async () => {
try {
const values = await form.validateFields()
setSubmitting(true)
if (selectedMarkets.length === 0) {
message.error(t('whaleMonitorStrategy.form.conditionIds'))
setSubmitting(false)
return
}
const payload = {
...values,
conditionIds: selectedMarkets.map(m => m.conditionId),
minPrice: String(values.minPrice ?? 0),
maxPrice: String(values.maxPrice ?? 1),
thresholdAmount: String(values.thresholdAmount),
orderAmount: String(values.orderAmount)
}
if (editingStrategy) {
const res = await apiService.whaleMonitorStrategy.update({
strategyId: editingStrategy.id,
...payload
})
if (res.data.code === 0) {
message.success('OK')
setFormVisible(false)
fetchList()
} else {
message.error(res.data.msg)
}
} else {
const res = await apiService.whaleMonitorStrategy.create(payload)
if (res.data.code === 0) {
message.success('OK')
setFormVisible(false)
fetchList()
} else {
message.error(res.data.msg)
}
}
} catch (_e) {
// form validation error
} finally {
setSubmitting(false)
}
}
const fetchTriggerRecords = async (strategyId: number, page: number, status?: string) => {
setTriggerLoading(true)
try {
const params: { strategyId: number; page: number; pageSize: number; status?: string } = {
strategyId,
page,
pageSize: 20,
}
if (status) params.status = status
const res = await apiService.whaleMonitorStrategy.triggers(params)
if (res.data.code === 0 && res.data.data) {
setTriggerRecords(res.data.data.list || [])
setTriggerTotal(res.data.data.total || 0)
}
} catch (_e) {
// ignore
} finally {
setTriggerLoading(false)
}
}
const showTriggerRecords = (strategyId: number) => {
setTriggerStrategyId(strategyId)
setTriggerPage(1)
setTriggerTab('success')
setTriggerVisible(true)
fetchTriggerRecords(strategyId, 1, 'success')
}
const handleTriggerTabChange = (tab: string) => {
setTriggerTab(tab)
setTriggerPage(1)
fetchTriggerRecords(triggerStrategyId, 1, tab === 'all' ? undefined : tab)
}
const getAccountName = (accountId: number): string => {
const acc = accounts.find((a: Account) => a.id === accountId)
if (!acc) return String(accountId)
return acc.accountName || `${acc.walletAddress.slice(0, 6)}...${acc.walletAddress.slice(-4)}`
}
const formatTime = (ts: number): string => {
if (!ts) return '-'
return new Date(ts).toLocaleString()
}
const columns = [
{
title: t('whaleMonitorStrategy.list.strategyName'),
dataIndex: 'name',
key: 'name',
ellipsis: true,
render: (name: string) => name || '-'
},
{
title: t('whaleMonitorStrategy.list.account'),
dataIndex: 'accountId',
key: 'accountId',
render: (accountId: number) => getAccountName(accountId)
},
{
title: t('whaleMonitorStrategy.list.marketCount'),
dataIndex: 'conditionIds',
key: 'marketCount',
render: (ids: string[]) => (
<Tooltip title={ids.join(', ')}>
<Tag>{ids.length}</Tag>
</Tooltip>
)
},
{
title: t('whaleMonitorStrategy.list.window'),
dataIndex: 'windowSeconds',
key: 'windowSeconds',
render: (v: number) => `${v}${t('whaleMonitorStrategy.list.seconds')}`
},
{
title: t('whaleMonitorStrategy.list.threshold'),
dataIndex: 'thresholdAmount',
key: 'thresholdAmount',
render: (v: string) => `$${formatUSDC(v)}`
},
{
title: t('whaleMonitorStrategy.list.orderAmount'),
dataIndex: 'orderAmount',
key: 'orderAmount',
render: (v: string) => `$${formatUSDC(v)}`
},
{
title: t('whaleMonitorStrategy.list.priceRange'),
key: 'priceRange',
render: (_: unknown, record: WhaleMonitorStrategyDto) => `${record.minPrice} ~ ${record.maxPrice}`
},
{
title: t('whaleMonitorStrategy.list.cooldown'),
dataIndex: 'cooldownSeconds',
key: 'cooldownSeconds',
render: (v: number) => `${v}${t('whaleMonitorStrategy.list.seconds')}`
},
{
title: t('whaleMonitorStrategy.list.enabled'),
dataIndex: 'enabled',
key: 'enabled',
render: (enabled: boolean, record: WhaleMonitorStrategyDto) => (
<Switch checked={enabled} onChange={() => handleToggleEnabled(record)} />
)
},
{
title: t('whaleMonitorStrategy.list.triggerCount'),
dataIndex: 'triggerCount',
key: 'triggerCount',
render: (count: number) => count || 0
},
{
title: t('whaleMonitorStrategy.list.actions'),
key: 'actions',
render: (_: unknown, record: WhaleMonitorStrategyDto) => (
<Space size="small">
<Button type="link" size="small" icon={<EditOutlined />} onClick={() => showEditModal(record)}>
{t('whaleMonitorStrategy.list.edit')}
</Button>
<Button type="link" size="small" icon={<UnorderedListOutlined />} onClick={() => showTriggerRecords(record.id)}>
{t('whaleMonitorStrategy.list.viewTriggers')}
</Button>
<Popconfirm title={t('whaleMonitorStrategy.list.deleteConfirm')} onConfirm={() => handleDelete(record.id)}>
<Button type="link" size="small" danger icon={<DeleteOutlined />}>
{t('whaleMonitorStrategy.list.delete')}
</Button>
</Popconfirm>
</Space>
)
}
]
const triggerColumns = [
{
title: t('whaleMonitorStrategy.triggerRecords.triggerTime'),
dataIndex: 'createdAt',
key: 'createdAt',
render: (ts: number) => formatTime(ts)
},
{
title: t('whaleMonitorStrategy.triggerRecords.conditionId'),
dataIndex: 'conditionId',
key: 'conditionId',
ellipsis: true,
render: (v: string) => (
<Tooltip title={v}>
<span>{v.slice(0, 10)}...</span>
</Tooltip>
)
},
{
title: t('whaleMonitorStrategy.triggerRecords.tokenId'),
dataIndex: 'tokenId',
key: 'tokenId',
ellipsis: true,
render: (v: string) => (
<Tooltip title={v}>
<span>{v.slice(0, 10)}...</span>
</Tooltip>
)
},
{
title: t('whaleMonitorStrategy.triggerRecords.triggerVolume'),
dataIndex: 'triggerVolume',
key: 'triggerVolume',
render: (v: string) => `$${formatUSDC(v)}`
},
{
title: t('whaleMonitorStrategy.triggerRecords.orderPrice'),
dataIndex: 'orderPrice',
key: 'orderPrice',
render: (v: string) => formatUSDC(v)
},
{
title: t('whaleMonitorStrategy.triggerRecords.orderAmount'),
dataIndex: 'orderAmount',
key: 'orderAmount',
render: (v: string) => `$${formatUSDC(v)}`
},
{
title: t('whaleMonitorStrategy.triggerRecords.orderId'),
dataIndex: 'orderId',
key: 'orderId',
ellipsis: true,
render: (v: string) => v ? (
<Tooltip title={v}><span>{v.slice(0, 10)}...</span></Tooltip>
) : '-'
},
{
title: t('whaleMonitorStrategy.triggerRecords.failReason'),
dataIndex: 'failReason',
key: 'failReason',
ellipsis: true,
render: (v: string) => v ? <Tooltip title={v}><span>{v}</span></Tooltip> : '-'
}
]
const renderMobileCard = (strategy: WhaleMonitorStrategyDto) => (
<Card key={strategy.id} size="small" style={{ marginBottom: 12 }}>
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: 8 }}>
<strong>{strategy.name || '-'}</strong>
<Switch checked={strategy.enabled} onChange={() => handleToggleEnabled(strategy)} />
</div>
<div style={{ fontSize: 12, color: '#888', marginBottom: 4 }}>
{t('whaleMonitorStrategy.list.account')}: {getAccountName(strategy.accountId)}
</div>
<div style={{ fontSize: 12, color: '#888', marginBottom: 4 }}>
{t('whaleMonitorStrategy.list.marketCount')}: <Tag>{strategy.conditionIds.length}</Tag>
</div>
<div style={{ fontSize: 12, color: '#888', marginBottom: 4 }}>
{t('whaleMonitorStrategy.list.window')}: {strategy.windowSeconds}{t('whaleMonitorStrategy.list.seconds')}
&nbsp;|&nbsp;
{t('whaleMonitorStrategy.list.threshold')}: ${formatUSDC(strategy.thresholdAmount)}
&nbsp;|&nbsp;
{t('whaleMonitorStrategy.list.orderAmount')}: ${formatUSDC(strategy.orderAmount)}
</div>
<div style={{ fontSize: 12, color: '#888', marginBottom: 8 }}>
{t('whaleMonitorStrategy.list.priceRange')}: {strategy.minPrice} ~ {strategy.maxPrice}
&nbsp;|&nbsp;
{t('whaleMonitorStrategy.list.cooldown')}: {strategy.cooldownSeconds}{t('whaleMonitorStrategy.list.seconds')}
</div>
<Space>
<Button size="small" icon={<EditOutlined />} onClick={() => showEditModal(strategy)}>
{t('whaleMonitorStrategy.list.edit')}
</Button>
<Button size="small" icon={<UnorderedListOutlined />} onClick={() => showTriggerRecords(strategy.id)}>
{t('whaleMonitorStrategy.list.viewTriggers')}
</Button>
<Popconfirm title={t('whaleMonitorStrategy.list.deleteConfirm')} onConfirm={() => handleDelete(strategy.id)}>
<Button size="small" danger icon={<DeleteOutlined />}>
{t('whaleMonitorStrategy.list.delete')}
</Button>
</Popconfirm>
</Space>
</Card>
)
return (
<div style={{ padding: isMobile ? 12 : 24 }}>
<Card>
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: 16, flexWrap: 'wrap', gap: 8 }}>
<h2 style={{ margin: 0 }}>{t('whaleMonitorStrategy.list.title')}</h2>
<Space wrap>
<Select
allowClear
placeholder={t('whaleMonitorStrategy.list.account')}
style={{ width: 160 }}
value={filterAccountId}
onChange={setFilterAccountId}
options={accounts.map((a: Account) => ({
label: a.accountName || `${a.walletAddress?.slice(0, 6)}...${a.walletAddress?.slice(-4)}`,
value: a.id
}))}
/>
<Select
allowClear
placeholder={t('whaleMonitorStrategy.list.enabled')}
style={{ width: 100 }}
value={filterEnabled}
onChange={setFilterEnabled}
options={[
{ label: t('whaleMonitorStrategy.list.enable'), value: true },
{ label: t('whaleMonitorStrategy.list.disable'), value: false }
]}
/>
<Button type="primary" icon={<PlusOutlined />} onClick={showCreateModal}>
{t('whaleMonitorStrategy.list.addStrategy')}
</Button>
</Space>
</div>
{isMobile ? (
<div>{strategies.map(renderMobileCard)}</div>
) : (
<Table
dataSource={strategies}
columns={columns}
rowKey="id"
loading={loading}
pagination={false}
scroll={{ x: 1200 }}
size="small"
/>
)}
</Card>
<Modal
title={editingStrategy ? t('whaleMonitorStrategy.list.edit') : t('whaleMonitorStrategy.list.addStrategy')}
open={formVisible}
onOk={handleSubmit}
onCancel={() => setFormVisible(false)}
confirmLoading={submitting}
width={isMobile ? '95%' : 600}
okText={editingStrategy ? t('whaleMonitorStrategy.form.update') : t('whaleMonitorStrategy.form.create')}
>
<Form form={form} layout="vertical">
<Form.Item name="accountId" label={t('whaleMonitorStrategy.form.selectAccount')} rules={[{ required: true }]}>
<Select
options={accounts.map((a: Account) => ({
label: a.accountName || `${a.walletAddress?.slice(0, 6)}...${a.walletAddress?.slice(-4)}`,
value: a.id
}))}
/>
</Form.Item>
<Form.Item name="name" label={t('whaleMonitorStrategy.form.strategyName')}>
<Input placeholder={t('whaleMonitorStrategy.form.strategyNamePlaceholder')} />
</Form.Item>
<Form.Item label={t('whaleMonitorStrategy.form.conditionIds')} required>
<div>
{selectedMarkets.length > 0 && (
<div style={{ marginBottom: 8, display: 'flex', flexWrap: 'wrap', gap: 4 }}>
{selectedMarkets.map(m => (
<Tag
key={m.conditionId}
closable
onClose={() =>
setSelectedMarkets(prev => prev.filter(item => item.conditionId !== m.conditionId))
}
>
{m.title}
</Tag>
))}
</div>
)}
<Button
block={isMobile}
onClick={goToMarketSelect}
style={{ minHeight: 44, display: 'flex', alignItems: 'center', justifyContent: 'space-between' }}
>
<span>
{selectedMarkets.length > 0
? t('whaleMonitorStrategy.form.marketsSelected', { count: selectedMarkets.length })
: t('whaleMonitorStrategy.form.selectMarkets')}
</span>
<RightOutlined />
</Button>
</div>
</Form.Item>
<Form.Item name="windowSeconds" label={t('whaleMonitorStrategy.form.windowSeconds')} rules={[{ required: true }]}>
<InputNumber min={1} style={{ width: '100%' }} />
</Form.Item>
<Form.Item name="thresholdAmount" label={t('whaleMonitorStrategy.form.thresholdAmount')} rules={[{ required: true }]}>
<InputNumber min={0.01} step={1} style={{ width: '100%' }} />
</Form.Item>
<Form.Item name="orderAmount" label={t('whaleMonitorStrategy.form.orderAmount')} rules={[{ required: true }]}>
<InputNumber min={1} step={1} style={{ width: '100%' }} />
</Form.Item>
<Space>
<Form.Item name="minPrice" label={t('whaleMonitorStrategy.form.minPrice')} rules={[{ required: true }]}>
<InputNumber min={0} max={1} step={0.01} precision={2} style={{ width: 120 }} />
</Form.Item>
<Form.Item name="maxPrice" label={t('whaleMonitorStrategy.form.maxPrice')} rules={[{ required: true }]}>
<InputNumber min={0} max={1} step={0.01} precision={2} style={{ width: 120 }} />
</Form.Item>
</Space>
<div style={{ fontSize: 12, color: '#999', marginTop: -16, marginBottom: 16 }}>
{t('whaleMonitorStrategy.form.priceTip')}
</div>
<Form.Item name="cooldownSeconds" label={t('whaleMonitorStrategy.form.cooldownSeconds')} rules={[{ required: true }]}>
<InputNumber min={1} style={{ width: '100%' }} />
</Form.Item>
<Form.Item name="enabled" label={t('whaleMonitorStrategy.form.enabled')} valuePropName="checked">
<Switch />
</Form.Item>
</Form>
</Modal>
<Modal
title={t('whaleMonitorStrategy.triggerRecords.title')}
open={triggerVisible}
onCancel={() => setTriggerVisible(false)}
footer={null}
width={isMobile ? '95%' : 900}
>
<Tabs
activeKey={triggerTab}
onChange={handleTriggerTabChange}
items={[
{ key: 'success', label: t('whaleMonitorStrategy.triggerRecords.successTab') },
{ key: 'fail', label: t('whaleMonitorStrategy.triggerRecords.failTab') }
]}
/>
{triggerRecords.length === 0 ? (
<Empty description={triggerTab === 'success' ? t('whaleMonitorStrategy.triggerRecords.emptySuccess') : t('whaleMonitorStrategy.triggerRecords.emptyFail')} />
) : (
<Table
dataSource={triggerRecords}
columns={triggerColumns}
rowKey="id"
loading={triggerLoading}
size="small"
scroll={{ x: isMobile ? 700 : 'auto' }}
pagination={{
current: triggerPage,
total: triggerTotal,
pageSize: 20,
onChange: (page) => {
setTriggerPage(page)
fetchTriggerRecords(triggerStrategyId, page, triggerTab === 'all' ? undefined : triggerTab)
}
}}
/>
)}
</Modal>
</div>
)
}
export default WhaleMonitorStrategyList
+78 -2
View File
@@ -314,8 +314,39 @@ export const apiService = {
/**
* 获取最新价(从订单表获取,供前端下单时显示)
*/
getLatestPrice: (data: { tokenId: string }) =>
apiClient.post<ApiResponse<any>>('/markets/latest-price', data)
getLatestPrice: (data: { tokenId: string }) =>
apiClient.post<ApiResponse<any>>('/markets/latest-price', data),
/**
* 搜索市场(按标题关键词)
*/
search: (data: { keyword: string; tagId?: string; seriesId?: string; sportSlug?: string; limit?: number }) =>
apiClient.post<ApiResponse<Array<{
conditionId: string
title: string
slug?: string
category?: string
volume?: string
outcomes?: string
eventTitle?: string
marketType?: 'game' | 'season'
image?: string
icon?: string
eventImage?: string
}>>>('/markets/search', data),
/**
* 获取体育联赛子分类列表
*/
sportsCategories: () =>
apiClient.post<ApiResponse<Array<{
id: number
slug: string
label: string
tagId: string
seriesId?: string
image?: string
}>>>('/markets/sports-categories', {})
},
/**
@@ -526,6 +557,51 @@ export const apiService = {
apiClient.post<ApiResponse<import('../types').CryptoTailManualOrderResponse>>('/crypto-tail-strategy/manual-order', data)
},
/**
* 大单监听策略 API
*/
whaleMonitorStrategy: {
list: (data: { accountId?: number; enabled?: boolean } = {}) =>
apiClient.post<ApiResponse<{ list: import('../types').WhaleMonitorStrategyDto[] }>>('/whale-monitor-strategy/list', data),
create: (data: {
accountId: number
name?: string
conditionIds: string[]
windowSeconds?: number
thresholdAmount: string
orderAmount: string
minPrice?: string
maxPrice?: string
cooldownSeconds?: number
enabled?: boolean
}) =>
apiClient.post<ApiResponse<import('../types').WhaleMonitorStrategyDto>>('/whale-monitor-strategy/create', data),
update: (data: {
strategyId: number
name?: string
conditionIds?: string[]
windowSeconds?: number
thresholdAmount?: string
orderAmount?: string
minPrice?: string
maxPrice?: string
cooldownSeconds?: number
enabled?: boolean
}) =>
apiClient.post<ApiResponse<import('../types').WhaleMonitorStrategyDto>>('/whale-monitor-strategy/update', data),
delete: (data: { strategyId: number }) =>
apiClient.post<ApiResponse<void>>('/whale-monitor-strategy/delete', data),
triggers: (data: {
strategyId: number
page?: number
pageSize?: number
status?: string
startDate?: number
endDate?: number
}) =>
apiClient.post<ApiResponse<{ list: import('../types').WhaleMonitorTriggerDto[]; total: number }>>('/whale-monitor-strategy/triggers', data)
},
/**
* 订单管理 API
*/
+68
View File
@@ -1309,3 +1309,71 @@ export interface TemplateVariablesResponse {
categories: TemplateVariableCategory[] // 分类列表
variables: TemplateVariable[] // 变量列表
}
// ==================== 大单监听策略 ====================
export type WhaleMonitorMarketType = 'game' | 'season'
export interface WhaleMonitorMarketItem {
conditionId: string
title: string
slug?: string
category?: string
volume?: string
outcomes?: string
/** 体育对阵/赛事名,如 Spurs vs. Thunder */
eventTitle?: string
/** game=单场赛事,season=长期/赛季市场 */
marketType?: WhaleMonitorMarketType
image?: string
icon?: string
eventImage?: string
}
export interface WhaleMonitorFormDraft {
formValues: Record<string, unknown>
selectedMarkets: WhaleMonitorMarketItem[]
editingStrategyId?: number
}
export interface WhaleMonitorMarketSelectLocationState {
selectedMarkets?: WhaleMonitorMarketItem[]
}
export interface WhaleMonitorStrategyListLocationState {
selectedMarkets?: WhaleMonitorMarketItem[]
}
export interface WhaleMonitorStrategyDto {
id: number
accountId: number
name?: string
conditionIds: string[]
windowSeconds: number
thresholdAmount: string
orderAmount: string
minPrice: string
maxPrice: string
cooldownSeconds: number
enabled: boolean
lastTriggerAt?: number
triggerCount: number
createdAt: number
updatedAt: number
}
export interface WhaleMonitorTriggerDto {
id: number
strategyId: number
conditionId: string
tokenId: string
side: string
triggerVolume: string
orderPrice: string
orderSize: string
orderAmount: string
orderId?: string
status: string
failReason?: string
createdAt: number
}