feat(cryptotail): 尾盘监控双连接与分时图优化
- 监控 WebSocket 拆分为当前周期连接与下一周期连接,周期切换时关闭过期连接并新建下一周期 - 下一周期市场未创建时也建立第二条空连接,保证始终两条连接 - refreshSubscription 增加 Mutex 防重入,避免周期结束时定时器与消息同时触发导致重复执行 - 修复 initMonitor/buildPushData 中 getCurrentOpenClose、spreadMode/spreadValue 等 API 与实体字段引用 - 移除重复的 buildSubscriptionMap、buildPushData 等方法,修复 StrategyPriceData.periodStartUnix - 前端分时图:市场价折线增加 connectNulls,新周期默认 0.5 价格展示 - 多语言与监控页入口、API 类型与 WebSocket 订阅集成 Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -35,6 +35,7 @@ import Announcements from './pages/Announcements'
|
||||
import BacktestList from './pages/BacktestList'
|
||||
import BacktestDetail from './pages/BacktestDetail'
|
||||
import CryptoTailStrategyList from './pages/CryptoTailStrategyList'
|
||||
import CryptoTailMonitor from './pages/CryptoTailMonitor'
|
||||
import { wsManager } from './services/websocket'
|
||||
import type { OrderPushMessage } from './types'
|
||||
import { apiService } from './services/api'
|
||||
@@ -252,6 +253,7 @@ function App() {
|
||||
<Route path="/templates/edit/:id" element={<ProtectedRoute><TemplateEdit /></ProtectedRoute>} />
|
||||
<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="/copy-trading/statistics/:copyTradingId" element={<ProtectedRoute><CopyTradingStatistics /></ProtectedRoute>} />
|
||||
{/* 保留旧路由以保持向后兼容 */}
|
||||
<Route path="/copy-trading/orders/buy/:copyTradingId" element={<ProtectedRoute><CopyTradingBuyOrders /></ProtectedRoute>} />
|
||||
|
||||
@@ -162,6 +162,11 @@ const Layout: React.FC<LayoutProps> = ({ children }) => {
|
||||
icon: <LineChartOutlined />,
|
||||
label: t('menu.cryptoTailStrategy')
|
||||
},
|
||||
{
|
||||
key: '/crypto-tail-monitor',
|
||||
icon: <LineChartOutlined />,
|
||||
label: t('menu.cryptoTailMonitor')
|
||||
},
|
||||
{
|
||||
key: '/positions',
|
||||
icon: <UnorderedListOutlined />,
|
||||
|
||||
@@ -313,6 +313,7 @@
|
||||
"templates": "Templates",
|
||||
"copyTradingConfig": "Copy Trading Config",
|
||||
"cryptoTailStrategy": "Tail Strategy",
|
||||
"cryptoTailMonitor": "Tail Monitor",
|
||||
"positions": "Position Management",
|
||||
"backtest": "Backtest",
|
||||
"statistics": "Statistics",
|
||||
@@ -1541,5 +1542,57 @@
|
||||
"emptyFail": "No failed records",
|
||||
"totalCount": "{count} record(s)"
|
||||
}
|
||||
},
|
||||
"cryptoTailMonitor": {
|
||||
"title": "Tail Strategy Monitor",
|
||||
"selectStrategy": "Strategy",
|
||||
"selectStrategyPlaceholder": "Select a strategy to monitor",
|
||||
"direction": "Direction",
|
||||
"directionUp": "Up",
|
||||
"directionDown": "Down",
|
||||
"noData": "Select a strategy to start monitoring",
|
||||
"priceRange": "Price Range",
|
||||
"timeWindow": "Time Window",
|
||||
"stat": {
|
||||
"openPrice": "Open Price",
|
||||
"currentPrice": "Current Price",
|
||||
"spread": "Spread",
|
||||
"remainingTime": "Remaining",
|
||||
"configuredSpread": "Configured Spread",
|
||||
"configuredSpreadMin": "Min Spread",
|
||||
"configuredSpreadMax": "Max Spread",
|
||||
"status": "Status",
|
||||
"minSpreadLine": "Min Spread",
|
||||
"periodSpreadMinMax": "Period Spread",
|
||||
"periodSpreadMin": "Period Min Spread",
|
||||
"periodSpreadMax": "Period Max Spread",
|
||||
"minSpread": "Min",
|
||||
"maxSpread": "Max"
|
||||
},
|
||||
"status": {
|
||||
"triggered": "Triggered",
|
||||
"periodEnded": "Period Ended",
|
||||
"inCondition": "In Condition",
|
||||
"waiting": "Waiting"
|
||||
},
|
||||
"chart": {
|
||||
"title": "Price Chart",
|
||||
"btcTitle": "BTC Price Chart",
|
||||
"marketTitle": "Market Price Chart",
|
||||
"price": "BTC Price",
|
||||
"openPrice": "Open",
|
||||
"spread": "Spread",
|
||||
"minSpreadLine": "Min Spread Line",
|
||||
"marketUp": "Up",
|
||||
"marketDown": "Down",
|
||||
"time": "Time"
|
||||
},
|
||||
"strategyInfo": {
|
||||
"title": "Strategy Info",
|
||||
"market": "Market",
|
||||
"interval": "Interval",
|
||||
"account": "Account",
|
||||
"spreadMode": "Spread Mode"
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -312,6 +312,7 @@
|
||||
"templates": "跟单模板",
|
||||
"copyTradingConfig": "跟单配置",
|
||||
"cryptoTailStrategy": "尾盘策略",
|
||||
"cryptoTailMonitor": "尾盘监控",
|
||||
"positions": "仓位管理",
|
||||
"backtest": "回测",
|
||||
"statistics": "统计信息",
|
||||
@@ -1540,5 +1541,57 @@
|
||||
"emptyFail": "暂无失败记录",
|
||||
"totalCount": "共 {count} 条"
|
||||
}
|
||||
},
|
||||
"cryptoTailMonitor": {
|
||||
"title": "尾盘策略监控",
|
||||
"selectStrategy": "选择策略",
|
||||
"selectStrategyPlaceholder": "请选择要监控的策略",
|
||||
"direction": "监控方向",
|
||||
"directionUp": "Up",
|
||||
"directionDown": "Down",
|
||||
"noData": "请选择一个策略开始监控",
|
||||
"priceRange": "价格区间",
|
||||
"timeWindow": "时间窗口",
|
||||
"stat": {
|
||||
"openPrice": "开盘价",
|
||||
"currentPrice": "最新价",
|
||||
"spread": "价差",
|
||||
"remainingTime": "剩余时间",
|
||||
"configuredSpread": "配置价差",
|
||||
"configuredSpreadMin": "最小价差",
|
||||
"configuredSpreadMax": "最大价差",
|
||||
"status": "状态",
|
||||
"minSpreadLine": "最小价差线",
|
||||
"periodSpreadMinMax": "周期内价差",
|
||||
"periodSpreadMin": "周期内最小价差",
|
||||
"periodSpreadMax": "周期内最大价差",
|
||||
"minSpread": "最小",
|
||||
"maxSpread": "最大"
|
||||
},
|
||||
"status": {
|
||||
"triggered": "已触发",
|
||||
"periodEnded": "周期结束",
|
||||
"inCondition": "满足条件",
|
||||
"waiting": "等待中"
|
||||
},
|
||||
"chart": {
|
||||
"title": "分时图",
|
||||
"btcTitle": "BTC 分时图",
|
||||
"marketTitle": "市场分时图",
|
||||
"price": "BTC 价格",
|
||||
"openPrice": "开盘价",
|
||||
"spread": "价差",
|
||||
"minSpreadLine": "最小价差线",
|
||||
"marketUp": "Up",
|
||||
"marketDown": "Down",
|
||||
"time": "时间"
|
||||
},
|
||||
"strategyInfo": {
|
||||
"title": "策略信息",
|
||||
"market": "市场",
|
||||
"interval": "周期",
|
||||
"account": "账户",
|
||||
"spreadMode": "价差模式"
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -313,6 +313,7 @@
|
||||
"templates": "跟單模板",
|
||||
"copyTradingConfig": "跟單配置",
|
||||
"cryptoTailStrategy": "尾盤策略",
|
||||
"cryptoTailMonitor": "尾盤監控",
|
||||
"positions": "倉位管理",
|
||||
"backtest": "回測",
|
||||
"statistics": "統計信息",
|
||||
@@ -1541,5 +1542,57 @@
|
||||
"emptyFail": "暫無失敗記錄",
|
||||
"totalCount": "共 {count} 條"
|
||||
}
|
||||
},
|
||||
"cryptoTailMonitor": {
|
||||
"title": "尾盤策略監控",
|
||||
"selectStrategy": "選擇策略",
|
||||
"selectStrategyPlaceholder": "請選擇要監控的策略",
|
||||
"direction": "監控方向",
|
||||
"directionUp": "Up",
|
||||
"directionDown": "Down",
|
||||
"noData": "請選擇一個策略開始監控",
|
||||
"priceRange": "價格區間",
|
||||
"timeWindow": "時間窗口",
|
||||
"stat": {
|
||||
"openPrice": "開盤價",
|
||||
"currentPrice": "最新價",
|
||||
"spread": "價差",
|
||||
"remainingTime": "剩餘時間",
|
||||
"configuredSpread": "配置價差",
|
||||
"configuredSpreadMin": "最小價差",
|
||||
"configuredSpreadMax": "最大價差",
|
||||
"status": "狀態",
|
||||
"minSpreadLine": "最小價差線",
|
||||
"periodSpreadMinMax": "週期內價差",
|
||||
"periodSpreadMin": "週期內最小價差",
|
||||
"periodSpreadMax": "週期內最大價差",
|
||||
"minSpread": "最小",
|
||||
"maxSpread": "最大"
|
||||
},
|
||||
"status": {
|
||||
"triggered": "已觸發",
|
||||
"periodEnded": "週期結束",
|
||||
"inCondition": "滿足條件",
|
||||
"waiting": "等待中"
|
||||
},
|
||||
"chart": {
|
||||
"title": "分時圖",
|
||||
"btcTitle": "BTC 分時圖",
|
||||
"marketTitle": "市場分時圖",
|
||||
"price": "BTC 價格",
|
||||
"openPrice": "開盤價",
|
||||
"spread": "價差",
|
||||
"minSpreadLine": "最小價差線",
|
||||
"marketUp": "Up",
|
||||
"marketDown": "Down",
|
||||
"time": "時間"
|
||||
},
|
||||
"strategyInfo": {
|
||||
"title": "策略信息",
|
||||
"market": "市場",
|
||||
"interval": "週期",
|
||||
"account": "賬戶",
|
||||
"spreadMode": "價差模式"
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,678 @@
|
||||
import { useEffect, useState, useRef, useCallback } from 'react'
|
||||
import {
|
||||
Card,
|
||||
Select,
|
||||
Space,
|
||||
Statistic,
|
||||
Row,
|
||||
Col,
|
||||
Typography,
|
||||
Spin,
|
||||
Empty,
|
||||
Alert
|
||||
} from 'antd'
|
||||
import { ClockCircleOutlined } from '@ant-design/icons'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { useMediaQuery } from 'react-responsive'
|
||||
import * as echarts from 'echarts'
|
||||
import type { EChartsOption } from 'echarts'
|
||||
import { apiService } from '../services/api'
|
||||
import { useWebSocketSubscription } from '../hooks/useWebSocket'
|
||||
import { formatNumber } from '../utils'
|
||||
import type {
|
||||
CryptoTailStrategyDto,
|
||||
CryptoTailMonitorInitResponse,
|
||||
CryptoTailMonitorPushData
|
||||
} from '../types'
|
||||
|
||||
const { Title, Text } = Typography
|
||||
|
||||
/** 分时图数据点:时间戳、BTC 价格 USDC、市场 Up/Down 价格 0-1 */
|
||||
interface PriceDataPoint {
|
||||
time: number
|
||||
btcPrice: number | null
|
||||
marketPriceUp: number | null
|
||||
marketPriceDown: number | null
|
||||
}
|
||||
|
||||
const CryptoTailMonitor: React.FC = () => {
|
||||
const { t } = useTranslation()
|
||||
const isMobile = useMediaQuery({ maxWidth: 768 })
|
||||
|
||||
// 策略列表
|
||||
const [strategies, setStrategies] = useState<CryptoTailStrategyDto[]>([])
|
||||
const [strategiesLoading, setStrategiesLoading] = useState(false)
|
||||
|
||||
// 选中的策略
|
||||
const [selectedStrategyId, setSelectedStrategyId] = useState<number | null>(null)
|
||||
|
||||
// 监控数据
|
||||
const [initData, setInitData] = useState<CryptoTailMonitorInitResponse | null>(null)
|
||||
const [pushData, setPushData] = useState<CryptoTailMonitorPushData | null>(null)
|
||||
const [initLoading, setInitLoading] = useState(false)
|
||||
|
||||
// 价格历史数据(用于分时图)
|
||||
const [priceHistory, setPriceHistory] = useState<PriceDataPoint[]>([])
|
||||
const chartRef = useRef<HTMLDivElement>(null)
|
||||
const chartInstance = useRef<echarts.ECharts | null>(null)
|
||||
const marketChartRef = useRef<HTMLDivElement>(null)
|
||||
const marketChartInstance = useRef<echarts.ECharts | null>(null)
|
||||
const lastPeriodStartRef = useRef<number | null>(null)
|
||||
// 记录首次数据进入时间(用于中途进入时的横轴起点)
|
||||
const [firstDataTime, setFirstDataTime] = useState<number | null>(null)
|
||||
// 标记是否已切换过周期(切换后使用完整周期)
|
||||
const [hasSwitchedPeriod, setHasSwitchedPeriod] = useState<boolean>(false)
|
||||
|
||||
// 获取策略列表
|
||||
useEffect(() => {
|
||||
const fetchStrategies = async () => {
|
||||
setStrategiesLoading(true)
|
||||
try {
|
||||
const res = await apiService.cryptoTailStrategy.list({ enabled: true })
|
||||
if (res.data.code === 0 && res.data.data) {
|
||||
setStrategies(res.data.data.list ?? [])
|
||||
// 自动选择第一个策略
|
||||
if (res.data.data.list?.length > 0 && !selectedStrategyId) {
|
||||
setSelectedStrategyId(res.data.data.list[0].id)
|
||||
}
|
||||
}
|
||||
} catch (e) {
|
||||
console.error('Failed to fetch strategies:', e)
|
||||
} finally {
|
||||
setStrategiesLoading(false)
|
||||
}
|
||||
}
|
||||
fetchStrategies()
|
||||
}, [])
|
||||
|
||||
// 初始化监控数据
|
||||
useEffect(() => {
|
||||
if (!selectedStrategyId) {
|
||||
setInitData(null)
|
||||
setPushData(null)
|
||||
setPriceHistory([])
|
||||
setFirstDataTime(null)
|
||||
setHasSwitchedPeriod(false)
|
||||
return
|
||||
}
|
||||
|
||||
const initMonitor = async () => {
|
||||
setInitLoading(true)
|
||||
setPriceHistory([])
|
||||
setFirstDataTime(null)
|
||||
setHasSwitchedPeriod(false)
|
||||
try {
|
||||
const res = await apiService.cryptoTailStrategy.monitorInit(selectedStrategyId)
|
||||
if (res.data.code === 0 && res.data.data) {
|
||||
setInitData(res.data.data)
|
||||
} else {
|
||||
setInitData(null)
|
||||
}
|
||||
} catch (e) {
|
||||
console.error('Failed to init monitor:', e)
|
||||
setInitData(null)
|
||||
} finally {
|
||||
setInitLoading(false)
|
||||
}
|
||||
}
|
||||
initMonitor()
|
||||
}, [selectedStrategyId])
|
||||
|
||||
// WebSocket 订阅
|
||||
const handlePushData = useCallback((data: CryptoTailMonitorPushData) => {
|
||||
if (data.strategyId !== selectedStrategyId) return
|
||||
setPushData(data)
|
||||
|
||||
const btcPrice = data.currentPriceBtc != null && data.currentPriceBtc !== ''
|
||||
? parseFloat(data.currentPriceBtc)
|
||||
: null
|
||||
const marketUp = data.currentPriceUp != null && data.currentPriceUp !== ''
|
||||
? parseFloat(data.currentPriceUp)
|
||||
: null
|
||||
const marketDown = data.currentPriceDown != null && data.currentPriceDown !== ''
|
||||
? parseFloat(data.currentPriceDown)
|
||||
: null
|
||||
const hasBtc = btcPrice != null && !Number.isNaN(btcPrice)
|
||||
const hasMarket = (marketUp != null && !Number.isNaN(marketUp)) || (marketDown != null && !Number.isNaN(marketDown))
|
||||
if (!hasBtc && !hasMarket) return
|
||||
|
||||
const newPoint: PriceDataPoint = {
|
||||
time: data.timestamp,
|
||||
btcPrice: hasBtc ? btcPrice : null,
|
||||
marketPriceUp: hasMarket && marketUp != null && !Number.isNaN(marketUp) ? marketUp : null,
|
||||
marketPriceDown: hasMarket && marketDown != null && !Number.isNaN(marketDown) ? marketDown : null
|
||||
}
|
||||
|
||||
// 用 ref 检测周期切换,避免因依赖 initData 导致回调频繁重建
|
||||
const pushPeriod = data.periodStartUnix
|
||||
const lastPeriod = lastPeriodStartRef.current
|
||||
|
||||
if (pushPeriod != null && pushPeriod !== lastPeriod) {
|
||||
// 新周期或首次推送:更新 ref 和 initData,清空历史
|
||||
lastPeriodStartRef.current = pushPeriod
|
||||
// 如果之前已经有周期数据,说明是周期切换,标记为已切换
|
||||
if (lastPeriod != null) {
|
||||
setHasSwitchedPeriod(true)
|
||||
}
|
||||
// 周期切换时重置首次数据时间
|
||||
setFirstDataTime(newPoint.time)
|
||||
setInitData(prev => prev ? { ...prev, periodStartUnix: pushPeriod } : null)
|
||||
setPriceHistory([newPoint])
|
||||
} else {
|
||||
// 同周期:追加数据
|
||||
// 记录首次数据时间(仅在中途进入且未切换过周期时记录)
|
||||
setFirstDataTime(prev => {
|
||||
if (prev == null) {
|
||||
return newPoint.time
|
||||
}
|
||||
return prev
|
||||
})
|
||||
setPriceHistory(prev => {
|
||||
const maxPoints = 300
|
||||
const newHistory = [...prev, newPoint]
|
||||
return newHistory.slice(-maxPoints)
|
||||
})
|
||||
}
|
||||
}, [selectedStrategyId])
|
||||
|
||||
const channel = selectedStrategyId ? `crypto_tail_monitor_${selectedStrategyId}` : ''
|
||||
useWebSocketSubscription(channel, handlePushData)
|
||||
|
||||
// 图表容器仅在 initData 存在时渲染,故在更新图表时懒初始化
|
||||
useEffect(() => {
|
||||
const handleResize = () => {
|
||||
chartInstance.current?.resize()
|
||||
marketChartInstance.current?.resize()
|
||||
}
|
||||
window.addEventListener('resize', handleResize)
|
||||
return () => {
|
||||
window.removeEventListener('resize', handleResize)
|
||||
chartInstance.current?.dispose()
|
||||
chartInstance.current = null
|
||||
marketChartInstance.current?.dispose()
|
||||
marketChartInstance.current = null
|
||||
}
|
||||
}, [])
|
||||
|
||||
// 更新图表:分时图为 BTC 价格 USDC
|
||||
useEffect(() => {
|
||||
if (!initData) return
|
||||
if (chartRef.current && !chartInstance.current) {
|
||||
chartInstance.current = echarts.init(chartRef.current)
|
||||
}
|
||||
if (!chartInstance.current) return
|
||||
|
||||
const periodStartMs = (initData.periodStartUnix ?? 0) * 1000
|
||||
const periodEndMs = periodStartMs + (initData.intervalSeconds ?? 300) * 1000
|
||||
|
||||
// data.timestamp 为毫秒,firstDataTime 已是 ms,无需再乘 1000
|
||||
const firstDataMs = firstDataTime != null ? firstDataTime : null
|
||||
const isMidEntry = firstDataMs != null && !hasSwitchedPeriod && firstDataMs > periodStartMs
|
||||
// 中途进入时横轴起点为进入时刻,否则为周期起点
|
||||
const xAxisMin = isMidEntry ? firstDataMs : periodStartMs
|
||||
|
||||
const btcData: [number, number | null][] = priceHistory.length > 0
|
||||
? priceHistory.map(p => [p.time, p.btcPrice])
|
||||
: []
|
||||
const openBtc = pushData?.openPriceBtc ?? initData.openPriceBtc
|
||||
const openBtcNum = openBtc != null ? parseFloat(openBtc) : null
|
||||
|
||||
const hasAnyBtcData = btcData.some(([, v]) => v != null && !Number.isNaN(v))
|
||||
const btcPlaceholderTime = xAxisMin
|
||||
const displayBtcData: [number, number | null][] = hasAnyBtcData
|
||||
? btcData
|
||||
: (openBtcNum != null ? [[btcPlaceholderTime, openBtcNum]] : [])
|
||||
const minSpreadUpRaw = pushData?.minSpreadLineUp ?? initData.autoMinSpreadUp
|
||||
const minSpreadDownRaw = pushData?.minSpreadLineDown ?? initData.autoMinSpreadDown
|
||||
const minSpreadUp = minSpreadUpRaw != null && minSpreadUpRaw !== '' ? parseFloat(minSpreadUpRaw) : null
|
||||
const minSpreadDown = minSpreadDownRaw != null && minSpreadDownRaw !== '' ? parseFloat(minSpreadDownRaw) : null
|
||||
|
||||
const validPrices = displayBtcData.flatMap(([, v]) => (v != null && !Number.isNaN(v) ? [v] : []))
|
||||
const defaultRange = 500
|
||||
let yMin: number | undefined
|
||||
let yMax: number | undefined
|
||||
if (validPrices.length > 0) {
|
||||
const dataMin = Math.min(...validPrices)
|
||||
const dataMax = Math.max(...validPrices)
|
||||
const dataRange = dataMax - dataMin
|
||||
const minRange = Math.max(Math.abs(dataMax) * 0.01, 10)
|
||||
const range = Math.max(dataRange, minRange)
|
||||
const padding = range * 0.25
|
||||
yMin = dataMin - padding
|
||||
yMax = dataMax + padding
|
||||
} else if (openBtcNum != null) {
|
||||
const spread = minSpreadUp ?? minSpreadDown ?? defaultRange
|
||||
const halfRange = spread * 1.5
|
||||
yMin = openBtcNum - halfRange
|
||||
yMax = openBtcNum + halfRange
|
||||
}
|
||||
|
||||
const markLineData: Array<{ name: string; yAxis: number; lineStyle: { type: 'dashed'; color: string } }> = []
|
||||
if (openBtcNum != null && !Number.isNaN(openBtcNum)) {
|
||||
markLineData.push({
|
||||
name: t('cryptoTailMonitor.chart.openPrice'),
|
||||
yAxis: openBtcNum,
|
||||
lineStyle: { type: 'dashed', color: '#999' }
|
||||
})
|
||||
}
|
||||
if (openBtcNum != null && minSpreadUp != null && !Number.isNaN(minSpreadUp)) {
|
||||
markLineData.push({
|
||||
name: t('cryptoTailMonitor.chart.minSpreadLine') + ' Up',
|
||||
yAxis: openBtcNum + minSpreadUp,
|
||||
lineStyle: { type: 'dashed', color: '#ff4d4f' }
|
||||
})
|
||||
}
|
||||
if (openBtcNum != null && minSpreadDown != null && !Number.isNaN(minSpreadDown)) {
|
||||
markLineData.push({
|
||||
name: t('cryptoTailMonitor.chart.minSpreadLine') + ' Down',
|
||||
yAxis: openBtcNum - minSpreadDown,
|
||||
lineStyle: { type: 'dashed', color: '#ff4d4f' }
|
||||
})
|
||||
}
|
||||
|
||||
const option: EChartsOption = {
|
||||
tooltip: {
|
||||
trigger: 'axis',
|
||||
formatter: (params: unknown) => {
|
||||
const arr = params as Array<{ seriesName: string; name: string; value: number | [number, number] }>
|
||||
const priceParam = arr.find(p => p.seriesName === t('cryptoTailMonitor.chart.price'))
|
||||
if (!priceParam) return ''
|
||||
const val = Array.isArray(priceParam.value) ? priceParam.value[1] : priceParam.value
|
||||
if (val == null || Number.isNaN(val)) return ''
|
||||
const ts = priceParam.name as unknown as number
|
||||
const offsetSec = Math.floor(ts / 1000) - (initData?.periodStartUnix ?? 0)
|
||||
const mins = Math.floor(offsetSec / 60)
|
||||
const secs = offsetSec % 60
|
||||
const timeStr = `${mins.toString().padStart(2, '0')}:${secs.toString().padStart(2, '0')}`
|
||||
return `
|
||||
<div>
|
||||
<div>${t('cryptoTailMonitor.chart.time')}: ${timeStr}</div>
|
||||
<div>${t('cryptoTailMonitor.chart.price')}: ${Number(val).toFixed(2)} USDC</div>
|
||||
</div>
|
||||
`
|
||||
}
|
||||
},
|
||||
legend: {
|
||||
show: true,
|
||||
top: 0
|
||||
},
|
||||
grid: {
|
||||
left: '3%',
|
||||
right: '4%',
|
||||
bottom: '3%',
|
||||
top: '12%',
|
||||
containLabel: true
|
||||
},
|
||||
xAxis: {
|
||||
type: 'time',
|
||||
min: xAxisMin,
|
||||
max: periodEndMs,
|
||||
boundaryGap: false,
|
||||
axisLabel: {
|
||||
formatter: (val: number) => {
|
||||
const offsetSec = Math.floor(val / 1000) - (initData.periodStartUnix ?? 0)
|
||||
const mins = Math.floor(offsetSec / 60)
|
||||
const secs = offsetSec % 60
|
||||
return `${mins.toString().padStart(2, '0')}:${secs.toString().padStart(2, '0')}`
|
||||
}
|
||||
}
|
||||
},
|
||||
yAxis: {
|
||||
type: 'value',
|
||||
scale: true,
|
||||
min: yMin,
|
||||
max: yMax,
|
||||
axisLabel: {
|
||||
formatter: (value: number) => value.toFixed(0)
|
||||
}
|
||||
},
|
||||
series: [
|
||||
{
|
||||
name: t('cryptoTailMonitor.chart.price'),
|
||||
type: 'line',
|
||||
data: displayBtcData,
|
||||
smooth: true,
|
||||
symbol: displayBtcData.length === 1 ? 'circle' : 'none',
|
||||
symbolSize: 4,
|
||||
lineStyle: { width: 2, color: '#1890ff' },
|
||||
areaStyle: {
|
||||
color: {
|
||||
type: 'linear',
|
||||
x: 0,
|
||||
y: 0,
|
||||
x2: 0,
|
||||
y2: 1,
|
||||
colorStops: [
|
||||
{ offset: 0, color: 'rgba(24, 144, 255, 0.3)' },
|
||||
{ offset: 1, color: 'rgba(24, 144, 255, 0.05)' }
|
||||
]
|
||||
}
|
||||
},
|
||||
markLine: markLineData.length > 0 ? { data: markLineData } : undefined
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
chartInstance.current.setOption(option, true)
|
||||
chartInstance.current.resize()
|
||||
}, [priceHistory, initData, pushData, firstDataTime, hasSwitchedPeriod, t])
|
||||
|
||||
// 更新市场分时图:Polymarket 价格 0-1
|
||||
useEffect(() => {
|
||||
if (!initData) return
|
||||
if (marketChartRef.current && !marketChartInstance.current) {
|
||||
marketChartInstance.current = echarts.init(marketChartRef.current)
|
||||
}
|
||||
if (!marketChartInstance.current) return
|
||||
|
||||
const periodStartMs = (initData.periodStartUnix ?? 0) * 1000
|
||||
const periodEndMs = periodStartMs + (initData.intervalSeconds ?? 300) * 1000
|
||||
|
||||
// data.timestamp 为毫秒,firstDataTime 已是 ms,无需再乘 1000
|
||||
const firstDataMs = firstDataTime != null ? firstDataTime : null
|
||||
const isMidEntry = firstDataMs != null && !hasSwitchedPeriod && firstDataMs > periodStartMs
|
||||
const xAxisMin = isMidEntry ? firstDataMs : periodStartMs
|
||||
|
||||
const toMs = (t: number) => (t > 0 && t < 1e12 ? t * 1000 : t)
|
||||
const marketUpData: [number, number | null][] = priceHistory.length > 0
|
||||
? priceHistory.map(p => [toMs(p.time), p.marketPriceUp])
|
||||
: []
|
||||
const marketDownData: [number, number | null][] = priceHistory.length > 0
|
||||
? priceHistory.map(p => [toMs(p.time), p.marketPriceDown])
|
||||
: []
|
||||
|
||||
const minPrice = parseFloat(initData.minPrice)
|
||||
const maxPrice = parseFloat(initData.maxPrice)
|
||||
const midPrice = (minPrice + maxPrice) / 2
|
||||
const isValid = (v: number | null): v is number => v != null && !Number.isNaN(v)
|
||||
const validUp: [number, number][] = marketUpData.filter((point): point is [number, number] => isValid(point[1]))
|
||||
const validDown: [number, number][] = marketDownData.filter((point): point is [number, number] => isValid(point[1]))
|
||||
const hasAnyMarketData = validUp.length > 0 || validDown.length > 0
|
||||
const placeholderTime = xAxisMin
|
||||
const finalMarketUp: [number, number][] = hasAnyMarketData ? validUp : [[placeholderTime, midPrice]]
|
||||
const finalMarketDown: [number, number][] = hasAnyMarketData ? validDown : [[placeholderTime, midPrice]]
|
||||
|
||||
const option: EChartsOption = {
|
||||
tooltip: {
|
||||
trigger: 'axis',
|
||||
formatter: (params: unknown) => {
|
||||
const arr = params as Array<{ seriesName: string; name: string | number; value: number | [number, number] }>
|
||||
const upParam = arr.find(p => p.seriesName === t('cryptoTailMonitor.chart.marketUp'))
|
||||
const downParam = arr.find(p => p.seriesName === t('cryptoTailMonitor.chart.marketDown'))
|
||||
const rawTime = arr[0]?.name
|
||||
const timeStr = typeof rawTime === 'number' && initData
|
||||
? (() => {
|
||||
const offsetSec = Math.floor(rawTime / 1000) - (initData.periodStartUnix ?? 0)
|
||||
const mins = Math.floor(offsetSec / 60)
|
||||
const secs = offsetSec % 60
|
||||
return `${mins.toString().padStart(2, '0')}:${secs.toString().padStart(2, '0')}`
|
||||
})()
|
||||
: String(rawTime ?? '')
|
||||
let html = `<div><div>${t('cryptoTailMonitor.chart.time')}: ${timeStr}</div>`
|
||||
const upVal = Array.isArray(upParam?.value) ? upParam?.value[1] : upParam?.value
|
||||
const downVal = Array.isArray(downParam?.value) ? downParam?.value[1] : downParam?.value
|
||||
if (upVal != null && !Number.isNaN(upVal)) html += `<div>Up: ${Number(upVal).toFixed(4)}</div>`
|
||||
if (downVal != null && !Number.isNaN(downVal)) html += `<div>Down: ${Number(downVal).toFixed(4)}</div>`
|
||||
html += '</div>'
|
||||
return html
|
||||
}
|
||||
},
|
||||
legend: {
|
||||
show: true,
|
||||
top: 0,
|
||||
data: [t('cryptoTailMonitor.chart.marketUp'), t('cryptoTailMonitor.chart.marketDown')]
|
||||
},
|
||||
grid: {
|
||||
left: '3%',
|
||||
right: '4%',
|
||||
bottom: '3%',
|
||||
top: '15%',
|
||||
containLabel: true
|
||||
},
|
||||
xAxis: {
|
||||
type: 'time',
|
||||
min: xAxisMin,
|
||||
max: periodEndMs,
|
||||
boundaryGap: false,
|
||||
axisLabel: {
|
||||
formatter: (val: number) => {
|
||||
const offsetSec = Math.floor(val / 1000) - (initData.periodStartUnix ?? 0)
|
||||
const mins = Math.floor(offsetSec / 60)
|
||||
const secs = offsetSec % 60
|
||||
return `${mins.toString().padStart(2, '0')}:${secs.toString().padStart(2, '0')}`
|
||||
}
|
||||
}
|
||||
},
|
||||
yAxis: {
|
||||
type: 'value',
|
||||
min: 0,
|
||||
max: 1,
|
||||
interval: 0.2,
|
||||
axisLabel: { formatter: (v: number) => v.toFixed(1) }
|
||||
},
|
||||
series: [
|
||||
{
|
||||
name: t('cryptoTailMonitor.chart.marketUp'),
|
||||
type: 'line',
|
||||
data: finalMarketUp,
|
||||
smooth: true,
|
||||
symbol: 'circle',
|
||||
symbolSize: 4,
|
||||
showSymbol: true,
|
||||
connectNulls: true,
|
||||
lineStyle: { width: 2, color: '#1890ff' },
|
||||
itemStyle: { color: '#1890ff' },
|
||||
markArea: {
|
||||
silent: true,
|
||||
itemStyle: { color: 'rgba(82, 196, 26, 0.12)' },
|
||||
data: [[{ yAxis: minPrice }, { yAxis: maxPrice }]]
|
||||
}
|
||||
},
|
||||
{
|
||||
name: t('cryptoTailMonitor.chart.marketDown'),
|
||||
type: 'line',
|
||||
data: finalMarketDown,
|
||||
smooth: true,
|
||||
symbol: 'circle',
|
||||
symbolSize: 4,
|
||||
showSymbol: true,
|
||||
connectNulls: true,
|
||||
lineStyle: { width: 2, color: '#fa8c16' },
|
||||
itemStyle: { color: '#fa8c16' }
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
marketChartInstance.current.setOption(option, true)
|
||||
marketChartInstance.current.resize()
|
||||
}, [priceHistory, initData, firstDataTime, hasSwitchedPeriod, t])
|
||||
|
||||
// 格式化剩余时间
|
||||
const formatRemainingTime = (seconds: number): string => {
|
||||
const mins = Math.floor(seconds / 60)
|
||||
const secs = seconds % 60
|
||||
return `${mins}:${secs.toString().padStart(2, '0')}`
|
||||
}
|
||||
|
||||
// 显示 BTC 价格(最新价、价差、开盘价均为 USDC)
|
||||
const openPrice = pushData?.openPriceBtc ?? initData?.openPriceBtc
|
||||
const currentPrice = pushData?.currentPriceBtc
|
||||
const currentSpread = pushData?.spreadBtc
|
||||
const minSpreadUpStr = pushData?.minSpreadLineUp ?? initData?.autoMinSpreadUp
|
||||
const minSpreadDownStr = pushData?.minSpreadLineDown ?? initData?.autoMinSpreadDown
|
||||
const minSpreadUpVal = minSpreadUpStr != null && minSpreadUpStr !== '' ? parseFloat(minSpreadUpStr) : null
|
||||
const minSpreadDownVal = minSpreadDownStr != null && minSpreadDownStr !== '' ? parseFloat(minSpreadDownStr) : null
|
||||
const minSpreadLineNum = [minSpreadUpVal, minSpreadDownVal].filter((v): v is number => v != null && !Number.isNaN(v))
|
||||
const spreadBelowThreshold = currentSpread != null && currentSpread !== '' && minSpreadLineNum.length > 0 &&
|
||||
parseFloat(currentSpread) < Math.min(...minSpreadLineNum)
|
||||
|
||||
return (
|
||||
<div style={{ padding: isMobile ? 12 : 24 }}>
|
||||
<Title level={2} style={{ marginBottom: 16, fontSize: isMobile ? 20 : 24 }}>
|
||||
{t('cryptoTailMonitor.title')}
|
||||
</Title>
|
||||
|
||||
{/* 顶部控制区 */}
|
||||
<Card style={{ marginBottom: 16 }}>
|
||||
<Space wrap size="middle">
|
||||
<Space>
|
||||
<Text strong>{t('cryptoTailMonitor.selectStrategy')}</Text>
|
||||
<Select
|
||||
style={{ minWidth: isMobile ? 200 : 300 }}
|
||||
loading={strategiesLoading}
|
||||
value={selectedStrategyId}
|
||||
onChange={(id) => setSelectedStrategyId(id)}
|
||||
placeholder={t('cryptoTailMonitor.selectStrategyPlaceholder')}
|
||||
options={strategies.map(s => ({
|
||||
label: `${s.name || s.marketSlugPrefix} (${s.intervalSeconds === 300 ? '5m' : '15m'})`,
|
||||
value: s.id
|
||||
}))}
|
||||
/>
|
||||
</Space>
|
||||
</Space>
|
||||
</Card>
|
||||
|
||||
{initLoading ? (
|
||||
<Spin spinning style={{ display: 'flex', justifyContent: 'center', padding: 100 }} />
|
||||
) : !initData ? (
|
||||
<Empty description={t('cryptoTailMonitor.noData')} />
|
||||
) : (
|
||||
<>
|
||||
{/* 状态卡片:最小宽度填满整行,间距 16 */}
|
||||
<Row gutter={16} style={{ marginBottom: 16 }}>
|
||||
<Col flex="1" style={{ minWidth: 140 }}>
|
||||
<Card size="small" style={{ width: '100%', minWidth: 0 }}>
|
||||
<Statistic
|
||||
title={t('cryptoTailMonitor.stat.openPrice')}
|
||||
value={openPrice ? formatNumber(openPrice, 2) : '-'}
|
||||
precision={2}
|
||||
/>
|
||||
</Card>
|
||||
</Col>
|
||||
<Col flex="1" style={{ minWidth: 140 }}>
|
||||
<Card size="small" style={{ width: '100%', minWidth: 0 }}>
|
||||
<Statistic
|
||||
title={t('cryptoTailMonitor.stat.currentPrice')}
|
||||
value={currentPrice ? formatNumber(currentPrice, 2) : '-'}
|
||||
precision={2}
|
||||
valueStyle={{ color: isMobile ? undefined : '#1890ff' }}
|
||||
/>
|
||||
</Card>
|
||||
</Col>
|
||||
<Col flex="1" style={{ minWidth: 140 }}>
|
||||
<Card size="small" style={{ width: '100%', minWidth: 0 }}>
|
||||
<Statistic
|
||||
title={t('cryptoTailMonitor.stat.spread')}
|
||||
value={(() => {
|
||||
if (currentSpread == null || currentSpread === '') return '-'
|
||||
const num = parseFloat(currentSpread)
|
||||
if (Number.isNaN(num)) return '-'
|
||||
const formatted = formatNumber(currentSpread, 2)
|
||||
return num >= 0 ? `+${formatted}` : formatted
|
||||
})()}
|
||||
precision={2}
|
||||
valueStyle={{
|
||||
color: spreadBelowThreshold ? '#ff4d4f' : undefined
|
||||
}}
|
||||
/>
|
||||
</Card>
|
||||
</Col>
|
||||
<Col flex="1" style={{ minWidth: 140 }}>
|
||||
<Card size="small" style={{ width: '100%', minWidth: 0 }}>
|
||||
<Statistic
|
||||
title={t('cryptoTailMonitor.stat.remainingTime')}
|
||||
value={pushData ? formatRemainingTime(pushData.remainingSeconds) : '-'}
|
||||
prefix={<ClockCircleOutlined />}
|
||||
valueStyle={{
|
||||
color: pushData && pushData.remainingSeconds < 60 ? '#ff4d4f' : undefined
|
||||
}}
|
||||
/>
|
||||
</Card>
|
||||
</Col>
|
||||
<Col flex="1" style={{ minWidth: 140 }}>
|
||||
<Card size="small" style={{ width: '100%', minWidth: 0 }}>
|
||||
<Statistic
|
||||
title={(initData.spreadDirection ?? 'MIN') === 'MAX' ? t('cryptoTailMonitor.stat.configuredSpreadMax') : t('cryptoTailMonitor.stat.configuredSpreadMin')}
|
||||
valueRender={() => {
|
||||
const mode = initData.minSpreadMode ?? 'NONE'
|
||||
if (mode === 'NONE') return <Text type="secondary">-</Text>
|
||||
if (mode === 'FIXED') {
|
||||
const v = initData.minSpreadValue
|
||||
return v != null && v !== '' ? formatNumber(v, 2) : '-'
|
||||
}
|
||||
const up = minSpreadUpStr != null && minSpreadUpStr !== '' ? formatNumber(minSpreadUpStr, 2) : null
|
||||
const down = minSpreadDownStr != null && minSpreadDownStr !== '' ? formatNumber(minSpreadDownStr, 2) : null
|
||||
if (up == null && down == null) return <Text type="secondary">-</Text>
|
||||
return (
|
||||
<Text style={{ fontSize: 13, lineHeight: 1.4 }}>
|
||||
{up != null && <span style={{ display: 'block' }}>Up: {up}</span>}
|
||||
{down != null && <span style={{ display: 'block' }}>Down: {down}</span>}
|
||||
</Text>
|
||||
)
|
||||
}}
|
||||
/>
|
||||
</Card>
|
||||
</Col>
|
||||
</Row>
|
||||
|
||||
{/* 价格区间提示 */}
|
||||
<Alert
|
||||
type="info"
|
||||
showIcon
|
||||
style={{ marginBottom: 16 }}
|
||||
message={`${t('cryptoTailMonitor.priceRange')}: ${formatNumber(initData.minPrice, 2)} ~ ${formatNumber(initData.maxPrice, 2)} | ${t('cryptoTailMonitor.timeWindow')}: ${Math.floor(initData.windowStartSeconds / 60)}:${(initData.windowStartSeconds % 60).toString().padStart(2, '0')} ~ ${Math.floor(initData.windowEndSeconds / 60)}:${(initData.windowEndSeconds % 60).toString().padStart(2, '0')}`}
|
||||
/>
|
||||
|
||||
{/* BTC 分时图 */}
|
||||
<Card title={t('cryptoTailMonitor.chart.btcTitle')}>
|
||||
<div
|
||||
ref={chartRef}
|
||||
style={{
|
||||
width: '100%',
|
||||
height: isMobile ? 200 : 240
|
||||
}}
|
||||
/>
|
||||
</Card>
|
||||
|
||||
{/* 市场分时图 */}
|
||||
<Card title={t('cryptoTailMonitor.chart.marketTitle')} style={{ marginTop: 16 }}>
|
||||
<div
|
||||
ref={marketChartRef}
|
||||
style={{
|
||||
width: '100%',
|
||||
height: isMobile ? 200 : 240
|
||||
}}
|
||||
/>
|
||||
</Card>
|
||||
|
||||
{/* 策略信息 */}
|
||||
<Card title={t('cryptoTailMonitor.strategyInfo.title')} style={{ marginTop: 16 }}>
|
||||
<Row gutter={[16, 8]}>
|
||||
<Col span={12}>
|
||||
<Text type="secondary">{t('cryptoTailMonitor.strategyInfo.market')}: </Text>
|
||||
<Text>{initData.marketTitle}</Text>
|
||||
</Col>
|
||||
<Col span={12}>
|
||||
<Text type="secondary">{t('cryptoTailMonitor.strategyInfo.interval')}: </Text>
|
||||
<Text>{initData.intervalSeconds === 300 ? '5m' : '15m'}</Text>
|
||||
</Col>
|
||||
<Col span={12}>
|
||||
<Text type="secondary">{t('cryptoTailMonitor.strategyInfo.account')}: </Text>
|
||||
<Text>{initData.accountName || `#${initData.accountId}`}</Text>
|
||||
</Col>
|
||||
<Col span={12}>
|
||||
<Text type="secondary">{t('cryptoTailMonitor.strategyInfo.spreadMode')}: </Text>
|
||||
<Text>{initData.minSpreadMode}</Text>
|
||||
{initData.minSpreadMode === 'FIXED' && initData.minSpreadValue && (
|
||||
<Text> ({formatNumber(initData.minSpreadValue, 4)})</Text>
|
||||
)}
|
||||
</Col>
|
||||
</Row>
|
||||
</Card>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default CryptoTailMonitor
|
||||
@@ -497,7 +497,9 @@ export const apiService = {
|
||||
marketOptions: () =>
|
||||
apiClient.post<ApiResponse<import('../types').CryptoTailMarketOptionDto[]>>('/crypto-tail-strategy/market-options', {}),
|
||||
autoMinSpread: (data: { intervalSeconds: number }) =>
|
||||
apiClient.post<ApiResponse<import('../types').CryptoTailAutoMinSpreadResponse>>('/crypto-tail-strategy/auto-min-spread', data)
|
||||
apiClient.post<ApiResponse<import('../types').CryptoTailAutoMinSpreadResponse>>('/crypto-tail-strategy/auto-min-spread', data),
|
||||
monitorInit: (strategyId: number) =>
|
||||
apiClient.post<ApiResponse<import('../types').CryptoTailMonitorInitResponse>>('/crypto-tail-strategy/monitor/init', { strategyId })
|
||||
},
|
||||
|
||||
/**
|
||||
|
||||
@@ -1107,3 +1107,97 @@ export interface CryptoTailMarketOptionDto {
|
||||
periodStartUnix: number
|
||||
endDate?: string
|
||||
}
|
||||
|
||||
/**
|
||||
* 尾盘策略监控初始化响应
|
||||
*/
|
||||
export interface CryptoTailMonitorInitResponse {
|
||||
/** 策略ID */
|
||||
strategyId: number
|
||||
/** 策略名称 */
|
||||
name: string
|
||||
/** 账户ID */
|
||||
accountId: number
|
||||
/** 账户名称 */
|
||||
accountName: string
|
||||
/** 市场 slug 前缀 */
|
||||
marketSlugPrefix: string
|
||||
/** 市场标题 */
|
||||
marketTitle: string
|
||||
/** 周期秒数 (300=5m, 900=15m) */
|
||||
intervalSeconds: number
|
||||
/** 当前周期开始时间 (Unix 秒) */
|
||||
periodStartUnix: number
|
||||
/** 时间窗口开始秒数 */
|
||||
windowStartSeconds: number
|
||||
/** 时间窗口结束秒数 */
|
||||
windowEndSeconds: number
|
||||
/** 最低价格 */
|
||||
minPrice: string
|
||||
/** 最高价格 */
|
||||
maxPrice: string
|
||||
/** 最小价差模式: NONE, FIXED, AUTO */
|
||||
minSpreadMode: string
|
||||
/** 价差方向: MIN(显示周期内最小价差), MAX(显示周期内最大价差) */
|
||||
spreadDirection?: string
|
||||
/** 最小价差数值 (FIXED 时有值) */
|
||||
minSpreadValue?: string
|
||||
/** 自动计算的最小价差 (Up方向) */
|
||||
autoMinSpreadUp?: string
|
||||
/** 自动计算的最小价差 (Down方向) */
|
||||
autoMinSpreadDown?: string
|
||||
/** BTC 开盘价 USDC(来自币安 K 线) */
|
||||
openPriceBtc?: string
|
||||
/** Up tokenId */
|
||||
tokenIdUp?: string
|
||||
/** Down tokenId */
|
||||
tokenIdDown?: string
|
||||
/** 当前时间 (毫秒时间戳) */
|
||||
currentTimestamp: number
|
||||
/** 是否启用 */
|
||||
enabled: boolean
|
||||
}
|
||||
|
||||
/**
|
||||
* 尾盘策略监控实时推送数据
|
||||
*/
|
||||
export interface CryptoTailMonitorPushData {
|
||||
/** 策略ID */
|
||||
strategyId: number
|
||||
/** 推送时间 (毫秒时间戳) */
|
||||
timestamp: number
|
||||
/** 当前周期开始时间 (Unix 秒) */
|
||||
periodStartUnix: number
|
||||
/** 当前价格 (Up方向,来自订单簿) */
|
||||
currentPriceUp?: string
|
||||
/** 当前价格 (Down方向,来自订单簿) */
|
||||
currentPriceDown?: string
|
||||
/** 当前价差 (Up方向: 1 - currentPriceUp) */
|
||||
spreadUp?: string
|
||||
/** 当前价差 (Down方向: currentPriceUp) */
|
||||
spreadDown?: string
|
||||
/** 最小价差线 (Up方向,USDC) */
|
||||
minSpreadLineUp?: string
|
||||
/** 最小价差线 (Down方向,USDC) */
|
||||
minSpreadLineDown?: string
|
||||
/** BTC 开盘价 USDC */
|
||||
openPriceBtc?: string
|
||||
/** BTC 最新价 USDC */
|
||||
currentPriceBtc?: string
|
||||
/** BTC 价差 USDC(currentPriceBtc - openPriceBtc) */
|
||||
spreadBtc?: string
|
||||
/** 周期剩余秒数 */
|
||||
remainingSeconds: number
|
||||
/** 是否在时间窗口内 */
|
||||
inTimeWindow: boolean
|
||||
/** 是否在价格区间内 (Up方向) */
|
||||
inPriceRangeUp: boolean
|
||||
/** 是否在价格区间内 (Down方向) */
|
||||
inPriceRangeDown: boolean
|
||||
/** 是否已触发 */
|
||||
triggered: boolean
|
||||
/** 触发方向: UP, DOWN, null */
|
||||
triggerDirection?: string
|
||||
/** 周期是否已结束 */
|
||||
periodEnded: boolean
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user