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:
WrBug
2026-02-14 05:44:19 +08:00
parent 0efd4a5fc3
commit f200ba7f7c
8 changed files with 132 additions and 32 deletions
@@ -36,19 +36,19 @@ data class CryptoTailStrategyTrigger(
val orderId: String? = null,
@Column(name = "condition_id", length = 66)
var conditionId: String? = null,
val conditionId: String? = null,
@Column(name = "resolved", nullable = false)
var resolved: Boolean = false,
val resolved: Boolean = false,
@Column(name = "winner_outcome_index")
var winnerOutcomeIndex: Int? = null,
val winnerOutcomeIndex: Int? = null,
@Column(name = "realized_pnl", precision = 20, scale = 8)
var realizedPnl: BigDecimal? = null,
val realizedPnl: BigDecimal? = null,
@Column(name = "settled_at")
var settledAt: Long? = null,
val settledAt: Long? = null,
@Column(name = "status", nullable = false, length = 20)
val status: String = "success",
@@ -15,8 +15,8 @@ interface CryptoTailStrategyTriggerRepository : JpaRepository<CryptoTailStrategy
fun findAllByStrategyIdAndStatusOrderByCreatedAtDesc(strategyId: Long, status: String, pageable: Pageable): Page<CryptoTailStrategyTrigger>
fun countByStrategyIdAndStatus(strategyId: Long, status: String): Long
/** 轮询结算:状态成功且未结算的触发记录(用于定时扫描并回写收益) */
fun findByStatusAndResolvedOrderByCreatedAtAsc(status: String, resolved: Boolean): List<CryptoTailStrategyTrigger>
/** 轮询结算:仅处理下单成功的订单(status=success 且 orderId 非空)、且未结算的触发记录 */
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")
@@ -85,7 +85,7 @@ class CryptoTailSettlementService(
}
private suspend fun doPollAndSettle(): Int {
val pending = triggerRepository.findByStatusAndResolvedOrderByCreatedAtAsc("success", false)
val pending = triggerRepository.findByStatusAndResolvedAndOrderIdIsNotNullOrderByCreatedAtAsc("success", false)
if (pending.isEmpty()) return 0
var settledCount = 0
for (trigger in pending) {
@@ -103,15 +103,36 @@ class CryptoTailSettlementService(
/**
* 处理单条触发记录:解析 conditionId -> 查链上结算 -> 若已结算则计算 pnl 并更新。
* 通过 copy() 生成新实体再 save,不直接修改原实体;有订单信息时用实际成交价与投入金额更新 triggerPrice、amountUsdc。
* @return true 表示本条已结算并更新
*/
private suspend fun settleOne(trigger: CryptoTailStrategyTrigger): Boolean {
if (trigger.resolved) return false
val strategy = strategyRepository.findById(trigger.strategyId).orElse(null) ?: return false
val conditionId = resolveConditionId(strategy, trigger)
?: return false
val (_, payouts) = blockchainService.getCondition(conditionId).getOrNull() ?: return false
if (payouts.isEmpty()) return false
val fill = fetchOrderFill(trigger, strategy)
val (newTriggerPrice, newAmountUsdc) = if (fill != null && fill.first.gt(BigDecimal.ZERO) && fill.second.gt(BigDecimal.ZERO)) {
val price = fill.first
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 }
if (winnerIndex < 0) return false
@@ -119,12 +140,16 @@ class CryptoTailSettlementService(
val pnl = computePnlFromApiOrFallback(trigger, strategy, won)
val now = System.currentTimeMillis()
trigger.conditionId = conditionId
trigger.resolved = true
trigger.winnerOutcomeIndex = winnerIndex
trigger.realizedPnl = pnl
trigger.settledAt = now
triggerRepository.save(trigger)
val updated = trigger.copy(
triggerPrice = newTriggerPrice,
amountUsdc = newAmountUsdc,
conditionId = conditionId,
resolved = true,
winnerOutcomeIndex = winnerIndex,
realizedPnl = pnl,
settledAt = now
)
triggerRepository.save(updated)
logger.debug("尾盘结算已更新: triggerId=${trigger.id}, winnerOutcomeIndex=$winnerIndex, won=$won, pnl=$pnl")
return true
}
+5 -1
View File
@@ -1414,6 +1414,8 @@
"ratio": "Ratio",
"fixed": "Fixed",
"recentTrigger": "Last Trigger",
"totalRealizedPnl": "Total PnL",
"winRate": "Win Rate",
"actions": "Actions",
"edit": "Edit",
"enable": "Enable",
@@ -1463,7 +1465,9 @@
"orderId": "Order ID",
"status": "Status",
"success": "Success",
"fail": "Fail"
"fail": "Fail",
"realizedPnl": "PnL",
"resolved": "Settled"
}
}
}
+5 -1
View File
@@ -1413,6 +1413,8 @@
"ratio": "比例",
"fixed": "固定金额",
"recentTrigger": "最近触发",
"totalRealizedPnl": "总收益",
"winRate": "胜率",
"actions": "操作",
"edit": "编辑",
"enable": "启用",
@@ -1462,7 +1464,9 @@
"orderId": "订单 ID",
"status": "状态",
"success": "成功",
"fail": "失败"
"fail": "失败",
"realizedPnl": "收益",
"resolved": "已结算"
}
}
}
+5 -1
View File
@@ -1414,6 +1414,8 @@
"ratio": "比例",
"fixed": "固定金額",
"recentTrigger": "最近觸發",
"totalRealizedPnl": "總收益",
"winRate": "勝率",
"actions": "操作",
"edit": "編輯",
"enable": "啟用",
@@ -1463,7 +1465,9 @@
"orderId": "訂單 ID",
"status": "狀態",
"success": "成功",
"fail": "失敗"
"fail": "失敗",
"realizedPnl": "收益",
"resolved": "已結算"
}
}
}
+63 -11
View File
@@ -24,7 +24,7 @@ import { useMediaQuery } from 'react-responsive'
import { apiService } from '../services/api'
import { useAccountStore } from '../store/accountStore'
import type { CryptoTailStrategyDto, CryptoTailStrategyTriggerDto, CryptoTailMarketOptionDto } from '../types'
import { formatUSDC } from '../utils'
import { formatUSDC, formatNumber } from '../utils'
const CryptoTailStrategyList: React.FC = () => {
const { t } = useTranslation()
@@ -258,6 +258,22 @@ const CryptoTailStrategyList: React.FC = () => {
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 = [
{
title: t('cryptoTailStrategy.list.strategyName'),
@@ -283,7 +299,7 @@ const CryptoTailStrategyList: React.FC = () => {
title: t('cryptoTailStrategy.list.priceRange'),
key: 'priceRange',
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'),
@@ -315,6 +331,23 @@ const CryptoTailStrategyList: React.FC = () => {
width: isMobile ? 100 : 160,
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'),
key: 'actions',
@@ -410,10 +443,21 @@ const CryptoTailStrategyList: React.FC = () => {
{t('cryptoTailStrategy.list.timeWindow')}: {formatTimeWindow(item.windowStartSeconds, item.windowEndSeconds)}
</div>
<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 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>
<Space>
<Switch
@@ -602,12 +646,6 @@ const CryptoTailStrategyList: React.FC = () => {
key: 'createdAt',
render: (ts: number) => new Date(ts).toLocaleString()
},
{
title: t('cryptoTailStrategy.triggerRecords.market'),
dataIndex: 'marketTitle',
key: 'marketTitle',
ellipsis: true
},
{
title: t('cryptoTailStrategy.triggerRecords.direction'),
dataIndex: 'outcomeIndex',
@@ -617,7 +655,8 @@ const CryptoTailStrategyList: React.FC = () => {
{
title: t('cryptoTailStrategy.triggerRecords.triggerPrice'),
dataIndex: 'triggerPrice',
key: 'triggerPrice'
key: 'triggerPrice',
render: (v: string) => (formatNumber(v, 2) || '-')
},
{
title: t('cryptoTailStrategy.triggerRecords.amount'),
@@ -640,6 +679,19 @@ const CryptoTailStrategyList: React.FC = () => {
{s === 'success' ? t('cryptoTailStrategy.triggerRecords.success') : t('cryptoTailStrategy.triggerRecords.fail')}
</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}
+11
View File
@@ -1053,6 +1053,12 @@ export interface CryptoTailStrategyDto {
amountValue: string
enabled: boolean
lastTriggerAt?: number
/** 已实现总收益 USDC */
totalRealizedPnl?: string
settledCount?: number
winCount?: number
/** 胜率 0~1 */
winRate?: string
createdAt: number
updatedAt: number
}
@@ -1071,6 +1077,11 @@ export interface CryptoTailStrategyTriggerDto {
orderId?: string
status: string
failReason?: string
resolved?: boolean
/** 已实现盈亏 USDC(结算后有值) */
realizedPnl?: string
winnerOutcomeIndex?: number
settledAt?: number
createdAt: number
}