diff --git a/backend/src/main/kotlin/com/wrbug/polymarketbot/entity/CryptoTailStrategyTrigger.kt b/backend/src/main/kotlin/com/wrbug/polymarketbot/entity/CryptoTailStrategyTrigger.kt index 50cc554..030e474 100644 --- a/backend/src/main/kotlin/com/wrbug/polymarketbot/entity/CryptoTailStrategyTrigger.kt +++ b/backend/src/main/kotlin/com/wrbug/polymarketbot/entity/CryptoTailStrategyTrigger.kt @@ -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", diff --git a/backend/src/main/kotlin/com/wrbug/polymarketbot/repository/CryptoTailStrategyTriggerRepository.kt b/backend/src/main/kotlin/com/wrbug/polymarketbot/repository/CryptoTailStrategyTriggerRepository.kt index e28986a..cca2976 100644 --- a/backend/src/main/kotlin/com/wrbug/polymarketbot/repository/CryptoTailStrategyTriggerRepository.kt +++ b/backend/src/main/kotlin/com/wrbug/polymarketbot/repository/CryptoTailStrategyTriggerRepository.kt @@ -15,8 +15,8 @@ interface CryptoTailStrategyTriggerRepository : JpaRepository fun countByStrategyIdAndStatus(strategyId: Long, status: String): Long - /** 轮询结算:状态成功且未结算的触发记录(用于定时扫描并回写收益) */ - fun findByStatusAndResolvedOrderByCreatedAtAsc(status: String, resolved: Boolean): List + /** 轮询结算:仅处理下单成功的订单(status=success 且 orderId 非空)、且未结算的触发记录 */ + fun findByStatusAndResolvedAndOrderIdIsNotNullOrderByCreatedAtAsc(status: String, resolved: Boolean): List /** 策略已结算订单的总已实现盈亏(用于收益统计) */ @Query("SELECT COALESCE(SUM(t.realizedPnl), 0) FROM CryptoTailStrategyTrigger t WHERE t.strategyId = :strategyId AND t.resolved = true") diff --git a/backend/src/main/kotlin/com/wrbug/polymarketbot/service/cryptotail/CryptoTailSettlementService.kt b/backend/src/main/kotlin/com/wrbug/polymarketbot/service/cryptotail/CryptoTailSettlementService.kt index c388397..013fe06 100644 --- a/backend/src/main/kotlin/com/wrbug/polymarketbot/service/cryptotail/CryptoTailSettlementService.kt +++ b/backend/src/main/kotlin/com/wrbug/polymarketbot/service/cryptotail/CryptoTailSettlementService.kt @@ -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 } diff --git a/frontend/src/locales/en/common.json b/frontend/src/locales/en/common.json index 21cd436..3b33065 100644 --- a/frontend/src/locales/en/common.json +++ b/frontend/src/locales/en/common.json @@ -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" } } } \ No newline at end of file diff --git a/frontend/src/locales/zh-CN/common.json b/frontend/src/locales/zh-CN/common.json index 912f458..5ac5aa6 100644 --- a/frontend/src/locales/zh-CN/common.json +++ b/frontend/src/locales/zh-CN/common.json @@ -1413,6 +1413,8 @@ "ratio": "比例", "fixed": "固定金额", "recentTrigger": "最近触发", + "totalRealizedPnl": "总收益", + "winRate": "胜率", "actions": "操作", "edit": "编辑", "enable": "启用", @@ -1462,7 +1464,9 @@ "orderId": "订单 ID", "status": "状态", "success": "成功", - "fail": "失败" + "fail": "失败", + "realizedPnl": "收益", + "resolved": "已结算" } } } \ No newline at end of file diff --git a/frontend/src/locales/zh-TW/common.json b/frontend/src/locales/zh-TW/common.json index e106988..bcf95ce 100644 --- a/frontend/src/locales/zh-TW/common.json +++ b/frontend/src/locales/zh-TW/common.json @@ -1414,6 +1414,8 @@ "ratio": "比例", "fixed": "固定金額", "recentTrigger": "最近觸發", + "totalRealizedPnl": "總收益", + "winRate": "勝率", "actions": "操作", "edit": "編輯", "enable": "啟用", @@ -1463,7 +1465,9 @@ "orderId": "訂單 ID", "status": "狀態", "success": "成功", - "fail": "失敗" + "fail": "失敗", + "realizedPnl": "收益", + "resolved": "已結算" } } } \ No newline at end of file diff --git a/frontend/src/pages/CryptoTailStrategyList.tsx b/frontend/src/pages/CryptoTailStrategyList.tsx index b7a7a71..fb99ca4 100644 --- a/frontend/src/pages/CryptoTailStrategyList.tsx +++ b/frontend/src/pages/CryptoTailStrategyList.tsx @@ -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 ? {text} : 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)}
- {t('cryptoTailStrategy.list.priceRange')}: [{item.minPrice}, {item.maxPrice}] + {t('cryptoTailStrategy.list.priceRange')}: {formatPriceRange(item.minPrice, item.maxPrice)} +
+
+ {item.amountMode === 'RATIO' ? `${item.amountValue}%` : `${formatUSDC(item.amountValue)} USDC`}
- {item.amountMode === 'RATIO' ? `${item.amountValue}%` : `${formatUSDC(item.amountValue)} USDC`} + {t('cryptoTailStrategy.list.totalRealizedPnl')}:{' '} + {item.totalRealizedPnl != null ? ( + + {formatUSDC(item.totalRealizedPnl)} USDC + + ) : ( + '-' + )} + {item.winRate != null ? ` · ${t('cryptoTailStrategy.list.winRate')}: ${(Number(item.winRate) * 100).toFixed(1)}%` : ''}
{ 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')} ) + }, + { + 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 ? {text} : text + } } ]} pagination={false} diff --git a/frontend/src/types/index.ts b/frontend/src/types/index.ts index 4ef799e..dff74ea 100644 --- a/frontend/src/types/index.ts +++ b/frontend/src/types/index.ts @@ -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 }