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,
|
||||
|
||||
@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",
|
||||
|
||||
+2
-2
@@ -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")
|
||||
|
||||
+36
-11
@@ -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
|
||||
}
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1413,6 +1413,8 @@
|
||||
"ratio": "比例",
|
||||
"fixed": "固定金额",
|
||||
"recentTrigger": "最近触发",
|
||||
"totalRealizedPnl": "总收益",
|
||||
"winRate": "胜率",
|
||||
"actions": "操作",
|
||||
"edit": "编辑",
|
||||
"enable": "启用",
|
||||
@@ -1462,7 +1464,9 @@
|
||||
"orderId": "订单 ID",
|
||||
"status": "状态",
|
||||
"success": "成功",
|
||||
"fail": "失败"
|
||||
"fail": "失败",
|
||||
"realizedPnl": "收益",
|
||||
"resolved": "已结算"
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1414,6 +1414,8 @@
|
||||
"ratio": "比例",
|
||||
"fixed": "固定金額",
|
||||
"recentTrigger": "最近觸發",
|
||||
"totalRealizedPnl": "總收益",
|
||||
"winRate": "勝率",
|
||||
"actions": "操作",
|
||||
"edit": "編輯",
|
||||
"enable": "啟用",
|
||||
@@ -1463,7 +1465,9 @@
|
||||
"orderId": "訂單 ID",
|
||||
"status": "狀態",
|
||||
"success": "成功",
|
||||
"fail": "失敗"
|
||||
"fail": "失敗",
|
||||
"realizedPnl": "收益",
|
||||
"resolved": "已結算"
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user