mirror of
https://github.com/NoFxAiOS/nofx.git
synced 2026-07-27 14:02:43 +08:00
Compare commits
3 Commits
dev
...
moltbot-no
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
8b4ce279da | ||
|
|
f9d8318869 | ||
|
|
01ba348841 |
47
.env.example
47
.env.example
@@ -49,9 +49,52 @@ RSA_PRIVATE_KEY=-----BEGIN RSA PRIVATE KEY-----\nYOUR_KEY_HERE\n-----END RSA PRI
|
|||||||
TRANSPORT_ENCRYPTION=false
|
TRANSPORT_ENCRYPTION=false
|
||||||
|
|
||||||
# ===========================================
|
# ===========================================
|
||||||
# Optional: External Services
|
# Telegram AI Assistant (NEW - moltbot-nofx)
|
||||||
# ===========================================
|
# ===========================================
|
||||||
|
|
||||||
|
# Telegram Bot Token (get from @BotFather)
|
||||||
|
# This enables the AI trading assistant via Telegram
|
||||||
|
TELEGRAM_BOT_TOKEN=
|
||||||
|
|
||||||
|
# Allowed users (comma-separated Telegram user IDs)
|
||||||
|
# Leave empty to allow all users (not recommended for production)
|
||||||
|
# Get your ID from @userinfobot
|
||||||
|
TELEGRAM_ALLOWED_USERS=
|
||||||
|
|
||||||
|
# Admin users (comma-separated Telegram user IDs)
|
||||||
|
# Admins can manage bot settings
|
||||||
|
TELEGRAM_ADMIN_USERS=
|
||||||
|
|
||||||
|
# Rate limit (messages per minute per user)
|
||||||
|
TELEGRAM_RATE_LIMIT=30
|
||||||
|
|
||||||
|
# Default language: "en" or "zh"
|
||||||
|
TELEGRAM_LANGUAGE=zh
|
||||||
|
|
||||||
|
# ===========================================
|
||||||
|
# AI Model Configuration (for Assistant)
|
||||||
|
# ===========================================
|
||||||
|
|
||||||
|
# DeepSeek (recommended - cost-effective)
|
||||||
|
DEEPSEEK_API_KEY=
|
||||||
|
DEEPSEEK_API_URL=
|
||||||
|
DEEPSEEK_MODEL=deepseek-chat
|
||||||
|
|
||||||
|
# Claude (optional alternative)
|
||||||
|
CLAUDE_API_KEY=
|
||||||
|
CLAUDE_API_URL=
|
||||||
|
CLAUDE_MODEL=
|
||||||
|
|
||||||
|
# OpenAI (optional alternative)
|
||||||
|
OPENAI_API_KEY=
|
||||||
|
OPENAI_API_URL=
|
||||||
|
OPENAI_MODEL=
|
||||||
|
|
||||||
|
# Qwen (optional alternative)
|
||||||
|
QWEN_API_KEY=
|
||||||
|
QWEN_API_URL=
|
||||||
|
QWEN_MODEL=
|
||||||
|
|
||||||
DB_TYPE=postgres
|
DB_TYPE=postgres
|
||||||
DB_HOST=10.
|
DB_HOST=10.
|
||||||
DB_PORT=5432
|
DB_PORT=5432
|
||||||
@@ -61,6 +104,6 @@ DB_NAME=nofx
|
|||||||
DB_SSLMODE=disable
|
DB_SSLMODE=disable
|
||||||
|
|
||||||
|
|
||||||
# Database configuration - SQLite (default)
|
# 数据库配置 - SQLite(默认)
|
||||||
DB_TYPE=sqlite
|
DB_TYPE=sqlite
|
||||||
DB_PATH=data/data.db
|
DB_PATH=data/data.db
|
||||||
120
.github/PULL_REQUEST_TEMPLATE.md
vendored
120
.github/PULL_REQUEST_TEMPLATE.md
vendored
@@ -1,50 +1,100 @@
|
|||||||
## Summary
|
# Pull Request
|
||||||
|
|
||||||
- Problem:
|
> **📋 Choose Specialized Template**
|
||||||
- What changed:
|
>
|
||||||
- What did NOT change (scope boundary):
|
> We now offer specialized templates for different types of PRs to help you fill out the information faster:
|
||||||
|
>
|
||||||
|
> - 🔧 **[Backend PR Template](./PULL_REQUEST_TEMPLATE/backend.md)** - For Go/API/Trading changes
|
||||||
|
> - 🎨 **[Frontend PR Template](./PULL_REQUEST_TEMPLATE/frontend.md)** - For UI/UX changes
|
||||||
|
> - 📝 **[Documentation PR Template](./PULL_REQUEST_TEMPLATE/docs.md)** - For documentation updates
|
||||||
|
> - 📦 **[General PR Template](./PULL_REQUEST_TEMPLATE/general.md)** - For mixed or other changes
|
||||||
|
>
|
||||||
|
> **How to use?**
|
||||||
|
> - When creating a PR, add `?template=backend.md` or other template name to the URL
|
||||||
|
> - Or simply copy and paste the content from the corresponding template
|
||||||
|
|
||||||
## Change Type
|
---
|
||||||
|
|
||||||
- [ ] Bug fix
|
> **💡 Tip:** Recommended PR title format `type(scope): description`
|
||||||
- [ ] Feature
|
> Example: `feat(trader): add new strategy` | `fix(api): resolve auth issue`
|
||||||
- [ ] Refactoring
|
|
||||||
- [ ] Docs
|
|
||||||
- [ ] Security fix
|
|
||||||
- [ ] Chore / infra
|
|
||||||
|
|
||||||
## Scope
|
---
|
||||||
|
|
||||||
- [ ] Trading engine / strategies
|
## 📝 Description
|
||||||
- [ ] MCP / AI clients
|
|
||||||
- [ ] API / server
|
|
||||||
- [ ] Telegram bot / agent
|
|
||||||
- [ ] Web UI / frontend
|
|
||||||
- [ ] Config / deployment
|
|
||||||
- [ ] CI/CD / infra
|
|
||||||
|
|
||||||
## Linked Issues
|
<!-- Describe your changes in detail -->
|
||||||
|
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 🎯 Type of Change
|
||||||
|
|
||||||
|
- [ ] 🐛 Bug fix
|
||||||
|
- [ ] ✨ New feature
|
||||||
|
- [ ] 💥 Breaking change
|
||||||
|
- [ ] 📝 Documentation update
|
||||||
|
- [ ] 🎨 Code style update
|
||||||
|
- [ ] ♻️ Refactoring
|
||||||
|
- [ ] ⚡ Performance improvement
|
||||||
|
- [ ] ✅ Test update
|
||||||
|
- [ ] 🔧 Build/config change
|
||||||
|
- [ ] 🔒 Security fix
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 🔗 Related Issues
|
||||||
|
|
||||||
- Closes #
|
- Closes #
|
||||||
- Related #
|
- Related to #
|
||||||
|
|
||||||
## Testing
|
---
|
||||||
|
|
||||||
What you verified and how:
|
## 📋 Changes Made
|
||||||
|
|
||||||
- [ ] `go build ./...` passes
|
<!-- List the specific changes made -->
|
||||||
- [ ] `go test ./...` passes
|
-
|
||||||
- [ ] Manual testing done (describe below)
|
-
|
||||||
|
|
||||||
## Security Impact
|
---
|
||||||
|
|
||||||
- Secrets/keys handling changed? (`Yes/No`)
|
## 🧪 Testing
|
||||||
- New/changed API endpoints? (`Yes/No`)
|
|
||||||
- User input validation affected? (`Yes/No`)
|
|
||||||
|
|
||||||
## Compatibility
|
- [ ] Tested locally
|
||||||
|
- [ ] Tests pass
|
||||||
|
- [ ] Verified no existing functionality broke
|
||||||
|
|
||||||
- Backward compatible? (`Yes/No`)
|
---
|
||||||
- Config/env changes? (`Yes/No`)
|
|
||||||
- Migration needed? (`Yes/No`)
|
## ✅ Checklist
|
||||||
- If yes, upgrade steps:
|
|
||||||
|
### Code Quality
|
||||||
|
- [ ] Code follows project style
|
||||||
|
- [ ] Self-review completed
|
||||||
|
- [ ] Comments added for complex logic
|
||||||
|
|
||||||
|
### Documentation
|
||||||
|
- [ ] Updated relevant documentation
|
||||||
|
|
||||||
|
### Git
|
||||||
|
- [ ] Commits follow conventional format
|
||||||
|
- [ ] Rebased on latest `dev` branch
|
||||||
|
- [ ] No merge conflicts
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 📚 Additional Notes
|
||||||
|
|
||||||
|
<!-- Any additional information or context -->
|
||||||
|
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
**By submitting this PR, I confirm:**
|
||||||
|
|
||||||
|
- [ ] I have read the [Contributing Guidelines](../CONTRIBUTING.md)
|
||||||
|
- [ ] I agree to the [Code of Conduct](../CODE_OF_CONDUCT.md)
|
||||||
|
- [ ] My contribution is licensed under AGPL-3.0
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
🌟 **Thank you for your contribution!**
|
||||||
|
|||||||
5
.github/workflows/docker-build.yml
vendored
5
.github/workflows/docker-build.yml
vendored
@@ -17,11 +17,6 @@ on:
|
|||||||
env:
|
env:
|
||||||
REGISTRY_GHCR: ghcr.io
|
REGISTRY_GHCR: ghcr.io
|
||||||
|
|
||||||
# Least privilege by default; build/push jobs that publish images declare
|
|
||||||
# their own job-level packages:write.
|
|
||||||
permissions:
|
|
||||||
contents: read
|
|
||||||
|
|
||||||
jobs:
|
jobs:
|
||||||
prepare:
|
prepare:
|
||||||
name: Prepare repository metadata
|
name: Prepare repository metadata
|
||||||
|
|||||||
331
.github/workflows/pr-checks-advisory.yml.old
vendored
Normal file
331
.github/workflows/pr-checks-advisory.yml.old
vendored
Normal file
@@ -0,0 +1,331 @@
|
|||||||
|
name: PR Checks (Advisory)
|
||||||
|
|
||||||
|
on:
|
||||||
|
pull_request:
|
||||||
|
types: [opened, synchronize, reopened]
|
||||||
|
branches: [main, dev]
|
||||||
|
|
||||||
|
# These checks are advisory only - they won't block PR merging
|
||||||
|
# Results will be posted as comments to help contributors improve their PRs
|
||||||
|
|
||||||
|
permissions:
|
||||||
|
contents: write
|
||||||
|
pull-requests: write
|
||||||
|
checks: write
|
||||||
|
issues: write
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
pr-info:
|
||||||
|
name: PR Information
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
steps:
|
||||||
|
- name: Check PR title format
|
||||||
|
id: check-title
|
||||||
|
run: |
|
||||||
|
PR_TITLE="${{ github.event.pull_request.title }}"
|
||||||
|
|
||||||
|
# Check if title follows conventional commits
|
||||||
|
if echo "$PR_TITLE" | grep -qE "^(feat|fix|docs|style|refactor|perf|test|chore|ci|security)(\(.+\))?: .+"; then
|
||||||
|
echo "status=✅ Good" >> $GITHUB_OUTPUT
|
||||||
|
echo "message=PR title follows Conventional Commits format" >> $GITHUB_OUTPUT
|
||||||
|
else
|
||||||
|
echo "status=⚠️ Suggestion" >> $GITHUB_OUTPUT
|
||||||
|
echo "message=Consider using Conventional Commits format: type(scope): description" >> $GITHUB_OUTPUT
|
||||||
|
fi
|
||||||
|
|
||||||
|
- name: Calculate PR size
|
||||||
|
id: pr-size
|
||||||
|
run: |
|
||||||
|
ADDITIONS=${{ github.event.pull_request.additions }}
|
||||||
|
DELETIONS=${{ github.event.pull_request.deletions }}
|
||||||
|
TOTAL=$((ADDITIONS + DELETIONS))
|
||||||
|
|
||||||
|
if [ $TOTAL -lt 100 ]; then
|
||||||
|
echo "size=🟢 Small" >> $GITHUB_OUTPUT
|
||||||
|
echo "label=size: small" >> $GITHUB_OUTPUT
|
||||||
|
elif [ $TOTAL -lt 500 ]; then
|
||||||
|
echo "size=🟡 Medium" >> $GITHUB_OUTPUT
|
||||||
|
echo "label=size: medium" >> $GITHUB_OUTPUT
|
||||||
|
else
|
||||||
|
echo "size=🔴 Large" >> $GITHUB_OUTPUT
|
||||||
|
echo "label=size: large" >> $GITHUB_OUTPUT
|
||||||
|
echo "suggestion=Consider breaking this into smaller PRs for easier review" >> $GITHUB_OUTPUT
|
||||||
|
fi
|
||||||
|
echo "lines=$TOTAL" >> $GITHUB_OUTPUT
|
||||||
|
|
||||||
|
- name: Post advisory comment
|
||||||
|
uses: actions/github-script@v7
|
||||||
|
with:
|
||||||
|
script: |
|
||||||
|
const titleStatus = '${{ steps.check-title.outputs.status }}';
|
||||||
|
const titleMessage = '${{ steps.check-title.outputs.message }}';
|
||||||
|
const prSize = '${{ steps.pr-size.outputs.size }}';
|
||||||
|
const prLines = '${{ steps.pr-size.outputs.lines }}';
|
||||||
|
const sizeSuggestion = '${{ steps.pr-size.outputs.suggestion }}' || '';
|
||||||
|
|
||||||
|
let comment = '## 🤖 PR Advisory Feedback\n\n';
|
||||||
|
comment += 'Thank you for your contribution! Here\'s some automated feedback to help improve your PR:\n\n';
|
||||||
|
comment += '### PR Title\n';
|
||||||
|
comment += titleStatus + ' ' + titleMessage + '\n\n';
|
||||||
|
comment += '### PR Size\n';
|
||||||
|
comment += prSize + ' (' + prLines + ' lines changed)\n';
|
||||||
|
if (sizeSuggestion) {
|
||||||
|
comment += '\n💡 **Suggestion:** ' + sizeSuggestion + '\n';
|
||||||
|
}
|
||||||
|
comment += '\n---\n\n';
|
||||||
|
comment += '### 📖 New PR Management System\n\n';
|
||||||
|
comment += 'We\'re introducing a new PR management system! These checks are **advisory only** and won\'t block your PR.\n\n';
|
||||||
|
comment += '**Want to check your PR against new standards?**\n';
|
||||||
|
comment += '```bash\n';
|
||||||
|
comment += '# Run the PR health check tool\n';
|
||||||
|
comment += './scripts/pr-check.sh\n';
|
||||||
|
comment += '```\n\n';
|
||||||
|
comment += 'This tool will:\n';
|
||||||
|
comment += '- 🔍 Analyze your PR (doesn\'t modify anything)\n';
|
||||||
|
comment += '- ✅ Show what\'s already good\n';
|
||||||
|
comment += '- ⚠️ Point out issues\n';
|
||||||
|
comment += '- 💡 Give specific suggestions on how to fix\n\n';
|
||||||
|
comment += '**Learn more:**\n';
|
||||||
|
comment += '- [Migration Guide](https://github.com/NoFxAiOS/nofx/blob/dev/docs/community/MIGRATION_ANNOUNCEMENT.md)\n';
|
||||||
|
comment += '- [Contributing Guidelines](https://github.com/NoFxAiOS/nofx/blob/dev/CONTRIBUTING.md)\n\n';
|
||||||
|
comment += '**Questions?** Just ask in the comments! We\'re here to help. 🙏\n\n';
|
||||||
|
comment += '---\n\n';
|
||||||
|
comment += '*This is an automated message. It won\'t affect your PR being merged.*';
|
||||||
|
|
||||||
|
github.rest.issues.createComment({
|
||||||
|
issue_number: context.issue.number,
|
||||||
|
owner: context.repo.owner,
|
||||||
|
repo: context.repo.repo,
|
||||||
|
body: comment
|
||||||
|
});
|
||||||
|
|
||||||
|
backend-checks:
|
||||||
|
name: Backend Checks (Advisory)
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
continue-on-error: true
|
||||||
|
steps:
|
||||||
|
- uses: actions/checkout@v4
|
||||||
|
|
||||||
|
- name: Set up Go
|
||||||
|
uses: actions/setup-go@v5
|
||||||
|
with:
|
||||||
|
go-version: '1.21'
|
||||||
|
|
||||||
|
- name: Install dependencies
|
||||||
|
run: |
|
||||||
|
sudo apt-get update
|
||||||
|
sudo apt-get install -y libta-lib-dev || true
|
||||||
|
go mod download || true
|
||||||
|
|
||||||
|
- name: Check Go formatting
|
||||||
|
id: go-fmt
|
||||||
|
continue-on-error: true
|
||||||
|
run: |
|
||||||
|
UNFORMATTED=$(gofmt -l . 2>/dev/null || echo "")
|
||||||
|
if [ -n "$UNFORMATTED" ]; then
|
||||||
|
echo "status=⚠️ Needs formatting" >> $GITHUB_OUTPUT
|
||||||
|
echo "files<<EOF" >> $GITHUB_OUTPUT
|
||||||
|
echo "$UNFORMATTED" | head -10 >> $GITHUB_OUTPUT
|
||||||
|
echo "EOF" >> $GITHUB_OUTPUT
|
||||||
|
else
|
||||||
|
echo "status=✅ Good" >> $GITHUB_OUTPUT
|
||||||
|
echo "files=" >> $GITHUB_OUTPUT
|
||||||
|
fi
|
||||||
|
|
||||||
|
- name: Run go vet
|
||||||
|
id: go-vet
|
||||||
|
continue-on-error: true
|
||||||
|
run: |
|
||||||
|
if go vet ./... 2>&1 | tee vet-output.txt; then
|
||||||
|
echo "status=✅ Good" >> $GITHUB_OUTPUT
|
||||||
|
echo "output=" >> $GITHUB_OUTPUT
|
||||||
|
else
|
||||||
|
echo "status=⚠️ Issues found" >> $GITHUB_OUTPUT
|
||||||
|
echo "output<<EOF" >> $GITHUB_OUTPUT
|
||||||
|
cat vet-output.txt | head -20 >> $GITHUB_OUTPUT
|
||||||
|
echo "EOF" >> $GITHUB_OUTPUT
|
||||||
|
fi
|
||||||
|
|
||||||
|
- name: Run tests
|
||||||
|
id: go-test
|
||||||
|
continue-on-error: true
|
||||||
|
run: |
|
||||||
|
if go test ./... -v 2>&1 | tee test-output.txt; then
|
||||||
|
echo "status=✅ Passed" >> $GITHUB_OUTPUT
|
||||||
|
echo "output=" >> $GITHUB_OUTPUT
|
||||||
|
else
|
||||||
|
echo "status=⚠️ Failed" >> $GITHUB_OUTPUT
|
||||||
|
echo "output<<EOF" >> $GITHUB_OUTPUT
|
||||||
|
cat test-output.txt | tail -30 >> $GITHUB_OUTPUT
|
||||||
|
echo "EOF" >> $GITHUB_OUTPUT
|
||||||
|
fi
|
||||||
|
|
||||||
|
- name: Post backend feedback
|
||||||
|
if: always()
|
||||||
|
uses: actions/github-script@v7
|
||||||
|
with:
|
||||||
|
script: |
|
||||||
|
const fmtStatus = '${{ steps.go-fmt.outputs.status }}' || '⚠️ Skipped';
|
||||||
|
const vetStatus = '${{ steps.go-vet.outputs.status }}' || '⚠️ Skipped';
|
||||||
|
const testStatus = '${{ steps.go-test.outputs.status }}' || '⚠️ Skipped';
|
||||||
|
const fmtFiles = `${{ steps.go-fmt.outputs.files }}`;
|
||||||
|
const vetOutput = `${{ steps.go-vet.outputs.output }}`;
|
||||||
|
const testOutput = `${{ steps.go-test.outputs.output }}`;
|
||||||
|
|
||||||
|
let comment = '## 🔧 Backend Checks (Advisory)\n\n';
|
||||||
|
comment += '### Go Formatting\n';
|
||||||
|
comment += fmtStatus + '\n';
|
||||||
|
if (fmtFiles) {
|
||||||
|
comment += '\nFiles needing formatting:\n```\n' + fmtFiles + '\n```\n';
|
||||||
|
}
|
||||||
|
comment += '\n### Go Vet\n';
|
||||||
|
comment += vetStatus + '\n';
|
||||||
|
if (vetOutput) {
|
||||||
|
comment += '\n```\n' + vetOutput.substring(0, 500) + '\n```\n';
|
||||||
|
}
|
||||||
|
comment += '\n### Tests\n';
|
||||||
|
comment += testStatus + '\n';
|
||||||
|
if (testOutput) {
|
||||||
|
comment += '\n```\n' + testOutput.substring(0, 1000) + '\n```\n';
|
||||||
|
}
|
||||||
|
comment += '\n---\n\n';
|
||||||
|
comment += '💡 **To fix locally:**\n';
|
||||||
|
comment += '```bash\n';
|
||||||
|
comment += '# Format code\n';
|
||||||
|
comment += 'go fmt ./...\n\n';
|
||||||
|
comment += '# Check for issues\n';
|
||||||
|
comment += 'go vet ./...\n\n';
|
||||||
|
comment += '# Run tests\n';
|
||||||
|
comment += 'go test ./...\n';
|
||||||
|
comment += '```\n\n';
|
||||||
|
comment += '*These checks are advisory and won\'t block merging. Need help? Just ask!*';
|
||||||
|
|
||||||
|
github.rest.issues.createComment({
|
||||||
|
issue_number: context.issue.number,
|
||||||
|
owner: context.repo.owner,
|
||||||
|
repo: context.repo.repo,
|
||||||
|
body: comment
|
||||||
|
});
|
||||||
|
|
||||||
|
frontend-checks:
|
||||||
|
name: Frontend Checks (Advisory)
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
continue-on-error: true
|
||||||
|
steps:
|
||||||
|
- uses: actions/checkout@v4
|
||||||
|
|
||||||
|
- name: Set up Node.js
|
||||||
|
uses: actions/setup-node@v4
|
||||||
|
with:
|
||||||
|
node-version: '18'
|
||||||
|
|
||||||
|
- name: Check if web directory exists
|
||||||
|
id: check-web
|
||||||
|
run: |
|
||||||
|
if [ -d "web" ]; then
|
||||||
|
echo "exists=true" >> $GITHUB_OUTPUT
|
||||||
|
else
|
||||||
|
echo "exists=false" >> $GITHUB_OUTPUT
|
||||||
|
fi
|
||||||
|
|
||||||
|
- name: Install dependencies
|
||||||
|
if: steps.check-web.outputs.exists == 'true'
|
||||||
|
working-directory: ./web
|
||||||
|
continue-on-error: true
|
||||||
|
run: npm ci
|
||||||
|
|
||||||
|
- name: Run linter
|
||||||
|
if: steps.check-web.outputs.exists == 'true'
|
||||||
|
id: lint
|
||||||
|
working-directory: ./web
|
||||||
|
continue-on-error: true
|
||||||
|
run: |
|
||||||
|
if npm run lint 2>&1 | tee lint-output.txt; then
|
||||||
|
echo "status=✅ Good" >> $GITHUB_OUTPUT
|
||||||
|
echo "output=" >> $GITHUB_OUTPUT
|
||||||
|
else
|
||||||
|
echo "status=⚠️ Issues found" >> $GITHUB_OUTPUT
|
||||||
|
echo "output<<EOF" >> $GITHUB_OUTPUT
|
||||||
|
cat lint-output.txt | head -20 >> $GITHUB_OUTPUT
|
||||||
|
echo "EOF" >> $GITHUB_OUTPUT
|
||||||
|
fi
|
||||||
|
|
||||||
|
- name: Type check
|
||||||
|
if: steps.check-web.outputs.exists == 'true'
|
||||||
|
id: typecheck
|
||||||
|
working-directory: ./web
|
||||||
|
continue-on-error: true
|
||||||
|
run: |
|
||||||
|
if npm run type-check 2>&1 | tee typecheck-output.txt; then
|
||||||
|
echo "status=✅ Good" >> $GITHUB_OUTPUT
|
||||||
|
echo "output=" >> $GITHUB_OUTPUT
|
||||||
|
else
|
||||||
|
echo "status=⚠️ Issues found" >> $GITHUB_OUTPUT
|
||||||
|
echo "output<<EOF" >> $GITHUB_OUTPUT
|
||||||
|
cat typecheck-output.txt | head -20 >> $GITHUB_OUTPUT
|
||||||
|
echo "EOF" >> $GITHUB_OUTPUT
|
||||||
|
fi
|
||||||
|
|
||||||
|
- name: Build
|
||||||
|
if: steps.check-web.outputs.exists == 'true'
|
||||||
|
id: build
|
||||||
|
working-directory: ./web
|
||||||
|
continue-on-error: true
|
||||||
|
run: |
|
||||||
|
if npm run build 2>&1 | tee build-output.txt; then
|
||||||
|
echo "status=✅ Success" >> $GITHUB_OUTPUT
|
||||||
|
echo "output=" >> $GITHUB_OUTPUT
|
||||||
|
else
|
||||||
|
echo "status=⚠️ Failed" >> $GITHUB_OUTPUT
|
||||||
|
echo "output<<EOF" >> $GITHUB_OUTPUT
|
||||||
|
cat build-output.txt | tail -20 >> $GITHUB_OUTPUT
|
||||||
|
echo "EOF" >> $GITHUB_OUTPUT
|
||||||
|
fi
|
||||||
|
|
||||||
|
- name: Post frontend feedback
|
||||||
|
if: always() && steps.check-web.outputs.exists == 'true'
|
||||||
|
uses: actions/github-script@v7
|
||||||
|
with:
|
||||||
|
script: |
|
||||||
|
const lintStatus = '${{ steps.lint.outputs.status }}' || '⚠️ Skipped';
|
||||||
|
const typecheckStatus = '${{ steps.typecheck.outputs.status }}' || '⚠️ Skipped';
|
||||||
|
const buildStatus = '${{ steps.build.outputs.status }}' || '⚠️ Skipped';
|
||||||
|
const lintOutput = `${{ steps.lint.outputs.output }}`;
|
||||||
|
const typecheckOutput = `${{ steps.typecheck.outputs.output }}`;
|
||||||
|
const buildOutput = `${{ steps.build.outputs.output }}`;
|
||||||
|
|
||||||
|
let comment = '## ⚛️ Frontend Checks (Advisory)\n\n';
|
||||||
|
comment += '### Linting\n';
|
||||||
|
comment += lintStatus + '\n';
|
||||||
|
if (lintOutput) {
|
||||||
|
comment += '\n```\n' + lintOutput.substring(0, 500) + '\n```\n';
|
||||||
|
}
|
||||||
|
comment += '\n### Type Checking\n';
|
||||||
|
comment += typecheckStatus + '\n';
|
||||||
|
if (typecheckOutput) {
|
||||||
|
comment += '\n```\n' + typecheckOutput.substring(0, 500) + '\n```\n';
|
||||||
|
}
|
||||||
|
comment += '\n### Build\n';
|
||||||
|
comment += buildStatus + '\n';
|
||||||
|
if (buildOutput) {
|
||||||
|
comment += '\n```\n' + buildOutput.substring(0, 500) + '\n```\n';
|
||||||
|
}
|
||||||
|
comment += '\n---\n\n';
|
||||||
|
comment += '💡 **To fix locally:**\n';
|
||||||
|
comment += '```bash\n';
|
||||||
|
comment += 'cd web\n\n';
|
||||||
|
comment += '# Fix linting issues\n';
|
||||||
|
comment += 'npm run lint -- --fix\n\n';
|
||||||
|
comment += '# Check types\n';
|
||||||
|
comment += 'npm run type-check\n\n';
|
||||||
|
comment += '# Test build\n';
|
||||||
|
comment += 'npm run build\n';
|
||||||
|
comment += '```\n\n';
|
||||||
|
comment += '*These checks are advisory and won\'t block merging. Need help? Just ask!*';
|
||||||
|
|
||||||
|
github.rest.issues.createComment({
|
||||||
|
issue_number: context.issue.number,
|
||||||
|
owner: context.repo.owner,
|
||||||
|
repo: context.repo.repo,
|
||||||
|
body: comment
|
||||||
|
});
|
||||||
70
.github/workflows/pr-checks-comment.yml
vendored
70
.github/workflows/pr-checks-comment.yml
vendored
@@ -108,17 +108,9 @@ jobs:
|
|||||||
id: pr-info
|
id: pr-info
|
||||||
if: steps.backend.outputs.pr_number != '0'
|
if: steps.backend.outputs.pr_number != '0'
|
||||||
uses: actions/github-script@v7
|
uses: actions/github-script@v7
|
||||||
env:
|
|
||||||
# Artifact data comes from the untrusted PR workflow — always pass it
|
|
||||||
# via env (data), never template-interpolate into script source (code).
|
|
||||||
PR_NUMBER: ${{ steps.backend.outputs.pr_number }}
|
|
||||||
with:
|
with:
|
||||||
script: |
|
script: |
|
||||||
const prNumber = parseInt(process.env.PR_NUMBER, 10);
|
const prNumber = ${{ steps.backend.outputs.pr_number }};
|
||||||
if (!Number.isInteger(prNumber) || prNumber <= 0) {
|
|
||||||
core.setFailed(`Invalid PR number from artifact: ${process.env.PR_NUMBER}`);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Get PR details
|
// Get PR details
|
||||||
const { data: pr } = await github.rest.pulls.get({
|
const { data: pr } = await github.rest.pulls.get({
|
||||||
@@ -162,44 +154,21 @@ jobs:
|
|||||||
- name: Post advisory results comment
|
- name: Post advisory results comment
|
||||||
if: steps.backend.outputs.pr_number != '0'
|
if: steps.backend.outputs.pr_number != '0'
|
||||||
uses: actions/github-script@v7
|
uses: actions/github-script@v7
|
||||||
env:
|
|
||||||
# All of these originate from the untrusted PR workflow's artifacts
|
|
||||||
# (or derive from PR content) — env-only, never inline in the script.
|
|
||||||
PR_NUMBER: ${{ steps.backend.outputs.pr_number }}
|
|
||||||
PR_TITLE: ${{ steps.pr-info.outputs.pr_title }}
|
|
||||||
TITLE_VALID: ${{ steps.pr-info.outputs.title_valid }}
|
|
||||||
PR_SIZE: ${{ steps.pr-info.outputs.pr_size }}
|
|
||||||
SIZE_EMOJI: ${{ steps.pr-info.outputs.size_emoji }}
|
|
||||||
TOTAL_LINES: ${{ steps.pr-info.outputs.total_lines }}
|
|
||||||
ADDITIONS: ${{ steps.pr-info.outputs.additions }}
|
|
||||||
DELETIONS: ${{ steps.pr-info.outputs.deletions }}
|
|
||||||
FMT_STATUS: ${{ steps.backend.outputs.fmt_status }}
|
|
||||||
VET_STATUS: ${{ steps.backend.outputs.vet_status }}
|
|
||||||
TEST_STATUS: ${{ steps.backend.outputs.test_status }}
|
|
||||||
FMT_FILES: ${{ steps.backend.outputs.fmt_files }}
|
|
||||||
VET_OUTPUT: ${{ steps.backend.outputs.vet_output }}
|
|
||||||
TEST_OUTPUT: ${{ steps.backend.outputs.test_output }}
|
|
||||||
BUILD_STATUS: ${{ steps.frontend.outputs.build_status }}
|
|
||||||
BUILD_OUTPUT: ${{ steps.frontend.outputs.build_output }}
|
|
||||||
with:
|
with:
|
||||||
script: |
|
script: |
|
||||||
const prNumber = parseInt(process.env.PR_NUMBER, 10);
|
const prNumber = ${{ steps.backend.outputs.pr_number }};
|
||||||
if (!Number.isInteger(prNumber) || prNumber <= 0) {
|
|
||||||
core.setFailed(`Invalid PR number from artifact: ${process.env.PR_NUMBER}`);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
let comment = '## 🤖 Advisory Check Results\n\n';
|
let comment = '## 🤖 Advisory Check Results\n\n';
|
||||||
comment += 'These are **advisory** checks to help improve code quality. They won\'t block your PR from being merged.\n\n';
|
comment += 'These are **advisory** checks to help improve code quality. They won\'t block your PR from being merged.\n\n';
|
||||||
|
|
||||||
// PR Information section
|
// PR Information section
|
||||||
const prTitle = process.env.PR_TITLE || '';
|
const prTitle = '${{ steps.pr-info.outputs.pr_title }}';
|
||||||
const titleValid = process.env.TITLE_VALID === 'true';
|
const titleValid = '${{ steps.pr-info.outputs.title_valid }}' === 'true';
|
||||||
const prSize = process.env.PR_SIZE || '';
|
const prSize = '${{ steps.pr-info.outputs.pr_size }}';
|
||||||
const sizeEmoji = process.env.SIZE_EMOJI || '';
|
const sizeEmoji = '${{ steps.pr-info.outputs.size_emoji }}';
|
||||||
const totalLines = process.env.TOTAL_LINES || '0';
|
const totalLines = '${{ steps.pr-info.outputs.total_lines }}';
|
||||||
const additions = process.env.ADDITIONS || '0';
|
const additions = '${{ steps.pr-info.outputs.additions }}';
|
||||||
const deletions = process.env.DELETIONS || '0';
|
const deletions = '${{ steps.pr-info.outputs.deletions }}';
|
||||||
|
|
||||||
comment += '### 📋 PR Information\n\n';
|
comment += '### 📋 PR Information\n\n';
|
||||||
|
|
||||||
@@ -227,16 +196,16 @@ jobs:
|
|||||||
comment += '\n';
|
comment += '\n';
|
||||||
|
|
||||||
// Backend checks
|
// Backend checks
|
||||||
const fmtStatus = process.env.FMT_STATUS || '';
|
const fmtStatus = '${{ steps.backend.outputs.fmt_status }}';
|
||||||
const vetStatus = process.env.VET_STATUS || '';
|
const vetStatus = '${{ steps.backend.outputs.vet_status }}';
|
||||||
const testStatus = process.env.TEST_STATUS || '';
|
const testStatus = '${{ steps.backend.outputs.test_status }}';
|
||||||
|
|
||||||
if (fmtStatus || vetStatus || testStatus) {
|
if (fmtStatus || vetStatus || testStatus) {
|
||||||
comment += '\n### 🔧 Backend Checks\n\n';
|
comment += '\n### 🔧 Backend Checks\n\n';
|
||||||
|
|
||||||
if (fmtStatus) {
|
if (fmtStatus) {
|
||||||
comment += '**Go Formatting:** ' + fmtStatus + '\n';
|
comment += '**Go Formatting:** ' + fmtStatus + '\n';
|
||||||
const fmtFiles = process.env.FMT_FILES || '';
|
const fmtFiles = `${{ steps.backend.outputs.fmt_files }}`;
|
||||||
if (fmtFiles && fmtFiles.trim()) {
|
if (fmtFiles && fmtFiles.trim()) {
|
||||||
comment += '<details><summary>Files needing formatting</summary>\n\n```\n' + fmtFiles + '\n```\n</details>\n\n';
|
comment += '<details><summary>Files needing formatting</summary>\n\n```\n' + fmtFiles + '\n```\n</details>\n\n';
|
||||||
}
|
}
|
||||||
@@ -244,7 +213,7 @@ jobs:
|
|||||||
|
|
||||||
if (vetStatus) {
|
if (vetStatus) {
|
||||||
comment += '**Go Vet:** ' + vetStatus + '\n';
|
comment += '**Go Vet:** ' + vetStatus + '\n';
|
||||||
const vetOutput = process.env.VET_OUTPUT || '';
|
const vetOutput = `${{ steps.backend.outputs.vet_output }}`;
|
||||||
if (vetOutput && vetOutput.trim()) {
|
if (vetOutput && vetOutput.trim()) {
|
||||||
comment += '<details><summary>Issues found</summary>\n\n```\n' + vetOutput.substring(0, 1000) + '\n```\n</details>\n\n';
|
comment += '<details><summary>Issues found</summary>\n\n```\n' + vetOutput.substring(0, 1000) + '\n```\n</details>\n\n';
|
||||||
}
|
}
|
||||||
@@ -252,7 +221,7 @@ jobs:
|
|||||||
|
|
||||||
if (testStatus) {
|
if (testStatus) {
|
||||||
comment += '**Tests:** ' + testStatus + '\n';
|
comment += '**Tests:** ' + testStatus + '\n';
|
||||||
const testOutput = process.env.TEST_OUTPUT || '';
|
const testOutput = `${{ steps.backend.outputs.test_output }}`;
|
||||||
if (testOutput && testOutput.trim()) {
|
if (testOutput && testOutput.trim()) {
|
||||||
comment += '<details><summary>Test output</summary>\n\n```\n' + testOutput.substring(0, 1000) + '\n```\n</details>\n\n';
|
comment += '<details><summary>Test output</summary>\n\n```\n' + testOutput.substring(0, 1000) + '\n```\n</details>\n\n';
|
||||||
}
|
}
|
||||||
@@ -267,13 +236,13 @@ jobs:
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Frontend checks
|
// Frontend checks
|
||||||
const buildStatus = process.env.BUILD_STATUS || '';
|
const buildStatus = '${{ steps.frontend.outputs.build_status }}';
|
||||||
|
|
||||||
if (buildStatus) {
|
if (buildStatus) {
|
||||||
comment += '\n### ⚛️ Frontend Checks\n\n';
|
comment += '\n### ⚛️ Frontend Checks\n\n';
|
||||||
|
|
||||||
comment += '**Build & Type Check:** ' + buildStatus + '\n';
|
comment += '**Build & Type Check:** ' + buildStatus + '\n';
|
||||||
const buildOutput = process.env.BUILD_OUTPUT || '';
|
const buildOutput = `${{ steps.frontend.outputs.build_output }}`;
|
||||||
if (buildOutput && buildOutput.trim()) {
|
if (buildOutput && buildOutput.trim()) {
|
||||||
comment += '<details><summary>Build output</summary>\n\n```\n' + buildOutput.substring(0, 1000) + '\n```\n</details>\n\n';
|
comment += '<details><summary>Build output</summary>\n\n```\n' + buildOutput.substring(0, 1000) + '\n```\n</details>\n\n';
|
||||||
}
|
}
|
||||||
@@ -304,9 +273,6 @@ jobs:
|
|||||||
- name: Post fallback comment if no results
|
- name: Post fallback comment if no results
|
||||||
if: steps.backend.outputs.pr_number == '0'
|
if: steps.backend.outputs.pr_number == '0'
|
||||||
uses: actions/github-script@v7
|
uses: actions/github-script@v7
|
||||||
env:
|
|
||||||
# Fork branch names are attacker-controlled — env, not inline.
|
|
||||||
HEAD_BRANCH: ${{ github.event.workflow_run.head_branch }}
|
|
||||||
with:
|
with:
|
||||||
script: |
|
script: |
|
||||||
// Try to get PR number from the workflow_run event
|
// Try to get PR number from the workflow_run event
|
||||||
@@ -314,7 +280,7 @@ jobs:
|
|||||||
owner: context.repo.owner,
|
owner: context.repo.owner,
|
||||||
repo: context.repo.repo,
|
repo: context.repo.repo,
|
||||||
state: 'open',
|
state: 'open',
|
||||||
head: `${context.repo.owner}:${process.env.HEAD_BRANCH}`
|
head: `${context.repo.owner}:${{ github.event.workflow_run.head_branch }}`
|
||||||
});
|
});
|
||||||
|
|
||||||
if (pulls.data.length === 0) {
|
if (pulls.data.length === 0) {
|
||||||
|
|||||||
12
.github/workflows/pr-checks.yml
vendored
12
.github/workflows/pr-checks.yml
vendored
@@ -273,11 +273,7 @@ jobs:
|
|||||||
uses: actions/checkout@v4
|
uses: actions/checkout@v4
|
||||||
|
|
||||||
- name: Run Trivy vulnerability scanner
|
- name: Run Trivy vulnerability scanner
|
||||||
# SECURITY: pinned to a full 40-char commit SHA (v0.36.0) — a mutable
|
uses: aquasecurity/trivy-action@master
|
||||||
# version tag could be re-pointed by an upstream compromise (GHSA-69fq-xp46-6x23:
|
|
||||||
# trivy-action's published artifacts were briefly poisoned). The trailing
|
|
||||||
# comment records the human-readable version; Dependabot updates the SHA.
|
|
||||||
uses: aquasecurity/trivy-action@ed142fd0673e97e23eac54620cfb913e5ce36c25 # v0.36.0
|
|
||||||
with:
|
with:
|
||||||
scan-type: 'fs'
|
scan-type: 'fs'
|
||||||
scan-ref: '.'
|
scan-ref: '.'
|
||||||
@@ -303,11 +299,7 @@ jobs:
|
|||||||
fetch-depth: 0
|
fetch-depth: 0
|
||||||
|
|
||||||
- name: Run TruffleHog OSS
|
- name: Run TruffleHog OSS
|
||||||
# SECURITY: never use @main — upstream compromise = secret exfil.
|
uses: trufflesecurity/trufflehog@main
|
||||||
# TODO: pin to a full 40-char SHA from
|
|
||||||
# https://github.com/trufflesecurity/trufflehog/releases and configure
|
|
||||||
# Dependabot. Version tag is still mutable but is a major upgrade over @main.
|
|
||||||
uses: trufflesecurity/trufflehog@v3.82.13
|
|
||||||
with:
|
with:
|
||||||
path: ./
|
path: ./
|
||||||
base: ${{ github.event.pull_request.base.sha }}
|
base: ${{ github.event.pull_request.base.sha }}
|
||||||
|
|||||||
38
.github/workflows/pr-docker-check.yml
vendored
38
.github/workflows/pr-docker-check.yml
vendored
@@ -1,7 +1,7 @@
|
|||||||
name: PR Docker Build Check
|
name: PR Docker Build Check
|
||||||
|
|
||||||
# Lightweight build check on PR only, no image push
|
# PR 时只做轻量级构建检查,不推送镜像
|
||||||
# Strategy: Quick verify amd64 + spot check arm64 (backend only)
|
# 策略: 快速验证 amd64 + 抽样检查 arm64 (backend only)
|
||||||
on:
|
on:
|
||||||
pull_request:
|
pull_request:
|
||||||
branches:
|
branches:
|
||||||
@@ -18,7 +18,7 @@ on:
|
|||||||
- '.github/workflows/pr-docker-check.yml'
|
- '.github/workflows/pr-docker-check.yml'
|
||||||
|
|
||||||
jobs:
|
jobs:
|
||||||
# Quick check: amd64 builds for all images
|
# 快速检查: 所有镜像的 amd64 版本
|
||||||
docker-build-amd64:
|
docker-build-amd64:
|
||||||
name: Build Check (amd64)
|
name: Build Check (amd64)
|
||||||
runs-on: ubuntu-22.04
|
runs-on: ubuntu-22.04
|
||||||
@@ -31,7 +31,7 @@ jobs:
|
|||||||
include:
|
include:
|
||||||
- name: backend
|
- name: backend
|
||||||
dockerfile: ./docker/Dockerfile.backend
|
dockerfile: ./docker/Dockerfile.backend
|
||||||
test_run: true # Needs test run
|
test_run: true # 需要测试运行
|
||||||
- name: frontend
|
- name: frontend
|
||||||
dockerfile: ./docker/Dockerfile.frontend
|
dockerfile: ./docker/Dockerfile.frontend
|
||||||
test_run: true
|
test_run: true
|
||||||
@@ -51,7 +51,7 @@ jobs:
|
|||||||
file: ${{ matrix.dockerfile }}
|
file: ${{ matrix.dockerfile }}
|
||||||
platforms: linux/amd64
|
platforms: linux/amd64
|
||||||
push: false
|
push: false
|
||||||
load: true # Load into local Docker for test run
|
load: true # 加载到本地 Docker,用于测试运行
|
||||||
tags: nofx-${{ matrix.name }}:pr-test
|
tags: nofx-${{ matrix.name }}:pr-test
|
||||||
cache-from: type=gha,scope=${{ matrix.name }}-amd64
|
cache-from: type=gha,scope=${{ matrix.name }}-amd64
|
||||||
cache-to: type=gha,mode=max,scope=${{ matrix.name }}-amd64
|
cache-to: type=gha,mode=max,scope=${{ matrix.name }}-amd64
|
||||||
@@ -66,12 +66,12 @@ jobs:
|
|||||||
run: |
|
run: |
|
||||||
echo "🧪 Testing container startup..."
|
echo "🧪 Testing container startup..."
|
||||||
|
|
||||||
# Start container
|
# 启动容器
|
||||||
docker run -d --name test-${{ matrix.name }} \
|
docker run -d --name test-${{ matrix.name }} \
|
||||||
--health-cmd="exit 0" \
|
--health-cmd="exit 0" \
|
||||||
nofx-${{ matrix.name }}:pr-test
|
nofx-${{ matrix.name }}:pr-test
|
||||||
|
|
||||||
# Wait for container to start (up to 30 seconds)
|
# 等待容器启动 (最多 30 秒)
|
||||||
for i in {1..30}; do
|
for i in {1..30}; do
|
||||||
if docker ps | grep -q test-${{ matrix.name }}; then
|
if docker ps | grep -q test-${{ matrix.name }}; then
|
||||||
echo "✅ Container started successfully"
|
echo "✅ Container started successfully"
|
||||||
@@ -93,7 +93,7 @@ jobs:
|
|||||||
|
|
||||||
echo "📦 Image size: ${SIZE_MB} MB"
|
echo "📦 Image size: ${SIZE_MB} MB"
|
||||||
|
|
||||||
# Warning thresholds
|
# 警告阈值
|
||||||
if [ "${{ matrix.name }}" = "backend" ] && [ $SIZE_MB -gt 500 ]; then
|
if [ "${{ matrix.name }}" = "backend" ] && [ $SIZE_MB -gt 500 ]; then
|
||||||
echo "⚠️ Warning: Backend image is larger than 500MB"
|
echo "⚠️ Warning: Backend image is larger than 500MB"
|
||||||
elif [ "${{ matrix.name }}" = "frontend" ] && [ $SIZE_MB -gt 200 ]; then
|
elif [ "${{ matrix.name }}" = "frontend" ] && [ $SIZE_MB -gt 200 ]; then
|
||||||
@@ -102,10 +102,10 @@ jobs:
|
|||||||
echo "✅ Image size is reasonable"
|
echo "✅ Image size is reasonable"
|
||||||
fi
|
fi
|
||||||
|
|
||||||
# ARM64 native build check: Uses GitHub native ARM64 runner (fast!)
|
# ARM64 原生构建检查: 使用 GitHub 原生 ARM64 runner (快速!)
|
||||||
docker-build-arm64-native:
|
docker-build-arm64-native:
|
||||||
name: Build Check (arm64 native - backend)
|
name: Build Check (arm64 native - backend)
|
||||||
runs-on: ubuntu-22.04-arm # Native ARM64 runner
|
runs-on: ubuntu-22.04-arm # 原生 ARM64 runner
|
||||||
permissions:
|
permissions:
|
||||||
contents: read
|
contents: read
|
||||||
|
|
||||||
@@ -113,19 +113,19 @@ jobs:
|
|||||||
- name: Checkout code
|
- name: Checkout code
|
||||||
uses: actions/checkout@v4
|
uses: actions/checkout@v4
|
||||||
|
|
||||||
# Native ARM64 does not need QEMU, builds directly
|
# 原生 ARM64 不需要 QEMU,直接构建
|
||||||
- name: Set up Docker Buildx
|
- name: Set up Docker Buildx
|
||||||
uses: docker/setup-buildx-action@v3
|
uses: docker/setup-buildx-action@v3
|
||||||
|
|
||||||
- name: Build backend image (arm64 native)
|
- name: Build backend image (arm64 native)
|
||||||
uses: docker/build-push-action@v5
|
uses: docker/build-push-action@v5
|
||||||
timeout-minutes: 15 # Native builds are faster!
|
timeout-minutes: 15 # 原生构建更快!
|
||||||
with:
|
with:
|
||||||
context: .
|
context: .
|
||||||
file: ./docker/Dockerfile.backend
|
file: ./docker/Dockerfile.backend
|
||||||
platforms: linux/arm64
|
platforms: linux/arm64
|
||||||
push: false
|
push: false
|
||||||
load: true # Load locally for testing
|
load: true # 加载到本地,用于测试
|
||||||
tags: nofx-backend:pr-test-arm64
|
tags: nofx-backend:pr-test-arm64
|
||||||
cache-from: type=gha,scope=backend-arm64
|
cache-from: type=gha,scope=backend-arm64
|
||||||
cache-to: type=gha,mode=max,scope=backend-arm64
|
cache-to: type=gha,mode=max,scope=backend-arm64
|
||||||
@@ -139,12 +139,12 @@ jobs:
|
|||||||
run: |
|
run: |
|
||||||
echo "🧪 Testing ARM64 container startup..."
|
echo "🧪 Testing ARM64 container startup..."
|
||||||
|
|
||||||
# Start container
|
# 启动容器
|
||||||
docker run -d --name test-backend-arm64 \
|
docker run -d --name test-backend-arm64 \
|
||||||
--health-cmd="exit 0" \
|
--health-cmd="exit 0" \
|
||||||
nofx-backend:pr-test-arm64
|
nofx-backend:pr-test-arm64
|
||||||
|
|
||||||
# Wait for startup
|
# 等待启动
|
||||||
for i in {1..30}; do
|
for i in {1..30}; do
|
||||||
if docker ps | grep -q test-backend-arm64; then
|
if docker ps | grep -q test-backend-arm64; then
|
||||||
echo "✅ ARM64 container started successfully"
|
echo "✅ ARM64 container started successfully"
|
||||||
@@ -165,14 +165,14 @@ jobs:
|
|||||||
echo "Using GitHub native ARM64 runner - no QEMU needed!"
|
echo "Using GitHub native ARM64 runner - no QEMU needed!"
|
||||||
echo "Build time is ~3x faster than emulation"
|
echo "Build time is ~3x faster than emulation"
|
||||||
|
|
||||||
# Aggregate check results
|
# 汇总检查结果
|
||||||
check-summary:
|
check-summary:
|
||||||
name: Docker Build Summary
|
name: Docker Build Summary
|
||||||
needs: [docker-build-amd64, docker-build-arm64-native]
|
needs: [docker-build-amd64, docker-build-arm64-native]
|
||||||
runs-on: ubuntu-22.04
|
runs-on: ubuntu-22.04
|
||||||
if: always()
|
if: always()
|
||||||
permissions:
|
permissions:
|
||||||
pull-requests: write # For posting comments
|
pull-requests: write # 用于发布评论
|
||||||
steps:
|
steps:
|
||||||
- name: Check build results
|
- name: Check build results
|
||||||
id: check
|
id: check
|
||||||
@@ -180,7 +180,7 @@ jobs:
|
|||||||
echo "## 🐳 Docker Build Check Results" >> $GITHUB_STEP_SUMMARY
|
echo "## 🐳 Docker Build Check Results" >> $GITHUB_STEP_SUMMARY
|
||||||
echo "" >> $GITHUB_STEP_SUMMARY
|
echo "" >> $GITHUB_STEP_SUMMARY
|
||||||
|
|
||||||
# Check amd64 build
|
# 检查 amd64 构建
|
||||||
if [[ "${{ needs.docker-build-amd64.result }}" == "success" ]]; then
|
if [[ "${{ needs.docker-build-amd64.result }}" == "success" ]]; then
|
||||||
echo "✅ **AMD64 builds**: All passed" >> $GITHUB_STEP_SUMMARY
|
echo "✅ **AMD64 builds**: All passed" >> $GITHUB_STEP_SUMMARY
|
||||||
AMD64_OK=true
|
AMD64_OK=true
|
||||||
@@ -189,7 +189,7 @@ jobs:
|
|||||||
AMD64_OK=false
|
AMD64_OK=false
|
||||||
fi
|
fi
|
||||||
|
|
||||||
# Check arm64 build
|
# 检查 arm64 构建
|
||||||
if [[ "${{ needs.docker-build-arm64-native.result }}" == "success" ]]; then
|
if [[ "${{ needs.docker-build-arm64-native.result }}" == "success" ]]; then
|
||||||
echo "✅ **ARM64 build** (native): Backend passed (frontend will be verified after merge)" >> $GITHUB_STEP_SUMMARY
|
echo "✅ **ARM64 build** (native): Backend passed (frontend will be verified after merge)" >> $GITHUB_STEP_SUMMARY
|
||||||
ARM64_OK=true
|
ARM64_OK=true
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
name: PR Docker Compose Healthcheck
|
name: PR Docker Compose Healthcheck
|
||||||
|
|
||||||
# Verify docker-compose.yml healthcheck config works correctly in Alpine containers
|
# 驗證 docker-compose.yml 的 healthcheck 配置在 Alpine 容器中正常工作
|
||||||
on:
|
on:
|
||||||
pull_request:
|
pull_request:
|
||||||
branches:
|
branches:
|
||||||
|
|||||||
210
.github/workflows/pr-template-suggester.yml
vendored
210
.github/workflows/pr-template-suggester.yml
vendored
@@ -1,18 +1,22 @@
|
|||||||
name: PR Labeler
|
name: PR Template Suggester
|
||||||
|
|
||||||
on:
|
on:
|
||||||
pull_request:
|
pull_request:
|
||||||
types: [opened, synchronize, reopened]
|
types: [opened, edited, synchronize]
|
||||||
|
|
||||||
permissions:
|
permissions:
|
||||||
pull-requests: write
|
pull-requests: write
|
||||||
|
issues: write
|
||||||
contents: read
|
contents: read
|
||||||
|
|
||||||
jobs:
|
jobs:
|
||||||
label-pr:
|
suggest-template:
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
steps:
|
steps:
|
||||||
- name: Analyze PR and apply labels
|
- name: Checkout code
|
||||||
|
uses: actions/checkout@v4
|
||||||
|
|
||||||
|
- name: Analyze PR files and auto-apply template
|
||||||
uses: actions/github-script@v7
|
uses: actions/github-script@v7
|
||||||
with:
|
with:
|
||||||
github-token: ${{ secrets.GITHUB_TOKEN }}
|
github-token: ${{ secrets.GITHUB_TOKEN }}
|
||||||
@@ -21,72 +25,166 @@ jobs:
|
|||||||
owner: context.repo.owner,
|
owner: context.repo.owner,
|
||||||
repo: context.repo.repo,
|
repo: context.repo.repo,
|
||||||
pull_number: context.issue.number,
|
pull_number: context.issue.number,
|
||||||
per_page: 100,
|
|
||||||
});
|
});
|
||||||
|
|
||||||
let goFiles = 0, jsFiles = 0, tsFiles = 0, mdFiles = 0, otherFiles = 0;
|
let goFiles = 0, jsFiles = 0, tsFiles = 0, mdFiles = 0, otherFiles = 0;
|
||||||
let additions = 0, deletions = 0;
|
|
||||||
|
|
||||||
for (const file of files) {
|
for (const file of files) {
|
||||||
const name = file.filename.toLowerCase();
|
const filename = file.filename.toLowerCase();
|
||||||
additions += file.additions || 0;
|
if (filename.endsWith('.go')) goFiles++;
|
||||||
deletions += file.deletions || 0;
|
else if (filename.endsWith('.js') || filename.endsWith('.jsx')) jsFiles++;
|
||||||
if (name.endsWith('.go')) goFiles++;
|
else if (filename.endsWith('.ts') || filename.endsWith('.tsx') || filename.endsWith('.vue')) tsFiles++;
|
||||||
else if (name.endsWith('.js') || name.endsWith('.jsx')) jsFiles++;
|
else if (filename.endsWith('.md')) mdFiles++;
|
||||||
else if (name.endsWith('.ts') || name.endsWith('.tsx') || name.endsWith('.vue')) tsFiles++;
|
|
||||||
else if (name.endsWith('.md')) mdFiles++;
|
|
||||||
else otherFiles++;
|
else otherFiles++;
|
||||||
}
|
}
|
||||||
|
|
||||||
const totalFiles = goFiles + jsFiles + tsFiles + mdFiles + otherFiles;
|
const totalFiles = goFiles + jsFiles + tsFiles + mdFiles + otherFiles;
|
||||||
if (totalFiles === 0) return;
|
if (totalFiles === 0) { console.log('No files changed'); return; }
|
||||||
|
|
||||||
// --- Scope label ---
|
let suggestedTemplate = null, templateEmoji = '', templateLabel = '';
|
||||||
const labels = [];
|
|
||||||
if (goFiles / totalFiles > 0.5) labels.push('backend');
|
|
||||||
else if ((jsFiles + tsFiles) / totalFiles > 0.5) labels.push('frontend');
|
|
||||||
else if (mdFiles / totalFiles > 0.7) labels.push('documentation');
|
|
||||||
else labels.push('fullstack');
|
|
||||||
|
|
||||||
// --- Size label (like OpenClaw) ---
|
if (goFiles / totalFiles > 0.5) {
|
||||||
const totalChanged = additions + deletions;
|
suggestedTemplate = 'backend'; templateEmoji = '🔧'; templateLabel = 'backend';
|
||||||
const sizeLabels = ['size: XS', 'size: S', 'size: M', 'size: L', 'size: XL'];
|
} else if ((jsFiles + tsFiles) / totalFiles > 0.5) {
|
||||||
let sizeLabel = 'size: XL';
|
suggestedTemplate = 'frontend'; templateEmoji = '🎨'; templateLabel = 'frontend';
|
||||||
if (totalChanged < 50) sizeLabel = 'size: XS';
|
} else if (mdFiles / totalFiles > 0.7) {
|
||||||
else if (totalChanged < 200) sizeLabel = 'size: S';
|
suggestedTemplate = 'docs'; templateEmoji = '📝'; templateLabel = 'documentation';
|
||||||
else if (totalChanged < 500) sizeLabel = 'size: M';
|
|
||||||
else if (totalChanged < 1000) sizeLabel = 'size: L';
|
|
||||||
labels.push(sizeLabel);
|
|
||||||
|
|
||||||
// Ensure size labels exist
|
|
||||||
for (const sl of sizeLabels) {
|
|
||||||
try {
|
|
||||||
await github.rest.issues.getLabel({ owner: context.repo.owner, repo: context.repo.repo, name: sl });
|
|
||||||
} catch (e) {
|
|
||||||
if (e.status === 404) {
|
|
||||||
await github.rest.issues.createLabel({ owner: context.repo.owner, repo: context.repo.repo, name: sl, color: 'b76e79' });
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Remove stale size labels
|
const { data: pr } = await github.rest.pulls.get({
|
||||||
const { data: currentLabels } = await github.rest.issues.listLabelsOnIssue({
|
|
||||||
owner: context.repo.owner, repo: context.repo.repo, issue_number: context.issue.number,
|
|
||||||
});
|
|
||||||
for (const cl of currentLabels) {
|
|
||||||
if (sizeLabels.includes(cl.name) && cl.name !== sizeLabel) {
|
|
||||||
await github.rest.issues.removeLabel({
|
|
||||||
owner: context.repo.owner, repo: context.repo.repo, issue_number: context.issue.number, name: cl.name,
|
|
||||||
}).catch(() => {});
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Apply labels
|
|
||||||
await github.rest.issues.addLabels({
|
|
||||||
owner: context.repo.owner,
|
owner: context.repo.owner,
|
||||||
repo: context.repo.repo,
|
repo: context.repo.repo,
|
||||||
issue_number: context.issue.number,
|
pull_number: context.issue.number,
|
||||||
labels: labels,
|
|
||||||
});
|
});
|
||||||
|
|
||||||
console.log(`Applied labels: ${labels.join(', ')} (${totalChanged} lines changed)`);
|
const prBody = pr.body || '';
|
||||||
|
const usesBackendTemplate = prBody.includes('Pull Request - Backend');
|
||||||
|
const usesFrontendTemplate = prBody.includes('Pull Request - Frontend');
|
||||||
|
const usesDocsTemplate = prBody.includes('Pull Request - Documentation');
|
||||||
|
const usesGeneralTemplate = prBody.includes('Pull Request - General');
|
||||||
|
const usingDefaultTemplate = !usesBackendTemplate && !usesFrontendTemplate && !usesDocsTemplate && !usesGeneralTemplate;
|
||||||
|
|
||||||
|
if (templateLabel) {
|
||||||
|
try {
|
||||||
|
await github.rest.issues.addLabels({
|
||||||
|
owner: context.repo.owner,
|
||||||
|
repo: context.repo.repo,
|
||||||
|
issue_number: context.issue.number,
|
||||||
|
labels: [templateLabel]
|
||||||
|
});
|
||||||
|
console.log('Added label: ' + templateLabel);
|
||||||
|
} catch (error) {
|
||||||
|
console.log('Label might not exist, skipping...');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function isPRBodyEmpty(body) {
|
||||||
|
if (!body || body.trim().length < 100) return true;
|
||||||
|
const hasEmptyDescription = body.includes('**English:**') && body.match(/\*\*English:\*\*\s*\n\s*\n\s*\n/);
|
||||||
|
const hasEmptyChanges = body.includes('具体变更') && body.match(/\*\*中文:\*\*\s*\n\s*-\s*\n\s*-\s*\n/);
|
||||||
|
if (hasEmptyDescription || hasEmptyChanges) return true;
|
||||||
|
const descMatch = body.match(/\*\*English:\*\*[||]\s*\*\*中文:\*\*\s*\n\s*(.+)/);
|
||||||
|
if (!descMatch || descMatch[1].trim().length < 10) return true;
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (suggestedTemplate && usingDefaultTemplate) {
|
||||||
|
const shouldAutoApply = isPRBodyEmpty(prBody);
|
||||||
|
const templatePath = '.github/PULL_REQUEST_TEMPLATE/' + suggestedTemplate + '.md';
|
||||||
|
|
||||||
|
if (shouldAutoApply) {
|
||||||
|
try {
|
||||||
|
const { data: templateFile } = await github.rest.repos.getContent({
|
||||||
|
owner: context.repo.owner,
|
||||||
|
repo: context.repo.repo,
|
||||||
|
path: templatePath,
|
||||||
|
ref: context.payload.pull_request.head.ref
|
||||||
|
});
|
||||||
|
|
||||||
|
const templateContent = Buffer.from(templateFile.content, 'base64').toString('utf-8');
|
||||||
|
|
||||||
|
await github.rest.pulls.update({
|
||||||
|
owner: context.repo.owner,
|
||||||
|
repo: context.repo.repo,
|
||||||
|
pull_number: context.issue.number,
|
||||||
|
body: templateContent
|
||||||
|
});
|
||||||
|
|
||||||
|
console.log('Auto-applied ' + suggestedTemplate + ' template');
|
||||||
|
|
||||||
|
let fileStats = [];
|
||||||
|
if (goFiles > 0) fileStats.push('- 🔧 Go files: ' + goFiles);
|
||||||
|
if (jsFiles > 0) fileStats.push('- 🎨 JavaScript files: ' + jsFiles);
|
||||||
|
if (tsFiles > 0) fileStats.push('- 🎨 TypeScript files: ' + tsFiles);
|
||||||
|
if (mdFiles > 0) fileStats.push('- 📝 Markdown files: ' + mdFiles);
|
||||||
|
if (otherFiles > 0) fileStats.push('- 📦 Other files: ' + otherFiles);
|
||||||
|
const fileStatsText = fileStats.join('\n');
|
||||||
|
|
||||||
|
const notifyComment = '## ' + templateEmoji + ' 已自动应用专用模板 | Auto-Applied Template\n\n' +
|
||||||
|
'检测到您的PR主要包含 **' + suggestedTemplate + '** 相关的变更,系统已自动为您应用相应的模板。\n\n' +
|
||||||
|
'Detected that your PR primarily contains **' + suggestedTemplate + '** changes. The appropriate template has been automatically applied.\n\n' +
|
||||||
|
'**文件统计 | File Statistics**\n' + fileStatsText + '\n\n' +
|
||||||
|
'**已应用模板 | Applied Template**\n`' + templatePath + '`\n\n' +
|
||||||
|
'✨ 您现在可以直接在PR描述中填写相关信息了!\n\n' +
|
||||||
|
'✨ You can now fill in the relevant information in the PR description!';
|
||||||
|
|
||||||
|
await github.rest.issues.createComment({
|
||||||
|
owner: context.repo.owner,
|
||||||
|
repo: context.repo.repo,
|
||||||
|
issue_number: context.issue.number,
|
||||||
|
body: notifyComment
|
||||||
|
});
|
||||||
|
|
||||||
|
} catch (error) {
|
||||||
|
console.log('Failed to fetch or apply template: ' + error.message);
|
||||||
|
const templateUrl = 'https://raw.githubusercontent.com/' + context.repo.owner + '/' + context.repo.repo + '/dev/.github/PULL_REQUEST_TEMPLATE/' + suggestedTemplate + '.md';
|
||||||
|
const fallbackComment = '## ' + templateEmoji + ' 建议使用专用模板 | Suggested Template\n\n' +
|
||||||
|
'您的PR主要包含 **' + suggestedTemplate + '** 相关的变更。\n\n' +
|
||||||
|
'**推荐模板 | Recommended Template:** `.github/PULL_REQUEST_TEMPLATE/' + suggestedTemplate + '.md`\n\n' +
|
||||||
|
'**如何使用 | How to use:** [点击查看模板内容](' + templateUrl + ')';
|
||||||
|
|
||||||
|
await github.rest.issues.createComment({
|
||||||
|
owner: context.repo.owner,
|
||||||
|
repo: context.repo.repo,
|
||||||
|
issue_number: context.issue.number,
|
||||||
|
body: fallbackComment
|
||||||
|
});
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
console.log('PR body has content, sending suggestion only');
|
||||||
|
|
||||||
|
let fileStats = [];
|
||||||
|
if (goFiles > 0) fileStats.push('- 🔧 Go files: ' + goFiles);
|
||||||
|
if (jsFiles > 0) fileStats.push('- 🎨 JavaScript files: ' + jsFiles);
|
||||||
|
if (tsFiles > 0) fileStats.push('- 🎨 TypeScript files: ' + tsFiles);
|
||||||
|
if (mdFiles > 0) fileStats.push('- 📝 Markdown files: ' + mdFiles);
|
||||||
|
if (otherFiles > 0) fileStats.push('- 📦 Other files: ' + otherFiles);
|
||||||
|
const fileStatsText = fileStats.join('\n');
|
||||||
|
|
||||||
|
const templateUrl = 'https://raw.githubusercontent.com/' + context.repo.owner + '/' + context.repo.repo + '/dev/.github/PULL_REQUEST_TEMPLATE/' + suggestedTemplate + '.md';
|
||||||
|
|
||||||
|
const comment = '## ' + templateEmoji + ' 建议使用专用模板 | Suggested Template\n\n' +
|
||||||
|
'您的PR主要包含 **' + suggestedTemplate + '** 相关的变更。我们建议使用更适合的模板以简化填写。\n\n' +
|
||||||
|
'Your PR primarily contains **' + suggestedTemplate + '** changes. We suggest using a more suitable template to simplify filling.\n\n' +
|
||||||
|
'**文件统计 | File Statistics**\n' + fileStatsText + '\n\n' +
|
||||||
|
'**推荐模板 | Recommended Template**\n```\n.github/PULL_REQUEST_TEMPLATE/' + suggestedTemplate + '.md\n```\n\n' +
|
||||||
|
'**如何使用 | How to use**\n' +
|
||||||
|
'1. 编辑PR描述 | Edit PR description\n' +
|
||||||
|
'2. 复制 [' + suggestedTemplate + ' 模板内容](' + templateUrl + ') | Copy [' + suggestedTemplate + ' template content](' + templateUrl + ')\n' +
|
||||||
|
'3. 或在创建PR时使用URL参数 | Or use URL parameter when creating PR\n' +
|
||||||
|
' `?template=' + suggestedTemplate + '.md`\n\n' +
|
||||||
|
'_这是一个自动建议,您可以继续使用当前模板。_\n\n' +
|
||||||
|
'_This is an automated suggestion. You may continue using the current template._';
|
||||||
|
|
||||||
|
await github.rest.issues.createComment({
|
||||||
|
owner: context.repo.owner,
|
||||||
|
repo: context.repo.repo,
|
||||||
|
issue_number: context.issue.number,
|
||||||
|
body: comment
|
||||||
|
});
|
||||||
|
}
|
||||||
|
} else if (suggestedTemplate && !usingDefaultTemplate) {
|
||||||
|
console.log('PR already uses a specific template');
|
||||||
|
} else {
|
||||||
|
console.log('No specific template suggestion needed - mixed changes');
|
||||||
|
}
|
||||||
|
|||||||
4
.github/workflows/test.yml
vendored
4
.github/workflows/test.yml
vendored
@@ -6,10 +6,6 @@ on:
|
|||||||
pull_request:
|
pull_request:
|
||||||
branches: [main, dev]
|
branches: [main, dev]
|
||||||
|
|
||||||
# Least privilege: these jobs only read the repo.
|
|
||||||
permissions:
|
|
||||||
contents: read
|
|
||||||
|
|
||||||
jobs:
|
jobs:
|
||||||
backend-tests:
|
backend-tests:
|
||||||
name: Backend Tests
|
name: Backend Tests
|
||||||
|
|||||||
18
.gitignore
vendored
18
.gitignore
vendored
@@ -16,7 +16,6 @@ nofx_test
|
|||||||
# Go 相关
|
# Go 相关
|
||||||
*.test
|
*.test
|
||||||
*.out
|
*.out
|
||||||
.gocache/
|
|
||||||
|
|
||||||
# 操作系统
|
# 操作系统
|
||||||
.DS_Store
|
.DS_Store
|
||||||
@@ -27,8 +26,6 @@ Thumbs.db
|
|||||||
*.tmp
|
*.tmp
|
||||||
*.bak
|
*.bak
|
||||||
*.backup
|
*.backup
|
||||||
.cache/
|
|
||||||
.gh-config/
|
|
||||||
|
|
||||||
# 环境变量
|
# 环境变量
|
||||||
.env
|
.env
|
||||||
@@ -44,7 +41,6 @@ decision_logs/
|
|||||||
nofx_test
|
nofx_test
|
||||||
|
|
||||||
# Node.js
|
# Node.js
|
||||||
web/node_modules
|
|
||||||
web/node_modules/
|
web/node_modules/
|
||||||
node_modules/
|
node_modules/
|
||||||
web/dist/
|
web/dist/
|
||||||
@@ -53,9 +49,6 @@ web/.vite/
|
|||||||
# ESLint 临时报告文件(调试时生成,不纳入版本控制)
|
# ESLint 临时报告文件(调试时生成,不纳入版本控制)
|
||||||
eslint-*.json
|
eslint-*.json
|
||||||
|
|
||||||
# 本地 Agent QA seed(个人调试用,不纳入版本控制)
|
|
||||||
docs/qa/fixtures/agent_self_play_seed.zh-CN.json
|
|
||||||
|
|
||||||
# VS code
|
# VS code
|
||||||
.vscode
|
.vscode
|
||||||
|
|
||||||
@@ -85,6 +78,7 @@ dist/
|
|||||||
downloads/
|
downloads/
|
||||||
eggs/
|
eggs/
|
||||||
.eggs/
|
.eggs/
|
||||||
|
lib/
|
||||||
lib64/
|
lib64/
|
||||||
parts/
|
parts/
|
||||||
sdist/
|
sdist/
|
||||||
@@ -130,12 +124,4 @@ dmypy.json
|
|||||||
# Pyre type checker
|
# Pyre type checker
|
||||||
.pyre/
|
.pyre/
|
||||||
PR_DESCRIPTION.md
|
PR_DESCRIPTION.md
|
||||||
|
nofx-moltbot
|
||||||
# Go build artifacts
|
|
||||||
/nofx-server
|
|
||||||
.gstack/
|
|
||||||
|
|
||||||
# Local AI agent / skill scaffolding (not part of the runtime app)
|
|
||||||
.agents/
|
|
||||||
skills-lock.json
|
|
||||||
img.png
|
|
||||||
|
|||||||
@@ -1,37 +1,37 @@
|
|||||||
# Railway All-in-One: Reuse existing GHCR images
|
# Railway All-in-One: 复用现有 GHCR 镜像
|
||||||
# Extract content from existing images and merge into a single container
|
# 从现有镜像提取内容,合并到一个容器
|
||||||
|
|
||||||
# Extract binary from backend image
|
# 从后端镜像提取二进制
|
||||||
FROM ghcr.io/nofxaios/nofx/nofx-backend:latest AS backend
|
FROM ghcr.io/nofxaios/nofx/nofx-backend:latest AS backend
|
||||||
|
|
||||||
# Extract static files from frontend image
|
# 从前端镜像提取静态文件
|
||||||
FROM ghcr.io/nofxaios/nofx/nofx-frontend:latest AS frontend
|
FROM ghcr.io/nofxaios/nofx/nofx-frontend:latest AS frontend
|
||||||
|
|
||||||
# Final image
|
# 最终镜像
|
||||||
FROM alpine:latest
|
FROM alpine:latest
|
||||||
|
|
||||||
RUN apk add --no-cache ca-certificates tzdata sqlite nginx openssl gettext
|
RUN apk add --no-cache ca-certificates tzdata sqlite nginx openssl gettext
|
||||||
|
|
||||||
# Copy backend binary
|
# 复制后端二进制
|
||||||
COPY --from=backend /app/nofx /app/nofx
|
COPY --from=backend /app/nofx /app/nofx
|
||||||
|
|
||||||
# Copy TA-Lib libraries
|
# 复制 TA-Lib 库
|
||||||
COPY --from=backend /usr/local/lib/libta_lib* /usr/local/lib/
|
COPY --from=backend /usr/local/lib/libta_lib* /usr/local/lib/
|
||||||
RUN ldconfig /usr/local/lib 2>/dev/null || true
|
RUN ldconfig /usr/local/lib 2>/dev/null || true
|
||||||
|
|
||||||
# Copy frontend static files
|
# 复制前端静态文件
|
||||||
COPY --from=frontend /usr/share/nginx/html /usr/share/nginx/html
|
COPY --from=frontend /usr/share/nginx/html /usr/share/nginx/html
|
||||||
|
|
||||||
WORKDIR /app
|
WORKDIR /app
|
||||||
RUN mkdir -p /app/data
|
RUN mkdir -p /app/data
|
||||||
|
|
||||||
# Startup script (includes nginx config generation)
|
# 启动脚本(包含 nginx 配置生成)
|
||||||
COPY railway/start.sh /app/start.sh
|
COPY railway/start.sh /app/start.sh
|
||||||
RUN chmod +x /app/start.sh
|
RUN chmod +x /app/start.sh
|
||||||
|
|
||||||
ENV DB_PATH=/app/data/data.db
|
ENV DB_PATH=/app/data/data.db
|
||||||
|
|
||||||
# Railway automatically sets the PORT environment variable
|
# Railway 会自动设置 PORT 环境变量
|
||||||
EXPOSE 8080
|
EXPOSE 8080
|
||||||
|
|
||||||
HEALTHCHECK --interval=30s --timeout=10s --start-period=60s --retries=3 \
|
HEALTHCHECK --interval=30s --timeout=10s --start-period=60s --retries=3 \
|
||||||
|
|||||||
1340
README.ja.md
Normal file
1340
README.ja.md
Normal file
File diff suppressed because it is too large
Load Diff
709
README.md
709
README.md
@@ -1,282 +1,513 @@
|
|||||||
<p align="center"><strong>Backed by <a href="https://vergex.trade">vergex.trade</a></strong></p>
|
# NOFX - Agentic Trading OS
|
||||||
|
|
||||||
<p align="center">
|
[](https://golang.org/)
|
||||||
<img src="docs/assets/nofx-banner.svg" alt="NOFX — AI trading terminal" width="100%"/>
|
[](https://reactjs.org/)
|
||||||
</p>
|
[](https://www.typescriptlang.org/)
|
||||||
|
[](LICENSE)
|
||||||
|
|
||||||
<p align="center">
|
| CONTRIBUTOR AIRDROP PROGRAM |
|
||||||
<a href="https://github.com/NoFxAiOS/nofx/stargazers"><img src="https://img.shields.io/github/stars/NoFxAiOS/nofx?style=flat-square&labelColor=1A1813&color=E0483B" alt="Stars"></a>
|
|:----------------------------------:|
|
||||||
<a href="https://github.com/NoFxAiOS/nofx/releases"><img src="https://img.shields.io/github/v/release/NoFxAiOS/nofx?style=flat-square&labelColor=1A1813&color=E0483B" alt="Release"></a>
|
| Code · Bug Fixes · Issues → Airdrop |
|
||||||
<a href="https://github.com/NoFxAiOS/nofx/blob/main/LICENSE"><img src="https://img.shields.io/badge/license-AGPL--3.0-E0483B?style=flat-square&labelColor=1A1813" alt="License"></a>
|
| [Learn More](#contributor-airdrop-program) |
|
||||||
<a href="https://t.me/nofx_dev_community"><img src="https://img.shields.io/badge/telegram-community-E0483B?style=flat-square&labelColor=1A1813&logo=telegram&logoColor=white" alt="Telegram"></a>
|
|
||||||
</p>
|
|
||||||
|
|
||||||
<p align="center">
|
**Languages:** [English](README.md) | [中文](docs/i18n/zh-CN/README.md) | [日本語](docs/i18n/ja/README.md) | [한국어](docs/i18n/ko/README.md) | [Русский](docs/i18n/ru/README.md) | [Українська](docs/i18n/uk/README.md) | [Tiếng Việt](docs/i18n/vi/README.md)
|
||||||
<a href="README.md">English</a> ·
|
|
||||||
<a href="docs/i18n/zh-CN/README.md">中文</a> ·
|
|
||||||
<a href="docs/i18n/ja/README.md">日本語</a> ·
|
|
||||||
<a href="docs/i18n/ko/README.md">한국어</a> ·
|
|
||||||
<a href="docs/i18n/ru/README.md">Русский</a> ·
|
|
||||||
<a href="docs/i18n/uk/README.md">Українська</a> ·
|
|
||||||
<a href="docs/i18n/vi/README.md">Tiếng Việt</a>
|
|
||||||
</p>
|
|
||||||
|
|
||||||
<br/>
|
---
|
||||||
|
|
||||||
NOFX is an open-source trading terminal where the strategy is a language model. Each trader runs a continuous loop — read market structure, decide, execute, record the reasoning — while a Go runtime clamps every order to hard risk limits the model cannot override.
|
## AI-Powered Multi-Asset Trading Platform
|
||||||
|
|
||||||
Traders compose freely: any model, any of nine exchanges, any strategy. Run several side by side and compare them on a public leaderboard by realized return. Everything runs on your own machine; exchange credentials are encrypted at rest and never leave it.
|
**NOFX** is an open-source AI trading system that lets you run multiple AI models to trade automatically. Configure strategies through a web interface, monitor performance in real-time, and let AI agents compete to find the best trading approach.
|
||||||
|
|
||||||
|
### Supported Markets
|
||||||
|
|
||||||
|
| Market | Trading | Status |
|
||||||
|
|--------|---------|--------|
|
||||||
|
| 🪙 **Crypto** | BTC, ETH, Altcoins | ✅ Supported |
|
||||||
|
| 📈 **US Stocks** | AAPL, TSLA, NVDA, etc. | ✅ Supported |
|
||||||
|
| 💱 **Forex** | EUR/USD, GBP/USD, etc. | ✅ Supported |
|
||||||
|
| 🥇 **Metals** | Gold, Silver | ✅ Supported |
|
||||||
|
|
||||||
|
### Core Features
|
||||||
|
|
||||||
|
- **Multi-AI Support**: Run DeepSeek, Qwen, GPT, Claude, Gemini, Grok, Kimi - switch models anytime
|
||||||
|
- **Multi-Exchange**: Trade on Binance, Bybit, OKX, Bitget, Hyperliquid, Aster DEX, Lighter from one platform
|
||||||
|
- **Strategy Studio**: Visual strategy builder with coin sources, indicators, and risk controls
|
||||||
|
- **AI Debate Arena**: Multiple AI models debate trading decisions with different roles (Bull, Bear, Analyst)
|
||||||
|
- **AI Competition Mode**: Multiple AI traders compete in real-time, track performance side by side
|
||||||
|
- **Web-Based Config**: No JSON editing - configure everything through the web interface
|
||||||
|
- **Real-Time Dashboard**: Live positions, P/L tracking, AI decision logs with Chain of Thought
|
||||||
|
|
||||||
|
### Core Team
|
||||||
|
|
||||||
|
- **Tinkle** - [@Web3Tinkle](https://x.com/Web3Tinkle)
|
||||||
|
- **Official Twitter** - [@nofx_official](https://x.com/nofx_official)
|
||||||
|
|
||||||
|
### Official Links
|
||||||
|
|
||||||
|
- **Official Website**: [https://nofxai.com](https://nofxai.com)
|
||||||
|
- **Data Dashboard**: [https://nofxos.ai/dashboard](https://nofxos.ai/dashboard)
|
||||||
|
- **API Documentation**: [https://nofxos.ai/api-docs](https://nofxos.ai/api-docs)
|
||||||
|
|
||||||
|
> **Risk Warning**: This system is experimental. AI auto-trading carries significant risks. Strongly recommended for learning/research purposes or testing with small amounts only!
|
||||||
|
|
||||||
|
## Developer Community
|
||||||
|
|
||||||
|
Join our Telegram developer community: **[NOFX Developer Community](https://t.me/nofx_dev_community)**
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Before You Begin
|
||||||
|
|
||||||
|
To use NOFX, you'll need:
|
||||||
|
|
||||||
|
1. **Exchange Account** - Register on any supported exchange and create API credentials with trading permissions
|
||||||
|
2. **AI Model API Key** - Get from any supported provider (DeepSeek recommended for cost-effectiveness)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Supported Exchanges
|
||||||
|
|
||||||
|
### CEX (Centralized Exchanges)
|
||||||
|
|
||||||
|
| Exchange | Status | Register (Fee Discount) |
|
||||||
|
|----------|--------|-------------------------|
|
||||||
|
| **Binance** | ✅ Supported | [Register](https://www.binance.com/join?ref=NOFXENG) |
|
||||||
|
| **Bybit** | ✅ Supported | [Register](https://partner.bybit.com/b/83856) |
|
||||||
|
| **OKX** | ✅ Supported | [Register](https://www.okx.com/join/1865360) |
|
||||||
|
| **Bitget** | ✅ Supported | [Register](https://www.bitget.com/referral/register?from=referral&clacCode=c8a43172) |
|
||||||
|
|
||||||
|
### Perp-DEX (Decentralized Perpetual Exchanges)
|
||||||
|
|
||||||
|
| Exchange | Status | Register (Fee Discount) |
|
||||||
|
|----------|--------|-------------------------|
|
||||||
|
| **Hyperliquid** | ✅ Supported | [Register](https://app.hyperliquid.xyz/join/AITRADING) |
|
||||||
|
| **Aster DEX** | ✅ Supported | [Register](https://www.asterdex.com/en/referral/fdfc0e) |
|
||||||
|
| **Lighter** | ✅ Supported | [Register](https://app.lighter.xyz/?referral=68151432) |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Supported AI Models
|
||||||
|
|
||||||
|
| AI Model | Status | Get API Key |
|
||||||
|
|----------|--------|-------------|
|
||||||
|
| **DeepSeek** | ✅ Supported | [Get API Key](https://platform.deepseek.com) |
|
||||||
|
| **Qwen** | ✅ Supported | [Get API Key](https://dashscope.console.aliyun.com) |
|
||||||
|
| **OpenAI (GPT)** | ✅ Supported | [Get API Key](https://platform.openai.com) |
|
||||||
|
| **Claude** | ✅ Supported | [Get API Key](https://console.anthropic.com) |
|
||||||
|
| **Gemini** | ✅ Supported | [Get API Key](https://aistudio.google.com) |
|
||||||
|
| **Grok** | ✅ Supported | [Get API Key](https://console.x.ai) |
|
||||||
|
| **Kimi** | ✅ Supported | [Get API Key](https://platform.moonshot.cn) |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Screenshots
|
||||||
|
|
||||||
|
### Config Page
|
||||||
|
| AI Models & Exchanges | Traders List |
|
||||||
|
|:---:|:---:|
|
||||||
|
| <img src="screenshots/config-ai-exchanges.png" width="400" alt="Config - AI Models & Exchanges"/> | <img src="screenshots/config-traders-list.png" width="400" alt="Config - Traders List"/> |
|
||||||
|
|
||||||
|
### Competition & Backtest
|
||||||
|
| Competition Mode | Backtest Lab |
|
||||||
|
|:---:|:---:|
|
||||||
|
| <img src="screenshots/competition-page.png" width="400" alt="Competition Page"/> | <img src="screenshots/backtest-lab.png" width="400" alt="Backtest Lab"/> |
|
||||||
|
|
||||||
|
### Dashboard
|
||||||
|
| Overview | Market Chart |
|
||||||
|
|:---:|:---:|
|
||||||
|
| <img src="screenshots/dashboard-page.png" width="400" alt="Dashboard Overview"/> | <img src="screenshots/dashboard-market-chart.png" width="400" alt="Dashboard Market Chart"/> |
|
||||||
|
|
||||||
|
| Trading Stats | Position History |
|
||||||
|
|:---:|:---:|
|
||||||
|
| <img src="screenshots/dashboard-trading-stats.png" width="400" alt="Trading Stats"/> | <img src="screenshots/dashboard-position-history.png" width="400" alt="Position History"/> |
|
||||||
|
|
||||||
|
| Positions | Trader Details |
|
||||||
|
|:---:|:---:|
|
||||||
|
| <img src="screenshots/dashboard-positions.png" width="400" alt="Dashboard Positions"/> | <img src="screenshots/details-page.png" width="400" alt="Trader Details"/> |
|
||||||
|
|
||||||
|
### Strategy Studio
|
||||||
|
| Strategy Editor | Indicators Config |
|
||||||
|
|:---:|:---:|
|
||||||
|
| <img src="screenshots/strategy-studio.png" width="400" alt="Strategy Studio"/> | <img src="screenshots/strategy-indicators.png" width="400" alt="Strategy Indicators"/> |
|
||||||
|
|
||||||
|
### Debate Arena
|
||||||
|
| AI Debate Session | Create Debate |
|
||||||
|
|:---:|:---:|
|
||||||
|
| <img src="screenshots/debate-arena.png" width="400" alt="Debate Arena"/> | <img src="screenshots/debate-create.png" width="400" alt="Create Debate"/> |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Quick Start
|
||||||
|
|
||||||
|
### One-Click Install (Local/Server)
|
||||||
|
|
||||||
|
**Linux / macOS:**
|
||||||
```bash
|
```bash
|
||||||
curl -fsSL https://raw.githubusercontent.com/NoFxAiOS/nofx/main/install.sh | bash
|
curl -fsSL https://raw.githubusercontent.com/NoFxAiOS/nofx/main/install.sh | bash
|
||||||
```
|
```
|
||||||
|
|
||||||
The terminal opens at `http://127.0.0.1:3000`.
|
That's it! Open **http://127.0.0.1:3000** in your browser.
|
||||||
|
|
||||||
**First run**
|
### One-Click Cloud Deploy (Railway)
|
||||||
|
|
||||||
1. Register — the first account becomes the owner of the instance.
|
Deploy to Railway with one click - no server setup required:
|
||||||
2. Follow the guided launch: put **$1+ USDC** (Base network) in the AI fee wallet it creates for you, then connect Hyperliquid and deposit **$12+ USDC** to trade with.
|
|
||||||
3. Start **Autopilot**. The AI scans the market every few minutes and trades on its own; every decision appears on the dashboard as it happens. Stop it anytime with one click.
|
|
||||||
|
|
||||||
<br/>
|
|
||||||
|
|
||||||
## Register exchanges
|
|
||||||
|
|
||||||
NOFX is free and open source. Opening an account through the partner links below carries reduced trading fees and funds continued development.
|
|
||||||
|
|
||||||
| Exchange | Status | Register with fee discount |
|
|
||||||
| :---------------------------------------------------------------------------------------------------------------------------- | :----: | :---------------------------------------------------------------------------------- |
|
|
||||||
| <img src="web/public/exchange-icons/binance.jpg" width="20" height="20" style="vertical-align: middle;"/> **Binance** | ✅ | [Register](https://www.binance.com/join?ref=NOFXENG) |
|
|
||||||
| <img src="web/public/exchange-icons/bybit.png" width="20" height="20" style="vertical-align: middle;"/> **Bybit** | ✅ | [Register](https://partner.bybit.com/b/83856) |
|
|
||||||
| <img src="web/public/exchange-icons/okx.svg" width="20" height="20" style="vertical-align: middle;"/> **OKX** | ✅ | [Register](https://www.okx.com/join/1865360) |
|
|
||||||
| <img src="web/public/exchange-icons/hyperliquid.png" width="20" height="20" style="vertical-align: middle;"/> **Hyperliquid** | ✅ | [Register](https://app.hyperliquid.xyz/join/AITRADING) |
|
|
||||||
| <img src="web/public/exchange-icons/bitget.svg" width="20" height="20" style="vertical-align: middle;"/> **Bitget** | ✅ | [Register](https://www.bitget.com/referral/register?from=referral&clacCode=c8a43172) |
|
|
||||||
| <img src="web/public/exchange-icons/kucoin.svg" width="20" height="20" style="vertical-align: middle;"/> **KuCoin** | ✅ | [Register](https://www.kucoin.com/r/broker/CXEV7XKK) |
|
|
||||||
| <img src="web/public/exchange-icons/gate.svg" width="20" height="20" style="vertical-align: middle;"/> **Gate** | ✅ | [Register](https://www.gatenode.xyz/share/VQBGUAxY) |
|
|
||||||
| <img src="web/public/exchange-icons/aster.svg" width="20" height="20" style="vertical-align: middle;"/> **Aster** | ✅ | [Register](https://www.asterdex.com/en/referral/fdfc0e) |
|
|
||||||
| <img src="web/public/exchange-icons/lighter.png" width="20" height="20" style="vertical-align: middle;"/> **Lighter** | ✅ | [Register](https://app.lighter.xyz/?referral=68151432) |
|
|
||||||
|
|
||||||
<br/>
|
|
||||||
|
|
||||||
## Demo
|
|
||||||
|
|
||||||
https://github.com/user-attachments/assets/3310f495-14c5-4586-a1cc-3d32e44aa505
|
|
||||||
|
|
||||||
<br/>
|
|
||||||
|
|
||||||
## The model proposes. The runtime disposes.
|
|
||||||
|
|
||||||
Decisions come from a language model reading the [Claw402.ai](https://claw402.ai) · Vergex data stack: a live signal board that ranks every market with directional bias and signal strength, per-symbol Signal Lab deep signals, cost-basis and liquidation heatmaps that show where the crowd's fuel and walls sit, and real-time market net flow — cross-checked against raw candles and the trader's own live track record. Execution does not.
|
|
||||||
|
|
||||||
Every order passes through limits enforced in code, outside the model's reach:
|
|
||||||
|
|
||||||
| | |
|
|
||||||
| :----------------------- | :--------------------------------------------------------------------------------- |
|
|
||||||
| Position limits | Max concurrent positions, notional capped as a ratio of equity, one position per symbol |
|
|
||||||
| Leverage clamps | Hard caps applied at order-sizing time, independent of what the model requests |
|
|
||||||
| Exchange-side protection | Stop-loss and take-profit placed on the exchange immediately after every entry |
|
|
||||||
| Drawdown auto-close | Profitable positions that give back too much from their peak are closed |
|
|
||||||
| Trade throttling | Minimum hold times, per-symbol re-entry cooldowns, per-cycle and per-hour entry limits |
|
|
||||||
| Safe mode | Repeated model failures block new entries until the model recovers |
|
|
||||||
| Launch preflight | Model access, wallet funds, strategy, and exchange balances verified before a trader may start |
|
|
||||||
|
|
||||||
Each decision is stored with the model's full reasoning. There is no position without a paper trail.
|
|
||||||
|
|
||||||
<br/>
|
|
||||||
|
|
||||||
## Terminal
|
|
||||||
|
|
||||||
| | |
|
|
||||||
| :--- | :--- |
|
|
||||||
| **Autopilot** | Guided launch: fund, connect, deposit, start — with server-side preflight throughout |
|
|
||||||
| **Strategy Studio** | Style presets, coin universes, indicators, leverage, entry confidence, custom prompts |
|
|
||||||
| **Competition** | Public leaderboard ranked by realized return, each entry attributed to its model |
|
|
||||||
| **Dashboard** | Live positions, orders, statistics, and the reasoning behind every decision |
|
|
||||||
|
|
||||||
<details>
|
|
||||||
<summary>Screenshots</summary>
|
|
||||||
|
|
||||||
<br/>
|
|
||||||
|
|
||||||
| Overview | Market Chart |
|
|
||||||
| :-----------------------------------------------------: | :-------------------------------------------------------------: |
|
|
||||||
| <img src="screenshots/dashboard-page.png" width="400"/> | <img src="screenshots/dashboard-market-chart.png" width="400"/> |
|
|
||||||
|
|
||||||
| Trading Stats | Position History |
|
|
||||||
| :--------------------------------------------------------------: | :-----------------------------------------------------------------: |
|
|
||||||
| <img src="screenshots/dashboard-trading-stats.png" width="400"/> | <img src="screenshots/dashboard-position-history.png" width="400"/> |
|
|
||||||
|
|
||||||
| Strategy Editor | Indicators Config |
|
|
||||||
| :------------------------------------------------------: | :----------------------------------------------------------: |
|
|
||||||
| <img src="screenshots/strategy-studio.png" width="400"/> | <img src="screenshots/strategy-indicators.png" width="400"/> |
|
|
||||||
|
|
||||||
| Competition | Configuration |
|
|
||||||
| :-------------------------------------------------------: | :-----------------------------------------------------------: |
|
|
||||||
| <img src="screenshots/competition-page.png" width="400"/> | <img src="screenshots/config-ai-exchanges.png" width="400"/> |
|
|
||||||
|
|
||||||
</details>
|
|
||||||
|
|
||||||
<br/>
|
|
||||||
|
|
||||||
## Models
|
|
||||||
|
|
||||||
Eight providers with your own keys — DeepSeek, OpenAI, Claude, Qwen, Gemini, Grok, Kimi, MiniMax — including custom endpoints and model names.
|
|
||||||
|
|
||||||
Or no keys at all: [Claw402](https://claw402.ai) meters model usage per call in USDC over the x402 protocol. A wallet on Base replaces every API key.
|
|
||||||
|
|
||||||
| Provider | Access |
|
|
||||||
| :------- | :----- |
|
|
||||||
| **Claw402** | [Pay-as-you-go AI models with official discount](https://claw402.ai) |
|
|
||||||
|
|
||||||
## Markets
|
|
||||||
|
|
||||||
Crypto perpetuals on all nine exchanges. On Hyperliquid, the same runtime also trades tokenized US equities, commodities, indices, FX, and pre-IPO perps — TSLA, NVDA, GOLD, SPX, EUR, OPENAI — alongside crypto.
|
|
||||||
|
|
||||||
<br/>
|
|
||||||
|
|
||||||
## Architecture
|
|
||||||
|
|
||||||
```
|
|
||||||
┌─────────────────────────────────────────────────┐
|
|
||||||
│ Trading Terminal │
|
|
||||||
│ React · TypeScript · TradingView │
|
|
||||||
│ Dashboard · Strategy Studio · Competition │
|
|
||||||
├─────────────────────────────────────────────────┤
|
|
||||||
│ API Server (Go) │
|
|
||||||
│ JWT auth · encrypted credential store │
|
|
||||||
├──────────────┬──────────────┬───────────────────┤
|
|
||||||
│ Strategy │ Autopilot │ Trader Runtime │
|
|
||||||
│ Engine │ Preflight │ Risk Engine │
|
|
||||||
├──────────────┴──────────────┴───────────────────┤
|
|
||||||
│ AI Model Layer │
|
|
||||||
│ DeepSeek · OpenAI · Claude · Qwen · Gemini │
|
|
||||||
│ Grok · Kimi · MiniMax · Claw402 (x402 USDC) │
|
|
||||||
├─────────────────────────────────────────────────┤
|
|
||||||
│ Exchange Connectivity │
|
|
||||||
│ Binance · Bybit · OKX · Hyperliquid · Bitget │
|
|
||||||
│ KuCoin · Gate · Aster · Lighter │
|
|
||||||
└─────────────────────────────────────────────────┘
|
|
||||||
```
|
|
||||||
|
|
||||||
<br/>
|
|
||||||
|
|
||||||
## Install
|
|
||||||
|
|
||||||
**Linux / macOS**
|
|
||||||
|
|
||||||
```bash
|
|
||||||
curl -fsSL https://raw.githubusercontent.com/NoFxAiOS/nofx/main/install.sh | bash
|
|
||||||
```
|
|
||||||
|
|
||||||
**Railway**
|
|
||||||
|
|
||||||
[](https://railway.com/deploy/nofx?referralCode=nofx)
|
[](https://railway.com/deploy/nofx?referralCode=nofx)
|
||||||
|
|
||||||
**Docker**
|
After deployment, Railway will provide a public URL to access your NOFX instance.
|
||||||
|
|
||||||
|
### Docker Compose (Manual)
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
|
# Download and start
|
||||||
curl -O https://raw.githubusercontent.com/NoFxAiOS/nofx/main/docker-compose.prod.yml
|
curl -O https://raw.githubusercontent.com/NoFxAiOS/nofx/main/docker-compose.prod.yml
|
||||||
docker compose -f docker-compose.prod.yml up -d
|
docker compose -f docker-compose.prod.yml up -d
|
||||||
```
|
```
|
||||||
|
|
||||||
**Windows** — install [Docker Desktop](https://www.docker.com/products/docker-desktop/), then:
|
Access Web Interface: **http://127.0.0.1:3000**
|
||||||
|
|
||||||
```powershell
|
|
||||||
curl -o docker-compose.prod.yml https://raw.githubusercontent.com/NoFxAiOS/nofx/main/docker-compose.prod.yml
|
|
||||||
docker compose -f docker-compose.prod.yml up -d
|
|
||||||
```
|
|
||||||
|
|
||||||
**From source** — Go 1.21+, Node.js 18+:
|
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
git clone https://github.com/NoFxAiOS/nofx.git && cd nofx
|
# Management commands
|
||||||
go build -o nofx && ./nofx # backend
|
docker compose -f docker-compose.prod.yml logs -f # View logs
|
||||||
cd web && npm install && npm run dev # frontend, in a second terminal
|
docker compose -f docker-compose.prod.yml restart # Restart
|
||||||
|
docker compose -f docker-compose.prod.yml down # Stop
|
||||||
|
docker compose -f docker-compose.prod.yml pull && docker compose -f docker-compose.prod.yml up -d # Update
|
||||||
```
|
```
|
||||||
|
|
||||||
**Update** — re-run the install script; it upgrades in place.
|
### Keeping Updated
|
||||||
|
|
||||||
<details>
|
> **💡 Updates are frequent.** Run this command daily to stay current with the latest features and fixes:
|
||||||
<summary>Server deployment</summary>
|
|
||||||
|
|
||||||
<br/>
|
|
||||||
|
|
||||||
**HTTP**
|
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
curl -fsSL https://raw.githubusercontent.com/NoFxAiOS/nofx/main/install.sh | bash
|
curl -fsSL https://raw.githubusercontent.com/NoFxAiOS/nofx/main/install.sh | bash
|
||||||
# http://YOUR_IP:3000
|
|
||||||
```
|
```
|
||||||
|
|
||||||
**HTTPS via Cloudflare**
|
This one-liner pulls the latest official images and restarts services automatically.
|
||||||
|
|
||||||
1. Add the domain to [Cloudflare](https://dash.cloudflare.com) (free plan)
|
### Manual Installation (For Developers)
|
||||||
2. A record → server IP, proxied
|
|
||||||
3. SSL/TLS → Flexible
|
|
||||||
4. `TRANSPORT_ENCRYPTION=true` in `.env`
|
|
||||||
|
|
||||||
</details>
|
#### Prerequisites
|
||||||
|
|
||||||
<br/>
|
- **Go 1.21+**
|
||||||
|
- **Node.js 18+**
|
||||||
|
- **TA-Lib** (technical indicator library)
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Install TA-Lib
|
||||||
|
# macOS
|
||||||
|
brew install ta-lib
|
||||||
|
|
||||||
|
# Ubuntu/Debian
|
||||||
|
sudo apt-get install libta-lib0-dev
|
||||||
|
```
|
||||||
|
|
||||||
|
#### Installation Steps
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# 1. Clone the repository
|
||||||
|
git clone https://github.com/NoFxAiOS/nofx.git
|
||||||
|
cd nofx
|
||||||
|
|
||||||
|
# 2. Install backend dependencies
|
||||||
|
go mod download
|
||||||
|
|
||||||
|
# 3. Install frontend dependencies
|
||||||
|
cd web
|
||||||
|
npm install
|
||||||
|
cd ..
|
||||||
|
|
||||||
|
# 4. Build and start backend
|
||||||
|
go build -o nofx
|
||||||
|
./nofx
|
||||||
|
|
||||||
|
# 5. Start frontend (new terminal)
|
||||||
|
cd web
|
||||||
|
npm run dev
|
||||||
|
```
|
||||||
|
|
||||||
|
Access Web Interface: **http://127.0.0.1:3000**
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Windows Installation
|
||||||
|
|
||||||
|
### Method 1: Docker Desktop (Recommended)
|
||||||
|
|
||||||
|
1. **Install Docker Desktop**
|
||||||
|
- Download from [docker.com/products/docker-desktop](https://www.docker.com/products/docker-desktop/)
|
||||||
|
- Run the installer and restart your computer
|
||||||
|
- Start Docker Desktop and wait for it to be ready
|
||||||
|
|
||||||
|
2. **Run NOFX**
|
||||||
|
```powershell
|
||||||
|
# Open PowerShell and run:
|
||||||
|
curl -o docker-compose.prod.yml https://raw.githubusercontent.com/NoFxAiOS/nofx/main/docker-compose.prod.yml
|
||||||
|
docker compose -f docker-compose.prod.yml up -d
|
||||||
|
```
|
||||||
|
|
||||||
|
3. **Access**: Open **http://127.0.0.1:3000** in your browser
|
||||||
|
|
||||||
|
### Method 2: WSL2 (For Development)
|
||||||
|
|
||||||
|
1. **Install WSL2**
|
||||||
|
```powershell
|
||||||
|
# Open PowerShell as Administrator
|
||||||
|
wsl --install
|
||||||
|
```
|
||||||
|
Restart your computer after installation.
|
||||||
|
|
||||||
|
2. **Install Ubuntu from Microsoft Store**
|
||||||
|
- Open Microsoft Store
|
||||||
|
- Search "Ubuntu 22.04" and install
|
||||||
|
- Launch Ubuntu and set up username/password
|
||||||
|
|
||||||
|
3. **Install Dependencies in WSL2**
|
||||||
|
```bash
|
||||||
|
# Update system
|
||||||
|
sudo apt update && sudo apt upgrade -y
|
||||||
|
|
||||||
|
# Install Go
|
||||||
|
wget https://go.dev/dl/go1.21.5.linux-amd64.tar.gz
|
||||||
|
sudo tar -C /usr/local -xzf go1.21.5.linux-amd64.tar.gz
|
||||||
|
echo 'export PATH=$PATH:/usr/local/go/bin' >> ~/.bashrc
|
||||||
|
source ~/.bashrc
|
||||||
|
|
||||||
|
# Install Node.js
|
||||||
|
curl -fsSL https://deb.nodesource.com/setup_18.x | sudo -E bash -
|
||||||
|
sudo apt-get install -y nodejs
|
||||||
|
|
||||||
|
# Install TA-Lib
|
||||||
|
sudo apt-get install -y libta-lib0-dev
|
||||||
|
|
||||||
|
# Install Git
|
||||||
|
sudo apt-get install -y git
|
||||||
|
```
|
||||||
|
|
||||||
|
4. **Clone and Run NOFX**
|
||||||
|
```bash
|
||||||
|
git clone https://github.com/NoFxAiOS/nofx.git
|
||||||
|
cd nofx
|
||||||
|
|
||||||
|
# Build and run backend
|
||||||
|
go build -o nofx && ./nofx
|
||||||
|
|
||||||
|
# In another terminal, run frontend
|
||||||
|
cd web && npm install && npm run dev
|
||||||
|
```
|
||||||
|
|
||||||
|
5. **Access**: Open **http://127.0.0.1:3000** in Windows browser
|
||||||
|
|
||||||
|
### Method 3: Docker in WSL2 (Best of Both Worlds)
|
||||||
|
|
||||||
|
1. **Install Docker Desktop with WSL2 backend**
|
||||||
|
- During Docker Desktop installation, enable "Use WSL 2 based engine"
|
||||||
|
- In Docker Desktop Settings → Resources → WSL Integration, enable your Linux distro
|
||||||
|
|
||||||
|
2. **Run from WSL2 terminal**
|
||||||
|
```bash
|
||||||
|
curl -fsSL https://raw.githubusercontent.com/NoFxAiOS/nofx/main/install.sh | bash
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Server Deployment
|
||||||
|
|
||||||
|
### Quick Deploy (HTTP via IP)
|
||||||
|
|
||||||
|
By default, transport encryption is **disabled**, allowing you to access NOFX via IP address without HTTPS:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Deploy to your server
|
||||||
|
curl -fsSL https://raw.githubusercontent.com/NoFxAiOS/nofx/main/install.sh | bash
|
||||||
|
```
|
||||||
|
|
||||||
|
Access via `http://YOUR_SERVER_IP:3000` - works immediately.
|
||||||
|
|
||||||
|
### Enhanced Security (HTTPS)
|
||||||
|
|
||||||
|
For enhanced security, enable transport encryption in `.env`:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
TRANSPORT_ENCRYPTION=true
|
||||||
|
```
|
||||||
|
|
||||||
|
When enabled, browser uses Web Crypto API to encrypt API keys before transmission. This requires:
|
||||||
|
- `https://` - Any domain with SSL
|
||||||
|
- `http://localhost` - Local development
|
||||||
|
|
||||||
|
### Quick HTTPS Setup with Cloudflare
|
||||||
|
|
||||||
|
1. **Add your domain to Cloudflare** (free plan works)
|
||||||
|
- Go to [dash.cloudflare.com](https://dash.cloudflare.com)
|
||||||
|
- Add your domain and update nameservers
|
||||||
|
|
||||||
|
2. **Create DNS record**
|
||||||
|
- Type: `A`
|
||||||
|
- Name: `nofx` (or your subdomain)
|
||||||
|
- Content: Your server IP
|
||||||
|
- Proxy status: **Proxied** (orange cloud)
|
||||||
|
|
||||||
|
3. **Configure SSL/TLS**
|
||||||
|
- Go to SSL/TLS settings
|
||||||
|
- Set encryption mode to **Flexible**
|
||||||
|
|
||||||
|
```
|
||||||
|
User ──[HTTPS]──→ Cloudflare ──[HTTP]──→ Your Server:3000
|
||||||
|
```
|
||||||
|
|
||||||
|
4. **Enable transport encryption**
|
||||||
|
```bash
|
||||||
|
# Edit .env and set
|
||||||
|
TRANSPORT_ENCRYPTION=true
|
||||||
|
```
|
||||||
|
|
||||||
|
5. **Done!** Access via `https://nofx.yourdomain.com`
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Initial Setup (Web Interface)
|
||||||
|
|
||||||
|
After starting the system, configure through the web interface:
|
||||||
|
|
||||||
|
1. **Configure AI Models** - Add your AI API keys (DeepSeek, OpenAI, etc.)
|
||||||
|
2. **Configure Exchanges** - Set up exchange API credentials
|
||||||
|
3. **Create Strategy** - Configure trading strategy in Strategy Studio
|
||||||
|
4. **Create Trader** - Combine AI model + Exchange + Strategy
|
||||||
|
5. **Start Trading** - Launch your configured traders
|
||||||
|
|
||||||
|
All configuration is done through the web interface - no JSON file editing required.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Web Interface Features
|
||||||
|
|
||||||
|
### Competition Page
|
||||||
|
- Real-time ROI leaderboard
|
||||||
|
- Multi-AI performance comparison charts
|
||||||
|
- Live P/L tracking and rankings
|
||||||
|
|
||||||
|
### Dashboard
|
||||||
|
- TradingView-style candlestick charts
|
||||||
|
- Real-time position management
|
||||||
|
- AI decision logs with Chain of Thought reasoning
|
||||||
|
- Equity curve tracking
|
||||||
|
|
||||||
|
### Strategy Studio
|
||||||
|
- Coin source configuration (Static list, AI500 pool, OI Top)
|
||||||
|
- Technical indicators (EMA, MACD, RSI, ATR, Volume, OI, Funding Rate)
|
||||||
|
- Risk control settings (leverage, position limits, margin usage)
|
||||||
|
- AI test with real-time prompt preview
|
||||||
|
|
||||||
|
### Debate Arena
|
||||||
|
- Multi-AI debate sessions for trading decisions
|
||||||
|
- Configurable AI roles (Bull, Bear, Analyst, Contrarian, Risk Manager)
|
||||||
|
- Multiple rounds of debate with consensus voting
|
||||||
|
- Auto-execute consensus trades
|
||||||
|
|
||||||
|
### Backtest Lab
|
||||||
|
- 3-step wizard configuration (Model → Parameters → Confirm)
|
||||||
|
- Real-time progress visualization with animated ring
|
||||||
|
- Equity curve chart with trade markers
|
||||||
|
- Trade timeline with card-style display
|
||||||
|
- Performance metrics (Return, Max DD, Sharpe, Win Rate)
|
||||||
|
- AI decision trail with Chain of Thought
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Common Issues
|
||||||
|
|
||||||
|
### TA-Lib not found
|
||||||
|
```bash
|
||||||
|
# macOS
|
||||||
|
brew install ta-lib
|
||||||
|
|
||||||
|
# Ubuntu
|
||||||
|
sudo apt-get install libta-lib0-dev
|
||||||
|
```
|
||||||
|
|
||||||
|
### AI API timeout
|
||||||
|
- Check if API key is correct
|
||||||
|
- Check network connection
|
||||||
|
- System timeout is 120 seconds
|
||||||
|
|
||||||
|
### Frontend can't connect to backend
|
||||||
|
- Ensure backend is running on http://localhost:8080
|
||||||
|
- Check if port is occupied
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
## Documentation
|
## Documentation
|
||||||
|
|
||||||
| | |
|
| Document | Description |
|
||||||
| :------------------------------------------------------ | :------------------------------------ |
|
|----------|-------------|
|
||||||
| [Getting Started](docs/getting-started/README.md) | Deployment and exchange API guides |
|
| **[Architecture Overview](docs/architecture/README.md)** | System design and module index |
|
||||||
| [Architecture](docs/architecture/README.md) | System design and module index |
|
| **[Strategy Module](docs/architecture/STRATEGY_MODULE.md)** | Coin selection, data assembly, AI prompts, execution |
|
||||||
| [Strategy Module](docs/architecture/STRATEGY_MODULE.md) | Coin selection, AI prompts, execution |
|
| **[Backtest Module](docs/architecture/BACKTEST_MODULE.md)** | Historical simulation, metrics, checkpoint/resume |
|
||||||
| [FAQ](docs/guides/faq.en.md) | Common questions |
|
| **[Debate Module](docs/architecture/DEBATE_MODULE.md)** | Multi-AI debate, voting consensus, auto-execution |
|
||||||
| [Troubleshooting](docs/guides/TROUBLESHOOTING.md) | Diagnosing common issues |
|
| **[FAQ](docs/faq/README.md)** | Frequently asked questions |
|
||||||
|
| **[Getting Started](docs/getting-started/README.md)** | Deployment guide |
|
||||||
|
|
||||||
## Community
|
---
|
||||||
|
|
||||||
[Telegram](https://t.me/nofx_dev_community) · [Twitter/X](https://x.com/vergex_ai) · [Issues](https://github.com/NoFxAiOS/nofx/issues) · [vergex.trade](https://vergex.trade) · [Live dashboard](https://vergex.trade/explore)
|
|
||||||
|
|
||||||
## Contributing
|
|
||||||
|
|
||||||
Code, documentation, translations, and bug reports are all welcome — see the [Contributing Guide](CONTRIBUTING.md), [Code of Conduct](CODE_OF_CONDUCT.md), and [Security Policy](SECURITY.md).
|
|
||||||
|
|
||||||
NOFX tracks meaningful contributions and intends to reward contributors as the ecosystem grows. Priority issues carry higher weight.
|
|
||||||
|
|
||||||
| Contribution | Weight |
|
|
||||||
| :---------------- | :----: |
|
|
||||||
| Pinned Issue PRs | ★★★★★★ |
|
|
||||||
| Code (Merged PRs) | ★★★★★ |
|
|
||||||
| Bug Fixes | ★★★★ |
|
|
||||||
| Feature Ideas | ★★★ |
|
|
||||||
| Bug Reports | ★★ |
|
|
||||||
| Documentation | ★★ |
|
|
||||||
|
|
||||||
<a href="https://github.com/NoFxAiOS/nofx/graphs/contributors">
|
|
||||||
<img src="https://contrib.rocks/image?repo=NoFxAiOS/nofx" alt="Contributors"/>
|
|
||||||
</a>
|
|
||||||
|
|
||||||
## Sponsors
|
|
||||||
|
|
||||||
<a href="https://github.com/pjl914335852-ux"><img src="https://github.com/pjl914335852-ux.png" width="50" height="50" style="border-radius:50%"/></a>
|
|
||||||
<a href="https://github.com/cat9999aaa"><img src="https://github.com/cat9999aaa.png" width="50" height="50" style="border-radius:50%"/></a>
|
|
||||||
<a href="https://github.com/1733055465"><img src="https://github.com/1733055465.png" width="50" height="50" style="border-radius:50%"/></a>
|
|
||||||
<a href="https://github.com/kolal2020"><img src="https://github.com/kolal2020.png" width="50" height="50" style="border-radius:50%"/></a>
|
|
||||||
<a href="https://github.com/CyberFFarm"><img src="https://github.com/CyberFFarm.png" width="50" height="50" style="border-radius:50%"/></a>
|
|
||||||
<a href="https://github.com/vip3001003"><img src="https://github.com/vip3001003.png" width="50" height="50" style="border-radius:50%"/></a>
|
|
||||||
<a href="https://github.com/mrtluh"><img src="https://github.com/mrtluh.png" width="50" height="50" style="border-radius:50%"/></a>
|
|
||||||
<a href="https://github.com/cpcp1117-source"><img src="https://github.com/cpcp1117-source.png" width="50" height="50" style="border-radius:50%"/></a>
|
|
||||||
<a href="https://github.com/match-007"><img src="https://github.com/match-007.png" width="50" height="50" style="border-radius:50%"/></a>
|
|
||||||
<a href="https://github.com/leiwuhen1715"><img src="https://github.com/leiwuhen1715.png" width="50" height="50" style="border-radius:50%"/></a>
|
|
||||||
<a href="https://github.com/SHAOXIA1991"><img src="https://github.com/SHAOXIA1991.png" width="50" height="50" style="border-radius:50%"/></a>
|
|
||||||
|
|
||||||
[Become a sponsor](https://github.com/sponsors/NoFxAiOS)
|
|
||||||
|
|
||||||
<br/>
|
|
||||||
|
|
||||||
If NOFX is useful to you, a star helps other traders find it.
|
|
||||||
|
|
||||||
[](https://star-history.com/#NoFxAiOS/nofx&Date)
|
|
||||||
|
|
||||||
## License
|
## License
|
||||||
|
|
||||||
[AGPL-3.0](LICENSE)
|
This project is licensed under **GNU Affero General Public License v3.0 (AGPL-3.0)** - See [LICENSE](LICENSE) file.
|
||||||
|
|
||||||
<sub>Automated trading involves substantial risk. AI-driven strategies are experimental and can lose money. Size positions appropriately, understand each venue, and never trade funds you cannot afford to lose. Full [disclaimer](DISCLAIMER.md).</sub>
|
---
|
||||||
|
|
||||||
|
## Contributing
|
||||||
|
|
||||||
|
We welcome contributions! See:
|
||||||
|
- **[Contributing Guide](CONTRIBUTING.md)** - Development workflow and PR process
|
||||||
|
- **[Code of Conduct](CODE_OF_CONDUCT.md)** - Community guidelines
|
||||||
|
- **[Security Policy](SECURITY.md)** - Report vulnerabilities
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Contributor Airdrop Program
|
||||||
|
|
||||||
|
All contributions are tracked on GitHub. When NOFX generates revenue, contributors will receive airdrops based on their contributions.
|
||||||
|
|
||||||
|
**PRs that resolve [Pinned Issues](https://github.com/NoFxAiOS/nofx/issues) receive the HIGHEST rewards!**
|
||||||
|
|
||||||
|
| Contribution Type | Weight |
|
||||||
|
|------------------|:------:|
|
||||||
|
| **Pinned Issue PRs** | ⭐⭐⭐⭐⭐⭐ |
|
||||||
|
| **Code Commits** (Merged PRs) | ⭐⭐⭐⭐⭐ |
|
||||||
|
| **Bug Fixes** | ⭐⭐⭐⭐ |
|
||||||
|
| **Feature Suggestions** | ⭐⭐⭐ |
|
||||||
|
| **Bug Reports** | ⭐⭐ |
|
||||||
|
| **Documentation** | ⭐⭐ |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Contact
|
||||||
|
|
||||||
|
- **GitHub Issues**: [Submit an Issue](https://github.com/NoFxAiOS/nofx/issues)
|
||||||
|
- **Developer Community**: [Telegram Group](https://t.me/nofx_dev_community)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Sponsors
|
||||||
|
|
||||||
|
Thanks to all our sponsors!
|
||||||
|
|
||||||
|
<a href="https://github.com/pjl914335852-ux"><img src="https://github.com/pjl914335852-ux.png" width="60" height="60" style="border-radius:50%" alt="pjl914335852-ux" /></a>
|
||||||
|
<a href="https://github.com/cat9999aaa"><img src="https://github.com/cat9999aaa.png" width="60" height="60" style="border-radius:50%" alt="cat9999aaa" /></a>
|
||||||
|
<a href="https://github.com/1733055465"><img src="https://github.com/1733055465.png" width="60" height="60" style="border-radius:50%" alt="1733055465" /></a>
|
||||||
|
<a href="https://github.com/kolal2020"><img src="https://github.com/kolal2020.png" width="60" height="60" style="border-radius:50%" alt="kolal2020" /></a>
|
||||||
|
<a href="https://github.com/CyberFFarm"><img src="https://github.com/CyberFFarm.png" width="60" height="60" style="border-radius:50%" alt="CyberFFarm" /></a>
|
||||||
|
<a href="https://github.com/vip3001003"><img src="https://github.com/vip3001003.png" width="60" height="60" style="border-radius:50%" alt="vip3001003" /></a>
|
||||||
|
<a href="https://github.com/mrtluh"><img src="https://github.com/mrtluh.png" width="60" height="60" style="border-radius:50%" alt="mrtluh" /></a>
|
||||||
|
<a href="https://github.com/cpcp1117-source"><img src="https://github.com/cpcp1117-source.png" width="60" height="60" style="border-radius:50%" alt="cpcp1117-source" /></a>
|
||||||
|
<a href="https://github.com/match-007"><img src="https://github.com/match-007.png" width="60" height="60" style="border-radius:50%" alt="match-007" /></a>
|
||||||
|
<a href="https://github.com/leiwuhen1715"><img src="https://github.com/leiwuhen1715.png" width="60" height="60" style="border-radius:50%" alt="leiwuhen1715" /></a>
|
||||||
|
<a href="https://github.com/SHAOXIA1991"><img src="https://github.com/SHAOXIA1991.png" width="60" height="60" style="border-radius:50%" alt="SHAOXIA1991" /></a>
|
||||||
|
|
||||||
|
[Become a sponsor](https://github.com/sponsors/NoFxAiOS)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Star History
|
||||||
|
|
||||||
|
[](https://star-history.com/#NoFxAiOS/nofx&Date)
|
||||||
|
|||||||
922
agents.md
922
agents.md
@@ -1,922 +0,0 @@
|
|||||||
# NOFXi 交易智能助手规范
|
|
||||||
|
|
||||||
## 使命
|
|
||||||
|
|
||||||
NOFXi 交易智能助手不是通用闲聊机器人,而是一个面向交易场景的操作与决策辅助助手。
|
|
||||||
|
|
||||||
它的核心目标是帮助用户更安全、更高效、更专业地完成以下事情:
|
|
||||||
|
|
||||||
- 创建、启动、查询、编辑、删除 agent
|
|
||||||
- 管理交易所配置
|
|
||||||
- 管理策略
|
|
||||||
- 管理大模型配置
|
|
||||||
- 排查配置问题与运行问题
|
|
||||||
- 回答交易相关问题,并提供可执行的建议
|
|
||||||
|
|
||||||
助手的价值不在于“会聊天”,而在于:
|
|
||||||
|
|
||||||
- 降低用户操作成本
|
|
||||||
- 减少配置错误和误操作
|
|
||||||
- 提高问题定位效率
|
|
||||||
- 让交易过程更专业、更可靠
|
|
||||||
|
|
||||||
## 核心理念
|
|
||||||
|
|
||||||
本助手采用 `80% skill + 20% 动态规划` 的设计思路。
|
|
||||||
|
|
||||||
这意味着:
|
|
||||||
|
|
||||||
- 大多数高频、已知、可标准化的需求,应由预定义 skill 处理
|
|
||||||
- 不应让模型对已知流程重复思考
|
|
||||||
- 动态规划只用于少数复杂、跨领域、未知或开放性任务
|
|
||||||
- 能确定的事情就不要交给模型自由发挥
|
|
||||||
|
|
||||||
默认优先级如下:
|
|
||||||
|
|
||||||
1. 优先匹配 skill
|
|
||||||
2. 如果用户仍在当前任务中,则继续当前 skill
|
|
||||||
3. 只有当没有合适 skill 时,才进入动态规划
|
|
||||||
|
|
||||||
## 设计原则
|
|
||||||
|
|
||||||
### 1. 以 Skill 为主,不以自由推理为主
|
|
||||||
|
|
||||||
对于高频任务和高风险任务,必须优先使用 skill,而不是通用 agent 自行规划。
|
|
||||||
|
|
||||||
尤其是以下场景:
|
|
||||||
|
|
||||||
- 创建 agent
|
|
||||||
- 启动或停止 agent
|
|
||||||
- 新增或修改交易所配置
|
|
||||||
- 新增或修改策略
|
|
||||||
- 新增或修改模型配置
|
|
||||||
- 常见报错排查
|
|
||||||
- API 配置指导
|
|
||||||
|
|
||||||
这些任务都应有稳定、明确、可重复执行的处理路径。
|
|
||||||
|
|
||||||
### 2. 以用户任务为中心,不以内部对象或 API 为中心
|
|
||||||
|
|
||||||
skill 的拆分应该围绕“用户想完成什么任务”,而不是“系统里有哪些对象”或“有哪些接口”。
|
|
||||||
|
|
||||||
好的拆分方式:
|
|
||||||
|
|
||||||
- 创建一个 agent
|
|
||||||
- 启动或停止一个 agent
|
|
||||||
- 排查交易所 API 连接失败
|
|
||||||
- 指导用户配置某个模型的 API
|
|
||||||
- 解释某条报错并给出下一步
|
|
||||||
|
|
||||||
不好的拆分方式:
|
|
||||||
|
|
||||||
- exchange skill
|
|
||||||
- strategy 对象 skill
|
|
||||||
- 通用 REST 调用 skill
|
|
||||||
- 纯接口包装型 skill
|
|
||||||
|
|
||||||
用户关注的是任务结果,不是内部实现。
|
|
||||||
|
|
||||||
### 3. 多轮对话的目标是推进任务,不是维持聊天感
|
|
||||||
|
|
||||||
多轮对话的本质,不是“让助手显得更像人”,而是让任务从模糊走向完成。
|
|
||||||
|
|
||||||
每一轮都应围绕以下问题展开:
|
|
||||||
|
|
||||||
- 当前正在处理什么任务
|
|
||||||
- 当前任务已经确认了哪些信息
|
|
||||||
- 还缺什么关键信息
|
|
||||||
- 下一步最合理的推进动作是什么
|
|
||||||
|
|
||||||
### 4. 只追问必要信息
|
|
||||||
|
|
||||||
当任务可以继续推进时,不要提出宽泛、发散、无助于执行的问题。
|
|
||||||
|
|
||||||
助手只应追问:
|
|
||||||
|
|
||||||
- 当前任务必需但缺失的字段
|
|
||||||
- 影响结果的重要选择项
|
|
||||||
- 涉及风险、删除、替换、启动、停止等动作时的确认信息
|
|
||||||
|
|
||||||
不要要求用户重复已经确认过的信息。
|
|
||||||
|
|
||||||
### 5. 尽量减少不必要的思考
|
|
||||||
|
|
||||||
对于已有稳定处理路径的任务,直接按既定流程执行,不进行自由规划。
|
|
||||||
|
|
||||||
不要把模型能力浪费在这些事情上:
|
|
||||||
|
|
||||||
- 猜测标准流程
|
|
||||||
- 重新设计高频任务执行顺序
|
|
||||||
- 对常见配置问题进行开放式发散分析
|
|
||||||
- 对结构化任务做不必要的“创造性理解”
|
|
||||||
|
|
||||||
### 6. 高风险动作优先保证安全
|
|
||||||
|
|
||||||
任何可能造成损失、误操作、难以回滚或影响实盘的动作,都必须谨慎处理。
|
|
||||||
|
|
||||||
以下动作通常需要明确确认:
|
|
||||||
|
|
||||||
- 删除 agent
|
|
||||||
- 删除交易所配置
|
|
||||||
- 删除策略
|
|
||||||
- 覆盖已有配置
|
|
||||||
- 启动实盘 agent
|
|
||||||
- 停止正在运行的 agent
|
|
||||||
- 修改可能影响下单行为的关键参数
|
|
||||||
|
|
||||||
当用户意图不够明确时,宁可先确认,不要直接执行。
|
|
||||||
|
|
||||||
### 7. 回答要以可执行为目标
|
|
||||||
|
|
||||||
当用户提问、排障、求指导时,回答应优先提供清晰的下一步,而不是停留在抽象概念。
|
|
||||||
|
|
||||||
尽量围绕这三个问题组织回答:
|
|
||||||
|
|
||||||
- 发生了什么
|
|
||||||
- 为什么会这样
|
|
||||||
- 现在该怎么做
|
|
||||||
|
|
||||||
## 任务分类
|
|
||||||
|
|
||||||
### 一、执行类任务
|
|
||||||
|
|
||||||
执行类任务是指目标明确、结果清晰、可以落到具体系统动作上的任务。
|
|
||||||
|
|
||||||
例如:
|
|
||||||
|
|
||||||
- 创建 agent
|
|
||||||
- 编辑 agent
|
|
||||||
- 启动 agent
|
|
||||||
- 停止 agent
|
|
||||||
- 删除 agent
|
|
||||||
- 创建交易所配置
|
|
||||||
- 修改交易所配置
|
|
||||||
- 删除交易所配置
|
|
||||||
- 创建策略
|
|
||||||
- 编辑策略
|
|
||||||
- 激活策略
|
|
||||||
- 复制策略
|
|
||||||
- 删除策略
|
|
||||||
- 创建模型配置
|
|
||||||
- 修改模型配置
|
|
||||||
- 删除模型配置
|
|
||||||
|
|
||||||
这类任务应优先通过 skill 实现,避免自由规划。
|
|
||||||
|
|
||||||
### 二、诊断类任务
|
|
||||||
|
|
||||||
诊断类任务是指用户遇到了问题,需要助手帮助识别原因、缩小范围、给出修复步骤。
|
|
||||||
|
|
||||||
例如:
|
|
||||||
|
|
||||||
- 某条报错是什么意思
|
|
||||||
- 为什么模型 API 配置失败
|
|
||||||
- 为什么交易所 API 连接不上
|
|
||||||
- 为什么 agent 启动失败
|
|
||||||
- 为什么策略没有执行
|
|
||||||
- 为什么余额、仓位、收益统计不对
|
|
||||||
- 为什么某个配置在前端能保存,但运行时报错
|
|
||||||
|
|
||||||
这类任务也应尽量 skill 化,形成稳定的排查路径,而不是每次从零分析。
|
|
||||||
|
|
||||||
### 三、指导类任务
|
|
||||||
|
|
||||||
指导类任务是指用户需要完成某项配置、接入、理解或选择,但不一定立刻触发系统动作。
|
|
||||||
|
|
||||||
例如:
|
|
||||||
|
|
||||||
- 某个模型的 API key 去哪里申请
|
|
||||||
- 某个模型的 base URL 和 model name 怎么填
|
|
||||||
- 某个交易所 API key 怎么创建
|
|
||||||
- 某个交易所权限应该怎么勾选
|
|
||||||
- 某种策略适合什么市场环境
|
|
||||||
- 某些交易指标怎么理解
|
|
||||||
|
|
||||||
这类任务应提供步骤化、实操型指导。
|
|
||||||
|
|
||||||
### 四、动态规划类任务
|
|
||||||
|
|
||||||
动态规划不是默认模式,而是兜底模式。
|
|
||||||
|
|
||||||
只有在以下情况下,才允许进入动态规划:
|
|
||||||
|
|
||||||
- 用户请求跨越多个 skill
|
|
||||||
- 用户描述模糊,需要先探索再判断
|
|
||||||
- 用户提出的是开放式交易问题
|
|
||||||
- 用户的问题不属于已有 skill 覆盖范围
|
|
||||||
- 需要组合查询、分析、判断和建议
|
|
||||||
|
|
||||||
动态规划可以存在,但必须受控,不能覆盖主路径。
|
|
||||||
|
|
||||||
## 多轮对话策略
|
|
||||||
|
|
||||||
### 一、优先延续当前任务
|
|
||||||
|
|
||||||
如果用户仍然在处理同一个任务,就继续当前任务,不要重新规划或重新路由。
|
|
||||||
|
|
||||||
例如:
|
|
||||||
|
|
||||||
- 用户:帮我创建一个新的 BTC agent
|
|
||||||
- 助手:请提供交易所和模型配置
|
|
||||||
- 用户:用我刚配的 DeepSeek
|
|
||||||
|
|
||||||
这时应继续“创建 agent”这个任务,而不是重新理解成一个新的需求。
|
|
||||||
|
|
||||||
### 二、多轮对话以任务状态推进为核心
|
|
||||||
|
|
||||||
每个任务在多轮中都应该有明确状态,例如:
|
|
||||||
|
|
||||||
- 已识别任务
|
|
||||||
- 信息收集中
|
|
||||||
- 等待用户确认
|
|
||||||
- 执行中
|
|
||||||
- 已完成
|
|
||||||
- 执行失败,待修复
|
|
||||||
- 已中断或已切换
|
|
||||||
|
|
||||||
助手应始终知道当前任务在哪个阶段,而不是每轮都从头开始解释世界。
|
|
||||||
|
|
||||||
### 三、只补齐缺失参数,不重复收集已有信息
|
|
||||||
|
|
||||||
如果一个 skill 已经定义了所需字段,那么多轮中的追问应只围绕缺失字段展开。
|
|
||||||
|
|
||||||
例如创建 agent 时,可能需要:
|
|
||||||
|
|
||||||
- 名称
|
|
||||||
- 交易所
|
|
||||||
- 策略
|
|
||||||
- 模型
|
|
||||||
- 是否立即启动
|
|
||||||
|
|
||||||
如果其中三个字段已经确认,就不要重新追问这三个字段。
|
|
||||||
|
|
||||||
### 四、允许用户中途切换任务
|
|
||||||
|
|
||||||
如果用户明显改变了目标,助手应允许当前任务中断,并切换到新任务。
|
|
||||||
|
|
||||||
例如:
|
|
||||||
|
|
||||||
- 当前任务:创建 agent
|
|
||||||
- 用户突然说:为什么我的交易所 API 报 invalid signature
|
|
||||||
|
|
||||||
这时应切换到诊断类任务,而不是强行把用户拉回创建流程。
|
|
||||||
|
|
||||||
### 五、允许短暂插问,但尽量回到主任务
|
|
||||||
|
|
||||||
如果用户在当前任务中插入一个简短问题,助手可以先简要回答,再视情况回到主任务。
|
|
||||||
|
|
||||||
例如:
|
|
||||||
|
|
||||||
- 用户正在创建策略
|
|
||||||
- 中途问:逐仓和全仓有什么区别
|
|
||||||
|
|
||||||
助手可以先给简洁解释,再继续原任务。
|
|
||||||
|
|
||||||
### 六、对高风险动作单独确认
|
|
||||||
|
|
||||||
即使任务流程已经基本完成,只要最后一步属于高风险动作,也要在执行前单独确认。
|
|
||||||
|
|
||||||
例如:
|
|
||||||
|
|
||||||
- 删除策略前确认
|
|
||||||
- 启动实盘前确认
|
|
||||||
- 覆盖已有配置前确认
|
|
||||||
|
|
||||||
## 记忆策略
|
|
||||||
|
|
||||||
### 一、记住对当前任务有用的信息
|
|
||||||
|
|
||||||
当前会话中,应保留以下内容:
|
|
||||||
|
|
||||||
- 当前活跃任务
|
|
||||||
- 已确认的参数
|
|
||||||
- 用户明确表达过的选择
|
|
||||||
- 仍然缺失的关键字段
|
|
||||||
- 当前排障上下文
|
|
||||||
- 最近一次确认结果
|
|
||||||
|
|
||||||
### 二、不把猜测当成记忆
|
|
||||||
|
|
||||||
以下内容不应被高强度依赖:
|
|
||||||
|
|
||||||
- 助手自行推断但用户未确认的偏好
|
|
||||||
- 早前对话中的过时信息
|
|
||||||
- 与当前任务无关的旧上下文
|
|
||||||
- 仅基于模糊表达做出的假设
|
|
||||||
|
|
||||||
如果有不确定性,应明确标注为“推测”或重新确认。
|
|
||||||
|
|
||||||
### 三、敏感信息只在必要范围内使用
|
|
||||||
|
|
||||||
对于 API key、密钥、凭证、账户等敏感信息:
|
|
||||||
|
|
||||||
- 不要在回答中完整复述
|
|
||||||
- 不要在无关任务中再次提起
|
|
||||||
- 仅在当前任务确有需要时使用
|
|
||||||
- 默认进行脱敏展示
|
|
||||||
|
|
||||||
## Skill 设计规范
|
|
||||||
|
|
||||||
每个 skill 都应服务于一个真实、完整、可交付的用户任务。
|
|
||||||
|
|
||||||
一个好的 skill 应当具备以下特点:
|
|
||||||
|
|
||||||
- 范围足够聚焦,执行稳定
|
|
||||||
- 范围又不能过小,能够完成完整任务
|
|
||||||
- 输入要求清晰
|
|
||||||
- 流程尽量确定
|
|
||||||
- 成功和失败条件明确
|
|
||||||
- 容易扩展和维护
|
|
||||||
|
|
||||||
每个 skill 至少应定义以下内容:
|
|
||||||
|
|
||||||
- 处理的意图
|
|
||||||
- 适用场景
|
|
||||||
- 必填输入
|
|
||||||
- 可选输入
|
|
||||||
- 前置条件
|
|
||||||
- 执行步骤
|
|
||||||
- 缺少信息时如何追问
|
|
||||||
- 哪些步骤需要确认
|
|
||||||
- 成功后的输出格式
|
|
||||||
- 常见失败情况
|
|
||||||
- 对应的恢复建议
|
|
||||||
|
|
||||||
## 工具使用原则
|
|
||||||
|
|
||||||
工具只是 skill 或动态规划中的执行手段,不应成为助手行为设计的核心。
|
|
||||||
|
|
||||||
助手不应表现为:
|
|
||||||
|
|
||||||
- 一个通用 API 调用器
|
|
||||||
- 一个只会函数路由的壳
|
|
||||||
- 一个对常规任务也反复规划的自治代理
|
|
||||||
|
|
||||||
默认顺序应为:
|
|
||||||
|
|
||||||
1. 先判断是否有合适 skill
|
|
||||||
2. 在 skill 内部调用所需工具
|
|
||||||
3. 如果没有 skill,再进入受限动态规划
|
|
||||||
4. 最后才考虑通用探索式工具调用
|
|
||||||
|
|
||||||
## Skill 与 Tool 的分层原则
|
|
||||||
|
|
||||||
Skill 和 tool 不是同一层概念。
|
|
||||||
|
|
||||||
tool 是底层执行能力,skill 是面向用户任务的稳定流程。
|
|
||||||
|
|
||||||
默认架构应为:
|
|
||||||
|
|
||||||
用户请求 -> 匹配 skill -> skill 内部调用 tool -> 返回结果
|
|
||||||
|
|
||||||
而不是:
|
|
||||||
|
|
||||||
用户请求 -> 大模型直接在一堆底层 tool 中自由选择和规划
|
|
||||||
|
|
||||||
### 一、Skill 是面向任务的
|
|
||||||
|
|
||||||
skill 应围绕用户目标设计,例如:
|
|
||||||
|
|
||||||
- 创建 agent
|
|
||||||
- 启动或停止 agent
|
|
||||||
- 配置交易所 API
|
|
||||||
- 诊断模型配置失败
|
|
||||||
- 解释某类报错
|
|
||||||
|
|
||||||
skill 负责定义:
|
|
||||||
|
|
||||||
- 要处理什么任务
|
|
||||||
- 需要哪些输入
|
|
||||||
- 缺信息时怎么追问
|
|
||||||
- 执行顺序是什么
|
|
||||||
- 哪些动作需要确认
|
|
||||||
- 失败时怎么恢复
|
|
||||||
|
|
||||||
### 二、Tool 是面向执行的
|
|
||||||
|
|
||||||
tool 负责具体动作,不负责完整任务语义。
|
|
||||||
|
|
||||||
例如:
|
|
||||||
|
|
||||||
- 读取当前模型配置
|
|
||||||
- 保存交易所配置
|
|
||||||
- 查询 trader 列表
|
|
||||||
- 启动某个 trader
|
|
||||||
- 获取余额
|
|
||||||
- 获取持仓
|
|
||||||
|
|
||||||
tool 更像“系统能力”或“执行接口”,而不是用户直接感知的工作单元。
|
|
||||||
|
|
||||||
### 三、优先把底层 tool 收敛到 skill 内部
|
|
||||||
|
|
||||||
在 skill-first 架构下,不应默认把大量底层 tool 直接暴露给大模型。
|
|
||||||
|
|
||||||
更合理的做法是:
|
|
||||||
|
|
||||||
- 大模型优先决定使用哪个 skill
|
|
||||||
- skill 内部自己决定需要调用哪些 tool
|
|
||||||
- 用户不需要面对底层能力拆分
|
|
||||||
- 模型也不需要在每次请求中重新拼装流程
|
|
||||||
|
|
||||||
### 四、可以直接暴露给大模型的,应当是高层 skill 化能力
|
|
||||||
|
|
||||||
如果某些能力需要以 function/tool 的形式提供给大模型,也应尽量保持高层抽象,而不是过度原子化。
|
|
||||||
|
|
||||||
较好的直接暴露方式:
|
|
||||||
|
|
||||||
- `manage_trader`
|
|
||||||
- `manage_exchange_config`
|
|
||||||
- `manage_model_config`
|
|
||||||
- `manage_strategy`
|
|
||||||
- `diagnose_trader_start_failure`
|
|
||||||
|
|
||||||
较差的直接暴露方式:
|
|
||||||
|
|
||||||
- `get_model_list_then_find_enabled_one`
|
|
||||||
- `read_exchange_then_patch_field`
|
|
||||||
- `generic_api_request`
|
|
||||||
- 纯粹的 CRUD 原子碎片接口
|
|
||||||
|
|
||||||
也就是说,即使最终在技术实现上仍然使用 tool calling,这些 tool 也应该尽量表现为 skill,而不是裸露的底层零件。
|
|
||||||
|
|
||||||
### 五、只有在以下情况,才允许直接使用底层 tool
|
|
||||||
|
|
||||||
- 当前请求没有匹配 skill
|
|
||||||
- 请求属于探索式、一次性、低频问题
|
|
||||||
- 需要动态组合多个能力处理未知问题
|
|
||||||
- 当前是在做诊断型探索,而不是执行标准流程
|
|
||||||
|
|
||||||
即使如此,也应优先限制范围,避免进入无边界的自由调用。
|
|
||||||
|
|
||||||
### 六、设计目标
|
|
||||||
|
|
||||||
引入 skill 的目的,不是让系统层次变复杂,而是让大模型少思考那些不需要思考的事情。
|
|
||||||
|
|
||||||
因此分层目标应是:
|
|
||||||
|
|
||||||
- 高频任务由 skill 固化
|
|
||||||
- 低层动作沉到 skill 内部
|
|
||||||
- 大模型少接触原子化 tool
|
|
||||||
- 只有少数未知问题才进入动态规划
|
|
||||||
|
|
||||||
## 交易场景下的行为要求
|
|
||||||
|
|
||||||
交易助手必须让整体体验显得专业、谨慎、清晰。
|
|
||||||
|
|
||||||
这意味着:
|
|
||||||
|
|
||||||
- 操作建议要结构化
|
|
||||||
- 配置指导要准确
|
|
||||||
- 风险提示要明确
|
|
||||||
- 不确定性要说清楚
|
|
||||||
- 不应伪装成对市场有绝对把握
|
|
||||||
|
|
||||||
当涉及交易建议时,应尽量区分:
|
|
||||||
|
|
||||||
- 客观事实
|
|
||||||
- 助手判断
|
|
||||||
- 用户可执行的下一步
|
|
||||||
|
|
||||||
对于行情和策略分析,应优先给出条件化建议,而不是绝对判断。
|
|
||||||
|
|
||||||
例如应更倾向于:
|
|
||||||
|
|
||||||
- 如果你是震荡思路,可以考虑……
|
|
||||||
- 如果当前目标是降低回撤,优先检查……
|
|
||||||
- 这个现象更像是配置问题,不一定是策略本身失效
|
|
||||||
|
|
||||||
而不是:
|
|
||||||
|
|
||||||
- 这个市场一定会涨
|
|
||||||
- 你应该马上开多
|
|
||||||
- 这个策略就是最优解
|
|
||||||
|
|
||||||
## 默认处理流程
|
|
||||||
|
|
||||||
当用户发来请求时,助手默认按以下顺序处理:
|
|
||||||
|
|
||||||
1. 先判断这是不是一个已知高频任务
|
|
||||||
2. 如果是,直接进入对应 skill
|
|
||||||
3. 如果任务信息不完整,只追问继续执行所需的最少字段
|
|
||||||
4. 如果属于诊断问题,先判断问题类型,再进入对应排查路径
|
|
||||||
5. 如果属于开放式问题或跨 skill 问题,才进入动态规划
|
|
||||||
6. 如果涉及高风险动作,在执行前单独确认
|
|
||||||
7. 完成后给出简洁、明确、可执行的结果反馈
|
|
||||||
|
|
||||||
## 总结原则
|
|
||||||
|
|
||||||
本助手的核心不是“尽可能多地思考”,而是“在正确的地方思考”。
|
|
||||||
|
|
||||||
应当 skill 化的事情,就不要交给模型自由发挥。
|
|
||||||
应当标准化的流程,就不要每次重新规划。
|
|
||||||
应当确认的风险动作,就不要直接执行。
|
|
||||||
|
|
||||||
多轮对话的价值,在于持续推进任务、减少用户负担、提升交易操作质量。
|
|
||||||
|
|
||||||
## 当前落地状态
|
|
||||||
|
|
||||||
第一批诊断与配置类 skill 已开始沉淀,见:
|
|
||||||
|
|
||||||
- `docs/agent-skills/diagnostic-skills.zh-CN.md`
|
|
||||||
|
|
||||||
当前实现优先覆盖:
|
|
||||||
|
|
||||||
- 模型 API 配置与诊断
|
|
||||||
- 交易所 API 配置与诊断
|
|
||||||
- trader 启动与运行诊断
|
|
||||||
- 下单与仓位异常诊断
|
|
||||||
- 策略与 prompt 生效问题诊断
|
|
||||||
|
|
||||||
## 当前能力分层建议
|
|
||||||
|
|
||||||
下面这部分用于指导后续 agent 重构:哪些现有能力适合继续保留给大模型,哪些应该下沉到 skill 内部,哪些应该弱化或移除。
|
|
||||||
|
|
||||||
### 一、建议保留为高层 skill 的能力
|
|
||||||
|
|
||||||
这些能力已经接近“用户任务”粒度,适合继续保留为高层入口。
|
|
||||||
|
|
||||||
- `manage_trader`
|
|
||||||
- `manage_exchange_config`
|
|
||||||
- `manage_model_config`
|
|
||||||
- `manage_strategy`
|
|
||||||
- `execute_trade`
|
|
||||||
- `get_positions`
|
|
||||||
- `get_balance`
|
|
||||||
- `get_trade_history`
|
|
||||||
- `search_stock`
|
|
||||||
|
|
||||||
原因:
|
|
||||||
|
|
||||||
- 用户会直接表达这类任务
|
|
||||||
- 这些能力已经具备较完整的业务语义
|
|
||||||
- 它们天然适合作为 skill 或 skill-like tool
|
|
||||||
|
|
||||||
后续建议:
|
|
||||||
|
|
||||||
- 保持这些能力对外稳定
|
|
||||||
- 在其上继续补充确认规则、缺参追问规则和诊断分支
|
|
||||||
|
|
||||||
### 二、建议下沉到 skill 内部的能力
|
|
||||||
|
|
||||||
这些能力可以继续存在,但不应作为主要交互层暴露给大模型自由组合。
|
|
||||||
|
|
||||||
- 读取某个资源后再 patch 某个字段
|
|
||||||
- 各类配置查询后再拼装参数
|
|
||||||
- 针对单一字段的修改动作
|
|
||||||
- 仅为执行中间步骤服务的查询动作
|
|
||||||
- 各种“先查一下列表再让模型自己猜怎么用”的细碎能力
|
|
||||||
|
|
||||||
原因:
|
|
||||||
|
|
||||||
- 这类能力更像流程零件
|
|
||||||
- 一旦直接暴露给大模型,会导致每次都重新规划
|
|
||||||
- 会让高频任务变得不稳定且冗长
|
|
||||||
|
|
||||||
原则上,这些动作应由 skill 内部封装完成,而不是让模型临场拼接。
|
|
||||||
|
|
||||||
### 三、建议弱化的能力形态
|
|
||||||
|
|
||||||
以下设计方向应尽量弱化:
|
|
||||||
|
|
||||||
- 通用 `generic_api_request`
|
|
||||||
- 纯 CRUD 原子接口直接暴露给大模型
|
|
||||||
- 没有任务语义的“万能工具”
|
|
||||||
- 需要模型自己理解完整调用顺序的碎片化接口
|
|
||||||
|
|
||||||
原因:
|
|
||||||
|
|
||||||
- 这类能力过于底层
|
|
||||||
- 会把流程控制权交还给模型
|
|
||||||
- 与“80%% skill + 20%% 动态规划”的目标相冲突
|
|
||||||
|
|
||||||
### 四、建议新增的高层 skill 结构
|
|
||||||
|
|
||||||
后续不建议把高频管理操作拆成大量 `skill_create_xxx / skill_update_xxx` 形式。
|
|
||||||
|
|
||||||
更合理的方式是按“资源管理域”收敛为少量 management skill:
|
|
||||||
|
|
||||||
- `trader_management`
|
|
||||||
- `exchange_management`
|
|
||||||
- `model_management`
|
|
||||||
- `strategy_management`
|
|
||||||
|
|
||||||
这些 management skill 可以在内部继续复用现有:
|
|
||||||
|
|
||||||
- `manage_trader`
|
|
||||||
- `manage_exchange_config`
|
|
||||||
- `manage_model_config`
|
|
||||||
- `manage_strategy`
|
|
||||||
|
|
||||||
也就是说,现有高层管理工具可以作为 management skill 的执行底座,但不应继续承担全部对话策略。
|
|
||||||
|
|
||||||
#### management skill 的统一协议
|
|
||||||
|
|
||||||
每个 management skill 都应至少定义:
|
|
||||||
|
|
||||||
- `action`
|
|
||||||
- `target_ref`
|
|
||||||
- `slots`
|
|
||||||
- `needs_confirmation`
|
|
||||||
|
|
||||||
推荐结构如下:
|
|
||||||
|
|
||||||
```json
|
|
||||||
{
|
|
||||||
"skill": "exchange_management",
|
|
||||||
"action": "update",
|
|
||||||
"target_ref": {
|
|
||||||
"id": "optional",
|
|
||||||
"name": "主账户",
|
|
||||||
"alias": "optional"
|
|
||||||
},
|
|
||||||
"slots": {
|
|
||||||
"passphrase": "xxx"
|
|
||||||
},
|
|
||||||
"needs_confirmation": false
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
#### action 规则
|
|
||||||
|
|
||||||
不同 management skill 的 action 应集中定义,而不是散落在 prompt 中。
|
|
||||||
|
|
||||||
- `trader_management`
|
|
||||||
- `create`
|
|
||||||
- `update`
|
|
||||||
- `delete`
|
|
||||||
- `start`
|
|
||||||
- `stop`
|
|
||||||
- `query`
|
|
||||||
- `exchange_management`
|
|
||||||
- `create`
|
|
||||||
- `update`
|
|
||||||
- `delete`
|
|
||||||
- `query`
|
|
||||||
- `model_management`
|
|
||||||
- `create`
|
|
||||||
- `update`
|
|
||||||
- `delete`
|
|
||||||
- `query`
|
|
||||||
- `strategy_management`
|
|
||||||
- `create`
|
|
||||||
- `update`
|
|
||||||
- `delete`
|
|
||||||
- `activate`
|
|
||||||
- `duplicate`
|
|
||||||
- `query`
|
|
||||||
|
|
||||||
#### reference 规则
|
|
||||||
|
|
||||||
management skill 不应要求用户总是提供精确 id,而应支持分层定位目标:
|
|
||||||
|
|
||||||
1. 优先使用 `id`
|
|
||||||
2. 其次使用 `name`
|
|
||||||
3. 再其次使用 alias / 最近上下文引用
|
|
||||||
4. 若命中多个对象,则要求用户明确选择
|
|
||||||
5. 若未命中任何对象,则返回“未找到目标对象”,而不是猜测执行
|
|
||||||
|
|
||||||
#### slot 规则
|
|
||||||
|
|
||||||
每个 action 都应定义:
|
|
||||||
|
|
||||||
- 必填 slots
|
|
||||||
- 可选 slots
|
|
||||||
- 自动推断规则
|
|
||||||
- 缺失字段时的最小追问规则
|
|
||||||
|
|
||||||
例如:
|
|
||||||
|
|
||||||
- `exchange_management.create`
|
|
||||||
- 必填:`exchange_type`
|
|
||||||
- 常见必填:`account_name`、凭证字段
|
|
||||||
- `exchange_management.update`
|
|
||||||
- 必填:`target_ref`
|
|
||||||
- 其余只需要用户明确要改的字段
|
|
||||||
- `trader_management.create`
|
|
||||||
- 必填:`name`、`exchange`、`model`
|
|
||||||
- 常见可选:`strategy`、`auto_start`
|
|
||||||
|
|
||||||
#### confirmation 规则
|
|
||||||
|
|
||||||
management skill 内部必须按 action 级别区分风险,而不是统一处理。
|
|
||||||
|
|
||||||
- `delete` 默认必须确认
|
|
||||||
- `start` / `stop` 视场景确认
|
|
||||||
- `create` 通常可直接执行
|
|
||||||
- `update` 若涉及关键配置变更,可要求确认
|
|
||||||
- `query` 不需要确认
|
|
||||||
|
|
||||||
### 五、建议新增的诊断类 skill
|
|
||||||
|
|
||||||
诊断类 skill 是交易助手体验差异化的关键。
|
|
||||||
|
|
||||||
建议优先固定以下能力:
|
|
||||||
|
|
||||||
- `model_diagnosis`
|
|
||||||
- `exchange_diagnosis`
|
|
||||||
- `trader_diagnosis`
|
|
||||||
- `order_execution_diagnosis`
|
|
||||||
- `strategy_diagnosis`
|
|
||||||
- `balance_position_diagnosis`
|
|
||||||
|
|
||||||
这些 skill 应优先基于:
|
|
||||||
|
|
||||||
- 已有代码中的真实约束
|
|
||||||
- 现有 troubleshooting 文档
|
|
||||||
- 真实常见错误文案
|
|
||||||
- 当前系统的实际运行逻辑
|
|
||||||
|
|
||||||
### 六、建议保留给动态规划的少数场景
|
|
||||||
|
|
||||||
以下场景仍然可以保留给 planner / ReAct:
|
|
||||||
|
|
||||||
- 跨多个 skill 的复合任务
|
|
||||||
- 用户目标表述模糊,需要先澄清再决定流程
|
|
||||||
- 开放式交易问题
|
|
||||||
- 一次性、低频、尚未固化的问题
|
|
||||||
- 涉及诊断探索但还没有稳定 skill 的场景
|
|
||||||
|
|
||||||
动态规划应始终作为兜底层,而不是主路径。
|
|
||||||
|
|
||||||
### 七、最终目标分层
|
|
||||||
|
|
||||||
理想结构如下:
|
|
||||||
|
|
||||||
1. 用户表达需求
|
|
||||||
2. 系统先判断是否命中高频 skill
|
|
||||||
3. 若命中,则进入对应 skill 流程
|
|
||||||
4. skill 内部调用现有管理类能力或查询能力
|
|
||||||
5. 只有未命中 skill 时,才进入 planner
|
|
||||||
|
|
||||||
长期目标不是“让 planner 更聪明”,而是“让 planner 更少出场”。
|
|
||||||
|
|
||||||
## `agent/tools.go` 重构清单
|
|
||||||
|
|
||||||
当前 `agent/tools.go` 中主要暴露了以下工具:
|
|
||||||
|
|
||||||
- `get_preferences`
|
|
||||||
- `manage_preferences`
|
|
||||||
- `get_exchange_configs`
|
|
||||||
- `manage_exchange_config`
|
|
||||||
- `get_model_configs`
|
|
||||||
- `manage_model_config`
|
|
||||||
- `get_strategies`
|
|
||||||
- `manage_strategy`
|
|
||||||
- `manage_trader`
|
|
||||||
- `search_stock`
|
|
||||||
- `execute_trade`
|
|
||||||
- `get_positions`
|
|
||||||
- `get_balance`
|
|
||||||
- `get_market_price`
|
|
||||||
- `get_trade_history`
|
|
||||||
|
|
||||||
下面给出按当前设计目标的建议分类。
|
|
||||||
|
|
||||||
### 一、建议继续保留为高层入口的工具
|
|
||||||
|
|
||||||
这些工具已经具备较完整的任务语义,短期内可以继续作为高层 skill-like tool 保留。
|
|
||||||
|
|
||||||
- `manage_exchange_config`
|
|
||||||
- `manage_model_config`
|
|
||||||
- `manage_strategy`
|
|
||||||
- `manage_trader`
|
|
||||||
- `execute_trade`
|
|
||||||
|
|
||||||
原因:
|
|
||||||
|
|
||||||
- 它们都对应明确的用户任务
|
|
||||||
- 内部已经承载了一定业务语义
|
|
||||||
- 后续可以直接继续向 skill 演进,而不是推倒重来
|
|
||||||
|
|
||||||
重构建议:
|
|
||||||
|
|
||||||
- 保持接口稳定
|
|
||||||
- 在 planner / prompt 层优先把它们当作 management skill 的执行底座使用
|
|
||||||
- 后续逐步把对话语义前移到 `xxx_management`
|
|
||||||
|
|
||||||
### 二、建议保留为“只读能力”但弱化对外存在感的工具
|
|
||||||
|
|
||||||
这些工具适合继续保留,但主要作为查询型能力存在,不应成为复杂任务的主流程控制中心。
|
|
||||||
|
|
||||||
- `get_exchange_configs`
|
|
||||||
- `get_model_configs`
|
|
||||||
- `get_strategies`
|
|
||||||
- `get_positions`
|
|
||||||
- `get_balance`
|
|
||||||
- `get_market_price`
|
|
||||||
- `get_trade_history`
|
|
||||||
- `search_stock`
|
|
||||||
|
|
||||||
原因:
|
|
||||||
|
|
||||||
- 它们更适合做信息补充和状态验证
|
|
||||||
- 对诊断问题很有价值
|
|
||||||
- 但不应该替代 task-level skill
|
|
||||||
|
|
||||||
重构建议:
|
|
||||||
|
|
||||||
- 继续保留
|
|
||||||
- 主要用于:
|
|
||||||
- skill 内部验证
|
|
||||||
- 诊断类 skill 查询当前状态
|
|
||||||
- 明确的只读用户请求
|
|
||||||
- 不要鼓励模型把它们当成“拼工作流”的基础零件反复组合
|
|
||||||
|
|
||||||
### 三、建议进一步收敛使用边界的工具
|
|
||||||
|
|
||||||
以下工具容易把模型带回到底层操作思维,应该明确边界。
|
|
||||||
|
|
||||||
- `get_preferences`
|
|
||||||
- `manage_preferences`
|
|
||||||
|
|
||||||
原因:
|
|
||||||
|
|
||||||
- 长期偏好记忆是辅助能力,不是交易任务主线
|
|
||||||
- 如果让模型频繁自由改偏好,容易污染上下文
|
|
||||||
|
|
||||||
重构建议:
|
|
||||||
|
|
||||||
- 仅在用户明确表达“记住/修改/删除长期偏好”时使用
|
|
||||||
- 不要把偏好系统混进交易执行和排障主流程
|
|
||||||
|
|
||||||
### 四、建议前移为 management / diagnosis skill 的现有高层工具
|
|
||||||
|
|
||||||
下面这些现有高层工具虽然可用,但语义仍然过宽,建议后续逐步前移为 management / diagnosis skill。
|
|
||||||
|
|
||||||
#### 1. `manage_trader`
|
|
||||||
|
|
||||||
建议逐步前移为:
|
|
||||||
|
|
||||||
- `trader_management`
|
|
||||||
- `trader_diagnosis`
|
|
||||||
|
|
||||||
原因:
|
|
||||||
|
|
||||||
- 创建、修改、启动、停止、删除虽然动作不同,但属于同一资源管理域
|
|
||||||
- 诊断路径和执行路径应分开
|
|
||||||
|
|
||||||
#### 2. `manage_exchange_config`
|
|
||||||
|
|
||||||
建议逐步前移为:
|
|
||||||
|
|
||||||
- `exchange_management`
|
|
||||||
- `exchange_diagnosis`
|
|
||||||
|
|
||||||
原因:
|
|
||||||
|
|
||||||
- CRUD / query 属于同一资源管理域
|
|
||||||
- invalid signature / timestamp / IP 白名单问题需要单独诊断路径
|
|
||||||
|
|
||||||
#### 3. `manage_model_config`
|
|
||||||
|
|
||||||
建议逐步前移为:
|
|
||||||
|
|
||||||
- `model_management`
|
|
||||||
- `model_diagnosis`
|
|
||||||
|
|
||||||
原因:
|
|
||||||
|
|
||||||
- 模型对象管理应集中到一个 management skill
|
|
||||||
- provider 配置失败和运行失败应集中到 diagnosis skill
|
|
||||||
|
|
||||||
#### 4. `manage_strategy`
|
|
||||||
|
|
||||||
建议逐步前移为:
|
|
||||||
|
|
||||||
- `strategy_management`
|
|
||||||
- `strategy_diagnosis`
|
|
||||||
|
|
||||||
原因:
|
|
||||||
|
|
||||||
- 策略模板管理和策略问题排查是两类不同任务
|
|
||||||
- create / update / activate / duplicate / delete / query 可以统一在 management skill 内处理
|
|
||||||
|
|
||||||
### 五、当前最适合直接做成硬 skill 的第一批对象
|
|
||||||
|
|
||||||
如果后续开始从“prompt 约束”走向“真正 dispatcher + skill runner”,建议优先落以下几类:
|
|
||||||
|
|
||||||
1. `create_trader`
|
|
||||||
2. `trader_management`
|
|
||||||
3. `exchange_management`
|
|
||||||
4. `model_management`
|
|
||||||
5. `exchange_diagnosis`
|
|
||||||
6. `model_diagnosis`
|
|
||||||
7. `trader_diagnosis`
|
|
||||||
|
|
||||||
原因:
|
|
||||||
|
|
||||||
- 这些最常见
|
|
||||||
- 多轮价值最高
|
|
||||||
- 失败成本高
|
|
||||||
- 用户对稳定性的感知最强
|
|
||||||
|
|
||||||
### 六、最终目标
|
|
||||||
|
|
||||||
`agent/tools.go` 中的工具未来应逐步承担“skill 的执行底座”角色,而不是直接承担全部对话策略。
|
|
||||||
|
|
||||||
也就是说,长期理想状态是:
|
|
||||||
|
|
||||||
- 文档层:按 skill 组织
|
|
||||||
- 对话层:先匹配 skill
|
|
||||||
- 执行层:skill 内部复用现有 tool
|
|
||||||
- planner 层:只兜底少数复杂情况
|
|
||||||
861
api/backtest.go
Normal file
861
api/backtest.go
Normal file
@@ -0,0 +1,861 @@
|
|||||||
|
package api
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"database/sql"
|
||||||
|
"encoding/json"
|
||||||
|
"errors"
|
||||||
|
"fmt"
|
||||||
|
"net/http"
|
||||||
|
"os"
|
||||||
|
"strconv"
|
||||||
|
"strings"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"nofx/backtest"
|
||||||
|
"nofx/logger"
|
||||||
|
"nofx/market"
|
||||||
|
"nofx/provider/nofxos"
|
||||||
|
"nofx/store"
|
||||||
|
|
||||||
|
"github.com/gin-gonic/gin"
|
||||||
|
)
|
||||||
|
|
||||||
|
func (s *Server) registerBacktestRoutes(router *gin.RouterGroup) {
|
||||||
|
router.POST("/start", s.handleBacktestStart)
|
||||||
|
router.POST("/pause", s.handleBacktestPause)
|
||||||
|
router.POST("/resume", s.handleBacktestResume)
|
||||||
|
router.POST("/stop", s.handleBacktestStop)
|
||||||
|
router.POST("/label", s.handleBacktestLabel)
|
||||||
|
router.POST("/delete", s.handleBacktestDelete)
|
||||||
|
router.GET("/status", s.handleBacktestStatus)
|
||||||
|
router.GET("/runs", s.handleBacktestRuns)
|
||||||
|
router.GET("/equity", s.handleBacktestEquity)
|
||||||
|
router.GET("/trades", s.handleBacktestTrades)
|
||||||
|
router.GET("/metrics", s.handleBacktestMetrics)
|
||||||
|
router.GET("/trace", s.handleBacktestTrace)
|
||||||
|
router.GET("/decisions", s.handleBacktestDecisions)
|
||||||
|
router.GET("/export", s.handleBacktestExport)
|
||||||
|
router.GET("/klines", s.handleBacktestKlines)
|
||||||
|
}
|
||||||
|
|
||||||
|
type backtestStartRequest struct {
|
||||||
|
Config backtest.BacktestConfig `json:"config"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type runIDRequest struct {
|
||||||
|
RunID string `json:"run_id"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type labelRequest struct {
|
||||||
|
RunID string `json:"run_id"`
|
||||||
|
Label string `json:"label"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Server) handleBacktestStart(c *gin.Context) {
|
||||||
|
if s.backtestManager == nil {
|
||||||
|
c.JSON(http.StatusServiceUnavailable, gin.H{"error": "backtest manager unavailable"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
var req backtestStartRequest
|
||||||
|
if err := c.ShouldBindJSON(&req); err != nil {
|
||||||
|
SafeBadRequest(c, "Invalid request parameters")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
cfg := req.Config
|
||||||
|
if cfg.RunID == "" {
|
||||||
|
cfg.RunID = "bt_" + time.Now().UTC().Format("20060102_150405")
|
||||||
|
}
|
||||||
|
cfg.CustomPrompt = strings.TrimSpace(cfg.CustomPrompt)
|
||||||
|
cfg.UserID = normalizeUserID(c.GetString("user_id"))
|
||||||
|
|
||||||
|
logger.Infof("📊 Backtest request - symbols from request: %v (count=%d), strategyID: %s",
|
||||||
|
cfg.Symbols, len(cfg.Symbols), cfg.StrategyID)
|
||||||
|
|
||||||
|
// Load strategy config if strategy_id is provided
|
||||||
|
if cfg.StrategyID != "" {
|
||||||
|
strategy, err := s.store.Strategy().Get(cfg.UserID, cfg.StrategyID)
|
||||||
|
if err != nil {
|
||||||
|
SafeBadRequest(c, "Failed to load strategy")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if strategy == nil {
|
||||||
|
SafeBadRequest(c, "Strategy not found")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
var strategyConfig store.StrategyConfig
|
||||||
|
if err := json.Unmarshal([]byte(strategy.Config), &strategyConfig); err != nil {
|
||||||
|
SafeBadRequest(c, "Failed to parse strategy config")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
cfg.SetLoadedStrategy(&strategyConfig)
|
||||||
|
logger.Infof("📊 Backtest using saved strategy: %s (%s)", strategy.Name, strategy.ID)
|
||||||
|
logger.Infof("📊 Strategy coin source: type=%s, use_ai500=%v, use_oi_top=%v, static_coins=%v",
|
||||||
|
strategyConfig.CoinSource.SourceType,
|
||||||
|
strategyConfig.CoinSource.UseAI500,
|
||||||
|
strategyConfig.CoinSource.UseOITop,
|
||||||
|
strategyConfig.CoinSource.StaticCoins)
|
||||||
|
|
||||||
|
// If no symbols provided, fetch from strategy's coin source
|
||||||
|
if len(cfg.Symbols) == 0 {
|
||||||
|
symbols, err := s.resolveStrategyCoins(&strategyConfig)
|
||||||
|
if err != nil {
|
||||||
|
SafeBadRequest(c, "Failed to resolve coins from strategy")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
cfg.Symbols = symbols
|
||||||
|
logger.Infof("📊 Resolved %d coins from strategy: %v", len(symbols), symbols)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := s.hydrateBacktestAIConfig(&cfg); err != nil {
|
||||||
|
SafeBadRequest(c, "Failed to configure AI model")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
logger.Infof("📊 Starting backtest with final config: runID=%s, symbols=%v (count=%d), strategyID=%s",
|
||||||
|
cfg.RunID, cfg.Symbols, len(cfg.Symbols), cfg.StrategyID)
|
||||||
|
|
||||||
|
runner, err := s.backtestManager.Start(context.Background(), cfg)
|
||||||
|
if err != nil {
|
||||||
|
SafeError(c, http.StatusBadRequest, "Failed to start backtest", err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
meta := runner.CurrentMetadata()
|
||||||
|
c.JSON(http.StatusOK, meta)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Server) handleBacktestPause(c *gin.Context) {
|
||||||
|
s.handleBacktestControl(c, s.backtestManager.Pause)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Server) handleBacktestResume(c *gin.Context) {
|
||||||
|
s.handleBacktestControl(c, s.backtestManager.Resume)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Server) handleBacktestStop(c *gin.Context) {
|
||||||
|
s.handleBacktestControl(c, s.backtestManager.Stop)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Server) handleBacktestControl(c *gin.Context, fn func(string) error) {
|
||||||
|
if s.backtestManager == nil {
|
||||||
|
c.JSON(http.StatusServiceUnavailable, gin.H{"error": "backtest manager unavailable"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
userID := normalizeUserID(c.GetString("user_id"))
|
||||||
|
|
||||||
|
var req runIDRequest
|
||||||
|
if err := c.ShouldBindJSON(&req); err != nil {
|
||||||
|
SafeBadRequest(c, "Invalid request parameters")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if req.RunID == "" {
|
||||||
|
SafeBadRequest(c, "run_id is required")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if _, err := s.ensureBacktestRunOwnership(req.RunID, userID); writeBacktestAccessError(c, err) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := fn(req.RunID); err != nil {
|
||||||
|
SafeError(c, http.StatusBadRequest, "Failed to execute backtest operation", err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
meta, err := s.backtestManager.LoadMetadata(req.RunID)
|
||||||
|
if err != nil {
|
||||||
|
c.JSON(http.StatusOK, gin.H{"message": "ok"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
c.JSON(http.StatusOK, meta)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Server) handleBacktestLabel(c *gin.Context) {
|
||||||
|
if s.backtestManager == nil {
|
||||||
|
c.JSON(http.StatusServiceUnavailable, gin.H{"error": "backtest manager unavailable"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
var req labelRequest
|
||||||
|
if err := c.ShouldBindJSON(&req); err != nil {
|
||||||
|
SafeBadRequest(c, "Invalid request parameters")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if strings.TrimSpace(req.RunID) == "" {
|
||||||
|
SafeBadRequest(c, "run_id is required")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
userID := normalizeUserID(c.GetString("user_id"))
|
||||||
|
if _, err := s.ensureBacktestRunOwnership(req.RunID, userID); writeBacktestAccessError(c, err) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
meta, err := s.backtestManager.UpdateLabel(req.RunID, req.Label)
|
||||||
|
if err != nil {
|
||||||
|
SafeInternalError(c, "Update backtest label", err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
c.JSON(http.StatusOK, meta)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Server) handleBacktestDelete(c *gin.Context) {
|
||||||
|
if s.backtestManager == nil {
|
||||||
|
c.JSON(http.StatusServiceUnavailable, gin.H{"error": "backtest manager unavailable"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
var req runIDRequest
|
||||||
|
if err := c.ShouldBindJSON(&req); err != nil {
|
||||||
|
SafeBadRequest(c, "Invalid request parameters")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if strings.TrimSpace(req.RunID) == "" {
|
||||||
|
SafeBadRequest(c, "run_id is required")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
userID := normalizeUserID(c.GetString("user_id"))
|
||||||
|
if _, err := s.ensureBacktestRunOwnership(req.RunID, userID); writeBacktestAccessError(c, err) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if err := s.backtestManager.Delete(req.RunID); err != nil {
|
||||||
|
SafeInternalError(c, "Delete backtest run", err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
c.JSON(http.StatusOK, gin.H{"message": "deleted"})
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Server) handleBacktestStatus(c *gin.Context) {
|
||||||
|
if s.backtestManager == nil {
|
||||||
|
c.JSON(http.StatusServiceUnavailable, gin.H{"error": "backtest manager unavailable"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
userID := normalizeUserID(c.GetString("user_id"))
|
||||||
|
|
||||||
|
runID := c.Query("run_id")
|
||||||
|
if runID == "" {
|
||||||
|
c.JSON(http.StatusBadRequest, gin.H{"error": "run_id is required"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
meta, err := s.ensureBacktestRunOwnership(runID, userID)
|
||||||
|
if writeBacktestAccessError(c, err) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
status := s.backtestManager.Status(runID)
|
||||||
|
if status != nil {
|
||||||
|
c.JSON(http.StatusOK, status)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
payload := backtest.StatusPayload{
|
||||||
|
RunID: meta.RunID,
|
||||||
|
State: meta.State,
|
||||||
|
ProgressPct: meta.Summary.ProgressPct,
|
||||||
|
ProcessedBars: meta.Summary.ProcessedBars,
|
||||||
|
CurrentTime: 0,
|
||||||
|
DecisionCycle: meta.Summary.ProcessedBars,
|
||||||
|
Equity: meta.Summary.EquityLast,
|
||||||
|
UnrealizedPnL: 0,
|
||||||
|
RealizedPnL: 0,
|
||||||
|
Note: meta.Summary.LiquidationNote,
|
||||||
|
LastUpdatedIso: meta.UpdatedAt.Format(time.RFC3339),
|
||||||
|
}
|
||||||
|
c.JSON(http.StatusOK, payload)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Server) handleBacktestRuns(c *gin.Context) {
|
||||||
|
if s.backtestManager == nil {
|
||||||
|
c.JSON(http.StatusServiceUnavailable, gin.H{"error": "backtest manager unavailable"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
rawUserID := strings.TrimSpace(c.GetString("user_id"))
|
||||||
|
userID := normalizeUserID(rawUserID)
|
||||||
|
filterByUser := rawUserID != "" && rawUserID != "admin"
|
||||||
|
|
||||||
|
metas, err := s.backtestManager.ListRuns()
|
||||||
|
if err != nil {
|
||||||
|
SafeInternalError(c, "List backtest runs", err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
stateFilter := strings.ToLower(strings.TrimSpace(c.Query("state")))
|
||||||
|
search := strings.ToLower(strings.TrimSpace(c.Query("search")))
|
||||||
|
limit := queryInt(c, "limit", 50)
|
||||||
|
offset := queryInt(c, "offset", 0)
|
||||||
|
if limit <= 0 {
|
||||||
|
limit = 50
|
||||||
|
}
|
||||||
|
if offset < 0 {
|
||||||
|
offset = 0
|
||||||
|
}
|
||||||
|
|
||||||
|
filtered := make([]*backtest.RunMetadata, 0, len(metas))
|
||||||
|
for _, meta := range metas {
|
||||||
|
if stateFilter != "" && !strings.EqualFold(string(meta.State), stateFilter) {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if search != "" {
|
||||||
|
target := strings.ToLower(meta.RunID + " " + meta.Summary.DecisionTF + " " + meta.Label + " " + meta.LastError)
|
||||||
|
if !strings.Contains(target, search) {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if filterByUser {
|
||||||
|
owner := strings.TrimSpace(meta.UserID)
|
||||||
|
if owner != "" && owner != userID {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
}
|
||||||
|
filtered = append(filtered, meta)
|
||||||
|
}
|
||||||
|
|
||||||
|
total := len(filtered)
|
||||||
|
start := offset
|
||||||
|
if start > total {
|
||||||
|
start = total
|
||||||
|
}
|
||||||
|
end := offset + limit
|
||||||
|
if end > total {
|
||||||
|
end = total
|
||||||
|
}
|
||||||
|
page := filtered[start:end]
|
||||||
|
|
||||||
|
c.JSON(http.StatusOK, gin.H{
|
||||||
|
"total": total,
|
||||||
|
"items": page,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Server) handleBacktestEquity(c *gin.Context) {
|
||||||
|
if s.backtestManager == nil {
|
||||||
|
c.JSON(http.StatusServiceUnavailable, gin.H{"error": "backtest manager unavailable"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
userID := normalizeUserID(c.GetString("user_id"))
|
||||||
|
|
||||||
|
runID := c.Query("run_id")
|
||||||
|
if runID == "" {
|
||||||
|
c.JSON(http.StatusBadRequest, gin.H{"error": "run_id is required"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if _, err := s.ensureBacktestRunOwnership(runID, userID); writeBacktestAccessError(c, err) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
timeframe := c.Query("tf")
|
||||||
|
limit := queryInt(c, "limit", 1000)
|
||||||
|
|
||||||
|
points, err := s.backtestManager.LoadEquity(runID, timeframe, limit)
|
||||||
|
if err != nil {
|
||||||
|
SafeError(c, http.StatusBadRequest, "Failed to load equity data", err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
c.JSON(http.StatusOK, points)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Server) handleBacktestTrades(c *gin.Context) {
|
||||||
|
if s.backtestManager == nil {
|
||||||
|
c.JSON(http.StatusServiceUnavailable, gin.H{"error": "backtest manager unavailable"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
userID := normalizeUserID(c.GetString("user_id"))
|
||||||
|
|
||||||
|
runID := c.Query("run_id")
|
||||||
|
if runID == "" {
|
||||||
|
c.JSON(http.StatusBadRequest, gin.H{"error": "run_id is required"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if _, err := s.ensureBacktestRunOwnership(runID, userID); writeBacktestAccessError(c, err) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
limit := queryInt(c, "limit", 1000)
|
||||||
|
|
||||||
|
events, err := s.backtestManager.LoadTrades(runID, limit)
|
||||||
|
if err != nil {
|
||||||
|
SafeError(c, http.StatusBadRequest, "Failed to load trades", err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
c.JSON(http.StatusOK, events)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Server) handleBacktestMetrics(c *gin.Context) {
|
||||||
|
if s.backtestManager == nil {
|
||||||
|
c.JSON(http.StatusServiceUnavailable, gin.H{"error": "backtest manager unavailable"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
userID := normalizeUserID(c.GetString("user_id"))
|
||||||
|
|
||||||
|
runID := c.Query("run_id")
|
||||||
|
if runID == "" {
|
||||||
|
c.JSON(http.StatusBadRequest, gin.H{"error": "run_id is required"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if _, err := s.ensureBacktestRunOwnership(runID, userID); writeBacktestAccessError(c, err) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
metrics, err := s.backtestManager.GetMetrics(runID)
|
||||||
|
if err != nil {
|
||||||
|
if errors.Is(err, sql.ErrNoRows) || errors.Is(err, os.ErrNotExist) {
|
||||||
|
c.JSON(http.StatusAccepted, gin.H{"error": "metrics not ready yet"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
SafeError(c, http.StatusBadRequest, "Failed to load metrics", err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
c.JSON(http.StatusOK, metrics)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Server) handleBacktestTrace(c *gin.Context) {
|
||||||
|
if s.backtestManager == nil {
|
||||||
|
c.JSON(http.StatusServiceUnavailable, gin.H{"error": "backtest manager unavailable"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
userID := normalizeUserID(c.GetString("user_id"))
|
||||||
|
runID := c.Query("run_id")
|
||||||
|
if runID == "" {
|
||||||
|
c.JSON(http.StatusBadRequest, gin.H{"error": "run_id is required"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if _, err := s.ensureBacktestRunOwnership(runID, userID); writeBacktestAccessError(c, err) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
cycle := queryInt(c, "cycle", 0)
|
||||||
|
record, err := s.backtestManager.GetTrace(runID, cycle)
|
||||||
|
if err != nil {
|
||||||
|
SafeNotFound(c, "Trace record")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
c.JSON(http.StatusOK, record)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Server) handleBacktestDecisions(c *gin.Context) {
|
||||||
|
if s.backtestManager == nil {
|
||||||
|
c.JSON(http.StatusServiceUnavailable, gin.H{"error": "backtest manager unavailable"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
userID := normalizeUserID(c.GetString("user_id"))
|
||||||
|
runID := c.Query("run_id")
|
||||||
|
if runID == "" {
|
||||||
|
c.JSON(http.StatusBadRequest, gin.H{"error": "run_id is required"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if _, err := s.ensureBacktestRunOwnership(runID, userID); writeBacktestAccessError(c, err) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
limit := queryInt(c, "limit", 20)
|
||||||
|
offset := queryInt(c, "offset", 0)
|
||||||
|
if limit <= 0 {
|
||||||
|
limit = 20
|
||||||
|
}
|
||||||
|
if limit > 200 {
|
||||||
|
limit = 200
|
||||||
|
}
|
||||||
|
if offset < 0 {
|
||||||
|
offset = 0
|
||||||
|
}
|
||||||
|
|
||||||
|
records, err := backtest.LoadDecisionRecords(runID, limit, offset)
|
||||||
|
if err != nil {
|
||||||
|
SafeInternalError(c, "Load decision records", err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
c.JSON(http.StatusOK, records)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Server) handleBacktestExport(c *gin.Context) {
|
||||||
|
if s.backtestManager == nil {
|
||||||
|
c.JSON(http.StatusServiceUnavailable, gin.H{"error": "backtest manager unavailable"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
userID := normalizeUserID(c.GetString("user_id"))
|
||||||
|
runID := c.Query("run_id")
|
||||||
|
if runID == "" {
|
||||||
|
c.JSON(http.StatusBadRequest, gin.H{"error": "run_id is required"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if _, err := s.ensureBacktestRunOwnership(runID, userID); writeBacktestAccessError(c, err) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
path, err := s.backtestManager.ExportRun(runID)
|
||||||
|
if err != nil {
|
||||||
|
SafeError(c, http.StatusBadRequest, "Failed to export backtest", err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
defer os.Remove(path)
|
||||||
|
filename := fmt.Sprintf("%s_export.zip", runID)
|
||||||
|
c.FileAttachment(path, filename)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Server) handleBacktestKlines(c *gin.Context) {
|
||||||
|
if s.backtestManager == nil {
|
||||||
|
c.JSON(http.StatusServiceUnavailable, gin.H{"error": "backtest manager unavailable"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
userID := normalizeUserID(c.GetString("user_id"))
|
||||||
|
runID := c.Query("run_id")
|
||||||
|
symbol := c.Query("symbol")
|
||||||
|
timeframe := c.Query("timeframe")
|
||||||
|
|
||||||
|
if runID == "" {
|
||||||
|
c.JSON(http.StatusBadRequest, gin.H{"error": "run_id is required"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if symbol == "" {
|
||||||
|
c.JSON(http.StatusBadRequest, gin.H{"error": "symbol is required"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
meta, err := s.ensureBacktestRunOwnership(runID, userID)
|
||||||
|
if writeBacktestAccessError(c, err) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Load config to get time range
|
||||||
|
cfg, err := backtest.LoadConfig(runID)
|
||||||
|
if err != nil {
|
||||||
|
c.JSON(http.StatusNotFound, gin.H{"error": "failed to load backtest config"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Use decision timeframe if not specified
|
||||||
|
if timeframe == "" {
|
||||||
|
timeframe = cfg.DecisionTimeframe
|
||||||
|
if timeframe == "" {
|
||||||
|
timeframe = "15m"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Fetch klines for the backtest time range
|
||||||
|
startTime := time.Unix(cfg.StartTS, 0)
|
||||||
|
endTime := time.Unix(cfg.EndTS, 0)
|
||||||
|
|
||||||
|
klines, err := market.GetKlinesRange(symbol, timeframe, startTime, endTime)
|
||||||
|
if err != nil {
|
||||||
|
SafeInternalError(c, "Fetch klines", err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Convert to response format
|
||||||
|
type KlineResponse struct {
|
||||||
|
Time int64 `json:"time"`
|
||||||
|
Open float64 `json:"open"`
|
||||||
|
High float64 `json:"high"`
|
||||||
|
Low float64 `json:"low"`
|
||||||
|
Close float64 `json:"close"`
|
||||||
|
Volume float64 `json:"volume"`
|
||||||
|
}
|
||||||
|
|
||||||
|
result := make([]KlineResponse, len(klines))
|
||||||
|
for i, k := range klines {
|
||||||
|
result[i] = KlineResponse{
|
||||||
|
Time: k.OpenTime / 1000, // Convert to seconds for lightweight-charts
|
||||||
|
Open: k.Open,
|
||||||
|
High: k.High,
|
||||||
|
Low: k.Low,
|
||||||
|
Close: k.Close,
|
||||||
|
Volume: k.Volume,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
c.JSON(http.StatusOK, gin.H{
|
||||||
|
"symbol": symbol,
|
||||||
|
"timeframe": timeframe,
|
||||||
|
"start_ts": cfg.StartTS,
|
||||||
|
"end_ts": cfg.EndTS,
|
||||||
|
"count": len(result),
|
||||||
|
"klines": result,
|
||||||
|
"run_id": meta.RunID,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
func queryInt(c *gin.Context, name string, fallback int) int {
|
||||||
|
if value := c.Query(name); value != "" {
|
||||||
|
if v, err := strconv.Atoi(value); err == nil {
|
||||||
|
return v
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return fallback
|
||||||
|
}
|
||||||
|
|
||||||
|
var errBacktestForbidden = errors.New("backtest run forbidden")
|
||||||
|
|
||||||
|
func normalizeUserID(id string) string {
|
||||||
|
id = strings.TrimSpace(id)
|
||||||
|
if id == "" {
|
||||||
|
return "default"
|
||||||
|
}
|
||||||
|
return id
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Server) ensureBacktestRunOwnership(runID, userID string) (*backtest.RunMetadata, error) {
|
||||||
|
if s.backtestManager == nil {
|
||||||
|
return nil, fmt.Errorf("backtest manager unavailable")
|
||||||
|
}
|
||||||
|
meta, err := s.backtestManager.LoadMetadata(runID)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
if userID == "" || userID == "admin" {
|
||||||
|
return meta, nil
|
||||||
|
}
|
||||||
|
owner := strings.TrimSpace(meta.UserID)
|
||||||
|
if owner == "" {
|
||||||
|
return meta, nil
|
||||||
|
}
|
||||||
|
if owner != userID {
|
||||||
|
return nil, errBacktestForbidden
|
||||||
|
}
|
||||||
|
return meta, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func writeBacktestAccessError(c *gin.Context, err error) bool {
|
||||||
|
if err == nil {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
switch {
|
||||||
|
case errors.Is(err, errBacktestForbidden):
|
||||||
|
SafeForbidden(c, "No permission to access this backtest task")
|
||||||
|
case errors.Is(err, os.ErrNotExist), errors.Is(err, sql.ErrNoRows):
|
||||||
|
SafeNotFound(c, "Backtest task")
|
||||||
|
default:
|
||||||
|
SafeInternalError(c, "Access backtest", err)
|
||||||
|
}
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
// resolveStrategyCoins fetches coins based on strategy's coin source configuration
|
||||||
|
func (s *Server) resolveStrategyCoins(strategyConfig *store.StrategyConfig) ([]string, error) {
|
||||||
|
if strategyConfig == nil {
|
||||||
|
return nil, fmt.Errorf("strategy config is nil")
|
||||||
|
}
|
||||||
|
|
||||||
|
coinSource := strategyConfig.CoinSource
|
||||||
|
var symbols []string
|
||||||
|
symbolSet := make(map[string]bool)
|
||||||
|
|
||||||
|
// Handle empty source_type - check flags for backward compatibility
|
||||||
|
sourceType := coinSource.SourceType
|
||||||
|
if sourceType == "" {
|
||||||
|
if coinSource.UseAI500 && coinSource.UseOITop {
|
||||||
|
sourceType = "mixed"
|
||||||
|
} else if coinSource.UseAI500 {
|
||||||
|
sourceType = "ai500"
|
||||||
|
} else if coinSource.UseOITop {
|
||||||
|
sourceType = "oi_top"
|
||||||
|
} else if len(coinSource.StaticCoins) > 0 {
|
||||||
|
sourceType = "static"
|
||||||
|
} else {
|
||||||
|
return nil, fmt.Errorf("strategy has no coin source configured")
|
||||||
|
}
|
||||||
|
logger.Infof("📊 Inferred source_type=%s from flags", sourceType)
|
||||||
|
}
|
||||||
|
|
||||||
|
switch sourceType {
|
||||||
|
case "static":
|
||||||
|
for _, sym := range coinSource.StaticCoins {
|
||||||
|
sym = market.Normalize(sym)
|
||||||
|
if !symbolSet[sym] {
|
||||||
|
symbols = append(symbols, sym)
|
||||||
|
symbolSet[sym] = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
case "ai500":
|
||||||
|
limit := coinSource.AI500Limit
|
||||||
|
if limit <= 0 {
|
||||||
|
limit = 30
|
||||||
|
}
|
||||||
|
logger.Infof("📊 Fetching AI500 coins with limit=%d", limit)
|
||||||
|
coins, err := nofxos.DefaultClient().GetTopRatedCoins(limit)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("failed to get AI500 coins: %w", err)
|
||||||
|
}
|
||||||
|
logger.Infof("📊 Got %d coins from AI500: %v", len(coins), coins)
|
||||||
|
for _, sym := range coins {
|
||||||
|
sym = market.Normalize(sym)
|
||||||
|
if !symbolSet[sym] {
|
||||||
|
symbols = append(symbols, sym)
|
||||||
|
symbolSet[sym] = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
case "oi_top":
|
||||||
|
coins, err := nofxos.DefaultClient().GetOITopSymbols()
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("failed to get OI Top coins: %w", err)
|
||||||
|
}
|
||||||
|
limit := coinSource.OITopLimit
|
||||||
|
if limit <= 0 || limit > len(coins) {
|
||||||
|
limit = len(coins)
|
||||||
|
}
|
||||||
|
for i, sym := range coins {
|
||||||
|
if i >= limit {
|
||||||
|
break
|
||||||
|
}
|
||||||
|
sym = market.Normalize(sym)
|
||||||
|
if !symbolSet[sym] {
|
||||||
|
symbols = append(symbols, sym)
|
||||||
|
symbolSet[sym] = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
case "mixed":
|
||||||
|
// Get from AI500
|
||||||
|
if coinSource.UseAI500 {
|
||||||
|
limit := coinSource.AI500Limit
|
||||||
|
if limit <= 0 {
|
||||||
|
limit = 30
|
||||||
|
}
|
||||||
|
coins, err := nofxos.DefaultClient().GetTopRatedCoins(limit)
|
||||||
|
if err != nil {
|
||||||
|
logger.Warnf("Failed to get AI500 coins: %v", err)
|
||||||
|
} else {
|
||||||
|
for _, sym := range coins {
|
||||||
|
sym = market.Normalize(sym)
|
||||||
|
if !symbolSet[sym] {
|
||||||
|
symbols = append(symbols, sym)
|
||||||
|
symbolSet[sym] = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get from OI Top
|
||||||
|
if coinSource.UseOITop {
|
||||||
|
coins, err := nofxos.DefaultClient().GetOITopSymbols()
|
||||||
|
if err != nil {
|
||||||
|
logger.Warnf("Failed to get OI Top coins: %v", err)
|
||||||
|
} else {
|
||||||
|
limit := coinSource.OITopLimit
|
||||||
|
if limit <= 0 || limit > len(coins) {
|
||||||
|
limit = len(coins)
|
||||||
|
}
|
||||||
|
for i, sym := range coins {
|
||||||
|
if i >= limit {
|
||||||
|
break
|
||||||
|
}
|
||||||
|
sym = market.Normalize(sym)
|
||||||
|
if !symbolSet[sym] {
|
||||||
|
symbols = append(symbols, sym)
|
||||||
|
symbolSet[sym] = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Add static coins
|
||||||
|
for _, sym := range coinSource.StaticCoins {
|
||||||
|
sym = market.Normalize(sym)
|
||||||
|
if !symbolSet[sym] {
|
||||||
|
symbols = append(symbols, sym)
|
||||||
|
symbolSet[sym] = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
default:
|
||||||
|
return nil, fmt.Errorf("unknown coin source type: %s", sourceType)
|
||||||
|
}
|
||||||
|
|
||||||
|
if len(symbols) == 0 {
|
||||||
|
return nil, fmt.Errorf("no coins resolved from strategy")
|
||||||
|
}
|
||||||
|
|
||||||
|
logger.Infof("📊 Final resolved symbols: %d coins - %v", len(symbols), symbols)
|
||||||
|
return symbols, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Server) resolveBacktestAIConfig(cfg *backtest.BacktestConfig, userID string) error {
|
||||||
|
if cfg == nil {
|
||||||
|
return fmt.Errorf("config is nil")
|
||||||
|
}
|
||||||
|
if s.store == nil {
|
||||||
|
return fmt.Errorf("System database not ready, cannot load AI model configuration")
|
||||||
|
}
|
||||||
|
|
||||||
|
cfg.UserID = normalizeUserID(userID)
|
||||||
|
|
||||||
|
return s.hydrateBacktestAIConfig(cfg)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Server) hydrateBacktestAIConfig(cfg *backtest.BacktestConfig) error {
|
||||||
|
if cfg == nil {
|
||||||
|
return fmt.Errorf("config is nil")
|
||||||
|
}
|
||||||
|
if s.store == nil {
|
||||||
|
return fmt.Errorf("System database not ready, cannot load AI model configuration")
|
||||||
|
}
|
||||||
|
|
||||||
|
cfg.UserID = normalizeUserID(cfg.UserID)
|
||||||
|
modelID := strings.TrimSpace(cfg.AIModelID)
|
||||||
|
|
||||||
|
var (
|
||||||
|
model *store.AIModel
|
||||||
|
err error
|
||||||
|
)
|
||||||
|
|
||||||
|
if modelID != "" {
|
||||||
|
model, err = s.store.AIModel().Get(cfg.UserID, modelID)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("Failed to load AI model: %w", err)
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
model, err = s.store.AIModel().GetDefault(cfg.UserID)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("No available AI model found: %w", err)
|
||||||
|
}
|
||||||
|
cfg.AIModelID = model.ID
|
||||||
|
}
|
||||||
|
|
||||||
|
if !model.Enabled {
|
||||||
|
return fmt.Errorf("AI model %s is not enabled yet", model.Name)
|
||||||
|
}
|
||||||
|
|
||||||
|
apiKey := strings.TrimSpace(string(model.APIKey))
|
||||||
|
if apiKey == "" {
|
||||||
|
return fmt.Errorf("AI model %s is missing API Key, please configure it in the system first", model.Name)
|
||||||
|
}
|
||||||
|
|
||||||
|
provider := strings.ToLower(strings.TrimSpace(model.Provider))
|
||||||
|
// Ensure provider is never empty or "inherit" - infer from model name if needed
|
||||||
|
if provider == "" || provider == "inherit" {
|
||||||
|
modelNameLower := strings.ToLower(model.Name)
|
||||||
|
if strings.Contains(modelNameLower, "claude") || strings.Contains(modelNameLower, "anthropic") {
|
||||||
|
provider = "anthropic"
|
||||||
|
} else if strings.Contains(modelNameLower, "gpt") || strings.Contains(modelNameLower, "openai") {
|
||||||
|
provider = "openai"
|
||||||
|
} else if strings.Contains(modelNameLower, "gemini") || strings.Contains(modelNameLower, "google") {
|
||||||
|
provider = "google"
|
||||||
|
} else if strings.Contains(modelNameLower, "deepseek") {
|
||||||
|
provider = "deepseek"
|
||||||
|
} else if model.CustomAPIURL != "" {
|
||||||
|
provider = "custom"
|
||||||
|
} else {
|
||||||
|
provider = "openai" // default fallback
|
||||||
|
}
|
||||||
|
logger.Infof("📊 Inferred AI provider '%s' from model name '%s'", provider, model.Name)
|
||||||
|
}
|
||||||
|
cfg.AICfg.Provider = provider
|
||||||
|
cfg.AICfg.APIKey = apiKey
|
||||||
|
cfg.AICfg.BaseURL = strings.TrimSpace(model.CustomAPIURL)
|
||||||
|
modelName := strings.TrimSpace(model.CustomModelName)
|
||||||
|
if cfg.AICfg.Model == "" {
|
||||||
|
cfg.AICfg.Model = modelName
|
||||||
|
}
|
||||||
|
cfg.AICfg.Model = strings.TrimSpace(cfg.AICfg.Model)
|
||||||
|
|
||||||
|
if cfg.AICfg.Provider == "custom" {
|
||||||
|
if cfg.AICfg.BaseURL == "" {
|
||||||
|
return fmt.Errorf("Custom AI model requires API URL configuration")
|
||||||
|
}
|
||||||
|
if cfg.AICfg.Model == "" {
|
||||||
|
return fmt.Errorf("Custom AI model requires model name configuration")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
@@ -1,6 +1,7 @@
|
|||||||
package api
|
package api
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"log"
|
||||||
"net/http"
|
"net/http"
|
||||||
"nofx/config"
|
"nofx/config"
|
||||||
"nofx/crypto"
|
"nofx/crypto"
|
||||||
@@ -52,16 +53,28 @@ func (h *CryptoHandler) HandleGetPublicKey(c *gin.Context) {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
// ==================== Encrypted Data Decryption ====================
|
// ==================== Encrypted Data Decryption Endpoint ====================
|
||||||
//
|
|
||||||
// SECURITY: there is deliberately NO public decrypt endpoint. Transport
|
// HandleDecryptSensitiveData Decrypt encrypted data sent from client
|
||||||
// encryption is one-directional — clients encrypt sensitive fields to the
|
func (h *CryptoHandler) HandleDecryptSensitiveData(c *gin.Context) {
|
||||||
// server's RSA public key and the authenticated config-update handlers
|
var payload crypto.EncryptedPayload
|
||||||
// (handleUpdateModelConfigs / handleUpdateExchangeConfigs / handleCreateExchange)
|
if err := c.ShouldBindJSON(&payload); err != nil {
|
||||||
// decrypt them server-side via cryptoService.DecryptSensitiveData. Exposing a
|
c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid request"})
|
||||||
// generic decrypt route would turn the server into a decryption oracle that any
|
return
|
||||||
// unauthenticated caller could use to recover the plaintext of a captured
|
}
|
||||||
// ciphertext, defeating the entire transport-encryption layer.
|
|
||||||
|
// Decrypt
|
||||||
|
decrypted, err := h.cryptoService.DecryptSensitiveData(&payload)
|
||||||
|
if err != nil {
|
||||||
|
log.Printf("❌ Decryption failed: %v", err)
|
||||||
|
c.JSON(http.StatusInternalServerError, gin.H{"error": "Decryption failed"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
c.JSON(http.StatusOK, map[string]string{
|
||||||
|
"plaintext": decrypted,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
// ==================== Audit Log Query Endpoint ====================
|
// ==================== Audit Log Query Endpoint ====================
|
||||||
|
|
||||||
|
|||||||
635
api/debate.go
Normal file
635
api/debate.go
Normal file
@@ -0,0 +1,635 @@
|
|||||||
|
package api
|
||||||
|
|
||||||
|
import (
|
||||||
|
"encoding/json"
|
||||||
|
"fmt"
|
||||||
|
"net/http"
|
||||||
|
"sync"
|
||||||
|
|
||||||
|
"nofx/debate"
|
||||||
|
"nofx/logger"
|
||||||
|
"nofx/provider/nofxos"
|
||||||
|
"nofx/store"
|
||||||
|
|
||||||
|
"github.com/gin-gonic/gin"
|
||||||
|
)
|
||||||
|
|
||||||
|
// DebateHandler handles debate-related API requests
|
||||||
|
type DebateHandler struct {
|
||||||
|
debateStore *store.DebateStore
|
||||||
|
strategyStore *store.StrategyStore
|
||||||
|
aiModelStore *store.AIModelStore
|
||||||
|
engine *debate.DebateEngine
|
||||||
|
|
||||||
|
// Trader manager for execution
|
||||||
|
traderManager DebateTraderManager
|
||||||
|
|
||||||
|
// SSE subscribers
|
||||||
|
subscribers map[string]map[chan []byte]bool // sessionID -> channels
|
||||||
|
subscribersMu sync.RWMutex
|
||||||
|
}
|
||||||
|
|
||||||
|
// DebateTraderManager interface for getting trader executors
|
||||||
|
type DebateTraderManager interface {
|
||||||
|
GetTraderExecutor(traderID string) (debate.TraderExecutor, error)
|
||||||
|
}
|
||||||
|
|
||||||
|
// NewDebateHandler creates a new DebateHandler
|
||||||
|
func NewDebateHandler(debateStore *store.DebateStore, strategyStore *store.StrategyStore, aiModelStore *store.AIModelStore) *DebateHandler {
|
||||||
|
handler := &DebateHandler{
|
||||||
|
debateStore: debateStore,
|
||||||
|
strategyStore: strategyStore,
|
||||||
|
aiModelStore: aiModelStore,
|
||||||
|
subscribers: make(map[string]map[chan []byte]bool),
|
||||||
|
}
|
||||||
|
|
||||||
|
// Create debate engine with event callbacks
|
||||||
|
handler.engine = debate.NewDebateEngine(debateStore, strategyStore, aiModelStore)
|
||||||
|
handler.engine.OnRoundStart = handler.broadcastRoundStart
|
||||||
|
handler.engine.OnMessage = handler.broadcastMessage
|
||||||
|
handler.engine.OnRoundEnd = handler.broadcastRoundEnd
|
||||||
|
handler.engine.OnVote = handler.broadcastVote
|
||||||
|
handler.engine.OnConsensus = handler.broadcastConsensus
|
||||||
|
handler.engine.OnError = handler.broadcastError
|
||||||
|
|
||||||
|
return handler
|
||||||
|
}
|
||||||
|
|
||||||
|
// CreateDebateRequest represents a request to create a new debate
|
||||||
|
type CreateDebateRequest struct {
|
||||||
|
Name string `json:"name" binding:"required"`
|
||||||
|
StrategyID string `json:"strategy_id" binding:"required"`
|
||||||
|
Symbol string `json:"symbol"` // Optional: auto-selected based on strategy if empty
|
||||||
|
MaxRounds int `json:"max_rounds"`
|
||||||
|
IntervalMinutes int `json:"interval_minutes"`
|
||||||
|
PromptVariant string `json:"prompt_variant"`
|
||||||
|
AutoExecute bool `json:"auto_execute"`
|
||||||
|
TraderID string `json:"trader_id"`
|
||||||
|
Participants []ParticipantConfig `json:"participants" binding:"required,min=2"`
|
||||||
|
// OI Ranking data options
|
||||||
|
EnableOIRanking bool `json:"enable_oi_ranking"` // Whether to include OI ranking data
|
||||||
|
OIRankingLimit int `json:"oi_ranking_limit"` // Number of OI ranking entries (default 10)
|
||||||
|
OIDuration string `json:"oi_duration"` // Duration for OI data (1h, 4h, 24h, etc.)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ParticipantConfig represents a participant configuration
|
||||||
|
type ParticipantConfig struct {
|
||||||
|
AIModelID string `json:"ai_model_id" binding:"required"`
|
||||||
|
Personality string `json:"personality" binding:"required"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// HandleListDebates lists all debates for a user
|
||||||
|
func (h *DebateHandler) HandleListDebates(c *gin.Context) {
|
||||||
|
userID := c.GetString("user_id")
|
||||||
|
if userID == "" {
|
||||||
|
c.JSON(http.StatusUnauthorized, gin.H{"error": "unauthorized"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
sessions, err := h.debateStore.GetSessionsByUser(userID)
|
||||||
|
if err != nil {
|
||||||
|
logger.Errorf("Failed to get debates for user %s: %v", userID, err)
|
||||||
|
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to get debates"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Return empty array instead of null
|
||||||
|
if sessions == nil {
|
||||||
|
sessions = []*store.DebateSession{}
|
||||||
|
}
|
||||||
|
|
||||||
|
c.JSON(http.StatusOK, sessions)
|
||||||
|
}
|
||||||
|
|
||||||
|
// HandleGetDebate gets a specific debate with all details
|
||||||
|
func (h *DebateHandler) HandleGetDebate(c *gin.Context) {
|
||||||
|
debateID := c.Param("id")
|
||||||
|
userID := c.GetString("user_id")
|
||||||
|
|
||||||
|
session, err := h.debateStore.GetSessionWithDetails(debateID)
|
||||||
|
if err != nil {
|
||||||
|
c.JSON(http.StatusNotFound, gin.H{"error": "debate not found"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check ownership
|
||||||
|
if session.UserID != userID {
|
||||||
|
c.JSON(http.StatusForbidden, gin.H{"error": "access denied"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
c.JSON(http.StatusOK, session)
|
||||||
|
}
|
||||||
|
|
||||||
|
// HandleCreateDebate creates a new debate
|
||||||
|
func (h *DebateHandler) HandleCreateDebate(c *gin.Context) {
|
||||||
|
userID := c.GetString("user_id")
|
||||||
|
if userID == "" {
|
||||||
|
c.JSON(http.StatusUnauthorized, gin.H{"error": "unauthorized"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
var req CreateDebateRequest
|
||||||
|
if err := c.ShouldBindJSON(&req); err != nil {
|
||||||
|
SafeBadRequest(c, "Invalid request parameters")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Validate strategy exists
|
||||||
|
strategy, err := h.strategyStore.Get(userID, req.StrategyID)
|
||||||
|
if err != nil {
|
||||||
|
c.JSON(http.StatusBadRequest, gin.H{"error": "strategy not found"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Validate strategy belongs to user or is default
|
||||||
|
if strategy.UserID != userID && !strategy.IsDefault {
|
||||||
|
c.JSON(http.StatusForbidden, gin.H{"error": "strategy access denied"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Auto-select symbol based on strategy if not provided
|
||||||
|
if req.Symbol == "" {
|
||||||
|
req.Symbol = "BTCUSDT" // default fallback
|
||||||
|
if strategyConfig, err := strategy.ParseConfig(); err == nil {
|
||||||
|
coinSource := strategyConfig.CoinSource
|
||||||
|
switch coinSource.SourceType {
|
||||||
|
case "static":
|
||||||
|
if len(coinSource.StaticCoins) > 0 {
|
||||||
|
req.Symbol = coinSource.StaticCoins[0]
|
||||||
|
}
|
||||||
|
case "ai500":
|
||||||
|
// Fetch from AI500 API
|
||||||
|
if coins, err := nofxos.DefaultClient().GetTopRatedCoins(1); err == nil && len(coins) > 0 {
|
||||||
|
req.Symbol = coins[0]
|
||||||
|
logger.Infof("Fetched coin from AI500 API: %s", req.Symbol)
|
||||||
|
}
|
||||||
|
case "oi_top":
|
||||||
|
// Fetch from OI top API
|
||||||
|
if coins, err := nofxos.DefaultClient().GetOITopSymbols(); err == nil && len(coins) > 0 {
|
||||||
|
req.Symbol = coins[0]
|
||||||
|
logger.Infof("Fetched coin from OI Top API: %s", req.Symbol)
|
||||||
|
}
|
||||||
|
case "mixed":
|
||||||
|
// Try AI500 first, then OI top
|
||||||
|
if coinSource.UseAI500 {
|
||||||
|
if coins, err := nofxos.DefaultClient().GetTopRatedCoins(1); err == nil && len(coins) > 0 {
|
||||||
|
req.Symbol = coins[0]
|
||||||
|
logger.Infof("Fetched coin from AI500 API (mixed): %s", req.Symbol)
|
||||||
|
}
|
||||||
|
} else if coinSource.UseOITop {
|
||||||
|
if coins, err := nofxos.DefaultClient().GetOITopSymbols(); err == nil && len(coins) > 0 {
|
||||||
|
req.Symbol = coins[0]
|
||||||
|
logger.Infof("Fetched coin from OI Top API (mixed): %s", req.Symbol)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
logger.Infof("Auto-selected symbol %s for debate based on strategy %s (source_type=%s)",
|
||||||
|
req.Symbol, strategy.Name, coinSource.SourceType)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Set defaults
|
||||||
|
if req.MaxRounds <= 0 || req.MaxRounds > 5 {
|
||||||
|
req.MaxRounds = 3
|
||||||
|
}
|
||||||
|
if req.IntervalMinutes <= 0 {
|
||||||
|
req.IntervalMinutes = 5
|
||||||
|
}
|
||||||
|
if req.PromptVariant == "" {
|
||||||
|
req.PromptVariant = "balanced"
|
||||||
|
}
|
||||||
|
|
||||||
|
// Create session
|
||||||
|
session := &store.DebateSession{
|
||||||
|
UserID: userID,
|
||||||
|
Name: req.Name,
|
||||||
|
StrategyID: req.StrategyID,
|
||||||
|
Symbol: req.Symbol,
|
||||||
|
MaxRounds: req.MaxRounds,
|
||||||
|
IntervalMinutes: req.IntervalMinutes,
|
||||||
|
PromptVariant: req.PromptVariant,
|
||||||
|
AutoExecute: req.AutoExecute,
|
||||||
|
TraderID: req.TraderID,
|
||||||
|
EnableOIRanking: req.EnableOIRanking,
|
||||||
|
OIRankingLimit: req.OIRankingLimit,
|
||||||
|
OIDuration: req.OIDuration,
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := h.debateStore.CreateSession(session); err != nil {
|
||||||
|
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to create debate"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Add participants
|
||||||
|
for i, p := range req.Participants {
|
||||||
|
// Validate AI model exists and belongs to user
|
||||||
|
aiModel, err := h.aiModelStore.GetByID(p.AIModelID)
|
||||||
|
if err != nil {
|
||||||
|
logger.Warnf("AI model not found: %s", p.AIModelID)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if aiModel.UserID != userID {
|
||||||
|
logger.Warnf("AI model %s does not belong to user", p.AIModelID)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
// Validate personality
|
||||||
|
personality := store.DebatePersonality(p.Personality)
|
||||||
|
if _, ok := store.PersonalityColors[personality]; !ok {
|
||||||
|
personality = store.PersonalityAnalyst
|
||||||
|
}
|
||||||
|
|
||||||
|
participant := &store.DebateParticipant{
|
||||||
|
SessionID: session.ID,
|
||||||
|
AIModelID: p.AIModelID,
|
||||||
|
AIModelName: aiModel.Name,
|
||||||
|
Provider: aiModel.Provider,
|
||||||
|
Personality: personality,
|
||||||
|
Color: store.PersonalityColors[personality],
|
||||||
|
SpeakOrder: i,
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := h.debateStore.AddParticipant(participant); err != nil {
|
||||||
|
logger.Errorf("Failed to add participant: %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get full session with participants
|
||||||
|
fullSession, _ := h.debateStore.GetSessionWithDetails(session.ID)
|
||||||
|
|
||||||
|
c.JSON(http.StatusCreated, fullSession)
|
||||||
|
}
|
||||||
|
|
||||||
|
// HandleStartDebate starts a debate
|
||||||
|
func (h *DebateHandler) HandleStartDebate(c *gin.Context) {
|
||||||
|
debateID := c.Param("id")
|
||||||
|
userID := c.GetString("user_id")
|
||||||
|
|
||||||
|
session, err := h.debateStore.GetSession(debateID)
|
||||||
|
if err != nil {
|
||||||
|
c.JSON(http.StatusNotFound, gin.H{"error": "debate not found"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if session.UserID != userID {
|
||||||
|
c.JSON(http.StatusForbidden, gin.H{"error": "access denied"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if session.Status != store.DebateStatusPending {
|
||||||
|
c.JSON(http.StatusBadRequest, gin.H{"error": "debate is not in pending status"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Start debate asynchronously
|
||||||
|
if err := h.engine.StartDebate(debateID); err != nil {
|
||||||
|
SafeInternalError(c, "Start debate", err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
c.JSON(http.StatusOK, gin.H{"message": "debate started", "id": debateID})
|
||||||
|
}
|
||||||
|
|
||||||
|
// HandleCancelDebate cancels a running debate
|
||||||
|
func (h *DebateHandler) HandleCancelDebate(c *gin.Context) {
|
||||||
|
debateID := c.Param("id")
|
||||||
|
userID := c.GetString("user_id")
|
||||||
|
|
||||||
|
session, err := h.debateStore.GetSession(debateID)
|
||||||
|
if err != nil {
|
||||||
|
c.JSON(http.StatusNotFound, gin.H{"error": "debate not found"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if session.UserID != userID {
|
||||||
|
c.JSON(http.StatusForbidden, gin.H{"error": "access denied"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := h.engine.CancelDebate(debateID); err != nil {
|
||||||
|
SafeInternalError(c, "Cancel debate", err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
c.JSON(http.StatusOK, gin.H{"message": "debate cancelled"})
|
||||||
|
}
|
||||||
|
|
||||||
|
// HandleDeleteDebate deletes a debate
|
||||||
|
func (h *DebateHandler) HandleDeleteDebate(c *gin.Context) {
|
||||||
|
debateID := c.Param("id")
|
||||||
|
userID := c.GetString("user_id")
|
||||||
|
|
||||||
|
session, err := h.debateStore.GetSession(debateID)
|
||||||
|
if err != nil {
|
||||||
|
c.JSON(http.StatusNotFound, gin.H{"error": "debate not found"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if session.UserID != userID {
|
||||||
|
c.JSON(http.StatusForbidden, gin.H{"error": "access denied"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Don't allow deleting running debates
|
||||||
|
if session.Status == store.DebateStatusRunning || session.Status == store.DebateStatusVoting {
|
||||||
|
c.JSON(http.StatusBadRequest, gin.H{"error": "cannot delete running debate"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := h.debateStore.DeleteSession(debateID); err != nil {
|
||||||
|
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to delete debate"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
c.JSON(http.StatusOK, gin.H{"message": "debate deleted"})
|
||||||
|
}
|
||||||
|
|
||||||
|
// HandleGetMessages gets all messages for a debate
|
||||||
|
func (h *DebateHandler) HandleGetMessages(c *gin.Context) {
|
||||||
|
debateID := c.Param("id")
|
||||||
|
userID := c.GetString("user_id")
|
||||||
|
|
||||||
|
session, err := h.debateStore.GetSession(debateID)
|
||||||
|
if err != nil {
|
||||||
|
c.JSON(http.StatusNotFound, gin.H{"error": "debate not found"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if session.UserID != userID {
|
||||||
|
c.JSON(http.StatusForbidden, gin.H{"error": "access denied"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
messages, err := h.debateStore.GetMessages(debateID)
|
||||||
|
if err != nil {
|
||||||
|
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to get messages"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
c.JSON(http.StatusOK, messages)
|
||||||
|
}
|
||||||
|
|
||||||
|
// HandleGetVotes gets all votes for a debate
|
||||||
|
func (h *DebateHandler) HandleGetVotes(c *gin.Context) {
|
||||||
|
debateID := c.Param("id")
|
||||||
|
userID := c.GetString("user_id")
|
||||||
|
|
||||||
|
session, err := h.debateStore.GetSession(debateID)
|
||||||
|
if err != nil {
|
||||||
|
c.JSON(http.StatusNotFound, gin.H{"error": "debate not found"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if session.UserID != userID {
|
||||||
|
c.JSON(http.StatusForbidden, gin.H{"error": "access denied"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
votes, err := h.debateStore.GetVotes(debateID)
|
||||||
|
if err != nil {
|
||||||
|
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to get votes"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
c.JSON(http.StatusOK, votes)
|
||||||
|
}
|
||||||
|
|
||||||
|
// HandleDebateStream handles SSE streaming for live debate updates
|
||||||
|
func (h *DebateHandler) HandleDebateStream(c *gin.Context) {
|
||||||
|
debateID := c.Param("id")
|
||||||
|
userID := c.GetString("user_id")
|
||||||
|
|
||||||
|
session, err := h.debateStore.GetSession(debateID)
|
||||||
|
if err != nil {
|
||||||
|
c.JSON(http.StatusNotFound, gin.H{"error": "debate not found"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if session.UserID != userID {
|
||||||
|
c.JSON(http.StatusForbidden, gin.H{"error": "access denied"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Set SSE headers
|
||||||
|
c.Header("Content-Type", "text/event-stream")
|
||||||
|
c.Header("Cache-Control", "no-cache")
|
||||||
|
c.Header("Connection", "keep-alive")
|
||||||
|
c.Header("Transfer-Encoding", "chunked")
|
||||||
|
|
||||||
|
// Create channel for this subscriber
|
||||||
|
ch := make(chan []byte, 100)
|
||||||
|
h.addSubscriber(debateID, ch)
|
||||||
|
defer h.removeSubscriber(debateID, ch)
|
||||||
|
|
||||||
|
// Send initial state
|
||||||
|
initialState, _ := h.debateStore.GetSessionWithDetails(debateID)
|
||||||
|
initialData, _ := json.Marshal(map[string]interface{}{
|
||||||
|
"event": "initial",
|
||||||
|
"data": initialState,
|
||||||
|
})
|
||||||
|
c.Writer.Write([]byte(fmt.Sprintf("event: initial\ndata: %s\n\n", initialData)))
|
||||||
|
c.Writer.Flush()
|
||||||
|
|
||||||
|
// Stream updates
|
||||||
|
clientGone := c.Request.Context().Done()
|
||||||
|
for {
|
||||||
|
select {
|
||||||
|
case <-clientGone:
|
||||||
|
return
|
||||||
|
case msg := <-ch:
|
||||||
|
c.Writer.Write(msg)
|
||||||
|
c.Writer.Flush()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// SetTraderManager sets the trader manager for executing trades
|
||||||
|
func (h *DebateHandler) SetTraderManager(tm DebateTraderManager) {
|
||||||
|
h.traderManager = tm
|
||||||
|
}
|
||||||
|
|
||||||
|
// ExecuteDebateRequest represents a request to execute a debate's consensus
|
||||||
|
type ExecuteDebateRequest struct {
|
||||||
|
TraderID string `json:"trader_id" binding:"required"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// HandleExecuteDebate executes the consensus decision from a completed debate
|
||||||
|
func (h *DebateHandler) HandleExecuteDebate(c *gin.Context) {
|
||||||
|
debateID := c.Param("id")
|
||||||
|
userID := c.GetString("user_id")
|
||||||
|
|
||||||
|
// Check trader manager is available
|
||||||
|
if h.traderManager == nil {
|
||||||
|
c.JSON(http.StatusServiceUnavailable, gin.H{"error": "trading service not available"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get debate session
|
||||||
|
session, err := h.debateStore.GetSession(debateID)
|
||||||
|
if err != nil {
|
||||||
|
c.JSON(http.StatusNotFound, gin.H{"error": "debate not found"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check ownership
|
||||||
|
if session.UserID != userID {
|
||||||
|
c.JSON(http.StatusForbidden, gin.H{"error": "access denied"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check status
|
||||||
|
if session.Status != store.DebateStatusCompleted {
|
||||||
|
c.JSON(http.StatusBadRequest, gin.H{"error": "debate is not completed"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Parse request
|
||||||
|
var req ExecuteDebateRequest
|
||||||
|
if err := c.ShouldBindJSON(&req); err != nil {
|
||||||
|
SafeBadRequest(c, "Invalid request parameters")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get trader executor
|
||||||
|
executor, err := h.traderManager.GetTraderExecutor(req.TraderID)
|
||||||
|
if err != nil {
|
||||||
|
SafeError(c, http.StatusBadRequest, "Trader not available", err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Execute consensus
|
||||||
|
if err := h.engine.ExecuteConsensus(debateID, executor); err != nil {
|
||||||
|
SafeInternalError(c, "Execute consensus", err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get updated session
|
||||||
|
updatedSession, _ := h.debateStore.GetSessionWithDetails(debateID)
|
||||||
|
|
||||||
|
c.JSON(http.StatusOK, gin.H{
|
||||||
|
"message": "consensus executed successfully",
|
||||||
|
"session": updatedSession,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetPersonalities returns available AI personalities
|
||||||
|
func (h *DebateHandler) HandleGetPersonalities(c *gin.Context) {
|
||||||
|
personalities := []map[string]interface{}{
|
||||||
|
{
|
||||||
|
"id": "bull",
|
||||||
|
"name": "Aggressive Bull",
|
||||||
|
"emoji": "🐂",
|
||||||
|
"color": store.PersonalityColors[store.PersonalityBull],
|
||||||
|
"description": "Looks for long opportunities, optimistic about market",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "bear",
|
||||||
|
"name": "Cautious Bear",
|
||||||
|
"emoji": "🐻",
|
||||||
|
"color": store.PersonalityColors[store.PersonalityBear],
|
||||||
|
"description": "Skeptical, focuses on risks and short opportunities",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "analyst",
|
||||||
|
"name": "Data Analyst",
|
||||||
|
"emoji": "📊",
|
||||||
|
"color": store.PersonalityColors[store.PersonalityAnalyst],
|
||||||
|
"description": "Pure technical analysis, neutral and data-driven",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "contrarian",
|
||||||
|
"name": "Contrarian",
|
||||||
|
"emoji": "🔄",
|
||||||
|
"color": store.PersonalityColors[store.PersonalityContrarian],
|
||||||
|
"description": "Challenges majority opinion, looks for overlooked opportunities",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "risk_manager",
|
||||||
|
"name": "Risk Manager",
|
||||||
|
"emoji": "🛡️",
|
||||||
|
"color": store.PersonalityColors[store.PersonalityRiskManager],
|
||||||
|
"description": "Focuses on position sizing, stop losses, and risk control",
|
||||||
|
},
|
||||||
|
}
|
||||||
|
c.JSON(http.StatusOK, personalities)
|
||||||
|
}
|
||||||
|
|
||||||
|
// SSE broadcast helpers
|
||||||
|
func (h *DebateHandler) addSubscriber(sessionID string, ch chan []byte) {
|
||||||
|
h.subscribersMu.Lock()
|
||||||
|
defer h.subscribersMu.Unlock()
|
||||||
|
|
||||||
|
if h.subscribers[sessionID] == nil {
|
||||||
|
h.subscribers[sessionID] = make(map[chan []byte]bool)
|
||||||
|
}
|
||||||
|
h.subscribers[sessionID][ch] = true
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h *DebateHandler) removeSubscriber(sessionID string, ch chan []byte) {
|
||||||
|
h.subscribersMu.Lock()
|
||||||
|
defer h.subscribersMu.Unlock()
|
||||||
|
|
||||||
|
if h.subscribers[sessionID] != nil {
|
||||||
|
delete(h.subscribers[sessionID], ch)
|
||||||
|
close(ch)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h *DebateHandler) broadcast(sessionID string, event string, data interface{}) {
|
||||||
|
h.subscribersMu.RLock()
|
||||||
|
defer h.subscribersMu.RUnlock()
|
||||||
|
|
||||||
|
subs := h.subscribers[sessionID]
|
||||||
|
if subs == nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
jsonData, err := json.Marshal(data)
|
||||||
|
if err != nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
msg := []byte(fmt.Sprintf("event: %s\ndata: %s\n\n", event, jsonData))
|
||||||
|
for ch := range subs {
|
||||||
|
select {
|
||||||
|
case ch <- msg:
|
||||||
|
default:
|
||||||
|
// Channel full, skip
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h *DebateHandler) broadcastRoundStart(sessionID string, round int) {
|
||||||
|
h.broadcast(sessionID, "round_start", map[string]interface{}{
|
||||||
|
"round": round,
|
||||||
|
"status": "running",
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h *DebateHandler) broadcastMessage(sessionID string, msg *store.DebateMessage) {
|
||||||
|
h.broadcast(sessionID, "message", msg)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h *DebateHandler) broadcastRoundEnd(sessionID string, round int) {
|
||||||
|
h.broadcast(sessionID, "round_end", map[string]interface{}{
|
||||||
|
"round": round,
|
||||||
|
"status": "completed",
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h *DebateHandler) broadcastVote(sessionID string, vote *store.DebateVote) {
|
||||||
|
h.broadcast(sessionID, "vote", vote)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h *DebateHandler) broadcastConsensus(sessionID string, decision *store.DebateDecision) {
|
||||||
|
h.broadcast(sessionID, "consensus", decision)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h *DebateHandler) broadcastError(sessionID string, err error) {
|
||||||
|
// Sanitize error message before broadcasting to client
|
||||||
|
safeMsg := SanitizeError(err, "An error occurred during debate")
|
||||||
|
h.broadcast(sessionID, "error", map[string]interface{}{
|
||||||
|
"error": safeMsg,
|
||||||
|
})
|
||||||
|
}
|
||||||
@@ -8,25 +8,6 @@ import (
|
|||||||
"nofx/logger"
|
"nofx/logger"
|
||||||
)
|
)
|
||||||
|
|
||||||
type APIErrorResponse struct {
|
|
||||||
Error string `json:"error"`
|
|
||||||
ErrorKey string `json:"error_key,omitempty"`
|
|
||||||
ErrorParams map[string]string `json:"error_params,omitempty"`
|
|
||||||
}
|
|
||||||
|
|
||||||
func writeAPIError(c *gin.Context, statusCode int, publicMsg, errorKey string, errorParams map[string]string) {
|
|
||||||
resp := APIErrorResponse{
|
|
||||||
Error: publicMsg,
|
|
||||||
}
|
|
||||||
if errorKey != "" {
|
|
||||||
resp.ErrorKey = errorKey
|
|
||||||
}
|
|
||||||
if len(errorParams) > 0 {
|
|
||||||
resp.ErrorParams = errorParams
|
|
||||||
}
|
|
||||||
c.JSON(statusCode, resp)
|
|
||||||
}
|
|
||||||
|
|
||||||
// SafeError returns a safe error message without exposing internal details
|
// SafeError returns a safe error message without exposing internal details
|
||||||
// It logs the actual error for debugging but returns a generic message to the client
|
// It logs the actual error for debugging but returns a generic message to the client
|
||||||
func SafeError(c *gin.Context, statusCode int, publicMsg string, internalErr error) {
|
func SafeError(c *gin.Context, statusCode int, publicMsg string, internalErr error) {
|
||||||
@@ -35,46 +16,34 @@ func SafeError(c *gin.Context, statusCode int, publicMsg string, internalErr err
|
|||||||
logger.Errorf("[API Error] %s: %v", publicMsg, internalErr)
|
logger.Errorf("[API Error] %s: %v", publicMsg, internalErr)
|
||||||
}
|
}
|
||||||
|
|
||||||
writeAPIError(c, statusCode, publicMsg, "", nil)
|
c.JSON(statusCode, gin.H{"error": publicMsg})
|
||||||
}
|
|
||||||
|
|
||||||
func SafeErrorWithDetails(c *gin.Context, statusCode int, publicMsg, errorKey string, errorParams map[string]string, internalErr error) {
|
|
||||||
if internalErr != nil {
|
|
||||||
logger.Errorf("[API Error] %s: %v", publicMsg, internalErr)
|
|
||||||
}
|
|
||||||
|
|
||||||
writeAPIError(c, statusCode, publicMsg, errorKey, errorParams)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// SafeInternalError logs internal error and returns a generic message
|
// SafeInternalError logs internal error and returns a generic message
|
||||||
func SafeInternalError(c *gin.Context, operation string, err error) {
|
func SafeInternalError(c *gin.Context, operation string, err error) {
|
||||||
logger.Errorf("[Internal Error] %s: %v", operation, err)
|
logger.Errorf("[Internal Error] %s: %v", operation, err)
|
||||||
writeAPIError(c, http.StatusInternalServerError, operation+" failed", "", nil)
|
c.JSON(http.StatusInternalServerError, gin.H{"error": operation + " failed"})
|
||||||
}
|
}
|
||||||
|
|
||||||
// SafeBadRequest returns a safe bad request error
|
// SafeBadRequest returns a safe bad request error
|
||||||
// For validation errors, we can be more specific since they're about user input
|
// For validation errors, we can be more specific since they're about user input
|
||||||
func SafeBadRequest(c *gin.Context, msg string) {
|
func SafeBadRequest(c *gin.Context, msg string) {
|
||||||
writeAPIError(c, http.StatusBadRequest, msg, "", nil)
|
c.JSON(http.StatusBadRequest, gin.H{"error": msg})
|
||||||
}
|
|
||||||
|
|
||||||
func SafeBadRequestWithDetails(c *gin.Context, msg, errorKey string, errorParams map[string]string) {
|
|
||||||
writeAPIError(c, http.StatusBadRequest, msg, errorKey, errorParams)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// SafeNotFound returns a generic not found error
|
// SafeNotFound returns a generic not found error
|
||||||
func SafeNotFound(c *gin.Context, resource string) {
|
func SafeNotFound(c *gin.Context, resource string) {
|
||||||
writeAPIError(c, http.StatusNotFound, resource+" not found", "", nil)
|
c.JSON(http.StatusNotFound, gin.H{"error": resource + " not found"})
|
||||||
}
|
}
|
||||||
|
|
||||||
// SafeUnauthorized returns unauthorized error
|
// SafeUnauthorized returns unauthorized error
|
||||||
func SafeUnauthorized(c *gin.Context) {
|
func SafeUnauthorized(c *gin.Context) {
|
||||||
writeAPIError(c, http.StatusUnauthorized, "Unauthorized", "", nil)
|
c.JSON(http.StatusUnauthorized, gin.H{"error": "Unauthorized"})
|
||||||
}
|
}
|
||||||
|
|
||||||
// SafeForbidden returns forbidden error
|
// SafeForbidden returns forbidden error
|
||||||
func SafeForbidden(c *gin.Context, msg string) {
|
func SafeForbidden(c *gin.Context, msg string) {
|
||||||
writeAPIError(c, http.StatusForbidden, msg, "", nil)
|
c.JSON(http.StatusForbidden, gin.H{"error": msg})
|
||||||
}
|
}
|
||||||
|
|
||||||
// IsSensitiveError checks if an error message contains sensitive information
|
// IsSensitiveError checks if an error message contains sensitive information
|
||||||
|
|||||||
@@ -1,375 +0,0 @@
|
|||||||
package api
|
|
||||||
|
|
||||||
import (
|
|
||||||
"fmt"
|
|
||||||
"net/http"
|
|
||||||
"strconv"
|
|
||||||
"strings"
|
|
||||||
"sync"
|
|
||||||
"time"
|
|
||||||
|
|
||||||
"nofx/logger"
|
|
||||||
"nofx/store"
|
|
||||||
"nofx/trader"
|
|
||||||
"nofx/trader/aster"
|
|
||||||
"nofx/trader/binance"
|
|
||||||
"nofx/trader/bitget"
|
|
||||||
"nofx/trader/bybit"
|
|
||||||
"nofx/trader/gate"
|
|
||||||
hyperliquidtrader "nofx/trader/hyperliquid"
|
|
||||||
"nofx/trader/indodax"
|
|
||||||
"nofx/trader/kucoin"
|
|
||||||
"nofx/trader/lighter"
|
|
||||||
"nofx/trader/okx"
|
|
||||||
|
|
||||||
"github.com/gin-gonic/gin"
|
|
||||||
)
|
|
||||||
|
|
||||||
const exchangeAccountStateCacheTTL = 30 * time.Second
|
|
||||||
|
|
||||||
const (
|
|
||||||
exchangeAccountStatusOK = "ok"
|
|
||||||
exchangeAccountStatusDisabled = "disabled"
|
|
||||||
exchangeAccountStatusMissingCredentials = "missing_credentials"
|
|
||||||
exchangeAccountStatusInvalidCredentials = "invalid_credentials"
|
|
||||||
exchangeAccountStatusPermissionDenied = "permission_denied"
|
|
||||||
exchangeAccountStatusUnavailable = "unavailable"
|
|
||||||
)
|
|
||||||
|
|
||||||
type ExchangeAccountState struct {
|
|
||||||
ExchangeID string `json:"exchange_id"`
|
|
||||||
Status string `json:"status"`
|
|
||||||
DisplayBalance string `json:"display_balance,omitempty"`
|
|
||||||
Asset string `json:"asset,omitempty"`
|
|
||||||
TotalEquity float64 `json:"total_equity,omitempty"`
|
|
||||||
AvailableBalance float64 `json:"available_balance,omitempty"`
|
|
||||||
CheckedAt time.Time `json:"checked_at"`
|
|
||||||
ErrorCode string `json:"error_code,omitempty"`
|
|
||||||
ErrorMessage string `json:"error_message,omitempty"`
|
|
||||||
}
|
|
||||||
|
|
||||||
type cachedExchangeAccountStates struct {
|
|
||||||
states map[string]ExchangeAccountState
|
|
||||||
cachedAt time.Time
|
|
||||||
}
|
|
||||||
|
|
||||||
type ExchangeAccountStateCache struct {
|
|
||||||
entries map[string]cachedExchangeAccountStates
|
|
||||||
mu sync.RWMutex
|
|
||||||
}
|
|
||||||
|
|
||||||
func NewExchangeAccountStateCache() *ExchangeAccountStateCache {
|
|
||||||
return &ExchangeAccountStateCache{
|
|
||||||
entries: make(map[string]cachedExchangeAccountStates),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func (c *ExchangeAccountStateCache) Get(userID string) (map[string]ExchangeAccountState, bool) {
|
|
||||||
c.mu.RLock()
|
|
||||||
entry, ok := c.entries[userID]
|
|
||||||
c.mu.RUnlock()
|
|
||||||
if !ok || time.Since(entry.cachedAt) >= exchangeAccountStateCacheTTL {
|
|
||||||
return nil, false
|
|
||||||
}
|
|
||||||
return cloneExchangeAccountStates(entry.states), true
|
|
||||||
}
|
|
||||||
|
|
||||||
func (c *ExchangeAccountStateCache) Set(userID string, states map[string]ExchangeAccountState) {
|
|
||||||
c.mu.Lock()
|
|
||||||
c.entries[userID] = cachedExchangeAccountStates{
|
|
||||||
states: cloneExchangeAccountStates(states),
|
|
||||||
cachedAt: time.Now(),
|
|
||||||
}
|
|
||||||
c.mu.Unlock()
|
|
||||||
}
|
|
||||||
|
|
||||||
func (c *ExchangeAccountStateCache) Invalidate(userID string) {
|
|
||||||
c.mu.Lock()
|
|
||||||
delete(c.entries, userID)
|
|
||||||
c.mu.Unlock()
|
|
||||||
}
|
|
||||||
|
|
||||||
func cloneExchangeAccountStates(states map[string]ExchangeAccountState) map[string]ExchangeAccountState {
|
|
||||||
cloned := make(map[string]ExchangeAccountState, len(states))
|
|
||||||
for id, state := range states {
|
|
||||||
cloned[id] = state
|
|
||||||
}
|
|
||||||
return cloned
|
|
||||||
}
|
|
||||||
|
|
||||||
func (s *Server) handleGetExchangeAccountStates(c *gin.Context) {
|
|
||||||
userID := c.GetString("user_id")
|
|
||||||
|
|
||||||
states, err := s.getExchangeAccountStates(userID)
|
|
||||||
if err != nil {
|
|
||||||
SafeInternalError(c, "Failed to get exchange account states", err)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
c.JSON(http.StatusOK, gin.H{"states": states})
|
|
||||||
}
|
|
||||||
|
|
||||||
func (s *Server) getExchangeAccountStates(userID string) (map[string]ExchangeAccountState, error) {
|
|
||||||
if cached, ok := s.exchangeAccountStateCache.Get(userID); ok {
|
|
||||||
return cached, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
exchanges, err := s.store.Exchange().List(userID)
|
|
||||||
if err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
|
|
||||||
states := make(map[string]ExchangeAccountState, len(exchanges))
|
|
||||||
if len(exchanges) == 0 {
|
|
||||||
return states, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
var wg sync.WaitGroup
|
|
||||||
var mu sync.Mutex
|
|
||||||
|
|
||||||
for _, exchangeCfg := range exchanges {
|
|
||||||
exchangeCfg := exchangeCfg
|
|
||||||
wg.Add(1)
|
|
||||||
go func() {
|
|
||||||
defer wg.Done()
|
|
||||||
state := probeExchangeAccountState(exchangeCfg, userID)
|
|
||||||
mu.Lock()
|
|
||||||
states[exchangeCfg.ID] = state
|
|
||||||
mu.Unlock()
|
|
||||||
}()
|
|
||||||
}
|
|
||||||
|
|
||||||
wg.Wait()
|
|
||||||
s.exchangeAccountStateCache.Set(userID, states)
|
|
||||||
|
|
||||||
return cloneExchangeAccountStates(states), nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func probeExchangeAccountState(exchangeCfg *store.Exchange, userID string) ExchangeAccountState {
|
|
||||||
state := ExchangeAccountState{
|
|
||||||
ExchangeID: exchangeCfg.ID,
|
|
||||||
CheckedAt: time.Now().UTC(),
|
|
||||||
Asset: accountAssetForExchange(exchangeCfg.ExchangeType),
|
|
||||||
}
|
|
||||||
|
|
||||||
if !exchangeCfg.Enabled {
|
|
||||||
state.Status = exchangeAccountStatusDisabled
|
|
||||||
state.ErrorCode = "EXCHANGE_DISABLED"
|
|
||||||
state.ErrorMessage = "Exchange account is disabled"
|
|
||||||
return state
|
|
||||||
}
|
|
||||||
|
|
||||||
if status, code, message, missing := missingExchangeCredentials(exchangeCfg); missing {
|
|
||||||
state.Status = status
|
|
||||||
state.ErrorCode = code
|
|
||||||
state.ErrorMessage = message
|
|
||||||
return state
|
|
||||||
}
|
|
||||||
|
|
||||||
tempTrader, err := buildExchangeProbeTrader(exchangeCfg, userID)
|
|
||||||
if err != nil {
|
|
||||||
status, code, message := classifyExchangeProbeError(err)
|
|
||||||
state.Status = status
|
|
||||||
state.ErrorCode = code
|
|
||||||
state.ErrorMessage = message
|
|
||||||
return state
|
|
||||||
}
|
|
||||||
|
|
||||||
balanceInfo, err := tempTrader.GetBalance()
|
|
||||||
if err != nil {
|
|
||||||
status, code, message := classifyExchangeProbeError(err)
|
|
||||||
state.Status = status
|
|
||||||
state.ErrorCode = code
|
|
||||||
state.ErrorMessage = message
|
|
||||||
logger.Infof("⚠️ Failed to probe exchange account %s (%s): %v", exchangeCfg.ID, exchangeCfg.ExchangeType, err)
|
|
||||||
return state
|
|
||||||
}
|
|
||||||
|
|
||||||
totalEquity, totalFound := extractFirstNumeric(balanceInfo,
|
|
||||||
"total_equity", "totalEquity", "totalWalletBalance", "wallet_balance", "totalEq", "balance")
|
|
||||||
availableBalance, availableFound := extractFirstNumeric(balanceInfo,
|
|
||||||
"available_balance", "availableBalance", "available")
|
|
||||||
|
|
||||||
if !totalFound && availableFound {
|
|
||||||
totalEquity = availableBalance
|
|
||||||
totalFound = true
|
|
||||||
}
|
|
||||||
|
|
||||||
if !availableFound && totalFound {
|
|
||||||
availableBalance = totalEquity
|
|
||||||
availableFound = true
|
|
||||||
}
|
|
||||||
|
|
||||||
if !totalFound && !availableFound {
|
|
||||||
state.Status = exchangeAccountStatusUnavailable
|
|
||||||
state.ErrorCode = "BALANCE_NOT_FOUND"
|
|
||||||
state.ErrorMessage = "Connected but no balance fields were returned"
|
|
||||||
return state
|
|
||||||
}
|
|
||||||
|
|
||||||
state.Status = exchangeAccountStatusOK
|
|
||||||
if totalFound {
|
|
||||||
state.TotalEquity = totalEquity
|
|
||||||
state.DisplayBalance = formatDisplayBalance(totalEquity, state.Asset)
|
|
||||||
}
|
|
||||||
if availableFound {
|
|
||||||
state.AvailableBalance = availableBalance
|
|
||||||
if state.DisplayBalance == "" {
|
|
||||||
state.DisplayBalance = formatDisplayBalance(availableBalance, state.Asset)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return state
|
|
||||||
}
|
|
||||||
|
|
||||||
func buildExchangeProbeTrader(exchangeCfg *store.Exchange, userID string) (trader.Trader, error) {
|
|
||||||
switch exchangeCfg.ExchangeType {
|
|
||||||
case "binance":
|
|
||||||
return binance.NewFuturesTrader(string(exchangeCfg.APIKey), string(exchangeCfg.SecretKey), userID), nil
|
|
||||||
case "bybit":
|
|
||||||
return bybit.NewBybitTrader(string(exchangeCfg.APIKey), string(exchangeCfg.SecretKey)), nil
|
|
||||||
case "okx":
|
|
||||||
return okx.NewOKXTrader(string(exchangeCfg.APIKey), string(exchangeCfg.SecretKey), string(exchangeCfg.Passphrase)), nil
|
|
||||||
case "bitget":
|
|
||||||
return bitget.NewBitgetTrader(string(exchangeCfg.APIKey), string(exchangeCfg.SecretKey), string(exchangeCfg.Passphrase)), nil
|
|
||||||
case "gate":
|
|
||||||
return gate.NewGateTrader(string(exchangeCfg.APIKey), string(exchangeCfg.SecretKey)), nil
|
|
||||||
case "kucoin":
|
|
||||||
return kucoin.NewKuCoinTrader(string(exchangeCfg.APIKey), string(exchangeCfg.SecretKey), string(exchangeCfg.Passphrase)), nil
|
|
||||||
case "indodax":
|
|
||||||
return indodax.NewIndodaxTrader(string(exchangeCfg.APIKey), string(exchangeCfg.SecretKey)), nil
|
|
||||||
case "hyperliquid":
|
|
||||||
return hyperliquidtrader.NewHyperliquidTrader(
|
|
||||||
string(exchangeCfg.APIKey),
|
|
||||||
exchangeCfg.HyperliquidWalletAddr,
|
|
||||||
exchangeCfg.Testnet,
|
|
||||||
exchangeCfg.HyperliquidUnifiedAcct,
|
|
||||||
)
|
|
||||||
case "aster":
|
|
||||||
return aster.NewAsterTrader(
|
|
||||||
exchangeCfg.AsterUser,
|
|
||||||
exchangeCfg.AsterSigner,
|
|
||||||
string(exchangeCfg.AsterPrivateKey),
|
|
||||||
)
|
|
||||||
case "lighter":
|
|
||||||
return lighter.NewLighterTraderV2(
|
|
||||||
exchangeCfg.LighterWalletAddr,
|
|
||||||
string(exchangeCfg.LighterAPIKeyPrivateKey),
|
|
||||||
exchangeCfg.LighterAPIKeyIndex,
|
|
||||||
false,
|
|
||||||
)
|
|
||||||
default:
|
|
||||||
return nil, fmt.Errorf("unsupported exchange type: %s", exchangeCfg.ExchangeType)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func extractExchangeTotalEquity(balanceInfo map[string]interface{}) (float64, bool) {
|
|
||||||
return extractFirstNumeric(balanceInfo,
|
|
||||||
"total_equity", "totalEquity", "totalWalletBalance", "wallet_balance", "totalEq", "balance")
|
|
||||||
}
|
|
||||||
|
|
||||||
func extractFirstNumeric(values map[string]interface{}, keys ...string) (float64, bool) {
|
|
||||||
for _, key := range keys {
|
|
||||||
raw, ok := values[key]
|
|
||||||
if !ok {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
|
|
||||||
switch v := raw.(type) {
|
|
||||||
case float64:
|
|
||||||
return v, true
|
|
||||||
case float32:
|
|
||||||
return float64(v), true
|
|
||||||
case int:
|
|
||||||
return float64(v), true
|
|
||||||
case int64:
|
|
||||||
return float64(v), true
|
|
||||||
case int32:
|
|
||||||
return float64(v), true
|
|
||||||
case string:
|
|
||||||
parsed, err := strconv.ParseFloat(v, 64)
|
|
||||||
if err == nil {
|
|
||||||
return parsed, true
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return 0, false
|
|
||||||
}
|
|
||||||
|
|
||||||
func formatDisplayBalance(value float64, asset string) string {
|
|
||||||
formatted := strconv.FormatFloat(value, 'f', 4, 64)
|
|
||||||
formatted = strings.TrimRight(strings.TrimRight(formatted, "0"), ".")
|
|
||||||
if formatted == "" {
|
|
||||||
formatted = "0"
|
|
||||||
}
|
|
||||||
if asset == "" {
|
|
||||||
return formatted
|
|
||||||
}
|
|
||||||
return fmt.Sprintf("%s %s", formatted, asset)
|
|
||||||
}
|
|
||||||
|
|
||||||
func accountAssetForExchange(exchangeType string) string {
|
|
||||||
switch exchangeType {
|
|
||||||
case "hyperliquid", "aster", "lighter":
|
|
||||||
return "USDC"
|
|
||||||
default:
|
|
||||||
return "USDT"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func missingExchangeCredentials(exchangeCfg *store.Exchange) (status string, code string, message string, missing bool) {
|
|
||||||
missingFields := store.MissingRequiredExchangeCredentialFields(
|
|
||||||
exchangeCfg.ExchangeType,
|
|
||||||
string(exchangeCfg.APIKey),
|
|
||||||
string(exchangeCfg.SecretKey),
|
|
||||||
string(exchangeCfg.Passphrase),
|
|
||||||
exchangeCfg.HyperliquidWalletAddr,
|
|
||||||
exchangeCfg.AsterUser,
|
|
||||||
exchangeCfg.AsterSigner,
|
|
||||||
string(exchangeCfg.AsterPrivateKey),
|
|
||||||
exchangeCfg.LighterWalletAddr,
|
|
||||||
string(exchangeCfg.LighterAPIKeyPrivateKey),
|
|
||||||
)
|
|
||||||
if len(missingFields) > 0 {
|
|
||||||
if len(missingFields) == 1 && missingFields[0] == "exchange_type" {
|
|
||||||
return exchangeAccountStatusUnavailable, "UNSUPPORTED_EXCHANGE", "Unsupported exchange type", true
|
|
||||||
}
|
|
||||||
return exchangeAccountStatusMissingCredentials, "MISSING_REQUIRED_FIELDS", "Missing required fields: " + strings.Join(missingFields, ", "), true
|
|
||||||
}
|
|
||||||
|
|
||||||
return "", "", "", false
|
|
||||||
}
|
|
||||||
|
|
||||||
func classifyExchangeProbeError(err error) (status string, code string, message string) {
|
|
||||||
if err == nil {
|
|
||||||
return exchangeAccountStatusOK, "", ""
|
|
||||||
}
|
|
||||||
|
|
||||||
rawMessage := err.Error()
|
|
||||||
msg := strings.ToLower(rawMessage)
|
|
||||||
|
|
||||||
switch {
|
|
||||||
case strings.Contains(msg, "unsupported exchange type"):
|
|
||||||
return exchangeAccountStatusUnavailable, "UNSUPPORTED_EXCHANGE", "Unsupported exchange type"
|
|
||||||
case strings.Contains(msg, "requires ") || strings.Contains(msg, "missing") || strings.Contains(msg, "empty"):
|
|
||||||
return exchangeAccountStatusMissingCredentials, "MISSING_REQUIRED_FIELDS", "Exchange credentials are incomplete"
|
|
||||||
case strings.Contains(msg, "permission") || strings.Contains(msg, "forbidden") || strings.Contains(msg, "no authority") || strings.Contains(msg, "not allowed"):
|
|
||||||
return exchangeAccountStatusPermissionDenied, "PERMISSION_DENIED", "Exchange account has no permission to read balances"
|
|
||||||
case strings.Contains(msg, "invalid") || strings.Contains(msg, "signature") || strings.Contains(msg, "unauthorized") || strings.Contains(msg, "api key") || strings.Contains(msg, "api-key") || strings.Contains(msg, "auth"):
|
|
||||||
return exchangeAccountStatusInvalidCredentials, "INVALID_CREDENTIALS", "Exchange credentials are invalid"
|
|
||||||
default:
|
|
||||||
return exchangeAccountStatusUnavailable, "EXCHANGE_UNAVAILABLE", limitErrorMessage(rawMessage)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func limitErrorMessage(message string) string {
|
|
||||||
message = strings.TrimSpace(message)
|
|
||||||
if message == "" {
|
|
||||||
return "Unable to fetch exchange balance right now"
|
|
||||||
}
|
|
||||||
if len(message) <= 160 {
|
|
||||||
return message
|
|
||||||
}
|
|
||||||
return message[:157] + "..."
|
|
||||||
}
|
|
||||||
@@ -1,43 +0,0 @@
|
|||||||
package api
|
|
||||||
|
|
||||||
import (
|
|
||||||
"net/http"
|
|
||||||
|
|
||||||
"github.com/gin-gonic/gin"
|
|
||||||
)
|
|
||||||
|
|
||||||
// handleGetAICosts returns AI charges for a specific trader
|
|
||||||
func (s *Server) handleGetAICosts(c *gin.Context) {
|
|
||||||
traderID := c.Query("trader_id")
|
|
||||||
period := c.DefaultQuery("period", "today")
|
|
||||||
|
|
||||||
if traderID == "" {
|
|
||||||
c.JSON(http.StatusBadRequest, gin.H{"error": "trader_id is required"})
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
charges, total, err := s.store.AICharge().GetCharges(traderID, period)
|
|
||||||
if err != nil {
|
|
||||||
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
c.JSON(http.StatusOK, gin.H{
|
|
||||||
"charges": charges,
|
|
||||||
"total": total,
|
|
||||||
"count": len(charges),
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
// handleGetAICostsSummary returns AI cost summary across all traders
|
|
||||||
func (s *Server) handleGetAICostsSummary(c *gin.Context) {
|
|
||||||
period := c.DefaultQuery("period", "today")
|
|
||||||
|
|
||||||
total, count, byModel := s.store.AICharge().GetSummary(period)
|
|
||||||
|
|
||||||
c.JSON(http.StatusOK, gin.H{
|
|
||||||
"total": total,
|
|
||||||
"count": count,
|
|
||||||
"by_model": byModel,
|
|
||||||
})
|
|
||||||
}
|
|
||||||
@@ -1,256 +0,0 @@
|
|||||||
package api
|
|
||||||
|
|
||||||
import (
|
|
||||||
"encoding/json"
|
|
||||||
"fmt"
|
|
||||||
"net/http"
|
|
||||||
"strings"
|
|
||||||
|
|
||||||
"nofx/config"
|
|
||||||
"nofx/crypto"
|
|
||||||
"nofx/logger"
|
|
||||||
"nofx/security"
|
|
||||||
"nofx/store"
|
|
||||||
"nofx/wallet"
|
|
||||||
|
|
||||||
"github.com/gin-gonic/gin"
|
|
||||||
)
|
|
||||||
|
|
||||||
type ModelConfig struct {
|
|
||||||
ID string `json:"id"`
|
|
||||||
Name string `json:"name"`
|
|
||||||
Provider string `json:"provider"`
|
|
||||||
Enabled bool `json:"enabled"`
|
|
||||||
APIKey string `json:"apiKey,omitempty"`
|
|
||||||
CustomAPIURL string `json:"customApiUrl,omitempty"`
|
|
||||||
}
|
|
||||||
|
|
||||||
// SafeModelConfig Safe model configuration structure (does not contain sensitive information)
|
|
||||||
type SafeModelConfig struct {
|
|
||||||
ID string `json:"id"`
|
|
||||||
Name string `json:"name"`
|
|
||||||
Provider string `json:"provider"`
|
|
||||||
Enabled bool `json:"enabled"`
|
|
||||||
HasAPIKey bool `json:"has_api_key"`
|
|
||||||
CustomAPIURL string `json:"customApiUrl"` // Custom API URL (usually not sensitive)
|
|
||||||
CustomModelName string `json:"customModelName"` // Custom model name (not sensitive)
|
|
||||||
WalletAddress string `json:"walletAddress,omitempty"`
|
|
||||||
BalanceUSDC string `json:"balanceUsdc,omitempty"`
|
|
||||||
}
|
|
||||||
|
|
||||||
// ModelConfigUpdate is a single model's update payload. It is a named type
|
|
||||||
// (rather than an inline anonymous struct) so the log-sanitizer in utils.go is
|
|
||||||
// guaranteed to stay in sync with this shape — a mismatch there is what let
|
|
||||||
// plaintext credentials reach the logs previously.
|
|
||||||
type ModelConfigUpdate struct {
|
|
||||||
Enabled bool `json:"enabled"`
|
|
||||||
APIKey string `json:"api_key"`
|
|
||||||
CustomAPIURL string `json:"custom_api_url"`
|
|
||||||
CustomModelName string `json:"custom_model_name"`
|
|
||||||
}
|
|
||||||
|
|
||||||
type UpdateModelConfigRequest struct {
|
|
||||||
Models map[string]ModelConfigUpdate `json:"models"`
|
|
||||||
}
|
|
||||||
|
|
||||||
// handleGetModelConfigs Get AI model configurations
|
|
||||||
func (s *Server) handleGetModelConfigs(c *gin.Context) {
|
|
||||||
userID := c.GetString("user_id")
|
|
||||||
logger.Infof("🔍 Querying AI model configs for user %s", userID)
|
|
||||||
models, err := s.store.AIModel().List(userID)
|
|
||||||
if err != nil {
|
|
||||||
logger.Infof("❌ Failed to get AI model configs: %v", err)
|
|
||||||
SafeInternalError(c, "Failed to get AI model configs", err)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
// If no models in database, return default models
|
|
||||||
if len(models) == 0 {
|
|
||||||
logger.Infof("⚠️ No AI models in database, returning defaults")
|
|
||||||
defaultModels := []SafeModelConfig{
|
|
||||||
{ID: "deepseek", Name: "DeepSeek AI", Provider: "deepseek", Enabled: false, HasAPIKey: false},
|
|
||||||
{ID: "qwen", Name: "Qwen AI", Provider: "qwen", Enabled: false, HasAPIKey: false},
|
|
||||||
{ID: "openai", Name: "OpenAI", Provider: "openai", Enabled: false, HasAPIKey: false},
|
|
||||||
{ID: "claude", Name: "Claude AI", Provider: "claude", Enabled: false, HasAPIKey: false},
|
|
||||||
{ID: "gemini", Name: "Gemini AI", Provider: "gemini", Enabled: false, HasAPIKey: false},
|
|
||||||
{ID: "grok", Name: "Grok AI", Provider: "grok", Enabled: false, HasAPIKey: false},
|
|
||||||
{ID: "kimi", Name: "Kimi AI", Provider: "kimi", Enabled: false, HasAPIKey: false},
|
|
||||||
{ID: "minimax", Name: "MiniMax AI", Provider: "minimax", Enabled: false, HasAPIKey: false},
|
|
||||||
}
|
|
||||||
c.JSON(http.StatusOK, defaultModels)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
logger.Infof("✅ Found %d AI model configs", len(models))
|
|
||||||
|
|
||||||
// Convert to safe response structure, remove sensitive information
|
|
||||||
safeModels := make([]SafeModelConfig, 0, len(models))
|
|
||||||
for _, model := range models {
|
|
||||||
if !store.IsVisibleAIModel(model) {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
safeModel := SafeModelConfig{
|
|
||||||
ID: model.ID,
|
|
||||||
Name: model.Name,
|
|
||||||
Provider: model.Provider,
|
|
||||||
Enabled: model.Enabled,
|
|
||||||
HasAPIKey: model.APIKey != "",
|
|
||||||
CustomAPIURL: model.CustomAPIURL,
|
|
||||||
CustomModelName: model.CustomModelName,
|
|
||||||
}
|
|
||||||
|
|
||||||
if model.Provider == "claw402" {
|
|
||||||
if privateKey := strings.TrimSpace(model.APIKey.String()); privateKey != "" {
|
|
||||||
if walletAddress, addrErr := walletAddressFromPrivateKey(privateKey); addrErr == nil {
|
|
||||||
safeModel.WalletAddress = walletAddress
|
|
||||||
safeModel.BalanceUSDC = wallet.QueryUSDCBalanceStr(walletAddress)
|
|
||||||
} else {
|
|
||||||
logger.Warnf("⚠️ Failed to derive claw402 wallet address for model %s: %v", model.ID, addrErr)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
safeModels = append(safeModels, safeModel)
|
|
||||||
}
|
|
||||||
|
|
||||||
if len(safeModels) == 0 {
|
|
||||||
logger.Infof("⚠️ No visible AI models in database, returning defaults")
|
|
||||||
defaultModels := []SafeModelConfig{
|
|
||||||
{ID: "deepseek", Name: "DeepSeek AI", Provider: "deepseek", Enabled: false, HasAPIKey: false},
|
|
||||||
{ID: "qwen", Name: "Qwen AI", Provider: "qwen", Enabled: false, HasAPIKey: false},
|
|
||||||
{ID: "openai", Name: "OpenAI", Provider: "openai", Enabled: false, HasAPIKey: false},
|
|
||||||
{ID: "claude", Name: "Claude AI", Provider: "claude", Enabled: false, HasAPIKey: false},
|
|
||||||
{ID: "gemini", Name: "Gemini AI", Provider: "gemini", Enabled: false, HasAPIKey: false},
|
|
||||||
{ID: "grok", Name: "Grok AI", Provider: "grok", Enabled: false, HasAPIKey: false},
|
|
||||||
{ID: "kimi", Name: "Kimi AI", Provider: "kimi", Enabled: false, HasAPIKey: false},
|
|
||||||
{ID: "minimax", Name: "MiniMax AI", Provider: "minimax", Enabled: false, HasAPIKey: false},
|
|
||||||
}
|
|
||||||
c.JSON(http.StatusOK, defaultModels)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
c.JSON(http.StatusOK, safeModels)
|
|
||||||
}
|
|
||||||
|
|
||||||
// handleUpdateModelConfigs Update AI model configurations (supports both encrypted and plain text based on config)
|
|
||||||
func (s *Server) handleUpdateModelConfigs(c *gin.Context) {
|
|
||||||
userID := c.GetString("user_id")
|
|
||||||
cfg := config.Get()
|
|
||||||
|
|
||||||
// Read raw request body
|
|
||||||
bodyBytes, err := c.GetRawData()
|
|
||||||
if err != nil {
|
|
||||||
c.JSON(http.StatusBadRequest, gin.H{"error": "Failed to read request body"})
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
var req UpdateModelConfigRequest
|
|
||||||
|
|
||||||
// Check if transport encryption is enabled
|
|
||||||
if !cfg.TransportEncryption {
|
|
||||||
// Transport encryption disabled, accept plain JSON
|
|
||||||
if err := json.Unmarshal(bodyBytes, &req); err != nil {
|
|
||||||
logger.Infof("❌ Failed to parse plain JSON request: %v", err)
|
|
||||||
c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid request format"})
|
|
||||||
return
|
|
||||||
}
|
|
||||||
logger.Infof("📝 Received plain text model config (UserID: %s)", userID)
|
|
||||||
} else {
|
|
||||||
// Transport encryption enabled, require encrypted payload
|
|
||||||
var encryptedPayload crypto.EncryptedPayload
|
|
||||||
if err := json.Unmarshal(bodyBytes, &encryptedPayload); err != nil {
|
|
||||||
logger.Infof("❌ Failed to parse encrypted payload: %v", err)
|
|
||||||
c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid request format, encrypted transmission required"})
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
// Verify encrypted data
|
|
||||||
if encryptedPayload.WrappedKey == "" {
|
|
||||||
logger.Infof("❌ Detected unencrypted request (UserID: %s)", userID)
|
|
||||||
c.JSON(http.StatusBadRequest, gin.H{
|
|
||||||
"error": "This endpoint only supports encrypted transmission, please use encrypted client",
|
|
||||||
"code": "ENCRYPTION_REQUIRED",
|
|
||||||
"message": "Encrypted transmission is required for security reasons",
|
|
||||||
})
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
// Decrypt data
|
|
||||||
decrypted, err := s.cryptoHandler.cryptoService.DecryptSensitiveData(&encryptedPayload)
|
|
||||||
if err != nil {
|
|
||||||
logger.Infof("❌ Failed to decrypt model config (UserID: %s): %v", userID, err)
|
|
||||||
c.JSON(http.StatusBadRequest, gin.H{"error": "Failed to decrypt data"})
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
// Parse decrypted data
|
|
||||||
if err := json.Unmarshal([]byte(decrypted), &req); err != nil {
|
|
||||||
logger.Infof("❌ Failed to parse decrypted data: %v", err)
|
|
||||||
c.JSON(http.StatusBadRequest, gin.H{"error": "Failed to parse decrypted data"})
|
|
||||||
return
|
|
||||||
}
|
|
||||||
logger.Infof("🔓 Decrypted model config data (UserID: %s)", userID)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Update each model's configuration and track traders that need reload
|
|
||||||
tradersToReload := make(map[string]bool)
|
|
||||||
for modelID, modelData := range req.Models {
|
|
||||||
// SSRF protection: validate custom_api_url before storing
|
|
||||||
if modelData.CustomAPIURL != "" {
|
|
||||||
cleanURL := strings.TrimSuffix(modelData.CustomAPIURL, "#")
|
|
||||||
if err := security.ValidateURL(cleanURL); err != nil {
|
|
||||||
logger.Warnf("Invalid custom_api_url for model %s: %v", modelID, err)
|
|
||||||
c.JSON(http.StatusBadRequest, gin.H{"error": fmt.Sprintf("Invalid custom_api_url for model %s: URL must be a valid HTTPS endpoint", modelID)})
|
|
||||||
return
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Find traders using this AI model BEFORE updating
|
|
||||||
traders, _ := s.store.Trader().ListByAIModelID(userID, modelID)
|
|
||||||
for _, t := range traders {
|
|
||||||
tradersToReload[t.ID] = true
|
|
||||||
}
|
|
||||||
|
|
||||||
err := s.store.AIModel().Update(userID, modelID, modelData.Enabled, modelData.APIKey, modelData.CustomAPIURL, modelData.CustomModelName)
|
|
||||||
if err != nil {
|
|
||||||
SafeInternalError(c, fmt.Sprintf("Update model %s", modelID), err)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Remove affected traders from memory BEFORE reloading to pick up new config
|
|
||||||
for traderID := range tradersToReload {
|
|
||||||
logger.Infof("🔄 Removing trader %s from memory to reload with new AI model config", traderID)
|
|
||||||
s.traderManager.RemoveTrader(traderID)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Reload all traders for this user to make new config take effect immediately
|
|
||||||
err = s.traderManager.LoadUserTradersFromStore(s.store, userID)
|
|
||||||
if err != nil {
|
|
||||||
logger.Infof("⚠️ Failed to reload user traders into memory: %v", err)
|
|
||||||
// Don't return error here since model config was successfully updated to database
|
|
||||||
}
|
|
||||||
|
|
||||||
logger.Infof("✓ AI model config updated: %+v", SanitizeModelConfigForLog(req.Models))
|
|
||||||
c.JSON(http.StatusOK, gin.H{"message": "Model configuration updated"})
|
|
||||||
}
|
|
||||||
|
|
||||||
// handleGetSupportedModels Get list of AI models supported by the system
|
|
||||||
func (s *Server) handleGetSupportedModels(c *gin.Context) {
|
|
||||||
// Return static list of supported AI models with default versions
|
|
||||||
supportedModels := []map[string]interface{}{
|
|
||||||
{"id": "deepseek", "name": "DeepSeek", "provider": "deepseek", "defaultModel": "deepseek-chat"},
|
|
||||||
{"id": "qwen", "name": "Qwen", "provider": "qwen", "defaultModel": "qwen3-max"},
|
|
||||||
{"id": "openai", "name": "OpenAI", "provider": "openai", "defaultModel": "gpt-5.1"},
|
|
||||||
{"id": "claude", "name": "Claude", "provider": "claude", "defaultModel": "claude-opus-4-6"},
|
|
||||||
{"id": "gemini", "name": "Google Gemini", "provider": "gemini", "defaultModel": "gemini-3-pro-preview"},
|
|
||||||
{"id": "grok", "name": "Grok (xAI)", "provider": "grok", "defaultModel": "grok-3-latest"},
|
|
||||||
{"id": "kimi", "name": "Kimi (Moonshot)", "provider": "kimi", "defaultModel": "moonshot-v1-auto"},
|
|
||||||
{"id": "minimax", "name": "MiniMax", "provider": "minimax", "defaultModel": "MiniMax-M2.7"},
|
|
||||||
{"id": "blockrun-base", "name": "BlockRun (Base Wallet)", "provider": "blockrun-base", "defaultModel": "auto"},
|
|
||||||
{"id": "blockrun-sol", "name": "BlockRun (Solana Wallet)", "provider": "blockrun-sol", "defaultModel": "auto"},
|
|
||||||
{"id": "claw402", "name": "Claw402 (Base USDC)", "provider": "claw402", "defaultModel": "deepseek-v4-flash"},
|
|
||||||
}
|
|
||||||
|
|
||||||
c.JSON(http.StatusOK, supportedModels)
|
|
||||||
}
|
|
||||||
@@ -1,522 +0,0 @@
|
|||||||
package api
|
|
||||||
|
|
||||||
import (
|
|
||||||
"fmt"
|
|
||||||
"net/http"
|
|
||||||
"strconv"
|
|
||||||
"strings"
|
|
||||||
"time"
|
|
||||||
|
|
||||||
"nofx/logger"
|
|
||||||
"nofx/store"
|
|
||||||
|
|
||||||
"github.com/gin-gonic/gin"
|
|
||||||
)
|
|
||||||
|
|
||||||
// handleDecisions Decision log list
|
|
||||||
func (s *Server) handleDecisions(c *gin.Context) {
|
|
||||||
_, traderID, err := s.getTraderFromQuery(c)
|
|
||||||
if err != nil {
|
|
||||||
SafeBadRequest(c, "Invalid trader ID")
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
trader, err := s.traderManager.GetTrader(traderID)
|
|
||||||
if err != nil {
|
|
||||||
SafeNotFound(c, "Trader")
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
// Get all historical decision records (unlimited)
|
|
||||||
records, err := trader.GetStore().Decision().GetLatestRecords(trader.GetID(), 10000)
|
|
||||||
if err != nil {
|
|
||||||
SafeInternalError(c, "Get decision log", err)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
c.JSON(http.StatusOK, records)
|
|
||||||
}
|
|
||||||
|
|
||||||
// handleLatestDecisions Latest decision logs (newest first, supports limit parameter)
|
|
||||||
func (s *Server) handleLatestDecisions(c *gin.Context) {
|
|
||||||
_, traderID, err := s.getTraderFromQuery(c)
|
|
||||||
if err != nil {
|
|
||||||
SafeBadRequest(c, "Invalid trader ID")
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
trader, err := s.traderManager.GetTrader(traderID)
|
|
||||||
if err != nil {
|
|
||||||
SafeNotFound(c, "Trader")
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
// Get limit from query parameter, default to 5
|
|
||||||
limit := 5
|
|
||||||
if limitStr := c.Query("limit"); limitStr != "" {
|
|
||||||
if parsedLimit, err := strconv.Atoi(limitStr); err == nil && parsedLimit > 0 {
|
|
||||||
limit = parsedLimit
|
|
||||||
if limit > 100 {
|
|
||||||
limit = 100 // Max 100 to prevent abuse
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
records, err := trader.GetStore().Decision().GetLatestRecords(trader.GetID(), limit)
|
|
||||||
if err != nil {
|
|
||||||
SafeInternalError(c, "Get decision log", err)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
// Reverse array to put newest first (for list display)
|
|
||||||
// GetLatestRecords returns oldest to newest (for charts), here we need newest to oldest
|
|
||||||
for i, j := 0, len(records)-1; i < j; i, j = i+1, j-1 {
|
|
||||||
records[i], records[j] = records[j], records[i]
|
|
||||||
}
|
|
||||||
|
|
||||||
c.JSON(http.StatusOK, records)
|
|
||||||
}
|
|
||||||
|
|
||||||
// handleStatistics Statistics information
|
|
||||||
func (s *Server) handleStatistics(c *gin.Context) {
|
|
||||||
_, traderID, err := s.getTraderFromQuery(c)
|
|
||||||
if err != nil {
|
|
||||||
SafeBadRequest(c, "Invalid trader ID")
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
trader, err := s.traderManager.GetTrader(traderID)
|
|
||||||
if err != nil {
|
|
||||||
SafeNotFound(c, "Trader")
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
stats, err := trader.GetStore().Decision().GetStatistics(trader.GetID())
|
|
||||||
if err != nil {
|
|
||||||
SafeInternalError(c, "Get statistics", err)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
c.JSON(http.StatusOK, stats)
|
|
||||||
}
|
|
||||||
|
|
||||||
// handleCompetition Competition overview (compare all traders)
|
|
||||||
func (s *Server) handleCompetition(c *gin.Context) {
|
|
||||||
userID := c.GetString("user_id")
|
|
||||||
|
|
||||||
// Ensure user's traders are loaded into memory
|
|
||||||
err := s.traderManager.LoadUserTradersFromStore(s.store, userID)
|
|
||||||
if err != nil {
|
|
||||||
logger.Infof("⚠️ Failed to load traders for user %s: %v", userID, err)
|
|
||||||
}
|
|
||||||
|
|
||||||
competition, err := s.traderManager.GetCompetitionData()
|
|
||||||
if err != nil {
|
|
||||||
SafeInternalError(c, "Get competition data", err)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
c.JSON(http.StatusOK, competition)
|
|
||||||
}
|
|
||||||
|
|
||||||
// handleEquityHistory returns equity history for a trader. This endpoint is
|
|
||||||
// PUBLIC (used by the competition leaderboard), so it cannot use the
|
|
||||||
// authenticated getTraderFromQuery helper. Instead, it validates that the
|
|
||||||
// requested trader has explicitly opted into the public competition via
|
|
||||||
// show_in_competition=true. Traders without that flag are not exposed.
|
|
||||||
func (s *Server) handleEquityHistory(c *gin.Context) {
|
|
||||||
traderID := c.Query("trader_id")
|
|
||||||
if traderID == "" {
|
|
||||||
SafeBadRequest(c, "trader_id is required")
|
|
||||||
return
|
|
||||||
}
|
|
||||||
trader, err := s.store.Trader().GetByID(traderID)
|
|
||||||
if err != nil || trader == nil {
|
|
||||||
SafeNotFound(c, "Trader")
|
|
||||||
return
|
|
||||||
}
|
|
||||||
if !trader.ShowInCompetition {
|
|
||||||
// Do not leak that a private trader exists; report not found.
|
|
||||||
SafeNotFound(c, "Trader")
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
// Get equity historical data from new equity table
|
|
||||||
// Every 3 minutes per cycle: 10000 records = about 20 days of data
|
|
||||||
snapshots, err := s.store.Equity().GetLatest(traderID, 10000)
|
|
||||||
if err != nil {
|
|
||||||
SafeInternalError(c, "Get historical data", err)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
if len(snapshots) == 0 {
|
|
||||||
c.JSON(http.StatusOK, []interface{}{})
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
// Build return rate historical data points
|
|
||||||
type EquityPoint struct {
|
|
||||||
Timestamp string `json:"timestamp"`
|
|
||||||
TotalEquity float64 `json:"total_equity"` // Account equity (wallet + unrealized)
|
|
||||||
AvailableBalance float64 `json:"available_balance"` // Available balance
|
|
||||||
TotalPnL float64 `json:"total_pnl"` // Total PnL (unrealized PnL)
|
|
||||||
TotalPnLPct float64 `json:"total_pnl_pct"` // Total PnL percentage
|
|
||||||
PositionCount int `json:"position_count"` // Position count
|
|
||||||
MarginUsedPct float64 `json:"margin_used_pct"` // Margin used percentage
|
|
||||||
}
|
|
||||||
|
|
||||||
initialBalance := trader.InitialBalance
|
|
||||||
if initialBalance <= 0 {
|
|
||||||
initialBalance = snapshots[0].TotalEquity
|
|
||||||
}
|
|
||||||
if initialBalance == 0 {
|
|
||||||
initialBalance = 1 // Avoid division by zero
|
|
||||||
}
|
|
||||||
|
|
||||||
var history []EquityPoint
|
|
||||||
var lastSnapshotTime time.Time
|
|
||||||
for _, snap := range snapshots {
|
|
||||||
totalPnL := snap.TotalEquity - initialBalance
|
|
||||||
totalPnLPct := 0.0
|
|
||||||
if initialBalance > 0 {
|
|
||||||
totalPnLPct = (totalPnL / initialBalance) * 100
|
|
||||||
}
|
|
||||||
|
|
||||||
history = append(history, EquityPoint{
|
|
||||||
Timestamp: snap.Timestamp.Format("2006-01-02 15:04:05"),
|
|
||||||
TotalEquity: snap.TotalEquity,
|
|
||||||
AvailableBalance: equitySnapshotAvailableBalance(snap),
|
|
||||||
TotalPnL: totalPnL,
|
|
||||||
TotalPnLPct: totalPnLPct,
|
|
||||||
PositionCount: snap.PositionCount,
|
|
||||||
MarginUsedPct: snap.MarginUsedPct,
|
|
||||||
})
|
|
||||||
if snap.Timestamp.After(lastSnapshotTime) {
|
|
||||||
lastSnapshotTime = snap.Timestamp
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if runtimeTrader, err := s.traderManager.GetTrader(traderID); err == nil {
|
|
||||||
if accountInfo, err := runtimeTrader.GetAccountInfo(); err == nil && time.Since(lastSnapshotTime) > 30*time.Second {
|
|
||||||
totalEquity := floatFromMap(accountInfo, "total_equity")
|
|
||||||
totalPnL := totalEquity - initialBalance
|
|
||||||
totalPnLPct := 0.0
|
|
||||||
if initialBalance > 0 {
|
|
||||||
totalPnLPct = (totalPnL / initialBalance) * 100
|
|
||||||
}
|
|
||||||
history = append(history, EquityPoint{
|
|
||||||
Timestamp: time.Now().UTC().Format("2006-01-02 15:04:05"),
|
|
||||||
TotalEquity: totalEquity,
|
|
||||||
AvailableBalance: floatFromMap(accountInfo, "available_balance"),
|
|
||||||
TotalPnL: totalPnL,
|
|
||||||
TotalPnLPct: totalPnLPct,
|
|
||||||
PositionCount: int(floatFromMap(accountInfo, "position_count")),
|
|
||||||
MarginUsedPct: floatFromMap(accountInfo, "margin_used_pct"),
|
|
||||||
})
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
c.JSON(http.StatusOK, history)
|
|
||||||
}
|
|
||||||
|
|
||||||
func equitySnapshotAvailableBalance(snap *store.EquitySnapshot) float64 {
|
|
||||||
if snap == nil {
|
|
||||||
return 0
|
|
||||||
}
|
|
||||||
if snap.AvailableBalance != 0 || snap.PositionCount > 0 {
|
|
||||||
return snap.AvailableBalance
|
|
||||||
}
|
|
||||||
return snap.Balance
|
|
||||||
}
|
|
||||||
|
|
||||||
func floatFromMap(values map[string]interface{}, key string) float64 {
|
|
||||||
if value, ok := values[key].(float64); ok {
|
|
||||||
return value
|
|
||||||
}
|
|
||||||
if value, ok := values[key].(int); ok {
|
|
||||||
return float64(value)
|
|
||||||
}
|
|
||||||
return 0
|
|
||||||
}
|
|
||||||
|
|
||||||
// handlePublicTraderList Get public trader list (no authentication required)
|
|
||||||
func (s *Server) handlePublicTraderList(c *gin.Context) {
|
|
||||||
// Get trader information from all users
|
|
||||||
competition, err := s.traderManager.GetCompetitionData()
|
|
||||||
if err != nil {
|
|
||||||
SafeInternalError(c, "Get trader list", err)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
// Get traders array
|
|
||||||
tradersData, exists := competition["traders"]
|
|
||||||
if !exists {
|
|
||||||
c.JSON(http.StatusOK, []map[string]interface{}{})
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
traders, ok := tradersData.([]map[string]interface{})
|
|
||||||
if !ok {
|
|
||||||
c.JSON(http.StatusInternalServerError, gin.H{
|
|
||||||
"error": "Trader data format error",
|
|
||||||
})
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
// Return trader basic information, filter sensitive information
|
|
||||||
result := make([]map[string]interface{}, 0, len(traders))
|
|
||||||
for _, trader := range traders {
|
|
||||||
result = append(result, map[string]interface{}{
|
|
||||||
"trader_id": trader["trader_id"],
|
|
||||||
"trader_name": trader["trader_name"],
|
|
||||||
"ai_model": trader["ai_model"],
|
|
||||||
"exchange": trader["exchange"],
|
|
||||||
"is_running": trader["is_running"],
|
|
||||||
"total_equity": trader["total_equity"],
|
|
||||||
"total_pnl": trader["total_pnl"],
|
|
||||||
"total_pnl_pct": trader["total_pnl_pct"],
|
|
||||||
"position_count": trader["position_count"],
|
|
||||||
"margin_used_pct": trader["margin_used_pct"],
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
c.JSON(http.StatusOK, result)
|
|
||||||
}
|
|
||||||
|
|
||||||
// handlePublicCompetition Get public competition data (no authentication required)
|
|
||||||
func (s *Server) handlePublicCompetition(c *gin.Context) {
|
|
||||||
competition, err := s.traderManager.GetCompetitionData()
|
|
||||||
if err != nil {
|
|
||||||
SafeInternalError(c, "Get competition data", err)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
c.JSON(http.StatusOK, competition)
|
|
||||||
}
|
|
||||||
|
|
||||||
// handleTopTraders Get top 5 trader data (no authentication required, for performance comparison)
|
|
||||||
func (s *Server) handleTopTraders(c *gin.Context) {
|
|
||||||
topTraders, err := s.traderManager.GetTopTradersData()
|
|
||||||
if err != nil {
|
|
||||||
SafeInternalError(c, "Get top traders data", err)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
c.JSON(http.StatusOK, topTraders)
|
|
||||||
}
|
|
||||||
|
|
||||||
// handleEquityHistoryBatch Batch get return rate historical data for multiple traders (no authentication required, for performance comparison)
|
|
||||||
// Supports optional 'hours' parameter to filter data by time range (e.g., hours=24 for last 24 hours)
|
|
||||||
func (s *Server) handleEquityHistoryBatch(c *gin.Context) {
|
|
||||||
var requestBody struct {
|
|
||||||
TraderIDs []string `json:"trader_ids"`
|
|
||||||
Hours int `json:"hours"` // Optional: filter by last N hours (0 = all data)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Try to parse POST request JSON body
|
|
||||||
if err := c.ShouldBindJSON(&requestBody); err != nil {
|
|
||||||
// If JSON parse fails, try to get from query parameters (compatible with GET request)
|
|
||||||
traderIDsParam := c.Query("trader_ids")
|
|
||||||
if traderIDsParam == "" {
|
|
||||||
// If no trader_ids specified, return historical data for top 5
|
|
||||||
topTraders, err := s.traderManager.GetTopTradersData()
|
|
||||||
if err != nil {
|
|
||||||
SafeInternalError(c, "Get top traders", err)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
traders, ok := topTraders["traders"].([]map[string]interface{})
|
|
||||||
if !ok {
|
|
||||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "Trader data format error"})
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
// Extract trader IDs
|
|
||||||
traderIDs := make([]string, 0, len(traders))
|
|
||||||
for _, trader := range traders {
|
|
||||||
if traderID, ok := trader["trader_id"].(string); ok {
|
|
||||||
traderIDs = append(traderIDs, traderID)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Parse hours parameter from query
|
|
||||||
hoursParam := c.Query("hours")
|
|
||||||
hours := 0
|
|
||||||
if hoursParam != "" {
|
|
||||||
fmt.Sscanf(hoursParam, "%d", &hours)
|
|
||||||
}
|
|
||||||
|
|
||||||
result := s.getEquityHistoryForTraders(traderIDs, hours)
|
|
||||||
c.JSON(http.StatusOK, result)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
// Parse comma-separated trader IDs
|
|
||||||
requestBody.TraderIDs = strings.Split(traderIDsParam, ",")
|
|
||||||
for i := range requestBody.TraderIDs {
|
|
||||||
requestBody.TraderIDs[i] = strings.TrimSpace(requestBody.TraderIDs[i])
|
|
||||||
}
|
|
||||||
|
|
||||||
// Parse hours parameter from query
|
|
||||||
hoursParam := c.Query("hours")
|
|
||||||
if hoursParam != "" {
|
|
||||||
fmt.Sscanf(hoursParam, "%d", &requestBody.Hours)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Limit to maximum 20 traders to prevent oversized requests
|
|
||||||
if len(requestBody.TraderIDs) > 20 {
|
|
||||||
requestBody.TraderIDs = requestBody.TraderIDs[:20]
|
|
||||||
}
|
|
||||||
|
|
||||||
result := s.getEquityHistoryForTraders(requestBody.TraderIDs, requestBody.Hours)
|
|
||||||
c.JSON(http.StatusOK, result)
|
|
||||||
}
|
|
||||||
|
|
||||||
// getEquityHistoryForTraders Get historical data for multiple traders
|
|
||||||
// Query directly from database, not dependent on trader in memory (so historical data can be retrieved after restart)
|
|
||||||
// Also appends current real-time data point to ensure chart matches leaderboard
|
|
||||||
// hours: filter by last N hours (0 = use default limit of 500 records)
|
|
||||||
func (s *Server) getEquityHistoryForTraders(traderIDs []string, hours int) map[string]interface{} {
|
|
||||||
result := make(map[string]interface{})
|
|
||||||
histories := make(map[string]interface{})
|
|
||||||
errors := make(map[string]string)
|
|
||||||
|
|
||||||
// Use a single consistent timestamp for all real-time data points
|
|
||||||
now := time.Now()
|
|
||||||
|
|
||||||
// Pre-fetch initial balances for all traders
|
|
||||||
initialBalances := make(map[string]float64)
|
|
||||||
for _, traderID := range traderIDs {
|
|
||||||
if traderID == "" {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
// Get trader's initial balance from database (use GetByID which doesn't require userID)
|
|
||||||
trader, err := s.store.Trader().GetByID(traderID)
|
|
||||||
if err == nil && trader != nil && trader.InitialBalance > 0 {
|
|
||||||
initialBalances[traderID] = trader.InitialBalance
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
for _, traderID := range traderIDs {
|
|
||||||
if traderID == "" {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
|
|
||||||
// Get equity historical data from new equity table
|
|
||||||
var snapshots []*store.EquitySnapshot
|
|
||||||
var err error
|
|
||||||
|
|
||||||
if hours > 0 {
|
|
||||||
// Filter by time range
|
|
||||||
startTime := now.Add(-time.Duration(hours) * time.Hour)
|
|
||||||
snapshots, err = s.store.Equity().GetByTimeRange(traderID, startTime, now)
|
|
||||||
} else {
|
|
||||||
// Default: get latest 500 records
|
|
||||||
snapshots, err = s.store.Equity().GetLatest(traderID, 500)
|
|
||||||
}
|
|
||||||
if err != nil {
|
|
||||||
logger.Errorf("[API] Failed to get equity history for %s: %v", traderID, err)
|
|
||||||
errors[traderID] = "Failed to get historical data"
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
|
|
||||||
// Get initial balance for calculating PnL percentage
|
|
||||||
initialBalance := initialBalances[traderID]
|
|
||||||
if initialBalance <= 0 && len(snapshots) > 0 {
|
|
||||||
// If no initial balance configured, use the first snapshot's equity as baseline
|
|
||||||
initialBalance = snapshots[0].TotalEquity
|
|
||||||
}
|
|
||||||
|
|
||||||
// Build return rate historical data with PnL percentage
|
|
||||||
history := make([]map[string]interface{}, 0, len(snapshots)+1)
|
|
||||||
var lastSnapshotTime time.Time
|
|
||||||
for _, snap := range snapshots {
|
|
||||||
totalPnL := snap.TotalEquity - initialBalance
|
|
||||||
// Calculate PnL percentage: (current_equity - initial_balance) / initial_balance * 100
|
|
||||||
pnlPct := 0.0
|
|
||||||
if initialBalance > 0 {
|
|
||||||
pnlPct = totalPnL / initialBalance * 100
|
|
||||||
}
|
|
||||||
|
|
||||||
history = append(history, map[string]interface{}{
|
|
||||||
"timestamp": snap.Timestamp,
|
|
||||||
"total_equity": snap.TotalEquity,
|
|
||||||
"available_balance": equitySnapshotAvailableBalance(snap),
|
|
||||||
"total_pnl": totalPnL,
|
|
||||||
"total_pnl_pct": pnlPct,
|
|
||||||
"balance": snap.Balance,
|
|
||||||
})
|
|
||||||
if snap.Timestamp.After(lastSnapshotTime) {
|
|
||||||
lastSnapshotTime = snap.Timestamp
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Append current real-time data point to ensure chart matches leaderboard
|
|
||||||
// This ensures the latest point is always current, not from a potentially stale snapshot
|
|
||||||
if trader, err := s.traderManager.GetTrader(traderID); err == nil {
|
|
||||||
if accountInfo, err := trader.GetAccountInfo(); err == nil {
|
|
||||||
// Only append if it's been more than 30 seconds since last snapshot
|
|
||||||
if now.Sub(lastSnapshotTime) > 30*time.Second {
|
|
||||||
totalEquity := floatFromMap(accountInfo, "total_equity")
|
|
||||||
totalPnL := totalEquity - initialBalance
|
|
||||||
walletBalance := floatFromMap(accountInfo, "wallet_balance")
|
|
||||||
pnlPct := 0.0
|
|
||||||
if initialBalance > 0 {
|
|
||||||
pnlPct = (totalEquity - initialBalance) / initialBalance * 100
|
|
||||||
}
|
|
||||||
|
|
||||||
history = append(history, map[string]interface{}{
|
|
||||||
"timestamp": now,
|
|
||||||
"total_equity": totalEquity,
|
|
||||||
"available_balance": floatFromMap(accountInfo, "available_balance"),
|
|
||||||
"total_pnl": totalPnL,
|
|
||||||
"total_pnl_pct": pnlPct,
|
|
||||||
"balance": walletBalance,
|
|
||||||
})
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
histories[traderID] = history
|
|
||||||
}
|
|
||||||
|
|
||||||
result["histories"] = histories
|
|
||||||
result["count"] = len(histories)
|
|
||||||
if len(errors) > 0 {
|
|
||||||
result["errors"] = errors
|
|
||||||
}
|
|
||||||
|
|
||||||
return result
|
|
||||||
}
|
|
||||||
|
|
||||||
// handleGetPublicTraderConfig Get public trader configuration information (no authentication required, does not include sensitive information)
|
|
||||||
func (s *Server) handleGetPublicTraderConfig(c *gin.Context) {
|
|
||||||
traderID := c.Param("id")
|
|
||||||
if traderID == "" {
|
|
||||||
c.JSON(http.StatusBadRequest, gin.H{"error": "Trader ID cannot be empty"})
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
trader, err := s.traderManager.GetTrader(traderID)
|
|
||||||
if err != nil {
|
|
||||||
c.JSON(http.StatusNotFound, gin.H{"error": "Trader does not exist"})
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
// Get trader status information
|
|
||||||
status := trader.GetStatus()
|
|
||||||
|
|
||||||
// Only return public configuration information, not including sensitive data like API keys
|
|
||||||
result := map[string]interface{}{
|
|
||||||
"trader_id": trader.GetID(),
|
|
||||||
"trader_name": trader.GetName(),
|
|
||||||
"ai_model": trader.GetAIModel(),
|
|
||||||
"exchange": trader.GetExchange(),
|
|
||||||
"is_running": status["is_running"],
|
|
||||||
"ai_provider": status["ai_provider"],
|
|
||||||
"start_time": status["start_time"],
|
|
||||||
}
|
|
||||||
|
|
||||||
c.JSON(http.StatusOK, result)
|
|
||||||
}
|
|
||||||
@@ -1,494 +0,0 @@
|
|||||||
package api
|
|
||||||
|
|
||||||
import (
|
|
||||||
"encoding/json"
|
|
||||||
"fmt"
|
|
||||||
"net/http"
|
|
||||||
"strings"
|
|
||||||
|
|
||||||
"nofx/config"
|
|
||||||
"nofx/crypto"
|
|
||||||
"nofx/logger"
|
|
||||||
"nofx/store"
|
|
||||||
|
|
||||||
"github.com/gin-gonic/gin"
|
|
||||||
)
|
|
||||||
|
|
||||||
type ExchangeConfig struct {
|
|
||||||
ID string `json:"id"`
|
|
||||||
Name string `json:"name"`
|
|
||||||
Type string `json:"type"` // "cex" or "dex"
|
|
||||||
Enabled bool `json:"enabled"`
|
|
||||||
APIKey string `json:"apiKey,omitempty"`
|
|
||||||
SecretKey string `json:"secretKey,omitempty"`
|
|
||||||
Testnet bool `json:"testnet,omitempty"`
|
|
||||||
}
|
|
||||||
|
|
||||||
// SafeExchangeConfig Safe exchange configuration structure (does not contain sensitive information)
|
|
||||||
type SafeExchangeConfig struct {
|
|
||||||
ID string `json:"id"` // UUID
|
|
||||||
ExchangeType string `json:"exchange_type"` // "binance", "bybit", "okx", "hyperliquid", "aster", "lighter"
|
|
||||||
AccountName string `json:"account_name"` // User-defined account name
|
|
||||||
Name string `json:"name"` // Display name
|
|
||||||
Type string `json:"type"` // "cex" or "dex"
|
|
||||||
Enabled bool `json:"enabled"`
|
|
||||||
HasAPIKey bool `json:"has_api_key"`
|
|
||||||
HasSecretKey bool `json:"has_secret_key"`
|
|
||||||
HasPassphrase bool `json:"has_passphrase"`
|
|
||||||
Testnet bool `json:"testnet,omitempty"`
|
|
||||||
HyperliquidWalletAddr string `json:"hyperliquidWalletAddr"` // Hyperliquid wallet address (not sensitive)
|
|
||||||
HyperliquidUnifiedAcct bool `json:"hyperliquidUnifiedAccount"`
|
|
||||||
HyperliquidBuilderApproved bool `json:"hyperliquidBuilderApproved"`
|
|
||||||
HasAsterPrivateKey bool `json:"has_aster_private_key"`
|
|
||||||
AsterUser string `json:"asterUser"` // Aster username (not sensitive)
|
|
||||||
AsterSigner string `json:"asterSigner"` // Aster signer (not sensitive)
|
|
||||||
LighterWalletAddr string `json:"lighterWalletAddr"` // LIGHTER wallet address (not sensitive)
|
|
||||||
HasLighterPrivateKey bool `json:"has_lighter_private_key"`
|
|
||||||
HasLighterAPIKey bool `json:"has_lighter_api_key_private_key"`
|
|
||||||
}
|
|
||||||
|
|
||||||
func safeExchangeConfigFromStore(exchange *store.Exchange) SafeExchangeConfig {
|
|
||||||
return SafeExchangeConfig{
|
|
||||||
ID: exchange.ID,
|
|
||||||
ExchangeType: exchange.ExchangeType,
|
|
||||||
AccountName: exchange.AccountName,
|
|
||||||
Name: exchange.Name,
|
|
||||||
Type: exchange.Type,
|
|
||||||
Enabled: exchange.Enabled,
|
|
||||||
HasAPIKey: exchange.APIKey != "",
|
|
||||||
HasSecretKey: exchange.SecretKey != "",
|
|
||||||
HasPassphrase: exchange.Passphrase != "",
|
|
||||||
Testnet: exchange.Testnet,
|
|
||||||
HyperliquidWalletAddr: exchange.HyperliquidWalletAddr,
|
|
||||||
HyperliquidUnifiedAcct: exchange.HyperliquidUnifiedAcct,
|
|
||||||
HyperliquidBuilderApproved: exchange.HyperliquidBuilderApproved,
|
|
||||||
HasAsterPrivateKey: exchange.AsterPrivateKey != "",
|
|
||||||
AsterUser: exchange.AsterUser,
|
|
||||||
AsterSigner: exchange.AsterSigner,
|
|
||||||
LighterWalletAddr: exchange.LighterWalletAddr,
|
|
||||||
HasLighterPrivateKey: exchange.LighterPrivateKey != "",
|
|
||||||
HasLighterAPIKey: exchange.LighterAPIKeyPrivateKey != "",
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// ExchangeConfigUpdate is a single exchange account's update payload. It is a
|
|
||||||
// named type (rather than an inline anonymous struct) so the log-sanitizer in
|
|
||||||
// utils.go is guaranteed to cover every sensitive field — a drift between the
|
|
||||||
// two shapes is what let passphrases / private keys reach the logs previously.
|
|
||||||
type ExchangeConfigUpdate struct {
|
|
||||||
Enabled bool `json:"enabled"`
|
|
||||||
APIKey string `json:"api_key"`
|
|
||||||
SecretKey string `json:"secret_key"`
|
|
||||||
Passphrase string `json:"passphrase"` // OKX specific
|
|
||||||
Testnet bool `json:"testnet"`
|
|
||||||
HyperliquidWalletAddr string `json:"hyperliquid_wallet_addr"`
|
|
||||||
HyperliquidUnifiedAcct *bool `json:"hyperliquid_unified_account"` // Unified Account mode
|
|
||||||
HyperliquidBuilderApproved *bool `json:"hyperliquid_builder_approved"`
|
|
||||||
AsterUser string `json:"aster_user"`
|
|
||||||
AsterSigner string `json:"aster_signer"`
|
|
||||||
AsterPrivateKey string `json:"aster_private_key"`
|
|
||||||
LighterWalletAddr string `json:"lighter_wallet_addr"`
|
|
||||||
LighterPrivateKey string `json:"lighter_private_key"`
|
|
||||||
LighterAPIKeyPrivateKey string `json:"lighter_api_key_private_key"`
|
|
||||||
LighterAPIKeyIndex int `json:"lighter_api_key_index"`
|
|
||||||
}
|
|
||||||
|
|
||||||
type UpdateExchangeConfigRequest struct {
|
|
||||||
Exchanges map[string]ExchangeConfigUpdate `json:"exchanges"`
|
|
||||||
}
|
|
||||||
|
|
||||||
// CreateExchangeRequest request structure for creating a new exchange account
|
|
||||||
type CreateExchangeRequest struct {
|
|
||||||
ExchangeType string `json:"exchange_type" binding:"required"` // "binance", "bybit", "okx", "hyperliquid", "aster", "lighter"
|
|
||||||
AccountName string `json:"account_name"` // User-defined account name
|
|
||||||
Enabled bool `json:"enabled"`
|
|
||||||
APIKey string `json:"api_key"`
|
|
||||||
SecretKey string `json:"secret_key"`
|
|
||||||
Passphrase string `json:"passphrase"`
|
|
||||||
Testnet bool `json:"testnet"`
|
|
||||||
HyperliquidWalletAddr string `json:"hyperliquid_wallet_addr"`
|
|
||||||
HyperliquidUnifiedAcct *bool `json:"hyperliquid_unified_account"` // Unified Account mode: Spot as Perp collateral
|
|
||||||
HyperliquidBuilderApproved bool `json:"hyperliquid_builder_approved"`
|
|
||||||
AsterUser string `json:"aster_user"`
|
|
||||||
AsterSigner string `json:"aster_signer"`
|
|
||||||
AsterPrivateKey string `json:"aster_private_key"`
|
|
||||||
LighterWalletAddr string `json:"lighter_wallet_addr"`
|
|
||||||
LighterPrivateKey string `json:"lighter_private_key"`
|
|
||||||
LighterAPIKeyPrivateKey string `json:"lighter_api_key_private_key"`
|
|
||||||
LighterAPIKeyIndex int `json:"lighter_api_key_index"`
|
|
||||||
}
|
|
||||||
|
|
||||||
// handleGetExchangeConfigs Get exchange configurations
|
|
||||||
func (s *Server) handleGetExchangeConfigs(c *gin.Context) {
|
|
||||||
userID := c.GetString("user_id")
|
|
||||||
logger.Infof("🔍 Querying exchange configs for user %s", userID)
|
|
||||||
exchanges, err := s.store.Exchange().List(userID)
|
|
||||||
if err != nil {
|
|
||||||
SafeInternalError(c, "Failed to get exchange configs", err)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
// If no exchanges in database, return empty array (user needs to create accounts)
|
|
||||||
if len(exchanges) == 0 {
|
|
||||||
logger.Infof("⚠️ No exchanges in database for user %s", userID)
|
|
||||||
c.JSON(http.StatusOK, []SafeExchangeConfig{})
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
logger.Infof("✅ Found %d exchange configs", len(exchanges))
|
|
||||||
|
|
||||||
// Convert to safe response structure, remove sensitive information
|
|
||||||
safeExchanges := make([]SafeExchangeConfig, 0, len(exchanges))
|
|
||||||
for _, exchange := range exchanges {
|
|
||||||
if !store.IsVisibleExchange(exchange) {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
safeExchanges = append(safeExchanges, safeExchangeConfigFromStore(exchange))
|
|
||||||
}
|
|
||||||
|
|
||||||
c.JSON(http.StatusOK, safeExchanges)
|
|
||||||
}
|
|
||||||
|
|
||||||
func effectiveHyperliquidUnifiedAccount(exchangeType string, requested *bool, fallback ...bool) bool {
|
|
||||||
if requested != nil {
|
|
||||||
return *requested
|
|
||||||
}
|
|
||||||
if strings.EqualFold(exchangeType, "hyperliquid") {
|
|
||||||
if len(fallback) > 0 {
|
|
||||||
return fallback[0]
|
|
||||||
}
|
|
||||||
return true
|
|
||||||
}
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
|
|
||||||
// handleUpdateExchangeConfigs Update exchange configurations (supports both encrypted and plain text based on config)
|
|
||||||
func (s *Server) handleUpdateExchangeConfigs(c *gin.Context) {
|
|
||||||
userID := c.GetString("user_id")
|
|
||||||
cfg := config.Get()
|
|
||||||
|
|
||||||
// Read raw request body
|
|
||||||
bodyBytes, err := c.GetRawData()
|
|
||||||
if err != nil {
|
|
||||||
c.JSON(http.StatusBadRequest, gin.H{"error": "Failed to read request body"})
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
var req UpdateExchangeConfigRequest
|
|
||||||
|
|
||||||
// Check if transport encryption is enabled
|
|
||||||
if !cfg.TransportEncryption {
|
|
||||||
// Transport encryption disabled, accept plain JSON
|
|
||||||
if err := json.Unmarshal(bodyBytes, &req); err != nil {
|
|
||||||
logger.Infof("❌ Failed to parse plain JSON request: %v", err)
|
|
||||||
c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid request format"})
|
|
||||||
return
|
|
||||||
}
|
|
||||||
logger.Infof("📝 Received plain text exchange config (UserID: %s)", userID)
|
|
||||||
} else {
|
|
||||||
// Transport encryption enabled, require encrypted payload
|
|
||||||
var encryptedPayload crypto.EncryptedPayload
|
|
||||||
if err := json.Unmarshal(bodyBytes, &encryptedPayload); err != nil {
|
|
||||||
logger.Infof("❌ Failed to parse encrypted payload: %v", err)
|
|
||||||
c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid request format, encrypted transmission required"})
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
// Verify encrypted data
|
|
||||||
if encryptedPayload.WrappedKey == "" {
|
|
||||||
logger.Infof("❌ Detected unencrypted request (UserID: %s)", userID)
|
|
||||||
c.JSON(http.StatusBadRequest, gin.H{
|
|
||||||
"error": "This endpoint only supports encrypted transmission, please use encrypted client",
|
|
||||||
"code": "ENCRYPTION_REQUIRED",
|
|
||||||
"message": "Encrypted transmission is required for security reasons",
|
|
||||||
})
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
// Decrypt data
|
|
||||||
decrypted, err := s.cryptoHandler.cryptoService.DecryptSensitiveData(&encryptedPayload)
|
|
||||||
if err != nil {
|
|
||||||
logger.Infof("❌ Failed to decrypt exchange config (UserID: %s): %v", userID, err)
|
|
||||||
c.JSON(http.StatusBadRequest, gin.H{"error": "Failed to decrypt data"})
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
// Parse decrypted data
|
|
||||||
if err := json.Unmarshal([]byte(decrypted), &req); err != nil {
|
|
||||||
logger.Infof("❌ Failed to parse decrypted data: %v", err)
|
|
||||||
c.JSON(http.StatusBadRequest, gin.H{"error": "Failed to parse decrypted data"})
|
|
||||||
return
|
|
||||||
}
|
|
||||||
logger.Infof("🔓 Decrypted exchange config data (UserID: %s)", userID)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Update each exchange's configuration and track traders that need reload
|
|
||||||
tradersToReload := make(map[string]bool)
|
|
||||||
for exchangeID, exchangeData := range req.Exchanges {
|
|
||||||
existing, err := s.store.Exchange().GetByID(userID, exchangeID)
|
|
||||||
if err != nil {
|
|
||||||
SafeInternalError(c, fmt.Sprintf("Load exchange %s", exchangeID), err)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
effectiveAPIKey := strings.TrimSpace(exchangeData.APIKey)
|
|
||||||
if effectiveAPIKey == "" {
|
|
||||||
effectiveAPIKey = strings.TrimSpace(string(existing.APIKey))
|
|
||||||
}
|
|
||||||
effectiveSecretKey := strings.TrimSpace(exchangeData.SecretKey)
|
|
||||||
if effectiveSecretKey == "" {
|
|
||||||
effectiveSecretKey = strings.TrimSpace(string(existing.SecretKey))
|
|
||||||
}
|
|
||||||
effectivePassphrase := strings.TrimSpace(exchangeData.Passphrase)
|
|
||||||
if effectivePassphrase == "" {
|
|
||||||
effectivePassphrase = strings.TrimSpace(string(existing.Passphrase))
|
|
||||||
}
|
|
||||||
effectiveAsterPrivateKey := strings.TrimSpace(exchangeData.AsterPrivateKey)
|
|
||||||
if effectiveAsterPrivateKey == "" {
|
|
||||||
effectiveAsterPrivateKey = strings.TrimSpace(string(existing.AsterPrivateKey))
|
|
||||||
}
|
|
||||||
effectiveLighterAPIKeyPrivateKey := strings.TrimSpace(exchangeData.LighterAPIKeyPrivateKey)
|
|
||||||
if effectiveLighterAPIKeyPrivateKey == "" {
|
|
||||||
effectiveLighterAPIKeyPrivateKey = strings.TrimSpace(string(existing.LighterAPIKeyPrivateKey))
|
|
||||||
}
|
|
||||||
effectiveHyperliquidWalletAddr := strings.TrimSpace(exchangeData.HyperliquidWalletAddr)
|
|
||||||
if effectiveHyperliquidWalletAddr == "" {
|
|
||||||
effectiveHyperliquidWalletAddr = strings.TrimSpace(existing.HyperliquidWalletAddr)
|
|
||||||
}
|
|
||||||
effectiveAsterUser := strings.TrimSpace(exchangeData.AsterUser)
|
|
||||||
if effectiveAsterUser == "" {
|
|
||||||
effectiveAsterUser = strings.TrimSpace(existing.AsterUser)
|
|
||||||
}
|
|
||||||
effectiveAsterSigner := strings.TrimSpace(exchangeData.AsterSigner)
|
|
||||||
if effectiveAsterSigner == "" {
|
|
||||||
effectiveAsterSigner = strings.TrimSpace(existing.AsterSigner)
|
|
||||||
}
|
|
||||||
effectiveLighterWalletAddr := strings.TrimSpace(exchangeData.LighterWalletAddr)
|
|
||||||
if effectiveLighterWalletAddr == "" {
|
|
||||||
effectiveLighterWalletAddr = strings.TrimSpace(existing.LighterWalletAddr)
|
|
||||||
}
|
|
||||||
effectiveHyperliquidBuilderApproved := existing.HyperliquidBuilderApproved
|
|
||||||
if exchangeData.HyperliquidBuilderApproved != nil {
|
|
||||||
effectiveHyperliquidBuilderApproved = *exchangeData.HyperliquidBuilderApproved
|
|
||||||
}
|
|
||||||
effectiveHyperliquidUnifiedAcct := effectiveHyperliquidUnifiedAccount(
|
|
||||||
existing.ExchangeType,
|
|
||||||
exchangeData.HyperliquidUnifiedAcct,
|
|
||||||
existing.HyperliquidUnifiedAcct,
|
|
||||||
)
|
|
||||||
|
|
||||||
if missing := store.MissingRequiredExchangeCredentialFields(
|
|
||||||
existing.ExchangeType,
|
|
||||||
effectiveAPIKey,
|
|
||||||
effectiveSecretKey,
|
|
||||||
effectivePassphrase,
|
|
||||||
effectiveHyperliquidWalletAddr,
|
|
||||||
effectiveAsterUser,
|
|
||||||
effectiveAsterSigner,
|
|
||||||
effectiveAsterPrivateKey,
|
|
||||||
effectiveLighterWalletAddr,
|
|
||||||
effectiveLighterAPIKeyPrivateKey,
|
|
||||||
); len(missing) > 0 {
|
|
||||||
c.JSON(http.StatusBadRequest, gin.H{
|
|
||||||
"error": fmt.Sprintf("Missing required exchange fields: %s", strings.Join(missing, ", ")),
|
|
||||||
"missing_fields": missing,
|
|
||||||
})
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
// Find traders using this exchange BEFORE updating
|
|
||||||
traders, _ := s.store.Trader().ListByExchangeID(userID, exchangeID)
|
|
||||||
for _, t := range traders {
|
|
||||||
tradersToReload[t.ID] = true
|
|
||||||
}
|
|
||||||
|
|
||||||
err = s.store.Exchange().Update(userID, exchangeID, true, exchangeData.APIKey, exchangeData.SecretKey, exchangeData.Passphrase, exchangeData.Testnet, effectiveHyperliquidWalletAddr, effectiveHyperliquidUnifiedAcct, effectiveHyperliquidBuilderApproved, effectiveAsterUser, effectiveAsterSigner, exchangeData.AsterPrivateKey, effectiveLighterWalletAddr, exchangeData.LighterPrivateKey, exchangeData.LighterAPIKeyPrivateKey, exchangeData.LighterAPIKeyIndex)
|
|
||||||
if err != nil {
|
|
||||||
SafeInternalError(c, fmt.Sprintf("Update exchange %s", exchangeID), err)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
s.exchangeAccountStateCache.Invalidate(userID)
|
|
||||||
|
|
||||||
// Remove affected traders from memory BEFORE reloading to pick up new config
|
|
||||||
for traderID := range tradersToReload {
|
|
||||||
logger.Infof("🔄 Removing trader %s from memory to reload with new exchange config", traderID)
|
|
||||||
s.traderManager.RemoveTrader(traderID)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Reload all traders for this user to make new config take effect immediately
|
|
||||||
err = s.traderManager.LoadUserTradersFromStore(s.store, userID)
|
|
||||||
if err != nil {
|
|
||||||
logger.Infof("⚠️ Failed to reload user traders into memory: %v", err)
|
|
||||||
// Don't return error here since exchange config was successfully updated to database
|
|
||||||
}
|
|
||||||
|
|
||||||
logger.Infof("✓ Exchange config updated: %+v", SanitizeExchangeConfigForLog(req.Exchanges))
|
|
||||||
c.JSON(http.StatusOK, gin.H{"message": "Exchange configuration updated"})
|
|
||||||
}
|
|
||||||
|
|
||||||
// handleCreateExchange Create a new exchange account
|
|
||||||
func (s *Server) handleCreateExchange(c *gin.Context) {
|
|
||||||
userID := c.GetString("user_id")
|
|
||||||
cfg := config.Get()
|
|
||||||
|
|
||||||
// Read raw request body
|
|
||||||
bodyBytes, err := c.GetRawData()
|
|
||||||
if err != nil {
|
|
||||||
c.JSON(http.StatusBadRequest, gin.H{"error": "Failed to read request body"})
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
var req CreateExchangeRequest
|
|
||||||
|
|
||||||
// Check if transport encryption is enabled
|
|
||||||
if !cfg.TransportEncryption {
|
|
||||||
// Transport encryption disabled, accept plain JSON
|
|
||||||
if err := json.Unmarshal(bodyBytes, &req); err != nil {
|
|
||||||
logger.Infof("❌ Failed to parse plain JSON request: %v", err)
|
|
||||||
c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid request format"})
|
|
||||||
return
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
// Transport encryption enabled, require encrypted payload
|
|
||||||
var encryptedPayload crypto.EncryptedPayload
|
|
||||||
if err := json.Unmarshal(bodyBytes, &encryptedPayload); err != nil {
|
|
||||||
c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid request format, encrypted transmission required"})
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
if encryptedPayload.WrappedKey == "" {
|
|
||||||
c.JSON(http.StatusBadRequest, gin.H{
|
|
||||||
"error": "This endpoint only supports encrypted transmission",
|
|
||||||
"code": "ENCRYPTION_REQUIRED",
|
|
||||||
"message": "Encrypted transmission is required for security reasons",
|
|
||||||
})
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
decrypted, err := s.cryptoHandler.cryptoService.DecryptSensitiveData(&encryptedPayload)
|
|
||||||
if err != nil {
|
|
||||||
c.JSON(http.StatusBadRequest, gin.H{"error": "Failed to decrypt data"})
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
if err := json.Unmarshal([]byte(decrypted), &req); err != nil {
|
|
||||||
c.JSON(http.StatusBadRequest, gin.H{"error": "Failed to parse decrypted data"})
|
|
||||||
return
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Validate exchange type
|
|
||||||
validTypes := map[string]bool{
|
|
||||||
"binance": true, "bybit": true, "okx": true, "bitget": true,
|
|
||||||
"hyperliquid": true, "aster": true, "lighter": true, "gate": true, "kucoin": true, "indodax": true,
|
|
||||||
}
|
|
||||||
if !validTypes[req.ExchangeType] {
|
|
||||||
c.JSON(http.StatusBadRequest, gin.H{"error": fmt.Sprintf("Invalid exchange type: %s", req.ExchangeType)})
|
|
||||||
return
|
|
||||||
}
|
|
||||||
if missing := store.MissingRequiredExchangeCredentialFields(
|
|
||||||
req.ExchangeType,
|
|
||||||
req.APIKey,
|
|
||||||
req.SecretKey,
|
|
||||||
req.Passphrase,
|
|
||||||
req.HyperliquidWalletAddr,
|
|
||||||
req.AsterUser,
|
|
||||||
req.AsterSigner,
|
|
||||||
req.AsterPrivateKey,
|
|
||||||
req.LighterWalletAddr,
|
|
||||||
req.LighterAPIKeyPrivateKey,
|
|
||||||
); len(missing) > 0 {
|
|
||||||
c.JSON(http.StatusBadRequest, gin.H{
|
|
||||||
"error": fmt.Sprintf("Missing required exchange fields: %s", strings.Join(missing, ", ")),
|
|
||||||
"missing_fields": missing,
|
|
||||||
})
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
// Exchange configs only persist once complete; persisted configs are always enabled.
|
|
||||||
effectiveHyperliquidUnifiedAcct := effectiveHyperliquidUnifiedAccount(req.ExchangeType, req.HyperliquidUnifiedAcct)
|
|
||||||
id, err := s.store.Exchange().Create(
|
|
||||||
userID, req.ExchangeType, req.AccountName, true,
|
|
||||||
req.APIKey, req.SecretKey, req.Passphrase, req.Testnet,
|
|
||||||
req.HyperliquidWalletAddr, effectiveHyperliquidUnifiedAcct, req.HyperliquidBuilderApproved,
|
|
||||||
req.AsterUser, req.AsterSigner, req.AsterPrivateKey,
|
|
||||||
req.LighterWalletAddr, req.LighterPrivateKey, req.LighterAPIKeyPrivateKey, req.LighterAPIKeyIndex,
|
|
||||||
)
|
|
||||||
if err != nil {
|
|
||||||
logger.Infof("❌ Failed to create exchange account: %v", err)
|
|
||||||
SafeInternalError(c, "Failed to create exchange account", err)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
s.exchangeAccountStateCache.Invalidate(userID)
|
|
||||||
|
|
||||||
logger.Infof("✓ Created exchange account: type=%s, name=%s, id=%s", req.ExchangeType, req.AccountName, id)
|
|
||||||
c.JSON(http.StatusOK, gin.H{
|
|
||||||
"message": "Exchange account created",
|
|
||||||
"id": id,
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
// handleDeleteExchange Delete an exchange account
|
|
||||||
func (s *Server) handleDeleteExchange(c *gin.Context) {
|
|
||||||
userID := c.GetString("user_id")
|
|
||||||
exchangeID := c.Param("id")
|
|
||||||
|
|
||||||
if exchangeID == "" {
|
|
||||||
c.JSON(http.StatusBadRequest, gin.H{"error": "Exchange ID is required"})
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
// Check if any traders are using this exchange
|
|
||||||
traders, err := s.store.Trader().List(userID)
|
|
||||||
if err != nil {
|
|
||||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to check traders"})
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
for _, trader := range traders {
|
|
||||||
if trader.ExchangeID == exchangeID {
|
|
||||||
c.JSON(http.StatusBadRequest, gin.H{
|
|
||||||
"error": "Cannot delete exchange account that is in use by traders",
|
|
||||||
"trader_id": trader.ID,
|
|
||||||
"trader_name": trader.Name,
|
|
||||||
})
|
|
||||||
return
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Delete exchange account
|
|
||||||
err = s.store.Exchange().Delete(userID, exchangeID)
|
|
||||||
if err != nil {
|
|
||||||
logger.Infof("❌ Failed to delete exchange account: %v", err)
|
|
||||||
SafeInternalError(c, "Failed to delete exchange account", err)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
s.exchangeAccountStateCache.Invalidate(userID)
|
|
||||||
|
|
||||||
logger.Infof("✓ Deleted exchange account: id=%s", exchangeID)
|
|
||||||
c.JSON(http.StatusOK, gin.H{"message": "Exchange account deleted"})
|
|
||||||
}
|
|
||||||
|
|
||||||
// handleGetSupportedExchanges Get list of exchanges supported by the system
|
|
||||||
func (s *Server) handleGetSupportedExchanges(c *gin.Context) {
|
|
||||||
// Return static list of supported exchange types
|
|
||||||
// Note: ID is empty for supported exchanges (they are templates, not actual accounts)
|
|
||||||
supportedExchanges := []SafeExchangeConfig{
|
|
||||||
{ExchangeType: "binance", Name: "Binance Futures", Type: "cex"},
|
|
||||||
{ExchangeType: "bybit", Name: "Bybit Futures", Type: "cex"},
|
|
||||||
{ExchangeType: "okx", Name: "OKX Futures", Type: "cex"},
|
|
||||||
{ExchangeType: "gate", Name: "Gate.io Futures", Type: "cex"},
|
|
||||||
{ExchangeType: "kucoin", Name: "KuCoin Futures", Type: "cex"},
|
|
||||||
{ExchangeType: "hyperliquid", Name: "Hyperliquid", Type: "dex"},
|
|
||||||
{ExchangeType: "aster", Name: "Aster DEX", Type: "dex"},
|
|
||||||
{ExchangeType: "lighter", Name: "LIGHTER DEX", Type: "dex"},
|
|
||||||
{ExchangeType: "alpaca", Name: "Alpaca (US Stocks)", Type: "stock"},
|
|
||||||
{ExchangeType: "forex", Name: "Forex (TwelveData)", Type: "forex"},
|
|
||||||
{ExchangeType: "metals", Name: "Metals (TwelveData)", Type: "metals"},
|
|
||||||
}
|
|
||||||
|
|
||||||
c.JSON(http.StatusOK, supportedExchanges)
|
|
||||||
}
|
|
||||||
@@ -1,66 +0,0 @@
|
|||||||
package api
|
|
||||||
|
|
||||||
import (
|
|
||||||
"testing"
|
|
||||||
|
|
||||||
"nofx/crypto"
|
|
||||||
"nofx/store"
|
|
||||||
)
|
|
||||||
|
|
||||||
func TestSafeExchangeConfigFromStoreIncludesCredentialPresenceFlags(t *testing.T) {
|
|
||||||
cfg := &store.Exchange{
|
|
||||||
ID: "ex-1",
|
|
||||||
ExchangeType: "okx",
|
|
||||||
AccountName: "OKX Main",
|
|
||||||
Name: "OKX Main",
|
|
||||||
Type: "cex",
|
|
||||||
Enabled: true,
|
|
||||||
APIKey: crypto.EncryptedString("api-test-123"),
|
|
||||||
SecretKey: crypto.EncryptedString("secret-test-123"),
|
|
||||||
Passphrase: crypto.EncryptedString("passphrase-test-123"),
|
|
||||||
HyperliquidUnifiedAcct: true,
|
|
||||||
AsterPrivateKey: crypto.EncryptedString("aster-private-key"),
|
|
||||||
LighterPrivateKey: crypto.EncryptedString("lighter-private-key"),
|
|
||||||
LighterAPIKeyPrivateKey: crypto.EncryptedString("lighter-api-key-private-key"),
|
|
||||||
}
|
|
||||||
|
|
||||||
safe := safeExchangeConfigFromStore(cfg)
|
|
||||||
if !safe.HasAPIKey {
|
|
||||||
t.Fatalf("expected has_api_key to be true")
|
|
||||||
}
|
|
||||||
if !safe.HasSecretKey {
|
|
||||||
t.Fatalf("expected has_secret_key to be true")
|
|
||||||
}
|
|
||||||
if !safe.HasPassphrase {
|
|
||||||
t.Fatalf("expected has_passphrase to be true")
|
|
||||||
}
|
|
||||||
if !safe.HasAsterPrivateKey {
|
|
||||||
t.Fatalf("expected has_aster_private_key to be true")
|
|
||||||
}
|
|
||||||
if !safe.HasLighterPrivateKey {
|
|
||||||
t.Fatalf("expected has_lighter_private_key to be true")
|
|
||||||
}
|
|
||||||
if !safe.HasLighterAPIKey {
|
|
||||||
t.Fatalf("expected has_lighter_api_key_private_key to be true")
|
|
||||||
}
|
|
||||||
if !safe.HyperliquidUnifiedAcct {
|
|
||||||
t.Fatalf("expected hyperliquid unified account to be exposed")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestEffectiveHyperliquidUnifiedAccountDefaultsAndPreserves(t *testing.T) {
|
|
||||||
if !effectiveHyperliquidUnifiedAccount("hyperliquid", nil) {
|
|
||||||
t.Fatalf("expected new hyperliquid accounts to default unified account on")
|
|
||||||
}
|
|
||||||
if effectiveHyperliquidUnifiedAccount("binance", nil) {
|
|
||||||
t.Fatalf("expected non-hyperliquid accounts to default unified account off")
|
|
||||||
}
|
|
||||||
fallbackFalse := effectiveHyperliquidUnifiedAccount("hyperliquid", nil, false)
|
|
||||||
if fallbackFalse {
|
|
||||||
t.Fatalf("expected omitted update field to preserve existing false value")
|
|
||||||
}
|
|
||||||
requestedTrue := true
|
|
||||||
if !effectiveHyperliquidUnifiedAccount("hyperliquid", &requestedTrue, false) {
|
|
||||||
t.Fatalf("expected explicit true to override existing false value")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,469 +0,0 @@
|
|||||||
package api
|
|
||||||
|
|
||||||
import (
|
|
||||||
"bytes"
|
|
||||||
"encoding/json"
|
|
||||||
"fmt"
|
|
||||||
"io"
|
|
||||||
"net/http"
|
|
||||||
"regexp"
|
|
||||||
"strconv"
|
|
||||||
"strings"
|
|
||||||
"time"
|
|
||||||
|
|
||||||
"github.com/gin-gonic/gin"
|
|
||||||
)
|
|
||||||
|
|
||||||
const (
|
|
||||||
defaultHyperliquidBuilderAddress = "0x891dc6f05ad47a3c1a05da55e7a7517971faaf0d"
|
|
||||||
// 0.05% (5 bps) — matches BuilderInfo.Fee=50 charged at order placement.
|
|
||||||
// New wallet approvals sign this exact value; existing approvals at the
|
|
||||||
// prior 0.1% cap remain valid because 0.05% is within their approved max.
|
|
||||||
defaultHyperliquidBuilderMaxFee = "0.05%"
|
|
||||||
hyperliquidExchangeURL = "https://api.hyperliquid.xyz/exchange"
|
|
||||||
hyperliquidInfoURL = "https://api.hyperliquid.xyz/info"
|
|
||||||
// nofxHyperliquidAgentName must match AGENT_NAME used by the frontend
|
|
||||||
// approveAgent flow so we can locate the NOFX-managed agent on-chain.
|
|
||||||
nofxHyperliquidAgentName = "NOFX Agent"
|
|
||||||
)
|
|
||||||
|
|
||||||
type hyperliquidSubmitRequest struct {
|
|
||||||
Action map[string]any `json:"action" binding:"required"`
|
|
||||||
Nonce int64 `json:"nonce" binding:"required"`
|
|
||||||
Signature struct {
|
|
||||||
R string `json:"r" binding:"required"`
|
|
||||||
S string `json:"s" binding:"required"`
|
|
||||||
V int `json:"v"`
|
|
||||||
} `json:"signature" binding:"required"`
|
|
||||||
}
|
|
||||||
|
|
||||||
type hyperliquidConfigResponse struct {
|
|
||||||
BuilderAddress string `json:"builderAddress"`
|
|
||||||
BuilderMaxFee string `json:"builderMaxFee"`
|
|
||||||
Chain string `json:"chain"`
|
|
||||||
SignatureChain string `json:"signatureChainId"`
|
|
||||||
}
|
|
||||||
|
|
||||||
type hyperliquidAccountSummary struct {
|
|
||||||
Address string `json:"address"`
|
|
||||||
AccountValue float64 `json:"accountValue"`
|
|
||||||
Withdrawable float64 `json:"withdrawable"`
|
|
||||||
TotalMarginUsed float64 `json:"totalMarginUsed"`
|
|
||||||
UnrealizedPnl float64 `json:"unrealizedPnl"`
|
|
||||||
OpenPositions int `json:"openPositions"`
|
|
||||||
// Spot USDC ("main wallet") balance, so the UI can offer spot->perp funding.
|
|
||||||
SpotUSDC float64 `json:"spotUsdc"`
|
|
||||||
SpotUSDCAvailable float64 `json:"spotUsdcAvailable"`
|
|
||||||
UpdatedAt int64 `json:"updatedAt"`
|
|
||||||
}
|
|
||||||
|
|
||||||
type hyperliquidSpotState struct {
|
|
||||||
Balances []struct {
|
|
||||||
Coin string `json:"coin"`
|
|
||||||
Total string `json:"total"`
|
|
||||||
Hold string `json:"hold"`
|
|
||||||
} `json:"balances"`
|
|
||||||
}
|
|
||||||
|
|
||||||
type hyperliquidAgentInfo struct {
|
|
||||||
Name string `json:"name"`
|
|
||||||
Address string `json:"address"`
|
|
||||||
ValidUntil int64 `json:"validUntil"` // unix milliseconds
|
|
||||||
}
|
|
||||||
|
|
||||||
type hyperliquidAgentResponse struct {
|
|
||||||
// Agent is the NOFX-managed agent ("NOFX Agent"), nil when none is approved.
|
|
||||||
Agent *hyperliquidAgentInfo `json:"agent"`
|
|
||||||
// Agents lists every approved agent for the wallet (for visibility/cleanup).
|
|
||||||
Agents []hyperliquidAgentInfo `json:"agents"`
|
|
||||||
}
|
|
||||||
|
|
||||||
type hyperliquidClearinghouseState struct {
|
|
||||||
MarginSummary struct {
|
|
||||||
AccountValue string `json:"accountValue"`
|
|
||||||
TotalMarginUsed string `json:"totalMarginUsed"`
|
|
||||||
} `json:"marginSummary"`
|
|
||||||
CrossMarginSummary struct {
|
|
||||||
AccountValue string `json:"accountValue"`
|
|
||||||
TotalMarginUsed string `json:"totalMarginUsed"`
|
|
||||||
} `json:"crossMarginSummary"`
|
|
||||||
Withdrawable string `json:"withdrawable"`
|
|
||||||
AssetPositions []struct {
|
|
||||||
Position struct {
|
|
||||||
Szi string `json:"szi"`
|
|
||||||
UnrealizedPnl string `json:"unrealizedPnl"`
|
|
||||||
} `json:"position"`
|
|
||||||
} `json:"assetPositions"`
|
|
||||||
}
|
|
||||||
|
|
||||||
// agentValidUntilSuffix matches the " valid_until <ms>" suffix Hyperliquid uses
|
|
||||||
// to encode an agent's expiry inside the agent name. Hyperliquid normally strips
|
|
||||||
// it from the stored name, but we strip defensively before matching the slot.
|
|
||||||
var agentValidUntilSuffix = regexp.MustCompile(` valid_until \d+$`)
|
|
||||||
|
|
||||||
func baseAgentName(name string) string {
|
|
||||||
return strings.TrimSpace(agentValidUntilSuffix.ReplaceAllString(name, ""))
|
|
||||||
}
|
|
||||||
|
|
||||||
func hyperliquidBuilderAddress() string {
|
|
||||||
return defaultHyperliquidBuilderAddress
|
|
||||||
}
|
|
||||||
|
|
||||||
func hyperliquidBuilderMaxFee() string {
|
|
||||||
return defaultHyperliquidBuilderMaxFee
|
|
||||||
}
|
|
||||||
|
|
||||||
func (s *Server) handleHyperliquidConnectConfig(c *gin.Context) {
|
|
||||||
c.JSON(http.StatusOK, hyperliquidConfigResponse{
|
|
||||||
BuilderAddress: hyperliquidBuilderAddress(),
|
|
||||||
BuilderMaxFee: hyperliquidBuilderMaxFee(),
|
|
||||||
Chain: "Mainnet",
|
|
||||||
SignatureChain: "0x66eee",
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
func (s *Server) handleHyperliquidAccount(c *gin.Context) {
|
|
||||||
address := strings.ToLower(strings.TrimSpace(c.Query("address")))
|
|
||||||
if !isEVMAddress(address) {
|
|
||||||
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid Hyperliquid wallet address"})
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
var state hyperliquidClearinghouseState
|
|
||||||
if err := postHyperliquidInfo(c, map[string]any{"type": "clearinghouseState", "user": address}, &state); err != nil {
|
|
||||||
c.JSON(http.StatusBadGateway, gin.H{"error": "failed to query Hyperliquid balance", "detail": err.Error()})
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
// Spot ("main wallet") balance is best-effort: a wallet with no spot assets
|
|
||||||
// must not break the perp summary.
|
|
||||||
var spotUSDC, spotAvailable float64
|
|
||||||
var spotState hyperliquidSpotState
|
|
||||||
if err := postHyperliquidInfo(c, map[string]any{"type": "spotClearinghouseState", "user": address}, &spotState); err == nil {
|
|
||||||
for _, balance := range spotState.Balances {
|
|
||||||
if strings.EqualFold(balance.Coin, "USDC") {
|
|
||||||
spotUSDC = parseFloatOrZero(balance.Total)
|
|
||||||
spotAvailable = spotUSDC - parseFloatOrZero(balance.Hold)
|
|
||||||
if spotAvailable < 0 {
|
|
||||||
spotAvailable = 0
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
accountValue := parseFloatOrZero(state.MarginSummary.AccountValue)
|
|
||||||
if accountValue == 0 {
|
|
||||||
accountValue = parseFloatOrZero(state.CrossMarginSummary.AccountValue)
|
|
||||||
}
|
|
||||||
marginUsed := parseFloatOrZero(state.MarginSummary.TotalMarginUsed)
|
|
||||||
if marginUsed == 0 {
|
|
||||||
marginUsed = parseFloatOrZero(state.CrossMarginSummary.TotalMarginUsed)
|
|
||||||
}
|
|
||||||
|
|
||||||
var unrealizedPnl float64
|
|
||||||
openPositions := 0
|
|
||||||
for _, position := range state.AssetPositions {
|
|
||||||
size := parseFloatOrZero(position.Position.Szi)
|
|
||||||
if size != 0 {
|
|
||||||
openPositions++
|
|
||||||
}
|
|
||||||
unrealizedPnl += parseFloatOrZero(position.Position.UnrealizedPnl)
|
|
||||||
}
|
|
||||||
|
|
||||||
c.JSON(http.StatusOK, hyperliquidAccountSummary{
|
|
||||||
Address: address,
|
|
||||||
AccountValue: accountValue,
|
|
||||||
Withdrawable: parseFloatOrZero(state.Withdrawable),
|
|
||||||
TotalMarginUsed: marginUsed,
|
|
||||||
UnrealizedPnl: unrealizedPnl,
|
|
||||||
OpenPositions: openPositions,
|
|
||||||
SpotUSDC: spotUSDC,
|
|
||||||
SpotUSDCAvailable: spotAvailable,
|
|
||||||
UpdatedAt: time.Now().UnixMilli(),
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
// postHyperliquidInfo posts a query to the Hyperliquid info endpoint and
|
|
||||||
// decodes the JSON response into out.
|
|
||||||
func postHyperliquidInfo(c *gin.Context, requestBody map[string]any, out any) error {
|
|
||||||
body, err := json.Marshal(requestBody)
|
|
||||||
if err != nil {
|
|
||||||
return fmt.Errorf("encode request: %w", err)
|
|
||||||
}
|
|
||||||
req, err := http.NewRequestWithContext(c.Request.Context(), http.MethodPost, hyperliquidInfoURL, bytes.NewReader(body))
|
|
||||||
if err != nil {
|
|
||||||
return fmt.Errorf("create request: %w", err)
|
|
||||||
}
|
|
||||||
req.Header.Set("Content-Type", "application/json")
|
|
||||||
|
|
||||||
client := &http.Client{Timeout: 20 * time.Second}
|
|
||||||
resp, err := client.Do(req)
|
|
||||||
if err != nil {
|
|
||||||
return fmt.Errorf("reach Hyperliquid: %w", err)
|
|
||||||
}
|
|
||||||
defer resp.Body.Close()
|
|
||||||
|
|
||||||
respBody, _ := io.ReadAll(io.LimitReader(resp.Body, 1<<20))
|
|
||||||
if resp.StatusCode < 200 || resp.StatusCode >= 300 {
|
|
||||||
return fmt.Errorf("Hyperliquid returned status %d", resp.StatusCode)
|
|
||||||
}
|
|
||||||
if err := json.Unmarshal(respBody, out); err != nil {
|
|
||||||
return fmt.Errorf("parse response: %w", err)
|
|
||||||
}
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// handleHyperliquidAgent reports the on-chain approved agents for a wallet,
|
|
||||||
// including the NOFX agent's validUntil so the UI can show the expiry date and
|
|
||||||
// warn before the 180-day authorization lapses.
|
|
||||||
func (s *Server) handleHyperliquidAgent(c *gin.Context) {
|
|
||||||
address := strings.ToLower(strings.TrimSpace(c.Query("address")))
|
|
||||||
if !isEVMAddress(address) {
|
|
||||||
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid Hyperliquid wallet address"})
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
body, err := json.Marshal(map[string]any{"type": "extraAgents", "user": address})
|
|
||||||
if err != nil {
|
|
||||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to encode Hyperliquid agent request"})
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
req, err := http.NewRequestWithContext(c.Request.Context(), http.MethodPost, hyperliquidInfoURL, bytes.NewReader(body))
|
|
||||||
if err != nil {
|
|
||||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to create Hyperliquid agent request"})
|
|
||||||
return
|
|
||||||
}
|
|
||||||
req.Header.Set("Content-Type", "application/json")
|
|
||||||
|
|
||||||
client := &http.Client{Timeout: 20 * time.Second}
|
|
||||||
resp, err := client.Do(req)
|
|
||||||
if err != nil {
|
|
||||||
c.JSON(http.StatusBadGateway, gin.H{"error": "failed to reach Hyperliquid", "detail": err.Error()})
|
|
||||||
return
|
|
||||||
}
|
|
||||||
defer resp.Body.Close()
|
|
||||||
|
|
||||||
respBody, _ := io.ReadAll(io.LimitReader(resp.Body, 1<<20))
|
|
||||||
if resp.StatusCode < 200 || resp.StatusCode >= 300 {
|
|
||||||
c.JSON(http.StatusBadGateway, gin.H{"error": "Hyperliquid rejected the agent request", "status": resp.StatusCode})
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
// extraAgents returns null when no agents are approved.
|
|
||||||
agents := []hyperliquidAgentInfo{}
|
|
||||||
if len(respBody) > 0 && string(bytes.TrimSpace(respBody)) != "null" {
|
|
||||||
if err := json.Unmarshal(respBody, &agents); err != nil {
|
|
||||||
c.JSON(http.StatusBadGateway, gin.H{"error": "failed to parse Hyperliquid agent response"})
|
|
||||||
return
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
out := hyperliquidAgentResponse{Agents: agents}
|
|
||||||
for i := range agents {
|
|
||||||
if strings.EqualFold(baseAgentName(agents[i].Name), nofxHyperliquidAgentName) {
|
|
||||||
agent := agents[i]
|
|
||||||
out.Agent = &agent
|
|
||||||
break
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
c.JSON(http.StatusOK, out)
|
|
||||||
}
|
|
||||||
|
|
||||||
func (s *Server) handleHyperliquidSubmitExchange(c *gin.Context) {
|
|
||||||
var req hyperliquidSubmitRequest
|
|
||||||
if err := c.ShouldBindJSON(&req); err != nil {
|
|
||||||
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid Hyperliquid submit payload"})
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
if err := validateSubmittedNonce(req.Action, req.Nonce); err != nil {
|
|
||||||
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
actionType, _ := req.Action["type"].(string)
|
|
||||||
switch actionType {
|
|
||||||
case "approveAgent":
|
|
||||||
if err := validateApproveAgentAction(req.Action); err != nil {
|
|
||||||
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
|
||||||
return
|
|
||||||
}
|
|
||||||
case "approveBuilderFee":
|
|
||||||
if err := validateApproveBuilderFeeAction(req.Action); err != nil {
|
|
||||||
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
|
||||||
return
|
|
||||||
}
|
|
||||||
case "usdClassTransfer":
|
|
||||||
if err := validateUsdClassTransferAction(req.Action); err != nil {
|
|
||||||
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
|
||||||
return
|
|
||||||
}
|
|
||||||
default:
|
|
||||||
c.JSON(http.StatusBadRequest, gin.H{"error": "unsupported Hyperliquid action"})
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
payload := map[string]any{
|
|
||||||
"action": req.Action,
|
|
||||||
"nonce": req.Nonce,
|
|
||||||
"signature": req.Signature,
|
|
||||||
}
|
|
||||||
body, err := json.Marshal(payload)
|
|
||||||
if err != nil {
|
|
||||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to encode Hyperliquid payload"})
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
client := &http.Client{Timeout: 20 * time.Second}
|
|
||||||
hlReq, err := http.NewRequestWithContext(c.Request.Context(), http.MethodPost, hyperliquidExchangeURL, bytes.NewReader(body))
|
|
||||||
if err != nil {
|
|
||||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to create Hyperliquid request"})
|
|
||||||
return
|
|
||||||
}
|
|
||||||
hlReq.Header.Set("Content-Type", "application/json")
|
|
||||||
|
|
||||||
resp, err := client.Do(hlReq)
|
|
||||||
if err != nil {
|
|
||||||
c.JSON(http.StatusBadGateway, gin.H{"error": "failed to reach Hyperliquid", "detail": err.Error()})
|
|
||||||
return
|
|
||||||
}
|
|
||||||
defer resp.Body.Close()
|
|
||||||
|
|
||||||
respBody, _ := io.ReadAll(io.LimitReader(resp.Body, 1<<20))
|
|
||||||
var decoded any
|
|
||||||
if len(respBody) > 0 {
|
|
||||||
_ = json.Unmarshal(respBody, &decoded)
|
|
||||||
}
|
|
||||||
if resp.StatusCode < 200 || resp.StatusCode >= 300 {
|
|
||||||
c.JSON(http.StatusBadGateway, gin.H{"error": "Hyperliquid rejected the action", "status": resp.StatusCode, "response": decoded})
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
// Hyperliquid returns HTTP 200 even for logical failures, signalling them via
|
|
||||||
// {"status":"err","response":"<message>"}. Without this check a rejected
|
|
||||||
// approval (e.g. valid_until past the cap, or an unchanged agent) is reported
|
|
||||||
// to the user as success while nothing changes on-chain.
|
|
||||||
var hlResp struct {
|
|
||||||
Status string `json:"status"`
|
|
||||||
Response json.RawMessage `json:"response"`
|
|
||||||
}
|
|
||||||
if err := json.Unmarshal(respBody, &hlResp); err == nil && strings.EqualFold(hlResp.Status, "err") {
|
|
||||||
msg := strings.TrimSpace(strings.Trim(string(hlResp.Response), `"`))
|
|
||||||
if msg == "" {
|
|
||||||
msg = "Hyperliquid rejected the action"
|
|
||||||
}
|
|
||||||
c.JSON(http.StatusBadGateway, gin.H{"error": msg, "response": decoded})
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
c.JSON(http.StatusOK, gin.H{"success": true, "response": decoded})
|
|
||||||
}
|
|
||||||
|
|
||||||
func validateApproveAgentAction(action map[string]any) error {
|
|
||||||
if strings.TrimSpace(fmt.Sprint(action["agentAddress"])) == "" {
|
|
||||||
return fmt.Errorf("missing agentAddress")
|
|
||||||
}
|
|
||||||
if strings.TrimSpace(fmt.Sprint(action["agentName"])) == "" {
|
|
||||||
return fmt.Errorf("missing agentName")
|
|
||||||
}
|
|
||||||
return validateCommonHyperliquidSignedAction(action)
|
|
||||||
}
|
|
||||||
|
|
||||||
func validateApproveBuilderFeeAction(action map[string]any) error {
|
|
||||||
builder := strings.ToLower(strings.TrimSpace(fmt.Sprint(action["builder"])))
|
|
||||||
if builder != hyperliquidBuilderAddress() {
|
|
||||||
return fmt.Errorf("builder address mismatch")
|
|
||||||
}
|
|
||||||
if strings.TrimSpace(fmt.Sprint(action["maxFeeRate"])) != hyperliquidBuilderMaxFee() {
|
|
||||||
return fmt.Errorf("builder max fee mismatch")
|
|
||||||
}
|
|
||||||
return validateCommonHyperliquidSignedAction(action)
|
|
||||||
}
|
|
||||||
|
|
||||||
// validateUsdClassTransferAction guards the spot<->perp transfer relay. The
|
|
||||||
// signature is produced by the user's own wallet, so the account moved is
|
|
||||||
// always the signer's; validation only keeps malformed or SDK-style
|
|
||||||
// subaccount-suffixed amounts from reaching Hyperliquid through NOFX.
|
|
||||||
func validateUsdClassTransferAction(action map[string]any) error {
|
|
||||||
rawAmount, ok := action["amount"].(string)
|
|
||||||
if !ok {
|
|
||||||
return fmt.Errorf("missing amount")
|
|
||||||
}
|
|
||||||
amount := strings.TrimSpace(rawAmount)
|
|
||||||
if strings.Contains(amount, " ") {
|
|
||||||
return fmt.Errorf("invalid amount")
|
|
||||||
}
|
|
||||||
parsed, err := strconv.ParseFloat(amount, 64)
|
|
||||||
if err != nil || parsed <= 0 {
|
|
||||||
return fmt.Errorf("amount must be a positive number")
|
|
||||||
}
|
|
||||||
if _, ok := action["toPerp"].(bool); !ok {
|
|
||||||
return fmt.Errorf("missing or invalid toPerp")
|
|
||||||
}
|
|
||||||
return validateCommonHyperliquidSignedAction(action)
|
|
||||||
}
|
|
||||||
|
|
||||||
func validateCommonHyperliquidSignedAction(action map[string]any) error {
|
|
||||||
if strings.TrimSpace(fmt.Sprint(action["signatureChainId"])) != "0x66eee" {
|
|
||||||
return fmt.Errorf("invalid signatureChainId")
|
|
||||||
}
|
|
||||||
if strings.TrimSpace(fmt.Sprint(action["hyperliquidChain"])) != "Mainnet" {
|
|
||||||
return fmt.Errorf("invalid hyperliquidChain")
|
|
||||||
}
|
|
||||||
if _, err := actionNonce(action); err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func validateSubmittedNonce(action map[string]any, submitted int64) error {
|
|
||||||
actionValue, err := actionNonce(action)
|
|
||||||
if err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
if actionValue != submitted {
|
|
||||||
return fmt.Errorf("nonce mismatch")
|
|
||||||
}
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func isEVMAddress(address string) bool {
|
|
||||||
if len(address) != 42 || !strings.HasPrefix(address, "0x") {
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
for _, char := range address[2:] {
|
|
||||||
if (char < '0' || char > '9') && (char < 'a' || char > 'f') {
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return true
|
|
||||||
}
|
|
||||||
|
|
||||||
func parseFloatOrZero(value string) float64 {
|
|
||||||
parsed, err := strconv.ParseFloat(strings.TrimSpace(value), 64)
|
|
||||||
if err != nil {
|
|
||||||
return 0
|
|
||||||
}
|
|
||||||
return parsed
|
|
||||||
}
|
|
||||||
|
|
||||||
func actionNonce(action map[string]any) (int64, error) {
|
|
||||||
raw, ok := action["nonce"]
|
|
||||||
if !ok {
|
|
||||||
return 0, fmt.Errorf("missing nonce")
|
|
||||||
}
|
|
||||||
switch value := raw.(type) {
|
|
||||||
case float64:
|
|
||||||
return int64(value), nil
|
|
||||||
case int64:
|
|
||||||
return value, nil
|
|
||||||
case json.Number:
|
|
||||||
return value.Int64()
|
|
||||||
case string:
|
|
||||||
return strconv.ParseInt(value, 10, 64)
|
|
||||||
default:
|
|
||||||
return 0, fmt.Errorf("invalid nonce")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,67 +0,0 @@
|
|||||||
package api
|
|
||||||
|
|
||||||
import (
|
|
||||||
"strings"
|
|
||||||
"testing"
|
|
||||||
)
|
|
||||||
|
|
||||||
func usdClassTransferAction(overrides map[string]any) map[string]any {
|
|
||||||
action := map[string]any{
|
|
||||||
"type": "usdClassTransfer",
|
|
||||||
"signatureChainId": "0x66eee",
|
|
||||||
"hyperliquidChain": "Mainnet",
|
|
||||||
"amount": "21.5",
|
|
||||||
"toPerp": true,
|
|
||||||
"nonce": float64(1784900000000),
|
|
||||||
}
|
|
||||||
for k, v := range overrides {
|
|
||||||
if v == nil {
|
|
||||||
delete(action, k)
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
action[k] = v
|
|
||||||
}
|
|
||||||
return action
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestValidateUsdClassTransferActionAcceptsValidTransfer(t *testing.T) {
|
|
||||||
if err := validateUsdClassTransferAction(usdClassTransferAction(nil)); err != nil {
|
|
||||||
t.Fatalf("expected valid usdClassTransfer to pass, got %v", err)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestValidateUsdClassTransferActionRejectsBadAmounts(t *testing.T) {
|
|
||||||
cases := map[string]any{
|
|
||||||
"zero": "0",
|
|
||||||
"negative": "-5",
|
|
||||||
"not number": "abc",
|
|
||||||
"empty": "",
|
|
||||||
// The SDK's subaccount suffix must not be relayable from the browser.
|
|
||||||
"subaccount": "21.5 subaccount:0x1234",
|
|
||||||
}
|
|
||||||
for name, amount := range cases {
|
|
||||||
err := validateUsdClassTransferAction(usdClassTransferAction(map[string]any{"amount": amount}))
|
|
||||||
if err == nil {
|
|
||||||
t.Fatalf("%s: expected amount %q to be rejected", name, amount)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestValidateUsdClassTransferActionRequiresBooleanToPerp(t *testing.T) {
|
|
||||||
if err := validateUsdClassTransferAction(usdClassTransferAction(map[string]any{"toPerp": nil})); err == nil {
|
|
||||||
t.Fatal("expected missing toPerp to be rejected")
|
|
||||||
}
|
|
||||||
if err := validateUsdClassTransferAction(usdClassTransferAction(map[string]any{"toPerp": "true"})); err == nil {
|
|
||||||
t.Fatal("expected non-boolean toPerp to be rejected")
|
|
||||||
}
|
|
||||||
if err := validateUsdClassTransferAction(usdClassTransferAction(map[string]any{"toPerp": false})); err != nil {
|
|
||||||
t.Fatalf("expected toPerp=false (perp->spot) to be valid, got %v", err)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestValidateUsdClassTransferActionRequiresMainnetChain(t *testing.T) {
|
|
||||||
err := validateUsdClassTransferAction(usdClassTransferAction(map[string]any{"hyperliquidChain": "Testnet"}))
|
|
||||||
if err == nil || !strings.Contains(err.Error(), "hyperliquidChain") {
|
|
||||||
t.Fatalf("expected Testnet chain to be rejected, got %v", err)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,548 +0,0 @@
|
|||||||
package api
|
|
||||||
|
|
||||||
import (
|
|
||||||
"context"
|
|
||||||
"fmt"
|
|
||||||
"net/http"
|
|
||||||
"sort"
|
|
||||||
"strconv"
|
|
||||||
"strings"
|
|
||||||
"time"
|
|
||||||
|
|
||||||
"nofx/logger"
|
|
||||||
"nofx/market"
|
|
||||||
"nofx/provider/alpaca"
|
|
||||||
"nofx/provider/coinank/coinank_api"
|
|
||||||
"nofx/provider/coinank/coinank_enum"
|
|
||||||
"nofx/provider/hyperliquid"
|
|
||||||
"nofx/provider/twelvedata"
|
|
||||||
|
|
||||||
"github.com/gin-gonic/gin"
|
|
||||||
)
|
|
||||||
|
|
||||||
// handleKlines K-line data (supports multiple exchanges via coinank)
|
|
||||||
func (s *Server) handleKlines(c *gin.Context) {
|
|
||||||
// Get query parameters
|
|
||||||
symbol := c.Query("symbol")
|
|
||||||
if symbol == "" {
|
|
||||||
c.JSON(http.StatusBadRequest, gin.H{"error": "symbol parameter is required"})
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
interval := c.DefaultQuery("interval", "5m")
|
|
||||||
exchange := c.DefaultQuery("exchange", "binance") // Default to binance for backward compatibility
|
|
||||||
limitStr := c.DefaultQuery("limit", "1000")
|
|
||||||
limit, err := strconv.Atoi(limitStr)
|
|
||||||
if err != nil || limit <= 0 {
|
|
||||||
limit = 1000
|
|
||||||
}
|
|
||||||
|
|
||||||
// Coinank API has a maximum limit of 1500 klines per request
|
|
||||||
if limit > 1500 {
|
|
||||||
limit = 1500
|
|
||||||
}
|
|
||||||
|
|
||||||
var klines []market.Kline
|
|
||||||
exchangeLower := strings.ToLower(exchange)
|
|
||||||
|
|
||||||
// Route to appropriate data source based on exchange type
|
|
||||||
switch exchangeLower {
|
|
||||||
case "alpaca":
|
|
||||||
// US Stocks via Alpaca
|
|
||||||
klines, err = s.getKlinesFromAlpaca(symbol, interval, limit)
|
|
||||||
if err != nil {
|
|
||||||
SafeInternalError(c, "Get klines from Alpaca", err)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
case "forex", "metals":
|
|
||||||
// Forex and Metals via Twelve Data
|
|
||||||
klines, err = s.getKlinesFromTwelveData(symbol, interval, limit)
|
|
||||||
if err != nil {
|
|
||||||
SafeInternalError(c, "Get klines from TwelveData", err)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
case "hyperliquid", "hyperliquid-xyz", "xyz":
|
|
||||||
// Hyperliquid native API - supports both crypto perps and stock perps (xyz dex)
|
|
||||||
klines, err = s.getKlinesFromHyperliquid(symbol, interval, limit)
|
|
||||||
if err != nil {
|
|
||||||
SafeInternalError(c, "Get klines from Hyperliquid", err)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
default:
|
|
||||||
// Crypto exchanges via CoinAnk
|
|
||||||
symbol = market.Normalize(symbol)
|
|
||||||
klines, err = s.getKlinesFromCoinank(symbol, interval, exchange, limit)
|
|
||||||
if err != nil {
|
|
||||||
SafeInternalError(c, "Get klines from CoinAnk", err)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
c.JSON(http.StatusOK, klines)
|
|
||||||
}
|
|
||||||
|
|
||||||
// getKlinesFromCoinank fetches kline data from coinank free/open API for multiple exchanges
|
|
||||||
func (s *Server) getKlinesFromCoinank(symbol, interval, exchange string, limit int) ([]market.Kline, error) {
|
|
||||||
// Map exchange string to coinank enum
|
|
||||||
var coinankExchange coinank_enum.Exchange
|
|
||||||
switch strings.ToLower(exchange) {
|
|
||||||
case "binance":
|
|
||||||
coinankExchange = coinank_enum.Binance
|
|
||||||
case "bybit":
|
|
||||||
coinankExchange = coinank_enum.Bybit
|
|
||||||
case "okx":
|
|
||||||
coinankExchange = coinank_enum.Okex
|
|
||||||
case "bitget":
|
|
||||||
coinankExchange = coinank_enum.Bitget
|
|
||||||
case "gate":
|
|
||||||
coinankExchange = coinank_enum.Gate
|
|
||||||
case "aster":
|
|
||||||
coinankExchange = coinank_enum.Aster
|
|
||||||
case "lighter":
|
|
||||||
// Lighter doesn't have direct CoinAnk support, use Binance data as fallback
|
|
||||||
coinankExchange = coinank_enum.Binance
|
|
||||||
case "kucoin":
|
|
||||||
// KuCoin doesn't have direct CoinAnk support, use Binance data as fallback
|
|
||||||
coinankExchange = coinank_enum.Binance
|
|
||||||
default:
|
|
||||||
// For any unknown exchange, default to Binance
|
|
||||||
logger.Warnf("⚠️ Unknown exchange '%s', defaulting to Binance for CoinAnk", exchange)
|
|
||||||
coinankExchange = coinank_enum.Binance
|
|
||||||
}
|
|
||||||
|
|
||||||
// Map interval string to coinank enum
|
|
||||||
var coinankInterval coinank_enum.Interval
|
|
||||||
switch interval {
|
|
||||||
case "1s":
|
|
||||||
coinankInterval = coinank_enum.Second1
|
|
||||||
case "5s":
|
|
||||||
coinankInterval = coinank_enum.Second5
|
|
||||||
case "10s":
|
|
||||||
coinankInterval = coinank_enum.Second10
|
|
||||||
case "30s":
|
|
||||||
coinankInterval = coinank_enum.Second30
|
|
||||||
case "1m":
|
|
||||||
coinankInterval = coinank_enum.Minute1
|
|
||||||
case "3m":
|
|
||||||
coinankInterval = coinank_enum.Minute3
|
|
||||||
case "5m":
|
|
||||||
coinankInterval = coinank_enum.Minute5
|
|
||||||
case "10m":
|
|
||||||
coinankInterval = coinank_enum.Minute10
|
|
||||||
case "15m":
|
|
||||||
coinankInterval = coinank_enum.Minute15
|
|
||||||
case "30m":
|
|
||||||
coinankInterval = coinank_enum.Minute30
|
|
||||||
case "1h":
|
|
||||||
coinankInterval = coinank_enum.Hour1
|
|
||||||
case "2h":
|
|
||||||
coinankInterval = coinank_enum.Hour2
|
|
||||||
case "4h":
|
|
||||||
coinankInterval = coinank_enum.Hour4
|
|
||||||
case "6h":
|
|
||||||
coinankInterval = coinank_enum.Hour6
|
|
||||||
case "8h":
|
|
||||||
coinankInterval = coinank_enum.Hour8
|
|
||||||
case "12h":
|
|
||||||
coinankInterval = coinank_enum.Hour12
|
|
||||||
case "1d":
|
|
||||||
coinankInterval = coinank_enum.Day1
|
|
||||||
case "3d":
|
|
||||||
coinankInterval = coinank_enum.Day3
|
|
||||||
case "1w":
|
|
||||||
coinankInterval = coinank_enum.Week1
|
|
||||||
case "1M":
|
|
||||||
coinankInterval = coinank_enum.Month1
|
|
||||||
default:
|
|
||||||
return nil, fmt.Errorf("unsupported interval for coinank: %s", interval)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Convert symbol format for different exchanges
|
|
||||||
// OKX uses "BTC-USDT-SWAP" format instead of "BTCUSDT"
|
|
||||||
apiSymbol := symbol
|
|
||||||
if coinankExchange == coinank_enum.Okex {
|
|
||||||
// Convert BTCUSDT -> BTC-USDT-SWAP
|
|
||||||
if strings.HasSuffix(symbol, "USDT") {
|
|
||||||
base := strings.TrimSuffix(symbol, "USDT")
|
|
||||||
apiSymbol = fmt.Sprintf("%s-USDT-SWAP", base)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Call coinank free/open API (no authentication required)
|
|
||||||
ctx := context.Background()
|
|
||||||
ts := time.Now().UnixMilli()
|
|
||||||
// Use "To" side to search backward from current time (get historical klines)
|
|
||||||
coinankKlines, err := coinank_api.Kline(ctx, apiSymbol, coinankExchange, ts, coinank_enum.To, limit, coinankInterval)
|
|
||||||
if err != nil {
|
|
||||||
// Free API doesn't support all exchanges (e.g., OKX, Bitget)
|
|
||||||
// Fallback to Binance data as reference
|
|
||||||
if coinankExchange != coinank_enum.Binance {
|
|
||||||
logger.Warnf("⚠️ CoinAnk free API doesn't support %s, falling back to Binance data", coinankExchange)
|
|
||||||
coinankKlines, err = coinank_api.Kline(ctx, symbol, coinank_enum.Binance, ts, coinank_enum.To, limit, coinankInterval)
|
|
||||||
if err != nil {
|
|
||||||
return nil, fmt.Errorf("coinank API error (fallback): %w", err)
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
return nil, fmt.Errorf("coinank API error: %w", err)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Convert coinank kline format to market.Kline format
|
|
||||||
// Coinank: Volume = BTC quantity, Quantity = USDT turnover
|
|
||||||
klines := make([]market.Kline, len(coinankKlines))
|
|
||||||
for i, ck := range coinankKlines {
|
|
||||||
klines[i] = market.Kline{
|
|
||||||
OpenTime: ck.StartTime,
|
|
||||||
Open: ck.Open,
|
|
||||||
High: ck.High,
|
|
||||||
Low: ck.Low,
|
|
||||||
Close: ck.Close,
|
|
||||||
Volume: ck.Volume, // BTC quantity
|
|
||||||
QuoteVolume: ck.Quantity, // USDT turnover
|
|
||||||
CloseTime: ck.EndTime,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return klines, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// getKlinesFromAlpaca fetches kline data from Alpaca API for US stocks
|
|
||||||
func (s *Server) getKlinesFromAlpaca(symbol, interval string, limit int) ([]market.Kline, error) {
|
|
||||||
// Create Alpaca client
|
|
||||||
client := alpaca.NewClient()
|
|
||||||
|
|
||||||
// Map interval to Alpaca timeframe format
|
|
||||||
timeframe := alpaca.MapTimeframe(interval)
|
|
||||||
|
|
||||||
// Fetch bars from Alpaca
|
|
||||||
ctx := context.Background()
|
|
||||||
bars, err := client.GetBars(ctx, symbol, timeframe, limit)
|
|
||||||
if err != nil {
|
|
||||||
return nil, fmt.Errorf("alpaca API error: %w", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Convert Alpaca bars to market.Kline format
|
|
||||||
klines := make([]market.Kline, len(bars))
|
|
||||||
for i, bar := range bars {
|
|
||||||
klines[i] = market.Kline{
|
|
||||||
OpenTime: bar.Timestamp.UnixMilli(),
|
|
||||||
Open: bar.Open,
|
|
||||||
High: bar.High,
|
|
||||||
Low: bar.Low,
|
|
||||||
Close: bar.Close,
|
|
||||||
Volume: float64(bar.Volume), // share count
|
|
||||||
QuoteVolume: float64(bar.Volume) * bar.Close, // turnover = shares * close price (USD)
|
|
||||||
CloseTime: bar.Timestamp.UnixMilli(),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return klines, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// getKlinesFromTwelveData fetches kline data from Twelve Data API for forex and metals
|
|
||||||
func (s *Server) getKlinesFromTwelveData(symbol, interval string, limit int) ([]market.Kline, error) {
|
|
||||||
// Create Twelve Data client
|
|
||||||
client := twelvedata.NewClient()
|
|
||||||
|
|
||||||
// Map interval to Twelve Data timeframe format
|
|
||||||
timeframe := twelvedata.MapTimeframe(interval)
|
|
||||||
|
|
||||||
// Fetch time series from Twelve Data
|
|
||||||
ctx := context.Background()
|
|
||||||
result, err := client.GetTimeSeries(ctx, symbol, timeframe, limit)
|
|
||||||
if err != nil {
|
|
||||||
return nil, fmt.Errorf("twelvedata API error: %w", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Convert Twelve Data bars to market.Kline format
|
|
||||||
// Note: Twelve Data returns bars in reverse order (newest first)
|
|
||||||
klines := make([]market.Kline, len(result.Values))
|
|
||||||
for i, bar := range result.Values {
|
|
||||||
open, high, low, close, volume, timestamp, err := twelvedata.ParseBar(bar)
|
|
||||||
if err != nil {
|
|
||||||
logger.Warnf("⚠️ Failed to parse TwelveData bar: %v", err)
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
|
|
||||||
// Reverse order: put oldest first
|
|
||||||
idx := len(result.Values) - 1 - i
|
|
||||||
klines[idx] = market.Kline{
|
|
||||||
OpenTime: timestamp,
|
|
||||||
Open: open,
|
|
||||||
High: high,
|
|
||||||
Low: low,
|
|
||||||
Close: close,
|
|
||||||
Volume: volume,
|
|
||||||
CloseTime: timestamp,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return klines, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// getKlinesFromHyperliquid fetches kline data from Hyperliquid API
|
|
||||||
// Supports both crypto perps (default dex) and stock perps/forex/commodities (xyz dex)
|
|
||||||
func (s *Server) getKlinesFromHyperliquid(symbol, interval string, limit int) ([]market.Kline, error) {
|
|
||||||
// Create Hyperliquid client
|
|
||||||
client := hyperliquid.NewClient()
|
|
||||||
|
|
||||||
// Map interval to Hyperliquid format
|
|
||||||
timeframe := hyperliquid.MapTimeframe(interval)
|
|
||||||
|
|
||||||
// Fetch candles from Hyperliquid
|
|
||||||
// FormatCoinForAPI will automatically add xyz: prefix for stock perps
|
|
||||||
ctx := context.Background()
|
|
||||||
candles, err := client.GetCandles(ctx, symbol, timeframe, limit)
|
|
||||||
if err != nil {
|
|
||||||
return nil, fmt.Errorf("hyperliquid API error: %w", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Convert Hyperliquid candles to market.Kline format
|
|
||||||
klines := make([]market.Kline, len(candles))
|
|
||||||
for i, candle := range candles {
|
|
||||||
open, _ := strconv.ParseFloat(candle.Open, 64)
|
|
||||||
high, _ := strconv.ParseFloat(candle.High, 64)
|
|
||||||
low, _ := strconv.ParseFloat(candle.Low, 64)
|
|
||||||
close, _ := strconv.ParseFloat(candle.Close, 64)
|
|
||||||
volume, _ := strconv.ParseFloat(candle.Volume, 64)
|
|
||||||
|
|
||||||
klines[i] = market.Kline{
|
|
||||||
OpenTime: candle.OpenTime,
|
|
||||||
Open: open,
|
|
||||||
High: high,
|
|
||||||
Low: low,
|
|
||||||
Close: close,
|
|
||||||
Volume: volume, // contract quantity
|
|
||||||
QuoteVolume: volume * close, // turnover (USD)
|
|
||||||
CloseTime: candle.CloseTime,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return klines, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func hyperliquidXYZDisplayBase(baseSymbol string) string {
|
|
||||||
baseSymbol = strings.ToUpper(strings.TrimSpace(baseSymbol))
|
|
||||||
// User-facing names should be product names, not exchange shorthand tickers.
|
|
||||||
// Keep the internal symbol separate because Hyperliquid's xyz dex still routes
|
|
||||||
// orders/candles by the short coin name (for example xyz:SMSN).
|
|
||||||
fullNames := map[string]string{
|
|
||||||
"XYZ100": "XYZ100",
|
|
||||||
"TSLA": "TESLA",
|
|
||||||
"NVDA": "NVIDIA",
|
|
||||||
"GOLD": "GOLD",
|
|
||||||
"HOOD": "ROBINHOOD",
|
|
||||||
"INTC": "INTEL",
|
|
||||||
"PLTR": "PALANTIR",
|
|
||||||
"COIN": "COINBASE",
|
|
||||||
"META": "META",
|
|
||||||
"AAPL": "APPLE",
|
|
||||||
"MSFT": "MICROSOFT",
|
|
||||||
"ORCL": "ORACLE",
|
|
||||||
"GOOGL": "GOOGLE",
|
|
||||||
"AMZN": "AMAZON",
|
|
||||||
"AMD": "AMD",
|
|
||||||
"MU": "MICRON",
|
|
||||||
"SNDK": "SANDISK",
|
|
||||||
"MSTR": "MICROSTRATEGY",
|
|
||||||
"CRCL": "CIRCLE",
|
|
||||||
"NFLX": "NETFLIX",
|
|
||||||
"COST": "COSTCO",
|
|
||||||
"LLY": "ELI-LILLY",
|
|
||||||
"SKHX": "SK-HYNIX",
|
|
||||||
"TSM": "TSMC",
|
|
||||||
"JPY": "JPY",
|
|
||||||
"EUR": "EUR",
|
|
||||||
"SILVER": "SILVER",
|
|
||||||
"RIVN": "RIVIAN",
|
|
||||||
"BABA": "ALIBABA",
|
|
||||||
"CL": "CRUDE-OIL",
|
|
||||||
"COPPER": "COPPER",
|
|
||||||
"NATGAS": "NATURAL-GAS",
|
|
||||||
"URANIUM": "URANIUM",
|
|
||||||
"ALUMINIUM": "ALUMINIUM",
|
|
||||||
"SMSN": "SAMSUNG",
|
|
||||||
"PLATINUM": "PLATINUM",
|
|
||||||
"USAR": "USA-RARE-EARTH",
|
|
||||||
"CRWV": "COREWEAVE",
|
|
||||||
"URNM": "URNM",
|
|
||||||
"PALLADIUM": "PALLADIUM",
|
|
||||||
"DXY": "DOLLAR-INDEX",
|
|
||||||
"GME": "GAMESTOP",
|
|
||||||
"KR200": "KOREA-200",
|
|
||||||
"SOFTBANK": "SOFTBANK",
|
|
||||||
"JP225": "JAPAN-225",
|
|
||||||
"HYUNDAI": "HYUNDAI",
|
|
||||||
"KIOXIA": "KIOXIA",
|
|
||||||
"EWY": "SOUTH-KOREA-ETF",
|
|
||||||
"EWJ": "JAPAN-ETF",
|
|
||||||
"BRENTOIL": "BRENT-OIL",
|
|
||||||
"VIX": "VIX",
|
|
||||||
"HIMS": "HIMS-HERS",
|
|
||||||
"SP500": "S&P-500",
|
|
||||||
"DKNG": "DRAFTKINGS",
|
|
||||||
"LITE": "LITECOIN",
|
|
||||||
"CORN": "CORN",
|
|
||||||
"XLE": "ENERGY-SECTOR-ETF",
|
|
||||||
"WHEAT": "WHEAT",
|
|
||||||
"TTF": "TTF-GAS",
|
|
||||||
"BX": "BLACKSTONE",
|
|
||||||
"PURRDAT": "PURRDAT",
|
|
||||||
"MRVL": "MARVELL",
|
|
||||||
"RKLB": "ROCKET-LAB",
|
|
||||||
"BIRD": "BIRD",
|
|
||||||
"VOL": "VOLATILITY",
|
|
||||||
"DRAM": "DRAM",
|
|
||||||
"CBRS": "COINBASE-PRE-IPO",
|
|
||||||
"EWZ": "BRAZIL-ETF",
|
|
||||||
"KRW": "KRW",
|
|
||||||
"ZM": "ZOOM",
|
|
||||||
"EBAY": "EBAY",
|
|
||||||
"H100": "H100",
|
|
||||||
"NIFTY": "NIFTY-50",
|
|
||||||
"ARM": "ARM",
|
|
||||||
"EWT": "TAIWAN-ETF",
|
|
||||||
"GBP": "GBP",
|
|
||||||
"SPCX": "SPACEX-PRE-IPO",
|
|
||||||
"IBOV": "IBOVESPA",
|
|
||||||
"ASML": "ASML",
|
|
||||||
}
|
|
||||||
if fullName, ok := fullNames[baseSymbol]; ok {
|
|
||||||
return fullName
|
|
||||||
}
|
|
||||||
return baseSymbol
|
|
||||||
}
|
|
||||||
|
|
||||||
func hyperliquidXYZCategory(baseSymbol string) string {
|
|
||||||
baseSymbol = strings.ToUpper(strings.TrimSpace(baseSymbol))
|
|
||||||
switch baseSymbol {
|
|
||||||
case "GOLD", "SILVER", "CL", "COPPER", "NATGAS", "URANIUM", "ALUMINIUM", "PLATINUM", "PALLADIUM", "BRENTOIL", "CORN", "WHEAT", "TTF":
|
|
||||||
return "commodity"
|
|
||||||
case "XYZ100", "SP500", "JP225", "KR200", "DXY", "VIX", "XLE", "EWY", "EWJ", "EWZ", "EWT", "NIFTY", "IBOV":
|
|
||||||
return "index"
|
|
||||||
case "EUR", "JPY", "GBP", "KRW":
|
|
||||||
return "forex"
|
|
||||||
case "SPCX", "BIRD", "PURRDAT", "H100", "CBRS":
|
|
||||||
return "pre_ipo"
|
|
||||||
default:
|
|
||||||
return "stock"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func hyperliquidCategoryOrder(category string) int {
|
|
||||||
switch category {
|
|
||||||
case "stock":
|
|
||||||
return 0
|
|
||||||
case "commodity":
|
|
||||||
return 1
|
|
||||||
case "index":
|
|
||||||
return 2
|
|
||||||
case "forex":
|
|
||||||
return 3
|
|
||||||
case "pre_ipo":
|
|
||||||
return 4
|
|
||||||
case "crypto":
|
|
||||||
return 5
|
|
||||||
default:
|
|
||||||
return 99
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// handleSymbols returns available symbols for a given exchange
|
|
||||||
func (s *Server) handleSymbols(c *gin.Context) {
|
|
||||||
exchange := c.DefaultQuery("exchange", "hyperliquid")
|
|
||||||
|
|
||||||
type SymbolInfo struct {
|
|
||||||
Symbol string `json:"symbol"`
|
|
||||||
Display string `json:"display"`
|
|
||||||
Name string `json:"name"`
|
|
||||||
Category string `json:"category"` // crypto, stock, forex, commodity, index
|
|
||||||
Exchange string `json:"exchange"`
|
|
||||||
Volume24h float64 `json:"volume_24h"`
|
|
||||||
MarkPrice float64 `json:"mark_price"`
|
|
||||||
PrevDayPrice float64 `json:"prev_day_price,omitempty"`
|
|
||||||
Change24hPct float64 `json:"change_24h_pct,omitempty"`
|
|
||||||
MaxLeverage int `json:"maxLeverage,omitempty"`
|
|
||||||
SzDecimals int `json:"sz_decimals,omitempty"`
|
|
||||||
}
|
|
||||||
|
|
||||||
var symbols []SymbolInfo
|
|
||||||
|
|
||||||
exchangeLower := strings.ToLower(exchange)
|
|
||||||
switch exchangeLower {
|
|
||||||
case "hyperliquid", "hyperliquid-xyz", "xyz":
|
|
||||||
ctx := context.Background()
|
|
||||||
|
|
||||||
// hyperliquid-xyz returns the full USDC trading board in product order:
|
|
||||||
// stocks → commodities → indices → forex → pre-IPO → crypto.
|
|
||||||
if exchangeLower == "hyperliquid-xyz" || exchangeLower == "xyz" {
|
|
||||||
xyzCoins, err := hyperliquid.GetPerpDexCoins(ctx, hyperliquid.XYZDex)
|
|
||||||
if err != nil {
|
|
||||||
SafeInternalError(c, "Get Hyperliquid XYZ symbols", err)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
for _, coin := range xyzCoins {
|
|
||||||
baseSymbol := strings.TrimPrefix(coin.Symbol, "xyz:")
|
|
||||||
displayBase := hyperliquidXYZDisplayBase(baseSymbol)
|
|
||||||
displaySymbol := displayBase + "-USDC"
|
|
||||||
tradeSymbol := baseSymbol + "-USDC"
|
|
||||||
symbols = append(symbols, SymbolInfo{
|
|
||||||
Symbol: tradeSymbol,
|
|
||||||
Display: displaySymbol,
|
|
||||||
Name: displayBase,
|
|
||||||
Category: hyperliquidXYZCategory(baseSymbol),
|
|
||||||
Exchange: "hyperliquid-xyz",
|
|
||||||
Volume24h: coin.Volume24h,
|
|
||||||
MarkPrice: coin.MarkPrice,
|
|
||||||
PrevDayPrice: coin.PrevDayPrice,
|
|
||||||
Change24hPct: coin.Change24hPct,
|
|
||||||
MaxLeverage: coin.MaxLeverage,
|
|
||||||
SzDecimals: coin.SzDecimals,
|
|
||||||
})
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Crypto perps are shown last; only include them on the combined Hyperliquid board.
|
|
||||||
if exchangeLower == "hyperliquid" || exchangeLower == "hyperliquid-xyz" {
|
|
||||||
coins, err := hyperliquid.GetProvider().GetAllCoins(ctx)
|
|
||||||
if err != nil {
|
|
||||||
SafeInternalError(c, "Get Hyperliquid symbols", err)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
for _, coin := range coins {
|
|
||||||
symbols = append(symbols, SymbolInfo{
|
|
||||||
Symbol: coin.Symbol,
|
|
||||||
Display: coin.Symbol,
|
|
||||||
Name: coin.Symbol,
|
|
||||||
Category: "crypto",
|
|
||||||
Exchange: "hyperliquid",
|
|
||||||
Volume24h: coin.Volume24h,
|
|
||||||
MarkPrice: coin.MarkPrice,
|
|
||||||
PrevDayPrice: coin.PrevDayPrice,
|
|
||||||
Change24hPct: coin.Change24hPct,
|
|
||||||
MaxLeverage: coin.MaxLeverage,
|
|
||||||
SzDecimals: coin.SzDecimals,
|
|
||||||
})
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
default:
|
|
||||||
c.JSON(http.StatusBadRequest, gin.H{"error": "Unsupported exchange for symbol listing"})
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
sort.SliceStable(symbols, func(i, j int) bool {
|
|
||||||
ci := hyperliquidCategoryOrder(symbols[i].Category)
|
|
||||||
cj := hyperliquidCategoryOrder(symbols[j].Category)
|
|
||||||
if ci != cj {
|
|
||||||
return ci < cj
|
|
||||||
}
|
|
||||||
return symbols[i].Volume24h > symbols[j].Volume24h
|
|
||||||
})
|
|
||||||
|
|
||||||
c.JSON(http.StatusOK, gin.H{
|
|
||||||
"exchange": exchange,
|
|
||||||
"symbols": symbols,
|
|
||||||
"count": len(symbols),
|
|
||||||
})
|
|
||||||
}
|
|
||||||
@@ -1,363 +0,0 @@
|
|||||||
package api
|
|
||||||
|
|
||||||
import (
|
|
||||||
"bufio"
|
|
||||||
"encoding/hex"
|
|
||||||
"fmt"
|
|
||||||
"net/http"
|
|
||||||
"os"
|
|
||||||
"path/filepath"
|
|
||||||
"strings"
|
|
||||||
|
|
||||||
"nofx/logger"
|
|
||||||
"nofx/mcp/payment"
|
|
||||||
"nofx/wallet"
|
|
||||||
|
|
||||||
gethcrypto "github.com/ethereum/go-ethereum/crypto"
|
|
||||||
"github.com/gin-gonic/gin"
|
|
||||||
)
|
|
||||||
|
|
||||||
type beginnerOnboardingResponse struct {
|
|
||||||
Address string `json:"address"`
|
|
||||||
PrivateKey string `json:"private_key"`
|
|
||||||
Chain string `json:"chain"`
|
|
||||||
Asset string `json:"asset"`
|
|
||||||
Provider string `json:"provider"`
|
|
||||||
DefaultModel string `json:"default_model"`
|
|
||||||
ConfiguredModelID string `json:"configured_model_id"`
|
|
||||||
BalanceUSDC string `json:"balance_usdc"`
|
|
||||||
BalanceStatus string `json:"balance_status,omitempty"`
|
|
||||||
EnvSaved bool `json:"env_saved"`
|
|
||||||
EnvPath string `json:"env_path,omitempty"`
|
|
||||||
ReusedExisting bool `json:"reused_existing"`
|
|
||||||
EnvWarning string `json:"env_warning,omitempty"`
|
|
||||||
}
|
|
||||||
|
|
||||||
type currentBeginnerWalletResponse struct {
|
|
||||||
Found bool `json:"found"`
|
|
||||||
Address string `json:"address,omitempty"`
|
|
||||||
BalanceUSDC string `json:"balance_usdc,omitempty"`
|
|
||||||
BalanceStatus string `json:"balance_status,omitempty"`
|
|
||||||
Source string `json:"source,omitempty"`
|
|
||||||
Claw402Status string `json:"claw402_status"`
|
|
||||||
}
|
|
||||||
|
|
||||||
// queryBeginnerWalletBalance returns the wallet balance plus a status flag so
|
|
||||||
// the UI can distinguish "RPC unreachable" from a genuinely empty wallet.
|
|
||||||
// Uses the 30s cache — safe for UI polling.
|
|
||||||
func queryBeginnerWalletBalance(address string) (balanceUSDC string, balanceStatus string) {
|
|
||||||
balance, err := wallet.QueryUSDCBalanceCached(address)
|
|
||||||
if err != nil {
|
|
||||||
return "", "unknown"
|
|
||||||
}
|
|
||||||
return fmt.Sprintf("%.2f", balance), "ok"
|
|
||||||
}
|
|
||||||
|
|
||||||
func (s *Server) handleBeginnerOnboarding(c *gin.Context) {
|
|
||||||
userID := c.GetString("user_id")
|
|
||||||
if userID == "" {
|
|
||||||
c.JSON(http.StatusUnauthorized, gin.H{"error": "missing user context"})
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
privateKey, address, configuredModelID, reusedExisting, err := s.resolveBeginnerWallet(userID)
|
|
||||||
if err != nil {
|
|
||||||
logger.Errorf("Failed to resolve beginner wallet for user %s: %v", userID, err)
|
|
||||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to prepare beginner wallet"})
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
if !reusedExisting {
|
|
||||||
if err := s.store.AIModel().Update(userID, "claw402", true, privateKey, "", payment.DefaultClaw402Model); err != nil {
|
|
||||||
logger.Errorf("Failed to save beginner claw402 config for user %s: %v", userID, err)
|
|
||||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to save beginner model configuration"})
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
configuredModelID, err = s.findConfiguredClaw402ModelID(userID)
|
|
||||||
if err != nil {
|
|
||||||
logger.Warnf("Could not resolve configured claw402 model id for user %s: %v", userID, err)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
os.Setenv("CLAW402_WALLET_KEY", privateKey)
|
|
||||||
os.Setenv("CLAW402_WALLET_ADDRESS", address)
|
|
||||||
os.Setenv("CLAW402_DEFAULT_MODEL", payment.DefaultClaw402Model)
|
|
||||||
|
|
||||||
envSaved, envPath, envErr := persistBeginnerWalletEnv(privateKey, address)
|
|
||||||
balanceUSDC, balanceStatus := queryBeginnerWalletBalance(address)
|
|
||||||
resp := beginnerOnboardingResponse{
|
|
||||||
Address: address,
|
|
||||||
PrivateKey: privateKey,
|
|
||||||
Chain: "base",
|
|
||||||
Asset: "USDC",
|
|
||||||
Provider: "claw402",
|
|
||||||
DefaultModel: payment.DefaultClaw402Model,
|
|
||||||
ConfiguredModelID: configuredModelID,
|
|
||||||
BalanceUSDC: balanceUSDC,
|
|
||||||
BalanceStatus: balanceStatus,
|
|
||||||
EnvSaved: envSaved,
|
|
||||||
EnvPath: envPath,
|
|
||||||
ReusedExisting: reusedExisting,
|
|
||||||
}
|
|
||||||
if envErr != nil {
|
|
||||||
resp.EnvWarning = envErr.Error()
|
|
||||||
logger.Warnf("Beginner wallet env persistence warning for user %s: %v", userID, envErr)
|
|
||||||
}
|
|
||||||
|
|
||||||
c.JSON(http.StatusOK, resp)
|
|
||||||
}
|
|
||||||
|
|
||||||
func (s *Server) handleCurrentBeginnerWallet(c *gin.Context) {
|
|
||||||
userID := c.GetString("user_id")
|
|
||||||
if userID == "" {
|
|
||||||
c.JSON(http.StatusUnauthorized, gin.H{"error": "missing user context"})
|
|
||||||
return
|
|
||||||
}
|
|
||||||
claw402Status := checkClaw402Health()
|
|
||||||
|
|
||||||
models, err := s.store.AIModel().List(userID)
|
|
||||||
if err != nil {
|
|
||||||
logger.Errorf("Failed to load current beginner wallet for user %s: %v", userID, err)
|
|
||||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to load current wallet"})
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
for _, model := range models {
|
|
||||||
if model == nil || model.Provider != "claw402" {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
|
|
||||||
privateKey := strings.TrimSpace(model.APIKey.String())
|
|
||||||
if privateKey == "" {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
|
|
||||||
address, addrErr := walletAddressFromPrivateKey(privateKey)
|
|
||||||
if addrErr != nil {
|
|
||||||
logger.Warnf("Failed to derive current beginner wallet for user %s: %v", userID, addrErr)
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
|
|
||||||
balanceUSDC, balanceStatus := queryBeginnerWalletBalance(address)
|
|
||||||
c.JSON(http.StatusOK, currentBeginnerWalletResponse{
|
|
||||||
Found: true,
|
|
||||||
Address: address,
|
|
||||||
BalanceUSDC: balanceUSDC,
|
|
||||||
BalanceStatus: balanceStatus,
|
|
||||||
Source: "model",
|
|
||||||
Claw402Status: claw402Status,
|
|
||||||
})
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
address := strings.TrimSpace(os.Getenv("CLAW402_WALLET_ADDRESS"))
|
|
||||||
if address != "" {
|
|
||||||
balanceUSDC, balanceStatus := queryBeginnerWalletBalance(address)
|
|
||||||
c.JSON(http.StatusOK, currentBeginnerWalletResponse{
|
|
||||||
Found: true,
|
|
||||||
Address: address,
|
|
||||||
BalanceUSDC: balanceUSDC,
|
|
||||||
BalanceStatus: balanceStatus,
|
|
||||||
Source: "env",
|
|
||||||
Claw402Status: claw402Status,
|
|
||||||
})
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
c.JSON(http.StatusOK, currentBeginnerWalletResponse{
|
|
||||||
Found: false,
|
|
||||||
Claw402Status: claw402Status,
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
func (s *Server) resolveBeginnerWallet(userID string) (privateKey string, address string, configuredModelID string, reused bool, err error) {
|
|
||||||
// 1. Check if current user already has a claw402 wallet
|
|
||||||
models, err := s.store.AIModel().List(userID)
|
|
||||||
if err != nil {
|
|
||||||
return "", "", "", false, err
|
|
||||||
}
|
|
||||||
|
|
||||||
for _, model := range models {
|
|
||||||
if model == nil || model.Provider != "claw402" {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
existingKey := strings.TrimSpace(model.APIKey.String())
|
|
||||||
if existingKey == "" {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
|
|
||||||
addr, addrErr := walletAddressFromPrivateKey(existingKey)
|
|
||||||
if addrErr != nil {
|
|
||||||
logger.Warnf("Existing claw402 key for user %s is invalid, regenerating: %v", userID, addrErr)
|
|
||||||
break
|
|
||||||
}
|
|
||||||
|
|
||||||
return existingKey, addr, model.ID, true, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// 2. Check for orphan claw402 wallet from a previous account (e.g. after account reset).
|
|
||||||
// Adopt it to preserve funds.
|
|
||||||
orphan, orphanErr := s.store.AIModel().FindOrphanClaw402()
|
|
||||||
if orphanErr == nil && orphan != nil {
|
|
||||||
existingKey := strings.TrimSpace(orphan.APIKey.String())
|
|
||||||
if existingKey != "" {
|
|
||||||
addr, addrErr := walletAddressFromPrivateKey(existingKey)
|
|
||||||
if addrErr == nil {
|
|
||||||
if adoptErr := s.store.AIModel().AdoptModel(orphan.ID, userID); adoptErr != nil {
|
|
||||||
logger.Warnf("Failed to adopt orphan claw402 wallet for user %s: %v", userID, adoptErr)
|
|
||||||
} else {
|
|
||||||
logger.Infof("✓ Adopted orphan claw402 wallet %s for new user %s (address: %s)", orphan.ID, userID, addr)
|
|
||||||
return existingKey, addr, orphan.ID, true, nil
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// 3. No existing wallet found — generate a new one
|
|
||||||
privateKeyObj, genErr := gethcrypto.GenerateKey()
|
|
||||||
if genErr != nil {
|
|
||||||
return "", "", "", false, genErr
|
|
||||||
}
|
|
||||||
|
|
||||||
addr := gethcrypto.PubkeyToAddress(privateKeyObj.PublicKey)
|
|
||||||
keyHex := "0x" + hex.EncodeToString(gethcrypto.FromECDSA(privateKeyObj))
|
|
||||||
return keyHex, addr.Hex(), "", false, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func (s *Server) findConfiguredClaw402ModelID(userID string) (string, error) {
|
|
||||||
models, err := s.store.AIModel().List(userID)
|
|
||||||
if err != nil {
|
|
||||||
return "", err
|
|
||||||
}
|
|
||||||
|
|
||||||
for _, model := range models {
|
|
||||||
if model != nil && model.Provider == "claw402" {
|
|
||||||
return model.ID, nil
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return "", fmt.Errorf("claw402 model not found")
|
|
||||||
}
|
|
||||||
|
|
||||||
func walletAddressFromPrivateKey(privateKey string) (string, error) {
|
|
||||||
key := strings.TrimSpace(privateKey)
|
|
||||||
if !strings.HasPrefix(key, "0x") {
|
|
||||||
return "", fmt.Errorf("private key must start with 0x")
|
|
||||||
}
|
|
||||||
if len(key) != 66 {
|
|
||||||
return "", fmt.Errorf("private key must be 66 characters")
|
|
||||||
}
|
|
||||||
|
|
||||||
privateKeyObj, err := gethcrypto.HexToECDSA(strings.TrimPrefix(key, "0x"))
|
|
||||||
if err != nil {
|
|
||||||
return "", err
|
|
||||||
}
|
|
||||||
|
|
||||||
return gethcrypto.PubkeyToAddress(privateKeyObj.PublicKey).Hex(), nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func persistBeginnerWalletEnv(privateKey string, address string) (bool, string, error) {
|
|
||||||
paths := uniqueEnvPaths([]string{
|
|
||||||
".env",
|
|
||||||
filepath.Join(".", ".env"),
|
|
||||||
"/app/.env",
|
|
||||||
})
|
|
||||||
|
|
||||||
var lastErr error
|
|
||||||
for _, path := range paths {
|
|
||||||
if path == "" {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
|
|
||||||
if err := upsertEnvFile(path, map[string]string{
|
|
||||||
"CLAW402_WALLET_KEY": privateKey,
|
|
||||||
"CLAW402_WALLET_ADDRESS": address,
|
|
||||||
"CLAW402_DEFAULT_MODEL": payment.DefaultClaw402Model,
|
|
||||||
}); err != nil {
|
|
||||||
lastErr = err
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
|
|
||||||
return true, path, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
if lastErr == nil {
|
|
||||||
lastErr = fmt.Errorf("no writable .env path found")
|
|
||||||
}
|
|
||||||
return false, "", lastErr
|
|
||||||
}
|
|
||||||
|
|
||||||
func uniqueEnvPaths(paths []string) []string {
|
|
||||||
seen := make(map[string]struct{}, len(paths))
|
|
||||||
result := make([]string, 0, len(paths))
|
|
||||||
for _, path := range paths {
|
|
||||||
clean := filepath.Clean(path)
|
|
||||||
if _, ok := seen[clean]; ok {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
seen[clean] = struct{}{}
|
|
||||||
result = append(result, clean)
|
|
||||||
}
|
|
||||||
return result
|
|
||||||
}
|
|
||||||
|
|
||||||
func upsertEnvFile(path string, values map[string]string) error {
|
|
||||||
if err := os.MkdirAll(filepath.Dir(path), 0755); err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
|
|
||||||
existingLines := make([]string, 0)
|
|
||||||
if file, err := os.Open(path); err == nil {
|
|
||||||
scanner := bufio.NewScanner(file)
|
|
||||||
for scanner.Scan() {
|
|
||||||
existingLines = append(existingLines, scanner.Text())
|
|
||||||
}
|
|
||||||
file.Close()
|
|
||||||
if err := scanner.Err(); err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
} else if !os.IsNotExist(err) {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
|
|
||||||
remaining := make(map[string]string, len(values))
|
|
||||||
for key, value := range values {
|
|
||||||
remaining[key] = value
|
|
||||||
}
|
|
||||||
|
|
||||||
updatedLines := make([]string, 0, len(existingLines)+len(values))
|
|
||||||
for _, line := range existingLines {
|
|
||||||
trimmed := strings.TrimSpace(line)
|
|
||||||
if trimmed == "" || strings.HasPrefix(trimmed, "#") || !strings.Contains(line, "=") {
|
|
||||||
updatedLines = append(updatedLines, line)
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
|
|
||||||
parts := strings.SplitN(line, "=", 2)
|
|
||||||
key := strings.TrimSpace(parts[0])
|
|
||||||
value, ok := remaining[key]
|
|
||||||
if !ok {
|
|
||||||
updatedLines = append(updatedLines, line)
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
|
|
||||||
updatedLines = append(updatedLines, fmt.Sprintf("%s=%s", key, value))
|
|
||||||
delete(remaining, key)
|
|
||||||
}
|
|
||||||
|
|
||||||
for key, value := range remaining {
|
|
||||||
updatedLines = append(updatedLines, fmt.Sprintf("%s=%s", key, value))
|
|
||||||
}
|
|
||||||
|
|
||||||
content := strings.Join(updatedLines, "\n")
|
|
||||||
if content != "" && !strings.HasSuffix(content, "\n") {
|
|
||||||
content += "\n"
|
|
||||||
}
|
|
||||||
|
|
||||||
if err := os.WriteFile(path, []byte(content), 0600); err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
@@ -1,425 +0,0 @@
|
|||||||
package api
|
|
||||||
|
|
||||||
import (
|
|
||||||
"net/http"
|
|
||||||
"strconv"
|
|
||||||
"strings"
|
|
||||||
|
|
||||||
"nofx/logger"
|
|
||||||
"nofx/market"
|
|
||||||
|
|
||||||
"github.com/gin-gonic/gin"
|
|
||||||
)
|
|
||||||
|
|
||||||
// handleTraderList Trader list
|
|
||||||
func (s *Server) handleTraderList(c *gin.Context) {
|
|
||||||
userID := c.GetString("user_id")
|
|
||||||
traders, err := s.store.Trader().List(userID)
|
|
||||||
if err != nil {
|
|
||||||
SafeInternalError(c, "Failed to get trader list", err)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
result := make([]map[string]interface{}, 0, len(traders))
|
|
||||||
for _, trader := range traders {
|
|
||||||
// Get real-time running status
|
|
||||||
isRunning := trader.IsRunning
|
|
||||||
if at, err := s.traderManager.GetTrader(trader.ID); err == nil {
|
|
||||||
status := at.GetStatus()
|
|
||||||
if running, ok := status["is_running"].(bool); ok {
|
|
||||||
isRunning = running
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Get strategy name if strategy_id is set
|
|
||||||
var strategyName string
|
|
||||||
if trader.StrategyID != "" {
|
|
||||||
if strategy, err := s.store.Strategy().Get(userID, trader.StrategyID); err == nil {
|
|
||||||
strategyName = strategy.Name
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Return complete AIModelID (e.g. "admin_deepseek"), don't truncate
|
|
||||||
// Frontend needs complete ID to verify model exists (consistent with handleGetTraderConfig)
|
|
||||||
result = append(result, map[string]interface{}{
|
|
||||||
"trader_id": trader.ID,
|
|
||||||
"trader_name": trader.Name,
|
|
||||||
"ai_model": trader.AIModelID, // Use complete ID
|
|
||||||
"exchange_id": trader.ExchangeID,
|
|
||||||
"is_running": isRunning,
|
|
||||||
"show_in_competition": trader.ShowInCompetition,
|
|
||||||
"initial_balance": trader.InitialBalance,
|
|
||||||
"strategy_id": trader.StrategyID,
|
|
||||||
"strategy_name": strategyName,
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
c.JSON(http.StatusOK, result)
|
|
||||||
}
|
|
||||||
|
|
||||||
// handleGetTraderConfig Get trader detailed configuration
|
|
||||||
func (s *Server) handleGetTraderConfig(c *gin.Context) {
|
|
||||||
userID := c.GetString("user_id")
|
|
||||||
traderID := c.Param("id")
|
|
||||||
|
|
||||||
if traderID == "" {
|
|
||||||
c.JSON(http.StatusBadRequest, gin.H{"error": "Trader ID cannot be empty"})
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
fullCfg, err := s.store.Trader().GetFullConfig(userID, traderID)
|
|
||||||
if err != nil {
|
|
||||||
SafeNotFound(c, "Trader config")
|
|
||||||
return
|
|
||||||
}
|
|
||||||
traderConfig := fullCfg.Trader
|
|
||||||
|
|
||||||
// Get real-time running status
|
|
||||||
isRunning := traderConfig.IsRunning
|
|
||||||
if at, err := s.traderManager.GetTrader(traderID); err == nil {
|
|
||||||
status := at.GetStatus()
|
|
||||||
if running, ok := status["is_running"].(bool); ok {
|
|
||||||
isRunning = running
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Return complete model ID without conversion, consistent with frontend model list
|
|
||||||
aiModelID := traderConfig.AIModelID
|
|
||||||
|
|
||||||
result := map[string]interface{}{
|
|
||||||
"trader_id": traderConfig.ID,
|
|
||||||
"trader_name": traderConfig.Name,
|
|
||||||
"ai_model": aiModelID,
|
|
||||||
"exchange_id": traderConfig.ExchangeID,
|
|
||||||
"strategy_id": traderConfig.StrategyID,
|
|
||||||
"initial_balance": traderConfig.InitialBalance,
|
|
||||||
"scan_interval_minutes": traderConfig.ScanIntervalMinutes,
|
|
||||||
"btc_eth_leverage": traderConfig.BTCETHLeverage,
|
|
||||||
"altcoin_leverage": traderConfig.AltcoinLeverage,
|
|
||||||
"trading_symbols": traderConfig.TradingSymbols,
|
|
||||||
"custom_prompt": traderConfig.CustomPrompt,
|
|
||||||
"override_base_prompt": traderConfig.OverrideBasePrompt,
|
|
||||||
"is_cross_margin": traderConfig.IsCrossMargin,
|
|
||||||
"use_ai500": traderConfig.UseAI500,
|
|
||||||
"use_oi_top": traderConfig.UseOITop,
|
|
||||||
"is_running": isRunning,
|
|
||||||
}
|
|
||||||
|
|
||||||
c.JSON(http.StatusOK, result)
|
|
||||||
}
|
|
||||||
|
|
||||||
// handleStatus System status
|
|
||||||
func (s *Server) handleStatus(c *gin.Context) {
|
|
||||||
_, traderID, err := s.getTraderFromQuery(c)
|
|
||||||
if err != nil {
|
|
||||||
SafeBadRequest(c, "Invalid trader ID")
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
trader, err := s.traderManager.GetTrader(traderID)
|
|
||||||
if err != nil {
|
|
||||||
SafeNotFound(c, "Trader")
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
status := trader.GetStatus()
|
|
||||||
c.JSON(http.StatusOK, status)
|
|
||||||
}
|
|
||||||
|
|
||||||
// handleAccount Account information
|
|
||||||
func (s *Server) handleAccount(c *gin.Context) {
|
|
||||||
_, traderID, err := s.getTraderFromQuery(c)
|
|
||||||
if err != nil {
|
|
||||||
SafeBadRequest(c, "Invalid trader ID")
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
trader, err := s.traderManager.GetTrader(traderID)
|
|
||||||
if err != nil {
|
|
||||||
SafeNotFound(c, "Trader")
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
logger.Infof("📊 Received account info request [%s]", trader.GetName())
|
|
||||||
account, err := trader.GetAccountInfo()
|
|
||||||
if err != nil {
|
|
||||||
SafeInternalError(c, "Get account info", err)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
logger.Infof("✓ Returning account info [%s]: equity=%.2f, available=%.2f, pnl=%.2f (%.2f%%)",
|
|
||||||
trader.GetName(),
|
|
||||||
account["total_equity"],
|
|
||||||
account["available_balance"],
|
|
||||||
account["total_pnl"],
|
|
||||||
account["total_pnl_pct"])
|
|
||||||
c.JSON(http.StatusOK, account)
|
|
||||||
}
|
|
||||||
|
|
||||||
// handlePositions Position list
|
|
||||||
func (s *Server) handlePositions(c *gin.Context) {
|
|
||||||
_, traderID, err := s.getTraderFromQuery(c)
|
|
||||||
if err != nil {
|
|
||||||
SafeBadRequest(c, "Invalid trader ID")
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
trader, err := s.traderManager.GetTrader(traderID)
|
|
||||||
if err != nil {
|
|
||||||
SafeNotFound(c, "Trader")
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
positions, err := trader.GetPositions()
|
|
||||||
if err != nil {
|
|
||||||
SafeInternalError(c, "Get positions", err)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
c.JSON(http.StatusOK, positions)
|
|
||||||
}
|
|
||||||
|
|
||||||
// handlePositionHistory Historical closed positions with statistics
|
|
||||||
func (s *Server) handlePositionHistory(c *gin.Context) {
|
|
||||||
_, traderID, err := s.getTraderFromQuery(c)
|
|
||||||
if err != nil {
|
|
||||||
SafeBadRequest(c, "Invalid trader ID")
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
trader, err := s.traderManager.GetTrader(traderID)
|
|
||||||
if err != nil {
|
|
||||||
SafeNotFound(c, "Trader")
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
// Get optional query parameters
|
|
||||||
limitStr := c.DefaultQuery("limit", "100")
|
|
||||||
limit := 100
|
|
||||||
if l, err := strconv.Atoi(limitStr); err == nil && l > 0 && l <= 500 {
|
|
||||||
limit = l
|
|
||||||
}
|
|
||||||
|
|
||||||
// Get store
|
|
||||||
store := trader.GetStore()
|
|
||||||
if store == nil {
|
|
||||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "Store not available"})
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
userID := c.GetString("user_id")
|
|
||||||
if fullCfg, cfgErr := s.store.Trader().GetFullConfig(userID, traderID); cfgErr == nil && fullCfg.Exchange != nil {
|
|
||||||
if syncErr := s.syncOrdersFromExchange(
|
|
||||||
trader.GetUnderlyingTrader(),
|
|
||||||
trader.GetID(),
|
|
||||||
fullCfg.Exchange.ID,
|
|
||||||
fullCfg.Exchange.ExchangeType,
|
|
||||||
); syncErr != nil {
|
|
||||||
logger.Infof("⚠️ Position history refresh sync skipped: %v", syncErr)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
traderIDs := []string{trader.GetID()}
|
|
||||||
var traderIDPatterns []string
|
|
||||||
if strings.EqualFold(strings.TrimSpace(trader.GetName()), "NOFX Autopilot") && strings.TrimSpace(userID) != "" {
|
|
||||||
// Older one-click launches created new Autopilot trader rows. When a row was
|
|
||||||
// deleted, its closed position records remained under the old generated ID.
|
|
||||||
// The generated Autopilot ID embeds userID + "claw402", so this safely
|
|
||||||
// restores same-user history continuity without joining deleted rows.
|
|
||||||
traderIDPatterns = append(traderIDPatterns, "%_"+userID+"_claw402_%")
|
|
||||||
}
|
|
||||||
|
|
||||||
// Get closed positions
|
|
||||||
positions, err := store.Position().GetClosedPositionsByTraderFilters(traderIDs, traderIDPatterns, limit)
|
|
||||||
if err != nil {
|
|
||||||
SafeInternalError(c, "Get position history", err)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
// Get statistics
|
|
||||||
stats, _ := store.Position().GetFullStatsByTraderFilters(traderIDs, traderIDPatterns, trader.GetInitialBalance())
|
|
||||||
|
|
||||||
// Get symbol stats
|
|
||||||
symbolStats, _ := store.Position().GetSymbolStatsByTraderFilters(traderIDs, traderIDPatterns, 10)
|
|
||||||
|
|
||||||
// Get direction stats
|
|
||||||
directionStats, _ := store.Position().GetDirectionStatsByTraderFilters(traderIDs, traderIDPatterns)
|
|
||||||
|
|
||||||
c.JSON(http.StatusOK, gin.H{
|
|
||||||
"positions": positions,
|
|
||||||
"stats": stats,
|
|
||||||
"symbol_stats": symbolStats,
|
|
||||||
"direction_stats": directionStats,
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
// handleTrades Historical trades list
|
|
||||||
func (s *Server) handleTrades(c *gin.Context) {
|
|
||||||
_, traderID, err := s.getTraderFromQuery(c)
|
|
||||||
if err != nil {
|
|
||||||
SafeBadRequest(c, "Invalid trader ID")
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
trader, err := s.traderManager.GetTrader(traderID)
|
|
||||||
if err != nil {
|
|
||||||
SafeNotFound(c, "Trader")
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
// Get optional query parameters
|
|
||||||
symbol := c.Query("symbol")
|
|
||||||
limitStr := c.DefaultQuery("limit", "100")
|
|
||||||
limit := 100
|
|
||||||
if l, err := strconv.Atoi(limitStr); err == nil && l > 0 {
|
|
||||||
limit = l
|
|
||||||
}
|
|
||||||
|
|
||||||
// Normalize symbol (add USDT suffix if not present)
|
|
||||||
if symbol != "" {
|
|
||||||
symbol = market.Normalize(symbol)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Get trades from store
|
|
||||||
store := trader.GetStore()
|
|
||||||
if store == nil {
|
|
||||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "Store not available"})
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
allTrades, err := store.Position().GetRecentTrades(trader.GetID(), limit)
|
|
||||||
if err != nil {
|
|
||||||
SafeInternalError(c, "Get trades", err)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
// Filter by symbol if specified
|
|
||||||
if symbol != "" {
|
|
||||||
var result []interface{}
|
|
||||||
for _, trade := range allTrades {
|
|
||||||
if trade.Symbol == symbol {
|
|
||||||
result = append(result, trade)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
c.JSON(http.StatusOK, result)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
c.JSON(http.StatusOK, allTrades)
|
|
||||||
}
|
|
||||||
|
|
||||||
// handleOrders Order list (all orders including open, close, stop loss, take profit, etc.)
|
|
||||||
func (s *Server) handleOrders(c *gin.Context) {
|
|
||||||
_, traderID, err := s.getTraderFromQuery(c)
|
|
||||||
if err != nil {
|
|
||||||
SafeBadRequest(c, "Invalid trader ID")
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
trader, err := s.traderManager.GetTrader(traderID)
|
|
||||||
if err != nil {
|
|
||||||
SafeNotFound(c, "Trader")
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
// Get optional query parameters
|
|
||||||
symbol := c.Query("symbol")
|
|
||||||
statusFilter := c.Query("status") // NEW, FILLED, CANCELED, etc.
|
|
||||||
limitStr := c.DefaultQuery("limit", "100")
|
|
||||||
limit := 100
|
|
||||||
if l, err := strconv.Atoi(limitStr); err == nil && l > 0 {
|
|
||||||
limit = l
|
|
||||||
}
|
|
||||||
|
|
||||||
// Normalize symbol (add USDT suffix if not present)
|
|
||||||
if symbol != "" {
|
|
||||||
symbol = market.Normalize(symbol)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Get orders from store
|
|
||||||
store := trader.GetStore()
|
|
||||||
if store == nil {
|
|
||||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "Store not available"})
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
// Get orders with filters applied at database level
|
|
||||||
orders, err := store.Order().GetTraderOrdersFiltered(trader.GetID(), symbol, statusFilter, limit)
|
|
||||||
if err != nil {
|
|
||||||
SafeInternalError(c, "Get orders", err)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
c.JSON(http.StatusOK, orders)
|
|
||||||
}
|
|
||||||
|
|
||||||
// handleOrderFills Order fill details (all fills for a specific order)
|
|
||||||
func (s *Server) handleOrderFills(c *gin.Context) {
|
|
||||||
orderIDStr := c.Param("id")
|
|
||||||
orderID, err := strconv.ParseInt(orderIDStr, 10, 64)
|
|
||||||
if err != nil {
|
|
||||||
c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid order ID"})
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
_, traderID, err := s.getTraderFromQuery(c)
|
|
||||||
if err != nil {
|
|
||||||
SafeBadRequest(c, "Invalid trader ID")
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
trader, err := s.traderManager.GetTrader(traderID)
|
|
||||||
if err != nil {
|
|
||||||
SafeNotFound(c, "Trader")
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
store := trader.GetStore()
|
|
||||||
if store == nil {
|
|
||||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "Store not available"})
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
// Get fills for this order, scoped to the trader (ownership boundary).
|
|
||||||
fills, err := store.Order().GetOrderFills(traderID, orderID)
|
|
||||||
if err != nil {
|
|
||||||
SafeInternalError(c, "Get order fills", err)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
c.JSON(http.StatusOK, fills)
|
|
||||||
}
|
|
||||||
|
|
||||||
// handleOpenOrders Get open orders (pending SL/TP) from exchange
|
|
||||||
func (s *Server) handleOpenOrders(c *gin.Context) {
|
|
||||||
_, traderID, err := s.getTraderFromQuery(c)
|
|
||||||
if err != nil {
|
|
||||||
SafeBadRequest(c, "Invalid trader ID")
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
trader, err := s.traderManager.GetTrader(traderID)
|
|
||||||
if err != nil {
|
|
||||||
SafeNotFound(c, "Trader")
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
// Get symbol parameter (required for exchange query)
|
|
||||||
symbol := c.Query("symbol")
|
|
||||||
if symbol == "" {
|
|
||||||
c.JSON(http.StatusBadRequest, gin.H{"error": "symbol parameter is required"})
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
// Normalize symbol
|
|
||||||
symbol = market.Normalize(symbol)
|
|
||||||
|
|
||||||
// Get open orders from exchange
|
|
||||||
openOrders, err := trader.GetOpenOrders(symbol)
|
|
||||||
if err != nil {
|
|
||||||
SafeInternalError(c, "Get open orders", err)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
c.JSON(http.StatusOK, openOrders)
|
|
||||||
}
|
|
||||||
@@ -1,58 +0,0 @@
|
|||||||
package api
|
|
||||||
|
|
||||||
import (
|
|
||||||
"net/http"
|
|
||||||
"strings"
|
|
||||||
|
|
||||||
"github.com/gin-gonic/gin"
|
|
||||||
)
|
|
||||||
|
|
||||||
// handleStatisticsFull returns the full set of computed performance metrics for
|
|
||||||
// a single trader: win rate, profit factor, Sharpe ratio, max drawdown, and the
|
|
||||||
// average win/loss amounts. These are derived from the trader's CLOSED positions
|
|
||||||
// via store.Position().GetFullStatsByTraderFilters — the same computation the
|
|
||||||
// strategy engine feeds to the AI, so the dashboard and the model see identical
|
|
||||||
// numbers.
|
|
||||||
//
|
|
||||||
// The existing GET /statistics endpoint only returns cycle/position counts; this
|
|
||||||
// endpoint exposes the richer trade-quality metrics the terminal dashboard needs.
|
|
||||||
func (s *Server) handleStatisticsFull(c *gin.Context) {
|
|
||||||
_, traderID, err := s.getTraderFromQuery(c)
|
|
||||||
if err != nil {
|
|
||||||
SafeBadRequest(c, "Invalid trader ID")
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
trader, err := s.traderManager.GetTrader(traderID)
|
|
||||||
if err != nil {
|
|
||||||
SafeNotFound(c, "Trader")
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
store := trader.GetStore()
|
|
||||||
if store == nil {
|
|
||||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "Store not available"})
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
// Aggregate across the trader's historical IDs exactly like the position
|
|
||||||
// history endpoint (handler_order.go). One-click "NOFX Autopilot" relaunches
|
|
||||||
// create fresh trader rows, but the closed positions stay under the old
|
|
||||||
// generated IDs (which embed userID + "claw402"). Without this, a freshly
|
|
||||||
// relaunched Autopilot would report only the current incarnation's trades
|
|
||||||
// instead of its real lifetime history.
|
|
||||||
userID := c.GetString("user_id")
|
|
||||||
traderIDs := []string{trader.GetID()}
|
|
||||||
var traderIDPatterns []string
|
|
||||||
if strings.EqualFold(strings.TrimSpace(trader.GetName()), "NOFX Autopilot") && strings.TrimSpace(userID) != "" {
|
|
||||||
traderIDPatterns = append(traderIDPatterns, "%_"+userID+"_claw402_%")
|
|
||||||
}
|
|
||||||
|
|
||||||
stats, err := store.Position().GetFullStatsByTraderFilters(traderIDs, traderIDPatterns, trader.GetInitialBalance())
|
|
||||||
if err != nil {
|
|
||||||
SafeInternalError(c, "Get full statistics", err)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
c.JSON(http.StatusOK, stats)
|
|
||||||
}
|
|
||||||
@@ -1,105 +0,0 @@
|
|||||||
package api
|
|
||||||
|
|
||||||
import (
|
|
||||||
"net/http"
|
|
||||||
|
|
||||||
"github.com/gin-gonic/gin"
|
|
||||||
)
|
|
||||||
|
|
||||||
// handleGetTelegramConfig returns current Telegram bot configuration and binding status
|
|
||||||
func (s *Server) handleGetTelegramConfig(c *gin.Context) {
|
|
||||||
cfg, err := s.store.TelegramConfig().Get()
|
|
||||||
if err != nil {
|
|
||||||
// Not configured yet - return empty state
|
|
||||||
c.JSON(http.StatusOK, gin.H{
|
|
||||||
"configured": false,
|
|
||||||
"is_bound": false,
|
|
||||||
"token_masked": "",
|
|
||||||
"username": "",
|
|
||||||
})
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
// Mask bot token for security (show only last 6 chars)
|
|
||||||
tokenMasked := ""
|
|
||||||
if cfg.BotToken != "" {
|
|
||||||
if len(cfg.BotToken) > 6 {
|
|
||||||
tokenMasked = "***" + cfg.BotToken[len(cfg.BotToken)-6:]
|
|
||||||
} else {
|
|
||||||
tokenMasked = "***"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
c.JSON(http.StatusOK, gin.H{
|
|
||||||
"configured": cfg.BotToken != "",
|
|
||||||
"is_bound": cfg.ChatID != 0,
|
|
||||||
"username": cfg.Username,
|
|
||||||
"bound_at": cfg.BoundAt,
|
|
||||||
"token_masked": tokenMasked,
|
|
||||||
"model_id": cfg.ModelID,
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
// handleUpdateTelegramConfig saves bot token (+ optional model ID) and triggers bot hot-reload
|
|
||||||
func (s *Server) handleUpdateTelegramConfig(c *gin.Context) {
|
|
||||||
var req struct {
|
|
||||||
BotToken string `json:"bot_token"`
|
|
||||||
ModelID string `json:"model_id"`
|
|
||||||
}
|
|
||||||
if err := c.ShouldBindJSON(&req); err != nil {
|
|
||||||
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid request"})
|
|
||||||
return
|
|
||||||
}
|
|
||||||
if req.BotToken == "" {
|
|
||||||
c.JSON(http.StatusBadRequest, gin.H{"error": "bot_token is required"})
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
if err := s.store.TelegramConfig().Save(req.BotToken, req.ModelID); err != nil {
|
|
||||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to save config"})
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
// Signal bot hot-reload if channel is available
|
|
||||||
if s.telegramReloadCh != nil {
|
|
||||||
select {
|
|
||||||
case s.telegramReloadCh <- struct{}{}:
|
|
||||||
default: // non-blocking
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
c.JSON(http.StatusOK, gin.H{"success": true, "message": "Bot token saved. Bot will reload automatically."})
|
|
||||||
}
|
|
||||||
|
|
||||||
// handleUnbindTelegram removes Telegram user binding
|
|
||||||
func (s *Server) handleUnbindTelegram(c *gin.Context) {
|
|
||||||
if err := s.store.TelegramConfig().Unbind(); err != nil {
|
|
||||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to unbind"})
|
|
||||||
return
|
|
||||||
}
|
|
||||||
c.JSON(http.StatusOK, gin.H{"success": true, "message": "Telegram binding removed"})
|
|
||||||
}
|
|
||||||
|
|
||||||
// handleUpdateTelegramModel updates only the AI model used for Telegram replies (no token re-entry needed)
|
|
||||||
func (s *Server) handleUpdateTelegramModel(c *gin.Context) {
|
|
||||||
var req struct {
|
|
||||||
ModelID string `json:"model_id"`
|
|
||||||
}
|
|
||||||
if err := c.ShouldBindJSON(&req); err != nil {
|
|
||||||
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid request"})
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
cfg, err := s.store.TelegramConfig().Get()
|
|
||||||
if err != nil {
|
|
||||||
c.JSON(http.StatusBadRequest, gin.H{"error": "no Telegram config found, save a bot token first"})
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
if err := s.store.TelegramConfig().Save(cfg.BotToken, req.ModelID); err != nil {
|
|
||||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to save model config"})
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
c.JSON(http.StatusOK, gin.H{"success": true, "model_id": req.ModelID})
|
|
||||||
}
|
|
||||||
@@ -1,931 +0,0 @@
|
|||||||
package api
|
|
||||||
|
|
||||||
import (
|
|
||||||
"errors"
|
|
||||||
"fmt"
|
|
||||||
"net/http"
|
|
||||||
"strings"
|
|
||||||
"time"
|
|
||||||
|
|
||||||
"nofx/logger"
|
|
||||||
"nofx/store"
|
|
||||||
|
|
||||||
"github.com/gin-gonic/gin"
|
|
||||||
"gorm.io/gorm"
|
|
||||||
)
|
|
||||||
|
|
||||||
const (
|
|
||||||
maxManualBTCETHLeverage = 20
|
|
||||||
maxManualAltLeverage = 20
|
|
||||||
)
|
|
||||||
|
|
||||||
// AI trader management related structures
|
|
||||||
type CreateTraderRequest struct {
|
|
||||||
Name string `json:"name" binding:"required"`
|
|
||||||
AIModelID string `json:"ai_model_id" binding:"required"`
|
|
||||||
ExchangeID string `json:"exchange_id" binding:"required"`
|
|
||||||
StrategyID string `json:"strategy_id"` // Strategy ID (new version)
|
|
||||||
InitialBalance float64 `json:"initial_balance"`
|
|
||||||
ScanIntervalMinutes int `json:"scan_interval_minutes"`
|
|
||||||
IsCrossMargin *bool `json:"is_cross_margin"` // Pointer type, nil means use default value true
|
|
||||||
ShowInCompetition *bool `json:"show_in_competition"` // Pointer type, nil means use default value true
|
|
||||||
// The following fields are kept for backward compatibility, new version uses strategy config
|
|
||||||
BTCETHLeverage int `json:"btc_eth_leverage"`
|
|
||||||
AltcoinLeverage int `json:"altcoin_leverage"`
|
|
||||||
TradingSymbols string `json:"trading_symbols"`
|
|
||||||
CustomPrompt string `json:"custom_prompt"`
|
|
||||||
OverrideBasePrompt bool `json:"override_base_prompt"`
|
|
||||||
SystemPromptTemplate string `json:"system_prompt_template"` // System prompt template name
|
|
||||||
UseAI500 bool `json:"use_ai500"`
|
|
||||||
UseOITop bool `json:"use_oi_top"`
|
|
||||||
}
|
|
||||||
|
|
||||||
// UpdateTraderRequest Update trader request
|
|
||||||
type UpdateTraderRequest struct {
|
|
||||||
Name string `json:"name" binding:"required"`
|
|
||||||
AIModelID string `json:"ai_model_id" binding:"required"`
|
|
||||||
ExchangeID string `json:"exchange_id" binding:"required"`
|
|
||||||
StrategyID string `json:"strategy_id"` // Strategy ID (new version)
|
|
||||||
InitialBalance float64 `json:"initial_balance"`
|
|
||||||
ScanIntervalMinutes int `json:"scan_interval_minutes"`
|
|
||||||
IsCrossMargin *bool `json:"is_cross_margin"`
|
|
||||||
ShowInCompetition *bool `json:"show_in_competition"`
|
|
||||||
// The following fields are kept for backward compatibility, new version uses strategy config
|
|
||||||
BTCETHLeverage int `json:"btc_eth_leverage"`
|
|
||||||
AltcoinLeverage int `json:"altcoin_leverage"`
|
|
||||||
TradingSymbols string `json:"trading_symbols"`
|
|
||||||
CustomPrompt string `json:"custom_prompt"`
|
|
||||||
OverrideBasePrompt bool `json:"override_base_prompt"`
|
|
||||||
SystemPromptTemplate string `json:"system_prompt_template"`
|
|
||||||
}
|
|
||||||
|
|
||||||
func formatTraderCreationError(reason, nextStep string) string {
|
|
||||||
if nextStep == "" {
|
|
||||||
return fmt.Sprintf("Failed to create the bot this time: %s.", reason)
|
|
||||||
}
|
|
||||||
return fmt.Sprintf("Failed to create the bot this time: %s. %s.", reason, nextStep)
|
|
||||||
}
|
|
||||||
|
|
||||||
func traderCreationRequestError(reason string) string {
|
|
||||||
return formatTraderCreationError(reason, "Please check the information you just entered and submit again")
|
|
||||||
}
|
|
||||||
|
|
||||||
func validateTraderLeverageRange(btcEthLeverage, altcoinLeverage int) (string, string) {
|
|
||||||
if btcEthLeverage < 0 || btcEthLeverage > maxManualBTCETHLeverage {
|
|
||||||
return traderCreationRequestError("BTC/ETH leverage must be between 1x and 20x"), "trader.create.invalid_btc_eth_leverage"
|
|
||||||
}
|
|
||||||
if altcoinLeverage < 0 || altcoinLeverage > maxManualAltLeverage {
|
|
||||||
return traderCreationRequestError("Altcoin leverage must be between 1x and 20x"), "trader.create.invalid_altcoin_leverage"
|
|
||||||
}
|
|
||||||
return "", ""
|
|
||||||
}
|
|
||||||
|
|
||||||
func isSupportedTraderSymbol(symbol string) bool {
|
|
||||||
normalized := strings.ToUpper(strings.TrimSpace(symbol))
|
|
||||||
if normalized == "" {
|
|
||||||
return true
|
|
||||||
}
|
|
||||||
return strings.HasSuffix(normalized, "USDT") || strings.HasSuffix(normalized, "-USDC") || strings.HasPrefix(normalized, "XYZ:")
|
|
||||||
}
|
|
||||||
|
|
||||||
func exchangeDisplayName(exchange *store.Exchange) string {
|
|
||||||
if exchange == nil {
|
|
||||||
return "the selected exchange account"
|
|
||||||
}
|
|
||||||
if exchange.AccountName != "" {
|
|
||||||
return fmt.Sprintf("%s (%s)", exchange.Name, exchange.AccountName)
|
|
||||||
}
|
|
||||||
if exchange.Name != "" {
|
|
||||||
return exchange.Name
|
|
||||||
}
|
|
||||||
return "the selected exchange account"
|
|
||||||
}
|
|
||||||
|
|
||||||
func missingExchangeFields(exchange *store.Exchange) []string {
|
|
||||||
if exchange == nil {
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
var missing []string
|
|
||||||
switch exchange.ExchangeType {
|
|
||||||
case "binance", "bybit", "gate", "indodax":
|
|
||||||
if exchange.APIKey == "" {
|
|
||||||
missing = append(missing, "API Key")
|
|
||||||
}
|
|
||||||
if exchange.SecretKey == "" {
|
|
||||||
missing = append(missing, "Secret Key")
|
|
||||||
}
|
|
||||||
case "okx", "bitget", "kucoin":
|
|
||||||
if exchange.APIKey == "" {
|
|
||||||
missing = append(missing, "API Key")
|
|
||||||
}
|
|
||||||
if exchange.SecretKey == "" {
|
|
||||||
missing = append(missing, "Secret Key")
|
|
||||||
}
|
|
||||||
if exchange.Passphrase == "" {
|
|
||||||
missing = append(missing, "Passphrase")
|
|
||||||
}
|
|
||||||
case "hyperliquid":
|
|
||||||
if exchange.APIKey == "" {
|
|
||||||
missing = append(missing, "Private Key")
|
|
||||||
}
|
|
||||||
if strings.TrimSpace(exchange.HyperliquidWalletAddr) == "" {
|
|
||||||
missing = append(missing, "Wallet Address")
|
|
||||||
}
|
|
||||||
case "aster":
|
|
||||||
if strings.TrimSpace(exchange.AsterUser) == "" {
|
|
||||||
missing = append(missing, "Aster User")
|
|
||||||
}
|
|
||||||
if strings.TrimSpace(exchange.AsterSigner) == "" {
|
|
||||||
missing = append(missing, "Aster Signer")
|
|
||||||
}
|
|
||||||
if exchange.AsterPrivateKey == "" {
|
|
||||||
missing = append(missing, "Aster Private Key")
|
|
||||||
}
|
|
||||||
case "lighter":
|
|
||||||
if strings.TrimSpace(exchange.LighterWalletAddr) == "" {
|
|
||||||
missing = append(missing, "Wallet Address")
|
|
||||||
}
|
|
||||||
if exchange.LighterAPIKeyPrivateKey == "" {
|
|
||||||
missing = append(missing, "API Key Private Key")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return missing
|
|
||||||
}
|
|
||||||
|
|
||||||
func mapStringPairs(kv ...string) map[string]string {
|
|
||||||
if len(kv) == 0 {
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
params := make(map[string]string, len(kv)/2)
|
|
||||||
for i := 0; i+1 < len(kv); i += 2 {
|
|
||||||
params[kv[i]] = kv[i+1]
|
|
||||||
}
|
|
||||||
return params
|
|
||||||
}
|
|
||||||
|
|
||||||
func validateExchangeForTraderCreation(exchange *store.Exchange) (string, string, map[string]string) {
|
|
||||||
if exchange == nil {
|
|
||||||
return formatTraderCreationError("The exchange account you selected was not found", "Please go to \"Settings > Exchange Config\" and add an available account first, then come back to create the bot"),
|
|
||||||
"trader.create.exchange_not_found", nil
|
|
||||||
}
|
|
||||||
if !exchange.Enabled {
|
|
||||||
return formatTraderCreationError(
|
|
||||||
fmt.Sprintf("Exchange account \"%s\" is currently disabled", exchangeDisplayName(exchange)),
|
|
||||||
"Please go to \"Settings > Exchange Config\" to enable this account, then create the bot again",
|
|
||||||
), "trader.create.exchange_disabled", mapStringPairs("exchange_name", exchangeDisplayName(exchange))
|
|
||||||
}
|
|
||||||
|
|
||||||
missing := missingExchangeFields(exchange)
|
|
||||||
if len(missing) > 0 {
|
|
||||||
return formatTraderCreationError(
|
|
||||||
fmt.Sprintf("The configuration for exchange account \"%s\" is incomplete, missing %s", exchangeDisplayName(exchange), strings.Join(missing, ", ")),
|
|
||||||
"Please go to \"Settings > Exchange Config\" to complete the required information for this account, then create the bot again",
|
|
||||||
), "trader.create.exchange_missing_fields", mapStringPairs(
|
|
||||||
"exchange_name", exchangeDisplayName(exchange),
|
|
||||||
"missing_fields", strings.Join(missing, ", "),
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
switch exchange.ExchangeType {
|
|
||||||
case "binance", "bybit", "okx", "bitget", "gate", "kucoin", "hyperliquid", "aster", "lighter", "indodax":
|
|
||||||
return "", "", nil
|
|
||||||
default:
|
|
||||||
return formatTraderCreationError(
|
|
||||||
fmt.Sprintf("Exchange account \"%s\" uses type %s, which is not supported in the current version", exchangeDisplayName(exchange), exchange.ExchangeType),
|
|
||||||
"Please switch to an exchange account supported by the current version, then create the bot again",
|
|
||||||
), "trader.create.exchange_unsupported", mapStringPairs(
|
|
||||||
"exchange_name", exchangeDisplayName(exchange),
|
|
||||||
"exchange_type", exchange.ExchangeType,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func classifyTraderSetupReason(reason string) (string, string) {
|
|
||||||
trimmed := strings.TrimSpace(reason)
|
|
||||||
if trimmed == "" {
|
|
||||||
return "", ""
|
|
||||||
}
|
|
||||||
|
|
||||||
lower := strings.ToLower(trimmed)
|
|
||||||
|
|
||||||
switch {
|
|
||||||
case strings.Contains(lower, "failed to parse strategy config"),
|
|
||||||
strings.Contains(lower, "failed to parse strategy configuration"):
|
|
||||||
return "trader.reason.strategy_config_invalid", "The current strategy configuration is corrupted and the system cannot parse it for now"
|
|
||||||
case strings.Contains(lower, "has no strategy configured"):
|
|
||||||
return "trader.reason.strategy_missing", "The current bot is missing a valid trading strategy configuration"
|
|
||||||
case strings.Contains(lower, "failed to parse private key"),
|
|
||||||
(strings.Contains(lower, "invalid hex character") && strings.Contains(lower, "private key")):
|
|
||||||
return "trader.reason.private_key_invalid", "The private key format is incorrect and the system cannot recognize it"
|
|
||||||
case strings.Contains(lower, "failed to initialize hyperliquid trader"):
|
|
||||||
return "trader.reason.hyperliquid_init_failed", "Hyperliquid account initialization failed; please confirm the private key, main wallet address, and Agent Wallet configuration are correct"
|
|
||||||
case strings.Contains(lower, "failed to initialize aster trader"):
|
|
||||||
return "trader.reason.aster_init_failed", "Aster account initialization failed; please confirm the Aster User, Signer, and private key are correct"
|
|
||||||
case strings.Contains(lower, "failed to get meta information"):
|
|
||||||
return "trader.reason.exchange_meta_unavailable", "The system cannot read account meta information from the exchange for now"
|
|
||||||
case strings.Contains(lower, "security check failed") && strings.Contains(lower, "agent wallet balance too high"):
|
|
||||||
return "trader.reason.hyperliquid_agent_balance_too_high", "The Hyperliquid Agent Wallet balance is too high and does not meet the current security requirements"
|
|
||||||
case strings.Contains(lower, "failed to initialize account"):
|
|
||||||
return "trader.reason.exchange_account_init_failed", "Exchange account initialization failed; please confirm the wallet address and API Key match"
|
|
||||||
case strings.Contains(lower, "unsupported trading platform"):
|
|
||||||
return "trader.reason.exchange_unsupported", "The current exchange type does not support bot initialization"
|
|
||||||
case strings.Contains(lower, "initial balance not set and unable to fetch balance from exchange"):
|
|
||||||
return "trader.reason.exchange_balance_unavailable", "The system cannot read the account balance from the exchange for now"
|
|
||||||
case strings.Contains(lower, "timeout"), strings.Contains(lower, "no such host"), strings.Contains(lower, "connection refused"):
|
|
||||||
return "trader.reason.exchange_service_unreachable", "The system cannot connect to the exchange service for now"
|
|
||||||
default:
|
|
||||||
return "trader.reason.unknown", trimmed
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func humanizeTraderSetupReason(reason string) string {
|
|
||||||
_, message := classifyTraderSetupReason(reason)
|
|
||||||
return message
|
|
||||||
}
|
|
||||||
|
|
||||||
func traderSetupReasonParams(err error, fallback string, kv ...string) map[string]string {
|
|
||||||
params := mapStringPairs(kv...)
|
|
||||||
rawReason := SanitizeError(err, fallback)
|
|
||||||
reasonKey, reasonMessage := classifyTraderSetupReason(rawReason)
|
|
||||||
if reasonMessage == "" && fallback != "" {
|
|
||||||
reasonMessage = fallback
|
|
||||||
}
|
|
||||||
if reasonMessage != "" {
|
|
||||||
if params == nil {
|
|
||||||
params = map[string]string{}
|
|
||||||
}
|
|
||||||
params["reason"] = reasonMessage
|
|
||||||
}
|
|
||||||
if reasonKey != "" {
|
|
||||||
if params == nil {
|
|
||||||
params = map[string]string{}
|
|
||||||
}
|
|
||||||
params["reason_key"] = reasonKey
|
|
||||||
}
|
|
||||||
return params
|
|
||||||
}
|
|
||||||
|
|
||||||
func describeTraderLoadError(traderName string, err error) string {
|
|
||||||
if err == nil {
|
|
||||||
return formatTraderCreationError("The bot configuration was saved, but the runtime instance failed to initialize", "Please check that the model, strategy, and exchange configuration are complete, then try again")
|
|
||||||
}
|
|
||||||
|
|
||||||
reason := humanizeTraderSetupReason(SanitizeError(err, ""))
|
|
||||||
if reason == "" {
|
|
||||||
return formatTraderCreationError(
|
|
||||||
fmt.Sprintf("Bot \"%s\" failed to start when initializing its runtime instance", traderName),
|
|
||||||
"Please check that the model, strategy, and exchange configuration are complete, then try again",
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
return formatTraderCreationError(
|
|
||||||
fmt.Sprintf("Bot \"%s\" failed to start when initializing its runtime instance, because: %s", traderName, reason),
|
|
||||||
"Please check that the model, strategy, and exchange configuration are complete, then try again",
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
func describeTraderCreationWarning(traderName string, err error) string {
|
|
||||||
if err == nil {
|
|
||||||
return fmt.Sprintf("Bot \"%s\" has been saved, but it has not yet passed the pre-start validation. Please check the model, strategy, and exchange configuration first, then click start after fixing them.", traderName)
|
|
||||||
}
|
|
||||||
|
|
||||||
reason := humanizeTraderSetupReason(SanitizeError(err, ""))
|
|
||||||
if reason == "" {
|
|
||||||
return fmt.Sprintf("Bot \"%s\" has been saved, but it cannot start for now. Please check the model, strategy, and exchange configuration first, then click start after fixing them.", traderName)
|
|
||||||
}
|
|
||||||
|
|
||||||
return fmt.Sprintf("Bot \"%s\" has been saved, but it cannot start for now, because: %s. Please check the model, strategy, and exchange configuration first, then click start after fixing them.", traderName, reason)
|
|
||||||
}
|
|
||||||
|
|
||||||
func describeTraderStartError(traderName string, err error) string {
|
|
||||||
if err == nil {
|
|
||||||
return fmt.Sprintf("Failed to start the bot this time: bot \"%s\" cannot start for now. Please check the model, strategy, and exchange configuration, then click start again.", traderName)
|
|
||||||
}
|
|
||||||
|
|
||||||
reason := humanizeTraderSetupReason(SanitizeError(err, ""))
|
|
||||||
if reason == "" {
|
|
||||||
return fmt.Sprintf("Failed to start the bot this time: bot \"%s\" cannot start for now. Please check the model, strategy, and exchange configuration, then click start again.", traderName)
|
|
||||||
}
|
|
||||||
|
|
||||||
return fmt.Sprintf("Failed to start the bot this time: bot \"%s\" cannot start for now, because: %s. Please check the model, strategy, and exchange configuration, then click start again.", traderName, reason)
|
|
||||||
}
|
|
||||||
|
|
||||||
func formatTraderStartError(reason, nextStep string) string {
|
|
||||||
if nextStep == "" {
|
|
||||||
return fmt.Sprintf("Failed to start the bot this time: %s.", reason)
|
|
||||||
}
|
|
||||||
return fmt.Sprintf("Failed to start the bot this time: %s. %s.", reason, nextStep)
|
|
||||||
}
|
|
||||||
|
|
||||||
// handleCreateTrader Create new AI trader
|
|
||||||
func (s *Server) handleCreateTrader(c *gin.Context) {
|
|
||||||
userID := c.GetString("user_id")
|
|
||||||
var req CreateTraderRequest
|
|
||||||
if err := c.ShouldBindJSON(&req); err != nil {
|
|
||||||
SafeBadRequestWithDetails(c, traderCreationRequestError("The submitted information is incomplete or has an invalid format"), "trader.create.invalid_request", nil)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
// Validate leverage values against the same limits exposed by manual user config.
|
|
||||||
if errMsg, errCode := validateTraderLeverageRange(req.BTCETHLeverage, req.AltcoinLeverage); errMsg != "" {
|
|
||||||
SafeBadRequestWithDetails(c, errMsg, errCode, nil)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
// Validate trading symbol format. Hyperliquid xyz dex markets (stocks,
|
|
||||||
// commodities, indices, FX, Pre-IPO) are user-facing SYMBOL-USDC pairs,
|
|
||||||
// while standard crypto/perp markets keep the legacy USDT suffix format.
|
|
||||||
if req.TradingSymbols != "" {
|
|
||||||
symbols := strings.Split(req.TradingSymbols, ",")
|
|
||||||
for _, symbol := range symbols {
|
|
||||||
symbol = strings.TrimSpace(symbol)
|
|
||||||
if !isSupportedTraderSymbol(symbol) {
|
|
||||||
SafeBadRequestWithDetails(c, traderCreationRequestError(
|
|
||||||
fmt.Sprintf("The trading pair %s has an invalid format; only USDT perpetuals or Hyperliquid XYZ USDC instruments (SYMBOL-USDC) are currently supported", symbol),
|
|
||||||
), "trader.create.invalid_symbol", mapStringPairs("symbol", symbol))
|
|
||||||
return
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
model, err := s.store.AIModel().Get(userID, req.AIModelID)
|
|
||||||
if err != nil {
|
|
||||||
if errors.Is(err, gorm.ErrRecordNotFound) {
|
|
||||||
SafeBadRequestWithDetails(c, formatTraderCreationError("The AI model you selected was not found", "Please go to \"Settings > Model Config\" to add and enable an available model first, then come back to create the bot"), "trader.create.model_not_found", nil)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
SafeError(c, http.StatusInternalServerError,
|
|
||||||
formatTraderCreationError("Unable to read your AI model configuration for now", "Please retry later; if the problem persists, check whether the local service is running normally"),
|
|
||||||
err,
|
|
||||||
)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
if !model.Enabled {
|
|
||||||
SafeBadRequestWithDetails(c, formatTraderCreationError(
|
|
||||||
fmt.Sprintf("AI model \"%s\" is not enabled yet", model.Name),
|
|
||||||
"Please go to \"Settings > Model Config\" to enable it, then create the bot again",
|
|
||||||
), "trader.create.model_disabled", mapStringPairs("model_name", model.Name))
|
|
||||||
return
|
|
||||||
}
|
|
||||||
if model.APIKey == "" {
|
|
||||||
SafeBadRequestWithDetails(c, formatTraderCreationError(
|
|
||||||
fmt.Sprintf("AI model \"%s\" is missing an API Key or payment credentials", model.Name),
|
|
||||||
"Please go to \"Settings > Model Config\" to complete the model credentials, then create the bot again",
|
|
||||||
), "trader.create.model_missing_credentials", mapStringPairs("model_name", model.Name))
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
if req.StrategyID == "" {
|
|
||||||
SafeBadRequestWithDetails(c, formatTraderCreationError("You have not selected a trading strategy yet", "Please select a strategy first, then continue creating the bot"), "trader.create.strategy_required", nil)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
if req.StrategyID != "" {
|
|
||||||
_, err = s.store.Strategy().Get(userID, req.StrategyID)
|
|
||||||
if err != nil {
|
|
||||||
if errors.Is(err, gorm.ErrRecordNotFound) {
|
|
||||||
SafeBadRequestWithDetails(c, formatTraderCreationError("The strategy you selected does not exist or has been deleted", "Please select another available strategy, then continue creating the bot"), "trader.create.strategy_not_found", nil)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
SafeError(c, http.StatusInternalServerError,
|
|
||||||
formatTraderCreationError("Unable to read the strategy configuration you selected for now", "Please retry later; if the problem persists, check whether the local service is running normally"),
|
|
||||||
err,
|
|
||||||
)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Generate trader ID (use short UUID prefix for readability)
|
|
||||||
exchangeIDShort := req.ExchangeID
|
|
||||||
if len(exchangeIDShort) > 8 {
|
|
||||||
exchangeIDShort = exchangeIDShort[:8]
|
|
||||||
}
|
|
||||||
traderID := fmt.Sprintf("%s_%s_%d", exchangeIDShort, req.AIModelID, time.Now().Unix())
|
|
||||||
|
|
||||||
// Set default values
|
|
||||||
isCrossMargin := true // Default to cross margin mode
|
|
||||||
if req.IsCrossMargin != nil {
|
|
||||||
isCrossMargin = *req.IsCrossMargin
|
|
||||||
}
|
|
||||||
|
|
||||||
showInCompetition := true // Default to show in competition
|
|
||||||
if req.ShowInCompetition != nil {
|
|
||||||
showInCompetition = *req.ShowInCompetition
|
|
||||||
}
|
|
||||||
|
|
||||||
// Set leverage default values
|
|
||||||
btcEthLeverage := 10 // Default value
|
|
||||||
altcoinLeverage := 5 // Default value
|
|
||||||
if req.BTCETHLeverage > 0 {
|
|
||||||
btcEthLeverage = req.BTCETHLeverage
|
|
||||||
}
|
|
||||||
if req.AltcoinLeverage > 0 {
|
|
||||||
altcoinLeverage = req.AltcoinLeverage
|
|
||||||
}
|
|
||||||
|
|
||||||
// Set system prompt template default value
|
|
||||||
systemPromptTemplate := "default"
|
|
||||||
if req.SystemPromptTemplate != "" {
|
|
||||||
systemPromptTemplate = req.SystemPromptTemplate
|
|
||||||
}
|
|
||||||
|
|
||||||
// Set scan interval default value
|
|
||||||
scanIntervalMinutes := req.ScanIntervalMinutes
|
|
||||||
if scanIntervalMinutes <= 0 {
|
|
||||||
scanIntervalMinutes = 15
|
|
||||||
} else if scanIntervalMinutes < 3 {
|
|
||||||
scanIntervalMinutes = 3 // Explicit values below 3 minutes are clamped to the minimum.
|
|
||||||
}
|
|
||||||
|
|
||||||
// Query exchange actual balance, override user input
|
|
||||||
actualBalance := req.InitialBalance // Default to use user input
|
|
||||||
exchanges, err := s.store.Exchange().List(userID)
|
|
||||||
if err != nil {
|
|
||||||
SafeError(c, http.StatusInternalServerError,
|
|
||||||
formatTraderCreationError("Unable to read your exchange configuration for now", "Please retry later; if the problem persists, check whether the local service is running normally"),
|
|
||||||
err,
|
|
||||||
)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
// Find matching exchange configuration
|
|
||||||
var exchangeCfg *store.Exchange
|
|
||||||
for _, ex := range exchanges {
|
|
||||||
if ex.ID == req.ExchangeID {
|
|
||||||
exchangeCfg = ex
|
|
||||||
break
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if exchangeMsg, exchangeErrorKey, exchangeErrorParams := validateExchangeForTraderCreation(exchangeCfg); exchangeMsg != "" {
|
|
||||||
SafeBadRequestWithDetails(c, exchangeMsg, exchangeErrorKey, exchangeErrorParams)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
{
|
|
||||||
tempTrader, createErr := buildExchangeProbeTrader(exchangeCfg, userID)
|
|
||||||
if createErr != nil {
|
|
||||||
SafeBadRequestWithDetails(c, formatTraderCreationError(
|
|
||||||
fmt.Sprintf("Exchange account \"%s\" did not pass initialization validation, because: %s", exchangeDisplayName(exchangeCfg), humanizeTraderSetupReason(SanitizeError(createErr, "Configuration validation failed"))),
|
|
||||||
"Please go to \"Settings > Exchange Config\" to check whether this account's keys, address, and account information are entered correctly",
|
|
||||||
), "trader.create.exchange_probe_failed", traderSetupReasonParams(createErr, "Configuration validation failed",
|
|
||||||
"exchange_name", exchangeDisplayName(exchangeCfg),
|
|
||||||
))
|
|
||||||
return
|
|
||||||
} else if tempTrader != nil {
|
|
||||||
// Query actual balance
|
|
||||||
balanceInfo, balanceErr := tempTrader.GetBalance()
|
|
||||||
if balanceErr != nil {
|
|
||||||
logger.Infof("⚠️ Failed to query exchange balance, using user input for initial balance: %v", balanceErr)
|
|
||||||
} else {
|
|
||||||
if extractedBalance, found := extractExchangeTotalEquity(balanceInfo); found {
|
|
||||||
actualBalance = extractedBalance
|
|
||||||
logger.Infof("✓ Queried exchange total equity: %.2f %s (user input: %.2f)",
|
|
||||||
actualBalance, accountAssetForExchange(exchangeCfg.ExchangeType), req.InitialBalance)
|
|
||||||
} else {
|
|
||||||
logger.Infof("⚠️ Unable to extract total equity from balance info, balanceInfo=%v, using user input for initial balance", balanceInfo)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Create trader configuration (database entity)
|
|
||||||
logger.Infof("🔧 DEBUG: Starting to create trader config, ID=%s, Name=%s, AIModel=%s, Exchange=%s, StrategyID=%s", traderID, req.Name, req.AIModelID, req.ExchangeID, req.StrategyID)
|
|
||||||
traderRecord := &store.Trader{
|
|
||||||
ID: traderID,
|
|
||||||
UserID: userID,
|
|
||||||
Name: req.Name,
|
|
||||||
AIModelID: req.AIModelID,
|
|
||||||
ExchangeID: req.ExchangeID,
|
|
||||||
StrategyID: req.StrategyID, // Associated strategy ID (new version)
|
|
||||||
InitialBalance: actualBalance, // Use actual queried balance
|
|
||||||
BTCETHLeverage: btcEthLeverage,
|
|
||||||
AltcoinLeverage: altcoinLeverage,
|
|
||||||
TradingSymbols: req.TradingSymbols,
|
|
||||||
UseAI500: req.UseAI500,
|
|
||||||
UseOITop: req.UseOITop,
|
|
||||||
CustomPrompt: req.CustomPrompt,
|
|
||||||
OverrideBasePrompt: req.OverrideBasePrompt,
|
|
||||||
SystemPromptTemplate: systemPromptTemplate,
|
|
||||||
IsCrossMargin: isCrossMargin,
|
|
||||||
ShowInCompetition: showInCompetition,
|
|
||||||
ScanIntervalMinutes: scanIntervalMinutes,
|
|
||||||
IsRunning: false,
|
|
||||||
}
|
|
||||||
|
|
||||||
// Save to database
|
|
||||||
logger.Infof("🔧 DEBUG: Preparing to call CreateTrader")
|
|
||||||
err = s.store.Trader().Create(traderRecord)
|
|
||||||
if err != nil {
|
|
||||||
logger.Infof("❌ Failed to create trader: %v", err)
|
|
||||||
publicMsg := SanitizeError(err, formatTraderCreationError("The bot configuration was not saved successfully", "Please check the name, model, strategy, and exchange configuration, then try again"))
|
|
||||||
statusCode := http.StatusBadRequest
|
|
||||||
if publicMsg == formatTraderCreationError("The bot configuration was not saved successfully", "Please check the name, model, strategy, and exchange configuration, then try again") {
|
|
||||||
statusCode = http.StatusInternalServerError
|
|
||||||
}
|
|
||||||
SafeError(c, statusCode, publicMsg, err)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
logger.Infof("🔧 DEBUG: CreateTrader succeeded")
|
|
||||||
|
|
||||||
// Immediately load new trader into TraderManager
|
|
||||||
logger.Infof("🔧 DEBUG: Preparing to call LoadUserTraders")
|
|
||||||
startupWarning := ""
|
|
||||||
err = s.traderManager.LoadUserTradersFromStore(s.store, userID)
|
|
||||||
if err != nil {
|
|
||||||
logger.Infof("⚠️ Failed to load user traders into memory: %v", err)
|
|
||||||
startupWarning = describeTraderCreationWarning(req.Name, err)
|
|
||||||
}
|
|
||||||
logger.Infof("🔧 DEBUG: LoadUserTraders completed")
|
|
||||||
|
|
||||||
if startupWarning == "" {
|
|
||||||
if loadErr := s.traderManager.GetLoadError(traderID); loadErr != nil {
|
|
||||||
logger.Infof("⚠️ Trader %s failed to load after creation: %v", traderID, loadErr)
|
|
||||||
startupWarning = describeTraderCreationWarning(req.Name, loadErr)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if startupWarning == "" {
|
|
||||||
if _, getErr := s.traderManager.GetTrader(traderID); getErr != nil {
|
|
||||||
logger.Infof("⚠️ Trader %s not found in memory after creation: %v", traderID, getErr)
|
|
||||||
startupWarning = describeTraderCreationWarning(req.Name, getErr)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
logger.Infof("✓ Trader created successfully: %s (model: %s, exchange: %s)", req.Name, req.AIModelID, req.ExchangeID)
|
|
||||||
|
|
||||||
c.JSON(http.StatusCreated, gin.H{
|
|
||||||
"trader_id": traderID,
|
|
||||||
"trader_name": req.Name,
|
|
||||||
"ai_model": req.AIModelID,
|
|
||||||
"is_running": false,
|
|
||||||
"startup_warning": startupWarning,
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
// handleUpdateTrader Update trader configuration
|
|
||||||
func (s *Server) handleUpdateTrader(c *gin.Context) {
|
|
||||||
userID := c.GetString("user_id")
|
|
||||||
traderID := c.Param("id")
|
|
||||||
|
|
||||||
var req UpdateTraderRequest
|
|
||||||
if err := c.ShouldBindJSON(&req); err != nil {
|
|
||||||
SafeBadRequest(c, "Invalid request parameters")
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
// Check if trader exists and belongs to current user
|
|
||||||
traders, err := s.store.Trader().List(userID)
|
|
||||||
if err != nil {
|
|
||||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to get trader list"})
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
var existingTrader *store.Trader
|
|
||||||
for _, t := range traders {
|
|
||||||
if t.ID == traderID {
|
|
||||||
existingTrader = t
|
|
||||||
break
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if existingTrader == nil {
|
|
||||||
c.JSON(http.StatusNotFound, gin.H{"error": "Trader does not exist"})
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
if errMsg, errCode := validateTraderLeverageRange(req.BTCETHLeverage, req.AltcoinLeverage); errMsg != "" {
|
|
||||||
SafeBadRequestWithDetails(c, errMsg, errCode, nil)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
// Set default values
|
|
||||||
isCrossMargin := existingTrader.IsCrossMargin // Keep original value
|
|
||||||
if req.IsCrossMargin != nil {
|
|
||||||
isCrossMargin = *req.IsCrossMargin
|
|
||||||
}
|
|
||||||
|
|
||||||
showInCompetition := existingTrader.ShowInCompetition // Keep original value
|
|
||||||
if req.ShowInCompetition != nil {
|
|
||||||
showInCompetition = *req.ShowInCompetition
|
|
||||||
}
|
|
||||||
|
|
||||||
// Set leverage default values
|
|
||||||
btcEthLeverage := req.BTCETHLeverage
|
|
||||||
altcoinLeverage := req.AltcoinLeverage
|
|
||||||
if btcEthLeverage <= 0 {
|
|
||||||
btcEthLeverage = existingTrader.BTCETHLeverage // Keep original value
|
|
||||||
}
|
|
||||||
if altcoinLeverage <= 0 {
|
|
||||||
altcoinLeverage = existingTrader.AltcoinLeverage // Keep original value
|
|
||||||
}
|
|
||||||
|
|
||||||
// Set scan interval, allow updates
|
|
||||||
scanIntervalMinutes := req.ScanIntervalMinutes
|
|
||||||
logger.Infof("📊 Update trader scan_interval: req=%d, existing=%d", req.ScanIntervalMinutes, existingTrader.ScanIntervalMinutes)
|
|
||||||
if scanIntervalMinutes <= 0 {
|
|
||||||
scanIntervalMinutes = existingTrader.ScanIntervalMinutes // Keep original value
|
|
||||||
} else if scanIntervalMinutes < 3 {
|
|
||||||
scanIntervalMinutes = 3
|
|
||||||
}
|
|
||||||
logger.Infof("📊 Final scan_interval_minutes: %d", scanIntervalMinutes)
|
|
||||||
|
|
||||||
// Set system prompt template
|
|
||||||
systemPromptTemplate := req.SystemPromptTemplate
|
|
||||||
if systemPromptTemplate == "" {
|
|
||||||
systemPromptTemplate = existingTrader.SystemPromptTemplate // Keep original value
|
|
||||||
}
|
|
||||||
|
|
||||||
// Handle strategy ID (if not provided, keep original value)
|
|
||||||
strategyID := req.StrategyID
|
|
||||||
if strategyID == "" {
|
|
||||||
strategyID = existingTrader.StrategyID
|
|
||||||
}
|
|
||||||
|
|
||||||
exchangeChanged := req.ExchangeID != "" && req.ExchangeID != existingTrader.ExchangeID
|
|
||||||
resetInitialBalance := exchangeChanged && req.InitialBalance <= 0
|
|
||||||
|
|
||||||
initialBalance := existingTrader.InitialBalance
|
|
||||||
if req.InitialBalance > 0 {
|
|
||||||
initialBalance = req.InitialBalance
|
|
||||||
}
|
|
||||||
if resetInitialBalance {
|
|
||||||
initialBalance = 0
|
|
||||||
}
|
|
||||||
|
|
||||||
// Update trader configuration
|
|
||||||
traderRecord := &store.Trader{
|
|
||||||
ID: traderID,
|
|
||||||
UserID: userID,
|
|
||||||
Name: req.Name,
|
|
||||||
AIModelID: req.AIModelID,
|
|
||||||
ExchangeID: req.ExchangeID,
|
|
||||||
StrategyID: strategyID, // Associated strategy ID
|
|
||||||
InitialBalance: initialBalance,
|
|
||||||
BTCETHLeverage: btcEthLeverage,
|
|
||||||
AltcoinLeverage: altcoinLeverage,
|
|
||||||
TradingSymbols: req.TradingSymbols,
|
|
||||||
CustomPrompt: req.CustomPrompt,
|
|
||||||
OverrideBasePrompt: req.OverrideBasePrompt,
|
|
||||||
SystemPromptTemplate: systemPromptTemplate,
|
|
||||||
IsCrossMargin: isCrossMargin,
|
|
||||||
ShowInCompetition: showInCompetition,
|
|
||||||
ScanIntervalMinutes: scanIntervalMinutes,
|
|
||||||
IsRunning: existingTrader.IsRunning, // Keep original value
|
|
||||||
}
|
|
||||||
|
|
||||||
// Check if trader was running before update (we'll restart it after)
|
|
||||||
wasRunning := false
|
|
||||||
if existingMemTrader, memErr := s.traderManager.GetTrader(traderID); memErr == nil {
|
|
||||||
status := existingMemTrader.GetStatus()
|
|
||||||
if running, ok := status["is_running"].(bool); ok && running {
|
|
||||||
wasRunning = true
|
|
||||||
logger.Infof("🔄 Trader %s was running, will restart with new config after update", traderID)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Update database
|
|
||||||
logger.Infof("🔄 Updating trader: ID=%s, Name=%s, AIModelID=%s, StrategyID=%s, ScanInterval=%d min",
|
|
||||||
traderRecord.ID, traderRecord.Name, traderRecord.AIModelID, traderRecord.StrategyID, scanIntervalMinutes)
|
|
||||||
err = s.store.Trader().Update(traderRecord)
|
|
||||||
if err != nil {
|
|
||||||
SafeInternalError(c, "Failed to update trader", err)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
if resetInitialBalance {
|
|
||||||
logger.Infof("🔄 Exchange changed for trader %s, resetting stale initial_balance to 0", traderID)
|
|
||||||
if err := s.store.Trader().UpdateInitialBalance(userID, traderID, 0); err != nil {
|
|
||||||
SafeInternalError(c, "Failed to reset trader initial balance", err)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Remove old trader from memory first (this also stops if running)
|
|
||||||
s.traderManager.RemoveTrader(traderID)
|
|
||||||
|
|
||||||
// Reload traders into memory with fresh config
|
|
||||||
err = s.traderManager.LoadUserTradersFromStore(s.store, userID)
|
|
||||||
if err != nil {
|
|
||||||
logger.Infof("⚠️ Failed to reload user traders into memory: %v", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
// If trader was running before, restart it with new config
|
|
||||||
if wasRunning {
|
|
||||||
if reloadedTrader, getErr := s.traderManager.GetTrader(traderID); getErr == nil {
|
|
||||||
go func() {
|
|
||||||
logger.Infof("▶️ Restarting trader %s with new config...", traderID)
|
|
||||||
if runErr := reloadedTrader.Run(); runErr != nil {
|
|
||||||
logger.Infof("❌ Trader %s runtime error: %v", traderID, runErr)
|
|
||||||
}
|
|
||||||
}()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
logger.Infof("✓ Trader updated successfully: %s (model: %s, exchange: %s, strategy: %s)", req.Name, req.AIModelID, req.ExchangeID, strategyID)
|
|
||||||
|
|
||||||
c.JSON(http.StatusOK, gin.H{
|
|
||||||
"trader_id": traderID,
|
|
||||||
"trader_name": req.Name,
|
|
||||||
"ai_model": req.AIModelID,
|
|
||||||
// A running trader is restarted with the new config (async above), so
|
|
||||||
// report it as running — callers must not fire a redundant start.
|
|
||||||
"is_running": wasRunning,
|
|
||||||
"message": "Trader updated successfully",
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
// handleDeleteTrader Delete trader
|
|
||||||
func (s *Server) handleDeleteTrader(c *gin.Context) {
|
|
||||||
userID := c.GetString("user_id")
|
|
||||||
traderID := c.Param("id")
|
|
||||||
|
|
||||||
// Delete from database
|
|
||||||
err := s.store.Trader().Delete(userID, traderID)
|
|
||||||
if err != nil {
|
|
||||||
SafeInternalError(c, "Failed to delete trader", err)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
// If trader is running, stop it first
|
|
||||||
if trader, err := s.traderManager.GetTrader(traderID); err == nil {
|
|
||||||
status := trader.GetStatus()
|
|
||||||
if isRunning, ok := status["is_running"].(bool); ok && isRunning {
|
|
||||||
trader.Stop()
|
|
||||||
logger.Infof("⏹ Stopped running trader: %s", traderID)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Remove trader from memory
|
|
||||||
s.traderManager.RemoveTrader(traderID)
|
|
||||||
|
|
||||||
logger.Infof("✓ Trader deleted: %s", traderID)
|
|
||||||
c.JSON(http.StatusOK, gin.H{"message": "Trader deleted"})
|
|
||||||
}
|
|
||||||
|
|
||||||
// handleStartTrader Start trader
|
|
||||||
func (s *Server) handleStartTrader(c *gin.Context) {
|
|
||||||
userID := c.GetString("user_id")
|
|
||||||
traderID := c.Param("id")
|
|
||||||
|
|
||||||
// Verify trader belongs to current user
|
|
||||||
fullCfg, err := s.store.Trader().GetFullConfig(userID, traderID)
|
|
||||||
if err != nil {
|
|
||||||
c.JSON(http.StatusNotFound, gin.H{"error": "Trader does not exist or no access permission"})
|
|
||||||
return
|
|
||||||
}
|
|
||||||
traderName := traderID
|
|
||||||
if fullCfg != nil && fullCfg.Trader != nil && fullCfg.Trader.Name != "" {
|
|
||||||
traderName = fullCfg.Trader.Name
|
|
||||||
}
|
|
||||||
|
|
||||||
if fullCfg != nil && fullCfg.Exchange != nil && fullCfg.Exchange.ExchangeType == "hyperliquid" && !fullCfg.Exchange.HyperliquidBuilderApproved {
|
|
||||||
SafeBadRequestWithDetails(c, formatTraderStartError(
|
|
||||||
fmt.Sprintf("The Hyperliquid trading authorization for bot \"%s\" is not yet complete", traderName),
|
|
||||||
"Please reconnect the Hyperliquid wallet and complete the trading authorization, then start the bot",
|
|
||||||
), "trader.start.hyperliquid_builder_not_approved", mapStringPairs("trader_name", traderName, "exchange_name", exchangeDisplayName(fullCfg.Exchange)))
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
// Check if trader exists in memory and if it's running
|
|
||||||
existingTrader, _ := s.traderManager.GetTrader(traderID)
|
|
||||||
if existingTrader != nil {
|
|
||||||
status := existingTrader.GetStatus()
|
|
||||||
if isRunning, ok := status["is_running"].(bool); ok && isRunning {
|
|
||||||
c.JSON(http.StatusBadRequest, gin.H{
|
|
||||||
"error": "Trader is already running",
|
|
||||||
"error_key": "trader.start.already_running",
|
|
||||||
})
|
|
||||||
return
|
|
||||||
}
|
|
||||||
// Trader exists but is stopped - remove from memory to reload fresh config
|
|
||||||
logger.Infof("🔄 Removing stopped trader %s from memory to reload config...", traderID)
|
|
||||||
s.traderManager.RemoveTrader(traderID)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Load trader from database (always reload to get latest config)
|
|
||||||
logger.Infof("🔄 Loading trader %s from database...", traderID)
|
|
||||||
if loadErr := s.traderManager.LoadUserTradersFromStore(s.store, userID); loadErr != nil {
|
|
||||||
logger.Infof("❌ Failed to load user traders: %v", loadErr)
|
|
||||||
SafeErrorWithDetails(c, http.StatusInternalServerError, describeTraderStartError(traderName, loadErr), "trader.start.load_failed", traderSetupReasonParams(loadErr, "", "trader_name", traderName), loadErr)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
trader, err := s.traderManager.GetTrader(traderID)
|
|
||||||
if err != nil {
|
|
||||||
if fullCfg != nil && fullCfg.Trader != nil {
|
|
||||||
// Check strategy
|
|
||||||
if fullCfg.Strategy == nil {
|
|
||||||
SafeBadRequestWithDetails(c, describeTraderStartError(traderName, fmt.Errorf("trader has no strategy configured")), "trader.start.strategy_missing", mapStringPairs("trader_name", traderName))
|
|
||||||
return
|
|
||||||
}
|
|
||||||
// Check AI model
|
|
||||||
if fullCfg.AIModel == nil {
|
|
||||||
SafeBadRequestWithDetails(c, formatTraderStartError("The AI model associated with this bot does not exist", "Please go to \"Settings > Model Config\" to check, then click start again"), "trader.start.model_not_found", mapStringPairs("trader_name", traderName))
|
|
||||||
return
|
|
||||||
}
|
|
||||||
if !fullCfg.AIModel.Enabled {
|
|
||||||
SafeBadRequestWithDetails(c, formatTraderStartError(
|
|
||||||
fmt.Sprintf("The AI model \"%s\" associated with bot \"%s\" is not enabled yet", fullCfg.AIModel.Name, traderName),
|
|
||||||
"Please go to \"Settings > Model Config\" to enable it, then click start again",
|
|
||||||
), "trader.start.model_disabled", mapStringPairs("trader_name", traderName, "model_name", fullCfg.AIModel.Name))
|
|
||||||
return
|
|
||||||
}
|
|
||||||
// Check exchange
|
|
||||||
if fullCfg.Exchange == nil {
|
|
||||||
SafeBadRequestWithDetails(c, formatTraderStartError("The exchange account associated with this bot does not exist", "Please go to \"Settings > Exchange Config\" to check, then click start again"), "trader.start.exchange_not_found", mapStringPairs("trader_name", traderName))
|
|
||||||
return
|
|
||||||
}
|
|
||||||
if !fullCfg.Exchange.Enabled {
|
|
||||||
SafeBadRequestWithDetails(c, formatTraderStartError(
|
|
||||||
fmt.Sprintf("The exchange account \"%s\" associated with bot \"%s\" is not enabled yet", exchangeDisplayName(fullCfg.Exchange), traderName),
|
|
||||||
"Please go to \"Settings > Exchange Config\" to enable it, then click start again",
|
|
||||||
), "trader.start.exchange_disabled", mapStringPairs("trader_name", traderName, "exchange_name", exchangeDisplayName(fullCfg.Exchange)))
|
|
||||||
return
|
|
||||||
}
|
|
||||||
}
|
|
||||||
// Check if there's a specific load error
|
|
||||||
if loadErr := s.traderManager.GetLoadError(traderID); loadErr != nil {
|
|
||||||
SafeBadRequestWithDetails(c, describeTraderStartError(traderName, loadErr), "trader.start.load_failed", traderSetupReasonParams(loadErr, "", "trader_name", traderName))
|
|
||||||
return
|
|
||||||
}
|
|
||||||
SafeBadRequestWithDetails(c, describeTraderStartError(traderName, err), "trader.start.setup_invalid", traderSetupReasonParams(err, "", "trader_name", traderName))
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
// Server-side launch gate: the trader cannot function without a funded AI
|
|
||||||
// wallet and a ready exchange account, so verify both before the run loop
|
|
||||||
// starts. `?force=true` skips the gate for deliberate manual overrides.
|
|
||||||
if c.Query("force") != "true" {
|
|
||||||
// strategyRequired=false: a trader that loaded into memory necessarily
|
|
||||||
// has a valid strategy (the manager refuses to load without one), so the
|
|
||||||
// preflight strategy check would be redundant here.
|
|
||||||
preflight := s.runLaunchPreflight(userID, fullCfg.AIModel, fullCfg.Exchange, fullCfg.Strategy, false)
|
|
||||||
if !preflight.Ready {
|
|
||||||
c.JSON(http.StatusBadRequest, gin.H{
|
|
||||||
"error": formatTraderStartError(preflight.Summary(), "Complete the failing checks, then start the bot again"),
|
|
||||||
"error_key": "trader.start.preflight_failed",
|
|
||||||
"preflight": preflight,
|
|
||||||
})
|
|
||||||
return
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Start trader
|
|
||||||
go func() {
|
|
||||||
logger.Infof("▶️ Starting trader %s (%s)", traderID, trader.GetName())
|
|
||||||
if err := trader.Run(); err != nil {
|
|
||||||
logger.Infof("❌ Trader %s runtime error: %v", trader.GetName(), err)
|
|
||||||
}
|
|
||||||
}()
|
|
||||||
|
|
||||||
// Update running status in database
|
|
||||||
err = s.store.Trader().UpdateStatus(userID, traderID, true)
|
|
||||||
if err != nil {
|
|
||||||
logger.Infof("⚠️ Failed to update trader status: %v", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
logger.Infof("✓ Trader %s started", trader.GetName())
|
|
||||||
c.JSON(http.StatusOK, gin.H{"message": "Trader started"})
|
|
||||||
}
|
|
||||||
|
|
||||||
// handleStopTrader Stop trader
|
|
||||||
func (s *Server) handleStopTrader(c *gin.Context) {
|
|
||||||
userID := c.GetString("user_id")
|
|
||||||
traderID := c.Param("id")
|
|
||||||
|
|
||||||
// Verify trader belongs to current user
|
|
||||||
_, err := s.store.Trader().GetFullConfig(userID, traderID)
|
|
||||||
if err != nil {
|
|
||||||
c.JSON(http.StatusNotFound, gin.H{"error": "Trader does not exist or no access permission"})
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
trader, err := s.traderManager.GetTrader(traderID)
|
|
||||||
if err != nil {
|
|
||||||
c.JSON(http.StatusNotFound, gin.H{"error": "Trader does not exist"})
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
// Check if trader is running
|
|
||||||
status := trader.GetStatus()
|
|
||||||
if isRunning, ok := status["is_running"].(bool); ok && !isRunning {
|
|
||||||
c.JSON(http.StatusBadRequest, gin.H{"error": "Trader is already stopped"})
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
// Stop trader
|
|
||||||
trader.Stop()
|
|
||||||
|
|
||||||
// Update running status in database
|
|
||||||
err = s.store.Trader().UpdateStatus(userID, traderID, false)
|
|
||||||
if err != nil {
|
|
||||||
logger.Infof("⚠️ Failed to update trader status: %v", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
logger.Infof("⏹ Trader %s stopped", trader.GetName())
|
|
||||||
c.JSON(http.StatusOK, gin.H{"message": "Trader stopped"})
|
|
||||||
}
|
|
||||||
@@ -1,79 +0,0 @@
|
|||||||
package api
|
|
||||||
|
|
||||||
import (
|
|
||||||
"net/http"
|
|
||||||
|
|
||||||
"nofx/logger"
|
|
||||||
|
|
||||||
"github.com/gin-gonic/gin"
|
|
||||||
)
|
|
||||||
|
|
||||||
// handleUpdateTraderPrompt Update trader custom prompt
|
|
||||||
func (s *Server) handleUpdateTraderPrompt(c *gin.Context) {
|
|
||||||
traderID := c.Param("id")
|
|
||||||
userID := c.GetString("user_id")
|
|
||||||
|
|
||||||
var req struct {
|
|
||||||
CustomPrompt string `json:"custom_prompt"`
|
|
||||||
OverrideBasePrompt bool `json:"override_base_prompt"`
|
|
||||||
}
|
|
||||||
|
|
||||||
if err := c.ShouldBindJSON(&req); err != nil {
|
|
||||||
SafeBadRequest(c, "Invalid request parameters")
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
// Update database
|
|
||||||
err := s.store.Trader().UpdateCustomPrompt(userID, traderID, req.CustomPrompt, req.OverrideBasePrompt)
|
|
||||||
if err != nil {
|
|
||||||
SafeInternalError(c, "Failed to update custom prompt", err)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
// If trader is in memory, update its custom prompt and override settings
|
|
||||||
trader, err := s.traderManager.GetTrader(traderID)
|
|
||||||
if err == nil {
|
|
||||||
trader.SetCustomPrompt(req.CustomPrompt)
|
|
||||||
trader.SetOverrideBasePrompt(req.OverrideBasePrompt)
|
|
||||||
logger.Infof("✓ Updated trader %s custom prompt (override base=%v)", trader.GetName(), req.OverrideBasePrompt)
|
|
||||||
}
|
|
||||||
|
|
||||||
c.JSON(http.StatusOK, gin.H{"message": "Custom prompt updated"})
|
|
||||||
}
|
|
||||||
|
|
||||||
// handleToggleCompetition Toggle trader competition visibility
|
|
||||||
func (s *Server) handleToggleCompetition(c *gin.Context) {
|
|
||||||
traderID := c.Param("id")
|
|
||||||
userID := c.GetString("user_id")
|
|
||||||
|
|
||||||
var req struct {
|
|
||||||
ShowInCompetition bool `json:"show_in_competition"`
|
|
||||||
}
|
|
||||||
|
|
||||||
if err := c.ShouldBindJSON(&req); err != nil {
|
|
||||||
SafeBadRequest(c, "Invalid request parameters")
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
// Update database
|
|
||||||
err := s.store.Trader().UpdateShowInCompetition(userID, traderID, req.ShowInCompetition)
|
|
||||||
if err != nil {
|
|
||||||
SafeInternalError(c, "Update competition visibility", err)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
// Update in-memory trader if it exists
|
|
||||||
if trader, err := s.traderManager.GetTrader(traderID); err == nil {
|
|
||||||
trader.SetShowInCompetition(req.ShowInCompetition)
|
|
||||||
}
|
|
||||||
|
|
||||||
status := "shown"
|
|
||||||
if !req.ShowInCompetition {
|
|
||||||
status = "hidden"
|
|
||||||
}
|
|
||||||
logger.Infof("✓ Trader %s competition visibility updated: %s", traderID, status)
|
|
||||||
c.JSON(http.StatusOK, gin.H{
|
|
||||||
"message": "Competition visibility updated",
|
|
||||||
"show_in_competition": req.ShowInCompetition,
|
|
||||||
})
|
|
||||||
}
|
|
||||||
@@ -1,540 +0,0 @@
|
|||||||
package api
|
|
||||||
|
|
||||||
import (
|
|
||||||
"fmt"
|
|
||||||
"net/http"
|
|
||||||
"strings"
|
|
||||||
"time"
|
|
||||||
|
|
||||||
"nofx/logger"
|
|
||||||
"nofx/store"
|
|
||||||
"nofx/trader"
|
|
||||||
"nofx/trader/aster"
|
|
||||||
"nofx/trader/binance"
|
|
||||||
"nofx/trader/bitget"
|
|
||||||
"nofx/trader/bybit"
|
|
||||||
"nofx/trader/gate"
|
|
||||||
hyperliquidtrader "nofx/trader/hyperliquid"
|
|
||||||
"nofx/trader/kucoin"
|
|
||||||
"nofx/trader/lighter"
|
|
||||||
"nofx/trader/okx"
|
|
||||||
|
|
||||||
"github.com/gin-gonic/gin"
|
|
||||||
)
|
|
||||||
|
|
||||||
// handleGetGridRiskInfo returns current risk information for a grid trader
|
|
||||||
func (s *Server) handleGetGridRiskInfo(c *gin.Context) {
|
|
||||||
traderID := c.Param("id")
|
|
||||||
|
|
||||||
autoTrader, err := s.traderManager.GetTrader(traderID)
|
|
||||||
if err != nil {
|
|
||||||
c.JSON(http.StatusNotFound, gin.H{"error": "trader not found"})
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
riskInfo := autoTrader.GetGridRiskInfo()
|
|
||||||
c.JSON(http.StatusOK, riskInfo)
|
|
||||||
}
|
|
||||||
|
|
||||||
// handleSyncBalance Sync exchange balance to initial_balance (Option B: Manual Sync + Option C: Smart Detection)
|
|
||||||
func (s *Server) handleSyncBalance(c *gin.Context) {
|
|
||||||
userID := c.GetString("user_id")
|
|
||||||
traderID := c.Param("id")
|
|
||||||
|
|
||||||
logger.Infof("🔄 User %s requested balance sync for trader %s", userID, traderID)
|
|
||||||
|
|
||||||
// Get trader configuration from database (including exchange info)
|
|
||||||
fullConfig, err := s.store.Trader().GetFullConfig(userID, traderID)
|
|
||||||
if err != nil {
|
|
||||||
c.JSON(http.StatusNotFound, gin.H{"error": "Trader does not exist"})
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
traderConfig := fullConfig.Trader
|
|
||||||
exchangeCfg := fullConfig.Exchange
|
|
||||||
|
|
||||||
if exchangeCfg == nil || !exchangeCfg.Enabled {
|
|
||||||
c.JSON(http.StatusBadRequest, gin.H{"error": "Exchange not configured or not enabled"})
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
tempTrader, createErr := buildExchangeProbeTrader(exchangeCfg, userID)
|
|
||||||
if createErr != nil {
|
|
||||||
logger.Infof("⚠️ Failed to create temporary trader: %v", createErr)
|
|
||||||
SafeInternalError(c, "Failed to connect to exchange", createErr)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
// Query actual balance
|
|
||||||
balanceInfo, balanceErr := tempTrader.GetBalance()
|
|
||||||
if balanceErr != nil {
|
|
||||||
logger.Infof("⚠️ Failed to query exchange balance: %v", balanceErr)
|
|
||||||
SafeInternalError(c, "Failed to query balance", balanceErr)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
// Extract total equity (for P&L calculation, we need total account value, not available balance)
|
|
||||||
actualBalance, found := extractExchangeTotalEquity(balanceInfo)
|
|
||||||
if !found {
|
|
||||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "Unable to get total equity"})
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
s.exchangeAccountStateCache.Invalidate(userID)
|
|
||||||
|
|
||||||
oldBalance := traderConfig.InitialBalance
|
|
||||||
|
|
||||||
// Smart balance change detection
|
|
||||||
changePercent := ((actualBalance - oldBalance) / oldBalance) * 100
|
|
||||||
changeType := "increase"
|
|
||||||
if changePercent < 0 {
|
|
||||||
changeType = "decrease"
|
|
||||||
}
|
|
||||||
|
|
||||||
logger.Infof("✓ Queried actual exchange balance: %.2f USDT (current config: %.2f USDT, change: %.2f%%)",
|
|
||||||
actualBalance, oldBalance, changePercent)
|
|
||||||
|
|
||||||
// Update initial_balance in database
|
|
||||||
err = s.store.Trader().UpdateInitialBalance(userID, traderID, actualBalance)
|
|
||||||
if err != nil {
|
|
||||||
logger.Infof("❌ Failed to update initial_balance: %v", err)
|
|
||||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to update balance"})
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
// Reload traders into memory
|
|
||||||
err = s.traderManager.LoadUserTradersFromStore(s.store, userID)
|
|
||||||
if err != nil {
|
|
||||||
logger.Infof("⚠️ Failed to reload user traders into memory: %v", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
logger.Infof("✅ Synced balance: %.2f → %.2f USDT (%s %.2f%%)", oldBalance, actualBalance, changeType, changePercent)
|
|
||||||
|
|
||||||
c.JSON(http.StatusOK, gin.H{
|
|
||||||
"message": "Balance synced successfully",
|
|
||||||
"old_balance": oldBalance,
|
|
||||||
"new_balance": actualBalance,
|
|
||||||
"change_percent": changePercent,
|
|
||||||
"change_type": changeType,
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
// handleClosePosition One-click close position
|
|
||||||
func (s *Server) handleClosePosition(c *gin.Context) {
|
|
||||||
userID := c.GetString("user_id")
|
|
||||||
traderID := c.Param("id")
|
|
||||||
|
|
||||||
var req struct {
|
|
||||||
Symbol string `json:"symbol" binding:"required"`
|
|
||||||
Side string `json:"side" binding:"required"` // "LONG" or "SHORT"
|
|
||||||
}
|
|
||||||
|
|
||||||
if err := c.ShouldBindJSON(&req); err != nil {
|
|
||||||
c.JSON(http.StatusBadRequest, gin.H{"error": "Parameter error: symbol and side are required"})
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
logger.Infof("🔻 User %s requested position close: trader=%s, symbol=%s, side=%s", userID, traderID, req.Symbol, req.Side)
|
|
||||||
|
|
||||||
// Get trader configuration from database (including exchange info)
|
|
||||||
fullConfig, err := s.store.Trader().GetFullConfig(userID, traderID)
|
|
||||||
if err != nil {
|
|
||||||
c.JSON(http.StatusNotFound, gin.H{"error": "Trader does not exist"})
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
exchangeCfg := fullConfig.Exchange
|
|
||||||
|
|
||||||
if exchangeCfg == nil || !exchangeCfg.Enabled {
|
|
||||||
c.JSON(http.StatusBadRequest, gin.H{"error": "Exchange not configured or not enabled"})
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
// Create temporary trader to execute close position
|
|
||||||
var tempTrader trader.Trader
|
|
||||||
var createErr error
|
|
||||||
|
|
||||||
// Use ExchangeType (e.g., "binance") instead of ExchangeID (which is now UUID)
|
|
||||||
// Convert EncryptedString fields to string
|
|
||||||
switch exchangeCfg.ExchangeType {
|
|
||||||
case "binance":
|
|
||||||
tempTrader = binance.NewFuturesTrader(string(exchangeCfg.APIKey), string(exchangeCfg.SecretKey), userID)
|
|
||||||
case "hyperliquid":
|
|
||||||
tempTrader, createErr = hyperliquidtrader.NewHyperliquidTrader(
|
|
||||||
string(exchangeCfg.APIKey),
|
|
||||||
exchangeCfg.HyperliquidWalletAddr,
|
|
||||||
exchangeCfg.Testnet,
|
|
||||||
exchangeCfg.HyperliquidUnifiedAcct,
|
|
||||||
)
|
|
||||||
case "aster":
|
|
||||||
tempTrader, createErr = aster.NewAsterTrader(
|
|
||||||
exchangeCfg.AsterUser,
|
|
||||||
exchangeCfg.AsterSigner,
|
|
||||||
string(exchangeCfg.AsterPrivateKey),
|
|
||||||
)
|
|
||||||
case "bybit":
|
|
||||||
tempTrader = bybit.NewBybitTrader(
|
|
||||||
string(exchangeCfg.APIKey),
|
|
||||||
string(exchangeCfg.SecretKey),
|
|
||||||
)
|
|
||||||
case "okx":
|
|
||||||
tempTrader = okx.NewOKXTrader(
|
|
||||||
string(exchangeCfg.APIKey),
|
|
||||||
string(exchangeCfg.SecretKey),
|
|
||||||
string(exchangeCfg.Passphrase),
|
|
||||||
)
|
|
||||||
case "bitget":
|
|
||||||
tempTrader = bitget.NewBitgetTrader(
|
|
||||||
string(exchangeCfg.APIKey),
|
|
||||||
string(exchangeCfg.SecretKey),
|
|
||||||
string(exchangeCfg.Passphrase),
|
|
||||||
)
|
|
||||||
case "gate":
|
|
||||||
tempTrader = gate.NewGateTrader(
|
|
||||||
string(exchangeCfg.APIKey),
|
|
||||||
string(exchangeCfg.SecretKey),
|
|
||||||
)
|
|
||||||
case "kucoin":
|
|
||||||
tempTrader = kucoin.NewKuCoinTrader(
|
|
||||||
string(exchangeCfg.APIKey),
|
|
||||||
string(exchangeCfg.SecretKey),
|
|
||||||
string(exchangeCfg.Passphrase),
|
|
||||||
)
|
|
||||||
case "lighter":
|
|
||||||
if exchangeCfg.LighterWalletAddr != "" && string(exchangeCfg.LighterAPIKeyPrivateKey) != "" {
|
|
||||||
// Lighter only supports mainnet
|
|
||||||
tempTrader, createErr = lighter.NewLighterTraderV2(
|
|
||||||
exchangeCfg.LighterWalletAddr,
|
|
||||||
string(exchangeCfg.LighterAPIKeyPrivateKey),
|
|
||||||
exchangeCfg.LighterAPIKeyIndex,
|
|
||||||
false, // Always use mainnet for Lighter
|
|
||||||
)
|
|
||||||
} else {
|
|
||||||
createErr = fmt.Errorf("Lighter requires wallet address and API Key private key")
|
|
||||||
}
|
|
||||||
default:
|
|
||||||
c.JSON(http.StatusBadRequest, gin.H{"error": "Unsupported exchange type"})
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
if createErr != nil {
|
|
||||||
logger.Infof("⚠️ Failed to create temporary trader: %v", createErr)
|
|
||||||
SafeInternalError(c, "Failed to connect to exchange", createErr)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
// Get current position info BEFORE closing (to get quantity and price)
|
|
||||||
positions, err := tempTrader.GetPositions()
|
|
||||||
if err != nil {
|
|
||||||
logger.Infof("⚠️ Failed to get positions: %v", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
var posQty float64
|
|
||||||
var entryPrice float64
|
|
||||||
for _, pos := range positions {
|
|
||||||
if pos["symbol"] == req.Symbol && pos["side"] == strings.ToLower(req.Side) {
|
|
||||||
if amt, ok := pos["positionAmt"].(float64); ok {
|
|
||||||
posQty = amt
|
|
||||||
if posQty < 0 {
|
|
||||||
posQty = -posQty // Make positive
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if price, ok := pos["entryPrice"].(float64); ok {
|
|
||||||
entryPrice = price
|
|
||||||
}
|
|
||||||
break
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Execute close position operation
|
|
||||||
var result map[string]interface{}
|
|
||||||
var closeErr error
|
|
||||||
|
|
||||||
if req.Side == "LONG" {
|
|
||||||
result, closeErr = tempTrader.CloseLong(req.Symbol, 0) // 0 means close all
|
|
||||||
} else if req.Side == "SHORT" {
|
|
||||||
result, closeErr = tempTrader.CloseShort(req.Symbol, 0) // 0 means close all
|
|
||||||
} else {
|
|
||||||
c.JSON(http.StatusBadRequest, gin.H{"error": "side must be LONG or SHORT"})
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
if closeErr != nil {
|
|
||||||
logger.Infof("❌ Close position failed: symbol=%s, side=%s, error=%v", req.Symbol, req.Side, closeErr)
|
|
||||||
SafeInternalError(c, "Close position", closeErr)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
logger.Infof("✅ Position closed successfully: symbol=%s, side=%s, qty=%.6f, result=%v", req.Symbol, req.Side, posQty, result)
|
|
||||||
|
|
||||||
// Backfill the just-closed fill immediately. Manual closes may happen while
|
|
||||||
// the bot runtime is stopped, so the background OrderSync loop is not enough.
|
|
||||||
if syncErr := s.syncOrdersAfterManualClose(tempTrader, traderID, exchangeCfg.ID, exchangeCfg.ExchangeType); syncErr != nil {
|
|
||||||
logger.Infof(" ⚠️ Manual close sync failed: %v", syncErr)
|
|
||||||
s.recordClosePositionOrder(traderID, exchangeCfg.ID, exchangeCfg.ExchangeType, req.Symbol, req.Side, posQty, entryPrice, result)
|
|
||||||
}
|
|
||||||
|
|
||||||
c.JSON(http.StatusOK, gin.H{
|
|
||||||
"message": "Position closed successfully",
|
|
||||||
"symbol": req.Symbol,
|
|
||||||
"side": req.Side,
|
|
||||||
"result": result,
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
func (s *Server) syncOrdersFromExchange(exchangeTrader trader.Trader, traderID, exchangeID, exchangeType string) error {
|
|
||||||
switch t := exchangeTrader.(type) {
|
|
||||||
case *binance.FuturesTrader:
|
|
||||||
return t.SyncOrdersFromBinance(traderID, exchangeID, exchangeType, s.store)
|
|
||||||
case *hyperliquidtrader.HyperliquidTrader:
|
|
||||||
return t.SyncOrdersFromHyperliquid(traderID, exchangeID, exchangeType, s.store)
|
|
||||||
case *aster.AsterTrader:
|
|
||||||
return t.SyncOrdersFromAster(traderID, exchangeID, exchangeType, s.store)
|
|
||||||
case *bybit.BybitTrader:
|
|
||||||
return t.SyncOrdersFromBybit(traderID, exchangeID, exchangeType, s.store)
|
|
||||||
case *okx.OKXTrader:
|
|
||||||
return t.SyncOrdersFromOKX(traderID, exchangeID, exchangeType, s.store)
|
|
||||||
case *bitget.BitgetTrader:
|
|
||||||
return t.SyncOrdersFromBitget(traderID, exchangeID, exchangeType, s.store)
|
|
||||||
case *gate.GateTrader:
|
|
||||||
return t.SyncOrdersFromGate(traderID, exchangeID, exchangeType, s.store)
|
|
||||||
case *kucoin.KuCoinTrader:
|
|
||||||
return t.SyncOrdersFromKuCoin(traderID, exchangeID, exchangeType, s.store)
|
|
||||||
case *lighter.LighterTraderV2:
|
|
||||||
return t.SyncOrdersFromLighter(traderID, exchangeID, exchangeType, s.store)
|
|
||||||
default:
|
|
||||||
return fmt.Errorf("order sync is not available for exchange type %s", exchangeType)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func (s *Server) syncOrdersAfterManualClose(exchangeTrader trader.Trader, traderID, exchangeID, exchangeType string) error {
|
|
||||||
var lastErr error
|
|
||||||
for attempt := 1; attempt <= 4; attempt++ {
|
|
||||||
if attempt > 1 {
|
|
||||||
time.Sleep(time.Duration(attempt-1) * 500 * time.Millisecond)
|
|
||||||
}
|
|
||||||
if err := s.syncOrdersFromExchange(exchangeTrader, traderID, exchangeID, exchangeType); err != nil {
|
|
||||||
lastErr = err
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
if lastErr != nil {
|
|
||||||
return lastErr
|
|
||||||
}
|
|
||||||
return fmt.Errorf("manual close sync did not run")
|
|
||||||
}
|
|
||||||
|
|
||||||
// recordClosePositionOrder Record close position order to database (Lighter version - direct FILLED status)
|
|
||||||
func (s *Server) recordClosePositionOrder(traderID, exchangeID, exchangeType, symbol, side string, quantity, exitPrice float64, result map[string]interface{}) {
|
|
||||||
// Skip for exchanges with OrderSync - let the background sync handle it to avoid duplicates
|
|
||||||
switch exchangeType {
|
|
||||||
case "binance", "lighter", "hyperliquid", "bybit", "okx", "bitget", "aster", "gate":
|
|
||||||
logger.Infof(" 📝 Close order will be synced by OrderSync, skipping immediate record")
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
// Check if order was placed (skip if NO_POSITION)
|
|
||||||
status, _ := result["status"].(string)
|
|
||||||
if status == "NO_POSITION" {
|
|
||||||
logger.Infof(" ⚠️ No position to close, skipping order record")
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
// Get order ID from result
|
|
||||||
var orderID string
|
|
||||||
switch v := result["orderId"].(type) {
|
|
||||||
case int64:
|
|
||||||
orderID = fmt.Sprintf("%d", v)
|
|
||||||
case float64:
|
|
||||||
orderID = fmt.Sprintf("%.0f", v)
|
|
||||||
case string:
|
|
||||||
orderID = v
|
|
||||||
default:
|
|
||||||
orderID = fmt.Sprintf("%v", v)
|
|
||||||
}
|
|
||||||
|
|
||||||
if orderID == "" || orderID == "0" {
|
|
||||||
logger.Infof(" ⚠️ Order ID is empty, skipping record")
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
// Determine order action based on side
|
|
||||||
var orderAction string
|
|
||||||
if side == "LONG" {
|
|
||||||
orderAction = "close_long"
|
|
||||||
} else {
|
|
||||||
orderAction = "close_short"
|
|
||||||
}
|
|
||||||
|
|
||||||
// Use entry price if exit price not available
|
|
||||||
if exitPrice == 0 {
|
|
||||||
exitPrice = quantity * 100 // Rough estimate if we don't have price
|
|
||||||
}
|
|
||||||
|
|
||||||
// Estimate fee (0.04% for Lighter taker)
|
|
||||||
fee := exitPrice * quantity * 0.0004
|
|
||||||
|
|
||||||
// Create order record - DIRECTLY as FILLED (Lighter market orders fill immediately)
|
|
||||||
orderRecord := &store.TraderOrder{
|
|
||||||
TraderID: traderID,
|
|
||||||
ExchangeID: exchangeID,
|
|
||||||
ExchangeType: exchangeType,
|
|
||||||
ExchangeOrderID: orderID,
|
|
||||||
Symbol: symbol,
|
|
||||||
PositionSide: side,
|
|
||||||
OrderAction: orderAction,
|
|
||||||
Type: "MARKET",
|
|
||||||
Side: getSideFromAction(orderAction),
|
|
||||||
Quantity: quantity,
|
|
||||||
Price: 0, // Market order
|
|
||||||
Status: "FILLED",
|
|
||||||
FilledQuantity: quantity,
|
|
||||||
AvgFillPrice: exitPrice,
|
|
||||||
Commission: fee,
|
|
||||||
FilledAt: time.Now().UTC().UnixMilli(),
|
|
||||||
CreatedAt: time.Now().UTC().UnixMilli(),
|
|
||||||
UpdatedAt: time.Now().UTC().UnixMilli(),
|
|
||||||
}
|
|
||||||
|
|
||||||
if err := s.store.Order().CreateOrder(orderRecord); err != nil {
|
|
||||||
logger.Infof(" ⚠️ Failed to record order: %v", err)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
logger.Infof(" ✅ Order recorded as FILLED: %s [%s] %s qty=%.6f price=%.6f", orderID, orderAction, symbol, quantity, exitPrice)
|
|
||||||
|
|
||||||
// Create fill record immediately
|
|
||||||
tradeID := fmt.Sprintf("%s-%d", orderID, time.Now().UnixNano())
|
|
||||||
fillRecord := &store.TraderFill{
|
|
||||||
TraderID: traderID,
|
|
||||||
ExchangeID: exchangeID,
|
|
||||||
ExchangeType: exchangeType,
|
|
||||||
OrderID: orderRecord.ID,
|
|
||||||
ExchangeOrderID: orderID,
|
|
||||||
ExchangeTradeID: tradeID,
|
|
||||||
Symbol: symbol,
|
|
||||||
Side: getSideFromAction(orderAction),
|
|
||||||
Price: exitPrice,
|
|
||||||
Quantity: quantity,
|
|
||||||
QuoteQuantity: exitPrice * quantity,
|
|
||||||
Commission: fee,
|
|
||||||
CommissionAsset: "USDT",
|
|
||||||
RealizedPnL: 0,
|
|
||||||
IsMaker: false,
|
|
||||||
CreatedAt: time.Now().UTC().UnixMilli(),
|
|
||||||
}
|
|
||||||
|
|
||||||
if err := s.store.Order().CreateFill(fillRecord); err != nil {
|
|
||||||
logger.Infof(" ⚠️ Failed to record fill: %v", err)
|
|
||||||
} else {
|
|
||||||
logger.Infof(" ✅ Fill record created: price=%.6f qty=%.6f", exitPrice, quantity)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// pollAndUpdateOrderStatus Poll order status and update with fill data
|
|
||||||
func (s *Server) pollAndUpdateOrderStatus(orderRecordID int64, traderID, exchangeID, exchangeType, orderID, symbol, orderAction string, tempTrader trader.Trader) {
|
|
||||||
var actualPrice float64
|
|
||||||
var actualQty float64
|
|
||||||
var fee float64
|
|
||||||
|
|
||||||
// Wait a bit for order to be filled
|
|
||||||
time.Sleep(500 * time.Millisecond)
|
|
||||||
|
|
||||||
// For Lighter, use GetTrades instead of GetOrderStatus (market orders are filled immediately)
|
|
||||||
if exchangeType == "lighter" {
|
|
||||||
s.pollLighterTradeHistory(orderRecordID, traderID, exchangeID, exchangeType, orderID, symbol, orderAction, tempTrader)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
// For other exchanges, poll GetOrderStatus
|
|
||||||
for i := 0; i < 5; i++ {
|
|
||||||
status, err := tempTrader.GetOrderStatus(symbol, orderID)
|
|
||||||
if err != nil {
|
|
||||||
logger.Infof(" ⚠️ GetOrderStatus failed (attempt %d/5): %v", i+1, err)
|
|
||||||
time.Sleep(500 * time.Millisecond)
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
if err == nil {
|
|
||||||
statusStr, _ := status["status"].(string)
|
|
||||||
if statusStr == "FILLED" {
|
|
||||||
// Get actual fill price
|
|
||||||
if avgPrice, ok := status["avgPrice"].(float64); ok && avgPrice > 0 {
|
|
||||||
actualPrice = avgPrice
|
|
||||||
}
|
|
||||||
// Get actual executed quantity
|
|
||||||
if execQty, ok := status["executedQty"].(float64); ok && execQty > 0 {
|
|
||||||
actualQty = execQty
|
|
||||||
}
|
|
||||||
// Get commission/fee
|
|
||||||
if commission, ok := status["commission"].(float64); ok {
|
|
||||||
fee = commission
|
|
||||||
}
|
|
||||||
|
|
||||||
logger.Infof(" ✅ Order filled: avgPrice=%.6f, qty=%.6f, fee=%.6f", actualPrice, actualQty, fee)
|
|
||||||
|
|
||||||
// Update order status to FILLED
|
|
||||||
if err := s.store.Order().UpdateOrderStatus(orderRecordID, "FILLED", actualQty, actualPrice, fee); err != nil {
|
|
||||||
logger.Infof(" ⚠️ Failed to update order status: %v", err)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
// Record fill details
|
|
||||||
tradeID := fmt.Sprintf("%s-%d", orderID, time.Now().UnixNano())
|
|
||||||
fillRecord := &store.TraderFill{
|
|
||||||
TraderID: traderID,
|
|
||||||
ExchangeID: exchangeID,
|
|
||||||
ExchangeType: exchangeType,
|
|
||||||
OrderID: orderRecordID,
|
|
||||||
ExchangeOrderID: orderID,
|
|
||||||
ExchangeTradeID: tradeID,
|
|
||||||
Symbol: symbol,
|
|
||||||
Side: getSideFromAction(orderAction),
|
|
||||||
Price: actualPrice,
|
|
||||||
Quantity: actualQty,
|
|
||||||
QuoteQuantity: actualPrice * actualQty,
|
|
||||||
Commission: fee,
|
|
||||||
CommissionAsset: "USDT",
|
|
||||||
RealizedPnL: 0,
|
|
||||||
IsMaker: false,
|
|
||||||
CreatedAt: time.Now().UTC().UnixMilli(),
|
|
||||||
}
|
|
||||||
|
|
||||||
if err := s.store.Order().CreateFill(fillRecord); err != nil {
|
|
||||||
logger.Infof(" ⚠️ Failed to record fill: %v", err)
|
|
||||||
} else {
|
|
||||||
logger.Infof(" 📝 Fill recorded: price=%.6f, qty=%.6f", actualPrice, actualQty)
|
|
||||||
}
|
|
||||||
|
|
||||||
return
|
|
||||||
} else if statusStr == "CANCELED" || statusStr == "EXPIRED" || statusStr == "REJECTED" {
|
|
||||||
logger.Infof(" ⚠️ Order %s, updating status", statusStr)
|
|
||||||
s.store.Order().UpdateOrderStatus(orderRecordID, statusStr, 0, 0, 0)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
}
|
|
||||||
time.Sleep(500 * time.Millisecond)
|
|
||||||
}
|
|
||||||
|
|
||||||
logger.Infof(" ⚠️ Failed to confirm order fill after polling, order may still be pending")
|
|
||||||
}
|
|
||||||
|
|
||||||
// pollLighterTradeHistory No longer used - Lighter orders are marked as FILLED immediately
|
|
||||||
// Keeping this function stub for compatibility with other exchanges
|
|
||||||
func (s *Server) pollLighterTradeHistory(orderRecordID int64, traderID, exchangeID, exchangeType, orderID, symbol, orderAction string, tempTrader trader.Trader) {
|
|
||||||
// For Lighter, orders are now recorded as FILLED immediately in recordClosePositionOrder
|
|
||||||
// This function is no longer called for Lighter exchange
|
|
||||||
logger.Infof(" ℹ️ pollLighterTradeHistory called but not needed (order already marked FILLED)")
|
|
||||||
}
|
|
||||||
|
|
||||||
// getSideFromAction Get order side (BUY/SELL) from order action
|
|
||||||
func getSideFromAction(action string) string {
|
|
||||||
switch action {
|
|
||||||
case "open_long", "close_short":
|
|
||||||
return "BUY"
|
|
||||||
case "open_short", "close_long":
|
|
||||||
return "SELL"
|
|
||||||
default:
|
|
||||||
return "BUY"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,28 +0,0 @@
|
|||||||
package api
|
|
||||||
|
|
||||||
import "testing"
|
|
||||||
|
|
||||||
func TestIsSupportedTraderSymbol(t *testing.T) {
|
|
||||||
tests := []struct {
|
|
||||||
name string
|
|
||||||
symbol string
|
|
||||||
want bool
|
|
||||||
}{
|
|
||||||
{name: "legacy USDT perp", symbol: "BTCUSDT", want: true},
|
|
||||||
{name: "legacy USDT perp lowercase", symbol: "ethusdt", want: true},
|
|
||||||
{name: "Hyperliquid xyz stock USDC pair", symbol: "SMSN-USDC", want: true},
|
|
||||||
{name: "Hyperliquid xyz commodity USDC pair", symbol: "GOLD-USDC", want: true},
|
|
||||||
{name: "legacy internal xyz prefix still accepted", symbol: "xyz:SMSN", want: true},
|
|
||||||
{name: "empty slot ignored", symbol: " ", want: true},
|
|
||||||
{name: "bare stock without xyz prefix rejected", symbol: "SMSN", want: false},
|
|
||||||
{name: "unknown non-USDT pair rejected", symbol: "BTCUSD", want: false},
|
|
||||||
}
|
|
||||||
|
|
||||||
for _, tt := range tests {
|
|
||||||
t.Run(tt.name, func(t *testing.T) {
|
|
||||||
if got := isSupportedTraderSymbol(tt.symbol); got != tt.want {
|
|
||||||
t.Fatalf("isSupportedTraderSymbol(%q) = %v, want %v", tt.symbol, got, tt.want)
|
|
||||||
}
|
|
||||||
})
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,17 +0,0 @@
|
|||||||
package api
|
|
||||||
|
|
||||||
import "testing"
|
|
||||||
|
|
||||||
func TestValidateTraderLeverageRangeMatchesManualLimits(t *testing.T) {
|
|
||||||
if msg, code := validateTraderLeverageRange(20, 20); msg != "" || code != "" {
|
|
||||||
t.Fatalf("expected 20/20 leverage to be accepted, got msg=%q code=%q", msg, code)
|
|
||||||
}
|
|
||||||
|
|
||||||
if msg, code := validateTraderLeverageRange(21, 20); msg == "" || code != "trader.create.invalid_btc_eth_leverage" {
|
|
||||||
t.Fatalf("expected BTC/ETH leverage > 20 to be rejected, got msg=%q code=%q", msg, code)
|
|
||||||
}
|
|
||||||
|
|
||||||
if msg, code := validateTraderLeverageRange(20, 21); msg == "" || code != "trader.create.invalid_altcoin_leverage" {
|
|
||||||
t.Fatalf("expected altcoin leverage > 20 to be rejected, got msg=%q code=%q", msg, code)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,370 +0,0 @@
|
|||||||
package api
|
|
||||||
|
|
||||||
import (
|
|
||||||
"fmt"
|
|
||||||
"net/http"
|
|
||||||
"strings"
|
|
||||||
"time"
|
|
||||||
|
|
||||||
"nofx/auth"
|
|
||||||
"nofx/logger"
|
|
||||||
"nofx/store"
|
|
||||||
|
|
||||||
"github.com/gin-gonic/gin"
|
|
||||||
"github.com/google/uuid"
|
|
||||||
"gorm.io/gorm"
|
|
||||||
)
|
|
||||||
|
|
||||||
// handleLogout Add current token to blacklist
|
|
||||||
func (s *Server) handleLogout(c *gin.Context) {
|
|
||||||
authHeader := c.GetHeader("Authorization")
|
|
||||||
if authHeader == "" {
|
|
||||||
c.JSON(http.StatusUnauthorized, gin.H{"error": "Missing Authorization header"})
|
|
||||||
return
|
|
||||||
}
|
|
||||||
parts := strings.Split(authHeader, " ")
|
|
||||||
if len(parts) != 2 || parts[0] != "Bearer" {
|
|
||||||
c.JSON(http.StatusUnauthorized, gin.H{"error": "Invalid Authorization format"})
|
|
||||||
return
|
|
||||||
}
|
|
||||||
tokenString := parts[1]
|
|
||||||
claims, err := auth.ValidateJWT(tokenString)
|
|
||||||
if err != nil {
|
|
||||||
c.JSON(http.StatusUnauthorized, gin.H{"error": "Invalid token"})
|
|
||||||
return
|
|
||||||
}
|
|
||||||
var exp time.Time
|
|
||||||
if claims.ExpiresAt != nil {
|
|
||||||
exp = claims.ExpiresAt.Time
|
|
||||||
} else {
|
|
||||||
exp = time.Now().Add(24 * time.Hour)
|
|
||||||
}
|
|
||||||
auth.BlacklistToken(tokenString, exp)
|
|
||||||
c.JSON(http.StatusOK, gin.H{"message": "Logged out"})
|
|
||||||
}
|
|
||||||
|
|
||||||
// handleRegister Handle user registration request.
|
|
||||||
// handleRegister allows registration only when no users exist yet (first-time setup).
|
|
||||||
// This is a single-user system; subsequent registrations are permanently closed.
|
|
||||||
func (s *Server) handleRegister(c *gin.Context) {
|
|
||||||
userCount, err := s.store.User().Count()
|
|
||||||
if err != nil {
|
|
||||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to check user count"})
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
if userCount > 0 {
|
|
||||||
c.JSON(http.StatusForbidden, gin.H{"error": "System already initialized"})
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
var req struct {
|
|
||||||
Email string `json:"email" binding:"required,email"`
|
|
||||||
Password string `json:"password" binding:"required,min=8"`
|
|
||||||
Lang string `json:"lang"`
|
|
||||||
}
|
|
||||||
|
|
||||||
if err := c.ShouldBindJSON(&req); err != nil {
|
|
||||||
SafeBadRequest(c, "Invalid request parameters")
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
lang := req.Lang
|
|
||||||
if lang != "zh" && lang != "id" {
|
|
||||||
lang = "en"
|
|
||||||
}
|
|
||||||
|
|
||||||
// Check if email already exists
|
|
||||||
_, err = s.store.User().GetByEmail(req.Email)
|
|
||||||
if err == nil {
|
|
||||||
c.JSON(http.StatusConflict, gin.H{"error": "Email already registered"})
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
// Generate password hash
|
|
||||||
passwordHash, err := auth.HashPassword(req.Password)
|
|
||||||
if err != nil {
|
|
||||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "Password processing failed"})
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
// Create user
|
|
||||||
userID := uuid.New().String()
|
|
||||||
user := &store.User{
|
|
||||||
ID: userID,
|
|
||||||
Email: req.Email,
|
|
||||||
PasswordHash: passwordHash,
|
|
||||||
}
|
|
||||||
|
|
||||||
err = s.store.User().Create(user)
|
|
||||||
if err != nil {
|
|
||||||
SafeInternalError(c, "Failed to create user", err)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
// NOTE: Orphan record adoption was removed for security reasons. Previously,
|
|
||||||
// after a reset-account call, any new user would inherit the prior owner's
|
|
||||||
// wallet keys and exchange API credentials — a catastrophic IDOR/takeover
|
|
||||||
// path. Operators who need to migrate credentials across users must do so
|
|
||||||
// explicitly via export/import, never via implicit adoption on registration.
|
|
||||||
|
|
||||||
// Generate JWT token
|
|
||||||
token, err := auth.GenerateJWT(user.ID, user.Email)
|
|
||||||
if err != nil {
|
|
||||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to generate token"})
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
// Initialize default model and exchange configs for user
|
|
||||||
err = s.initUserDefaultConfigs(user.ID, lang)
|
|
||||||
if err != nil {
|
|
||||||
logger.Infof("Failed to initialize user default configs: %v", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
c.JSON(http.StatusOK, gin.H{
|
|
||||||
"token": token,
|
|
||||||
"user_id": user.ID,
|
|
||||||
"email": user.Email,
|
|
||||||
"message": "Registration successful",
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
// dummyPasswordHash is a valid bcrypt hash of a throwaway value. It is compared
|
|
||||||
// against when the submitted email does not exist so that login takes roughly
|
|
||||||
// the same time whether or not the account exists — closing the timing side
|
|
||||||
// channel that would otherwise let an attacker enumerate valid emails (a fast
|
|
||||||
// "no such user" vs. a slow bcrypt compare). It is not a secret.
|
|
||||||
const dummyPasswordHash = "$2a$10$0iF0bCoQLJ6Ph1bF.MXwHOW.IMTxQjeEW.w38dctRQAB2kwB6ga1q"
|
|
||||||
|
|
||||||
// handleLogin Handle user login request
|
|
||||||
func (s *Server) handleLogin(c *gin.Context) {
|
|
||||||
var req struct {
|
|
||||||
Email string `json:"email" binding:"required,email"`
|
|
||||||
Password string `json:"password" binding:"required"`
|
|
||||||
}
|
|
||||||
|
|
||||||
if err := c.ShouldBindJSON(&req); err != nil {
|
|
||||||
SafeBadRequest(c, "Invalid request parameters")
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
// Get user information
|
|
||||||
user, err := s.store.User().GetByEmail(req.Email)
|
|
||||||
if err != nil {
|
|
||||||
// Perform a dummy comparison so the response time does not reveal
|
|
||||||
// whether the email exists (anti user-enumeration), then fail uniformly.
|
|
||||||
auth.CheckPassword(req.Password, dummyPasswordHash)
|
|
||||||
c.JSON(http.StatusUnauthorized, gin.H{"error": "Email or password incorrect"})
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
// Verify password
|
|
||||||
if !auth.CheckPassword(req.Password, user.PasswordHash) {
|
|
||||||
c.JSON(http.StatusUnauthorized, gin.H{"error": "Email or password incorrect"})
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
// Issue token directly after password verification.
|
|
||||||
token, err := auth.GenerateJWT(user.ID, user.Email)
|
|
||||||
if err != nil {
|
|
||||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to generate token"})
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
c.JSON(http.StatusOK, gin.H{
|
|
||||||
"token": token,
|
|
||||||
"user_id": user.ID,
|
|
||||||
"email": user.Email,
|
|
||||||
"message": "Login successful",
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
// handleChangePassword changes the password for the currently authenticated user.
|
|
||||||
func (s *Server) handleChangePassword(c *gin.Context) {
|
|
||||||
userID := c.GetString("user_id")
|
|
||||||
var req struct {
|
|
||||||
NewPassword string `json:"new_password" binding:"required,min=8"`
|
|
||||||
}
|
|
||||||
if err := c.ShouldBindJSON(&req); err != nil {
|
|
||||||
SafeBadRequest(c, "new_password is required (min 8 chars)")
|
|
||||||
return
|
|
||||||
}
|
|
||||||
hash, err := auth.HashPassword(req.NewPassword)
|
|
||||||
if err != nil {
|
|
||||||
SafeInternalError(c, "Password processing failed", err)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
if err := s.store.User().UpdatePassword(userID, hash); err != nil {
|
|
||||||
SafeInternalError(c, "Failed to update password", err)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
c.JSON(http.StatusOK, gin.H{"message": "Password updated"})
|
|
||||||
}
|
|
||||||
|
|
||||||
// NOTE: Password and account recovery used to live here as the public,
|
|
||||||
// unauthenticated handlers handleResetPassword / handleResetAccount. They were
|
|
||||||
// removed because an unauthenticated recovery endpoint is a remotely
|
|
||||||
// exploitable auth-bypass on any public-facing deployment: the confirm phrase
|
|
||||||
// was embedded in the frontend (and echoed back by the API), so it was friction
|
|
||||||
// rather than authentication. Recovery now lives in the local CLI
|
|
||||||
// (`nofx reset-password` / `nofx reset-account`, see cli.go), which requires
|
|
||||||
// shell access to the host — something a remote attacker does not have.
|
|
||||||
|
|
||||||
// initUserDefaultConfigs Initialize default configs for new user
|
|
||||||
func (s *Server) initUserDefaultConfigs(userID string, lang string) error {
|
|
||||||
if err := s.createDefaultStrategies(userID, lang); err != nil {
|
|
||||||
logger.Warnf("Failed to create default strategies for user %s: %v", userID, err)
|
|
||||||
// Non-fatal: user can create strategies manually
|
|
||||||
}
|
|
||||||
logger.Infof("✓ User %s registration completed with default strategies", userID)
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func (s *Server) createDefaultStrategies(userID string, lang string) error {
|
|
||||||
type strategyI18n struct {
|
|
||||||
name, description string
|
|
||||||
}
|
|
||||||
type strategyLocale struct {
|
|
||||||
defaultStrategy strategyI18n
|
|
||||||
}
|
|
||||||
locales := map[string]strategyLocale{
|
|
||||||
"zh": {
|
|
||||||
defaultStrategy: strategyI18n{"NOFX Claw402 Auto Strategy", "The only built-in strategy: read the Claw402.ai board each cycle, fetch Signal Lab and cost/liquidation heatmap per candidate, then decide with raw candles."},
|
|
||||||
},
|
|
||||||
"en": {
|
|
||||||
defaultStrategy: strategyI18n{"NOFX Claw402 Auto Strategy", "The only built-in strategy: read the Claw402.ai board each cycle, fetch Signal Lab and cost/liquidation heatmap per candidate, then decide with raw candles."},
|
|
||||||
},
|
|
||||||
"id": {
|
|
||||||
defaultStrategy: strategyI18n{"Strategi Otomatis NOFX Claw402", "Satu strategi bawaan: membaca papan Claw402.ai, mengambil Signal Lab dan heatmap biaya/likuidasi per kandidat, lalu memutuskan dengan candle mentah."},
|
|
||||||
},
|
|
||||||
}
|
|
||||||
locale, ok := locales[lang]
|
|
||||||
if !ok {
|
|
||||||
locale = locales["en"]
|
|
||||||
}
|
|
||||||
|
|
||||||
type strategyDef struct {
|
|
||||||
name string
|
|
||||||
description string
|
|
||||||
isActive bool
|
|
||||||
applyConfig func(*store.StrategyConfig)
|
|
||||||
}
|
|
||||||
|
|
||||||
setClaw402Strategy := func(c *store.StrategyConfig) {
|
|
||||||
c.CoinSource.SourceType = "vergex_signal"
|
|
||||||
c.CoinSource.StaticCoins = nil
|
|
||||||
c.CoinSource.UseAI500 = false
|
|
||||||
c.CoinSource.UseOITop = false
|
|
||||||
c.CoinSource.UseOILow = false
|
|
||||||
c.CoinSource.UseHyperAll = false
|
|
||||||
c.CoinSource.UseHyperMain = false
|
|
||||||
c.CoinSource.HyperRankCategory = "all"
|
|
||||||
c.CoinSource.VergexLimit = 10
|
|
||||||
c.CoinSource.VergexMarketType = "all"
|
|
||||||
c.CoinSource.VergexChain = "hyperliquid"
|
|
||||||
c.RiskControl.MaxPositions = 2
|
|
||||||
c.RiskControl.BTCETHMaxLeverage = 10
|
|
||||||
c.RiskControl.AltcoinMaxLeverage = 10
|
|
||||||
// Few, concentrated positions held for big moves. 10x leverage keeps a
|
|
||||||
// wide (-5%) stop survivable (~-50% margin, ~10% liquidation cushion);
|
|
||||||
// 2 positions × 5x = 10x total notional (full margin, doubled exposure).
|
|
||||||
c.RiskControl.BTCETHMaxPositionValueRatio = 5.0
|
|
||||||
c.RiskControl.AltcoinMaxPositionValueRatio = 5.0
|
|
||||||
c.RiskControl.MaxMarginUsage = 1.0
|
|
||||||
c.RiskControl.MinConfidence = 78
|
|
||||||
c.RiskControl.MinRiskRewardRatio = 3.0
|
|
||||||
c.Indicators.Klines.PrimaryTimeframe = "15m"
|
|
||||||
c.Indicators.Klines.PrimaryCount = 30
|
|
||||||
c.Indicators.Klines.LongerTimeframe = ""
|
|
||||||
c.Indicators.Klines.LongerCount = 0
|
|
||||||
c.Indicators.Klines.EnableMultiTimeframe = false
|
|
||||||
c.Indicators.Klines.SelectedTimeframes = []string{"15m"}
|
|
||||||
c.Indicators.EnableRawKlines = true
|
|
||||||
}
|
|
||||||
|
|
||||||
definitions := []strategyDef{
|
|
||||||
{
|
|
||||||
name: locale.defaultStrategy.name,
|
|
||||||
description: locale.defaultStrategy.description,
|
|
||||||
isActive: true,
|
|
||||||
applyConfig: func(c *store.StrategyConfig) {
|
|
||||||
setClaw402Strategy(c)
|
|
||||||
},
|
|
||||||
},
|
|
||||||
}
|
|
||||||
|
|
||||||
// GetDefaultStrategyConfig only supports zh/en; map id -> en
|
|
||||||
configLang := lang
|
|
||||||
if lang == "id" {
|
|
||||||
configLang = "en"
|
|
||||||
}
|
|
||||||
|
|
||||||
// Pre-build all strategy objects before opening the transaction
|
|
||||||
var strategies []*store.Strategy
|
|
||||||
for _, def := range definitions {
|
|
||||||
config := store.GetDefaultStrategyConfig(configLang)
|
|
||||||
def.applyConfig(&config)
|
|
||||||
config.ClampLimits()
|
|
||||||
|
|
||||||
strategy := &store.Strategy{
|
|
||||||
ID: uuid.New().String(),
|
|
||||||
UserID: userID,
|
|
||||||
Name: def.name,
|
|
||||||
Description: def.description,
|
|
||||||
IsActive: def.isActive,
|
|
||||||
IsDefault: false,
|
|
||||||
}
|
|
||||||
if err := strategy.SetConfig(&config); err != nil {
|
|
||||||
return fmt.Errorf("failed to set config for strategy %q: %w", def.name, err)
|
|
||||||
}
|
|
||||||
strategies = append(strategies, strategy)
|
|
||||||
}
|
|
||||||
|
|
||||||
legacyDefaultNames := []string{
|
|
||||||
"Balanced Strategy", "Steady Strategy", "Aggressive Strategy",
|
|
||||||
"US Stock Trend Strategy", "US Stock Steady Strategy", "US Stock Breakout Strategy",
|
|
||||||
"Balanced Strategy", "Conservative Strategy", "Aggressive Strategy",
|
|
||||||
"US Stock Trend Strategy", "US Stock Steady Strategy", "US Stock Breakout Strategy",
|
|
||||||
"Strategi Seimbang", "Strategi Konservatif", "Strategi Agresif",
|
|
||||||
"Strategi Tren Saham AS", "Strategi Stabil Saham AS", "Strategi Breakout Saham AS",
|
|
||||||
}
|
|
||||||
|
|
||||||
return s.store.Transaction(func(tx *gorm.DB) error {
|
|
||||||
// Remove obsolete built-in risk-profile presets for this user. If a trader still
|
|
||||||
// references one of them, keep it to avoid breaking an existing running setup.
|
|
||||||
deleteResult := tx.Where("user_id = ? AND name IN ? AND id NOT IN (SELECT strategy_id FROM traders WHERE user_id = ? AND strategy_id IS NOT NULL)", userID, legacyDefaultNames, userID).
|
|
||||||
Delete(&store.Strategy{})
|
|
||||||
if deleteResult.Error != nil {
|
|
||||||
return fmt.Errorf("failed to remove legacy default strategies: %w", deleteResult.Error)
|
|
||||||
}
|
|
||||||
if deleteResult.RowsAffected > 0 {
|
|
||||||
logger.Infof(" ✓ Removed %d legacy default strategy preset(s)", deleteResult.RowsAffected)
|
|
||||||
}
|
|
||||||
|
|
||||||
var activeCount int64
|
|
||||||
if err := tx.Model(&store.Strategy{}).Where("user_id = ? AND is_active = ?", userID, true).Count(&activeCount).Error; err != nil {
|
|
||||||
return fmt.Errorf("failed to count active strategies: %w", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
for _, strategy := range strategies {
|
|
||||||
var existing int64
|
|
||||||
if err := tx.Model(&store.Strategy{}).Where("user_id = ? AND name = ?", userID, strategy.Name).Count(&existing).Error; err != nil {
|
|
||||||
return fmt.Errorf("failed to check strategy %q: %w", strategy.Name, err)
|
|
||||||
}
|
|
||||||
if existing > 0 {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
if activeCount > 0 {
|
|
||||||
strategy.IsActive = false
|
|
||||||
}
|
|
||||||
if err := tx.Create(strategy).Error; err != nil {
|
|
||||||
return fmt.Errorf("failed to create strategy %q: %w", strategy.Name, err)
|
|
||||||
}
|
|
||||||
if strategy.IsActive {
|
|
||||||
activeCount++
|
|
||||||
}
|
|
||||||
logger.Infof(" ✓ Created default strategy: %s (active=%v)", strategy.Name, strategy.IsActive)
|
|
||||||
}
|
|
||||||
return nil
|
|
||||||
})
|
|
||||||
}
|
|
||||||
@@ -1,136 +0,0 @@
|
|||||||
package api
|
|
||||||
|
|
||||||
import (
|
|
||||||
"testing"
|
|
||||||
|
|
||||||
"github.com/google/uuid"
|
|
||||||
"nofx/store"
|
|
||||||
)
|
|
||||||
|
|
||||||
func TestCreateDefaultStrategiesUsesOneReadyToRunClaw402Preset(t *testing.T) {
|
|
||||||
st, err := store.New(t.TempDir() + "/nofx.db")
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("store.New failed: %v", err)
|
|
||||||
}
|
|
||||||
t.Cleanup(func() { _ = st.Close() })
|
|
||||||
|
|
||||||
s := &Server{store: st}
|
|
||||||
userID := "user-us-stock-presets"
|
|
||||||
if err := s.createDefaultStrategies(userID, "zh"); err != nil {
|
|
||||||
t.Fatalf("createDefaultStrategies failed: %v", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
strategies, err := st.Strategy().List(userID)
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("List strategies failed: %v", err)
|
|
||||||
}
|
|
||||||
if len(strategies) != 1 {
|
|
||||||
t.Fatalf("expected 1 default strategy, got %d", len(strategies))
|
|
||||||
}
|
|
||||||
|
|
||||||
byName := map[string]*store.Strategy{}
|
|
||||||
activeCount := 0
|
|
||||||
for _, strategy := range strategies {
|
|
||||||
byName[strategy.Name] = strategy
|
|
||||||
if strategy.IsActive {
|
|
||||||
activeCount++
|
|
||||||
}
|
|
||||||
if strategy.Name == "Balanced Strategy" || strategy.Name == "Steady Strategy" || strategy.Name == "Aggressive Strategy" {
|
|
||||||
t.Fatalf("legacy crypto-style default strategy still present: %s", strategy.Name)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if activeCount != 1 {
|
|
||||||
t.Fatalf("expected exactly one active strategy, got %d", activeCount)
|
|
||||||
}
|
|
||||||
|
|
||||||
defaultStrategy := byName["NOFX Claw402 Auto Strategy"]
|
|
||||||
if defaultStrategy == nil || !defaultStrategy.IsActive {
|
|
||||||
t.Fatalf("NOFX Claw402 Auto Strategy should exist and be active")
|
|
||||||
}
|
|
||||||
trendCfg, err := defaultStrategy.ParseConfig()
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("default ParseConfig failed: %v", err)
|
|
||||||
}
|
|
||||||
if trendCfg.CoinSource.SourceType != "vergex_signal" || trendCfg.CoinSource.VergexLimit != 10 || trendCfg.CoinSource.VergexMarketType != "all" {
|
|
||||||
t.Fatalf("default strategy should use the Claw402/Vergex all-market signal ranking, got %+v", trendCfg.CoinSource)
|
|
||||||
}
|
|
||||||
if trendCfg.CoinSource.UseAI500 || trendCfg.RiskControl.MaxPositions != 2 {
|
|
||||||
t.Fatalf("default strategy should be Claw402/Vergex native with a 2-position concentrated book, got coin=%+v risk=%+v", trendCfg.CoinSource, trendCfg.RiskControl)
|
|
||||||
}
|
|
||||||
if trendCfg.RiskControl.BTCETHMaxLeverage != 10 || trendCfg.RiskControl.AltcoinMaxLeverage != 10 {
|
|
||||||
t.Fatalf("default strategy should use 10x leverage for all Claw402 opens, got risk=%+v", trendCfg.RiskControl)
|
|
||||||
}
|
|
||||||
if trendCfg.RiskControl.BTCETHMaxPositionValueRatio != 5.0 ||
|
|
||||||
trendCfg.RiskControl.AltcoinMaxPositionValueRatio != 5.0 ||
|
|
||||||
trendCfg.RiskControl.MaxMarginUsage != 1.0 {
|
|
||||||
t.Fatalf("default strategy should size Claw402 opens at 5x equity notional (2 positions = 10x total at 10x), got risk=%+v", trendCfg.RiskControl)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestCreateDefaultStrategiesMigratesLegacyPresetsWithoutOverridingActiveCustom(t *testing.T) {
|
|
||||||
st, err := store.New(t.TempDir() + "/nofx.db")
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("store.New failed: %v", err)
|
|
||||||
}
|
|
||||||
t.Cleanup(func() { _ = st.Close() })
|
|
||||||
|
|
||||||
userID := "user-existing-custom"
|
|
||||||
legacyCfg := store.GetDefaultStrategyConfig("zh")
|
|
||||||
legacy := &store.Strategy{
|
|
||||||
ID: uuid.New().String(),
|
|
||||||
UserID: userID,
|
|
||||||
Name: "Balanced Strategy",
|
|
||||||
Description: "legacy",
|
|
||||||
IsActive: false,
|
|
||||||
}
|
|
||||||
if err := legacy.SetConfig(&legacyCfg); err != nil {
|
|
||||||
t.Fatalf("legacy SetConfig failed: %v", err)
|
|
||||||
}
|
|
||||||
if err := st.Strategy().Create(legacy); err != nil {
|
|
||||||
t.Fatalf("create legacy failed: %v", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
custom := &store.Strategy{
|
|
||||||
ID: uuid.New().String(),
|
|
||||||
UserID: userID,
|
|
||||||
Name: "aa",
|
|
||||||
Description: "user custom active strategy",
|
|
||||||
IsActive: true,
|
|
||||||
}
|
|
||||||
if err := custom.SetConfig(&legacyCfg); err != nil {
|
|
||||||
t.Fatalf("custom SetConfig failed: %v", err)
|
|
||||||
}
|
|
||||||
if err := st.Strategy().Create(custom); err != nil {
|
|
||||||
t.Fatalf("create custom failed: %v", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
s := &Server{store: st}
|
|
||||||
if err := s.createDefaultStrategies(userID, "zh"); err != nil {
|
|
||||||
t.Fatalf("createDefaultStrategies failed: %v", err)
|
|
||||||
}
|
|
||||||
if err := s.createDefaultStrategies(userID, "zh"); err != nil {
|
|
||||||
t.Fatalf("second createDefaultStrategies should be idempotent: %v", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
strategies, err := st.Strategy().List(userID)
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("List strategies failed: %v", err)
|
|
||||||
}
|
|
||||||
byName := map[string]int{}
|
|
||||||
activeNames := []string{}
|
|
||||||
for _, strategy := range strategies {
|
|
||||||
byName[strategy.Name]++
|
|
||||||
if strategy.IsActive {
|
|
||||||
activeNames = append(activeNames, strategy.Name)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if byName["Balanced Strategy"] != 0 {
|
|
||||||
t.Fatalf("legacy preset should be removed, got names=%+v", byName)
|
|
||||||
}
|
|
||||||
if byName["NOFX Claw402 Auto Strategy"] != 1 {
|
|
||||||
t.Fatalf("expected exactly one NOFX Claw402 Auto Strategy, got names=%+v", byName)
|
|
||||||
}
|
|
||||||
if len(activeNames) != 1 || activeNames[0] != "aa" {
|
|
||||||
t.Fatalf("existing active custom strategy should stay the only active one, got %+v", activeNames)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,137 +0,0 @@
|
|||||||
package api
|
|
||||||
|
|
||||||
import (
|
|
||||||
"context"
|
|
||||||
"fmt"
|
|
||||||
"net/http"
|
|
||||||
"nofx/logger"
|
|
||||||
"nofx/provider/vergex"
|
|
||||||
"strings"
|
|
||||||
|
|
||||||
"github.com/gin-gonic/gin"
|
|
||||||
)
|
|
||||||
|
|
||||||
func (s *Server) handleVergexSignalRanking(c *gin.Context) {
|
|
||||||
client, ok := s.newVergexClientForRequest(c)
|
|
||||||
if !ok {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
data, err := client.GetSignalRanking(context.Background(), vergex.Query{
|
|
||||||
Chain: strings.TrimSpace(c.Query("chain")),
|
|
||||||
LiqBand: strings.TrimSpace(c.Query("liqBand")),
|
|
||||||
})
|
|
||||||
if err != nil {
|
|
||||||
logger.Warnf("Vergex signal-ranking failed: %v", err)
|
|
||||||
c.JSON(http.StatusBadGateway, gin.H{"error": err.Error()})
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
limit := parsePositiveInt(c.Query("limit"), vergex.MaxSignalRankingItems)
|
|
||||||
marketType := strings.TrimSpace(c.Query("marketType"))
|
|
||||||
items := vergex.FilterSignalRankingItems(data.Items, marketType, limit)
|
|
||||||
c.JSON(http.StatusOK, gin.H{
|
|
||||||
"items": items,
|
|
||||||
"raw": data.Raw,
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
func (s *Server) handleVergexSignalLab(c *gin.Context) {
|
|
||||||
client, ok := s.newVergexClientForRequest(c)
|
|
||||||
if !ok {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
body, err := client.GetSignalLab(context.Background(), vergex.Query{
|
|
||||||
MarketType: withDefault(strings.TrimSpace(c.Query("marketType")), vergex.DefaultMarketType),
|
|
||||||
Symbol: strings.TrimSpace(c.Query("symbol")),
|
|
||||||
Chain: strings.TrimSpace(c.Query("chain")),
|
|
||||||
LiqBand: strings.TrimSpace(c.Query("liqBand")),
|
|
||||||
})
|
|
||||||
if err != nil {
|
|
||||||
logger.Warnf("Vergex signal-lab failed: %v", err)
|
|
||||||
c.JSON(http.StatusBadGateway, gin.H{"error": err.Error()})
|
|
||||||
return
|
|
||||||
}
|
|
||||||
c.Data(http.StatusOK, "application/json; charset=utf-8", body)
|
|
||||||
}
|
|
||||||
|
|
||||||
func (s *Server) handleVergexCostLiquidationHeatmap(c *gin.Context) {
|
|
||||||
client, ok := s.newVergexClientForRequest(c)
|
|
||||||
if !ok {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
body, err := client.GetCostLiquidationHeatmap(context.Background(), vergex.Query{
|
|
||||||
MarketType: withDefault(strings.TrimSpace(c.Query("marketType")), vergex.DefaultMarketType),
|
|
||||||
Symbol: strings.TrimSpace(c.Query("symbol")),
|
|
||||||
Chain: strings.TrimSpace(c.Query("chain")),
|
|
||||||
LiqBand: strings.TrimSpace(c.Query("liqBand")),
|
|
||||||
})
|
|
||||||
if err != nil {
|
|
||||||
logger.Warnf("Vergex cost-liquidation-heatmap failed: %v", err)
|
|
||||||
c.JSON(http.StatusBadGateway, gin.H{"error": err.Error()})
|
|
||||||
return
|
|
||||||
}
|
|
||||||
c.Data(http.StatusOK, "application/json; charset=utf-8", body)
|
|
||||||
}
|
|
||||||
|
|
||||||
// handleVergexFlowMarkets proxies the Vergex net-flow market ranking (paid x402
|
|
||||||
// endpoint) using the caller's claw402 wallet. The upstream JSON is passed
|
|
||||||
// through verbatim: { data: { window, by, inflow: [{ symbol, netFlow,
|
|
||||||
// buyNotional, sellNotional, trades, latestPrice }, ...] } }.
|
|
||||||
func (s *Server) handleVergexFlowMarkets(c *gin.Context) {
|
|
||||||
client, ok := s.newVergexClientForRequest(c)
|
|
||||||
if !ok {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
chain := withDefault(strings.TrimSpace(c.Query("chain")), "mainnet")
|
|
||||||
window := withDefault(strings.TrimSpace(c.Query("window")), "1h")
|
|
||||||
limit := parsePositiveInt(c.Query("limit"), 25)
|
|
||||||
|
|
||||||
body, err := client.GetFlowMarkets(context.Background(), chain, window, limit)
|
|
||||||
if err != nil {
|
|
||||||
logger.Warnf("Vergex flow-markets failed: %v", err)
|
|
||||||
c.JSON(http.StatusBadGateway, gin.H{"error": err.Error()})
|
|
||||||
return
|
|
||||||
}
|
|
||||||
c.Data(http.StatusOK, "application/json; charset=utf-8", body)
|
|
||||||
}
|
|
||||||
|
|
||||||
func (s *Server) newVergexClientForRequest(c *gin.Context) (*vergex.Client, bool) {
|
|
||||||
userID := c.GetString("user_id")
|
|
||||||
if userID == "" {
|
|
||||||
c.JSON(http.StatusUnauthorized, gin.H{"error": "Unauthorized"})
|
|
||||||
return nil, false
|
|
||||||
}
|
|
||||||
walletKey, err := s.resolveStrategyDataWalletKey(userID, c.Query("ai_model_id"))
|
|
||||||
if err != nil {
|
|
||||||
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
|
||||||
return nil, false
|
|
||||||
}
|
|
||||||
if walletKey == "" {
|
|
||||||
c.JSON(http.StatusBadRequest, gin.H{"error": "claw402 wallet is not configured"})
|
|
||||||
return nil, false
|
|
||||||
}
|
|
||||||
client, err := vergex.NewClient("", walletKey, &logger.MCPLogger{})
|
|
||||||
if err != nil {
|
|
||||||
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
|
||||||
return nil, false
|
|
||||||
}
|
|
||||||
return client, true
|
|
||||||
}
|
|
||||||
|
|
||||||
func parsePositiveInt(raw string, fallback int) int {
|
|
||||||
if raw == "" {
|
|
||||||
return fallback
|
|
||||||
}
|
|
||||||
var n int
|
|
||||||
if _, err := fmt.Sscanf(raw, "%d", &n); err != nil || n <= 0 {
|
|
||||||
return fallback
|
|
||||||
}
|
|
||||||
return n
|
|
||||||
}
|
|
||||||
|
|
||||||
func withDefault(value, fallback string) string {
|
|
||||||
if strings.TrimSpace(value) == "" {
|
|
||||||
return fallback
|
|
||||||
}
|
|
||||||
return value
|
|
||||||
}
|
|
||||||
@@ -1,130 +0,0 @@
|
|||||||
package api
|
|
||||||
|
|
||||||
import (
|
|
||||||
"encoding/hex"
|
|
||||||
"fmt"
|
|
||||||
"net/http"
|
|
||||||
"nofx/wallet"
|
|
||||||
"strings"
|
|
||||||
"time"
|
|
||||||
|
|
||||||
"github.com/ethereum/go-ethereum/crypto"
|
|
||||||
"github.com/gin-gonic/gin"
|
|
||||||
)
|
|
||||||
|
|
||||||
type walletValidateRequest struct {
|
|
||||||
PrivateKey string `json:"private_key"`
|
|
||||||
}
|
|
||||||
|
|
||||||
type walletValidateResponse struct {
|
|
||||||
Valid bool `json:"valid"`
|
|
||||||
Address string `json:"address,omitempty"`
|
|
||||||
BalanceUSDC string `json:"balance_usdc,omitempty"`
|
|
||||||
Claw402Status string `json:"claw402_status"` // "ok", "unreachable", "error"
|
|
||||||
Error string `json:"error,omitempty"`
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
func (s *Server) handleWalletValidate(c *gin.Context) {
|
|
||||||
var req walletValidateRequest
|
|
||||||
if err := c.ShouldBindJSON(&req); err != nil {
|
|
||||||
c.JSON(http.StatusBadRequest, walletValidateResponse{
|
|
||||||
Valid: false,
|
|
||||||
Error: "invalid request body",
|
|
||||||
})
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
pk := req.PrivateKey
|
|
||||||
|
|
||||||
// Validate format
|
|
||||||
if !strings.HasPrefix(pk, "0x") {
|
|
||||||
c.JSON(http.StatusOK, walletValidateResponse{
|
|
||||||
Valid: false,
|
|
||||||
Error: "missing 0x prefix",
|
|
||||||
})
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
if len(pk) != 66 {
|
|
||||||
c.JSON(http.StatusOK, walletValidateResponse{
|
|
||||||
Valid: false,
|
|
||||||
Error: fmt.Sprintf("should be 66 characters, got %d", len(pk)),
|
|
||||||
})
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
hexPart := pk[2:]
|
|
||||||
if _, err := hex.DecodeString(hexPart); err != nil {
|
|
||||||
c.JSON(http.StatusOK, walletValidateResponse{
|
|
||||||
Valid: false,
|
|
||||||
Error: "contains invalid hex characters",
|
|
||||||
})
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
// Derive address
|
|
||||||
privateKey, err := crypto.HexToECDSA(hexPart)
|
|
||||||
if err != nil {
|
|
||||||
c.JSON(http.StatusOK, walletValidateResponse{
|
|
||||||
Valid: false,
|
|
||||||
Error: "invalid private key",
|
|
||||||
})
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
address := crypto.PubkeyToAddress(privateKey.PublicKey)
|
|
||||||
addrHex := address.Hex()
|
|
||||||
|
|
||||||
// Query USDC balance (async-ish, but sequential for simplicity)
|
|
||||||
balanceStr := wallet.QueryUSDCBalanceStr(addrHex)
|
|
||||||
|
|
||||||
// Check claw402 health
|
|
||||||
claw402Status := checkClaw402Health()
|
|
||||||
|
|
||||||
c.JSON(http.StatusOK, walletValidateResponse{
|
|
||||||
Valid: true,
|
|
||||||
Address: addrHex,
|
|
||||||
BalanceUSDC: balanceStr,
|
|
||||||
Claw402Status: claw402Status,
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
type walletGenerateResponse struct {
|
|
||||||
Address string `json:"address"`
|
|
||||||
PrivateKey string `json:"private_key"`
|
|
||||||
}
|
|
||||||
|
|
||||||
func (s *Server) handleWalletGenerate(c *gin.Context) {
|
|
||||||
// Generate new EVM wallet
|
|
||||||
privateKey, err := crypto.GenerateKey()
|
|
||||||
if err != nil {
|
|
||||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to generate wallet"})
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
address := crypto.PubkeyToAddress(privateKey.PublicKey)
|
|
||||||
privKeyHex := "0x" + hex.EncodeToString(crypto.FromECDSA(privateKey))
|
|
||||||
|
|
||||||
c.JSON(http.StatusOK, walletGenerateResponse{
|
|
||||||
Address: address.Hex(),
|
|
||||||
PrivateKey: privKeyHex,
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
func checkClaw402Health() string {
|
|
||||||
client := &http.Client{Timeout: 5 * time.Second}
|
|
||||||
resp, err := client.Get("https://claw402.ai/health")
|
|
||||||
if err != nil {
|
|
||||||
return "unreachable"
|
|
||||||
}
|
|
||||||
defer resp.Body.Close()
|
|
||||||
|
|
||||||
if resp.StatusCode >= 200 && resp.StatusCode < 300 {
|
|
||||||
return "ok"
|
|
||||||
}
|
|
||||||
return "error"
|
|
||||||
}
|
|
||||||
@@ -1,375 +0,0 @@
|
|||||||
package api
|
|
||||||
|
|
||||||
import (
|
|
||||||
"fmt"
|
|
||||||
"net/http"
|
|
||||||
"strings"
|
|
||||||
"time"
|
|
||||||
|
|
||||||
"nofx/store"
|
|
||||||
"nofx/wallet"
|
|
||||||
|
|
||||||
"github.com/gin-gonic/gin"
|
|
||||||
)
|
|
||||||
|
|
||||||
// Launch readiness minimums. These are the single source of truth — the
|
|
||||||
// frontend reads them from the preflight response instead of hardcoding.
|
|
||||||
const (
|
|
||||||
// MinAIFeeUSDC is the minimum Base USDC a claw402 fee wallet needs so the
|
|
||||||
// trader can pay for its first AI/data calls.
|
|
||||||
MinAIFeeUSDC = 1.0
|
|
||||||
// MinTradingUSDC is the minimum available balance the exchange account
|
|
||||||
// needs before the trader can place its first order.
|
|
||||||
MinTradingUSDC = 12.0
|
|
||||||
)
|
|
||||||
|
|
||||||
const (
|
|
||||||
launchCheckStatusOK = "ok"
|
|
||||||
launchCheckStatusFailed = "failed"
|
|
||||||
launchCheckStatusWarning = "warning"
|
|
||||||
launchCheckStatusSkipped = "skipped"
|
|
||||||
)
|
|
||||||
|
|
||||||
// Check IDs — stable identifiers the frontend maps to guided-setup steps.
|
|
||||||
const (
|
|
||||||
launchCheckAIModel = "ai_model"
|
|
||||||
launchCheckAIWallet = "ai_wallet"
|
|
||||||
launchCheckAIWalletFunds = "ai_wallet_funds"
|
|
||||||
launchCheckStrategy = "strategy"
|
|
||||||
launchCheckExchangeConfig = "exchange_config"
|
|
||||||
launchCheckExchangeAccount = "exchange_account"
|
|
||||||
launchCheckExchangeFunds = "exchange_funds"
|
|
||||||
)
|
|
||||||
|
|
||||||
// queryAIWalletBalance is swappable in tests to avoid live Base RPC calls.
|
|
||||||
var queryAIWalletBalance = wallet.QueryUSDCBalanceCached
|
|
||||||
|
|
||||||
type LaunchCheck struct {
|
|
||||||
ID string `json:"id"`
|
|
||||||
Status string `json:"status"`
|
|
||||||
Code string `json:"code,omitempty"`
|
|
||||||
Message string `json:"message,omitempty"`
|
|
||||||
// Numeric context so the UI can render progress like "8.40 / 12 USDC".
|
|
||||||
Required float64 `json:"required,omitempty"`
|
|
||||||
Actual *float64 `json:"actual,omitempty"`
|
|
||||||
Asset string `json:"asset,omitempty"`
|
|
||||||
// Address is the funding address for balance checks, so the UI can offer
|
|
||||||
// a deposit shortcut next to the failing item.
|
|
||||||
Address string `json:"address,omitempty"`
|
|
||||||
}
|
|
||||||
|
|
||||||
type LaunchPreflightResult struct {
|
|
||||||
Ready bool `json:"ready"`
|
|
||||||
Checks []LaunchCheck `json:"checks"`
|
|
||||||
MinAIFeeUSDC float64 `json:"min_ai_fee_usdc"`
|
|
||||||
MinTradingUSDC float64 `json:"min_trading_usdc"`
|
|
||||||
CheckedAt time.Time `json:"checked_at"`
|
|
||||||
}
|
|
||||||
|
|
||||||
func (r LaunchPreflightResult) failedChecks() []LaunchCheck {
|
|
||||||
var failed []LaunchCheck
|
|
||||||
for _, check := range r.Checks {
|
|
||||||
if check.Status == launchCheckStatusFailed {
|
|
||||||
failed = append(failed, check)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return failed
|
|
||||||
}
|
|
||||||
|
|
||||||
// Summary joins the failing messages into one human-readable sentence.
|
|
||||||
func (r LaunchPreflightResult) Summary() string {
|
|
||||||
failed := r.failedChecks()
|
|
||||||
if len(failed) == 0 {
|
|
||||||
return ""
|
|
||||||
}
|
|
||||||
messages := make([]string, 0, len(failed))
|
|
||||||
for _, check := range failed {
|
|
||||||
if check.Message != "" {
|
|
||||||
messages = append(messages, check.Message)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return strings.Join(messages, " ")
|
|
||||||
}
|
|
||||||
|
|
||||||
type launchPreflightRequest struct {
|
|
||||||
AIModelID string `json:"ai_model_id"`
|
|
||||||
ExchangeID string `json:"exchange_id"`
|
|
||||||
StrategyID string `json:"strategy_id"`
|
|
||||||
}
|
|
||||||
|
|
||||||
// handleLaunchPreflight runs launch readiness checks for a model/exchange
|
|
||||||
// pair before any trader is created or mutated. POST /api/launch/preflight
|
|
||||||
func (s *Server) handleLaunchPreflight(c *gin.Context) {
|
|
||||||
userID := c.GetString("user_id")
|
|
||||||
|
|
||||||
var req launchPreflightRequest
|
|
||||||
if err := c.ShouldBindJSON(&req); err != nil {
|
|
||||||
SafeBadRequest(c, "Invalid preflight request")
|
|
||||||
return
|
|
||||||
}
|
|
||||||
if strings.TrimSpace(req.AIModelID) == "" || strings.TrimSpace(req.ExchangeID) == "" {
|
|
||||||
SafeBadRequest(c, "ai_model_id and exchange_id are required")
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
model, err := s.store.AIModel().Get(userID, req.AIModelID)
|
|
||||||
if err != nil {
|
|
||||||
model = nil
|
|
||||||
}
|
|
||||||
exchange, err := s.store.Exchange().GetByID(userID, req.ExchangeID)
|
|
||||||
if err != nil {
|
|
||||||
exchange = nil
|
|
||||||
}
|
|
||||||
|
|
||||||
var strategy *store.Strategy
|
|
||||||
strategyRequired := strings.TrimSpace(req.StrategyID) != ""
|
|
||||||
if strategyRequired {
|
|
||||||
strategy, err = s.store.Strategy().Get(userID, req.StrategyID)
|
|
||||||
if err != nil {
|
|
||||||
strategy = nil
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
result := s.runLaunchPreflight(userID, model, exchange, strategy, strategyRequired)
|
|
||||||
c.JSON(http.StatusOK, result)
|
|
||||||
}
|
|
||||||
|
|
||||||
// handleTraderPreflight runs the same checks against an existing trader's
|
|
||||||
// full configuration. GET /api/traders/:id/preflight
|
|
||||||
func (s *Server) handleTraderPreflight(c *gin.Context) {
|
|
||||||
userID := c.GetString("user_id")
|
|
||||||
traderID := c.Param("id")
|
|
||||||
|
|
||||||
fullCfg, err := s.store.Trader().GetFullConfig(userID, traderID)
|
|
||||||
if err != nil || fullCfg == nil || fullCfg.Trader == nil {
|
|
||||||
SafeNotFound(c, "Trader")
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
result := s.runLaunchPreflight(userID, fullCfg.AIModel, fullCfg.Exchange, fullCfg.Strategy, true)
|
|
||||||
c.JSON(http.StatusOK, result)
|
|
||||||
}
|
|
||||||
|
|
||||||
// runLaunchPreflight composes every launch readiness check. A check that
|
|
||||||
// cannot be evaluated (RPC outage, probe timeout) reports "warning" instead
|
|
||||||
// of "failed" so an infrastructure hiccup never hard-blocks a launch.
|
|
||||||
func (s *Server) runLaunchPreflight(
|
|
||||||
userID string,
|
|
||||||
model *store.AIModel,
|
|
||||||
exchange *store.Exchange,
|
|
||||||
strategy *store.Strategy,
|
|
||||||
strategyRequired bool,
|
|
||||||
) LaunchPreflightResult {
|
|
||||||
checks := []LaunchCheck{checkLaunchAIModel(model)}
|
|
||||||
checks = append(checks, checkLaunchAIWallet(model)...)
|
|
||||||
checks = append(checks, checkLaunchStrategy(strategy, strategyRequired))
|
|
||||||
checks = append(checks, s.checkLaunchExchange(userID, exchange)...)
|
|
||||||
|
|
||||||
ready := true
|
|
||||||
for _, check := range checks {
|
|
||||||
if check.Status == launchCheckStatusFailed {
|
|
||||||
ready = false
|
|
||||||
break
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return LaunchPreflightResult{
|
|
||||||
Ready: ready,
|
|
||||||
Checks: checks,
|
|
||||||
MinAIFeeUSDC: MinAIFeeUSDC,
|
|
||||||
MinTradingUSDC: MinTradingUSDC,
|
|
||||||
CheckedAt: time.Now().UTC(),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func checkLaunchAIModel(model *store.AIModel) LaunchCheck {
|
|
||||||
check := LaunchCheck{ID: launchCheckAIModel}
|
|
||||||
|
|
||||||
switch {
|
|
||||||
case model == nil:
|
|
||||||
check.Status = launchCheckStatusFailed
|
|
||||||
check.Code = "MODEL_NOT_FOUND"
|
|
||||||
check.Message = "The selected AI model was not found. Configure an AI model first."
|
|
||||||
case !model.Enabled:
|
|
||||||
check.Status = launchCheckStatusFailed
|
|
||||||
check.Code = "MODEL_DISABLED"
|
|
||||||
check.Message = fmt.Sprintf("AI model \"%s\" is disabled. Enable it first.", model.Name)
|
|
||||||
case strings.TrimSpace(model.APIKey.String()) == "":
|
|
||||||
check.Status = launchCheckStatusFailed
|
|
||||||
check.Code = "MODEL_MISSING_CREDENTIALS"
|
|
||||||
check.Message = fmt.Sprintf("AI model \"%s\" has no credential saved. Add the API key or wallet key first.", model.Name)
|
|
||||||
default:
|
|
||||||
check.Status = launchCheckStatusOK
|
|
||||||
check.Message = model.Name
|
|
||||||
}
|
|
||||||
|
|
||||||
return check
|
|
||||||
}
|
|
||||||
|
|
||||||
// checkLaunchAIWallet validates the claw402 fee wallet (address + Base USDC
|
|
||||||
// balance). Non-claw402 providers pay per API key, so both checks are skipped.
|
|
||||||
func checkLaunchAIWallet(model *store.AIModel) []LaunchCheck {
|
|
||||||
walletCheck := LaunchCheck{ID: launchCheckAIWallet}
|
|
||||||
fundsCheck := LaunchCheck{ID: launchCheckAIWalletFunds, Asset: "USDC", Required: MinAIFeeUSDC}
|
|
||||||
|
|
||||||
if model == nil || model.Provider != "claw402" || strings.TrimSpace(model.APIKey.String()) == "" {
|
|
||||||
walletCheck.Status = launchCheckStatusSkipped
|
|
||||||
fundsCheck.Status = launchCheckStatusSkipped
|
|
||||||
return []LaunchCheck{walletCheck, fundsCheck}
|
|
||||||
}
|
|
||||||
|
|
||||||
address, err := walletAddressFromPrivateKey(model.APIKey.String())
|
|
||||||
if err != nil {
|
|
||||||
walletCheck.Status = launchCheckStatusFailed
|
|
||||||
walletCheck.Code = "AI_WALLET_INVALID_KEY"
|
|
||||||
walletCheck.Message = "The Claw402 wallet key is invalid. Recreate the Base USDC payment wallet."
|
|
||||||
fundsCheck.Status = launchCheckStatusSkipped
|
|
||||||
return []LaunchCheck{walletCheck, fundsCheck}
|
|
||||||
}
|
|
||||||
|
|
||||||
walletCheck.Status = launchCheckStatusOK
|
|
||||||
walletCheck.Address = address
|
|
||||||
fundsCheck.Address = address
|
|
||||||
|
|
||||||
balance, err := queryAIWalletBalance(address)
|
|
||||||
if err != nil {
|
|
||||||
fundsCheck.Status = launchCheckStatusWarning
|
|
||||||
fundsCheck.Code = "AI_WALLET_BALANCE_UNKNOWN"
|
|
||||||
fundsCheck.Message = "Could not verify the Base USDC balance right now. The trader will start, but AI calls fail if the wallet is empty."
|
|
||||||
return []LaunchCheck{walletCheck, fundsCheck}
|
|
||||||
}
|
|
||||||
|
|
||||||
fundsCheck.Actual = &balance
|
|
||||||
if balance < MinAIFeeUSDC {
|
|
||||||
fundsCheck.Status = launchCheckStatusFailed
|
|
||||||
fundsCheck.Code = "AI_WALLET_INSUFFICIENT_FUNDS"
|
|
||||||
fundsCheck.Message = fmt.Sprintf(
|
|
||||||
"The Claw402 wallet holds %.2f USDC but needs at least %.0f USDC on Base to pay for AI and data calls.",
|
|
||||||
balance, MinAIFeeUSDC,
|
|
||||||
)
|
|
||||||
} else {
|
|
||||||
fundsCheck.Status = launchCheckStatusOK
|
|
||||||
}
|
|
||||||
|
|
||||||
return []LaunchCheck{walletCheck, fundsCheck}
|
|
||||||
}
|
|
||||||
|
|
||||||
func checkLaunchStrategy(strategy *store.Strategy, required bool) LaunchCheck {
|
|
||||||
check := LaunchCheck{ID: launchCheckStrategy}
|
|
||||||
|
|
||||||
switch {
|
|
||||||
case strategy != nil:
|
|
||||||
check.Status = launchCheckStatusOK
|
|
||||||
check.Message = strategy.Name
|
|
||||||
case required:
|
|
||||||
check.Status = launchCheckStatusFailed
|
|
||||||
check.Code = "STRATEGY_NOT_FOUND"
|
|
||||||
check.Message = "The selected strategy was not found. Pick or create a strategy first."
|
|
||||||
default:
|
|
||||||
check.Status = launchCheckStatusSkipped
|
|
||||||
}
|
|
||||||
|
|
||||||
return check
|
|
||||||
}
|
|
||||||
|
|
||||||
// checkLaunchExchange validates exchange configuration completeness and then
|
|
||||||
// probes the live account (30s server cache) for status and balance.
|
|
||||||
func (s *Server) checkLaunchExchange(userID string, exchange *store.Exchange) []LaunchCheck {
|
|
||||||
configCheck := LaunchCheck{ID: launchCheckExchangeConfig}
|
|
||||||
accountCheck := LaunchCheck{ID: launchCheckExchangeAccount}
|
|
||||||
fundsCheck := LaunchCheck{ID: launchCheckExchangeFunds, Required: MinTradingUSDC}
|
|
||||||
|
|
||||||
if msg, code := describeExchangeConfigIssue(exchange); code != "" {
|
|
||||||
configCheck.Status = launchCheckStatusFailed
|
|
||||||
configCheck.Code = code
|
|
||||||
configCheck.Message = msg
|
|
||||||
accountCheck.Status = launchCheckStatusSkipped
|
|
||||||
fundsCheck.Status = launchCheckStatusSkipped
|
|
||||||
return []LaunchCheck{configCheck, accountCheck, fundsCheck}
|
|
||||||
}
|
|
||||||
|
|
||||||
configCheck.Status = launchCheckStatusOK
|
|
||||||
configCheck.Message = exchangeDisplayName(exchange)
|
|
||||||
fundsCheck.Asset = accountAssetForExchange(exchange.ExchangeType)
|
|
||||||
|
|
||||||
states, err := s.getExchangeAccountStates(userID)
|
|
||||||
if err != nil {
|
|
||||||
accountCheck.Status = launchCheckStatusWarning
|
|
||||||
accountCheck.Code = "EXCHANGE_STATE_UNKNOWN"
|
|
||||||
accountCheck.Message = "Could not verify the exchange account right now."
|
|
||||||
fundsCheck.Status = launchCheckStatusSkipped
|
|
||||||
return []LaunchCheck{configCheck, accountCheck, fundsCheck}
|
|
||||||
}
|
|
||||||
|
|
||||||
state, ok := states[exchange.ID]
|
|
||||||
if !ok {
|
|
||||||
accountCheck.Status = launchCheckStatusWarning
|
|
||||||
accountCheck.Code = "EXCHANGE_STATE_UNKNOWN"
|
|
||||||
accountCheck.Message = "Could not verify the exchange account right now."
|
|
||||||
fundsCheck.Status = launchCheckStatusSkipped
|
|
||||||
return []LaunchCheck{configCheck, accountCheck, fundsCheck}
|
|
||||||
}
|
|
||||||
|
|
||||||
if state.Status != exchangeAccountStatusOK {
|
|
||||||
accountCheck.Status = launchCheckStatusFailed
|
|
||||||
accountCheck.Code = state.ErrorCode
|
|
||||||
accountCheck.Message = state.ErrorMessage
|
|
||||||
if accountCheck.Message == "" {
|
|
||||||
accountCheck.Message = fmt.Sprintf("Exchange account \"%s\" is not ready (%s).", exchangeDisplayName(exchange), state.Status)
|
|
||||||
}
|
|
||||||
fundsCheck.Status = launchCheckStatusSkipped
|
|
||||||
return []LaunchCheck{configCheck, accountCheck, fundsCheck}
|
|
||||||
}
|
|
||||||
|
|
||||||
accountCheck.Status = launchCheckStatusOK
|
|
||||||
accountCheck.Message = state.DisplayBalance
|
|
||||||
|
|
||||||
// Gate on the better of available balance and total equity: a bot with
|
|
||||||
// capital deployed in open positions has low *available* margin but is
|
|
||||||
// clearly funded — restarting it must not be blocked.
|
|
||||||
funded := state.AvailableBalance
|
|
||||||
if state.TotalEquity > funded {
|
|
||||||
funded = state.TotalEquity
|
|
||||||
}
|
|
||||||
fundsCheck.Actual = &funded
|
|
||||||
|
|
||||||
if funded < MinTradingUSDC {
|
|
||||||
message := fmt.Sprintf(
|
|
||||||
"Exchange account \"%s\" holds %.2f %s but needs at least %.0f %s to place the first trade.",
|
|
||||||
exchangeDisplayName(exchange), funded, fundsCheck.Asset, MinTradingUSDC, fundsCheck.Asset,
|
|
||||||
)
|
|
||||||
if exchange.Testnet {
|
|
||||||
// Testnet balances are play money — warn instead of block.
|
|
||||||
fundsCheck.Status = launchCheckStatusWarning
|
|
||||||
} else {
|
|
||||||
fundsCheck.Status = launchCheckStatusFailed
|
|
||||||
}
|
|
||||||
fundsCheck.Code = "EXCHANGE_INSUFFICIENT_FUNDS"
|
|
||||||
fundsCheck.Message = message
|
|
||||||
} else {
|
|
||||||
fundsCheck.Status = launchCheckStatusOK
|
|
||||||
}
|
|
||||||
|
|
||||||
return []LaunchCheck{configCheck, accountCheck, fundsCheck}
|
|
||||||
}
|
|
||||||
|
|
||||||
// describeExchangeConfigIssue mirrors the create-time exchange validation but
|
|
||||||
// returns stable uppercase codes for the checklist UI.
|
|
||||||
func describeExchangeConfigIssue(exchange *store.Exchange) (string, string) {
|
|
||||||
if exchange == nil {
|
|
||||||
return "The selected exchange account was not found. Connect an exchange first.", "EXCHANGE_NOT_FOUND"
|
|
||||||
}
|
|
||||||
if !exchange.Enabled {
|
|
||||||
return fmt.Sprintf("Exchange account \"%s\" is disabled. Enable it first.", exchangeDisplayName(exchange)), "EXCHANGE_DISABLED"
|
|
||||||
}
|
|
||||||
if missing := missingExchangeFields(exchange); len(missing) > 0 {
|
|
||||||
return fmt.Sprintf(
|
|
||||||
"Exchange account \"%s\" is missing %s. Complete the connection first.",
|
|
||||||
exchangeDisplayName(exchange), strings.Join(missing, ", "),
|
|
||||||
), "EXCHANGE_MISSING_FIELDS"
|
|
||||||
}
|
|
||||||
if exchange.ExchangeType == "hyperliquid" && !exchange.HyperliquidBuilderApproved {
|
|
||||||
return "Hyperliquid builder authorization is not complete. Reconnect the wallet and finish the authorization.", "HYPERLIQUID_BUILDER_NOT_APPROVED"
|
|
||||||
}
|
|
||||||
return "", ""
|
|
||||||
}
|
|
||||||
@@ -1,293 +0,0 @@
|
|||||||
package api
|
|
||||||
|
|
||||||
import (
|
|
||||||
"errors"
|
|
||||||
"testing"
|
|
||||||
|
|
||||||
"nofx/crypto"
|
|
||||||
"nofx/store"
|
|
||||||
)
|
|
||||||
|
|
||||||
// Well-known throwaway development key (hardhat account #1) — never funded.
|
|
||||||
const testClaw402Key = "0x59c6995e998f97a5a0044966f0945389dc9e86dae88c7a8412f4603b6b78690d"
|
|
||||||
|
|
||||||
func withAIWalletBalance(t *testing.T, balance float64, err error) {
|
|
||||||
t.Helper()
|
|
||||||
original := queryAIWalletBalance
|
|
||||||
queryAIWalletBalance = func(string) (float64, error) {
|
|
||||||
return balance, err
|
|
||||||
}
|
|
||||||
t.Cleanup(func() {
|
|
||||||
queryAIWalletBalance = original
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
func findCheck(t *testing.T, checks []LaunchCheck, id string) LaunchCheck {
|
|
||||||
t.Helper()
|
|
||||||
for _, check := range checks {
|
|
||||||
if check.ID == id {
|
|
||||||
return check
|
|
||||||
}
|
|
||||||
}
|
|
||||||
t.Fatalf("check %q not found in %+v", id, checks)
|
|
||||||
return LaunchCheck{}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestCheckLaunchAIModel(t *testing.T) {
|
|
||||||
if got := checkLaunchAIModel(nil); got.Status != launchCheckStatusFailed || got.Code != "MODEL_NOT_FOUND" {
|
|
||||||
t.Fatalf("nil model: expected failed/MODEL_NOT_FOUND, got %+v", got)
|
|
||||||
}
|
|
||||||
|
|
||||||
disabled := &store.AIModel{Name: "Claw402", Provider: "claw402", Enabled: false}
|
|
||||||
if got := checkLaunchAIModel(disabled); got.Code != "MODEL_DISABLED" {
|
|
||||||
t.Fatalf("disabled model: expected MODEL_DISABLED, got %+v", got)
|
|
||||||
}
|
|
||||||
|
|
||||||
noKey := &store.AIModel{Name: "Claw402", Provider: "claw402", Enabled: true}
|
|
||||||
if got := checkLaunchAIModel(noKey); got.Code != "MODEL_MISSING_CREDENTIALS" {
|
|
||||||
t.Fatalf("missing key: expected MODEL_MISSING_CREDENTIALS, got %+v", got)
|
|
||||||
}
|
|
||||||
|
|
||||||
ready := &store.AIModel{Name: "Claw402", Provider: "claw402", Enabled: true, APIKey: crypto.EncryptedString(testClaw402Key)}
|
|
||||||
if got := checkLaunchAIModel(ready); got.Status != launchCheckStatusOK {
|
|
||||||
t.Fatalf("ready model: expected ok, got %+v", got)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestCheckLaunchAIWalletSkipsNonClaw402(t *testing.T) {
|
|
||||||
model := &store.AIModel{Name: "DeepSeek", Provider: "deepseek", Enabled: true, APIKey: crypto.EncryptedString("sk-test")}
|
|
||||||
checks := checkLaunchAIWallet(model)
|
|
||||||
if got := findCheck(t, checks, launchCheckAIWallet); got.Status != launchCheckStatusSkipped {
|
|
||||||
t.Fatalf("non-claw402 wallet check should be skipped, got %+v", got)
|
|
||||||
}
|
|
||||||
if got := findCheck(t, checks, launchCheckAIWalletFunds); got.Status != launchCheckStatusSkipped {
|
|
||||||
t.Fatalf("non-claw402 funds check should be skipped, got %+v", got)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestCheckLaunchAIWalletInvalidKey(t *testing.T) {
|
|
||||||
model := &store.AIModel{Name: "Claw402", Provider: "claw402", Enabled: true, APIKey: crypto.EncryptedString("not-a-key")}
|
|
||||||
checks := checkLaunchAIWallet(model)
|
|
||||||
got := findCheck(t, checks, launchCheckAIWallet)
|
|
||||||
if got.Status != launchCheckStatusFailed || got.Code != "AI_WALLET_INVALID_KEY" {
|
|
||||||
t.Fatalf("invalid key: expected failed/AI_WALLET_INVALID_KEY, got %+v", got)
|
|
||||||
}
|
|
||||||
if funds := findCheck(t, checks, launchCheckAIWalletFunds); funds.Status != launchCheckStatusSkipped {
|
|
||||||
t.Fatalf("funds check should be skipped when the key is invalid, got %+v", funds)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestCheckLaunchAIWalletInsufficientFunds(t *testing.T) {
|
|
||||||
withAIWalletBalance(t, 0.25, nil)
|
|
||||||
|
|
||||||
model := &store.AIModel{Name: "Claw402", Provider: "claw402", Enabled: true, APIKey: crypto.EncryptedString(testClaw402Key)}
|
|
||||||
checks := checkLaunchAIWallet(model)
|
|
||||||
|
|
||||||
wallet := findCheck(t, checks, launchCheckAIWallet)
|
|
||||||
if wallet.Status != launchCheckStatusOK || wallet.Address == "" {
|
|
||||||
t.Fatalf("wallet check should pass with derived address, got %+v", wallet)
|
|
||||||
}
|
|
||||||
|
|
||||||
funds := findCheck(t, checks, launchCheckAIWalletFunds)
|
|
||||||
if funds.Status != launchCheckStatusFailed || funds.Code != "AI_WALLET_INSUFFICIENT_FUNDS" {
|
|
||||||
t.Fatalf("expected failed/AI_WALLET_INSUFFICIENT_FUNDS, got %+v", funds)
|
|
||||||
}
|
|
||||||
if funds.Actual == nil || *funds.Actual != 0.25 {
|
|
||||||
t.Fatalf("expected actual balance 0.25, got %+v", funds.Actual)
|
|
||||||
}
|
|
||||||
if funds.Required != MinAIFeeUSDC {
|
|
||||||
t.Fatalf("expected required %v, got %v", MinAIFeeUSDC, funds.Required)
|
|
||||||
}
|
|
||||||
if funds.Address == "" {
|
|
||||||
t.Fatalf("funds check should carry the deposit address")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestCheckLaunchAIWalletRPCOutageIsWarningNotFailure(t *testing.T) {
|
|
||||||
withAIWalletBalance(t, 0, errors.New("rpc unreachable"))
|
|
||||||
|
|
||||||
model := &store.AIModel{Name: "Claw402", Provider: "claw402", Enabled: true, APIKey: crypto.EncryptedString(testClaw402Key)}
|
|
||||||
checks := checkLaunchAIWallet(model)
|
|
||||||
|
|
||||||
funds := findCheck(t, checks, launchCheckAIWalletFunds)
|
|
||||||
if funds.Status != launchCheckStatusWarning || funds.Code != "AI_WALLET_BALANCE_UNKNOWN" {
|
|
||||||
t.Fatalf("RPC outage must degrade to warning, got %+v", funds)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestCheckLaunchAIWalletFunded(t *testing.T) {
|
|
||||||
withAIWalletBalance(t, 25.5, nil)
|
|
||||||
|
|
||||||
model := &store.AIModel{Name: "Claw402", Provider: "claw402", Enabled: true, APIKey: crypto.EncryptedString(testClaw402Key)}
|
|
||||||
checks := checkLaunchAIWallet(model)
|
|
||||||
|
|
||||||
funds := findCheck(t, checks, launchCheckAIWalletFunds)
|
|
||||||
if funds.Status != launchCheckStatusOK {
|
|
||||||
t.Fatalf("funded wallet should pass, got %+v", funds)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestDescribeExchangeConfigIssue(t *testing.T) {
|
|
||||||
if _, code := describeExchangeConfigIssue(nil); code != "EXCHANGE_NOT_FOUND" {
|
|
||||||
t.Fatalf("nil exchange: expected EXCHANGE_NOT_FOUND, got %s", code)
|
|
||||||
}
|
|
||||||
|
|
||||||
disabled := &store.Exchange{ID: "ex", ExchangeType: "hyperliquid", Enabled: false}
|
|
||||||
if _, code := describeExchangeConfigIssue(disabled); code != "EXCHANGE_DISABLED" {
|
|
||||||
t.Fatalf("disabled: expected EXCHANGE_DISABLED, got %s", code)
|
|
||||||
}
|
|
||||||
|
|
||||||
missing := &store.Exchange{ID: "ex", ExchangeType: "hyperliquid", Enabled: true}
|
|
||||||
if _, code := describeExchangeConfigIssue(missing); code != "EXCHANGE_MISSING_FIELDS" {
|
|
||||||
t.Fatalf("missing fields: expected EXCHANGE_MISSING_FIELDS, got %s", code)
|
|
||||||
}
|
|
||||||
|
|
||||||
unapproved := &store.Exchange{
|
|
||||||
ID: "ex",
|
|
||||||
ExchangeType: "hyperliquid",
|
|
||||||
Enabled: true,
|
|
||||||
APIKey: crypto.EncryptedString(testClaw402Key),
|
|
||||||
HyperliquidWalletAddr: "0x1111111111111111111111111111111111111111",
|
|
||||||
}
|
|
||||||
if _, code := describeExchangeConfigIssue(unapproved); code != "HYPERLIQUID_BUILDER_NOT_APPROVED" {
|
|
||||||
t.Fatalf("builder unapproved: expected HYPERLIQUID_BUILDER_NOT_APPROVED, got %s", code)
|
|
||||||
}
|
|
||||||
|
|
||||||
unapproved.HyperliquidBuilderApproved = true
|
|
||||||
if _, code := describeExchangeConfigIssue(unapproved); code != "" {
|
|
||||||
t.Fatalf("ready exchange: expected no issue, got %s", code)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func readyHyperliquidExchange() *store.Exchange {
|
|
||||||
return &store.Exchange{
|
|
||||||
ID: "ex-hl",
|
|
||||||
ExchangeType: "hyperliquid",
|
|
||||||
Enabled: true,
|
|
||||||
APIKey: crypto.EncryptedString(testClaw402Key),
|
|
||||||
HyperliquidWalletAddr: "0x1111111111111111111111111111111111111111",
|
|
||||||
HyperliquidBuilderApproved: true,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func preflightTestServer(t *testing.T, userID string, states map[string]ExchangeAccountState) *Server {
|
|
||||||
t.Helper()
|
|
||||||
server := &Server{exchangeAccountStateCache: NewExchangeAccountStateCache()}
|
|
||||||
if states != nil {
|
|
||||||
server.exchangeAccountStateCache.Set(userID, states)
|
|
||||||
}
|
|
||||||
return server
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestCheckLaunchExchangeInsufficientFundsBlocks(t *testing.T) {
|
|
||||||
exchange := readyHyperliquidExchange()
|
|
||||||
server := preflightTestServer(t, "user-1", map[string]ExchangeAccountState{
|
|
||||||
exchange.ID: {ExchangeID: exchange.ID, Status: exchangeAccountStatusOK, AvailableBalance: 5.5, TotalEquity: 5.5},
|
|
||||||
})
|
|
||||||
|
|
||||||
checks := server.checkLaunchExchange("user-1", exchange)
|
|
||||||
funds := findCheck(t, checks, launchCheckExchangeFunds)
|
|
||||||
if funds.Status != launchCheckStatusFailed || funds.Code != "EXCHANGE_INSUFFICIENT_FUNDS" {
|
|
||||||
t.Fatalf("expected failed/EXCHANGE_INSUFFICIENT_FUNDS, got %+v", funds)
|
|
||||||
}
|
|
||||||
if funds.Actual == nil || *funds.Actual != 5.5 {
|
|
||||||
t.Fatalf("expected actual 5.5, got %+v", funds.Actual)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestCheckLaunchExchangeDeployedMarginPasses(t *testing.T) {
|
|
||||||
// A running bot with capital locked in positions: low available balance
|
|
||||||
// but healthy equity. Restart must not be blocked.
|
|
||||||
exchange := readyHyperliquidExchange()
|
|
||||||
server := preflightTestServer(t, "user-1", map[string]ExchangeAccountState{
|
|
||||||
exchange.ID: {ExchangeID: exchange.ID, Status: exchangeAccountStatusOK, AvailableBalance: 3, TotalEquity: 100},
|
|
||||||
})
|
|
||||||
|
|
||||||
checks := server.checkLaunchExchange("user-1", exchange)
|
|
||||||
funds := findCheck(t, checks, launchCheckExchangeFunds)
|
|
||||||
if funds.Status != launchCheckStatusOK {
|
|
||||||
t.Fatalf("deployed margin with healthy equity should pass, got %+v", funds)
|
|
||||||
}
|
|
||||||
if funds.Actual == nil || *funds.Actual != 100 {
|
|
||||||
t.Fatalf("expected actual 100 (equity), got %+v", funds.Actual)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestCheckLaunchExchangeTestnetLowFundsIsWarning(t *testing.T) {
|
|
||||||
exchange := readyHyperliquidExchange()
|
|
||||||
exchange.Testnet = true
|
|
||||||
server := preflightTestServer(t, "user-1", map[string]ExchangeAccountState{
|
|
||||||
exchange.ID: {ExchangeID: exchange.ID, Status: exchangeAccountStatusOK, AvailableBalance: 0},
|
|
||||||
})
|
|
||||||
|
|
||||||
checks := server.checkLaunchExchange("user-1", exchange)
|
|
||||||
funds := findCheck(t, checks, launchCheckExchangeFunds)
|
|
||||||
if funds.Status != launchCheckStatusWarning {
|
|
||||||
t.Fatalf("testnet low funds should warn, not block, got %+v", funds)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestCheckLaunchExchangeInvalidCredentials(t *testing.T) {
|
|
||||||
exchange := readyHyperliquidExchange()
|
|
||||||
server := preflightTestServer(t, "user-1", map[string]ExchangeAccountState{
|
|
||||||
exchange.ID: {
|
|
||||||
ExchangeID: exchange.ID,
|
|
||||||
Status: exchangeAccountStatusInvalidCredentials,
|
|
||||||
ErrorCode: "INVALID_CREDENTIALS",
|
|
||||||
ErrorMessage: "Exchange credentials are invalid",
|
|
||||||
},
|
|
||||||
})
|
|
||||||
|
|
||||||
checks := server.checkLaunchExchange("user-1", exchange)
|
|
||||||
account := findCheck(t, checks, launchCheckExchangeAccount)
|
|
||||||
if account.Status != launchCheckStatusFailed || account.Code != "INVALID_CREDENTIALS" {
|
|
||||||
t.Fatalf("expected failed/INVALID_CREDENTIALS, got %+v", account)
|
|
||||||
}
|
|
||||||
if funds := findCheck(t, checks, launchCheckExchangeFunds); funds.Status != launchCheckStatusSkipped {
|
|
||||||
t.Fatalf("funds check should be skipped when account probe fails, got %+v", funds)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestRunLaunchPreflightAggregatesReadiness(t *testing.T) {
|
|
||||||
withAIWalletBalance(t, 10, nil)
|
|
||||||
|
|
||||||
exchange := readyHyperliquidExchange()
|
|
||||||
server := preflightTestServer(t, "user-1", map[string]ExchangeAccountState{
|
|
||||||
exchange.ID: {ExchangeID: exchange.ID, Status: exchangeAccountStatusOK, AvailableBalance: 100, TotalEquity: 100},
|
|
||||||
})
|
|
||||||
model := &store.AIModel{Name: "Claw402", Provider: "claw402", Enabled: true, APIKey: crypto.EncryptedString(testClaw402Key)}
|
|
||||||
strategy := &store.Strategy{ID: "strat-1", Name: "Autopilot"}
|
|
||||||
|
|
||||||
result := server.runLaunchPreflight("user-1", model, exchange, strategy, true)
|
|
||||||
if !result.Ready {
|
|
||||||
t.Fatalf("expected ready, got %+v", result)
|
|
||||||
}
|
|
||||||
if result.MinAIFeeUSDC != MinAIFeeUSDC || result.MinTradingUSDC != MinTradingUSDC {
|
|
||||||
t.Fatalf("minimums must be exposed in the response, got %+v", result)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Break one prerequisite → not ready, and Summary explains it.
|
|
||||||
withAIWalletBalance(t, 0, nil)
|
|
||||||
result = server.runLaunchPreflight("user-1", model, exchange, strategy, true)
|
|
||||||
if result.Ready {
|
|
||||||
t.Fatalf("expected not ready with empty AI wallet, got %+v", result)
|
|
||||||
}
|
|
||||||
if result.Summary() == "" {
|
|
||||||
t.Fatalf("summary should describe the failing check")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestRunLaunchPreflightWarningsDoNotBlock(t *testing.T) {
|
|
||||||
withAIWalletBalance(t, 0, errors.New("rpc down"))
|
|
||||||
|
|
||||||
exchange := readyHyperliquidExchange()
|
|
||||||
server := preflightTestServer(t, "user-1", map[string]ExchangeAccountState{
|
|
||||||
exchange.ID: {ExchangeID: exchange.ID, Status: exchangeAccountStatusOK, AvailableBalance: 100},
|
|
||||||
})
|
|
||||||
model := &store.AIModel{Name: "Claw402", Provider: "claw402", Enabled: true, APIKey: crypto.EncryptedString(testClaw402Key)}
|
|
||||||
|
|
||||||
result := server.runLaunchPreflight("user-1", model, exchange, nil, false)
|
|
||||||
if !result.Ready {
|
|
||||||
t.Fatalf("warnings must not block launch, got %+v", result)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
101
api/ratelimit.go
101
api/ratelimit.go
@@ -1,101 +0,0 @@
|
|||||||
package api
|
|
||||||
|
|
||||||
import (
|
|
||||||
"math"
|
|
||||||
"net/http"
|
|
||||||
"sync"
|
|
||||||
"time"
|
|
||||||
|
|
||||||
"github.com/gin-gonic/gin"
|
|
||||||
)
|
|
||||||
|
|
||||||
// ipRateLimiter is a small, dependency-free token-bucket rate limiter keyed by
|
|
||||||
// client IP. It is used to throttle the unauthenticated auth endpoints
|
|
||||||
// (login / register) against online brute-force attacks.
|
|
||||||
//
|
|
||||||
// Design notes:
|
|
||||||
// - Per-IP token bucket with lazy refill (no background goroutine).
|
|
||||||
// - Idle buckets are evicted opportunistically so a flood of distinct source
|
|
||||||
// IPs (e.g. spoofed X-Forwarded-For) cannot grow the map without bound.
|
|
||||||
// - This is a throttle, not an authenticator. Behind a reverse proxy the
|
|
||||||
// effective key is whatever gin's ClientIP() resolves; operators who
|
|
||||||
// terminate TLS at a proxy should configure trusted proxies so ClientIP()
|
|
||||||
// reflects the real peer rather than a spoofable header.
|
|
||||||
type ipRateLimiter struct {
|
|
||||||
mu sync.Mutex
|
|
||||||
buckets map[string]*rlBucket
|
|
||||||
rate float64 // tokens added per second
|
|
||||||
burst float64 // maximum tokens (and initial fill)
|
|
||||||
lastGC time.Time
|
|
||||||
}
|
|
||||||
|
|
||||||
type rlBucket struct {
|
|
||||||
tokens float64
|
|
||||||
last time.Time
|
|
||||||
}
|
|
||||||
|
|
||||||
// newIPRateLimiter creates a limiter that allows bursts up to `burst` requests
|
|
||||||
// and then refills at `ratePerSec` tokens/second per client IP.
|
|
||||||
func newIPRateLimiter(ratePerSec, burst float64) *ipRateLimiter {
|
|
||||||
return &ipRateLimiter{
|
|
||||||
buckets: make(map[string]*rlBucket),
|
|
||||||
rate: ratePerSec,
|
|
||||||
burst: burst,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// allow reports whether a request from key is permitted at time now, consuming
|
|
||||||
// one token when it is.
|
|
||||||
func (l *ipRateLimiter) allow(key string, now time.Time) bool {
|
|
||||||
l.mu.Lock()
|
|
||||||
defer l.mu.Unlock()
|
|
||||||
|
|
||||||
// Opportunistic GC: drop buckets idle for >10 minutes. Bounds memory even
|
|
||||||
// under a spoofed-IP flood without needing a background goroutine.
|
|
||||||
if l.lastGC.IsZero() {
|
|
||||||
l.lastGC = now
|
|
||||||
}
|
|
||||||
if now.Sub(l.lastGC) > time.Minute {
|
|
||||||
for k, b := range l.buckets {
|
|
||||||
if now.Sub(b.last) > 10*time.Minute {
|
|
||||||
delete(l.buckets, k)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
l.lastGC = now
|
|
||||||
}
|
|
||||||
|
|
||||||
b, ok := l.buckets[key]
|
|
||||||
if !ok {
|
|
||||||
b = &rlBucket{tokens: l.burst, last: now}
|
|
||||||
l.buckets[key] = b
|
|
||||||
}
|
|
||||||
|
|
||||||
// Refill based on elapsed time, capped at burst.
|
|
||||||
elapsed := now.Sub(b.last).Seconds()
|
|
||||||
if elapsed > 0 {
|
|
||||||
b.tokens = math.Min(l.burst, b.tokens+elapsed*l.rate)
|
|
||||||
b.last = now
|
|
||||||
}
|
|
||||||
|
|
||||||
if b.tokens < 1 {
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
b.tokens--
|
|
||||||
return true
|
|
||||||
}
|
|
||||||
|
|
||||||
// rateLimitMiddleware throttles requests per client IP, returning 429 when the
|
|
||||||
// caller exceeds the configured rate.
|
|
||||||
func rateLimitMiddleware(l *ipRateLimiter) gin.HandlerFunc {
|
|
||||||
return func(c *gin.Context) {
|
|
||||||
if !l.allow(c.ClientIP(), time.Now()) {
|
|
||||||
c.Header("Retry-After", "60")
|
|
||||||
c.JSON(http.StatusTooManyRequests, gin.H{
|
|
||||||
"error": "Too many requests. Please slow down and try again in a minute.",
|
|
||||||
})
|
|
||||||
c.Abort()
|
|
||||||
return
|
|
||||||
}
|
|
||||||
c.Next()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,54 +0,0 @@
|
|||||||
package api
|
|
||||||
|
|
||||||
import (
|
|
||||||
"testing"
|
|
||||||
"time"
|
|
||||||
)
|
|
||||||
|
|
||||||
// TestIPRateLimiterBurstThenThrottle verifies that a client gets `burst`
|
|
||||||
// immediate attempts and is then throttled until tokens refill.
|
|
||||||
func TestIPRateLimiterBurstThenThrottle(t *testing.T) {
|
|
||||||
// 1 token/sec, burst of 3.
|
|
||||||
l := newIPRateLimiter(1.0, 3)
|
|
||||||
now := time.Unix(1_700_000_000, 0)
|
|
||||||
|
|
||||||
// First 3 requests in the same instant are allowed (the burst).
|
|
||||||
for i := 0; i < 3; i++ {
|
|
||||||
if !l.allow("1.2.3.4", now) {
|
|
||||||
t.Fatalf("request %d in burst should be allowed", i+1)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
// 4th in the same instant is throttled.
|
|
||||||
if l.allow("1.2.3.4", now) {
|
|
||||||
t.Fatalf("request beyond burst should be throttled")
|
|
||||||
}
|
|
||||||
|
|
||||||
// After 1 second, one token refills → exactly one more request allowed.
|
|
||||||
now = now.Add(time.Second)
|
|
||||||
if !l.allow("1.2.3.4", now) {
|
|
||||||
t.Fatalf("one token should have refilled after 1s")
|
|
||||||
}
|
|
||||||
if l.allow("1.2.3.4", now) {
|
|
||||||
t.Fatalf("only one token should refill per second")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// TestIPRateLimiterIsolatesClients verifies one IP exhausting its bucket does
|
|
||||||
// not throttle a different IP.
|
|
||||||
func TestIPRateLimiterIsolatesClients(t *testing.T) {
|
|
||||||
l := newIPRateLimiter(1.0, 2)
|
|
||||||
now := time.Unix(1_700_000_000, 0)
|
|
||||||
|
|
||||||
// Exhaust IP A.
|
|
||||||
if !l.allow("10.0.0.1", now) || !l.allow("10.0.0.1", now) {
|
|
||||||
t.Fatalf("IP A burst should be allowed")
|
|
||||||
}
|
|
||||||
if l.allow("10.0.0.1", now) {
|
|
||||||
t.Fatalf("IP A should be throttled after burst")
|
|
||||||
}
|
|
||||||
|
|
||||||
// IP B is unaffected.
|
|
||||||
if !l.allow("10.0.0.2", now) {
|
|
||||||
t.Fatalf("IP B should be allowed regardless of IP A")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
252
api/register_otp_test.go
Normal file
252
api/register_otp_test.go
Normal file
@@ -0,0 +1,252 @@
|
|||||||
|
package api
|
||||||
|
|
||||||
|
import (
|
||||||
|
"testing"
|
||||||
|
)
|
||||||
|
|
||||||
|
// MockUser Mock user structure
|
||||||
|
type MockUser struct {
|
||||||
|
ID int
|
||||||
|
Email string
|
||||||
|
OTPSecret string
|
||||||
|
OTPVerified bool
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestOTPRefetchLogic Test OTP refetch logic
|
||||||
|
func TestOTPRefetchLogic(t *testing.T) {
|
||||||
|
tests := []struct {
|
||||||
|
name string
|
||||||
|
existingUser *MockUser
|
||||||
|
userExists bool
|
||||||
|
expectedAction string // "allow_refetch", "reject_duplicate", "create_new"
|
||||||
|
expectedMessage string
|
||||||
|
}{
|
||||||
|
{
|
||||||
|
name: "New user registration - email does not exist",
|
||||||
|
existingUser: nil,
|
||||||
|
userExists: false,
|
||||||
|
expectedAction: "create_new",
|
||||||
|
expectedMessage: "Create new user",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "Incomplete OTP verification - allow refetch",
|
||||||
|
existingUser: &MockUser{
|
||||||
|
ID: 1,
|
||||||
|
Email: "test@example.com",
|
||||||
|
OTPSecret: "SECRET123",
|
||||||
|
OTPVerified: false,
|
||||||
|
},
|
||||||
|
userExists: true,
|
||||||
|
expectedAction: "allow_refetch",
|
||||||
|
expectedMessage: "Incomplete registration detected, please continue OTP setup",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "Completed OTP verification - reject duplicate registration",
|
||||||
|
existingUser: &MockUser{
|
||||||
|
ID: 2,
|
||||||
|
Email: "verified@example.com",
|
||||||
|
OTPSecret: "SECRET456",
|
||||||
|
OTPVerified: true,
|
||||||
|
},
|
||||||
|
userExists: true,
|
||||||
|
expectedAction: "reject_duplicate",
|
||||||
|
expectedMessage: "Email already registered",
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, tt := range tests {
|
||||||
|
t.Run(tt.name, func(t *testing.T) {
|
||||||
|
// Simulate logic processing flow
|
||||||
|
var actualAction string
|
||||||
|
var actualMessage string
|
||||||
|
|
||||||
|
if !tt.userExists {
|
||||||
|
// User does not exist, create new user
|
||||||
|
actualAction = "create_new"
|
||||||
|
actualMessage = "Create new user"
|
||||||
|
} else {
|
||||||
|
// User exists, check OTP verification status
|
||||||
|
if !tt.existingUser.OTPVerified {
|
||||||
|
// OTP verification incomplete, allow refetch
|
||||||
|
actualAction = "allow_refetch"
|
||||||
|
actualMessage = "Incomplete registration detected, please continue OTP setup"
|
||||||
|
} else {
|
||||||
|
// Verification completed, reject duplicate registration
|
||||||
|
actualAction = "reject_duplicate"
|
||||||
|
actualMessage = "Email already registered"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Verify results
|
||||||
|
if actualAction != tt.expectedAction {
|
||||||
|
t.Errorf("Action mismatch: got %s, want %s", actualAction, tt.expectedAction)
|
||||||
|
}
|
||||||
|
if actualMessage != tt.expectedMessage {
|
||||||
|
t.Errorf("Message mismatch: got %s, want %s", actualMessage, tt.expectedMessage)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestOTPVerificationStates Test OTP verification state determination
|
||||||
|
func TestOTPVerificationStates(t *testing.T) {
|
||||||
|
tests := []struct {
|
||||||
|
name string
|
||||||
|
otpVerified bool
|
||||||
|
shouldAllowRefetch bool
|
||||||
|
}{
|
||||||
|
{
|
||||||
|
name: "OTP verified - disallow refetch",
|
||||||
|
otpVerified: true,
|
||||||
|
shouldAllowRefetch: false,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "OTP not verified - allow refetch",
|
||||||
|
otpVerified: false,
|
||||||
|
shouldAllowRefetch: true,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, tt := range tests {
|
||||||
|
t.Run(tt.name, func(t *testing.T) {
|
||||||
|
// Simulate verification logic
|
||||||
|
allowRefetch := !tt.otpVerified
|
||||||
|
|
||||||
|
if allowRefetch != tt.shouldAllowRefetch {
|
||||||
|
t.Errorf("Refetch logic error: OTPVerified=%v, allowRefetch=%v, expected=%v",
|
||||||
|
tt.otpVerified, allowRefetch, tt.shouldAllowRefetch)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestRegistrationFlow Test complete registration flow logic branches
|
||||||
|
func TestRegistrationFlow(t *testing.T) {
|
||||||
|
tests := []struct {
|
||||||
|
name string
|
||||||
|
scenario string
|
||||||
|
userExists bool
|
||||||
|
otpVerified bool
|
||||||
|
expectHTTPCode int // Simulated HTTP status code
|
||||||
|
expectResponse string
|
||||||
|
}{
|
||||||
|
{
|
||||||
|
name: "Scenario 1: New user first registration",
|
||||||
|
scenario: "New user first accesses registration endpoint",
|
||||||
|
userExists: false,
|
||||||
|
otpVerified: false,
|
||||||
|
expectHTTPCode: 200,
|
||||||
|
expectResponse: "Create user and return OTP setup information",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "Scenario 2: User re-accesses after interrupting registration",
|
||||||
|
scenario: "User registered previously but did not complete OTP setup, now re-accessing",
|
||||||
|
userExists: true,
|
||||||
|
otpVerified: false,
|
||||||
|
expectHTTPCode: 200,
|
||||||
|
expectResponse: "Return existing user's OTP information, allow continuation",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "Scenario 3: Registered user attempts duplicate registration",
|
||||||
|
scenario: "User already completed registration, attempts to register again with same email",
|
||||||
|
userExists: true,
|
||||||
|
otpVerified: true,
|
||||||
|
expectHTTPCode: 409, // Conflict
|
||||||
|
expectResponse: "Email already registered",
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, tt := range tests {
|
||||||
|
t.Run(tt.name, func(t *testing.T) {
|
||||||
|
// Simulate registration flow logic
|
||||||
|
var actualHTTPCode int
|
||||||
|
var actualResponse string
|
||||||
|
|
||||||
|
if !tt.userExists {
|
||||||
|
// New user, create and return OTP information
|
||||||
|
actualHTTPCode = 200
|
||||||
|
actualResponse = "Create user and return OTP setup information"
|
||||||
|
} else {
|
||||||
|
// User exists
|
||||||
|
if !tt.otpVerified {
|
||||||
|
// OTP verification incomplete, allow refetch
|
||||||
|
actualHTTPCode = 200
|
||||||
|
actualResponse = "Return existing user's OTP information, allow continuation"
|
||||||
|
} else {
|
||||||
|
// Verification completed, reject duplicate registration
|
||||||
|
actualHTTPCode = 409
|
||||||
|
actualResponse = "Email already registered"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Verify
|
||||||
|
if actualHTTPCode != tt.expectHTTPCode {
|
||||||
|
t.Errorf("HTTP code mismatch: got %d, want %d (scenario: %s)",
|
||||||
|
actualHTTPCode, tt.expectHTTPCode, tt.scenario)
|
||||||
|
}
|
||||||
|
if actualResponse != tt.expectResponse {
|
||||||
|
t.Errorf("Response mismatch: got %s, want %s (scenario: %s)",
|
||||||
|
actualResponse, tt.expectResponse, tt.scenario)
|
||||||
|
}
|
||||||
|
|
||||||
|
t.Logf("✓ %s: HTTP %d, %s", tt.scenario, actualHTTPCode, actualResponse)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestEdgeCases Test edge cases
|
||||||
|
func TestEdgeCases(t *testing.T) {
|
||||||
|
tests := []struct {
|
||||||
|
name string
|
||||||
|
user *MockUser
|
||||||
|
expectAllow bool
|
||||||
|
description string
|
||||||
|
}{
|
||||||
|
{
|
||||||
|
name: "User ID is 0 - treated as new user",
|
||||||
|
user: &MockUser{
|
||||||
|
ID: 0,
|
||||||
|
Email: "new@example.com",
|
||||||
|
OTPVerified: false,
|
||||||
|
},
|
||||||
|
expectAllow: true,
|
||||||
|
description: "ID of 0 usually indicates user has not been created yet",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "OTPSecret is empty - still can refetch",
|
||||||
|
user: &MockUser{
|
||||||
|
ID: 1,
|
||||||
|
Email: "test@example.com",
|
||||||
|
OTPSecret: "",
|
||||||
|
OTPVerified: false,
|
||||||
|
},
|
||||||
|
expectAllow: true,
|
||||||
|
description: "Even if OTPSecret is empty, as long as not verified, refetch is allowed",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "OTPSecret exists but already verified - not allowed",
|
||||||
|
user: &MockUser{
|
||||||
|
ID: 2,
|
||||||
|
Email: "verified@example.com",
|
||||||
|
OTPSecret: "SECRET789",
|
||||||
|
OTPVerified: true,
|
||||||
|
},
|
||||||
|
expectAllow: false,
|
||||||
|
description: "Users with verified OTP cannot refetch",
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, tt := range tests {
|
||||||
|
t.Run(tt.name, func(t *testing.T) {
|
||||||
|
// Core logic: as long as OTPVerified is false, refetch is allowed
|
||||||
|
allowRefetch := !tt.user.OTPVerified
|
||||||
|
|
||||||
|
if allowRefetch != tt.expectAllow {
|
||||||
|
t.Errorf("Edge case failed: %s\nUser: ID=%d, OTPVerified=%v\nExpected allow=%v, got=%v",
|
||||||
|
tt.description, tt.user.ID, tt.user.OTPVerified, tt.expectAllow, allowRefetch)
|
||||||
|
}
|
||||||
|
|
||||||
|
t.Logf("✓ %s", tt.description)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,66 +0,0 @@
|
|||||||
package api
|
|
||||||
|
|
||||||
import (
|
|
||||||
"fmt"
|
|
||||||
"strings"
|
|
||||||
|
|
||||||
"github.com/gin-gonic/gin"
|
|
||||||
)
|
|
||||||
|
|
||||||
// RouteDoc holds documentation for a single API route.
|
|
||||||
type RouteDoc struct {
|
|
||||||
Method string
|
|
||||||
Path string
|
|
||||||
Description string
|
|
||||||
Schema string // optional: full parameter/body schema documentation
|
|
||||||
}
|
|
||||||
|
|
||||||
// routeRegistry stores all documented routes. Populated via s.route() calls in setupRoutes.
|
|
||||||
var routeRegistry []RouteDoc
|
|
||||||
|
|
||||||
// route registers an HTTP route with a one-line description.
|
|
||||||
func (s *Server) route(g *gin.RouterGroup, method, path, description string, h gin.HandlerFunc) {
|
|
||||||
s.routeWithSchema(g, method, path, description, "", h)
|
|
||||||
}
|
|
||||||
|
|
||||||
// routeWithSchema registers an HTTP route with full parameter schema documentation.
|
|
||||||
// schema is injected verbatim into the API docs seen by the LLM.
|
|
||||||
func (s *Server) routeWithSchema(g *gin.RouterGroup, method, path, description, schema string, h gin.HandlerFunc) {
|
|
||||||
fullPath := strings.TrimSuffix(g.BasePath(), "/") + "/" + strings.TrimPrefix(path, "/")
|
|
||||||
routeRegistry = append(routeRegistry, RouteDoc{
|
|
||||||
Method: method,
|
|
||||||
Path: fullPath,
|
|
||||||
Description: description,
|
|
||||||
Schema: schema,
|
|
||||||
})
|
|
||||||
switch method {
|
|
||||||
case "GET":
|
|
||||||
g.GET(path, h)
|
|
||||||
case "POST":
|
|
||||||
g.POST(path, h)
|
|
||||||
case "PUT":
|
|
||||||
g.PUT(path, h)
|
|
||||||
case "DELETE":
|
|
||||||
g.DELETE(path, h)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// GetAPIDocs returns formatted API documentation for injection into the LLM system prompt.
|
|
||||||
// Routes with schema documentation include full parameter details.
|
|
||||||
func GetAPIDocs() string {
|
|
||||||
var sb strings.Builder
|
|
||||||
for _, r := range routeRegistry {
|
|
||||||
sb.WriteString(fmt.Sprintf("%-8s %s\n", r.Method, r.Path))
|
|
||||||
sb.WriteString(fmt.Sprintf(" %s\n", r.Description))
|
|
||||||
if r.Schema != "" {
|
|
||||||
// Indent each schema line for readability
|
|
||||||
for _, line := range strings.Split(strings.TrimSpace(r.Schema), "\n") {
|
|
||||||
sb.WriteString(" ")
|
|
||||||
sb.WriteString(line)
|
|
||||||
sb.WriteByte('\n')
|
|
||||||
}
|
|
||||||
}
|
|
||||||
sb.WriteByte('\n')
|
|
||||||
}
|
|
||||||
return sb.String()
|
|
||||||
}
|
|
||||||
3745
api/server.go
3745
api/server.go
File diff suppressed because it is too large
Load Diff
@@ -2,38 +2,11 @@ package api
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
"net/http"
|
|
||||||
"net/http/httptest"
|
|
||||||
"testing"
|
"testing"
|
||||||
|
|
||||||
"nofx/store"
|
"nofx/store"
|
||||||
|
|
||||||
"github.com/gin-gonic/gin"
|
|
||||||
)
|
)
|
||||||
|
|
||||||
// TestPublicDecryptRouteNotRegistered is a security regression test: the
|
|
||||||
// unauthenticated POST /api/crypto/decrypt route was a decryption oracle and
|
|
||||||
// must never be re-registered. A built server's router must not route to it.
|
|
||||||
func TestPublicDecryptRouteNotRegistered(t *testing.T) {
|
|
||||||
gin.SetMode(gin.TestMode)
|
|
||||||
s := &Server{router: gin.New()}
|
|
||||||
s.setupRoutes()
|
|
||||||
|
|
||||||
for _, r := range s.router.Routes() {
|
|
||||||
if r.Method == http.MethodPost && r.Path == "/api/crypto/decrypt" {
|
|
||||||
t.Fatalf("SECURITY REGRESSION: public decryption oracle POST /api/crypto/decrypt is registered")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Also assert at the HTTP layer that the route is not handled.
|
|
||||||
req := httptest.NewRequest(http.MethodPost, "/api/crypto/decrypt", nil)
|
|
||||||
w := httptest.NewRecorder()
|
|
||||||
s.router.ServeHTTP(w, req)
|
|
||||||
if w.Code != http.StatusNotFound {
|
|
||||||
t.Fatalf("expected 404 for POST /api/crypto/decrypt, got %d", w.Code)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// TestUpdateTraderRequest_SystemPromptTemplate Test whether SystemPromptTemplate field exists when updating trader
|
// TestUpdateTraderRequest_SystemPromptTemplate Test whether SystemPromptTemplate field exists when updating trader
|
||||||
func TestUpdateTraderRequest_SystemPromptTemplate(t *testing.T) {
|
func TestUpdateTraderRequest_SystemPromptTemplate(t *testing.T) {
|
||||||
tests := []struct {
|
tests := []struct {
|
||||||
|
|||||||
226
api/strategy.go
226
api/strategy.go
@@ -1,7 +1,6 @@
|
|||||||
package api
|
package api
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"context"
|
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
"fmt"
|
"fmt"
|
||||||
"net/http"
|
"net/http"
|
||||||
@@ -9,8 +8,6 @@ import (
|
|||||||
"nofx/logger"
|
"nofx/logger"
|
||||||
"nofx/market"
|
"nofx/market"
|
||||||
"nofx/mcp"
|
"nofx/mcp"
|
||||||
_ "nofx/mcp/payment"
|
|
||||||
_ "nofx/mcp/provider"
|
|
||||||
"nofx/store"
|
"nofx/store"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
@@ -21,9 +18,6 @@ import (
|
|||||||
// validateStrategyConfig validates strategy configuration and returns warnings
|
// validateStrategyConfig validates strategy configuration and returns warnings
|
||||||
func validateStrategyConfig(config *store.StrategyConfig) []string {
|
func validateStrategyConfig(config *store.StrategyConfig) []string {
|
||||||
var warnings []string
|
var warnings []string
|
||||||
if config.StrategyType == "grid_trading" {
|
|
||||||
return warnings
|
|
||||||
}
|
|
||||||
|
|
||||||
// Validate NofxOS API key if any NofxOS feature is enabled
|
// Validate NofxOS API key if any NofxOS feature is enabled
|
||||||
if (config.Indicators.EnableQuantData || config.Indicators.EnableOIRanking ||
|
if (config.Indicators.EnableQuantData || config.Indicators.EnableOIRanking ||
|
||||||
@@ -35,31 +29,6 @@ func validateStrategyConfig(config *store.StrategyConfig) []string {
|
|||||||
return warnings
|
return warnings
|
||||||
}
|
}
|
||||||
|
|
||||||
func attachPublishConfig(config *store.StrategyConfig, strategy *store.Strategy) {
|
|
||||||
if config == nil || strategy == nil {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
config.ClampLimits()
|
|
||||||
config.PublishConfig = &store.PublishStrategyConfig{
|
|
||||||
IsPublic: strategy.IsPublic,
|
|
||||||
ConfigVisible: strategy.ConfigVisible,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// handleEstimateTokens estimates token usage for a strategy config (no auth required, pure computation)
|
|
||||||
func (s *Server) handleEstimateTokens(c *gin.Context) {
|
|
||||||
var req struct {
|
|
||||||
Config store.StrategyConfig `json:"config" binding:"required"`
|
|
||||||
}
|
|
||||||
if err := c.ShouldBindJSON(&req); err != nil {
|
|
||||||
SafeBadRequest(c, "Invalid request parameters")
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
estimate := req.Config.EstimateTokens()
|
|
||||||
c.JSON(http.StatusOK, estimate)
|
|
||||||
}
|
|
||||||
|
|
||||||
// handlePublicStrategies Get public strategies for strategy market (no auth required)
|
// handlePublicStrategies Get public strategies for strategy market (no auth required)
|
||||||
func (s *Server) handlePublicStrategies(c *gin.Context) {
|
func (s *Server) handlePublicStrategies(c *gin.Context) {
|
||||||
strategies, err := s.store.Strategy().ListPublic()
|
strategies, err := s.store.Strategy().ListPublic()
|
||||||
@@ -86,7 +55,6 @@ func (s *Server) handlePublicStrategies(c *gin.Context) {
|
|||||||
if st.ConfigVisible {
|
if st.ConfigVisible {
|
||||||
var config store.StrategyConfig
|
var config store.StrategyConfig
|
||||||
json.Unmarshal([]byte(st.Config), &config)
|
json.Unmarshal([]byte(st.Config), &config)
|
||||||
attachPublishConfig(&config, st)
|
|
||||||
item["config"] = config
|
item["config"] = config
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -106,14 +74,6 @@ func (s *Server) handleGetStrategies(c *gin.Context) {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
lang := c.Query("lang")
|
|
||||||
if lang == "" {
|
|
||||||
lang = "zh"
|
|
||||||
}
|
|
||||||
if err := s.createDefaultStrategies(userID, lang); err != nil {
|
|
||||||
logger.Warnf("Failed to sync default strategy presets for user %s: %v", userID, err)
|
|
||||||
}
|
|
||||||
|
|
||||||
strategies, err := s.store.Strategy().List(userID)
|
strategies, err := s.store.Strategy().List(userID)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
SafeInternalError(c, "Failed to get strategy list", err)
|
SafeInternalError(c, "Failed to get strategy list", err)
|
||||||
@@ -125,7 +85,6 @@ func (s *Server) handleGetStrategies(c *gin.Context) {
|
|||||||
for _, st := range strategies {
|
for _, st := range strategies {
|
||||||
var config store.StrategyConfig
|
var config store.StrategyConfig
|
||||||
json.Unmarshal([]byte(st.Config), &config)
|
json.Unmarshal([]byte(st.Config), &config)
|
||||||
attachPublishConfig(&config, st)
|
|
||||||
|
|
||||||
result = append(result, gin.H{
|
result = append(result, gin.H{
|
||||||
"id": st.ID,
|
"id": st.ID,
|
||||||
@@ -164,7 +123,6 @@ func (s *Server) handleGetStrategy(c *gin.Context) {
|
|||||||
|
|
||||||
var config store.StrategyConfig
|
var config store.StrategyConfig
|
||||||
json.Unmarshal([]byte(strategy.Config), &config)
|
json.Unmarshal([]byte(strategy.Config), &config)
|
||||||
attachPublishConfig(&config, strategy)
|
|
||||||
|
|
||||||
c.JSON(http.StatusOK, gin.H{
|
c.JSON(http.StatusOK, gin.H{
|
||||||
"id": strategy.ID,
|
"id": strategy.ID,
|
||||||
@@ -178,8 +136,7 @@ func (s *Server) handleGetStrategy(c *gin.Context) {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
// handleCreateStrategy Create strategy.
|
// handleCreateStrategy Create strategy
|
||||||
// If "config" is omitted from the request body, the system default config is used automatically.
|
|
||||||
func (s *Server) handleCreateStrategy(c *gin.Context) {
|
func (s *Server) handleCreateStrategy(c *gin.Context) {
|
||||||
userID := c.GetString("user_id")
|
userID := c.GetString("user_id")
|
||||||
if userID == "" {
|
if userID == "" {
|
||||||
@@ -188,12 +145,9 @@ func (s *Server) handleCreateStrategy(c *gin.Context) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
var req struct {
|
var req struct {
|
||||||
Name string `json:"name" binding:"required"`
|
Name string `json:"name" binding:"required"`
|
||||||
Description string `json:"description"`
|
Description string `json:"description"`
|
||||||
Lang string `json:"lang"` // "zh" or "en", used when config is omitted
|
Config store.StrategyConfig `json:"config" binding:"required"`
|
||||||
Config *store.StrategyConfig `json:"config"` // optional — uses default if omitted
|
|
||||||
IsPublic bool `json:"is_public"`
|
|
||||||
ConfigVisible bool `json:"config_visible"`
|
|
||||||
}
|
}
|
||||||
|
|
||||||
if err := c.ShouldBindJSON(&req); err != nil {
|
if err := c.ShouldBindJSON(&req); err != nil {
|
||||||
@@ -201,29 +155,6 @@ func (s *Server) handleCreateStrategy(c *gin.Context) {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
// Use default config when none provided
|
|
||||||
if req.Config == nil {
|
|
||||||
lang := req.Lang
|
|
||||||
if lang == "" {
|
|
||||||
lang = "zh"
|
|
||||||
}
|
|
||||||
defaultCfg := store.GetDefaultStrategyConfig(lang)
|
|
||||||
req.Config = &defaultCfg
|
|
||||||
}
|
|
||||||
beforeClamp := *req.Config
|
|
||||||
req.Config.ClampLimits()
|
|
||||||
hadPublishConfig := req.Config.PublishConfig != nil
|
|
||||||
isPublic := req.IsPublic
|
|
||||||
configVisible := req.ConfigVisible
|
|
||||||
if hadPublishConfig {
|
|
||||||
isPublic = req.Config.PublishConfig.IsPublic
|
|
||||||
configVisible = req.Config.PublishConfig.ConfigVisible
|
|
||||||
}
|
|
||||||
req.Config.PublishConfig = &store.PublishStrategyConfig{
|
|
||||||
IsPublic: isPublic,
|
|
||||||
ConfigVisible: configVisible,
|
|
||||||
}
|
|
||||||
|
|
||||||
// Serialize configuration
|
// Serialize configuration
|
||||||
configJSON, err := json.Marshal(req.Config)
|
configJSON, err := json.Marshal(req.Config)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -238,10 +169,7 @@ func (s *Server) handleCreateStrategy(c *gin.Context) {
|
|||||||
Description: req.Description,
|
Description: req.Description,
|
||||||
IsActive: false,
|
IsActive: false,
|
||||||
IsDefault: false,
|
IsDefault: false,
|
||||||
IsPublic: isPublic,
|
Config: string(configJSON),
|
||||||
// Existing default is true; keep that behavior when no explicit publish config is sent.
|
|
||||||
ConfigVisible: configVisible || !hadPublishConfig,
|
|
||||||
Config: string(configJSON),
|
|
||||||
}
|
}
|
||||||
|
|
||||||
if err := s.store.Strategy().Create(strategy); err != nil {
|
if err := s.store.Strategy().Create(strategy); err != nil {
|
||||||
@@ -250,8 +178,7 @@ func (s *Server) handleCreateStrategy(c *gin.Context) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Validate configuration and collect warnings
|
// Validate configuration and collect warnings
|
||||||
warnings := validateStrategyConfig(req.Config)
|
warnings := validateStrategyConfig(&req.Config)
|
||||||
warnings = append(warnings, store.StrategyClampWarnings(beforeClamp, *req.Config, req.Config.Language)...)
|
|
||||||
|
|
||||||
response := gin.H{
|
response := gin.H{
|
||||||
"id": strategy.ID,
|
"id": strategy.ID,
|
||||||
@@ -264,10 +191,7 @@ func (s *Server) handleCreateStrategy(c *gin.Context) {
|
|||||||
c.JSON(http.StatusOK, response)
|
c.JSON(http.StatusOK, response)
|
||||||
}
|
}
|
||||||
|
|
||||||
// handleUpdateStrategy Update strategy.
|
// handleUpdateStrategy Update strategy
|
||||||
// The incoming config is merged with the existing one: top-level sections present in the
|
|
||||||
// request overwrite the corresponding existing sections; absent sections are preserved.
|
|
||||||
// This prevents partial updates from zeroing out unmentioned fields.
|
|
||||||
func (s *Server) handleUpdateStrategy(c *gin.Context) {
|
func (s *Server) handleUpdateStrategy(c *gin.Context) {
|
||||||
userID := c.GetString("user_id")
|
userID := c.GetString("user_id")
|
||||||
strategyID := c.Param("id")
|
strategyID := c.Param("id")
|
||||||
@@ -289,11 +213,11 @@ func (s *Server) handleUpdateStrategy(c *gin.Context) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
var req struct {
|
var req struct {
|
||||||
Name string `json:"name"`
|
Name string `json:"name"`
|
||||||
Description string `json:"description"`
|
Description string `json:"description"`
|
||||||
Config json.RawMessage `json:"config"` // raw JSON so we can merge
|
Config store.StrategyConfig `json:"config"`
|
||||||
IsPublic bool `json:"is_public"`
|
IsPublic bool `json:"is_public"`
|
||||||
ConfigVisible bool `json:"config_visible"`
|
ConfigVisible bool `json:"config_visible"`
|
||||||
}
|
}
|
||||||
|
|
||||||
if err := c.ShouldBindJSON(&req); err != nil {
|
if err := c.ShouldBindJSON(&req); err != nil {
|
||||||
@@ -301,40 +225,8 @@ func (s *Server) handleUpdateStrategy(c *gin.Context) {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
// Start with the existing config as base — preserves all unmentioned fields.
|
// Serialize configuration
|
||||||
var mergedConfig store.StrategyConfig
|
configJSON, err := json.Marshal(req.Config)
|
||||||
if err := json.Unmarshal([]byte(existing.Config), &mergedConfig); err != nil {
|
|
||||||
// If existing config is corrupt, start from zero
|
|
||||||
mergedConfig = store.StrategyConfig{}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Apply incoming config on top while preserving nested fields that were not sent.
|
|
||||||
if len(req.Config) > 0 && string(req.Config) != "null" {
|
|
||||||
var patch map[string]any
|
|
||||||
if err := json.Unmarshal(req.Config, &patch); err != nil {
|
|
||||||
SafeBadRequest(c, "Invalid config JSON")
|
|
||||||
return
|
|
||||||
}
|
|
||||||
mergedConfig, err = store.MergeStrategyConfig(mergedConfig, patch)
|
|
||||||
if err != nil {
|
|
||||||
SafeBadRequest(c, "Invalid config JSON")
|
|
||||||
return
|
|
||||||
}
|
|
||||||
}
|
|
||||||
beforeClamp := mergedConfig
|
|
||||||
mergedConfig.ClampLimits()
|
|
||||||
|
|
||||||
// Preserve existing name/description when not supplied
|
|
||||||
name := req.Name
|
|
||||||
if name == "" {
|
|
||||||
name = existing.Name
|
|
||||||
}
|
|
||||||
description := req.Description
|
|
||||||
if description == "" {
|
|
||||||
description = existing.Description
|
|
||||||
}
|
|
||||||
|
|
||||||
configJSON, err := json.Marshal(mergedConfig)
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
SafeInternalError(c, "Serialize configuration", err)
|
SafeInternalError(c, "Serialize configuration", err)
|
||||||
return
|
return
|
||||||
@@ -343,8 +235,8 @@ func (s *Server) handleUpdateStrategy(c *gin.Context) {
|
|||||||
strategy := &store.Strategy{
|
strategy := &store.Strategy{
|
||||||
ID: strategyID,
|
ID: strategyID,
|
||||||
UserID: userID,
|
UserID: userID,
|
||||||
Name: name,
|
Name: req.Name,
|
||||||
Description: description,
|
Description: req.Description,
|
||||||
Config: string(configJSON),
|
Config: string(configJSON),
|
||||||
IsPublic: req.IsPublic,
|
IsPublic: req.IsPublic,
|
||||||
ConfigVisible: req.ConfigVisible,
|
ConfigVisible: req.ConfigVisible,
|
||||||
@@ -355,28 +247,8 @@ func (s *Server) handleUpdateStrategy(c *gin.Context) {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
// Token overflow check — block save if all models exceed context limits
|
// Validate configuration and collect warnings
|
||||||
if mergedConfig.StrategyType == "" || mergedConfig.StrategyType == "ai_trading" {
|
warnings := validateStrategyConfig(&req.Config)
|
||||||
estimate := mergedConfig.EstimateTokens()
|
|
||||||
allExceed := true
|
|
||||||
for _, ml := range estimate.ModelLimits {
|
|
||||||
if ml.UsagePct <= 100 {
|
|
||||||
allExceed = false
|
|
||||||
break
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if allExceed && len(estimate.ModelLimits) > 0 {
|
|
||||||
c.JSON(http.StatusBadRequest, gin.H{
|
|
||||||
"error": fmt.Sprintf("Estimated %d tokens exceeds all known model context limits. Reduce coins, timeframes, or K-line count.", estimate.Total),
|
|
||||||
"token_estimate": estimate,
|
|
||||||
})
|
|
||||||
return
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Validate merged configuration and collect warnings
|
|
||||||
warnings := validateStrategyConfig(&mergedConfig)
|
|
||||||
warnings = append(warnings, store.StrategyClampWarnings(beforeClamp, mergedConfig, mergedConfig.Language)...)
|
|
||||||
|
|
||||||
response := gin.H{"message": "Strategy updated successfully"}
|
response := gin.H{"message": "Strategy updated successfully"}
|
||||||
if len(warnings) > 0 {
|
if len(warnings) > 0 {
|
||||||
@@ -397,7 +269,7 @@ func (s *Server) handleDeleteStrategy(c *gin.Context) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if err := s.store.Strategy().Delete(userID, strategyID); err != nil {
|
if err := s.store.Strategy().Delete(userID, strategyID); err != nil {
|
||||||
c.JSON(http.StatusBadRequest, gin.H{"error": SanitizeError(err, "Failed to delete strategy")})
|
SafeInternalError(c, "Failed to delete strategy", err)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -470,7 +342,6 @@ func (s *Server) handleGetActiveStrategy(c *gin.Context) {
|
|||||||
|
|
||||||
var config store.StrategyConfig
|
var config store.StrategyConfig
|
||||||
json.Unmarshal([]byte(strategy.Config), &config)
|
json.Unmarshal([]byte(strategy.Config), &config)
|
||||||
attachPublishConfig(&config, strategy)
|
|
||||||
|
|
||||||
c.JSON(http.StatusOK, gin.H{
|
c.JSON(http.StatusOK, gin.H{
|
||||||
"id": strategy.ID,
|
"id": strategy.ID,
|
||||||
@@ -506,9 +377,9 @@ func (s *Server) handlePreviewPrompt(c *gin.Context) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
var req struct {
|
var req struct {
|
||||||
Config store.StrategyConfig `json:"config" binding:"required"`
|
Config store.StrategyConfig `json:"config" binding:"required"`
|
||||||
AccountEquity float64 `json:"account_equity"`
|
AccountEquity float64 `json:"account_equity"`
|
||||||
PromptVariant string `json:"prompt_variant"`
|
PromptVariant string `json:"prompt_variant"`
|
||||||
}
|
}
|
||||||
|
|
||||||
if err := c.ShouldBindJSON(&req); err != nil {
|
if err := c.ShouldBindJSON(&req); err != nil {
|
||||||
@@ -570,17 +441,8 @@ func (s *Server) handleStrategyTestRun(c *gin.Context) {
|
|||||||
req.PromptVariant = "balanced"
|
req.PromptVariant = "balanced"
|
||||||
}
|
}
|
||||||
|
|
||||||
claw402WalletKey, err := s.resolveStrategyDataWalletKey(userID, req.AIModelID)
|
|
||||||
if err != nil {
|
|
||||||
c.JSON(http.StatusBadRequest, gin.H{
|
|
||||||
"error": err.Error(),
|
|
||||||
"ai_response": "",
|
|
||||||
})
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
// Create strategy engine to build prompt
|
// Create strategy engine to build prompt
|
||||||
engine := kernel.NewStrategyEngine(&req.Config, claw402WalletKey)
|
engine := kernel.NewStrategyEngine(&req.Config)
|
||||||
|
|
||||||
// Get candidate coins
|
// Get candidate coins
|
||||||
candidates, err := engine.GetCandidateCoins()
|
candidates, err := engine.GetCandidateCoins()
|
||||||
@@ -637,7 +499,6 @@ func (s *Server) handleStrategyTestRun(c *gin.Context) {
|
|||||||
symbols = append(symbols, c.Symbol)
|
symbols = append(symbols, c.Symbol)
|
||||||
}
|
}
|
||||||
quantDataMap := engine.FetchQuantDataBatch(symbols)
|
quantDataMap := engine.FetchQuantDataBatch(symbols)
|
||||||
vergexDataMap := engine.FetchVergexDataBatch(context.Background(), symbols)
|
|
||||||
|
|
||||||
// Fetch OI ranking data (market-wide position changes)
|
// Fetch OI ranking data (market-wide position changes)
|
||||||
oiRankingData := engine.FetchOIRankingData()
|
oiRankingData := engine.FetchOIRankingData()
|
||||||
@@ -668,7 +529,6 @@ func (s *Server) handleStrategyTestRun(c *gin.Context) {
|
|||||||
PromptVariant: req.PromptVariant,
|
PromptVariant: req.PromptVariant,
|
||||||
MarketDataMap: marketDataMap,
|
MarketDataMap: marketDataMap,
|
||||||
QuantDataMap: quantDataMap,
|
QuantDataMap: quantDataMap,
|
||||||
VergexDataMap: vergexDataMap,
|
|
||||||
OIRankingData: oiRankingData,
|
OIRankingData: oiRankingData,
|
||||||
NetFlowRankingData: netFlowRankingData,
|
NetFlowRankingData: netFlowRankingData,
|
||||||
PriceRankingData: priceRankingData,
|
PriceRankingData: priceRankingData,
|
||||||
@@ -737,20 +597,37 @@ func (s *Server) runRealAITest(userID, modelID, systemPrompt, userPrompt string)
|
|||||||
return "", fmt.Errorf("AI model %s is missing API Key", model.Name)
|
return "", fmt.Errorf("AI model %s is missing API Key", model.Name)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Create AI client via registry
|
// Create AI client
|
||||||
|
var aiClient mcp.AIClient
|
||||||
provider := model.Provider
|
provider := model.Provider
|
||||||
|
|
||||||
|
// Convert EncryptedString to string for API key
|
||||||
apiKey := string(model.APIKey)
|
apiKey := string(model.APIKey)
|
||||||
|
|
||||||
aiClient := mcp.NewAIClientByProvider(provider)
|
|
||||||
if aiClient == nil {
|
|
||||||
aiClient = mcp.NewClient()
|
|
||||||
}
|
|
||||||
|
|
||||||
// Payment providers ignore custom URL
|
|
||||||
switch provider {
|
switch provider {
|
||||||
case "claw402":
|
case "qwen":
|
||||||
aiClient.SetAPIKey(apiKey, "", model.CustomModelName)
|
aiClient = mcp.NewQwenClient()
|
||||||
|
aiClient.SetAPIKey(apiKey, model.CustomAPIURL, model.CustomModelName)
|
||||||
|
case "deepseek":
|
||||||
|
aiClient = mcp.NewDeepSeekClient()
|
||||||
|
aiClient.SetAPIKey(apiKey, model.CustomAPIURL, model.CustomModelName)
|
||||||
|
case "claude":
|
||||||
|
aiClient = mcp.NewClaudeClient()
|
||||||
|
aiClient.SetAPIKey(apiKey, model.CustomAPIURL, model.CustomModelName)
|
||||||
|
case "kimi":
|
||||||
|
aiClient = mcp.NewKimiClient()
|
||||||
|
aiClient.SetAPIKey(apiKey, model.CustomAPIURL, model.CustomModelName)
|
||||||
|
case "gemini":
|
||||||
|
aiClient = mcp.NewGeminiClient()
|
||||||
|
aiClient.SetAPIKey(apiKey, model.CustomAPIURL, model.CustomModelName)
|
||||||
|
case "grok":
|
||||||
|
aiClient = mcp.NewGrokClient()
|
||||||
|
aiClient.SetAPIKey(apiKey, model.CustomAPIURL, model.CustomModelName)
|
||||||
|
case "openai":
|
||||||
|
aiClient = mcp.NewOpenAIClient()
|
||||||
|
aiClient.SetAPIKey(apiKey, model.CustomAPIURL, model.CustomModelName)
|
||||||
default:
|
default:
|
||||||
|
// Use generic client
|
||||||
|
aiClient = mcp.NewClient()
|
||||||
aiClient.SetAPIKey(apiKey, model.CustomAPIURL, model.CustomModelName)
|
aiClient.SetAPIKey(apiKey, model.CustomAPIURL, model.CustomModelName)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -763,6 +640,3 @@ func (s *Server) runRealAITest(userID, modelID, systemPrompt, userPrompt string)
|
|||||||
return response, nil
|
return response, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func (s *Server) resolveStrategyDataWalletKey(userID, selectedModelID string) (string, error) {
|
|
||||||
return s.store.AIModel().ResolveClaw402WalletKey(userID, selectedModelID)
|
|
||||||
}
|
|
||||||
|
|||||||
36
api/utils.go
36
api/utils.go
@@ -15,10 +15,13 @@ func MaskSensitiveString(s string) string {
|
|||||||
return s[:4] + "****" + s[length-4:]
|
return s[:4] + "****" + s[length-4:]
|
||||||
}
|
}
|
||||||
|
|
||||||
// SanitizeModelConfigForLog Sanitize model configuration for log output.
|
// SanitizeModelConfigForLog Sanitize model configuration for log output
|
||||||
// Takes the same ModelConfigUpdate type used by the request handler so the two
|
func SanitizeModelConfigForLog(models map[string]struct {
|
||||||
// can never drift out of sync.
|
Enabled bool `json:"enabled"`
|
||||||
func SanitizeModelConfigForLog(models map[string]ModelConfigUpdate) map[string]interface{} {
|
APIKey string `json:"api_key"`
|
||||||
|
CustomAPIURL string `json:"custom_api_url"`
|
||||||
|
CustomModelName string `json:"custom_model_name"`
|
||||||
|
}) map[string]interface{} {
|
||||||
safe := make(map[string]interface{})
|
safe := make(map[string]interface{})
|
||||||
for modelID, cfg := range models {
|
for modelID, cfg := range models {
|
||||||
safe[modelID] = map[string]interface{}{
|
safe[modelID] = map[string]interface{}{
|
||||||
@@ -31,12 +34,19 @@ func SanitizeModelConfigForLog(models map[string]ModelConfigUpdate) map[string]i
|
|||||||
return safe
|
return safe
|
||||||
}
|
}
|
||||||
|
|
||||||
// SanitizeExchangeConfigForLog Sanitize exchange configuration for log output.
|
// SanitizeExchangeConfigForLog Sanitize exchange configuration for log output
|
||||||
// Takes the same ExchangeConfigUpdate type used by the request handler so every
|
func SanitizeExchangeConfigForLog(exchanges map[string]struct {
|
||||||
// sensitive field is guaranteed to be masked — adding a field to the request
|
Enabled bool `json:"enabled"`
|
||||||
// type without masking it here would not compile around this helper, but more
|
APIKey string `json:"api_key"`
|
||||||
// importantly keeps the masking exhaustive.
|
SecretKey string `json:"secret_key"`
|
||||||
func SanitizeExchangeConfigForLog(exchanges map[string]ExchangeConfigUpdate) map[string]interface{} {
|
Testnet bool `json:"testnet"`
|
||||||
|
HyperliquidWalletAddr string `json:"hyperliquid_wallet_addr"`
|
||||||
|
AsterUser string `json:"aster_user"`
|
||||||
|
AsterSigner string `json:"aster_signer"`
|
||||||
|
AsterPrivateKey string `json:"aster_private_key"`
|
||||||
|
LighterWalletAddr string `json:"lighter_wallet_addr"`
|
||||||
|
LighterPrivateKey string `json:"lighter_private_key"`
|
||||||
|
}) map[string]interface{} {
|
||||||
safe := make(map[string]interface{})
|
safe := make(map[string]interface{})
|
||||||
for exchangeID, cfg := range exchanges {
|
for exchangeID, cfg := range exchanges {
|
||||||
safeExchange := map[string]interface{}{
|
safeExchange := map[string]interface{}{
|
||||||
@@ -51,18 +61,12 @@ func SanitizeExchangeConfigForLog(exchanges map[string]ExchangeConfigUpdate) map
|
|||||||
if cfg.SecretKey != "" {
|
if cfg.SecretKey != "" {
|
||||||
safeExchange["secret_key"] = MaskSensitiveString(cfg.SecretKey)
|
safeExchange["secret_key"] = MaskSensitiveString(cfg.SecretKey)
|
||||||
}
|
}
|
||||||
if cfg.Passphrase != "" {
|
|
||||||
safeExchange["passphrase"] = MaskSensitiveString(cfg.Passphrase)
|
|
||||||
}
|
|
||||||
if cfg.AsterPrivateKey != "" {
|
if cfg.AsterPrivateKey != "" {
|
||||||
safeExchange["aster_private_key"] = MaskSensitiveString(cfg.AsterPrivateKey)
|
safeExchange["aster_private_key"] = MaskSensitiveString(cfg.AsterPrivateKey)
|
||||||
}
|
}
|
||||||
if cfg.LighterPrivateKey != "" {
|
if cfg.LighterPrivateKey != "" {
|
||||||
safeExchange["lighter_private_key"] = MaskSensitiveString(cfg.LighterPrivateKey)
|
safeExchange["lighter_private_key"] = MaskSensitiveString(cfg.LighterPrivateKey)
|
||||||
}
|
}
|
||||||
if cfg.LighterAPIKeyPrivateKey != "" {
|
|
||||||
safeExchange["lighter_api_key_private_key"] = MaskSensitiveString(cfg.LighterAPIKeyPrivateKey)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Add non-sensitive fields directly
|
// Add non-sensitive fields directly
|
||||||
if cfg.HyperliquidWalletAddr != "" {
|
if cfg.HyperliquidWalletAddr != "" {
|
||||||
|
|||||||
@@ -1,8 +1,6 @@
|
|||||||
package api
|
package api
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"fmt"
|
|
||||||
"strings"
|
|
||||||
"testing"
|
"testing"
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -50,7 +48,12 @@ func TestMaskSensitiveString(t *testing.T) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func TestSanitizeModelConfigForLog(t *testing.T) {
|
func TestSanitizeModelConfigForLog(t *testing.T) {
|
||||||
models := map[string]ModelConfigUpdate{
|
models := map[string]struct {
|
||||||
|
Enabled bool `json:"enabled"`
|
||||||
|
APIKey string `json:"api_key"`
|
||||||
|
CustomAPIURL string `json:"custom_api_url"`
|
||||||
|
CustomModelName string `json:"custom_model_name"`
|
||||||
|
}{
|
||||||
"deepseek": {
|
"deepseek": {
|
||||||
Enabled: true,
|
Enabled: true,
|
||||||
APIKey: "sk-1234567890abcdefghijklmnopqrstuvwxyz",
|
APIKey: "sk-1234567890abcdefghijklmnopqrstuvwxyz",
|
||||||
@@ -85,29 +88,32 @@ func TestSanitizeModelConfigForLog(t *testing.T) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func TestSanitizeExchangeConfigForLog(t *testing.T) {
|
func TestSanitizeExchangeConfigForLog(t *testing.T) {
|
||||||
exchanges := map[string]ExchangeConfigUpdate{
|
exchanges := map[string]struct {
|
||||||
|
Enabled bool `json:"enabled"`
|
||||||
|
APIKey string `json:"api_key"`
|
||||||
|
SecretKey string `json:"secret_key"`
|
||||||
|
Testnet bool `json:"testnet"`
|
||||||
|
HyperliquidWalletAddr string `json:"hyperliquid_wallet_addr"`
|
||||||
|
AsterUser string `json:"aster_user"`
|
||||||
|
AsterSigner string `json:"aster_signer"`
|
||||||
|
AsterPrivateKey string `json:"aster_private_key"`
|
||||||
|
LighterWalletAddr string `json:"lighter_wallet_addr"`
|
||||||
|
LighterPrivateKey string `json:"lighter_private_key"`
|
||||||
|
}{
|
||||||
"binance": {
|
"binance": {
|
||||||
Enabled: true,
|
Enabled: true,
|
||||||
APIKey: "binance_api_key_1234567890abcdef",
|
APIKey: "binance_api_key_1234567890abcdef",
|
||||||
SecretKey: "binance_secret_key_1234567890abcdef",
|
SecretKey: "binance_secret_key_1234567890abcdef",
|
||||||
Testnet: false,
|
Testnet: false,
|
||||||
},
|
LighterWalletAddr: "",
|
||||||
"okx": {
|
LighterPrivateKey: "",
|
||||||
Enabled: true,
|
|
||||||
APIKey: "okx_api_key_1234567890abcdef",
|
|
||||||
SecretKey: "okx_secret_key_1234567890abcdef",
|
|
||||||
Passphrase: "okx_passphrase_supersecret_value",
|
|
||||||
},
|
|
||||||
"lighter": {
|
|
||||||
Enabled: true,
|
|
||||||
LighterWalletAddr: "0xabcdef0000000000000000000000000000000000",
|
|
||||||
LighterPrivateKey: "lighter_private_key_1234567890abcdef",
|
|
||||||
LighterAPIKeyPrivateKey: "lighter_api_key_private_key_1234567890abcdef",
|
|
||||||
},
|
},
|
||||||
"hyperliquid": {
|
"hyperliquid": {
|
||||||
Enabled: true,
|
Enabled: true,
|
||||||
HyperliquidWalletAddr: "0x1234567890abcdef1234567890abcdef12345678",
|
HyperliquidWalletAddr: "0x1234567890abcdef1234567890abcdef12345678",
|
||||||
Testnet: false,
|
Testnet: false,
|
||||||
|
LighterWalletAddr: "",
|
||||||
|
LighterPrivateKey: "",
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -137,32 +143,6 @@ func TestSanitizeExchangeConfigForLog(t *testing.T) {
|
|||||||
t.Errorf("expected masked secret_key='bina****cdef', got %q", maskedSecretKey)
|
t.Errorf("expected masked secret_key='bina****cdef', got %q", maskedSecretKey)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Check OKX passphrase is masked (regression: previously not covered)
|
|
||||||
okxConfig, ok := result["okx"].(map[string]interface{})
|
|
||||||
if !ok {
|
|
||||||
t.Fatal("okx config not found or wrong type")
|
|
||||||
}
|
|
||||||
maskedPassphrase, ok := okxConfig["passphrase"].(string)
|
|
||||||
if !ok {
|
|
||||||
t.Fatal("okx passphrase not found or wrong type")
|
|
||||||
}
|
|
||||||
if maskedPassphrase != "okx_****alue" {
|
|
||||||
t.Errorf("expected masked passphrase='okx_****alue', got %q", maskedPassphrase)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Check Lighter API key private key is masked (regression: previously not covered)
|
|
||||||
lighterConfig, ok := result["lighter"].(map[string]interface{})
|
|
||||||
if !ok {
|
|
||||||
t.Fatal("lighter config not found or wrong type")
|
|
||||||
}
|
|
||||||
maskedLighterAPIKey, ok := lighterConfig["lighter_api_key_private_key"].(string)
|
|
||||||
if !ok {
|
|
||||||
t.Fatal("lighter_api_key_private_key not found or wrong type")
|
|
||||||
}
|
|
||||||
if maskedLighterAPIKey != "ligh****cdef" {
|
|
||||||
t.Errorf("expected masked lighter_api_key_private_key='ligh****cdef', got %q", maskedLighterAPIKey)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Check Hyperliquid configuration
|
// Check Hyperliquid configuration
|
||||||
hlConfig, ok := result["hyperliquid"].(map[string]interface{})
|
hlConfig, ok := result["hyperliquid"].(map[string]interface{})
|
||||||
if !ok {
|
if !ok {
|
||||||
@@ -180,41 +160,6 @@ func TestSanitizeExchangeConfigForLog(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// TestSanitizeExchangeConfigForLog_NoPlaintextSecrets renders the sanitized log
|
|
||||||
// output exactly as the handler does (`%+v`) and asserts that no plaintext
|
|
||||||
// secret — including the passphrase and lighter API key private key that were
|
|
||||||
// historically not redacted — survives into the log line.
|
|
||||||
func TestSanitizeExchangeConfigForLog_NoPlaintextSecrets(t *testing.T) {
|
|
||||||
secrets := map[string]string{
|
|
||||||
"api_key": "binance_api_key_1234567890abcdef",
|
|
||||||
"secret_key": "binance_secret_key_1234567890abcdef",
|
|
||||||
"passphrase": "okx_passphrase_supersecret_value",
|
|
||||||
"aster_private_key": "aster_private_key_1234567890abcdef",
|
|
||||||
"lighter_private_key": "lighter_private_key_1234567890abcdef",
|
|
||||||
"lighter_api_key_private_key": "lighter_api_key_private_key_1234567890abcdef",
|
|
||||||
}
|
|
||||||
|
|
||||||
exchanges := map[string]ExchangeConfigUpdate{
|
|
||||||
"okx": {
|
|
||||||
Enabled: true,
|
|
||||||
APIKey: secrets["api_key"],
|
|
||||||
SecretKey: secrets["secret_key"],
|
|
||||||
Passphrase: secrets["passphrase"],
|
|
||||||
AsterPrivateKey: secrets["aster_private_key"],
|
|
||||||
LighterPrivateKey: secrets["lighter_private_key"],
|
|
||||||
LighterAPIKeyPrivateKey: secrets["lighter_api_key_private_key"],
|
|
||||||
},
|
|
||||||
}
|
|
||||||
|
|
||||||
rendered := fmt.Sprintf("%+v", SanitizeExchangeConfigForLog(exchanges))
|
|
||||||
|
|
||||||
for field, secret := range secrets {
|
|
||||||
if strings.Contains(rendered, secret) {
|
|
||||||
t.Errorf("sanitized log leaked plaintext %s: %q present in %q", field, secret, rendered)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestMaskEmail(t *testing.T) {
|
func TestMaskEmail(t *testing.T) {
|
||||||
tests := []struct {
|
tests := []struct {
|
||||||
name string
|
name string
|
||||||
|
|||||||
350
assistant/agent.go
Normal file
350
assistant/agent.go
Normal file
@@ -0,0 +1,350 @@
|
|||||||
|
// Package assistant implements the AI Agent runtime with tool calling capabilities
|
||||||
|
// Inspired by moltbot's agent architecture, specialized for trading
|
||||||
|
package assistant
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"encoding/json"
|
||||||
|
"fmt"
|
||||||
|
"nofx/logger"
|
||||||
|
"nofx/mcp"
|
||||||
|
"strings"
|
||||||
|
"sync"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Agent represents an AI assistant with tool-calling capabilities
|
||||||
|
type Agent struct {
|
||||||
|
// AI client for LLM calls
|
||||||
|
aiClient mcp.AIClient
|
||||||
|
|
||||||
|
// Tool registry
|
||||||
|
tools map[string]Tool
|
||||||
|
toolsLock sync.RWMutex
|
||||||
|
|
||||||
|
// Session/memory management
|
||||||
|
sessions map[string]*Session
|
||||||
|
sessionsLock sync.RWMutex
|
||||||
|
|
||||||
|
// Configuration
|
||||||
|
config AgentConfig
|
||||||
|
|
||||||
|
// System prompt
|
||||||
|
systemPrompt string
|
||||||
|
}
|
||||||
|
|
||||||
|
// AgentConfig holds agent configuration
|
||||||
|
type AgentConfig struct {
|
||||||
|
// Max tool calls per turn (prevent infinite loops)
|
||||||
|
MaxToolCalls int `json:"max_tool_calls"`
|
||||||
|
|
||||||
|
// Max conversation history to keep
|
||||||
|
MaxHistoryMessages int `json:"max_history_messages"`
|
||||||
|
|
||||||
|
// Timeout for single AI call
|
||||||
|
AITimeout time.Duration `json:"ai_timeout"`
|
||||||
|
|
||||||
|
// Model to use
|
||||||
|
Model string `json:"model"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// DefaultAgentConfig returns sensible defaults
|
||||||
|
func DefaultAgentConfig() AgentConfig {
|
||||||
|
return AgentConfig{
|
||||||
|
MaxToolCalls: 10,
|
||||||
|
MaxHistoryMessages: 50,
|
||||||
|
AITimeout: 120 * time.Second,
|
||||||
|
Model: "deepseek-chat",
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// NewAgent creates a new AI agent
|
||||||
|
func NewAgent(aiClient mcp.AIClient, config AgentConfig) *Agent {
|
||||||
|
agent := &Agent{
|
||||||
|
aiClient: aiClient,
|
||||||
|
tools: make(map[string]Tool),
|
||||||
|
sessions: make(map[string]*Session),
|
||||||
|
config: config,
|
||||||
|
}
|
||||||
|
|
||||||
|
// Set default system prompt
|
||||||
|
agent.systemPrompt = DefaultTradingSystemPrompt()
|
||||||
|
|
||||||
|
return agent
|
||||||
|
}
|
||||||
|
|
||||||
|
// RegisterTool adds a tool to the agent's toolkit
|
||||||
|
func (a *Agent) RegisterTool(tool Tool) {
|
||||||
|
a.toolsLock.Lock()
|
||||||
|
defer a.toolsLock.Unlock()
|
||||||
|
a.tools[tool.Name()] = tool
|
||||||
|
logger.Infof("🔧 Registered tool: %s", tool.Name())
|
||||||
|
}
|
||||||
|
|
||||||
|
// RegisterTools adds multiple tools
|
||||||
|
func (a *Agent) RegisterTools(tools ...Tool) {
|
||||||
|
for _, tool := range tools {
|
||||||
|
a.RegisterTool(tool)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// SetSystemPrompt sets the agent's system prompt
|
||||||
|
func (a *Agent) SetSystemPrompt(prompt string) {
|
||||||
|
a.systemPrompt = prompt
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetSession returns or creates a session for the given ID
|
||||||
|
func (a *Agent) GetSession(sessionID string) *Session {
|
||||||
|
a.sessionsLock.Lock()
|
||||||
|
defer a.sessionsLock.Unlock()
|
||||||
|
|
||||||
|
if session, ok := a.sessions[sessionID]; ok {
|
||||||
|
return session
|
||||||
|
}
|
||||||
|
|
||||||
|
session := NewSession(sessionID, a.config.MaxHistoryMessages)
|
||||||
|
a.sessions[sessionID] = session
|
||||||
|
return session
|
||||||
|
}
|
||||||
|
|
||||||
|
// Chat processes a user message and returns the agent's response
|
||||||
|
// This is the main entry point for the agent loop
|
||||||
|
func (a *Agent) Chat(ctx context.Context, sessionID string, userMessage string) (*AgentResponse, error) {
|
||||||
|
session := a.GetSession(sessionID)
|
||||||
|
|
||||||
|
// Add user message to history
|
||||||
|
session.AddMessage(Message{
|
||||||
|
Role: "user",
|
||||||
|
Content: userMessage,
|
||||||
|
Timestamp: time.Now(),
|
||||||
|
})
|
||||||
|
|
||||||
|
// Build the full prompt with tools
|
||||||
|
systemPrompt := a.buildSystemPromptWithTools()
|
||||||
|
conversationPrompt := a.buildConversationPrompt(session)
|
||||||
|
|
||||||
|
// Agent loop - keep calling AI until it's done or max iterations
|
||||||
|
var finalResponse string
|
||||||
|
toolCallCount := 0
|
||||||
|
|
||||||
|
for {
|
||||||
|
// Check context cancellation
|
||||||
|
if ctx.Err() != nil {
|
||||||
|
return nil, ctx.Err()
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check max tool calls
|
||||||
|
if toolCallCount >= a.config.MaxToolCalls {
|
||||||
|
logger.Warnf("⚠️ Max tool calls reached (%d), stopping agent loop", a.config.MaxToolCalls)
|
||||||
|
break
|
||||||
|
}
|
||||||
|
|
||||||
|
// Call AI
|
||||||
|
response, err := a.aiClient.CallWithMessages(systemPrompt, conversationPrompt)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("AI call failed: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Parse response for tool calls
|
||||||
|
toolCalls, textResponse, err := a.parseResponse(response)
|
||||||
|
if err != nil {
|
||||||
|
// If parsing fails, treat entire response as text
|
||||||
|
finalResponse = response
|
||||||
|
break
|
||||||
|
}
|
||||||
|
|
||||||
|
// If no tool calls, we're done
|
||||||
|
if len(toolCalls) == 0 {
|
||||||
|
finalResponse = textResponse
|
||||||
|
break
|
||||||
|
}
|
||||||
|
|
||||||
|
// Execute tool calls
|
||||||
|
toolResults := a.executeToolCalls(ctx, toolCalls)
|
||||||
|
toolCallCount += len(toolCalls)
|
||||||
|
|
||||||
|
// Add tool calls and results to conversation for next iteration
|
||||||
|
conversationPrompt += fmt.Sprintf("\n\nAssistant called tools:\n%s\n\nTool results:\n%s\n\nBased on the tool results, please provide your response to the user:",
|
||||||
|
formatToolCalls(toolCalls),
|
||||||
|
formatToolResults(toolResults))
|
||||||
|
|
||||||
|
// If there's also a text response, capture it
|
||||||
|
if textResponse != "" {
|
||||||
|
finalResponse = textResponse
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Add assistant response to history
|
||||||
|
session.AddMessage(Message{
|
||||||
|
Role: "assistant",
|
||||||
|
Content: finalResponse,
|
||||||
|
Timestamp: time.Now(),
|
||||||
|
})
|
||||||
|
|
||||||
|
return &AgentResponse{
|
||||||
|
Text: finalResponse,
|
||||||
|
SessionID: sessionID,
|
||||||
|
}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// buildSystemPromptWithTools creates the system prompt including tool definitions
|
||||||
|
func (a *Agent) buildSystemPromptWithTools() string {
|
||||||
|
a.toolsLock.RLock()
|
||||||
|
defer a.toolsLock.RUnlock()
|
||||||
|
|
||||||
|
var toolDefs []string
|
||||||
|
for _, tool := range a.tools {
|
||||||
|
toolDef := fmt.Sprintf(`- **%s**: %s
|
||||||
|
Parameters: %s`, tool.Name(), tool.Description(), tool.ParameterSchema())
|
||||||
|
toolDefs = append(toolDefs, toolDef)
|
||||||
|
}
|
||||||
|
|
||||||
|
toolsSection := ""
|
||||||
|
if len(toolDefs) > 0 {
|
||||||
|
toolsSection = fmt.Sprintf(`
|
||||||
|
|
||||||
|
## Available Tools
|
||||||
|
|
||||||
|
You can call tools by responding with JSON in this format:
|
||||||
|
{"tool_calls": [{"name": "tool_name", "arguments": {"param": "value"}}]}
|
||||||
|
|
||||||
|
After receiving tool results, provide a natural language response to the user.
|
||||||
|
|
||||||
|
Tools:
|
||||||
|
%s
|
||||||
|
`, strings.Join(toolDefs, "\n"))
|
||||||
|
}
|
||||||
|
|
||||||
|
return a.systemPrompt + toolsSection
|
||||||
|
}
|
||||||
|
|
||||||
|
// buildConversationPrompt builds the conversation history as a prompt
|
||||||
|
func (a *Agent) buildConversationPrompt(session *Session) string {
|
||||||
|
messages := session.GetMessages()
|
||||||
|
var parts []string
|
||||||
|
|
||||||
|
for _, msg := range messages {
|
||||||
|
parts = append(parts, fmt.Sprintf("%s: %s", strings.Title(msg.Role), msg.Content))
|
||||||
|
}
|
||||||
|
|
||||||
|
return strings.Join(parts, "\n\n")
|
||||||
|
}
|
||||||
|
|
||||||
|
// parseResponse extracts tool calls and text from AI response
|
||||||
|
func (a *Agent) parseResponse(response string) ([]ToolCall, string, error) {
|
||||||
|
// Try to find JSON tool calls in response
|
||||||
|
// Look for {"tool_calls": [...]} pattern
|
||||||
|
|
||||||
|
var toolCalls []ToolCall
|
||||||
|
textResponse := response
|
||||||
|
|
||||||
|
// Try to parse as JSON
|
||||||
|
if strings.Contains(response, "tool_calls") {
|
||||||
|
// Find JSON block
|
||||||
|
start := strings.Index(response, "{")
|
||||||
|
end := strings.LastIndex(response, "}")
|
||||||
|
|
||||||
|
if start >= 0 && end > start {
|
||||||
|
jsonStr := response[start : end+1]
|
||||||
|
|
||||||
|
var parsed struct {
|
||||||
|
ToolCalls []struct {
|
||||||
|
Name string `json:"name"`
|
||||||
|
Arguments json.RawMessage `json:"arguments"`
|
||||||
|
} `json:"tool_calls"`
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := json.Unmarshal([]byte(jsonStr), &parsed); err == nil {
|
||||||
|
for _, tc := range parsed.ToolCalls {
|
||||||
|
toolCalls = append(toolCalls, ToolCall{
|
||||||
|
Name: tc.Name,
|
||||||
|
Arguments: tc.Arguments,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// Extract text before/after JSON
|
||||||
|
textResponse = strings.TrimSpace(response[:start] + response[end+1:])
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return toolCalls, textResponse, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// executeToolCalls runs the requested tools
|
||||||
|
func (a *Agent) executeToolCalls(ctx context.Context, calls []ToolCall) []ToolResult {
|
||||||
|
a.toolsLock.RLock()
|
||||||
|
defer a.toolsLock.RUnlock()
|
||||||
|
|
||||||
|
var results []ToolResult
|
||||||
|
|
||||||
|
for _, call := range calls {
|
||||||
|
tool, ok := a.tools[call.Name]
|
||||||
|
if !ok {
|
||||||
|
results = append(results, ToolResult{
|
||||||
|
Name: call.Name,
|
||||||
|
Error: fmt.Sprintf("unknown tool: %s", call.Name),
|
||||||
|
})
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
logger.Infof("🔧 Executing tool: %s", call.Name)
|
||||||
|
|
||||||
|
result, err := tool.Execute(ctx, call.Arguments)
|
||||||
|
if err != nil {
|
||||||
|
logger.Errorf("❌ Tool %s failed: %v", call.Name, err)
|
||||||
|
results = append(results, ToolResult{
|
||||||
|
Name: call.Name,
|
||||||
|
Error: err.Error(),
|
||||||
|
})
|
||||||
|
} else {
|
||||||
|
logger.Infof("✅ Tool %s completed", call.Name)
|
||||||
|
results = append(results, ToolResult{
|
||||||
|
Name: call.Name,
|
||||||
|
Result: result,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return results
|
||||||
|
}
|
||||||
|
|
||||||
|
// ToolCall represents a tool invocation request from the AI
|
||||||
|
type ToolCall struct {
|
||||||
|
Name string `json:"name"`
|
||||||
|
Arguments json.RawMessage `json:"arguments"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// ToolResult represents the result of a tool execution
|
||||||
|
type ToolResult struct {
|
||||||
|
Name string `json:"name"`
|
||||||
|
Result interface{} `json:"result,omitempty"`
|
||||||
|
Error string `json:"error,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// AgentResponse is the final response from the agent
|
||||||
|
type AgentResponse struct {
|
||||||
|
Text string `json:"text"`
|
||||||
|
SessionID string `json:"session_id"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func formatToolCalls(calls []ToolCall) string {
|
||||||
|
var parts []string
|
||||||
|
for _, c := range calls {
|
||||||
|
parts = append(parts, fmt.Sprintf("- %s(%s)", c.Name, string(c.Arguments)))
|
||||||
|
}
|
||||||
|
return strings.Join(parts, "\n")
|
||||||
|
}
|
||||||
|
|
||||||
|
func formatToolResults(results []ToolResult) string {
|
||||||
|
var parts []string
|
||||||
|
for _, r := range results {
|
||||||
|
if r.Error != "" {
|
||||||
|
parts = append(parts, fmt.Sprintf("- %s: ERROR: %s", r.Name, r.Error))
|
||||||
|
} else {
|
||||||
|
resultJSON, _ := json.Marshal(r.Result)
|
||||||
|
parts = append(parts, fmt.Sprintf("- %s: %s", r.Name, string(resultJSON)))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return strings.Join(parts, "\n")
|
||||||
|
}
|
||||||
312
assistant/context.go
Normal file
312
assistant/context.go
Normal file
@@ -0,0 +1,312 @@
|
|||||||
|
// Package assistant - Trading Context Builder
|
||||||
|
// Automatically enriches AI prompts with real-time market and portfolio data
|
||||||
|
package assistant
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
"nofx/manager"
|
||||||
|
"nofx/store"
|
||||||
|
"strings"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
// TradingContext holds real-time trading context for AI decision making
|
||||||
|
type TradingContext struct {
|
||||||
|
// Portfolio state
|
||||||
|
TotalEquity float64 `json:"total_equity"`
|
||||||
|
AvailableBalance float64 `json:"available_balance"`
|
||||||
|
UnrealizedPnL float64 `json:"unrealized_pnl"`
|
||||||
|
Positions []PositionSummary `json:"positions"`
|
||||||
|
|
||||||
|
// Market data
|
||||||
|
MarketPrices map[string]float64 `json:"market_prices"`
|
||||||
|
PriceChanges24h map[string]float64 `json:"price_changes_24h"`
|
||||||
|
|
||||||
|
// Trader states
|
||||||
|
ActiveTraders []TraderSummary `json:"active_traders"`
|
||||||
|
|
||||||
|
// Alerts
|
||||||
|
Alerts []Alert `json:"alerts"`
|
||||||
|
|
||||||
|
// Timestamp
|
||||||
|
UpdatedAt time.Time `json:"updated_at"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// PositionSummary summarizes a position
|
||||||
|
type PositionSummary struct {
|
||||||
|
Symbol string `json:"symbol"`
|
||||||
|
Side string `json:"side"` // "long" or "short"
|
||||||
|
Size float64 `json:"size"`
|
||||||
|
EntryPrice float64 `json:"entry_price"`
|
||||||
|
MarkPrice float64 `json:"mark_price"`
|
||||||
|
UnrealizedPnL float64 `json:"unrealized_pnl"`
|
||||||
|
PnLPercent float64 `json:"pnl_percent"`
|
||||||
|
Leverage int `json:"leverage"`
|
||||||
|
LiquidationPrice float64 `json:"liquidation_price,omitempty"`
|
||||||
|
TraderID string `json:"trader_id"`
|
||||||
|
TraderName string `json:"trader_name"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// TraderSummary summarizes a trader's state
|
||||||
|
type TraderSummary struct {
|
||||||
|
ID string `json:"id"`
|
||||||
|
Name string `json:"name"`
|
||||||
|
Exchange string `json:"exchange"`
|
||||||
|
IsRunning bool `json:"is_running"`
|
||||||
|
Equity float64 `json:"equity"`
|
||||||
|
PositionCount int `json:"position_count"`
|
||||||
|
TodayPnL float64 `json:"today_pnl,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// Alert represents a trading alert
|
||||||
|
type Alert struct {
|
||||||
|
Level string `json:"level"` // "info", "warning", "danger"
|
||||||
|
Type string `json:"type"` // "liquidation_risk", "large_loss", "price_alert", etc.
|
||||||
|
Message string `json:"message"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// ContextBuilder builds trading context for AI
|
||||||
|
type ContextBuilder struct {
|
||||||
|
traderManager *manager.TraderManager
|
||||||
|
store *store.Store
|
||||||
|
}
|
||||||
|
|
||||||
|
// NewContextBuilder creates a context builder
|
||||||
|
func NewContextBuilder(tm *manager.TraderManager, st *store.Store) *ContextBuilder {
|
||||||
|
return &ContextBuilder{
|
||||||
|
traderManager: tm,
|
||||||
|
store: st,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// BuildContext builds current trading context
|
||||||
|
func (cb *ContextBuilder) BuildContext() *TradingContext {
|
||||||
|
ctx := &TradingContext{
|
||||||
|
MarketPrices: make(map[string]float64),
|
||||||
|
PriceChanges24h: make(map[string]float64),
|
||||||
|
UpdatedAt: time.Now(),
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get all traders
|
||||||
|
allTraders := cb.traderManager.GetAllTraders()
|
||||||
|
|
||||||
|
for id, trader := range allTraders {
|
||||||
|
summary := TraderSummary{
|
||||||
|
ID: id,
|
||||||
|
Name: trader.GetName(),
|
||||||
|
Exchange: trader.GetExchange(),
|
||||||
|
IsRunning: true, // If in map, it's running
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get account info
|
||||||
|
if accountInfo, err := trader.GetAccountInfo(); err == nil {
|
||||||
|
if equity, ok := accountInfo["total_equity"].(float64); ok {
|
||||||
|
summary.Equity = equity
|
||||||
|
ctx.TotalEquity += equity
|
||||||
|
}
|
||||||
|
if available, ok := accountInfo["available_balance"].(float64); ok {
|
||||||
|
ctx.AvailableBalance += available
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get positions
|
||||||
|
if positions, err := trader.GetPositions(); err == nil {
|
||||||
|
summary.PositionCount = len(positions)
|
||||||
|
|
||||||
|
for _, pos := range positions {
|
||||||
|
posSummary := cb.parsePosition(pos, id, trader.GetName())
|
||||||
|
if posSummary != nil {
|
||||||
|
ctx.Positions = append(ctx.Positions, *posSummary)
|
||||||
|
ctx.UnrealizedPnL += posSummary.UnrealizedPnL
|
||||||
|
|
||||||
|
// Track market prices
|
||||||
|
ctx.MarketPrices[posSummary.Symbol] = posSummary.MarkPrice
|
||||||
|
|
||||||
|
// Check for alerts
|
||||||
|
cb.checkPositionAlerts(ctx, posSummary)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
ctx.ActiveTraders = append(ctx.ActiveTraders, summary)
|
||||||
|
}
|
||||||
|
|
||||||
|
return ctx
|
||||||
|
}
|
||||||
|
|
||||||
|
// parsePosition parses position data into summary
|
||||||
|
func (cb *ContextBuilder) parsePosition(pos map[string]interface{}, traderID, traderName string) *PositionSummary {
|
||||||
|
summary := &PositionSummary{
|
||||||
|
TraderID: traderID,
|
||||||
|
TraderName: traderName,
|
||||||
|
}
|
||||||
|
|
||||||
|
if symbol, ok := pos["symbol"].(string); ok {
|
||||||
|
summary.Symbol = symbol
|
||||||
|
}
|
||||||
|
if side, ok := pos["side"].(string); ok {
|
||||||
|
summary.Side = strings.ToLower(side)
|
||||||
|
}
|
||||||
|
if size, ok := pos["size"].(float64); ok {
|
||||||
|
summary.Size = size
|
||||||
|
}
|
||||||
|
if entry, ok := pos["entry_price"].(float64); ok {
|
||||||
|
summary.EntryPrice = entry
|
||||||
|
}
|
||||||
|
if mark, ok := pos["mark_price"].(float64); ok {
|
||||||
|
summary.MarkPrice = mark
|
||||||
|
}
|
||||||
|
if pnl, ok := pos["unrealized_pnl"].(float64); ok {
|
||||||
|
summary.UnrealizedPnL = pnl
|
||||||
|
}
|
||||||
|
if lev, ok := pos["leverage"].(int); ok {
|
||||||
|
summary.Leverage = lev
|
||||||
|
}
|
||||||
|
if liq, ok := pos["liquidation_price"].(float64); ok {
|
||||||
|
summary.LiquidationPrice = liq
|
||||||
|
}
|
||||||
|
|
||||||
|
// Calculate PnL percent
|
||||||
|
if summary.EntryPrice > 0 && summary.Size > 0 {
|
||||||
|
if summary.Side == "long" {
|
||||||
|
summary.PnLPercent = ((summary.MarkPrice - summary.EntryPrice) / summary.EntryPrice) * 100 * float64(summary.Leverage)
|
||||||
|
} else {
|
||||||
|
summary.PnLPercent = ((summary.EntryPrice - summary.MarkPrice) / summary.EntryPrice) * 100 * float64(summary.Leverage)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return summary
|
||||||
|
}
|
||||||
|
|
||||||
|
// checkPositionAlerts checks for position-related alerts
|
||||||
|
func (cb *ContextBuilder) checkPositionAlerts(ctx *TradingContext, pos *PositionSummary) {
|
||||||
|
// Liquidation risk alert
|
||||||
|
if pos.LiquidationPrice > 0 && pos.MarkPrice > 0 {
|
||||||
|
var distancePercent float64
|
||||||
|
if pos.Side == "long" {
|
||||||
|
distancePercent = ((pos.MarkPrice - pos.LiquidationPrice) / pos.MarkPrice) * 100
|
||||||
|
} else {
|
||||||
|
distancePercent = ((pos.LiquidationPrice - pos.MarkPrice) / pos.MarkPrice) * 100
|
||||||
|
}
|
||||||
|
|
||||||
|
if distancePercent < 5 {
|
||||||
|
ctx.Alerts = append(ctx.Alerts, Alert{
|
||||||
|
Level: "danger",
|
||||||
|
Type: "liquidation_risk",
|
||||||
|
Message: fmt.Sprintf("⚠️ %s %s仓位距离强平仅 %.1f%%!", pos.Symbol, pos.Side, distancePercent),
|
||||||
|
})
|
||||||
|
} else if distancePercent < 10 {
|
||||||
|
ctx.Alerts = append(ctx.Alerts, Alert{
|
||||||
|
Level: "warning",
|
||||||
|
Type: "liquidation_risk",
|
||||||
|
Message: fmt.Sprintf("⚡ %s %s仓位距离强平 %.1f%%,注意风险", pos.Symbol, pos.Side, distancePercent),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Large loss alert
|
||||||
|
if pos.PnLPercent < -20 {
|
||||||
|
ctx.Alerts = append(ctx.Alerts, Alert{
|
||||||
|
Level: "danger",
|
||||||
|
Type: "large_loss",
|
||||||
|
Message: fmt.Sprintf("📉 %s %s仓位亏损 %.1f%%,考虑止损", pos.Symbol, pos.Side, pos.PnLPercent),
|
||||||
|
})
|
||||||
|
} else if pos.PnLPercent < -10 {
|
||||||
|
ctx.Alerts = append(ctx.Alerts, Alert{
|
||||||
|
Level: "warning",
|
||||||
|
Type: "large_loss",
|
||||||
|
Message: fmt.Sprintf("📉 %s %s仓位亏损 %.1f%%", pos.Symbol, pos.Side, pos.PnLPercent),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// Large profit - consider taking profit
|
||||||
|
if pos.PnLPercent > 50 {
|
||||||
|
ctx.Alerts = append(ctx.Alerts, Alert{
|
||||||
|
Level: "info",
|
||||||
|
Type: "large_profit",
|
||||||
|
Message: fmt.Sprintf("📈 %s %s仓位盈利 %.1f%%,考虑部分止盈", pos.Symbol, pos.Side, pos.PnLPercent),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// FormatContextForPrompt formats context as text for AI prompt injection
|
||||||
|
func (ctx *TradingContext) FormatContextForPrompt() string {
|
||||||
|
var sb strings.Builder
|
||||||
|
|
||||||
|
sb.WriteString("\n\n---\n## 📊 当前交易状态 (实时)\n\n")
|
||||||
|
|
||||||
|
// Portfolio summary
|
||||||
|
sb.WriteString(fmt.Sprintf("**总权益:** $%.2f | **可用余额:** $%.2f | **未实现盈亏:** $%.2f\n\n",
|
||||||
|
ctx.TotalEquity, ctx.AvailableBalance, ctx.UnrealizedPnL))
|
||||||
|
|
||||||
|
// Alerts (high priority)
|
||||||
|
if len(ctx.Alerts) > 0 {
|
||||||
|
sb.WriteString("### ⚠️ 警报\n")
|
||||||
|
for _, alert := range ctx.Alerts {
|
||||||
|
sb.WriteString(fmt.Sprintf("- %s\n", alert.Message))
|
||||||
|
}
|
||||||
|
sb.WriteString("\n")
|
||||||
|
}
|
||||||
|
|
||||||
|
// Active positions
|
||||||
|
if len(ctx.Positions) > 0 {
|
||||||
|
sb.WriteString("### 📈 持仓\n")
|
||||||
|
sb.WriteString("| 交易对 | 方向 | 数量 | 入场价 | 现价 | 盈亏 | 盈亏% | 杠杆 | 交易员 |\n")
|
||||||
|
sb.WriteString("|--------|------|------|--------|------|------|-------|------|--------|\n")
|
||||||
|
for _, pos := range ctx.Positions {
|
||||||
|
pnlEmoji := "🟢"
|
||||||
|
if pos.UnrealizedPnL < 0 {
|
||||||
|
pnlEmoji = "🔴"
|
||||||
|
}
|
||||||
|
sb.WriteString(fmt.Sprintf("| %s | %s | %.4f | %.2f | %.2f | %s$%.2f | %.1f%% | %dx | %s |\n",
|
||||||
|
pos.Symbol, pos.Side, pos.Size, pos.EntryPrice, pos.MarkPrice,
|
||||||
|
pnlEmoji, pos.UnrealizedPnL, pos.PnLPercent, pos.Leverage, pos.TraderName))
|
||||||
|
}
|
||||||
|
sb.WriteString("\n")
|
||||||
|
} else {
|
||||||
|
sb.WriteString("### 📈 持仓\n无持仓\n\n")
|
||||||
|
}
|
||||||
|
|
||||||
|
// Active traders
|
||||||
|
if len(ctx.ActiveTraders) > 0 {
|
||||||
|
sb.WriteString("### 🤖 运行中的交易员\n")
|
||||||
|
for _, t := range ctx.ActiveTraders {
|
||||||
|
status := "✅ 运行中"
|
||||||
|
if !t.IsRunning {
|
||||||
|
status = "❌ 已停止"
|
||||||
|
}
|
||||||
|
sb.WriteString(fmt.Sprintf("- **%s** (%s) %s | 权益: $%.2f | 持仓: %d\n",
|
||||||
|
t.Name, t.Exchange, status, t.Equity, t.PositionCount))
|
||||||
|
}
|
||||||
|
sb.WriteString("\n")
|
||||||
|
}
|
||||||
|
|
||||||
|
sb.WriteString(fmt.Sprintf("*数据更新时间: %s*\n---\n", ctx.UpdatedAt.Format("2006-01-02 15:04:05")))
|
||||||
|
|
||||||
|
return sb.String()
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetTopSymbols returns symbols with positions for market data queries
|
||||||
|
func (ctx *TradingContext) GetTopSymbols() []string {
|
||||||
|
symbolSet := make(map[string]bool)
|
||||||
|
for _, pos := range ctx.Positions {
|
||||||
|
symbolSet[pos.Symbol] = true
|
||||||
|
}
|
||||||
|
|
||||||
|
// Always include major pairs
|
||||||
|
symbolSet["BTCUSDT"] = true
|
||||||
|
symbolSet["ETHUSDT"] = true
|
||||||
|
|
||||||
|
symbols := make([]string, 0, len(symbolSet))
|
||||||
|
for s := range symbolSet {
|
||||||
|
symbols = append(symbols, s)
|
||||||
|
}
|
||||||
|
return symbols
|
||||||
|
}
|
||||||
|
|
||||||
|
// EnrichWithMarketData adds market data to context
|
||||||
|
// Note: Market prices are already populated from position data
|
||||||
|
func (cb *ContextBuilder) EnrichWithMarketData(ctx *TradingContext, symbols []string) {
|
||||||
|
// Market prices are populated from position mark prices
|
||||||
|
// Additional market data enrichment can be added here in the future
|
||||||
|
}
|
||||||
200
assistant/monitor.go
Normal file
200
assistant/monitor.go
Normal file
@@ -0,0 +1,200 @@
|
|||||||
|
package assistant
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
"nofx/logger"
|
||||||
|
"nofx/manager"
|
||||||
|
"nofx/store"
|
||||||
|
"sync"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Monitor provides proactive monitoring and alerts
|
||||||
|
type Monitor struct {
|
||||||
|
traderManager *manager.TraderManager
|
||||||
|
store *store.Store
|
||||||
|
contextBuilder *ContextBuilder
|
||||||
|
|
||||||
|
// Alert callbacks
|
||||||
|
alertCallbacks []func(Alert)
|
||||||
|
callbackMu sync.RWMutex
|
||||||
|
|
||||||
|
// State
|
||||||
|
running bool
|
||||||
|
stopChan chan struct{}
|
||||||
|
interval time.Duration
|
||||||
|
|
||||||
|
// Last known state for change detection
|
||||||
|
lastPositions map[string]PositionSummary
|
||||||
|
lastAlerts map[string]time.Time // Prevent alert spam
|
||||||
|
mu sync.RWMutex
|
||||||
|
}
|
||||||
|
|
||||||
|
// NewMonitor creates a new trading monitor
|
||||||
|
func NewMonitor(tm *manager.TraderManager, st *store.Store) *Monitor {
|
||||||
|
return &Monitor{
|
||||||
|
traderManager: tm,
|
||||||
|
store: st,
|
||||||
|
contextBuilder: NewContextBuilder(tm, st),
|
||||||
|
stopChan: make(chan struct{}),
|
||||||
|
interval: 30 * time.Second, // Check every 30 seconds
|
||||||
|
lastPositions: make(map[string]PositionSummary),
|
||||||
|
lastAlerts: make(map[string]time.Time),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// OnAlert registers an alert callback
|
||||||
|
func (m *Monitor) OnAlert(callback func(Alert)) {
|
||||||
|
m.callbackMu.Lock()
|
||||||
|
defer m.callbackMu.Unlock()
|
||||||
|
m.alertCallbacks = append(m.alertCallbacks, callback)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Start starts the monitor
|
||||||
|
func (m *Monitor) Start() {
|
||||||
|
m.mu.Lock()
|
||||||
|
if m.running {
|
||||||
|
m.mu.Unlock()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
m.running = true
|
||||||
|
m.stopChan = make(chan struct{})
|
||||||
|
m.mu.Unlock()
|
||||||
|
|
||||||
|
logger.Info("🔍 Starting trading monitor...")
|
||||||
|
|
||||||
|
go m.monitorLoop()
|
||||||
|
}
|
||||||
|
|
||||||
|
// Stop stops the monitor
|
||||||
|
func (m *Monitor) Stop() {
|
||||||
|
m.mu.Lock()
|
||||||
|
defer m.mu.Unlock()
|
||||||
|
|
||||||
|
if !m.running {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
m.running = false
|
||||||
|
close(m.stopChan)
|
||||||
|
logger.Info("🔍 Trading monitor stopped")
|
||||||
|
}
|
||||||
|
|
||||||
|
// monitorLoop is the main monitoring loop
|
||||||
|
func (m *Monitor) monitorLoop() {
|
||||||
|
ticker := time.NewTicker(m.interval)
|
||||||
|
defer ticker.Stop()
|
||||||
|
|
||||||
|
// Initial check
|
||||||
|
m.checkAndAlert()
|
||||||
|
|
||||||
|
for {
|
||||||
|
select {
|
||||||
|
case <-ticker.C:
|
||||||
|
m.checkAndAlert()
|
||||||
|
case <-m.stopChan:
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// checkAndAlert checks positions and sends alerts
|
||||||
|
func (m *Monitor) checkAndAlert() {
|
||||||
|
ctx := m.contextBuilder.BuildContext()
|
||||||
|
|
||||||
|
// Process built-in alerts from context
|
||||||
|
for _, alert := range ctx.Alerts {
|
||||||
|
m.sendAlertIfNew(alert)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check for position changes
|
||||||
|
m.checkPositionChanges(ctx)
|
||||||
|
|
||||||
|
// Check for new large movements
|
||||||
|
m.checkMarketMovements(ctx)
|
||||||
|
}
|
||||||
|
|
||||||
|
// checkPositionChanges detects significant position changes
|
||||||
|
func (m *Monitor) checkPositionChanges(ctx *TradingContext) {
|
||||||
|
m.mu.Lock()
|
||||||
|
defer m.mu.Unlock()
|
||||||
|
|
||||||
|
currentPositions := make(map[string]PositionSummary)
|
||||||
|
|
||||||
|
for _, pos := range ctx.Positions {
|
||||||
|
key := fmt.Sprintf("%s_%s_%s", pos.TraderID, pos.Symbol, pos.Side)
|
||||||
|
currentPositions[key] = pos
|
||||||
|
|
||||||
|
// Check if this is a new position
|
||||||
|
if _, existed := m.lastPositions[key]; !existed {
|
||||||
|
m.sendAlert(Alert{
|
||||||
|
Level: "info",
|
||||||
|
Type: "new_position",
|
||||||
|
Message: fmt.Sprintf("📍 新开仓位: %s %s %.4f @ %.2f (%dx)",
|
||||||
|
pos.Symbol, pos.Side, pos.Size, pos.EntryPrice, pos.Leverage),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check for closed positions
|
||||||
|
for key, oldPos := range m.lastPositions {
|
||||||
|
if _, exists := currentPositions[key]; !exists {
|
||||||
|
m.sendAlert(Alert{
|
||||||
|
Level: "info",
|
||||||
|
Type: "position_closed",
|
||||||
|
Message: fmt.Sprintf("📍 仓位已平: %s %s (入场价: %.2f)",
|
||||||
|
oldPos.Symbol, oldPos.Side, oldPos.EntryPrice),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
m.lastPositions = currentPositions
|
||||||
|
}
|
||||||
|
|
||||||
|
// checkMarketMovements checks for significant market movements
|
||||||
|
func (m *Monitor) checkMarketMovements(ctx *TradingContext) {
|
||||||
|
// This could be expanded to check price movements
|
||||||
|
// For now, we rely on the context builder's alerts
|
||||||
|
}
|
||||||
|
|
||||||
|
// sendAlertIfNew sends an alert only if it's new (avoid spam)
|
||||||
|
func (m *Monitor) sendAlertIfNew(alert Alert) {
|
||||||
|
m.mu.Lock()
|
||||||
|
defer m.mu.Unlock()
|
||||||
|
|
||||||
|
key := fmt.Sprintf("%s_%s", alert.Type, alert.Message)
|
||||||
|
|
||||||
|
// Check if we sent this alert recently (within 5 minutes)
|
||||||
|
if lastSent, ok := m.lastAlerts[key]; ok {
|
||||||
|
if time.Since(lastSent) < 5*time.Minute {
|
||||||
|
return // Skip, already sent recently
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
m.lastAlerts[key] = time.Now()
|
||||||
|
m.sendAlert(alert)
|
||||||
|
}
|
||||||
|
|
||||||
|
// sendAlert sends alert to all registered callbacks
|
||||||
|
func (m *Monitor) sendAlert(alert Alert) {
|
||||||
|
m.callbackMu.RLock()
|
||||||
|
callbacks := make([]func(Alert), len(m.alertCallbacks))
|
||||||
|
copy(callbacks, m.alertCallbacks)
|
||||||
|
m.callbackMu.RUnlock()
|
||||||
|
|
||||||
|
for _, cb := range callbacks {
|
||||||
|
go cb(alert)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetCurrentContext returns the current trading context
|
||||||
|
func (m *Monitor) GetCurrentContext() *TradingContext {
|
||||||
|
return m.contextBuilder.BuildContext()
|
||||||
|
}
|
||||||
|
|
||||||
|
// SetInterval sets the monitoring interval
|
||||||
|
func (m *Monitor) SetInterval(d time.Duration) {
|
||||||
|
m.mu.Lock()
|
||||||
|
defer m.mu.Unlock()
|
||||||
|
m.interval = d
|
||||||
|
}
|
||||||
117
assistant/prompts.go
Normal file
117
assistant/prompts.go
Normal file
@@ -0,0 +1,117 @@
|
|||||||
|
package assistant
|
||||||
|
|
||||||
|
// DefaultTradingSystemPrompt returns the default system prompt for trading assistant
|
||||||
|
func DefaultTradingSystemPrompt() string {
|
||||||
|
return `# NOFX Trading Assistant
|
||||||
|
|
||||||
|
You are an expert AI trading assistant powered by NOFX - an advanced AI-powered trading system.
|
||||||
|
|
||||||
|
## Your Capabilities
|
||||||
|
|
||||||
|
1. **Account Management**
|
||||||
|
- Check balances across multiple exchanges
|
||||||
|
- View current positions and P&L
|
||||||
|
- Monitor portfolio performance
|
||||||
|
|
||||||
|
2. **Trading Operations**
|
||||||
|
- Execute trades (open/close positions)
|
||||||
|
- Manage stop-loss and take-profit orders
|
||||||
|
- Adjust leverage and margin settings
|
||||||
|
|
||||||
|
3. **AI Traders Management**
|
||||||
|
- Start/stop AI traders
|
||||||
|
- Monitor AI trader performance
|
||||||
|
- Configure trading strategies
|
||||||
|
|
||||||
|
4. **Strategy & Analysis**
|
||||||
|
- Create and modify trading strategies
|
||||||
|
- Initiate AI debate sessions for market analysis
|
||||||
|
- Backtest strategies on historical data
|
||||||
|
|
||||||
|
5. **Market Intelligence**
|
||||||
|
- Get real-time prices and market data
|
||||||
|
- Analyze market conditions
|
||||||
|
- Track open interest and funding rates
|
||||||
|
|
||||||
|
## Guidelines
|
||||||
|
|
||||||
|
1. **Safety First**: Always confirm with the user before executing trades or making significant changes
|
||||||
|
2. **Be Precise**: When dealing with numbers, be exact - trading involves real money
|
||||||
|
3. **Explain Reasoning**: Help users understand your analysis and recommendations
|
||||||
|
4. **Risk Awareness**: Always remind users about the risks involved in trading
|
||||||
|
5. **Proactive Monitoring**: Alert users to important position changes or market movements
|
||||||
|
|
||||||
|
## Response Style
|
||||||
|
|
||||||
|
- Be concise but thorough
|
||||||
|
- Use tables for data when appropriate
|
||||||
|
- Include relevant metrics (P&L, ROI, etc.)
|
||||||
|
- Provide actionable insights, not just data dumps
|
||||||
|
- Support both English and Chinese (respond in the user's language)
|
||||||
|
|
||||||
|
## Important Notes
|
||||||
|
|
||||||
|
- Never share API keys or sensitive credentials
|
||||||
|
- Always use proper position sizing based on user's risk tolerance
|
||||||
|
- Warn users about high-risk operations (high leverage, large positions)
|
||||||
|
|
||||||
|
Remember: You are a professional trading assistant. Users trust you with their trading operations. Be accurate, be helpful, and be responsible.`
|
||||||
|
}
|
||||||
|
|
||||||
|
// ChineseSystemPrompt returns Chinese version of the system prompt
|
||||||
|
func ChineseSystemPrompt() string {
|
||||||
|
return `# NOFX 交易助手
|
||||||
|
|
||||||
|
你是一个由 NOFX 驱动的专业 AI 交易助手 - 一个先进的 AI 驱动交易系统。
|
||||||
|
|
||||||
|
## 你的能力
|
||||||
|
|
||||||
|
1. **账户管理**
|
||||||
|
- 查询多交易所余额
|
||||||
|
- 查看当前持仓和盈亏
|
||||||
|
- 监控投资组合表现
|
||||||
|
|
||||||
|
2. **交易操作**
|
||||||
|
- 执行交易(开仓/平仓)
|
||||||
|
- 管理止损止盈订单
|
||||||
|
- 调整杠杆和保证金设置
|
||||||
|
|
||||||
|
3. **AI 交易员管理**
|
||||||
|
- 启动/停止 AI 交易员
|
||||||
|
- 监控 AI 交易员表现
|
||||||
|
- 配置交易策略
|
||||||
|
|
||||||
|
4. **策略与分析**
|
||||||
|
- 创建和修改交易策略
|
||||||
|
- 发起 AI 辩论会议进行市场分析
|
||||||
|
- 回测历史数据
|
||||||
|
|
||||||
|
5. **市场情报**
|
||||||
|
- 获取实时价格和市场数据
|
||||||
|
- 分析市场状况
|
||||||
|
- 跟踪持仓量和资金费率
|
||||||
|
|
||||||
|
## 行为准则
|
||||||
|
|
||||||
|
1. **安全第一**:执行交易或重大操作前,务必与用户确认
|
||||||
|
2. **精确无误**:涉及数字时必须精确 - 交易涉及真金白银
|
||||||
|
3. **解释逻辑**:帮助用户理解你的分析和建议
|
||||||
|
4. **风险意识**:始终提醒用户交易风险
|
||||||
|
5. **主动监控**:及时提醒用户重要的仓位变化或市场波动
|
||||||
|
|
||||||
|
## 回复风格
|
||||||
|
|
||||||
|
- 简洁但全面
|
||||||
|
- 适当使用表格展示数据
|
||||||
|
- 包含相关指标(盈亏、收益率等)
|
||||||
|
- 提供可操作的见解,而非单纯的数据罗列
|
||||||
|
- 支持中英文(根据用户使用的语言回复)
|
||||||
|
|
||||||
|
## 重要提示
|
||||||
|
|
||||||
|
- 永远不要分享 API 密钥或敏感凭证
|
||||||
|
- 根据用户的风险承受能力进行合理的仓位管理
|
||||||
|
- 对高风险操作(高杠杆、大仓位)发出警告
|
||||||
|
|
||||||
|
记住:你是专业的交易助手。用户将交易操作托付于你。准确、有用、负责。`
|
||||||
|
}
|
||||||
122
assistant/session.go
Normal file
122
assistant/session.go
Normal file
@@ -0,0 +1,122 @@
|
|||||||
|
package assistant
|
||||||
|
|
||||||
|
import (
|
||||||
|
"sync"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Message represents a single message in conversation
|
||||||
|
type Message struct {
|
||||||
|
Role string `json:"role"` // "user", "assistant", "system", "tool"
|
||||||
|
Content string `json:"content"`
|
||||||
|
Timestamp time.Time `json:"timestamp"`
|
||||||
|
|
||||||
|
// For tool messages
|
||||||
|
ToolName string `json:"tool_name,omitempty"`
|
||||||
|
ToolResult interface{} `json:"tool_result,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// Session represents a conversation session with memory
|
||||||
|
type Session struct {
|
||||||
|
ID string `json:"id"`
|
||||||
|
CreatedAt time.Time `json:"created_at"`
|
||||||
|
UpdatedAt time.Time `json:"updated_at"`
|
||||||
|
|
||||||
|
// User info
|
||||||
|
UserID string `json:"user_id"`
|
||||||
|
UserName string `json:"user_name"`
|
||||||
|
Platform string `json:"platform"` // "telegram", "web", etc.
|
||||||
|
|
||||||
|
// Conversation history
|
||||||
|
messages []Message
|
||||||
|
maxMessages int
|
||||||
|
mu sync.RWMutex
|
||||||
|
|
||||||
|
// Custom metadata
|
||||||
|
Metadata map[string]interface{} `json:"metadata"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// NewSession creates a new conversation session
|
||||||
|
func NewSession(id string, maxMessages int) *Session {
|
||||||
|
return &Session{
|
||||||
|
ID: id,
|
||||||
|
CreatedAt: time.Now(),
|
||||||
|
UpdatedAt: time.Now(),
|
||||||
|
messages: make([]Message, 0),
|
||||||
|
maxMessages: maxMessages,
|
||||||
|
Metadata: make(map[string]interface{}),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// AddMessage adds a message to the session
|
||||||
|
func (s *Session) AddMessage(msg Message) {
|
||||||
|
s.mu.Lock()
|
||||||
|
defer s.mu.Unlock()
|
||||||
|
|
||||||
|
s.messages = append(s.messages, msg)
|
||||||
|
s.UpdatedAt = time.Now()
|
||||||
|
|
||||||
|
// Trim old messages if exceeding max
|
||||||
|
if len(s.messages) > s.maxMessages {
|
||||||
|
// Keep the most recent messages
|
||||||
|
s.messages = s.messages[len(s.messages)-s.maxMessages:]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetMessages returns a copy of all messages
|
||||||
|
func (s *Session) GetMessages() []Message {
|
||||||
|
s.mu.RLock()
|
||||||
|
defer s.mu.RUnlock()
|
||||||
|
|
||||||
|
result := make([]Message, len(s.messages))
|
||||||
|
copy(result, s.messages)
|
||||||
|
return result
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetRecentMessages returns the N most recent messages
|
||||||
|
func (s *Session) GetRecentMessages(n int) []Message {
|
||||||
|
s.mu.RLock()
|
||||||
|
defer s.mu.RUnlock()
|
||||||
|
|
||||||
|
if n >= len(s.messages) {
|
||||||
|
result := make([]Message, len(s.messages))
|
||||||
|
copy(result, s.messages)
|
||||||
|
return result
|
||||||
|
}
|
||||||
|
|
||||||
|
result := make([]Message, n)
|
||||||
|
copy(result, s.messages[len(s.messages)-n:])
|
||||||
|
return result
|
||||||
|
}
|
||||||
|
|
||||||
|
// Clear removes all messages from the session
|
||||||
|
func (s *Session) Clear() {
|
||||||
|
s.mu.Lock()
|
||||||
|
defer s.mu.Unlock()
|
||||||
|
s.messages = make([]Message, 0)
|
||||||
|
s.UpdatedAt = time.Now()
|
||||||
|
}
|
||||||
|
|
||||||
|
// SetUserInfo sets user information
|
||||||
|
func (s *Session) SetUserInfo(userID, userName, platform string) {
|
||||||
|
s.mu.Lock()
|
||||||
|
defer s.mu.Unlock()
|
||||||
|
s.UserID = userID
|
||||||
|
s.UserName = userName
|
||||||
|
s.Platform = platform
|
||||||
|
}
|
||||||
|
|
||||||
|
// SetMetadata sets a metadata value
|
||||||
|
func (s *Session) SetMetadata(key string, value interface{}) {
|
||||||
|
s.mu.Lock()
|
||||||
|
defer s.mu.Unlock()
|
||||||
|
s.Metadata[key] = value
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetMetadata gets a metadata value
|
||||||
|
func (s *Session) GetMetadata(key string) (interface{}, bool) {
|
||||||
|
s.mu.RLock()
|
||||||
|
defer s.mu.RUnlock()
|
||||||
|
v, ok := s.Metadata[key]
|
||||||
|
return v, ok
|
||||||
|
}
|
||||||
216
assistant/smart_agent.go
Normal file
216
assistant/smart_agent.go
Normal file
@@ -0,0 +1,216 @@
|
|||||||
|
package assistant
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"fmt"
|
||||||
|
"nofx/logger"
|
||||||
|
"nofx/manager"
|
||||||
|
"nofx/mcp"
|
||||||
|
"nofx/store"
|
||||||
|
"strings"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
// SmartAgent is an enhanced AI agent with trading context awareness
|
||||||
|
type SmartAgent struct {
|
||||||
|
*Agent
|
||||||
|
contextBuilder *ContextBuilder
|
||||||
|
monitor *Monitor
|
||||||
|
|
||||||
|
// Auto-inject context into prompts
|
||||||
|
autoInjectContext bool
|
||||||
|
}
|
||||||
|
|
||||||
|
// NewSmartAgent creates a new smart trading agent
|
||||||
|
func NewSmartAgent(aiClient mcp.AIClient, config AgentConfig, tm *manager.TraderManager, st *store.Store) *SmartAgent {
|
||||||
|
baseAgent := NewAgent(aiClient, config)
|
||||||
|
baseAgent.SetSystemPrompt(SmartTradingPrompt())
|
||||||
|
|
||||||
|
contextBuilder := NewContextBuilder(tm, st)
|
||||||
|
monitor := NewMonitor(tm, st)
|
||||||
|
|
||||||
|
return &SmartAgent{
|
||||||
|
Agent: baseAgent,
|
||||||
|
contextBuilder: contextBuilder,
|
||||||
|
monitor: monitor,
|
||||||
|
autoInjectContext: true,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// SetAutoInjectContext enables/disables automatic context injection
|
||||||
|
func (sa *SmartAgent) SetAutoInjectContext(enabled bool) {
|
||||||
|
sa.autoInjectContext = enabled
|
||||||
|
}
|
||||||
|
|
||||||
|
// StartMonitor starts the background monitor
|
||||||
|
func (sa *SmartAgent) StartMonitor() {
|
||||||
|
sa.monitor.Start()
|
||||||
|
}
|
||||||
|
|
||||||
|
// StopMonitor stops the background monitor
|
||||||
|
func (sa *SmartAgent) StopMonitor() {
|
||||||
|
sa.monitor.Stop()
|
||||||
|
}
|
||||||
|
|
||||||
|
// OnAlert registers an alert callback
|
||||||
|
func (sa *SmartAgent) OnAlert(callback func(Alert)) {
|
||||||
|
sa.monitor.OnAlert(callback)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Chat processes a message with smart context injection
|
||||||
|
func (sa *SmartAgent) Chat(ctx context.Context, sessionID string, userMessage string) (*AgentResponse, error) {
|
||||||
|
session := sa.GetSession(sessionID)
|
||||||
|
|
||||||
|
// Add user message to history
|
||||||
|
session.AddMessage(Message{
|
||||||
|
Role: "user",
|
||||||
|
Content: userMessage,
|
||||||
|
Timestamp: time.Now(),
|
||||||
|
})
|
||||||
|
|
||||||
|
// Build system prompt with tools
|
||||||
|
systemPrompt := sa.buildSmartSystemPrompt()
|
||||||
|
|
||||||
|
// Build conversation prompt with context injection
|
||||||
|
conversationPrompt := sa.buildSmartConversationPrompt(session, userMessage)
|
||||||
|
|
||||||
|
// Agent loop
|
||||||
|
var finalResponse string
|
||||||
|
toolCallCount := 0
|
||||||
|
|
||||||
|
for {
|
||||||
|
if ctx.Err() != nil {
|
||||||
|
return nil, ctx.Err()
|
||||||
|
}
|
||||||
|
|
||||||
|
if toolCallCount >= sa.config.MaxToolCalls {
|
||||||
|
logger.Warnf("⚠️ Max tool calls reached (%d)", sa.config.MaxToolCalls)
|
||||||
|
break
|
||||||
|
}
|
||||||
|
|
||||||
|
response, err := sa.aiClient.CallWithMessages(systemPrompt, conversationPrompt)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("AI call failed: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
toolCalls, textResponse, err := sa.parseResponse(response)
|
||||||
|
if err != nil {
|
||||||
|
finalResponse = response
|
||||||
|
break
|
||||||
|
}
|
||||||
|
|
||||||
|
if len(toolCalls) == 0 {
|
||||||
|
finalResponse = textResponse
|
||||||
|
break
|
||||||
|
}
|
||||||
|
|
||||||
|
// Execute tool calls
|
||||||
|
toolResults := sa.executeToolCalls(ctx, toolCalls)
|
||||||
|
toolCallCount += len(toolCalls)
|
||||||
|
|
||||||
|
// Add results to conversation
|
||||||
|
conversationPrompt += fmt.Sprintf("\n\nAssistant called tools:\n%s\n\nTool results:\n%s\n\nBased on the tool results, provide a helpful response:",
|
||||||
|
formatToolCalls(toolCalls),
|
||||||
|
formatToolResults(toolResults))
|
||||||
|
|
||||||
|
if textResponse != "" {
|
||||||
|
finalResponse = textResponse
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Add response to history
|
||||||
|
session.AddMessage(Message{
|
||||||
|
Role: "assistant",
|
||||||
|
Content: finalResponse,
|
||||||
|
Timestamp: time.Now(),
|
||||||
|
})
|
||||||
|
|
||||||
|
return &AgentResponse{
|
||||||
|
Text: finalResponse,
|
||||||
|
SessionID: sessionID,
|
||||||
|
}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// buildSmartSystemPrompt builds system prompt with tools
|
||||||
|
func (sa *SmartAgent) buildSmartSystemPrompt() string {
|
||||||
|
sa.toolsLock.RLock()
|
||||||
|
defer sa.toolsLock.RUnlock()
|
||||||
|
|
||||||
|
var toolDefs []string
|
||||||
|
for _, tool := range sa.tools {
|
||||||
|
toolDef := fmt.Sprintf(`- **%s**: %s
|
||||||
|
Parameters: %s`, tool.Name(), tool.Description(), tool.ParameterSchema())
|
||||||
|
toolDefs = append(toolDefs, toolDef)
|
||||||
|
}
|
||||||
|
|
||||||
|
toolsSection := ""
|
||||||
|
if len(toolDefs) > 0 {
|
||||||
|
toolsSection = fmt.Sprintf(`
|
||||||
|
|
||||||
|
## 可用工具
|
||||||
|
|
||||||
|
调用工具时,使用以下 JSON 格式:
|
||||||
|
{"tool_calls": [{"name": "工具名", "arguments": {"参数": "值"}}]}
|
||||||
|
|
||||||
|
收到工具结果后,用自然语言回复用户。
|
||||||
|
|
||||||
|
可用工具:
|
||||||
|
%s
|
||||||
|
`, strings.Join(toolDefs, "\n"))
|
||||||
|
}
|
||||||
|
|
||||||
|
return sa.systemPrompt + toolsSection
|
||||||
|
}
|
||||||
|
|
||||||
|
// buildSmartConversationPrompt builds conversation with context injection
|
||||||
|
func (sa *SmartAgent) buildSmartConversationPrompt(session *Session, currentMessage string) string {
|
||||||
|
var sb strings.Builder
|
||||||
|
|
||||||
|
// Inject current trading context if enabled
|
||||||
|
if sa.autoInjectContext {
|
||||||
|
tradingCtx := sa.contextBuilder.BuildContext()
|
||||||
|
sb.WriteString(tradingCtx.FormatContextForPrompt())
|
||||||
|
}
|
||||||
|
|
||||||
|
// Add conversation history
|
||||||
|
messages := session.GetMessages()
|
||||||
|
for _, msg := range messages {
|
||||||
|
sb.WriteString(fmt.Sprintf("\n%s: %s\n", strings.Title(msg.Role), msg.Content))
|
||||||
|
}
|
||||||
|
|
||||||
|
return sb.String()
|
||||||
|
}
|
||||||
|
|
||||||
|
// QuickStatus returns a quick status summary
|
||||||
|
func (sa *SmartAgent) QuickStatus() string {
|
||||||
|
ctx := sa.contextBuilder.BuildContext()
|
||||||
|
|
||||||
|
var sb strings.Builder
|
||||||
|
sb.WriteString("📊 **交易状态概览**\n\n")
|
||||||
|
|
||||||
|
sb.WriteString(fmt.Sprintf("💰 总权益: $%.2f\n", ctx.TotalEquity))
|
||||||
|
sb.WriteString(fmt.Sprintf("💵 可用余额: $%.2f\n", ctx.AvailableBalance))
|
||||||
|
|
||||||
|
if ctx.UnrealizedPnL >= 0 {
|
||||||
|
sb.WriteString(fmt.Sprintf("📈 未实现盈亏: 🟢 +$%.2f\n", ctx.UnrealizedPnL))
|
||||||
|
} else {
|
||||||
|
sb.WriteString(fmt.Sprintf("📉 未实现盈亏: 🔴 $%.2f\n", ctx.UnrealizedPnL))
|
||||||
|
}
|
||||||
|
|
||||||
|
sb.WriteString(fmt.Sprintf("📍 持仓数: %d\n", len(ctx.Positions)))
|
||||||
|
sb.WriteString(fmt.Sprintf("🤖 运行交易员: %d\n", len(ctx.ActiveTraders)))
|
||||||
|
|
||||||
|
if len(ctx.Alerts) > 0 {
|
||||||
|
sb.WriteString("\n⚠️ **警报**\n")
|
||||||
|
for _, alert := range ctx.Alerts {
|
||||||
|
sb.WriteString(fmt.Sprintf("- %s\n", alert.Message))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return sb.String()
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetTradingContext returns current trading context
|
||||||
|
func (sa *SmartAgent) GetTradingContext() *TradingContext {
|
||||||
|
return sa.contextBuilder.BuildContext()
|
||||||
|
}
|
||||||
115
assistant/smart_prompts.go
Normal file
115
assistant/smart_prompts.go
Normal file
@@ -0,0 +1,115 @@
|
|||||||
|
package assistant
|
||||||
|
|
||||||
|
import "fmt"
|
||||||
|
|
||||||
|
// SmartTradingPrompt returns an enhanced system prompt with trading intelligence
|
||||||
|
func SmartTradingPrompt() string {
|
||||||
|
return `# 🧠 NOFX 智能交易助手
|
||||||
|
|
||||||
|
你是一个专业的 AI 交易助手,具备以下能力:
|
||||||
|
|
||||||
|
## 核心能力
|
||||||
|
|
||||||
|
### 1. 智能分析
|
||||||
|
- 分析用户意图,理解交易需求
|
||||||
|
- 在执行交易前,主动评估风险
|
||||||
|
- 结合市场数据给出建议
|
||||||
|
|
||||||
|
### 2. 主动提醒
|
||||||
|
- 发现持仓风险时主动警告
|
||||||
|
- 大额亏损时建议止损
|
||||||
|
- 接近强平时紧急提醒
|
||||||
|
|
||||||
|
### 3. 专业建议
|
||||||
|
- 根据仓位情况建议操作
|
||||||
|
- 评估杠杆和仓位大小是否合理
|
||||||
|
- 提供入场/出场时机建议
|
||||||
|
|
||||||
|
## 交易原则
|
||||||
|
|
||||||
|
1. **安全第一**:任何交易操作前必须确认,高风险操作要多次确认
|
||||||
|
2. **风险控制**:
|
||||||
|
- 单笔交易不超过总资金的 10%
|
||||||
|
- 杠杆建议:BTC/ETH ≤10x,山寨币 ≤5x
|
||||||
|
- 发现强平风险立即警告
|
||||||
|
3. **理性决策**:不鼓励情绪化交易,亏损时建议冷静
|
||||||
|
|
||||||
|
## 回复风格
|
||||||
|
|
||||||
|
- 简洁专业,像交易员一样说话
|
||||||
|
- 数据说话,给出具体数字
|
||||||
|
- 风险提示放在显眼位置
|
||||||
|
- 支持中英文,根据用户语言回复
|
||||||
|
|
||||||
|
## 工具使用策略
|
||||||
|
|
||||||
|
当用户问到持仓、余额时:
|
||||||
|
1. 先调用 list_traders 获取交易员列表
|
||||||
|
2. 对运行中的交易员调用 get_balance 和 get_positions
|
||||||
|
3. 汇总数据后清晰展示
|
||||||
|
|
||||||
|
当用户想交易时:
|
||||||
|
1. 先获取当前持仓和余额
|
||||||
|
2. 评估这笔交易的风险
|
||||||
|
3. 明确告知风险后请求确认
|
||||||
|
4. 确认后执行交易
|
||||||
|
|
||||||
|
当用户问市场行情时:
|
||||||
|
1. 获取相关币种价格
|
||||||
|
2. 结合持仓情况分析
|
||||||
|
3. 给出操作建议(但声明不构成投资建议)
|
||||||
|
|
||||||
|
## 重要:响应格式
|
||||||
|
|
||||||
|
- 持仓展示用表格
|
||||||
|
- 重要警告用 ⚠️ 标注
|
||||||
|
- 盈利用 🟢,亏损用 🔴
|
||||||
|
- 操作建议用列表
|
||||||
|
|
||||||
|
记住:你的目标是帮助用户更好地管理交易,而不是鼓励频繁交易。稳健盈利比追求高收益更重要。`
|
||||||
|
}
|
||||||
|
|
||||||
|
// RiskAssessmentPrompt returns a prompt for risk assessment before trades
|
||||||
|
func RiskAssessmentPrompt(action, symbol string, quantity, leverage float64, currentBalance, currentPositions string) string {
|
||||||
|
return fmt.Sprintf(`## 交易风险评估
|
||||||
|
|
||||||
|
请评估以下交易的风险:
|
||||||
|
|
||||||
|
**操作**: %s %s
|
||||||
|
**数量**: %.4f
|
||||||
|
**杠杆**: %.0fx
|
||||||
|
|
||||||
|
**当前账户状态**:
|
||||||
|
%s
|
||||||
|
|
||||||
|
**当前持仓**:
|
||||||
|
%s
|
||||||
|
|
||||||
|
请分析:
|
||||||
|
1. 这笔交易是否合理?
|
||||||
|
2. 仓位大小是否过大?
|
||||||
|
3. 杠杆是否过高?
|
||||||
|
4. 有什么潜在风险?
|
||||||
|
5. 你的建议是什么?
|
||||||
|
|
||||||
|
如果风险过高,请明确警告用户。`, action, symbol, quantity, leverage, currentBalance, currentPositions)
|
||||||
|
}
|
||||||
|
|
||||||
|
// MarketAnalysisPrompt returns a prompt for market analysis
|
||||||
|
func MarketAnalysisPrompt(symbol string, priceData, positionData string) string {
|
||||||
|
return fmt.Sprintf(`## %s 市场分析
|
||||||
|
|
||||||
|
**价格数据**:
|
||||||
|
%s
|
||||||
|
|
||||||
|
**相关持仓**:
|
||||||
|
%s
|
||||||
|
|
||||||
|
请分析:
|
||||||
|
1. 当前价格趋势
|
||||||
|
2. 关键支撑/阻力位
|
||||||
|
3. 持仓建议(继续持有/加仓/减仓/平仓)
|
||||||
|
4. 风险提示
|
||||||
|
|
||||||
|
注:这是基于有限数据的分析,不构成投资建议。`, symbol, priceData, positionData)
|
||||||
|
}
|
||||||
382
assistant/strategy_builder.go
Normal file
382
assistant/strategy_builder.go
Normal file
@@ -0,0 +1,382 @@
|
|||||||
|
// Package assistant - Intelligent Strategy Builder
|
||||||
|
// Allows users to create powerful, flexible trading strategies through natural language
|
||||||
|
package assistant
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
"nofx/store"
|
||||||
|
"strings"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/google/uuid"
|
||||||
|
)
|
||||||
|
|
||||||
|
// StrategyType defines the type of trading strategy
|
||||||
|
type StrategyType string
|
||||||
|
|
||||||
|
const (
|
||||||
|
StrategyTypeAI StrategyType = "ai" // AI decides everything
|
||||||
|
StrategyTypeTrend StrategyType = "trend" // Trend following
|
||||||
|
StrategyTypeMeanRevert StrategyType = "mean_revert" // Mean reversion
|
||||||
|
StrategyTypeGrid StrategyType = "grid" // Grid trading
|
||||||
|
StrategyTypeDCA StrategyType = "dca" // Dollar cost averaging
|
||||||
|
StrategyTypeBreakout StrategyType = "breakout" // Breakout trading
|
||||||
|
StrategyTypeArbitrage StrategyType = "arbitrage" // Cross-exchange arbitrage
|
||||||
|
StrategyTypeCustom StrategyType = "custom" // Custom rules
|
||||||
|
)
|
||||||
|
|
||||||
|
// SmartStrategy represents a user-defined trading strategy
|
||||||
|
type SmartStrategy struct {
|
||||||
|
ID string `json:"id"`
|
||||||
|
Name string `json:"name"`
|
||||||
|
Description string `json:"description"`
|
||||||
|
Type StrategyType `json:"type"`
|
||||||
|
|
||||||
|
// Trading pairs
|
||||||
|
Symbols []string `json:"symbols"` // e.g., ["BTCUSDT", "ETHUSDT"]
|
||||||
|
SymbolMode string `json:"symbol_mode"` // "static", "ai_select", "top_volume", "top_oi"
|
||||||
|
MaxSymbols int `json:"max_symbols"` // Max symbols to trade simultaneously
|
||||||
|
|
||||||
|
// Entry conditions
|
||||||
|
EntryRules []Rule `json:"entry_rules"`
|
||||||
|
EntryMode string `json:"entry_mode"` // "any" (OR) or "all" (AND)
|
||||||
|
|
||||||
|
// Exit conditions
|
||||||
|
ExitRules []Rule `json:"exit_rules"`
|
||||||
|
TakeProfit *float64 `json:"take_profit"` // TP percentage
|
||||||
|
StopLoss *float64 `json:"stop_loss"` // SL percentage
|
||||||
|
TrailingStop *float64 `json:"trailing_stop"` // Trailing stop percentage
|
||||||
|
|
||||||
|
// Position sizing
|
||||||
|
PositionSize PositionSizeConfig `json:"position_size"`
|
||||||
|
MaxPositions int `json:"max_positions"` // Max concurrent positions
|
||||||
|
MaxPerSymbol int `json:"max_per_symbol"` // Max positions per symbol
|
||||||
|
|
||||||
|
// Risk management
|
||||||
|
RiskConfig RiskConfig `json:"risk_config"`
|
||||||
|
|
||||||
|
// Leverage settings
|
||||||
|
LeverageConfig LeverageConfig `json:"leverage_config"`
|
||||||
|
|
||||||
|
// Time settings
|
||||||
|
TimeConfig TimeConfig `json:"time_config"`
|
||||||
|
|
||||||
|
// AI enhancement
|
||||||
|
AIConfig AIStrategyConfig `json:"ai_config"`
|
||||||
|
|
||||||
|
// Metadata
|
||||||
|
CreatedAt time.Time `json:"created_at"`
|
||||||
|
UpdatedAt time.Time `json:"updated_at"`
|
||||||
|
CreatedBy string `json:"created_by"`
|
||||||
|
IsActive bool `json:"is_active"`
|
||||||
|
Performance *StrategyPerformance `json:"performance,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// Rule represents a trading rule/condition
|
||||||
|
type Rule struct {
|
||||||
|
ID string `json:"id"`
|
||||||
|
Name string `json:"name"`
|
||||||
|
Type string `json:"type"` // "indicator", "price", "time", "volume", "ai", "custom"
|
||||||
|
Indicator string `json:"indicator"` // e.g., "RSI", "MACD", "EMA"
|
||||||
|
Condition string `json:"condition"` // e.g., "crosses_above", "greater_than", "less_than"
|
||||||
|
Value interface{} `json:"value"` // The value to compare against
|
||||||
|
Timeframe string `json:"timeframe"` // e.g., "1h", "4h", "1d"
|
||||||
|
Weight float64 `json:"weight"` // Weight for scoring (0-1)
|
||||||
|
Description string `json:"description"` // Human readable description
|
||||||
|
}
|
||||||
|
|
||||||
|
// PositionSizeConfig defines how to size positions
|
||||||
|
type PositionSizeConfig struct {
|
||||||
|
Mode string `json:"mode"` // "fixed", "percent", "risk_based", "kelly"
|
||||||
|
FixedAmount float64 `json:"fixed_amount"` // Fixed USDT amount
|
||||||
|
PercentOfEquity float64 `json:"percent_of_equity"` // Percentage of total equity
|
||||||
|
RiskPerTrade float64 `json:"risk_per_trade"` // Max risk per trade (%)
|
||||||
|
MaxSingleTrade float64 `json:"max_single_trade"` // Max single trade size (USDT)
|
||||||
|
}
|
||||||
|
|
||||||
|
// RiskConfig defines risk management rules
|
||||||
|
type RiskConfig struct {
|
||||||
|
MaxDrawdown float64 `json:"max_drawdown"` // Max drawdown before stopping (%)
|
||||||
|
MaxDailyLoss float64 `json:"max_daily_loss"` // Max daily loss (%)
|
||||||
|
MaxOpenRisk float64 `json:"max_open_risk"` // Max total open risk (%)
|
||||||
|
CooldownAfterLoss int `json:"cooldown_after_loss"` // Minutes to wait after a loss
|
||||||
|
RequireConfirmation bool `json:"require_confirmation"` // Require user confirmation for trades
|
||||||
|
EmergencyStopLoss float64 `json:"emergency_stop_loss"` // Emergency SL for all positions (%)
|
||||||
|
}
|
||||||
|
|
||||||
|
// LeverageConfig defines leverage settings
|
||||||
|
type LeverageConfig struct {
|
||||||
|
Mode string `json:"mode"` // "fixed", "dynamic", "per_symbol"
|
||||||
|
DefaultLeverage int `json:"default_leverage"`
|
||||||
|
MaxLeverage int `json:"max_leverage"`
|
||||||
|
PerSymbol map[string]int `json:"per_symbol"` // Symbol-specific leverage
|
||||||
|
PerVolatility []VolatilityLever `json:"per_volatility"` // Volatility-based leverage
|
||||||
|
}
|
||||||
|
|
||||||
|
// VolatilityLever defines leverage based on volatility
|
||||||
|
type VolatilityLever struct {
|
||||||
|
MaxVolatility float64 `json:"max_volatility"` // ATR percentage threshold
|
||||||
|
Leverage int `json:"leverage"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// TimeConfig defines time-based settings
|
||||||
|
type TimeConfig struct {
|
||||||
|
TradingHours []TimeRange `json:"trading_hours"` // When to trade
|
||||||
|
AvoidNews bool `json:"avoid_news"` // Avoid major news events
|
||||||
|
AvoidWeekends bool `json:"avoid_weekends"`
|
||||||
|
MinHoldTime int `json:"min_hold_time"` // Minimum hold time (minutes)
|
||||||
|
MaxHoldTime int `json:"max_hold_time"` // Maximum hold time (minutes)
|
||||||
|
ScanInterval int `json:"scan_interval"` // How often to scan (minutes)
|
||||||
|
}
|
||||||
|
|
||||||
|
// TimeRange represents a time range
|
||||||
|
type TimeRange struct {
|
||||||
|
Start string `json:"start"` // "09:00"
|
||||||
|
End string `json:"end"` // "17:00"
|
||||||
|
TZ string `json:"tz"` // Timezone
|
||||||
|
}
|
||||||
|
|
||||||
|
// AIStrategyConfig defines AI-specific settings
|
||||||
|
type AIStrategyConfig struct {
|
||||||
|
Enabled bool `json:"enabled"`
|
||||||
|
Model string `json:"model"` // AI model to use
|
||||||
|
ConfidenceThreshold float64 `json:"confidence_threshold"` // Min confidence to act
|
||||||
|
UseMarketSentiment bool `json:"use_market_sentiment"`
|
||||||
|
UseTechnicalAnalysis bool `json:"use_technical_analysis"`
|
||||||
|
UseOnChainData bool `json:"use_onchain_data"`
|
||||||
|
CustomPrompt string `json:"custom_prompt"` // Custom instructions for AI
|
||||||
|
Personality string `json:"personality"` // "aggressive", "conservative", "balanced"
|
||||||
|
}
|
||||||
|
|
||||||
|
// StrategyPerformance tracks strategy performance
|
||||||
|
type StrategyPerformance struct {
|
||||||
|
TotalTrades int `json:"total_trades"`
|
||||||
|
WinningTrades int `json:"winning_trades"`
|
||||||
|
LosingTrades int `json:"losing_trades"`
|
||||||
|
WinRate float64 `json:"win_rate"`
|
||||||
|
TotalPnL float64 `json:"total_pnl"`
|
||||||
|
MaxDrawdown float64 `json:"max_drawdown"`
|
||||||
|
SharpeRatio float64 `json:"sharpe_ratio"`
|
||||||
|
ProfitFactor float64 `json:"profit_factor"`
|
||||||
|
AvgWin float64 `json:"avg_win"`
|
||||||
|
AvgLoss float64 `json:"avg_loss"`
|
||||||
|
LastUpdated time.Time `json:"last_updated"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// StrategyBuilder helps users create strategies through conversation
|
||||||
|
type StrategyBuilder struct {
|
||||||
|
store *store.Store
|
||||||
|
}
|
||||||
|
|
||||||
|
// NewStrategyBuilder creates a new strategy builder
|
||||||
|
func NewStrategyBuilder(st *store.Store) *StrategyBuilder {
|
||||||
|
return &StrategyBuilder{store: st}
|
||||||
|
}
|
||||||
|
|
||||||
|
// CreateFromNaturalLanguage creates a strategy from natural language description
|
||||||
|
func (sb *StrategyBuilder) CreateFromNaturalLanguage(description string, userID string) (*SmartStrategy, error) {
|
||||||
|
// This would typically call an AI to parse the description
|
||||||
|
// For now, we create a basic template
|
||||||
|
strategy := &SmartStrategy{
|
||||||
|
ID: uuid.New().String()[:8],
|
||||||
|
Name: "Custom Strategy",
|
||||||
|
Description: description,
|
||||||
|
Type: StrategyTypeAI,
|
||||||
|
SymbolMode: "ai_select",
|
||||||
|
MaxSymbols: 5,
|
||||||
|
EntryMode: "all",
|
||||||
|
MaxPositions: 5,
|
||||||
|
MaxPerSymbol: 1,
|
||||||
|
PositionSize: PositionSizeConfig{
|
||||||
|
Mode: "percent",
|
||||||
|
PercentOfEquity: 5,
|
||||||
|
MaxSingleTrade: 1000,
|
||||||
|
},
|
||||||
|
RiskConfig: RiskConfig{
|
||||||
|
MaxDrawdown: 20,
|
||||||
|
MaxDailyLoss: 5,
|
||||||
|
MaxOpenRisk: 10,
|
||||||
|
CooldownAfterLoss: 30,
|
||||||
|
RequireConfirmation: true,
|
||||||
|
EmergencyStopLoss: 30,
|
||||||
|
},
|
||||||
|
LeverageConfig: LeverageConfig{
|
||||||
|
Mode: "dynamic",
|
||||||
|
DefaultLeverage: 3,
|
||||||
|
MaxLeverage: 10,
|
||||||
|
},
|
||||||
|
TimeConfig: TimeConfig{
|
||||||
|
ScanInterval: 5,
|
||||||
|
AvoidWeekends: false,
|
||||||
|
},
|
||||||
|
AIConfig: AIStrategyConfig{
|
||||||
|
Enabled: true,
|
||||||
|
ConfidenceThreshold: 0.7,
|
||||||
|
UseMarketSentiment: true,
|
||||||
|
UseTechnicalAnalysis: true,
|
||||||
|
Personality: "balanced",
|
||||||
|
CustomPrompt: description,
|
||||||
|
},
|
||||||
|
CreatedAt: time.Now(),
|
||||||
|
UpdatedAt: time.Now(),
|
||||||
|
CreatedBy: userID,
|
||||||
|
IsActive: false,
|
||||||
|
}
|
||||||
|
|
||||||
|
return strategy, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// CreateGridStrategy creates a grid trading strategy
|
||||||
|
func (sb *StrategyBuilder) CreateGridStrategy(symbol string, lowerPrice, upperPrice float64, gridCount int, amountPerGrid float64) *SmartStrategy {
|
||||||
|
return &SmartStrategy{
|
||||||
|
ID: uuid.New().String()[:8],
|
||||||
|
Name: fmt.Sprintf("Grid %s", symbol),
|
||||||
|
Description: fmt.Sprintf("Grid trading %s from %.2f to %.2f with %d grids", symbol, lowerPrice, upperPrice, gridCount),
|
||||||
|
Type: StrategyTypeGrid,
|
||||||
|
Symbols: []string{symbol},
|
||||||
|
SymbolMode: "static",
|
||||||
|
MaxPositions: gridCount,
|
||||||
|
PositionSize: PositionSizeConfig{
|
||||||
|
Mode: "fixed",
|
||||||
|
FixedAmount: amountPerGrid,
|
||||||
|
},
|
||||||
|
EntryRules: []Rule{
|
||||||
|
{
|
||||||
|
ID: "grid_entry",
|
||||||
|
Type: "price",
|
||||||
|
Condition: "grid_level",
|
||||||
|
Value: map[string]interface{}{
|
||||||
|
"lower_price": lowerPrice,
|
||||||
|
"upper_price": upperPrice,
|
||||||
|
"grid_count": gridCount,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
CreatedAt: time.Now(),
|
||||||
|
IsActive: false,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// CreateDCAStrategy creates a DCA strategy
|
||||||
|
func (sb *StrategyBuilder) CreateDCAStrategy(symbol string, intervalMinutes int, amountPerBuy float64, maxBuys int) *SmartStrategy {
|
||||||
|
return &SmartStrategy{
|
||||||
|
ID: uuid.New().String()[:8],
|
||||||
|
Name: fmt.Sprintf("DCA %s", symbol),
|
||||||
|
Description: fmt.Sprintf("DCA into %s every %d minutes, $%.2f per buy, max %d buys", symbol, intervalMinutes, amountPerBuy, maxBuys),
|
||||||
|
Type: StrategyTypeDCA,
|
||||||
|
Symbols: []string{symbol},
|
||||||
|
SymbolMode: "static",
|
||||||
|
MaxPositions: maxBuys,
|
||||||
|
PositionSize: PositionSizeConfig{
|
||||||
|
Mode: "fixed",
|
||||||
|
FixedAmount: amountPerBuy,
|
||||||
|
},
|
||||||
|
TimeConfig: TimeConfig{
|
||||||
|
ScanInterval: intervalMinutes,
|
||||||
|
},
|
||||||
|
CreatedAt: time.Now(),
|
||||||
|
IsActive: false,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// CreateTrendStrategy creates a trend following strategy
|
||||||
|
func (sb *StrategyBuilder) CreateTrendStrategy(symbols []string, emaFast, emaSlow int, leverage int) *SmartStrategy {
|
||||||
|
return &SmartStrategy{
|
||||||
|
ID: uuid.New().String()[:8],
|
||||||
|
Name: "Trend Following",
|
||||||
|
Description: fmt.Sprintf("EMA %d/%d crossover strategy", emaFast, emaSlow),
|
||||||
|
Type: StrategyTypeTrend,
|
||||||
|
Symbols: symbols,
|
||||||
|
SymbolMode: "static",
|
||||||
|
EntryMode: "all",
|
||||||
|
EntryRules: []Rule{
|
||||||
|
{
|
||||||
|
ID: "ema_cross",
|
||||||
|
Name: "EMA Crossover",
|
||||||
|
Type: "indicator",
|
||||||
|
Indicator: "EMA",
|
||||||
|
Condition: "crosses_above",
|
||||||
|
Value: map[string]int{
|
||||||
|
"fast_period": emaFast,
|
||||||
|
"slow_period": emaSlow,
|
||||||
|
},
|
||||||
|
Timeframe: "1h",
|
||||||
|
Weight: 1.0,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
ExitRules: []Rule{
|
||||||
|
{
|
||||||
|
ID: "ema_cross_exit",
|
||||||
|
Name: "EMA Crossover Exit",
|
||||||
|
Type: "indicator",
|
||||||
|
Indicator: "EMA",
|
||||||
|
Condition: "crosses_below",
|
||||||
|
Value: map[string]int{
|
||||||
|
"fast_period": emaFast,
|
||||||
|
"slow_period": emaSlow,
|
||||||
|
},
|
||||||
|
Timeframe: "1h",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
LeverageConfig: LeverageConfig{
|
||||||
|
Mode: "fixed",
|
||||||
|
DefaultLeverage: leverage,
|
||||||
|
},
|
||||||
|
CreatedAt: time.Now(),
|
||||||
|
IsActive: false,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// StrategyToPrompt converts a strategy to an AI prompt
|
||||||
|
func StrategyToPrompt(s *SmartStrategy) string {
|
||||||
|
var sb strings.Builder
|
||||||
|
|
||||||
|
sb.WriteString(fmt.Sprintf("# 策略: %s\n\n", s.Name))
|
||||||
|
sb.WriteString(fmt.Sprintf("**描述**: %s\n", s.Description))
|
||||||
|
sb.WriteString(fmt.Sprintf("**类型**: %s\n\n", s.Type))
|
||||||
|
|
||||||
|
// Trading pairs
|
||||||
|
if len(s.Symbols) > 0 {
|
||||||
|
sb.WriteString(fmt.Sprintf("**交易对**: %s\n", strings.Join(s.Symbols, ", ")))
|
||||||
|
} else {
|
||||||
|
sb.WriteString(fmt.Sprintf("**选币模式**: %s (最多 %d 个)\n", s.SymbolMode, s.MaxSymbols))
|
||||||
|
}
|
||||||
|
|
||||||
|
// Entry rules
|
||||||
|
if len(s.EntryRules) > 0 {
|
||||||
|
sb.WriteString("\n## 入场规则\n")
|
||||||
|
for _, rule := range s.EntryRules {
|
||||||
|
sb.WriteString(fmt.Sprintf("- %s: %s %s %v\n", rule.Name, rule.Indicator, rule.Condition, rule.Value))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Exit rules
|
||||||
|
sb.WriteString("\n## 出场规则\n")
|
||||||
|
if s.TakeProfit != nil {
|
||||||
|
sb.WriteString(fmt.Sprintf("- 止盈: %.1f%%\n", *s.TakeProfit))
|
||||||
|
}
|
||||||
|
if s.StopLoss != nil {
|
||||||
|
sb.WriteString(fmt.Sprintf("- 止损: %.1f%%\n", *s.StopLoss))
|
||||||
|
}
|
||||||
|
if s.TrailingStop != nil {
|
||||||
|
sb.WriteString(fmt.Sprintf("- 移动止损: %.1f%%\n", *s.TrailingStop))
|
||||||
|
}
|
||||||
|
|
||||||
|
// Risk management
|
||||||
|
sb.WriteString("\n## 风险管理\n")
|
||||||
|
sb.WriteString(fmt.Sprintf("- 最大回撤: %.1f%%\n", s.RiskConfig.MaxDrawdown))
|
||||||
|
sb.WriteString(fmt.Sprintf("- 单日最大亏损: %.1f%%\n", s.RiskConfig.MaxDailyLoss))
|
||||||
|
sb.WriteString(fmt.Sprintf("- 最大持仓数: %d\n", s.MaxPositions))
|
||||||
|
|
||||||
|
// AI settings
|
||||||
|
if s.AIConfig.Enabled {
|
||||||
|
sb.WriteString("\n## AI 配置\n")
|
||||||
|
sb.WriteString(fmt.Sprintf("- 置信度阈值: %.0f%%\n", s.AIConfig.ConfidenceThreshold*100))
|
||||||
|
sb.WriteString(fmt.Sprintf("- 风格: %s\n", s.AIConfig.Personality))
|
||||||
|
if s.AIConfig.CustomPrompt != "" {
|
||||||
|
sb.WriteString(fmt.Sprintf("- 自定义指令: %s\n", s.AIConfig.CustomPrompt))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return sb.String()
|
||||||
|
}
|
||||||
548
assistant/strategy_tools.go
Normal file
548
assistant/strategy_tools.go
Normal file
@@ -0,0 +1,548 @@
|
|||||||
|
package assistant
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"encoding/json"
|
||||||
|
"fmt"
|
||||||
|
"nofx/store"
|
||||||
|
)
|
||||||
|
|
||||||
|
// StrategyTools provides strategy management tools for the AI agent
|
||||||
|
type StrategyTools struct {
|
||||||
|
store *store.Store
|
||||||
|
strategyBuilder *StrategyBuilder
|
||||||
|
strategies map[string]*SmartStrategy // In-memory strategy cache
|
||||||
|
}
|
||||||
|
|
||||||
|
// NewStrategyTools creates strategy tools
|
||||||
|
func NewStrategyTools(st *store.Store) *StrategyTools {
|
||||||
|
return &StrategyTools{
|
||||||
|
store: st,
|
||||||
|
strategyBuilder: NewStrategyBuilder(st),
|
||||||
|
strategies: make(map[string]*SmartStrategy),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetAllTools returns all strategy tools
|
||||||
|
func (st *StrategyTools) GetAllTools() []Tool {
|
||||||
|
return []Tool{
|
||||||
|
st.CreateStrategyTool(),
|
||||||
|
st.CreateGridStrategyTool(),
|
||||||
|
st.CreateDCAStrategyTool(),
|
||||||
|
st.CreateTrendStrategyTool(),
|
||||||
|
st.ListSmartStrategiesTool(),
|
||||||
|
st.GetStrategyDetailsTool(),
|
||||||
|
st.UpdateStrategyTool(),
|
||||||
|
st.ActivateStrategyTool(),
|
||||||
|
st.DeactivateStrategyTool(),
|
||||||
|
st.DeleteStrategyTool(),
|
||||||
|
st.GetStrategyTemplates(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// CreateStrategyTool creates a strategy from natural language
|
||||||
|
func (st *StrategyTools) CreateStrategyTool() Tool {
|
||||||
|
return NewTool(
|
||||||
|
"create_strategy",
|
||||||
|
`Create a new trading strategy from natural language description.
|
||||||
|
Examples:
|
||||||
|
- "当RSI低于30时买入BTC,RSI高于70时卖出"
|
||||||
|
- "每天定投100美元ETH"
|
||||||
|
- "BTC在5万到6万之间做网格交易"`,
|
||||||
|
`{
|
||||||
|
"name": "string (required) - Strategy name",
|
||||||
|
"description": "string (required) - Natural language description of the strategy",
|
||||||
|
"symbols": "array (optional) - Trading pairs, e.g., [\"BTCUSDT\", \"ETHUSDT\"]",
|
||||||
|
"take_profit": "number (optional) - Take profit percentage",
|
||||||
|
"stop_loss": "number (optional) - Stop loss percentage",
|
||||||
|
"leverage": "number (optional) - Leverage to use (default: 3)",
|
||||||
|
"max_positions": "number (optional) - Max concurrent positions (default: 5)"
|
||||||
|
}`,
|
||||||
|
func(ctx context.Context, args json.RawMessage) (interface{}, error) {
|
||||||
|
var params struct {
|
||||||
|
Name string `json:"name"`
|
||||||
|
Description string `json:"description"`
|
||||||
|
Symbols []string `json:"symbols"`
|
||||||
|
TakeProfit *float64 `json:"take_profit"`
|
||||||
|
StopLoss *float64 `json:"stop_loss"`
|
||||||
|
Leverage int `json:"leverage"`
|
||||||
|
MaxPositions int `json:"max_positions"`
|
||||||
|
}
|
||||||
|
if err := json.Unmarshal(args, ¶ms); err != nil {
|
||||||
|
return nil, fmt.Errorf("invalid arguments: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if params.Description == "" {
|
||||||
|
return nil, fmt.Errorf("strategy description is required")
|
||||||
|
}
|
||||||
|
|
||||||
|
strategy, err := st.strategyBuilder.CreateFromNaturalLanguage(params.Description, "default")
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
// Apply user customizations
|
||||||
|
if params.Name != "" {
|
||||||
|
strategy.Name = params.Name
|
||||||
|
}
|
||||||
|
if len(params.Symbols) > 0 {
|
||||||
|
strategy.Symbols = params.Symbols
|
||||||
|
strategy.SymbolMode = "static"
|
||||||
|
}
|
||||||
|
if params.TakeProfit != nil {
|
||||||
|
strategy.TakeProfit = params.TakeProfit
|
||||||
|
}
|
||||||
|
if params.StopLoss != nil {
|
||||||
|
strategy.StopLoss = params.StopLoss
|
||||||
|
}
|
||||||
|
if params.Leverage > 0 {
|
||||||
|
strategy.LeverageConfig.DefaultLeverage = params.Leverage
|
||||||
|
}
|
||||||
|
if params.MaxPositions > 0 {
|
||||||
|
strategy.MaxPositions = params.MaxPositions
|
||||||
|
}
|
||||||
|
|
||||||
|
// Store in memory
|
||||||
|
st.strategies[strategy.ID] = strategy
|
||||||
|
|
||||||
|
return map[string]interface{}{
|
||||||
|
"success": true,
|
||||||
|
"strategy": strategy,
|
||||||
|
"message": fmt.Sprintf("策略 '%s' (ID: %s) 创建成功!使用 activate_strategy 激活它。", strategy.Name, strategy.ID),
|
||||||
|
}, nil
|
||||||
|
},
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// CreateGridStrategyTool creates a grid trading strategy
|
||||||
|
func (st *StrategyTools) CreateGridStrategyTool() Tool {
|
||||||
|
return NewTool(
|
||||||
|
"create_grid_strategy",
|
||||||
|
"Create a grid trading strategy. Grid trading places buy and sell orders at predetermined price levels.",
|
||||||
|
`{
|
||||||
|
"symbol": "string (required) - Trading pair, e.g., BTCUSDT",
|
||||||
|
"lower_price": "number (required) - Lower price bound",
|
||||||
|
"upper_price": "number (required) - Upper price bound",
|
||||||
|
"grid_count": "number (required) - Number of grids (10-100)",
|
||||||
|
"amount_per_grid": "number (required) - USDT amount per grid"
|
||||||
|
}`,
|
||||||
|
func(ctx context.Context, args json.RawMessage) (interface{}, error) {
|
||||||
|
var params struct {
|
||||||
|
Symbol string `json:"symbol"`
|
||||||
|
LowerPrice float64 `json:"lower_price"`
|
||||||
|
UpperPrice float64 `json:"upper_price"`
|
||||||
|
GridCount int `json:"grid_count"`
|
||||||
|
AmountPerGrid float64 `json:"amount_per_grid"`
|
||||||
|
}
|
||||||
|
if err := json.Unmarshal(args, ¶ms); err != nil {
|
||||||
|
return nil, fmt.Errorf("invalid arguments: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if params.LowerPrice >= params.UpperPrice {
|
||||||
|
return nil, fmt.Errorf("lower_price must be less than upper_price")
|
||||||
|
}
|
||||||
|
if params.GridCount < 2 || params.GridCount > 100 {
|
||||||
|
return nil, fmt.Errorf("grid_count must be between 2 and 100")
|
||||||
|
}
|
||||||
|
|
||||||
|
strategy := st.strategyBuilder.CreateGridStrategy(
|
||||||
|
params.Symbol, params.LowerPrice, params.UpperPrice,
|
||||||
|
params.GridCount, params.AmountPerGrid,
|
||||||
|
)
|
||||||
|
st.strategies[strategy.ID] = strategy
|
||||||
|
|
||||||
|
gridSize := (params.UpperPrice - params.LowerPrice) / float64(params.GridCount)
|
||||||
|
totalInvestment := params.AmountPerGrid * float64(params.GridCount)
|
||||||
|
|
||||||
|
return map[string]interface{}{
|
||||||
|
"success": true,
|
||||||
|
"strategy": strategy,
|
||||||
|
"details": map[string]interface{}{
|
||||||
|
"grid_size": gridSize,
|
||||||
|
"total_investment": totalInvestment,
|
||||||
|
"profit_per_grid": (gridSize / params.LowerPrice) * 100,
|
||||||
|
},
|
||||||
|
"message": fmt.Sprintf("网格策略创建成功!\n价格区间: %.2f - %.2f\n网格数: %d\n每格间距: %.2f\n总投资: $%.2f",
|
||||||
|
params.LowerPrice, params.UpperPrice, params.GridCount, gridSize, totalInvestment),
|
||||||
|
}, nil
|
||||||
|
},
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// CreateDCAStrategyTool creates a DCA strategy
|
||||||
|
func (st *StrategyTools) CreateDCAStrategyTool() Tool {
|
||||||
|
return NewTool(
|
||||||
|
"create_dca_strategy",
|
||||||
|
"Create a Dollar Cost Averaging (DCA) strategy. Automatically buy at regular intervals.",
|
||||||
|
`{
|
||||||
|
"symbol": "string (required) - Trading pair, e.g., BTCUSDT",
|
||||||
|
"interval_minutes": "number (required) - Buy interval in minutes (min: 5)",
|
||||||
|
"amount_per_buy": "number (required) - USDT amount per purchase",
|
||||||
|
"max_buys": "number (optional) - Maximum number of buys (default: unlimited)"
|
||||||
|
}`,
|
||||||
|
func(ctx context.Context, args json.RawMessage) (interface{}, error) {
|
||||||
|
var params struct {
|
||||||
|
Symbol string `json:"symbol"`
|
||||||
|
IntervalMinutes int `json:"interval_minutes"`
|
||||||
|
AmountPerBuy float64 `json:"amount_per_buy"`
|
||||||
|
MaxBuys int `json:"max_buys"`
|
||||||
|
}
|
||||||
|
if err := json.Unmarshal(args, ¶ms); err != nil {
|
||||||
|
return nil, fmt.Errorf("invalid arguments: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if params.IntervalMinutes < 5 {
|
||||||
|
return nil, fmt.Errorf("interval must be at least 5 minutes")
|
||||||
|
}
|
||||||
|
if params.MaxBuys == 0 {
|
||||||
|
params.MaxBuys = 1000 // Effectively unlimited
|
||||||
|
}
|
||||||
|
|
||||||
|
strategy := st.strategyBuilder.CreateDCAStrategy(
|
||||||
|
params.Symbol, params.IntervalMinutes, params.AmountPerBuy, params.MaxBuys,
|
||||||
|
)
|
||||||
|
st.strategies[strategy.ID] = strategy
|
||||||
|
|
||||||
|
return map[string]interface{}{
|
||||||
|
"success": true,
|
||||||
|
"strategy": strategy,
|
||||||
|
"message": fmt.Sprintf("DCA策略创建成功!\n币种: %s\n定投间隔: %d分钟\n每次金额: $%.2f\n最大次数: %d",
|
||||||
|
params.Symbol, params.IntervalMinutes, params.AmountPerBuy, params.MaxBuys),
|
||||||
|
}, nil
|
||||||
|
},
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// CreateTrendStrategyTool creates a trend following strategy
|
||||||
|
func (st *StrategyTools) CreateTrendStrategyTool() Tool {
|
||||||
|
return NewTool(
|
||||||
|
"create_trend_strategy",
|
||||||
|
"Create a trend following strategy using EMA crossover.",
|
||||||
|
`{
|
||||||
|
"symbols": "array (required) - Trading pairs",
|
||||||
|
"ema_fast": "number (optional) - Fast EMA period (default: 9)",
|
||||||
|
"ema_slow": "number (optional) - Slow EMA period (default: 21)",
|
||||||
|
"leverage": "number (optional) - Leverage (default: 3)",
|
||||||
|
"take_profit": "number (optional) - Take profit %",
|
||||||
|
"stop_loss": "number (optional) - Stop loss %"
|
||||||
|
}`,
|
||||||
|
func(ctx context.Context, args json.RawMessage) (interface{}, error) {
|
||||||
|
var params struct {
|
||||||
|
Symbols []string `json:"symbols"`
|
||||||
|
EMAFast int `json:"ema_fast"`
|
||||||
|
EMASlow int `json:"ema_slow"`
|
||||||
|
Leverage int `json:"leverage"`
|
||||||
|
TakeProfit *float64 `json:"take_profit"`
|
||||||
|
StopLoss *float64 `json:"stop_loss"`
|
||||||
|
}
|
||||||
|
if err := json.Unmarshal(args, ¶ms); err != nil {
|
||||||
|
return nil, fmt.Errorf("invalid arguments: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if len(params.Symbols) == 0 {
|
||||||
|
params.Symbols = []string{"BTCUSDT", "ETHUSDT"}
|
||||||
|
}
|
||||||
|
if params.EMAFast == 0 {
|
||||||
|
params.EMAFast = 9
|
||||||
|
}
|
||||||
|
if params.EMASlow == 0 {
|
||||||
|
params.EMASlow = 21
|
||||||
|
}
|
||||||
|
if params.Leverage == 0 {
|
||||||
|
params.Leverage = 3
|
||||||
|
}
|
||||||
|
|
||||||
|
strategy := st.strategyBuilder.CreateTrendStrategy(
|
||||||
|
params.Symbols, params.EMAFast, params.EMASlow, params.Leverage,
|
||||||
|
)
|
||||||
|
strategy.TakeProfit = params.TakeProfit
|
||||||
|
strategy.StopLoss = params.StopLoss
|
||||||
|
st.strategies[strategy.ID] = strategy
|
||||||
|
|
||||||
|
return map[string]interface{}{
|
||||||
|
"success": true,
|
||||||
|
"strategy": strategy,
|
||||||
|
"message": fmt.Sprintf("趋势策略创建成功!\nEMA %d/%d 交叉\n交易对: %v\n杠杆: %dx",
|
||||||
|
params.EMAFast, params.EMASlow, params.Symbols, params.Leverage),
|
||||||
|
}, nil
|
||||||
|
},
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ListSmartStrategiesTool lists all smart strategies
|
||||||
|
func (st *StrategyTools) ListSmartStrategiesTool() Tool {
|
||||||
|
return NewTool(
|
||||||
|
"list_smart_strategies",
|
||||||
|
"List all smart strategies (both in-memory and saved).",
|
||||||
|
`{}`,
|
||||||
|
func(ctx context.Context, args json.RawMessage) (interface{}, error) {
|
||||||
|
var result []map[string]interface{}
|
||||||
|
|
||||||
|
for _, s := range st.strategies {
|
||||||
|
result = append(result, map[string]interface{}{
|
||||||
|
"id": s.ID,
|
||||||
|
"name": s.Name,
|
||||||
|
"type": s.Type,
|
||||||
|
"description": s.Description,
|
||||||
|
"is_active": s.IsActive,
|
||||||
|
"symbols": s.Symbols,
|
||||||
|
"created_at": s.CreatedAt,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// Also get strategies from store
|
||||||
|
if dbStrategies, err := st.store.Strategy().List("default"); err == nil {
|
||||||
|
for _, s := range dbStrategies {
|
||||||
|
result = append(result, map[string]interface{}{
|
||||||
|
"id": s.ID,
|
||||||
|
"name": s.Name,
|
||||||
|
"type": "db_strategy",
|
||||||
|
"description": s.Description,
|
||||||
|
"is_active": s.IsActive,
|
||||||
|
"source": "database",
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if len(result) == 0 {
|
||||||
|
return map[string]interface{}{
|
||||||
|
"strategies": []interface{}{},
|
||||||
|
"message": "暂无策略。使用 create_strategy 创建一个新策略。",
|
||||||
|
}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
return map[string]interface{}{
|
||||||
|
"strategies": result,
|
||||||
|
"count": len(result),
|
||||||
|
}, nil
|
||||||
|
},
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetStrategyDetailsTool gets detailed strategy info
|
||||||
|
func (st *StrategyTools) GetStrategyDetailsTool() Tool {
|
||||||
|
return NewTool(
|
||||||
|
"get_strategy_details",
|
||||||
|
"Get detailed information about a specific strategy.",
|
||||||
|
`{"strategy_id": "string (required)"}`,
|
||||||
|
func(ctx context.Context, args json.RawMessage) (interface{}, error) {
|
||||||
|
var params struct {
|
||||||
|
StrategyID string `json:"strategy_id"`
|
||||||
|
}
|
||||||
|
if err := json.Unmarshal(args, ¶ms); err != nil {
|
||||||
|
return nil, fmt.Errorf("invalid arguments: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if s, ok := st.strategies[params.StrategyID]; ok {
|
||||||
|
return map[string]interface{}{
|
||||||
|
"strategy": s,
|
||||||
|
"prompt_text": StrategyToPrompt(s),
|
||||||
|
}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil, fmt.Errorf("strategy not found: %s", params.StrategyID)
|
||||||
|
},
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// UpdateStrategyTool updates a strategy
|
||||||
|
func (st *StrategyTools) UpdateStrategyTool() Tool {
|
||||||
|
return NewTool(
|
||||||
|
"update_strategy",
|
||||||
|
"Update an existing strategy's settings.",
|
||||||
|
`{
|
||||||
|
"strategy_id": "string (required)",
|
||||||
|
"name": "string (optional)",
|
||||||
|
"take_profit": "number (optional)",
|
||||||
|
"stop_loss": "number (optional)",
|
||||||
|
"leverage": "number (optional)",
|
||||||
|
"max_positions": "number (optional)",
|
||||||
|
"symbols": "array (optional)"
|
||||||
|
}`,
|
||||||
|
func(ctx context.Context, args json.RawMessage) (interface{}, error) {
|
||||||
|
var params struct {
|
||||||
|
StrategyID string `json:"strategy_id"`
|
||||||
|
Name string `json:"name"`
|
||||||
|
TakeProfit *float64 `json:"take_profit"`
|
||||||
|
StopLoss *float64 `json:"stop_loss"`
|
||||||
|
Leverage int `json:"leverage"`
|
||||||
|
MaxPositions int `json:"max_positions"`
|
||||||
|
Symbols []string `json:"symbols"`
|
||||||
|
}
|
||||||
|
if err := json.Unmarshal(args, ¶ms); err != nil {
|
||||||
|
return nil, fmt.Errorf("invalid arguments: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
s, ok := st.strategies[params.StrategyID]
|
||||||
|
if !ok {
|
||||||
|
return nil, fmt.Errorf("strategy not found: %s", params.StrategyID)
|
||||||
|
}
|
||||||
|
|
||||||
|
if params.Name != "" {
|
||||||
|
s.Name = params.Name
|
||||||
|
}
|
||||||
|
if params.TakeProfit != nil {
|
||||||
|
s.TakeProfit = params.TakeProfit
|
||||||
|
}
|
||||||
|
if params.StopLoss != nil {
|
||||||
|
s.StopLoss = params.StopLoss
|
||||||
|
}
|
||||||
|
if params.Leverage > 0 {
|
||||||
|
s.LeverageConfig.DefaultLeverage = params.Leverage
|
||||||
|
}
|
||||||
|
if params.MaxPositions > 0 {
|
||||||
|
s.MaxPositions = params.MaxPositions
|
||||||
|
}
|
||||||
|
if len(params.Symbols) > 0 {
|
||||||
|
s.Symbols = params.Symbols
|
||||||
|
}
|
||||||
|
|
||||||
|
return map[string]interface{}{
|
||||||
|
"success": true,
|
||||||
|
"strategy": s,
|
||||||
|
"message": "策略已更新",
|
||||||
|
}, nil
|
||||||
|
},
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ActivateStrategyTool activates a strategy
|
||||||
|
func (st *StrategyTools) ActivateStrategyTool() Tool {
|
||||||
|
return NewTool(
|
||||||
|
"activate_strategy",
|
||||||
|
"Activate a strategy to start trading. ⚠️ This will start real trading!",
|
||||||
|
`{"strategy_id": "string (required)"}`,
|
||||||
|
func(ctx context.Context, args json.RawMessage) (interface{}, error) {
|
||||||
|
var params struct {
|
||||||
|
StrategyID string `json:"strategy_id"`
|
||||||
|
}
|
||||||
|
if err := json.Unmarshal(args, ¶ms); err != nil {
|
||||||
|
return nil, fmt.Errorf("invalid arguments: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
s, ok := st.strategies[params.StrategyID]
|
||||||
|
if !ok {
|
||||||
|
return nil, fmt.Errorf("strategy not found: %s", params.StrategyID)
|
||||||
|
}
|
||||||
|
|
||||||
|
s.IsActive = true
|
||||||
|
|
||||||
|
return map[string]interface{}{
|
||||||
|
"success": true,
|
||||||
|
"message": fmt.Sprintf("⚠️ 策略 '%s' 已激活!将开始真实交易。", s.Name),
|
||||||
|
"strategy": s,
|
||||||
|
}, nil
|
||||||
|
},
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// DeactivateStrategyTool deactivates a strategy
|
||||||
|
func (st *StrategyTools) DeactivateStrategyTool() Tool {
|
||||||
|
return NewTool(
|
||||||
|
"deactivate_strategy",
|
||||||
|
"Deactivate a strategy to stop trading.",
|
||||||
|
`{"strategy_id": "string (required)"}`,
|
||||||
|
func(ctx context.Context, args json.RawMessage) (interface{}, error) {
|
||||||
|
var params struct {
|
||||||
|
StrategyID string `json:"strategy_id"`
|
||||||
|
}
|
||||||
|
if err := json.Unmarshal(args, ¶ms); err != nil {
|
||||||
|
return nil, fmt.Errorf("invalid arguments: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
s, ok := st.strategies[params.StrategyID]
|
||||||
|
if !ok {
|
||||||
|
return nil, fmt.Errorf("strategy not found: %s", params.StrategyID)
|
||||||
|
}
|
||||||
|
|
||||||
|
s.IsActive = false
|
||||||
|
|
||||||
|
return map[string]interface{}{
|
||||||
|
"success": true,
|
||||||
|
"message": fmt.Sprintf("策略 '%s' 已停用", s.Name),
|
||||||
|
}, nil
|
||||||
|
},
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// DeleteStrategyTool deletes a strategy
|
||||||
|
func (st *StrategyTools) DeleteStrategyTool() Tool {
|
||||||
|
return NewTool(
|
||||||
|
"delete_strategy",
|
||||||
|
"Delete a strategy permanently.",
|
||||||
|
`{"strategy_id": "string (required)"}`,
|
||||||
|
func(ctx context.Context, args json.RawMessage) (interface{}, error) {
|
||||||
|
var params struct {
|
||||||
|
StrategyID string `json:"strategy_id"`
|
||||||
|
}
|
||||||
|
if err := json.Unmarshal(args, ¶ms); err != nil {
|
||||||
|
return nil, fmt.Errorf("invalid arguments: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if _, ok := st.strategies[params.StrategyID]; !ok {
|
||||||
|
return nil, fmt.Errorf("strategy not found: %s", params.StrategyID)
|
||||||
|
}
|
||||||
|
|
||||||
|
delete(st.strategies, params.StrategyID)
|
||||||
|
|
||||||
|
return map[string]interface{}{
|
||||||
|
"success": true,
|
||||||
|
"message": "策略已删除",
|
||||||
|
}, nil
|
||||||
|
},
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetStrategyTemplates returns available strategy templates
|
||||||
|
func (st *StrategyTools) GetStrategyTemplates() Tool {
|
||||||
|
return NewTool(
|
||||||
|
"get_strategy_templates",
|
||||||
|
"Get available strategy templates and examples.",
|
||||||
|
`{}`,
|
||||||
|
func(ctx context.Context, args json.RawMessage) (interface{}, error) {
|
||||||
|
templates := []map[string]interface{}{
|
||||||
|
{
|
||||||
|
"name": "AI 智能交易",
|
||||||
|
"type": "ai",
|
||||||
|
"description": "让 AI 自主分析市场并决策,适合不想手动盯盘的用户",
|
||||||
|
"example": "create_strategy(name='AI智能', description='分析BTC和ETH的技术指标和市场情绪,在有明确趋势时入场')",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "网格交易",
|
||||||
|
"type": "grid",
|
||||||
|
"description": "在价格区间内自动低买高卖,适合震荡行情",
|
||||||
|
"example": "create_grid_strategy(symbol='BTCUSDT', lower_price=90000, upper_price=100000, grid_count=20, amount_per_grid=100)",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "定投 DCA",
|
||||||
|
"type": "dca",
|
||||||
|
"description": "定期定额买入,摊薄成本,适合长期投资",
|
||||||
|
"example": "create_dca_strategy(symbol='ETHUSDT', interval_minutes=1440, amount_per_buy=50, max_buys=365)",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "趋势跟踪",
|
||||||
|
"type": "trend",
|
||||||
|
"description": "跟随趋势,EMA金叉买入死叉卖出",
|
||||||
|
"example": "create_trend_strategy(symbols=['BTCUSDT','ETHUSDT'], ema_fast=9, ema_slow=21, leverage=3)",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "RSI 超买超卖",
|
||||||
|
"type": "custom",
|
||||||
|
"description": "RSI 低于 30 买入,高于 70 卖出",
|
||||||
|
"example": "create_strategy(name='RSI策略', description='当RSI14低于30时买入,高于70时卖出,止损10%')",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "突破策略",
|
||||||
|
"type": "breakout",
|
||||||
|
"description": "价格突破关键位时入场",
|
||||||
|
"example": "create_strategy(name='突破策略', description='当价格突破20日最高点时做多,突破20日最低点时做空')",
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
return map[string]interface{}{
|
||||||
|
"templates": templates,
|
||||||
|
"message": "以上是可用的策略模板,选择一个并告诉我你想怎么定制!",
|
||||||
|
}, nil
|
||||||
|
},
|
||||||
|
)
|
||||||
|
}
|
||||||
47
assistant/tool.go
Normal file
47
assistant/tool.go
Normal file
@@ -0,0 +1,47 @@
|
|||||||
|
package assistant
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"encoding/json"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Tool represents a callable tool that the AI agent can use
|
||||||
|
type Tool interface {
|
||||||
|
// Name returns the tool's unique identifier
|
||||||
|
Name() string
|
||||||
|
|
||||||
|
// Description returns a human-readable description for the AI
|
||||||
|
Description() string
|
||||||
|
|
||||||
|
// ParameterSchema returns JSON schema for the tool's parameters
|
||||||
|
ParameterSchema() string
|
||||||
|
|
||||||
|
// Execute runs the tool with the given arguments
|
||||||
|
Execute(ctx context.Context, args json.RawMessage) (interface{}, error)
|
||||||
|
}
|
||||||
|
|
||||||
|
// BaseTool provides common functionality for tools
|
||||||
|
type BaseTool struct {
|
||||||
|
ToolName string
|
||||||
|
ToolDescription string
|
||||||
|
ToolSchema string
|
||||||
|
ExecuteFunc func(ctx context.Context, args json.RawMessage) (interface{}, error)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (t *BaseTool) Name() string { return t.ToolName }
|
||||||
|
func (t *BaseTool) Description() string { return t.ToolDescription }
|
||||||
|
func (t *BaseTool) ParameterSchema() string { return t.ToolSchema }
|
||||||
|
|
||||||
|
func (t *BaseTool) Execute(ctx context.Context, args json.RawMessage) (interface{}, error) {
|
||||||
|
return t.ExecuteFunc(ctx, args)
|
||||||
|
}
|
||||||
|
|
||||||
|
// NewTool creates a simple tool from a function
|
||||||
|
func NewTool(name, description, schema string, fn func(ctx context.Context, args json.RawMessage) (interface{}, error)) Tool {
|
||||||
|
return &BaseTool{
|
||||||
|
ToolName: name,
|
||||||
|
ToolDescription: description,
|
||||||
|
ToolSchema: schema,
|
||||||
|
ExecuteFunc: fn,
|
||||||
|
}
|
||||||
|
}
|
||||||
530
assistant/trading_tools.go
Normal file
530
assistant/trading_tools.go
Normal file
@@ -0,0 +1,530 @@
|
|||||||
|
package assistant
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"encoding/json"
|
||||||
|
"fmt"
|
||||||
|
"nofx/logger"
|
||||||
|
"nofx/manager"
|
||||||
|
"nofx/store"
|
||||||
|
)
|
||||||
|
|
||||||
|
// TradingTools provides all trading-related tools for the AI agent
|
||||||
|
type TradingTools struct {
|
||||||
|
traderManager *manager.TraderManager
|
||||||
|
store *store.Store
|
||||||
|
}
|
||||||
|
|
||||||
|
// NewTradingTools creates trading tools with access to NOFX core
|
||||||
|
func NewTradingTools(tm *manager.TraderManager, st *store.Store) *TradingTools {
|
||||||
|
return &TradingTools{
|
||||||
|
traderManager: tm,
|
||||||
|
store: st,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetAllTools returns all trading tools
|
||||||
|
func (t *TradingTools) GetAllTools() []Tool {
|
||||||
|
return []Tool{
|
||||||
|
t.GetBalanceTool(),
|
||||||
|
t.GetPositionsTool(),
|
||||||
|
t.ListTradersTool(),
|
||||||
|
t.GetTraderStatusTool(),
|
||||||
|
t.StartTraderTool(),
|
||||||
|
t.StopTraderTool(),
|
||||||
|
t.GetMarketPriceTool(),
|
||||||
|
t.OpenLongTool(),
|
||||||
|
t.OpenShortTool(),
|
||||||
|
t.ClosePositionTool(),
|
||||||
|
t.ListStrategiesTool(),
|
||||||
|
t.ListExchangesTool(),
|
||||||
|
t.ListAIModelsTool(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ==================== Query Tools ====================
|
||||||
|
|
||||||
|
// GetBalanceTool returns the get_balance tool
|
||||||
|
func (t *TradingTools) GetBalanceTool() Tool {
|
||||||
|
return NewTool(
|
||||||
|
"get_balance",
|
||||||
|
"Get account balance for a trader. Returns available balance, total equity, and margin info.",
|
||||||
|
`{"trader_id": "string (required) - The trader ID to query"}`,
|
||||||
|
func(ctx context.Context, args json.RawMessage) (interface{}, error) {
|
||||||
|
var params struct {
|
||||||
|
TraderID string `json:"trader_id"`
|
||||||
|
}
|
||||||
|
if err := json.Unmarshal(args, ¶ms); err != nil {
|
||||||
|
return nil, fmt.Errorf("invalid arguments: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
trader, err := t.traderManager.GetTrader(params.TraderID)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("trader not found: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
balance, err := trader.GetAccountInfo()
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("failed to get balance: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
return balance, nil
|
||||||
|
},
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetPositionsTool returns the get_positions tool
|
||||||
|
func (t *TradingTools) GetPositionsTool() Tool {
|
||||||
|
return NewTool(
|
||||||
|
"get_positions",
|
||||||
|
"Get all open positions for a trader. Returns symbol, side, size, entry price, unrealized P&L.",
|
||||||
|
`{"trader_id": "string (required) - The trader ID to query"}`,
|
||||||
|
func(ctx context.Context, args json.RawMessage) (interface{}, error) {
|
||||||
|
var params struct {
|
||||||
|
TraderID string `json:"trader_id"`
|
||||||
|
}
|
||||||
|
if err := json.Unmarshal(args, ¶ms); err != nil {
|
||||||
|
return nil, fmt.Errorf("invalid arguments: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
trader, err := t.traderManager.GetTrader(params.TraderID)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("trader not found: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
positions, err := trader.GetPositions()
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("failed to get positions: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
return positions, nil
|
||||||
|
},
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ListTradersTool returns the list_traders tool
|
||||||
|
func (t *TradingTools) ListTradersTool() Tool {
|
||||||
|
return NewTool(
|
||||||
|
"list_traders",
|
||||||
|
"List all configured AI traders with their status (running/stopped), exchange, AI model, and performance.",
|
||||||
|
`{}`,
|
||||||
|
func(ctx context.Context, args json.RawMessage) (interface{}, error) {
|
||||||
|
traders, err := t.store.Trader().List("default")
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("failed to list traders: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
var result []map[string]interface{}
|
||||||
|
for _, tr := range traders {
|
||||||
|
traderInfo := map[string]interface{}{
|
||||||
|
"id": tr.ID,
|
||||||
|
"name": tr.Name,
|
||||||
|
"is_running": tr.IsRunning,
|
||||||
|
"ai_model_id": tr.AIModelID,
|
||||||
|
"exchange_id": tr.ExchangeID,
|
||||||
|
"strategy_id": tr.StrategyID,
|
||||||
|
"created_at": tr.CreatedAt,
|
||||||
|
}
|
||||||
|
|
||||||
|
// Try to get live status if trader is running
|
||||||
|
if liveTrader, err := t.traderManager.GetTrader(tr.ID); err == nil {
|
||||||
|
status := liveTrader.GetStatus()
|
||||||
|
traderInfo["live_status"] = status
|
||||||
|
}
|
||||||
|
|
||||||
|
result = append(result, traderInfo)
|
||||||
|
}
|
||||||
|
|
||||||
|
return result, nil
|
||||||
|
},
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetTraderStatusTool returns detailed status of a specific trader
|
||||||
|
func (t *TradingTools) GetTraderStatusTool() Tool {
|
||||||
|
return NewTool(
|
||||||
|
"get_trader_status",
|
||||||
|
"Get detailed status of a specific trader including current positions, recent trades, and performance metrics.",
|
||||||
|
`{"trader_id": "string (required) - The trader ID to query"}`,
|
||||||
|
func(ctx context.Context, args json.RawMessage) (interface{}, error) {
|
||||||
|
var params struct {
|
||||||
|
TraderID string `json:"trader_id"`
|
||||||
|
}
|
||||||
|
if err := json.Unmarshal(args, ¶ms); err != nil {
|
||||||
|
return nil, fmt.Errorf("invalid arguments: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get trader config from store
|
||||||
|
traderConfig, err := t.store.Trader().GetByID(params.TraderID)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("trader not found: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
result := map[string]interface{}{
|
||||||
|
"id": traderConfig.ID,
|
||||||
|
"name": traderConfig.Name,
|
||||||
|
"is_running": traderConfig.IsRunning,
|
||||||
|
"ai_model_id": traderConfig.AIModelID,
|
||||||
|
"exchange_id": traderConfig.ExchangeID,
|
||||||
|
"strategy_id": traderConfig.StrategyID,
|
||||||
|
}
|
||||||
|
|
||||||
|
// If trader is running, get live data
|
||||||
|
trader, err := t.traderManager.GetTrader(params.TraderID)
|
||||||
|
if err == nil && trader != nil {
|
||||||
|
result["live_status"] = trader.GetStatus()
|
||||||
|
if balance, err := trader.GetAccountInfo(); err == nil {
|
||||||
|
result["balance"] = balance
|
||||||
|
}
|
||||||
|
if positions, err := trader.GetPositions(); err == nil {
|
||||||
|
result["positions"] = positions
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return result, nil
|
||||||
|
},
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ==================== Control Tools ====================
|
||||||
|
|
||||||
|
// StartTraderTool starts an AI trader
|
||||||
|
func (t *TradingTools) StartTraderTool() Tool {
|
||||||
|
return NewTool(
|
||||||
|
"start_trader",
|
||||||
|
"Start an AI trader to begin automated trading. The trader will execute trades based on its configured strategy.",
|
||||||
|
`{"trader_id": "string (required) - The trader ID to start"}`,
|
||||||
|
func(ctx context.Context, args json.RawMessage) (interface{}, error) {
|
||||||
|
var params struct {
|
||||||
|
TraderID string `json:"trader_id"`
|
||||||
|
}
|
||||||
|
if err := json.Unmarshal(args, ¶ms); err != nil {
|
||||||
|
return nil, fmt.Errorf("invalid arguments: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check if already running
|
||||||
|
existingTrader, _ := t.traderManager.GetTrader(params.TraderID)
|
||||||
|
if existingTrader != nil {
|
||||||
|
status := existingTrader.GetStatus()
|
||||||
|
if isRunning, ok := status["is_running"].(bool); ok && isRunning {
|
||||||
|
return nil, fmt.Errorf("trader is already running")
|
||||||
|
}
|
||||||
|
// Remove from memory to reload
|
||||||
|
t.traderManager.RemoveTrader(params.TraderID)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Load and start trader
|
||||||
|
if err := t.traderManager.LoadUserTradersFromStore(t.store, "default"); err != nil {
|
||||||
|
return nil, fmt.Errorf("failed to load trader: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
trader, err := t.traderManager.GetTrader(params.TraderID)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("failed to get trader after load: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Start the trader in a goroutine
|
||||||
|
go func() {
|
||||||
|
if err := trader.Run(); err != nil {
|
||||||
|
logger.Errorf("Trader %s error: %v", params.TraderID, err)
|
||||||
|
}
|
||||||
|
}()
|
||||||
|
|
||||||
|
// Update status in database
|
||||||
|
if err := t.store.Trader().UpdateStatus("default", params.TraderID, true); err != nil {
|
||||||
|
logger.Warnf("Failed to update trader status in DB: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
return map[string]interface{}{
|
||||||
|
"success": true,
|
||||||
|
"trader_id": params.TraderID,
|
||||||
|
"message": "Trader started successfully",
|
||||||
|
}, nil
|
||||||
|
},
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// StopTraderTool stops an AI trader
|
||||||
|
func (t *TradingTools) StopTraderTool() Tool {
|
||||||
|
return NewTool(
|
||||||
|
"stop_trader",
|
||||||
|
"Stop an AI trader. This will halt automated trading but keep existing positions open.",
|
||||||
|
`{"trader_id": "string (required) - The trader ID to stop"}`,
|
||||||
|
func(ctx context.Context, args json.RawMessage) (interface{}, error) {
|
||||||
|
var params struct {
|
||||||
|
TraderID string `json:"trader_id"`
|
||||||
|
}
|
||||||
|
if err := json.Unmarshal(args, ¶ms); err != nil {
|
||||||
|
return nil, fmt.Errorf("invalid arguments: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
trader, err := t.traderManager.GetTrader(params.TraderID)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("trader not found: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check if running
|
||||||
|
status := trader.GetStatus()
|
||||||
|
if isRunning, ok := status["is_running"].(bool); ok && !isRunning {
|
||||||
|
return nil, fmt.Errorf("trader is already stopped")
|
||||||
|
}
|
||||||
|
|
||||||
|
// Stop the trader
|
||||||
|
trader.Stop()
|
||||||
|
|
||||||
|
// Update status in database
|
||||||
|
if err := t.store.Trader().UpdateStatus("default", params.TraderID, false); err != nil {
|
||||||
|
logger.Warnf("Failed to update trader status in DB: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
return map[string]interface{}{
|
||||||
|
"success": true,
|
||||||
|
"trader_id": params.TraderID,
|
||||||
|
"message": "Trader stopped successfully",
|
||||||
|
}, nil
|
||||||
|
},
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ==================== Trading Tools ====================
|
||||||
|
|
||||||
|
// GetMarketPriceTool gets current market price
|
||||||
|
func (t *TradingTools) GetMarketPriceTool() Tool {
|
||||||
|
return NewTool(
|
||||||
|
"get_market_price",
|
||||||
|
"Get current market price for a trading pair from a specific trader's exchange.",
|
||||||
|
`{"trader_id": "string (required)", "symbol": "string (required) - e.g., BTCUSDT"}`,
|
||||||
|
func(ctx context.Context, args json.RawMessage) (interface{}, error) {
|
||||||
|
var params struct {
|
||||||
|
TraderID string `json:"trader_id"`
|
||||||
|
Symbol string `json:"symbol"`
|
||||||
|
}
|
||||||
|
if err := json.Unmarshal(args, ¶ms); err != nil {
|
||||||
|
return nil, fmt.Errorf("invalid arguments: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
autoTrader, err := t.traderManager.GetTrader(params.TraderID)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("trader not found: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get the underlying trader interface
|
||||||
|
underlyingTrader := autoTrader.GetUnderlyingTrader()
|
||||||
|
if underlyingTrader == nil {
|
||||||
|
return nil, fmt.Errorf("underlying trader not available")
|
||||||
|
}
|
||||||
|
|
||||||
|
price, err := underlyingTrader.GetMarketPrice(params.Symbol)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("failed to get price: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
return map[string]interface{}{
|
||||||
|
"symbol": params.Symbol,
|
||||||
|
"price": price,
|
||||||
|
}, nil
|
||||||
|
},
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// OpenLongTool opens a long position
|
||||||
|
func (t *TradingTools) OpenLongTool() Tool {
|
||||||
|
return NewTool(
|
||||||
|
"open_long",
|
||||||
|
"Open a long (buy) position. WARNING: This will execute a real trade!",
|
||||||
|
`{"trader_id": "string (required)", "symbol": "string (required)", "quantity": "number (required)", "leverage": "number (optional, default 1)"}`,
|
||||||
|
func(ctx context.Context, args json.RawMessage) (interface{}, error) {
|
||||||
|
var params struct {
|
||||||
|
TraderID string `json:"trader_id"`
|
||||||
|
Symbol string `json:"symbol"`
|
||||||
|
Quantity float64 `json:"quantity"`
|
||||||
|
Leverage int `json:"leverage"`
|
||||||
|
}
|
||||||
|
if err := json.Unmarshal(args, ¶ms); err != nil {
|
||||||
|
return nil, fmt.Errorf("invalid arguments: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if params.Leverage == 0 {
|
||||||
|
params.Leverage = 1
|
||||||
|
}
|
||||||
|
|
||||||
|
autoTrader, err := t.traderManager.GetTrader(params.TraderID)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("trader not found: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
underlyingTrader := autoTrader.GetUnderlyingTrader()
|
||||||
|
if underlyingTrader == nil {
|
||||||
|
return nil, fmt.Errorf("underlying trader not available")
|
||||||
|
}
|
||||||
|
|
||||||
|
result, err := underlyingTrader.OpenLong(params.Symbol, params.Quantity, params.Leverage)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("failed to open long: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
return result, nil
|
||||||
|
},
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// OpenShortTool opens a short position
|
||||||
|
func (t *TradingTools) OpenShortTool() Tool {
|
||||||
|
return NewTool(
|
||||||
|
"open_short",
|
||||||
|
"Open a short (sell) position. WARNING: This will execute a real trade!",
|
||||||
|
`{"trader_id": "string (required)", "symbol": "string (required)", "quantity": "number (required)", "leverage": "number (optional, default 1)"}`,
|
||||||
|
func(ctx context.Context, args json.RawMessage) (interface{}, error) {
|
||||||
|
var params struct {
|
||||||
|
TraderID string `json:"trader_id"`
|
||||||
|
Symbol string `json:"symbol"`
|
||||||
|
Quantity float64 `json:"quantity"`
|
||||||
|
Leverage int `json:"leverage"`
|
||||||
|
}
|
||||||
|
if err := json.Unmarshal(args, ¶ms); err != nil {
|
||||||
|
return nil, fmt.Errorf("invalid arguments: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if params.Leverage == 0 {
|
||||||
|
params.Leverage = 1
|
||||||
|
}
|
||||||
|
|
||||||
|
autoTrader, err := t.traderManager.GetTrader(params.TraderID)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("trader not found: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
underlyingTrader := autoTrader.GetUnderlyingTrader()
|
||||||
|
if underlyingTrader == nil {
|
||||||
|
return nil, fmt.Errorf("underlying trader not available")
|
||||||
|
}
|
||||||
|
|
||||||
|
result, err := underlyingTrader.OpenShort(params.Symbol, params.Quantity, params.Leverage)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("failed to open short: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
return result, nil
|
||||||
|
},
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ClosePositionTool closes a position
|
||||||
|
func (t *TradingTools) ClosePositionTool() Tool {
|
||||||
|
return NewTool(
|
||||||
|
"close_position",
|
||||||
|
"Close an existing position (long or short). WARNING: This will execute a real trade!",
|
||||||
|
`{"trader_id": "string (required)", "symbol": "string (required)", "side": "string (required) - 'long' or 'short'", "quantity": "number (optional) - leave empty to close all"}`,
|
||||||
|
func(ctx context.Context, args json.RawMessage) (interface{}, error) {
|
||||||
|
var params struct {
|
||||||
|
TraderID string `json:"trader_id"`
|
||||||
|
Symbol string `json:"symbol"`
|
||||||
|
Side string `json:"side"`
|
||||||
|
Quantity float64 `json:"quantity"`
|
||||||
|
}
|
||||||
|
if err := json.Unmarshal(args, ¶ms); err != nil {
|
||||||
|
return nil, fmt.Errorf("invalid arguments: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
autoTrader, err := t.traderManager.GetTrader(params.TraderID)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("trader not found: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
underlyingTrader := autoTrader.GetUnderlyingTrader()
|
||||||
|
if underlyingTrader == nil {
|
||||||
|
return nil, fmt.Errorf("underlying trader not available")
|
||||||
|
}
|
||||||
|
|
||||||
|
var result map[string]interface{}
|
||||||
|
|
||||||
|
if params.Side == "long" {
|
||||||
|
result, err = underlyingTrader.CloseLong(params.Symbol, params.Quantity)
|
||||||
|
} else if params.Side == "short" {
|
||||||
|
result, err = underlyingTrader.CloseShort(params.Symbol, params.Quantity)
|
||||||
|
} else {
|
||||||
|
return nil, fmt.Errorf("invalid side: %s (must be 'long' or 'short')", params.Side)
|
||||||
|
}
|
||||||
|
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("failed to close position: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
return result, nil
|
||||||
|
},
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ==================== Config Tools ====================
|
||||||
|
|
||||||
|
// ListStrategiesTool lists all strategies
|
||||||
|
func (t *TradingTools) ListStrategiesTool() Tool {
|
||||||
|
return NewTool(
|
||||||
|
"list_strategies",
|
||||||
|
"List all trading strategies configured in the system.",
|
||||||
|
`{}`,
|
||||||
|
func(ctx context.Context, args json.RawMessage) (interface{}, error) {
|
||||||
|
strategies, err := t.store.Strategy().List("default")
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("failed to list strategies: %w", err)
|
||||||
|
}
|
||||||
|
return strategies, nil
|
||||||
|
},
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ListExchangesTool lists all exchange configurations
|
||||||
|
func (t *TradingTools) ListExchangesTool() Tool {
|
||||||
|
return NewTool(
|
||||||
|
"list_exchanges",
|
||||||
|
"List all configured exchanges (without showing API keys).",
|
||||||
|
`{}`,
|
||||||
|
func(ctx context.Context, args json.RawMessage) (interface{}, error) {
|
||||||
|
exchanges, err := t.store.Exchange().List("default")
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("failed to list exchanges: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Remove sensitive data
|
||||||
|
var result []map[string]interface{}
|
||||||
|
for _, ex := range exchanges {
|
||||||
|
result = append(result, map[string]interface{}{
|
||||||
|
"id": ex.ID,
|
||||||
|
"name": ex.Name,
|
||||||
|
"exchange_type": ex.ExchangeType,
|
||||||
|
"type": ex.Type,
|
||||||
|
"enabled": ex.Enabled,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
return result, nil
|
||||||
|
},
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ListAIModelsTool lists all AI model configurations
|
||||||
|
func (t *TradingTools) ListAIModelsTool() Tool {
|
||||||
|
return NewTool(
|
||||||
|
"list_ai_models",
|
||||||
|
"List all configured AI models (without showing API keys).",
|
||||||
|
`{}`,
|
||||||
|
func(ctx context.Context, args json.RawMessage) (interface{}, error) {
|
||||||
|
models, err := t.store.AIModel().List("default")
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("failed to list AI models: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Remove sensitive data
|
||||||
|
var result []map[string]interface{}
|
||||||
|
for _, m := range models {
|
||||||
|
result = append(result, map[string]interface{}{
|
||||||
|
"id": m.ID,
|
||||||
|
"name": m.Name,
|
||||||
|
"provider": m.Provider,
|
||||||
|
"custom_model": m.CustomModelName,
|
||||||
|
"enabled": m.Enabled,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
return result, nil
|
||||||
|
},
|
||||||
|
)
|
||||||
|
}
|
||||||
35
auth/auth.go
35
auth/auth.go
@@ -1,12 +1,15 @@
|
|||||||
package auth
|
package auth
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"crypto/rand"
|
||||||
"fmt"
|
"fmt"
|
||||||
"log"
|
"log"
|
||||||
"sync"
|
"sync"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"github.com/golang-jwt/jwt/v5"
|
"github.com/golang-jwt/jwt/v5"
|
||||||
|
"github.com/google/uuid"
|
||||||
|
"github.com/pquerna/otp/totp"
|
||||||
"golang.org/x/crypto/bcrypt"
|
"golang.org/x/crypto/bcrypt"
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -22,6 +25,9 @@ var tokenBlacklist = struct {
|
|||||||
// maxBlacklistEntries is the maximum capacity threshold for blacklist
|
// maxBlacklistEntries is the maximum capacity threshold for blacklist
|
||||||
const maxBlacklistEntries = 100_000
|
const maxBlacklistEntries = 100_000
|
||||||
|
|
||||||
|
// OTPIssuer is the OTP issuer name
|
||||||
|
const OTPIssuer = "nofxAI"
|
||||||
|
|
||||||
// SetJWTSecret sets the JWT secret key
|
// SetJWTSecret sets the JWT secret key
|
||||||
func SetJWTSecret(secret string) {
|
func SetJWTSecret(secret string) {
|
||||||
JWTSecret = []byte(secret)
|
JWTSecret = []byte(secret)
|
||||||
@@ -81,6 +87,30 @@ func CheckPassword(password, hash string) bool {
|
|||||||
return err == nil
|
return err == nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// GenerateOTPSecret generates OTP secret
|
||||||
|
func GenerateOTPSecret() (string, error) {
|
||||||
|
secret := make([]byte, 20)
|
||||||
|
_, err := rand.Read(secret)
|
||||||
|
if err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
|
||||||
|
key, err := totp.Generate(totp.GenerateOpts{
|
||||||
|
Issuer: OTPIssuer,
|
||||||
|
AccountName: uuid.New().String(),
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
|
||||||
|
return key.Secret(), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// VerifyOTP verifies OTP code
|
||||||
|
func VerifyOTP(secret, code string) bool {
|
||||||
|
return totp.Validate(code, secret)
|
||||||
|
}
|
||||||
|
|
||||||
// GenerateJWT generates JWT token
|
// GenerateJWT generates JWT token
|
||||||
func GenerateJWT(userID, email string) (string, error) {
|
func GenerateJWT(userID, email string) (string, error) {
|
||||||
claims := Claims{
|
claims := Claims{
|
||||||
@@ -117,3 +147,8 @@ func ValidateJWT(tokenString string) (*Claims, error) {
|
|||||||
|
|
||||||
return nil, fmt.Errorf("invalid token")
|
return nil, fmt.Errorf("invalid token")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// GetOTPQRCodeURL gets OTP QR code URL
|
||||||
|
func GetOTPQRCodeURL(secret, email string) string {
|
||||||
|
return fmt.Sprintf("otpauth://totp/%s:%s?secret=%s&issuer=%s", OTPIssuer, email, secret, OTPIssuer)
|
||||||
|
}
|
||||||
|
|||||||
267
backtest/account.go
Normal file
267
backtest/account.go
Normal file
@@ -0,0 +1,267 @@
|
|||||||
|
package backtest
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
"math"
|
||||||
|
"strings"
|
||||||
|
)
|
||||||
|
|
||||||
|
const epsilon = 1e-8
|
||||||
|
|
||||||
|
type position struct {
|
||||||
|
Symbol string
|
||||||
|
Side string
|
||||||
|
Quantity float64
|
||||||
|
EntryPrice float64
|
||||||
|
Leverage int
|
||||||
|
Margin float64
|
||||||
|
Notional float64
|
||||||
|
LiquidationPrice float64
|
||||||
|
OpenTime int64
|
||||||
|
AccumulatedFee float64 // Total fees paid (opening + any additions)
|
||||||
|
}
|
||||||
|
|
||||||
|
type BacktestAccount struct {
|
||||||
|
initialBalance float64
|
||||||
|
cash float64
|
||||||
|
feeRate float64
|
||||||
|
slippageRate float64
|
||||||
|
positions map[string]*position
|
||||||
|
realizedPnL float64
|
||||||
|
}
|
||||||
|
|
||||||
|
func NewBacktestAccount(initialBalance, feeBps, slippageBps float64) *BacktestAccount {
|
||||||
|
return &BacktestAccount{
|
||||||
|
initialBalance: initialBalance,
|
||||||
|
cash: initialBalance,
|
||||||
|
feeRate: feeBps / 10000.0,
|
||||||
|
slippageRate: slippageBps / 10000.0,
|
||||||
|
positions: make(map[string]*position),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func positionKey(symbol, side string) string {
|
||||||
|
return strings.ToUpper(symbol) + ":" + side
|
||||||
|
}
|
||||||
|
|
||||||
|
func (acc *BacktestAccount) ensurePosition(symbol, side string) *position {
|
||||||
|
key := positionKey(symbol, side)
|
||||||
|
if pos, ok := acc.positions[key]; ok {
|
||||||
|
return pos
|
||||||
|
}
|
||||||
|
pos := &position{Symbol: strings.ToUpper(symbol), Side: side}
|
||||||
|
acc.positions[key] = pos
|
||||||
|
return pos
|
||||||
|
}
|
||||||
|
|
||||||
|
func (acc *BacktestAccount) removePosition(pos *position) {
|
||||||
|
key := positionKey(pos.Symbol, pos.Side)
|
||||||
|
delete(acc.positions, key)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (acc *BacktestAccount) Open(symbol, side string, quantity float64, leverage int, price float64, ts int64) (*position, float64, float64, error) {
|
||||||
|
if quantity <= 0 {
|
||||||
|
return nil, 0, 0, fmt.Errorf("quantity must be positive")
|
||||||
|
}
|
||||||
|
if leverage <= 0 {
|
||||||
|
return nil, 0, 0, fmt.Errorf("leverage must be positive")
|
||||||
|
}
|
||||||
|
|
||||||
|
execPrice := applySlippage(price, acc.slippageRate, side, true)
|
||||||
|
notional := execPrice * quantity
|
||||||
|
margin := notional / float64(leverage)
|
||||||
|
fee := notional * acc.feeRate
|
||||||
|
|
||||||
|
if margin+fee > acc.cash+epsilon {
|
||||||
|
return nil, 0, 0, fmt.Errorf("insufficient cash: need %.2f", margin+fee)
|
||||||
|
}
|
||||||
|
|
||||||
|
acc.cash -= margin + fee
|
||||||
|
|
||||||
|
pos := acc.ensurePosition(symbol, side)
|
||||||
|
|
||||||
|
if pos.Quantity < epsilon {
|
||||||
|
pos.Quantity = quantity
|
||||||
|
pos.EntryPrice = execPrice
|
||||||
|
pos.Leverage = leverage
|
||||||
|
pos.Margin = margin
|
||||||
|
pos.Notional = notional
|
||||||
|
pos.OpenTime = ts
|
||||||
|
pos.LiquidationPrice = computeLiquidation(execPrice, leverage, side)
|
||||||
|
pos.AccumulatedFee = fee // Track opening fee
|
||||||
|
} else {
|
||||||
|
if leverage != pos.Leverage {
|
||||||
|
// Use weighted average leverage (approximate)
|
||||||
|
weightedMargin := pos.Margin + margin
|
||||||
|
pos.Leverage = int(math.Round((pos.Notional + notional) / weightedMargin))
|
||||||
|
}
|
||||||
|
pos.Notional += notional
|
||||||
|
pos.Margin += margin
|
||||||
|
pos.EntryPrice = ((pos.EntryPrice * pos.Quantity) + execPrice*quantity) / (pos.Quantity + quantity)
|
||||||
|
pos.Quantity += quantity
|
||||||
|
pos.LiquidationPrice = computeLiquidation(pos.EntryPrice, pos.Leverage, side)
|
||||||
|
pos.AccumulatedFee += fee // Add to accumulated fee for position additions
|
||||||
|
}
|
||||||
|
|
||||||
|
return pos, fee, execPrice, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (acc *BacktestAccount) Close(symbol, side string, quantity float64, price float64) (float64, float64, float64, error) {
|
||||||
|
key := positionKey(symbol, side)
|
||||||
|
pos, ok := acc.positions[key]
|
||||||
|
if !ok || pos.Quantity <= epsilon {
|
||||||
|
return 0, 0, 0, fmt.Errorf("no active %s position for %s", side, symbol)
|
||||||
|
}
|
||||||
|
|
||||||
|
if quantity <= 0 || quantity > pos.Quantity+epsilon {
|
||||||
|
if math.Abs(quantity) <= epsilon {
|
||||||
|
quantity = pos.Quantity
|
||||||
|
} else {
|
||||||
|
return 0, 0, 0, fmt.Errorf("invalid close quantity")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
execPrice := applySlippage(price, acc.slippageRate, side, false)
|
||||||
|
closeNotional := execPrice * quantity // Notional at close price (for fee calculation)
|
||||||
|
closingFee := closeNotional * acc.feeRate
|
||||||
|
|
||||||
|
// Calculate proportional values based on the portion being closed
|
||||||
|
closePortion := quantity / pos.Quantity
|
||||||
|
openingFeePortion := pos.AccumulatedFee * closePortion
|
||||||
|
totalFee := closingFee + openingFeePortion
|
||||||
|
|
||||||
|
realized := realizedPnL(pos, quantity, execPrice)
|
||||||
|
|
||||||
|
marginPortion := pos.Margin * closePortion
|
||||||
|
// BUG FIX: Calculate notional portion based on ENTRY price, not close price
|
||||||
|
// pos.Notional tracks the total notional at entry, so we must subtract proportionally
|
||||||
|
entryNotionalPortion := pos.Notional * closePortion
|
||||||
|
|
||||||
|
// Note: Opening fee was already deducted from cash when opening, so we only deduct closing fee here
|
||||||
|
acc.cash += marginPortion + realized - closingFee
|
||||||
|
// But for realized P&L tracking, we include both fees
|
||||||
|
acc.realizedPnL += realized - totalFee
|
||||||
|
|
||||||
|
pos.Quantity -= quantity
|
||||||
|
pos.Notional -= entryNotionalPortion // FIX: Use entry notional portion, not close notional
|
||||||
|
pos.Margin -= marginPortion
|
||||||
|
pos.AccumulatedFee -= openingFeePortion // Reduce tracked opening fee
|
||||||
|
|
||||||
|
if pos.Quantity <= epsilon {
|
||||||
|
acc.removePosition(pos)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Return total fee (opening + closing) so caller can calculate accurate P&L
|
||||||
|
return realized, totalFee, execPrice, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (acc *BacktestAccount) TotalEquity(priceMap map[string]float64) (float64, float64, map[string]float64) {
|
||||||
|
unrealized := 0.0
|
||||||
|
margin := 0.0
|
||||||
|
perSymbol := make(map[string]float64)
|
||||||
|
for _, pos := range acc.positions {
|
||||||
|
price := priceMap[pos.Symbol]
|
||||||
|
pnl := unrealizedPnL(pos, price)
|
||||||
|
unrealized += pnl
|
||||||
|
margin += pos.Margin
|
||||||
|
perSymbol[pos.Symbol+":"+pos.Side] = pnl
|
||||||
|
}
|
||||||
|
return acc.cash + margin + unrealized, unrealized, perSymbol
|
||||||
|
}
|
||||||
|
|
||||||
|
func applySlippage(price float64, rate float64, side string, isOpen bool) float64 {
|
||||||
|
if rate <= 0 {
|
||||||
|
return price
|
||||||
|
}
|
||||||
|
adjust := 1.0
|
||||||
|
if side == "long" {
|
||||||
|
if isOpen {
|
||||||
|
adjust += rate
|
||||||
|
} else {
|
||||||
|
adjust -= rate
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
if isOpen {
|
||||||
|
adjust -= rate
|
||||||
|
} else {
|
||||||
|
adjust += rate
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return price * adjust
|
||||||
|
}
|
||||||
|
|
||||||
|
func computeLiquidation(entry float64, leverage int, side string) float64 {
|
||||||
|
if leverage <= 0 {
|
||||||
|
return 0
|
||||||
|
}
|
||||||
|
lev := float64(leverage)
|
||||||
|
if side == "long" {
|
||||||
|
return entry * (1.0 - 1.0/lev)
|
||||||
|
}
|
||||||
|
return entry * (1.0 + 1.0/lev)
|
||||||
|
}
|
||||||
|
|
||||||
|
func realizedPnL(pos *position, qty, price float64) float64 {
|
||||||
|
if pos.Side == "long" {
|
||||||
|
return (price - pos.EntryPrice) * qty
|
||||||
|
}
|
||||||
|
return (pos.EntryPrice - price) * qty
|
||||||
|
}
|
||||||
|
|
||||||
|
func unrealizedPnL(pos *position, price float64) float64 {
|
||||||
|
if pos.Side == "long" {
|
||||||
|
return (price - pos.EntryPrice) * pos.Quantity
|
||||||
|
}
|
||||||
|
return (pos.EntryPrice - price) * pos.Quantity
|
||||||
|
}
|
||||||
|
|
||||||
|
func (acc *BacktestAccount) Positions() []*position {
|
||||||
|
list := make([]*position, 0, len(acc.positions))
|
||||||
|
for _, pos := range acc.positions {
|
||||||
|
list = append(list, pos)
|
||||||
|
}
|
||||||
|
return list
|
||||||
|
}
|
||||||
|
|
||||||
|
func (acc *BacktestAccount) positionLeverage(symbol, side string) int {
|
||||||
|
key := positionKey(symbol, side)
|
||||||
|
if pos, ok := acc.positions[key]; ok && pos.Quantity > epsilon {
|
||||||
|
return pos.Leverage
|
||||||
|
}
|
||||||
|
return 0
|
||||||
|
}
|
||||||
|
|
||||||
|
func (acc *BacktestAccount) Cash() float64 {
|
||||||
|
return acc.cash
|
||||||
|
}
|
||||||
|
|
||||||
|
func (acc *BacktestAccount) InitialBalance() float64 {
|
||||||
|
return acc.initialBalance
|
||||||
|
}
|
||||||
|
|
||||||
|
func (acc *BacktestAccount) RealizedPnL() float64 {
|
||||||
|
return acc.realizedPnL
|
||||||
|
}
|
||||||
|
|
||||||
|
// RestoreFromSnapshots restores account state from checkpoint.
|
||||||
|
func (acc *BacktestAccount) RestoreFromSnapshots(cash float64, realized float64, snaps []PositionSnapshot) {
|
||||||
|
acc.cash = cash
|
||||||
|
acc.realizedPnL = realized
|
||||||
|
acc.positions = make(map[string]*position)
|
||||||
|
for _, snap := range snaps {
|
||||||
|
pos := &position{
|
||||||
|
Symbol: snap.Symbol,
|
||||||
|
Side: snap.Side,
|
||||||
|
Quantity: snap.Quantity,
|
||||||
|
EntryPrice: snap.AvgPrice,
|
||||||
|
Leverage: snap.Leverage,
|
||||||
|
Margin: snap.MarginUsed,
|
||||||
|
Notional: snap.Quantity * snap.AvgPrice,
|
||||||
|
LiquidationPrice: snap.LiquidationPrice,
|
||||||
|
OpenTime: snap.OpenTime,
|
||||||
|
AccumulatedFee: snap.AccumulatedFee,
|
||||||
|
}
|
||||||
|
key := positionKey(pos.Symbol, pos.Side)
|
||||||
|
acc.positions[key] = pos
|
||||||
|
}
|
||||||
|
}
|
||||||
131
backtest/ai_client.go
Normal file
131
backtest/ai_client.go
Normal file
@@ -0,0 +1,131 @@
|
|||||||
|
package backtest
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
"strings"
|
||||||
|
|
||||||
|
"nofx/mcp"
|
||||||
|
)
|
||||||
|
|
||||||
|
// configureMCPClient creates/clones an MCP client based on configuration (returns mcp.AIClient interface).
|
||||||
|
// Note: mcp.New() returns an interface type; here we convert to concrete implementation before copying to avoid concurrent shared state.
|
||||||
|
func configureMCPClient(cfg BacktestConfig, base mcp.AIClient) (mcp.AIClient, error) {
|
||||||
|
provider := strings.ToLower(strings.TrimSpace(cfg.AICfg.Provider))
|
||||||
|
|
||||||
|
// DeepSeek
|
||||||
|
if provider == "" || provider == "inherit" || provider == "default" {
|
||||||
|
client := cloneBaseClient(base)
|
||||||
|
if cfg.AICfg.APIKey != "" || cfg.AICfg.BaseURL != "" || cfg.AICfg.Model != "" {
|
||||||
|
client.SetAPIKey(cfg.AICfg.APIKey, cfg.AICfg.BaseURL, cfg.AICfg.Model)
|
||||||
|
}
|
||||||
|
return client, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
switch provider {
|
||||||
|
case "deepseek":
|
||||||
|
if cfg.AICfg.APIKey == "" {
|
||||||
|
return nil, fmt.Errorf("deepseek provider requires api key")
|
||||||
|
}
|
||||||
|
ds := mcp.NewDeepSeekClientWithOptions()
|
||||||
|
ds.(*mcp.DeepSeekClient).SetAPIKey(cfg.AICfg.APIKey, cfg.AICfg.BaseURL, cfg.AICfg.Model)
|
||||||
|
return ds, nil
|
||||||
|
case "qwen":
|
||||||
|
if cfg.AICfg.APIKey == "" {
|
||||||
|
return nil, fmt.Errorf("qwen provider requires api key")
|
||||||
|
}
|
||||||
|
qc := mcp.NewQwenClientWithOptions()
|
||||||
|
qc.(*mcp.QwenClient).SetAPIKey(cfg.AICfg.APIKey, cfg.AICfg.BaseURL, cfg.AICfg.Model)
|
||||||
|
return qc, nil
|
||||||
|
case "claude":
|
||||||
|
if cfg.AICfg.APIKey == "" {
|
||||||
|
return nil, fmt.Errorf("claude provider requires api key")
|
||||||
|
}
|
||||||
|
cc := mcp.NewClaudeClientWithOptions()
|
||||||
|
cc.(*mcp.ClaudeClient).SetAPIKey(cfg.AICfg.APIKey, cfg.AICfg.BaseURL, cfg.AICfg.Model)
|
||||||
|
return cc, nil
|
||||||
|
case "kimi":
|
||||||
|
if cfg.AICfg.APIKey == "" {
|
||||||
|
return nil, fmt.Errorf("kimi provider requires api key")
|
||||||
|
}
|
||||||
|
kc := mcp.NewKimiClientWithOptions()
|
||||||
|
kc.(*mcp.KimiClient).SetAPIKey(cfg.AICfg.APIKey, cfg.AICfg.BaseURL, cfg.AICfg.Model)
|
||||||
|
return kc, nil
|
||||||
|
case "gemini":
|
||||||
|
if cfg.AICfg.APIKey == "" {
|
||||||
|
return nil, fmt.Errorf("gemini provider requires api key")
|
||||||
|
}
|
||||||
|
gc := mcp.NewGeminiClientWithOptions()
|
||||||
|
gc.(*mcp.GeminiClient).SetAPIKey(cfg.AICfg.APIKey, cfg.AICfg.BaseURL, cfg.AICfg.Model)
|
||||||
|
return gc, nil
|
||||||
|
case "grok":
|
||||||
|
if cfg.AICfg.APIKey == "" {
|
||||||
|
return nil, fmt.Errorf("grok provider requires api key")
|
||||||
|
}
|
||||||
|
grokC := mcp.NewGrokClientWithOptions()
|
||||||
|
grokC.(*mcp.GrokClient).SetAPIKey(cfg.AICfg.APIKey, cfg.AICfg.BaseURL, cfg.AICfg.Model)
|
||||||
|
return grokC, nil
|
||||||
|
case "openai":
|
||||||
|
if cfg.AICfg.APIKey == "" {
|
||||||
|
return nil, fmt.Errorf("openai provider requires api key")
|
||||||
|
}
|
||||||
|
oaiC := mcp.NewOpenAIClientWithOptions()
|
||||||
|
oaiC.(*mcp.OpenAIClient).SetAPIKey(cfg.AICfg.APIKey, cfg.AICfg.BaseURL, cfg.AICfg.Model)
|
||||||
|
return oaiC, nil
|
||||||
|
case "custom":
|
||||||
|
if cfg.AICfg.BaseURL == "" || cfg.AICfg.APIKey == "" || cfg.AICfg.Model == "" {
|
||||||
|
return nil, fmt.Errorf("custom provider requires base_url, api key and model")
|
||||||
|
}
|
||||||
|
client := cloneBaseClient(base)
|
||||||
|
client.SetAPIKey(cfg.AICfg.APIKey, cfg.AICfg.BaseURL, cfg.AICfg.Model)
|
||||||
|
return client, nil
|
||||||
|
default:
|
||||||
|
return nil, fmt.Errorf("unsupported ai provider %s", cfg.AICfg.Provider)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// cloneBaseClient copies the base client to avoid shared mutable state.
|
||||||
|
func cloneBaseClient(base mcp.AIClient) *mcp.Client {
|
||||||
|
// Prefer to reuse the passed-in base client (deep copy)
|
||||||
|
switch c := base.(type) {
|
||||||
|
case *mcp.Client:
|
||||||
|
cp := *c
|
||||||
|
return &cp
|
||||||
|
case *mcp.DeepSeekClient:
|
||||||
|
if c != nil && c.Client != nil {
|
||||||
|
cp := *c.Client
|
||||||
|
return &cp
|
||||||
|
}
|
||||||
|
case *mcp.QwenClient:
|
||||||
|
if c != nil && c.Client != nil {
|
||||||
|
cp := *c.Client
|
||||||
|
return &cp
|
||||||
|
}
|
||||||
|
case *mcp.ClaudeClient:
|
||||||
|
if c != nil && c.Client != nil {
|
||||||
|
cp := *c.Client
|
||||||
|
return &cp
|
||||||
|
}
|
||||||
|
case *mcp.KimiClient:
|
||||||
|
if c != nil && c.Client != nil {
|
||||||
|
cp := *c.Client
|
||||||
|
return &cp
|
||||||
|
}
|
||||||
|
case *mcp.GeminiClient:
|
||||||
|
if c != nil && c.Client != nil {
|
||||||
|
cp := *c.Client
|
||||||
|
return &cp
|
||||||
|
}
|
||||||
|
case *mcp.GrokClient:
|
||||||
|
if c != nil && c.Client != nil {
|
||||||
|
cp := *c.Client
|
||||||
|
return &cp
|
||||||
|
}
|
||||||
|
case *mcp.OpenAIClient:
|
||||||
|
if c != nil && c.Client != nil {
|
||||||
|
cp := *c.Client
|
||||||
|
return &cp
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// Fall back to a new default client
|
||||||
|
return mcp.NewClient().(*mcp.Client)
|
||||||
|
}
|
||||||
168
backtest/aicache.go
Normal file
168
backtest/aicache.go
Normal file
@@ -0,0 +1,168 @@
|
|||||||
|
package backtest
|
||||||
|
|
||||||
|
import (
|
||||||
|
"crypto/sha256"
|
||||||
|
"encoding/hex"
|
||||||
|
"encoding/json"
|
||||||
|
"fmt"
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"sync"
|
||||||
|
|
||||||
|
"nofx/kernel"
|
||||||
|
"nofx/market"
|
||||||
|
)
|
||||||
|
|
||||||
|
type cachedDecision struct {
|
||||||
|
Key string `json:"key"`
|
||||||
|
PromptVariant string `json:"prompt_variant"`
|
||||||
|
Timestamp int64 `json:"ts"`
|
||||||
|
Decision *kernel.FullDecision `json:"decision"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// AICache persists AI decisions for repeated backtesting or replay.
|
||||||
|
type AICache struct {
|
||||||
|
mu sync.RWMutex
|
||||||
|
path string
|
||||||
|
Entries map[string]cachedDecision `json:"entries"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func LoadAICache(path string) (*AICache, error) {
|
||||||
|
if path == "" {
|
||||||
|
return nil, fmt.Errorf("ai cache path is empty")
|
||||||
|
}
|
||||||
|
dir := filepath.Dir(path)
|
||||||
|
if err := os.MkdirAll(dir, 0o755); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
cache := &AICache{
|
||||||
|
path: path,
|
||||||
|
Entries: make(map[string]cachedDecision),
|
||||||
|
}
|
||||||
|
|
||||||
|
data, err := os.ReadFile(path)
|
||||||
|
if err != nil {
|
||||||
|
if os.IsNotExist(err) {
|
||||||
|
return cache, nil
|
||||||
|
}
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
if len(data) == 0 {
|
||||||
|
return cache, nil
|
||||||
|
}
|
||||||
|
if err := json.Unmarshal(data, cache); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
if cache.Entries == nil {
|
||||||
|
cache.Entries = make(map[string]cachedDecision)
|
||||||
|
}
|
||||||
|
return cache, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *AICache) Path() string {
|
||||||
|
if c == nil {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
return c.path
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *AICache) Get(key string) (*kernel.FullDecision, bool) {
|
||||||
|
if c == nil || key == "" {
|
||||||
|
return nil, false
|
||||||
|
}
|
||||||
|
c.mu.RLock()
|
||||||
|
entry, ok := c.Entries[key]
|
||||||
|
c.mu.RUnlock()
|
||||||
|
if !ok || entry.Decision == nil {
|
||||||
|
return nil, false
|
||||||
|
}
|
||||||
|
return cloneDecision(entry.Decision), true
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *AICache) Put(key string, variant string, ts int64, decision *kernel.FullDecision) error {
|
||||||
|
if c == nil || key == "" || decision == nil {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
entry := cachedDecision{
|
||||||
|
Key: key,
|
||||||
|
PromptVariant: variant,
|
||||||
|
Timestamp: ts,
|
||||||
|
Decision: cloneDecision(decision),
|
||||||
|
}
|
||||||
|
c.mu.Lock()
|
||||||
|
c.Entries[key] = entry
|
||||||
|
c.mu.Unlock()
|
||||||
|
return c.save()
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *AICache) save() error {
|
||||||
|
if c == nil || c.path == "" {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
c.mu.RLock()
|
||||||
|
data, err := json.MarshalIndent(c, "", " ")
|
||||||
|
c.mu.RUnlock()
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
return writeFileAtomic(c.path, data, 0o644)
|
||||||
|
}
|
||||||
|
|
||||||
|
func cloneDecision(src *kernel.FullDecision) *kernel.FullDecision {
|
||||||
|
if src == nil {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
data, err := json.Marshal(src)
|
||||||
|
if err != nil {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
var dst kernel.FullDecision
|
||||||
|
if err := json.Unmarshal(data, &dst); err != nil {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
return &dst
|
||||||
|
}
|
||||||
|
|
||||||
|
func computeCacheKey(ctx *kernel.Context, variant string, ts int64) (string, error) {
|
||||||
|
if ctx == nil {
|
||||||
|
return "", fmt.Errorf("context is nil")
|
||||||
|
}
|
||||||
|
payload := struct {
|
||||||
|
Variant string `json:"variant"`
|
||||||
|
Timestamp int64 `json:"ts"`
|
||||||
|
CurrentTime string `json:"current_time"`
|
||||||
|
Account kernel.AccountInfo `json:"account"`
|
||||||
|
Positions []kernel.PositionInfo `json:"positions"`
|
||||||
|
CandidateCoins []kernel.CandidateCoin `json:"candidate_coins"`
|
||||||
|
MarketData map[string]market.Data `json:"market"`
|
||||||
|
MarginUsedPct float64 `json:"margin_used_pct"`
|
||||||
|
Runtime int `json:"runtime_minutes"`
|
||||||
|
CallCount int `json:"call_count"`
|
||||||
|
}{
|
||||||
|
Variant: variant,
|
||||||
|
Timestamp: ts,
|
||||||
|
CurrentTime: ctx.CurrentTime,
|
||||||
|
Account: ctx.Account,
|
||||||
|
Positions: ctx.Positions,
|
||||||
|
CandidateCoins: ctx.CandidateCoins,
|
||||||
|
MarginUsedPct: ctx.Account.MarginUsedPct,
|
||||||
|
Runtime: ctx.RuntimeMinutes,
|
||||||
|
CallCount: ctx.CallCount,
|
||||||
|
MarketData: make(map[string]market.Data, len(ctx.MarketDataMap)),
|
||||||
|
}
|
||||||
|
|
||||||
|
for symbol, data := range ctx.MarketDataMap {
|
||||||
|
if data == nil {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
payload.MarketData[symbol] = *data
|
||||||
|
}
|
||||||
|
|
||||||
|
bytes, err := json.Marshal(payload)
|
||||||
|
if err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
sum := sha256.Sum256(bytes)
|
||||||
|
return hex.EncodeToString(sum[:]), nil
|
||||||
|
}
|
||||||
285
backtest/config.go
Normal file
285
backtest/config.go
Normal file
@@ -0,0 +1,285 @@
|
|||||||
|
package backtest
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
"strings"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"nofx/market"
|
||||||
|
"nofx/store"
|
||||||
|
)
|
||||||
|
|
||||||
|
// AIConfig defines the AI client configuration used in backtesting.
|
||||||
|
type AIConfig struct {
|
||||||
|
Provider string `json:"provider"`
|
||||||
|
Model string `json:"model"`
|
||||||
|
APIKey string `json:"key"`
|
||||||
|
SecretKey string `json:"secret_key,omitempty"`
|
||||||
|
BaseURL string `json:"base_url,omitempty"`
|
||||||
|
Temperature float64 `json:"temperature,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type LeverageConfig struct {
|
||||||
|
BTCETHLeverage int `json:"btc_eth_leverage"`
|
||||||
|
AltcoinLeverage int `json:"altcoin_leverage"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// BacktestConfig describes the input configuration for a backtest run.
|
||||||
|
type BacktestConfig struct {
|
||||||
|
RunID string `json:"run_id"`
|
||||||
|
UserID string `json:"user_id,omitempty"`
|
||||||
|
AIModelID string `json:"ai_model_id,omitempty"`
|
||||||
|
StrategyID string `json:"strategy_id,omitempty"` // Optional: use saved strategy from Strategy Studio
|
||||||
|
Symbols []string `json:"symbols"`
|
||||||
|
Timeframes []string `json:"timeframes"`
|
||||||
|
DecisionTimeframe string `json:"decision_timeframe"`
|
||||||
|
DecisionCadenceNBars int `json:"decision_cadence_nbars"`
|
||||||
|
StartTS int64 `json:"start_ts"`
|
||||||
|
EndTS int64 `json:"end_ts"`
|
||||||
|
InitialBalance float64 `json:"initial_balance"`
|
||||||
|
FeeBps float64 `json:"fee_bps"`
|
||||||
|
SlippageBps float64 `json:"slippage_bps"`
|
||||||
|
FillPolicy string `json:"fill_policy"`
|
||||||
|
PromptVariant string `json:"prompt_variant"`
|
||||||
|
PromptTemplate string `json:"prompt_template"`
|
||||||
|
CustomPrompt string `json:"custom_prompt"`
|
||||||
|
OverrideBasePrompt bool `json:"override_prompt"`
|
||||||
|
CacheAI bool `json:"cache_ai"`
|
||||||
|
ReplayOnly bool `json:"replay_only"`
|
||||||
|
|
||||||
|
AICfg AIConfig `json:"ai"`
|
||||||
|
Leverage LeverageConfig `json:"leverage"`
|
||||||
|
|
||||||
|
SharedAICachePath string `json:"ai_cache_path,omitempty"`
|
||||||
|
CheckpointIntervalBars int `json:"checkpoint_interval_bars,omitempty"`
|
||||||
|
CheckpointIntervalSeconds int `json:"checkpoint_interval_seconds,omitempty"`
|
||||||
|
ReplayDecisionDir string `json:"replay_decision_dir,omitempty"`
|
||||||
|
|
||||||
|
// Internal: loaded strategy config (set by Manager when StrategyID is provided)
|
||||||
|
loadedStrategy *store.StrategyConfig `json:"-"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// Validate performs validity checks on the configuration and fills in default values.
|
||||||
|
func (cfg *BacktestConfig) Validate() error {
|
||||||
|
if cfg == nil {
|
||||||
|
return fmt.Errorf("config is nil")
|
||||||
|
}
|
||||||
|
cfg.RunID = strings.TrimSpace(cfg.RunID)
|
||||||
|
if cfg.RunID == "" {
|
||||||
|
return fmt.Errorf("run_id cannot be empty")
|
||||||
|
}
|
||||||
|
cfg.UserID = strings.TrimSpace(cfg.UserID)
|
||||||
|
if cfg.UserID == "" {
|
||||||
|
cfg.UserID = "default"
|
||||||
|
}
|
||||||
|
cfg.AIModelID = strings.TrimSpace(cfg.AIModelID)
|
||||||
|
|
||||||
|
if len(cfg.Symbols) == 0 {
|
||||||
|
return fmt.Errorf("at least one symbol is required")
|
||||||
|
}
|
||||||
|
for i, sym := range cfg.Symbols {
|
||||||
|
cfg.Symbols[i] = market.Normalize(sym)
|
||||||
|
}
|
||||||
|
|
||||||
|
if len(cfg.Timeframes) == 0 {
|
||||||
|
cfg.Timeframes = []string{"3m", "15m", "4h"}
|
||||||
|
}
|
||||||
|
normTF := make([]string, 0, len(cfg.Timeframes))
|
||||||
|
for _, tf := range cfg.Timeframes {
|
||||||
|
normalized, err := market.NormalizeTimeframe(tf)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("invalid timeframe '%s': %w", tf, err)
|
||||||
|
}
|
||||||
|
normTF = append(normTF, normalized)
|
||||||
|
}
|
||||||
|
cfg.Timeframes = normTF
|
||||||
|
|
||||||
|
if cfg.DecisionTimeframe == "" {
|
||||||
|
cfg.DecisionTimeframe = cfg.Timeframes[0]
|
||||||
|
}
|
||||||
|
normalizedDecision, err := market.NormalizeTimeframe(cfg.DecisionTimeframe)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("invalid decision_timeframe: %w", err)
|
||||||
|
}
|
||||||
|
cfg.DecisionTimeframe = normalizedDecision
|
||||||
|
|
||||||
|
if cfg.DecisionCadenceNBars <= 0 {
|
||||||
|
cfg.DecisionCadenceNBars = 20
|
||||||
|
}
|
||||||
|
|
||||||
|
if cfg.StartTS <= 0 || cfg.EndTS <= 0 || cfg.EndTS <= cfg.StartTS {
|
||||||
|
return fmt.Errorf("invalid start_ts/end_ts")
|
||||||
|
}
|
||||||
|
|
||||||
|
if cfg.InitialBalance <= 0 {
|
||||||
|
cfg.InitialBalance = 1000
|
||||||
|
}
|
||||||
|
|
||||||
|
if cfg.FillPolicy == "" {
|
||||||
|
cfg.FillPolicy = FillPolicyNextOpen
|
||||||
|
}
|
||||||
|
if err := validateFillPolicy(cfg.FillPolicy); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
if cfg.CheckpointIntervalBars <= 0 {
|
||||||
|
cfg.CheckpointIntervalBars = 20
|
||||||
|
}
|
||||||
|
if cfg.CheckpointIntervalSeconds <= 0 {
|
||||||
|
cfg.CheckpointIntervalSeconds = 2
|
||||||
|
}
|
||||||
|
|
||||||
|
cfg.PromptVariant = strings.TrimSpace(cfg.PromptVariant)
|
||||||
|
if cfg.PromptVariant == "" {
|
||||||
|
cfg.PromptVariant = "baseline"
|
||||||
|
}
|
||||||
|
cfg.PromptTemplate = strings.TrimSpace(cfg.PromptTemplate)
|
||||||
|
if cfg.PromptTemplate == "" {
|
||||||
|
cfg.PromptTemplate = "default"
|
||||||
|
}
|
||||||
|
cfg.CustomPrompt = strings.TrimSpace(cfg.CustomPrompt)
|
||||||
|
|
||||||
|
if cfg.AICfg.Provider == "" {
|
||||||
|
cfg.AICfg.Provider = "inherit"
|
||||||
|
}
|
||||||
|
if cfg.AICfg.Temperature == 0 {
|
||||||
|
cfg.AICfg.Temperature = 0.4
|
||||||
|
}
|
||||||
|
|
||||||
|
if cfg.Leverage.BTCETHLeverage <= 0 {
|
||||||
|
cfg.Leverage.BTCETHLeverage = 5
|
||||||
|
}
|
||||||
|
if cfg.Leverage.AltcoinLeverage <= 0 {
|
||||||
|
cfg.Leverage.AltcoinLeverage = 5
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Duration returns the backtest interval duration.
|
||||||
|
func (cfg *BacktestConfig) Duration() time.Duration {
|
||||||
|
if cfg == nil {
|
||||||
|
return 0
|
||||||
|
}
|
||||||
|
return time.Unix(cfg.EndTS, 0).Sub(time.Unix(cfg.StartTS, 0))
|
||||||
|
}
|
||||||
|
|
||||||
|
const (
|
||||||
|
// FillPolicyNextOpen uses the open price of the next bar for execution.
|
||||||
|
FillPolicyNextOpen = "next_open"
|
||||||
|
// FillPolicyBarVWAP uses the approximate VWAP of the current bar for execution.
|
||||||
|
FillPolicyBarVWAP = "bar_vwap"
|
||||||
|
// FillPolicyMidPrice uses the mid-price (high+low)/2 for execution.
|
||||||
|
FillPolicyMidPrice = "mid"
|
||||||
|
)
|
||||||
|
|
||||||
|
func validateFillPolicy(policy string) error {
|
||||||
|
switch policy {
|
||||||
|
case FillPolicyNextOpen, FillPolicyBarVWAP, FillPolicyMidPrice:
|
||||||
|
return nil
|
||||||
|
default:
|
||||||
|
return fmt.Errorf("unsupported fill_policy '%s'", policy)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// SetLoadedStrategy sets the loaded strategy config from database.
|
||||||
|
func (cfg *BacktestConfig) SetLoadedStrategy(strategy *store.StrategyConfig) {
|
||||||
|
cfg.loadedStrategy = strategy
|
||||||
|
}
|
||||||
|
|
||||||
|
// ToStrategyConfig converts BacktestConfig to StrategyConfig for unified prompt generation.
|
||||||
|
// This ensures backtest uses the same StrategyEngine logic as live trading.
|
||||||
|
// If a strategy was loaded from database (via StrategyID), it will be used with overrides.
|
||||||
|
func (cfg *BacktestConfig) ToStrategyConfig() *store.StrategyConfig {
|
||||||
|
// If a strategy was loaded from database, use it with some overrides
|
||||||
|
if cfg.loadedStrategy != nil {
|
||||||
|
result := *cfg.loadedStrategy // Make a copy
|
||||||
|
|
||||||
|
// Override coin source with backtest symbols (回测指定的币对优先)
|
||||||
|
if len(cfg.Symbols) > 0 {
|
||||||
|
result.CoinSource.SourceType = "static"
|
||||||
|
result.CoinSource.StaticCoins = cfg.Symbols
|
||||||
|
result.CoinSource.UseAI500 = false
|
||||||
|
result.CoinSource.UseOITop = false
|
||||||
|
}
|
||||||
|
|
||||||
|
// Override timeframes with backtest config
|
||||||
|
if len(cfg.Timeframes) > 0 {
|
||||||
|
result.Indicators.Klines.SelectedTimeframes = cfg.Timeframes
|
||||||
|
result.Indicators.Klines.PrimaryTimeframe = cfg.Timeframes[0]
|
||||||
|
if len(cfg.Timeframes) > 1 {
|
||||||
|
result.Indicators.Klines.LongerTimeframe = cfg.Timeframes[len(cfg.Timeframes)-1]
|
||||||
|
}
|
||||||
|
result.Indicators.Klines.EnableMultiTimeframe = len(cfg.Timeframes) > 1
|
||||||
|
}
|
||||||
|
|
||||||
|
// Override leverage with backtest config
|
||||||
|
if cfg.Leverage.BTCETHLeverage > 0 {
|
||||||
|
result.RiskControl.BTCETHMaxLeverage = cfg.Leverage.BTCETHLeverage
|
||||||
|
}
|
||||||
|
if cfg.Leverage.AltcoinLeverage > 0 {
|
||||||
|
result.RiskControl.AltcoinMaxLeverage = cfg.Leverage.AltcoinLeverage
|
||||||
|
}
|
||||||
|
|
||||||
|
// Override custom prompt if provided in backtest config
|
||||||
|
if cfg.CustomPrompt != "" {
|
||||||
|
result.CustomPrompt = cfg.CustomPrompt
|
||||||
|
}
|
||||||
|
|
||||||
|
return &result
|
||||||
|
}
|
||||||
|
|
||||||
|
// Fallback: build strategy config from backtest config (original logic)
|
||||||
|
primaryTF := "5m"
|
||||||
|
longerTF := "4h"
|
||||||
|
if len(cfg.Timeframes) > 0 {
|
||||||
|
primaryTF = cfg.Timeframes[0]
|
||||||
|
}
|
||||||
|
if len(cfg.Timeframes) > 1 {
|
||||||
|
longerTF = cfg.Timeframes[len(cfg.Timeframes)-1]
|
||||||
|
}
|
||||||
|
|
||||||
|
return &store.StrategyConfig{
|
||||||
|
CoinSource: store.CoinSourceConfig{
|
||||||
|
SourceType: "static",
|
||||||
|
StaticCoins: cfg.Symbols,
|
||||||
|
UseAI500: false,
|
||||||
|
AI500Limit: len(cfg.Symbols),
|
||||||
|
UseOITop: false,
|
||||||
|
OITopLimit: 0,
|
||||||
|
},
|
||||||
|
Indicators: store.IndicatorConfig{
|
||||||
|
Klines: store.KlineConfig{
|
||||||
|
PrimaryTimeframe: primaryTF,
|
||||||
|
PrimaryCount: 30,
|
||||||
|
LongerTimeframe: longerTF,
|
||||||
|
LongerCount: 10,
|
||||||
|
EnableMultiTimeframe: len(cfg.Timeframes) > 1,
|
||||||
|
SelectedTimeframes: cfg.Timeframes,
|
||||||
|
},
|
||||||
|
EnableRawKlines: true,
|
||||||
|
EnableEMA: true,
|
||||||
|
EnableMACD: true,
|
||||||
|
EnableRSI: true,
|
||||||
|
EnableATR: true,
|
||||||
|
EnableVolume: true,
|
||||||
|
EnableOI: true,
|
||||||
|
EnableFundingRate: true,
|
||||||
|
EMAPeriods: []int{20, 50},
|
||||||
|
RSIPeriods: []int{7, 14},
|
||||||
|
ATRPeriods: []int{14},
|
||||||
|
},
|
||||||
|
CustomPrompt: cfg.CustomPrompt,
|
||||||
|
RiskControl: store.RiskControlConfig{
|
||||||
|
MaxPositions: 3,
|
||||||
|
BTCETHMaxLeverage: cfg.Leverage.BTCETHLeverage,
|
||||||
|
AltcoinMaxLeverage: cfg.Leverage.AltcoinLeverage,
|
||||||
|
BTCETHMaxPositionValueRatio: 5.0,
|
||||||
|
AltcoinMaxPositionValueRatio: 1.0,
|
||||||
|
MaxMarginUsage: 0.9,
|
||||||
|
MinPositionSize: 12,
|
||||||
|
MinRiskRewardRatio: 3.0,
|
||||||
|
MinConfidence: 75,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
206
backtest/datafeed.go
Normal file
206
backtest/datafeed.go
Normal file
@@ -0,0 +1,206 @@
|
|||||||
|
package backtest
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
"sort"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"nofx/market"
|
||||||
|
)
|
||||||
|
|
||||||
|
type timeframeSeries struct {
|
||||||
|
klines []market.Kline
|
||||||
|
closeTimes []int64
|
||||||
|
}
|
||||||
|
|
||||||
|
type symbolSeries struct {
|
||||||
|
byTF map[string]*timeframeSeries
|
||||||
|
}
|
||||||
|
|
||||||
|
// DataFeed manages historical kline data and provides time-progressive snapshots for backtesting.
|
||||||
|
type DataFeed struct {
|
||||||
|
cfg BacktestConfig
|
||||||
|
symbols []string
|
||||||
|
timeframes []string
|
||||||
|
symbolSeries map[string]*symbolSeries
|
||||||
|
decisionTimes []int64
|
||||||
|
primaryTF string
|
||||||
|
longerTF string
|
||||||
|
}
|
||||||
|
|
||||||
|
func NewDataFeed(cfg BacktestConfig) (*DataFeed, error) {
|
||||||
|
df := &DataFeed{
|
||||||
|
cfg: cfg,
|
||||||
|
symbols: make([]string, len(cfg.Symbols)),
|
||||||
|
timeframes: append([]string(nil), cfg.Timeframes...),
|
||||||
|
symbolSeries: make(map[string]*symbolSeries),
|
||||||
|
primaryTF: cfg.DecisionTimeframe,
|
||||||
|
}
|
||||||
|
copy(df.symbols, cfg.Symbols)
|
||||||
|
|
||||||
|
if err := df.loadAll(); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
return df, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (df *DataFeed) loadAll() error {
|
||||||
|
start := time.Unix(df.cfg.StartTS, 0)
|
||||||
|
end := time.Unix(df.cfg.EndTS, 0)
|
||||||
|
|
||||||
|
// longest timeframe used for auxiliary indicators
|
||||||
|
var longestDur time.Duration
|
||||||
|
for _, tf := range df.timeframes {
|
||||||
|
dur, err := market.TFDuration(tf)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if dur > longestDur {
|
||||||
|
longestDur = dur
|
||||||
|
df.longerTF = tf
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, symbol := range df.symbols {
|
||||||
|
ss := &symbolSeries{byTF: make(map[string]*timeframeSeries)}
|
||||||
|
for _, tf := range df.timeframes {
|
||||||
|
dur, _ := market.TFDuration(tf)
|
||||||
|
buffer := dur * 200
|
||||||
|
fetchStart := start.Add(-buffer)
|
||||||
|
if fetchStart.Before(time.Unix(0, 0)) {
|
||||||
|
fetchStart = time.Unix(0, 0)
|
||||||
|
}
|
||||||
|
fetchEnd := end.Add(dur)
|
||||||
|
|
||||||
|
klines, err := market.GetKlinesRange(symbol, tf, fetchStart, fetchEnd)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("fetch klines for %s %s: %w", symbol, tf, err)
|
||||||
|
}
|
||||||
|
if len(klines) == 0 {
|
||||||
|
return fmt.Errorf("no klines for %s %s", symbol, tf)
|
||||||
|
}
|
||||||
|
|
||||||
|
series := &timeframeSeries{
|
||||||
|
klines: klines,
|
||||||
|
closeTimes: make([]int64, len(klines)),
|
||||||
|
}
|
||||||
|
for i, k := range klines {
|
||||||
|
series.closeTimes[i] = k.CloseTime
|
||||||
|
}
|
||||||
|
ss.byTF[tf] = series
|
||||||
|
}
|
||||||
|
df.symbolSeries[symbol] = ss
|
||||||
|
}
|
||||||
|
|
||||||
|
// Generate backtest progress timeline using the primary timeframe of the first symbol
|
||||||
|
firstSymbol := df.symbols[0]
|
||||||
|
primarySeries := df.symbolSeries[firstSymbol].byTF[df.primaryTF]
|
||||||
|
startMs := start.UnixMilli()
|
||||||
|
endMs := end.UnixMilli()
|
||||||
|
for _, ts := range primarySeries.closeTimes {
|
||||||
|
if ts < startMs {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if ts > endMs {
|
||||||
|
break
|
||||||
|
}
|
||||||
|
df.decisionTimes = append(df.decisionTimes, ts)
|
||||||
|
// Align other symbols; report error early if data is missing
|
||||||
|
for _, symbol := range df.symbols[1:] {
|
||||||
|
if _, ok := df.symbolSeries[symbol].byTF[df.primaryTF]; !ok {
|
||||||
|
return fmt.Errorf("symbol %s missing timeframe %s", symbol, df.primaryTF)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if len(df.decisionTimes) == 0 {
|
||||||
|
return fmt.Errorf("no decision bars in range")
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (df *DataFeed) DecisionBarCount() int {
|
||||||
|
return len(df.decisionTimes)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (df *DataFeed) DecisionTimestamp(index int) int64 {
|
||||||
|
// Bounds check to prevent panic
|
||||||
|
if index < 0 || index >= len(df.decisionTimes) {
|
||||||
|
return 0
|
||||||
|
}
|
||||||
|
return df.decisionTimes[index]
|
||||||
|
}
|
||||||
|
|
||||||
|
func (df *DataFeed) sliceUpTo(symbol, tf string, ts int64) []market.Kline {
|
||||||
|
// Nil checks to prevent panic
|
||||||
|
ss, ok := df.symbolSeries[symbol]
|
||||||
|
if !ok || ss == nil {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
series, ok := ss.byTF[tf]
|
||||||
|
if !ok || series == nil {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
idx := sort.Search(len(series.closeTimes), func(i int) bool {
|
||||||
|
return series.closeTimes[i] > ts
|
||||||
|
})
|
||||||
|
if idx <= 0 {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
return series.klines[:idx]
|
||||||
|
}
|
||||||
|
|
||||||
|
func (df *DataFeed) BuildMarketData(ts int64) (map[string]*market.Data, map[string]map[string]*market.Data, error) {
|
||||||
|
result := make(map[string]*market.Data, len(df.symbols))
|
||||||
|
multi := make(map[string]map[string]*market.Data, len(df.symbols))
|
||||||
|
|
||||||
|
for _, symbol := range df.symbols {
|
||||||
|
perTF := make(map[string]*market.Data, len(df.timeframes))
|
||||||
|
for _, tf := range df.timeframes {
|
||||||
|
series := df.sliceUpTo(symbol, tf, ts)
|
||||||
|
if len(series) == 0 {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
var longer []market.Kline
|
||||||
|
if df.longerTF != "" && df.longerTF != tf {
|
||||||
|
longer = df.sliceUpTo(symbol, df.longerTF, ts)
|
||||||
|
}
|
||||||
|
data, err := market.BuildDataFromKlines(symbol, series, longer)
|
||||||
|
if err != nil {
|
||||||
|
return nil, nil, err
|
||||||
|
}
|
||||||
|
perTF[tf] = data
|
||||||
|
if tf == df.primaryTF {
|
||||||
|
result[symbol] = data
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if _, ok := perTF[df.primaryTF]; !ok {
|
||||||
|
return nil, nil, fmt.Errorf("no primary data for %s at %d", symbol, ts)
|
||||||
|
}
|
||||||
|
multi[symbol] = perTF
|
||||||
|
}
|
||||||
|
return result, multi, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (df *DataFeed) decisionBarSnapshot(symbol string, ts int64) (*market.Kline, *market.Kline) {
|
||||||
|
ss, ok := df.symbolSeries[symbol]
|
||||||
|
if !ok {
|
||||||
|
return nil, nil
|
||||||
|
}
|
||||||
|
series, ok := ss.byTF[df.primaryTF]
|
||||||
|
if !ok {
|
||||||
|
return nil, nil
|
||||||
|
}
|
||||||
|
idx := sort.Search(len(series.closeTimes), func(i int) bool {
|
||||||
|
return series.closeTimes[i] >= ts
|
||||||
|
})
|
||||||
|
if idx >= len(series.closeTimes) || series.closeTimes[idx] != ts {
|
||||||
|
return nil, nil
|
||||||
|
}
|
||||||
|
curr := &series.klines[idx]
|
||||||
|
var next *market.Kline
|
||||||
|
if idx+1 < len(series.klines) {
|
||||||
|
next = &series.klines[idx+1]
|
||||||
|
}
|
||||||
|
return curr, next
|
||||||
|
}
|
||||||
95
backtest/equity.go
Normal file
95
backtest/equity.go
Normal file
@@ -0,0 +1,95 @@
|
|||||||
|
package backtest
|
||||||
|
|
||||||
|
import (
|
||||||
|
"math"
|
||||||
|
"sort"
|
||||||
|
|
||||||
|
"nofx/market"
|
||||||
|
)
|
||||||
|
|
||||||
|
// ResampleEquity resamples equity curve based on timeframe.
|
||||||
|
func ResampleEquity(points []EquityPoint, timeframe string) ([]EquityPoint, error) {
|
||||||
|
if timeframe == "" {
|
||||||
|
return points, nil
|
||||||
|
}
|
||||||
|
dur, err := market.TFDuration(timeframe)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
if len(points) == 0 {
|
||||||
|
return points, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
durMs := dur.Milliseconds()
|
||||||
|
if durMs <= 0 {
|
||||||
|
return points, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
bucketMap := make(map[int64]EquityPoint)
|
||||||
|
bucketKeys := make([]int64, 0)
|
||||||
|
for _, pt := range points {
|
||||||
|
bucket := (pt.Timestamp / durMs) * durMs
|
||||||
|
if _, exists := bucketMap[bucket]; !exists {
|
||||||
|
bucketKeys = append(bucketKeys, bucket)
|
||||||
|
}
|
||||||
|
bucketPoint := pt
|
||||||
|
bucketPoint.Timestamp = bucket
|
||||||
|
bucketMap[bucket] = bucketPoint
|
||||||
|
}
|
||||||
|
|
||||||
|
sort.Slice(bucketKeys, func(i, j int) bool {
|
||||||
|
return bucketKeys[i] < bucketKeys[j]
|
||||||
|
})
|
||||||
|
|
||||||
|
resampled := make([]EquityPoint, 0, len(bucketKeys))
|
||||||
|
for _, key := range bucketKeys {
|
||||||
|
resampled = append(resampled, bucketMap[key])
|
||||||
|
}
|
||||||
|
|
||||||
|
return resampled, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// LimitEquityPoints limits the number of data points within a given range (uniform sampling).
|
||||||
|
func LimitEquityPoints(points []EquityPoint, limit int) []EquityPoint {
|
||||||
|
if limit <= 0 || len(points) <= limit {
|
||||||
|
return points
|
||||||
|
}
|
||||||
|
|
||||||
|
step := float64(len(points)) / float64(limit)
|
||||||
|
result := make([]EquityPoint, 0, limit)
|
||||||
|
for i := 0; i < limit; i++ {
|
||||||
|
idx := int(math.Round(step * float64(i)))
|
||||||
|
if idx >= len(points) {
|
||||||
|
idx = len(points) - 1
|
||||||
|
}
|
||||||
|
result = append(result, points[idx])
|
||||||
|
}
|
||||||
|
|
||||||
|
return result
|
||||||
|
}
|
||||||
|
|
||||||
|
// LimitTradeEvents applies uniform sampling to trade events.
|
||||||
|
func LimitTradeEvents(events []TradeEvent, limit int) []TradeEvent {
|
||||||
|
if limit <= 0 || len(events) <= limit {
|
||||||
|
return events
|
||||||
|
}
|
||||||
|
|
||||||
|
step := float64(len(events)) / float64(limit)
|
||||||
|
result := make([]TradeEvent, 0, limit)
|
||||||
|
for i := 0; i < limit; i++ {
|
||||||
|
idx := int(math.Round(step * float64(i)))
|
||||||
|
if idx >= len(events) {
|
||||||
|
idx = len(events) - 1
|
||||||
|
}
|
||||||
|
result = append(result, events[idx])
|
||||||
|
}
|
||||||
|
return result
|
||||||
|
}
|
||||||
|
|
||||||
|
// AlignEquityTimestamps ensures timestamps are sorted in ascending order.
|
||||||
|
func AlignEquityTimestamps(points []EquityPoint) []EquityPoint {
|
||||||
|
sort.Slice(points, func(i, j int) bool {
|
||||||
|
return points[i].Timestamp < points[j].Timestamp
|
||||||
|
})
|
||||||
|
return points
|
||||||
|
}
|
||||||
100
backtest/lock.go
Normal file
100
backtest/lock.go
Normal file
@@ -0,0 +1,100 @@
|
|||||||
|
package backtest
|
||||||
|
|
||||||
|
import (
|
||||||
|
"encoding/json"
|
||||||
|
"errors"
|
||||||
|
"fmt"
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
const (
|
||||||
|
lockFileName = "lock"
|
||||||
|
lockHeartbeatInterval = 2 * time.Second
|
||||||
|
lockStaleAfter = 10 * time.Second
|
||||||
|
)
|
||||||
|
|
||||||
|
// RunLockInfo represents the lock file structure for a backtest run.
|
||||||
|
type RunLockInfo struct {
|
||||||
|
RunID string `json:"run_id"`
|
||||||
|
PID int `json:"pid"`
|
||||||
|
Host string `json:"host"`
|
||||||
|
StartedAt time.Time `json:"started_at"`
|
||||||
|
LastHeartbeat time.Time `json:"last_heartbeat"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func lockFilePath(runID string) string {
|
||||||
|
return filepath.Join(runDir(runID), lockFileName)
|
||||||
|
}
|
||||||
|
|
||||||
|
func loadRunLock(runID string) (*RunLockInfo, error) {
|
||||||
|
path := lockFilePath(runID)
|
||||||
|
data, err := os.ReadFile(path)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
var info RunLockInfo
|
||||||
|
if err := json.Unmarshal(data, &info); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return &info, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func saveRunLock(info *RunLockInfo) error {
|
||||||
|
if info == nil {
|
||||||
|
return fmt.Errorf("lock info nil")
|
||||||
|
}
|
||||||
|
return writeJSONAtomic(lockFilePath(info.RunID), info)
|
||||||
|
}
|
||||||
|
|
||||||
|
func deleteRunLock(runID string) error {
|
||||||
|
err := os.Remove(lockFilePath(runID))
|
||||||
|
if err != nil && !errors.Is(err, os.ErrNotExist) {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func lockIsStale(info *RunLockInfo) bool {
|
||||||
|
if info == nil {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
return time.Since(info.LastHeartbeat) > lockStaleAfter
|
||||||
|
}
|
||||||
|
|
||||||
|
func acquireRunLock(runID string) (*RunLockInfo, error) {
|
||||||
|
if err := ensureRunDir(runID); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
if existing, err := loadRunLock(runID); err == nil {
|
||||||
|
if !lockIsStale(existing) {
|
||||||
|
return nil, fmt.Errorf("run %s is locked by pid %d", runID, existing.PID)
|
||||||
|
}
|
||||||
|
} else if err != nil && !errors.Is(err, os.ErrNotExist) {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
host, _ := os.Hostname()
|
||||||
|
info := &RunLockInfo{
|
||||||
|
RunID: runID,
|
||||||
|
PID: os.Getpid(),
|
||||||
|
Host: host,
|
||||||
|
StartedAt: time.Now().UTC(),
|
||||||
|
LastHeartbeat: time.Now().UTC(),
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := saveRunLock(info); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return info, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func updateRunLockHeartbeat(info *RunLockInfo) error {
|
||||||
|
if info == nil {
|
||||||
|
return fmt.Errorf("lock info nil")
|
||||||
|
}
|
||||||
|
info.LastHeartbeat = time.Now().UTC()
|
||||||
|
return saveRunLock(info)
|
||||||
|
}
|
||||||
493
backtest/manager.go
Normal file
493
backtest/manager.go
Normal file
@@ -0,0 +1,493 @@
|
|||||||
|
package backtest
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"errors"
|
||||||
|
"fmt"
|
||||||
|
"nofx/logger"
|
||||||
|
"os"
|
||||||
|
"sort"
|
||||||
|
"strings"
|
||||||
|
"sync"
|
||||||
|
|
||||||
|
"nofx/mcp"
|
||||||
|
"nofx/store"
|
||||||
|
)
|
||||||
|
|
||||||
|
type Manager struct {
|
||||||
|
mu sync.RWMutex
|
||||||
|
runners map[string]*Runner
|
||||||
|
metadata map[string]*RunMetadata
|
||||||
|
cancels map[string]context.CancelFunc
|
||||||
|
mcpClient mcp.AIClient
|
||||||
|
aiResolver AIConfigResolver
|
||||||
|
}
|
||||||
|
|
||||||
|
type AIConfigResolver func(*BacktestConfig) error
|
||||||
|
|
||||||
|
func NewManager(defaultClient mcp.AIClient) *Manager {
|
||||||
|
return &Manager{
|
||||||
|
runners: make(map[string]*Runner),
|
||||||
|
metadata: make(map[string]*RunMetadata),
|
||||||
|
cancels: make(map[string]context.CancelFunc),
|
||||||
|
mcpClient: defaultClient,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m *Manager) SetAIResolver(resolver AIConfigResolver) {
|
||||||
|
m.mu.Lock()
|
||||||
|
defer m.mu.Unlock()
|
||||||
|
m.aiResolver = resolver
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m *Manager) Start(ctx context.Context, cfg BacktestConfig) (*Runner, error) {
|
||||||
|
if err := cfg.Validate(); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
if err := m.resolveAIConfig(&cfg); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
if ctx == nil {
|
||||||
|
ctx = context.Background()
|
||||||
|
}
|
||||||
|
|
||||||
|
m.mu.Lock()
|
||||||
|
if existing, ok := m.runners[cfg.RunID]; ok {
|
||||||
|
state := existing.Status()
|
||||||
|
if state == RunStateRunning || state == RunStatePaused {
|
||||||
|
m.mu.Unlock()
|
||||||
|
return nil, fmt.Errorf("run %s is already active", cfg.RunID)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
m.mu.Unlock()
|
||||||
|
|
||||||
|
persistCfg := cfg
|
||||||
|
persistCfg.AICfg.APIKey = ""
|
||||||
|
if err := SaveConfig(cfg.RunID, &persistCfg); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
runner, err := NewRunner(cfg, m.client())
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
runCtx, cancel := context.WithCancel(ctx)
|
||||||
|
|
||||||
|
m.mu.Lock()
|
||||||
|
if _, exists := m.runners[cfg.RunID]; exists {
|
||||||
|
m.mu.Unlock()
|
||||||
|
cancel()
|
||||||
|
return nil, fmt.Errorf("run %s is already active", cfg.RunID)
|
||||||
|
}
|
||||||
|
m.runners[cfg.RunID] = runner
|
||||||
|
m.cancels[cfg.RunID] = cancel
|
||||||
|
meta := runner.CurrentMetadata()
|
||||||
|
m.metadata[cfg.RunID] = meta
|
||||||
|
m.mu.Unlock()
|
||||||
|
|
||||||
|
if err := runner.Start(runCtx); err != nil {
|
||||||
|
cancel()
|
||||||
|
m.mu.Lock()
|
||||||
|
delete(m.runners, cfg.RunID)
|
||||||
|
delete(m.cancels, cfg.RunID)
|
||||||
|
delete(m.metadata, cfg.RunID)
|
||||||
|
m.mu.Unlock()
|
||||||
|
runner.releaseLock()
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
m.storeMetadata(cfg.RunID, meta)
|
||||||
|
m.launchWatcher(cfg.RunID, runner)
|
||||||
|
return runner, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m *Manager) client() mcp.AIClient {
|
||||||
|
if m.mcpClient != nil {
|
||||||
|
return m.mcpClient
|
||||||
|
}
|
||||||
|
return mcp.New()
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m *Manager) GetRunner(runID string) (*Runner, bool) {
|
||||||
|
m.mu.RLock()
|
||||||
|
runner, ok := m.runners[runID]
|
||||||
|
m.mu.RUnlock()
|
||||||
|
return runner, ok
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m *Manager) ListRuns() ([]*RunMetadata, error) {
|
||||||
|
m.mu.RLock()
|
||||||
|
localCopy := make(map[string]*RunMetadata, len(m.metadata))
|
||||||
|
for k, v := range m.metadata {
|
||||||
|
localCopy[k] = v
|
||||||
|
}
|
||||||
|
m.mu.RUnlock()
|
||||||
|
|
||||||
|
runIDs, err := LoadRunIDs()
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
ordered := make([]string, 0, len(runIDs))
|
||||||
|
if entries, err := listIndexEntries(); err == nil {
|
||||||
|
seen := make(map[string]bool, len(runIDs))
|
||||||
|
for _, entry := range entries {
|
||||||
|
if contains(runIDs, entry.RunID) {
|
||||||
|
ordered = append(ordered, entry.RunID)
|
||||||
|
seen[entry.RunID] = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for _, id := range runIDs {
|
||||||
|
if !seen[id] {
|
||||||
|
ordered = append(ordered, id)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
ordered = append(ordered, runIDs...)
|
||||||
|
}
|
||||||
|
|
||||||
|
metas := make([]*RunMetadata, 0, len(runIDs))
|
||||||
|
for _, runID := range ordered {
|
||||||
|
if meta, ok := localCopy[runID]; ok {
|
||||||
|
metas = append(metas, meta)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
meta, err := LoadRunMetadata(runID)
|
||||||
|
if err == nil {
|
||||||
|
metas = append(metas, meta)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
sort.Slice(metas, func(i, j int) bool {
|
||||||
|
return metas[i].UpdatedAt.After(metas[j].UpdatedAt)
|
||||||
|
})
|
||||||
|
|
||||||
|
return metas, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func contains(list []string, target string) bool {
|
||||||
|
for _, item := range list {
|
||||||
|
if item == target {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m *Manager) Pause(runID string) error {
|
||||||
|
runner, ok := m.GetRunner(runID)
|
||||||
|
if !ok {
|
||||||
|
return fmt.Errorf("run %s not found", runID)
|
||||||
|
}
|
||||||
|
runner.Pause()
|
||||||
|
m.refreshMetadata(runID)
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m *Manager) Resume(runID string) error {
|
||||||
|
if runID == "" {
|
||||||
|
return fmt.Errorf("run_id is required")
|
||||||
|
}
|
||||||
|
|
||||||
|
runner, ok := m.GetRunner(runID)
|
||||||
|
if ok {
|
||||||
|
runner.Resume()
|
||||||
|
m.refreshMetadata(runID)
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
cfg, err := LoadConfig(runID)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
cfgCopy := *cfg
|
||||||
|
if err := cfgCopy.Validate(); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if err := m.resolveAIConfig(&cfgCopy); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
restored, err := NewRunner(cfgCopy, m.client())
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if err := restored.RestoreFromCheckpoint(); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
ctx, cancel := context.WithCancel(context.Background())
|
||||||
|
|
||||||
|
m.mu.Lock()
|
||||||
|
if _, exists := m.runners[runID]; exists {
|
||||||
|
m.mu.Unlock()
|
||||||
|
cancel()
|
||||||
|
return fmt.Errorf("run %s is already active", runID)
|
||||||
|
}
|
||||||
|
m.runners[runID] = restored
|
||||||
|
m.cancels[runID] = cancel
|
||||||
|
m.metadata[runID] = restored.CurrentMetadata()
|
||||||
|
m.mu.Unlock()
|
||||||
|
|
||||||
|
if err := restored.Start(ctx); err != nil {
|
||||||
|
cancel()
|
||||||
|
m.mu.Lock()
|
||||||
|
delete(m.runners, runID)
|
||||||
|
delete(m.cancels, runID)
|
||||||
|
delete(m.metadata, runID)
|
||||||
|
m.mu.Unlock()
|
||||||
|
restored.releaseLock()
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
m.storeMetadata(runID, restored.CurrentMetadata())
|
||||||
|
m.launchWatcher(runID, restored)
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m *Manager) Stop(runID string) error {
|
||||||
|
runner, ok := m.GetRunner(runID)
|
||||||
|
if ok {
|
||||||
|
runner.Stop()
|
||||||
|
err := runner.Wait()
|
||||||
|
m.refreshMetadata(runID)
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
meta, err := m.LoadMetadata(runID)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if meta.State == RunStateStopped || meta.State == RunStateCompleted {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
meta.State = RunStateStopped
|
||||||
|
m.storeMetadata(runID, meta)
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m *Manager) Wait(runID string) error {
|
||||||
|
runner, ok := m.GetRunner(runID)
|
||||||
|
if !ok {
|
||||||
|
return fmt.Errorf("run %s not found", runID)
|
||||||
|
}
|
||||||
|
err := runner.Wait()
|
||||||
|
m.refreshMetadata(runID)
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m *Manager) UpdateLabel(runID, label string) (*RunMetadata, error) {
|
||||||
|
meta, err := m.LoadMetadata(runID)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
clean := strings.TrimSpace(label)
|
||||||
|
metaCopy := *meta
|
||||||
|
metaCopy.Label = clean
|
||||||
|
m.storeMetadata(runID, &metaCopy)
|
||||||
|
return &metaCopy, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m *Manager) Delete(runID string) error {
|
||||||
|
runner, ok := m.GetRunner(runID)
|
||||||
|
if ok {
|
||||||
|
runner.Stop()
|
||||||
|
_ = runner.Wait()
|
||||||
|
}
|
||||||
|
m.mu.Lock()
|
||||||
|
if cancel, ok := m.cancels[runID]; ok {
|
||||||
|
cancel()
|
||||||
|
delete(m.cancels, runID)
|
||||||
|
}
|
||||||
|
delete(m.runners, runID)
|
||||||
|
delete(m.metadata, runID)
|
||||||
|
m.mu.Unlock()
|
||||||
|
if err := removeFromRunIndex(runID); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if err := deleteRunLock(runID); err != nil && !errors.Is(err, os.ErrNotExist) {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m *Manager) LoadMetadata(runID string) (*RunMetadata, error) {
|
||||||
|
runner, ok := m.GetRunner(runID)
|
||||||
|
if ok {
|
||||||
|
meta := runner.CurrentMetadata()
|
||||||
|
m.storeMetadata(runID, meta)
|
||||||
|
return meta, nil
|
||||||
|
}
|
||||||
|
meta, err := LoadRunMetadata(runID)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
m.storeMetadata(runID, meta)
|
||||||
|
return meta, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m *Manager) LoadEquity(runID string, timeframe string, limit int) ([]EquityPoint, error) {
|
||||||
|
points, err := LoadEquityPoints(runID)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
if timeframe != "" {
|
||||||
|
points, err = ResampleEquity(points, timeframe)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
points = AlignEquityTimestamps(points)
|
||||||
|
points = LimitEquityPoints(points, limit)
|
||||||
|
return points, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m *Manager) LoadTrades(runID string, limit int) ([]TradeEvent, error) {
|
||||||
|
events, err := LoadTradeEvents(runID)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return LimitTradeEvents(events, limit), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m *Manager) GetMetrics(runID string) (*Metrics, error) {
|
||||||
|
return LoadMetrics(runID)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m *Manager) Cleanup(runID string) {
|
||||||
|
m.mu.Lock()
|
||||||
|
defer m.mu.Unlock()
|
||||||
|
delete(m.runners, runID)
|
||||||
|
if cancel, ok := m.cancels[runID]; ok {
|
||||||
|
cancel()
|
||||||
|
delete(m.cancels, runID)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m *Manager) Status(runID string) *StatusPayload {
|
||||||
|
runner, ok := m.GetRunner(runID)
|
||||||
|
if !ok {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
payload := runner.StatusPayload()
|
||||||
|
m.storeMetadata(runID, runner.CurrentMetadata())
|
||||||
|
return &payload
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m *Manager) launchWatcher(runID string, runner *Runner) {
|
||||||
|
go func() {
|
||||||
|
if err := runner.Wait(); err != nil {
|
||||||
|
logger.Infof("backtest run %s finished with error: %v", runID, err)
|
||||||
|
}
|
||||||
|
runner.PersistMetadata()
|
||||||
|
meta := runner.CurrentMetadata()
|
||||||
|
m.storeMetadata(runID, meta)
|
||||||
|
|
||||||
|
m.mu.Lock()
|
||||||
|
if cancel, ok := m.cancels[runID]; ok {
|
||||||
|
cancel()
|
||||||
|
delete(m.cancels, runID)
|
||||||
|
}
|
||||||
|
delete(m.runners, runID)
|
||||||
|
m.mu.Unlock()
|
||||||
|
}()
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m *Manager) refreshMetadata(runID string) {
|
||||||
|
runner, ok := m.GetRunner(runID)
|
||||||
|
if !ok {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
meta := runner.CurrentMetadata()
|
||||||
|
m.storeMetadata(runID, meta)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m *Manager) storeMetadata(runID string, meta *RunMetadata) {
|
||||||
|
if meta == nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
m.mu.Lock()
|
||||||
|
if existing, ok := m.metadata[runID]; ok {
|
||||||
|
if meta.Label == "" && existing.Label != "" {
|
||||||
|
meta.Label = existing.Label
|
||||||
|
}
|
||||||
|
if meta.LastError == "" && existing.LastError != "" {
|
||||||
|
meta.LastError = existing.LastError
|
||||||
|
}
|
||||||
|
}
|
||||||
|
m.metadata[runID] = meta
|
||||||
|
m.mu.Unlock()
|
||||||
|
_ = SaveRunMetadata(meta)
|
||||||
|
if err := updateRunIndex(meta, nil); err != nil {
|
||||||
|
logger.Infof("failed to update run index for %s: %v", runID, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m *Manager) resolveAIConfig(cfg *BacktestConfig) error {
|
||||||
|
if cfg == nil {
|
||||||
|
return fmt.Errorf("ai config missing")
|
||||||
|
}
|
||||||
|
provider := strings.TrimSpace(cfg.AICfg.Provider)
|
||||||
|
apiKey := strings.TrimSpace(cfg.AICfg.APIKey)
|
||||||
|
if provider != "" && !strings.EqualFold(provider, "inherit") && apiKey != "" {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
m.mu.RLock()
|
||||||
|
resolver := m.aiResolver
|
||||||
|
m.mu.RUnlock()
|
||||||
|
if resolver == nil {
|
||||||
|
if apiKey == "" {
|
||||||
|
return fmt.Errorf("AI configuration missing key and no resolver configured")
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
return resolver(cfg)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m *Manager) GetTrace(runID string, cycle int) (*store.DecisionRecord, error) {
|
||||||
|
return LoadDecisionTrace(runID, cycle)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m *Manager) ExportRun(runID string) (string, error) {
|
||||||
|
return CreateRunExport(runID)
|
||||||
|
}
|
||||||
|
|
||||||
|
// RestoreRuns scans the backtests directory and restores metadata for existing runs (service restart scenario).
|
||||||
|
func (m *Manager) RestoreRuns() error {
|
||||||
|
runIDs, err := LoadRunIDs()
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
for _, runID := range runIDs {
|
||||||
|
meta, err := LoadRunMetadata(runID)
|
||||||
|
if err != nil {
|
||||||
|
logger.Infof("skip run %s: %v", runID, err)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if meta.State == RunStateRunning {
|
||||||
|
lock, err := loadRunLock(runID)
|
||||||
|
if err != nil || lockIsStale(lock) {
|
||||||
|
if err := deleteRunLock(runID); err != nil {
|
||||||
|
logger.Infof("failed to cleanup lock for %s: %v", runID, err)
|
||||||
|
}
|
||||||
|
meta.State = RunStatePaused
|
||||||
|
if err := SaveRunMetadata(meta); err != nil {
|
||||||
|
logger.Infof("failed to mark %s paused: %v", runID, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
m.mu.Lock()
|
||||||
|
m.metadata[runID] = meta
|
||||||
|
m.mu.Unlock()
|
||||||
|
if err := updateRunIndex(meta, nil); err != nil {
|
||||||
|
logger.Infof("failed to sync index for %s: %v", runID, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// RestoreRunsFromDisk retains the old method name for backward compatibility.
|
||||||
|
func (m *Manager) RestoreRunsFromDisk() error {
|
||||||
|
return m.RestoreRuns()
|
||||||
|
}
|
||||||
239
backtest/metrics.go
Normal file
239
backtest/metrics.go
Normal file
@@ -0,0 +1,239 @@
|
|||||||
|
package backtest
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
"math"
|
||||||
|
"strings"
|
||||||
|
)
|
||||||
|
|
||||||
|
// CalculateMetrics reads existing logs and calculates summary metrics. state is optional, used to supplement information not yet persisted.
|
||||||
|
func CalculateMetrics(runID string, cfg *BacktestConfig, state *BacktestState) (*Metrics, error) {
|
||||||
|
if cfg == nil {
|
||||||
|
return nil, fmt.Errorf("config is nil")
|
||||||
|
}
|
||||||
|
|
||||||
|
points, err := LoadEquityPoints(runID)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("load equity points: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
events, err := LoadTradeEvents(runID)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("load trade events: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
metrics := &Metrics{
|
||||||
|
SymbolStats: make(map[string]SymbolMetrics),
|
||||||
|
}
|
||||||
|
|
||||||
|
metrics.Liquidated = determineLiquidation(events, state)
|
||||||
|
|
||||||
|
initialBalance := cfg.InitialBalance
|
||||||
|
if initialBalance <= 0 {
|
||||||
|
initialBalance = 1
|
||||||
|
}
|
||||||
|
|
||||||
|
lastEquity := initialBalance
|
||||||
|
if len(points) > 0 && points[len(points)-1].Equity > 0 {
|
||||||
|
lastEquity = points[len(points)-1].Equity
|
||||||
|
} else if state != nil && state.Equity > 0 {
|
||||||
|
lastEquity = state.Equity
|
||||||
|
}
|
||||||
|
metrics.TotalReturnPct = ((lastEquity - initialBalance) / initialBalance) * 100
|
||||||
|
|
||||||
|
metrics.MaxDrawdownPct = maxDrawdown(points, state)
|
||||||
|
metrics.SharpeRatio = sharpeRatio(points)
|
||||||
|
|
||||||
|
fillTradeMetrics(metrics, events)
|
||||||
|
|
||||||
|
return metrics, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func determineLiquidation(events []TradeEvent, state *BacktestState) bool {
|
||||||
|
if state != nil && state.Liquidated {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
for i := len(events) - 1; i >= 0; i-- {
|
||||||
|
if events[i].LiquidationFlag {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
func maxDrawdown(points []EquityPoint, state *BacktestState) float64 {
|
||||||
|
if len(points) == 0 {
|
||||||
|
if state != nil {
|
||||||
|
return state.MaxDrawdownPct
|
||||||
|
}
|
||||||
|
return 0
|
||||||
|
}
|
||||||
|
peak := points[0].Equity
|
||||||
|
if peak <= 0 {
|
||||||
|
peak = 1
|
||||||
|
}
|
||||||
|
maxDD := 0.0
|
||||||
|
for _, pt := range points {
|
||||||
|
if pt.Equity > peak {
|
||||||
|
peak = pt.Equity
|
||||||
|
}
|
||||||
|
if peak <= 0 {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
dd := (peak - pt.Equity) / peak * 100
|
||||||
|
if dd > maxDD {
|
||||||
|
maxDD = dd
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if state != nil && state.MaxDrawdownPct > maxDD {
|
||||||
|
maxDD = state.MaxDrawdownPct
|
||||||
|
}
|
||||||
|
return maxDD
|
||||||
|
}
|
||||||
|
|
||||||
|
// sharpeRatio calculates the Sharpe ratio from equity points.
|
||||||
|
// Uses sample standard deviation (n-1) and annualizes assuming ~252 trading days.
|
||||||
|
// Returns math.NaN() for edge cases (insufficient data, zero variance).
|
||||||
|
func sharpeRatio(points []EquityPoint) float64 {
|
||||||
|
// Need at least 10 data points for meaningful Sharpe calculation
|
||||||
|
const minDataPoints = 10
|
||||||
|
if len(points) < minDataPoints {
|
||||||
|
return 0
|
||||||
|
}
|
||||||
|
|
||||||
|
returns := make([]float64, 0, len(points)-1)
|
||||||
|
prev := points[0].Equity
|
||||||
|
for i := 1; i < len(points); i++ {
|
||||||
|
curr := points[i].Equity
|
||||||
|
if prev <= 0 {
|
||||||
|
prev = curr
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
ret := (curr - prev) / prev
|
||||||
|
returns = append(returns, ret)
|
||||||
|
prev = curr
|
||||||
|
}
|
||||||
|
if len(returns) < minDataPoints-1 {
|
||||||
|
return 0
|
||||||
|
}
|
||||||
|
|
||||||
|
// Calculate mean return
|
||||||
|
mean := 0.0
|
||||||
|
for _, r := range returns {
|
||||||
|
mean += r
|
||||||
|
}
|
||||||
|
mean /= float64(len(returns))
|
||||||
|
|
||||||
|
// Calculate sample variance (using n-1 for unbiased estimator)
|
||||||
|
variance := 0.0
|
||||||
|
for _, r := range returns {
|
||||||
|
diff := r - mean
|
||||||
|
variance += diff * diff
|
||||||
|
}
|
||||||
|
if len(returns) > 1 {
|
||||||
|
variance /= float64(len(returns) - 1)
|
||||||
|
}
|
||||||
|
|
||||||
|
std := math.Sqrt(variance)
|
||||||
|
if std < 1e-10 {
|
||||||
|
// Zero or near-zero volatility - return 0 instead of infinity/NaN
|
||||||
|
return 0
|
||||||
|
}
|
||||||
|
|
||||||
|
// Calculate Sharpe ratio (assuming risk-free rate = 0 for crypto)
|
||||||
|
// Annualize by multiplying by sqrt(periods per year)
|
||||||
|
// Assuming each equity point represents ~1 hour, we have ~8760 periods/year
|
||||||
|
// For conservative estimate, use sqrt(252) as if daily returns
|
||||||
|
periodsPerYear := 252.0
|
||||||
|
annualizationFactor := math.Sqrt(periodsPerYear)
|
||||||
|
|
||||||
|
sharpe := (mean / std) * annualizationFactor
|
||||||
|
return sharpe
|
||||||
|
}
|
||||||
|
|
||||||
|
func fillTradeMetrics(metrics *Metrics, events []TradeEvent) {
|
||||||
|
if metrics == nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
totalTrades := 0
|
||||||
|
winTrades := 0
|
||||||
|
lossTrades := 0
|
||||||
|
totalWinAmount := 0.0
|
||||||
|
totalLossAmount := 0.0
|
||||||
|
|
||||||
|
for _, evt := range events {
|
||||||
|
include := evt.LiquidationFlag || strings.HasPrefix(evt.Action, "close")
|
||||||
|
if evt.RealizedPnL != 0 {
|
||||||
|
include = true
|
||||||
|
}
|
||||||
|
if !include {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
totalTrades++
|
||||||
|
|
||||||
|
stats := metrics.SymbolStats[evt.Symbol]
|
||||||
|
stats.TotalTrades++
|
||||||
|
stats.TotalPnL += evt.RealizedPnL
|
||||||
|
|
||||||
|
if evt.RealizedPnL > 0 {
|
||||||
|
winTrades++
|
||||||
|
totalWinAmount += evt.RealizedPnL
|
||||||
|
stats.WinningTrades++
|
||||||
|
} else if evt.RealizedPnL < 0 {
|
||||||
|
lossTrades++
|
||||||
|
totalLossAmount += -evt.RealizedPnL
|
||||||
|
stats.LosingTrades++
|
||||||
|
}
|
||||||
|
|
||||||
|
metrics.SymbolStats[evt.Symbol] = stats
|
||||||
|
}
|
||||||
|
|
||||||
|
metrics.Trades = totalTrades
|
||||||
|
if totalTrades > 0 {
|
||||||
|
metrics.WinRate = (float64(winTrades) / float64(totalTrades)) * 100
|
||||||
|
}
|
||||||
|
if winTrades > 0 {
|
||||||
|
metrics.AvgWin = totalWinAmount / float64(winTrades)
|
||||||
|
}
|
||||||
|
if lossTrades > 0 {
|
||||||
|
metrics.AvgLoss = -(totalLossAmount / float64(lossTrades))
|
||||||
|
}
|
||||||
|
if totalLossAmount > 0 {
|
||||||
|
metrics.ProfitFactor = totalWinAmount / totalLossAmount
|
||||||
|
} else if totalWinAmount > 0 {
|
||||||
|
// No losses but have wins - use a high but reasonable cap
|
||||||
|
metrics.ProfitFactor = 100.0
|
||||||
|
}
|
||||||
|
|
||||||
|
bestSymbol := ""
|
||||||
|
bestPnL := math.Inf(-1)
|
||||||
|
worstSymbol := ""
|
||||||
|
worstPnL := math.Inf(1)
|
||||||
|
|
||||||
|
for symbol, stats := range metrics.SymbolStats {
|
||||||
|
if stats.TotalTrades > 0 {
|
||||||
|
if stats.TotalPnL > bestPnL {
|
||||||
|
bestPnL = stats.TotalPnL
|
||||||
|
bestSymbol = symbol
|
||||||
|
}
|
||||||
|
if stats.TotalPnL < worstPnL {
|
||||||
|
worstPnL = stats.TotalPnL
|
||||||
|
worstSymbol = symbol
|
||||||
|
}
|
||||||
|
|
||||||
|
stats.AvgPnL = stats.TotalPnL / float64(stats.TotalTrades)
|
||||||
|
stats.WinRate = (float64(stats.WinningTrades) / float64(stats.TotalTrades)) * 100
|
||||||
|
}
|
||||||
|
metrics.SymbolStats[symbol] = stats
|
||||||
|
}
|
||||||
|
|
||||||
|
metrics.BestSymbol = bestSymbol
|
||||||
|
if math.IsInf(bestPnL, -1) {
|
||||||
|
metrics.BestSymbol = ""
|
||||||
|
}
|
||||||
|
metrics.WorstSymbol = worstSymbol
|
||||||
|
if math.IsInf(worstPnL, 1) {
|
||||||
|
metrics.WorstSymbol = ""
|
||||||
|
}
|
||||||
|
}
|
||||||
40
backtest/persistence_db.go
Normal file
40
backtest/persistence_db.go
Normal file
@@ -0,0 +1,40 @@
|
|||||||
|
package backtest
|
||||||
|
|
||||||
|
import (
|
||||||
|
"database/sql"
|
||||||
|
"fmt"
|
||||||
|
"strings"
|
||||||
|
)
|
||||||
|
|
||||||
|
var persistenceDB *sql.DB
|
||||||
|
var dbIsPostgres bool
|
||||||
|
|
||||||
|
// UseDatabase enables database-backed persistence for all backtest storage operations.
|
||||||
|
// If isPostgres is true, queries will use $1, $2... placeholders instead of ?
|
||||||
|
func UseDatabase(db *sql.DB) {
|
||||||
|
persistenceDB = db
|
||||||
|
}
|
||||||
|
|
||||||
|
// UseDatabaseWithType enables database-backed persistence with explicit type.
|
||||||
|
func UseDatabaseWithType(db *sql.DB, isPostgres bool) {
|
||||||
|
persistenceDB = db
|
||||||
|
dbIsPostgres = isPostgres
|
||||||
|
}
|
||||||
|
|
||||||
|
func usingDB() bool {
|
||||||
|
return persistenceDB != nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// convertQuery converts ? placeholders to $1, $2, etc. for PostgreSQL
|
||||||
|
func convertQuery(query string) string {
|
||||||
|
if !dbIsPostgres {
|
||||||
|
return query
|
||||||
|
}
|
||||||
|
result := query
|
||||||
|
index := 1
|
||||||
|
for strings.Contains(result, "?") {
|
||||||
|
result = strings.Replace(result, "?", fmt.Sprintf("$%d", index), 1)
|
||||||
|
index++
|
||||||
|
}
|
||||||
|
return result
|
||||||
|
}
|
||||||
160
backtest/registry.go
Normal file
160
backtest/registry.go
Normal file
@@ -0,0 +1,160 @@
|
|||||||
|
package backtest
|
||||||
|
|
||||||
|
import (
|
||||||
|
"encoding/json"
|
||||||
|
"errors"
|
||||||
|
"fmt"
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"sort"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
const runIndexFile = "index.json"
|
||||||
|
|
||||||
|
type RunIndexEntry struct {
|
||||||
|
RunID string `json:"run_id"`
|
||||||
|
State RunState `json:"state"`
|
||||||
|
Symbols []string `json:"symbols"`
|
||||||
|
DecisionTF string `json:"decision_tf"`
|
||||||
|
StartTS int64 `json:"start_ts"`
|
||||||
|
EndTS int64 `json:"end_ts"`
|
||||||
|
EquityLast float64 `json:"equity_last"`
|
||||||
|
MaxDrawdownPct float64 `json:"max_dd_pct"`
|
||||||
|
CreatedAtISO string `json:"created_at"`
|
||||||
|
UpdatedAtISO string `json:"updated_at"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type RunIndex struct {
|
||||||
|
Runs map[string]RunIndexEntry `json:"runs"`
|
||||||
|
UpdatedAt string `json:"updated_at"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func runIndexPath() string {
|
||||||
|
return filepath.Join(backtestsRootDir, runIndexFile)
|
||||||
|
}
|
||||||
|
|
||||||
|
func loadRunIndex() (*RunIndex, error) {
|
||||||
|
if usingDB() {
|
||||||
|
entries, err := listIndexEntriesDB()
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
idx := &RunIndex{
|
||||||
|
Runs: make(map[string]RunIndexEntry),
|
||||||
|
UpdatedAt: time.Now().UTC().Format(time.RFC3339),
|
||||||
|
}
|
||||||
|
for _, entry := range entries {
|
||||||
|
idx.Runs[entry.RunID] = entry
|
||||||
|
}
|
||||||
|
return idx, nil
|
||||||
|
}
|
||||||
|
path := runIndexPath()
|
||||||
|
data, err := os.ReadFile(path)
|
||||||
|
if err != nil {
|
||||||
|
if errors.Is(err, os.ErrNotExist) {
|
||||||
|
return &RunIndex{Runs: make(map[string]RunIndexEntry)}, nil
|
||||||
|
}
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
var idx RunIndex
|
||||||
|
if err := json.Unmarshal(data, &idx); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
if idx.Runs == nil {
|
||||||
|
idx.Runs = make(map[string]RunIndexEntry)
|
||||||
|
}
|
||||||
|
return &idx, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func saveRunIndex(idx *RunIndex) error {
|
||||||
|
if usingDB() {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
if idx == nil {
|
||||||
|
return fmt.Errorf("index is nil")
|
||||||
|
}
|
||||||
|
idx.UpdatedAt = time.Now().UTC().Format(time.RFC3339)
|
||||||
|
return writeJSONAtomic(runIndexPath(), idx)
|
||||||
|
}
|
||||||
|
|
||||||
|
func updateRunIndex(meta *RunMetadata, cfg *BacktestConfig) error {
|
||||||
|
if usingDB() {
|
||||||
|
enforceRetention(maxCompletedRuns)
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
if meta == nil {
|
||||||
|
return fmt.Errorf("meta nil")
|
||||||
|
}
|
||||||
|
if cfg == nil {
|
||||||
|
var err error
|
||||||
|
cfg, err = LoadConfig(meta.RunID)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
idx, err := loadRunIndex()
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
entry := RunIndexEntry{
|
||||||
|
RunID: meta.RunID,
|
||||||
|
State: meta.State,
|
||||||
|
Symbols: append([]string(nil), cfg.Symbols...),
|
||||||
|
DecisionTF: meta.Summary.DecisionTF,
|
||||||
|
StartTS: cfg.StartTS,
|
||||||
|
EndTS: cfg.EndTS,
|
||||||
|
EquityLast: meta.Summary.EquityLast,
|
||||||
|
MaxDrawdownPct: meta.Summary.MaxDrawdownPct,
|
||||||
|
CreatedAtISO: meta.CreatedAt.Format(time.RFC3339),
|
||||||
|
UpdatedAtISO: meta.UpdatedAt.Format(time.RFC3339),
|
||||||
|
}
|
||||||
|
|
||||||
|
if idx.Runs == nil {
|
||||||
|
idx.Runs = make(map[string]RunIndexEntry)
|
||||||
|
}
|
||||||
|
idx.Runs[meta.RunID] = entry
|
||||||
|
if err := saveRunIndex(idx); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
enforceRetention(maxCompletedRuns)
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func removeFromRunIndex(runID string) error {
|
||||||
|
if usingDB() {
|
||||||
|
if err := deleteRunDB(runID); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
return os.RemoveAll(runDir(runID))
|
||||||
|
}
|
||||||
|
idx, err := loadRunIndex()
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if idx.Runs == nil {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
delete(idx.Runs, runID)
|
||||||
|
return saveRunIndex(idx)
|
||||||
|
}
|
||||||
|
|
||||||
|
func listIndexEntries() ([]RunIndexEntry, error) {
|
||||||
|
if usingDB() {
|
||||||
|
return listIndexEntriesDB()
|
||||||
|
}
|
||||||
|
idx, err := loadRunIndex()
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
entries := make([]RunIndexEntry, 0, len(idx.Runs))
|
||||||
|
for _, entry := range idx.Runs {
|
||||||
|
entries = append(entries, entry)
|
||||||
|
}
|
||||||
|
sort.Slice(entries, func(i, j int) bool {
|
||||||
|
return entries[i].UpdatedAtISO > entries[j].UpdatedAtISO
|
||||||
|
})
|
||||||
|
return entries, nil
|
||||||
|
}
|
||||||
101
backtest/retention.go
Normal file
101
backtest/retention.go
Normal file
@@ -0,0 +1,101 @@
|
|||||||
|
package backtest
|
||||||
|
|
||||||
|
import (
|
||||||
|
"nofx/logger"
|
||||||
|
"os"
|
||||||
|
"sort"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
const maxCompletedRuns = 100
|
||||||
|
|
||||||
|
func enforceRetention(maxRuns int) {
|
||||||
|
if maxRuns <= 0 {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if usingDB() {
|
||||||
|
enforceRetentionDB(maxRuns)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
idx, err := loadRunIndex()
|
||||||
|
if err != nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
type wrapped struct {
|
||||||
|
entry RunIndexEntry
|
||||||
|
updated time.Time
|
||||||
|
}
|
||||||
|
finalStates := map[RunState]bool{
|
||||||
|
RunStateCompleted: true,
|
||||||
|
RunStateStopped: true,
|
||||||
|
RunStateFailed: true,
|
||||||
|
RunStateLiquidated: true,
|
||||||
|
}
|
||||||
|
|
||||||
|
candidates := make([]wrapped, 0)
|
||||||
|
for _, entry := range idx.Runs {
|
||||||
|
if !finalStates[entry.State] {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
ts, err := time.Parse(time.RFC3339, entry.UpdatedAtISO)
|
||||||
|
if err != nil {
|
||||||
|
ts = time.Now()
|
||||||
|
}
|
||||||
|
candidates = append(candidates, wrapped{entry: entry, updated: ts})
|
||||||
|
}
|
||||||
|
if len(candidates) <= maxRuns {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
sort.Slice(candidates, func(i, j int) bool {
|
||||||
|
return candidates[i].updated.Before(candidates[j].updated)
|
||||||
|
})
|
||||||
|
|
||||||
|
toRemove := len(candidates) - maxRuns
|
||||||
|
for i := 0; i < toRemove; i++ {
|
||||||
|
runID := candidates[i].entry.RunID
|
||||||
|
if err := os.RemoveAll(runDir(runID)); err != nil {
|
||||||
|
logger.Infof("failed to prune run %s: %v", runID, err)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
delete(idx.Runs, runID)
|
||||||
|
}
|
||||||
|
if err := saveRunIndex(idx); err != nil {
|
||||||
|
logger.Infof("failed to save index after pruning: %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func enforceRetentionDB(maxRuns int) {
|
||||||
|
finalStates := []RunState{
|
||||||
|
RunStateCompleted,
|
||||||
|
RunStateStopped,
|
||||||
|
RunStateFailed,
|
||||||
|
RunStateLiquidated,
|
||||||
|
}
|
||||||
|
query := convertQuery(`
|
||||||
|
SELECT run_id FROM backtest_runs
|
||||||
|
WHERE state IN (?, ?, ?, ?)
|
||||||
|
ORDER BY updated_at DESC
|
||||||
|
OFFSET ?
|
||||||
|
`)
|
||||||
|
rows, err := persistenceDB.Query(query,
|
||||||
|
finalStates[0], finalStates[1], finalStates[2], finalStates[3], maxRuns)
|
||||||
|
if err != nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
defer rows.Close()
|
||||||
|
for rows.Next() {
|
||||||
|
var runID string
|
||||||
|
if err := rows.Scan(&runID); err != nil {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if err := deleteRunDB(runID); err != nil {
|
||||||
|
logger.Infof("failed to remove run %s: %v", runID, err)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if err := os.RemoveAll(runDir(runID)); err != nil {
|
||||||
|
logger.Infof("failed to remove run dir %s: %v", runID, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
1531
backtest/runner.go
Normal file
1531
backtest/runner.go
Normal file
File diff suppressed because it is too large
Load Diff
561
backtest/storage.go
Normal file
561
backtest/storage.go
Normal file
@@ -0,0 +1,561 @@
|
|||||||
|
package backtest
|
||||||
|
|
||||||
|
import (
|
||||||
|
"archive/zip"
|
||||||
|
"bufio"
|
||||||
|
"encoding/json"
|
||||||
|
"errors"
|
||||||
|
"fmt"
|
||||||
|
"io"
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"sort"
|
||||||
|
"strings"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"nofx/store"
|
||||||
|
)
|
||||||
|
|
||||||
|
const (
|
||||||
|
backtestsRootDir = "backtests"
|
||||||
|
)
|
||||||
|
|
||||||
|
type progressPayload struct {
|
||||||
|
BarIndex int `json:"bar_index"`
|
||||||
|
Equity float64 `json:"equity"`
|
||||||
|
ProgressPct float64 `json:"progress_pct"`
|
||||||
|
Liquidated bool `json:"liquidated"`
|
||||||
|
UpdatedAtISO string `json:"updated_at_iso"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func runDir(runID string) string {
|
||||||
|
return filepath.Join(backtestsRootDir, runID)
|
||||||
|
}
|
||||||
|
|
||||||
|
func ensureRunDir(runID string) error {
|
||||||
|
dir := runDir(runID)
|
||||||
|
return os.MkdirAll(dir, 0o755)
|
||||||
|
}
|
||||||
|
|
||||||
|
func checkpointPath(runID string) string {
|
||||||
|
return filepath.Join(runDir(runID), "checkpoint.json")
|
||||||
|
}
|
||||||
|
|
||||||
|
func runMetadataPath(runID string) string {
|
||||||
|
return filepath.Join(runDir(runID), "run.json")
|
||||||
|
}
|
||||||
|
|
||||||
|
func equityLogPath(runID string) string {
|
||||||
|
return filepath.Join(runDir(runID), "equity.jsonl")
|
||||||
|
}
|
||||||
|
|
||||||
|
func tradesLogPath(runID string) string {
|
||||||
|
return filepath.Join(runDir(runID), "trades.jsonl")
|
||||||
|
}
|
||||||
|
|
||||||
|
func metricsPath(runID string) string {
|
||||||
|
return filepath.Join(runDir(runID), "metrics.json")
|
||||||
|
}
|
||||||
|
|
||||||
|
func progressPath(runID string) string {
|
||||||
|
return filepath.Join(runDir(runID), "progress.json")
|
||||||
|
}
|
||||||
|
|
||||||
|
func decisionLogDir(runID string) string {
|
||||||
|
return filepath.Join(runDir(runID), "decision_logs")
|
||||||
|
}
|
||||||
|
|
||||||
|
func writeJSONAtomic(path string, v any) error {
|
||||||
|
data, err := json.MarshalIndent(v, "", " ")
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
return writeFileAtomic(path, data, 0o644)
|
||||||
|
}
|
||||||
|
|
||||||
|
func writeFileAtomic(path string, data []byte, perm os.FileMode) error {
|
||||||
|
dir := filepath.Dir(path)
|
||||||
|
if err := os.MkdirAll(dir, 0o755); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
tmpFile, err := os.CreateTemp(dir, ".tmp-*")
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
tmpPath := tmpFile.Name()
|
||||||
|
if _, err := tmpFile.Write(data); err != nil {
|
||||||
|
tmpFile.Close()
|
||||||
|
os.Remove(tmpPath)
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if err := tmpFile.Sync(); err != nil {
|
||||||
|
tmpFile.Close()
|
||||||
|
os.Remove(tmpPath)
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if err := tmpFile.Close(); err != nil {
|
||||||
|
os.Remove(tmpPath)
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if err := os.Chmod(tmpPath, perm); err != nil {
|
||||||
|
os.Remove(tmpPath)
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
return os.Rename(tmpPath, path)
|
||||||
|
}
|
||||||
|
|
||||||
|
func appendJSONLine(path string, payload any) error {
|
||||||
|
data, err := json.Marshal(payload)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
dir := filepath.Dir(path)
|
||||||
|
if err := os.MkdirAll(dir, 0o755); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
f, err := os.OpenFile(path, os.O_APPEND|os.O_CREATE|os.O_WRONLY, 0o644)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
defer f.Close()
|
||||||
|
|
||||||
|
writer := bufio.NewWriter(f)
|
||||||
|
if _, err := writer.Write(data); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if err := writer.WriteByte('\n'); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if err := writer.Flush(); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
return f.Sync()
|
||||||
|
}
|
||||||
|
|
||||||
|
// SaveCheckpoint writes the checkpoint to disk.
|
||||||
|
func SaveCheckpoint(runID string, ckpt *Checkpoint) error {
|
||||||
|
if ckpt == nil {
|
||||||
|
return fmt.Errorf("checkpoint is nil")
|
||||||
|
}
|
||||||
|
if usingDB() {
|
||||||
|
return saveCheckpointDB(runID, ckpt)
|
||||||
|
}
|
||||||
|
return writeJSONAtomic(checkpointPath(runID), ckpt)
|
||||||
|
}
|
||||||
|
|
||||||
|
// LoadCheckpoint reads the most recent checkpoint.
|
||||||
|
func LoadCheckpoint(runID string) (*Checkpoint, error) {
|
||||||
|
if usingDB() {
|
||||||
|
return loadCheckpointDB(runID)
|
||||||
|
}
|
||||||
|
path := checkpointPath(runID)
|
||||||
|
data, err := os.ReadFile(path)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
var ckpt Checkpoint
|
||||||
|
if err := json.Unmarshal(data, &ckpt); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return &ckpt, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// SaveRunMetadata writes to run.json.
|
||||||
|
func SaveRunMetadata(meta *RunMetadata) error {
|
||||||
|
if meta == nil {
|
||||||
|
return fmt.Errorf("run metadata is nil")
|
||||||
|
}
|
||||||
|
if meta.Version == 0 {
|
||||||
|
meta.Version = 1
|
||||||
|
}
|
||||||
|
if meta.CreatedAt.IsZero() {
|
||||||
|
meta.CreatedAt = time.Now().UTC()
|
||||||
|
}
|
||||||
|
meta.UpdatedAt = time.Now().UTC()
|
||||||
|
if usingDB() {
|
||||||
|
return saveRunMetadataDB(meta)
|
||||||
|
}
|
||||||
|
return writeJSONAtomic(runMetadataPath(meta.RunID), meta)
|
||||||
|
}
|
||||||
|
|
||||||
|
// LoadRunMetadata reads run.json.
|
||||||
|
func LoadRunMetadata(runID string) (*RunMetadata, error) {
|
||||||
|
if usingDB() {
|
||||||
|
return loadRunMetadataDB(runID)
|
||||||
|
}
|
||||||
|
path := runMetadataPath(runID)
|
||||||
|
data, err := os.ReadFile(path)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
var meta RunMetadata
|
||||||
|
if err := json.Unmarshal(data, &meta); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return &meta, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func appendEquityPoint(runID string, point EquityPoint) error {
|
||||||
|
if usingDB() {
|
||||||
|
return appendEquityPointDB(runID, point)
|
||||||
|
}
|
||||||
|
return appendJSONLine(equityLogPath(runID), point)
|
||||||
|
}
|
||||||
|
|
||||||
|
func appendTradeEvent(runID string, event TradeEvent) error {
|
||||||
|
if usingDB() {
|
||||||
|
return appendTradeEventDB(runID, event)
|
||||||
|
}
|
||||||
|
return appendJSONLine(tradesLogPath(runID), event)
|
||||||
|
}
|
||||||
|
|
||||||
|
func saveMetrics(runID string, metrics *Metrics) error {
|
||||||
|
if metrics == nil {
|
||||||
|
return fmt.Errorf("metrics is nil")
|
||||||
|
}
|
||||||
|
if usingDB() {
|
||||||
|
return saveMetricsDB(runID, metrics)
|
||||||
|
}
|
||||||
|
return writeJSONAtomic(metricsPath(runID), metrics)
|
||||||
|
}
|
||||||
|
|
||||||
|
func saveProgress(runID string, state *BacktestState, cfg *BacktestConfig) error {
|
||||||
|
if state == nil || cfg == nil {
|
||||||
|
return fmt.Errorf("state or config nil")
|
||||||
|
}
|
||||||
|
dur := cfg.Duration()
|
||||||
|
progress := 0.0
|
||||||
|
if dur > 0 {
|
||||||
|
current := time.UnixMilli(state.BarTimestamp)
|
||||||
|
start := time.Unix(cfg.StartTS, 0)
|
||||||
|
if current.After(start) {
|
||||||
|
elapsed := current.Sub(start)
|
||||||
|
progress = float64(elapsed) / float64(dur)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
payload := progressPayload{
|
||||||
|
BarIndex: state.BarIndex,
|
||||||
|
Equity: state.Equity,
|
||||||
|
ProgressPct: progress * 100,
|
||||||
|
Liquidated: state.Liquidated,
|
||||||
|
|
||||||
|
UpdatedAtISO: time.Now().UTC().Format(time.RFC3339),
|
||||||
|
}
|
||||||
|
if usingDB() {
|
||||||
|
return saveProgressDB(runID, payload)
|
||||||
|
}
|
||||||
|
return writeJSONAtomic(progressPath(runID), payload)
|
||||||
|
}
|
||||||
|
|
||||||
|
func SaveConfig(runID string, cfg *BacktestConfig) error {
|
||||||
|
if cfg == nil {
|
||||||
|
return fmt.Errorf("config is nil")
|
||||||
|
}
|
||||||
|
persist := *cfg
|
||||||
|
persist.AICfg.APIKey = ""
|
||||||
|
if usingDB() {
|
||||||
|
return saveConfigDB(runID, &persist)
|
||||||
|
}
|
||||||
|
if err := ensureRunDir(runID); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
return writeJSONAtomic(filepath.Join(runDir(runID), "config.json"), &persist)
|
||||||
|
}
|
||||||
|
|
||||||
|
func LoadConfig(runID string) (*BacktestConfig, error) {
|
||||||
|
if usingDB() {
|
||||||
|
return loadConfigDB(runID)
|
||||||
|
}
|
||||||
|
data, err := os.ReadFile(filepath.Join(runDir(runID), "config.json"))
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
var cfg BacktestConfig
|
||||||
|
if err := json.Unmarshal(data, &cfg); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return &cfg, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func LoadEquityPoints(runID string) ([]EquityPoint, error) {
|
||||||
|
if usingDB() {
|
||||||
|
return loadEquityPointsDB(runID)
|
||||||
|
}
|
||||||
|
points, err := loadJSONLines[EquityPoint](equityLogPath(runID))
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
sort.Slice(points, func(i, j int) bool {
|
||||||
|
return points[i].Timestamp < points[j].Timestamp
|
||||||
|
})
|
||||||
|
return points, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func LoadTradeEvents(runID string) ([]TradeEvent, error) {
|
||||||
|
if usingDB() {
|
||||||
|
return loadTradeEventsDB(runID)
|
||||||
|
}
|
||||||
|
events, err := loadJSONLines[TradeEvent](tradesLogPath(runID))
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
sort.Slice(events, func(i, j int) bool {
|
||||||
|
if events[i].Timestamp == events[j].Timestamp {
|
||||||
|
return events[i].Symbol < events[j].Symbol
|
||||||
|
}
|
||||||
|
return events[i].Timestamp < events[j].Timestamp
|
||||||
|
})
|
||||||
|
return events, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func LoadMetrics(runID string) (*Metrics, error) {
|
||||||
|
if usingDB() {
|
||||||
|
return loadMetricsDB(runID)
|
||||||
|
}
|
||||||
|
data, err := os.ReadFile(metricsPath(runID))
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
var metrics Metrics
|
||||||
|
if err := json.Unmarshal(data, &metrics); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return &metrics, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func LoadRunIDs() ([]string, error) {
|
||||||
|
if usingDB() {
|
||||||
|
return loadRunIDsDB()
|
||||||
|
}
|
||||||
|
entries, err := os.ReadDir(backtestsRootDir)
|
||||||
|
if err != nil {
|
||||||
|
if errors.Is(err, os.ErrNotExist) {
|
||||||
|
return []string{}, nil
|
||||||
|
}
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
runIDs := make([]string, 0, len(entries))
|
||||||
|
for _, entry := range entries {
|
||||||
|
if entry.IsDir() {
|
||||||
|
runIDs = append(runIDs, entry.Name())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
sort.Strings(runIDs)
|
||||||
|
return runIDs, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func loadJSONLines[T any](path string) ([]T, error) {
|
||||||
|
file, err := os.Open(path)
|
||||||
|
if err != nil {
|
||||||
|
if errors.Is(err, os.ErrNotExist) {
|
||||||
|
return []T{}, nil
|
||||||
|
}
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
defer file.Close()
|
||||||
|
|
||||||
|
scanner := bufio.NewScanner(file)
|
||||||
|
scanner.Buffer(make([]byte, 0, 64*1024), 16*1024*1024)
|
||||||
|
|
||||||
|
var result []T
|
||||||
|
for scanner.Scan() {
|
||||||
|
line := scanner.Bytes()
|
||||||
|
if len(line) == 0 {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
var item T
|
||||||
|
if err := json.Unmarshal(line, &item); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
result = append(result, item)
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := scanner.Err(); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
return result, nil
|
||||||
|
}
|
||||||
|
func PersistMetrics(runID string, metrics *Metrics) error {
|
||||||
|
return saveMetrics(runID, metrics)
|
||||||
|
}
|
||||||
|
|
||||||
|
func LoadDecisionTrace(runID string, cycle int) (*store.DecisionRecord, error) {
|
||||||
|
if usingDB() {
|
||||||
|
return loadDecisionTraceDB(runID, cycle)
|
||||||
|
}
|
||||||
|
dir := decisionLogDir(runID)
|
||||||
|
entries, err := os.ReadDir(dir)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
type candidate struct {
|
||||||
|
path string
|
||||||
|
info os.DirEntry
|
||||||
|
}
|
||||||
|
cands := make([]candidate, 0, len(entries))
|
||||||
|
for _, entry := range entries {
|
||||||
|
if entry.IsDir() {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
name := entry.Name()
|
||||||
|
if !strings.HasPrefix(name, "decision_") || !strings.HasSuffix(name, ".json") {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
cands = append(cands, candidate{path: filepath.Join(dir, name), info: entry})
|
||||||
|
}
|
||||||
|
sort.Slice(cands, func(i, j int) bool {
|
||||||
|
infoI, _ := cands[i].info.Info()
|
||||||
|
infoJ, _ := cands[j].info.Info()
|
||||||
|
if infoI == nil || infoJ == nil {
|
||||||
|
return cands[i].path > cands[j].path
|
||||||
|
}
|
||||||
|
return infoI.ModTime().After(infoJ.ModTime())
|
||||||
|
})
|
||||||
|
|
||||||
|
for _, cand := range cands {
|
||||||
|
data, err := os.ReadFile(cand.path)
|
||||||
|
if err != nil {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
var record store.DecisionRecord
|
||||||
|
if err := json.Unmarshal(data, &record); err != nil {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if cycle <= 0 || record.CycleNumber == cycle {
|
||||||
|
return &record, nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return nil, fmt.Errorf("decision trace not found for run %s cycle %d", runID, cycle)
|
||||||
|
}
|
||||||
|
|
||||||
|
func LoadDecisionRecords(runID string, limit, offset int) ([]*store.DecisionRecord, error) {
|
||||||
|
if limit <= 0 {
|
||||||
|
limit = 20
|
||||||
|
}
|
||||||
|
if offset < 0 {
|
||||||
|
offset = 0
|
||||||
|
}
|
||||||
|
if usingDB() {
|
||||||
|
return loadDecisionRecordsDB(runID, limit, offset)
|
||||||
|
}
|
||||||
|
dir := decisionLogDir(runID)
|
||||||
|
entries, err := os.ReadDir(dir)
|
||||||
|
if err != nil {
|
||||||
|
if errors.Is(err, os.ErrNotExist) {
|
||||||
|
return []*store.DecisionRecord{}, nil
|
||||||
|
}
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
type fileEntry struct {
|
||||||
|
path string
|
||||||
|
info os.DirEntry
|
||||||
|
}
|
||||||
|
files := make([]fileEntry, 0, len(entries))
|
||||||
|
for _, entry := range entries {
|
||||||
|
if entry.IsDir() {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
name := entry.Name()
|
||||||
|
if !strings.HasPrefix(name, "decision_") || !strings.HasSuffix(name, ".json") {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
files = append(files, fileEntry{path: filepath.Join(dir, name), info: entry})
|
||||||
|
}
|
||||||
|
sort.Slice(files, func(i, j int) bool {
|
||||||
|
infoI, _ := files[i].info.Info()
|
||||||
|
infoJ, _ := files[j].info.Info()
|
||||||
|
if infoI == nil || infoJ == nil {
|
||||||
|
return files[i].path > files[j].path
|
||||||
|
}
|
||||||
|
return infoI.ModTime().After(infoJ.ModTime())
|
||||||
|
})
|
||||||
|
if offset >= len(files) {
|
||||||
|
return []*store.DecisionRecord{}, nil
|
||||||
|
}
|
||||||
|
end := offset + limit
|
||||||
|
if end > len(files) {
|
||||||
|
end = len(files)
|
||||||
|
}
|
||||||
|
records := make([]*store.DecisionRecord, 0, end-offset)
|
||||||
|
for _, file := range files[offset:end] {
|
||||||
|
data, err := os.ReadFile(file.path)
|
||||||
|
if err != nil {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
var record store.DecisionRecord
|
||||||
|
if err := json.Unmarshal(data, &record); err != nil {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
records = append(records, &record)
|
||||||
|
}
|
||||||
|
return records, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func CreateRunExport(runID string) (string, error) {
|
||||||
|
if usingDB() {
|
||||||
|
return createRunExportDB(runID)
|
||||||
|
}
|
||||||
|
root := runDir(runID)
|
||||||
|
if _, err := os.Stat(root); err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
tmpFile, err := os.CreateTemp("", fmt.Sprintf("%s-*.zip", runID))
|
||||||
|
if err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
defer tmpFile.Close()
|
||||||
|
|
||||||
|
zipWriter := zip.NewWriter(tmpFile)
|
||||||
|
err = filepath.WalkDir(root, func(path string, d os.DirEntry, err error) error {
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if d.IsDir() {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
rel, err := filepath.Rel(root, path)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
info, err := d.Info()
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
header, err := zip.FileInfoHeader(info)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
header.Name = rel
|
||||||
|
header.Method = zip.Deflate
|
||||||
|
writer, err := zipWriter.CreateHeader(header)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
src, err := os.Open(path)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if _, err := io.Copy(writer, src); err != nil {
|
||||||
|
src.Close()
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
src.Close()
|
||||||
|
return nil
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
zipWriter.Close()
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
if err := zipWriter.Close(); err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
return tmpFile.Name(), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func persistDecisionRecord(runID string, record *store.DecisionRecord) {
|
||||||
|
if !usingDB() || record == nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
_ = saveDecisionRecordDB(runID, record)
|
||||||
|
}
|
||||||
498
backtest/storage_db_impl.go
Normal file
498
backtest/storage_db_impl.go
Normal file
@@ -0,0 +1,498 @@
|
|||||||
|
package backtest
|
||||||
|
|
||||||
|
import (
|
||||||
|
"archive/zip"
|
||||||
|
"database/sql"
|
||||||
|
"encoding/json"
|
||||||
|
"errors"
|
||||||
|
"fmt"
|
||||||
|
"os"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"nofx/store"
|
||||||
|
)
|
||||||
|
|
||||||
|
func saveCheckpointDB(runID string, ckpt *Checkpoint) error {
|
||||||
|
data, err := json.Marshal(ckpt)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
_, err = persistenceDB.Exec(convertQuery(`
|
||||||
|
INSERT INTO backtest_checkpoints (run_id, payload, updated_at)
|
||||||
|
VALUES (?, ?, CURRENT_TIMESTAMP)
|
||||||
|
ON CONFLICT(run_id) DO UPDATE SET payload=excluded.payload, updated_at=CURRENT_TIMESTAMP
|
||||||
|
`), runID, data)
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
func loadCheckpointDB(runID string) (*Checkpoint, error) {
|
||||||
|
var payload []byte
|
||||||
|
err := persistenceDB.QueryRow(convertQuery(`SELECT payload FROM backtest_checkpoints WHERE run_id = ?`), runID).Scan(&payload)
|
||||||
|
if err != nil {
|
||||||
|
if errors.Is(err, sql.ErrNoRows) {
|
||||||
|
return nil, os.ErrNotExist
|
||||||
|
}
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
var ckpt Checkpoint
|
||||||
|
if err := json.Unmarshal(payload, &ckpt); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return &ckpt, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func saveConfigDB(runID string, cfg *BacktestConfig) error {
|
||||||
|
persist := *cfg
|
||||||
|
persist.AICfg.APIKey = ""
|
||||||
|
data, err := json.Marshal(&persist)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
template := cfg.PromptTemplate
|
||||||
|
if template == "" {
|
||||||
|
template = "default"
|
||||||
|
}
|
||||||
|
now := time.Now().UTC().Format(time.RFC3339)
|
||||||
|
userID := cfg.UserID
|
||||||
|
if userID == "" {
|
||||||
|
userID = "default"
|
||||||
|
}
|
||||||
|
_, err = persistenceDB.Exec(convertQuery(`
|
||||||
|
INSERT INTO backtest_runs (run_id, user_id, config_json, prompt_template, custom_prompt, override_prompt, ai_provider, ai_model, created_at, updated_at)
|
||||||
|
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||||
|
ON CONFLICT(run_id) DO NOTHING
|
||||||
|
`), runID, userID, data, template, cfg.CustomPrompt, cfg.OverrideBasePrompt, cfg.AICfg.Provider, cfg.AICfg.Model, now, now)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
_, err = persistenceDB.Exec(convertQuery(`
|
||||||
|
UPDATE backtest_runs
|
||||||
|
SET user_id = ?, config_json = ?, prompt_template = ?, custom_prompt = ?, override_prompt = ?, ai_provider = ?, ai_model = ?, updated_at = CURRENT_TIMESTAMP
|
||||||
|
WHERE run_id = ?
|
||||||
|
`), userID, data, template, cfg.CustomPrompt, cfg.OverrideBasePrompt, cfg.AICfg.Provider, cfg.AICfg.Model, runID)
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
func loadConfigDB(runID string) (*BacktestConfig, error) {
|
||||||
|
var payload []byte
|
||||||
|
err := persistenceDB.QueryRow(convertQuery(`SELECT config_json FROM backtest_runs WHERE run_id = ?`), runID).Scan(&payload)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
if len(payload) == 0 {
|
||||||
|
return nil, fmt.Errorf("config missing for %s", runID)
|
||||||
|
}
|
||||||
|
var cfg BacktestConfig
|
||||||
|
if err := json.Unmarshal(payload, &cfg); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return &cfg, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func saveRunMetadataDB(meta *RunMetadata) error {
|
||||||
|
created := meta.CreatedAt.UTC().Format(time.RFC3339)
|
||||||
|
updated := meta.UpdatedAt.UTC().Format(time.RFC3339)
|
||||||
|
userID := meta.UserID
|
||||||
|
if userID == "" {
|
||||||
|
userID = "default"
|
||||||
|
}
|
||||||
|
if _, err := persistenceDB.Exec(convertQuery(`
|
||||||
|
INSERT INTO backtest_runs (run_id, user_id, label, last_error, created_at, updated_at)
|
||||||
|
VALUES (?, ?, ?, ?, ?, ?)
|
||||||
|
ON CONFLICT(run_id) DO NOTHING
|
||||||
|
`), meta.RunID, userID, meta.Label, meta.LastError, created, updated); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
_, err := persistenceDB.Exec(convertQuery(`
|
||||||
|
UPDATE backtest_runs
|
||||||
|
SET user_id = ?, state = ?, symbol_count = ?, decision_tf = ?, processed_bars = ?, progress_pct = ?, equity_last = ?, max_drawdown_pct = ?, liquidated = ?, liquidation_note = ?, label = ?, last_error = ?, updated_at = ?
|
||||||
|
WHERE run_id = ?
|
||||||
|
`), userID, string(meta.State), meta.Summary.SymbolCount, meta.Summary.DecisionTF, meta.Summary.ProcessedBars, meta.Summary.ProgressPct, meta.Summary.EquityLast, meta.Summary.MaxDrawdownPct, meta.Summary.Liquidated, meta.Summary.LiquidationNote, meta.Label, meta.LastError, updated, meta.RunID)
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
func loadRunMetadataDB(runID string) (*RunMetadata, error) {
|
||||||
|
var (
|
||||||
|
userID string
|
||||||
|
state string
|
||||||
|
label string
|
||||||
|
lastErr string
|
||||||
|
symbolCount int
|
||||||
|
decisionTF string
|
||||||
|
processedBars int
|
||||||
|
progressPct float64
|
||||||
|
equityLast float64
|
||||||
|
maxDD float64
|
||||||
|
liquidated bool
|
||||||
|
liquidationNote string
|
||||||
|
createdISO string
|
||||||
|
updatedISO string
|
||||||
|
)
|
||||||
|
err := persistenceDB.QueryRow(convertQuery(`
|
||||||
|
SELECT user_id, state, label, last_error, symbol_count, decision_tf, processed_bars, progress_pct, equity_last, max_drawdown_pct, liquidated, liquidation_note, created_at, updated_at
|
||||||
|
FROM backtest_runs WHERE run_id = ?
|
||||||
|
`), runID).Scan(&userID, &state, &label, &lastErr, &symbolCount, &decisionTF, &processedBars, &progressPct, &equityLast, &maxDD, &liquidated, &liquidationNote, &createdISO, &updatedISO)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
meta := &RunMetadata{
|
||||||
|
RunID: runID,
|
||||||
|
UserID: userID,
|
||||||
|
Version: 1,
|
||||||
|
State: RunState(state),
|
||||||
|
Label: label,
|
||||||
|
LastError: lastErr,
|
||||||
|
Summary: RunSummary{
|
||||||
|
SymbolCount: symbolCount,
|
||||||
|
DecisionTF: decisionTF,
|
||||||
|
ProcessedBars: processedBars,
|
||||||
|
ProgressPct: progressPct,
|
||||||
|
EquityLast: equityLast,
|
||||||
|
MaxDrawdownPct: maxDD,
|
||||||
|
Liquidated: liquidated,
|
||||||
|
LiquidationNote: liquidationNote,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
if meta.UserID == "" {
|
||||||
|
meta.UserID = "default"
|
||||||
|
}
|
||||||
|
if t, err := time.Parse(time.RFC3339, createdISO); err == nil {
|
||||||
|
meta.CreatedAt = t
|
||||||
|
}
|
||||||
|
if t, err := time.Parse(time.RFC3339, updatedISO); err == nil {
|
||||||
|
meta.UpdatedAt = t
|
||||||
|
}
|
||||||
|
return meta, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func loadRunIDsDB() ([]string, error) {
|
||||||
|
rows, err := persistenceDB.Query(`SELECT run_id FROM backtest_runs ORDER BY updated_at DESC`)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
defer rows.Close()
|
||||||
|
var ids []string
|
||||||
|
for rows.Next() {
|
||||||
|
var runID string
|
||||||
|
if err := rows.Scan(&runID); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
ids = append(ids, runID)
|
||||||
|
}
|
||||||
|
return ids, rows.Err()
|
||||||
|
}
|
||||||
|
|
||||||
|
func appendEquityPointDB(runID string, point EquityPoint) error {
|
||||||
|
_, err := persistenceDB.Exec(convertQuery(`
|
||||||
|
INSERT INTO backtest_equity (run_id, ts, equity, available, pnl, pnl_pct, dd_pct, cycle)
|
||||||
|
VALUES (?, ?, ?, ?, ?, ?, ?, ?)
|
||||||
|
`), runID, point.Timestamp, point.Equity, point.Available, point.PnL, point.PnLPct, point.DrawdownPct, point.Cycle)
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
func loadEquityPointsDB(runID string) ([]EquityPoint, error) {
|
||||||
|
rows, err := persistenceDB.Query(convertQuery(`
|
||||||
|
SELECT ts, equity, available, pnl, pnl_pct, dd_pct, cycle
|
||||||
|
FROM backtest_equity WHERE run_id = ? ORDER BY ts ASC
|
||||||
|
`), runID)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
defer rows.Close()
|
||||||
|
points := make([]EquityPoint, 0)
|
||||||
|
for rows.Next() {
|
||||||
|
var point EquityPoint
|
||||||
|
if err := rows.Scan(&point.Timestamp, &point.Equity, &point.Available, &point.PnL, &point.PnLPct, &point.DrawdownPct, &point.Cycle); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
points = append(points, point)
|
||||||
|
}
|
||||||
|
return points, rows.Err()
|
||||||
|
}
|
||||||
|
|
||||||
|
func appendTradeEventDB(runID string, event TradeEvent) error {
|
||||||
|
_, err := persistenceDB.Exec(convertQuery(`
|
||||||
|
INSERT INTO backtest_trades (run_id, ts, symbol, action, side, qty, price, fee, slippage, order_value, realized_pnl, leverage, cycle, position_after, liquidation, note)
|
||||||
|
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||||
|
`), runID, event.Timestamp, event.Symbol, event.Action, event.Side, event.Quantity, event.Price, event.Fee, event.Slippage, event.OrderValue, event.RealizedPnL, event.Leverage, event.Cycle, event.PositionAfter, event.LiquidationFlag, event.Note)
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
func loadTradeEventsDB(runID string) ([]TradeEvent, error) {
|
||||||
|
rows, err := persistenceDB.Query(convertQuery(`
|
||||||
|
SELECT ts, symbol, action, side, qty, price, fee, slippage, order_value, realized_pnl, leverage, cycle, position_after, liquidation, note
|
||||||
|
FROM backtest_trades WHERE run_id = ? ORDER BY ts ASC
|
||||||
|
`), runID)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
defer rows.Close()
|
||||||
|
events := make([]TradeEvent, 0)
|
||||||
|
for rows.Next() {
|
||||||
|
var event TradeEvent
|
||||||
|
if err := rows.Scan(&event.Timestamp, &event.Symbol, &event.Action, &event.Side, &event.Quantity, &event.Price, &event.Fee, &event.Slippage, &event.OrderValue, &event.RealizedPnL, &event.Leverage, &event.Cycle, &event.PositionAfter, &event.LiquidationFlag, &event.Note); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
events = append(events, event)
|
||||||
|
}
|
||||||
|
return events, rows.Err()
|
||||||
|
}
|
||||||
|
|
||||||
|
func saveMetricsDB(runID string, metrics *Metrics) error {
|
||||||
|
data, err := json.Marshal(metrics)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
_, err = persistenceDB.Exec(convertQuery(`
|
||||||
|
INSERT INTO backtest_metrics (run_id, payload, updated_at)
|
||||||
|
VALUES (?, ?, CURRENT_TIMESTAMP)
|
||||||
|
ON CONFLICT(run_id) DO UPDATE SET payload=excluded.payload, updated_at=CURRENT_TIMESTAMP
|
||||||
|
`), runID, data)
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
func loadMetricsDB(runID string) (*Metrics, error) {
|
||||||
|
var payload []byte
|
||||||
|
err := persistenceDB.QueryRow(convertQuery(`SELECT payload FROM backtest_metrics WHERE run_id = ?`), runID).Scan(&payload)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
var metrics Metrics
|
||||||
|
if err := json.Unmarshal(payload, &metrics); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return &metrics, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func saveProgressDB(runID string, payload progressPayload) error {
|
||||||
|
_, err := persistenceDB.Exec(convertQuery(`
|
||||||
|
UPDATE backtest_runs
|
||||||
|
SET progress_pct = ?, equity_last = ?, processed_bars = ?, liquidated = ?, updated_at = ?
|
||||||
|
WHERE run_id = ?
|
||||||
|
`), payload.ProgressPct, payload.Equity, payload.BarIndex, payload.Liquidated, payload.UpdatedAtISO, runID)
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
func loadDecisionTraceDB(runID string, cycle int) (*store.DecisionRecord, error) {
|
||||||
|
var rows *sql.Rows
|
||||||
|
var err error
|
||||||
|
if cycle > 0 {
|
||||||
|
rows, err = persistenceDB.Query(convertQuery(`SELECT payload FROM backtest_decisions WHERE run_id = ? AND cycle = ? ORDER BY created_at DESC LIMIT 1`), runID, cycle)
|
||||||
|
} else {
|
||||||
|
rows, err = persistenceDB.Query(convertQuery(`SELECT payload FROM backtest_decisions WHERE run_id = ? ORDER BY created_at DESC LIMIT 1`), runID)
|
||||||
|
}
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
defer rows.Close()
|
||||||
|
if !rows.Next() {
|
||||||
|
return nil, fmt.Errorf("decision trace not found for %s", runID)
|
||||||
|
}
|
||||||
|
var payload []byte
|
||||||
|
if err := rows.Scan(&payload); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
var record store.DecisionRecord
|
||||||
|
if err := json.Unmarshal(payload, &record); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return &record, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func saveDecisionRecordDB(runID string, record *store.DecisionRecord) error {
|
||||||
|
if record == nil {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
data, err := json.Marshal(record)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
_, err = persistenceDB.Exec(convertQuery(`
|
||||||
|
INSERT INTO backtest_decisions (run_id, cycle, payload)
|
||||||
|
VALUES (?, ?, ?)
|
||||||
|
`), runID, record.CycleNumber, data)
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
func loadDecisionRecordsDB(runID string, limit, offset int) ([]*store.DecisionRecord, error) {
|
||||||
|
rows, err := persistenceDB.Query(convertQuery(`
|
||||||
|
SELECT payload FROM backtest_decisions
|
||||||
|
WHERE run_id = ?
|
||||||
|
ORDER BY id DESC
|
||||||
|
LIMIT ? OFFSET ?
|
||||||
|
`), runID, limit, offset)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
defer rows.Close()
|
||||||
|
records := make([]*store.DecisionRecord, 0, limit)
|
||||||
|
for rows.Next() {
|
||||||
|
var payload []byte
|
||||||
|
if err := rows.Scan(&payload); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
var record store.DecisionRecord
|
||||||
|
if err := json.Unmarshal(payload, &record); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
records = append(records, &record)
|
||||||
|
}
|
||||||
|
return records, rows.Err()
|
||||||
|
}
|
||||||
|
|
||||||
|
func createRunExportDB(runID string) (string, error) {
|
||||||
|
tmpFile, err := os.CreateTemp("", fmt.Sprintf("%s-*.zip", runID))
|
||||||
|
if err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
defer tmpFile.Close()
|
||||||
|
|
||||||
|
zipWriter := zip.NewWriter(tmpFile)
|
||||||
|
defer zipWriter.Close()
|
||||||
|
|
||||||
|
if meta, err := loadRunMetadataDB(runID); err == nil {
|
||||||
|
if err := writeJSONToZip(zipWriter, "run.json", meta); err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if cfg, err := loadConfigDB(runID); err == nil {
|
||||||
|
if err := writeJSONToZip(zipWriter, "config.json", cfg); err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if ckpt, err := loadCheckpointDB(runID); err == nil {
|
||||||
|
if err := writeJSONToZip(zipWriter, "checkpoint.json", ckpt); err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if metrics, err := loadMetricsDB(runID); err == nil {
|
||||||
|
if err := writeJSONToZip(zipWriter, "metrics.json", metrics); err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if points, err := loadEquityPointsDB(runID); err == nil && len(points) > 0 {
|
||||||
|
if err := writeJSONLinesToZip(zipWriter, "equity.jsonl", points); err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if trades, err := loadTradeEventsDB(runID); err == nil && len(trades) > 0 {
|
||||||
|
if err := writeJSONLinesToZip(zipWriter, "trades.jsonl", trades); err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if err := writeDecisionLogsToZip(zipWriter, runID); err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := zipWriter.Close(); err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
if err := tmpFile.Sync(); err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
return tmpFile.Name(), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func writeJSONToZip(z *zip.Writer, name string, value any) error {
|
||||||
|
data, err := json.MarshalIndent(value, "", " ")
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
w, err := z.Create(name)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
_, err = w.Write(data)
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
func writeJSONLinesToZip[T any](z *zip.Writer, name string, items []T) error {
|
||||||
|
w, err := z.Create(name)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
for _, item := range items {
|
||||||
|
data, err := json.Marshal(item)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if _, err := w.Write(data); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if _, err := w.Write([]byte("\n")); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func writeDecisionLogsToZip(z *zip.Writer, runID string) error {
|
||||||
|
rows, err := persistenceDB.Query(convertQuery(`
|
||||||
|
SELECT id, cycle, payload FROM backtest_decisions
|
||||||
|
WHERE run_id = ? ORDER BY id ASC
|
||||||
|
`), runID)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
defer rows.Close()
|
||||||
|
for rows.Next() {
|
||||||
|
var (
|
||||||
|
id int64
|
||||||
|
cycle int
|
||||||
|
payload []byte
|
||||||
|
)
|
||||||
|
if err := rows.Scan(&id, &cycle, &payload); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
name := fmt.Sprintf("decision_logs/decision_%d_cycle%d.json", id, cycle)
|
||||||
|
w, err := z.Create(name)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if _, err := w.Write(payload); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return rows.Err()
|
||||||
|
}
|
||||||
|
|
||||||
|
func listIndexEntriesDB() ([]RunIndexEntry, error) {
|
||||||
|
rows, err := persistenceDB.Query(`
|
||||||
|
SELECT run_id, state, symbol_count, decision_tf, equity_last, max_drawdown_pct, created_at, updated_at, config_json
|
||||||
|
FROM backtest_runs
|
||||||
|
ORDER BY updated_at DESC
|
||||||
|
`)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
defer rows.Close()
|
||||||
|
var entries []RunIndexEntry
|
||||||
|
for rows.Next() {
|
||||||
|
var (
|
||||||
|
entry RunIndexEntry
|
||||||
|
createdISO string
|
||||||
|
updatedISO string
|
||||||
|
cfgJSON []byte
|
||||||
|
symbolCnt int
|
||||||
|
)
|
||||||
|
if err := rows.Scan(&entry.RunID, &entry.State, &symbolCnt, &entry.DecisionTF, &entry.EquityLast, &entry.MaxDrawdownPct, &createdISO, &updatedISO, &cfgJSON); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
entry.CreatedAtISO = createdISO
|
||||||
|
entry.UpdatedAtISO = updatedISO
|
||||||
|
entry.Symbols = make([]string, 0, symbolCnt)
|
||||||
|
var cfg BacktestConfig
|
||||||
|
if len(cfgJSON) > 0 && json.Unmarshal(cfgJSON, &cfg) == nil {
|
||||||
|
entry.Symbols = append([]string(nil), cfg.Symbols...)
|
||||||
|
entry.StartTS = cfg.StartTS
|
||||||
|
entry.EndTS = cfg.EndTS
|
||||||
|
}
|
||||||
|
entries = append(entries, entry)
|
||||||
|
}
|
||||||
|
return entries, rows.Err()
|
||||||
|
}
|
||||||
|
|
||||||
|
func deleteRunDB(runID string) error {
|
||||||
|
_, err := persistenceDB.Exec(convertQuery(`DELETE FROM backtest_runs WHERE run_id = ?`), runID)
|
||||||
|
return err
|
||||||
|
}
|
||||||
179
backtest/types.go
Normal file
179
backtest/types.go
Normal file
@@ -0,0 +1,179 @@
|
|||||||
|
package backtest
|
||||||
|
|
||||||
|
import "time"
|
||||||
|
|
||||||
|
// RunState represents the current state of a backtest run.
|
||||||
|
type RunState string
|
||||||
|
|
||||||
|
const (
|
||||||
|
RunStateCreated RunState = "created"
|
||||||
|
RunStateRunning RunState = "running"
|
||||||
|
RunStatePaused RunState = "paused"
|
||||||
|
RunStateStopped RunState = "stopped"
|
||||||
|
RunStateCompleted RunState = "completed"
|
||||||
|
RunStateFailed RunState = "failed"
|
||||||
|
RunStateLiquidated RunState = "liquidated"
|
||||||
|
)
|
||||||
|
|
||||||
|
// PositionSnapshot represents core position data for backtest state and persistence.
|
||||||
|
type PositionSnapshot struct {
|
||||||
|
Symbol string `json:"symbol"`
|
||||||
|
Side string `json:"side"`
|
||||||
|
Quantity float64 `json:"quantity"`
|
||||||
|
AvgPrice float64 `json:"avg_price"`
|
||||||
|
Leverage int `json:"leverage"`
|
||||||
|
LiquidationPrice float64 `json:"liquidation_price"`
|
||||||
|
MarginUsed float64 `json:"margin_used"`
|
||||||
|
OpenTime int64 `json:"open_time"`
|
||||||
|
AccumulatedFee float64 `json:"accumulated_fee,omitempty"` // Opening fees accumulated
|
||||||
|
}
|
||||||
|
|
||||||
|
// BacktestState represents the real-time state during execution (in-memory state).
|
||||||
|
type BacktestState struct {
|
||||||
|
BarIndex int
|
||||||
|
BarTimestamp int64
|
||||||
|
DecisionCycle int
|
||||||
|
|
||||||
|
Cash float64
|
||||||
|
Equity float64
|
||||||
|
UnrealizedPnL float64
|
||||||
|
RealizedPnL float64
|
||||||
|
MaxEquity float64
|
||||||
|
MinEquity float64
|
||||||
|
MaxDrawdownPct float64
|
||||||
|
Positions map[string]PositionSnapshot
|
||||||
|
LastUpdate time.Time
|
||||||
|
Liquidated bool
|
||||||
|
LiquidationNote string
|
||||||
|
}
|
||||||
|
|
||||||
|
// EquityPoint represents a single point on the equity curve.
|
||||||
|
type EquityPoint struct {
|
||||||
|
Timestamp int64 `json:"ts"`
|
||||||
|
Equity float64 `json:"equity"`
|
||||||
|
Available float64 `json:"available"`
|
||||||
|
PnL float64 `json:"pnl"`
|
||||||
|
PnLPct float64 `json:"pnl_pct"`
|
||||||
|
DrawdownPct float64 `json:"dd_pct"`
|
||||||
|
Cycle int `json:"cycle"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// TradeEvent records a trade execution result or special event (such as liquidation).
|
||||||
|
type TradeEvent struct {
|
||||||
|
Timestamp int64 `json:"ts"`
|
||||||
|
Symbol string `json:"symbol"`
|
||||||
|
Action string `json:"action"`
|
||||||
|
Side string `json:"side,omitempty"`
|
||||||
|
Quantity float64 `json:"qty"`
|
||||||
|
Price float64 `json:"price"`
|
||||||
|
Fee float64 `json:"fee"`
|
||||||
|
Slippage float64 `json:"slippage"`
|
||||||
|
OrderValue float64 `json:"order_value"`
|
||||||
|
RealizedPnL float64 `json:"realized_pnl"`
|
||||||
|
Leverage int `json:"leverage,omitempty"`
|
||||||
|
Cycle int `json:"cycle"`
|
||||||
|
PositionAfter float64 `json:"position_after"`
|
||||||
|
LiquidationFlag bool `json:"liquidation"`
|
||||||
|
Note string `json:"note,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// Metrics summarizes backtest performance metrics.
|
||||||
|
type Metrics struct {
|
||||||
|
TotalReturnPct float64 `json:"total_return_pct"`
|
||||||
|
MaxDrawdownPct float64 `json:"max_drawdown_pct"`
|
||||||
|
SharpeRatio float64 `json:"sharpe_ratio"`
|
||||||
|
ProfitFactor float64 `json:"profit_factor"`
|
||||||
|
WinRate float64 `json:"win_rate"`
|
||||||
|
Trades int `json:"trades"`
|
||||||
|
AvgWin float64 `json:"avg_win"`
|
||||||
|
AvgLoss float64 `json:"avg_loss"`
|
||||||
|
BestSymbol string `json:"best_symbol"`
|
||||||
|
WorstSymbol string `json:"worst_symbol"`
|
||||||
|
SymbolStats map[string]SymbolMetrics `json:"symbol_stats"`
|
||||||
|
Liquidated bool `json:"liquidated"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// SymbolMetrics records performance for a single symbol.
|
||||||
|
type SymbolMetrics struct {
|
||||||
|
TotalTrades int `json:"total_trades"`
|
||||||
|
WinningTrades int `json:"winning_trades"`
|
||||||
|
LosingTrades int `json:"losing_trades"`
|
||||||
|
TotalPnL float64 `json:"total_pnl"`
|
||||||
|
AvgPnL float64 `json:"avg_pnl"`
|
||||||
|
WinRate float64 `json:"win_rate"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// Checkpoint represents checkpoint information saved to disk for pause, resume, and crash recovery.
|
||||||
|
type Checkpoint struct {
|
||||||
|
BarIndex int `json:"bar_index"`
|
||||||
|
BarTimestamp int64 `json:"bar_ts"`
|
||||||
|
Cash float64 `json:"cash"`
|
||||||
|
Equity float64 `json:"equity"`
|
||||||
|
MaxEquity float64 `json:"max_equity"`
|
||||||
|
MinEquity float64 `json:"min_equity"`
|
||||||
|
MaxDrawdownPct float64 `json:"max_drawdown_pct"`
|
||||||
|
UnrealizedPnL float64 `json:"unrealized_pnl"`
|
||||||
|
RealizedPnL float64 `json:"realized_pnl"`
|
||||||
|
Positions []PositionSnapshot `json:"positions"`
|
||||||
|
DecisionCycle int `json:"decision_cycle"`
|
||||||
|
IndicatorsState map[string]map[string]any `json:"indicators_state,omitempty"`
|
||||||
|
RNGSeed int64 `json:"rng_seed,omitempty"`
|
||||||
|
AICacheRef string `json:"ai_cache_ref,omitempty"`
|
||||||
|
Liquidated bool `json:"liquidated"`
|
||||||
|
LiquidationNote string `json:"liquidation_note,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// RunMetadata records the summary required for run.json.
|
||||||
|
type RunMetadata struct {
|
||||||
|
RunID string `json:"run_id"`
|
||||||
|
Label string `json:"label,omitempty"`
|
||||||
|
UserID string `json:"user_id,omitempty"`
|
||||||
|
LastError string `json:"last_error,omitempty"`
|
||||||
|
Version int `json:"version"`
|
||||||
|
State RunState `json:"state"`
|
||||||
|
CreatedAt time.Time `json:"created_at"`
|
||||||
|
UpdatedAt time.Time `json:"updated_at"`
|
||||||
|
Summary RunSummary `json:"summary"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// RunSummary represents the summary field in run.json.
|
||||||
|
type RunSummary struct {
|
||||||
|
SymbolCount int `json:"symbol_count"`
|
||||||
|
DecisionTF string `json:"decision_tf"`
|
||||||
|
ProcessedBars int `json:"processed_bars"`
|
||||||
|
ProgressPct float64 `json:"progress_pct"`
|
||||||
|
EquityLast float64 `json:"equity_last"`
|
||||||
|
MaxDrawdownPct float64 `json:"max_drawdown_pct"`
|
||||||
|
Liquidated bool `json:"liquidated"`
|
||||||
|
LiquidationNote string `json:"liquidation_note,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// StatusPayload is used for /status API responses.
|
||||||
|
type StatusPayload struct {
|
||||||
|
RunID string `json:"run_id"`
|
||||||
|
State RunState `json:"state"`
|
||||||
|
ProgressPct float64 `json:"progress_pct"`
|
||||||
|
ProcessedBars int `json:"processed_bars"`
|
||||||
|
CurrentTime int64 `json:"current_time"`
|
||||||
|
DecisionCycle int `json:"decision_cycle"`
|
||||||
|
Equity float64 `json:"equity"`
|
||||||
|
UnrealizedPnL float64 `json:"unrealized_pnl"`
|
||||||
|
RealizedPnL float64 `json:"realized_pnl"`
|
||||||
|
Positions []PositionStatus `json:"positions,omitempty"`
|
||||||
|
Note string `json:"note,omitempty"`
|
||||||
|
LastError string `json:"last_error,omitempty"`
|
||||||
|
LastUpdatedIso string `json:"last_updated_iso"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// PositionStatus represents a position with unrealized P&L for status display.
|
||||||
|
type PositionStatus struct {
|
||||||
|
Symbol string `json:"symbol"`
|
||||||
|
Side string `json:"side"`
|
||||||
|
Quantity float64 `json:"quantity"`
|
||||||
|
EntryPrice float64 `json:"entry_price"`
|
||||||
|
MarkPrice float64 `json:"mark_price"`
|
||||||
|
Leverage int `json:"leverage"`
|
||||||
|
UnrealizedPnL float64 `json:"unrealized_pnl"`
|
||||||
|
UnrealizedPnLPct float64 `json:"unrealized_pnl_pct"`
|
||||||
|
MarginUsed float64 `json:"margin_used"`
|
||||||
|
}
|
||||||
228
cli.go
228
cli.go
@@ -1,228 +0,0 @@
|
|||||||
package main
|
|
||||||
|
|
||||||
import (
|
|
||||||
"bufio"
|
|
||||||
"errors"
|
|
||||||
"flag"
|
|
||||||
"fmt"
|
|
||||||
"os"
|
|
||||||
"path/filepath"
|
|
||||||
"strings"
|
|
||||||
|
|
||||||
"nofx/auth"
|
|
||||||
"nofx/config"
|
|
||||||
"nofx/crypto"
|
|
||||||
"nofx/logger"
|
|
||||||
"nofx/store"
|
|
||||||
|
|
||||||
"github.com/joho/godotenv"
|
|
||||||
"golang.org/x/term"
|
|
||||||
"gorm.io/gorm"
|
|
||||||
)
|
|
||||||
|
|
||||||
// minResetPasswordLen mirrors the minimum enforced on the authenticated
|
|
||||||
// password-change path (PUT /api/user/password).
|
|
||||||
const minResetPasswordLen = 8
|
|
||||||
|
|
||||||
// runCLISubcommand dispatches local admin subcommands.
|
|
||||||
//
|
|
||||||
// SECURITY: account recovery (reset-password, reset-account) is intentionally
|
|
||||||
// NOT exposed over HTTP. Performing it requires running this binary on the host,
|
|
||||||
// which in turn requires shell/file access to the server. A remote attacker on a
|
|
||||||
// public-facing deployment has only the network — they can reach the API but not
|
|
||||||
// a local process — so recovery cannot be triggered remotely. This is what makes
|
|
||||||
// the recovery path safe even when NOFX is deployed on the public internet.
|
|
||||||
//
|
|
||||||
// Returns true if a subcommand was recognized and handled (caller should exit).
|
|
||||||
// Unknown first args fall through to false to preserve the historical behavior
|
|
||||||
// where `nofx <dbpath>` overrides the SQLite path.
|
|
||||||
func runCLISubcommand(args []string) bool {
|
|
||||||
if len(args) == 0 {
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
switch args[0] {
|
|
||||||
case "reset-password":
|
|
||||||
runResetPassword(args[1:])
|
|
||||||
return true
|
|
||||||
case "reset-account":
|
|
||||||
runResetAccount(args[1:])
|
|
||||||
return true
|
|
||||||
default:
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// openStoreForCLI loads config + encryption and opens the same database the
|
|
||||||
// server uses, so subcommands operate on the live data.
|
|
||||||
func openStoreForCLI(dbPathOverride string) (*store.Store, error) {
|
|
||||||
_ = godotenv.Load()
|
|
||||||
logger.Init(nil)
|
|
||||||
config.MustInit()
|
|
||||||
cfg := config.Get()
|
|
||||||
if strings.TrimSpace(dbPathOverride) != "" {
|
|
||||||
cfg.DBPath = dbPathOverride
|
|
||||||
}
|
|
||||||
|
|
||||||
cryptoService, err := crypto.NewCryptoService()
|
|
||||||
if err != nil {
|
|
||||||
return nil, fmt.Errorf("initialize encryption service: %w", err)
|
|
||||||
}
|
|
||||||
crypto.SetGlobalCryptoService(cryptoService)
|
|
||||||
|
|
||||||
if cfg.DBType == "sqlite" {
|
|
||||||
if dir := filepath.Dir(cfg.DBPath); dir != "." {
|
|
||||||
if err := os.MkdirAll(dir, 0o755); err != nil {
|
|
||||||
return nil, fmt.Errorf("create data directory: %w", err)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
dbType := store.DBTypeSQLite
|
|
||||||
if cfg.DBType == "postgres" {
|
|
||||||
dbType = store.DBTypePostgres
|
|
||||||
}
|
|
||||||
return store.NewWithConfig(store.DBConfig{
|
|
||||||
Type: dbType,
|
|
||||||
Path: cfg.DBPath,
|
|
||||||
Host: cfg.DBHost,
|
|
||||||
Port: cfg.DBPort,
|
|
||||||
User: cfg.DBUser,
|
|
||||||
Password: cfg.DBPassword,
|
|
||||||
DBName: cfg.DBName,
|
|
||||||
SSLMode: cfg.DBSSLMode,
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
// runResetPassword resets the password for a single account from the command
|
|
||||||
// line. Usage: `nofx reset-password --email you@example.com`.
|
|
||||||
func runResetPassword(args []string) {
|
|
||||||
fs := flag.NewFlagSet("reset-password", flag.ExitOnError)
|
|
||||||
email := fs.String("email", "", "email of the account to reset (required)")
|
|
||||||
password := fs.String("password", "", "new password (min 8 chars); omit to enter it interactively")
|
|
||||||
dbPath := fs.String("db", "", "override SQLite DB path (defaults to config / DB_PATH)")
|
|
||||||
_ = fs.Parse(args)
|
|
||||||
|
|
||||||
if strings.TrimSpace(*email) == "" {
|
|
||||||
fmt.Fprintln(os.Stderr, "error: --email is required")
|
|
||||||
fmt.Fprintln(os.Stderr, "usage: nofx reset-password --email you@example.com")
|
|
||||||
os.Exit(2)
|
|
||||||
}
|
|
||||||
|
|
||||||
st, err := openStoreForCLI(*dbPath)
|
|
||||||
if err != nil {
|
|
||||||
fmt.Fprintf(os.Stderr, "error: %v\n", err)
|
|
||||||
os.Exit(1)
|
|
||||||
}
|
|
||||||
defer st.Close()
|
|
||||||
|
|
||||||
user, err := st.User().GetByEmail(strings.TrimSpace(*email))
|
|
||||||
if err != nil {
|
|
||||||
fmt.Fprintf(os.Stderr, "error: no account found for %q\n", strings.TrimSpace(*email))
|
|
||||||
os.Exit(1)
|
|
||||||
}
|
|
||||||
|
|
||||||
newPassword, err := resolveNewPassword(*password)
|
|
||||||
if err != nil {
|
|
||||||
fmt.Fprintf(os.Stderr, "error: %v\n", err)
|
|
||||||
os.Exit(1)
|
|
||||||
}
|
|
||||||
|
|
||||||
hash, err := auth.HashPassword(newPassword)
|
|
||||||
if err != nil {
|
|
||||||
fmt.Fprintf(os.Stderr, "error: failed to hash password: %v\n", err)
|
|
||||||
os.Exit(1)
|
|
||||||
}
|
|
||||||
if err := st.User().UpdatePassword(user.ID, hash); err != nil {
|
|
||||||
fmt.Fprintf(os.Stderr, "error: failed to update password: %v\n", err)
|
|
||||||
os.Exit(1)
|
|
||||||
}
|
|
||||||
|
|
||||||
fmt.Printf("✓ Password reset for %s. Log in with the new password.\n", user.Email)
|
|
||||||
}
|
|
||||||
|
|
||||||
// runResetAccount wipes the database back to an uninitialized state. This is the
|
|
||||||
// destructive "forgot everything" recovery, moved off the public API.
|
|
||||||
func runResetAccount(args []string) {
|
|
||||||
fs := flag.NewFlagSet("reset-account", flag.ExitOnError)
|
|
||||||
dbPath := fs.String("db", "", "override SQLite DB path (defaults to config / DB_PATH)")
|
|
||||||
yes := fs.Bool("yes", false, "skip the interactive confirmation prompt")
|
|
||||||
_ = fs.Parse(args)
|
|
||||||
|
|
||||||
if !*yes {
|
|
||||||
fmt.Print("This permanently deletes ALL users, traders, strategies, AI models and\n" +
|
|
||||||
"exchanges — including wallet keys and exchange credentials.\n" +
|
|
||||||
"Type 'wipe' to confirm: ")
|
|
||||||
line, _ := bufio.NewReader(os.Stdin).ReadString('\n')
|
|
||||||
if strings.TrimSpace(line) != "wipe" {
|
|
||||||
fmt.Fprintln(os.Stderr, "aborted")
|
|
||||||
os.Exit(1)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
st, err := openStoreForCLI(*dbPath)
|
|
||||||
if err != nil {
|
|
||||||
fmt.Fprintf(os.Stderr, "error: %v\n", err)
|
|
||||||
os.Exit(1)
|
|
||||||
}
|
|
||||||
defer st.Close()
|
|
||||||
|
|
||||||
err = st.Transaction(func(tx *gorm.DB) error {
|
|
||||||
tx.Session(&gorm.Session{AllowGlobalUpdate: true}).Delete(&store.Trader{})
|
|
||||||
tx.Session(&gorm.Session{AllowGlobalUpdate: true}).Delete(&store.Strategy{})
|
|
||||||
tx.Session(&gorm.Session{AllowGlobalUpdate: true}).Delete(&store.AIModel{})
|
|
||||||
tx.Session(&gorm.Session{AllowGlobalUpdate: true}).Delete(&store.Exchange{})
|
|
||||||
if err := tx.Session(&gorm.Session{AllowGlobalUpdate: true}).Delete(&store.User{}).Error; err != nil {
|
|
||||||
return fmt.Errorf("failed to delete users: %w", err)
|
|
||||||
}
|
|
||||||
return nil
|
|
||||||
})
|
|
||||||
if err != nil {
|
|
||||||
fmt.Fprintf(os.Stderr, "error: failed to reset account: %v\n", err)
|
|
||||||
os.Exit(1)
|
|
||||||
}
|
|
||||||
|
|
||||||
fmt.Println("✓ System wiped. Register a fresh account and re-import everything.")
|
|
||||||
}
|
|
||||||
|
|
||||||
// resolveNewPassword returns the new password from the --password flag, or
|
|
||||||
// prompts for it (hidden) on a TTY, or reads a single line from piped stdin.
|
|
||||||
func resolveNewPassword(flagValue string) (string, error) {
|
|
||||||
if flagValue != "" {
|
|
||||||
if len(flagValue) < minResetPasswordLen {
|
|
||||||
return "", fmt.Errorf("password must be at least %d characters", minResetPasswordLen)
|
|
||||||
}
|
|
||||||
return flagValue, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
if term.IsTerminal(int(os.Stdin.Fd())) {
|
|
||||||
fmt.Printf("New password (min %d chars): ", minResetPasswordLen)
|
|
||||||
first, err := term.ReadPassword(int(os.Stdin.Fd()))
|
|
||||||
fmt.Println()
|
|
||||||
if err != nil {
|
|
||||||
return "", fmt.Errorf("failed to read password: %w", err)
|
|
||||||
}
|
|
||||||
fmt.Print("Confirm new password: ")
|
|
||||||
second, err := term.ReadPassword(int(os.Stdin.Fd()))
|
|
||||||
fmt.Println()
|
|
||||||
if err != nil {
|
|
||||||
return "", fmt.Errorf("failed to read password: %w", err)
|
|
||||||
}
|
|
||||||
if string(first) != string(second) {
|
|
||||||
return "", errors.New("passwords do not match")
|
|
||||||
}
|
|
||||||
if len(first) < minResetPasswordLen {
|
|
||||||
return "", fmt.Errorf("password must be at least %d characters", minResetPasswordLen)
|
|
||||||
}
|
|
||||||
return string(first), nil
|
|
||||||
}
|
|
||||||
|
|
||||||
line, err := bufio.NewReader(os.Stdin).ReadString('\n')
|
|
||||||
password := strings.TrimRight(line, "\r\n")
|
|
||||||
if password == "" {
|
|
||||||
return "", fmt.Errorf("no password provided on stdin: %w", err)
|
|
||||||
}
|
|
||||||
if len(password) < minResetPasswordLen {
|
|
||||||
return "", fmt.Errorf("password must be at least %d characters", minResetPasswordLen)
|
|
||||||
}
|
|
||||||
return password, nil
|
|
||||||
}
|
|
||||||
@@ -1,120 +0,0 @@
|
|||||||
package main
|
|
||||||
|
|
||||||
import (
|
|
||||||
"encoding/json"
|
|
||||||
"fmt"
|
|
||||||
"net/http"
|
|
||||||
"nofx/config"
|
|
||||||
nofxcrypto "nofx/crypto"
|
|
||||||
"nofx/store"
|
|
||||||
hltrader "nofx/trader/hyperliquid"
|
|
||||||
"os"
|
|
||||||
"strconv"
|
|
||||||
"strings"
|
|
||||||
"time"
|
|
||||||
|
|
||||||
"github.com/joho/godotenv"
|
|
||||||
)
|
|
||||||
|
|
||||||
type clearinghouseState struct {
|
|
||||||
CrossMarginSummary struct {
|
|
||||||
AccountValue string `json:"accountValue"`
|
|
||||||
} `json:"crossMarginSummary"`
|
|
||||||
Withdrawable string `json:"withdrawable"`
|
|
||||||
AssetPositions []struct {
|
|
||||||
Position struct {
|
|
||||||
Coin string `json:"coin"`
|
|
||||||
Szi string `json:"szi"`
|
|
||||||
EntryPx string `json:"entryPx"`
|
|
||||||
PositionValue string `json:"positionValue"`
|
|
||||||
} `json:"position"`
|
|
||||||
} `json:"assetPositions"`
|
|
||||||
}
|
|
||||||
|
|
||||||
func fetchState(wallet string) (*clearinghouseState, error) {
|
|
||||||
body := strings.NewReader(fmt.Sprintf(`{"type":"clearinghouseState","user":%q}`, wallet))
|
|
||||||
resp, err := http.Post("https://api.hyperliquid.xyz/info", "application/json", body)
|
|
||||||
if err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
defer resp.Body.Close()
|
|
||||||
var state clearinghouseState
|
|
||||||
if err := json.NewDecoder(resp.Body).Decode(&state); err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
return &state, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func positionSize(state *clearinghouseState, coin string) float64 {
|
|
||||||
for _, ap := range state.AssetPositions {
|
|
||||||
if strings.EqualFold(ap.Position.Coin, coin) {
|
|
||||||
v, _ := strconv.ParseFloat(ap.Position.Szi, 64)
|
|
||||||
return v
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return 0
|
|
||||||
}
|
|
||||||
|
|
||||||
func main() {
|
|
||||||
_ = godotenv.Load()
|
|
||||||
config.Init()
|
|
||||||
cryptoService, err := nofxcrypto.NewCryptoService()
|
|
||||||
if err != nil {
|
|
||||||
panic(err)
|
|
||||||
}
|
|
||||||
nofxcrypto.SetGlobalCryptoService(cryptoService)
|
|
||||||
cfg := config.Get()
|
|
||||||
st, err := store.NewWithConfig(store.DBConfig{Type: store.DBTypeSQLite, Path: cfg.DBPath})
|
|
||||||
if err != nil {
|
|
||||||
panic(err)
|
|
||||||
}
|
|
||||||
defer st.Close()
|
|
||||||
|
|
||||||
var ex store.Exchange
|
|
||||||
if err := st.GormDB().Where("exchange_type = ? AND enabled = ? AND hyperliquid_wallet_addr <> ''", "hyperliquid", true).First(&ex).Error; err != nil {
|
|
||||||
panic(fmt.Errorf("no enabled Hyperliquid exchange with wallet/private key found: %w", err))
|
|
||||||
}
|
|
||||||
if strings.TrimSpace(string(ex.APIKey)) == "" {
|
|
||||||
panic("Hyperliquid exchange has empty decrypted agent private key")
|
|
||||||
}
|
|
||||||
|
|
||||||
fmt.Printf("E2E exchange=%s account=%s wallet=%s testnet=%v builderApprovedFlag=%v\n", ex.ID, ex.AccountName, ex.HyperliquidWalletAddr, ex.Testnet, ex.HyperliquidBuilderApproved)
|
|
||||||
before, err := fetchState(ex.HyperliquidWalletAddr)
|
|
||||||
if err != nil {
|
|
||||||
panic(err)
|
|
||||||
}
|
|
||||||
fmt.Printf("BEFORE accountValue=%s withdrawable=%s HOOD_szi=%.6f\n", before.CrossMarginSummary.AccountValue, before.Withdrawable, positionSize(before, "xyz:HOOD"))
|
|
||||||
|
|
||||||
tr, err := hltrader.NewHyperliquidTrader(string(ex.APIKey), ex.HyperliquidWalletAddr, false, ex.HyperliquidUnifiedAcct)
|
|
||||||
if err != nil {
|
|
||||||
panic(err)
|
|
||||||
}
|
|
||||||
|
|
||||||
const symbol = "HOOD-USDC"
|
|
||||||
const qty = 0.15
|
|
||||||
fmt.Printf("OPEN_LONG symbol=%s qty=%.3f builderRequired=true\n", symbol, qty)
|
|
||||||
if _, err := tr.OpenLong(symbol, qty, 1); err != nil {
|
|
||||||
panic(fmt.Errorf("open long failed: %w", err))
|
|
||||||
}
|
|
||||||
time.Sleep(2 * time.Second)
|
|
||||||
mid, _ := fetchState(ex.HyperliquidWalletAddr)
|
|
||||||
pos := positionSize(mid, "xyz:HOOD")
|
|
||||||
fmt.Printf("AFTER_OPEN HOOD_szi=%.6f\n", pos)
|
|
||||||
closeQty := qty
|
|
||||||
if pos > 0 && pos < closeQty {
|
|
||||||
closeQty = pos
|
|
||||||
}
|
|
||||||
if closeQty > 0 {
|
|
||||||
fmt.Printf("CLOSE_LONG symbol=%s qty=%.6f builderRequired=true\n", symbol, closeQty)
|
|
||||||
if _, err := tr.CloseLong(symbol, closeQty); err != nil {
|
|
||||||
panic(fmt.Errorf("close long failed; manual intervention may be needed for %s size %.6f: %w", symbol, closeQty, err))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
time.Sleep(2 * time.Second)
|
|
||||||
after, err := fetchState(ex.HyperliquidWalletAddr)
|
|
||||||
if err != nil {
|
|
||||||
panic(err)
|
|
||||||
}
|
|
||||||
fmt.Printf("AFTER_CLOSE accountValue=%s withdrawable=%s HOOD_szi=%.6f\n", after.CrossMarginSummary.AccountValue, after.Withdrawable, positionSize(after, "xyz:HOOD"))
|
|
||||||
fmt.Fprintln(os.Stdout, "E2E_BUILDER_FEE_REAL_XYZ_STOCK_TRADE_DONE")
|
|
||||||
}
|
|
||||||
233
cmd/lighter_test/main.go
Normal file
233
cmd/lighter_test/main.go
Normal file
@@ -0,0 +1,233 @@
|
|||||||
|
// Lighter API Authentication Test Tool
|
||||||
|
// Usage: go run cmd/lighter_test/main.go -wallet=0x... -apikey=... [-testnet]
|
||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"encoding/json"
|
||||||
|
"flag"
|
||||||
|
"fmt"
|
||||||
|
"io"
|
||||||
|
"net/http"
|
||||||
|
"net/url"
|
||||||
|
"os"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
lighterClient "github.com/elliottech/lighter-go/client"
|
||||||
|
lighterHTTP "github.com/elliottech/lighter-go/client/http"
|
||||||
|
)
|
||||||
|
|
||||||
|
func main() {
|
||||||
|
// Parse command line flags
|
||||||
|
walletAddr := flag.String("wallet", "", "Ethereum wallet address")
|
||||||
|
apiKeyPrivateKey := flag.String("apikey", "", "API key private key (40 bytes hex)")
|
||||||
|
apiKeyIndex := flag.Int("apikeyindex", 0, "API key index (0-255)")
|
||||||
|
testnet := flag.Bool("testnet", false, "Use testnet instead of mainnet")
|
||||||
|
flag.Parse()
|
||||||
|
|
||||||
|
if *walletAddr == "" || *apiKeyPrivateKey == "" {
|
||||||
|
fmt.Println("Usage: go run cmd/lighter_test/main.go -wallet=0x... -apikey=...")
|
||||||
|
fmt.Println("Options:")
|
||||||
|
fmt.Println(" -wallet Ethereum wallet address (required)")
|
||||||
|
fmt.Println(" -apikey API key private key, 40 bytes hex (required)")
|
||||||
|
fmt.Println(" -apikeyindex API key index, 0-255 (default: 0)")
|
||||||
|
fmt.Println(" -testnet Use testnet instead of mainnet")
|
||||||
|
os.Exit(1)
|
||||||
|
}
|
||||||
|
|
||||||
|
fmt.Println("=== Lighter API Authentication Test ===")
|
||||||
|
fmt.Printf("Wallet: %s\n", *walletAddr)
|
||||||
|
fmt.Printf("API Key Index: %d\n", *apiKeyIndex)
|
||||||
|
fmt.Printf("Testnet: %v\n", *testnet)
|
||||||
|
fmt.Println()
|
||||||
|
|
||||||
|
// Determine base URL
|
||||||
|
baseURL := "https://mainnet.zklighter.elliot.ai"
|
||||||
|
chainID := uint32(304)
|
||||||
|
if *testnet {
|
||||||
|
baseURL = "https://testnet.zklighter.elliot.ai"
|
||||||
|
chainID = uint32(300)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Create HTTP client
|
||||||
|
httpClient := lighterHTTP.NewClient(baseURL)
|
||||||
|
client := &http.Client{Timeout: 30 * time.Second}
|
||||||
|
|
||||||
|
// Step 1: Get account info
|
||||||
|
fmt.Println("Step 1: Getting account info...")
|
||||||
|
accountInfo, err := getAccountByL1Address(client, baseURL, *walletAddr)
|
||||||
|
if err != nil {
|
||||||
|
fmt.Printf("ERROR: Failed to get account info: %v\n", err)
|
||||||
|
os.Exit(1)
|
||||||
|
}
|
||||||
|
fmt.Printf("SUCCESS: Account index = %d\n\n", accountInfo.AccountIndex)
|
||||||
|
|
||||||
|
// Step 2: Create TxClient
|
||||||
|
fmt.Println("Step 2: Creating TxClient...")
|
||||||
|
txClient, err := lighterClient.NewTxClient(
|
||||||
|
httpClient,
|
||||||
|
*apiKeyPrivateKey,
|
||||||
|
accountInfo.AccountIndex,
|
||||||
|
uint8(*apiKeyIndex),
|
||||||
|
chainID,
|
||||||
|
)
|
||||||
|
if err != nil {
|
||||||
|
fmt.Printf("ERROR: Failed to create TxClient: %v\n", err)
|
||||||
|
os.Exit(1)
|
||||||
|
}
|
||||||
|
fmt.Println("SUCCESS: TxClient created\n")
|
||||||
|
|
||||||
|
// Step 3: Generate auth token
|
||||||
|
fmt.Println("Step 3: Generating auth token...")
|
||||||
|
deadline := time.Now().Add(1 * time.Hour)
|
||||||
|
authToken, err := txClient.GetAuthToken(deadline)
|
||||||
|
if err != nil {
|
||||||
|
fmt.Printf("ERROR: Failed to generate auth token: %v\n", err)
|
||||||
|
os.Exit(1)
|
||||||
|
}
|
||||||
|
fmt.Printf("SUCCESS: Auth token generated\n")
|
||||||
|
fmt.Printf("Token: %s...\n", authToken[:min(50, len(authToken))])
|
||||||
|
fmt.Printf("Valid until: %s\n\n", deadline.Format(time.RFC3339))
|
||||||
|
|
||||||
|
// Step 4: Test GetActiveOrders API with auth query parameter
|
||||||
|
fmt.Println("Step 4: Testing GetActiveOrders API...")
|
||||||
|
encodedAuth := url.QueryEscape(authToken)
|
||||||
|
endpoint := fmt.Sprintf("%s/api/v1/accountActiveOrders?account_index=%d&market_id=0&auth=%s",
|
||||||
|
baseURL, accountInfo.AccountIndex, encodedAuth)
|
||||||
|
|
||||||
|
fmt.Printf("Endpoint: %s...\n", endpoint[:min(120, len(endpoint))])
|
||||||
|
|
||||||
|
req, err := http.NewRequest("GET", endpoint, nil)
|
||||||
|
if err != nil {
|
||||||
|
fmt.Printf("ERROR: Failed to create request: %v\n", err)
|
||||||
|
os.Exit(1)
|
||||||
|
}
|
||||||
|
req.Header.Set("Content-Type", "application/json")
|
||||||
|
|
||||||
|
resp, err := client.Do(req)
|
||||||
|
if err != nil {
|
||||||
|
fmt.Printf("ERROR: Request failed: %v\n", err)
|
||||||
|
os.Exit(1)
|
||||||
|
}
|
||||||
|
defer resp.Body.Close()
|
||||||
|
|
||||||
|
body, _ := io.ReadAll(resp.Body)
|
||||||
|
fmt.Printf("Status: %d\n", resp.StatusCode)
|
||||||
|
fmt.Printf("Response: %s\n\n", string(body))
|
||||||
|
|
||||||
|
// Parse response
|
||||||
|
var apiResp struct {
|
||||||
|
Code int `json:"code"`
|
||||||
|
Message string `json:"message"`
|
||||||
|
Orders []struct {
|
||||||
|
OrderID string `json:"order_id"`
|
||||||
|
Side string `json:"side"`
|
||||||
|
Type string `json:"type"`
|
||||||
|
Price string `json:"price"`
|
||||||
|
} `json:"orders"`
|
||||||
|
}
|
||||||
|
if err := json.Unmarshal(body, &apiResp); err != nil {
|
||||||
|
fmt.Printf("ERROR: Failed to parse response: %v\n", err)
|
||||||
|
os.Exit(1)
|
||||||
|
}
|
||||||
|
|
||||||
|
if apiResp.Code != 200 {
|
||||||
|
fmt.Printf("API ERROR: code=%d, message=%s\n", apiResp.Code, apiResp.Message)
|
||||||
|
fmt.Println("\n=== DIAGNOSTIC INFO ===")
|
||||||
|
fmt.Println("If you see 'invalid signature', possible causes:")
|
||||||
|
fmt.Println("1. API key is not registered on-chain")
|
||||||
|
fmt.Println("2. API key private key is incorrect")
|
||||||
|
fmt.Println("3. API key index is wrong")
|
||||||
|
fmt.Println("4. Account index mismatch")
|
||||||
|
fmt.Println("\nTo fix:")
|
||||||
|
fmt.Println("- Go to app.lighter.xyz and register/verify your API key")
|
||||||
|
fmt.Println("- Make sure you're using the correct API key private key")
|
||||||
|
os.Exit(1)
|
||||||
|
}
|
||||||
|
|
||||||
|
fmt.Printf("SUCCESS: Retrieved %d orders\n", len(apiResp.Orders))
|
||||||
|
for i, order := range apiResp.Orders {
|
||||||
|
if i >= 5 {
|
||||||
|
fmt.Printf("... and %d more orders\n", len(apiResp.Orders)-5)
|
||||||
|
break
|
||||||
|
}
|
||||||
|
fmt.Printf(" Order %s: %s %s @ %s\n", order.OrderID, order.Side, order.Type, order.Price)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Step 5: Test GetTrades API (also needs auth)
|
||||||
|
fmt.Println("\nStep 5: Testing GetTrades API...")
|
||||||
|
tradesEndpoint := fmt.Sprintf("%s/api/v1/trades?account_index=%d&sort_by=timestamp&sort_dir=desc&limit=5&auth=%s",
|
||||||
|
baseURL, accountInfo.AccountIndex, encodedAuth)
|
||||||
|
|
||||||
|
tradesReq, _ := http.NewRequest("GET", tradesEndpoint, nil)
|
||||||
|
tradesResp, err := client.Do(tradesReq)
|
||||||
|
if err != nil {
|
||||||
|
fmt.Printf("ERROR: Trades request failed: %v\n", err)
|
||||||
|
} else {
|
||||||
|
defer tradesResp.Body.Close()
|
||||||
|
tradesBody, _ := io.ReadAll(tradesResp.Body)
|
||||||
|
fmt.Printf("Status: %d\n", tradesResp.StatusCode)
|
||||||
|
if tradesResp.StatusCode == 200 {
|
||||||
|
fmt.Println("SUCCESS: GetTrades API working")
|
||||||
|
} else {
|
||||||
|
fmt.Printf("Response: %s\n", string(tradesBody))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fmt.Println("\n=== ALL TESTS PASSED ===")
|
||||||
|
}
|
||||||
|
|
||||||
|
// AccountInfo represents Lighter account information
|
||||||
|
type AccountInfo struct {
|
||||||
|
AccountIndex int64 `json:"account_index"`
|
||||||
|
L1Address string `json:"l1_address"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// getAccountByL1Address gets account info by L1 wallet address
|
||||||
|
func getAccountByL1Address(client *http.Client, baseURL, walletAddr string) (*AccountInfo, error) {
|
||||||
|
endpoint := fmt.Sprintf("%s/api/v1/account?by=l1_address&value=%s", baseURL, walletAddr)
|
||||||
|
|
||||||
|
req, err := http.NewRequest("GET", endpoint, nil)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
|
||||||
|
defer cancel()
|
||||||
|
req = req.WithContext(ctx)
|
||||||
|
|
||||||
|
resp, err := client.Do(req)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
defer resp.Body.Close()
|
||||||
|
|
||||||
|
body, err := io.ReadAll(resp.Body)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
// Parse response - can be in "accounts" or "sub_accounts" field
|
||||||
|
var apiResp struct {
|
||||||
|
Code int `json:"code"`
|
||||||
|
Message string `json:"message"`
|
||||||
|
Accounts []AccountInfo `json:"accounts"`
|
||||||
|
SubAccounts []AccountInfo `json:"sub_accounts"`
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := json.Unmarshal(body, &apiResp); err != nil {
|
||||||
|
return nil, fmt.Errorf("failed to parse response: %w, body: %s", err, string(body))
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check main accounts first
|
||||||
|
if len(apiResp.Accounts) > 0 {
|
||||||
|
return &apiResp.Accounts[0], nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check sub-accounts
|
||||||
|
if len(apiResp.SubAccounts) > 0 {
|
||||||
|
return &apiResp.SubAccounts[0], nil
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil, fmt.Errorf("no account found for address: %s", walletAddr)
|
||||||
|
}
|
||||||
@@ -1,23 +1,13 @@
|
|||||||
package config
|
package config
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"fmt"
|
"nofx/experience"
|
||||||
"nofx/mcp"
|
"nofx/mcp"
|
||||||
"nofx/telemetry"
|
|
||||||
"os"
|
"os"
|
||||||
"strconv"
|
"strconv"
|
||||||
"strings"
|
"strings"
|
||||||
)
|
)
|
||||||
|
|
||||||
// insecureDefaultJWTSecret is the historical fallback value. Refusing to boot when
|
|
||||||
// JWT_SECRET matches it (or is missing) prevents the server from silently signing
|
|
||||||
// tokens with a well-known secret.
|
|
||||||
const insecureDefaultJWTSecret = "default-jwt-secret-change-in-production"
|
|
||||||
|
|
||||||
// minJWTSecretLength is the minimum byte length we accept for HS256 signing keys.
|
|
||||||
// HS256 keys shorter than 32 bytes are brute-forceable.
|
|
||||||
const minJWTSecretLength = 32
|
|
||||||
|
|
||||||
// Global configuration instance
|
// Global configuration instance
|
||||||
var global *Config
|
var global *Config
|
||||||
|
|
||||||
@@ -25,8 +15,10 @@ var global *Config
|
|||||||
// Only contains truly global config, trading related config is at trader/strategy level
|
// Only contains truly global config, trading related config is at trader/strategy level
|
||||||
type Config struct {
|
type Config struct {
|
||||||
// Service configuration
|
// Service configuration
|
||||||
APIServerPort int
|
APIServerPort int
|
||||||
JWTSecret string
|
JWTSecret string
|
||||||
|
RegistrationEnabled bool
|
||||||
|
MaxUsers int // Maximum number of users allowed (0 = unlimited, default = 10)
|
||||||
|
|
||||||
// Database configuration
|
// Database configuration
|
||||||
DBType string // sqlite or postgres
|
DBType string // sqlite or postgres
|
||||||
@@ -52,29 +44,14 @@ type Config struct {
|
|||||||
AlpacaAPIKey string // Alpaca API key for US stocks
|
AlpacaAPIKey string // Alpaca API key for US stocks
|
||||||
AlpacaSecretKey string // Alpaca secret key
|
AlpacaSecretKey string // Alpaca secret key
|
||||||
TwelveDataKey string // TwelveData API key for forex & metals
|
TwelveDataKey string // TwelveData API key for forex & metals
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// MustInit initializes global configuration or panics. Use from main() so the
|
// Init initializes global configuration (from .env)
|
||||||
// process refuses to start under an insecure config (e.g. default JWT secret).
|
|
||||||
func MustInit() {
|
|
||||||
if err := initConfig(); err != nil {
|
|
||||||
panic(fmt.Sprintf("config: %v", err))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Init initializes global configuration (from .env). Prefer MustInit from main.
|
|
||||||
func Init() {
|
func Init() {
|
||||||
if err := initConfig(); err != nil {
|
|
||||||
// Preserve historical fail-soft behavior for non-main callers (tests, tools);
|
|
||||||
// the process can still observe the error via Get() returning nil.
|
|
||||||
fmt.Fprintf(os.Stderr, "config init failed: %v\n", err)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func initConfig() error {
|
|
||||||
cfg := &Config{
|
cfg := &Config{
|
||||||
APIServerPort: 8080,
|
APIServerPort: 8080,
|
||||||
|
RegistrationEnabled: true,
|
||||||
|
MaxUsers: 10, // Default: 10 users allowed
|
||||||
ExperienceImprovement: true, // Default: enabled to help improve the product
|
ExperienceImprovement: true, // Default: enabled to help improve the product
|
||||||
// Database defaults
|
// Database defaults
|
||||||
DBType: "sqlite",
|
DBType: "sqlite",
|
||||||
@@ -91,13 +68,17 @@ func initConfig() error {
|
|||||||
cfg.JWTSecret = strings.TrimSpace(v)
|
cfg.JWTSecret = strings.TrimSpace(v)
|
||||||
}
|
}
|
||||||
if cfg.JWTSecret == "" {
|
if cfg.JWTSecret == "" {
|
||||||
return fmt.Errorf("JWT_SECRET is required (set a random %d+ byte value in .env)", minJWTSecretLength)
|
cfg.JWTSecret = "default-jwt-secret-change-in-production"
|
||||||
}
|
}
|
||||||
if cfg.JWTSecret == insecureDefaultJWTSecret {
|
|
||||||
return fmt.Errorf("JWT_SECRET matches the insecure default; generate a fresh random value (e.g. `openssl rand -base64 48`)")
|
if v := os.Getenv("REGISTRATION_ENABLED"); v != "" {
|
||||||
|
cfg.RegistrationEnabled = strings.ToLower(v) == "true"
|
||||||
}
|
}
|
||||||
if len(cfg.JWTSecret) < minJWTSecretLength {
|
|
||||||
return fmt.Errorf("JWT_SECRET must be at least %d bytes (got %d); generate via `openssl rand -base64 48`", minJWTSecretLength, len(cfg.JWTSecret))
|
if v := os.Getenv("MAX_USERS"); v != "" {
|
||||||
|
if maxUsers, err := strconv.Atoi(v); err == nil && maxUsers >= 0 {
|
||||||
|
cfg.MaxUsers = maxUsers
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if v := os.Getenv("API_SERVER_PORT"); v != "" {
|
if v := os.Getenv("API_SERVER_PORT"); v != "" {
|
||||||
@@ -154,19 +135,17 @@ func initConfig() error {
|
|||||||
global = cfg
|
global = cfg
|
||||||
|
|
||||||
// Initialize experience improvement (installation ID will be set after database init)
|
// Initialize experience improvement (installation ID will be set after database init)
|
||||||
telemetry.Init(cfg.ExperienceImprovement, "")
|
experience.Init(cfg.ExperienceImprovement, "")
|
||||||
|
|
||||||
// Set up AI token usage tracking callback
|
// Set up AI token usage tracking callback
|
||||||
mcp.TokenUsageCallback = func(usage mcp.TokenUsage) {
|
mcp.TokenUsageCallback = func(usage mcp.TokenUsage) {
|
||||||
telemetry.TrackAIUsage(telemetry.AIUsageEvent{
|
experience.TrackAIUsage(experience.AIUsageEvent{
|
||||||
ModelProvider: usage.Provider,
|
ModelProvider: usage.Provider,
|
||||||
ModelName: usage.Model,
|
ModelName: usage.Model,
|
||||||
Channel: usage.Channel(),
|
|
||||||
InputTokens: usage.PromptTokens,
|
InputTokens: usage.PromptTokens,
|
||||||
OutputTokens: usage.CompletionTokens,
|
OutputTokens: usage.CompletionTokens,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
return nil
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Get returns the global configuration
|
// Get returns the global configuration
|
||||||
|
|||||||
@@ -282,16 +282,12 @@ func isEncryptedStorageValue(value string) bool {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func (cs *CryptoService) DecryptPayload(payload *EncryptedPayload) ([]byte, error) {
|
func (cs *CryptoService) DecryptPayload(payload *EncryptedPayload) ([]byte, error) {
|
||||||
// 1. Validate timestamp (prevent replay attacks).
|
// 1. Validate timestamp (prevent replay attacks)
|
||||||
// The timestamp is mandatory: a missing/zero ts previously skipped this check
|
if payload.TS != 0 {
|
||||||
// entirely, which let a captured ciphertext be replayed indefinitely. The
|
elapsed := time.Since(time.Unix(payload.TS, 0))
|
||||||
// client (web/src/lib/crypto.ts) always stamps ts, so requiring it is safe.
|
if elapsed > 5*time.Minute || elapsed < -1*time.Minute {
|
||||||
if payload.TS == 0 {
|
return nil, errors.New("timestamp invalid or expired")
|
||||||
return nil, errors.New("missing timestamp")
|
}
|
||||||
}
|
|
||||||
elapsed := time.Since(time.Unix(payload.TS, 0))
|
|
||||||
if elapsed > 5*time.Minute || elapsed < -1*time.Minute {
|
|
||||||
return nil, errors.New("timestamp invalid or expired")
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// 2. Decode base64url
|
// 2. Decode base64url
|
||||||
@@ -459,11 +455,8 @@ func (es EncryptedString) Value() (driver.Value, error) {
|
|||||||
if globalCryptoService != nil {
|
if globalCryptoService != nil {
|
||||||
encrypted, err := globalCryptoService.EncryptForStorage(string(es))
|
encrypted, err := globalCryptoService.EncryptForStorage(string(es))
|
||||||
if err != nil {
|
if err != nil {
|
||||||
// Fail closed: never silently persist a plaintext secret when
|
// If encryption fails, return the original value
|
||||||
// encryption was expected to happen. Returning the error aborts the
|
return string(es), nil
|
||||||
// write so a misconfigured/broken crypto service cannot leak
|
|
||||||
// credentials into the database in cleartext.
|
|
||||||
return nil, fmt.Errorf("failed to encrypt sensitive field for storage: %w", err)
|
|
||||||
}
|
}
|
||||||
return encrypted, nil
|
return encrypted, nil
|
||||||
}
|
}
|
||||||
|
|||||||
1416
debate/engine.go
Normal file
1416
debate/engine.go
Normal file
File diff suppressed because it is too large
Load Diff
@@ -6,18 +6,11 @@ services:
|
|||||||
dockerfile: ./docker/Dockerfile.backend
|
dockerfile: ./docker/Dockerfile.backend
|
||||||
container_name: nofx-trading
|
container_name: nofx-trading
|
||||||
restart: unless-stopped
|
restart: unless-stopped
|
||||||
stop_grace_period: 30s # Allow the app 30 seconds for graceful shutdown
|
stop_grace_period: 30s # 允许应用有 30 秒时间优雅关闭
|
||||||
ports:
|
ports:
|
||||||
- "${NOFX_BACKEND_PORT:-8080}:8080"
|
- "${NOFX_BACKEND_PORT:-8080}:8080"
|
||||||
# pprof profiling is bound to host loopback only; uncomment for local debug.
|
- "6060:6060" # pprof profiling
|
||||||
# - "127.0.0.1:6060:6060"
|
|
||||||
volumes:
|
volumes:
|
||||||
# NOTE: .env is bind-mounted so the beginner-onboarding flow
|
|
||||||
# (persistBeginnerWalletEnv) can write CLAW402_WALLET_* back to the host
|
|
||||||
# file. Without this mount the wallet is regenerated on every container
|
|
||||||
# restart. For threat models where the .env file should not be reachable
|
|
||||||
# via container RCE, deploy via env vars only and remove this mount.
|
|
||||||
- ./.env:/app/.env
|
|
||||||
- ./data:/app/data
|
- ./data:/app/data
|
||||||
- /etc/localtime:/etc/localtime:ro
|
- /etc/localtime:/etc/localtime:ro
|
||||||
env_file:
|
env_file:
|
||||||
@@ -56,4 +49,4 @@ services:
|
|||||||
|
|
||||||
networks:
|
networks:
|
||||||
nofx-network:
|
nofx-network:
|
||||||
driver: bridge
|
driver: bridge
|
||||||
@@ -1,203 +0,0 @@
|
|||||||
# NOFXi 诊断与配置 Skills(第一批)
|
|
||||||
|
|
||||||
这份文档用于沉淀交易智能助手的第一批高频诊断与配置 skill。
|
|
||||||
|
|
||||||
目标不是让模型“更会想”,而是让它面对常见问题时,优先走稳定、可复用的排查路径。
|
|
||||||
|
|
||||||
## 设计原则
|
|
||||||
|
|
||||||
- 优先按 skill 回答,不要对高频问题重复自由规划
|
|
||||||
- 先归类问题,再给出原因、检查项和修复建议
|
|
||||||
- 能通过工具验证当前状态时,先查再下结论
|
|
||||||
- 敏感信息只指导填写,不完整回显
|
|
||||||
- 对结论不确定时,要明确标注为“更可能”或“优先怀疑”
|
|
||||||
|
|
||||||
## skill_model_api_setup
|
|
||||||
|
|
||||||
### 适用场景
|
|
||||||
|
|
||||||
- 用户问某个大模型的 API key 去哪里申请
|
|
||||||
- 用户问 base URL 怎么填
|
|
||||||
- 用户问 model name 怎么填
|
|
||||||
- 用户问 OpenAI / Claude / Gemini / DeepSeek / Qwen / Kimi / Grok / MiniMax 怎么接入
|
|
||||||
|
|
||||||
### 处理策略
|
|
||||||
|
|
||||||
1. 先确认用户要配置哪个 provider
|
|
||||||
2. 告诉用户需要准备的最少字段:
|
|
||||||
- provider
|
|
||||||
- API key
|
|
||||||
- custom_api_url
|
|
||||||
- custom_model_name
|
|
||||||
3. 如果系统已有默认地址和默认模型名,优先给推荐值
|
|
||||||
4. 回答按步骤组织,不要泛泛解释概念
|
|
||||||
|
|
||||||
### 已知实现事实
|
|
||||||
|
|
||||||
- 系统内置 provider 默认运行配置,见 `agent.resolveModelRuntimeConfig(...)`
|
|
||||||
- 常见 provider 已有默认 URL 和默认 model name
|
|
||||||
|
|
||||||
## skill_model_config_diagnosis
|
|
||||||
|
|
||||||
### 适用场景
|
|
||||||
|
|
||||||
- 模型保存成功但 agent 仍然不可用
|
|
||||||
- 提示 AI unavailable
|
|
||||||
- 提示模型没启用
|
|
||||||
- 提示 custom_api_url 不合法
|
|
||||||
- 配置后 trader 不生效
|
|
||||||
|
|
||||||
### 优先排查
|
|
||||||
|
|
||||||
1. 是否存在已启用模型
|
|
||||||
2. API key 是否为空
|
|
||||||
3. custom_api_url 是否为合法 HTTPS 地址
|
|
||||||
4. custom_model_name 是否为空或不匹配
|
|
||||||
5. 当前 trader 是否绑定了这个模型
|
|
||||||
6. 更新模型后是否已触发 trader reload
|
|
||||||
|
|
||||||
### 已知实现事实
|
|
||||||
|
|
||||||
- 非 HTTPS 的 `custom_api_url` 会被后端拒绝,见 `api/handler_ai_model.go`
|
|
||||||
- 已启用模型如果缺少 API Key 或 URL,会导致 agent 无法就绪,见 `agent.ensureAIClientForStoreUser(...)`
|
|
||||||
- 更新模型配置后,系统会尝试移除并重载相关 trader,使新配置立即生效
|
|
||||||
|
|
||||||
### 输出格式
|
|
||||||
|
|
||||||
- 现象
|
|
||||||
- 更可能原因
|
|
||||||
- 先检查什么
|
|
||||||
- 下一步怎么修复
|
|
||||||
|
|
||||||
## skill_exchange_api_setup
|
|
||||||
|
|
||||||
### 适用场景
|
|
||||||
|
|
||||||
- 用户要新建交易所 API
|
|
||||||
- 用户不知道交易所需要哪些权限
|
|
||||||
- 用户问 API key / secret / passphrase 分别填什么
|
|
||||||
|
|
||||||
### 通用处理策略
|
|
||||||
|
|
||||||
1. 先确认交易所类型
|
|
||||||
2. 告知必须权限与禁止权限
|
|
||||||
3. 告知是否需要额外字段
|
|
||||||
4. 强调 IP 白名单与权限配置
|
|
||||||
5. 引导用户回到系统内完成绑定
|
|
||||||
|
|
||||||
### 特殊规则
|
|
||||||
|
|
||||||
- OKX 除 API Key 和 Secret 外,还需要 passphrase
|
|
||||||
- Bybit 永续/合约交易需要合约权限
|
|
||||||
- 不建议开启提现权限
|
|
||||||
|
|
||||||
### 参考文档
|
|
||||||
|
|
||||||
- `docs/getting-started/okx-api.md`
|
|
||||||
- `docs/getting-started/bybit-api.md`
|
|
||||||
|
|
||||||
## skill_exchange_api_diagnosis
|
|
||||||
|
|
||||||
### 适用场景
|
|
||||||
|
|
||||||
- `invalid signature`
|
|
||||||
- `timestamp` 错误
|
|
||||||
- `IP not allowed`
|
|
||||||
- `permission denied`
|
|
||||||
- 交易所连接不上
|
|
||||||
|
|
||||||
### 优先排查
|
|
||||||
|
|
||||||
1. 系统时间是否同步
|
|
||||||
2. API Key / Secret 是否正确
|
|
||||||
3. 是否遗漏额外字段,如 OKX passphrase
|
|
||||||
4. IP 白名单是否包含当前服务器
|
|
||||||
5. 是否启用了交易或合约权限
|
|
||||||
6. 密钥是否过期或已重建
|
|
||||||
|
|
||||||
### 已知实现事实
|
|
||||||
|
|
||||||
- 时间不同步是 `invalid signature` / `timestamp` 的高频根因,见 `docs/guides/TROUBLESHOOTING.zh-CN.md`
|
|
||||||
- OKX 的 passphrase 缺失会导致签名相关问题,见 `docs/getting-started/okx-api.md`
|
|
||||||
|
|
||||||
### 输出格式
|
|
||||||
|
|
||||||
- 报错现象
|
|
||||||
- 最常见根因
|
|
||||||
- 优先检查顺序
|
|
||||||
- 修复步骤
|
|
||||||
|
|
||||||
## skill_trader_start_diagnosis
|
|
||||||
|
|
||||||
### 适用场景
|
|
||||||
|
|
||||||
- trader 启动不了
|
|
||||||
- trader 启动了但没开始交易
|
|
||||||
- 页面显示已启动但一直没有动作
|
|
||||||
- 用户怀疑 strategy / model / exchange 绑定有问题
|
|
||||||
|
|
||||||
### 优先排查
|
|
||||||
|
|
||||||
1. 是否有已启用的模型配置
|
|
||||||
2. 是否有已启用的交易所配置
|
|
||||||
3. trader 是否绑定了 exchange_id / strategy_id / ai_model_id
|
|
||||||
4. 交易所余额和权限是否满足下单条件
|
|
||||||
5. AI 最近的决策到底是 wait、hold 还是下单失败
|
|
||||||
|
|
||||||
### 回答原则
|
|
||||||
|
|
||||||
- 要区分“没启动”“启动了但 AI 选择不交易”“尝试下单但失败”这三类
|
|
||||||
- 不要把“没开仓”直接等同于“系统故障”
|
|
||||||
|
|
||||||
## skill_order_execution_diagnosis
|
|
||||||
|
|
||||||
### 适用场景
|
|
||||||
|
|
||||||
- 下单失败
|
|
||||||
- 只开空不开户 / 只开单边
|
|
||||||
- 杠杆报错
|
|
||||||
- position side mismatch
|
|
||||||
|
|
||||||
### 优先排查
|
|
||||||
|
|
||||||
1. 账户模式是否匹配,例如 Binance 是否为 Hedge Mode
|
|
||||||
2. 是否为子账户杠杆限制
|
|
||||||
3. 合约权限是否开启
|
|
||||||
4. 余额、保证金、可交易 symbol 是否满足条件
|
|
||||||
|
|
||||||
### 已知实现事实
|
|
||||||
|
|
||||||
- Binance 在 One-way Mode 下,可能出现 `position side mismatch` 或单边行为
|
|
||||||
- 某些子账户杠杆上限较低,超过限制会直接失败
|
|
||||||
- 这些问题在 `docs/guides/TROUBLESHOOTING.md` 已有明确说明
|
|
||||||
|
|
||||||
## skill_strategy_diagnosis
|
|
||||||
|
|
||||||
### 适用场景
|
|
||||||
|
|
||||||
- 用户说策略没生效
|
|
||||||
- 用户说 prompt 预览和实际不一致
|
|
||||||
- 用户说修改策略后 trader 行为没有变化
|
|
||||||
|
|
||||||
### 优先排查
|
|
||||||
|
|
||||||
1. 当前编辑的是策略模板,还是 trader 的 custom prompt
|
|
||||||
2. 策略是否真的保存成功
|
|
||||||
3. 是否需要重新读取当前配置做对比
|
|
||||||
4. 用户说的“没生效”是指未保存、未绑定,还是运行结果与预期不一致
|
|
||||||
|
|
||||||
### 回答原则
|
|
||||||
|
|
||||||
- 先明确“对象”再排查:strategy template / trader / prompt override
|
|
||||||
- 如果能读取当前保存值,就不要凭印象判断
|
|
||||||
|
|
||||||
## 后续扩展方向
|
|
||||||
|
|
||||||
下一批可以继续补:
|
|
||||||
|
|
||||||
- `skill_balance_and_position_diagnosis`
|
|
||||||
- `skill_market_data_diagnosis`
|
|
||||||
- `skill_prompt_generation_diagnosis`
|
|
||||||
- `skill_strategy_test_run_diagnosis`
|
|
||||||
- `skill_exchange_specific_setup_<exchange>`
|
|
||||||
- `skill_model_provider_setup_<provider>`
|
|
||||||
@@ -1,613 +0,0 @@
|
|||||||
# NOFXi Agent 当前设计说明
|
|
||||||
|
|
||||||
## 目的
|
|
||||||
|
|
||||||
本文描述当前 NOFXi Agent 的实际设计,而不是早期版本的理想设计。重点回答这些问题:
|
|
||||||
|
|
||||||
- 用户消息从哪里进入
|
|
||||||
- 什么请求会进入 planner
|
|
||||||
- 当前有哪些记忆层
|
|
||||||
- planner 如何生成与执行 plan
|
|
||||||
- tool 现在是怎么设计的
|
|
||||||
- 动态快照和当前引用分别解决什么问题
|
|
||||||
- 为什么某些问题会出现“看起来有历史,但模型还是会追问”
|
|
||||||
|
|
||||||
本文对应的主要实现文件:
|
|
||||||
|
|
||||||
- `agent/agent.go`
|
|
||||||
- `agent/web.go`
|
|
||||||
- `api/agent_routes.go`
|
|
||||||
- `agent/planner_runtime.go`
|
|
||||||
- `agent/execution_state.go`
|
|
||||||
- `agent/memory.go`
|
|
||||||
- `agent/history.go`
|
|
||||||
- `agent/tools.go`
|
|
||||||
|
|
||||||
## 一句话总览
|
|
||||||
|
|
||||||
当前 Agent 的运行模型可以概括为:
|
|
||||||
|
|
||||||
1. 前端把消息发到 `/api/agent/chat/stream`
|
|
||||||
2. 后端把登录用户身份放进 context
|
|
||||||
3. Agent 除 `/clear` 和 `/status` 外,其他消息全部进入 planner
|
|
||||||
4. planner 结合多层记忆、动态快照和 tool schema 生成 plan
|
|
||||||
5. 执行 plan 中的 `tool / reason / ask_user / respond`
|
|
||||||
6. 在执行过程中持续更新执行态、短期原话、长期摘要和当前对象引用
|
|
||||||
|
|
||||||
## 请求入口
|
|
||||||
|
|
||||||
### 前端入口
|
|
||||||
|
|
||||||
前端 Agent 页面在:
|
|
||||||
|
|
||||||
- `web/src/pages/AgentChatPage.tsx`
|
|
||||||
|
|
||||||
当前聊天使用:
|
|
||||||
|
|
||||||
- `POST /api/agent/chat/stream`
|
|
||||||
|
|
||||||
请求体里会传:
|
|
||||||
|
|
||||||
- `message`
|
|
||||||
- `lang`
|
|
||||||
- `user_key`
|
|
||||||
|
|
||||||
### 后端路由入口
|
|
||||||
|
|
||||||
路由注册在:
|
|
||||||
|
|
||||||
- `api/agent_routes.go`
|
|
||||||
|
|
||||||
这里会:
|
|
||||||
|
|
||||||
1. 经过 `authMiddleware`
|
|
||||||
2. 从登录态里取出 `user_id`
|
|
||||||
3. 通过 `agent.WithStoreUserID(...)` 写入 request context
|
|
||||||
|
|
||||||
### Agent Web Handler
|
|
||||||
|
|
||||||
真正的 HTTP handler 在:
|
|
||||||
|
|
||||||
- `agent/web.go`
|
|
||||||
|
|
||||||
主要入口:
|
|
||||||
|
|
||||||
- `HandleChat(...)`
|
|
||||||
- `HandleChatStream(...)`
|
|
||||||
|
|
||||||
再往下进入:
|
|
||||||
|
|
||||||
- `HandleMessageForStoreUser(...)`
|
|
||||||
- `HandleMessageStreamForStoreUser(...)`
|
|
||||||
|
|
||||||
## 最外层分流
|
|
||||||
|
|
||||||
当前外层分流已经被收口。
|
|
||||||
|
|
||||||
在 `agent/agent.go` 中,除了这两个命令之外,其他输入全部交给 planner:
|
|
||||||
|
|
||||||
- `/clear`
|
|
||||||
- `/status`
|
|
||||||
|
|
||||||
也就是说,现在这些都不再在外层直接处理:
|
|
||||||
|
|
||||||
- setup flow
|
|
||||||
- trade confirmation
|
|
||||||
- direct trade regex
|
|
||||||
- 自然语言配置流程
|
|
||||||
- 自然语言策略创建
|
|
||||||
|
|
||||||
这些都统一进入 planner。
|
|
||||||
|
|
||||||
这是当前设计里一个很重要的原则:
|
|
||||||
|
|
||||||
- 外层分流越少,行为边界越清晰
|
|
||||||
- 自然语言理解尽量统一交给 planner + tool
|
|
||||||
|
|
||||||
## 当前的 5 层记忆
|
|
||||||
|
|
||||||
当前不是 3 层,也不是 4 层,而是 5 层:
|
|
||||||
|
|
||||||
1. `chatHistory`
|
|
||||||
2. `TaskState`
|
|
||||||
3. `ExecutionState`
|
|
||||||
4. `CurrentReferences`
|
|
||||||
5. `Persistent Preferences`
|
|
||||||
|
|
||||||
### 1. chatHistory
|
|
||||||
|
|
||||||
定义位置:
|
|
||||||
|
|
||||||
- `agent/history.go`
|
|
||||||
|
|
||||||
作用:
|
|
||||||
|
|
||||||
- 保存最近几轮用户 / assistant 原始消息
|
|
||||||
- 给模型保留最近原话上下文
|
|
||||||
- 为后续摘要成 `TaskState` 提供原始素材
|
|
||||||
|
|
||||||
特点:
|
|
||||||
|
|
||||||
- 只保留短期原话
|
|
||||||
- 内存态
|
|
||||||
- `/clear` 时清空
|
|
||||||
|
|
||||||
适合存:
|
|
||||||
|
|
||||||
- 最近几轮对话原文
|
|
||||||
- 用户的最新措辞
|
|
||||||
- 刚刚的自然语言上下文
|
|
||||||
|
|
||||||
不适合存:
|
|
||||||
|
|
||||||
- 长期真相
|
|
||||||
- 当前外部系统状态
|
|
||||||
- 当前流程精确执行位置
|
|
||||||
|
|
||||||
### 2. TaskState
|
|
||||||
|
|
||||||
定义位置:
|
|
||||||
|
|
||||||
- `agent/memory.go`
|
|
||||||
|
|
||||||
作用:
|
|
||||||
|
|
||||||
- 保存跨轮次仍然有意义的高层摘要
|
|
||||||
- 注入 planner / reasoning / final response
|
|
||||||
|
|
||||||
持久化 key:
|
|
||||||
|
|
||||||
- `agent_task_state_<userID>`
|
|
||||||
|
|
||||||
字段:
|
|
||||||
|
|
||||||
- `CurrentGoal`
|
|
||||||
- `ActiveFlow`
|
|
||||||
- `OpenLoops`
|
|
||||||
- `ImportantFacts`
|
|
||||||
- `LastDecision`
|
|
||||||
- `UpdatedAt`
|
|
||||||
|
|
||||||
适合存:
|
|
||||||
|
|
||||||
- 当前高层目标
|
|
||||||
- 跨轮次仍然成立的未闭环事项
|
|
||||||
- 关键事实
|
|
||||||
- 最近一次重要决策及其原因
|
|
||||||
|
|
||||||
不适合存:
|
|
||||||
|
|
||||||
- step 级待办
|
|
||||||
- “下一步调用哪个 tool”
|
|
||||||
- 动态余额、持仓、配置存在性
|
|
||||||
- 任何可以通过 tool 重新读取的实时状态
|
|
||||||
|
|
||||||
### 3. ExecutionState
|
|
||||||
|
|
||||||
定义位置:
|
|
||||||
|
|
||||||
- `agent/execution_state.go`
|
|
||||||
|
|
||||||
作用:
|
|
||||||
|
|
||||||
- 保存当前 plan 的执行态
|
|
||||||
- 支持 `ask_user` 之后继续执行
|
|
||||||
- 保存 plan、当前步骤、执行日志、等待状态等
|
|
||||||
|
|
||||||
持久化 key:
|
|
||||||
|
|
||||||
- `agent_execution_state_<userID>`
|
|
||||||
|
|
||||||
当前关键字段:
|
|
||||||
|
|
||||||
- `SessionID`
|
|
||||||
- `Goal`
|
|
||||||
- `Status`
|
|
||||||
- `PlanID`
|
|
||||||
- `Steps`
|
|
||||||
- `CurrentStepID`
|
|
||||||
- `DynamicSnapshots`
|
|
||||||
- `ExecutionLog`
|
|
||||||
- `SummaryNotes`
|
|
||||||
- `Waiting`
|
|
||||||
- `CurrentReferences`
|
|
||||||
- `FinalAnswer`
|
|
||||||
- `LastError`
|
|
||||||
|
|
||||||
### 4. CurrentReferences
|
|
||||||
|
|
||||||
定义位置:
|
|
||||||
|
|
||||||
- `agent/execution_state.go`
|
|
||||||
|
|
||||||
作用:
|
|
||||||
|
|
||||||
- 记录当前对话里“这个 / 那个 / 刚才那个”到底指的是谁
|
|
||||||
|
|
||||||
当前支持的引用对象:
|
|
||||||
|
|
||||||
- `strategy`
|
|
||||||
- `trader`
|
|
||||||
- `model`
|
|
||||||
- `exchange`
|
|
||||||
|
|
||||||
这是为了解决一种常见问题:
|
|
||||||
|
|
||||||
- 用户明明前一轮刚说过“激进策略”
|
|
||||||
- 下一轮说“改一下这个策略”
|
|
||||||
- 如果没有结构化引用,模型虽然有聊天历史,也容易重新追问
|
|
||||||
|
|
||||||
`CurrentReferences` 不是系统状态快照,而是:
|
|
||||||
|
|
||||||
- 当前对话焦点对象
|
|
||||||
- 当前代词绑定对象
|
|
||||||
|
|
||||||
### 5. Persistent Preferences
|
|
||||||
|
|
||||||
对应工具:
|
|
||||||
|
|
||||||
- `get_preferences`
|
|
||||||
- `manage_preferences`
|
|
||||||
|
|
||||||
作用:
|
|
||||||
|
|
||||||
- 保存用户长期偏好
|
|
||||||
|
|
||||||
适合存:
|
|
||||||
|
|
||||||
- 默认中文回复
|
|
||||||
- 偏好激进风格
|
|
||||||
- 更关注 BTC / ETH
|
|
||||||
- 不喜欢高频
|
|
||||||
- 每天固定时间简报
|
|
||||||
|
|
||||||
它和 `TaskState` 的区别是:
|
|
||||||
|
|
||||||
- `TaskState` 偏向当前任务摘要
|
|
||||||
- `Persistent Preferences` 偏向长期用户画像
|
|
||||||
|
|
||||||
## DynamicSnapshots 是什么
|
|
||||||
|
|
||||||
`DynamicSnapshots` 是当前真实系统状态的快照。
|
|
||||||
|
|
||||||
它不是历史,也不是长期记忆,而是 planner 在规划前或执行中插入的“当前事实”。
|
|
||||||
|
|
||||||
当前会进入快照的典型信息包括:
|
|
||||||
|
|
||||||
- 当前模型配置列表
|
|
||||||
- 当前交易所配置列表
|
|
||||||
- 当前策略列表
|
|
||||||
- 当前 trader 列表
|
|
||||||
- 当前余额
|
|
||||||
- 当前持仓
|
|
||||||
- 最近交易历史
|
|
||||||
|
|
||||||
作用:
|
|
||||||
|
|
||||||
- 防止 planner 盲信旧结论
|
|
||||||
- 避免“之前没配置,现在其实已经配好了却还说没有”
|
|
||||||
- 避免“之前余额是 A,现在拿旧 observation 继续回答”
|
|
||||||
|
|
||||||
一句话:
|
|
||||||
|
|
||||||
- `DynamicSnapshots` = 当前世界里真实有什么
|
|
||||||
|
|
||||||
## CurrentReferences 和 DynamicSnapshots 的区别
|
|
||||||
|
|
||||||
这两个容易混淆,但职责完全不同。
|
|
||||||
|
|
||||||
`DynamicSnapshots`:
|
|
||||||
|
|
||||||
- 当前系统状态快照
|
|
||||||
- 是候选集合 / 当前事实
|
|
||||||
- 例如当前有两个策略:`激进`、`新策略`
|
|
||||||
|
|
||||||
`CurrentReferences`:
|
|
||||||
|
|
||||||
- 当前对话焦点对象
|
|
||||||
- 是“这个”到底指谁
|
|
||||||
- 例如用户现在说的“这个策略”就是 `激进`
|
|
||||||
|
|
||||||
可以这样理解:
|
|
||||||
|
|
||||||
- `DynamicSnapshots` 是地图
|
|
||||||
- `CurrentReferences` 是你手指现在指着地图上的哪个点
|
|
||||||
|
|
||||||
## Planner 的输入
|
|
||||||
|
|
||||||
planner 主逻辑在:
|
|
||||||
|
|
||||||
- `agent/planner_runtime.go`
|
|
||||||
|
|
||||||
生成计划时,当前会把这些东西一起送给模型:
|
|
||||||
|
|
||||||
- 当前用户请求
|
|
||||||
- tool schema
|
|
||||||
- `Persistent Preferences`
|
|
||||||
- `TaskState`
|
|
||||||
- `ExecutionState`
|
|
||||||
- `Resume context`
|
|
||||||
- `Structured waiting state`
|
|
||||||
- `Observation context`
|
|
||||||
|
|
||||||
其中 observation context 不是旧版单数组,而是分层后的:
|
|
||||||
|
|
||||||
- `dynamic_snapshots`
|
|
||||||
- `execution_log`
|
|
||||||
- `summary_notes`
|
|
||||||
|
|
||||||
## Plan 的结构
|
|
||||||
|
|
||||||
当前 planner 只允许这 4 类 step:
|
|
||||||
|
|
||||||
- `tool`
|
|
||||||
- `reason`
|
|
||||||
- `ask_user`
|
|
||||||
- `respond`
|
|
||||||
|
|
||||||
这意味着现在的 Agent 不是一个“自由发挥的回复器”,而是:
|
|
||||||
|
|
||||||
- 先规划
|
|
||||||
- 再执行步骤
|
|
||||||
- 必要时重规划
|
|
||||||
|
|
||||||
## 步骤执行流程
|
|
||||||
|
|
||||||
`executePlan(...)` 的核心逻辑是:
|
|
||||||
|
|
||||||
1. 找下一个 pending step
|
|
||||||
2. 标记 step 为 running
|
|
||||||
3. 执行对应类型
|
|
||||||
4. 写回 `ExecutionState`
|
|
||||||
5. 必要时触发 replanning
|
|
||||||
|
|
||||||
不同 step 类型行为如下:
|
|
||||||
|
|
||||||
### tool
|
|
||||||
|
|
||||||
- 调内部 tool
|
|
||||||
- 把结果写入 `ExecutionLog`
|
|
||||||
- 根据结果更新 `CurrentReferences`
|
|
||||||
- 必要时触发 replanner
|
|
||||||
|
|
||||||
### reason
|
|
||||||
|
|
||||||
- 发起一次短 reasoning 调用
|
|
||||||
- 生成一段简短中间推理
|
|
||||||
- 写入 `ExecutionLog`
|
|
||||||
|
|
||||||
### ask_user
|
|
||||||
|
|
||||||
- 进入 `waiting_user`
|
|
||||||
- 保存 `WaitingState`
|
|
||||||
- 把问题直接回给用户
|
|
||||||
|
|
||||||
### respond
|
|
||||||
|
|
||||||
- 生成最终回答
|
|
||||||
- 标记当前执行完成
|
|
||||||
|
|
||||||
## WaitingState 是什么
|
|
||||||
|
|
||||||
`WaitingState` 用来解决:
|
|
||||||
|
|
||||||
- 用户回复 `是`
|
|
||||||
- 用户回复 `继续`
|
|
||||||
- 用户回复 `那个就行`
|
|
||||||
|
|
||||||
这类短回复如果没有结构化等待状态,很容易丢上下文。
|
|
||||||
|
|
||||||
当前字段包括:
|
|
||||||
|
|
||||||
- `Question`
|
|
||||||
- `Intent`
|
|
||||||
- `PendingFields`
|
|
||||||
- `ConfirmationTarget`
|
|
||||||
- `CreatedAt`
|
|
||||||
|
|
||||||
它的作用是:
|
|
||||||
|
|
||||||
- 告诉 planner 上一轮到底在等什么
|
|
||||||
- 让这轮短回复更容易被理解成“对上一问的回答”
|
|
||||||
|
|
||||||
## CurrentReferences 如何更新
|
|
||||||
|
|
||||||
当前是双路径更新:
|
|
||||||
|
|
||||||
### 1. 用户消息命中对象名时更新
|
|
||||||
|
|
||||||
如果用户说:
|
|
||||||
|
|
||||||
- `修改激进策略`
|
|
||||||
- `停止 lky`
|
|
||||||
- `用 DeepSeek`
|
|
||||||
|
|
||||||
系统会去当前用户的策略 / trader / model / exchange 列表里尝试匹配名称或 ID。
|
|
||||||
|
|
||||||
匹配成功后,更新 `CurrentReferences`。
|
|
||||||
|
|
||||||
### 2. tool 成功返回对象时更新
|
|
||||||
|
|
||||||
比如:
|
|
||||||
|
|
||||||
- `manage_strategy(create/update/activate)`
|
|
||||||
- `manage_trader(create/update)`
|
|
||||||
- `manage_model_config(update)`
|
|
||||||
- `manage_exchange_config(update)`
|
|
||||||
|
|
||||||
只要 tool 返回了具体对象,系统就会把对应 ID / name 写回当前引用。
|
|
||||||
|
|
||||||
## Tool 设计
|
|
||||||
|
|
||||||
当前 tool 是“资源型 tool”设计,不是“页面动作型 tool”。
|
|
||||||
|
|
||||||
### 当前主要工具
|
|
||||||
|
|
||||||
配置资源:
|
|
||||||
|
|
||||||
- `get_exchange_configs`
|
|
||||||
- `manage_exchange_config`
|
|
||||||
- `get_model_configs`
|
|
||||||
- `manage_model_config`
|
|
||||||
|
|
||||||
策略资源:
|
|
||||||
|
|
||||||
- `get_strategies`
|
|
||||||
- `manage_strategy`
|
|
||||||
|
|
||||||
trader 资源:
|
|
||||||
|
|
||||||
- `manage_trader`
|
|
||||||
|
|
||||||
交易 / 查询资源:
|
|
||||||
|
|
||||||
- `search_stock`
|
|
||||||
- `execute_trade`
|
|
||||||
- `get_positions`
|
|
||||||
- `get_balance`
|
|
||||||
- `get_market_price`
|
|
||||||
- `get_trade_history`
|
|
||||||
|
|
||||||
### 为什么这么设计
|
|
||||||
|
|
||||||
优点:
|
|
||||||
|
|
||||||
- tool schema 稳定
|
|
||||||
- 行为边界清晰
|
|
||||||
- planner 更容易学会
|
|
||||||
- 资源增删改查统一
|
|
||||||
|
|
||||||
当前 `manage_strategy` 支持:
|
|
||||||
|
|
||||||
- `list`
|
|
||||||
- `get_default_config`
|
|
||||||
- `create`
|
|
||||||
- `update`
|
|
||||||
- `delete`
|
|
||||||
- `activate`
|
|
||||||
- `duplicate`
|
|
||||||
|
|
||||||
当前 `manage_trader` 支持:
|
|
||||||
|
|
||||||
- `list`
|
|
||||||
- `create`
|
|
||||||
- `update`
|
|
||||||
- `delete`
|
|
||||||
- `start`
|
|
||||||
- `stop`
|
|
||||||
|
|
||||||
## 为什么“创建策略”不该默认依赖交易所和模型
|
|
||||||
|
|
||||||
当前设计里,策略模板应该是独立资源:
|
|
||||||
|
|
||||||
- `strategy`
|
|
||||||
|
|
||||||
而运行态对象是:
|
|
||||||
|
|
||||||
- `trader`
|
|
||||||
|
|
||||||
更合理的边界是:
|
|
||||||
|
|
||||||
- 创建策略模板:用 `manage_strategy`
|
|
||||||
- 把策略跑起来:用 `manage_trader`
|
|
||||||
|
|
||||||
也就是说:
|
|
||||||
|
|
||||||
- 策略不默认依赖交易所和模型
|
|
||||||
- 只有当用户要求“运行 / 部署 / 创建 trader”时,才需要进一步关联 exchange / model / trader
|
|
||||||
|
|
||||||
## 当前一个完整例子
|
|
||||||
|
|
||||||
用户输入:
|
|
||||||
|
|
||||||
`帮我创建一个新的激进策略模板,名字就叫激进。创建完后,再把这个策略绑定到 trader lky。`
|
|
||||||
|
|
||||||
当前大致流程:
|
|
||||||
|
|
||||||
1. 前端请求 `/api/agent/chat/stream`
|
|
||||||
2. 后端注入 `store_user_id`
|
|
||||||
3. Agent 进入 planner
|
|
||||||
4. planner 刷新动态快照:
|
|
||||||
- 当前策略
|
|
||||||
- 当前 trader
|
|
||||||
5. 生成 plan,例如:
|
|
||||||
- `get_strategies`
|
|
||||||
- `manage_strategy(create)`
|
|
||||||
- `manage_trader(update)`
|
|
||||||
- `respond`
|
|
||||||
6. 执行 `manage_strategy(create)` 后:
|
|
||||||
- 写入 `ExecutionLog`
|
|
||||||
- 更新 `CurrentReferences.strategy`
|
|
||||||
7. 执行 `manage_trader(update)` 时:
|
|
||||||
- 直接使用刚创建策略的 ID
|
|
||||||
8. 输出最终回复
|
|
||||||
|
|
||||||
如果此后用户继续说:
|
|
||||||
|
|
||||||
`把这个策略的 prompt 改激进一点`
|
|
||||||
|
|
||||||
系统会优先从 `CurrentReferences.strategy` 理解“这个策略”。
|
|
||||||
|
|
||||||
## 为什么看起来“有历史”,模型还是会追问
|
|
||||||
|
|
||||||
因为“有聊天历史”不等于“有结构化对象绑定”。
|
|
||||||
|
|
||||||
如果没有 `CurrentReferences`:
|
|
||||||
|
|
||||||
- 模型只能依赖原话文本推断“这个策略”是谁
|
|
||||||
- 一旦中间插入多条消息,或者有多个候选策略
|
|
||||||
- 就容易重新追问
|
|
||||||
|
|
||||||
所以当前设计里,`CurrentReferences` 是补齐这一块的关键。
|
|
||||||
|
|
||||||
## 当前已知限制
|
|
||||||
|
|
||||||
### 1. 外层虽然已经大幅收口,但仍然不是纯 graph runtime
|
|
||||||
|
|
||||||
现在比之前更统一,但整体仍然是:
|
|
||||||
|
|
||||||
- Agent 主入口
|
|
||||||
- Planner
|
|
||||||
- Tool 执行
|
|
||||||
|
|
||||||
而不是完整 node-graph 引擎。
|
|
||||||
|
|
||||||
### 2. ExecutionState 仍然是按 userID 单槽位
|
|
||||||
|
|
||||||
这意味着:
|
|
||||||
|
|
||||||
- 同一用户的多个并行任务仍然可能相互影响
|
|
||||||
|
|
||||||
更彻底的方向应该是:
|
|
||||||
|
|
||||||
- 按 thread / session 多实例存储
|
|
||||||
|
|
||||||
### 3. CurrentReferences 目前还是轻量实现
|
|
||||||
|
|
||||||
当前只覆盖:
|
|
||||||
|
|
||||||
- strategy
|
|
||||||
- trader
|
|
||||||
- model
|
|
||||||
- exchange
|
|
||||||
|
|
||||||
后面如果要更强,需要考虑:
|
|
||||||
|
|
||||||
- 多候选冲突消解
|
|
||||||
- 昵称映射
|
|
||||||
- 跨更长会话的稳定实体绑定
|
|
||||||
|
|
||||||
## 当前设计的核心思想
|
|
||||||
|
|
||||||
一句话总结:
|
|
||||||
|
|
||||||
- `chatHistory` 记原话
|
|
||||||
- `Persistent Preferences` 记长期偏好
|
|
||||||
- `TaskState` 记高层摘要
|
|
||||||
- `ExecutionState` 记当前流程
|
|
||||||
- `DynamicSnapshots` 记当前事实
|
|
||||||
- `CurrentReferences` 记当前指代对象
|
|
||||||
- `planner` 决定步骤
|
|
||||||
- `tools` 执行落地动作
|
|
||||||
|
|
||||||
这就是当前 NOFXi Agent 的实际运行设计。
|
|
||||||
@@ -1,454 +0,0 @@
|
|||||||
# NOFXi Agent Memory And Planning Design
|
|
||||||
|
|
||||||
## Purpose
|
|
||||||
|
|
||||||
This document explains how the current NOFXi agent handles:
|
|
||||||
|
|
||||||
- short-term conversation memory
|
|
||||||
- durable task memory
|
|
||||||
- durable execution / planning state
|
|
||||||
- planner execution and replanning
|
|
||||||
- state reset and resume behavior
|
|
||||||
|
|
||||||
The implementation described here is primarily in:
|
|
||||||
|
|
||||||
- `agent/history.go`
|
|
||||||
- `agent/memory.go`
|
|
||||||
- `agent/execution_state.go`
|
|
||||||
- `agent/planner_runtime.go`
|
|
||||||
- `agent/agent.go`
|
|
||||||
|
|
||||||
## High-Level Model
|
|
||||||
|
|
||||||
The current agent uses three different layers of state:
|
|
||||||
|
|
||||||
1. `chatHistory`
|
|
||||||
Recent in-memory user/assistant turns for the live conversation.
|
|
||||||
|
|
||||||
2. `TaskState`
|
|
||||||
Durable summarized context that should survive beyond recent turns.
|
|
||||||
|
|
||||||
3. `ExecutionState`
|
|
||||||
Durable workflow state for the currently running or recently blocked plan.
|
|
||||||
|
|
||||||
These three layers serve different purposes and should not be treated as the same thing.
|
|
||||||
|
|
||||||
## State Layers
|
|
||||||
|
|
||||||
### 1. `chatHistory`
|
|
||||||
|
|
||||||
Defined in `agent/history.go`.
|
|
||||||
|
|
||||||
Role:
|
|
||||||
|
|
||||||
- stores recent `user` / `assistant` messages in memory
|
|
||||||
- keyed by `userID`
|
|
||||||
- used as short-term conversational context
|
|
||||||
- acts as the source material for later compression into `TaskState`
|
|
||||||
|
|
||||||
Characteristics:
|
|
||||||
|
|
||||||
- in-memory only
|
|
||||||
- capped by `maxTurns`
|
|
||||||
- cleared by `/clear`
|
|
||||||
- not suitable as durable truth
|
|
||||||
|
|
||||||
Typical contents:
|
|
||||||
|
|
||||||
- the last few user questions
|
|
||||||
- the last few assistant replies
|
|
||||||
- temporary conversational wording
|
|
||||||
|
|
||||||
### 2. `TaskState`
|
|
||||||
|
|
||||||
Defined in `agent/memory.go`.
|
|
||||||
|
|
||||||
Role:
|
|
||||||
|
|
||||||
- stores durable, structured, non-derivable context
|
|
||||||
- persisted through `system_config`
|
|
||||||
- injected into planning and reasoning prompts
|
|
||||||
|
|
||||||
Storage key:
|
|
||||||
|
|
||||||
- `agent_task_state_<userID>`
|
|
||||||
|
|
||||||
Fields:
|
|
||||||
|
|
||||||
- `CurrentGoal`
|
|
||||||
- `ActiveFlow`
|
|
||||||
- `OpenLoops`
|
|
||||||
- `ImportantFacts`
|
|
||||||
- `LastDecision`
|
|
||||||
- `UpdatedAt`
|
|
||||||
|
|
||||||
Intended contents:
|
|
||||||
|
|
||||||
- user goal that still matters across turns
|
|
||||||
- high-level unresolved issues that still matter across turns
|
|
||||||
- facts that tools cannot cheaply re-fetch
|
|
||||||
- latest important decision summary
|
|
||||||
|
|
||||||
Explicitly not intended for:
|
|
||||||
|
|
||||||
- step-level pending items such as "wait for API key"
|
|
||||||
- execution actions such as "call get_exchange_configs"
|
|
||||||
- live balances
|
|
||||||
- current positions
|
|
||||||
- current market prices
|
|
||||||
- mutable configuration availability
|
|
||||||
|
|
||||||
Those should be checked from tools at planning time instead of being trusted from old summaries.
|
|
||||||
|
|
||||||
### 3. `ExecutionState`
|
|
||||||
|
|
||||||
Defined in `agent/execution_state.go`.
|
|
||||||
|
|
||||||
Role:
|
|
||||||
|
|
||||||
- stores the current execution workflow
|
|
||||||
- allows the agent to resume after `ask_user`
|
|
||||||
- persists plan steps, observations, and completion status
|
|
||||||
|
|
||||||
Storage key:
|
|
||||||
|
|
||||||
- `agent_execution_state_<userID>`
|
|
||||||
|
|
||||||
Fields:
|
|
||||||
|
|
||||||
- `SessionID`
|
|
||||||
- `UserID`
|
|
||||||
- `Goal`
|
|
||||||
- `Status`
|
|
||||||
- `PlanID`
|
|
||||||
- `Steps`
|
|
||||||
- `CurrentStepID`
|
|
||||||
- `Observations`
|
|
||||||
- `FinalAnswer`
|
|
||||||
- `LastError`
|
|
||||||
- `UpdatedAt`
|
|
||||||
|
|
||||||
This is the planner's working state, not a general memory store.
|
|
||||||
|
|
||||||
## Data Flow
|
|
||||||
|
|
||||||
### Request Entry
|
|
||||||
|
|
||||||
Entry points:
|
|
||||||
|
|
||||||
- `HandleMessage(...)`
|
|
||||||
- `HandleMessageStream(...)`
|
|
||||||
|
|
||||||
Flow:
|
|
||||||
|
|
||||||
1. user message enters `agent`
|
|
||||||
2. slash commands and explicit direct branches are handled first
|
|
||||||
3. all other requests go into planner flow via `thinkAndAct(...)` / `thinkAndActStream(...)`
|
|
||||||
|
|
||||||
### Planner Flow
|
|
||||||
|
|
||||||
The planner pipeline in `agent/planner_runtime.go` is:
|
|
||||||
|
|
||||||
1. append user message into `chatHistory`
|
|
||||||
2. emit `planning` SSE event
|
|
||||||
3. load `ExecutionState`
|
|
||||||
4. optionally reset stale `ExecutionState`
|
|
||||||
5. optionally refresh dynamic configuration snapshots
|
|
||||||
6. create a fresh execution plan with the LLM
|
|
||||||
7. execute steps one by one
|
|
||||||
8. persist `ExecutionState` after important transitions
|
|
||||||
9. append assistant answer into `chatHistory`
|
|
||||||
10. maybe compress old conversation into `TaskState`
|
|
||||||
|
|
||||||
## Short-Term vs Durable Memory
|
|
||||||
|
|
||||||
### What lives in `chatHistory`
|
|
||||||
|
|
||||||
Good fits:
|
|
||||||
|
|
||||||
- raw recent messages
|
|
||||||
- conversational wording
|
|
||||||
- latest assistant phrasing
|
|
||||||
|
|
||||||
Bad fits:
|
|
||||||
|
|
||||||
- long-lived truths
|
|
||||||
- current external system state
|
|
||||||
|
|
||||||
### What lives in `TaskState`
|
|
||||||
|
|
||||||
Good fits:
|
|
||||||
|
|
||||||
- durable goal
|
|
||||||
- high-level unfinished work that remains relevant across turns
|
|
||||||
- important facts the user stated
|
|
||||||
- previous decisions and why they were made
|
|
||||||
|
|
||||||
Bad fits:
|
|
||||||
|
|
||||||
- pending steps inside the current plan
|
|
||||||
- execution-level reminders such as "wait for a field" or "call a tool"
|
|
||||||
- old conclusions about whether tools exist
|
|
||||||
- old conclusions about whether model/exchange config is present
|
|
||||||
- live operational state that can change outside the chat
|
|
||||||
|
|
||||||
### What lives in `ExecutionState`
|
|
||||||
|
|
||||||
Good fits:
|
|
||||||
|
|
||||||
- current plan steps
|
|
||||||
- observations from tool calls
|
|
||||||
- blocked-on-user-input status
|
|
||||||
- exact current workflow state
|
|
||||||
- step-level pending work and block reasons
|
|
||||||
|
|
||||||
Bad fits:
|
|
||||||
|
|
||||||
- evergreen user profile
|
|
||||||
- long-term semantic memory
|
|
||||||
|
|
||||||
## Planning Logic
|
|
||||||
|
|
||||||
### Plan Creation
|
|
||||||
|
|
||||||
`createExecutionPlan(...)` sends the following into the planner model:
|
|
||||||
|
|
||||||
- available tool definitions
|
|
||||||
- persistent preferences
|
|
||||||
- `TaskState` context
|
|
||||||
- `ExecutionState` JSON
|
|
||||||
- current user request
|
|
||||||
|
|
||||||
The planner must return JSON only with step types:
|
|
||||||
|
|
||||||
- `tool`
|
|
||||||
- `reason`
|
|
||||||
- `ask_user`
|
|
||||||
- `respond`
|
|
||||||
|
|
||||||
### Step Execution
|
|
||||||
|
|
||||||
`executePlan(...)` executes the plan loop:
|
|
||||||
|
|
||||||
- `tool`
|
|
||||||
call tool and append observation
|
|
||||||
- `reason`
|
|
||||||
run reasoning sub-call and append observation
|
|
||||||
- `ask_user`
|
|
||||||
save `waiting_user` state and return question
|
|
||||||
- `respond`
|
|
||||||
generate final answer and mark completed
|
|
||||||
|
|
||||||
After each completed step, `replanAfterStep(...)` may:
|
|
||||||
|
|
||||||
- continue
|
|
||||||
- replace remaining steps
|
|
||||||
- ask user
|
|
||||||
- finish
|
|
||||||
|
|
||||||
## Resume Behavior
|
|
||||||
|
|
||||||
When `ExecutionState.Status == waiting_user`, the next user turn is treated as a reply to the pending question.
|
|
||||||
|
|
||||||
Current safeguards:
|
|
||||||
|
|
||||||
- latest asked question is extracted from the stored plan
|
|
||||||
- the user reply is appended as a `user_reply` observation
|
|
||||||
- planner prompt receives explicit `Resume context`
|
|
||||||
|
|
||||||
This prevents short replies like `是` from being misread as unrelated fresh intents as often as before.
|
|
||||||
|
|
||||||
## Dynamic State Refresh
|
|
||||||
|
|
||||||
Configuration and trader management requests are dynamic by nature. Their truth can change outside the current chat, for example:
|
|
||||||
|
|
||||||
- user configures exchange in the UI
|
|
||||||
- user adds model in another tab
|
|
||||||
- user creates trader elsewhere
|
|
||||||
|
|
||||||
Because of that, configuration/trader requests should not trust stale model conclusions.
|
|
||||||
|
|
||||||
Current protection in `planner_runtime.go`:
|
|
||||||
|
|
||||||
- detects config / trader intent with `isConfigOrTraderIntent(...)`
|
|
||||||
- clears `TaskState` context from the planner prompt for these requests
|
|
||||||
- refreshes `ExecutionState.Observations` with fresh snapshots from:
|
|
||||||
- `toolGetModelConfigs(...)`
|
|
||||||
- `toolGetExchangeConfigs(...)`
|
|
||||||
- `toolListTraders(...)`
|
|
||||||
|
|
||||||
This makes the planner rely more on current system state and less on older narrative memory.
|
|
||||||
|
|
||||||
## Reset Strategy
|
|
||||||
|
|
||||||
The system currently resets or weakens stale execution state when:
|
|
||||||
|
|
||||||
- user says retry-like phrases such as `再试`, `继续`, `try again`, `continue`
|
|
||||||
- request is config / trader related and old execution state is failed / completed / waiting
|
|
||||||
|
|
||||||
Reset scope:
|
|
||||||
|
|
||||||
- `ExecutionState` may be cleared
|
|
||||||
- `TaskState` is not globally deleted, but it is intentionally ignored for config/trader planning
|
|
||||||
|
|
||||||
Manual reset:
|
|
||||||
|
|
||||||
- `/clear`
|
|
||||||
|
|
||||||
This clears:
|
|
||||||
|
|
||||||
- short-term chat history
|
|
||||||
- task state
|
|
||||||
- execution state
|
|
||||||
|
|
||||||
## Compression Design
|
|
||||||
|
|
||||||
`maybeCompressHistory(...)` moves older short-term chat content into `TaskState` when:
|
|
||||||
|
|
||||||
- recent message count exceeds the configured window
|
|
||||||
- estimated token count exceeds the threshold
|
|
||||||
|
|
||||||
Compression strategy:
|
|
||||||
|
|
||||||
1. keep recent conversation in `chatHistory`
|
|
||||||
2. summarize older turns into structured `TaskState`
|
|
||||||
3. persist new `TaskState`
|
|
||||||
4. replace `chatHistory` with recent slice
|
|
||||||
|
|
||||||
Important design rule:
|
|
||||||
|
|
||||||
- `TaskState` should keep durable context only
|
|
||||||
- it should not become a stale copy of mutable operational state
|
|
||||||
|
|
||||||
## Current Architecture Diagram
|
|
||||||
|
|
||||||
```mermaid
|
|
||||||
flowchart TD
|
|
||||||
U[User Message] --> A[HandleMessage / HandleMessageStream]
|
|
||||||
A --> B{Direct command?}
|
|
||||||
B -->|Yes| C[Direct branch or slash command]
|
|
||||||
B -->|No| D[thinkAndAct / thinkAndActStream]
|
|
||||||
|
|
||||||
D --> E[Append user turn to chatHistory]
|
|
||||||
D --> F[Load ExecutionState]
|
|
||||||
F --> G{waiting_user?}
|
|
||||||
G -->|Yes| H[Attach user_reply observation]
|
|
||||||
G -->|No| I[Create fresh ExecutionState]
|
|
||||||
|
|
||||||
H --> J[Refresh dynamic snapshots if config/trader intent]
|
|
||||||
I --> J
|
|
||||||
J --> K[createExecutionPlan via LLM]
|
|
||||||
K --> L[Execution plan]
|
|
||||||
L --> M[executePlan loop]
|
|
||||||
|
|
||||||
M --> N[tool step]
|
|
||||||
M --> O[reason step]
|
|
||||||
M --> P[ask_user step]
|
|
||||||
M --> Q[respond step]
|
|
||||||
|
|
||||||
N --> R[Append Observation]
|
|
||||||
O --> R
|
|
||||||
R --> S[replanAfterStep]
|
|
||||||
S --> M
|
|
||||||
|
|
||||||
P --> T[Persist waiting_user ExecutionState]
|
|
||||||
T --> UQ[Return question to user]
|
|
||||||
|
|
||||||
Q --> V[Persist completed ExecutionState]
|
|
||||||
V --> W[Append assistant turn to chatHistory]
|
|
||||||
W --> X[maybeCompressHistory]
|
|
||||||
X --> Y[Persist TaskState]
|
|
||||||
Y --> Z[Final response]
|
|
||||||
```
|
|
||||||
|
|
||||||
## Memory Relationship Diagram
|
|
||||||
|
|
||||||
```mermaid
|
|
||||||
flowchart LR
|
|
||||||
CH[chatHistory\nin-memory\nrecent turns]
|
|
||||||
TS[TaskState\npersisted summary\nsystem_config]
|
|
||||||
ES[ExecutionState\npersisted workflow\nsystem_config]
|
|
||||||
PL[Planner Prompt]
|
|
||||||
|
|
||||||
CH -->|recent raw turns| PL
|
|
||||||
ES -->|current workflow JSON| PL
|
|
||||||
TS -->|durable structured context| PL
|
|
||||||
|
|
||||||
CH -->|old turns compressed| TS
|
|
||||||
PL -->|plan / observations / status| ES
|
|
||||||
```
|
|
||||||
|
|
||||||
## State Transition Diagram
|
|
||||||
|
|
||||||
```mermaid
|
|
||||||
stateDiagram-v2
|
|
||||||
[*] --> planning
|
|
||||||
planning --> running: plan created
|
|
||||||
running --> waiting_user: ask_user step
|
|
||||||
waiting_user --> planning: user replies
|
|
||||||
running --> completed: respond step finished
|
|
||||||
running --> failed: step error
|
|
||||||
failed --> planning: retry / continue / config-trader reset
|
|
||||||
completed --> planning: new relevant request or retry flow
|
|
||||||
```
|
|
||||||
|
|
||||||
## Known Design Tradeoffs
|
|
||||||
|
|
||||||
### Strengths
|
|
||||||
|
|
||||||
- separates short-term chat from durable task summary
|
|
||||||
- allows blocked flows to resume
|
|
||||||
- supports replanning after every meaningful step
|
|
||||||
- can recover from stale assumptions better for dynamic config/trader requests
|
|
||||||
|
|
||||||
### Weaknesses
|
|
||||||
|
|
||||||
- `TaskState` is still summary-driven, so summarization quality matters
|
|
||||||
- planner still depends on model compliance for some transitions
|
|
||||||
- `ExecutionState` is single-track per user, not multiple concurrent workflows
|
|
||||||
- config/trader intent detection is heuristic and keyword-based
|
|
||||||
|
|
||||||
## Practical Guidance
|
|
||||||
|
|
||||||
### When to trust `TaskState`
|
|
||||||
|
|
||||||
Trust it for:
|
|
||||||
|
|
||||||
- user intent continuity
|
|
||||||
- open loops
|
|
||||||
- durable facts
|
|
||||||
|
|
||||||
Do not trust it for:
|
|
||||||
|
|
||||||
- whether current exchange/model/trader config exists now
|
|
||||||
- whether a specific operational action is currently possible
|
|
||||||
|
|
||||||
### When to trust `ExecutionState`
|
|
||||||
|
|
||||||
Trust it for:
|
|
||||||
|
|
||||||
- current plan continuity
|
|
||||||
- exact blocked step
|
|
||||||
- latest observation chain
|
|
||||||
|
|
||||||
Do not trust it blindly when:
|
|
||||||
|
|
||||||
- user has changed configuration outside the chat
|
|
||||||
- the system capabilities changed after deployment
|
|
||||||
|
|
||||||
### When to fetch live state again
|
|
||||||
|
|
||||||
Always prefer fresh tool snapshots before answering about:
|
|
||||||
|
|
||||||
- existing model configs
|
|
||||||
- existing exchange configs
|
|
||||||
- existing traders
|
|
||||||
- whether trader creation can proceed
|
|
||||||
|
|
||||||
## Suggested Future Improvements
|
|
||||||
|
|
||||||
- add workflow versioning so capability changes invalidate stale `ExecutionState`
|
|
||||||
- separate `waiting_user_confirmation` from generic `waiting_user`
|
|
||||||
- introduce code-level handling for short confirmations such as `是`, `好`, `继续`
|
|
||||||
- move dynamic state refresh from heuristic to explicit planner preflight stage
|
|
||||||
- support multiple concurrent execution sessions per user if needed
|
|
||||||
@@ -1,453 +0,0 @@
|
|||||||
# NOFXi Agent 记忆与规划设计
|
|
||||||
|
|
||||||
## 目的
|
|
||||||
|
|
||||||
本文说明当前 NOFXi agent 是如何处理以下能力的:
|
|
||||||
|
|
||||||
- 短期对话记忆
|
|
||||||
- 持久化任务记忆
|
|
||||||
- 持久化执行态 / 规划态
|
|
||||||
- planner 的执行与重规划
|
|
||||||
- 状态重置与恢复
|
|
||||||
|
|
||||||
本文主要对应以下实现文件:
|
|
||||||
|
|
||||||
- `agent/history.go`
|
|
||||||
- `agent/memory.go`
|
|
||||||
- `agent/execution_state.go`
|
|
||||||
- `agent/planner_runtime.go`
|
|
||||||
- `agent/agent.go`
|
|
||||||
|
|
||||||
## 总体模型
|
|
||||||
|
|
||||||
当前 agent 使用三层不同的状态:
|
|
||||||
|
|
||||||
1. `chatHistory`
|
|
||||||
用于保存当前会话最近几轮的原始用户/助手对话,驻留内存。
|
|
||||||
|
|
||||||
2. `TaskState`
|
|
||||||
用于保存跨轮次仍然有价值的结构化摘要,持久化存储。
|
|
||||||
|
|
||||||
3. `ExecutionState`
|
|
||||||
用于保存当前规划流程的执行态,支持流程中断后的继续执行。
|
|
||||||
|
|
||||||
这三层职责不同,不能混为一谈。
|
|
||||||
|
|
||||||
## 三层状态
|
|
||||||
|
|
||||||
### 1. `chatHistory`
|
|
||||||
|
|
||||||
定义位置:`agent/history.go`
|
|
||||||
|
|
||||||
作用:
|
|
||||||
|
|
||||||
- 按 `userID` 保存最近的 `user` / `assistant` 消息
|
|
||||||
- 作为短期对话上下文
|
|
||||||
- 作为后续压缩进 `TaskState` 的原始素材
|
|
||||||
|
|
||||||
特性:
|
|
||||||
|
|
||||||
- 仅在内存中存在
|
|
||||||
- 有 `maxTurns` 上限
|
|
||||||
- `/clear` 时会清空
|
|
||||||
- 不适合作为长期真相来源
|
|
||||||
|
|
||||||
典型内容:
|
|
||||||
|
|
||||||
- 最近几轮用户问题
|
|
||||||
- 最近几轮助手回答
|
|
||||||
- 临时措辞与上下文表达
|
|
||||||
|
|
||||||
### 2. `TaskState`
|
|
||||||
|
|
||||||
定义位置:`agent/memory.go`
|
|
||||||
|
|
||||||
作用:
|
|
||||||
|
|
||||||
- 保存持久化、结构化、不可轻易从工具重新推导出的上下文
|
|
||||||
- 通过 `system_config` 持久化
|
|
||||||
- 注入到 planner / reasoning prompt 中
|
|
||||||
|
|
||||||
存储 key:
|
|
||||||
|
|
||||||
- `agent_task_state_<userID>`
|
|
||||||
|
|
||||||
字段:
|
|
||||||
|
|
||||||
- `CurrentGoal`
|
|
||||||
- `ActiveFlow`
|
|
||||||
- `OpenLoops`
|
|
||||||
- `ImportantFacts`
|
|
||||||
- `LastDecision`
|
|
||||||
- `UpdatedAt`
|
|
||||||
|
|
||||||
适合存放:
|
|
||||||
|
|
||||||
- 当前仍有效的用户目标
|
|
||||||
- 跨轮次仍然成立的高层未闭环问题
|
|
||||||
- 无法简单通过工具重新读取的重要事实
|
|
||||||
- 最近一次关键决策及原因
|
|
||||||
|
|
||||||
不适合存放:
|
|
||||||
|
|
||||||
- “等用户提供 API Key” 这类 step 级待办
|
|
||||||
- “调用 get_exchange_configs” 这类执行动作
|
|
||||||
- 实时余额
|
|
||||||
- 当前持仓
|
|
||||||
- 当前行情价格
|
|
||||||
- 是否存在某个配置这类会变化的状态
|
|
||||||
|
|
||||||
这些动态信息应该在规划阶段通过工具重新检查,而不是相信旧摘要。
|
|
||||||
|
|
||||||
### 3. `ExecutionState`
|
|
||||||
|
|
||||||
定义位置:`agent/execution_state.go`
|
|
||||||
|
|
||||||
作用:
|
|
||||||
|
|
||||||
- 保存当前执行中的工作流状态
|
|
||||||
- 支持 `ask_user` 之后恢复执行
|
|
||||||
- 持久化保存计划步骤、观察结果和最终状态
|
|
||||||
|
|
||||||
存储 key:
|
|
||||||
|
|
||||||
- `agent_execution_state_<userID>`
|
|
||||||
|
|
||||||
字段:
|
|
||||||
|
|
||||||
- `SessionID`
|
|
||||||
- `UserID`
|
|
||||||
- `Goal`
|
|
||||||
- `Status`
|
|
||||||
- `PlanID`
|
|
||||||
- `Steps`
|
|
||||||
- `CurrentStepID`
|
|
||||||
- `Observations`
|
|
||||||
- `FinalAnswer`
|
|
||||||
- `LastError`
|
|
||||||
- `UpdatedAt`
|
|
||||||
|
|
||||||
它是 planner 的“工作态”,不是通用记忆仓库。
|
|
||||||
|
|
||||||
## 数据流
|
|
||||||
|
|
||||||
### 请求入口
|
|
||||||
|
|
||||||
入口函数:
|
|
||||||
|
|
||||||
- `HandleMessage(...)`
|
|
||||||
- `HandleMessageStream(...)`
|
|
||||||
|
|
||||||
流程:
|
|
||||||
|
|
||||||
1. 用户消息进入 `agent`
|
|
||||||
2. 优先处理 slash command 和显式直达分支
|
|
||||||
3. 其余请求进入 planner 流程:`thinkAndAct(...)` / `thinkAndActStream(...)`
|
|
||||||
|
|
||||||
### Planner 主流程
|
|
||||||
|
|
||||||
`agent/planner_runtime.go` 中的 planner 管线如下:
|
|
||||||
|
|
||||||
1. 把用户消息加入 `chatHistory`
|
|
||||||
2. 发出 `planning` SSE 事件
|
|
||||||
3. 加载 `ExecutionState`
|
|
||||||
4. 视情况重置过期的 `ExecutionState`
|
|
||||||
5. 视情况刷新动态配置快照
|
|
||||||
6. 调用 LLM 生成新的执行计划
|
|
||||||
7. 按步骤执行计划
|
|
||||||
8. 在关键状态变化后持久化 `ExecutionState`
|
|
||||||
9. 把助手回答加入 `chatHistory`
|
|
||||||
10. 视情况把旧对话压缩进 `TaskState`
|
|
||||||
|
|
||||||
## 短期记忆 vs 持久记忆
|
|
||||||
|
|
||||||
### `chatHistory` 里应该放什么
|
|
||||||
|
|
||||||
适合:
|
|
||||||
|
|
||||||
- 最近原始消息
|
|
||||||
- 对话措辞
|
|
||||||
- 最近一轮助手的表达方式
|
|
||||||
|
|
||||||
不适合:
|
|
||||||
|
|
||||||
- 长期真相
|
|
||||||
- 外部系统当前状态
|
|
||||||
|
|
||||||
### `TaskState` 里应该放什么
|
|
||||||
|
|
||||||
适合:
|
|
||||||
|
|
||||||
- 持续目标
|
|
||||||
- 跨轮次仍有意义的高层未闭环事项
|
|
||||||
- 用户明确讲过的重要事实
|
|
||||||
- 历史关键决策和原因
|
|
||||||
|
|
||||||
不适合:
|
|
||||||
|
|
||||||
- 当前 plan 中尚未执行的步骤
|
|
||||||
- “等待某个字段”“调用某个 tool” 这类执行级待办
|
|
||||||
- “系统有没有这个工具” 这种过时结论
|
|
||||||
- “当前有没有模型/交易所配置” 这种可变化状态
|
|
||||||
- 可以通过工具重新查询到的动态状态
|
|
||||||
|
|
||||||
### `ExecutionState` 里应该放什么
|
|
||||||
|
|
||||||
适合:
|
|
||||||
|
|
||||||
- 当前计划步骤
|
|
||||||
- 工具调用观察结果
|
|
||||||
- 当前是否卡在等用户补充信息
|
|
||||||
- 当前工作流的精确执行位置
|
|
||||||
- step 级待办和阻塞原因
|
|
||||||
|
|
||||||
不适合:
|
|
||||||
|
|
||||||
- 长期用户画像
|
|
||||||
- 通用长期语义记忆
|
|
||||||
|
|
||||||
## 规划逻辑
|
|
||||||
|
|
||||||
### 计划生成
|
|
||||||
|
|
||||||
`createExecutionPlan(...)` 会把以下信息送给 planner 模型:
|
|
||||||
|
|
||||||
- 当前可用 tool 定义
|
|
||||||
- 持久化用户偏好
|
|
||||||
- `TaskState` 上下文
|
|
||||||
- `ExecutionState` JSON
|
|
||||||
- 当前用户请求
|
|
||||||
|
|
||||||
planner 必须返回 JSON,且步骤类型只能是:
|
|
||||||
|
|
||||||
- `tool`
|
|
||||||
- `reason`
|
|
||||||
- `ask_user`
|
|
||||||
- `respond`
|
|
||||||
|
|
||||||
### 步骤执行
|
|
||||||
|
|
||||||
`executePlan(...)` 的执行循环如下:
|
|
||||||
|
|
||||||
- `tool`
|
|
||||||
调用工具并写入 observation
|
|
||||||
- `reason`
|
|
||||||
发起 reasoning 子调用并写入 observation
|
|
||||||
- `ask_user`
|
|
||||||
保存 `waiting_user` 状态并把问题返回给用户
|
|
||||||
- `respond`
|
|
||||||
生成最终回答并标记完成
|
|
||||||
|
|
||||||
每个步骤结束后,`replanAfterStep(...)` 还可以决定:
|
|
||||||
|
|
||||||
- continue
|
|
||||||
- replace_remaining
|
|
||||||
- ask_user
|
|
||||||
- finish
|
|
||||||
|
|
||||||
## 恢复执行
|
|
||||||
|
|
||||||
当 `ExecutionState.Status == waiting_user` 时,下一条用户消息会被视为对上一轮追问的回复。
|
|
||||||
|
|
||||||
当前保护机制:
|
|
||||||
|
|
||||||
- 从已有 plan 中提取最近一次追问内容
|
|
||||||
- 将用户回复作为 `user_reply` observation 追加
|
|
||||||
- 在 planner prompt 中注入显式的 `Resume context`
|
|
||||||
|
|
||||||
这样可以减少用户只回复 `是` 这类短消息时,被错误理解成全新意图的情况。
|
|
||||||
|
|
||||||
## 动态状态刷新
|
|
||||||
|
|
||||||
配置类与 trader 管理类请求本质上是动态请求,它们的真相可能在聊天之外发生变化,例如:
|
|
||||||
|
|
||||||
- 用户在 Web UI 中配置了交易所
|
|
||||||
- 用户在另一个页面新增了模型
|
|
||||||
- 用户在别处创建了 trader
|
|
||||||
|
|
||||||
因此,这类请求不能依赖旧的模型结论。
|
|
||||||
|
|
||||||
当前在 `planner_runtime.go` 中的保护措施:
|
|
||||||
|
|
||||||
- 通过 `isConfigOrTraderIntent(...)` 检测配置 / trader 意图
|
|
||||||
- 这类请求在 planner prompt 中不再注入旧 `TaskState`
|
|
||||||
- 同时刷新 `ExecutionState.Observations` 中的实时快照:
|
|
||||||
- `toolGetModelConfigs(...)`
|
|
||||||
- `toolGetExchangeConfigs(...)`
|
|
||||||
- `toolListTraders(...)`
|
|
||||||
|
|
||||||
这样 planner 会更多依赖当前系统状态,而不是依赖旧记忆中的描述。
|
|
||||||
|
|
||||||
## 重置策略
|
|
||||||
|
|
||||||
当前系统在以下场景会重置或弱化旧执行态:
|
|
||||||
|
|
||||||
- 用户说了类似 `再试`、`继续`、`try again`、`continue`
|
|
||||||
- 当前请求是配置 / trader 相关,并且旧 `ExecutionState` 已经失败 / 完成 / 正在等待用户
|
|
||||||
|
|
||||||
重置范围:
|
|
||||||
|
|
||||||
- `ExecutionState` 可能会被清空
|
|
||||||
- `TaskState` 不会整体删除,但在配置 / trader 请求中会被主动忽略
|
|
||||||
|
|
||||||
手动清理:
|
|
||||||
|
|
||||||
- `/clear`
|
|
||||||
|
|
||||||
这条命令会清掉:
|
|
||||||
|
|
||||||
- 短期 chat history
|
|
||||||
- task state
|
|
||||||
- execution state
|
|
||||||
|
|
||||||
## 压缩设计
|
|
||||||
|
|
||||||
`maybeCompressHistory(...)` 会在以下条件满足时把旧的短期对话压缩进 `TaskState`:
|
|
||||||
|
|
||||||
- 最近消息数超过窗口
|
|
||||||
- 估算 token 数超过阈值
|
|
||||||
|
|
||||||
压缩流程:
|
|
||||||
|
|
||||||
1. 保留最近若干轮对话在 `chatHistory`
|
|
||||||
2. 把更早的内容总结成结构化 `TaskState`
|
|
||||||
3. 持久化新的 `TaskState`
|
|
||||||
4. 用最近消息切片替换 `chatHistory`
|
|
||||||
|
|
||||||
重要设计原则:
|
|
||||||
|
|
||||||
- `TaskState` 只保留长期有效上下文
|
|
||||||
- 不能把它变成动态运营状态的陈旧副本
|
|
||||||
|
|
||||||
## 当前架构图
|
|
||||||
|
|
||||||
```mermaid
|
|
||||||
flowchart TD
|
|
||||||
U[用户消息] --> A[HandleMessage / HandleMessageStream]
|
|
||||||
A --> B{是否命中直达分支?}
|
|
||||||
B -->|是| C[直接处理 slash command 或快捷分支]
|
|
||||||
B -->|否| D[thinkAndAct / thinkAndActStream]
|
|
||||||
|
|
||||||
D --> E[写入 chatHistory]
|
|
||||||
D --> F[加载 ExecutionState]
|
|
||||||
F --> G{是否 waiting_user?}
|
|
||||||
G -->|是| H[追加 user_reply observation]
|
|
||||||
G -->|否| I[创建新的 ExecutionState]
|
|
||||||
|
|
||||||
H --> J[若为配置或 trader 请求则刷新动态快照]
|
|
||||||
I --> J
|
|
||||||
J --> K[createExecutionPlan 调用 LLM]
|
|
||||||
K --> L[得到 execution plan]
|
|
||||||
L --> M[executePlan 循环执行]
|
|
||||||
|
|
||||||
M --> N[tool step]
|
|
||||||
M --> O[reason step]
|
|
||||||
M --> P[ask_user step]
|
|
||||||
M --> Q[respond step]
|
|
||||||
|
|
||||||
N --> R[写入 Observation]
|
|
||||||
O --> R
|
|
||||||
R --> S[replanAfterStep]
|
|
||||||
S --> M
|
|
||||||
|
|
||||||
P --> T[持久化 waiting_user ExecutionState]
|
|
||||||
T --> UQ[向用户返回追问]
|
|
||||||
|
|
||||||
Q --> V[持久化 completed ExecutionState]
|
|
||||||
V --> W[把 assistant 回复写入 chatHistory]
|
|
||||||
W --> X[maybeCompressHistory]
|
|
||||||
X --> Y[持久化 TaskState]
|
|
||||||
Y --> Z[返回最终回答]
|
|
||||||
```
|
|
||||||
|
|
||||||
## 记忆关系图
|
|
||||||
|
|
||||||
```mermaid
|
|
||||||
flowchart LR
|
|
||||||
CH[chatHistory\n内存态\n最近对话]
|
|
||||||
TS[TaskState\n持久化摘要\nsystem_config]
|
|
||||||
ES[ExecutionState\n持久化执行态\nsystem_config]
|
|
||||||
PL[Planner Prompt]
|
|
||||||
|
|
||||||
CH -->|最近原始对话| PL
|
|
||||||
ES -->|当前工作流 JSON| PL
|
|
||||||
TS -->|长期结构化上下文| PL
|
|
||||||
|
|
||||||
CH -->|旧消息压缩| TS
|
|
||||||
PL -->|计划 / 观察 / 状态| ES
|
|
||||||
```
|
|
||||||
|
|
||||||
## 状态转换图
|
|
||||||
|
|
||||||
```mermaid
|
|
||||||
stateDiagram-v2
|
|
||||||
[*] --> planning
|
|
||||||
planning --> running: plan created
|
|
||||||
running --> waiting_user: ask_user step
|
|
||||||
waiting_user --> planning: user replies
|
|
||||||
running --> completed: respond step finished
|
|
||||||
running --> failed: step error
|
|
||||||
failed --> planning: retry / continue / config-trader reset
|
|
||||||
completed --> planning: new relevant request or retry flow
|
|
||||||
```
|
|
||||||
|
|
||||||
## 当前设计的取舍
|
|
||||||
|
|
||||||
### 优点
|
|
||||||
|
|
||||||
- 将短期对话与长期摘要分离
|
|
||||||
- 支持在 `ask_user` 之后恢复执行
|
|
||||||
- 每个关键步骤后都支持重规划
|
|
||||||
- 对配置 / 创建 trader 这类动态请求,已经能更好抵抗旧结论污染
|
|
||||||
|
|
||||||
### 缺点
|
|
||||||
|
|
||||||
- `TaskState` 的质量仍然依赖总结效果
|
|
||||||
- 某些恢复逻辑仍依赖模型是否听话
|
|
||||||
- 每个用户当前只有一条 `ExecutionState`,不支持多个并发工作流
|
|
||||||
- 配置 / trader 意图识别目前仍是关键词启发式
|
|
||||||
|
|
||||||
## 实践建议
|
|
||||||
|
|
||||||
### 什么时候该相信 `TaskState`
|
|
||||||
|
|
||||||
应该相信它用于:
|
|
||||||
|
|
||||||
- 延续用户目标
|
|
||||||
- 跟踪未完成事项
|
|
||||||
- 保留长期有效事实
|
|
||||||
|
|
||||||
不应该相信它用于:
|
|
||||||
|
|
||||||
- 当前是否存在模型 / 交易所 / trader 配置
|
|
||||||
- 当前是否能够执行某个操作
|
|
||||||
|
|
||||||
### 什么时候该相信 `ExecutionState`
|
|
||||||
|
|
||||||
应该相信它用于:
|
|
||||||
|
|
||||||
- 当前工作流是否仍然连续
|
|
||||||
- 当前阻塞在哪一步
|
|
||||||
- 最近的 observation 链条
|
|
||||||
|
|
||||||
不应该盲信它用于:
|
|
||||||
|
|
||||||
- 用户在聊天外已经修改过配置的场景
|
|
||||||
- 系统能力或工具集发生变化后的旧结论
|
|
||||||
|
|
||||||
### 什么时候必须重新获取实时状态
|
|
||||||
|
|
||||||
以下场景应该优先重新通过工具获取:
|
|
||||||
|
|
||||||
- 当前模型配置
|
|
||||||
- 当前交易所配置
|
|
||||||
- 当前 trader 列表
|
|
||||||
- 当前是否满足 trader 创建条件
|
|
||||||
|
|
||||||
## 后续建议
|
|
||||||
|
|
||||||
- 为 `ExecutionState` 增加版本号或能力签名,能力变化时自动失效
|
|
||||||
- 将 `waiting_user_confirmation` 与通用 `waiting_user` 分开
|
|
||||||
- 对 `是`、`好`、`继续` 这类短确认增加代码级识别
|
|
||||||
- 将动态快照刷新从启发式升级为显式 planner 预检查阶段
|
|
||||||
- 如果后续需要,支持一个用户多条并发执行会话
|
|
||||||
624
docs/architecture/BACKTEST_MODULE.md
Normal file
624
docs/architecture/BACKTEST_MODULE.md
Normal file
@@ -0,0 +1,624 @@
|
|||||||
|
# NOFX Backtest Module - Technical Documentation
|
||||||
|
|
||||||
|
**Language:** [English](BACKTEST_MODULE.md) | [中文](BACKTEST_MODULE.zh-CN.md)
|
||||||
|
|
||||||
|
## Overview
|
||||||
|
|
||||||
|
This document describes the complete technical implementation of the NOFX backtest module, including configuration, historical data loading, simulation engine, AI decision making, performance metrics calculation, and result storage.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Complete Backtest Flow
|
||||||
|
|
||||||
|
```
|
||||||
|
┌─────────────────────────────────────────────────────────────────┐
|
||||||
|
│ Backtest Execution Flow │
|
||||||
|
└─────────────────────────────────────────────────────────────────┘
|
||||||
|
|
||||||
|
1. API Request: /backtest/start
|
||||||
|
↓
|
||||||
|
2. Manager.Start()
|
||||||
|
├─ Validate config
|
||||||
|
├─ Parse AI model
|
||||||
|
├─ Create Runner instance
|
||||||
|
└─ Start runner.Start() (goroutine)
|
||||||
|
↓
|
||||||
|
3. Runner.Start() → Runner.loop()
|
||||||
|
└─ Iterate each decision time point:
|
||||||
|
├─ DataFeed.BuildMarketData() [Build market data]
|
||||||
|
├─ Check decision trigger [Every N bars]
|
||||||
|
├─ buildDecisionContext() [Build decision context]
|
||||||
|
├─ invokeAIWithRetry() [Call AI + cache]
|
||||||
|
├─ executeDecision() [Execute trades]
|
||||||
|
├─ checkLiquidation() [Check liquidation]
|
||||||
|
├─ updateState() [Update state]
|
||||||
|
├─ appendEquityPoint() [Record equity]
|
||||||
|
├─ appendTradeEvent() [Record trades]
|
||||||
|
├─ maybeCheckpoint() [Save checkpoint]
|
||||||
|
└─ persistMetrics() [Persist metrics]
|
||||||
|
↓
|
||||||
|
4. Complete/Failed
|
||||||
|
├─ Calculate final metrics
|
||||||
|
├─ Persist all results
|
||||||
|
└─ Release lock
|
||||||
|
↓
|
||||||
|
5. API Query: /backtest/metrics, /backtest/equity, /backtest/trades
|
||||||
|
└─ Load and return results
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 1. Configuration
|
||||||
|
|
||||||
|
**Core File:** `backtest/config.go`
|
||||||
|
|
||||||
|
### 1.1 Config Parameters
|
||||||
|
|
||||||
|
| Parameter | Type | Default | Description |
|
||||||
|
|-----------|------|---------|-------------|
|
||||||
|
| `RunID` | string | (required) | Unique backtest run ID |
|
||||||
|
| `UserID` | string | "default" | User ID |
|
||||||
|
| `Symbols` | []string | (required) | Trading symbols list |
|
||||||
|
| `Timeframes` | []string | ["3m", "15m", "4h"] | K-line timeframes |
|
||||||
|
| `DecisionTimeframe` | string | Symbols[0] | Primary decision timeframe |
|
||||||
|
| `DecisionCadenceNBars` | int | 20 | Trigger decision every N bars |
|
||||||
|
| `StartTS`, `EndTS` | int64 | (required) | Backtest time range (Unix timestamp) |
|
||||||
|
| `InitialBalance` | float64 | 1000 | Initial balance (USD) |
|
||||||
|
| `FeeBps` | float64 | 5 | Trading fee (basis points) |
|
||||||
|
| `SlippageBps` | float64 | 2 | Slippage (basis points) |
|
||||||
|
| `FillPolicy` | string | "next_open" | Fill policy |
|
||||||
|
| `PromptVariant` | string | "baseline" | AI prompt variant |
|
||||||
|
| `CacheAI` | bool | false | Cache AI decisions |
|
||||||
|
| `Leverage` | LeverageConfig | BTC/ETH:5, Altcoin:5 | Leverage settings |
|
||||||
|
|
||||||
|
### 1.2 Fill Policy
|
||||||
|
|
||||||
|
```go
|
||||||
|
// backtest/config.go:163-179
|
||||||
|
switch fillPolicy {
|
||||||
|
case "next_open": // Next bar open price
|
||||||
|
case "bar_vwap": // Current bar VWAP
|
||||||
|
case "mid": // Current bar (High+Low)/2
|
||||||
|
default: // Mark Price
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### 1.3 Config Example
|
||||||
|
|
||||||
|
```go
|
||||||
|
cfg := backtest.BacktestConfig{
|
||||||
|
RunID: "bt_20231215_150405",
|
||||||
|
Symbols: []string{"BTCUSDT", "ETHUSDT"},
|
||||||
|
Timeframes: []string{"3m", "15m", "4h"},
|
||||||
|
DecisionTimeframe: "3m",
|
||||||
|
DecisionCadenceNBars: 20,
|
||||||
|
StartTS: 1702566000,
|
||||||
|
EndTS: 1702652400,
|
||||||
|
InitialBalance: 10000,
|
||||||
|
FeeBps: 5,
|
||||||
|
SlippageBps: 2,
|
||||||
|
FillPolicy: "next_open",
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 2. Data Loading
|
||||||
|
|
||||||
|
**Core File:** `backtest/datafeed.go`
|
||||||
|
|
||||||
|
### 2.1 Data Loading Flow
|
||||||
|
|
||||||
|
```
|
||||||
|
1. NewDataFeed() - Initialize
|
||||||
|
↓
|
||||||
|
2. loadAll() - Load all historical data
|
||||||
|
├─ Calculate buffer (200 bars before StartTS)
|
||||||
|
├─ Call market.GetKlinesRange() to fetch data
|
||||||
|
├─ Store in symbolSeries map
|
||||||
|
└─ Build decision timeline from primary timeframe
|
||||||
|
↓
|
||||||
|
3. BuildMarketData() - Build market data snapshot
|
||||||
|
├─ Slice K-line data to current timestamp
|
||||||
|
├─ Calculate technical indicators (EMA, MACD, RSI, ATR)
|
||||||
|
└─ Return market.Data structure
|
||||||
|
```
|
||||||
|
|
||||||
|
### 2.2 Data Structure
|
||||||
|
|
||||||
|
```go
|
||||||
|
// DataFeed core structure
|
||||||
|
type DataFeed struct {
|
||||||
|
decisionTimes []int64 // Decision time points list
|
||||||
|
symbolSeries map[string]*symbolSeries // Data stored by symbol
|
||||||
|
}
|
||||||
|
|
||||||
|
// Single symbol time series
|
||||||
|
type symbolSeries struct {
|
||||||
|
timeframes map[string]*timeframeSeries // Stored by timeframe
|
||||||
|
}
|
||||||
|
|
||||||
|
// Single timeframe data
|
||||||
|
type timeframeSeries struct {
|
||||||
|
klines []market.Kline // K-line data
|
||||||
|
closeTimes []int64 // Close time index
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### 2.3 Key Code References
|
||||||
|
|
||||||
|
- Data fetching: `backtest/datafeed.go:48-93`
|
||||||
|
- Timeline generation: `backtest/datafeed.go:96-115`
|
||||||
|
- Market data assembly: `backtest/datafeed.go:141-171`
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 3. Simulation Engine
|
||||||
|
|
||||||
|
**Core File:** `backtest/runner.go`
|
||||||
|
|
||||||
|
### 3.1 Main Loop
|
||||||
|
|
||||||
|
```go
|
||||||
|
// backtest/runner.go:232-264
|
||||||
|
func (r *Runner) loop() {
|
||||||
|
for _, ts := range r.feed.DecisionTimes() {
|
||||||
|
if r.isPaused() {
|
||||||
|
break
|
||||||
|
}
|
||||||
|
r.stepOnce(ts)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### 3.2 Single Step Execution
|
||||||
|
|
||||||
|
```go
|
||||||
|
// backtest/runner.go:266-471
|
||||||
|
func (r *Runner) stepOnce(ts int64) {
|
||||||
|
// 1. Get current bar timestamp
|
||||||
|
// 2. Build market data
|
||||||
|
// 3. Check decision trigger (every N bars)
|
||||||
|
// 4. Execute decision cycle (if triggered)
|
||||||
|
// 5. Check liquidation
|
||||||
|
// 6. Update state and record
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### 3.3 State Management
|
||||||
|
|
||||||
|
```go
|
||||||
|
// backtest/types.go:31-47
|
||||||
|
type BacktestState struct {
|
||||||
|
BarIndex int // Current bar index
|
||||||
|
Cash float64 // Available balance
|
||||||
|
Equity float64 // Total equity
|
||||||
|
UnrealizedPnL float64 // Unrealized PnL
|
||||||
|
RealizedPnL float64 // Realized PnL
|
||||||
|
MaxEquity float64 // Peak equity
|
||||||
|
MinEquity float64 // Trough equity
|
||||||
|
MaxDrawdownPct float64 // Max drawdown
|
||||||
|
Positions map[string]*position // Positions
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 4. AI Decision Making
|
||||||
|
|
||||||
|
**Core File:** `backtest/runner.go`
|
||||||
|
|
||||||
|
### 4.1 Decision Context Building
|
||||||
|
|
||||||
|
```go
|
||||||
|
// backtest/runner.go:473-532
|
||||||
|
func (r *Runner) buildDecisionContext() *decision.Context {
|
||||||
|
return &decision.Context{
|
||||||
|
CurrentTime: "2023-12-15 10:30:00 UTC",
|
||||||
|
RuntimeMinutes: elapsed,
|
||||||
|
CallCount: cycleNumber,
|
||||||
|
Account: {
|
||||||
|
TotalEquity, AvailableBalance, TotalPnL, MarginUsedPct
|
||||||
|
},
|
||||||
|
Positions: []PositionInfo{...},
|
||||||
|
CandidateCoins: []string{symbols...},
|
||||||
|
MarketDataMap: map[symbol]*market.Data{...},
|
||||||
|
MultiTFMarket: map[symbol]map[timeframe]*market.Data{...},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### 4.2 AI Invocation
|
||||||
|
|
||||||
|
```go
|
||||||
|
// backtest/runner.go:544-563
|
||||||
|
func (r *Runner) invokeAIWithRetry() (*decision.FullDecision, error) {
|
||||||
|
// Max 3 retries
|
||||||
|
// Exponential backoff: 500ms, 1000ms, 1500ms
|
||||||
|
// Uses decision.GetFullDecisionWithStrategy() for unified prompt generation
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### 4.3 AI Cache
|
||||||
|
|
||||||
|
```go
|
||||||
|
// backtest/aicache.go:127-168
|
||||||
|
// Cache key: SHA256(context payload)
|
||||||
|
// Contains: variant, timestamp, account, positions, market data
|
||||||
|
```
|
||||||
|
|
||||||
|
### 4.4 Supported AI Models
|
||||||
|
|
||||||
|
| Model | Client File |
|
||||||
|
|-------|-------------|
|
||||||
|
| DeepSeek | `mcp/deepseek_client.go` |
|
||||||
|
| Qwen | `mcp/qwen_client.go` |
|
||||||
|
| Claude | `mcp/claude_client.go` |
|
||||||
|
| Gemini | `mcp/gemini_client.go` |
|
||||||
|
| Grok | `mcp/grok_client.go` |
|
||||||
|
| OpenAI | `mcp/openai_client.go` |
|
||||||
|
| Kimi | `mcp/kimi_client.go` |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 5. Performance Metrics
|
||||||
|
|
||||||
|
**Core File:** `backtest/metrics.go`
|
||||||
|
|
||||||
|
### 5.1 Metrics Calculation
|
||||||
|
|
||||||
|
| Metric | Formula | Code Location |
|
||||||
|
|--------|---------|---------------|
|
||||||
|
| **Total Return** | (Final Equity - Initial) / Initial × 100 | metrics.go:36-42 |
|
||||||
|
| **Max Drawdown** | max((Peak - Current) / Peak × 100) | metrics.go:64-91 |
|
||||||
|
| **Sharpe Ratio** | Avg Return / Return StdDev | metrics.go:94-138 |
|
||||||
|
| **Win Rate** | Winning Trades / Total Trades × 100 | metrics.go:180-181 |
|
||||||
|
| **Profit Factor** | Total Profit / Total Loss | metrics.go:189-193 |
|
||||||
|
|
||||||
|
### 5.2 Trade Statistics
|
||||||
|
|
||||||
|
```go
|
||||||
|
// backtest/metrics.go:141-225
|
||||||
|
type TradeMetrics struct {
|
||||||
|
TotalTrades int
|
||||||
|
WinningTrades int
|
||||||
|
LosingTrades int
|
||||||
|
AvgWin float64
|
||||||
|
AvgLoss float64
|
||||||
|
BestSymbol string
|
||||||
|
WorstSymbol string
|
||||||
|
SymbolStats map[string]*SymbolStat
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 6. Equity Curve
|
||||||
|
|
||||||
|
**Core File:** `backtest/equity.go`
|
||||||
|
|
||||||
|
### 6.1 Equity Point Structure
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"ts": 1702566000000,
|
||||||
|
"equity": 10500.50,
|
||||||
|
"available": 8000.00,
|
||||||
|
"pnl": 500.50,
|
||||||
|
"pnl_pct": 5.005,
|
||||||
|
"dd_pct": 2.34,
|
||||||
|
"cycle": 42
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### 6.2 Equity Update
|
||||||
|
|
||||||
|
```go
|
||||||
|
// backtest/runner.go:829-872
|
||||||
|
func (r *Runner) updateState() {
|
||||||
|
// 1. Calculate total equity: cash + margin + unrealized PnL
|
||||||
|
// 2. Track peak (MaxEquity)
|
||||||
|
// 3. Track trough (MinEquity)
|
||||||
|
// 4. Recalculate drawdown: (MaxEquity - Equity) / MaxEquity × 100
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### 6.3 Data Resampling
|
||||||
|
|
||||||
|
```go
|
||||||
|
// backtest/equity.go:10-50
|
||||||
|
func ResampleEquity(points []EquityPoint, timeframe string) []EquityPoint {
|
||||||
|
// Bucket by timeframe
|
||||||
|
// Keep last point in each bucket
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 7. Result Storage
|
||||||
|
|
||||||
|
**Core Files:** `backtest/storage.go`, `store/backtest.go`
|
||||||
|
|
||||||
|
### 7.1 File Storage Structure
|
||||||
|
|
||||||
|
```
|
||||||
|
backtests/
|
||||||
|
├── <run_id>/
|
||||||
|
│ ├── run.json # Run metadata
|
||||||
|
│ ├── checkpoint.json # Checkpoint (for resume)
|
||||||
|
│ ├── equity.jsonl # Equity curve (line-delimited JSON)
|
||||||
|
│ ├── trades.jsonl # Trade records (line-delimited JSON)
|
||||||
|
│ ├── metrics.json # Performance metrics
|
||||||
|
│ ├── progress.json # Progress info
|
||||||
|
│ ├── ai_cache.json # AI decision cache
|
||||||
|
│ └── decision_logs/ # Decision logs
|
||||||
|
│ ├── 0.json
|
||||||
|
│ ├── 1.json
|
||||||
|
│ └── ...
|
||||||
|
```
|
||||||
|
|
||||||
|
### 7.2 Database Schema
|
||||||
|
|
||||||
|
```sql
|
||||||
|
-- Backtest run metadata
|
||||||
|
CREATE TABLE backtest_runs (
|
||||||
|
run_id TEXT PRIMARY KEY,
|
||||||
|
user_id TEXT,
|
||||||
|
config_json TEXT,
|
||||||
|
state TEXT, -- pending, running, completed, failed
|
||||||
|
processed_bars INTEGER,
|
||||||
|
progress_pct REAL,
|
||||||
|
equity_last REAL,
|
||||||
|
max_drawdown_pct REAL,
|
||||||
|
liquidated BOOLEAN,
|
||||||
|
ai_provider TEXT,
|
||||||
|
ai_model TEXT,
|
||||||
|
created_at DATETIME,
|
||||||
|
updated_at DATETIME
|
||||||
|
);
|
||||||
|
|
||||||
|
-- Equity curve
|
||||||
|
CREATE TABLE backtest_equity (
|
||||||
|
id INTEGER PRIMARY KEY,
|
||||||
|
run_id TEXT,
|
||||||
|
ts INTEGER,
|
||||||
|
equity REAL,
|
||||||
|
available REAL,
|
||||||
|
pnl REAL,
|
||||||
|
pnl_pct REAL,
|
||||||
|
dd_pct REAL,
|
||||||
|
cycle INTEGER
|
||||||
|
);
|
||||||
|
|
||||||
|
-- Trade records
|
||||||
|
CREATE TABLE backtest_trades (
|
||||||
|
id INTEGER PRIMARY KEY,
|
||||||
|
run_id TEXT,
|
||||||
|
ts INTEGER,
|
||||||
|
symbol TEXT,
|
||||||
|
action TEXT,
|
||||||
|
side TEXT,
|
||||||
|
qty REAL,
|
||||||
|
price REAL,
|
||||||
|
fee REAL,
|
||||||
|
slippage REAL,
|
||||||
|
realized_pnl REAL,
|
||||||
|
leverage INTEGER,
|
||||||
|
liquidation BOOLEAN
|
||||||
|
);
|
||||||
|
|
||||||
|
-- Performance metrics
|
||||||
|
CREATE TABLE backtest_metrics (
|
||||||
|
run_id TEXT PRIMARY KEY,
|
||||||
|
payload BLOB,
|
||||||
|
updated_at DATETIME
|
||||||
|
);
|
||||||
|
|
||||||
|
-- Checkpoints (pause/resume)
|
||||||
|
CREATE TABLE backtest_checkpoints (
|
||||||
|
run_id TEXT PRIMARY KEY,
|
||||||
|
payload BLOB,
|
||||||
|
updated_at DATETIME
|
||||||
|
);
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 8. API Endpoints
|
||||||
|
|
||||||
|
**Core File:** `api/backtest.go`
|
||||||
|
|
||||||
|
### 8.1 Endpoint List
|
||||||
|
|
||||||
|
| Endpoint | Method | Description |
|
||||||
|
|----------|--------|-------------|
|
||||||
|
| `/backtest/start` | POST | Start backtest |
|
||||||
|
| `/backtest/pause` | POST | Pause backtest |
|
||||||
|
| `/backtest/resume` | POST | Resume backtest |
|
||||||
|
| `/backtest/stop` | POST | Stop backtest |
|
||||||
|
| `/backtest/status` | GET | Get status |
|
||||||
|
| `/backtest/runs` | GET | List all backtests |
|
||||||
|
| `/backtest/equity` | GET | Get equity curve |
|
||||||
|
| `/backtest/trades` | GET | Get trade records |
|
||||||
|
| `/backtest/metrics` | GET | Get performance metrics |
|
||||||
|
| `/backtest/trace` | GET | Get decision logs |
|
||||||
|
| `/backtest/export` | GET | Export ZIP |
|
||||||
|
| `/backtest/delete` | POST | Delete backtest |
|
||||||
|
|
||||||
|
### 8.2 Request Examples
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Start backtest
|
||||||
|
POST /backtest/start
|
||||||
|
{
|
||||||
|
"config": {
|
||||||
|
"run_id": "bt_20231215",
|
||||||
|
"symbols": ["BTCUSDT", "ETHUSDT"],
|
||||||
|
"timeframes": ["3m", "15m", "4h"],
|
||||||
|
"start_ts": 1702566000,
|
||||||
|
"end_ts": 1702652400,
|
||||||
|
"initial_balance": 10000,
|
||||||
|
"ai_model_id": "model_001"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
# Get equity curve
|
||||||
|
GET /backtest/equity?run_id=bt_20231215&tf=1h&limit=1000
|
||||||
|
|
||||||
|
# Get metrics
|
||||||
|
GET /backtest/metrics?run_id=bt_20231215
|
||||||
|
```
|
||||||
|
|
||||||
|
### 8.3 Response Examples
|
||||||
|
|
||||||
|
```json
|
||||||
|
// Status response
|
||||||
|
{
|
||||||
|
"run_id": "bt_20231215",
|
||||||
|
"state": "running",
|
||||||
|
"progress_pct": 45.5,
|
||||||
|
"processed_bars": 1234,
|
||||||
|
"equity": 10234.50,
|
||||||
|
"unrealized_pnl": 234.50
|
||||||
|
}
|
||||||
|
|
||||||
|
// Metrics response
|
||||||
|
{
|
||||||
|
"total_return_pct": 12.34,
|
||||||
|
"max_drawdown_pct": 5.67,
|
||||||
|
"sharpe_ratio": 1.89,
|
||||||
|
"profit_factor": 2.34,
|
||||||
|
"win_rate": 65.5,
|
||||||
|
"trades": 123
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 9. Account & Position Management
|
||||||
|
|
||||||
|
**Core File:** `backtest/account.go`
|
||||||
|
|
||||||
|
### 9.1 Position Structure
|
||||||
|
|
||||||
|
```go
|
||||||
|
type position struct {
|
||||||
|
Symbol string
|
||||||
|
Side string // "long" or "short"
|
||||||
|
Quantity float64
|
||||||
|
EntryPrice float64
|
||||||
|
Leverage int
|
||||||
|
Margin float64 // Margin
|
||||||
|
Notional float64 // Notional value
|
||||||
|
LiquidationPrice float64 // Liquidation price
|
||||||
|
OpenTime int64
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### 9.2 Open Position Logic
|
||||||
|
|
||||||
|
```go
|
||||||
|
// backtest/account.go:61-104
|
||||||
|
func (a *BacktestAccount) Open(symbol, side string, qty, price float64, leverage int) {
|
||||||
|
// 1. Apply slippage
|
||||||
|
// 2. Calculate notional value (qty × price)
|
||||||
|
// 3. Calculate margin (notional / leverage)
|
||||||
|
// 4. Deduct margin + fees
|
||||||
|
// 5. Create/add to position
|
||||||
|
// 6. Calculate liquidation price
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### 9.3 Close Position Logic
|
||||||
|
|
||||||
|
```go
|
||||||
|
// backtest/account.go:106-140
|
||||||
|
func (a *BacktestAccount) Close(symbol, side string, qty, price float64) {
|
||||||
|
// 1. Verify position exists
|
||||||
|
// 2. Apply slippage (reverse direction)
|
||||||
|
// 3. Calculate realized PnL
|
||||||
|
// long: (exit - entry) × qty
|
||||||
|
// short: (entry - exit) × qty
|
||||||
|
// 4. Return margin + PnL - fees
|
||||||
|
// 5. Update/delete position
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### 9.4 Liquidation Price Calculation
|
||||||
|
|
||||||
|
```go
|
||||||
|
// backtest/account.go:177-186
|
||||||
|
func computeLiquidation(entry float64, leverage int, side string) float64 {
|
||||||
|
if side == "long" {
|
||||||
|
return entry * (1 - 1.0/float64(leverage)) // Long: liquidate on drop
|
||||||
|
}
|
||||||
|
return entry * (1 + 1.0/float64(leverage)) // Short: liquidate on rise
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 10. Checkpoint & Resume
|
||||||
|
|
||||||
|
**Core File:** `backtest/runner.go`
|
||||||
|
|
||||||
|
### 10.1 Checkpoint Structure
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"bar_index": 1234,
|
||||||
|
"bar_ts": 1702609200000,
|
||||||
|
"cash": 8000.00,
|
||||||
|
"equity": 10234.50,
|
||||||
|
"max_equity": 10500.00,
|
||||||
|
"max_drawdown_pct": 5.67,
|
||||||
|
"positions": [...],
|
||||||
|
"decision_cycle": 62,
|
||||||
|
"liquidated": false
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### 10.2 Checkpoint Trigger
|
||||||
|
|
||||||
|
```go
|
||||||
|
// backtest/runner.go:874-898
|
||||||
|
func (r *Runner) maybeCheckpoint() {
|
||||||
|
// Save every N bars
|
||||||
|
// Or save every N seconds
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### 10.3 Resume Flow
|
||||||
|
|
||||||
|
```go
|
||||||
|
func (r *Runner) RestoreFromCheckpoint() {
|
||||||
|
// 1. Load checkpoint
|
||||||
|
// 2. Restore account state
|
||||||
|
// 3. Restore bar index (continue from next bar)
|
||||||
|
// 4. Restore equity curve, trade records
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Core File Index
|
||||||
|
|
||||||
|
| Module | File | Key Methods |
|
||||||
|
|--------|------|-------------|
|
||||||
|
| **Config** | `backtest/config.go` | `BacktestConfig`, `Validate()` |
|
||||||
|
| **Data Loading** | `backtest/datafeed.go` | `NewDataFeed()`, `loadAll()`, `BuildMarketData()` |
|
||||||
|
| **Sim Engine** | `backtest/runner.go` | `Start()`, `loop()`, `stepOnce()` |
|
||||||
|
| **Decision** | `backtest/runner.go` | `buildDecisionContext()`, `invokeAIWithRetry()` |
|
||||||
|
| **Execution** | `backtest/runner.go` | `executeDecision()` |
|
||||||
|
| **Account** | `backtest/account.go` | `Open()`, `Close()`, `TotalEquity()` |
|
||||||
|
| **Metrics** | `backtest/metrics.go` | `CalculateMetrics()` |
|
||||||
|
| **Equity** | `backtest/equity.go` | `ResampleEquity()`, `LimitEquityPoints()` |
|
||||||
|
| **Storage** | `backtest/storage.go` | `SaveCheckpoint()`, `appendEquityPoint()` |
|
||||||
|
| **Database** | `store/backtest.go` | Schema and CRUD operations |
|
||||||
|
| **API** | `api/backtest.go` | HTTP handlers |
|
||||||
|
| **AI Cache** | `backtest/aicache.go` | `Get()`, `Put()`, `save()` |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
**Document Version:** 1.0.0
|
||||||
|
**Last Updated:** 2025-01-15
|
||||||
624
docs/architecture/BACKTEST_MODULE.zh-CN.md
Normal file
624
docs/architecture/BACKTEST_MODULE.zh-CN.md
Normal file
@@ -0,0 +1,624 @@
|
|||||||
|
# NOFX 回测模块技术文档
|
||||||
|
|
||||||
|
**语言:** [English](BACKTEST_MODULE.md) | [中文](BACKTEST_MODULE.zh-CN.md)
|
||||||
|
|
||||||
|
## 概述
|
||||||
|
|
||||||
|
本文档详细描述 NOFX 回测模块的完整技术实现,包括配置、历史数据加载、模拟引擎、AI 决策、性能指标计算和结果存储。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 完整回测流程图
|
||||||
|
|
||||||
|
```
|
||||||
|
┌─────────────────────────────────────────────────────────────────┐
|
||||||
|
│ 回测执行流程 │
|
||||||
|
└─────────────────────────────────────────────────────────────────┘
|
||||||
|
|
||||||
|
1. API 请求: /backtest/start
|
||||||
|
↓
|
||||||
|
2. Manager.Start()
|
||||||
|
├─ 验证配置
|
||||||
|
├─ 解析 AI 模型
|
||||||
|
├─ 创建 Runner 实例
|
||||||
|
└─ 启动 runner.Start() (goroutine)
|
||||||
|
↓
|
||||||
|
3. Runner.Start() → Runner.loop()
|
||||||
|
└─ 遍历每个决策时间点:
|
||||||
|
├─ DataFeed.BuildMarketData() [构建市场数据]
|
||||||
|
├─ 检查决策触发条件 [每 N 根 K 线]
|
||||||
|
├─ buildDecisionContext() [构建决策上下文]
|
||||||
|
├─ invokeAIWithRetry() [调用 AI + 缓存]
|
||||||
|
├─ executeDecision() [执行交易]
|
||||||
|
├─ checkLiquidation() [检查爆仓]
|
||||||
|
├─ updateState() [更新状态]
|
||||||
|
├─ appendEquityPoint() [记录权益]
|
||||||
|
├─ appendTradeEvent() [记录交易]
|
||||||
|
├─ maybeCheckpoint() [保存检查点]
|
||||||
|
└─ persistMetrics() [持久化指标]
|
||||||
|
↓
|
||||||
|
4. 完成/失败
|
||||||
|
├─ 计算最终指标
|
||||||
|
├─ 持久化所有结果
|
||||||
|
└─ 释放锁
|
||||||
|
↓
|
||||||
|
5. API 查询: /backtest/metrics, /backtest/equity, /backtest/trades
|
||||||
|
└─ 加载并返回结果
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 1. 回测配置 (Configuration)
|
||||||
|
|
||||||
|
**核心文件:** `backtest/config.go`
|
||||||
|
|
||||||
|
### 1.1 配置参数
|
||||||
|
|
||||||
|
| 参数 | 类型 | 默认值 | 说明 |
|
||||||
|
|------|------|--------|------|
|
||||||
|
| `RunID` | string | (必填) | 回测运行唯一标识 |
|
||||||
|
| `UserID` | string | "default" | 用户 ID |
|
||||||
|
| `Symbols` | []string | (必填) | 交易币种列表 |
|
||||||
|
| `Timeframes` | []string | ["3m", "15m", "4h"] | K 线周期 |
|
||||||
|
| `DecisionTimeframe` | string | Symbols[0] | 主决策周期 |
|
||||||
|
| `DecisionCadenceNBars` | int | 20 | 每 N 根 K 线触发一次决策 |
|
||||||
|
| `StartTS`, `EndTS` | int64 | (必填) | 回测时间范围 (Unix 时间戳) |
|
||||||
|
| `InitialBalance` | float64 | 1000 | 初始资金 (USD) |
|
||||||
|
| `FeeBps` | float64 | 5 | 手续费 (基点) |
|
||||||
|
| `SlippageBps` | float64 | 2 | 滑点 (基点) |
|
||||||
|
| `FillPolicy` | string | "next_open" | 成交策略 |
|
||||||
|
| `PromptVariant` | string | "baseline" | AI 提示词变体 |
|
||||||
|
| `CacheAI` | bool | false | 是否缓存 AI 决策 |
|
||||||
|
| `Leverage` | LeverageConfig | BTC/ETH:5, Altcoin:5 | 杠杆设置 |
|
||||||
|
|
||||||
|
### 1.2 成交策略 (Fill Policy)
|
||||||
|
|
||||||
|
```go
|
||||||
|
// backtest/config.go:163-179
|
||||||
|
switch fillPolicy {
|
||||||
|
case "next_open": // 下一根 K 线开盘价
|
||||||
|
case "bar_vwap": // 当前 K 线 VWAP
|
||||||
|
case "mid": // 当前 K 线 (High+Low)/2
|
||||||
|
default: // Mark Price
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### 1.3 配置示例
|
||||||
|
|
||||||
|
```go
|
||||||
|
cfg := backtest.BacktestConfig{
|
||||||
|
RunID: "bt_20231215_150405",
|
||||||
|
Symbols: []string{"BTCUSDT", "ETHUSDT"},
|
||||||
|
Timeframes: []string{"3m", "15m", "4h"},
|
||||||
|
DecisionTimeframe: "3m",
|
||||||
|
DecisionCadenceNBars: 20,
|
||||||
|
StartTS: 1702566000,
|
||||||
|
EndTS: 1702652400,
|
||||||
|
InitialBalance: 10000,
|
||||||
|
FeeBps: 5,
|
||||||
|
SlippageBps: 2,
|
||||||
|
FillPolicy: "next_open",
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 2. 历史数据加载 (Data Loading)
|
||||||
|
|
||||||
|
**核心文件:** `backtest/datafeed.go`
|
||||||
|
|
||||||
|
### 2.1 数据加载流程
|
||||||
|
|
||||||
|
```
|
||||||
|
1. NewDataFeed() - 初始化
|
||||||
|
↓
|
||||||
|
2. loadAll() - 加载所有历史数据
|
||||||
|
├─ 计算缓冲区 (StartTS 前 200 根 K 线)
|
||||||
|
├─ 调用 market.GetKlinesRange() 获取数据
|
||||||
|
├─ 存储到 symbolSeries map
|
||||||
|
└─ 从主周期构建决策时间线
|
||||||
|
↓
|
||||||
|
3. BuildMarketData() - 构建市场数据快照
|
||||||
|
├─ 切片 K 线数据到当前时间戳
|
||||||
|
├─ 计算技术指标 (EMA, MACD, RSI, ATR)
|
||||||
|
└─ 返回 market.Data 结构
|
||||||
|
```
|
||||||
|
|
||||||
|
### 2.2 数据结构
|
||||||
|
|
||||||
|
```go
|
||||||
|
// DataFeed 核心结构
|
||||||
|
type DataFeed struct {
|
||||||
|
decisionTimes []int64 // 决策时间点列表
|
||||||
|
symbolSeries map[string]*symbolSeries // 按币种存储的数据
|
||||||
|
}
|
||||||
|
|
||||||
|
// 单币种时间序列
|
||||||
|
type symbolSeries struct {
|
||||||
|
timeframes map[string]*timeframeSeries // 按周期存储
|
||||||
|
}
|
||||||
|
|
||||||
|
// 单周期数据
|
||||||
|
type timeframeSeries struct {
|
||||||
|
klines []market.Kline // K 线数据
|
||||||
|
closeTimes []int64 // 收盘时间索引
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### 2.3 关键代码引用
|
||||||
|
|
||||||
|
- 数据获取: `backtest/datafeed.go:48-93`
|
||||||
|
- 时间线生成: `backtest/datafeed.go:96-115`
|
||||||
|
- 市场数据组装: `backtest/datafeed.go:141-171`
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 3. 模拟引擎 (Simulation Engine)
|
||||||
|
|
||||||
|
**核心文件:** `backtest/runner.go`
|
||||||
|
|
||||||
|
### 3.1 主循环
|
||||||
|
|
||||||
|
```go
|
||||||
|
// backtest/runner.go:232-264
|
||||||
|
func (r *Runner) loop() {
|
||||||
|
for _, ts := range r.feed.DecisionTimes() {
|
||||||
|
if r.isPaused() {
|
||||||
|
break
|
||||||
|
}
|
||||||
|
r.stepOnce(ts)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### 3.2 单步执行
|
||||||
|
|
||||||
|
```go
|
||||||
|
// backtest/runner.go:266-471
|
||||||
|
func (r *Runner) stepOnce(ts int64) {
|
||||||
|
// 1. 获取当前 K 线时间戳
|
||||||
|
// 2. 构建市场数据
|
||||||
|
// 3. 检查决策触发条件 (每 N 根 K 线)
|
||||||
|
// 4. 执行决策周期 (如果触发)
|
||||||
|
// 5. 检查爆仓
|
||||||
|
// 6. 更新状态并记录
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### 3.3 状态管理
|
||||||
|
|
||||||
|
```go
|
||||||
|
// backtest/types.go:31-47
|
||||||
|
type BacktestState struct {
|
||||||
|
BarIndex int // 当前 K 线索引
|
||||||
|
Cash float64 // 可用余额
|
||||||
|
Equity float64 // 总权益
|
||||||
|
UnrealizedPnL float64 // 未实现盈亏
|
||||||
|
RealizedPnL float64 // 已实现盈亏
|
||||||
|
MaxEquity float64 // 最高权益
|
||||||
|
MinEquity float64 // 最低权益
|
||||||
|
MaxDrawdownPct float64 // 最大回撤
|
||||||
|
Positions map[string]*position // 持仓
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 4. AI 决策 (AI Decision Making)
|
||||||
|
|
||||||
|
**核心文件:** `backtest/runner.go`
|
||||||
|
|
||||||
|
### 4.1 决策上下文构建
|
||||||
|
|
||||||
|
```go
|
||||||
|
// backtest/runner.go:473-532
|
||||||
|
func (r *Runner) buildDecisionContext() *decision.Context {
|
||||||
|
return &decision.Context{
|
||||||
|
CurrentTime: "2023-12-15 10:30:00 UTC",
|
||||||
|
RuntimeMinutes: elapsed,
|
||||||
|
CallCount: cycleNumber,
|
||||||
|
Account: {
|
||||||
|
TotalEquity, AvailableBalance, TotalPnL, MarginUsedPct
|
||||||
|
},
|
||||||
|
Positions: []PositionInfo{...},
|
||||||
|
CandidateCoins: []string{symbols...},
|
||||||
|
MarketDataMap: map[symbol]*market.Data{...},
|
||||||
|
MultiTFMarket: map[symbol]map[timeframe]*market.Data{...},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### 4.2 AI 调用
|
||||||
|
|
||||||
|
```go
|
||||||
|
// backtest/runner.go:544-563
|
||||||
|
func (r *Runner) invokeAIWithRetry() (*decision.FullDecision, error) {
|
||||||
|
// 最多重试 3 次
|
||||||
|
// 指数退避: 500ms, 1000ms, 1500ms
|
||||||
|
// 使用 decision.GetFullDecisionWithStrategy() 统一提示词生成
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### 4.3 AI 缓存
|
||||||
|
|
||||||
|
```go
|
||||||
|
// backtest/aicache.go:127-168
|
||||||
|
// 缓存键: SHA256(context payload)
|
||||||
|
// 包含: variant, timestamp, account, positions, market data
|
||||||
|
```
|
||||||
|
|
||||||
|
### 4.4 支持的 AI 模型
|
||||||
|
|
||||||
|
| 模型 | 客户端文件 |
|
||||||
|
|------|-----------|
|
||||||
|
| DeepSeek | `mcp/deepseek_client.go` |
|
||||||
|
| Qwen | `mcp/qwen_client.go` |
|
||||||
|
| Claude | `mcp/claude_client.go` |
|
||||||
|
| Gemini | `mcp/gemini_client.go` |
|
||||||
|
| Grok | `mcp/grok_client.go` |
|
||||||
|
| OpenAI | `mcp/openai_client.go` |
|
||||||
|
| Kimi | `mcp/kimi_client.go` |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 5. 性能指标 (Performance Metrics)
|
||||||
|
|
||||||
|
**核心文件:** `backtest/metrics.go`
|
||||||
|
|
||||||
|
### 5.1 指标计算
|
||||||
|
|
||||||
|
| 指标 | 公式 | 代码位置 |
|
||||||
|
|------|------|----------|
|
||||||
|
| **总收益率** | (最终权益 - 初始资金) / 初始资金 × 100 | metrics.go:36-42 |
|
||||||
|
| **最大回撤** | max((峰值 - 当前) / 峰值 × 100) | metrics.go:64-91 |
|
||||||
|
| **夏普比率** | 平均收益 / 收益标准差 | metrics.go:94-138 |
|
||||||
|
| **胜率** | 盈利交易数 / 总交易数 × 100 | metrics.go:180-181 |
|
||||||
|
| **盈亏比** | 总盈利 / 总亏损 | metrics.go:189-193 |
|
||||||
|
|
||||||
|
### 5.2 交易统计
|
||||||
|
|
||||||
|
```go
|
||||||
|
// backtest/metrics.go:141-225
|
||||||
|
type TradeMetrics struct {
|
||||||
|
TotalTrades int
|
||||||
|
WinningTrades int
|
||||||
|
LosingTrades int
|
||||||
|
AvgWin float64
|
||||||
|
AvgLoss float64
|
||||||
|
BestSymbol string
|
||||||
|
WorstSymbol string
|
||||||
|
SymbolStats map[string]*SymbolStat
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 6. 权益曲线 (Equity Curve)
|
||||||
|
|
||||||
|
**核心文件:** `backtest/equity.go`
|
||||||
|
|
||||||
|
### 6.1 权益点结构
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"ts": 1702566000000,
|
||||||
|
"equity": 10500.50,
|
||||||
|
"available": 8000.00,
|
||||||
|
"pnl": 500.50,
|
||||||
|
"pnl_pct": 5.005,
|
||||||
|
"dd_pct": 2.34,
|
||||||
|
"cycle": 42
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### 6.2 权益更新
|
||||||
|
|
||||||
|
```go
|
||||||
|
// backtest/runner.go:829-872
|
||||||
|
func (r *Runner) updateState() {
|
||||||
|
// 1. 计算总权益: cash + margin + 未实现盈亏
|
||||||
|
// 2. 追踪峰值 (MaxEquity)
|
||||||
|
// 3. 追踪谷值 (MinEquity)
|
||||||
|
// 4. 重新计算回撤: (MaxEquity - Equity) / MaxEquity × 100
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### 6.3 数据重采样
|
||||||
|
|
||||||
|
```go
|
||||||
|
// backtest/equity.go:10-50
|
||||||
|
func ResampleEquity(points []EquityPoint, timeframe string) []EquityPoint {
|
||||||
|
// 按时间周期分桶
|
||||||
|
// 保留每个桶的最后一个点
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 7. 结果存储 (Result Storage)
|
||||||
|
|
||||||
|
**核心文件:** `backtest/storage.go`, `store/backtest.go`
|
||||||
|
|
||||||
|
### 7.1 文件存储结构
|
||||||
|
|
||||||
|
```
|
||||||
|
backtests/
|
||||||
|
├── <run_id>/
|
||||||
|
│ ├── run.json # 运行元数据
|
||||||
|
│ ├── checkpoint.json # 检查点 (用于恢复)
|
||||||
|
│ ├── equity.jsonl # 权益曲线 (逐行 JSON)
|
||||||
|
│ ├── trades.jsonl # 交易记录 (逐行 JSON)
|
||||||
|
│ ├── metrics.json # 性能指标
|
||||||
|
│ ├── progress.json # 进度信息
|
||||||
|
│ ├── ai_cache.json # AI 决策缓存
|
||||||
|
│ └── decision_logs/ # 决策日志
|
||||||
|
│ ├── 0.json
|
||||||
|
│ ├── 1.json
|
||||||
|
│ └── ...
|
||||||
|
```
|
||||||
|
|
||||||
|
### 7.2 数据库表结构
|
||||||
|
|
||||||
|
```sql
|
||||||
|
-- 回测运行元数据
|
||||||
|
CREATE TABLE backtest_runs (
|
||||||
|
run_id TEXT PRIMARY KEY,
|
||||||
|
user_id TEXT,
|
||||||
|
config_json TEXT,
|
||||||
|
state TEXT, -- pending, running, completed, failed
|
||||||
|
processed_bars INTEGER,
|
||||||
|
progress_pct REAL,
|
||||||
|
equity_last REAL,
|
||||||
|
max_drawdown_pct REAL,
|
||||||
|
liquidated BOOLEAN,
|
||||||
|
ai_provider TEXT,
|
||||||
|
ai_model TEXT,
|
||||||
|
created_at DATETIME,
|
||||||
|
updated_at DATETIME
|
||||||
|
);
|
||||||
|
|
||||||
|
-- 权益曲线
|
||||||
|
CREATE TABLE backtest_equity (
|
||||||
|
id INTEGER PRIMARY KEY,
|
||||||
|
run_id TEXT,
|
||||||
|
ts INTEGER,
|
||||||
|
equity REAL,
|
||||||
|
available REAL,
|
||||||
|
pnl REAL,
|
||||||
|
pnl_pct REAL,
|
||||||
|
dd_pct REAL,
|
||||||
|
cycle INTEGER
|
||||||
|
);
|
||||||
|
|
||||||
|
-- 交易记录
|
||||||
|
CREATE TABLE backtest_trades (
|
||||||
|
id INTEGER PRIMARY KEY,
|
||||||
|
run_id TEXT,
|
||||||
|
ts INTEGER,
|
||||||
|
symbol TEXT,
|
||||||
|
action TEXT,
|
||||||
|
side TEXT,
|
||||||
|
qty REAL,
|
||||||
|
price REAL,
|
||||||
|
fee REAL,
|
||||||
|
slippage REAL,
|
||||||
|
realized_pnl REAL,
|
||||||
|
leverage INTEGER,
|
||||||
|
liquidation BOOLEAN
|
||||||
|
);
|
||||||
|
|
||||||
|
-- 性能指标
|
||||||
|
CREATE TABLE backtest_metrics (
|
||||||
|
run_id TEXT PRIMARY KEY,
|
||||||
|
payload BLOB,
|
||||||
|
updated_at DATETIME
|
||||||
|
);
|
||||||
|
|
||||||
|
-- 检查点 (暂停/恢复)
|
||||||
|
CREATE TABLE backtest_checkpoints (
|
||||||
|
run_id TEXT PRIMARY KEY,
|
||||||
|
payload BLOB,
|
||||||
|
updated_at DATETIME
|
||||||
|
);
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 8. API 接口
|
||||||
|
|
||||||
|
**核心文件:** `api/backtest.go`
|
||||||
|
|
||||||
|
### 8.1 接口列表
|
||||||
|
|
||||||
|
| 接口 | 方法 | 说明 |
|
||||||
|
|------|------|------|
|
||||||
|
| `/backtest/start` | POST | 开始回测 |
|
||||||
|
| `/backtest/pause` | POST | 暂停回测 |
|
||||||
|
| `/backtest/resume` | POST | 恢复回测 |
|
||||||
|
| `/backtest/stop` | POST | 停止回测 |
|
||||||
|
| `/backtest/status` | GET | 获取状态 |
|
||||||
|
| `/backtest/runs` | GET | 列出所有回测 |
|
||||||
|
| `/backtest/equity` | GET | 获取权益曲线 |
|
||||||
|
| `/backtest/trades` | GET | 获取交易记录 |
|
||||||
|
| `/backtest/metrics` | GET | 获取性能指标 |
|
||||||
|
| `/backtest/trace` | GET | 获取决策日志 |
|
||||||
|
| `/backtest/export` | GET | 导出 ZIP |
|
||||||
|
| `/backtest/delete` | POST | 删除回测 |
|
||||||
|
|
||||||
|
### 8.2 请求示例
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# 开始回测
|
||||||
|
POST /backtest/start
|
||||||
|
{
|
||||||
|
"config": {
|
||||||
|
"run_id": "bt_20231215",
|
||||||
|
"symbols": ["BTCUSDT", "ETHUSDT"],
|
||||||
|
"timeframes": ["3m", "15m", "4h"],
|
||||||
|
"start_ts": 1702566000,
|
||||||
|
"end_ts": 1702652400,
|
||||||
|
"initial_balance": 10000,
|
||||||
|
"ai_model_id": "model_001"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
# 获取权益曲线
|
||||||
|
GET /backtest/equity?run_id=bt_20231215&tf=1h&limit=1000
|
||||||
|
|
||||||
|
# 获取指标
|
||||||
|
GET /backtest/metrics?run_id=bt_20231215
|
||||||
|
```
|
||||||
|
|
||||||
|
### 8.3 响应示例
|
||||||
|
|
||||||
|
```json
|
||||||
|
// 状态响应
|
||||||
|
{
|
||||||
|
"run_id": "bt_20231215",
|
||||||
|
"state": "running",
|
||||||
|
"progress_pct": 45.5,
|
||||||
|
"processed_bars": 1234,
|
||||||
|
"equity": 10234.50,
|
||||||
|
"unrealized_pnl": 234.50
|
||||||
|
}
|
||||||
|
|
||||||
|
// 指标响应
|
||||||
|
{
|
||||||
|
"total_return_pct": 12.34,
|
||||||
|
"max_drawdown_pct": 5.67,
|
||||||
|
"sharpe_ratio": 1.89,
|
||||||
|
"profit_factor": 2.34,
|
||||||
|
"win_rate": 65.5,
|
||||||
|
"trades": 123
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 9. 账户与持仓管理
|
||||||
|
|
||||||
|
**核心文件:** `backtest/account.go`
|
||||||
|
|
||||||
|
### 9.1 持仓结构
|
||||||
|
|
||||||
|
```go
|
||||||
|
type position struct {
|
||||||
|
Symbol string
|
||||||
|
Side string // "long" 或 "short"
|
||||||
|
Quantity float64
|
||||||
|
EntryPrice float64
|
||||||
|
Leverage int
|
||||||
|
Margin float64 // 保证金
|
||||||
|
Notional float64 // 名义价值
|
||||||
|
LiquidationPrice float64 // 爆仓价格
|
||||||
|
OpenTime int64
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### 9.2 开仓逻辑
|
||||||
|
|
||||||
|
```go
|
||||||
|
// backtest/account.go:61-104
|
||||||
|
func (a *BacktestAccount) Open(symbol, side string, qty, price float64, leverage int) {
|
||||||
|
// 1. 应用滑点
|
||||||
|
// 2. 计算名义价值 (qty × price)
|
||||||
|
// 3. 计算保证金 (notional / leverage)
|
||||||
|
// 4. 扣除保证金 + 手续费
|
||||||
|
// 5. 创建/加仓
|
||||||
|
// 6. 计算爆仓价格
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### 9.3 平仓逻辑
|
||||||
|
|
||||||
|
```go
|
||||||
|
// backtest/account.go:106-140
|
||||||
|
func (a *BacktestAccount) Close(symbol, side string, qty, price float64) {
|
||||||
|
// 1. 验证持仓存在
|
||||||
|
// 2. 应用滑点 (反向)
|
||||||
|
// 3. 计算已实现盈亏
|
||||||
|
// long: (exit - entry) × qty
|
||||||
|
// short: (entry - exit) × qty
|
||||||
|
// 4. 返还保证金 + 盈亏 - 手续费
|
||||||
|
// 5. 更新/删除持仓
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### 9.4 爆仓价格计算
|
||||||
|
|
||||||
|
```go
|
||||||
|
// backtest/account.go:177-186
|
||||||
|
func computeLiquidation(entry float64, leverage int, side string) float64 {
|
||||||
|
if side == "long" {
|
||||||
|
return entry * (1 - 1.0/float64(leverage)) // 做多: 下跌爆仓
|
||||||
|
}
|
||||||
|
return entry * (1 + 1.0/float64(leverage)) // 做空: 上涨爆仓
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 10. 检查点与恢复
|
||||||
|
|
||||||
|
**核心文件:** `backtest/runner.go`
|
||||||
|
|
||||||
|
### 10.1 检查点结构
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"bar_index": 1234,
|
||||||
|
"bar_ts": 1702609200000,
|
||||||
|
"cash": 8000.00,
|
||||||
|
"equity": 10234.50,
|
||||||
|
"max_equity": 10500.00,
|
||||||
|
"max_drawdown_pct": 5.67,
|
||||||
|
"positions": [...],
|
||||||
|
"decision_cycle": 62,
|
||||||
|
"liquidated": false
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### 10.2 检查点触发
|
||||||
|
|
||||||
|
```go
|
||||||
|
// backtest/runner.go:874-898
|
||||||
|
func (r *Runner) maybeCheckpoint() {
|
||||||
|
// 每 N 根 K 线保存
|
||||||
|
// 或每 N 秒保存
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### 10.3 恢复流程
|
||||||
|
|
||||||
|
```go
|
||||||
|
func (r *Runner) RestoreFromCheckpoint() {
|
||||||
|
// 1. 加载检查点
|
||||||
|
// 2. 恢复账户状态
|
||||||
|
// 3. 恢复 K 线索引 (从下一根继续)
|
||||||
|
// 4. 恢复权益曲线、交易记录
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 核心文件索引
|
||||||
|
|
||||||
|
| 模块 | 文件 | 关键方法 |
|
||||||
|
|------|------|----------|
|
||||||
|
| **配置** | `backtest/config.go` | `BacktestConfig`, `Validate()` |
|
||||||
|
| **数据加载** | `backtest/datafeed.go` | `NewDataFeed()`, `loadAll()`, `BuildMarketData()` |
|
||||||
|
| **模拟引擎** | `backtest/runner.go` | `Start()`, `loop()`, `stepOnce()` |
|
||||||
|
| **决策** | `backtest/runner.go` | `buildDecisionContext()`, `invokeAIWithRetry()` |
|
||||||
|
| **执行** | `backtest/runner.go` | `executeDecision()` |
|
||||||
|
| **账户** | `backtest/account.go` | `Open()`, `Close()`, `TotalEquity()` |
|
||||||
|
| **指标** | `backtest/metrics.go` | `CalculateMetrics()` |
|
||||||
|
| **权益** | `backtest/equity.go` | `ResampleEquity()`, `LimitEquityPoints()` |
|
||||||
|
| **存储** | `backtest/storage.go` | `SaveCheckpoint()`, `appendEquityPoint()` |
|
||||||
|
| **数据库** | `store/backtest.go` | 表结构和 CRUD 操作 |
|
||||||
|
| **API** | `api/backtest.go` | HTTP 处理器 |
|
||||||
|
| **AI 缓存** | `backtest/aicache.go` | `Get()`, `Put()`, `save()` |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
**文档版本:** 1.0.0
|
||||||
|
**最后更新:** 2025-01-15
|
||||||
909
docs/architecture/DEBATE_MODULE.md
Normal file
909
docs/architecture/DEBATE_MODULE.md
Normal file
@@ -0,0 +1,909 @@
|
|||||||
|
# Debate Arena Module - Technical Documentation
|
||||||
|
|
||||||
|
**Language:** [English](DEBATE_MODULE.md) | [中文](DEBATE_MODULE.zh-CN.md)
|
||||||
|
|
||||||
|
## Overview
|
||||||
|
|
||||||
|
The Debate Arena is a collaborative AI decision-making system where multiple AI models with different personalities debate market conditions and reach consensus on trading decisions. The system supports multi-round debates, real-time streaming, voting mechanisms, and automatic trade execution.
|
||||||
|
|
||||||
|
## Table of Contents
|
||||||
|
|
||||||
|
1. [Architecture Overview](#1-architecture-overview)
|
||||||
|
2. [Backend Components](#2-backend-components)
|
||||||
|
3. [Debate Execution Flow](#3-debate-execution-flow)
|
||||||
|
4. [Personality System](#4-personality-system)
|
||||||
|
5. [Consensus Algorithm](#5-consensus-algorithm)
|
||||||
|
6. [Auto-Execution](#6-auto-execution)
|
||||||
|
7. [API Reference](#7-api-reference)
|
||||||
|
8. [Real-Time Updates (SSE)](#8-real-time-updates-sse)
|
||||||
|
9. [Database Schema](#9-database-schema)
|
||||||
|
10. [Frontend Components](#10-frontend-components)
|
||||||
|
11. [Integration Points](#11-integration-points)
|
||||||
|
12. [Error Handling](#12-error-handling)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 1. Architecture Overview
|
||||||
|
|
||||||
|
```
|
||||||
|
┌─────────────────────────────────────────────────────────────────────────────┐
|
||||||
|
│ Debate Arena System │
|
||||||
|
├─────────────────────────────────────────────────────────────────────────────┤
|
||||||
|
│ │
|
||||||
|
│ ┌─────────────┐ ┌─────────────┐ ┌─────────────┐ ┌─────────────┐ │
|
||||||
|
│ │ Bull AI │ │ Bear AI │ │ Analyst AI │ │ Risk Mgr AI │ │
|
||||||
|
│ │ 🐂 │ │ 🐻 │ │ 📊 │ │ 🛡️ │ │
|
||||||
|
│ └──────┬──────┘ └──────┬──────┘ └──────┬──────┘ └──────┬──────┘ │
|
||||||
|
│ │ │ │ │ │
|
||||||
|
│ └──────────────────┴──────────────────┴──────────────────┘ │
|
||||||
|
│ │ │
|
||||||
|
│ ┌─────────▼─────────┐ │
|
||||||
|
│ │ Debate Engine │ │
|
||||||
|
│ │ (debate/engine) │ │
|
||||||
|
│ └─────────┬─────────┘ │
|
||||||
|
│ │ │
|
||||||
|
│ ┌──────────────────────────┼──────────────────────────┐ │
|
||||||
|
│ │ │ │ │
|
||||||
|
│ ┌──────▼──────┐ ┌─────────▼─────────┐ ┌────────▼────────┐ │
|
||||||
|
│ │ Market Data │ │ Voting System │ │ Auto-Executor │ │
|
||||||
|
│ │ Assembly │ │ & Consensus │ │ (optional) │ │
|
||||||
|
│ └─────────────┘ └───────────────────┘ └─────────────────┘ │
|
||||||
|
│ │
|
||||||
|
└─────────────────────────────────────────────────────────────────────────────┘
|
||||||
|
```
|
||||||
|
|
||||||
|
### File Structure
|
||||||
|
|
||||||
|
```
|
||||||
|
├── debate/
|
||||||
|
│ └── engine.go # Core debate engine logic
|
||||||
|
├── api/
|
||||||
|
│ └── debate.go # HTTP handlers and SSE streaming
|
||||||
|
├── store/
|
||||||
|
│ └── debate.go # Database operations and schema
|
||||||
|
└── web/src/pages/
|
||||||
|
└── DebateArenaPage.tsx # Frontend UI
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 2. Backend Components
|
||||||
|
|
||||||
|
### 2.1 Core Files
|
||||||
|
|
||||||
|
| File | Purpose | Key Functions |
|
||||||
|
|------|---------|---------------|
|
||||||
|
| `debate/engine.go` | Core debate logic | `StartDebate()`, `runDebate()`, `collectVotes()`, `determineConsensus()` |
|
||||||
|
| `api/debate.go` | HTTP handlers | `HandleCreateDebate()`, `HandleStartDebate()`, `HandleDebateStream()` |
|
||||||
|
| `store/debate.go` | Database ops | `CreateSession()`, `AddMessage()`, `AddVote()`, `GetSessionWithDetails()` |
|
||||||
|
|
||||||
|
### 2.2 Debate Engine Structure
|
||||||
|
|
||||||
|
```go
|
||||||
|
// debate/engine.go
|
||||||
|
|
||||||
|
type DebateEngine struct {
|
||||||
|
store *store.DebateStore
|
||||||
|
aiClients map[string]ai.Client
|
||||||
|
strategyEngine *strategy.Engine
|
||||||
|
subscribers map[string]map[chan []byte]bool
|
||||||
|
}
|
||||||
|
|
||||||
|
// Event callbacks for real-time updates
|
||||||
|
var OnRoundStart func(sessionID string, round int)
|
||||||
|
var OnMessage func(sessionID string, msg *DebateMessage)
|
||||||
|
var OnVote func(sessionID string, vote *DebateVote)
|
||||||
|
var OnConsensus func(sessionID string, decision *DebateDecision)
|
||||||
|
var OnError func(sessionID string, err error)
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 3. Debate Execution Flow
|
||||||
|
|
||||||
|
### 3.1 Session Creation
|
||||||
|
|
||||||
|
```
|
||||||
|
POST /api/debates
|
||||||
|
│
|
||||||
|
▼
|
||||||
|
┌─────────────────────────────────────────────────────────────┐
|
||||||
|
│ 1. Validate user authentication │
|
||||||
|
│ 2. Parse CreateDebateRequest: │
|
||||||
|
│ - name, strategy_id, symbol, max_rounds, participants │
|
||||||
|
│ - interval_minutes, prompt_variant, auto_execute │
|
||||||
|
│ 3. Validate strategy ownership │
|
||||||
|
│ 4. Auto-select symbol if not provided: │
|
||||||
|
│ - Static coins → Use first coin from strategy │
|
||||||
|
│ - CoinPool → Fetch from AI500 API │
|
||||||
|
│ - OI Top → Fetch from OI ranking API │
|
||||||
|
│ - Mixed → Try pool first, fallback to OI │
|
||||||
|
│ 5. Set defaults: │
|
||||||
|
│ - max_rounds: 3 (range 2-5) │
|
||||||
|
│ - interval_minutes: 5 │
|
||||||
|
│ - prompt_variant: "balanced" │
|
||||||
|
│ 6. Create DebateSession in database │
|
||||||
|
│ 7. Add participants with AI models and personalities │
|
||||||
|
│ 8. Return full session with participants │
|
||||||
|
└─────────────────────────────────────────────────────────────┘
|
||||||
|
```
|
||||||
|
|
||||||
|
### 3.2 Debate Start
|
||||||
|
|
||||||
|
**Location:** `debate/engine.go:StartDebate()` (Lines 114-154)
|
||||||
|
|
||||||
|
```
|
||||||
|
POST /api/debates/:id/start
|
||||||
|
│
|
||||||
|
▼
|
||||||
|
┌─────────────────────────────────────────────────────────────┐
|
||||||
|
│ 1. Validate session status (must be pending) │
|
||||||
|
│ 2. Validate participants (minimum 2) │
|
||||||
|
│ 3. Initialize AI clients for all participants │
|
||||||
|
│ 4. Get strategy configuration │
|
||||||
|
│ 5. Update status to "running" │
|
||||||
|
│ 6. Launch goroutine: runDebate() │
|
||||||
|
└─────────────────────────────────────────────────────────────┘
|
||||||
|
```
|
||||||
|
|
||||||
|
### 3.3 Market Context Building
|
||||||
|
|
||||||
|
**Location:** `debate/engine.go:buildMarketContext()` (Lines 292-362)
|
||||||
|
|
||||||
|
```
|
||||||
|
┌─────────────────────────────────────────────────────────────┐
|
||||||
|
│ buildMarketContext() │
|
||||||
|
├─────────────────────────────────────────────────────────────┤
|
||||||
|
│ 1. Get candidate coins from strategy engine │
|
||||||
|
│ 2. Fetch market data for each candidate: │
|
||||||
|
│ - Multiple timeframes (15m, 1h, 4h) │
|
||||||
|
│ - K-line count from strategy config │
|
||||||
|
│ - OHLCV data, indicators │
|
||||||
|
│ 3. Fetch quantitative data batch: │
|
||||||
|
│ - Capital flow │
|
||||||
|
│ - Position changes │
|
||||||
|
│ 4. Fetch OI ranking data (market-wide) │
|
||||||
|
│ 5. Build Context object with: │
|
||||||
|
│ - Account info (simulated: $1000 equity) │
|
||||||
|
│ - Candidate coins │
|
||||||
|
│ - Market data map │
|
||||||
|
│ - Quant data map │
|
||||||
|
│ - OI ranking data │
|
||||||
|
└─────────────────────────────────────────────────────────────┘
|
||||||
|
```
|
||||||
|
|
||||||
|
### 3.4 Debate Rounds
|
||||||
|
|
||||||
|
**Location:** `debate/engine.go:runDebate()` (Lines 157-289)
|
||||||
|
|
||||||
|
```
|
||||||
|
┌─────────────────────────────────────────────────────────────┐
|
||||||
|
│ For each round (1 to max_rounds): │
|
||||||
|
│ ┌─────────────────────────────────────────────────────────┐ │
|
||||||
|
│ │ 1. Broadcast "round_start" event │ │
|
||||||
|
│ │ 2. For each participant (in speak_order): │ │
|
||||||
|
│ │ ┌─────────────────────────────────────────────────┐ │ │
|
||||||
|
│ │ │ a. Build personality-enhanced system prompt │ │ │
|
||||||
|
│ │ │ b. Build user prompt with: │ │ │
|
||||||
|
│ │ │ - Market data (from strategy engine) │ │ │
|
||||||
|
│ │ │ - Previous debate messages (if round > 1) │ │ │
|
||||||
|
│ │ │ c. Call AI model with 60s timeout │ │ │
|
||||||
|
│ │ │ d. Parse multi-coin decisions from response │ │ │
|
||||||
|
│ │ │ e. Save message to database │ │ │
|
||||||
|
│ │ │ f. Broadcast "message" event │ │ │
|
||||||
|
│ │ └─────────────────────────────────────────────────┘ │ │
|
||||||
|
│ │ 3. Broadcast "round_end" event │ │
|
||||||
|
│ └─────────────────────────────────────────────────────────┘ │
|
||||||
|
│ │
|
||||||
|
│ After all rounds: │
|
||||||
|
│ ┌─────────────────────────────────────────────────────────┐ │
|
||||||
|
│ │ 1. Enter voting phase (status = "voting") │ │
|
||||||
|
│ │ 2. Collect final votes from all participants │ │
|
||||||
|
│ │ 3. Determine multi-coin consensus │ │
|
||||||
|
│ │ 4. Store final decisions │ │
|
||||||
|
│ │ 5. Update status to "completed" │ │
|
||||||
|
│ │ 6. Broadcast "consensus" event │ │
|
||||||
|
│ └─────────────────────────────────────────────────────────┘ │
|
||||||
|
└─────────────────────────────────────────────────────────────┘
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 4. Personality System
|
||||||
|
|
||||||
|
### 4.1 Available Personalities
|
||||||
|
|
||||||
|
| Personality | Emoji | Name | Description | Trading Bias |
|
||||||
|
|------------|-------|------|-------------|--------------|
|
||||||
|
| Bull | 🐂 | Aggressive Bull | Looks for long opportunities | Optimistic, trend-following |
|
||||||
|
| Bear | 🐻 | Cautious Bear | Skeptical, focuses on risks | Pessimistic, short bias |
|
||||||
|
| Analyst | 📊 | Data Analyst | Neutral, purely data-driven | No bias, objective analysis |
|
||||||
|
| Contrarian | 🔄 | Contrarian | Challenges majority view | Alternative perspectives |
|
||||||
|
| Risk Manager | 🛡️ | Risk Manager | Focus on risk control | Position sizing, stop loss |
|
||||||
|
|
||||||
|
### 4.2 Personality Prompt Enhancement
|
||||||
|
|
||||||
|
**Location:** `debate/engine.go:buildDebateSystemPrompt()` (Lines 365-426)
|
||||||
|
|
||||||
|
```
|
||||||
|
## DEBATE MODE - ROUND {round}/{max_rounds}
|
||||||
|
|
||||||
|
You are participating as {emoji} {personality}.
|
||||||
|
|
||||||
|
### Your Debate Role:
|
||||||
|
{personality_description}
|
||||||
|
|
||||||
|
### Debate Rules:
|
||||||
|
1. Analyze ALL candidate coins
|
||||||
|
2. Support arguments with specific data
|
||||||
|
3. Respond to other participants (round > 1)
|
||||||
|
4. Be persuasive but data-driven
|
||||||
|
5. Can recommend multiple coins with different actions
|
||||||
|
|
||||||
|
### Output Format (STRICT JSON):
|
||||||
|
<reasoning>
|
||||||
|
- Market analysis with data references
|
||||||
|
- Main trading thesis
|
||||||
|
- Response to others (if round > 1)
|
||||||
|
</reasoning>
|
||||||
|
|
||||||
|
<decision>
|
||||||
|
[
|
||||||
|
{"symbol": "BTCUSDT", "action": "open_long", "confidence": 75, ...},
|
||||||
|
{"symbol": "ETHUSDT", "action": "open_short", "confidence": 80, ...}
|
||||||
|
]
|
||||||
|
</decision>
|
||||||
|
```
|
||||||
|
|
||||||
|
### 4.3 Personality-Specific Prompts
|
||||||
|
|
||||||
|
**Bull (🐂):**
|
||||||
|
```
|
||||||
|
As a bull, you are optimistic about market trends.
|
||||||
|
Look for long opportunities, identify bullish patterns,
|
||||||
|
and support your thesis with technical and fundamental data.
|
||||||
|
Focus on: breakout patterns, momentum, support levels.
|
||||||
|
```
|
||||||
|
|
||||||
|
**Bear (🐻):**
|
||||||
|
```
|
||||||
|
As a bear, you are cautious and skeptical.
|
||||||
|
Look for short opportunities, identify bearish patterns,
|
||||||
|
and highlight risks and potential downside.
|
||||||
|
Focus on: resistance levels, divergences, overbought conditions.
|
||||||
|
```
|
||||||
|
|
||||||
|
**Analyst (📊):**
|
||||||
|
```
|
||||||
|
As a data analyst, you are completely neutral.
|
||||||
|
Provide objective analysis based purely on data.
|
||||||
|
No emotional bias - let the numbers speak.
|
||||||
|
Focus on: key metrics, statistical patterns, historical comparisons.
|
||||||
|
```
|
||||||
|
|
||||||
|
**Contrarian (🔄):**
|
||||||
|
```
|
||||||
|
As a contrarian, challenge the majority view.
|
||||||
|
Look for overlooked opportunities and hidden risks.
|
||||||
|
Play devil's advocate to strengthen the debate.
|
||||||
|
Focus on: crowd positioning, sentiment extremes, neglected signals.
|
||||||
|
```
|
||||||
|
|
||||||
|
**Risk Manager (🛡️):**
|
||||||
|
```
|
||||||
|
As a risk manager, focus on capital preservation.
|
||||||
|
Evaluate position sizing, stop loss levels, and risk/reward ratios.
|
||||||
|
Ensure all decisions have appropriate risk controls.
|
||||||
|
Focus on: max drawdown, position limits, volatility-adjusted sizing.
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 5. Consensus Algorithm
|
||||||
|
|
||||||
|
### 5.1 Vote Collection
|
||||||
|
|
||||||
|
**Location:** `debate/engine.go:collectVotes()` (Lines 542-567)
|
||||||
|
|
||||||
|
```
|
||||||
|
For each participant:
|
||||||
|
┌─────────────────────────────────────────────────────────────┐
|
||||||
|
│ 1. Build voting system prompt │
|
||||||
|
│ 2. Build voting user prompt with debate summary │
|
||||||
|
│ 3. Call AI model for final vote │
|
||||||
|
│ 4. Parse multi-coin decisions │
|
||||||
|
│ 5. Validate/fix symbols against session.Symbol │
|
||||||
|
│ 6. Save vote to database │
|
||||||
|
│ 7. Broadcast "vote" event │
|
||||||
|
└─────────────────────────────────────────────────────────────┘
|
||||||
|
```
|
||||||
|
|
||||||
|
### 5.2 Multi-Coin Consensus Determination
|
||||||
|
|
||||||
|
**Location:** `debate/engine.go:determineMultiCoinConsensus()` (Lines 752-924)
|
||||||
|
|
||||||
|
**Algorithm:**
|
||||||
|
|
||||||
|
```
|
||||||
|
1. Collect all coin decisions from all votes
|
||||||
|
2. Group by: symbol → action → aggregated data
|
||||||
|
|
||||||
|
3. For each vote decision:
|
||||||
|
weight = confidence / 100.0
|
||||||
|
Accumulate:
|
||||||
|
┌─────────────────────────────────────────────────────────┐
|
||||||
|
│ score += weight │
|
||||||
|
│ total_confidence += confidence │
|
||||||
|
│ total_leverage += leverage │
|
||||||
|
│ total_position_pct += position_pct │
|
||||||
|
│ total_stop_loss += stop_loss │
|
||||||
|
│ total_take_profit += take_profit │
|
||||||
|
│ count++ │
|
||||||
|
└─────────────────────────────────────────────────────────┘
|
||||||
|
|
||||||
|
4. For each symbol:
|
||||||
|
Find winning action (max score)
|
||||||
|
Calculate averages:
|
||||||
|
┌─────────────────────────────────────────────────────────┐
|
||||||
|
│ avg_confidence = total_confidence / count │
|
||||||
|
│ avg_leverage = clamp(total_leverage / count, 1, 20) │
|
||||||
|
│ avg_position_pct = clamp(total_pct / count, 0.1, 1.0) │
|
||||||
|
│ avg_stop_loss = default 3% if not set │
|
||||||
|
│ avg_take_profit = default 6% if not set │
|
||||||
|
└─────────────────────────────────────────────────────────┘
|
||||||
|
|
||||||
|
5. Return array of consensus decisions
|
||||||
|
```
|
||||||
|
|
||||||
|
### 5.3 Consensus Example
|
||||||
|
|
||||||
|
**Input Votes:**
|
||||||
|
```
|
||||||
|
AI1 (Bull): BTC open_long (conf=80, lev=10, pos=0.3)
|
||||||
|
AI2 (Bear): BTC open_short (conf=60, lev=5, pos=0.2)
|
||||||
|
AI3 (Analyst): BTC open_long (conf=70, lev=8, pos=0.25)
|
||||||
|
```
|
||||||
|
|
||||||
|
**Calculation:**
|
||||||
|
```
|
||||||
|
open_long:
|
||||||
|
score = 0.80 + 0.70 = 1.50
|
||||||
|
avg_conf = (80 + 70) / 2 = 75
|
||||||
|
avg_lev = (10 + 8) / 2 = 9
|
||||||
|
avg_pos = (0.3 + 0.25) / 2 = 0.275
|
||||||
|
|
||||||
|
open_short:
|
||||||
|
score = 0.60
|
||||||
|
avg_conf = 60
|
||||||
|
avg_lev = 5
|
||||||
|
avg_pos = 0.2
|
||||||
|
|
||||||
|
Winner: open_long (score 1.50 > 0.60)
|
||||||
|
```
|
||||||
|
|
||||||
|
**Output:**
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"symbol": "BTCUSDT",
|
||||||
|
"action": "open_long",
|
||||||
|
"confidence": 75,
|
||||||
|
"leverage": 9,
|
||||||
|
"position_pct": 0.275,
|
||||||
|
"stop_loss": 0.03,
|
||||||
|
"take_profit": 0.06
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 6. Auto-Execution
|
||||||
|
|
||||||
|
### 6.1 Execution Flow
|
||||||
|
|
||||||
|
**Location:** `debate/engine.go:ExecuteConsensus()` (Lines 932-1052)
|
||||||
|
|
||||||
|
```
|
||||||
|
POST /api/debates/:id/execute
|
||||||
|
│
|
||||||
|
▼
|
||||||
|
┌─────────────────────────────────────────────────────────────┐
|
||||||
|
│ 1. Validate session status = completed │
|
||||||
|
│ 2. Validate final_decision exists and not executed │
|
||||||
|
│ 3. Validate action is open_long or open_short │
|
||||||
|
│ 4. Get current market price │
|
||||||
|
│ 5. Get account balance: │
|
||||||
|
│ - Try available_balance │
|
||||||
|
│ - Fallback to total_equity or wallet_balance │
|
||||||
|
│ 6. Calculate position size: │
|
||||||
|
│ position_size_usd = available_balance × position_pct │
|
||||||
|
│ (minimum $12 to meet exchange requirements) │
|
||||||
|
│ 7. Calculate stop loss and take profit prices: │
|
||||||
|
│ ┌───────────────────────────────────────────────────┐ │
|
||||||
|
│ │ open_long: │ │
|
||||||
|
│ │ SL = price × (1 - stop_loss_pct) │ │
|
||||||
|
│ │ TP = price × (1 + take_profit_pct) │ │
|
||||||
|
│ │ open_short: │ │
|
||||||
|
│ │ SL = price × (1 + stop_loss_pct) │ │
|
||||||
|
│ │ TP = price × (1 - take_profit_pct) │ │
|
||||||
|
│ └───────────────────────────────────────────────────┘ │
|
||||||
|
│ 8. Create Decision object │
|
||||||
|
│ 9. Call executor.ExecuteDecision() │
|
||||||
|
│ 10. Update final_decision: │
|
||||||
|
│ - executed = true/false │
|
||||||
|
│ - executed_at = timestamp │
|
||||||
|
│ - error message if failed │
|
||||||
|
└─────────────────────────────────────────────────────────────┘
|
||||||
|
```
|
||||||
|
|
||||||
|
### 6.2 Position Size Calculation
|
||||||
|
|
||||||
|
```go
|
||||||
|
// Calculate position value
|
||||||
|
position_size_usd := available_balance * position_pct
|
||||||
|
|
||||||
|
// Ensure minimum size for exchange
|
||||||
|
if position_size_usd < 12 {
|
||||||
|
position_size_usd = 12
|
||||||
|
}
|
||||||
|
|
||||||
|
// Calculate quantity
|
||||||
|
quantity := position_size_usd / market_price
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 7. API Reference
|
||||||
|
|
||||||
|
### 7.1 Endpoints
|
||||||
|
|
||||||
|
| Method | Path | Description |
|
||||||
|
|--------|------|-------------|
|
||||||
|
| GET | `/api/debates` | List all debates for user |
|
||||||
|
| GET | `/api/debates/personalities` | Get AI personality configs |
|
||||||
|
| GET | `/api/debates/:id` | Get debate with full details |
|
||||||
|
| POST | `/api/debates` | Create new debate |
|
||||||
|
| POST | `/api/debates/:id/start` | Start debate execution |
|
||||||
|
| POST | `/api/debates/:id/cancel` | Cancel running debate |
|
||||||
|
| POST | `/api/debates/:id/execute` | Execute consensus trade |
|
||||||
|
| DELETE | `/api/debates/:id` | Delete debate |
|
||||||
|
| GET | `/api/debates/:id/messages` | Get all messages |
|
||||||
|
| GET | `/api/debates/:id/votes` | Get all votes |
|
||||||
|
| GET | `/api/debates/:id/stream` | SSE live stream |
|
||||||
|
|
||||||
|
### 7.2 Create Debate Request
|
||||||
|
|
||||||
|
```json
|
||||||
|
POST /api/debates
|
||||||
|
{
|
||||||
|
"name": "BTC Market Debate",
|
||||||
|
"strategy_id": "strategy-uuid",
|
||||||
|
"symbol": "BTCUSDT",
|
||||||
|
"max_rounds": 3,
|
||||||
|
"interval_minutes": 5,
|
||||||
|
"prompt_variant": "balanced",
|
||||||
|
"auto_execute": false,
|
||||||
|
"trader_id": "trader-uuid",
|
||||||
|
"enable_oi_ranking": true,
|
||||||
|
"oi_ranking_limit": 10,
|
||||||
|
"oi_duration": "1h",
|
||||||
|
"participants": [
|
||||||
|
{"ai_model_id": "deepseek-v3", "personality": "bull"},
|
||||||
|
{"ai_model_id": "qwen-max", "personality": "bear"},
|
||||||
|
{"ai_model_id": "gpt-5.2", "personality": "analyst"}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### 7.3 Create Debate Response
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"id": "debate-uuid",
|
||||||
|
"user_id": "user-uuid",
|
||||||
|
"name": "BTC Market Debate",
|
||||||
|
"strategy_id": "strategy-uuid",
|
||||||
|
"status": "pending",
|
||||||
|
"symbol": "BTCUSDT",
|
||||||
|
"max_rounds": 3,
|
||||||
|
"current_round": 0,
|
||||||
|
"participants": [
|
||||||
|
{
|
||||||
|
"id": "participant-uuid",
|
||||||
|
"ai_model_id": "deepseek-v3",
|
||||||
|
"ai_model_name": "DeepSeek V3",
|
||||||
|
"provider": "deepseek",
|
||||||
|
"personality": "bull",
|
||||||
|
"color": "#22C55E",
|
||||||
|
"speak_order": 0
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"messages": [],
|
||||||
|
"votes": [],
|
||||||
|
"created_at": "2025-12-15T12:00:00Z"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### 7.4 Execute Consensus Request
|
||||||
|
|
||||||
|
```json
|
||||||
|
POST /api/debates/:id/execute
|
||||||
|
{
|
||||||
|
"trader_id": "trader-uuid"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 8. Real-Time Updates (SSE)
|
||||||
|
|
||||||
|
### 8.1 SSE Endpoint
|
||||||
|
|
||||||
|
**Location:** `api/debate.go:HandleDebateStream()` (Lines 407-453)
|
||||||
|
|
||||||
|
```
|
||||||
|
GET /api/debates/:id/stream
|
||||||
|
│
|
||||||
|
▼
|
||||||
|
┌─────────────────────────────────────────────────────────────┐
|
||||||
|
│ 1. Validate user ownership │
|
||||||
|
│ 2. Set SSE headers: │
|
||||||
|
│ Content-Type: text/event-stream │
|
||||||
|
│ Cache-Control: no-cache │
|
||||||
|
│ Connection: keep-alive │
|
||||||
|
│ 3. Send initial state │
|
||||||
|
│ 4. Subscribe to events │
|
||||||
|
│ 5. Stream updates until client disconnects │
|
||||||
|
└─────────────────────────────────────────────────────────────┘
|
||||||
|
```
|
||||||
|
|
||||||
|
### 8.2 Event Types
|
||||||
|
|
||||||
|
| Event | Trigger | Data |
|
||||||
|
|-------|---------|------|
|
||||||
|
| `initial` | Connection start | Full session state |
|
||||||
|
| `round_start` | Round begins | `{round, status}` |
|
||||||
|
| `message` | AI speaks | DebateMessage object |
|
||||||
|
| `round_end` | Round complete | `{round, status}` |
|
||||||
|
| `vote` | AI votes | DebateVote object |
|
||||||
|
| `consensus` | Debate complete | DebateDecision object |
|
||||||
|
| `error` | Error occurs | `{error: string}` |
|
||||||
|
|
||||||
|
### 8.3 SSE Message Format
|
||||||
|
|
||||||
|
```
|
||||||
|
event: message
|
||||||
|
data: {"id":"msg-uuid","session_id":"session-uuid","round":1,"ai_model_name":"DeepSeek V3","personality":"bull","content":"...","decision":{"action":"open_long","symbol":"BTCUSDT","confidence":75}}
|
||||||
|
|
||||||
|
event: vote
|
||||||
|
data: {"id":"vote-uuid","session_id":"session-uuid","ai_model_name":"DeepSeek V3","action":"open_long","symbol":"BTCUSDT","confidence":80,"reasoning":"..."}
|
||||||
|
|
||||||
|
event: consensus
|
||||||
|
data: {"action":"open_long","symbol":"BTCUSDT","confidence":75,"leverage":8,"position_pct":0.25,"stop_loss":0.03,"take_profit":0.06}
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 9. Database Schema
|
||||||
|
|
||||||
|
### 9.1 Tables
|
||||||
|
|
||||||
|
**debate_sessions:**
|
||||||
|
```sql
|
||||||
|
CREATE TABLE debate_sessions (
|
||||||
|
id TEXT PRIMARY KEY,
|
||||||
|
user_id TEXT NOT NULL,
|
||||||
|
name TEXT NOT NULL,
|
||||||
|
strategy_id TEXT NOT NULL,
|
||||||
|
status TEXT NOT NULL DEFAULT 'pending',
|
||||||
|
symbol TEXT NOT NULL,
|
||||||
|
max_rounds INTEGER DEFAULT 3,
|
||||||
|
current_round INTEGER DEFAULT 0,
|
||||||
|
interval_minutes INTEGER DEFAULT 5,
|
||||||
|
prompt_variant TEXT DEFAULT 'balanced',
|
||||||
|
final_decision TEXT,
|
||||||
|
final_decisions TEXT,
|
||||||
|
auto_execute BOOLEAN DEFAULT 0,
|
||||||
|
trader_id TEXT,
|
||||||
|
enable_oi_ranking BOOLEAN DEFAULT 0,
|
||||||
|
oi_ranking_limit INTEGER DEFAULT 10,
|
||||||
|
oi_duration TEXT DEFAULT '1h',
|
||||||
|
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
updated_at DATETIME DEFAULT CURRENT_TIMESTAMP
|
||||||
|
);
|
||||||
|
```
|
||||||
|
|
||||||
|
**debate_participants:**
|
||||||
|
```sql
|
||||||
|
CREATE TABLE debate_participants (
|
||||||
|
id TEXT PRIMARY KEY,
|
||||||
|
session_id TEXT NOT NULL,
|
||||||
|
ai_model_id TEXT NOT NULL,
|
||||||
|
ai_model_name TEXT NOT NULL,
|
||||||
|
provider TEXT NOT NULL,
|
||||||
|
personality TEXT NOT NULL,
|
||||||
|
color TEXT NOT NULL,
|
||||||
|
speak_order INTEGER DEFAULT 0,
|
||||||
|
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
FOREIGN KEY (session_id) REFERENCES debate_sessions(id) ON DELETE CASCADE
|
||||||
|
);
|
||||||
|
```
|
||||||
|
|
||||||
|
**debate_messages:**
|
||||||
|
```sql
|
||||||
|
CREATE TABLE debate_messages (
|
||||||
|
id TEXT PRIMARY KEY,
|
||||||
|
session_id TEXT NOT NULL,
|
||||||
|
round INTEGER NOT NULL,
|
||||||
|
ai_model_id TEXT NOT NULL,
|
||||||
|
ai_model_name TEXT NOT NULL,
|
||||||
|
provider TEXT NOT NULL,
|
||||||
|
personality TEXT NOT NULL,
|
||||||
|
message_type TEXT NOT NULL,
|
||||||
|
content TEXT NOT NULL,
|
||||||
|
decision TEXT,
|
||||||
|
decisions TEXT,
|
||||||
|
confidence INTEGER DEFAULT 0,
|
||||||
|
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
FOREIGN KEY (session_id) REFERENCES debate_sessions(id) ON DELETE CASCADE
|
||||||
|
);
|
||||||
|
```
|
||||||
|
|
||||||
|
**debate_votes:**
|
||||||
|
```sql
|
||||||
|
CREATE TABLE debate_votes (
|
||||||
|
id TEXT PRIMARY KEY,
|
||||||
|
session_id TEXT NOT NULL,
|
||||||
|
ai_model_id TEXT NOT NULL,
|
||||||
|
ai_model_name TEXT NOT NULL,
|
||||||
|
action TEXT NOT NULL,
|
||||||
|
symbol TEXT NOT NULL,
|
||||||
|
confidence INTEGER DEFAULT 0,
|
||||||
|
leverage INTEGER DEFAULT 5,
|
||||||
|
position_pct REAL DEFAULT 0.2,
|
||||||
|
stop_loss_pct REAL DEFAULT 0.03,
|
||||||
|
take_profit_pct REAL DEFAULT 0.06,
|
||||||
|
reasoning TEXT,
|
||||||
|
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
FOREIGN KEY (session_id) REFERENCES debate_sessions(id) ON DELETE CASCADE
|
||||||
|
);
|
||||||
|
```
|
||||||
|
|
||||||
|
### 9.2 Key Store Methods
|
||||||
|
|
||||||
|
| Method | Description |
|
||||||
|
|--------|-------------|
|
||||||
|
| `CreateSession()` | Create new debate session |
|
||||||
|
| `GetSession()` | Get session by ID |
|
||||||
|
| `GetSessionWithDetails()` | Get session with participants, messages, votes |
|
||||||
|
| `UpdateSessionStatus()` | Update session status |
|
||||||
|
| `UpdateSessionRound()` | Update current round |
|
||||||
|
| `UpdateSessionFinalDecisions()` | Store consensus decisions |
|
||||||
|
| `AddParticipant()` | Add AI participant |
|
||||||
|
| `AddMessage()` | Store debate message |
|
||||||
|
| `AddVote()` | Store final vote |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 10. Frontend Components
|
||||||
|
|
||||||
|
### 10.1 Page Structure
|
||||||
|
|
||||||
|
**Location:** `web/src/pages/DebateArenaPage.tsx`
|
||||||
|
|
||||||
|
```
|
||||||
|
DebateArenaPage
|
||||||
|
├── Left Sidebar (w-56)
|
||||||
|
│ ├── New Debate Button
|
||||||
|
│ ├── Debate Sessions List
|
||||||
|
│ │ └── SessionItem (status, name, timestamp)
|
||||||
|
│ └── Online Traders List
|
||||||
|
│ └── TraderItem (name, status, AI model)
|
||||||
|
│
|
||||||
|
├── Main Content
|
||||||
|
│ ├── Header Bar
|
||||||
|
│ │ ├── Session Info (name, status, symbol)
|
||||||
|
│ │ ├── Participants Avatars
|
||||||
|
│ │ └── Vote Summary
|
||||||
|
│ │
|
||||||
|
│ ├── Content Area (two-column)
|
||||||
|
│ │ ├── Left: Discussion Records
|
||||||
|
│ │ │ ├── Round Headers
|
||||||
|
│ │ │ └── MessageCards (expandable)
|
||||||
|
│ │ │
|
||||||
|
│ │ └── Right: Final Votes
|
||||||
|
│ │ └── VoteCards (action, confidence, reasoning)
|
||||||
|
│ │
|
||||||
|
│ └── Consensus Bar
|
||||||
|
│ ├── Final Decision Display
|
||||||
|
│ └── Execute Button (if auto_execute disabled)
|
||||||
|
│
|
||||||
|
└── Modals
|
||||||
|
├── CreateModal
|
||||||
|
│ ├── Name Input
|
||||||
|
│ ├── Strategy Selector
|
||||||
|
│ ├── Symbol Input (auto-filled)
|
||||||
|
│ ├── Max Rounds Selector
|
||||||
|
│ └── Participant Picker (AI model + personality)
|
||||||
|
│
|
||||||
|
└── ExecuteModal
|
||||||
|
└── Trader Selector
|
||||||
|
```
|
||||||
|
|
||||||
|
### 10.2 UI Components
|
||||||
|
|
||||||
|
**MessageCard:**
|
||||||
|
- Expandable message display
|
||||||
|
- Shows AI avatar, personality emoji, decision
|
||||||
|
- Parses reasoning/analysis sections from content
|
||||||
|
- Displays decision details (leverage, position, SL/TP)
|
||||||
|
- Supports multi-coin decisions
|
||||||
|
|
||||||
|
**VoteCard:**
|
||||||
|
- Confidence bar visualization
|
||||||
|
- Action indicator (long/short/hold/wait)
|
||||||
|
- Leverage and position size display
|
||||||
|
- Stop loss and take profit display
|
||||||
|
- Reasoning preview
|
||||||
|
|
||||||
|
### 10.3 Status Colors
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
const STATUS_COLOR = {
|
||||||
|
pending: 'bg-gray-500',
|
||||||
|
running: 'bg-blue-500 animate-pulse',
|
||||||
|
voting: 'bg-yellow-500 animate-pulse',
|
||||||
|
completed: 'bg-green-500',
|
||||||
|
cancelled: 'bg-red-500',
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### 10.4 Action Styling
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
const ACT = {
|
||||||
|
open_long: {
|
||||||
|
color: 'text-green-400',
|
||||||
|
bg: 'bg-green-500/20',
|
||||||
|
icon: <TrendingUp />,
|
||||||
|
label: 'LONG'
|
||||||
|
},
|
||||||
|
open_short: {
|
||||||
|
color: 'text-red-400',
|
||||||
|
bg: 'bg-red-500/20',
|
||||||
|
icon: <TrendingDown />,
|
||||||
|
label: 'SHORT'
|
||||||
|
},
|
||||||
|
hold: {
|
||||||
|
color: 'text-blue-400',
|
||||||
|
bg: 'bg-blue-500/20',
|
||||||
|
icon: <Minus />,
|
||||||
|
label: 'HOLD'
|
||||||
|
},
|
||||||
|
wait: {
|
||||||
|
color: 'text-gray-400',
|
||||||
|
bg: 'bg-gray-500/20',
|
||||||
|
icon: <Clock />,
|
||||||
|
label: 'WAIT'
|
||||||
|
},
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### 10.5 Personality Colors
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
const PERS = {
|
||||||
|
bull: { emoji: '🐂', color: '#22C55E', name: '多头', nameEn: 'Bull' },
|
||||||
|
bear: { emoji: '🐻', color: '#EF4444', name: '空头', nameEn: 'Bear' },
|
||||||
|
analyst: { emoji: '📊', color: '#3B82F6', name: '分析', nameEn: 'Analyst' },
|
||||||
|
contrarian: { emoji: '🔄', color: '#F59E0B', name: '逆势', nameEn: 'Contrarian' },
|
||||||
|
risk_manager: { emoji: '🛡️', color: '#8B5CF6', name: '风控', nameEn: 'Risk Mgr' },
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 11. Integration Points
|
||||||
|
|
||||||
|
### 11.1 Strategy System
|
||||||
|
|
||||||
|
Debate sessions depend on saved strategies for:
|
||||||
|
- **Coin source configuration:** static/pool/OI top
|
||||||
|
- **Market data indicators:** K-lines, timeframes, technical indicators
|
||||||
|
- **Risk control parameters:** leverage limits, position sizing
|
||||||
|
- **Custom prompts:** role definition, trading rules
|
||||||
|
|
||||||
|
### 11.2 AI Model System
|
||||||
|
|
||||||
|
Each participant requires:
|
||||||
|
- AI model configuration (provider, API key, custom URL)
|
||||||
|
- Supported providers: deepseek, qwen, openai, claude, gemini, grok, kimi
|
||||||
|
- Client initialization with timeout handling (60s per call)
|
||||||
|
|
||||||
|
### 11.3 Trader System
|
||||||
|
|
||||||
|
For auto-execution:
|
||||||
|
- Requires active trader with running status
|
||||||
|
- Trader must have valid exchange connection
|
||||||
|
- Executor interface: `ExecuteDecision()`, `GetBalance()`
|
||||||
|
|
||||||
|
### 11.4 Market Data
|
||||||
|
|
||||||
|
Market context building uses:
|
||||||
|
- Market data service (K-lines, OHLCV)
|
||||||
|
- Quantitative data (capital flow, position changes)
|
||||||
|
- OI ranking data (market-wide position changes)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 12. Error Handling
|
||||||
|
|
||||||
|
### 12.1 Cleanup on Startup
|
||||||
|
|
||||||
|
**Location:** `debate/engine.go:cleanupStaleDebates()` (Lines 58-71)
|
||||||
|
|
||||||
|
```go
|
||||||
|
// On server restart, cancel all running/voting debates
|
||||||
|
func cleanupStaleDebates() {
|
||||||
|
sessions := debateStore.ListAllSessions()
|
||||||
|
for _, session := range sessions {
|
||||||
|
if session.Status == running || session.Status == voting {
|
||||||
|
debateStore.UpdateSessionStatus(session.ID, cancelled)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### 12.2 AI Call Timeout
|
||||||
|
|
||||||
|
```go
|
||||||
|
// 60 seconds per participant response
|
||||||
|
select {
|
||||||
|
case res := <-resultCh:
|
||||||
|
response = res.response
|
||||||
|
case <-time.After(60 * time.Second):
|
||||||
|
return nil, fmt.Errorf("AI call timeout")
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### 12.3 Symbol Validation
|
||||||
|
|
||||||
|
```go
|
||||||
|
// Force all decisions to use session symbol if specified
|
||||||
|
if session.Symbol != "" {
|
||||||
|
for _, d := range decisions {
|
||||||
|
if d.Symbol == "" || d.Symbol != session.Symbol {
|
||||||
|
logger.Warnf("Fixing invalid symbol '%s' -> '%s'", d.Symbol, session.Symbol)
|
||||||
|
d.Symbol = session.Symbol
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### 12.4 Panic Recovery
|
||||||
|
|
||||||
|
```go
|
||||||
|
defer func() {
|
||||||
|
if r := recover(); r != nil {
|
||||||
|
logger.Errorf("Debate panic: %v", r)
|
||||||
|
debateStore.UpdateSessionStatus(sessionID, cancelled)
|
||||||
|
if OnError != nil {
|
||||||
|
OnError(sessionID, fmt.Errorf("panic: %v", r))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}()
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Summary
|
||||||
|
|
||||||
|
The Debate Arena module provides a sophisticated multi-AI collaborative decision system with:
|
||||||
|
|
||||||
|
- **Multi-Personality Debate:** 5 distinct AI personalities (Bull, Bear, Analyst, Contrarian, Risk Manager) with unique trading biases
|
||||||
|
- **Consensus Mechanism:** Weighted voting based on confidence levels to determine final decisions
|
||||||
|
- **Real-Time Updates:** SSE streaming for live debate progress
|
||||||
|
- **Auto-Execution:** Optional automatic trade execution based on consensus
|
||||||
|
- **Strategy Integration:** Deep integration with strategy configuration for market data and risk parameters
|
||||||
|
- **Multi-Coin Support:** Ability to analyze and decide on multiple coins simultaneously
|
||||||
|
|
||||||
|
The system enables users to leverage multiple AI perspectives for more robust trading decisions while maintaining full control over execution.
|
||||||
606
docs/architecture/DEBATE_MODULE.zh-CN.md
Normal file
606
docs/architecture/DEBATE_MODULE.zh-CN.md
Normal file
@@ -0,0 +1,606 @@
|
|||||||
|
# NOFX 辩论竞技场模块 - 技术文档
|
||||||
|
|
||||||
|
**语言:** [English](DEBATE_MODULE.md) | [中文](DEBATE_MODULE.zh-CN.md)
|
||||||
|
|
||||||
|
## 概述
|
||||||
|
|
||||||
|
辩论竞技场是一个多 AI 协作决策系统,多个具有不同性格的 AI 模型对市场状况进行辩论并达成交易决策共识。系统支持多轮辩论、实时流推送、投票机制和自动交易执行。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 1. 架构概览
|
||||||
|
|
||||||
|
```
|
||||||
|
┌─────────────────────────────────────────────────────────────────────────────┐
|
||||||
|
│ 辩论竞技场系统 │
|
||||||
|
├─────────────────────────────────────────────────────────────────────────────┤
|
||||||
|
│ │
|
||||||
|
│ ┌─────────────┐ ┌─────────────┐ ┌─────────────┐ ┌─────────────┐ │
|
||||||
|
│ │ 多头 AI │ │ 空头 AI │ │ 分析 AI │ │ 风控 AI │ │
|
||||||
|
│ │ 🐂 │ │ 🐻 │ │ 📊 │ │ 🛡️ │ │
|
||||||
|
│ └──────┬──────┘ └──────┬──────┘ └──────┬──────┘ └──────┬──────┘ │
|
||||||
|
│ │ │ │ │ │
|
||||||
|
│ └──────────────────┴──────────────────┴──────────────────┘ │
|
||||||
|
│ │ │
|
||||||
|
│ ┌─────────▼─────────┐ │
|
||||||
|
│ │ 辩论引擎 │ │
|
||||||
|
│ │ (debate/engine) │ │
|
||||||
|
│ └─────────┬─────────┘ │
|
||||||
|
│ │ │
|
||||||
|
│ ┌──────────────────────────┼──────────────────────────┐ │
|
||||||
|
│ │ │ │ │
|
||||||
|
│ ┌──────▼──────┐ ┌─────────▼─────────┐ ┌────────▼────────┐ │
|
||||||
|
│ │ 市场数据 │ │ 投票系统 │ │ 自动执行器 │ │
|
||||||
|
│ │ 组装 │ │ 与共识机制 │ │ (可选) │ │
|
||||||
|
│ └─────────────┘ └───────────────────┘ └─────────────────┘ │
|
||||||
|
│ │
|
||||||
|
└─────────────────────────────────────────────────────────────────────────────┘
|
||||||
|
```
|
||||||
|
|
||||||
|
### 文件结构
|
||||||
|
|
||||||
|
```
|
||||||
|
├── debate/
|
||||||
|
│ └── engine.go # 核心辩论引擎逻辑
|
||||||
|
├── api/
|
||||||
|
│ └── debate.go # HTTP 处理器和 SSE 流
|
||||||
|
├── store/
|
||||||
|
│ └── debate.go # 数据库操作和模式
|
||||||
|
└── web/src/pages/
|
||||||
|
└── DebateArenaPage.tsx # 前端 UI
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 2. 性格系统
|
||||||
|
|
||||||
|
### 2.1 可用性格
|
||||||
|
|
||||||
|
| 性格 | 图标 | 名称 | 描述 | 交易偏向 |
|
||||||
|
|------|------|------|------|----------|
|
||||||
|
| Bull | 🐂 | 激进多头 | 寻找做多机会 | 乐观,趋势跟随 |
|
||||||
|
| Bear | 🐻 | 谨慎空头 | 关注风险 | 悲观,做空偏向 |
|
||||||
|
| Analyst | 📊 | 数据分析师 | 纯数据驱动 | 无偏见,客观分析 |
|
||||||
|
| Contrarian | 🔄 | 逆势者 | 挑战多数观点 | 另类视角 |
|
||||||
|
| Risk Manager | 🛡️ | 风控经理 | 关注风险控制 | 仓位管理,止损 |
|
||||||
|
|
||||||
|
### 2.2 性格提示词增强
|
||||||
|
|
||||||
|
**文件位置:** `debate/engine.go:buildDebateSystemPrompt()` (365-426行)
|
||||||
|
|
||||||
|
```
|
||||||
|
## 辩论模式 - 第 {round}/{max_rounds} 轮
|
||||||
|
|
||||||
|
你作为 {emoji} {personality} 参与辩论。
|
||||||
|
|
||||||
|
### 你的辩论角色:
|
||||||
|
{personality_description}
|
||||||
|
|
||||||
|
### 辩论规则:
|
||||||
|
1. 分析所有候选币种
|
||||||
|
2. 用具体数据支持论点
|
||||||
|
3. 回应其他参与者 (第2轮起)
|
||||||
|
4. 有说服力但基于数据
|
||||||
|
5. 可以推荐多个不同操作的币种
|
||||||
|
|
||||||
|
### 输出格式 (严格 JSON):
|
||||||
|
<reasoning>
|
||||||
|
- 带数据引用的市场分析
|
||||||
|
- 主要交易论点
|
||||||
|
- 对他人的回应 (第2轮起)
|
||||||
|
</reasoning>
|
||||||
|
|
||||||
|
<decision>
|
||||||
|
[
|
||||||
|
{"symbol": "BTCUSDT", "action": "open_long", "confidence": 75, ...},
|
||||||
|
{"symbol": "ETHUSDT", "action": "open_short", "confidence": 80, ...}
|
||||||
|
]
|
||||||
|
</decision>
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 3. 辩论执行流程
|
||||||
|
|
||||||
|
### 3.1 会话创建
|
||||||
|
|
||||||
|
```
|
||||||
|
POST /api/debates
|
||||||
|
│
|
||||||
|
▼
|
||||||
|
┌─────────────────────────────────────────────────────────────┐
|
||||||
|
│ 1. 验证用户认证 │
|
||||||
|
│ 2. 解析 CreateDebateRequest: │
|
||||||
|
│ - name, strategy_id, symbol, max_rounds, participants │
|
||||||
|
│ - interval_minutes, prompt_variant, auto_execute │
|
||||||
|
│ 3. 验证策略所有权 │
|
||||||
|
│ 4. 自动选择币种 (如未提供): │
|
||||||
|
│ - 静态币种 → 使用策略第一个币种 │
|
||||||
|
│ - CoinPool → 从 AI500 API 获取 │
|
||||||
|
│ - OI Top → 从 OI 排行 API 获取 │
|
||||||
|
│ - Mixed → 先尝试池,回退到 OI │
|
||||||
|
│ 5. 设置默认值: │
|
||||||
|
│ - max_rounds: 3 (范围 2-5) │
|
||||||
|
│ - interval_minutes: 5 │
|
||||||
|
│ - prompt_variant: "balanced" │
|
||||||
|
│ 6. 在数据库创建 DebateSession │
|
||||||
|
│ 7. 添加带 AI 模型和性格的参与者 │
|
||||||
|
│ 8. 返回完整会话及参与者 │
|
||||||
|
└─────────────────────────────────────────────────────────────┘
|
||||||
|
```
|
||||||
|
|
||||||
|
### 3.2 辩论轮次执行
|
||||||
|
|
||||||
|
**文件位置:** `debate/engine.go:runDebate()` (157-289行)
|
||||||
|
|
||||||
|
```
|
||||||
|
┌─────────────────────────────────────────────────────────────┐
|
||||||
|
│ 每轮 (1 到 max_rounds): │
|
||||||
|
│ ┌─────────────────────────────────────────────────────────┐ │
|
||||||
|
│ │ 1. 广播 "round_start" 事件 │ │
|
||||||
|
│ │ 2. 每个参与者 (按 speak_order): │ │
|
||||||
|
│ │ ┌─────────────────────────────────────────────────┐ │ │
|
||||||
|
│ │ │ a. 构建性格增强的系统提示词 │ │ │
|
||||||
|
│ │ │ b. 构建用户提示词: │ │ │
|
||||||
|
│ │ │ - 市场数据 (来自策略引擎) │ │ │
|
||||||
|
│ │ │ - 之前的辩论消息 (第2轮起) │ │ │
|
||||||
|
│ │ │ c. 调用 AI 模型,60秒超时 │ │ │
|
||||||
|
│ │ │ d. 从响应解析多币种决策 │ │ │
|
||||||
|
│ │ │ e. 保存消息到数据库 │ │ │
|
||||||
|
│ │ │ f. 广播 "message" 事件 │ │ │
|
||||||
|
│ │ └─────────────────────────────────────────────────┘ │ │
|
||||||
|
│ │ 3. 广播 "round_end" 事件 │ │
|
||||||
|
│ └─────────────────────────────────────────────────────────┘ │
|
||||||
|
│ │
|
||||||
|
│ 所有轮次后: │
|
||||||
|
│ ┌─────────────────────────────────────────────────────────┐ │
|
||||||
|
│ │ 1. 进入投票阶段 (status = "voting") │ │
|
||||||
|
│ │ 2. 收集所有参与者的最终投票 │ │
|
||||||
|
│ │ 3. 确定多币种共识 │ │
|
||||||
|
│ │ 4. 存储最终决策 │ │
|
||||||
|
│ │ 5. 更新状态为 "completed" │ │
|
||||||
|
│ │ 6. 广播 "consensus" 事件 │ │
|
||||||
|
│ └─────────────────────────────────────────────────────────┘ │
|
||||||
|
└─────────────────────────────────────────────────────────────┘
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 4. 共识算法
|
||||||
|
|
||||||
|
### 4.1 投票收集
|
||||||
|
|
||||||
|
**文件位置:** `debate/engine.go:collectVotes()` (542-567行)
|
||||||
|
|
||||||
|
```
|
||||||
|
每个参与者:
|
||||||
|
┌─────────────────────────────────────────────────────────────┐
|
||||||
|
│ 1. 构建投票系统提示词 │
|
||||||
|
│ 2. 构建带辩论摘要的投票用户提示词 │
|
||||||
|
│ 3. 调用 AI 模型获取最终投票 │
|
||||||
|
│ 4. 解析多币种决策 │
|
||||||
|
│ 5. 验证/修复币种与 session.Symbol 一致 │
|
||||||
|
│ 6. 保存投票到数据库 │
|
||||||
|
│ 7. 广播 "vote" 事件 │
|
||||||
|
└─────────────────────────────────────────────────────────────┘
|
||||||
|
```
|
||||||
|
|
||||||
|
### 4.2 多币种共识确定
|
||||||
|
|
||||||
|
**文件位置:** `debate/engine.go:determineMultiCoinConsensus()` (752-924行)
|
||||||
|
|
||||||
|
**算法:**
|
||||||
|
|
||||||
|
```
|
||||||
|
1. 收集所有投票中的所有币种决策
|
||||||
|
2. 按 symbol → action → 聚合数据 分组
|
||||||
|
|
||||||
|
3. 对每个投票决策:
|
||||||
|
weight = confidence / 100.0
|
||||||
|
累加:
|
||||||
|
┌─────────────────────────────────────────────────────────┐
|
||||||
|
│ score += weight │
|
||||||
|
│ total_confidence += confidence │
|
||||||
|
│ total_leverage += leverage │
|
||||||
|
│ total_position_pct += position_pct │
|
||||||
|
│ total_stop_loss += stop_loss │
|
||||||
|
│ total_take_profit += take_profit │
|
||||||
|
│ count++ │
|
||||||
|
└─────────────────────────────────────────────────────────┘
|
||||||
|
|
||||||
|
4. 对每个币种:
|
||||||
|
找到胜出操作 (最高 score)
|
||||||
|
计算平均值:
|
||||||
|
┌─────────────────────────────────────────────────────────┐
|
||||||
|
│ avg_confidence = total_confidence / count │
|
||||||
|
│ avg_leverage = clamp(total_leverage / count, 1, 20) │
|
||||||
|
│ avg_position_pct = clamp(total_pct / count, 0.1, 1.0) │
|
||||||
|
│ avg_stop_loss = 默认 3% (如未设置) │
|
||||||
|
│ avg_take_profit = 默认 6% (如未设置) │
|
||||||
|
└─────────────────────────────────────────────────────────┘
|
||||||
|
|
||||||
|
5. 返回共识决策数组
|
||||||
|
```
|
||||||
|
|
||||||
|
### 4.3 共识示例
|
||||||
|
|
||||||
|
**输入投票:**
|
||||||
|
```
|
||||||
|
AI1 (多头): BTC open_long (conf=80, lev=10, pos=0.3)
|
||||||
|
AI2 (空头): BTC open_short (conf=60, lev=5, pos=0.2)
|
||||||
|
AI3 (分析): BTC open_long (conf=70, lev=8, pos=0.25)
|
||||||
|
```
|
||||||
|
|
||||||
|
**计算:**
|
||||||
|
```
|
||||||
|
open_long:
|
||||||
|
score = 0.80 + 0.70 = 1.50
|
||||||
|
avg_conf = (80 + 70) / 2 = 75
|
||||||
|
avg_lev = (10 + 8) / 2 = 9
|
||||||
|
avg_pos = (0.3 + 0.25) / 2 = 0.275
|
||||||
|
|
||||||
|
open_short:
|
||||||
|
score = 0.60
|
||||||
|
avg_conf = 60
|
||||||
|
avg_lev = 5
|
||||||
|
avg_pos = 0.2
|
||||||
|
|
||||||
|
胜出: open_long (score 1.50 > 0.60)
|
||||||
|
```
|
||||||
|
|
||||||
|
**输出:**
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"symbol": "BTCUSDT",
|
||||||
|
"action": "open_long",
|
||||||
|
"confidence": 75,
|
||||||
|
"leverage": 9,
|
||||||
|
"position_pct": 0.275,
|
||||||
|
"stop_loss": 0.03,
|
||||||
|
"take_profit": 0.06
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 5. 自动执行
|
||||||
|
|
||||||
|
### 5.1 执行流程
|
||||||
|
|
||||||
|
**文件位置:** `debate/engine.go:ExecuteConsensus()` (932-1052行)
|
||||||
|
|
||||||
|
```
|
||||||
|
POST /api/debates/:id/execute
|
||||||
|
│
|
||||||
|
▼
|
||||||
|
┌─────────────────────────────────────────────────────────────┐
|
||||||
|
│ 1. 验证会话状态 = completed │
|
||||||
|
│ 2. 验证 final_decision 存在且未执行 │
|
||||||
|
│ 3. 验证操作是 open_long 或 open_short │
|
||||||
|
│ 4. 获取当前市场价格 │
|
||||||
|
│ 5. 获取账户余额: │
|
||||||
|
│ - 尝试 available_balance │
|
||||||
|
│ - 回退到 total_equity 或 wallet_balance │
|
||||||
|
│ 6. 计算仓位大小: │
|
||||||
|
│ position_size_usd = available_balance × position_pct │
|
||||||
|
│ (最小 $12 以满足交易所要求) │
|
||||||
|
│ 7. 计算止损和止盈价格: │
|
||||||
|
│ ┌───────────────────────────────────────────────────┐ │
|
||||||
|
│ │ open_long: │ │
|
||||||
|
│ │ SL = price × (1 - stop_loss_pct) │ │
|
||||||
|
│ │ TP = price × (1 + take_profit_pct) │ │
|
||||||
|
│ │ open_short: │ │
|
||||||
|
│ │ SL = price × (1 + stop_loss_pct) │ │
|
||||||
|
│ │ TP = price × (1 - take_profit_pct) │ │
|
||||||
|
│ └───────────────────────────────────────────────────┘ │
|
||||||
|
│ 8. 创建 Decision 对象 │
|
||||||
|
│ 9. 调用 executor.ExecuteDecision() │
|
||||||
|
│ 10. 更新 final_decision: │
|
||||||
|
│ - executed = true/false │
|
||||||
|
│ - executed_at = 时间戳 │
|
||||||
|
│ - error 消息 (如失败) │
|
||||||
|
└─────────────────────────────────────────────────────────────┘
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 6. API 接口
|
||||||
|
|
||||||
|
### 6.1 接口列表
|
||||||
|
|
||||||
|
| 接口 | 方法 | 描述 |
|
||||||
|
|------|------|------|
|
||||||
|
| `/api/debates` | GET | 列出用户所有辩论 |
|
||||||
|
| `/api/debates/personalities` | GET | 获取 AI 性格配置 |
|
||||||
|
| `/api/debates/:id` | GET | 获取辩论详情 |
|
||||||
|
| `/api/debates` | POST | 创建新辩论 |
|
||||||
|
| `/api/debates/:id/start` | POST | 开始辩论执行 |
|
||||||
|
| `/api/debates/:id/cancel` | POST | 取消运行中的辩论 |
|
||||||
|
| `/api/debates/:id/execute` | POST | 执行共识交易 |
|
||||||
|
| `/api/debates/:id` | DELETE | 删除辩论 |
|
||||||
|
| `/api/debates/:id/messages` | GET | 获取所有消息 |
|
||||||
|
| `/api/debates/:id/votes` | GET | 获取所有投票 |
|
||||||
|
| `/api/debates/:id/stream` | GET | SSE 实时流 |
|
||||||
|
|
||||||
|
### 6.2 创建辩论请求
|
||||||
|
|
||||||
|
```json
|
||||||
|
POST /api/debates
|
||||||
|
{
|
||||||
|
"name": "BTC 市场辩论",
|
||||||
|
"strategy_id": "strategy-uuid",
|
||||||
|
"symbol": "BTCUSDT",
|
||||||
|
"max_rounds": 3,
|
||||||
|
"interval_minutes": 5,
|
||||||
|
"prompt_variant": "balanced",
|
||||||
|
"auto_execute": false,
|
||||||
|
"trader_id": "trader-uuid",
|
||||||
|
"enable_oi_ranking": true,
|
||||||
|
"oi_ranking_limit": 10,
|
||||||
|
"oi_duration": "1h",
|
||||||
|
"participants": [
|
||||||
|
{"ai_model_id": "deepseek-v3", "personality": "bull"},
|
||||||
|
{"ai_model_id": "qwen-max", "personality": "bear"},
|
||||||
|
{"ai_model_id": "gpt-5.2", "personality": "analyst"}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 7. 实时更新 (SSE)
|
||||||
|
|
||||||
|
### 7.1 SSE 接口
|
||||||
|
|
||||||
|
**文件位置:** `api/debate.go:HandleDebateStream()` (407-453行)
|
||||||
|
|
||||||
|
```
|
||||||
|
GET /api/debates/:id/stream
|
||||||
|
│
|
||||||
|
▼
|
||||||
|
┌─────────────────────────────────────────────────────────────┐
|
||||||
|
│ 1. 验证用户所有权 │
|
||||||
|
│ 2. 设置 SSE 头: │
|
||||||
|
│ Content-Type: text/event-stream │
|
||||||
|
│ Cache-Control: no-cache │
|
||||||
|
│ Connection: keep-alive │
|
||||||
|
│ 3. 发送初始状态 │
|
||||||
|
│ 4. 订阅事件 │
|
||||||
|
│ 5. 流式推送更新直到客户端断开 │
|
||||||
|
└─────────────────────────────────────────────────────────────┘
|
||||||
|
```
|
||||||
|
|
||||||
|
### 7.2 事件类型
|
||||||
|
|
||||||
|
| 事件 | 触发时机 | 数据 |
|
||||||
|
|------|----------|------|
|
||||||
|
| `initial` | 连接开始 | 完整会话状态 |
|
||||||
|
| `round_start` | 轮次开始 | `{round, status}` |
|
||||||
|
| `message` | AI 发言 | DebateMessage 对象 |
|
||||||
|
| `round_end` | 轮次结束 | `{round, status}` |
|
||||||
|
| `vote` | AI 投票 | DebateVote 对象 |
|
||||||
|
| `consensus` | 辩论完成 | DebateDecision 对象 |
|
||||||
|
| `error` | 发生错误 | `{error: string}` |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 8. 数据库模式
|
||||||
|
|
||||||
|
### 8.1 表结构
|
||||||
|
|
||||||
|
**debate_sessions:**
|
||||||
|
```sql
|
||||||
|
CREATE TABLE debate_sessions (
|
||||||
|
id TEXT PRIMARY KEY,
|
||||||
|
user_id TEXT NOT NULL,
|
||||||
|
name TEXT NOT NULL,
|
||||||
|
strategy_id TEXT NOT NULL,
|
||||||
|
status TEXT NOT NULL DEFAULT 'pending',
|
||||||
|
symbol TEXT NOT NULL,
|
||||||
|
max_rounds INTEGER DEFAULT 3,
|
||||||
|
current_round INTEGER DEFAULT 0,
|
||||||
|
interval_minutes INTEGER DEFAULT 5,
|
||||||
|
prompt_variant TEXT DEFAULT 'balanced',
|
||||||
|
final_decision TEXT,
|
||||||
|
final_decisions TEXT,
|
||||||
|
auto_execute BOOLEAN DEFAULT 0,
|
||||||
|
trader_id TEXT,
|
||||||
|
enable_oi_ranking BOOLEAN DEFAULT 0,
|
||||||
|
oi_ranking_limit INTEGER DEFAULT 10,
|
||||||
|
oi_duration TEXT DEFAULT '1h',
|
||||||
|
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
updated_at DATETIME DEFAULT CURRENT_TIMESTAMP
|
||||||
|
);
|
||||||
|
```
|
||||||
|
|
||||||
|
**debate_participants:**
|
||||||
|
```sql
|
||||||
|
CREATE TABLE debate_participants (
|
||||||
|
id TEXT PRIMARY KEY,
|
||||||
|
session_id TEXT NOT NULL,
|
||||||
|
ai_model_id TEXT NOT NULL,
|
||||||
|
ai_model_name TEXT NOT NULL,
|
||||||
|
provider TEXT NOT NULL,
|
||||||
|
personality TEXT NOT NULL,
|
||||||
|
color TEXT NOT NULL,
|
||||||
|
speak_order INTEGER DEFAULT 0,
|
||||||
|
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
FOREIGN KEY (session_id) REFERENCES debate_sessions(id) ON DELETE CASCADE
|
||||||
|
);
|
||||||
|
```
|
||||||
|
|
||||||
|
**debate_messages:**
|
||||||
|
```sql
|
||||||
|
CREATE TABLE debate_messages (
|
||||||
|
id TEXT PRIMARY KEY,
|
||||||
|
session_id TEXT NOT NULL,
|
||||||
|
round INTEGER NOT NULL,
|
||||||
|
ai_model_id TEXT NOT NULL,
|
||||||
|
ai_model_name TEXT NOT NULL,
|
||||||
|
provider TEXT NOT NULL,
|
||||||
|
personality TEXT NOT NULL,
|
||||||
|
message_type TEXT NOT NULL,
|
||||||
|
content TEXT NOT NULL,
|
||||||
|
decision TEXT,
|
||||||
|
decisions TEXT,
|
||||||
|
confidence INTEGER DEFAULT 0,
|
||||||
|
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
FOREIGN KEY (session_id) REFERENCES debate_sessions(id) ON DELETE CASCADE
|
||||||
|
);
|
||||||
|
```
|
||||||
|
|
||||||
|
**debate_votes:**
|
||||||
|
```sql
|
||||||
|
CREATE TABLE debate_votes (
|
||||||
|
id TEXT PRIMARY KEY,
|
||||||
|
session_id TEXT NOT NULL,
|
||||||
|
ai_model_id TEXT NOT NULL,
|
||||||
|
ai_model_name TEXT NOT NULL,
|
||||||
|
action TEXT NOT NULL,
|
||||||
|
symbol TEXT NOT NULL,
|
||||||
|
confidence INTEGER DEFAULT 0,
|
||||||
|
leverage INTEGER DEFAULT 5,
|
||||||
|
position_pct REAL DEFAULT 0.2,
|
||||||
|
stop_loss_pct REAL DEFAULT 0.03,
|
||||||
|
take_profit_pct REAL DEFAULT 0.06,
|
||||||
|
reasoning TEXT,
|
||||||
|
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
FOREIGN KEY (session_id) REFERENCES debate_sessions(id) ON DELETE CASCADE
|
||||||
|
);
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 9. 前端组件
|
||||||
|
|
||||||
|
### 9.1 页面结构
|
||||||
|
|
||||||
|
**文件位置:** `web/src/pages/DebateArenaPage.tsx`
|
||||||
|
|
||||||
|
```
|
||||||
|
DebateArenaPage
|
||||||
|
├── 左侧边栏 (w-56)
|
||||||
|
│ ├── 新建辩论按钮
|
||||||
|
│ ├── 辩论会话列表
|
||||||
|
│ │ └── SessionItem (状态, 名称, 时间戳)
|
||||||
|
│ └── 在线交易员列表
|
||||||
|
│ └── TraderItem (名称, 状态, AI 模型)
|
||||||
|
│
|
||||||
|
├── 主内容区
|
||||||
|
│ ├── 头部栏
|
||||||
|
│ │ ├── 会话信息 (名称, 状态, 币种)
|
||||||
|
│ │ ├── 参与者头像
|
||||||
|
│ │ └── 投票摘要
|
||||||
|
│ │
|
||||||
|
│ ├── 内容区 (双栏)
|
||||||
|
│ │ ├── 左: 讨论记录
|
||||||
|
│ │ │ ├── 轮次标题
|
||||||
|
│ │ │ └── MessageCards (可展开)
|
||||||
|
│ │ │
|
||||||
|
│ │ └── 右: 最终投票
|
||||||
|
│ │ └── VoteCards (操作, 置信度, 理由)
|
||||||
|
│ │
|
||||||
|
│ └── 共识栏
|
||||||
|
│ ├── 最终决策显示
|
||||||
|
│ └── 执行按钮 (如果 auto_execute 禁用)
|
||||||
|
│
|
||||||
|
└── 弹窗
|
||||||
|
├── CreateModal
|
||||||
|
│ ├── 名称输入
|
||||||
|
│ ├── 策略选择器
|
||||||
|
│ ├── 币种输入 (自动填充)
|
||||||
|
│ ├── 最大轮数选择器
|
||||||
|
│ └── 参与者选择器 (AI 模型 + 性格)
|
||||||
|
│
|
||||||
|
└── ExecuteModal
|
||||||
|
└── 交易员选择器
|
||||||
|
```
|
||||||
|
|
||||||
|
### 9.2 状态颜色
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
const STATUS_COLOR = {
|
||||||
|
pending: 'bg-gray-500',
|
||||||
|
running: 'bg-blue-500 animate-pulse',
|
||||||
|
voting: 'bg-yellow-500 animate-pulse',
|
||||||
|
completed: 'bg-green-500',
|
||||||
|
cancelled: 'bg-red-500',
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### 9.3 操作样式
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
const ACT = {
|
||||||
|
open_long: {
|
||||||
|
color: 'text-green-400',
|
||||||
|
bg: 'bg-green-500/20',
|
||||||
|
icon: <TrendingUp />,
|
||||||
|
label: 'LONG'
|
||||||
|
},
|
||||||
|
open_short: {
|
||||||
|
color: 'text-red-400',
|
||||||
|
bg: 'bg-red-500/20',
|
||||||
|
icon: <TrendingDown />,
|
||||||
|
label: 'SHORT'
|
||||||
|
},
|
||||||
|
hold: {
|
||||||
|
color: 'text-blue-400',
|
||||||
|
bg: 'bg-blue-500/20',
|
||||||
|
icon: <Minus />,
|
||||||
|
label: 'HOLD'
|
||||||
|
},
|
||||||
|
wait: {
|
||||||
|
color: 'text-gray-400',
|
||||||
|
bg: 'bg-gray-500/20',
|
||||||
|
icon: <Clock />,
|
||||||
|
label: 'WAIT'
|
||||||
|
},
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 10. 集成点
|
||||||
|
|
||||||
|
### 10.1 策略系统
|
||||||
|
|
||||||
|
辩论会话依赖保存的策略:
|
||||||
|
- **币种来源配置:** static/pool/OI top
|
||||||
|
- **市场数据指标:** K线、时间周期、技术指标
|
||||||
|
- **风控参数:** 杠杆限制、仓位大小
|
||||||
|
- **自定义提示词:** 角色定义、交易规则
|
||||||
|
|
||||||
|
### 10.2 AI 模型系统
|
||||||
|
|
||||||
|
每个参与者需要:
|
||||||
|
- AI 模型配置 (provider, API key, 自定义 URL)
|
||||||
|
- 支持的 providers: deepseek, qwen, openai, claude, gemini, grok, kimi
|
||||||
|
- 客户端初始化带超时处理 (每次调用 60s)
|
||||||
|
|
||||||
|
### 10.3 交易员系统
|
||||||
|
|
||||||
|
自动执行需要:
|
||||||
|
- 运行中状态的活跃交易员
|
||||||
|
- 交易员必须有有效的交易所连接
|
||||||
|
- 执行器接口: `ExecuteDecision()`, `GetBalance()`
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 总结
|
||||||
|
|
||||||
|
辩论竞技场模块提供了一个复杂的多 AI 协作决策系统:
|
||||||
|
|
||||||
|
- **多性格辩论:** 5 种独特的 AI 性格 (多头、空头、分析师、逆势者、风控经理),具有独特的交易偏向
|
||||||
|
- **共识机制:** 基于置信度的加权投票来确定最终决策
|
||||||
|
- **实时更新:** SSE 流推送实时辩论进度
|
||||||
|
- **自动执行:** 可选的基于共识的自动交易执行
|
||||||
|
- **策略集成:** 与策略配置深度集成,用于市场数据和风控参数
|
||||||
|
- **多币种支持:** 能够同时分析和决策多个币种
|
||||||
|
|
||||||
|
该系统使用户能够利用多个 AI 视角做出更稳健的交易决策,同时保持对执行的完全控制。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
**文档版本:** 1.0.0
|
||||||
|
**最后更新:** 2025-01-15
|
||||||
@@ -24,12 +24,12 @@ NOFX is a full-stack AI trading platform for cryptocurrency and US stock markets
|
|||||||
│ NOFX Platform │
|
│ NOFX Platform │
|
||||||
├─────────────────────────────────────────────────────────────────────────────┤
|
├─────────────────────────────────────────────────────────────────────────────┤
|
||||||
│ │
|
│ │
|
||||||
│ ┌─────────────┐ ┌─────────────────────────────────────┐│
|
│ ┌─────────────┐ ┌─────────────┐ ┌─────────────┐ ┌─────────────────────┐│
|
||||||
│ │ Strategy │ │ Live Trading ││
|
│ │ Strategy │ │ Backtest │ │ Debate │ │ Live Trading ││
|
||||||
│ │ Studio │ │ (Auto Trader) ││
|
│ │ Studio │ │ Engine │ │ Arena │ │ (Auto Trader) ││
|
||||||
│ └──────┬──────┘ └──────────────────┬──────────────────┘│
|
│ └──────┬──────┘ └──────┬──────┘ └──────┬──────┘ └──────────┬──────────┘│
|
||||||
│ │ │ │
|
│ │ │ │ │ │
|
||||||
│ └────────────────────────────┘ │
|
│ └────────────────┴────────────────┴────────────────────┘ │
|
||||||
│ │ │
|
│ │ │
|
||||||
│ ┌─────────▼─────────┐ │
|
│ ┌─────────▼─────────┐ │
|
||||||
│ │ Core Services │ │
|
│ │ Core Services │ │
|
||||||
@@ -57,6 +57,8 @@ NOFX is a full-stack AI trading platform for cryptocurrency and US stock markets
|
|||||||
| Module | Description | Documentation |
|
| Module | Description | Documentation |
|
||||||
|--------|-------------|---------------|
|
|--------|-------------|---------------|
|
||||||
| **Strategy Studio** | Strategy configuration, coin selection, data assembly, AI prompts | [STRATEGY_MODULE.md](STRATEGY_MODULE.md) |
|
| **Strategy Studio** | Strategy configuration, coin selection, data assembly, AI prompts | [STRATEGY_MODULE.md](STRATEGY_MODULE.md) |
|
||||||
|
| **Backtest Engine** | Historical simulation, performance metrics, AI decision replay | [BACKTEST_MODULE.md](BACKTEST_MODULE.md) |
|
||||||
|
| **Debate Arena** | Multi-AI collaborative decision making with voting consensus | [DEBATE_MODULE.md](DEBATE_MODULE.md) |
|
||||||
|
|
||||||
### Module Overview
|
### Module Overview
|
||||||
|
|
||||||
@@ -70,6 +72,26 @@ Complete strategy configuration system including:
|
|||||||
|
|
||||||
**[Read Full Documentation →](STRATEGY_MODULE.md)**
|
**[Read Full Documentation →](STRATEGY_MODULE.md)**
|
||||||
|
|
||||||
|
#### Backtest Module
|
||||||
|
Historical trading simulation engine:
|
||||||
|
- Multi-symbol, multi-timeframe backtesting
|
||||||
|
- AI decision replay with caching
|
||||||
|
- Performance metrics (Sharpe, drawdown, win rate)
|
||||||
|
- Real-time progress streaming via SSE
|
||||||
|
- Checkpoint and resume support
|
||||||
|
|
||||||
|
**[Read Full Documentation →](BACKTEST_MODULE.md)**
|
||||||
|
|
||||||
|
#### Debate Module
|
||||||
|
Multi-AI collaborative decision system:
|
||||||
|
- 5 AI personalities (Bull, Bear, Analyst, Contrarian, Risk Manager)
|
||||||
|
- Multi-round debate with market context
|
||||||
|
- Weighted voting and consensus algorithm
|
||||||
|
- Auto-execution to live trading
|
||||||
|
- Real-time SSE streaming
|
||||||
|
|
||||||
|
**[Read Full Documentation →](DEBATE_MODULE.md)**
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## Project Structure
|
## Project Structure
|
||||||
@@ -80,6 +102,8 @@ nofx/
|
|||||||
├── api/ # HTTP API (Gin framework)
|
├── api/ # HTTP API (Gin framework)
|
||||||
├── trader/ # Trading execution layer
|
├── trader/ # Trading execution layer
|
||||||
├── strategy/ # Strategy engine
|
├── strategy/ # Strategy engine
|
||||||
|
├── backtest/ # Backtest simulation engine
|
||||||
|
├── debate/ # Debate arena engine
|
||||||
├── market/ # Market data service
|
├── market/ # Market data service
|
||||||
├── mcp/ # AI model clients
|
├── mcp/ # AI model clients
|
||||||
├── store/ # Database operations
|
├── store/ # Database operations
|
||||||
@@ -119,6 +143,8 @@ nofx/
|
|||||||
## Quick Links
|
## Quick Links
|
||||||
|
|
||||||
- [Strategy Module](STRATEGY_MODULE.md) - How strategies work
|
- [Strategy Module](STRATEGY_MODULE.md) - How strategies work
|
||||||
|
- [Backtest Module](BACKTEST_MODULE.md) - How backtesting works
|
||||||
|
- [Debate Module](DEBATE_MODULE.md) - How AI debates work
|
||||||
- [Getting Started](../getting-started/README.md) - Setup guide
|
- [Getting Started](../getting-started/README.md) - Setup guide
|
||||||
- [FAQ](../faq/README.md) - Frequently asked questions
|
- [FAQ](../faq/README.md) - Frequently asked questions
|
||||||
|
|
||||||
|
|||||||
@@ -24,12 +24,12 @@ NOFX 是一个支持加密货币和美股市场的全栈 AI 交易平台:
|
|||||||
│ NOFX 平台 │
|
│ NOFX 平台 │
|
||||||
├─────────────────────────────────────────────────────────────────────────────┤
|
├─────────────────────────────────────────────────────────────────────────────┤
|
||||||
│ │
|
│ │
|
||||||
│ ┌─────────────┐ ┌─────────────────────────────────────┐│
|
│ ┌─────────────┐ ┌─────────────┐ ┌─────────────┐ ┌─────────────────────┐│
|
||||||
│ │ 策略 │ │ 实盘交易 ││
|
│ │ 策略 │ │ 回测 │ │ 辩论 │ │ 实盘交易 ││
|
||||||
│ │ 工作室 │ │ (自动交易员) ││
|
│ │ 工作室 │ │ 引擎 │ │ 竞技场 │ │ (自动交易员) ││
|
||||||
│ └──────┬──────┘ └──────────────────┬──────────────────┘│
|
│ └──────┬──────┘ └──────┬──────┘ └──────┬──────┘ └──────────┬──────────┘│
|
||||||
│ │ │ │
|
│ │ │ │ │ │
|
||||||
│ └────────────────────────────┘ │
|
│ └────────────────┴────────────────┴────────────────────┘ │
|
||||||
│ │ │
|
│ │ │
|
||||||
│ ┌─────────▼─────────┐ │
|
│ ┌─────────▼─────────┐ │
|
||||||
│ │ 核心服务 │ │
|
│ │ 核心服务 │ │
|
||||||
@@ -57,6 +57,8 @@ NOFX 是一个支持加密货币和美股市场的全栈 AI 交易平台:
|
|||||||
| 模块 | 描述 | 文档 |
|
| 模块 | 描述 | 文档 |
|
||||||
|------|------|------|
|
|------|------|------|
|
||||||
| **策略工作室** | 策略配置、币种选择、数据组装、AI 提示词 | [STRATEGY_MODULE.md](STRATEGY_MODULE.md) |
|
| **策略工作室** | 策略配置、币种选择、数据组装、AI 提示词 | [STRATEGY_MODULE.md](STRATEGY_MODULE.md) |
|
||||||
|
| **回测引擎** | 历史模拟、性能指标、AI 决策回放 | [BACKTEST_MODULE.md](BACKTEST_MODULE.md) |
|
||||||
|
| **辩论竞技场** | 多 AI 协作决策,投票共识机制 | [DEBATE_MODULE.md](DEBATE_MODULE.md) |
|
||||||
|
|
||||||
### 模块概览
|
### 模块概览
|
||||||
|
|
||||||
@@ -70,6 +72,26 @@ NOFX 是一个支持加密货币和美股市场的全栈 AI 交易平台:
|
|||||||
|
|
||||||
**[阅读完整文档 →](STRATEGY_MODULE.md)**
|
**[阅读完整文档 →](STRATEGY_MODULE.md)**
|
||||||
|
|
||||||
|
#### 回测模块
|
||||||
|
历史交易模拟引擎:
|
||||||
|
- 多币种、多时间周期回测
|
||||||
|
- AI 决策回放与缓存
|
||||||
|
- 性能指标(夏普比率、最大回撤、胜率)
|
||||||
|
- SSE 实时进度推送
|
||||||
|
- 断点续测支持
|
||||||
|
|
||||||
|
**[阅读完整文档 →](BACKTEST_MODULE.md)**
|
||||||
|
|
||||||
|
#### 辩论模块
|
||||||
|
多 AI 协作决策系统:
|
||||||
|
- 5 种 AI 性格(多头、空头、分析师、逆势者、风控)
|
||||||
|
- 多轮辩论与市场数据上下文
|
||||||
|
- 加权投票与共识算法
|
||||||
|
- 自动执行到实盘交易
|
||||||
|
- SSE 实时流推送
|
||||||
|
|
||||||
|
**[阅读完整文档 →](DEBATE_MODULE.md)**
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## 项目结构
|
## 项目结构
|
||||||
@@ -80,6 +102,8 @@ nofx/
|
|||||||
├── api/ # HTTP API (Gin 框架)
|
├── api/ # HTTP API (Gin 框架)
|
||||||
├── trader/ # 交易执行层
|
├── trader/ # 交易执行层
|
||||||
├── strategy/ # 策略引擎
|
├── strategy/ # 策略引擎
|
||||||
|
├── backtest/ # 回测模拟引擎
|
||||||
|
├── debate/ # 辩论竞技场引擎
|
||||||
├── market/ # 行情数据服务
|
├── market/ # 行情数据服务
|
||||||
├── mcp/ # AI 模型客户端
|
├── mcp/ # AI 模型客户端
|
||||||
├── store/ # 数据库操作
|
├── store/ # 数据库操作
|
||||||
@@ -119,6 +143,8 @@ nofx/
|
|||||||
## 快速链接
|
## 快速链接
|
||||||
|
|
||||||
- [策略模块](STRATEGY_MODULE.md) - 策略如何运作
|
- [策略模块](STRATEGY_MODULE.md) - 策略如何运作
|
||||||
|
- [回测模块](BACKTEST_MODULE.md) - 回测如何运作
|
||||||
|
- [辩论模块](DEBATE_MODULE.md) - AI 辩论如何运作
|
||||||
- [快速开始](../getting-started/README.zh-CN.md) - 部署指南
|
- [快速开始](../getting-started/README.zh-CN.md) - 部署指南
|
||||||
- [常见问题](../faq/README.md) - FAQ
|
- [常见问题](../faq/README.md) - FAQ
|
||||||
|
|
||||||
|
|||||||
@@ -1,282 +0,0 @@
|
|||||||
# x402 Streaming Payment Architecture
|
|
||||||
|
|
||||||
## Overview
|
|
||||||
|
|
||||||
NOFX calls AI models (DeepSeek, GPT, Claude, etc.) through the claw402 gateway, using the [x402 protocol](https://github.com/coinbase/x402) to pay per request with USDC on Base L2.
|
|
||||||
|
|
||||||
This document describes the full implementation of the SSE streaming call mode, including client, server, and billing logic.
|
|
||||||
|
|
||||||
## Why Streaming Is Needed
|
|
||||||
|
|
||||||
```
|
|
||||||
NOFX (client) ──→ Cloudflare (100s idle limit) ──→ claw402 (gateway) ──→ AI upstream
|
|
||||||
```
|
|
||||||
|
|
||||||
- DeepSeek inference takes 60–180 seconds (up to 5 minutes)
|
|
||||||
- Cloudflare enforces a **100-second hard limit** on idle connections, returning 520/EOF on timeout
|
|
||||||
- Non-streaming mode: the client receives no data until inference completes — Cloudflare disconnects after 100 seconds
|
|
||||||
- Streaming mode: the first byte arrives within seconds, subsequent chunks flow continuously, keeping Cloudflare alive
|
|
||||||
|
|
||||||
## End-to-End Request Flow
|
|
||||||
|
|
||||||
```
|
|
||||||
NOFX Client claw402 Gateway AI Upstream
|
|
||||||
│ │ │
|
|
||||||
── Phase 1: Payment ──────────────────────────────────────────────────────────────────────────────
|
|
||||||
│ │ │
|
|
||||||
1. POST /api/v1/ai/... │ ─── body + stream:true ──────────→ │ │
|
|
||||||
(no payment header) │ │ │
|
|
||||||
│ ←── 402 + Payment-Required ────── │ │
|
|
||||||
│ (base64 JSON: price/chain/asset) │
|
|
||||||
│ │ │
|
|
||||||
2. EIP-712 signing │ │ │
|
|
||||||
(USDC TransferWithAuth)│ │ │
|
|
||||||
│ │ │
|
|
||||||
3. POST + X-Payment hdr │ ─── body + signature ────────────→ │ │
|
|
||||||
│ │ ── verify signature → Facilitator
|
|
||||||
│ │ ←── OK ──────────── Facilitator
|
|
||||||
│ │ ── settle USDC ───→ Facilitator
|
|
||||||
│ │ ←── tx hash ─────── Facilitator
|
|
||||||
│ │ │
|
|
||||||
── Phase 2: Streaming Response ───────────────────────────────────────────────────────────────────
|
|
||||||
│ │ │
|
|
||||||
│ ←── 200 OK ────────────────────── │ ─── POST stream:true ────────→ │
|
|
||||||
│ ←── data: {"choices":[...]} ───── │ ←── SSE chunk ──────────────── │
|
|
||||||
│ ←── data: {"choices":[...]} ───── │ ←── SSE chunk ──────────────── │
|
|
||||||
│ ←── ... (continuous) ──────────── │ ←── ... ─────────────────────── │
|
|
||||||
│ ←── data: [DONE] ──────────────── │ ←── data: [DONE] ────────────── │
|
|
||||||
```
|
|
||||||
|
|
||||||
## Client Implementation (NOFX)
|
|
||||||
|
|
||||||
### File Structure
|
|
||||||
|
|
||||||
| File | Responsibility |
|
|
||||||
|------|----------------|
|
|
||||||
| `mcp/payment/claw402.go` | Claw402Client — model routing, wallet management |
|
|
||||||
| `mcp/payment/x402.go` | x402 payment flow core — DoX402RequestStream, X402CallStream |
|
|
||||||
| `mcp/client.go` | ParseSSEStream — shared SSE parsing function |
|
|
||||||
|
|
||||||
### Call Chain
|
|
||||||
|
|
||||||
```
|
|
||||||
Claw402Client.Call()
|
|
||||||
└→ X402CallStream() // x402.go:380
|
|
||||||
├→ Build request body + inject stream:true
|
|
||||||
├→ DoX402RequestStream() // x402.go:239
|
|
||||||
│ ├→ Send initial request (no payment header)
|
|
||||||
│ ├→ Receive 402 → parse Payment-Required header
|
|
||||||
│ ├→ signFn() → EIP-712 signature
|
|
||||||
│ └→ Send retry request with X-Payment header → return open *http.Response
|
|
||||||
│
|
|
||||||
├→ Start idle timeout watchdog (90s with no data → disconnect)
|
|
||||||
├→ TeeReader: simultaneous SSE parsing + raw byte buffering
|
|
||||||
├→ ParseSSEStream() // client.go:703
|
|
||||||
│ ├→ bufio.Scanner line-by-line read
|
|
||||||
│ ├→ Parse "data: {...}" → OpenAI chunk format
|
|
||||||
│ └→ Accumulate text + call onChunk callback
|
|
||||||
│
|
|
||||||
└→ Fallback: if SSE yields nothing, try JSON parsing on buffered bodyBuf
|
|
||||||
```
|
|
||||||
|
|
||||||
### Request Identification
|
|
||||||
|
|
||||||
Every request carries an `X-Client-ID: nofx` header (`x402.go:473`), allowing claw402 to identify the request source for logging and monitoring.
|
|
||||||
|
|
||||||
### Model Routing
|
|
||||||
|
|
||||||
`claw402ModelEndpoints` maps user-friendly model names to API paths:
|
|
||||||
|
|
||||||
```go
|
|
||||||
"deepseek" → "/api/v1/ai/deepseek/chat"
|
|
||||||
"gpt-5.4" → "/api/v1/ai/openai/chat/5.4"
|
|
||||||
"claude-opus" → "/api/v1/ai/anthropic/messages/opus"
|
|
||||||
"qwen-max" → "/api/v1/ai/qwen/chat/max"
|
|
||||||
// ... more
|
|
||||||
```
|
|
||||||
|
|
||||||
Anthropic endpoints (containing `/anthropic/`) automatically switch to the Messages API wire format.
|
|
||||||
|
|
||||||
## Server Implementation (claw402)
|
|
||||||
|
|
||||||
### Core Problem: ginmw Is Incompatible with SSE
|
|
||||||
|
|
||||||
Coinbase's standard Gin middleware `ginmw.PaymentMiddlewareFromConfig` internally works as follows:
|
|
||||||
|
|
||||||
```
|
|
||||||
1. Wrap c.Writer with responseCapture (all writes go to buffer)
|
|
||||||
2. c.Next() — handler runs, SSE chunks all go into buffer
|
|
||||||
3. Settle payment after handler completes
|
|
||||||
4. Write buffered content to client only after successful settlement
|
|
||||||
```
|
|
||||||
|
|
||||||
Problems:
|
|
||||||
- SSE chunks are buffered — the client receives no data for minutes
|
|
||||||
- Cloudflare disconnects after 100 seconds → 520 error
|
|
||||||
- Handler runs too long (5 min), settlement context expires
|
|
||||||
|
|
||||||
### Solution: streamAwareX402Middleware
|
|
||||||
|
|
||||||
Dual-path design (`internal/gateway/x402.go`):
|
|
||||||
|
|
||||||
```go
|
|
||||||
func streamAwareX402Middleware(streamServer, standardMW) {
|
|
||||||
return func(c *gin.Context) {
|
|
||||||
if !isStreamingBody(c) {
|
|
||||||
standardMW(c) // Non-streaming → standard ginmw (battle-tested)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
// Streaming → custom path
|
|
||||||
}
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
#### Non-Streaming Path
|
|
||||||
|
|
||||||
Delegates entirely to `ginmw.PaymentMiddlewareFromConfig` with no custom logic.
|
|
||||||
|
|
||||||
#### Streaming Path (Pre-Settlement)
|
|
||||||
|
|
||||||
```
|
|
||||||
1. isStreamingBody(c) — read body to check for {"stream": true}, restore body
|
|
||||||
2. streamServer.RequiresPayment(reqCtx) — does this route require payment?
|
|
||||||
3. streamServer.ProcessHTTPRequest() — verify X-Payment signature
|
|
||||||
4. handleStreamingPayment():
|
|
||||||
a. ProcessSettlement() — settle USDC on-chain (collect payment first)
|
|
||||||
b. c.Next() — pass to HandleAPIKeyStream
|
|
||||||
c. SSE chunks write directly to c.Writer (no responseCapture buffer)
|
|
||||||
```
|
|
||||||
|
|
||||||
Key differences:
|
|
||||||
|
|
||||||
| | Standard ginmw (non-streaming) | Custom path (streaming) |
|
|
||||||
|---|---|---|
|
|
||||||
| Settlement timing | **After** handler completes | **Before** handler starts |
|
|
||||||
| Response buffer | `responseCapture` buffers everything | No buffer, writes directly to client |
|
|
||||||
| Timeout risk | Slow handler causes context expiry | Settlement uses `context.Background()` |
|
|
||||||
| SSE compatible | No | Yes |
|
|
||||||
|
|
||||||
## Billing Logic
|
|
||||||
|
|
||||||
### x402 Protocol Flow
|
|
||||||
|
|
||||||
x402 is an HTTP 402 payment protocol proposed by Coinbase. Core roles:
|
|
||||||
|
|
||||||
- **Resource Server** (claw402) — provides paid APIs
|
|
||||||
- **Client** (NOFX) — consumer, holds an EVM wallet
|
|
||||||
- **Facilitator** (Coinbase CDP) — verifies signatures, executes on-chain settlement
|
|
||||||
|
|
||||||
### Payment Signing (EIP-712)
|
|
||||||
|
|
||||||
Client signature type: USDC `TransferWithAuthorization`
|
|
||||||
|
|
||||||
```
|
|
||||||
1. Receive Payment-Required header from 402 response (base64 JSON)
|
|
||||||
2. Decode to get:
|
|
||||||
- scheme: "exact"
|
|
||||||
- network: "eip155:8453" (Base L2)
|
|
||||||
- amount: USDC amount (e.g., "3000" = $0.003)
|
|
||||||
- asset: USDC contract address
|
|
||||||
- payTo: claw402 recipient address
|
|
||||||
3. Sign with wallet private key using EIP-712, authorizing USDC transfer from user wallet to payTo
|
|
||||||
4. Place signature in X-Payment + Payment-Signature headers
|
|
||||||
```
|
|
||||||
|
|
||||||
### Pricing Models
|
|
||||||
|
|
||||||
Each AI model route has its own price configured in claw402:
|
|
||||||
|
|
||||||
| Mode | Description | Example |
|
|
||||||
|------|-------------|---------|
|
|
||||||
| Fixed price | Specified directly via `user_price` field | `$0.003` per request |
|
|
||||||
| Token-based dynamic pricing | Calculated from request token count | `$0.001` per 1K tokens |
|
|
||||||
| Dispatch fallback | Default price for SDK-compatible routes | `$0.01` per request |
|
|
||||||
|
|
||||||
```go
|
|
||||||
// Fixed price
|
|
||||||
price := fmt.Sprintf("$%s", route.UserPrice)
|
|
||||||
|
|
||||||
// Dynamic pricing
|
|
||||||
price = DynamicPriceFunc(func(ctx, reqCtx) (Price, error) {
|
|
||||||
return resolveDynamicPrice(ctx, reqCtx, rule)
|
|
||||||
})
|
|
||||||
```
|
|
||||||
|
|
||||||
### Retry Logic and Double-Charge Prevention
|
|
||||||
|
|
||||||
```go
|
|
||||||
const X402MaxPaymentRetries = 5
|
|
||||||
const X402RetryBaseWait = 3 * time.Second
|
|
||||||
```
|
|
||||||
|
|
||||||
- **5xx errors** → exponential backoff retry (3s, 6s, 9s...), no re-signing (same payment authorization)
|
|
||||||
- **Another 402** → previous signature expired, re-sign and retry (on-chain authorization auto-invalidates, **no double charge**)
|
|
||||||
- **4xx (non-402)** → non-retryable, fail immediately
|
|
||||||
- Outer retry is set to 1 (`WithMaxRetries(1)`) to prevent outer retries from causing duplicate payments
|
|
||||||
|
|
||||||
### Settlement Timing: Streaming vs Non-Streaming
|
|
||||||
|
|
||||||
| | Non-Streaming | Streaming |
|
|
||||||
|---|---|---|
|
|
||||||
| Settlement timing | After receiving full response | Before streaming begins |
|
|
||||||
| Risk | Low (content confirmed before charge) | Slightly higher (charge before seeing content) |
|
|
||||||
| Necessity | Standard mode | Must charge first, otherwise SSE is buffered |
|
|
||||||
|
|
||||||
## Timeout Configuration
|
|
||||||
|
|
||||||
| Location | Timeout | Purpose |
|
|
||||||
|----------|---------|---------|
|
|
||||||
| NOFX `X402Timeout` | 5 min | HTTP client overall timeout |
|
|
||||||
| NOFX `x402StreamIdleTimeout` | 90s | SSE idle disconnect (prevent hangs) |
|
|
||||||
| NOFX `CallWithRequestStream` idle | 60s | Idle timeout for non-x402 streaming |
|
|
||||||
| claw402 `ResponseHeaderTimeout` | 120s | Wait for first byte from AI upstream |
|
|
||||||
| claw402 `streamingHTTP.Timeout` | 0 (unlimited) | SSE stream can last indefinitely |
|
|
||||||
| claw402 `standardMW WithTimeout` | 10 min | Non-streaming ginmw overall timeout |
|
|
||||||
| claw402 `x402PaymentTimeout` | 30s | Payment verification/settlement timeout |
|
|
||||||
|
|
||||||
## SSE Fault Tolerance
|
|
||||||
|
|
||||||
### TeeReader Dual Parsing
|
|
||||||
|
|
||||||
```go
|
|
||||||
var bodyBuf bytes.Buffer
|
|
||||||
tee := io.TeeReader(resp.Body, &bodyBuf)
|
|
||||||
text, sseErr := ParseSSEStream(tee, onChunk, onLine)
|
|
||||||
|
|
||||||
if text != "" {
|
|
||||||
return text, nil // SSE succeeded
|
|
||||||
}
|
|
||||||
// SSE yielded nothing → try JSON parsing on bodyBuf (server may have returned non-streaming JSON)
|
|
||||||
jsonText, _ := ParseMCPResponse(bodyBuf.Bytes())
|
|
||||||
```
|
|
||||||
|
|
||||||
### Idle Timeout Watchdog
|
|
||||||
|
|
||||||
```go
|
|
||||||
go func() {
|
|
||||||
t := time.NewTimer(90s)
|
|
||||||
for {
|
|
||||||
select {
|
|
||||||
case <-t.C:
|
|
||||||
cancel() // timeout → cancel context → close TCP → body.Read() returns error
|
|
||||||
case <-resetCh:
|
|
||||||
t.Reset(90s) // received SSE line → reset timer
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}()
|
|
||||||
```
|
|
||||||
|
|
||||||
Every incoming SSE line resets the timer. If no data arrives for 90 seconds, the context is cancelled and the TCP connection is closed, preventing indefinite blocking.
|
|
||||||
|
|
||||||
## Related Files
|
|
||||||
|
|
||||||
### NOFX (Client)
|
|
||||||
- `mcp/payment/claw402.go` — Claw402Client entry point
|
|
||||||
- `mcp/payment/x402.go` — x402 payment flow (DoX402Request, DoX402RequestStream, X402CallStream)
|
|
||||||
- `mcp/payment/x402_sign.go` — EIP-712 signing implementation
|
|
||||||
- `mcp/client.go` — ParseSSEStream, CallWithRequestStream
|
|
||||||
|
|
||||||
### claw402 (Server)
|
|
||||||
- `internal/gateway/x402.go` — x402 middleware (streamAwareX402Middleware)
|
|
||||||
- `internal/gateway/proxy/stream.go` — SSE proxy (HandleAPIKeyStream)
|
|
||||||
- `internal/config/` — Route configuration (pricing, model mapping)
|
|
||||||
@@ -1,44 +0,0 @@
|
|||||||
<svg width="1200" height="380" viewBox="0 0 1200 380" xmlns="http://www.w3.org/2000/svg">
|
|
||||||
<defs>
|
|
||||||
<clipPath id="frame"><rect width="1200" height="380" rx="14"/></clipPath>
|
|
||||||
<pattern id="dots" width="28" height="28" patternUnits="userSpaceOnUse">
|
|
||||||
<circle cx="1.5" cy="1.5" r="1.2" fill="#1A1813" opacity="0.07"/>
|
|
||||||
</pattern>
|
|
||||||
</defs>
|
|
||||||
|
|
||||||
<g clip-path="url(#frame)">
|
|
||||||
<rect width="1200" height="380" fill="#F1ECE2"/>
|
|
||||||
<rect width="1200" height="380" fill="url(#dots)"/>
|
|
||||||
|
|
||||||
<!-- title bar -->
|
|
||||||
<rect width="1200" height="52" fill="#F7F4EC"/>
|
|
||||||
<line x1="0" y1="52.5" x2="1200" y2="52.5" stroke="#1A1813" stroke-opacity="0.14"/>
|
|
||||||
<circle cx="30" cy="26" r="6" fill="#D6433A" opacity="0.9"/>
|
|
||||||
<circle cx="54" cy="26" r="6" fill="#B4B2A9"/>
|
|
||||||
<circle cx="78" cy="26" r="6" fill="#2E8B57" opacity="0.9"/>
|
|
||||||
<text x="600" y="31" text-anchor="middle" font-family="ui-monospace, SFMono-Regular, Menlo, Consolas, 'Liberation Mono', monospace" font-size="13" letter-spacing="2" fill="#8A8478">nofx — ai trading terminal</text>
|
|
||||||
|
|
||||||
<!-- wordmark -->
|
|
||||||
<text x="64" y="182" font-family="ui-monospace, SFMono-Regular, Menlo, Consolas, 'Liberation Mono', monospace" font-size="92" font-weight="700" letter-spacing="4" fill="#1A1813">NOFX<tspan fill="#E0483B">_</tspan></text>
|
|
||||||
<text x="64" y="228" font-family="ui-monospace, SFMono-Regular, Menlo, Consolas, 'Liberation Mono', monospace" font-size="17" letter-spacing="9" fill="#6B6557">AI TRADING TERMINAL</text>
|
|
||||||
|
|
||||||
<text x="64" y="286" font-family="ui-monospace, SFMono-Regular, Menlo, Consolas, 'Liberation Mono', monospace" font-size="15" fill="#6B6557">Language models make the decisions.</text>
|
|
||||||
<text x="64" y="312" font-family="ui-monospace, SFMono-Regular, Menlo, Consolas, 'Liberation Mono', monospace" font-size="15" fill="#6B6557">A Go runtime enforces the limits.</text>
|
|
||||||
|
|
||||||
<!-- divider -->
|
|
||||||
<line x1="672" y1="84" x2="672" y2="344" stroke="#1A1813" stroke-opacity="0.12"/>
|
|
||||||
|
|
||||||
<!-- session log -->
|
|
||||||
<g font-family="ui-monospace, SFMono-Regular, Menlo, Consolas, 'Liberation Mono', monospace" font-size="14.5">
|
|
||||||
<text x="712" y="112" fill="#8A8478">$ nofx start</text>
|
|
||||||
<text x="712" y="146"><tspan fill="#2E8B57">✓</tspan><tspan fill="#1A1813" dx="10">preflight passed</tspan><tspan fill="#8A8478" dx="10">7/7 checks</tspan></text>
|
|
||||||
<text x="712" y="180"><tspan fill="#2E8B57">✓</tspan><tspan fill="#1A1813" dx="10">trader online</tspan><tspan fill="#8A8478" dx="10">deepseek · hyperliquid</tspan></text>
|
|
||||||
<text x="712" y="222"><tspan fill="#8A8478">decision</tspan><tspan fill="#E0483B" dx="12" font-weight="700">OPEN_LONG</tspan><tspan fill="#1A1813" dx="12">BTC ×3</tspan></text>
|
|
||||||
<text x="712" y="252"><tspan fill="#8A8478">reason</tspan><tspan fill="#6B6557" dx="12">oi rising · funding neutral · 4h breakout</tspan></text>
|
|
||||||
<text x="712" y="282"><tspan fill="#8A8478">risk</tspan><tspan fill="#6B6557" dx="12">sl −2.5% · tp +5.0% · placed on exchange</tspan></text>
|
|
||||||
<text x="712" y="324"><tspan fill="#8A8478">equity</tspan><tspan fill="#1A1813" dx="12">$12,847</tspan><tspan fill="#8A8478" dx="18">pnl</tspan><tspan fill="#2E8B57" dx="12">+4.2%</tspan></text>
|
|
||||||
</g>
|
|
||||||
</g>
|
|
||||||
|
|
||||||
<rect x="0.5" y="0.5" width="1199" height="379" rx="14" fill="none" stroke="#1A1813" stroke-opacity="0.22"/>
|
|
||||||
</svg>
|
|
||||||
|
Before Width: | Height: | Size: 3.3 KiB |
@@ -1,50 +0,0 @@
|
|||||||
# ⚠️ Official Accounts & Anti-Impersonation Notice
|
|
||||||
|
|
||||||
## Legal Entity
|
|
||||||
|
|
||||||
| Field | Details |
|
|
||||||
|-------|---------|
|
|
||||||
| Company Name | **Cryonic Holdings Limited** |
|
|
||||||
| Company No. | 2193977 |
|
|
||||||
| Jurisdiction | British Virgin Islands |
|
|
||||||
| Address | Mandar House, 3rd Floor, P.O. Box 2196, Johnson's Ghut, Tortola, BVI |
|
|
||||||
| Contact Email | 0xccfelix@gmail.com |
|
|
||||||
|
|
||||||
## Official Social Media & Channels
|
|
||||||
|
|
||||||
| Platform | Official Account | Link | Status |
|
|
||||||
|----------|-----------------|------|--------|
|
|
||||||
| Twitter/X | **@nofx_official** | https://x.com/nofx_official | ✅ Official |
|
|
||||||
| Twitter/X | **@Web3Tinkle** | https://x.com/Web3Tinkle | ✅ Founder |
|
|
||||||
| GitHub | **NoFxAiOS** | https://github.com/NoFxAiOS | ✅ Official |
|
|
||||||
| Website | **nofxai.com** | https://nofxai.com | ✅ Official |
|
|
||||||
| Dashboard | **nofxos.ai** | https://nofxos.ai | ✅ Official |
|
|
||||||
|
|
||||||
## ⛔ Known Impersonation Accounts
|
|
||||||
|
|
||||||
The following accounts are **NOT affiliated** with the NoFx project:
|
|
||||||
|
|
||||||
| Platform | Account | Status |
|
|
||||||
|----------|---------|--------|
|
|
||||||
| Twitter/X | @nofx_ai | ❌ **NOT OFFICIAL** — Not affiliated with this project |
|
|
||||||
|
|
||||||
> **Warning:** Any account claiming to represent NoFx that is not listed above is unauthorized. Please verify through this page before trusting any account claiming to be associated with NoFx.
|
|
||||||
|
|
||||||
## How to Verify Authenticity
|
|
||||||
|
|
||||||
1. Check this page (OFFICIAL_ACCOUNTS.md) in our official GitHub repository
|
|
||||||
2. Our GitHub repository sidebar links directly to our official Twitter
|
|
||||||
3. Our README.md lists all official accounts under "Core Team" and "Official Links"
|
|
||||||
4. Our operating entity is Cryonic Holdings Limited (BVI No. 2193977)
|
|
||||||
5. Official contact email: 0xccfelix@gmail.com
|
|
||||||
|
|
||||||
## Report Impersonation
|
|
||||||
|
|
||||||
If you encounter accounts impersonating NoFx, please:
|
|
||||||
1. Report them on the respective platform
|
|
||||||
2. Open an issue in this repository to notify our team
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
*Last updated: 2026-03-01*
|
|
||||||
*This document is maintained by Cryonic Holdings Limited in the official NoFx GitHub repository (10,500+ ⭐)*
|
|
||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user