feat: add leader research agent
This commit is contained in:
@@ -95,6 +95,7 @@ coverage/
|
||||
# Test
|
||||
test-results/
|
||||
*.test.log
|
||||
.playwright-cli/
|
||||
|
||||
# Python
|
||||
__pycache__/
|
||||
|
||||
@@ -422,6 +422,7 @@ cd frontend
|
||||
- [跟单系统需求文档](docs/zh/copy-trading-requirements.md) - 后端 API 接口文档
|
||||
- [前端需求文档](docs/zh/copy-trading-frontend-requirements.md) - 前端功能文档
|
||||
- [跟单亏损诊断与风险安全带](docs/zh/copy-trading-risk-seatbelt.md) - PnL 诊断、安全带确认流程和运维排查命令
|
||||
- [Leader Research Agent](docs/zh/leader-research-agent.md) - 自动发现、纸跟、评分和禁用试跟审批说明
|
||||
- [动态更新文档](docs/zh/DYNAMIC_UPDATE.md) - 动态更新功能说明
|
||||
|
||||
### 🤝 贡献指南
|
||||
|
||||
+1
-1
@@ -420,6 +420,7 @@ The development documentation includes:
|
||||
- [Development Documentation](docs/en/DEVELOPMENT.md) - Development guide
|
||||
- [Copy Trading System Requirements](docs/zh/copy-trading-requirements.md) - Backend API documentation (Chinese only)
|
||||
- [Frontend Requirements](docs/zh/copy-trading-frontend-requirements.md) - Frontend feature documentation (Chinese only)
|
||||
- [Leader Research Agent](docs/zh/leader-research-agent.md) - Auto-discovery, paper trading, scoring, and disabled-trial approval guide (Chinese only)
|
||||
|
||||
### 🤝 Contributing
|
||||
|
||||
@@ -467,4 +468,3 @@ Thanks to all developers and users who have contributed to this project!
|
||||
---
|
||||
|
||||
**⭐ If this project helps you, please give it a Star!**
|
||||
|
||||
|
||||
+142
@@ -0,0 +1,142 @@
|
||||
package com.wrbug.polymarketbot.controller.copytrading.research
|
||||
|
||||
import com.wrbug.polymarketbot.dto.ApiResponse
|
||||
import com.wrbug.polymarketbot.dto.LeaderResearchApprovalRequest
|
||||
import com.wrbug.polymarketbot.dto.LeaderResearchApprovalResponse
|
||||
import com.wrbug.polymarketbot.dto.LeaderResearchCandidateDetailDto
|
||||
import com.wrbug.polymarketbot.dto.LeaderResearchCandidateListRequest
|
||||
import com.wrbug.polymarketbot.dto.LeaderResearchCandidateListResponse
|
||||
import com.wrbug.polymarketbot.dto.LeaderResearchEventDto
|
||||
import com.wrbug.polymarketbot.dto.LeaderPaperSessionDto
|
||||
import com.wrbug.polymarketbot.dto.LeaderResearchRunDto
|
||||
import com.wrbug.polymarketbot.dto.LeaderResearchRunRequest
|
||||
import com.wrbug.polymarketbot.dto.LeaderResearchSourceStateDto
|
||||
import com.wrbug.polymarketbot.dto.LeaderResearchSummaryDto
|
||||
import com.wrbug.polymarketbot.enums.ErrorCode
|
||||
import com.wrbug.polymarketbot.enums.LeaderResearchTriggerType
|
||||
import com.wrbug.polymarketbot.service.copytrading.research.LeaderResearchApprovalConfirmRequiredException
|
||||
import com.wrbug.polymarketbot.service.copytrading.research.LeaderResearchApprovalService
|
||||
import com.wrbug.polymarketbot.service.copytrading.research.LeaderResearchCandidateNotReadyException
|
||||
import com.wrbug.polymarketbot.service.copytrading.research.LeaderResearchCandidateLockedException
|
||||
import com.wrbug.polymarketbot.service.copytrading.research.LeaderResearchDuplicateTrialConfigException
|
||||
import com.wrbug.polymarketbot.service.copytrading.research.LeaderResearchJobService
|
||||
import com.wrbug.polymarketbot.service.copytrading.research.LeaderResearchMapper
|
||||
import com.wrbug.polymarketbot.service.copytrading.research.LeaderResearchRealMoneyForbiddenException
|
||||
import com.wrbug.polymarketbot.service.copytrading.research.LeaderResearchService
|
||||
import org.slf4j.LoggerFactory
|
||||
import org.springframework.context.MessageSource
|
||||
import org.springframework.http.ResponseEntity
|
||||
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
|
||||
|
||||
data class LeaderResearchDetailRequest(val candidateId: Long)
|
||||
data class LeaderResearchEventsRequest(val page: Int = 0, val size: Int = 50)
|
||||
data class LeaderResearchPaperSessionsRequest(val candidateId: Long)
|
||||
|
||||
@RestController
|
||||
@RequestMapping("/api/copy-trading/leader-research")
|
||||
class LeaderResearchController(
|
||||
private val jobService: LeaderResearchJobService,
|
||||
private val researchService: LeaderResearchService,
|
||||
private val approvalService: LeaderResearchApprovalService,
|
||||
private val mapper: LeaderResearchMapper,
|
||||
private val messageSource: MessageSource
|
||||
) {
|
||||
private val logger = LoggerFactory.getLogger(LeaderResearchController::class.java)
|
||||
|
||||
@PostMapping("/run")
|
||||
fun run(@RequestBody request: LeaderResearchRunRequest): ResponseEntity<ApiResponse<LeaderResearchRunDto>> {
|
||||
return try {
|
||||
val trigger = runCatching { LeaderResearchTriggerType.valueOf(request.triggerType.uppercase()) }
|
||||
.getOrDefault(LeaderResearchTriggerType.MANUAL)
|
||||
val run = jobService.runOnce(request.dryRun, trigger)
|
||||
ResponseEntity.ok(ApiResponse.success(mapper.runDto(run)))
|
||||
} catch (e: Exception) {
|
||||
logger.error("Leader research run failed", e)
|
||||
ResponseEntity.ok(ApiResponse.error(ErrorCode.SERVER_LEADER_RESEARCH_RUN_FAILED, e.message, messageSource))
|
||||
}
|
||||
}
|
||||
|
||||
@PostMapping("/summary")
|
||||
fun summary(): ResponseEntity<ApiResponse<LeaderResearchSummaryDto>> {
|
||||
return safe(ErrorCode.SERVER_LEADER_RESEARCH_FETCH_FAILED) { researchService.summary() }
|
||||
}
|
||||
|
||||
@PostMapping("/candidates/list")
|
||||
fun list(@RequestBody request: LeaderResearchCandidateListRequest): ResponseEntity<ApiResponse<LeaderResearchCandidateListResponse>> {
|
||||
return safe(ErrorCode.SERVER_LEADER_RESEARCH_FETCH_FAILED) { researchService.listCandidates(request) }
|
||||
}
|
||||
|
||||
@PostMapping("/candidates/detail")
|
||||
fun detail(@RequestBody request: LeaderResearchDetailRequest): ResponseEntity<ApiResponse<LeaderResearchCandidateDetailDto>> {
|
||||
if (request.candidateId <= 0) {
|
||||
return ResponseEntity.ok(ApiResponse.error(ErrorCode.PARAM_INVALID, "candidateId 无效", messageSource))
|
||||
}
|
||||
return safe(ErrorCode.SERVER_LEADER_RESEARCH_FETCH_FAILED) { researchService.detail(request.candidateId) }
|
||||
}
|
||||
|
||||
@PostMapping("/paper-sessions")
|
||||
fun paperSessions(@RequestBody request: LeaderResearchPaperSessionsRequest): ResponseEntity<ApiResponse<List<LeaderPaperSessionDto>>> {
|
||||
if (request.candidateId <= 0) {
|
||||
return ResponseEntity.ok(ApiResponse.error(ErrorCode.PARAM_INVALID, "candidateId 无效", messageSource))
|
||||
}
|
||||
return safe(ErrorCode.SERVER_LEADER_RESEARCH_FETCH_FAILED) { researchService.paperSessions(request.candidateId) }
|
||||
}
|
||||
|
||||
@PostMapping("/source-health")
|
||||
fun sourceHealth(): ResponseEntity<ApiResponse<List<LeaderResearchSourceStateDto>>> {
|
||||
return safe(ErrorCode.SERVER_LEADER_RESEARCH_FETCH_FAILED) { researchService.sourceHealth() }
|
||||
}
|
||||
|
||||
@PostMapping("/events/list")
|
||||
fun events(@RequestBody request: LeaderResearchEventsRequest): ResponseEntity<ApiResponse<List<LeaderResearchEventDto>>> {
|
||||
return safe(ErrorCode.SERVER_LEADER_RESEARCH_FETCH_FAILED) { researchService.events(request.page, request.size) }
|
||||
}
|
||||
|
||||
@PostMapping("/approval/create-disabled-trial-config")
|
||||
fun approve(@RequestBody request: LeaderResearchApprovalRequest): ResponseEntity<ApiResponse<LeaderResearchApprovalResponse>> {
|
||||
if (request.candidateId <= 0) {
|
||||
return ResponseEntity.ok(ApiResponse.error(ErrorCode.PARAM_INVALID, "candidateId 无效", messageSource))
|
||||
}
|
||||
if (request.accountId <= 0) {
|
||||
return ResponseEntity.ok(ApiResponse.error(ErrorCode.PARAM_ACCOUNT_ID_INVALID, messageSource = messageSource))
|
||||
}
|
||||
return try {
|
||||
approvalService.createDisabledTrialConfig(request).fold(
|
||||
onSuccess = { ResponseEntity.ok(ApiResponse.success(it)) },
|
||||
onFailure = { e -> errorResponse(e, ErrorCode.SERVER_LEADER_RESEARCH_APPROVAL_FAILED) }
|
||||
)
|
||||
} catch (e: Exception) {
|
||||
logger.error("Leader research approval failed", e)
|
||||
ResponseEntity.ok(ApiResponse.error(ErrorCode.SERVER_LEADER_RESEARCH_APPROVAL_FAILED, e.message, messageSource))
|
||||
}
|
||||
}
|
||||
|
||||
private fun <T> safe(errorCode: ErrorCode, block: () -> T): ResponseEntity<ApiResponse<T>> {
|
||||
return try {
|
||||
ResponseEntity.ok(ApiResponse.success(block()))
|
||||
} catch (e: Exception) {
|
||||
logger.error("Leader research request failed", e)
|
||||
ResponseEntity.ok(ApiResponse.error(errorCode, e.message, messageSource))
|
||||
}
|
||||
}
|
||||
|
||||
private fun <T> errorResponse(e: Throwable, fallback: ErrorCode): ResponseEntity<ApiResponse<T>> {
|
||||
val errorCode = when (e) {
|
||||
is LeaderResearchCandidateNotReadyException -> ErrorCode.LEADER_RESEARCH_CANDIDATE_NOT_READY
|
||||
is LeaderResearchApprovalConfirmRequiredException -> ErrorCode.LEADER_RESEARCH_APPROVAL_CONFIRM_REQUIRED
|
||||
is LeaderResearchDuplicateTrialConfigException -> ErrorCode.LEADER_RESEARCH_DUPLICATE_TRIAL_CONFIG
|
||||
is LeaderResearchRealMoneyForbiddenException -> ErrorCode.LEADER_RESEARCH_REAL_MONEY_FORBIDDEN
|
||||
is LeaderResearchCandidateLockedException -> ErrorCode.LEADER_RESEARCH_CANDIDATE_LOCKED
|
||||
is IllegalArgumentException -> when (e.message) {
|
||||
"账户不存在" -> ErrorCode.ACCOUNT_NOT_FOUND
|
||||
"候选不存在" -> ErrorCode.LEADER_RESEARCH_CANDIDATE_NOT_FOUND
|
||||
else -> ErrorCode.PARAM_ERROR
|
||||
}
|
||||
else -> fallback
|
||||
}
|
||||
return ResponseEntity.ok(ApiResponse.error(errorCode, null, messageSource))
|
||||
}
|
||||
}
|
||||
@@ -75,6 +75,12 @@ data class LeaderPoolItemDto(
|
||||
val lastPromotedAt: Long?,
|
||||
val cooldownUntil: Long?,
|
||||
val locked: Boolean,
|
||||
val researchCandidateId: Long?,
|
||||
val researchState: String?,
|
||||
val researchBadge: String?,
|
||||
val researchSummary: String?,
|
||||
val researchScore: String?,
|
||||
val researchUpdatedAt: Long?,
|
||||
val createdAt: Long,
|
||||
val updatedAt: Long
|
||||
)
|
||||
|
||||
@@ -0,0 +1,224 @@
|
||||
package com.wrbug.polymarketbot.dto
|
||||
|
||||
data class LeaderResearchRunRequest(
|
||||
val dryRun: Boolean = false,
|
||||
val triggerType: String = "MANUAL"
|
||||
)
|
||||
|
||||
data class LeaderResearchRunDto(
|
||||
val id: Long,
|
||||
val status: String,
|
||||
val triggerType: String,
|
||||
val dryRun: Boolean,
|
||||
val startedAt: Long,
|
||||
val finishedAt: Long?,
|
||||
val durationMs: Long?,
|
||||
val sourceCountsJson: String?,
|
||||
val candidateCountsJson: String?,
|
||||
val partialFailure: Boolean,
|
||||
val skippedReason: String?,
|
||||
val errorClass: String?,
|
||||
val errorMessage: String?
|
||||
)
|
||||
|
||||
data class LeaderResearchSummaryDto(
|
||||
val discoveredCount: Long,
|
||||
val candidateCount: Long,
|
||||
val paperCount: Long,
|
||||
val trialReadyCount: Long,
|
||||
val cooldownCount: Long,
|
||||
val retiredCount: Long,
|
||||
val activePaperSessions: Long,
|
||||
val pendingRiskCount: Long,
|
||||
val lastRun: LeaderResearchRunDto?,
|
||||
val sourceLimitations: List<String>
|
||||
)
|
||||
|
||||
data class LeaderResearchCandidateListRequest(
|
||||
val page: Int = 0,
|
||||
val size: Int = 20,
|
||||
val state: String? = null,
|
||||
val query: String? = null
|
||||
)
|
||||
|
||||
data class LeaderResearchCandidateListResponse(
|
||||
val list: List<LeaderResearchCandidateDto>,
|
||||
val total: Long,
|
||||
val summary: LeaderResearchSummaryDto
|
||||
)
|
||||
|
||||
data class LeaderResearchCandidateDto(
|
||||
val id: Long,
|
||||
val normalizedWallet: String,
|
||||
val leaderId: Long?,
|
||||
val leaderName: String?,
|
||||
val poolId: Long?,
|
||||
val poolStatus: String?,
|
||||
val suggestedFixedAmount: String?,
|
||||
val suggestedMaxDailyLoss: String?,
|
||||
val suggestedMaxDailyOrders: Int?,
|
||||
val suggestedMinPrice: String?,
|
||||
val suggestedMaxPrice: String?,
|
||||
val suggestedMaxPositionValue: String?,
|
||||
val researchState: String,
|
||||
val source: String,
|
||||
val sourceRank: Int?,
|
||||
val score: String?,
|
||||
val scoreVersion: String?,
|
||||
val reason: String?,
|
||||
val riskFlags: List<String>,
|
||||
val locked: Boolean,
|
||||
val agentOwned: Boolean,
|
||||
val provenance: String,
|
||||
val sourceEvidence: String?,
|
||||
val firstSeenAt: Long,
|
||||
val lastSourceSeenAt: Long?,
|
||||
val lastScoredAt: Long?,
|
||||
val cooldownUntil: Long?,
|
||||
val cooldownCount: Int,
|
||||
val trialReadyAt: Long?,
|
||||
val retiredAt: Long?,
|
||||
val lastPaperSessionId: Long?,
|
||||
val latestPaperSession: LeaderPaperSessionDto?
|
||||
)
|
||||
|
||||
data class LeaderResearchCandidateDetailDto(
|
||||
val candidate: LeaderResearchCandidateDto,
|
||||
val latestScore: LeaderResearchScoreDto?,
|
||||
val paperSessions: List<LeaderPaperSessionDto>,
|
||||
val paperTrades: List<LeaderPaperTradeDto>,
|
||||
val paperPositions: List<LeaderPaperPositionDto>,
|
||||
val events: List<LeaderResearchEventDto>
|
||||
)
|
||||
|
||||
data class LeaderResearchScoreDto(
|
||||
val id: Long,
|
||||
val candidateId: Long,
|
||||
val runId: Long?,
|
||||
val scoreVersion: String,
|
||||
val totalScore: String,
|
||||
val profitSignal: String,
|
||||
val repeatability: String,
|
||||
val liquidityFit: String,
|
||||
val entryPriceFit: String,
|
||||
val slippageRisk: String,
|
||||
val holdingPeriodFit: String,
|
||||
val marketTypeRisk: String,
|
||||
val drawdownRisk: String,
|
||||
val exitLiquidityRisk: String,
|
||||
val dataFreshness: String,
|
||||
val filterPassRate: String,
|
||||
val sampleTradeCount: Int,
|
||||
val reason: String?,
|
||||
val createdAt: Long
|
||||
)
|
||||
|
||||
data class LeaderPaperSessionDto(
|
||||
val id: Long,
|
||||
val candidateId: Long,
|
||||
val status: String,
|
||||
val startedAt: Long,
|
||||
val endedAt: Long?,
|
||||
val tradeCount: Int,
|
||||
val filteredCount: Int,
|
||||
val openExposure: String,
|
||||
val totalRealizedPnl: String,
|
||||
val totalUnrealizedPnl: String,
|
||||
val copyablePnl: String,
|
||||
val maxDrawdown: String,
|
||||
val unknownValuationExposure: String,
|
||||
val confirmedZeroExposure: String,
|
||||
val filteredRatio: String,
|
||||
val lastProcessedEventTime: Long?,
|
||||
val scoreSnapshot: String?
|
||||
)
|
||||
|
||||
data class LeaderPaperTradeDto(
|
||||
val id: Long,
|
||||
val sessionId: Long,
|
||||
val candidateId: Long,
|
||||
val activityEventId: Long?,
|
||||
val leaderTradeId: String,
|
||||
val marketId: String,
|
||||
val marketTitle: String?,
|
||||
val marketSlug: String?,
|
||||
val side: String,
|
||||
val outcome: String?,
|
||||
val outcomeIndex: Int?,
|
||||
val leaderPrice: String?,
|
||||
val leaderSize: String?,
|
||||
val simulatedPrice: String?,
|
||||
val simulatedSize: String?,
|
||||
val simulatedAmount: String?,
|
||||
val fillAssumption: String,
|
||||
val quoteConfidence: String,
|
||||
val quoteSource: String?,
|
||||
val quoteTimestamp: Long?,
|
||||
val filterResult: String,
|
||||
val filterReason: String?,
|
||||
val valuationStatus: String,
|
||||
val realizedPnl: String?,
|
||||
val eventTime: Long,
|
||||
val createdAt: Long
|
||||
)
|
||||
|
||||
data class LeaderPaperPositionDto(
|
||||
val id: Long,
|
||||
val sessionId: Long,
|
||||
val candidateId: Long,
|
||||
val marketId: String,
|
||||
val outcome: String?,
|
||||
val outcomeIndex: Int?,
|
||||
val quantity: String,
|
||||
val cost: String,
|
||||
val avgPrice: String,
|
||||
val currentPrice: String?,
|
||||
val currentValue: String,
|
||||
val realizedPnl: String,
|
||||
val unrealizedPnl: String,
|
||||
val valuationStatus: String,
|
||||
val quoteConfidence: String,
|
||||
val quoteSource: String?,
|
||||
val quoteTimestamp: Long?,
|
||||
val updatedAt: Long
|
||||
)
|
||||
|
||||
data class LeaderResearchSourceStateDto(
|
||||
val sourceType: String,
|
||||
val status: String,
|
||||
val lastSuccessAt: Long?,
|
||||
val lastFailureAt: Long?,
|
||||
val lastRunAt: Long?,
|
||||
val lastCandidateCount: Int,
|
||||
val errorClass: String?,
|
||||
val errorMessage: String?,
|
||||
val stale: Boolean,
|
||||
val disabledReason: String?,
|
||||
val lastCursor: String?,
|
||||
val updatedAt: Long
|
||||
)
|
||||
|
||||
data class LeaderResearchEventDto(
|
||||
val id: Long,
|
||||
val candidateId: Long?,
|
||||
val runId: Long?,
|
||||
val eventType: String,
|
||||
val reason: String?,
|
||||
val payloadSummary: String?,
|
||||
val notificationStatus: String,
|
||||
val notificationError: String?,
|
||||
val dedupeKey: String?,
|
||||
val createdAt: Long,
|
||||
val notifiedAt: Long?
|
||||
)
|
||||
|
||||
data class LeaderResearchApprovalRequest(
|
||||
val candidateId: Long,
|
||||
val accountId: Long,
|
||||
val confirm: Boolean = false
|
||||
)
|
||||
|
||||
data class LeaderResearchApprovalResponse(
|
||||
val copyTrading: CopyTradingDto,
|
||||
val warning: String = "已创建禁用状态的试跟配置;需要你手动启用后才会真钱跟单。"
|
||||
)
|
||||
@@ -1,6 +1,7 @@
|
||||
package com.wrbug.polymarketbot.entity
|
||||
|
||||
import com.wrbug.polymarketbot.enums.LeaderPoolStatus
|
||||
import com.wrbug.polymarketbot.enums.LeaderResearchState
|
||||
import jakarta.persistence.*
|
||||
import java.math.BigDecimal
|
||||
|
||||
@@ -63,6 +64,25 @@ data class LeaderPool(
|
||||
@Column(name = "locked", nullable = false)
|
||||
val locked: Boolean = false,
|
||||
|
||||
@Column(name = "research_candidate_id")
|
||||
val researchCandidateId: Long? = null,
|
||||
|
||||
@Enumerated(EnumType.STRING)
|
||||
@Column(name = "research_state", length = 30)
|
||||
val researchState: LeaderResearchState? = null,
|
||||
|
||||
@Column(name = "research_badge", length = 50)
|
||||
val researchBadge: String? = null,
|
||||
|
||||
@Column(name = "research_summary", columnDefinition = "TEXT")
|
||||
val researchSummary: String? = null,
|
||||
|
||||
@Column(name = "research_score", precision = 20, scale = 8)
|
||||
val researchScore: BigDecimal? = null,
|
||||
|
||||
@Column(name = "research_updated_at")
|
||||
val researchUpdatedAt: Long? = null,
|
||||
|
||||
@Column(name = "created_at", nullable = false)
|
||||
val createdAt: Long = System.currentTimeMillis(),
|
||||
|
||||
|
||||
@@ -0,0 +1,600 @@
|
||||
package com.wrbug.polymarketbot.entity
|
||||
|
||||
import com.wrbug.polymarketbot.enums.*
|
||||
import jakarta.persistence.*
|
||||
import java.math.BigDecimal
|
||||
|
||||
@Entity
|
||||
@Table(name = "leader_research_run")
|
||||
data class LeaderResearchRun(
|
||||
@Id
|
||||
@GeneratedValue(strategy = GenerationType.IDENTITY)
|
||||
val id: Long? = null,
|
||||
|
||||
@Enumerated(EnumType.STRING)
|
||||
@Column(name = "status", nullable = false, length = 30)
|
||||
val status: LeaderResearchRunStatus = LeaderResearchRunStatus.RUNNING,
|
||||
|
||||
@Enumerated(EnumType.STRING)
|
||||
@Column(name = "trigger_type", nullable = false, length = 30)
|
||||
val triggerType: LeaderResearchTriggerType = LeaderResearchTriggerType.MANUAL,
|
||||
|
||||
@Column(name = "dry_run", nullable = false)
|
||||
val dryRun: Boolean = false,
|
||||
|
||||
@Column(name = "started_at", nullable = false)
|
||||
val startedAt: Long = System.currentTimeMillis(),
|
||||
|
||||
@Column(name = "finished_at")
|
||||
val finishedAt: Long? = null,
|
||||
|
||||
@Column(name = "duration_ms")
|
||||
val durationMs: Long? = null,
|
||||
|
||||
@Column(name = "source_counts_json", columnDefinition = "TEXT")
|
||||
val sourceCountsJson: String? = null,
|
||||
|
||||
@Column(name = "candidate_counts_json", columnDefinition = "TEXT")
|
||||
val candidateCountsJson: String? = null,
|
||||
|
||||
@Column(name = "error_class")
|
||||
val errorClass: String? = null,
|
||||
|
||||
@Column(name = "error_message", columnDefinition = "TEXT")
|
||||
val errorMessage: String? = null,
|
||||
|
||||
@Column(name = "partial_failure", nullable = false)
|
||||
val partialFailure: Boolean = false,
|
||||
|
||||
@Column(name = "skipped_reason")
|
||||
val skippedReason: String? = null,
|
||||
|
||||
@Column(name = "last_event_cursor")
|
||||
val lastEventCursor: String? = null,
|
||||
|
||||
@Column(name = "created_at", nullable = false)
|
||||
val createdAt: Long = System.currentTimeMillis(),
|
||||
|
||||
@Column(name = "updated_at", nullable = false)
|
||||
val updatedAt: Long = System.currentTimeMillis()
|
||||
)
|
||||
|
||||
@Entity
|
||||
@Table(name = "leader_research_candidate")
|
||||
data class LeaderResearchCandidate(
|
||||
@Id
|
||||
@GeneratedValue(strategy = GenerationType.IDENTITY)
|
||||
val id: Long? = null,
|
||||
|
||||
@Column(name = "normalized_wallet", nullable = false, length = 42, unique = true)
|
||||
val normalizedWallet: String,
|
||||
|
||||
@Column(name = "leader_id")
|
||||
val leaderId: Long? = null,
|
||||
|
||||
@Column(name = "pool_id")
|
||||
val poolId: Long? = null,
|
||||
|
||||
@Enumerated(EnumType.STRING)
|
||||
@Column(name = "research_state", nullable = false, length = 30)
|
||||
val researchState: LeaderResearchState = LeaderResearchState.DISCOVERED,
|
||||
|
||||
@Column(name = "source", nullable = false, length = 50)
|
||||
val source: String = LeaderResearchSourceType.ACTIVITY_DERIVED.name,
|
||||
|
||||
@Column(name = "source_rank")
|
||||
val sourceRank: Int? = null,
|
||||
|
||||
@Column(name = "score", precision = 20, scale = 8)
|
||||
val score: BigDecimal? = null,
|
||||
|
||||
@Column(name = "score_version", length = 100)
|
||||
val scoreVersion: String? = null,
|
||||
|
||||
@Column(name = "reason", columnDefinition = "TEXT")
|
||||
val reason: String? = null,
|
||||
|
||||
@Column(name = "risk_flags", columnDefinition = "TEXT")
|
||||
val riskFlags: String? = null,
|
||||
|
||||
@Column(name = "locked", nullable = false)
|
||||
val locked: Boolean = false,
|
||||
|
||||
@Column(name = "agent_owned", nullable = false)
|
||||
val agentOwned: Boolean = true,
|
||||
|
||||
@Enumerated(EnumType.STRING)
|
||||
@Column(name = "provenance", nullable = false, length = 50)
|
||||
val provenance: LeaderCandidateProvenance = LeaderCandidateProvenance.AGENT_CREATED,
|
||||
|
||||
@Column(name = "source_evidence", columnDefinition = "TEXT")
|
||||
val sourceEvidence: String? = null,
|
||||
|
||||
@Column(name = "first_seen_at", nullable = false)
|
||||
val firstSeenAt: Long = System.currentTimeMillis(),
|
||||
|
||||
@Column(name = "last_source_seen_at")
|
||||
val lastSourceSeenAt: Long? = null,
|
||||
|
||||
@Column(name = "last_scored_at")
|
||||
val lastScoredAt: Long? = null,
|
||||
|
||||
@Column(name = "cooldown_until")
|
||||
val cooldownUntil: Long? = null,
|
||||
|
||||
@Column(name = "cooldown_count", nullable = false)
|
||||
val cooldownCount: Int = 0,
|
||||
|
||||
@Column(name = "last_transition_at")
|
||||
val lastTransitionAt: Long? = null,
|
||||
|
||||
@Column(name = "trial_ready_at")
|
||||
val trialReadyAt: Long? = null,
|
||||
|
||||
@Column(name = "retired_at")
|
||||
val retiredAt: Long? = null,
|
||||
|
||||
@Column(name = "last_paper_session_id")
|
||||
val lastPaperSessionId: Long? = null,
|
||||
|
||||
@Column(name = "created_at", nullable = false)
|
||||
val createdAt: Long = System.currentTimeMillis(),
|
||||
|
||||
@Column(name = "updated_at", nullable = false)
|
||||
val updatedAt: Long = System.currentTimeMillis()
|
||||
)
|
||||
|
||||
@Entity
|
||||
@Table(name = "leader_research_score")
|
||||
data class LeaderResearchScore(
|
||||
@Id
|
||||
@GeneratedValue(strategy = GenerationType.IDENTITY)
|
||||
val id: Long? = null,
|
||||
|
||||
@Column(name = "candidate_id", nullable = false)
|
||||
val candidateId: Long,
|
||||
|
||||
@Column(name = "run_id")
|
||||
val runId: Long? = null,
|
||||
|
||||
@Column(name = "score_version", nullable = false, length = 100)
|
||||
val scoreVersion: String,
|
||||
|
||||
@Column(name = "total_score", nullable = false, precision = 20, scale = 8)
|
||||
val totalScore: BigDecimal = BigDecimal.ZERO,
|
||||
|
||||
@Column(name = "profit_signal", nullable = false, precision = 20, scale = 8)
|
||||
val profitSignal: BigDecimal = BigDecimal.ZERO,
|
||||
|
||||
@Column(name = "repeatability", nullable = false, precision = 20, scale = 8)
|
||||
val repeatability: BigDecimal = BigDecimal.ZERO,
|
||||
|
||||
@Column(name = "liquidity_fit", nullable = false, precision = 20, scale = 8)
|
||||
val liquidityFit: BigDecimal = BigDecimal.ZERO,
|
||||
|
||||
@Column(name = "entry_price_fit", nullable = false, precision = 20, scale = 8)
|
||||
val entryPriceFit: BigDecimal = BigDecimal.ZERO,
|
||||
|
||||
@Column(name = "slippage_risk", nullable = false, precision = 20, scale = 8)
|
||||
val slippageRisk: BigDecimal = BigDecimal.ZERO,
|
||||
|
||||
@Column(name = "holding_period_fit", nullable = false, precision = 20, scale = 8)
|
||||
val holdingPeriodFit: BigDecimal = BigDecimal.ZERO,
|
||||
|
||||
@Column(name = "market_type_risk", nullable = false, precision = 20, scale = 8)
|
||||
val marketTypeRisk: BigDecimal = BigDecimal.ZERO,
|
||||
|
||||
@Column(name = "drawdown_risk", nullable = false, precision = 20, scale = 8)
|
||||
val drawdownRisk: BigDecimal = BigDecimal.ZERO,
|
||||
|
||||
@Column(name = "exit_liquidity_risk", nullable = false, precision = 20, scale = 8)
|
||||
val exitLiquidityRisk: BigDecimal = BigDecimal.ZERO,
|
||||
|
||||
@Column(name = "data_freshness", nullable = false, precision = 20, scale = 8)
|
||||
val dataFreshness: BigDecimal = BigDecimal.ZERO,
|
||||
|
||||
@Column(name = "filter_pass_rate", nullable = false, precision = 20, scale = 8)
|
||||
val filterPassRate: BigDecimal = BigDecimal.ZERO,
|
||||
|
||||
@Column(name = "sample_trade_count", nullable = false)
|
||||
val sampleTradeCount: Int = 0,
|
||||
|
||||
@Column(name = "reason", columnDefinition = "TEXT")
|
||||
val reason: String? = null,
|
||||
|
||||
@Column(name = "created_at", nullable = false)
|
||||
val createdAt: Long = System.currentTimeMillis()
|
||||
)
|
||||
|
||||
@Entity
|
||||
@Table(name = "leader_research_event")
|
||||
data class LeaderResearchEvent(
|
||||
@Id
|
||||
@GeneratedValue(strategy = GenerationType.IDENTITY)
|
||||
val id: Long? = null,
|
||||
|
||||
@Column(name = "candidate_id")
|
||||
val candidateId: Long? = null,
|
||||
|
||||
@Column(name = "run_id")
|
||||
val runId: Long? = null,
|
||||
|
||||
@Enumerated(EnumType.STRING)
|
||||
@Column(name = "event_type", nullable = false, length = 50)
|
||||
val eventType: LeaderResearchEventType,
|
||||
|
||||
@Column(name = "reason", columnDefinition = "TEXT")
|
||||
val reason: String? = null,
|
||||
|
||||
@Column(name = "payload_summary", columnDefinition = "TEXT")
|
||||
val payloadSummary: String? = null,
|
||||
|
||||
@Enumerated(EnumType.STRING)
|
||||
@Column(name = "notification_status", nullable = false, length = 30)
|
||||
val notificationStatus: LeaderResearchNotificationStatus = LeaderResearchNotificationStatus.PENDING,
|
||||
|
||||
@Column(name = "notification_error", columnDefinition = "TEXT")
|
||||
val notificationError: String? = null,
|
||||
|
||||
@Column(name = "dedupe_key")
|
||||
val dedupeKey: String? = null,
|
||||
|
||||
@Column(name = "created_at", nullable = false)
|
||||
val createdAt: Long = System.currentTimeMillis(),
|
||||
|
||||
@Column(name = "notified_at")
|
||||
val notifiedAt: Long? = null
|
||||
)
|
||||
|
||||
@Entity
|
||||
@Table(name = "leader_research_source_state")
|
||||
data class LeaderResearchSourceState(
|
||||
@Id
|
||||
@GeneratedValue(strategy = GenerationType.IDENTITY)
|
||||
val id: Long? = null,
|
||||
|
||||
@Enumerated(EnumType.STRING)
|
||||
@Column(name = "source_type", nullable = false, length = 50, unique = true)
|
||||
val sourceType: LeaderResearchSourceType,
|
||||
|
||||
@Enumerated(EnumType.STRING)
|
||||
@Column(name = "status", nullable = false, length = 30)
|
||||
val status: LeaderResearchSourceStatus = LeaderResearchSourceStatus.DISABLED,
|
||||
|
||||
@Column(name = "last_success_at")
|
||||
val lastSuccessAt: Long? = null,
|
||||
|
||||
@Column(name = "last_failure_at")
|
||||
val lastFailureAt: Long? = null,
|
||||
|
||||
@Column(name = "last_run_at")
|
||||
val lastRunAt: Long? = null,
|
||||
|
||||
@Column(name = "last_candidate_count", nullable = false)
|
||||
val lastCandidateCount: Int = 0,
|
||||
|
||||
@Column(name = "error_class")
|
||||
val errorClass: String? = null,
|
||||
|
||||
@Column(name = "error_message", columnDefinition = "TEXT")
|
||||
val errorMessage: String? = null,
|
||||
|
||||
@Column(name = "stale", nullable = false)
|
||||
val stale: Boolean = false,
|
||||
|
||||
@Column(name = "disabled_reason")
|
||||
val disabledReason: String? = null,
|
||||
|
||||
@Column(name = "last_cursor")
|
||||
val lastCursor: String? = null,
|
||||
|
||||
@Column(name = "created_at", nullable = false)
|
||||
val createdAt: Long = System.currentTimeMillis(),
|
||||
|
||||
@Column(name = "updated_at", nullable = false)
|
||||
val updatedAt: Long = System.currentTimeMillis()
|
||||
)
|
||||
|
||||
@Entity
|
||||
@Table(name = "leader_activity_event")
|
||||
data class LeaderActivityEvent(
|
||||
@Id
|
||||
@GeneratedValue(strategy = GenerationType.IDENTITY)
|
||||
val id: Long? = null,
|
||||
|
||||
@Column(name = "source", nullable = false, length = 50)
|
||||
val source: String,
|
||||
|
||||
@Column(name = "source_event_id")
|
||||
val sourceEventId: String? = null,
|
||||
|
||||
@Column(name = "stable_event_key", nullable = false, unique = true)
|
||||
val stableEventKey: String,
|
||||
|
||||
@Column(name = "normalized_wallet", length = 42)
|
||||
val normalizedWallet: String? = null,
|
||||
|
||||
@Column(name = "market_id")
|
||||
val marketId: String? = null,
|
||||
|
||||
@Column(name = "market_title")
|
||||
val marketTitle: String? = null,
|
||||
|
||||
@Column(name = "market_slug")
|
||||
val marketSlug: String? = null,
|
||||
|
||||
@Column(name = "asset")
|
||||
val asset: String? = null,
|
||||
|
||||
@Column(name = "side", length = 20)
|
||||
val side: String? = null,
|
||||
|
||||
@Column(name = "outcome")
|
||||
val outcome: String? = null,
|
||||
|
||||
@Column(name = "outcome_index")
|
||||
val outcomeIndex: Int? = null,
|
||||
|
||||
@Column(name = "price", precision = 20, scale = 8)
|
||||
val price: BigDecimal? = null,
|
||||
|
||||
@Column(name = "size", precision = 20, scale = 8)
|
||||
val size: BigDecimal? = null,
|
||||
|
||||
@Column(name = "amount", precision = 20, scale = 8)
|
||||
val amount: BigDecimal? = null,
|
||||
|
||||
@Column(name = "event_time", nullable = false)
|
||||
val eventTime: Long,
|
||||
|
||||
@Column(name = "raw_payload_hash", nullable = false, length = 128)
|
||||
val rawPayloadHash: String = "",
|
||||
|
||||
@Column(name = "payload_summary", columnDefinition = "TEXT")
|
||||
val payloadSummary: String? = null,
|
||||
|
||||
@Column(name = "usable_for_discovery", nullable = false)
|
||||
val usableForDiscovery: Boolean = false,
|
||||
|
||||
@Column(name = "usable_for_paper", nullable = false)
|
||||
val usableForPaper: Boolean = false,
|
||||
|
||||
@Column(name = "unusable_reason")
|
||||
val unusableReason: String? = null,
|
||||
|
||||
@Enumerated(EnumType.STRING)
|
||||
@Column(name = "paper_processing_status", nullable = false, length = 30)
|
||||
val paperProcessingStatus: LeaderPaperProcessingStatus = LeaderPaperProcessingStatus.NEW,
|
||||
|
||||
@Column(name = "processing_attempts", nullable = false)
|
||||
val processingAttempts: Int = 0,
|
||||
|
||||
@Column(name = "paper_processing_started_at")
|
||||
val paperProcessingStartedAt: Long? = null,
|
||||
|
||||
@Column(name = "paper_processed_at")
|
||||
val paperProcessedAt: Long? = null,
|
||||
|
||||
@Column(name = "last_processing_error", columnDefinition = "TEXT")
|
||||
val lastProcessingError: String? = null,
|
||||
|
||||
@Column(name = "created_at", nullable = false)
|
||||
val createdAt: Long = System.currentTimeMillis(),
|
||||
|
||||
@Column(name = "updated_at", nullable = false)
|
||||
val updatedAt: Long = System.currentTimeMillis()
|
||||
)
|
||||
|
||||
@Entity
|
||||
@Table(name = "leader_paper_session")
|
||||
data class LeaderPaperSession(
|
||||
@Id
|
||||
@GeneratedValue(strategy = GenerationType.IDENTITY)
|
||||
val id: Long? = null,
|
||||
|
||||
@Column(name = "candidate_id", nullable = false)
|
||||
val candidateId: Long,
|
||||
|
||||
@Enumerated(EnumType.STRING)
|
||||
@Column(name = "status", nullable = false, length = 30)
|
||||
val status: LeaderPaperSessionStatus = LeaderPaperSessionStatus.ACTIVE,
|
||||
|
||||
@Column(name = "started_at", nullable = false)
|
||||
val startedAt: Long = System.currentTimeMillis(),
|
||||
|
||||
@Column(name = "ended_at")
|
||||
val endedAt: Long? = null,
|
||||
|
||||
@Column(name = "trade_count", nullable = false)
|
||||
val tradeCount: Int = 0,
|
||||
|
||||
@Column(name = "filtered_count", nullable = false)
|
||||
val filteredCount: Int = 0,
|
||||
|
||||
@Column(name = "open_exposure", nullable = false, precision = 20, scale = 8)
|
||||
val openExposure: BigDecimal = BigDecimal.ZERO,
|
||||
|
||||
@Column(name = "total_realized_pnl", nullable = false, precision = 20, scale = 8)
|
||||
val totalRealizedPnl: BigDecimal = BigDecimal.ZERO,
|
||||
|
||||
@Column(name = "total_unrealized_pnl", nullable = false, precision = 20, scale = 8)
|
||||
val totalUnrealizedPnl: BigDecimal = BigDecimal.ZERO,
|
||||
|
||||
@Column(name = "copyable_pnl", nullable = false, precision = 20, scale = 8)
|
||||
val copyablePnl: BigDecimal = BigDecimal.ZERO,
|
||||
|
||||
@Column(name = "max_drawdown", nullable = false, precision = 20, scale = 8)
|
||||
val maxDrawdown: BigDecimal = BigDecimal.ZERO,
|
||||
|
||||
@Column(name = "unknown_valuation_exposure", nullable = false, precision = 20, scale = 8)
|
||||
val unknownValuationExposure: BigDecimal = BigDecimal.ZERO,
|
||||
|
||||
@Column(name = "confirmed_zero_exposure", nullable = false, precision = 20, scale = 8)
|
||||
val confirmedZeroExposure: BigDecimal = BigDecimal.ZERO,
|
||||
|
||||
@Column(name = "filtered_ratio", nullable = false, precision = 20, scale = 8)
|
||||
val filteredRatio: BigDecimal = BigDecimal.ZERO,
|
||||
|
||||
@Column(name = "last_processed_event_time")
|
||||
val lastProcessedEventTime: Long? = null,
|
||||
|
||||
@Column(name = "score_snapshot", precision = 20, scale = 8)
|
||||
val scoreSnapshot: BigDecimal? = null,
|
||||
|
||||
@Column(name = "created_at", nullable = false)
|
||||
val createdAt: Long = System.currentTimeMillis(),
|
||||
|
||||
@Column(name = "updated_at", nullable = false)
|
||||
val updatedAt: Long = System.currentTimeMillis()
|
||||
)
|
||||
|
||||
@Entity
|
||||
@Table(name = "leader_paper_trade")
|
||||
data class LeaderPaperTrade(
|
||||
@Id
|
||||
@GeneratedValue(strategy = GenerationType.IDENTITY)
|
||||
val id: Long? = null,
|
||||
|
||||
@Column(name = "session_id", nullable = false)
|
||||
val sessionId: Long,
|
||||
|
||||
@Column(name = "candidate_id", nullable = false)
|
||||
val candidateId: Long,
|
||||
|
||||
@Column(name = "activity_event_id")
|
||||
val activityEventId: Long? = null,
|
||||
|
||||
@Column(name = "leader_trade_id", nullable = false)
|
||||
val leaderTradeId: String,
|
||||
|
||||
@Column(name = "market_id", nullable = false)
|
||||
val marketId: String,
|
||||
|
||||
@Column(name = "market_title")
|
||||
val marketTitle: String? = null,
|
||||
|
||||
@Column(name = "market_slug")
|
||||
val marketSlug: String? = null,
|
||||
|
||||
@Column(name = "side", nullable = false, length = 20)
|
||||
val side: String,
|
||||
|
||||
@Column(name = "outcome")
|
||||
val outcome: String? = null,
|
||||
|
||||
@Column(name = "outcome_index")
|
||||
val outcomeIndex: Int? = null,
|
||||
|
||||
@Column(name = "leader_price", precision = 20, scale = 8)
|
||||
val leaderPrice: BigDecimal? = null,
|
||||
|
||||
@Column(name = "leader_size", precision = 20, scale = 8)
|
||||
val leaderSize: BigDecimal? = null,
|
||||
|
||||
@Column(name = "simulated_price", precision = 20, scale = 8)
|
||||
val simulatedPrice: BigDecimal? = null,
|
||||
|
||||
@Column(name = "simulated_size", precision = 20, scale = 8)
|
||||
val simulatedSize: BigDecimal? = null,
|
||||
|
||||
@Column(name = "simulated_amount", precision = 20, scale = 8)
|
||||
val simulatedAmount: BigDecimal? = null,
|
||||
|
||||
@Enumerated(EnumType.STRING)
|
||||
@Column(name = "fill_assumption", nullable = false, length = 30)
|
||||
val fillAssumption: LeaderPaperFillAssumption = LeaderPaperFillAssumption.LEADER_PRICE,
|
||||
|
||||
@Enumerated(EnumType.STRING)
|
||||
@Column(name = "quote_confidence", nullable = false, length = 30)
|
||||
val quoteConfidence: LeaderResearchQuoteConfidence = LeaderResearchQuoteConfidence.UNKNOWN,
|
||||
|
||||
@Column(name = "quote_source", length = 50)
|
||||
val quoteSource: String? = null,
|
||||
|
||||
@Column(name = "quote_timestamp")
|
||||
val quoteTimestamp: Long? = null,
|
||||
|
||||
@Enumerated(EnumType.STRING)
|
||||
@Column(name = "filter_result", nullable = false, length = 30)
|
||||
val filterResult: LeaderPaperFilterResult = LeaderPaperFilterResult.PASSED,
|
||||
|
||||
@Column(name = "filter_reason", columnDefinition = "TEXT")
|
||||
val filterReason: String? = null,
|
||||
|
||||
@Enumerated(EnumType.STRING)
|
||||
@Column(name = "valuation_status", nullable = false, length = 30)
|
||||
val valuationStatus: LeaderResearchValuationStatus = LeaderResearchValuationStatus.UNKNOWN,
|
||||
|
||||
@Column(name = "realized_pnl", precision = 20, scale = 8)
|
||||
val realizedPnl: BigDecimal? = null,
|
||||
|
||||
@Column(name = "event_time", nullable = false)
|
||||
val eventTime: Long,
|
||||
|
||||
@Column(name = "created_at", nullable = false)
|
||||
val createdAt: Long = System.currentTimeMillis()
|
||||
)
|
||||
|
||||
@Entity
|
||||
@Table(name = "leader_paper_position")
|
||||
data class LeaderPaperPosition(
|
||||
@Id
|
||||
@GeneratedValue(strategy = GenerationType.IDENTITY)
|
||||
val id: Long? = null,
|
||||
|
||||
@Column(name = "session_id", nullable = false)
|
||||
val sessionId: Long,
|
||||
|
||||
@Column(name = "candidate_id", nullable = false)
|
||||
val candidateId: Long,
|
||||
|
||||
@Column(name = "market_id", nullable = false)
|
||||
val marketId: String,
|
||||
|
||||
@Column(name = "outcome")
|
||||
val outcome: String? = null,
|
||||
|
||||
@Column(name = "outcome_index")
|
||||
val outcomeIndex: Int? = null,
|
||||
|
||||
@Column(name = "quantity", nullable = false, precision = 20, scale = 8)
|
||||
val quantity: BigDecimal = BigDecimal.ZERO,
|
||||
|
||||
@Column(name = "cost", nullable = false, precision = 20, scale = 8)
|
||||
val cost: BigDecimal = BigDecimal.ZERO,
|
||||
|
||||
@Column(name = "avg_price", nullable = false, precision = 20, scale = 8)
|
||||
val avgPrice: BigDecimal = BigDecimal.ZERO,
|
||||
|
||||
@Column(name = "current_price", precision = 20, scale = 8)
|
||||
val currentPrice: BigDecimal? = null,
|
||||
|
||||
@Column(name = "current_value", nullable = false, precision = 20, scale = 8)
|
||||
val currentValue: BigDecimal = BigDecimal.ZERO,
|
||||
|
||||
@Column(name = "realized_pnl", nullable = false, precision = 20, scale = 8)
|
||||
val realizedPnl: BigDecimal = BigDecimal.ZERO,
|
||||
|
||||
@Column(name = "unrealized_pnl", nullable = false, precision = 20, scale = 8)
|
||||
val unrealizedPnl: BigDecimal = BigDecimal.ZERO,
|
||||
|
||||
@Enumerated(EnumType.STRING)
|
||||
@Column(name = "valuation_status", nullable = false, length = 30)
|
||||
val valuationStatus: LeaderResearchValuationStatus = LeaderResearchValuationStatus.UNKNOWN,
|
||||
|
||||
@Enumerated(EnumType.STRING)
|
||||
@Column(name = "quote_confidence", nullable = false, length = 30)
|
||||
val quoteConfidence: LeaderResearchQuoteConfidence = LeaderResearchQuoteConfidence.UNKNOWN,
|
||||
|
||||
@Column(name = "quote_source", length = 50)
|
||||
val quoteSource: String? = null,
|
||||
|
||||
@Column(name = "quote_timestamp")
|
||||
val quoteTimestamp: Long? = null,
|
||||
|
||||
@Column(name = "created_at", nullable = false)
|
||||
val createdAt: Long = System.currentTimeMillis(),
|
||||
|
||||
@Column(name = "updated_at", nullable = false)
|
||||
val updatedAt: Long = System.currentTimeMillis()
|
||||
)
|
||||
@@ -114,6 +114,14 @@ enum class ErrorCode(
|
||||
LEADER_POOL_ALREADY_EXISTS(4252, "Leader 已在池子中", "error.leader_pool_already_exists"),
|
||||
LEADER_POOL_DUPLICATE_TRIAL_CONFIG(4253, "该账户已存在此 Leader 的跟单配置", "error.leader_pool_duplicate_trial_config"),
|
||||
LEADER_POOL_CONFIRM_REQUIRED(4254, "立即启用试跟配置需要显式确认", "error.leader_pool_confirm_required"),
|
||||
LEADER_RESEARCH_CANDIDATE_NOT_FOUND(4261, "研究候选不存在", "error.leader_research_candidate_not_found"),
|
||||
LEADER_RESEARCH_CANDIDATE_NOT_READY(4262, "研究候选尚未进入试跟建议状态", "error.leader_research_candidate_not_ready"),
|
||||
LEADER_RESEARCH_APPROVAL_CONFIRM_REQUIRED(4263, "创建禁用试跟配置需要显式确认", "error.leader_research_approval_confirm_required"),
|
||||
LEADER_RESEARCH_DUPLICATE_TRIAL_CONFIG(4264, "该账户已存在此 Leader 的跟单配置", "error.leader_research_duplicate_trial_config"),
|
||||
LEADER_RESEARCH_REAL_MONEY_FORBIDDEN(4265, "研究 Agent 不允许自动启用真钱跟单", "error.leader_research_real_money_forbidden"),
|
||||
LEADER_RESEARCH_CANDIDATE_LOCKED(4266, "研究候选已锁定", "error.leader_research_candidate_locked"),
|
||||
LEADER_RESEARCH_SOURCE_UNAVAILABLE(4267, "研究来源不可用", "error.leader_research_source_unavailable"),
|
||||
LEADER_RESEARCH_PAPER_VALUATION_UNAVAILABLE(4268, "纸跟估值不可用", "error.leader_research_paper_valuation_unavailable"),
|
||||
|
||||
// 订单相关 (4301-4399)
|
||||
ORDER_CREATE_FAILED(4301, "创建订单失败", "error.order_create_failed"),
|
||||
@@ -220,6 +228,9 @@ enum class ErrorCode(
|
||||
SERVER_LEADER_POOL_LIST_FETCH_FAILED(5451, "查询 Leader 池失败", "error.server.leader_pool_list_fetch_failed"),
|
||||
SERVER_LEADER_POOL_SAVE_FAILED(5452, "保存 Leader 池失败", "error.server.leader_pool_save_failed"),
|
||||
SERVER_LEADER_POOL_CREATE_TRIAL_FAILED(5453, "创建 Leader 池试跟配置失败", "error.server.leader_pool_create_trial_failed"),
|
||||
SERVER_LEADER_RESEARCH_RUN_FAILED(5454, "运行 Leader Research Agent 失败", "error.server.leader_research_run_failed"),
|
||||
SERVER_LEADER_RESEARCH_FETCH_FAILED(5455, "查询 Leader Research 数据失败", "error.server.leader_research_fetch_failed"),
|
||||
SERVER_LEADER_RESEARCH_APPROVAL_FAILED(5456, "创建禁用试跟配置失败", "error.server.leader_research_approval_failed"),
|
||||
|
||||
// 市场服务错误 (5501-5599)
|
||||
SERVER_MARKET_PRICE_FETCH_FAILED(5501, "获取市场价格失败", "error.server.market_price_fetch_failed"),
|
||||
|
||||
@@ -0,0 +1,123 @@
|
||||
package com.wrbug.polymarketbot.enums
|
||||
|
||||
enum class LeaderResearchState {
|
||||
DISCOVERED,
|
||||
CANDIDATE,
|
||||
PAPER,
|
||||
TRIAL_READY,
|
||||
COOLDOWN,
|
||||
RETIRED
|
||||
}
|
||||
|
||||
enum class LeaderResearchRunStatus {
|
||||
RUNNING,
|
||||
SUCCESS,
|
||||
PARTIAL_FAILURE,
|
||||
FAILED,
|
||||
SKIPPED
|
||||
}
|
||||
|
||||
enum class LeaderResearchTriggerType {
|
||||
MANUAL,
|
||||
SCHEDULED,
|
||||
PREVIEW
|
||||
}
|
||||
|
||||
enum class LeaderResearchSourceType {
|
||||
WATCHLIST,
|
||||
EXISTING_LEADER,
|
||||
ACTIVITY_DERIVED,
|
||||
GLOBAL_ACTIVITY_CAPTURE,
|
||||
PUBLIC_LEADERBOARD
|
||||
}
|
||||
|
||||
enum class LeaderResearchSourceStatus {
|
||||
SUCCESS,
|
||||
FAILURE,
|
||||
STALE,
|
||||
DISABLED,
|
||||
DEGRADED
|
||||
}
|
||||
|
||||
enum class LeaderCandidateProvenance {
|
||||
AGENT_CREATED,
|
||||
USER_LEADER,
|
||||
USER_POOL,
|
||||
MANUAL_LOCKED
|
||||
}
|
||||
|
||||
enum class LeaderPaperSessionStatus {
|
||||
ACTIVE,
|
||||
PAUSED,
|
||||
COMPLETED,
|
||||
FAILED
|
||||
}
|
||||
|
||||
enum class LeaderPaperProcessingStatus {
|
||||
NEW,
|
||||
PROCESSING,
|
||||
PROCESSED,
|
||||
FILTERED,
|
||||
RETRYABLE,
|
||||
FAILED
|
||||
}
|
||||
|
||||
enum class LeaderResearchValuationStatus {
|
||||
AVAILABLE,
|
||||
NO_MATCH,
|
||||
UNAVAILABLE,
|
||||
CONFIRMED_ZERO,
|
||||
UNKNOWN
|
||||
}
|
||||
|
||||
enum class LeaderResearchQuoteConfidence {
|
||||
HIGH,
|
||||
MEDIUM,
|
||||
LOW,
|
||||
UNKNOWN
|
||||
}
|
||||
|
||||
enum class LeaderPaperFillAssumption {
|
||||
LEADER_PRICE,
|
||||
BEST_ASK_AT_EVENT,
|
||||
MID_PRICE,
|
||||
UNKNOWN
|
||||
}
|
||||
|
||||
enum class LeaderPaperFilterResult {
|
||||
PASSED,
|
||||
FILTERED
|
||||
}
|
||||
|
||||
enum class LeaderResearchEventType {
|
||||
RUN_STARTED,
|
||||
RUN_COMPLETED,
|
||||
RUN_FAILED,
|
||||
RUN_SKIPPED,
|
||||
SOURCE_SUCCESS,
|
||||
SOURCE_FAILURE,
|
||||
SOURCE_DISABLED,
|
||||
CANDIDATE_DISCOVERED,
|
||||
CANDIDATE_UPDATED,
|
||||
PAPER_STARTED,
|
||||
PAPER_TRADE_RECORDED,
|
||||
PAPER_TRADE_FILTERED,
|
||||
PAPER_PROCESSING_FAILED,
|
||||
STATE_TRANSITION,
|
||||
TRIAL_READY,
|
||||
COOLDOWN,
|
||||
RETIRED,
|
||||
VALUATION_STALE,
|
||||
APPROVAL_CREATED_DISABLED_CONFIG,
|
||||
APPROVAL_REJECTED,
|
||||
DUPLICATE_APPROVAL,
|
||||
REAL_MONEY_ACTIVATION_FORBIDDEN,
|
||||
NOTIFICATION_SUMMARY
|
||||
}
|
||||
|
||||
enum class LeaderResearchNotificationStatus {
|
||||
PENDING,
|
||||
SENT,
|
||||
FAILED,
|
||||
SKIPPED
|
||||
}
|
||||
@@ -1,7 +1,11 @@
|
||||
package com.wrbug.polymarketbot.repository
|
||||
|
||||
import com.wrbug.polymarketbot.entity.Account
|
||||
import jakarta.persistence.LockModeType
|
||||
import org.springframework.data.jpa.repository.JpaRepository
|
||||
import org.springframework.data.jpa.repository.Lock
|
||||
import org.springframework.data.jpa.repository.Query
|
||||
import org.springframework.data.repository.query.Param
|
||||
import org.springframework.stereotype.Repository
|
||||
|
||||
/**
|
||||
@@ -19,6 +23,10 @@ interface AccountRepository : JpaRepository<Account, Long> {
|
||||
* 查找默认账户
|
||||
*/
|
||||
fun findByIsDefaultTrue(): Account?
|
||||
|
||||
@Lock(LockModeType.PESSIMISTIC_WRITE)
|
||||
@Query("select a from Account a where a.id = :id")
|
||||
fun findByIdForUpdate(@Param("id") id: Long): Account?
|
||||
|
||||
/**
|
||||
* 查找所有账户,按创建时间排序
|
||||
@@ -35,4 +43,3 @@ interface AccountRepository : JpaRepository<Account, Long> {
|
||||
*/
|
||||
fun existsByProxyAddress(proxyAddress: String): Boolean
|
||||
}
|
||||
|
||||
|
||||
@@ -16,4 +16,6 @@ interface LeaderPoolRepository : JpaRepository<LeaderPool, Long> {
|
||||
fun findAllByOrderByCreatedAtDesc(): List<LeaderPool>
|
||||
|
||||
fun deleteByLeaderId(leaderId: Long)
|
||||
|
||||
fun findByIdIn(ids: Collection<Long>): List<LeaderPool>
|
||||
}
|
||||
|
||||
@@ -29,5 +29,6 @@ interface LeaderRepository : JpaRepository<Leader, Long> {
|
||||
* 查找所有 Leader,按创建时间排序
|
||||
*/
|
||||
fun findAllByOrderByCreatedAtAsc(): List<Leader>
|
||||
}
|
||||
|
||||
fun findByIdIn(ids: Collection<Long>): List<Leader>
|
||||
}
|
||||
|
||||
+132
@@ -0,0 +1,132 @@
|
||||
package com.wrbug.polymarketbot.repository
|
||||
|
||||
import com.wrbug.polymarketbot.entity.*
|
||||
import com.wrbug.polymarketbot.enums.*
|
||||
import org.springframework.data.domain.Page
|
||||
import org.springframework.data.domain.Pageable
|
||||
import org.springframework.data.jpa.repository.JpaRepository
|
||||
import org.springframework.data.jpa.repository.Modifying
|
||||
import org.springframework.data.jpa.repository.Query
|
||||
import org.springframework.data.repository.query.Param
|
||||
import org.springframework.stereotype.Repository
|
||||
|
||||
@Repository
|
||||
interface LeaderResearchRunRepository : JpaRepository<LeaderResearchRun, Long> {
|
||||
fun findTopByOrderByStartedAtDesc(): LeaderResearchRun?
|
||||
fun findByStatus(status: LeaderResearchRunStatus): List<LeaderResearchRun>
|
||||
fun findTopByStatusOrderByStartedAtDesc(status: LeaderResearchRunStatus): LeaderResearchRun?
|
||||
}
|
||||
|
||||
@Repository
|
||||
interface LeaderResearchCandidateRepository : JpaRepository<LeaderResearchCandidate, Long> {
|
||||
fun findByNormalizedWallet(normalizedWallet: String): LeaderResearchCandidate?
|
||||
fun findByLeaderId(leaderId: Long): LeaderResearchCandidate?
|
||||
fun findByPoolId(poolId: Long): LeaderResearchCandidate?
|
||||
fun findByResearchState(researchState: LeaderResearchState): List<LeaderResearchCandidate>
|
||||
fun findByResearchStateIn(states: Collection<LeaderResearchState>): List<LeaderResearchCandidate>
|
||||
fun findByResearchStateIn(states: Collection<LeaderResearchState>, pageable: Pageable): Page<LeaderResearchCandidate>
|
||||
fun findAllByOrderByUpdatedAtDesc(pageable: Pageable): Page<LeaderResearchCandidate>
|
||||
fun countByResearchState(researchState: LeaderResearchState): Long
|
||||
|
||||
@Query(
|
||||
"""
|
||||
select c from LeaderResearchCandidate c
|
||||
where (:state is null or c.researchState = :state)
|
||||
and (
|
||||
:query is null
|
||||
or lower(c.normalizedWallet) like lower(concat(concat('%', :query), '%'))
|
||||
or lower(c.source) like lower(concat(concat('%', :query), '%'))
|
||||
or lower(coalesce(c.reason, '')) like lower(concat(concat('%', :query), '%'))
|
||||
or lower(coalesce(c.sourceEvidence, '')) like lower(concat(concat('%', :query), '%'))
|
||||
)
|
||||
order by c.updatedAt desc
|
||||
"""
|
||||
)
|
||||
fun search(
|
||||
@Param("state") state: LeaderResearchState?,
|
||||
@Param("query") query: String?,
|
||||
pageable: Pageable
|
||||
): Page<LeaderResearchCandidate>
|
||||
}
|
||||
|
||||
@Repository
|
||||
interface LeaderResearchScoreRepository : JpaRepository<LeaderResearchScore, Long> {
|
||||
fun findTopByCandidateIdOrderByCreatedAtDesc(candidateId: Long): LeaderResearchScore?
|
||||
fun findByCandidateIdOrderByCreatedAtDesc(candidateId: Long): List<LeaderResearchScore>
|
||||
}
|
||||
|
||||
@Repository
|
||||
interface LeaderResearchEventRepository : JpaRepository<LeaderResearchEvent, Long> {
|
||||
fun findByCandidateIdOrderByCreatedAtDesc(candidateId: Long, pageable: Pageable): Page<LeaderResearchEvent>
|
||||
fun findByRunIdOrderByCreatedAtDesc(runId: Long): List<LeaderResearchEvent>
|
||||
fun findByNotificationStatusOrderByCreatedAtAsc(status: LeaderResearchNotificationStatus, pageable: Pageable): Page<LeaderResearchEvent>
|
||||
fun findTopByDedupeKey(dedupeKey: String): LeaderResearchEvent?
|
||||
fun findAllByOrderByCreatedAtDesc(pageable: Pageable): Page<LeaderResearchEvent>
|
||||
}
|
||||
|
||||
@Repository
|
||||
interface LeaderResearchSourceStateRepository : JpaRepository<LeaderResearchSourceState, Long> {
|
||||
fun findBySourceType(sourceType: LeaderResearchSourceType): LeaderResearchSourceState?
|
||||
fun findAllByOrderByUpdatedAtDesc(): List<LeaderResearchSourceState>
|
||||
}
|
||||
|
||||
@Repository
|
||||
interface LeaderActivityEventRepository : JpaRepository<LeaderActivityEvent, Long> {
|
||||
fun findByStableEventKey(stableEventKey: String): LeaderActivityEvent?
|
||||
fun findBySourceAndSourceEventId(source: String, sourceEventId: String): LeaderActivityEvent?
|
||||
fun findTopByOrderByEventTimeDesc(): LeaderActivityEvent?
|
||||
fun findByNormalizedWalletAndEventTimeBetweenOrderByEventTimeAsc(normalizedWallet: String, start: Long, end: Long): List<LeaderActivityEvent>
|
||||
fun findByUsableForDiscoveryTrueAndEventTimeGreaterThanEqual(eventTime: Long): List<LeaderActivityEvent>
|
||||
fun findByPaperProcessingStatusInAndUsableForPaperTrueOrderByEventTimeAsc(statuses: Collection<LeaderPaperProcessingStatus>, pageable: Pageable): Page<LeaderActivityEvent>
|
||||
|
||||
fun deleteByEventTimeLessThanAndPaperProcessingStatusIn(
|
||||
eventTime: Long,
|
||||
statuses: Collection<LeaderPaperProcessingStatus>
|
||||
): Long
|
||||
|
||||
@Modifying
|
||||
@Query(
|
||||
"update LeaderActivityEvent e set e.paperProcessingStatus = :nextStatus, e.paperProcessingStartedAt = :startedAt, e.processingAttempts = e.processingAttempts + 1, e.updatedAt = :startedAt where e.id = :id and e.paperProcessingStatus in :allowed"
|
||||
)
|
||||
fun claimForPaperProcessing(
|
||||
@Param("id") id: Long,
|
||||
@Param("allowed") allowed: Collection<LeaderPaperProcessingStatus>,
|
||||
@Param("nextStatus") nextStatus: LeaderPaperProcessingStatus,
|
||||
@Param("startedAt") startedAt: Long
|
||||
): Int
|
||||
}
|
||||
|
||||
@Repository
|
||||
interface LeaderPaperSessionRepository : JpaRepository<LeaderPaperSession, Long> {
|
||||
fun findTopByCandidateIdAndStatusOrderByStartedAtDesc(candidateId: Long, status: LeaderPaperSessionStatus): LeaderPaperSession?
|
||||
fun findTopByCandidateIdOrderByStartedAtDesc(candidateId: Long): LeaderPaperSession?
|
||||
fun findByCandidateIdOrderByStartedAtDesc(candidateId: Long): List<LeaderPaperSession>
|
||||
fun findByUpdatedAtLessThanAndStatusIn(updatedAt: Long, statuses: Collection<LeaderPaperSessionStatus>, pageable: Pageable): Page<LeaderPaperSession>
|
||||
|
||||
@Query(
|
||||
"""
|
||||
select s from LeaderPaperSession s
|
||||
where s.candidateId in :candidateIds
|
||||
and s.startedAt = (
|
||||
select max(s2.startedAt) from LeaderPaperSession s2 where s2.candidateId = s.candidateId
|
||||
)
|
||||
"""
|
||||
)
|
||||
fun findLatestByCandidateIds(@Param("candidateIds") candidateIds: Collection<Long>): List<LeaderPaperSession>
|
||||
}
|
||||
|
||||
@Repository
|
||||
interface LeaderPaperTradeRepository : JpaRepository<LeaderPaperTrade, Long> {
|
||||
fun existsBySessionIdAndLeaderTradeIdAndSide(sessionId: Long, leaderTradeId: String, side: String): Boolean
|
||||
fun findBySessionIdOrderByEventTimeDesc(sessionId: Long, pageable: Pageable): Page<LeaderPaperTrade>
|
||||
fun findBySessionIdOrderByEventTimeAsc(sessionId: Long): List<LeaderPaperTrade>
|
||||
fun countBySessionId(sessionId: Long): Long
|
||||
fun countBySessionIdAndFilterResult(sessionId: Long, filterResult: LeaderPaperFilterResult): Long
|
||||
}
|
||||
|
||||
@Repository
|
||||
interface LeaderPaperPositionRepository : JpaRepository<LeaderPaperPosition, Long> {
|
||||
fun findBySessionIdAndMarketIdAndOutcomeIndex(sessionId: Long, marketId: String, outcomeIndex: Int?): LeaderPaperPosition?
|
||||
fun findBySessionIdOrderByUpdatedAtDesc(sessionId: Long): List<LeaderPaperPosition>
|
||||
fun findByCandidateIdOrderByUpdatedAtDesc(candidateId: Long): List<LeaderPaperPosition>
|
||||
}
|
||||
+6
@@ -416,6 +416,12 @@ class LeaderPoolService(
|
||||
lastPromotedAt = pool.lastPromotedAt,
|
||||
cooldownUntil = pool.cooldownUntil,
|
||||
locked = pool.locked,
|
||||
researchCandidateId = pool.researchCandidateId,
|
||||
researchState = pool.researchState?.name,
|
||||
researchBadge = pool.researchBadge,
|
||||
researchSummary = pool.researchSummary,
|
||||
researchScore = pool.researchScore?.strip(),
|
||||
researchUpdatedAt = pool.researchUpdatedAt,
|
||||
createdAt = pool.createdAt,
|
||||
updatedAt = pool.updatedAt
|
||||
)
|
||||
|
||||
+106
-2
@@ -6,7 +6,11 @@ import com.wrbug.polymarketbot.api.TradeResponse
|
||||
import com.wrbug.polymarketbot.dto.ActivityTradeMessage
|
||||
import com.wrbug.polymarketbot.dto.ActivityTradePayload
|
||||
import com.wrbug.polymarketbot.entity.Leader
|
||||
import com.wrbug.polymarketbot.enums.LeaderResearchSourceStatus
|
||||
import com.wrbug.polymarketbot.enums.LeaderResearchSourceType
|
||||
import com.wrbug.polymarketbot.repository.LeaderRepository
|
||||
import com.wrbug.polymarketbot.service.copytrading.research.LeaderActivityIngestionService
|
||||
import com.wrbug.polymarketbot.service.copytrading.research.LeaderResearchSourceHealthService
|
||||
import com.wrbug.polymarketbot.service.copytrading.statistics.CopyOrderTrackingService
|
||||
import com.wrbug.polymarketbot.util.fromJson
|
||||
import com.wrbug.polymarketbot.constants.PolymarketConstants
|
||||
@@ -14,6 +18,8 @@ import com.wrbug.polymarketbot.websocket.PolymarketWebSocketClient
|
||||
import jakarta.annotation.PreDestroy
|
||||
import kotlinx.coroutines.*
|
||||
import org.slf4j.LoggerFactory
|
||||
import org.springframework.beans.factory.ObjectProvider
|
||||
import org.springframework.beans.factory.annotation.Value
|
||||
import org.springframework.stereotype.Service
|
||||
import java.math.BigDecimal
|
||||
import java.util.concurrent.ConcurrentHashMap
|
||||
@@ -27,7 +33,11 @@ import java.util.concurrent.TimeUnit
|
||||
@Service
|
||||
class PolymarketActivityWsService(
|
||||
private val copyOrderTrackingService: CopyOrderTrackingService,
|
||||
private val leaderRepository: LeaderRepository
|
||||
private val leaderRepository: LeaderRepository,
|
||||
private val researchIngestionProvider: ObjectProvider<LeaderActivityIngestionService>,
|
||||
private val researchSourceHealthProvider: ObjectProvider<LeaderResearchSourceHealthService>,
|
||||
@Value("\${leader.research.global-capture.enabled:false}") private val researchGlobalCaptureEnabled: Boolean,
|
||||
@Value("\${leader.research.global-capture.max-writes-per-minute:120}") private val researchGlobalCaptureMaxWritesPerMinute: Long
|
||||
) {
|
||||
|
||||
private val logger = LoggerFactory.getLogger(PolymarketActivityWsService::class.java)
|
||||
@@ -65,6 +75,10 @@ class PolymarketActivityWsService(
|
||||
private var addressMatchMessages = 0L
|
||||
private var jsonParseMessages = 0L
|
||||
private var duplicateTxHashMessages = 0L
|
||||
private var researchCaptureWindowMinute = 0L
|
||||
private var researchCaptureWritesThisMinute = 0L
|
||||
private var researchCaptureLastHealthStatus: LeaderResearchSourceStatus? = null
|
||||
private var researchCaptureLastHealthWriteAt = 0L
|
||||
|
||||
/**
|
||||
* 启动监听
|
||||
@@ -314,6 +328,8 @@ class PolymarketActivityWsService(
|
||||
return
|
||||
}
|
||||
|
||||
maybeCaptureResearchActivity(message)
|
||||
|
||||
// 快速预检查:检查是否包含监听地址
|
||||
// 绝大部分消息会在这一步被过滤掉,避免不必要的 JSON 解析
|
||||
if (!containsMonitoredAddress(message)) {
|
||||
@@ -390,6 +406,91 @@ class PolymarketActivityWsService(
|
||||
}
|
||||
}
|
||||
|
||||
private fun maybeCaptureResearchActivity(message: String) {
|
||||
if (!researchGlobalCaptureEnabled) {
|
||||
recordResearchCaptureHealth(
|
||||
status = LeaderResearchSourceStatus.DISABLED,
|
||||
disabledReason = "Global activity capture is disabled"
|
||||
)
|
||||
return
|
||||
}
|
||||
val currentMinute = System.currentTimeMillis() / 60_000
|
||||
if (researchCaptureWindowMinute != currentMinute) {
|
||||
researchCaptureWindowMinute = currentMinute
|
||||
researchCaptureWritesThisMinute = 0
|
||||
}
|
||||
if (researchCaptureWritesThisMinute >= researchGlobalCaptureMaxWritesPerMinute) {
|
||||
recordResearchCaptureHealth(
|
||||
status = LeaderResearchSourceStatus.DEGRADED,
|
||||
errorClass = "WriteCapReached",
|
||||
errorMessage = "write capped at $researchGlobalCaptureMaxWritesPerMinute events per minute"
|
||||
)
|
||||
return
|
||||
}
|
||||
val tradeMessage = message.fromJson<ActivityTradeMessage>() ?: run {
|
||||
recordResearchCaptureHealth(
|
||||
status = LeaderResearchSourceStatus.FAILURE,
|
||||
errorClass = "JsonParseFailure",
|
||||
errorMessage = "failed to parse activity websocket message"
|
||||
)
|
||||
return
|
||||
}
|
||||
if (tradeMessage.topic != "activity" || (tradeMessage.type != "trades" && tradeMessage.type != "orders_matched")) {
|
||||
return
|
||||
}
|
||||
val ingestionService = researchIngestionProvider.getIfAvailable() ?: return
|
||||
try {
|
||||
val event = ingestionService.ingestWebSocketTrade(tradeMessage)
|
||||
researchCaptureWritesThisMinute++
|
||||
recordResearchCaptureHealth(
|
||||
status = LeaderResearchSourceStatus.SUCCESS,
|
||||
candidateCount = researchCaptureWritesThisMinute.toInt(),
|
||||
lastCursor = "${event.eventTime}:${event.stableEventKey}"
|
||||
)
|
||||
} catch (e: Exception) {
|
||||
logger.warn("Research global activity capture failed: {}", e.message)
|
||||
recordResearchCaptureHealth(
|
||||
status = LeaderResearchSourceStatus.FAILURE,
|
||||
errorClass = e::class.java.simpleName,
|
||||
errorMessage = e.message
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
private fun recordResearchCaptureHealth(
|
||||
status: LeaderResearchSourceStatus,
|
||||
candidateCount: Int = 0,
|
||||
errorClass: String? = null,
|
||||
errorMessage: String? = null,
|
||||
disabledReason: String? = null,
|
||||
lastCursor: String? = null
|
||||
) {
|
||||
if (shouldThrottleResearchCaptureHealth(status)) {
|
||||
return
|
||||
}
|
||||
researchSourceHealthProvider.getIfAvailable()?.record(
|
||||
sourceType = LeaderResearchSourceType.GLOBAL_ACTIVITY_CAPTURE,
|
||||
status = status,
|
||||
candidateCount = candidateCount,
|
||||
errorClass = errorClass,
|
||||
errorMessage = errorMessage,
|
||||
disabledReason = disabledReason,
|
||||
lastCursor = lastCursor
|
||||
)
|
||||
}
|
||||
|
||||
private fun shouldThrottleResearchCaptureHealth(status: LeaderResearchSourceStatus): Boolean {
|
||||
val now = System.currentTimeMillis()
|
||||
val throttle = status != LeaderResearchSourceStatus.SUCCESS &&
|
||||
status == researchCaptureLastHealthStatus &&
|
||||
now - researchCaptureLastHealthWriteAt < RESEARCH_CAPTURE_HEALTH_THROTTLE_MS
|
||||
if (!throttle) {
|
||||
researchCaptureLastHealthStatus = status
|
||||
researchCaptureLastHealthWriteAt = now
|
||||
}
|
||||
return throttle
|
||||
}
|
||||
|
||||
/**
|
||||
* 提取交易者地址
|
||||
* 优先检查 trader.address,fallback 到 proxyWallet
|
||||
@@ -574,5 +675,8 @@ class PolymarketActivityWsService(
|
||||
stop()
|
||||
scope.cancel()
|
||||
}
|
||||
}
|
||||
|
||||
companion object {
|
||||
private const val RESEARCH_CAPTURE_HEALTH_THROTTLE_MS = 60_000L
|
||||
}
|
||||
}
|
||||
|
||||
+203
@@ -0,0 +1,203 @@
|
||||
package com.wrbug.polymarketbot.service.copytrading.research
|
||||
|
||||
import com.google.gson.Gson
|
||||
import com.wrbug.polymarketbot.api.UserActivityResponse
|
||||
import com.wrbug.polymarketbot.dto.ActivityTradeMessage
|
||||
import com.wrbug.polymarketbot.entity.LeaderActivityEvent
|
||||
import com.wrbug.polymarketbot.enums.LeaderPaperProcessingStatus
|
||||
import com.wrbug.polymarketbot.enums.LeaderResearchSourceType
|
||||
import com.wrbug.polymarketbot.repository.LeaderActivityEventRepository
|
||||
import org.slf4j.LoggerFactory
|
||||
import org.springframework.dao.DataIntegrityViolationException
|
||||
import org.springframework.stereotype.Service
|
||||
import org.springframework.transaction.annotation.Transactional
|
||||
import java.math.BigDecimal
|
||||
import java.security.MessageDigest
|
||||
|
||||
@Service
|
||||
class LeaderActivityIngestionService(
|
||||
private val activityEventRepository: LeaderActivityEventRepository,
|
||||
private val gson: Gson
|
||||
) {
|
||||
private val logger = LoggerFactory.getLogger(LeaderActivityIngestionService::class.java)
|
||||
|
||||
@Transactional
|
||||
fun ingestUserActivity(
|
||||
activity: UserActivityResponse,
|
||||
source: LeaderResearchSourceType = LeaderResearchSourceType.ACTIVITY_DERIVED
|
||||
): LeaderActivityEvent {
|
||||
val raw = gson.toJson(activity)
|
||||
val normalizedWallet = normalizeWallet(activity.proxyWallet)
|
||||
val isTrade = activity.type.equals("TRADE", ignoreCase = true)
|
||||
val hasRequiredTradeFields = isTrade &&
|
||||
!normalizedWallet.isNullOrBlank() &&
|
||||
!activity.conditionId.isNullOrBlank() &&
|
||||
!activity.side.isNullOrBlank() &&
|
||||
activity.price != null &&
|
||||
activity.size != null
|
||||
val eventTime = normalizeTimestamp(activity.timestamp)
|
||||
val stableKey = activity.transactionHash?.trim()?.takeIf { it.isNotBlank() }
|
||||
?: sha256("${source.name}:${activity.proxyWallet}:${activity.conditionId}:${activity.side}:${activity.asset}:${eventTime}:${activity.price}:${activity.size}")
|
||||
|
||||
val event = LeaderActivityEvent(
|
||||
source = source.name,
|
||||
sourceEventId = activity.transactionHash,
|
||||
stableEventKey = stableKey,
|
||||
normalizedWallet = normalizedWallet,
|
||||
marketId = activity.conditionId,
|
||||
marketTitle = activity.title,
|
||||
marketSlug = activity.slug,
|
||||
asset = activity.asset,
|
||||
side = activity.side?.uppercase(),
|
||||
outcome = activity.outcome,
|
||||
outcomeIndex = activity.outcomeIndex,
|
||||
price = activity.price?.let { BigDecimal.valueOf(it) },
|
||||
size = activity.size?.let { BigDecimal.valueOf(it) },
|
||||
amount = activity.usdcSize?.let { BigDecimal.valueOf(it) }
|
||||
?: amount(activity.price, activity.size),
|
||||
eventTime = eventTime,
|
||||
rawPayloadHash = sha256(raw),
|
||||
payloadSummary = summarize(
|
||||
wallet = normalizedWallet,
|
||||
side = activity.side,
|
||||
marketTitle = activity.title,
|
||||
price = activity.price?.toString(),
|
||||
size = activity.size?.toString()
|
||||
),
|
||||
usableForDiscovery = !normalizedWallet.isNullOrBlank() && isTrade,
|
||||
usableForPaper = hasRequiredTradeFields,
|
||||
unusableReason = if (hasRequiredTradeFields) null else buildUnusableReason(isTrade, normalizedWallet, activity.conditionId, activity.side, activity.price, activity.size),
|
||||
paperProcessingStatus = LeaderPaperProcessingStatus.NEW,
|
||||
createdAt = System.currentTimeMillis(),
|
||||
updatedAt = System.currentTimeMillis()
|
||||
)
|
||||
return saveDeduped(event)
|
||||
}
|
||||
|
||||
@Transactional
|
||||
fun ingestWebSocketTrade(
|
||||
message: ActivityTradeMessage,
|
||||
source: LeaderResearchSourceType = LeaderResearchSourceType.GLOBAL_ACTIVITY_CAPTURE
|
||||
): LeaderActivityEvent {
|
||||
val payload = message.payload
|
||||
val raw = gson.toJson(message)
|
||||
val normalizedWallet = normalizeWallet(payload.trader?.address ?: payload.proxyWallet)
|
||||
val eventTime = normalizeTimestamp(payload.timestamp ?: message.timestamp)
|
||||
val price = payload.price.toBigDecimalOrNull()
|
||||
val size = payload.size.toBigDecimalOrNull()
|
||||
val hasRequiredTradeFields = !normalizedWallet.isNullOrBlank() &&
|
||||
payload.conditionId.isNotBlank() &&
|
||||
payload.side.isNotBlank() &&
|
||||
price != null &&
|
||||
size != null
|
||||
val stableKey = payload.transactionHash?.trim()?.takeIf { it.isNotBlank() }
|
||||
?: sha256("${source.name}:$normalizedWallet:${payload.conditionId}:${payload.side}:${payload.asset}:$eventTime:$price:$size")
|
||||
|
||||
val event = LeaderActivityEvent(
|
||||
source = source.name,
|
||||
sourceEventId = payload.transactionHash,
|
||||
stableEventKey = stableKey,
|
||||
normalizedWallet = normalizedWallet,
|
||||
marketId = payload.conditionId.takeIf { it.isNotBlank() },
|
||||
marketSlug = payload.slug,
|
||||
asset = payload.asset.takeIf { it.isNotBlank() },
|
||||
side = payload.side.uppercase().takeIf { it.isNotBlank() },
|
||||
outcome = payload.outcome,
|
||||
outcomeIndex = payload.outcomeIndex,
|
||||
price = price,
|
||||
size = size,
|
||||
amount = if (price != null && size != null) price.multiply(size) else null,
|
||||
eventTime = eventTime,
|
||||
rawPayloadHash = sha256(raw),
|
||||
payloadSummary = summarize(
|
||||
wallet = normalizedWallet,
|
||||
side = payload.side,
|
||||
marketTitle = payload.slug,
|
||||
price = price?.toPlainString(),
|
||||
size = size?.toPlainString()
|
||||
),
|
||||
usableForDiscovery = !normalizedWallet.isNullOrBlank(),
|
||||
usableForPaper = hasRequiredTradeFields,
|
||||
unusableReason = if (hasRequiredTradeFields) null else buildUnusableReason(true, normalizedWallet, payload.conditionId, payload.side, price, size),
|
||||
paperProcessingStatus = LeaderPaperProcessingStatus.NEW,
|
||||
createdAt = System.currentTimeMillis(),
|
||||
updatedAt = System.currentTimeMillis()
|
||||
)
|
||||
return saveDeduped(event)
|
||||
}
|
||||
|
||||
fun normalizeWallet(wallet: String?): String? {
|
||||
val trimmed = wallet?.trim()?.lowercase() ?: return null
|
||||
val evm = Regex("^0x[a-f0-9]{40}$")
|
||||
return trimmed.takeIf { evm.matches(it) }
|
||||
}
|
||||
|
||||
fun stableHash(raw: String): String = sha256(raw)
|
||||
|
||||
private fun saveDeduped(event: LeaderActivityEvent): LeaderActivityEvent {
|
||||
activityEventRepository.findByStableEventKey(event.stableEventKey)?.let { return it }
|
||||
event.sourceEventId?.takeIf { it.isNotBlank() }?.let { sourceEventId ->
|
||||
activityEventRepository.findBySourceAndSourceEventId(event.source, sourceEventId)?.let { return it }
|
||||
}
|
||||
return try {
|
||||
activityEventRepository.save(event)
|
||||
} catch (e: DataIntegrityViolationException) {
|
||||
logger.debug("Activity event deduped: stableKey={}", event.stableEventKey)
|
||||
activityEventRepository.findByStableEventKey(event.stableEventKey)
|
||||
?: event.sourceEventId?.takeIf { it.isNotBlank() }?.let { activityEventRepository.findBySourceAndSourceEventId(event.source, it) }
|
||||
?: throw e
|
||||
}
|
||||
}
|
||||
|
||||
private fun normalizeTimestamp(value: Any?): Long {
|
||||
val number = when (value) {
|
||||
is Number -> value.toLong()
|
||||
is String -> value.toLongOrNull()
|
||||
else -> null
|
||||
} ?: return System.currentTimeMillis()
|
||||
return if (number < 10_000_000_000L) number * 1000 else number
|
||||
}
|
||||
|
||||
private fun Any?.toBigDecimalOrNull(): BigDecimal? {
|
||||
return when (this) {
|
||||
is BigDecimal -> this
|
||||
is Number -> BigDecimal.valueOf(this.toDouble())
|
||||
is String -> this.trim().takeIf { it.isNotBlank() }?.let { runCatching { BigDecimal(it) }.getOrNull() }
|
||||
else -> null
|
||||
}
|
||||
}
|
||||
|
||||
private fun amount(price: Double?, size: Double?): BigDecimal? {
|
||||
if (price == null || size == null) return null
|
||||
return BigDecimal.valueOf(price).multiply(BigDecimal.valueOf(size))
|
||||
}
|
||||
|
||||
private fun buildUnusableReason(
|
||||
isTrade: Boolean,
|
||||
wallet: String?,
|
||||
marketId: String?,
|
||||
side: String?,
|
||||
price: Any?,
|
||||
size: Any?
|
||||
): String {
|
||||
val reasons = mutableListOf<String>()
|
||||
if (!isTrade) reasons += "not_trade"
|
||||
if (wallet.isNullOrBlank()) reasons += "wallet_missing_or_invalid"
|
||||
if (marketId.isNullOrBlank()) reasons += "market_missing"
|
||||
if (side.isNullOrBlank()) reasons += "side_missing"
|
||||
if (price == null) reasons += "price_missing"
|
||||
if (size == null) reasons += "size_missing"
|
||||
return reasons.joinToString(",")
|
||||
}
|
||||
|
||||
private fun summarize(wallet: String?, side: String?, marketTitle: String?, price: String?, size: String?): String {
|
||||
return listOfNotNull(wallet, side?.uppercase(), marketTitle, price?.let { "price=$it" }, size?.let { "size=$it" })
|
||||
.joinToString(" | ")
|
||||
.take(1000)
|
||||
}
|
||||
|
||||
private fun sha256(raw: String): String {
|
||||
val digest = MessageDigest.getInstance("SHA-256").digest(raw.toByteArray(Charsets.UTF_8))
|
||||
return digest.joinToString("") { "%02x".format(it) }
|
||||
}
|
||||
}
|
||||
+530
@@ -0,0 +1,530 @@
|
||||
package com.wrbug.polymarketbot.service.copytrading.research
|
||||
|
||||
import com.wrbug.polymarketbot.entity.LeaderActivityEvent
|
||||
import com.wrbug.polymarketbot.entity.LeaderPaperPosition
|
||||
import com.wrbug.polymarketbot.entity.LeaderPaperSession
|
||||
import com.wrbug.polymarketbot.entity.LeaderPaperTrade
|
||||
import com.wrbug.polymarketbot.entity.LeaderResearchCandidate
|
||||
import com.wrbug.polymarketbot.enums.LeaderPaperFillAssumption
|
||||
import com.wrbug.polymarketbot.enums.LeaderPaperFilterResult
|
||||
import com.wrbug.polymarketbot.enums.LeaderPaperProcessingStatus
|
||||
import com.wrbug.polymarketbot.enums.LeaderPaperSessionStatus
|
||||
import com.wrbug.polymarketbot.enums.LeaderResearchEventType
|
||||
import com.wrbug.polymarketbot.enums.LeaderResearchQuoteConfidence
|
||||
import com.wrbug.polymarketbot.enums.LeaderResearchState
|
||||
import com.wrbug.polymarketbot.enums.LeaderResearchValuationStatus
|
||||
import com.wrbug.polymarketbot.repository.LeaderActivityEventRepository
|
||||
import com.wrbug.polymarketbot.repository.LeaderPaperPositionRepository
|
||||
import com.wrbug.polymarketbot.repository.LeaderPaperSessionRepository
|
||||
import com.wrbug.polymarketbot.repository.LeaderPaperTradeRepository
|
||||
import com.wrbug.polymarketbot.repository.LeaderResearchCandidateRepository
|
||||
import com.wrbug.polymarketbot.service.common.MarketPriceService
|
||||
import kotlinx.coroutines.runBlocking
|
||||
import org.slf4j.LoggerFactory
|
||||
import org.springframework.data.domain.PageRequest
|
||||
import org.springframework.stereotype.Service
|
||||
import org.springframework.transaction.annotation.Transactional
|
||||
import java.math.BigDecimal
|
||||
import java.math.RoundingMode
|
||||
|
||||
data class LeaderPaperProcessingResult(
|
||||
val processed: Int,
|
||||
val filtered: Int,
|
||||
val failed: Int
|
||||
)
|
||||
|
||||
@Service
|
||||
class LeaderPaperTradingService(
|
||||
private val candidateRepository: LeaderResearchCandidateRepository,
|
||||
private val activityEventRepository: LeaderActivityEventRepository,
|
||||
private val paperSessionRepository: LeaderPaperSessionRepository,
|
||||
private val paperTradeRepository: LeaderPaperTradeRepository,
|
||||
private val paperPositionRepository: LeaderPaperPositionRepository,
|
||||
private val marketPriceService: MarketPriceService,
|
||||
private val eventService: LeaderResearchEventService
|
||||
) {
|
||||
private val logger = LoggerFactory.getLogger(LeaderPaperTradingService::class.java)
|
||||
|
||||
@Transactional
|
||||
fun ensureSession(candidate: LeaderResearchCandidate, runId: Long? = null): LeaderPaperSession {
|
||||
val candidateId = candidate.id ?: throw IllegalArgumentException("candidate id missing")
|
||||
paperSessionRepository.findTopByCandidateIdAndStatusOrderByStartedAtDesc(candidateId, LeaderPaperSessionStatus.ACTIVE)
|
||||
?.let { return it }
|
||||
val now = System.currentTimeMillis()
|
||||
val session = paperSessionRepository.save(
|
||||
LeaderPaperSession(
|
||||
candidateId = candidateId,
|
||||
status = LeaderPaperSessionStatus.ACTIVE,
|
||||
startedAt = now,
|
||||
createdAt = now,
|
||||
updatedAt = now
|
||||
)
|
||||
)
|
||||
candidateRepository.save(
|
||||
candidate.copy(
|
||||
lastPaperSessionId = session.id,
|
||||
updatedAt = now
|
||||
)
|
||||
)
|
||||
eventService.record(
|
||||
type = LeaderResearchEventType.PAPER_STARTED,
|
||||
candidateId = candidateId,
|
||||
runId = runId,
|
||||
reason = "Paper session started",
|
||||
dedupeKey = "paper-started:$candidateId:${session.id}"
|
||||
)
|
||||
return session
|
||||
}
|
||||
|
||||
@Transactional
|
||||
fun processPaperCandidates(runId: Long? = null, batchSize: Int = 200): LeaderPaperProcessingResult {
|
||||
val paperCandidates = candidateRepository.findByResearchStateIn(
|
||||
listOf(LeaderResearchState.PAPER, LeaderResearchState.TRIAL_READY)
|
||||
)
|
||||
if (paperCandidates.isEmpty()) {
|
||||
return LeaderPaperProcessingResult(processed = 0, filtered = 0, failed = 0)
|
||||
}
|
||||
|
||||
val candidatesByWallet = paperCandidates.associateBy { it.normalizedWallet }
|
||||
paperCandidates.forEach { ensureSession(it, runId) }
|
||||
|
||||
val page = activityEventRepository.findByPaperProcessingStatusInAndUsableForPaperTrueOrderByEventTimeAsc(
|
||||
listOf(LeaderPaperProcessingStatus.NEW, LeaderPaperProcessingStatus.RETRYABLE),
|
||||
PageRequest.of(0, batchSize)
|
||||
)
|
||||
|
||||
var processed = 0
|
||||
var filtered = 0
|
||||
var failed = 0
|
||||
val now = System.currentTimeMillis()
|
||||
|
||||
page.content.forEach { event ->
|
||||
val wallet = event.normalizedWallet ?: return@forEach
|
||||
val candidate = candidatesByWallet[wallet] ?: return@forEach
|
||||
val eventId = event.id ?: return@forEach
|
||||
val claimed = activityEventRepository.claimForPaperProcessing(
|
||||
id = eventId,
|
||||
allowed = listOf(LeaderPaperProcessingStatus.NEW, LeaderPaperProcessingStatus.RETRYABLE),
|
||||
nextStatus = LeaderPaperProcessingStatus.PROCESSING,
|
||||
startedAt = now
|
||||
)
|
||||
if (claimed != 1) return@forEach
|
||||
|
||||
val claimedEvent = event.copy(
|
||||
paperProcessingStatus = LeaderPaperProcessingStatus.PROCESSING,
|
||||
processingAttempts = event.processingAttempts + 1,
|
||||
paperProcessingStartedAt = now,
|
||||
updatedAt = now
|
||||
)
|
||||
try {
|
||||
val session = ensureSession(candidate, runId)
|
||||
val outcome = processEvent(candidate, session, claimedEvent)
|
||||
when (outcome) {
|
||||
LeaderPaperFilterResult.PASSED -> processed += 1
|
||||
LeaderPaperFilterResult.FILTERED -> filtered += 1
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
failed += 1
|
||||
val nextAttempts = claimedEvent.processingAttempts
|
||||
val nextStatus = if (nextAttempts >= MAX_PROCESSING_ATTEMPTS) {
|
||||
LeaderPaperProcessingStatus.FAILED
|
||||
} else {
|
||||
LeaderPaperProcessingStatus.RETRYABLE
|
||||
}
|
||||
logger.warn("Paper event processing failed: eventId={}, error={}", eventId, e.message, e)
|
||||
activityEventRepository.save(
|
||||
claimedEvent.copy(
|
||||
paperProcessingStatus = nextStatus,
|
||||
paperProcessedAt = System.currentTimeMillis(),
|
||||
lastProcessingError = e.message,
|
||||
updatedAt = System.currentTimeMillis()
|
||||
)
|
||||
)
|
||||
eventService.record(
|
||||
type = LeaderResearchEventType.PAPER_PROCESSING_FAILED,
|
||||
candidateId = candidate.id,
|
||||
runId = runId,
|
||||
reason = "Paper event processing failed with status=$nextStatus: ${e.message}",
|
||||
payloadSummary = claimedEvent.payloadSummary,
|
||||
dedupeKey = "paper-processing-failed:$eventId:$nextAttempts"
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
return LeaderPaperProcessingResult(processed = processed, filtered = filtered, failed = failed)
|
||||
}
|
||||
|
||||
fun isEligibleForTrialReady(session: LeaderPaperSession, now: Long = System.currentTimeMillis()): Boolean {
|
||||
val ageMs = now - session.startedAt
|
||||
val totalTrades = session.tradeCount + session.filteredCount
|
||||
val unknownRatio = if (session.openExposure > BigDecimal.ZERO) {
|
||||
session.unknownValuationExposure.safeDivide(session.openExposure)
|
||||
} else {
|
||||
BigDecimal.ZERO
|
||||
}
|
||||
return ageMs >= PAPER_MIN_AGE_MS &&
|
||||
session.tradeCount >= PAPER_MIN_TRADES &&
|
||||
session.copyablePnl > BigDecimal.ZERO &&
|
||||
session.maxDrawdown >= BigDecimal("-15") &&
|
||||
unknownRatio <= BigDecimal("0.20") &&
|
||||
session.filteredRatio < BigDecimal("0.50") &&
|
||||
totalTrades >= PAPER_MIN_TRADES
|
||||
}
|
||||
|
||||
fun shouldEnterCooldown(session: LeaderPaperSession, sourceFresh: Boolean): String? {
|
||||
if (session.maxDrawdown < BigDecimal("-20")) return "paper_drawdown_below_-20"
|
||||
if (session.tradeCount >= 10 && session.copyablePnl < BigDecimal("-5")) return "copyable_pnl_below_-5_after_10_trades"
|
||||
if (!sourceFresh) return "source_stale_over_72h"
|
||||
if ((session.openExposure > BigDecimal.ZERO) &&
|
||||
session.unknownValuationExposure.safeDivide(session.openExposure) > BigDecimal("0.50")
|
||||
) {
|
||||
return "thin_liquidity_exit_risk"
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
@Transactional
|
||||
fun refreshSessionSummary(sessionId: Long): LeaderPaperSession {
|
||||
val session = paperSessionRepository.findById(sessionId).orElseThrow { IllegalArgumentException("Paper session not found") }
|
||||
return saveSessionSummary(session)
|
||||
}
|
||||
|
||||
private fun processEvent(
|
||||
candidate: LeaderResearchCandidate,
|
||||
session: LeaderPaperSession,
|
||||
event: LeaderActivityEvent
|
||||
): LeaderPaperFilterResult {
|
||||
val candidateId = candidate.id ?: throw IllegalArgumentException("candidate id missing")
|
||||
val sessionId = session.id ?: throw IllegalArgumentException("session id missing")
|
||||
val filterReason = filterReason(event)
|
||||
if (filterReason != null) {
|
||||
val trade = buildTrade(
|
||||
candidateId = candidateId,
|
||||
sessionId = sessionId,
|
||||
event = event,
|
||||
filterResult = LeaderPaperFilterResult.FILTERED,
|
||||
filterReason = filterReason,
|
||||
simulatedPrice = null,
|
||||
simulatedSize = null,
|
||||
simulatedAmount = null,
|
||||
valuationStatus = LeaderResearchValuationStatus.UNKNOWN
|
||||
)
|
||||
saveTradeIfAbsent(trade)
|
||||
markEvent(event, LeaderPaperProcessingStatus.FILTERED, null)
|
||||
saveSessionSummary(session)
|
||||
eventService.record(
|
||||
type = LeaderResearchEventType.PAPER_TRADE_FILTERED,
|
||||
candidateId = candidateId,
|
||||
reason = filterReason,
|
||||
payloadSummary = event.payloadSummary,
|
||||
dedupeKey = "paper-filtered:${sessionId}:${event.stableEventKey}"
|
||||
)
|
||||
return LeaderPaperFilterResult.FILTERED
|
||||
}
|
||||
|
||||
val side = event.side!!.uppercase()
|
||||
val price = event.price!!
|
||||
val marketId = event.marketId!!
|
||||
val outcomeIndex = event.outcomeIndex ?: 0
|
||||
if (paperTradeRepository.existsBySessionIdAndLeaderTradeIdAndSide(sessionId, event.stableEventKey, side)) {
|
||||
markEvent(event, LeaderPaperProcessingStatus.PROCESSED, null)
|
||||
return LeaderPaperFilterResult.PASSED
|
||||
}
|
||||
val existingPosition = paperPositionRepository.findBySessionIdAndMarketIdAndOutcomeIndex(sessionId, marketId, outcomeIndex)
|
||||
val simulatedAmount = when (side) {
|
||||
"BUY" -> minDecimal(event.amount ?: price.multiply(event.size ?: BigDecimal.ZERO), PAPER_FIXED_AMOUNT)
|
||||
"SELL" -> {
|
||||
val maxSell = existingPosition?.quantity ?: BigDecimal.ZERO
|
||||
val eventSellSize = event.size ?: maxSell
|
||||
minDecimal(maxSell, eventSellSize).multiply(price)
|
||||
}
|
||||
else -> BigDecimal.ZERO
|
||||
}.atLeast(BigDecimal.ZERO)
|
||||
val simulatedSize = if (price > BigDecimal.ZERO) simulatedAmount.safeDivide(price) else BigDecimal.ZERO
|
||||
|
||||
val valuation = quoteMarket(marketId, outcomeIndex)
|
||||
val realizedPnl = applyPosition(
|
||||
session = session,
|
||||
event = event,
|
||||
side = side,
|
||||
simulatedPrice = price,
|
||||
simulatedSize = simulatedSize,
|
||||
simulatedAmount = simulatedAmount,
|
||||
valuation = valuation
|
||||
)
|
||||
val trade = buildTrade(
|
||||
candidateId = candidateId,
|
||||
sessionId = sessionId,
|
||||
event = event,
|
||||
filterResult = LeaderPaperFilterResult.PASSED,
|
||||
filterReason = null,
|
||||
simulatedPrice = price,
|
||||
simulatedSize = simulatedSize,
|
||||
simulatedAmount = simulatedAmount,
|
||||
valuationStatus = valuation.status,
|
||||
realizedPnl = realizedPnl
|
||||
)
|
||||
saveTradeIfAbsent(trade)
|
||||
markEvent(event, LeaderPaperProcessingStatus.PROCESSED, null)
|
||||
saveSessionSummary(session)
|
||||
eventService.record(
|
||||
type = LeaderResearchEventType.PAPER_TRADE_RECORDED,
|
||||
candidateId = candidateId,
|
||||
reason = "${side} paper trade recorded",
|
||||
payloadSummary = event.payloadSummary,
|
||||
dedupeKey = "paper-trade:${sessionId}:${event.stableEventKey}:$side"
|
||||
)
|
||||
if (valuation.status == LeaderResearchValuationStatus.UNKNOWN || valuation.status == LeaderResearchValuationStatus.UNAVAILABLE) {
|
||||
eventService.record(
|
||||
type = LeaderResearchEventType.VALUATION_STALE,
|
||||
candidateId = candidateId,
|
||||
reason = "Valuation unavailable for $marketId/$outcomeIndex",
|
||||
dedupeKey = "paper-valuation:${sessionId}:${event.stableEventKey}"
|
||||
)
|
||||
}
|
||||
return LeaderPaperFilterResult.PASSED
|
||||
}
|
||||
|
||||
private fun applyPosition(
|
||||
session: LeaderPaperSession,
|
||||
event: LeaderActivityEvent,
|
||||
side: String,
|
||||
simulatedPrice: BigDecimal,
|
||||
simulatedSize: BigDecimal,
|
||||
simulatedAmount: BigDecimal,
|
||||
valuation: PaperQuote
|
||||
): BigDecimal {
|
||||
val sessionId = session.id ?: throw IllegalArgumentException("session id missing")
|
||||
val candidateId = session.candidateId
|
||||
val marketId = event.marketId!!
|
||||
val outcomeIndex = event.outcomeIndex ?: 0
|
||||
val now = System.currentTimeMillis()
|
||||
val existing = paperPositionRepository.findBySessionIdAndMarketIdAndOutcomeIndex(sessionId, marketId, outcomeIndex)
|
||||
val updated = if (side == "SELL") {
|
||||
val position = existing ?: LeaderPaperPosition(
|
||||
sessionId = sessionId,
|
||||
candidateId = candidateId,
|
||||
marketId = marketId,
|
||||
outcome = event.outcome,
|
||||
outcomeIndex = outcomeIndex,
|
||||
createdAt = now,
|
||||
updatedAt = now
|
||||
)
|
||||
val sellSize = minDecimal(position.quantity, simulatedSize)
|
||||
val costPortion = if (position.quantity > BigDecimal.ZERO) {
|
||||
position.cost.multiply(sellSize).safeDivide(position.quantity)
|
||||
} else {
|
||||
BigDecimal.ZERO
|
||||
}
|
||||
val realized = simulatedPrice.multiply(sellSize).subtract(costPortion)
|
||||
val remainingQuantity = position.quantity.subtract(sellSize).atLeast(BigDecimal.ZERO)
|
||||
val remainingCost = position.cost.subtract(costPortion).atLeast(BigDecimal.ZERO)
|
||||
val currentValue = valuation.price?.multiply(remainingQuantity) ?: BigDecimal.ZERO
|
||||
position.copy(
|
||||
quantity = remainingQuantity,
|
||||
cost = remainingCost,
|
||||
avgPrice = if (remainingQuantity > BigDecimal.ZERO) remainingCost.safeDivide(remainingQuantity) else BigDecimal.ZERO,
|
||||
currentPrice = valuation.price,
|
||||
currentValue = currentValue,
|
||||
realizedPnl = position.realizedPnl.add(realized),
|
||||
unrealizedPnl = currentValue.subtract(remainingCost),
|
||||
valuationStatus = valuation.status,
|
||||
quoteConfidence = valuation.confidence,
|
||||
quoteSource = valuation.source,
|
||||
quoteTimestamp = valuation.timestamp,
|
||||
updatedAt = now
|
||||
)
|
||||
} else {
|
||||
val position = existing ?: LeaderPaperPosition(
|
||||
sessionId = sessionId,
|
||||
candidateId = candidateId,
|
||||
marketId = marketId,
|
||||
outcome = event.outcome,
|
||||
outcomeIndex = outcomeIndex,
|
||||
createdAt = now,
|
||||
updatedAt = now
|
||||
)
|
||||
val newQuantity = position.quantity.add(simulatedSize)
|
||||
val newCost = position.cost.add(simulatedAmount)
|
||||
val currentValue = valuation.price?.multiply(newQuantity) ?: BigDecimal.ZERO
|
||||
position.copy(
|
||||
quantity = newQuantity,
|
||||
cost = newCost,
|
||||
avgPrice = if (newQuantity > BigDecimal.ZERO) newCost.safeDivide(newQuantity) else BigDecimal.ZERO,
|
||||
currentPrice = valuation.price,
|
||||
currentValue = currentValue,
|
||||
unrealizedPnl = currentValue.subtract(newCost),
|
||||
valuationStatus = valuation.status,
|
||||
quoteConfidence = valuation.confidence,
|
||||
quoteSource = valuation.source,
|
||||
quoteTimestamp = valuation.timestamp,
|
||||
updatedAt = now
|
||||
)
|
||||
}
|
||||
val saved = paperPositionRepository.save(updated)
|
||||
return if (side == "SELL") saved.realizedPnl.subtract(existing?.realizedPnl ?: BigDecimal.ZERO) else BigDecimal.ZERO
|
||||
}
|
||||
|
||||
private fun saveSessionSummary(session: LeaderPaperSession): LeaderPaperSession {
|
||||
val sessionId = session.id ?: throw IllegalArgumentException("session id missing")
|
||||
val positions = paperPositionRepository.findBySessionIdOrderByUpdatedAtDesc(sessionId)
|
||||
val trades = paperTradeRepository.findBySessionIdOrderByEventTimeAsc(sessionId)
|
||||
val tradeCount = trades.count { it.filterResult == LeaderPaperFilterResult.PASSED }
|
||||
val filteredCount = trades.count { it.filterResult == LeaderPaperFilterResult.FILTERED }
|
||||
val totalEvents = tradeCount + filteredCount
|
||||
val realized = positions.fold(BigDecimal.ZERO) { acc, position -> acc + position.realizedPnl }
|
||||
val availableUnrealized = positions
|
||||
.filter { it.valuationStatus == LeaderResearchValuationStatus.AVAILABLE || it.valuationStatus == LeaderResearchValuationStatus.CONFIRMED_ZERO }
|
||||
.fold(BigDecimal.ZERO) { acc, position -> acc + position.unrealizedPnl }
|
||||
val unknownExposure = positions
|
||||
.filter { it.valuationStatus == LeaderResearchValuationStatus.UNKNOWN || it.valuationStatus == LeaderResearchValuationStatus.UNAVAILABLE || it.valuationStatus == LeaderResearchValuationStatus.NO_MATCH }
|
||||
.fold(BigDecimal.ZERO) { acc, position -> acc + position.cost }
|
||||
val confirmedZeroExposure = positions
|
||||
.filter { it.valuationStatus == LeaderResearchValuationStatus.CONFIRMED_ZERO }
|
||||
.fold(BigDecimal.ZERO) { acc, position -> acc + position.cost }
|
||||
val openExposure = positions.fold(BigDecimal.ZERO) { acc, position -> acc + position.cost }
|
||||
val copyablePnl = realized.add(availableUnrealized)
|
||||
val maxDrawdown = minDecimal(session.maxDrawdown, copyablePnl)
|
||||
return paperSessionRepository.save(
|
||||
session.copy(
|
||||
tradeCount = tradeCount,
|
||||
filteredCount = filteredCount,
|
||||
openExposure = openExposure,
|
||||
totalRealizedPnl = realized,
|
||||
totalUnrealizedPnl = positions.fold(BigDecimal.ZERO) { acc, position -> acc + position.unrealizedPnl },
|
||||
copyablePnl = copyablePnl,
|
||||
maxDrawdown = maxDrawdown,
|
||||
unknownValuationExposure = unknownExposure,
|
||||
confirmedZeroExposure = confirmedZeroExposure,
|
||||
filteredRatio = if (totalEvents > 0) BigDecimal(filteredCount).safeDivide(BigDecimal(totalEvents)) else BigDecimal.ZERO,
|
||||
lastProcessedEventTime = trades.maxOfOrNull { it.eventTime } ?: session.lastProcessedEventTime,
|
||||
updatedAt = System.currentTimeMillis()
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
private fun buildTrade(
|
||||
candidateId: Long,
|
||||
sessionId: Long,
|
||||
event: LeaderActivityEvent,
|
||||
filterResult: LeaderPaperFilterResult,
|
||||
filterReason: String?,
|
||||
simulatedPrice: BigDecimal?,
|
||||
simulatedSize: BigDecimal?,
|
||||
simulatedAmount: BigDecimal?,
|
||||
valuationStatus: LeaderResearchValuationStatus,
|
||||
realizedPnl: BigDecimal? = null
|
||||
): LeaderPaperTrade {
|
||||
return LeaderPaperTrade(
|
||||
sessionId = sessionId,
|
||||
candidateId = candidateId,
|
||||
activityEventId = event.id,
|
||||
leaderTradeId = event.stableEventKey,
|
||||
marketId = event.marketId ?: "unknown",
|
||||
marketTitle = event.marketTitle,
|
||||
marketSlug = event.marketSlug,
|
||||
side = event.side?.uppercase() ?: "UNKNOWN",
|
||||
outcome = event.outcome,
|
||||
outcomeIndex = event.outcomeIndex,
|
||||
leaderPrice = event.price,
|
||||
leaderSize = event.size,
|
||||
simulatedPrice = simulatedPrice,
|
||||
simulatedSize = simulatedSize,
|
||||
simulatedAmount = simulatedAmount,
|
||||
fillAssumption = if (simulatedPrice != null) LeaderPaperFillAssumption.LEADER_PRICE else LeaderPaperFillAssumption.UNKNOWN,
|
||||
quoteConfidence = if (valuationStatus == LeaderResearchValuationStatus.AVAILABLE || valuationStatus == LeaderResearchValuationStatus.CONFIRMED_ZERO) {
|
||||
LeaderResearchQuoteConfidence.MEDIUM
|
||||
} else {
|
||||
LeaderResearchQuoteConfidence.UNKNOWN
|
||||
},
|
||||
quoteSource = "paper_v1",
|
||||
quoteTimestamp = System.currentTimeMillis(),
|
||||
filterResult = filterResult,
|
||||
filterReason = filterReason,
|
||||
valuationStatus = valuationStatus,
|
||||
realizedPnl = realizedPnl,
|
||||
eventTime = event.eventTime,
|
||||
createdAt = System.currentTimeMillis()
|
||||
)
|
||||
}
|
||||
|
||||
private fun saveTradeIfAbsent(trade: LeaderPaperTrade): LeaderPaperTrade {
|
||||
if (paperTradeRepository.existsBySessionIdAndLeaderTradeIdAndSide(trade.sessionId, trade.leaderTradeId, trade.side)) {
|
||||
return paperTradeRepository.findBySessionIdOrderByEventTimeAsc(trade.sessionId)
|
||||
.first { it.leaderTradeId == trade.leaderTradeId && it.side == trade.side }
|
||||
}
|
||||
return paperTradeRepository.save(trade)
|
||||
}
|
||||
|
||||
private fun markEvent(event: LeaderActivityEvent, status: LeaderPaperProcessingStatus, error: String?) {
|
||||
activityEventRepository.save(
|
||||
event.copy(
|
||||
paperProcessingStatus = status,
|
||||
processingAttempts = event.processingAttempts,
|
||||
paperProcessingStartedAt = event.paperProcessingStartedAt,
|
||||
paperProcessedAt = System.currentTimeMillis(),
|
||||
lastProcessingError = error,
|
||||
updatedAt = System.currentTimeMillis()
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
private fun filterReason(event: LeaderActivityEvent): String? {
|
||||
if (event.marketId.isNullOrBlank()) return "market_missing"
|
||||
if (event.side.isNullOrBlank()) return "side_missing"
|
||||
if (event.side.uppercase() !in setOf("BUY", "SELL")) return "unsupported_side:${event.side}"
|
||||
if (event.price == null || event.price <= BigDecimal.ZERO) return "price_missing_or_invalid"
|
||||
if (event.price < MIN_PRICE || event.price > MAX_PRICE) return "price_outside_safe_band"
|
||||
if (event.size == null || event.size <= BigDecimal.ZERO) return "size_missing_or_invalid"
|
||||
if (event.side.uppercase() == "BUY" && (event.amount ?: event.price.multiply(event.size)) <= BigDecimal.ZERO) return "amount_missing_or_invalid"
|
||||
return null
|
||||
}
|
||||
|
||||
private fun quoteMarket(marketId: String, outcomeIndex: Int): PaperQuote {
|
||||
return try {
|
||||
val price = runBlocking { marketPriceService.getCurrentMarketPrice(marketId, outcomeIndex) }
|
||||
PaperQuote(
|
||||
price = price,
|
||||
status = if (price.compareTo(BigDecimal.ZERO) == 0) LeaderResearchValuationStatus.CONFIRMED_ZERO else LeaderResearchValuationStatus.AVAILABLE,
|
||||
confidence = LeaderResearchQuoteConfidence.MEDIUM,
|
||||
source = "MarketPriceService",
|
||||
timestamp = System.currentTimeMillis()
|
||||
)
|
||||
} catch (e: Exception) {
|
||||
logger.debug("Paper valuation unavailable: marketId={}, outcomeIndex={}, error={}", marketId, outcomeIndex, e.message)
|
||||
PaperQuote(
|
||||
price = null,
|
||||
status = LeaderResearchValuationStatus.UNKNOWN,
|
||||
confidence = LeaderResearchQuoteConfidence.UNKNOWN,
|
||||
source = "MarketPriceService",
|
||||
timestamp = System.currentTimeMillis()
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
private fun BigDecimal.safeDivide(other: BigDecimal): BigDecimal {
|
||||
if (other.compareTo(BigDecimal.ZERO) == 0) return BigDecimal.ZERO
|
||||
return divide(other, 8, RoundingMode.HALF_UP)
|
||||
}
|
||||
|
||||
private fun BigDecimal.atLeast(other: BigDecimal): BigDecimal = if (this >= other) this else other
|
||||
|
||||
private fun minDecimal(left: BigDecimal, right: BigDecimal): BigDecimal = if (left <= right) left else right
|
||||
|
||||
private data class PaperQuote(
|
||||
val price: BigDecimal?,
|
||||
val status: LeaderResearchValuationStatus,
|
||||
val confidence: LeaderResearchQuoteConfidence,
|
||||
val source: String,
|
||||
val timestamp: Long
|
||||
)
|
||||
|
||||
companion object {
|
||||
private val PAPER_FIXED_AMOUNT = BigDecimal("1.00000000")
|
||||
private val MIN_PRICE = BigDecimal("0.10000000")
|
||||
private val MAX_PRICE = BigDecimal("0.80000000")
|
||||
private const val PAPER_MIN_TRADES = 10
|
||||
private const val PAPER_MIN_AGE_MS = 7L * 24 * 60 * 60 * 1000
|
||||
private const val MAX_PROCESSING_ATTEMPTS = 3
|
||||
}
|
||||
}
|
||||
+146
@@ -0,0 +1,146 @@
|
||||
package com.wrbug.polymarketbot.service.copytrading.research
|
||||
|
||||
import com.wrbug.polymarketbot.dto.CopyTradingCreateRequest
|
||||
import com.wrbug.polymarketbot.dto.LeaderResearchApprovalRequest
|
||||
import com.wrbug.polymarketbot.dto.LeaderResearchApprovalResponse
|
||||
import com.wrbug.polymarketbot.entity.LeaderPool
|
||||
import com.wrbug.polymarketbot.enums.LeaderPoolStatus
|
||||
import com.wrbug.polymarketbot.enums.LeaderResearchEventType
|
||||
import com.wrbug.polymarketbot.enums.LeaderResearchState
|
||||
import com.wrbug.polymarketbot.repository.AccountRepository
|
||||
import com.wrbug.polymarketbot.repository.CopyTradingRepository
|
||||
import com.wrbug.polymarketbot.repository.LeaderPoolRepository
|
||||
import com.wrbug.polymarketbot.repository.LeaderResearchCandidateRepository
|
||||
import com.wrbug.polymarketbot.service.copytrading.configs.CopyTradingService
|
||||
import org.slf4j.LoggerFactory
|
||||
import org.springframework.stereotype.Service
|
||||
import org.springframework.transaction.annotation.Transactional
|
||||
import java.math.BigDecimal
|
||||
|
||||
class LeaderResearchCandidateNotReadyException : RuntimeException("候选尚未进入 TRIAL_READY,不能创建试跟配置")
|
||||
class LeaderResearchApprovalConfirmRequiredException : RuntimeException("创建禁用试跟配置需要显式确认")
|
||||
class LeaderResearchDuplicateTrialConfigException : RuntimeException("该账户已存在此 Leader 的跟单配置")
|
||||
class LeaderResearchRealMoneyForbiddenException : RuntimeException("Leader Research Agent 不允许自动启用真钱跟单")
|
||||
class LeaderResearchCandidateLockedException : RuntimeException("研究候选已锁定")
|
||||
|
||||
@Service
|
||||
class LeaderResearchApprovalService(
|
||||
private val candidateRepository: LeaderResearchCandidateRepository,
|
||||
private val accountRepository: AccountRepository,
|
||||
private val copyTradingRepository: CopyTradingRepository,
|
||||
private val leaderPoolRepository: LeaderPoolRepository,
|
||||
private val copyTradingService: CopyTradingService,
|
||||
private val poolMappingService: LeaderResearchPoolMappingService,
|
||||
private val eventService: LeaderResearchEventService
|
||||
) {
|
||||
private val logger = LoggerFactory.getLogger(LeaderResearchApprovalService::class.java)
|
||||
|
||||
@Transactional
|
||||
fun createDisabledTrialConfig(request: LeaderResearchApprovalRequest): Result<LeaderResearchApprovalResponse> {
|
||||
return try {
|
||||
if (!request.confirm) {
|
||||
return Result.failure(LeaderResearchApprovalConfirmRequiredException())
|
||||
}
|
||||
val candidate = candidateRepository.findById(request.candidateId).orElse(null)
|
||||
?: return Result.failure(IllegalArgumentException("候选不存在"))
|
||||
if (candidate.locked) {
|
||||
eventService.record(
|
||||
type = LeaderResearchEventType.APPROVAL_REJECTED,
|
||||
candidateId = candidate.id,
|
||||
reason = "Candidate is locked; manual unlock is required before approval"
|
||||
)
|
||||
return Result.failure(LeaderResearchCandidateLockedException())
|
||||
}
|
||||
if (candidate.researchState != LeaderResearchState.TRIAL_READY) {
|
||||
eventService.record(
|
||||
type = LeaderResearchEventType.APPROVAL_REJECTED,
|
||||
candidateId = candidate.id,
|
||||
reason = "Candidate state is ${candidate.researchState}, not TRIAL_READY"
|
||||
)
|
||||
return Result.failure(LeaderResearchCandidateNotReadyException())
|
||||
}
|
||||
val account = accountRepository.findByIdForUpdate(request.accountId)
|
||||
?: return Result.failure(IllegalArgumentException("账户不存在"))
|
||||
val synced = poolMappingService.syncCandidate(candidate)
|
||||
val pool = synced.poolId?.let { leaderPoolRepository.findById(it).orElse(null) }
|
||||
?: return Result.failure(IllegalStateException("Leader Pool 同步失败"))
|
||||
val leaderId = synced.leaderId ?: pool.leaderId
|
||||
if (copyTradingRepository.findByAccountIdAndLeaderId(account.id ?: request.accountId, leaderId).isNotEmpty()) {
|
||||
eventService.record(
|
||||
type = LeaderResearchEventType.DUPLICATE_APPROVAL,
|
||||
candidateId = candidate.id,
|
||||
reason = "Duplicate copy trading config for account=${account.id}, leader=$leaderId"
|
||||
)
|
||||
return Result.failure(LeaderResearchDuplicateTrialConfigException())
|
||||
}
|
||||
|
||||
val copyRequest = buildDisabledCopyTradingRequest(pool, request.accountId, leaderId)
|
||||
if (copyRequest.enabled) {
|
||||
eventService.record(
|
||||
type = LeaderResearchEventType.REAL_MONEY_ACTIVATION_FORBIDDEN,
|
||||
candidateId = candidate.id,
|
||||
reason = "Research approval attempted to create enabled copy trading config",
|
||||
dedupeKey = "approval-real-money-forbidden:${candidate.id}:${request.accountId}"
|
||||
)
|
||||
return Result.failure(LeaderResearchRealMoneyForbiddenException())
|
||||
}
|
||||
val copyTrading = copyTradingService.createCopyTrading(copyRequest).getOrThrow()
|
||||
val now = System.currentTimeMillis()
|
||||
leaderPoolRepository.save(
|
||||
pool.copy(
|
||||
status = LeaderPoolStatus.TRIAL,
|
||||
lastPromotedAt = now,
|
||||
lastReviewedAt = now,
|
||||
researchState = LeaderResearchState.TRIAL_READY,
|
||||
researchBadge = "DISABLED_TRIAL_CREATED",
|
||||
researchUpdatedAt = now,
|
||||
updatedAt = now
|
||||
)
|
||||
)
|
||||
eventService.record(
|
||||
type = LeaderResearchEventType.APPROVAL_CREATED_DISABLED_CONFIG,
|
||||
candidateId = candidate.id,
|
||||
reason = "Created disabled copy trading config id=${copyTrading.id}; manual enable required",
|
||||
payloadSummary = "accountId=${request.accountId}, leaderId=$leaderId",
|
||||
dedupeKey = "approval-disabled:${candidate.id}:${request.accountId}"
|
||||
)
|
||||
Result.success(LeaderResearchApprovalResponse(copyTrading))
|
||||
} catch (e: Exception) {
|
||||
logger.error("Leader research approval failed: candidateId=${request.candidateId}", e)
|
||||
Result.failure(e)
|
||||
}
|
||||
}
|
||||
|
||||
private fun buildDisabledCopyTradingRequest(pool: LeaderPool, accountId: Long, leaderId: Long): CopyTradingCreateRequest {
|
||||
val fixedAmount = pool.suggestedFixedAmount.takeIf { it > BigDecimal.ZERO } ?: BigDecimal("1.00000000")
|
||||
return CopyTradingCreateRequest(
|
||||
accountId = accountId,
|
||||
leaderId = leaderId,
|
||||
enabled = false,
|
||||
copyMode = "FIXED",
|
||||
copyRatio = "1",
|
||||
fixedAmount = fixedAmount.strip(),
|
||||
maxOrderSize = fixedAmount.strip(),
|
||||
minOrderSize = "1",
|
||||
maxDailyLoss = (pool.suggestedMaxDailyLoss.takeIf { it > BigDecimal.ZERO } ?: BigDecimal("5.00000000")).strip(),
|
||||
maxDailyOrders = pool.suggestedMaxDailyOrders.coerceIn(1, 10),
|
||||
priceTolerance = "1",
|
||||
delaySeconds = 0,
|
||||
pollIntervalSeconds = 5,
|
||||
useWebSocket = true,
|
||||
websocketReconnectInterval = 5000,
|
||||
websocketMaxRetries = 10,
|
||||
supportSell = true,
|
||||
minPrice = pool.suggestedMinPrice?.strip() ?: "0.1",
|
||||
maxPrice = pool.suggestedMaxPrice?.strip() ?: "0.8",
|
||||
maxPositionValue = pool.suggestedMaxPositionValue?.strip() ?: "5",
|
||||
keywordFilterMode = "DISABLED",
|
||||
keywords = null,
|
||||
configName = "Research试跟-${pool.researchCandidateId ?: pool.leaderId}",
|
||||
pushFailedOrders = true,
|
||||
pushFilteredOrders = true
|
||||
)
|
||||
}
|
||||
|
||||
private fun BigDecimal.strip(): String = stripTrailingZeros().toPlainString()
|
||||
}
|
||||
+50
@@ -0,0 +1,50 @@
|
||||
package com.wrbug.polymarketbot.service.copytrading.research
|
||||
|
||||
import com.wrbug.polymarketbot.entity.LeaderResearchEvent
|
||||
import com.wrbug.polymarketbot.enums.LeaderResearchEventType
|
||||
import com.wrbug.polymarketbot.enums.LeaderResearchNotificationStatus
|
||||
import com.wrbug.polymarketbot.repository.LeaderResearchEventRepository
|
||||
import org.slf4j.LoggerFactory
|
||||
import org.springframework.dao.DataIntegrityViolationException
|
||||
import org.springframework.stereotype.Service
|
||||
|
||||
@Service
|
||||
class LeaderResearchEventService(
|
||||
private val eventRepository: LeaderResearchEventRepository
|
||||
) {
|
||||
private val logger = LoggerFactory.getLogger(LeaderResearchEventService::class.java)
|
||||
|
||||
fun record(
|
||||
type: LeaderResearchEventType,
|
||||
candidateId: Long? = null,
|
||||
runId: Long? = null,
|
||||
reason: String? = null,
|
||||
payloadSummary: String? = null,
|
||||
dedupeKey: String? = null,
|
||||
notificationStatus: LeaderResearchNotificationStatus = LeaderResearchNotificationStatus.PENDING
|
||||
): LeaderResearchEvent? {
|
||||
return try {
|
||||
if (!dedupeKey.isNullOrBlank()) {
|
||||
eventRepository.findTopByDedupeKey(dedupeKey)?.let { return it }
|
||||
}
|
||||
eventRepository.save(
|
||||
LeaderResearchEvent(
|
||||
candidateId = candidateId,
|
||||
runId = runId,
|
||||
eventType = type,
|
||||
reason = reason,
|
||||
payloadSummary = payloadSummary,
|
||||
notificationStatus = notificationStatus,
|
||||
dedupeKey = dedupeKey,
|
||||
createdAt = System.currentTimeMillis()
|
||||
)
|
||||
)
|
||||
} catch (e: DataIntegrityViolationException) {
|
||||
logger.debug("Research event deduped: type={}, dedupeKey={}", type, dedupeKey)
|
||||
dedupeKey?.let { eventRepository.findTopByDedupeKey(it) }
|
||||
} catch (e: Exception) {
|
||||
logger.warn("Failed to record research event: type={}, candidateId={}, error={}", type, candidateId, e.message)
|
||||
null
|
||||
}
|
||||
}
|
||||
}
|
||||
+144
@@ -0,0 +1,144 @@
|
||||
package com.wrbug.polymarketbot.service.copytrading.research
|
||||
|
||||
import com.wrbug.polymarketbot.entity.LeaderResearchRun
|
||||
import com.wrbug.polymarketbot.enums.LeaderResearchEventType
|
||||
import com.wrbug.polymarketbot.enums.LeaderResearchRunStatus
|
||||
import com.wrbug.polymarketbot.enums.LeaderResearchState
|
||||
import com.wrbug.polymarketbot.enums.LeaderResearchTriggerType
|
||||
import com.wrbug.polymarketbot.repository.LeaderActivityEventRepository
|
||||
import com.wrbug.polymarketbot.repository.LeaderResearchCandidateRepository
|
||||
import com.wrbug.polymarketbot.repository.LeaderResearchRunRepository
|
||||
import org.slf4j.LoggerFactory
|
||||
import org.springframework.beans.factory.annotation.Value
|
||||
import org.springframework.scheduling.annotation.Scheduled
|
||||
import org.springframework.stereotype.Service
|
||||
import org.springframework.transaction.annotation.Transactional
|
||||
import java.util.concurrent.atomic.AtomicBoolean
|
||||
|
||||
@Service
|
||||
class LeaderResearchJobService(
|
||||
private val runRepository: LeaderResearchRunRepository,
|
||||
private val activityEventRepository: LeaderActivityEventRepository,
|
||||
private val candidateRepository: LeaderResearchCandidateRepository,
|
||||
private val sourceService: LeaderResearchSourceService,
|
||||
private val paperTradingService: LeaderPaperTradingService,
|
||||
private val scoringService: LeaderResearchScoringService,
|
||||
private val stateMachine: LeaderResearchStateMachine,
|
||||
private val eventService: LeaderResearchEventService,
|
||||
@Value("\${leader.research.enabled:false}") private val scheduledEnabled: Boolean
|
||||
) {
|
||||
private val logger = LoggerFactory.getLogger(LeaderResearchJobService::class.java)
|
||||
private val running = AtomicBoolean(false)
|
||||
|
||||
@Scheduled(fixedDelayString = "\${leader.research.fixed-delay-ms:900000}")
|
||||
fun scheduledRun() {
|
||||
if (!scheduledEnabled) return
|
||||
runOnce(dryRun = false, triggerType = LeaderResearchTriggerType.SCHEDULED)
|
||||
}
|
||||
|
||||
@Transactional
|
||||
fun runOnce(dryRun: Boolean, triggerType: LeaderResearchTriggerType = LeaderResearchTriggerType.MANUAL): LeaderResearchRun {
|
||||
if (!running.compareAndSet(false, true)) {
|
||||
val now = System.currentTimeMillis()
|
||||
val skipped = runRepository.save(
|
||||
LeaderResearchRun(
|
||||
status = LeaderResearchRunStatus.SKIPPED,
|
||||
triggerType = triggerType,
|
||||
dryRun = dryRun,
|
||||
startedAt = now,
|
||||
finishedAt = now,
|
||||
durationMs = 0,
|
||||
skippedReason = "another_run_in_progress",
|
||||
createdAt = now,
|
||||
updatedAt = now
|
||||
)
|
||||
)
|
||||
eventService.record(
|
||||
type = LeaderResearchEventType.RUN_SKIPPED,
|
||||
runId = skipped.id,
|
||||
reason = "Skipped because another research run is in progress"
|
||||
)
|
||||
return skipped
|
||||
}
|
||||
|
||||
val startedAt = System.currentTimeMillis()
|
||||
var run = runRepository.save(
|
||||
LeaderResearchRun(
|
||||
status = LeaderResearchRunStatus.RUNNING,
|
||||
triggerType = triggerType,
|
||||
dryRun = dryRun,
|
||||
startedAt = startedAt,
|
||||
createdAt = startedAt,
|
||||
updatedAt = startedAt
|
||||
)
|
||||
)
|
||||
eventService.record(
|
||||
type = LeaderResearchEventType.RUN_STARTED,
|
||||
runId = run.id,
|
||||
reason = "Leader research run started"
|
||||
)
|
||||
|
||||
return try {
|
||||
val isPreview = dryRun || triggerType == LeaderResearchTriggerType.PREVIEW
|
||||
val sourceResults = if (isPreview) sourceService.previewCandidates() else sourceService.discoverCandidates(run.id)
|
||||
if (!isPreview) {
|
||||
scoringService.scoreAll(run.id)
|
||||
stateMachine.advanceAll(run.id)
|
||||
paperTradingService.processPaperCandidates(run.id)
|
||||
scoringService.scoreAll(run.id)
|
||||
stateMachine.advanceAll(run.id)
|
||||
}
|
||||
val now = System.currentTimeMillis()
|
||||
val sourceCounts = sourceResults.joinToString(",", prefix = "{", postfix = "}") {
|
||||
"\"${it.sourceType.name}\":${it.candidates.size}"
|
||||
}
|
||||
val candidateCounts = LeaderResearchState.values().joinToString(",", prefix = "{", postfix = "}") { state ->
|
||||
"\"${state.name}\":${candidateRepository.countByResearchState(state)}"
|
||||
}
|
||||
val lastEventCursor = activityEventRepository.findTopByOrderByEventTimeDesc()
|
||||
?.let { "${it.eventTime}:${it.stableEventKey}" }
|
||||
val hasSourceProblems = sourceResults.any { it.status.name == "FAILURE" || it.status.name == "DEGRADED" }
|
||||
run = runRepository.save(
|
||||
run.copy(
|
||||
status = if (hasSourceProblems) LeaderResearchRunStatus.PARTIAL_FAILURE else LeaderResearchRunStatus.SUCCESS,
|
||||
finishedAt = now,
|
||||
durationMs = now - startedAt,
|
||||
sourceCountsJson = sourceCounts,
|
||||
candidateCountsJson = candidateCounts,
|
||||
lastEventCursor = lastEventCursor,
|
||||
partialFailure = hasSourceProblems,
|
||||
updatedAt = now
|
||||
)
|
||||
)
|
||||
eventService.record(
|
||||
type = LeaderResearchEventType.RUN_COMPLETED,
|
||||
runId = run.id,
|
||||
reason = "Leader research run completed",
|
||||
payloadSummary = "sourceCounts=$sourceCounts candidateCounts=$candidateCounts"
|
||||
)
|
||||
run
|
||||
} catch (e: Exception) {
|
||||
logger.error("Leader research run failed", e)
|
||||
val now = System.currentTimeMillis()
|
||||
runRepository.save(
|
||||
run.copy(
|
||||
status = LeaderResearchRunStatus.FAILED,
|
||||
finishedAt = now,
|
||||
durationMs = now - startedAt,
|
||||
errorClass = e::class.java.simpleName,
|
||||
errorMessage = e.message,
|
||||
updatedAt = now
|
||||
)
|
||||
).also {
|
||||
eventService.record(
|
||||
type = LeaderResearchEventType.RUN_FAILED,
|
||||
runId = it.id,
|
||||
reason = e.message,
|
||||
payloadSummary = e::class.java.name
|
||||
)
|
||||
}
|
||||
} finally {
|
||||
running.set(false)
|
||||
}
|
||||
}
|
||||
}
|
||||
+251
@@ -0,0 +1,251 @@
|
||||
package com.wrbug.polymarketbot.service.copytrading.research
|
||||
|
||||
import com.wrbug.polymarketbot.dto.*
|
||||
import com.wrbug.polymarketbot.entity.*
|
||||
import com.wrbug.polymarketbot.enums.LeaderPoolStatus
|
||||
import com.wrbug.polymarketbot.repository.LeaderPoolRepository
|
||||
import com.wrbug.polymarketbot.repository.LeaderRepository
|
||||
import com.wrbug.polymarketbot.repository.LeaderResearchSourceStateRepository
|
||||
import org.springframework.stereotype.Component
|
||||
import java.math.BigDecimal
|
||||
|
||||
data class LeaderResearchCandidateDtoContext(
|
||||
val leadersById: Map<Long, Leader> = emptyMap(),
|
||||
val poolsById: Map<Long, LeaderPool> = emptyMap(),
|
||||
val latestSessionsByCandidateId: Map<Long, LeaderPaperSession> = emptyMap()
|
||||
)
|
||||
|
||||
@Component
|
||||
class LeaderResearchMapper(
|
||||
private val leaderRepository: LeaderRepository,
|
||||
private val leaderPoolRepository: LeaderPoolRepository,
|
||||
private val sourceStateRepository: LeaderResearchSourceStateRepository
|
||||
) {
|
||||
fun runDto(run: LeaderResearchRun): LeaderResearchRunDto {
|
||||
return LeaderResearchRunDto(
|
||||
id = run.id ?: 0,
|
||||
status = run.status.name,
|
||||
triggerType = run.triggerType.name,
|
||||
dryRun = run.dryRun,
|
||||
startedAt = run.startedAt,
|
||||
finishedAt = run.finishedAt,
|
||||
durationMs = run.durationMs,
|
||||
sourceCountsJson = run.sourceCountsJson,
|
||||
candidateCountsJson = run.candidateCountsJson,
|
||||
partialFailure = run.partialFailure,
|
||||
skippedReason = run.skippedReason,
|
||||
errorClass = run.errorClass,
|
||||
errorMessage = run.errorMessage
|
||||
)
|
||||
}
|
||||
|
||||
fun candidateDto(candidate: LeaderResearchCandidate, latestSession: LeaderPaperSession? = null): LeaderResearchCandidateDto {
|
||||
val leader = candidate.leaderId?.let { leaderRepository.findById(it).orElse(null) }
|
||||
val pool = candidate.poolId?.let { leaderPoolRepository.findById(it).orElse(null) }
|
||||
return candidateDto(candidate, leader, pool, latestSession)
|
||||
}
|
||||
|
||||
fun candidateDtos(candidates: List<LeaderResearchCandidate>, context: LeaderResearchCandidateDtoContext): List<LeaderResearchCandidateDto> {
|
||||
return candidates.map { candidate ->
|
||||
val candidateId = candidate.id
|
||||
candidateDto(
|
||||
candidate = candidate,
|
||||
leader = candidate.leaderId?.let { context.leadersById[it] },
|
||||
pool = candidate.poolId?.let { context.poolsById[it] },
|
||||
latestSession = candidateId?.let { context.latestSessionsByCandidateId[it] }
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
private fun candidateDto(
|
||||
candidate: LeaderResearchCandidate,
|
||||
leader: Leader?,
|
||||
pool: LeaderPool?,
|
||||
latestSession: LeaderPaperSession?
|
||||
): LeaderResearchCandidateDto {
|
||||
return LeaderResearchCandidateDto(
|
||||
id = candidate.id ?: 0,
|
||||
normalizedWallet = candidate.normalizedWallet,
|
||||
leaderId = candidate.leaderId,
|
||||
leaderName = leader?.leaderName,
|
||||
poolId = candidate.poolId,
|
||||
poolStatus = pool?.status?.name,
|
||||
suggestedFixedAmount = pool?.suggestedFixedAmount?.strip(),
|
||||
suggestedMaxDailyLoss = pool?.suggestedMaxDailyLoss?.strip(),
|
||||
suggestedMaxDailyOrders = pool?.suggestedMaxDailyOrders,
|
||||
suggestedMinPrice = pool?.suggestedMinPrice?.strip(),
|
||||
suggestedMaxPrice = pool?.suggestedMaxPrice?.strip(),
|
||||
suggestedMaxPositionValue = pool?.suggestedMaxPositionValue?.strip(),
|
||||
researchState = candidate.researchState.name,
|
||||
source = candidate.source,
|
||||
sourceRank = candidate.sourceRank,
|
||||
score = candidate.score?.strip(),
|
||||
scoreVersion = candidate.scoreVersion,
|
||||
reason = candidate.reason,
|
||||
riskFlags = splitFlags(candidate.riskFlags),
|
||||
locked = candidate.locked,
|
||||
agentOwned = candidate.agentOwned,
|
||||
provenance = candidate.provenance.name,
|
||||
sourceEvidence = candidate.sourceEvidence,
|
||||
firstSeenAt = candidate.firstSeenAt,
|
||||
lastSourceSeenAt = candidate.lastSourceSeenAt,
|
||||
lastScoredAt = candidate.lastScoredAt,
|
||||
cooldownUntil = candidate.cooldownUntil,
|
||||
cooldownCount = candidate.cooldownCount,
|
||||
trialReadyAt = candidate.trialReadyAt,
|
||||
retiredAt = candidate.retiredAt,
|
||||
lastPaperSessionId = candidate.lastPaperSessionId,
|
||||
latestPaperSession = latestSession?.let { paperSessionDto(it) }
|
||||
)
|
||||
}
|
||||
|
||||
fun scoreDto(score: LeaderResearchScore): LeaderResearchScoreDto {
|
||||
return LeaderResearchScoreDto(
|
||||
id = score.id ?: 0,
|
||||
candidateId = score.candidateId,
|
||||
runId = score.runId,
|
||||
scoreVersion = score.scoreVersion,
|
||||
totalScore = score.totalScore.strip(),
|
||||
profitSignal = score.profitSignal.strip(),
|
||||
repeatability = score.repeatability.strip(),
|
||||
liquidityFit = score.liquidityFit.strip(),
|
||||
entryPriceFit = score.entryPriceFit.strip(),
|
||||
slippageRisk = score.slippageRisk.strip(),
|
||||
holdingPeriodFit = score.holdingPeriodFit.strip(),
|
||||
marketTypeRisk = score.marketTypeRisk.strip(),
|
||||
drawdownRisk = score.drawdownRisk.strip(),
|
||||
exitLiquidityRisk = score.exitLiquidityRisk.strip(),
|
||||
dataFreshness = score.dataFreshness.strip(),
|
||||
filterPassRate = score.filterPassRate.strip(),
|
||||
sampleTradeCount = score.sampleTradeCount,
|
||||
reason = score.reason,
|
||||
createdAt = score.createdAt
|
||||
)
|
||||
}
|
||||
|
||||
fun paperSessionDto(session: LeaderPaperSession): LeaderPaperSessionDto {
|
||||
return LeaderPaperSessionDto(
|
||||
id = session.id ?: 0,
|
||||
candidateId = session.candidateId,
|
||||
status = session.status.name,
|
||||
startedAt = session.startedAt,
|
||||
endedAt = session.endedAt,
|
||||
tradeCount = session.tradeCount,
|
||||
filteredCount = session.filteredCount,
|
||||
openExposure = session.openExposure.strip(),
|
||||
totalRealizedPnl = session.totalRealizedPnl.strip(),
|
||||
totalUnrealizedPnl = session.totalUnrealizedPnl.strip(),
|
||||
copyablePnl = session.copyablePnl.strip(),
|
||||
maxDrawdown = session.maxDrawdown.strip(),
|
||||
unknownValuationExposure = session.unknownValuationExposure.strip(),
|
||||
confirmedZeroExposure = session.confirmedZeroExposure.strip(),
|
||||
filteredRatio = session.filteredRatio.strip(),
|
||||
lastProcessedEventTime = session.lastProcessedEventTime,
|
||||
scoreSnapshot = session.scoreSnapshot?.strip()
|
||||
)
|
||||
}
|
||||
|
||||
fun paperTradeDto(trade: LeaderPaperTrade): LeaderPaperTradeDto {
|
||||
return LeaderPaperTradeDto(
|
||||
id = trade.id ?: 0,
|
||||
sessionId = trade.sessionId,
|
||||
candidateId = trade.candidateId,
|
||||
activityEventId = trade.activityEventId,
|
||||
leaderTradeId = trade.leaderTradeId,
|
||||
marketId = trade.marketId,
|
||||
marketTitle = trade.marketTitle,
|
||||
marketSlug = trade.marketSlug,
|
||||
side = trade.side,
|
||||
outcome = trade.outcome,
|
||||
outcomeIndex = trade.outcomeIndex,
|
||||
leaderPrice = trade.leaderPrice?.strip(),
|
||||
leaderSize = trade.leaderSize?.strip(),
|
||||
simulatedPrice = trade.simulatedPrice?.strip(),
|
||||
simulatedSize = trade.simulatedSize?.strip(),
|
||||
simulatedAmount = trade.simulatedAmount?.strip(),
|
||||
fillAssumption = trade.fillAssumption.name,
|
||||
quoteConfidence = trade.quoteConfidence.name,
|
||||
quoteSource = trade.quoteSource,
|
||||
quoteTimestamp = trade.quoteTimestamp,
|
||||
filterResult = trade.filterResult.name,
|
||||
filterReason = trade.filterReason,
|
||||
valuationStatus = trade.valuationStatus.name,
|
||||
realizedPnl = trade.realizedPnl?.strip(),
|
||||
eventTime = trade.eventTime,
|
||||
createdAt = trade.createdAt
|
||||
)
|
||||
}
|
||||
|
||||
fun paperPositionDto(position: LeaderPaperPosition): LeaderPaperPositionDto {
|
||||
return LeaderPaperPositionDto(
|
||||
id = position.id ?: 0,
|
||||
sessionId = position.sessionId,
|
||||
candidateId = position.candidateId,
|
||||
marketId = position.marketId,
|
||||
outcome = position.outcome,
|
||||
outcomeIndex = position.outcomeIndex,
|
||||
quantity = position.quantity.strip(),
|
||||
cost = position.cost.strip(),
|
||||
avgPrice = position.avgPrice.strip(),
|
||||
currentPrice = position.currentPrice?.strip(),
|
||||
currentValue = position.currentValue.strip(),
|
||||
realizedPnl = position.realizedPnl.strip(),
|
||||
unrealizedPnl = position.unrealizedPnl.strip(),
|
||||
valuationStatus = position.valuationStatus.name,
|
||||
quoteConfidence = position.quoteConfidence.name,
|
||||
quoteSource = position.quoteSource,
|
||||
quoteTimestamp = position.quoteTimestamp,
|
||||
updatedAt = position.updatedAt
|
||||
)
|
||||
}
|
||||
|
||||
fun sourceStateDto(state: LeaderResearchSourceState): LeaderResearchSourceStateDto {
|
||||
return LeaderResearchSourceStateDto(
|
||||
sourceType = state.sourceType.name,
|
||||
status = state.status.name,
|
||||
lastSuccessAt = state.lastSuccessAt,
|
||||
lastFailureAt = state.lastFailureAt,
|
||||
lastRunAt = state.lastRunAt,
|
||||
lastCandidateCount = state.lastCandidateCount,
|
||||
errorClass = state.errorClass,
|
||||
errorMessage = state.errorMessage,
|
||||
stale = state.stale,
|
||||
disabledReason = state.disabledReason,
|
||||
lastCursor = state.lastCursor,
|
||||
updatedAt = state.updatedAt
|
||||
)
|
||||
}
|
||||
|
||||
fun eventDto(event: LeaderResearchEvent): LeaderResearchEventDto {
|
||||
return LeaderResearchEventDto(
|
||||
id = event.id ?: 0,
|
||||
candidateId = event.candidateId,
|
||||
runId = event.runId,
|
||||
eventType = event.eventType.name,
|
||||
reason = event.reason,
|
||||
payloadSummary = event.payloadSummary,
|
||||
notificationStatus = event.notificationStatus.name,
|
||||
notificationError = event.notificationError,
|
||||
dedupeKey = event.dedupeKey,
|
||||
createdAt = event.createdAt,
|
||||
notifiedAt = event.notifiedAt
|
||||
)
|
||||
}
|
||||
|
||||
fun sourceLimitations(): List<String> {
|
||||
return sourceStateRepository.findAllByOrderByUpdatedAtDesc()
|
||||
.filter { it.stale || it.status.name == "DISABLED" || !it.disabledReason.isNullOrBlank() }
|
||||
.map { "${it.sourceType.name}: ${it.disabledReason ?: it.errorMessage ?: it.status.name}" }
|
||||
}
|
||||
|
||||
fun isTrialOrActive(status: LeaderPoolStatus?): Boolean {
|
||||
return status == LeaderPoolStatus.TRIAL || status == LeaderPoolStatus.ACTIVE
|
||||
}
|
||||
|
||||
private fun splitFlags(raw: String?): List<String> {
|
||||
if (raw.isNullOrBlank()) return emptyList()
|
||||
return raw.split(",", "\n", ";").map { it.trim() }.filter { it.isNotEmpty() }
|
||||
}
|
||||
|
||||
private fun BigDecimal.strip(): String = stripTrailingZeros().toPlainString()
|
||||
}
|
||||
+72
@@ -0,0 +1,72 @@
|
||||
package com.wrbug.polymarketbot.service.copytrading.research
|
||||
|
||||
import com.wrbug.polymarketbot.entity.LeaderResearchEvent
|
||||
import com.wrbug.polymarketbot.enums.LeaderResearchEventType
|
||||
import com.wrbug.polymarketbot.enums.LeaderResearchNotificationStatus
|
||||
import com.wrbug.polymarketbot.repository.LeaderResearchEventRepository
|
||||
import org.springframework.data.domain.PageRequest
|
||||
import org.springframework.stereotype.Service
|
||||
import org.springframework.transaction.annotation.Transactional
|
||||
|
||||
data class LeaderResearchNotificationSummary(
|
||||
val total: Int,
|
||||
val newCandidates: Int,
|
||||
val trialReady: Int,
|
||||
val cooldowns: Int,
|
||||
val sourceFailures: Int,
|
||||
val valuationWarnings: Int,
|
||||
val approvalWarnings: Int,
|
||||
val lines: List<String>
|
||||
)
|
||||
|
||||
@Service
|
||||
class LeaderResearchNotificationSummaryService(
|
||||
private val eventRepository: LeaderResearchEventRepository
|
||||
) {
|
||||
fun buildPendingSummary(limit: Int = 100): LeaderResearchNotificationSummary {
|
||||
val events = eventRepository.findByNotificationStatusOrderByCreatedAtAsc(
|
||||
LeaderResearchNotificationStatus.PENDING,
|
||||
PageRequest.of(0, limit.coerceIn(1, 500))
|
||||
).content
|
||||
return summarize(events)
|
||||
}
|
||||
|
||||
@Transactional
|
||||
fun markPendingAsSkipped(limit: Int = 100, reason: String = "operator_console_only"): LeaderResearchNotificationSummary {
|
||||
val events = eventRepository.findByNotificationStatusOrderByCreatedAtAsc(
|
||||
LeaderResearchNotificationStatus.PENDING,
|
||||
PageRequest.of(0, limit.coerceIn(1, 500))
|
||||
).content
|
||||
val now = System.currentTimeMillis()
|
||||
events.forEach { event ->
|
||||
eventRepository.save(
|
||||
event.copy(
|
||||
notificationStatus = LeaderResearchNotificationStatus.SKIPPED,
|
||||
notificationError = reason,
|
||||
notifiedAt = now
|
||||
)
|
||||
)
|
||||
}
|
||||
return summarize(events)
|
||||
}
|
||||
|
||||
private fun summarize(events: List<LeaderResearchEvent>): LeaderResearchNotificationSummary {
|
||||
val lines = events.take(20).map { event ->
|
||||
"${event.eventType.name}: ${event.reason ?: event.payloadSummary ?: "no details"}"
|
||||
}
|
||||
return LeaderResearchNotificationSummary(
|
||||
total = events.size,
|
||||
newCandidates = events.count { it.eventType == LeaderResearchEventType.CANDIDATE_DISCOVERED },
|
||||
trialReady = events.count { it.eventType == LeaderResearchEventType.TRIAL_READY },
|
||||
cooldowns = events.count { it.eventType == LeaderResearchEventType.COOLDOWN },
|
||||
sourceFailures = events.count { it.eventType == LeaderResearchEventType.SOURCE_FAILURE },
|
||||
valuationWarnings = events.count { it.eventType == LeaderResearchEventType.VALUATION_STALE },
|
||||
approvalWarnings = events.count {
|
||||
it.eventType == LeaderResearchEventType.APPROVAL_REJECTED ||
|
||||
it.eventType == LeaderResearchEventType.DUPLICATE_APPROVAL ||
|
||||
it.eventType == LeaderResearchEventType.REAL_MONEY_ACTIVATION_FORBIDDEN
|
||||
},
|
||||
lines = lines
|
||||
)
|
||||
}
|
||||
}
|
||||
+99
@@ -0,0 +1,99 @@
|
||||
package com.wrbug.polymarketbot.service.copytrading.research
|
||||
|
||||
import com.wrbug.polymarketbot.entity.Leader
|
||||
import com.wrbug.polymarketbot.entity.LeaderPool
|
||||
import com.wrbug.polymarketbot.entity.LeaderResearchCandidate
|
||||
import com.wrbug.polymarketbot.enums.LeaderPoolStatus
|
||||
import com.wrbug.polymarketbot.enums.LeaderResearchState
|
||||
import com.wrbug.polymarketbot.repository.LeaderPoolRepository
|
||||
import com.wrbug.polymarketbot.repository.LeaderRepository
|
||||
import com.wrbug.polymarketbot.repository.LeaderResearchCandidateRepository
|
||||
import org.springframework.stereotype.Service
|
||||
import org.springframework.transaction.annotation.Transactional
|
||||
import java.math.BigDecimal
|
||||
|
||||
@Service
|
||||
class LeaderResearchPoolMappingService(
|
||||
private val leaderRepository: LeaderRepository,
|
||||
private val leaderPoolRepository: LeaderPoolRepository,
|
||||
private val candidateRepository: LeaderResearchCandidateRepository
|
||||
) {
|
||||
@Transactional
|
||||
fun syncCandidate(candidate: LeaderResearchCandidate): LeaderResearchCandidate {
|
||||
require(candidate.researchState != LeaderResearchState.DISCOVERED) {
|
||||
"DISCOVERED research candidates must not be synced to Leader Pool"
|
||||
}
|
||||
val now = System.currentTimeMillis()
|
||||
val leader = ensureLeader(candidate)
|
||||
val pool = ensurePool(candidate, leader)
|
||||
val badge = when (candidate.researchState) {
|
||||
LeaderResearchState.TRIAL_READY -> "RESEARCH_TRIAL_READY"
|
||||
LeaderResearchState.PAPER -> "RESEARCH_PAPER"
|
||||
LeaderResearchState.COOLDOWN -> "RESEARCH_COOLDOWN"
|
||||
else -> null
|
||||
}
|
||||
val savedPool = leaderPoolRepository.save(
|
||||
pool.copy(
|
||||
researchCandidateId = candidate.id,
|
||||
researchState = candidate.researchState,
|
||||
researchBadge = badge,
|
||||
researchSummary = candidate.reason?.take(1000),
|
||||
researchScore = candidate.score,
|
||||
researchUpdatedAt = now,
|
||||
updatedAt = now
|
||||
)
|
||||
)
|
||||
return candidateRepository.save(
|
||||
candidate.copy(
|
||||
leaderId = leader.id,
|
||||
poolId = savedPool.id,
|
||||
updatedAt = now
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
private fun ensureLeader(candidate: LeaderResearchCandidate): Leader {
|
||||
candidate.leaderId?.let { id ->
|
||||
leaderRepository.findById(id).orElse(null)?.let { return it }
|
||||
}
|
||||
leaderRepository.findByLeaderAddress(candidate.normalizedWallet)?.let { return it }
|
||||
val now = System.currentTimeMillis()
|
||||
return leaderRepository.save(
|
||||
Leader(
|
||||
leaderAddress = candidate.normalizedWallet,
|
||||
leaderName = "Research ${candidate.normalizedWallet.take(6)}...${candidate.normalizedWallet.takeLast(4)}",
|
||||
remark = "Created by Leader Research Agent. Manual enable is required before real-money copy trading.",
|
||||
createdAt = now,
|
||||
updatedAt = now
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
private fun ensurePool(candidate: LeaderResearchCandidate, leader: Leader): LeaderPool {
|
||||
leader.id?.let { leaderPoolRepository.findByLeaderId(it) }?.let { return it }
|
||||
val now = System.currentTimeMillis()
|
||||
return leaderPoolRepository.save(
|
||||
LeaderPool(
|
||||
leaderId = leader.id ?: 0,
|
||||
status = LeaderPoolStatus.WATCH,
|
||||
source = "RESEARCH_AGENT",
|
||||
score = candidate.score,
|
||||
reason = candidate.reason,
|
||||
notes = "Research agent candidate. Pool row is informational until you approve a disabled trial config.",
|
||||
suggestedFixedAmount = BigDecimal("1.00000000"),
|
||||
suggestedMaxDailyOrders = 10,
|
||||
suggestedMaxDailyLoss = BigDecimal("5.00000000"),
|
||||
suggestedMinPrice = BigDecimal("0.10000000"),
|
||||
suggestedMaxPrice = BigDecimal("0.80000000"),
|
||||
suggestedMaxPositionValue = BigDecimal("5.00000000"),
|
||||
researchCandidateId = candidate.id,
|
||||
researchState = candidate.researchState,
|
||||
researchScore = candidate.score,
|
||||
researchSummary = candidate.reason,
|
||||
researchUpdatedAt = now,
|
||||
createdAt = now,
|
||||
updatedAt = now
|
||||
)
|
||||
)
|
||||
}
|
||||
}
|
||||
+61
@@ -0,0 +1,61 @@
|
||||
package com.wrbug.polymarketbot.service.copytrading.research
|
||||
|
||||
import com.wrbug.polymarketbot.enums.LeaderPaperProcessingStatus
|
||||
import com.wrbug.polymarketbot.enums.LeaderPaperSessionStatus
|
||||
import com.wrbug.polymarketbot.repository.LeaderActivityEventRepository
|
||||
import com.wrbug.polymarketbot.repository.LeaderPaperSessionRepository
|
||||
import org.springframework.beans.factory.annotation.Value
|
||||
import org.springframework.data.domain.PageRequest
|
||||
import org.springframework.scheduling.annotation.Scheduled
|
||||
import org.springframework.stereotype.Service
|
||||
import org.springframework.transaction.annotation.Transactional
|
||||
|
||||
data class LeaderResearchRetentionResult(
|
||||
val deletedActivityEvents: Long,
|
||||
val deletedPaperSessions: Long
|
||||
)
|
||||
|
||||
@Service
|
||||
class LeaderResearchRetentionService(
|
||||
private val activityEventRepository: LeaderActivityEventRepository,
|
||||
private val paperSessionRepository: LeaderPaperSessionRepository,
|
||||
@Value("\${leader.research.retention.enabled:true}") private val enabled: Boolean,
|
||||
@Value("\${leader.research.retention.activity-days:90}") private val activityRetentionDays: Long,
|
||||
@Value("\${leader.research.retention.paper-session-days:180}") private val paperSessionRetentionDays: Long,
|
||||
@Value("\${leader.research.retention.max-paper-sessions-per-run:100}") private val maxPaperSessionsPerRun: Int
|
||||
) {
|
||||
@Scheduled(cron = "\${leader.research.retention.cron:0 17 3 * * *}")
|
||||
fun scheduledCleanup() {
|
||||
if (!enabled) return
|
||||
cleanup()
|
||||
}
|
||||
|
||||
@Transactional
|
||||
fun cleanup(now: Long = System.currentTimeMillis()): LeaderResearchRetentionResult {
|
||||
if (!enabled) return LeaderResearchRetentionResult(0, 0)
|
||||
val activityCutoff = now - activityRetentionDays.coerceAtLeast(7) * MILLIS_PER_DAY
|
||||
val paperCutoff = now - paperSessionRetentionDays.coerceAtLeast(30) * MILLIS_PER_DAY
|
||||
val deletedActivities = activityEventRepository.deleteByEventTimeLessThanAndPaperProcessingStatusIn(
|
||||
activityCutoff,
|
||||
listOf(
|
||||
LeaderPaperProcessingStatus.PROCESSED,
|
||||
LeaderPaperProcessingStatus.FILTERED,
|
||||
LeaderPaperProcessingStatus.FAILED
|
||||
)
|
||||
)
|
||||
val staleSessions = paperSessionRepository.findByUpdatedAtLessThanAndStatusIn(
|
||||
paperCutoff,
|
||||
listOf(LeaderPaperSessionStatus.COMPLETED, LeaderPaperSessionStatus.FAILED),
|
||||
PageRequest.of(0, maxPaperSessionsPerRun.coerceIn(1, 1000))
|
||||
)
|
||||
paperSessionRepository.deleteAll(staleSessions.content)
|
||||
return LeaderResearchRetentionResult(
|
||||
deletedActivityEvents = deletedActivities,
|
||||
deletedPaperSessions = staleSessions.content.size.toLong()
|
||||
)
|
||||
}
|
||||
|
||||
companion object {
|
||||
private const val MILLIS_PER_DAY = 24L * 60 * 60 * 1000
|
||||
}
|
||||
}
|
||||
+166
@@ -0,0 +1,166 @@
|
||||
package com.wrbug.polymarketbot.service.copytrading.research
|
||||
|
||||
import com.wrbug.polymarketbot.entity.LeaderPaperSession
|
||||
import com.wrbug.polymarketbot.entity.LeaderResearchCandidate
|
||||
import com.wrbug.polymarketbot.entity.LeaderResearchScore
|
||||
import com.wrbug.polymarketbot.enums.LeaderResearchState
|
||||
import com.wrbug.polymarketbot.repository.LeaderPaperSessionRepository
|
||||
import com.wrbug.polymarketbot.repository.LeaderResearchCandidateRepository
|
||||
import com.wrbug.polymarketbot.repository.LeaderResearchScoreRepository
|
||||
import org.springframework.stereotype.Service
|
||||
import org.springframework.transaction.annotation.Transactional
|
||||
import java.math.BigDecimal
|
||||
import java.math.RoundingMode
|
||||
|
||||
@Service
|
||||
class LeaderResearchScoringService(
|
||||
private val candidateRepository: LeaderResearchCandidateRepository,
|
||||
private val paperSessionRepository: LeaderPaperSessionRepository,
|
||||
private val scoreRepository: LeaderResearchScoreRepository
|
||||
) {
|
||||
@Transactional
|
||||
fun scoreAll(runId: Long?): List<LeaderResearchScore> {
|
||||
return candidateRepository.findByResearchStateIn(
|
||||
listOf(
|
||||
LeaderResearchState.DISCOVERED,
|
||||
LeaderResearchState.CANDIDATE,
|
||||
LeaderResearchState.PAPER,
|
||||
LeaderResearchState.TRIAL_READY,
|
||||
LeaderResearchState.COOLDOWN
|
||||
)
|
||||
).map { scoreCandidate(it, runId) }
|
||||
}
|
||||
|
||||
@Transactional
|
||||
fun scoreCandidate(candidate: LeaderResearchCandidate, runId: Long?): LeaderResearchScore {
|
||||
val session = candidate.id?.let { paperSessionRepository.findTopByCandidateIdOrderByStartedAtDesc(it) }
|
||||
val score = compute(candidate, session, runId)
|
||||
val savedScore = scoreRepository.save(score)
|
||||
val now = System.currentTimeMillis()
|
||||
candidateRepository.save(
|
||||
candidate.copy(
|
||||
score = savedScore.totalScore,
|
||||
scoreVersion = savedScore.scoreVersion,
|
||||
reason = savedScore.reason,
|
||||
riskFlags = buildRiskFlags(session),
|
||||
lastScoredAt = now,
|
||||
updatedAt = now
|
||||
)
|
||||
)
|
||||
return savedScore
|
||||
}
|
||||
|
||||
fun compute(candidate: LeaderResearchCandidate, session: LeaderPaperSession?, runId: Long?): LeaderResearchScore {
|
||||
val now = System.currentTimeMillis()
|
||||
val sourceFresh = candidate.lastSourceSeenAt?.let { now - it <= SOURCE_FRESH_MS } == true
|
||||
val paperAgeMs = session?.let { now - it.startedAt } ?: 0L
|
||||
val unknownRatio = session?.unknownRatio() ?: BigDecimal.ONE
|
||||
val filteredRatio = session?.filteredRatio ?: BigDecimal.ONE
|
||||
val copyablePnl = session?.copyablePnl ?: BigDecimal.ZERO
|
||||
val tradeCount = session?.tradeCount ?: 0
|
||||
|
||||
val profitSignal = when {
|
||||
copyablePnl > BigDecimal("10") -> BigDecimal("20")
|
||||
copyablePnl > BigDecimal.ZERO -> copyablePnl.multiply(BigDecimal("2")).clamp(BigDecimal.ZERO, BigDecimal("20"))
|
||||
else -> BigDecimal.ZERO
|
||||
}
|
||||
val repeatability = BigDecimal(tradeCount).multiply(BigDecimal("1.5")).clamp(BigDecimal.ZERO, BigDecimal("15"))
|
||||
val liquidityFit = BigDecimal("10").subtract(unknownRatio.multiply(BigDecimal("10"))).clamp(BigDecimal.ZERO, BigDecimal("10"))
|
||||
val entryPriceFit = BigDecimal("10").subtract(filteredRatio.multiply(BigDecimal("10"))).clamp(BigDecimal.ZERO, BigDecimal("10"))
|
||||
val slippageRisk = if (unknownRatio <= BigDecimal("0.20")) BigDecimal("10") else BigDecimal("4")
|
||||
val holdingPeriodFit = if (paperAgeMs >= PAPER_MIN_AGE_MS) BigDecimal("5") else BigDecimal(paperAgeMs).safeDivide(BigDecimal(PAPER_MIN_AGE_MS)).multiply(BigDecimal("5"))
|
||||
val marketTypeRisk = BigDecimal("5")
|
||||
val drawdownRisk = when {
|
||||
session == null -> BigDecimal("5")
|
||||
session.maxDrawdown >= BigDecimal("-5") -> BigDecimal("10")
|
||||
session.maxDrawdown >= BigDecimal("-15") -> BigDecimal("7")
|
||||
session.maxDrawdown >= BigDecimal("-20") -> BigDecimal("3")
|
||||
else -> BigDecimal.ZERO
|
||||
}
|
||||
val exitLiquidityRisk = if (unknownRatio <= BigDecimal("0.20")) BigDecimal("5") else BigDecimal("1")
|
||||
val dataFreshness = if (sourceFresh) BigDecimal("5") else BigDecimal.ZERO
|
||||
val filterPassRate = BigDecimal("5").subtract(filteredRatio.multiply(BigDecimal("5"))).clamp(BigDecimal.ZERO, BigDecimal("5"))
|
||||
val rawTotal = listOf(
|
||||
profitSignal,
|
||||
repeatability,
|
||||
liquidityFit,
|
||||
entryPriceFit,
|
||||
slippageRisk,
|
||||
holdingPeriodFit,
|
||||
marketTypeRisk,
|
||||
drawdownRisk,
|
||||
exitLiquidityRisk,
|
||||
dataFreshness,
|
||||
filterPassRate
|
||||
).fold(BigDecimal.ZERO, BigDecimal::add).setScale(8, RoundingMode.HALF_UP)
|
||||
val sampleCapApplied = tradeCount < PAPER_MIN_TRADES && rawTotal > SAMPLE_INSUFFICIENT_CAP
|
||||
val total = if (sampleCapApplied) SAMPLE_INSUFFICIENT_CAP else rawTotal
|
||||
|
||||
val reason = listOf(
|
||||
"score_v1=$total",
|
||||
"copyable_pnl=$copyablePnl",
|
||||
"trades=$tradeCount",
|
||||
"sample_cap_applied=$sampleCapApplied",
|
||||
"unknown_quote_ratio=${unknownRatio.setScale(4, RoundingMode.HALF_UP)}",
|
||||
"filtered_ratio=${filteredRatio.setScale(4, RoundingMode.HALF_UP)}",
|
||||
"source_fresh=$sourceFresh"
|
||||
).joinToString("; ")
|
||||
|
||||
return LeaderResearchScore(
|
||||
candidateId = candidate.id ?: 0,
|
||||
runId = runId,
|
||||
scoreVersion = SCORE_VERSION,
|
||||
totalScore = total,
|
||||
profitSignal = profitSignal,
|
||||
repeatability = repeatability,
|
||||
liquidityFit = liquidityFit,
|
||||
entryPriceFit = entryPriceFit,
|
||||
slippageRisk = slippageRisk,
|
||||
holdingPeriodFit = holdingPeriodFit,
|
||||
marketTypeRisk = marketTypeRisk,
|
||||
drawdownRisk = drawdownRisk,
|
||||
exitLiquidityRisk = exitLiquidityRisk,
|
||||
dataFreshness = dataFreshness,
|
||||
filterPassRate = filterPassRate,
|
||||
sampleTradeCount = tradeCount,
|
||||
reason = reason,
|
||||
createdAt = System.currentTimeMillis()
|
||||
)
|
||||
}
|
||||
|
||||
private fun buildRiskFlags(session: LeaderPaperSession?): String? {
|
||||
if (session == null) return "no_paper_session"
|
||||
val flags = mutableListOf<String>()
|
||||
if (session.maxDrawdown < BigDecimal("-15")) flags += "drawdown_gt_15"
|
||||
if (session.filteredRatio >= BigDecimal("0.50")) flags += "high_filtered_ratio"
|
||||
if (session.unknownRatio() > BigDecimal("0.20")) flags += "high_unknown_quote_exposure"
|
||||
if (session.tradeCount < 10) flags += "small_sample"
|
||||
return flags.takeIf { it.isNotEmpty() }?.joinToString(",")
|
||||
}
|
||||
|
||||
private fun LeaderPaperSession.unknownRatio(): BigDecimal {
|
||||
if (openExposure <= BigDecimal.ZERO) return BigDecimal.ZERO
|
||||
return unknownValuationExposure.safeDivide(openExposure)
|
||||
}
|
||||
|
||||
private fun BigDecimal.safeDivide(other: BigDecimal): BigDecimal {
|
||||
if (other.compareTo(BigDecimal.ZERO) == 0) return BigDecimal.ZERO
|
||||
return divide(other, 8, RoundingMode.HALF_UP)
|
||||
}
|
||||
|
||||
private fun BigDecimal.clamp(min: BigDecimal, max: BigDecimal): BigDecimal {
|
||||
return when {
|
||||
this < min -> min
|
||||
this > max -> max
|
||||
else -> this
|
||||
}
|
||||
}
|
||||
|
||||
companion object {
|
||||
const val SCORE_VERSION = "research-copyability-v1"
|
||||
private val SAMPLE_INSUFFICIENT_CAP = BigDecimal("59")
|
||||
private const val PAPER_MIN_TRADES = 10
|
||||
private const val SOURCE_FRESH_MS = 48L * 60 * 60 * 1000
|
||||
private const val PAPER_MIN_AGE_MS = 7L * 24 * 60 * 60 * 1000
|
||||
}
|
||||
}
|
||||
+115
@@ -0,0 +1,115 @@
|
||||
package com.wrbug.polymarketbot.service.copytrading.research
|
||||
|
||||
import com.wrbug.polymarketbot.dto.LeaderPaperSessionDto
|
||||
import com.wrbug.polymarketbot.dto.LeaderResearchCandidateDetailDto
|
||||
import com.wrbug.polymarketbot.dto.LeaderResearchCandidateDto
|
||||
import com.wrbug.polymarketbot.dto.LeaderResearchCandidateListRequest
|
||||
import com.wrbug.polymarketbot.dto.LeaderResearchCandidateListResponse
|
||||
import com.wrbug.polymarketbot.dto.LeaderResearchEventDto
|
||||
import com.wrbug.polymarketbot.dto.LeaderResearchSourceStateDto
|
||||
import com.wrbug.polymarketbot.dto.LeaderResearchSummaryDto
|
||||
import com.wrbug.polymarketbot.enums.LeaderResearchState
|
||||
import com.wrbug.polymarketbot.repository.LeaderPaperPositionRepository
|
||||
import com.wrbug.polymarketbot.repository.LeaderPaperSessionRepository
|
||||
import com.wrbug.polymarketbot.repository.LeaderPaperTradeRepository
|
||||
import com.wrbug.polymarketbot.repository.LeaderPoolRepository
|
||||
import com.wrbug.polymarketbot.repository.LeaderRepository
|
||||
import com.wrbug.polymarketbot.repository.LeaderResearchCandidateRepository
|
||||
import com.wrbug.polymarketbot.repository.LeaderResearchEventRepository
|
||||
import com.wrbug.polymarketbot.repository.LeaderResearchRunRepository
|
||||
import com.wrbug.polymarketbot.repository.LeaderResearchScoreRepository
|
||||
import com.wrbug.polymarketbot.repository.LeaderResearchSourceStateRepository
|
||||
import org.springframework.data.domain.PageRequest
|
||||
import org.springframework.stereotype.Service
|
||||
|
||||
@Service
|
||||
class LeaderResearchService(
|
||||
private val candidateRepository: LeaderResearchCandidateRepository,
|
||||
private val runRepository: LeaderResearchRunRepository,
|
||||
private val scoreRepository: LeaderResearchScoreRepository,
|
||||
private val sourceStateRepository: LeaderResearchSourceStateRepository,
|
||||
private val eventRepository: LeaderResearchEventRepository,
|
||||
private val paperSessionRepository: LeaderPaperSessionRepository,
|
||||
private val paperTradeRepository: LeaderPaperTradeRepository,
|
||||
private val paperPositionRepository: LeaderPaperPositionRepository,
|
||||
private val leaderRepository: LeaderRepository,
|
||||
private val leaderPoolRepository: LeaderPoolRepository,
|
||||
private val mapper: LeaderResearchMapper
|
||||
) {
|
||||
fun summary(): LeaderResearchSummaryDto {
|
||||
return LeaderResearchSummaryDto(
|
||||
discoveredCount = candidateRepository.countByResearchState(LeaderResearchState.DISCOVERED),
|
||||
candidateCount = candidateRepository.countByResearchState(LeaderResearchState.CANDIDATE),
|
||||
paperCount = candidateRepository.countByResearchState(LeaderResearchState.PAPER),
|
||||
trialReadyCount = candidateRepository.countByResearchState(LeaderResearchState.TRIAL_READY),
|
||||
cooldownCount = candidateRepository.countByResearchState(LeaderResearchState.COOLDOWN),
|
||||
retiredCount = candidateRepository.countByResearchState(LeaderResearchState.RETIRED),
|
||||
activePaperSessions = candidateRepository.findByResearchStateIn(listOf(LeaderResearchState.PAPER, LeaderResearchState.TRIAL_READY)).count().toLong(),
|
||||
pendingRiskCount = candidateRepository.findByResearchStateIn(listOf(LeaderResearchState.COOLDOWN)).count().toLong(),
|
||||
lastRun = runRepository.findTopByOrderByStartedAtDesc()?.let { mapper.runDto(it) },
|
||||
sourceLimitations = mapper.sourceLimitations()
|
||||
)
|
||||
}
|
||||
|
||||
fun listCandidates(request: LeaderResearchCandidateListRequest): LeaderResearchCandidateListResponse {
|
||||
val pageable = PageRequest.of(request.page.coerceAtLeast(0), request.size.coerceIn(1, 100))
|
||||
val state = request.state?.trim()?.takeIf { it.isNotBlank() }?.let { LeaderResearchState.valueOf(it.uppercase()) }
|
||||
val query = request.query?.trim()?.lowercase()?.takeIf { it.isNotBlank() }
|
||||
val page = candidateRepository.search(state, query, pageable)
|
||||
val content = page.content
|
||||
return LeaderResearchCandidateListResponse(
|
||||
list = mapper.candidateDtos(content, listContext(content)),
|
||||
total = page.totalElements,
|
||||
summary = summary()
|
||||
)
|
||||
}
|
||||
|
||||
private fun listContext(candidates: List<com.wrbug.polymarketbot.entity.LeaderResearchCandidate>): LeaderResearchCandidateDtoContext {
|
||||
if (candidates.isEmpty()) return LeaderResearchCandidateDtoContext()
|
||||
val leaderIds = candidates.mapNotNull { it.leaderId }.distinct()
|
||||
val poolIds = candidates.mapNotNull { it.poolId }.distinct()
|
||||
val candidateIds = candidates.mapNotNull { it.id }.distinct()
|
||||
return LeaderResearchCandidateDtoContext(
|
||||
leadersById = if (leaderIds.isEmpty()) emptyMap() else leaderRepository.findByIdIn(leaderIds)
|
||||
.mapNotNull { leader -> leader.id?.let { it to leader } }
|
||||
.toMap(),
|
||||
poolsById = if (poolIds.isEmpty()) emptyMap() else leaderPoolRepository.findByIdIn(poolIds)
|
||||
.mapNotNull { pool -> pool.id?.let { it to pool } }
|
||||
.toMap(),
|
||||
latestSessionsByCandidateId = if (candidateIds.isEmpty()) emptyMap() else paperSessionRepository.findLatestByCandidateIds(candidateIds)
|
||||
.associateBy { it.candidateId }
|
||||
)
|
||||
}
|
||||
|
||||
fun detail(candidateId: Long): LeaderResearchCandidateDetailDto {
|
||||
val candidate = candidateRepository.findById(candidateId).orElseThrow { IllegalArgumentException("候选不存在") }
|
||||
val sessions = paperSessionRepository.findByCandidateIdOrderByStartedAtDesc(candidateId)
|
||||
val latestSession = sessions.firstOrNull()
|
||||
val trades = latestSession?.id?.let {
|
||||
paperTradeRepository.findBySessionIdOrderByEventTimeDesc(it, PageRequest.of(0, 100)).content
|
||||
}.orEmpty()
|
||||
val positions = latestSession?.id?.let { paperPositionRepository.findBySessionIdOrderByUpdatedAtDesc(it) }.orEmpty()
|
||||
return LeaderResearchCandidateDetailDto(
|
||||
candidate = mapper.candidateDto(candidate, latestSession),
|
||||
latestScore = scoreRepository.findTopByCandidateIdOrderByCreatedAtDesc(candidateId)?.let { mapper.scoreDto(it) },
|
||||
paperSessions = sessions.map { mapper.paperSessionDto(it) },
|
||||
paperTrades = trades.map { mapper.paperTradeDto(it) },
|
||||
paperPositions = positions.map { mapper.paperPositionDto(it) },
|
||||
events = eventRepository.findByCandidateIdOrderByCreatedAtDesc(candidateId, PageRequest.of(0, 100)).content.map { mapper.eventDto(it) }
|
||||
)
|
||||
}
|
||||
|
||||
fun sourceHealth(): List<LeaderResearchSourceStateDto> {
|
||||
return sourceStateRepository.findAllByOrderByUpdatedAtDesc().map { mapper.sourceStateDto(it) }
|
||||
}
|
||||
|
||||
fun events(page: Int, size: Int): List<LeaderResearchEventDto> {
|
||||
return eventRepository.findAllByOrderByCreatedAtDesc(PageRequest.of(page.coerceAtLeast(0), size.coerceIn(1, 100)))
|
||||
.content
|
||||
.map { mapper.eventDto(it) }
|
||||
}
|
||||
|
||||
fun paperSessions(candidateId: Long): List<LeaderPaperSessionDto> {
|
||||
return paperSessionRepository.findByCandidateIdOrderByStartedAtDesc(candidateId).map { mapper.paperSessionDto(it) }
|
||||
}
|
||||
}
|
||||
+64
@@ -0,0 +1,64 @@
|
||||
package com.wrbug.polymarketbot.service.copytrading.research
|
||||
|
||||
import com.wrbug.polymarketbot.entity.LeaderResearchSourceState
|
||||
import com.wrbug.polymarketbot.enums.LeaderResearchSourceStatus
|
||||
import com.wrbug.polymarketbot.enums.LeaderResearchSourceType
|
||||
import com.wrbug.polymarketbot.repository.LeaderResearchSourceStateRepository
|
||||
import org.springframework.stereotype.Service
|
||||
import org.springframework.transaction.annotation.Transactional
|
||||
|
||||
@Service
|
||||
class LeaderResearchSourceHealthService(
|
||||
private val sourceStateRepository: LeaderResearchSourceStateRepository
|
||||
) {
|
||||
@Transactional
|
||||
fun record(
|
||||
sourceType: LeaderResearchSourceType,
|
||||
status: LeaderResearchSourceStatus,
|
||||
candidateCount: Int = 0,
|
||||
errorClass: String? = null,
|
||||
errorMessage: String? = null,
|
||||
disabledReason: String? = null,
|
||||
stale: Boolean = false,
|
||||
lastCursor: String? = null,
|
||||
now: Long = System.currentTimeMillis()
|
||||
): LeaderResearchSourceState {
|
||||
val existing = sourceStateRepository.findBySourceType(sourceType)
|
||||
val failedLike = status == LeaderResearchSourceStatus.FAILURE ||
|
||||
status == LeaderResearchSourceStatus.DEGRADED ||
|
||||
status == LeaderResearchSourceStatus.STALE
|
||||
val nextDisabledReason = when {
|
||||
disabledReason != null -> disabledReason
|
||||
status == LeaderResearchSourceStatus.SUCCESS -> null
|
||||
else -> existing?.disabledReason
|
||||
}
|
||||
val state = existing?.copy(
|
||||
status = status,
|
||||
lastSuccessAt = if (status == LeaderResearchSourceStatus.SUCCESS) now else existing.lastSuccessAt,
|
||||
lastFailureAt = if (failedLike) now else existing.lastFailureAt,
|
||||
lastRunAt = now,
|
||||
lastCandidateCount = candidateCount,
|
||||
errorClass = errorClass,
|
||||
errorMessage = errorMessage,
|
||||
stale = stale || status == LeaderResearchSourceStatus.STALE,
|
||||
disabledReason = nextDisabledReason,
|
||||
lastCursor = lastCursor ?: existing.lastCursor,
|
||||
updatedAt = now
|
||||
) ?: LeaderResearchSourceState(
|
||||
sourceType = sourceType,
|
||||
status = status,
|
||||
lastSuccessAt = if (status == LeaderResearchSourceStatus.SUCCESS) now else null,
|
||||
lastFailureAt = if (failedLike) now else null,
|
||||
lastRunAt = now,
|
||||
lastCandidateCount = candidateCount,
|
||||
errorClass = errorClass,
|
||||
errorMessage = errorMessage,
|
||||
stale = stale || status == LeaderResearchSourceStatus.STALE,
|
||||
disabledReason = nextDisabledReason,
|
||||
lastCursor = lastCursor,
|
||||
createdAt = now,
|
||||
updatedAt = now
|
||||
)
|
||||
return sourceStateRepository.save(state)
|
||||
}
|
||||
}
|
||||
+508
@@ -0,0 +1,508 @@
|
||||
package com.wrbug.polymarketbot.service.copytrading.research
|
||||
|
||||
import com.wrbug.polymarketbot.entity.Leader
|
||||
import com.wrbug.polymarketbot.entity.LeaderResearchCandidate
|
||||
import com.wrbug.polymarketbot.enums.LeaderCandidateProvenance
|
||||
import com.wrbug.polymarketbot.enums.LeaderResearchEventType
|
||||
import com.wrbug.polymarketbot.enums.LeaderResearchSourceStatus
|
||||
import com.wrbug.polymarketbot.enums.LeaderResearchSourceType
|
||||
import com.wrbug.polymarketbot.enums.LeaderResearchState
|
||||
import com.wrbug.polymarketbot.repository.LeaderActivityEventRepository
|
||||
import com.wrbug.polymarketbot.repository.LeaderPoolRepository
|
||||
import com.wrbug.polymarketbot.repository.LeaderRepository
|
||||
import com.wrbug.polymarketbot.repository.LeaderResearchCandidateRepository
|
||||
import com.wrbug.polymarketbot.repository.SystemConfigRepository
|
||||
import com.wrbug.polymarketbot.util.RetrofitFactory
|
||||
import kotlinx.coroutines.runBlocking
|
||||
import org.slf4j.LoggerFactory
|
||||
import org.springframework.beans.factory.annotation.Value
|
||||
import org.springframework.stereotype.Service
|
||||
import org.springframework.transaction.annotation.Transactional
|
||||
|
||||
data class LeaderResearchSourceRunResult(
|
||||
val sourceType: LeaderResearchSourceType,
|
||||
val candidates: List<LeaderResearchCandidate>,
|
||||
val status: LeaderResearchSourceStatus,
|
||||
val errorClass: String? = null,
|
||||
val errorMessage: String? = null,
|
||||
val limitation: String? = null
|
||||
)
|
||||
|
||||
private data class SourceDiscovery(
|
||||
val candidates: List<LeaderResearchCandidate>,
|
||||
val status: LeaderResearchSourceStatus = LeaderResearchSourceStatus.SUCCESS,
|
||||
val errorClass: String? = null,
|
||||
val errorMessage: String? = null,
|
||||
val limitation: String? = null
|
||||
)
|
||||
|
||||
private data class BackfillFailure(
|
||||
val wallet: String,
|
||||
val errorClass: String,
|
||||
val errorMessage: String?
|
||||
)
|
||||
|
||||
private data class BackfillResult(
|
||||
val attemptedWallets: Int,
|
||||
val failures: List<BackfillFailure>
|
||||
) {
|
||||
val hasFailures: Boolean = failures.isNotEmpty()
|
||||
|
||||
fun status(): LeaderResearchSourceStatus =
|
||||
if (hasFailures) LeaderResearchSourceStatus.DEGRADED else LeaderResearchSourceStatus.SUCCESS
|
||||
|
||||
fun errorClass(): String? = failures.firstOrNull()?.errorClass
|
||||
|
||||
fun errorMessage(): String? {
|
||||
if (failures.isEmpty()) return null
|
||||
val sampled = failures.take(3).joinToString("; ") { "${it.wallet}: ${it.errorMessage ?: it.errorClass}" }
|
||||
val suffix = if (failures.size > 3) "; +${failures.size - 3} more" else ""
|
||||
return "Data API backfill failed for ${failures.size}/$attemptedWallets wallets: $sampled$suffix"
|
||||
}
|
||||
}
|
||||
|
||||
@Service
|
||||
class LeaderResearchSourceService(
|
||||
private val candidateRepository: LeaderResearchCandidateRepository,
|
||||
private val leaderRepository: LeaderRepository,
|
||||
private val leaderPoolRepository: LeaderPoolRepository,
|
||||
private val activityEventRepository: LeaderActivityEventRepository,
|
||||
private val sourceHealthService: LeaderResearchSourceHealthService,
|
||||
private val systemConfigRepository: SystemConfigRepository,
|
||||
private val retrofitFactory: RetrofitFactory,
|
||||
private val eventService: LeaderResearchEventService,
|
||||
private val ingestionService: LeaderActivityIngestionService,
|
||||
@Value("\${leader.research.data-api-backfill.limit:200}") private val backfillLimit: Int,
|
||||
@Value("\${leader.research.global-capture.enabled:false}") private val globalCaptureEnabled: Boolean
|
||||
) {
|
||||
private val logger = LoggerFactory.getLogger(LeaderResearchSourceService::class.java)
|
||||
|
||||
@Transactional
|
||||
fun discoverCandidates(runId: Long?): List<LeaderResearchSourceRunResult> {
|
||||
val results = mutableListOf<LeaderResearchSourceRunResult>()
|
||||
results += captureSource(LeaderResearchSourceType.WATCHLIST, runId) { discoverWatchlist(runId) }
|
||||
results += captureSource(LeaderResearchSourceType.EXISTING_LEADER, runId) { discoverExistingLeaders(runId) }
|
||||
val activityResult = captureSource(LeaderResearchSourceType.ACTIVITY_DERIVED, runId) { discoverFromPersistedActivity(runId) }
|
||||
results += if (globalCaptureEnabled) activityResult else markActivityDerivedDegraded(activityResult)
|
||||
if (!globalCaptureEnabled) {
|
||||
results += markGlobalActivityCaptureDisabled(runId)
|
||||
}
|
||||
results += markPublicLeaderboardDisabled(runId)
|
||||
return results
|
||||
}
|
||||
|
||||
fun previewCandidates(): List<LeaderResearchSourceRunResult> {
|
||||
val freshAfter = System.currentTimeMillis() - FRESH_ACTIVITY_WINDOW_MS
|
||||
val watchlist = watchlistWallets().map { transientCandidate(it, LeaderResearchSourceType.WATCHLIST) }
|
||||
val existing = leaderRepository.findAllByOrderByCreatedAtAsc().map {
|
||||
transientCandidate(it.leaderAddress, LeaderResearchSourceType.EXISTING_LEADER, it)
|
||||
}
|
||||
val activity = activityEventRepository.findByUsableForDiscoveryTrueAndEventTimeGreaterThanEqual(freshAfter)
|
||||
.mapNotNull { it.normalizedWallet }
|
||||
.distinct()
|
||||
.mapIndexed { index, wallet -> transientCandidate(wallet, LeaderResearchSourceType.ACTIVITY_DERIVED, sourceRank = index + 1) }
|
||||
val results = mutableListOf(
|
||||
LeaderResearchSourceRunResult(LeaderResearchSourceType.WATCHLIST, watchlist, LeaderResearchSourceStatus.SUCCESS),
|
||||
LeaderResearchSourceRunResult(LeaderResearchSourceType.EXISTING_LEADER, existing, LeaderResearchSourceStatus.SUCCESS),
|
||||
LeaderResearchSourceRunResult(
|
||||
LeaderResearchSourceType.ACTIVITY_DERIVED,
|
||||
activity,
|
||||
if (globalCaptureEnabled) LeaderResearchSourceStatus.SUCCESS else LeaderResearchSourceStatus.DEGRADED,
|
||||
limitation = if (globalCaptureEnabled) null else GLOBAL_CAPTURE_DISABLED_LIMITATION
|
||||
)
|
||||
)
|
||||
if (!globalCaptureEnabled) {
|
||||
results += LeaderResearchSourceRunResult(
|
||||
LeaderResearchSourceType.GLOBAL_ACTIVITY_CAPTURE,
|
||||
emptyList(),
|
||||
LeaderResearchSourceStatus.DISABLED,
|
||||
limitation = GLOBAL_CAPTURE_DISABLED_LIMITATION
|
||||
)
|
||||
}
|
||||
results += LeaderResearchSourceRunResult(
|
||||
LeaderResearchSourceType.PUBLIC_LEADERBOARD,
|
||||
emptyList(),
|
||||
LeaderResearchSourceStatus.DISABLED,
|
||||
limitation = PUBLIC_LEADERBOARD_DISABLED_LIMITATION
|
||||
)
|
||||
return results
|
||||
}
|
||||
|
||||
fun watchlistWallets(): List<String> {
|
||||
val raw = systemConfigRepository.findByConfigKey(CONFIG_WATCHLIST)?.configValue ?: return emptyList()
|
||||
return raw.split(",", "\n", ";", " ", "\t")
|
||||
.mapNotNull { ingestionService.normalizeWallet(it) }
|
||||
.distinct()
|
||||
}
|
||||
|
||||
private fun captureSource(
|
||||
sourceType: LeaderResearchSourceType,
|
||||
runId: Long?,
|
||||
block: () -> SourceDiscovery
|
||||
): LeaderResearchSourceRunResult {
|
||||
val now = System.currentTimeMillis()
|
||||
return try {
|
||||
val discovery = block()
|
||||
saveSourceState(
|
||||
sourceType = sourceType,
|
||||
status = discovery.status,
|
||||
now = now,
|
||||
candidateCount = discovery.candidates.size,
|
||||
errorClass = discovery.errorClass,
|
||||
errorMessage = discovery.errorMessage
|
||||
)
|
||||
eventService.record(
|
||||
type = if (discovery.status == LeaderResearchSourceStatus.SUCCESS) {
|
||||
LeaderResearchEventType.SOURCE_SUCCESS
|
||||
} else {
|
||||
LeaderResearchEventType.SOURCE_FAILURE
|
||||
},
|
||||
runId = runId,
|
||||
reason = if (discovery.status == LeaderResearchSourceStatus.SUCCESS) {
|
||||
"${sourceType.name} discovered ${discovery.candidates.size} candidates"
|
||||
} else {
|
||||
"${sourceType.name} degraded: ${discovery.errorMessage ?: discovery.limitation ?: discovery.status.name}"
|
||||
},
|
||||
dedupeKey = "source:${sourceType.name}:$runId:${discovery.status.name.lowercase()}"
|
||||
)
|
||||
LeaderResearchSourceRunResult(
|
||||
sourceType = sourceType,
|
||||
candidates = discovery.candidates,
|
||||
status = discovery.status,
|
||||
errorClass = discovery.errorClass,
|
||||
errorMessage = discovery.errorMessage,
|
||||
limitation = discovery.limitation
|
||||
)
|
||||
} catch (e: Exception) {
|
||||
logger.warn("Leader research source failed: source={}, error={}", sourceType, e.message, e)
|
||||
saveSourceState(
|
||||
sourceType = sourceType,
|
||||
status = LeaderResearchSourceStatus.FAILURE,
|
||||
now = now,
|
||||
candidateCount = 0,
|
||||
errorClass = e::class.java.simpleName,
|
||||
errorMessage = e.message
|
||||
)
|
||||
eventService.record(
|
||||
type = LeaderResearchEventType.SOURCE_FAILURE,
|
||||
runId = runId,
|
||||
reason = "${sourceType.name} failed: ${e.message}",
|
||||
dedupeKey = "source:${sourceType.name}:$runId:failure"
|
||||
)
|
||||
LeaderResearchSourceRunResult(sourceType, emptyList(), LeaderResearchSourceStatus.FAILURE, e::class.java.simpleName, e.message)
|
||||
}
|
||||
}
|
||||
|
||||
private fun discoverWatchlist(runId: Long?): SourceDiscovery {
|
||||
val wallets = watchlistWallets()
|
||||
val backfill = backfillWalletActivities(wallets, LeaderResearchSourceType.WATCHLIST, runId)
|
||||
val candidates = wallets.map { wallet ->
|
||||
upsertCandidate(
|
||||
wallet = wallet,
|
||||
sourceType = LeaderResearchSourceType.WATCHLIST,
|
||||
leader = leaderRepository.findByLeaderAddress(wallet),
|
||||
sourceRank = null,
|
||||
provenance = LeaderCandidateProvenance.AGENT_CREATED,
|
||||
sourceEvidence = "system_config:$CONFIG_WATCHLIST",
|
||||
runId = runId
|
||||
)
|
||||
}
|
||||
return SourceDiscovery(
|
||||
candidates = candidates,
|
||||
status = backfill.status(),
|
||||
errorClass = backfill.errorClass(),
|
||||
errorMessage = backfill.errorMessage()
|
||||
)
|
||||
}
|
||||
|
||||
private fun discoverExistingLeaders(runId: Long?): SourceDiscovery {
|
||||
val leaders = leaderRepository.findAllByOrderByCreatedAtAsc()
|
||||
val backfill = backfillWalletActivities(leaders.map { it.leaderAddress }, LeaderResearchSourceType.EXISTING_LEADER, runId)
|
||||
val candidates = leaders.map { leader ->
|
||||
val pool = leader.id?.let { leaderPoolRepository.findByLeaderId(it) }
|
||||
upsertCandidate(
|
||||
wallet = leader.leaderAddress,
|
||||
sourceType = LeaderResearchSourceType.EXISTING_LEADER,
|
||||
leader = leader,
|
||||
poolId = pool?.id,
|
||||
sourceRank = null,
|
||||
provenance = if (pool == null) LeaderCandidateProvenance.USER_LEADER else LeaderCandidateProvenance.USER_POOL,
|
||||
sourceEvidence = "existing_leader:${leader.id}",
|
||||
runId = runId
|
||||
)
|
||||
}
|
||||
return SourceDiscovery(
|
||||
candidates = candidates,
|
||||
status = backfill.status(),
|
||||
errorClass = backfill.errorClass(),
|
||||
errorMessage = backfill.errorMessage()
|
||||
)
|
||||
}
|
||||
|
||||
private fun discoverFromPersistedActivity(runId: Long?): SourceDiscovery {
|
||||
val backfill = backfillWalletActivities(activeResearchWallets(), LeaderResearchSourceType.ACTIVITY_DERIVED, runId)
|
||||
val freshAfter = System.currentTimeMillis() - FRESH_ACTIVITY_WINDOW_MS
|
||||
val events = activityEventRepository.findByUsableForDiscoveryTrueAndEventTimeGreaterThanEqual(freshAfter)
|
||||
val wallets = events.mapNotNull { it.normalizedWallet }.distinct()
|
||||
val candidates = wallets.mapIndexed { index, wallet ->
|
||||
upsertCandidate(
|
||||
wallet = wallet,
|
||||
sourceType = LeaderResearchSourceType.ACTIVITY_DERIVED,
|
||||
leader = leaderRepository.findByLeaderAddress(wallet),
|
||||
sourceRank = index + 1,
|
||||
provenance = LeaderCandidateProvenance.AGENT_CREATED,
|
||||
sourceEvidence = "leader_activity_event:fresh_count=${events.count { it.normalizedWallet == wallet }}",
|
||||
runId = runId
|
||||
)
|
||||
}
|
||||
return SourceDiscovery(
|
||||
candidates = candidates,
|
||||
status = backfill.status(),
|
||||
errorClass = backfill.errorClass(),
|
||||
errorMessage = backfill.errorMessage()
|
||||
)
|
||||
}
|
||||
|
||||
private fun activeResearchWallets(): List<String> {
|
||||
return candidateRepository.findByResearchStateIn(
|
||||
listOf(LeaderResearchState.DISCOVERED, LeaderResearchState.CANDIDATE, LeaderResearchState.PAPER, LeaderResearchState.TRIAL_READY)
|
||||
).map { it.normalizedWallet }.distinct()
|
||||
}
|
||||
|
||||
private fun backfillWalletActivities(wallets: List<String>, sourceType: LeaderResearchSourceType, runId: Long?): BackfillResult {
|
||||
val normalizedWallets = wallets.mapNotNull { ingestionService.normalizeWallet(it) }.distinct()
|
||||
if (normalizedWallets.isEmpty()) return BackfillResult(0, emptyList())
|
||||
val dataApi = retrofitFactory.createDataApi()
|
||||
val startSeconds = (System.currentTimeMillis() - FRESH_ACTIVITY_WINDOW_MS) / 1000
|
||||
val endSeconds = System.currentTimeMillis() / 1000
|
||||
val sampledWallets = normalizedWallets.take(MAX_BACKFILL_WALLETS_PER_RUN)
|
||||
val failures = mutableListOf<BackfillFailure>()
|
||||
sampledWallets.forEach { wallet ->
|
||||
try {
|
||||
val response = runBlocking {
|
||||
dataApi.getUserActivity(
|
||||
user = wallet,
|
||||
type = listOf("TRADE"),
|
||||
start = startSeconds,
|
||||
end = endSeconds,
|
||||
limit = backfillLimit.coerceIn(1, 500),
|
||||
offset = null,
|
||||
sortBy = "TIMESTAMP",
|
||||
sortDirection = "ASC"
|
||||
)
|
||||
}
|
||||
if (!response.isSuccessful || response.body() == null) {
|
||||
throw IllegalStateException("Data API backfill failed: ${response.code()} ${response.message()}")
|
||||
}
|
||||
response.body().orEmpty().forEach { activity ->
|
||||
ingestionService.ingestUserActivity(activity, sourceType)
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
failures += BackfillFailure(wallet, e::class.java.simpleName, e.message)
|
||||
eventService.record(
|
||||
type = LeaderResearchEventType.SOURCE_FAILURE,
|
||||
runId = runId,
|
||||
reason = "Data API backfill failed for $wallet: ${e.message}",
|
||||
payloadSummary = sourceType.name,
|
||||
dedupeKey = "data-api-backfill:${sourceType.name}:$wallet:${System.currentTimeMillis() / 3600000}"
|
||||
)
|
||||
logger.warn("Research Data API backfill failed: source={}, wallet={}, error={}", sourceType, wallet, e.message)
|
||||
}
|
||||
}
|
||||
return BackfillResult(sampledWallets.size, failures)
|
||||
}
|
||||
|
||||
private fun upsertCandidate(
|
||||
wallet: String,
|
||||
sourceType: LeaderResearchSourceType,
|
||||
leader: Leader?,
|
||||
poolId: Long? = null,
|
||||
sourceRank: Int?,
|
||||
provenance: LeaderCandidateProvenance,
|
||||
sourceEvidence: String,
|
||||
runId: Long?
|
||||
): LeaderResearchCandidate {
|
||||
val normalized = ingestionService.normalizeWallet(wallet)
|
||||
?: throw IllegalArgumentException("Invalid wallet for research candidate: $wallet")
|
||||
val now = System.currentTimeMillis()
|
||||
val existing = candidateRepository.findByNormalizedWallet(normalized)
|
||||
val saved = if (existing == null) {
|
||||
candidateRepository.save(
|
||||
LeaderResearchCandidate(
|
||||
normalizedWallet = normalized,
|
||||
leaderId = leader?.id,
|
||||
poolId = poolId,
|
||||
researchState = LeaderResearchState.DISCOVERED,
|
||||
source = sourceType.name,
|
||||
sourceRank = sourceRank,
|
||||
agentOwned = provenance == LeaderCandidateProvenance.AGENT_CREATED,
|
||||
provenance = provenance,
|
||||
sourceEvidence = sourceEvidence,
|
||||
firstSeenAt = now,
|
||||
lastSourceSeenAt = now,
|
||||
lastTransitionAt = now,
|
||||
createdAt = now,
|
||||
updatedAt = now
|
||||
)
|
||||
)
|
||||
} else {
|
||||
val shouldPreserveHuman = existing.locked || existing.provenance == LeaderCandidateProvenance.MANUAL_LOCKED
|
||||
candidateRepository.save(
|
||||
existing.copy(
|
||||
leaderId = existing.leaderId ?: leader?.id,
|
||||
poolId = existing.poolId ?: poolId,
|
||||
source = if (shouldPreserveHuman) existing.source else mergeSource(existing.source, sourceType.name),
|
||||
sourceRank = existing.sourceRank ?: sourceRank,
|
||||
provenance = if (shouldPreserveHuman) existing.provenance else strongestProvenance(existing.provenance, provenance),
|
||||
sourceEvidence = appendEvidence(existing.sourceEvidence, sourceEvidence),
|
||||
lastSourceSeenAt = now,
|
||||
updatedAt = now
|
||||
)
|
||||
)
|
||||
}
|
||||
eventService.record(
|
||||
type = if (existing == null) LeaderResearchEventType.CANDIDATE_DISCOVERED else LeaderResearchEventType.CANDIDATE_UPDATED,
|
||||
candidateId = saved.id,
|
||||
runId = runId,
|
||||
reason = "Candidate seen from ${sourceType.name}",
|
||||
payloadSummary = sourceEvidence,
|
||||
dedupeKey = "candidate:${saved.normalizedWallet}:${sourceType.name}:$runId"
|
||||
)
|
||||
return saved
|
||||
}
|
||||
|
||||
private fun saveSourceState(
|
||||
sourceType: LeaderResearchSourceType,
|
||||
status: LeaderResearchSourceStatus,
|
||||
now: Long,
|
||||
candidateCount: Int,
|
||||
errorClass: String? = null,
|
||||
errorMessage: String? = null,
|
||||
disabledReason: String? = null,
|
||||
stale: Boolean = false
|
||||
) {
|
||||
sourceHealthService.record(
|
||||
sourceType = sourceType,
|
||||
status = status,
|
||||
now = now,
|
||||
candidateCount = candidateCount,
|
||||
errorClass = errorClass,
|
||||
errorMessage = errorMessage,
|
||||
disabledReason = disabledReason,
|
||||
stale = stale
|
||||
)
|
||||
}
|
||||
|
||||
private fun markActivityDerivedDegraded(result: LeaderResearchSourceRunResult): LeaderResearchSourceRunResult {
|
||||
saveSourceState(
|
||||
sourceType = LeaderResearchSourceType.ACTIVITY_DERIVED,
|
||||
status = LeaderResearchSourceStatus.DEGRADED,
|
||||
now = System.currentTimeMillis(),
|
||||
candidateCount = result.candidates.size,
|
||||
errorClass = result.errorClass,
|
||||
errorMessage = result.errorMessage,
|
||||
disabledReason = GLOBAL_CAPTURE_DISABLED_LIMITATION,
|
||||
stale = false
|
||||
)
|
||||
return result.copy(
|
||||
status = LeaderResearchSourceStatus.DEGRADED,
|
||||
limitation = GLOBAL_CAPTURE_DISABLED_LIMITATION
|
||||
)
|
||||
}
|
||||
|
||||
private fun markPublicLeaderboardDisabled(runId: Long?): LeaderResearchSourceRunResult {
|
||||
saveSourceState(
|
||||
sourceType = LeaderResearchSourceType.PUBLIC_LEADERBOARD,
|
||||
status = LeaderResearchSourceStatus.DISABLED,
|
||||
now = System.currentTimeMillis(),
|
||||
candidateCount = 0,
|
||||
disabledReason = PUBLIC_LEADERBOARD_DISABLED_LIMITATION,
|
||||
stale = false
|
||||
)
|
||||
eventService.record(
|
||||
type = LeaderResearchEventType.SOURCE_DISABLED,
|
||||
runId = runId,
|
||||
reason = PUBLIC_LEADERBOARD_DISABLED_LIMITATION,
|
||||
dedupeKey = "source:${LeaderResearchSourceType.PUBLIC_LEADERBOARD.name}:disabled"
|
||||
)
|
||||
return LeaderResearchSourceRunResult(
|
||||
sourceType = LeaderResearchSourceType.PUBLIC_LEADERBOARD,
|
||||
candidates = emptyList(),
|
||||
status = LeaderResearchSourceStatus.DISABLED,
|
||||
limitation = PUBLIC_LEADERBOARD_DISABLED_LIMITATION
|
||||
)
|
||||
}
|
||||
|
||||
private fun markGlobalActivityCaptureDisabled(runId: Long?): LeaderResearchSourceRunResult {
|
||||
saveSourceState(
|
||||
sourceType = LeaderResearchSourceType.GLOBAL_ACTIVITY_CAPTURE,
|
||||
status = LeaderResearchSourceStatus.DISABLED,
|
||||
now = System.currentTimeMillis(),
|
||||
candidateCount = 0,
|
||||
disabledReason = GLOBAL_CAPTURE_DISABLED_LIMITATION,
|
||||
stale = false
|
||||
)
|
||||
eventService.record(
|
||||
type = LeaderResearchEventType.SOURCE_DISABLED,
|
||||
runId = runId,
|
||||
reason = GLOBAL_CAPTURE_DISABLED_LIMITATION,
|
||||
dedupeKey = "source:${LeaderResearchSourceType.GLOBAL_ACTIVITY_CAPTURE.name}:disabled"
|
||||
)
|
||||
return LeaderResearchSourceRunResult(
|
||||
sourceType = LeaderResearchSourceType.GLOBAL_ACTIVITY_CAPTURE,
|
||||
candidates = emptyList(),
|
||||
status = LeaderResearchSourceStatus.DISABLED,
|
||||
limitation = GLOBAL_CAPTURE_DISABLED_LIMITATION
|
||||
)
|
||||
}
|
||||
|
||||
private fun transientCandidate(
|
||||
wallet: String,
|
||||
sourceType: LeaderResearchSourceType,
|
||||
leader: Leader? = leaderRepository.findByLeaderAddress(wallet),
|
||||
sourceRank: Int? = null
|
||||
): LeaderResearchCandidate {
|
||||
val normalized = ingestionService.normalizeWallet(wallet)
|
||||
?: throw IllegalArgumentException("Invalid wallet for research preview candidate: $wallet")
|
||||
return LeaderResearchCandidate(
|
||||
normalizedWallet = normalized,
|
||||
leaderId = leader?.id,
|
||||
source = sourceType.name,
|
||||
sourceRank = sourceRank,
|
||||
provenance = if (leader == null) LeaderCandidateProvenance.AGENT_CREATED else LeaderCandidateProvenance.USER_LEADER,
|
||||
sourceEvidence = "preview:${sourceType.name}",
|
||||
firstSeenAt = System.currentTimeMillis(),
|
||||
lastSourceSeenAt = System.currentTimeMillis()
|
||||
)
|
||||
}
|
||||
|
||||
private fun mergeSource(existing: String, incoming: String): String {
|
||||
val sources = (existing.split(",") + incoming).map { it.trim() }.filter { it.isNotBlank() }.distinct()
|
||||
return sources.joinToString(",")
|
||||
}
|
||||
|
||||
private fun strongestProvenance(current: LeaderCandidateProvenance, incoming: LeaderCandidateProvenance): LeaderCandidateProvenance {
|
||||
val rank = mapOf(
|
||||
LeaderCandidateProvenance.MANUAL_LOCKED to 4,
|
||||
LeaderCandidateProvenance.USER_POOL to 3,
|
||||
LeaderCandidateProvenance.USER_LEADER to 2,
|
||||
LeaderCandidateProvenance.AGENT_CREATED to 1
|
||||
)
|
||||
return if ((rank[incoming] ?: 0) > (rank[current] ?: 0)) incoming else current
|
||||
}
|
||||
|
||||
private fun appendEvidence(existing: String?, incoming: String): String {
|
||||
val lines = (existing?.lines().orEmpty() + incoming).map { it.trim() }.filter { it.isNotBlank() }.distinct()
|
||||
return lines.takeLast(10).joinToString("\n")
|
||||
}
|
||||
|
||||
companion object {
|
||||
const val CONFIG_WATCHLIST = "leader_research.watchlist"
|
||||
const val FRESH_ACTIVITY_WINDOW_MS = 48L * 60 * 60 * 1000
|
||||
const val MAX_BACKFILL_WALLETS_PER_RUN = 50
|
||||
private const val GLOBAL_CAPTURE_DISABLED_LIMITATION =
|
||||
"Global activity capture is disabled; activity-derived discovery only uses already persisted research events."
|
||||
private const val PUBLIC_LEADERBOARD_DISABLED_LIMITATION =
|
||||
"Public leaderboard source is intentionally disabled in v1; discovery uses watchlist, existing leaders, and persisted activity only."
|
||||
}
|
||||
}
|
||||
+154
@@ -0,0 +1,154 @@
|
||||
package com.wrbug.polymarketbot.service.copytrading.research
|
||||
|
||||
import com.wrbug.polymarketbot.entity.LeaderPaperSession
|
||||
import com.wrbug.polymarketbot.entity.LeaderResearchCandidate
|
||||
import com.wrbug.polymarketbot.enums.LeaderResearchEventType
|
||||
import com.wrbug.polymarketbot.enums.LeaderResearchState
|
||||
import com.wrbug.polymarketbot.repository.LeaderPaperSessionRepository
|
||||
import com.wrbug.polymarketbot.repository.LeaderResearchCandidateRepository
|
||||
import org.springframework.stereotype.Service
|
||||
import org.springframework.transaction.annotation.Transactional
|
||||
import java.math.BigDecimal
|
||||
|
||||
@Service
|
||||
class LeaderResearchStateMachine(
|
||||
private val candidateRepository: LeaderResearchCandidateRepository,
|
||||
private val paperSessionRepository: LeaderPaperSessionRepository,
|
||||
private val paperTradingService: LeaderPaperTradingService,
|
||||
private val poolMappingService: LeaderResearchPoolMappingService,
|
||||
private val eventService: LeaderResearchEventService
|
||||
) {
|
||||
@Transactional
|
||||
fun advanceAll(runId: Long?): List<LeaderResearchCandidate> {
|
||||
return candidateRepository.findByResearchStateIn(
|
||||
listOf(
|
||||
LeaderResearchState.DISCOVERED,
|
||||
LeaderResearchState.CANDIDATE,
|
||||
LeaderResearchState.PAPER,
|
||||
LeaderResearchState.TRIAL_READY,
|
||||
LeaderResearchState.COOLDOWN
|
||||
)
|
||||
).map { advance(it, runId) }
|
||||
}
|
||||
|
||||
@Transactional
|
||||
fun advance(candidate: LeaderResearchCandidate, runId: Long?): LeaderResearchCandidate {
|
||||
if (candidate.locked) return candidate
|
||||
val now = System.currentTimeMillis()
|
||||
val latestSession = candidate.id?.let { paperSessionRepository.findTopByCandidateIdOrderByStartedAtDesc(it) }
|
||||
val sourceFresh48h = candidate.lastSourceSeenAt?.let { now - it <= SOURCE_FRESH_48H_MS } == true
|
||||
val sourceFresh72h = candidate.lastSourceSeenAt?.let { now - it <= SOURCE_STALE_72H_MS } == true
|
||||
val score = candidate.score ?: BigDecimal.ZERO
|
||||
|
||||
val nextState = when (candidate.researchState) {
|
||||
LeaderResearchState.DISCOVERED -> {
|
||||
if (sourceFresh48h && (score >= BigDecimal("60") || canBootstrapPaperObservation(candidate))) {
|
||||
LeaderResearchState.CANDIDATE
|
||||
} else {
|
||||
candidate.researchState
|
||||
}
|
||||
}
|
||||
LeaderResearchState.CANDIDATE -> {
|
||||
if (sourceFresh48h && (score >= BigDecimal("60") || latestSession == null && canBootstrapPaperObservation(candidate))) {
|
||||
LeaderResearchState.PAPER
|
||||
} else {
|
||||
candidate.researchState
|
||||
}
|
||||
}
|
||||
LeaderResearchState.PAPER -> {
|
||||
cooldownReason(latestSession, sourceFresh72h)?.let {
|
||||
return transition(candidate, LeaderResearchState.COOLDOWN, runId, it)
|
||||
}
|
||||
if (latestSession != null && paperTradingService.isEligibleForTrialReady(latestSession, now)) {
|
||||
LeaderResearchState.TRIAL_READY
|
||||
} else {
|
||||
candidate.researchState
|
||||
}
|
||||
}
|
||||
LeaderResearchState.TRIAL_READY -> {
|
||||
cooldownReason(latestSession, sourceFresh72h)?.let {
|
||||
return transition(candidate, LeaderResearchState.COOLDOWN, runId, it)
|
||||
}
|
||||
candidate.researchState
|
||||
}
|
||||
LeaderResearchState.COOLDOWN -> {
|
||||
val cooldownElapsed = candidate.cooldownUntil?.let { now >= it } ?: true
|
||||
when {
|
||||
candidate.cooldownCount >= 3 || candidate.lastSourceSeenAt?.let { now - it > SOURCE_RETIRE_30D_MS } == true -> LeaderResearchState.RETIRED
|
||||
cooldownElapsed && sourceFresh48h -> LeaderResearchState.CANDIDATE
|
||||
else -> candidate.researchState
|
||||
}
|
||||
}
|
||||
LeaderResearchState.RETIRED -> candidate.researchState
|
||||
}
|
||||
|
||||
val saved = if (nextState != candidate.researchState) {
|
||||
transition(candidate, nextState, runId, "state criteria satisfied")
|
||||
} else {
|
||||
candidate
|
||||
}
|
||||
val withSession = if (saved.researchState == LeaderResearchState.PAPER && latestSession == null) {
|
||||
val session = paperTradingService.ensureSession(saved, runId)
|
||||
candidateRepository.save(saved.copy(lastPaperSessionId = session.id, updatedAt = now))
|
||||
} else {
|
||||
saved
|
||||
}
|
||||
return if (withSession.researchState.canSyncToLeaderPool()) {
|
||||
poolMappingService.syncCandidate(withSession)
|
||||
} else {
|
||||
withSession
|
||||
}
|
||||
}
|
||||
|
||||
private fun LeaderResearchState.canSyncToLeaderPool(): Boolean {
|
||||
return this != LeaderResearchState.DISCOVERED
|
||||
}
|
||||
|
||||
private fun cooldownReason(session: LeaderPaperSession?, sourceFresh72h: Boolean): String? {
|
||||
if (session == null) return null
|
||||
return paperTradingService.shouldEnterCooldown(session, sourceFresh72h)
|
||||
}
|
||||
|
||||
private fun canBootstrapPaperObservation(candidate: LeaderResearchCandidate): Boolean {
|
||||
return candidate.agentOwned || candidate.leaderId != null || candidate.poolId != null
|
||||
}
|
||||
|
||||
private fun transition(
|
||||
candidate: LeaderResearchCandidate,
|
||||
nextState: LeaderResearchState,
|
||||
runId: Long?,
|
||||
reason: String
|
||||
): LeaderResearchCandidate {
|
||||
val now = System.currentTimeMillis()
|
||||
val updated = candidate.copy(
|
||||
researchState = nextState,
|
||||
cooldownUntil = if (nextState == LeaderResearchState.COOLDOWN) now + COOLDOWN_MS else candidate.cooldownUntil,
|
||||
cooldownCount = if (nextState == LeaderResearchState.COOLDOWN) candidate.cooldownCount + 1 else candidate.cooldownCount,
|
||||
trialReadyAt = if (nextState == LeaderResearchState.TRIAL_READY) now else candidate.trialReadyAt,
|
||||
retiredAt = if (nextState == LeaderResearchState.RETIRED) now else candidate.retiredAt,
|
||||
lastTransitionAt = now,
|
||||
updatedAt = now
|
||||
)
|
||||
val saved = candidateRepository.save(updated)
|
||||
eventService.record(
|
||||
type = when (nextState) {
|
||||
LeaderResearchState.TRIAL_READY -> LeaderResearchEventType.TRIAL_READY
|
||||
LeaderResearchState.COOLDOWN -> LeaderResearchEventType.COOLDOWN
|
||||
LeaderResearchState.RETIRED -> LeaderResearchEventType.RETIRED
|
||||
else -> LeaderResearchEventType.STATE_TRANSITION
|
||||
},
|
||||
candidateId = saved.id,
|
||||
runId = runId,
|
||||
reason = "${candidate.researchState.name} -> ${nextState.name}: $reason",
|
||||
dedupeKey = "state:${candidate.id}:${nextState.name}:${now / 60000}"
|
||||
)
|
||||
return saved
|
||||
}
|
||||
|
||||
companion object {
|
||||
private const val SOURCE_FRESH_48H_MS = 48L * 60 * 60 * 1000
|
||||
private const val SOURCE_STALE_72H_MS = 72L * 60 * 60 * 1000
|
||||
private const val SOURCE_RETIRE_30D_MS = 30L * 24 * 60 * 60 * 1000
|
||||
private const val COOLDOWN_MS = 3L * 24 * 60 * 60 * 1000
|
||||
}
|
||||
}
|
||||
@@ -54,6 +54,22 @@ copy.trading.polling.enabled=${COPY_TRADING_POLLING_ENABLED:true}
|
||||
# 链上 WebSocket 重连延迟(毫秒),默认3秒
|
||||
copy.trading.onchain.ws.reconnect.delay=${COPY_TRADING_ONCHAIN_WS_RECONNECT_DELAY:3000}
|
||||
|
||||
# Leader Research Agent 配置
|
||||
# 定时研究任务默认关闭;手动运行可在受保护的 Leader 研究页面触发
|
||||
leader.research.enabled=${LEADER_RESEARCH_ENABLED:false}
|
||||
leader.research.fixed-delay-ms=${LEADER_RESEARCH_FIXED_DELAY_MS:900000}
|
||||
# 全局 activity capture 默认关闭;关闭时只使用 watchlist、已有 Leader 和已持久化 research events
|
||||
leader.research.global-capture.enabled=${LEADER_RESEARCH_GLOBAL_CAPTURE_ENABLED:false}
|
||||
leader.research.global-capture.max-writes-per-minute=${LEADER_RESEARCH_GLOBAL_CAPTURE_MAX_WRITES_PER_MINUTE:120}
|
||||
# Data API bounded backfill 单钱包拉取上限
|
||||
leader.research.data-api-backfill.limit=${LEADER_RESEARCH_DATA_API_BACKFILL_LIMIT:200}
|
||||
# Research 数据保留策略,避免 activity/paper 历史无限增长
|
||||
leader.research.retention.enabled=${LEADER_RESEARCH_RETENTION_ENABLED:true}
|
||||
leader.research.retention.activity-days=${LEADER_RESEARCH_RETENTION_ACTIVITY_DAYS:90}
|
||||
leader.research.retention.paper-session-days=${LEADER_RESEARCH_RETENTION_PAPER_SESSION_DAYS:180}
|
||||
leader.research.retention.max-paper-sessions-per-run=${LEADER_RESEARCH_RETENTION_MAX_PAPER_SESSIONS_PER_RUN:100}
|
||||
leader.research.retention.cron=${LEADER_RESEARCH_RETENTION_CRON:0 17 3 * * *}
|
||||
|
||||
# WebSocket 配置
|
||||
websocket.heartbeat-timeout=${WEBSOCKET_HEARTBEAT_TIMEOUT:60000}
|
||||
|
||||
@@ -81,4 +97,3 @@ rate-limit.reset-password.window-seconds=60
|
||||
github.repo.owner=WrBug
|
||||
github.repo.name=PolyHermes
|
||||
github.announcement.issue.number=1
|
||||
|
||||
|
||||
@@ -0,0 +1,262 @@
|
||||
-- ============================================
|
||||
-- V42: Leader Research Agent
|
||||
-- ============================================
|
||||
|
||||
CREATE TABLE IF NOT EXISTS leader_research_run (
|
||||
id BIGINT AUTO_INCREMENT PRIMARY KEY COMMENT 'Research run ID',
|
||||
status VARCHAR(30) NOT NULL COMMENT 'RUNNING/SUCCESS/PARTIAL_FAILURE/FAILED/SKIPPED',
|
||||
trigger_type VARCHAR(30) NOT NULL DEFAULT 'MANUAL' COMMENT 'MANUAL/SCHEDULED/PREVIEW',
|
||||
dry_run TINYINT(1) NOT NULL DEFAULT 0 COMMENT '是否预览运行',
|
||||
started_at BIGINT NOT NULL COMMENT '开始时间',
|
||||
finished_at BIGINT DEFAULT NULL COMMENT '结束时间',
|
||||
duration_ms BIGINT DEFAULT NULL COMMENT '耗时毫秒',
|
||||
source_counts_json TEXT DEFAULT NULL COMMENT '来源统计 JSON',
|
||||
candidate_counts_json TEXT DEFAULT NULL COMMENT '候选统计 JSON',
|
||||
error_class VARCHAR(255) DEFAULT NULL COMMENT '错误类型',
|
||||
error_message TEXT DEFAULT NULL COMMENT '错误信息',
|
||||
partial_failure TINYINT(1) NOT NULL DEFAULT 0 COMMENT '是否部分失败',
|
||||
skipped_reason VARCHAR(255) DEFAULT NULL COMMENT '跳过原因',
|
||||
last_event_cursor VARCHAR(255) DEFAULT NULL COMMENT '事件处理游标',
|
||||
created_at BIGINT NOT NULL COMMENT '创建时间',
|
||||
updated_at BIGINT NOT NULL COMMENT '更新时间',
|
||||
INDEX idx_leader_research_run_status_started (status, started_at),
|
||||
INDEX idx_leader_research_run_started (started_at)
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='Leader research run records';
|
||||
|
||||
CREATE TABLE IF NOT EXISTS leader_research_candidate (
|
||||
id BIGINT AUTO_INCREMENT PRIMARY KEY COMMENT 'Research candidate ID',
|
||||
normalized_wallet VARCHAR(42) NOT NULL COMMENT '小写钱包地址',
|
||||
leader_id BIGINT DEFAULT NULL COMMENT '关联 Leader ID',
|
||||
pool_id BIGINT DEFAULT NULL COMMENT '关联 Leader Pool ID',
|
||||
research_state VARCHAR(30) NOT NULL DEFAULT 'DISCOVERED' COMMENT '研究状态',
|
||||
source VARCHAR(50) NOT NULL DEFAULT 'UNKNOWN' COMMENT '主来源',
|
||||
source_rank INT DEFAULT NULL COMMENT '来源排名',
|
||||
score DECIMAL(20, 8) DEFAULT NULL COMMENT '当前总分',
|
||||
score_version VARCHAR(100) DEFAULT NULL COMMENT '评分版本',
|
||||
reason TEXT DEFAULT NULL COMMENT '推荐原因',
|
||||
risk_flags TEXT DEFAULT NULL COMMENT '风险标记,逗号或 JSON',
|
||||
locked TINYINT(1) NOT NULL DEFAULT 0 COMMENT '是否锁定',
|
||||
agent_owned TINYINT(1) NOT NULL DEFAULT 1 COMMENT '是否由 agent 创建/管理',
|
||||
provenance VARCHAR(50) NOT NULL DEFAULT 'AGENT_CREATED' COMMENT '来源归属',
|
||||
source_evidence TEXT DEFAULT NULL COMMENT '来源证据摘要 JSON',
|
||||
first_seen_at BIGINT NOT NULL COMMENT '首次发现时间',
|
||||
last_source_seen_at BIGINT DEFAULT NULL COMMENT '最后来源新鲜时间',
|
||||
last_scored_at BIGINT DEFAULT NULL COMMENT '最后评分时间',
|
||||
cooldown_until BIGINT DEFAULT NULL COMMENT '冷却截止时间',
|
||||
cooldown_count INT NOT NULL DEFAULT 0 COMMENT '冷却次数',
|
||||
last_transition_at BIGINT DEFAULT NULL COMMENT '最后状态迁移时间',
|
||||
trial_ready_at BIGINT DEFAULT NULL COMMENT '进入试跟建议时间',
|
||||
retired_at BIGINT DEFAULT NULL COMMENT '退休时间',
|
||||
last_paper_session_id BIGINT DEFAULT NULL COMMENT '最后纸跟 session',
|
||||
created_at BIGINT NOT NULL COMMENT '创建时间',
|
||||
updated_at BIGINT NOT NULL COMMENT '更新时间',
|
||||
UNIQUE KEY uk_leader_research_candidate_wallet (normalized_wallet),
|
||||
INDEX idx_leader_research_candidate_state_seen (research_state, last_source_seen_at),
|
||||
INDEX idx_leader_research_candidate_leader (leader_id),
|
||||
INDEX idx_leader_research_candidate_pool (pool_id),
|
||||
INDEX idx_leader_research_candidate_score (score),
|
||||
CONSTRAINT fk_leader_research_candidate_leader FOREIGN KEY (leader_id) REFERENCES copy_trading_leaders(id) ON DELETE SET NULL,
|
||||
CONSTRAINT fk_leader_research_candidate_pool FOREIGN KEY (pool_id) REFERENCES copy_trading_leader_pool(id) ON DELETE SET NULL
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='Leader research candidates';
|
||||
|
||||
CREATE TABLE IF NOT EXISTS leader_research_score (
|
||||
id BIGINT AUTO_INCREMENT PRIMARY KEY COMMENT 'Research score ID',
|
||||
candidate_id BIGINT NOT NULL COMMENT '候选 ID',
|
||||
run_id BIGINT DEFAULT NULL COMMENT '运行 ID',
|
||||
score_version VARCHAR(100) NOT NULL COMMENT '评分版本',
|
||||
total_score DECIMAL(20, 8) NOT NULL DEFAULT 0 COMMENT '总分',
|
||||
profit_signal DECIMAL(20, 8) NOT NULL DEFAULT 0,
|
||||
repeatability DECIMAL(20, 8) NOT NULL DEFAULT 0,
|
||||
liquidity_fit DECIMAL(20, 8) NOT NULL DEFAULT 0,
|
||||
entry_price_fit DECIMAL(20, 8) NOT NULL DEFAULT 0,
|
||||
slippage_risk DECIMAL(20, 8) NOT NULL DEFAULT 0,
|
||||
holding_period_fit DECIMAL(20, 8) NOT NULL DEFAULT 0,
|
||||
market_type_risk DECIMAL(20, 8) NOT NULL DEFAULT 0,
|
||||
drawdown_risk DECIMAL(20, 8) NOT NULL DEFAULT 0,
|
||||
exit_liquidity_risk DECIMAL(20, 8) NOT NULL DEFAULT 0,
|
||||
data_freshness DECIMAL(20, 8) NOT NULL DEFAULT 0,
|
||||
filter_pass_rate DECIMAL(20, 8) NOT NULL DEFAULT 0,
|
||||
sample_trade_count INT NOT NULL DEFAULT 0 COMMENT '样本交易数',
|
||||
reason TEXT DEFAULT NULL COMMENT '评分解释',
|
||||
created_at BIGINT NOT NULL COMMENT '创建时间',
|
||||
INDEX idx_leader_research_score_candidate_created (candidate_id, created_at),
|
||||
INDEX idx_leader_research_score_run (run_id),
|
||||
CONSTRAINT fk_leader_research_score_candidate FOREIGN KEY (candidate_id) REFERENCES leader_research_candidate(id) ON DELETE CASCADE,
|
||||
CONSTRAINT fk_leader_research_score_run FOREIGN KEY (run_id) REFERENCES leader_research_run(id) ON DELETE SET NULL
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='Leader research score history';
|
||||
|
||||
CREATE TABLE IF NOT EXISTS leader_research_event (
|
||||
id BIGINT AUTO_INCREMENT PRIMARY KEY COMMENT 'Research event ID',
|
||||
candidate_id BIGINT DEFAULT NULL COMMENT '候选 ID',
|
||||
run_id BIGINT DEFAULT NULL COMMENT '运行 ID',
|
||||
event_type VARCHAR(50) NOT NULL COMMENT '事件类型',
|
||||
reason TEXT DEFAULT NULL COMMENT '原因',
|
||||
payload_summary TEXT DEFAULT NULL COMMENT 'payload 摘要',
|
||||
notification_status VARCHAR(30) NOT NULL DEFAULT 'PENDING' COMMENT '通知状态',
|
||||
notification_error TEXT DEFAULT NULL COMMENT '通知错误',
|
||||
dedupe_key VARCHAR(255) DEFAULT NULL COMMENT '事件去重 key',
|
||||
created_at BIGINT NOT NULL COMMENT '创建时间',
|
||||
notified_at BIGINT DEFAULT NULL COMMENT '通知时间',
|
||||
UNIQUE KEY uk_leader_research_event_dedupe (dedupe_key),
|
||||
INDEX idx_leader_research_event_candidate_created (candidate_id, created_at),
|
||||
INDEX idx_leader_research_event_run (run_id),
|
||||
INDEX idx_leader_research_event_type_created (event_type, created_at),
|
||||
INDEX idx_leader_research_event_notification (notification_status, created_at),
|
||||
CONSTRAINT fk_leader_research_event_candidate FOREIGN KEY (candidate_id) REFERENCES leader_research_candidate(id) ON DELETE SET NULL,
|
||||
CONSTRAINT fk_leader_research_event_run FOREIGN KEY (run_id) REFERENCES leader_research_run(id) ON DELETE SET NULL
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='Leader research events';
|
||||
|
||||
CREATE TABLE IF NOT EXISTS leader_research_source_state (
|
||||
id BIGINT AUTO_INCREMENT PRIMARY KEY COMMENT 'Source state ID',
|
||||
source_type VARCHAR(50) NOT NULL COMMENT '来源类型',
|
||||
status VARCHAR(30) NOT NULL DEFAULT 'DISABLED' COMMENT 'SUCCESS/FAILURE/STALE/DISABLED/DEGRADED',
|
||||
last_success_at BIGINT DEFAULT NULL,
|
||||
last_failure_at BIGINT DEFAULT NULL,
|
||||
last_run_at BIGINT DEFAULT NULL,
|
||||
last_candidate_count INT NOT NULL DEFAULT 0,
|
||||
error_class VARCHAR(255) DEFAULT NULL,
|
||||
error_message TEXT DEFAULT NULL,
|
||||
stale TINYINT(1) NOT NULL DEFAULT 0,
|
||||
disabled_reason VARCHAR(255) DEFAULT NULL,
|
||||
last_cursor VARCHAR(255) DEFAULT NULL,
|
||||
created_at BIGINT NOT NULL,
|
||||
updated_at BIGINT NOT NULL,
|
||||
UNIQUE KEY uk_leader_research_source_state_type (source_type),
|
||||
INDEX idx_leader_research_source_state_status (status),
|
||||
INDEX idx_leader_research_source_state_updated (updated_at)
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='Leader research source health';
|
||||
|
||||
CREATE TABLE IF NOT EXISTS leader_activity_event (
|
||||
id BIGINT AUTO_INCREMENT PRIMARY KEY COMMENT 'Activity event ID',
|
||||
source VARCHAR(50) NOT NULL COMMENT '来源',
|
||||
source_event_id VARCHAR(255) DEFAULT NULL COMMENT '来源事件 ID',
|
||||
stable_event_key VARCHAR(255) NOT NULL COMMENT '稳定去重 key',
|
||||
normalized_wallet VARCHAR(42) DEFAULT NULL COMMENT '小写钱包地址',
|
||||
market_id VARCHAR(100) DEFAULT NULL COMMENT 'conditionId',
|
||||
market_title VARCHAR(500) DEFAULT NULL,
|
||||
market_slug VARCHAR(255) DEFAULT NULL,
|
||||
asset VARCHAR(120) DEFAULT NULL COMMENT 'token id',
|
||||
side VARCHAR(20) DEFAULT NULL COMMENT 'BUY/SELL',
|
||||
outcome VARCHAR(100) DEFAULT NULL,
|
||||
outcome_index INT DEFAULT NULL,
|
||||
price DECIMAL(20, 8) DEFAULT NULL,
|
||||
size DECIMAL(20, 8) DEFAULT NULL,
|
||||
amount DECIMAL(20, 8) DEFAULT NULL,
|
||||
event_time BIGINT NOT NULL COMMENT '事件时间',
|
||||
raw_payload_hash VARCHAR(128) NOT NULL,
|
||||
payload_summary TEXT DEFAULT NULL,
|
||||
usable_for_discovery TINYINT(1) NOT NULL DEFAULT 0,
|
||||
usable_for_paper TINYINT(1) NOT NULL DEFAULT 0,
|
||||
unusable_reason VARCHAR(255) DEFAULT NULL,
|
||||
paper_processing_status VARCHAR(30) NOT NULL DEFAULT 'NEW',
|
||||
processing_attempts INT NOT NULL DEFAULT 0,
|
||||
paper_processing_started_at BIGINT DEFAULT NULL,
|
||||
paper_processed_at BIGINT DEFAULT NULL,
|
||||
last_processing_error TEXT DEFAULT NULL,
|
||||
created_at BIGINT NOT NULL,
|
||||
updated_at BIGINT NOT NULL,
|
||||
UNIQUE KEY uk_leader_activity_event_stable_key (stable_event_key),
|
||||
UNIQUE KEY uk_leader_activity_event_source_event (source, source_event_id),
|
||||
INDEX idx_leader_activity_event_processing (paper_processing_status, event_time),
|
||||
INDEX idx_leader_activity_event_wallet_time (normalized_wallet, event_time),
|
||||
INDEX idx_leader_activity_event_source_time (source, event_time),
|
||||
INDEX idx_leader_activity_event_usable (usable_for_discovery, event_time)
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='Append-only leader activity events';
|
||||
|
||||
CREATE TABLE IF NOT EXISTS leader_paper_session (
|
||||
id BIGINT AUTO_INCREMENT PRIMARY KEY COMMENT 'Paper session ID',
|
||||
candidate_id BIGINT NOT NULL COMMENT '候选 ID',
|
||||
status VARCHAR(30) NOT NULL DEFAULT 'ACTIVE',
|
||||
started_at BIGINT NOT NULL,
|
||||
ended_at BIGINT DEFAULT NULL,
|
||||
trade_count INT NOT NULL DEFAULT 0,
|
||||
filtered_count INT NOT NULL DEFAULT 0,
|
||||
open_exposure DECIMAL(20, 8) NOT NULL DEFAULT 0,
|
||||
total_realized_pnl DECIMAL(20, 8) NOT NULL DEFAULT 0,
|
||||
total_unrealized_pnl DECIMAL(20, 8) NOT NULL DEFAULT 0,
|
||||
copyable_pnl DECIMAL(20, 8) NOT NULL DEFAULT 0,
|
||||
max_drawdown DECIMAL(20, 8) NOT NULL DEFAULT 0,
|
||||
unknown_valuation_exposure DECIMAL(20, 8) NOT NULL DEFAULT 0,
|
||||
confirmed_zero_exposure DECIMAL(20, 8) NOT NULL DEFAULT 0,
|
||||
filtered_ratio DECIMAL(20, 8) NOT NULL DEFAULT 0,
|
||||
last_processed_event_time BIGINT DEFAULT NULL,
|
||||
score_snapshot DECIMAL(20, 8) DEFAULT NULL,
|
||||
created_at BIGINT NOT NULL,
|
||||
updated_at BIGINT NOT NULL,
|
||||
INDEX idx_leader_paper_session_candidate_status (candidate_id, status),
|
||||
INDEX idx_leader_paper_session_status_started (status, started_at),
|
||||
CONSTRAINT fk_leader_paper_session_candidate FOREIGN KEY (candidate_id) REFERENCES leader_research_candidate(id) ON DELETE CASCADE
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='Leader paper trading sessions';
|
||||
|
||||
CREATE TABLE IF NOT EXISTS leader_paper_trade (
|
||||
id BIGINT AUTO_INCREMENT PRIMARY KEY COMMENT 'Paper trade ID',
|
||||
session_id BIGINT NOT NULL,
|
||||
candidate_id BIGINT NOT NULL,
|
||||
activity_event_id BIGINT DEFAULT NULL,
|
||||
leader_trade_id VARCHAR(255) NOT NULL,
|
||||
market_id VARCHAR(100) NOT NULL,
|
||||
market_title VARCHAR(500) DEFAULT NULL,
|
||||
market_slug VARCHAR(255) DEFAULT NULL,
|
||||
side VARCHAR(20) NOT NULL,
|
||||
outcome VARCHAR(100) DEFAULT NULL,
|
||||
outcome_index INT DEFAULT NULL,
|
||||
leader_price DECIMAL(20, 8) DEFAULT NULL,
|
||||
leader_size DECIMAL(20, 8) DEFAULT NULL,
|
||||
simulated_price DECIMAL(20, 8) DEFAULT NULL,
|
||||
simulated_size DECIMAL(20, 8) DEFAULT NULL,
|
||||
simulated_amount DECIMAL(20, 8) DEFAULT NULL,
|
||||
fill_assumption VARCHAR(30) NOT NULL DEFAULT 'LEADER_PRICE',
|
||||
quote_confidence VARCHAR(30) NOT NULL DEFAULT 'UNKNOWN',
|
||||
quote_source VARCHAR(50) DEFAULT NULL,
|
||||
quote_timestamp BIGINT DEFAULT NULL,
|
||||
filter_result VARCHAR(30) NOT NULL DEFAULT 'PASSED',
|
||||
filter_reason TEXT DEFAULT NULL,
|
||||
valuation_status VARCHAR(30) NOT NULL DEFAULT 'UNKNOWN',
|
||||
realized_pnl DECIMAL(20, 8) DEFAULT NULL,
|
||||
event_time BIGINT NOT NULL,
|
||||
created_at BIGINT NOT NULL,
|
||||
UNIQUE KEY uk_leader_paper_trade_session_trade (session_id, leader_trade_id, side),
|
||||
INDEX idx_leader_paper_trade_session_time (session_id, event_time),
|
||||
INDEX idx_leader_paper_trade_candidate_time (candidate_id, event_time),
|
||||
INDEX idx_leader_paper_trade_activity (activity_event_id),
|
||||
CONSTRAINT fk_leader_paper_trade_session FOREIGN KEY (session_id) REFERENCES leader_paper_session(id) ON DELETE CASCADE,
|
||||
CONSTRAINT fk_leader_paper_trade_candidate FOREIGN KEY (candidate_id) REFERENCES leader_research_candidate(id) ON DELETE CASCADE,
|
||||
CONSTRAINT fk_leader_paper_trade_activity FOREIGN KEY (activity_event_id) REFERENCES leader_activity_event(id) ON DELETE SET NULL
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='Leader paper trades';
|
||||
|
||||
CREATE TABLE IF NOT EXISTS leader_paper_position (
|
||||
id BIGINT AUTO_INCREMENT PRIMARY KEY COMMENT 'Paper position ID',
|
||||
session_id BIGINT NOT NULL,
|
||||
candidate_id BIGINT NOT NULL,
|
||||
market_id VARCHAR(100) NOT NULL,
|
||||
outcome VARCHAR(100) DEFAULT NULL,
|
||||
outcome_index INT DEFAULT NULL,
|
||||
quantity DECIMAL(20, 8) NOT NULL DEFAULT 0,
|
||||
cost DECIMAL(20, 8) NOT NULL DEFAULT 0,
|
||||
avg_price DECIMAL(20, 8) NOT NULL DEFAULT 0,
|
||||
current_price DECIMAL(20, 8) DEFAULT NULL,
|
||||
current_value DECIMAL(20, 8) NOT NULL DEFAULT 0,
|
||||
realized_pnl DECIMAL(20, 8) NOT NULL DEFAULT 0,
|
||||
unrealized_pnl DECIMAL(20, 8) NOT NULL DEFAULT 0,
|
||||
valuation_status VARCHAR(30) NOT NULL DEFAULT 'UNKNOWN',
|
||||
quote_confidence VARCHAR(30) NOT NULL DEFAULT 'UNKNOWN',
|
||||
quote_source VARCHAR(50) DEFAULT NULL,
|
||||
quote_timestamp BIGINT DEFAULT NULL,
|
||||
created_at BIGINT NOT NULL,
|
||||
updated_at BIGINT NOT NULL,
|
||||
UNIQUE KEY uk_leader_paper_position_session_market_outcome (session_id, market_id, outcome_index),
|
||||
INDEX idx_leader_paper_position_candidate (candidate_id),
|
||||
INDEX idx_leader_paper_position_status (valuation_status),
|
||||
CONSTRAINT fk_leader_paper_position_session FOREIGN KEY (session_id) REFERENCES leader_paper_session(id) ON DELETE CASCADE,
|
||||
CONSTRAINT fk_leader_paper_position_candidate FOREIGN KEY (candidate_id) REFERENCES leader_research_candidate(id) ON DELETE CASCADE
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='Leader paper positions';
|
||||
|
||||
ALTER TABLE copy_trading_leader_pool
|
||||
ADD COLUMN research_candidate_id BIGINT DEFAULT NULL COMMENT '关联 research candidate ID',
|
||||
ADD COLUMN research_state VARCHAR(30) DEFAULT NULL COMMENT 'Research 状态',
|
||||
ADD COLUMN research_badge VARCHAR(50) DEFAULT NULL COMMENT 'Research 推荐 badge',
|
||||
ADD COLUMN research_summary TEXT DEFAULT NULL COMMENT 'Research 摘要',
|
||||
ADD COLUMN research_score DECIMAL(20, 8) DEFAULT NULL COMMENT 'Research 最新评分',
|
||||
ADD COLUMN research_updated_at BIGINT DEFAULT NULL COMMENT 'Research 更新时间',
|
||||
ADD INDEX idx_leader_pool_research_candidate (research_candidate_id),
|
||||
ADD INDEX idx_leader_pool_research_state (research_state);
|
||||
@@ -155,6 +155,14 @@ error.leader_pool_not_found=Leader pool item not found
|
||||
error.leader_pool_already_exists=Leader is already in the pool
|
||||
error.leader_pool_duplicate_trial_config=This account already has a copy trading config for this Leader
|
||||
error.leader_pool_confirm_required=Immediate trial enablement requires explicit confirmation
|
||||
error.leader_research_candidate_not_found=Research candidate not found
|
||||
error.leader_research_candidate_not_ready=Research candidate is not trial-ready
|
||||
error.leader_research_approval_confirm_required=Creating a disabled trial config requires explicit confirmation
|
||||
error.leader_research_duplicate_trial_config=This account already has a copy trading config for this Leader
|
||||
error.leader_research_real_money_forbidden=The research agent cannot auto-enable real-money copy trading
|
||||
error.leader_research_candidate_locked=Research candidate is locked
|
||||
error.leader_research_source_unavailable=Research source is unavailable
|
||||
error.leader_research_paper_valuation_unavailable=Paper valuation is unavailable
|
||||
|
||||
# Order related
|
||||
error.order_create_failed=Failed to create order
|
||||
@@ -254,6 +262,9 @@ error.server.copy_trading_templates_fetch_failed=Failed to query templates bound
|
||||
error.server.leader_pool_list_fetch_failed=Failed to fetch Leader pool
|
||||
error.server.leader_pool_save_failed=Failed to save Leader pool
|
||||
error.server.leader_pool_create_trial_failed=Failed to create Leader pool trial config
|
||||
error.server.leader_research_run_failed=Failed to run Leader Research Agent
|
||||
error.server.leader_research_fetch_failed=Failed to fetch Leader Research data
|
||||
error.server.leader_research_approval_failed=Failed to create disabled trial config
|
||||
|
||||
# Market service errors
|
||||
error.server.market_price_fetch_failed=Failed to fetch market price
|
||||
|
||||
@@ -155,6 +155,14 @@ error.leader_pool_not_found=Leader 池项不存在
|
||||
error.leader_pool_already_exists=Leader 已在池子中
|
||||
error.leader_pool_duplicate_trial_config=该账户已存在此 Leader 的跟单配置
|
||||
error.leader_pool_confirm_required=立即启用试跟配置需要显式确认
|
||||
error.leader_research_candidate_not_found=研究候选不存在
|
||||
error.leader_research_candidate_not_ready=研究候选尚未进入试跟建议状态
|
||||
error.leader_research_approval_confirm_required=创建禁用试跟配置需要显式确认
|
||||
error.leader_research_duplicate_trial_config=该账户已存在此 Leader 的跟单配置
|
||||
error.leader_research_real_money_forbidden=研究 Agent 不允许自动启用真钱跟单
|
||||
error.leader_research_candidate_locked=研究候选已锁定
|
||||
error.leader_research_source_unavailable=研究来源不可用
|
||||
error.leader_research_paper_valuation_unavailable=纸跟估值不可用
|
||||
|
||||
# 订单相关
|
||||
error.order_create_failed=创建订单失败
|
||||
@@ -254,6 +262,9 @@ error.server.copy_trading_templates_fetch_failed=查询钱包绑定的模板失
|
||||
error.server.leader_pool_list_fetch_failed=查询 Leader 池失败
|
||||
error.server.leader_pool_save_failed=保存 Leader 池失败
|
||||
error.server.leader_pool_create_trial_failed=创建 Leader 池试跟配置失败
|
||||
error.server.leader_research_run_failed=运行 Leader Research Agent 失败
|
||||
error.server.leader_research_fetch_failed=查询 Leader Research 数据失败
|
||||
error.server.leader_research_approval_failed=创建禁用试跟配置失败
|
||||
|
||||
# 市场服务错误
|
||||
error.server.market_price_fetch_failed=获取市场价格失败
|
||||
|
||||
@@ -155,6 +155,14 @@ error.leader_pool_not_found=Leader 池項不存在
|
||||
error.leader_pool_already_exists=Leader 已在池子中
|
||||
error.leader_pool_duplicate_trial_config=該帳戶已存在此 Leader 的跟單配置
|
||||
error.leader_pool_confirm_required=立即啟用試跟配置需要明確確認
|
||||
error.leader_research_candidate_not_found=研究候選不存在
|
||||
error.leader_research_candidate_not_ready=研究候選尚未進入試跟建議狀態
|
||||
error.leader_research_approval_confirm_required=建立停用試跟配置需要明確確認
|
||||
error.leader_research_duplicate_trial_config=該帳戶已存在此 Leader 的跟單配置
|
||||
error.leader_research_real_money_forbidden=研究 Agent 不允許自動啟用真錢跟單
|
||||
error.leader_research_candidate_locked=研究候選已鎖定
|
||||
error.leader_research_source_unavailable=研究來源不可用
|
||||
error.leader_research_paper_valuation_unavailable=紙跟估值不可用
|
||||
|
||||
# 訂單相關
|
||||
error.order_create_failed=創建訂單失敗
|
||||
@@ -254,6 +262,9 @@ error.server.copy_trading_templates_fetch_failed=查詢錢包綁定的模板失
|
||||
error.server.leader_pool_list_fetch_failed=查詢 Leader 池失敗
|
||||
error.server.leader_pool_save_failed=保存 Leader 池失敗
|
||||
error.server.leader_pool_create_trial_failed=創建 Leader 池試跟配置失敗
|
||||
error.server.leader_research_run_failed=運行 Leader Research Agent 失敗
|
||||
error.server.leader_research_fetch_failed=查詢 Leader Research 資料失敗
|
||||
error.server.leader_research_approval_failed=建立停用試跟配置失敗
|
||||
|
||||
# 市場服務錯誤
|
||||
error.server.market_price_fetch_failed=獲取市場價格失敗
|
||||
|
||||
+6
@@ -162,6 +162,12 @@ class LeaderPoolControllerTest {
|
||||
lastPromotedAt = null,
|
||||
cooldownUntil = null,
|
||||
locked = false,
|
||||
researchCandidateId = null,
|
||||
researchState = null,
|
||||
researchBadge = null,
|
||||
researchSummary = null,
|
||||
researchScore = null,
|
||||
researchUpdatedAt = null,
|
||||
createdAt = 1,
|
||||
updatedAt = 1
|
||||
)
|
||||
|
||||
+93
@@ -0,0 +1,93 @@
|
||||
package com.wrbug.polymarketbot.controller.copytrading.research
|
||||
|
||||
import com.wrbug.polymarketbot.dto.LeaderResearchApprovalRequest
|
||||
import com.wrbug.polymarketbot.dto.LeaderResearchRunRequest
|
||||
import com.wrbug.polymarketbot.entity.LeaderResearchRun
|
||||
import com.wrbug.polymarketbot.enums.ErrorCode
|
||||
import com.wrbug.polymarketbot.service.copytrading.research.LeaderResearchApprovalConfirmRequiredException
|
||||
import com.wrbug.polymarketbot.service.copytrading.research.LeaderResearchApprovalService
|
||||
import com.wrbug.polymarketbot.service.copytrading.research.LeaderResearchCandidateLockedException
|
||||
import com.wrbug.polymarketbot.service.copytrading.research.LeaderResearchJobService
|
||||
import com.wrbug.polymarketbot.service.copytrading.research.LeaderResearchMapper
|
||||
import com.wrbug.polymarketbot.service.copytrading.research.LeaderResearchService
|
||||
import org.junit.jupiter.api.Assertions.assertEquals
|
||||
import org.junit.jupiter.api.Test
|
||||
import org.mockito.Mockito
|
||||
import org.springframework.context.support.StaticMessageSource
|
||||
|
||||
class LeaderResearchControllerTest {
|
||||
private val jobService: LeaderResearchJobService = mock()
|
||||
private val researchService: LeaderResearchService = mock()
|
||||
private val approvalService: LeaderResearchApprovalService = mock()
|
||||
private val mapper: LeaderResearchMapper = mock()
|
||||
private val controller = LeaderResearchController(
|
||||
jobService = jobService,
|
||||
researchService = researchService,
|
||||
approvalService = approvalService,
|
||||
mapper = mapper,
|
||||
messageSource = StaticMessageSource()
|
||||
)
|
||||
|
||||
@Test
|
||||
fun `run returns run dto`() {
|
||||
val run = LeaderResearchRun(id = 1L)
|
||||
Mockito.`when`(jobService.runOnce(false, com.wrbug.polymarketbot.enums.LeaderResearchTriggerType.MANUAL)).thenReturn(run)
|
||||
Mockito.`when`(mapper.runDto(run)).thenReturn(
|
||||
com.wrbug.polymarketbot.dto.LeaderResearchRunDto(
|
||||
id = 1,
|
||||
status = "RUNNING",
|
||||
triggerType = "MANUAL",
|
||||
dryRun = false,
|
||||
startedAt = run.startedAt,
|
||||
finishedAt = null,
|
||||
durationMs = null,
|
||||
sourceCountsJson = null,
|
||||
candidateCountsJson = null,
|
||||
partialFailure = false,
|
||||
skippedReason = null,
|
||||
errorClass = null,
|
||||
errorMessage = null
|
||||
)
|
||||
)
|
||||
|
||||
val response = controller.run(LeaderResearchRunRequest())
|
||||
|
||||
assertEquals(0, response.body!!.code)
|
||||
assertEquals(1, response.body!!.data!!.id)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `detail rejects invalid candidate id`() {
|
||||
val response = controller.detail(LeaderResearchDetailRequest(candidateId = 0))
|
||||
|
||||
assertEquals(ErrorCode.PARAM_INVALID.code, response.body!!.code)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `approval maps confirm required`() {
|
||||
Mockito.`when`(approvalService.createDisabledTrialConfig(anyApprovalRequest()))
|
||||
.thenReturn(Result.failure(LeaderResearchApprovalConfirmRequiredException()))
|
||||
|
||||
val response = controller.approve(LeaderResearchApprovalRequest(candidateId = 1, accountId = 2, confirm = false))
|
||||
|
||||
assertEquals(ErrorCode.LEADER_RESEARCH_APPROVAL_CONFIRM_REQUIRED.code, response.body!!.code)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `approval maps locked candidate`() {
|
||||
Mockito.`when`(approvalService.createDisabledTrialConfig(anyApprovalRequest()))
|
||||
.thenReturn(Result.failure(LeaderResearchCandidateLockedException()))
|
||||
|
||||
val response = controller.approve(LeaderResearchApprovalRequest(candidateId = 1, accountId = 2, confirm = true))
|
||||
|
||||
assertEquals(ErrorCode.LEADER_RESEARCH_CANDIDATE_LOCKED.code, response.body!!.code)
|
||||
}
|
||||
|
||||
@Suppress("UNCHECKED_CAST")
|
||||
private inline fun <reified T> mock(): T = Mockito.mock(T::class.java)
|
||||
|
||||
private fun anyApprovalRequest(): LeaderResearchApprovalRequest {
|
||||
Mockito.any(LeaderResearchApprovalRequest::class.java)
|
||||
return LeaderResearchApprovalRequest(candidateId = 1, accountId = 2, confirm = true)
|
||||
}
|
||||
}
|
||||
+123
@@ -0,0 +1,123 @@
|
||||
package com.wrbug.polymarketbot.service.copytrading.monitor
|
||||
|
||||
import com.google.gson.Gson
|
||||
import com.wrbug.polymarketbot.entity.LeaderActivityEvent
|
||||
import com.wrbug.polymarketbot.enums.LeaderResearchSourceStatus
|
||||
import com.wrbug.polymarketbot.enums.LeaderResearchSourceType
|
||||
import com.wrbug.polymarketbot.repository.LeaderActivityEventRepository
|
||||
import com.wrbug.polymarketbot.repository.LeaderRepository
|
||||
import com.wrbug.polymarketbot.service.copytrading.research.LeaderActivityIngestionService
|
||||
import com.wrbug.polymarketbot.service.copytrading.research.LeaderResearchSourceHealthService
|
||||
import com.wrbug.polymarketbot.service.copytrading.statistics.CopyOrderTrackingService
|
||||
import org.junit.jupiter.api.Assertions.assertEquals
|
||||
import org.junit.jupiter.api.Assertions.assertTrue
|
||||
import org.junit.jupiter.api.Test
|
||||
import org.mockito.Mockito
|
||||
import org.springframework.beans.factory.ObjectProvider
|
||||
|
||||
class PolymarketActivityWsResearchCaptureTest {
|
||||
private val copyOrderTrackingService: CopyOrderTrackingService = mock()
|
||||
private val leaderRepository: LeaderRepository = mock()
|
||||
private val activityEventRepository: LeaderActivityEventRepository = mock()
|
||||
private val ingestionService = LeaderActivityIngestionService(activityEventRepository, Gson())
|
||||
private val healthService: LeaderResearchSourceHealthService = mock()
|
||||
|
||||
@Test
|
||||
fun `disabled global capture records disabled source health without parsing message`() {
|
||||
val service = service(globalCaptureEnabled = false)
|
||||
|
||||
invokeHandleMessage(service, "not-json")
|
||||
|
||||
val invocation = Mockito.mockingDetails(healthService).invocations.single()
|
||||
assertEquals(LeaderResearchSourceType.GLOBAL_ACTIVITY_CAPTURE, invocation.arguments[0])
|
||||
assertEquals(LeaderResearchSourceStatus.DISABLED, invocation.arguments[1])
|
||||
assertEquals("Global activity capture is disabled", invocation.arguments[5])
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `write cap records degraded source health`() {
|
||||
val service = service(globalCaptureEnabled = true, maxWritesPerMinute = 0)
|
||||
|
||||
invokeHandleMessage(service, activityMessage("tx-capped"))
|
||||
|
||||
val invocation = Mockito.mockingDetails(healthService).invocations.single()
|
||||
assertEquals(LeaderResearchSourceStatus.DEGRADED, invocation.arguments[1])
|
||||
assertEquals("WriteCapReached", invocation.arguments[3])
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `parse failure records failure source health`() {
|
||||
val service = service(globalCaptureEnabled = true)
|
||||
|
||||
invokeHandleMessage(service, "not-json")
|
||||
|
||||
val invocation = Mockito.mockingDetails(healthService).invocations.single()
|
||||
assertEquals(LeaderResearchSourceStatus.FAILURE, invocation.arguments[1])
|
||||
assertEquals("JsonParseFailure", invocation.arguments[3])
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `successful research capture writes success source health cursor before known leader filtering`() {
|
||||
Mockito.`when`(activityEventRepository.findByStableEventKey(Mockito.anyString())).thenReturn(null)
|
||||
Mockito.`when`(activityEventRepository.save(anyActivityEvent())).thenAnswer { it.arguments[0] }
|
||||
val service = service(globalCaptureEnabled = true)
|
||||
|
||||
invokeHandleMessage(service, activityMessage("tx-success"))
|
||||
|
||||
val invocation = Mockito.mockingDetails(healthService).invocations.single()
|
||||
assertEquals(LeaderResearchSourceStatus.SUCCESS, invocation.arguments[1])
|
||||
assertEquals(1, invocation.arguments[2])
|
||||
assertTrue((invocation.arguments[7] as String).contains("tx-success"))
|
||||
}
|
||||
|
||||
private fun service(
|
||||
globalCaptureEnabled: Boolean,
|
||||
maxWritesPerMinute: Long = 120
|
||||
) = PolymarketActivityWsService(
|
||||
copyOrderTrackingService = copyOrderTrackingService,
|
||||
leaderRepository = leaderRepository,
|
||||
researchIngestionProvider = provider(ingestionService),
|
||||
researchSourceHealthProvider = provider(healthService),
|
||||
researchGlobalCaptureEnabled = globalCaptureEnabled,
|
||||
researchGlobalCaptureMaxWritesPerMinute = maxWritesPerMinute
|
||||
)
|
||||
|
||||
private fun invokeHandleMessage(service: PolymarketActivityWsService, message: String) {
|
||||
val method = PolymarketActivityWsService::class.java.getDeclaredMethod("handleMessage", String::class.java)
|
||||
method.isAccessible = true
|
||||
method.invoke(service, message)
|
||||
}
|
||||
|
||||
private fun activityMessage(txHash: String): String {
|
||||
return """
|
||||
{
|
||||
"topic": "activity",
|
||||
"type": "trades",
|
||||
"payload": {
|
||||
"proxyWallet": "0x9999999999999999999999999999999999999999",
|
||||
"conditionId": "market-1",
|
||||
"side": "BUY",
|
||||
"price": "0.42",
|
||||
"size": "2.5",
|
||||
"asset": "asset-1",
|
||||
"transactionHash": "$txHash"
|
||||
}
|
||||
}
|
||||
""".trimIndent()
|
||||
}
|
||||
|
||||
private fun anyActivityEvent(): LeaderActivityEvent {
|
||||
Mockito.any(LeaderActivityEvent::class.java)
|
||||
return LeaderActivityEvent(source = "GLOBAL_ACTIVITY_CAPTURE", stableEventKey = "dummy", eventTime = 1, rawPayloadHash = "hash")
|
||||
}
|
||||
|
||||
@Suppress("UNCHECKED_CAST")
|
||||
private fun <T> provider(value: T): ObjectProvider<T> {
|
||||
val provider = Mockito.mock(ObjectProvider::class.java) as ObjectProvider<T>
|
||||
Mockito.`when`(provider.getIfAvailable()).thenReturn(value)
|
||||
return provider
|
||||
}
|
||||
|
||||
@Suppress("UNCHECKED_CAST")
|
||||
private inline fun <reified T> mock(): T = Mockito.mock(T::class.java)
|
||||
}
|
||||
+161
@@ -0,0 +1,161 @@
|
||||
package com.wrbug.polymarketbot.service.copytrading.research
|
||||
|
||||
import com.google.gson.Gson
|
||||
import com.wrbug.polymarketbot.api.UserActivityResponse
|
||||
import com.wrbug.polymarketbot.dto.ActivityTradeMessage
|
||||
import com.wrbug.polymarketbot.dto.ActivityTradePayload
|
||||
import com.wrbug.polymarketbot.entity.LeaderActivityEvent
|
||||
import com.wrbug.polymarketbot.enums.LeaderResearchSourceType
|
||||
import com.wrbug.polymarketbot.repository.LeaderActivityEventRepository
|
||||
import org.junit.jupiter.api.Assertions.assertEquals
|
||||
import org.junit.jupiter.api.Assertions.assertFalse
|
||||
import org.junit.jupiter.api.Assertions.assertNotNull
|
||||
import org.junit.jupiter.api.Assertions.assertTrue
|
||||
import org.junit.jupiter.api.Test
|
||||
import org.mockito.Mockito
|
||||
import org.springframework.dao.DataIntegrityViolationException
|
||||
|
||||
class LeaderActivityIngestionServiceTest {
|
||||
private val repository: LeaderActivityEventRepository = mock()
|
||||
private val service = LeaderActivityIngestionService(repository, Gson())
|
||||
|
||||
@Test
|
||||
fun `ingests valid activity with fallback key and raw hash`() {
|
||||
Mockito.`when`(repository.findByStableEventKey(Mockito.anyString())).thenReturn(null)
|
||||
Mockito.`when`(repository.save(anyEvent())).thenAnswer { it.arguments[0] }
|
||||
|
||||
val event = service.ingestUserActivity(
|
||||
UserActivityResponse(
|
||||
proxyWallet = "0x1111111111111111111111111111111111111111",
|
||||
timestamp = 1_700_000_000,
|
||||
conditionId = "condition-1",
|
||||
type = "TRADE",
|
||||
size = 10.0,
|
||||
price = 0.45,
|
||||
asset = "asset-1",
|
||||
side = "BUY"
|
||||
)
|
||||
)
|
||||
|
||||
assertTrue(event.usableForDiscovery)
|
||||
assertTrue(event.usableForPaper)
|
||||
assertFalse(event.rawPayloadHash.isBlank())
|
||||
assertEquals(64, event.rawPayloadHash.length)
|
||||
assertNotNull(event.stableEventKey)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `records unusable reason for incomplete activity`() {
|
||||
Mockito.`when`(repository.findByStableEventKey(Mockito.anyString())).thenReturn(null)
|
||||
Mockito.`when`(repository.save(anyEvent())).thenAnswer { it.arguments[0] }
|
||||
|
||||
val event = service.ingestUserActivity(
|
||||
UserActivityResponse(
|
||||
proxyWallet = "not-a-wallet",
|
||||
timestamp = 1_700_000_000,
|
||||
conditionId = "",
|
||||
type = "TRADE"
|
||||
)
|
||||
)
|
||||
|
||||
assertFalse(event.usableForDiscovery)
|
||||
assertFalse(event.usableForPaper)
|
||||
assertTrue(event.unusableReason!!.contains("wallet_missing_or_invalid"))
|
||||
assertTrue(event.unusableReason!!.contains("market_missing"))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `dedupes by source event id`() {
|
||||
val existing = LeaderActivityEvent(
|
||||
source = "ACTIVITY_DERIVED",
|
||||
sourceEventId = "tx-1",
|
||||
stableEventKey = "tx-1",
|
||||
normalizedWallet = "0x1111111111111111111111111111111111111111",
|
||||
eventTime = 1_700_000_000_000,
|
||||
rawPayloadHash = "hash"
|
||||
)
|
||||
Mockito.`when`(repository.findByStableEventKey("tx-1")).thenReturn(null)
|
||||
Mockito.`when`(repository.findBySourceAndSourceEventId("ACTIVITY_DERIVED", "tx-1")).thenReturn(existing)
|
||||
|
||||
val event = service.ingestUserActivity(
|
||||
UserActivityResponse(
|
||||
proxyWallet = "0x1111111111111111111111111111111111111111",
|
||||
timestamp = 1_700_000_000,
|
||||
conditionId = "condition-1",
|
||||
type = "TRADE",
|
||||
size = 10.0,
|
||||
transactionHash = "tx-1",
|
||||
price = 0.45,
|
||||
asset = "asset-1",
|
||||
side = "BUY"
|
||||
)
|
||||
)
|
||||
|
||||
assertEquals(existing, event)
|
||||
Mockito.verify(repository, Mockito.never()).save(Mockito.any(LeaderActivityEvent::class.java))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `dedupes after database uniqueness violation`() {
|
||||
val existing = LeaderActivityEvent(
|
||||
source = "ACTIVITY_DERIVED",
|
||||
stableEventKey = "stable-1",
|
||||
eventTime = 1_700_000_000_000,
|
||||
rawPayloadHash = "hash"
|
||||
)
|
||||
Mockito.`when`(repository.findByStableEventKey(Mockito.anyString())).thenReturn(null, existing)
|
||||
Mockito.`when`(repository.save(Mockito.any(LeaderActivityEvent::class.java))).thenThrow(DataIntegrityViolationException("duplicate"))
|
||||
|
||||
val event = service.ingestUserActivity(validActivity(transactionHash = null))
|
||||
|
||||
assertEquals(existing, event)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `ingests websocket trade before known leader filtering`() {
|
||||
Mockito.`when`(repository.findByStableEventKey(Mockito.anyString())).thenReturn(null)
|
||||
Mockito.`when`(repository.save(anyEvent())).thenAnswer { it.arguments[0] }
|
||||
|
||||
val event = service.ingestWebSocketTrade(
|
||||
ActivityTradeMessage(
|
||||
topic = "activity",
|
||||
type = "trades",
|
||||
payload = ActivityTradePayload(
|
||||
proxyWallet = "0x9999999999999999999999999999999999999999",
|
||||
conditionId = "condition-unknown-leader",
|
||||
side = "BUY",
|
||||
price = "0.42",
|
||||
size = "2.5",
|
||||
asset = "asset-unknown",
|
||||
transactionHash = "ws-tx-1"
|
||||
)
|
||||
),
|
||||
LeaderResearchSourceType.GLOBAL_ACTIVITY_CAPTURE
|
||||
)
|
||||
|
||||
assertEquals("0x9999999999999999999999999999999999999999", event.normalizedWallet)
|
||||
assertEquals("GLOBAL_ACTIVITY_CAPTURE", event.source)
|
||||
assertTrue(event.usableForDiscovery)
|
||||
assertTrue(event.usableForPaper)
|
||||
}
|
||||
|
||||
private fun validActivity(transactionHash: String?) = UserActivityResponse(
|
||||
proxyWallet = "0x1111111111111111111111111111111111111111",
|
||||
timestamp = 1_700_000_000,
|
||||
conditionId = "condition-1",
|
||||
type = "TRADE",
|
||||
size = 10.0,
|
||||
transactionHash = transactionHash,
|
||||
price = 0.45,
|
||||
asset = "asset-1",
|
||||
side = "BUY"
|
||||
)
|
||||
|
||||
private fun anyEvent(): LeaderActivityEvent {
|
||||
Mockito.any(LeaderActivityEvent::class.java)
|
||||
return LeaderActivityEvent(source = "ACTIVITY_DERIVED", stableEventKey = "dummy", eventTime = 1, rawPayloadHash = "hash")
|
||||
}
|
||||
|
||||
@Suppress("UNCHECKED_CAST")
|
||||
private inline fun <reified T> mock(): T = Mockito.mock(T::class.java)
|
||||
}
|
||||
+376
@@ -0,0 +1,376 @@
|
||||
package com.wrbug.polymarketbot.service.copytrading.research
|
||||
|
||||
import com.wrbug.polymarketbot.entity.LeaderActivityEvent
|
||||
import com.wrbug.polymarketbot.entity.LeaderPaperPosition
|
||||
import com.wrbug.polymarketbot.entity.LeaderPaperSession
|
||||
import com.wrbug.polymarketbot.entity.LeaderPaperTrade
|
||||
import com.wrbug.polymarketbot.entity.LeaderResearchCandidate
|
||||
import com.wrbug.polymarketbot.enums.LeaderPaperFilterResult
|
||||
import com.wrbug.polymarketbot.enums.LeaderPaperProcessingStatus
|
||||
import com.wrbug.polymarketbot.enums.LeaderPaperSessionStatus
|
||||
import com.wrbug.polymarketbot.enums.LeaderResearchState
|
||||
import com.wrbug.polymarketbot.enums.LeaderResearchValuationStatus
|
||||
import com.wrbug.polymarketbot.repository.LeaderActivityEventRepository
|
||||
import com.wrbug.polymarketbot.repository.LeaderPaperPositionRepository
|
||||
import com.wrbug.polymarketbot.repository.LeaderPaperSessionRepository
|
||||
import com.wrbug.polymarketbot.repository.LeaderPaperTradeRepository
|
||||
import com.wrbug.polymarketbot.repository.LeaderResearchCandidateRepository
|
||||
import com.wrbug.polymarketbot.service.common.MarketPriceService
|
||||
import kotlinx.coroutines.runBlocking
|
||||
import org.junit.jupiter.api.Assertions.assertEquals
|
||||
import org.junit.jupiter.api.Assertions.assertFalse
|
||||
import org.junit.jupiter.api.Assertions.assertTrue
|
||||
import org.junit.jupiter.api.Test
|
||||
import org.mockito.Mockito
|
||||
import org.springframework.data.domain.PageImpl
|
||||
import org.springframework.data.domain.PageRequest
|
||||
import java.math.BigDecimal
|
||||
|
||||
class LeaderPaperTradingServiceTest {
|
||||
private val candidateRepository: LeaderResearchCandidateRepository = mock()
|
||||
private val activityEventRepository: LeaderActivityEventRepository = mock()
|
||||
private val paperSessionRepository: LeaderPaperSessionRepository = mock()
|
||||
private val paperTradeRepository: LeaderPaperTradeRepository = mock()
|
||||
private val paperPositionRepository: LeaderPaperPositionRepository = mock()
|
||||
private val marketPriceService: MarketPriceService = mock()
|
||||
private val eventService: LeaderResearchEventService = mock()
|
||||
private val service = LeaderPaperTradingService(
|
||||
candidateRepository = candidateRepository,
|
||||
activityEventRepository = activityEventRepository,
|
||||
paperSessionRepository = paperSessionRepository,
|
||||
paperTradeRepository = paperTradeRepository,
|
||||
paperPositionRepository = paperPositionRepository,
|
||||
marketPriceService = marketPriceService,
|
||||
eventService = eventService
|
||||
)
|
||||
|
||||
@Test
|
||||
fun `trial ready requires enough age trades positive pnl and bounded unknown exposure`() {
|
||||
val session = LeaderPaperSession(
|
||||
id = 1L,
|
||||
candidateId = 1L,
|
||||
startedAt = System.currentTimeMillis() - 8L * 24 * 60 * 60 * 1000,
|
||||
tradeCount = 10,
|
||||
filteredCount = 1,
|
||||
openExposure = BigDecimal("10"),
|
||||
copyablePnl = BigDecimal("1"),
|
||||
maxDrawdown = BigDecimal("-5"),
|
||||
unknownValuationExposure = BigDecimal("1"),
|
||||
filteredRatio = BigDecimal("0.09")
|
||||
)
|
||||
|
||||
assertTrue(service.isEligibleForTrialReady(session))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `trial ready rejects confirmed stale unknown quote exposure`() {
|
||||
val session = LeaderPaperSession(
|
||||
id = 1L,
|
||||
candidateId = 1L,
|
||||
startedAt = System.currentTimeMillis() - 8L * 24 * 60 * 60 * 1000,
|
||||
tradeCount = 10,
|
||||
openExposure = BigDecimal("10"),
|
||||
copyablePnl = BigDecimal("1"),
|
||||
maxDrawdown = BigDecimal("-5"),
|
||||
unknownValuationExposure = BigDecimal("3"),
|
||||
filteredRatio = BigDecimal("0.09")
|
||||
)
|
||||
|
||||
assertFalse(service.isEligibleForTrialReady(session))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `process paper candidates records buy sell pnl and confirmed zero exposure separately`() {
|
||||
val candidate = paperCandidate()
|
||||
val session = LeaderPaperSession(id = 10L, candidateId = candidate.id!!)
|
||||
val events = listOf(
|
||||
paperEvent(id = 100L, stableKey = "buy-1", side = "BUY", price = "0.50", size = "10"),
|
||||
paperEvent(id = 101L, stableKey = "sell-1", side = "SELL", price = "0.70", size = "1")
|
||||
)
|
||||
val savedTrades = mutableListOf<LeaderPaperTrade>()
|
||||
val savedPositions = mutableListOf<LeaderPaperPosition>()
|
||||
val savedSessions = mutableListOf<LeaderPaperSession>()
|
||||
stubPaperPipeline(candidate, session, events, savedTrades, savedPositions, savedSessions)
|
||||
runBlocking {
|
||||
Mockito.`when`(marketPriceService.getCurrentMarketPrice("market-1", 0))
|
||||
.thenReturn(BigDecimal("0.60"), BigDecimal.ZERO)
|
||||
}
|
||||
|
||||
val result = service.processPaperCandidates(runId = 9L, batchSize = 10)
|
||||
|
||||
assertEquals(2, result.processed)
|
||||
assertEquals(0, result.filtered)
|
||||
assertEquals(0, result.failed)
|
||||
assertEquals(listOf("BUY", "SELL"), savedTrades.map { it.side })
|
||||
assertEquals(0, BigDecimal("0.20").compareTo(savedTrades.last().realizedPnl))
|
||||
assertEquals(LeaderResearchValuationStatus.CONFIRMED_ZERO, savedTrades.last().valuationStatus)
|
||||
val finalPosition = savedPositions.last()
|
||||
assertEquals(0, BigDecimal("1.00000000").compareTo(finalPosition.quantity))
|
||||
assertEquals(0, BigDecimal("0.20").compareTo(finalPosition.realizedPnl))
|
||||
val finalSummary = savedSessions.last()
|
||||
assertEquals(2, finalSummary.tradeCount)
|
||||
assertEquals(0, BigDecimal("0.500000000").compareTo(finalSummary.confirmedZeroExposure))
|
||||
assertEquals(0, BigDecimal("0.20").compareTo(finalSummary.totalRealizedPnl))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `process paper candidates records filtered trade without position mutation`() {
|
||||
val candidate = paperCandidate()
|
||||
val session = LeaderPaperSession(id = 10L, candidateId = candidate.id!!)
|
||||
val savedTrades = mutableListOf<LeaderPaperTrade>()
|
||||
val savedPositions = mutableListOf<LeaderPaperPosition>()
|
||||
val savedSessions = mutableListOf<LeaderPaperSession>()
|
||||
stubPaperPipeline(
|
||||
candidate = candidate,
|
||||
session = session,
|
||||
events = listOf(paperEvent(id = 100L, stableKey = "bad-price", side = "BUY", price = "0.05", size = "10")),
|
||||
savedTrades = savedTrades,
|
||||
savedPositions = savedPositions,
|
||||
savedSessions = savedSessions
|
||||
)
|
||||
|
||||
val result = service.processPaperCandidates(runId = 9L, batchSize = 10)
|
||||
|
||||
assertEquals(0, result.processed)
|
||||
assertEquals(1, result.filtered)
|
||||
assertEquals(0, result.failed)
|
||||
assertEquals(LeaderPaperFilterResult.FILTERED, savedTrades.single().filterResult)
|
||||
assertEquals("price_outside_safe_band", savedTrades.single().filterReason)
|
||||
assertTrue(savedPositions.isEmpty())
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `duplicate leader trade does not create another paper trade or mutate position`() {
|
||||
val candidate = paperCandidate()
|
||||
val session = LeaderPaperSession(id = 10L, candidateId = candidate.id!!)
|
||||
val existingTrade = LeaderPaperTrade(
|
||||
id = 99L,
|
||||
sessionId = session.id!!,
|
||||
candidateId = candidate.id!!,
|
||||
leaderTradeId = "duplicate-1",
|
||||
marketId = "market-1",
|
||||
side = "BUY",
|
||||
eventTime = 1_700_000_000_000
|
||||
)
|
||||
val savedTrades = mutableListOf(existingTrade)
|
||||
val savedPositions = mutableListOf<LeaderPaperPosition>()
|
||||
val savedSessions = mutableListOf<LeaderPaperSession>()
|
||||
stubPaperPipeline(
|
||||
candidate = candidate,
|
||||
session = session,
|
||||
events = listOf(paperEvent(id = 100L, stableKey = "duplicate-1", side = "BUY", price = "0.50", size = "10")),
|
||||
savedTrades = savedTrades,
|
||||
savedPositions = savedPositions,
|
||||
savedSessions = savedSessions
|
||||
)
|
||||
Mockito.`when`(paperTradeRepository.existsBySessionIdAndLeaderTradeIdAndSide(session.id!!, "duplicate-1", "BUY"))
|
||||
.thenReturn(true)
|
||||
|
||||
val result = service.processPaperCandidates(runId = 9L, batchSize = 10)
|
||||
|
||||
assertEquals(1, result.processed)
|
||||
assertEquals(1, savedTrades.size)
|
||||
assertTrue(savedPositions.isEmpty())
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `claim miss isolates concurrent paper processing`() {
|
||||
val candidate = paperCandidate()
|
||||
val session = LeaderPaperSession(id = 10L, candidateId = candidate.id!!)
|
||||
val savedTrades = mutableListOf<LeaderPaperTrade>()
|
||||
val savedPositions = mutableListOf<LeaderPaperPosition>()
|
||||
val savedSessions = mutableListOf<LeaderPaperSession>()
|
||||
stubPaperPipeline(
|
||||
candidate = candidate,
|
||||
session = session,
|
||||
events = listOf(paperEvent(id = 100L, stableKey = "claimed-elsewhere", side = "BUY", price = "0.50", size = "10")),
|
||||
savedTrades = savedTrades,
|
||||
savedPositions = savedPositions,
|
||||
savedSessions = savedSessions,
|
||||
claimResult = 0
|
||||
)
|
||||
|
||||
val result = service.processPaperCandidates(runId = 9L, batchSize = 10)
|
||||
|
||||
assertEquals(0, result.processed)
|
||||
assertEquals(0, result.filtered)
|
||||
assertEquals(0, result.failed)
|
||||
assertTrue(savedTrades.isEmpty())
|
||||
assertTrue(savedPositions.isEmpty())
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `processing failure becomes failed after max attempts and does not block batch`() {
|
||||
val candidate = paperCandidate()
|
||||
val session = LeaderPaperSession(id = 10L, candidateId = candidate.id!!)
|
||||
val failedEvents = mutableListOf<LeaderActivityEvent>()
|
||||
stubPaperPipeline(
|
||||
candidate = candidate,
|
||||
session = session,
|
||||
events = listOf(
|
||||
paperEvent(
|
||||
id = 100L,
|
||||
stableKey = "save-fails",
|
||||
side = "BUY",
|
||||
price = "0.50",
|
||||
size = "10",
|
||||
processingAttempts = 2
|
||||
)
|
||||
),
|
||||
savedTrades = mutableListOf(),
|
||||
savedPositions = mutableListOf(),
|
||||
savedSessions = mutableListOf()
|
||||
)
|
||||
runBlocking {
|
||||
Mockito.`when`(marketPriceService.getCurrentMarketPrice("market-1", 0)).thenReturn(BigDecimal("0.60"))
|
||||
}
|
||||
Mockito.`when`(paperTradeRepository.save(anyTrade())).thenThrow(IllegalStateException("db down"))
|
||||
Mockito.`when`(activityEventRepository.save(anyActivityEvent())).thenAnswer {
|
||||
val event = it.arguments[0] as LeaderActivityEvent
|
||||
failedEvents += event
|
||||
event
|
||||
}
|
||||
|
||||
val result = service.processPaperCandidates(runId = 9L, batchSize = 10)
|
||||
|
||||
assertEquals(0, result.processed)
|
||||
assertEquals(0, result.filtered)
|
||||
assertEquals(1, result.failed)
|
||||
assertEquals(LeaderPaperProcessingStatus.FAILED, failedEvents.last().paperProcessingStatus)
|
||||
assertTrue(failedEvents.last().lastProcessingError!!.contains("db down"))
|
||||
}
|
||||
|
||||
private fun stubPaperPipeline(
|
||||
candidate: LeaderResearchCandidate,
|
||||
session: LeaderPaperSession,
|
||||
events: List<LeaderActivityEvent>,
|
||||
savedTrades: MutableList<LeaderPaperTrade>,
|
||||
savedPositions: MutableList<LeaderPaperPosition>,
|
||||
savedSessions: MutableList<LeaderPaperSession>,
|
||||
claimResult: Int = 1
|
||||
) {
|
||||
Mockito.`when`(candidateRepository.findByResearchStateIn(listOf(LeaderResearchState.PAPER, LeaderResearchState.TRIAL_READY)))
|
||||
.thenReturn(listOf(candidate))
|
||||
Mockito.`when`(candidateRepository.save(anyCandidate())).thenAnswer { it.arguments[0] }
|
||||
Mockito.`when`(paperSessionRepository.findTopByCandidateIdAndStatusOrderByStartedAtDesc(candidate.id!!, LeaderPaperSessionStatus.ACTIVE))
|
||||
.thenReturn(null, session, session, session, session)
|
||||
Mockito.`when`(paperSessionRepository.save(anySession())).thenAnswer {
|
||||
val incoming = it.arguments[0] as LeaderPaperSession
|
||||
val saved = if (incoming.id == null) incoming.copy(id = session.id) else incoming
|
||||
savedSessions += saved
|
||||
saved
|
||||
}
|
||||
Mockito.`when`(
|
||||
activityEventRepository.findByPaperProcessingStatusInAndUsableForPaperTrueOrderByEventTimeAsc(
|
||||
listOf(LeaderPaperProcessingStatus.NEW, LeaderPaperProcessingStatus.RETRYABLE),
|
||||
PageRequest.of(0, 10)
|
||||
)
|
||||
).thenReturn(PageImpl(events))
|
||||
Mockito.`when`(
|
||||
activityEventRepository.claimForPaperProcessing(
|
||||
Mockito.anyLong(),
|
||||
anyProcessingStatuses(),
|
||||
anyProcessingStatus(),
|
||||
Mockito.anyLong()
|
||||
)
|
||||
).thenReturn(claimResult)
|
||||
Mockito.`when`(activityEventRepository.save(anyActivityEvent())).thenAnswer { it.arguments[0] }
|
||||
Mockito.`when`(paperPositionRepository.findBySessionIdAndMarketIdAndOutcomeIndex(session.id!!, "market-1", 0))
|
||||
.thenAnswer { savedPositions.lastOrNull { it.marketId == "market-1" && it.outcomeIndex == 0 } }
|
||||
Mockito.`when`(paperPositionRepository.findBySessionIdOrderByUpdatedAtDesc(session.id!!))
|
||||
.thenAnswer { savedPositions.toList().asReversed() }
|
||||
Mockito.`when`(paperPositionRepository.save(anyPosition())).thenAnswer {
|
||||
val position = it.arguments[0] as LeaderPaperPosition
|
||||
val existingIndex = savedPositions.indexOfFirst { saved ->
|
||||
saved.sessionId == position.sessionId &&
|
||||
saved.marketId == position.marketId &&
|
||||
saved.outcomeIndex == position.outcomeIndex
|
||||
}
|
||||
if (existingIndex >= 0) {
|
||||
savedPositions[existingIndex] = position
|
||||
} else {
|
||||
savedPositions += position
|
||||
}
|
||||
position
|
||||
}
|
||||
Mockito.`when`(paperTradeRepository.existsBySessionIdAndLeaderTradeIdAndSide(Mockito.anyLong(), Mockito.anyString(), Mockito.anyString()))
|
||||
.thenReturn(false)
|
||||
Mockito.`when`(paperTradeRepository.findBySessionIdOrderByEventTimeAsc(session.id!!))
|
||||
.thenAnswer { savedTrades.sortedBy { it.eventTime } }
|
||||
Mockito.`when`(paperTradeRepository.save(anyTrade())).thenAnswer {
|
||||
val trade = it.arguments[0] as LeaderPaperTrade
|
||||
savedTrades += trade
|
||||
trade
|
||||
}
|
||||
}
|
||||
|
||||
private fun paperCandidate() = LeaderResearchCandidate(
|
||||
id = 1L,
|
||||
normalizedWallet = "0x1111111111111111111111111111111111111111",
|
||||
researchState = LeaderResearchState.PAPER
|
||||
)
|
||||
|
||||
private fun paperEvent(
|
||||
id: Long,
|
||||
stableKey: String,
|
||||
side: String,
|
||||
price: String,
|
||||
size: String,
|
||||
processingAttempts: Int = 0
|
||||
) = LeaderActivityEvent(
|
||||
id = id,
|
||||
source = "ACTIVITY_DERIVED",
|
||||
sourceEventId = stableKey,
|
||||
stableEventKey = stableKey,
|
||||
normalizedWallet = "0x1111111111111111111111111111111111111111",
|
||||
marketId = "market-1",
|
||||
side = side,
|
||||
outcomeIndex = 0,
|
||||
price = BigDecimal(price),
|
||||
size = BigDecimal(size),
|
||||
amount = BigDecimal(price).multiply(BigDecimal(size)),
|
||||
eventTime = 1_700_000_000_000 + id,
|
||||
rawPayloadHash = "hash-$stableKey",
|
||||
usableForPaper = true,
|
||||
paperProcessingStatus = if (processingAttempts > 0) LeaderPaperProcessingStatus.RETRYABLE else LeaderPaperProcessingStatus.NEW,
|
||||
processingAttempts = processingAttempts
|
||||
)
|
||||
|
||||
private fun anyCandidate(): LeaderResearchCandidate {
|
||||
Mockito.any(LeaderResearchCandidate::class.java)
|
||||
return paperCandidate()
|
||||
}
|
||||
|
||||
private fun anySession(): LeaderPaperSession {
|
||||
Mockito.any(LeaderPaperSession::class.java)
|
||||
return LeaderPaperSession(candidateId = 1)
|
||||
}
|
||||
|
||||
private fun anyActivityEvent(): LeaderActivityEvent {
|
||||
Mockito.any(LeaderActivityEvent::class.java)
|
||||
return paperEvent(id = 1, stableKey = "dummy", side = "BUY", price = "0.50", size = "1")
|
||||
}
|
||||
|
||||
private fun anyPosition(): LeaderPaperPosition {
|
||||
Mockito.any(LeaderPaperPosition::class.java)
|
||||
return LeaderPaperPosition(sessionId = 10, candidateId = 1, marketId = "market-1")
|
||||
}
|
||||
|
||||
private fun anyTrade(): LeaderPaperTrade {
|
||||
Mockito.any(LeaderPaperTrade::class.java)
|
||||
return LeaderPaperTrade(sessionId = 10, candidateId = 1, leaderTradeId = "dummy", marketId = "market-1", side = "BUY", eventTime = 1)
|
||||
}
|
||||
|
||||
private fun anyProcessingStatuses(): Collection<LeaderPaperProcessingStatus> {
|
||||
Mockito.anyCollection<LeaderPaperProcessingStatus>()
|
||||
return emptyList()
|
||||
}
|
||||
|
||||
private fun anyProcessingStatus(): LeaderPaperProcessingStatus {
|
||||
Mockito.any(LeaderPaperProcessingStatus::class.java)
|
||||
return LeaderPaperProcessingStatus.PROCESSING
|
||||
}
|
||||
|
||||
@Suppress("UNCHECKED_CAST")
|
||||
private inline fun <reified T> mock(): T = org.mockito.Mockito.mock(T::class.java)
|
||||
}
|
||||
+152
@@ -0,0 +1,152 @@
|
||||
package com.wrbug.polymarketbot.service.copytrading.research
|
||||
|
||||
import com.wrbug.polymarketbot.dto.CopyTradingDto
|
||||
import com.wrbug.polymarketbot.dto.LeaderResearchApprovalRequest
|
||||
import com.wrbug.polymarketbot.entity.Account
|
||||
import com.wrbug.polymarketbot.entity.LeaderPool
|
||||
import com.wrbug.polymarketbot.entity.LeaderResearchCandidate
|
||||
import com.wrbug.polymarketbot.enums.LeaderResearchState
|
||||
import com.wrbug.polymarketbot.repository.AccountRepository
|
||||
import com.wrbug.polymarketbot.repository.CopyTradingRepository
|
||||
import com.wrbug.polymarketbot.repository.LeaderPoolRepository
|
||||
import com.wrbug.polymarketbot.repository.LeaderResearchCandidateRepository
|
||||
import com.wrbug.polymarketbot.service.copytrading.configs.CopyTradingService
|
||||
import org.junit.jupiter.api.Assertions.assertFalse
|
||||
import org.junit.jupiter.api.Assertions.assertTrue
|
||||
import org.junit.jupiter.api.Test
|
||||
import org.mockito.ArgumentCaptor
|
||||
import org.mockito.Mockito
|
||||
import java.util.Optional
|
||||
|
||||
class LeaderResearchApprovalServiceTest {
|
||||
private val candidateRepository: LeaderResearchCandidateRepository = mock()
|
||||
private val accountRepository: AccountRepository = mock()
|
||||
private val copyTradingRepository: CopyTradingRepository = mock()
|
||||
private val leaderPoolRepository: LeaderPoolRepository = mock()
|
||||
private val copyTradingService: CopyTradingService = mock()
|
||||
private val poolMappingService: LeaderResearchPoolMappingService = mock()
|
||||
private val eventService: LeaderResearchEventService = mock()
|
||||
private val service = LeaderResearchApprovalService(
|
||||
candidateRepository,
|
||||
accountRepository,
|
||||
copyTradingRepository,
|
||||
leaderPoolRepository,
|
||||
copyTradingService,
|
||||
poolMappingService,
|
||||
eventService
|
||||
)
|
||||
|
||||
@Test
|
||||
fun `approval requires explicit confirm`() {
|
||||
val result = service.createDisabledTrialConfig(LeaderResearchApprovalRequest(candidateId = 1L, accountId = 2L, confirm = false))
|
||||
|
||||
assertTrue(result.isFailure)
|
||||
assertTrue(result.exceptionOrNull() is LeaderResearchApprovalConfirmRequiredException)
|
||||
Mockito.verify(copyTradingService, Mockito.never()).createCopyTrading(anyCreateRequest())
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `approval creates disabled copy trading config only`() {
|
||||
val candidate = LeaderResearchCandidate(
|
||||
id = 1L,
|
||||
normalizedWallet = "0x1111111111111111111111111111111111111111",
|
||||
leaderId = 9L,
|
||||
poolId = 10L,
|
||||
researchState = LeaderResearchState.TRIAL_READY
|
||||
)
|
||||
Mockito.`when`(candidateRepository.findById(1L)).thenReturn(Optional.of(candidate))
|
||||
Mockito.`when`(accountRepository.findByIdForUpdate(2L)).thenReturn(account())
|
||||
Mockito.`when`(poolMappingService.syncCandidate(candidate)).thenReturn(candidate)
|
||||
Mockito.`when`(leaderPoolRepository.findById(10L)).thenReturn(Optional.of(pool()))
|
||||
Mockito.`when`(copyTradingRepository.findByAccountIdAndLeaderId(2L, 9L)).thenReturn(emptyList())
|
||||
Mockito.`when`(copyTradingService.createCopyTrading(anyCreateRequest())).thenReturn(Result.success(copyTradingDto()))
|
||||
Mockito.`when`(leaderPoolRepository.save(anyLeaderPool())).thenAnswer { it.arguments[0] }
|
||||
|
||||
val result = service.createDisabledTrialConfig(LeaderResearchApprovalRequest(candidateId = 1L, accountId = 2L, confirm = true))
|
||||
|
||||
assertTrue(result.isSuccess)
|
||||
val captor = ArgumentCaptor.forClass(com.wrbug.polymarketbot.dto.CopyTradingCreateRequest::class.java)
|
||||
Mockito.verify(copyTradingService).createCopyTrading(captureCreateRequest(captor))
|
||||
assertFalse(captor.value.enabled)
|
||||
Mockito.verify(accountRepository).findByIdForUpdate(2L)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `locked candidate cannot create approval config`() {
|
||||
val candidate = LeaderResearchCandidate(
|
||||
id = 1L,
|
||||
normalizedWallet = "0x1111111111111111111111111111111111111111",
|
||||
leaderId = 9L,
|
||||
poolId = 10L,
|
||||
researchState = LeaderResearchState.TRIAL_READY,
|
||||
locked = true
|
||||
)
|
||||
Mockito.`when`(candidateRepository.findById(1L)).thenReturn(Optional.of(candidate))
|
||||
|
||||
val result = service.createDisabledTrialConfig(LeaderResearchApprovalRequest(candidateId = 1L, accountId = 2L, confirm = true))
|
||||
|
||||
assertTrue(result.isFailure)
|
||||
assertTrue(result.exceptionOrNull() is LeaderResearchCandidateLockedException)
|
||||
Mockito.verify(accountRepository, Mockito.never()).findByIdForUpdate(2L)
|
||||
Mockito.verify(copyTradingService, Mockito.never()).createCopyTrading(anyCreateRequest())
|
||||
}
|
||||
|
||||
private fun account() = Account(
|
||||
id = 2L,
|
||||
privateKey = "enc",
|
||||
walletAddress = "0x2222222222222222222222222222222222222222",
|
||||
proxyAddress = "0x3333333333333333333333333333333333333333"
|
||||
)
|
||||
|
||||
private fun pool() = LeaderPool(id = 10L, leaderId = 9L, researchCandidateId = 1L)
|
||||
|
||||
private fun copyTradingDto() = CopyTradingDto(
|
||||
id = 20L,
|
||||
accountId = 2L,
|
||||
accountName = null,
|
||||
walletAddress = "0x2222222222222222222222222222222222222222",
|
||||
leaderId = 9L,
|
||||
leaderName = null,
|
||||
leaderAddress = "0x1111111111111111111111111111111111111111",
|
||||
enabled = false,
|
||||
copyMode = "FIXED",
|
||||
copyRatio = "1",
|
||||
fixedAmount = "1",
|
||||
maxOrderSize = "1",
|
||||
minOrderSize = "1",
|
||||
maxDailyLoss = "5",
|
||||
maxDailyOrders = 10,
|
||||
priceTolerance = "1",
|
||||
delaySeconds = 0,
|
||||
pollIntervalSeconds = 5,
|
||||
useWebSocket = true,
|
||||
websocketReconnectInterval = 5000,
|
||||
websocketMaxRetries = 10,
|
||||
supportSell = true,
|
||||
minOrderDepth = null,
|
||||
maxSpread = null,
|
||||
minPrice = "0.1",
|
||||
maxPrice = "0.8",
|
||||
maxPositionValue = "5",
|
||||
createdAt = 1L,
|
||||
updatedAt = 1L
|
||||
)
|
||||
|
||||
private fun anyCreateRequest(): com.wrbug.polymarketbot.dto.CopyTradingCreateRequest {
|
||||
Mockito.any(com.wrbug.polymarketbot.dto.CopyTradingCreateRequest::class.java)
|
||||
return com.wrbug.polymarketbot.dto.CopyTradingCreateRequest(accountId = 2L, leaderId = 9L)
|
||||
}
|
||||
|
||||
private fun anyLeaderPool(): LeaderPool {
|
||||
Mockito.any(LeaderPool::class.java)
|
||||
return pool()
|
||||
}
|
||||
|
||||
private fun captureCreateRequest(captor: ArgumentCaptor<com.wrbug.polymarketbot.dto.CopyTradingCreateRequest>): com.wrbug.polymarketbot.dto.CopyTradingCreateRequest {
|
||||
captor.capture()
|
||||
return com.wrbug.polymarketbot.dto.CopyTradingCreateRequest(accountId = 2L, leaderId = 9L)
|
||||
}
|
||||
|
||||
@Suppress("UNCHECKED_CAST")
|
||||
private inline fun <reified T> mock(): T = Mockito.mock(T::class.java)
|
||||
}
|
||||
+142
@@ -0,0 +1,142 @@
|
||||
package com.wrbug.polymarketbot.service.copytrading.research
|
||||
|
||||
import com.wrbug.polymarketbot.entity.LeaderActivityEvent
|
||||
import com.wrbug.polymarketbot.entity.LeaderResearchRun
|
||||
import com.wrbug.polymarketbot.enums.LeaderResearchRunStatus
|
||||
import com.wrbug.polymarketbot.enums.LeaderResearchSourceStatus
|
||||
import com.wrbug.polymarketbot.enums.LeaderResearchSourceType
|
||||
import com.wrbug.polymarketbot.enums.LeaderResearchState
|
||||
import com.wrbug.polymarketbot.enums.LeaderResearchTriggerType
|
||||
import com.wrbug.polymarketbot.repository.LeaderActivityEventRepository
|
||||
import com.wrbug.polymarketbot.repository.LeaderResearchCandidateRepository
|
||||
import com.wrbug.polymarketbot.repository.LeaderResearchRunRepository
|
||||
import org.junit.jupiter.api.Assertions.assertEquals
|
||||
import org.junit.jupiter.api.Assertions.assertFalse
|
||||
import org.junit.jupiter.api.Assertions.assertNotNull
|
||||
import org.junit.jupiter.api.Assertions.assertTrue
|
||||
import org.junit.jupiter.api.Test
|
||||
import org.mockito.Mockito
|
||||
|
||||
class LeaderResearchJobServiceTest {
|
||||
private val runRepository: LeaderResearchRunRepository = mock()
|
||||
private val activityEventRepository: LeaderActivityEventRepository = mock()
|
||||
private val candidateRepository: LeaderResearchCandidateRepository = mock()
|
||||
private val sourceService: LeaderResearchSourceService = mock()
|
||||
private val paperTradingService: LeaderPaperTradingService = mock()
|
||||
private val scoringService: LeaderResearchScoringService = mock()
|
||||
private val stateMachine: LeaderResearchStateMachine = mock()
|
||||
private val eventService: LeaderResearchEventService = mock()
|
||||
|
||||
@Test
|
||||
fun `successful run writes run record counts cursor and processing phases`() {
|
||||
val service = service()
|
||||
stubRunSaves()
|
||||
Mockito.`when`(sourceService.discoverCandidates(1L)).thenReturn(
|
||||
listOf(LeaderResearchSourceRunResult(LeaderResearchSourceType.WATCHLIST, emptyList(), LeaderResearchSourceStatus.SUCCESS))
|
||||
)
|
||||
LeaderResearchState.values().forEach { state ->
|
||||
Mockito.`when`(candidateRepository.countByResearchState(state)).thenReturn(2)
|
||||
}
|
||||
Mockito.`when`(activityEventRepository.findTopByOrderByEventTimeDesc()).thenReturn(
|
||||
LeaderActivityEvent(source = "ACTIVITY_DERIVED", stableEventKey = "cursor-1", eventTime = 123, rawPayloadHash = "hash")
|
||||
)
|
||||
|
||||
val run = service.runOnce(dryRun = false, triggerType = LeaderResearchTriggerType.MANUAL)
|
||||
|
||||
assertEquals(LeaderResearchRunStatus.SUCCESS, run.status)
|
||||
assertFalse(run.partialFailure)
|
||||
assertTrue(run.sourceCountsJson!!.contains("\"WATCHLIST\":0"))
|
||||
assertTrue(run.candidateCountsJson!!.contains("\"PAPER\":2"))
|
||||
assertEquals("123:cursor-1", run.lastEventCursor)
|
||||
Mockito.verify(scoringService, Mockito.times(2)).scoreAll(run.id)
|
||||
Mockito.verify(stateMachine, Mockito.times(2)).advanceAll(run.id)
|
||||
Mockito.verify(paperTradingService).processPaperCandidates(run.id)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `degraded source marks run partial failure without aborting run`() {
|
||||
val service = service()
|
||||
stubRunSaves()
|
||||
Mockito.`when`(sourceService.discoverCandidates(1L)).thenReturn(
|
||||
listOf(
|
||||
LeaderResearchSourceRunResult(LeaderResearchSourceType.WATCHLIST, emptyList(), LeaderResearchSourceStatus.SUCCESS),
|
||||
LeaderResearchSourceRunResult(
|
||||
LeaderResearchSourceType.ACTIVITY_DERIVED,
|
||||
emptyList(),
|
||||
LeaderResearchSourceStatus.DEGRADED,
|
||||
errorClass = "DataApiFailure",
|
||||
errorMessage = "timeout"
|
||||
)
|
||||
)
|
||||
)
|
||||
|
||||
val run = service.runOnce(dryRun = false, triggerType = LeaderResearchTriggerType.MANUAL)
|
||||
|
||||
assertEquals(LeaderResearchRunStatus.PARTIAL_FAILURE, run.status)
|
||||
assertTrue(run.partialFailure)
|
||||
Mockito.verify(paperTradingService).processPaperCandidates(run.id)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `preview run does not score advance or paper trade`() {
|
||||
val service = service()
|
||||
stubRunSaves()
|
||||
Mockito.`when`(sourceService.previewCandidates()).thenReturn(
|
||||
listOf(LeaderResearchSourceRunResult(LeaderResearchSourceType.WATCHLIST, emptyList(), LeaderResearchSourceStatus.SUCCESS))
|
||||
)
|
||||
|
||||
val run = service.runOnce(dryRun = true, triggerType = LeaderResearchTriggerType.PREVIEW)
|
||||
|
||||
assertEquals(LeaderResearchRunStatus.SUCCESS, run.status)
|
||||
assertTrue(run.dryRun)
|
||||
Mockito.verify(sourceService).previewCandidates()
|
||||
Mockito.verifyNoInteractions(scoringService, stateMachine, paperTradingService)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `overlap guard records skipped run while outer run continues`() {
|
||||
lateinit var service: LeaderResearchJobService
|
||||
val savedRuns = mutableListOf<LeaderResearchRun>()
|
||||
stubRunSaves(savedRuns)
|
||||
service = service()
|
||||
Mockito.`when`(sourceService.discoverCandidates(1L)).thenAnswer {
|
||||
val skipped = service.runOnce(dryRun = false, triggerType = LeaderResearchTriggerType.MANUAL)
|
||||
assertEquals(LeaderResearchRunStatus.SKIPPED, skipped.status)
|
||||
emptyList<LeaderResearchSourceRunResult>()
|
||||
}.thenReturn(emptyList())
|
||||
|
||||
val outer = service.runOnce(dryRun = false, triggerType = LeaderResearchTriggerType.MANUAL)
|
||||
|
||||
assertEquals(LeaderResearchRunStatus.SUCCESS, outer.status)
|
||||
assertNotNull(savedRuns.firstOrNull { it.status == LeaderResearchRunStatus.SKIPPED })
|
||||
assertEquals("another_run_in_progress", savedRuns.first { it.status == LeaderResearchRunStatus.SKIPPED }.skippedReason)
|
||||
}
|
||||
|
||||
private fun service() = LeaderResearchJobService(
|
||||
runRepository = runRepository,
|
||||
activityEventRepository = activityEventRepository,
|
||||
candidateRepository = candidateRepository,
|
||||
sourceService = sourceService,
|
||||
paperTradingService = paperTradingService,
|
||||
scoringService = scoringService,
|
||||
stateMachine = stateMachine,
|
||||
eventService = eventService,
|
||||
scheduledEnabled = false
|
||||
)
|
||||
|
||||
private fun stubRunSaves(savedRuns: MutableList<LeaderResearchRun> = mutableListOf()) {
|
||||
var nextId = 1L
|
||||
Mockito.`when`(runRepository.save(anyRun())).thenAnswer {
|
||||
val incoming = it.arguments[0] as LeaderResearchRun
|
||||
incoming.copy(id = incoming.id ?: nextId++).also { savedRuns += it }
|
||||
}
|
||||
}
|
||||
|
||||
private fun anyRun(): LeaderResearchRun {
|
||||
Mockito.any(LeaderResearchRun::class.java)
|
||||
return LeaderResearchRun()
|
||||
}
|
||||
|
||||
@Suppress("UNCHECKED_CAST")
|
||||
private inline fun <reified T> mock(): T = Mockito.mock(T::class.java)
|
||||
}
|
||||
+68
@@ -0,0 +1,68 @@
|
||||
package com.wrbug.polymarketbot.service.copytrading.research
|
||||
|
||||
import com.wrbug.polymarketbot.entity.LeaderResearchEvent
|
||||
import com.wrbug.polymarketbot.enums.LeaderResearchEventType
|
||||
import com.wrbug.polymarketbot.enums.LeaderResearchNotificationStatus
|
||||
import com.wrbug.polymarketbot.repository.LeaderResearchEventRepository
|
||||
import org.junit.jupiter.api.Assertions.assertEquals
|
||||
import org.junit.jupiter.api.Test
|
||||
import org.mockito.Mockito
|
||||
import org.springframework.data.domain.PageImpl
|
||||
import org.springframework.data.domain.PageRequest
|
||||
|
||||
class LeaderResearchNotificationSummaryServiceTest {
|
||||
private val repository: LeaderResearchEventRepository = mock()
|
||||
private val service = LeaderResearchNotificationSummaryService(repository)
|
||||
|
||||
@Test
|
||||
fun `builds pending safety summary`() {
|
||||
val page = PageRequest.of(0, 50)
|
||||
Mockito.`when`(
|
||||
repository.findByNotificationStatusOrderByCreatedAtAsc(
|
||||
LeaderResearchNotificationStatus.PENDING,
|
||||
page
|
||||
)
|
||||
).thenReturn(PageImpl(events()))
|
||||
|
||||
val summary = service.buildPendingSummary(limit = 50)
|
||||
|
||||
assertEquals(4, summary.total)
|
||||
assertEquals(1, summary.newCandidates)
|
||||
assertEquals(1, summary.trialReady)
|
||||
assertEquals(1, summary.sourceFailures)
|
||||
assertEquals(1, summary.approvalWarnings)
|
||||
assertEquals(4, summary.lines.size)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `mark pending as skipped preserves events and marks notification failure reason`() {
|
||||
val page = PageRequest.of(0, 100)
|
||||
Mockito.`when`(
|
||||
repository.findByNotificationStatusOrderByCreatedAtAsc(
|
||||
LeaderResearchNotificationStatus.PENDING,
|
||||
page
|
||||
)
|
||||
).thenReturn(PageImpl(events()))
|
||||
Mockito.`when`(repository.save(anyEvent())).thenAnswer { it.arguments[0] }
|
||||
|
||||
val summary = service.markPendingAsSkipped(reason = "operator_console_only")
|
||||
|
||||
assertEquals(4, summary.total)
|
||||
Mockito.verify(repository, Mockito.times(4)).save(anyEvent())
|
||||
}
|
||||
|
||||
private fun events() = listOf(
|
||||
LeaderResearchEvent(eventType = LeaderResearchEventType.CANDIDATE_DISCOVERED, reason = "new"),
|
||||
LeaderResearchEvent(eventType = LeaderResearchEventType.TRIAL_READY, reason = "ready"),
|
||||
LeaderResearchEvent(eventType = LeaderResearchEventType.SOURCE_FAILURE, reason = "source failed"),
|
||||
LeaderResearchEvent(eventType = LeaderResearchEventType.DUPLICATE_APPROVAL, reason = "duplicate")
|
||||
)
|
||||
|
||||
private fun anyEvent(): LeaderResearchEvent {
|
||||
Mockito.any(LeaderResearchEvent::class.java)
|
||||
return LeaderResearchEvent(eventType = LeaderResearchEventType.CANDIDATE_DISCOVERED)
|
||||
}
|
||||
|
||||
@Suppress("UNCHECKED_CAST")
|
||||
private inline fun <reified T> mock(): T = Mockito.mock(T::class.java)
|
||||
}
|
||||
+90
@@ -0,0 +1,90 @@
|
||||
package com.wrbug.polymarketbot.service.copytrading.research
|
||||
|
||||
import com.wrbug.polymarketbot.entity.LeaderPaperSession
|
||||
import com.wrbug.polymarketbot.enums.LeaderPaperProcessingStatus
|
||||
import com.wrbug.polymarketbot.enums.LeaderPaperSessionStatus
|
||||
import com.wrbug.polymarketbot.repository.LeaderActivityEventRepository
|
||||
import com.wrbug.polymarketbot.repository.LeaderPaperSessionRepository
|
||||
import org.junit.jupiter.api.Assertions.assertEquals
|
||||
import org.junit.jupiter.api.Test
|
||||
import org.mockito.Mockito
|
||||
import org.springframework.data.domain.PageImpl
|
||||
import org.springframework.data.domain.PageRequest
|
||||
|
||||
class LeaderResearchRetentionServiceTest {
|
||||
private val activityRepository: LeaderActivityEventRepository = mock()
|
||||
private val sessionRepository: LeaderPaperSessionRepository = mock()
|
||||
|
||||
@Test
|
||||
fun `cleanup deletes only terminal activity events and terminal paper sessions`() {
|
||||
val staleSessions = listOf(
|
||||
LeaderPaperSession(id = 1, candidateId = 1, status = LeaderPaperSessionStatus.COMPLETED),
|
||||
LeaderPaperSession(id = 2, candidateId = 2, status = LeaderPaperSessionStatus.FAILED)
|
||||
)
|
||||
val terminalActivityStatuses = listOf(
|
||||
LeaderPaperProcessingStatus.PROCESSED,
|
||||
LeaderPaperProcessingStatus.FILTERED,
|
||||
LeaderPaperProcessingStatus.FAILED
|
||||
)
|
||||
val terminalSessionStatuses = listOf(
|
||||
LeaderPaperSessionStatus.COMPLETED,
|
||||
LeaderPaperSessionStatus.FAILED
|
||||
)
|
||||
val now = 1_000_000_000L
|
||||
val activityCutoff = -6_776_000_000L
|
||||
val paperCutoff = -14_552_000_000L
|
||||
val paperPage = PageRequest.of(0, 100)
|
||||
val service = LeaderResearchRetentionService(
|
||||
activityEventRepository = activityRepository,
|
||||
paperSessionRepository = sessionRepository,
|
||||
enabled = true,
|
||||
activityRetentionDays = 90,
|
||||
paperSessionRetentionDays = 180,
|
||||
maxPaperSessionsPerRun = 100
|
||||
)
|
||||
Mockito.`when`(
|
||||
activityRepository.deleteByEventTimeLessThanAndPaperProcessingStatusIn(
|
||||
activityCutoff,
|
||||
terminalActivityStatuses
|
||||
)
|
||||
).thenReturn(7)
|
||||
Mockito.`when`(
|
||||
sessionRepository.findByUpdatedAtLessThanAndStatusIn(
|
||||
paperCutoff,
|
||||
terminalSessionStatuses,
|
||||
paperPage
|
||||
)
|
||||
).thenReturn(PageImpl(staleSessions))
|
||||
|
||||
val result = service.cleanup(now = now)
|
||||
|
||||
assertEquals(7, result.deletedActivityEvents)
|
||||
assertEquals(2, result.deletedPaperSessions)
|
||||
Mockito.verify(activityRepository).deleteByEventTimeLessThanAndPaperProcessingStatusIn(
|
||||
activityCutoff,
|
||||
terminalActivityStatuses
|
||||
)
|
||||
Mockito.verify(sessionRepository).deleteAll(staleSessions)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `disabled cleanup does nothing`() {
|
||||
val service = LeaderResearchRetentionService(
|
||||
activityEventRepository = activityRepository,
|
||||
paperSessionRepository = sessionRepository,
|
||||
enabled = false,
|
||||
activityRetentionDays = 90,
|
||||
paperSessionRetentionDays = 180,
|
||||
maxPaperSessionsPerRun = 100
|
||||
)
|
||||
|
||||
val result = service.cleanup()
|
||||
|
||||
assertEquals(0, result.deletedActivityEvents)
|
||||
assertEquals(0, result.deletedPaperSessions)
|
||||
Mockito.verifyNoInteractions(activityRepository, sessionRepository)
|
||||
}
|
||||
|
||||
@Suppress("UNCHECKED_CAST")
|
||||
private inline fun <reified T> mock(): T = Mockito.mock(T::class.java)
|
||||
}
|
||||
+95
@@ -0,0 +1,95 @@
|
||||
package com.wrbug.polymarketbot.service.copytrading.research
|
||||
|
||||
import com.wrbug.polymarketbot.entity.LeaderPaperSession
|
||||
import com.wrbug.polymarketbot.entity.LeaderResearchCandidate
|
||||
import org.junit.jupiter.api.Assertions.assertEquals
|
||||
import org.junit.jupiter.api.Assertions.assertTrue
|
||||
import org.junit.jupiter.api.Test
|
||||
import java.math.BigDecimal
|
||||
|
||||
class LeaderResearchScoringServiceTest {
|
||||
private val service = LeaderResearchScoringService(
|
||||
candidateRepository = mock(),
|
||||
paperSessionRepository = mock(),
|
||||
scoreRepository = mock()
|
||||
)
|
||||
|
||||
@Test
|
||||
fun `compute rewards profitable repeatable fresh paper session`() {
|
||||
val now = System.currentTimeMillis()
|
||||
val candidate = LeaderResearchCandidate(
|
||||
id = 1L,
|
||||
normalizedWallet = "0x1111111111111111111111111111111111111111",
|
||||
lastSourceSeenAt = now
|
||||
)
|
||||
val session = LeaderPaperSession(
|
||||
id = 10L,
|
||||
candidateId = 1L,
|
||||
startedAt = now - 8L * 24 * 60 * 60 * 1000,
|
||||
tradeCount = 12,
|
||||
filteredCount = 1,
|
||||
openExposure = BigDecimal("10"),
|
||||
copyablePnl = BigDecimal("4"),
|
||||
maxDrawdown = BigDecimal("-3"),
|
||||
unknownValuationExposure = BigDecimal("1"),
|
||||
filteredRatio = BigDecimal("0.08")
|
||||
)
|
||||
|
||||
val score = service.compute(candidate, session, runId = 99L)
|
||||
|
||||
assertTrue(score.totalScore >= BigDecimal("60"))
|
||||
assertEquals("research-copyability-v1", score.scoreVersion)
|
||||
assertEquals(12, score.sampleTradeCount)
|
||||
assertTrue(score.reason!!.contains("source_fresh=true"))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `compute penalizes stale source and unknown quotes`() {
|
||||
val candidate = LeaderResearchCandidate(
|
||||
id = 1L,
|
||||
normalizedWallet = "0x1111111111111111111111111111111111111111",
|
||||
lastSourceSeenAt = System.currentTimeMillis() - 7L * 24 * 60 * 60 * 1000
|
||||
)
|
||||
val session = LeaderPaperSession(
|
||||
id = 10L,
|
||||
candidateId = 1L,
|
||||
tradeCount = 2,
|
||||
openExposure = BigDecimal("10"),
|
||||
unknownValuationExposure = BigDecimal("8"),
|
||||
filteredRatio = BigDecimal("0.50")
|
||||
)
|
||||
|
||||
val score = service.compute(candidate, session, runId = null)
|
||||
|
||||
assertTrue(score.totalScore < BigDecimal("60"))
|
||||
assertTrue(score.reason!!.contains("source_fresh=false"))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `compute caps small samples below promotion threshold`() {
|
||||
val now = System.currentTimeMillis()
|
||||
val candidate = LeaderResearchCandidate(
|
||||
id = 1L,
|
||||
normalizedWallet = "0x1111111111111111111111111111111111111111",
|
||||
lastSourceSeenAt = now
|
||||
)
|
||||
val session = LeaderPaperSession(
|
||||
id = 10L,
|
||||
candidateId = 1L,
|
||||
startedAt = now - 8L * 24 * 60 * 60 * 1000,
|
||||
tradeCount = 1,
|
||||
openExposure = BigDecimal("1"),
|
||||
copyablePnl = BigDecimal("100"),
|
||||
maxDrawdown = BigDecimal.ZERO,
|
||||
filteredRatio = BigDecimal.ZERO
|
||||
)
|
||||
|
||||
val score = service.compute(candidate, session, runId = null)
|
||||
|
||||
assertTrue(score.totalScore <= BigDecimal("59"))
|
||||
assertTrue(score.reason!!.contains("sample_cap_applied=true"))
|
||||
}
|
||||
|
||||
@Suppress("UNCHECKED_CAST")
|
||||
private inline fun <reified T> mock(): T = org.mockito.Mockito.mock(T::class.java)
|
||||
}
|
||||
+99
@@ -0,0 +1,99 @@
|
||||
package com.wrbug.polymarketbot.service.copytrading.research
|
||||
|
||||
import com.wrbug.polymarketbot.entity.LeaderResearchSourceState
|
||||
import com.wrbug.polymarketbot.enums.LeaderResearchSourceStatus
|
||||
import com.wrbug.polymarketbot.enums.LeaderResearchSourceType
|
||||
import com.wrbug.polymarketbot.repository.LeaderResearchSourceStateRepository
|
||||
import org.junit.jupiter.api.Assertions.assertEquals
|
||||
import org.junit.jupiter.api.Assertions.assertNull
|
||||
import org.junit.jupiter.api.Assertions.assertTrue
|
||||
import org.junit.jupiter.api.Test
|
||||
import org.mockito.Mockito
|
||||
|
||||
class LeaderResearchSourceHealthServiceTest {
|
||||
private val repository: LeaderResearchSourceStateRepository = mock()
|
||||
private val service = LeaderResearchSourceHealthService(repository)
|
||||
|
||||
@Test
|
||||
fun `records disabled websocket capture state`() {
|
||||
Mockito.`when`(repository.findBySourceType(LeaderResearchSourceType.GLOBAL_ACTIVITY_CAPTURE)).thenReturn(null)
|
||||
Mockito.`when`(repository.save(anyState())).thenAnswer { it.arguments[0] }
|
||||
|
||||
val state = service.record(
|
||||
sourceType = LeaderResearchSourceType.GLOBAL_ACTIVITY_CAPTURE,
|
||||
status = LeaderResearchSourceStatus.DISABLED,
|
||||
disabledReason = "Global activity capture is disabled",
|
||||
now = 100
|
||||
)
|
||||
|
||||
assertEquals(LeaderResearchSourceStatus.DISABLED, state.status)
|
||||
assertEquals("Global activity capture is disabled", state.disabledReason)
|
||||
assertEquals(100, state.lastRunAt)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `degraded source keeps failure timestamp and error`() {
|
||||
Mockito.`when`(repository.findBySourceType(LeaderResearchSourceType.ACTIVITY_DERIVED)).thenReturn(null)
|
||||
Mockito.`when`(repository.save(anyState())).thenAnswer { it.arguments[0] }
|
||||
|
||||
val state = service.record(
|
||||
sourceType = LeaderResearchSourceType.ACTIVITY_DERIVED,
|
||||
status = LeaderResearchSourceStatus.DEGRADED,
|
||||
errorClass = "DataApiFailure",
|
||||
errorMessage = "429",
|
||||
now = 200
|
||||
)
|
||||
|
||||
assertEquals(LeaderResearchSourceStatus.DEGRADED, state.status)
|
||||
assertEquals(200, state.lastFailureAt)
|
||||
assertEquals("DataApiFailure", state.errorClass)
|
||||
assertEquals("429", state.errorMessage)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `success clears disabled reason but preserves cursor update`() {
|
||||
val existing = LeaderResearchSourceState(
|
||||
sourceType = LeaderResearchSourceType.GLOBAL_ACTIVITY_CAPTURE,
|
||||
status = LeaderResearchSourceStatus.DISABLED,
|
||||
disabledReason = "disabled",
|
||||
lastCursor = "old"
|
||||
)
|
||||
Mockito.`when`(repository.findBySourceType(LeaderResearchSourceType.GLOBAL_ACTIVITY_CAPTURE)).thenReturn(existing)
|
||||
Mockito.`when`(repository.save(anyState())).thenAnswer { it.arguments[0] }
|
||||
|
||||
val state = service.record(
|
||||
sourceType = LeaderResearchSourceType.GLOBAL_ACTIVITY_CAPTURE,
|
||||
status = LeaderResearchSourceStatus.SUCCESS,
|
||||
candidateCount = 3,
|
||||
lastCursor = "new",
|
||||
now = 300
|
||||
)
|
||||
|
||||
assertEquals(LeaderResearchSourceStatus.SUCCESS, state.status)
|
||||
assertEquals(300, state.lastSuccessAt)
|
||||
assertEquals(3, state.lastCandidateCount)
|
||||
assertEquals("new", state.lastCursor)
|
||||
assertNull(state.disabledReason)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `stale status is flagged stale`() {
|
||||
Mockito.`when`(repository.findBySourceType(LeaderResearchSourceType.ACTIVITY_DERIVED)).thenReturn(null)
|
||||
Mockito.`when`(repository.save(anyState())).thenAnswer { it.arguments[0] }
|
||||
|
||||
val state = service.record(
|
||||
sourceType = LeaderResearchSourceType.ACTIVITY_DERIVED,
|
||||
status = LeaderResearchSourceStatus.STALE
|
||||
)
|
||||
|
||||
assertTrue(state.stale)
|
||||
}
|
||||
|
||||
private fun anyState(): LeaderResearchSourceState {
|
||||
Mockito.any(LeaderResearchSourceState::class.java)
|
||||
return LeaderResearchSourceState(sourceType = LeaderResearchSourceType.ACTIVITY_DERIVED)
|
||||
}
|
||||
|
||||
@Suppress("UNCHECKED_CAST")
|
||||
private inline fun <reified T> mock(): T = Mockito.mock(T::class.java)
|
||||
}
|
||||
+211
@@ -0,0 +1,211 @@
|
||||
package com.wrbug.polymarketbot.service.copytrading.research
|
||||
|
||||
import com.google.gson.Gson
|
||||
import com.wrbug.polymarketbot.api.PolymarketDataApi
|
||||
import com.wrbug.polymarketbot.api.UserActivityResponse
|
||||
import com.wrbug.polymarketbot.entity.Leader
|
||||
import com.wrbug.polymarketbot.entity.LeaderActivityEvent
|
||||
import com.wrbug.polymarketbot.entity.LeaderPool
|
||||
import com.wrbug.polymarketbot.entity.LeaderResearchCandidate
|
||||
import com.wrbug.polymarketbot.entity.SystemConfig
|
||||
import com.wrbug.polymarketbot.enums.LeaderCandidateProvenance
|
||||
import com.wrbug.polymarketbot.enums.LeaderResearchSourceStatus
|
||||
import com.wrbug.polymarketbot.enums.LeaderResearchSourceType
|
||||
import com.wrbug.polymarketbot.repository.LeaderActivityEventRepository
|
||||
import com.wrbug.polymarketbot.repository.LeaderPoolRepository
|
||||
import com.wrbug.polymarketbot.repository.LeaderRepository
|
||||
import com.wrbug.polymarketbot.repository.LeaderResearchCandidateRepository
|
||||
import com.wrbug.polymarketbot.repository.SystemConfigRepository
|
||||
import com.wrbug.polymarketbot.util.RetrofitFactory
|
||||
import kotlinx.coroutines.runBlocking
|
||||
import org.junit.jupiter.api.Assertions.assertEquals
|
||||
import org.junit.jupiter.api.Assertions.assertFalse
|
||||
import org.junit.jupiter.api.Assertions.assertTrue
|
||||
import org.junit.jupiter.api.Test
|
||||
import org.mockito.Mockito
|
||||
import retrofit2.Response
|
||||
|
||||
class LeaderResearchSourceServiceTest {
|
||||
private val candidateRepository: LeaderResearchCandidateRepository = mock()
|
||||
private val leaderRepository: LeaderRepository = mock()
|
||||
private val leaderPoolRepository: LeaderPoolRepository = mock()
|
||||
private val activityEventRepository: LeaderActivityEventRepository = mock()
|
||||
private val sourceHealthService: LeaderResearchSourceHealthService = mock()
|
||||
private val systemConfigRepository: SystemConfigRepository = mock()
|
||||
private val retrofitFactory: RetrofitFactory = mock()
|
||||
private val eventService: LeaderResearchEventService = mock()
|
||||
private val ingestionService = LeaderActivityIngestionService(mock(), Gson())
|
||||
private val dataApi: PolymarketDataApi = mock()
|
||||
|
||||
@Test
|
||||
fun `discover candidates handles empty disabled invalid duplicate existing leader and locked protection`() {
|
||||
val watchWallet = "0x1111111111111111111111111111111111111111"
|
||||
val existingWallet = "0x2222222222222222222222222222222222222222"
|
||||
val activityWallet = "0x3333333333333333333333333333333333333333"
|
||||
val locked = LeaderResearchCandidate(
|
||||
id = 30L,
|
||||
normalizedWallet = activityWallet,
|
||||
source = "manual",
|
||||
provenance = LeaderCandidateProvenance.MANUAL_LOCKED,
|
||||
locked = true,
|
||||
sourceEvidence = "manual note"
|
||||
)
|
||||
stubCommonDataApi(success = true)
|
||||
Mockito.`when`(systemConfigRepository.findByConfigKey(LeaderResearchSourceService.CONFIG_WATCHLIST))
|
||||
.thenReturn(SystemConfig(configKey = LeaderResearchSourceService.CONFIG_WATCHLIST, configValue = "$watchWallet,not-a-wallet,$watchWallet"))
|
||||
Mockito.`when`(leaderRepository.findByLeaderAddress(watchWallet)).thenReturn(null)
|
||||
Mockito.`when`(leaderRepository.findByLeaderAddress(activityWallet)).thenReturn(null)
|
||||
Mockito.`when`(leaderRepository.findAllByOrderByCreatedAtAsc())
|
||||
.thenReturn(listOf(Leader(id = 2L, leaderAddress = existingWallet, leaderName = "known")))
|
||||
Mockito.`when`(leaderPoolRepository.findByLeaderId(2L)).thenReturn(LeaderPool(id = 20L, leaderId = 2L))
|
||||
Mockito.`when`(activityEventRepository.findByUsableForDiscoveryTrueAndEventTimeGreaterThanEqual(Mockito.anyLong()))
|
||||
.thenReturn(listOf(activityEvent(activityWallet), activityEvent(activityWallet)))
|
||||
Mockito.`when`(candidateRepository.findByResearchStateIn(anyResearchStates())).thenReturn(emptyList())
|
||||
Mockito.`when`(candidateRepository.findByNormalizedWallet(watchWallet)).thenReturn(null)
|
||||
Mockito.`when`(candidateRepository.findByNormalizedWallet(existingWallet)).thenReturn(null)
|
||||
Mockito.`when`(candidateRepository.findByNormalizedWallet(activityWallet)).thenReturn(locked)
|
||||
Mockito.`when`(candidateRepository.save(anyCandidate())).thenAnswer {
|
||||
val candidate = it.arguments[0] as LeaderResearchCandidate
|
||||
candidate.copy(id = candidate.id ?: candidate.normalizedWallet.last().digitToInt().toLong())
|
||||
}
|
||||
|
||||
val results = service(globalCaptureEnabled = false).discoverCandidates(runId = 99L)
|
||||
|
||||
assertEquals(5, results.size)
|
||||
assertEquals(1, results.first { it.sourceType == LeaderResearchSourceType.WATCHLIST }.candidates.size)
|
||||
assertEquals(LeaderResearchSourceStatus.DEGRADED, results.first { it.sourceType == LeaderResearchSourceType.ACTIVITY_DERIVED }.status)
|
||||
assertEquals(LeaderResearchSourceStatus.DISABLED, results.first { it.sourceType == LeaderResearchSourceType.GLOBAL_ACTIVITY_CAPTURE }.status)
|
||||
val preserved = results.first { it.sourceType == LeaderResearchSourceType.ACTIVITY_DERIVED }.candidates.single()
|
||||
assertTrue(preserved.locked)
|
||||
assertEquals("manual", preserved.source)
|
||||
assertEquals(LeaderCandidateProvenance.MANUAL_LOCKED, preserved.provenance)
|
||||
assertTrue(preserved.sourceEvidence!!.contains("manual note"))
|
||||
assertTrue(preserved.sourceEvidence!!.contains("leader_activity_event:fresh_count=2"))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `source failure degrades only failing source and preserves other candidates`() {
|
||||
val watchWallet = "0x1111111111111111111111111111111111111111"
|
||||
val existingWallet = "0x2222222222222222222222222222222222222222"
|
||||
val activityWallet = "0x3333333333333333333333333333333333333333"
|
||||
stubCommonDataApi(success = false)
|
||||
Mockito.`when`(systemConfigRepository.findByConfigKey(LeaderResearchSourceService.CONFIG_WATCHLIST))
|
||||
.thenReturn(SystemConfig(configKey = LeaderResearchSourceService.CONFIG_WATCHLIST, configValue = watchWallet))
|
||||
Mockito.`when`(leaderRepository.findAllByOrderByCreatedAtAsc())
|
||||
.thenReturn(listOf(Leader(id = 2L, leaderAddress = existingWallet)))
|
||||
Mockito.`when`(leaderRepository.findByLeaderAddress(Mockito.anyString())).thenReturn(null)
|
||||
Mockito.`when`(activityEventRepository.findByUsableForDiscoveryTrueAndEventTimeGreaterThanEqual(Mockito.anyLong()))
|
||||
.thenReturn(listOf(activityEvent(activityWallet)))
|
||||
Mockito.`when`(candidateRepository.findByResearchStateIn(anyResearchStates()))
|
||||
.thenReturn(listOf(LeaderResearchCandidate(normalizedWallet = activityWallet)))
|
||||
Mockito.`when`(candidateRepository.findByNormalizedWallet(Mockito.anyString())).thenReturn(null)
|
||||
Mockito.`when`(candidateRepository.save(anyCandidate())).thenAnswer { it.arguments[0] }
|
||||
|
||||
val results = service(globalCaptureEnabled = true).discoverCandidates(runId = 99L)
|
||||
|
||||
assertEquals(4, results.size)
|
||||
assertEquals(LeaderResearchSourceStatus.DEGRADED, results.first { it.sourceType == LeaderResearchSourceType.WATCHLIST }.status)
|
||||
assertEquals(LeaderResearchSourceStatus.DEGRADED, results.first { it.sourceType == LeaderResearchSourceType.EXISTING_LEADER }.status)
|
||||
assertEquals(LeaderResearchSourceStatus.DEGRADED, results.first { it.sourceType == LeaderResearchSourceType.ACTIVITY_DERIVED }.status)
|
||||
assertTrue(results.flatMap { it.candidates }.map { it.normalizedWallet }.containsAll(listOf(watchWallet, existingWallet, activityWallet)))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `preview returns source limitation without persisting candidates`() {
|
||||
val watchWallet = "0x1111111111111111111111111111111111111111"
|
||||
val activityWallet = "0x3333333333333333333333333333333333333333"
|
||||
Mockito.`when`(systemConfigRepository.findByConfigKey(LeaderResearchSourceService.CONFIG_WATCHLIST))
|
||||
.thenReturn(SystemConfig(configKey = LeaderResearchSourceService.CONFIG_WATCHLIST, configValue = watchWallet))
|
||||
Mockito.`when`(leaderRepository.findAllByOrderByCreatedAtAsc()).thenReturn(emptyList())
|
||||
Mockito.`when`(leaderRepository.findByLeaderAddress(Mockito.anyString())).thenReturn(null)
|
||||
Mockito.`when`(activityEventRepository.findByUsableForDiscoveryTrueAndEventTimeGreaterThanEqual(Mockito.anyLong()))
|
||||
.thenReturn(listOf(activityEvent(activityWallet)))
|
||||
|
||||
val results = service(globalCaptureEnabled = false).previewCandidates()
|
||||
|
||||
assertEquals(LeaderResearchSourceStatus.DEGRADED, results.first { it.sourceType == LeaderResearchSourceType.ACTIVITY_DERIVED }.status)
|
||||
assertEquals(LeaderResearchSourceStatus.DISABLED, results.first { it.sourceType == LeaderResearchSourceType.GLOBAL_ACTIVITY_CAPTURE }.status)
|
||||
assertFalse(results.flatMap { it.candidates }.isEmpty())
|
||||
Mockito.verify(candidateRepository, Mockito.never()).save(anyCandidate())
|
||||
}
|
||||
|
||||
private fun service(globalCaptureEnabled: Boolean) = LeaderResearchSourceService(
|
||||
candidateRepository = candidateRepository,
|
||||
leaderRepository = leaderRepository,
|
||||
leaderPoolRepository = leaderPoolRepository,
|
||||
activityEventRepository = activityEventRepository,
|
||||
sourceHealthService = sourceHealthService,
|
||||
systemConfigRepository = systemConfigRepository,
|
||||
retrofitFactory = retrofitFactory,
|
||||
eventService = eventService,
|
||||
ingestionService = ingestionService,
|
||||
backfillLimit = 200,
|
||||
globalCaptureEnabled = globalCaptureEnabled
|
||||
)
|
||||
|
||||
private fun stubCommonDataApi(success: Boolean) {
|
||||
Mockito.`when`(retrofitFactory.createDataApi()).thenReturn(dataApi)
|
||||
runBlocking {
|
||||
if (success) {
|
||||
Mockito.`when`(
|
||||
dataApi.getUserActivity(
|
||||
user = Mockito.anyString(),
|
||||
limit = Mockito.anyInt(),
|
||||
offset = Mockito.isNull(),
|
||||
market = Mockito.isNull(),
|
||||
eventId = Mockito.isNull(),
|
||||
type = anyStringList(),
|
||||
start = Mockito.anyLong(),
|
||||
end = Mockito.anyLong(),
|
||||
sortBy = Mockito.anyString(),
|
||||
sortDirection = Mockito.anyString(),
|
||||
side = Mockito.isNull()
|
||||
)
|
||||
).thenReturn(Response.success(emptyList<UserActivityResponse>()))
|
||||
} else {
|
||||
Mockito.`when`(
|
||||
dataApi.getUserActivity(
|
||||
user = Mockito.anyString(),
|
||||
limit = Mockito.anyInt(),
|
||||
offset = Mockito.isNull(),
|
||||
market = Mockito.isNull(),
|
||||
eventId = Mockito.isNull(),
|
||||
type = anyStringList(),
|
||||
start = Mockito.anyLong(),
|
||||
end = Mockito.anyLong(),
|
||||
sortBy = Mockito.anyString(),
|
||||
sortDirection = Mockito.anyString(),
|
||||
side = Mockito.isNull()
|
||||
)
|
||||
).thenThrow(IllegalStateException("timeout"))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun activityEvent(wallet: String) = LeaderActivityEvent(
|
||||
source = "ACTIVITY_DERIVED",
|
||||
stableEventKey = "event-$wallet",
|
||||
normalizedWallet = wallet,
|
||||
eventTime = System.currentTimeMillis(),
|
||||
rawPayloadHash = "hash",
|
||||
usableForDiscovery = true
|
||||
)
|
||||
|
||||
private fun anyCandidate(): LeaderResearchCandidate {
|
||||
Mockito.any(LeaderResearchCandidate::class.java)
|
||||
return LeaderResearchCandidate(normalizedWallet = "0x1111111111111111111111111111111111111111")
|
||||
}
|
||||
|
||||
private fun anyResearchStates(): Collection<com.wrbug.polymarketbot.enums.LeaderResearchState> {
|
||||
Mockito.anyCollection<com.wrbug.polymarketbot.enums.LeaderResearchState>()
|
||||
return emptyList()
|
||||
}
|
||||
|
||||
private fun anyStringList(): List<String> {
|
||||
Mockito.anyList<String>()
|
||||
return emptyList()
|
||||
}
|
||||
|
||||
@Suppress("UNCHECKED_CAST")
|
||||
private inline fun <reified T> mock(): T = Mockito.mock(T::class.java)
|
||||
}
|
||||
+83
@@ -0,0 +1,83 @@
|
||||
package com.wrbug.polymarketbot.service.copytrading.research
|
||||
|
||||
import com.wrbug.polymarketbot.entity.LeaderResearchCandidate
|
||||
import com.wrbug.polymarketbot.enums.LeaderResearchState
|
||||
import com.wrbug.polymarketbot.repository.LeaderPaperSessionRepository
|
||||
import com.wrbug.polymarketbot.repository.LeaderResearchCandidateRepository
|
||||
import org.junit.jupiter.api.Assertions.assertEquals
|
||||
import org.junit.jupiter.api.Test
|
||||
import org.mockito.Mockito
|
||||
|
||||
class LeaderResearchStateMachineTest {
|
||||
private val candidateRepository: LeaderResearchCandidateRepository = mock()
|
||||
private val paperSessionRepository: LeaderPaperSessionRepository = mock()
|
||||
private val paperTradingService: LeaderPaperTradingService = mock()
|
||||
private val poolMappingService: LeaderResearchPoolMappingService = mock()
|
||||
private val eventService: LeaderResearchEventService = mock()
|
||||
private val stateMachine = LeaderResearchStateMachine(
|
||||
candidateRepository,
|
||||
paperSessionRepository,
|
||||
paperTradingService,
|
||||
poolMappingService,
|
||||
eventService
|
||||
)
|
||||
|
||||
@Test
|
||||
fun `fresh discovered agent candidate can bootstrap into candidate for paper observation`() {
|
||||
val candidate = LeaderResearchCandidate(
|
||||
id = 1L,
|
||||
normalizedWallet = "0x1111111111111111111111111111111111111111",
|
||||
researchState = LeaderResearchState.DISCOVERED,
|
||||
lastSourceSeenAt = System.currentTimeMillis(),
|
||||
agentOwned = true
|
||||
)
|
||||
Mockito.`when`(paperSessionRepository.findTopByCandidateIdOrderByStartedAtDesc(1L)).thenReturn(null)
|
||||
Mockito.`when`(candidateRepository.save(anyCandidate())).thenAnswer { it.arguments[0] }
|
||||
Mockito.`when`(poolMappingService.syncCandidate(anyCandidate())).thenAnswer { it.arguments[0] }
|
||||
|
||||
val result = stateMachine.advance(candidate, runId = 99L)
|
||||
|
||||
assertEquals(LeaderResearchState.CANDIDATE, result.researchState)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `locked candidate is not automatically advanced`() {
|
||||
val candidate = LeaderResearchCandidate(
|
||||
id = 1L,
|
||||
normalizedWallet = "0x1111111111111111111111111111111111111111",
|
||||
researchState = LeaderResearchState.DISCOVERED,
|
||||
lastSourceSeenAt = System.currentTimeMillis(),
|
||||
locked = true
|
||||
)
|
||||
val result = stateMachine.advance(candidate, runId = 99L)
|
||||
|
||||
assertEquals(LeaderResearchState.DISCOVERED, result.researchState)
|
||||
Mockito.verify(candidateRepository, Mockito.never()).save(anyCandidate())
|
||||
Mockito.verify(poolMappingService, Mockito.never()).syncCandidate(anyCandidate())
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `unchanged discovered candidate does not sync into leader pool`() {
|
||||
val candidate = LeaderResearchCandidate(
|
||||
id = 1L,
|
||||
normalizedWallet = "0x1111111111111111111111111111111111111111",
|
||||
researchState = LeaderResearchState.DISCOVERED,
|
||||
lastSourceSeenAt = System.currentTimeMillis() - 7L * 24 * 60 * 60 * 1000,
|
||||
agentOwned = false
|
||||
)
|
||||
Mockito.`when`(paperSessionRepository.findTopByCandidateIdOrderByStartedAtDesc(1L)).thenReturn(null)
|
||||
|
||||
val result = stateMachine.advance(candidate, runId = 99L)
|
||||
|
||||
assertEquals(LeaderResearchState.DISCOVERED, result.researchState)
|
||||
Mockito.verify(poolMappingService, Mockito.never()).syncCandidate(anyCandidate())
|
||||
}
|
||||
|
||||
private fun anyCandidate(): LeaderResearchCandidate {
|
||||
Mockito.any(LeaderResearchCandidate::class.java)
|
||||
return LeaderResearchCandidate(normalizedWallet = "0x1111111111111111111111111111111111111111")
|
||||
}
|
||||
|
||||
@Suppress("UNCHECKED_CAST")
|
||||
private inline fun <reified T> mock(): T = Mockito.mock(T::class.java)
|
||||
}
|
||||
@@ -0,0 +1,65 @@
|
||||
# Leader Research Agent
|
||||
|
||||
Leader Research Agent 用来自动发现潜在优秀 leader、做纸上跟单、评分并给出试跟建议。它不会自动启用真钱跟单。
|
||||
|
||||
## 使用方式
|
||||
|
||||
1. 打开「跟单交易 -> Leader 研究」。
|
||||
2. 点击「立即运行研究」拉取 watchlist、已有 Leader 和已持久化 activity-derived 候选。
|
||||
3. 在候选表查看状态、评分、纸跟 PnL、过滤比例和估值状态。
|
||||
4. 只有 `建议试跟` 状态的候选可以点击「创建禁用试跟」。
|
||||
5. 创建后系统只生成 `enabled=false` 的跟单配置;如果你决定真钱跟单,需要到「跟单配置」页手动启用。
|
||||
|
||||
## 研究状态
|
||||
|
||||
- `DISCOVERED`: 已发现,但评分或新鲜度不足。
|
||||
- `CANDIDATE`: 满足基础评分和新鲜度,可以进入纸跟。
|
||||
- `PAPER`: 正在用独立纸跟账本模拟跟单。
|
||||
- `TRIAL_READY`: 纸跟样本、PnL、回撤、未知报价暴露和过滤比例满足阈值。
|
||||
- `COOLDOWN`: 回撤、亏损、来源陈旧或退出流动性风险触发冷却。
|
||||
- `RETIRED`: 多轮冷却或长时间无新鲜来源后淘汰。
|
||||
|
||||
## 评分公式
|
||||
|
||||
当前版本为 `research-copyability-v1`。总分满分 100,分项权重如下:
|
||||
|
||||
- profit signal 20、repeatability 15、liquidity fit 10、entry price fit 10、slippage risk 10。
|
||||
- drawdown risk 10、holding period fit 5、market type risk 5、exit liquidity risk 5、data freshness 5、filter pass rate 5。
|
||||
|
||||
缺数据会保守扣分:没有纸跟 session 或报价 UNKNOWN 时,不会把未知估值当成亏到 0,但会降低 liquidity、slippage、exit liquidity 等分项。样本不足 10 笔时总分 cap 到 59,不能进入 `TRIAL_READY`。
|
||||
|
||||
## 来源限制
|
||||
|
||||
v1 只启用三类来源:
|
||||
|
||||
- watchlist: `system_config.config_key = leader_research.watchlist`,值可以用逗号、空格、换行分隔钱包地址。
|
||||
- existing leader: 已在 Leader 管理里的地址。
|
||||
- activity-derived: `leader_activity_event` 里已经持久化、可归因、较新的活动事件。
|
||||
|
||||
public leaderboard 当前显式禁用,页面会展示 source limitation,避免误以为系统已经能全网发现。
|
||||
|
||||
## 运维与开关
|
||||
|
||||
- 定时任务默认关闭:`leader.research.enabled=false`。
|
||||
- 手动运行不依赖定时开关,可以在「Leader 研究」页面点击「立即运行研究」。
|
||||
- 全局 activity capture 默认关闭:`leader.research.global-capture.enabled=false`。
|
||||
- 全局 capture 写入上限:`leader.research.global-capture.max-writes-per-minute=120`。
|
||||
- Data API bounded backfill 每个钱包默认最多拉取 `leader.research.data-api-backfill.limit=200` 条,最多处理 50 个钱包。
|
||||
- kill switch: 关闭 `leader.research.enabled` 可停止自动推进;关闭 `leader.research.global-capture.enabled` 可停止地址过滤前的全局事件捕获;前端入口可以隐藏但历史研究数据仍可读。
|
||||
- 性能验证脚本:`scripts/leader-research-perf-check.sql` 可在一次性测试库中生成 100 个候选、1 万条 activity event 和 1 万条 paper trade,并对热查询执行 `EXPLAIN`。
|
||||
|
||||
## 排查
|
||||
|
||||
- source health 显示 `DISABLED`: 检查是否是 public leaderboard 或 global capture 未启用。
|
||||
- source health 显示 `FAILURE`: 查看 recent research events 中的 `SOURCE_FAILURE`。
|
||||
- 估值为 `UNKNOWN` 或 `UNAVAILABLE`: 代表无法确认当前市场价格,不会被当成亏到 0。
|
||||
- 重复 activity event: 依赖 `stable_event_key` 和 `leader_paper_trade(session_id, leader_trade_id, side)` 去重。
|
||||
- 重复审批: 同账户同 leader 已有配置时会拒绝,不会创建第二条真钱或试跟配置。
|
||||
|
||||
## 安全边界
|
||||
|
||||
- 研究状态和 Leader Pool 状态分离。
|
||||
- `TRIAL_READY` 只是推荐 badge,不代表真钱跟单已启用。
|
||||
- 审批接口只创建禁用配置,并强制 `enabled=false`。
|
||||
- 纸跟账本使用 `leader_paper_session`、`leader_paper_trade`、`leader_paper_position`,不写入真钱订单跟踪表。
|
||||
- `UNKNOWN` 估值不会被当成 confirmed zero 计入收益。
|
||||
@@ -15,6 +15,7 @@ import AccountDetail from './pages/AccountDetail'
|
||||
import AccountEdit from './pages/AccountEdit'
|
||||
import LeaderList from './pages/LeaderList'
|
||||
import LeaderPool from './pages/LeaderPool'
|
||||
import LeaderResearch from './pages/LeaderResearch'
|
||||
import LeaderAdd from './pages/LeaderAdd'
|
||||
import LeaderEdit from './pages/LeaderEdit'
|
||||
import ConfigPage from './pages/ConfigPage'
|
||||
@@ -267,6 +268,7 @@ function App() {
|
||||
<Route path="/accounts/edit" element={<ProtectedRoute><AccountEdit /></ProtectedRoute>} />
|
||||
<Route path="/leaders" element={<ProtectedRoute><LeaderList /></ProtectedRoute>} />
|
||||
<Route path="/leader-pool" element={<ProtectedRoute><LeaderPool /></ProtectedRoute>} />
|
||||
<Route path="/leader-research" element={<ProtectedRoute><LeaderResearch /></ProtectedRoute>} />
|
||||
<Route path="/leaders/add" element={<ProtectedRoute><LeaderAdd /></ProtectedRoute>} />
|
||||
<Route path="/leaders/edit" element={<ProtectedRoute><LeaderEdit /></ProtectedRoute>} />
|
||||
<Route path="/templates" element={<ProtectedRoute><TemplateList /></ProtectedRoute>} />
|
||||
|
||||
@@ -23,7 +23,8 @@ import {
|
||||
NotificationOutlined,
|
||||
LineChartOutlined,
|
||||
RocketOutlined,
|
||||
DashboardOutlined
|
||||
DashboardOutlined,
|
||||
ExperimentOutlined
|
||||
} from '@ant-design/icons'
|
||||
import type { MenuProps } from 'antd'
|
||||
import type { ReactNode } from 'react'
|
||||
@@ -74,7 +75,7 @@ const Layout: React.FC<LayoutProps> = ({ children }) => {
|
||||
const getInitialOpenKeys = (): string[] => {
|
||||
const path = location.pathname
|
||||
const keys: string[] = []
|
||||
if (path.startsWith('/leaders') || path.startsWith('/leader-pool') || path.startsWith('/templates') || path.startsWith('/copy-trading') || path.startsWith('/backtest')) {
|
||||
if (path.startsWith('/leaders') || path.startsWith('/leader-pool') || path.startsWith('/leader-research') || path.startsWith('/templates') || path.startsWith('/copy-trading') || path.startsWith('/backtest')) {
|
||||
keys.push('/copy-trading-management')
|
||||
}
|
||||
if (path.startsWith('/crypto-tail-strategy') || path.startsWith('/crypto-tail-monitor')) {
|
||||
@@ -92,7 +93,7 @@ const Layout: React.FC<LayoutProps> = ({ children }) => {
|
||||
useEffect(() => {
|
||||
const path = location.pathname
|
||||
const keys: string[] = []
|
||||
if (path.startsWith('/leaders') || path.startsWith('/leader-pool') || path.startsWith('/templates') || path.startsWith('/copy-trading') || path.startsWith('/backtest')) {
|
||||
if (path.startsWith('/leaders') || path.startsWith('/leader-pool') || path.startsWith('/leader-research') || path.startsWith('/templates') || path.startsWith('/copy-trading') || path.startsWith('/backtest')) {
|
||||
keys.push('/copy-trading-management')
|
||||
}
|
||||
if (path.startsWith('/crypto-tail-strategy') || path.startsWith('/crypto-tail-monitor')) {
|
||||
@@ -153,6 +154,11 @@ const Layout: React.FC<LayoutProps> = ({ children }) => {
|
||||
icon: <TeamOutlined />,
|
||||
label: t('menu.leaderPool')
|
||||
},
|
||||
{
|
||||
key: '/leader-research',
|
||||
icon: <ExperimentOutlined />,
|
||||
label: t('menu.leaderResearch')
|
||||
},
|
||||
{
|
||||
key: '/leaders',
|
||||
icon: <UserOutlined />,
|
||||
|
||||
@@ -4,6 +4,9 @@
|
||||
"save": "Save",
|
||||
"cancel": "Cancel",
|
||||
"edit": "Edit",
|
||||
"detail": "Detail",
|
||||
"overview": "Overview",
|
||||
"time": "Time",
|
||||
"viewDetail": "View Details",
|
||||
"delete": "Delete",
|
||||
"add": "Add",
|
||||
@@ -315,6 +318,7 @@
|
||||
"templates": "Templates",
|
||||
"copyTradingConfig": "Copy Trading Config",
|
||||
"leaderPool": "Leader Pool",
|
||||
"leaderResearch": "Leader Research",
|
||||
"cryptoSpreadStrategy": "Crypto Spread Strategy",
|
||||
"cryptoTailStrategy": "Strategy Config",
|
||||
"cryptoTailMonitor": "Real-time Monitor",
|
||||
@@ -623,7 +627,69 @@
|
||||
"goCopyTrading": "View Copy Trading",
|
||||
"removeConfirm": "This only removes the pool item. It will not delete the Leader address or existing copy trading configs. Continue?",
|
||||
"removeSuccess": "Pool item removed",
|
||||
"removeFailed": "Failed to remove pool item"
|
||||
"removeFailed": "Failed to remove pool item",
|
||||
"viewResearch": "View Research"
|
||||
},
|
||||
"leaderResearch": {
|
||||
"title": "Leader Research",
|
||||
"subtitle": "Automatically discover, paper trade, and score strong leaders. Real-money copying still requires your manual approval and enablement.",
|
||||
"safetyTitle": "The research agent never auto-enables real-money copy trading",
|
||||
"safetyDesc": "The agent only advances research state, maintains the paper ledger, and recommends trials. Approval creates a disabled config by default.",
|
||||
"fetchFailed": "Failed to fetch Leader Research data",
|
||||
"runNow": "Run Research",
|
||||
"runStarted": "Research run completed",
|
||||
"runFailed": "Research run failed",
|
||||
"sourceLimitations": "Source Limitations",
|
||||
"sourceHealth": "Source Health",
|
||||
"filterState": "Filter by research state",
|
||||
"searchPlaceholder": "Search wallet or reason",
|
||||
"empty": "No research candidates yet. Configure a watchlist or wait for persisted activity events.",
|
||||
"wallet": "Wallet",
|
||||
"score": "Score",
|
||||
"paper": "Paper",
|
||||
"source": "Source",
|
||||
"lastSeen": "Last Seen",
|
||||
"copyablePnl": "Copyable PnL",
|
||||
"trades": "Trades",
|
||||
"filtered": "Filtered",
|
||||
"candidates": "Candidates",
|
||||
"detailTitle": "Research Detail",
|
||||
"reason": "Reason",
|
||||
"riskFlags": "Risk Flags",
|
||||
"sourceEvidence": "Source Evidence",
|
||||
"scoreBreakdown": "Score Breakdown",
|
||||
"paperTrades": "Paper Trades",
|
||||
"paperPositions": "Paper Positions",
|
||||
"events": "Events",
|
||||
"eventType": "Event Type",
|
||||
"createDisabledTrial": "Create Disabled Trial",
|
||||
"approvalSafetyTitle": "This only creates a disabled config",
|
||||
"approvalSafetyDesc": "You must manually enable it on the copy trading config page before any real-money copy trading happens.",
|
||||
"approvalCreated": "Disabled trial config created",
|
||||
"approvalFailed": "Failed to create disabled trial",
|
||||
"trialReadyHint": "Suggested small trial, awaiting your confirmation",
|
||||
"runStatus": "Run Status",
|
||||
"lastRun": "Last Run",
|
||||
"duration": "Duration",
|
||||
"sourceCounts": "Source Counts",
|
||||
"candidateCounts": "Candidate Counts",
|
||||
"noRuns": "No research runs yet",
|
||||
"pendingDecisions": "Pending Decisions",
|
||||
"noPendingDecisions": "No candidates awaiting approval",
|
||||
"approvalPreview": "Conservative config to be created",
|
||||
"fixedAmount": "Fixed Amount",
|
||||
"maxDailyLoss": "Max Daily Loss",
|
||||
"maxDailyOrders": "Max Daily Orders",
|
||||
"priceRange": "Price Range",
|
||||
"maxPositionValue": "Max Position Value",
|
||||
"states": {
|
||||
"DISCOVERED": "Discovered",
|
||||
"CANDIDATE": "Candidate",
|
||||
"PAPER": "Paper",
|
||||
"TRIAL_READY": "Trial Ready",
|
||||
"COOLDOWN": "Cooldown",
|
||||
"RETIRED": "Retired"
|
||||
}
|
||||
},
|
||||
"leaderAdd": {
|
||||
"title": "Add Leader",
|
||||
|
||||
@@ -7,6 +7,9 @@
|
||||
"later": "稍后",
|
||||
"delete": "删除",
|
||||
"edit": "编辑",
|
||||
"detail": "详情",
|
||||
"overview": "概览",
|
||||
"time": "时间",
|
||||
"viewDetail": "查看详情",
|
||||
"add": "添加",
|
||||
"refresh": "刷新",
|
||||
@@ -315,6 +318,7 @@
|
||||
"templates": "跟单模板",
|
||||
"copyTradingConfig": "跟单配置",
|
||||
"leaderPool": "Leader 池",
|
||||
"leaderResearch": "Leader 研究",
|
||||
"cryptoSpreadStrategy": "加密价差策略",
|
||||
"cryptoTailStrategy": "策略配置",
|
||||
"cryptoTailMonitor": "实时监控",
|
||||
@@ -623,7 +627,69 @@
|
||||
"goCopyTrading": "查看跟单配置",
|
||||
"removeConfirm": "只移除池子项,不会删除 Leader 地址或已有跟单配置。确定继续?",
|
||||
"removeSuccess": "池子项已移除",
|
||||
"removeFailed": "移除池子项失败"
|
||||
"removeFailed": "移除池子项失败",
|
||||
"viewResearch": "查看研究"
|
||||
},
|
||||
"leaderResearch": {
|
||||
"title": "Leader 研究",
|
||||
"subtitle": "自动发现、纸上跟单和评分优秀 leader;真钱跟单必须由你手动审批和启用。",
|
||||
"safetyTitle": "研究 Agent 不会自动真钱跟单",
|
||||
"safetyDesc": "Agent 只会推进研究状态、维护纸跟账本、给出试跟建议。审批时也只创建默认禁用的跟单配置。",
|
||||
"fetchFailed": "获取 Leader 研究数据失败",
|
||||
"runNow": "立即运行研究",
|
||||
"runStarted": "研究运行完成",
|
||||
"runFailed": "运行研究失败",
|
||||
"sourceLimitations": "来源限制",
|
||||
"sourceHealth": "来源健康",
|
||||
"filterState": "按研究状态筛选",
|
||||
"searchPlaceholder": "搜索钱包或原因",
|
||||
"empty": "暂无研究候选。可以先配置 watchlist 或等待 activity 事件持久化。",
|
||||
"wallet": "钱包",
|
||||
"score": "评分",
|
||||
"paper": "纸跟",
|
||||
"source": "来源",
|
||||
"lastSeen": "最后发现",
|
||||
"copyablePnl": "可归因 PnL",
|
||||
"trades": "成交",
|
||||
"filtered": "过滤",
|
||||
"candidates": "候选",
|
||||
"detailTitle": "研究详情",
|
||||
"reason": "原因",
|
||||
"riskFlags": "风险标记",
|
||||
"sourceEvidence": "来源证据",
|
||||
"scoreBreakdown": "评分拆解",
|
||||
"paperTrades": "纸跟交易",
|
||||
"paperPositions": "纸跟仓位",
|
||||
"events": "事件",
|
||||
"eventType": "事件类型",
|
||||
"createDisabledTrial": "创建禁用试跟",
|
||||
"approvalSafetyTitle": "只会创建禁用配置",
|
||||
"approvalSafetyDesc": "创建后你需要到跟单配置页手动启用,才会发生真钱跟单。",
|
||||
"approvalCreated": "已创建禁用试跟配置",
|
||||
"approvalFailed": "创建禁用试跟失败",
|
||||
"trialReadyHint": "建议小额试跟,待你确认",
|
||||
"runStatus": "运行状态",
|
||||
"lastRun": "最近运行",
|
||||
"duration": "耗时",
|
||||
"sourceCounts": "来源统计",
|
||||
"candidateCounts": "候选统计",
|
||||
"noRuns": "暂无运行记录",
|
||||
"pendingDecisions": "待处理决策",
|
||||
"noPendingDecisions": "暂无待审批候选",
|
||||
"approvalPreview": "将创建的保守配置",
|
||||
"fixedAmount": "固定金额",
|
||||
"maxDailyLoss": "每日最大亏损",
|
||||
"maxDailyOrders": "每日最大订单数",
|
||||
"priceRange": "价格范围",
|
||||
"maxPositionValue": "最大仓位金额",
|
||||
"states": {
|
||||
"DISCOVERED": "已发现",
|
||||
"CANDIDATE": "候选",
|
||||
"PAPER": "纸跟中",
|
||||
"TRIAL_READY": "建议试跟",
|
||||
"COOLDOWN": "冷却",
|
||||
"RETIRED": "淘汰"
|
||||
}
|
||||
},
|
||||
"leaderAdd": {
|
||||
"title": "添加 Leader",
|
||||
|
||||
@@ -4,6 +4,9 @@
|
||||
"save": "保存",
|
||||
"cancel": "取消",
|
||||
"edit": "編輯",
|
||||
"detail": "詳情",
|
||||
"overview": "概覽",
|
||||
"time": "時間",
|
||||
"viewDetail": "查看詳情",
|
||||
"delete": "刪除",
|
||||
"add": "添加",
|
||||
@@ -315,6 +318,7 @@
|
||||
"templates": "跟單模板",
|
||||
"copyTradingConfig": "跟單配置",
|
||||
"leaderPool": "Leader 池",
|
||||
"leaderResearch": "Leader 研究",
|
||||
"cryptoSpreadStrategy": "加密價差策略",
|
||||
"cryptoTailStrategy": "策略配置",
|
||||
"cryptoTailMonitor": "即時監控",
|
||||
@@ -623,7 +627,69 @@
|
||||
"goCopyTrading": "查看跟單配置",
|
||||
"removeConfirm": "只移除池子項,不會刪除 Leader 地址或已有跟單配置。確定繼續?",
|
||||
"removeSuccess": "池子項已移除",
|
||||
"removeFailed": "移除池子項失敗"
|
||||
"removeFailed": "移除池子項失敗",
|
||||
"viewResearch": "查看研究"
|
||||
},
|
||||
"leaderResearch": {
|
||||
"title": "Leader 研究",
|
||||
"subtitle": "自動發現、紙上跟單和評分優秀 leader;真錢跟單必須由你手動審批和啟用。",
|
||||
"safetyTitle": "研究 Agent 不會自動真錢跟單",
|
||||
"safetyDesc": "Agent 只會推進研究狀態、維護紙跟帳本、給出試跟建議。審批時也只建立預設停用的跟單配置。",
|
||||
"fetchFailed": "取得 Leader 研究資料失敗",
|
||||
"runNow": "立即運行研究",
|
||||
"runStarted": "研究運行完成",
|
||||
"runFailed": "運行研究失敗",
|
||||
"sourceLimitations": "來源限制",
|
||||
"sourceHealth": "來源健康",
|
||||
"filterState": "按研究狀態篩選",
|
||||
"searchPlaceholder": "搜尋錢包或原因",
|
||||
"empty": "暫無研究候選。可以先配置 watchlist 或等待 activity 事件持久化。",
|
||||
"wallet": "錢包",
|
||||
"score": "評分",
|
||||
"paper": "紙跟",
|
||||
"source": "來源",
|
||||
"lastSeen": "最後發現",
|
||||
"copyablePnl": "可歸因 PnL",
|
||||
"trades": "成交",
|
||||
"filtered": "過濾",
|
||||
"candidates": "候選",
|
||||
"detailTitle": "研究詳情",
|
||||
"reason": "原因",
|
||||
"riskFlags": "風險標記",
|
||||
"sourceEvidence": "來源證據",
|
||||
"scoreBreakdown": "評分拆解",
|
||||
"paperTrades": "紙跟交易",
|
||||
"paperPositions": "紙跟倉位",
|
||||
"events": "事件",
|
||||
"eventType": "事件類型",
|
||||
"createDisabledTrial": "建立停用試跟",
|
||||
"approvalSafetyTitle": "只會建立停用配置",
|
||||
"approvalSafetyDesc": "建立後你需要到跟單配置頁手動啟用,才會發生真錢跟單。",
|
||||
"approvalCreated": "已建立停用試跟配置",
|
||||
"approvalFailed": "建立停用試跟失敗",
|
||||
"trialReadyHint": "建議小額試跟,待你確認",
|
||||
"runStatus": "運行狀態",
|
||||
"lastRun": "最近運行",
|
||||
"duration": "耗時",
|
||||
"sourceCounts": "來源統計",
|
||||
"candidateCounts": "候選統計",
|
||||
"noRuns": "暫無運行記錄",
|
||||
"pendingDecisions": "待處理決策",
|
||||
"noPendingDecisions": "暫無待審批候選",
|
||||
"approvalPreview": "將建立的保守配置",
|
||||
"fixedAmount": "固定金額",
|
||||
"maxDailyLoss": "每日最大虧損",
|
||||
"maxDailyOrders": "每日最大訂單數",
|
||||
"priceRange": "價格範圍",
|
||||
"maxPositionValue": "最大倉位金額",
|
||||
"states": {
|
||||
"DISCOVERED": "已發現",
|
||||
"CANDIDATE": "候選",
|
||||
"PAPER": "紙跟中",
|
||||
"TRIAL_READY": "建議試跟",
|
||||
"COOLDOWN": "冷卻",
|
||||
"RETIRED": "淘汰"
|
||||
}
|
||||
},
|
||||
"leaderAdd": {
|
||||
"title": "添加 Leader",
|
||||
|
||||
@@ -269,6 +269,17 @@ const LeaderPool: React.FC = () => {
|
||||
<Text copyable style={{ fontSize: 12, fontFamily: 'monospace' }} type="secondary">
|
||||
{item.leaderAddress}
|
||||
</Text>
|
||||
{item.researchBadge && (
|
||||
<Space size={4}>
|
||||
<Tag color={item.researchState === 'TRIAL_READY' ? 'green' : 'blue'}>{t(`leaderResearch.states.${item.researchState}`, { defaultValue: item.researchState })}</Tag>
|
||||
{item.researchScore && <Text type="secondary">{t('leaderResearch.score')}: {item.researchScore}</Text>}
|
||||
</Space>
|
||||
)}
|
||||
{item.researchCandidateId && (
|
||||
<Button type="link" size="small" style={{ padding: 0 }} onClick={() => navigate('/leader-research')}>
|
||||
{t('leaderPool.viewResearch')}
|
||||
</Button>
|
||||
)}
|
||||
</Space>
|
||||
)
|
||||
},
|
||||
|
||||
@@ -0,0 +1,577 @@
|
||||
import { useEffect, useState } from 'react'
|
||||
import {
|
||||
Alert,
|
||||
Badge,
|
||||
Button,
|
||||
Card,
|
||||
Col,
|
||||
Descriptions,
|
||||
Drawer,
|
||||
Empty,
|
||||
Form,
|
||||
Input,
|
||||
Modal,
|
||||
Row,
|
||||
Select,
|
||||
Space,
|
||||
Statistic,
|
||||
Table,
|
||||
Tabs,
|
||||
Tag,
|
||||
Typography,
|
||||
message
|
||||
} from 'antd'
|
||||
import {
|
||||
ExperimentOutlined,
|
||||
PlayCircleOutlined,
|
||||
ReloadOutlined,
|
||||
SafetyCertificateOutlined
|
||||
} from '@ant-design/icons'
|
||||
import dayjs from 'dayjs'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { apiService } from '../services/api'
|
||||
import type {
|
||||
Account,
|
||||
LeaderPaperPosition,
|
||||
LeaderPaperTrade,
|
||||
LeaderResearchCandidate,
|
||||
LeaderResearchCandidateDetail,
|
||||
LeaderResearchCandidateListResponse,
|
||||
LeaderResearchSourceState,
|
||||
LeaderResearchState,
|
||||
LeaderResearchSummary
|
||||
} from '../types'
|
||||
|
||||
const { Paragraph, Text, Title } = Typography
|
||||
|
||||
const STATE_COLORS: Record<LeaderResearchState, string> = {
|
||||
DISCOVERED: 'default',
|
||||
CANDIDATE: 'blue',
|
||||
PAPER: 'geekblue',
|
||||
TRIAL_READY: 'green',
|
||||
COOLDOWN: 'orange',
|
||||
RETIRED: 'red'
|
||||
}
|
||||
|
||||
const VALUATION_COLORS: Record<string, string> = {
|
||||
AVAILABLE: 'green',
|
||||
CONFIRMED_ZERO: 'purple',
|
||||
UNKNOWN: 'orange',
|
||||
UNAVAILABLE: 'red',
|
||||
NO_MATCH: 'volcano'
|
||||
}
|
||||
|
||||
const formatDate = (timestamp?: number) => {
|
||||
if (!timestamp) return '-'
|
||||
return dayjs(timestamp).format('YYYY-MM-DD HH:mm')
|
||||
}
|
||||
|
||||
const usdc = (value?: string) => value ? `${value} USDC` : '-'
|
||||
|
||||
const approvalPreview = (candidate?: LeaderResearchCandidate | null) => ({
|
||||
fixedAmount: usdc(candidate?.suggestedFixedAmount),
|
||||
maxDailyLoss: usdc(candidate?.suggestedMaxDailyLoss),
|
||||
maxDailyOrders: candidate?.suggestedMaxDailyOrders ?? '-',
|
||||
priceRange: candidate?.suggestedMinPrice || candidate?.suggestedMaxPrice
|
||||
? `${candidate?.suggestedMinPrice ?? '-'} - ${candidate?.suggestedMaxPrice ?? '-'}`
|
||||
: '-',
|
||||
maxPositionValue: usdc(candidate?.suggestedMaxPositionValue)
|
||||
})
|
||||
|
||||
const valuationTag = (status?: string) => {
|
||||
if (!status) return <Tag>-</Tag>
|
||||
return <Tag color={VALUATION_COLORS[status] || 'default'}>{status}</Tag>
|
||||
}
|
||||
|
||||
const LeaderResearch: React.FC = () => {
|
||||
const { t } = useTranslation()
|
||||
const [summary, setSummary] = useState<LeaderResearchSummary | null>(null)
|
||||
const [candidates, setCandidates] = useState<LeaderResearchCandidateListResponse>({ list: [], total: 0, summary: summaryFallback })
|
||||
const [sourceHealth, setSourceHealth] = useState<LeaderResearchSourceState[]>([])
|
||||
const [accounts, setAccounts] = useState<Account[]>([])
|
||||
const [stateFilter, setStateFilter] = useState<LeaderResearchState | undefined>()
|
||||
const [query, setQuery] = useState('')
|
||||
const [loading, setLoading] = useState(false)
|
||||
const [running, setRunning] = useState(false)
|
||||
const [detailLoading, setDetailLoading] = useState(false)
|
||||
const [detail, setDetail] = useState<LeaderResearchCandidateDetail | null>(null)
|
||||
const [approvalCandidate, setApprovalCandidate] = useState<LeaderResearchCandidate | null>(null)
|
||||
const [approvalLoading, setApprovalLoading] = useState(false)
|
||||
const [approvalForm] = Form.useForm()
|
||||
|
||||
const loadAll = async () => {
|
||||
setLoading(true)
|
||||
try {
|
||||
const [candidateResp, summaryResp, sourceResp, accountResp] = await Promise.all([
|
||||
apiService.leaderResearch.listCandidates({ page: 0, size: 50, state: stateFilter, query: query || undefined }),
|
||||
apiService.leaderResearch.summary(),
|
||||
apiService.leaderResearch.sourceHealth(),
|
||||
apiService.accounts.list()
|
||||
])
|
||||
if (candidateResp.data.code === 0 && candidateResp.data.data) {
|
||||
setCandidates(candidateResp.data.data)
|
||||
} else {
|
||||
message.error(candidateResp.data.msg || t('leaderResearch.fetchFailed'))
|
||||
}
|
||||
if (summaryResp.data.code === 0 && summaryResp.data.data) {
|
||||
setSummary(summaryResp.data.data)
|
||||
}
|
||||
if (sourceResp.data.code === 0 && sourceResp.data.data) {
|
||||
setSourceHealth(sourceResp.data.data)
|
||||
}
|
||||
if (accountResp.data.code === 0 && accountResp.data.data) {
|
||||
setAccounts(accountResp.data.data.list || [])
|
||||
}
|
||||
} catch (error: any) {
|
||||
message.error(error.message || t('leaderResearch.fetchFailed'))
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
loadAll()
|
||||
}, [stateFilter])
|
||||
|
||||
const runAgent = async () => {
|
||||
setRunning(true)
|
||||
try {
|
||||
const response = await apiService.leaderResearch.run({ dryRun: false, triggerType: 'MANUAL' })
|
||||
if (response.data.code === 0) {
|
||||
message.success(t('leaderResearch.runStarted'))
|
||||
await loadAll()
|
||||
} else {
|
||||
message.error(response.data.msg || t('leaderResearch.runFailed'))
|
||||
}
|
||||
} catch (error: any) {
|
||||
message.error(error.message || t('leaderResearch.runFailed'))
|
||||
} finally {
|
||||
setRunning(false)
|
||||
}
|
||||
}
|
||||
|
||||
const openDetail = async (candidate: LeaderResearchCandidate) => {
|
||||
setDetailLoading(true)
|
||||
try {
|
||||
const response = await apiService.leaderResearch.detail({ candidateId: candidate.id })
|
||||
if (response.data.code === 0 && response.data.data) {
|
||||
setDetail(response.data.data)
|
||||
} else {
|
||||
message.error(response.data.msg || t('leaderResearch.fetchFailed'))
|
||||
}
|
||||
} finally {
|
||||
setDetailLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
const openApproval = (candidate: LeaderResearchCandidate) => {
|
||||
setApprovalCandidate(candidate)
|
||||
approvalForm.setFieldsValue({ accountId: accounts[0]?.id })
|
||||
}
|
||||
|
||||
const submitApproval = async () => {
|
||||
if (!approvalCandidate) return
|
||||
const values = await approvalForm.validateFields()
|
||||
setApprovalLoading(true)
|
||||
try {
|
||||
const response = await apiService.leaderResearch.createDisabledTrialConfig({
|
||||
candidateId: approvalCandidate.id,
|
||||
accountId: values.accountId,
|
||||
confirm: true
|
||||
})
|
||||
if (response.data.code === 0) {
|
||||
message.success(t('leaderResearch.approvalCreated'))
|
||||
setApprovalCandidate(null)
|
||||
await loadAll()
|
||||
} else {
|
||||
message.error(response.data.msg || t('leaderResearch.approvalFailed'))
|
||||
}
|
||||
} catch (error: any) {
|
||||
message.error(error.message || t('leaderResearch.approvalFailed'))
|
||||
} finally {
|
||||
setApprovalLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
const activeSummary = summary || candidates.summary || summaryFallback
|
||||
const pendingDecisions = candidates.list.filter(candidate => candidate.researchState === 'TRIAL_READY')
|
||||
const lastRun = activeSummary.lastRun
|
||||
const activeApprovalPreview = approvalPreview(approvalCandidate)
|
||||
|
||||
const columns = [
|
||||
{
|
||||
title: t('leaderResearch.wallet'),
|
||||
key: 'wallet',
|
||||
width: 260,
|
||||
render: (_: unknown, item: LeaderResearchCandidate) => (
|
||||
<Space direction="vertical" size={0}>
|
||||
<Text strong>{item.leaderName || item.normalizedWallet.slice(0, 10)}</Text>
|
||||
<Text copyable type="secondary" style={{ fontSize: 12, fontFamily: 'monospace' }}>
|
||||
{item.normalizedWallet}
|
||||
</Text>
|
||||
</Space>
|
||||
)
|
||||
},
|
||||
{
|
||||
title: t('common.status'),
|
||||
dataIndex: 'researchState',
|
||||
width: 130,
|
||||
render: (state: LeaderResearchState) => (
|
||||
<Space direction="vertical" size={0}>
|
||||
<Tag color={STATE_COLORS[state]}>{t(`leaderResearch.states.${state}`, { defaultValue: state })}</Tag>
|
||||
{state === 'TRIAL_READY' && (
|
||||
<Text type="secondary" style={{ fontSize: 12 }}>{t('leaderResearch.trialReadyHint')}</Text>
|
||||
)}
|
||||
</Space>
|
||||
)
|
||||
},
|
||||
{
|
||||
title: t('leaderResearch.score'),
|
||||
dataIndex: 'score',
|
||||
width: 100,
|
||||
render: (score?: string) => <Text strong>{score || '-'}</Text>
|
||||
},
|
||||
{
|
||||
title: t('leaderResearch.paper'),
|
||||
key: 'paper',
|
||||
width: 220,
|
||||
render: (_: unknown, item: LeaderResearchCandidate) => {
|
||||
const session = item.latestPaperSession
|
||||
if (!session) return <Text type="secondary">-</Text>
|
||||
return (
|
||||
<Space direction="vertical" size={0}>
|
||||
<Text>{t('leaderResearch.copyablePnl')}: {session.copyablePnl}</Text>
|
||||
<Text type="secondary">{t('leaderResearch.trades')}: {session.tradeCount} / {t('leaderResearch.filtered')}: {session.filteredCount}</Text>
|
||||
</Space>
|
||||
)
|
||||
}
|
||||
},
|
||||
{
|
||||
title: t('leaderResearch.source'),
|
||||
dataIndex: 'source',
|
||||
width: 160
|
||||
},
|
||||
{
|
||||
title: t('leaderResearch.lastSeen'),
|
||||
dataIndex: 'lastSourceSeenAt',
|
||||
width: 160,
|
||||
render: (value?: number) => formatDate(value)
|
||||
},
|
||||
{
|
||||
title: t('common.actions'),
|
||||
key: 'actions',
|
||||
fixed: 'right' as const,
|
||||
width: 230,
|
||||
render: (_: unknown, item: LeaderResearchCandidate) => (
|
||||
<Space wrap>
|
||||
<Button size="small" onClick={() => openDetail(item)}>
|
||||
{t('common.detail')}
|
||||
</Button>
|
||||
<Button
|
||||
size="small"
|
||||
type="primary"
|
||||
icon={<SafetyCertificateOutlined />}
|
||||
disabled={item.researchState !== 'TRIAL_READY'}
|
||||
onClick={() => openApproval(item)}
|
||||
>
|
||||
{t('leaderResearch.createDisabledTrial')}
|
||||
</Button>
|
||||
</Space>
|
||||
)
|
||||
}
|
||||
]
|
||||
|
||||
return (
|
||||
<Space direction="vertical" size="large" style={{ width: '100%' }}>
|
||||
<Card>
|
||||
<Space direction="vertical" style={{ width: '100%' }}>
|
||||
<Space align="start" style={{ justifyContent: 'space-between', width: '100%' }}>
|
||||
<div>
|
||||
<Title level={3} style={{ marginBottom: 4 }}>{t('leaderResearch.title')}</Title>
|
||||
<Paragraph type="secondary" style={{ marginBottom: 0 }}>{t('leaderResearch.subtitle')}</Paragraph>
|
||||
</div>
|
||||
<Space>
|
||||
<Button icon={<ReloadOutlined />} onClick={loadAll}>{t('common.refresh')}</Button>
|
||||
<Button type="primary" icon={<PlayCircleOutlined />} loading={running} onClick={runAgent}>
|
||||
{t('leaderResearch.runNow')}
|
||||
</Button>
|
||||
</Space>
|
||||
</Space>
|
||||
<Alert
|
||||
type="info"
|
||||
showIcon
|
||||
icon={<ExperimentOutlined />}
|
||||
message={t('leaderResearch.safetyTitle')}
|
||||
description={t('leaderResearch.safetyDesc')}
|
||||
/>
|
||||
{activeSummary.sourceLimitations?.length > 0 && (
|
||||
<Alert
|
||||
type="warning"
|
||||
showIcon
|
||||
message={t('leaderResearch.sourceLimitations')}
|
||||
description={activeSummary.sourceLimitations.join(' | ')}
|
||||
/>
|
||||
)}
|
||||
</Space>
|
||||
</Card>
|
||||
|
||||
<Row gutter={[16, 16]}>
|
||||
<Col xs={24} sm={12} lg={4}><Card><Statistic title={t('leaderResearch.states.DISCOVERED')} value={activeSummary.discoveredCount} /></Card></Col>
|
||||
<Col xs={24} sm={12} lg={4}><Card><Statistic title={t('leaderResearch.states.CANDIDATE')} value={activeSummary.candidateCount} /></Card></Col>
|
||||
<Col xs={24} sm={12} lg={4}><Card><Statistic title={t('leaderResearch.states.PAPER')} value={activeSummary.paperCount} /></Card></Col>
|
||||
<Col xs={24} sm={12} lg={4}><Card><Statistic title={t('leaderResearch.states.TRIAL_READY')} value={activeSummary.trialReadyCount} /></Card></Col>
|
||||
<Col xs={24} sm={12} lg={4}><Card><Statistic title={t('leaderResearch.states.COOLDOWN')} value={activeSummary.cooldownCount} /></Card></Col>
|
||||
<Col xs={24} sm={12} lg={4}><Card><Statistic title={t('leaderResearch.states.RETIRED')} value={activeSummary.retiredCount} /></Card></Col>
|
||||
</Row>
|
||||
|
||||
<Row gutter={[16, 16]}>
|
||||
<Col xs={24} lg={12}>
|
||||
<Card title={t('leaderResearch.runStatus')}>
|
||||
{lastRun ? (
|
||||
<Descriptions size="small" column={1}>
|
||||
<Descriptions.Item label={t('common.status')}>
|
||||
<Tag color={lastRun.partialFailure ? 'orange' : lastRun.status === 'SUCCESS' ? 'green' : 'default'}>{lastRun.status}</Tag>
|
||||
</Descriptions.Item>
|
||||
<Descriptions.Item label={t('leaderResearch.lastRun')}>{formatDate(lastRun.startedAt)}</Descriptions.Item>
|
||||
<Descriptions.Item label={t('leaderResearch.duration')}>{lastRun.durationMs ?? '-'} ms</Descriptions.Item>
|
||||
<Descriptions.Item label={t('leaderResearch.sourceCounts')}>{lastRun.sourceCountsJson || '-'}</Descriptions.Item>
|
||||
<Descriptions.Item label={t('leaderResearch.candidateCounts')}>{lastRun.candidateCountsJson || '-'}</Descriptions.Item>
|
||||
{(lastRun.errorMessage || lastRun.skippedReason) && (
|
||||
<Descriptions.Item label={t('leaderResearch.reason')}>{lastRun.errorMessage || lastRun.skippedReason}</Descriptions.Item>
|
||||
)}
|
||||
</Descriptions>
|
||||
) : (
|
||||
<Empty image={Empty.PRESENTED_IMAGE_SIMPLE} description={t('leaderResearch.noRuns')} />
|
||||
)}
|
||||
</Card>
|
||||
</Col>
|
||||
<Col xs={24} lg={12}>
|
||||
<Card title={t('leaderResearch.pendingDecisions')}>
|
||||
{pendingDecisions.length > 0 ? (
|
||||
<Space direction="vertical" style={{ width: '100%' }}>
|
||||
{pendingDecisions.slice(0, 5).map(candidate => (
|
||||
<Card key={candidate.id} size="small">
|
||||
<Space style={{ justifyContent: 'space-between', width: '100%' }} wrap>
|
||||
<Space direction="vertical" size={0}>
|
||||
<Text strong>{candidate.leaderName || candidate.normalizedWallet.slice(0, 10)}</Text>
|
||||
<Text type="secondary">{t('leaderResearch.trialReadyHint')}</Text>
|
||||
</Space>
|
||||
<Button size="small" type="primary" loading={approvalLoading && approvalCandidate?.id === candidate.id} onClick={() => openApproval(candidate)}>
|
||||
{t('leaderResearch.createDisabledTrial')}
|
||||
</Button>
|
||||
</Space>
|
||||
</Card>
|
||||
))}
|
||||
</Space>
|
||||
) : (
|
||||
<Empty image={Empty.PRESENTED_IMAGE_SIMPLE} description={t('leaderResearch.noPendingDecisions')} />
|
||||
)}
|
||||
</Card>
|
||||
</Col>
|
||||
</Row>
|
||||
|
||||
<Card>
|
||||
<Space direction="vertical" size="middle" style={{ width: '100%' }}>
|
||||
<Space wrap>
|
||||
<Select
|
||||
allowClear
|
||||
style={{ width: 220 }}
|
||||
placeholder={t('leaderResearch.filterState')}
|
||||
value={stateFilter}
|
||||
onChange={setStateFilter}
|
||||
options={Object.keys(STATE_COLORS).map(state => ({
|
||||
value: state,
|
||||
label: t(`leaderResearch.states.${state}`, { defaultValue: state })
|
||||
}))}
|
||||
/>
|
||||
<Input.Search
|
||||
allowClear
|
||||
style={{ width: 320 }}
|
||||
placeholder={t('leaderResearch.searchPlaceholder')}
|
||||
value={query}
|
||||
onChange={event => setQuery(event.target.value)}
|
||||
onSearch={loadAll}
|
||||
/>
|
||||
</Space>
|
||||
<Table
|
||||
rowKey="id"
|
||||
loading={loading}
|
||||
columns={columns}
|
||||
dataSource={candidates.list}
|
||||
scroll={{ x: 1300 }}
|
||||
locale={{ emptyText: <Empty image={Empty.PRESENTED_IMAGE_SIMPLE} description={t('leaderResearch.empty')} /> }}
|
||||
/>
|
||||
</Space>
|
||||
</Card>
|
||||
|
||||
<Card title={t('leaderResearch.sourceHealth')}>
|
||||
<Row gutter={[12, 12]}>
|
||||
{sourceHealth.map(source => (
|
||||
<Col xs={24} md={12} lg={6} key={source.sourceType}>
|
||||
<Card size="small">
|
||||
<Space direction="vertical" size={4}>
|
||||
<Badge status={source.status === 'SUCCESS' ? 'success' : source.status === 'DISABLED' ? 'default' : 'warning'} text={source.sourceType} />
|
||||
<Tag>{source.status}</Tag>
|
||||
<Text type="secondary">{t('leaderResearch.candidates')}: {source.lastCandidateCount}</Text>
|
||||
<Text type="secondary">{formatDate(source.lastRunAt)}</Text>
|
||||
{(source.disabledReason || source.errorMessage) && <Text type="secondary">{source.disabledReason || source.errorMessage}</Text>}
|
||||
</Space>
|
||||
</Card>
|
||||
</Col>
|
||||
))}
|
||||
</Row>
|
||||
</Card>
|
||||
|
||||
<Drawer
|
||||
width={880}
|
||||
open={!!detail}
|
||||
title={t('leaderResearch.detailTitle')}
|
||||
onClose={() => setDetail(null)}
|
||||
loading={detailLoading}
|
||||
>
|
||||
{detail && (
|
||||
<Tabs
|
||||
items={[
|
||||
{
|
||||
key: 'overview',
|
||||
label: t('common.overview'),
|
||||
children: (
|
||||
<Space direction="vertical" style={{ width: '100%' }}>
|
||||
<Descriptions bordered column={1} size="small">
|
||||
<Descriptions.Item label={t('leaderResearch.wallet')}>{detail.candidate.normalizedWallet}</Descriptions.Item>
|
||||
<Descriptions.Item label={t('common.status')}>{detail.candidate.researchState}</Descriptions.Item>
|
||||
<Descriptions.Item label={t('leaderResearch.score')}>{detail.candidate.score || '-'}</Descriptions.Item>
|
||||
<Descriptions.Item label={t('leaderResearch.reason')}>{detail.candidate.reason || '-'}</Descriptions.Item>
|
||||
<Descriptions.Item label={t('leaderResearch.riskFlags')}>{detail.candidate.riskFlags.join(', ') || '-'}</Descriptions.Item>
|
||||
<Descriptions.Item label={t('leaderResearch.sourceEvidence')}>{detail.candidate.sourceEvidence || '-'}</Descriptions.Item>
|
||||
</Descriptions>
|
||||
{detail.latestScore && (
|
||||
<Descriptions bordered size="small" column={2} title={t('leaderResearch.scoreBreakdown')}>
|
||||
<Descriptions.Item label="profit">{detail.latestScore.profitSignal}</Descriptions.Item>
|
||||
<Descriptions.Item label="repeatability">{detail.latestScore.repeatability}</Descriptions.Item>
|
||||
<Descriptions.Item label="liquidity">{detail.latestScore.liquidityFit}</Descriptions.Item>
|
||||
<Descriptions.Item label="entry">{detail.latestScore.entryPriceFit}</Descriptions.Item>
|
||||
<Descriptions.Item label="slippage">{detail.latestScore.slippageRisk}</Descriptions.Item>
|
||||
<Descriptions.Item label="drawdown">{detail.latestScore.drawdownRisk}</Descriptions.Item>
|
||||
</Descriptions>
|
||||
)}
|
||||
</Space>
|
||||
)
|
||||
},
|
||||
{
|
||||
key: 'trades',
|
||||
label: t('leaderResearch.paperTrades'),
|
||||
children: <PaperTradeTable trades={detail.paperTrades} />
|
||||
},
|
||||
{
|
||||
key: 'positions',
|
||||
label: t('leaderResearch.paperPositions'),
|
||||
children: <PaperPositionTable positions={detail.paperPositions} />
|
||||
},
|
||||
{
|
||||
key: 'events',
|
||||
label: t('leaderResearch.events'),
|
||||
children: (
|
||||
<Table
|
||||
rowKey="id"
|
||||
size="small"
|
||||
dataSource={detail.events}
|
||||
columns={[
|
||||
{ title: t('common.time'), dataIndex: 'createdAt', render: formatDate },
|
||||
{ title: t('leaderResearch.eventType'), dataIndex: 'eventType' },
|
||||
{ title: t('leaderResearch.reason'), dataIndex: 'reason' }
|
||||
]}
|
||||
/>
|
||||
)
|
||||
}
|
||||
]}
|
||||
/>
|
||||
)}
|
||||
</Drawer>
|
||||
|
||||
<Modal
|
||||
open={!!approvalCandidate}
|
||||
title={t('leaderResearch.createDisabledTrial')}
|
||||
onCancel={() => setApprovalCandidate(null)}
|
||||
onOk={submitApproval}
|
||||
confirmLoading={approvalLoading}
|
||||
>
|
||||
<Space direction="vertical" style={{ width: '100%' }}>
|
||||
<Alert
|
||||
type="warning"
|
||||
showIcon
|
||||
message={t('leaderResearch.approvalSafetyTitle')}
|
||||
description={t('leaderResearch.approvalSafetyDesc')}
|
||||
/>
|
||||
<Form form={approvalForm} layout="vertical">
|
||||
<Descriptions bordered size="small" column={1} title={t('leaderResearch.approvalPreview')}>
|
||||
<Descriptions.Item label={t('leaderResearch.fixedAmount')}>{activeApprovalPreview.fixedAmount}</Descriptions.Item>
|
||||
<Descriptions.Item label={t('leaderResearch.maxDailyLoss')}>{activeApprovalPreview.maxDailyLoss}</Descriptions.Item>
|
||||
<Descriptions.Item label={t('leaderResearch.maxDailyOrders')}>{activeApprovalPreview.maxDailyOrders}</Descriptions.Item>
|
||||
<Descriptions.Item label={t('leaderResearch.priceRange')}>{activeApprovalPreview.priceRange}</Descriptions.Item>
|
||||
<Descriptions.Item label={t('leaderResearch.maxPositionValue')}>{activeApprovalPreview.maxPositionValue}</Descriptions.Item>
|
||||
</Descriptions>
|
||||
<Form.Item name="accountId" label={t('leaderPool.account')} rules={[{ required: true, message: t('leaderPool.selectAccount') }]}>
|
||||
<Select
|
||||
options={accounts.map(account => ({
|
||||
value: account.id,
|
||||
label: `${account.accountName || account.walletAddress} (${account.proxyAddress?.slice(0, 8)}...)`
|
||||
}))}
|
||||
/>
|
||||
</Form.Item>
|
||||
</Form>
|
||||
</Space>
|
||||
</Modal>
|
||||
</Space>
|
||||
)
|
||||
}
|
||||
|
||||
const PaperTradeTable: React.FC<{ trades: LeaderPaperTrade[] }> = ({ trades }) => (
|
||||
<Table
|
||||
rowKey="id"
|
||||
size="small"
|
||||
dataSource={trades}
|
||||
columns={[
|
||||
{ title: 'Time', dataIndex: 'eventTime', render: formatDate },
|
||||
{ title: 'Side', dataIndex: 'side' },
|
||||
{ title: 'Market', dataIndex: 'marketTitle', render: (value?: string, item?: LeaderPaperTrade) => value || item?.marketId },
|
||||
{ title: 'Leader Price', dataIndex: 'leaderPrice' },
|
||||
{ title: 'Sim Amount', dataIndex: 'simulatedAmount' },
|
||||
{ title: 'Filter', dataIndex: 'filterResult' },
|
||||
{ title: 'Quote', dataIndex: 'quoteConfidence' },
|
||||
{ title: 'Valuation', dataIndex: 'valuationStatus', render: valuationTag }
|
||||
]}
|
||||
/>
|
||||
)
|
||||
|
||||
const PaperPositionTable: React.FC<{ positions: LeaderPaperPosition[] }> = ({ positions }) => (
|
||||
<Table
|
||||
rowKey="id"
|
||||
size="small"
|
||||
dataSource={positions}
|
||||
columns={[
|
||||
{ title: 'Market', dataIndex: 'marketId' },
|
||||
{ title: 'Outcome', dataIndex: 'outcome' },
|
||||
{ title: 'Qty', dataIndex: 'quantity' },
|
||||
{ title: 'Cost', dataIndex: 'cost' },
|
||||
{ title: 'Value', dataIndex: 'currentValue' },
|
||||
{ title: 'PnL', dataIndex: 'unrealizedPnl' },
|
||||
{ title: 'Quote', dataIndex: 'quoteConfidence' },
|
||||
{ title: 'Valuation', dataIndex: 'valuationStatus', render: valuationTag }
|
||||
]}
|
||||
/>
|
||||
)
|
||||
|
||||
const summaryFallback: LeaderResearchSummary = {
|
||||
discoveredCount: 0,
|
||||
candidateCount: 0,
|
||||
paperCount: 0,
|
||||
trialReadyCount: 0,
|
||||
cooldownCount: 0,
|
||||
retiredCount: 0,
|
||||
activePaperSessions: 0,
|
||||
pendingRiskCount: 0,
|
||||
sourceLimitations: []
|
||||
}
|
||||
|
||||
export default LeaderResearch
|
||||
@@ -8,6 +8,16 @@ import type {
|
||||
LeaderPoolListResponse,
|
||||
LeaderPoolUpdatePlanRequest,
|
||||
LeaderPoolUpdateStatusRequest,
|
||||
LeaderResearchApprovalRequest,
|
||||
LeaderResearchApprovalResponse,
|
||||
LeaderResearchCandidateDetail,
|
||||
LeaderResearchCandidateListRequest,
|
||||
LeaderResearchCandidateListResponse,
|
||||
LeaderResearchEvent,
|
||||
LeaderResearchRun,
|
||||
LeaderResearchRunRequest,
|
||||
LeaderResearchSourceState,
|
||||
LeaderResearchSummary,
|
||||
NotificationConfig,
|
||||
NotificationConfigRequest,
|
||||
NotificationConfigUpdateRequest,
|
||||
@@ -396,6 +406,29 @@ export const apiService = {
|
||||
remove: (data: { poolId: number }) =>
|
||||
apiClient.post<ApiResponse<void>>('/copy-trading/leader-pool/remove', data)
|
||||
},
|
||||
|
||||
leaderResearch: {
|
||||
run: (data: LeaderResearchRunRequest = {}) =>
|
||||
apiClient.post<ApiResponse<LeaderResearchRun>>('/copy-trading/leader-research/run', data),
|
||||
|
||||
summary: () =>
|
||||
apiClient.post<ApiResponse<LeaderResearchSummary>>('/copy-trading/leader-research/summary', {}),
|
||||
|
||||
listCandidates: (data: LeaderResearchCandidateListRequest = {}) =>
|
||||
apiClient.post<ApiResponse<LeaderResearchCandidateListResponse>>('/copy-trading/leader-research/candidates/list', data),
|
||||
|
||||
detail: (data: { candidateId: number }) =>
|
||||
apiClient.post<ApiResponse<LeaderResearchCandidateDetail>>('/copy-trading/leader-research/candidates/detail', data),
|
||||
|
||||
sourceHealth: () =>
|
||||
apiClient.post<ApiResponse<LeaderResearchSourceState[]>>('/copy-trading/leader-research/source-health', {}),
|
||||
|
||||
events: (data: { page?: number; size?: number } = {}) =>
|
||||
apiClient.post<ApiResponse<LeaderResearchEvent[]>>('/copy-trading/leader-research/events/list', data),
|
||||
|
||||
createDisabledTrialConfig: (data: LeaderResearchApprovalRequest) =>
|
||||
apiClient.post<ApiResponse<LeaderResearchApprovalResponse>>('/copy-trading/leader-research/approval/create-disabled-trial-config', data)
|
||||
},
|
||||
|
||||
/**
|
||||
* 跟单模板管理 API(子菜单:跟单模板)
|
||||
|
||||
@@ -344,6 +344,12 @@ export interface LeaderPoolItem {
|
||||
lastPromotedAt?: number
|
||||
cooldownUntil?: number
|
||||
locked: boolean
|
||||
researchCandidateId?: number
|
||||
researchState?: LeaderResearchState
|
||||
researchBadge?: string
|
||||
researchSummary?: string
|
||||
researchScore?: string
|
||||
researchUpdatedAt?: number
|
||||
createdAt: number
|
||||
updatedAt: number
|
||||
}
|
||||
@@ -391,6 +397,231 @@ export interface LeaderPoolCreateTrialConfigRequest {
|
||||
confirm?: boolean
|
||||
}
|
||||
|
||||
export type LeaderResearchState = 'DISCOVERED' | 'CANDIDATE' | 'PAPER' | 'TRIAL_READY' | 'COOLDOWN' | 'RETIRED'
|
||||
|
||||
export interface LeaderResearchRunRequest {
|
||||
dryRun?: boolean
|
||||
triggerType?: 'MANUAL' | 'SCHEDULED' | 'PREVIEW'
|
||||
}
|
||||
|
||||
export interface LeaderResearchRun {
|
||||
id: number
|
||||
status: string
|
||||
triggerType: string
|
||||
dryRun: boolean
|
||||
startedAt: number
|
||||
finishedAt?: number
|
||||
durationMs?: number
|
||||
sourceCountsJson?: string
|
||||
candidateCountsJson?: string
|
||||
partialFailure: boolean
|
||||
skippedReason?: string
|
||||
errorClass?: string
|
||||
errorMessage?: string
|
||||
}
|
||||
|
||||
export interface LeaderResearchSummary {
|
||||
discoveredCount: number
|
||||
candidateCount: number
|
||||
paperCount: number
|
||||
trialReadyCount: number
|
||||
cooldownCount: number
|
||||
retiredCount: number
|
||||
activePaperSessions: number
|
||||
pendingRiskCount: number
|
||||
lastRun?: LeaderResearchRun
|
||||
sourceLimitations: string[]
|
||||
}
|
||||
|
||||
export interface LeaderResearchCandidateListRequest {
|
||||
page?: number
|
||||
size?: number
|
||||
state?: LeaderResearchState
|
||||
query?: string
|
||||
}
|
||||
|
||||
export interface LeaderResearchCandidateListResponse {
|
||||
list: LeaderResearchCandidate[]
|
||||
total: number
|
||||
summary: LeaderResearchSummary
|
||||
}
|
||||
|
||||
export interface LeaderResearchCandidate {
|
||||
id: number
|
||||
normalizedWallet: string
|
||||
leaderId?: number
|
||||
leaderName?: string
|
||||
poolId?: number
|
||||
poolStatus?: string
|
||||
suggestedFixedAmount?: string
|
||||
suggestedMaxDailyLoss?: string
|
||||
suggestedMaxDailyOrders?: number
|
||||
suggestedMinPrice?: string
|
||||
suggestedMaxPrice?: string
|
||||
suggestedMaxPositionValue?: string
|
||||
researchState: LeaderResearchState
|
||||
source: string
|
||||
sourceRank?: number
|
||||
score?: string
|
||||
scoreVersion?: string
|
||||
reason?: string
|
||||
riskFlags: string[]
|
||||
locked: boolean
|
||||
agentOwned: boolean
|
||||
provenance: string
|
||||
sourceEvidence?: string
|
||||
firstSeenAt: number
|
||||
lastSourceSeenAt?: number
|
||||
lastScoredAt?: number
|
||||
cooldownUntil?: number
|
||||
cooldownCount: number
|
||||
trialReadyAt?: number
|
||||
retiredAt?: number
|
||||
lastPaperSessionId?: number
|
||||
latestPaperSession?: LeaderPaperSession
|
||||
}
|
||||
|
||||
export interface LeaderResearchCandidateDetail {
|
||||
candidate: LeaderResearchCandidate
|
||||
latestScore?: LeaderResearchScore
|
||||
paperSessions: LeaderPaperSession[]
|
||||
paperTrades: LeaderPaperTrade[]
|
||||
paperPositions: LeaderPaperPosition[]
|
||||
events: LeaderResearchEvent[]
|
||||
}
|
||||
|
||||
export interface LeaderResearchScore {
|
||||
id: number
|
||||
candidateId: number
|
||||
runId?: number
|
||||
scoreVersion: string
|
||||
totalScore: string
|
||||
profitSignal: string
|
||||
repeatability: string
|
||||
liquidityFit: string
|
||||
entryPriceFit: string
|
||||
slippageRisk: string
|
||||
holdingPeriodFit: string
|
||||
marketTypeRisk: string
|
||||
drawdownRisk: string
|
||||
exitLiquidityRisk: string
|
||||
dataFreshness: string
|
||||
filterPassRate: string
|
||||
sampleTradeCount: number
|
||||
reason?: string
|
||||
createdAt: number
|
||||
}
|
||||
|
||||
export interface LeaderPaperSession {
|
||||
id: number
|
||||
candidateId: number
|
||||
status: string
|
||||
startedAt: number
|
||||
endedAt?: number
|
||||
tradeCount: number
|
||||
filteredCount: number
|
||||
openExposure: string
|
||||
totalRealizedPnl: string
|
||||
totalUnrealizedPnl: string
|
||||
copyablePnl: string
|
||||
maxDrawdown: string
|
||||
unknownValuationExposure: string
|
||||
confirmedZeroExposure: string
|
||||
filteredRatio: string
|
||||
lastProcessedEventTime?: number
|
||||
scoreSnapshot?: string
|
||||
}
|
||||
|
||||
export interface LeaderPaperTrade {
|
||||
id: number
|
||||
sessionId: number
|
||||
candidateId: number
|
||||
activityEventId?: number
|
||||
leaderTradeId: string
|
||||
marketId: string
|
||||
marketTitle?: string
|
||||
marketSlug?: string
|
||||
side: string
|
||||
outcome?: string
|
||||
outcomeIndex?: number
|
||||
leaderPrice?: string
|
||||
leaderSize?: string
|
||||
simulatedPrice?: string
|
||||
simulatedSize?: string
|
||||
simulatedAmount?: string
|
||||
fillAssumption: string
|
||||
quoteConfidence: string
|
||||
quoteSource?: string
|
||||
quoteTimestamp?: number
|
||||
filterResult: string
|
||||
filterReason?: string
|
||||
valuationStatus: string
|
||||
realizedPnl?: string
|
||||
eventTime: number
|
||||
createdAt: number
|
||||
}
|
||||
|
||||
export interface LeaderPaperPosition {
|
||||
id: number
|
||||
sessionId: number
|
||||
candidateId: number
|
||||
marketId: string
|
||||
outcome?: string
|
||||
outcomeIndex?: number
|
||||
quantity: string
|
||||
cost: string
|
||||
avgPrice: string
|
||||
currentPrice?: string
|
||||
currentValue: string
|
||||
realizedPnl: string
|
||||
unrealizedPnl: string
|
||||
valuationStatus: string
|
||||
quoteConfidence: string
|
||||
quoteSource?: string
|
||||
quoteTimestamp?: number
|
||||
updatedAt: number
|
||||
}
|
||||
|
||||
export interface LeaderResearchSourceState {
|
||||
sourceType: string
|
||||
status: string
|
||||
lastSuccessAt?: number
|
||||
lastFailureAt?: number
|
||||
lastRunAt?: number
|
||||
lastCandidateCount: number
|
||||
errorClass?: string
|
||||
errorMessage?: string
|
||||
stale: boolean
|
||||
disabledReason?: string
|
||||
lastCursor?: string
|
||||
updatedAt: number
|
||||
}
|
||||
|
||||
export interface LeaderResearchEvent {
|
||||
id: number
|
||||
candidateId?: number
|
||||
runId?: number
|
||||
eventType: string
|
||||
reason?: string
|
||||
payloadSummary?: string
|
||||
notificationStatus: string
|
||||
notificationError?: string
|
||||
dedupeKey?: string
|
||||
createdAt: number
|
||||
notifiedAt?: number
|
||||
}
|
||||
|
||||
export interface LeaderResearchApprovalRequest {
|
||||
candidateId: number
|
||||
accountId: number
|
||||
confirm?: boolean
|
||||
}
|
||||
|
||||
export interface LeaderResearchApprovalResponse {
|
||||
copyTrading: CopyTrading
|
||||
warning: string
|
||||
}
|
||||
|
||||
/**
|
||||
* 跟单创建请求
|
||||
* 所有配置参数都需要手动输入,模板仅用于前端快速填充表单
|
||||
|
||||
@@ -0,0 +1,2 @@
|
||||
schema: spec-driven
|
||||
created: 2026-05-04
|
||||
@@ -0,0 +1,282 @@
|
||||
## Context
|
||||
|
||||
PolyHermes 当前已有三层跟单工作流:
|
||||
|
||||
- `Leader 管理` 保存可被跟单的钱包地址,对应 `copy_trading_leaders`。
|
||||
- `Leader 池` 管理人工候选、观察、小额试跟、冷却、淘汰,对应 `copy_trading_leader_pool`。
|
||||
- `跟单配置` 管理真实账户、leader 和风控参数,对应 `copy_trading`。
|
||||
|
||||
这个结构已经把“地址库”和“真钱跟单”拆开,但仍需要用户自己去找 leader、判断是否值得观察、跟踪表现、决定是否小额试跟。用户的目标不是手动维护池子,而是让系统自动发现优秀且可复制的 leader,先纸跟验证,再由用户授权真钱动作。
|
||||
|
||||
本设计把新增能力定位为 Leader Research Agent。它是研究代理,不是自动交易代理。它可以自动发现、自动纸跟、自动建议、自动冷却;它不能自动启用真钱跟单。
|
||||
|
||||
## Goals / Non-Goals
|
||||
|
||||
**Goals:**
|
||||
|
||||
- 提供多源候选发现,第一阶段支持 watchlist、已有 Leader 管理记录和 activity-derived candidates。
|
||||
- 为研究运行、候选、分数、事件、纸跟 session、纸跟交易、纸跟持仓建立独立数据模型。
|
||||
- 持久化原始或规范化 activity event,使纸跟可以基于可重放事件运行,而不是复用真实订单表。
|
||||
- 用透明规则计算 copyability score,并保存分项分数和 score version。
|
||||
- 自动推进研究状态,但研究状态必须与 Leader 池状态分离。
|
||||
- 在不锁定的情况下,自动化只能保守地增强 Leader 池记录,不能自动改成真实 `TRIAL` 或 `ACTIVE`。
|
||||
- 提供操作台展示运行状态、候选详情、纸跟表现、待处理决策和源健康。
|
||||
- 提供手动审批动作,创建保守的 `enabled=false` 真实跟单配置。
|
||||
- 为源失败、quote 不可用、valuation unknown、重复事件、锁定候选、非法真钱启用等关键路径提供可见错误和测试。
|
||||
|
||||
**Non-Goals:**
|
||||
|
||||
- 不自动启用真钱跟单。
|
||||
- 不自动加仓、不自动增加 fixed amount、不自动放宽风控参数。
|
||||
- 不做黑盒 AI/ML 评分。
|
||||
- 不在第一阶段依赖公开 leaderboard 端点。
|
||||
- 不复用 `copy_order_tracking` 作为纸跟账本。
|
||||
- 不把 unknown valuation 当成 confirmed zero。
|
||||
- 不替换现有 Leader 池、Leader 管理、跟单配置和真实 PnL 统计。
|
||||
|
||||
## Decisions
|
||||
|
||||
### 1. 研究状态独立于 Leader 池状态
|
||||
|
||||
研究状态回答“系统证明了什么”,Leader 池状态回答“用户当前怎么处理这个 leader”。两者不能合并。
|
||||
|
||||
研究状态:
|
||||
|
||||
```text
|
||||
DISCOVERED -> CANDIDATE -> PAPER -> TRIAL_READY
|
||||
\-> COOLDOWN -> RETIRED
|
||||
```
|
||||
|
||||
Leader 池状态:
|
||||
|
||||
```text
|
||||
CANDIDATE -> WATCH -> TRIAL -> ACTIVE
|
||||
\-> COOLDOWN -> RETIRED
|
||||
```
|
||||
|
||||
映射规则:
|
||||
|
||||
```text
|
||||
research DISCOVERED
|
||||
-> 可以只存在于 leader_research_candidate,不强制创建 Leader 池项
|
||||
|
||||
research CANDIDATE
|
||||
-> 展示给用户时可创建或更新 Leader 池 CANDIDATE
|
||||
|
||||
research PAPER
|
||||
-> Leader 池保持 WATCH,或后续 UI 暴露 PAPER
|
||||
|
||||
research TRIAL_READY
|
||||
-> Leader 池保持 WATCH,并展示“建议小额试跟” badge
|
||||
-> 不能自动变成 Leader 池 TRIAL
|
||||
|
||||
research COOLDOWN
|
||||
-> 未锁定时可以同步 Leader 池 COOLDOWN
|
||||
|
||||
research RETIRED
|
||||
-> 只有 agent 拥有且未锁定的候选,才可同步 Leader 池 RETIRED
|
||||
```
|
||||
|
||||
`TRIAL_READY` 只是建议。只有用户创建了真实禁用试跟配置后,Leader 池才可以进入 `TRIAL`。
|
||||
|
||||
### 2. 纸跟必须独立建账
|
||||
|
||||
`enabled=false` 的真实跟单配置不会产生模拟成交、模拟持仓、过滤原因、quote confidence 和纸跟 PnL。把它伪装成纸跟会让后续晋升建议没有证据链。
|
||||
|
||||
新增建议表:
|
||||
|
||||
```text
|
||||
leader_paper_session
|
||||
leader_paper_trade
|
||||
leader_paper_position
|
||||
```
|
||||
|
||||
每笔纸跟交易至少记录:
|
||||
|
||||
```text
|
||||
leader_trade_id
|
||||
leader_price
|
||||
leader_size
|
||||
simulated_price
|
||||
simulated_size
|
||||
simulated_amount
|
||||
fill_assumption: LEADER_PRICE | BEST_ASK_AT_EVENT | MID_PRICE | UNKNOWN
|
||||
quote_confidence: HIGH | MEDIUM | LOW | UNKNOWN
|
||||
quote_source
|
||||
quote_timestamp
|
||||
filter_result: PASSED | FILTERED
|
||||
filter_reason
|
||||
valuation_status: AVAILABLE | NO_MATCH | UNAVAILABLE | CONFIRMED_ZERO
|
||||
```
|
||||
|
||||
`UNAVAILABLE` 和 `UNKNOWN` 不得计为真实归零。这个约束必须沿用到纸跟 PnL 和前端展示。
|
||||
|
||||
### 3. 第一阶段先做可重放事件和内部来源
|
||||
|
||||
第一阶段候选来源:
|
||||
|
||||
```text
|
||||
watchlist
|
||||
已有 Leader 管理记录
|
||||
已持久化 activity event 中可归因的钱包
|
||||
```
|
||||
|
||||
公开 leaderboard 保留为后续来源,不作为第一阶段 apply-ready 的依赖。原因是 leaderboard 端点可能不稳定或未文档化,不能让第一版核心闭环被外部端点卡住。
|
||||
|
||||
如果当前 app 没有持久化 raw activity event,需要新增 append-only 表:
|
||||
|
||||
```text
|
||||
leader_activity_event
|
||||
```
|
||||
|
||||
推荐唯一键:
|
||||
|
||||
```text
|
||||
source + event_id
|
||||
```
|
||||
|
||||
如果 event_id 缺失,必须生成稳定 fallback key,避免重启或重放时重复纸跟。
|
||||
|
||||
### 4. 评分必须可解释且可版本化
|
||||
|
||||
不要只保存一个总分。保存分项分数、总分、score version 和 reason。
|
||||
|
||||
分项:
|
||||
|
||||
```text
|
||||
profit_signal
|
||||
repeatability
|
||||
liquidity_fit
|
||||
entry_price_fit
|
||||
slippage_risk
|
||||
holding_period_fit
|
||||
market_type_risk
|
||||
drawdown_risk
|
||||
exit_liquidity_risk
|
||||
data_freshness
|
||||
filter_pass_rate
|
||||
```
|
||||
|
||||
研究状态迁移必须记录 rule version,避免以后调整阈值后无法解释旧决策。
|
||||
|
||||
默认阈值:
|
||||
|
||||
```text
|
||||
DISCOVERED -> CANDIDATE
|
||||
条件:候选来自可信来源,地址格式有效
|
||||
|
||||
CANDIDATE -> PAPER
|
||||
条件:score >= 60,未锁定,未退休,source 48 小时内新鲜
|
||||
|
||||
PAPER -> TRIAL_READY
|
||||
条件:
|
||||
纸跟时间 >= 7 天
|
||||
模拟交易数 >= 10
|
||||
copyable PnL > 0
|
||||
最大回撤 >= -15%
|
||||
UNKNOWN quote 暴露占比 <= 20%
|
||||
filtered-trade ratio < 50%
|
||||
无 critical risk flag
|
||||
|
||||
PAPER -> COOLDOWN
|
||||
条件:
|
||||
最大回撤 < -20%
|
||||
或 10 笔后 copyable PnL < -5
|
||||
或 quote/source stale > 72 小时
|
||||
或存在 thin_liquidity_exit_risk
|
||||
|
||||
COOLDOWN -> CANDIDATE
|
||||
条件:cooldown_until 已过,source 重新新鲜,未锁定
|
||||
|
||||
COOLDOWN -> RETIRED
|
||||
条件:3 次冷却周期,或 30 天无新鲜来源
|
||||
```
|
||||
|
||||
这些阈值是第一版保守默认值,不是金融真理;后续可配置。
|
||||
|
||||
### 5. 后端强制真钱边界
|
||||
|
||||
研究代理可以推荐小额试跟,但不能启用真钱。
|
||||
|
||||
手动审批流:
|
||||
|
||||
```text
|
||||
用户打开候选详情
|
||||
-> 点击“创建禁用试跟配置”
|
||||
-> 前端展示 fixed amount、max daily loss、max daily orders、price range、max position value
|
||||
-> 用户确认
|
||||
-> 后端组装 CopyTradingCreateRequest
|
||||
-> 后端强制 enabled=false
|
||||
-> 返回 created copyTrading
|
||||
-> 用户必须去 跟单配置 页面手动启用
|
||||
```
|
||||
|
||||
后端必须忽略或拒绝任何客户端传入的 `enabled=true`、更大 fixed amount 或更松风控参数。安全边界在后端,不在弹窗。
|
||||
|
||||
### 6. 操作台先做“晨报式”决策界面
|
||||
|
||||
第一版页面不做复杂量化终端。优先回答:
|
||||
|
||||
```text
|
||||
今天有什么变化?
|
||||
哪些候选需要我决策?
|
||||
为什么推荐?
|
||||
证据是什么?
|
||||
风险是什么?
|
||||
我能安全点击什么?
|
||||
```
|
||||
|
||||
页面模块:
|
||||
|
||||
```text
|
||||
Run Status
|
||||
Candidate Detail
|
||||
Paper Sessions
|
||||
Pending Decisions
|
||||
Source Health
|
||||
```
|
||||
|
||||
### 7. 调度必须有 overlap guard 和 run record
|
||||
|
||||
新增 `LeaderResearchJobService` 使用现有 Spring `@Scheduled` 模式,但默认关闭或可配置开启。
|
||||
|
||||
每次运行必须创建 `leader_research_run`,记录:
|
||||
|
||||
```text
|
||||
status
|
||||
started_at
|
||||
finished_at
|
||||
duration
|
||||
source counts
|
||||
candidate counts
|
||||
error class
|
||||
error message
|
||||
partial failure flag
|
||||
```
|
||||
|
||||
如果上一次运行还未结束,新运行 MUST 跳过并记录 overlap 事件,不能并发推进状态。
|
||||
|
||||
## Risks / Trade-offs
|
||||
|
||||
- [纸跟精度看起来比实际更高] -> 每笔纸跟交易必须保存 fill assumption、quote confidence 和 valuation status;UI 必须展示 unknown/partial 状态。
|
||||
- [source 失败导致错误降级候选] -> source failure 只更新 source health,不得静默删除候选或降低 score。
|
||||
- [自动状态机误伤人工决策] -> `locked=true` 的候选和池子项不得被自动化改变状态、建议配置或 trial recommendation。
|
||||
- [通知系统 Telegram-first] -> 第一阶段先落 `leader_research_event` 和控制台提示,外部通知渠道在事件流稳定后再复用/扩展通知配置。
|
||||
- [表数量增加导致复杂度上升] -> 用里程碑拆分:先数据 spine,再纸跟,再评分状态机,再 UI/通知,再手动审批。
|
||||
- [数据量增长影响性能] -> 为 event、candidate、paper trade、paper position、run、event 表添加唯一键和查询索引,并对详情列表分页。
|
||||
|
||||
## Migration Plan
|
||||
|
||||
1. 新增研究与纸跟相关 Flyway 迁移,所有表为空表创建,不修改现有真实订单热表。
|
||||
2. 部署后端,但 research job 默认关闭。
|
||||
3. 部署前端操作台,若 job 未启用则展示禁用/空态。
|
||||
4. 在本地或测试环境手动运行 research job,验证 run record、source health、candidate、paper session 和 paper trade。
|
||||
5. 开启 watchlist + existing leaders 来源,不启用 public leaderboard。
|
||||
6. 确认内部 research events 稳定后再接外部通知渠道。
|
||||
7. 最后启用手动“创建禁用试跟配置”入口。
|
||||
|
||||
Rollback:
|
||||
|
||||
- 如果 job 出错,关闭 research job 配置并保留数据表。
|
||||
- 如果 UI 出错,隐藏菜单或回滚前端路由。
|
||||
- 如果迁移失败,在启用 job 前停止部署;不要在热回滚中删除已有研究数据。
|
||||
@@ -0,0 +1,37 @@
|
||||
## Why
|
||||
|
||||
当前 `Leader 池` 已经解决了“候选、观察、小额试跟、冷却、淘汰”的人工决策层,但用户的真实目标更靠前:系统自动发现优秀且可复制的 leader,先用纸跟证据验证,再让用户决定是否创建真实小额跟单配置。
|
||||
|
||||
如果只搬运排行榜,系统会把“leader 自己赚钱”误当成“用户也能跟着赚钱”。本变更要把 PolyHermes 从手动候选池升级为 paper-first 的 Leader Research Agent:自动发现、自动纸跟、自动解释、自动建议,但真钱启用必须手动确认。
|
||||
|
||||
## What Changes
|
||||
|
||||
- 新增 Leader Research Agent 能力,提供多源候选发现、研究运行记录、源健康状态和候选评分。
|
||||
- 新增独立纸跟账本,记录模拟买卖、模拟持仓、过滤原因、估值状态、quote confidence 和纸跟 PnL。
|
||||
- 新增研究状态机,支持 `DISCOVERED`、`CANDIDATE`、`PAPER`、`TRIAL_READY`、`COOLDOWN`、`RETIRED` 等研究状态,并与现有 Leader 池状态分离。
|
||||
- 新增可解释 copyability score,按收益信号、可重复性、流动性适配、入场价格适配、滑点风险、持仓周期、市场类型风险、回撤风险、退出流动性风险、数据新鲜度和过滤通过率拆分。
|
||||
- 新增 Leader Research 操作台,展示运行状态、候选详情、纸跟证据、待处理决策和数据源健康。
|
||||
- 新增研究事件与通知摘要,让系统主动提示新增候选、纸跟达标、建议小额试跟、冷却、数据失败和数据过期。
|
||||
- 新增手动审批路径:用户从研究候选详情确认后,系统只能创建 `enabled=false` 的保守真实跟单配置;研究代理不得自动启用真钱跟单。
|
||||
- 第一阶段候选来源仅包含 watchlist、已有 `Leader 管理` 记录和已持久化 activity 事件;公开 leaderboard 接入保留为后续能力,等端点契约和失败行为确认后再启用。
|
||||
- 不做自动真钱启用、不做自动加仓、不做自动放大固定金额、不做黑盒 AI 评分、不删除用户已有 leader 或跟单配置。
|
||||
|
||||
## Capabilities
|
||||
|
||||
### New Capabilities
|
||||
|
||||
- `leader-research-agent`: 自动研究 leader 的完整能力,包括候选发现、纸跟账本、copyability 评分、研究状态机、操作台、通知事件和手动创建禁用试跟配置。
|
||||
|
||||
### Modified Capabilities
|
||||
|
||||
无。
|
||||
|
||||
## Impact
|
||||
|
||||
- 后端新增 research 相关实体、Flyway 迁移、Repository、DTO、Service、Controller、定时任务和错误码。
|
||||
- 后端需要复用现有 `copy_trading_leaders`、`copy_trading_leader_pool`、`copy_trading`、`CopyTradingService`、`LeaderPoolService` 和 activity 监听链路。
|
||||
- 后端需要新增 append-only activity event 持久化能力,如果当前实时 activity 事件没有落库,则纸跟必须先基于该事件表运行。
|
||||
- 前端新增 Leader Research 操作台页面、路由、菜单入口、API service、类型定义和中文/英文/繁中文文案。
|
||||
- 前端 Leader 池需要能展示研究推荐 badge 或跳转研究候选详情,但不得把 `TRIAL_READY` 直接表现成真实 `TRIAL`。
|
||||
- 通知系统先复用内部 `leader_research_event` 和控制台提示;外部通知渠道在事件流稳定后再接入现有通知配置抽象。
|
||||
- 交易安全影响:本变更会靠近真钱配置创建路径,因此所有真实配置创建必须后端强制 `enabled=false`,并通过测试证明研究代理没有任何自动启用真钱的路径。
|
||||
@@ -0,0 +1,228 @@
|
||||
## ADDED Requirements
|
||||
|
||||
### Requirement: 提供 Leader Research 操作台
|
||||
系统 SHALL 在受保护的 Web 应用中提供 Leader Research 操作台,用于查看自动研究运行状态、候选、纸跟证据、待处理决策和数据源健康。
|
||||
|
||||
#### Scenario: 打开操作台
|
||||
- **WHEN** 已登录用户打开 Leader Research 页面
|
||||
- **THEN** 系统 MUST 展示最近研究运行状态、候选概览、待处理决策、纸跟概览和数据源健康
|
||||
|
||||
#### Scenario: 研究功能未启用
|
||||
- **WHEN** 研究 job 未启用或尚未运行
|
||||
- **THEN** 页面 MUST 展示明确空态,并 MUST NOT 暗示系统已经完成 leader 研究
|
||||
|
||||
#### Scenario: 数据源部分失败
|
||||
- **WHEN** 部分候选来源失败但其他来源可用
|
||||
- **THEN** 页面 MUST 展示 degraded 状态,并 MUST 继续展示可用来源产生的候选和证据
|
||||
|
||||
### Requirement: 记录研究运行
|
||||
系统 SHALL 为每次自动或手动研究运行记录 run record,确保研究结果可追溯、可排查且不会静默失败。
|
||||
|
||||
#### Scenario: 成功运行研究任务
|
||||
- **WHEN** 研究任务完成一次正常运行
|
||||
- **THEN** 系统 MUST 保存运行状态、开始时间、结束时间、耗时、来源统计、候选统计和成功状态
|
||||
|
||||
#### Scenario: 研究任务部分失败
|
||||
- **WHEN** 研究任务中某个来源或阶段失败但整体任务仍可继续
|
||||
- **THEN** 系统 MUST 将 run 标记为 partial failure,并 MUST 保存失败来源、错误类型和错误信息
|
||||
|
||||
#### Scenario: 防止并发运行
|
||||
- **WHEN** 上一次研究任务仍在运行时触发新的研究任务
|
||||
- **THEN** 系统 MUST 跳过新的任务或拒绝新的任务,并 MUST 记录 overlap 事件
|
||||
|
||||
### Requirement: 多源发现候选
|
||||
系统 SHALL 支持从多个来源发现 leader 候选,并将候选标准化为统一研究候选记录。
|
||||
|
||||
#### Scenario: 从 watchlist 发现候选
|
||||
- **WHEN** 系统运行 watchlist 来源
|
||||
- **THEN** 系统 MUST 为 watchlist 中的有效钱包创建或更新研究候选
|
||||
|
||||
#### Scenario: 从已有 Leader 管理记录发现候选
|
||||
- **WHEN** 系统运行已有 leader 来源
|
||||
- **THEN** 系统 MUST 为 `copy_trading_leaders` 中的有效 leader 创建或更新研究候选
|
||||
|
||||
#### Scenario: 从 activity 事件发现候选
|
||||
- **WHEN** 系统从已持久化 activity event 中识别可归因的钱包
|
||||
- **THEN** 系统 MUST 创建或更新研究候选,并 MUST 记录来源为 activity-derived
|
||||
|
||||
#### Scenario: 来源返回空结果
|
||||
- **WHEN** 某个来源成功运行但没有发现候选
|
||||
- **THEN** 系统 MUST 记录该来源成功且候选数为 0,不得把它当作失败
|
||||
|
||||
#### Scenario: 来源失败
|
||||
- **WHEN** 某个来源超时、限流、解析失败或返回异常
|
||||
- **THEN** 系统 MUST 更新来源健康状态,并 MUST NOT 因本次失败删除候选或自动降级候选
|
||||
|
||||
### Requirement: 持久化 activity event
|
||||
系统 SHALL 持久化用于纸跟的 activity event,确保纸跟可以重放、去重和排查。
|
||||
|
||||
#### Scenario: 保存新的 activity event
|
||||
- **WHEN** 系统接收到包含交易者、市场、方向、价格、数量和时间的 activity event
|
||||
- **THEN** 系统 MUST 保存该事件,并 MUST 记录 source、event id、钱包地址、市场、方向、价格、数量、时间戳和原始 payload 摘要
|
||||
|
||||
#### Scenario: 重复 activity event
|
||||
- **WHEN** 系统再次接收到相同 source 和 event id 的 activity event
|
||||
- **THEN** 系统 MUST 不创建重复事件,并 MUST 保持纸跟处理幂等
|
||||
|
||||
#### Scenario: activity event 缺少必需字段
|
||||
- **WHEN** activity event 缺少钱包地址、市场、方向、价格或数量
|
||||
- **THEN** 系统 MUST 标记该事件不可用于纸跟,并 MUST 记录原因
|
||||
|
||||
### Requirement: 建立独立纸跟账本
|
||||
系统 SHALL 使用独立纸跟账本记录模拟买卖、模拟持仓、过滤结果和纸跟 PnL,不得使用真实订单跟踪表伪装纸跟。
|
||||
|
||||
#### Scenario: 启动纸跟 session
|
||||
- **WHEN** 研究候选达到进入纸跟的条件
|
||||
- **THEN** 系统 MUST 创建 paper session,并 MUST 将候选研究状态更新为 `PAPER`
|
||||
|
||||
#### Scenario: 记录纸跟买入
|
||||
- **WHEN** 已进入纸跟的候选出现符合过滤条件的 BUY activity event
|
||||
- **THEN** 系统 MUST 记录 paper trade 和 paper position,并 MUST 保存模拟价格、模拟数量、fill assumption 和 quote confidence
|
||||
|
||||
#### Scenario: 记录纸跟卖出
|
||||
- **WHEN** 已进入纸跟的候选出现可匹配的 SELL activity event
|
||||
- **THEN** 系统 MUST 更新对应 paper position,并 MUST 记录 realized paper PnL
|
||||
|
||||
#### Scenario: 记录被过滤交易
|
||||
- **WHEN** 候选交易因价格区间、流动性、风控或数据缺失被过滤
|
||||
- **THEN** 系统 MUST 记录 filtered paper trade 或 research event,并 MUST 保存 filter reason
|
||||
|
||||
#### Scenario: 估值不可用
|
||||
- **WHEN** 纸跟持仓无法获得可靠 quote 或 position valuation
|
||||
- **THEN** 系统 MUST 将 valuation status 标记为 `UNAVAILABLE` 或 `UNKNOWN`,并 MUST NOT 将其计为 confirmed zero
|
||||
|
||||
### Requirement: 计算可解释 copyability score
|
||||
系统 SHALL 为研究候选计算可解释的 copyability score,并保存分项分数、总分、版本和原因。
|
||||
|
||||
#### Scenario: 保存分项分数
|
||||
- **WHEN** 系统计算候选评分
|
||||
- **THEN** 系统 MUST 保存收益信号、可重复性、流动性适配、入场价格适配、滑点风险、持仓周期、市场类型风险、回撤风险、退出流动性风险、数据新鲜度和过滤通过率
|
||||
|
||||
#### Scenario: 保存评分版本
|
||||
- **WHEN** 系统保存候选评分
|
||||
- **THEN** 系统 MUST 保存 score version,以便后续解释历史评分
|
||||
|
||||
#### Scenario: 单次大赢不能主导评分
|
||||
- **WHEN** 候选只有单次异常盈利但样本量不足
|
||||
- **THEN** 系统 MUST 限制收益信号对总分的影响,并 MUST 通过样本量或可重复性降低晋升概率
|
||||
|
||||
### Requirement: 自动推进研究状态
|
||||
系统 SHALL 基于透明规则自动推进研究状态,并记录每次状态变化的原因。
|
||||
|
||||
#### Scenario: 候选进入纸跟
|
||||
- **WHEN** 候选分数达到阈值、来源新鲜、未退休且未锁定
|
||||
- **THEN** 系统 MUST 将研究状态从 `CANDIDATE` 推进为 `PAPER`,并 MUST 记录规则版本和原因
|
||||
|
||||
#### Scenario: 纸跟达标进入试跟建议
|
||||
- **WHEN** 候选纸跟至少 7 天、模拟交易至少 10 笔、copyable PnL 为正、最大回撤不低于 -15%、UNKNOWN quote 暴露不超过 20%、过滤比例低于 50% 且无 critical risk flag
|
||||
- **THEN** 系统 MUST 将研究状态推进为 `TRIAL_READY`,并 MUST 生成试跟建议事件
|
||||
|
||||
#### Scenario: 纸跟风险进入冷却
|
||||
- **WHEN** 候选最大回撤低于 -20%、10 笔后 copyable PnL 小于 -5、quote 或 source stale 超过 72 小时,或出现 thin liquidity exit risk
|
||||
- **THEN** 系统 MUST 将研究状态推进为 `COOLDOWN`,并 MUST 记录风险原因
|
||||
|
||||
#### Scenario: 冷却后恢复候选
|
||||
- **WHEN** 候选冷却截止时间已过、来源重新新鲜且未锁定
|
||||
- **THEN** 系统 MAY 将研究状态恢复为 `CANDIDATE`,并 MUST 记录恢复原因
|
||||
|
||||
#### Scenario: 多次冷却后退休
|
||||
- **WHEN** 候选经历 3 次冷却周期或 30 天没有新鲜来源
|
||||
- **THEN** 系统 MAY 将研究状态推进为 `RETIRED`,并 MUST 记录退休原因
|
||||
|
||||
### Requirement: 尊重锁定候选
|
||||
系统 SHALL 支持锁定研究候选或 Leader 池项,避免自动任务覆盖人工判断。
|
||||
|
||||
#### Scenario: 锁定候选
|
||||
- **WHEN** 用户锁定某个研究候选
|
||||
- **THEN** 自动任务 MUST NOT 改变该候选的研究状态、试跟建议、建议配置或冷却/退休状态
|
||||
|
||||
#### Scenario: 锁定候选仍可更新证据
|
||||
- **WHEN** 锁定候选出现新的来源证据或纸跟证据
|
||||
- **THEN** 系统 MAY 追加只读证据,但 MUST NOT 改变人工锁定的决策字段
|
||||
|
||||
### Requirement: 保守增强 Leader 池
|
||||
系统 SHALL 允许研究代理保守地增强现有 Leader 池记录,但不得替代 Leader 池的人工作业语义。
|
||||
|
||||
#### Scenario: 研究候选同步到 Leader 池
|
||||
- **WHEN** 候选进入 `CANDIDATE` 或更高研究状态且需要展示给用户
|
||||
- **THEN** 系统 MAY 创建或更新对应 Leader 池项,并 MUST 保留 Leader 池作为候选决策层
|
||||
|
||||
#### Scenario: 试跟建议不等于真实试跟状态
|
||||
- **WHEN** 候选研究状态为 `TRIAL_READY`
|
||||
- **THEN** 系统 MUST NOT 自动把 Leader 池状态改为 `TRIAL` 或 `ACTIVE`
|
||||
|
||||
#### Scenario: 不自动放大建议配置
|
||||
- **WHEN** 自动任务更新已有 Leader 池记录
|
||||
- **THEN** 系统 MUST NOT 自动增加 suggested fixed amount、suggested max daily loss 或 suggested max daily orders
|
||||
|
||||
#### Scenario: 不覆盖手工备注
|
||||
- **WHEN** 自动任务写入研究摘要
|
||||
- **THEN** 系统 MUST NOT 覆盖用户手工 notes,只能追加或写入独立研究摘要字段
|
||||
|
||||
### Requirement: 手动创建禁用试跟配置
|
||||
系统 SHALL 支持用户从试跟建议手动创建禁用的保守真实跟单配置,并禁止研究代理自动启用真钱。
|
||||
|
||||
#### Scenario: 创建禁用试跟配置
|
||||
- **WHEN** 用户从 `TRIAL_READY` 候选详情点击创建禁用试跟配置并确认
|
||||
- **THEN** 后端 MUST 通过现有跟单配置服务创建 `enabled=false` 的保守 `FIXED` 配置
|
||||
|
||||
#### Scenario: 后端强制禁用状态
|
||||
- **WHEN** 创建禁用试跟配置请求包含 `enabled=true` 或其他试图立即启用的字段
|
||||
- **THEN** 后端 MUST 忽略或拒绝这些字段,并 MUST NOT 创建启用中的真实跟单配置
|
||||
|
||||
#### Scenario: 创建前展示风险参数
|
||||
- **WHEN** 用户确认创建禁用试跟配置
|
||||
- **THEN** UI MUST 展示 fixed amount、max daily loss、max daily orders、price range 和 max position value
|
||||
|
||||
#### Scenario: 已存在同账户同 leader 配置
|
||||
- **WHEN** 用户尝试为同一账户和 leader 创建禁用试跟配置且配置已存在
|
||||
- **THEN** 系统 MUST 拒绝默认创建,并 MUST 展示已有配置提示
|
||||
|
||||
### Requirement: 记录研究事件和通知摘要
|
||||
系统 SHALL 为关键研究动作记录事件,并在事件流稳定后提供通知摘要。
|
||||
|
||||
#### Scenario: 记录关键研究事件
|
||||
- **WHEN** 系统发现新候选、启动纸跟、晋升试跟建议、进入冷却、退休、来源失败或 valuation stale
|
||||
- **THEN** 系统 MUST 写入 leader research event,并 MUST 包含候选、事件类型、原因和时间
|
||||
|
||||
#### Scenario: 操作台展示待处理事件
|
||||
- **WHEN** 存在试跟建议或数据源失败事件
|
||||
- **THEN** 操作台 MUST 展示这些事件,并 MUST 提供跳转到候选详情或源健康详情的入口
|
||||
|
||||
#### Scenario: 外部通知失败
|
||||
- **WHEN** 外部通知渠道发送失败
|
||||
- **THEN** 系统 MUST 保留 research event,并 MUST 标记通知失败,不得丢失研究事件
|
||||
|
||||
### Requirement: 提供研究 API
|
||||
系统 SHALL 提供受保护的 Leader Research API,用于运行研究、查看运行状态、查看候选、查看纸跟详情、查看事件和创建禁用试跟配置。
|
||||
|
||||
#### Scenario: 手动触发研究运行
|
||||
- **WHEN** 用户请求手动运行研究任务
|
||||
- **THEN** 后端 MUST 校验权限和 overlap guard,并 MUST 返回 run record 或明确跳过原因
|
||||
|
||||
#### Scenario: 查询候选列表
|
||||
- **WHEN** 前端请求候选列表
|
||||
- **THEN** 后端 MUST 返回候选研究状态、来源、总分、关键风险、纸跟摘要和待处理决策状态
|
||||
|
||||
#### Scenario: 查询候选详情
|
||||
- **WHEN** 前端请求候选详情
|
||||
- **THEN** 后端 MUST 返回来源历史、score components、paper session、paper trades、paper positions、research events 和 Leader 池映射信息
|
||||
|
||||
#### Scenario: 查询源健康
|
||||
- **WHEN** 前端请求数据源健康
|
||||
- **THEN** 后端 MUST 返回每个来源的最近成功时间、最近失败时间、错误类型、错误信息和 stale 状态
|
||||
|
||||
### Requirement: 保证研究任务可观测和可回滚
|
||||
系统 SHALL 提供研究任务的日志、指标、运行记录和开关,确保上线后可以诊断、暂停和回滚。
|
||||
|
||||
#### Scenario: 研究 job 默认关闭
|
||||
- **WHEN** 新版本首次部署
|
||||
- **THEN** 研究 job SHOULD 默认为关闭或可配置关闭,直到人工验证通过
|
||||
|
||||
#### Scenario: 记录结构化日志
|
||||
- **WHEN** 研究任务运行、候选状态变化、纸跟交易记录、源失败或手动审批发生
|
||||
- **THEN** 后端 MUST 记录包含 runId、candidateId、source、eventId、oldState、newState 或 copyTradingId 的结构化日志
|
||||
|
||||
#### Scenario: 关闭研究 job
|
||||
- **WHEN** 运维关闭研究 job 配置
|
||||
- **THEN** 系统 MUST 停止自动研究运行,但 MUST 保留已有研究数据和页面查询能力
|
||||
@@ -0,0 +1,208 @@
|
||||
## 1. 后端数据模型与迁移
|
||||
|
||||
- [x] 1.1 新增 Flyway 迁移,创建 `leader_research_run` 表,记录运行状态、时间、耗时、来源统计、候选统计、错误类型、错误信息和 partial failure 标记。
|
||||
- [x] 1.2 新增 Flyway 迁移,创建 `leader_research_candidate` 表,记录钱包地址、关联 leaderId、研究状态、来源、score、reason、risk flags、锁定字段和时间戳。
|
||||
- [x] 1.3 新增 Flyway 迁移,创建 `leader_research_score` 表,记录 score version、总分和所有 copyability 分项。
|
||||
- [x] 1.4 新增 Flyway 迁移,创建 `leader_research_event` 表,记录研究事件类型、candidateId、runId、原因、payload 摘要、通知状态和时间戳。
|
||||
- [x] 1.5 新增 Flyway 迁移,创建 `leader_research_source_state` 表或等价字段,记录每个来源的最近成功、最近失败、错误类型、错误信息和 stale 状态。
|
||||
- [x] 1.6 新增 Flyway 迁移,创建 `leader_activity_event` append-only 表,若当前 app 未持久化 raw activity event,则用于纸跟重放和去重。
|
||||
- [x] 1.7 新增 Flyway 迁移,创建 `leader_paper_session` 表,记录 candidateId、状态、开始/结束时间、统计摘要和 PnL 摘要。
|
||||
- [x] 1.8 新增 Flyway 迁移,创建 `leader_paper_trade` 表,记录 leader trade、模拟成交、filter reason、fill assumption、quote confidence 和 valuation status。
|
||||
- [x] 1.9 新增 Flyway 迁移,创建 `leader_paper_position` 表,记录 session、market、outcome、数量、成本、当前估值、realized/unrealized PnL 和 valuation status。
|
||||
- [x] 1.10 为 activity event、candidate、paper trade、paper position、run、event 和 source state 添加唯一键与查询索引,覆盖去重、分页、状态筛选和候选详情查询。
|
||||
- [x] 1.11 新增 JPA entity 与 repository,覆盖所有 research、activity、paper 相关表。
|
||||
- [x] 1.12 新增 research 状态、paper session 状态、valuation status、quote confidence、fill assumption、event type 和 source type 枚举。
|
||||
- [x] 1.13 `leader_activity_event` 必须包含 `normalized_wallet`、`source_event_id`、`stable_event_key`、`event_time`、`raw_payload_hash`、`payload_summary`、`usable_for_discovery`、`unusable_reason`、`paper_processing_status`、`processing_attempts`、`paper_processing_started_at`、`paper_processed_at` 和 `last_processing_error`。
|
||||
- [x] 1.14 `leader_research_candidate` 必须记录 agent ownership/provenance,区分 agent 创建候选、用户已有 leader、用户锁定候选和人工池子项,避免自动退休或覆盖用户资产。
|
||||
- [x] 1.15 `leader_research_run` 必须支持单实例运行锁或等价约束,保证同一时间最多一个 research run 推进候选状态。
|
||||
- [x] 1.16 为 `leader_activity_event(paper_processing_status, event_time)`、`leader_activity_event(normalized_wallet, event_time)`、`leader_paper_trade(session_id, leader_trade_id)`、`leader_research_candidate(research_state, last_source_seen_at)` 添加组合索引。
|
||||
- [x] 1.17 为 paper trade 增加数据库唯一约束,至少覆盖 `session_id + leader_trade_id + side`,用数据库兜底防止重复模拟成交。
|
||||
- [x] 1.18 所有新增表必须有可回滚的空表迁移策略;迁移不得修改现有真实订单热表语义。
|
||||
|
||||
## 2. Activity 事件持久化
|
||||
|
||||
- [x] 2.1 新增 `LeaderActivityIngestionService`,负责 research 专用 activity event 标准化、去重、持久化和不可用原因记录。
|
||||
- [x] 2.2 保持现有 `PolymarketActivityWsService` 的真实跟单快路径不被 research 拖慢;不得依赖它当前的“已监听 leader 地址预过滤”来发现未知 leader。
|
||||
- [x] 2.3 新增 bounded Data API backfill 来源,按 watchlist、已有 Leader 管理记录和 active research candidates 拉取 `/activity`,用于补齐历史 paper event。
|
||||
- [x] 2.4 新增 research global activity capture 扩展点,解析全局 WS activity 时必须在地址过滤前标准化事件;该能力默认关闭,并受配置开关、每分钟写入上限、payload 截断和保留策略保护。
|
||||
- [x] 2.5 如果 global capture 未启用,activity-derived source 必须在 source health 中明确显示 disabled/degraded,不得假装已经自动发现未知 leader。
|
||||
- [x] 2.6 为 activity event 实现 source + eventId 幂等保存;缺失 eventId 时用 wallet、conditionId、asset、side、price、size、timestamp、transactionHash 的稳定 hash 生成 fallback key。
|
||||
- [x] 2.7 对缺少钱包地址、市场、方向、价格或数量的 event 仍写入摘要,并标记不可用于 discovery/paper trading 的原因。
|
||||
- [x] 2.8 确保 activity event 持久化失败不会阻断现有真实跟单处理链路;失败只写 research run/source error。
|
||||
- [x] 2.9 新增 activity source health 更新,区分 Data API success empty、Data API failure、WS capture disabled、WS parse failure、write capped 和 stale。
|
||||
- [x] 2.10 新增 activity event repository 查询方法,支持按钱包、时间窗口、未处理状态、来源、可发现状态和 stable key 查询。
|
||||
- [x] 2.11 新增 activity ingestion 测试,覆盖正常保存、重复事件、缺失字段、fallback key、持久化失败隔离、write cap、WS capture disabled 和 Data API success empty。
|
||||
- [x] 2.12 新增回归测试,证明 current copy-trading WS 的已知 leader 过滤不会阻断 research ingestion 对未知钱包的发现路径。
|
||||
|
||||
## 3. 候选来源与研究运行
|
||||
|
||||
- [x] 3.1 新增 `LeaderResearchJobService`,支持手动 runOnce 和定时运行,默认通过配置关闭自动调度。
|
||||
- [x] 3.2 为研究任务实现 overlap guard,当前一 run 未结束时跳过或拒绝新 run,并写入 research event。
|
||||
- [x] 3.3 新增 `LeaderResearchSourceService`,统一 watchlist、已有 Leader 管理记录和 activity-derived candidates;每个 source 返回 typed result,包含 candidates、source status、error class、error message 和 freshness。
|
||||
- [x] 3.4 新增 watchlist 配置读取路径,支持配置钱包地址、备注和来源标签。
|
||||
- [x] 3.5 实现 existing leaders 来源,从 `copy_trading_leaders` 创建或更新研究候选。
|
||||
- [x] 3.6 实现 activity-derived 来源,从 `leader_activity_event` 中发现 fresh、usable、可归因钱包;如果 global capture 未启用,只能基于已持久化事件工作,并必须展示 source limitation。
|
||||
- [x] 3.7 候选创建必须复用 `LeaderRepository` 的唯一地址语义;新 leader address 必须标准化小写并验证 42 位 EVM 地址格式。
|
||||
- [x] 3.8 每个来源运行后更新 source state,区分 success with zero candidates、failure、stale、disabled、degraded 和 partial failure。
|
||||
- [x] 3.9 公开 leaderboard 来源只保留接口/扩展点,不作为第一阶段启用来源;不得在实现中偷偷依赖未确认 endpoint。
|
||||
- [x] 3.10 候选合并必须保留所有来源 evidence,不得因为同一钱包重复出现而覆盖更早来源、用户备注或锁定状态。
|
||||
- [x] 3.11 新增 run-level dry-run 或 preview 能力,允许查看本次将新增/更新/冷却多少候选而不推进状态。
|
||||
- [x] 3.12 新增候选来源测试,覆盖空结果、失败结果、disabled source、重复候选、无效地址、已有 leader 复用和用户锁定保护。
|
||||
- [x] 3.13 新增研究运行测试,覆盖成功、partial failure、overlap guard、run record 完整性、source limitation 和 preview 不产生状态副作用。
|
||||
- [x] 3.14 新增生产失败场景测试:某个 source 超时或抛异常时,其他 source 仍能产出候选,且不会删除或降级已有候选。
|
||||
|
||||
## 4. 纸跟账本与模拟成交
|
||||
|
||||
- [x] 4.1 新增 `LeaderPaperTradingService`,根据候选和 activity event 启动或更新 paper session。
|
||||
- [x] 4.2 `LeaderPaperTradingService` 必须按 batch claim `leader_activity_event`,通过状态从 `NEW`/`RETRYABLE` 原子更新到 `PROCESSING` 后再处理,避免并发 run 重复模拟成交。
|
||||
- [x] 4.3 对进入 `PAPER` 状态的候选,处理 BUY event 并记录 paper trade 与 paper position。
|
||||
- [x] 4.4 对进入 `PAPER` 状态的候选,处理 SELL event 并按持仓匹配规则更新 paper position 和 realized PnL。
|
||||
- [x] 4.5 将现有 `CopyTradingFilterService` 抽出或复用为 paper-safe evaluator;paper 场景不得调用真实账户持仓检查来决定模拟持仓,必须使用 paper position provider。
|
||||
- [x] 4.6 为 research 定义保守 paper config,包含 fixed amount、max daily loss、max daily orders、min/max price、max position value、support sell 和 market end guard。
|
||||
- [x] 4.7 每笔 paper trade 保存 leader price、leader size、simulated price、simulated size、simulated amount、fill assumption、quote confidence、quote source、quote timestamp、filter result 和 filter reason。
|
||||
- [x] 4.8 对 quote/valuation 不可用的持仓标记 `UNAVAILABLE` 或 `UNKNOWN`,并确保不计为 confirmed zero。
|
||||
- [x] 4.9 `copyable PnL` 必须区分 realized PnL、available unrealized PnL、unknown valuation exposure 和 confirmed zero exposure;unknown/unavailable 不得伪装成亏到 0。
|
||||
- [x] 4.10 为 paper trade 实现 leaderTradeId 幂等去重,重复 activity event 不得重复产生纸跟。
|
||||
- [x] 4.11 处理失败必须增加 `processing_attempts`、记录 `last_processing_error`,超过阈值进入 `FAILED` 并写 research event,不得无限重试卡住整轮 run。
|
||||
- [x] 4.12 新增 paper session 汇总计算,包含交易数、过滤数、open exposure、realized/unrealized/copyable PnL、max drawdown、unknown valuation exposure、confirmed zero exposure 和 filtered ratio。
|
||||
- [x] 4.13 新增纸跟服务测试,覆盖 BUY、SELL、过滤、重复 event、unknown valuation、confirmed zero、PnL 计算、max drawdown、event claim 并发、失败重试和 FAILED 隔离。
|
||||
|
||||
## 5. Copyability 评分与研究状态机
|
||||
|
||||
- [x] 5.1 新增 `LeaderResearchScoringService`,计算并保存所有 copyability 分项和总分。
|
||||
- [x] 5.2 定义 `score_version = research-copyability-v1`,写明每个分项的 0-100 取值范围、权重、缺数据策略和总分计算公式。
|
||||
- [x] 5.3 v1 默认权重建议:profit signal 20、repeatability 15、liquidity fit 10、entry price fit 10、slippage risk 10、drawdown risk 10、holding period fit 5、market type risk 5、exit liquidity risk 5、data freshness 5、filter pass rate 5。
|
||||
- [x] 5.4 实现收益信号封顶逻辑,避免单次大赢主导评分;样本量不足时总分必须被 cap,且不能进入 `TRIAL_READY`。
|
||||
- [x] 5.5 实现 repeatability、liquidity fit、entry price fit、slippage risk、holding period fit、market type risk、drawdown risk、exit liquidity risk、data freshness 和 filter pass rate 分项。
|
||||
- [x] 5.6 对 quote unknown、valuation unavailable、source stale、filtered ratio 过高和 critical risk flag 定义明确扣分或晋升阻断规则。
|
||||
- [x] 5.7 新增 `LeaderResearchStateMachine`,实现 `DISCOVERED -> CANDIDATE -> PAPER -> TRIAL_READY -> COOLDOWN/RETIRED` 迁移。
|
||||
- [x] 5.8 状态迁移必须在事务中保存 rule version、oldState、newState、trigger reason、runId 和 research event。
|
||||
- [x] 5.9 实现 `CANDIDATE -> PAPER` 默认阈值:score >= 60、未锁定、未退休、source 48 小时内新鲜。
|
||||
- [x] 5.10 实现 `PAPER -> TRIAL_READY` 默认阈值:纸跟 >= 7 天、交易 >= 10、copyable PnL > 0、最大回撤不低于 -15%、UNKNOWN quote 暴露 <= 20%、过滤比例 < 50%、无 critical risk flag。
|
||||
- [x] 5.11 实现 `PAPER -> COOLDOWN` 默认阈值:最大回撤 < -20%、10 笔后 copyable PnL < -5、quote/source stale > 72 小时或 thin liquidity exit risk。
|
||||
- [x] 5.12 实现 cooldown 恢复和退休规则。
|
||||
- [x] 5.13 实现 locked 候选保护,自动任务不得改变锁定候选的状态、试跟建议、建议配置或冷却/退休状态。
|
||||
- [x] 5.14 新增评分和状态机测试,覆盖全部迁移、边界阈值、锁定保护、source stale、rule version、缺数据 cap、单次大赢 cap 和 critical risk 晋升阻断。
|
||||
|
||||
## 6. Leader 池联动与真钱安全边界
|
||||
|
||||
- [x] 6.1 新增 research 与 `copy_trading_leader_pool` 的映射服务,确保研究状态与 Leader 池状态分离。
|
||||
- [x] 6.2 当研究候选进入 `CANDIDATE` 或更高状态且需要展示时,保守创建或更新 Leader 池项。
|
||||
- [x] 6.3 `TRIAL_READY` 只能在 Leader 池展示“建议小额试跟” badge,不得自动把 Leader 池状态改为 `TRIAL` 或 `ACTIVE`。
|
||||
- [x] 6.4 自动任务不得增加 existing pool 的 suggested fixed amount、suggested max daily loss 或 suggested max daily orders。
|
||||
- [x] 6.5 自动任务不得覆盖用户手工 notes;研究摘要必须写入独立字段或 append-only event。
|
||||
- [x] 6.6 新增 research 专用手动审批服务,从 `TRIAL_READY` 候选创建禁用试跟配置;不得原样复用当前 `LeaderPoolService.createTrialConfig`,除非先重构出 disabled-only 安全 helper。
|
||||
- [x] 6.7 research approval request DTO 不允许暴露 `enabled`、`enableImmediately` 或放宽风控字段;后端组装 `CopyTradingCreateRequest` 时必须强制 `enabled=false`。
|
||||
- [x] 6.8 如果客户端篡改提交 `enabled=true`、更大 fixed amount、更高 max daily loss、更多 max daily orders 或更宽 price range,后端必须拒绝或忽略,并写入 safety research event。
|
||||
- [x] 6.9 创建禁用试跟配置前检查同账户同 leader 是否已有跟单配置,默认拒绝重复创建;双击提交必须只创建一条配置。
|
||||
- [x] 6.10 用户明确创建 disabled trial config 成功后,Leader 池才可以从 WATCH/建议状态进入 `TRIAL`;仅 `TRIAL_READY` recommendation 不得进入 `TRIAL`。
|
||||
- [x] 6.11 创建禁用试跟配置成功后写入 research event,并提供跳转 `跟单配置` 的信息。
|
||||
- [x] 6.12 新增安全测试,覆盖客户端篡改 `enabled=true`、高 fixed amount、放宽风控、locked 候选、重复配置、双击创建、TRIAL_READY 不自动 TRIAL 和 disabled config 创建后 copy_trading.enabled 仍为 false。
|
||||
|
||||
## 7. 后端 API 与错误处理
|
||||
|
||||
- [x] 7.1 新增 `LeaderResearchController`,提供研究运行、候选列表、候选详情、paper session、source health、research events 和手动创建禁用试跟配置 API。
|
||||
- [x] 7.2 所有 research API 必须走现有受保护路由与鉴权边界。
|
||||
- [x] 7.3 新增 research DTO,覆盖 run summary、candidate summary/detail、score components、paper session、paper trade、paper position、source health、event 和 approval response。
|
||||
- [x] 7.4 新增 research 错误码和中英繁 i18n 文案,覆盖 run overlap、candidate not found、candidate locked、not trial ready、source unavailable、paper valuation unavailable、real money activation forbidden。
|
||||
- [x] 7.5 服务内部使用命名错误或明确 result 类型,不得只用 catch-all Exception 表达业务失败。
|
||||
- [x] 7.6 所有源失败、解析失败、状态迁移失败、纸跟失败和审批失败必须写入 research event 或 run error。
|
||||
- [x] 7.7 新增 controller 测试,覆盖所有 API 的成功路径、参数错误、权限/保护边界、业务错误和真钱启用拒绝。
|
||||
- [x] 7.8 手动 run 和创建禁用试跟配置 API 必须有重复提交保护;重复请求返回已有 run/config 或明确拒绝,不得产生重复副作用。
|
||||
- [x] 7.9 候选详情 API 必须分页返回 paper trades 和 research events,不得一次性返回完整历史。
|
||||
- [x] 7.10 API 错误响应必须让前端能区分 `disabled source`、`degraded source`、`valuation unknown`、`candidate locked` 和 `real money activation forbidden`。
|
||||
|
||||
## 8. 前端类型、API 与操作台
|
||||
|
||||
- [x] 8.1 在 `frontend/src/types` 中新增 Leader Research 类型,覆盖状态、source health、score components、paper session/trade/position、events 和 approval request/response。
|
||||
- [x] 8.2 在 `frontend/src/services/api.ts` 新增 `leaderResearch` API 分组。
|
||||
- [x] 8.3 新增 `LeaderResearch` 页面和受保护路由。
|
||||
- [x] 8.4 在左侧菜单合适位置新增 `Leader Research` 或 `Leader 雷达` 入口,避免和现有 `Leader 池` 混淆。
|
||||
- [x] 8.5 操作台顶部展示 Today summary:trial-ready 数、新候选数、新纸跟数、冷却数、source stale 数。
|
||||
- [x] 8.6 实现 Run Status 模块,展示最近运行、耗时、状态、来源统计和失败信息。
|
||||
- [x] 8.7 实现 Pending Decisions 模块,展示 `TRIAL_READY` 候选和手动创建禁用试跟配置入口。
|
||||
- [x] 8.8 实现 Candidate Detail,展示来源历史、score components、paper session summary、risk flags、research events 和 Leader 池映射状态。
|
||||
- [x] 8.9 实现 Paper Sessions/Trades 展示,明确展示 valuation status、quote confidence 和 filtered reason。
|
||||
- [x] 8.10 实现 Source Health 模块,区分 success empty、failure、stale 和 degraded。
|
||||
- [x] 8.11 创建禁用试跟配置前,UI 必须展示 fixed amount、max daily loss、max daily orders、price range 和 max position value,并要求确认。
|
||||
- [x] 8.12 UI 不得把 `TRIAL_READY` 文案表现成已经真实试跟;必须明确“建议小额试跟,待你确认”。
|
||||
- [x] 8.13 新增 zh-CN、zh-TW、en 文案。
|
||||
- [x] 8.14 确保移动端操作台至少可查看 summary、pending decisions 和 candidate detail,不出现不可操作表格。
|
||||
- [x] 8.15 UI 必须展示 activity source limitation:global capture disabled、Data API backfill stale、source degraded 等状态,避免用户误以为系统已经全网自动发现。
|
||||
- [x] 8.16 创建禁用试跟配置按钮必须防双击,提交中禁用,并在重复配置错误时跳转或提示已有配置。
|
||||
- [x] 8.17 Candidate Detail 必须把 `UNKNOWN`、`UNAVAILABLE`、`NO_MATCH`、`CONFIRMED_ZERO` 用不同文案和颜色展示,不能都显示成 0。
|
||||
- [x] 8.18 Leader 池或 Leader Research 的 `TRIAL_READY` badge 必须和真实 `TRIAL` 状态视觉区分,避免用户误以为真钱已开始。
|
||||
|
||||
## 9. 通知与内部事件
|
||||
|
||||
- [x] 9.1 新增 research event 查询与标记通知状态能力。
|
||||
- [x] 9.2 第一阶段在操作台展示内部事件,不强依赖外部通知渠道。
|
||||
- [x] 9.3 新增通知摘要生成服务,基于 research event 聚合新增候选、纸跟达标、建议试跟、冷却、source failure 和 stale data。
|
||||
- [x] 9.4 外部通知发送失败时必须保留 research event,并记录通知失败原因。
|
||||
- [x] 9.5 如果接入现有 Telegram 通知,必须避免硬编码新渠道 secret,并复用现有通知配置读取路径。
|
||||
- [x] 9.6 新增通知测试,覆盖事件生成、摘要生成、发送失败和事件保留。
|
||||
- [x] 9.7 通知摘要必须包含 safety-relevant 事件:source disabled/degraded、valuation unknown、approval rejected、duplicate approval 和 real money activation forbidden。
|
||||
- [x] 9.8 通知事件必须可去重,避免同一候选每轮 research run 重复推送相同 `TRIAL_READY` 或 source failure。
|
||||
|
||||
## 10. 性能、索引与数据保留
|
||||
|
||||
- [x] 10.1 候选列表、paper trade 列表、research event 列表必须分页。
|
||||
- [x] 10.2 研究任务每次运行只处理 active candidates 和新 activity events,不得全量重算全部历史。
|
||||
- [x] 10.3 为所有详情查询路径增加 repository 查询或聚合,避免 N+1 查询。
|
||||
- [x] 10.4 增加 paper/activity 数据保留策略配置,默认保留足够研究窗口但避免无限增长。
|
||||
- [x] 10.5 新增性能相关测试或验证脚本,至少覆盖 100 个候选、1 万条 activity event、1 万条 paper trade 的列表和一次研究运行。
|
||||
- [x] 10.6 research run 必须使用 cursor/checkpoint 记录上次处理到的 event time 或 stable key;失败重跑时只回看有限窗口。
|
||||
- [x] 10.7 paper processing 每批必须有 batch size 上限、运行时间上限和写入上限,避免一次 run 长时间占用数据库。
|
||||
- [x] 10.8 global activity capture 如果启用,必须有每分钟写入上限、payload 最大长度、失败熔断和可观测计数。
|
||||
- [x] 10.9 候选详情必须用聚合 DTO 或批量查询组装 score、paper summary、source health、pool mapping,禁止对列表中每个 candidate 单独查多张表。
|
||||
|
||||
## 11. 文档与运维
|
||||
|
||||
- [x] 11.1 新增中文文档,说明 Leader Research Agent、Leader 池、Leader 管理和跟单配置的区别。
|
||||
- [x] 11.2 文档明确:研究代理可以自动纸跟和推荐,但绝不会自动启用真钱跟单。
|
||||
- [x] 11.3 文档说明研究状态和 Leader 池状态映射,特别是 `TRIAL_READY` 不等于 `TRIAL`。
|
||||
- [x] 11.4 新增运维说明,覆盖开启/关闭 research job、查看 source health、排查 unknown valuation、排查重复 event 和回滚。
|
||||
- [x] 11.5 更新相关 README 或 docs 索引,指向 Leader Research 文档。
|
||||
- [x] 11.6 文档明确 activity-derived discovery 的真实覆盖范围:watchlist/existing leader Data API backfill、global capture 是否启用、public leaderboard 尚未接入。
|
||||
- [x] 11.7 运维说明必须包含 kill switch:关闭 scheduled research、关闭 global activity capture、隐藏 approval endpoint 或前端入口。
|
||||
|
||||
## 12. 验证
|
||||
|
||||
- [x] 12.1 运行后端 research 相关单元测试和 controller 测试。
|
||||
- [x] 12.2 运行已有 Leader 池、跟单配置、PnL 统计相关测试,确保未破坏现有安全边界。
|
||||
- [x] 12.3 运行前端 TypeScript 构建。
|
||||
- [x] 12.4 运行前端 lint。
|
||||
- [x] 12.5 手动验证 research job 默认关闭、手动 run、source health、候选列表、候选详情、纸跟详情和 pending decisions。
|
||||
- [x] 12.6 手动验证创建禁用试跟配置后 `copy_trading.enabled=false`,且不会自动启用。
|
||||
- [x] 12.7 手动验证 source failure 不会删除候选、不会降级 locked 候选、不会把 unknown valuation 展示成 confirmed zero。
|
||||
- [x] 12.8 在本地或测试环境验证重复 activity event 不会产生重复 paper trade。
|
||||
- [x] 12.9 运行 `./gradlew test --tests "*LeaderResearch*"`,覆盖 research service、state machine、paper trading、approval safety 和 controller。
|
||||
- [x] 12.10 运行 `./gradlew test --tests "*LeaderPool*" --tests "*CopyTrading*" --tests "*Pnl*"`,确保现有 Leader 池、跟单配置和 PnL 语义未回退。
|
||||
- [x] 12.11 使用 gstack/browser 或 Playwright 进行前端安全 QA,覆盖 `TRIAL_READY` 文案、disabled approval modal、双击提交、source degraded、valuation unknown 和移动端关键路径。
|
||||
- [x] 12.12 使用 `/qa` 时优先加载 `/Users/codyhhchen/.gstack/projects/WrBug-PolyHermes/codyhhchen-feat-real-pnl-statistics-eng-review-test-plan-20260504-210925.md` 作为主测试输入。
|
||||
- [x] 12.13 验证当前 `PolymarketActivityWsService` 的已知 leader 过滤不会阻断 research ingestion 的未知钱包发现路径。
|
||||
- [x] 12.14 验证 research job kill switch 生效:关闭后不再推进状态、不再处理 paper events,但页面仍可读历史数据。
|
||||
|
||||
## 13. 一次性实施顺序与合并策略
|
||||
|
||||
- [x] 13.1 第一批实现数据 spine:迁移、entity、repository、enum、source state、run record、activity event 和 processing status。
|
||||
- [x] 13.2 第二批实现 ingestion 与 source:Data API backfill、optional global capture、watchlist、existing leaders、activity-derived candidates 和 source health。
|
||||
- [x] 13.3 第三批实现 paper trading:event claim、paper config、BUY/SELL、filter evaluator、valuation、PnL、drawdown 和重试隔离。
|
||||
- [x] 13.4 第四批实现 scoring/state machine:score v1 公式、状态迁移、locked protection、cooldown/retire 和 research events。
|
||||
- [x] 13.5 第五批实现 Leader 池联动和 disabled approval safety:pool mapping、recommendation badge、disabled-only config creation、duplicate protection 和 safety tests。
|
||||
- [x] 13.6 第六批实现 API 和前端操作台:run status、candidate list/detail、paper sessions、source health、pending decisions、approval modal 和 i18n。
|
||||
- [x] 13.7 第七批实现通知、运维文档、kill switch、性能脚本和全量 QA。
|
||||
- [x] 13.8 每一批都必须先补对应测试再合并到下一批;不得把 safety tests 延后到最后。
|
||||
- [x] 13.9 如果并行工作,数据 spine 是共享依赖;paper trading、frontend shell、notification summary 可以并行,但 approval safety 必须等 DTO/服务边界稳定后再接。
|
||||
|
||||
## 14. Review 整改任务
|
||||
|
||||
- [x] 14.1 修复 locked candidate 安全边界:`LeaderResearchStateMachine.advance()` 对锁定候选必须完全跳过状态、score、badge、summary、Leader 池映射和建议配置写入,不得调用会产生副作用的 `syncCandidate`;补充 locked candidate 不变更 pool/candidate/poolId 的回归测试。
|
||||
- [x] 14.2 修复 `DISCOVERED` 候选污染 Leader 池:状态机或 pool mapping 只能在候选达到 `CANDIDATE` 或更高状态时创建/更新 `copy_trading_leader_pool`,原始 `DISCOVERED` 钱包不得出现在 Leader 池;补充 `DISCOVERED` 不建池、`CANDIDATE+` 才建池的测试。
|
||||
- [x] 14.3 修复创建禁用试跟配置的并发重复问题:为同账户同 leader 恢复数据库级唯一保护或引入等价事务锁/幂等键,确保双击、并发请求和重试最多创建一条 copy-trading config;补充并发审批测试和迁移回归测试。
|
||||
- [x] 14.4 修复 Data API backfill 失败被误报为 source success:`backfillWalletActivities` 失败必须向 `captureSource` 返回 typed failure/partial failure,source health 和 research run 必须显示 failure/degraded,而不是 SUCCESS;补充 Data API failure、success empty、partial failure 的 source health 测试。
|
||||
- [x] 14.5 修复前端 approval preview 硬编码风险参数:确认弹窗必须展示后端返回或候选池建议的 `fixedAmount`、`maxDailyLoss`、`maxDailyOrders`、`minPrice`、`maxPrice`、`maxPositionValue`,不得写死 1 USDC/5 USDC/10 orders/0.10-0.80;补充 UI 数据映射测试或浏览器 QA。
|
||||
- [x] 14.6 修复 candidate 搜索只搜当前页:后端搜索必须在数据库查询层按 wallet/address/source/status 等条件过滤并返回正确 total,不得先分页再内存过滤;补充分页外命中、空结果和 total 计数测试。
|
||||
- [x] 14.7 修复 candidate list N+1 查询:列表 DTO 组装必须批量加载 leader、pool mapping、score/paper summary 等依赖,禁止每个 candidate 单独查多张表;补充 50+ 候选列表查询数量或性能回归测试。
|
||||
- [x] 14.8 补齐 Review 发现对应的验证任务:完成后必须运行前端 build/lint、后端 `LeaderResearch` 相关测试、Leader 池/跟单配置/PnL 回归测试,并在本机没有 Java Runtime 时先安装或切换到可运行 Java 的环境再执行后端测试。
|
||||
- [x] 14.9 完成未勾选的安全和 QA 任务后再关闭 change:至少覆盖 activity source health、候选来源、研究运行、纸跟、评分状态机、approval safety、controller、通知、数据保留、手动 QA 和浏览器 QA;不得只修 review 代码而保留关键验证项未完成。
|
||||
@@ -0,0 +1,179 @@
|
||||
-- Leader Research Agent performance fixture and query plan helper.
|
||||
-- Intended for a disposable local/test database after migrations have run.
|
||||
-- It seeds at least 100 candidates, 10k activity events, and 10k paper trades,
|
||||
-- then runs EXPLAIN on the hot list/detail paths.
|
||||
|
||||
SET @now_ms = UNIX_TIMESTAMP(CURRENT_TIMESTAMP(3)) * 1000;
|
||||
|
||||
WITH RECURSIVE seq(n) AS (
|
||||
SELECT 1
|
||||
UNION ALL
|
||||
SELECT n + 1 FROM seq WHERE n < 10000
|
||||
)
|
||||
INSERT IGNORE INTO leader_research_candidate (
|
||||
normalized_wallet,
|
||||
research_state,
|
||||
source,
|
||||
score,
|
||||
score_version,
|
||||
agent_owned,
|
||||
provenance,
|
||||
first_seen_at,
|
||||
last_source_seen_at,
|
||||
last_transition_at,
|
||||
created_at,
|
||||
updated_at
|
||||
)
|
||||
SELECT
|
||||
CONCAT('0x', LPAD(HEX(n), 40, '0')),
|
||||
CASE WHEN n % 5 = 0 THEN 'TRIAL_READY' WHEN n % 3 = 0 THEN 'PAPER' ELSE 'CANDIDATE' END,
|
||||
'PERF_FIXTURE',
|
||||
60 + (n % 40),
|
||||
'research-copyability-v1',
|
||||
1,
|
||||
'AGENT_CREATED',
|
||||
@now_ms - 3600000,
|
||||
@now_ms - 60000,
|
||||
@now_ms - 60000,
|
||||
@now_ms,
|
||||
@now_ms
|
||||
FROM seq
|
||||
WHERE n <= 100;
|
||||
|
||||
WITH RECURSIVE seq(n) AS (
|
||||
SELECT 1
|
||||
UNION ALL
|
||||
SELECT n + 1 FROM seq WHERE n < 10000
|
||||
)
|
||||
INSERT IGNORE INTO leader_activity_event (
|
||||
source,
|
||||
source_event_id,
|
||||
stable_event_key,
|
||||
normalized_wallet,
|
||||
market_id,
|
||||
asset,
|
||||
side,
|
||||
price,
|
||||
size,
|
||||
amount,
|
||||
event_time,
|
||||
raw_payload_hash,
|
||||
payload_summary,
|
||||
usable_for_discovery,
|
||||
usable_for_paper,
|
||||
paper_processing_status,
|
||||
processing_attempts,
|
||||
created_at,
|
||||
updated_at
|
||||
)
|
||||
SELECT
|
||||
'PERF_FIXTURE',
|
||||
CONCAT('perf-event-', n),
|
||||
CONCAT('perf-event-', n),
|
||||
CONCAT('0x', LPAD(HEX(1 + (n % 100)), 40, '0')),
|
||||
CONCAT('condition-', n % 200),
|
||||
CONCAT('asset-', n % 200),
|
||||
IF(n % 2 = 0, 'BUY', 'SELL'),
|
||||
0.45,
|
||||
10,
|
||||
4.5,
|
||||
@now_ms - (n * 1000),
|
||||
SHA2(CONCAT('perf-event-', n), 256),
|
||||
CONCAT('perf event ', n),
|
||||
1,
|
||||
1,
|
||||
'NEW',
|
||||
0,
|
||||
@now_ms,
|
||||
@now_ms
|
||||
FROM seq;
|
||||
|
||||
WITH RECURSIVE seq(n) AS (
|
||||
SELECT 1
|
||||
UNION ALL
|
||||
SELECT n + 1 FROM seq WHERE n < 10000
|
||||
)
|
||||
INSERT IGNORE INTO leader_paper_session (
|
||||
id,
|
||||
candidate_id,
|
||||
status,
|
||||
started_at,
|
||||
trade_count,
|
||||
filtered_count,
|
||||
open_exposure,
|
||||
copyable_pnl,
|
||||
max_drawdown,
|
||||
unknown_valuation_exposure,
|
||||
confirmed_zero_exposure,
|
||||
filtered_ratio,
|
||||
created_at,
|
||||
updated_at
|
||||
)
|
||||
SELECT
|
||||
n,
|
||||
1 + (n % 100),
|
||||
'ACTIVE',
|
||||
@now_ms - 604800000,
|
||||
100,
|
||||
5,
|
||||
100,
|
||||
10,
|
||||
-3,
|
||||
5,
|
||||
0,
|
||||
0.0476,
|
||||
@now_ms,
|
||||
@now_ms
|
||||
FROM seq
|
||||
WHERE n <= 100;
|
||||
|
||||
WITH RECURSIVE seq(n) AS (
|
||||
SELECT 1
|
||||
UNION ALL
|
||||
SELECT n + 1 FROM seq WHERE n < 10000
|
||||
)
|
||||
INSERT IGNORE INTO leader_paper_trade (
|
||||
session_id,
|
||||
candidate_id,
|
||||
leader_trade_id,
|
||||
market_id,
|
||||
side,
|
||||
leader_price,
|
||||
leader_size,
|
||||
simulated_price,
|
||||
simulated_size,
|
||||
simulated_amount,
|
||||
fill_assumption,
|
||||
quote_confidence,
|
||||
quote_source,
|
||||
quote_timestamp,
|
||||
filter_result,
|
||||
valuation_status,
|
||||
event_time,
|
||||
created_at
|
||||
)
|
||||
SELECT
|
||||
1 + (n % 100),
|
||||
1 + (n % 100),
|
||||
CONCAT('perf-trade-', n),
|
||||
CONCAT('condition-', n % 200),
|
||||
IF(n % 2 = 0, 'BUY', 'SELL'),
|
||||
0.45,
|
||||
10,
|
||||
0.45,
|
||||
2,
|
||||
1,
|
||||
'LEADER_PRICE',
|
||||
'MEDIUM',
|
||||
'perf',
|
||||
@now_ms,
|
||||
'PASSED',
|
||||
IF(n % 20 = 0, 'UNKNOWN', 'AVAILABLE'),
|
||||
@now_ms - (n * 1000),
|
||||
@now_ms
|
||||
FROM seq;
|
||||
|
||||
EXPLAIN SELECT * FROM leader_research_candidate WHERE research_state IN ('PAPER', 'TRIAL_READY') ORDER BY updated_at DESC LIMIT 50;
|
||||
EXPLAIN SELECT * FROM leader_activity_event WHERE paper_processing_status IN ('NEW', 'RETRYABLE') AND usable_for_paper = 1 ORDER BY event_time ASC LIMIT 200;
|
||||
EXPLAIN SELECT * FROM leader_paper_trade WHERE session_id = 1 ORDER BY event_time DESC LIMIT 100;
|
||||
EXPLAIN SELECT * FROM leader_research_event ORDER BY created_at DESC LIMIT 100;
|
||||
Reference in New Issue
Block a user