feat: add leader research agent
This commit is contained in:
+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)
|
||||
}
|
||||
Reference in New Issue
Block a user