feat: add leader research agent

This commit is contained in:
codyhhchen
2026-05-05 18:17:51 +08:00
committed by codychen123
parent 82ecf31867
commit a3f74b8567
64 changed files with 8023 additions and 12 deletions
@@ -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>
}
@@ -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>
}
@@ -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
)
@@ -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.addressfallback 到 proxyWallet
@@ -574,5 +675,8 @@ class PolymarketActivityWsService(
stop()
scope.cancel()
}
}
companion object {
private const val RESEARCH_CAPTURE_HEALTH_THROTTLE_MS = 60_000L
}
}
@@ -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) }
}
}
@@ -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
}
}
@@ -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()
}
@@ -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
}
}
}
@@ -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)
}
}
}
@@ -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()
}
@@ -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
)
}
}
@@ -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
)
)
}
}
@@ -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
}
}
@@ -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
}
}
@@ -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) }
}
}
@@ -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)
}
}
@@ -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."
}
}
@@ -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=獲取市場價格失敗
@@ -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
)
@@ -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)
}
}
@@ -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)
}
@@ -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)
}
@@ -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)
}
@@ -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)
}
@@ -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)
}
@@ -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)
}
@@ -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)
}
@@ -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)
}
@@ -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)
}
@@ -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)
}
@@ -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)
}