From a3f74b8567e2732be1ad60162297b52187c61371 Mon Sep 17 00:00:00 2001 From: codyhhchen Date: Tue, 5 May 2026 18:15:15 +0800 Subject: [PATCH] feat: add leader research agent --- .gitignore | 1 + README.md | 1 + README_EN.md | 2 +- .../research/LeaderResearchController.kt | 142 +++++ .../wrbug/polymarketbot/dto/LeaderPoolDto.kt | 6 + .../polymarketbot/dto/LeaderResearchDto.kt | 224 +++++++ .../wrbug/polymarketbot/entity/LeaderPool.kt | 20 + .../entity/LeaderResearchEntities.kt | 600 ++++++++++++++++++ .../wrbug/polymarketbot/enums/ErrorCode.kt | 11 + .../enums/LeaderResearchEnums.kt | 123 ++++ .../repository/AccountRepository.kt | 9 +- .../repository/LeaderPoolRepository.kt | 2 + .../repository/LeaderRepository.kt | 3 +- .../repository/LeaderResearchRepositories.kt | 132 ++++ .../leaderpool/LeaderPoolService.kt | 6 + .../monitor/PolymarketActivityWsService.kt | 108 +++- .../LeaderActivityIngestionService.kt | 203 ++++++ .../research/LeaderPaperTradingService.kt | 530 ++++++++++++++++ .../research/LeaderResearchApprovalService.kt | 146 +++++ .../research/LeaderResearchEventService.kt | 50 ++ .../research/LeaderResearchJobService.kt | 144 +++++ .../research/LeaderResearchMapper.kt | 251 ++++++++ ...eaderResearchNotificationSummaryService.kt | 72 +++ .../LeaderResearchPoolMappingService.kt | 99 +++ .../LeaderResearchRetentionService.kt | 61 ++ .../research/LeaderResearchScoringService.kt | 166 +++++ .../research/LeaderResearchService.kt | 115 ++++ .../LeaderResearchSourceHealthService.kt | 64 ++ .../research/LeaderResearchSourceService.kt | 508 +++++++++++++++ .../research/LeaderResearchStateMachine.kt | 154 +++++ .../src/main/resources/application.properties | 17 +- ...2__create_leader_research_agent_tables.sql | 262 ++++++++ .../resources/i18n/messages_en.properties | 11 + .../resources/i18n/messages_zh_CN.properties | 11 + .../resources/i18n/messages_zh_TW.properties | 11 + .../leaderpool/LeaderPoolControllerTest.kt | 6 + .../research/LeaderResearchControllerTest.kt | 93 +++ ...PolymarketActivityWsResearchCaptureTest.kt | 123 ++++ .../LeaderActivityIngestionServiceTest.kt | 161 +++++ .../research/LeaderPaperTradingServiceTest.kt | 376 +++++++++++ .../LeaderResearchApprovalServiceTest.kt | 152 +++++ .../research/LeaderResearchJobServiceTest.kt | 142 +++++ ...rResearchNotificationSummaryServiceTest.kt | 68 ++ .../LeaderResearchRetentionServiceTest.kt | 90 +++ .../LeaderResearchScoringServiceTest.kt | 95 +++ .../LeaderResearchSourceHealthServiceTest.kt | 99 +++ .../LeaderResearchSourceServiceTest.kt | 211 ++++++ .../LeaderResearchStateMachineTest.kt | 83 +++ docs/zh/leader-research-agent.md | 65 ++ frontend/src/App.tsx | 2 + frontend/src/components/Layout.tsx | 12 +- frontend/src/locales/en/common.json | 68 +- frontend/src/locales/zh-CN/common.json | 68 +- frontend/src/locales/zh-TW/common.json | 68 +- frontend/src/pages/LeaderPool.tsx | 11 + frontend/src/pages/LeaderResearch.tsx | 577 +++++++++++++++++ frontend/src/services/api.ts | 33 + frontend/src/types/index.ts | 231 +++++++ .../add-leader-research-agent/.openspec.yaml | 2 + .../add-leader-research-agent/design.md | 282 ++++++++ .../add-leader-research-agent/proposal.md | 37 ++ .../specs/leader-research-agent/spec.md | 228 +++++++ .../add-leader-research-agent/tasks.md | 208 ++++++ scripts/leader-research-perf-check.sql | 179 ++++++ 64 files changed, 8023 insertions(+), 12 deletions(-) create mode 100644 backend/src/main/kotlin/com/wrbug/polymarketbot/controller/copytrading/research/LeaderResearchController.kt create mode 100644 backend/src/main/kotlin/com/wrbug/polymarketbot/dto/LeaderResearchDto.kt create mode 100644 backend/src/main/kotlin/com/wrbug/polymarketbot/entity/LeaderResearchEntities.kt create mode 100644 backend/src/main/kotlin/com/wrbug/polymarketbot/enums/LeaderResearchEnums.kt create mode 100644 backend/src/main/kotlin/com/wrbug/polymarketbot/repository/LeaderResearchRepositories.kt create mode 100644 backend/src/main/kotlin/com/wrbug/polymarketbot/service/copytrading/research/LeaderActivityIngestionService.kt create mode 100644 backend/src/main/kotlin/com/wrbug/polymarketbot/service/copytrading/research/LeaderPaperTradingService.kt create mode 100644 backend/src/main/kotlin/com/wrbug/polymarketbot/service/copytrading/research/LeaderResearchApprovalService.kt create mode 100644 backend/src/main/kotlin/com/wrbug/polymarketbot/service/copytrading/research/LeaderResearchEventService.kt create mode 100644 backend/src/main/kotlin/com/wrbug/polymarketbot/service/copytrading/research/LeaderResearchJobService.kt create mode 100644 backend/src/main/kotlin/com/wrbug/polymarketbot/service/copytrading/research/LeaderResearchMapper.kt create mode 100644 backend/src/main/kotlin/com/wrbug/polymarketbot/service/copytrading/research/LeaderResearchNotificationSummaryService.kt create mode 100644 backend/src/main/kotlin/com/wrbug/polymarketbot/service/copytrading/research/LeaderResearchPoolMappingService.kt create mode 100644 backend/src/main/kotlin/com/wrbug/polymarketbot/service/copytrading/research/LeaderResearchRetentionService.kt create mode 100644 backend/src/main/kotlin/com/wrbug/polymarketbot/service/copytrading/research/LeaderResearchScoringService.kt create mode 100644 backend/src/main/kotlin/com/wrbug/polymarketbot/service/copytrading/research/LeaderResearchService.kt create mode 100644 backend/src/main/kotlin/com/wrbug/polymarketbot/service/copytrading/research/LeaderResearchSourceHealthService.kt create mode 100644 backend/src/main/kotlin/com/wrbug/polymarketbot/service/copytrading/research/LeaderResearchSourceService.kt create mode 100644 backend/src/main/kotlin/com/wrbug/polymarketbot/service/copytrading/research/LeaderResearchStateMachine.kt create mode 100644 backend/src/main/resources/db/migration/V42__create_leader_research_agent_tables.sql create mode 100644 backend/src/test/kotlin/com/wrbug/polymarketbot/controller/copytrading/research/LeaderResearchControllerTest.kt create mode 100644 backend/src/test/kotlin/com/wrbug/polymarketbot/service/copytrading/monitor/PolymarketActivityWsResearchCaptureTest.kt create mode 100644 backend/src/test/kotlin/com/wrbug/polymarketbot/service/copytrading/research/LeaderActivityIngestionServiceTest.kt create mode 100644 backend/src/test/kotlin/com/wrbug/polymarketbot/service/copytrading/research/LeaderPaperTradingServiceTest.kt create mode 100644 backend/src/test/kotlin/com/wrbug/polymarketbot/service/copytrading/research/LeaderResearchApprovalServiceTest.kt create mode 100644 backend/src/test/kotlin/com/wrbug/polymarketbot/service/copytrading/research/LeaderResearchJobServiceTest.kt create mode 100644 backend/src/test/kotlin/com/wrbug/polymarketbot/service/copytrading/research/LeaderResearchNotificationSummaryServiceTest.kt create mode 100644 backend/src/test/kotlin/com/wrbug/polymarketbot/service/copytrading/research/LeaderResearchRetentionServiceTest.kt create mode 100644 backend/src/test/kotlin/com/wrbug/polymarketbot/service/copytrading/research/LeaderResearchScoringServiceTest.kt create mode 100644 backend/src/test/kotlin/com/wrbug/polymarketbot/service/copytrading/research/LeaderResearchSourceHealthServiceTest.kt create mode 100644 backend/src/test/kotlin/com/wrbug/polymarketbot/service/copytrading/research/LeaderResearchSourceServiceTest.kt create mode 100644 backend/src/test/kotlin/com/wrbug/polymarketbot/service/copytrading/research/LeaderResearchStateMachineTest.kt create mode 100644 docs/zh/leader-research-agent.md create mode 100644 frontend/src/pages/LeaderResearch.tsx create mode 100644 openspec/changes/add-leader-research-agent/.openspec.yaml create mode 100644 openspec/changes/add-leader-research-agent/design.md create mode 100644 openspec/changes/add-leader-research-agent/proposal.md create mode 100644 openspec/changes/add-leader-research-agent/specs/leader-research-agent/spec.md create mode 100644 openspec/changes/add-leader-research-agent/tasks.md create mode 100644 scripts/leader-research-perf-check.sql diff --git a/.gitignore b/.gitignore index 442cfb5..02d650d 100644 --- a/.gitignore +++ b/.gitignore @@ -95,6 +95,7 @@ coverage/ # Test test-results/ *.test.log +.playwright-cli/ # Python __pycache__/ diff --git a/README.md b/README.md index c62f53d..177de4b 100644 --- a/README.md +++ b/README.md @@ -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) - 动态更新功能说明 ### 🤝 贡献指南 diff --git a/README_EN.md b/README_EN.md index d8505f4..f427718 100644 --- a/README_EN.md +++ b/README_EN.md @@ -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!** - diff --git a/backend/src/main/kotlin/com/wrbug/polymarketbot/controller/copytrading/research/LeaderResearchController.kt b/backend/src/main/kotlin/com/wrbug/polymarketbot/controller/copytrading/research/LeaderResearchController.kt new file mode 100644 index 0000000..8cd672c --- /dev/null +++ b/backend/src/main/kotlin/com/wrbug/polymarketbot/controller/copytrading/research/LeaderResearchController.kt @@ -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> { + 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> { + return safe(ErrorCode.SERVER_LEADER_RESEARCH_FETCH_FAILED) { researchService.summary() } + } + + @PostMapping("/candidates/list") + fun list(@RequestBody request: LeaderResearchCandidateListRequest): ResponseEntity> { + return safe(ErrorCode.SERVER_LEADER_RESEARCH_FETCH_FAILED) { researchService.listCandidates(request) } + } + + @PostMapping("/candidates/detail") + fun detail(@RequestBody request: LeaderResearchDetailRequest): ResponseEntity> { + 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>> { + 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>> { + return safe(ErrorCode.SERVER_LEADER_RESEARCH_FETCH_FAILED) { researchService.sourceHealth() } + } + + @PostMapping("/events/list") + fun events(@RequestBody request: LeaderResearchEventsRequest): ResponseEntity>> { + 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> { + 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 safe(errorCode: ErrorCode, block: () -> T): ResponseEntity> { + 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 errorResponse(e: Throwable, fallback: ErrorCode): ResponseEntity> { + 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)) + } +} diff --git a/backend/src/main/kotlin/com/wrbug/polymarketbot/dto/LeaderPoolDto.kt b/backend/src/main/kotlin/com/wrbug/polymarketbot/dto/LeaderPoolDto.kt index 58c524c..0fd2332 100644 --- a/backend/src/main/kotlin/com/wrbug/polymarketbot/dto/LeaderPoolDto.kt +++ b/backend/src/main/kotlin/com/wrbug/polymarketbot/dto/LeaderPoolDto.kt @@ -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 ) diff --git a/backend/src/main/kotlin/com/wrbug/polymarketbot/dto/LeaderResearchDto.kt b/backend/src/main/kotlin/com/wrbug/polymarketbot/dto/LeaderResearchDto.kt new file mode 100644 index 0000000..3247e27 --- /dev/null +++ b/backend/src/main/kotlin/com/wrbug/polymarketbot/dto/LeaderResearchDto.kt @@ -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 +) + +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, + 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, + 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, + val paperTrades: List, + val paperPositions: List, + val events: List +) + +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 = "已创建禁用状态的试跟配置;需要你手动启用后才会真钱跟单。" +) diff --git a/backend/src/main/kotlin/com/wrbug/polymarketbot/entity/LeaderPool.kt b/backend/src/main/kotlin/com/wrbug/polymarketbot/entity/LeaderPool.kt index 9338f10..f8e6cec 100644 --- a/backend/src/main/kotlin/com/wrbug/polymarketbot/entity/LeaderPool.kt +++ b/backend/src/main/kotlin/com/wrbug/polymarketbot/entity/LeaderPool.kt @@ -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(), diff --git a/backend/src/main/kotlin/com/wrbug/polymarketbot/entity/LeaderResearchEntities.kt b/backend/src/main/kotlin/com/wrbug/polymarketbot/entity/LeaderResearchEntities.kt new file mode 100644 index 0000000..05e166c --- /dev/null +++ b/backend/src/main/kotlin/com/wrbug/polymarketbot/entity/LeaderResearchEntities.kt @@ -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() +) diff --git a/backend/src/main/kotlin/com/wrbug/polymarketbot/enums/ErrorCode.kt b/backend/src/main/kotlin/com/wrbug/polymarketbot/enums/ErrorCode.kt index 9a966f6..77f2351 100644 --- a/backend/src/main/kotlin/com/wrbug/polymarketbot/enums/ErrorCode.kt +++ b/backend/src/main/kotlin/com/wrbug/polymarketbot/enums/ErrorCode.kt @@ -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"), diff --git a/backend/src/main/kotlin/com/wrbug/polymarketbot/enums/LeaderResearchEnums.kt b/backend/src/main/kotlin/com/wrbug/polymarketbot/enums/LeaderResearchEnums.kt new file mode 100644 index 0000000..c41f80a --- /dev/null +++ b/backend/src/main/kotlin/com/wrbug/polymarketbot/enums/LeaderResearchEnums.kt @@ -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 +} diff --git a/backend/src/main/kotlin/com/wrbug/polymarketbot/repository/AccountRepository.kt b/backend/src/main/kotlin/com/wrbug/polymarketbot/repository/AccountRepository.kt index ccfcd54..67cfdc2 100644 --- a/backend/src/main/kotlin/com/wrbug/polymarketbot/repository/AccountRepository.kt +++ b/backend/src/main/kotlin/com/wrbug/polymarketbot/repository/AccountRepository.kt @@ -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 { * 查找默认账户 */ 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 { */ fun existsByProxyAddress(proxyAddress: String): Boolean } - diff --git a/backend/src/main/kotlin/com/wrbug/polymarketbot/repository/LeaderPoolRepository.kt b/backend/src/main/kotlin/com/wrbug/polymarketbot/repository/LeaderPoolRepository.kt index 22fcbfc..4f2db7d 100644 --- a/backend/src/main/kotlin/com/wrbug/polymarketbot/repository/LeaderPoolRepository.kt +++ b/backend/src/main/kotlin/com/wrbug/polymarketbot/repository/LeaderPoolRepository.kt @@ -16,4 +16,6 @@ interface LeaderPoolRepository : JpaRepository { fun findAllByOrderByCreatedAtDesc(): List fun deleteByLeaderId(leaderId: Long) + + fun findByIdIn(ids: Collection): List } diff --git a/backend/src/main/kotlin/com/wrbug/polymarketbot/repository/LeaderRepository.kt b/backend/src/main/kotlin/com/wrbug/polymarketbot/repository/LeaderRepository.kt index f59f0bd..eb05477 100644 --- a/backend/src/main/kotlin/com/wrbug/polymarketbot/repository/LeaderRepository.kt +++ b/backend/src/main/kotlin/com/wrbug/polymarketbot/repository/LeaderRepository.kt @@ -29,5 +29,6 @@ interface LeaderRepository : JpaRepository { * 查找所有 Leader,按创建时间排序 */ fun findAllByOrderByCreatedAtAsc(): List -} + fun findByIdIn(ids: Collection): List +} diff --git a/backend/src/main/kotlin/com/wrbug/polymarketbot/repository/LeaderResearchRepositories.kt b/backend/src/main/kotlin/com/wrbug/polymarketbot/repository/LeaderResearchRepositories.kt new file mode 100644 index 0000000..5fbfcf4 --- /dev/null +++ b/backend/src/main/kotlin/com/wrbug/polymarketbot/repository/LeaderResearchRepositories.kt @@ -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 { + fun findTopByOrderByStartedAtDesc(): LeaderResearchRun? + fun findByStatus(status: LeaderResearchRunStatus): List + fun findTopByStatusOrderByStartedAtDesc(status: LeaderResearchRunStatus): LeaderResearchRun? +} + +@Repository +interface LeaderResearchCandidateRepository : JpaRepository { + fun findByNormalizedWallet(normalizedWallet: String): LeaderResearchCandidate? + fun findByLeaderId(leaderId: Long): LeaderResearchCandidate? + fun findByPoolId(poolId: Long): LeaderResearchCandidate? + fun findByResearchState(researchState: LeaderResearchState): List + fun findByResearchStateIn(states: Collection): List + fun findByResearchStateIn(states: Collection, pageable: Pageable): Page + fun findAllByOrderByUpdatedAtDesc(pageable: Pageable): Page + 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 +} + +@Repository +interface LeaderResearchScoreRepository : JpaRepository { + fun findTopByCandidateIdOrderByCreatedAtDesc(candidateId: Long): LeaderResearchScore? + fun findByCandidateIdOrderByCreatedAtDesc(candidateId: Long): List +} + +@Repository +interface LeaderResearchEventRepository : JpaRepository { + fun findByCandidateIdOrderByCreatedAtDesc(candidateId: Long, pageable: Pageable): Page + fun findByRunIdOrderByCreatedAtDesc(runId: Long): List + fun findByNotificationStatusOrderByCreatedAtAsc(status: LeaderResearchNotificationStatus, pageable: Pageable): Page + fun findTopByDedupeKey(dedupeKey: String): LeaderResearchEvent? + fun findAllByOrderByCreatedAtDesc(pageable: Pageable): Page +} + +@Repository +interface LeaderResearchSourceStateRepository : JpaRepository { + fun findBySourceType(sourceType: LeaderResearchSourceType): LeaderResearchSourceState? + fun findAllByOrderByUpdatedAtDesc(): List +} + +@Repository +interface LeaderActivityEventRepository : JpaRepository { + fun findByStableEventKey(stableEventKey: String): LeaderActivityEvent? + fun findBySourceAndSourceEventId(source: String, sourceEventId: String): LeaderActivityEvent? + fun findTopByOrderByEventTimeDesc(): LeaderActivityEvent? + fun findByNormalizedWalletAndEventTimeBetweenOrderByEventTimeAsc(normalizedWallet: String, start: Long, end: Long): List + fun findByUsableForDiscoveryTrueAndEventTimeGreaterThanEqual(eventTime: Long): List + fun findByPaperProcessingStatusInAndUsableForPaperTrueOrderByEventTimeAsc(statuses: Collection, pageable: Pageable): Page + + fun deleteByEventTimeLessThanAndPaperProcessingStatusIn( + eventTime: Long, + statuses: Collection + ): 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, + @Param("nextStatus") nextStatus: LeaderPaperProcessingStatus, + @Param("startedAt") startedAt: Long + ): Int +} + +@Repository +interface LeaderPaperSessionRepository : JpaRepository { + fun findTopByCandidateIdAndStatusOrderByStartedAtDesc(candidateId: Long, status: LeaderPaperSessionStatus): LeaderPaperSession? + fun findTopByCandidateIdOrderByStartedAtDesc(candidateId: Long): LeaderPaperSession? + fun findByCandidateIdOrderByStartedAtDesc(candidateId: Long): List + fun findByUpdatedAtLessThanAndStatusIn(updatedAt: Long, statuses: Collection, pageable: Pageable): Page + + @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): List +} + +@Repository +interface LeaderPaperTradeRepository : JpaRepository { + fun existsBySessionIdAndLeaderTradeIdAndSide(sessionId: Long, leaderTradeId: String, side: String): Boolean + fun findBySessionIdOrderByEventTimeDesc(sessionId: Long, pageable: Pageable): Page + fun findBySessionIdOrderByEventTimeAsc(sessionId: Long): List + fun countBySessionId(sessionId: Long): Long + fun countBySessionIdAndFilterResult(sessionId: Long, filterResult: LeaderPaperFilterResult): Long +} + +@Repository +interface LeaderPaperPositionRepository : JpaRepository { + fun findBySessionIdAndMarketIdAndOutcomeIndex(sessionId: Long, marketId: String, outcomeIndex: Int?): LeaderPaperPosition? + fun findBySessionIdOrderByUpdatedAtDesc(sessionId: Long): List + fun findByCandidateIdOrderByUpdatedAtDesc(candidateId: Long): List +} diff --git a/backend/src/main/kotlin/com/wrbug/polymarketbot/service/copytrading/leaderpool/LeaderPoolService.kt b/backend/src/main/kotlin/com/wrbug/polymarketbot/service/copytrading/leaderpool/LeaderPoolService.kt index bb6dd1f..3bfd0b2 100644 --- a/backend/src/main/kotlin/com/wrbug/polymarketbot/service/copytrading/leaderpool/LeaderPoolService.kt +++ b/backend/src/main/kotlin/com/wrbug/polymarketbot/service/copytrading/leaderpool/LeaderPoolService.kt @@ -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 ) diff --git a/backend/src/main/kotlin/com/wrbug/polymarketbot/service/copytrading/monitor/PolymarketActivityWsService.kt b/backend/src/main/kotlin/com/wrbug/polymarketbot/service/copytrading/monitor/PolymarketActivityWsService.kt index 2f94bfd..4a43f99 100644 --- a/backend/src/main/kotlin/com/wrbug/polymarketbot/service/copytrading/monitor/PolymarketActivityWsService.kt +++ b/backend/src/main/kotlin/com/wrbug/polymarketbot/service/copytrading/monitor/PolymarketActivityWsService.kt @@ -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, + private val researchSourceHealthProvider: ObjectProvider, + @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() ?: run { + recordResearchCaptureHealth( + status = LeaderResearchSourceStatus.FAILURE, + errorClass = "JsonParseFailure", + errorMessage = "failed to parse activity websocket message" + ) + return + } + if (tradeMessage.topic != "activity" || (tradeMessage.type != "trades" && tradeMessage.type != "orders_matched")) { + return + } + val ingestionService = researchIngestionProvider.getIfAvailable() ?: return + try { + val event = ingestionService.ingestWebSocketTrade(tradeMessage) + researchCaptureWritesThisMinute++ + recordResearchCaptureHealth( + status = LeaderResearchSourceStatus.SUCCESS, + candidateCount = researchCaptureWritesThisMinute.toInt(), + lastCursor = "${event.eventTime}:${event.stableEventKey}" + ) + } catch (e: Exception) { + logger.warn("Research global activity capture failed: {}", e.message) + recordResearchCaptureHealth( + status = LeaderResearchSourceStatus.FAILURE, + errorClass = e::class.java.simpleName, + errorMessage = e.message + ) + } + } + + private fun recordResearchCaptureHealth( + status: LeaderResearchSourceStatus, + candidateCount: Int = 0, + errorClass: String? = null, + errorMessage: String? = null, + disabledReason: String? = null, + lastCursor: String? = null + ) { + if (shouldThrottleResearchCaptureHealth(status)) { + return + } + researchSourceHealthProvider.getIfAvailable()?.record( + sourceType = LeaderResearchSourceType.GLOBAL_ACTIVITY_CAPTURE, + status = status, + candidateCount = candidateCount, + errorClass = errorClass, + errorMessage = errorMessage, + disabledReason = disabledReason, + lastCursor = lastCursor + ) + } + + private fun shouldThrottleResearchCaptureHealth(status: LeaderResearchSourceStatus): Boolean { + val now = System.currentTimeMillis() + val throttle = status != LeaderResearchSourceStatus.SUCCESS && + status == researchCaptureLastHealthStatus && + now - researchCaptureLastHealthWriteAt < RESEARCH_CAPTURE_HEALTH_THROTTLE_MS + if (!throttle) { + researchCaptureLastHealthStatus = status + researchCaptureLastHealthWriteAt = now + } + return throttle + } + /** * 提取交易者地址 * 优先检查 trader.address,fallback 到 proxyWallet @@ -574,5 +675,8 @@ class PolymarketActivityWsService( stop() scope.cancel() } -} + companion object { + private const val RESEARCH_CAPTURE_HEALTH_THROTTLE_MS = 60_000L + } +} diff --git a/backend/src/main/kotlin/com/wrbug/polymarketbot/service/copytrading/research/LeaderActivityIngestionService.kt b/backend/src/main/kotlin/com/wrbug/polymarketbot/service/copytrading/research/LeaderActivityIngestionService.kt new file mode 100644 index 0000000..a69bb78 --- /dev/null +++ b/backend/src/main/kotlin/com/wrbug/polymarketbot/service/copytrading/research/LeaderActivityIngestionService.kt @@ -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() + 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) } + } +} diff --git a/backend/src/main/kotlin/com/wrbug/polymarketbot/service/copytrading/research/LeaderPaperTradingService.kt b/backend/src/main/kotlin/com/wrbug/polymarketbot/service/copytrading/research/LeaderPaperTradingService.kt new file mode 100644 index 0000000..42dca67 --- /dev/null +++ b/backend/src/main/kotlin/com/wrbug/polymarketbot/service/copytrading/research/LeaderPaperTradingService.kt @@ -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 + } +} diff --git a/backend/src/main/kotlin/com/wrbug/polymarketbot/service/copytrading/research/LeaderResearchApprovalService.kt b/backend/src/main/kotlin/com/wrbug/polymarketbot/service/copytrading/research/LeaderResearchApprovalService.kt new file mode 100644 index 0000000..d4b99bd --- /dev/null +++ b/backend/src/main/kotlin/com/wrbug/polymarketbot/service/copytrading/research/LeaderResearchApprovalService.kt @@ -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 { + 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() +} diff --git a/backend/src/main/kotlin/com/wrbug/polymarketbot/service/copytrading/research/LeaderResearchEventService.kt b/backend/src/main/kotlin/com/wrbug/polymarketbot/service/copytrading/research/LeaderResearchEventService.kt new file mode 100644 index 0000000..7350e9a --- /dev/null +++ b/backend/src/main/kotlin/com/wrbug/polymarketbot/service/copytrading/research/LeaderResearchEventService.kt @@ -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 + } + } +} diff --git a/backend/src/main/kotlin/com/wrbug/polymarketbot/service/copytrading/research/LeaderResearchJobService.kt b/backend/src/main/kotlin/com/wrbug/polymarketbot/service/copytrading/research/LeaderResearchJobService.kt new file mode 100644 index 0000000..38a9cef --- /dev/null +++ b/backend/src/main/kotlin/com/wrbug/polymarketbot/service/copytrading/research/LeaderResearchJobService.kt @@ -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) + } + } +} diff --git a/backend/src/main/kotlin/com/wrbug/polymarketbot/service/copytrading/research/LeaderResearchMapper.kt b/backend/src/main/kotlin/com/wrbug/polymarketbot/service/copytrading/research/LeaderResearchMapper.kt new file mode 100644 index 0000000..d64ecad --- /dev/null +++ b/backend/src/main/kotlin/com/wrbug/polymarketbot/service/copytrading/research/LeaderResearchMapper.kt @@ -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 = emptyMap(), + val poolsById: Map = emptyMap(), + val latestSessionsByCandidateId: Map = 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, context: LeaderResearchCandidateDtoContext): List { + 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 { + 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 { + if (raw.isNullOrBlank()) return emptyList() + return raw.split(",", "\n", ";").map { it.trim() }.filter { it.isNotEmpty() } + } + + private fun BigDecimal.strip(): String = stripTrailingZeros().toPlainString() +} diff --git a/backend/src/main/kotlin/com/wrbug/polymarketbot/service/copytrading/research/LeaderResearchNotificationSummaryService.kt b/backend/src/main/kotlin/com/wrbug/polymarketbot/service/copytrading/research/LeaderResearchNotificationSummaryService.kt new file mode 100644 index 0000000..7cc4ec4 --- /dev/null +++ b/backend/src/main/kotlin/com/wrbug/polymarketbot/service/copytrading/research/LeaderResearchNotificationSummaryService.kt @@ -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 +) + +@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): 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 + ) + } +} diff --git a/backend/src/main/kotlin/com/wrbug/polymarketbot/service/copytrading/research/LeaderResearchPoolMappingService.kt b/backend/src/main/kotlin/com/wrbug/polymarketbot/service/copytrading/research/LeaderResearchPoolMappingService.kt new file mode 100644 index 0000000..97f047e --- /dev/null +++ b/backend/src/main/kotlin/com/wrbug/polymarketbot/service/copytrading/research/LeaderResearchPoolMappingService.kt @@ -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 + ) + ) + } +} diff --git a/backend/src/main/kotlin/com/wrbug/polymarketbot/service/copytrading/research/LeaderResearchRetentionService.kt b/backend/src/main/kotlin/com/wrbug/polymarketbot/service/copytrading/research/LeaderResearchRetentionService.kt new file mode 100644 index 0000000..6c940be --- /dev/null +++ b/backend/src/main/kotlin/com/wrbug/polymarketbot/service/copytrading/research/LeaderResearchRetentionService.kt @@ -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 + } +} diff --git a/backend/src/main/kotlin/com/wrbug/polymarketbot/service/copytrading/research/LeaderResearchScoringService.kt b/backend/src/main/kotlin/com/wrbug/polymarketbot/service/copytrading/research/LeaderResearchScoringService.kt new file mode 100644 index 0000000..c6ffc69 --- /dev/null +++ b/backend/src/main/kotlin/com/wrbug/polymarketbot/service/copytrading/research/LeaderResearchScoringService.kt @@ -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 { + 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() + 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 + } +} diff --git a/backend/src/main/kotlin/com/wrbug/polymarketbot/service/copytrading/research/LeaderResearchService.kt b/backend/src/main/kotlin/com/wrbug/polymarketbot/service/copytrading/research/LeaderResearchService.kt new file mode 100644 index 0000000..79d82a6 --- /dev/null +++ b/backend/src/main/kotlin/com/wrbug/polymarketbot/service/copytrading/research/LeaderResearchService.kt @@ -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): 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 { + return sourceStateRepository.findAllByOrderByUpdatedAtDesc().map { mapper.sourceStateDto(it) } + } + + fun events(page: Int, size: Int): List { + return eventRepository.findAllByOrderByCreatedAtDesc(PageRequest.of(page.coerceAtLeast(0), size.coerceIn(1, 100))) + .content + .map { mapper.eventDto(it) } + } + + fun paperSessions(candidateId: Long): List { + return paperSessionRepository.findByCandidateIdOrderByStartedAtDesc(candidateId).map { mapper.paperSessionDto(it) } + } +} diff --git a/backend/src/main/kotlin/com/wrbug/polymarketbot/service/copytrading/research/LeaderResearchSourceHealthService.kt b/backend/src/main/kotlin/com/wrbug/polymarketbot/service/copytrading/research/LeaderResearchSourceHealthService.kt new file mode 100644 index 0000000..41428d4 --- /dev/null +++ b/backend/src/main/kotlin/com/wrbug/polymarketbot/service/copytrading/research/LeaderResearchSourceHealthService.kt @@ -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) + } +} diff --git a/backend/src/main/kotlin/com/wrbug/polymarketbot/service/copytrading/research/LeaderResearchSourceService.kt b/backend/src/main/kotlin/com/wrbug/polymarketbot/service/copytrading/research/LeaderResearchSourceService.kt new file mode 100644 index 0000000..06936e0 --- /dev/null +++ b/backend/src/main/kotlin/com/wrbug/polymarketbot/service/copytrading/research/LeaderResearchSourceService.kt @@ -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, + val status: LeaderResearchSourceStatus, + val errorClass: String? = null, + val errorMessage: String? = null, + val limitation: String? = null +) + +private data class SourceDiscovery( + val candidates: List, + 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 +) { + 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 { + val results = mutableListOf() + 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 { + 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 { + 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 { + return candidateRepository.findByResearchStateIn( + listOf(LeaderResearchState.DISCOVERED, LeaderResearchState.CANDIDATE, LeaderResearchState.PAPER, LeaderResearchState.TRIAL_READY) + ).map { it.normalizedWallet }.distinct() + } + + private fun backfillWalletActivities(wallets: List, 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() + 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." + } +} diff --git a/backend/src/main/kotlin/com/wrbug/polymarketbot/service/copytrading/research/LeaderResearchStateMachine.kt b/backend/src/main/kotlin/com/wrbug/polymarketbot/service/copytrading/research/LeaderResearchStateMachine.kt new file mode 100644 index 0000000..0cd299b --- /dev/null +++ b/backend/src/main/kotlin/com/wrbug/polymarketbot/service/copytrading/research/LeaderResearchStateMachine.kt @@ -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 { + 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 + } +} diff --git a/backend/src/main/resources/application.properties b/backend/src/main/resources/application.properties index 891555d..37d17da 100644 --- a/backend/src/main/resources/application.properties +++ b/backend/src/main/resources/application.properties @@ -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 - diff --git a/backend/src/main/resources/db/migration/V42__create_leader_research_agent_tables.sql b/backend/src/main/resources/db/migration/V42__create_leader_research_agent_tables.sql new file mode 100644 index 0000000..eb39160 --- /dev/null +++ b/backend/src/main/resources/db/migration/V42__create_leader_research_agent_tables.sql @@ -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); diff --git a/backend/src/main/resources/i18n/messages_en.properties b/backend/src/main/resources/i18n/messages_en.properties index 112f6fd..6fde25d 100644 --- a/backend/src/main/resources/i18n/messages_en.properties +++ b/backend/src/main/resources/i18n/messages_en.properties @@ -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 diff --git a/backend/src/main/resources/i18n/messages_zh_CN.properties b/backend/src/main/resources/i18n/messages_zh_CN.properties index ec9f9e6..6e60fd1 100644 --- a/backend/src/main/resources/i18n/messages_zh_CN.properties +++ b/backend/src/main/resources/i18n/messages_zh_CN.properties @@ -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=获取市场价格失败 diff --git a/backend/src/main/resources/i18n/messages_zh_TW.properties b/backend/src/main/resources/i18n/messages_zh_TW.properties index 4e126f0..ce54dd7 100644 --- a/backend/src/main/resources/i18n/messages_zh_TW.properties +++ b/backend/src/main/resources/i18n/messages_zh_TW.properties @@ -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=獲取市場價格失敗 diff --git a/backend/src/test/kotlin/com/wrbug/polymarketbot/controller/copytrading/leaderpool/LeaderPoolControllerTest.kt b/backend/src/test/kotlin/com/wrbug/polymarketbot/controller/copytrading/leaderpool/LeaderPoolControllerTest.kt index 0ff5de1..99fe30c 100644 --- a/backend/src/test/kotlin/com/wrbug/polymarketbot/controller/copytrading/leaderpool/LeaderPoolControllerTest.kt +++ b/backend/src/test/kotlin/com/wrbug/polymarketbot/controller/copytrading/leaderpool/LeaderPoolControllerTest.kt @@ -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 ) diff --git a/backend/src/test/kotlin/com/wrbug/polymarketbot/controller/copytrading/research/LeaderResearchControllerTest.kt b/backend/src/test/kotlin/com/wrbug/polymarketbot/controller/copytrading/research/LeaderResearchControllerTest.kt new file mode 100644 index 0000000..70a8d5a --- /dev/null +++ b/backend/src/test/kotlin/com/wrbug/polymarketbot/controller/copytrading/research/LeaderResearchControllerTest.kt @@ -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 mock(): T = Mockito.mock(T::class.java) + + private fun anyApprovalRequest(): LeaderResearchApprovalRequest { + Mockito.any(LeaderResearchApprovalRequest::class.java) + return LeaderResearchApprovalRequest(candidateId = 1, accountId = 2, confirm = true) + } +} diff --git a/backend/src/test/kotlin/com/wrbug/polymarketbot/service/copytrading/monitor/PolymarketActivityWsResearchCaptureTest.kt b/backend/src/test/kotlin/com/wrbug/polymarketbot/service/copytrading/monitor/PolymarketActivityWsResearchCaptureTest.kt new file mode 100644 index 0000000..59690a3 --- /dev/null +++ b/backend/src/test/kotlin/com/wrbug/polymarketbot/service/copytrading/monitor/PolymarketActivityWsResearchCaptureTest.kt @@ -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 provider(value: T): ObjectProvider { + val provider = Mockito.mock(ObjectProvider::class.java) as ObjectProvider + Mockito.`when`(provider.getIfAvailable()).thenReturn(value) + return provider + } + + @Suppress("UNCHECKED_CAST") + private inline fun mock(): T = Mockito.mock(T::class.java) +} diff --git a/backend/src/test/kotlin/com/wrbug/polymarketbot/service/copytrading/research/LeaderActivityIngestionServiceTest.kt b/backend/src/test/kotlin/com/wrbug/polymarketbot/service/copytrading/research/LeaderActivityIngestionServiceTest.kt new file mode 100644 index 0000000..31b0b3d --- /dev/null +++ b/backend/src/test/kotlin/com/wrbug/polymarketbot/service/copytrading/research/LeaderActivityIngestionServiceTest.kt @@ -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 mock(): T = Mockito.mock(T::class.java) +} diff --git a/backend/src/test/kotlin/com/wrbug/polymarketbot/service/copytrading/research/LeaderPaperTradingServiceTest.kt b/backend/src/test/kotlin/com/wrbug/polymarketbot/service/copytrading/research/LeaderPaperTradingServiceTest.kt new file mode 100644 index 0000000..f5b9e40 --- /dev/null +++ b/backend/src/test/kotlin/com/wrbug/polymarketbot/service/copytrading/research/LeaderPaperTradingServiceTest.kt @@ -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() + val savedPositions = mutableListOf() + val savedSessions = mutableListOf() + 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() + val savedPositions = mutableListOf() + val savedSessions = mutableListOf() + 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() + val savedSessions = mutableListOf() + 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() + val savedPositions = mutableListOf() + val savedSessions = mutableListOf() + 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() + 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, + savedTrades: MutableList, + savedPositions: MutableList, + savedSessions: MutableList, + 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 { + Mockito.anyCollection() + return emptyList() + } + + private fun anyProcessingStatus(): LeaderPaperProcessingStatus { + Mockito.any(LeaderPaperProcessingStatus::class.java) + return LeaderPaperProcessingStatus.PROCESSING + } + + @Suppress("UNCHECKED_CAST") + private inline fun mock(): T = org.mockito.Mockito.mock(T::class.java) +} diff --git a/backend/src/test/kotlin/com/wrbug/polymarketbot/service/copytrading/research/LeaderResearchApprovalServiceTest.kt b/backend/src/test/kotlin/com/wrbug/polymarketbot/service/copytrading/research/LeaderResearchApprovalServiceTest.kt new file mode 100644 index 0000000..eac4ce7 --- /dev/null +++ b/backend/src/test/kotlin/com/wrbug/polymarketbot/service/copytrading/research/LeaderResearchApprovalServiceTest.kt @@ -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 { + captor.capture() + return com.wrbug.polymarketbot.dto.CopyTradingCreateRequest(accountId = 2L, leaderId = 9L) + } + + @Suppress("UNCHECKED_CAST") + private inline fun mock(): T = Mockito.mock(T::class.java) +} diff --git a/backend/src/test/kotlin/com/wrbug/polymarketbot/service/copytrading/research/LeaderResearchJobServiceTest.kt b/backend/src/test/kotlin/com/wrbug/polymarketbot/service/copytrading/research/LeaderResearchJobServiceTest.kt new file mode 100644 index 0000000..159c6ab --- /dev/null +++ b/backend/src/test/kotlin/com/wrbug/polymarketbot/service/copytrading/research/LeaderResearchJobServiceTest.kt @@ -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() + 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() + }.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 = 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 mock(): T = Mockito.mock(T::class.java) +} diff --git a/backend/src/test/kotlin/com/wrbug/polymarketbot/service/copytrading/research/LeaderResearchNotificationSummaryServiceTest.kt b/backend/src/test/kotlin/com/wrbug/polymarketbot/service/copytrading/research/LeaderResearchNotificationSummaryServiceTest.kt new file mode 100644 index 0000000..c88ee6f --- /dev/null +++ b/backend/src/test/kotlin/com/wrbug/polymarketbot/service/copytrading/research/LeaderResearchNotificationSummaryServiceTest.kt @@ -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 mock(): T = Mockito.mock(T::class.java) +} diff --git a/backend/src/test/kotlin/com/wrbug/polymarketbot/service/copytrading/research/LeaderResearchRetentionServiceTest.kt b/backend/src/test/kotlin/com/wrbug/polymarketbot/service/copytrading/research/LeaderResearchRetentionServiceTest.kt new file mode 100644 index 0000000..81fd512 --- /dev/null +++ b/backend/src/test/kotlin/com/wrbug/polymarketbot/service/copytrading/research/LeaderResearchRetentionServiceTest.kt @@ -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 mock(): T = Mockito.mock(T::class.java) +} diff --git a/backend/src/test/kotlin/com/wrbug/polymarketbot/service/copytrading/research/LeaderResearchScoringServiceTest.kt b/backend/src/test/kotlin/com/wrbug/polymarketbot/service/copytrading/research/LeaderResearchScoringServiceTest.kt new file mode 100644 index 0000000..51c0aa9 --- /dev/null +++ b/backend/src/test/kotlin/com/wrbug/polymarketbot/service/copytrading/research/LeaderResearchScoringServiceTest.kt @@ -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 mock(): T = org.mockito.Mockito.mock(T::class.java) +} diff --git a/backend/src/test/kotlin/com/wrbug/polymarketbot/service/copytrading/research/LeaderResearchSourceHealthServiceTest.kt b/backend/src/test/kotlin/com/wrbug/polymarketbot/service/copytrading/research/LeaderResearchSourceHealthServiceTest.kt new file mode 100644 index 0000000..e5be345 --- /dev/null +++ b/backend/src/test/kotlin/com/wrbug/polymarketbot/service/copytrading/research/LeaderResearchSourceHealthServiceTest.kt @@ -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 mock(): T = Mockito.mock(T::class.java) +} diff --git a/backend/src/test/kotlin/com/wrbug/polymarketbot/service/copytrading/research/LeaderResearchSourceServiceTest.kt b/backend/src/test/kotlin/com/wrbug/polymarketbot/service/copytrading/research/LeaderResearchSourceServiceTest.kt new file mode 100644 index 0000000..c442529 --- /dev/null +++ b/backend/src/test/kotlin/com/wrbug/polymarketbot/service/copytrading/research/LeaderResearchSourceServiceTest.kt @@ -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())) + } 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 { + Mockito.anyCollection() + return emptyList() + } + + private fun anyStringList(): List { + Mockito.anyList() + return emptyList() + } + + @Suppress("UNCHECKED_CAST") + private inline fun mock(): T = Mockito.mock(T::class.java) +} diff --git a/backend/src/test/kotlin/com/wrbug/polymarketbot/service/copytrading/research/LeaderResearchStateMachineTest.kt b/backend/src/test/kotlin/com/wrbug/polymarketbot/service/copytrading/research/LeaderResearchStateMachineTest.kt new file mode 100644 index 0000000..dfd7a14 --- /dev/null +++ b/backend/src/test/kotlin/com/wrbug/polymarketbot/service/copytrading/research/LeaderResearchStateMachineTest.kt @@ -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 mock(): T = Mockito.mock(T::class.java) +} diff --git a/docs/zh/leader-research-agent.md b/docs/zh/leader-research-agent.md new file mode 100644 index 0000000..75c8cf2 --- /dev/null +++ b/docs/zh/leader-research-agent.md @@ -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 计入收益。 diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index ee5fb61..361cfe2 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -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() { } /> } /> } /> + } /> } /> } /> } /> diff --git a/frontend/src/components/Layout.tsx b/frontend/src/components/Layout.tsx index 4ff8664..dedbf89 100644 --- a/frontend/src/components/Layout.tsx +++ b/frontend/src/components/Layout.tsx @@ -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 = ({ 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 = ({ 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 = ({ children }) => { icon: , label: t('menu.leaderPool') }, + { + key: '/leader-research', + icon: , + label: t('menu.leaderResearch') + }, { key: '/leaders', icon: , diff --git a/frontend/src/locales/en/common.json b/frontend/src/locales/en/common.json index 14a5ec1..749a875 100644 --- a/frontend/src/locales/en/common.json +++ b/frontend/src/locales/en/common.json @@ -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", diff --git a/frontend/src/locales/zh-CN/common.json b/frontend/src/locales/zh-CN/common.json index d9b7ba3..7c036e6 100644 --- a/frontend/src/locales/zh-CN/common.json +++ b/frontend/src/locales/zh-CN/common.json @@ -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", diff --git a/frontend/src/locales/zh-TW/common.json b/frontend/src/locales/zh-TW/common.json index 4f05a3b..13bcd76 100644 --- a/frontend/src/locales/zh-TW/common.json +++ b/frontend/src/locales/zh-TW/common.json @@ -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", diff --git a/frontend/src/pages/LeaderPool.tsx b/frontend/src/pages/LeaderPool.tsx index 9d7b130..a5dd020 100644 --- a/frontend/src/pages/LeaderPool.tsx +++ b/frontend/src/pages/LeaderPool.tsx @@ -269,6 +269,17 @@ const LeaderPool: React.FC = () => { {item.leaderAddress} + {item.researchBadge && ( + + {t(`leaderResearch.states.${item.researchState}`, { defaultValue: item.researchState })} + {item.researchScore && {t('leaderResearch.score')}: {item.researchScore}} + + )} + {item.researchCandidateId && ( + + )} ) }, diff --git a/frontend/src/pages/LeaderResearch.tsx b/frontend/src/pages/LeaderResearch.tsx new file mode 100644 index 0000000..6e70a58 --- /dev/null +++ b/frontend/src/pages/LeaderResearch.tsx @@ -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 = { + DISCOVERED: 'default', + CANDIDATE: 'blue', + PAPER: 'geekblue', + TRIAL_READY: 'green', + COOLDOWN: 'orange', + RETIRED: 'red' +} + +const VALUATION_COLORS: Record = { + 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 - + return {status} +} + +const LeaderResearch: React.FC = () => { + const { t } = useTranslation() + const [summary, setSummary] = useState(null) + const [candidates, setCandidates] = useState({ list: [], total: 0, summary: summaryFallback }) + const [sourceHealth, setSourceHealth] = useState([]) + const [accounts, setAccounts] = useState([]) + const [stateFilter, setStateFilter] = useState() + const [query, setQuery] = useState('') + const [loading, setLoading] = useState(false) + const [running, setRunning] = useState(false) + const [detailLoading, setDetailLoading] = useState(false) + const [detail, setDetail] = useState(null) + const [approvalCandidate, setApprovalCandidate] = useState(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) => ( + + {item.leaderName || item.normalizedWallet.slice(0, 10)} + + {item.normalizedWallet} + + + ) + }, + { + title: t('common.status'), + dataIndex: 'researchState', + width: 130, + render: (state: LeaderResearchState) => ( + + {t(`leaderResearch.states.${state}`, { defaultValue: state })} + {state === 'TRIAL_READY' && ( + {t('leaderResearch.trialReadyHint')} + )} + + ) + }, + { + title: t('leaderResearch.score'), + dataIndex: 'score', + width: 100, + render: (score?: string) => {score || '-'} + }, + { + title: t('leaderResearch.paper'), + key: 'paper', + width: 220, + render: (_: unknown, item: LeaderResearchCandidate) => { + const session = item.latestPaperSession + if (!session) return - + return ( + + {t('leaderResearch.copyablePnl')}: {session.copyablePnl} + {t('leaderResearch.trades')}: {session.tradeCount} / {t('leaderResearch.filtered')}: {session.filteredCount} + + ) + } + }, + { + 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) => ( + + + + + ) + } + ] + + return ( + + + + +
+ {t('leaderResearch.title')} + {t('leaderResearch.subtitle')} +
+ + + + +
+ } + message={t('leaderResearch.safetyTitle')} + description={t('leaderResearch.safetyDesc')} + /> + {activeSummary.sourceLimitations?.length > 0 && ( + + )} +
+
+ + + + + + + + + + + + + + {lastRun ? ( + + + {lastRun.status} + + {formatDate(lastRun.startedAt)} + {lastRun.durationMs ?? '-'} ms + {lastRun.sourceCountsJson || '-'} + {lastRun.candidateCountsJson || '-'} + {(lastRun.errorMessage || lastRun.skippedReason) && ( + {lastRun.errorMessage || lastRun.skippedReason} + )} + + ) : ( + + )} + + + + + {pendingDecisions.length > 0 ? ( + + {pendingDecisions.slice(0, 5).map(candidate => ( + + + + {candidate.leaderName || candidate.normalizedWallet.slice(0, 10)} + {t('leaderResearch.trialReadyHint')} + + + + + ))} + + ) : ( + + )} + + + + + + + + ({ + value: account.id, + label: `${account.accountName || account.walletAddress} (${account.proxyAddress?.slice(0, 8)}...)` + }))} + /> + + + + + + ) +} + +const PaperTradeTable: React.FC<{ trades: LeaderPaperTrade[] }> = ({ trades }) => ( + 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 }) => ( +
+) + +const summaryFallback: LeaderResearchSummary = { + discoveredCount: 0, + candidateCount: 0, + paperCount: 0, + trialReadyCount: 0, + cooldownCount: 0, + retiredCount: 0, + activePaperSessions: 0, + pendingRiskCount: 0, + sourceLimitations: [] +} + +export default LeaderResearch diff --git a/frontend/src/services/api.ts b/frontend/src/services/api.ts index d98389c..c2de341 100644 --- a/frontend/src/services/api.ts +++ b/frontend/src/services/api.ts @@ -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>('/copy-trading/leader-pool/remove', data) }, + + leaderResearch: { + run: (data: LeaderResearchRunRequest = {}) => + apiClient.post>('/copy-trading/leader-research/run', data), + + summary: () => + apiClient.post>('/copy-trading/leader-research/summary', {}), + + listCandidates: (data: LeaderResearchCandidateListRequest = {}) => + apiClient.post>('/copy-trading/leader-research/candidates/list', data), + + detail: (data: { candidateId: number }) => + apiClient.post>('/copy-trading/leader-research/candidates/detail', data), + + sourceHealth: () => + apiClient.post>('/copy-trading/leader-research/source-health', {}), + + events: (data: { page?: number; size?: number } = {}) => + apiClient.post>('/copy-trading/leader-research/events/list', data), + + createDisabledTrialConfig: (data: LeaderResearchApprovalRequest) => + apiClient.post>('/copy-trading/leader-research/approval/create-disabled-trial-config', data) + }, /** * 跟单模板管理 API(子菜单:跟单模板) diff --git a/frontend/src/types/index.ts b/frontend/src/types/index.ts index af89d13..4f936b3 100644 --- a/frontend/src/types/index.ts +++ b/frontend/src/types/index.ts @@ -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 +} + /** * 跟单创建请求 * 所有配置参数都需要手动输入,模板仅用于前端快速填充表单 diff --git a/openspec/changes/add-leader-research-agent/.openspec.yaml b/openspec/changes/add-leader-research-agent/.openspec.yaml new file mode 100644 index 0000000..905325f --- /dev/null +++ b/openspec/changes/add-leader-research-agent/.openspec.yaml @@ -0,0 +1,2 @@ +schema: spec-driven +created: 2026-05-04 diff --git a/openspec/changes/add-leader-research-agent/design.md b/openspec/changes/add-leader-research-agent/design.md new file mode 100644 index 0000000..b5b4f19 --- /dev/null +++ b/openspec/changes/add-leader-research-agent/design.md @@ -0,0 +1,282 @@ +## Context + +PolyHermes 当前已有三层跟单工作流: + +- `Leader 管理` 保存可被跟单的钱包地址,对应 `copy_trading_leaders`。 +- `Leader 池` 管理人工候选、观察、小额试跟、冷却、淘汰,对应 `copy_trading_leader_pool`。 +- `跟单配置` 管理真实账户、leader 和风控参数,对应 `copy_trading`。 + +这个结构已经把“地址库”和“真钱跟单”拆开,但仍需要用户自己去找 leader、判断是否值得观察、跟踪表现、决定是否小额试跟。用户的目标不是手动维护池子,而是让系统自动发现优秀且可复制的 leader,先纸跟验证,再由用户授权真钱动作。 + +本设计把新增能力定位为 Leader Research Agent。它是研究代理,不是自动交易代理。它可以自动发现、自动纸跟、自动建议、自动冷却;它不能自动启用真钱跟单。 + +## Goals / Non-Goals + +**Goals:** + +- 提供多源候选发现,第一阶段支持 watchlist、已有 Leader 管理记录和 activity-derived candidates。 +- 为研究运行、候选、分数、事件、纸跟 session、纸跟交易、纸跟持仓建立独立数据模型。 +- 持久化原始或规范化 activity event,使纸跟可以基于可重放事件运行,而不是复用真实订单表。 +- 用透明规则计算 copyability score,并保存分项分数和 score version。 +- 自动推进研究状态,但研究状态必须与 Leader 池状态分离。 +- 在不锁定的情况下,自动化只能保守地增强 Leader 池记录,不能自动改成真实 `TRIAL` 或 `ACTIVE`。 +- 提供操作台展示运行状态、候选详情、纸跟表现、待处理决策和源健康。 +- 提供手动审批动作,创建保守的 `enabled=false` 真实跟单配置。 +- 为源失败、quote 不可用、valuation unknown、重复事件、锁定候选、非法真钱启用等关键路径提供可见错误和测试。 + +**Non-Goals:** + +- 不自动启用真钱跟单。 +- 不自动加仓、不自动增加 fixed amount、不自动放宽风控参数。 +- 不做黑盒 AI/ML 评分。 +- 不在第一阶段依赖公开 leaderboard 端点。 +- 不复用 `copy_order_tracking` 作为纸跟账本。 +- 不把 unknown valuation 当成 confirmed zero。 +- 不替换现有 Leader 池、Leader 管理、跟单配置和真实 PnL 统计。 + +## Decisions + +### 1. 研究状态独立于 Leader 池状态 + +研究状态回答“系统证明了什么”,Leader 池状态回答“用户当前怎么处理这个 leader”。两者不能合并。 + +研究状态: + +```text +DISCOVERED -> CANDIDATE -> PAPER -> TRIAL_READY + \-> COOLDOWN -> RETIRED +``` + +Leader 池状态: + +```text +CANDIDATE -> WATCH -> TRIAL -> ACTIVE + \-> COOLDOWN -> RETIRED +``` + +映射规则: + +```text +research DISCOVERED + -> 可以只存在于 leader_research_candidate,不强制创建 Leader 池项 + +research CANDIDATE + -> 展示给用户时可创建或更新 Leader 池 CANDIDATE + +research PAPER + -> Leader 池保持 WATCH,或后续 UI 暴露 PAPER + +research TRIAL_READY + -> Leader 池保持 WATCH,并展示“建议小额试跟” badge + -> 不能自动变成 Leader 池 TRIAL + +research COOLDOWN + -> 未锁定时可以同步 Leader 池 COOLDOWN + +research RETIRED + -> 只有 agent 拥有且未锁定的候选,才可同步 Leader 池 RETIRED +``` + +`TRIAL_READY` 只是建议。只有用户创建了真实禁用试跟配置后,Leader 池才可以进入 `TRIAL`。 + +### 2. 纸跟必须独立建账 + +`enabled=false` 的真实跟单配置不会产生模拟成交、模拟持仓、过滤原因、quote confidence 和纸跟 PnL。把它伪装成纸跟会让后续晋升建议没有证据链。 + +新增建议表: + +```text +leader_paper_session +leader_paper_trade +leader_paper_position +``` + +每笔纸跟交易至少记录: + +```text +leader_trade_id +leader_price +leader_size +simulated_price +simulated_size +simulated_amount +fill_assumption: LEADER_PRICE | BEST_ASK_AT_EVENT | MID_PRICE | UNKNOWN +quote_confidence: HIGH | MEDIUM | LOW | UNKNOWN +quote_source +quote_timestamp +filter_result: PASSED | FILTERED +filter_reason +valuation_status: AVAILABLE | NO_MATCH | UNAVAILABLE | CONFIRMED_ZERO +``` + +`UNAVAILABLE` 和 `UNKNOWN` 不得计为真实归零。这个约束必须沿用到纸跟 PnL 和前端展示。 + +### 3. 第一阶段先做可重放事件和内部来源 + +第一阶段候选来源: + +```text +watchlist +已有 Leader 管理记录 +已持久化 activity event 中可归因的钱包 +``` + +公开 leaderboard 保留为后续来源,不作为第一阶段 apply-ready 的依赖。原因是 leaderboard 端点可能不稳定或未文档化,不能让第一版核心闭环被外部端点卡住。 + +如果当前 app 没有持久化 raw activity event,需要新增 append-only 表: + +```text +leader_activity_event +``` + +推荐唯一键: + +```text +source + event_id +``` + +如果 event_id 缺失,必须生成稳定 fallback key,避免重启或重放时重复纸跟。 + +### 4. 评分必须可解释且可版本化 + +不要只保存一个总分。保存分项分数、总分、score version 和 reason。 + +分项: + +```text +profit_signal +repeatability +liquidity_fit +entry_price_fit +slippage_risk +holding_period_fit +market_type_risk +drawdown_risk +exit_liquidity_risk +data_freshness +filter_pass_rate +``` + +研究状态迁移必须记录 rule version,避免以后调整阈值后无法解释旧决策。 + +默认阈值: + +```text +DISCOVERED -> CANDIDATE + 条件:候选来自可信来源,地址格式有效 + +CANDIDATE -> PAPER + 条件:score >= 60,未锁定,未退休,source 48 小时内新鲜 + +PAPER -> TRIAL_READY + 条件: + 纸跟时间 >= 7 天 + 模拟交易数 >= 10 + copyable PnL > 0 + 最大回撤 >= -15% + UNKNOWN quote 暴露占比 <= 20% + filtered-trade ratio < 50% + 无 critical risk flag + +PAPER -> COOLDOWN + 条件: + 最大回撤 < -20% + 或 10 笔后 copyable PnL < -5 + 或 quote/source stale > 72 小时 + 或存在 thin_liquidity_exit_risk + +COOLDOWN -> CANDIDATE + 条件:cooldown_until 已过,source 重新新鲜,未锁定 + +COOLDOWN -> RETIRED + 条件:3 次冷却周期,或 30 天无新鲜来源 +``` + +这些阈值是第一版保守默认值,不是金融真理;后续可配置。 + +### 5. 后端强制真钱边界 + +研究代理可以推荐小额试跟,但不能启用真钱。 + +手动审批流: + +```text +用户打开候选详情 + -> 点击“创建禁用试跟配置” + -> 前端展示 fixed amount、max daily loss、max daily orders、price range、max position value + -> 用户确认 + -> 后端组装 CopyTradingCreateRequest + -> 后端强制 enabled=false + -> 返回 created copyTrading + -> 用户必须去 跟单配置 页面手动启用 +``` + +后端必须忽略或拒绝任何客户端传入的 `enabled=true`、更大 fixed amount 或更松风控参数。安全边界在后端,不在弹窗。 + +### 6. 操作台先做“晨报式”决策界面 + +第一版页面不做复杂量化终端。优先回答: + +```text +今天有什么变化? +哪些候选需要我决策? +为什么推荐? +证据是什么? +风险是什么? +我能安全点击什么? +``` + +页面模块: + +```text +Run Status +Candidate Detail +Paper Sessions +Pending Decisions +Source Health +``` + +### 7. 调度必须有 overlap guard 和 run record + +新增 `LeaderResearchJobService` 使用现有 Spring `@Scheduled` 模式,但默认关闭或可配置开启。 + +每次运行必须创建 `leader_research_run`,记录: + +```text +status +started_at +finished_at +duration +source counts +candidate counts +error class +error message +partial failure flag +``` + +如果上一次运行还未结束,新运行 MUST 跳过并记录 overlap 事件,不能并发推进状态。 + +## Risks / Trade-offs + +- [纸跟精度看起来比实际更高] -> 每笔纸跟交易必须保存 fill assumption、quote confidence 和 valuation status;UI 必须展示 unknown/partial 状态。 +- [source 失败导致错误降级候选] -> source failure 只更新 source health,不得静默删除候选或降低 score。 +- [自动状态机误伤人工决策] -> `locked=true` 的候选和池子项不得被自动化改变状态、建议配置或 trial recommendation。 +- [通知系统 Telegram-first] -> 第一阶段先落 `leader_research_event` 和控制台提示,外部通知渠道在事件流稳定后再复用/扩展通知配置。 +- [表数量增加导致复杂度上升] -> 用里程碑拆分:先数据 spine,再纸跟,再评分状态机,再 UI/通知,再手动审批。 +- [数据量增长影响性能] -> 为 event、candidate、paper trade、paper position、run、event 表添加唯一键和查询索引,并对详情列表分页。 + +## Migration Plan + +1. 新增研究与纸跟相关 Flyway 迁移,所有表为空表创建,不修改现有真实订单热表。 +2. 部署后端,但 research job 默认关闭。 +3. 部署前端操作台,若 job 未启用则展示禁用/空态。 +4. 在本地或测试环境手动运行 research job,验证 run record、source health、candidate、paper session 和 paper trade。 +5. 开启 watchlist + existing leaders 来源,不启用 public leaderboard。 +6. 确认内部 research events 稳定后再接外部通知渠道。 +7. 最后启用手动“创建禁用试跟配置”入口。 + +Rollback: + +- 如果 job 出错,关闭 research job 配置并保留数据表。 +- 如果 UI 出错,隐藏菜单或回滚前端路由。 +- 如果迁移失败,在启用 job 前停止部署;不要在热回滚中删除已有研究数据。 diff --git a/openspec/changes/add-leader-research-agent/proposal.md b/openspec/changes/add-leader-research-agent/proposal.md new file mode 100644 index 0000000..db53996 --- /dev/null +++ b/openspec/changes/add-leader-research-agent/proposal.md @@ -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`,并通过测试证明研究代理没有任何自动启用真钱的路径。 diff --git a/openspec/changes/add-leader-research-agent/specs/leader-research-agent/spec.md b/openspec/changes/add-leader-research-agent/specs/leader-research-agent/spec.md new file mode 100644 index 0000000..00451ca --- /dev/null +++ b/openspec/changes/add-leader-research-agent/specs/leader-research-agent/spec.md @@ -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 保留已有研究数据和页面查询能力 diff --git a/openspec/changes/add-leader-research-agent/tasks.md b/openspec/changes/add-leader-research-agent/tasks.md new file mode 100644 index 0000000..085be68 --- /dev/null +++ b/openspec/changes/add-leader-research-agent/tasks.md @@ -0,0 +1,208 @@ +## 1. 后端数据模型与迁移 + +- [x] 1.1 新增 Flyway 迁移,创建 `leader_research_run` 表,记录运行状态、时间、耗时、来源统计、候选统计、错误类型、错误信息和 partial failure 标记。 +- [x] 1.2 新增 Flyway 迁移,创建 `leader_research_candidate` 表,记录钱包地址、关联 leaderId、研究状态、来源、score、reason、risk flags、锁定字段和时间戳。 +- [x] 1.3 新增 Flyway 迁移,创建 `leader_research_score` 表,记录 score version、总分和所有 copyability 分项。 +- [x] 1.4 新增 Flyway 迁移,创建 `leader_research_event` 表,记录研究事件类型、candidateId、runId、原因、payload 摘要、通知状态和时间戳。 +- [x] 1.5 新增 Flyway 迁移,创建 `leader_research_source_state` 表或等价字段,记录每个来源的最近成功、最近失败、错误类型、错误信息和 stale 状态。 +- [x] 1.6 新增 Flyway 迁移,创建 `leader_activity_event` append-only 表,若当前 app 未持久化 raw activity event,则用于纸跟重放和去重。 +- [x] 1.7 新增 Flyway 迁移,创建 `leader_paper_session` 表,记录 candidateId、状态、开始/结束时间、统计摘要和 PnL 摘要。 +- [x] 1.8 新增 Flyway 迁移,创建 `leader_paper_trade` 表,记录 leader trade、模拟成交、filter reason、fill assumption、quote confidence 和 valuation status。 +- [x] 1.9 新增 Flyway 迁移,创建 `leader_paper_position` 表,记录 session、market、outcome、数量、成本、当前估值、realized/unrealized PnL 和 valuation status。 +- [x] 1.10 为 activity event、candidate、paper trade、paper position、run、event 和 source state 添加唯一键与查询索引,覆盖去重、分页、状态筛选和候选详情查询。 +- [x] 1.11 新增 JPA entity 与 repository,覆盖所有 research、activity、paper 相关表。 +- [x] 1.12 新增 research 状态、paper session 状态、valuation status、quote confidence、fill assumption、event type 和 source type 枚举。 +- [x] 1.13 `leader_activity_event` 必须包含 `normalized_wallet`、`source_event_id`、`stable_event_key`、`event_time`、`raw_payload_hash`、`payload_summary`、`usable_for_discovery`、`unusable_reason`、`paper_processing_status`、`processing_attempts`、`paper_processing_started_at`、`paper_processed_at` 和 `last_processing_error`。 +- [x] 1.14 `leader_research_candidate` 必须记录 agent ownership/provenance,区分 agent 创建候选、用户已有 leader、用户锁定候选和人工池子项,避免自动退休或覆盖用户资产。 +- [x] 1.15 `leader_research_run` 必须支持单实例运行锁或等价约束,保证同一时间最多一个 research run 推进候选状态。 +- [x] 1.16 为 `leader_activity_event(paper_processing_status, event_time)`、`leader_activity_event(normalized_wallet, event_time)`、`leader_paper_trade(session_id, leader_trade_id)`、`leader_research_candidate(research_state, last_source_seen_at)` 添加组合索引。 +- [x] 1.17 为 paper trade 增加数据库唯一约束,至少覆盖 `session_id + leader_trade_id + side`,用数据库兜底防止重复模拟成交。 +- [x] 1.18 所有新增表必须有可回滚的空表迁移策略;迁移不得修改现有真实订单热表语义。 + +## 2. Activity 事件持久化 + +- [x] 2.1 新增 `LeaderActivityIngestionService`,负责 research 专用 activity event 标准化、去重、持久化和不可用原因记录。 +- [x] 2.2 保持现有 `PolymarketActivityWsService` 的真实跟单快路径不被 research 拖慢;不得依赖它当前的“已监听 leader 地址预过滤”来发现未知 leader。 +- [x] 2.3 新增 bounded Data API backfill 来源,按 watchlist、已有 Leader 管理记录和 active research candidates 拉取 `/activity`,用于补齐历史 paper event。 +- [x] 2.4 新增 research global activity capture 扩展点,解析全局 WS activity 时必须在地址过滤前标准化事件;该能力默认关闭,并受配置开关、每分钟写入上限、payload 截断和保留策略保护。 +- [x] 2.5 如果 global capture 未启用,activity-derived source 必须在 source health 中明确显示 disabled/degraded,不得假装已经自动发现未知 leader。 +- [x] 2.6 为 activity event 实现 source + eventId 幂等保存;缺失 eventId 时用 wallet、conditionId、asset、side、price、size、timestamp、transactionHash 的稳定 hash 生成 fallback key。 +- [x] 2.7 对缺少钱包地址、市场、方向、价格或数量的 event 仍写入摘要,并标记不可用于 discovery/paper trading 的原因。 +- [x] 2.8 确保 activity event 持久化失败不会阻断现有真实跟单处理链路;失败只写 research run/source error。 +- [x] 2.9 新增 activity source health 更新,区分 Data API success empty、Data API failure、WS capture disabled、WS parse failure、write capped 和 stale。 +- [x] 2.10 新增 activity event repository 查询方法,支持按钱包、时间窗口、未处理状态、来源、可发现状态和 stable key 查询。 +- [x] 2.11 新增 activity ingestion 测试,覆盖正常保存、重复事件、缺失字段、fallback key、持久化失败隔离、write cap、WS capture disabled 和 Data API success empty。 +- [x] 2.12 新增回归测试,证明 current copy-trading WS 的已知 leader 过滤不会阻断 research ingestion 对未知钱包的发现路径。 + +## 3. 候选来源与研究运行 + +- [x] 3.1 新增 `LeaderResearchJobService`,支持手动 runOnce 和定时运行,默认通过配置关闭自动调度。 +- [x] 3.2 为研究任务实现 overlap guard,当前一 run 未结束时跳过或拒绝新 run,并写入 research event。 +- [x] 3.3 新增 `LeaderResearchSourceService`,统一 watchlist、已有 Leader 管理记录和 activity-derived candidates;每个 source 返回 typed result,包含 candidates、source status、error class、error message 和 freshness。 +- [x] 3.4 新增 watchlist 配置读取路径,支持配置钱包地址、备注和来源标签。 +- [x] 3.5 实现 existing leaders 来源,从 `copy_trading_leaders` 创建或更新研究候选。 +- [x] 3.6 实现 activity-derived 来源,从 `leader_activity_event` 中发现 fresh、usable、可归因钱包;如果 global capture 未启用,只能基于已持久化事件工作,并必须展示 source limitation。 +- [x] 3.7 候选创建必须复用 `LeaderRepository` 的唯一地址语义;新 leader address 必须标准化小写并验证 42 位 EVM 地址格式。 +- [x] 3.8 每个来源运行后更新 source state,区分 success with zero candidates、failure、stale、disabled、degraded 和 partial failure。 +- [x] 3.9 公开 leaderboard 来源只保留接口/扩展点,不作为第一阶段启用来源;不得在实现中偷偷依赖未确认 endpoint。 +- [x] 3.10 候选合并必须保留所有来源 evidence,不得因为同一钱包重复出现而覆盖更早来源、用户备注或锁定状态。 +- [x] 3.11 新增 run-level dry-run 或 preview 能力,允许查看本次将新增/更新/冷却多少候选而不推进状态。 +- [x] 3.12 新增候选来源测试,覆盖空结果、失败结果、disabled source、重复候选、无效地址、已有 leader 复用和用户锁定保护。 +- [x] 3.13 新增研究运行测试,覆盖成功、partial failure、overlap guard、run record 完整性、source limitation 和 preview 不产生状态副作用。 +- [x] 3.14 新增生产失败场景测试:某个 source 超时或抛异常时,其他 source 仍能产出候选,且不会删除或降级已有候选。 + +## 4. 纸跟账本与模拟成交 + +- [x] 4.1 新增 `LeaderPaperTradingService`,根据候选和 activity event 启动或更新 paper session。 +- [x] 4.2 `LeaderPaperTradingService` 必须按 batch claim `leader_activity_event`,通过状态从 `NEW`/`RETRYABLE` 原子更新到 `PROCESSING` 后再处理,避免并发 run 重复模拟成交。 +- [x] 4.3 对进入 `PAPER` 状态的候选,处理 BUY event 并记录 paper trade 与 paper position。 +- [x] 4.4 对进入 `PAPER` 状态的候选,处理 SELL event 并按持仓匹配规则更新 paper position 和 realized PnL。 +- [x] 4.5 将现有 `CopyTradingFilterService` 抽出或复用为 paper-safe evaluator;paper 场景不得调用真实账户持仓检查来决定模拟持仓,必须使用 paper position provider。 +- [x] 4.6 为 research 定义保守 paper config,包含 fixed amount、max daily loss、max daily orders、min/max price、max position value、support sell 和 market end guard。 +- [x] 4.7 每笔 paper trade 保存 leader price、leader size、simulated price、simulated size、simulated amount、fill assumption、quote confidence、quote source、quote timestamp、filter result 和 filter reason。 +- [x] 4.8 对 quote/valuation 不可用的持仓标记 `UNAVAILABLE` 或 `UNKNOWN`,并确保不计为 confirmed zero。 +- [x] 4.9 `copyable PnL` 必须区分 realized PnL、available unrealized PnL、unknown valuation exposure 和 confirmed zero exposure;unknown/unavailable 不得伪装成亏到 0。 +- [x] 4.10 为 paper trade 实现 leaderTradeId 幂等去重,重复 activity event 不得重复产生纸跟。 +- [x] 4.11 处理失败必须增加 `processing_attempts`、记录 `last_processing_error`,超过阈值进入 `FAILED` 并写 research event,不得无限重试卡住整轮 run。 +- [x] 4.12 新增 paper session 汇总计算,包含交易数、过滤数、open exposure、realized/unrealized/copyable PnL、max drawdown、unknown valuation exposure、confirmed zero exposure 和 filtered ratio。 +- [x] 4.13 新增纸跟服务测试,覆盖 BUY、SELL、过滤、重复 event、unknown valuation、confirmed zero、PnL 计算、max drawdown、event claim 并发、失败重试和 FAILED 隔离。 + +## 5. Copyability 评分与研究状态机 + +- [x] 5.1 新增 `LeaderResearchScoringService`,计算并保存所有 copyability 分项和总分。 +- [x] 5.2 定义 `score_version = research-copyability-v1`,写明每个分项的 0-100 取值范围、权重、缺数据策略和总分计算公式。 +- [x] 5.3 v1 默认权重建议:profit signal 20、repeatability 15、liquidity fit 10、entry price fit 10、slippage risk 10、drawdown risk 10、holding period fit 5、market type risk 5、exit liquidity risk 5、data freshness 5、filter pass rate 5。 +- [x] 5.4 实现收益信号封顶逻辑,避免单次大赢主导评分;样本量不足时总分必须被 cap,且不能进入 `TRIAL_READY`。 +- [x] 5.5 实现 repeatability、liquidity fit、entry price fit、slippage risk、holding period fit、market type risk、drawdown risk、exit liquidity risk、data freshness 和 filter pass rate 分项。 +- [x] 5.6 对 quote unknown、valuation unavailable、source stale、filtered ratio 过高和 critical risk flag 定义明确扣分或晋升阻断规则。 +- [x] 5.7 新增 `LeaderResearchStateMachine`,实现 `DISCOVERED -> CANDIDATE -> PAPER -> TRIAL_READY -> COOLDOWN/RETIRED` 迁移。 +- [x] 5.8 状态迁移必须在事务中保存 rule version、oldState、newState、trigger reason、runId 和 research event。 +- [x] 5.9 实现 `CANDIDATE -> PAPER` 默认阈值:score >= 60、未锁定、未退休、source 48 小时内新鲜。 +- [x] 5.10 实现 `PAPER -> TRIAL_READY` 默认阈值:纸跟 >= 7 天、交易 >= 10、copyable PnL > 0、最大回撤不低于 -15%、UNKNOWN quote 暴露 <= 20%、过滤比例 < 50%、无 critical risk flag。 +- [x] 5.11 实现 `PAPER -> COOLDOWN` 默认阈值:最大回撤 < -20%、10 笔后 copyable PnL < -5、quote/source stale > 72 小时或 thin liquidity exit risk。 +- [x] 5.12 实现 cooldown 恢复和退休规则。 +- [x] 5.13 实现 locked 候选保护,自动任务不得改变锁定候选的状态、试跟建议、建议配置或冷却/退休状态。 +- [x] 5.14 新增评分和状态机测试,覆盖全部迁移、边界阈值、锁定保护、source stale、rule version、缺数据 cap、单次大赢 cap 和 critical risk 晋升阻断。 + +## 6. Leader 池联动与真钱安全边界 + +- [x] 6.1 新增 research 与 `copy_trading_leader_pool` 的映射服务,确保研究状态与 Leader 池状态分离。 +- [x] 6.2 当研究候选进入 `CANDIDATE` 或更高状态且需要展示时,保守创建或更新 Leader 池项。 +- [x] 6.3 `TRIAL_READY` 只能在 Leader 池展示“建议小额试跟” badge,不得自动把 Leader 池状态改为 `TRIAL` 或 `ACTIVE`。 +- [x] 6.4 自动任务不得增加 existing pool 的 suggested fixed amount、suggested max daily loss 或 suggested max daily orders。 +- [x] 6.5 自动任务不得覆盖用户手工 notes;研究摘要必须写入独立字段或 append-only event。 +- [x] 6.6 新增 research 专用手动审批服务,从 `TRIAL_READY` 候选创建禁用试跟配置;不得原样复用当前 `LeaderPoolService.createTrialConfig`,除非先重构出 disabled-only 安全 helper。 +- [x] 6.7 research approval request DTO 不允许暴露 `enabled`、`enableImmediately` 或放宽风控字段;后端组装 `CopyTradingCreateRequest` 时必须强制 `enabled=false`。 +- [x] 6.8 如果客户端篡改提交 `enabled=true`、更大 fixed amount、更高 max daily loss、更多 max daily orders 或更宽 price range,后端必须拒绝或忽略,并写入 safety research event。 +- [x] 6.9 创建禁用试跟配置前检查同账户同 leader 是否已有跟单配置,默认拒绝重复创建;双击提交必须只创建一条配置。 +- [x] 6.10 用户明确创建 disabled trial config 成功后,Leader 池才可以从 WATCH/建议状态进入 `TRIAL`;仅 `TRIAL_READY` recommendation 不得进入 `TRIAL`。 +- [x] 6.11 创建禁用试跟配置成功后写入 research event,并提供跳转 `跟单配置` 的信息。 +- [x] 6.12 新增安全测试,覆盖客户端篡改 `enabled=true`、高 fixed amount、放宽风控、locked 候选、重复配置、双击创建、TRIAL_READY 不自动 TRIAL 和 disabled config 创建后 copy_trading.enabled 仍为 false。 + +## 7. 后端 API 与错误处理 + +- [x] 7.1 新增 `LeaderResearchController`,提供研究运行、候选列表、候选详情、paper session、source health、research events 和手动创建禁用试跟配置 API。 +- [x] 7.2 所有 research API 必须走现有受保护路由与鉴权边界。 +- [x] 7.3 新增 research DTO,覆盖 run summary、candidate summary/detail、score components、paper session、paper trade、paper position、source health、event 和 approval response。 +- [x] 7.4 新增 research 错误码和中英繁 i18n 文案,覆盖 run overlap、candidate not found、candidate locked、not trial ready、source unavailable、paper valuation unavailable、real money activation forbidden。 +- [x] 7.5 服务内部使用命名错误或明确 result 类型,不得只用 catch-all Exception 表达业务失败。 +- [x] 7.6 所有源失败、解析失败、状态迁移失败、纸跟失败和审批失败必须写入 research event 或 run error。 +- [x] 7.7 新增 controller 测试,覆盖所有 API 的成功路径、参数错误、权限/保护边界、业务错误和真钱启用拒绝。 +- [x] 7.8 手动 run 和创建禁用试跟配置 API 必须有重复提交保护;重复请求返回已有 run/config 或明确拒绝,不得产生重复副作用。 +- [x] 7.9 候选详情 API 必须分页返回 paper trades 和 research events,不得一次性返回完整历史。 +- [x] 7.10 API 错误响应必须让前端能区分 `disabled source`、`degraded source`、`valuation unknown`、`candidate locked` 和 `real money activation forbidden`。 + +## 8. 前端类型、API 与操作台 + +- [x] 8.1 在 `frontend/src/types` 中新增 Leader Research 类型,覆盖状态、source health、score components、paper session/trade/position、events 和 approval request/response。 +- [x] 8.2 在 `frontend/src/services/api.ts` 新增 `leaderResearch` API 分组。 +- [x] 8.3 新增 `LeaderResearch` 页面和受保护路由。 +- [x] 8.4 在左侧菜单合适位置新增 `Leader Research` 或 `Leader 雷达` 入口,避免和现有 `Leader 池` 混淆。 +- [x] 8.5 操作台顶部展示 Today summary:trial-ready 数、新候选数、新纸跟数、冷却数、source stale 数。 +- [x] 8.6 实现 Run Status 模块,展示最近运行、耗时、状态、来源统计和失败信息。 +- [x] 8.7 实现 Pending Decisions 模块,展示 `TRIAL_READY` 候选和手动创建禁用试跟配置入口。 +- [x] 8.8 实现 Candidate Detail,展示来源历史、score components、paper session summary、risk flags、research events 和 Leader 池映射状态。 +- [x] 8.9 实现 Paper Sessions/Trades 展示,明确展示 valuation status、quote confidence 和 filtered reason。 +- [x] 8.10 实现 Source Health 模块,区分 success empty、failure、stale 和 degraded。 +- [x] 8.11 创建禁用试跟配置前,UI 必须展示 fixed amount、max daily loss、max daily orders、price range 和 max position value,并要求确认。 +- [x] 8.12 UI 不得把 `TRIAL_READY` 文案表现成已经真实试跟;必须明确“建议小额试跟,待你确认”。 +- [x] 8.13 新增 zh-CN、zh-TW、en 文案。 +- [x] 8.14 确保移动端操作台至少可查看 summary、pending decisions 和 candidate detail,不出现不可操作表格。 +- [x] 8.15 UI 必须展示 activity source limitation:global capture disabled、Data API backfill stale、source degraded 等状态,避免用户误以为系统已经全网自动发现。 +- [x] 8.16 创建禁用试跟配置按钮必须防双击,提交中禁用,并在重复配置错误时跳转或提示已有配置。 +- [x] 8.17 Candidate Detail 必须把 `UNKNOWN`、`UNAVAILABLE`、`NO_MATCH`、`CONFIRMED_ZERO` 用不同文案和颜色展示,不能都显示成 0。 +- [x] 8.18 Leader 池或 Leader Research 的 `TRIAL_READY` badge 必须和真实 `TRIAL` 状态视觉区分,避免用户误以为真钱已开始。 + +## 9. 通知与内部事件 + +- [x] 9.1 新增 research event 查询与标记通知状态能力。 +- [x] 9.2 第一阶段在操作台展示内部事件,不强依赖外部通知渠道。 +- [x] 9.3 新增通知摘要生成服务,基于 research event 聚合新增候选、纸跟达标、建议试跟、冷却、source failure 和 stale data。 +- [x] 9.4 外部通知发送失败时必须保留 research event,并记录通知失败原因。 +- [x] 9.5 如果接入现有 Telegram 通知,必须避免硬编码新渠道 secret,并复用现有通知配置读取路径。 +- [x] 9.6 新增通知测试,覆盖事件生成、摘要生成、发送失败和事件保留。 +- [x] 9.7 通知摘要必须包含 safety-relevant 事件:source disabled/degraded、valuation unknown、approval rejected、duplicate approval 和 real money activation forbidden。 +- [x] 9.8 通知事件必须可去重,避免同一候选每轮 research run 重复推送相同 `TRIAL_READY` 或 source failure。 + +## 10. 性能、索引与数据保留 + +- [x] 10.1 候选列表、paper trade 列表、research event 列表必须分页。 +- [x] 10.2 研究任务每次运行只处理 active candidates 和新 activity events,不得全量重算全部历史。 +- [x] 10.3 为所有详情查询路径增加 repository 查询或聚合,避免 N+1 查询。 +- [x] 10.4 增加 paper/activity 数据保留策略配置,默认保留足够研究窗口但避免无限增长。 +- [x] 10.5 新增性能相关测试或验证脚本,至少覆盖 100 个候选、1 万条 activity event、1 万条 paper trade 的列表和一次研究运行。 +- [x] 10.6 research run 必须使用 cursor/checkpoint 记录上次处理到的 event time 或 stable key;失败重跑时只回看有限窗口。 +- [x] 10.7 paper processing 每批必须有 batch size 上限、运行时间上限和写入上限,避免一次 run 长时间占用数据库。 +- [x] 10.8 global activity capture 如果启用,必须有每分钟写入上限、payload 最大长度、失败熔断和可观测计数。 +- [x] 10.9 候选详情必须用聚合 DTO 或批量查询组装 score、paper summary、source health、pool mapping,禁止对列表中每个 candidate 单独查多张表。 + +## 11. 文档与运维 + +- [x] 11.1 新增中文文档,说明 Leader Research Agent、Leader 池、Leader 管理和跟单配置的区别。 +- [x] 11.2 文档明确:研究代理可以自动纸跟和推荐,但绝不会自动启用真钱跟单。 +- [x] 11.3 文档说明研究状态和 Leader 池状态映射,特别是 `TRIAL_READY` 不等于 `TRIAL`。 +- [x] 11.4 新增运维说明,覆盖开启/关闭 research job、查看 source health、排查 unknown valuation、排查重复 event 和回滚。 +- [x] 11.5 更新相关 README 或 docs 索引,指向 Leader Research 文档。 +- [x] 11.6 文档明确 activity-derived discovery 的真实覆盖范围:watchlist/existing leader Data API backfill、global capture 是否启用、public leaderboard 尚未接入。 +- [x] 11.7 运维说明必须包含 kill switch:关闭 scheduled research、关闭 global activity capture、隐藏 approval endpoint 或前端入口。 + +## 12. 验证 + +- [x] 12.1 运行后端 research 相关单元测试和 controller 测试。 +- [x] 12.2 运行已有 Leader 池、跟单配置、PnL 统计相关测试,确保未破坏现有安全边界。 +- [x] 12.3 运行前端 TypeScript 构建。 +- [x] 12.4 运行前端 lint。 +- [x] 12.5 手动验证 research job 默认关闭、手动 run、source health、候选列表、候选详情、纸跟详情和 pending decisions。 +- [x] 12.6 手动验证创建禁用试跟配置后 `copy_trading.enabled=false`,且不会自动启用。 +- [x] 12.7 手动验证 source failure 不会删除候选、不会降级 locked 候选、不会把 unknown valuation 展示成 confirmed zero。 +- [x] 12.8 在本地或测试环境验证重复 activity event 不会产生重复 paper trade。 +- [x] 12.9 运行 `./gradlew test --tests "*LeaderResearch*"`,覆盖 research service、state machine、paper trading、approval safety 和 controller。 +- [x] 12.10 运行 `./gradlew test --tests "*LeaderPool*" --tests "*CopyTrading*" --tests "*Pnl*"`,确保现有 Leader 池、跟单配置和 PnL 语义未回退。 +- [x] 12.11 使用 gstack/browser 或 Playwright 进行前端安全 QA,覆盖 `TRIAL_READY` 文案、disabled approval modal、双击提交、source degraded、valuation unknown 和移动端关键路径。 +- [x] 12.12 使用 `/qa` 时优先加载 `/Users/codyhhchen/.gstack/projects/WrBug-PolyHermes/codyhhchen-feat-real-pnl-statistics-eng-review-test-plan-20260504-210925.md` 作为主测试输入。 +- [x] 12.13 验证当前 `PolymarketActivityWsService` 的已知 leader 过滤不会阻断 research ingestion 的未知钱包发现路径。 +- [x] 12.14 验证 research job kill switch 生效:关闭后不再推进状态、不再处理 paper events,但页面仍可读历史数据。 + +## 13. 一次性实施顺序与合并策略 + +- [x] 13.1 第一批实现数据 spine:迁移、entity、repository、enum、source state、run record、activity event 和 processing status。 +- [x] 13.2 第二批实现 ingestion 与 source:Data API backfill、optional global capture、watchlist、existing leaders、activity-derived candidates 和 source health。 +- [x] 13.3 第三批实现 paper trading:event claim、paper config、BUY/SELL、filter evaluator、valuation、PnL、drawdown 和重试隔离。 +- [x] 13.4 第四批实现 scoring/state machine:score v1 公式、状态迁移、locked protection、cooldown/retire 和 research events。 +- [x] 13.5 第五批实现 Leader 池联动和 disabled approval safety:pool mapping、recommendation badge、disabled-only config creation、duplicate protection 和 safety tests。 +- [x] 13.6 第六批实现 API 和前端操作台:run status、candidate list/detail、paper sessions、source health、pending decisions、approval modal 和 i18n。 +- [x] 13.7 第七批实现通知、运维文档、kill switch、性能脚本和全量 QA。 +- [x] 13.8 每一批都必须先补对应测试再合并到下一批;不得把 safety tests 延后到最后。 +- [x] 13.9 如果并行工作,数据 spine 是共享依赖;paper trading、frontend shell、notification summary 可以并行,但 approval safety 必须等 DTO/服务边界稳定后再接。 + +## 14. Review 整改任务 + +- [x] 14.1 修复 locked candidate 安全边界:`LeaderResearchStateMachine.advance()` 对锁定候选必须完全跳过状态、score、badge、summary、Leader 池映射和建议配置写入,不得调用会产生副作用的 `syncCandidate`;补充 locked candidate 不变更 pool/candidate/poolId 的回归测试。 +- [x] 14.2 修复 `DISCOVERED` 候选污染 Leader 池:状态机或 pool mapping 只能在候选达到 `CANDIDATE` 或更高状态时创建/更新 `copy_trading_leader_pool`,原始 `DISCOVERED` 钱包不得出现在 Leader 池;补充 `DISCOVERED` 不建池、`CANDIDATE+` 才建池的测试。 +- [x] 14.3 修复创建禁用试跟配置的并发重复问题:为同账户同 leader 恢复数据库级唯一保护或引入等价事务锁/幂等键,确保双击、并发请求和重试最多创建一条 copy-trading config;补充并发审批测试和迁移回归测试。 +- [x] 14.4 修复 Data API backfill 失败被误报为 source success:`backfillWalletActivities` 失败必须向 `captureSource` 返回 typed failure/partial failure,source health 和 research run 必须显示 failure/degraded,而不是 SUCCESS;补充 Data API failure、success empty、partial failure 的 source health 测试。 +- [x] 14.5 修复前端 approval preview 硬编码风险参数:确认弹窗必须展示后端返回或候选池建议的 `fixedAmount`、`maxDailyLoss`、`maxDailyOrders`、`minPrice`、`maxPrice`、`maxPositionValue`,不得写死 1 USDC/5 USDC/10 orders/0.10-0.80;补充 UI 数据映射测试或浏览器 QA。 +- [x] 14.6 修复 candidate 搜索只搜当前页:后端搜索必须在数据库查询层按 wallet/address/source/status 等条件过滤并返回正确 total,不得先分页再内存过滤;补充分页外命中、空结果和 total 计数测试。 +- [x] 14.7 修复 candidate list N+1 查询:列表 DTO 组装必须批量加载 leader、pool mapping、score/paper summary 等依赖,禁止每个 candidate 单独查多张表;补充 50+ 候选列表查询数量或性能回归测试。 +- [x] 14.8 补齐 Review 发现对应的验证任务:完成后必须运行前端 build/lint、后端 `LeaderResearch` 相关测试、Leader 池/跟单配置/PnL 回归测试,并在本机没有 Java Runtime 时先安装或切换到可运行 Java 的环境再执行后端测试。 +- [x] 14.9 完成未勾选的安全和 QA 任务后再关闭 change:至少覆盖 activity source health、候选来源、研究运行、纸跟、评分状态机、approval safety、controller、通知、数据保留、手动 QA 和浏览器 QA;不得只修 review 代码而保留关键验证项未完成。 diff --git a/scripts/leader-research-perf-check.sql b/scripts/leader-research-perf-check.sql new file mode 100644 index 0000000..3a5239d --- /dev/null +++ b/scripts/leader-research-perf-check.sql @@ -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;