Merge branch 'dev'
Made-with: Cursor
This commit is contained in:
@@ -212,6 +212,7 @@ data class NewOrderResponse(
|
||||
val success: Boolean, // boolean indicating if server-side error
|
||||
@SerializedName("errorMsg")
|
||||
val errorMsg: String? = null, // error message in case of unsuccessful placement
|
||||
val error: String? = null, // error message (alternative field, e.g. "Trading restricted in your region...")
|
||||
@SerializedName("orderID")
|
||||
val orderId: String? = null, // id of order(API 返回字段名为 orderID)
|
||||
@SerializedName("transactionsHashes")
|
||||
@@ -222,7 +223,17 @@ data class NewOrderResponse(
|
||||
val takingAmount: String? = null, // taking amount
|
||||
@SerializedName("makingAmount")
|
||||
val makingAmount: String? = null // making amount
|
||||
)
|
||||
) {
|
||||
/**
|
||||
* 获取错误信息的便捷方法
|
||||
* 优先返回 errorMsg,其次返回 error,最后返回默认消息
|
||||
*/
|
||||
fun getErrorMessage(): String {
|
||||
return errorMsg?.takeIf { it.isNotBlank() }
|
||||
?: error?.takeIf { it.isNotBlank() }
|
||||
?: "创建订单失败"
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 旧的订单请求格式(已废弃,保留用于兼容)
|
||||
|
||||
+64
@@ -13,10 +13,15 @@ import com.wrbug.polymarketbot.dto.CryptoTailMarketOptionDto
|
||||
import com.wrbug.polymarketbot.dto.CryptoTailAutoMinSpreadResponse
|
||||
import com.wrbug.polymarketbot.dto.CryptoTailMonitorInitRequest
|
||||
import com.wrbug.polymarketbot.dto.CryptoTailMonitorInitResponse
|
||||
import com.wrbug.polymarketbot.dto.CryptoTailManualOrderRequest
|
||||
import com.wrbug.polymarketbot.dto.CryptoTailManualOrderResponse
|
||||
import com.wrbug.polymarketbot.dto.CryptoTailPnlCurveRequest
|
||||
import com.wrbug.polymarketbot.dto.CryptoTailPnlCurveResponse
|
||||
import com.wrbug.polymarketbot.enums.ErrorCode
|
||||
import com.wrbug.polymarketbot.service.binance.BinanceKlineAutoSpreadService
|
||||
import com.wrbug.polymarketbot.service.cryptotail.CryptoTailStrategyService
|
||||
import com.wrbug.polymarketbot.service.cryptotail.CryptoTailMonitorService
|
||||
import com.wrbug.polymarketbot.service.cryptotail.CryptoTailStrategyExecutionService
|
||||
import org.slf4j.LoggerFactory
|
||||
import org.springframework.context.MessageSource
|
||||
import org.springframework.http.ResponseEntity
|
||||
@@ -24,12 +29,14 @@ import org.springframework.web.bind.annotation.PostMapping
|
||||
import org.springframework.web.bind.annotation.RequestBody
|
||||
import org.springframework.web.bind.annotation.RequestMapping
|
||||
import org.springframework.web.bind.annotation.RestController
|
||||
import kotlinx.coroutines.runBlocking
|
||||
|
||||
@RestController
|
||||
@RequestMapping("/api/crypto-tail-strategy")
|
||||
class CryptoTailStrategyController(
|
||||
private val cryptoTailStrategyService: CryptoTailStrategyService,
|
||||
private val cryptoTailMonitorService: CryptoTailMonitorService,
|
||||
private val cryptoTailStrategyExecutionService: CryptoTailStrategyExecutionService,
|
||||
private val binanceKlineAutoSpreadService: BinanceKlineAutoSpreadService,
|
||||
private val messageSource: MessageSource
|
||||
) {
|
||||
@@ -125,6 +132,26 @@ class CryptoTailStrategyController(
|
||||
}
|
||||
}
|
||||
|
||||
@PostMapping("/pnl-curve")
|
||||
fun getPnlCurve(@RequestBody request: CryptoTailPnlCurveRequest): ResponseEntity<ApiResponse<CryptoTailPnlCurveResponse>> {
|
||||
return try {
|
||||
if (request.strategyId <= 0) {
|
||||
return ResponseEntity.ok(ApiResponse.error(ErrorCode.CRYPTO_TAIL_STRATEGY_NOT_FOUND, messageSource = messageSource))
|
||||
}
|
||||
val result = cryptoTailStrategyService.getPnlCurve(request)
|
||||
result.fold(
|
||||
onSuccess = { ResponseEntity.ok(ApiResponse.success(it)) },
|
||||
onFailure = { e ->
|
||||
logger.error("查询收益曲线失败: ${e.message}", e)
|
||||
ResponseEntity.ok(ApiResponse.error(ErrorCode.SERVER_CRYPTO_TAIL_STRATEGY_TRIGGERS_FETCH_FAILED, e.message, messageSource))
|
||||
}
|
||||
)
|
||||
} catch (e: Exception) {
|
||||
logger.error("查询收益曲线异常: ${e.message}", e)
|
||||
ResponseEntity.ok(ApiResponse.error(ErrorCode.SERVER_CRYPTO_TAIL_STRATEGY_TRIGGERS_FETCH_FAILED, e.message, messageSource))
|
||||
}
|
||||
}
|
||||
|
||||
@PostMapping("/triggers")
|
||||
fun getTriggerRecords(@RequestBody request: CryptoTailStrategyTriggerListRequest): ResponseEntity<ApiResponse<CryptoTailStrategyTriggerListResponse>> {
|
||||
return try {
|
||||
@@ -216,4 +243,41 @@ class CryptoTailStrategyController(
|
||||
ResponseEntity.ok(ApiResponse.error(ErrorCode.SERVER_ERROR, e.message, messageSource))
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 手动下单
|
||||
* 用户主动触发下单,不检查任何条件,仅检查当前周期是否已下单
|
||||
*/
|
||||
@PostMapping("/manual-order")
|
||||
fun manualOrder(@RequestBody request: CryptoTailManualOrderRequest): ResponseEntity<ApiResponse<CryptoTailManualOrderResponse>> {
|
||||
return runBlocking {
|
||||
try {
|
||||
if (request.strategyId <= 0) {
|
||||
return@runBlocking ResponseEntity.ok(
|
||||
ApiResponse.error(ErrorCode.CRYPTO_TAIL_STRATEGY_NOT_FOUND, messageSource = messageSource)
|
||||
)
|
||||
}
|
||||
val result = cryptoTailStrategyExecutionService.manualOrder(request)
|
||||
result.fold(
|
||||
onSuccess = { ResponseEntity.ok(ApiResponse.success(it)) },
|
||||
onFailure = { e ->
|
||||
logger.error("手动下单失败: ${e.message}", e)
|
||||
val code = when (e.message) {
|
||||
"策略不存在" -> ErrorCode.CRYPTO_TAIL_STRATEGY_NOT_FOUND
|
||||
"当前周期已下单" -> ErrorCode.PARAM_ERROR
|
||||
"价格必须在 0~1 之间" -> ErrorCode.PARAM_ERROR
|
||||
"数量不能少于 1" -> ErrorCode.PARAM_ERROR
|
||||
"总金额不能少于 1 USDC" -> ErrorCode.PARAM_ERROR
|
||||
"总金额超过策略配置的投入金额" -> ErrorCode.PARAM_ERROR
|
||||
else -> ErrorCode.SERVER_ERROR
|
||||
}
|
||||
ResponseEntity.ok(ApiResponse.error(code, e.message, messageSource))
|
||||
}
|
||||
)
|
||||
} catch (e: Exception) {
|
||||
logger.error("手动下单异常: ${e.message}", e)
|
||||
ResponseEntity.ok(ApiResponse.error(ErrorCode.SERVER_ERROR, e.message, messageSource))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+166
@@ -3,6 +3,7 @@ package com.wrbug.polymarketbot.controller.system
|
||||
import com.wrbug.polymarketbot.dto.*
|
||||
import com.wrbug.polymarketbot.enums.ErrorCode
|
||||
import com.wrbug.polymarketbot.service.system.NotificationConfigService
|
||||
import com.wrbug.polymarketbot.service.system.NotificationTemplateService
|
||||
import com.wrbug.polymarketbot.service.system.TelegramNotificationService
|
||||
import kotlinx.coroutines.runBlocking
|
||||
import org.slf4j.LoggerFactory
|
||||
@@ -18,6 +19,7 @@ import org.springframework.web.bind.annotation.*
|
||||
class NotificationController(
|
||||
private val notificationConfigService: NotificationConfigService,
|
||||
private val telegramNotificationService: TelegramNotificationService,
|
||||
private val notificationTemplateService: NotificationTemplateService,
|
||||
private val messageSource: MessageSource
|
||||
) {
|
||||
|
||||
@@ -335,6 +337,155 @@ class NotificationController(
|
||||
))
|
||||
}
|
||||
}
|
||||
|
||||
// ==================== 模板相关 API ====================
|
||||
|
||||
/**
|
||||
* 获取所有模板类型
|
||||
*/
|
||||
@PostMapping("/templates/types")
|
||||
fun getTemplateTypes(): ResponseEntity<ApiResponse<List<TemplateTypeInfoDto>>> {
|
||||
return try {
|
||||
val types = notificationTemplateService.getTemplateTypes()
|
||||
ResponseEntity.ok(ApiResponse.success(types))
|
||||
} catch (e: Exception) {
|
||||
logger.error("获取模板类型失败: ${e.message}", e)
|
||||
ResponseEntity.ok(ApiResponse.error(ErrorCode.SERVER_ERROR, messageSource = messageSource))
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取所有模板
|
||||
*/
|
||||
@PostMapping("/templates/list")
|
||||
fun getTemplates(): ResponseEntity<ApiResponse<List<NotificationTemplateDto>>> {
|
||||
return try {
|
||||
val templates = notificationTemplateService.getAllTemplates()
|
||||
ResponseEntity.ok(ApiResponse.success(templates))
|
||||
} catch (e: Exception) {
|
||||
logger.error("获取模板列表失败: ${e.message}", e)
|
||||
ResponseEntity.ok(ApiResponse.error(ErrorCode.SERVER_ERROR, messageSource = messageSource))
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取单个模板
|
||||
*/
|
||||
@PostMapping("/templates/detail")
|
||||
fun getTemplateDetail(@RequestBody request: TemplateDetailRequest): ResponseEntity<ApiResponse<NotificationTemplateDto>> {
|
||||
return try {
|
||||
if (request.templateType.isBlank()) {
|
||||
return ResponseEntity.ok(ApiResponse.paramError("模板类型不能为空"))
|
||||
}
|
||||
|
||||
val template = notificationTemplateService.getTemplate(request.templateType)
|
||||
if (template == null) {
|
||||
ResponseEntity.ok(ApiResponse.error(ErrorCode.NOT_FOUND, messageSource = messageSource))
|
||||
} else {
|
||||
ResponseEntity.ok(ApiResponse.success(template))
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
logger.error("获取模板详情失败: ${e.message}", e)
|
||||
ResponseEntity.ok(ApiResponse.error(ErrorCode.SERVER_ERROR, messageSource = messageSource))
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取模板可用变量
|
||||
*/
|
||||
@PostMapping("/templates/variables")
|
||||
fun getTemplateVariables(@RequestBody request: TemplateDetailRequest): ResponseEntity<ApiResponse<TemplateVariablesResponse>> {
|
||||
return try {
|
||||
if (request.templateType.isBlank()) {
|
||||
return ResponseEntity.ok(ApiResponse.paramError("模板类型不能为空"))
|
||||
}
|
||||
|
||||
val variables = notificationTemplateService.getTemplateVariables(request.templateType)
|
||||
if (variables == null) {
|
||||
ResponseEntity.ok(ApiResponse.error(ErrorCode.NOT_FOUND, messageSource = messageSource))
|
||||
} else {
|
||||
ResponseEntity.ok(ApiResponse.success(variables))
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
logger.error("获取模板变量失败: ${e.message}", e)
|
||||
ResponseEntity.ok(ApiResponse.error(ErrorCode.SERVER_ERROR, messageSource = messageSource))
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 更新模板
|
||||
*/
|
||||
@PostMapping("/templates/update")
|
||||
fun updateTemplate(@RequestBody request: UpdateTemplateRequestWithId): ResponseEntity<ApiResponse<NotificationTemplateDto>> {
|
||||
return try {
|
||||
if (request.templateType.isBlank()) {
|
||||
return ResponseEntity.ok(ApiResponse.paramError("模板类型不能为空"))
|
||||
}
|
||||
if (request.templateContent.isBlank()) {
|
||||
return ResponseEntity.ok(ApiResponse.paramError("模板内容不能为空"))
|
||||
}
|
||||
|
||||
val template = notificationTemplateService.updateTemplate(request.templateType, request.templateContent)
|
||||
ResponseEntity.ok(ApiResponse.success(template))
|
||||
} catch (e: Exception) {
|
||||
logger.error("更新模板失败: ${e.message}", e)
|
||||
ResponseEntity.ok(ApiResponse.error(ErrorCode.SERVER_ERROR, messageSource = messageSource))
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 重置模板为默认
|
||||
*/
|
||||
@PostMapping("/templates/reset")
|
||||
fun resetTemplate(@RequestBody request: TemplateDetailRequest): ResponseEntity<ApiResponse<NotificationTemplateDto>> {
|
||||
return try {
|
||||
if (request.templateType.isBlank()) {
|
||||
return ResponseEntity.ok(ApiResponse.paramError("模板类型不能为空"))
|
||||
}
|
||||
|
||||
val template = notificationTemplateService.resetTemplate(request.templateType)
|
||||
if (template == null) {
|
||||
ResponseEntity.ok(ApiResponse.error(ErrorCode.NOT_FOUND, messageSource = messageSource))
|
||||
} else {
|
||||
ResponseEntity.ok(ApiResponse.success(template))
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
logger.error("重置模板失败: ${e.message}", e)
|
||||
ResponseEntity.ok(ApiResponse.error(ErrorCode.SERVER_ERROR, messageSource = messageSource))
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 发送模板测试消息
|
||||
*/
|
||||
@PostMapping("/templates/test")
|
||||
fun testTemplate(@RequestBody request: TestTemplateRequest): ResponseEntity<ApiResponse<Boolean>> {
|
||||
return try {
|
||||
if (request.templateType.isBlank()) {
|
||||
return ResponseEntity.ok(ApiResponse.paramError("模板类型不能为空"))
|
||||
}
|
||||
|
||||
val success = runBlocking {
|
||||
notificationTemplateService.sendTestMessage(request.templateType, request.templateContent)
|
||||
}
|
||||
|
||||
if (success) {
|
||||
ResponseEntity.ok(ApiResponse.success(true))
|
||||
} else {
|
||||
ResponseEntity.ok(ApiResponse.error(
|
||||
ErrorCode.NOTIFICATION_TEST_FAILED,
|
||||
messageSource = messageSource
|
||||
))
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
logger.error("发送模板测试消息失败: ${e.message}", e)
|
||||
ResponseEntity.ok(ApiResponse.error(
|
||||
ErrorCode.NOTIFICATION_TEST_FAILED,
|
||||
customMsg = "发送测试消息失败:${e.message}",
|
||||
messageSource = messageSource
|
||||
))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -384,3 +535,18 @@ data class NotificationConfigDeleteRequest(
|
||||
val id: Long
|
||||
)
|
||||
|
||||
/**
|
||||
* 模板详情请求
|
||||
*/
|
||||
data class TemplateDetailRequest(
|
||||
val templateType: String
|
||||
)
|
||||
|
||||
/**
|
||||
* 更新模板请求(带类型)
|
||||
*/
|
||||
data class UpdateTemplateRequestWithId(
|
||||
val templateType: String,
|
||||
val templateContent: String
|
||||
)
|
||||
|
||||
|
||||
@@ -0,0 +1,21 @@
|
||||
package com.wrbug.polymarketbot.dto
|
||||
|
||||
/**
|
||||
* 加密价差策略手动下单请求
|
||||
*/
|
||||
data class CryptoTailManualOrderRequest(
|
||||
/** 策略ID */
|
||||
val strategyId: Long = 0L,
|
||||
/** 当前周期开始时间 (Unix 秒) */
|
||||
val periodStartUnix: Long = 0L,
|
||||
/** 下单方向: UP or DOWN */
|
||||
val direction: String = "UP",
|
||||
/** 下单价格 */
|
||||
val price: String = "0",
|
||||
/** 下单数量 */
|
||||
val size: String = "1",
|
||||
/** 市场标题(用于记录) */
|
||||
val marketTitle: String = "",
|
||||
/** Token IDs */
|
||||
val tokenIds: List<String> = emptyList()
|
||||
)
|
||||
@@ -0,0 +1,31 @@
|
||||
package com.wrbug.polymarketbot.dto
|
||||
|
||||
/**
|
||||
* 加密价差策略手动下单响应
|
||||
*/
|
||||
data class CryptoTailManualOrderResponse(
|
||||
/** 是否成功 */
|
||||
val success: Boolean = false,
|
||||
/** 订单ID */
|
||||
val orderId: String? = null,
|
||||
/** 提示消息 */
|
||||
val message: String = "",
|
||||
/** 下单详情 */
|
||||
val orderDetails: ManualOrderDetails? = null
|
||||
)
|
||||
|
||||
/**
|
||||
* 手动下单详情
|
||||
*/
|
||||
data class ManualOrderDetails(
|
||||
/** 策略ID */
|
||||
val strategyId: Long = 0L,
|
||||
/** 方向 */
|
||||
val direction: String = "",
|
||||
/** 下单价格 */
|
||||
val price: String = "",
|
||||
/** 下单数量 */
|
||||
val size: String = "",
|
||||
/** 总金额 */
|
||||
val totalAmount: String = ""
|
||||
)
|
||||
@@ -5,7 +5,9 @@ package com.wrbug.polymarketbot.dto
|
||||
*/
|
||||
data class CryptoTailMonitorInitRequest(
|
||||
/** 策略ID */
|
||||
val strategyId: Long = 0L
|
||||
val strategyId: Long = 0L,
|
||||
/** 指定周期开始时间 (Unix 秒),不传则用服务器当前周期 */
|
||||
val periodStartUnix: Long? = null
|
||||
)
|
||||
|
||||
/**
|
||||
@@ -55,7 +57,11 @@ data class CryptoTailMonitorInitResponse(
|
||||
/** 当前时间 (毫秒时间戳) */
|
||||
val currentTimestamp: Long = System.currentTimeMillis(),
|
||||
/** 是否启用 */
|
||||
val enabled: Boolean = true
|
||||
val enabled: Boolean = true,
|
||||
/** 投入金额模式: FIXED or RATIO */
|
||||
val amountMode: String? = null,
|
||||
/** 投入金额数值 */
|
||||
val amountValue: String? = null
|
||||
)
|
||||
|
||||
/**
|
||||
|
||||
@@ -167,3 +167,45 @@ data class CryptoTailMarketOptionDto(
|
||||
val periodStartUnix: Long = 0L,
|
||||
val endDate: String? = null
|
||||
)
|
||||
|
||||
/**
|
||||
* 收益曲线请求
|
||||
* @param strategyId 策略ID
|
||||
* @param startDate 开始时间(毫秒时间戳),null 表示不限制
|
||||
* @param endDate 结束时间(毫秒时间戳),null 表示不限制
|
||||
*/
|
||||
data class CryptoTailPnlCurveRequest(
|
||||
val strategyId: Long = 0L,
|
||||
val startDate: Long? = null,
|
||||
val endDate: Long? = null
|
||||
)
|
||||
|
||||
/**
|
||||
* 收益曲线单点数据
|
||||
*/
|
||||
data class CryptoTailPnlCurvePoint(
|
||||
/** 时间点(毫秒时间戳,结算时间或创建时间) */
|
||||
val timestamp: Long = 0L,
|
||||
/** 累计收益 USDC */
|
||||
val cumulativePnl: String = "0",
|
||||
/** 当笔收益 USDC */
|
||||
val pointPnl: String = "0",
|
||||
/** 截至该点累计已结算笔数 */
|
||||
val settledCount: Long = 0L
|
||||
)
|
||||
|
||||
/**
|
||||
* 收益曲线响应
|
||||
*/
|
||||
data class CryptoTailPnlCurveResponse(
|
||||
val strategyId: Long = 0L,
|
||||
val strategyName: String = "",
|
||||
/** 筛选范围内总已实现收益 USDC */
|
||||
val totalRealizedPnl: String = "0",
|
||||
val settledCount: Long = 0L,
|
||||
val winCount: Long = 0L,
|
||||
val winRate: String? = null,
|
||||
/** 最大回撤 USDC(正数表示回撤幅度) */
|
||||
val maxDrawdown: String? = null,
|
||||
val curveData: List<CryptoTailPnlCurvePoint> = emptyList()
|
||||
)
|
||||
|
||||
@@ -0,0 +1,63 @@
|
||||
package com.wrbug.polymarketbot.dto
|
||||
|
||||
/**
|
||||
* 消息模板 DTO
|
||||
*/
|
||||
data class NotificationTemplateDto(
|
||||
val id: Long? = null,
|
||||
val templateType: String, // 模板类型
|
||||
val templateContent: String, // 模板内容
|
||||
val isDefault: Boolean = false, // 是否使用默认模板
|
||||
val createdAt: Long? = null,
|
||||
val updatedAt: Long? = null
|
||||
)
|
||||
|
||||
/**
|
||||
* 模板变量 DTO
|
||||
*/
|
||||
data class TemplateVariableDto(
|
||||
val key: String, // 变量名,如 account_name
|
||||
val category: String, // 分类:common, order, copy_trading, redeem, error
|
||||
val sortOrder: Int = 0 // 排序顺序
|
||||
)
|
||||
|
||||
/**
|
||||
* 模板变量分类 DTO
|
||||
*/
|
||||
data class TemplateVariableCategoryDto(
|
||||
val key: String, // 分类 key
|
||||
val sortOrder: Int = 0 // 排序顺序
|
||||
)
|
||||
|
||||
/**
|
||||
* 模板变量列表响应
|
||||
*/
|
||||
data class TemplateVariablesResponse(
|
||||
val templateType: String, // 模板类型
|
||||
val categories: List<TemplateVariableCategoryDto>, // 分类列表
|
||||
val variables: List<TemplateVariableDto> // 变量列表
|
||||
)
|
||||
|
||||
/**
|
||||
* 更新模板请求
|
||||
*/
|
||||
data class UpdateTemplateRequest(
|
||||
val templateContent: String // 模板内容
|
||||
)
|
||||
|
||||
/**
|
||||
* 测试模板请求
|
||||
*/
|
||||
data class TestTemplateRequest(
|
||||
val templateType: String, // 模板类型
|
||||
val templateContent: String? = null // 可选,如果不提供则使用已保存的模板
|
||||
)
|
||||
|
||||
/**
|
||||
* 模板类型信息
|
||||
*/
|
||||
data class TemplateTypeInfoDto(
|
||||
val type: String, // 模板类型
|
||||
val name: String, // 类型名称
|
||||
val description: String // 类型描述
|
||||
)
|
||||
@@ -51,18 +51,21 @@ data class OrderPushMessage(
|
||||
|
||||
/**
|
||||
* 订单详情(通过 API 获取)
|
||||
* @param price 订单限价(用户提交的买入/卖出价)
|
||||
* @param avgFilledPrice 实际成交价 = original_size * price / size_matched(有成交时优先用于推送展示)
|
||||
*/
|
||||
data class OrderDetailDto(
|
||||
val id: String, // 订单 ID
|
||||
val market: String, // 市场 ID (condition ID)
|
||||
val side: String, // BUY/SELL
|
||||
val price: String, // 价格
|
||||
val price: String, // 订单限价
|
||||
val size: String, // 订单大小
|
||||
val filled: String, // 已成交数量
|
||||
val status: String, // 订单状态
|
||||
val createdAt: String, // 创建时间(ISO 8601 格式)
|
||||
val marketName: String? = null, // 市场名称(通过 Data API 获取)
|
||||
val marketSlug: String? = null, // 市场 slug
|
||||
val marketIcon: String? = null // 市场图标
|
||||
val marketIcon: String? = null, // 市场图标
|
||||
val avgFilledPrice: String? = null // 实际成交价 = original_size*price/size_matched(有成交时使用)
|
||||
)
|
||||
|
||||
|
||||
@@ -56,6 +56,9 @@ data class CryptoTailStrategyTrigger(
|
||||
@Column(name = "fail_reason", length = 500)
|
||||
val failReason: String? = null,
|
||||
|
||||
@Column(name = "trigger_type", nullable = false, length = 20)
|
||||
val triggerType: String = "AUTO",
|
||||
|
||||
@Column(name = "created_at", nullable = false)
|
||||
val createdAt: Long = System.currentTimeMillis(),
|
||||
|
||||
|
||||
@@ -0,0 +1,30 @@
|
||||
package com.wrbug.polymarketbot.entity
|
||||
|
||||
import jakarta.persistence.*
|
||||
|
||||
/**
|
||||
* 消息推送模板实体
|
||||
* 用于存储用户自定义的消息模板
|
||||
*/
|
||||
@Entity
|
||||
@Table(name = "notification_templates")
|
||||
data class NotificationTemplate(
|
||||
@Id
|
||||
@GeneratedValue(strategy = GenerationType.IDENTITY)
|
||||
val id: Long? = null,
|
||||
|
||||
@Column(name = "template_type", unique = true, nullable = false, length = 50)
|
||||
val templateType: String, // ORDER_SUCCESS, ORDER_FAILED, ORDER_FILTERED, CRYPTO_TAIL_SUCCESS, REDEEM_SUCCESS, REDEEM_NO_RETURN
|
||||
|
||||
@Column(name = "template_content", nullable = false, columnDefinition = "TEXT")
|
||||
var templateContent: String, // 模板内容,支持 {{variable}} 变量
|
||||
|
||||
@Column(name = "is_default", nullable = false)
|
||||
var isDefault: Boolean = false, // 是否使用默认模板
|
||||
|
||||
@Column(name = "created_at", nullable = false)
|
||||
val createdAt: Long = System.currentTimeMillis(),
|
||||
|
||||
@Column(name = "updated_at", nullable = false)
|
||||
var updatedAt: Long = System.currentTimeMillis()
|
||||
)
|
||||
+12
@@ -40,4 +40,16 @@ interface CryptoTailStrategyTriggerRepository : JpaRepository<CryptoTailStrategy
|
||||
/** 策略已结算中赢的笔数(outcome_index = winner_outcome_index) */
|
||||
@Query("SELECT COUNT(t) FROM CryptoTailStrategyTrigger t WHERE t.strategyId = :strategyId AND t.resolved = true AND t.outcomeIndex = t.winnerOutcomeIndex")
|
||||
fun countWinsByStrategyId(@Param("strategyId") strategyId: Long): Long
|
||||
|
||||
/** 收益曲线:已结算记录,按结算时间(无则创建时间)在区间内升序 */
|
||||
@Query(
|
||||
"SELECT t FROM CryptoTailStrategyTrigger t WHERE t.strategyId = :strategyId AND t.resolved = true " +
|
||||
"AND COALESCE(t.settledAt, t.createdAt) >= :start AND COALESCE(t.settledAt, t.createdAt) <= :end " +
|
||||
"ORDER BY COALESCE(t.settledAt, t.createdAt) ASC"
|
||||
)
|
||||
fun findResolvedByStrategyIdAndTimeRangeOrderBySettledAsc(
|
||||
@Param("strategyId") strategyId: Long,
|
||||
@Param("start") start: Long,
|
||||
@Param("end") end: Long
|
||||
): List<CryptoTailStrategyTrigger>
|
||||
}
|
||||
|
||||
+11
@@ -0,0 +1,11 @@
|
||||
package com.wrbug.polymarketbot.repository
|
||||
|
||||
import com.wrbug.polymarketbot.entity.NotificationTemplate
|
||||
import org.springframework.data.jpa.repository.JpaRepository
|
||||
import org.springframework.stereotype.Repository
|
||||
|
||||
@Repository
|
||||
interface NotificationTemplateRepository : JpaRepository<NotificationTemplate, Long> {
|
||||
fun findByTemplateType(templateType: String): NotificationTemplate?
|
||||
fun existsByTemplateType(templateType: String): Boolean
|
||||
}
|
||||
+65
-16
@@ -10,7 +10,10 @@ import com.wrbug.polymarketbot.util.toSafeBigDecimal
|
||||
import com.wrbug.polymarketbot.util.eq
|
||||
import com.wrbug.polymarketbot.util.gt
|
||||
import com.wrbug.polymarketbot.util.JsonUtils
|
||||
import com.wrbug.polymarketbot.util.fromJson
|
||||
import com.wrbug.polymarketbot.util.getEventSlug
|
||||
import com.google.gson.JsonObject
|
||||
import com.google.gson.JsonPrimitive
|
||||
import com.wrbug.polymarketbot.service.common.PolymarketClobService
|
||||
import com.wrbug.polymarketbot.service.common.BlockchainService
|
||||
import com.wrbug.polymarketbot.service.common.MarketService
|
||||
@@ -1331,13 +1334,22 @@ class AccountService(
|
||||
|
||||
// 使用当前时间作为订单创建时间
|
||||
val orderTime = System.currentTimeMillis()
|
||||
|
||||
// 查询可用余额
|
||||
val availableBalance = try {
|
||||
blockchainService.getUsdcBalance(account.walletAddress, account.proxyAddress).getOrNull()
|
||||
} catch (e: Exception) {
|
||||
logger.warn("查询可用余额失败: accountId=${account.id}, ${e.message}")
|
||||
null
|
||||
}
|
||||
|
||||
telegramNotificationService?.sendOrderSuccessNotification(
|
||||
orderId = orderId,
|
||||
marketTitle = marketTitle,
|
||||
marketId = request.marketId,
|
||||
marketSlug = marketSlug,
|
||||
side = request.side,
|
||||
side = "SELL", // 手动卖出订单,方向固定为 SELL
|
||||
outcome = request.side, // request.side 是市场方向(YES/NO)
|
||||
price = sellPrice, // 直接传递卖出价格
|
||||
size = sellQuantity.toPlainString(), // 直接传递卖出数量
|
||||
accountName = account.accountName,
|
||||
@@ -1348,7 +1360,8 @@ class AccountService(
|
||||
apiPassphrase = try { cryptoUtils.decrypt(account.apiPassphrase!!) } catch (e: Exception) { null },
|
||||
walletAddressForApi = account.walletAddress,
|
||||
locale = locale,
|
||||
orderTime = orderTime // 使用订单创建时间
|
||||
orderTime = orderTime, // 使用订单创建时间
|
||||
availableBalance = availableBalance
|
||||
)
|
||||
} catch (e: Exception) {
|
||||
logger.warn("发送订单成功通知失败: ${e.message}", e)
|
||||
@@ -1368,7 +1381,7 @@ class AccountService(
|
||||
)
|
||||
)
|
||||
} else {
|
||||
val errorMsg = response.errorMsg ?: "未知错误"
|
||||
val errorMsg = response.getErrorMessage()
|
||||
val fullErrorMsg = "创建订单失败: accountId=${account.id}, marketId=${request.marketId}, side=${request.side}, orderType=${request.orderType}, price=${if (request.orderType == "LIMIT") sellPrice else "MARKET"}, quantity=${sellQuantity.toPlainString()}, errorMsg=$errorMsg"
|
||||
logger.error(fullErrorMsg)
|
||||
|
||||
@@ -1413,6 +1426,14 @@ class AccountService(
|
||||
} catch (e: Exception) {
|
||||
null
|
||||
}
|
||||
|
||||
// 尝试从 errorBody 解析 error 字段(使用 Gson)
|
||||
val apiError = try {
|
||||
(errorBody?.fromJson<JsonObject>()?.get("error") as? JsonPrimitive)?.asString
|
||||
} catch (e: Exception) {
|
||||
null
|
||||
}
|
||||
|
||||
val fullErrorMsg = "创建订单失败: accountId=${account.id}, marketId=${request.marketId}, side=${request.side}, orderType=${request.orderType}, price=${if (request.orderType == "LIMIT") sellPrice else "MARKET"}, quantity=${sellQuantity.toPlainString()}, code=${orderResponse.code()}, message=${orderResponse.message()}${if (errorBody != null) ", errorBody=$errorBody" else ""}"
|
||||
logger.error(fullErrorMsg)
|
||||
|
||||
@@ -1431,8 +1452,10 @@ class AccountService(
|
||||
java.util.Locale("zh", "CN") // 默认简体中文
|
||||
}
|
||||
|
||||
// 只传递后端返回的 msg,不传递完整堆栈
|
||||
val errorMsg = orderResponse.body()?.errorMsg ?: "创建订单失败"
|
||||
// 优先使用解析的 API error,其次使用响应体的 errorMsg,最后使用默认消息
|
||||
val errorMsg = apiError
|
||||
?: orderResponse.body()?.getErrorMessage()
|
||||
?: "创建订单失败 (HTTP ${orderResponse.code()})"
|
||||
|
||||
telegramNotificationService?.sendOrderFailureNotification(
|
||||
marketTitle = marketTitle,
|
||||
@@ -1442,7 +1465,7 @@ class AccountService(
|
||||
outcome = null, // 失败时可能没有 outcome
|
||||
price = if (request.orderType == "LIMIT") sellPrice.toString() else "MARKET",
|
||||
size = sellQuantity.toString(),
|
||||
errorMessage = errorMsg, // 只传递后端返回的 msg
|
||||
errorMessage = errorMsg, // 只传递后端返回的错误信息
|
||||
accountName = account.accountName,
|
||||
walletAddress = account.walletAddress,
|
||||
locale = locale
|
||||
@@ -1800,16 +1823,42 @@ class AccountService(
|
||||
for (transaction in accountTransactions) {
|
||||
val account = accounts[transaction.accountId]
|
||||
if (account != null) {
|
||||
telegramNotificationService?.sendRedeemNotification(
|
||||
accountName = account.accountName,
|
||||
walletAddress = account.walletAddress,
|
||||
transactionHash = transaction.transactionHash,
|
||||
totalRedeemedValue = transaction.positions.fold(BigDecimal.ZERO) { sum, info ->
|
||||
sum.add(info.value.toSafeBigDecimal())
|
||||
}.toPlainString(),
|
||||
positions = transaction.positions,
|
||||
locale = locale
|
||||
)
|
||||
// 查询可用余额
|
||||
val availableBalance = try {
|
||||
blockchainService.getUsdcBalance(account.walletAddress, account.proxyAddress).getOrNull()
|
||||
} catch (e: Exception) {
|
||||
logger.warn("查询可用余额失败: accountId=${account.id}, ${e.message}")
|
||||
null
|
||||
}
|
||||
|
||||
// 计算该账户的赎回总价值
|
||||
val accountTotalValue = transaction.positions.fold(BigDecimal.ZERO) { sum, info ->
|
||||
sum.add(info.value.toSafeBigDecimal())
|
||||
}
|
||||
|
||||
// 根据赎回价值选择不同的通知类型
|
||||
if (accountTotalValue.gt(BigDecimal.ZERO)) {
|
||||
// 有收益:发送赎回成功通知
|
||||
telegramNotificationService?.sendRedeemNotification(
|
||||
accountName = account.accountName,
|
||||
walletAddress = account.walletAddress,
|
||||
transactionHash = transaction.transactionHash,
|
||||
totalRedeemedValue = accountTotalValue.toPlainString(),
|
||||
positions = transaction.positions,
|
||||
locale = locale,
|
||||
availableBalance = availableBalance
|
||||
)
|
||||
} else {
|
||||
// 无收益(输的仓位):发送已结算无收益通知
|
||||
telegramNotificationService?.sendRedeemNoReturnNotification(
|
||||
accountName = account.accountName,
|
||||
walletAddress = account.walletAddress,
|
||||
transactionHash = transaction.transactionHash,
|
||||
positions = transaction.positions,
|
||||
locale = locale,
|
||||
availableBalance = availableBalance
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
|
||||
+6
-1
@@ -91,7 +91,12 @@ class BinanceKlineService {
|
||||
}
|
||||
}.toSet()
|
||||
val wsKeysNeeded = parsed.map { (_, symbol, interval) -> "$symbol-$interval" }.toSet()
|
||||
if (normalized == requiredMarketPrefixes.get()) return
|
||||
|
||||
// 检查是否有需要的 WebSocket 连接缺失(可能因网络问题断开)
|
||||
val hasMissingConnection = wsKeysNeeded.any { it !in connectedWebSockets.keys }
|
||||
|
||||
// 只有当集合相同且所有需要的连接都存在时才跳过
|
||||
if (normalized == requiredMarketPrefixes.get() && !hasMissingConnection) return
|
||||
requiredMarketPrefixes.set(normalized)
|
||||
synchronized(subscriptionLock) {
|
||||
connectedWebSockets.keys.toList().forEach { wsKey ->
|
||||
|
||||
+1
-1
@@ -401,7 +401,7 @@ class PolymarketClobService(
|
||||
Result.failure(e)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* 获取费率
|
||||
* 文档: https://docs.polymarket.com/developers/market-makers/maker-rebates-program#1-fetch-the-fee-rate
|
||||
|
||||
+18
-4
@@ -20,7 +20,12 @@ import com.wrbug.polymarketbot.repository.CopyTradingRepository
|
||||
import com.wrbug.polymarketbot.repository.LeaderRepository
|
||||
import com.wrbug.polymarketbot.constants.PolymarketConstants
|
||||
import com.wrbug.polymarketbot.service.common.MarketService
|
||||
import com.wrbug.polymarketbot.util.div
|
||||
import com.wrbug.polymarketbot.util.gt
|
||||
import com.wrbug.polymarketbot.util.multi
|
||||
import com.wrbug.polymarketbot.util.toSafeBigDecimal
|
||||
import org.springframework.stereotype.Service
|
||||
import java.math.BigDecimal
|
||||
import java.util.concurrent.ConcurrentHashMap
|
||||
|
||||
/**
|
||||
@@ -426,20 +431,29 @@ class OrderPushService(
|
||||
// 获取市场信息(使用 MarketService,优先从数据库/缓存获取)
|
||||
val market = marketService.getMarket(conditionId ?: openOrder.market)
|
||||
|
||||
// 转换为 DTO
|
||||
// 有成交时按公式计算实际成交价:original_size * price / size_matched,数量用 size_matched
|
||||
val sizeMatched = openOrder.sizeMatched.toSafeBigDecimal()
|
||||
val avgFilledPrice = if (sizeMatched.gt(BigDecimal.ZERO)) {
|
||||
openOrder.originalSize.toSafeBigDecimal()
|
||||
.multi(openOrder.price)
|
||||
.div(sizeMatched, 18)
|
||||
} else null
|
||||
|
||||
// 转换为 DTO(展示数量用 size_matched)
|
||||
// 注意:createdAt 是 unix timestamp (Long),需要转换为字符串
|
||||
OrderDetailDto(
|
||||
id = openOrder.id,
|
||||
market = openOrder.market,
|
||||
side = openOrder.side,
|
||||
price = openOrder.price,
|
||||
size = openOrder.originalSize, // 使用 original_size
|
||||
filled = openOrder.sizeMatched, // 使用 size_matched
|
||||
size = openOrder.originalSize,
|
||||
filled = openOrder.sizeMatched, // 已成交数量用 size_matched
|
||||
status = openOrder.status,
|
||||
createdAt = openOrder.createdAt.toString(), // unix timestamp 转换为字符串
|
||||
marketName = market?.title,
|
||||
marketSlug = market?.slug, // 显示用的 slug
|
||||
marketIcon = market?.icon
|
||||
marketIcon = market?.icon,
|
||||
avgFilledPrice = avgFilledPrice?.toPlainString() // 实际成交价 = original_size*price/size_matched
|
||||
)
|
||||
},
|
||||
onFailure = { e ->
|
||||
|
||||
+56
-11
@@ -7,8 +7,10 @@ import com.wrbug.polymarketbot.service.common.MarketService
|
||||
import com.wrbug.polymarketbot.service.system.TelegramNotificationService
|
||||
import com.wrbug.polymarketbot.util.RetrofitFactory
|
||||
import com.wrbug.polymarketbot.util.CryptoUtils
|
||||
import com.wrbug.polymarketbot.util.toSafeBigDecimal
|
||||
import com.wrbug.polymarketbot.util.div
|
||||
import com.wrbug.polymarketbot.util.gt
|
||||
import com.wrbug.polymarketbot.util.multi
|
||||
import com.wrbug.polymarketbot.util.toSafeBigDecimal
|
||||
import kotlinx.coroutines.*
|
||||
import org.slf4j.LoggerFactory
|
||||
import org.springframework.boot.context.event.ApplicationReadyEvent
|
||||
@@ -38,7 +40,8 @@ class OrderStatusUpdateService(
|
||||
private val cryptoUtils: CryptoUtils,
|
||||
private val trackingService: CopyOrderTrackingService,
|
||||
private val marketService: MarketService, // 市场信息服务
|
||||
private val telegramNotificationService: TelegramNotificationService?
|
||||
private val telegramNotificationService: TelegramNotificationService?,
|
||||
private val blockchainService: com.wrbug.polymarketbot.service.common.BlockchainService
|
||||
) : ApplicationContextAware {
|
||||
|
||||
private val logger = LoggerFactory.getLogger(OrderStatusUpdateService::class.java)
|
||||
@@ -555,11 +558,13 @@ class OrderStatusUpdateService(
|
||||
|
||||
logger.info("更新卖出订单价格成功: orderId=${record.sellOrderId}, 原价格=${record.sellPrice}, 新价格=$actualSellPrice")
|
||||
|
||||
// 发送通知(使用实际价格)
|
||||
// 发送通知(使用实际成交价)
|
||||
sendSellOrderNotification(
|
||||
record = updatedRecord,
|
||||
actualPrice = actualSellPrice.toString(),
|
||||
actualSize = record.totalMatchedQuantity.toString(),
|
||||
avgFilledPrice = actualSellPrice.toString(),
|
||||
filled = record.totalMatchedQuantity.toString(),
|
||||
account = account,
|
||||
copyTrading = copyTrading,
|
||||
clobApi = clobApi,
|
||||
@@ -588,11 +593,13 @@ class OrderStatusUpdateService(
|
||||
|
||||
logger.debug("卖出订单价格无需更新: orderId=${record.sellOrderId}, price=$actualSellPrice")
|
||||
|
||||
// 发送通知
|
||||
// 发送通知(使用实际成交价)
|
||||
sendSellOrderNotification(
|
||||
record = updatedRecord,
|
||||
actualPrice = actualSellPrice.toString(),
|
||||
actualSize = record.totalMatchedQuantity.toString(),
|
||||
avgFilledPrice = actualSellPrice.toString(),
|
||||
filled = record.totalMatchedQuantity.toString(),
|
||||
account = account,
|
||||
copyTrading = copyTrading,
|
||||
clobApi = clobApi,
|
||||
@@ -844,12 +851,24 @@ class OrderStatusUpdateService(
|
||||
logger.debug("买入订单数据无需更新: orderId=${order.buyOrderId}")
|
||||
}
|
||||
|
||||
// 发送通知(使用实际数据)
|
||||
// 有成交时按公式计算实际成交价:original_size * price / size_matched,数量用 size_matched
|
||||
val sizeMatchedDec = orderDetail.sizeMatched.toSafeBigDecimal()
|
||||
val avgFilledPriceStr = if (sizeMatchedDec.gt(BigDecimal.ZERO)) {
|
||||
orderDetail.originalSize.toSafeBigDecimal()
|
||||
.multi(orderDetail.price)
|
||||
.div(sizeMatchedDec, 18)
|
||||
.toPlainString()
|
||||
} else null
|
||||
val filledSize = orderDetail.sizeMatched
|
||||
|
||||
// 发送通知(使用实际数据,优先展示平均成交价)
|
||||
sendBuyOrderNotification(
|
||||
order = updatedOrder,
|
||||
actualPrice = actualPrice.toString(),
|
||||
actualSize = actualSize.toString(),
|
||||
actualOutcome = actualOutcome,
|
||||
avgFilledPrice = avgFilledPriceStr,
|
||||
filled = filledSize,
|
||||
account = account,
|
||||
copyTrading = copyTrading,
|
||||
clobApi = clobApi,
|
||||
@@ -876,6 +895,8 @@ class OrderStatusUpdateService(
|
||||
actualPrice: String? = null,
|
||||
actualSize: String? = null,
|
||||
actualOutcome: String? = null,
|
||||
avgFilledPrice: String? = null, // 平均成交价(有成交时用于 TG 展示)
|
||||
filled: String? = null, // 已成交数量(与 avgFilledPrice 一起用于金额计算)
|
||||
account: Account? = null,
|
||||
copyTrading: CopyTrading? = null,
|
||||
clobApi: PolymarketClobApi? = null,
|
||||
@@ -930,14 +951,24 @@ class OrderStatusUpdateService(
|
||||
null
|
||||
}
|
||||
|
||||
// 发送通知
|
||||
// 查询可用余额
|
||||
val availableBalance = try {
|
||||
blockchainService.getUsdcBalance(finalAccount.walletAddress, finalAccount.proxyAddress).getOrNull()
|
||||
} catch (e: Exception) {
|
||||
logger.warn("查询可用余额失败: accountId=${finalAccount.id}, ${e.message}")
|
||||
null
|
||||
}
|
||||
|
||||
// 发送通知(优先使用平均成交价展示)
|
||||
telegramNotificationService.sendOrderSuccessNotification(
|
||||
orderId = order.buyOrderId,
|
||||
marketTitle = marketTitle,
|
||||
marketId = order.marketId,
|
||||
marketSlug = market?.eventSlug, // 跳转用的 slug
|
||||
side = "BUY",
|
||||
price = actualPrice ?: order.price.toString(), // 使用实际价格或临时价格
|
||||
price = actualPrice ?: order.price.toString(), // 限价,无 avgFilledPrice 时展示
|
||||
avgFilledPrice = avgFilledPrice,
|
||||
filled = filled,
|
||||
size = actualSize ?: order.quantity.toString(), // 使用实际数量或临时数量
|
||||
outcome = actualOutcome, // 使用实际 outcome
|
||||
accountName = finalAccount.accountName,
|
||||
@@ -950,7 +981,8 @@ class OrderStatusUpdateService(
|
||||
locale = locale,
|
||||
leaderName = leaderName,
|
||||
configName = configName,
|
||||
orderTime = orderCreatedAt // 使用订单创建时间
|
||||
orderTime = orderCreatedAt, // 使用订单创建时间
|
||||
availableBalance = availableBalance
|
||||
)
|
||||
|
||||
logger.info("买入订单通知已发送: orderId=${order.buyOrderId}, copyTradingId=${order.copyTradingId}")
|
||||
@@ -969,6 +1001,8 @@ class OrderStatusUpdateService(
|
||||
actualPrice: String? = null,
|
||||
actualSize: String? = null,
|
||||
actualOutcome: String? = null,
|
||||
avgFilledPrice: String? = null, // 平均成交价(有成交时用于 TG 展示)
|
||||
filled: String? = null, // 已成交数量(与 avgFilledPrice 一起用于金额计算)
|
||||
account: Account? = null,
|
||||
copyTrading: CopyTrading? = null,
|
||||
clobApi: PolymarketClobApi? = null,
|
||||
@@ -1023,14 +1057,24 @@ class OrderStatusUpdateService(
|
||||
null
|
||||
}
|
||||
|
||||
// 发送通知
|
||||
// 查询可用余额
|
||||
val availableBalance = try {
|
||||
blockchainService.getUsdcBalance(finalAccount.walletAddress, finalAccount.proxyAddress).getOrNull()
|
||||
} catch (e: Exception) {
|
||||
logger.warn("查询可用余额失败: accountId=${finalAccount.id}, ${e.message}")
|
||||
null
|
||||
}
|
||||
|
||||
// 发送通知(优先使用平均成交价展示)
|
||||
telegramNotificationService.sendOrderSuccessNotification(
|
||||
orderId = record.sellOrderId,
|
||||
marketTitle = marketTitle,
|
||||
marketId = record.marketId,
|
||||
marketSlug = market?.eventSlug, // 跳转用的 slug
|
||||
side = "SELL",
|
||||
price = actualPrice ?: record.sellPrice.toString(), // 使用实际价格或临时价格
|
||||
price = actualPrice ?: record.sellPrice.toString(), // 限价,无 avgFilledPrice 时展示
|
||||
avgFilledPrice = avgFilledPrice,
|
||||
filled = filled,
|
||||
size = actualSize ?: record.totalMatchedQuantity.toString(), // 使用实际数量或临时数量
|
||||
outcome = actualOutcome, // 使用实际 outcome
|
||||
accountName = finalAccount.accountName,
|
||||
@@ -1043,7 +1087,8 @@ class OrderStatusUpdateService(
|
||||
locale = locale,
|
||||
leaderName = leaderName,
|
||||
configName = configName,
|
||||
orderTime = orderCreatedAt // 使用订单创建时间
|
||||
orderTime = orderCreatedAt, // 使用订单创建时间
|
||||
availableBalance = availableBalance
|
||||
)
|
||||
|
||||
logger.info("卖出订单通知已发送: orderId=${record.sellOrderId}, copyTradingId=${record.copyTradingId}")
|
||||
|
||||
+5
-2
@@ -147,7 +147,8 @@ class CryptoTailMonitorService(
|
||||
|
||||
val account = accountRepository.findById(strategy.accountId).orElse(null)
|
||||
val nowSeconds = System.currentTimeMillis() / 1000
|
||||
val periodStartUnix = (nowSeconds / strategy.intervalSeconds) * strategy.intervalSeconds
|
||||
val periodStartUnix = request.periodStartUnix
|
||||
?: ((nowSeconds / strategy.intervalSeconds) * strategy.intervalSeconds)
|
||||
|
||||
// 获取市场信息
|
||||
val slug = "${strategy.marketSlugPrefix}-$periodStartUnix"
|
||||
@@ -207,7 +208,9 @@ class CryptoTailMonitorService(
|
||||
tokenIdUp = tokenIds.getOrNull(0),
|
||||
tokenIdDown = tokenIds.getOrNull(1),
|
||||
currentTimestamp = System.currentTimeMillis(),
|
||||
enabled = strategy.enabled
|
||||
enabled = strategy.enabled,
|
||||
amountMode = strategy.amountMode,
|
||||
amountValue = strategy.amountValue.toPlainString()
|
||||
)
|
||||
|
||||
Result.success(response)
|
||||
|
||||
+16
@@ -6,6 +6,10 @@ import com.wrbug.polymarketbot.repository.CryptoTailStrategyRepository
|
||||
import com.wrbug.polymarketbot.repository.CryptoTailStrategyTriggerRepository
|
||||
import com.wrbug.polymarketbot.service.common.MarketService
|
||||
import com.wrbug.polymarketbot.service.system.TelegramNotificationService
|
||||
import com.wrbug.polymarketbot.util.div
|
||||
import com.wrbug.polymarketbot.util.gt
|
||||
import com.wrbug.polymarketbot.util.multi
|
||||
import com.wrbug.polymarketbot.util.toSafeBigDecimal
|
||||
import com.wrbug.polymarketbot.util.CryptoUtils
|
||||
import com.wrbug.polymarketbot.util.RetrofitFactory
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
@@ -20,6 +24,7 @@ import org.springframework.scheduling.annotation.Scheduled
|
||||
import org.springframework.stereotype.Service
|
||||
import org.springframework.transaction.annotation.Transactional
|
||||
import jakarta.annotation.PreDestroy
|
||||
import java.math.BigDecimal
|
||||
|
||||
/**
|
||||
* 加密价差策略订单 TG 通知轮询服务(与跟单一致)
|
||||
@@ -128,6 +133,15 @@ class CryptoTailOrderNotificationPollingService(
|
||||
val market = marketService.getMarket(order.market)
|
||||
val marketTitle = trigger.marketTitle?.takeIf { it.isNotBlank() } ?: market?.title ?: order.market
|
||||
val orderTimeMs = if (order.createdAt < 1_000_000_000_000L) order.createdAt * 1000 else order.createdAt
|
||||
// 实际成交价 = original_size * price / size_matched,数量用 size_matched
|
||||
val sizeMatchedDec = order.sizeMatched.toSafeBigDecimal()
|
||||
val avgFilledPriceStr = if (sizeMatchedDec.gt(BigDecimal.ZERO)) {
|
||||
order.originalSize.toSafeBigDecimal()
|
||||
.multi(order.price)
|
||||
.div(sizeMatchedDec, 18)
|
||||
.toPlainString()
|
||||
} else null
|
||||
val filledSize = order.sizeMatched
|
||||
telegramNotificationService.sendCryptoTailOrderSuccessNotification(
|
||||
orderId = orderId,
|
||||
marketTitle = marketTitle,
|
||||
@@ -137,6 +151,8 @@ class CryptoTailOrderNotificationPollingService(
|
||||
outcome = order.outcome,
|
||||
price = order.price,
|
||||
size = order.originalSize,
|
||||
avgFilledPrice = avgFilledPriceStr,
|
||||
filled = filledSize,
|
||||
strategyName = strategy.name,
|
||||
accountName = account.accountName,
|
||||
walletAddress = account.walletAddress,
|
||||
|
||||
+177
-7
@@ -3,6 +3,9 @@ package com.wrbug.polymarketbot.service.cryptotail
|
||||
import com.wrbug.polymarketbot.api.GammaEventBySlugResponse
|
||||
import com.wrbug.polymarketbot.api.NewOrderRequest
|
||||
import com.wrbug.polymarketbot.api.PolymarketClobApi
|
||||
import com.wrbug.polymarketbot.dto.CryptoTailManualOrderRequest
|
||||
import com.wrbug.polymarketbot.dto.CryptoTailManualOrderResponse
|
||||
import com.wrbug.polymarketbot.dto.ManualOrderDetails
|
||||
import com.wrbug.polymarketbot.entity.Account
|
||||
import com.wrbug.polymarketbot.entity.CryptoTailStrategy
|
||||
import com.wrbug.polymarketbot.entity.CryptoTailStrategyTrigger
|
||||
@@ -421,7 +424,8 @@ class CryptoTailStrategyExecutionService(
|
||||
outcomeIndex,
|
||||
triggerPrice,
|
||||
amountUsdc,
|
||||
orderRequest
|
||||
orderRequest,
|
||||
triggerType = "AUTO"
|
||||
)
|
||||
return
|
||||
}
|
||||
@@ -437,7 +441,8 @@ class CryptoTailStrategyExecutionService(
|
||||
outcomeIndex: Int,
|
||||
triggerPrice: BigDecimal,
|
||||
amountUsdc: BigDecimal,
|
||||
orderRequest: NewOrderRequest
|
||||
orderRequest: NewOrderRequest,
|
||||
triggerType: String = "AUTO"
|
||||
) {
|
||||
var failReason: String? = null
|
||||
try {
|
||||
@@ -454,9 +459,10 @@ class CryptoTailStrategyExecutionService(
|
||||
amountUsdc,
|
||||
body.orderId,
|
||||
"success",
|
||||
null
|
||||
null,
|
||||
triggerType = triggerType
|
||||
)
|
||||
logger.info("加密价差策略下单成功: strategyId=${strategy.id}, periodStartUnix=$periodStartUnix, outcomeIndex=$outcomeIndex, orderId=${body.orderId}")
|
||||
logger.info("加密价差策略下单成功: strategyId=${strategy.id}, periodStartUnix=$periodStartUnix, outcomeIndex=$outcomeIndex, orderId=${body.orderId}, triggerType=$triggerType")
|
||||
return
|
||||
}
|
||||
failReason = body.errorMsg ?: "unknown"
|
||||
@@ -477,7 +483,8 @@ class CryptoTailStrategyExecutionService(
|
||||
amountUsdc,
|
||||
null,
|
||||
"fail",
|
||||
failReason
|
||||
failReason,
|
||||
triggerType = triggerType
|
||||
)
|
||||
logger.error("加密价差策略下单失败: strategyId=${strategy.id}, periodStartUnix=$periodStartUnix, reason=$failReason")
|
||||
}
|
||||
@@ -665,7 +672,8 @@ class CryptoTailStrategyExecutionService(
|
||||
amountUsdc: BigDecimal,
|
||||
orderId: String?,
|
||||
status: String,
|
||||
failReason: String?
|
||||
failReason: String?,
|
||||
triggerType: String = "AUTO"
|
||||
) {
|
||||
val record = CryptoTailStrategyTrigger(
|
||||
strategyId = strategy.id!!,
|
||||
@@ -676,11 +684,173 @@ class CryptoTailStrategyExecutionService(
|
||||
amountUsdc = amountUsdc,
|
||||
orderId = orderId,
|
||||
status = status,
|
||||
failReason = failReason
|
||||
failReason = failReason,
|
||||
triggerType = triggerType
|
||||
)
|
||||
triggerRepository.save(record)
|
||||
}
|
||||
|
||||
/**
|
||||
* 手动下单:用户主动触发下单,不检查任何条件,仅检查当前周期是否已下单
|
||||
*/
|
||||
suspend fun manualOrder(request: CryptoTailManualOrderRequest): Result<CryptoTailManualOrderResponse> {
|
||||
return try {
|
||||
val strategy = strategyRepository.findById(request.strategyId).orElse(null)
|
||||
?: return Result.failure(IllegalArgumentException("策略不存在"))
|
||||
|
||||
val outcomeIndex = if (request.direction.uppercase() == "UP") 0 else 1
|
||||
|
||||
if (outcomeIndex < 0 || outcomeIndex >= request.tokenIds.size) {
|
||||
return Result.failure(IllegalArgumentException("outcomeIndex 越界"))
|
||||
}
|
||||
|
||||
val price = request.price.toSafeBigDecimal()
|
||||
if (price <= BigDecimal.ZERO || price > BigDecimal.ONE) {
|
||||
return Result.failure(IllegalArgumentException("价格必须在 0~1 之间"))
|
||||
}
|
||||
val priceRounded = price.setScale(4, RoundingMode.UP)
|
||||
|
||||
val size = request.size.toSafeBigDecimal()
|
||||
if (size < BigDecimal.ONE) {
|
||||
return Result.failure(IllegalArgumentException("数量不能少于 1"))
|
||||
}
|
||||
|
||||
val amountUsdc = priceRounded.multi(size).setScale(2, RoundingMode.HALF_UP)
|
||||
if (amountUsdc < BigDecimal.ONE) {
|
||||
return Result.failure(IllegalArgumentException("总金额不能少于 1 USDC"))
|
||||
}
|
||||
|
||||
val mutex = getTriggerMutex(strategy.id!!, request.periodStartUnix)
|
||||
mutex.withLock {
|
||||
if (triggerRepository.findByStrategyIdAndPeriodStartUnix(
|
||||
strategy.id!!,
|
||||
request.periodStartUnix
|
||||
) != null
|
||||
) {
|
||||
return@withLock Result.failure(IllegalArgumentException("当前周期已下单"))
|
||||
}
|
||||
|
||||
var ctx = getOrInvalidatePeriodContext(strategy, request.periodStartUnix)
|
||||
if (ctx == null) {
|
||||
ctx = ensurePeriodContext(
|
||||
strategy,
|
||||
request.periodStartUnix,
|
||||
request.tokenIds,
|
||||
request.marketTitle.ifBlank { null }
|
||||
)
|
||||
}
|
||||
if (ctx != null) {
|
||||
val tokenId = request.tokenIds.getOrNull(outcomeIndex)
|
||||
?: return@withLock Result.failure(IllegalArgumentException("tokenIds 越界"))
|
||||
|
||||
val priceStr = priceRounded.toPlainString()
|
||||
val sizeStr = size.toPlainString()
|
||||
val feeRateBps = ctx.feeRateByTokenId[tokenId] ?: "0"
|
||||
|
||||
val signedOrder = orderSigningService.createAndSignOrder(
|
||||
privateKey = ctx.decryptedPrivateKey,
|
||||
makerAddress = ctx.account.proxyAddress,
|
||||
tokenId = tokenId,
|
||||
side = "BUY",
|
||||
price = priceStr,
|
||||
size = sizeStr,
|
||||
signatureType = ctx.signatureType,
|
||||
nonce = "0",
|
||||
feeRateBps = feeRateBps,
|
||||
expiration = "0"
|
||||
)
|
||||
|
||||
val orderRequest = NewOrderRequest(
|
||||
order = signedOrder,
|
||||
owner = ctx.account.apiKey!!,
|
||||
orderType = "FAK",
|
||||
deferExec = false
|
||||
)
|
||||
|
||||
val orderResult = submitOrderForManualOrder(
|
||||
ctx.clobApi,
|
||||
strategy,
|
||||
request.periodStartUnix,
|
||||
request.marketTitle,
|
||||
outcomeIndex,
|
||||
priceRounded,
|
||||
amountUsdc,
|
||||
orderRequest
|
||||
)
|
||||
|
||||
orderResult.fold(
|
||||
onSuccess = { orderId ->
|
||||
Result.success(
|
||||
CryptoTailManualOrderResponse(
|
||||
success = true,
|
||||
orderId = orderId,
|
||||
message = "下单成功",
|
||||
orderDetails = ManualOrderDetails(
|
||||
strategyId = strategy.id!!,
|
||||
direction = request.direction,
|
||||
price = priceStr,
|
||||
size = sizeStr,
|
||||
totalAmount = amountUsdc.toPlainString()
|
||||
)
|
||||
)
|
||||
)
|
||||
},
|
||||
onFailure = { e ->
|
||||
Result.failure(e)
|
||||
}
|
||||
)
|
||||
} else {
|
||||
Result.failure(IllegalArgumentException("账户未配置或凭证不足"))
|
||||
}
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
logger.error("手动下单异常: strategyId=${request.strategyId}, ${e.message}", e)
|
||||
Result.failure(e)
|
||||
}
|
||||
}
|
||||
|
||||
private suspend fun submitOrderForManualOrder(
|
||||
clobApi: PolymarketClobApi,
|
||||
strategy: CryptoTailStrategy,
|
||||
periodStartUnix: Long,
|
||||
marketTitle: String?,
|
||||
outcomeIndex: Int,
|
||||
price: BigDecimal,
|
||||
amountUsdc: BigDecimal,
|
||||
orderRequest: NewOrderRequest
|
||||
): Result<String> {
|
||||
return try {
|
||||
val response = clobApi.createOrder(orderRequest)
|
||||
if (response.isSuccessful && response.body() != null) {
|
||||
val body = response.body()!!
|
||||
if (body.success && body.orderId != null) {
|
||||
saveTriggerRecord(
|
||||
strategy,
|
||||
periodStartUnix,
|
||||
marketTitle,
|
||||
outcomeIndex,
|
||||
price,
|
||||
amountUsdc,
|
||||
body.orderId,
|
||||
"success",
|
||||
null,
|
||||
triggerType = "MANUAL"
|
||||
)
|
||||
logger.info("手动下单成功: strategyId=${strategy.id}, periodStartUnix=$periodStartUnix, outcomeIndex=$outcomeIndex, orderId=${body.orderId}")
|
||||
Result.success(body.orderId)
|
||||
} else {
|
||||
Result.failure(Exception(body.errorMsg ?: "unknown"))
|
||||
}
|
||||
} else {
|
||||
val errorBody = response.errorBody()?.string().orEmpty()
|
||||
Result.failure(Exception(errorBody.ifEmpty { "请求失败" }))
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
logger.error("手动下单异常: strategyId=${strategy.id}, periodStartUnix=$periodStartUnix", e)
|
||||
Result.failure(e)
|
||||
}
|
||||
}
|
||||
|
||||
@PreDestroy
|
||||
fun destroy() {
|
||||
// 清理所有周期上下文缓存,避免敏感信息(明文私钥、API Secret)在内存中保留
|
||||
|
||||
+56
@@ -9,6 +9,7 @@ import com.wrbug.polymarketbot.enums.SpreadDirection
|
||||
import com.wrbug.polymarketbot.repository.CryptoTailStrategyRepository
|
||||
import com.wrbug.polymarketbot.repository.CryptoTailStrategyTriggerRepository
|
||||
import com.wrbug.polymarketbot.event.CryptoTailStrategyChangedEvent
|
||||
import com.wrbug.polymarketbot.util.gt
|
||||
import com.wrbug.polymarketbot.util.toSafeBigDecimal
|
||||
import org.slf4j.LoggerFactory
|
||||
import org.springframework.context.ApplicationEventPublisher
|
||||
@@ -220,6 +221,61 @@ class CryptoTailStrategyService(
|
||||
}
|
||||
}
|
||||
|
||||
fun getPnlCurve(request: CryptoTailPnlCurveRequest): Result<CryptoTailPnlCurveResponse> {
|
||||
return try {
|
||||
val strategy = strategyRepository.findById(request.strategyId).orElse(null)
|
||||
?: return Result.failure(IllegalArgumentException(ErrorCode.CRYPTO_TAIL_STRATEGY_NOT_FOUND.messageKey))
|
||||
val start = request.startDate ?: 0L
|
||||
val end = request.endDate ?: Long.MAX_VALUE
|
||||
val triggers = triggerRepository.findResolvedByStrategyIdAndTimeRangeOrderBySettledAsc(
|
||||
request.strategyId, start, end
|
||||
)
|
||||
var cumulative = BigDecimal.ZERO
|
||||
var peak = BigDecimal.ZERO
|
||||
var maxDrawdown = BigDecimal.ZERO
|
||||
var winCountInRange = 0L
|
||||
val curveData = triggers.map { t ->
|
||||
val pnl = t.realizedPnl ?: BigDecimal.ZERO
|
||||
cumulative = cumulative.add(pnl)
|
||||
if (cumulative.gt(peak)) peak = cumulative
|
||||
val drawdown = peak.subtract(cumulative)
|
||||
if (drawdown.gt(maxDrawdown)) maxDrawdown = drawdown
|
||||
if (t.winnerOutcomeIndex != null && t.outcomeIndex == t.winnerOutcomeIndex) winCountInRange++
|
||||
val ts = t.settledAt ?: t.createdAt
|
||||
CryptoTailPnlCurvePoint(
|
||||
timestamp = ts,
|
||||
cumulativePnl = cumulative.toPlainString(),
|
||||
pointPnl = pnl.toPlainString(),
|
||||
settledCount = 0L
|
||||
)
|
||||
}.mapIndexed { index, p ->
|
||||
p.copy(settledCount = (index + 1).toLong())
|
||||
}
|
||||
val totalPnl = if (curveData.isEmpty()) BigDecimal.ZERO else curveData.last().cumulativePnl.toSafeBigDecimal()
|
||||
val settledCountInRange = curveData.size.toLong()
|
||||
val winRateStr = if (settledCountInRange > 0L) {
|
||||
BigDecimal(winCountInRange).divide(BigDecimal(settledCountInRange), 4, java.math.RoundingMode.HALF_UP).toPlainString()
|
||||
} else null
|
||||
Result.success(
|
||||
CryptoTailPnlCurveResponse(
|
||||
strategyId = request.strategyId,
|
||||
strategyName = strategy.name ?: strategy.marketSlugPrefix,
|
||||
totalRealizedPnl = totalPnl.toPlainString(),
|
||||
settledCount = settledCountInRange,
|
||||
winCount = winCountInRange,
|
||||
winRate = winRateStr,
|
||||
maxDrawdown = if (maxDrawdown.compareTo(BigDecimal.ZERO) > 0) maxDrawdown.toPlainString() else null,
|
||||
curveData = curveData
|
||||
)
|
||||
)
|
||||
} catch (e: IllegalArgumentException) {
|
||||
Result.failure(e)
|
||||
} catch (e: Exception) {
|
||||
logger.error("查询收益曲线失败: ${e.message}", e)
|
||||
Result.failure(e)
|
||||
}
|
||||
}
|
||||
|
||||
fun getTriggerRecords(request: CryptoTailStrategyTriggerListRequest): Result<CryptoTailStrategyTriggerListResponse> {
|
||||
return try {
|
||||
val page = PageRequest.of((request.page - 1).coerceAtLeast(0), request.pageSize.coerceIn(1, 100))
|
||||
|
||||
+457
@@ -0,0 +1,457 @@
|
||||
package com.wrbug.polymarketbot.service.system
|
||||
|
||||
import com.wrbug.polymarketbot.dto.*
|
||||
import com.wrbug.polymarketbot.entity.NotificationTemplate
|
||||
import com.wrbug.polymarketbot.repository.NotificationTemplateRepository
|
||||
import org.slf4j.LoggerFactory
|
||||
import org.springframework.context.annotation.Lazy
|
||||
import org.springframework.stereotype.Service
|
||||
import org.springframework.transaction.annotation.Transactional
|
||||
|
||||
/**
|
||||
* 消息模板服务
|
||||
* 负责管理消息模板、渲染模板、提供变量信息
|
||||
*/
|
||||
@Service
|
||||
class NotificationTemplateService(
|
||||
private val templateRepository: NotificationTemplateRepository,
|
||||
@Lazy private val telegramNotificationService: TelegramNotificationService
|
||||
) {
|
||||
private val logger = LoggerFactory.getLogger(NotificationTemplateService::class.java)
|
||||
|
||||
companion object {
|
||||
// 模板类型定义
|
||||
val TEMPLATE_TYPES = mapOf(
|
||||
"ORDER_SUCCESS" to TemplateTypeInfoDto(
|
||||
type = "ORDER_SUCCESS",
|
||||
name = "订单成功通知",
|
||||
description = "订单创建成功时发送的通知"
|
||||
),
|
||||
"ORDER_FAILED" to TemplateTypeInfoDto(
|
||||
type = "ORDER_FAILED",
|
||||
name = "订单失败通知",
|
||||
description = "订单创建失败时发送的通知"
|
||||
),
|
||||
"ORDER_FILTERED" to TemplateTypeInfoDto(
|
||||
type = "ORDER_FILTERED",
|
||||
name = "订单过滤通知",
|
||||
description = "订单被风控过滤时发送的通知"
|
||||
),
|
||||
"CRYPTO_TAIL_SUCCESS" to TemplateTypeInfoDto(
|
||||
type = "CRYPTO_TAIL_SUCCESS",
|
||||
name = "加密价差策略成功通知",
|
||||
description = "加密价差策略下单成功时发送的通知"
|
||||
),
|
||||
"REDEEM_SUCCESS" to TemplateTypeInfoDto(
|
||||
type = "REDEEM_SUCCESS",
|
||||
name = "仓位赎回成功通知",
|
||||
description = "仓位赎回成功时发送的通知"
|
||||
),
|
||||
"REDEEM_NO_RETURN" to TemplateTypeInfoDto(
|
||||
type = "REDEEM_NO_RETURN",
|
||||
name = "仓位结算(无收益)通知",
|
||||
description = "仓位结算但无收益时发送的通知"
|
||||
)
|
||||
)
|
||||
|
||||
// 变量分类
|
||||
val VARIABLE_CATEGORIES = listOf(
|
||||
TemplateVariableCategoryDto("common", 0),
|
||||
TemplateVariableCategoryDto("order", 10),
|
||||
TemplateVariableCategoryDto("copy_trading", 20),
|
||||
TemplateVariableCategoryDto("redeem", 30),
|
||||
TemplateVariableCategoryDto("error", 40),
|
||||
TemplateVariableCategoryDto("filter", 50),
|
||||
TemplateVariableCategoryDto("strategy", 60)
|
||||
)
|
||||
|
||||
// 各模板类型可用的变量
|
||||
val TEMPLATE_VARIABLES = mapOf(
|
||||
"ORDER_SUCCESS" to listOf(
|
||||
// 通用变量
|
||||
TemplateVariableDto("account_name", "common", 1),
|
||||
TemplateVariableDto("wallet_address", "common", 2),
|
||||
TemplateVariableDto("time", "common", 3),
|
||||
// 订单变量
|
||||
TemplateVariableDto("order_id", "order", 10),
|
||||
TemplateVariableDto("market_title", "order", 11),
|
||||
TemplateVariableDto("market_link", "order", 12),
|
||||
TemplateVariableDto("side", "order", 13),
|
||||
TemplateVariableDto("outcome", "order", 14),
|
||||
TemplateVariableDto("price", "order", 15),
|
||||
TemplateVariableDto("quantity", "order", 16),
|
||||
TemplateVariableDto("amount", "order", 17),
|
||||
TemplateVariableDto("available_balance", "order", 18),
|
||||
// 跟单变量
|
||||
TemplateVariableDto("leader_name", "copy_trading", 21),
|
||||
TemplateVariableDto("config_name", "copy_trading", 22)
|
||||
),
|
||||
"ORDER_FAILED" to listOf(
|
||||
// 通用变量
|
||||
TemplateVariableDto("account_name", "common", 1),
|
||||
TemplateVariableDto("wallet_address", "common", 2),
|
||||
TemplateVariableDto("time", "common", 3),
|
||||
// 订单变量
|
||||
TemplateVariableDto("market_title", "order", 10),
|
||||
TemplateVariableDto("market_link", "order", 11),
|
||||
TemplateVariableDto("side", "order", 12),
|
||||
TemplateVariableDto("outcome", "order", 13),
|
||||
TemplateVariableDto("price", "order", 14),
|
||||
TemplateVariableDto("quantity", "order", 15),
|
||||
TemplateVariableDto("amount", "order", 16),
|
||||
// 错误变量
|
||||
TemplateVariableDto("error_message", "error", 20)
|
||||
),
|
||||
"ORDER_FILTERED" to listOf(
|
||||
// 通用变量
|
||||
TemplateVariableDto("account_name", "common", 1),
|
||||
TemplateVariableDto("wallet_address", "common", 2),
|
||||
TemplateVariableDto("time", "common", 3),
|
||||
// 订单变量
|
||||
TemplateVariableDto("market_title", "order", 10),
|
||||
TemplateVariableDto("market_link", "order", 11),
|
||||
TemplateVariableDto("side", "order", 12),
|
||||
TemplateVariableDto("outcome", "order", 13),
|
||||
TemplateVariableDto("price", "order", 14),
|
||||
TemplateVariableDto("quantity", "order", 15),
|
||||
TemplateVariableDto("amount", "order", 16),
|
||||
// 过滤变量
|
||||
TemplateVariableDto("filter_type", "filter", 20),
|
||||
TemplateVariableDto("filter_reason", "filter", 21)
|
||||
),
|
||||
"CRYPTO_TAIL_SUCCESS" to listOf(
|
||||
// 通用变量
|
||||
TemplateVariableDto("account_name", "common", 1),
|
||||
TemplateVariableDto("wallet_address", "common", 2),
|
||||
TemplateVariableDto("time", "common", 3),
|
||||
// 订单变量
|
||||
TemplateVariableDto("order_id", "order", 10),
|
||||
TemplateVariableDto("market_title", "order", 11),
|
||||
TemplateVariableDto("market_link", "order", 12),
|
||||
TemplateVariableDto("side", "order", 13),
|
||||
TemplateVariableDto("outcome", "order", 14),
|
||||
TemplateVariableDto("price", "order", 15),
|
||||
TemplateVariableDto("quantity", "order", 16),
|
||||
TemplateVariableDto("amount", "order", 17),
|
||||
// 策略变量
|
||||
TemplateVariableDto("strategy_name", "strategy", 20)
|
||||
),
|
||||
"REDEEM_SUCCESS" to listOf(
|
||||
// 通用变量
|
||||
TemplateVariableDto("account_name", "common", 1),
|
||||
TemplateVariableDto("wallet_address", "common", 2),
|
||||
TemplateVariableDto("time", "common", 3),
|
||||
// 赎回变量
|
||||
TemplateVariableDto("transaction_hash", "redeem", 10),
|
||||
TemplateVariableDto("total_value", "redeem", 11),
|
||||
TemplateVariableDto("available_balance", "redeem", 12)
|
||||
),
|
||||
"REDEEM_NO_RETURN" to listOf(
|
||||
// 通用变量
|
||||
TemplateVariableDto("account_name", "common", 1),
|
||||
TemplateVariableDto("wallet_address", "common", 2),
|
||||
TemplateVariableDto("time", "common", 3),
|
||||
// 赎回变量
|
||||
TemplateVariableDto("transaction_hash", "redeem", 10),
|
||||
TemplateVariableDto("available_balance", "redeem", 11)
|
||||
)
|
||||
)
|
||||
|
||||
// 默认模板
|
||||
val DEFAULT_TEMPLATES = mapOf(
|
||||
"ORDER_SUCCESS" to """
|
||||
🚀 <b>订单创建成功</b>
|
||||
|
||||
📊 <b>订单信息:</b>
|
||||
• 订单ID: <code>{{order_id}}</code>
|
||||
• 市场: <a href="{{market_link}}">{{market_title}}</a>
|
||||
• 市场方向: <b>{{outcome}}</b>
|
||||
• 方向: <b>{{side}}</b>
|
||||
• 价格: <code>{{price}}</code>
|
||||
• 数量: <code>{{quantity}}</code> shares
|
||||
• 金额: <code>{{amount}}</code> USDC
|
||||
• 账户: {{account_name}}
|
||||
• 可用余额: <code>{{available_balance}}</code> USDC
|
||||
|
||||
⏰ 时间: <code>{{time}}</code>
|
||||
""".trimIndent(),
|
||||
"ORDER_FAILED" to """
|
||||
❌ <b>订单创建失败</b>
|
||||
|
||||
📊 <b>订单信息:</b>
|
||||
• 市场: <a href="{{market_link}}">{{market_title}}</a>
|
||||
• 市场方向: <b>{{outcome}}</b>
|
||||
• 方向: <b>{{side}}</b>
|
||||
• 价格: <code>{{price}}</code>
|
||||
• 数量: <code>{{quantity}}</code> shares
|
||||
• 金额: <code>{{amount}}</code> USDC
|
||||
• 账户: {{account_name}}
|
||||
|
||||
⚠️ <b>错误信息:</b>
|
||||
<code>{{error_message}}</code>
|
||||
|
||||
⏰ 时间: <code>{{time}}</code>
|
||||
""".trimIndent(),
|
||||
"ORDER_FILTERED" to """
|
||||
🚫 <b>订单被过滤</b>
|
||||
|
||||
📊 <b>订单信息:</b>
|
||||
• 市场: <a href="{{market_link}}">{{market_title}}</a>
|
||||
• 市场方向: <b>{{outcome}}</b>
|
||||
• 方向: <b>{{side}}</b>
|
||||
• 价格: <code>{{price}}</code>
|
||||
• 数量: <code>{{quantity}}</code> shares
|
||||
• 金额: <code>{{amount}}</code> USDC
|
||||
• 账户: {{account_name}}
|
||||
|
||||
⚠️ <b>过滤类型:</b> <code>{{filter_type}}</code>
|
||||
|
||||
📝 <b>过滤原因:</b>
|
||||
<code>{{filter_reason}}</code>
|
||||
|
||||
⏰ 时间: <code>{{time}}</code>
|
||||
""".trimIndent(),
|
||||
"CRYPTO_TAIL_SUCCESS" to """
|
||||
🚀 <b>加密价差策略下单成功</b>
|
||||
|
||||
📊 <b>订单信息:</b>
|
||||
• 订单ID: <code>{{order_id}}</code>
|
||||
• 策略: {{strategy_name}}
|
||||
• 市场: <a href="{{market_link}}">{{market_title}}</a>
|
||||
• 市场方向: <b>{{outcome}}</b>
|
||||
• 方向: <b>{{side}}</b>
|
||||
• 价格: <code>{{price}}</code>
|
||||
• 数量: <code>{{quantity}}</code> shares
|
||||
• 金额: <code>{{amount}}</code> USDC
|
||||
• 账户: {{account_name}}
|
||||
|
||||
⏰ 时间: <code>{{time}}</code>
|
||||
""".trimIndent(),
|
||||
"REDEEM_SUCCESS" to """
|
||||
💸 <b>仓位赎回成功</b>
|
||||
|
||||
📊 <b>赎回信息:</b>
|
||||
• 账户: {{account_name}}
|
||||
• 交易哈希: <code>{{transaction_hash}}</code>
|
||||
• 赎回总价值: <code>{{total_value}}</code> USDC
|
||||
• 可用余额: <code>{{available_balance}}</code> USDC
|
||||
|
||||
⏰ 时间: <code>{{time}}</code>
|
||||
""".trimIndent(),
|
||||
"REDEEM_NO_RETURN" to """
|
||||
📋 <b>仓位已结算(无收益)</b>
|
||||
|
||||
📊 <b>结算信息:</b>
|
||||
<i>市场已结算,您的预测未命中,赎回价值为 0。</i>
|
||||
|
||||
• 账户: {{account_name}}
|
||||
• 交易哈希: <code>{{transaction_hash}}</code>
|
||||
• 可用余额: <code>{{available_balance}}</code> USDC
|
||||
|
||||
⏰ 时间: <code>{{time}}</code>
|
||||
""".trimIndent()
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取所有模板类型
|
||||
*/
|
||||
fun getTemplateTypes(): List<TemplateTypeInfoDto> {
|
||||
return TEMPLATE_TYPES.values.toList()
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取所有模板列表
|
||||
*/
|
||||
fun getAllTemplates(): List<NotificationTemplateDto> {
|
||||
return templateRepository.findAll().map { it.toDto() }
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取单个模板
|
||||
*/
|
||||
fun getTemplate(templateType: String): NotificationTemplateDto? {
|
||||
return templateRepository.findByTemplateType(templateType)?.toDto()
|
||||
?: DEFAULT_TEMPLATES[templateType]?.let {
|
||||
NotificationTemplateDto(
|
||||
templateType = templateType,
|
||||
templateContent = it,
|
||||
isDefault = true
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取模板可用变量
|
||||
*/
|
||||
fun getTemplateVariables(templateType: String): TemplateVariablesResponse? {
|
||||
if (!TEMPLATE_TYPES.containsKey(templateType)) return null
|
||||
val variables = TEMPLATE_VARIABLES[templateType] ?: emptyList()
|
||||
|
||||
// 获取使用的分类
|
||||
val usedCategories = variables.map { it.category }.toSet()
|
||||
val categories = VARIABLE_CATEGORIES.filter { usedCategories.contains(it.key) }
|
||||
|
||||
return TemplateVariablesResponse(
|
||||
templateType = templateType,
|
||||
categories = categories,
|
||||
variables = variables
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* 更新模板
|
||||
*/
|
||||
@Transactional
|
||||
fun updateTemplate(templateType: String, content: String): NotificationTemplateDto {
|
||||
val template = templateRepository.findByTemplateType(templateType)
|
||||
val now = System.currentTimeMillis()
|
||||
|
||||
return if (template != null) {
|
||||
template.templateContent = content
|
||||
template.isDefault = false
|
||||
template.updatedAt = now
|
||||
templateRepository.save(template).toDto()
|
||||
} else {
|
||||
val newTemplate = NotificationTemplate(
|
||||
templateType = templateType,
|
||||
templateContent = content,
|
||||
isDefault = false,
|
||||
createdAt = now,
|
||||
updatedAt = now
|
||||
)
|
||||
templateRepository.save(newTemplate).toDto()
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 重置模板为默认
|
||||
*/
|
||||
@Transactional
|
||||
fun resetTemplate(templateType: String): NotificationTemplateDto? {
|
||||
val defaultContent = DEFAULT_TEMPLATES[templateType] ?: return null
|
||||
val template = templateRepository.findByTemplateType(templateType)
|
||||
val now = System.currentTimeMillis()
|
||||
|
||||
return if (template != null) {
|
||||
template.templateContent = defaultContent
|
||||
template.isDefault = true
|
||||
template.updatedAt = now
|
||||
templateRepository.save(template).toDto()
|
||||
} else {
|
||||
val newTemplate = NotificationTemplate(
|
||||
templateType = templateType,
|
||||
templateContent = defaultContent,
|
||||
isDefault = true,
|
||||
createdAt = now,
|
||||
updatedAt = now
|
||||
)
|
||||
templateRepository.save(newTemplate).toDto()
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 渲染模板(按类型取模板内容后替换变量)
|
||||
* 优化:先解析模版中需要的变量,只替换这些变量,未提供的变量使用 "-" 占位
|
||||
*/
|
||||
fun renderTemplate(templateType: String, variables: Map<String, String>): String {
|
||||
val template = getTemplate(templateType)
|
||||
val content = template?.templateContent ?: DEFAULT_TEMPLATES[templateType] ?: ""
|
||||
return renderTemplateContent(content, variables)
|
||||
}
|
||||
|
||||
/**
|
||||
* 对给定模板内容做变量替换(不查库)
|
||||
* 优化:先解析模版中的变量占位符,只替换这些变量,未提供的变量使用 "-" 占位
|
||||
*/
|
||||
fun renderTemplateContent(content: String, variables: Map<String, String>): String {
|
||||
// 先解析模版中需要的变量
|
||||
val requiredVariables = extractTemplateVariables(content)
|
||||
|
||||
var result = content
|
||||
// 只替换模版中实际使用的变量
|
||||
requiredVariables.forEach { varName ->
|
||||
val value = variables[varName]
|
||||
result = result.replace("{{$varName}}", value ?: "-")
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
/**
|
||||
* 解析模版中使用的变量名
|
||||
* @return 变量名列表(去重)
|
||||
*/
|
||||
private fun extractTemplateVariables(content: String): Set<String> {
|
||||
val regex = Regex("\\{\\{([^}]+)}}")
|
||||
return regex.findAll(content)
|
||||
.map { it.groupValues[1].trim() }
|
||||
.toSet()
|
||||
}
|
||||
|
||||
/**
|
||||
* 根据模版需要的变量过滤输入变量
|
||||
* 只保留模版中实际使用的变量,避免不必要的数据获取
|
||||
*/
|
||||
fun filterVariablesForTemplate(templateType: String, variables: Map<String, String>): Map<String, String> {
|
||||
val template = getTemplate(templateType)
|
||||
val content = template?.templateContent ?: DEFAULT_TEMPLATES[templateType] ?: return emptyMap()
|
||||
val requiredVariables = extractTemplateVariables(content)
|
||||
return variables.filterKeys { it in requiredVariables }
|
||||
}
|
||||
|
||||
/**
|
||||
* 发送测试消息
|
||||
*/
|
||||
suspend fun sendTestMessage(templateType: String, content: String? = null): Boolean {
|
||||
val templateContent = content ?: getTemplate(templateType)?.templateContent ?: return false
|
||||
val testVariables = generateTestVariables(templateType)
|
||||
val message = renderTemplateContent(templateContent, testVariables)
|
||||
return try {
|
||||
telegramNotificationService.sendMessage(message)
|
||||
true
|
||||
} catch (e: Exception) {
|
||||
logger.error("发送测试消息失败: ${e.message}", e)
|
||||
false
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 生成测试变量数据
|
||||
*/
|
||||
private fun generateTestVariables(templateType: String): Map<String, String> {
|
||||
return mapOf(
|
||||
"account_name" to "测试账户",
|
||||
"wallet_address" to "0x1234...5678",
|
||||
"time" to "2024-01-15 12:30:00",
|
||||
"order_id" to "12345678",
|
||||
"market_title" to "测试市场标题",
|
||||
"market_link" to "https://polymarket.com/event/test",
|
||||
"side" to "买入",
|
||||
"outcome" to "YES",
|
||||
"price" to "0.55",
|
||||
"quantity" to "100",
|
||||
"amount" to "55.00",
|
||||
"available_balance" to "1000.00",
|
||||
"leader_name" to "测试Leader",
|
||||
"config_name" to "测试配置",
|
||||
"error_message" to "余额不足",
|
||||
"filter_type" to "价差过大",
|
||||
"filter_reason" to "当前市场价差为 5%,超过设定的 3% 限制",
|
||||
"strategy_name" to "BTC价差策略",
|
||||
"transaction_hash" to "0xabcd...efgh",
|
||||
"total_value" to "100.00"
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Entity 转 DTO
|
||||
*/
|
||||
private fun NotificationTemplate.toDto() = NotificationTemplateDto(
|
||||
id = id,
|
||||
templateType = templateType,
|
||||
templateContent = templateContent,
|
||||
isDefault = isDefault,
|
||||
createdAt = createdAt,
|
||||
updatedAt = updatedAt
|
||||
)
|
||||
}
|
||||
+496
-31
@@ -26,6 +26,7 @@ import java.util.concurrent.TimeUnit
|
||||
@Service
|
||||
class TelegramNotificationService(
|
||||
private val notificationConfigService: NotificationConfigService,
|
||||
private val notificationTemplateService: NotificationTemplateService,
|
||||
private val objectMapper: ObjectMapper,
|
||||
private val messageSource: MessageSource
|
||||
) {
|
||||
@@ -85,7 +86,9 @@ class TelegramNotificationService(
|
||||
marketId: String? = null,
|
||||
marketSlug: String? = null,
|
||||
side: String,
|
||||
price: String? = null, // 订单价格(可选,如果提供则直接使用)
|
||||
price: String? = null, // 订单限价(可选)
|
||||
avgFilledPrice: String? = null, // 平均成交价(可选,有成交时优先展示)
|
||||
filled: String? = null, // 已成交数量(可选,与 avgFilledPrice 一起时用于金额计算)
|
||||
size: String? = null, // 订单数量(可选,如果提供则直接使用)
|
||||
outcome: String? = null, // 市场方向(可选,如果提供则直接使用)
|
||||
accountName: String? = null,
|
||||
@@ -98,7 +101,8 @@ class TelegramNotificationService(
|
||||
locale: java.util.Locale? = null,
|
||||
leaderName: String? = null, // Leader 名称(备注)
|
||||
configName: String? = null, // 跟单配置名
|
||||
orderTime: Long? = null // 订单创建时间(毫秒时间戳),用于通知中的时间显示
|
||||
orderTime: Long? = null, // 订单创建时间(毫秒时间戳),用于通知中的时间显示
|
||||
availableBalance: String? = null // 可用余额(可选)
|
||||
) {
|
||||
// 1. 如果提供了 orderId,检查是否已发送过通知(去重)
|
||||
if (orderId != null) {
|
||||
@@ -128,14 +132,21 @@ class TelegramNotificationService(
|
||||
java.util.Locale("zh", "CN") // 默认简体中文
|
||||
}
|
||||
|
||||
// 优先使用传入的价格和数量,如果没有提供则尝试从订单详情获取
|
||||
var actualPrice: String? = price
|
||||
// 优先使用平均成交价(实际成交价),其次传入的限价,若未提供则从订单详情获取
|
||||
var actualPrice: String? = avgFilledPrice?.takeIf { it.isNotBlank() } ?: price
|
||||
var actualSize: String? = size
|
||||
var actualSide: String = side
|
||||
var actualOutcome: String? = outcome
|
||||
|
||||
// 有平均成交价时,已成交数量优先用 filled,用于金额计算
|
||||
val sizeForAmount: String? = if (avgFilledPrice != null && avgFilledPrice.isNotBlank() && filled != null && filled.isNotBlank()) {
|
||||
filled
|
||||
} else {
|
||||
null
|
||||
}
|
||||
|
||||
// 如果价格或数量未提供,尝试从订单详情获取
|
||||
if ((actualPrice == null || actualSize == null) && orderId != null && clobApi != null && apiKey != null && apiSecret != null && apiPassphrase != null && walletAddressForApi != null) {
|
||||
// 如果价格、数量或市场方向未提供,尝试从订单详情获取
|
||||
if ((actualPrice == null || actualSize == null || actualOutcome == null) && orderId != null && clobApi != null && apiKey != null && apiSecret != null && apiPassphrase != null && walletAddressForApi != null) {
|
||||
try {
|
||||
val orderResponse = clobApi.getOrder(orderId)
|
||||
if (orderResponse.isSuccessful) {
|
||||
@@ -147,7 +158,8 @@ class TelegramNotificationService(
|
||||
if (actualSize == null) {
|
||||
actualSize = order.originalSize // 使用 originalSize 作为订单数量
|
||||
}
|
||||
actualSide = order.side // 使用订单详情中的 side
|
||||
// 注意:不覆盖 side,因为传入的 side(BUY/SELL)是正确的
|
||||
// actualSide = order.side // 不要使用订单详情中的 side,因为它可能不准确
|
||||
if (actualOutcome == null) {
|
||||
actualOutcome = order.outcome // 使用订单详情中的 outcome(市场方向)
|
||||
}
|
||||
@@ -165,19 +177,28 @@ class TelegramNotificationService(
|
||||
|
||||
// 如果仍然没有获取到实际值,使用默认值(这种情况不应该发生,但为了兼容性保留)
|
||||
val finalPrice = actualPrice ?: "0"
|
||||
val finalSize = actualSize ?: "0"
|
||||
// 有实际成交价时展示数量用 size_matched(filled),否则用订单数量(original_size)
|
||||
val finalSize = if (avgFilledPrice != null && avgFilledPrice.isNotBlank() && filled != null && filled.isNotBlank()) {
|
||||
filled
|
||||
} else {
|
||||
actualSize ?: "0"
|
||||
}
|
||||
// 金额计算:有实际成交价和已成交数量时用二者乘积,否则用展示价格×订单数量
|
||||
val sizeForCalc = sizeForAmount?.takeIf { it.isNotBlank() } ?: finalSize
|
||||
|
||||
// 计算订单金额 = price × size(USDC)
|
||||
val amount = try {
|
||||
val priceDecimal = finalPrice.toSafeBigDecimal()
|
||||
val sizeDecimal = finalSize.toSafeBigDecimal()
|
||||
val sizeDecimal = sizeForCalc.toSafeBigDecimal()
|
||||
priceDecimal.multiply(sizeDecimal).toString()
|
||||
} catch (e: Exception) {
|
||||
logger.warn("计算订单金额失败: ${e.message}", e)
|
||||
null
|
||||
}
|
||||
|
||||
val message = buildOrderSuccessMessage(
|
||||
val unknownAccount = messageSource.getMessage("notification.order.unknown_account", null, "未知账户", currentLocale).orEmpty().ifEmpty { "未知账户" }
|
||||
val calculateFailed = messageSource.getMessage("notification.order.calculate_failed", null, "计算失败", currentLocale).orEmpty().ifEmpty { "计算失败" }
|
||||
val vars = buildOrderSuccessVariables(
|
||||
orderId = orderId,
|
||||
marketTitle = marketTitle,
|
||||
marketId = marketId,
|
||||
@@ -192,8 +213,12 @@ class TelegramNotificationService(
|
||||
locale = currentLocale,
|
||||
leaderName = leaderName,
|
||||
configName = configName,
|
||||
orderTime = orderTime
|
||||
orderTime = orderTime,
|
||||
availableBalance = availableBalance,
|
||||
unknownAccount = unknownAccount,
|
||||
calculateFailed = calculateFailed
|
||||
)
|
||||
val message = notificationTemplateService.renderTemplate("ORDER_SUCCESS", vars)
|
||||
sendMessage(message)
|
||||
}
|
||||
|
||||
@@ -232,7 +257,9 @@ class TelegramNotificationService(
|
||||
null
|
||||
}
|
||||
|
||||
val message = buildOrderFailureMessage(
|
||||
val unknownAccount = messageSource.getMessage("notification.order.unknown_account", null, "未知账户", currentLocale).orEmpty().ifEmpty { "未知账户" }
|
||||
val calculateFailed = messageSource.getMessage("notification.order.calculate_failed", null, "计算失败", currentLocale).orEmpty().ifEmpty { "计算失败" }
|
||||
val vars = buildOrderFailureVariables(
|
||||
marketTitle = marketTitle,
|
||||
marketId = marketId,
|
||||
marketSlug = marketSlug,
|
||||
@@ -244,11 +271,65 @@ class TelegramNotificationService(
|
||||
errorMessage = errorMessage,
|
||||
accountName = accountName,
|
||||
walletAddress = walletAddress,
|
||||
locale = currentLocale
|
||||
locale = currentLocale,
|
||||
unknownAccount = unknownAccount,
|
||||
calculateFailed = calculateFailed
|
||||
)
|
||||
val message = notificationTemplateService.renderTemplate("ORDER_FAILED", vars)
|
||||
sendMessage(message)
|
||||
}
|
||||
|
||||
/**
|
||||
* 构建订单失败通知的变量 Map
|
||||
*/
|
||||
private fun buildOrderFailureVariables(
|
||||
marketTitle: String,
|
||||
marketId: String?,
|
||||
marketSlug: String?,
|
||||
side: String,
|
||||
outcome: String?,
|
||||
price: String,
|
||||
size: String,
|
||||
amount: String?,
|
||||
errorMessage: String,
|
||||
accountName: String?,
|
||||
walletAddress: String?,
|
||||
locale: java.util.Locale,
|
||||
unknownAccount: String,
|
||||
calculateFailed: String
|
||||
): Map<String, String> {
|
||||
val sideDisplay = when (side.uppercase()) {
|
||||
"BUY" -> messageSource.getMessage("notification.order.side.buy", null, "买入", locale).orEmpty().ifEmpty { "买入" }
|
||||
"SELL" -> messageSource.getMessage("notification.order.side.sell", null, "卖出", locale).orEmpty().ifEmpty { "卖出" }
|
||||
else -> side
|
||||
}
|
||||
val accountInfo = buildAccountInfo(accountName, walletAddress, unknownAccount)
|
||||
val marketLink = when {
|
||||
!marketSlug.isNullOrBlank() -> "https://polymarket.com/event/$marketSlug"
|
||||
!marketId.isNullOrBlank() && marketId.startsWith("0x") -> "https://polymarket.com/condition/$marketId"
|
||||
else -> ""
|
||||
}
|
||||
val amountDisplay = amount?.let { am ->
|
||||
try {
|
||||
val amountDecimal = am.toSafeBigDecimal()
|
||||
(if (amountDecimal.scale() > 4) amountDecimal.setScale(4, java.math.RoundingMode.DOWN).stripTrailingZeros() else amountDecimal.stripTrailingZeros()).toPlainString()
|
||||
} catch (e: Exception) { am }
|
||||
} ?: calculateFailed
|
||||
val shortError = if (errorMessage.length > 500) errorMessage.substring(0, 500) + "..." else errorMessage
|
||||
return mapOf(
|
||||
"market_title" to marketTitle.replace("<", "<").replace(">", ">"),
|
||||
"market_link" to marketLink,
|
||||
"side" to sideDisplay,
|
||||
"outcome" to (outcome?.replace("<", "<")?.replace(">", ">") ?: ""),
|
||||
"price" to formatPrice(price),
|
||||
"quantity" to formatQuantity(size),
|
||||
"amount" to amountDisplay,
|
||||
"account_name" to accountInfo,
|
||||
"error_message" to shortError.replace("<", "<").replace(">", ">"),
|
||||
"time" to DateUtils.formatDateTime()
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* 发送订单被过滤通知
|
||||
* @param locale 语言设置(可选,如果提供则使用,否则使用 LocaleContextHolder 获取)
|
||||
@@ -285,7 +366,9 @@ class TelegramNotificationService(
|
||||
null
|
||||
}
|
||||
|
||||
val message = buildOrderFilteredMessage(
|
||||
val unknownAccount = messageSource.getMessage("notification.order.unknown_account", null, "未知账户", currentLocale).orEmpty().ifEmpty { "未知账户" }
|
||||
val calculateFailed = messageSource.getMessage("notification.order.calculate_failed", null, "计算失败", currentLocale).orEmpty().ifEmpty { "计算失败" }
|
||||
val vars = buildOrderFilteredVariables(
|
||||
marketTitle = marketTitle,
|
||||
marketId = marketId,
|
||||
marketSlug = marketSlug,
|
||||
@@ -298,13 +381,75 @@ class TelegramNotificationService(
|
||||
filterType = filterType,
|
||||
accountName = accountName,
|
||||
walletAddress = walletAddress,
|
||||
locale = currentLocale
|
||||
locale = currentLocale,
|
||||
unknownAccount = unknownAccount,
|
||||
calculateFailed = calculateFailed
|
||||
)
|
||||
val message = notificationTemplateService.renderTemplate("ORDER_FILTERED", vars)
|
||||
sendMessage(message)
|
||||
}
|
||||
|
||||
private fun buildOrderFilteredVariables(
|
||||
marketTitle: String,
|
||||
marketId: String?,
|
||||
marketSlug: String?,
|
||||
side: String,
|
||||
outcome: String?,
|
||||
price: String,
|
||||
size: String,
|
||||
amount: String?,
|
||||
filterReason: String,
|
||||
filterType: String,
|
||||
accountName: String?,
|
||||
walletAddress: String?,
|
||||
locale: java.util.Locale,
|
||||
unknownAccount: String,
|
||||
calculateFailed: String
|
||||
): Map<String, String> {
|
||||
val sideDisplay = when (side.uppercase()) {
|
||||
"BUY" -> messageSource.getMessage("notification.order.side.buy", null, "买入", locale).orEmpty().ifEmpty { "买入" }
|
||||
"SELL" -> messageSource.getMessage("notification.order.side.sell", null, "卖出", locale).orEmpty().ifEmpty { "卖出" }
|
||||
else -> side
|
||||
}
|
||||
val filterTypeDisplay = when (filterType.uppercase()) {
|
||||
"ORDER_DEPTH" -> messageSource.getMessage("notification.filter.type.order_depth", null, "订单深度不足", locale).orEmpty().ifEmpty { "订单深度不足" }
|
||||
"SPREAD" -> messageSource.getMessage("notification.filter.type.spread", null, "价差过大", locale).orEmpty().ifEmpty { "价差过大" }
|
||||
"ORDERBOOK_DEPTH" -> messageSource.getMessage("notification.filter.type.orderbook_depth", null, "订单簿深度不足", locale).orEmpty().ifEmpty { "订单簿深度不足" }
|
||||
"PRICE_VALIDITY" -> messageSource.getMessage("notification.filter.type.price_validity", null, "价格不合理", locale).orEmpty().ifEmpty { "价格不合理" }
|
||||
"MARKET_STATUS" -> messageSource.getMessage("notification.filter.type.market_status", null, "市场状态不可交易", locale).orEmpty().ifEmpty { "市场状态不可交易" }
|
||||
else -> filterType
|
||||
}
|
||||
val accountInfo = buildAccountInfo(accountName, walletAddress, unknownAccount)
|
||||
val marketLink = when {
|
||||
!marketSlug.isNullOrBlank() -> "https://polymarket.com/event/$marketSlug"
|
||||
!marketId.isNullOrBlank() && marketId.startsWith("0x") -> "https://polymarket.com/condition/$marketId"
|
||||
else -> ""
|
||||
}
|
||||
val amountDisplay = amount?.let { am ->
|
||||
try {
|
||||
(am.toSafeBigDecimal().let { if (it.scale() > 4) it.setScale(4, java.math.RoundingMode.DOWN).stripTrailingZeros() else it.stripTrailingZeros() }.toPlainString())
|
||||
} catch (e: Exception) { am }
|
||||
} ?: calculateFailed
|
||||
return mapOf(
|
||||
"market_title" to marketTitle.replace("<", "<").replace(">", ">"),
|
||||
"market_link" to marketLink,
|
||||
"side" to sideDisplay,
|
||||
"outcome" to (outcome?.replace("<", "<")?.replace(">", ">") ?: ""),
|
||||
"price" to formatPrice(price),
|
||||
"quantity" to formatQuantity(size),
|
||||
"amount" to amountDisplay,
|
||||
"account_name" to accountInfo,
|
||||
"filter_type" to filterTypeDisplay,
|
||||
"filter_reason" to filterReason.replace("<", "<").replace(">", ">"),
|
||||
"time" to DateUtils.formatDateTime()
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* 发送加密价差策略下单成功通知(与跟单一致:在收到 WS 订单推送时匹配价差策略订单后调用)
|
||||
* @param price 订单限价
|
||||
* @param avgFilledPrice 平均成交价(可选,有成交时优先展示)
|
||||
* @param filled 已成交数量(可选,与 avgFilledPrice 一起时用于金额计算)
|
||||
*/
|
||||
suspend fun sendCryptoTailOrderSuccessNotification(
|
||||
orderId: String?,
|
||||
@@ -315,6 +460,8 @@ class TelegramNotificationService(
|
||||
outcome: String? = null,
|
||||
price: String,
|
||||
size: String,
|
||||
avgFilledPrice: String? = null,
|
||||
filled: String? = null,
|
||||
strategyName: String? = null,
|
||||
accountName: String? = null,
|
||||
walletAddress: String? = null,
|
||||
@@ -339,33 +486,95 @@ class TelegramNotificationService(
|
||||
logger.warn("获取语言设置失败,使用默认语言: ${e.message}", e)
|
||||
java.util.Locale("zh", "CN")
|
||||
}
|
||||
val displayPrice = avgFilledPrice?.takeIf { it.isNotBlank() } ?: price
|
||||
val hasAvgFilled = avgFilledPrice != null && avgFilledPrice.isNotBlank() && filled != null && filled.isNotBlank()
|
||||
val sizeForAmount = if (hasAvgFilled) filled else size
|
||||
val quantityDisplay = if (hasAvgFilled) filled else size // 有实际成交价时展示数量用 size_matched
|
||||
val amount = try {
|
||||
val priceDecimal = price.toSafeBigDecimal()
|
||||
val sizeDecimal = size.toSafeBigDecimal()
|
||||
val priceDecimal = displayPrice.toSafeBigDecimal()
|
||||
val sizeDecimal = sizeForAmount.toSafeBigDecimal()
|
||||
priceDecimal.multiply(sizeDecimal).toString()
|
||||
} catch (e: Exception) {
|
||||
logger.warn("计算订单金额失败: ${e.message}", e)
|
||||
null
|
||||
}
|
||||
val message = buildCryptoTailOrderSuccessMessage(
|
||||
val unknown = messageSource.getMessage("common.unknown", null, "未知", currentLocale).orEmpty().ifEmpty { "未知" }
|
||||
val unknownAccount = messageSource.getMessage("notification.order.unknown_account", null, "未知账户", currentLocale).orEmpty().ifEmpty { "未知账户" }
|
||||
val calculateFailed = messageSource.getMessage("notification.order.calculate_failed", null, "计算失败", currentLocale).orEmpty().ifEmpty { "计算失败" }
|
||||
val vars = buildCryptoTailOrderSuccessVariables(
|
||||
orderId = orderId,
|
||||
marketTitle = marketTitle,
|
||||
marketId = marketId,
|
||||
marketSlug = marketSlug,
|
||||
side = side,
|
||||
outcome = outcome,
|
||||
price = price,
|
||||
size = size,
|
||||
price = displayPrice,
|
||||
size = quantityDisplay.orEmpty(),
|
||||
amount = amount,
|
||||
strategyName = strategyName,
|
||||
accountName = accountName,
|
||||
walletAddress = walletAddress,
|
||||
locale = currentLocale,
|
||||
orderTime = orderTime
|
||||
orderTime = orderTime,
|
||||
unknown = unknown,
|
||||
unknownAccount = unknownAccount,
|
||||
calculateFailed = calculateFailed,
|
||||
locale = currentLocale
|
||||
)
|
||||
val message = notificationTemplateService.renderTemplate("CRYPTO_TAIL_SUCCESS", vars)
|
||||
sendMessage(message)
|
||||
}
|
||||
|
||||
private fun buildCryptoTailOrderSuccessVariables(
|
||||
orderId: String?,
|
||||
marketTitle: String,
|
||||
marketId: String?,
|
||||
marketSlug: String?,
|
||||
side: String,
|
||||
outcome: String?,
|
||||
price: String,
|
||||
size: String,
|
||||
amount: String?,
|
||||
strategyName: String?,
|
||||
accountName: String?,
|
||||
walletAddress: String?,
|
||||
orderTime: Long?,
|
||||
unknown: String,
|
||||
unknownAccount: String,
|
||||
calculateFailed: String,
|
||||
locale: java.util.Locale
|
||||
): Map<String, String> {
|
||||
val sideDisplay = when (side.uppercase()) {
|
||||
"BUY" -> messageSource.getMessage("notification.order.side.buy", null, "买入", locale).orEmpty().ifEmpty { "买入" }
|
||||
"SELL" -> messageSource.getMessage("notification.order.side.sell", null, "卖出", locale).orEmpty().ifEmpty { "卖出" }
|
||||
else -> side
|
||||
}
|
||||
val accountInfo = buildAccountInfo(accountName, walletAddress, unknownAccount)
|
||||
val time = if (orderTime != null) DateUtils.formatDateTime(orderTime) else DateUtils.formatDateTime()
|
||||
val marketLink = when {
|
||||
!marketSlug.isNullOrBlank() -> "https://polymarket.com/event/$marketSlug"
|
||||
!marketId.isNullOrBlank() && marketId.startsWith("0x") -> "https://polymarket.com/condition/$marketId"
|
||||
else -> ""
|
||||
}
|
||||
val amountDisplay = amount?.let { am ->
|
||||
try {
|
||||
(am.toSafeBigDecimal().let { if (it.scale() > 4) it.setScale(4, java.math.RoundingMode.DOWN).stripTrailingZeros() else it.stripTrailingZeros() }.toPlainString())
|
||||
} catch (e: Exception) { am }
|
||||
} ?: calculateFailed
|
||||
return mapOf(
|
||||
"order_id" to (orderId ?: unknown),
|
||||
"market_title" to marketTitle.replace("<", "<").replace(">", ">"),
|
||||
"market_link" to marketLink,
|
||||
"side" to sideDisplay,
|
||||
"outcome" to (outcome?.replace("<", "<")?.replace(">", ">") ?: ""),
|
||||
"price" to formatPrice(price),
|
||||
"quantity" to formatQuantity(size),
|
||||
"amount" to amountDisplay,
|
||||
"account_name" to accountInfo,
|
||||
"strategy_name" to (strategyName?.takeIf { it.isNotBlank() } ?: unknown),
|
||||
"time" to time
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* 构建订单被过滤消息
|
||||
*/
|
||||
@@ -748,6 +957,76 @@ class TelegramNotificationService(
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 构建订单成功通知的变量 Map(供模板渲染)
|
||||
*/
|
||||
private fun buildOrderSuccessVariables(
|
||||
orderId: String?,
|
||||
marketTitle: String,
|
||||
marketId: String?,
|
||||
marketSlug: String?,
|
||||
side: String,
|
||||
outcome: String?,
|
||||
price: String,
|
||||
size: String,
|
||||
amount: String?,
|
||||
accountName: String?,
|
||||
walletAddress: String?,
|
||||
locale: java.util.Locale,
|
||||
leaderName: String?,
|
||||
configName: String?,
|
||||
orderTime: Long?,
|
||||
availableBalance: String?,
|
||||
unknownAccount: String,
|
||||
calculateFailed: String
|
||||
): Map<String, String> {
|
||||
val sideDisplay = when (side.uppercase()) {
|
||||
"BUY" -> messageSource.getMessage("notification.order.side.buy", null, "买入", locale).orEmpty().ifEmpty { "买入" }
|
||||
"SELL" -> messageSource.getMessage("notification.order.side.sell", null, "卖出", locale).orEmpty().ifEmpty { "卖出" }
|
||||
else -> side
|
||||
}
|
||||
val unknown = messageSource.getMessage("common.unknown", null, "未知", locale).orEmpty().ifEmpty { "未知" }
|
||||
val accountInfo = buildAccountInfo(accountName, walletAddress, unknownAccount)
|
||||
val time = if (orderTime != null) DateUtils.formatDateTime(orderTime) else DateUtils.formatDateTime()
|
||||
val marketLink = when {
|
||||
!marketSlug.isNullOrBlank() -> "https://polymarket.com/event/$marketSlug"
|
||||
!marketId.isNullOrBlank() && marketId.startsWith("0x") -> "https://polymarket.com/condition/$marketId"
|
||||
else -> ""
|
||||
}
|
||||
val amountDisplay = when {
|
||||
amount != null -> try {
|
||||
val amountDecimal = amount.toSafeBigDecimal()
|
||||
val formatted = if (amountDecimal.scale() > 4) amountDecimal.setScale(4, java.math.RoundingMode.DOWN).stripTrailingZeros() else amountDecimal.stripTrailingZeros()
|
||||
formatted.toPlainString()
|
||||
} catch (e: Exception) { amount ?: calculateFailed }
|
||||
else -> calculateFailed
|
||||
}
|
||||
val availableBalanceDisplay = if (!availableBalance.isNullOrBlank()) {
|
||||
try {
|
||||
val balanceDecimal = availableBalance.toSafeBigDecimal()
|
||||
val formatted = if (balanceDecimal.scale() > 4) balanceDecimal.setScale(4, java.math.RoundingMode.DOWN).stripTrailingZeros() else balanceDecimal.stripTrailingZeros()
|
||||
formatted.toPlainString()
|
||||
} catch (e: Exception) { availableBalance ?: "" }
|
||||
} else { "" }
|
||||
val escapedMarketTitle = marketTitle.replace("<", "<").replace(">", ">")
|
||||
val escapedOutcome = outcome?.replace("<", "<")?.replace(">", ">") ?: ""
|
||||
return mapOf(
|
||||
"order_id" to (orderId ?: unknown),
|
||||
"market_title" to escapedMarketTitle,
|
||||
"market_link" to marketLink,
|
||||
"side" to sideDisplay,
|
||||
"outcome" to escapedOutcome,
|
||||
"price" to formatPrice(price),
|
||||
"quantity" to formatQuantity(size),
|
||||
"amount" to amountDisplay,
|
||||
"account_name" to accountInfo,
|
||||
"available_balance" to availableBalanceDisplay,
|
||||
"leader_name" to (leaderName ?: ""),
|
||||
"config_name" to (configName ?: ""),
|
||||
"time" to time
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* 构建订单成功消息
|
||||
*/
|
||||
@@ -766,7 +1045,8 @@ class TelegramNotificationService(
|
||||
locale: java.util.Locale,
|
||||
leaderName: String? = null, // Leader 名称(备注)
|
||||
configName: String? = null, // 跟单配置名
|
||||
orderTime: Long? = null // 订单创建时间(毫秒时间戳)
|
||||
orderTime: Long? = null, // 订单创建时间(毫秒时间戳)
|
||||
availableBalance: String? = null // 可用余额
|
||||
): String {
|
||||
|
||||
// 获取多语言文本
|
||||
@@ -781,6 +1061,7 @@ class TelegramNotificationService(
|
||||
val amountLabel = messageSource.getMessage("notification.order.amount", null, "金额", locale)
|
||||
val accountLabel = messageSource.getMessage("notification.order.account", null, "账户", locale)
|
||||
val timeLabel = messageSource.getMessage("notification.order.time", null, "时间", locale)
|
||||
val availableBalanceLabel = messageSource.getMessage("notification.order.available_balance", null, "可用余额", locale)
|
||||
val unknown = messageSource.getMessage("common.unknown", null, "未知", locale)
|
||||
val unknownAccount: String = messageSource.getMessage("notification.order.unknown_account", null, "未知账户", locale) ?: "未知账户"
|
||||
val calculateFailed = messageSource.getMessage("notification.order.calculate_failed", null, "计算失败", locale)
|
||||
@@ -879,6 +1160,23 @@ class TelegramNotificationService(
|
||||
val priceDisplay = formatPrice(price)
|
||||
val sizeDisplay = formatQuantity(size)
|
||||
|
||||
// 格式化可用余额
|
||||
val availableBalanceDisplay = if (!availableBalance.isNullOrBlank()) {
|
||||
try {
|
||||
val balanceDecimal = availableBalance.toSafeBigDecimal()
|
||||
val formatted = if (balanceDecimal.scale() > 4) {
|
||||
balanceDecimal.setScale(4, java.math.RoundingMode.DOWN).stripTrailingZeros()
|
||||
} else {
|
||||
balanceDecimal.stripTrailingZeros()
|
||||
}
|
||||
"\n• $availableBalanceLabel: <code>${formatted.toPlainString()}</code> USDC"
|
||||
} catch (e: Exception) {
|
||||
"\n• $availableBalanceLabel: <code>$availableBalance</code> USDC"
|
||||
}
|
||||
} else {
|
||||
""
|
||||
}
|
||||
|
||||
return """$icon <b>$orderCreatedSuccess</b>
|
||||
|
||||
📊 <b>$orderInfo:</b>
|
||||
@@ -888,7 +1186,7 @@ class TelegramNotificationService(
|
||||
• $priceLabel: <code>$priceDisplay</code>
|
||||
• $quantityLabel: <code>$sizeDisplay</code> shares
|
||||
• $amountLabel: <code>$amountDisplay</code> USDC
|
||||
• $accountLabel: $escapedAccountInfo$escapedCopyTradingInfo
|
||||
• $accountLabel: $escapedAccountInfo$escapedCopyTradingInfo$availableBalanceDisplay
|
||||
|
||||
⏰ $timeLabel: <code>$time</code>"""
|
||||
}
|
||||
@@ -1095,6 +1393,7 @@ class TelegramNotificationService(
|
||||
/**
|
||||
* 发送仓位赎回通知
|
||||
* @param locale 语言设置(可选,如果提供则使用,否则使用 LocaleContextHolder 获取)
|
||||
* @param availableBalance 可用余额(可选)
|
||||
*/
|
||||
suspend fun sendRedeemNotification(
|
||||
accountName: String?,
|
||||
@@ -1102,7 +1401,8 @@ class TelegramNotificationService(
|
||||
transactionHash: String,
|
||||
totalRedeemedValue: String,
|
||||
positions: List<com.wrbug.polymarketbot.dto.RedeemedPositionInfo>,
|
||||
locale: java.util.Locale? = null
|
||||
locale: java.util.Locale? = null,
|
||||
availableBalance: String? = null
|
||||
) {
|
||||
// 获取语言设置(优先使用传入的 locale,否则从 LocaleContextHolder 获取)
|
||||
val currentLocale = locale ?: try {
|
||||
@@ -1112,16 +1412,46 @@ class TelegramNotificationService(
|
||||
java.util.Locale("zh", "CN") // 默认简体中文
|
||||
}
|
||||
|
||||
val message = buildRedeemMessage(
|
||||
val unknownAccount = messageSource.getMessage("notification.order.unknown_account", null, "未知账户", currentLocale) ?: "未知账户"
|
||||
val vars = buildRedeemSuccessVariables(
|
||||
accountName = accountName,
|
||||
walletAddress = walletAddress,
|
||||
transactionHash = transactionHash,
|
||||
totalRedeemedValue = totalRedeemedValue,
|
||||
positions = positions,
|
||||
locale = currentLocale
|
||||
availableBalance = availableBalance,
|
||||
unknownAccount = unknownAccount
|
||||
)
|
||||
val message = notificationTemplateService.renderTemplate("REDEEM_SUCCESS", vars)
|
||||
sendMessage(message)
|
||||
}
|
||||
|
||||
private fun buildRedeemSuccessVariables(
|
||||
accountName: String?,
|
||||
walletAddress: String?,
|
||||
transactionHash: String,
|
||||
totalRedeemedValue: String,
|
||||
availableBalance: String?,
|
||||
unknownAccount: String
|
||||
): Map<String, String> {
|
||||
val accountInfo = buildAccountInfo(accountName, walletAddress, unknownAccount)
|
||||
val totalValueDisplay = try {
|
||||
val d = totalRedeemedValue.toSafeBigDecimal()
|
||||
(if (d.scale() > 4) d.setScale(4, java.math.RoundingMode.DOWN).stripTrailingZeros() else d.stripTrailingZeros()).toPlainString()
|
||||
} catch (e: Exception) { totalRedeemedValue }
|
||||
val availableBalanceDisplay = availableBalance?.let { ab ->
|
||||
try {
|
||||
val d = ab.toSafeBigDecimal()
|
||||
(if (d.scale() > 4) d.setScale(4, java.math.RoundingMode.DOWN).stripTrailingZeros() else d.stripTrailingZeros()).toPlainString()
|
||||
} catch (e: Exception) { ab }
|
||||
} ?: ""
|
||||
return mapOf(
|
||||
"account_name" to accountInfo,
|
||||
"transaction_hash" to transactionHash.replace("<", "<").replace(">", ">"),
|
||||
"total_value" to totalValueDisplay,
|
||||
"available_balance" to availableBalanceDisplay,
|
||||
"time" to DateUtils.formatDateTime()
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* 构建仓位赎回消息
|
||||
@@ -1132,7 +1462,8 @@ class TelegramNotificationService(
|
||||
transactionHash: String,
|
||||
totalRedeemedValue: String,
|
||||
positions: List<com.wrbug.polymarketbot.dto.RedeemedPositionInfo>,
|
||||
locale: java.util.Locale
|
||||
locale: java.util.Locale,
|
||||
availableBalance: String? = null
|
||||
): String {
|
||||
// 获取多语言文本
|
||||
val redeemSuccess = messageSource.getMessage("notification.redeem.success", null, "仓位赎回成功", locale)
|
||||
@@ -1145,6 +1476,7 @@ class TelegramNotificationService(
|
||||
val quantityLabel = messageSource.getMessage("notification.order.quantity", null, "数量", locale)
|
||||
val valueLabel = messageSource.getMessage("notification.order.amount", null, "金额", locale)
|
||||
val timeLabel = messageSource.getMessage("notification.order.time", null, "时间", locale)
|
||||
val availableBalanceLabel = messageSource.getMessage("notification.redeem.available_balance", null, "可用余额", locale)
|
||||
val unknownAccount: String = messageSource.getMessage("notification.order.unknown_account", null, "未知账户", locale) ?: "未知账户"
|
||||
|
||||
// 构建账户信息(格式:账户名(钱包地址))
|
||||
@@ -1186,19 +1518,152 @@ class TelegramNotificationService(
|
||||
" • ${position.marketId.substring(0, 8)}... (${position.side}): $quantityDisplay shares = $valueDisplay USDC"
|
||||
}
|
||||
|
||||
// 格式化可用余额
|
||||
val availableBalanceDisplay = if (!availableBalance.isNullOrBlank()) {
|
||||
try {
|
||||
val balanceDecimal = availableBalance.toSafeBigDecimal()
|
||||
val formatted = if (balanceDecimal.scale() > 4) {
|
||||
balanceDecimal.setScale(4, java.math.RoundingMode.DOWN).stripTrailingZeros()
|
||||
} else {
|
||||
balanceDecimal.stripTrailingZeros()
|
||||
}
|
||||
"\n• $availableBalanceLabel: <code>${formatted.toPlainString()}</code> USDC"
|
||||
} catch (e: Exception) {
|
||||
"\n• $availableBalanceLabel: <code>$availableBalance</code> USDC"
|
||||
}
|
||||
} else {
|
||||
""
|
||||
}
|
||||
|
||||
return """💸 <b>$redeemSuccess</b>
|
||||
|
||||
📊 <b>$redeemInfo:</b>
|
||||
• $accountLabel: $escapedAccountInfo
|
||||
• $transactionHashLabel: <code>$escapedTxHash</code>
|
||||
• $totalValueLabel: <code>$totalValueDisplay</code> USDC
|
||||
• $totalValueLabel: <code>$totalValueDisplay</code> USDC$availableBalanceDisplay
|
||||
|
||||
📦 <b>$positionsLabel:</b>
|
||||
$positionsText
|
||||
|
||||
⏰ $timeLabel: <code>$time</code>"""
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* 发送仓位已结算(无收益)通知
|
||||
* 用于输的仓位,赎回价值为 0 的情况
|
||||
*/
|
||||
suspend fun sendRedeemNoReturnNotification(
|
||||
accountName: String?,
|
||||
walletAddress: String?,
|
||||
transactionHash: String,
|
||||
positions: List<com.wrbug.polymarketbot.dto.RedeemedPositionInfo>,
|
||||
locale: java.util.Locale? = null,
|
||||
availableBalance: String? = null
|
||||
) {
|
||||
val currentLocale = locale ?: try {
|
||||
LocaleContextHolder.getLocale()
|
||||
} catch (e: Exception) {
|
||||
logger.warn("获取语言设置失败,使用默认语言: ${e.message}", e)
|
||||
java.util.Locale("zh", "CN")
|
||||
}
|
||||
|
||||
val unknownAccount = messageSource.getMessage("notification.order.unknown_account", null, "未知账户", currentLocale) ?: "未知账户"
|
||||
val vars = buildRedeemNoReturnVariables(
|
||||
accountName = accountName,
|
||||
walletAddress = walletAddress,
|
||||
transactionHash = transactionHash,
|
||||
availableBalance = availableBalance,
|
||||
unknownAccount = unknownAccount
|
||||
)
|
||||
val message = notificationTemplateService.renderTemplate("REDEEM_NO_RETURN", vars)
|
||||
sendMessage(message)
|
||||
}
|
||||
|
||||
private fun buildRedeemNoReturnVariables(
|
||||
accountName: String?,
|
||||
walletAddress: String?,
|
||||
transactionHash: String,
|
||||
availableBalance: String?,
|
||||
unknownAccount: String
|
||||
): Map<String, String> {
|
||||
val accountInfo = buildAccountInfo(accountName, walletAddress, unknownAccount)
|
||||
val availableBalanceDisplay = availableBalance?.let { ab ->
|
||||
try {
|
||||
val d = ab.toSafeBigDecimal()
|
||||
(if (d.scale() > 4) d.setScale(4, java.math.RoundingMode.DOWN).stripTrailingZeros() else d.stripTrailingZeros()).toPlainString()
|
||||
} catch (e: Exception) { ab }
|
||||
} ?: ""
|
||||
return mapOf(
|
||||
"account_name" to accountInfo,
|
||||
"transaction_hash" to transactionHash.replace("<", "<").replace(">", ">"),
|
||||
"available_balance" to availableBalanceDisplay,
|
||||
"time" to DateUtils.formatDateTime()
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* 构建仓位已结算(无收益)消息
|
||||
*/
|
||||
private fun buildRedeemNoReturnMessage(
|
||||
accountName: String?,
|
||||
walletAddress: String?,
|
||||
transactionHash: String,
|
||||
positions: List<com.wrbug.polymarketbot.dto.RedeemedPositionInfo>,
|
||||
locale: java.util.Locale,
|
||||
availableBalance: String? = null
|
||||
): String {
|
||||
val noReturnTitle = messageSource.getMessage("notification.redeem.no_return.title", null, "仓位已结算(无收益)", locale)
|
||||
val noReturnInfo = messageSource.getMessage("notification.redeem.no_return.info", null, "结算信息", locale)
|
||||
val noReturnMessage = messageSource.getMessage("notification.redeem.no_return.message", null, "市场已结算,您的预测未命中,赎回价值为 0。", locale)
|
||||
val accountLabel = messageSource.getMessage("notification.order.account", null, "账户", locale)
|
||||
val transactionHashLabel = messageSource.getMessage("notification.redeem.transaction_hash", null, "交易哈希", locale)
|
||||
val positionsLabel = messageSource.getMessage("notification.redeem.no_return.positions", null, "结算仓位", locale)
|
||||
val timeLabel = messageSource.getMessage("notification.order.time", null, "时间", locale)
|
||||
val availableBalanceLabel = messageSource.getMessage("notification.redeem.available_balance", null, "可用余额", locale)
|
||||
val unknownAccount: String = messageSource.getMessage("notification.order.unknown_account", null, "未知账户", locale) ?: "未知账户"
|
||||
|
||||
val accountInfo = buildAccountInfo(accountName, walletAddress, unknownAccount)
|
||||
val time = DateUtils.formatDateTime()
|
||||
|
||||
val escapedAccountInfo = accountInfo.replace("<", "<").replace(">", ">")
|
||||
val escapedTxHash = transactionHash.replace("<", "<").replace(">", ">")
|
||||
|
||||
val positionsText = positions.joinToString("\n") { position ->
|
||||
val quantityDisplay = formatQuantity(position.quantity)
|
||||
" • ${position.marketId.substring(0, 8)}... (${position.side}): $quantityDisplay shares"
|
||||
}
|
||||
|
||||
// 格式化可用余额
|
||||
val availableBalanceDisplay = if (!availableBalance.isNullOrBlank()) {
|
||||
try {
|
||||
val balanceDecimal = availableBalance.toSafeBigDecimal()
|
||||
val formatted = if (balanceDecimal.scale() > 4) {
|
||||
balanceDecimal.setScale(4, java.math.RoundingMode.DOWN).stripTrailingZeros()
|
||||
} else {
|
||||
balanceDecimal.stripTrailingZeros()
|
||||
}
|
||||
"\n• $availableBalanceLabel: <code>${formatted.toPlainString()}</code> USDC"
|
||||
} catch (e: Exception) {
|
||||
"\n• $availableBalanceLabel: <code>$availableBalance</code> USDC"
|
||||
}
|
||||
} else {
|
||||
""
|
||||
}
|
||||
|
||||
return """📋 <b>$noReturnTitle</b>
|
||||
|
||||
📊 <b>$noReturnInfo:</b>
|
||||
<i>$noReturnMessage</i>
|
||||
|
||||
• $accountLabel: $escapedAccountInfo
|
||||
• $transactionHashLabel: <code>$escapedTxHash</code>$availableBalanceDisplay
|
||||
|
||||
📦 <b>$positionsLabel:</b>
|
||||
$positionsText
|
||||
|
||||
⏰ $timeLabel: <code>$time</code>"""
|
||||
}
|
||||
|
||||
/**
|
||||
* 脱敏显示地址(只显示前6位和后4位)
|
||||
*/
|
||||
|
||||
+5
@@ -0,0 +1,5 @@
|
||||
-- 添加触发类型字段到加密价差策略触发记录表
|
||||
-- AUTO: 自动下单触发
|
||||
-- MANUAL: 手动下单触发
|
||||
ALTER TABLE crypto_tail_strategy_trigger
|
||||
ADD COLUMN trigger_type VARCHAR(20) DEFAULT 'AUTO' COMMENT '触发类型:AUTO(自动)或 MANUAL(手动)';
|
||||
@@ -0,0 +1,97 @@
|
||||
-- 消息模板表
|
||||
CREATE TABLE notification_templates (
|
||||
id BIGINT AUTO_INCREMENT PRIMARY KEY,
|
||||
template_type VARCHAR(50) NOT NULL COMMENT '模板类型',
|
||||
template_content TEXT NOT NULL COMMENT '模板内容,支持 {{variable}} 变量',
|
||||
is_default TINYINT(1) DEFAULT 0 COMMENT '是否使用默认模板(0=自定义,1=默认)',
|
||||
created_at BIGINT NOT NULL,
|
||||
updated_at BIGINT NOT NULL,
|
||||
UNIQUE KEY uk_template_type (template_type)
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='消息推送模板';
|
||||
|
||||
-- 插入默认模板
|
||||
INSERT INTO notification_templates (template_type, template_content, is_default, created_at, updated_at) VALUES
|
||||
('ORDER_SUCCESS', '🚀 <b>订单创建成功</b>
|
||||
|
||||
📊 <b>订单信息:</b>
|
||||
• 订单ID: <code>{{order_id}}</code>
|
||||
• 市场: <a href="{{market_link}}">{{market_title}}</a>
|
||||
• 市场方向: <b>{{outcome}}</b>
|
||||
• 方向: <b>{{side}}</b>
|
||||
• 价格: <code>{{price}}</code>
|
||||
• 数量: <code>{{quantity}}</code> shares
|
||||
• 金额: <code>{{amount}}</code> USDC
|
||||
• 账户: {{account_name}}
|
||||
• 可用余额: <code>{{available_balance}}</code> USDC
|
||||
|
||||
⏰ 时间: <code>{{time}}</code>', 1, UNIX_TIMESTAMP() * 1000, UNIX_TIMESTAMP() * 1000),
|
||||
|
||||
('ORDER_FAILED', '❌ <b>订单创建失败</b>
|
||||
|
||||
📊 <b>订单信息:</b>
|
||||
• 市场: <a href="{{market_link}}">{{market_title}}</a>
|
||||
• 市场方向: <b>{{outcome}}</b>
|
||||
• 方向: <b>{{side}}</b>
|
||||
• 价格: <code>{{price}}</code>
|
||||
• 数量: <code>{{quantity}}</code> shares
|
||||
• 金额: <code>{{amount}}</code> USDC
|
||||
• 账户: {{account_name}}
|
||||
|
||||
⚠️ <b>错误信息:</b>
|
||||
<code>{{error_message}}</code>
|
||||
|
||||
⏰ 时间: <code>{{time}}</code>', 1, UNIX_TIMESTAMP() * 1000, UNIX_TIMESTAMP() * 1000),
|
||||
|
||||
('ORDER_FILTERED', '🚫 <b>订单被过滤</b>
|
||||
|
||||
📊 <b>订单信息:</b>
|
||||
• 市场: <a href="{{market_link}}">{{market_title}}</a>
|
||||
• 市场方向: <b>{{outcome}}</b>
|
||||
• 方向: <b>{{side}}</b>
|
||||
• 价格: <code>{{price}}</code>
|
||||
• 数量: <code>{{quantity}}</code> shares
|
||||
• 金额: <code>{{amount}}</code> USDC
|
||||
• 账户: {{account_name}}
|
||||
|
||||
⚠️ <b>过滤类型:</b> <code>{{filter_type}}</code>
|
||||
|
||||
📝 <b>过滤原因:</b>
|
||||
<code>{{filter_reason}}</code>
|
||||
|
||||
⏰ 时间: <code>{{time}}</code>', 1, UNIX_TIMESTAMP() * 1000, UNIX_TIMESTAMP() * 1000),
|
||||
|
||||
('CRYPTO_TAIL_SUCCESS', '🚀 <b>加密价差策略下单成功</b>
|
||||
|
||||
📊 <b>订单信息:</b>
|
||||
• 订单ID: <code>{{order_id}}</code>
|
||||
• 策略: {{strategy_name}}
|
||||
• 市场: <a href="{{market_link}}">{{market_title}}</a>
|
||||
• 市场方向: <b>{{outcome}}</b>
|
||||
• 方向: <b>{{side}}</b>
|
||||
• 价格: <code>{{price}}</code>
|
||||
• 数量: <code>{{quantity}}</code> shares
|
||||
• 金额: <code>{{amount}}</code> USDC
|
||||
• 账户: {{account_name}}
|
||||
|
||||
⏰ 时间: <code>{{time}}</code>', 1, UNIX_TIMESTAMP() * 1000, UNIX_TIMESTAMP() * 1000),
|
||||
|
||||
('REDEEM_SUCCESS', '💸 <b>仓位赎回成功</b>
|
||||
|
||||
📊 <b>赎回信息:</b>
|
||||
• 账户: {{account_name}}
|
||||
• 交易哈希: <code>{{transaction_hash}}</code>
|
||||
• 赎回总价值: <code>{{total_value}}</code> USDC
|
||||
• 可用余额: <code>{{available_balance}}</code> USDC
|
||||
|
||||
⏰ 时间: <code>{{time}}</code>', 1, UNIX_TIMESTAMP() * 1000, UNIX_TIMESTAMP() * 1000),
|
||||
|
||||
('REDEEM_NO_RETURN', '📋 <b>仓位已结算(无收益)</b>
|
||||
|
||||
📊 <b>结算信息:</b>
|
||||
<i>市场已结算,您的预测未命中,赎回价值为 0。</i>
|
||||
|
||||
• 账户: {{account_name}}
|
||||
• 交易哈希: <code>{{transaction_hash}}</code>
|
||||
• 可用余额: <code>{{available_balance}}</code> USDC
|
||||
|
||||
⏰ 时间: <code>{{time}}</code>', 1, UNIX_TIMESTAMP() * 1000, UNIX_TIMESTAMP() * 1000);
|
||||
@@ -13,9 +13,18 @@ notification.order.quantity=Quantity
|
||||
notification.order.amount=Amount
|
||||
notification.order.account=Account
|
||||
notification.order.time=Time
|
||||
notification.order.available_balance=Available Balance
|
||||
notification.order.error_info=Error Information
|
||||
notification.order.unknown_account=Unknown Account
|
||||
notification.order.calculate_failed=Calculation Failed
|
||||
notification.order.filtered=Order Filtered
|
||||
notification.order.filter_reason=Filter Reason
|
||||
notification.order.filter_type=Filter Type
|
||||
notification.filter.type.order_depth=Insufficient Order Depth
|
||||
notification.filter.type.spread=Spread Too Large
|
||||
notification.filter.type.orderbook_depth=Insufficient Orderbook Depth
|
||||
notification.filter.type.price_validity=Invalid Price
|
||||
notification.filter.type.market_status=Market Not Tradable
|
||||
notification.tail.order.success=Crypto spread strategy order success
|
||||
notification.tail.strategy=Strategy
|
||||
notification.redeem.success=Position Redeemed Successfully
|
||||
@@ -26,6 +35,13 @@ notification.redeem.position_count=Position Count
|
||||
notification.redeem.positions=Redeemed Positions
|
||||
notification.redeem.account=Account
|
||||
notification.redeem.time=Time
|
||||
notification.redeem.available_balance=Available Balance
|
||||
|
||||
# Position Settled (No Return)
|
||||
notification.redeem.no_return.title=Position Settled (No Return)
|
||||
notification.redeem.no_return.info=Settlement Information
|
||||
notification.redeem.no_return.message=Market settled. Your prediction was incorrect. Redemption value is 0.
|
||||
notification.redeem.no_return.positions=Settled Positions
|
||||
|
||||
# Auto Redeem related notifications
|
||||
notification.auto_redeem.disabled.title=Auto Redeem Disabled
|
||||
|
||||
@@ -13,9 +13,18 @@ notification.order.quantity=数量
|
||||
notification.order.amount=金额
|
||||
notification.order.account=账户
|
||||
notification.order.time=时间
|
||||
notification.order.available_balance=可用余额
|
||||
notification.order.error_info=错误信息
|
||||
notification.order.unknown_account=未知账户
|
||||
notification.order.calculate_failed=计算失败
|
||||
notification.order.filtered=订单被过滤
|
||||
notification.order.filter_reason=过滤原因
|
||||
notification.order.filter_type=过滤类型
|
||||
notification.filter.type.order_depth=订单深度不足
|
||||
notification.filter.type.spread=价差过大
|
||||
notification.filter.type.orderbook_depth=订单簿深度不足
|
||||
notification.filter.type.price_validity=价格不合理
|
||||
notification.filter.type.market_status=市场状态不可交易
|
||||
notification.tail.order.success=加密价差策略下单成功
|
||||
notification.tail.strategy=策略
|
||||
notification.redeem.success=仓位赎回成功
|
||||
@@ -26,6 +35,13 @@ notification.redeem.position_count=仓位数量
|
||||
notification.redeem.positions=赎回仓位
|
||||
notification.redeem.account=账户
|
||||
notification.redeem.time=时间
|
||||
notification.redeem.available_balance=可用余额
|
||||
|
||||
# 仓位已结算(无收益)
|
||||
notification.redeem.no_return.title=仓位已结算(无收益)
|
||||
notification.redeem.no_return.info=结算信息
|
||||
notification.redeem.no_return.message=市场已结算,您的预测未命中,赎回价值为 0。
|
||||
notification.redeem.no_return.positions=结算仓位
|
||||
|
||||
# 自动赎回相关通知
|
||||
notification.auto_redeem.disabled.title=自动赎回未开启
|
||||
|
||||
@@ -13,9 +13,18 @@ notification.order.quantity=數量
|
||||
notification.order.amount=金額
|
||||
notification.order.account=賬戶
|
||||
notification.order.time=時間
|
||||
notification.order.available_balance=可用餘額
|
||||
notification.order.error_info=錯誤信息
|
||||
notification.order.unknown_account=未知賬戶
|
||||
notification.order.calculate_failed=計算失敗
|
||||
notification.order.filtered=訂單被過濾
|
||||
notification.order.filter_reason=過濾原因
|
||||
notification.order.filter_type=過濾類型
|
||||
notification.filter.type.order_depth=訂單深度不足
|
||||
notification.filter.type.spread=價差過大
|
||||
notification.filter.type.orderbook_depth=訂單簿深度不足
|
||||
notification.filter.type.price_validity=價格不合理
|
||||
notification.filter.type.market_status=市場狀態不可交易
|
||||
notification.tail.order.success=加密價差策略下單成功
|
||||
notification.tail.strategy=策略
|
||||
notification.redeem.success=倉位贖回成功
|
||||
@@ -26,6 +35,13 @@ notification.redeem.position_count=倉位數量
|
||||
notification.redeem.positions=贖回倉位
|
||||
notification.redeem.account=賬戶
|
||||
notification.redeem.time=時間
|
||||
notification.redeem.available_balance=可用餘額
|
||||
|
||||
# 倉位已結算(無收益)
|
||||
notification.redeem.no_return.title=倉位已結算(無收益)
|
||||
notification.redeem.no_return.info=結算信息
|
||||
notification.redeem.no_return.message=市場已結算,您的預測未命中,贖回價值為 0。
|
||||
notification.redeem.no_return.positions=結算倉位
|
||||
|
||||
# 自動贖回相關通知
|
||||
notification.auto_redeem.disabled.title=自動贖回未開啟
|
||||
|
||||
Reference in New Issue
Block a user