feat(crypto-tail): 触发记录与结算优化、前端收益与价格展示
后端: - 结算轮询仅处理下单成功订单(status=success 且 orderId 非空) - 实体通过 copy() 更新,不再直接改字段;结算时用订单 fill 回写 triggerPrice、amountUsdc - Trigger 实体结算相关字段改为 val,统一 copy+save 更新 前端: - 触发记录: 去掉市场列,保留触发价格(轮询后为实际成交价);每条收益列、涨跌色 - 策略列表: 总收益与胜率、涨跌色;价格区间格式 0.5 ~ 1 - 类型与 i18n: totalRealizedPnl/winRate/realizedPnl 等 Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -36,19 +36,19 @@ data class CryptoTailStrategyTrigger(
|
|||||||
val orderId: String? = null,
|
val orderId: String? = null,
|
||||||
|
|
||||||
@Column(name = "condition_id", length = 66)
|
@Column(name = "condition_id", length = 66)
|
||||||
var conditionId: String? = null,
|
val conditionId: String? = null,
|
||||||
|
|
||||||
@Column(name = "resolved", nullable = false)
|
@Column(name = "resolved", nullable = false)
|
||||||
var resolved: Boolean = false,
|
val resolved: Boolean = false,
|
||||||
|
|
||||||
@Column(name = "winner_outcome_index")
|
@Column(name = "winner_outcome_index")
|
||||||
var winnerOutcomeIndex: Int? = null,
|
val winnerOutcomeIndex: Int? = null,
|
||||||
|
|
||||||
@Column(name = "realized_pnl", precision = 20, scale = 8)
|
@Column(name = "realized_pnl", precision = 20, scale = 8)
|
||||||
var realizedPnl: BigDecimal? = null,
|
val realizedPnl: BigDecimal? = null,
|
||||||
|
|
||||||
@Column(name = "settled_at")
|
@Column(name = "settled_at")
|
||||||
var settledAt: Long? = null,
|
val settledAt: Long? = null,
|
||||||
|
|
||||||
@Column(name = "status", nullable = false, length = 20)
|
@Column(name = "status", nullable = false, length = 20)
|
||||||
val status: String = "success",
|
val status: String = "success",
|
||||||
|
|||||||
+2
-2
@@ -15,8 +15,8 @@ interface CryptoTailStrategyTriggerRepository : JpaRepository<CryptoTailStrategy
|
|||||||
fun findAllByStrategyIdAndStatusOrderByCreatedAtDesc(strategyId: Long, status: String, pageable: Pageable): Page<CryptoTailStrategyTrigger>
|
fun findAllByStrategyIdAndStatusOrderByCreatedAtDesc(strategyId: Long, status: String, pageable: Pageable): Page<CryptoTailStrategyTrigger>
|
||||||
fun countByStrategyIdAndStatus(strategyId: Long, status: String): Long
|
fun countByStrategyIdAndStatus(strategyId: Long, status: String): Long
|
||||||
|
|
||||||
/** 轮询结算:状态成功且未结算的触发记录(用于定时扫描并回写收益) */
|
/** 轮询结算:仅处理下单成功的订单(status=success 且 orderId 非空)、且未结算的触发记录 */
|
||||||
fun findByStatusAndResolvedOrderByCreatedAtAsc(status: String, resolved: Boolean): List<CryptoTailStrategyTrigger>
|
fun findByStatusAndResolvedAndOrderIdIsNotNullOrderByCreatedAtAsc(status: String, resolved: Boolean): List<CryptoTailStrategyTrigger>
|
||||||
|
|
||||||
/** 策略已结算订单的总已实现盈亏(用于收益统计) */
|
/** 策略已结算订单的总已实现盈亏(用于收益统计) */
|
||||||
@Query("SELECT COALESCE(SUM(t.realizedPnl), 0) FROM CryptoTailStrategyTrigger t WHERE t.strategyId = :strategyId AND t.resolved = true")
|
@Query("SELECT COALESCE(SUM(t.realizedPnl), 0) FROM CryptoTailStrategyTrigger t WHERE t.strategyId = :strategyId AND t.resolved = true")
|
||||||
|
|||||||
+36
-11
@@ -85,7 +85,7 @@ class CryptoTailSettlementService(
|
|||||||
}
|
}
|
||||||
|
|
||||||
private suspend fun doPollAndSettle(): Int {
|
private suspend fun doPollAndSettle(): Int {
|
||||||
val pending = triggerRepository.findByStatusAndResolvedOrderByCreatedAtAsc("success", false)
|
val pending = triggerRepository.findByStatusAndResolvedAndOrderIdIsNotNullOrderByCreatedAtAsc("success", false)
|
||||||
if (pending.isEmpty()) return 0
|
if (pending.isEmpty()) return 0
|
||||||
var settledCount = 0
|
var settledCount = 0
|
||||||
for (trigger in pending) {
|
for (trigger in pending) {
|
||||||
@@ -103,15 +103,36 @@ class CryptoTailSettlementService(
|
|||||||
|
|
||||||
/**
|
/**
|
||||||
* 处理单条触发记录:解析 conditionId -> 查链上结算 -> 若已结算则计算 pnl 并更新。
|
* 处理单条触发记录:解析 conditionId -> 查链上结算 -> 若已结算则计算 pnl 并更新。
|
||||||
|
* 通过 copy() 生成新实体再 save,不直接修改原实体;有订单信息时用实际成交价与投入金额更新 triggerPrice、amountUsdc。
|
||||||
* @return true 表示本条已结算并更新
|
* @return true 表示本条已结算并更新
|
||||||
*/
|
*/
|
||||||
private suspend fun settleOne(trigger: CryptoTailStrategyTrigger): Boolean {
|
private suspend fun settleOne(trigger: CryptoTailStrategyTrigger): Boolean {
|
||||||
if (trigger.resolved) return false
|
if (trigger.resolved) return false
|
||||||
val strategy = strategyRepository.findById(trigger.strategyId).orElse(null) ?: return false
|
val strategy = strategyRepository.findById(trigger.strategyId).orElse(null) ?: return false
|
||||||
val conditionId = resolveConditionId(strategy, trigger)
|
val fill = fetchOrderFill(trigger, strategy)
|
||||||
?: return false
|
val (newTriggerPrice, newAmountUsdc) = if (fill != null && fill.first.gt(BigDecimal.ZERO) && fill.second.gt(BigDecimal.ZERO)) {
|
||||||
val (_, payouts) = blockchainService.getCondition(conditionId).getOrNull() ?: return false
|
val price = fill.first
|
||||||
if (payouts.isEmpty()) return false
|
val cost = price.multi(fill.second).setScale(pnlScale, RoundingMode.HALF_UP)
|
||||||
|
Pair(price, cost)
|
||||||
|
} else {
|
||||||
|
Pair(trigger.triggerPrice, trigger.amountUsdc)
|
||||||
|
}
|
||||||
|
|
||||||
|
val conditionId = resolveConditionId(strategy, trigger) ?: return false
|
||||||
|
val (_, payouts) = blockchainService.getCondition(conditionId).getOrNull() ?: run {
|
||||||
|
if (fill != null) {
|
||||||
|
val updated = trigger.copy(triggerPrice = newTriggerPrice, amountUsdc = newAmountUsdc)
|
||||||
|
triggerRepository.save(updated)
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
if (payouts.isEmpty()) {
|
||||||
|
if (fill != null) {
|
||||||
|
val updated = trigger.copy(triggerPrice = newTriggerPrice, amountUsdc = newAmountUsdc)
|
||||||
|
triggerRepository.save(updated)
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
}
|
||||||
val winnerIndex = payouts.indexOfFirst { it == java.math.BigInteger.ONE }
|
val winnerIndex = payouts.indexOfFirst { it == java.math.BigInteger.ONE }
|
||||||
if (winnerIndex < 0) return false
|
if (winnerIndex < 0) return false
|
||||||
|
|
||||||
@@ -119,12 +140,16 @@ class CryptoTailSettlementService(
|
|||||||
val pnl = computePnlFromApiOrFallback(trigger, strategy, won)
|
val pnl = computePnlFromApiOrFallback(trigger, strategy, won)
|
||||||
val now = System.currentTimeMillis()
|
val now = System.currentTimeMillis()
|
||||||
|
|
||||||
trigger.conditionId = conditionId
|
val updated = trigger.copy(
|
||||||
trigger.resolved = true
|
triggerPrice = newTriggerPrice,
|
||||||
trigger.winnerOutcomeIndex = winnerIndex
|
amountUsdc = newAmountUsdc,
|
||||||
trigger.realizedPnl = pnl
|
conditionId = conditionId,
|
||||||
trigger.settledAt = now
|
resolved = true,
|
||||||
triggerRepository.save(trigger)
|
winnerOutcomeIndex = winnerIndex,
|
||||||
|
realizedPnl = pnl,
|
||||||
|
settledAt = now
|
||||||
|
)
|
||||||
|
triggerRepository.save(updated)
|
||||||
logger.debug("尾盘结算已更新: triggerId=${trigger.id}, winnerOutcomeIndex=$winnerIndex, won=$won, pnl=$pnl")
|
logger.debug("尾盘结算已更新: triggerId=${trigger.id}, winnerOutcomeIndex=$winnerIndex, won=$won, pnl=$pnl")
|
||||||
return true
|
return true
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1414,6 +1414,8 @@
|
|||||||
"ratio": "Ratio",
|
"ratio": "Ratio",
|
||||||
"fixed": "Fixed",
|
"fixed": "Fixed",
|
||||||
"recentTrigger": "Last Trigger",
|
"recentTrigger": "Last Trigger",
|
||||||
|
"totalRealizedPnl": "Total PnL",
|
||||||
|
"winRate": "Win Rate",
|
||||||
"actions": "Actions",
|
"actions": "Actions",
|
||||||
"edit": "Edit",
|
"edit": "Edit",
|
||||||
"enable": "Enable",
|
"enable": "Enable",
|
||||||
@@ -1463,7 +1465,9 @@
|
|||||||
"orderId": "Order ID",
|
"orderId": "Order ID",
|
||||||
"status": "Status",
|
"status": "Status",
|
||||||
"success": "Success",
|
"success": "Success",
|
||||||
"fail": "Fail"
|
"fail": "Fail",
|
||||||
|
"realizedPnl": "PnL",
|
||||||
|
"resolved": "Settled"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -1413,6 +1413,8 @@
|
|||||||
"ratio": "比例",
|
"ratio": "比例",
|
||||||
"fixed": "固定金额",
|
"fixed": "固定金额",
|
||||||
"recentTrigger": "最近触发",
|
"recentTrigger": "最近触发",
|
||||||
|
"totalRealizedPnl": "总收益",
|
||||||
|
"winRate": "胜率",
|
||||||
"actions": "操作",
|
"actions": "操作",
|
||||||
"edit": "编辑",
|
"edit": "编辑",
|
||||||
"enable": "启用",
|
"enable": "启用",
|
||||||
@@ -1462,7 +1464,9 @@
|
|||||||
"orderId": "订单 ID",
|
"orderId": "订单 ID",
|
||||||
"status": "状态",
|
"status": "状态",
|
||||||
"success": "成功",
|
"success": "成功",
|
||||||
"fail": "失败"
|
"fail": "失败",
|
||||||
|
"realizedPnl": "收益",
|
||||||
|
"resolved": "已结算"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -1414,6 +1414,8 @@
|
|||||||
"ratio": "比例",
|
"ratio": "比例",
|
||||||
"fixed": "固定金額",
|
"fixed": "固定金額",
|
||||||
"recentTrigger": "最近觸發",
|
"recentTrigger": "最近觸發",
|
||||||
|
"totalRealizedPnl": "總收益",
|
||||||
|
"winRate": "勝率",
|
||||||
"actions": "操作",
|
"actions": "操作",
|
||||||
"edit": "編輯",
|
"edit": "編輯",
|
||||||
"enable": "啟用",
|
"enable": "啟用",
|
||||||
@@ -1463,7 +1465,9 @@
|
|||||||
"orderId": "訂單 ID",
|
"orderId": "訂單 ID",
|
||||||
"status": "狀態",
|
"status": "狀態",
|
||||||
"success": "成功",
|
"success": "成功",
|
||||||
"fail": "失敗"
|
"fail": "失敗",
|
||||||
|
"realizedPnl": "收益",
|
||||||
|
"resolved": "已結算"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -24,7 +24,7 @@ import { useMediaQuery } from 'react-responsive'
|
|||||||
import { apiService } from '../services/api'
|
import { apiService } from '../services/api'
|
||||||
import { useAccountStore } from '../store/accountStore'
|
import { useAccountStore } from '../store/accountStore'
|
||||||
import type { CryptoTailStrategyDto, CryptoTailStrategyTriggerDto, CryptoTailMarketOptionDto } from '../types'
|
import type { CryptoTailStrategyDto, CryptoTailStrategyTriggerDto, CryptoTailMarketOptionDto } from '../types'
|
||||||
import { formatUSDC } from '../utils'
|
import { formatUSDC, formatNumber } from '../utils'
|
||||||
|
|
||||||
const CryptoTailStrategyList: React.FC = () => {
|
const CryptoTailStrategyList: React.FC = () => {
|
||||||
const { t } = useTranslation()
|
const { t } = useTranslation()
|
||||||
@@ -258,6 +258,22 @@ const CryptoTailStrategyList: React.FC = () => {
|
|||||||
return d.toLocaleString()
|
return d.toLocaleString()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const formatPriceRange = (minPrice: string, maxPrice: string): string => {
|
||||||
|
const min = formatNumber(minPrice, 2)
|
||||||
|
const max = formatNumber(maxPrice, 2)
|
||||||
|
if (min === '' || max === '') return '-'
|
||||||
|
return `${min} ~ ${max}`
|
||||||
|
}
|
||||||
|
|
||||||
|
const pnlColor = (value: string | number | null | undefined): string | undefined => {
|
||||||
|
if (value == null || value === '') return undefined
|
||||||
|
const num = typeof value === 'string' ? Number(value) : value
|
||||||
|
if (Number.isNaN(num)) return undefined
|
||||||
|
if (num > 0) return '#52c41a'
|
||||||
|
if (num < 0) return '#ff4d4f'
|
||||||
|
return undefined
|
||||||
|
}
|
||||||
|
|
||||||
const columns = [
|
const columns = [
|
||||||
{
|
{
|
||||||
title: t('cryptoTailStrategy.list.strategyName'),
|
title: t('cryptoTailStrategy.list.strategyName'),
|
||||||
@@ -283,7 +299,7 @@ const CryptoTailStrategyList: React.FC = () => {
|
|||||||
title: t('cryptoTailStrategy.list.priceRange'),
|
title: t('cryptoTailStrategy.list.priceRange'),
|
||||||
key: 'priceRange',
|
key: 'priceRange',
|
||||||
width: isMobile ? 90 : 120,
|
width: isMobile ? 90 : 120,
|
||||||
render: (_: unknown, r: CryptoTailStrategyDto) => `[${r.minPrice}, ${r.maxPrice}]`
|
render: (_: unknown, r: CryptoTailStrategyDto) => formatPriceRange(r.minPrice, r.maxPrice)
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
title: t('cryptoTailStrategy.list.amountMode'),
|
title: t('cryptoTailStrategy.list.amountMode'),
|
||||||
@@ -315,6 +331,23 @@ const CryptoTailStrategyList: React.FC = () => {
|
|||||||
width: isMobile ? 100 : 160,
|
width: isMobile ? 100 : 160,
|
||||||
render: (ts: number | undefined) => formatLastTrigger(ts)
|
render: (ts: number | undefined) => formatLastTrigger(ts)
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
title: t('cryptoTailStrategy.list.totalRealizedPnl'),
|
||||||
|
key: 'totalRealizedPnl',
|
||||||
|
width: isMobile ? 90 : 110,
|
||||||
|
render: (_: unknown, r: CryptoTailStrategyDto) => {
|
||||||
|
const text = r.totalRealizedPnl != null ? `${formatUSDC(r.totalRealizedPnl)} USDC` : '-'
|
||||||
|
const color = pnlColor(r.totalRealizedPnl)
|
||||||
|
return color ? <span style={{ color }}>{text}</span> : text
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: t('cryptoTailStrategy.list.winRate'),
|
||||||
|
key: 'winRate',
|
||||||
|
width: isMobile ? 70 : 80,
|
||||||
|
render: (_: unknown, r: CryptoTailStrategyDto) =>
|
||||||
|
r.winRate != null ? `${(Number(r.winRate) * 100).toFixed(1)}%` : '-'
|
||||||
|
},
|
||||||
{
|
{
|
||||||
title: t('cryptoTailStrategy.list.actions'),
|
title: t('cryptoTailStrategy.list.actions'),
|
||||||
key: 'actions',
|
key: 'actions',
|
||||||
@@ -410,10 +443,21 @@ const CryptoTailStrategyList: React.FC = () => {
|
|||||||
{t('cryptoTailStrategy.list.timeWindow')}: {formatTimeWindow(item.windowStartSeconds, item.windowEndSeconds)}
|
{t('cryptoTailStrategy.list.timeWindow')}: {formatTimeWindow(item.windowStartSeconds, item.windowEndSeconds)}
|
||||||
</div>
|
</div>
|
||||||
<div style={{ fontSize: 12, color: '#666', marginBottom: 4 }}>
|
<div style={{ fontSize: 12, color: '#666', marginBottom: 4 }}>
|
||||||
{t('cryptoTailStrategy.list.priceRange')}: [{item.minPrice}, {item.maxPrice}]
|
{t('cryptoTailStrategy.list.priceRange')}: {formatPriceRange(item.minPrice, item.maxPrice)}
|
||||||
|
</div>
|
||||||
|
<div style={{ fontSize: 12, color: '#666', marginBottom: 4 }}>
|
||||||
|
{item.amountMode === 'RATIO' ? `${item.amountValue}%` : `${formatUSDC(item.amountValue)} USDC`}
|
||||||
</div>
|
</div>
|
||||||
<div style={{ fontSize: 12, color: '#666', marginBottom: 8 }}>
|
<div style={{ fontSize: 12, color: '#666', marginBottom: 8 }}>
|
||||||
{item.amountMode === 'RATIO' ? `${item.amountValue}%` : `${formatUSDC(item.amountValue)} USDC`}
|
{t('cryptoTailStrategy.list.totalRealizedPnl')}:{' '}
|
||||||
|
{item.totalRealizedPnl != null ? (
|
||||||
|
<span style={{ color: pnlColor(item.totalRealizedPnl) ?? '#666' }}>
|
||||||
|
{formatUSDC(item.totalRealizedPnl)} USDC
|
||||||
|
</span>
|
||||||
|
) : (
|
||||||
|
'-'
|
||||||
|
)}
|
||||||
|
{item.winRate != null ? ` · ${t('cryptoTailStrategy.list.winRate')}: ${(Number(item.winRate) * 100).toFixed(1)}%` : ''}
|
||||||
</div>
|
</div>
|
||||||
<Space>
|
<Space>
|
||||||
<Switch
|
<Switch
|
||||||
@@ -602,12 +646,6 @@ const CryptoTailStrategyList: React.FC = () => {
|
|||||||
key: 'createdAt',
|
key: 'createdAt',
|
||||||
render: (ts: number) => new Date(ts).toLocaleString()
|
render: (ts: number) => new Date(ts).toLocaleString()
|
||||||
},
|
},
|
||||||
{
|
|
||||||
title: t('cryptoTailStrategy.triggerRecords.market'),
|
|
||||||
dataIndex: 'marketTitle',
|
|
||||||
key: 'marketTitle',
|
|
||||||
ellipsis: true
|
|
||||||
},
|
|
||||||
{
|
{
|
||||||
title: t('cryptoTailStrategy.triggerRecords.direction'),
|
title: t('cryptoTailStrategy.triggerRecords.direction'),
|
||||||
dataIndex: 'outcomeIndex',
|
dataIndex: 'outcomeIndex',
|
||||||
@@ -617,7 +655,8 @@ const CryptoTailStrategyList: React.FC = () => {
|
|||||||
{
|
{
|
||||||
title: t('cryptoTailStrategy.triggerRecords.triggerPrice'),
|
title: t('cryptoTailStrategy.triggerRecords.triggerPrice'),
|
||||||
dataIndex: 'triggerPrice',
|
dataIndex: 'triggerPrice',
|
||||||
key: 'triggerPrice'
|
key: 'triggerPrice',
|
||||||
|
render: (v: string) => (formatNumber(v, 2) || '-')
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
title: t('cryptoTailStrategy.triggerRecords.amount'),
|
title: t('cryptoTailStrategy.triggerRecords.amount'),
|
||||||
@@ -640,6 +679,19 @@ const CryptoTailStrategyList: React.FC = () => {
|
|||||||
{s === 'success' ? t('cryptoTailStrategy.triggerRecords.success') : t('cryptoTailStrategy.triggerRecords.fail')}
|
{s === 'success' ? t('cryptoTailStrategy.triggerRecords.success') : t('cryptoTailStrategy.triggerRecords.fail')}
|
||||||
</Tag>
|
</Tag>
|
||||||
)
|
)
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: t('cryptoTailStrategy.triggerRecords.realizedPnl'),
|
||||||
|
dataIndex: 'realizedPnl',
|
||||||
|
key: 'realizedPnl',
|
||||||
|
render: (v: string | undefined, r: CryptoTailStrategyTriggerDto) => {
|
||||||
|
if (v == null || v === '') return r.resolved ? formatUSDC('0') : '-'
|
||||||
|
const num = Number(v)
|
||||||
|
const formatted = formatUSDC(String(Math.abs(num)))
|
||||||
|
const text = num >= 0 ? `+${formatted}` : `-${formatted}`
|
||||||
|
const color = pnlColor(v)
|
||||||
|
return color ? <span style={{ color }}>{text}</span> : text
|
||||||
|
}
|
||||||
}
|
}
|
||||||
]}
|
]}
|
||||||
pagination={false}
|
pagination={false}
|
||||||
|
|||||||
@@ -1053,6 +1053,12 @@ export interface CryptoTailStrategyDto {
|
|||||||
amountValue: string
|
amountValue: string
|
||||||
enabled: boolean
|
enabled: boolean
|
||||||
lastTriggerAt?: number
|
lastTriggerAt?: number
|
||||||
|
/** 已实现总收益 USDC */
|
||||||
|
totalRealizedPnl?: string
|
||||||
|
settledCount?: number
|
||||||
|
winCount?: number
|
||||||
|
/** 胜率 0~1 */
|
||||||
|
winRate?: string
|
||||||
createdAt: number
|
createdAt: number
|
||||||
updatedAt: number
|
updatedAt: number
|
||||||
}
|
}
|
||||||
@@ -1071,6 +1077,11 @@ export interface CryptoTailStrategyTriggerDto {
|
|||||||
orderId?: string
|
orderId?: string
|
||||||
status: string
|
status: string
|
||||||
failReason?: string
|
failReason?: string
|
||||||
|
resolved?: boolean
|
||||||
|
/** 已实现盈亏 USDC(结算后有值) */
|
||||||
|
realizedPnl?: string
|
||||||
|
winnerOutcomeIndex?: number
|
||||||
|
settledAt?: number
|
||||||
createdAt: number
|
createdAt: number
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user