Compare commits
20 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 1087591ff6 | |||
| 21c570c64c | |||
| 83432cf1c4 | |||
| 017c58d5bf | |||
| 1ce2f5d273 | |||
| 4057b3f611 | |||
| 7c8761a049 | |||
| a3f74b8567 | |||
| 82ecf31867 | |||
| e114312c0c | |||
| e7894480f3 | |||
| 6cc7cfde10 | |||
| a7bfe660fc | |||
| 09f6e5346d | |||
| bea0c0e6f0 | |||
| cc6375104c | |||
| 4b58b7cd46 | |||
| 504d0bbe36 | |||
| ac1d6d64f2 | |||
| 5470b4dab6 |
@@ -0,0 +1,156 @@
|
||||
---
|
||||
name: openspec-apply-change
|
||||
description: Implement tasks from an OpenSpec change. Use when the user wants to start implementing, continue implementation, or work through tasks.
|
||||
license: MIT
|
||||
compatibility: Requires openspec CLI.
|
||||
metadata:
|
||||
author: openspec
|
||||
version: "1.0"
|
||||
generatedBy: "1.3.1"
|
||||
---
|
||||
|
||||
Implement tasks from an OpenSpec change.
|
||||
|
||||
**Input**: Optionally specify a change name. If omitted, check if it can be inferred from conversation context. If vague or ambiguous you MUST prompt for available changes.
|
||||
|
||||
**Steps**
|
||||
|
||||
1. **Select the change**
|
||||
|
||||
If a name is provided, use it. Otherwise:
|
||||
- Infer from conversation context if the user mentioned a change
|
||||
- Auto-select if only one active change exists
|
||||
- If ambiguous, run `openspec list --json` to get available changes and use the **AskUserQuestion tool** to let the user select
|
||||
|
||||
Always announce: "Using change: <name>" and how to override (e.g., `/opsx:apply <other>`).
|
||||
|
||||
2. **Check status to understand the schema**
|
||||
```bash
|
||||
openspec status --change "<name>" --json
|
||||
```
|
||||
Parse the JSON to understand:
|
||||
- `schemaName`: The workflow being used (e.g., "spec-driven")
|
||||
- Which artifact contains the tasks (typically "tasks" for spec-driven, check status for others)
|
||||
|
||||
3. **Get apply instructions**
|
||||
|
||||
```bash
|
||||
openspec instructions apply --change "<name>" --json
|
||||
```
|
||||
|
||||
This returns:
|
||||
- `contextFiles`: artifact ID -> array of concrete file paths (varies by schema - could be proposal/specs/design/tasks or spec/tests/implementation/docs)
|
||||
- Progress (total, complete, remaining)
|
||||
- Task list with status
|
||||
- Dynamic instruction based on current state
|
||||
|
||||
**Handle states:**
|
||||
- If `state: "blocked"` (missing artifacts): show message, suggest using openspec-continue-change
|
||||
- If `state: "all_done"`: congratulate, suggest archive
|
||||
- Otherwise: proceed to implementation
|
||||
|
||||
4. **Read context files**
|
||||
|
||||
Read every file path listed under `contextFiles` from the apply instructions output.
|
||||
The files depend on the schema being used:
|
||||
- **spec-driven**: proposal, specs, design, tasks
|
||||
- Other schemas: follow the contextFiles from CLI output
|
||||
|
||||
5. **Show current progress**
|
||||
|
||||
Display:
|
||||
- Schema being used
|
||||
- Progress: "N/M tasks complete"
|
||||
- Remaining tasks overview
|
||||
- Dynamic instruction from CLI
|
||||
|
||||
6. **Implement tasks (loop until done or blocked)**
|
||||
|
||||
For each pending task:
|
||||
- Show which task is being worked on
|
||||
- Make the code changes required
|
||||
- Keep changes minimal and focused
|
||||
- Mark task complete in the tasks file: `- [ ]` → `- [x]`
|
||||
- Continue to next task
|
||||
|
||||
**Pause if:**
|
||||
- Task is unclear → ask for clarification
|
||||
- Implementation reveals a design issue → suggest updating artifacts
|
||||
- Error or blocker encountered → report and wait for guidance
|
||||
- User interrupts
|
||||
|
||||
7. **On completion or pause, show status**
|
||||
|
||||
Display:
|
||||
- Tasks completed this session
|
||||
- Overall progress: "N/M tasks complete"
|
||||
- If all done: suggest archive
|
||||
- If paused: explain why and wait for guidance
|
||||
|
||||
**Output During Implementation**
|
||||
|
||||
```
|
||||
## Implementing: <change-name> (schema: <schema-name>)
|
||||
|
||||
Working on task 3/7: <task description>
|
||||
[...implementation happening...]
|
||||
✓ Task complete
|
||||
|
||||
Working on task 4/7: <task description>
|
||||
[...implementation happening...]
|
||||
✓ Task complete
|
||||
```
|
||||
|
||||
**Output On Completion**
|
||||
|
||||
```
|
||||
## Implementation Complete
|
||||
|
||||
**Change:** <change-name>
|
||||
**Schema:** <schema-name>
|
||||
**Progress:** 7/7 tasks complete ✓
|
||||
|
||||
### Completed This Session
|
||||
- [x] Task 1
|
||||
- [x] Task 2
|
||||
...
|
||||
|
||||
All tasks complete! Ready to archive this change.
|
||||
```
|
||||
|
||||
**Output On Pause (Issue Encountered)**
|
||||
|
||||
```
|
||||
## Implementation Paused
|
||||
|
||||
**Change:** <change-name>
|
||||
**Schema:** <schema-name>
|
||||
**Progress:** 4/7 tasks complete
|
||||
|
||||
### Issue Encountered
|
||||
<description of the issue>
|
||||
|
||||
**Options:**
|
||||
1. <option 1>
|
||||
2. <option 2>
|
||||
3. Other approach
|
||||
|
||||
What would you like to do?
|
||||
```
|
||||
|
||||
**Guardrails**
|
||||
- Keep going through tasks until done or blocked
|
||||
- Always read context files before starting (from the apply instructions output)
|
||||
- If task is ambiguous, pause and ask before implementing
|
||||
- If implementation reveals issues, pause and suggest artifact updates
|
||||
- Keep code changes minimal and scoped to each task
|
||||
- Update task checkbox immediately after completing each task
|
||||
- Pause on errors, blockers, or unclear requirements - don't guess
|
||||
- Use contextFiles from CLI output, don't assume specific file names
|
||||
|
||||
**Fluid Workflow Integration**
|
||||
|
||||
This skill supports the "actions on a change" model:
|
||||
|
||||
- **Can be invoked anytime**: Before all artifacts are done (if tasks exist), after partial implementation, interleaved with other actions
|
||||
- **Allows artifact updates**: If implementation reveals design issues, suggest updating artifacts - not phase-locked, work fluidly
|
||||
@@ -0,0 +1,114 @@
|
||||
---
|
||||
name: openspec-archive-change
|
||||
description: Archive a completed change in the experimental workflow. Use when the user wants to finalize and archive a change after implementation is complete.
|
||||
license: MIT
|
||||
compatibility: Requires openspec CLI.
|
||||
metadata:
|
||||
author: openspec
|
||||
version: "1.0"
|
||||
generatedBy: "1.3.1"
|
||||
---
|
||||
|
||||
Archive a completed change in the experimental workflow.
|
||||
|
||||
**Input**: Optionally specify a change name. If omitted, check if it can be inferred from conversation context. If vague or ambiguous you MUST prompt for available changes.
|
||||
|
||||
**Steps**
|
||||
|
||||
1. **If no change name provided, prompt for selection**
|
||||
|
||||
Run `openspec list --json` to get available changes. Use the **AskUserQuestion tool** to let the user select.
|
||||
|
||||
Show only active changes (not already archived).
|
||||
Include the schema used for each change if available.
|
||||
|
||||
**IMPORTANT**: Do NOT guess or auto-select a change. Always let the user choose.
|
||||
|
||||
2. **Check artifact completion status**
|
||||
|
||||
Run `openspec status --change "<name>" --json` to check artifact completion.
|
||||
|
||||
Parse the JSON to understand:
|
||||
- `schemaName`: The workflow being used
|
||||
- `artifacts`: List of artifacts with their status (`done` or other)
|
||||
|
||||
**If any artifacts are not `done`:**
|
||||
- Display warning listing incomplete artifacts
|
||||
- Use **AskUserQuestion tool** to confirm user wants to proceed
|
||||
- Proceed if user confirms
|
||||
|
||||
3. **Check task completion status**
|
||||
|
||||
Read the tasks file (typically `tasks.md`) to check for incomplete tasks.
|
||||
|
||||
Count tasks marked with `- [ ]` (incomplete) vs `- [x]` (complete).
|
||||
|
||||
**If incomplete tasks found:**
|
||||
- Display warning showing count of incomplete tasks
|
||||
- Use **AskUserQuestion tool** to confirm user wants to proceed
|
||||
- Proceed if user confirms
|
||||
|
||||
**If no tasks file exists:** Proceed without task-related warning.
|
||||
|
||||
4. **Assess delta spec sync state**
|
||||
|
||||
Check for delta specs at `openspec/changes/<name>/specs/`. If none exist, proceed without sync prompt.
|
||||
|
||||
**If delta specs exist:**
|
||||
- Compare each delta spec with its corresponding main spec at `openspec/specs/<capability>/spec.md`
|
||||
- Determine what changes would be applied (adds, modifications, removals, renames)
|
||||
- Show a combined summary before prompting
|
||||
|
||||
**Prompt options:**
|
||||
- If changes needed: "Sync now (recommended)", "Archive without syncing"
|
||||
- If already synced: "Archive now", "Sync anyway", "Cancel"
|
||||
|
||||
If user chooses sync, use Task tool (subagent_type: "general-purpose", prompt: "Use Skill tool to invoke openspec-sync-specs for change '<name>'. Delta spec analysis: <include the analyzed delta spec summary>"). Proceed to archive regardless of choice.
|
||||
|
||||
5. **Perform the archive**
|
||||
|
||||
Create the archive directory if it doesn't exist:
|
||||
```bash
|
||||
mkdir -p openspec/changes/archive
|
||||
```
|
||||
|
||||
Generate target name using current date: `YYYY-MM-DD-<change-name>`
|
||||
|
||||
**Check if target already exists:**
|
||||
- If yes: Fail with error, suggest renaming existing archive or using different date
|
||||
- If no: Move the change directory to archive
|
||||
|
||||
```bash
|
||||
mv openspec/changes/<name> openspec/changes/archive/YYYY-MM-DD-<name>
|
||||
```
|
||||
|
||||
6. **Display summary**
|
||||
|
||||
Show archive completion summary including:
|
||||
- Change name
|
||||
- Schema that was used
|
||||
- Archive location
|
||||
- Whether specs were synced (if applicable)
|
||||
- Note about any warnings (incomplete artifacts/tasks)
|
||||
|
||||
**Output On Success**
|
||||
|
||||
```
|
||||
## Archive Complete
|
||||
|
||||
**Change:** <change-name>
|
||||
**Schema:** <schema-name>
|
||||
**Archived to:** openspec/changes/archive/YYYY-MM-DD-<name>/
|
||||
**Specs:** ✓ Synced to main specs (or "No delta specs" or "Sync skipped")
|
||||
|
||||
All artifacts complete. All tasks complete.
|
||||
```
|
||||
|
||||
**Guardrails**
|
||||
- Always prompt for change selection if not provided
|
||||
- Use artifact graph (openspec status --json) for completion checking
|
||||
- Don't block archive on warnings - just inform and confirm
|
||||
- Preserve .openspec.yaml when moving to archive (it moves with the directory)
|
||||
- Show clear summary of what happened
|
||||
- If sync is requested, use openspec-sync-specs approach (agent-driven)
|
||||
- If delta specs exist, always run the sync assessment and show the combined summary before prompting
|
||||
@@ -0,0 +1,246 @@
|
||||
---
|
||||
name: openspec-bulk-archive-change
|
||||
description: Archive multiple completed changes at once. Use when archiving several parallel changes.
|
||||
license: MIT
|
||||
compatibility: Requires openspec CLI.
|
||||
metadata:
|
||||
author: openspec
|
||||
version: "1.0"
|
||||
generatedBy: "1.3.1"
|
||||
---
|
||||
|
||||
Archive multiple completed changes in a single operation.
|
||||
|
||||
This skill allows you to batch-archive changes, handling spec conflicts intelligently by checking the codebase to determine what's actually implemented.
|
||||
|
||||
**Input**: None required (prompts for selection)
|
||||
|
||||
**Steps**
|
||||
|
||||
1. **Get active changes**
|
||||
|
||||
Run `openspec list --json` to get all active changes.
|
||||
|
||||
If no active changes exist, inform user and stop.
|
||||
|
||||
2. **Prompt for change selection**
|
||||
|
||||
Use **AskUserQuestion tool** with multi-select to let user choose changes:
|
||||
- Show each change with its schema
|
||||
- Include an option for "All changes"
|
||||
- Allow any number of selections (1+ works, 2+ is the typical use case)
|
||||
|
||||
**IMPORTANT**: Do NOT auto-select. Always let the user choose.
|
||||
|
||||
3. **Batch validation - gather status for all selected changes**
|
||||
|
||||
For each selected change, collect:
|
||||
|
||||
a. **Artifact status** - Run `openspec status --change "<name>" --json`
|
||||
- Parse `schemaName` and `artifacts` list
|
||||
- Note which artifacts are `done` vs other states
|
||||
|
||||
b. **Task completion** - Read `openspec/changes/<name>/tasks.md`
|
||||
- Count `- [ ]` (incomplete) vs `- [x]` (complete)
|
||||
- If no tasks file exists, note as "No tasks"
|
||||
|
||||
c. **Delta specs** - Check `openspec/changes/<name>/specs/` directory
|
||||
- List which capability specs exist
|
||||
- For each, extract requirement names (lines matching `### Requirement: <name>`)
|
||||
|
||||
4. **Detect spec conflicts**
|
||||
|
||||
Build a map of `capability -> [changes that touch it]`:
|
||||
|
||||
```
|
||||
auth -> [change-a, change-b] <- CONFLICT (2+ changes)
|
||||
api -> [change-c] <- OK (only 1 change)
|
||||
```
|
||||
|
||||
A conflict exists when 2+ selected changes have delta specs for the same capability.
|
||||
|
||||
5. **Resolve conflicts agentically**
|
||||
|
||||
**For each conflict**, investigate the codebase:
|
||||
|
||||
a. **Read the delta specs** from each conflicting change to understand what each claims to add/modify
|
||||
|
||||
b. **Search the codebase** for implementation evidence:
|
||||
- Look for code implementing requirements from each delta spec
|
||||
- Check for related files, functions, or tests
|
||||
|
||||
c. **Determine resolution**:
|
||||
- If only one change is actually implemented -> sync that one's specs
|
||||
- If both implemented -> apply in chronological order (older first, newer overwrites)
|
||||
- If neither implemented -> skip spec sync, warn user
|
||||
|
||||
d. **Record resolution** for each conflict:
|
||||
- Which change's specs to apply
|
||||
- In what order (if both)
|
||||
- Rationale (what was found in codebase)
|
||||
|
||||
6. **Show consolidated status table**
|
||||
|
||||
Display a table summarizing all changes:
|
||||
|
||||
```
|
||||
| Change | Artifacts | Tasks | Specs | Conflicts | Status |
|
||||
|---------------------|-----------|-------|---------|-----------|--------|
|
||||
| schema-management | Done | 5/5 | 2 delta | None | Ready |
|
||||
| project-config | Done | 3/3 | 1 delta | None | Ready |
|
||||
| add-oauth | Done | 4/4 | 1 delta | auth (!) | Ready* |
|
||||
| add-verify-skill | 1 left | 2/5 | None | None | Warn |
|
||||
```
|
||||
|
||||
For conflicts, show the resolution:
|
||||
```
|
||||
* Conflict resolution:
|
||||
- auth spec: Will apply add-oauth then add-jwt (both implemented, chronological order)
|
||||
```
|
||||
|
||||
For incomplete changes, show warnings:
|
||||
```
|
||||
Warnings:
|
||||
- add-verify-skill: 1 incomplete artifact, 3 incomplete tasks
|
||||
```
|
||||
|
||||
7. **Confirm batch operation**
|
||||
|
||||
Use **AskUserQuestion tool** with a single confirmation:
|
||||
|
||||
- "Archive N changes?" with options based on status
|
||||
- Options might include:
|
||||
- "Archive all N changes"
|
||||
- "Archive only N ready changes (skip incomplete)"
|
||||
- "Cancel"
|
||||
|
||||
If there are incomplete changes, make clear they'll be archived with warnings.
|
||||
|
||||
8. **Execute archive for each confirmed change**
|
||||
|
||||
Process changes in the determined order (respecting conflict resolution):
|
||||
|
||||
a. **Sync specs** if delta specs exist:
|
||||
- Use the openspec-sync-specs approach (agent-driven intelligent merge)
|
||||
- For conflicts, apply in resolved order
|
||||
- Track if sync was done
|
||||
|
||||
b. **Perform the archive**:
|
||||
```bash
|
||||
mkdir -p openspec/changes/archive
|
||||
mv openspec/changes/<name> openspec/changes/archive/YYYY-MM-DD-<name>
|
||||
```
|
||||
|
||||
c. **Track outcome** for each change:
|
||||
- Success: archived successfully
|
||||
- Failed: error during archive (record error)
|
||||
- Skipped: user chose not to archive (if applicable)
|
||||
|
||||
9. **Display summary**
|
||||
|
||||
Show final results:
|
||||
|
||||
```
|
||||
## Bulk Archive Complete
|
||||
|
||||
Archived 3 changes:
|
||||
- schema-management-cli -> archive/2026-01-19-schema-management-cli/
|
||||
- project-config -> archive/2026-01-19-project-config/
|
||||
- add-oauth -> archive/2026-01-19-add-oauth/
|
||||
|
||||
Skipped 1 change:
|
||||
- add-verify-skill (user chose not to archive incomplete)
|
||||
|
||||
Spec sync summary:
|
||||
- 4 delta specs synced to main specs
|
||||
- 1 conflict resolved (auth: applied both in chronological order)
|
||||
```
|
||||
|
||||
If any failures:
|
||||
```
|
||||
Failed 1 change:
|
||||
- some-change: Archive directory already exists
|
||||
```
|
||||
|
||||
**Conflict Resolution Examples**
|
||||
|
||||
Example 1: Only one implemented
|
||||
```
|
||||
Conflict: specs/auth/spec.md touched by [add-oauth, add-jwt]
|
||||
|
||||
Checking add-oauth:
|
||||
- Delta adds "OAuth Provider Integration" requirement
|
||||
- Searching codebase... found src/auth/oauth.ts implementing OAuth flow
|
||||
|
||||
Checking add-jwt:
|
||||
- Delta adds "JWT Token Handling" requirement
|
||||
- Searching codebase... no JWT implementation found
|
||||
|
||||
Resolution: Only add-oauth is implemented. Will sync add-oauth specs only.
|
||||
```
|
||||
|
||||
Example 2: Both implemented
|
||||
```
|
||||
Conflict: specs/api/spec.md touched by [add-rest-api, add-graphql]
|
||||
|
||||
Checking add-rest-api (created 2026-01-10):
|
||||
- Delta adds "REST Endpoints" requirement
|
||||
- Searching codebase... found src/api/rest.ts
|
||||
|
||||
Checking add-graphql (created 2026-01-15):
|
||||
- Delta adds "GraphQL Schema" requirement
|
||||
- Searching codebase... found src/api/graphql.ts
|
||||
|
||||
Resolution: Both implemented. Will apply add-rest-api specs first,
|
||||
then add-graphql specs (chronological order, newer takes precedence).
|
||||
```
|
||||
|
||||
**Output On Success**
|
||||
|
||||
```
|
||||
## Bulk Archive Complete
|
||||
|
||||
Archived N changes:
|
||||
- <change-1> -> archive/YYYY-MM-DD-<change-1>/
|
||||
- <change-2> -> archive/YYYY-MM-DD-<change-2>/
|
||||
|
||||
Spec sync summary:
|
||||
- N delta specs synced to main specs
|
||||
- No conflicts (or: M conflicts resolved)
|
||||
```
|
||||
|
||||
**Output On Partial Success**
|
||||
|
||||
```
|
||||
## Bulk Archive Complete (partial)
|
||||
|
||||
Archived N changes:
|
||||
- <change-1> -> archive/YYYY-MM-DD-<change-1>/
|
||||
|
||||
Skipped M changes:
|
||||
- <change-2> (user chose not to archive incomplete)
|
||||
|
||||
Failed K changes:
|
||||
- <change-3>: Archive directory already exists
|
||||
```
|
||||
|
||||
**Output When No Changes**
|
||||
|
||||
```
|
||||
## No Changes to Archive
|
||||
|
||||
No active changes found. Create a new change to get started.
|
||||
```
|
||||
|
||||
**Guardrails**
|
||||
- Allow any number of changes (1+ is fine, 2+ is the typical use case)
|
||||
- Always prompt for selection, never auto-select
|
||||
- Detect spec conflicts early and resolve by checking codebase
|
||||
- When both changes are implemented, apply specs in chronological order
|
||||
- Skip spec sync only when implementation is missing (warn user)
|
||||
- Show clear per-change status before confirming
|
||||
- Use single confirmation for entire batch
|
||||
- Track and report all outcomes (success/skip/fail)
|
||||
- Preserve .openspec.yaml when moving to archive
|
||||
- Archive directory target uses current date: YYYY-MM-DD-<name>
|
||||
- If archive target exists, fail that change but continue with others
|
||||
@@ -0,0 +1,118 @@
|
||||
---
|
||||
name: openspec-continue-change
|
||||
description: Continue working on an OpenSpec change by creating the next artifact. Use when the user wants to progress their change, create the next artifact, or continue their workflow.
|
||||
license: MIT
|
||||
compatibility: Requires openspec CLI.
|
||||
metadata:
|
||||
author: openspec
|
||||
version: "1.0"
|
||||
generatedBy: "1.3.1"
|
||||
---
|
||||
|
||||
Continue working on a change by creating the next artifact.
|
||||
|
||||
**Input**: Optionally specify a change name. If omitted, check if it can be inferred from conversation context. If vague or ambiguous you MUST prompt for available changes.
|
||||
|
||||
**Steps**
|
||||
|
||||
1. **If no change name provided, prompt for selection**
|
||||
|
||||
Run `openspec list --json` to get available changes sorted by most recently modified. Then use the **AskUserQuestion tool** to let the user select which change to work on.
|
||||
|
||||
Present the top 3-4 most recently modified changes as options, showing:
|
||||
- Change name
|
||||
- Schema (from `schema` field if present, otherwise "spec-driven")
|
||||
- Status (e.g., "0/5 tasks", "complete", "no tasks")
|
||||
- How recently it was modified (from `lastModified` field)
|
||||
|
||||
Mark the most recently modified change as "(Recommended)" since it's likely what the user wants to continue.
|
||||
|
||||
**IMPORTANT**: Do NOT guess or auto-select a change. Always let the user choose.
|
||||
|
||||
2. **Check current status**
|
||||
```bash
|
||||
openspec status --change "<name>" --json
|
||||
```
|
||||
Parse the JSON to understand current state. The response includes:
|
||||
- `schemaName`: The workflow schema being used (e.g., "spec-driven")
|
||||
- `artifacts`: Array of artifacts with their status ("done", "ready", "blocked")
|
||||
- `isComplete`: Boolean indicating if all artifacts are complete
|
||||
|
||||
3. **Act based on status**:
|
||||
|
||||
---
|
||||
|
||||
**If all artifacts are complete (`isComplete: true`)**:
|
||||
- Congratulate the user
|
||||
- Show final status including the schema used
|
||||
- Suggest: "All artifacts created! You can now implement this change or archive it."
|
||||
- STOP
|
||||
|
||||
---
|
||||
|
||||
**If artifacts are ready to create** (status shows artifacts with `status: "ready"`):
|
||||
- Pick the FIRST artifact with `status: "ready"` from the status output
|
||||
- Get its instructions:
|
||||
```bash
|
||||
openspec instructions <artifact-id> --change "<name>" --json
|
||||
```
|
||||
- Parse the JSON. The key fields are:
|
||||
- `context`: Project background (constraints for you - do NOT include in output)
|
||||
- `rules`: Artifact-specific rules (constraints for you - do NOT include in output)
|
||||
- `template`: The structure to use for your output file
|
||||
- `instruction`: Schema-specific guidance
|
||||
- `outputPath`: Where to write the artifact
|
||||
- `dependencies`: Completed artifacts to read for context
|
||||
- **Create the artifact file**:
|
||||
- Read any completed dependency files for context
|
||||
- Use `template` as the structure - fill in its sections
|
||||
- Apply `context` and `rules` as constraints when writing - but do NOT copy them into the file
|
||||
- Write to the output path specified in instructions
|
||||
- Show what was created and what's now unlocked
|
||||
- STOP after creating ONE artifact
|
||||
|
||||
---
|
||||
|
||||
**If no artifacts are ready (all blocked)**:
|
||||
- This shouldn't happen with a valid schema
|
||||
- Show status and suggest checking for issues
|
||||
|
||||
4. **After creating an artifact, show progress**
|
||||
```bash
|
||||
openspec status --change "<name>"
|
||||
```
|
||||
|
||||
**Output**
|
||||
|
||||
After each invocation, show:
|
||||
- Which artifact was created
|
||||
- Schema workflow being used
|
||||
- Current progress (N/M complete)
|
||||
- What artifacts are now unlocked
|
||||
- Prompt: "Want to continue? Just ask me to continue or tell me what to do next."
|
||||
|
||||
**Artifact Creation Guidelines**
|
||||
|
||||
The artifact types and their purpose depend on the schema. Use the `instruction` field from the instructions output to understand what to create.
|
||||
|
||||
Common artifact patterns:
|
||||
|
||||
**spec-driven schema** (proposal → specs → design → tasks):
|
||||
- **proposal.md**: Ask user about the change if not clear. Fill in Why, What Changes, Capabilities, Impact.
|
||||
- The Capabilities section is critical - each capability listed will need a spec file.
|
||||
- **specs/<capability>/spec.md**: Create one spec per capability listed in the proposal's Capabilities section (use the capability name, not the change name).
|
||||
- **design.md**: Document technical decisions, architecture, and implementation approach.
|
||||
- **tasks.md**: Break down implementation into checkboxed tasks.
|
||||
|
||||
For other schemas, follow the `instruction` field from the CLI output.
|
||||
|
||||
**Guardrails**
|
||||
- Create ONE artifact per invocation
|
||||
- Always read dependency artifacts before creating a new one
|
||||
- Never skip artifacts or create out of order
|
||||
- If context is unclear, ask the user before creating
|
||||
- Verify the artifact file exists after writing before marking progress
|
||||
- Use the schema's artifact sequence, don't assume specific artifact names
|
||||
- **IMPORTANT**: `context` and `rules` are constraints for YOU, not content for the file
|
||||
- Do NOT copy `<context>`, `<rules>`, `<project_context>` blocks into the artifact
|
||||
- These guide what you write, but should never appear in the output
|
||||
@@ -0,0 +1,288 @@
|
||||
---
|
||||
name: openspec-explore
|
||||
description: Enter explore mode - a thinking partner for exploring ideas, investigating problems, and clarifying requirements. Use when the user wants to think through something before or during a change.
|
||||
license: MIT
|
||||
compatibility: Requires openspec CLI.
|
||||
metadata:
|
||||
author: openspec
|
||||
version: "1.0"
|
||||
generatedBy: "1.3.1"
|
||||
---
|
||||
|
||||
Enter explore mode. Think deeply. Visualize freely. Follow the conversation wherever it goes.
|
||||
|
||||
**IMPORTANT: Explore mode is for thinking, not implementing.** You may read files, search code, and investigate the codebase, but you must NEVER write code or implement features. If the user asks you to implement something, remind them to exit explore mode first and create a change proposal. You MAY create OpenSpec artifacts (proposals, designs, specs) if the user asks—that's capturing thinking, not implementing.
|
||||
|
||||
**This is a stance, not a workflow.** There are no fixed steps, no required sequence, no mandatory outputs. You're a thinking partner helping the user explore.
|
||||
|
||||
---
|
||||
|
||||
## The Stance
|
||||
|
||||
- **Curious, not prescriptive** - Ask questions that emerge naturally, don't follow a script
|
||||
- **Open threads, not interrogations** - Surface multiple interesting directions and let the user follow what resonates. Don't funnel them through a single path of questions.
|
||||
- **Visual** - Use ASCII diagrams liberally when they'd help clarify thinking
|
||||
- **Adaptive** - Follow interesting threads, pivot when new information emerges
|
||||
- **Patient** - Don't rush to conclusions, let the shape of the problem emerge
|
||||
- **Grounded** - Explore the actual codebase when relevant, don't just theorize
|
||||
|
||||
---
|
||||
|
||||
## What You Might Do
|
||||
|
||||
Depending on what the user brings, you might:
|
||||
|
||||
**Explore the problem space**
|
||||
- Ask clarifying questions that emerge from what they said
|
||||
- Challenge assumptions
|
||||
- Reframe the problem
|
||||
- Find analogies
|
||||
|
||||
**Investigate the codebase**
|
||||
- Map existing architecture relevant to the discussion
|
||||
- Find integration points
|
||||
- Identify patterns already in use
|
||||
- Surface hidden complexity
|
||||
|
||||
**Compare options**
|
||||
- Brainstorm multiple approaches
|
||||
- Build comparison tables
|
||||
- Sketch tradeoffs
|
||||
- Recommend a path (if asked)
|
||||
|
||||
**Visualize**
|
||||
```
|
||||
┌─────────────────────────────────────────┐
|
||||
│ Use ASCII diagrams liberally │
|
||||
├─────────────────────────────────────────┤
|
||||
│ │
|
||||
│ ┌────────┐ ┌────────┐ │
|
||||
│ │ State │────────▶│ State │ │
|
||||
│ │ A │ │ B │ │
|
||||
│ └────────┘ └────────┘ │
|
||||
│ │
|
||||
│ System diagrams, state machines, │
|
||||
│ data flows, architecture sketches, │
|
||||
│ dependency graphs, comparison tables │
|
||||
│ │
|
||||
└─────────────────────────────────────────┘
|
||||
```
|
||||
|
||||
**Surface risks and unknowns**
|
||||
- Identify what could go wrong
|
||||
- Find gaps in understanding
|
||||
- Suggest spikes or investigations
|
||||
|
||||
---
|
||||
|
||||
## OpenSpec Awareness
|
||||
|
||||
You have full context of the OpenSpec system. Use it naturally, don't force it.
|
||||
|
||||
### Check for context
|
||||
|
||||
At the start, quickly check what exists:
|
||||
```bash
|
||||
openspec list --json
|
||||
```
|
||||
|
||||
This tells you:
|
||||
- If there are active changes
|
||||
- Their names, schemas, and status
|
||||
- What the user might be working on
|
||||
|
||||
### When no change exists
|
||||
|
||||
Think freely. When insights crystallize, you might offer:
|
||||
|
||||
- "This feels solid enough to start a change. Want me to create a proposal?"
|
||||
- Or keep exploring - no pressure to formalize
|
||||
|
||||
### When a change exists
|
||||
|
||||
If the user mentions a change or you detect one is relevant:
|
||||
|
||||
1. **Read existing artifacts for context**
|
||||
- `openspec/changes/<name>/proposal.md`
|
||||
- `openspec/changes/<name>/design.md`
|
||||
- `openspec/changes/<name>/tasks.md`
|
||||
- etc.
|
||||
|
||||
2. **Reference them naturally in conversation**
|
||||
- "Your design mentions using Redis, but we just realized SQLite fits better..."
|
||||
- "The proposal scopes this to premium users, but we're now thinking everyone..."
|
||||
|
||||
3. **Offer to capture when decisions are made**
|
||||
|
||||
| Insight Type | Where to Capture |
|
||||
|----------------------------|--------------------------------|
|
||||
| New requirement discovered | `specs/<capability>/spec.md` |
|
||||
| Requirement changed | `specs/<capability>/spec.md` |
|
||||
| Design decision made | `design.md` |
|
||||
| Scope changed | `proposal.md` |
|
||||
| New work identified | `tasks.md` |
|
||||
| Assumption invalidated | Relevant artifact |
|
||||
|
||||
Example offers:
|
||||
- "That's a design decision. Capture it in design.md?"
|
||||
- "This is a new requirement. Add it to specs?"
|
||||
- "This changes scope. Update the proposal?"
|
||||
|
||||
4. **The user decides** - Offer and move on. Don't pressure. Don't auto-capture.
|
||||
|
||||
---
|
||||
|
||||
## What You Don't Have To Do
|
||||
|
||||
- Follow a script
|
||||
- Ask the same questions every time
|
||||
- Produce a specific artifact
|
||||
- Reach a conclusion
|
||||
- Stay on topic if a tangent is valuable
|
||||
- Be brief (this is thinking time)
|
||||
|
||||
---
|
||||
|
||||
## Handling Different Entry Points
|
||||
|
||||
**User brings a vague idea:**
|
||||
```
|
||||
User: I'm thinking about adding real-time collaboration
|
||||
|
||||
You: Real-time collab is a big space. Let me think about this...
|
||||
|
||||
COLLABORATION SPECTRUM
|
||||
════════════════════════════════════════════
|
||||
|
||||
Awareness Coordination Sync
|
||||
│ │ │
|
||||
▼ ▼ ▼
|
||||
┌────────┐ ┌────────┐ ┌────────┐
|
||||
│Presence│ │Cursors │ │ CRDT │
|
||||
│ "3 │ │ Multi │ │Conflict│
|
||||
│online" │ │ select │ │ free │
|
||||
└────────┘ └────────┘ └────────┘
|
||||
│ │ │
|
||||
trivial moderate complex
|
||||
|
||||
Where's your head at?
|
||||
```
|
||||
|
||||
**User brings a specific problem:**
|
||||
```
|
||||
User: The auth system is a mess
|
||||
|
||||
You: [reads codebase]
|
||||
|
||||
┌─────────────────────────────────────────────┐
|
||||
│ CURRENT AUTH FLOW │
|
||||
└─────────────────────────────────────────────┘
|
||||
│
|
||||
┌─────────────┼─────────────┐
|
||||
▼ ▼ ▼
|
||||
┌─────────┐ ┌─────────┐ ┌─────────┐
|
||||
│ Google │ │ GitHub │ │ Email │
|
||||
│ OAuth │ │ OAuth │ │ Magic │
|
||||
└────┬────┘ └────┬────┘ └────┬────┘
|
||||
│ │ │
|
||||
└─────────────┼─────────────┘
|
||||
▼
|
||||
┌───────────┐
|
||||
│ Session │
|
||||
└─────┬─────┘
|
||||
│
|
||||
▼
|
||||
┌───────────┐
|
||||
│ Perms │
|
||||
└───────────┘
|
||||
|
||||
I see three tangles. Which one's burning?
|
||||
```
|
||||
|
||||
**User is stuck mid-implementation:**
|
||||
```
|
||||
User: /opsx:explore add-auth-system
|
||||
The OAuth integration is more complex than expected
|
||||
|
||||
You: [reads change artifacts]
|
||||
|
||||
You're on task 4: "Implement OAuth flow"
|
||||
|
||||
Let me trace what's involved...
|
||||
|
||||
[draws diagram, explores options, suggests paths]
|
||||
|
||||
Want to update the design to reflect this?
|
||||
Or add a spike task to investigate?
|
||||
```
|
||||
|
||||
**User wants to compare options:**
|
||||
```
|
||||
User: Should we use Postgres or SQLite?
|
||||
|
||||
You: Generic answer is boring. What's the context?
|
||||
|
||||
User: A CLI tool that tracks local dev environments
|
||||
|
||||
You: That changes everything.
|
||||
|
||||
┌─────────────────────────────────────────────────┐
|
||||
│ CLI TOOL DATA STORAGE │
|
||||
└─────────────────────────────────────────────────┘
|
||||
|
||||
Key constraints:
|
||||
• No daemon running
|
||||
• Must work offline
|
||||
• Single user
|
||||
|
||||
SQLite Postgres
|
||||
Deployment embedded ✓ needs server ✗
|
||||
Offline yes ✓ no ✗
|
||||
Single file yes ✓ no ✗
|
||||
|
||||
SQLite. Not even close.
|
||||
|
||||
Unless... is there a sync component?
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Ending Discovery
|
||||
|
||||
There's no required ending. Discovery might:
|
||||
|
||||
- **Flow into a proposal**: "Ready to start? I can create a change proposal."
|
||||
- **Result in artifact updates**: "Updated design.md with these decisions"
|
||||
- **Just provide clarity**: User has what they need, moves on
|
||||
- **Continue later**: "We can pick this up anytime"
|
||||
|
||||
When it feels like things are crystallizing, you might summarize:
|
||||
|
||||
```
|
||||
## What We Figured Out
|
||||
|
||||
**The problem**: [crystallized understanding]
|
||||
|
||||
**The approach**: [if one emerged]
|
||||
|
||||
**Open questions**: [if any remain]
|
||||
|
||||
**Next steps** (if ready):
|
||||
- Create a change proposal
|
||||
- Keep exploring: just keep talking
|
||||
```
|
||||
|
||||
But this summary is optional. Sometimes the thinking IS the value.
|
||||
|
||||
---
|
||||
|
||||
## Guardrails
|
||||
|
||||
- **Don't implement** - Never write code or implement features. Creating OpenSpec artifacts is fine, writing application code is not.
|
||||
- **Don't fake understanding** - If something is unclear, dig deeper
|
||||
- **Don't rush** - Discovery is thinking time, not task time
|
||||
- **Don't force structure** - Let patterns emerge naturally
|
||||
- **Don't auto-capture** - Offer to save insights, don't just do it
|
||||
- **Do visualize** - A good diagram is worth many paragraphs
|
||||
- **Do explore the codebase** - Ground discussions in reality
|
||||
- **Do question assumptions** - Including the user's and your own
|
||||
@@ -0,0 +1,101 @@
|
||||
---
|
||||
name: openspec-ff-change
|
||||
description: Fast-forward through OpenSpec artifact creation. Use when the user wants to quickly create all artifacts needed for implementation without stepping through each one individually.
|
||||
license: MIT
|
||||
compatibility: Requires openspec CLI.
|
||||
metadata:
|
||||
author: openspec
|
||||
version: "1.0"
|
||||
generatedBy: "1.3.1"
|
||||
---
|
||||
|
||||
Fast-forward through artifact creation - generate everything needed to start implementation in one go.
|
||||
|
||||
**Input**: The user's request should include a change name (kebab-case) OR a description of what they want to build.
|
||||
|
||||
**Steps**
|
||||
|
||||
1. **If no clear input provided, ask what they want to build**
|
||||
|
||||
Use the **AskUserQuestion tool** (open-ended, no preset options) to ask:
|
||||
> "What change do you want to work on? Describe what you want to build or fix."
|
||||
|
||||
From their description, derive a kebab-case name (e.g., "add user authentication" → `add-user-auth`).
|
||||
|
||||
**IMPORTANT**: Do NOT proceed without understanding what the user wants to build.
|
||||
|
||||
2. **Create the change directory**
|
||||
```bash
|
||||
openspec new change "<name>"
|
||||
```
|
||||
This creates a scaffolded change at `openspec/changes/<name>/`.
|
||||
|
||||
3. **Get the artifact build order**
|
||||
```bash
|
||||
openspec status --change "<name>" --json
|
||||
```
|
||||
Parse the JSON to get:
|
||||
- `applyRequires`: array of artifact IDs needed before implementation (e.g., `["tasks"]`)
|
||||
- `artifacts`: list of all artifacts with their status and dependencies
|
||||
|
||||
4. **Create artifacts in sequence until apply-ready**
|
||||
|
||||
Use the **TodoWrite tool** to track progress through the artifacts.
|
||||
|
||||
Loop through artifacts in dependency order (artifacts with no pending dependencies first):
|
||||
|
||||
a. **For each artifact that is `ready` (dependencies satisfied)**:
|
||||
- Get instructions:
|
||||
```bash
|
||||
openspec instructions <artifact-id> --change "<name>" --json
|
||||
```
|
||||
- The instructions JSON includes:
|
||||
- `context`: Project background (constraints for you - do NOT include in output)
|
||||
- `rules`: Artifact-specific rules (constraints for you - do NOT include in output)
|
||||
- `template`: The structure to use for your output file
|
||||
- `instruction`: Schema-specific guidance for this artifact type
|
||||
- `outputPath`: Where to write the artifact
|
||||
- `dependencies`: Completed artifacts to read for context
|
||||
- Read any completed dependency files for context
|
||||
- Create the artifact file using `template` as the structure
|
||||
- Apply `context` and `rules` as constraints - but do NOT copy them into the file
|
||||
- Show brief progress: "✓ Created <artifact-id>"
|
||||
|
||||
b. **Continue until all `applyRequires` artifacts are complete**
|
||||
- After creating each artifact, re-run `openspec status --change "<name>" --json`
|
||||
- Check if every artifact ID in `applyRequires` has `status: "done"` in the artifacts array
|
||||
- Stop when all `applyRequires` artifacts are done
|
||||
|
||||
c. **If an artifact requires user input** (unclear context):
|
||||
- Use **AskUserQuestion tool** to clarify
|
||||
- Then continue with creation
|
||||
|
||||
5. **Show final status**
|
||||
```bash
|
||||
openspec status --change "<name>"
|
||||
```
|
||||
|
||||
**Output**
|
||||
|
||||
After completing all artifacts, summarize:
|
||||
- Change name and location
|
||||
- List of artifacts created with brief descriptions
|
||||
- What's ready: "All artifacts created! Ready for implementation."
|
||||
- Prompt: "Run `/opsx:apply` or ask me to implement to start working on the tasks."
|
||||
|
||||
**Artifact Creation Guidelines**
|
||||
|
||||
- Follow the `instruction` field from `openspec instructions` for each artifact type
|
||||
- The schema defines what each artifact should contain - follow it
|
||||
- Read dependency artifacts for context before creating new ones
|
||||
- Use `template` as the structure for your output file - fill in its sections
|
||||
- **IMPORTANT**: `context` and `rules` are constraints for YOU, not content for the file
|
||||
- Do NOT copy `<context>`, `<rules>`, `<project_context>` blocks into the artifact
|
||||
- These guide what you write, but should never appear in the output
|
||||
|
||||
**Guardrails**
|
||||
- Create ALL artifacts needed for implementation (as defined by schema's `apply.requires`)
|
||||
- Always read dependency artifacts before creating a new one
|
||||
- If context is critically unclear, ask the user - but prefer making reasonable decisions to keep momentum
|
||||
- If a change with that name already exists, suggest continuing that change instead
|
||||
- Verify each artifact file exists after writing before proceeding to next
|
||||
@@ -0,0 +1,74 @@
|
||||
---
|
||||
name: openspec-new-change
|
||||
description: Start a new OpenSpec change using the experimental artifact workflow. Use when the user wants to create a new feature, fix, or modification with a structured step-by-step approach.
|
||||
license: MIT
|
||||
compatibility: Requires openspec CLI.
|
||||
metadata:
|
||||
author: openspec
|
||||
version: "1.0"
|
||||
generatedBy: "1.3.1"
|
||||
---
|
||||
|
||||
Start a new change using the experimental artifact-driven approach.
|
||||
|
||||
**Input**: The user's request should include a change name (kebab-case) OR a description of what they want to build.
|
||||
|
||||
**Steps**
|
||||
|
||||
1. **If no clear input provided, ask what they want to build**
|
||||
|
||||
Use the **AskUserQuestion tool** (open-ended, no preset options) to ask:
|
||||
> "What change do you want to work on? Describe what you want to build or fix."
|
||||
|
||||
From their description, derive a kebab-case name (e.g., "add user authentication" → `add-user-auth`).
|
||||
|
||||
**IMPORTANT**: Do NOT proceed without understanding what the user wants to build.
|
||||
|
||||
2. **Determine the workflow schema**
|
||||
|
||||
Use the default schema (omit `--schema`) unless the user explicitly requests a different workflow.
|
||||
|
||||
**Use a different schema only if the user mentions:**
|
||||
- A specific schema name → use `--schema <name>`
|
||||
- "show workflows" or "what workflows" → run `openspec schemas --json` and let them choose
|
||||
|
||||
**Otherwise**: Omit `--schema` to use the default.
|
||||
|
||||
3. **Create the change directory**
|
||||
```bash
|
||||
openspec new change "<name>"
|
||||
```
|
||||
Add `--schema <name>` only if the user requested a specific workflow.
|
||||
This creates a scaffolded change at `openspec/changes/<name>/` with the selected schema.
|
||||
|
||||
4. **Show the artifact status**
|
||||
```bash
|
||||
openspec status --change "<name>"
|
||||
```
|
||||
This shows which artifacts need to be created and which are ready (dependencies satisfied).
|
||||
|
||||
5. **Get instructions for the first artifact**
|
||||
The first artifact depends on the schema (e.g., `proposal` for spec-driven).
|
||||
Check the status output to find the first artifact with status "ready".
|
||||
```bash
|
||||
openspec instructions <first-artifact-id> --change "<name>"
|
||||
```
|
||||
This outputs the template and context for creating the first artifact.
|
||||
|
||||
6. **STOP and wait for user direction**
|
||||
|
||||
**Output**
|
||||
|
||||
After completing the steps, summarize:
|
||||
- Change name and location
|
||||
- Schema/workflow being used and its artifact sequence
|
||||
- Current status (0/N artifacts complete)
|
||||
- The template for the first artifact
|
||||
- Prompt: "Ready to create the first artifact? Just describe what this change is about and I'll draft it, or ask me to continue."
|
||||
|
||||
**Guardrails**
|
||||
- Do NOT create any artifacts yet - just show the instructions
|
||||
- Do NOT advance beyond showing the first artifact template
|
||||
- If the name is invalid (not kebab-case), ask for a valid name
|
||||
- If a change with that name already exists, suggest continuing that change instead
|
||||
- Pass --schema if using a non-default workflow
|
||||
@@ -0,0 +1,554 @@
|
||||
---
|
||||
name: openspec-onboard
|
||||
description: Guided onboarding for OpenSpec - walk through a complete workflow cycle with narration and real codebase work.
|
||||
license: MIT
|
||||
compatibility: Requires openspec CLI.
|
||||
metadata:
|
||||
author: openspec
|
||||
version: "1.0"
|
||||
generatedBy: "1.3.1"
|
||||
---
|
||||
|
||||
Guide the user through their first complete OpenSpec workflow cycle. This is a teaching experience—you'll do real work in their codebase while explaining each step.
|
||||
|
||||
---
|
||||
|
||||
## Preflight
|
||||
|
||||
Before starting, check if the OpenSpec CLI is installed:
|
||||
|
||||
```bash
|
||||
# Unix/macOS
|
||||
openspec --version 2>&1 || echo "CLI_NOT_INSTALLED"
|
||||
# Windows (PowerShell)
|
||||
# if (Get-Command openspec -ErrorAction SilentlyContinue) { openspec --version } else { echo "CLI_NOT_INSTALLED" }
|
||||
```
|
||||
|
||||
**If CLI not installed:**
|
||||
> OpenSpec CLI is not installed. Install it first, then come back to `/opsx:onboard`.
|
||||
|
||||
Stop here if not installed.
|
||||
|
||||
---
|
||||
|
||||
## Phase 1: Welcome
|
||||
|
||||
Display:
|
||||
|
||||
```
|
||||
## Welcome to OpenSpec!
|
||||
|
||||
I'll walk you through a complete change cycle—from idea to implementation—using a real task in your codebase. Along the way, you'll learn the workflow by doing it.
|
||||
|
||||
**What we'll do:**
|
||||
1. Pick a small, real task in your codebase
|
||||
2. Explore the problem briefly
|
||||
3. Create a change (the container for our work)
|
||||
4. Build the artifacts: proposal → specs → design → tasks
|
||||
5. Implement the tasks
|
||||
6. Archive the completed change
|
||||
|
||||
**Time:** ~15-20 minutes
|
||||
|
||||
Let's start by finding something to work on.
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Phase 2: Task Selection
|
||||
|
||||
### Codebase Analysis
|
||||
|
||||
Scan the codebase for small improvement opportunities. Look for:
|
||||
|
||||
1. **TODO/FIXME comments** - Search for `TODO`, `FIXME`, `HACK`, `XXX` in code files
|
||||
2. **Missing error handling** - `catch` blocks that swallow errors, risky operations without try-catch
|
||||
3. **Functions without tests** - Cross-reference `src/` with test directories
|
||||
4. **Type issues** - `any` types in TypeScript files (`: any`, `as any`)
|
||||
5. **Debug artifacts** - `console.log`, `console.debug`, `debugger` statements in non-debug code
|
||||
6. **Missing validation** - User input handlers without validation
|
||||
|
||||
Also check recent git activity:
|
||||
```bash
|
||||
# Unix/macOS
|
||||
git log --oneline -10 2>/dev/null || echo "No git history"
|
||||
# Windows (PowerShell)
|
||||
# git log --oneline -10 2>$null; if ($LASTEXITCODE -ne 0) { echo "No git history" }
|
||||
```
|
||||
|
||||
### Present Suggestions
|
||||
|
||||
From your analysis, present 3-4 specific suggestions:
|
||||
|
||||
```
|
||||
## Task Suggestions
|
||||
|
||||
Based on scanning your codebase, here are some good starter tasks:
|
||||
|
||||
**1. [Most promising task]**
|
||||
Location: `src/path/to/file.ts:42`
|
||||
Scope: ~1-2 files, ~20-30 lines
|
||||
Why it's good: [brief reason]
|
||||
|
||||
**2. [Second task]**
|
||||
Location: `src/another/file.ts`
|
||||
Scope: ~1 file, ~15 lines
|
||||
Why it's good: [brief reason]
|
||||
|
||||
**3. [Third task]**
|
||||
Location: [location]
|
||||
Scope: [estimate]
|
||||
Why it's good: [brief reason]
|
||||
|
||||
**4. Something else?**
|
||||
Tell me what you'd like to work on.
|
||||
|
||||
Which task interests you? (Pick a number or describe your own)
|
||||
```
|
||||
|
||||
**If nothing found:** Fall back to asking what the user wants to build:
|
||||
> I didn't find obvious quick wins in your codebase. What's something small you've been meaning to add or fix?
|
||||
|
||||
### Scope Guardrail
|
||||
|
||||
If the user picks or describes something too large (major feature, multi-day work):
|
||||
|
||||
```
|
||||
That's a valuable task, but it's probably larger than ideal for your first OpenSpec run-through.
|
||||
|
||||
For learning the workflow, smaller is better—it lets you see the full cycle without getting stuck in implementation details.
|
||||
|
||||
**Options:**
|
||||
1. **Slice it smaller** - What's the smallest useful piece of [their task]? Maybe just [specific slice]?
|
||||
2. **Pick something else** - One of the other suggestions, or a different small task?
|
||||
3. **Do it anyway** - If you really want to tackle this, we can. Just know it'll take longer.
|
||||
|
||||
What would you prefer?
|
||||
```
|
||||
|
||||
Let the user override if they insist—this is a soft guardrail.
|
||||
|
||||
---
|
||||
|
||||
## Phase 3: Explore Demo
|
||||
|
||||
Once a task is selected, briefly demonstrate explore mode:
|
||||
|
||||
```
|
||||
Before we create a change, let me quickly show you **explore mode**—it's how you think through problems before committing to a direction.
|
||||
```
|
||||
|
||||
Spend 1-2 minutes investigating the relevant code:
|
||||
- Read the file(s) involved
|
||||
- Draw a quick ASCII diagram if it helps
|
||||
- Note any considerations
|
||||
|
||||
```
|
||||
## Quick Exploration
|
||||
|
||||
[Your brief analysis—what you found, any considerations]
|
||||
|
||||
┌─────────────────────────────────────────┐
|
||||
│ [Optional: ASCII diagram if helpful] │
|
||||
└─────────────────────────────────────────┘
|
||||
|
||||
Explore mode (`/opsx:explore`) is for this kind of thinking—investigating before implementing. You can use it anytime you need to think through a problem.
|
||||
|
||||
Now let's create a change to hold our work.
|
||||
```
|
||||
|
||||
**PAUSE** - Wait for user acknowledgment before proceeding.
|
||||
|
||||
---
|
||||
|
||||
## Phase 4: Create the Change
|
||||
|
||||
**EXPLAIN:**
|
||||
```
|
||||
## Creating a Change
|
||||
|
||||
A "change" in OpenSpec is a container for all the thinking and planning around a piece of work. It lives in `openspec/changes/<name>/` and holds your artifacts—proposal, specs, design, tasks.
|
||||
|
||||
Let me create one for our task.
|
||||
```
|
||||
|
||||
**DO:** Create the change with a derived kebab-case name:
|
||||
```bash
|
||||
openspec new change "<derived-name>"
|
||||
```
|
||||
|
||||
**SHOW:**
|
||||
```
|
||||
Created: `openspec/changes/<name>/`
|
||||
|
||||
The folder structure:
|
||||
```
|
||||
openspec/changes/<name>/
|
||||
├── proposal.md ← Why we're doing this (empty, we'll fill it)
|
||||
├── design.md ← How we'll build it (empty)
|
||||
├── specs/ ← Detailed requirements (empty)
|
||||
└── tasks.md ← Implementation checklist (empty)
|
||||
```
|
||||
|
||||
Now let's fill in the first artifact—the proposal.
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Phase 5: Proposal
|
||||
|
||||
**EXPLAIN:**
|
||||
```
|
||||
## The Proposal
|
||||
|
||||
The proposal captures **why** we're making this change and **what** it involves at a high level. It's the "elevator pitch" for the work.
|
||||
|
||||
I'll draft one based on our task.
|
||||
```
|
||||
|
||||
**DO:** Draft the proposal content (don't save yet):
|
||||
|
||||
```
|
||||
Here's a draft proposal:
|
||||
|
||||
---
|
||||
|
||||
## Why
|
||||
|
||||
[1-2 sentences explaining the problem/opportunity]
|
||||
|
||||
## What Changes
|
||||
|
||||
[Bullet points of what will be different]
|
||||
|
||||
## Capabilities
|
||||
|
||||
### New Capabilities
|
||||
- `<capability-name>`: [brief description]
|
||||
|
||||
### Modified Capabilities
|
||||
<!-- If modifying existing behavior -->
|
||||
|
||||
## Impact
|
||||
|
||||
- `src/path/to/file.ts`: [what changes]
|
||||
- [other files if applicable]
|
||||
|
||||
---
|
||||
|
||||
Does this capture the intent? I can adjust before we save it.
|
||||
```
|
||||
|
||||
**PAUSE** - Wait for user approval/feedback.
|
||||
|
||||
After approval, save the proposal:
|
||||
```bash
|
||||
openspec instructions proposal --change "<name>" --json
|
||||
```
|
||||
Then write the content to `openspec/changes/<name>/proposal.md`.
|
||||
|
||||
```
|
||||
Proposal saved. This is your "why" document—you can always come back and refine it as understanding evolves.
|
||||
|
||||
Next up: specs.
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Phase 6: Specs
|
||||
|
||||
**EXPLAIN:**
|
||||
```
|
||||
## Specs
|
||||
|
||||
Specs define **what** we're building in precise, testable terms. They use a requirement/scenario format that makes expected behavior crystal clear.
|
||||
|
||||
For a small task like this, we might only need one spec file.
|
||||
```
|
||||
|
||||
**DO:** Create the spec file:
|
||||
```bash
|
||||
# Unix/macOS
|
||||
mkdir -p openspec/changes/<name>/specs/<capability-name>
|
||||
# Windows (PowerShell)
|
||||
# New-Item -ItemType Directory -Force -Path "openspec/changes/<name>/specs/<capability-name>"
|
||||
```
|
||||
|
||||
Draft the spec content:
|
||||
|
||||
```
|
||||
Here's the spec:
|
||||
|
||||
---
|
||||
|
||||
## ADDED Requirements
|
||||
|
||||
### Requirement: <Name>
|
||||
|
||||
<Description of what the system should do>
|
||||
|
||||
#### Scenario: <Scenario name>
|
||||
|
||||
- **WHEN** <trigger condition>
|
||||
- **THEN** <expected outcome>
|
||||
- **AND** <additional outcome if needed>
|
||||
|
||||
---
|
||||
|
||||
This format—WHEN/THEN/AND—makes requirements testable. You can literally read them as test cases.
|
||||
```
|
||||
|
||||
Save to `openspec/changes/<name>/specs/<capability>/spec.md`.
|
||||
|
||||
---
|
||||
|
||||
## Phase 7: Design
|
||||
|
||||
**EXPLAIN:**
|
||||
```
|
||||
## Design
|
||||
|
||||
The design captures **how** we'll build it—technical decisions, tradeoffs, approach.
|
||||
|
||||
For small changes, this might be brief. That's fine—not every change needs deep design discussion.
|
||||
```
|
||||
|
||||
**DO:** Draft design.md:
|
||||
|
||||
```
|
||||
Here's the design:
|
||||
|
||||
---
|
||||
|
||||
## Context
|
||||
|
||||
[Brief context about the current state]
|
||||
|
||||
## Goals / Non-Goals
|
||||
|
||||
**Goals:**
|
||||
- [What we're trying to achieve]
|
||||
|
||||
**Non-Goals:**
|
||||
- [What's explicitly out of scope]
|
||||
|
||||
## Decisions
|
||||
|
||||
### Decision 1: [Key decision]
|
||||
|
||||
[Explanation of approach and rationale]
|
||||
|
||||
---
|
||||
|
||||
For a small task, this captures the key decisions without over-engineering.
|
||||
```
|
||||
|
||||
Save to `openspec/changes/<name>/design.md`.
|
||||
|
||||
---
|
||||
|
||||
## Phase 8: Tasks
|
||||
|
||||
**EXPLAIN:**
|
||||
```
|
||||
## Tasks
|
||||
|
||||
Finally, we break the work into implementation tasks—checkboxes that drive the apply phase.
|
||||
|
||||
These should be small, clear, and in logical order.
|
||||
```
|
||||
|
||||
**DO:** Generate tasks based on specs and design:
|
||||
|
||||
```
|
||||
Here are the implementation tasks:
|
||||
|
||||
---
|
||||
|
||||
## 1. [Category or file]
|
||||
|
||||
- [ ] 1.1 [Specific task]
|
||||
- [ ] 1.2 [Specific task]
|
||||
|
||||
## 2. Verify
|
||||
|
||||
- [ ] 2.1 [Verification step]
|
||||
|
||||
---
|
||||
|
||||
Each checkbox becomes a unit of work in the apply phase. Ready to implement?
|
||||
```
|
||||
|
||||
**PAUSE** - Wait for user to confirm they're ready to implement.
|
||||
|
||||
Save to `openspec/changes/<name>/tasks.md`.
|
||||
|
||||
---
|
||||
|
||||
## Phase 9: Apply (Implementation)
|
||||
|
||||
**EXPLAIN:**
|
||||
```
|
||||
## Implementation
|
||||
|
||||
Now we implement each task, checking them off as we go. I'll announce each one and occasionally note how the specs/design informed the approach.
|
||||
```
|
||||
|
||||
**DO:** For each task:
|
||||
|
||||
1. Announce: "Working on task N: [description]"
|
||||
2. Implement the change in the codebase
|
||||
3. Reference specs/design naturally: "The spec says X, so I'm doing Y"
|
||||
4. Mark complete in tasks.md: `- [ ]` → `- [x]`
|
||||
5. Brief status: "✓ Task N complete"
|
||||
|
||||
Keep narration light—don't over-explain every line of code.
|
||||
|
||||
After all tasks:
|
||||
|
||||
```
|
||||
## Implementation Complete
|
||||
|
||||
All tasks done:
|
||||
- [x] Task 1
|
||||
- [x] Task 2
|
||||
- [x] ...
|
||||
|
||||
The change is implemented! One more step—let's archive it.
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Phase 10: Archive
|
||||
|
||||
**EXPLAIN:**
|
||||
```
|
||||
## Archiving
|
||||
|
||||
When a change is complete, we archive it. This moves it from `openspec/changes/` to `openspec/changes/archive/YYYY-MM-DD-<name>/`.
|
||||
|
||||
Archived changes become your project's decision history—you can always find them later to understand why something was built a certain way.
|
||||
```
|
||||
|
||||
**DO:**
|
||||
```bash
|
||||
openspec archive "<name>"
|
||||
```
|
||||
|
||||
**SHOW:**
|
||||
```
|
||||
Archived to: `openspec/changes/archive/YYYY-MM-DD-<name>/`
|
||||
|
||||
The change is now part of your project's history. The code is in your codebase, the decision record is preserved.
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Phase 11: Recap & Next Steps
|
||||
|
||||
```
|
||||
## Congratulations!
|
||||
|
||||
You just completed a full OpenSpec cycle:
|
||||
|
||||
1. **Explore** - Thought through the problem
|
||||
2. **New** - Created a change container
|
||||
3. **Proposal** - Captured WHY
|
||||
4. **Specs** - Defined WHAT in detail
|
||||
5. **Design** - Decided HOW
|
||||
6. **Tasks** - Broke it into steps
|
||||
7. **Apply** - Implemented the work
|
||||
8. **Archive** - Preserved the record
|
||||
|
||||
This same rhythm works for any size change—a small fix or a major feature.
|
||||
|
||||
---
|
||||
|
||||
## Command Reference
|
||||
|
||||
**Core workflow:**
|
||||
|
||||
| Command | What it does |
|
||||
|-------------------|--------------------------------------------|
|
||||
| `/opsx:propose` | Create a change and generate all artifacts |
|
||||
| `/opsx:explore` | Think through problems before/during work |
|
||||
| `/opsx:apply` | Implement tasks from a change |
|
||||
| `/opsx:archive` | Archive a completed change |
|
||||
|
||||
**Additional commands:**
|
||||
|
||||
| Command | What it does |
|
||||
|--------------------|----------------------------------------------------------|
|
||||
| `/opsx:new` | Start a new change, step through artifacts one at a time |
|
||||
| `/opsx:continue` | Continue working on an existing change |
|
||||
| `/opsx:ff` | Fast-forward: create all artifacts at once |
|
||||
| `/opsx:verify` | Verify implementation matches artifacts |
|
||||
|
||||
---
|
||||
|
||||
## What's Next?
|
||||
|
||||
Try `/opsx:propose` on something you actually want to build. You've got the rhythm now!
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Graceful Exit Handling
|
||||
|
||||
### User wants to stop mid-way
|
||||
|
||||
If the user says they need to stop, want to pause, or seem disengaged:
|
||||
|
||||
```
|
||||
No problem! Your change is saved at `openspec/changes/<name>/`.
|
||||
|
||||
To pick up where we left off later:
|
||||
- `/opsx:continue <name>` - Resume artifact creation
|
||||
- `/opsx:apply <name>` - Jump to implementation (if tasks exist)
|
||||
|
||||
The work won't be lost. Come back whenever you're ready.
|
||||
```
|
||||
|
||||
Exit gracefully without pressure.
|
||||
|
||||
### User just wants command reference
|
||||
|
||||
If the user says they just want to see the commands or skip the tutorial:
|
||||
|
||||
```
|
||||
## OpenSpec Quick Reference
|
||||
|
||||
**Core workflow:**
|
||||
|
||||
| Command | What it does |
|
||||
|--------------------------|--------------------------------------------|
|
||||
| `/opsx:propose <name>` | Create a change and generate all artifacts |
|
||||
| `/opsx:explore` | Think through problems (no code changes) |
|
||||
| `/opsx:apply <name>` | Implement tasks |
|
||||
| `/opsx:archive <name>` | Archive when done |
|
||||
|
||||
**Additional commands:**
|
||||
|
||||
| Command | What it does |
|
||||
|---------------------------|-------------------------------------|
|
||||
| `/opsx:new <name>` | Start a new change, step by step |
|
||||
| `/opsx:continue <name>` | Continue an existing change |
|
||||
| `/opsx:ff <name>` | Fast-forward: all artifacts at once |
|
||||
| `/opsx:verify <name>` | Verify implementation |
|
||||
|
||||
Try `/opsx:propose` to start your first change.
|
||||
```
|
||||
|
||||
Exit gracefully.
|
||||
|
||||
---
|
||||
|
||||
## Guardrails
|
||||
|
||||
- **Follow the EXPLAIN → DO → SHOW → PAUSE pattern** at key transitions (after explore, after proposal draft, after tasks, after archive)
|
||||
- **Keep narration light** during implementation—teach without lecturing
|
||||
- **Don't skip phases** even if the change is small—the goal is teaching the workflow
|
||||
- **Pause for acknowledgment** at marked points, but don't over-pause
|
||||
- **Handle exits gracefully**—never pressure the user to continue
|
||||
- **Use real codebase tasks**—don't simulate or use fake examples
|
||||
- **Adjust scope gently**—guide toward smaller tasks but respect user choice
|
||||
@@ -0,0 +1,110 @@
|
||||
---
|
||||
name: openspec-propose
|
||||
description: Propose a new change with all artifacts generated in one step. Use when the user wants to quickly describe what they want to build and get a complete proposal with design, specs, and tasks ready for implementation.
|
||||
license: MIT
|
||||
compatibility: Requires openspec CLI.
|
||||
metadata:
|
||||
author: openspec
|
||||
version: "1.0"
|
||||
generatedBy: "1.3.1"
|
||||
---
|
||||
|
||||
Propose a new change - create the change and generate all artifacts in one step.
|
||||
|
||||
I'll create a change with artifacts:
|
||||
- proposal.md (what & why)
|
||||
- design.md (how)
|
||||
- tasks.md (implementation steps)
|
||||
|
||||
When ready to implement, run /opsx:apply
|
||||
|
||||
---
|
||||
|
||||
**Input**: The user's request should include a change name (kebab-case) OR a description of what they want to build.
|
||||
|
||||
**Steps**
|
||||
|
||||
1. **If no clear input provided, ask what they want to build**
|
||||
|
||||
Use the **AskUserQuestion tool** (open-ended, no preset options) to ask:
|
||||
> "What change do you want to work on? Describe what you want to build or fix."
|
||||
|
||||
From their description, derive a kebab-case name (e.g., "add user authentication" → `add-user-auth`).
|
||||
|
||||
**IMPORTANT**: Do NOT proceed without understanding what the user wants to build.
|
||||
|
||||
2. **Create the change directory**
|
||||
```bash
|
||||
openspec new change "<name>"
|
||||
```
|
||||
This creates a scaffolded change at `openspec/changes/<name>/` with `.openspec.yaml`.
|
||||
|
||||
3. **Get the artifact build order**
|
||||
```bash
|
||||
openspec status --change "<name>" --json
|
||||
```
|
||||
Parse the JSON to get:
|
||||
- `applyRequires`: array of artifact IDs needed before implementation (e.g., `["tasks"]`)
|
||||
- `artifacts`: list of all artifacts with their status and dependencies
|
||||
|
||||
4. **Create artifacts in sequence until apply-ready**
|
||||
|
||||
Use the **TodoWrite tool** to track progress through the artifacts.
|
||||
|
||||
Loop through artifacts in dependency order (artifacts with no pending dependencies first):
|
||||
|
||||
a. **For each artifact that is `ready` (dependencies satisfied)**:
|
||||
- Get instructions:
|
||||
```bash
|
||||
openspec instructions <artifact-id> --change "<name>" --json
|
||||
```
|
||||
- The instructions JSON includes:
|
||||
- `context`: Project background (constraints for you - do NOT include in output)
|
||||
- `rules`: Artifact-specific rules (constraints for you - do NOT include in output)
|
||||
- `template`: The structure to use for your output file
|
||||
- `instruction`: Schema-specific guidance for this artifact type
|
||||
- `outputPath`: Where to write the artifact
|
||||
- `dependencies`: Completed artifacts to read for context
|
||||
- Read any completed dependency files for context
|
||||
- Create the artifact file using `template` as the structure
|
||||
- Apply `context` and `rules` as constraints - but do NOT copy them into the file
|
||||
- Show brief progress: "Created <artifact-id>"
|
||||
|
||||
b. **Continue until all `applyRequires` artifacts are complete**
|
||||
- After creating each artifact, re-run `openspec status --change "<name>" --json`
|
||||
- Check if every artifact ID in `applyRequires` has `status: "done"` in the artifacts array
|
||||
- Stop when all `applyRequires` artifacts are done
|
||||
|
||||
c. **If an artifact requires user input** (unclear context):
|
||||
- Use **AskUserQuestion tool** to clarify
|
||||
- Then continue with creation
|
||||
|
||||
5. **Show final status**
|
||||
```bash
|
||||
openspec status --change "<name>"
|
||||
```
|
||||
|
||||
**Output**
|
||||
|
||||
After completing all artifacts, summarize:
|
||||
- Change name and location
|
||||
- List of artifacts created with brief descriptions
|
||||
- What's ready: "All artifacts created! Ready for implementation."
|
||||
- Prompt: "Run `/opsx:apply` or ask me to implement to start working on the tasks."
|
||||
|
||||
**Artifact Creation Guidelines**
|
||||
|
||||
- Follow the `instruction` field from `openspec instructions` for each artifact type
|
||||
- The schema defines what each artifact should contain - follow it
|
||||
- Read dependency artifacts for context before creating new ones
|
||||
- Use `template` as the structure for your output file - fill in its sections
|
||||
- **IMPORTANT**: `context` and `rules` are constraints for YOU, not content for the file
|
||||
- Do NOT copy `<context>`, `<rules>`, `<project_context>` blocks into the artifact
|
||||
- These guide what you write, but should never appear in the output
|
||||
|
||||
**Guardrails**
|
||||
- Create ALL artifacts needed for implementation (as defined by schema's `apply.requires`)
|
||||
- Always read dependency artifacts before creating a new one
|
||||
- If context is critically unclear, ask the user - but prefer making reasonable decisions to keep momentum
|
||||
- If a change with that name already exists, ask if user wants to continue it or create a new one
|
||||
- Verify each artifact file exists after writing before proceeding to next
|
||||
@@ -0,0 +1,138 @@
|
||||
---
|
||||
name: openspec-sync-specs
|
||||
description: Sync delta specs from a change to main specs. Use when the user wants to update main specs with changes from a delta spec, without archiving the change.
|
||||
license: MIT
|
||||
compatibility: Requires openspec CLI.
|
||||
metadata:
|
||||
author: openspec
|
||||
version: "1.0"
|
||||
generatedBy: "1.3.1"
|
||||
---
|
||||
|
||||
Sync delta specs from a change to main specs.
|
||||
|
||||
This is an **agent-driven** operation - you will read delta specs and directly edit main specs to apply the changes. This allows intelligent merging (e.g., adding a scenario without copying the entire requirement).
|
||||
|
||||
**Input**: Optionally specify a change name. If omitted, check if it can be inferred from conversation context. If vague or ambiguous you MUST prompt for available changes.
|
||||
|
||||
**Steps**
|
||||
|
||||
1. **If no change name provided, prompt for selection**
|
||||
|
||||
Run `openspec list --json` to get available changes. Use the **AskUserQuestion tool** to let the user select.
|
||||
|
||||
Show changes that have delta specs (under `specs/` directory).
|
||||
|
||||
**IMPORTANT**: Do NOT guess or auto-select a change. Always let the user choose.
|
||||
|
||||
2. **Find delta specs**
|
||||
|
||||
Look for delta spec files in `openspec/changes/<name>/specs/*/spec.md`.
|
||||
|
||||
Each delta spec file contains sections like:
|
||||
- `## ADDED Requirements` - New requirements to add
|
||||
- `## MODIFIED Requirements` - Changes to existing requirements
|
||||
- `## REMOVED Requirements` - Requirements to remove
|
||||
- `## RENAMED Requirements` - Requirements to rename (FROM:/TO: format)
|
||||
|
||||
If no delta specs found, inform user and stop.
|
||||
|
||||
3. **For each delta spec, apply changes to main specs**
|
||||
|
||||
For each capability with a delta spec at `openspec/changes/<name>/specs/<capability>/spec.md`:
|
||||
|
||||
a. **Read the delta spec** to understand the intended changes
|
||||
|
||||
b. **Read the main spec** at `openspec/specs/<capability>/spec.md` (may not exist yet)
|
||||
|
||||
c. **Apply changes intelligently**:
|
||||
|
||||
**ADDED Requirements:**
|
||||
- If requirement doesn't exist in main spec → add it
|
||||
- If requirement already exists → update it to match (treat as implicit MODIFIED)
|
||||
|
||||
**MODIFIED Requirements:**
|
||||
- Find the requirement in main spec
|
||||
- Apply the changes - this can be:
|
||||
- Adding new scenarios (don't need to copy existing ones)
|
||||
- Modifying existing scenarios
|
||||
- Changing the requirement description
|
||||
- Preserve scenarios/content not mentioned in the delta
|
||||
|
||||
**REMOVED Requirements:**
|
||||
- Remove the entire requirement block from main spec
|
||||
|
||||
**RENAMED Requirements:**
|
||||
- Find the FROM requirement, rename to TO
|
||||
|
||||
d. **Create new main spec** if capability doesn't exist yet:
|
||||
- Create `openspec/specs/<capability>/spec.md`
|
||||
- Add Purpose section (can be brief, mark as TBD)
|
||||
- Add Requirements section with the ADDED requirements
|
||||
|
||||
4. **Show summary**
|
||||
|
||||
After applying all changes, summarize:
|
||||
- Which capabilities were updated
|
||||
- What changes were made (requirements added/modified/removed/renamed)
|
||||
|
||||
**Delta Spec Format Reference**
|
||||
|
||||
```markdown
|
||||
## ADDED Requirements
|
||||
|
||||
### Requirement: New Feature
|
||||
The system SHALL do something new.
|
||||
|
||||
#### Scenario: Basic case
|
||||
- **WHEN** user does X
|
||||
- **THEN** system does Y
|
||||
|
||||
## MODIFIED Requirements
|
||||
|
||||
### Requirement: Existing Feature
|
||||
#### Scenario: New scenario to add
|
||||
- **WHEN** user does A
|
||||
- **THEN** system does B
|
||||
|
||||
## REMOVED Requirements
|
||||
|
||||
### Requirement: Deprecated Feature
|
||||
|
||||
## RENAMED Requirements
|
||||
|
||||
- FROM: `### Requirement: Old Name`
|
||||
- TO: `### Requirement: New Name`
|
||||
```
|
||||
|
||||
**Key Principle: Intelligent Merging**
|
||||
|
||||
Unlike programmatic merging, you can apply **partial updates**:
|
||||
- To add a scenario, just include that scenario under MODIFIED - don't copy existing scenarios
|
||||
- The delta represents *intent*, not a wholesale replacement
|
||||
- Use your judgment to merge changes sensibly
|
||||
|
||||
**Output On Success**
|
||||
|
||||
```
|
||||
## Specs Synced: <change-name>
|
||||
|
||||
Updated main specs:
|
||||
|
||||
**<capability-1>**:
|
||||
- Added requirement: "New Feature"
|
||||
- Modified requirement: "Existing Feature" (added 1 scenario)
|
||||
|
||||
**<capability-2>**:
|
||||
- Created new spec file
|
||||
- Added requirement: "Another Feature"
|
||||
|
||||
Main specs are now updated. The change remains active - archive when implementation is complete.
|
||||
```
|
||||
|
||||
**Guardrails**
|
||||
- Read both delta and main specs before making changes
|
||||
- Preserve existing content not mentioned in delta
|
||||
- If something is unclear, ask for clarification
|
||||
- Show what you're changing as you go
|
||||
- The operation should be idempotent - running twice should give same result
|
||||
@@ -0,0 +1,168 @@
|
||||
---
|
||||
name: openspec-verify-change
|
||||
description: Verify implementation matches change artifacts. Use when the user wants to validate that implementation is complete, correct, and coherent before archiving.
|
||||
license: MIT
|
||||
compatibility: Requires openspec CLI.
|
||||
metadata:
|
||||
author: openspec
|
||||
version: "1.0"
|
||||
generatedBy: "1.3.1"
|
||||
---
|
||||
|
||||
Verify that an implementation matches the change artifacts (specs, tasks, design).
|
||||
|
||||
**Input**: Optionally specify a change name. If omitted, check if it can be inferred from conversation context. If vague or ambiguous you MUST prompt for available changes.
|
||||
|
||||
**Steps**
|
||||
|
||||
1. **If no change name provided, prompt for selection**
|
||||
|
||||
Run `openspec list --json` to get available changes. Use the **AskUserQuestion tool** to let the user select.
|
||||
|
||||
Show changes that have implementation tasks (tasks artifact exists).
|
||||
Include the schema used for each change if available.
|
||||
Mark changes with incomplete tasks as "(In Progress)".
|
||||
|
||||
**IMPORTANT**: Do NOT guess or auto-select a change. Always let the user choose.
|
||||
|
||||
2. **Check status to understand the schema**
|
||||
```bash
|
||||
openspec status --change "<name>" --json
|
||||
```
|
||||
Parse the JSON to understand:
|
||||
- `schemaName`: The workflow being used (e.g., "spec-driven")
|
||||
- Which artifacts exist for this change
|
||||
|
||||
3. **Get the change directory and load artifacts**
|
||||
|
||||
```bash
|
||||
openspec instructions apply --change "<name>" --json
|
||||
```
|
||||
|
||||
This returns the change directory and `contextFiles` (artifact ID -> array of concrete file paths). Read all available artifacts from `contextFiles`.
|
||||
|
||||
4. **Initialize verification report structure**
|
||||
|
||||
Create a report structure with three dimensions:
|
||||
- **Completeness**: Track tasks and spec coverage
|
||||
- **Correctness**: Track requirement implementation and scenario coverage
|
||||
- **Coherence**: Track design adherence and pattern consistency
|
||||
|
||||
Each dimension can have CRITICAL, WARNING, or SUGGESTION issues.
|
||||
|
||||
5. **Verify Completeness**
|
||||
|
||||
**Task Completion**:
|
||||
- If `contextFiles.tasks` exists, read every file path in it
|
||||
- Parse checkboxes: `- [ ]` (incomplete) vs `- [x]` (complete)
|
||||
- Count complete vs total tasks
|
||||
- If incomplete tasks exist:
|
||||
- Add CRITICAL issue for each incomplete task
|
||||
- Recommendation: "Complete task: <description>" or "Mark as done if already implemented"
|
||||
|
||||
**Spec Coverage**:
|
||||
- If delta specs exist in `openspec/changes/<name>/specs/`:
|
||||
- Extract all requirements (marked with "### Requirement:")
|
||||
- For each requirement:
|
||||
- Search codebase for keywords related to the requirement
|
||||
- Assess if implementation likely exists
|
||||
- If requirements appear unimplemented:
|
||||
- Add CRITICAL issue: "Requirement not found: <requirement name>"
|
||||
- Recommendation: "Implement requirement X: <description>"
|
||||
|
||||
6. **Verify Correctness**
|
||||
|
||||
**Requirement Implementation Mapping**:
|
||||
- For each requirement from delta specs:
|
||||
- Search codebase for implementation evidence
|
||||
- If found, note file paths and line ranges
|
||||
- Assess if implementation matches requirement intent
|
||||
- If divergence detected:
|
||||
- Add WARNING: "Implementation may diverge from spec: <details>"
|
||||
- Recommendation: "Review <file>:<lines> against requirement X"
|
||||
|
||||
**Scenario Coverage**:
|
||||
- For each scenario in delta specs (marked with "#### Scenario:"):
|
||||
- Check if conditions are handled in code
|
||||
- Check if tests exist covering the scenario
|
||||
- If scenario appears uncovered:
|
||||
- Add WARNING: "Scenario not covered: <scenario name>"
|
||||
- Recommendation: "Add test or implementation for scenario: <description>"
|
||||
|
||||
7. **Verify Coherence**
|
||||
|
||||
**Design Adherence**:
|
||||
- If `contextFiles.design` exists:
|
||||
- Extract key decisions (look for sections like "Decision:", "Approach:", "Architecture:")
|
||||
- Verify implementation follows those decisions
|
||||
- If contradiction detected:
|
||||
- Add WARNING: "Design decision not followed: <decision>"
|
||||
- Recommendation: "Update implementation or revise design.md to match reality"
|
||||
- If no design.md: Skip design adherence check, note "No design.md to verify against"
|
||||
|
||||
**Code Pattern Consistency**:
|
||||
- Review new code for consistency with project patterns
|
||||
- Check file naming, directory structure, coding style
|
||||
- If significant deviations found:
|
||||
- Add SUGGESTION: "Code pattern deviation: <details>"
|
||||
- Recommendation: "Consider following project pattern: <example>"
|
||||
|
||||
8. **Generate Verification Report**
|
||||
|
||||
**Summary Scorecard**:
|
||||
```
|
||||
## Verification Report: <change-name>
|
||||
|
||||
### Summary
|
||||
| Dimension | Status |
|
||||
|--------------|------------------|
|
||||
| Completeness | X/Y tasks, N reqs|
|
||||
| Correctness | M/N reqs covered |
|
||||
| Coherence | Followed/Issues |
|
||||
```
|
||||
|
||||
**Issues by Priority**:
|
||||
|
||||
1. **CRITICAL** (Must fix before archive):
|
||||
- Incomplete tasks
|
||||
- Missing requirement implementations
|
||||
- Each with specific, actionable recommendation
|
||||
|
||||
2. **WARNING** (Should fix):
|
||||
- Spec/design divergences
|
||||
- Missing scenario coverage
|
||||
- Each with specific recommendation
|
||||
|
||||
3. **SUGGESTION** (Nice to fix):
|
||||
- Pattern inconsistencies
|
||||
- Minor improvements
|
||||
- Each with specific recommendation
|
||||
|
||||
**Final Assessment**:
|
||||
- If CRITICAL issues: "X critical issue(s) found. Fix before archiving."
|
||||
- If only warnings: "No critical issues. Y warning(s) to consider. Ready for archive (with noted improvements)."
|
||||
- If all clear: "All checks passed. Ready for archive."
|
||||
|
||||
**Verification Heuristics**
|
||||
|
||||
- **Completeness**: Focus on objective checklist items (checkboxes, requirements list)
|
||||
- **Correctness**: Use keyword search, file path analysis, reasonable inference - don't require perfect certainty
|
||||
- **Coherence**: Look for glaring inconsistencies, don't nitpick style
|
||||
- **False Positives**: When uncertain, prefer SUGGESTION over WARNING, WARNING over CRITICAL
|
||||
- **Actionability**: Every issue must have a specific recommendation with file/line references where applicable
|
||||
|
||||
**Graceful Degradation**
|
||||
|
||||
- If only tasks.md exists: verify task completion only, skip spec/design checks
|
||||
- If tasks + specs exist: verify completeness and correctness, skip design
|
||||
- If full artifacts: verify all three dimensions
|
||||
- Always note which checks were skipped and why
|
||||
|
||||
**Output Format**
|
||||
|
||||
Use clear markdown with:
|
||||
- Table for summary scorecard
|
||||
- Grouped lists for issues (CRITICAL/WARNING/SUGGESTION)
|
||||
- Code references in format: `file.ts:123`
|
||||
- Specific, actionable recommendations
|
||||
- No vague suggestions like "consider reviewing"
|
||||
@@ -0,0 +1,27 @@
|
||||
name: Sync fork
|
||||
|
||||
on:
|
||||
workflow_dispatch:
|
||||
schedule:
|
||||
# 10:00 Asia/Shanghai every day (GitHub Actions cron uses UTC).
|
||||
- cron: "0 2 * * *"
|
||||
|
||||
permissions:
|
||||
contents: write
|
||||
|
||||
concurrency:
|
||||
group: sync-fork-main
|
||||
cancel-in-progress: false
|
||||
|
||||
jobs:
|
||||
sync-main:
|
||||
if: github.repository == 'codychen123/PolyHermes'
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Sync fork main from upstream
|
||||
run: |
|
||||
gh repo sync "$GITHUB_REPOSITORY" \
|
||||
--source WrBug/PolyHermes \
|
||||
--branch main
|
||||
env:
|
||||
GH_TOKEN: ${{ github.token }}
|
||||
+3
-1
@@ -95,6 +95,7 @@ coverage/
|
||||
# Test
|
||||
test-results/
|
||||
*.test.log
|
||||
.playwright-cli/
|
||||
|
||||
# Python
|
||||
__pycache__/
|
||||
@@ -113,4 +114,5 @@ clob-client/
|
||||
builder-relayer-client/
|
||||
landing-page/
|
||||
clob-client-v2/
|
||||
settings.local.json
|
||||
settings.local.json
|
||||
.gstack/
|
||||
|
||||
+12
-1
@@ -100,6 +100,17 @@ RUN if [ "$BUILD_IN_DOCKER" = "true" ]; then \
|
||||
fi; \
|
||||
fi
|
||||
|
||||
# 统一选出可执行 JAR,避免 *-plain.jar 和 bootJar 同时存在导致最终 COPY 匹配多个文件
|
||||
RUN set -e; \
|
||||
JAR="$(find build/libs -maxdepth 1 -type f -name '*.jar' ! -name '*-plain.jar' | head -n 1)"; \
|
||||
if [ -z "$JAR" ]; then \
|
||||
echo "❌ 错误:找不到可执行 JAR(已排除 *-plain.jar)"; \
|
||||
exit 1; \
|
||||
fi; \
|
||||
if [ "$JAR" != "build/libs/app.jar" ]; then \
|
||||
cp "$JAR" build/libs/app.jar; \
|
||||
fi
|
||||
|
||||
# ==================== 阶段3:运行环境 ====================
|
||||
FROM eclipse-temurin:17-jre-jammy
|
||||
|
||||
@@ -114,7 +125,7 @@ RUN apt-get update && \
|
||||
# 从构建阶段复制文件
|
||||
# 当 BUILD_IN_DOCKER=false 时,构建阶段已经复制了外部产物
|
||||
COPY --from=frontend-build /app/frontend/dist /usr/share/nginx/html
|
||||
COPY --from=backend-build /app/backend/build/libs/*.jar app.jar
|
||||
COPY --from=backend-build /app/backend/build/libs/app.jar app.jar
|
||||
|
||||
# 复制 Nginx 配置
|
||||
COPY docker/nginx.conf /etc/nginx/nginx.conf
|
||||
|
||||
@@ -421,6 +421,8 @@ cd frontend
|
||||
- [开发文档](docs/zh/DEVELOPMENT.md) - 开发指南
|
||||
- [跟单系统需求文档](docs/zh/copy-trading-requirements.md) - 后端 API 接口文档
|
||||
- [前端需求文档](docs/zh/copy-trading-frontend-requirements.md) - 前端功能文档
|
||||
- [跟单亏损诊断与风险安全带](docs/zh/copy-trading-risk-seatbelt.md) - PnL 诊断、安全带确认流程和运维排查命令
|
||||
- [Leader Research Agent](docs/zh/leader-research-agent.md) - 自动发现、纸跟、评分和禁用试跟审批说明
|
||||
- [动态更新文档](docs/zh/DYNAMIC_UPDATE.md) - 动态更新功能说明
|
||||
|
||||
### 🤝 贡献指南
|
||||
|
||||
+1
-1
@@ -420,6 +420,7 @@ The development documentation includes:
|
||||
- [Development Documentation](docs/en/DEVELOPMENT.md) - Development guide
|
||||
- [Copy Trading System Requirements](docs/zh/copy-trading-requirements.md) - Backend API documentation (Chinese only)
|
||||
- [Frontend Requirements](docs/zh/copy-trading-frontend-requirements.md) - Frontend feature documentation (Chinese only)
|
||||
- [Leader Research Agent](docs/zh/leader-research-agent.md) - Auto-discovery, paper trading, scoring, and disabled-trial approval guide (Chinese only)
|
||||
|
||||
### 🤝 Contributing
|
||||
|
||||
@@ -467,4 +468,3 @@ Thanks to all developers and users who have contributed to this project!
|
||||
---
|
||||
|
||||
**⭐ If this project helps you, please give it a Star!**
|
||||
|
||||
|
||||
+37
-1
@@ -112,6 +112,43 @@ class CopyTradingController(
|
||||
ResponseEntity.ok(ApiResponse.error(ErrorCode.SERVER_COPY_TRADING_UPDATE_FAILED, e.message, messageSource))
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 应用诊断建议的保守风控配置。
|
||||
*/
|
||||
@PostMapping("/apply-conservative-config")
|
||||
fun applyConservativeConfig(@RequestBody request: ApplyConservativeConfigRequest): ResponseEntity<ApiResponse<CopyTradingDto>> {
|
||||
return try {
|
||||
if (request.copyTradingId <= 0) {
|
||||
return ResponseEntity.ok(ApiResponse.error(ErrorCode.PARAM_COPY_TRADING_ID_INVALID, messageSource = messageSource))
|
||||
}
|
||||
|
||||
val result = copyTradingService.applyConservativeConfig(request)
|
||||
result.fold(
|
||||
onSuccess = { copyTrading ->
|
||||
ResponseEntity.ok(ApiResponse.success(copyTrading))
|
||||
},
|
||||
onFailure = { e ->
|
||||
logger.error("应用保守配置失败: ${e.message}", e)
|
||||
when (e) {
|
||||
is IllegalArgumentException -> {
|
||||
val errorCode = if (e.message == "跟单配置不存在") {
|
||||
ErrorCode.COPY_TRADING_NOT_FOUND
|
||||
} else {
|
||||
ErrorCode.PARAM_ERROR
|
||||
}
|
||||
ResponseEntity.ok(ApiResponse.error(errorCode, e.message, messageSource))
|
||||
}
|
||||
is IllegalStateException -> ResponseEntity.ok(ApiResponse.error(ErrorCode.BUSINESS_ERROR, e.message, messageSource))
|
||||
else -> ResponseEntity.ok(ApiResponse.error(ErrorCode.SERVER_COPY_TRADING_UPDATE_FAILED, e.message, messageSource))
|
||||
}
|
||||
}
|
||||
)
|
||||
} catch (e: Exception) {
|
||||
logger.error("应用保守配置异常: ${e.message}", e)
|
||||
ResponseEntity.ok(ApiResponse.error(ErrorCode.SERVER_COPY_TRADING_UPDATE_FAILED, e.message, messageSource))
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 更新跟单状态(兼容旧接口)
|
||||
@@ -219,4 +256,3 @@ class CopyTradingController(
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
+175
@@ -0,0 +1,175 @@
|
||||
package com.wrbug.polymarketbot.controller.copytrading.leaderpool
|
||||
|
||||
import com.wrbug.polymarketbot.dto.*
|
||||
import com.wrbug.polymarketbot.enums.ErrorCode
|
||||
import com.wrbug.polymarketbot.service.copytrading.leaderpool.LeaderPoolAlreadyExistsException
|
||||
import com.wrbug.polymarketbot.service.copytrading.leaderpool.LeaderPoolConfirmRequiredException
|
||||
import com.wrbug.polymarketbot.service.copytrading.leaderpool.LeaderPoolDuplicateTrialConfigException
|
||||
import com.wrbug.polymarketbot.service.copytrading.leaderpool.LeaderPoolNotFoundException
|
||||
import com.wrbug.polymarketbot.service.copytrading.leaderpool.LeaderPoolResearchCandidateNotReadyException
|
||||
import com.wrbug.polymarketbot.service.copytrading.leaderpool.LeaderPoolService
|
||||
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
|
||||
|
||||
@RestController
|
||||
@RequestMapping("/api/copy-trading/leader-pool")
|
||||
class LeaderPoolController(
|
||||
private val leaderPoolService: LeaderPoolService,
|
||||
private val messageSource: MessageSource
|
||||
) {
|
||||
private val logger = LoggerFactory.getLogger(LeaderPoolController::class.java)
|
||||
|
||||
@PostMapping("/list")
|
||||
fun list(@RequestBody request: LeaderPoolListRequest): ResponseEntity<ApiResponse<LeaderPoolListResponse>> {
|
||||
return try {
|
||||
leaderPoolService.getPoolList(request).fold(
|
||||
onSuccess = { ResponseEntity.ok(ApiResponse.success(it)) },
|
||||
onFailure = { e ->
|
||||
logger.error("查询 Leader 池失败: ${e.message}", e)
|
||||
errorResponse(e, ErrorCode.SERVER_LEADER_POOL_LIST_FETCH_FAILED)
|
||||
}
|
||||
)
|
||||
} catch (e: Exception) {
|
||||
logger.error("查询 Leader 池异常: ${e.message}", e)
|
||||
ResponseEntity.ok(ApiResponse.error(ErrorCode.SERVER_LEADER_POOL_LIST_FETCH_FAILED, e.message, messageSource))
|
||||
}
|
||||
}
|
||||
|
||||
@PostMapping("/add")
|
||||
fun add(@RequestBody request: LeaderPoolAddRequest): ResponseEntity<ApiResponse<LeaderPoolItemDto>> {
|
||||
return try {
|
||||
if (request.leaderId <= 0) {
|
||||
return ResponseEntity.ok(ApiResponse.error(ErrorCode.PARAM_LEADER_ID_INVALID, messageSource = messageSource))
|
||||
}
|
||||
leaderPoolService.addToPool(request).fold(
|
||||
onSuccess = { ResponseEntity.ok(ApiResponse.success(it)) },
|
||||
onFailure = { e ->
|
||||
logger.error("加入 Leader 池失败: ${e.message}", e)
|
||||
errorResponse(e, ErrorCode.SERVER_LEADER_POOL_SAVE_FAILED)
|
||||
}
|
||||
)
|
||||
} catch (e: Exception) {
|
||||
logger.error("加入 Leader 池异常: ${e.message}", e)
|
||||
ResponseEntity.ok(ApiResponse.error(ErrorCode.SERVER_LEADER_POOL_SAVE_FAILED, e.message, messageSource))
|
||||
}
|
||||
}
|
||||
|
||||
@PostMapping("/update-status")
|
||||
fun updateStatus(@RequestBody request: LeaderPoolUpdateStatusRequest): ResponseEntity<ApiResponse<LeaderPoolItemDto>> {
|
||||
return try {
|
||||
if (request.poolId <= 0) {
|
||||
return ResponseEntity.ok(ApiResponse.error(ErrorCode.PARAM_INVALID, "poolId 无效", messageSource))
|
||||
}
|
||||
if (request.status.isBlank()) {
|
||||
return ResponseEntity.ok(ApiResponse.error(ErrorCode.PARAM_EMPTY, "status 不能为空", messageSource))
|
||||
}
|
||||
leaderPoolService.updateStatus(request).fold(
|
||||
onSuccess = { ResponseEntity.ok(ApiResponse.success(it)) },
|
||||
onFailure = { e ->
|
||||
logger.error("更新 Leader 池状态失败: ${e.message}", e)
|
||||
errorResponse(e, ErrorCode.SERVER_LEADER_POOL_SAVE_FAILED)
|
||||
}
|
||||
)
|
||||
} catch (e: Exception) {
|
||||
logger.error("更新 Leader 池状态异常: ${e.message}", e)
|
||||
ResponseEntity.ok(ApiResponse.error(ErrorCode.SERVER_LEADER_POOL_SAVE_FAILED, e.message, messageSource))
|
||||
}
|
||||
}
|
||||
|
||||
@PostMapping("/update-plan")
|
||||
fun updatePlan(@RequestBody request: LeaderPoolUpdatePlanRequest): ResponseEntity<ApiResponse<LeaderPoolItemDto>> {
|
||||
return try {
|
||||
if (request.poolId <= 0) {
|
||||
return ResponseEntity.ok(ApiResponse.error(ErrorCode.PARAM_INVALID, "poolId 无效", messageSource))
|
||||
}
|
||||
leaderPoolService.updatePlan(request).fold(
|
||||
onSuccess = { ResponseEntity.ok(ApiResponse.success(it)) },
|
||||
onFailure = { e ->
|
||||
logger.error("更新 Leader 池建议配置失败: ${e.message}", e)
|
||||
errorResponse(e, ErrorCode.SERVER_LEADER_POOL_SAVE_FAILED)
|
||||
}
|
||||
)
|
||||
} catch (e: Exception) {
|
||||
logger.error("更新 Leader 池建议配置异常: ${e.message}", e)
|
||||
ResponseEntity.ok(ApiResponse.error(ErrorCode.SERVER_LEADER_POOL_SAVE_FAILED, e.message, messageSource))
|
||||
}
|
||||
}
|
||||
|
||||
@PostMapping("/create-trial-config")
|
||||
fun createTrialConfig(@RequestBody request: LeaderPoolCreateTrialConfigRequest): ResponseEntity<ApiResponse<CopyTradingDto>> {
|
||||
return try {
|
||||
if (request.poolId <= 0) {
|
||||
return ResponseEntity.ok(ApiResponse.error(ErrorCode.PARAM_INVALID, "poolId 无效", messageSource))
|
||||
}
|
||||
if (request.accountId <= 0) {
|
||||
return ResponseEntity.ok(ApiResponse.error(ErrorCode.PARAM_ACCOUNT_ID_INVALID, messageSource = messageSource))
|
||||
}
|
||||
leaderPoolService.createTrialConfig(request).fold(
|
||||
onSuccess = { ResponseEntity.ok(ApiResponse.success(it)) },
|
||||
onFailure = { e ->
|
||||
logger.error("创建 Leader 池试跟配置失败: ${e.message}", e)
|
||||
errorResponse(e, ErrorCode.SERVER_LEADER_POOL_CREATE_TRIAL_FAILED)
|
||||
}
|
||||
)
|
||||
} catch (e: Exception) {
|
||||
logger.error("创建 Leader 池试跟配置异常: ${e.message}", e)
|
||||
ResponseEntity.ok(ApiResponse.error(ErrorCode.SERVER_LEADER_POOL_CREATE_TRIAL_FAILED, e.message, messageSource))
|
||||
}
|
||||
}
|
||||
|
||||
@PostMapping("/remove")
|
||||
fun remove(@RequestBody request: LeaderPoolRemoveRequest): ResponseEntity<ApiResponse<Unit>> {
|
||||
return try {
|
||||
if (request.poolId <= 0) {
|
||||
return ResponseEntity.ok(ApiResponse.error(ErrorCode.PARAM_INVALID, "poolId 无效", messageSource))
|
||||
}
|
||||
leaderPoolService.remove(request).fold(
|
||||
onSuccess = { ResponseEntity.ok(ApiResponse.success(Unit)) },
|
||||
onFailure = { e ->
|
||||
logger.error("移除 Leader 池项失败: ${e.message}", e)
|
||||
errorResponse(e, ErrorCode.SERVER_LEADER_POOL_SAVE_FAILED)
|
||||
}
|
||||
)
|
||||
} catch (e: Exception) {
|
||||
logger.error("移除 Leader 池项异常: ${e.message}", e)
|
||||
ResponseEntity.ok(ApiResponse.error(ErrorCode.SERVER_LEADER_POOL_SAVE_FAILED, e.message, messageSource))
|
||||
}
|
||||
}
|
||||
|
||||
private fun mapErrorCode(e: Throwable, fallback: ErrorCode): ErrorCode {
|
||||
return when (e) {
|
||||
is LeaderPoolNotFoundException -> ErrorCode.LEADER_POOL_NOT_FOUND
|
||||
is LeaderPoolAlreadyExistsException -> ErrorCode.LEADER_POOL_ALREADY_EXISTS
|
||||
is LeaderPoolDuplicateTrialConfigException -> ErrorCode.LEADER_POOL_DUPLICATE_TRIAL_CONFIG
|
||||
is LeaderPoolConfirmRequiredException -> ErrorCode.LEADER_POOL_CONFIRM_REQUIRED
|
||||
is LeaderPoolResearchCandidateNotReadyException -> ErrorCode.LEADER_RESEARCH_CANDIDATE_NOT_READY
|
||||
is IllegalArgumentException -> when (e.message) {
|
||||
"账户不存在" -> ErrorCode.ACCOUNT_NOT_FOUND
|
||||
"Leader 不存在" -> ErrorCode.LEADER_NOT_FOUND
|
||||
else -> ErrorCode.PARAM_ERROR
|
||||
}
|
||||
else -> fallback
|
||||
}
|
||||
}
|
||||
|
||||
private fun <T> errorResponse(e: Throwable, fallback: ErrorCode): ResponseEntity<ApiResponse<T>> {
|
||||
val errorCode = mapErrorCode(e, fallback)
|
||||
val customMsg = if (usesI18nMessage(errorCode)) null else e.message
|
||||
return ResponseEntity.ok(ApiResponse.error(errorCode, customMsg, messageSource))
|
||||
}
|
||||
|
||||
private fun usesI18nMessage(errorCode: ErrorCode): Boolean {
|
||||
return errorCode == ErrorCode.LEADER_POOL_NOT_FOUND ||
|
||||
errorCode == ErrorCode.LEADER_POOL_ALREADY_EXISTS ||
|
||||
errorCode == ErrorCode.LEADER_POOL_DUPLICATE_TRIAL_CONFIG ||
|
||||
errorCode == ErrorCode.LEADER_POOL_CONFIRM_REQUIRED ||
|
||||
errorCode == ErrorCode.LEADER_RESEARCH_CANDIDATE_NOT_READY ||
|
||||
errorCode == ErrorCode.ACCOUNT_NOT_FOUND ||
|
||||
errorCode == ErrorCode.LEADER_NOT_FOUND
|
||||
}
|
||||
}
|
||||
+146
@@ -0,0 +1,146 @@
|
||||
package com.wrbug.polymarketbot.controller.copytrading.research
|
||||
|
||||
import com.wrbug.polymarketbot.dto.ApiResponse
|
||||
import com.wrbug.polymarketbot.dto.LeaderResearchApprovalRequest
|
||||
import com.wrbug.polymarketbot.dto.LeaderResearchApprovalResponse
|
||||
import com.wrbug.polymarketbot.dto.LeaderResearchCandidateDetailDto
|
||||
import com.wrbug.polymarketbot.dto.LeaderResearchCandidateListRequest
|
||||
import com.wrbug.polymarketbot.dto.LeaderResearchCandidateListResponse
|
||||
import com.wrbug.polymarketbot.dto.LeaderResearchEventDto
|
||||
import com.wrbug.polymarketbot.dto.LeaderPaperSessionDto
|
||||
import com.wrbug.polymarketbot.dto.LeaderResearchRunDto
|
||||
import com.wrbug.polymarketbot.dto.LeaderResearchRunRequest
|
||||
import com.wrbug.polymarketbot.dto.LeaderResearchSourceStateDto
|
||||
import com.wrbug.polymarketbot.dto.LeaderResearchSummaryDto
|
||||
import com.wrbug.polymarketbot.enums.ErrorCode
|
||||
import com.wrbug.polymarketbot.enums.LeaderResearchTriggerType
|
||||
import com.wrbug.polymarketbot.service.copytrading.research.LeaderResearchApprovalConfirmRequiredException
|
||||
import com.wrbug.polymarketbot.service.copytrading.research.LeaderResearchApprovalService
|
||||
import com.wrbug.polymarketbot.service.copytrading.research.LeaderResearchCandidateNotReadyException
|
||||
import com.wrbug.polymarketbot.service.copytrading.research.LeaderResearchCandidateLockedException
|
||||
import com.wrbug.polymarketbot.service.copytrading.research.LeaderResearchDuplicateTrialConfigException
|
||||
import com.wrbug.polymarketbot.service.copytrading.research.LeaderResearchJobService
|
||||
import com.wrbug.polymarketbot.service.copytrading.research.LeaderResearchMapper
|
||||
import com.wrbug.polymarketbot.service.copytrading.research.LeaderResearchRealMoneyForbiddenException
|
||||
import com.wrbug.polymarketbot.service.copytrading.research.LeaderResearchService
|
||||
import org.slf4j.LoggerFactory
|
||||
import org.springframework.context.MessageSource
|
||||
import org.springframework.http.ResponseEntity
|
||||
import org.springframework.web.bind.annotation.PostMapping
|
||||
import org.springframework.web.bind.annotation.RequestBody
|
||||
import org.springframework.web.bind.annotation.RequestMapping
|
||||
import org.springframework.web.bind.annotation.RestController
|
||||
|
||||
data class LeaderResearchDetailRequest(val candidateId: Long)
|
||||
data class LeaderResearchEventsRequest(val page: Int = 0, val size: Int = 50)
|
||||
data class LeaderResearchPaperSessionsRequest(val candidateId: Long)
|
||||
|
||||
@RestController
|
||||
@RequestMapping("/api/copy-trading/leader-research")
|
||||
class LeaderResearchController(
|
||||
private val jobService: LeaderResearchJobService,
|
||||
private val researchService: LeaderResearchService,
|
||||
private val approvalService: LeaderResearchApprovalService,
|
||||
private val mapper: LeaderResearchMapper,
|
||||
private val messageSource: MessageSource
|
||||
) {
|
||||
private val logger = LoggerFactory.getLogger(LeaderResearchController::class.java)
|
||||
|
||||
@PostMapping("/run")
|
||||
fun run(@RequestBody request: LeaderResearchRunRequest): ResponseEntity<ApiResponse<LeaderResearchRunDto>> {
|
||||
return try {
|
||||
val trigger = runCatching { LeaderResearchTriggerType.valueOf(request.triggerType.uppercase()) }
|
||||
.getOrDefault(LeaderResearchTriggerType.MANUAL)
|
||||
val run = if (request.dryRun || trigger == LeaderResearchTriggerType.PREVIEW) {
|
||||
jobService.runOnce(request.dryRun, trigger)
|
||||
} else {
|
||||
jobService.startAsync(request.dryRun, trigger)
|
||||
}
|
||||
ResponseEntity.ok(ApiResponse.success(mapper.runDto(run)))
|
||||
} catch (e: Exception) {
|
||||
logger.error("Leader research run failed", e)
|
||||
ResponseEntity.ok(ApiResponse.error(ErrorCode.SERVER_LEADER_RESEARCH_RUN_FAILED, e.message, messageSource))
|
||||
}
|
||||
}
|
||||
|
||||
@PostMapping("/summary")
|
||||
fun summary(): ResponseEntity<ApiResponse<LeaderResearchSummaryDto>> {
|
||||
return safe(ErrorCode.SERVER_LEADER_RESEARCH_FETCH_FAILED) { researchService.summary() }
|
||||
}
|
||||
|
||||
@PostMapping("/candidates/list")
|
||||
fun list(@RequestBody request: LeaderResearchCandidateListRequest): ResponseEntity<ApiResponse<LeaderResearchCandidateListResponse>> {
|
||||
return safe(ErrorCode.SERVER_LEADER_RESEARCH_FETCH_FAILED) { researchService.listCandidates(request) }
|
||||
}
|
||||
|
||||
@PostMapping("/candidates/detail")
|
||||
fun detail(@RequestBody request: LeaderResearchDetailRequest): ResponseEntity<ApiResponse<LeaderResearchCandidateDetailDto>> {
|
||||
if (request.candidateId <= 0) {
|
||||
return ResponseEntity.ok(ApiResponse.error(ErrorCode.PARAM_INVALID, "candidateId 无效", messageSource))
|
||||
}
|
||||
return safe(ErrorCode.SERVER_LEADER_RESEARCH_FETCH_FAILED) { researchService.detail(request.candidateId) }
|
||||
}
|
||||
|
||||
@PostMapping("/paper-sessions")
|
||||
fun paperSessions(@RequestBody request: LeaderResearchPaperSessionsRequest): ResponseEntity<ApiResponse<List<LeaderPaperSessionDto>>> {
|
||||
if (request.candidateId <= 0) {
|
||||
return ResponseEntity.ok(ApiResponse.error(ErrorCode.PARAM_INVALID, "candidateId 无效", messageSource))
|
||||
}
|
||||
return safe(ErrorCode.SERVER_LEADER_RESEARCH_FETCH_FAILED) { researchService.paperSessions(request.candidateId) }
|
||||
}
|
||||
|
||||
@PostMapping("/source-health")
|
||||
fun sourceHealth(): ResponseEntity<ApiResponse<List<LeaderResearchSourceStateDto>>> {
|
||||
return safe(ErrorCode.SERVER_LEADER_RESEARCH_FETCH_FAILED) { researchService.sourceHealth() }
|
||||
}
|
||||
|
||||
@PostMapping("/events/list")
|
||||
fun events(@RequestBody request: LeaderResearchEventsRequest): ResponseEntity<ApiResponse<List<LeaderResearchEventDto>>> {
|
||||
return safe(ErrorCode.SERVER_LEADER_RESEARCH_FETCH_FAILED) { researchService.events(request.page, request.size) }
|
||||
}
|
||||
|
||||
@PostMapping("/approval/create-disabled-trial-config")
|
||||
fun approve(@RequestBody request: LeaderResearchApprovalRequest): ResponseEntity<ApiResponse<LeaderResearchApprovalResponse>> {
|
||||
if (request.candidateId <= 0) {
|
||||
return ResponseEntity.ok(ApiResponse.error(ErrorCode.PARAM_INVALID, "candidateId 无效", messageSource))
|
||||
}
|
||||
if (request.accountId <= 0) {
|
||||
return ResponseEntity.ok(ApiResponse.error(ErrorCode.PARAM_ACCOUNT_ID_INVALID, messageSource = messageSource))
|
||||
}
|
||||
return try {
|
||||
approvalService.createDisabledTrialConfig(request).fold(
|
||||
onSuccess = { ResponseEntity.ok(ApiResponse.success(it)) },
|
||||
onFailure = { e -> errorResponse(e, ErrorCode.SERVER_LEADER_RESEARCH_APPROVAL_FAILED) }
|
||||
)
|
||||
} catch (e: Exception) {
|
||||
logger.error("Leader research approval failed", e)
|
||||
ResponseEntity.ok(ApiResponse.error(ErrorCode.SERVER_LEADER_RESEARCH_APPROVAL_FAILED, e.message, messageSource))
|
||||
}
|
||||
}
|
||||
|
||||
private fun <T> safe(errorCode: ErrorCode, block: () -> T): ResponseEntity<ApiResponse<T>> {
|
||||
return try {
|
||||
ResponseEntity.ok(ApiResponse.success(block()))
|
||||
} catch (e: Exception) {
|
||||
logger.error("Leader research request failed", e)
|
||||
ResponseEntity.ok(ApiResponse.error(errorCode, e.message, messageSource))
|
||||
}
|
||||
}
|
||||
|
||||
private fun <T> errorResponse(e: Throwable, fallback: ErrorCode): ResponseEntity<ApiResponse<T>> {
|
||||
val errorCode = when (e) {
|
||||
is LeaderResearchCandidateNotReadyException -> ErrorCode.LEADER_RESEARCH_CANDIDATE_NOT_READY
|
||||
is LeaderResearchApprovalConfirmRequiredException -> ErrorCode.LEADER_RESEARCH_APPROVAL_CONFIRM_REQUIRED
|
||||
is LeaderResearchDuplicateTrialConfigException -> ErrorCode.LEADER_RESEARCH_DUPLICATE_TRIAL_CONFIG
|
||||
is LeaderResearchRealMoneyForbiddenException -> ErrorCode.LEADER_RESEARCH_REAL_MONEY_FORBIDDEN
|
||||
is LeaderResearchCandidateLockedException -> ErrorCode.LEADER_RESEARCH_CANDIDATE_LOCKED
|
||||
is IllegalArgumentException -> when (e.message) {
|
||||
"账户不存在" -> ErrorCode.ACCOUNT_NOT_FOUND
|
||||
"候选不存在" -> ErrorCode.LEADER_RESEARCH_CANDIDATE_NOT_FOUND
|
||||
else -> ErrorCode.PARAM_ERROR
|
||||
}
|
||||
else -> fallback
|
||||
}
|
||||
return ResponseEntity.ok(ApiResponse.error(errorCode, null, messageSource))
|
||||
}
|
||||
}
|
||||
-28
@@ -3,7 +3,6 @@ package com.wrbug.polymarketbot.controller.system
|
||||
import com.wrbug.polymarketbot.dto.*
|
||||
import com.wrbug.polymarketbot.enums.ErrorCode
|
||||
import com.wrbug.polymarketbot.service.system.ApiHealthCheckService
|
||||
import com.wrbug.polymarketbot.service.system.GeoblockService
|
||||
import com.wrbug.polymarketbot.service.system.ProxyConfigService
|
||||
import jakarta.servlet.http.HttpServletRequest
|
||||
import kotlinx.coroutines.runBlocking
|
||||
@@ -20,7 +19,6 @@ import org.springframework.web.bind.annotation.*
|
||||
class ProxyConfigController(
|
||||
private val proxyConfigService: ProxyConfigService,
|
||||
private val apiHealthCheckService: ApiHealthCheckService,
|
||||
private val geoblockService: GeoblockService,
|
||||
private val messageSource: MessageSource
|
||||
) {
|
||||
|
||||
@@ -125,32 +123,6 @@ class ProxyConfigController(
|
||||
ResponseEntity.ok(ApiResponse.error(ErrorCode.SERVER_ERROR, "API 健康检查失败:${e.message}", messageSource))
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 检查交易服务器出口 IP 的地域限制(Polymarket Geoblock)
|
||||
*/
|
||||
@PostMapping("/geoblock-check")
|
||||
fun checkGeoblock(): ResponseEntity<ApiResponse<GeoblockCheckDto>> {
|
||||
return try {
|
||||
val result = runBlocking { geoblockService.checkGeoblock() }
|
||||
if (result.isSuccess) {
|
||||
ResponseEntity.ok(ApiResponse.success(result.getOrNull()))
|
||||
} else {
|
||||
val error = result.exceptionOrNull()
|
||||
logger.error("Geoblock 检查失败", error)
|
||||
ResponseEntity.ok(
|
||||
ApiResponse.error(
|
||||
ErrorCode.SERVER_ERROR,
|
||||
error?.message ?: "Geoblock 检查失败",
|
||||
messageSource
|
||||
)
|
||||
)
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
logger.error("Geoblock 检查异常", e)
|
||||
ResponseEntity.ok(ApiResponse.error(ErrorCode.SERVER_ERROR, "Geoblock 检查失败:${e.message}", messageSource))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
@@ -84,6 +84,24 @@ data class CopyTradingUpdateRequest(
|
||||
val maxMarketEndDate: Long? = null // 市场截止时间限制(毫秒时间戳),仅跟单截止时间小于此时间的订单,NULL表示不启用
|
||||
)
|
||||
|
||||
/**
|
||||
* 应用保守风控配置请求。
|
||||
*
|
||||
* 只暴露安全带允许修改的白名单字段,避免误改 leader、启用状态、金额模式等真实交易行为。
|
||||
*/
|
||||
data class ApplyConservativeConfigRequest(
|
||||
val copyTradingId: Long,
|
||||
val confirm: Boolean = false,
|
||||
val maxDailyOrders: Int? = null,
|
||||
val maxDailyLoss: String? = null,
|
||||
val minPrice: String? = null,
|
||||
val maxPrice: String? = null,
|
||||
val maxPositionValue: String? = null,
|
||||
val minOrderDepth: String? = null,
|
||||
val maxSpread: String? = null,
|
||||
val priceTolerance: String? = null
|
||||
)
|
||||
|
||||
/**
|
||||
* 跟单列表请求
|
||||
*/
|
||||
@@ -189,4 +207,3 @@ data class AccountTemplatesResponse(
|
||||
val list: List<AccountTemplateDto>,
|
||||
val total: Long
|
||||
)
|
||||
|
||||
|
||||
@@ -24,7 +24,16 @@ data class CopyTradingStatisticsResponse(
|
||||
|
||||
// 持仓统计
|
||||
val currentPositionQuantity: String,
|
||||
val currentPositionCost: String,
|
||||
val currentPositionValue: String,
|
||||
val zeroValuePositionCost: String = "0",
|
||||
val confirmedZeroValuePositionCost: String = "0",
|
||||
val quoteOverallStatus: String = "AVAILABLE",
|
||||
val quoteAvailableCount: Int = 0,
|
||||
val quoteNoMatchCount: Int = 0,
|
||||
val quoteUnavailableCount: Int = 0,
|
||||
val quoteIncomplete: Boolean = false,
|
||||
val riskDiagnosis: CopyTradingRiskDiagnosisDto? = null,
|
||||
|
||||
// 盈亏统计
|
||||
val totalRealizedPnl: String,
|
||||
@@ -33,6 +42,49 @@ data class CopyTradingStatisticsResponse(
|
||||
val totalPnlPercent: String
|
||||
)
|
||||
|
||||
data class CopyTradingRiskDiagnosisDto(
|
||||
val copyTradingId: Long,
|
||||
val totalRealizedPnl: String,
|
||||
val totalUnrealizedPnl: String,
|
||||
val totalPnl: String,
|
||||
val currentPositionCost: String,
|
||||
val currentPositionValue: String,
|
||||
val zeroValuePositionCost: String,
|
||||
val confirmedZeroValuePositionCost: String,
|
||||
val zeroSellLoss: String,
|
||||
val openPositionQuantity: String,
|
||||
val totalBuyOrders: Int,
|
||||
val totalSellRecords: Int,
|
||||
val totalMatchDetails: Int,
|
||||
val filteredOrderCount: Long,
|
||||
val sampleSize: Int,
|
||||
val lowConfidence: Boolean,
|
||||
val confidenceReason: String,
|
||||
val quoteOverallStatus: String,
|
||||
val quoteAvailableCount: Int,
|
||||
val quoteNoMatchCount: Int,
|
||||
val quoteUnavailableCount: Int,
|
||||
val dataIncomplete: Boolean,
|
||||
val missingSources: List<String>,
|
||||
val topLosingMarkets: List<TopLosingMarketDto>,
|
||||
val riskWarnings: List<RiskWarningDto>,
|
||||
val generatedAt: Long
|
||||
)
|
||||
|
||||
data class TopLosingMarketDto(
|
||||
val marketId: String,
|
||||
val realizedPnl: String,
|
||||
val matchedOrders: Int
|
||||
)
|
||||
|
||||
data class RiskWarningDto(
|
||||
val field: String,
|
||||
val currentValue: String?,
|
||||
val suggestedValue: String,
|
||||
val severity: String,
|
||||
val reason: String
|
||||
)
|
||||
|
||||
/**
|
||||
* 买入订单信息
|
||||
*/
|
||||
@@ -208,4 +260,3 @@ data class StatisticsResponse(
|
||||
val maxProfit: String,
|
||||
val maxLoss: String
|
||||
)
|
||||
|
||||
|
||||
@@ -1,23 +0,0 @@
|
||||
package com.wrbug.polymarketbot.dto
|
||||
|
||||
/**
|
||||
* Polymarket Geoblock API 原始响应
|
||||
*/
|
||||
data class PolymarketGeoblockApiResponse(
|
||||
val blocked: Boolean = false,
|
||||
val ip: String = "",
|
||||
val country: String = "",
|
||||
val region: String = ""
|
||||
)
|
||||
|
||||
/**
|
||||
* 地域限制检查结果(返回给前端)
|
||||
*/
|
||||
data class GeoblockCheckDto(
|
||||
val blocked: Boolean,
|
||||
val ip: String,
|
||||
val country: String,
|
||||
val region: String,
|
||||
val checkedAt: Long,
|
||||
val source: String = "server"
|
||||
)
|
||||
@@ -0,0 +1,92 @@
|
||||
package com.wrbug.polymarketbot.dto
|
||||
|
||||
data class LeaderPoolListRequest(
|
||||
val status: String? = null
|
||||
)
|
||||
|
||||
data class LeaderPoolAddRequest(
|
||||
val leaderId: Long,
|
||||
val source: String? = null,
|
||||
val reason: String? = null,
|
||||
val notes: String? = null
|
||||
)
|
||||
|
||||
data class LeaderPoolUpdateStatusRequest(
|
||||
val poolId: Long,
|
||||
val status: String,
|
||||
val cooldownUntil: Long? = null,
|
||||
val locked: Boolean? = null
|
||||
)
|
||||
|
||||
data class LeaderPoolUpdatePlanRequest(
|
||||
val poolId: Long,
|
||||
val suggestedFixedAmount: String? = null,
|
||||
val suggestedMaxDailyOrders: Int? = null,
|
||||
val suggestedMaxDailyLoss: String? = null,
|
||||
val suggestedMinPrice: String? = null,
|
||||
val suggestedMaxPrice: String? = null,
|
||||
val suggestedMaxPositionValue: String? = null,
|
||||
val reason: String? = null,
|
||||
val notes: String? = null
|
||||
)
|
||||
|
||||
data class LeaderPoolCreateTrialConfigRequest(
|
||||
val poolId: Long,
|
||||
val accountId: Long,
|
||||
val enableImmediately: Boolean = false,
|
||||
val confirm: Boolean = false
|
||||
)
|
||||
|
||||
data class LeaderPoolRemoveRequest(
|
||||
val poolId: Long
|
||||
)
|
||||
|
||||
data class LeaderPoolSummaryDto(
|
||||
val totalCount: Int,
|
||||
val trialCount: Int,
|
||||
val estimatedWorstExposure: String,
|
||||
val pendingRiskCount: Int,
|
||||
val defaultExperimentBudget: String = "50"
|
||||
)
|
||||
|
||||
data class LeaderPoolItemDto(
|
||||
val id: Long,
|
||||
val leaderId: Long,
|
||||
val leaderName: String?,
|
||||
val leaderAddress: String,
|
||||
val category: String?,
|
||||
val profileUrl: String,
|
||||
val status: String,
|
||||
val source: String,
|
||||
val sourceRank: Int?,
|
||||
val score: String?,
|
||||
val reason: String?,
|
||||
val notes: String?,
|
||||
val suggestedFixedAmount: String,
|
||||
val suggestedMaxDailyOrders: Int,
|
||||
val suggestedMaxDailyLoss: String,
|
||||
val suggestedMinPrice: String?,
|
||||
val suggestedMaxPrice: String?,
|
||||
val suggestedMaxPositionValue: String?,
|
||||
val copyTradingCount: Int,
|
||||
val hasEnabledCopyTrading: Boolean,
|
||||
val estimatedWorstExposure: String,
|
||||
val lastReviewedAt: Long?,
|
||||
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
|
||||
)
|
||||
|
||||
data class LeaderPoolListResponse(
|
||||
val summary: LeaderPoolSummaryDto,
|
||||
val list: List<LeaderPoolItemDto>,
|
||||
val total: Int
|
||||
)
|
||||
@@ -0,0 +1,224 @@
|
||||
package com.wrbug.polymarketbot.dto
|
||||
|
||||
data class LeaderResearchRunRequest(
|
||||
val dryRun: Boolean = false,
|
||||
val triggerType: String = "MANUAL"
|
||||
)
|
||||
|
||||
data class LeaderResearchRunDto(
|
||||
val id: Long,
|
||||
val status: String,
|
||||
val triggerType: String,
|
||||
val dryRun: Boolean,
|
||||
val startedAt: Long,
|
||||
val finishedAt: Long?,
|
||||
val durationMs: Long?,
|
||||
val sourceCountsJson: String?,
|
||||
val candidateCountsJson: String?,
|
||||
val partialFailure: Boolean,
|
||||
val skippedReason: String?,
|
||||
val errorClass: String?,
|
||||
val errorMessage: String?
|
||||
)
|
||||
|
||||
data class LeaderResearchSummaryDto(
|
||||
val discoveredCount: Long,
|
||||
val candidateCount: Long,
|
||||
val paperCount: Long,
|
||||
val trialReadyCount: Long,
|
||||
val cooldownCount: Long,
|
||||
val retiredCount: Long,
|
||||
val activePaperSessions: Long,
|
||||
val pendingRiskCount: Long,
|
||||
val lastRun: LeaderResearchRunDto?,
|
||||
val sourceLimitations: List<String>
|
||||
)
|
||||
|
||||
data class LeaderResearchCandidateListRequest(
|
||||
val page: Int = 0,
|
||||
val size: Int = 20,
|
||||
val state: String? = null,
|
||||
val query: String? = null
|
||||
)
|
||||
|
||||
data class LeaderResearchCandidateListResponse(
|
||||
val list: List<LeaderResearchCandidateDto>,
|
||||
val total: Long,
|
||||
val summary: LeaderResearchSummaryDto
|
||||
)
|
||||
|
||||
data class LeaderResearchCandidateDto(
|
||||
val id: Long,
|
||||
val normalizedWallet: String,
|
||||
val leaderId: Long?,
|
||||
val leaderName: String?,
|
||||
val poolId: Long?,
|
||||
val poolStatus: String?,
|
||||
val suggestedFixedAmount: String?,
|
||||
val suggestedMaxDailyLoss: String?,
|
||||
val suggestedMaxDailyOrders: Int?,
|
||||
val suggestedMinPrice: String?,
|
||||
val suggestedMaxPrice: String?,
|
||||
val suggestedMaxPositionValue: String?,
|
||||
val researchState: String,
|
||||
val source: String,
|
||||
val sourceRank: Int?,
|
||||
val score: String?,
|
||||
val scoreVersion: String?,
|
||||
val reason: String?,
|
||||
val riskFlags: List<String>,
|
||||
val locked: Boolean,
|
||||
val agentOwned: Boolean,
|
||||
val provenance: String,
|
||||
val sourceEvidence: String?,
|
||||
val firstSeenAt: Long,
|
||||
val lastSourceSeenAt: Long?,
|
||||
val lastScoredAt: Long?,
|
||||
val cooldownUntil: Long?,
|
||||
val cooldownCount: Int,
|
||||
val trialReadyAt: Long?,
|
||||
val retiredAt: Long?,
|
||||
val lastPaperSessionId: Long?,
|
||||
val latestPaperSession: LeaderPaperSessionDto?
|
||||
)
|
||||
|
||||
data class LeaderResearchCandidateDetailDto(
|
||||
val candidate: LeaderResearchCandidateDto,
|
||||
val latestScore: LeaderResearchScoreDto?,
|
||||
val paperSessions: List<LeaderPaperSessionDto>,
|
||||
val paperTrades: List<LeaderPaperTradeDto>,
|
||||
val paperPositions: List<LeaderPaperPositionDto>,
|
||||
val events: List<LeaderResearchEventDto>
|
||||
)
|
||||
|
||||
data class LeaderResearchScoreDto(
|
||||
val id: Long,
|
||||
val candidateId: Long,
|
||||
val runId: Long?,
|
||||
val scoreVersion: String,
|
||||
val totalScore: String,
|
||||
val profitSignal: String,
|
||||
val repeatability: String,
|
||||
val liquidityFit: String,
|
||||
val entryPriceFit: String,
|
||||
val slippageRisk: String,
|
||||
val holdingPeriodFit: String,
|
||||
val marketTypeRisk: String,
|
||||
val drawdownRisk: String,
|
||||
val exitLiquidityRisk: String,
|
||||
val dataFreshness: String,
|
||||
val filterPassRate: String,
|
||||
val sampleTradeCount: Int,
|
||||
val reason: String?,
|
||||
val createdAt: Long
|
||||
)
|
||||
|
||||
data class LeaderPaperSessionDto(
|
||||
val id: Long,
|
||||
val candidateId: Long,
|
||||
val status: String,
|
||||
val startedAt: Long,
|
||||
val endedAt: Long?,
|
||||
val tradeCount: Int,
|
||||
val filteredCount: Int,
|
||||
val openExposure: String,
|
||||
val totalRealizedPnl: String,
|
||||
val totalUnrealizedPnl: String,
|
||||
val copyablePnl: String,
|
||||
val maxDrawdown: String,
|
||||
val unknownValuationExposure: String,
|
||||
val confirmedZeroExposure: String,
|
||||
val filteredRatio: String,
|
||||
val lastProcessedEventTime: Long?,
|
||||
val scoreSnapshot: String?
|
||||
)
|
||||
|
||||
data class LeaderPaperTradeDto(
|
||||
val id: Long,
|
||||
val sessionId: Long,
|
||||
val candidateId: Long,
|
||||
val activityEventId: Long?,
|
||||
val leaderTradeId: String,
|
||||
val marketId: String,
|
||||
val marketTitle: String?,
|
||||
val marketSlug: String?,
|
||||
val side: String,
|
||||
val outcome: String?,
|
||||
val outcomeIndex: Int?,
|
||||
val leaderPrice: String?,
|
||||
val leaderSize: String?,
|
||||
val simulatedPrice: String?,
|
||||
val simulatedSize: String?,
|
||||
val simulatedAmount: String?,
|
||||
val fillAssumption: String,
|
||||
val quoteConfidence: String,
|
||||
val quoteSource: String?,
|
||||
val quoteTimestamp: Long?,
|
||||
val filterResult: String,
|
||||
val filterReason: String?,
|
||||
val valuationStatus: String,
|
||||
val realizedPnl: String?,
|
||||
val eventTime: Long,
|
||||
val createdAt: Long
|
||||
)
|
||||
|
||||
data class LeaderPaperPositionDto(
|
||||
val id: Long,
|
||||
val sessionId: Long,
|
||||
val candidateId: Long,
|
||||
val marketId: String,
|
||||
val outcome: String?,
|
||||
val outcomeIndex: Int?,
|
||||
val quantity: String,
|
||||
val cost: String,
|
||||
val avgPrice: String,
|
||||
val currentPrice: String?,
|
||||
val currentValue: String,
|
||||
val realizedPnl: String,
|
||||
val unrealizedPnl: String,
|
||||
val valuationStatus: String,
|
||||
val quoteConfidence: String,
|
||||
val quoteSource: String?,
|
||||
val quoteTimestamp: Long?,
|
||||
val updatedAt: Long
|
||||
)
|
||||
|
||||
data class LeaderResearchSourceStateDto(
|
||||
val sourceType: String,
|
||||
val status: String,
|
||||
val lastSuccessAt: Long?,
|
||||
val lastFailureAt: Long?,
|
||||
val lastRunAt: Long?,
|
||||
val lastCandidateCount: Int,
|
||||
val errorClass: String?,
|
||||
val errorMessage: String?,
|
||||
val stale: Boolean,
|
||||
val disabledReason: String?,
|
||||
val lastCursor: String?,
|
||||
val updatedAt: Long
|
||||
)
|
||||
|
||||
data class LeaderResearchEventDto(
|
||||
val id: Long,
|
||||
val candidateId: Long?,
|
||||
val runId: Long?,
|
||||
val eventType: String,
|
||||
val reason: String?,
|
||||
val payloadSummary: String?,
|
||||
val notificationStatus: String,
|
||||
val notificationError: String?,
|
||||
val dedupeKey: String?,
|
||||
val createdAt: Long,
|
||||
val notifiedAt: Long?
|
||||
)
|
||||
|
||||
data class LeaderResearchApprovalRequest(
|
||||
val candidateId: Long,
|
||||
val accountId: Long,
|
||||
val confirm: Boolean = false
|
||||
)
|
||||
|
||||
data class LeaderResearchApprovalResponse(
|
||||
val copyTrading: CopyTradingDto,
|
||||
val warning: String = "已创建禁用状态的试跟配置;需要你手动启用后才会真钱跟单。"
|
||||
)
|
||||
@@ -36,18 +36,6 @@ data class SubscriptionProxyConfigRequest(
|
||||
val type: String // CLASH 或 SS
|
||||
)
|
||||
|
||||
/**
|
||||
* 代理检查时的 Polymarket 地域限制检测结果
|
||||
*/
|
||||
data class ProxyCheckGeoblockResult(
|
||||
val checked: Boolean,
|
||||
val blocked: Boolean? = null,
|
||||
val ip: String? = null,
|
||||
val country: String? = null,
|
||||
val region: String? = null,
|
||||
val message: String? = null
|
||||
)
|
||||
|
||||
/**
|
||||
* 代理检查响应
|
||||
*/
|
||||
@@ -55,22 +43,15 @@ data class ProxyCheckResponse(
|
||||
val success: Boolean,
|
||||
val message: String,
|
||||
val responseTime: Long? = null, // 响应时间(毫秒)
|
||||
val latency: Long? = null, // 延迟(毫秒),与 responseTime 相同,用于前端显示
|
||||
val geoblock: ProxyCheckGeoblockResult? = null
|
||||
val latency: Long? = null // 延迟(毫秒),与 responseTime 相同,用于前端显示
|
||||
) {
|
||||
companion object {
|
||||
fun create(
|
||||
success: Boolean,
|
||||
message: String,
|
||||
responseTime: Long? = null,
|
||||
geoblock: ProxyCheckGeoblockResult? = null
|
||||
): ProxyCheckResponse {
|
||||
fun create(success: Boolean, message: String, responseTime: Long? = null): ProxyCheckResponse {
|
||||
return ProxyCheckResponse(
|
||||
success = success,
|
||||
message = message,
|
||||
responseTime = responseTime,
|
||||
latency = responseTime,
|
||||
geoblock = geoblock
|
||||
latency = responseTime // latency 和 responseTime 相同
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,91 @@
|
||||
package com.wrbug.polymarketbot.entity
|
||||
|
||||
import com.wrbug.polymarketbot.enums.LeaderPoolStatus
|
||||
import com.wrbug.polymarketbot.enums.LeaderResearchState
|
||||
import jakarta.persistence.*
|
||||
import java.math.BigDecimal
|
||||
|
||||
@Entity
|
||||
@Table(name = "copy_trading_leader_pool")
|
||||
data class LeaderPool(
|
||||
@Id
|
||||
@GeneratedValue(strategy = GenerationType.IDENTITY)
|
||||
val id: Long? = null,
|
||||
|
||||
@Column(name = "leader_id", nullable = false)
|
||||
val leaderId: Long,
|
||||
|
||||
@Enumerated(EnumType.STRING)
|
||||
@Column(name = "status", nullable = false, length = 20, columnDefinition = "VARCHAR(20)")
|
||||
val status: LeaderPoolStatus = LeaderPoolStatus.CANDIDATE,
|
||||
|
||||
@Column(name = "source", nullable = false, length = 50)
|
||||
val source: String = "MANUAL",
|
||||
|
||||
@Column(name = "source_rank")
|
||||
val sourceRank: Int? = null,
|
||||
|
||||
@Column(name = "score", precision = 20, scale = 8)
|
||||
val score: BigDecimal? = null,
|
||||
|
||||
@Column(name = "reason", columnDefinition = "TEXT")
|
||||
val reason: String? = null,
|
||||
|
||||
@Column(name = "notes", columnDefinition = "TEXT")
|
||||
val notes: String? = null,
|
||||
|
||||
@Column(name = "suggested_fixed_amount", nullable = false, precision = 20, scale = 8)
|
||||
val suggestedFixedAmount: BigDecimal = BigDecimal("1.00000000"),
|
||||
|
||||
@Column(name = "suggested_max_daily_orders", nullable = false)
|
||||
val suggestedMaxDailyOrders: Int = 10,
|
||||
|
||||
@Column(name = "suggested_max_daily_loss", nullable = false, precision = 20, scale = 8)
|
||||
val suggestedMaxDailyLoss: BigDecimal = BigDecimal("5.00000000"),
|
||||
|
||||
@Column(name = "suggested_min_price", precision = 20, scale = 8)
|
||||
val suggestedMinPrice: BigDecimal? = BigDecimal("0.10000000"),
|
||||
|
||||
@Column(name = "suggested_max_price", precision = 20, scale = 8)
|
||||
val suggestedMaxPrice: BigDecimal? = BigDecimal("0.80000000"),
|
||||
|
||||
@Column(name = "suggested_max_position_value", precision = 20, scale = 8)
|
||||
val suggestedMaxPositionValue: BigDecimal? = BigDecimal("5.00000000"),
|
||||
|
||||
@Column(name = "last_reviewed_at")
|
||||
val lastReviewedAt: Long? = null,
|
||||
|
||||
@Column(name = "last_promoted_at")
|
||||
val lastPromotedAt: Long? = null,
|
||||
|
||||
@Column(name = "cooldown_until")
|
||||
val cooldownUntil: Long? = null,
|
||||
|
||||
@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, columnDefinition = "VARCHAR(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(),
|
||||
|
||||
@Column(name = "updated_at", nullable = false)
|
||||
var updatedAt: Long = System.currentTimeMillis()
|
||||
)
|
||||
@@ -0,0 +1,600 @@
|
||||
package com.wrbug.polymarketbot.entity
|
||||
|
||||
import com.wrbug.polymarketbot.enums.*
|
||||
import jakarta.persistence.*
|
||||
import java.math.BigDecimal
|
||||
|
||||
@Entity
|
||||
@Table(name = "leader_research_run")
|
||||
data class LeaderResearchRun(
|
||||
@Id
|
||||
@GeneratedValue(strategy = GenerationType.IDENTITY)
|
||||
val id: Long? = null,
|
||||
|
||||
@Enumerated(EnumType.STRING)
|
||||
@Column(name = "status", nullable = false, length = 30, columnDefinition = "VARCHAR(30)")
|
||||
val status: LeaderResearchRunStatus = LeaderResearchRunStatus.RUNNING,
|
||||
|
||||
@Enumerated(EnumType.STRING)
|
||||
@Column(name = "trigger_type", nullable = false, length = 30, columnDefinition = "VARCHAR(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, columnDefinition = "VARCHAR(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, columnDefinition = "VARCHAR(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, columnDefinition = "VARCHAR(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, columnDefinition = "VARCHAR(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, columnDefinition = "VARCHAR(50)")
|
||||
val sourceType: LeaderResearchSourceType,
|
||||
|
||||
@Enumerated(EnumType.STRING)
|
||||
@Column(name = "status", nullable = false, length = 30, columnDefinition = "VARCHAR(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, columnDefinition = "VARCHAR(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, columnDefinition = "VARCHAR(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, columnDefinition = "VARCHAR(30)")
|
||||
val fillAssumption: LeaderPaperFillAssumption = LeaderPaperFillAssumption.LEADER_PRICE,
|
||||
|
||||
@Enumerated(EnumType.STRING)
|
||||
@Column(name = "quote_confidence", nullable = false, length = 30, columnDefinition = "VARCHAR(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, columnDefinition = "VARCHAR(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, columnDefinition = "VARCHAR(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, columnDefinition = "VARCHAR(30)")
|
||||
val valuationStatus: LeaderResearchValuationStatus = LeaderResearchValuationStatus.UNKNOWN,
|
||||
|
||||
@Enumerated(EnumType.STRING)
|
||||
@Column(name = "quote_confidence", nullable = false, length = 30, columnDefinition = "VARCHAR(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()
|
||||
)
|
||||
@@ -110,6 +110,18 @@ enum class ErrorCode(
|
||||
COPY_TRADING_DISABLED(4202, "跟单关系已禁用", "error.copy_trading_disabled"),
|
||||
COPY_TRADING_ENABLED(4203, "跟单关系已启用", "error.copy_trading_enabled"),
|
||||
NO_ENABLED_COPY_TRADINGS(4204, "没有启用的跟单关系", "error.no_enabled_copy_tradings"),
|
||||
LEADER_POOL_NOT_FOUND(4251, "Leader 池项不存在", "error.leader_pool_not_found"),
|
||||
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"),
|
||||
@@ -213,6 +225,12 @@ enum class ErrorCode(
|
||||
SERVER_COPY_TRADING_DELETE_FAILED(5403, "删除跟单失败", "error.server.copy_trading_delete_failed"),
|
||||
SERVER_COPY_TRADING_LIST_FETCH_FAILED(5404, "查询跟单列表失败", "error.server.copy_trading_list_fetch_failed"),
|
||||
SERVER_COPY_TRADING_TEMPLATES_FETCH_FAILED(5405, "查询钱包绑定的模板失败", "error.server.copy_trading_templates_fetch_failed"),
|
||||
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"),
|
||||
@@ -283,4 +301,3 @@ enum class ErrorCode(
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,11 @@
|
||||
package com.wrbug.polymarketbot.enums
|
||||
|
||||
enum class LeaderPoolStatus {
|
||||
CANDIDATE,
|
||||
WATCH,
|
||||
PAPER,
|
||||
TRIAL,
|
||||
ACTIVE,
|
||||
COOLDOWN,
|
||||
RETIRED
|
||||
}
|
||||
@@ -0,0 +1,123 @@
|
||||
package com.wrbug.polymarketbot.enums
|
||||
|
||||
enum class LeaderResearchState {
|
||||
DISCOVERED,
|
||||
CANDIDATE,
|
||||
PAPER,
|
||||
TRIAL_READY,
|
||||
COOLDOWN,
|
||||
RETIRED
|
||||
}
|
||||
|
||||
enum class LeaderResearchRunStatus {
|
||||
RUNNING,
|
||||
SUCCESS,
|
||||
PARTIAL_FAILURE,
|
||||
FAILED,
|
||||
SKIPPED
|
||||
}
|
||||
|
||||
enum class LeaderResearchTriggerType {
|
||||
MANUAL,
|
||||
SCHEDULED,
|
||||
PREVIEW
|
||||
}
|
||||
|
||||
enum class LeaderResearchSourceType {
|
||||
WATCHLIST,
|
||||
EXISTING_LEADER,
|
||||
ACTIVITY_DERIVED,
|
||||
GLOBAL_ACTIVITY_CAPTURE,
|
||||
PUBLIC_LEADERBOARD
|
||||
}
|
||||
|
||||
enum class LeaderResearchSourceStatus {
|
||||
SUCCESS,
|
||||
FAILURE,
|
||||
STALE,
|
||||
DISABLED,
|
||||
DEGRADED
|
||||
}
|
||||
|
||||
enum class LeaderCandidateProvenance {
|
||||
AGENT_CREATED,
|
||||
USER_LEADER,
|
||||
USER_POOL,
|
||||
MANUAL_LOCKED
|
||||
}
|
||||
|
||||
enum class LeaderPaperSessionStatus {
|
||||
ACTIVE,
|
||||
PAUSED,
|
||||
COMPLETED,
|
||||
FAILED
|
||||
}
|
||||
|
||||
enum class LeaderPaperProcessingStatus {
|
||||
NEW,
|
||||
PROCESSING,
|
||||
PROCESSED,
|
||||
FILTERED,
|
||||
RETRYABLE,
|
||||
FAILED
|
||||
}
|
||||
|
||||
enum class LeaderResearchValuationStatus {
|
||||
AVAILABLE,
|
||||
NO_MATCH,
|
||||
UNAVAILABLE,
|
||||
CONFIRMED_ZERO,
|
||||
UNKNOWN
|
||||
}
|
||||
|
||||
enum class LeaderResearchQuoteConfidence {
|
||||
HIGH,
|
||||
MEDIUM,
|
||||
LOW,
|
||||
UNKNOWN
|
||||
}
|
||||
|
||||
enum class LeaderPaperFillAssumption {
|
||||
LEADER_PRICE,
|
||||
BEST_ASK_AT_EVENT,
|
||||
MID_PRICE,
|
||||
UNKNOWN
|
||||
}
|
||||
|
||||
enum class LeaderPaperFilterResult {
|
||||
PASSED,
|
||||
FILTERED
|
||||
}
|
||||
|
||||
enum class LeaderResearchEventType {
|
||||
RUN_STARTED,
|
||||
RUN_COMPLETED,
|
||||
RUN_FAILED,
|
||||
RUN_SKIPPED,
|
||||
SOURCE_SUCCESS,
|
||||
SOURCE_FAILURE,
|
||||
SOURCE_DISABLED,
|
||||
CANDIDATE_DISCOVERED,
|
||||
CANDIDATE_UPDATED,
|
||||
PAPER_STARTED,
|
||||
PAPER_TRADE_RECORDED,
|
||||
PAPER_TRADE_FILTERED,
|
||||
PAPER_PROCESSING_FAILED,
|
||||
STATE_TRANSITION,
|
||||
TRIAL_READY,
|
||||
COOLDOWN,
|
||||
RETIRED,
|
||||
VALUATION_STALE,
|
||||
APPROVAL_CREATED_DISABLED_CONFIG,
|
||||
APPROVAL_REJECTED,
|
||||
DUPLICATE_APPROVAL,
|
||||
REAL_MONEY_ACTIVATION_FORBIDDEN,
|
||||
NOTIFICATION_SUMMARY
|
||||
}
|
||||
|
||||
enum class LeaderResearchNotificationStatus {
|
||||
PENDING,
|
||||
SENT,
|
||||
FAILED,
|
||||
SKIPPED
|
||||
}
|
||||
@@ -1,7 +1,11 @@
|
||||
package com.wrbug.polymarketbot.repository
|
||||
|
||||
import com.wrbug.polymarketbot.entity.Account
|
||||
import jakarta.persistence.LockModeType
|
||||
import org.springframework.data.jpa.repository.JpaRepository
|
||||
import org.springframework.data.jpa.repository.Lock
|
||||
import org.springframework.data.jpa.repository.Query
|
||||
import org.springframework.data.repository.query.Param
|
||||
import org.springframework.stereotype.Repository
|
||||
|
||||
/**
|
||||
@@ -19,6 +23,10 @@ interface AccountRepository : JpaRepository<Account, Long> {
|
||||
* 查找默认账户
|
||||
*/
|
||||
fun findByIsDefaultTrue(): Account?
|
||||
|
||||
@Lock(LockModeType.PESSIMISTIC_WRITE)
|
||||
@Query("select a from Account a where a.id = :id")
|
||||
fun findByIdForUpdate(@Param("id") id: Long): Account?
|
||||
|
||||
/**
|
||||
* 查找所有账户,按创建时间排序
|
||||
@@ -35,4 +43,3 @@ interface AccountRepository : JpaRepository<Account, Long> {
|
||||
*/
|
||||
fun existsByProxyAddress(proxyAddress: String): Boolean
|
||||
}
|
||||
|
||||
|
||||
@@ -19,6 +19,11 @@ interface CopyTradingRepository : JpaRepository<CopyTrading, Long> {
|
||||
* 根据 Leader ID 查找跟单列表
|
||||
*/
|
||||
fun findByLeaderId(leaderId: Long): List<CopyTrading>
|
||||
|
||||
/**
|
||||
* 根据 Leader ID 批量查找跟单列表,用于聚合页面避免 N+1 查询。
|
||||
*/
|
||||
fun findByLeaderIdIn(leaderIds: Collection<Long>): List<CopyTrading>
|
||||
|
||||
/**
|
||||
* 根据账户ID和Leader ID查找跟单列表
|
||||
@@ -48,4 +53,3 @@ interface CopyTradingRepository : JpaRepository<CopyTrading, Long> {
|
||||
*/
|
||||
fun countByLeaderId(leaderId: Long): Long
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,21 @@
|
||||
package com.wrbug.polymarketbot.repository
|
||||
|
||||
import com.wrbug.polymarketbot.entity.LeaderPool
|
||||
import com.wrbug.polymarketbot.enums.LeaderPoolStatus
|
||||
import org.springframework.data.jpa.repository.JpaRepository
|
||||
import org.springframework.stereotype.Repository
|
||||
|
||||
@Repository
|
||||
interface LeaderPoolRepository : JpaRepository<LeaderPool, Long> {
|
||||
fun findByLeaderId(leaderId: Long): LeaderPool?
|
||||
|
||||
fun existsByLeaderId(leaderId: Long): Boolean
|
||||
|
||||
fun findByStatus(status: LeaderPoolStatus): List<LeaderPool>
|
||||
|
||||
fun findAllByOrderByCreatedAtDesc(): List<LeaderPool>
|
||||
|
||||
fun deleteByLeaderId(leaderId: Long)
|
||||
|
||||
fun findByIdIn(ids: Collection<Long>): List<LeaderPool>
|
||||
}
|
||||
@@ -29,5 +29,6 @@ interface LeaderRepository : JpaRepository<Leader, Long> {
|
||||
* 查找所有 Leader,按创建时间排序
|
||||
*/
|
||||
fun findAllByOrderByCreatedAtAsc(): List<Leader>
|
||||
}
|
||||
|
||||
fun findByIdIn(ids: Collection<Long>): List<Leader>
|
||||
}
|
||||
|
||||
+132
@@ -0,0 +1,132 @@
|
||||
package com.wrbug.polymarketbot.repository
|
||||
|
||||
import com.wrbug.polymarketbot.entity.*
|
||||
import com.wrbug.polymarketbot.enums.*
|
||||
import org.springframework.data.domain.Page
|
||||
import org.springframework.data.domain.Pageable
|
||||
import org.springframework.data.jpa.repository.JpaRepository
|
||||
import org.springframework.data.jpa.repository.Modifying
|
||||
import org.springframework.data.jpa.repository.Query
|
||||
import org.springframework.data.repository.query.Param
|
||||
import org.springframework.stereotype.Repository
|
||||
|
||||
@Repository
|
||||
interface LeaderResearchRunRepository : JpaRepository<LeaderResearchRun, Long> {
|
||||
fun findTopByOrderByStartedAtDesc(): LeaderResearchRun?
|
||||
fun findByStatus(status: LeaderResearchRunStatus): List<LeaderResearchRun>
|
||||
fun findTopByStatusOrderByStartedAtDesc(status: LeaderResearchRunStatus): LeaderResearchRun?
|
||||
}
|
||||
|
||||
@Repository
|
||||
interface LeaderResearchCandidateRepository : JpaRepository<LeaderResearchCandidate, Long> {
|
||||
fun findByNormalizedWallet(normalizedWallet: String): LeaderResearchCandidate?
|
||||
fun findByLeaderId(leaderId: Long): LeaderResearchCandidate?
|
||||
fun findByPoolId(poolId: Long): LeaderResearchCandidate?
|
||||
fun findByResearchState(researchState: LeaderResearchState): List<LeaderResearchCandidate>
|
||||
fun findByResearchStateIn(states: Collection<LeaderResearchState>): List<LeaderResearchCandidate>
|
||||
fun findByResearchStateIn(states: Collection<LeaderResearchState>, pageable: Pageable): Page<LeaderResearchCandidate>
|
||||
fun findAllByOrderByUpdatedAtDesc(pageable: Pageable): Page<LeaderResearchCandidate>
|
||||
fun countByResearchState(researchState: LeaderResearchState): Long
|
||||
|
||||
@Query(
|
||||
"""
|
||||
select c from LeaderResearchCandidate c
|
||||
where (:state is null or c.researchState = :state)
|
||||
and (
|
||||
:query is null
|
||||
or lower(c.normalizedWallet) like lower(concat(concat('%', :query), '%'))
|
||||
or lower(c.source) like lower(concat(concat('%', :query), '%'))
|
||||
or lower(coalesce(c.reason, '')) like lower(concat(concat('%', :query), '%'))
|
||||
or lower(coalesce(c.sourceEvidence, '')) like lower(concat(concat('%', :query), '%'))
|
||||
)
|
||||
order by c.updatedAt desc
|
||||
"""
|
||||
)
|
||||
fun search(
|
||||
@Param("state") state: LeaderResearchState?,
|
||||
@Param("query") query: String?,
|
||||
pageable: Pageable
|
||||
): Page<LeaderResearchCandidate>
|
||||
}
|
||||
|
||||
@Repository
|
||||
interface LeaderResearchScoreRepository : JpaRepository<LeaderResearchScore, Long> {
|
||||
fun findTopByCandidateIdOrderByCreatedAtDesc(candidateId: Long): LeaderResearchScore?
|
||||
fun findByCandidateIdOrderByCreatedAtDesc(candidateId: Long): List<LeaderResearchScore>
|
||||
}
|
||||
|
||||
@Repository
|
||||
interface LeaderResearchEventRepository : JpaRepository<LeaderResearchEvent, Long> {
|
||||
fun findByCandidateIdOrderByCreatedAtDesc(candidateId: Long, pageable: Pageable): Page<LeaderResearchEvent>
|
||||
fun findByRunIdOrderByCreatedAtDesc(runId: Long): List<LeaderResearchEvent>
|
||||
fun findByNotificationStatusOrderByCreatedAtAsc(status: LeaderResearchNotificationStatus, pageable: Pageable): Page<LeaderResearchEvent>
|
||||
fun findTopByDedupeKey(dedupeKey: String): LeaderResearchEvent?
|
||||
fun findAllByOrderByCreatedAtDesc(pageable: Pageable): Page<LeaderResearchEvent>
|
||||
}
|
||||
|
||||
@Repository
|
||||
interface LeaderResearchSourceStateRepository : JpaRepository<LeaderResearchSourceState, Long> {
|
||||
fun findBySourceType(sourceType: LeaderResearchSourceType): LeaderResearchSourceState?
|
||||
fun findAllByOrderByUpdatedAtDesc(): List<LeaderResearchSourceState>
|
||||
}
|
||||
|
||||
@Repository
|
||||
interface LeaderActivityEventRepository : JpaRepository<LeaderActivityEvent, Long> {
|
||||
fun findByStableEventKey(stableEventKey: String): LeaderActivityEvent?
|
||||
fun findBySourceAndSourceEventId(source: String, sourceEventId: String): LeaderActivityEvent?
|
||||
fun findTopByOrderByEventTimeDesc(): LeaderActivityEvent?
|
||||
fun findByNormalizedWalletAndEventTimeBetweenOrderByEventTimeAsc(normalizedWallet: String, start: Long, end: Long): List<LeaderActivityEvent>
|
||||
fun findByUsableForDiscoveryTrueAndEventTimeGreaterThanEqual(eventTime: Long): List<LeaderActivityEvent>
|
||||
fun findByPaperProcessingStatusInAndUsableForPaperTrueOrderByEventTimeAsc(statuses: Collection<LeaderPaperProcessingStatus>, pageable: Pageable): Page<LeaderActivityEvent>
|
||||
|
||||
fun deleteByEventTimeLessThanAndPaperProcessingStatusIn(
|
||||
eventTime: Long,
|
||||
statuses: Collection<LeaderPaperProcessingStatus>
|
||||
): Long
|
||||
|
||||
@Modifying
|
||||
@Query(
|
||||
"update LeaderActivityEvent e set e.paperProcessingStatus = :nextStatus, e.paperProcessingStartedAt = :startedAt, e.processingAttempts = e.processingAttempts + 1, e.updatedAt = :startedAt where e.id = :id and e.paperProcessingStatus in :allowed"
|
||||
)
|
||||
fun claimForPaperProcessing(
|
||||
@Param("id") id: Long,
|
||||
@Param("allowed") allowed: Collection<LeaderPaperProcessingStatus>,
|
||||
@Param("nextStatus") nextStatus: LeaderPaperProcessingStatus,
|
||||
@Param("startedAt") startedAt: Long
|
||||
): Int
|
||||
}
|
||||
|
||||
@Repository
|
||||
interface LeaderPaperSessionRepository : JpaRepository<LeaderPaperSession, Long> {
|
||||
fun findTopByCandidateIdAndStatusOrderByStartedAtDesc(candidateId: Long, status: LeaderPaperSessionStatus): LeaderPaperSession?
|
||||
fun findTopByCandidateIdOrderByStartedAtDesc(candidateId: Long): LeaderPaperSession?
|
||||
fun findByCandidateIdOrderByStartedAtDesc(candidateId: Long): List<LeaderPaperSession>
|
||||
fun findByUpdatedAtLessThanAndStatusIn(updatedAt: Long, statuses: Collection<LeaderPaperSessionStatus>, pageable: Pageable): Page<LeaderPaperSession>
|
||||
|
||||
@Query(
|
||||
"""
|
||||
select s from LeaderPaperSession s
|
||||
where s.candidateId in :candidateIds
|
||||
and s.startedAt = (
|
||||
select max(s2.startedAt) from LeaderPaperSession s2 where s2.candidateId = s.candidateId
|
||||
)
|
||||
"""
|
||||
)
|
||||
fun findLatestByCandidateIds(@Param("candidateIds") candidateIds: Collection<Long>): List<LeaderPaperSession>
|
||||
}
|
||||
|
||||
@Repository
|
||||
interface LeaderPaperTradeRepository : JpaRepository<LeaderPaperTrade, Long> {
|
||||
fun existsBySessionIdAndLeaderTradeIdAndSide(sessionId: Long, leaderTradeId: String, side: String): Boolean
|
||||
fun findBySessionIdOrderByEventTimeDesc(sessionId: Long, pageable: Pageable): Page<LeaderPaperTrade>
|
||||
fun findBySessionIdOrderByEventTimeAsc(sessionId: Long): List<LeaderPaperTrade>
|
||||
fun countBySessionId(sessionId: Long): Long
|
||||
fun countBySessionIdAndFilterResult(sessionId: Long, filterResult: LeaderPaperFilterResult): Long
|
||||
}
|
||||
|
||||
@Repository
|
||||
interface LeaderPaperPositionRepository : JpaRepository<LeaderPaperPosition, Long> {
|
||||
fun findBySessionIdAndMarketIdAndOutcomeIndex(sessionId: Long, marketId: String, outcomeIndex: Int?): LeaderPaperPosition?
|
||||
fun findBySessionIdOrderByUpdatedAtDesc(sessionId: Long): List<LeaderPaperPosition>
|
||||
fun findByCandidateIdOrderByUpdatedAtDesc(candidateId: Long): List<LeaderPaperPosition>
|
||||
}
|
||||
+87
@@ -0,0 +1,87 @@
|
||||
package com.wrbug.polymarketbot.service.copytrading.configs
|
||||
|
||||
import com.wrbug.polymarketbot.dto.ApplyConservativeConfigRequest
|
||||
import com.wrbug.polymarketbot.entity.CopyTrading
|
||||
import java.math.BigDecimal
|
||||
|
||||
object CopyTradingSafetyConfigService {
|
||||
private val MAX_DAILY_LOSS_LIMIT = BigDecimal("10")
|
||||
private val MAX_POSITION_VALUE_LIMIT = BigDecimal("10")
|
||||
private val MIN_ORDER_DEPTH_LIMIT = BigDecimal("100")
|
||||
private val MAX_SPREAD_LIMIT = BigDecimal("0.03")
|
||||
private val PRICE_TOLERANCE_LIMIT = BigDecimal("3")
|
||||
|
||||
fun applyConservativeConfig(
|
||||
current: CopyTrading,
|
||||
request: ApplyConservativeConfigRequest
|
||||
): CopyTrading {
|
||||
if (!request.confirm) {
|
||||
throw IllegalStateException("应用保守配置需要显式确认")
|
||||
}
|
||||
|
||||
val maxDailyOrders = request.maxDailyOrders?.also {
|
||||
if (it !in 1..20) {
|
||||
throw IllegalArgumentException("maxDailyOrders 必须在 1 到 20 之间")
|
||||
}
|
||||
} ?: current.maxDailyOrders
|
||||
|
||||
val maxDailyLoss = request.maxDailyLoss?.asPositiveDecimalAtMost("maxDailyLoss", MAX_DAILY_LOSS_LIMIT)
|
||||
?: current.maxDailyLoss
|
||||
val minPrice = request.minPrice?.asPrice("minPrice") ?: current.minPrice
|
||||
val maxPrice = request.maxPrice?.asPrice("maxPrice") ?: current.maxPrice
|
||||
if (minPrice != null && maxPrice != null && minPrice > maxPrice) {
|
||||
throw IllegalArgumentException("minPrice 不能大于 maxPrice")
|
||||
}
|
||||
|
||||
return current.copy(
|
||||
maxDailyOrders = maxDailyOrders,
|
||||
maxDailyLoss = maxDailyLoss,
|
||||
minPrice = minPrice,
|
||||
maxPrice = maxPrice,
|
||||
maxPositionValue = request.maxPositionValue?.asPositiveDecimalAtMost("maxPositionValue", MAX_POSITION_VALUE_LIMIT)
|
||||
?: current.maxPositionValue,
|
||||
minOrderDepth = request.minOrderDepth?.asPositiveDecimalAtLeast("minOrderDepth", MIN_ORDER_DEPTH_LIMIT)
|
||||
?: current.minOrderDepth,
|
||||
maxSpread = request.maxSpread?.asPositiveDecimalAtMost("maxSpread", MAX_SPREAD_LIMIT)
|
||||
?: current.maxSpread,
|
||||
priceTolerance = request.priceTolerance?.asPositiveDecimalAtMost("priceTolerance", PRICE_TOLERANCE_LIMIT)
|
||||
?: current.priceTolerance,
|
||||
updatedAt = System.currentTimeMillis()
|
||||
)
|
||||
}
|
||||
|
||||
private fun String.asPositiveDecimal(field: String): BigDecimal {
|
||||
val value = trim().toBigDecimalOrNull()
|
||||
?: throw IllegalArgumentException("$field 必须是有效数字")
|
||||
if (value <= BigDecimal.ZERO) {
|
||||
throw IllegalArgumentException("$field 必须大于 0")
|
||||
}
|
||||
return value
|
||||
}
|
||||
|
||||
private fun String.asPositiveDecimalAtMost(field: String, max: BigDecimal): BigDecimal {
|
||||
val value = asPositiveDecimal(field)
|
||||
if (value > max) {
|
||||
throw IllegalArgumentException("$field 必须大于 0 且不超过 ${max.strip()}")
|
||||
}
|
||||
return value
|
||||
}
|
||||
|
||||
private fun String.asPositiveDecimalAtLeast(field: String, min: BigDecimal): BigDecimal {
|
||||
val value = asPositiveDecimal(field)
|
||||
if (value < min) {
|
||||
throw IllegalArgumentException("$field 必须不小于 ${min.strip()}")
|
||||
}
|
||||
return value
|
||||
}
|
||||
|
||||
private fun String.asPrice(field: String): BigDecimal {
|
||||
val value = asPositiveDecimal(field)
|
||||
if (value > BigDecimal.ONE) {
|
||||
throw IllegalArgumentException("$field 必须在 0 到 1 之间")
|
||||
}
|
||||
return value
|
||||
}
|
||||
|
||||
private fun BigDecimal.strip(): String = stripTrailingZeros().toPlainString()
|
||||
}
|
||||
+30
@@ -328,6 +328,36 @@ class CopyTradingService(
|
||||
Result.failure(e)
|
||||
}
|
||||
}
|
||||
|
||||
@Transactional
|
||||
fun applyConservativeConfig(request: ApplyConservativeConfigRequest): Result<CopyTradingDto> {
|
||||
return try {
|
||||
val copyTrading = copyTradingRepository.findById(request.copyTradingId).orElse(null)
|
||||
?: return Result.failure(IllegalArgumentException("跟单配置不存在"))
|
||||
val updated = CopyTradingSafetyConfigService.applyConservativeConfig(copyTrading, request)
|
||||
val saved = copyTradingRepository.save(updated)
|
||||
|
||||
kotlinx.coroutines.runBlocking {
|
||||
try {
|
||||
monitorService.updateLeaderMonitoring(saved.leaderId)
|
||||
monitorService.updateAccountMonitoring(saved.accountId)
|
||||
} catch (e: Exception) {
|
||||
logger.error("更新监听失败", e)
|
||||
}
|
||||
}
|
||||
|
||||
val account = accountRepository.findById(saved.accountId).orElse(null)
|
||||
val leader = leaderRepository.findById(saved.leaderId).orElse(null)
|
||||
if (account == null || leader == null) {
|
||||
return Result.failure(IllegalStateException("跟单配置数据不完整"))
|
||||
}
|
||||
|
||||
Result.success(toDto(saved, account, leader))
|
||||
} catch (e: Exception) {
|
||||
logger.error("应用保守配置失败", e)
|
||||
Result.failure(e)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 更新跟单状态(兼容旧接口)
|
||||
|
||||
+13
@@ -0,0 +1,13 @@
|
||||
package com.wrbug.polymarketbot.service.copytrading.leaderpool
|
||||
|
||||
class LeaderPoolNotFoundException(message: String = "Leader 池项不存在") : RuntimeException(message)
|
||||
|
||||
class LeaderPoolAlreadyExistsException(message: String = "Leader 已在池子中") : RuntimeException(message)
|
||||
|
||||
class LeaderPoolDuplicateTrialConfigException(message: String = "该账户已存在此 Leader 的跟单配置") : RuntimeException(message)
|
||||
|
||||
class LeaderPoolConfirmRequiredException(message: String = "立即启用试跟配置需要显式确认") : RuntimeException(message)
|
||||
|
||||
class LeaderPoolResearchCandidateNotReadyException(
|
||||
message: String = "研究候选尚未进入试跟建议状态"
|
||||
) : RuntimeException(message)
|
||||
+451
@@ -0,0 +1,451 @@
|
||||
package com.wrbug.polymarketbot.service.copytrading.leaderpool
|
||||
|
||||
import com.wrbug.polymarketbot.dto.*
|
||||
import com.wrbug.polymarketbot.entity.CopyTrading
|
||||
import com.wrbug.polymarketbot.entity.Leader
|
||||
import com.wrbug.polymarketbot.entity.LeaderPool
|
||||
import com.wrbug.polymarketbot.enums.LeaderPoolStatus
|
||||
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.LeaderRepository
|
||||
import com.wrbug.polymarketbot.service.copytrading.configs.CopyTradingService
|
||||
import org.slf4j.LoggerFactory
|
||||
import org.springframework.dao.DataIntegrityViolationException
|
||||
import org.springframework.stereotype.Service
|
||||
import org.springframework.transaction.annotation.Transactional
|
||||
import java.math.BigDecimal
|
||||
|
||||
@Service
|
||||
class LeaderPoolService(
|
||||
private val leaderPoolRepository: LeaderPoolRepository,
|
||||
private val leaderRepository: LeaderRepository,
|
||||
private val copyTradingRepository: CopyTradingRepository,
|
||||
private val accountRepository: AccountRepository,
|
||||
private val copyTradingService: CopyTradingService
|
||||
) {
|
||||
private val logger = LoggerFactory.getLogger(LeaderPoolService::class.java)
|
||||
|
||||
@Transactional
|
||||
open fun addToPool(request: LeaderPoolAddRequest): Result<LeaderPoolItemDto> {
|
||||
return try {
|
||||
val leader = leaderRepository.findById(request.leaderId).orElse(null)
|
||||
if (leader == null) {
|
||||
logger.warn("拒绝加入 Leader 池,Leader 不存在: leaderId={}", request.leaderId)
|
||||
return Result.failure(IllegalArgumentException("Leader 不存在"))
|
||||
}
|
||||
|
||||
leaderPoolRepository.findByLeaderId(request.leaderId)?.let {
|
||||
logger.warn("Leader 已在池子中: leaderId={}, poolId={}", request.leaderId, it.id)
|
||||
return Result.failure(LeaderPoolAlreadyExistsException())
|
||||
}
|
||||
|
||||
val now = System.currentTimeMillis()
|
||||
val pool = LeaderPool(
|
||||
leaderId = request.leaderId,
|
||||
source = request.source?.trim().takeUnless { it.isNullOrBlank() } ?: "MANUAL",
|
||||
reason = request.reason?.trim().takeUnless { it.isNullOrBlank() },
|
||||
notes = request.notes?.trim().takeUnless { it.isNullOrBlank() },
|
||||
createdAt = now,
|
||||
updatedAt = now
|
||||
)
|
||||
|
||||
val saved = try {
|
||||
leaderPoolRepository.saveAndFlush(pool)
|
||||
} catch (e: DataIntegrityViolationException) {
|
||||
logger.warn("并发重复加入 Leader 池: leaderId={}", request.leaderId, e)
|
||||
return Result.failure(LeaderPoolAlreadyExistsException())
|
||||
}
|
||||
|
||||
logger.info("Leader 加入池子: leaderId={}, poolId={}, status={}", saved.leaderId, saved.id, saved.status)
|
||||
Result.success(toDto(saved, leader, emptyList()))
|
||||
} catch (e: Exception) {
|
||||
logger.error("加入 Leader 池失败: leaderId=${request.leaderId}", e)
|
||||
Result.failure(e)
|
||||
}
|
||||
}
|
||||
|
||||
open fun getPoolList(request: LeaderPoolListRequest): Result<LeaderPoolListResponse> {
|
||||
return try {
|
||||
val status = request.status?.trim().takeUnless { it.isNullOrBlank() }?.let { parseStatus(it) }
|
||||
val pools = if (status != null) {
|
||||
leaderPoolRepository.findByStatus(status).sortedByDescending { it.createdAt }
|
||||
} else {
|
||||
leaderPoolRepository.findAllByOrderByCreatedAtDesc()
|
||||
}
|
||||
|
||||
val leaderIds = pools.map { it.leaderId }.distinct()
|
||||
val leaders = if (leaderIds.isEmpty()) {
|
||||
emptyMap()
|
||||
} else {
|
||||
leaderRepository.findAllById(leaderIds).associateBy { it.id!! }
|
||||
}
|
||||
val copyTradingsByLeader = if (leaderIds.isEmpty()) {
|
||||
emptyMap()
|
||||
} else {
|
||||
copyTradingRepository.findByLeaderIdIn(leaderIds).groupBy { it.leaderId }
|
||||
}
|
||||
|
||||
val items = pools.mapNotNull { pool ->
|
||||
val leader = leaders[pool.leaderId]
|
||||
if (leader == null) {
|
||||
logger.warn("Leader 池项缺少 leader 记录: poolId={}, leaderId={}", pool.id, pool.leaderId)
|
||||
null
|
||||
} else {
|
||||
toDto(pool, leader, copyTradingsByLeader[pool.leaderId].orEmpty())
|
||||
}
|
||||
}
|
||||
|
||||
val estimatedWorstExposure = items.fold(BigDecimal.ZERO) { acc, item ->
|
||||
acc + BigDecimal(item.estimatedWorstExposure)
|
||||
}
|
||||
val summary = LeaderPoolSummaryDto(
|
||||
totalCount = items.size,
|
||||
trialCount = items.count { it.status == LeaderPoolStatus.TRIAL.name || it.status == LeaderPoolStatus.ACTIVE.name },
|
||||
estimatedWorstExposure = estimatedWorstExposure.strip(),
|
||||
pendingRiskCount = items.count { it.status == LeaderPoolStatus.COOLDOWN.name || it.hasEnabledCopyTrading },
|
||||
)
|
||||
|
||||
Result.success(
|
||||
LeaderPoolListResponse(
|
||||
summary = summary,
|
||||
list = items,
|
||||
total = items.size
|
||||
)
|
||||
)
|
||||
} catch (e: Exception) {
|
||||
logger.error("查询 Leader 池列表失败", e)
|
||||
Result.failure(e)
|
||||
}
|
||||
}
|
||||
|
||||
@Transactional
|
||||
open fun updateStatus(request: LeaderPoolUpdateStatusRequest): Result<LeaderPoolItemDto> {
|
||||
return try {
|
||||
val pool = findPool(request.poolId)
|
||||
val leader = findLeader(pool.leaderId)
|
||||
val newStatus = parseStatus(request.status)
|
||||
val now = System.currentTimeMillis()
|
||||
val updated = pool.copy(
|
||||
status = newStatus,
|
||||
cooldownUntil = if (newStatus == LeaderPoolStatus.COOLDOWN) request.cooldownUntil else request.cooldownUntil ?: pool.cooldownUntil,
|
||||
locked = request.locked ?: pool.locked,
|
||||
lastReviewedAt = now,
|
||||
updatedAt = now
|
||||
)
|
||||
|
||||
val saved = leaderPoolRepository.save(updated)
|
||||
logger.info(
|
||||
"Leader 池状态变化: poolId={}, leaderId={}, status={}, cooldownUntil={}",
|
||||
saved.id,
|
||||
saved.leaderId,
|
||||
saved.status,
|
||||
saved.cooldownUntil
|
||||
)
|
||||
Result.success(toDto(saved, leader, copyTradingRepository.findByLeaderId(saved.leaderId)))
|
||||
} catch (e: Exception) {
|
||||
logger.error("更新 Leader 池状态失败: poolId=${request.poolId}", e)
|
||||
Result.failure(e)
|
||||
}
|
||||
}
|
||||
|
||||
@Transactional
|
||||
open fun updatePlan(request: LeaderPoolUpdatePlanRequest): Result<LeaderPoolItemDto> {
|
||||
return try {
|
||||
val pool = findPool(request.poolId)
|
||||
val leader = findLeader(pool.leaderId)
|
||||
val nextFixedAmount = parseDecimal("suggestedFixedAmount", request.suggestedFixedAmount) ?: pool.suggestedFixedAmount
|
||||
val nextMaxDailyOrders = request.suggestedMaxDailyOrders ?: pool.suggestedMaxDailyOrders
|
||||
val nextMaxDailyLoss = parseDecimal("suggestedMaxDailyLoss", request.suggestedMaxDailyLoss) ?: pool.suggestedMaxDailyLoss
|
||||
val nextMinPrice = parseNullableDecimal("suggestedMinPrice", request.suggestedMinPrice, pool.suggestedMinPrice)
|
||||
val nextMaxPrice = parseNullableDecimal("suggestedMaxPrice", request.suggestedMaxPrice, pool.suggestedMaxPrice)
|
||||
val nextMaxPositionValue = parseNullableDecimal(
|
||||
"suggestedMaxPositionValue",
|
||||
request.suggestedMaxPositionValue,
|
||||
pool.suggestedMaxPositionValue
|
||||
)
|
||||
|
||||
validateSuggestedPlan(
|
||||
fixedAmount = nextFixedAmount,
|
||||
maxDailyOrders = nextMaxDailyOrders,
|
||||
maxDailyLoss = nextMaxDailyLoss,
|
||||
minPrice = nextMinPrice,
|
||||
maxPrice = nextMaxPrice,
|
||||
maxPositionValue = nextMaxPositionValue
|
||||
)
|
||||
|
||||
val now = System.currentTimeMillis()
|
||||
val updated = pool.copy(
|
||||
suggestedFixedAmount = nextFixedAmount,
|
||||
suggestedMaxDailyOrders = nextMaxDailyOrders,
|
||||
suggestedMaxDailyLoss = nextMaxDailyLoss,
|
||||
suggestedMinPrice = nextMinPrice,
|
||||
suggestedMaxPrice = nextMaxPrice,
|
||||
suggestedMaxPositionValue = nextMaxPositionValue,
|
||||
reason = request.reason?.trim().takeUnless { it.isNullOrBlank() } ?: pool.reason,
|
||||
notes = request.notes?.trim().takeUnless { it.isNullOrBlank() } ?: pool.notes,
|
||||
lastReviewedAt = now,
|
||||
updatedAt = now
|
||||
)
|
||||
|
||||
val saved = leaderPoolRepository.save(updated)
|
||||
logger.info("Leader 池建议配置更新: poolId={}, leaderId={}", saved.id, saved.leaderId)
|
||||
Result.success(toDto(saved, leader, copyTradingRepository.findByLeaderId(saved.leaderId)))
|
||||
} catch (e: Exception) {
|
||||
logger.error("更新 Leader 池建议配置失败: poolId=${request.poolId}", e)
|
||||
Result.failure(e)
|
||||
}
|
||||
}
|
||||
|
||||
@Transactional
|
||||
open fun createTrialConfig(request: LeaderPoolCreateTrialConfigRequest): Result<CopyTradingDto> {
|
||||
val pool = try {
|
||||
findPool(request.poolId)
|
||||
} catch (e: Exception) {
|
||||
logger.error("创建 Leader 池试跟配置失败: poolId=${request.poolId}", e)
|
||||
return Result.failure(e)
|
||||
}
|
||||
|
||||
return try {
|
||||
if (pool.researchCandidateId != null && pool.researchState != LeaderResearchState.TRIAL_READY) {
|
||||
logger.warn(
|
||||
"拒绝从未试跟建议的研究候选创建 Leader 池试跟配置: poolId={}, leaderId={}, researchState={}",
|
||||
pool.id,
|
||||
pool.leaderId,
|
||||
pool.researchState
|
||||
)
|
||||
return Result.failure(LeaderPoolResearchCandidateNotReadyException())
|
||||
}
|
||||
if (request.enableImmediately && !request.confirm) {
|
||||
logger.warn("拒绝未确认的立即启用试跟配置: poolId={}, leaderId={}", pool.id, pool.leaderId)
|
||||
return Result.failure(LeaderPoolConfirmRequiredException())
|
||||
}
|
||||
val account = accountRepository.findById(request.accountId).orElse(null)
|
||||
if (account == null) {
|
||||
logger.warn(
|
||||
"拒绝创建 Leader 池试跟配置,账户不存在: poolId={}, leaderId={}, accountId={}",
|
||||
pool.id,
|
||||
pool.leaderId,
|
||||
request.accountId
|
||||
)
|
||||
return Result.failure(IllegalArgumentException("账户不存在"))
|
||||
}
|
||||
val leader = findLeader(pool.leaderId)
|
||||
|
||||
validateSuggestedPlan(
|
||||
fixedAmount = pool.suggestedFixedAmount,
|
||||
maxDailyOrders = pool.suggestedMaxDailyOrders,
|
||||
maxDailyLoss = pool.suggestedMaxDailyLoss,
|
||||
minPrice = pool.suggestedMinPrice,
|
||||
maxPrice = pool.suggestedMaxPrice,
|
||||
maxPositionValue = pool.suggestedMaxPositionValue
|
||||
)
|
||||
|
||||
if (copyTradingRepository.findByAccountIdAndLeaderId(request.accountId, pool.leaderId).isNotEmpty()) {
|
||||
logger.warn(
|
||||
"拒绝重复创建 Leader 池试跟配置: poolId={}, leaderId={}, accountId={}",
|
||||
pool.id,
|
||||
pool.leaderId,
|
||||
request.accountId
|
||||
)
|
||||
return Result.failure(LeaderPoolDuplicateTrialConfigException())
|
||||
}
|
||||
|
||||
val copyTradingRequest = CopyTradingCreateRequest(
|
||||
accountId = request.accountId,
|
||||
leaderId = pool.leaderId,
|
||||
enabled = request.enableImmediately && request.confirm,
|
||||
copyMode = "FIXED",
|
||||
copyRatio = "1",
|
||||
fixedAmount = pool.suggestedFixedAmount.strip(),
|
||||
maxOrderSize = pool.suggestedFixedAmount.strip(),
|
||||
minOrderSize = "1",
|
||||
maxDailyLoss = pool.suggestedMaxDailyLoss.strip(),
|
||||
maxDailyOrders = pool.suggestedMaxDailyOrders,
|
||||
priceTolerance = "1",
|
||||
delaySeconds = 0,
|
||||
pollIntervalSeconds = 5,
|
||||
useWebSocket = true,
|
||||
websocketReconnectInterval = 5000,
|
||||
websocketMaxRetries = 10,
|
||||
supportSell = true,
|
||||
minPrice = pool.suggestedMinPrice?.strip(),
|
||||
maxPrice = pool.suggestedMaxPrice?.strip(),
|
||||
maxPositionValue = pool.suggestedMaxPositionValue?.strip(),
|
||||
keywordFilterMode = "DISABLED",
|
||||
keywords = null,
|
||||
configName = buildTrialConfigName(leader),
|
||||
pushFailedOrders = true,
|
||||
pushFilteredOrders = true
|
||||
)
|
||||
|
||||
val result = copyTradingService.createCopyTrading(copyTradingRequest)
|
||||
result.onSuccess {
|
||||
val now = System.currentTimeMillis()
|
||||
leaderPoolRepository.save(
|
||||
pool.copy(
|
||||
status = LeaderPoolStatus.TRIAL,
|
||||
lastPromotedAt = now,
|
||||
lastReviewedAt = now,
|
||||
updatedAt = now
|
||||
)
|
||||
)
|
||||
logger.info(
|
||||
"Leader 池试跟配置创建成功: poolId={}, leaderId={}, accountId={}, copyTradingId={}",
|
||||
pool.id,
|
||||
pool.leaderId,
|
||||
request.accountId,
|
||||
it.id
|
||||
)
|
||||
}.onFailure {
|
||||
logger.error(
|
||||
"Leader 池试跟配置创建失败: poolId=${pool.id}, leaderId=${pool.leaderId}, accountId=${request.accountId}, error=${it.message}",
|
||||
it
|
||||
)
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
logger.error(
|
||||
"创建 Leader 池试跟配置异常: poolId=${pool.id}, leaderId=${pool.leaderId}, accountId=${request.accountId}",
|
||||
e
|
||||
)
|
||||
Result.failure(e)
|
||||
}
|
||||
}
|
||||
|
||||
@Transactional
|
||||
open fun remove(request: LeaderPoolRemoveRequest): Result<Unit> {
|
||||
return try {
|
||||
val pool = findPool(request.poolId)
|
||||
leaderPoolRepository.delete(pool)
|
||||
logger.info("Leader 池项移除: poolId={}, leaderId={}", pool.id, pool.leaderId)
|
||||
Result.success(Unit)
|
||||
} catch (e: Exception) {
|
||||
logger.error("移除 Leader 池项失败: poolId=${request.poolId}", e)
|
||||
Result.failure(e)
|
||||
}
|
||||
}
|
||||
|
||||
private fun findPool(poolId: Long): LeaderPool {
|
||||
return leaderPoolRepository.findById(poolId).orElse(null) ?: throw LeaderPoolNotFoundException()
|
||||
}
|
||||
|
||||
private fun findLeader(leaderId: Long): Leader {
|
||||
return leaderRepository.findById(leaderId).orElse(null) ?: throw IllegalArgumentException("Leader 不存在")
|
||||
}
|
||||
|
||||
private fun parseStatus(status: String): LeaderPoolStatus {
|
||||
return try {
|
||||
LeaderPoolStatus.valueOf(status.trim().uppercase())
|
||||
} catch (e: Exception) {
|
||||
throw IllegalArgumentException("Leader 池状态无效")
|
||||
}
|
||||
}
|
||||
|
||||
private fun parseDecimal(fieldName: String, value: String?): BigDecimal? {
|
||||
if (value == null) return null
|
||||
return value.trim().takeUnless { it.isBlank() }?.toBigDecimalOrNull()
|
||||
?: throw IllegalArgumentException("$fieldName 必须是有效数字")
|
||||
}
|
||||
|
||||
private fun parseNullableDecimal(fieldName: String, value: String?, current: BigDecimal?): BigDecimal? {
|
||||
if (value == null) return current
|
||||
val trimmed = value.trim()
|
||||
if (trimmed.isBlank()) return null
|
||||
return trimmed.toBigDecimalOrNull() ?: throw IllegalArgumentException("$fieldName 必须是有效数字")
|
||||
}
|
||||
|
||||
private fun validateSuggestedPlan(
|
||||
fixedAmount: BigDecimal,
|
||||
maxDailyOrders: Int,
|
||||
maxDailyLoss: BigDecimal,
|
||||
minPrice: BigDecimal?,
|
||||
maxPrice: BigDecimal?,
|
||||
maxPositionValue: BigDecimal?
|
||||
) {
|
||||
if (fixedAmount <= BigDecimal.ZERO) {
|
||||
throw IllegalArgumentException("suggestedFixedAmount 必须大于 0")
|
||||
}
|
||||
if (maxDailyOrders !in 1..100) {
|
||||
throw IllegalArgumentException("suggestedMaxDailyOrders 必须在 1 到 100 之间")
|
||||
}
|
||||
if (maxDailyLoss <= BigDecimal.ZERO) {
|
||||
throw IllegalArgumentException("suggestedMaxDailyLoss 必须大于 0")
|
||||
}
|
||||
minPrice?.let {
|
||||
if (it < BigDecimal.ZERO || it > BigDecimal.ONE) {
|
||||
throw IllegalArgumentException("suggestedMinPrice 必须在 0 到 1 之间")
|
||||
}
|
||||
}
|
||||
maxPrice?.let {
|
||||
if (it < BigDecimal.ZERO || it > BigDecimal.ONE) {
|
||||
throw IllegalArgumentException("suggestedMaxPrice 必须在 0 到 1 之间")
|
||||
}
|
||||
}
|
||||
if (minPrice != null && maxPrice != null && minPrice > maxPrice) {
|
||||
throw IllegalArgumentException("suggestedMinPrice 不能大于 suggestedMaxPrice")
|
||||
}
|
||||
maxPositionValue?.let {
|
||||
if (it <= BigDecimal.ZERO) {
|
||||
throw IllegalArgumentException("suggestedMaxPositionValue 必须大于 0")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun toDto(pool: LeaderPool, leader: Leader, copyTradings: List<CopyTrading>): LeaderPoolItemDto {
|
||||
val isTrialOrActive = pool.status == LeaderPoolStatus.TRIAL || pool.status == LeaderPoolStatus.ACTIVE
|
||||
val estimatedWorstExposure = if (isTrialOrActive) {
|
||||
pool.suggestedMaxPositionValue ?: DEFAULT_MAX_POSITION_VALUE
|
||||
} else {
|
||||
BigDecimal.ZERO
|
||||
}
|
||||
val leaderAddress = leader.leaderAddress
|
||||
return LeaderPoolItemDto(
|
||||
id = pool.id!!,
|
||||
leaderId = pool.leaderId,
|
||||
leaderName = leader.leaderName,
|
||||
leaderAddress = leaderAddress,
|
||||
category = leader.category,
|
||||
profileUrl = "https://polymarket.com/profile/$leaderAddress",
|
||||
status = pool.status.name,
|
||||
source = pool.source,
|
||||
sourceRank = pool.sourceRank,
|
||||
score = pool.score?.strip(),
|
||||
reason = pool.reason,
|
||||
notes = pool.notes,
|
||||
suggestedFixedAmount = pool.suggestedFixedAmount.strip(),
|
||||
suggestedMaxDailyOrders = pool.suggestedMaxDailyOrders,
|
||||
suggestedMaxDailyLoss = pool.suggestedMaxDailyLoss.strip(),
|
||||
suggestedMinPrice = pool.suggestedMinPrice?.strip(),
|
||||
suggestedMaxPrice = pool.suggestedMaxPrice?.strip(),
|
||||
suggestedMaxPositionValue = pool.suggestedMaxPositionValue?.strip(),
|
||||
copyTradingCount = copyTradings.size,
|
||||
hasEnabledCopyTrading = copyTradings.any { it.enabled },
|
||||
estimatedWorstExposure = estimatedWorstExposure.strip(),
|
||||
lastReviewedAt = pool.lastReviewedAt,
|
||||
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
|
||||
)
|
||||
}
|
||||
|
||||
private fun buildTrialConfigName(leader: Leader): String {
|
||||
val baseName = leader.leaderName?.trim().takeUnless { it.isNullOrBlank() }
|
||||
?: leader.leaderAddress.takeLast(6)
|
||||
return "Leader池-$baseName"
|
||||
}
|
||||
|
||||
private fun BigDecimal.strip(): String = stripTrailingZeros().toPlainString()
|
||||
|
||||
companion object {
|
||||
private val DEFAULT_MAX_POSITION_VALUE = BigDecimal("5")
|
||||
}
|
||||
}
|
||||
+116
-2
@@ -6,7 +6,11 @@ import com.wrbug.polymarketbot.api.TradeResponse
|
||||
import com.wrbug.polymarketbot.dto.ActivityTradeMessage
|
||||
import com.wrbug.polymarketbot.dto.ActivityTradePayload
|
||||
import com.wrbug.polymarketbot.entity.Leader
|
||||
import com.wrbug.polymarketbot.enums.LeaderResearchSourceStatus
|
||||
import com.wrbug.polymarketbot.enums.LeaderResearchSourceType
|
||||
import com.wrbug.polymarketbot.repository.LeaderRepository
|
||||
import com.wrbug.polymarketbot.service.copytrading.research.LeaderActivityIngestionService
|
||||
import com.wrbug.polymarketbot.service.copytrading.research.LeaderResearchSourceHealthService
|
||||
import com.wrbug.polymarketbot.service.copytrading.statistics.CopyOrderTrackingService
|
||||
import com.wrbug.polymarketbot.util.fromJson
|
||||
import com.wrbug.polymarketbot.constants.PolymarketConstants
|
||||
@@ -14,6 +18,8 @@ import com.wrbug.polymarketbot.websocket.PolymarketWebSocketClient
|
||||
import jakarta.annotation.PreDestroy
|
||||
import kotlinx.coroutines.*
|
||||
import org.slf4j.LoggerFactory
|
||||
import org.springframework.beans.factory.ObjectProvider
|
||||
import org.springframework.beans.factory.annotation.Value
|
||||
import org.springframework.stereotype.Service
|
||||
import java.math.BigDecimal
|
||||
import java.util.concurrent.ConcurrentHashMap
|
||||
@@ -27,7 +33,11 @@ import java.util.concurrent.TimeUnit
|
||||
@Service
|
||||
class PolymarketActivityWsService(
|
||||
private val copyOrderTrackingService: CopyOrderTrackingService,
|
||||
private val leaderRepository: LeaderRepository
|
||||
private val leaderRepository: LeaderRepository,
|
||||
private val researchIngestionProvider: ObjectProvider<LeaderActivityIngestionService>,
|
||||
private val researchSourceHealthProvider: ObjectProvider<LeaderResearchSourceHealthService>,
|
||||
@Value("\${leader.research.global-capture.enabled:false}") private val researchGlobalCaptureEnabled: Boolean,
|
||||
@Value("\${leader.research.global-capture.max-writes-per-minute:120}") private val researchGlobalCaptureMaxWritesPerMinute: Long
|
||||
) {
|
||||
|
||||
private val logger = LoggerFactory.getLogger(PolymarketActivityWsService::class.java)
|
||||
@@ -65,6 +75,10 @@ class PolymarketActivityWsService(
|
||||
private var addressMatchMessages = 0L
|
||||
private var jsonParseMessages = 0L
|
||||
private var duplicateTxHashMessages = 0L
|
||||
private var researchCaptureWindowMinute = 0L
|
||||
private var researchCaptureWritesThisMinute = 0L
|
||||
private var researchCaptureLastHealthStatus: LeaderResearchSourceStatus? = null
|
||||
private var researchCaptureLastHealthWriteAt = 0L
|
||||
|
||||
/**
|
||||
* 启动监听
|
||||
@@ -314,6 +328,8 @@ class PolymarketActivityWsService(
|
||||
return
|
||||
}
|
||||
|
||||
maybeCaptureResearchActivity(message)
|
||||
|
||||
// 快速预检查:检查是否包含监听地址
|
||||
// 绝大部分消息会在这一步被过滤掉,避免不必要的 JSON 解析
|
||||
if (!containsMonitoredAddress(message)) {
|
||||
@@ -390,6 +406,100 @@ class PolymarketActivityWsService(
|
||||
}
|
||||
}
|
||||
|
||||
private fun maybeCaptureResearchActivity(message: String) {
|
||||
if (!researchGlobalCaptureEnabled) {
|
||||
recordResearchCaptureHealth(
|
||||
status = LeaderResearchSourceStatus.DISABLED,
|
||||
disabledReason = "Global activity capture is disabled"
|
||||
)
|
||||
return
|
||||
}
|
||||
val currentMinute = System.currentTimeMillis() / 60_000
|
||||
if (researchCaptureWindowMinute != currentMinute) {
|
||||
researchCaptureWindowMinute = currentMinute
|
||||
researchCaptureWritesThisMinute = 0
|
||||
}
|
||||
if (researchCaptureWritesThisMinute >= researchGlobalCaptureMaxWritesPerMinute) {
|
||||
recordResearchCaptureHealth(
|
||||
status = LeaderResearchSourceStatus.DEGRADED,
|
||||
errorClass = "WriteCapReached",
|
||||
errorMessage = "write capped at $researchGlobalCaptureMaxWritesPerMinute events per minute"
|
||||
)
|
||||
return
|
||||
}
|
||||
val tradeMessage = message.fromJson<ActivityTradeMessage>() ?: run {
|
||||
recordResearchCaptureHealth(
|
||||
status = LeaderResearchSourceStatus.FAILURE,
|
||||
errorClass = "JsonParseFailure",
|
||||
errorMessage = "failed to parse activity websocket message"
|
||||
)
|
||||
return
|
||||
}
|
||||
if (tradeMessage.topic != "activity" || (tradeMessage.type != "trades" && tradeMessage.type != "orders_matched")) {
|
||||
return
|
||||
}
|
||||
val ingestionService = researchIngestionProvider.getIfAvailable() ?: return
|
||||
try {
|
||||
val event = ingestionService.ingestWebSocketTrade(tradeMessage)
|
||||
researchCaptureWritesThisMinute++
|
||||
recordResearchCaptureHealth(
|
||||
status = LeaderResearchSourceStatus.SUCCESS,
|
||||
candidateCount = researchCaptureWritesThisMinute.toInt(),
|
||||
lastCursor = "${event.eventTime}:${event.stableEventKey}"
|
||||
)
|
||||
} catch (e: Exception) {
|
||||
logger.warn("Research global activity capture failed: {}", e.message)
|
||||
recordResearchCaptureHealth(
|
||||
status = LeaderResearchSourceStatus.FAILURE,
|
||||
errorClass = e::class.java.simpleName,
|
||||
errorMessage = e.message
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
private fun recordResearchCaptureHealth(
|
||||
status: LeaderResearchSourceStatus,
|
||||
candidateCount: Int = 0,
|
||||
errorClass: String? = null,
|
||||
errorMessage: String? = null,
|
||||
disabledReason: String? = null,
|
||||
lastCursor: String? = null
|
||||
) {
|
||||
if (shouldThrottleResearchCaptureHealth(status)) {
|
||||
return
|
||||
}
|
||||
try {
|
||||
researchSourceHealthProvider.getIfAvailable()?.record(
|
||||
sourceType = LeaderResearchSourceType.GLOBAL_ACTIVITY_CAPTURE,
|
||||
status = status,
|
||||
candidateCount = candidateCount,
|
||||
errorClass = errorClass,
|
||||
errorMessage = errorMessage,
|
||||
disabledReason = disabledReason,
|
||||
lastCursor = lastCursor
|
||||
)
|
||||
} catch (e: Exception) {
|
||||
logger.warn("记录研究全局 activity 来源健康失败: status={}, error={}", status, e.message)
|
||||
}
|
||||
}
|
||||
|
||||
private fun shouldThrottleResearchCaptureHealth(status: LeaderResearchSourceStatus): Boolean {
|
||||
val now = System.currentTimeMillis()
|
||||
val throttleWindow = when (status) {
|
||||
LeaderResearchSourceStatus.DISABLED -> RESEARCH_CAPTURE_DISABLED_HEALTH_THROTTLE_MS
|
||||
LeaderResearchSourceStatus.SUCCESS -> 0L
|
||||
else -> RESEARCH_CAPTURE_HEALTH_THROTTLE_MS
|
||||
}
|
||||
val throttle = throttleWindow > 0 &&
|
||||
status == researchCaptureLastHealthStatus &&
|
||||
now - researchCaptureLastHealthWriteAt < throttleWindow
|
||||
if (!throttle) {
|
||||
researchCaptureLastHealthStatus = status
|
||||
researchCaptureLastHealthWriteAt = now
|
||||
}
|
||||
return throttle
|
||||
}
|
||||
|
||||
/**
|
||||
* 提取交易者地址
|
||||
* 优先检查 trader.address,fallback 到 proxyWallet
|
||||
@@ -574,5 +684,9 @@ class PolymarketActivityWsService(
|
||||
stop()
|
||||
scope.cancel()
|
||||
}
|
||||
}
|
||||
|
||||
companion object {
|
||||
private const val RESEARCH_CAPTURE_HEALTH_THROTTLE_MS = 60_000L
|
||||
private const val RESEARCH_CAPTURE_DISABLED_HEALTH_THROTTLE_MS = 60L * 60L * 1000L
|
||||
}
|
||||
}
|
||||
|
||||
+203
@@ -0,0 +1,203 @@
|
||||
package com.wrbug.polymarketbot.service.copytrading.research
|
||||
|
||||
import com.google.gson.Gson
|
||||
import com.wrbug.polymarketbot.api.UserActivityResponse
|
||||
import com.wrbug.polymarketbot.dto.ActivityTradeMessage
|
||||
import com.wrbug.polymarketbot.entity.LeaderActivityEvent
|
||||
import com.wrbug.polymarketbot.enums.LeaderPaperProcessingStatus
|
||||
import com.wrbug.polymarketbot.enums.LeaderResearchSourceType
|
||||
import com.wrbug.polymarketbot.repository.LeaderActivityEventRepository
|
||||
import org.slf4j.LoggerFactory
|
||||
import org.springframework.dao.DataIntegrityViolationException
|
||||
import org.springframework.stereotype.Service
|
||||
import org.springframework.transaction.annotation.Transactional
|
||||
import java.math.BigDecimal
|
||||
import java.security.MessageDigest
|
||||
|
||||
@Service
|
||||
class LeaderActivityIngestionService(
|
||||
private val activityEventRepository: LeaderActivityEventRepository,
|
||||
private val gson: Gson
|
||||
) {
|
||||
private val logger = LoggerFactory.getLogger(LeaderActivityIngestionService::class.java)
|
||||
|
||||
@Transactional
|
||||
fun ingestUserActivity(
|
||||
activity: UserActivityResponse,
|
||||
source: LeaderResearchSourceType = LeaderResearchSourceType.ACTIVITY_DERIVED
|
||||
): LeaderActivityEvent {
|
||||
val raw = gson.toJson(activity)
|
||||
val normalizedWallet = normalizeWallet(activity.proxyWallet)
|
||||
val isTrade = activity.type.equals("TRADE", ignoreCase = true)
|
||||
val hasRequiredTradeFields = isTrade &&
|
||||
!normalizedWallet.isNullOrBlank() &&
|
||||
!activity.conditionId.isNullOrBlank() &&
|
||||
!activity.side.isNullOrBlank() &&
|
||||
activity.price != null &&
|
||||
activity.size != null
|
||||
val eventTime = normalizeTimestamp(activity.timestamp)
|
||||
val stableKey = activity.transactionHash?.trim()?.takeIf { it.isNotBlank() }
|
||||
?: sha256("${source.name}:${activity.proxyWallet}:${activity.conditionId}:${activity.side}:${activity.asset}:${eventTime}:${activity.price}:${activity.size}")
|
||||
|
||||
val event = LeaderActivityEvent(
|
||||
source = source.name,
|
||||
sourceEventId = activity.transactionHash,
|
||||
stableEventKey = stableKey,
|
||||
normalizedWallet = normalizedWallet,
|
||||
marketId = activity.conditionId,
|
||||
marketTitle = activity.title,
|
||||
marketSlug = activity.slug,
|
||||
asset = activity.asset,
|
||||
side = activity.side?.uppercase(),
|
||||
outcome = activity.outcome,
|
||||
outcomeIndex = activity.outcomeIndex,
|
||||
price = activity.price?.let { BigDecimal.valueOf(it) },
|
||||
size = activity.size?.let { BigDecimal.valueOf(it) },
|
||||
amount = activity.usdcSize?.let { BigDecimal.valueOf(it) }
|
||||
?: amount(activity.price, activity.size),
|
||||
eventTime = eventTime,
|
||||
rawPayloadHash = sha256(raw),
|
||||
payloadSummary = summarize(
|
||||
wallet = normalizedWallet,
|
||||
side = activity.side,
|
||||
marketTitle = activity.title,
|
||||
price = activity.price?.toString(),
|
||||
size = activity.size?.toString()
|
||||
),
|
||||
usableForDiscovery = !normalizedWallet.isNullOrBlank() && isTrade,
|
||||
usableForPaper = hasRequiredTradeFields,
|
||||
unusableReason = if (hasRequiredTradeFields) null else buildUnusableReason(isTrade, normalizedWallet, activity.conditionId, activity.side, activity.price, activity.size),
|
||||
paperProcessingStatus = LeaderPaperProcessingStatus.NEW,
|
||||
createdAt = System.currentTimeMillis(),
|
||||
updatedAt = System.currentTimeMillis()
|
||||
)
|
||||
return saveDeduped(event)
|
||||
}
|
||||
|
||||
@Transactional
|
||||
fun ingestWebSocketTrade(
|
||||
message: ActivityTradeMessage,
|
||||
source: LeaderResearchSourceType = LeaderResearchSourceType.GLOBAL_ACTIVITY_CAPTURE
|
||||
): LeaderActivityEvent {
|
||||
val payload = message.payload
|
||||
val raw = gson.toJson(message)
|
||||
val normalizedWallet = normalizeWallet(payload.trader?.address ?: payload.proxyWallet)
|
||||
val eventTime = normalizeTimestamp(payload.timestamp ?: message.timestamp)
|
||||
val price = payload.price.toBigDecimalOrNull()
|
||||
val size = payload.size.toBigDecimalOrNull()
|
||||
val hasRequiredTradeFields = !normalizedWallet.isNullOrBlank() &&
|
||||
payload.conditionId.isNotBlank() &&
|
||||
payload.side.isNotBlank() &&
|
||||
price != null &&
|
||||
size != null
|
||||
val stableKey = payload.transactionHash?.trim()?.takeIf { it.isNotBlank() }
|
||||
?: sha256("${source.name}:$normalizedWallet:${payload.conditionId}:${payload.side}:${payload.asset}:$eventTime:$price:$size")
|
||||
|
||||
val event = LeaderActivityEvent(
|
||||
source = source.name,
|
||||
sourceEventId = payload.transactionHash,
|
||||
stableEventKey = stableKey,
|
||||
normalizedWallet = normalizedWallet,
|
||||
marketId = payload.conditionId.takeIf { it.isNotBlank() },
|
||||
marketSlug = payload.slug,
|
||||
asset = payload.asset.takeIf { it.isNotBlank() },
|
||||
side = payload.side.uppercase().takeIf { it.isNotBlank() },
|
||||
outcome = payload.outcome,
|
||||
outcomeIndex = payload.outcomeIndex,
|
||||
price = price,
|
||||
size = size,
|
||||
amount = if (price != null && size != null) price.multiply(size) else null,
|
||||
eventTime = eventTime,
|
||||
rawPayloadHash = sha256(raw),
|
||||
payloadSummary = summarize(
|
||||
wallet = normalizedWallet,
|
||||
side = payload.side,
|
||||
marketTitle = payload.slug,
|
||||
price = price?.toPlainString(),
|
||||
size = size?.toPlainString()
|
||||
),
|
||||
usableForDiscovery = !normalizedWallet.isNullOrBlank(),
|
||||
usableForPaper = hasRequiredTradeFields,
|
||||
unusableReason = if (hasRequiredTradeFields) null else buildUnusableReason(true, normalizedWallet, payload.conditionId, payload.side, price, size),
|
||||
paperProcessingStatus = LeaderPaperProcessingStatus.NEW,
|
||||
createdAt = System.currentTimeMillis(),
|
||||
updatedAt = System.currentTimeMillis()
|
||||
)
|
||||
return saveDeduped(event)
|
||||
}
|
||||
|
||||
fun normalizeWallet(wallet: String?): String? {
|
||||
val trimmed = wallet?.trim()?.lowercase() ?: return null
|
||||
val evm = Regex("^0x[a-f0-9]{40}$")
|
||||
return trimmed.takeIf { evm.matches(it) }
|
||||
}
|
||||
|
||||
fun stableHash(raw: String): String = sha256(raw)
|
||||
|
||||
private fun saveDeduped(event: LeaderActivityEvent): LeaderActivityEvent {
|
||||
activityEventRepository.findByStableEventKey(event.stableEventKey)?.let { return it }
|
||||
event.sourceEventId?.takeIf { it.isNotBlank() }?.let { sourceEventId ->
|
||||
activityEventRepository.findBySourceAndSourceEventId(event.source, sourceEventId)?.let { return it }
|
||||
}
|
||||
return try {
|
||||
activityEventRepository.save(event)
|
||||
} catch (e: DataIntegrityViolationException) {
|
||||
logger.debug("Activity event deduped: stableKey={}", event.stableEventKey)
|
||||
activityEventRepository.findByStableEventKey(event.stableEventKey)
|
||||
?: event.sourceEventId?.takeIf { it.isNotBlank() }?.let { activityEventRepository.findBySourceAndSourceEventId(event.source, it) }
|
||||
?: throw e
|
||||
}
|
||||
}
|
||||
|
||||
private fun normalizeTimestamp(value: Any?): Long {
|
||||
val number = when (value) {
|
||||
is Number -> value.toLong()
|
||||
is String -> value.toLongOrNull()
|
||||
else -> null
|
||||
} ?: return System.currentTimeMillis()
|
||||
return if (number < 10_000_000_000L) number * 1000 else number
|
||||
}
|
||||
|
||||
private fun Any?.toBigDecimalOrNull(): BigDecimal? {
|
||||
return when (this) {
|
||||
is BigDecimal -> this
|
||||
is Number -> BigDecimal.valueOf(this.toDouble())
|
||||
is String -> this.trim().takeIf { it.isNotBlank() }?.let { runCatching { BigDecimal(it) }.getOrNull() }
|
||||
else -> null
|
||||
}
|
||||
}
|
||||
|
||||
private fun amount(price: Double?, size: Double?): BigDecimal? {
|
||||
if (price == null || size == null) return null
|
||||
return BigDecimal.valueOf(price).multiply(BigDecimal.valueOf(size))
|
||||
}
|
||||
|
||||
private fun buildUnusableReason(
|
||||
isTrade: Boolean,
|
||||
wallet: String?,
|
||||
marketId: String?,
|
||||
side: String?,
|
||||
price: Any?,
|
||||
size: Any?
|
||||
): String {
|
||||
val reasons = mutableListOf<String>()
|
||||
if (!isTrade) reasons += "not_trade"
|
||||
if (wallet.isNullOrBlank()) reasons += "wallet_missing_or_invalid"
|
||||
if (marketId.isNullOrBlank()) reasons += "market_missing"
|
||||
if (side.isNullOrBlank()) reasons += "side_missing"
|
||||
if (price == null) reasons += "price_missing"
|
||||
if (size == null) reasons += "size_missing"
|
||||
return reasons.joinToString(",")
|
||||
}
|
||||
|
||||
private fun summarize(wallet: String?, side: String?, marketTitle: String?, price: String?, size: String?): String {
|
||||
return listOfNotNull(wallet, side?.uppercase(), marketTitle, price?.let { "price=$it" }, size?.let { "size=$it" })
|
||||
.joinToString(" | ")
|
||||
.take(1000)
|
||||
}
|
||||
|
||||
private fun sha256(raw: String): String {
|
||||
val digest = MessageDigest.getInstance("SHA-256").digest(raw.toByteArray(Charsets.UTF_8))
|
||||
return digest.joinToString("") { "%02x".format(it) }
|
||||
}
|
||||
}
|
||||
+530
@@ -0,0 +1,530 @@
|
||||
package com.wrbug.polymarketbot.service.copytrading.research
|
||||
|
||||
import com.wrbug.polymarketbot.entity.LeaderActivityEvent
|
||||
import com.wrbug.polymarketbot.entity.LeaderPaperPosition
|
||||
import com.wrbug.polymarketbot.entity.LeaderPaperSession
|
||||
import com.wrbug.polymarketbot.entity.LeaderPaperTrade
|
||||
import com.wrbug.polymarketbot.entity.LeaderResearchCandidate
|
||||
import com.wrbug.polymarketbot.enums.LeaderPaperFillAssumption
|
||||
import com.wrbug.polymarketbot.enums.LeaderPaperFilterResult
|
||||
import com.wrbug.polymarketbot.enums.LeaderPaperProcessingStatus
|
||||
import com.wrbug.polymarketbot.enums.LeaderPaperSessionStatus
|
||||
import com.wrbug.polymarketbot.enums.LeaderResearchEventType
|
||||
import com.wrbug.polymarketbot.enums.LeaderResearchQuoteConfidence
|
||||
import com.wrbug.polymarketbot.enums.LeaderResearchState
|
||||
import com.wrbug.polymarketbot.enums.LeaderResearchValuationStatus
|
||||
import com.wrbug.polymarketbot.repository.LeaderActivityEventRepository
|
||||
import com.wrbug.polymarketbot.repository.LeaderPaperPositionRepository
|
||||
import com.wrbug.polymarketbot.repository.LeaderPaperSessionRepository
|
||||
import com.wrbug.polymarketbot.repository.LeaderPaperTradeRepository
|
||||
import com.wrbug.polymarketbot.repository.LeaderResearchCandidateRepository
|
||||
import com.wrbug.polymarketbot.service.common.MarketPriceService
|
||||
import kotlinx.coroutines.runBlocking
|
||||
import org.slf4j.LoggerFactory
|
||||
import org.springframework.data.domain.PageRequest
|
||||
import org.springframework.stereotype.Service
|
||||
import org.springframework.transaction.annotation.Transactional
|
||||
import java.math.BigDecimal
|
||||
import java.math.RoundingMode
|
||||
|
||||
data class LeaderPaperProcessingResult(
|
||||
val processed: Int,
|
||||
val filtered: Int,
|
||||
val failed: Int
|
||||
)
|
||||
|
||||
@Service
|
||||
class LeaderPaperTradingService(
|
||||
private val candidateRepository: LeaderResearchCandidateRepository,
|
||||
private val activityEventRepository: LeaderActivityEventRepository,
|
||||
private val paperSessionRepository: LeaderPaperSessionRepository,
|
||||
private val paperTradeRepository: LeaderPaperTradeRepository,
|
||||
private val paperPositionRepository: LeaderPaperPositionRepository,
|
||||
private val marketPriceService: MarketPriceService,
|
||||
private val eventService: LeaderResearchEventService
|
||||
) {
|
||||
private val logger = LoggerFactory.getLogger(LeaderPaperTradingService::class.java)
|
||||
|
||||
@Transactional
|
||||
fun ensureSession(candidate: LeaderResearchCandidate, runId: Long? = null): LeaderPaperSession {
|
||||
val candidateId = candidate.id ?: throw IllegalArgumentException("candidate id missing")
|
||||
paperSessionRepository.findTopByCandidateIdAndStatusOrderByStartedAtDesc(candidateId, LeaderPaperSessionStatus.ACTIVE)
|
||||
?.let { return it }
|
||||
val now = System.currentTimeMillis()
|
||||
val session = paperSessionRepository.save(
|
||||
LeaderPaperSession(
|
||||
candidateId = candidateId,
|
||||
status = LeaderPaperSessionStatus.ACTIVE,
|
||||
startedAt = now,
|
||||
createdAt = now,
|
||||
updatedAt = now
|
||||
)
|
||||
)
|
||||
candidateRepository.save(
|
||||
candidate.copy(
|
||||
lastPaperSessionId = session.id,
|
||||
updatedAt = now
|
||||
)
|
||||
)
|
||||
eventService.record(
|
||||
type = LeaderResearchEventType.PAPER_STARTED,
|
||||
candidateId = candidateId,
|
||||
runId = runId,
|
||||
reason = "Paper session started",
|
||||
dedupeKey = "paper-started:$candidateId:${session.id}"
|
||||
)
|
||||
return session
|
||||
}
|
||||
|
||||
@Transactional
|
||||
fun processPaperCandidates(runId: Long? = null, batchSize: Int = 200): LeaderPaperProcessingResult {
|
||||
val paperCandidates = candidateRepository.findByResearchStateIn(
|
||||
listOf(LeaderResearchState.PAPER, LeaderResearchState.TRIAL_READY)
|
||||
)
|
||||
if (paperCandidates.isEmpty()) {
|
||||
return LeaderPaperProcessingResult(processed = 0, filtered = 0, failed = 0)
|
||||
}
|
||||
|
||||
val candidatesByWallet = paperCandidates.associateBy { it.normalizedWallet }
|
||||
paperCandidates.forEach { ensureSession(it, runId) }
|
||||
|
||||
val page = activityEventRepository.findByPaperProcessingStatusInAndUsableForPaperTrueOrderByEventTimeAsc(
|
||||
listOf(LeaderPaperProcessingStatus.NEW, LeaderPaperProcessingStatus.RETRYABLE),
|
||||
PageRequest.of(0, batchSize)
|
||||
)
|
||||
|
||||
var processed = 0
|
||||
var filtered = 0
|
||||
var failed = 0
|
||||
val now = System.currentTimeMillis()
|
||||
|
||||
page.content.forEach { event ->
|
||||
val wallet = event.normalizedWallet ?: return@forEach
|
||||
val candidate = candidatesByWallet[wallet] ?: return@forEach
|
||||
val eventId = event.id ?: return@forEach
|
||||
val claimed = activityEventRepository.claimForPaperProcessing(
|
||||
id = eventId,
|
||||
allowed = listOf(LeaderPaperProcessingStatus.NEW, LeaderPaperProcessingStatus.RETRYABLE),
|
||||
nextStatus = LeaderPaperProcessingStatus.PROCESSING,
|
||||
startedAt = now
|
||||
)
|
||||
if (claimed != 1) return@forEach
|
||||
|
||||
val claimedEvent = event.copy(
|
||||
paperProcessingStatus = LeaderPaperProcessingStatus.PROCESSING,
|
||||
processingAttempts = event.processingAttempts + 1,
|
||||
paperProcessingStartedAt = now,
|
||||
updatedAt = now
|
||||
)
|
||||
try {
|
||||
val session = ensureSession(candidate, runId)
|
||||
val outcome = processEvent(candidate, session, claimedEvent)
|
||||
when (outcome) {
|
||||
LeaderPaperFilterResult.PASSED -> processed += 1
|
||||
LeaderPaperFilterResult.FILTERED -> filtered += 1
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
failed += 1
|
||||
val nextAttempts = claimedEvent.processingAttempts
|
||||
val nextStatus = if (nextAttempts >= MAX_PROCESSING_ATTEMPTS) {
|
||||
LeaderPaperProcessingStatus.FAILED
|
||||
} else {
|
||||
LeaderPaperProcessingStatus.RETRYABLE
|
||||
}
|
||||
logger.warn("Paper event processing failed: eventId={}, error={}", eventId, e.message, e)
|
||||
activityEventRepository.save(
|
||||
claimedEvent.copy(
|
||||
paperProcessingStatus = nextStatus,
|
||||
paperProcessedAt = System.currentTimeMillis(),
|
||||
lastProcessingError = e.message,
|
||||
updatedAt = System.currentTimeMillis()
|
||||
)
|
||||
)
|
||||
eventService.record(
|
||||
type = LeaderResearchEventType.PAPER_PROCESSING_FAILED,
|
||||
candidateId = candidate.id,
|
||||
runId = runId,
|
||||
reason = "Paper event processing failed with status=$nextStatus: ${e.message}",
|
||||
payloadSummary = claimedEvent.payloadSummary,
|
||||
dedupeKey = "paper-processing-failed:$eventId:$nextAttempts"
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
return LeaderPaperProcessingResult(processed = processed, filtered = filtered, failed = failed)
|
||||
}
|
||||
|
||||
fun isEligibleForTrialReady(session: LeaderPaperSession, now: Long = System.currentTimeMillis()): Boolean {
|
||||
val ageMs = now - session.startedAt
|
||||
val totalTrades = session.tradeCount + session.filteredCount
|
||||
val unknownRatio = if (session.openExposure > BigDecimal.ZERO) {
|
||||
session.unknownValuationExposure.safeDivide(session.openExposure)
|
||||
} else {
|
||||
BigDecimal.ZERO
|
||||
}
|
||||
return ageMs >= PAPER_MIN_AGE_MS &&
|
||||
session.tradeCount >= PAPER_MIN_TRADES &&
|
||||
session.copyablePnl > BigDecimal.ZERO &&
|
||||
session.maxDrawdown >= BigDecimal("-15") &&
|
||||
unknownRatio <= BigDecimal("0.20") &&
|
||||
session.filteredRatio < BigDecimal("0.50") &&
|
||||
totalTrades >= PAPER_MIN_TRADES
|
||||
}
|
||||
|
||||
fun shouldEnterCooldown(session: LeaderPaperSession, sourceFresh: Boolean): String? {
|
||||
if (session.maxDrawdown < BigDecimal("-20")) return "paper_drawdown_below_-20"
|
||||
if (session.tradeCount >= 10 && session.copyablePnl < BigDecimal("-5")) return "copyable_pnl_below_-5_after_10_trades"
|
||||
if (!sourceFresh) return "source_stale_over_72h"
|
||||
if ((session.openExposure > BigDecimal.ZERO) &&
|
||||
session.unknownValuationExposure.safeDivide(session.openExposure) > BigDecimal("0.50")
|
||||
) {
|
||||
return "thin_liquidity_exit_risk"
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
@Transactional
|
||||
fun refreshSessionSummary(sessionId: Long): LeaderPaperSession {
|
||||
val session = paperSessionRepository.findById(sessionId).orElseThrow { IllegalArgumentException("Paper session not found") }
|
||||
return saveSessionSummary(session)
|
||||
}
|
||||
|
||||
private fun processEvent(
|
||||
candidate: LeaderResearchCandidate,
|
||||
session: LeaderPaperSession,
|
||||
event: LeaderActivityEvent
|
||||
): LeaderPaperFilterResult {
|
||||
val candidateId = candidate.id ?: throw IllegalArgumentException("candidate id missing")
|
||||
val sessionId = session.id ?: throw IllegalArgumentException("session id missing")
|
||||
val filterReason = filterReason(event)
|
||||
if (filterReason != null) {
|
||||
val trade = buildTrade(
|
||||
candidateId = candidateId,
|
||||
sessionId = sessionId,
|
||||
event = event,
|
||||
filterResult = LeaderPaperFilterResult.FILTERED,
|
||||
filterReason = filterReason,
|
||||
simulatedPrice = null,
|
||||
simulatedSize = null,
|
||||
simulatedAmount = null,
|
||||
valuationStatus = LeaderResearchValuationStatus.UNKNOWN
|
||||
)
|
||||
saveTradeIfAbsent(trade)
|
||||
markEvent(event, LeaderPaperProcessingStatus.FILTERED, null)
|
||||
saveSessionSummary(session)
|
||||
eventService.record(
|
||||
type = LeaderResearchEventType.PAPER_TRADE_FILTERED,
|
||||
candidateId = candidateId,
|
||||
reason = filterReason,
|
||||
payloadSummary = event.payloadSummary,
|
||||
dedupeKey = "paper-filtered:${sessionId}:${event.stableEventKey}"
|
||||
)
|
||||
return LeaderPaperFilterResult.FILTERED
|
||||
}
|
||||
|
||||
val side = event.side!!.uppercase()
|
||||
val price = event.price!!
|
||||
val marketId = event.marketId!!
|
||||
val outcomeIndex = event.outcomeIndex ?: 0
|
||||
if (paperTradeRepository.existsBySessionIdAndLeaderTradeIdAndSide(sessionId, event.stableEventKey, side)) {
|
||||
markEvent(event, LeaderPaperProcessingStatus.PROCESSED, null)
|
||||
return LeaderPaperFilterResult.PASSED
|
||||
}
|
||||
val existingPosition = paperPositionRepository.findBySessionIdAndMarketIdAndOutcomeIndex(sessionId, marketId, outcomeIndex)
|
||||
val simulatedAmount = when (side) {
|
||||
"BUY" -> minDecimal(event.amount ?: price.multiply(event.size ?: BigDecimal.ZERO), PAPER_FIXED_AMOUNT)
|
||||
"SELL" -> {
|
||||
val maxSell = existingPosition?.quantity ?: BigDecimal.ZERO
|
||||
val eventSellSize = event.size ?: maxSell
|
||||
minDecimal(maxSell, eventSellSize).multiply(price)
|
||||
}
|
||||
else -> BigDecimal.ZERO
|
||||
}.atLeast(BigDecimal.ZERO)
|
||||
val simulatedSize = if (price > BigDecimal.ZERO) simulatedAmount.safeDivide(price) else BigDecimal.ZERO
|
||||
|
||||
val valuation = quoteMarket(marketId, outcomeIndex)
|
||||
val realizedPnl = applyPosition(
|
||||
session = session,
|
||||
event = event,
|
||||
side = side,
|
||||
simulatedPrice = price,
|
||||
simulatedSize = simulatedSize,
|
||||
simulatedAmount = simulatedAmount,
|
||||
valuation = valuation
|
||||
)
|
||||
val trade = buildTrade(
|
||||
candidateId = candidateId,
|
||||
sessionId = sessionId,
|
||||
event = event,
|
||||
filterResult = LeaderPaperFilterResult.PASSED,
|
||||
filterReason = null,
|
||||
simulatedPrice = price,
|
||||
simulatedSize = simulatedSize,
|
||||
simulatedAmount = simulatedAmount,
|
||||
valuationStatus = valuation.status,
|
||||
realizedPnl = realizedPnl
|
||||
)
|
||||
saveTradeIfAbsent(trade)
|
||||
markEvent(event, LeaderPaperProcessingStatus.PROCESSED, null)
|
||||
saveSessionSummary(session)
|
||||
eventService.record(
|
||||
type = LeaderResearchEventType.PAPER_TRADE_RECORDED,
|
||||
candidateId = candidateId,
|
||||
reason = "${side} paper trade recorded",
|
||||
payloadSummary = event.payloadSummary,
|
||||
dedupeKey = "paper-trade:${sessionId}:${event.stableEventKey}:$side"
|
||||
)
|
||||
if (valuation.status == LeaderResearchValuationStatus.UNKNOWN || valuation.status == LeaderResearchValuationStatus.UNAVAILABLE) {
|
||||
eventService.record(
|
||||
type = LeaderResearchEventType.VALUATION_STALE,
|
||||
candidateId = candidateId,
|
||||
reason = "Valuation unavailable for $marketId/$outcomeIndex",
|
||||
dedupeKey = "paper-valuation:${sessionId}:${event.stableEventKey}"
|
||||
)
|
||||
}
|
||||
return LeaderPaperFilterResult.PASSED
|
||||
}
|
||||
|
||||
private fun applyPosition(
|
||||
session: LeaderPaperSession,
|
||||
event: LeaderActivityEvent,
|
||||
side: String,
|
||||
simulatedPrice: BigDecimal,
|
||||
simulatedSize: BigDecimal,
|
||||
simulatedAmount: BigDecimal,
|
||||
valuation: PaperQuote
|
||||
): BigDecimal {
|
||||
val sessionId = session.id ?: throw IllegalArgumentException("session id missing")
|
||||
val candidateId = session.candidateId
|
||||
val marketId = event.marketId!!
|
||||
val outcomeIndex = event.outcomeIndex ?: 0
|
||||
val now = System.currentTimeMillis()
|
||||
val existing = paperPositionRepository.findBySessionIdAndMarketIdAndOutcomeIndex(sessionId, marketId, outcomeIndex)
|
||||
val updated = if (side == "SELL") {
|
||||
val position = existing ?: LeaderPaperPosition(
|
||||
sessionId = sessionId,
|
||||
candidateId = candidateId,
|
||||
marketId = marketId,
|
||||
outcome = event.outcome,
|
||||
outcomeIndex = outcomeIndex,
|
||||
createdAt = now,
|
||||
updatedAt = now
|
||||
)
|
||||
val sellSize = minDecimal(position.quantity, simulatedSize)
|
||||
val costPortion = if (position.quantity > BigDecimal.ZERO) {
|
||||
position.cost.multiply(sellSize).safeDivide(position.quantity)
|
||||
} else {
|
||||
BigDecimal.ZERO
|
||||
}
|
||||
val realized = simulatedPrice.multiply(sellSize).subtract(costPortion)
|
||||
val remainingQuantity = position.quantity.subtract(sellSize).atLeast(BigDecimal.ZERO)
|
||||
val remainingCost = position.cost.subtract(costPortion).atLeast(BigDecimal.ZERO)
|
||||
val currentValue = valuation.price?.multiply(remainingQuantity) ?: BigDecimal.ZERO
|
||||
position.copy(
|
||||
quantity = remainingQuantity,
|
||||
cost = remainingCost,
|
||||
avgPrice = if (remainingQuantity > BigDecimal.ZERO) remainingCost.safeDivide(remainingQuantity) else BigDecimal.ZERO,
|
||||
currentPrice = valuation.price,
|
||||
currentValue = currentValue,
|
||||
realizedPnl = position.realizedPnl.add(realized),
|
||||
unrealizedPnl = currentValue.subtract(remainingCost),
|
||||
valuationStatus = valuation.status,
|
||||
quoteConfidence = valuation.confidence,
|
||||
quoteSource = valuation.source,
|
||||
quoteTimestamp = valuation.timestamp,
|
||||
updatedAt = now
|
||||
)
|
||||
} else {
|
||||
val position = existing ?: LeaderPaperPosition(
|
||||
sessionId = sessionId,
|
||||
candidateId = candidateId,
|
||||
marketId = marketId,
|
||||
outcome = event.outcome,
|
||||
outcomeIndex = outcomeIndex,
|
||||
createdAt = now,
|
||||
updatedAt = now
|
||||
)
|
||||
val newQuantity = position.quantity.add(simulatedSize)
|
||||
val newCost = position.cost.add(simulatedAmount)
|
||||
val currentValue = valuation.price?.multiply(newQuantity) ?: BigDecimal.ZERO
|
||||
position.copy(
|
||||
quantity = newQuantity,
|
||||
cost = newCost,
|
||||
avgPrice = if (newQuantity > BigDecimal.ZERO) newCost.safeDivide(newQuantity) else BigDecimal.ZERO,
|
||||
currentPrice = valuation.price,
|
||||
currentValue = currentValue,
|
||||
unrealizedPnl = currentValue.subtract(newCost),
|
||||
valuationStatus = valuation.status,
|
||||
quoteConfidence = valuation.confidence,
|
||||
quoteSource = valuation.source,
|
||||
quoteTimestamp = valuation.timestamp,
|
||||
updatedAt = now
|
||||
)
|
||||
}
|
||||
val saved = paperPositionRepository.save(updated)
|
||||
return if (side == "SELL") saved.realizedPnl.subtract(existing?.realizedPnl ?: BigDecimal.ZERO) else BigDecimal.ZERO
|
||||
}
|
||||
|
||||
private fun saveSessionSummary(session: LeaderPaperSession): LeaderPaperSession {
|
||||
val sessionId = session.id ?: throw IllegalArgumentException("session id missing")
|
||||
val positions = paperPositionRepository.findBySessionIdOrderByUpdatedAtDesc(sessionId)
|
||||
val trades = paperTradeRepository.findBySessionIdOrderByEventTimeAsc(sessionId)
|
||||
val tradeCount = trades.count { it.filterResult == LeaderPaperFilterResult.PASSED }
|
||||
val filteredCount = trades.count { it.filterResult == LeaderPaperFilterResult.FILTERED }
|
||||
val totalEvents = tradeCount + filteredCount
|
||||
val realized = positions.fold(BigDecimal.ZERO) { acc, position -> acc + position.realizedPnl }
|
||||
val availableUnrealized = positions
|
||||
.filter { it.valuationStatus == LeaderResearchValuationStatus.AVAILABLE || it.valuationStatus == LeaderResearchValuationStatus.CONFIRMED_ZERO }
|
||||
.fold(BigDecimal.ZERO) { acc, position -> acc + position.unrealizedPnl }
|
||||
val unknownExposure = positions
|
||||
.filter { it.valuationStatus == LeaderResearchValuationStatus.UNKNOWN || it.valuationStatus == LeaderResearchValuationStatus.UNAVAILABLE || it.valuationStatus == LeaderResearchValuationStatus.NO_MATCH }
|
||||
.fold(BigDecimal.ZERO) { acc, position -> acc + position.cost }
|
||||
val confirmedZeroExposure = positions
|
||||
.filter { it.valuationStatus == LeaderResearchValuationStatus.CONFIRMED_ZERO }
|
||||
.fold(BigDecimal.ZERO) { acc, position -> acc + position.cost }
|
||||
val openExposure = positions.fold(BigDecimal.ZERO) { acc, position -> acc + position.cost }
|
||||
val copyablePnl = realized.add(availableUnrealized)
|
||||
val maxDrawdown = minDecimal(session.maxDrawdown, copyablePnl)
|
||||
return paperSessionRepository.save(
|
||||
session.copy(
|
||||
tradeCount = tradeCount,
|
||||
filteredCount = filteredCount,
|
||||
openExposure = openExposure,
|
||||
totalRealizedPnl = realized,
|
||||
totalUnrealizedPnl = positions.fold(BigDecimal.ZERO) { acc, position -> acc + position.unrealizedPnl },
|
||||
copyablePnl = copyablePnl,
|
||||
maxDrawdown = maxDrawdown,
|
||||
unknownValuationExposure = unknownExposure,
|
||||
confirmedZeroExposure = confirmedZeroExposure,
|
||||
filteredRatio = if (totalEvents > 0) BigDecimal(filteredCount).safeDivide(BigDecimal(totalEvents)) else BigDecimal.ZERO,
|
||||
lastProcessedEventTime = trades.maxOfOrNull { it.eventTime } ?: session.lastProcessedEventTime,
|
||||
updatedAt = System.currentTimeMillis()
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
private fun buildTrade(
|
||||
candidateId: Long,
|
||||
sessionId: Long,
|
||||
event: LeaderActivityEvent,
|
||||
filterResult: LeaderPaperFilterResult,
|
||||
filterReason: String?,
|
||||
simulatedPrice: BigDecimal?,
|
||||
simulatedSize: BigDecimal?,
|
||||
simulatedAmount: BigDecimal?,
|
||||
valuationStatus: LeaderResearchValuationStatus,
|
||||
realizedPnl: BigDecimal? = null
|
||||
): LeaderPaperTrade {
|
||||
return LeaderPaperTrade(
|
||||
sessionId = sessionId,
|
||||
candidateId = candidateId,
|
||||
activityEventId = event.id,
|
||||
leaderTradeId = event.stableEventKey,
|
||||
marketId = event.marketId ?: "unknown",
|
||||
marketTitle = event.marketTitle,
|
||||
marketSlug = event.marketSlug,
|
||||
side = event.side?.uppercase() ?: "UNKNOWN",
|
||||
outcome = event.outcome,
|
||||
outcomeIndex = event.outcomeIndex,
|
||||
leaderPrice = event.price,
|
||||
leaderSize = event.size,
|
||||
simulatedPrice = simulatedPrice,
|
||||
simulatedSize = simulatedSize,
|
||||
simulatedAmount = simulatedAmount,
|
||||
fillAssumption = if (simulatedPrice != null) LeaderPaperFillAssumption.LEADER_PRICE else LeaderPaperFillAssumption.UNKNOWN,
|
||||
quoteConfidence = if (valuationStatus == LeaderResearchValuationStatus.AVAILABLE || valuationStatus == LeaderResearchValuationStatus.CONFIRMED_ZERO) {
|
||||
LeaderResearchQuoteConfidence.MEDIUM
|
||||
} else {
|
||||
LeaderResearchQuoteConfidence.UNKNOWN
|
||||
},
|
||||
quoteSource = "paper_v1",
|
||||
quoteTimestamp = System.currentTimeMillis(),
|
||||
filterResult = filterResult,
|
||||
filterReason = filterReason,
|
||||
valuationStatus = valuationStatus,
|
||||
realizedPnl = realizedPnl,
|
||||
eventTime = event.eventTime,
|
||||
createdAt = System.currentTimeMillis()
|
||||
)
|
||||
}
|
||||
|
||||
private fun saveTradeIfAbsent(trade: LeaderPaperTrade): LeaderPaperTrade {
|
||||
if (paperTradeRepository.existsBySessionIdAndLeaderTradeIdAndSide(trade.sessionId, trade.leaderTradeId, trade.side)) {
|
||||
return paperTradeRepository.findBySessionIdOrderByEventTimeAsc(trade.sessionId)
|
||||
.first { it.leaderTradeId == trade.leaderTradeId && it.side == trade.side }
|
||||
}
|
||||
return paperTradeRepository.save(trade)
|
||||
}
|
||||
|
||||
private fun markEvent(event: LeaderActivityEvent, status: LeaderPaperProcessingStatus, error: String?) {
|
||||
activityEventRepository.save(
|
||||
event.copy(
|
||||
paperProcessingStatus = status,
|
||||
processingAttempts = event.processingAttempts,
|
||||
paperProcessingStartedAt = event.paperProcessingStartedAt,
|
||||
paperProcessedAt = System.currentTimeMillis(),
|
||||
lastProcessingError = error,
|
||||
updatedAt = System.currentTimeMillis()
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
private fun filterReason(event: LeaderActivityEvent): String? {
|
||||
if (event.marketId.isNullOrBlank()) return "market_missing"
|
||||
if (event.side.isNullOrBlank()) return "side_missing"
|
||||
if (event.side.uppercase() !in setOf("BUY", "SELL")) return "unsupported_side:${event.side}"
|
||||
if (event.price == null || event.price <= BigDecimal.ZERO) return "price_missing_or_invalid"
|
||||
if (event.price < MIN_PRICE || event.price > MAX_PRICE) return "price_outside_safe_band"
|
||||
if (event.size == null || event.size <= BigDecimal.ZERO) return "size_missing_or_invalid"
|
||||
if (event.side.uppercase() == "BUY" && (event.amount ?: event.price.multiply(event.size)) <= BigDecimal.ZERO) return "amount_missing_or_invalid"
|
||||
return null
|
||||
}
|
||||
|
||||
private fun quoteMarket(marketId: String, outcomeIndex: Int): PaperQuote {
|
||||
return try {
|
||||
val price = runBlocking { marketPriceService.getCurrentMarketPrice(marketId, outcomeIndex) }
|
||||
PaperQuote(
|
||||
price = price,
|
||||
status = if (price.compareTo(BigDecimal.ZERO) == 0) LeaderResearchValuationStatus.CONFIRMED_ZERO else LeaderResearchValuationStatus.AVAILABLE,
|
||||
confidence = LeaderResearchQuoteConfidence.MEDIUM,
|
||||
source = "MarketPriceService",
|
||||
timestamp = System.currentTimeMillis()
|
||||
)
|
||||
} catch (e: Exception) {
|
||||
logger.debug("Paper valuation unavailable: marketId={}, outcomeIndex={}, error={}", marketId, outcomeIndex, e.message)
|
||||
PaperQuote(
|
||||
price = null,
|
||||
status = LeaderResearchValuationStatus.UNKNOWN,
|
||||
confidence = LeaderResearchQuoteConfidence.UNKNOWN,
|
||||
source = "MarketPriceService",
|
||||
timestamp = System.currentTimeMillis()
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
private fun BigDecimal.safeDivide(other: BigDecimal): BigDecimal {
|
||||
if (other.compareTo(BigDecimal.ZERO) == 0) return BigDecimal.ZERO
|
||||
return divide(other, 8, RoundingMode.HALF_UP)
|
||||
}
|
||||
|
||||
private fun BigDecimal.atLeast(other: BigDecimal): BigDecimal = if (this >= other) this else other
|
||||
|
||||
private fun minDecimal(left: BigDecimal, right: BigDecimal): BigDecimal = if (left <= right) left else right
|
||||
|
||||
private data class PaperQuote(
|
||||
val price: BigDecimal?,
|
||||
val status: LeaderResearchValuationStatus,
|
||||
val confidence: LeaderResearchQuoteConfidence,
|
||||
val source: String,
|
||||
val timestamp: Long
|
||||
)
|
||||
|
||||
companion object {
|
||||
private val PAPER_FIXED_AMOUNT = BigDecimal("1.00000000")
|
||||
private val MIN_PRICE = BigDecimal("0.10000000")
|
||||
private val MAX_PRICE = BigDecimal("0.80000000")
|
||||
private const val PAPER_MIN_TRADES = 10
|
||||
private const val PAPER_MIN_AGE_MS = 7L * 24 * 60 * 60 * 1000
|
||||
private const val MAX_PROCESSING_ATTEMPTS = 3
|
||||
}
|
||||
}
|
||||
+146
@@ -0,0 +1,146 @@
|
||||
package com.wrbug.polymarketbot.service.copytrading.research
|
||||
|
||||
import com.wrbug.polymarketbot.dto.CopyTradingCreateRequest
|
||||
import com.wrbug.polymarketbot.dto.LeaderResearchApprovalRequest
|
||||
import com.wrbug.polymarketbot.dto.LeaderResearchApprovalResponse
|
||||
import com.wrbug.polymarketbot.entity.LeaderPool
|
||||
import com.wrbug.polymarketbot.enums.LeaderPoolStatus
|
||||
import com.wrbug.polymarketbot.enums.LeaderResearchEventType
|
||||
import com.wrbug.polymarketbot.enums.LeaderResearchState
|
||||
import com.wrbug.polymarketbot.repository.AccountRepository
|
||||
import com.wrbug.polymarketbot.repository.CopyTradingRepository
|
||||
import com.wrbug.polymarketbot.repository.LeaderPoolRepository
|
||||
import com.wrbug.polymarketbot.repository.LeaderResearchCandidateRepository
|
||||
import com.wrbug.polymarketbot.service.copytrading.configs.CopyTradingService
|
||||
import org.slf4j.LoggerFactory
|
||||
import org.springframework.stereotype.Service
|
||||
import org.springframework.transaction.annotation.Transactional
|
||||
import java.math.BigDecimal
|
||||
|
||||
class LeaderResearchCandidateNotReadyException : RuntimeException("候选尚未进入 TRIAL_READY,不能创建试跟配置")
|
||||
class LeaderResearchApprovalConfirmRequiredException : RuntimeException("创建禁用试跟配置需要显式确认")
|
||||
class LeaderResearchDuplicateTrialConfigException : RuntimeException("该账户已存在此 Leader 的跟单配置")
|
||||
class LeaderResearchRealMoneyForbiddenException : RuntimeException("Leader Research Agent 不允许自动启用真钱跟单")
|
||||
class LeaderResearchCandidateLockedException : RuntimeException("研究候选已锁定")
|
||||
|
||||
@Service
|
||||
class LeaderResearchApprovalService(
|
||||
private val candidateRepository: LeaderResearchCandidateRepository,
|
||||
private val accountRepository: AccountRepository,
|
||||
private val copyTradingRepository: CopyTradingRepository,
|
||||
private val leaderPoolRepository: LeaderPoolRepository,
|
||||
private val copyTradingService: CopyTradingService,
|
||||
private val poolMappingService: LeaderResearchPoolMappingService,
|
||||
private val eventService: LeaderResearchEventService
|
||||
) {
|
||||
private val logger = LoggerFactory.getLogger(LeaderResearchApprovalService::class.java)
|
||||
|
||||
@Transactional
|
||||
fun createDisabledTrialConfig(request: LeaderResearchApprovalRequest): Result<LeaderResearchApprovalResponse> {
|
||||
return try {
|
||||
if (!request.confirm) {
|
||||
return Result.failure(LeaderResearchApprovalConfirmRequiredException())
|
||||
}
|
||||
val candidate = candidateRepository.findById(request.candidateId).orElse(null)
|
||||
?: return Result.failure(IllegalArgumentException("候选不存在"))
|
||||
if (candidate.locked) {
|
||||
eventService.record(
|
||||
type = LeaderResearchEventType.APPROVAL_REJECTED,
|
||||
candidateId = candidate.id,
|
||||
reason = "Candidate is locked; manual unlock is required before approval"
|
||||
)
|
||||
return Result.failure(LeaderResearchCandidateLockedException())
|
||||
}
|
||||
if (candidate.researchState != LeaderResearchState.TRIAL_READY) {
|
||||
eventService.record(
|
||||
type = LeaderResearchEventType.APPROVAL_REJECTED,
|
||||
candidateId = candidate.id,
|
||||
reason = "Candidate state is ${candidate.researchState}, not TRIAL_READY"
|
||||
)
|
||||
return Result.failure(LeaderResearchCandidateNotReadyException())
|
||||
}
|
||||
val account = accountRepository.findByIdForUpdate(request.accountId)
|
||||
?: return Result.failure(IllegalArgumentException("账户不存在"))
|
||||
val synced = poolMappingService.syncCandidate(candidate)
|
||||
val pool = synced.poolId?.let { leaderPoolRepository.findById(it).orElse(null) }
|
||||
?: return Result.failure(IllegalStateException("Leader Pool 同步失败"))
|
||||
val leaderId = synced.leaderId ?: pool.leaderId
|
||||
if (copyTradingRepository.findByAccountIdAndLeaderId(account.id ?: request.accountId, leaderId).isNotEmpty()) {
|
||||
eventService.record(
|
||||
type = LeaderResearchEventType.DUPLICATE_APPROVAL,
|
||||
candidateId = candidate.id,
|
||||
reason = "Duplicate copy trading config for account=${account.id}, leader=$leaderId"
|
||||
)
|
||||
return Result.failure(LeaderResearchDuplicateTrialConfigException())
|
||||
}
|
||||
|
||||
val copyRequest = buildDisabledCopyTradingRequest(pool, request.accountId, leaderId)
|
||||
if (copyRequest.enabled) {
|
||||
eventService.record(
|
||||
type = LeaderResearchEventType.REAL_MONEY_ACTIVATION_FORBIDDEN,
|
||||
candidateId = candidate.id,
|
||||
reason = "Research approval attempted to create enabled copy trading config",
|
||||
dedupeKey = "approval-real-money-forbidden:${candidate.id}:${request.accountId}"
|
||||
)
|
||||
return Result.failure(LeaderResearchRealMoneyForbiddenException())
|
||||
}
|
||||
val copyTrading = copyTradingService.createCopyTrading(copyRequest).getOrThrow()
|
||||
val now = System.currentTimeMillis()
|
||||
leaderPoolRepository.save(
|
||||
pool.copy(
|
||||
status = LeaderPoolStatus.TRIAL,
|
||||
lastPromotedAt = now,
|
||||
lastReviewedAt = now,
|
||||
researchState = LeaderResearchState.TRIAL_READY,
|
||||
researchBadge = "DISABLED_TRIAL_CREATED",
|
||||
researchUpdatedAt = now,
|
||||
updatedAt = now
|
||||
)
|
||||
)
|
||||
eventService.record(
|
||||
type = LeaderResearchEventType.APPROVAL_CREATED_DISABLED_CONFIG,
|
||||
candidateId = candidate.id,
|
||||
reason = "Created disabled copy trading config id=${copyTrading.id}; manual enable required",
|
||||
payloadSummary = "accountId=${request.accountId}, leaderId=$leaderId",
|
||||
dedupeKey = "approval-disabled:${candidate.id}:${request.accountId}"
|
||||
)
|
||||
Result.success(LeaderResearchApprovalResponse(copyTrading))
|
||||
} catch (e: Exception) {
|
||||
logger.error("Leader research approval failed: candidateId=${request.candidateId}", e)
|
||||
Result.failure(e)
|
||||
}
|
||||
}
|
||||
|
||||
private fun buildDisabledCopyTradingRequest(pool: LeaderPool, accountId: Long, leaderId: Long): CopyTradingCreateRequest {
|
||||
val fixedAmount = pool.suggestedFixedAmount.takeIf { it > BigDecimal.ZERO } ?: BigDecimal("1.00000000")
|
||||
return CopyTradingCreateRequest(
|
||||
accountId = accountId,
|
||||
leaderId = leaderId,
|
||||
enabled = false,
|
||||
copyMode = "FIXED",
|
||||
copyRatio = "1",
|
||||
fixedAmount = fixedAmount.strip(),
|
||||
maxOrderSize = fixedAmount.strip(),
|
||||
minOrderSize = "1",
|
||||
maxDailyLoss = (pool.suggestedMaxDailyLoss.takeIf { it > BigDecimal.ZERO } ?: BigDecimal("5.00000000")).strip(),
|
||||
maxDailyOrders = pool.suggestedMaxDailyOrders.coerceIn(1, 10),
|
||||
priceTolerance = "1",
|
||||
delaySeconds = 0,
|
||||
pollIntervalSeconds = 5,
|
||||
useWebSocket = true,
|
||||
websocketReconnectInterval = 5000,
|
||||
websocketMaxRetries = 10,
|
||||
supportSell = true,
|
||||
minPrice = pool.suggestedMinPrice?.strip() ?: "0.1",
|
||||
maxPrice = pool.suggestedMaxPrice?.strip() ?: "0.8",
|
||||
maxPositionValue = pool.suggestedMaxPositionValue?.strip() ?: "5",
|
||||
keywordFilterMode = "DISABLED",
|
||||
keywords = null,
|
||||
configName = "Research试跟-${pool.researchCandidateId ?: pool.leaderId}",
|
||||
pushFailedOrders = true,
|
||||
pushFilteredOrders = true
|
||||
)
|
||||
}
|
||||
|
||||
private fun BigDecimal.strip(): String = stripTrailingZeros().toPlainString()
|
||||
}
|
||||
+50
@@ -0,0 +1,50 @@
|
||||
package com.wrbug.polymarketbot.service.copytrading.research
|
||||
|
||||
import com.wrbug.polymarketbot.entity.LeaderResearchEvent
|
||||
import com.wrbug.polymarketbot.enums.LeaderResearchEventType
|
||||
import com.wrbug.polymarketbot.enums.LeaderResearchNotificationStatus
|
||||
import com.wrbug.polymarketbot.repository.LeaderResearchEventRepository
|
||||
import org.slf4j.LoggerFactory
|
||||
import org.springframework.dao.DataIntegrityViolationException
|
||||
import org.springframework.stereotype.Service
|
||||
|
||||
@Service
|
||||
class LeaderResearchEventService(
|
||||
private val eventRepository: LeaderResearchEventRepository
|
||||
) {
|
||||
private val logger = LoggerFactory.getLogger(LeaderResearchEventService::class.java)
|
||||
|
||||
fun record(
|
||||
type: LeaderResearchEventType,
|
||||
candidateId: Long? = null,
|
||||
runId: Long? = null,
|
||||
reason: String? = null,
|
||||
payloadSummary: String? = null,
|
||||
dedupeKey: String? = null,
|
||||
notificationStatus: LeaderResearchNotificationStatus = LeaderResearchNotificationStatus.PENDING
|
||||
): LeaderResearchEvent? {
|
||||
return try {
|
||||
if (!dedupeKey.isNullOrBlank()) {
|
||||
eventRepository.findTopByDedupeKey(dedupeKey)?.let { return it }
|
||||
}
|
||||
eventRepository.save(
|
||||
LeaderResearchEvent(
|
||||
candidateId = candidateId,
|
||||
runId = runId,
|
||||
eventType = type,
|
||||
reason = reason,
|
||||
payloadSummary = payloadSummary,
|
||||
notificationStatus = notificationStatus,
|
||||
dedupeKey = dedupeKey,
|
||||
createdAt = System.currentTimeMillis()
|
||||
)
|
||||
)
|
||||
} catch (e: DataIntegrityViolationException) {
|
||||
logger.debug("Research event deduped: type={}, dedupeKey={}", type, dedupeKey)
|
||||
dedupeKey?.let { eventRepository.findTopByDedupeKey(it) }
|
||||
} catch (e: Exception) {
|
||||
logger.warn("Failed to record research event: type={}, candidateId={}, error={}", type, candidateId, e.message)
|
||||
null
|
||||
}
|
||||
}
|
||||
}
|
||||
+195
@@ -0,0 +1,195 @@
|
||||
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.LeaderResearchSourceStatus
|
||||
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 jakarta.annotation.PreDestroy
|
||||
import org.slf4j.LoggerFactory
|
||||
import org.springframework.beans.factory.annotation.Value
|
||||
import org.springframework.scheduling.annotation.Scheduled
|
||||
import org.springframework.stereotype.Service
|
||||
import java.util.concurrent.ExecutorService
|
||||
import java.util.concurrent.Executors
|
||||
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)
|
||||
internal var runExecutor: ExecutorService = Executors.newSingleThreadExecutor { runnable ->
|
||||
Thread(runnable, "leader-research-runner").apply { isDaemon = true }
|
||||
}
|
||||
|
||||
@Scheduled(fixedDelayString = "\${leader.research.fixed-delay-ms:900000}")
|
||||
fun scheduledRun() {
|
||||
if (!scheduledEnabled) return
|
||||
runOnce(dryRun = false, triggerType = LeaderResearchTriggerType.SCHEDULED)
|
||||
}
|
||||
|
||||
fun runOnce(dryRun: Boolean, triggerType: LeaderResearchTriggerType = LeaderResearchTriggerType.MANUAL): LeaderResearchRun {
|
||||
val start = startRunRecord(dryRun, triggerType)
|
||||
if (!start.acquired) return start.run
|
||||
return executeStartedRun(start.run)
|
||||
}
|
||||
|
||||
fun startAsync(dryRun: Boolean, triggerType: LeaderResearchTriggerType = LeaderResearchTriggerType.MANUAL): LeaderResearchRun {
|
||||
val start = startRunRecord(dryRun, triggerType)
|
||||
if (!start.acquired) return start.run
|
||||
|
||||
return try {
|
||||
runExecutor.submit {
|
||||
logger.info("Leader research async run started: runId={}", start.run.id)
|
||||
executeStartedRun(start.run)
|
||||
}
|
||||
logger.info("Leader research async run queued: runId={}, triggerType={}", start.run.id, triggerType)
|
||||
start.run
|
||||
} catch (e: RuntimeException) {
|
||||
logger.error("Failed to queue leader research async run: runId={}", start.run.id, e)
|
||||
failStartedRun(start.run, e).also { running.set(false) }
|
||||
}
|
||||
}
|
||||
|
||||
@PreDestroy
|
||||
fun shutdown() {
|
||||
runExecutor.shutdownNow()
|
||||
}
|
||||
|
||||
private data class RunStart(val run: LeaderResearchRun, val acquired: Boolean)
|
||||
|
||||
private fun startRunRecord(dryRun: Boolean, triggerType: LeaderResearchTriggerType): RunStart {
|
||||
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 RunStart(skipped, acquired = false)
|
||||
}
|
||||
|
||||
val startedAt = System.currentTimeMillis()
|
||||
return try {
|
||||
val 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"
|
||||
)
|
||||
RunStart(run, acquired = true)
|
||||
} catch (e: RuntimeException) {
|
||||
running.set(false)
|
||||
throw e
|
||||
}
|
||||
}
|
||||
|
||||
private fun executeStartedRun(startedRun: LeaderResearchRun): LeaderResearchRun {
|
||||
var run = startedRun
|
||||
val startedAt = startedRun.startedAt
|
||||
return try {
|
||||
val isPreview = run.dryRun || run.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.expectedLimitation && (it.status == LeaderResearchSourceStatus.FAILURE || it.status == LeaderResearchSourceStatus.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) {
|
||||
failStartedRun(run, e)
|
||||
} finally {
|
||||
running.set(false)
|
||||
}
|
||||
}
|
||||
|
||||
private fun failStartedRun(run: LeaderResearchRun, e: Exception): LeaderResearchRun {
|
||||
logger.error("Leader research run failed", e)
|
||||
val now = System.currentTimeMillis()
|
||||
return runRepository.save(
|
||||
run.copy(
|
||||
status = LeaderResearchRunStatus.FAILED,
|
||||
finishedAt = now,
|
||||
durationMs = now - run.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
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
+251
@@ -0,0 +1,251 @@
|
||||
package com.wrbug.polymarketbot.service.copytrading.research
|
||||
|
||||
import com.wrbug.polymarketbot.dto.*
|
||||
import com.wrbug.polymarketbot.entity.*
|
||||
import com.wrbug.polymarketbot.enums.LeaderPoolStatus
|
||||
import com.wrbug.polymarketbot.repository.LeaderPoolRepository
|
||||
import com.wrbug.polymarketbot.repository.LeaderRepository
|
||||
import com.wrbug.polymarketbot.repository.LeaderResearchSourceStateRepository
|
||||
import org.springframework.stereotype.Component
|
||||
import java.math.BigDecimal
|
||||
|
||||
data class LeaderResearchCandidateDtoContext(
|
||||
val leadersById: Map<Long, Leader> = emptyMap(),
|
||||
val poolsById: Map<Long, LeaderPool> = emptyMap(),
|
||||
val latestSessionsByCandidateId: Map<Long, LeaderPaperSession> = emptyMap()
|
||||
)
|
||||
|
||||
@Component
|
||||
class LeaderResearchMapper(
|
||||
private val leaderRepository: LeaderRepository,
|
||||
private val leaderPoolRepository: LeaderPoolRepository,
|
||||
private val sourceStateRepository: LeaderResearchSourceStateRepository
|
||||
) {
|
||||
fun runDto(run: LeaderResearchRun): LeaderResearchRunDto {
|
||||
return LeaderResearchRunDto(
|
||||
id = run.id ?: 0,
|
||||
status = run.status.name,
|
||||
triggerType = run.triggerType.name,
|
||||
dryRun = run.dryRun,
|
||||
startedAt = run.startedAt,
|
||||
finishedAt = run.finishedAt,
|
||||
durationMs = run.durationMs,
|
||||
sourceCountsJson = run.sourceCountsJson,
|
||||
candidateCountsJson = run.candidateCountsJson,
|
||||
partialFailure = run.partialFailure,
|
||||
skippedReason = run.skippedReason,
|
||||
errorClass = run.errorClass,
|
||||
errorMessage = run.errorMessage
|
||||
)
|
||||
}
|
||||
|
||||
fun candidateDto(candidate: LeaderResearchCandidate, latestSession: LeaderPaperSession? = null): LeaderResearchCandidateDto {
|
||||
val leader = candidate.leaderId?.let { leaderRepository.findById(it).orElse(null) }
|
||||
val pool = candidate.poolId?.let { leaderPoolRepository.findById(it).orElse(null) }
|
||||
return candidateDto(candidate, leader, pool, latestSession)
|
||||
}
|
||||
|
||||
fun candidateDtos(candidates: List<LeaderResearchCandidate>, context: LeaderResearchCandidateDtoContext): List<LeaderResearchCandidateDto> {
|
||||
return candidates.map { candidate ->
|
||||
val candidateId = candidate.id
|
||||
candidateDto(
|
||||
candidate = candidate,
|
||||
leader = candidate.leaderId?.let { context.leadersById[it] },
|
||||
pool = candidate.poolId?.let { context.poolsById[it] },
|
||||
latestSession = candidateId?.let { context.latestSessionsByCandidateId[it] }
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
private fun candidateDto(
|
||||
candidate: LeaderResearchCandidate,
|
||||
leader: Leader?,
|
||||
pool: LeaderPool?,
|
||||
latestSession: LeaderPaperSession?
|
||||
): LeaderResearchCandidateDto {
|
||||
return LeaderResearchCandidateDto(
|
||||
id = candidate.id ?: 0,
|
||||
normalizedWallet = candidate.normalizedWallet,
|
||||
leaderId = candidate.leaderId,
|
||||
leaderName = leader?.leaderName,
|
||||
poolId = candidate.poolId,
|
||||
poolStatus = pool?.status?.name,
|
||||
suggestedFixedAmount = pool?.suggestedFixedAmount?.strip(),
|
||||
suggestedMaxDailyLoss = pool?.suggestedMaxDailyLoss?.strip(),
|
||||
suggestedMaxDailyOrders = pool?.suggestedMaxDailyOrders,
|
||||
suggestedMinPrice = pool?.suggestedMinPrice?.strip(),
|
||||
suggestedMaxPrice = pool?.suggestedMaxPrice?.strip(),
|
||||
suggestedMaxPositionValue = pool?.suggestedMaxPositionValue?.strip(),
|
||||
researchState = candidate.researchState.name,
|
||||
source = candidate.source,
|
||||
sourceRank = candidate.sourceRank,
|
||||
score = candidate.score?.strip(),
|
||||
scoreVersion = candidate.scoreVersion,
|
||||
reason = candidate.reason,
|
||||
riskFlags = splitFlags(candidate.riskFlags),
|
||||
locked = candidate.locked,
|
||||
agentOwned = candidate.agentOwned,
|
||||
provenance = candidate.provenance.name,
|
||||
sourceEvidence = candidate.sourceEvidence,
|
||||
firstSeenAt = candidate.firstSeenAt,
|
||||
lastSourceSeenAt = candidate.lastSourceSeenAt,
|
||||
lastScoredAt = candidate.lastScoredAt,
|
||||
cooldownUntil = candidate.cooldownUntil,
|
||||
cooldownCount = candidate.cooldownCount,
|
||||
trialReadyAt = candidate.trialReadyAt,
|
||||
retiredAt = candidate.retiredAt,
|
||||
lastPaperSessionId = candidate.lastPaperSessionId,
|
||||
latestPaperSession = latestSession?.let { paperSessionDto(it) }
|
||||
)
|
||||
}
|
||||
|
||||
fun scoreDto(score: LeaderResearchScore): LeaderResearchScoreDto {
|
||||
return LeaderResearchScoreDto(
|
||||
id = score.id ?: 0,
|
||||
candidateId = score.candidateId,
|
||||
runId = score.runId,
|
||||
scoreVersion = score.scoreVersion,
|
||||
totalScore = score.totalScore.strip(),
|
||||
profitSignal = score.profitSignal.strip(),
|
||||
repeatability = score.repeatability.strip(),
|
||||
liquidityFit = score.liquidityFit.strip(),
|
||||
entryPriceFit = score.entryPriceFit.strip(),
|
||||
slippageRisk = score.slippageRisk.strip(),
|
||||
holdingPeriodFit = score.holdingPeriodFit.strip(),
|
||||
marketTypeRisk = score.marketTypeRisk.strip(),
|
||||
drawdownRisk = score.drawdownRisk.strip(),
|
||||
exitLiquidityRisk = score.exitLiquidityRisk.strip(),
|
||||
dataFreshness = score.dataFreshness.strip(),
|
||||
filterPassRate = score.filterPassRate.strip(),
|
||||
sampleTradeCount = score.sampleTradeCount,
|
||||
reason = score.reason,
|
||||
createdAt = score.createdAt
|
||||
)
|
||||
}
|
||||
|
||||
fun paperSessionDto(session: LeaderPaperSession): LeaderPaperSessionDto {
|
||||
return LeaderPaperSessionDto(
|
||||
id = session.id ?: 0,
|
||||
candidateId = session.candidateId,
|
||||
status = session.status.name,
|
||||
startedAt = session.startedAt,
|
||||
endedAt = session.endedAt,
|
||||
tradeCount = session.tradeCount,
|
||||
filteredCount = session.filteredCount,
|
||||
openExposure = session.openExposure.strip(),
|
||||
totalRealizedPnl = session.totalRealizedPnl.strip(),
|
||||
totalUnrealizedPnl = session.totalUnrealizedPnl.strip(),
|
||||
copyablePnl = session.copyablePnl.strip(),
|
||||
maxDrawdown = session.maxDrawdown.strip(),
|
||||
unknownValuationExposure = session.unknownValuationExposure.strip(),
|
||||
confirmedZeroExposure = session.confirmedZeroExposure.strip(),
|
||||
filteredRatio = session.filteredRatio.strip(),
|
||||
lastProcessedEventTime = session.lastProcessedEventTime,
|
||||
scoreSnapshot = session.scoreSnapshot?.strip()
|
||||
)
|
||||
}
|
||||
|
||||
fun paperTradeDto(trade: LeaderPaperTrade): LeaderPaperTradeDto {
|
||||
return LeaderPaperTradeDto(
|
||||
id = trade.id ?: 0,
|
||||
sessionId = trade.sessionId,
|
||||
candidateId = trade.candidateId,
|
||||
activityEventId = trade.activityEventId,
|
||||
leaderTradeId = trade.leaderTradeId,
|
||||
marketId = trade.marketId,
|
||||
marketTitle = trade.marketTitle,
|
||||
marketSlug = trade.marketSlug,
|
||||
side = trade.side,
|
||||
outcome = trade.outcome,
|
||||
outcomeIndex = trade.outcomeIndex,
|
||||
leaderPrice = trade.leaderPrice?.strip(),
|
||||
leaderSize = trade.leaderSize?.strip(),
|
||||
simulatedPrice = trade.simulatedPrice?.strip(),
|
||||
simulatedSize = trade.simulatedSize?.strip(),
|
||||
simulatedAmount = trade.simulatedAmount?.strip(),
|
||||
fillAssumption = trade.fillAssumption.name,
|
||||
quoteConfidence = trade.quoteConfidence.name,
|
||||
quoteSource = trade.quoteSource,
|
||||
quoteTimestamp = trade.quoteTimestamp,
|
||||
filterResult = trade.filterResult.name,
|
||||
filterReason = trade.filterReason,
|
||||
valuationStatus = trade.valuationStatus.name,
|
||||
realizedPnl = trade.realizedPnl?.strip(),
|
||||
eventTime = trade.eventTime,
|
||||
createdAt = trade.createdAt
|
||||
)
|
||||
}
|
||||
|
||||
fun paperPositionDto(position: LeaderPaperPosition): LeaderPaperPositionDto {
|
||||
return LeaderPaperPositionDto(
|
||||
id = position.id ?: 0,
|
||||
sessionId = position.sessionId,
|
||||
candidateId = position.candidateId,
|
||||
marketId = position.marketId,
|
||||
outcome = position.outcome,
|
||||
outcomeIndex = position.outcomeIndex,
|
||||
quantity = position.quantity.strip(),
|
||||
cost = position.cost.strip(),
|
||||
avgPrice = position.avgPrice.strip(),
|
||||
currentPrice = position.currentPrice?.strip(),
|
||||
currentValue = position.currentValue.strip(),
|
||||
realizedPnl = position.realizedPnl.strip(),
|
||||
unrealizedPnl = position.unrealizedPnl.strip(),
|
||||
valuationStatus = position.valuationStatus.name,
|
||||
quoteConfidence = position.quoteConfidence.name,
|
||||
quoteSource = position.quoteSource,
|
||||
quoteTimestamp = position.quoteTimestamp,
|
||||
updatedAt = position.updatedAt
|
||||
)
|
||||
}
|
||||
|
||||
fun sourceStateDto(state: LeaderResearchSourceState): LeaderResearchSourceStateDto {
|
||||
return LeaderResearchSourceStateDto(
|
||||
sourceType = state.sourceType.name,
|
||||
status = state.status.name,
|
||||
lastSuccessAt = state.lastSuccessAt,
|
||||
lastFailureAt = state.lastFailureAt,
|
||||
lastRunAt = state.lastRunAt,
|
||||
lastCandidateCount = state.lastCandidateCount,
|
||||
errorClass = state.errorClass,
|
||||
errorMessage = state.errorMessage,
|
||||
stale = state.stale,
|
||||
disabledReason = state.disabledReason,
|
||||
lastCursor = state.lastCursor,
|
||||
updatedAt = state.updatedAt
|
||||
)
|
||||
}
|
||||
|
||||
fun eventDto(event: LeaderResearchEvent): LeaderResearchEventDto {
|
||||
return LeaderResearchEventDto(
|
||||
id = event.id ?: 0,
|
||||
candidateId = event.candidateId,
|
||||
runId = event.runId,
|
||||
eventType = event.eventType.name,
|
||||
reason = event.reason,
|
||||
payloadSummary = event.payloadSummary,
|
||||
notificationStatus = event.notificationStatus.name,
|
||||
notificationError = event.notificationError,
|
||||
dedupeKey = event.dedupeKey,
|
||||
createdAt = event.createdAt,
|
||||
notifiedAt = event.notifiedAt
|
||||
)
|
||||
}
|
||||
|
||||
fun sourceLimitations(): List<String> {
|
||||
return sourceStateRepository.findAllByOrderByUpdatedAtDesc()
|
||||
.filter { it.stale || it.status.name == "DISABLED" || !it.disabledReason.isNullOrBlank() }
|
||||
.map { "${it.sourceType.name}: ${it.disabledReason ?: it.errorMessage ?: it.status.name}" }
|
||||
}
|
||||
|
||||
fun isTrialOrActive(status: LeaderPoolStatus?): Boolean {
|
||||
return status == LeaderPoolStatus.TRIAL || status == LeaderPoolStatus.ACTIVE
|
||||
}
|
||||
|
||||
private fun splitFlags(raw: String?): List<String> {
|
||||
if (raw.isNullOrBlank()) return emptyList()
|
||||
return raw.split(",", "\n", ";").map { it.trim() }.filter { it.isNotEmpty() }
|
||||
}
|
||||
|
||||
private fun BigDecimal.strip(): String = stripTrailingZeros().toPlainString()
|
||||
}
|
||||
+72
@@ -0,0 +1,72 @@
|
||||
package com.wrbug.polymarketbot.service.copytrading.research
|
||||
|
||||
import com.wrbug.polymarketbot.entity.LeaderResearchEvent
|
||||
import com.wrbug.polymarketbot.enums.LeaderResearchEventType
|
||||
import com.wrbug.polymarketbot.enums.LeaderResearchNotificationStatus
|
||||
import com.wrbug.polymarketbot.repository.LeaderResearchEventRepository
|
||||
import org.springframework.data.domain.PageRequest
|
||||
import org.springframework.stereotype.Service
|
||||
import org.springframework.transaction.annotation.Transactional
|
||||
|
||||
data class LeaderResearchNotificationSummary(
|
||||
val total: Int,
|
||||
val newCandidates: Int,
|
||||
val trialReady: Int,
|
||||
val cooldowns: Int,
|
||||
val sourceFailures: Int,
|
||||
val valuationWarnings: Int,
|
||||
val approvalWarnings: Int,
|
||||
val lines: List<String>
|
||||
)
|
||||
|
||||
@Service
|
||||
class LeaderResearchNotificationSummaryService(
|
||||
private val eventRepository: LeaderResearchEventRepository
|
||||
) {
|
||||
fun buildPendingSummary(limit: Int = 100): LeaderResearchNotificationSummary {
|
||||
val events = eventRepository.findByNotificationStatusOrderByCreatedAtAsc(
|
||||
LeaderResearchNotificationStatus.PENDING,
|
||||
PageRequest.of(0, limit.coerceIn(1, 500))
|
||||
).content
|
||||
return summarize(events)
|
||||
}
|
||||
|
||||
@Transactional
|
||||
fun markPendingAsSkipped(limit: Int = 100, reason: String = "operator_console_only"): LeaderResearchNotificationSummary {
|
||||
val events = eventRepository.findByNotificationStatusOrderByCreatedAtAsc(
|
||||
LeaderResearchNotificationStatus.PENDING,
|
||||
PageRequest.of(0, limit.coerceIn(1, 500))
|
||||
).content
|
||||
val now = System.currentTimeMillis()
|
||||
events.forEach { event ->
|
||||
eventRepository.save(
|
||||
event.copy(
|
||||
notificationStatus = LeaderResearchNotificationStatus.SKIPPED,
|
||||
notificationError = reason,
|
||||
notifiedAt = now
|
||||
)
|
||||
)
|
||||
}
|
||||
return summarize(events)
|
||||
}
|
||||
|
||||
private fun summarize(events: List<LeaderResearchEvent>): LeaderResearchNotificationSummary {
|
||||
val lines = events.take(20).map { event ->
|
||||
"${event.eventType.name}: ${event.reason ?: event.payloadSummary ?: "no details"}"
|
||||
}
|
||||
return LeaderResearchNotificationSummary(
|
||||
total = events.size,
|
||||
newCandidates = events.count { it.eventType == LeaderResearchEventType.CANDIDATE_DISCOVERED },
|
||||
trialReady = events.count { it.eventType == LeaderResearchEventType.TRIAL_READY },
|
||||
cooldowns = events.count { it.eventType == LeaderResearchEventType.COOLDOWN },
|
||||
sourceFailures = events.count { it.eventType == LeaderResearchEventType.SOURCE_FAILURE },
|
||||
valuationWarnings = events.count { it.eventType == LeaderResearchEventType.VALUATION_STALE },
|
||||
approvalWarnings = events.count {
|
||||
it.eventType == LeaderResearchEventType.APPROVAL_REJECTED ||
|
||||
it.eventType == LeaderResearchEventType.DUPLICATE_APPROVAL ||
|
||||
it.eventType == LeaderResearchEventType.REAL_MONEY_ACTIVATION_FORBIDDEN
|
||||
},
|
||||
lines = lines
|
||||
)
|
||||
}
|
||||
}
|
||||
+99
@@ -0,0 +1,99 @@
|
||||
package com.wrbug.polymarketbot.service.copytrading.research
|
||||
|
||||
import com.wrbug.polymarketbot.entity.Leader
|
||||
import com.wrbug.polymarketbot.entity.LeaderPool
|
||||
import com.wrbug.polymarketbot.entity.LeaderResearchCandidate
|
||||
import com.wrbug.polymarketbot.enums.LeaderPoolStatus
|
||||
import com.wrbug.polymarketbot.enums.LeaderResearchState
|
||||
import com.wrbug.polymarketbot.repository.LeaderPoolRepository
|
||||
import com.wrbug.polymarketbot.repository.LeaderRepository
|
||||
import com.wrbug.polymarketbot.repository.LeaderResearchCandidateRepository
|
||||
import org.springframework.stereotype.Service
|
||||
import org.springframework.transaction.annotation.Transactional
|
||||
import java.math.BigDecimal
|
||||
|
||||
@Service
|
||||
class LeaderResearchPoolMappingService(
|
||||
private val leaderRepository: LeaderRepository,
|
||||
private val leaderPoolRepository: LeaderPoolRepository,
|
||||
private val candidateRepository: LeaderResearchCandidateRepository
|
||||
) {
|
||||
@Transactional
|
||||
fun syncCandidate(candidate: LeaderResearchCandidate): LeaderResearchCandidate {
|
||||
require(candidate.researchState != LeaderResearchState.DISCOVERED) {
|
||||
"DISCOVERED research candidates must not be synced to Leader Pool"
|
||||
}
|
||||
val now = System.currentTimeMillis()
|
||||
val leader = ensureLeader(candidate)
|
||||
val pool = ensurePool(candidate, leader)
|
||||
val badge = when (candidate.researchState) {
|
||||
LeaderResearchState.TRIAL_READY -> "RESEARCH_TRIAL_READY"
|
||||
LeaderResearchState.PAPER -> "RESEARCH_PAPER"
|
||||
LeaderResearchState.COOLDOWN -> "RESEARCH_COOLDOWN"
|
||||
else -> null
|
||||
}
|
||||
val savedPool = leaderPoolRepository.save(
|
||||
pool.copy(
|
||||
researchCandidateId = candidate.id,
|
||||
researchState = candidate.researchState,
|
||||
researchBadge = badge,
|
||||
researchSummary = candidate.reason?.take(1000),
|
||||
researchScore = candidate.score,
|
||||
researchUpdatedAt = now,
|
||||
updatedAt = now
|
||||
)
|
||||
)
|
||||
return candidateRepository.save(
|
||||
candidate.copy(
|
||||
leaderId = leader.id,
|
||||
poolId = savedPool.id,
|
||||
updatedAt = now
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
private fun ensureLeader(candidate: LeaderResearchCandidate): Leader {
|
||||
candidate.leaderId?.let { id ->
|
||||
leaderRepository.findById(id).orElse(null)?.let { return it }
|
||||
}
|
||||
leaderRepository.findByLeaderAddress(candidate.normalizedWallet)?.let { return it }
|
||||
val now = System.currentTimeMillis()
|
||||
return leaderRepository.save(
|
||||
Leader(
|
||||
leaderAddress = candidate.normalizedWallet,
|
||||
leaderName = "Research ${candidate.normalizedWallet.take(6)}...${candidate.normalizedWallet.takeLast(4)}",
|
||||
remark = "Created by Leader Research Agent. Manual enable is required before real-money copy trading.",
|
||||
createdAt = now,
|
||||
updatedAt = now
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
private fun ensurePool(candidate: LeaderResearchCandidate, leader: Leader): LeaderPool {
|
||||
leader.id?.let { leaderPoolRepository.findByLeaderId(it) }?.let { return it }
|
||||
val now = System.currentTimeMillis()
|
||||
return leaderPoolRepository.save(
|
||||
LeaderPool(
|
||||
leaderId = leader.id ?: 0,
|
||||
status = LeaderPoolStatus.WATCH,
|
||||
source = "RESEARCH_AGENT",
|
||||
score = candidate.score,
|
||||
reason = candidate.reason,
|
||||
notes = "Research agent candidate. Pool row is informational until you approve a disabled trial config.",
|
||||
suggestedFixedAmount = BigDecimal("1.00000000"),
|
||||
suggestedMaxDailyOrders = 10,
|
||||
suggestedMaxDailyLoss = BigDecimal("5.00000000"),
|
||||
suggestedMinPrice = BigDecimal("0.10000000"),
|
||||
suggestedMaxPrice = BigDecimal("0.80000000"),
|
||||
suggestedMaxPositionValue = BigDecimal("5.00000000"),
|
||||
researchCandidateId = candidate.id,
|
||||
researchState = candidate.researchState,
|
||||
researchScore = candidate.score,
|
||||
researchSummary = candidate.reason,
|
||||
researchUpdatedAt = now,
|
||||
createdAt = now,
|
||||
updatedAt = now
|
||||
)
|
||||
)
|
||||
}
|
||||
}
|
||||
+61
@@ -0,0 +1,61 @@
|
||||
package com.wrbug.polymarketbot.service.copytrading.research
|
||||
|
||||
import com.wrbug.polymarketbot.enums.LeaderPaperProcessingStatus
|
||||
import com.wrbug.polymarketbot.enums.LeaderPaperSessionStatus
|
||||
import com.wrbug.polymarketbot.repository.LeaderActivityEventRepository
|
||||
import com.wrbug.polymarketbot.repository.LeaderPaperSessionRepository
|
||||
import org.springframework.beans.factory.annotation.Value
|
||||
import org.springframework.data.domain.PageRequest
|
||||
import org.springframework.scheduling.annotation.Scheduled
|
||||
import org.springframework.stereotype.Service
|
||||
import org.springframework.transaction.annotation.Transactional
|
||||
|
||||
data class LeaderResearchRetentionResult(
|
||||
val deletedActivityEvents: Long,
|
||||
val deletedPaperSessions: Long
|
||||
)
|
||||
|
||||
@Service
|
||||
class LeaderResearchRetentionService(
|
||||
private val activityEventRepository: LeaderActivityEventRepository,
|
||||
private val paperSessionRepository: LeaderPaperSessionRepository,
|
||||
@Value("\${leader.research.retention.enabled:true}") private val enabled: Boolean,
|
||||
@Value("\${leader.research.retention.activity-days:90}") private val activityRetentionDays: Long,
|
||||
@Value("\${leader.research.retention.paper-session-days:180}") private val paperSessionRetentionDays: Long,
|
||||
@Value("\${leader.research.retention.max-paper-sessions-per-run:100}") private val maxPaperSessionsPerRun: Int
|
||||
) {
|
||||
@Scheduled(cron = "\${leader.research.retention.cron:0 17 3 * * *}")
|
||||
fun scheduledCleanup() {
|
||||
if (!enabled) return
|
||||
cleanup()
|
||||
}
|
||||
|
||||
@Transactional
|
||||
fun cleanup(now: Long = System.currentTimeMillis()): LeaderResearchRetentionResult {
|
||||
if (!enabled) return LeaderResearchRetentionResult(0, 0)
|
||||
val activityCutoff = now - activityRetentionDays.coerceAtLeast(7) * MILLIS_PER_DAY
|
||||
val paperCutoff = now - paperSessionRetentionDays.coerceAtLeast(30) * MILLIS_PER_DAY
|
||||
val deletedActivities = activityEventRepository.deleteByEventTimeLessThanAndPaperProcessingStatusIn(
|
||||
activityCutoff,
|
||||
listOf(
|
||||
LeaderPaperProcessingStatus.PROCESSED,
|
||||
LeaderPaperProcessingStatus.FILTERED,
|
||||
LeaderPaperProcessingStatus.FAILED
|
||||
)
|
||||
)
|
||||
val staleSessions = paperSessionRepository.findByUpdatedAtLessThanAndStatusIn(
|
||||
paperCutoff,
|
||||
listOf(LeaderPaperSessionStatus.COMPLETED, LeaderPaperSessionStatus.FAILED),
|
||||
PageRequest.of(0, maxPaperSessionsPerRun.coerceIn(1, 1000))
|
||||
)
|
||||
paperSessionRepository.deleteAll(staleSessions.content)
|
||||
return LeaderResearchRetentionResult(
|
||||
deletedActivityEvents = deletedActivities,
|
||||
deletedPaperSessions = staleSessions.content.size.toLong()
|
||||
)
|
||||
}
|
||||
|
||||
companion object {
|
||||
private const val MILLIS_PER_DAY = 24L * 60 * 60 * 1000
|
||||
}
|
||||
}
|
||||
+166
@@ -0,0 +1,166 @@
|
||||
package com.wrbug.polymarketbot.service.copytrading.research
|
||||
|
||||
import com.wrbug.polymarketbot.entity.LeaderPaperSession
|
||||
import com.wrbug.polymarketbot.entity.LeaderResearchCandidate
|
||||
import com.wrbug.polymarketbot.entity.LeaderResearchScore
|
||||
import com.wrbug.polymarketbot.enums.LeaderResearchState
|
||||
import com.wrbug.polymarketbot.repository.LeaderPaperSessionRepository
|
||||
import com.wrbug.polymarketbot.repository.LeaderResearchCandidateRepository
|
||||
import com.wrbug.polymarketbot.repository.LeaderResearchScoreRepository
|
||||
import org.springframework.stereotype.Service
|
||||
import org.springframework.transaction.annotation.Transactional
|
||||
import java.math.BigDecimal
|
||||
import java.math.RoundingMode
|
||||
|
||||
@Service
|
||||
class LeaderResearchScoringService(
|
||||
private val candidateRepository: LeaderResearchCandidateRepository,
|
||||
private val paperSessionRepository: LeaderPaperSessionRepository,
|
||||
private val scoreRepository: LeaderResearchScoreRepository
|
||||
) {
|
||||
@Transactional
|
||||
fun scoreAll(runId: Long?): List<LeaderResearchScore> {
|
||||
return candidateRepository.findByResearchStateIn(
|
||||
listOf(
|
||||
LeaderResearchState.DISCOVERED,
|
||||
LeaderResearchState.CANDIDATE,
|
||||
LeaderResearchState.PAPER,
|
||||
LeaderResearchState.TRIAL_READY,
|
||||
LeaderResearchState.COOLDOWN
|
||||
)
|
||||
).map { scoreCandidate(it, runId) }
|
||||
}
|
||||
|
||||
@Transactional
|
||||
fun scoreCandidate(candidate: LeaderResearchCandidate, runId: Long?): LeaderResearchScore {
|
||||
val session = candidate.id?.let { paperSessionRepository.findTopByCandidateIdOrderByStartedAtDesc(it) }
|
||||
val score = compute(candidate, session, runId)
|
||||
val savedScore = scoreRepository.save(score)
|
||||
val now = System.currentTimeMillis()
|
||||
candidateRepository.save(
|
||||
candidate.copy(
|
||||
score = savedScore.totalScore,
|
||||
scoreVersion = savedScore.scoreVersion,
|
||||
reason = savedScore.reason,
|
||||
riskFlags = buildRiskFlags(session),
|
||||
lastScoredAt = now,
|
||||
updatedAt = now
|
||||
)
|
||||
)
|
||||
return savedScore
|
||||
}
|
||||
|
||||
fun compute(candidate: LeaderResearchCandidate, session: LeaderPaperSession?, runId: Long?): LeaderResearchScore {
|
||||
val now = System.currentTimeMillis()
|
||||
val sourceFresh = candidate.lastSourceSeenAt?.let { now - it <= SOURCE_FRESH_MS } == true
|
||||
val paperAgeMs = session?.let { now - it.startedAt } ?: 0L
|
||||
val unknownRatio = session?.unknownRatio() ?: BigDecimal.ONE
|
||||
val filteredRatio = session?.filteredRatio ?: BigDecimal.ONE
|
||||
val copyablePnl = session?.copyablePnl ?: BigDecimal.ZERO
|
||||
val tradeCount = session?.tradeCount ?: 0
|
||||
|
||||
val profitSignal = when {
|
||||
copyablePnl > BigDecimal("10") -> BigDecimal("20")
|
||||
copyablePnl > BigDecimal.ZERO -> copyablePnl.multiply(BigDecimal("2")).clamp(BigDecimal.ZERO, BigDecimal("20"))
|
||||
else -> BigDecimal.ZERO
|
||||
}
|
||||
val repeatability = BigDecimal(tradeCount).multiply(BigDecimal("1.5")).clamp(BigDecimal.ZERO, BigDecimal("15"))
|
||||
val liquidityFit = BigDecimal("10").subtract(unknownRatio.multiply(BigDecimal("10"))).clamp(BigDecimal.ZERO, BigDecimal("10"))
|
||||
val entryPriceFit = BigDecimal("10").subtract(filteredRatio.multiply(BigDecimal("10"))).clamp(BigDecimal.ZERO, BigDecimal("10"))
|
||||
val slippageRisk = if (unknownRatio <= BigDecimal("0.20")) BigDecimal("10") else BigDecimal("4")
|
||||
val holdingPeriodFit = if (paperAgeMs >= PAPER_MIN_AGE_MS) BigDecimal("5") else BigDecimal(paperAgeMs).safeDivide(BigDecimal(PAPER_MIN_AGE_MS)).multiply(BigDecimal("5"))
|
||||
val marketTypeRisk = BigDecimal("5")
|
||||
val drawdownRisk = when {
|
||||
session == null -> BigDecimal("5")
|
||||
session.maxDrawdown >= BigDecimal("-5") -> BigDecimal("10")
|
||||
session.maxDrawdown >= BigDecimal("-15") -> BigDecimal("7")
|
||||
session.maxDrawdown >= BigDecimal("-20") -> BigDecimal("3")
|
||||
else -> BigDecimal.ZERO
|
||||
}
|
||||
val exitLiquidityRisk = if (unknownRatio <= BigDecimal("0.20")) BigDecimal("5") else BigDecimal("1")
|
||||
val dataFreshness = if (sourceFresh) BigDecimal("5") else BigDecimal.ZERO
|
||||
val filterPassRate = BigDecimal("5").subtract(filteredRatio.multiply(BigDecimal("5"))).clamp(BigDecimal.ZERO, BigDecimal("5"))
|
||||
val rawTotal = listOf(
|
||||
profitSignal,
|
||||
repeatability,
|
||||
liquidityFit,
|
||||
entryPriceFit,
|
||||
slippageRisk,
|
||||
holdingPeriodFit,
|
||||
marketTypeRisk,
|
||||
drawdownRisk,
|
||||
exitLiquidityRisk,
|
||||
dataFreshness,
|
||||
filterPassRate
|
||||
).fold(BigDecimal.ZERO, BigDecimal::add).setScale(8, RoundingMode.HALF_UP)
|
||||
val sampleCapApplied = tradeCount < PAPER_MIN_TRADES && rawTotal > SAMPLE_INSUFFICIENT_CAP
|
||||
val total = if (sampleCapApplied) SAMPLE_INSUFFICIENT_CAP else rawTotal
|
||||
|
||||
val reason = listOf(
|
||||
"score_v1=$total",
|
||||
"copyable_pnl=$copyablePnl",
|
||||
"trades=$tradeCount",
|
||||
"sample_cap_applied=$sampleCapApplied",
|
||||
"unknown_quote_ratio=${unknownRatio.setScale(4, RoundingMode.HALF_UP)}",
|
||||
"filtered_ratio=${filteredRatio.setScale(4, RoundingMode.HALF_UP)}",
|
||||
"source_fresh=$sourceFresh"
|
||||
).joinToString("; ")
|
||||
|
||||
return LeaderResearchScore(
|
||||
candidateId = candidate.id ?: 0,
|
||||
runId = runId,
|
||||
scoreVersion = SCORE_VERSION,
|
||||
totalScore = total,
|
||||
profitSignal = profitSignal,
|
||||
repeatability = repeatability,
|
||||
liquidityFit = liquidityFit,
|
||||
entryPriceFit = entryPriceFit,
|
||||
slippageRisk = slippageRisk,
|
||||
holdingPeriodFit = holdingPeriodFit,
|
||||
marketTypeRisk = marketTypeRisk,
|
||||
drawdownRisk = drawdownRisk,
|
||||
exitLiquidityRisk = exitLiquidityRisk,
|
||||
dataFreshness = dataFreshness,
|
||||
filterPassRate = filterPassRate,
|
||||
sampleTradeCount = tradeCount,
|
||||
reason = reason,
|
||||
createdAt = System.currentTimeMillis()
|
||||
)
|
||||
}
|
||||
|
||||
private fun buildRiskFlags(session: LeaderPaperSession?): String? {
|
||||
if (session == null) return "no_paper_session"
|
||||
val flags = mutableListOf<String>()
|
||||
if (session.maxDrawdown < BigDecimal("-15")) flags += "drawdown_gt_15"
|
||||
if (session.filteredRatio >= BigDecimal("0.50")) flags += "high_filtered_ratio"
|
||||
if (session.unknownRatio() > BigDecimal("0.20")) flags += "high_unknown_quote_exposure"
|
||||
if (session.tradeCount < 10) flags += "small_sample"
|
||||
return flags.takeIf { it.isNotEmpty() }?.joinToString(",")
|
||||
}
|
||||
|
||||
private fun LeaderPaperSession.unknownRatio(): BigDecimal {
|
||||
if (openExposure <= BigDecimal.ZERO) return BigDecimal.ZERO
|
||||
return unknownValuationExposure.safeDivide(openExposure)
|
||||
}
|
||||
|
||||
private fun BigDecimal.safeDivide(other: BigDecimal): BigDecimal {
|
||||
if (other.compareTo(BigDecimal.ZERO) == 0) return BigDecimal.ZERO
|
||||
return divide(other, 8, RoundingMode.HALF_UP)
|
||||
}
|
||||
|
||||
private fun BigDecimal.clamp(min: BigDecimal, max: BigDecimal): BigDecimal {
|
||||
return when {
|
||||
this < min -> min
|
||||
this > max -> max
|
||||
else -> this
|
||||
}
|
||||
}
|
||||
|
||||
companion object {
|
||||
const val SCORE_VERSION = "research-copyability-v1"
|
||||
private val SAMPLE_INSUFFICIENT_CAP = BigDecimal("59")
|
||||
private const val PAPER_MIN_TRADES = 10
|
||||
private const val SOURCE_FRESH_MS = 48L * 60 * 60 * 1000
|
||||
private const val PAPER_MIN_AGE_MS = 7L * 24 * 60 * 60 * 1000
|
||||
}
|
||||
}
|
||||
+115
@@ -0,0 +1,115 @@
|
||||
package com.wrbug.polymarketbot.service.copytrading.research
|
||||
|
||||
import com.wrbug.polymarketbot.dto.LeaderPaperSessionDto
|
||||
import com.wrbug.polymarketbot.dto.LeaderResearchCandidateDetailDto
|
||||
import com.wrbug.polymarketbot.dto.LeaderResearchCandidateDto
|
||||
import com.wrbug.polymarketbot.dto.LeaderResearchCandidateListRequest
|
||||
import com.wrbug.polymarketbot.dto.LeaderResearchCandidateListResponse
|
||||
import com.wrbug.polymarketbot.dto.LeaderResearchEventDto
|
||||
import com.wrbug.polymarketbot.dto.LeaderResearchSourceStateDto
|
||||
import com.wrbug.polymarketbot.dto.LeaderResearchSummaryDto
|
||||
import com.wrbug.polymarketbot.enums.LeaderResearchState
|
||||
import com.wrbug.polymarketbot.repository.LeaderPaperPositionRepository
|
||||
import com.wrbug.polymarketbot.repository.LeaderPaperSessionRepository
|
||||
import com.wrbug.polymarketbot.repository.LeaderPaperTradeRepository
|
||||
import com.wrbug.polymarketbot.repository.LeaderPoolRepository
|
||||
import com.wrbug.polymarketbot.repository.LeaderRepository
|
||||
import com.wrbug.polymarketbot.repository.LeaderResearchCandidateRepository
|
||||
import com.wrbug.polymarketbot.repository.LeaderResearchEventRepository
|
||||
import com.wrbug.polymarketbot.repository.LeaderResearchRunRepository
|
||||
import com.wrbug.polymarketbot.repository.LeaderResearchScoreRepository
|
||||
import com.wrbug.polymarketbot.repository.LeaderResearchSourceStateRepository
|
||||
import org.springframework.data.domain.PageRequest
|
||||
import org.springframework.stereotype.Service
|
||||
|
||||
@Service
|
||||
class LeaderResearchService(
|
||||
private val candidateRepository: LeaderResearchCandidateRepository,
|
||||
private val runRepository: LeaderResearchRunRepository,
|
||||
private val scoreRepository: LeaderResearchScoreRepository,
|
||||
private val sourceStateRepository: LeaderResearchSourceStateRepository,
|
||||
private val eventRepository: LeaderResearchEventRepository,
|
||||
private val paperSessionRepository: LeaderPaperSessionRepository,
|
||||
private val paperTradeRepository: LeaderPaperTradeRepository,
|
||||
private val paperPositionRepository: LeaderPaperPositionRepository,
|
||||
private val leaderRepository: LeaderRepository,
|
||||
private val leaderPoolRepository: LeaderPoolRepository,
|
||||
private val mapper: LeaderResearchMapper
|
||||
) {
|
||||
fun summary(): LeaderResearchSummaryDto {
|
||||
return LeaderResearchSummaryDto(
|
||||
discoveredCount = candidateRepository.countByResearchState(LeaderResearchState.DISCOVERED),
|
||||
candidateCount = candidateRepository.countByResearchState(LeaderResearchState.CANDIDATE),
|
||||
paperCount = candidateRepository.countByResearchState(LeaderResearchState.PAPER),
|
||||
trialReadyCount = candidateRepository.countByResearchState(LeaderResearchState.TRIAL_READY),
|
||||
cooldownCount = candidateRepository.countByResearchState(LeaderResearchState.COOLDOWN),
|
||||
retiredCount = candidateRepository.countByResearchState(LeaderResearchState.RETIRED),
|
||||
activePaperSessions = candidateRepository.findByResearchStateIn(listOf(LeaderResearchState.PAPER, LeaderResearchState.TRIAL_READY)).count().toLong(),
|
||||
pendingRiskCount = candidateRepository.findByResearchStateIn(listOf(LeaderResearchState.COOLDOWN)).count().toLong(),
|
||||
lastRun = runRepository.findTopByOrderByStartedAtDesc()?.let { mapper.runDto(it) },
|
||||
sourceLimitations = mapper.sourceLimitations()
|
||||
)
|
||||
}
|
||||
|
||||
fun listCandidates(request: LeaderResearchCandidateListRequest): LeaderResearchCandidateListResponse {
|
||||
val pageable = PageRequest.of(request.page.coerceAtLeast(0), request.size.coerceIn(1, 100))
|
||||
val state = request.state?.trim()?.takeIf { it.isNotBlank() }?.let { LeaderResearchState.valueOf(it.uppercase()) }
|
||||
val query = request.query?.trim()?.lowercase()?.takeIf { it.isNotBlank() }
|
||||
val page = candidateRepository.search(state, query, pageable)
|
||||
val content = page.content
|
||||
return LeaderResearchCandidateListResponse(
|
||||
list = mapper.candidateDtos(content, listContext(content)),
|
||||
total = page.totalElements,
|
||||
summary = summary()
|
||||
)
|
||||
}
|
||||
|
||||
private fun listContext(candidates: List<com.wrbug.polymarketbot.entity.LeaderResearchCandidate>): LeaderResearchCandidateDtoContext {
|
||||
if (candidates.isEmpty()) return LeaderResearchCandidateDtoContext()
|
||||
val leaderIds = candidates.mapNotNull { it.leaderId }.distinct()
|
||||
val poolIds = candidates.mapNotNull { it.poolId }.distinct()
|
||||
val candidateIds = candidates.mapNotNull { it.id }.distinct()
|
||||
return LeaderResearchCandidateDtoContext(
|
||||
leadersById = if (leaderIds.isEmpty()) emptyMap() else leaderRepository.findByIdIn(leaderIds)
|
||||
.mapNotNull { leader -> leader.id?.let { it to leader } }
|
||||
.toMap(),
|
||||
poolsById = if (poolIds.isEmpty()) emptyMap() else leaderPoolRepository.findByIdIn(poolIds)
|
||||
.mapNotNull { pool -> pool.id?.let { it to pool } }
|
||||
.toMap(),
|
||||
latestSessionsByCandidateId = if (candidateIds.isEmpty()) emptyMap() else paperSessionRepository.findLatestByCandidateIds(candidateIds)
|
||||
.associateBy { it.candidateId }
|
||||
)
|
||||
}
|
||||
|
||||
fun detail(candidateId: Long): LeaderResearchCandidateDetailDto {
|
||||
val candidate = candidateRepository.findById(candidateId).orElseThrow { IllegalArgumentException("候选不存在") }
|
||||
val sessions = paperSessionRepository.findByCandidateIdOrderByStartedAtDesc(candidateId)
|
||||
val latestSession = sessions.firstOrNull()
|
||||
val trades = latestSession?.id?.let {
|
||||
paperTradeRepository.findBySessionIdOrderByEventTimeDesc(it, PageRequest.of(0, 100)).content
|
||||
}.orEmpty()
|
||||
val positions = latestSession?.id?.let { paperPositionRepository.findBySessionIdOrderByUpdatedAtDesc(it) }.orEmpty()
|
||||
return LeaderResearchCandidateDetailDto(
|
||||
candidate = mapper.candidateDto(candidate, latestSession),
|
||||
latestScore = scoreRepository.findTopByCandidateIdOrderByCreatedAtDesc(candidateId)?.let { mapper.scoreDto(it) },
|
||||
paperSessions = sessions.map { mapper.paperSessionDto(it) },
|
||||
paperTrades = trades.map { mapper.paperTradeDto(it) },
|
||||
paperPositions = positions.map { mapper.paperPositionDto(it) },
|
||||
events = eventRepository.findByCandidateIdOrderByCreatedAtDesc(candidateId, PageRequest.of(0, 100)).content.map { mapper.eventDto(it) }
|
||||
)
|
||||
}
|
||||
|
||||
fun sourceHealth(): List<LeaderResearchSourceStateDto> {
|
||||
return sourceStateRepository.findAllByOrderByUpdatedAtDesc().map { mapper.sourceStateDto(it) }
|
||||
}
|
||||
|
||||
fun events(page: Int, size: Int): List<LeaderResearchEventDto> {
|
||||
return eventRepository.findAllByOrderByCreatedAtDesc(PageRequest.of(page.coerceAtLeast(0), size.coerceIn(1, 100)))
|
||||
.content
|
||||
.map { mapper.eventDto(it) }
|
||||
}
|
||||
|
||||
fun paperSessions(candidateId: Long): List<LeaderPaperSessionDto> {
|
||||
return paperSessionRepository.findByCandidateIdOrderByStartedAtDesc(candidateId).map { mapper.paperSessionDto(it) }
|
||||
}
|
||||
}
|
||||
+64
@@ -0,0 +1,64 @@
|
||||
package com.wrbug.polymarketbot.service.copytrading.research
|
||||
|
||||
import com.wrbug.polymarketbot.entity.LeaderResearchSourceState
|
||||
import com.wrbug.polymarketbot.enums.LeaderResearchSourceStatus
|
||||
import com.wrbug.polymarketbot.enums.LeaderResearchSourceType
|
||||
import com.wrbug.polymarketbot.repository.LeaderResearchSourceStateRepository
|
||||
import org.springframework.stereotype.Service
|
||||
import org.springframework.transaction.annotation.Transactional
|
||||
|
||||
@Service
|
||||
class LeaderResearchSourceHealthService(
|
||||
private val sourceStateRepository: LeaderResearchSourceStateRepository
|
||||
) {
|
||||
@Transactional
|
||||
fun record(
|
||||
sourceType: LeaderResearchSourceType,
|
||||
status: LeaderResearchSourceStatus,
|
||||
candidateCount: Int = 0,
|
||||
errorClass: String? = null,
|
||||
errorMessage: String? = null,
|
||||
disabledReason: String? = null,
|
||||
stale: Boolean = false,
|
||||
lastCursor: String? = null,
|
||||
now: Long = System.currentTimeMillis()
|
||||
): LeaderResearchSourceState {
|
||||
val existing = sourceStateRepository.findBySourceType(sourceType)
|
||||
val failedLike = status == LeaderResearchSourceStatus.FAILURE ||
|
||||
status == LeaderResearchSourceStatus.DEGRADED ||
|
||||
status == LeaderResearchSourceStatus.STALE
|
||||
val nextDisabledReason = when {
|
||||
disabledReason != null -> disabledReason
|
||||
status == LeaderResearchSourceStatus.SUCCESS -> null
|
||||
else -> existing?.disabledReason
|
||||
}
|
||||
val state = existing?.copy(
|
||||
status = status,
|
||||
lastSuccessAt = if (status == LeaderResearchSourceStatus.SUCCESS) now else existing.lastSuccessAt,
|
||||
lastFailureAt = if (failedLike) now else existing.lastFailureAt,
|
||||
lastRunAt = now,
|
||||
lastCandidateCount = candidateCount,
|
||||
errorClass = errorClass,
|
||||
errorMessage = errorMessage,
|
||||
stale = stale || status == LeaderResearchSourceStatus.STALE,
|
||||
disabledReason = nextDisabledReason,
|
||||
lastCursor = lastCursor ?: existing.lastCursor,
|
||||
updatedAt = now
|
||||
) ?: LeaderResearchSourceState(
|
||||
sourceType = sourceType,
|
||||
status = status,
|
||||
lastSuccessAt = if (status == LeaderResearchSourceStatus.SUCCESS) now else null,
|
||||
lastFailureAt = if (failedLike) now else null,
|
||||
lastRunAt = now,
|
||||
lastCandidateCount = candidateCount,
|
||||
errorClass = errorClass,
|
||||
errorMessage = errorMessage,
|
||||
stale = stale || status == LeaderResearchSourceStatus.STALE,
|
||||
disabledReason = nextDisabledReason,
|
||||
lastCursor = lastCursor,
|
||||
createdAt = now,
|
||||
updatedAt = now
|
||||
)
|
||||
return sourceStateRepository.save(state)
|
||||
}
|
||||
}
|
||||
+518
@@ -0,0 +1,518 @@
|
||||
package com.wrbug.polymarketbot.service.copytrading.research
|
||||
|
||||
import com.wrbug.polymarketbot.entity.Leader
|
||||
import com.wrbug.polymarketbot.entity.LeaderResearchCandidate
|
||||
import com.wrbug.polymarketbot.enums.LeaderCandidateProvenance
|
||||
import com.wrbug.polymarketbot.enums.LeaderResearchEventType
|
||||
import com.wrbug.polymarketbot.enums.LeaderResearchSourceStatus
|
||||
import com.wrbug.polymarketbot.enums.LeaderResearchSourceType
|
||||
import com.wrbug.polymarketbot.enums.LeaderResearchState
|
||||
import com.wrbug.polymarketbot.repository.LeaderActivityEventRepository
|
||||
import com.wrbug.polymarketbot.repository.LeaderPoolRepository
|
||||
import com.wrbug.polymarketbot.repository.LeaderRepository
|
||||
import com.wrbug.polymarketbot.repository.LeaderResearchCandidateRepository
|
||||
import com.wrbug.polymarketbot.repository.SystemConfigRepository
|
||||
import com.wrbug.polymarketbot.util.RetrofitFactory
|
||||
import kotlinx.coroutines.runBlocking
|
||||
import org.slf4j.LoggerFactory
|
||||
import org.springframework.beans.factory.annotation.Value
|
||||
import org.springframework.stereotype.Service
|
||||
import org.springframework.transaction.annotation.Transactional
|
||||
|
||||
data class LeaderResearchSourceRunResult(
|
||||
val sourceType: LeaderResearchSourceType,
|
||||
val candidates: List<LeaderResearchCandidate>,
|
||||
val status: LeaderResearchSourceStatus,
|
||||
val errorClass: String? = null,
|
||||
val errorMessage: String? = null,
|
||||
val limitation: String? = null,
|
||||
val expectedLimitation: Boolean = false
|
||||
)
|
||||
|
||||
private data class SourceDiscovery(
|
||||
val candidates: List<LeaderResearchCandidate>,
|
||||
val status: LeaderResearchSourceStatus = LeaderResearchSourceStatus.SUCCESS,
|
||||
val errorClass: String? = null,
|
||||
val errorMessage: String? = null,
|
||||
val limitation: String? = null
|
||||
)
|
||||
|
||||
private data class BackfillFailure(
|
||||
val wallet: String,
|
||||
val errorClass: String,
|
||||
val errorMessage: String?
|
||||
)
|
||||
|
||||
private data class BackfillResult(
|
||||
val attemptedWallets: Int,
|
||||
val failures: List<BackfillFailure>
|
||||
) {
|
||||
val hasFailures: Boolean = failures.isNotEmpty()
|
||||
|
||||
fun status(): LeaderResearchSourceStatus =
|
||||
if (hasFailures) LeaderResearchSourceStatus.DEGRADED else LeaderResearchSourceStatus.SUCCESS
|
||||
|
||||
fun errorClass(): String? = failures.firstOrNull()?.errorClass
|
||||
|
||||
fun errorMessage(): String? {
|
||||
if (failures.isEmpty()) return null
|
||||
val sampled = failures.take(3).joinToString("; ") { "${it.wallet}: ${it.errorMessage ?: it.errorClass}" }
|
||||
val suffix = if (failures.size > 3) "; +${failures.size - 3} more" else ""
|
||||
return "Data API backfill failed for ${failures.size}/$attemptedWallets wallets: $sampled$suffix"
|
||||
}
|
||||
}
|
||||
|
||||
@Service
|
||||
class LeaderResearchSourceService(
|
||||
private val candidateRepository: LeaderResearchCandidateRepository,
|
||||
private val leaderRepository: LeaderRepository,
|
||||
private val leaderPoolRepository: LeaderPoolRepository,
|
||||
private val activityEventRepository: LeaderActivityEventRepository,
|
||||
private val sourceHealthService: LeaderResearchSourceHealthService,
|
||||
private val systemConfigRepository: SystemConfigRepository,
|
||||
private val retrofitFactory: RetrofitFactory,
|
||||
private val eventService: LeaderResearchEventService,
|
||||
private val ingestionService: LeaderActivityIngestionService,
|
||||
@Value("\${leader.research.data-api-backfill.limit:200}") private val backfillLimit: Int,
|
||||
@Value("\${leader.research.global-capture.enabled:false}") private val globalCaptureEnabled: Boolean
|
||||
) {
|
||||
private val logger = LoggerFactory.getLogger(LeaderResearchSourceService::class.java)
|
||||
|
||||
@Transactional
|
||||
fun discoverCandidates(runId: Long?): List<LeaderResearchSourceRunResult> {
|
||||
val results = mutableListOf<LeaderResearchSourceRunResult>()
|
||||
results += captureSource(LeaderResearchSourceType.WATCHLIST, runId) { discoverWatchlist(runId) }
|
||||
results += captureSource(LeaderResearchSourceType.EXISTING_LEADER, runId) { discoverExistingLeaders(runId) }
|
||||
val activityResult = captureSource(LeaderResearchSourceType.ACTIVITY_DERIVED, runId) { discoverFromPersistedActivity(runId) }
|
||||
results += if (globalCaptureEnabled) activityResult else markActivityDerivedDegraded(activityResult)
|
||||
if (!globalCaptureEnabled) {
|
||||
results += markGlobalActivityCaptureDisabled(runId)
|
||||
}
|
||||
results += markPublicLeaderboardDisabled(runId)
|
||||
return results
|
||||
}
|
||||
|
||||
fun previewCandidates(): List<LeaderResearchSourceRunResult> {
|
||||
val freshAfter = System.currentTimeMillis() - FRESH_ACTIVITY_WINDOW_MS
|
||||
val watchlist = watchlistWallets().map { transientCandidate(it, LeaderResearchSourceType.WATCHLIST) }
|
||||
val existing = leaderRepository.findAllByOrderByCreatedAtAsc().map {
|
||||
transientCandidate(it.leaderAddress, LeaderResearchSourceType.EXISTING_LEADER, it)
|
||||
}
|
||||
val activity = activityEventRepository.findByUsableForDiscoveryTrueAndEventTimeGreaterThanEqual(freshAfter)
|
||||
.mapNotNull { it.normalizedWallet }
|
||||
.distinct()
|
||||
.mapIndexed { index, wallet -> transientCandidate(wallet, LeaderResearchSourceType.ACTIVITY_DERIVED, sourceRank = index + 1) }
|
||||
val results = mutableListOf(
|
||||
LeaderResearchSourceRunResult(LeaderResearchSourceType.WATCHLIST, watchlist, LeaderResearchSourceStatus.SUCCESS),
|
||||
LeaderResearchSourceRunResult(LeaderResearchSourceType.EXISTING_LEADER, existing, LeaderResearchSourceStatus.SUCCESS),
|
||||
LeaderResearchSourceRunResult(
|
||||
LeaderResearchSourceType.ACTIVITY_DERIVED,
|
||||
activity,
|
||||
if (globalCaptureEnabled) LeaderResearchSourceStatus.SUCCESS else LeaderResearchSourceStatus.DEGRADED,
|
||||
limitation = if (globalCaptureEnabled) null else GLOBAL_CAPTURE_DISABLED_LIMITATION,
|
||||
expectedLimitation = !globalCaptureEnabled
|
||||
)
|
||||
)
|
||||
if (!globalCaptureEnabled) {
|
||||
results += LeaderResearchSourceRunResult(
|
||||
LeaderResearchSourceType.GLOBAL_ACTIVITY_CAPTURE,
|
||||
emptyList(),
|
||||
LeaderResearchSourceStatus.DISABLED,
|
||||
limitation = GLOBAL_CAPTURE_DISABLED_LIMITATION,
|
||||
expectedLimitation = true
|
||||
)
|
||||
}
|
||||
results += LeaderResearchSourceRunResult(
|
||||
LeaderResearchSourceType.PUBLIC_LEADERBOARD,
|
||||
emptyList(),
|
||||
LeaderResearchSourceStatus.DISABLED,
|
||||
limitation = PUBLIC_LEADERBOARD_DISABLED_LIMITATION,
|
||||
expectedLimitation = true
|
||||
)
|
||||
return results
|
||||
}
|
||||
|
||||
fun watchlistWallets(): List<String> {
|
||||
val raw = systemConfigRepository.findByConfigKey(CONFIG_WATCHLIST)?.configValue ?: return emptyList()
|
||||
return raw.split(",", "\n", ";", " ", "\t")
|
||||
.mapNotNull { ingestionService.normalizeWallet(it) }
|
||||
.distinct()
|
||||
}
|
||||
|
||||
private fun captureSource(
|
||||
sourceType: LeaderResearchSourceType,
|
||||
runId: Long?,
|
||||
block: () -> SourceDiscovery
|
||||
): LeaderResearchSourceRunResult {
|
||||
val now = System.currentTimeMillis()
|
||||
return try {
|
||||
val discovery = block()
|
||||
saveSourceState(
|
||||
sourceType = sourceType,
|
||||
status = discovery.status,
|
||||
now = now,
|
||||
candidateCount = discovery.candidates.size,
|
||||
errorClass = discovery.errorClass,
|
||||
errorMessage = discovery.errorMessage
|
||||
)
|
||||
eventService.record(
|
||||
type = if (discovery.status == LeaderResearchSourceStatus.SUCCESS) {
|
||||
LeaderResearchEventType.SOURCE_SUCCESS
|
||||
} else {
|
||||
LeaderResearchEventType.SOURCE_FAILURE
|
||||
},
|
||||
runId = runId,
|
||||
reason = if (discovery.status == LeaderResearchSourceStatus.SUCCESS) {
|
||||
"${sourceType.name} discovered ${discovery.candidates.size} candidates"
|
||||
} else {
|
||||
"${sourceType.name} degraded: ${discovery.errorMessage ?: discovery.limitation ?: discovery.status.name}"
|
||||
},
|
||||
dedupeKey = "source:${sourceType.name}:$runId:${discovery.status.name.lowercase()}"
|
||||
)
|
||||
LeaderResearchSourceRunResult(
|
||||
sourceType = sourceType,
|
||||
candidates = discovery.candidates,
|
||||
status = discovery.status,
|
||||
errorClass = discovery.errorClass,
|
||||
errorMessage = discovery.errorMessage,
|
||||
limitation = discovery.limitation
|
||||
)
|
||||
} catch (e: Exception) {
|
||||
logger.warn("Leader research source failed: source={}, error={}", sourceType, e.message, e)
|
||||
saveSourceState(
|
||||
sourceType = sourceType,
|
||||
status = LeaderResearchSourceStatus.FAILURE,
|
||||
now = now,
|
||||
candidateCount = 0,
|
||||
errorClass = e::class.java.simpleName,
|
||||
errorMessage = e.message
|
||||
)
|
||||
eventService.record(
|
||||
type = LeaderResearchEventType.SOURCE_FAILURE,
|
||||
runId = runId,
|
||||
reason = "${sourceType.name} failed: ${e.message}",
|
||||
dedupeKey = "source:${sourceType.name}:$runId:failure"
|
||||
)
|
||||
LeaderResearchSourceRunResult(sourceType, emptyList(), LeaderResearchSourceStatus.FAILURE, e::class.java.simpleName, e.message)
|
||||
}
|
||||
}
|
||||
|
||||
private fun discoverWatchlist(runId: Long?): SourceDiscovery {
|
||||
val wallets = watchlistWallets()
|
||||
val backfill = backfillWalletActivities(wallets, LeaderResearchSourceType.WATCHLIST, runId)
|
||||
val candidates = wallets.map { wallet ->
|
||||
upsertCandidate(
|
||||
wallet = wallet,
|
||||
sourceType = LeaderResearchSourceType.WATCHLIST,
|
||||
leader = leaderRepository.findByLeaderAddress(wallet),
|
||||
sourceRank = null,
|
||||
provenance = LeaderCandidateProvenance.AGENT_CREATED,
|
||||
sourceEvidence = "system_config:$CONFIG_WATCHLIST",
|
||||
runId = runId
|
||||
)
|
||||
}
|
||||
return SourceDiscovery(
|
||||
candidates = candidates,
|
||||
status = backfill.status(),
|
||||
errorClass = backfill.errorClass(),
|
||||
errorMessage = backfill.errorMessage()
|
||||
)
|
||||
}
|
||||
|
||||
private fun discoverExistingLeaders(runId: Long?): SourceDiscovery {
|
||||
val leaders = leaderRepository.findAllByOrderByCreatedAtAsc()
|
||||
val backfill = backfillWalletActivities(leaders.map { it.leaderAddress }, LeaderResearchSourceType.EXISTING_LEADER, runId)
|
||||
val candidates = leaders.map { leader ->
|
||||
val pool = leader.id?.let { leaderPoolRepository.findByLeaderId(it) }
|
||||
upsertCandidate(
|
||||
wallet = leader.leaderAddress,
|
||||
sourceType = LeaderResearchSourceType.EXISTING_LEADER,
|
||||
leader = leader,
|
||||
poolId = pool?.id,
|
||||
sourceRank = null,
|
||||
provenance = if (pool == null) LeaderCandidateProvenance.USER_LEADER else LeaderCandidateProvenance.USER_POOL,
|
||||
sourceEvidence = "existing_leader:${leader.id}",
|
||||
runId = runId
|
||||
)
|
||||
}
|
||||
return SourceDiscovery(
|
||||
candidates = candidates,
|
||||
status = backfill.status(),
|
||||
errorClass = backfill.errorClass(),
|
||||
errorMessage = backfill.errorMessage()
|
||||
)
|
||||
}
|
||||
|
||||
private fun discoverFromPersistedActivity(runId: Long?): SourceDiscovery {
|
||||
val backfill = backfillWalletActivities(activeResearchWallets(), LeaderResearchSourceType.ACTIVITY_DERIVED, runId)
|
||||
val freshAfter = System.currentTimeMillis() - FRESH_ACTIVITY_WINDOW_MS
|
||||
val events = activityEventRepository.findByUsableForDiscoveryTrueAndEventTimeGreaterThanEqual(freshAfter)
|
||||
val wallets = events.mapNotNull { it.normalizedWallet }.distinct()
|
||||
val candidates = wallets.mapIndexed { index, wallet ->
|
||||
upsertCandidate(
|
||||
wallet = wallet,
|
||||
sourceType = LeaderResearchSourceType.ACTIVITY_DERIVED,
|
||||
leader = leaderRepository.findByLeaderAddress(wallet),
|
||||
sourceRank = index + 1,
|
||||
provenance = LeaderCandidateProvenance.AGENT_CREATED,
|
||||
sourceEvidence = "leader_activity_event:fresh_count=${events.count { it.normalizedWallet == wallet }}",
|
||||
runId = runId
|
||||
)
|
||||
}
|
||||
return SourceDiscovery(
|
||||
candidates = candidates,
|
||||
status = backfill.status(),
|
||||
errorClass = backfill.errorClass(),
|
||||
errorMessage = backfill.errorMessage()
|
||||
)
|
||||
}
|
||||
|
||||
private fun activeResearchWallets(): List<String> {
|
||||
return candidateRepository.findByResearchStateIn(
|
||||
listOf(LeaderResearchState.DISCOVERED, LeaderResearchState.CANDIDATE, LeaderResearchState.PAPER, LeaderResearchState.TRIAL_READY)
|
||||
).map { it.normalizedWallet }.distinct()
|
||||
}
|
||||
|
||||
private fun backfillWalletActivities(wallets: List<String>, sourceType: LeaderResearchSourceType, runId: Long?): BackfillResult {
|
||||
val normalizedWallets = wallets.mapNotNull { ingestionService.normalizeWallet(it) }.distinct()
|
||||
if (normalizedWallets.isEmpty()) return BackfillResult(0, emptyList())
|
||||
val dataApi = retrofitFactory.createDataApi()
|
||||
val startSeconds = (System.currentTimeMillis() - FRESH_ACTIVITY_WINDOW_MS) / 1000
|
||||
val endSeconds = System.currentTimeMillis() / 1000
|
||||
val sampledWallets = normalizedWallets.take(MAX_BACKFILL_WALLETS_PER_RUN)
|
||||
val failures = mutableListOf<BackfillFailure>()
|
||||
sampledWallets.forEach { wallet ->
|
||||
try {
|
||||
val response = runBlocking {
|
||||
dataApi.getUserActivity(
|
||||
user = wallet,
|
||||
type = listOf("TRADE"),
|
||||
start = startSeconds,
|
||||
end = endSeconds,
|
||||
limit = backfillLimit.coerceIn(1, 500),
|
||||
offset = null,
|
||||
sortBy = "TIMESTAMP",
|
||||
sortDirection = "ASC"
|
||||
)
|
||||
}
|
||||
if (!response.isSuccessful || response.body() == null) {
|
||||
throw IllegalStateException("Data API backfill failed: ${response.code()} ${response.message()}")
|
||||
}
|
||||
response.body().orEmpty().forEach { activity ->
|
||||
ingestionService.ingestUserActivity(activity, sourceType)
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
failures += BackfillFailure(wallet, e::class.java.simpleName, e.message)
|
||||
eventService.record(
|
||||
type = LeaderResearchEventType.SOURCE_FAILURE,
|
||||
runId = runId,
|
||||
reason = "Data API backfill failed for $wallet: ${e.message}",
|
||||
payloadSummary = sourceType.name,
|
||||
dedupeKey = "data-api-backfill:${sourceType.name}:$wallet:${System.currentTimeMillis() / 3600000}"
|
||||
)
|
||||
logger.warn("Research Data API backfill failed: source={}, wallet={}, error={}", sourceType, wallet, e.message)
|
||||
}
|
||||
}
|
||||
return BackfillResult(sampledWallets.size, failures)
|
||||
}
|
||||
|
||||
private fun upsertCandidate(
|
||||
wallet: String,
|
||||
sourceType: LeaderResearchSourceType,
|
||||
leader: Leader?,
|
||||
poolId: Long? = null,
|
||||
sourceRank: Int?,
|
||||
provenance: LeaderCandidateProvenance,
|
||||
sourceEvidence: String,
|
||||
runId: Long?
|
||||
): LeaderResearchCandidate {
|
||||
val normalized = ingestionService.normalizeWallet(wallet)
|
||||
?: throw IllegalArgumentException("Invalid wallet for research candidate: $wallet")
|
||||
val now = System.currentTimeMillis()
|
||||
val existing = candidateRepository.findByNormalizedWallet(normalized)
|
||||
val saved = if (existing == null) {
|
||||
candidateRepository.save(
|
||||
LeaderResearchCandidate(
|
||||
normalizedWallet = normalized,
|
||||
leaderId = leader?.id,
|
||||
poolId = poolId,
|
||||
researchState = LeaderResearchState.DISCOVERED,
|
||||
source = sourceType.name,
|
||||
sourceRank = sourceRank,
|
||||
agentOwned = provenance == LeaderCandidateProvenance.AGENT_CREATED,
|
||||
provenance = provenance,
|
||||
sourceEvidence = sourceEvidence,
|
||||
firstSeenAt = now,
|
||||
lastSourceSeenAt = now,
|
||||
lastTransitionAt = now,
|
||||
createdAt = now,
|
||||
updatedAt = now
|
||||
)
|
||||
)
|
||||
} else {
|
||||
val shouldPreserveHuman = existing.locked || existing.provenance == LeaderCandidateProvenance.MANUAL_LOCKED
|
||||
candidateRepository.save(
|
||||
existing.copy(
|
||||
leaderId = existing.leaderId ?: leader?.id,
|
||||
poolId = existing.poolId ?: poolId,
|
||||
source = if (shouldPreserveHuman) existing.source else mergeSource(existing.source, sourceType.name),
|
||||
sourceRank = existing.sourceRank ?: sourceRank,
|
||||
provenance = if (shouldPreserveHuman) existing.provenance else strongestProvenance(existing.provenance, provenance),
|
||||
sourceEvidence = appendEvidence(existing.sourceEvidence, sourceEvidence),
|
||||
lastSourceSeenAt = now,
|
||||
updatedAt = now
|
||||
)
|
||||
)
|
||||
}
|
||||
eventService.record(
|
||||
type = if (existing == null) LeaderResearchEventType.CANDIDATE_DISCOVERED else LeaderResearchEventType.CANDIDATE_UPDATED,
|
||||
candidateId = saved.id,
|
||||
runId = runId,
|
||||
reason = "Candidate seen from ${sourceType.name}",
|
||||
payloadSummary = sourceEvidence,
|
||||
dedupeKey = "candidate:${saved.normalizedWallet}:${sourceType.name}:$runId"
|
||||
)
|
||||
return saved
|
||||
}
|
||||
|
||||
private fun saveSourceState(
|
||||
sourceType: LeaderResearchSourceType,
|
||||
status: LeaderResearchSourceStatus,
|
||||
now: Long,
|
||||
candidateCount: Int,
|
||||
errorClass: String? = null,
|
||||
errorMessage: String? = null,
|
||||
disabledReason: String? = null,
|
||||
stale: Boolean = false
|
||||
) {
|
||||
sourceHealthService.record(
|
||||
sourceType = sourceType,
|
||||
status = status,
|
||||
now = now,
|
||||
candidateCount = candidateCount,
|
||||
errorClass = errorClass,
|
||||
errorMessage = errorMessage,
|
||||
disabledReason = disabledReason,
|
||||
stale = stale
|
||||
)
|
||||
}
|
||||
|
||||
private fun markActivityDerivedDegraded(result: LeaderResearchSourceRunResult): LeaderResearchSourceRunResult {
|
||||
val expectedLimitation = result.status == LeaderResearchSourceStatus.SUCCESS &&
|
||||
result.errorClass == null &&
|
||||
result.errorMessage == null
|
||||
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,
|
||||
expectedLimitation = expectedLimitation
|
||||
)
|
||||
}
|
||||
|
||||
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,
|
||||
expectedLimitation = true
|
||||
)
|
||||
}
|
||||
|
||||
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,
|
||||
expectedLimitation = true
|
||||
)
|
||||
}
|
||||
|
||||
private fun transientCandidate(
|
||||
wallet: String,
|
||||
sourceType: LeaderResearchSourceType,
|
||||
leader: Leader? = leaderRepository.findByLeaderAddress(wallet),
|
||||
sourceRank: Int? = null
|
||||
): LeaderResearchCandidate {
|
||||
val normalized = ingestionService.normalizeWallet(wallet)
|
||||
?: throw IllegalArgumentException("Invalid wallet for research preview candidate: $wallet")
|
||||
return LeaderResearchCandidate(
|
||||
normalizedWallet = normalized,
|
||||
leaderId = leader?.id,
|
||||
source = sourceType.name,
|
||||
sourceRank = sourceRank,
|
||||
provenance = if (leader == null) LeaderCandidateProvenance.AGENT_CREATED else LeaderCandidateProvenance.USER_LEADER,
|
||||
sourceEvidence = "preview:${sourceType.name}",
|
||||
firstSeenAt = System.currentTimeMillis(),
|
||||
lastSourceSeenAt = System.currentTimeMillis()
|
||||
)
|
||||
}
|
||||
|
||||
private fun mergeSource(existing: String, incoming: String): String {
|
||||
val sources = (existing.split(",") + incoming).map { it.trim() }.filter { it.isNotBlank() }.distinct()
|
||||
return sources.joinToString(",")
|
||||
}
|
||||
|
||||
private fun strongestProvenance(current: LeaderCandidateProvenance, incoming: LeaderCandidateProvenance): LeaderCandidateProvenance {
|
||||
val rank = mapOf(
|
||||
LeaderCandidateProvenance.MANUAL_LOCKED to 4,
|
||||
LeaderCandidateProvenance.USER_POOL to 3,
|
||||
LeaderCandidateProvenance.USER_LEADER to 2,
|
||||
LeaderCandidateProvenance.AGENT_CREATED to 1
|
||||
)
|
||||
return if ((rank[incoming] ?: 0) > (rank[current] ?: 0)) incoming else current
|
||||
}
|
||||
|
||||
private fun appendEvidence(existing: String?, incoming: String): String {
|
||||
val lines = (existing?.lines().orEmpty() + incoming).map { it.trim() }.filter { it.isNotBlank() }.distinct()
|
||||
return lines.takeLast(10).joinToString("\n")
|
||||
}
|
||||
|
||||
companion object {
|
||||
const val CONFIG_WATCHLIST = "leader_research.watchlist"
|
||||
const val FRESH_ACTIVITY_WINDOW_MS = 48L * 60 * 60 * 1000
|
||||
const val MAX_BACKFILL_WALLETS_PER_RUN = 50
|
||||
private const val GLOBAL_CAPTURE_DISABLED_LIMITATION =
|
||||
"Global activity capture is disabled; activity-derived discovery only uses already persisted research events."
|
||||
private const val PUBLIC_LEADERBOARD_DISABLED_LIMITATION =
|
||||
"Public leaderboard source is intentionally disabled in v1; discovery uses watchlist, existing leaders, and persisted activity only."
|
||||
}
|
||||
}
|
||||
+154
@@ -0,0 +1,154 @@
|
||||
package com.wrbug.polymarketbot.service.copytrading.research
|
||||
|
||||
import com.wrbug.polymarketbot.entity.LeaderPaperSession
|
||||
import com.wrbug.polymarketbot.entity.LeaderResearchCandidate
|
||||
import com.wrbug.polymarketbot.enums.LeaderResearchEventType
|
||||
import com.wrbug.polymarketbot.enums.LeaderResearchState
|
||||
import com.wrbug.polymarketbot.repository.LeaderPaperSessionRepository
|
||||
import com.wrbug.polymarketbot.repository.LeaderResearchCandidateRepository
|
||||
import org.springframework.stereotype.Service
|
||||
import org.springframework.transaction.annotation.Transactional
|
||||
import java.math.BigDecimal
|
||||
|
||||
@Service
|
||||
class LeaderResearchStateMachine(
|
||||
private val candidateRepository: LeaderResearchCandidateRepository,
|
||||
private val paperSessionRepository: LeaderPaperSessionRepository,
|
||||
private val paperTradingService: LeaderPaperTradingService,
|
||||
private val poolMappingService: LeaderResearchPoolMappingService,
|
||||
private val eventService: LeaderResearchEventService
|
||||
) {
|
||||
@Transactional
|
||||
fun advanceAll(runId: Long?): List<LeaderResearchCandidate> {
|
||||
return candidateRepository.findByResearchStateIn(
|
||||
listOf(
|
||||
LeaderResearchState.DISCOVERED,
|
||||
LeaderResearchState.CANDIDATE,
|
||||
LeaderResearchState.PAPER,
|
||||
LeaderResearchState.TRIAL_READY,
|
||||
LeaderResearchState.COOLDOWN
|
||||
)
|
||||
).map { advance(it, runId) }
|
||||
}
|
||||
|
||||
@Transactional
|
||||
fun advance(candidate: LeaderResearchCandidate, runId: Long?): LeaderResearchCandidate {
|
||||
if (candidate.locked) return candidate
|
||||
val now = System.currentTimeMillis()
|
||||
val latestSession = candidate.id?.let { paperSessionRepository.findTopByCandidateIdOrderByStartedAtDesc(it) }
|
||||
val sourceFresh48h = candidate.lastSourceSeenAt?.let { now - it <= SOURCE_FRESH_48H_MS } == true
|
||||
val sourceFresh72h = candidate.lastSourceSeenAt?.let { now - it <= SOURCE_STALE_72H_MS } == true
|
||||
val score = candidate.score ?: BigDecimal.ZERO
|
||||
|
||||
val nextState = when (candidate.researchState) {
|
||||
LeaderResearchState.DISCOVERED -> {
|
||||
if (sourceFresh48h && (score >= BigDecimal("60") || canBootstrapPaperObservation(candidate))) {
|
||||
LeaderResearchState.CANDIDATE
|
||||
} else {
|
||||
candidate.researchState
|
||||
}
|
||||
}
|
||||
LeaderResearchState.CANDIDATE -> {
|
||||
if (sourceFresh48h && (score >= BigDecimal("60") || latestSession == null && canBootstrapPaperObservation(candidate))) {
|
||||
LeaderResearchState.PAPER
|
||||
} else {
|
||||
candidate.researchState
|
||||
}
|
||||
}
|
||||
LeaderResearchState.PAPER -> {
|
||||
cooldownReason(latestSession, sourceFresh72h)?.let {
|
||||
return transition(candidate, LeaderResearchState.COOLDOWN, runId, it)
|
||||
}
|
||||
if (latestSession != null && paperTradingService.isEligibleForTrialReady(latestSession, now)) {
|
||||
LeaderResearchState.TRIAL_READY
|
||||
} else {
|
||||
candidate.researchState
|
||||
}
|
||||
}
|
||||
LeaderResearchState.TRIAL_READY -> {
|
||||
cooldownReason(latestSession, sourceFresh72h)?.let {
|
||||
return transition(candidate, LeaderResearchState.COOLDOWN, runId, it)
|
||||
}
|
||||
candidate.researchState
|
||||
}
|
||||
LeaderResearchState.COOLDOWN -> {
|
||||
val cooldownElapsed = candidate.cooldownUntil?.let { now >= it } ?: true
|
||||
when {
|
||||
candidate.cooldownCount >= 3 || candidate.lastSourceSeenAt?.let { now - it > SOURCE_RETIRE_30D_MS } == true -> LeaderResearchState.RETIRED
|
||||
cooldownElapsed && sourceFresh48h -> LeaderResearchState.CANDIDATE
|
||||
else -> candidate.researchState
|
||||
}
|
||||
}
|
||||
LeaderResearchState.RETIRED -> candidate.researchState
|
||||
}
|
||||
|
||||
val saved = if (nextState != candidate.researchState) {
|
||||
transition(candidate, nextState, runId, "state criteria satisfied")
|
||||
} else {
|
||||
candidate
|
||||
}
|
||||
val withSession = if (saved.researchState == LeaderResearchState.PAPER && latestSession == null) {
|
||||
val session = paperTradingService.ensureSession(saved, runId)
|
||||
candidateRepository.save(saved.copy(lastPaperSessionId = session.id, updatedAt = now))
|
||||
} else {
|
||||
saved
|
||||
}
|
||||
return if (withSession.researchState.canSyncToLeaderPool()) {
|
||||
poolMappingService.syncCandidate(withSession)
|
||||
} else {
|
||||
withSession
|
||||
}
|
||||
}
|
||||
|
||||
private fun LeaderResearchState.canSyncToLeaderPool(): Boolean {
|
||||
return this != LeaderResearchState.DISCOVERED
|
||||
}
|
||||
|
||||
private fun cooldownReason(session: LeaderPaperSession?, sourceFresh72h: Boolean): String? {
|
||||
if (session == null) return null
|
||||
return paperTradingService.shouldEnterCooldown(session, sourceFresh72h)
|
||||
}
|
||||
|
||||
private fun canBootstrapPaperObservation(candidate: LeaderResearchCandidate): Boolean {
|
||||
return candidate.agentOwned || candidate.leaderId != null || candidate.poolId != null
|
||||
}
|
||||
|
||||
private fun transition(
|
||||
candidate: LeaderResearchCandidate,
|
||||
nextState: LeaderResearchState,
|
||||
runId: Long?,
|
||||
reason: String
|
||||
): LeaderResearchCandidate {
|
||||
val now = System.currentTimeMillis()
|
||||
val updated = candidate.copy(
|
||||
researchState = nextState,
|
||||
cooldownUntil = if (nextState == LeaderResearchState.COOLDOWN) now + COOLDOWN_MS else candidate.cooldownUntil,
|
||||
cooldownCount = if (nextState == LeaderResearchState.COOLDOWN) candidate.cooldownCount + 1 else candidate.cooldownCount,
|
||||
trialReadyAt = if (nextState == LeaderResearchState.TRIAL_READY) now else candidate.trialReadyAt,
|
||||
retiredAt = if (nextState == LeaderResearchState.RETIRED) now else candidate.retiredAt,
|
||||
lastTransitionAt = now,
|
||||
updatedAt = now
|
||||
)
|
||||
val saved = candidateRepository.save(updated)
|
||||
eventService.record(
|
||||
type = when (nextState) {
|
||||
LeaderResearchState.TRIAL_READY -> LeaderResearchEventType.TRIAL_READY
|
||||
LeaderResearchState.COOLDOWN -> LeaderResearchEventType.COOLDOWN
|
||||
LeaderResearchState.RETIRED -> LeaderResearchEventType.RETIRED
|
||||
else -> LeaderResearchEventType.STATE_TRANSITION
|
||||
},
|
||||
candidateId = saved.id,
|
||||
runId = runId,
|
||||
reason = "${candidate.researchState.name} -> ${nextState.name}: $reason",
|
||||
dedupeKey = "state:${candidate.id}:${nextState.name}:${now / 60000}"
|
||||
)
|
||||
return saved
|
||||
}
|
||||
|
||||
companion object {
|
||||
private const val SOURCE_FRESH_48H_MS = 48L * 60 * 60 * 1000
|
||||
private const val SOURCE_STALE_72H_MS = 72L * 60 * 60 * 1000
|
||||
private const val SOURCE_RETIRE_30D_MS = 30L * 24 * 60 * 60 * 1000
|
||||
private const val COOLDOWN_MS = 3L * 24 * 60 * 60 * 1000
|
||||
}
|
||||
}
|
||||
+199
@@ -0,0 +1,199 @@
|
||||
package com.wrbug.polymarketbot.service.copytrading.statistics
|
||||
|
||||
import com.wrbug.polymarketbot.entity.CopyOrderTracking
|
||||
import com.wrbug.polymarketbot.entity.SellMatchDetail
|
||||
import com.wrbug.polymarketbot.entity.SellMatchRecord
|
||||
import com.wrbug.polymarketbot.util.div
|
||||
import com.wrbug.polymarketbot.util.gt
|
||||
import com.wrbug.polymarketbot.util.lte
|
||||
import com.wrbug.polymarketbot.util.multi
|
||||
import com.wrbug.polymarketbot.util.toSafeBigDecimal
|
||||
import java.math.BigDecimal
|
||||
import java.math.RoundingMode
|
||||
|
||||
/**
|
||||
* Pure calculator for copy-trading PnL.
|
||||
*
|
||||
* The statistics API used to expose totalPnl as realized-only PnL and hard-code
|
||||
* unrealized PnL/current position value to zero. That makes active or expired
|
||||
* open positions invisible. This calculator keeps the accounting explicit:
|
||||
*
|
||||
* - currentPositionCost: remaining shares at their tracked buy cost
|
||||
* - currentPositionValue: remaining shares marked by current Polymarket price
|
||||
* - totalUnrealizedPnl: current value - current cost
|
||||
* - totalPnl: realized + unrealized
|
||||
*/
|
||||
object CopyTradingPnlCalculator {
|
||||
fun calculate(
|
||||
buyOrders: List<CopyOrderTracking>,
|
||||
sellRecords: List<SellMatchRecord>,
|
||||
matchDetails: List<SellMatchDetail>,
|
||||
quotes: List<PositionValuationQuote> = emptyList()
|
||||
): CopyTradingPnlStatistics {
|
||||
val totalBuyQuantity = buyOrders.sumOf { it.quantity.toSafeBigDecimal() }
|
||||
val totalBuyAmount = buyOrders.sumOf { it.quantity.toSafeBigDecimal().multi(it.price) }
|
||||
val totalBuyOrders = buyOrders.size.toLong()
|
||||
val avgBuyPrice = if (totalBuyQuantity.gt(BigDecimal.ZERO)) {
|
||||
totalBuyAmount.div(totalBuyQuantity)
|
||||
} else {
|
||||
BigDecimal.ZERO
|
||||
}
|
||||
|
||||
val totalSellQuantity = sellRecords.sumOf { it.totalMatchedQuantity.toSafeBigDecimal() }
|
||||
val totalSellAmount = matchDetails.sumOf { it.matchedQuantity.toSafeBigDecimal().multi(it.sellPrice) }
|
||||
val totalSellOrders = sellRecords.size.toLong()
|
||||
|
||||
val openOrders = buyOrders.filter { it.remainingQuantity.toSafeBigDecimal().gt(BigDecimal.ZERO) }
|
||||
val currentPositionQuantity = openOrders.sumOf { it.remainingQuantity.toSafeBigDecimal() }
|
||||
val currentPositionCost = openOrders.sumOf { it.remainingQuantity.toSafeBigDecimal().multi(it.price) }
|
||||
val hasUnavailableQuotes = quotes.any { it.status == PositionQuoteStatus.UNAVAILABLE }
|
||||
val quotedOpenPositions = openOrders.map { order ->
|
||||
val quote = findQuote(order, quotes)
|
||||
val status = when {
|
||||
quote?.status == PositionQuoteStatus.AVAILABLE -> PositionQuoteStatus.AVAILABLE
|
||||
hasUnavailableQuotes -> PositionQuoteStatus.UNAVAILABLE
|
||||
else -> PositionQuoteStatus.NO_MATCH
|
||||
}
|
||||
QuotedOpenPosition(
|
||||
order = order,
|
||||
status = status,
|
||||
currentPrice = quote?.currentPrice ?: BigDecimal.ZERO
|
||||
)
|
||||
}
|
||||
val currentPositionValue = quotedOpenPositions.sumOf { position ->
|
||||
position.order.remainingQuantity.toSafeBigDecimal().multi(position.currentPrice)
|
||||
}
|
||||
val zeroValuePositionCost = quotedOpenPositions
|
||||
.filter { it.currentPrice.lte(BigDecimal.ZERO) }
|
||||
.sumOf { it.order.remainingQuantity.toSafeBigDecimal().multi(it.order.price) }
|
||||
val confirmedZeroValuePositionCost = quotedOpenPositions
|
||||
.filter { it.status == PositionQuoteStatus.AVAILABLE && it.currentPrice.lte(BigDecimal.ZERO) }
|
||||
.sumOf { it.order.remainingQuantity.toSafeBigDecimal().multi(it.order.price) }
|
||||
val quoteStatusSummary = QuoteStatusSummary.from(quotedOpenPositions.map { it.status })
|
||||
|
||||
val totalRealizedPnl = matchDetails.sumOf { it.realizedPnl.toSafeBigDecimal() }
|
||||
val totalUnrealizedPnl = currentPositionValue.subtract(currentPositionCost)
|
||||
val totalPnl = totalRealizedPnl.add(totalUnrealizedPnl)
|
||||
|
||||
return CopyTradingPnlStatistics(
|
||||
totalBuyQuantity = totalBuyQuantity,
|
||||
totalBuyOrders = totalBuyOrders,
|
||||
totalBuyAmount = totalBuyAmount,
|
||||
avgBuyPrice = avgBuyPrice,
|
||||
totalSellQuantity = totalSellQuantity,
|
||||
totalSellOrders = totalSellOrders,
|
||||
totalSellAmount = totalSellAmount,
|
||||
currentPositionQuantity = currentPositionQuantity,
|
||||
currentPositionCost = currentPositionCost,
|
||||
currentPositionValue = currentPositionValue,
|
||||
zeroValuePositionCost = zeroValuePositionCost,
|
||||
confirmedZeroValuePositionCost = confirmedZeroValuePositionCost,
|
||||
quoteStatusSummary = quoteStatusSummary,
|
||||
totalRealizedPnl = totalRealizedPnl,
|
||||
totalUnrealizedPnl = totalUnrealizedPnl,
|
||||
totalPnl = totalPnl,
|
||||
totalPnlPercent = calculatePnlPercent(totalBuyAmount, totalPnl)
|
||||
)
|
||||
}
|
||||
|
||||
private fun findQuote(
|
||||
order: CopyOrderTracking,
|
||||
quotes: List<PositionValuationQuote>
|
||||
): PositionValuationQuote? {
|
||||
return quotes.firstOrNull { quote ->
|
||||
quote.marketId == order.marketId &&
|
||||
order.outcomeIndex != null &&
|
||||
quote.outcomeIndex == order.outcomeIndex
|
||||
} ?: quotes.firstOrNull { quote ->
|
||||
quote.marketId == order.marketId &&
|
||||
order.outcomeIndex == null &&
|
||||
!quote.side.isNullOrBlank() &&
|
||||
quote.side.equals(order.side, ignoreCase = true)
|
||||
}
|
||||
}
|
||||
|
||||
private fun calculatePnlPercent(totalBuyAmount: BigDecimal, totalPnl: BigDecimal): BigDecimal {
|
||||
if (totalBuyAmount.lte(BigDecimal.ZERO)) return BigDecimal.ZERO.setScale(2)
|
||||
return totalPnl.div(totalBuyAmount).multi(100).setScale(2, RoundingMode.HALF_UP)
|
||||
}
|
||||
}
|
||||
|
||||
data class PositionValuationQuote(
|
||||
val marketId: String,
|
||||
val outcomeIndex: Int?,
|
||||
val side: String?,
|
||||
val currentPrice: BigDecimal,
|
||||
val status: PositionQuoteStatus = PositionQuoteStatus.AVAILABLE,
|
||||
val failureReason: String? = null
|
||||
) {
|
||||
companion object {
|
||||
fun unavailable(reason: String? = null): PositionValuationQuote {
|
||||
return PositionValuationQuote(
|
||||
marketId = "__unavailable__",
|
||||
outcomeIndex = null,
|
||||
side = null,
|
||||
currentPrice = BigDecimal.ZERO,
|
||||
status = PositionQuoteStatus.UNAVAILABLE,
|
||||
failureReason = reason
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
enum class PositionQuoteStatus {
|
||||
AVAILABLE,
|
||||
NO_MATCH,
|
||||
UNAVAILABLE
|
||||
}
|
||||
|
||||
data class QuoteStatusSummary(
|
||||
val overallStatus: PositionQuoteStatus,
|
||||
val availableCount: Int,
|
||||
val noMatchCount: Int,
|
||||
val unavailableCount: Int
|
||||
) {
|
||||
companion object {
|
||||
fun from(statuses: List<PositionQuoteStatus>): QuoteStatusSummary {
|
||||
val unavailableCount = statuses.count { it == PositionQuoteStatus.UNAVAILABLE }
|
||||
val noMatchCount = statuses.count { it == PositionQuoteStatus.NO_MATCH }
|
||||
val availableCount = statuses.count { it == PositionQuoteStatus.AVAILABLE }
|
||||
val overallStatus = when {
|
||||
unavailableCount > 0 -> PositionQuoteStatus.UNAVAILABLE
|
||||
noMatchCount > 0 -> PositionQuoteStatus.NO_MATCH
|
||||
else -> PositionQuoteStatus.AVAILABLE
|
||||
}
|
||||
return QuoteStatusSummary(
|
||||
overallStatus = overallStatus,
|
||||
availableCount = availableCount,
|
||||
noMatchCount = noMatchCount,
|
||||
unavailableCount = unavailableCount
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private data class QuotedOpenPosition(
|
||||
val order: CopyOrderTracking,
|
||||
val status: PositionQuoteStatus,
|
||||
val currentPrice: BigDecimal
|
||||
)
|
||||
|
||||
data class CopyTradingPnlStatistics(
|
||||
val totalBuyQuantity: BigDecimal,
|
||||
val totalBuyOrders: Long,
|
||||
val totalBuyAmount: BigDecimal,
|
||||
val avgBuyPrice: BigDecimal,
|
||||
val totalSellQuantity: BigDecimal,
|
||||
val totalSellOrders: Long,
|
||||
val totalSellAmount: BigDecimal,
|
||||
val currentPositionQuantity: BigDecimal,
|
||||
val currentPositionCost: BigDecimal,
|
||||
val currentPositionValue: BigDecimal,
|
||||
val zeroValuePositionCost: BigDecimal,
|
||||
val confirmedZeroValuePositionCost: BigDecimal,
|
||||
val quoteStatusSummary: QuoteStatusSummary,
|
||||
val totalRealizedPnl: BigDecimal,
|
||||
val totalUnrealizedPnl: BigDecimal,
|
||||
val totalPnl: BigDecimal,
|
||||
val totalPnlPercent: BigDecimal
|
||||
)
|
||||
+155
@@ -0,0 +1,155 @@
|
||||
package com.wrbug.polymarketbot.service.copytrading.statistics
|
||||
|
||||
import com.wrbug.polymarketbot.dto.CopyTradingRiskDiagnosisDto
|
||||
import com.wrbug.polymarketbot.dto.RiskWarningDto
|
||||
import com.wrbug.polymarketbot.dto.TopLosingMarketDto
|
||||
import com.wrbug.polymarketbot.entity.CopyOrderTracking
|
||||
import com.wrbug.polymarketbot.entity.CopyTrading
|
||||
import com.wrbug.polymarketbot.entity.SellMatchDetail
|
||||
import com.wrbug.polymarketbot.util.gt
|
||||
import com.wrbug.polymarketbot.util.lte
|
||||
import java.math.BigDecimal
|
||||
|
||||
object CopyTradingRiskDiagnosisService {
|
||||
private const val MIN_CONFIDENCE_SAMPLE_SIZE = 10
|
||||
|
||||
fun buildDiagnosis(
|
||||
copyTrading: CopyTrading,
|
||||
buyOrders: List<CopyOrderTracking>,
|
||||
sellRecordsCount: Int,
|
||||
matchDetails: List<SellMatchDetail>,
|
||||
filteredOrderCount: Long,
|
||||
pnl: CopyTradingPnlStatistics,
|
||||
generatedAt: Long = System.currentTimeMillis()
|
||||
): CopyTradingRiskDiagnosisDto {
|
||||
val trackingByBuyOrderId = buyOrders.associateBy { it.buyOrderId }
|
||||
val topLosingMarkets = matchDetails
|
||||
.groupBy { detail -> trackingByBuyOrderId[detail.buyOrderId]?.marketId ?: detail.buyOrderId }
|
||||
.map { (marketId, details) ->
|
||||
TopLosingMarketDto(
|
||||
marketId = marketId,
|
||||
realizedPnl = details.sumOf { it.realizedPnl }.toPlainString(),
|
||||
matchedOrders = details.size
|
||||
)
|
||||
}
|
||||
.filter { it.realizedPnl.toBigDecimalOrNull()?.lt(BigDecimal.ZERO) == true }
|
||||
.sortedBy { it.realizedPnl.toBigDecimalOrNull() ?: BigDecimal.ZERO }
|
||||
.take(10)
|
||||
val zeroSellLoss = matchDetails
|
||||
.filter { it.sellPrice.lte(BigDecimal.ZERO) }
|
||||
.sumOf { it.realizedPnl.abs() }
|
||||
val sampleSize = buyOrders.size
|
||||
val lowConfidence = pnl.totalPnl.gt(BigDecimal.ZERO) && sampleSize < MIN_CONFIDENCE_SAMPLE_SIZE
|
||||
val missingSources = buildList {
|
||||
if (pnl.quoteStatusSummary.overallStatus == PositionQuoteStatus.UNAVAILABLE) add("position-quotes")
|
||||
}
|
||||
|
||||
return CopyTradingRiskDiagnosisDto(
|
||||
copyTradingId = copyTrading.id ?: 0,
|
||||
totalRealizedPnl = pnl.totalRealizedPnl.toPlainString(),
|
||||
totalUnrealizedPnl = pnl.totalUnrealizedPnl.toPlainString(),
|
||||
totalPnl = pnl.totalPnl.toPlainString(),
|
||||
currentPositionCost = pnl.currentPositionCost.toPlainString(),
|
||||
currentPositionValue = pnl.currentPositionValue.toPlainString(),
|
||||
zeroValuePositionCost = pnl.zeroValuePositionCost.toPlainString(),
|
||||
confirmedZeroValuePositionCost = pnl.confirmedZeroValuePositionCost.toPlainString(),
|
||||
zeroSellLoss = zeroSellLoss.toPlainString(),
|
||||
openPositionQuantity = pnl.currentPositionQuantity.toPlainString(),
|
||||
totalBuyOrders = buyOrders.size,
|
||||
totalSellRecords = sellRecordsCount,
|
||||
totalMatchDetails = matchDetails.size,
|
||||
filteredOrderCount = filteredOrderCount,
|
||||
sampleSize = sampleSize,
|
||||
lowConfidence = lowConfidence,
|
||||
confidenceReason = if (lowConfidence) {
|
||||
"样本量 ${sampleSize} 笔,低于 ${MIN_CONFIDENCE_SAMPLE_SIZE} 笔,不能视为已验证盈利"
|
||||
} else {
|
||||
"样本量满足第一版诊断阈值"
|
||||
},
|
||||
quoteOverallStatus = pnl.quoteStatusSummary.overallStatus.name,
|
||||
quoteAvailableCount = pnl.quoteStatusSummary.availableCount,
|
||||
quoteNoMatchCount = pnl.quoteStatusSummary.noMatchCount,
|
||||
quoteUnavailableCount = pnl.quoteStatusSummary.unavailableCount,
|
||||
dataIncomplete = missingSources.isNotEmpty(),
|
||||
missingSources = missingSources,
|
||||
topLosingMarkets = topLosingMarkets,
|
||||
riskWarnings = inspectRiskConfig(copyTrading),
|
||||
generatedAt = generatedAt
|
||||
)
|
||||
}
|
||||
|
||||
fun inspectRiskConfig(copyTrading: CopyTrading): List<RiskWarningDto> {
|
||||
return buildList {
|
||||
if (copyTrading.maxDailyOrders > 20) {
|
||||
add(
|
||||
RiskWarningDto(
|
||||
field = "maxDailyOrders",
|
||||
currentValue = copyTrading.maxDailyOrders.toString(),
|
||||
suggestedValue = "20",
|
||||
severity = RiskSeverity.HIGH.name,
|
||||
reason = "每日订单数过高,短周期市场会快速放大亏损"
|
||||
)
|
||||
)
|
||||
}
|
||||
if (copyTrading.maxDailyLoss > BigDecimal.TEN) {
|
||||
add(
|
||||
RiskWarningDto(
|
||||
field = "maxDailyLoss",
|
||||
currentValue = copyTrading.maxDailyLoss.strip(),
|
||||
suggestedValue = "10",
|
||||
severity = RiskSeverity.HIGH.name,
|
||||
reason = "每日最大亏损过高,不能起到止血作用"
|
||||
)
|
||||
)
|
||||
}
|
||||
if (copyTrading.minPrice == null) {
|
||||
addMissingGuard("minPrice", "0.10", "缺少最低价格限制,容易跟到极端赔率订单")
|
||||
}
|
||||
if (copyTrading.maxPrice == null) {
|
||||
addMissingGuard("maxPrice", "0.80", "缺少最高价格限制,容易在高价位承担不对称下行")
|
||||
}
|
||||
if (copyTrading.maxPositionValue == null) {
|
||||
addMissingGuard("maxPositionValue", "10", "缺少单市场仓位上限,同一市场可以堆出过大暴露")
|
||||
}
|
||||
if (copyTrading.minOrderDepth == null) {
|
||||
addMissingGuard("minOrderDepth", "100", "缺少深度过滤,薄盘口订单更容易滑点")
|
||||
}
|
||||
if (copyTrading.maxSpread == null) {
|
||||
addMissingGuard("maxSpread", "0.03", "缺少价差过滤,宽价差市场成交质量更差")
|
||||
}
|
||||
if (copyTrading.priceTolerance > BigDecimal("3")) {
|
||||
add(
|
||||
RiskWarningDto(
|
||||
field = "priceTolerance",
|
||||
currentValue = copyTrading.priceTolerance.strip(),
|
||||
suggestedValue = "3",
|
||||
severity = RiskSeverity.MEDIUM.name,
|
||||
reason = "价格容忍度偏宽,实际成交可能偏离 leader 价格"
|
||||
)
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun MutableList<RiskWarningDto>.addMissingGuard(field: String, suggestedValue: String, reason: String) {
|
||||
add(
|
||||
RiskWarningDto(
|
||||
field = field,
|
||||
currentValue = null,
|
||||
suggestedValue = suggestedValue,
|
||||
severity = RiskSeverity.HIGH.name,
|
||||
reason = reason
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
private fun BigDecimal.strip(): String = stripTrailingZeros().toPlainString()
|
||||
|
||||
private fun BigDecimal.lt(other: BigDecimal): Boolean = compareTo(other) < 0
|
||||
}
|
||||
|
||||
enum class RiskSeverity {
|
||||
LOW,
|
||||
MEDIUM,
|
||||
HIGH
|
||||
}
|
||||
+106
-96
@@ -7,17 +7,16 @@ import com.wrbug.polymarketbot.util.toSafeBigDecimal
|
||||
import com.wrbug.polymarketbot.util.multi
|
||||
import com.wrbug.polymarketbot.util.div
|
||||
import com.wrbug.polymarketbot.util.gt
|
||||
import com.wrbug.polymarketbot.util.eq
|
||||
import com.wrbug.polymarketbot.util.lte
|
||||
import org.slf4j.LoggerFactory
|
||||
import org.springframework.data.domain.PageRequest
|
||||
import org.springframework.data.domain.Pageable
|
||||
import org.springframework.data.domain.Sort
|
||||
import com.wrbug.polymarketbot.service.accounts.AccountService
|
||||
import com.wrbug.polymarketbot.service.common.BlockchainService
|
||||
import org.springframework.stereotype.Service
|
||||
import java.math.BigDecimal
|
||||
import java.math.RoundingMode
|
||||
import java.util.concurrent.ConcurrentHashMap
|
||||
|
||||
/**
|
||||
* 跟单统计服务
|
||||
@@ -31,10 +30,14 @@ class CopyTradingStatisticsService(
|
||||
private val sellMatchDetailRepository: SellMatchDetailRepository,
|
||||
private val accountRepository: AccountRepository,
|
||||
private val leaderRepository: LeaderRepository,
|
||||
private val marketService: com.wrbug.polymarketbot.service.common.MarketService
|
||||
private val filteredOrderRepository: FilteredOrderRepository,
|
||||
private val marketService: com.wrbug.polymarketbot.service.common.MarketService,
|
||||
private val blockchainService: BlockchainService
|
||||
) {
|
||||
|
||||
private val logger = LoggerFactory.getLogger(CopyTradingStatisticsService::class.java)
|
||||
private val quoteCacheTtlMillis = 30_000L
|
||||
private val quoteCache = ConcurrentHashMap<String, CachedPositionQuotes>()
|
||||
|
||||
/**
|
||||
* 获取跟单关系统计
|
||||
@@ -58,15 +61,28 @@ class CopyTradingStatisticsService(
|
||||
// 5. 获取匹配明细
|
||||
val matchDetails = sellMatchDetailRepository.findByCopyTradingId(copyTradingId)
|
||||
|
||||
// 6. 计算统计信息
|
||||
val statistics = calculateStatistics(buyOrders, sellRecords, matchDetails)
|
||||
// 6. 获取当前价格并计算真实口径统计
|
||||
// currentPositionCost 使用跟单系统记录的剩余仓位成本;currentPositionValue 使用
|
||||
// Polymarket Data API 当前价格按剩余份额估值。缺失报价仍按 0 参与旧字段计算,
|
||||
// 但必须通过 quote status 告诉 UI 这是已确认归零、未匹配还是接口不可用。
|
||||
val hasOpenPosition = buyOrders.any { it.remainingQuantity.toSafeBigDecimal().gt(BigDecimal.ZERO) }
|
||||
val quotes = if (hasOpenPosition) {
|
||||
buildPositionValuationQuotes(account?.proxyAddress)
|
||||
} else {
|
||||
emptyList()
|
||||
}
|
||||
val statistics = CopyTradingPnlCalculator.calculate(buyOrders, sellRecords, matchDetails, quotes)
|
||||
val filteredOrderCount = filteredOrderRepository.countByCopyTradingId(copyTradingId)
|
||||
val diagnosis = CopyTradingRiskDiagnosisService.buildDiagnosis(
|
||||
copyTrading = copyTrading,
|
||||
buyOrders = buyOrders,
|
||||
sellRecordsCount = sellRecords.size,
|
||||
matchDetails = matchDetails,
|
||||
filteredOrderCount = filteredOrderCount,
|
||||
pnl = statistics
|
||||
)
|
||||
|
||||
// 7. 不再计算未实现盈亏和持仓价值(优化性能)
|
||||
// 未实现盈亏计算需要查询链上持仓和市场价格,性能开销大
|
||||
val unrealizedPnl = "0"
|
||||
val positionValue = "0"
|
||||
|
||||
// 8. 构建响应(总盈亏 = 已实现盈亏)
|
||||
// 7. 构建响应(总盈亏 = 已实现盈亏 + 未实现盈亏)
|
||||
val response = CopyTradingStatisticsResponse(
|
||||
copyTradingId = copyTradingId,
|
||||
accountId = copyTrading.accountId,
|
||||
@@ -74,19 +90,28 @@ class CopyTradingStatisticsService(
|
||||
leaderId = copyTrading.leaderId,
|
||||
leaderName = leader?.leaderName,
|
||||
enabled = copyTrading.enabled,
|
||||
totalBuyQuantity = statistics.totalBuyQuantity,
|
||||
totalBuyQuantity = statistics.totalBuyQuantity.toString(),
|
||||
totalBuyOrders = statistics.totalBuyOrders,
|
||||
totalBuyAmount = statistics.totalBuyAmount,
|
||||
avgBuyPrice = statistics.avgBuyPrice,
|
||||
totalSellQuantity = statistics.totalSellQuantity,
|
||||
totalBuyAmount = statistics.totalBuyAmount.toString(),
|
||||
avgBuyPrice = statistics.avgBuyPrice.toString(),
|
||||
totalSellQuantity = statistics.totalSellQuantity.toString(),
|
||||
totalSellOrders = statistics.totalSellOrders,
|
||||
totalSellAmount = statistics.totalSellAmount,
|
||||
currentPositionQuantity = statistics.currentPositionQuantity,
|
||||
currentPositionValue = positionValue,
|
||||
totalRealizedPnl = statistics.totalRealizedPnl,
|
||||
totalUnrealizedPnl = unrealizedPnl,
|
||||
totalPnl = statistics.totalRealizedPnl,
|
||||
totalPnlPercent = calculatePnlPercentOnlyRealized(statistics.totalBuyAmount, statistics.totalRealizedPnl)
|
||||
totalSellAmount = statistics.totalSellAmount.toString(),
|
||||
currentPositionQuantity = statistics.currentPositionQuantity.toString(),
|
||||
currentPositionCost = statistics.currentPositionCost.toString(),
|
||||
currentPositionValue = statistics.currentPositionValue.toString(),
|
||||
zeroValuePositionCost = statistics.zeroValuePositionCost.toString(),
|
||||
confirmedZeroValuePositionCost = statistics.confirmedZeroValuePositionCost.toString(),
|
||||
quoteOverallStatus = statistics.quoteStatusSummary.overallStatus.name,
|
||||
quoteAvailableCount = statistics.quoteStatusSummary.availableCount,
|
||||
quoteNoMatchCount = statistics.quoteStatusSummary.noMatchCount,
|
||||
quoteUnavailableCount = statistics.quoteStatusSummary.unavailableCount,
|
||||
quoteIncomplete = statistics.quoteStatusSummary.overallStatus != PositionQuoteStatus.AVAILABLE,
|
||||
riskDiagnosis = diagnosis,
|
||||
totalRealizedPnl = statistics.totalRealizedPnl.toString(),
|
||||
totalUnrealizedPnl = statistics.totalUnrealizedPnl.toString(),
|
||||
totalPnl = statistics.totalPnl.toString(),
|
||||
totalPnlPercent = statistics.totalPnlPercent.toString()
|
||||
)
|
||||
|
||||
Result.success(response)
|
||||
@@ -96,6 +121,65 @@ class CopyTradingStatisticsService(
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取账户当前仓位报价,用于给跟单系统中仍有 remainingQuantity 的订单做市值估算。
|
||||
*
|
||||
* 注意:报价只用于估值,不直接使用 Data API 的 size/currentValue 汇总;这样可以按
|
||||
* copyTradingId 归因,避免同一钱包下多个 Leader 或手工仓位混在一起。
|
||||
*/
|
||||
private suspend fun buildPositionValuationQuotes(proxyAddress: String?): List<PositionValuationQuote> {
|
||||
val normalizedProxyAddress = proxyAddress?.trim()?.lowercase()?.takeIf { it.isNotBlank() }
|
||||
?: return emptyList()
|
||||
|
||||
val now = System.currentTimeMillis()
|
||||
quoteCache[normalizedProxyAddress]
|
||||
?.takeIf { it.expiresAtMillis > now }
|
||||
?.let { return it.quotes }
|
||||
|
||||
return try {
|
||||
val positionsResult = blockchainService.getPositions(normalizedProxyAddress)
|
||||
if (positionsResult.isFailure) {
|
||||
val reason = positionsResult.exceptionOrNull()?.message
|
||||
logger.warn("获取持仓报价失败: proxyAddress=${normalizedProxyAddress.take(10)}..., error=$reason")
|
||||
return listOf(PositionValuationQuote.unavailable(reason = reason))
|
||||
}
|
||||
|
||||
val quotes = positionsResult.getOrNull().orEmpty().mapNotNull { position ->
|
||||
val marketId = position.conditionId?.takeIf { it.isNotBlank() } ?: return@mapNotNull null
|
||||
val currentPrice = position.curPrice?.toSafeBigDecimal()
|
||||
?: derivePriceFromPositionValue(position.currentValue, position.size)
|
||||
?: BigDecimal.ZERO
|
||||
|
||||
PositionValuationQuote(
|
||||
marketId = marketId,
|
||||
outcomeIndex = position.outcomeIndex,
|
||||
side = position.outcome,
|
||||
currentPrice = currentPrice
|
||||
)
|
||||
}
|
||||
quoteCache[normalizedProxyAddress] = CachedPositionQuotes(
|
||||
quotes = quotes,
|
||||
expiresAtMillis = now + quoteCacheTtlMillis
|
||||
)
|
||||
quotes
|
||||
} catch (e: Exception) {
|
||||
logger.warn("获取持仓报价异常: proxyAddress=${normalizedProxyAddress.take(10)}..., error=${e.message}", e)
|
||||
listOf(PositionValuationQuote.unavailable(reason = e.message))
|
||||
}
|
||||
}
|
||||
|
||||
private fun derivePriceFromPositionValue(currentValue: Double?, size: Double?): BigDecimal? {
|
||||
val value = currentValue?.toSafeBigDecimal() ?: return null
|
||||
val quantity = size?.toSafeBigDecimal() ?: return null
|
||||
if (quantity.lte(BigDecimal.ZERO)) return null
|
||||
return value.div(quantity)
|
||||
}
|
||||
|
||||
private data class CachedPositionQuotes(
|
||||
val quotes: List<PositionValuationQuote>,
|
||||
val expiresAtMillis: Long
|
||||
)
|
||||
|
||||
/**
|
||||
* 查询订单列表
|
||||
*/
|
||||
@@ -337,65 +421,6 @@ class CopyTradingStatisticsService(
|
||||
return Pair(list, total)
|
||||
}
|
||||
|
||||
/**
|
||||
* 计算统计信息
|
||||
*/
|
||||
private fun calculateStatistics(
|
||||
buyOrders: List<CopyOrderTracking>,
|
||||
sellRecords: List<SellMatchRecord>,
|
||||
matchDetails: List<SellMatchDetail>
|
||||
): StatisticsData {
|
||||
// 买入统计
|
||||
val totalBuyQuantity = buyOrders.sumOf { it.quantity.toSafeBigDecimal() }
|
||||
val totalBuyAmount = buyOrders.sumOf { it.quantity.toSafeBigDecimal().multi(it.price) }
|
||||
val totalBuyOrders = buyOrders.size.toLong()
|
||||
val avgBuyPrice = if (totalBuyQuantity.gt(BigDecimal.ZERO)) {
|
||||
totalBuyAmount.div(totalBuyQuantity)
|
||||
} else {
|
||||
BigDecimal.ZERO
|
||||
}
|
||||
|
||||
// 卖出统计
|
||||
// 使用 SellMatchDetail 计算总卖出金额,确保准确性
|
||||
// 因为每个明细都记录了准确的匹配数量和卖出价格
|
||||
val totalSellQuantity = sellRecords.sumOf { it.totalMatchedQuantity.toSafeBigDecimal() }
|
||||
val totalSellAmount = matchDetails.sumOf { it.matchedQuantity.toSafeBigDecimal().multi(it.sellPrice) }
|
||||
val totalSellOrders = sellRecords.size.toLong()
|
||||
|
||||
// 持仓统计
|
||||
val currentPositionQuantity = buyOrders.sumOf { it.remainingQuantity.toSafeBigDecimal() }
|
||||
|
||||
// 已实现盈亏
|
||||
val totalRealizedPnl = matchDetails.sumOf { it.realizedPnl.toSafeBigDecimal() }
|
||||
|
||||
return StatisticsData(
|
||||
totalBuyQuantity = totalBuyQuantity.toString(),
|
||||
totalBuyOrders = totalBuyOrders,
|
||||
totalBuyAmount = totalBuyAmount.toString(),
|
||||
avgBuyPrice = avgBuyPrice.toString(),
|
||||
totalSellQuantity = totalSellQuantity.toString(),
|
||||
totalSellOrders = totalSellOrders,
|
||||
totalSellAmount = totalSellAmount.toString(),
|
||||
currentPositionQuantity = currentPositionQuantity.toString(),
|
||||
totalRealizedPnl = totalRealizedPnl.toString()
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* 计算盈亏百分比(仅基于已实现盈亏)
|
||||
*/
|
||||
private fun calculatePnlPercentOnlyRealized(
|
||||
totalBuyAmount: String,
|
||||
totalRealizedPnl: String
|
||||
): String {
|
||||
val buyAmount = totalBuyAmount.toSafeBigDecimal()
|
||||
if (buyAmount.lte(BigDecimal.ZERO)) return "0"
|
||||
|
||||
val percent = totalRealizedPnl.toSafeBigDecimal().div(buyAmount).multi(100)
|
||||
|
||||
return percent.setScale(2, RoundingMode.HALF_UP).toString()
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取全局统计
|
||||
*/
|
||||
@@ -556,21 +581,6 @@ class CopyTradingStatisticsService(
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* 统计数据结构
|
||||
*/
|
||||
private data class StatisticsData(
|
||||
val totalBuyQuantity: String,
|
||||
val totalBuyOrders: Long,
|
||||
val totalBuyAmount: String,
|
||||
val avgBuyPrice: String,
|
||||
val totalSellQuantity: String,
|
||||
val totalSellOrders: Long,
|
||||
val totalSellAmount: String,
|
||||
val currentPositionQuantity: String,
|
||||
val totalRealizedPnl: String
|
||||
)
|
||||
|
||||
/**
|
||||
* 获取按市场分组的买入订单列表
|
||||
*/
|
||||
|
||||
@@ -1,67 +0,0 @@
|
||||
package com.wrbug.polymarketbot.service.system
|
||||
|
||||
import com.google.gson.Gson
|
||||
import com.wrbug.polymarketbot.dto.GeoblockCheckDto
|
||||
import com.wrbug.polymarketbot.dto.PolymarketGeoblockApiResponse
|
||||
import com.wrbug.polymarketbot.util.createClient
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.withContext
|
||||
import okhttp3.OkHttpClient
|
||||
import okhttp3.Request
|
||||
import org.slf4j.LoggerFactory
|
||||
import org.springframework.stereotype.Service
|
||||
import java.util.concurrent.TimeUnit
|
||||
|
||||
@Service
|
||||
class GeoblockService(
|
||||
private val gson: Gson
|
||||
) {
|
||||
|
||||
private val logger = LoggerFactory.getLogger(GeoblockService::class.java)
|
||||
|
||||
companion object {
|
||||
private const val GEOBLOCK_URL = "https://polymarket.com/api/geoblock"
|
||||
}
|
||||
|
||||
suspend fun checkGeoblock(): Result<GeoblockCheckDto> = withContext(Dispatchers.IO) {
|
||||
val client = createClient()
|
||||
.connectTimeout(10, TimeUnit.SECONDS)
|
||||
.readTimeout(10, TimeUnit.SECONDS)
|
||||
.writeTimeout(10, TimeUnit.SECONDS)
|
||||
.build()
|
||||
checkGeoblockWithClient(client)
|
||||
}
|
||||
|
||||
fun checkGeoblockWithClient(client: OkHttpClient): Result<GeoblockCheckDto> {
|
||||
return try {
|
||||
val request = Request.Builder()
|
||||
.url(GEOBLOCK_URL)
|
||||
.get()
|
||||
.header("Accept", "application/json")
|
||||
.build()
|
||||
|
||||
client.newCall(request).execute().use { response ->
|
||||
if (!response.isSuccessful) {
|
||||
return Result.failure(
|
||||
IllegalStateException("HTTP ${response.code}: ${response.message}")
|
||||
)
|
||||
}
|
||||
val body = response.body?.string()
|
||||
?: return Result.failure(IllegalStateException("Empty response body"))
|
||||
val apiResponse = gson.fromJson(body, PolymarketGeoblockApiResponse::class.java)
|
||||
Result.success(
|
||||
GeoblockCheckDto(
|
||||
blocked = apiResponse.blocked,
|
||||
ip = apiResponse.ip,
|
||||
country = apiResponse.country,
|
||||
region = apiResponse.region,
|
||||
checkedAt = System.currentTimeMillis()
|
||||
)
|
||||
)
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
logger.warn("Geoblock 检查失败", e)
|
||||
Result.failure(e)
|
||||
}
|
||||
}
|
||||
}
|
||||
+48
-102
@@ -24,8 +24,7 @@ import java.util.concurrent.TimeUnit
|
||||
*/
|
||||
@Service
|
||||
class ProxyConfigService(
|
||||
private val proxyConfigRepository: ProxyConfigRepository,
|
||||
private val geoblockService: GeoblockService
|
||||
private val proxyConfigRepository: ProxyConfigRepository
|
||||
) : ApplicationContextAware {
|
||||
|
||||
private var applicationContext: ApplicationContext? = null
|
||||
@@ -149,153 +148,100 @@ class ProxyConfigService(
|
||||
}
|
||||
|
||||
/**
|
||||
* 检查代理是否可用,并检测经该代理访问 Polymarket 时的地域限制
|
||||
* 检查代理是否可用
|
||||
* 使用配置的代理请求 Polymarket 健康检查接口
|
||||
*/
|
||||
fun checkProxy(): ProxyCheckResponse {
|
||||
return try {
|
||||
val config = proxyConfigRepository.findByEnabledTrue()
|
||||
if (config == null) {
|
||||
return ProxyCheckResponse.create(
|
||||
?: return ProxyCheckResponse.create(
|
||||
success = false,
|
||||
message = "未配置代理或代理未启用",
|
||||
geoblock = checkGeoblockForProxy(null)
|
||||
message = "未配置代理或代理未启用"
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
if (config.type != "HTTP") {
|
||||
return ProxyCheckResponse.create(
|
||||
success = false,
|
||||
message = "当前仅支持检查 HTTP 代理(订阅代理检查功能待实现)",
|
||||
geoblock = checkGeoblockForProxy(null)
|
||||
message = "当前仅支持检查 HTTP 代理(订阅代理检查功能待实现)"
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
if (config.host == null || config.port == null) {
|
||||
return ProxyCheckResponse.create(
|
||||
success = false,
|
||||
message = "代理配置不完整:缺少主机或端口",
|
||||
geoblock = checkGeoblockForProxy(null)
|
||||
message = "代理配置不完整:缺少主机或端口"
|
||||
)
|
||||
}
|
||||
|
||||
val client = buildProxyHttpClient(config)
|
||||
val geoblock = checkGeoblockForProxy(client)
|
||||
|
||||
|
||||
// 创建代理
|
||||
val proxy = Proxy(Proxy.Type.HTTP, InetSocketAddress(config.host, config.port))
|
||||
|
||||
// 创建 OkHttpClient
|
||||
val clientBuilder = OkHttpClient.Builder()
|
||||
.proxy(proxy)
|
||||
.connectTimeout(10, TimeUnit.SECONDS)
|
||||
.readTimeout(10, TimeUnit.SECONDS)
|
||||
.writeTimeout(10, TimeUnit.SECONDS)
|
||||
|
||||
// 配置 SSL:信任所有证书(用于代理连接)
|
||||
clientBuilder.createSSLSocketFactory()
|
||||
clientBuilder.hostnameVerifier(TrustAllHostnameVerifier())
|
||||
|
||||
// 如果配置了用户名和密码,添加代理认证
|
||||
if (config.username != null && config.password != null) {
|
||||
clientBuilder.proxyAuthenticator { _, response ->
|
||||
val credential = okhttp3.Credentials.basic(config.username, config.password)
|
||||
response.request.newBuilder()
|
||||
.header("Proxy-Authorization", credential)
|
||||
.build()
|
||||
}
|
||||
}
|
||||
|
||||
val client = clientBuilder.build()
|
||||
|
||||
// 请求 Polymarket 健康检查接口
|
||||
val request = Request.Builder()
|
||||
.url("https://data-api.polymarket.com/")
|
||||
.get()
|
||||
.build()
|
||||
|
||||
|
||||
val startTime = System.currentTimeMillis()
|
||||
val response = client.newCall(request).execute()
|
||||
val responseTime = System.currentTimeMillis() - startTime
|
||||
|
||||
|
||||
val responseBody = response.body?.string()
|
||||
|
||||
|
||||
if (response.isSuccessful && responseBody != null) {
|
||||
// 检查响应内容是否为 {"data": "OK"}
|
||||
if (responseBody.contains("\"data\"") && responseBody.contains("OK")) {
|
||||
logger.info("代理检查成功:host=${config.host}, port=${config.port}, responseTime=${responseTime}ms")
|
||||
val message = buildProxyCheckMessage("代理连接成功", geoblock)
|
||||
ProxyCheckResponse.create(
|
||||
success = true,
|
||||
message = message,
|
||||
responseTime = responseTime,
|
||||
geoblock = geoblock
|
||||
message = "代理连接成功",
|
||||
responseTime = responseTime
|
||||
)
|
||||
} else {
|
||||
ProxyCheckResponse.create(
|
||||
success = false,
|
||||
message = buildProxyCheckMessage(
|
||||
"代理连接成功,但响应格式不正确:$responseBody",
|
||||
geoblock
|
||||
),
|
||||
responseTime = responseTime,
|
||||
geoblock = geoblock
|
||||
message = "代理连接成功,但响应格式不正确:$responseBody",
|
||||
responseTime = responseTime
|
||||
)
|
||||
}
|
||||
} else {
|
||||
ProxyCheckResponse.create(
|
||||
success = false,
|
||||
message = buildProxyCheckMessage(
|
||||
"代理连接失败:HTTP ${response.code} ${response.message}",
|
||||
geoblock
|
||||
),
|
||||
responseTime = responseTime,
|
||||
geoblock = geoblock
|
||||
message = "代理连接失败:HTTP ${response.code} ${response.message}",
|
||||
responseTime = responseTime
|
||||
)
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
logger.error("代理检查异常", e)
|
||||
ProxyCheckResponse.create(
|
||||
success = false,
|
||||
message = "代理检查失败:${e.message}",
|
||||
geoblock = checkGeoblockForProxy(null)
|
||||
message = "代理检查失败:${e.message}"
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
private fun buildProxyHttpClient(config: ProxyConfig): OkHttpClient {
|
||||
val host = requireNotNull(config.host) { "代理主机不能为空" }
|
||||
val port = requireNotNull(config.port) { "代理端口不能为空" }
|
||||
val proxy = Proxy(Proxy.Type.HTTP, InetSocketAddress(host, port))
|
||||
val clientBuilder = OkHttpClient.Builder()
|
||||
.proxy(proxy)
|
||||
.connectTimeout(10, TimeUnit.SECONDS)
|
||||
.readTimeout(10, TimeUnit.SECONDS)
|
||||
.writeTimeout(10, TimeUnit.SECONDS)
|
||||
|
||||
clientBuilder.createSSLSocketFactory()
|
||||
clientBuilder.hostnameVerifier(TrustAllHostnameVerifier())
|
||||
|
||||
if (config.username != null && config.password != null) {
|
||||
clientBuilder.proxyAuthenticator { _, response ->
|
||||
val credential = Credentials.basic(config.username, config.password)
|
||||
response.request.newBuilder()
|
||||
.header("Proxy-Authorization", credential)
|
||||
.build()
|
||||
}
|
||||
}
|
||||
|
||||
return clientBuilder.build()
|
||||
}
|
||||
|
||||
private fun checkGeoblockForProxy(client: OkHttpClient?): ProxyCheckGeoblockResult {
|
||||
val httpClient = client ?: com.wrbug.polymarketbot.util.createClient()
|
||||
.connectTimeout(10, TimeUnit.SECONDS)
|
||||
.readTimeout(10, TimeUnit.SECONDS)
|
||||
.writeTimeout(10, TimeUnit.SECONDS)
|
||||
.build()
|
||||
|
||||
return geoblockService.checkGeoblockWithClient(httpClient).fold(
|
||||
onSuccess = { dto ->
|
||||
ProxyCheckGeoblockResult(
|
||||
checked = true,
|
||||
blocked = dto.blocked,
|
||||
ip = dto.ip,
|
||||
country = dto.country,
|
||||
region = dto.region,
|
||||
message = if (dto.blocked) {
|
||||
"当前出口 IP(${dto.country}/${dto.region})在 Polymarket 受限,无法下单"
|
||||
} else {
|
||||
"当前出口 IP(${dto.country}/${dto.region})可向 Polymarket 下单"
|
||||
}
|
||||
)
|
||||
},
|
||||
onFailure = { e ->
|
||||
ProxyCheckGeoblockResult(
|
||||
checked = true,
|
||||
message = "地域限制检测失败:${e.message}"
|
||||
)
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
private fun buildProxyCheckMessage(baseMessage: String, geoblock: ProxyCheckGeoblockResult): String {
|
||||
if (!geoblock.checked || geoblock.message.isNullOrBlank()) {
|
||||
return baseMessage
|
||||
}
|
||||
return "$baseMessage;${geoblock.message}"
|
||||
}
|
||||
|
||||
/**
|
||||
* 删除代理配置
|
||||
|
||||
@@ -54,6 +54,22 @@ copy.trading.polling.enabled=${COPY_TRADING_POLLING_ENABLED:true}
|
||||
# 链上 WebSocket 重连延迟(毫秒),默认3秒
|
||||
copy.trading.onchain.ws.reconnect.delay=${COPY_TRADING_ONCHAIN_WS_RECONNECT_DELAY:3000}
|
||||
|
||||
# Leader Research Agent 配置
|
||||
# 定时研究任务默认关闭;手动运行可在受保护的 Leader 研究页面触发
|
||||
leader.research.enabled=${LEADER_RESEARCH_ENABLED:false}
|
||||
leader.research.fixed-delay-ms=${LEADER_RESEARCH_FIXED_DELAY_MS:900000}
|
||||
# 全局 activity capture 默认关闭;关闭时只使用 watchlist、已有 Leader 和已持久化 research events
|
||||
leader.research.global-capture.enabled=${LEADER_RESEARCH_GLOBAL_CAPTURE_ENABLED:false}
|
||||
leader.research.global-capture.max-writes-per-minute=${LEADER_RESEARCH_GLOBAL_CAPTURE_MAX_WRITES_PER_MINUTE:120}
|
||||
# Data API bounded backfill 单钱包拉取上限
|
||||
leader.research.data-api-backfill.limit=${LEADER_RESEARCH_DATA_API_BACKFILL_LIMIT:200}
|
||||
# Research 数据保留策略,避免 activity/paper 历史无限增长
|
||||
leader.research.retention.enabled=${LEADER_RESEARCH_RETENTION_ENABLED:true}
|
||||
leader.research.retention.activity-days=${LEADER_RESEARCH_RETENTION_ACTIVITY_DAYS:90}
|
||||
leader.research.retention.paper-session-days=${LEADER_RESEARCH_RETENTION_PAPER_SESSION_DAYS:180}
|
||||
leader.research.retention.max-paper-sessions-per-run=${LEADER_RESEARCH_RETENTION_MAX_PAPER_SESSIONS_PER_RUN:100}
|
||||
leader.research.retention.cron=${LEADER_RESEARCH_RETENTION_CRON:0 17 3 * * *}
|
||||
|
||||
# WebSocket 配置
|
||||
websocket.heartbeat-timeout=${WEBSOCKET_HEARTBEAT_TIMEOUT:60000}
|
||||
|
||||
@@ -81,4 +97,3 @@ rate-limit.reset-password.window-seconds=60
|
||||
github.repo.owner=WrBug
|
||||
github.repo.name=PolyHermes
|
||||
github.announcement.issue.number=1
|
||||
|
||||
|
||||
@@ -0,0 +1,29 @@
|
||||
-- ============================================
|
||||
-- V41: 创建 Leader 池表
|
||||
-- ============================================
|
||||
CREATE TABLE IF NOT EXISTS copy_trading_leader_pool (
|
||||
id BIGINT AUTO_INCREMENT PRIMARY KEY COMMENT 'Leader 池项ID',
|
||||
leader_id BIGINT NOT NULL COMMENT 'Leader ID',
|
||||
status VARCHAR(20) NOT NULL DEFAULT 'CANDIDATE' COMMENT '池子状态',
|
||||
source VARCHAR(50) NOT NULL DEFAULT 'MANUAL' COMMENT '来源',
|
||||
source_rank INT DEFAULT NULL COMMENT '来源排名',
|
||||
score DECIMAL(20, 8) DEFAULT NULL COMMENT '来源评分',
|
||||
reason TEXT DEFAULT NULL COMMENT '加入原因',
|
||||
notes TEXT DEFAULT NULL COMMENT '备注',
|
||||
suggested_fixed_amount DECIMAL(20, 8) NOT NULL DEFAULT 1.00000000 COMMENT '建议固定跟单金额',
|
||||
suggested_max_daily_orders INT NOT NULL DEFAULT 10 COMMENT '建议每日最大订单数',
|
||||
suggested_max_daily_loss DECIMAL(20, 8) NOT NULL DEFAULT 5.00000000 COMMENT '建议每日最大亏损',
|
||||
suggested_min_price DECIMAL(20, 8) DEFAULT 0.10000000 COMMENT '建议最低价格',
|
||||
suggested_max_price DECIMAL(20, 8) DEFAULT 0.80000000 COMMENT '建议最高价格',
|
||||
suggested_max_position_value DECIMAL(20, 8) DEFAULT 5.00000000 COMMENT '建议最大持仓价值',
|
||||
last_reviewed_at BIGINT DEFAULT NULL COMMENT '最后复核时间',
|
||||
last_promoted_at BIGINT DEFAULT NULL COMMENT '最后晋升/试跟时间',
|
||||
cooldown_until BIGINT DEFAULT NULL COMMENT '冷却截止时间',
|
||||
locked TINYINT(1) NOT NULL DEFAULT 0 COMMENT '是否锁定,避免后续自动任务修改',
|
||||
created_at BIGINT NOT NULL COMMENT '创建时间',
|
||||
updated_at BIGINT NOT NULL COMMENT '更新时间',
|
||||
UNIQUE KEY uk_leader_pool_leader_id (leader_id),
|
||||
INDEX idx_leader_pool_status (status),
|
||||
INDEX idx_leader_pool_source (source),
|
||||
FOREIGN KEY (leader_id) REFERENCES copy_trading_leaders(id) ON DELETE CASCADE
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='Copy Trading Leader 池';
|
||||
@@ -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);
|
||||
@@ -151,6 +151,18 @@ error.copy_trading_already_exists=This copy trading relationship already exists
|
||||
error.copy_trading_disabled=Copy trading relationship is disabled
|
||||
error.copy_trading_enabled=Copy trading relationship is enabled
|
||||
error.no_enabled_copy_tradings=No enabled copy trading relationships
|
||||
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
|
||||
@@ -247,6 +259,12 @@ error.server.copy_trading_update_failed=Failed to update copy trading
|
||||
error.server.copy_trading_delete_failed=Failed to delete copy trading
|
||||
error.server.copy_trading_list_fetch_failed=Failed to query copy trading list
|
||||
error.server.copy_trading_templates_fetch_failed=Failed to query templates bound to wallet
|
||||
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
|
||||
|
||||
@@ -151,6 +151,18 @@ error.copy_trading_already_exists=该跟单关系已存在
|
||||
error.copy_trading_disabled=跟单关系已禁用
|
||||
error.copy_trading_enabled=跟单关系已启用
|
||||
error.no_enabled_copy_tradings=没有启用的跟单关系
|
||||
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=创建订单失败
|
||||
@@ -247,6 +259,12 @@ error.server.copy_trading_update_failed=更新跟单失败
|
||||
error.server.copy_trading_delete_failed=删除跟单失败
|
||||
error.server.copy_trading_list_fetch_failed=查询跟单列表失败
|
||||
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=获取市场价格失败
|
||||
|
||||
@@ -151,6 +151,18 @@ error.copy_trading_already_exists=該跟單關係已存在
|
||||
error.copy_trading_disabled=跟單關係已禁用
|
||||
error.copy_trading_enabled=跟單關係已啟用
|
||||
error.no_enabled_copy_tradings=沒有啟用的跟單關係
|
||||
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=創建訂單失敗
|
||||
@@ -247,6 +259,12 @@ error.server.copy_trading_update_failed=更新跟單失敗
|
||||
error.server.copy_trading_delete_failed=刪除跟單失敗
|
||||
error.server.copy_trading_list_fetch_failed=查詢跟單列表失敗
|
||||
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=獲取市場價格失敗
|
||||
|
||||
+169
@@ -0,0 +1,169 @@
|
||||
package com.wrbug.polymarketbot.controller.copytrading.configs
|
||||
|
||||
import com.google.gson.Gson
|
||||
import com.wrbug.polymarketbot.dto.ApplyConservativeConfigRequest
|
||||
import com.wrbug.polymarketbot.dto.CopyTradingDto
|
||||
import com.wrbug.polymarketbot.enums.ErrorCode
|
||||
import com.wrbug.polymarketbot.repository.AccountRepository
|
||||
import com.wrbug.polymarketbot.repository.CopyTradingRepository
|
||||
import com.wrbug.polymarketbot.repository.CopyTradingTemplateRepository
|
||||
import com.wrbug.polymarketbot.repository.LeaderRepository
|
||||
import com.wrbug.polymarketbot.service.copytrading.configs.CopyTradingService
|
||||
import com.wrbug.polymarketbot.service.copytrading.configs.FilteredOrderService
|
||||
import com.wrbug.polymarketbot.service.copytrading.monitor.CopyTradingMonitorService
|
||||
import com.wrbug.polymarketbot.util.JsonUtils
|
||||
import org.junit.jupiter.api.Assertions.assertEquals
|
||||
import org.junit.jupiter.api.Test
|
||||
import org.mockito.Mockito
|
||||
import org.springframework.context.support.StaticMessageSource
|
||||
|
||||
class CopyTradingControllerTest {
|
||||
|
||||
@Test
|
||||
fun `apply conservative config returns success response`() {
|
||||
val service = StubCopyTradingService(Result.success(sampleCopyTradingDto()))
|
||||
val controller = controller(service)
|
||||
|
||||
val response = controller.applyConservativeConfig(
|
||||
ApplyConservativeConfigRequest(
|
||||
copyTradingId = 7,
|
||||
confirm = true,
|
||||
maxDailyOrders = 20
|
||||
)
|
||||
)
|
||||
|
||||
assertEquals(0, response.body!!.code)
|
||||
assertEquals(7, response.body!!.data!!.id)
|
||||
assertEquals(1, service.callCount)
|
||||
assertEquals(true, service.lastRequest!!.confirm)
|
||||
assertEquals(20, service.lastRequest!!.maxDailyOrders)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `apply conservative config rejects invalid id before service call`() {
|
||||
val service = StubCopyTradingService(Result.success(sampleCopyTradingDto()))
|
||||
val controller = controller(service)
|
||||
|
||||
val response = controller.applyConservativeConfig(
|
||||
ApplyConservativeConfigRequest(copyTradingId = 0, confirm = true)
|
||||
)
|
||||
|
||||
assertEquals(ErrorCode.PARAM_COPY_TRADING_ID_INVALID.code, response.body!!.code)
|
||||
assertEquals(0, service.callCount)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `apply conservative config maps missing copy trading to not found`() {
|
||||
val service = StubCopyTradingService(Result.failure(IllegalArgumentException("跟单配置不存在")))
|
||||
val controller = controller(service)
|
||||
|
||||
val response = controller.applyConservativeConfig(
|
||||
ApplyConservativeConfigRequest(copyTradingId = 7, confirm = true)
|
||||
)
|
||||
|
||||
assertEquals(ErrorCode.COPY_TRADING_NOT_FOUND.code, response.body!!.code)
|
||||
assertEquals("跟单配置不存在", response.body!!.msg)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `apply conservative config maps missing confirmation to business error`() {
|
||||
val service = StubCopyTradingService(Result.failure(IllegalStateException("应用保守配置需要显式确认")))
|
||||
val controller = controller(service)
|
||||
|
||||
val response = controller.applyConservativeConfig(
|
||||
ApplyConservativeConfigRequest(copyTradingId = 7, confirm = false)
|
||||
)
|
||||
|
||||
assertEquals(ErrorCode.BUSINESS_ERROR.code, response.body!!.code)
|
||||
assertEquals("应用保守配置需要显式确认", response.body!!.msg)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `apply conservative config maps validation failure to parameter error`() {
|
||||
val service = StubCopyTradingService(Result.failure(IllegalArgumentException("maxDailyOrders 必须在 1 到 20 之间")))
|
||||
val controller = controller(service)
|
||||
|
||||
val response = controller.applyConservativeConfig(
|
||||
ApplyConservativeConfigRequest(copyTradingId = 7, confirm = true, maxDailyOrders = 0)
|
||||
)
|
||||
|
||||
assertEquals(ErrorCode.PARAM_ERROR.code, response.body!!.code)
|
||||
assertEquals("maxDailyOrders 必须在 1 到 20 之间", response.body!!.msg)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `apply conservative config maps unexpected service failure to update server error`() {
|
||||
val service = StubCopyTradingService(Result.failure(RuntimeException("外部数据不可用")))
|
||||
val controller = controller(service)
|
||||
|
||||
val response = controller.applyConservativeConfig(
|
||||
ApplyConservativeConfigRequest(copyTradingId = 7, confirm = true)
|
||||
)
|
||||
|
||||
assertEquals(ErrorCode.SERVER_COPY_TRADING_UPDATE_FAILED.code, response.body!!.code)
|
||||
assertEquals("外部数据不可用", response.body!!.msg)
|
||||
}
|
||||
|
||||
private fun controller(copyTradingService: CopyTradingService) = CopyTradingController(
|
||||
copyTradingService = copyTradingService,
|
||||
filteredOrderService = mock(),
|
||||
messageSource = StaticMessageSource()
|
||||
)
|
||||
|
||||
private class StubCopyTradingService(
|
||||
private val nextResult: Result<CopyTradingDto>
|
||||
) : CopyTradingService(
|
||||
copyTradingRepository = mock(),
|
||||
accountRepository = mock(),
|
||||
templateRepository = mock(),
|
||||
leaderRepository = mock(),
|
||||
monitorService = mock(),
|
||||
jsonUtils = mock(),
|
||||
gson = Gson()
|
||||
) {
|
||||
var callCount = 0
|
||||
var lastRequest: ApplyConservativeConfigRequest? = null
|
||||
|
||||
override fun applyConservativeConfig(request: ApplyConservativeConfigRequest): Result<CopyTradingDto> {
|
||||
callCount++
|
||||
lastRequest = request
|
||||
return nextResult
|
||||
}
|
||||
}
|
||||
|
||||
companion object {
|
||||
private inline fun <reified T> mock(): T = Mockito.mock(T::class.java)
|
||||
|
||||
private fun sampleCopyTradingDto() = CopyTradingDto(
|
||||
id = 7,
|
||||
accountId = 1,
|
||||
accountName = "账户 A",
|
||||
walletAddress = "0xaccount",
|
||||
leaderId = 2,
|
||||
leaderName = "Leader A",
|
||||
leaderAddress = "0xleader",
|
||||
enabled = true,
|
||||
copyMode = "FIXED",
|
||||
copyRatio = "1",
|
||||
fixedAmount = "10",
|
||||
maxOrderSize = "10",
|
||||
minOrderSize = "1",
|
||||
maxDailyLoss = "10",
|
||||
maxDailyOrders = 20,
|
||||
priceTolerance = "3",
|
||||
delaySeconds = 0,
|
||||
pollIntervalSeconds = 5,
|
||||
useWebSocket = true,
|
||||
websocketReconnectInterval = 5000,
|
||||
websocketMaxRetries = 10,
|
||||
supportSell = true,
|
||||
minOrderDepth = "100",
|
||||
maxSpread = "0.03",
|
||||
minPrice = "0.10",
|
||||
maxPrice = "0.80",
|
||||
maxPositionValue = "10",
|
||||
createdAt = 1,
|
||||
updatedAt = 2
|
||||
)
|
||||
}
|
||||
}
|
||||
+207
@@ -0,0 +1,207 @@
|
||||
package com.wrbug.polymarketbot.controller.copytrading.leaderpool
|
||||
|
||||
import com.wrbug.polymarketbot.dto.CopyTradingDto
|
||||
import com.wrbug.polymarketbot.dto.LeaderPoolAddRequest
|
||||
import com.wrbug.polymarketbot.dto.LeaderPoolCreateTrialConfigRequest
|
||||
import com.wrbug.polymarketbot.dto.LeaderPoolItemDto
|
||||
import com.wrbug.polymarketbot.dto.LeaderPoolListRequest
|
||||
import com.wrbug.polymarketbot.dto.LeaderPoolListResponse
|
||||
import com.wrbug.polymarketbot.dto.LeaderPoolRemoveRequest
|
||||
import com.wrbug.polymarketbot.dto.LeaderPoolSummaryDto
|
||||
import com.wrbug.polymarketbot.dto.LeaderPoolUpdatePlanRequest
|
||||
import com.wrbug.polymarketbot.dto.LeaderPoolUpdateStatusRequest
|
||||
import com.wrbug.polymarketbot.enums.ErrorCode
|
||||
import com.wrbug.polymarketbot.service.copytrading.leaderpool.LeaderPoolAlreadyExistsException
|
||||
import com.wrbug.polymarketbot.service.copytrading.leaderpool.LeaderPoolConfirmRequiredException
|
||||
import com.wrbug.polymarketbot.service.copytrading.leaderpool.LeaderPoolDuplicateTrialConfigException
|
||||
import com.wrbug.polymarketbot.service.copytrading.leaderpool.LeaderPoolNotFoundException
|
||||
import com.wrbug.polymarketbot.service.copytrading.leaderpool.LeaderPoolService
|
||||
import org.junit.jupiter.api.Assertions.assertEquals
|
||||
import org.junit.jupiter.api.Test
|
||||
import org.mockito.Mockito
|
||||
import org.springframework.context.support.StaticMessageSource
|
||||
|
||||
class LeaderPoolControllerTest {
|
||||
|
||||
@Test
|
||||
fun `list returns pool response`() {
|
||||
val service = StubLeaderPoolService(listResult = Result.success(sampleListResponse()))
|
||||
val controller = controller(service)
|
||||
|
||||
val response = controller.list(LeaderPoolListRequest())
|
||||
|
||||
assertEquals(0, response.body!!.code)
|
||||
assertEquals(1, response.body!!.data!!.total)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `add maps duplicate to leader pool already exists code`() {
|
||||
val service = StubLeaderPoolService(addResult = Result.failure(LeaderPoolAlreadyExistsException()))
|
||||
val controller = controller(service)
|
||||
|
||||
val response = controller.add(LeaderPoolAddRequest(leaderId = 1))
|
||||
|
||||
assertEquals(ErrorCode.LEADER_POOL_ALREADY_EXISTS.code, response.body!!.code)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `update status maps missing pool to not found code`() {
|
||||
val service = StubLeaderPoolService(itemResult = Result.failure(LeaderPoolNotFoundException()))
|
||||
val controller = controller(service)
|
||||
|
||||
val response = controller.updateStatus(LeaderPoolUpdateStatusRequest(poolId = 1, status = "WATCH"))
|
||||
|
||||
assertEquals(ErrorCode.LEADER_POOL_NOT_FOUND.code, response.body!!.code)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `update plan maps validation failure to param error`() {
|
||||
val service = StubLeaderPoolService(itemResult = Result.failure(IllegalArgumentException("suggestedFixedAmount 必须大于 0")))
|
||||
val controller = controller(service)
|
||||
|
||||
val response = controller.updatePlan(LeaderPoolUpdatePlanRequest(poolId = 1, suggestedFixedAmount = "-1"))
|
||||
|
||||
assertEquals(ErrorCode.PARAM_ERROR.code, response.body!!.code)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `create trial maps duplicate config and confirm errors`() {
|
||||
val duplicateController = controller(
|
||||
StubLeaderPoolService(trialResult = Result.failure(LeaderPoolDuplicateTrialConfigException()))
|
||||
)
|
||||
val duplicate = duplicateController.createTrialConfig(LeaderPoolCreateTrialConfigRequest(poolId = 1, accountId = 2))
|
||||
assertEquals(ErrorCode.LEADER_POOL_DUPLICATE_TRIAL_CONFIG.code, duplicate.body!!.code)
|
||||
|
||||
val confirmController = controller(
|
||||
StubLeaderPoolService(trialResult = Result.failure(LeaderPoolConfirmRequiredException()))
|
||||
)
|
||||
val confirm = confirmController.createTrialConfig(
|
||||
LeaderPoolCreateTrialConfigRequest(poolId = 1, accountId = 2, enableImmediately = true, confirm = false)
|
||||
)
|
||||
assertEquals(ErrorCode.LEADER_POOL_CONFIRM_REQUIRED.code, confirm.body!!.code)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `remove returns success response`() {
|
||||
val service = StubLeaderPoolService(removeResult = Result.success(Unit))
|
||||
val controller = controller(service)
|
||||
|
||||
val response = controller.remove(LeaderPoolRemoveRequest(poolId = 1))
|
||||
|
||||
assertEquals(0, response.body!!.code)
|
||||
}
|
||||
|
||||
private fun controller(service: LeaderPoolService) = LeaderPoolController(
|
||||
leaderPoolService = service,
|
||||
messageSource = StaticMessageSource()
|
||||
)
|
||||
|
||||
private class StubLeaderPoolService(
|
||||
private val listResult: Result<LeaderPoolListResponse> = Result.success(sampleListResponse()),
|
||||
private val addResult: Result<LeaderPoolItemDto> = Result.success(sampleItem()),
|
||||
private val itemResult: Result<LeaderPoolItemDto> = Result.success(sampleItem()),
|
||||
private val trialResult: Result<CopyTradingDto> = Result.success(sampleCopyTradingDto()),
|
||||
private val removeResult: Result<Unit> = Result.success(Unit)
|
||||
) : LeaderPoolService(
|
||||
leaderPoolRepository = mock(),
|
||||
leaderRepository = mock(),
|
||||
copyTradingRepository = mock(),
|
||||
accountRepository = mock(),
|
||||
copyTradingService = mock()
|
||||
) {
|
||||
override fun getPoolList(request: LeaderPoolListRequest): Result<LeaderPoolListResponse> = listResult
|
||||
|
||||
override fun addToPool(request: LeaderPoolAddRequest): Result<LeaderPoolItemDto> = addResult
|
||||
|
||||
override fun updateStatus(request: LeaderPoolUpdateStatusRequest): Result<LeaderPoolItemDto> = itemResult
|
||||
|
||||
override fun updatePlan(request: LeaderPoolUpdatePlanRequest): Result<LeaderPoolItemDto> = itemResult
|
||||
|
||||
override fun createTrialConfig(request: LeaderPoolCreateTrialConfigRequest): Result<CopyTradingDto> = trialResult
|
||||
|
||||
override fun remove(request: LeaderPoolRemoveRequest): Result<Unit> = removeResult
|
||||
}
|
||||
|
||||
companion object {
|
||||
private inline fun <reified T> mock(): T = Mockito.mock(T::class.java)
|
||||
|
||||
private fun sampleListResponse() = LeaderPoolListResponse(
|
||||
summary = LeaderPoolSummaryDto(
|
||||
totalCount = 1,
|
||||
trialCount = 0,
|
||||
estimatedWorstExposure = "0",
|
||||
pendingRiskCount = 0
|
||||
),
|
||||
list = listOf(sampleItem()),
|
||||
total = 1
|
||||
)
|
||||
|
||||
private fun sampleItem() = LeaderPoolItemDto(
|
||||
id = 1,
|
||||
leaderId = 2,
|
||||
leaderName = "Leader",
|
||||
leaderAddress = "0xleader",
|
||||
category = null,
|
||||
profileUrl = "https://polymarket.com/profile/0xleader",
|
||||
status = "CANDIDATE",
|
||||
source = "MANUAL",
|
||||
sourceRank = null,
|
||||
score = null,
|
||||
reason = null,
|
||||
notes = null,
|
||||
suggestedFixedAmount = "1",
|
||||
suggestedMaxDailyOrders = 10,
|
||||
suggestedMaxDailyLoss = "5",
|
||||
suggestedMinPrice = "0.1",
|
||||
suggestedMaxPrice = "0.8",
|
||||
suggestedMaxPositionValue = "5",
|
||||
copyTradingCount = 0,
|
||||
hasEnabledCopyTrading = false,
|
||||
estimatedWorstExposure = "0",
|
||||
lastReviewedAt = null,
|
||||
lastPromotedAt = null,
|
||||
cooldownUntil = null,
|
||||
locked = false,
|
||||
researchCandidateId = null,
|
||||
researchState = null,
|
||||
researchBadge = null,
|
||||
researchSummary = null,
|
||||
researchScore = null,
|
||||
researchUpdatedAt = null,
|
||||
createdAt = 1,
|
||||
updatedAt = 1
|
||||
)
|
||||
|
||||
private fun sampleCopyTradingDto() = CopyTradingDto(
|
||||
id = 3,
|
||||
accountId = 2,
|
||||
accountName = "Account",
|
||||
walletAddress = "0xaccount",
|
||||
leaderId = 1,
|
||||
leaderName = "Leader",
|
||||
leaderAddress = "0xleader",
|
||||
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 = 1,
|
||||
updatedAt = 1
|
||||
)
|
||||
}
|
||||
}
|
||||
+126
@@ -0,0 +1,126 @@
|
||||
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.LeaderResearchTriggerType
|
||||
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 `manual run queues async run and returns run dto`() {
|
||||
val run = LeaderResearchRun(id = 1L)
|
||||
Mockito.`when`(jobService.startAsync(false, 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)
|
||||
Mockito.verify(jobService).startAsync(false, LeaderResearchTriggerType.MANUAL)
|
||||
Mockito.verify(jobService, Mockito.never()).runOnce(false, LeaderResearchTriggerType.MANUAL)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `preview run stays synchronous`() {
|
||||
val run = LeaderResearchRun(id = 2L, dryRun = true, triggerType = LeaderResearchTriggerType.PREVIEW)
|
||||
Mockito.`when`(jobService.runOnce(true, LeaderResearchTriggerType.PREVIEW)).thenReturn(run)
|
||||
Mockito.`when`(mapper.runDto(run)).thenReturn(
|
||||
com.wrbug.polymarketbot.dto.LeaderResearchRunDto(
|
||||
id = 2,
|
||||
status = "SUCCESS",
|
||||
triggerType = "PREVIEW",
|
||||
dryRun = true,
|
||||
startedAt = run.startedAt,
|
||||
finishedAt = null,
|
||||
durationMs = null,
|
||||
sourceCountsJson = null,
|
||||
candidateCountsJson = null,
|
||||
partialFailure = false,
|
||||
skippedReason = null,
|
||||
errorClass = null,
|
||||
errorMessage = null
|
||||
)
|
||||
)
|
||||
|
||||
val response = controller.run(LeaderResearchRunRequest(dryRun = true, triggerType = "PREVIEW"))
|
||||
|
||||
assertEquals(0, response.body!!.code)
|
||||
assertEquals(2, response.body!!.data!!.id)
|
||||
Mockito.verify(jobService).runOnce(true, LeaderResearchTriggerType.PREVIEW)
|
||||
Mockito.verify(jobService, Mockito.never()).startAsync(true, LeaderResearchTriggerType.PREVIEW)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `detail rejects invalid candidate id`() {
|
||||
val response = controller.detail(LeaderResearchDetailRequest(candidateId = 0))
|
||||
|
||||
assertEquals(ErrorCode.PARAM_INVALID.code, response.body!!.code)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `approval maps confirm required`() {
|
||||
Mockito.`when`(approvalService.createDisabledTrialConfig(anyApprovalRequest()))
|
||||
.thenReturn(Result.failure(LeaderResearchApprovalConfirmRequiredException()))
|
||||
|
||||
val response = controller.approve(LeaderResearchApprovalRequest(candidateId = 1, accountId = 2, confirm = false))
|
||||
|
||||
assertEquals(ErrorCode.LEADER_RESEARCH_APPROVAL_CONFIRM_REQUIRED.code, response.body!!.code)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `approval maps locked candidate`() {
|
||||
Mockito.`when`(approvalService.createDisabledTrialConfig(anyApprovalRequest()))
|
||||
.thenReturn(Result.failure(LeaderResearchCandidateLockedException()))
|
||||
|
||||
val response = controller.approve(LeaderResearchApprovalRequest(candidateId = 1, accountId = 2, confirm = true))
|
||||
|
||||
assertEquals(ErrorCode.LEADER_RESEARCH_CANDIDATE_LOCKED.code, response.body!!.code)
|
||||
}
|
||||
|
||||
@Suppress("UNCHECKED_CAST")
|
||||
private inline fun <reified T> mock(): T = Mockito.mock(T::class.java)
|
||||
|
||||
private fun anyApprovalRequest(): LeaderResearchApprovalRequest {
|
||||
Mockito.any(LeaderResearchApprovalRequest::class.java)
|
||||
return LeaderResearchApprovalRequest(candidateId = 1, accountId = 2, confirm = true)
|
||||
}
|
||||
}
|
||||
+116
@@ -0,0 +1,116 @@
|
||||
package com.wrbug.polymarketbot.service.copytrading.configs
|
||||
|
||||
import com.wrbug.polymarketbot.dto.ApplyConservativeConfigRequest
|
||||
import com.wrbug.polymarketbot.entity.CopyTrading
|
||||
import org.junit.jupiter.api.Assertions.assertEquals
|
||||
import org.junit.jupiter.api.Assertions.assertThrows
|
||||
import org.junit.jupiter.api.Test
|
||||
import java.math.BigDecimal
|
||||
|
||||
class CopyTradingSafetyConfigServiceTest {
|
||||
|
||||
@Test
|
||||
fun `requires explicit confirmation before applying conservative config`() {
|
||||
val error = assertThrows(IllegalStateException::class.java) {
|
||||
CopyTradingSafetyConfigService.applyConservativeConfig(
|
||||
current = riskyCopyTrading(),
|
||||
request = ApplyConservativeConfigRequest(copyTradingId = 1, confirm = false)
|
||||
)
|
||||
}
|
||||
|
||||
assertEquals("应用保守配置需要显式确认", error.message)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `applies only whitelisted risk fields`() {
|
||||
val updated = CopyTradingSafetyConfigService.applyConservativeConfig(
|
||||
current = riskyCopyTrading(),
|
||||
request = ApplyConservativeConfigRequest(
|
||||
copyTradingId = 1,
|
||||
confirm = true,
|
||||
maxDailyOrders = 20,
|
||||
maxDailyLoss = "10",
|
||||
minPrice = "0.10",
|
||||
maxPrice = "0.80",
|
||||
maxPositionValue = "10",
|
||||
minOrderDepth = "100",
|
||||
maxSpread = "0.03",
|
||||
priceTolerance = "3"
|
||||
)
|
||||
)
|
||||
|
||||
assertEquals(20, updated.maxDailyOrders)
|
||||
assertEquals("10", updated.maxDailyLoss.strip())
|
||||
assertEquals(0, BigDecimal("0.10").compareTo(updated.minPrice))
|
||||
assertEquals(0, BigDecimal("0.80").compareTo(updated.maxPrice))
|
||||
assertEquals("10", updated.maxPositionValue?.strip())
|
||||
assertEquals("100", updated.minOrderDepth?.strip())
|
||||
assertEquals("0.03", updated.maxSpread?.strip())
|
||||
assertEquals("3", updated.priceTolerance.strip())
|
||||
assertEquals(true, updated.enabled)
|
||||
assertEquals(1L, updated.leaderId)
|
||||
assertEquals("FIXED", updated.copyMode)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `rejects unsafe values before saving`() {
|
||||
val error = assertThrows(IllegalArgumentException::class.java) {
|
||||
CopyTradingSafetyConfigService.applyConservativeConfig(
|
||||
current = riskyCopyTrading(),
|
||||
request = ApplyConservativeConfigRequest(
|
||||
copyTradingId = 1,
|
||||
confirm = true,
|
||||
maxDailyOrders = 0
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
assertEquals("maxDailyOrders 必须在 1 到 20 之间", error.message)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `rejects values outside conservative guardrails`() {
|
||||
val error = assertThrows(IllegalArgumentException::class.java) {
|
||||
CopyTradingSafetyConfigService.applyConservativeConfig(
|
||||
current = riskyCopyTrading(),
|
||||
request = ApplyConservativeConfigRequest(
|
||||
copyTradingId = 1,
|
||||
confirm = true,
|
||||
maxDailyLoss = "100"
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
assertEquals("maxDailyLoss 必须大于 0 且不超过 10", error.message)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `rejects invalid decimal strings before saving`() {
|
||||
val error = assertThrows(IllegalArgumentException::class.java) {
|
||||
CopyTradingSafetyConfigService.applyConservativeConfig(
|
||||
current = riskyCopyTrading(),
|
||||
request = ApplyConservativeConfigRequest(
|
||||
copyTradingId = 1,
|
||||
confirm = true,
|
||||
maxSpread = "not-a-number"
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
assertEquals("maxSpread 必须是有效数字", error.message)
|
||||
}
|
||||
|
||||
private fun riskyCopyTrading() = CopyTrading(
|
||||
id = 1,
|
||||
accountId = 1,
|
||||
leaderId = 1,
|
||||
enabled = true,
|
||||
copyMode = "FIXED",
|
||||
fixedAmount = BigDecimal.ONE,
|
||||
maxDailyLoss = BigDecimal("10000"),
|
||||
maxDailyOrders = 100,
|
||||
priceTolerance = BigDecimal("5")
|
||||
)
|
||||
|
||||
private fun BigDecimal.strip(): String = stripTrailingZeros().toPlainString()
|
||||
}
|
||||
+370
@@ -0,0 +1,370 @@
|
||||
package com.wrbug.polymarketbot.service.copytrading.leaderpool
|
||||
|
||||
import com.wrbug.polymarketbot.dto.CopyTradingDto
|
||||
import com.wrbug.polymarketbot.dto.CopyTradingCreateRequest
|
||||
import com.wrbug.polymarketbot.dto.LeaderPoolAddRequest
|
||||
import com.wrbug.polymarketbot.dto.LeaderPoolCreateTrialConfigRequest
|
||||
import com.wrbug.polymarketbot.dto.LeaderPoolListRequest
|
||||
import com.wrbug.polymarketbot.dto.LeaderPoolUpdatePlanRequest
|
||||
import com.wrbug.polymarketbot.dto.LeaderPoolUpdateStatusRequest
|
||||
import com.wrbug.polymarketbot.entity.Account
|
||||
import com.wrbug.polymarketbot.entity.CopyTrading
|
||||
import com.wrbug.polymarketbot.entity.Leader
|
||||
import com.wrbug.polymarketbot.entity.LeaderPool
|
||||
import com.wrbug.polymarketbot.enums.LeaderPoolStatus
|
||||
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.LeaderRepository
|
||||
import com.wrbug.polymarketbot.service.copytrading.configs.CopyTradingService
|
||||
import org.junit.jupiter.api.Assertions.assertEquals
|
||||
import org.junit.jupiter.api.Assertions.assertTrue
|
||||
import org.junit.jupiter.api.Test
|
||||
import org.mockito.ArgumentCaptor
|
||||
import org.mockito.Mockito
|
||||
import org.springframework.dao.DataIntegrityViolationException
|
||||
import java.math.BigDecimal
|
||||
import java.util.Optional
|
||||
|
||||
class LeaderPoolServiceTest {
|
||||
|
||||
private val leaderPoolRepository: LeaderPoolRepository = mock()
|
||||
private val leaderRepository: LeaderRepository = mock()
|
||||
private val copyTradingRepository: CopyTradingRepository = mock()
|
||||
private val accountRepository: AccountRepository = mock()
|
||||
private val copyTradingService: CopyTradingService = mock()
|
||||
private val service = LeaderPoolService(
|
||||
leaderPoolRepository = leaderPoolRepository,
|
||||
leaderRepository = leaderRepository,
|
||||
copyTradingRepository = copyTradingRepository,
|
||||
accountRepository = accountRepository,
|
||||
copyTradingService = copyTradingService
|
||||
)
|
||||
|
||||
@Test
|
||||
fun `adds existing leader to pool as candidate`() {
|
||||
Mockito.`when`(leaderRepository.findById(1L)).thenReturn(Optional.of(leader()))
|
||||
Mockito.`when`(leaderPoolRepository.findByLeaderId(1L)).thenReturn(null)
|
||||
Mockito.`when`(leaderPoolRepository.saveAndFlush(anyLeaderPool())).thenAnswer {
|
||||
(it.arguments[0] as LeaderPool).copy(id = 10)
|
||||
}
|
||||
|
||||
val result = service.addToPool(LeaderPoolAddRequest(leaderId = 1))
|
||||
|
||||
assertTrue(result.isSuccess)
|
||||
assertEquals("CANDIDATE", result.getOrThrow().status)
|
||||
Mockito.verify(leaderPoolRepository).saveAndFlush(anyLeaderPool())
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `duplicate add does not create another pool item`() {
|
||||
Mockito.`when`(leaderRepository.findById(1L)).thenReturn(Optional.of(leader()))
|
||||
Mockito.`when`(leaderPoolRepository.findByLeaderId(1L)).thenReturn(pool())
|
||||
|
||||
val result = service.addToPool(LeaderPoolAddRequest(leaderId = 1))
|
||||
|
||||
assertTrue(result.isFailure)
|
||||
assertTrue(result.exceptionOrNull() is LeaderPoolAlreadyExistsException)
|
||||
Mockito.verify(leaderPoolRepository, Mockito.never()).saveAndFlush(anyLeaderPool())
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `missing leader returns error`() {
|
||||
Mockito.`when`(leaderRepository.findById(404L)).thenReturn(Optional.empty())
|
||||
|
||||
val result = service.addToPool(LeaderPoolAddRequest(leaderId = 404))
|
||||
|
||||
assertTrue(result.isFailure)
|
||||
assertEquals("Leader 不存在", result.exceptionOrNull()?.message)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `unique constraint conflict is mapped to already exists`() {
|
||||
Mockito.`when`(leaderRepository.findById(1L)).thenReturn(Optional.of(leader()))
|
||||
Mockito.`when`(leaderPoolRepository.findByLeaderId(1L)).thenReturn(null)
|
||||
Mockito.`when`(leaderPoolRepository.saveAndFlush(anyLeaderPool()))
|
||||
.thenThrow(DataIntegrityViolationException("duplicate"))
|
||||
|
||||
val result = service.addToPool(LeaderPoolAddRequest(leaderId = 1))
|
||||
|
||||
assertTrue(result.isFailure)
|
||||
assertTrue(result.exceptionOrNull() is LeaderPoolAlreadyExistsException)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `updates status and saves cooldown without deleting leader`() {
|
||||
Mockito.`when`(leaderPoolRepository.findById(10L)).thenReturn(Optional.of(pool()))
|
||||
Mockito.`when`(leaderRepository.findById(1L)).thenReturn(Optional.of(leader()))
|
||||
Mockito.`when`(leaderPoolRepository.save(anyLeaderPool())).thenAnswer { it.arguments[0] }
|
||||
Mockito.`when`(copyTradingRepository.findByLeaderId(1L)).thenReturn(emptyList())
|
||||
|
||||
val result = service.updateStatus(
|
||||
LeaderPoolUpdateStatusRequest(
|
||||
poolId = 10,
|
||||
status = "COOLDOWN",
|
||||
cooldownUntil = 123456L
|
||||
)
|
||||
)
|
||||
|
||||
assertTrue(result.isSuccess)
|
||||
assertEquals("COOLDOWN", result.getOrThrow().status)
|
||||
assertEquals(123456L, result.getOrThrow().cooldownUntil)
|
||||
Mockito.verify(leaderRepository, Mockito.never()).delete(anyLeader())
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `update plan does not modify existing copy trading`() {
|
||||
Mockito.`when`(leaderPoolRepository.findById(10L)).thenReturn(Optional.of(pool()))
|
||||
Mockito.`when`(leaderRepository.findById(1L)).thenReturn(Optional.of(leader()))
|
||||
Mockito.`when`(leaderPoolRepository.save(anyLeaderPool())).thenAnswer { it.arguments[0] }
|
||||
Mockito.`when`(copyTradingRepository.findByLeaderId(1L)).thenReturn(listOf(copyTrading()))
|
||||
|
||||
val result = service.updatePlan(
|
||||
LeaderPoolUpdatePlanRequest(
|
||||
poolId = 10,
|
||||
suggestedFixedAmount = "2",
|
||||
suggestedMaxDailyOrders = 8,
|
||||
suggestedMaxDailyLoss = "4",
|
||||
suggestedMinPrice = "0.2",
|
||||
suggestedMaxPrice = "0.7",
|
||||
suggestedMaxPositionValue = "6"
|
||||
)
|
||||
)
|
||||
|
||||
assertTrue(result.isSuccess)
|
||||
assertEquals("2", result.getOrThrow().suggestedFixedAmount)
|
||||
Mockito.verify(copyTradingRepository, Mockito.never()).save(anyCopyTrading())
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `invalid suggested plan is rejected without saving`() {
|
||||
Mockito.`when`(leaderPoolRepository.findById(10L)).thenReturn(Optional.of(pool()))
|
||||
Mockito.`when`(leaderRepository.findById(1L)).thenReturn(Optional.of(leader()))
|
||||
|
||||
val result = service.updatePlan(
|
||||
LeaderPoolUpdatePlanRequest(
|
||||
poolId = 10,
|
||||
suggestedFixedAmount = "-1"
|
||||
)
|
||||
)
|
||||
|
||||
assertTrue(result.isFailure)
|
||||
assertEquals("suggestedFixedAmount 必须大于 0", result.exceptionOrNull()?.message)
|
||||
Mockito.verify(leaderPoolRepository, Mockito.never()).save(anyLeaderPool())
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `creates disabled conservative trial config and promotes pool after success`() {
|
||||
Mockito.`when`(leaderPoolRepository.findById(10L)).thenReturn(Optional.of(pool()))
|
||||
Mockito.`when`(accountRepository.findById(2L)).thenReturn(Optional.of(account()))
|
||||
Mockito.`when`(leaderRepository.findById(1L)).thenReturn(Optional.of(leader()))
|
||||
Mockito.`when`(copyTradingRepository.findByAccountIdAndLeaderId(2L, 1L)).thenReturn(emptyList())
|
||||
Mockito.`when`(copyTradingService.createCopyTrading(anyCreateRequest())).thenReturn(Result.success(copyTradingDto()))
|
||||
Mockito.`when`(leaderPoolRepository.save(anyLeaderPool())).thenAnswer { it.arguments[0] }
|
||||
|
||||
val result = service.createTrialConfig(LeaderPoolCreateTrialConfigRequest(poolId = 10, accountId = 2))
|
||||
|
||||
assertTrue(result.isSuccess)
|
||||
val requestCaptor = ArgumentCaptor.forClass(com.wrbug.polymarketbot.dto.CopyTradingCreateRequest::class.java)
|
||||
Mockito.verify(copyTradingService).createCopyTrading(captureCreateRequest(requestCaptor))
|
||||
assertEquals(false, requestCaptor.value.enabled)
|
||||
assertEquals("FIXED", requestCaptor.value.copyMode)
|
||||
assertEquals("1", requestCaptor.value.fixedAmount)
|
||||
assertEquals(10, requestCaptor.value.maxDailyOrders)
|
||||
assertEquals("5", requestCaptor.value.maxDailyLoss)
|
||||
assertEquals("0.1", requestCaptor.value.minPrice)
|
||||
assertEquals("0.8", requestCaptor.value.maxPrice)
|
||||
assertEquals("5", requestCaptor.value.maxPositionValue)
|
||||
val poolCaptor = ArgumentCaptor.forClass(LeaderPool::class.java)
|
||||
Mockito.verify(leaderPoolRepository).save(captureLeaderPool(poolCaptor))
|
||||
assertEquals(LeaderPoolStatus.TRIAL, poolCaptor.value.status)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `research pool item must be trial ready before creating trial config`() {
|
||||
Mockito.`when`(leaderPoolRepository.findById(10L)).thenReturn(
|
||||
Optional.of(pool(researchCandidateId = 99, researchState = LeaderResearchState.PAPER))
|
||||
)
|
||||
|
||||
val result = service.createTrialConfig(LeaderPoolCreateTrialConfigRequest(poolId = 10, accountId = 2))
|
||||
|
||||
assertTrue(result.isFailure)
|
||||
assertTrue(result.exceptionOrNull() is LeaderPoolResearchCandidateNotReadyException)
|
||||
Mockito.verify(accountRepository, Mockito.never()).findById(2L)
|
||||
Mockito.verify(copyTradingService, Mockito.never()).createCopyTrading(anyCreateRequest())
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `create trial failure leaves pool status unchanged`() {
|
||||
Mockito.`when`(leaderPoolRepository.findById(10L)).thenReturn(Optional.of(pool()))
|
||||
Mockito.`when`(accountRepository.findById(2L)).thenReturn(Optional.of(account()))
|
||||
Mockito.`when`(leaderRepository.findById(1L)).thenReturn(Optional.of(leader()))
|
||||
Mockito.`when`(copyTradingRepository.findByAccountIdAndLeaderId(2L, 1L)).thenReturn(emptyList())
|
||||
Mockito.`when`(copyTradingService.createCopyTrading(anyCreateRequest()))
|
||||
.thenReturn(Result.failure(RuntimeException("create failed")))
|
||||
|
||||
val result = service.createTrialConfig(LeaderPoolCreateTrialConfigRequest(poolId = 10, accountId = 2))
|
||||
|
||||
assertTrue(result.isFailure)
|
||||
Mockito.verify(leaderPoolRepository, Mockito.never()).save(anyLeaderPool())
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `existing account leader config rejects duplicate trial creation`() {
|
||||
Mockito.`when`(leaderPoolRepository.findById(10L)).thenReturn(Optional.of(pool()))
|
||||
Mockito.`when`(accountRepository.findById(2L)).thenReturn(Optional.of(account()))
|
||||
Mockito.`when`(leaderRepository.findById(1L)).thenReturn(Optional.of(leader()))
|
||||
Mockito.`when`(copyTradingRepository.findByAccountIdAndLeaderId(2L, 1L)).thenReturn(listOf(copyTrading()))
|
||||
|
||||
val result = service.createTrialConfig(LeaderPoolCreateTrialConfigRequest(poolId = 10, accountId = 2))
|
||||
|
||||
assertTrue(result.isFailure)
|
||||
assertTrue(result.exceptionOrNull() is LeaderPoolDuplicateTrialConfigException)
|
||||
Mockito.verify(copyTradingService, Mockito.never()).createCopyTrading(anyCreateRequest())
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `immediate enable without confirmation rejects creation`() {
|
||||
Mockito.`when`(leaderPoolRepository.findById(10L)).thenReturn(Optional.of(pool()))
|
||||
|
||||
val result = service.createTrialConfig(
|
||||
LeaderPoolCreateTrialConfigRequest(
|
||||
poolId = 10,
|
||||
accountId = 2,
|
||||
enableImmediately = true,
|
||||
confirm = false
|
||||
)
|
||||
)
|
||||
|
||||
assertTrue(result.isFailure)
|
||||
assertTrue(result.exceptionOrNull() is LeaderPoolConfirmRequiredException)
|
||||
Mockito.verify(copyTradingService, Mockito.never()).createCopyTrading(anyCreateRequest())
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `pool list uses bulk leader and copy trading queries`() {
|
||||
val pools = listOf(pool(id = 10, leaderId = 1), pool(id = 11, leaderId = 2, status = LeaderPoolStatus.TRIAL))
|
||||
Mockito.`when`(leaderPoolRepository.findAllByOrderByCreatedAtDesc()).thenReturn(pools)
|
||||
Mockito.`when`(leaderRepository.findAllById(listOf(1L, 2L))).thenReturn(listOf(leader(1), leader(2)))
|
||||
Mockito.`when`(copyTradingRepository.findByLeaderIdIn(listOf(1L, 2L))).thenReturn(listOf(copyTrading(leaderId = 2)))
|
||||
|
||||
val result = service.getPoolList(LeaderPoolListRequest())
|
||||
|
||||
assertTrue(result.isSuccess)
|
||||
assertEquals(2, result.getOrThrow().total)
|
||||
assertEquals("5", result.getOrThrow().summary.estimatedWorstExposure)
|
||||
Mockito.verify(leaderRepository).findAllById(listOf(1L, 2L))
|
||||
Mockito.verify(copyTradingRepository).findByLeaderIdIn(listOf(1L, 2L))
|
||||
Mockito.verify(copyTradingRepository, Mockito.never()).findByLeaderId(1L)
|
||||
Mockito.verify(copyTradingRepository, Mockito.never()).findByLeaderId(2L)
|
||||
}
|
||||
|
||||
private fun leader(id: Long = 1) = Leader(
|
||||
id = id,
|
||||
leaderAddress = "0x${id.toString().padStart(40, '0')}",
|
||||
leaderName = "Leader $id"
|
||||
)
|
||||
|
||||
private fun account() = Account(
|
||||
id = 2,
|
||||
privateKey = "encrypted",
|
||||
walletAddress = "0xaccount",
|
||||
proxyAddress = "0xproxy"
|
||||
)
|
||||
|
||||
private fun pool(
|
||||
id: Long = 10,
|
||||
leaderId: Long = 1,
|
||||
status: LeaderPoolStatus = LeaderPoolStatus.CANDIDATE,
|
||||
researchCandidateId: Long? = null,
|
||||
researchState: LeaderResearchState? = null
|
||||
) = LeaderPool(
|
||||
id = id,
|
||||
leaderId = leaderId,
|
||||
status = status,
|
||||
researchCandidateId = researchCandidateId,
|
||||
researchState = researchState,
|
||||
suggestedFixedAmount = BigDecimal("1"),
|
||||
suggestedMaxDailyOrders = 10,
|
||||
suggestedMaxDailyLoss = BigDecimal("5"),
|
||||
suggestedMinPrice = BigDecimal("0.1"),
|
||||
suggestedMaxPrice = BigDecimal("0.8"),
|
||||
suggestedMaxPositionValue = BigDecimal("5")
|
||||
)
|
||||
|
||||
private fun copyTrading(leaderId: Long = 1) = CopyTrading(
|
||||
id = 3,
|
||||
accountId = 2,
|
||||
leaderId = leaderId,
|
||||
enabled = true,
|
||||
copyMode = "FIXED",
|
||||
fixedAmount = BigDecimal.ONE
|
||||
)
|
||||
|
||||
private fun copyTradingDto() = CopyTradingDto(
|
||||
id = 3,
|
||||
accountId = 2,
|
||||
accountName = "Account",
|
||||
walletAddress = "0xaccount",
|
||||
leaderId = 1,
|
||||
leaderName = "Leader 1",
|
||||
leaderAddress = "0x0000000000000000000000000000000000000001",
|
||||
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 = 1,
|
||||
updatedAt = 1
|
||||
)
|
||||
|
||||
private fun anyLeader(): Leader {
|
||||
Mockito.any(Leader::class.java)
|
||||
return leader()
|
||||
}
|
||||
|
||||
private fun anyLeaderPool(): LeaderPool {
|
||||
Mockito.any(LeaderPool::class.java)
|
||||
return pool()
|
||||
}
|
||||
|
||||
private fun anyCopyTrading(): CopyTrading {
|
||||
Mockito.any(CopyTrading::class.java)
|
||||
return copyTrading()
|
||||
}
|
||||
|
||||
private fun anyCreateRequest(): CopyTradingCreateRequest {
|
||||
Mockito.any(CopyTradingCreateRequest::class.java)
|
||||
return CopyTradingCreateRequest(accountId = 2, leaderId = 1)
|
||||
}
|
||||
|
||||
private fun captureCreateRequest(captor: ArgumentCaptor<CopyTradingCreateRequest>): CopyTradingCreateRequest {
|
||||
captor.capture()
|
||||
return CopyTradingCreateRequest(accountId = 2, leaderId = 1)
|
||||
}
|
||||
|
||||
private fun captureLeaderPool(captor: ArgumentCaptor<LeaderPool>): LeaderPool {
|
||||
captor.capture()
|
||||
return pool()
|
||||
}
|
||||
|
||||
companion object {
|
||||
private inline fun <reified T> mock(): T = Mockito.mock(T::class.java)
|
||||
}
|
||||
}
|
||||
+133
@@ -0,0 +1,133 @@
|
||||
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 `disabled global capture health is written once to avoid source state lock churn`() {
|
||||
val service = service(globalCaptureEnabled = false)
|
||||
|
||||
invokeHandleMessage(service, "not-json")
|
||||
invokeHandleMessage(service, "still-not-json")
|
||||
|
||||
assertEquals(1, Mockito.mockingDetails(healthService).invocations.size)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `write cap records degraded source health`() {
|
||||
val service = service(globalCaptureEnabled = true, maxWritesPerMinute = 0)
|
||||
|
||||
invokeHandleMessage(service, activityMessage("tx-capped"))
|
||||
|
||||
val invocation = Mockito.mockingDetails(healthService).invocations.single()
|
||||
assertEquals(LeaderResearchSourceStatus.DEGRADED, invocation.arguments[1])
|
||||
assertEquals("WriteCapReached", invocation.arguments[3])
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `parse failure records failure source health`() {
|
||||
val service = service(globalCaptureEnabled = true)
|
||||
|
||||
invokeHandleMessage(service, "not-json")
|
||||
|
||||
val invocation = Mockito.mockingDetails(healthService).invocations.single()
|
||||
assertEquals(LeaderResearchSourceStatus.FAILURE, invocation.arguments[1])
|
||||
assertEquals("JsonParseFailure", invocation.arguments[3])
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `successful research capture writes success source health cursor before known leader filtering`() {
|
||||
Mockito.`when`(activityEventRepository.findByStableEventKey(Mockito.anyString())).thenReturn(null)
|
||||
Mockito.`when`(activityEventRepository.save(anyActivityEvent())).thenAnswer { it.arguments[0] }
|
||||
val service = service(globalCaptureEnabled = true)
|
||||
|
||||
invokeHandleMessage(service, activityMessage("tx-success"))
|
||||
|
||||
val invocation = Mockito.mockingDetails(healthService).invocations.single()
|
||||
assertEquals(LeaderResearchSourceStatus.SUCCESS, invocation.arguments[1])
|
||||
assertEquals(1, invocation.arguments[2])
|
||||
assertTrue((invocation.arguments[7] as String).contains("tx-success"))
|
||||
}
|
||||
|
||||
private fun service(
|
||||
globalCaptureEnabled: Boolean,
|
||||
maxWritesPerMinute: Long = 120
|
||||
) = PolymarketActivityWsService(
|
||||
copyOrderTrackingService = copyOrderTrackingService,
|
||||
leaderRepository = leaderRepository,
|
||||
researchIngestionProvider = provider(ingestionService),
|
||||
researchSourceHealthProvider = provider(healthService),
|
||||
researchGlobalCaptureEnabled = globalCaptureEnabled,
|
||||
researchGlobalCaptureMaxWritesPerMinute = maxWritesPerMinute
|
||||
)
|
||||
|
||||
private fun invokeHandleMessage(service: PolymarketActivityWsService, message: String) {
|
||||
val method = PolymarketActivityWsService::class.java.getDeclaredMethod("handleMessage", String::class.java)
|
||||
method.isAccessible = true
|
||||
method.invoke(service, message)
|
||||
}
|
||||
|
||||
private fun activityMessage(txHash: String): String {
|
||||
return """
|
||||
{
|
||||
"topic": "activity",
|
||||
"type": "trades",
|
||||
"payload": {
|
||||
"proxyWallet": "0x9999999999999999999999999999999999999999",
|
||||
"conditionId": "market-1",
|
||||
"side": "BUY",
|
||||
"price": "0.42",
|
||||
"size": "2.5",
|
||||
"asset": "asset-1",
|
||||
"transactionHash": "$txHash"
|
||||
}
|
||||
}
|
||||
""".trimIndent()
|
||||
}
|
||||
|
||||
private fun anyActivityEvent(): LeaderActivityEvent {
|
||||
Mockito.any(LeaderActivityEvent::class.java)
|
||||
return LeaderActivityEvent(source = "GLOBAL_ACTIVITY_CAPTURE", stableEventKey = "dummy", eventTime = 1, rawPayloadHash = "hash")
|
||||
}
|
||||
|
||||
@Suppress("UNCHECKED_CAST")
|
||||
private fun <T> provider(value: T): ObjectProvider<T> {
|
||||
val provider = Mockito.mock(ObjectProvider::class.java) as ObjectProvider<T>
|
||||
Mockito.`when`(provider.getIfAvailable()).thenReturn(value)
|
||||
return provider
|
||||
}
|
||||
|
||||
@Suppress("UNCHECKED_CAST")
|
||||
private inline fun <reified T> mock(): T = Mockito.mock(T::class.java)
|
||||
}
|
||||
+161
@@ -0,0 +1,161 @@
|
||||
package com.wrbug.polymarketbot.service.copytrading.research
|
||||
|
||||
import com.google.gson.Gson
|
||||
import com.wrbug.polymarketbot.api.UserActivityResponse
|
||||
import com.wrbug.polymarketbot.dto.ActivityTradeMessage
|
||||
import com.wrbug.polymarketbot.dto.ActivityTradePayload
|
||||
import com.wrbug.polymarketbot.entity.LeaderActivityEvent
|
||||
import com.wrbug.polymarketbot.enums.LeaderResearchSourceType
|
||||
import com.wrbug.polymarketbot.repository.LeaderActivityEventRepository
|
||||
import org.junit.jupiter.api.Assertions.assertEquals
|
||||
import org.junit.jupiter.api.Assertions.assertFalse
|
||||
import org.junit.jupiter.api.Assertions.assertNotNull
|
||||
import org.junit.jupiter.api.Assertions.assertTrue
|
||||
import org.junit.jupiter.api.Test
|
||||
import org.mockito.Mockito
|
||||
import org.springframework.dao.DataIntegrityViolationException
|
||||
|
||||
class LeaderActivityIngestionServiceTest {
|
||||
private val repository: LeaderActivityEventRepository = mock()
|
||||
private val service = LeaderActivityIngestionService(repository, Gson())
|
||||
|
||||
@Test
|
||||
fun `ingests valid activity with fallback key and raw hash`() {
|
||||
Mockito.`when`(repository.findByStableEventKey(Mockito.anyString())).thenReturn(null)
|
||||
Mockito.`when`(repository.save(anyEvent())).thenAnswer { it.arguments[0] }
|
||||
|
||||
val event = service.ingestUserActivity(
|
||||
UserActivityResponse(
|
||||
proxyWallet = "0x1111111111111111111111111111111111111111",
|
||||
timestamp = 1_700_000_000,
|
||||
conditionId = "condition-1",
|
||||
type = "TRADE",
|
||||
size = 10.0,
|
||||
price = 0.45,
|
||||
asset = "asset-1",
|
||||
side = "BUY"
|
||||
)
|
||||
)
|
||||
|
||||
assertTrue(event.usableForDiscovery)
|
||||
assertTrue(event.usableForPaper)
|
||||
assertFalse(event.rawPayloadHash.isBlank())
|
||||
assertEquals(64, event.rawPayloadHash.length)
|
||||
assertNotNull(event.stableEventKey)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `records unusable reason for incomplete activity`() {
|
||||
Mockito.`when`(repository.findByStableEventKey(Mockito.anyString())).thenReturn(null)
|
||||
Mockito.`when`(repository.save(anyEvent())).thenAnswer { it.arguments[0] }
|
||||
|
||||
val event = service.ingestUserActivity(
|
||||
UserActivityResponse(
|
||||
proxyWallet = "not-a-wallet",
|
||||
timestamp = 1_700_000_000,
|
||||
conditionId = "",
|
||||
type = "TRADE"
|
||||
)
|
||||
)
|
||||
|
||||
assertFalse(event.usableForDiscovery)
|
||||
assertFalse(event.usableForPaper)
|
||||
assertTrue(event.unusableReason!!.contains("wallet_missing_or_invalid"))
|
||||
assertTrue(event.unusableReason!!.contains("market_missing"))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `dedupes by source event id`() {
|
||||
val existing = LeaderActivityEvent(
|
||||
source = "ACTIVITY_DERIVED",
|
||||
sourceEventId = "tx-1",
|
||||
stableEventKey = "tx-1",
|
||||
normalizedWallet = "0x1111111111111111111111111111111111111111",
|
||||
eventTime = 1_700_000_000_000,
|
||||
rawPayloadHash = "hash"
|
||||
)
|
||||
Mockito.`when`(repository.findByStableEventKey("tx-1")).thenReturn(null)
|
||||
Mockito.`when`(repository.findBySourceAndSourceEventId("ACTIVITY_DERIVED", "tx-1")).thenReturn(existing)
|
||||
|
||||
val event = service.ingestUserActivity(
|
||||
UserActivityResponse(
|
||||
proxyWallet = "0x1111111111111111111111111111111111111111",
|
||||
timestamp = 1_700_000_000,
|
||||
conditionId = "condition-1",
|
||||
type = "TRADE",
|
||||
size = 10.0,
|
||||
transactionHash = "tx-1",
|
||||
price = 0.45,
|
||||
asset = "asset-1",
|
||||
side = "BUY"
|
||||
)
|
||||
)
|
||||
|
||||
assertEquals(existing, event)
|
||||
Mockito.verify(repository, Mockito.never()).save(Mockito.any(LeaderActivityEvent::class.java))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `dedupes after database uniqueness violation`() {
|
||||
val existing = LeaderActivityEvent(
|
||||
source = "ACTIVITY_DERIVED",
|
||||
stableEventKey = "stable-1",
|
||||
eventTime = 1_700_000_000_000,
|
||||
rawPayloadHash = "hash"
|
||||
)
|
||||
Mockito.`when`(repository.findByStableEventKey(Mockito.anyString())).thenReturn(null, existing)
|
||||
Mockito.`when`(repository.save(Mockito.any(LeaderActivityEvent::class.java))).thenThrow(DataIntegrityViolationException("duplicate"))
|
||||
|
||||
val event = service.ingestUserActivity(validActivity(transactionHash = null))
|
||||
|
||||
assertEquals(existing, event)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `ingests websocket trade before known leader filtering`() {
|
||||
Mockito.`when`(repository.findByStableEventKey(Mockito.anyString())).thenReturn(null)
|
||||
Mockito.`when`(repository.save(anyEvent())).thenAnswer { it.arguments[0] }
|
||||
|
||||
val event = service.ingestWebSocketTrade(
|
||||
ActivityTradeMessage(
|
||||
topic = "activity",
|
||||
type = "trades",
|
||||
payload = ActivityTradePayload(
|
||||
proxyWallet = "0x9999999999999999999999999999999999999999",
|
||||
conditionId = "condition-unknown-leader",
|
||||
side = "BUY",
|
||||
price = "0.42",
|
||||
size = "2.5",
|
||||
asset = "asset-unknown",
|
||||
transactionHash = "ws-tx-1"
|
||||
)
|
||||
),
|
||||
LeaderResearchSourceType.GLOBAL_ACTIVITY_CAPTURE
|
||||
)
|
||||
|
||||
assertEquals("0x9999999999999999999999999999999999999999", event.normalizedWallet)
|
||||
assertEquals("GLOBAL_ACTIVITY_CAPTURE", event.source)
|
||||
assertTrue(event.usableForDiscovery)
|
||||
assertTrue(event.usableForPaper)
|
||||
}
|
||||
|
||||
private fun validActivity(transactionHash: String?) = UserActivityResponse(
|
||||
proxyWallet = "0x1111111111111111111111111111111111111111",
|
||||
timestamp = 1_700_000_000,
|
||||
conditionId = "condition-1",
|
||||
type = "TRADE",
|
||||
size = 10.0,
|
||||
transactionHash = transactionHash,
|
||||
price = 0.45,
|
||||
asset = "asset-1",
|
||||
side = "BUY"
|
||||
)
|
||||
|
||||
private fun anyEvent(): LeaderActivityEvent {
|
||||
Mockito.any(LeaderActivityEvent::class.java)
|
||||
return LeaderActivityEvent(source = "ACTIVITY_DERIVED", stableEventKey = "dummy", eventTime = 1, rawPayloadHash = "hash")
|
||||
}
|
||||
|
||||
@Suppress("UNCHECKED_CAST")
|
||||
private inline fun <reified T> mock(): T = Mockito.mock(T::class.java)
|
||||
}
|
||||
+376
@@ -0,0 +1,376 @@
|
||||
package com.wrbug.polymarketbot.service.copytrading.research
|
||||
|
||||
import com.wrbug.polymarketbot.entity.LeaderActivityEvent
|
||||
import com.wrbug.polymarketbot.entity.LeaderPaperPosition
|
||||
import com.wrbug.polymarketbot.entity.LeaderPaperSession
|
||||
import com.wrbug.polymarketbot.entity.LeaderPaperTrade
|
||||
import com.wrbug.polymarketbot.entity.LeaderResearchCandidate
|
||||
import com.wrbug.polymarketbot.enums.LeaderPaperFilterResult
|
||||
import com.wrbug.polymarketbot.enums.LeaderPaperProcessingStatus
|
||||
import com.wrbug.polymarketbot.enums.LeaderPaperSessionStatus
|
||||
import com.wrbug.polymarketbot.enums.LeaderResearchState
|
||||
import com.wrbug.polymarketbot.enums.LeaderResearchValuationStatus
|
||||
import com.wrbug.polymarketbot.repository.LeaderActivityEventRepository
|
||||
import com.wrbug.polymarketbot.repository.LeaderPaperPositionRepository
|
||||
import com.wrbug.polymarketbot.repository.LeaderPaperSessionRepository
|
||||
import com.wrbug.polymarketbot.repository.LeaderPaperTradeRepository
|
||||
import com.wrbug.polymarketbot.repository.LeaderResearchCandidateRepository
|
||||
import com.wrbug.polymarketbot.service.common.MarketPriceService
|
||||
import kotlinx.coroutines.runBlocking
|
||||
import org.junit.jupiter.api.Assertions.assertEquals
|
||||
import org.junit.jupiter.api.Assertions.assertFalse
|
||||
import org.junit.jupiter.api.Assertions.assertTrue
|
||||
import org.junit.jupiter.api.Test
|
||||
import org.mockito.Mockito
|
||||
import org.springframework.data.domain.PageImpl
|
||||
import org.springframework.data.domain.PageRequest
|
||||
import java.math.BigDecimal
|
||||
|
||||
class LeaderPaperTradingServiceTest {
|
||||
private val candidateRepository: LeaderResearchCandidateRepository = mock()
|
||||
private val activityEventRepository: LeaderActivityEventRepository = mock()
|
||||
private val paperSessionRepository: LeaderPaperSessionRepository = mock()
|
||||
private val paperTradeRepository: LeaderPaperTradeRepository = mock()
|
||||
private val paperPositionRepository: LeaderPaperPositionRepository = mock()
|
||||
private val marketPriceService: MarketPriceService = mock()
|
||||
private val eventService: LeaderResearchEventService = mock()
|
||||
private val service = LeaderPaperTradingService(
|
||||
candidateRepository = candidateRepository,
|
||||
activityEventRepository = activityEventRepository,
|
||||
paperSessionRepository = paperSessionRepository,
|
||||
paperTradeRepository = paperTradeRepository,
|
||||
paperPositionRepository = paperPositionRepository,
|
||||
marketPriceService = marketPriceService,
|
||||
eventService = eventService
|
||||
)
|
||||
|
||||
@Test
|
||||
fun `trial ready requires enough age trades positive pnl and bounded unknown exposure`() {
|
||||
val session = LeaderPaperSession(
|
||||
id = 1L,
|
||||
candidateId = 1L,
|
||||
startedAt = System.currentTimeMillis() - 8L * 24 * 60 * 60 * 1000,
|
||||
tradeCount = 10,
|
||||
filteredCount = 1,
|
||||
openExposure = BigDecimal("10"),
|
||||
copyablePnl = BigDecimal("1"),
|
||||
maxDrawdown = BigDecimal("-5"),
|
||||
unknownValuationExposure = BigDecimal("1"),
|
||||
filteredRatio = BigDecimal("0.09")
|
||||
)
|
||||
|
||||
assertTrue(service.isEligibleForTrialReady(session))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `trial ready rejects confirmed stale unknown quote exposure`() {
|
||||
val session = LeaderPaperSession(
|
||||
id = 1L,
|
||||
candidateId = 1L,
|
||||
startedAt = System.currentTimeMillis() - 8L * 24 * 60 * 60 * 1000,
|
||||
tradeCount = 10,
|
||||
openExposure = BigDecimal("10"),
|
||||
copyablePnl = BigDecimal("1"),
|
||||
maxDrawdown = BigDecimal("-5"),
|
||||
unknownValuationExposure = BigDecimal("3"),
|
||||
filteredRatio = BigDecimal("0.09")
|
||||
)
|
||||
|
||||
assertFalse(service.isEligibleForTrialReady(session))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `process paper candidates records buy sell pnl and confirmed zero exposure separately`() {
|
||||
val candidate = paperCandidate()
|
||||
val session = LeaderPaperSession(id = 10L, candidateId = candidate.id!!)
|
||||
val events = listOf(
|
||||
paperEvent(id = 100L, stableKey = "buy-1", side = "BUY", price = "0.50", size = "10"),
|
||||
paperEvent(id = 101L, stableKey = "sell-1", side = "SELL", price = "0.70", size = "1")
|
||||
)
|
||||
val savedTrades = mutableListOf<LeaderPaperTrade>()
|
||||
val savedPositions = mutableListOf<LeaderPaperPosition>()
|
||||
val savedSessions = mutableListOf<LeaderPaperSession>()
|
||||
stubPaperPipeline(candidate, session, events, savedTrades, savedPositions, savedSessions)
|
||||
runBlocking {
|
||||
Mockito.`when`(marketPriceService.getCurrentMarketPrice("market-1", 0))
|
||||
.thenReturn(BigDecimal("0.60"), BigDecimal.ZERO)
|
||||
}
|
||||
|
||||
val result = service.processPaperCandidates(runId = 9L, batchSize = 10)
|
||||
|
||||
assertEquals(2, result.processed)
|
||||
assertEquals(0, result.filtered)
|
||||
assertEquals(0, result.failed)
|
||||
assertEquals(listOf("BUY", "SELL"), savedTrades.map { it.side })
|
||||
assertEquals(0, BigDecimal("0.20").compareTo(savedTrades.last().realizedPnl))
|
||||
assertEquals(LeaderResearchValuationStatus.CONFIRMED_ZERO, savedTrades.last().valuationStatus)
|
||||
val finalPosition = savedPositions.last()
|
||||
assertEquals(0, BigDecimal("1.00000000").compareTo(finalPosition.quantity))
|
||||
assertEquals(0, BigDecimal("0.20").compareTo(finalPosition.realizedPnl))
|
||||
val finalSummary = savedSessions.last()
|
||||
assertEquals(2, finalSummary.tradeCount)
|
||||
assertEquals(0, BigDecimal("0.500000000").compareTo(finalSummary.confirmedZeroExposure))
|
||||
assertEquals(0, BigDecimal("0.20").compareTo(finalSummary.totalRealizedPnl))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `process paper candidates records filtered trade without position mutation`() {
|
||||
val candidate = paperCandidate()
|
||||
val session = LeaderPaperSession(id = 10L, candidateId = candidate.id!!)
|
||||
val savedTrades = mutableListOf<LeaderPaperTrade>()
|
||||
val savedPositions = mutableListOf<LeaderPaperPosition>()
|
||||
val savedSessions = mutableListOf<LeaderPaperSession>()
|
||||
stubPaperPipeline(
|
||||
candidate = candidate,
|
||||
session = session,
|
||||
events = listOf(paperEvent(id = 100L, stableKey = "bad-price", side = "BUY", price = "0.05", size = "10")),
|
||||
savedTrades = savedTrades,
|
||||
savedPositions = savedPositions,
|
||||
savedSessions = savedSessions
|
||||
)
|
||||
|
||||
val result = service.processPaperCandidates(runId = 9L, batchSize = 10)
|
||||
|
||||
assertEquals(0, result.processed)
|
||||
assertEquals(1, result.filtered)
|
||||
assertEquals(0, result.failed)
|
||||
assertEquals(LeaderPaperFilterResult.FILTERED, savedTrades.single().filterResult)
|
||||
assertEquals("price_outside_safe_band", savedTrades.single().filterReason)
|
||||
assertTrue(savedPositions.isEmpty())
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `duplicate leader trade does not create another paper trade or mutate position`() {
|
||||
val candidate = paperCandidate()
|
||||
val session = LeaderPaperSession(id = 10L, candidateId = candidate.id!!)
|
||||
val existingTrade = LeaderPaperTrade(
|
||||
id = 99L,
|
||||
sessionId = session.id!!,
|
||||
candidateId = candidate.id!!,
|
||||
leaderTradeId = "duplicate-1",
|
||||
marketId = "market-1",
|
||||
side = "BUY",
|
||||
eventTime = 1_700_000_000_000
|
||||
)
|
||||
val savedTrades = mutableListOf(existingTrade)
|
||||
val savedPositions = mutableListOf<LeaderPaperPosition>()
|
||||
val savedSessions = mutableListOf<LeaderPaperSession>()
|
||||
stubPaperPipeline(
|
||||
candidate = candidate,
|
||||
session = session,
|
||||
events = listOf(paperEvent(id = 100L, stableKey = "duplicate-1", side = "BUY", price = "0.50", size = "10")),
|
||||
savedTrades = savedTrades,
|
||||
savedPositions = savedPositions,
|
||||
savedSessions = savedSessions
|
||||
)
|
||||
Mockito.`when`(paperTradeRepository.existsBySessionIdAndLeaderTradeIdAndSide(session.id!!, "duplicate-1", "BUY"))
|
||||
.thenReturn(true)
|
||||
|
||||
val result = service.processPaperCandidates(runId = 9L, batchSize = 10)
|
||||
|
||||
assertEquals(1, result.processed)
|
||||
assertEquals(1, savedTrades.size)
|
||||
assertTrue(savedPositions.isEmpty())
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `claim miss isolates concurrent paper processing`() {
|
||||
val candidate = paperCandidate()
|
||||
val session = LeaderPaperSession(id = 10L, candidateId = candidate.id!!)
|
||||
val savedTrades = mutableListOf<LeaderPaperTrade>()
|
||||
val savedPositions = mutableListOf<LeaderPaperPosition>()
|
||||
val savedSessions = mutableListOf<LeaderPaperSession>()
|
||||
stubPaperPipeline(
|
||||
candidate = candidate,
|
||||
session = session,
|
||||
events = listOf(paperEvent(id = 100L, stableKey = "claimed-elsewhere", side = "BUY", price = "0.50", size = "10")),
|
||||
savedTrades = savedTrades,
|
||||
savedPositions = savedPositions,
|
||||
savedSessions = savedSessions,
|
||||
claimResult = 0
|
||||
)
|
||||
|
||||
val result = service.processPaperCandidates(runId = 9L, batchSize = 10)
|
||||
|
||||
assertEquals(0, result.processed)
|
||||
assertEquals(0, result.filtered)
|
||||
assertEquals(0, result.failed)
|
||||
assertTrue(savedTrades.isEmpty())
|
||||
assertTrue(savedPositions.isEmpty())
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `processing failure becomes failed after max attempts and does not block batch`() {
|
||||
val candidate = paperCandidate()
|
||||
val session = LeaderPaperSession(id = 10L, candidateId = candidate.id!!)
|
||||
val failedEvents = mutableListOf<LeaderActivityEvent>()
|
||||
stubPaperPipeline(
|
||||
candidate = candidate,
|
||||
session = session,
|
||||
events = listOf(
|
||||
paperEvent(
|
||||
id = 100L,
|
||||
stableKey = "save-fails",
|
||||
side = "BUY",
|
||||
price = "0.50",
|
||||
size = "10",
|
||||
processingAttempts = 2
|
||||
)
|
||||
),
|
||||
savedTrades = mutableListOf(),
|
||||
savedPositions = mutableListOf(),
|
||||
savedSessions = mutableListOf()
|
||||
)
|
||||
runBlocking {
|
||||
Mockito.`when`(marketPriceService.getCurrentMarketPrice("market-1", 0)).thenReturn(BigDecimal("0.60"))
|
||||
}
|
||||
Mockito.`when`(paperTradeRepository.save(anyTrade())).thenThrow(IllegalStateException("db down"))
|
||||
Mockito.`when`(activityEventRepository.save(anyActivityEvent())).thenAnswer {
|
||||
val event = it.arguments[0] as LeaderActivityEvent
|
||||
failedEvents += event
|
||||
event
|
||||
}
|
||||
|
||||
val result = service.processPaperCandidates(runId = 9L, batchSize = 10)
|
||||
|
||||
assertEquals(0, result.processed)
|
||||
assertEquals(0, result.filtered)
|
||||
assertEquals(1, result.failed)
|
||||
assertEquals(LeaderPaperProcessingStatus.FAILED, failedEvents.last().paperProcessingStatus)
|
||||
assertTrue(failedEvents.last().lastProcessingError!!.contains("db down"))
|
||||
}
|
||||
|
||||
private fun stubPaperPipeline(
|
||||
candidate: LeaderResearchCandidate,
|
||||
session: LeaderPaperSession,
|
||||
events: List<LeaderActivityEvent>,
|
||||
savedTrades: MutableList<LeaderPaperTrade>,
|
||||
savedPositions: MutableList<LeaderPaperPosition>,
|
||||
savedSessions: MutableList<LeaderPaperSession>,
|
||||
claimResult: Int = 1
|
||||
) {
|
||||
Mockito.`when`(candidateRepository.findByResearchStateIn(listOf(LeaderResearchState.PAPER, LeaderResearchState.TRIAL_READY)))
|
||||
.thenReturn(listOf(candidate))
|
||||
Mockito.`when`(candidateRepository.save(anyCandidate())).thenAnswer { it.arguments[0] }
|
||||
Mockito.`when`(paperSessionRepository.findTopByCandidateIdAndStatusOrderByStartedAtDesc(candidate.id!!, LeaderPaperSessionStatus.ACTIVE))
|
||||
.thenReturn(null, session, session, session, session)
|
||||
Mockito.`when`(paperSessionRepository.save(anySession())).thenAnswer {
|
||||
val incoming = it.arguments[0] as LeaderPaperSession
|
||||
val saved = if (incoming.id == null) incoming.copy(id = session.id) else incoming
|
||||
savedSessions += saved
|
||||
saved
|
||||
}
|
||||
Mockito.`when`(
|
||||
activityEventRepository.findByPaperProcessingStatusInAndUsableForPaperTrueOrderByEventTimeAsc(
|
||||
listOf(LeaderPaperProcessingStatus.NEW, LeaderPaperProcessingStatus.RETRYABLE),
|
||||
PageRequest.of(0, 10)
|
||||
)
|
||||
).thenReturn(PageImpl(events))
|
||||
Mockito.`when`(
|
||||
activityEventRepository.claimForPaperProcessing(
|
||||
Mockito.anyLong(),
|
||||
anyProcessingStatuses(),
|
||||
anyProcessingStatus(),
|
||||
Mockito.anyLong()
|
||||
)
|
||||
).thenReturn(claimResult)
|
||||
Mockito.`when`(activityEventRepository.save(anyActivityEvent())).thenAnswer { it.arguments[0] }
|
||||
Mockito.`when`(paperPositionRepository.findBySessionIdAndMarketIdAndOutcomeIndex(session.id!!, "market-1", 0))
|
||||
.thenAnswer { savedPositions.lastOrNull { it.marketId == "market-1" && it.outcomeIndex == 0 } }
|
||||
Mockito.`when`(paperPositionRepository.findBySessionIdOrderByUpdatedAtDesc(session.id!!))
|
||||
.thenAnswer { savedPositions.toList().asReversed() }
|
||||
Mockito.`when`(paperPositionRepository.save(anyPosition())).thenAnswer {
|
||||
val position = it.arguments[0] as LeaderPaperPosition
|
||||
val existingIndex = savedPositions.indexOfFirst { saved ->
|
||||
saved.sessionId == position.sessionId &&
|
||||
saved.marketId == position.marketId &&
|
||||
saved.outcomeIndex == position.outcomeIndex
|
||||
}
|
||||
if (existingIndex >= 0) {
|
||||
savedPositions[existingIndex] = position
|
||||
} else {
|
||||
savedPositions += position
|
||||
}
|
||||
position
|
||||
}
|
||||
Mockito.`when`(paperTradeRepository.existsBySessionIdAndLeaderTradeIdAndSide(Mockito.anyLong(), Mockito.anyString(), Mockito.anyString()))
|
||||
.thenReturn(false)
|
||||
Mockito.`when`(paperTradeRepository.findBySessionIdOrderByEventTimeAsc(session.id!!))
|
||||
.thenAnswer { savedTrades.sortedBy { it.eventTime } }
|
||||
Mockito.`when`(paperTradeRepository.save(anyTrade())).thenAnswer {
|
||||
val trade = it.arguments[0] as LeaderPaperTrade
|
||||
savedTrades += trade
|
||||
trade
|
||||
}
|
||||
}
|
||||
|
||||
private fun paperCandidate() = LeaderResearchCandidate(
|
||||
id = 1L,
|
||||
normalizedWallet = "0x1111111111111111111111111111111111111111",
|
||||
researchState = LeaderResearchState.PAPER
|
||||
)
|
||||
|
||||
private fun paperEvent(
|
||||
id: Long,
|
||||
stableKey: String,
|
||||
side: String,
|
||||
price: String,
|
||||
size: String,
|
||||
processingAttempts: Int = 0
|
||||
) = LeaderActivityEvent(
|
||||
id = id,
|
||||
source = "ACTIVITY_DERIVED",
|
||||
sourceEventId = stableKey,
|
||||
stableEventKey = stableKey,
|
||||
normalizedWallet = "0x1111111111111111111111111111111111111111",
|
||||
marketId = "market-1",
|
||||
side = side,
|
||||
outcomeIndex = 0,
|
||||
price = BigDecimal(price),
|
||||
size = BigDecimal(size),
|
||||
amount = BigDecimal(price).multiply(BigDecimal(size)),
|
||||
eventTime = 1_700_000_000_000 + id,
|
||||
rawPayloadHash = "hash-$stableKey",
|
||||
usableForPaper = true,
|
||||
paperProcessingStatus = if (processingAttempts > 0) LeaderPaperProcessingStatus.RETRYABLE else LeaderPaperProcessingStatus.NEW,
|
||||
processingAttempts = processingAttempts
|
||||
)
|
||||
|
||||
private fun anyCandidate(): LeaderResearchCandidate {
|
||||
Mockito.any(LeaderResearchCandidate::class.java)
|
||||
return paperCandidate()
|
||||
}
|
||||
|
||||
private fun anySession(): LeaderPaperSession {
|
||||
Mockito.any(LeaderPaperSession::class.java)
|
||||
return LeaderPaperSession(candidateId = 1)
|
||||
}
|
||||
|
||||
private fun anyActivityEvent(): LeaderActivityEvent {
|
||||
Mockito.any(LeaderActivityEvent::class.java)
|
||||
return paperEvent(id = 1, stableKey = "dummy", side = "BUY", price = "0.50", size = "1")
|
||||
}
|
||||
|
||||
private fun anyPosition(): LeaderPaperPosition {
|
||||
Mockito.any(LeaderPaperPosition::class.java)
|
||||
return LeaderPaperPosition(sessionId = 10, candidateId = 1, marketId = "market-1")
|
||||
}
|
||||
|
||||
private fun anyTrade(): LeaderPaperTrade {
|
||||
Mockito.any(LeaderPaperTrade::class.java)
|
||||
return LeaderPaperTrade(sessionId = 10, candidateId = 1, leaderTradeId = "dummy", marketId = "market-1", side = "BUY", eventTime = 1)
|
||||
}
|
||||
|
||||
private fun anyProcessingStatuses(): Collection<LeaderPaperProcessingStatus> {
|
||||
Mockito.anyCollection<LeaderPaperProcessingStatus>()
|
||||
return emptyList()
|
||||
}
|
||||
|
||||
private fun anyProcessingStatus(): LeaderPaperProcessingStatus {
|
||||
Mockito.any(LeaderPaperProcessingStatus::class.java)
|
||||
return LeaderPaperProcessingStatus.PROCESSING
|
||||
}
|
||||
|
||||
@Suppress("UNCHECKED_CAST")
|
||||
private inline fun <reified T> mock(): T = org.mockito.Mockito.mock(T::class.java)
|
||||
}
|
||||
+152
@@ -0,0 +1,152 @@
|
||||
package com.wrbug.polymarketbot.service.copytrading.research
|
||||
|
||||
import com.wrbug.polymarketbot.dto.CopyTradingDto
|
||||
import com.wrbug.polymarketbot.dto.LeaderResearchApprovalRequest
|
||||
import com.wrbug.polymarketbot.entity.Account
|
||||
import com.wrbug.polymarketbot.entity.LeaderPool
|
||||
import com.wrbug.polymarketbot.entity.LeaderResearchCandidate
|
||||
import com.wrbug.polymarketbot.enums.LeaderResearchState
|
||||
import com.wrbug.polymarketbot.repository.AccountRepository
|
||||
import com.wrbug.polymarketbot.repository.CopyTradingRepository
|
||||
import com.wrbug.polymarketbot.repository.LeaderPoolRepository
|
||||
import com.wrbug.polymarketbot.repository.LeaderResearchCandidateRepository
|
||||
import com.wrbug.polymarketbot.service.copytrading.configs.CopyTradingService
|
||||
import org.junit.jupiter.api.Assertions.assertFalse
|
||||
import org.junit.jupiter.api.Assertions.assertTrue
|
||||
import org.junit.jupiter.api.Test
|
||||
import org.mockito.ArgumentCaptor
|
||||
import org.mockito.Mockito
|
||||
import java.util.Optional
|
||||
|
||||
class LeaderResearchApprovalServiceTest {
|
||||
private val candidateRepository: LeaderResearchCandidateRepository = mock()
|
||||
private val accountRepository: AccountRepository = mock()
|
||||
private val copyTradingRepository: CopyTradingRepository = mock()
|
||||
private val leaderPoolRepository: LeaderPoolRepository = mock()
|
||||
private val copyTradingService: CopyTradingService = mock()
|
||||
private val poolMappingService: LeaderResearchPoolMappingService = mock()
|
||||
private val eventService: LeaderResearchEventService = mock()
|
||||
private val service = LeaderResearchApprovalService(
|
||||
candidateRepository,
|
||||
accountRepository,
|
||||
copyTradingRepository,
|
||||
leaderPoolRepository,
|
||||
copyTradingService,
|
||||
poolMappingService,
|
||||
eventService
|
||||
)
|
||||
|
||||
@Test
|
||||
fun `approval requires explicit confirm`() {
|
||||
val result = service.createDisabledTrialConfig(LeaderResearchApprovalRequest(candidateId = 1L, accountId = 2L, confirm = false))
|
||||
|
||||
assertTrue(result.isFailure)
|
||||
assertTrue(result.exceptionOrNull() is LeaderResearchApprovalConfirmRequiredException)
|
||||
Mockito.verify(copyTradingService, Mockito.never()).createCopyTrading(anyCreateRequest())
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `approval creates disabled copy trading config only`() {
|
||||
val candidate = LeaderResearchCandidate(
|
||||
id = 1L,
|
||||
normalizedWallet = "0x1111111111111111111111111111111111111111",
|
||||
leaderId = 9L,
|
||||
poolId = 10L,
|
||||
researchState = LeaderResearchState.TRIAL_READY
|
||||
)
|
||||
Mockito.`when`(candidateRepository.findById(1L)).thenReturn(Optional.of(candidate))
|
||||
Mockito.`when`(accountRepository.findByIdForUpdate(2L)).thenReturn(account())
|
||||
Mockito.`when`(poolMappingService.syncCandidate(candidate)).thenReturn(candidate)
|
||||
Mockito.`when`(leaderPoolRepository.findById(10L)).thenReturn(Optional.of(pool()))
|
||||
Mockito.`when`(copyTradingRepository.findByAccountIdAndLeaderId(2L, 9L)).thenReturn(emptyList())
|
||||
Mockito.`when`(copyTradingService.createCopyTrading(anyCreateRequest())).thenReturn(Result.success(copyTradingDto()))
|
||||
Mockito.`when`(leaderPoolRepository.save(anyLeaderPool())).thenAnswer { it.arguments[0] }
|
||||
|
||||
val result = service.createDisabledTrialConfig(LeaderResearchApprovalRequest(candidateId = 1L, accountId = 2L, confirm = true))
|
||||
|
||||
assertTrue(result.isSuccess)
|
||||
val captor = ArgumentCaptor.forClass(com.wrbug.polymarketbot.dto.CopyTradingCreateRequest::class.java)
|
||||
Mockito.verify(copyTradingService).createCopyTrading(captureCreateRequest(captor))
|
||||
assertFalse(captor.value.enabled)
|
||||
Mockito.verify(accountRepository).findByIdForUpdate(2L)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `locked candidate cannot create approval config`() {
|
||||
val candidate = LeaderResearchCandidate(
|
||||
id = 1L,
|
||||
normalizedWallet = "0x1111111111111111111111111111111111111111",
|
||||
leaderId = 9L,
|
||||
poolId = 10L,
|
||||
researchState = LeaderResearchState.TRIAL_READY,
|
||||
locked = true
|
||||
)
|
||||
Mockito.`when`(candidateRepository.findById(1L)).thenReturn(Optional.of(candidate))
|
||||
|
||||
val result = service.createDisabledTrialConfig(LeaderResearchApprovalRequest(candidateId = 1L, accountId = 2L, confirm = true))
|
||||
|
||||
assertTrue(result.isFailure)
|
||||
assertTrue(result.exceptionOrNull() is LeaderResearchCandidateLockedException)
|
||||
Mockito.verify(accountRepository, Mockito.never()).findByIdForUpdate(2L)
|
||||
Mockito.verify(copyTradingService, Mockito.never()).createCopyTrading(anyCreateRequest())
|
||||
}
|
||||
|
||||
private fun account() = Account(
|
||||
id = 2L,
|
||||
privateKey = "enc",
|
||||
walletAddress = "0x2222222222222222222222222222222222222222",
|
||||
proxyAddress = "0x3333333333333333333333333333333333333333"
|
||||
)
|
||||
|
||||
private fun pool() = LeaderPool(id = 10L, leaderId = 9L, researchCandidateId = 1L)
|
||||
|
||||
private fun copyTradingDto() = CopyTradingDto(
|
||||
id = 20L,
|
||||
accountId = 2L,
|
||||
accountName = null,
|
||||
walletAddress = "0x2222222222222222222222222222222222222222",
|
||||
leaderId = 9L,
|
||||
leaderName = null,
|
||||
leaderAddress = "0x1111111111111111111111111111111111111111",
|
||||
enabled = false,
|
||||
copyMode = "FIXED",
|
||||
copyRatio = "1",
|
||||
fixedAmount = "1",
|
||||
maxOrderSize = "1",
|
||||
minOrderSize = "1",
|
||||
maxDailyLoss = "5",
|
||||
maxDailyOrders = 10,
|
||||
priceTolerance = "1",
|
||||
delaySeconds = 0,
|
||||
pollIntervalSeconds = 5,
|
||||
useWebSocket = true,
|
||||
websocketReconnectInterval = 5000,
|
||||
websocketMaxRetries = 10,
|
||||
supportSell = true,
|
||||
minOrderDepth = null,
|
||||
maxSpread = null,
|
||||
minPrice = "0.1",
|
||||
maxPrice = "0.8",
|
||||
maxPositionValue = "5",
|
||||
createdAt = 1L,
|
||||
updatedAt = 1L
|
||||
)
|
||||
|
||||
private fun anyCreateRequest(): com.wrbug.polymarketbot.dto.CopyTradingCreateRequest {
|
||||
Mockito.any(com.wrbug.polymarketbot.dto.CopyTradingCreateRequest::class.java)
|
||||
return com.wrbug.polymarketbot.dto.CopyTradingCreateRequest(accountId = 2L, leaderId = 9L)
|
||||
}
|
||||
|
||||
private fun anyLeaderPool(): LeaderPool {
|
||||
Mockito.any(LeaderPool::class.java)
|
||||
return pool()
|
||||
}
|
||||
|
||||
private fun captureCreateRequest(captor: ArgumentCaptor<com.wrbug.polymarketbot.dto.CopyTradingCreateRequest>): com.wrbug.polymarketbot.dto.CopyTradingCreateRequest {
|
||||
captor.capture()
|
||||
return com.wrbug.polymarketbot.dto.CopyTradingCreateRequest(accountId = 2L, leaderId = 9L)
|
||||
}
|
||||
|
||||
@Suppress("UNCHECKED_CAST")
|
||||
private inline fun <reified T> mock(): T = Mockito.mock(T::class.java)
|
||||
}
|
||||
+201
@@ -0,0 +1,201 @@
|
||||
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
|
||||
import java.util.concurrent.Executors
|
||||
import java.util.concurrent.TimeUnit
|
||||
|
||||
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 `expected disabled sources do not mark run partial failure`() {
|
||||
val service = service()
|
||||
stubRunSaves()
|
||||
Mockito.`when`(sourceService.discoverCandidates(1L)).thenReturn(
|
||||
listOf(
|
||||
LeaderResearchSourceRunResult(LeaderResearchSourceType.WATCHLIST, emptyList(), LeaderResearchSourceStatus.SUCCESS),
|
||||
LeaderResearchSourceRunResult(
|
||||
LeaderResearchSourceType.ACTIVITY_DERIVED,
|
||||
emptyList(),
|
||||
LeaderResearchSourceStatus.DEGRADED,
|
||||
limitation = "Global activity capture is disabled",
|
||||
expectedLimitation = true
|
||||
),
|
||||
LeaderResearchSourceRunResult(
|
||||
LeaderResearchSourceType.GLOBAL_ACTIVITY_CAPTURE,
|
||||
emptyList(),
|
||||
LeaderResearchSourceStatus.DISABLED,
|
||||
limitation = "Global activity capture is disabled",
|
||||
expectedLimitation = true
|
||||
),
|
||||
LeaderResearchSourceRunResult(
|
||||
LeaderResearchSourceType.PUBLIC_LEADERBOARD,
|
||||
emptyList(),
|
||||
LeaderResearchSourceStatus.DISABLED,
|
||||
limitation = "Public leaderboard source is intentionally disabled",
|
||||
expectedLimitation = true
|
||||
)
|
||||
)
|
||||
)
|
||||
|
||||
val run = service.runOnce(dryRun = false, triggerType = LeaderResearchTriggerType.MANUAL)
|
||||
|
||||
assertEquals(LeaderResearchRunStatus.SUCCESS, run.status)
|
||||
assertFalse(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 `async run returns running record before background execution completes`() {
|
||||
val service = service()
|
||||
val executor = Executors.newSingleThreadExecutor()
|
||||
service.runExecutor = executor
|
||||
stubRunSaves()
|
||||
Mockito.`when`(sourceService.discoverCandidates(1L)).thenAnswer {
|
||||
Thread.sleep(100)
|
||||
emptyList<LeaderResearchSourceRunResult>()
|
||||
}
|
||||
|
||||
val run = service.startAsync(dryRun = false, triggerType = LeaderResearchTriggerType.MANUAL)
|
||||
|
||||
assertEquals(LeaderResearchRunStatus.RUNNING, run.status)
|
||||
executor.shutdown()
|
||||
assertTrue(executor.awaitTermination(2, TimeUnit.SECONDS))
|
||||
Mockito.verify(sourceService).discoverCandidates(run.id)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `overlap guard records skipped run while outer run continues`() {
|
||||
lateinit var service: LeaderResearchJobService
|
||||
val savedRuns = mutableListOf<LeaderResearchRun>()
|
||||
stubRunSaves(savedRuns)
|
||||
service = service()
|
||||
Mockito.`when`(sourceService.discoverCandidates(1L)).thenAnswer {
|
||||
val skipped = service.runOnce(dryRun = false, triggerType = LeaderResearchTriggerType.MANUAL)
|
||||
assertEquals(LeaderResearchRunStatus.SKIPPED, skipped.status)
|
||||
emptyList<LeaderResearchSourceRunResult>()
|
||||
}.thenReturn(emptyList())
|
||||
|
||||
val outer = service.runOnce(dryRun = false, triggerType = LeaderResearchTriggerType.MANUAL)
|
||||
|
||||
assertEquals(LeaderResearchRunStatus.SUCCESS, outer.status)
|
||||
assertNotNull(savedRuns.firstOrNull { it.status == LeaderResearchRunStatus.SKIPPED })
|
||||
assertEquals("another_run_in_progress", savedRuns.first { it.status == LeaderResearchRunStatus.SKIPPED }.skippedReason)
|
||||
}
|
||||
|
||||
private fun service() = LeaderResearchJobService(
|
||||
runRepository = runRepository,
|
||||
activityEventRepository = activityEventRepository,
|
||||
candidateRepository = candidateRepository,
|
||||
sourceService = sourceService,
|
||||
paperTradingService = paperTradingService,
|
||||
scoringService = scoringService,
|
||||
stateMachine = stateMachine,
|
||||
eventService = eventService,
|
||||
scheduledEnabled = false
|
||||
)
|
||||
|
||||
private fun stubRunSaves(savedRuns: MutableList<LeaderResearchRun> = mutableListOf()) {
|
||||
var nextId = 1L
|
||||
Mockito.`when`(runRepository.save(anyRun())).thenAnswer {
|
||||
val incoming = it.arguments[0] as LeaderResearchRun
|
||||
incoming.copy(id = incoming.id ?: nextId++).also { savedRuns += it }
|
||||
}
|
||||
}
|
||||
|
||||
private fun anyRun(): LeaderResearchRun {
|
||||
Mockito.any(LeaderResearchRun::class.java)
|
||||
return LeaderResearchRun()
|
||||
}
|
||||
|
||||
@Suppress("UNCHECKED_CAST")
|
||||
private inline fun <reified T> mock(): T = Mockito.mock(T::class.java)
|
||||
}
|
||||
+68
@@ -0,0 +1,68 @@
|
||||
package com.wrbug.polymarketbot.service.copytrading.research
|
||||
|
||||
import com.wrbug.polymarketbot.entity.LeaderResearchEvent
|
||||
import com.wrbug.polymarketbot.enums.LeaderResearchEventType
|
||||
import com.wrbug.polymarketbot.enums.LeaderResearchNotificationStatus
|
||||
import com.wrbug.polymarketbot.repository.LeaderResearchEventRepository
|
||||
import org.junit.jupiter.api.Assertions.assertEquals
|
||||
import org.junit.jupiter.api.Test
|
||||
import org.mockito.Mockito
|
||||
import org.springframework.data.domain.PageImpl
|
||||
import org.springframework.data.domain.PageRequest
|
||||
|
||||
class LeaderResearchNotificationSummaryServiceTest {
|
||||
private val repository: LeaderResearchEventRepository = mock()
|
||||
private val service = LeaderResearchNotificationSummaryService(repository)
|
||||
|
||||
@Test
|
||||
fun `builds pending safety summary`() {
|
||||
val page = PageRequest.of(0, 50)
|
||||
Mockito.`when`(
|
||||
repository.findByNotificationStatusOrderByCreatedAtAsc(
|
||||
LeaderResearchNotificationStatus.PENDING,
|
||||
page
|
||||
)
|
||||
).thenReturn(PageImpl(events()))
|
||||
|
||||
val summary = service.buildPendingSummary(limit = 50)
|
||||
|
||||
assertEquals(4, summary.total)
|
||||
assertEquals(1, summary.newCandidates)
|
||||
assertEquals(1, summary.trialReady)
|
||||
assertEquals(1, summary.sourceFailures)
|
||||
assertEquals(1, summary.approvalWarnings)
|
||||
assertEquals(4, summary.lines.size)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `mark pending as skipped preserves events and marks notification failure reason`() {
|
||||
val page = PageRequest.of(0, 100)
|
||||
Mockito.`when`(
|
||||
repository.findByNotificationStatusOrderByCreatedAtAsc(
|
||||
LeaderResearchNotificationStatus.PENDING,
|
||||
page
|
||||
)
|
||||
).thenReturn(PageImpl(events()))
|
||||
Mockito.`when`(repository.save(anyEvent())).thenAnswer { it.arguments[0] }
|
||||
|
||||
val summary = service.markPendingAsSkipped(reason = "operator_console_only")
|
||||
|
||||
assertEquals(4, summary.total)
|
||||
Mockito.verify(repository, Mockito.times(4)).save(anyEvent())
|
||||
}
|
||||
|
||||
private fun events() = listOf(
|
||||
LeaderResearchEvent(eventType = LeaderResearchEventType.CANDIDATE_DISCOVERED, reason = "new"),
|
||||
LeaderResearchEvent(eventType = LeaderResearchEventType.TRIAL_READY, reason = "ready"),
|
||||
LeaderResearchEvent(eventType = LeaderResearchEventType.SOURCE_FAILURE, reason = "source failed"),
|
||||
LeaderResearchEvent(eventType = LeaderResearchEventType.DUPLICATE_APPROVAL, reason = "duplicate")
|
||||
)
|
||||
|
||||
private fun anyEvent(): LeaderResearchEvent {
|
||||
Mockito.any(LeaderResearchEvent::class.java)
|
||||
return LeaderResearchEvent(eventType = LeaderResearchEventType.CANDIDATE_DISCOVERED)
|
||||
}
|
||||
|
||||
@Suppress("UNCHECKED_CAST")
|
||||
private inline fun <reified T> mock(): T = Mockito.mock(T::class.java)
|
||||
}
|
||||
+90
@@ -0,0 +1,90 @@
|
||||
package com.wrbug.polymarketbot.service.copytrading.research
|
||||
|
||||
import com.wrbug.polymarketbot.entity.LeaderPaperSession
|
||||
import com.wrbug.polymarketbot.enums.LeaderPaperProcessingStatus
|
||||
import com.wrbug.polymarketbot.enums.LeaderPaperSessionStatus
|
||||
import com.wrbug.polymarketbot.repository.LeaderActivityEventRepository
|
||||
import com.wrbug.polymarketbot.repository.LeaderPaperSessionRepository
|
||||
import org.junit.jupiter.api.Assertions.assertEquals
|
||||
import org.junit.jupiter.api.Test
|
||||
import org.mockito.Mockito
|
||||
import org.springframework.data.domain.PageImpl
|
||||
import org.springframework.data.domain.PageRequest
|
||||
|
||||
class LeaderResearchRetentionServiceTest {
|
||||
private val activityRepository: LeaderActivityEventRepository = mock()
|
||||
private val sessionRepository: LeaderPaperSessionRepository = mock()
|
||||
|
||||
@Test
|
||||
fun `cleanup deletes only terminal activity events and terminal paper sessions`() {
|
||||
val staleSessions = listOf(
|
||||
LeaderPaperSession(id = 1, candidateId = 1, status = LeaderPaperSessionStatus.COMPLETED),
|
||||
LeaderPaperSession(id = 2, candidateId = 2, status = LeaderPaperSessionStatus.FAILED)
|
||||
)
|
||||
val terminalActivityStatuses = listOf(
|
||||
LeaderPaperProcessingStatus.PROCESSED,
|
||||
LeaderPaperProcessingStatus.FILTERED,
|
||||
LeaderPaperProcessingStatus.FAILED
|
||||
)
|
||||
val terminalSessionStatuses = listOf(
|
||||
LeaderPaperSessionStatus.COMPLETED,
|
||||
LeaderPaperSessionStatus.FAILED
|
||||
)
|
||||
val now = 1_000_000_000L
|
||||
val activityCutoff = -6_776_000_000L
|
||||
val paperCutoff = -14_552_000_000L
|
||||
val paperPage = PageRequest.of(0, 100)
|
||||
val service = LeaderResearchRetentionService(
|
||||
activityEventRepository = activityRepository,
|
||||
paperSessionRepository = sessionRepository,
|
||||
enabled = true,
|
||||
activityRetentionDays = 90,
|
||||
paperSessionRetentionDays = 180,
|
||||
maxPaperSessionsPerRun = 100
|
||||
)
|
||||
Mockito.`when`(
|
||||
activityRepository.deleteByEventTimeLessThanAndPaperProcessingStatusIn(
|
||||
activityCutoff,
|
||||
terminalActivityStatuses
|
||||
)
|
||||
).thenReturn(7)
|
||||
Mockito.`when`(
|
||||
sessionRepository.findByUpdatedAtLessThanAndStatusIn(
|
||||
paperCutoff,
|
||||
terminalSessionStatuses,
|
||||
paperPage
|
||||
)
|
||||
).thenReturn(PageImpl(staleSessions))
|
||||
|
||||
val result = service.cleanup(now = now)
|
||||
|
||||
assertEquals(7, result.deletedActivityEvents)
|
||||
assertEquals(2, result.deletedPaperSessions)
|
||||
Mockito.verify(activityRepository).deleteByEventTimeLessThanAndPaperProcessingStatusIn(
|
||||
activityCutoff,
|
||||
terminalActivityStatuses
|
||||
)
|
||||
Mockito.verify(sessionRepository).deleteAll(staleSessions)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `disabled cleanup does nothing`() {
|
||||
val service = LeaderResearchRetentionService(
|
||||
activityEventRepository = activityRepository,
|
||||
paperSessionRepository = sessionRepository,
|
||||
enabled = false,
|
||||
activityRetentionDays = 90,
|
||||
paperSessionRetentionDays = 180,
|
||||
maxPaperSessionsPerRun = 100
|
||||
)
|
||||
|
||||
val result = service.cleanup()
|
||||
|
||||
assertEquals(0, result.deletedActivityEvents)
|
||||
assertEquals(0, result.deletedPaperSessions)
|
||||
Mockito.verifyNoInteractions(activityRepository, sessionRepository)
|
||||
}
|
||||
|
||||
@Suppress("UNCHECKED_CAST")
|
||||
private inline fun <reified T> mock(): T = Mockito.mock(T::class.java)
|
||||
}
|
||||
+95
@@ -0,0 +1,95 @@
|
||||
package com.wrbug.polymarketbot.service.copytrading.research
|
||||
|
||||
import com.wrbug.polymarketbot.entity.LeaderPaperSession
|
||||
import com.wrbug.polymarketbot.entity.LeaderResearchCandidate
|
||||
import org.junit.jupiter.api.Assertions.assertEquals
|
||||
import org.junit.jupiter.api.Assertions.assertTrue
|
||||
import org.junit.jupiter.api.Test
|
||||
import java.math.BigDecimal
|
||||
|
||||
class LeaderResearchScoringServiceTest {
|
||||
private val service = LeaderResearchScoringService(
|
||||
candidateRepository = mock(),
|
||||
paperSessionRepository = mock(),
|
||||
scoreRepository = mock()
|
||||
)
|
||||
|
||||
@Test
|
||||
fun `compute rewards profitable repeatable fresh paper session`() {
|
||||
val now = System.currentTimeMillis()
|
||||
val candidate = LeaderResearchCandidate(
|
||||
id = 1L,
|
||||
normalizedWallet = "0x1111111111111111111111111111111111111111",
|
||||
lastSourceSeenAt = now
|
||||
)
|
||||
val session = LeaderPaperSession(
|
||||
id = 10L,
|
||||
candidateId = 1L,
|
||||
startedAt = now - 8L * 24 * 60 * 60 * 1000,
|
||||
tradeCount = 12,
|
||||
filteredCount = 1,
|
||||
openExposure = BigDecimal("10"),
|
||||
copyablePnl = BigDecimal("4"),
|
||||
maxDrawdown = BigDecimal("-3"),
|
||||
unknownValuationExposure = BigDecimal("1"),
|
||||
filteredRatio = BigDecimal("0.08")
|
||||
)
|
||||
|
||||
val score = service.compute(candidate, session, runId = 99L)
|
||||
|
||||
assertTrue(score.totalScore >= BigDecimal("60"))
|
||||
assertEquals("research-copyability-v1", score.scoreVersion)
|
||||
assertEquals(12, score.sampleTradeCount)
|
||||
assertTrue(score.reason!!.contains("source_fresh=true"))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `compute penalizes stale source and unknown quotes`() {
|
||||
val candidate = LeaderResearchCandidate(
|
||||
id = 1L,
|
||||
normalizedWallet = "0x1111111111111111111111111111111111111111",
|
||||
lastSourceSeenAt = System.currentTimeMillis() - 7L * 24 * 60 * 60 * 1000
|
||||
)
|
||||
val session = LeaderPaperSession(
|
||||
id = 10L,
|
||||
candidateId = 1L,
|
||||
tradeCount = 2,
|
||||
openExposure = BigDecimal("10"),
|
||||
unknownValuationExposure = BigDecimal("8"),
|
||||
filteredRatio = BigDecimal("0.50")
|
||||
)
|
||||
|
||||
val score = service.compute(candidate, session, runId = null)
|
||||
|
||||
assertTrue(score.totalScore < BigDecimal("60"))
|
||||
assertTrue(score.reason!!.contains("source_fresh=false"))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `compute caps small samples below promotion threshold`() {
|
||||
val now = System.currentTimeMillis()
|
||||
val candidate = LeaderResearchCandidate(
|
||||
id = 1L,
|
||||
normalizedWallet = "0x1111111111111111111111111111111111111111",
|
||||
lastSourceSeenAt = now
|
||||
)
|
||||
val session = LeaderPaperSession(
|
||||
id = 10L,
|
||||
candidateId = 1L,
|
||||
startedAt = now - 8L * 24 * 60 * 60 * 1000,
|
||||
tradeCount = 1,
|
||||
openExposure = BigDecimal("1"),
|
||||
copyablePnl = BigDecimal("100"),
|
||||
maxDrawdown = BigDecimal.ZERO,
|
||||
filteredRatio = BigDecimal.ZERO
|
||||
)
|
||||
|
||||
val score = service.compute(candidate, session, runId = null)
|
||||
|
||||
assertTrue(score.totalScore <= BigDecimal("59"))
|
||||
assertTrue(score.reason!!.contains("sample_cap_applied=true"))
|
||||
}
|
||||
|
||||
@Suppress("UNCHECKED_CAST")
|
||||
private inline fun <reified T> mock(): T = org.mockito.Mockito.mock(T::class.java)
|
||||
}
|
||||
+99
@@ -0,0 +1,99 @@
|
||||
package com.wrbug.polymarketbot.service.copytrading.research
|
||||
|
||||
import com.wrbug.polymarketbot.entity.LeaderResearchSourceState
|
||||
import com.wrbug.polymarketbot.enums.LeaderResearchSourceStatus
|
||||
import com.wrbug.polymarketbot.enums.LeaderResearchSourceType
|
||||
import com.wrbug.polymarketbot.repository.LeaderResearchSourceStateRepository
|
||||
import org.junit.jupiter.api.Assertions.assertEquals
|
||||
import org.junit.jupiter.api.Assertions.assertNull
|
||||
import org.junit.jupiter.api.Assertions.assertTrue
|
||||
import org.junit.jupiter.api.Test
|
||||
import org.mockito.Mockito
|
||||
|
||||
class LeaderResearchSourceHealthServiceTest {
|
||||
private val repository: LeaderResearchSourceStateRepository = mock()
|
||||
private val service = LeaderResearchSourceHealthService(repository)
|
||||
|
||||
@Test
|
||||
fun `records disabled websocket capture state`() {
|
||||
Mockito.`when`(repository.findBySourceType(LeaderResearchSourceType.GLOBAL_ACTIVITY_CAPTURE)).thenReturn(null)
|
||||
Mockito.`when`(repository.save(anyState())).thenAnswer { it.arguments[0] }
|
||||
|
||||
val state = service.record(
|
||||
sourceType = LeaderResearchSourceType.GLOBAL_ACTIVITY_CAPTURE,
|
||||
status = LeaderResearchSourceStatus.DISABLED,
|
||||
disabledReason = "Global activity capture is disabled",
|
||||
now = 100
|
||||
)
|
||||
|
||||
assertEquals(LeaderResearchSourceStatus.DISABLED, state.status)
|
||||
assertEquals("Global activity capture is disabled", state.disabledReason)
|
||||
assertEquals(100, state.lastRunAt)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `degraded source keeps failure timestamp and error`() {
|
||||
Mockito.`when`(repository.findBySourceType(LeaderResearchSourceType.ACTIVITY_DERIVED)).thenReturn(null)
|
||||
Mockito.`when`(repository.save(anyState())).thenAnswer { it.arguments[0] }
|
||||
|
||||
val state = service.record(
|
||||
sourceType = LeaderResearchSourceType.ACTIVITY_DERIVED,
|
||||
status = LeaderResearchSourceStatus.DEGRADED,
|
||||
errorClass = "DataApiFailure",
|
||||
errorMessage = "429",
|
||||
now = 200
|
||||
)
|
||||
|
||||
assertEquals(LeaderResearchSourceStatus.DEGRADED, state.status)
|
||||
assertEquals(200, state.lastFailureAt)
|
||||
assertEquals("DataApiFailure", state.errorClass)
|
||||
assertEquals("429", state.errorMessage)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `success clears disabled reason but preserves cursor update`() {
|
||||
val existing = LeaderResearchSourceState(
|
||||
sourceType = LeaderResearchSourceType.GLOBAL_ACTIVITY_CAPTURE,
|
||||
status = LeaderResearchSourceStatus.DISABLED,
|
||||
disabledReason = "disabled",
|
||||
lastCursor = "old"
|
||||
)
|
||||
Mockito.`when`(repository.findBySourceType(LeaderResearchSourceType.GLOBAL_ACTIVITY_CAPTURE)).thenReturn(existing)
|
||||
Mockito.`when`(repository.save(anyState())).thenAnswer { it.arguments[0] }
|
||||
|
||||
val state = service.record(
|
||||
sourceType = LeaderResearchSourceType.GLOBAL_ACTIVITY_CAPTURE,
|
||||
status = LeaderResearchSourceStatus.SUCCESS,
|
||||
candidateCount = 3,
|
||||
lastCursor = "new",
|
||||
now = 300
|
||||
)
|
||||
|
||||
assertEquals(LeaderResearchSourceStatus.SUCCESS, state.status)
|
||||
assertEquals(300, state.lastSuccessAt)
|
||||
assertEquals(3, state.lastCandidateCount)
|
||||
assertEquals("new", state.lastCursor)
|
||||
assertNull(state.disabledReason)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `stale status is flagged stale`() {
|
||||
Mockito.`when`(repository.findBySourceType(LeaderResearchSourceType.ACTIVITY_DERIVED)).thenReturn(null)
|
||||
Mockito.`when`(repository.save(anyState())).thenAnswer { it.arguments[0] }
|
||||
|
||||
val state = service.record(
|
||||
sourceType = LeaderResearchSourceType.ACTIVITY_DERIVED,
|
||||
status = LeaderResearchSourceStatus.STALE
|
||||
)
|
||||
|
||||
assertTrue(state.stale)
|
||||
}
|
||||
|
||||
private fun anyState(): LeaderResearchSourceState {
|
||||
Mockito.any(LeaderResearchSourceState::class.java)
|
||||
return LeaderResearchSourceState(sourceType = LeaderResearchSourceType.ACTIVITY_DERIVED)
|
||||
}
|
||||
|
||||
@Suppress("UNCHECKED_CAST")
|
||||
private inline fun <reified T> mock(): T = Mockito.mock(T::class.java)
|
||||
}
|
||||
+213
@@ -0,0 +1,213 @@
|
||||
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)
|
||||
assertTrue(results.first { it.sourceType == LeaderResearchSourceType.ACTIVITY_DERIVED }.expectedLimitation)
|
||||
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)
|
||||
assertFalse(results.first { it.sourceType == LeaderResearchSourceType.ACTIVITY_DERIVED }.expectedLimitation)
|
||||
assertTrue(results.flatMap { it.candidates }.map { it.normalizedWallet }.containsAll(listOf(watchWallet, existingWallet, activityWallet)))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `preview returns source limitation without persisting candidates`() {
|
||||
val watchWallet = "0x1111111111111111111111111111111111111111"
|
||||
val activityWallet = "0x3333333333333333333333333333333333333333"
|
||||
Mockito.`when`(systemConfigRepository.findByConfigKey(LeaderResearchSourceService.CONFIG_WATCHLIST))
|
||||
.thenReturn(SystemConfig(configKey = LeaderResearchSourceService.CONFIG_WATCHLIST, configValue = watchWallet))
|
||||
Mockito.`when`(leaderRepository.findAllByOrderByCreatedAtAsc()).thenReturn(emptyList())
|
||||
Mockito.`when`(leaderRepository.findByLeaderAddress(Mockito.anyString())).thenReturn(null)
|
||||
Mockito.`when`(activityEventRepository.findByUsableForDiscoveryTrueAndEventTimeGreaterThanEqual(Mockito.anyLong()))
|
||||
.thenReturn(listOf(activityEvent(activityWallet)))
|
||||
|
||||
val results = service(globalCaptureEnabled = false).previewCandidates()
|
||||
|
||||
assertEquals(LeaderResearchSourceStatus.DEGRADED, results.first { it.sourceType == LeaderResearchSourceType.ACTIVITY_DERIVED }.status)
|
||||
assertEquals(LeaderResearchSourceStatus.DISABLED, results.first { it.sourceType == LeaderResearchSourceType.GLOBAL_ACTIVITY_CAPTURE }.status)
|
||||
assertFalse(results.flatMap { it.candidates }.isEmpty())
|
||||
Mockito.verify(candidateRepository, Mockito.never()).save(anyCandidate())
|
||||
}
|
||||
|
||||
private fun service(globalCaptureEnabled: Boolean) = LeaderResearchSourceService(
|
||||
candidateRepository = candidateRepository,
|
||||
leaderRepository = leaderRepository,
|
||||
leaderPoolRepository = leaderPoolRepository,
|
||||
activityEventRepository = activityEventRepository,
|
||||
sourceHealthService = sourceHealthService,
|
||||
systemConfigRepository = systemConfigRepository,
|
||||
retrofitFactory = retrofitFactory,
|
||||
eventService = eventService,
|
||||
ingestionService = ingestionService,
|
||||
backfillLimit = 200,
|
||||
globalCaptureEnabled = globalCaptureEnabled
|
||||
)
|
||||
|
||||
private fun stubCommonDataApi(success: Boolean) {
|
||||
Mockito.`when`(retrofitFactory.createDataApi()).thenReturn(dataApi)
|
||||
runBlocking {
|
||||
if (success) {
|
||||
Mockito.`when`(
|
||||
dataApi.getUserActivity(
|
||||
user = Mockito.anyString(),
|
||||
limit = Mockito.anyInt(),
|
||||
offset = Mockito.isNull(),
|
||||
market = Mockito.isNull(),
|
||||
eventId = Mockito.isNull(),
|
||||
type = anyStringList(),
|
||||
start = Mockito.anyLong(),
|
||||
end = Mockito.anyLong(),
|
||||
sortBy = Mockito.anyString(),
|
||||
sortDirection = Mockito.anyString(),
|
||||
side = Mockito.isNull()
|
||||
)
|
||||
).thenReturn(Response.success(emptyList<UserActivityResponse>()))
|
||||
} else {
|
||||
Mockito.`when`(
|
||||
dataApi.getUserActivity(
|
||||
user = Mockito.anyString(),
|
||||
limit = Mockito.anyInt(),
|
||||
offset = Mockito.isNull(),
|
||||
market = Mockito.isNull(),
|
||||
eventId = Mockito.isNull(),
|
||||
type = anyStringList(),
|
||||
start = Mockito.anyLong(),
|
||||
end = Mockito.anyLong(),
|
||||
sortBy = Mockito.anyString(),
|
||||
sortDirection = Mockito.anyString(),
|
||||
side = Mockito.isNull()
|
||||
)
|
||||
).thenThrow(IllegalStateException("timeout"))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun activityEvent(wallet: String) = LeaderActivityEvent(
|
||||
source = "ACTIVITY_DERIVED",
|
||||
stableEventKey = "event-$wallet",
|
||||
normalizedWallet = wallet,
|
||||
eventTime = System.currentTimeMillis(),
|
||||
rawPayloadHash = "hash",
|
||||
usableForDiscovery = true
|
||||
)
|
||||
|
||||
private fun anyCandidate(): LeaderResearchCandidate {
|
||||
Mockito.any(LeaderResearchCandidate::class.java)
|
||||
return LeaderResearchCandidate(normalizedWallet = "0x1111111111111111111111111111111111111111")
|
||||
}
|
||||
|
||||
private fun anyResearchStates(): Collection<com.wrbug.polymarketbot.enums.LeaderResearchState> {
|
||||
Mockito.anyCollection<com.wrbug.polymarketbot.enums.LeaderResearchState>()
|
||||
return emptyList()
|
||||
}
|
||||
|
||||
private fun anyStringList(): List<String> {
|
||||
Mockito.anyList<String>()
|
||||
return emptyList()
|
||||
}
|
||||
|
||||
@Suppress("UNCHECKED_CAST")
|
||||
private inline fun <reified T> mock(): T = Mockito.mock(T::class.java)
|
||||
}
|
||||
+83
@@ -0,0 +1,83 @@
|
||||
package com.wrbug.polymarketbot.service.copytrading.research
|
||||
|
||||
import com.wrbug.polymarketbot.entity.LeaderResearchCandidate
|
||||
import com.wrbug.polymarketbot.enums.LeaderResearchState
|
||||
import com.wrbug.polymarketbot.repository.LeaderPaperSessionRepository
|
||||
import com.wrbug.polymarketbot.repository.LeaderResearchCandidateRepository
|
||||
import org.junit.jupiter.api.Assertions.assertEquals
|
||||
import org.junit.jupiter.api.Test
|
||||
import org.mockito.Mockito
|
||||
|
||||
class LeaderResearchStateMachineTest {
|
||||
private val candidateRepository: LeaderResearchCandidateRepository = mock()
|
||||
private val paperSessionRepository: LeaderPaperSessionRepository = mock()
|
||||
private val paperTradingService: LeaderPaperTradingService = mock()
|
||||
private val poolMappingService: LeaderResearchPoolMappingService = mock()
|
||||
private val eventService: LeaderResearchEventService = mock()
|
||||
private val stateMachine = LeaderResearchStateMachine(
|
||||
candidateRepository,
|
||||
paperSessionRepository,
|
||||
paperTradingService,
|
||||
poolMappingService,
|
||||
eventService
|
||||
)
|
||||
|
||||
@Test
|
||||
fun `fresh discovered agent candidate can bootstrap into candidate for paper observation`() {
|
||||
val candidate = LeaderResearchCandidate(
|
||||
id = 1L,
|
||||
normalizedWallet = "0x1111111111111111111111111111111111111111",
|
||||
researchState = LeaderResearchState.DISCOVERED,
|
||||
lastSourceSeenAt = System.currentTimeMillis(),
|
||||
agentOwned = true
|
||||
)
|
||||
Mockito.`when`(paperSessionRepository.findTopByCandidateIdOrderByStartedAtDesc(1L)).thenReturn(null)
|
||||
Mockito.`when`(candidateRepository.save(anyCandidate())).thenAnswer { it.arguments[0] }
|
||||
Mockito.`when`(poolMappingService.syncCandidate(anyCandidate())).thenAnswer { it.arguments[0] }
|
||||
|
||||
val result = stateMachine.advance(candidate, runId = 99L)
|
||||
|
||||
assertEquals(LeaderResearchState.CANDIDATE, result.researchState)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `locked candidate is not automatically advanced`() {
|
||||
val candidate = LeaderResearchCandidate(
|
||||
id = 1L,
|
||||
normalizedWallet = "0x1111111111111111111111111111111111111111",
|
||||
researchState = LeaderResearchState.DISCOVERED,
|
||||
lastSourceSeenAt = System.currentTimeMillis(),
|
||||
locked = true
|
||||
)
|
||||
val result = stateMachine.advance(candidate, runId = 99L)
|
||||
|
||||
assertEquals(LeaderResearchState.DISCOVERED, result.researchState)
|
||||
Mockito.verify(candidateRepository, Mockito.never()).save(anyCandidate())
|
||||
Mockito.verify(poolMappingService, Mockito.never()).syncCandidate(anyCandidate())
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `unchanged discovered candidate does not sync into leader pool`() {
|
||||
val candidate = LeaderResearchCandidate(
|
||||
id = 1L,
|
||||
normalizedWallet = "0x1111111111111111111111111111111111111111",
|
||||
researchState = LeaderResearchState.DISCOVERED,
|
||||
lastSourceSeenAt = System.currentTimeMillis() - 7L * 24 * 60 * 60 * 1000,
|
||||
agentOwned = false
|
||||
)
|
||||
Mockito.`when`(paperSessionRepository.findTopByCandidateIdOrderByStartedAtDesc(1L)).thenReturn(null)
|
||||
|
||||
val result = stateMachine.advance(candidate, runId = 99L)
|
||||
|
||||
assertEquals(LeaderResearchState.DISCOVERED, result.researchState)
|
||||
Mockito.verify(poolMappingService, Mockito.never()).syncCandidate(anyCandidate())
|
||||
}
|
||||
|
||||
private fun anyCandidate(): LeaderResearchCandidate {
|
||||
Mockito.any(LeaderResearchCandidate::class.java)
|
||||
return LeaderResearchCandidate(normalizedWallet = "0x1111111111111111111111111111111111111111")
|
||||
}
|
||||
|
||||
@Suppress("UNCHECKED_CAST")
|
||||
private inline fun <reified T> mock(): T = Mockito.mock(T::class.java)
|
||||
}
|
||||
+266
@@ -0,0 +1,266 @@
|
||||
package com.wrbug.polymarketbot.service.copytrading.statistics
|
||||
|
||||
import com.wrbug.polymarketbot.entity.CopyOrderTracking
|
||||
import com.wrbug.polymarketbot.entity.SellMatchDetail
|
||||
import com.wrbug.polymarketbot.entity.SellMatchRecord
|
||||
import org.junit.jupiter.api.Assertions.assertEquals
|
||||
import org.junit.jupiter.api.Test
|
||||
import java.math.BigDecimal
|
||||
|
||||
class CopyTradingPnlCalculatorTest {
|
||||
|
||||
@Test
|
||||
fun `marks open positions with current prices and combines realized and unrealized pnl`() {
|
||||
val buyOrders = listOf(
|
||||
buyOrder(
|
||||
id = 1,
|
||||
marketId = "market-a",
|
||||
outcomeIndex = 0,
|
||||
quantity = "10",
|
||||
price = "0.60",
|
||||
matchedQuantity = "6",
|
||||
remainingQuantity = "4"
|
||||
),
|
||||
buyOrder(
|
||||
id = 2,
|
||||
marketId = "market-b",
|
||||
outcomeIndex = 1,
|
||||
quantity = "5",
|
||||
price = "0.20",
|
||||
matchedQuantity = "0",
|
||||
remainingQuantity = "5"
|
||||
)
|
||||
)
|
||||
val sellRecords = listOf(
|
||||
sellRecord(quantity = "6", price = "0.85", pnl = "1.50")
|
||||
)
|
||||
val matchDetails = listOf(
|
||||
matchDetail(trackingId = 1, buyOrderId = "buy-1", quantity = "6", buyPrice = "0.60", sellPrice = "0.85", pnl = "1.50")
|
||||
)
|
||||
val quotes = listOf(
|
||||
PositionValuationQuote(marketId = "market-a", outcomeIndex = 0, side = "0", currentPrice = bd("0.40")),
|
||||
PositionValuationQuote(marketId = "market-b", outcomeIndex = 1, side = "1", currentPrice = bd("0.05"))
|
||||
)
|
||||
|
||||
val stats = CopyTradingPnlCalculator.calculate(buyOrders, sellRecords, matchDetails, quotes)
|
||||
|
||||
assertEquals("3.40", stats.currentPositionCost.toPlainString())
|
||||
assertEquals("1.85", stats.currentPositionValue.toPlainString())
|
||||
assertEquals("-1.55", stats.totalUnrealizedPnl.toPlainString())
|
||||
assertEquals("1.50", stats.totalRealizedPnl.toPlainString())
|
||||
assertEquals("-0.05", stats.totalPnl.toPlainString())
|
||||
assertEquals("-0.71", stats.totalPnlPercent.toPlainString())
|
||||
assertEquals(PositionQuoteStatus.AVAILABLE, stats.quoteStatusSummary.overallStatus)
|
||||
assertEquals(2, stats.quoteStatusSummary.availableCount)
|
||||
assertEquals(0, stats.quoteStatusSummary.noMatchCount)
|
||||
assertEquals(0, stats.quoteStatusSummary.unavailableCount)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `reports no match separately from available zero valuation`() {
|
||||
val buyOrders = listOf(
|
||||
buyOrder(
|
||||
id = 1,
|
||||
marketId = "expired-market",
|
||||
outcomeIndex = 0,
|
||||
quantity = "8",
|
||||
price = "0.25",
|
||||
matchedQuantity = "0",
|
||||
remainingQuantity = "8"
|
||||
)
|
||||
)
|
||||
|
||||
val stats = CopyTradingPnlCalculator.calculate(
|
||||
buyOrders = buyOrders,
|
||||
sellRecords = emptyList(),
|
||||
matchDetails = emptyList(),
|
||||
quotes = emptyList()
|
||||
)
|
||||
|
||||
assertEquals("2.00", stats.currentPositionCost.toPlainString())
|
||||
assertEquals("0", stats.currentPositionValue.toPlainString())
|
||||
assertEquals("-2.00", stats.totalUnrealizedPnl.toPlainString())
|
||||
assertEquals("-2.00", stats.totalPnl.toPlainString())
|
||||
assertEquals("-100.00", stats.totalPnlPercent.toPlainString())
|
||||
assertEquals(PositionQuoteStatus.NO_MATCH, stats.quoteStatusSummary.overallStatus)
|
||||
assertEquals(0, stats.quoteStatusSummary.availableCount)
|
||||
assertEquals(1, stats.quoteStatusSummary.noMatchCount)
|
||||
assertEquals(0, stats.quoteStatusSummary.unavailableCount)
|
||||
assertEquals("2.00", stats.zeroValuePositionCost.toPlainString())
|
||||
assertEquals("0", stats.confirmedZeroValuePositionCost.toPlainString())
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `reports unavailable quotes separately from confirmed zero valuation`() {
|
||||
val buyOrders = listOf(
|
||||
buyOrder(
|
||||
id = 1,
|
||||
marketId = "unavailable-market",
|
||||
outcomeIndex = 0,
|
||||
quantity = "8",
|
||||
price = "0.25",
|
||||
matchedQuantity = "0",
|
||||
remainingQuantity = "8"
|
||||
)
|
||||
)
|
||||
|
||||
val stats = CopyTradingPnlCalculator.calculate(
|
||||
buyOrders = buyOrders,
|
||||
sellRecords = emptyList(),
|
||||
matchDetails = emptyList(),
|
||||
quotes = listOf(
|
||||
PositionValuationQuote.unavailable(reason = "positions timeout")
|
||||
)
|
||||
)
|
||||
|
||||
assertEquals("2.00", stats.currentPositionCost.toPlainString())
|
||||
assertEquals("0", stats.currentPositionValue.toPlainString())
|
||||
assertEquals(PositionQuoteStatus.UNAVAILABLE, stats.quoteStatusSummary.overallStatus)
|
||||
assertEquals(0, stats.quoteStatusSummary.availableCount)
|
||||
assertEquals(0, stats.quoteStatusSummary.noMatchCount)
|
||||
assertEquals(1, stats.quoteStatusSummary.unavailableCount)
|
||||
assertEquals("2.00", stats.zeroValuePositionCost.toPlainString())
|
||||
assertEquals("0", stats.confirmedZeroValuePositionCost.toPlainString())
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `counts available zero price as confirmed zero valuation`() {
|
||||
val buyOrders = listOf(
|
||||
buyOrder(
|
||||
id = 1,
|
||||
marketId = "settled-market",
|
||||
outcomeIndex = 0,
|
||||
quantity = "8",
|
||||
price = "0.25",
|
||||
matchedQuantity = "0",
|
||||
remainingQuantity = "8"
|
||||
)
|
||||
)
|
||||
|
||||
val stats = CopyTradingPnlCalculator.calculate(
|
||||
buyOrders = buyOrders,
|
||||
sellRecords = emptyList(),
|
||||
matchDetails = emptyList(),
|
||||
quotes = listOf(
|
||||
PositionValuationQuote(marketId = "settled-market", outcomeIndex = 0, side = "0", currentPrice = BigDecimal.ZERO)
|
||||
)
|
||||
)
|
||||
|
||||
assertEquals("2.00", stats.currentPositionCost.toPlainString())
|
||||
assertEquals("0", stats.currentPositionValue.toPlainString())
|
||||
assertEquals(PositionQuoteStatus.AVAILABLE, stats.quoteStatusSummary.overallStatus)
|
||||
assertEquals(1, stats.quoteStatusSummary.availableCount)
|
||||
assertEquals(0, stats.quoteStatusSummary.noMatchCount)
|
||||
assertEquals(0, stats.quoteStatusSummary.unavailableCount)
|
||||
assertEquals("2.00", stats.zeroValuePositionCost.toPlainString())
|
||||
assertEquals("2.00", stats.confirmedZeroValuePositionCost.toPlainString())
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `summarizes mixed available no match and unavailable quote states`() {
|
||||
val buyOrders = listOf(
|
||||
buyOrder(
|
||||
id = 1,
|
||||
marketId = "settled-market",
|
||||
outcomeIndex = 0,
|
||||
quantity = "8",
|
||||
price = "0.25",
|
||||
matchedQuantity = "0",
|
||||
remainingQuantity = "8"
|
||||
),
|
||||
buyOrder(
|
||||
id = 2,
|
||||
marketId = "missing-market",
|
||||
outcomeIndex = 0,
|
||||
quantity = "4",
|
||||
price = "0.50",
|
||||
matchedQuantity = "0",
|
||||
remainingQuantity = "4"
|
||||
)
|
||||
)
|
||||
|
||||
val stats = CopyTradingPnlCalculator.calculate(
|
||||
buyOrders = buyOrders,
|
||||
sellRecords = emptyList(),
|
||||
matchDetails = emptyList(),
|
||||
quotes = listOf(
|
||||
PositionValuationQuote(marketId = "settled-market", outcomeIndex = 0, side = "0", currentPrice = BigDecimal.ZERO),
|
||||
PositionValuationQuote.unavailable(reason = "positions timeout")
|
||||
)
|
||||
)
|
||||
|
||||
assertEquals("4.00", stats.currentPositionCost.toPlainString())
|
||||
assertEquals("0", stats.currentPositionValue.toPlainString())
|
||||
assertEquals(PositionQuoteStatus.UNAVAILABLE, stats.quoteStatusSummary.overallStatus)
|
||||
assertEquals(1, stats.quoteStatusSummary.availableCount)
|
||||
assertEquals(0, stats.quoteStatusSummary.noMatchCount)
|
||||
assertEquals(1, stats.quoteStatusSummary.unavailableCount)
|
||||
assertEquals("4.00", stats.zeroValuePositionCost.toPlainString())
|
||||
assertEquals("2.00", stats.confirmedZeroValuePositionCost.toPlainString())
|
||||
}
|
||||
|
||||
private fun buyOrder(
|
||||
id: Long,
|
||||
marketId: String,
|
||||
outcomeIndex: Int?,
|
||||
quantity: String,
|
||||
price: String,
|
||||
matchedQuantity: String,
|
||||
remainingQuantity: String
|
||||
) = CopyOrderTracking(
|
||||
id = id,
|
||||
copyTradingId = 1,
|
||||
accountId = 1,
|
||||
leaderId = 1,
|
||||
marketId = marketId,
|
||||
side = outcomeIndex?.toString() ?: "YES",
|
||||
outcomeIndex = outcomeIndex,
|
||||
buyOrderId = "buy-$id",
|
||||
leaderBuyTradeId = "leader-buy-$id",
|
||||
leaderBuyQuantity = null,
|
||||
quantity = bd(quantity),
|
||||
price = bd(price),
|
||||
matchedQuantity = bd(matchedQuantity),
|
||||
remainingQuantity = bd(remainingQuantity),
|
||||
status = if (bd(remainingQuantity).signum() == 0) "fully_matched" else "filled",
|
||||
source = "test",
|
||||
createdAt = id,
|
||||
updatedAt = id
|
||||
)
|
||||
|
||||
private fun sellRecord(quantity: String, price: String, pnl: String) = SellMatchRecord(
|
||||
id = 1,
|
||||
copyTradingId = 1,
|
||||
sellOrderId = "sell-1",
|
||||
leaderSellTradeId = "leader-sell-1",
|
||||
marketId = "market-a",
|
||||
side = "0",
|
||||
outcomeIndex = 0,
|
||||
totalMatchedQuantity = bd(quantity),
|
||||
sellPrice = bd(price),
|
||||
totalRealizedPnl = bd(pnl),
|
||||
priceUpdated = true,
|
||||
createdAt = 1
|
||||
)
|
||||
|
||||
private fun matchDetail(
|
||||
trackingId: Long,
|
||||
buyOrderId: String,
|
||||
quantity: String,
|
||||
buyPrice: String,
|
||||
sellPrice: String,
|
||||
pnl: String
|
||||
) = SellMatchDetail(
|
||||
id = trackingId,
|
||||
matchRecordId = 1,
|
||||
trackingId = trackingId,
|
||||
buyOrderId = buyOrderId,
|
||||
matchedQuantity = bd(quantity),
|
||||
buyPrice = bd(buyPrice),
|
||||
sellPrice = bd(sellPrice),
|
||||
realizedPnl = bd(pnl),
|
||||
createdAt = 1
|
||||
)
|
||||
|
||||
private fun bd(value: String) = BigDecimal(value)
|
||||
}
|
||||
+177
@@ -0,0 +1,177 @@
|
||||
package com.wrbug.polymarketbot.service.copytrading.statistics
|
||||
|
||||
import com.wrbug.polymarketbot.entity.CopyOrderTracking
|
||||
import com.wrbug.polymarketbot.entity.CopyTrading
|
||||
import com.wrbug.polymarketbot.entity.SellMatchDetail
|
||||
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 CopyTradingRiskDiagnosisServiceTest {
|
||||
|
||||
@Test
|
||||
fun `builds loss attribution with top losing markets and quote completeness`() {
|
||||
val buyOrders = listOf(
|
||||
buyOrder(id = 1, marketId = "market-a", quantity = "10", price = "0.60", remainingQuantity = "4"),
|
||||
buyOrder(id = 2, marketId = "market-b", quantity = "5", price = "0.20", remainingQuantity = "5")
|
||||
)
|
||||
val matchDetails = listOf(
|
||||
matchDetail(trackingId = 1, buyOrderId = "buy-1", sellPrice = "0", pnl = "-3.60"),
|
||||
matchDetail(trackingId = 2, buyOrderId = "buy-2", sellPrice = "0.50", pnl = "1.50")
|
||||
)
|
||||
val pnl = CopyTradingPnlCalculator.calculate(
|
||||
buyOrders = buyOrders,
|
||||
sellRecords = emptyList(),
|
||||
matchDetails = matchDetails,
|
||||
quotes = listOf(
|
||||
PositionValuationQuote(marketId = "market-a", outcomeIndex = 0, side = "0", currentPrice = BigDecimal.ZERO),
|
||||
PositionValuationQuote.unavailable("positions timeout")
|
||||
)
|
||||
)
|
||||
|
||||
val diagnosis = CopyTradingRiskDiagnosisService.buildDiagnosis(
|
||||
copyTrading = riskyCopyTrading(),
|
||||
buyOrders = buyOrders,
|
||||
sellRecordsCount = 0,
|
||||
matchDetails = matchDetails,
|
||||
filteredOrderCount = 0,
|
||||
pnl = pnl,
|
||||
generatedAt = 1234
|
||||
)
|
||||
|
||||
assertEquals("UNAVAILABLE", diagnosis.quoteOverallStatus)
|
||||
assertTrue(diagnosis.dataIncomplete)
|
||||
assertEquals(2, diagnosis.totalBuyOrders)
|
||||
assertEquals(2, diagnosis.sampleSize)
|
||||
assertEquals("3.40", diagnosis.zeroValuePositionCost)
|
||||
assertEquals("2.40", diagnosis.confirmedZeroValuePositionCost)
|
||||
assertEquals("3.60", diagnosis.zeroSellLoss)
|
||||
assertEquals("market-a", diagnosis.topLosingMarkets.first().marketId)
|
||||
assertEquals("-3.60", diagnosis.topLosingMarkets.first().realizedPnl)
|
||||
assertTrue(diagnosis.riskWarnings.any { it.field == "maxDailyOrders" && it.severity == RiskSeverity.HIGH.name })
|
||||
assertEquals(1234, diagnosis.generatedAt)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `marks profitable tiny samples as low confidence`() {
|
||||
val buyOrders = listOf(
|
||||
buyOrder(id = 1, marketId = "market-a", quantity = "10", price = "0.40", remainingQuantity = "0"),
|
||||
buyOrder(id = 2, marketId = "market-b", quantity = "10", price = "0.40", remainingQuantity = "0")
|
||||
)
|
||||
val matchDetails = listOf(
|
||||
matchDetail(trackingId = 1, buyOrderId = "buy-1", sellPrice = "0.80", pnl = "4.00"),
|
||||
matchDetail(trackingId = 2, buyOrderId = "buy-2", sellPrice = "0.60", pnl = "2.00")
|
||||
)
|
||||
val pnl = CopyTradingPnlCalculator.calculate(
|
||||
buyOrders = buyOrders,
|
||||
sellRecords = emptyList(),
|
||||
matchDetails = matchDetails,
|
||||
quotes = emptyList()
|
||||
)
|
||||
|
||||
val diagnosis = CopyTradingRiskDiagnosisService.buildDiagnosis(
|
||||
copyTrading = conservativeCopyTrading(),
|
||||
buyOrders = buyOrders,
|
||||
sellRecordsCount = 0,
|
||||
matchDetails = matchDetails,
|
||||
filteredOrderCount = 3,
|
||||
pnl = pnl,
|
||||
generatedAt = 1234
|
||||
)
|
||||
|
||||
assertTrue(diagnosis.lowConfidence)
|
||||
assertTrue(diagnosis.confidenceReason.contains("样本"))
|
||||
assertEquals("6.00", diagnosis.totalPnl)
|
||||
assertEquals(3, diagnosis.filteredOrderCount)
|
||||
assertTrue(diagnosis.riskWarnings.all { it.severity != RiskSeverity.HIGH.name })
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `returns field level conservative suggestions for unsafe config`() {
|
||||
val warnings = CopyTradingRiskDiagnosisService.inspectRiskConfig(riskyCopyTrading())
|
||||
|
||||
assertTrue(warnings.any { it.field == "maxDailyLoss" && it.currentValue == "10000" && it.suggestedValue == "10" })
|
||||
assertTrue(warnings.any { it.field == "minPrice" && it.currentValue == null && it.suggestedValue == "0.10" })
|
||||
assertTrue(warnings.any { it.field == "maxPrice" && it.currentValue == null && it.suggestedValue == "0.80" })
|
||||
assertTrue(warnings.any { it.field == "maxPositionValue" && it.currentValue == null && it.suggestedValue == "10" })
|
||||
assertTrue(warnings.any { it.field == "minOrderDepth" })
|
||||
assertTrue(warnings.any { it.field == "maxSpread" })
|
||||
}
|
||||
|
||||
private fun riskyCopyTrading() = CopyTrading(
|
||||
id = 1,
|
||||
accountId = 1,
|
||||
leaderId = 1,
|
||||
fixedAmount = bd("1"),
|
||||
maxDailyLoss = bd("10000"),
|
||||
maxDailyOrders = 100,
|
||||
priceTolerance = bd("5"),
|
||||
minPrice = null,
|
||||
maxPrice = null,
|
||||
maxPositionValue = null,
|
||||
minOrderDepth = null,
|
||||
maxSpread = null
|
||||
)
|
||||
|
||||
private fun conservativeCopyTrading() = CopyTrading(
|
||||
id = 1,
|
||||
accountId = 1,
|
||||
leaderId = 1,
|
||||
fixedAmount = bd("1"),
|
||||
maxDailyLoss = bd("5"),
|
||||
maxDailyOrders = 10,
|
||||
priceTolerance = bd("2"),
|
||||
minPrice = bd("0.10"),
|
||||
maxPrice = bd("0.80"),
|
||||
maxPositionValue = bd("5"),
|
||||
minOrderDepth = bd("100"),
|
||||
maxSpread = bd("0.03")
|
||||
)
|
||||
|
||||
private fun buyOrder(
|
||||
id: Long,
|
||||
marketId: String,
|
||||
quantity: String,
|
||||
price: String,
|
||||
remainingQuantity: String
|
||||
) = CopyOrderTracking(
|
||||
id = id,
|
||||
copyTradingId = 1,
|
||||
accountId = 1,
|
||||
leaderId = 1,
|
||||
marketId = marketId,
|
||||
side = "0",
|
||||
outcomeIndex = 0,
|
||||
buyOrderId = "buy-$id",
|
||||
leaderBuyTradeId = "leader-buy-$id",
|
||||
leaderBuyQuantity = null,
|
||||
quantity = bd(quantity),
|
||||
price = bd(price),
|
||||
matchedQuantity = bd(quantity).subtract(bd(remainingQuantity)),
|
||||
remainingQuantity = bd(remainingQuantity),
|
||||
status = if (bd(remainingQuantity).signum() == 0) "fully_matched" else "filled",
|
||||
source = "test",
|
||||
createdAt = id,
|
||||
updatedAt = id
|
||||
)
|
||||
|
||||
private fun matchDetail(
|
||||
trackingId: Long,
|
||||
buyOrderId: String,
|
||||
sellPrice: String,
|
||||
pnl: String
|
||||
) = SellMatchDetail(
|
||||
id = trackingId,
|
||||
matchRecordId = 1,
|
||||
trackingId = trackingId,
|
||||
buyOrderId = buyOrderId,
|
||||
matchedQuantity = bd("10"),
|
||||
buyPrice = bd("0.40"),
|
||||
sellPrice = bd(sellPrice),
|
||||
realizedPnl = bd(pnl),
|
||||
createdAt = 1
|
||||
)
|
||||
|
||||
private fun bd(value: String) = BigDecimal(value)
|
||||
}
|
||||
@@ -0,0 +1,206 @@
|
||||
# 跟单亏损诊断与风险安全带
|
||||
|
||||
本文档说明跟单统计页新增的亏损诊断、安全带建议和手动确认流程。第一阶段只做诊断和保守配置应用,不做自动换 leader、不做自动暂停,也不做收益预测。
|
||||
|
||||
## 一、诊断口径
|
||||
|
||||
### 1.1 PnL 分解
|
||||
|
||||
诊断结果基于现有跟单订单、卖出匹配记录、过滤记录和当前持仓报价生成,主要字段包括:
|
||||
|
||||
- 已实现 PnL:来自已匹配卖出记录的真实盈亏。
|
||||
- 未实现 PnL:当前持仓估值减去未平仓成本。
|
||||
- 总 PnL:已实现 PnL 加未实现 PnL。
|
||||
- 持仓成本:当前未平仓买入成本。
|
||||
- 持仓估值:当前可用报价计算出的持仓市值。
|
||||
- 归零或未知持仓成本:当前估值为 0、未匹配到报价或报价不可用的持仓成本。
|
||||
- 已确认归零成本:只有报价状态为 `AVAILABLE` 且当前价格为 0 的持仓才计入。
|
||||
- 卖出归零亏损:已实现亏损中按市场聚合出的归因结果。
|
||||
|
||||
### 1.2 报价状态
|
||||
|
||||
第一阶段不再把所有“没有价格”的情况都当作 0:
|
||||
|
||||
- `AVAILABLE`:报价可用,价格可以作为诊断依据。
|
||||
- `NO_MATCH`:持仓存在,但没有在当前报价列表中匹配到对应 token。
|
||||
- `UNAVAILABLE`:外部报价接口失败、超时或异常。
|
||||
|
||||
如果存在 `NO_MATCH` 或 `UNAVAILABLE`,页面会显示数据不完整提示。旧统计字段仍保持兼容返回,但前端必须展示报价状态,避免把未知估值误读成确定亏损。
|
||||
|
||||
### 1.3 低样本置信度
|
||||
|
||||
当样本量过小但总 PnL 为正时,诊断会标记为低置信度,避免把少量盈利订单误判成稳定盈利 leader。当前第一版阈值是样本量小于 10。
|
||||
|
||||
## 二、风险安全带
|
||||
|
||||
### 2.1 检查字段
|
||||
|
||||
安全带检查以下风控字段:
|
||||
|
||||
- `maxDailyOrders`
|
||||
- `maxDailyLoss`
|
||||
- `minPrice`
|
||||
- `maxPrice`
|
||||
- `maxPositionValue`
|
||||
- `minOrderDepth`
|
||||
- `maxSpread`
|
||||
- `priceTolerance`
|
||||
- `supportSell`
|
||||
|
||||
每条建议会返回字段名、当前值、建议值、严重程度和中文原因。建议只是降低尾部风险的安全带,不代表收益承诺。
|
||||
|
||||
### 2.2 第一阶段保守建议
|
||||
|
||||
第一阶段使用固定保守区间:
|
||||
|
||||
- `maxDailyOrders`:不超过 20。
|
||||
- `maxDailyLoss`:不超过 10。
|
||||
- `minPrice`:建议 0.10。
|
||||
- `maxPrice`:建议 0.80。
|
||||
- `maxPositionValue`:建议 10。
|
||||
- `minOrderDepth`:建议 100。
|
||||
- `maxSpread`:建议 0.03。
|
||||
- `priceTolerance`:不超过 3。
|
||||
|
||||
## 三、手动确认流程
|
||||
|
||||
用户在统计页或统计弹窗中点击“应用保守配置”后,前端会展示字段级 diff。只有用户在确认弹窗中点击“确认应用”时,才会调用后端保存接口。
|
||||
|
||||
接口:
|
||||
|
||||
```http
|
||||
POST /api/copy-trading/configs/apply-conservative-config
|
||||
```
|
||||
|
||||
请求必须包含:
|
||||
|
||||
```json
|
||||
{
|
||||
"copyTradingId": 1,
|
||||
"confirm": true
|
||||
}
|
||||
```
|
||||
|
||||
后端只接受白名单字段,不会修改以下真实交易行为:
|
||||
|
||||
- leader
|
||||
- account
|
||||
- enabled
|
||||
- copyMode
|
||||
- fixedAmount
|
||||
- copyRatio
|
||||
- supportSell
|
||||
|
||||
如果 `confirm` 不是 `true`,后端返回业务错误,不保存任何配置。
|
||||
|
||||
## 四、Mac mini 排查命令
|
||||
|
||||
### 4.1 登录和容器状态
|
||||
|
||||
```bash
|
||||
ssh m4
|
||||
cd ~/polyhermes
|
||||
docker compose ps
|
||||
docker compose logs -f app
|
||||
docker compose logs -f mysql
|
||||
```
|
||||
|
||||
如果生产环境使用的是旧版 compose 命令:
|
||||
|
||||
```bash
|
||||
docker-compose ps
|
||||
docker-compose logs -f app
|
||||
docker-compose logs -f mysql
|
||||
```
|
||||
|
||||
### 4.2 MySQL 容器排查
|
||||
|
||||
```bash
|
||||
docker exec -it polyhermes-mysql mysql -uroot -p
|
||||
SHOW DATABASES;
|
||||
USE polyhermes;
|
||||
SHOW TABLES;
|
||||
```
|
||||
|
||||
常用只读检查:
|
||||
|
||||
```sql
|
||||
SELECT id, account_id, leader_id, enabled, max_daily_orders, max_daily_loss
|
||||
FROM copy_trading
|
||||
ORDER BY id DESC
|
||||
LIMIT 20;
|
||||
```
|
||||
|
||||
### 4.3 后端接口和日志
|
||||
|
||||
```bash
|
||||
curl -s http://127.0.0.1/api/copy-trading/statistics/detail \
|
||||
-H 'Content-Type: application/json' \
|
||||
-d '{"copyTradingId":1}' | jq
|
||||
```
|
||||
|
||||
观察日志时重点搜索:
|
||||
|
||||
```bash
|
||||
docker compose logs app | grep -E '应用保守配置|报价|CopyTradingRiskDiagnosis|statistics'
|
||||
```
|
||||
|
||||
### 4.4 非交互 PATH 注意事项
|
||||
|
||||
在非交互 shell、CI 或远程命令中,不要假设 `java`、`node`、`docker compose` 一定在 PATH 中。必要时显式指定:
|
||||
|
||||
```bash
|
||||
export JAVA_HOME=/opt/homebrew/opt/openjdk@17
|
||||
export PATH="$JAVA_HOME/bin:$PATH"
|
||||
```
|
||||
|
||||
本地后端测试命令:
|
||||
|
||||
```bash
|
||||
cd backend
|
||||
JAVA_HOME=/opt/homebrew/opt/openjdk@17 PATH="/opt/homebrew/opt/openjdk@17/bin:$PATH" ./gradlew test
|
||||
```
|
||||
|
||||
前端构建命令:
|
||||
|
||||
```bash
|
||||
cd frontend
|
||||
npm ci
|
||||
npm run build
|
||||
```
|
||||
|
||||
## 五、本地验证记录
|
||||
|
||||
本次第一阶段本地已验证:
|
||||
|
||||
- 后端 PnL 计算、亏损诊断、保守配置和 controller 测试通过。
|
||||
- 前端 TypeScript 和 Vite 生产构建通过。
|
||||
- 前端构建仍有既有大 chunk 和 `api.ts` 动静态混合 import 警告,不阻断发布。
|
||||
- `npm ci` 报告既有依赖审计问题,需要单独评估,避免在本次安全带改动里做破坏性升级。
|
||||
|
||||
## 六、部署前和部署后验证
|
||||
|
||||
部署前建议用测试环境或本地脱敏数据抽查:
|
||||
|
||||
- 旧统计字段和新增诊断字段的 PnL 数字是否一致。
|
||||
- `UNAVAILABLE` 是否显示为“报价不可用”,而不是 0 亏损。
|
||||
- 点击取消确认弹窗时不发保存请求。
|
||||
- 点击确认后只修改白名单字段。
|
||||
- 低样本盈利 leader 是否显示低置信度。
|
||||
|
||||
部署后观察 24-72 小时:
|
||||
|
||||
- 诊断日志是否稳定生成。
|
||||
- 页面是否仍能兼容旧统计字段。
|
||||
- 是否出现把 `UNAVAILABLE` 展示成已确认归零的情况。
|
||||
- 是否有任何非用户确认触发的配置修改。
|
||||
|
||||
## 七、第二阶段边界
|
||||
|
||||
以下能力明确不在第一阶段:
|
||||
|
||||
- leader 池状态机:`ACTIVE`、`WATCH`、`COOLDOWN`、`RETIRED`、`LOCKED`。
|
||||
- leader 推荐记录表、推荐详情页、忽略/锁定/应用推荐动作。
|
||||
- 官方 Polymarket leaderboard 候选发现和定时同步。
|
||||
- 自动新增、删除、启用、停用 leader 或跟单配置。
|
||||
- 自动暂停、自动加仓、组合优化、跨钱包资金调度或收益预测。
|
||||
@@ -0,0 +1,49 @@
|
||||
# Leader 池
|
||||
|
||||
`Leader 池` 是 `Leader 管理` 和 `跟单配置` 之间的决策层。它用于管理小仓位、分散试验策略里的候选 leader,帮助你先观察和分层,再决定是否创建真实跟单配置。
|
||||
|
||||
## 三个入口的区别
|
||||
|
||||
| 入口 | 负责什么 | 是否会产生真实跟单配置 |
|
||||
| --- | --- | --- |
|
||||
| Leader 管理 | 保存可被跟单的钱包地址、备注、网站和分类 | 不会 |
|
||||
| Leader 池 | 维护候选、观察、小额试跟、冷却、淘汰等策略状态和建议风控参数 | 只有点击创建试跟配置时才会 |
|
||||
| 跟单配置 | 管理真实跟单关系、启停和订单过滤参数 | 会 |
|
||||
|
||||
## v1 安全边界
|
||||
|
||||
- 不会自动加仓。
|
||||
- 不会自动启用大额跟单。
|
||||
- 不会自动抓取 Polymarket leaderboard。
|
||||
- 不会因为加入池子或更新状态而自动创建真实跟单配置。
|
||||
- 从池子创建的小额试跟配置默认禁用,使用 `FIXED` 模式和保守参数。
|
||||
|
||||
## 默认试跟参数
|
||||
|
||||
从 Leader 池创建试跟配置时,默认使用:
|
||||
|
||||
- 固定金额:`1`
|
||||
- 每日最大单数:`10`
|
||||
- 每日最大亏损:`5`
|
||||
- 价格容忍度:`1`
|
||||
- 最低价格:`0.10`
|
||||
- 最高价格:`0.80`
|
||||
- 最大持仓:`5`
|
||||
- 支持卖出:开启
|
||||
- 失败订单和被过滤订单推送:开启
|
||||
|
||||
如果同一账户已经存在同一 leader 的跟单配置,Leader 池会拒绝重复创建。需要高级配置时,应前往 `跟单配置` 页面手动创建。
|
||||
|
||||
## 状态说明
|
||||
|
||||
- `候选`: 刚加入池子的 leader。
|
||||
- `观察`: 值得继续跟踪,但还不创建真实配置。
|
||||
- `小额试跟`: 已创建保守的小额试跟配置。
|
||||
- `冷却`: 暂停关注或等待重新评估,可设置冷却截止时间。
|
||||
- `淘汰`: 不再关注,但不会删除 Leader 地址和已有跟单配置。
|
||||
|
||||
后端还预留 `PAPER` 和 `ACTIVE` 状态,v1 前端暂不暴露,避免把早期工作流变复杂。
|
||||
|
||||
## 预算提示
|
||||
|
||||
页面顶部会展示池子人数、试跟中人数、估算最坏暴露和待处理风险。估算最坏暴露只是提示,不是资金托管,也不会限制账户余额或自动调仓。
|
||||
@@ -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 计入收益。
|
||||
@@ -0,0 +1,29 @@
|
||||
module.exports = {
|
||||
root: true,
|
||||
env: {
|
||||
browser: true,
|
||||
es2020: true,
|
||||
},
|
||||
parser: '@typescript-eslint/parser',
|
||||
parserOptions: {
|
||||
ecmaVersion: 'latest',
|
||||
sourceType: 'module',
|
||||
},
|
||||
plugins: ['@typescript-eslint', 'react-hooks', 'react-refresh'],
|
||||
extends: [
|
||||
'eslint:recommended',
|
||||
'plugin:react-hooks/recommended',
|
||||
],
|
||||
ignorePatterns: ['dist', 'build', 'node_modules', '.eslintrc.cjs'],
|
||||
rules: {
|
||||
// TypeScript 编译阶段已经开启 strict/noUnused*,这里避免历史代码里
|
||||
// 大量 any、依赖数组 warning 让 lint 从“可运行检查”变成一次性大迁移。
|
||||
'react-hooks/exhaustive-deps': 'off',
|
||||
'react-refresh/only-export-components': 'off',
|
||||
'no-undef': 'off',
|
||||
'no-unused-vars': 'off',
|
||||
'no-useless-catch': 'off',
|
||||
'no-useless-escape': 'off',
|
||||
'no-redeclare': 'off',
|
||||
},
|
||||
}
|
||||
@@ -14,6 +14,8 @@ import AccountImport from './pages/AccountImport'
|
||||
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'
|
||||
@@ -265,6 +267,8 @@ function App() {
|
||||
<Route path="/accounts/detail" element={<ProtectedRoute><AccountDetail /></ProtectedRoute>} />
|
||||
<Route path="/accounts/edit" element={<ProtectedRoute><AccountEdit /></ProtectedRoute>} />
|
||||
<Route path="/leaders" element={<ProtectedRoute><LeaderList /></ProtectedRoute>} />
|
||||
<Route path="/leader-pool" element={<ProtectedRoute><LeaderPool /></ProtectedRoute>} />
|
||||
<Route path="/leader-research" element={<ProtectedRoute><LeaderResearch /></ProtectedRoute>} />
|
||||
<Route path="/leaders/add" element={<ProtectedRoute><LeaderAdd /></ProtectedRoute>} />
|
||||
<Route path="/leaders/edit" element={<ProtectedRoute><LeaderEdit /></ProtectedRoute>} />
|
||||
<Route path="/templates" element={<ProtectedRoute><TemplateList /></ProtectedRoute>} />
|
||||
@@ -300,4 +304,3 @@ function App() {
|
||||
}
|
||||
|
||||
export default App
|
||||
|
||||
|
||||
@@ -197,7 +197,6 @@ const AccountImportForm: React.FC<AccountImportFormProps> = ({
|
||||
setSelectedProxyType('')
|
||||
setStep('input')
|
||||
form.setFieldsValue({ walletAddress: '', privateKey: '', mnemonic: '' })
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [importType])
|
||||
|
||||
const handleSubmit = async (values: any) => {
|
||||
|
||||
@@ -0,0 +1,220 @@
|
||||
import { Alert, Button, Card, Col, Modal, Row, Space, Statistic, Table, Tag, message } from 'antd'
|
||||
import { useState } from 'react'
|
||||
import { apiService } from '../services/api'
|
||||
import type { CopyTradingStatistics } from '../types'
|
||||
import { formatUSDC } from '../utils'
|
||||
|
||||
interface Props {
|
||||
statistics: CopyTradingStatistics
|
||||
onApplied?: () => void
|
||||
compact?: boolean
|
||||
}
|
||||
|
||||
const fieldLabels: Record<string, string> = {
|
||||
maxDailyOrders: '每日最大订单数',
|
||||
maxDailyLoss: '每日最大亏损',
|
||||
minPrice: '最低价格',
|
||||
maxPrice: '最高价格',
|
||||
maxPositionValue: '单市场最大仓位',
|
||||
minOrderDepth: '最小深度',
|
||||
maxSpread: '最大价差',
|
||||
priceTolerance: '价格容忍度'
|
||||
}
|
||||
|
||||
const statusText: Record<string, string> = {
|
||||
AVAILABLE: '报价可用',
|
||||
NO_MATCH: '有持仓未匹配到报价',
|
||||
UNAVAILABLE: '报价不可用'
|
||||
}
|
||||
|
||||
const statusColor: Record<string, string> = {
|
||||
AVAILABLE: 'green',
|
||||
NO_MATCH: 'orange',
|
||||
UNAVAILABLE: 'red'
|
||||
}
|
||||
|
||||
type ApplyConservativeConfigPayload = Parameters<typeof apiService.safetyConfig.applyConservative>[0]
|
||||
|
||||
const CopyTradingRiskSeatbeltPanel: React.FC<Props> = ({ statistics, onApplied, compact = false }) => {
|
||||
const diagnosis = statistics.riskDiagnosis
|
||||
const [applying, setApplying] = useState(false)
|
||||
|
||||
if (!diagnosis) {
|
||||
return (
|
||||
<Card title="风险安全带" style={{ marginTop: 16 }}>
|
||||
<Alert type="info" showIcon message="暂无诊断数据" description="旧接口仍可展示基础统计,诊断数据生成后这里会显示亏损归因和风控建议。" />
|
||||
</Card>
|
||||
)
|
||||
}
|
||||
|
||||
const riskWarnings = diagnosis.riskWarnings || []
|
||||
const dangerousWarnings = riskWarnings.filter(item => item.severity === 'HIGH' || item.severity === 'MEDIUM')
|
||||
|
||||
const buildApplyPayload = (): ApplyConservativeConfigPayload => {
|
||||
const payload: ApplyConservativeConfigPayload = {
|
||||
copyTradingId: statistics.copyTradingId,
|
||||
confirm: true
|
||||
}
|
||||
riskWarnings.forEach(item => {
|
||||
if (item.suggestedValue === undefined || item.suggestedValue === null) {
|
||||
return
|
||||
}
|
||||
|
||||
switch (item.field) {
|
||||
case 'maxDailyOrders':
|
||||
payload.maxDailyOrders = Number(item.suggestedValue)
|
||||
break
|
||||
case 'maxDailyLoss':
|
||||
payload.maxDailyLoss = item.suggestedValue
|
||||
break
|
||||
case 'minPrice':
|
||||
payload.minPrice = item.suggestedValue
|
||||
break
|
||||
case 'maxPrice':
|
||||
payload.maxPrice = item.suggestedValue
|
||||
break
|
||||
case 'maxPositionValue':
|
||||
payload.maxPositionValue = item.suggestedValue
|
||||
break
|
||||
case 'minOrderDepth':
|
||||
payload.minOrderDepth = item.suggestedValue
|
||||
break
|
||||
case 'maxSpread':
|
||||
payload.maxSpread = item.suggestedValue
|
||||
break
|
||||
case 'priceTolerance':
|
||||
payload.priceTolerance = item.suggestedValue
|
||||
break
|
||||
}
|
||||
})
|
||||
return payload
|
||||
}
|
||||
|
||||
const applyConservativeConfig = () => {
|
||||
if (dangerousWarnings.length === 0) {
|
||||
message.info('当前配置已经比较保守,无需应用安全带建议')
|
||||
return
|
||||
}
|
||||
|
||||
Modal.confirm({
|
||||
title: '确认应用保守配置?',
|
||||
content: (
|
||||
<div>
|
||||
<p>这只会修改风控白名单字段,不会启用/停用跟单,也不会更换 leader。</p>
|
||||
<Table
|
||||
size="small"
|
||||
pagination={false}
|
||||
rowKey="field"
|
||||
dataSource={dangerousWarnings}
|
||||
columns={[
|
||||
{ title: '字段', dataIndex: 'field', render: (field: string) => fieldLabels[field] || field },
|
||||
{ title: '当前值', dataIndex: 'currentValue', render: (value: string | null) => value ?? '未设置' },
|
||||
{ title: '建议值', dataIndex: 'suggestedValue' }
|
||||
]}
|
||||
/>
|
||||
</div>
|
||||
),
|
||||
okText: '确认应用',
|
||||
cancelText: '取消',
|
||||
onOk: async () => {
|
||||
setApplying(true)
|
||||
try {
|
||||
const response = await apiService.safetyConfig.applyConservative(buildApplyPayload())
|
||||
if (response.data.code === 0) {
|
||||
message.success('已应用保守配置')
|
||||
onApplied?.()
|
||||
} else {
|
||||
message.error(response.data.msg || '应用保守配置失败')
|
||||
}
|
||||
} finally {
|
||||
setApplying(false)
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
return (
|
||||
<Card title="亏损归因与风险安全带" style={{ marginTop: compact ? 12 : 16 }}>
|
||||
<Space direction="vertical" size="middle" style={{ width: '100%' }}>
|
||||
{diagnosis.dataIncomplete && (
|
||||
<Alert
|
||||
type="warning"
|
||||
showIcon
|
||||
message="诊断数据不完整"
|
||||
description={`缺失来源:${diagnosis.missingSources.join('、') || '未知'}。系统不会把未知估值当作已确认归零。`}
|
||||
/>
|
||||
)}
|
||||
{diagnosis.lowConfidence && (
|
||||
<Alert type="info" showIcon message="低置信度" description={diagnosis.confidenceReason} />
|
||||
)}
|
||||
|
||||
<Row gutter={[16, 16]}>
|
||||
<Col xs={24} sm={12} md={6}>
|
||||
<Statistic title="归零/未知持仓成本" value={formatUSDC(diagnosis.zeroValuePositionCost)} prefix="$" />
|
||||
</Col>
|
||||
<Col xs={24} sm={12} md={6}>
|
||||
<Statistic title="已确认归零成本" value={formatUSDC(diagnosis.confirmedZeroValuePositionCost)} prefix="$" />
|
||||
</Col>
|
||||
<Col xs={24} sm={12} md={6}>
|
||||
<Statistic title="卖出归零亏损" value={formatUSDC(diagnosis.zeroSellLoss)} prefix="$" />
|
||||
</Col>
|
||||
<Col xs={24} sm={12} md={6}>
|
||||
<div style={{ color: '#999', fontSize: 14, marginBottom: 4 }}>报价状态</div>
|
||||
<Tag color={statusColor[diagnosis.quoteOverallStatus] || 'default'}>
|
||||
{statusText[diagnosis.quoteOverallStatus] || diagnosis.quoteOverallStatus}
|
||||
</Tag>
|
||||
<div style={{ color: '#999', marginTop: 8, fontSize: 12 }}>
|
||||
可用 {diagnosis.quoteAvailableCount},未匹配 {diagnosis.quoteNoMatchCount},不可用 {diagnosis.quoteUnavailableCount}
|
||||
</div>
|
||||
</Col>
|
||||
</Row>
|
||||
|
||||
<div>
|
||||
<h4 style={{ marginBottom: 8 }}>亏损最大的市场</h4>
|
||||
{diagnosis.topLosingMarkets.length === 0 ? (
|
||||
<Alert type="success" showIcon message="暂无已实现亏损市场" />
|
||||
) : (
|
||||
<Table
|
||||
size="small"
|
||||
pagination={false}
|
||||
rowKey="marketId"
|
||||
dataSource={diagnosis.topLosingMarkets}
|
||||
columns={[
|
||||
{ title: '市场', dataIndex: 'marketId' },
|
||||
{ title: '已实现 PnL', dataIndex: 'realizedPnl', render: (value: string) => `$${formatUSDC(value)}` },
|
||||
{ title: '匹配数', dataIndex: 'matchedOrders' }
|
||||
]}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<h4 style={{ marginBottom: 8 }}>风险配置体检</h4>
|
||||
{riskWarnings.length === 0 ? (
|
||||
<Alert type="success" showIcon message="当前配置已经比较保守" />
|
||||
) : (
|
||||
<Table
|
||||
size="small"
|
||||
pagination={false}
|
||||
rowKey="field"
|
||||
dataSource={riskWarnings}
|
||||
columns={[
|
||||
{ title: '字段', dataIndex: 'field', render: (field: string) => fieldLabels[field] || field },
|
||||
{ title: '当前值', dataIndex: 'currentValue', render: (value: string | null) => value ?? '未设置' },
|
||||
{ title: '建议值', dataIndex: 'suggestedValue' },
|
||||
{ title: '级别', dataIndex: 'severity', render: (severity: string) => <Tag color={severity === 'HIGH' ? 'red' : 'orange'}>{severity}</Tag> },
|
||||
{ title: '原因', dataIndex: 'reason' }
|
||||
]}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<Button type="primary" danger disabled={dangerousWarnings.length === 0} loading={applying} onClick={applyConservativeConfig}>
|
||||
应用保守配置
|
||||
</Button>
|
||||
</Space>
|
||||
</Card>
|
||||
)
|
||||
}
|
||||
|
||||
export default CopyTradingRiskSeatbeltPanel
|
||||
@@ -1,139 +0,0 @@
|
||||
import { useMemo } from 'react'
|
||||
import { Button, Popover, Space } from 'antd'
|
||||
import { GlobalOutlined, LinkOutlined, LoadingOutlined, ReloadOutlined } from '@ant-design/icons'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { useGeoblockCheck } from '../hooks/useGeoblockCheck'
|
||||
|
||||
const GEOBLOCK_DOCS_URL = 'https://docs.polymarket.com/api-reference/geoblock'
|
||||
|
||||
type StatusTone = 'loading' | 'ok' | 'blocked' | 'warn'
|
||||
|
||||
const TONE_COLOR: Record<StatusTone, string> = {
|
||||
loading: 'rgba(255, 255, 255, 0.45)',
|
||||
ok: '#52c41a',
|
||||
blocked: '#ff7875',
|
||||
warn: '#faad14'
|
||||
}
|
||||
|
||||
function formatLocation(country: string, region: string): string {
|
||||
if (country && region) {
|
||||
return `${country}/${region}`
|
||||
}
|
||||
return country || region || '—'
|
||||
}
|
||||
|
||||
interface GeoblockStatusTriggerProps {
|
||||
iconSize?: number
|
||||
dotSize?: number
|
||||
}
|
||||
|
||||
const GeoblockStatusTrigger: React.FC<GeoblockStatusTriggerProps> = ({ iconSize = 18, dotSize = 12 }) => {
|
||||
const { t } = useTranslation()
|
||||
const { status, data, refresh, loading } = useGeoblockCheck(true)
|
||||
|
||||
const locationText = useMemo(() => {
|
||||
if (!data) return '—'
|
||||
return formatLocation(data.country, data.region)
|
||||
}, [data])
|
||||
|
||||
let tone: StatusTone = 'loading'
|
||||
let label = t('geoblock.checkingShort')
|
||||
|
||||
if (status === 'loading' || status === 'idle') {
|
||||
tone = 'loading'
|
||||
label = t('geoblock.checkingShort')
|
||||
} else if (status === 'error') {
|
||||
tone = 'warn'
|
||||
label = t('geoblock.unknown.short')
|
||||
} else if (data?.blocked) {
|
||||
tone = 'blocked'
|
||||
label = t('geoblock.menu.blocked', { location: locationText })
|
||||
} else if (status === 'success' && data) {
|
||||
tone = 'ok'
|
||||
label = t('geoblock.menu.ok', { location: locationText })
|
||||
}
|
||||
|
||||
const accent = TONE_COLOR[tone]
|
||||
|
||||
const popoverContent = (
|
||||
<div style={{ maxWidth: 240 }}>
|
||||
<div style={{ fontWeight: 500, marginBottom: 6 }}>{t('geoblock.title')}</div>
|
||||
<div style={{ fontSize: 13, marginBottom: 8, lineHeight: 1.5 }}>{label}</div>
|
||||
{data && (
|
||||
<div style={{ fontSize: 12, color: 'rgba(0, 0, 0, 0.45)', marginBottom: 10 }}>
|
||||
<div>IP: {data.ip || '—'}</div>
|
||||
<div>{t('geoblock.location')}: {locationText}</div>
|
||||
</div>
|
||||
)}
|
||||
<Space size={8}>
|
||||
<Button
|
||||
type="link"
|
||||
size="small"
|
||||
icon={loading ? <LoadingOutlined /> : <ReloadOutlined />}
|
||||
onClick={() => refresh()}
|
||||
loading={loading}
|
||||
style={{ padding: 0, height: 'auto' }}
|
||||
>
|
||||
{t('geoblock.refresh')}
|
||||
</Button>
|
||||
<Button
|
||||
type="link"
|
||||
size="small"
|
||||
icon={<LinkOutlined />}
|
||||
href={GEOBLOCK_DOCS_URL}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
style={{ padding: 0, height: 'auto' }}
|
||||
>
|
||||
{t('geoblock.viewDocsShort')}
|
||||
</Button>
|
||||
</Space>
|
||||
</div>
|
||||
)
|
||||
|
||||
return (
|
||||
<Popover content={popoverContent} trigger="click" placement="bottom">
|
||||
<button
|
||||
type="button"
|
||||
title={label}
|
||||
aria-label={t('geoblock.title')}
|
||||
style={{
|
||||
position: 'relative',
|
||||
color: '#fff',
|
||||
fontSize: iconSize,
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
background: 'none',
|
||||
border: 'none',
|
||||
cursor: 'pointer',
|
||||
padding: 0,
|
||||
lineHeight: 1
|
||||
}}
|
||||
>
|
||||
{tone === 'loading' ? (
|
||||
<LoadingOutlined style={{ fontSize: iconSize, color: accent }} />
|
||||
) : (
|
||||
<GlobalOutlined style={{ fontSize: iconSize }} />
|
||||
)}
|
||||
{tone !== 'loading' && (
|
||||
<span
|
||||
style={{
|
||||
position: 'absolute',
|
||||
right: -Math.round(dotSize / 3),
|
||||
bottom: -Math.round(dotSize / 3),
|
||||
width: dotSize,
|
||||
height: dotSize,
|
||||
borderRadius: '50%',
|
||||
background: accent,
|
||||
border: '2px solid #001529',
|
||||
boxShadow: tone === 'ok' ? `0 0 5px ${accent}` : undefined
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</button>
|
||||
</Popover>
|
||||
)
|
||||
}
|
||||
|
||||
export default GeoblockStatusTrigger
|
||||
@@ -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'
|
||||
@@ -31,7 +32,6 @@ import { removeToken, getVersionText, getVersionInfo } from '../utils'
|
||||
import { wsManager } from '../services/websocket'
|
||||
import { apiClient } from '../services/api'
|
||||
import Logo from './Logo'
|
||||
import GeoblockStatusTrigger from './GeoblockStatusTrigger'
|
||||
|
||||
const { Header, Content, Sider } = AntLayout
|
||||
|
||||
@@ -75,7 +75,7 @@ const Layout: React.FC<LayoutProps> = ({ children }) => {
|
||||
const getInitialOpenKeys = (): string[] => {
|
||||
const path = location.pathname
|
||||
const keys: string[] = []
|
||||
if (path.startsWith('/leaders') || path.startsWith('/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')) {
|
||||
@@ -93,7 +93,7 @@ const Layout: React.FC<LayoutProps> = ({ children }) => {
|
||||
useEffect(() => {
|
||||
const path = location.pathname
|
||||
const keys: string[] = []
|
||||
if (path.startsWith('/leaders') || 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')) {
|
||||
@@ -149,6 +149,16 @@ const Layout: React.FC<LayoutProps> = ({ children }) => {
|
||||
icon: <LinkOutlined />,
|
||||
label: t('menu.copyTradingConfig')
|
||||
},
|
||||
{
|
||||
key: '/leader-pool',
|
||||
icon: <TeamOutlined />,
|
||||
label: t('menu.leaderPool')
|
||||
},
|
||||
{
|
||||
key: '/leader-research',
|
||||
icon: <ExperimentOutlined />,
|
||||
label: t('menu.leaderResearch')
|
||||
},
|
||||
{
|
||||
key: '/leaders',
|
||||
icon: <UserOutlined />,
|
||||
@@ -347,7 +357,6 @@ const Layout: React.FC<LayoutProps> = ({ children }) => {
|
||||
>
|
||||
<SendOutlined />
|
||||
</a>
|
||||
<GeoblockStatusTrigger iconSize={16} dotSize={10} />
|
||||
<Button
|
||||
type="text"
|
||||
icon={<MenuOutlined />}
|
||||
@@ -395,9 +404,7 @@ const Layout: React.FC<LayoutProps> = ({ children }) => {
|
||||
position: 'fixed',
|
||||
left: 0,
|
||||
top: 0,
|
||||
overflow: 'hidden',
|
||||
display: 'flex',
|
||||
flexDirection: 'column'
|
||||
overflow: 'hidden'
|
||||
}}
|
||||
>
|
||||
<div style={{
|
||||
@@ -477,7 +484,6 @@ const Layout: React.FC<LayoutProps> = ({ children }) => {
|
||||
>
|
||||
<SendOutlined />
|
||||
</a>
|
||||
<GeoblockStatusTrigger iconSize={18} dotSize={12} />
|
||||
</div>
|
||||
</div>
|
||||
<Menu
|
||||
@@ -488,8 +494,7 @@ const Layout: React.FC<LayoutProps> = ({ children }) => {
|
||||
items={menuItems}
|
||||
onClick={handleMenuClick}
|
||||
style={{
|
||||
flex: 1,
|
||||
minHeight: 0,
|
||||
height: 'calc(100vh - 100px)',
|
||||
borderRight: 0,
|
||||
overflowY: 'auto'
|
||||
}}
|
||||
@@ -510,4 +515,3 @@ const Layout: React.FC<LayoutProps> = ({ children }) => {
|
||||
}
|
||||
|
||||
export default Layout
|
||||
|
||||
|
||||
@@ -1,181 +0,0 @@
|
||||
import { Alert, Tag, Typography } from 'antd'
|
||||
import { CloseCircleOutlined, WarningOutlined } from '@ant-design/icons'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { useMediaQuery } from 'react-responsive'
|
||||
|
||||
const { Text } = Typography
|
||||
|
||||
export interface ProxyCheckGeoblockResult {
|
||||
checked: boolean
|
||||
blocked?: boolean | null
|
||||
ip?: string | null
|
||||
country?: string | null
|
||||
region?: string | null
|
||||
message?: string | null
|
||||
}
|
||||
|
||||
export interface ProxyCheckResponse {
|
||||
success: boolean
|
||||
message: string
|
||||
responseTime?: number
|
||||
latency?: number
|
||||
geoblock?: ProxyCheckGeoblockResult | null
|
||||
}
|
||||
|
||||
interface ProxyCheckResultAlertProps {
|
||||
result: ProxyCheckResponse
|
||||
style?: React.CSSProperties
|
||||
}
|
||||
|
||||
interface ResultRowProps {
|
||||
label: string
|
||||
children: React.ReactNode
|
||||
fullWidth?: boolean
|
||||
isMobile: boolean
|
||||
}
|
||||
|
||||
function formatLocation(country?: string | null, region?: string | null): string {
|
||||
if (country && region) {
|
||||
return `${country} / ${region}`
|
||||
}
|
||||
return country || region || '—'
|
||||
}
|
||||
|
||||
function stripGeoblockSuffix(message: string, geoblockMessage?: string | null): string {
|
||||
if (!geoblockMessage) {
|
||||
return message
|
||||
}
|
||||
const suffix = `;${geoblockMessage}`
|
||||
if (message.endsWith(suffix)) {
|
||||
return message.slice(0, -suffix.length)
|
||||
}
|
||||
const semi = message.indexOf(';')
|
||||
if (semi > 0) {
|
||||
return message.slice(0, semi)
|
||||
}
|
||||
return message
|
||||
}
|
||||
|
||||
const ResultRow: React.FC<ResultRowProps> = ({ label, children, fullWidth, isMobile }) => (
|
||||
<div
|
||||
style={{
|
||||
gridColumn: fullWidth && !isMobile ? '1 / -1' : undefined,
|
||||
display: 'flex',
|
||||
alignItems: 'baseline',
|
||||
gap: 12,
|
||||
minWidth: 0,
|
||||
lineHeight: 1.5
|
||||
}}
|
||||
>
|
||||
<Text type="secondary" style={{ flexShrink: 0, width: isMobile ? 96 : 108, fontSize: 13 }}>
|
||||
{label}
|
||||
</Text>
|
||||
<div style={{ flex: 1, minWidth: 0, fontSize: 13 }}>{children}</div>
|
||||
</div>
|
||||
)
|
||||
|
||||
const ProxyCheckResultAlert: React.FC<ProxyCheckResultAlertProps> = ({ result, style }) => {
|
||||
const { t } = useTranslation()
|
||||
const isMobile = useMediaQuery({ maxWidth: 768 })
|
||||
|
||||
const geoblock = result.geoblock
|
||||
const geoblockChecked = Boolean(geoblock?.checked)
|
||||
const geoblockBlocked = geoblockChecked && geoblock?.blocked === true
|
||||
const geoblockUnknown = geoblockChecked && geoblock?.blocked == null
|
||||
|
||||
const alertType = !result.success ? 'error' : geoblockBlocked ? 'warning' : 'success'
|
||||
const alertMessage = !result.success
|
||||
? t('proxySettings.checkFailed')
|
||||
: geoblockBlocked
|
||||
? t('proxySettings.checkSuccessWithGeoblockWarning')
|
||||
: t('proxySettings.checkSuccess')
|
||||
|
||||
const latencyMs = result.latency ?? result.responseTime
|
||||
const connectionSummary = stripGeoblockSuffix(result.message, geoblock?.message)
|
||||
const locationText = formatLocation(geoblock?.country, geoblock?.region)
|
||||
|
||||
const renderGeoblockValue = () => {
|
||||
if (!geoblockChecked) {
|
||||
return null
|
||||
}
|
||||
if (geoblockUnknown) {
|
||||
return (
|
||||
<Tag icon={<WarningOutlined />} color="warning">
|
||||
{t('proxySettings.checkResult.geoblockUnknown')}
|
||||
</Tag>
|
||||
)
|
||||
}
|
||||
if (geoblockBlocked) {
|
||||
return (
|
||||
<Tag icon={<CloseCircleOutlined />} color="error">
|
||||
{t('proxySettings.checkResult.geoblockBlocked')}
|
||||
</Tag>
|
||||
)
|
||||
}
|
||||
return <Text>{t('proxySettings.checkResult.geoblockOk')}</Text>
|
||||
}
|
||||
|
||||
return (
|
||||
<Alert
|
||||
type={alertType}
|
||||
message={alertMessage}
|
||||
description={
|
||||
<div
|
||||
style={{
|
||||
marginTop: 8,
|
||||
display: 'grid',
|
||||
gridTemplateColumns: isMobile ? '1fr' : '1fr 1fr',
|
||||
gap: isMobile ? 10 : '10px 24px'
|
||||
}}
|
||||
>
|
||||
<ResultRow label={t('proxySettings.checkResult.connection')} isMobile={isMobile}>
|
||||
{result.success ? (
|
||||
<Text>{t('proxySettings.checkResult.connected')}</Text>
|
||||
) : (
|
||||
<Tag icon={<CloseCircleOutlined />} color="error">
|
||||
{t('proxySettings.checkResult.failed')}
|
||||
</Tag>
|
||||
)}
|
||||
</ResultRow>
|
||||
|
||||
{latencyMs !== undefined && (
|
||||
<ResultRow label={t('proxySettings.checkResult.latency')} isMobile={isMobile}>
|
||||
<Text type={latencyMs >= 3000 ? 'warning' : undefined}>{latencyMs} ms</Text>
|
||||
</ResultRow>
|
||||
)}
|
||||
|
||||
{geoblockChecked && (
|
||||
<>
|
||||
<ResultRow label={t('proxySettings.geoblockTitle')} isMobile={isMobile}>
|
||||
{renderGeoblockValue()}
|
||||
</ResultRow>
|
||||
<ResultRow label={t('geoblock.location')} isMobile={isMobile}>
|
||||
<Text>{locationText}</Text>
|
||||
</ResultRow>
|
||||
{geoblock?.ip && (
|
||||
<ResultRow label={t('geoblock.ip')} isMobile={isMobile} fullWidth>
|
||||
<Text style={{ wordBreak: 'break-all' }}>{geoblock.ip}</Text>
|
||||
</ResultRow>
|
||||
)}
|
||||
{geoblockUnknown && geoblock?.message && (
|
||||
<ResultRow label={t('proxySettings.checkResult.detail')} isMobile={isMobile} fullWidth>
|
||||
<Text type="secondary">{geoblock.message}</Text>
|
||||
</ResultRow>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
|
||||
{!result.success && connectionSummary && (
|
||||
<ResultRow label={t('proxySettings.checkResult.detail')} isMobile={isMobile} fullWidth>
|
||||
<Text type="secondary">{connectionSummary}</Text>
|
||||
</ResultRow>
|
||||
)}
|
||||
</div>
|
||||
}
|
||||
style={style}
|
||||
showIcon
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
export default ProxyCheckResultAlert
|
||||
@@ -1,105 +0,0 @@
|
||||
import { useCallback, useEffect, useRef, useState } from 'react'
|
||||
import { apiService } from '../services/api'
|
||||
|
||||
export interface GeoblockCheckResult {
|
||||
blocked: boolean
|
||||
ip: string
|
||||
country: string
|
||||
region: string
|
||||
checkedAt: number
|
||||
source: string
|
||||
}
|
||||
|
||||
export type GeoblockCheckStatus = 'idle' | 'loading' | 'success' | 'error'
|
||||
|
||||
const CACHE_KEY = 'geoblock_check_cache'
|
||||
const CACHE_TTL_MS = 5 * 60 * 1000
|
||||
|
||||
interface GeoblockCacheEntry {
|
||||
data: GeoblockCheckResult
|
||||
cachedAt: number
|
||||
}
|
||||
|
||||
function readCache(): GeoblockCheckResult | null {
|
||||
try {
|
||||
const raw = sessionStorage.getItem(CACHE_KEY)
|
||||
if (!raw) return null
|
||||
const entry = JSON.parse(raw) as GeoblockCacheEntry
|
||||
if (Date.now() - entry.cachedAt > CACHE_TTL_MS) {
|
||||
sessionStorage.removeItem(CACHE_KEY)
|
||||
return null
|
||||
}
|
||||
return entry.data
|
||||
} catch {
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
function writeCache(data: GeoblockCheckResult): void {
|
||||
const entry: GeoblockCacheEntry = { data, cachedAt: Date.now() }
|
||||
sessionStorage.setItem(CACHE_KEY, JSON.stringify(entry))
|
||||
}
|
||||
|
||||
export function useGeoblockCheck(autoFetch = true) {
|
||||
const [status, setStatus] = useState<GeoblockCheckStatus>('idle')
|
||||
const [data, setData] = useState<GeoblockCheckResult | null>(null)
|
||||
const [errorMessage, setErrorMessage] = useState<string | null>(null)
|
||||
const fetchingRef = useRef(false)
|
||||
|
||||
const fetchGeoblock = useCallback(async (force = false) => {
|
||||
if (fetchingRef.current) return
|
||||
if (!force) {
|
||||
const cached = readCache()
|
||||
if (cached) {
|
||||
setData(cached)
|
||||
setStatus('success')
|
||||
setErrorMessage(null)
|
||||
return
|
||||
}
|
||||
}
|
||||
fetchingRef.current = true
|
||||
setStatus('loading')
|
||||
setErrorMessage(null)
|
||||
try {
|
||||
const response = await apiService.proxyConfig.checkGeoblock()
|
||||
if (response.data.code === 0 && response.data.data) {
|
||||
const result: GeoblockCheckResult = {
|
||||
blocked: response.data.data.blocked,
|
||||
ip: response.data.data.ip,
|
||||
country: response.data.data.country,
|
||||
region: response.data.data.region,
|
||||
checkedAt: response.data.data.checkedAt,
|
||||
source: response.data.data.source ?? 'server'
|
||||
}
|
||||
writeCache(result)
|
||||
setData(result)
|
||||
setStatus('success')
|
||||
} else {
|
||||
setStatus('error')
|
||||
setErrorMessage(response.data.msg ?? 'Geoblock check failed')
|
||||
setData(null)
|
||||
}
|
||||
} catch (err: unknown) {
|
||||
setStatus('error')
|
||||
const message = err instanceof Error ? err.message : 'Geoblock check failed'
|
||||
setErrorMessage(message)
|
||||
setData(null)
|
||||
} finally {
|
||||
fetchingRef.current = false
|
||||
}
|
||||
}, [])
|
||||
|
||||
useEffect(() => {
|
||||
if (autoFetch) {
|
||||
fetchGeoblock()
|
||||
}
|
||||
}, [autoFetch, fetchGeoblock])
|
||||
|
||||
return {
|
||||
status,
|
||||
data,
|
||||
errorMessage,
|
||||
refresh: () => fetchGeoblock(true),
|
||||
loading: status === 'loading'
|
||||
}
|
||||
}
|
||||
@@ -4,6 +4,9 @@
|
||||
"save": "Save",
|
||||
"cancel": "Cancel",
|
||||
"edit": "Edit",
|
||||
"detail": "Detail",
|
||||
"overview": "Overview",
|
||||
"time": "Time",
|
||||
"viewDetail": "View Details",
|
||||
"delete": "Delete",
|
||||
"add": "Add",
|
||||
@@ -314,6 +317,8 @@
|
||||
"leaders": "Leader Management",
|
||||
"templates": "Templates",
|
||||
"copyTradingConfig": "Copy Trading Config",
|
||||
"leaderPool": "Leader Pool",
|
||||
"leaderResearch": "Leader Research",
|
||||
"cryptoSpreadStrategy": "Crypto Spread Strategy",
|
||||
"cryptoTailStrategy": "Strategy Config",
|
||||
"cryptoTailMonitor": "Real-time Monitor",
|
||||
@@ -381,21 +386,7 @@
|
||||
"saveSuccess": "Configuration saved successfully",
|
||||
"saveFailed": "Failed to save configuration",
|
||||
"getFailed": "Failed to get proxy configuration",
|
||||
"latency": "Latency",
|
||||
"checkResult": {
|
||||
"connection": "Proxy connection",
|
||||
"connected": "OK",
|
||||
"failed": "Failed",
|
||||
"latency": "Response time",
|
||||
"detail": "Details",
|
||||
"geoblockOk": "Can trade",
|
||||
"geoblockBlocked": "Region restricted",
|
||||
"geoblockUnknown": "Check failed"
|
||||
},
|
||||
"checkSuccessWithGeoblockWarning": "Proxy is OK, but trading region is restricted",
|
||||
"geoblockTitle": "Geo check",
|
||||
"geoblockAvailable": "Egress IP can trade ({{location}})",
|
||||
"geoblockBlocked": "Egress IP is restricted and orders are blocked ({{location}})"
|
||||
"latency": "Latency"
|
||||
},
|
||||
"configPage": {
|
||||
"title": "Global Configuration",
|
||||
@@ -566,7 +557,140 @@
|
||||
"deleteConfirmDesc": "This Leader has {{count}} copy trading relations, please delete them first",
|
||||
"openDetailFailed": "Failed to open detail",
|
||||
"noCopyTradings": "No copy trading configs",
|
||||
"noBacktests": "No backtests"
|
||||
"noBacktests": "No backtests",
|
||||
"addToPool": "Add to Leader Pool",
|
||||
"addToPoolSuccess": "Added to Leader Pool",
|
||||
"addToPoolExists": "This Leader is already in the pool",
|
||||
"addToPoolFailed": "Failed to add to Leader Pool",
|
||||
"goLeaderPool": "Go to Leader Pool"
|
||||
},
|
||||
"leaderPool": {
|
||||
"title": "Leader Pool",
|
||||
"subtitle": "Separate candidate, watch, trial, cooldown, and retired decisions from real copy trading configs.",
|
||||
"safetyHint": "Leader Pool will not auto-scale positions or auto-enable large copy trading.",
|
||||
"safetyDesc": "Trial configs created from the pool use conservative defaults and are disabled by default.",
|
||||
"fetchFailed": "Failed to fetch Leader Pool",
|
||||
"statuses": {
|
||||
"CANDIDATE": "Candidate",
|
||||
"WATCH": "Watch",
|
||||
"PAPER": "Paper",
|
||||
"TRIAL": "Trial",
|
||||
"ACTIVE": "Active",
|
||||
"COOLDOWN": "Cooldown",
|
||||
"RETIRED": "Retired"
|
||||
},
|
||||
"selectLeaderPlaceholder": "Select an existing Leader to add",
|
||||
"selectLeaderFirst": "Please select a Leader first",
|
||||
"addExistingLeader": "Add to Pool",
|
||||
"addSuccess": "Added to Leader Pool",
|
||||
"addFailed": "Failed to add to Leader Pool",
|
||||
"goLeaders": "Go to Leader Management",
|
||||
"noLeaders": "No Leaders yet",
|
||||
"totalCount": "Pool Size",
|
||||
"trialCount": "In Trial",
|
||||
"estimatedWorstExposure": "Worst Exposure",
|
||||
"pendingRiskCount": "Pending Risks",
|
||||
"filterStatus": "Filter by status",
|
||||
"emptyDesc": "The pool is empty. Add leaders from Leader Management or select an existing Leader here.",
|
||||
"leader": "Leader",
|
||||
"source": "Source",
|
||||
"suggestedConfig": "Suggested Config",
|
||||
"fixedAmount": "Fixed Amount",
|
||||
"maxDailyOrders": "Max Daily Orders",
|
||||
"maxDailyLoss": "Max Daily Loss",
|
||||
"minPrice": "Min Price",
|
||||
"maxPrice": "Max Price",
|
||||
"maxPositionValue": "Max Position",
|
||||
"priceRange": "Price Range",
|
||||
"copyTradingState": "Copy Trading State",
|
||||
"configs": "configs",
|
||||
"hasEnabled": "Has enabled config",
|
||||
"noEnabled": "No enabled config",
|
||||
"lastReviewedAt": "Last Reviewed",
|
||||
"updateStatus": "Update Status",
|
||||
"statusUpdated": "Status updated",
|
||||
"statusUpdateFailed": "Failed to update status",
|
||||
"cooldownUntil": "Cooldown Until",
|
||||
"locked": "Locked",
|
||||
"editPlan": "Edit Plan",
|
||||
"planUpdated": "Plan updated",
|
||||
"planUpdateFailed": "Failed to update plan",
|
||||
"notes": "Notes",
|
||||
"createTrial": "Create Trial Config",
|
||||
"trialConfirmTitle": "This creates a disabled FIXED small trial config",
|
||||
"trialConfirmDesc": "Confirm fixed amount, max daily orders, max daily loss, price range, and max position before submitting.",
|
||||
"trialRequiresResearchReady": "Research candidates must be trial-ready before a trial config can be created.",
|
||||
"account": "Account",
|
||||
"selectAccount": "Please select an account",
|
||||
"noAccounts": "No accounts yet",
|
||||
"trialCreated": "Trial config created",
|
||||
"trialCreateFailed": "Failed to create trial config",
|
||||
"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",
|
||||
"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",
|
||||
@@ -1300,36 +1424,6 @@
|
||||
"webhookUrlPlaceholder": "Webhook URL (from Slack App settings)",
|
||||
"webhookUrlRequired": "Please enter Webhook URL"
|
||||
},
|
||||
"geoblock": {
|
||||
"title": "Trading Region Check",
|
||||
"checking": "Checking geographic restrictions for the trading server egress IP…",
|
||||
"checkingShort": "Checking server region…",
|
||||
"ip": "Detected IP",
|
||||
"location": "Region",
|
||||
"refresh": "Refresh",
|
||||
"viewDocs": "View official geo restrictions",
|
||||
"viewDocsShort": "Docs",
|
||||
"sourceServer": "Source: trading server",
|
||||
"menu": {
|
||||
"ok": "Region OK · {{location}}",
|
||||
"blocked": "Restricted · {{location}}"
|
||||
},
|
||||
"available": {
|
||||
"title": "Trading server can place orders on Polymarket",
|
||||
"description": "IP: {{ip}}, region: {{location}}",
|
||||
"short": "Server OK · {{location}} · {{ip}}"
|
||||
},
|
||||
"blocked": {
|
||||
"title": "Trading server IP is in a restricted region",
|
||||
"description": "Polymarket rejects orders from restricted regions. Configure a compliant network or proxy for your trading server before copy trading.",
|
||||
"short": "Server IP restricted ({{location}} · {{ip}}) — orders blocked"
|
||||
},
|
||||
"unknown": {
|
||||
"title": "Could not complete region check",
|
||||
"description": "Check server network, proxy settings, or try again later. You can still read announcements, but verify your network before copy trading.",
|
||||
"short": "Region check failed"
|
||||
}
|
||||
},
|
||||
"announcements": {
|
||||
"title": "Announcements",
|
||||
"noAnnouncements": "No announcements",
|
||||
@@ -1855,4 +1949,4 @@
|
||||
"accountGuideButton": "USDC.e → pUSD",
|
||||
"dismissGuide": "Got it"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -7,6 +7,9 @@
|
||||
"later": "稍后",
|
||||
"delete": "删除",
|
||||
"edit": "编辑",
|
||||
"detail": "详情",
|
||||
"overview": "概览",
|
||||
"time": "时间",
|
||||
"viewDetail": "查看详情",
|
||||
"add": "添加",
|
||||
"refresh": "刷新",
|
||||
@@ -314,6 +317,8 @@
|
||||
"leaders": "Leader 管理",
|
||||
"templates": "跟单模板",
|
||||
"copyTradingConfig": "跟单配置",
|
||||
"leaderPool": "Leader 池",
|
||||
"leaderResearch": "Leader 研究",
|
||||
"cryptoSpreadStrategy": "加密价差策略",
|
||||
"cryptoTailStrategy": "策略配置",
|
||||
"cryptoTailMonitor": "实时监控",
|
||||
@@ -366,25 +371,11 @@
|
||||
"passwordHelpUpdate": "留空则不更新密码,输入新密码则更新",
|
||||
"check": "检查代理",
|
||||
"checkSuccess": "代理检查成功",
|
||||
"checkSuccessWithGeoblockWarning": "代理可用,但地域受限",
|
||||
"checkFailed": "代理检查失败",
|
||||
"geoblockTitle": "地域检测",
|
||||
"geoblockAvailable": "出口 IP 可下单({{location}})",
|
||||
"geoblockBlocked": "出口 IP 受限,无法下单({{location}})",
|
||||
"saveSuccess": "保存配置成功",
|
||||
"saveFailed": "保存配置失败",
|
||||
"getFailed": "获取代理配置失败",
|
||||
"latency": "延迟",
|
||||
"checkResult": {
|
||||
"connection": "代理连接",
|
||||
"connected": "正常",
|
||||
"failed": "失败",
|
||||
"latency": "响应延迟",
|
||||
"detail": "详情",
|
||||
"geoblockOk": "可下单",
|
||||
"geoblockBlocked": "地域受限",
|
||||
"geoblockUnknown": "检测异常"
|
||||
},
|
||||
"hostInvalid": "请输入有效的主机地址",
|
||||
"portInvalid": "端口必须在 1-65535 之间"
|
||||
},
|
||||
@@ -566,7 +557,140 @@
|
||||
"deleteConfirmDesc": "该 Leader 还有 {{count}} 个跟单关系,请先删除跟单关系",
|
||||
"openDetailFailed": "打开详情失败",
|
||||
"noCopyTradings": "暂无跟单配置",
|
||||
"noBacktests": "暂无回测"
|
||||
"noBacktests": "暂无回测",
|
||||
"addToPool": "加入 Leader 池",
|
||||
"addToPoolSuccess": "已加入 Leader 池",
|
||||
"addToPoolExists": "该 Leader 已在池子中",
|
||||
"addToPoolFailed": "加入 Leader 池失败",
|
||||
"goLeaderPool": "前往 Leader 池"
|
||||
},
|
||||
"leaderPool": {
|
||||
"title": "Leader 池",
|
||||
"subtitle": "把候选、观察、小额试跟、冷却和淘汰从真实跟单配置里拆出来,先决策,再下真钱。",
|
||||
"safetyHint": "Leader 池不会自动加仓,也不会自动启用大额跟单。",
|
||||
"safetyDesc": "从池子创建试跟配置时会使用保守默认值,并默认禁用。你可以之后在跟单配置页手动启用。",
|
||||
"fetchFailed": "获取 Leader 池失败",
|
||||
"statuses": {
|
||||
"CANDIDATE": "候选",
|
||||
"WATCH": "观察",
|
||||
"PAPER": "模拟",
|
||||
"TRIAL": "小额试跟",
|
||||
"ACTIVE": "活跃",
|
||||
"COOLDOWN": "冷却",
|
||||
"RETIRED": "淘汰"
|
||||
},
|
||||
"selectLeaderPlaceholder": "选择已有 Leader 加入池子",
|
||||
"selectLeaderFirst": "请先选择一个 Leader",
|
||||
"addExistingLeader": "加入池子",
|
||||
"addSuccess": "加入 Leader 池成功",
|
||||
"addFailed": "加入 Leader 池失败",
|
||||
"goLeaders": "去 Leader 管理",
|
||||
"noLeaders": "暂无 Leader,请先添加",
|
||||
"totalCount": "池子人数",
|
||||
"trialCount": "试跟中人数",
|
||||
"estimatedWorstExposure": "估算最坏暴露",
|
||||
"pendingRiskCount": "待处理风险",
|
||||
"filterStatus": "按状态筛选",
|
||||
"emptyDesc": "池子还是空的,可以从 Leader 管理加入 leader,或在本页选择已有 leader 加入。",
|
||||
"leader": "Leader",
|
||||
"source": "来源",
|
||||
"suggestedConfig": "建议配置",
|
||||
"fixedAmount": "固定金额",
|
||||
"maxDailyOrders": "每日最大单数",
|
||||
"maxDailyLoss": "每日最大亏损",
|
||||
"minPrice": "最低价格",
|
||||
"maxPrice": "最高价格",
|
||||
"maxPositionValue": "最大持仓",
|
||||
"priceRange": "价格区间",
|
||||
"copyTradingState": "跟单配置状态",
|
||||
"configs": "个配置",
|
||||
"hasEnabled": "存在启用配置",
|
||||
"noEnabled": "无启用配置",
|
||||
"lastReviewedAt": "最后复核",
|
||||
"updateStatus": "更新状态",
|
||||
"statusUpdated": "状态已更新",
|
||||
"statusUpdateFailed": "状态更新失败",
|
||||
"cooldownUntil": "冷却截止时间",
|
||||
"locked": "锁定",
|
||||
"editPlan": "编辑建议",
|
||||
"planUpdated": "建议配置已更新",
|
||||
"planUpdateFailed": "建议配置更新失败",
|
||||
"notes": "备注",
|
||||
"createTrial": "创建试跟配置",
|
||||
"trialConfirmTitle": "将创建默认禁用的小额 FIXED 跟单配置",
|
||||
"trialConfirmDesc": "提交前请确认固定金额、每日最大单数、每日最大亏损、价格区间和最大持仓。",
|
||||
"trialRequiresResearchReady": "研究候选进入建议试跟后才能创建试跟配置",
|
||||
"account": "账户",
|
||||
"selectAccount": "请选择账户",
|
||||
"noAccounts": "暂无账户,请先添加账户",
|
||||
"trialCreated": "试跟配置已创建",
|
||||
"trialCreateFailed": "创建试跟配置失败",
|
||||
"goCopyTrading": "查看跟单配置",
|
||||
"removeConfirm": "只移除池子项,不会删除 Leader 地址或已有跟单配置。确定继续?",
|
||||
"removeSuccess": "池子项已移除",
|
||||
"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",
|
||||
@@ -1300,36 +1424,6 @@
|
||||
"webhookUrlPlaceholder": "Webhook URL(从 Slack App 设置中获取)",
|
||||
"webhookUrlRequired": "请输入 Webhook URL"
|
||||
},
|
||||
"geoblock": {
|
||||
"title": "交易地域检查",
|
||||
"checking": "正在检测交易服务器出口 IP 的地域限制…",
|
||||
"checkingShort": "正在检测服务器地域…",
|
||||
"ip": "检测 IP",
|
||||
"location": "地区",
|
||||
"refresh": "刷新",
|
||||
"viewDocs": "查看官方地域限制说明",
|
||||
"viewDocsShort": "说明",
|
||||
"sourceServer": "检测对象:交易服务器",
|
||||
"menu": {
|
||||
"ok": "地域可用 · {{location}}",
|
||||
"blocked": "地域受限 · {{location}}"
|
||||
},
|
||||
"available": {
|
||||
"title": "当前服务器网络可向 Polymarket 提交订单",
|
||||
"description": "检测 IP:{{ip}},地区:{{location}}",
|
||||
"short": "服务器可交易 · {{location}} · {{ip}}"
|
||||
},
|
||||
"blocked": {
|
||||
"title": "当前服务器 IP 所在地区无法向 Polymarket 下单",
|
||||
"description": "Polymarket 会拒绝来自受限地区的订单。请为交易服务器配置合规的网络或代理环境后再进行跟单。",
|
||||
"short": "服务器 IP 受限({{location}} · {{ip}}),无法下单"
|
||||
},
|
||||
"unknown": {
|
||||
"title": "无法完成地域检测",
|
||||
"description": "请检查服务器网络、代理配置或稍后重试。检测失败不会阻止您查看公告,但跟单前请自行确认网络环境。",
|
||||
"short": "地域检测失败"
|
||||
}
|
||||
},
|
||||
"announcements": {
|
||||
"title": "公告",
|
||||
"noAnnouncements": "暂无公告",
|
||||
@@ -1855,4 +1949,4 @@
|
||||
"accountGuideButton": "USDC.e → pUSD",
|
||||
"dismissGuide": "知道了"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -4,6 +4,9 @@
|
||||
"save": "保存",
|
||||
"cancel": "取消",
|
||||
"edit": "編輯",
|
||||
"detail": "詳情",
|
||||
"overview": "概覽",
|
||||
"time": "時間",
|
||||
"viewDetail": "查看詳情",
|
||||
"delete": "刪除",
|
||||
"add": "添加",
|
||||
@@ -314,6 +317,8 @@
|
||||
"leaders": "Leader 管理",
|
||||
"templates": "跟單模板",
|
||||
"copyTradingConfig": "跟單配置",
|
||||
"leaderPool": "Leader 池",
|
||||
"leaderResearch": "Leader 研究",
|
||||
"cryptoSpreadStrategy": "加密價差策略",
|
||||
"cryptoTailStrategy": "策略配置",
|
||||
"cryptoTailMonitor": "即時監控",
|
||||
@@ -381,21 +386,7 @@
|
||||
"saveSuccess": "保存配置成功",
|
||||
"saveFailed": "保存配置失敗",
|
||||
"getFailed": "獲取代理配置失敗",
|
||||
"latency": "延遲",
|
||||
"checkResult": {
|
||||
"connection": "代理連線",
|
||||
"connected": "正常",
|
||||
"failed": "失敗",
|
||||
"latency": "回應延遲",
|
||||
"detail": "詳情",
|
||||
"geoblockOk": "可下單",
|
||||
"geoblockBlocked": "地域受限",
|
||||
"geoblockUnknown": "檢測異常"
|
||||
},
|
||||
"checkSuccessWithGeoblockWarning": "代理可用,但地域受限",
|
||||
"geoblockTitle": "地域檢測",
|
||||
"geoblockAvailable": "出口 IP 可下單({{location}})",
|
||||
"geoblockBlocked": "出口 IP 受限,無法下單({{location}})"
|
||||
"latency": "延遲"
|
||||
},
|
||||
"configPage": {
|
||||
"title": "全局配置",
|
||||
@@ -566,7 +557,140 @@
|
||||
"deleteConfirmDesc": "該 Leader 還有 {{count}} 個跟單關係,請先刪除跟單關係",
|
||||
"openDetailFailed": "打開詳情失敗",
|
||||
"noCopyTradings": "暫無跟單配置",
|
||||
"noBacktests": "暫無回測"
|
||||
"noBacktests": "暫無回測",
|
||||
"addToPool": "加入 Leader 池",
|
||||
"addToPoolSuccess": "已加入 Leader 池",
|
||||
"addToPoolExists": "該 Leader 已在池子中",
|
||||
"addToPoolFailed": "加入 Leader 池失敗",
|
||||
"goLeaderPool": "前往 Leader 池"
|
||||
},
|
||||
"leaderPool": {
|
||||
"title": "Leader 池",
|
||||
"subtitle": "把候選、觀察、小額試跟、冷卻和淘汰從真實跟單配置中拆出來,先決策,再下真錢。",
|
||||
"safetyHint": "Leader 池不會自動加倉,也不會自動啟用大額跟單。",
|
||||
"safetyDesc": "從池子創建試跟配置時會使用保守默認值,並默認禁用。你可以之後在跟單配置頁手動啟用。",
|
||||
"fetchFailed": "獲取 Leader 池失敗",
|
||||
"statuses": {
|
||||
"CANDIDATE": "候選",
|
||||
"WATCH": "觀察",
|
||||
"PAPER": "模擬",
|
||||
"TRIAL": "小額試跟",
|
||||
"ACTIVE": "活躍",
|
||||
"COOLDOWN": "冷卻",
|
||||
"RETIRED": "淘汰"
|
||||
},
|
||||
"selectLeaderPlaceholder": "選擇已有 Leader 加入池子",
|
||||
"selectLeaderFirst": "請先選擇一個 Leader",
|
||||
"addExistingLeader": "加入池子",
|
||||
"addSuccess": "加入 Leader 池成功",
|
||||
"addFailed": "加入 Leader 池失敗",
|
||||
"goLeaders": "去 Leader 管理",
|
||||
"noLeaders": "暫無 Leader,請先添加",
|
||||
"totalCount": "池子人數",
|
||||
"trialCount": "試跟中人數",
|
||||
"estimatedWorstExposure": "估算最壞暴露",
|
||||
"pendingRiskCount": "待處理風險",
|
||||
"filterStatus": "按狀態篩選",
|
||||
"emptyDesc": "池子還是空的,可以從 Leader 管理加入 leader,或在本頁選擇已有 leader 加入。",
|
||||
"leader": "Leader",
|
||||
"source": "來源",
|
||||
"suggestedConfig": "建議配置",
|
||||
"fixedAmount": "固定金額",
|
||||
"maxDailyOrders": "每日最大單數",
|
||||
"maxDailyLoss": "每日最大虧損",
|
||||
"minPrice": "最低價格",
|
||||
"maxPrice": "最高價格",
|
||||
"maxPositionValue": "最大持倉",
|
||||
"priceRange": "價格區間",
|
||||
"copyTradingState": "跟單配置狀態",
|
||||
"configs": "個配置",
|
||||
"hasEnabled": "存在啟用配置",
|
||||
"noEnabled": "無啟用配置",
|
||||
"lastReviewedAt": "最後複核",
|
||||
"updateStatus": "更新狀態",
|
||||
"statusUpdated": "狀態已更新",
|
||||
"statusUpdateFailed": "狀態更新失敗",
|
||||
"cooldownUntil": "冷卻截止時間",
|
||||
"locked": "鎖定",
|
||||
"editPlan": "編輯建議",
|
||||
"planUpdated": "建議配置已更新",
|
||||
"planUpdateFailed": "建議配置更新失敗",
|
||||
"notes": "備註",
|
||||
"createTrial": "創建試跟配置",
|
||||
"trialConfirmTitle": "將創建默認禁用的小額 FIXED 跟單配置",
|
||||
"trialConfirmDesc": "提交前請確認固定金額、每日最大單數、每日最大虧損、價格區間和最大持倉。",
|
||||
"trialRequiresResearchReady": "研究候選進入建議試跟後才能創建試跟配置",
|
||||
"account": "賬戶",
|
||||
"selectAccount": "請選擇賬戶",
|
||||
"noAccounts": "暫無賬戶,請先添加賬戶",
|
||||
"trialCreated": "試跟配置已創建",
|
||||
"trialCreateFailed": "創建試跟配置失敗",
|
||||
"goCopyTrading": "查看跟單配置",
|
||||
"removeConfirm": "只移除池子項,不會刪除 Leader 地址或已有跟單配置。確定繼續?",
|
||||
"removeSuccess": "池子項已移除",
|
||||
"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",
|
||||
@@ -1300,36 +1424,6 @@
|
||||
"webhookUrlPlaceholder": "Webhook URL(從 Slack App 設置中獲取)",
|
||||
"webhookUrlRequired": "請輸入 Webhook URL"
|
||||
},
|
||||
"geoblock": {
|
||||
"title": "交易地域檢查",
|
||||
"checking": "正在檢測交易伺服器出口 IP 的地域限制…",
|
||||
"checkingShort": "正在檢測伺服器地域…",
|
||||
"ip": "檢測 IP",
|
||||
"location": "地區",
|
||||
"refresh": "刷新",
|
||||
"viewDocs": "查看官方地域限制說明",
|
||||
"viewDocsShort": "說明",
|
||||
"sourceServer": "檢測對象:交易伺服器",
|
||||
"menu": {
|
||||
"ok": "地域可用 · {{location}}",
|
||||
"blocked": "地域受限 · {{location}}"
|
||||
},
|
||||
"available": {
|
||||
"title": "當前伺服器網路可向 Polymarket 提交訂單",
|
||||
"description": "檢測 IP:{{ip}},地區:{{location}}",
|
||||
"short": "伺服器可交易 · {{location}} · {{ip}}"
|
||||
},
|
||||
"blocked": {
|
||||
"title": "當前伺服器 IP 所在地區無法向 Polymarket 下單",
|
||||
"description": "Polymarket 會拒絕來自受限地區的訂單。請為交易伺服器配置合規的網路或代理環境後再進行跟單。",
|
||||
"short": "伺服器 IP 受限({{location}} · {{ip}}),無法下單"
|
||||
},
|
||||
"unknown": {
|
||||
"title": "無法完成地域檢測",
|
||||
"description": "請檢查伺服器網路、代理配置或稍後重試。檢測失敗不會阻止您查看公告,但跟單前請自行確認網路環境。",
|
||||
"short": "地域檢測失敗"
|
||||
}
|
||||
},
|
||||
"announcements": {
|
||||
"title": "公告",
|
||||
"noAnnouncements": "暫無公告",
|
||||
@@ -1855,4 +1949,4 @@
|
||||
"accountGuideButton": "USDC.e → pUSD",
|
||||
"dismissGuide": "知道了"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -6,6 +6,7 @@ import { apiService } from '../services/api'
|
||||
import { useMediaQuery } from 'react-responsive'
|
||||
import ReactMarkdown from 'react-markdown'
|
||||
import remarkGfm from 'remark-gfm'
|
||||
|
||||
const { Title, Text } = Typography
|
||||
|
||||
interface Reactions {
|
||||
|
||||
@@ -256,7 +256,7 @@ const CopyTradingList: React.FC = () => {
|
||||
)
|
||||
},
|
||||
{
|
||||
title: t('copyTradingList.totalPnl') || '总盈亏',
|
||||
title: '总盈亏(含未实现)',
|
||||
key: 'totalPnl',
|
||||
width: isMobile ? 100 : 150,
|
||||
render: (_: any, record: CopyTrading) => {
|
||||
@@ -537,7 +537,7 @@ const CopyTradingList: React.FC = () => {
|
||||
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', width: '100%' }}>
|
||||
<div>
|
||||
<div style={{ fontSize: '10px', color: '#8c8c8c' }}>
|
||||
{t('copyTradingList.totalPnl') || '总盈亏'}
|
||||
总盈亏(含未实现)
|
||||
</div>
|
||||
{stats ? (
|
||||
<div style={{
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user