feat: add copy trading safety and leader pool
This commit is contained in:
@@ -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"
|
||||
@@ -421,6 +421,7 @@ 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 诊断、安全带确认流程和运维排查命令
|
||||
- [动态更新文档](docs/zh/DYNAMIC_UPDATE.md) - 动态更新功能说明
|
||||
|
||||
### 🤝 贡献指南
|
||||
|
||||
+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(
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
+172
@@ -0,0 +1,172 @@
|
||||
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.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 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.ACCOUNT_NOT_FOUND ||
|
||||
errorCode == ErrorCode.LEADER_NOT_FOUND
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
)
|
||||
|
||||
|
||||
@@ -26,6 +26,14 @@ 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,
|
||||
@@ -34,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
|
||||
)
|
||||
|
||||
/**
|
||||
* 买入订单信息
|
||||
*/
|
||||
@@ -209,4 +260,3 @@ data class StatisticsResponse(
|
||||
val maxProfit: String,
|
||||
val maxLoss: String
|
||||
)
|
||||
|
||||
|
||||
@@ -0,0 +1,86 @@
|
||||
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 createdAt: Long,
|
||||
val updatedAt: Long
|
||||
)
|
||||
|
||||
data class LeaderPoolListResponse(
|
||||
val summary: LeaderPoolSummaryDto,
|
||||
val list: List<LeaderPoolItemDto>,
|
||||
val total: Int
|
||||
)
|
||||
@@ -0,0 +1,71 @@
|
||||
package com.wrbug.polymarketbot.entity
|
||||
|
||||
import com.wrbug.polymarketbot.enums.LeaderPoolStatus
|
||||
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 = "created_at", nullable = false)
|
||||
val createdAt: Long = System.currentTimeMillis(),
|
||||
|
||||
@Column(name = "updated_at", nullable = false)
|
||||
var updatedAt: Long = System.currentTimeMillis()
|
||||
)
|
||||
@@ -110,6 +110,10 @@ 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"),
|
||||
|
||||
// 订单相关 (4301-4399)
|
||||
ORDER_CREATE_FAILED(4301, "创建订单失败", "error.order_create_failed"),
|
||||
@@ -213,6 +217,9 @@ 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"),
|
||||
|
||||
// 市场服务错误 (5501-5599)
|
||||
SERVER_MARKET_PRICE_FETCH_FAILED(5501, "获取市场价格失败", "error.server.market_price_fetch_failed"),
|
||||
@@ -283,4 +290,3 @@ enum class ErrorCode(
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,11 @@
|
||||
package com.wrbug.polymarketbot.enums
|
||||
|
||||
enum class LeaderPoolStatus {
|
||||
CANDIDATE,
|
||||
WATCH,
|
||||
PAPER,
|
||||
TRIAL,
|
||||
ACTIVE,
|
||||
COOLDOWN,
|
||||
RETIRED
|
||||
}
|
||||
@@ -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,19 @@
|
||||
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)
|
||||
}
|
||||
+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)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 更新跟单状态(兼容旧接口)
|
||||
|
||||
+9
@@ -0,0 +1,9 @@
|
||||
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)
|
||||
+435
@@ -0,0 +1,435 @@
|
||||
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.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 (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,
|
||||
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")
|
||||
}
|
||||
}
|
||||
+82
-3
@@ -46,10 +46,30 @@ object CopyTradingPnlCalculator {
|
||||
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 currentPositionValue = openOrders.sumOf { order ->
|
||||
val currentPrice = findQuote(order, quotes)?.currentPrice ?: BigDecimal.ZERO
|
||||
order.remainingQuantity.toSafeBigDecimal().multi(currentPrice)
|
||||
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)
|
||||
@@ -66,6 +86,9 @@ object CopyTradingPnlCalculator {
|
||||
currentPositionQuantity = currentPositionQuantity,
|
||||
currentPositionCost = currentPositionCost,
|
||||
currentPositionValue = currentPositionValue,
|
||||
zeroValuePositionCost = zeroValuePositionCost,
|
||||
confirmedZeroValuePositionCost = confirmedZeroValuePositionCost,
|
||||
quoteStatusSummary = quoteStatusSummary,
|
||||
totalRealizedPnl = totalRealizedPnl,
|
||||
totalUnrealizedPnl = totalUnrealizedPnl,
|
||||
totalPnl = totalPnl,
|
||||
@@ -99,6 +122,59 @@ 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
|
||||
)
|
||||
|
||||
@@ -113,6 +189,9 @@ data class CopyTradingPnlStatistics(
|
||||
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,
|
||||
|
||||
+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
|
||||
}
|
||||
+24
-5
@@ -30,6 +30,7 @@ class CopyTradingStatisticsService(
|
||||
private val sellMatchDetailRepository: SellMatchDetailRepository,
|
||||
private val accountRepository: AccountRepository,
|
||||
private val leaderRepository: LeaderRepository,
|
||||
private val filteredOrderRepository: FilteredOrderRepository,
|
||||
private val marketService: com.wrbug.polymarketbot.service.common.MarketService,
|
||||
private val blockchainService: BlockchainService
|
||||
) {
|
||||
@@ -62,8 +63,8 @@ class CopyTradingStatisticsService(
|
||||
|
||||
// 6. 获取当前价格并计算真实口径统计
|
||||
// currentPositionCost 使用跟单系统记录的剩余仓位成本;currentPositionValue 使用
|
||||
// Polymarket Data API 当前价格按剩余份额估值。若某个未平仓仓位没有报价,按 0
|
||||
// 估值,避免已归零/待赎回仓位继续被统计成成本价。
|
||||
// Polymarket Data API 当前价格按剩余份额估值。缺失报价仍按 0 参与旧字段计算,
|
||||
// 但必须通过 quote status 告诉 UI 这是已确认归零、未匹配还是接口不可用。
|
||||
val hasOpenPosition = buyOrders.any { it.remainingQuantity.toSafeBigDecimal().gt(BigDecimal.ZERO) }
|
||||
val quotes = if (hasOpenPosition) {
|
||||
buildPositionValuationQuotes(account?.proxyAddress)
|
||||
@@ -71,6 +72,15 @@ class CopyTradingStatisticsService(
|
||||
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 response = CopyTradingStatisticsResponse(
|
||||
@@ -90,6 +100,14 @@ class CopyTradingStatisticsService(
|
||||
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(),
|
||||
@@ -121,8 +139,9 @@ class CopyTradingStatisticsService(
|
||||
return try {
|
||||
val positionsResult = blockchainService.getPositions(normalizedProxyAddress)
|
||||
if (positionsResult.isFailure) {
|
||||
logger.warn("获取持仓报价失败: proxyAddress=${normalizedProxyAddress.take(10)}..., error=${positionsResult.exceptionOrNull()?.message}")
|
||||
return emptyList()
|
||||
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 ->
|
||||
@@ -145,7 +164,7 @@ class CopyTradingStatisticsService(
|
||||
quotes
|
||||
} catch (e: Exception) {
|
||||
logger.warn("获取持仓报价异常: proxyAddress=${normalizedProxyAddress.take(10)}..., error=${e.message}", e)
|
||||
emptyList()
|
||||
listOf(PositionValuationQuote.unavailable(reason = e.message))
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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 池';
|
||||
@@ -151,6 +151,10 @@ 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
|
||||
|
||||
# Order related
|
||||
error.order_create_failed=Failed to create order
|
||||
@@ -247,6 +251,9 @@ 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
|
||||
|
||||
# Market service errors
|
||||
error.server.market_price_fetch_failed=Failed to fetch market price
|
||||
|
||||
@@ -151,6 +151,10 @@ 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.order_create_failed=创建订单失败
|
||||
@@ -247,6 +251,9 @@ 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.market_price_fetch_failed=获取市场价格失败
|
||||
|
||||
@@ -151,6 +151,10 @@ 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.order_create_failed=創建訂單失敗
|
||||
@@ -247,6 +251,9 @@ 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.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
|
||||
)
|
||||
}
|
||||
}
|
||||
+201
@@ -0,0 +1,201 @@
|
||||
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,
|
||||
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
|
||||
)
|
||||
}
|
||||
}
|
||||
+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()
|
||||
}
|
||||
+351
@@ -0,0 +1,351 @@
|
||||
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.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 `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
|
||||
) = LeaderPool(
|
||||
id = id,
|
||||
leaderId = leaderId,
|
||||
status = status,
|
||||
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)
|
||||
}
|
||||
}
|
||||
+120
-1
@@ -50,10 +50,14 @@ class CopyTradingPnlCalculatorTest {
|
||||
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 `treats tracked open positions without a quote as zero current value`() {
|
||||
fun `reports no match separately from available zero valuation`() {
|
||||
val buyOrders = listOf(
|
||||
buyOrder(
|
||||
id = 1,
|
||||
@@ -78,6 +82,121 @@ class CopyTradingPnlCalculatorTest {
|
||||
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(
|
||||
|
||||
+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 前端暂不暴露,避免把早期工作流变复杂。
|
||||
|
||||
## 预算提示
|
||||
|
||||
页面顶部会展示池子人数、试跟中人数、估算最坏暴露和待处理风险。估算最坏暴露只是提示,不是资金托管,也不会限制账户余额或自动调仓。
|
||||
@@ -14,6 +14,7 @@ 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 LeaderAdd from './pages/LeaderAdd'
|
||||
import LeaderEdit from './pages/LeaderEdit'
|
||||
import ConfigPage from './pages/ConfigPage'
|
||||
@@ -265,6 +266,7 @@ 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="/leaders/add" element={<ProtectedRoute><LeaderAdd /></ProtectedRoute>} />
|
||||
<Route path="/leaders/edit" element={<ProtectedRoute><LeaderEdit /></ProtectedRoute>} />
|
||||
<Route path="/templates" element={<ProtectedRoute><TemplateList /></ProtectedRoute>} />
|
||||
@@ -300,4 +302,3 @@ function App() {
|
||||
}
|
||||
|
||||
export default App
|
||||
|
||||
|
||||
@@ -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
|
||||
@@ -74,7 +74,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('/templates') || path.startsWith('/copy-trading') || path.startsWith('/backtest')) {
|
||||
keys.push('/copy-trading-management')
|
||||
}
|
||||
if (path.startsWith('/crypto-tail-strategy') || path.startsWith('/crypto-tail-monitor')) {
|
||||
@@ -92,7 +92,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('/templates') || path.startsWith('/copy-trading') || path.startsWith('/backtest')) {
|
||||
keys.push('/copy-trading-management')
|
||||
}
|
||||
if (path.startsWith('/crypto-tail-strategy') || path.startsWith('/crypto-tail-monitor')) {
|
||||
@@ -148,6 +148,11 @@ const Layout: React.FC<LayoutProps> = ({ children }) => {
|
||||
icon: <LinkOutlined />,
|
||||
label: t('menu.copyTradingConfig')
|
||||
},
|
||||
{
|
||||
key: '/leader-pool',
|
||||
icon: <TeamOutlined />,
|
||||
label: t('menu.leaderPool')
|
||||
},
|
||||
{
|
||||
key: '/leaders',
|
||||
icon: <UserOutlined />,
|
||||
@@ -504,4 +509,3 @@ const Layout: React.FC<LayoutProps> = ({ children }) => {
|
||||
}
|
||||
|
||||
export default Layout
|
||||
|
||||
|
||||
@@ -314,6 +314,7 @@
|
||||
"leaders": "Leader Management",
|
||||
"templates": "Templates",
|
||||
"copyTradingConfig": "Copy Trading Config",
|
||||
"leaderPool": "Leader Pool",
|
||||
"cryptoSpreadStrategy": "Crypto Spread Strategy",
|
||||
"cryptoTailStrategy": "Strategy Config",
|
||||
"cryptoTailMonitor": "Real-time Monitor",
|
||||
@@ -552,7 +553,68 @@
|
||||
"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",
|
||||
"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.",
|
||||
"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"
|
||||
},
|
||||
"leaderAdd": {
|
||||
"title": "Add Leader",
|
||||
@@ -1811,4 +1873,4 @@
|
||||
"accountGuideButton": "USDC.e → pUSD",
|
||||
"dismissGuide": "Got it"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -314,6 +314,7 @@
|
||||
"leaders": "Leader 管理",
|
||||
"templates": "跟单模板",
|
||||
"copyTradingConfig": "跟单配置",
|
||||
"leaderPool": "Leader 池",
|
||||
"cryptoSpreadStrategy": "加密价差策略",
|
||||
"cryptoTailStrategy": "策略配置",
|
||||
"cryptoTailMonitor": "实时监控",
|
||||
@@ -552,7 +553,68 @@
|
||||
"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 池失败",
|
||||
"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": "提交前请确认固定金额、每日最大单数、每日最大亏损、价格区间和最大持仓。",
|
||||
"account": "账户",
|
||||
"selectAccount": "请选择账户",
|
||||
"noAccounts": "暂无账户,请先添加账户",
|
||||
"trialCreated": "试跟配置已创建",
|
||||
"trialCreateFailed": "创建试跟配置失败",
|
||||
"goCopyTrading": "查看跟单配置",
|
||||
"removeConfirm": "只移除池子项,不会删除 Leader 地址或已有跟单配置。确定继续?",
|
||||
"removeSuccess": "池子项已移除",
|
||||
"removeFailed": "移除池子项失败"
|
||||
},
|
||||
"leaderAdd": {
|
||||
"title": "添加 Leader",
|
||||
@@ -1811,4 +1873,4 @@
|
||||
"accountGuideButton": "USDC.e → pUSD",
|
||||
"dismissGuide": "知道了"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -314,6 +314,7 @@
|
||||
"leaders": "Leader 管理",
|
||||
"templates": "跟單模板",
|
||||
"copyTradingConfig": "跟單配置",
|
||||
"leaderPool": "Leader 池",
|
||||
"cryptoSpreadStrategy": "加密價差策略",
|
||||
"cryptoTailStrategy": "策略配置",
|
||||
"cryptoTailMonitor": "即時監控",
|
||||
@@ -552,7 +553,68 @@
|
||||
"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 池失敗",
|
||||
"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": "提交前請確認固定金額、每日最大單數、每日最大虧損、價格區間和最大持倉。",
|
||||
"account": "賬戶",
|
||||
"selectAccount": "請選擇賬戶",
|
||||
"noAccounts": "暫無賬戶,請先添加賬戶",
|
||||
"trialCreated": "試跟配置已創建",
|
||||
"trialCreateFailed": "創建試跟配置失敗",
|
||||
"goCopyTrading": "查看跟單配置",
|
||||
"removeConfirm": "只移除池子項,不會刪除 Leader 地址或已有跟單配置。確定繼續?",
|
||||
"removeSuccess": "池子項已移除",
|
||||
"removeFailed": "移除池子項失敗"
|
||||
},
|
||||
"leaderAdd": {
|
||||
"title": "添加 Leader",
|
||||
@@ -1811,4 +1873,4 @@
|
||||
"accountGuideButton": "USDC.e → pUSD",
|
||||
"dismissGuide": "知道了"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -6,6 +6,7 @@ import { formatUSDC } from '../../utils'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { useMediaQuery } from 'react-responsive'
|
||||
import type { CopyTradingStatistics } from '../../types'
|
||||
import CopyTradingRiskSeatbeltPanel from '../../components/CopyTradingRiskSeatbeltPanel'
|
||||
|
||||
interface StatisticsModalProps {
|
||||
open: boolean
|
||||
@@ -159,6 +160,7 @@ const StatisticsModal: React.FC<StatisticsModalProps> = ({
|
||||
<span style={{ fontSize: 'clamp(12px, 4vw, 16px)' }}>${formatUSDC(statistics.totalUnrealizedPnl)}</span>
|
||||
</div>
|
||||
</div>
|
||||
<CopyTradingRiskSeatbeltPanel statistics={statistics} onApplied={fetchStatistics} compact />
|
||||
</div>
|
||||
) : (
|
||||
<div>
|
||||
@@ -230,6 +232,7 @@ const StatisticsModal: React.FC<StatisticsModalProps> = ({
|
||||
/>
|
||||
</Col>
|
||||
</Row>
|
||||
<CopyTradingRiskSeatbeltPanel statistics={statistics} onApplied={fetchStatistics} compact />
|
||||
</div>
|
||||
)}
|
||||
</Modal>
|
||||
@@ -237,4 +240,3 @@ const StatisticsModal: React.FC<StatisticsModalProps> = ({
|
||||
}
|
||||
|
||||
export default StatisticsModal
|
||||
|
||||
|
||||
@@ -6,6 +6,7 @@ import { apiService } from '../services/api'
|
||||
import { formatUSDC, formatNumber } from '../utils'
|
||||
import { useMediaQuery } from 'react-responsive'
|
||||
import type { CopyTradingStatistics } from '../types'
|
||||
import CopyTradingRiskSeatbeltPanel from '../components/CopyTradingRiskSeatbeltPanel'
|
||||
|
||||
const CopyTradingStatisticsPage: React.FC = () => {
|
||||
const { copyTradingId } = useParams<{ copyTradingId: string }>()
|
||||
@@ -263,9 +264,10 @@ const CopyTradingStatisticsPage: React.FC = () => {
|
||||
</Col>
|
||||
</Row>
|
||||
</Card>
|
||||
|
||||
<CopyTradingRiskSeatbeltPanel statistics={statistics} onApplied={fetchStatistics} />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default CopyTradingStatisticsPage
|
||||
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { useEffect, useState } from 'react'
|
||||
import { useNavigate } from 'react-router-dom'
|
||||
import { Card, Table, Button, Space, Tag, Popconfirm, message, List, Empty, Spin, Divider, Typography, Modal, Descriptions, Statistic, Row, Col, Tooltip, Badge } from 'antd'
|
||||
import { PlusOutlined, EditOutlined, DeleteOutlined, GlobalOutlined, EyeOutlined, ReloadOutlined, WalletOutlined, CopyOutlined, LineChartOutlined } from '@ant-design/icons'
|
||||
import { PlusOutlined, EditOutlined, DeleteOutlined, GlobalOutlined, EyeOutlined, ReloadOutlined, WalletOutlined, CopyOutlined, LineChartOutlined, TeamOutlined } from '@ant-design/icons'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { apiService } from '../services/api'
|
||||
import type { Leader, LeaderBalanceResponse } from '../types'
|
||||
@@ -18,6 +18,7 @@ const LeaderList: React.FC = () => {
|
||||
const [loading, setLoading] = useState(false)
|
||||
const [balanceMap, setBalanceMap] = useState<Record<number, { total: string; available: string; position: string }>>({})
|
||||
const [balanceLoading, setBalanceLoading] = useState<Record<number, boolean>>({})
|
||||
const [addingToPool, setAddingToPool] = useState<Record<number, boolean>>({})
|
||||
|
||||
// 详情 Modal
|
||||
const [detailModalVisible, setDetailModalVisible] = useState(false)
|
||||
@@ -95,6 +96,31 @@ const LeaderList: React.FC = () => {
|
||||
}
|
||||
}
|
||||
|
||||
const handleAddToPool = async (leader: Leader) => {
|
||||
setAddingToPool(prev => ({ ...prev, [leader.id]: true }))
|
||||
try {
|
||||
const response = await apiService.leaderPool.add({ leaderId: leader.id })
|
||||
if (response.data.code === 0) {
|
||||
message.success({
|
||||
content: (
|
||||
<Space>
|
||||
<span>{t('leaderList.addToPoolSuccess')}</span>
|
||||
<Button type="link" size="small" onClick={() => navigate('/leader-pool')}>
|
||||
{t('leaderList.goLeaderPool')}
|
||||
</Button>
|
||||
</Space>
|
||||
)
|
||||
})
|
||||
} else {
|
||||
message.warning(response.data.msg || t('leaderList.addToPoolExists'))
|
||||
}
|
||||
} catch (error: any) {
|
||||
message.error(error.message || t('leaderList.addToPoolFailed'))
|
||||
} finally {
|
||||
setAddingToPool(prev => ({ ...prev, [leader.id]: false }))
|
||||
}
|
||||
}
|
||||
|
||||
const handleShowDetail = async (leader: Leader) => {
|
||||
try {
|
||||
setDetailModalVisible(true)
|
||||
@@ -386,6 +412,26 @@ const LeaderList: React.FC = () => {
|
||||
</div>
|
||||
</Tooltip>
|
||||
|
||||
<Tooltip title={t('leaderList.addToPool')}>
|
||||
<div
|
||||
onClick={() => !addingToPool[record.id] && handleAddToPool(record)}
|
||||
style={{
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
width: '32px',
|
||||
height: '32px',
|
||||
cursor: addingToPool[record.id] ? 'wait' : 'pointer',
|
||||
borderRadius: '6px',
|
||||
transition: 'background-color 0.2s'
|
||||
}}
|
||||
onMouseEnter={(e) => e.currentTarget.style.backgroundColor = '#f0f0f0'}
|
||||
onMouseLeave={(e) => e.currentTarget.style.backgroundColor = 'transparent'}
|
||||
>
|
||||
<TeamOutlined style={{ fontSize: '16px', color: '#52c41a' }} />
|
||||
</div>
|
||||
</Tooltip>
|
||||
|
||||
<Popconfirm
|
||||
title={t('leaderList.deleteConfirm')}
|
||||
description={record.copyTradingCount > 0 ? t('leaderList.deleteConfirmDesc', { count: record.copyTradingCount }) : undefined}
|
||||
|
||||
@@ -0,0 +1,559 @@
|
||||
import { useEffect, useState } from 'react'
|
||||
import { useNavigate } from 'react-router-dom'
|
||||
import {
|
||||
Alert,
|
||||
Button,
|
||||
Card,
|
||||
DatePicker,
|
||||
Descriptions,
|
||||
Empty,
|
||||
Form,
|
||||
Input,
|
||||
InputNumber,
|
||||
Modal,
|
||||
Popconfirm,
|
||||
Row,
|
||||
Col,
|
||||
Select,
|
||||
Space,
|
||||
Statistic,
|
||||
Table,
|
||||
Tag,
|
||||
Typography,
|
||||
message
|
||||
} from 'antd'
|
||||
import { EyeOutlined, LinkOutlined, PlusOutlined, ReloadOutlined, SafetyCertificateOutlined } from '@ant-design/icons'
|
||||
import dayjs from 'dayjs'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { apiService } from '../services/api'
|
||||
import type { Account, Leader, LeaderPoolItem, LeaderPoolListResponse, LeaderPoolStatus } from '../types'
|
||||
|
||||
const { Text, Title, Paragraph } = Typography
|
||||
|
||||
const VISIBLE_STATUSES: Array<{ value: LeaderPoolStatus; color: string }> = [
|
||||
{ value: 'CANDIDATE', color: 'default' },
|
||||
{ value: 'WATCH', color: 'blue' },
|
||||
{ value: 'TRIAL', color: 'green' },
|
||||
{ value: 'COOLDOWN', color: 'orange' },
|
||||
{ value: 'RETIRED', color: 'red' }
|
||||
]
|
||||
|
||||
type LeaderPoolFilterValue = LeaderPoolStatus | 'ALL'
|
||||
|
||||
const statusLabels: Record<LeaderPoolStatus, string> = {
|
||||
CANDIDATE: '候选',
|
||||
WATCH: '观察',
|
||||
PAPER: '模拟',
|
||||
TRIAL: '小额试跟',
|
||||
ACTIVE: '活跃',
|
||||
COOLDOWN: '冷却',
|
||||
RETIRED: '淘汰'
|
||||
}
|
||||
|
||||
const formatDate = (timestamp?: number) => {
|
||||
if (!timestamp) return '-'
|
||||
return dayjs(timestamp).format('YYYY-MM-DD HH:mm')
|
||||
}
|
||||
|
||||
const LeaderPool: React.FC = () => {
|
||||
const { t } = useTranslation()
|
||||
const navigate = useNavigate()
|
||||
const [poolData, setPoolData] = useState<LeaderPoolListResponse>({
|
||||
summary: {
|
||||
totalCount: 0,
|
||||
trialCount: 0,
|
||||
estimatedWorstExposure: '0',
|
||||
pendingRiskCount: 0,
|
||||
defaultExperimentBudget: '50'
|
||||
},
|
||||
list: [],
|
||||
total: 0
|
||||
})
|
||||
const [leaders, setLeaders] = useState<Leader[]>([])
|
||||
const [accounts, setAccounts] = useState<Account[]>([])
|
||||
const [statusFilter, setStatusFilter] = useState<LeaderPoolStatus | undefined>()
|
||||
const [loading, setLoading] = useState(false)
|
||||
const [adding, setAdding] = useState(false)
|
||||
const [creatingMap, setCreatingMap] = useState<Record<number, boolean>>({})
|
||||
const [selectedLeaderId, setSelectedLeaderId] = useState<number | undefined>()
|
||||
const [statusModalItem, setStatusModalItem] = useState<LeaderPoolItem | null>(null)
|
||||
const [planModalItem, setPlanModalItem] = useState<LeaderPoolItem | null>(null)
|
||||
const [trialModalItem, setTrialModalItem] = useState<LeaderPoolItem | null>(null)
|
||||
const [statusForm] = Form.useForm()
|
||||
const [planForm] = Form.useForm()
|
||||
const [trialForm] = Form.useForm()
|
||||
|
||||
const fetchPool = async (status = statusFilter) => {
|
||||
setLoading(true)
|
||||
try {
|
||||
const response = await apiService.leaderPool.list(status ? { status } : {})
|
||||
if (response.data.code === 0 && response.data.data) {
|
||||
setPoolData(response.data.data)
|
||||
} else {
|
||||
message.error(response.data.msg || t('leaderPool.fetchFailed'))
|
||||
}
|
||||
} catch (error: any) {
|
||||
message.error(error.message || t('leaderPool.fetchFailed'))
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
const fetchLeaders = async () => {
|
||||
try {
|
||||
const response = await apiService.leaders.list()
|
||||
if (response.data.code === 0 && response.data.data) {
|
||||
setLeaders(response.data.data.list || [])
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('加载 Leader 列表失败:', error)
|
||||
}
|
||||
}
|
||||
|
||||
const fetchAccounts = async () => {
|
||||
try {
|
||||
const response = await apiService.accounts.list()
|
||||
if (response.data.code === 0 && response.data.data) {
|
||||
setAccounts(response.data.data.list || [])
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('加载账户列表失败:', error)
|
||||
}
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
fetchPool()
|
||||
fetchLeaders()
|
||||
fetchAccounts()
|
||||
}, [])
|
||||
|
||||
useEffect(() => {
|
||||
fetchPool(statusFilter)
|
||||
}, [statusFilter])
|
||||
|
||||
const handleAddLeader = async () => {
|
||||
if (!selectedLeaderId) {
|
||||
message.warning(t('leaderPool.selectLeaderFirst'))
|
||||
return
|
||||
}
|
||||
setAdding(true)
|
||||
try {
|
||||
const response = await apiService.leaderPool.add({ leaderId: selectedLeaderId })
|
||||
if (response.data.code === 0) {
|
||||
message.success(t('leaderPool.addSuccess'))
|
||||
setSelectedLeaderId(undefined)
|
||||
fetchPool()
|
||||
} else {
|
||||
message.warning(response.data.msg || t('leaderPool.addFailed'))
|
||||
}
|
||||
} catch (error: any) {
|
||||
message.error(error.message || t('leaderPool.addFailed'))
|
||||
} finally {
|
||||
setAdding(false)
|
||||
}
|
||||
}
|
||||
|
||||
const openStatusModal = (item: LeaderPoolItem) => {
|
||||
setStatusModalItem(item)
|
||||
statusForm.setFieldsValue({
|
||||
status: item.status,
|
||||
cooldownUntil: item.cooldownUntil ? dayjs(item.cooldownUntil) : undefined,
|
||||
locked: item.locked
|
||||
})
|
||||
}
|
||||
|
||||
const handleUpdateStatus = async () => {
|
||||
if (!statusModalItem) return
|
||||
const values = await statusForm.validateFields()
|
||||
const response = await apiService.leaderPool.updateStatus({
|
||||
poolId: statusModalItem.id,
|
||||
status: values.status,
|
||||
cooldownUntil: values.cooldownUntil ? values.cooldownUntil.valueOf() : undefined,
|
||||
locked: values.locked
|
||||
})
|
||||
if (response.data.code === 0) {
|
||||
message.success(t('leaderPool.statusUpdated'))
|
||||
setStatusModalItem(null)
|
||||
fetchPool()
|
||||
} else {
|
||||
message.error(response.data.msg || t('leaderPool.statusUpdateFailed'))
|
||||
}
|
||||
}
|
||||
|
||||
const openPlanModal = (item: LeaderPoolItem) => {
|
||||
setPlanModalItem(item)
|
||||
planForm.setFieldsValue({
|
||||
suggestedFixedAmount: item.suggestedFixedAmount,
|
||||
suggestedMaxDailyOrders: item.suggestedMaxDailyOrders,
|
||||
suggestedMaxDailyLoss: item.suggestedMaxDailyLoss,
|
||||
suggestedMinPrice: item.suggestedMinPrice,
|
||||
suggestedMaxPrice: item.suggestedMaxPrice,
|
||||
suggestedMaxPositionValue: item.suggestedMaxPositionValue,
|
||||
notes: item.notes
|
||||
})
|
||||
}
|
||||
|
||||
const handleUpdatePlan = async () => {
|
||||
if (!planModalItem) return
|
||||
const values = await planForm.validateFields()
|
||||
const response = await apiService.leaderPool.updatePlan({
|
||||
poolId: planModalItem.id,
|
||||
suggestedFixedAmount: values.suggestedFixedAmount?.toString(),
|
||||
suggestedMaxDailyOrders: values.suggestedMaxDailyOrders,
|
||||
suggestedMaxDailyLoss: values.suggestedMaxDailyLoss?.toString(),
|
||||
suggestedMinPrice: values.suggestedMinPrice?.toString(),
|
||||
suggestedMaxPrice: values.suggestedMaxPrice?.toString(),
|
||||
suggestedMaxPositionValue: values.suggestedMaxPositionValue?.toString(),
|
||||
notes: values.notes
|
||||
})
|
||||
if (response.data.code === 0) {
|
||||
message.success(t('leaderPool.planUpdated'))
|
||||
setPlanModalItem(null)
|
||||
fetchPool()
|
||||
} else {
|
||||
message.error(response.data.msg || t('leaderPool.planUpdateFailed'))
|
||||
}
|
||||
}
|
||||
|
||||
const openTrialModal = (item: LeaderPoolItem) => {
|
||||
setTrialModalItem(item)
|
||||
trialForm.setFieldsValue({ accountId: accounts[0]?.id })
|
||||
}
|
||||
|
||||
const handleCreateTrialConfig = async () => {
|
||||
if (!trialModalItem) return
|
||||
if (creatingMap[trialModalItem.id]) return
|
||||
const values = await trialForm.validateFields()
|
||||
setCreatingMap(prev => ({ ...prev, [trialModalItem.id]: true }))
|
||||
try {
|
||||
const response = await apiService.leaderPool.createTrialConfig({
|
||||
poolId: trialModalItem.id,
|
||||
accountId: values.accountId,
|
||||
enableImmediately: false,
|
||||
confirm: false
|
||||
})
|
||||
if (response.data.code === 0) {
|
||||
message.success({
|
||||
content: (
|
||||
<Space>
|
||||
<span>{t('leaderPool.trialCreated')}</span>
|
||||
<Button type="link" size="small" onClick={() => navigate('/copy-trading')}>
|
||||
{t('leaderPool.goCopyTrading')}
|
||||
</Button>
|
||||
</Space>
|
||||
)
|
||||
})
|
||||
setTrialModalItem(null)
|
||||
fetchPool()
|
||||
} else {
|
||||
message.error(response.data.msg || t('leaderPool.trialCreateFailed'))
|
||||
}
|
||||
} catch (error: any) {
|
||||
message.error(error.message || t('leaderPool.trialCreateFailed'))
|
||||
} finally {
|
||||
setCreatingMap(prev => ({ ...prev, [trialModalItem.id]: false }))
|
||||
}
|
||||
}
|
||||
|
||||
const handleRemove = async (item: LeaderPoolItem) => {
|
||||
const response = await apiService.leaderPool.remove({ poolId: item.id })
|
||||
if (response.data.code === 0) {
|
||||
message.success(t('leaderPool.removeSuccess'))
|
||||
fetchPool()
|
||||
} else {
|
||||
message.error(response.data.msg || t('leaderPool.removeFailed'))
|
||||
}
|
||||
}
|
||||
|
||||
const columns = [
|
||||
{
|
||||
title: t('leaderPool.leader'),
|
||||
key: 'leader',
|
||||
width: 260,
|
||||
render: (_: unknown, item: LeaderPoolItem) => (
|
||||
<Space direction="vertical" size={0}>
|
||||
<Text strong>{item.leaderName || `Leader ${item.leaderId}`}</Text>
|
||||
<Text copyable style={{ fontSize: 12, fontFamily: 'monospace' }} type="secondary">
|
||||
{item.leaderAddress}
|
||||
</Text>
|
||||
</Space>
|
||||
)
|
||||
},
|
||||
{
|
||||
title: t('common.status'),
|
||||
dataIndex: 'status',
|
||||
width: 120,
|
||||
render: (status: LeaderPoolStatus) => {
|
||||
const meta = VISIBLE_STATUSES.find(item => item.value === status)
|
||||
return <Tag color={meta?.color || 'default'}>{statusLabels[status] || status}</Tag>
|
||||
}
|
||||
},
|
||||
{
|
||||
title: t('leaderPool.source'),
|
||||
dataIndex: 'source',
|
||||
width: 120
|
||||
},
|
||||
{
|
||||
title: t('leaderPool.suggestedConfig'),
|
||||
key: 'plan',
|
||||
width: 220,
|
||||
render: (_: unknown, item: LeaderPoolItem) => (
|
||||
<Space direction="vertical" size={0}>
|
||||
<Text>{t('leaderPool.fixedAmount')}: {item.suggestedFixedAmount}</Text>
|
||||
<Text type="secondary">{t('leaderPool.maxDailyOrders')}: {item.suggestedMaxDailyOrders}</Text>
|
||||
<Text type="secondary">{t('leaderPool.maxDailyLoss')}: {item.suggestedMaxDailyLoss}</Text>
|
||||
</Space>
|
||||
)
|
||||
},
|
||||
{
|
||||
title: t('leaderPool.copyTradingState'),
|
||||
key: 'copyTradingState',
|
||||
width: 160,
|
||||
render: (_: unknown, item: LeaderPoolItem) => (
|
||||
<Space direction="vertical" size={0}>
|
||||
<Text>{item.copyTradingCount} {t('leaderPool.configs')}</Text>
|
||||
<Tag color={item.hasEnabledCopyTrading ? 'green' : 'default'}>
|
||||
{item.hasEnabledCopyTrading ? t('leaderPool.hasEnabled') : t('leaderPool.noEnabled')}
|
||||
</Tag>
|
||||
</Space>
|
||||
)
|
||||
},
|
||||
{
|
||||
title: t('leaderPool.lastReviewedAt'),
|
||||
dataIndex: 'lastReviewedAt',
|
||||
width: 150,
|
||||
render: (value?: number) => formatDate(value)
|
||||
},
|
||||
{
|
||||
title: t('common.actions'),
|
||||
key: 'actions',
|
||||
fixed: 'right' as const,
|
||||
width: 320,
|
||||
render: (_: unknown, item: LeaderPoolItem) => (
|
||||
<Space wrap size={4}>
|
||||
<Button size="small" icon={<EyeOutlined />} onClick={() => window.open(item.profileUrl, '_blank', 'noopener,noreferrer')}>
|
||||
Profile
|
||||
</Button>
|
||||
<Button size="small" onClick={() => openStatusModal(item)}>
|
||||
{t('leaderPool.updateStatus')}
|
||||
</Button>
|
||||
<Button size="small" onClick={() => openPlanModal(item)}>
|
||||
{t('leaderPool.editPlan')}
|
||||
</Button>
|
||||
<Button
|
||||
size="small"
|
||||
type="primary"
|
||||
loading={creatingMap[item.id]}
|
||||
disabled={creatingMap[item.id]}
|
||||
onClick={() => openTrialModal(item)}
|
||||
>
|
||||
{t('leaderPool.createTrial')}
|
||||
</Button>
|
||||
<Popconfirm
|
||||
title={t('leaderPool.removeConfirm')}
|
||||
okText={t('common.confirm')}
|
||||
cancelText={t('common.cancel')}
|
||||
onConfirm={() => handleRemove(item)}
|
||||
>
|
||||
<Button size="small" danger>{t('common.delete')}</Button>
|
||||
</Popconfirm>
|
||||
</Space>
|
||||
)
|
||||
}
|
||||
]
|
||||
|
||||
return (
|
||||
<div>
|
||||
<Space direction="vertical" size="large" style={{ width: '100%' }}>
|
||||
<Card>
|
||||
<Space direction="vertical" style={{ width: '100%' }}>
|
||||
<Space align="start" style={{ justifyContent: 'space-between', width: '100%' }}>
|
||||
<div>
|
||||
<Title level={3} style={{ marginBottom: 4 }}>{t('leaderPool.title')}</Title>
|
||||
<Paragraph type="secondary" style={{ marginBottom: 0 }}>
|
||||
{t('leaderPool.subtitle')}
|
||||
</Paragraph>
|
||||
</div>
|
||||
<Button icon={<ReloadOutlined />} onClick={() => fetchPool()}>{t('common.refresh')}</Button>
|
||||
</Space>
|
||||
<Alert
|
||||
type="info"
|
||||
showIcon
|
||||
message={t('leaderPool.safetyHint')}
|
||||
description={t('leaderPool.safetyDesc')}
|
||||
/>
|
||||
<Space wrap>
|
||||
<Select
|
||||
style={{ minWidth: 260 }}
|
||||
allowClear
|
||||
showSearch
|
||||
placeholder={t('leaderPool.selectLeaderPlaceholder')}
|
||||
optionFilterProp="label"
|
||||
value={selectedLeaderId}
|
||||
onChange={setSelectedLeaderId}
|
||||
options={leaders.map(leader => ({
|
||||
label: `${leader.leaderName || `Leader ${leader.id}`} - ${leader.leaderAddress.slice(0, 8)}...`,
|
||||
value: leader.id
|
||||
}))}
|
||||
notFoundContent={<Empty image={Empty.PRESENTED_IMAGE_SIMPLE} description={t('leaderPool.noLeaders')} />}
|
||||
/>
|
||||
<Button type="primary" icon={<PlusOutlined />} loading={adding} onClick={handleAddLeader}>
|
||||
{t('leaderPool.addExistingLeader')}
|
||||
</Button>
|
||||
<Button icon={<LinkOutlined />} onClick={() => navigate('/leaders')}>
|
||||
{t('leaderPool.goLeaders')}
|
||||
</Button>
|
||||
</Space>
|
||||
</Space>
|
||||
</Card>
|
||||
|
||||
<Row gutter={[16, 16]}>
|
||||
<Col xs={24} sm={12} lg={6}>
|
||||
<Card><Statistic title={t('leaderPool.totalCount')} value={poolData.summary.totalCount} /></Card>
|
||||
</Col>
|
||||
<Col xs={24} sm={12} lg={6}>
|
||||
<Card><Statistic title={t('leaderPool.trialCount')} value={poolData.summary.trialCount} /></Card>
|
||||
</Col>
|
||||
<Col xs={24} sm={12} lg={6}>
|
||||
<Card><Statistic prefix="$" title={t('leaderPool.estimatedWorstExposure')} value={poolData.summary.estimatedWorstExposure} /></Card>
|
||||
</Col>
|
||||
<Col xs={24} sm={12} lg={6}>
|
||||
<Card><Statistic title={t('leaderPool.pendingRiskCount')} value={poolData.summary.pendingRiskCount} /></Card>
|
||||
</Col>
|
||||
</Row>
|
||||
|
||||
<Card>
|
||||
<Space direction="vertical" size="middle" style={{ width: '100%' }}>
|
||||
<Select
|
||||
style={{ width: 220 }}
|
||||
placeholder={t('leaderPool.filterStatus')}
|
||||
value={statusFilter ?? 'ALL'}
|
||||
onChange={(value: LeaderPoolFilterValue) => setStatusFilter(value === 'ALL' ? undefined : value)}
|
||||
options={[
|
||||
{ value: 'ALL', label: t('common.all') },
|
||||
...VISIBLE_STATUSES.map(item => ({ value: item.value, label: statusLabels[item.value] }))
|
||||
]}
|
||||
/>
|
||||
<Table
|
||||
rowKey="id"
|
||||
loading={loading}
|
||||
columns={columns}
|
||||
dataSource={poolData.list}
|
||||
scroll={{ x: 1280 }}
|
||||
locale={{
|
||||
emptyText: (
|
||||
<Empty
|
||||
description={t('leaderPool.emptyDesc')}
|
||||
image={Empty.PRESENTED_IMAGE_SIMPLE}
|
||||
/>
|
||||
)
|
||||
}}
|
||||
/>
|
||||
</Space>
|
||||
</Card>
|
||||
</Space>
|
||||
|
||||
<Modal
|
||||
title={t('leaderPool.updateStatus')}
|
||||
open={!!statusModalItem}
|
||||
onCancel={() => setStatusModalItem(null)}
|
||||
onOk={handleUpdateStatus}
|
||||
>
|
||||
<Form form={statusForm} layout="vertical">
|
||||
<Form.Item name="status" label={t('common.status')} rules={[{ required: true }]}>
|
||||
<Select options={VISIBLE_STATUSES.map(item => ({ value: item.value, label: statusLabels[item.value] }))} />
|
||||
</Form.Item>
|
||||
<Form.Item shouldUpdate noStyle>
|
||||
{({ getFieldValue }) => getFieldValue('status') === 'COOLDOWN' ? (
|
||||
<Form.Item name="cooldownUntil" label={t('leaderPool.cooldownUntil')}>
|
||||
<DatePicker showTime style={{ width: '100%' }} />
|
||||
</Form.Item>
|
||||
) : null}
|
||||
</Form.Item>
|
||||
<Form.Item name="locked" label={t('leaderPool.locked')}>
|
||||
<Select
|
||||
options={[
|
||||
{ value: false, label: t('common.no') },
|
||||
{ value: true, label: t('common.yes') }
|
||||
]}
|
||||
/>
|
||||
</Form.Item>
|
||||
</Form>
|
||||
</Modal>
|
||||
|
||||
<Modal
|
||||
title={t('leaderPool.editPlan')}
|
||||
open={!!planModalItem}
|
||||
onCancel={() => setPlanModalItem(null)}
|
||||
onOk={handleUpdatePlan}
|
||||
>
|
||||
<Form form={planForm} layout="vertical">
|
||||
<Form.Item name="suggestedFixedAmount" label={t('leaderPool.fixedAmount')} rules={[{ required: true }]}>
|
||||
<InputNumber min={0.01} style={{ width: '100%' }} />
|
||||
</Form.Item>
|
||||
<Form.Item name="suggestedMaxDailyOrders" label={t('leaderPool.maxDailyOrders')} rules={[{ required: true }]}>
|
||||
<InputNumber min={1} max={100} style={{ width: '100%' }} />
|
||||
</Form.Item>
|
||||
<Form.Item name="suggestedMaxDailyLoss" label={t('leaderPool.maxDailyLoss')} rules={[{ required: true }]}>
|
||||
<InputNumber min={0.01} style={{ width: '100%' }} />
|
||||
</Form.Item>
|
||||
<Form.Item name="suggestedMinPrice" label={t('leaderPool.minPrice')}>
|
||||
<InputNumber min={0} max={1} step={0.01} style={{ width: '100%' }} />
|
||||
</Form.Item>
|
||||
<Form.Item name="suggestedMaxPrice" label={t('leaderPool.maxPrice')}>
|
||||
<InputNumber min={0} max={1} step={0.01} style={{ width: '100%' }} />
|
||||
</Form.Item>
|
||||
<Form.Item name="suggestedMaxPositionValue" label={t('leaderPool.maxPositionValue')}>
|
||||
<InputNumber min={0.01} style={{ width: '100%' }} />
|
||||
</Form.Item>
|
||||
<Form.Item name="notes" label={t('leaderPool.notes')}>
|
||||
<Input.TextArea rows={3} />
|
||||
</Form.Item>
|
||||
</Form>
|
||||
</Modal>
|
||||
|
||||
<Modal
|
||||
title={t('leaderPool.createTrial')}
|
||||
open={!!trialModalItem}
|
||||
onCancel={() => setTrialModalItem(null)}
|
||||
onOk={handleCreateTrialConfig}
|
||||
confirmLoading={trialModalItem ? creatingMap[trialModalItem.id] : false}
|
||||
>
|
||||
{trialModalItem && (
|
||||
<Space direction="vertical" style={{ width: '100%' }}>
|
||||
<Alert
|
||||
type="warning"
|
||||
showIcon
|
||||
icon={<SafetyCertificateOutlined />}
|
||||
message={t('leaderPool.trialConfirmTitle')}
|
||||
description={t('leaderPool.trialConfirmDesc')}
|
||||
/>
|
||||
<Form form={trialForm} layout="vertical">
|
||||
<Form.Item name="accountId" label={t('leaderPool.account')} rules={[{ required: true, message: t('leaderPool.selectAccount') }]}>
|
||||
<Select
|
||||
options={accounts.map(account => ({
|
||||
value: account.id,
|
||||
label: `${account.accountName || account.walletAddress} (${account.proxyAddress?.slice(0, 8)}...)`
|
||||
}))}
|
||||
notFoundContent={<Empty image={Empty.PRESENTED_IMAGE_SIMPLE} description={t('leaderPool.noAccounts')} />}
|
||||
/>
|
||||
</Form.Item>
|
||||
</Form>
|
||||
<Descriptions bordered size="small" column={1}>
|
||||
<Descriptions.Item label={t('leaderPool.fixedAmount')}>{trialModalItem.suggestedFixedAmount}</Descriptions.Item>
|
||||
<Descriptions.Item label={t('leaderPool.maxDailyOrders')}>{trialModalItem.suggestedMaxDailyOrders}</Descriptions.Item>
|
||||
<Descriptions.Item label={t('leaderPool.maxDailyLoss')}>{trialModalItem.suggestedMaxDailyLoss}</Descriptions.Item>
|
||||
<Descriptions.Item label={t('leaderPool.priceRange')}>
|
||||
{trialModalItem.suggestedMinPrice || '-'} - {trialModalItem.suggestedMaxPrice || '-'}
|
||||
</Descriptions.Item>
|
||||
<Descriptions.Item label={t('leaderPool.maxPositionValue')}>{trialModalItem.suggestedMaxPositionValue || '5'}</Descriptions.Item>
|
||||
<Descriptions.Item label={t('common.status')}>{t('common.disabled')}</Descriptions.Item>
|
||||
</Descriptions>
|
||||
</Space>
|
||||
)}
|
||||
</Modal>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default LeaderPool
|
||||
@@ -1,5 +1,20 @@
|
||||
import axios, { AxiosInstance, AxiosError } from 'axios'
|
||||
import type { ApiResponse, NotificationConfig, NotificationConfigRequest, NotificationConfigUpdateRequest, NotificationTemplate, TemplateTypeInfo, TemplateVariablesResponse } from '../types'
|
||||
import type {
|
||||
ApiResponse,
|
||||
LeaderPoolAddRequest,
|
||||
LeaderPoolCreateTrialConfigRequest,
|
||||
LeaderPoolItem,
|
||||
LeaderPoolListRequest,
|
||||
LeaderPoolListResponse,
|
||||
LeaderPoolUpdatePlanRequest,
|
||||
LeaderPoolUpdateStatusRequest,
|
||||
NotificationConfig,
|
||||
NotificationConfigRequest,
|
||||
NotificationConfigUpdateRequest,
|
||||
NotificationTemplate,
|
||||
TemplateTypeInfo,
|
||||
TemplateVariablesResponse
|
||||
} from '../types'
|
||||
import { getToken, setToken, removeToken } from '../utils'
|
||||
import { wsManager } from './websocket'
|
||||
import i18n from '../i18n/config'
|
||||
@@ -358,6 +373,29 @@ export const apiService = {
|
||||
balance: (data: { leaderId: number }) =>
|
||||
apiClient.post<ApiResponse<any>>('/copy-trading/leaders/balance', data)
|
||||
},
|
||||
|
||||
/**
|
||||
* Leader 池 API
|
||||
*/
|
||||
leaderPool: {
|
||||
list: (data: LeaderPoolListRequest = {}) =>
|
||||
apiClient.post<ApiResponse<LeaderPoolListResponse>>('/copy-trading/leader-pool/list', data),
|
||||
|
||||
add: (data: LeaderPoolAddRequest) =>
|
||||
apiClient.post<ApiResponse<LeaderPoolItem>>('/copy-trading/leader-pool/add', data),
|
||||
|
||||
updateStatus: (data: LeaderPoolUpdateStatusRequest) =>
|
||||
apiClient.post<ApiResponse<LeaderPoolItem>>('/copy-trading/leader-pool/update-status', data),
|
||||
|
||||
updatePlan: (data: LeaderPoolUpdatePlanRequest) =>
|
||||
apiClient.post<ApiResponse<LeaderPoolItem>>('/copy-trading/leader-pool/update-plan', data),
|
||||
|
||||
createTrialConfig: (data: LeaderPoolCreateTrialConfigRequest) =>
|
||||
apiClient.post<ApiResponse<any>>('/copy-trading/leader-pool/create-trial-config', data),
|
||||
|
||||
remove: (data: { poolId: number }) =>
|
||||
apiClient.post<ApiResponse<void>>('/copy-trading/leader-pool/remove', data)
|
||||
},
|
||||
|
||||
/**
|
||||
* 跟单模板管理 API(子菜单:跟单模板)
|
||||
@@ -571,6 +609,21 @@ export const apiService = {
|
||||
detail: (data: { copyTradingId: number }) =>
|
||||
apiClient.post<ApiResponse<any>>('/copy-trading/statistics/detail', data)
|
||||
},
|
||||
|
||||
safetyConfig: {
|
||||
applyConservative: (data: {
|
||||
copyTradingId: number
|
||||
confirm: boolean
|
||||
maxDailyOrders?: number
|
||||
maxDailyLoss?: string
|
||||
minPrice?: string
|
||||
maxPrice?: string
|
||||
maxPositionValue?: string
|
||||
minOrderDepth?: string
|
||||
maxSpread?: string
|
||||
priceTolerance?: string
|
||||
}) => apiClient.post<ApiResponse<any>>('/copy-trading/configs/apply-conservative-config', data)
|
||||
},
|
||||
|
||||
/**
|
||||
* 订单跟踪 API
|
||||
@@ -950,4 +1003,3 @@ export const backtestService = {
|
||||
*/
|
||||
rerun: (data: { id: number; taskName?: string }) => apiClient.post('/backtest/tasks/rerun', data)
|
||||
}
|
||||
|
||||
|
||||
@@ -308,6 +308,89 @@ export interface CopyTradingListResponse {
|
||||
total: number
|
||||
}
|
||||
|
||||
export type LeaderPoolStatus = 'CANDIDATE' | 'WATCH' | 'PAPER' | 'TRIAL' | 'ACTIVE' | 'COOLDOWN' | 'RETIRED'
|
||||
|
||||
export interface LeaderPoolSummary {
|
||||
totalCount: number
|
||||
trialCount: number
|
||||
estimatedWorstExposure: string
|
||||
pendingRiskCount: number
|
||||
defaultExperimentBudget: string
|
||||
}
|
||||
|
||||
export interface LeaderPoolItem {
|
||||
id: number
|
||||
leaderId: number
|
||||
leaderName?: string
|
||||
leaderAddress: string
|
||||
category?: string
|
||||
profileUrl: string
|
||||
status: LeaderPoolStatus
|
||||
source: string
|
||||
sourceRank?: number
|
||||
score?: string
|
||||
reason?: string
|
||||
notes?: string
|
||||
suggestedFixedAmount: string
|
||||
suggestedMaxDailyOrders: number
|
||||
suggestedMaxDailyLoss: string
|
||||
suggestedMinPrice?: string
|
||||
suggestedMaxPrice?: string
|
||||
suggestedMaxPositionValue?: string
|
||||
copyTradingCount: number
|
||||
hasEnabledCopyTrading: boolean
|
||||
estimatedWorstExposure: string
|
||||
lastReviewedAt?: number
|
||||
lastPromotedAt?: number
|
||||
cooldownUntil?: number
|
||||
locked: boolean
|
||||
createdAt: number
|
||||
updatedAt: number
|
||||
}
|
||||
|
||||
export interface LeaderPoolListResponse {
|
||||
summary: LeaderPoolSummary
|
||||
list: LeaderPoolItem[]
|
||||
total: number
|
||||
}
|
||||
|
||||
export interface LeaderPoolListRequest {
|
||||
status?: LeaderPoolStatus
|
||||
}
|
||||
|
||||
export interface LeaderPoolAddRequest {
|
||||
leaderId: number
|
||||
source?: string
|
||||
reason?: string
|
||||
notes?: string
|
||||
}
|
||||
|
||||
export interface LeaderPoolUpdateStatusRequest {
|
||||
poolId: number
|
||||
status: LeaderPoolStatus
|
||||
cooldownUntil?: number
|
||||
locked?: boolean
|
||||
}
|
||||
|
||||
export interface LeaderPoolUpdatePlanRequest {
|
||||
poolId: number
|
||||
suggestedFixedAmount?: string
|
||||
suggestedMaxDailyOrders?: number
|
||||
suggestedMaxDailyLoss?: string
|
||||
suggestedMinPrice?: string
|
||||
suggestedMaxPrice?: string
|
||||
suggestedMaxPositionValue?: string
|
||||
reason?: string
|
||||
notes?: string
|
||||
}
|
||||
|
||||
export interface LeaderPoolCreateTrialConfigRequest {
|
||||
poolId: number
|
||||
accountId: number
|
||||
enableImmediately?: boolean
|
||||
confirm?: boolean
|
||||
}
|
||||
|
||||
/**
|
||||
* 跟单创建请求
|
||||
* 所有配置参数都需要手动输入,模板仅用于前端快速填充表单
|
||||
@@ -707,6 +790,14 @@ export interface CopyTradingStatistics {
|
||||
currentPositionQuantity: string
|
||||
currentPositionCost: string
|
||||
currentPositionValue: string // 按当前价格估算的持仓市值
|
||||
zeroValuePositionCost?: string
|
||||
confirmedZeroValuePositionCost?: string
|
||||
quoteOverallStatus?: 'AVAILABLE' | 'NO_MATCH' | 'UNAVAILABLE'
|
||||
quoteAvailableCount?: number
|
||||
quoteNoMatchCount?: number
|
||||
quoteUnavailableCount?: number
|
||||
quoteIncomplete?: boolean
|
||||
riskDiagnosis?: CopyTradingRiskDiagnosis | null
|
||||
|
||||
// 盈亏统计
|
||||
totalRealizedPnl: string
|
||||
@@ -715,6 +806,45 @@ export interface CopyTradingStatistics {
|
||||
totalPnlPercent: string
|
||||
}
|
||||
|
||||
export interface CopyTradingRiskDiagnosis {
|
||||
copyTradingId: number
|
||||
totalRealizedPnl: string
|
||||
totalUnrealizedPnl: string
|
||||
totalPnl: string
|
||||
currentPositionCost: string
|
||||
currentPositionValue: string
|
||||
zeroValuePositionCost: string
|
||||
confirmedZeroValuePositionCost: string
|
||||
zeroSellLoss: string
|
||||
openPositionQuantity: string
|
||||
totalBuyOrders: number
|
||||
totalSellRecords: number
|
||||
totalMatchDetails: number
|
||||
filteredOrderCount: number
|
||||
sampleSize: number
|
||||
lowConfidence: boolean
|
||||
confidenceReason: string
|
||||
quoteOverallStatus: 'AVAILABLE' | 'NO_MATCH' | 'UNAVAILABLE'
|
||||
quoteAvailableCount: number
|
||||
quoteNoMatchCount: number
|
||||
quoteUnavailableCount: number
|
||||
dataIncomplete: boolean
|
||||
missingSources: string[]
|
||||
topLosingMarkets: Array<{
|
||||
marketId: string
|
||||
realizedPnl: string
|
||||
matchedOrders: number
|
||||
}>
|
||||
riskWarnings: Array<{
|
||||
field: string
|
||||
currentValue: string | null
|
||||
suggestedValue: string
|
||||
severity: 'LOW' | 'MEDIUM' | 'HIGH'
|
||||
reason: string
|
||||
}>
|
||||
generatedAt: number
|
||||
}
|
||||
|
||||
/**
|
||||
* 买入订单信息
|
||||
*/
|
||||
|
||||
@@ -0,0 +1,2 @@
|
||||
schema: spec-driven
|
||||
created: 2026-05-03
|
||||
@@ -0,0 +1,384 @@
|
||||
## Context
|
||||
|
||||
PolyHermes 当前把跟单流程拆成两层:
|
||||
|
||||
- `copy_trading_leaders` 保存可被跟单的钱包地址,前端入口是 `Leader 管理`。
|
||||
- `copy_trading` 保存真实跟单配置,前端入口是 `跟单配置`。
|
||||
|
||||
这两层之间缺少“候选池/配仓台”。用户如果想用小仓位摊大饼,只能靠脑子或外部笔记维护候选状态,然后手动创建真实跟单配置。这对真钱交易不够安全:观察、试跟、冷却、淘汰没有独立状态,配置也容易沿用过松默认值。
|
||||
|
||||
本设计新增 `Leader 池`,把它定位为 `Leader 管理` 与 `跟单配置` 中间的决策层。它不替代现有 leader 地址库,也不替代真实跟单配置;它负责帮助用户决定“谁进入候选、谁小额试跟、谁冷却或淘汰”。
|
||||
|
||||
## Goals / Non-Goals
|
||||
|
||||
**Goals:**
|
||||
|
||||
- 新增左侧菜单入口 `跟单交易 -> Leader 池`,路径为 `/leader-pool`。
|
||||
- 支持把已有 leader 加入池子,并维护池子状态。
|
||||
- 支持从 `Leader 管理` 一键加入池子。
|
||||
- 支持从池子创建保守小额跟单配置。
|
||||
- 在页面上展示池子人数、试跟人数、估算最坏暴露、待处理风险。
|
||||
- 复用现有 leader、账户、跟单配置和统计能力,避免重复业务逻辑。
|
||||
- 用显式确认和保守默认值保护真钱交易安全。
|
||||
|
||||
**Non-Goals:**
|
||||
|
||||
- 不接入 Polymarket leaderboard 自动发现。
|
||||
- 不自动启用大额跟单。
|
||||
- 不自动加仓。
|
||||
- 不做 AI 预测评分。
|
||||
- 不做复杂多账户资金托管。
|
||||
- 不修改真实 PnL 统计口径。统计中仍必须区分真实归零仓位和行情/持仓数据不可用。
|
||||
|
||||
## Decisions
|
||||
|
||||
### 1. 新增独立 `copy_trading_leader_pool` 表
|
||||
|
||||
采用独立表,不把池子字段塞进 `copy_trading_leaders`。
|
||||
|
||||
原因:
|
||||
|
||||
- `copy_trading_leaders` 是地址库,leader 可以存在但不在当前策略池中。
|
||||
- 池子有状态、来源、建议配置、冷却时间、复核时间,这些是策略决策数据,不是 leader 基础资料。
|
||||
- 后续 leaderboard radar、手动观察、亏损诊断都可以写入同一池子表,而不污染 leader 地址库。
|
||||
|
||||
建议迁移:
|
||||
|
||||
```text
|
||||
backend/src/main/resources/db/migration/V41__create_copy_trading_leader_pool.sql
|
||||
```
|
||||
|
||||
建议字段:
|
||||
|
||||
```text
|
||||
id BIGINT AUTO_INCREMENT PRIMARY KEY
|
||||
leader_id BIGINT NOT NULL
|
||||
status VARCHAR(20) NOT NULL
|
||||
source VARCHAR(50) NOT NULL DEFAULT 'MANUAL'
|
||||
source_rank INT NULL
|
||||
score DECIMAL(20, 8) NULL
|
||||
reason TEXT NULL
|
||||
notes TEXT NULL
|
||||
suggested_fixed_amount DECIMAL(20, 8) NOT NULL DEFAULT 1.00000000
|
||||
suggested_max_daily_orders INT NOT NULL DEFAULT 10
|
||||
suggested_max_daily_loss DECIMAL(20, 8) NOT NULL DEFAULT 5.00000000
|
||||
suggested_min_price DECIMAL(20, 8) NULL DEFAULT 0.10000000
|
||||
suggested_max_price DECIMAL(20, 8) NULL DEFAULT 0.80000000
|
||||
suggested_max_position_value DECIMAL(20, 8) NULL DEFAULT 5.00000000
|
||||
last_reviewed_at BIGINT NULL
|
||||
last_promoted_at BIGINT NULL
|
||||
cooldown_until BIGINT NULL
|
||||
locked BOOLEAN NOT NULL DEFAULT FALSE
|
||||
created_at BIGINT NOT NULL
|
||||
updated_at BIGINT NOT NULL
|
||||
```
|
||||
|
||||
约束与索引:
|
||||
|
||||
```text
|
||||
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
|
||||
```
|
||||
|
||||
v1 池子是全局策略池,不按账户拆分。账户只在创建小额试跟配置时选择;同一个 leader 在池子里只出现一次,避免把“候选观察状态”拆成多份互相打架的笔记。若后续需要多账户独立池,再新增 `account_id` 并迁移为 `UNIQUE(account_id, leader_id)`,本次不做。
|
||||
|
||||
### 2. 状态机后端完整、前端 v1 精简
|
||||
|
||||
后端状态枚举预留:
|
||||
|
||||
```text
|
||||
CANDIDATE
|
||||
WATCH
|
||||
PAPER
|
||||
TRIAL
|
||||
ACTIVE
|
||||
COOLDOWN
|
||||
RETIRED
|
||||
```
|
||||
|
||||
前端 v1 展示:
|
||||
|
||||
```text
|
||||
候选
|
||||
观察
|
||||
小额试跟
|
||||
冷却
|
||||
淘汰
|
||||
```
|
||||
|
||||
原因:
|
||||
|
||||
- 后端预留可以让后续 radar 或自动规则不需要迁移状态字段。
|
||||
- 前端不一次暴露太多状态,避免用户每天面对一堆概念。
|
||||
- 锁定是独立布尔字段 `locked`,不是状态。这样 `TRIAL + locked`、`COOLDOWN + locked` 都能表达,不会把“交易阶段”和“自动任务是否可改”混在一起。
|
||||
|
||||
状态更新规则:
|
||||
|
||||
- `RETIRED` 不删除 leader 地址,只让池子项退出关注。
|
||||
- `COOLDOWN` 可以设置 `cooldownUntil`。
|
||||
- `locked=true` 的池子项不允许被后续自动任务改状态。v1 没有自动任务,但字段先保留。
|
||||
|
||||
状态流:
|
||||
|
||||
```text
|
||||
+---------+
|
||||
| RETIRED |
|
||||
+---------+
|
||||
^
|
||||
|
|
||||
CANDIDATE -> WATCH -> TRIAL -> ACTIVE
|
||||
| | |
|
||||
+----------+--------+
|
||||
|
|
||||
v
|
||||
COOLDOWN
|
||||
|
||||
locked=true 是覆盖层,不是状态:
|
||||
任何状态 + locked=true => 后续自动任务不得改状态
|
||||
```
|
||||
|
||||
### 3. 创建试跟配置必须复用 `CopyTradingService.createCopyTrading`
|
||||
|
||||
新增 `LeaderPoolService.createTrialConfig`,内部组装 `CopyTradingCreateRequest`,然后调用现有 `CopyTradingService.createCopyTrading`。
|
||||
|
||||
原因:
|
||||
|
||||
- 现有服务已经处理账户、leader、模板、监听更新、字段校验。
|
||||
- 不复制跟单创建逻辑,避免两套规则不同步。
|
||||
|
||||
默认请求建议:
|
||||
|
||||
```text
|
||||
enableImmediately = false
|
||||
confirm = false
|
||||
enabled = false
|
||||
copyMode = FIXED
|
||||
fixedAmount = suggestedFixedAmount 默认 1
|
||||
maxOrderSize = suggestedFixedAmount 默认 1
|
||||
minOrderSize = 1
|
||||
maxDailyOrders = suggestedMaxDailyOrders 默认 10
|
||||
maxDailyLoss = suggestedMaxDailyLoss 默认 5
|
||||
priceTolerance = 1
|
||||
minPrice = 0.10
|
||||
maxPrice = 0.80
|
||||
maxPositionValue = 5
|
||||
supportSell = true
|
||||
pushFailedOrders = true
|
||||
pushFilteredOrders = true
|
||||
configName = "Leader池-<leaderName或地址后6位>"
|
||||
```
|
||||
|
||||
`enabled=false` 是推荐默认。若前端允许立即启用,必须弹出确认,并明确展示固定金额、最大日单数、最大日亏损和最大持仓。
|
||||
|
||||
后端也必须执行同一条安全规则:当 `enableImmediately=true` 时,`confirm` 必须为 `true`,否则拒绝创建。真钱交易不能只靠前端弹窗保护。
|
||||
|
||||
创建前还必须检查同一 `accountId + leaderId` 是否已经存在跟单配置。默认拒绝重复创建,并返回已有配置提示。PolyHermes 允许同一 leader 多个跟单配置,但 Leader 池的一键试跟不应该制造重复配置;真要高级配置,用户应该走 `跟单配置` 页面手动创建。
|
||||
|
||||
建议配置必须在后端校验,不只靠前端输入框:
|
||||
|
||||
```text
|
||||
suggestedFixedAmount > 0
|
||||
suggestedMaxDailyOrders between 1 and 100
|
||||
suggestedMaxDailyLoss > 0
|
||||
suggestedMinPrice / suggestedMaxPrice in [0, 1] when present
|
||||
suggestedMinPrice <= suggestedMaxPrice when both present
|
||||
suggestedMaxPositionValue > 0 when present
|
||||
```
|
||||
|
||||
这是“雨露均沾”的安全边界:小额可以分散,但不能因为一个坏输入把单个 leader 变成隐形重仓。
|
||||
|
||||
### 4. 新增 leader 池 API,路径归在 copy-trading 下
|
||||
|
||||
新增控制器:
|
||||
|
||||
```text
|
||||
backend/src/main/kotlin/com/wrbug/polymarketbot/controller/copytrading/leaderpool/LeaderPoolController.kt
|
||||
```
|
||||
|
||||
API:
|
||||
|
||||
```text
|
||||
POST /api/copy-trading/leader-pool/list
|
||||
POST /api/copy-trading/leader-pool/add
|
||||
POST /api/copy-trading/leader-pool/update-status
|
||||
POST /api/copy-trading/leader-pool/update-plan
|
||||
POST /api/copy-trading/leader-pool/create-trial-config
|
||||
POST /api/copy-trading/leader-pool/remove
|
||||
```
|
||||
|
||||
`list` 响应应聚合:
|
||||
|
||||
- leader 基础信息。
|
||||
- 池子状态和建议配置。
|
||||
- 该 leader 的跟单配置数量。
|
||||
- 该 leader 是否已有启用中的跟单配置。
|
||||
- 估算最坏暴露字段。
|
||||
|
||||
聚合必须使用批量查询或聚合查询,不能对每个池子项逐个查 leader 和跟单配置。计划新增 repository 方法,例如:
|
||||
|
||||
```text
|
||||
LeaderRepository.findAllById(...)
|
||||
CopyTradingRepository.findByLeaderIdIn(...)
|
||||
CopyTradingRepository.countByLeaderIdInGrouped(...) 或用 findByLeaderIdIn 后内存分组
|
||||
```
|
||||
|
||||
这保持 v1 足够简单,同时避免池子 50 人时打出 100 多次 SQL。软件会在你最懒得排查的时候提醒你什么叫 N+1。
|
||||
|
||||
主要数据流:
|
||||
|
||||
```text
|
||||
Leader 管理
|
||||
-> POST /leader-pool/add
|
||||
-> LeaderPoolService.addToPool
|
||||
-> copy_trading_leader_pool
|
||||
|
||||
Leader 池页面
|
||||
-> POST /leader-pool/list
|
||||
-> LeaderPoolService.getPoolList
|
||||
-> batch read leaders + copy_trading
|
||||
-> summary cards + table
|
||||
|
||||
Leader 池创建小额试跟
|
||||
-> POST /leader-pool/create-trial-config
|
||||
-> LeaderPoolService.createTrialConfig
|
||||
-> CopyTradingService.createCopyTrading
|
||||
-> copy_trading
|
||||
-> pool.status = TRIAL only after success
|
||||
```
|
||||
|
||||
### 5. 前端页面独立,Leader 管理只放轻入口
|
||||
|
||||
新增页面:
|
||||
|
||||
```text
|
||||
frontend/src/pages/LeaderPool.tsx
|
||||
```
|
||||
|
||||
修改路由:
|
||||
|
||||
```text
|
||||
frontend/src/App.tsx
|
||||
```
|
||||
|
||||
修改菜单:
|
||||
|
||||
```text
|
||||
frontend/src/components/Layout.tsx
|
||||
```
|
||||
|
||||
菜单位置:
|
||||
|
||||
```text
|
||||
跟单交易
|
||||
跟单配置
|
||||
Leader 池
|
||||
Leader 管理
|
||||
跟单模板
|
||||
回测
|
||||
```
|
||||
|
||||
`LeaderList.tsx` 只新增“加入 Leader 池”操作。不要把池子状态和预算管理塞进 Leader 管理页。
|
||||
|
||||
`LeaderPool.tsx` 不默认逐行查询 leader 余额。Leader 管理页当前有逐个加载余额的重型交互,但池子页的核心任务是筛选、配仓和创建小额试跟配置;默认列表只展示后端一次返回的聚合字段,profile 和详情可以按需打开。
|
||||
|
||||
### 6. 预算概览只做提示,不做资金托管
|
||||
|
||||
页面顶部显示:
|
||||
|
||||
```text
|
||||
池子人数
|
||||
试跟中人数
|
||||
估算最坏暴露
|
||||
待处理风险
|
||||
```
|
||||
|
||||
估算方式:
|
||||
|
||||
```text
|
||||
TRIAL/ACTIVE 状态的池子项数量 * suggestedMaxPositionValue
|
||||
```
|
||||
|
||||
如果某个池子项没有 `suggestedMaxPositionValue`,使用默认 5。
|
||||
|
||||
v1 可以先不持久化“总试验预算”。页面可用默认 50 显示提示,或提供前端输入但不存库。若实现持久化预算,优先使用现有系统配置能力,不新增复杂账户资金模型。
|
||||
|
||||
### 7. 错误态与空态
|
||||
|
||||
前端必须覆盖:
|
||||
|
||||
- 池子为空:提示从 `Leader 管理` 加入 leader,或在池子页选择已有 leader 加入。
|
||||
- leader 已在池子:加入动作返回明确提示,不创建重复项。
|
||||
- leader 不存在:提示先添加 leader。
|
||||
- 没有账户:创建试跟配置前提示先添加账户。
|
||||
- 创建配置失败:展示后端错误,不更新池子状态。
|
||||
- 创建配置成功:池子状态更新为 `TRIAL`,并给出跳转到 `跟单配置` 的入口。
|
||||
|
||||
### 8. 调度与自动化
|
||||
|
||||
本次不新增定时任务。
|
||||
|
||||
后续 leaderboard radar 可以把候选写入 `copy_trading_leader_pool`,但必须遵守:
|
||||
|
||||
- 不自动创建真实跟单配置。
|
||||
- 不改 `locked=true` 的池子项。
|
||||
- 不自动把 `COOLDOWN` 或 `RETIRED` 改回试跟。
|
||||
|
||||
### 9. 可观测性
|
||||
|
||||
后端日志至少记录:
|
||||
|
||||
- leader 加入池子。
|
||||
- 池子状态变化。
|
||||
- 创建试跟配置成功/失败。
|
||||
- 被拒绝的危险操作,例如重复加入、leader 不存在、账户不存在。
|
||||
|
||||
未来如果接入审计表,可把这些操作迁入审计事件。本次不强制新增审计表。
|
||||
|
||||
### 10. 错误码与国际化
|
||||
|
||||
Leader 池需要独立错误码,不要全部塞进 `BUSINESS_ERROR`。建议在现有错误码体系中新增:
|
||||
|
||||
```text
|
||||
LEADER_POOL_NOT_FOUND(4251)
|
||||
LEADER_POOL_ALREADY_EXISTS(4252)
|
||||
LEADER_POOL_DUPLICATE_TRIAL_CONFIG(4253)
|
||||
LEADER_POOL_CONFIRM_REQUIRED(4254)
|
||||
SERVER_LEADER_POOL_LIST_FAILED(5451)
|
||||
SERVER_LEADER_POOL_UPDATE_FAILED(5452)
|
||||
SERVER_LEADER_POOL_CREATE_TRIAL_FAILED(5453)
|
||||
```
|
||||
|
||||
这些编号避开当前 `ErrorCode` 中已经存在的 `4601` 冲突。实现时必须确认没有新增重复 code。
|
||||
|
||||
同时补齐 `messages_zh_CN.properties`、`messages_zh_TW.properties`、`messages_en.properties`。前端需要能看到明确错误,而不是一个泛泛的“业务逻辑错误”。
|
||||
|
||||
## Risks / Trade-offs
|
||||
|
||||
- [Risk] 用户误以为加入池子等于已经跟单 → Mitigation:页面文案明确区分 `观察`、`小额试跟` 和真实 `跟单配置`,创建配置后给出配置状态。
|
||||
- [Risk] 一键创建配置触发真钱交易 → Mitigation:默认 `enabled=false`;若支持立即启用,必须二次确认。
|
||||
- [Risk] 双击或多标签重复创建试跟配置 → Mitigation:前端提交时进入 loading 并禁用按钮;后端按 `accountId + leaderId` 检查已有配置,默认拒绝重复创建。
|
||||
- [Risk] 池子状态和跟单配置状态不一致 → Mitigation:list 响应聚合当前跟单配置数量和启用状态;不把池子状态当作真实交易状态。
|
||||
- [Risk] 同一 leader 重复加入池子 → Mitigation:数据库唯一约束 `leader_id`,服务层返回幂等提示。
|
||||
- [Risk] 并发加入同一 leader 时先查再写仍然冲突 → Mitigation:保留数据库唯一约束,并捕获唯一约束异常,返回“已在池子中”的明确结果。
|
||||
- [Risk] 建议配置输入为负数、超大值或无效价格区间 → Mitigation:后端校验所有建议配置字段,拒绝危险配置,不只依赖前端表单。
|
||||
- [Risk] 池子列表照搬 Leader 管理逐行余额查询导致页面变慢 → Mitigation:池子列表默认只用后端聚合字段,不逐行拉余额。
|
||||
- [Risk] 页面诱导追涨 → Mitigation:v1 不做收益率排行榜主导,不做 AI 评分;重点展示状态、预算、风险和保守配置。
|
||||
- [Risk] 预算只是估算,不等于真实账户风险 → Mitigation:页面文案使用“估算最坏暴露”,不展示为账户余额或保证金。
|
||||
- [Risk] 后续自动 radar 覆盖人工判断 → Mitigation:预留 `locked` 字段,自动任务不得修改锁定项,不得复活冷却/淘汰项。
|
||||
|
||||
## Migration Plan
|
||||
|
||||
1. 新增 Flyway 迁移 `V41__create_copy_trading_leader_pool.sql`。
|
||||
2. 部署后 Flyway 自动创建新表,不改动现有表数据。
|
||||
3. 新增 API 和前端入口上线后,池子默认为空。
|
||||
4. 用户可以从现有 `Leader 管理` 手动加入 leader。
|
||||
5. 回滚时可隐藏前端菜单和 API;新表保留不会影响现有跟单流程。
|
||||
6. 若必须数据库回滚,可删除 `copy_trading_leader_pool` 表;不会影响 `copy_trading_leaders` 和 `copy_trading`。
|
||||
|
||||
## Locked v1 Decisions
|
||||
|
||||
- v1 不持久化“总试验预算”。页面只展示默认 50 的试验预算提示和估算最坏暴露,不新增账户资金模型。
|
||||
- v1 UI 不提供“创建后立即启用”开关。池子创建的小额试跟配置默认 `enabled=false`;`enableImmediately` 与 `confirm` 只作为后端防御和后续扩展预留。
|
||||
- v1 UI 只暴露“候选、观察、小额试跟、冷却、淘汰”。`PAPER` 和 `ACTIVE` 保留在后端枚举中,第一版不进入筛选标签和主操作流。
|
||||
@@ -0,0 +1,35 @@
|
||||
## Why
|
||||
|
||||
PolyHermes 现在有 `Leader 管理` 和 `跟单配置`,但缺少中间的决策层:用户只能先保存 leader,再直接创建真实跟单配置。对于“小仓位、雨露均沾”的摊大饼策略,这会把“观察候选”和“真钱下单”混在一起,容易因为手动判断和配置过松扩大亏损。
|
||||
|
||||
这次变更要新增一个 `Leader 池` 入口,让用户先管理候选、观察、小额试跟、冷却和淘汰,再明确决定是否创建保守的小额跟单配置。
|
||||
|
||||
## What Changes
|
||||
|
||||
- 新增 `Leader 池` 页面入口,放在左侧 `跟单交易` 分组下,位于 `跟单配置` 与 `Leader 管理` 之间。
|
||||
- 新增 leader 池能力,支持把已有 leader 加入池子,并维护候选、观察、小额试跟、冷却、淘汰等状态。
|
||||
- 新增池子预算与风险概览,帮助用户看到试跟人数、建议最坏暴露、待处理风险等组合级信息。
|
||||
- 新增从池子创建保守小额跟单配置的动作,默认使用固定金额、小日单数、小日亏损、价格区间和单 leader 持仓上限。
|
||||
- 在 `Leader 管理` 中增加“加入 Leader 池”的辅助入口,避免用户来回复制地址。
|
||||
- 保留现有 `Leader 管理`、`跟单配置`、统计页和风险安全带,不替换现有工作流。
|
||||
- 不做自动加仓、不做自动启用大额跟单、不做 AI 预测评分。
|
||||
- 不在 v1 自动抓取 Polymarket leaderboard。leaderboard 自动发现后续可接入池子,但不属于本次实现。
|
||||
|
||||
## Capabilities
|
||||
|
||||
### New Capabilities
|
||||
|
||||
- `leader-pool`: 管理用于小仓位摊大饼策略的 leader 候选池,包括状态流转、保守试跟配置创建、组合预算提示和与现有 leader/跟单配置的联动。
|
||||
|
||||
### Modified Capabilities
|
||||
|
||||
无。
|
||||
|
||||
## Impact
|
||||
|
||||
- 后端新增 leader 池实体、迁移、仓库、DTO、服务和控制器。
|
||||
- 后端复用现有 `LeaderService`、`CopyTradingService`、`CopyTradingRepository`,避免重复实现 leader 校验和跟单配置创建逻辑。
|
||||
- 前端新增 `LeaderPool` 页面、路由、菜单项、API service、类型定义和中英文菜单文案。
|
||||
- 前端 `LeaderList` 增加“加入 Leader 池”操作。
|
||||
- 数据库新增 `copy_trading_leader_pool` 表,不修改现有 leader 和跟单配置表的语义。
|
||||
- 交易安全影响:本功能可能创建真实跟单配置,因此必须使用保守默认值、显式确认和非自动加仓策略。
|
||||
@@ -0,0 +1,167 @@
|
||||
## ADDED Requirements
|
||||
|
||||
### Requirement: 提供 Leader 池入口
|
||||
系统 SHALL 在受保护的 Web 应用中提供 `Leader 池` 页面入口,用于管理小仓位摊大饼策略的 leader 候选池。
|
||||
|
||||
#### Scenario: 左侧菜单展示 Leader 池
|
||||
- **WHEN** 已登录用户打开任意受保护页面
|
||||
- **THEN** 左侧 `跟单交易` 菜单中 MUST 展示 `Leader 池`,并位于 `跟单配置` 与 `Leader 管理` 之间
|
||||
|
||||
#### Scenario: 打开 Leader 池页面
|
||||
- **WHEN** 用户点击左侧菜单中的 `Leader 池`
|
||||
- **THEN** 系统 MUST 导航到 `/leader-pool` 并展示 Leader 池页面
|
||||
|
||||
### Requirement: 管理池子成员
|
||||
系统 SHALL 支持把已有 leader 加入 Leader 池,并保证同一个 leader 在池子中最多只有一个池子项。
|
||||
|
||||
#### Scenario: 从 Leader 管理加入池子
|
||||
- **WHEN** 用户在 `Leader 管理` 页面点击某个 leader 的 `加入 Leader 池`
|
||||
- **THEN** 系统 MUST 为该 leader 创建池子项,并默认状态为 `CANDIDATE`
|
||||
|
||||
#### Scenario: 重复加入同一 leader
|
||||
- **WHEN** 用户尝试把已经在池子中的 leader 再次加入池子
|
||||
- **THEN** 系统 MUST 不创建重复池子项,并 MUST 返回明确提示
|
||||
|
||||
#### Scenario: 并发重复加入同一 leader
|
||||
- **WHEN** 两个请求同时把同一个 leader 加入池子
|
||||
- **THEN** 系统 MUST 最多创建一个池子项,并 MUST 对冲突请求返回已在池子中的明确提示
|
||||
|
||||
#### Scenario: 加入不存在的 leader
|
||||
- **WHEN** 用户请求把不存在的 leader 加入池子
|
||||
- **THEN** 系统 MUST 拒绝请求,并 MUST 返回 leader 不存在的错误
|
||||
|
||||
### Requirement: 维护池子状态
|
||||
系统 SHALL 支持维护 leader 池状态,用于区分候选、观察、小额试跟、冷却和淘汰。
|
||||
|
||||
#### Scenario: 更新池子状态
|
||||
- **WHEN** 用户把某个池子项从 `CANDIDATE` 更新为 `WATCH`、`TRIAL`、`COOLDOWN` 或 `RETIRED`
|
||||
- **THEN** 系统 MUST 保存新状态并更新该池子项的更新时间
|
||||
|
||||
#### Scenario: 冷却状态包含冷却截止时间
|
||||
- **WHEN** 用户把某个池子项设置为 `COOLDOWN` 并提供冷却截止时间
|
||||
- **THEN** 系统 MUST 保存 `cooldownUntil`
|
||||
|
||||
#### Scenario: 淘汰不删除 leader 地址
|
||||
- **WHEN** 用户把某个池子项设置为 `RETIRED`
|
||||
- **THEN** 系统 MUST 保留对应 `copy_trading_leaders` 记录,不得删除 leader 地址
|
||||
|
||||
### Requirement: 展示池子概览
|
||||
系统 SHALL 在 Leader 池页面展示组合级概览,帮助用户理解小仓位策略的风险暴露。
|
||||
|
||||
#### Scenario: 展示统计卡片
|
||||
- **WHEN** 用户打开 Leader 池页面
|
||||
- **THEN** 页面 MUST 展示池子人数、试跟中人数、估算最坏暴露和待处理风险
|
||||
|
||||
#### Scenario: 估算最坏暴露
|
||||
- **WHEN** 池子中存在 `TRIAL` 或 `ACTIVE` 状态项
|
||||
- **THEN** 系统 MUST 使用这些项的建议最大持仓值合计估算最坏暴露
|
||||
|
||||
#### Scenario: 池子为空
|
||||
- **WHEN** 池子没有任何成员
|
||||
- **THEN** 页面 MUST 展示空态,并 MUST 提示用户可以从 `Leader 管理` 加入 leader
|
||||
|
||||
### Requirement: 展示池子列表信息
|
||||
系统 SHALL 在 Leader 池列表中展示池子状态、leader 基础信息、建议配置和现有跟单配置状态。
|
||||
|
||||
#### Scenario: 列表展示 leader 和状态
|
||||
- **WHEN** 用户打开 Leader 池页面且池子中存在成员
|
||||
- **THEN** 列表 MUST 展示 leader 名称或地址、池子状态、来源、建议固定金额、建议每日最大单数、建议每日最大亏损和最后复核时间
|
||||
|
||||
#### Scenario: 展示现有跟单配置状态
|
||||
- **WHEN** 某个池子 leader 已经存在跟单配置
|
||||
- **THEN** 列表 MUST 展示该 leader 的跟单配置数量,并 MUST 标明是否存在启用中的跟单配置
|
||||
|
||||
#### Scenario: 打开外部 profile
|
||||
- **WHEN** 用户点击池子项中的 Polymarket profile 操作
|
||||
- **THEN** 系统 MUST 打开该 leader 对应的 Polymarket profile 地址
|
||||
|
||||
#### Scenario: 池子列表不默认逐行查询余额
|
||||
- **WHEN** 用户打开 Leader 池页面且池子中存在多个成员
|
||||
- **THEN** 页面 MUST NOT 为每个池子项默认逐行请求 leader 余额接口
|
||||
|
||||
### Requirement: 创建保守小额试跟配置
|
||||
系统 SHALL 支持从 Leader 池为某个 leader 创建保守的小额跟单配置,并复用现有跟单配置创建逻辑。
|
||||
|
||||
#### Scenario: 创建默认禁用的试跟配置
|
||||
- **WHEN** 用户从池子项点击创建小额试跟配置并完成确认
|
||||
- **THEN** 系统 MUST 创建 `FIXED` 模式跟单配置,默认固定金额为 1,默认启用状态为禁用
|
||||
|
||||
#### Scenario: 使用保守风控默认值
|
||||
- **WHEN** 系统从池子创建小额试跟配置
|
||||
- **THEN** 创建请求 MUST 使用保守默认值,包括每日最大单数 10、每日最大亏损 5、价格容忍度 1、最低价格 0.10、最高价格 0.80、最大持仓 5
|
||||
|
||||
#### Scenario: 创建成功后更新池子状态
|
||||
- **WHEN** 小额试跟配置创建成功
|
||||
- **THEN** 系统 MUST 把池子项状态更新为 `TRIAL`,并 MUST 记录最后晋升时间
|
||||
|
||||
#### Scenario: 创建失败不更新状态
|
||||
- **WHEN** 小额试跟配置创建失败
|
||||
- **THEN** 系统 MUST 保留原池子状态,并 MUST 向用户展示失败原因
|
||||
|
||||
### Requirement: 保护真钱交易安全
|
||||
系统 SHALL 防止 Leader 池功能在没有明确用户动作的情况下扩大真实交易风险。
|
||||
|
||||
#### Scenario: 不自动创建跟单配置
|
||||
- **WHEN** 用户仅把 leader 加入池子或更新池子状态
|
||||
- **THEN** 系统 MUST NOT 自动创建真实跟单配置
|
||||
|
||||
#### Scenario: 不自动启用大额跟单
|
||||
- **WHEN** 系统从池子创建小额试跟配置
|
||||
- **THEN** 系统 MUST NOT 自动创建大额配置,并 MUST 使用保守小额参数
|
||||
|
||||
#### Scenario: 立即启用需要确认
|
||||
- **WHEN** UI 提供创建后立即启用选项
|
||||
- **THEN** UI MUST 在提交前展示固定金额、每日最大单数、每日最大亏损和最大持仓,并 MUST 要求用户确认
|
||||
|
||||
#### Scenario: 后端拒绝未确认的立即启用
|
||||
- **WHEN** 创建试跟配置请求要求立即启用但没有提供确认标记
|
||||
- **THEN** 后端 MUST 拒绝请求,并 MUST NOT 创建跟单配置
|
||||
|
||||
### Requirement: 支持更新建议配置
|
||||
系统 SHALL 支持用户更新池子项的建议固定金额和建议风控参数,但不得直接修改已有真实跟单配置。
|
||||
|
||||
#### Scenario: 更新建议配置
|
||||
- **WHEN** 用户更新池子项的建议固定金额、每日最大单数、每日最大亏损、价格区间或最大持仓
|
||||
- **THEN** 系统 MUST 保存这些建议字段,并 MUST 更新池子项的更新时间
|
||||
|
||||
#### Scenario: 建议配置不影响已有跟单
|
||||
- **WHEN** 用户只更新池子项建议配置
|
||||
- **THEN** 系统 MUST NOT 修改任何已有 `copy_trading` 记录
|
||||
|
||||
#### Scenario: 拒绝无效建议配置
|
||||
- **WHEN** 用户提交负数固定金额、无效每日最大单数、负数每日亏损、无效价格区间或负数最大持仓
|
||||
- **THEN** 系统 MUST 拒绝保存,并 MUST 保留原建议配置不变
|
||||
|
||||
### Requirement: 提供后端 API
|
||||
系统 SHALL 提供 Leader 池后端 API,用于列表查询、加入池子、状态更新、建议配置更新、创建试跟配置和移除池子项。
|
||||
|
||||
#### Scenario: 查询池子列表
|
||||
- **WHEN** 前端请求 `/api/copy-trading/leader-pool/list`
|
||||
- **THEN** 后端 MUST 返回池子项列表、汇总信息和每个池子项的 leader/跟单配置聚合信息
|
||||
|
||||
#### Scenario: 池子列表使用批量聚合
|
||||
- **WHEN** 后端生成池子列表响应
|
||||
- **THEN** 后端 MUST 使用批量查询或聚合查询获取 leader 与跟单配置信息,不得对每个池子项逐条查询 leader 和跟单配置
|
||||
|
||||
#### Scenario: 创建试跟配置 API
|
||||
- **WHEN** 前端请求 `/api/copy-trading/leader-pool/create-trial-config`
|
||||
- **THEN** 后端 MUST 校验池子项、账户和 leader,并 MUST 通过现有跟单配置服务创建配置
|
||||
|
||||
#### Scenario: 已存在同账户同 leader 跟单配置
|
||||
- **WHEN** 用户为某个账户和 leader 创建试跟配置且该账户已存在该 leader 的跟单配置
|
||||
- **THEN** 系统 MUST 拒绝默认试跟创建,并 MUST 返回已有配置提示
|
||||
|
||||
#### Scenario: 移除池子项
|
||||
- **WHEN** 用户请求移除某个池子项
|
||||
- **THEN** 系统 MUST 只移除池子项,不得删除 leader 地址,不得删除已有跟单配置
|
||||
|
||||
### Requirement: 记录关键操作日志
|
||||
系统 SHALL 对 Leader 池关键操作记录后端日志,便于排查交易相关行为。
|
||||
|
||||
#### Scenario: 记录加入和状态变化
|
||||
- **WHEN** leader 被加入池子或池子状态发生变化
|
||||
- **THEN** 后端 MUST 记录包含 leaderId、池子项 ID 和目标状态的日志
|
||||
|
||||
#### Scenario: 记录试跟配置创建结果
|
||||
- **WHEN** 系统尝试从池子创建小额试跟配置
|
||||
- **THEN** 后端 MUST 记录创建成功或失败日志,并包含 leaderId、accountId 和错误原因
|
||||
@@ -0,0 +1,95 @@
|
||||
## 1. 后端数据模型
|
||||
|
||||
- [x] 1.1 新增 Flyway 迁移 `V41__create_copy_trading_leader_pool.sql`,创建 `copy_trading_leader_pool` 表、`leader_id` 唯一约束、状态/来源索引和 leader 外键。
|
||||
- [x] 1.2 新增 `LeaderPool` JPA 实体,字段覆盖状态、来源、建议配置、复核时间、晋升时间、冷却时间、锁定标记和时间戳。
|
||||
- [x] 1.3 新增 `LeaderPoolRepository`,支持按 leaderId 查询、判断是否存在、按状态查询、按创建时间排序和删除池子项。
|
||||
- [x] 1.4 新增池子状态常量或枚举,覆盖 `CANDIDATE`、`WATCH`、`PAPER`、`TRIAL`、`ACTIVE`、`COOLDOWN`、`RETIRED`;锁定使用独立 `locked` 布尔字段,不作为状态。
|
||||
- [x] 1.5 扩展 `CopyTradingRepository` 或新增查询方法,支持按 leaderId 批量查询/聚合跟单配置,避免 Leader 池列表 N+1 查询。
|
||||
|
||||
## 2. 后端 DTO 与服务
|
||||
|
||||
- [x] 2.1 新增 Leader 池请求/响应 DTO,包括列表请求、加入请求、状态更新请求、建议配置更新请求、创建试跟配置请求和移除请求。
|
||||
- [x] 2.2 为创建试跟配置请求增加 `enableImmediately` 与 `confirm` 字段;当 `enableImmediately=true` 且未确认时后端必须拒绝创建。
|
||||
- [x] 2.3 新增 `LeaderPoolService.addToPool`,校验 leader 存在、处理重复加入,并默认创建 `CANDIDATE` 状态池子项。
|
||||
- [x] 2.4 在 `addToPool` 中捕获数据库唯一约束冲突,并返回“已在池子中”的明确业务结果,覆盖并发重复加入。
|
||||
- [x] 2.5 新增 `LeaderPoolService.getPoolList`,使用批量查询聚合 leader 信息、池子状态、建议配置、跟单配置数量、是否存在启用跟单和汇总概览。
|
||||
- [x] 2.6 新增 `LeaderPoolService.updateStatus`,支持状态更新、冷却截止时间保存、更新时间刷新,并确保淘汰不删除 leader 地址。
|
||||
- [x] 2.7 新增 `LeaderPoolService.updatePlan`,只更新建议固定金额、每日最大单数、每日最大亏损、价格区间和最大持仓,不修改已有 `copy_trading`。
|
||||
- [x] 2.8 新增 `LeaderPoolService.createTrialConfig`,先检查同一 `accountId + leaderId` 是否已有跟单配置,默认拒绝重复创建。
|
||||
- [x] 2.9 `createTrialConfig` 复用 `CopyTradingService.createCopyTrading` 创建默认禁用的保守 `FIXED` 小额跟单配置。
|
||||
- [x] 2.10 创建试跟配置成功后更新池子项为 `TRIAL` 并记录 `lastPromotedAt`;失败时保持原状态并返回错误。
|
||||
- [x] 2.11 在 Leader 池服务中记录加入池子、状态变化、建议配置更新、试跟配置创建成功/失败的关键日志。
|
||||
- [x] 2.12 在 `updatePlan` 和 `createTrialConfig` 中校验建议配置安全边界:固定金额大于 0、每日最大单数 1-100、每日最大亏损大于 0、价格区间在 0-1 且最小价不大于最大价、最大持仓大于 0。
|
||||
- [x] 2.13 新增 Leader 池专用错误码和中英繁三套 i18n 文案,使用不冲突编号 `4251-4254` 与 `5451-5453`,覆盖池子不存在、已存在、重复试跟、立即启用未确认和服务失败。
|
||||
|
||||
## 3. 后端 API
|
||||
|
||||
- [x] 3.1 新增 `LeaderPoolController`,路径为 `/api/copy-trading/leader-pool`。
|
||||
- [x] 3.2 实现 `POST /list`,返回池子列表和汇总概览。
|
||||
- [x] 3.3 实现 `POST /add`,支持从已有 leader 加入池子。
|
||||
- [x] 3.4 实现 `POST /update-status`,支持候选、观察、小额试跟、冷却、淘汰等状态更新。
|
||||
- [x] 3.5 实现 `POST /update-plan`,支持更新建议配置字段。
|
||||
- [x] 3.6 实现 `POST /create-trial-config`,支持创建保守小额试跟配置。
|
||||
- [x] 3.7 实现 `POST /remove`,只移除池子项,不删除 leader 地址,不删除已有跟单配置。
|
||||
- [x] 3.8 为重复加入、并发唯一约束冲突、leader 不存在、账户不存在、池子项不存在、立即启用未确认、重复试跟配置、创建配置失败等错误返回明确业务错误。
|
||||
|
||||
## 4. 前端 API 与类型
|
||||
|
||||
- [x] 4.1 在 `frontend/src/types/index.ts` 新增 Leader 池状态、池子项、汇总、请求和响应类型。
|
||||
- [x] 4.2 在 `frontend/src/services/api.ts` 新增 `leaderPool` API 分组,覆盖 list、add、updateStatus、updatePlan、createTrialConfig、remove。
|
||||
- [x] 4.3 确保前端类型包含跟单配置数量、是否有启用跟单、建议最大持仓和估算暴露字段。
|
||||
|
||||
## 5. 前端入口与页面
|
||||
|
||||
- [x] 5.1 新增 `frontend/src/pages/LeaderPool.tsx` 页面。
|
||||
- [x] 5.2 在 `frontend/src/App.tsx` 添加受保护路由 `/leader-pool`。
|
||||
- [x] 5.3 在 `frontend/src/components/Layout.tsx` 的 `跟单交易` 子菜单中加入 `Leader 池`,位置在 `跟单配置` 和 `Leader 管理` 之间。
|
||||
- [x] 5.4 更新 `getInitialOpenKeys` 和路径变化逻辑,使 `/leader-pool` 自动展开 `跟单交易` 菜单。
|
||||
- [x] 5.5 在 `frontend/src/locales/zh-CN/common.json`、`zh-TW/common.json`、`en/common.json` 增加 Leader 池菜单和页面基础文案。
|
||||
|
||||
## 6. Leader 池页面交互
|
||||
|
||||
- [x] 6.1 页面顶部展示池子人数、试跟中人数、估算最坏暴露和待处理风险统计卡片。
|
||||
- [x] 6.2 页面列表展示 leader 名称或地址、状态、来源、建议固定金额、建议每日最大单数、建议每日最大亏损、跟单配置状态和最后复核时间。
|
||||
- [x] 6.3 支持按状态筛选池子项,至少覆盖全部、候选、观察、小额试跟、冷却、淘汰。
|
||||
- [x] 6.4 支持从页面中把已有 leader 加入池子,并处理空列表、重复加入和 leader 不存在错误。
|
||||
- [x] 6.5 支持更新池子状态,冷却状态允许填写冷却截止时间。
|
||||
- [x] 6.6 支持编辑建议配置,保存后只更新池子建议字段。
|
||||
- [x] 6.7 支持打开 leader 的 Polymarket profile。
|
||||
- [x] 6.8 支持从池子项创建小额试跟配置,提交前展示固定金额、每日最大单数、每日最大亏损、价格区间和最大持仓确认信息。
|
||||
- [x] 6.9 创建试跟配置提交期间按钮进入 loading/disabled 状态,防止双击重复提交。
|
||||
- [x] 6.10 创建试跟配置成功后刷新池子列表,并提供跳转到 `跟单配置` 的入口。
|
||||
- [x] 6.11 创建试跟配置失败时展示后端错误,不在前端假更新状态。
|
||||
- [x] 6.12 Leader 池列表不默认逐行查询 leader 余额,避免照搬 `Leader 管理` 的重型余额加载路径。
|
||||
|
||||
## 7. Leader 管理联动
|
||||
|
||||
- [x] 7.1 在 `frontend/src/pages/LeaderList.tsx` 操作列增加 `加入 Leader 池` 操作。
|
||||
- [x] 7.2 点击加入后调用 Leader 池 add API,并对已加入池子的 leader 展示明确提示。
|
||||
- [x] 7.3 加入成功后提示用户可以前往 `Leader 池` 继续设置观察或小额试跟。
|
||||
|
||||
## 8. 测试
|
||||
|
||||
- [x] 8.1 新增后端服务测试:成功加入池子、重复加入不创建重复项、leader 不存在返回错误。
|
||||
- [x] 8.2 新增后端服务测试:并发/唯一约束重复加入被转换为明确“已在池子中”结果。
|
||||
- [x] 8.3 新增后端服务测试:状态更新、冷却截止时间保存、淘汰不删除 leader 地址。
|
||||
- [x] 8.4 新增后端服务测试:建议配置更新不修改已有 `copy_trading`。
|
||||
- [x] 8.5 新增后端服务测试:无效建议配置被拒绝且不修改原池子项。
|
||||
- [x] 8.6 新增后端服务测试:创建试跟配置使用保守默认值、默认禁用、成功后状态变为 `TRIAL`。
|
||||
- [x] 8.7 新增后端服务测试:创建试跟配置失败时池子状态不变。
|
||||
- [x] 8.8 新增后端服务测试:同账户同 leader 已有跟单配置时默认拒绝重复试跟创建。
|
||||
- [x] 8.9 新增后端服务测试:立即启用但未确认时拒绝创建跟单配置。
|
||||
- [x] 8.10 新增后端服务测试:池子列表使用批量查询路径,避免每个池子项逐条查 leader 和跟单配置。
|
||||
- [x] 8.11 新增后端控制器测试或集成测试,覆盖 list、add、update-status、update-plan、create-trial-config、remove 的主要成功和失败路径。
|
||||
- [x] 8.12 前端至少通过 TypeScript 构建验证,确保新增页面、类型、API 和菜单没有编译错误。
|
||||
- [x] 8.13 手动或自动验证前端双击创建试跟配置不会发起重复提交。
|
||||
- [x] 8.14 手动或自动验证 Leader 池列表不会默认逐行请求 leader 余额接口。
|
||||
|
||||
## 9. 文档与验证
|
||||
|
||||
- [x] 9.1 新增或更新中文文档,说明 `Leader 池` 与 `Leader 管理`、`跟单配置` 的区别。
|
||||
- [x] 9.2 文档中明确 `Leader 池` 不会自动加仓、不自动启用大额跟单、不自动抓取 leaderboard。
|
||||
- [x] 9.3 运行后端测试,至少覆盖 Leader 池相关测试。
|
||||
- [x] 9.4 运行前端构建。
|
||||
- [x] 9.5 运行前端 lint。
|
||||
- [x] 9.6 手动验证页面入口、空态、加入池子、状态更新、建议配置更新和创建小额试跟配置流程。
|
||||
@@ -0,0 +1,2 @@
|
||||
schema: spec-driven
|
||||
created: 2026-04-28
|
||||
@@ -0,0 +1,113 @@
|
||||
## Context
|
||||
|
||||
当前分支已经引入真实 PnL 统计基础:`CopyTradingPnlCalculator` 可以把已实现 PnL、未实现 PnL、当前持仓成本和当前持仓估值拆开,`CopyTradingStatisticsService` 会从买入订单、卖出匹配和持仓报价计算单个跟单配置的统计结果。
|
||||
|
||||
实盘排查显示,当前亏损主要来自三类问题:leader 参与的短周期体育市场大量归零,部分 leader 留下估值为 0 的未平仓,跟单配置几乎没有使用现有风控字段。当前统计还有一个关键歧义:当持仓/报价接口失败时,服务返回空 quotes,而计算器会把未匹配报价按 0 估值;这会把“真实归零”和“数据不可用”混在一起。
|
||||
|
||||
本变更面向真实资金部署,首要目标是止血和解释,不是追求高收益自动化。本阶段只做亏损诊断、报价状态、安全配置建议和人工确认,不做 leader 池状态机、leader 自动推荐或定时榜单同步。
|
||||
|
||||
## Goals / Non-Goals
|
||||
|
||||
**Goals:**
|
||||
|
||||
- 给每个跟单配置生成亏损归因:已实现亏损、未实现亏损、归零亏损、当前持仓成本/估值、top losing markets、订单数量和样本量。
|
||||
- 识别危险配置并给出保守参数建议,优先复用现有 `CopyTrading` 风控字段。
|
||||
- 明确区分真实 0 估值、无匹配报价、持仓/报价接口不可用,避免把未知数据当成亏损事实。
|
||||
- 默认只提供建议和手动确认入口,不自动新增 leader、不自动删除 leader、不自动加仓。
|
||||
- 为 Mac mini Docker 部署保留可观测日志和失败降级信息。
|
||||
|
||||
**Non-Goals:**
|
||||
|
||||
- 不承诺盈利,不做收益预测或投资建议。
|
||||
- 不做 leader 池状态机、leader 推荐记录、定时 leaderboard 候选刷新、全自动 leader 轮换、全自动加仓、复杂投资组合优化或跨钱包资金调度。
|
||||
- 不引入非官方私有数据源。
|
||||
- 不重写现有跟单执行引擎;本变更只在统计、诊断、建议和操作确认层加安全带。
|
||||
|
||||
## Decisions
|
||||
|
||||
### Decision 1: 用“诊断层”扩展现有统计,而不是把归因写死在 UI
|
||||
|
||||
新增后端诊断服务,例如 `CopyTradingRiskDiagnosisService`,从订单跟踪、卖出匹配、filtered orders、当前持仓报价和现有 PnL 统计中生成结构化诊断 DTO。UI 只负责展示,不在前端重新计算核心财务口径。
|
||||
|
||||
原因:PnL 归因属于业务口径,必须可测试、可复用。只在前端计算会导致统计页、确认弹窗和后续任务口径不一致。
|
||||
|
||||
替代方案:直接在 `CopyTradingStatisticsService` 内继续堆字段。这个方案改动少,但会让统计服务同时承担列表分页、持仓报价、PnL、风险建议和诊断解释,后续很难测试和维护。
|
||||
|
||||
### Decision 2: 持仓估值改为三态语义
|
||||
|
||||
持仓估值必须携带报价状态:
|
||||
|
||||
- `AVAILABLE`: 成功拿到当前价格,价格可能大于 0,也可能等于 0。
|
||||
- `NO_MATCH`: 持仓接口成功,但未找到对应 market/outcome 的报价。
|
||||
- `UNAVAILABLE`: 持仓/报价接口失败或超时。
|
||||
|
||||
只有 `AVAILABLE` 且当前价格为 0 时,UI 和诊断才能称为“当前估值为 0”。`UNAVAILABLE` 必须显示为“报价不可用”,并且 leader 推荐任务不得用这部分未知估值直接判定 leader 归零亏损。
|
||||
|
||||
原因:Mac mini 日志里存在持仓查询 timeout;如果继续把接口失败当作 0,系统会高估浮亏并产生错误换池建议。
|
||||
|
||||
替代方案:保持当前缺 quote 等于 0。这个方案对已结算归零市场友好,但对 API 故障过于危险。
|
||||
|
||||
### Decision 3: 风险安全带优先复用已有配置字段
|
||||
|
||||
危险配置识别和保守参数建议先基于已有字段实现:
|
||||
|
||||
- `maxDailyOrders`
|
||||
- `maxDailyLoss`
|
||||
- `minPrice`
|
||||
- `maxPrice`
|
||||
- `maxPositionValue`
|
||||
- `minOrderDepth`
|
||||
- `maxSpread`
|
||||
- `priceTolerance`
|
||||
- `supportSell`
|
||||
|
||||
新增能力先提供“建议值”和“一键确认保存”,不直接修改运行中的配置。保存动作走后端显式确认入口,只允许写入白名单风控字段,并在 UI 中明确展示变更前后差异。
|
||||
|
||||
原因:当前真实亏损已经证明已有字段没有被充分使用,先把这些字段变成可理解、可确认的安全带,比新增一套风控模型更快、更稳。
|
||||
|
||||
替代方案:新增独立 risk profile 表。长期可能有价值,但第一阶段会增加迁移和配置同步复杂度。
|
||||
|
||||
### Decision 4: 本阶段明确不做 leader 池状态机
|
||||
|
||||
leader 池轮换留到第二阶段。第一阶段只在诊断里给出人能读懂的风险结论,例如“样本太小,不建议加仓”“亏损集中在归零市场”“配置过松,先收紧风控”。不创建 `ACTIVE/WATCH/COOLDOWN/RETIRED/LOCKED` 状态,不接入官方 leaderboard,不新增定时推荐任务。
|
||||
|
||||
原因:当前最危险的问题不是缺少自动推荐,而是统计口径会把报价失败和真实归零混在一起,同时配置几乎没有安全限制。先修这两个问题,才有资格让系统推荐换人。
|
||||
|
||||
替代方案:本阶段直接做 leader 状态机和候选发现。这个方案看起来完整,但会把真实资金风控、外部榜单可靠性、状态迁移和 UI 操作全混在一个 PR 里,风险太大。
|
||||
|
||||
### Decision 5: UI 做成“黑盒记录仪 + 安全带面板”
|
||||
|
||||
在跟单配置列表或统计弹窗中新增:
|
||||
|
||||
- PnL 分解卡片:已实现、未实现、总 PnL、持仓成本、持仓估值。
|
||||
- 亏损归因区块:归零亏损、top losing markets、open position 风险、报价状态。
|
||||
- 风险配置体检:危险项、建议值、为什么危险。
|
||||
- 手动确认按钮:应用保守配置,取消时不发保存请求。
|
||||
|
||||
原因:这不是普通数据大屏,而是操作者决策台。用户要快速知道“为什么亏、现在要不要停、下一步怎么更安全”。
|
||||
|
||||
替代方案:单独新增复杂 dashboard。长期可以做,但第一阶段应嵌入现有跟单配置和统计路径,降低使用成本。
|
||||
|
||||
## Risks / Trade-offs
|
||||
|
||||
- [报价不可用被误判] → 所有估值结果必须携带 quote status;遇到 `UNAVAILABLE` 时降级为“数据不足”,不得生成强结论。
|
||||
- [诊断给人虚假确定性] → 诊断文案必须展示样本量、时间窗口、报价状态和数据完整性;低样本必须明确标低置信度。
|
||||
- [统计查询变慢] → 诊断服务优先新增 repository 聚合查询和必要索引,避免在大订单量下把所有订单加载到内存后过滤。
|
||||
- [scope 膨胀] → 第一阶段只做亏损归因、风险提示和手动确认;leader 状态机、leader 推荐、自动轮换、自动加仓、组合优化留到后续 change。
|
||||
- [误改真实交易配置] → “应用保守配置”必须显示 diff 并需要人工确认;后端保存前再次校验参数范围。
|
||||
- [主线 CLOB V2/pUSD 变更漂移] → 实施前需要把当前分支和最新 `origin/main` 对齐,确认金额单位、pUSD/$ 展示和 CLOB V2 持仓口径没有冲突。
|
||||
|
||||
## Migration Plan
|
||||
|
||||
1. 第一阶段不新增业务表;如实现中确认需要性能索引,只新增 append-only Flyway migration。
|
||||
2. 后端先发布兼容 API:旧统计字段保留,新诊断字段可为空;报价不可用时返回状态而不是抛出页面级错误。
|
||||
3. 前端灰度展示诊断区块:没有诊断数据时显示空态,不影响现有跟单列表。
|
||||
4. “应用保守配置”必须走后端显式确认入口;取消、未确认或非白名单字段混入时不得写库。
|
||||
5. Mac mini 部署后先观察 24-72 小时日志和诊断显示,确认没有把 `UNAVAILABLE` 展示成 0 亏损。
|
||||
6. 回滚时隐藏诊断 UI,并保留旧统计字段兼容;如果只新增索引无需回滚数据。
|
||||
|
||||
## Open Questions
|
||||
|
||||
- 保守参数默认值第一版使用固定区间:`maxDailyOrders=10-20`、`maxDailyLoss=5-10`、`minPrice=0.10`、`maxPrice=0.80`、`maxPositionValue=5-10`;未来再考虑按账户余额比例动态生成。
|
||||
- “应用保守配置”第一版采用后端显式确认入口。前端展示后端返回的 diff,用户确认后请求必须携带 `confirm=true`,后端只允许保存白名单风控字段。
|
||||
- 是否要在第一版加入通知推送,还是先只在 UI 中展示诊断和安全带建议。
|
||||
@@ -0,0 +1,32 @@
|
||||
## Why
|
||||
|
||||
当前跟单亏损已经不是单纯“leader 选错”能解释的问题。实盘排查显示,RN1 和 swisstony 的亏损与短周期体育市场归零、未平仓估值归零、以及过松的风控配置共同相关;如果直接做自动 leader 轮换,系统可能只是把风险从一个 leader 换到另一个 leader。
|
||||
|
||||
现在需要先把 PolyHermes 从“忠实执行跟单”升级为“带安全带的操作者控制台”:能解释亏损、识别危险配置、给出保守参数建议,并把任何真实配置修改都放在明确确认和可审计边界内。
|
||||
|
||||
## What Changes
|
||||
|
||||
- 新增跟单亏损归因能力:区分已实现 PnL、未实现 PnL、持仓成本、持仓估值、归零亏损、报价不可用、top losing markets 等关键原因。
|
||||
- 新增风险安全带能力:对危险跟单配置给出提示和保守参数建议,优先复用已有 `maxDailyOrders`、`maxDailyLoss`、`minPrice`、`maxPrice`、`maxPositionValue`、`minOrderDepth`、`maxSpread` 等字段。
|
||||
- 新增 operator-facing UI:在跟单统计/配置页显示亏损归因、风险提示、保守参数建议和证据来源。
|
||||
- 新增审计和降级要求:当 Polymarket 持仓/报价接口失败时,必须明确显示“数据不可用”,不能把未知估值直接当作 0 亏损。
|
||||
- 暂不做 leader 池状态机、leader 自动推荐、定时榜单同步、全自动加仓、全自动删除 leader、复杂资产组合优化、跨钱包资金调度或收益承诺。
|
||||
|
||||
## Capabilities
|
||||
|
||||
### New Capabilities
|
||||
|
||||
- `copy-trading-risk-seatbelt`: 覆盖跟单亏损归因、危险配置识别、保守参数建议、报价不可用处理和操作者确认边界。
|
||||
|
||||
### Modified Capabilities
|
||||
|
||||
- None.
|
||||
|
||||
## Impact
|
||||
|
||||
- 后端:影响 copy-trading statistics、PnL calculator、config service、repository 聚合查询、API response DTO。
|
||||
- 前端:影响跟单配置管理、统计弹窗/统计页、风险提示和建议确认 UI。
|
||||
- 数据库:第一阶段不新增业务表;如发现查询瓶颈,只追加必要索引,不创建 leader 状态/推荐记录/诊断快照表。
|
||||
- 外部数据:继续使用现有 Polymarket 持仓/报价数据;第一阶段不接入 leaderboard 候选发现。
|
||||
- 运维:Mac mini Docker 部署需要可观测日志、报价失败降级和手动确认边界。
|
||||
- 测试:需要覆盖真实 PnL 归因、报价失败、危险配置识别、UI 空态/错误态,以及不自动执行配置变更的安全约束。
|
||||
+60
@@ -0,0 +1,60 @@
|
||||
## ADDED Requirements
|
||||
|
||||
### Requirement: 跟单亏损归因
|
||||
系统必须为每个跟单配置提供结构化亏损归因,并拆分已实现 PnL、未实现 PnL、当前持仓成本、当前持仓估值、归零亏损、未平仓暴露、订单数量和样本量。
|
||||
|
||||
#### Scenario: 查看亏损跟单配置诊断
|
||||
- **WHEN** 用户打开一个包含买入订单、卖出匹配和未平仓的跟单配置统计或诊断页面
|
||||
- **THEN** 系统必须展示已实现 PnL、未实现 PnL、总 PnL、持仓成本、持仓估值、总订单数、已匹配订单数、未平仓数量和亏损最大的市场
|
||||
|
||||
#### Scenario: 查看小样本盈利配置
|
||||
- **WHEN** 某个跟单配置 PnL 为正,但订单数量低于配置的最小样本量
|
||||
- **THEN** 系统必须标记为低置信度,并且不能把该 leader 或配置描述成已经被验证盈利
|
||||
|
||||
### Requirement: 报价可用性状态
|
||||
系统必须区分真实归零持仓、未匹配报价数据、以及持仓或报价接口不可用。
|
||||
|
||||
#### Scenario: 报价接口成功且当前价格为零
|
||||
- **WHEN** 一个未平仓持仓匹配到报价,并且当前价格等于 0
|
||||
- **THEN** 系统必须把估值状态标记为 `AVAILABLE`,并允许该持仓计入归零亏损诊断
|
||||
|
||||
#### Scenario: 报价接口失败或超时
|
||||
- **WHEN** 账户持仓接口或报价接口失败、超时、或返回错误
|
||||
- **THEN** 系统必须把受影响的未平仓估值状态标记为 `UNAVAILABLE`,并且不能把缺失估值当作已确认的归零亏损
|
||||
|
||||
#### Scenario: 报价接口成功但没有匹配市场结果
|
||||
- **WHEN** 报价接口成功返回数据,但没有找到与跟单记录中的 market 和 outcome 对应的报价
|
||||
- **THEN** 系统必须把受影响的持仓状态标记为 `NO_MATCH`,并在诊断响应中暴露该状态
|
||||
|
||||
### Requirement: 风险配置体检
|
||||
系统必须在继续跟单或调整 leader 之前,先基于现有风控字段评估每个跟单配置是否危险。
|
||||
|
||||
#### Scenario: 配置风险限制过松
|
||||
- **WHEN** 某个跟单配置存在过高的 `maxDailyOrders`、过高的 `maxDailyLoss`、缺失的 `minPrice`、缺失的 `maxPrice`、缺失的 `maxPositionValue`、缺失的 `minOrderDepth` 或缺失的 `maxSpread`
|
||||
- **THEN** 系统必须把这些字段列为风险警告,并提供保守建议值和简短原因
|
||||
|
||||
#### Scenario: 配置已经较保守
|
||||
- **WHEN** 某个跟单配置已经设置了保守的每日订单数、每日亏损、价格区间和仓位暴露限制
|
||||
- **THEN** 系统必须显示对应风险检查已通过,并且不能无意义地要求用户继续修改这些字段
|
||||
|
||||
### Requirement: 风险配置修改必须人工确认
|
||||
系统必须在应用任何推荐风险配置之前,要求操作者明确确认。
|
||||
|
||||
#### Scenario: 用户应用保守配置
|
||||
- **WHEN** 用户选择应用推荐的保守配置
|
||||
- **THEN** 系统必须展示字段变更前后的 diff,并在保存前要求用户确认
|
||||
|
||||
#### Scenario: 用户取消应用保守配置
|
||||
- **WHEN** 用户在确认弹窗中取消操作
|
||||
- **THEN** 系统必须保持原跟单配置不变
|
||||
|
||||
### Requirement: 诊断结果可审计
|
||||
系统必须让每次诊断结果可追溯,至少暴露时间窗口、来源计数、报价状态和生成时间。
|
||||
|
||||
#### Scenario: 生成诊断结果
|
||||
- **WHEN** 系统返回一个诊断结果
|
||||
- **THEN** 响应必须包含评估时间窗口、来源订单数、来源卖出匹配数、报价状态摘要和生成时间
|
||||
|
||||
#### Scenario: 诊断数据不完整
|
||||
- **WHEN** 任一必要来源数据不可用或部分不可用
|
||||
- **THEN** 诊断必须标出缺失来源,并降级为部分结果,不能把不完整数据展示成完整结论
|
||||
@@ -0,0 +1,70 @@
|
||||
## 0. 实施前同步
|
||||
|
||||
- [x] 0.1 在开始编码前把当前分支与最新 `origin/main` 对齐,重点确认 CLOB V2、pUSD/$ 展示、持仓接口和金额单位没有冲突
|
||||
- [x] 0.2 对齐后重新运行现有 `CopyTradingPnlCalculatorTest`,确保当前真实 PnL 基线没有先坏掉
|
||||
|
||||
## 1. 报价状态和 PnL 口径
|
||||
|
||||
- [x] 1.1 重构持仓估值返回结构,支持 `AVAILABLE`、`NO_MATCH`、`UNAVAILABLE` 三种报价状态
|
||||
- [x] 1.2 调整 `CopyTradingStatisticsService.buildPositionValuationQuotes`,接口失败或超时时返回 `UNAVAILABLE` 状态,不再返回空 quotes 伪装成 0 估值
|
||||
- [x] 1.3 调整 `CopyTradingPnlCalculator` 或其调用层,只有 `AVAILABLE` 且当前价格为 0 时才计入“已确认归零”;`NO_MATCH` 和 `UNAVAILABLE` 必须从诊断中暴露出来
|
||||
- [x] 1.4 保持现有统计字段向后兼容;如果存在未知估值,旧字段可以继续返回数值,但必须新增状态字段告诉前端该数值不是完整结论
|
||||
- [x] 1.5 更新或替换当前“无报价按 0”的单元测试,覆盖报价成功为 0、报价未匹配、报价接口不可用、混合持仓四种场景
|
||||
|
||||
## 2. 亏损诊断后端
|
||||
|
||||
- [x] 2.1 新增 `CopyTradingRiskDiagnosisService`,聚合买入订单、卖出匹配、filtered orders、当前持仓估值和真实 PnL 统计
|
||||
- [x] 2.2 实现诊断 DTO:已实现 PnL、未实现 PnL、总 PnL、持仓成本、持仓估值、归零亏损、未平仓暴露、订单数量、样本量、top losing markets、报价状态摘要、数据完整性和生成时间
|
||||
- [x] 2.3 实现低样本识别逻辑,避免把 sovereign2013 这种少量盈利订单标记成高置信度盈利
|
||||
- [x] 2.4 实现诊断降级逻辑:报价或来源数据不可用时返回部分结果,并明确列出缺失来源
|
||||
- [x] 2.5 优先用 repository 聚合查询计算 top losing markets 和来源计数;如果需要索引,只新增 append-only Flyway migration,不新增诊断快照表
|
||||
|
||||
## 3. 风险安全带后端
|
||||
|
||||
- [x] 3.1 新增风险配置体检逻辑,检查 `maxDailyOrders`、`maxDailyLoss`、`minPrice`、`maxPrice`、`maxPositionValue`、`minOrderDepth`、`maxSpread`、`priceTolerance` 和 `supportSell`
|
||||
- [x] 3.2 实现保守参数建议,第一版使用固定保守区间:`maxDailyOrders=10-20`、`maxDailyLoss=5-10`、`minPrice=0.10`、`maxPrice=0.80`、`maxPositionValue=5-10`
|
||||
- [x] 3.3 在风险建议中返回字段级原因、当前值、建议值和严重程度,并标明建议只是安全带,不是收益承诺
|
||||
- [x] 3.4 新增“应用保守配置”的后端显式确认入口,请求必须带 `confirm=true`,后端只允许保存白名单风控字段
|
||||
- [x] 3.5 确保取消、未确认、参数校验失败、非白名单字段混入时不会修改任何真实跟单配置
|
||||
|
||||
## 4. API 和 DTO
|
||||
|
||||
- [x] 4.1 新增或扩展跟单诊断 API,返回 PnL 分解、亏损归因、报价状态、风险配置体检、保守配置 diff 和生成时间
|
||||
- [x] 4.2 新增应用保守配置 API,接收 `copyTradingId`、`confirm=true` 和后端生成的建议字段,成功后返回更新后的跟单配置
|
||||
- [x] 4.3 保持现有统计 API 向后兼容,旧字段继续返回,新诊断字段缺失时前端能显示空态
|
||||
- [x] 4.4 为新增或扩展 API 增加明确错误响应,区分参数错误、数据不存在、未确认、报价不可用和外部接口失败
|
||||
- [x] 4.5 不新增 leader 推荐列表 API、推荐处理 API、leader 状态更新 API;这些留到第二阶段
|
||||
|
||||
## 5. 前端界面
|
||||
|
||||
- [x] 5.1 在跟单统计页和统计弹窗中新增 PnL 分解卡片,展示已实现、未实现、总 PnL、持仓成本和持仓估值
|
||||
- [x] 5.2 新增亏损归因区块,展示归零亏损、top losing markets、未平仓风险、报价状态和数据完整性
|
||||
- [x] 5.3 新增风险配置体检区块,展示危险字段、当前值、建议值、原因和严重程度
|
||||
- [x] 5.4 新增应用保守配置确认弹窗,展示后端返回的字段变更前后 diff,取消时不发保存请求,确认时调用显式确认 API
|
||||
- [x] 5.5 补齐空态、加载态、部分数据不可用态和错误态,尤其是 `UNAVAILABLE` 报价状态不能显示成 0 亏损
|
||||
- [x] 5.6 前端文案使用中文优先,英文/繁中 i18n 可以先补基础 key,但不能让中文用户看到英文诊断主体
|
||||
|
||||
## 6. 测试
|
||||
|
||||
- [x] 6.1 为 PnL 计算增加单元测试,覆盖 `AVAILABLE` 且价格为 0、`NO_MATCH`、`UNAVAILABLE` 和混合持仓
|
||||
- [x] 6.2 为亏损诊断服务增加单元测试,覆盖归零亏损、top losing markets、低样本盈利、部分数据缺失和数据完整性字段
|
||||
- [x] 6.3 为风险配置体检增加单元测试,覆盖危险配置、保守配置和建议值生成
|
||||
- [x] 6.4 为新增或扩展 controller 增加接口测试,覆盖成功响应、参数错误、数据不存在、未确认、非白名单字段混入和外部数据不可用
|
||||
- [x] 6.5 为前端关键组件增加构建验证和交互检查,确保确认弹窗、空态和错误态可用
|
||||
|
||||
## 7. 文档、部署和验证
|
||||
|
||||
- [x] 7.1 更新 README 或运维文档,说明亏损诊断、风险安全带和手动确认流程
|
||||
- [x] 7.2 记录 Mac mini 排查命令,包括 `ssh m4`、Docker 日志、MySQL 容器和非交互 PATH 注意事项
|
||||
- [x] 7.3 明确第二阶段才做 leader 池状态机、leader 推荐、leaderboard 候选发现和任何自动暂停
|
||||
- [x] 7.4 本地运行后端测试和前端构建,记录命令与结果
|
||||
- [ ] 7.5 在 Mac mini 部署前先用测试环境或本地数据验证诊断结果与现有统计一致
|
||||
- [ ] 7.6 部署后观察 24-72 小时诊断日志和页面显示,确认没有把 `UNAVAILABLE` 展示成 0 亏损,也没有自动改动真实跟单配置
|
||||
|
||||
## 不在本阶段范围
|
||||
|
||||
- leader 池状态机:`ACTIVE`、`WATCH`、`COOLDOWN`、`RETIRED`、`LOCKED`
|
||||
- leader 推荐记录表、推荐详情页、忽略/锁定/应用推荐动作
|
||||
- 官方 Polymarket leaderboard 候选发现和定时同步
|
||||
- 自动新增、删除、启用、停用 leader 或跟单配置
|
||||
- 自动加仓、组合优化、跨钱包资金调度或收益预测
|
||||
@@ -0,0 +1,23 @@
|
||||
schema: spec-driven
|
||||
|
||||
context: |
|
||||
Project: PolyHermes, a Polymarket copy-trading system.
|
||||
Backend: Kotlin 1.9, Spring Boot 3.2, Spring Data JPA, Flyway, MySQL 8.2, Retrofit/OkHttp, WebSocket, Docker.
|
||||
Frontend: React 18, TypeScript, Vite, Ant Design, Zustand, axios, react-i18next.
|
||||
Domain: copy-trading configs connect accounts, leaders, and templates; orders are tracked through buy orders, sell match records, sell match details, filtered orders, and real PnL statistics.
|
||||
Product language: write user-facing OpenSpec artifacts in Simplified Chinese unless the user asks otherwise.
|
||||
Safety: this project can control real-money trading behavior. Prefer conservative defaults, explicit operator confirmation, audit trails, and non-destructive suggestions before automation.
|
||||
|
||||
rules:
|
||||
proposal:
|
||||
- State the user-facing problem, why now, and what is explicitly out of scope.
|
||||
- Separate live-money safety requirements from growth or automation ideas.
|
||||
design:
|
||||
- Cover backend, frontend, data model, scheduling, error states, observability, and rollout.
|
||||
- Distinguish true zero-valued positions from unavailable quote/position data.
|
||||
specs:
|
||||
- Write requirement names, scenario names, and scenario content in Simplified Chinese.
|
||||
- Keep only OpenSpec-required structural keywords in English when the CLI format requires them.
|
||||
tasks:
|
||||
- Split implementation into backend, frontend, tests, docs, deployment/ops, and verification chunks.
|
||||
- Keep auto-trading or auto-pausing steps gated behind explicit configuration and tests.
|
||||
Reference in New Issue
Block a user