feat: add leader research agent

This commit is contained in:
codyhhchen
2026-05-05 18:15:15 +08:00
committed by codychen123
parent 82ecf31867
commit a3f74b8567
64 changed files with 8023 additions and 12 deletions
+1
View File
@@ -95,6 +95,7 @@ coverage/
# Test
test-results/
*.test.log
.playwright-cli/
# Python
__pycache__/
+1
View File
@@ -422,6 +422,7 @@ cd frontend
- [跟单系统需求文档](docs/zh/copy-trading-requirements.md) - 后端 API 接口文档
- [前端需求文档](docs/zh/copy-trading-frontend-requirements.md) - 前端功能文档
- [跟单亏损诊断与风险安全带](docs/zh/copy-trading-risk-seatbelt.md) - PnL 诊断、安全带确认流程和运维排查命令
- [Leader Research Agent](docs/zh/leader-research-agent.md) - 自动发现、纸跟、评分和禁用试跟审批说明
- [动态更新文档](docs/zh/DYNAMIC_UPDATE.md) - 动态更新功能说明
### 🤝 贡献指南
+1 -1
View File
@@ -420,6 +420,7 @@ The development documentation includes:
- [Development Documentation](docs/en/DEVELOPMENT.md) - Development guide
- [Copy Trading System Requirements](docs/zh/copy-trading-requirements.md) - Backend API documentation (Chinese only)
- [Frontend Requirements](docs/zh/copy-trading-frontend-requirements.md) - Frontend feature documentation (Chinese only)
- [Leader Research Agent](docs/zh/leader-research-agent.md) - Auto-discovery, paper trading, scoring, and disabled-trial approval guide (Chinese only)
### 🤝 Contributing
@@ -467,4 +468,3 @@ Thanks to all developers and users who have contributed to this project!
---
**⭐ If this project helps you, please give it a Star!**
@@ -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)
}
+65
View File
@@ -0,0 +1,65 @@
# Leader Research Agent
Leader Research Agent 用来自动发现潜在优秀 leader、做纸上跟单、评分并给出试跟建议。它不会自动启用真钱跟单。
## 使用方式
1. 打开「跟单交易 -> Leader 研究」。
2. 点击「立即运行研究」拉取 watchlist、已有 Leader 和已持久化 activity-derived 候选。
3. 在候选表查看状态、评分、纸跟 PnL、过滤比例和估值状态。
4. 只有 `建议试跟` 状态的候选可以点击「创建禁用试跟」。
5. 创建后系统只生成 `enabled=false` 的跟单配置;如果你决定真钱跟单,需要到「跟单配置」页手动启用。
## 研究状态
- `DISCOVERED`: 已发现,但评分或新鲜度不足。
- `CANDIDATE`: 满足基础评分和新鲜度,可以进入纸跟。
- `PAPER`: 正在用独立纸跟账本模拟跟单。
- `TRIAL_READY`: 纸跟样本、PnL、回撤、未知报价暴露和过滤比例满足阈值。
- `COOLDOWN`: 回撤、亏损、来源陈旧或退出流动性风险触发冷却。
- `RETIRED`: 多轮冷却或长时间无新鲜来源后淘汰。
## 评分公式
当前版本为 `research-copyability-v1`。总分满分 100,分项权重如下:
- profit signal 20、repeatability 15、liquidity fit 10、entry price fit 10、slippage risk 10。
- drawdown risk 10、holding period fit 5、market type risk 5、exit liquidity risk 5、data freshness 5、filter pass rate 5。
缺数据会保守扣分:没有纸跟 session 或报价 UNKNOWN 时,不会把未知估值当成亏到 0,但会降低 liquidity、slippage、exit liquidity 等分项。样本不足 10 笔时总分 cap 到 59,不能进入 `TRIAL_READY`
## 来源限制
v1 只启用三类来源:
- watchlist: `system_config.config_key = leader_research.watchlist`,值可以用逗号、空格、换行分隔钱包地址。
- existing leader: 已在 Leader 管理里的地址。
- activity-derived: `leader_activity_event` 里已经持久化、可归因、较新的活动事件。
public leaderboard 当前显式禁用,页面会展示 source limitation,避免误以为系统已经能全网发现。
## 运维与开关
- 定时任务默认关闭:`leader.research.enabled=false`
- 手动运行不依赖定时开关,可以在「Leader 研究」页面点击「立即运行研究」。
- 全局 activity capture 默认关闭:`leader.research.global-capture.enabled=false`
- 全局 capture 写入上限:`leader.research.global-capture.max-writes-per-minute=120`
- Data API bounded backfill 每个钱包默认最多拉取 `leader.research.data-api-backfill.limit=200` 条,最多处理 50 个钱包。
- kill switch: 关闭 `leader.research.enabled` 可停止自动推进;关闭 `leader.research.global-capture.enabled` 可停止地址过滤前的全局事件捕获;前端入口可以隐藏但历史研究数据仍可读。
- 性能验证脚本:`scripts/leader-research-perf-check.sql` 可在一次性测试库中生成 100 个候选、1 万条 activity event 和 1 万条 paper trade,并对热查询执行 `EXPLAIN`
## 排查
- source health 显示 `DISABLED`: 检查是否是 public leaderboard 或 global capture 未启用。
- source health 显示 `FAILURE`: 查看 recent research events 中的 `SOURCE_FAILURE`
- 估值为 `UNKNOWN``UNAVAILABLE`: 代表无法确认当前市场价格,不会被当成亏到 0。
- 重复 activity event: 依赖 `stable_event_key``leader_paper_trade(session_id, leader_trade_id, side)` 去重。
- 重复审批: 同账户同 leader 已有配置时会拒绝,不会创建第二条真钱或试跟配置。
## 安全边界
- 研究状态和 Leader Pool 状态分离。
- `TRIAL_READY` 只是推荐 badge,不代表真钱跟单已启用。
- 审批接口只创建禁用配置,并强制 `enabled=false`
- 纸跟账本使用 `leader_paper_session``leader_paper_trade``leader_paper_position`,不写入真钱订单跟踪表。
- `UNKNOWN` 估值不会被当成 confirmed zero 计入收益。
+2
View File
@@ -15,6 +15,7 @@ import AccountDetail from './pages/AccountDetail'
import AccountEdit from './pages/AccountEdit'
import LeaderList from './pages/LeaderList'
import LeaderPool from './pages/LeaderPool'
import LeaderResearch from './pages/LeaderResearch'
import LeaderAdd from './pages/LeaderAdd'
import LeaderEdit from './pages/LeaderEdit'
import ConfigPage from './pages/ConfigPage'
@@ -267,6 +268,7 @@ function App() {
<Route path="/accounts/edit" element={<ProtectedRoute><AccountEdit /></ProtectedRoute>} />
<Route path="/leaders" element={<ProtectedRoute><LeaderList /></ProtectedRoute>} />
<Route path="/leader-pool" element={<ProtectedRoute><LeaderPool /></ProtectedRoute>} />
<Route path="/leader-research" element={<ProtectedRoute><LeaderResearch /></ProtectedRoute>} />
<Route path="/leaders/add" element={<ProtectedRoute><LeaderAdd /></ProtectedRoute>} />
<Route path="/leaders/edit" element={<ProtectedRoute><LeaderEdit /></ProtectedRoute>} />
<Route path="/templates" element={<ProtectedRoute><TemplateList /></ProtectedRoute>} />
+9 -3
View File
@@ -23,7 +23,8 @@ import {
NotificationOutlined,
LineChartOutlined,
RocketOutlined,
DashboardOutlined
DashboardOutlined,
ExperimentOutlined
} from '@ant-design/icons'
import type { MenuProps } from 'antd'
import type { ReactNode } from 'react'
@@ -74,7 +75,7 @@ const Layout: React.FC<LayoutProps> = ({ children }) => {
const getInitialOpenKeys = (): string[] => {
const path = location.pathname
const keys: string[] = []
if (path.startsWith('/leaders') || path.startsWith('/leader-pool') || path.startsWith('/templates') || path.startsWith('/copy-trading') || path.startsWith('/backtest')) {
if (path.startsWith('/leaders') || path.startsWith('/leader-pool') || path.startsWith('/leader-research') || path.startsWith('/templates') || path.startsWith('/copy-trading') || path.startsWith('/backtest')) {
keys.push('/copy-trading-management')
}
if (path.startsWith('/crypto-tail-strategy') || path.startsWith('/crypto-tail-monitor')) {
@@ -92,7 +93,7 @@ const Layout: React.FC<LayoutProps> = ({ children }) => {
useEffect(() => {
const path = location.pathname
const keys: string[] = []
if (path.startsWith('/leaders') || path.startsWith('/leader-pool') || path.startsWith('/templates') || path.startsWith('/copy-trading') || path.startsWith('/backtest')) {
if (path.startsWith('/leaders') || path.startsWith('/leader-pool') || path.startsWith('/leader-research') || path.startsWith('/templates') || path.startsWith('/copy-trading') || path.startsWith('/backtest')) {
keys.push('/copy-trading-management')
}
if (path.startsWith('/crypto-tail-strategy') || path.startsWith('/crypto-tail-monitor')) {
@@ -153,6 +154,11 @@ const Layout: React.FC<LayoutProps> = ({ children }) => {
icon: <TeamOutlined />,
label: t('menu.leaderPool')
},
{
key: '/leader-research',
icon: <ExperimentOutlined />,
label: t('menu.leaderResearch')
},
{
key: '/leaders',
icon: <UserOutlined />,
+67 -1
View File
@@ -4,6 +4,9 @@
"save": "Save",
"cancel": "Cancel",
"edit": "Edit",
"detail": "Detail",
"overview": "Overview",
"time": "Time",
"viewDetail": "View Details",
"delete": "Delete",
"add": "Add",
@@ -315,6 +318,7 @@
"templates": "Templates",
"copyTradingConfig": "Copy Trading Config",
"leaderPool": "Leader Pool",
"leaderResearch": "Leader Research",
"cryptoSpreadStrategy": "Crypto Spread Strategy",
"cryptoTailStrategy": "Strategy Config",
"cryptoTailMonitor": "Real-time Monitor",
@@ -623,7 +627,69 @@
"goCopyTrading": "View Copy Trading",
"removeConfirm": "This only removes the pool item. It will not delete the Leader address or existing copy trading configs. Continue?",
"removeSuccess": "Pool item removed",
"removeFailed": "Failed to remove pool item"
"removeFailed": "Failed to remove pool item",
"viewResearch": "View Research"
},
"leaderResearch": {
"title": "Leader Research",
"subtitle": "Automatically discover, paper trade, and score strong leaders. Real-money copying still requires your manual approval and enablement.",
"safetyTitle": "The research agent never auto-enables real-money copy trading",
"safetyDesc": "The agent only advances research state, maintains the paper ledger, and recommends trials. Approval creates a disabled config by default.",
"fetchFailed": "Failed to fetch Leader Research data",
"runNow": "Run Research",
"runStarted": "Research run completed",
"runFailed": "Research run failed",
"sourceLimitations": "Source Limitations",
"sourceHealth": "Source Health",
"filterState": "Filter by research state",
"searchPlaceholder": "Search wallet or reason",
"empty": "No research candidates yet. Configure a watchlist or wait for persisted activity events.",
"wallet": "Wallet",
"score": "Score",
"paper": "Paper",
"source": "Source",
"lastSeen": "Last Seen",
"copyablePnl": "Copyable PnL",
"trades": "Trades",
"filtered": "Filtered",
"candidates": "Candidates",
"detailTitle": "Research Detail",
"reason": "Reason",
"riskFlags": "Risk Flags",
"sourceEvidence": "Source Evidence",
"scoreBreakdown": "Score Breakdown",
"paperTrades": "Paper Trades",
"paperPositions": "Paper Positions",
"events": "Events",
"eventType": "Event Type",
"createDisabledTrial": "Create Disabled Trial",
"approvalSafetyTitle": "This only creates a disabled config",
"approvalSafetyDesc": "You must manually enable it on the copy trading config page before any real-money copy trading happens.",
"approvalCreated": "Disabled trial config created",
"approvalFailed": "Failed to create disabled trial",
"trialReadyHint": "Suggested small trial, awaiting your confirmation",
"runStatus": "Run Status",
"lastRun": "Last Run",
"duration": "Duration",
"sourceCounts": "Source Counts",
"candidateCounts": "Candidate Counts",
"noRuns": "No research runs yet",
"pendingDecisions": "Pending Decisions",
"noPendingDecisions": "No candidates awaiting approval",
"approvalPreview": "Conservative config to be created",
"fixedAmount": "Fixed Amount",
"maxDailyLoss": "Max Daily Loss",
"maxDailyOrders": "Max Daily Orders",
"priceRange": "Price Range",
"maxPositionValue": "Max Position Value",
"states": {
"DISCOVERED": "Discovered",
"CANDIDATE": "Candidate",
"PAPER": "Paper",
"TRIAL_READY": "Trial Ready",
"COOLDOWN": "Cooldown",
"RETIRED": "Retired"
}
},
"leaderAdd": {
"title": "Add Leader",
+67 -1
View File
@@ -7,6 +7,9 @@
"later": "稍后",
"delete": "删除",
"edit": "编辑",
"detail": "详情",
"overview": "概览",
"time": "时间",
"viewDetail": "查看详情",
"add": "添加",
"refresh": "刷新",
@@ -315,6 +318,7 @@
"templates": "跟单模板",
"copyTradingConfig": "跟单配置",
"leaderPool": "Leader 池",
"leaderResearch": "Leader 研究",
"cryptoSpreadStrategy": "加密价差策略",
"cryptoTailStrategy": "策略配置",
"cryptoTailMonitor": "实时监控",
@@ -623,7 +627,69 @@
"goCopyTrading": "查看跟单配置",
"removeConfirm": "只移除池子项,不会删除 Leader 地址或已有跟单配置。确定继续?",
"removeSuccess": "池子项已移除",
"removeFailed": "移除池子项失败"
"removeFailed": "移除池子项失败",
"viewResearch": "查看研究"
},
"leaderResearch": {
"title": "Leader 研究",
"subtitle": "自动发现、纸上跟单和评分优秀 leader;真钱跟单必须由你手动审批和启用。",
"safetyTitle": "研究 Agent 不会自动真钱跟单",
"safetyDesc": "Agent 只会推进研究状态、维护纸跟账本、给出试跟建议。审批时也只创建默认禁用的跟单配置。",
"fetchFailed": "获取 Leader 研究数据失败",
"runNow": "立即运行研究",
"runStarted": "研究运行完成",
"runFailed": "运行研究失败",
"sourceLimitations": "来源限制",
"sourceHealth": "来源健康",
"filterState": "按研究状态筛选",
"searchPlaceholder": "搜索钱包或原因",
"empty": "暂无研究候选。可以先配置 watchlist 或等待 activity 事件持久化。",
"wallet": "钱包",
"score": "评分",
"paper": "纸跟",
"source": "来源",
"lastSeen": "最后发现",
"copyablePnl": "可归因 PnL",
"trades": "成交",
"filtered": "过滤",
"candidates": "候选",
"detailTitle": "研究详情",
"reason": "原因",
"riskFlags": "风险标记",
"sourceEvidence": "来源证据",
"scoreBreakdown": "评分拆解",
"paperTrades": "纸跟交易",
"paperPositions": "纸跟仓位",
"events": "事件",
"eventType": "事件类型",
"createDisabledTrial": "创建禁用试跟",
"approvalSafetyTitle": "只会创建禁用配置",
"approvalSafetyDesc": "创建后你需要到跟单配置页手动启用,才会发生真钱跟单。",
"approvalCreated": "已创建禁用试跟配置",
"approvalFailed": "创建禁用试跟失败",
"trialReadyHint": "建议小额试跟,待你确认",
"runStatus": "运行状态",
"lastRun": "最近运行",
"duration": "耗时",
"sourceCounts": "来源统计",
"candidateCounts": "候选统计",
"noRuns": "暂无运行记录",
"pendingDecisions": "待处理决策",
"noPendingDecisions": "暂无待审批候选",
"approvalPreview": "将创建的保守配置",
"fixedAmount": "固定金额",
"maxDailyLoss": "每日最大亏损",
"maxDailyOrders": "每日最大订单数",
"priceRange": "价格范围",
"maxPositionValue": "最大仓位金额",
"states": {
"DISCOVERED": "已发现",
"CANDIDATE": "候选",
"PAPER": "纸跟中",
"TRIAL_READY": "建议试跟",
"COOLDOWN": "冷却",
"RETIRED": "淘汰"
}
},
"leaderAdd": {
"title": "添加 Leader",
+67 -1
View File
@@ -4,6 +4,9 @@
"save": "保存",
"cancel": "取消",
"edit": "編輯",
"detail": "詳情",
"overview": "概覽",
"time": "時間",
"viewDetail": "查看詳情",
"delete": "刪除",
"add": "添加",
@@ -315,6 +318,7 @@
"templates": "跟單模板",
"copyTradingConfig": "跟單配置",
"leaderPool": "Leader 池",
"leaderResearch": "Leader 研究",
"cryptoSpreadStrategy": "加密價差策略",
"cryptoTailStrategy": "策略配置",
"cryptoTailMonitor": "即時監控",
@@ -623,7 +627,69 @@
"goCopyTrading": "查看跟單配置",
"removeConfirm": "只移除池子項,不會刪除 Leader 地址或已有跟單配置。確定繼續?",
"removeSuccess": "池子項已移除",
"removeFailed": "移除池子項失敗"
"removeFailed": "移除池子項失敗",
"viewResearch": "查看研究"
},
"leaderResearch": {
"title": "Leader 研究",
"subtitle": "自動發現、紙上跟單和評分優秀 leader;真錢跟單必須由你手動審批和啟用。",
"safetyTitle": "研究 Agent 不會自動真錢跟單",
"safetyDesc": "Agent 只會推進研究狀態、維護紙跟帳本、給出試跟建議。審批時也只建立預設停用的跟單配置。",
"fetchFailed": "取得 Leader 研究資料失敗",
"runNow": "立即運行研究",
"runStarted": "研究運行完成",
"runFailed": "運行研究失敗",
"sourceLimitations": "來源限制",
"sourceHealth": "來源健康",
"filterState": "按研究狀態篩選",
"searchPlaceholder": "搜尋錢包或原因",
"empty": "暫無研究候選。可以先配置 watchlist 或等待 activity 事件持久化。",
"wallet": "錢包",
"score": "評分",
"paper": "紙跟",
"source": "來源",
"lastSeen": "最後發現",
"copyablePnl": "可歸因 PnL",
"trades": "成交",
"filtered": "過濾",
"candidates": "候選",
"detailTitle": "研究詳情",
"reason": "原因",
"riskFlags": "風險標記",
"sourceEvidence": "來源證據",
"scoreBreakdown": "評分拆解",
"paperTrades": "紙跟交易",
"paperPositions": "紙跟倉位",
"events": "事件",
"eventType": "事件類型",
"createDisabledTrial": "建立停用試跟",
"approvalSafetyTitle": "只會建立停用配置",
"approvalSafetyDesc": "建立後你需要到跟單配置頁手動啟用,才會發生真錢跟單。",
"approvalCreated": "已建立停用試跟配置",
"approvalFailed": "建立停用試跟失敗",
"trialReadyHint": "建議小額試跟,待你確認",
"runStatus": "運行狀態",
"lastRun": "最近運行",
"duration": "耗時",
"sourceCounts": "來源統計",
"candidateCounts": "候選統計",
"noRuns": "暫無運行記錄",
"pendingDecisions": "待處理決策",
"noPendingDecisions": "暫無待審批候選",
"approvalPreview": "將建立的保守配置",
"fixedAmount": "固定金額",
"maxDailyLoss": "每日最大虧損",
"maxDailyOrders": "每日最大訂單數",
"priceRange": "價格範圍",
"maxPositionValue": "最大倉位金額",
"states": {
"DISCOVERED": "已發現",
"CANDIDATE": "候選",
"PAPER": "紙跟中",
"TRIAL_READY": "建議試跟",
"COOLDOWN": "冷卻",
"RETIRED": "淘汰"
}
},
"leaderAdd": {
"title": "添加 Leader",
+11
View File
@@ -269,6 +269,17 @@ const LeaderPool: React.FC = () => {
<Text copyable style={{ fontSize: 12, fontFamily: 'monospace' }} type="secondary">
{item.leaderAddress}
</Text>
{item.researchBadge && (
<Space size={4}>
<Tag color={item.researchState === 'TRIAL_READY' ? 'green' : 'blue'}>{t(`leaderResearch.states.${item.researchState}`, { defaultValue: item.researchState })}</Tag>
{item.researchScore && <Text type="secondary">{t('leaderResearch.score')}: {item.researchScore}</Text>}
</Space>
)}
{item.researchCandidateId && (
<Button type="link" size="small" style={{ padding: 0 }} onClick={() => navigate('/leader-research')}>
{t('leaderPool.viewResearch')}
</Button>
)}
</Space>
)
},
+577
View File
@@ -0,0 +1,577 @@
import { useEffect, useState } from 'react'
import {
Alert,
Badge,
Button,
Card,
Col,
Descriptions,
Drawer,
Empty,
Form,
Input,
Modal,
Row,
Select,
Space,
Statistic,
Table,
Tabs,
Tag,
Typography,
message
} from 'antd'
import {
ExperimentOutlined,
PlayCircleOutlined,
ReloadOutlined,
SafetyCertificateOutlined
} from '@ant-design/icons'
import dayjs from 'dayjs'
import { useTranslation } from 'react-i18next'
import { apiService } from '../services/api'
import type {
Account,
LeaderPaperPosition,
LeaderPaperTrade,
LeaderResearchCandidate,
LeaderResearchCandidateDetail,
LeaderResearchCandidateListResponse,
LeaderResearchSourceState,
LeaderResearchState,
LeaderResearchSummary
} from '../types'
const { Paragraph, Text, Title } = Typography
const STATE_COLORS: Record<LeaderResearchState, string> = {
DISCOVERED: 'default',
CANDIDATE: 'blue',
PAPER: 'geekblue',
TRIAL_READY: 'green',
COOLDOWN: 'orange',
RETIRED: 'red'
}
const VALUATION_COLORS: Record<string, string> = {
AVAILABLE: 'green',
CONFIRMED_ZERO: 'purple',
UNKNOWN: 'orange',
UNAVAILABLE: 'red',
NO_MATCH: 'volcano'
}
const formatDate = (timestamp?: number) => {
if (!timestamp) return '-'
return dayjs(timestamp).format('YYYY-MM-DD HH:mm')
}
const usdc = (value?: string) => value ? `${value} USDC` : '-'
const approvalPreview = (candidate?: LeaderResearchCandidate | null) => ({
fixedAmount: usdc(candidate?.suggestedFixedAmount),
maxDailyLoss: usdc(candidate?.suggestedMaxDailyLoss),
maxDailyOrders: candidate?.suggestedMaxDailyOrders ?? '-',
priceRange: candidate?.suggestedMinPrice || candidate?.suggestedMaxPrice
? `${candidate?.suggestedMinPrice ?? '-'} - ${candidate?.suggestedMaxPrice ?? '-'}`
: '-',
maxPositionValue: usdc(candidate?.suggestedMaxPositionValue)
})
const valuationTag = (status?: string) => {
if (!status) return <Tag>-</Tag>
return <Tag color={VALUATION_COLORS[status] || 'default'}>{status}</Tag>
}
const LeaderResearch: React.FC = () => {
const { t } = useTranslation()
const [summary, setSummary] = useState<LeaderResearchSummary | null>(null)
const [candidates, setCandidates] = useState<LeaderResearchCandidateListResponse>({ list: [], total: 0, summary: summaryFallback })
const [sourceHealth, setSourceHealth] = useState<LeaderResearchSourceState[]>([])
const [accounts, setAccounts] = useState<Account[]>([])
const [stateFilter, setStateFilter] = useState<LeaderResearchState | undefined>()
const [query, setQuery] = useState('')
const [loading, setLoading] = useState(false)
const [running, setRunning] = useState(false)
const [detailLoading, setDetailLoading] = useState(false)
const [detail, setDetail] = useState<LeaderResearchCandidateDetail | null>(null)
const [approvalCandidate, setApprovalCandidate] = useState<LeaderResearchCandidate | null>(null)
const [approvalLoading, setApprovalLoading] = useState(false)
const [approvalForm] = Form.useForm()
const loadAll = async () => {
setLoading(true)
try {
const [candidateResp, summaryResp, sourceResp, accountResp] = await Promise.all([
apiService.leaderResearch.listCandidates({ page: 0, size: 50, state: stateFilter, query: query || undefined }),
apiService.leaderResearch.summary(),
apiService.leaderResearch.sourceHealth(),
apiService.accounts.list()
])
if (candidateResp.data.code === 0 && candidateResp.data.data) {
setCandidates(candidateResp.data.data)
} else {
message.error(candidateResp.data.msg || t('leaderResearch.fetchFailed'))
}
if (summaryResp.data.code === 0 && summaryResp.data.data) {
setSummary(summaryResp.data.data)
}
if (sourceResp.data.code === 0 && sourceResp.data.data) {
setSourceHealth(sourceResp.data.data)
}
if (accountResp.data.code === 0 && accountResp.data.data) {
setAccounts(accountResp.data.data.list || [])
}
} catch (error: any) {
message.error(error.message || t('leaderResearch.fetchFailed'))
} finally {
setLoading(false)
}
}
useEffect(() => {
loadAll()
}, [stateFilter])
const runAgent = async () => {
setRunning(true)
try {
const response = await apiService.leaderResearch.run({ dryRun: false, triggerType: 'MANUAL' })
if (response.data.code === 0) {
message.success(t('leaderResearch.runStarted'))
await loadAll()
} else {
message.error(response.data.msg || t('leaderResearch.runFailed'))
}
} catch (error: any) {
message.error(error.message || t('leaderResearch.runFailed'))
} finally {
setRunning(false)
}
}
const openDetail = async (candidate: LeaderResearchCandidate) => {
setDetailLoading(true)
try {
const response = await apiService.leaderResearch.detail({ candidateId: candidate.id })
if (response.data.code === 0 && response.data.data) {
setDetail(response.data.data)
} else {
message.error(response.data.msg || t('leaderResearch.fetchFailed'))
}
} finally {
setDetailLoading(false)
}
}
const openApproval = (candidate: LeaderResearchCandidate) => {
setApprovalCandidate(candidate)
approvalForm.setFieldsValue({ accountId: accounts[0]?.id })
}
const submitApproval = async () => {
if (!approvalCandidate) return
const values = await approvalForm.validateFields()
setApprovalLoading(true)
try {
const response = await apiService.leaderResearch.createDisabledTrialConfig({
candidateId: approvalCandidate.id,
accountId: values.accountId,
confirm: true
})
if (response.data.code === 0) {
message.success(t('leaderResearch.approvalCreated'))
setApprovalCandidate(null)
await loadAll()
} else {
message.error(response.data.msg || t('leaderResearch.approvalFailed'))
}
} catch (error: any) {
message.error(error.message || t('leaderResearch.approvalFailed'))
} finally {
setApprovalLoading(false)
}
}
const activeSummary = summary || candidates.summary || summaryFallback
const pendingDecisions = candidates.list.filter(candidate => candidate.researchState === 'TRIAL_READY')
const lastRun = activeSummary.lastRun
const activeApprovalPreview = approvalPreview(approvalCandidate)
const columns = [
{
title: t('leaderResearch.wallet'),
key: 'wallet',
width: 260,
render: (_: unknown, item: LeaderResearchCandidate) => (
<Space direction="vertical" size={0}>
<Text strong>{item.leaderName || item.normalizedWallet.slice(0, 10)}</Text>
<Text copyable type="secondary" style={{ fontSize: 12, fontFamily: 'monospace' }}>
{item.normalizedWallet}
</Text>
</Space>
)
},
{
title: t('common.status'),
dataIndex: 'researchState',
width: 130,
render: (state: LeaderResearchState) => (
<Space direction="vertical" size={0}>
<Tag color={STATE_COLORS[state]}>{t(`leaderResearch.states.${state}`, { defaultValue: state })}</Tag>
{state === 'TRIAL_READY' && (
<Text type="secondary" style={{ fontSize: 12 }}>{t('leaderResearch.trialReadyHint')}</Text>
)}
</Space>
)
},
{
title: t('leaderResearch.score'),
dataIndex: 'score',
width: 100,
render: (score?: string) => <Text strong>{score || '-'}</Text>
},
{
title: t('leaderResearch.paper'),
key: 'paper',
width: 220,
render: (_: unknown, item: LeaderResearchCandidate) => {
const session = item.latestPaperSession
if (!session) return <Text type="secondary">-</Text>
return (
<Space direction="vertical" size={0}>
<Text>{t('leaderResearch.copyablePnl')}: {session.copyablePnl}</Text>
<Text type="secondary">{t('leaderResearch.trades')}: {session.tradeCount} / {t('leaderResearch.filtered')}: {session.filteredCount}</Text>
</Space>
)
}
},
{
title: t('leaderResearch.source'),
dataIndex: 'source',
width: 160
},
{
title: t('leaderResearch.lastSeen'),
dataIndex: 'lastSourceSeenAt',
width: 160,
render: (value?: number) => formatDate(value)
},
{
title: t('common.actions'),
key: 'actions',
fixed: 'right' as const,
width: 230,
render: (_: unknown, item: LeaderResearchCandidate) => (
<Space wrap>
<Button size="small" onClick={() => openDetail(item)}>
{t('common.detail')}
</Button>
<Button
size="small"
type="primary"
icon={<SafetyCertificateOutlined />}
disabled={item.researchState !== 'TRIAL_READY'}
onClick={() => openApproval(item)}
>
{t('leaderResearch.createDisabledTrial')}
</Button>
</Space>
)
}
]
return (
<Space direction="vertical" size="large" style={{ width: '100%' }}>
<Card>
<Space direction="vertical" style={{ width: '100%' }}>
<Space align="start" style={{ justifyContent: 'space-between', width: '100%' }}>
<div>
<Title level={3} style={{ marginBottom: 4 }}>{t('leaderResearch.title')}</Title>
<Paragraph type="secondary" style={{ marginBottom: 0 }}>{t('leaderResearch.subtitle')}</Paragraph>
</div>
<Space>
<Button icon={<ReloadOutlined />} onClick={loadAll}>{t('common.refresh')}</Button>
<Button type="primary" icon={<PlayCircleOutlined />} loading={running} onClick={runAgent}>
{t('leaderResearch.runNow')}
</Button>
</Space>
</Space>
<Alert
type="info"
showIcon
icon={<ExperimentOutlined />}
message={t('leaderResearch.safetyTitle')}
description={t('leaderResearch.safetyDesc')}
/>
{activeSummary.sourceLimitations?.length > 0 && (
<Alert
type="warning"
showIcon
message={t('leaderResearch.sourceLimitations')}
description={activeSummary.sourceLimitations.join(' | ')}
/>
)}
</Space>
</Card>
<Row gutter={[16, 16]}>
<Col xs={24} sm={12} lg={4}><Card><Statistic title={t('leaderResearch.states.DISCOVERED')} value={activeSummary.discoveredCount} /></Card></Col>
<Col xs={24} sm={12} lg={4}><Card><Statistic title={t('leaderResearch.states.CANDIDATE')} value={activeSummary.candidateCount} /></Card></Col>
<Col xs={24} sm={12} lg={4}><Card><Statistic title={t('leaderResearch.states.PAPER')} value={activeSummary.paperCount} /></Card></Col>
<Col xs={24} sm={12} lg={4}><Card><Statistic title={t('leaderResearch.states.TRIAL_READY')} value={activeSummary.trialReadyCount} /></Card></Col>
<Col xs={24} sm={12} lg={4}><Card><Statistic title={t('leaderResearch.states.COOLDOWN')} value={activeSummary.cooldownCount} /></Card></Col>
<Col xs={24} sm={12} lg={4}><Card><Statistic title={t('leaderResearch.states.RETIRED')} value={activeSummary.retiredCount} /></Card></Col>
</Row>
<Row gutter={[16, 16]}>
<Col xs={24} lg={12}>
<Card title={t('leaderResearch.runStatus')}>
{lastRun ? (
<Descriptions size="small" column={1}>
<Descriptions.Item label={t('common.status')}>
<Tag color={lastRun.partialFailure ? 'orange' : lastRun.status === 'SUCCESS' ? 'green' : 'default'}>{lastRun.status}</Tag>
</Descriptions.Item>
<Descriptions.Item label={t('leaderResearch.lastRun')}>{formatDate(lastRun.startedAt)}</Descriptions.Item>
<Descriptions.Item label={t('leaderResearch.duration')}>{lastRun.durationMs ?? '-'} ms</Descriptions.Item>
<Descriptions.Item label={t('leaderResearch.sourceCounts')}>{lastRun.sourceCountsJson || '-'}</Descriptions.Item>
<Descriptions.Item label={t('leaderResearch.candidateCounts')}>{lastRun.candidateCountsJson || '-'}</Descriptions.Item>
{(lastRun.errorMessage || lastRun.skippedReason) && (
<Descriptions.Item label={t('leaderResearch.reason')}>{lastRun.errorMessage || lastRun.skippedReason}</Descriptions.Item>
)}
</Descriptions>
) : (
<Empty image={Empty.PRESENTED_IMAGE_SIMPLE} description={t('leaderResearch.noRuns')} />
)}
</Card>
</Col>
<Col xs={24} lg={12}>
<Card title={t('leaderResearch.pendingDecisions')}>
{pendingDecisions.length > 0 ? (
<Space direction="vertical" style={{ width: '100%' }}>
{pendingDecisions.slice(0, 5).map(candidate => (
<Card key={candidate.id} size="small">
<Space style={{ justifyContent: 'space-between', width: '100%' }} wrap>
<Space direction="vertical" size={0}>
<Text strong>{candidate.leaderName || candidate.normalizedWallet.slice(0, 10)}</Text>
<Text type="secondary">{t('leaderResearch.trialReadyHint')}</Text>
</Space>
<Button size="small" type="primary" loading={approvalLoading && approvalCandidate?.id === candidate.id} onClick={() => openApproval(candidate)}>
{t('leaderResearch.createDisabledTrial')}
</Button>
</Space>
</Card>
))}
</Space>
) : (
<Empty image={Empty.PRESENTED_IMAGE_SIMPLE} description={t('leaderResearch.noPendingDecisions')} />
)}
</Card>
</Col>
</Row>
<Card>
<Space direction="vertical" size="middle" style={{ width: '100%' }}>
<Space wrap>
<Select
allowClear
style={{ width: 220 }}
placeholder={t('leaderResearch.filterState')}
value={stateFilter}
onChange={setStateFilter}
options={Object.keys(STATE_COLORS).map(state => ({
value: state,
label: t(`leaderResearch.states.${state}`, { defaultValue: state })
}))}
/>
<Input.Search
allowClear
style={{ width: 320 }}
placeholder={t('leaderResearch.searchPlaceholder')}
value={query}
onChange={event => setQuery(event.target.value)}
onSearch={loadAll}
/>
</Space>
<Table
rowKey="id"
loading={loading}
columns={columns}
dataSource={candidates.list}
scroll={{ x: 1300 }}
locale={{ emptyText: <Empty image={Empty.PRESENTED_IMAGE_SIMPLE} description={t('leaderResearch.empty')} /> }}
/>
</Space>
</Card>
<Card title={t('leaderResearch.sourceHealth')}>
<Row gutter={[12, 12]}>
{sourceHealth.map(source => (
<Col xs={24} md={12} lg={6} key={source.sourceType}>
<Card size="small">
<Space direction="vertical" size={4}>
<Badge status={source.status === 'SUCCESS' ? 'success' : source.status === 'DISABLED' ? 'default' : 'warning'} text={source.sourceType} />
<Tag>{source.status}</Tag>
<Text type="secondary">{t('leaderResearch.candidates')}: {source.lastCandidateCount}</Text>
<Text type="secondary">{formatDate(source.lastRunAt)}</Text>
{(source.disabledReason || source.errorMessage) && <Text type="secondary">{source.disabledReason || source.errorMessage}</Text>}
</Space>
</Card>
</Col>
))}
</Row>
</Card>
<Drawer
width={880}
open={!!detail}
title={t('leaderResearch.detailTitle')}
onClose={() => setDetail(null)}
loading={detailLoading}
>
{detail && (
<Tabs
items={[
{
key: 'overview',
label: t('common.overview'),
children: (
<Space direction="vertical" style={{ width: '100%' }}>
<Descriptions bordered column={1} size="small">
<Descriptions.Item label={t('leaderResearch.wallet')}>{detail.candidate.normalizedWallet}</Descriptions.Item>
<Descriptions.Item label={t('common.status')}>{detail.candidate.researchState}</Descriptions.Item>
<Descriptions.Item label={t('leaderResearch.score')}>{detail.candidate.score || '-'}</Descriptions.Item>
<Descriptions.Item label={t('leaderResearch.reason')}>{detail.candidate.reason || '-'}</Descriptions.Item>
<Descriptions.Item label={t('leaderResearch.riskFlags')}>{detail.candidate.riskFlags.join(', ') || '-'}</Descriptions.Item>
<Descriptions.Item label={t('leaderResearch.sourceEvidence')}>{detail.candidate.sourceEvidence || '-'}</Descriptions.Item>
</Descriptions>
{detail.latestScore && (
<Descriptions bordered size="small" column={2} title={t('leaderResearch.scoreBreakdown')}>
<Descriptions.Item label="profit">{detail.latestScore.profitSignal}</Descriptions.Item>
<Descriptions.Item label="repeatability">{detail.latestScore.repeatability}</Descriptions.Item>
<Descriptions.Item label="liquidity">{detail.latestScore.liquidityFit}</Descriptions.Item>
<Descriptions.Item label="entry">{detail.latestScore.entryPriceFit}</Descriptions.Item>
<Descriptions.Item label="slippage">{detail.latestScore.slippageRisk}</Descriptions.Item>
<Descriptions.Item label="drawdown">{detail.latestScore.drawdownRisk}</Descriptions.Item>
</Descriptions>
)}
</Space>
)
},
{
key: 'trades',
label: t('leaderResearch.paperTrades'),
children: <PaperTradeTable trades={detail.paperTrades} />
},
{
key: 'positions',
label: t('leaderResearch.paperPositions'),
children: <PaperPositionTable positions={detail.paperPositions} />
},
{
key: 'events',
label: t('leaderResearch.events'),
children: (
<Table
rowKey="id"
size="small"
dataSource={detail.events}
columns={[
{ title: t('common.time'), dataIndex: 'createdAt', render: formatDate },
{ title: t('leaderResearch.eventType'), dataIndex: 'eventType' },
{ title: t('leaderResearch.reason'), dataIndex: 'reason' }
]}
/>
)
}
]}
/>
)}
</Drawer>
<Modal
open={!!approvalCandidate}
title={t('leaderResearch.createDisabledTrial')}
onCancel={() => setApprovalCandidate(null)}
onOk={submitApproval}
confirmLoading={approvalLoading}
>
<Space direction="vertical" style={{ width: '100%' }}>
<Alert
type="warning"
showIcon
message={t('leaderResearch.approvalSafetyTitle')}
description={t('leaderResearch.approvalSafetyDesc')}
/>
<Form form={approvalForm} layout="vertical">
<Descriptions bordered size="small" column={1} title={t('leaderResearch.approvalPreview')}>
<Descriptions.Item label={t('leaderResearch.fixedAmount')}>{activeApprovalPreview.fixedAmount}</Descriptions.Item>
<Descriptions.Item label={t('leaderResearch.maxDailyLoss')}>{activeApprovalPreview.maxDailyLoss}</Descriptions.Item>
<Descriptions.Item label={t('leaderResearch.maxDailyOrders')}>{activeApprovalPreview.maxDailyOrders}</Descriptions.Item>
<Descriptions.Item label={t('leaderResearch.priceRange')}>{activeApprovalPreview.priceRange}</Descriptions.Item>
<Descriptions.Item label={t('leaderResearch.maxPositionValue')}>{activeApprovalPreview.maxPositionValue}</Descriptions.Item>
</Descriptions>
<Form.Item name="accountId" label={t('leaderPool.account')} rules={[{ required: true, message: t('leaderPool.selectAccount') }]}>
<Select
options={accounts.map(account => ({
value: account.id,
label: `${account.accountName || account.walletAddress} (${account.proxyAddress?.slice(0, 8)}...)`
}))}
/>
</Form.Item>
</Form>
</Space>
</Modal>
</Space>
)
}
const PaperTradeTable: React.FC<{ trades: LeaderPaperTrade[] }> = ({ trades }) => (
<Table
rowKey="id"
size="small"
dataSource={trades}
columns={[
{ title: 'Time', dataIndex: 'eventTime', render: formatDate },
{ title: 'Side', dataIndex: 'side' },
{ title: 'Market', dataIndex: 'marketTitle', render: (value?: string, item?: LeaderPaperTrade) => value || item?.marketId },
{ title: 'Leader Price', dataIndex: 'leaderPrice' },
{ title: 'Sim Amount', dataIndex: 'simulatedAmount' },
{ title: 'Filter', dataIndex: 'filterResult' },
{ title: 'Quote', dataIndex: 'quoteConfidence' },
{ title: 'Valuation', dataIndex: 'valuationStatus', render: valuationTag }
]}
/>
)
const PaperPositionTable: React.FC<{ positions: LeaderPaperPosition[] }> = ({ positions }) => (
<Table
rowKey="id"
size="small"
dataSource={positions}
columns={[
{ title: 'Market', dataIndex: 'marketId' },
{ title: 'Outcome', dataIndex: 'outcome' },
{ title: 'Qty', dataIndex: 'quantity' },
{ title: 'Cost', dataIndex: 'cost' },
{ title: 'Value', dataIndex: 'currentValue' },
{ title: 'PnL', dataIndex: 'unrealizedPnl' },
{ title: 'Quote', dataIndex: 'quoteConfidence' },
{ title: 'Valuation', dataIndex: 'valuationStatus', render: valuationTag }
]}
/>
)
const summaryFallback: LeaderResearchSummary = {
discoveredCount: 0,
candidateCount: 0,
paperCount: 0,
trialReadyCount: 0,
cooldownCount: 0,
retiredCount: 0,
activePaperSessions: 0,
pendingRiskCount: 0,
sourceLimitations: []
}
export default LeaderResearch
+33
View File
@@ -8,6 +8,16 @@ import type {
LeaderPoolListResponse,
LeaderPoolUpdatePlanRequest,
LeaderPoolUpdateStatusRequest,
LeaderResearchApprovalRequest,
LeaderResearchApprovalResponse,
LeaderResearchCandidateDetail,
LeaderResearchCandidateListRequest,
LeaderResearchCandidateListResponse,
LeaderResearchEvent,
LeaderResearchRun,
LeaderResearchRunRequest,
LeaderResearchSourceState,
LeaderResearchSummary,
NotificationConfig,
NotificationConfigRequest,
NotificationConfigUpdateRequest,
@@ -396,6 +406,29 @@ export const apiService = {
remove: (data: { poolId: number }) =>
apiClient.post<ApiResponse<void>>('/copy-trading/leader-pool/remove', data)
},
leaderResearch: {
run: (data: LeaderResearchRunRequest = {}) =>
apiClient.post<ApiResponse<LeaderResearchRun>>('/copy-trading/leader-research/run', data),
summary: () =>
apiClient.post<ApiResponse<LeaderResearchSummary>>('/copy-trading/leader-research/summary', {}),
listCandidates: (data: LeaderResearchCandidateListRequest = {}) =>
apiClient.post<ApiResponse<LeaderResearchCandidateListResponse>>('/copy-trading/leader-research/candidates/list', data),
detail: (data: { candidateId: number }) =>
apiClient.post<ApiResponse<LeaderResearchCandidateDetail>>('/copy-trading/leader-research/candidates/detail', data),
sourceHealth: () =>
apiClient.post<ApiResponse<LeaderResearchSourceState[]>>('/copy-trading/leader-research/source-health', {}),
events: (data: { page?: number; size?: number } = {}) =>
apiClient.post<ApiResponse<LeaderResearchEvent[]>>('/copy-trading/leader-research/events/list', data),
createDisabledTrialConfig: (data: LeaderResearchApprovalRequest) =>
apiClient.post<ApiResponse<LeaderResearchApprovalResponse>>('/copy-trading/leader-research/approval/create-disabled-trial-config', data)
},
/**
* API
+231
View File
@@ -344,6 +344,12 @@ export interface LeaderPoolItem {
lastPromotedAt?: number
cooldownUntil?: number
locked: boolean
researchCandidateId?: number
researchState?: LeaderResearchState
researchBadge?: string
researchSummary?: string
researchScore?: string
researchUpdatedAt?: number
createdAt: number
updatedAt: number
}
@@ -391,6 +397,231 @@ export interface LeaderPoolCreateTrialConfigRequest {
confirm?: boolean
}
export type LeaderResearchState = 'DISCOVERED' | 'CANDIDATE' | 'PAPER' | 'TRIAL_READY' | 'COOLDOWN' | 'RETIRED'
export interface LeaderResearchRunRequest {
dryRun?: boolean
triggerType?: 'MANUAL' | 'SCHEDULED' | 'PREVIEW'
}
export interface LeaderResearchRun {
id: number
status: string
triggerType: string
dryRun: boolean
startedAt: number
finishedAt?: number
durationMs?: number
sourceCountsJson?: string
candidateCountsJson?: string
partialFailure: boolean
skippedReason?: string
errorClass?: string
errorMessage?: string
}
export interface LeaderResearchSummary {
discoveredCount: number
candidateCount: number
paperCount: number
trialReadyCount: number
cooldownCount: number
retiredCount: number
activePaperSessions: number
pendingRiskCount: number
lastRun?: LeaderResearchRun
sourceLimitations: string[]
}
export interface LeaderResearchCandidateListRequest {
page?: number
size?: number
state?: LeaderResearchState
query?: string
}
export interface LeaderResearchCandidateListResponse {
list: LeaderResearchCandidate[]
total: number
summary: LeaderResearchSummary
}
export interface LeaderResearchCandidate {
id: number
normalizedWallet: string
leaderId?: number
leaderName?: string
poolId?: number
poolStatus?: string
suggestedFixedAmount?: string
suggestedMaxDailyLoss?: string
suggestedMaxDailyOrders?: number
suggestedMinPrice?: string
suggestedMaxPrice?: string
suggestedMaxPositionValue?: string
researchState: LeaderResearchState
source: string
sourceRank?: number
score?: string
scoreVersion?: string
reason?: string
riskFlags: string[]
locked: boolean
agentOwned: boolean
provenance: string
sourceEvidence?: string
firstSeenAt: number
lastSourceSeenAt?: number
lastScoredAt?: number
cooldownUntil?: number
cooldownCount: number
trialReadyAt?: number
retiredAt?: number
lastPaperSessionId?: number
latestPaperSession?: LeaderPaperSession
}
export interface LeaderResearchCandidateDetail {
candidate: LeaderResearchCandidate
latestScore?: LeaderResearchScore
paperSessions: LeaderPaperSession[]
paperTrades: LeaderPaperTrade[]
paperPositions: LeaderPaperPosition[]
events: LeaderResearchEvent[]
}
export interface LeaderResearchScore {
id: number
candidateId: number
runId?: number
scoreVersion: string
totalScore: string
profitSignal: string
repeatability: string
liquidityFit: string
entryPriceFit: string
slippageRisk: string
holdingPeriodFit: string
marketTypeRisk: string
drawdownRisk: string
exitLiquidityRisk: string
dataFreshness: string
filterPassRate: string
sampleTradeCount: number
reason?: string
createdAt: number
}
export interface LeaderPaperSession {
id: number
candidateId: number
status: string
startedAt: number
endedAt?: number
tradeCount: number
filteredCount: number
openExposure: string
totalRealizedPnl: string
totalUnrealizedPnl: string
copyablePnl: string
maxDrawdown: string
unknownValuationExposure: string
confirmedZeroExposure: string
filteredRatio: string
lastProcessedEventTime?: number
scoreSnapshot?: string
}
export interface LeaderPaperTrade {
id: number
sessionId: number
candidateId: number
activityEventId?: number
leaderTradeId: string
marketId: string
marketTitle?: string
marketSlug?: string
side: string
outcome?: string
outcomeIndex?: number
leaderPrice?: string
leaderSize?: string
simulatedPrice?: string
simulatedSize?: string
simulatedAmount?: string
fillAssumption: string
quoteConfidence: string
quoteSource?: string
quoteTimestamp?: number
filterResult: string
filterReason?: string
valuationStatus: string
realizedPnl?: string
eventTime: number
createdAt: number
}
export interface LeaderPaperPosition {
id: number
sessionId: number
candidateId: number
marketId: string
outcome?: string
outcomeIndex?: number
quantity: string
cost: string
avgPrice: string
currentPrice?: string
currentValue: string
realizedPnl: string
unrealizedPnl: string
valuationStatus: string
quoteConfidence: string
quoteSource?: string
quoteTimestamp?: number
updatedAt: number
}
export interface LeaderResearchSourceState {
sourceType: string
status: string
lastSuccessAt?: number
lastFailureAt?: number
lastRunAt?: number
lastCandidateCount: number
errorClass?: string
errorMessage?: string
stale: boolean
disabledReason?: string
lastCursor?: string
updatedAt: number
}
export interface LeaderResearchEvent {
id: number
candidateId?: number
runId?: number
eventType: string
reason?: string
payloadSummary?: string
notificationStatus: string
notificationError?: string
dedupeKey?: string
createdAt: number
notifiedAt?: number
}
export interface LeaderResearchApprovalRequest {
candidateId: number
accountId: number
confirm?: boolean
}
export interface LeaderResearchApprovalResponse {
copyTrading: CopyTrading
warning: string
}
/**
*
*
@@ -0,0 +1,2 @@
schema: spec-driven
created: 2026-05-04
@@ -0,0 +1,282 @@
## Context
PolyHermes 当前已有三层跟单工作流:
- `Leader 管理` 保存可被跟单的钱包地址,对应 `copy_trading_leaders`
- `Leader 池` 管理人工候选、观察、小额试跟、冷却、淘汰,对应 `copy_trading_leader_pool`
- `跟单配置` 管理真实账户、leader 和风控参数,对应 `copy_trading`
这个结构已经把“地址库”和“真钱跟单”拆开,但仍需要用户自己去找 leader、判断是否值得观察、跟踪表现、决定是否小额试跟。用户的目标不是手动维护池子,而是让系统自动发现优秀且可复制的 leader,先纸跟验证,再由用户授权真钱动作。
本设计把新增能力定位为 Leader Research Agent。它是研究代理,不是自动交易代理。它可以自动发现、自动纸跟、自动建议、自动冷却;它不能自动启用真钱跟单。
## Goals / Non-Goals
**Goals:**
- 提供多源候选发现,第一阶段支持 watchlist、已有 Leader 管理记录和 activity-derived candidates。
- 为研究运行、候选、分数、事件、纸跟 session、纸跟交易、纸跟持仓建立独立数据模型。
- 持久化原始或规范化 activity event,使纸跟可以基于可重放事件运行,而不是复用真实订单表。
- 用透明规则计算 copyability score,并保存分项分数和 score version。
- 自动推进研究状态,但研究状态必须与 Leader 池状态分离。
- 在不锁定的情况下,自动化只能保守地增强 Leader 池记录,不能自动改成真实 `TRIAL``ACTIVE`
- 提供操作台展示运行状态、候选详情、纸跟表现、待处理决策和源健康。
- 提供手动审批动作,创建保守的 `enabled=false` 真实跟单配置。
- 为源失败、quote 不可用、valuation unknown、重复事件、锁定候选、非法真钱启用等关键路径提供可见错误和测试。
**Non-Goals:**
- 不自动启用真钱跟单。
- 不自动加仓、不自动增加 fixed amount、不自动放宽风控参数。
- 不做黑盒 AI/ML 评分。
- 不在第一阶段依赖公开 leaderboard 端点。
- 不复用 `copy_order_tracking` 作为纸跟账本。
- 不把 unknown valuation 当成 confirmed zero。
- 不替换现有 Leader 池、Leader 管理、跟单配置和真实 PnL 统计。
## Decisions
### 1. 研究状态独立于 Leader 池状态
研究状态回答“系统证明了什么”,Leader 池状态回答“用户当前怎么处理这个 leader”。两者不能合并。
研究状态:
```text
DISCOVERED -> CANDIDATE -> PAPER -> TRIAL_READY
\-> COOLDOWN -> RETIRED
```
Leader 池状态:
```text
CANDIDATE -> WATCH -> TRIAL -> ACTIVE
\-> COOLDOWN -> RETIRED
```
映射规则:
```text
research DISCOVERED
-> 可以只存在于 leader_research_candidate,不强制创建 Leader 池项
research CANDIDATE
-> 展示给用户时可创建或更新 Leader 池 CANDIDATE
research PAPER
-> Leader 池保持 WATCH,或后续 UI 暴露 PAPER
research TRIAL_READY
-> Leader 池保持 WATCH,并展示“建议小额试跟” badge
-> 不能自动变成 Leader 池 TRIAL
research COOLDOWN
-> 未锁定时可以同步 Leader 池 COOLDOWN
research RETIRED
-> 只有 agent 拥有且未锁定的候选,才可同步 Leader 池 RETIRED
```
`TRIAL_READY` 只是建议。只有用户创建了真实禁用试跟配置后,Leader 池才可以进入 `TRIAL`
### 2. 纸跟必须独立建账
`enabled=false` 的真实跟单配置不会产生模拟成交、模拟持仓、过滤原因、quote confidence 和纸跟 PnL。把它伪装成纸跟会让后续晋升建议没有证据链。
新增建议表:
```text
leader_paper_session
leader_paper_trade
leader_paper_position
```
每笔纸跟交易至少记录:
```text
leader_trade_id
leader_price
leader_size
simulated_price
simulated_size
simulated_amount
fill_assumption: LEADER_PRICE | BEST_ASK_AT_EVENT | MID_PRICE | UNKNOWN
quote_confidence: HIGH | MEDIUM | LOW | UNKNOWN
quote_source
quote_timestamp
filter_result: PASSED | FILTERED
filter_reason
valuation_status: AVAILABLE | NO_MATCH | UNAVAILABLE | CONFIRMED_ZERO
```
`UNAVAILABLE``UNKNOWN` 不得计为真实归零。这个约束必须沿用到纸跟 PnL 和前端展示。
### 3. 第一阶段先做可重放事件和内部来源
第一阶段候选来源:
```text
watchlist
已有 Leader 管理记录
已持久化 activity event 中可归因的钱包
```
公开 leaderboard 保留为后续来源,不作为第一阶段 apply-ready 的依赖。原因是 leaderboard 端点可能不稳定或未文档化,不能让第一版核心闭环被外部端点卡住。
如果当前 app 没有持久化 raw activity event,需要新增 append-only 表:
```text
leader_activity_event
```
推荐唯一键:
```text
source + event_id
```
如果 event_id 缺失,必须生成稳定 fallback key,避免重启或重放时重复纸跟。
### 4. 评分必须可解释且可版本化
不要只保存一个总分。保存分项分数、总分、score version 和 reason。
分项:
```text
profit_signal
repeatability
liquidity_fit
entry_price_fit
slippage_risk
holding_period_fit
market_type_risk
drawdown_risk
exit_liquidity_risk
data_freshness
filter_pass_rate
```
研究状态迁移必须记录 rule version,避免以后调整阈值后无法解释旧决策。
默认阈值:
```text
DISCOVERED -> CANDIDATE
条件:候选来自可信来源,地址格式有效
CANDIDATE -> PAPER
条件:score >= 60,未锁定,未退休,source 48 小时内新鲜
PAPER -> TRIAL_READY
条件:
纸跟时间 >= 7 天
模拟交易数 >= 10
copyable PnL > 0
最大回撤 >= -15%
UNKNOWN quote 暴露占比 <= 20%
filtered-trade ratio < 50%
无 critical risk flag
PAPER -> COOLDOWN
条件:
最大回撤 < -20%
或 10 笔后 copyable PnL < -5
或 quote/source stale > 72 小时
或存在 thin_liquidity_exit_risk
COOLDOWN -> CANDIDATE
条件:cooldown_until 已过,source 重新新鲜,未锁定
COOLDOWN -> RETIRED
条件:3 次冷却周期,或 30 天无新鲜来源
```
这些阈值是第一版保守默认值,不是金融真理;后续可配置。
### 5. 后端强制真钱边界
研究代理可以推荐小额试跟,但不能启用真钱。
手动审批流:
```text
用户打开候选详情
-> 点击“创建禁用试跟配置”
-> 前端展示 fixed amount、max daily loss、max daily orders、price range、max position value
-> 用户确认
-> 后端组装 CopyTradingCreateRequest
-> 后端强制 enabled=false
-> 返回 created copyTrading
-> 用户必须去 跟单配置 页面手动启用
```
后端必须忽略或拒绝任何客户端传入的 `enabled=true`、更大 fixed amount 或更松风控参数。安全边界在后端,不在弹窗。
### 6. 操作台先做“晨报式”决策界面
第一版页面不做复杂量化终端。优先回答:
```text
今天有什么变化?
哪些候选需要我决策?
为什么推荐?
证据是什么?
风险是什么?
我能安全点击什么?
```
页面模块:
```text
Run Status
Candidate Detail
Paper Sessions
Pending Decisions
Source Health
```
### 7. 调度必须有 overlap guard 和 run record
新增 `LeaderResearchJobService` 使用现有 Spring `@Scheduled` 模式,但默认关闭或可配置开启。
每次运行必须创建 `leader_research_run`,记录:
```text
status
started_at
finished_at
duration
source counts
candidate counts
error class
error message
partial failure flag
```
如果上一次运行还未结束,新运行 MUST 跳过并记录 overlap 事件,不能并发推进状态。
## Risks / Trade-offs
- [纸跟精度看起来比实际更高] -> 每笔纸跟交易必须保存 fill assumption、quote confidence 和 valuation statusUI 必须展示 unknown/partial 状态。
- [source 失败导致错误降级候选] -> source failure 只更新 source health,不得静默删除候选或降低 score。
- [自动状态机误伤人工决策] -> `locked=true` 的候选和池子项不得被自动化改变状态、建议配置或 trial recommendation。
- [通知系统 Telegram-first] -> 第一阶段先落 `leader_research_event` 和控制台提示,外部通知渠道在事件流稳定后再复用/扩展通知配置。
- [表数量增加导致复杂度上升] -> 用里程碑拆分:先数据 spine,再纸跟,再评分状态机,再 UI/通知,再手动审批。
- [数据量增长影响性能] -> 为 event、candidate、paper trade、paper position、run、event 表添加唯一键和查询索引,并对详情列表分页。
## Migration Plan
1. 新增研究与纸跟相关 Flyway 迁移,所有表为空表创建,不修改现有真实订单热表。
2. 部署后端,但 research job 默认关闭。
3. 部署前端操作台,若 job 未启用则展示禁用/空态。
4. 在本地或测试环境手动运行 research job,验证 run record、source health、candidate、paper session 和 paper trade。
5. 开启 watchlist + existing leaders 来源,不启用 public leaderboard。
6. 确认内部 research events 稳定后再接外部通知渠道。
7. 最后启用手动“创建禁用试跟配置”入口。
Rollback:
- 如果 job 出错,关闭 research job 配置并保留数据表。
- 如果 UI 出错,隐藏菜单或回滚前端路由。
- 如果迁移失败,在启用 job 前停止部署;不要在热回滚中删除已有研究数据。
@@ -0,0 +1,37 @@
## Why
当前 `Leader 池` 已经解决了“候选、观察、小额试跟、冷却、淘汰”的人工决策层,但用户的真实目标更靠前:系统自动发现优秀且可复制的 leader,先用纸跟证据验证,再让用户决定是否创建真实小额跟单配置。
如果只搬运排行榜,系统会把“leader 自己赚钱”误当成“用户也能跟着赚钱”。本变更要把 PolyHermes 从手动候选池升级为 paper-first 的 Leader Research Agent:自动发现、自动纸跟、自动解释、自动建议,但真钱启用必须手动确认。
## What Changes
- 新增 Leader Research Agent 能力,提供多源候选发现、研究运行记录、源健康状态和候选评分。
- 新增独立纸跟账本,记录模拟买卖、模拟持仓、过滤原因、估值状态、quote confidence 和纸跟 PnL。
- 新增研究状态机,支持 `DISCOVERED``CANDIDATE``PAPER``TRIAL_READY``COOLDOWN``RETIRED` 等研究状态,并与现有 Leader 池状态分离。
- 新增可解释 copyability score,按收益信号、可重复性、流动性适配、入场价格适配、滑点风险、持仓周期、市场类型风险、回撤风险、退出流动性风险、数据新鲜度和过滤通过率拆分。
- 新增 Leader Research 操作台,展示运行状态、候选详情、纸跟证据、待处理决策和数据源健康。
- 新增研究事件与通知摘要,让系统主动提示新增候选、纸跟达标、建议小额试跟、冷却、数据失败和数据过期。
- 新增手动审批路径:用户从研究候选详情确认后,系统只能创建 `enabled=false` 的保守真实跟单配置;研究代理不得自动启用真钱跟单。
- 第一阶段候选来源仅包含 watchlist、已有 `Leader 管理` 记录和已持久化 activity 事件;公开 leaderboard 接入保留为后续能力,等端点契约和失败行为确认后再启用。
- 不做自动真钱启用、不做自动加仓、不做自动放大固定金额、不做黑盒 AI 评分、不删除用户已有 leader 或跟单配置。
## Capabilities
### New Capabilities
- `leader-research-agent`: 自动研究 leader 的完整能力,包括候选发现、纸跟账本、copyability 评分、研究状态机、操作台、通知事件和手动创建禁用试跟配置。
### Modified Capabilities
无。
## Impact
- 后端新增 research 相关实体、Flyway 迁移、Repository、DTO、Service、Controller、定时任务和错误码。
- 后端需要复用现有 `copy_trading_leaders``copy_trading_leader_pool``copy_trading``CopyTradingService``LeaderPoolService` 和 activity 监听链路。
- 后端需要新增 append-only activity event 持久化能力,如果当前实时 activity 事件没有落库,则纸跟必须先基于该事件表运行。
- 前端新增 Leader Research 操作台页面、路由、菜单入口、API service、类型定义和中文/英文/繁中文文案。
- 前端 Leader 池需要能展示研究推荐 badge 或跳转研究候选详情,但不得把 `TRIAL_READY` 直接表现成真实 `TRIAL`
- 通知系统先复用内部 `leader_research_event` 和控制台提示;外部通知渠道在事件流稳定后再接入现有通知配置抽象。
- 交易安全影响:本变更会靠近真钱配置创建路径,因此所有真实配置创建必须后端强制 `enabled=false`,并通过测试证明研究代理没有任何自动启用真钱的路径。
@@ -0,0 +1,228 @@
## ADDED Requirements
### Requirement: 提供 Leader Research 操作台
系统 SHALL 在受保护的 Web 应用中提供 Leader Research 操作台,用于查看自动研究运行状态、候选、纸跟证据、待处理决策和数据源健康。
#### Scenario: 打开操作台
- **WHEN** 已登录用户打开 Leader Research 页面
- **THEN** 系统 MUST 展示最近研究运行状态、候选概览、待处理决策、纸跟概览和数据源健康
#### Scenario: 研究功能未启用
- **WHEN** 研究 job 未启用或尚未运行
- **THEN** 页面 MUST 展示明确空态,并 MUST NOT 暗示系统已经完成 leader 研究
#### Scenario: 数据源部分失败
- **WHEN** 部分候选来源失败但其他来源可用
- **THEN** 页面 MUST 展示 degraded 状态,并 MUST 继续展示可用来源产生的候选和证据
### Requirement: 记录研究运行
系统 SHALL 为每次自动或手动研究运行记录 run record,确保研究结果可追溯、可排查且不会静默失败。
#### Scenario: 成功运行研究任务
- **WHEN** 研究任务完成一次正常运行
- **THEN** 系统 MUST 保存运行状态、开始时间、结束时间、耗时、来源统计、候选统计和成功状态
#### Scenario: 研究任务部分失败
- **WHEN** 研究任务中某个来源或阶段失败但整体任务仍可继续
- **THEN** 系统 MUST 将 run 标记为 partial failure,并 MUST 保存失败来源、错误类型和错误信息
#### Scenario: 防止并发运行
- **WHEN** 上一次研究任务仍在运行时触发新的研究任务
- **THEN** 系统 MUST 跳过新的任务或拒绝新的任务,并 MUST 记录 overlap 事件
### Requirement: 多源发现候选
系统 SHALL 支持从多个来源发现 leader 候选,并将候选标准化为统一研究候选记录。
#### Scenario: 从 watchlist 发现候选
- **WHEN** 系统运行 watchlist 来源
- **THEN** 系统 MUST 为 watchlist 中的有效钱包创建或更新研究候选
#### Scenario: 从已有 Leader 管理记录发现候选
- **WHEN** 系统运行已有 leader 来源
- **THEN** 系统 MUST 为 `copy_trading_leaders` 中的有效 leader 创建或更新研究候选
#### Scenario: 从 activity 事件发现候选
- **WHEN** 系统从已持久化 activity event 中识别可归因的钱包
- **THEN** 系统 MUST 创建或更新研究候选,并 MUST 记录来源为 activity-derived
#### Scenario: 来源返回空结果
- **WHEN** 某个来源成功运行但没有发现候选
- **THEN** 系统 MUST 记录该来源成功且候选数为 0,不得把它当作失败
#### Scenario: 来源失败
- **WHEN** 某个来源超时、限流、解析失败或返回异常
- **THEN** 系统 MUST 更新来源健康状态,并 MUST NOT 因本次失败删除候选或自动降级候选
### Requirement: 持久化 activity event
系统 SHALL 持久化用于纸跟的 activity event,确保纸跟可以重放、去重和排查。
#### Scenario: 保存新的 activity event
- **WHEN** 系统接收到包含交易者、市场、方向、价格、数量和时间的 activity event
- **THEN** 系统 MUST 保存该事件,并 MUST 记录 source、event id、钱包地址、市场、方向、价格、数量、时间戳和原始 payload 摘要
#### Scenario: 重复 activity event
- **WHEN** 系统再次接收到相同 source 和 event id 的 activity event
- **THEN** 系统 MUST 不创建重复事件,并 MUST 保持纸跟处理幂等
#### Scenario: activity event 缺少必需字段
- **WHEN** activity event 缺少钱包地址、市场、方向、价格或数量
- **THEN** 系统 MUST 标记该事件不可用于纸跟,并 MUST 记录原因
### Requirement: 建立独立纸跟账本
系统 SHALL 使用独立纸跟账本记录模拟买卖、模拟持仓、过滤结果和纸跟 PnL,不得使用真实订单跟踪表伪装纸跟。
#### Scenario: 启动纸跟 session
- **WHEN** 研究候选达到进入纸跟的条件
- **THEN** 系统 MUST 创建 paper session,并 MUST 将候选研究状态更新为 `PAPER`
#### Scenario: 记录纸跟买入
- **WHEN** 已进入纸跟的候选出现符合过滤条件的 BUY activity event
- **THEN** 系统 MUST 记录 paper trade 和 paper position,并 MUST 保存模拟价格、模拟数量、fill assumption 和 quote confidence
#### Scenario: 记录纸跟卖出
- **WHEN** 已进入纸跟的候选出现可匹配的 SELL activity event
- **THEN** 系统 MUST 更新对应 paper position,并 MUST 记录 realized paper PnL
#### Scenario: 记录被过滤交易
- **WHEN** 候选交易因价格区间、流动性、风控或数据缺失被过滤
- **THEN** 系统 MUST 记录 filtered paper trade 或 research event,并 MUST 保存 filter reason
#### Scenario: 估值不可用
- **WHEN** 纸跟持仓无法获得可靠 quote 或 position valuation
- **THEN** 系统 MUST 将 valuation status 标记为 `UNAVAILABLE``UNKNOWN`,并 MUST NOT 将其计为 confirmed zero
### Requirement: 计算可解释 copyability score
系统 SHALL 为研究候选计算可解释的 copyability score,并保存分项分数、总分、版本和原因。
#### Scenario: 保存分项分数
- **WHEN** 系统计算候选评分
- **THEN** 系统 MUST 保存收益信号、可重复性、流动性适配、入场价格适配、滑点风险、持仓周期、市场类型风险、回撤风险、退出流动性风险、数据新鲜度和过滤通过率
#### Scenario: 保存评分版本
- **WHEN** 系统保存候选评分
- **THEN** 系统 MUST 保存 score version,以便后续解释历史评分
#### Scenario: 单次大赢不能主导评分
- **WHEN** 候选只有单次异常盈利但样本量不足
- **THEN** 系统 MUST 限制收益信号对总分的影响,并 MUST 通过样本量或可重复性降低晋升概率
### Requirement: 自动推进研究状态
系统 SHALL 基于透明规则自动推进研究状态,并记录每次状态变化的原因。
#### Scenario: 候选进入纸跟
- **WHEN** 候选分数达到阈值、来源新鲜、未退休且未锁定
- **THEN** 系统 MUST 将研究状态从 `CANDIDATE` 推进为 `PAPER`,并 MUST 记录规则版本和原因
#### Scenario: 纸跟达标进入试跟建议
- **WHEN** 候选纸跟至少 7 天、模拟交易至少 10 笔、copyable PnL 为正、最大回撤不低于 -15%、UNKNOWN quote 暴露不超过 20%、过滤比例低于 50% 且无 critical risk flag
- **THEN** 系统 MUST 将研究状态推进为 `TRIAL_READY`,并 MUST 生成试跟建议事件
#### Scenario: 纸跟风险进入冷却
- **WHEN** 候选最大回撤低于 -20%、10 笔后 copyable PnL 小于 -5、quote 或 source stale 超过 72 小时,或出现 thin liquidity exit risk
- **THEN** 系统 MUST 将研究状态推进为 `COOLDOWN`,并 MUST 记录风险原因
#### Scenario: 冷却后恢复候选
- **WHEN** 候选冷却截止时间已过、来源重新新鲜且未锁定
- **THEN** 系统 MAY 将研究状态恢复为 `CANDIDATE`,并 MUST 记录恢复原因
#### Scenario: 多次冷却后退休
- **WHEN** 候选经历 3 次冷却周期或 30 天没有新鲜来源
- **THEN** 系统 MAY 将研究状态推进为 `RETIRED`,并 MUST 记录退休原因
### Requirement: 尊重锁定候选
系统 SHALL 支持锁定研究候选或 Leader 池项,避免自动任务覆盖人工判断。
#### Scenario: 锁定候选
- **WHEN** 用户锁定某个研究候选
- **THEN** 自动任务 MUST NOT 改变该候选的研究状态、试跟建议、建议配置或冷却/退休状态
#### Scenario: 锁定候选仍可更新证据
- **WHEN** 锁定候选出现新的来源证据或纸跟证据
- **THEN** 系统 MAY 追加只读证据,但 MUST NOT 改变人工锁定的决策字段
### Requirement: 保守增强 Leader 池
系统 SHALL 允许研究代理保守地增强现有 Leader 池记录,但不得替代 Leader 池的人工作业语义。
#### Scenario: 研究候选同步到 Leader 池
- **WHEN** 候选进入 `CANDIDATE` 或更高研究状态且需要展示给用户
- **THEN** 系统 MAY 创建或更新对应 Leader 池项,并 MUST 保留 Leader 池作为候选决策层
#### Scenario: 试跟建议不等于真实试跟状态
- **WHEN** 候选研究状态为 `TRIAL_READY`
- **THEN** 系统 MUST NOT 自动把 Leader 池状态改为 `TRIAL``ACTIVE`
#### Scenario: 不自动放大建议配置
- **WHEN** 自动任务更新已有 Leader 池记录
- **THEN** 系统 MUST NOT 自动增加 suggested fixed amount、suggested max daily loss 或 suggested max daily orders
#### Scenario: 不覆盖手工备注
- **WHEN** 自动任务写入研究摘要
- **THEN** 系统 MUST NOT 覆盖用户手工 notes,只能追加或写入独立研究摘要字段
### Requirement: 手动创建禁用试跟配置
系统 SHALL 支持用户从试跟建议手动创建禁用的保守真实跟单配置,并禁止研究代理自动启用真钱。
#### Scenario: 创建禁用试跟配置
- **WHEN** 用户从 `TRIAL_READY` 候选详情点击创建禁用试跟配置并确认
- **THEN** 后端 MUST 通过现有跟单配置服务创建 `enabled=false` 的保守 `FIXED` 配置
#### Scenario: 后端强制禁用状态
- **WHEN** 创建禁用试跟配置请求包含 `enabled=true` 或其他试图立即启用的字段
- **THEN** 后端 MUST 忽略或拒绝这些字段,并 MUST NOT 创建启用中的真实跟单配置
#### Scenario: 创建前展示风险参数
- **WHEN** 用户确认创建禁用试跟配置
- **THEN** UI MUST 展示 fixed amount、max daily loss、max daily orders、price range 和 max position value
#### Scenario: 已存在同账户同 leader 配置
- **WHEN** 用户尝试为同一账户和 leader 创建禁用试跟配置且配置已存在
- **THEN** 系统 MUST 拒绝默认创建,并 MUST 展示已有配置提示
### Requirement: 记录研究事件和通知摘要
系统 SHALL 为关键研究动作记录事件,并在事件流稳定后提供通知摘要。
#### Scenario: 记录关键研究事件
- **WHEN** 系统发现新候选、启动纸跟、晋升试跟建议、进入冷却、退休、来源失败或 valuation stale
- **THEN** 系统 MUST 写入 leader research event,并 MUST 包含候选、事件类型、原因和时间
#### Scenario: 操作台展示待处理事件
- **WHEN** 存在试跟建议或数据源失败事件
- **THEN** 操作台 MUST 展示这些事件,并 MUST 提供跳转到候选详情或源健康详情的入口
#### Scenario: 外部通知失败
- **WHEN** 外部通知渠道发送失败
- **THEN** 系统 MUST 保留 research event,并 MUST 标记通知失败,不得丢失研究事件
### Requirement: 提供研究 API
系统 SHALL 提供受保护的 Leader Research API,用于运行研究、查看运行状态、查看候选、查看纸跟详情、查看事件和创建禁用试跟配置。
#### Scenario: 手动触发研究运行
- **WHEN** 用户请求手动运行研究任务
- **THEN** 后端 MUST 校验权限和 overlap guard,并 MUST 返回 run record 或明确跳过原因
#### Scenario: 查询候选列表
- **WHEN** 前端请求候选列表
- **THEN** 后端 MUST 返回候选研究状态、来源、总分、关键风险、纸跟摘要和待处理决策状态
#### Scenario: 查询候选详情
- **WHEN** 前端请求候选详情
- **THEN** 后端 MUST 返回来源历史、score components、paper session、paper trades、paper positions、research events 和 Leader 池映射信息
#### Scenario: 查询源健康
- **WHEN** 前端请求数据源健康
- **THEN** 后端 MUST 返回每个来源的最近成功时间、最近失败时间、错误类型、错误信息和 stale 状态
### Requirement: 保证研究任务可观测和可回滚
系统 SHALL 提供研究任务的日志、指标、运行记录和开关,确保上线后可以诊断、暂停和回滚。
#### Scenario: 研究 job 默认关闭
- **WHEN** 新版本首次部署
- **THEN** 研究 job SHOULD 默认为关闭或可配置关闭,直到人工验证通过
#### Scenario: 记录结构化日志
- **WHEN** 研究任务运行、候选状态变化、纸跟交易记录、源失败或手动审批发生
- **THEN** 后端 MUST 记录包含 runId、candidateId、source、eventId、oldState、newState 或 copyTradingId 的结构化日志
#### Scenario: 关闭研究 job
- **WHEN** 运维关闭研究 job 配置
- **THEN** 系统 MUST 停止自动研究运行,但 MUST 保留已有研究数据和页面查询能力
@@ -0,0 +1,208 @@
## 1. 后端数据模型与迁移
- [x] 1.1 新增 Flyway 迁移,创建 `leader_research_run` 表,记录运行状态、时间、耗时、来源统计、候选统计、错误类型、错误信息和 partial failure 标记。
- [x] 1.2 新增 Flyway 迁移,创建 `leader_research_candidate` 表,记录钱包地址、关联 leaderId、研究状态、来源、score、reason、risk flags、锁定字段和时间戳。
- [x] 1.3 新增 Flyway 迁移,创建 `leader_research_score` 表,记录 score version、总分和所有 copyability 分项。
- [x] 1.4 新增 Flyway 迁移,创建 `leader_research_event` 表,记录研究事件类型、candidateId、runId、原因、payload 摘要、通知状态和时间戳。
- [x] 1.5 新增 Flyway 迁移,创建 `leader_research_source_state` 表或等价字段,记录每个来源的最近成功、最近失败、错误类型、错误信息和 stale 状态。
- [x] 1.6 新增 Flyway 迁移,创建 `leader_activity_event` append-only 表,若当前 app 未持久化 raw activity event,则用于纸跟重放和去重。
- [x] 1.7 新增 Flyway 迁移,创建 `leader_paper_session` 表,记录 candidateId、状态、开始/结束时间、统计摘要和 PnL 摘要。
- [x] 1.8 新增 Flyway 迁移,创建 `leader_paper_trade` 表,记录 leader trade、模拟成交、filter reason、fill assumption、quote confidence 和 valuation status。
- [x] 1.9 新增 Flyway 迁移,创建 `leader_paper_position` 表,记录 session、market、outcome、数量、成本、当前估值、realized/unrealized PnL 和 valuation status。
- [x] 1.10 为 activity event、candidate、paper trade、paper position、run、event 和 source state 添加唯一键与查询索引,覆盖去重、分页、状态筛选和候选详情查询。
- [x] 1.11 新增 JPA entity 与 repository,覆盖所有 research、activity、paper 相关表。
- [x] 1.12 新增 research 状态、paper session 状态、valuation status、quote confidence、fill assumption、event type 和 source type 枚举。
- [x] 1.13 `leader_activity_event` 必须包含 `normalized_wallet``source_event_id``stable_event_key``event_time``raw_payload_hash``payload_summary``usable_for_discovery``unusable_reason``paper_processing_status``processing_attempts``paper_processing_started_at``paper_processed_at``last_processing_error`
- [x] 1.14 `leader_research_candidate` 必须记录 agent ownership/provenance,区分 agent 创建候选、用户已有 leader、用户锁定候选和人工池子项,避免自动退休或覆盖用户资产。
- [x] 1.15 `leader_research_run` 必须支持单实例运行锁或等价约束,保证同一时间最多一个 research run 推进候选状态。
- [x] 1.16 为 `leader_activity_event(paper_processing_status, event_time)``leader_activity_event(normalized_wallet, event_time)``leader_paper_trade(session_id, leader_trade_id)``leader_research_candidate(research_state, last_source_seen_at)` 添加组合索引。
- [x] 1.17 为 paper trade 增加数据库唯一约束,至少覆盖 `session_id + leader_trade_id + side`,用数据库兜底防止重复模拟成交。
- [x] 1.18 所有新增表必须有可回滚的空表迁移策略;迁移不得修改现有真实订单热表语义。
## 2. Activity 事件持久化
- [x] 2.1 新增 `LeaderActivityIngestionService`,负责 research 专用 activity event 标准化、去重、持久化和不可用原因记录。
- [x] 2.2 保持现有 `PolymarketActivityWsService` 的真实跟单快路径不被 research 拖慢;不得依赖它当前的“已监听 leader 地址预过滤”来发现未知 leader。
- [x] 2.3 新增 bounded Data API backfill 来源,按 watchlist、已有 Leader 管理记录和 active research candidates 拉取 `/activity`,用于补齐历史 paper event。
- [x] 2.4 新增 research global activity capture 扩展点,解析全局 WS activity 时必须在地址过滤前标准化事件;该能力默认关闭,并受配置开关、每分钟写入上限、payload 截断和保留策略保护。
- [x] 2.5 如果 global capture 未启用,activity-derived source 必须在 source health 中明确显示 disabled/degraded,不得假装已经自动发现未知 leader。
- [x] 2.6 为 activity event 实现 source + eventId 幂等保存;缺失 eventId 时用 wallet、conditionId、asset、side、price、size、timestamp、transactionHash 的稳定 hash 生成 fallback key。
- [x] 2.7 对缺少钱包地址、市场、方向、价格或数量的 event 仍写入摘要,并标记不可用于 discovery/paper trading 的原因。
- [x] 2.8 确保 activity event 持久化失败不会阻断现有真实跟单处理链路;失败只写 research run/source error。
- [x] 2.9 新增 activity source health 更新,区分 Data API success empty、Data API failure、WS capture disabled、WS parse failure、write capped 和 stale。
- [x] 2.10 新增 activity event repository 查询方法,支持按钱包、时间窗口、未处理状态、来源、可发现状态和 stable key 查询。
- [x] 2.11 新增 activity ingestion 测试,覆盖正常保存、重复事件、缺失字段、fallback key、持久化失败隔离、write cap、WS capture disabled 和 Data API success empty。
- [x] 2.12 新增回归测试,证明 current copy-trading WS 的已知 leader 过滤不会阻断 research ingestion 对未知钱包的发现路径。
## 3. 候选来源与研究运行
- [x] 3.1 新增 `LeaderResearchJobService`,支持手动 runOnce 和定时运行,默认通过配置关闭自动调度。
- [x] 3.2 为研究任务实现 overlap guard,当前一 run 未结束时跳过或拒绝新 run,并写入 research event。
- [x] 3.3 新增 `LeaderResearchSourceService`,统一 watchlist、已有 Leader 管理记录和 activity-derived candidates;每个 source 返回 typed result,包含 candidates、source status、error class、error message 和 freshness。
- [x] 3.4 新增 watchlist 配置读取路径,支持配置钱包地址、备注和来源标签。
- [x] 3.5 实现 existing leaders 来源,从 `copy_trading_leaders` 创建或更新研究候选。
- [x] 3.6 实现 activity-derived 来源,从 `leader_activity_event` 中发现 fresh、usable、可归因钱包;如果 global capture 未启用,只能基于已持久化事件工作,并必须展示 source limitation。
- [x] 3.7 候选创建必须复用 `LeaderRepository` 的唯一地址语义;新 leader address 必须标准化小写并验证 42 位 EVM 地址格式。
- [x] 3.8 每个来源运行后更新 source state,区分 success with zero candidates、failure、stale、disabled、degraded 和 partial failure。
- [x] 3.9 公开 leaderboard 来源只保留接口/扩展点,不作为第一阶段启用来源;不得在实现中偷偷依赖未确认 endpoint。
- [x] 3.10 候选合并必须保留所有来源 evidence,不得因为同一钱包重复出现而覆盖更早来源、用户备注或锁定状态。
- [x] 3.11 新增 run-level dry-run 或 preview 能力,允许查看本次将新增/更新/冷却多少候选而不推进状态。
- [x] 3.12 新增候选来源测试,覆盖空结果、失败结果、disabled source、重复候选、无效地址、已有 leader 复用和用户锁定保护。
- [x] 3.13 新增研究运行测试,覆盖成功、partial failure、overlap guard、run record 完整性、source limitation 和 preview 不产生状态副作用。
- [x] 3.14 新增生产失败场景测试:某个 source 超时或抛异常时,其他 source 仍能产出候选,且不会删除或降级已有候选。
## 4. 纸跟账本与模拟成交
- [x] 4.1 新增 `LeaderPaperTradingService`,根据候选和 activity event 启动或更新 paper session。
- [x] 4.2 `LeaderPaperTradingService` 必须按 batch claim `leader_activity_event`,通过状态从 `NEW`/`RETRYABLE` 原子更新到 `PROCESSING` 后再处理,避免并发 run 重复模拟成交。
- [x] 4.3 对进入 `PAPER` 状态的候选,处理 BUY event 并记录 paper trade 与 paper position。
- [x] 4.4 对进入 `PAPER` 状态的候选,处理 SELL event 并按持仓匹配规则更新 paper position 和 realized PnL。
- [x] 4.5 将现有 `CopyTradingFilterService` 抽出或复用为 paper-safe evaluatorpaper 场景不得调用真实账户持仓检查来决定模拟持仓,必须使用 paper position provider。
- [x] 4.6 为 research 定义保守 paper config,包含 fixed amount、max daily loss、max daily orders、min/max price、max position value、support sell 和 market end guard。
- [x] 4.7 每笔 paper trade 保存 leader price、leader size、simulated price、simulated size、simulated amount、fill assumption、quote confidence、quote source、quote timestamp、filter result 和 filter reason。
- [x] 4.8 对 quote/valuation 不可用的持仓标记 `UNAVAILABLE``UNKNOWN`,并确保不计为 confirmed zero。
- [x] 4.9 `copyable PnL` 必须区分 realized PnL、available unrealized PnL、unknown valuation exposure 和 confirmed zero exposureunknown/unavailable 不得伪装成亏到 0。
- [x] 4.10 为 paper trade 实现 leaderTradeId 幂等去重,重复 activity event 不得重复产生纸跟。
- [x] 4.11 处理失败必须增加 `processing_attempts`、记录 `last_processing_error`,超过阈值进入 `FAILED` 并写 research event,不得无限重试卡住整轮 run。
- [x] 4.12 新增 paper session 汇总计算,包含交易数、过滤数、open exposure、realized/unrealized/copyable PnL、max drawdown、unknown valuation exposure、confirmed zero exposure 和 filtered ratio。
- [x] 4.13 新增纸跟服务测试,覆盖 BUY、SELL、过滤、重复 event、unknown valuation、confirmed zero、PnL 计算、max drawdown、event claim 并发、失败重试和 FAILED 隔离。
## 5. Copyability 评分与研究状态机
- [x] 5.1 新增 `LeaderResearchScoringService`,计算并保存所有 copyability 分项和总分。
- [x] 5.2 定义 `score_version = research-copyability-v1`,写明每个分项的 0-100 取值范围、权重、缺数据策略和总分计算公式。
- [x] 5.3 v1 默认权重建议:profit signal 20、repeatability 15、liquidity fit 10、entry price fit 10、slippage risk 10、drawdown risk 10、holding period fit 5、market type risk 5、exit liquidity risk 5、data freshness 5、filter pass rate 5。
- [x] 5.4 实现收益信号封顶逻辑,避免单次大赢主导评分;样本量不足时总分必须被 cap,且不能进入 `TRIAL_READY`
- [x] 5.5 实现 repeatability、liquidity fit、entry price fit、slippage risk、holding period fit、market type risk、drawdown risk、exit liquidity risk、data freshness 和 filter pass rate 分项。
- [x] 5.6 对 quote unknown、valuation unavailable、source stale、filtered ratio 过高和 critical risk flag 定义明确扣分或晋升阻断规则。
- [x] 5.7 新增 `LeaderResearchStateMachine`,实现 `DISCOVERED -> CANDIDATE -> PAPER -> TRIAL_READY -> COOLDOWN/RETIRED` 迁移。
- [x] 5.8 状态迁移必须在事务中保存 rule version、oldState、newState、trigger reason、runId 和 research event。
- [x] 5.9 实现 `CANDIDATE -> PAPER` 默认阈值:score >= 60、未锁定、未退休、source 48 小时内新鲜。
- [x] 5.10 实现 `PAPER -> TRIAL_READY` 默认阈值:纸跟 >= 7 天、交易 >= 10、copyable PnL > 0、最大回撤不低于 -15%、UNKNOWN quote 暴露 <= 20%、过滤比例 < 50%、无 critical risk flag。
- [x] 5.11 实现 `PAPER -> COOLDOWN` 默认阈值:最大回撤 < -20%、10 笔后 copyable PnL < -5、quote/source stale > 72 小时或 thin liquidity exit risk。
- [x] 5.12 实现 cooldown 恢复和退休规则。
- [x] 5.13 实现 locked 候选保护,自动任务不得改变锁定候选的状态、试跟建议、建议配置或冷却/退休状态。
- [x] 5.14 新增评分和状态机测试,覆盖全部迁移、边界阈值、锁定保护、source stale、rule version、缺数据 cap、单次大赢 cap 和 critical risk 晋升阻断。
## 6. Leader 池联动与真钱安全边界
- [x] 6.1 新增 research 与 `copy_trading_leader_pool` 的映射服务,确保研究状态与 Leader 池状态分离。
- [x] 6.2 当研究候选进入 `CANDIDATE` 或更高状态且需要展示时,保守创建或更新 Leader 池项。
- [x] 6.3 `TRIAL_READY` 只能在 Leader 池展示“建议小额试跟” badge,不得自动把 Leader 池状态改为 `TRIAL``ACTIVE`
- [x] 6.4 自动任务不得增加 existing pool 的 suggested fixed amount、suggested max daily loss 或 suggested max daily orders。
- [x] 6.5 自动任务不得覆盖用户手工 notes;研究摘要必须写入独立字段或 append-only event。
- [x] 6.6 新增 research 专用手动审批服务,从 `TRIAL_READY` 候选创建禁用试跟配置;不得原样复用当前 `LeaderPoolService.createTrialConfig`,除非先重构出 disabled-only 安全 helper。
- [x] 6.7 research approval request DTO 不允许暴露 `enabled``enableImmediately` 或放宽风控字段;后端组装 `CopyTradingCreateRequest` 时必须强制 `enabled=false`
- [x] 6.8 如果客户端篡改提交 `enabled=true`、更大 fixed amount、更高 max daily loss、更多 max daily orders 或更宽 price range,后端必须拒绝或忽略,并写入 safety research event。
- [x] 6.9 创建禁用试跟配置前检查同账户同 leader 是否已有跟单配置,默认拒绝重复创建;双击提交必须只创建一条配置。
- [x] 6.10 用户明确创建 disabled trial config 成功后,Leader 池才可以从 WATCH/建议状态进入 `TRIAL`;仅 `TRIAL_READY` recommendation 不得进入 `TRIAL`
- [x] 6.11 创建禁用试跟配置成功后写入 research event,并提供跳转 `跟单配置` 的信息。
- [x] 6.12 新增安全测试,覆盖客户端篡改 `enabled=true`、高 fixed amount、放宽风控、locked 候选、重复配置、双击创建、TRIAL_READY 不自动 TRIAL 和 disabled config 创建后 copy_trading.enabled 仍为 false。
## 7. 后端 API 与错误处理
- [x] 7.1 新增 `LeaderResearchController`,提供研究运行、候选列表、候选详情、paper session、source health、research events 和手动创建禁用试跟配置 API。
- [x] 7.2 所有 research API 必须走现有受保护路由与鉴权边界。
- [x] 7.3 新增 research DTO,覆盖 run summary、candidate summary/detail、score components、paper session、paper trade、paper position、source health、event 和 approval response。
- [x] 7.4 新增 research 错误码和中英繁 i18n 文案,覆盖 run overlap、candidate not found、candidate locked、not trial ready、source unavailable、paper valuation unavailable、real money activation forbidden。
- [x] 7.5 服务内部使用命名错误或明确 result 类型,不得只用 catch-all Exception 表达业务失败。
- [x] 7.6 所有源失败、解析失败、状态迁移失败、纸跟失败和审批失败必须写入 research event 或 run error。
- [x] 7.7 新增 controller 测试,覆盖所有 API 的成功路径、参数错误、权限/保护边界、业务错误和真钱启用拒绝。
- [x] 7.8 手动 run 和创建禁用试跟配置 API 必须有重复提交保护;重复请求返回已有 run/config 或明确拒绝,不得产生重复副作用。
- [x] 7.9 候选详情 API 必须分页返回 paper trades 和 research events,不得一次性返回完整历史。
- [x] 7.10 API 错误响应必须让前端能区分 `disabled source``degraded source``valuation unknown``candidate locked``real money activation forbidden`
## 8. 前端类型、API 与操作台
- [x] 8.1 在 `frontend/src/types` 中新增 Leader Research 类型,覆盖状态、source health、score components、paper session/trade/position、events 和 approval request/response。
- [x] 8.2 在 `frontend/src/services/api.ts` 新增 `leaderResearch` API 分组。
- [x] 8.3 新增 `LeaderResearch` 页面和受保护路由。
- [x] 8.4 在左侧菜单合适位置新增 `Leader Research``Leader 雷达` 入口,避免和现有 `Leader 池` 混淆。
- [x] 8.5 操作台顶部展示 Today summarytrial-ready 数、新候选数、新纸跟数、冷却数、source stale 数。
- [x] 8.6 实现 Run Status 模块,展示最近运行、耗时、状态、来源统计和失败信息。
- [x] 8.7 实现 Pending Decisions 模块,展示 `TRIAL_READY` 候选和手动创建禁用试跟配置入口。
- [x] 8.8 实现 Candidate Detail,展示来源历史、score components、paper session summary、risk flags、research events 和 Leader 池映射状态。
- [x] 8.9 实现 Paper Sessions/Trades 展示,明确展示 valuation status、quote confidence 和 filtered reason。
- [x] 8.10 实现 Source Health 模块,区分 success empty、failure、stale 和 degraded。
- [x] 8.11 创建禁用试跟配置前,UI 必须展示 fixed amount、max daily loss、max daily orders、price range 和 max position value,并要求确认。
- [x] 8.12 UI 不得把 `TRIAL_READY` 文案表现成已经真实试跟;必须明确“建议小额试跟,待你确认”。
- [x] 8.13 新增 zh-CN、zh-TW、en 文案。
- [x] 8.14 确保移动端操作台至少可查看 summary、pending decisions 和 candidate detail,不出现不可操作表格。
- [x] 8.15 UI 必须展示 activity source limitationglobal capture disabled、Data API backfill stale、source degraded 等状态,避免用户误以为系统已经全网自动发现。
- [x] 8.16 创建禁用试跟配置按钮必须防双击,提交中禁用,并在重复配置错误时跳转或提示已有配置。
- [x] 8.17 Candidate Detail 必须把 `UNKNOWN``UNAVAILABLE``NO_MATCH``CONFIRMED_ZERO` 用不同文案和颜色展示,不能都显示成 0。
- [x] 8.18 Leader 池或 Leader Research 的 `TRIAL_READY` badge 必须和真实 `TRIAL` 状态视觉区分,避免用户误以为真钱已开始。
## 9. 通知与内部事件
- [x] 9.1 新增 research event 查询与标记通知状态能力。
- [x] 9.2 第一阶段在操作台展示内部事件,不强依赖外部通知渠道。
- [x] 9.3 新增通知摘要生成服务,基于 research event 聚合新增候选、纸跟达标、建议试跟、冷却、source failure 和 stale data。
- [x] 9.4 外部通知发送失败时必须保留 research event,并记录通知失败原因。
- [x] 9.5 如果接入现有 Telegram 通知,必须避免硬编码新渠道 secret,并复用现有通知配置读取路径。
- [x] 9.6 新增通知测试,覆盖事件生成、摘要生成、发送失败和事件保留。
- [x] 9.7 通知摘要必须包含 safety-relevant 事件:source disabled/degraded、valuation unknown、approval rejected、duplicate approval 和 real money activation forbidden。
- [x] 9.8 通知事件必须可去重,避免同一候选每轮 research run 重复推送相同 `TRIAL_READY` 或 source failure。
## 10. 性能、索引与数据保留
- [x] 10.1 候选列表、paper trade 列表、research event 列表必须分页。
- [x] 10.2 研究任务每次运行只处理 active candidates 和新 activity events,不得全量重算全部历史。
- [x] 10.3 为所有详情查询路径增加 repository 查询或聚合,避免 N+1 查询。
- [x] 10.4 增加 paper/activity 数据保留策略配置,默认保留足够研究窗口但避免无限增长。
- [x] 10.5 新增性能相关测试或验证脚本,至少覆盖 100 个候选、1 万条 activity event、1 万条 paper trade 的列表和一次研究运行。
- [x] 10.6 research run 必须使用 cursor/checkpoint 记录上次处理到的 event time 或 stable key;失败重跑时只回看有限窗口。
- [x] 10.7 paper processing 每批必须有 batch size 上限、运行时间上限和写入上限,避免一次 run 长时间占用数据库。
- [x] 10.8 global activity capture 如果启用,必须有每分钟写入上限、payload 最大长度、失败熔断和可观测计数。
- [x] 10.9 候选详情必须用聚合 DTO 或批量查询组装 score、paper summary、source health、pool mapping,禁止对列表中每个 candidate 单独查多张表。
## 11. 文档与运维
- [x] 11.1 新增中文文档,说明 Leader Research Agent、Leader 池、Leader 管理和跟单配置的区别。
- [x] 11.2 文档明确:研究代理可以自动纸跟和推荐,但绝不会自动启用真钱跟单。
- [x] 11.3 文档说明研究状态和 Leader 池状态映射,特别是 `TRIAL_READY` 不等于 `TRIAL`
- [x] 11.4 新增运维说明,覆盖开启/关闭 research job、查看 source health、排查 unknown valuation、排查重复 event 和回滚。
- [x] 11.5 更新相关 README 或 docs 索引,指向 Leader Research 文档。
- [x] 11.6 文档明确 activity-derived discovery 的真实覆盖范围:watchlist/existing leader Data API backfill、global capture 是否启用、public leaderboard 尚未接入。
- [x] 11.7 运维说明必须包含 kill switch:关闭 scheduled research、关闭 global activity capture、隐藏 approval endpoint 或前端入口。
## 12. 验证
- [x] 12.1 运行后端 research 相关单元测试和 controller 测试。
- [x] 12.2 运行已有 Leader 池、跟单配置、PnL 统计相关测试,确保未破坏现有安全边界。
- [x] 12.3 运行前端 TypeScript 构建。
- [x] 12.4 运行前端 lint。
- [x] 12.5 手动验证 research job 默认关闭、手动 run、source health、候选列表、候选详情、纸跟详情和 pending decisions。
- [x] 12.6 手动验证创建禁用试跟配置后 `copy_trading.enabled=false`,且不会自动启用。
- [x] 12.7 手动验证 source failure 不会删除候选、不会降级 locked 候选、不会把 unknown valuation 展示成 confirmed zero。
- [x] 12.8 在本地或测试环境验证重复 activity event 不会产生重复 paper trade。
- [x] 12.9 运行 `./gradlew test --tests "*LeaderResearch*"`,覆盖 research service、state machine、paper trading、approval safety 和 controller。
- [x] 12.10 运行 `./gradlew test --tests "*LeaderPool*" --tests "*CopyTrading*" --tests "*Pnl*"`,确保现有 Leader 池、跟单配置和 PnL 语义未回退。
- [x] 12.11 使用 gstack/browser 或 Playwright 进行前端安全 QA,覆盖 `TRIAL_READY` 文案、disabled approval modal、双击提交、source degraded、valuation unknown 和移动端关键路径。
- [x] 12.12 使用 `/qa` 时优先加载 `/Users/codyhhchen/.gstack/projects/WrBug-PolyHermes/codyhhchen-feat-real-pnl-statistics-eng-review-test-plan-20260504-210925.md` 作为主测试输入。
- [x] 12.13 验证当前 `PolymarketActivityWsService` 的已知 leader 过滤不会阻断 research ingestion 的未知钱包发现路径。
- [x] 12.14 验证 research job kill switch 生效:关闭后不再推进状态、不再处理 paper events,但页面仍可读历史数据。
## 13. 一次性实施顺序与合并策略
- [x] 13.1 第一批实现数据 spine:迁移、entity、repository、enum、source state、run record、activity event 和 processing status。
- [x] 13.2 第二批实现 ingestion 与 sourceData API backfill、optional global capture、watchlist、existing leaders、activity-derived candidates 和 source health。
- [x] 13.3 第三批实现 paper tradingevent claim、paper config、BUY/SELL、filter evaluator、valuation、PnL、drawdown 和重试隔离。
- [x] 13.4 第四批实现 scoring/state machinescore v1 公式、状态迁移、locked protection、cooldown/retire 和 research events。
- [x] 13.5 第五批实现 Leader 池联动和 disabled approval safetypool mapping、recommendation badge、disabled-only config creation、duplicate protection 和 safety tests。
- [x] 13.6 第六批实现 API 和前端操作台:run status、candidate list/detail、paper sessions、source health、pending decisions、approval modal 和 i18n。
- [x] 13.7 第七批实现通知、运维文档、kill switch、性能脚本和全量 QA。
- [x] 13.8 每一批都必须先补对应测试再合并到下一批;不得把 safety tests 延后到最后。
- [x] 13.9 如果并行工作,数据 spine 是共享依赖;paper trading、frontend shell、notification summary 可以并行,但 approval safety 必须等 DTO/服务边界稳定后再接。
## 14. Review 整改任务
- [x] 14.1 修复 locked candidate 安全边界:`LeaderResearchStateMachine.advance()` 对锁定候选必须完全跳过状态、score、badge、summary、Leader 池映射和建议配置写入,不得调用会产生副作用的 `syncCandidate`;补充 locked candidate 不变更 pool/candidate/poolId 的回归测试。
- [x] 14.2 修复 `DISCOVERED` 候选污染 Leader 池:状态机或 pool mapping 只能在候选达到 `CANDIDATE` 或更高状态时创建/更新 `copy_trading_leader_pool`,原始 `DISCOVERED` 钱包不得出现在 Leader 池;补充 `DISCOVERED` 不建池、`CANDIDATE+` 才建池的测试。
- [x] 14.3 修复创建禁用试跟配置的并发重复问题:为同账户同 leader 恢复数据库级唯一保护或引入等价事务锁/幂等键,确保双击、并发请求和重试最多创建一条 copy-trading config;补充并发审批测试和迁移回归测试。
- [x] 14.4 修复 Data API backfill 失败被误报为 source success`backfillWalletActivities` 失败必须向 `captureSource` 返回 typed failure/partial failuresource health 和 research run 必须显示 failure/degraded,而不是 SUCCESS;补充 Data API failure、success empty、partial failure 的 source health 测试。
- [x] 14.5 修复前端 approval preview 硬编码风险参数:确认弹窗必须展示后端返回或候选池建议的 `fixedAmount``maxDailyLoss``maxDailyOrders``minPrice``maxPrice``maxPositionValue`,不得写死 1 USDC/5 USDC/10 orders/0.10-0.80;补充 UI 数据映射测试或浏览器 QA。
- [x] 14.6 修复 candidate 搜索只搜当前页:后端搜索必须在数据库查询层按 wallet/address/source/status 等条件过滤并返回正确 total,不得先分页再内存过滤;补充分页外命中、空结果和 total 计数测试。
- [x] 14.7 修复 candidate list N+1 查询:列表 DTO 组装必须批量加载 leader、pool mapping、score/paper summary 等依赖,禁止每个 candidate 单独查多张表;补充 50+ 候选列表查询数量或性能回归测试。
- [x] 14.8 补齐 Review 发现对应的验证任务:完成后必须运行前端 build/lint、后端 `LeaderResearch` 相关测试、Leader 池/跟单配置/PnL 回归测试,并在本机没有 Java Runtime 时先安装或切换到可运行 Java 的环境再执行后端测试。
- [x] 14.9 完成未勾选的安全和 QA 任务后再关闭 change:至少覆盖 activity source health、候选来源、研究运行、纸跟、评分状态机、approval safety、controller、通知、数据保留、手动 QA 和浏览器 QA;不得只修 review 代码而保留关键验证项未完成。
+179
View File
@@ -0,0 +1,179 @@
-- Leader Research Agent performance fixture and query plan helper.
-- Intended for a disposable local/test database after migrations have run.
-- It seeds at least 100 candidates, 10k activity events, and 10k paper trades,
-- then runs EXPLAIN on the hot list/detail paths.
SET @now_ms = UNIX_TIMESTAMP(CURRENT_TIMESTAMP(3)) * 1000;
WITH RECURSIVE seq(n) AS (
SELECT 1
UNION ALL
SELECT n + 1 FROM seq WHERE n < 10000
)
INSERT IGNORE INTO leader_research_candidate (
normalized_wallet,
research_state,
source,
score,
score_version,
agent_owned,
provenance,
first_seen_at,
last_source_seen_at,
last_transition_at,
created_at,
updated_at
)
SELECT
CONCAT('0x', LPAD(HEX(n), 40, '0')),
CASE WHEN n % 5 = 0 THEN 'TRIAL_READY' WHEN n % 3 = 0 THEN 'PAPER' ELSE 'CANDIDATE' END,
'PERF_FIXTURE',
60 + (n % 40),
'research-copyability-v1',
1,
'AGENT_CREATED',
@now_ms - 3600000,
@now_ms - 60000,
@now_ms - 60000,
@now_ms,
@now_ms
FROM seq
WHERE n <= 100;
WITH RECURSIVE seq(n) AS (
SELECT 1
UNION ALL
SELECT n + 1 FROM seq WHERE n < 10000
)
INSERT IGNORE INTO leader_activity_event (
source,
source_event_id,
stable_event_key,
normalized_wallet,
market_id,
asset,
side,
price,
size,
amount,
event_time,
raw_payload_hash,
payload_summary,
usable_for_discovery,
usable_for_paper,
paper_processing_status,
processing_attempts,
created_at,
updated_at
)
SELECT
'PERF_FIXTURE',
CONCAT('perf-event-', n),
CONCAT('perf-event-', n),
CONCAT('0x', LPAD(HEX(1 + (n % 100)), 40, '0')),
CONCAT('condition-', n % 200),
CONCAT('asset-', n % 200),
IF(n % 2 = 0, 'BUY', 'SELL'),
0.45,
10,
4.5,
@now_ms - (n * 1000),
SHA2(CONCAT('perf-event-', n), 256),
CONCAT('perf event ', n),
1,
1,
'NEW',
0,
@now_ms,
@now_ms
FROM seq;
WITH RECURSIVE seq(n) AS (
SELECT 1
UNION ALL
SELECT n + 1 FROM seq WHERE n < 10000
)
INSERT IGNORE INTO leader_paper_session (
id,
candidate_id,
status,
started_at,
trade_count,
filtered_count,
open_exposure,
copyable_pnl,
max_drawdown,
unknown_valuation_exposure,
confirmed_zero_exposure,
filtered_ratio,
created_at,
updated_at
)
SELECT
n,
1 + (n % 100),
'ACTIVE',
@now_ms - 604800000,
100,
5,
100,
10,
-3,
5,
0,
0.0476,
@now_ms,
@now_ms
FROM seq
WHERE n <= 100;
WITH RECURSIVE seq(n) AS (
SELECT 1
UNION ALL
SELECT n + 1 FROM seq WHERE n < 10000
)
INSERT IGNORE INTO leader_paper_trade (
session_id,
candidate_id,
leader_trade_id,
market_id,
side,
leader_price,
leader_size,
simulated_price,
simulated_size,
simulated_amount,
fill_assumption,
quote_confidence,
quote_source,
quote_timestamp,
filter_result,
valuation_status,
event_time,
created_at
)
SELECT
1 + (n % 100),
1 + (n % 100),
CONCAT('perf-trade-', n),
CONCAT('condition-', n % 200),
IF(n % 2 = 0, 'BUY', 'SELL'),
0.45,
10,
0.45,
2,
1,
'LEADER_PRICE',
'MEDIUM',
'perf',
@now_ms,
'PASSED',
IF(n % 20 = 0, 'UNKNOWN', 'AVAILABLE'),
@now_ms - (n * 1000),
@now_ms
FROM seq;
EXPLAIN SELECT * FROM leader_research_candidate WHERE research_state IN ('PAPER', 'TRIAL_READY') ORDER BY updated_at DESC LIMIT 50;
EXPLAIN SELECT * FROM leader_activity_event WHERE paper_processing_status IN ('NEW', 'RETRYABLE') AND usable_for_paper = 1 ORDER BY event_time ASC LIMIT 200;
EXPLAIN SELECT * FROM leader_paper_trade WHERE session_id = 1 ORDER BY event_time DESC LIMIT 100;
EXPLAIN SELECT * FROM leader_research_event ORDER BY created_at DESC LIMIT 100;