mirror of
https://github.com/NoFxAiOS/nofx.git
synced 2026-07-22 11:47:36 +08:00
Compare commits
3 Commits
21407030ea
...
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
|
||||
|
||||
# ===========================================
|
||||
# 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_HOST=10.
|
||||
DB_PORT=5432
|
||||
@@ -61,6 +104,6 @@ DB_NAME=nofx
|
||||
DB_SSLMODE=disable
|
||||
|
||||
|
||||
# Database configuration - SQLite (default)
|
||||
# 数据库配置 - SQLite(默认)
|
||||
DB_TYPE=sqlite
|
||||
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:
|
||||
- What changed:
|
||||
- What did NOT change (scope boundary):
|
||||
> **📋 Choose Specialized Template**
|
||||
>
|
||||
> 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
|
||||
- [ ] Feature
|
||||
- [ ] Refactoring
|
||||
- [ ] Docs
|
||||
- [ ] Security fix
|
||||
- [ ] Chore / infra
|
||||
> **💡 Tip:** Recommended PR title format `type(scope): description`
|
||||
> Example: `feat(trader): add new strategy` | `fix(api): resolve auth issue`
|
||||
|
||||
## Scope
|
||||
---
|
||||
|
||||
- [ ] Trading engine / strategies
|
||||
- [ ] MCP / AI clients
|
||||
- [ ] API / server
|
||||
- [ ] Telegram bot / agent
|
||||
- [ ] Web UI / frontend
|
||||
- [ ] Config / deployment
|
||||
- [ ] CI/CD / infra
|
||||
## 📝 Description
|
||||
|
||||
## 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 #
|
||||
- Related #
|
||||
- Related to #
|
||||
|
||||
## Testing
|
||||
---
|
||||
|
||||
What you verified and how:
|
||||
## 📋 Changes Made
|
||||
|
||||
- [ ] `go build ./...` passes
|
||||
- [ ] `go test ./...` passes
|
||||
- [ ] Manual testing done (describe below)
|
||||
<!-- List the specific changes made -->
|
||||
-
|
||||
-
|
||||
|
||||
## Security Impact
|
||||
---
|
||||
|
||||
- Secrets/keys handling changed? (`Yes/No`)
|
||||
- New/changed API endpoints? (`Yes/No`)
|
||||
- User input validation affected? (`Yes/No`)
|
||||
## 🧪 Testing
|
||||
|
||||
## Compatibility
|
||||
- [ ] Tested locally
|
||||
- [ ] Tests pass
|
||||
- [ ] Verified no existing functionality broke
|
||||
|
||||
- Backward compatible? (`Yes/No`)
|
||||
- Config/env changes? (`Yes/No`)
|
||||
- Migration needed? (`Yes/No`)
|
||||
- If yes, upgrade steps:
|
||||
---
|
||||
|
||||
## ✅ Checklist
|
||||
|
||||
### 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!**
|
||||
|
||||
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
|
||||
});
|
||||
12
.github/workflows/pr-checks.yml
vendored
12
.github/workflows/pr-checks.yml
vendored
@@ -273,11 +273,7 @@ jobs:
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Run Trivy vulnerability scanner
|
||||
# SECURITY: pinned to a full 40-char commit SHA (v0.36.0) — a mutable
|
||||
# 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
|
||||
uses: aquasecurity/trivy-action@master
|
||||
with:
|
||||
scan-type: 'fs'
|
||||
scan-ref: '.'
|
||||
@@ -303,11 +299,7 @@ jobs:
|
||||
fetch-depth: 0
|
||||
|
||||
- name: Run TruffleHog OSS
|
||||
# SECURITY: never use @main — upstream compromise = secret exfil.
|
||||
# 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
|
||||
uses: trufflesecurity/trufflehog@main
|
||||
with:
|
||||
path: ./
|
||||
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
|
||||
|
||||
# Lightweight build check on PR only, no image push
|
||||
# Strategy: Quick verify amd64 + spot check arm64 (backend only)
|
||||
# PR 时只做轻量级构建检查,不推送镜像
|
||||
# 策略: 快速验证 amd64 + 抽样检查 arm64 (backend only)
|
||||
on:
|
||||
pull_request:
|
||||
branches:
|
||||
@@ -18,7 +18,7 @@ on:
|
||||
- '.github/workflows/pr-docker-check.yml'
|
||||
|
||||
jobs:
|
||||
# Quick check: amd64 builds for all images
|
||||
# 快速检查: 所有镜像的 amd64 版本
|
||||
docker-build-amd64:
|
||||
name: Build Check (amd64)
|
||||
runs-on: ubuntu-22.04
|
||||
@@ -31,7 +31,7 @@ jobs:
|
||||
include:
|
||||
- name: backend
|
||||
dockerfile: ./docker/Dockerfile.backend
|
||||
test_run: true # Needs test run
|
||||
test_run: true # 需要测试运行
|
||||
- name: frontend
|
||||
dockerfile: ./docker/Dockerfile.frontend
|
||||
test_run: true
|
||||
@@ -51,7 +51,7 @@ jobs:
|
||||
file: ${{ matrix.dockerfile }}
|
||||
platforms: linux/amd64
|
||||
push: false
|
||||
load: true # Load into local Docker for test run
|
||||
load: true # 加载到本地 Docker,用于测试运行
|
||||
tags: nofx-${{ matrix.name }}:pr-test
|
||||
cache-from: type=gha,scope=${{ matrix.name }}-amd64
|
||||
cache-to: type=gha,mode=max,scope=${{ matrix.name }}-amd64
|
||||
@@ -66,12 +66,12 @@ jobs:
|
||||
run: |
|
||||
echo "🧪 Testing container startup..."
|
||||
|
||||
# Start container
|
||||
# 启动容器
|
||||
docker run -d --name test-${{ matrix.name }} \
|
||||
--health-cmd="exit 0" \
|
||||
nofx-${{ matrix.name }}:pr-test
|
||||
|
||||
# Wait for container to start (up to 30 seconds)
|
||||
# 等待容器启动 (最多 30 秒)
|
||||
for i in {1..30}; do
|
||||
if docker ps | grep -q test-${{ matrix.name }}; then
|
||||
echo "✅ Container started successfully"
|
||||
@@ -93,7 +93,7 @@ jobs:
|
||||
|
||||
echo "📦 Image size: ${SIZE_MB} MB"
|
||||
|
||||
# Warning thresholds
|
||||
# 警告阈值
|
||||
if [ "${{ matrix.name }}" = "backend" ] && [ $SIZE_MB -gt 500 ]; then
|
||||
echo "⚠️ Warning: Backend image is larger than 500MB"
|
||||
elif [ "${{ matrix.name }}" = "frontend" ] && [ $SIZE_MB -gt 200 ]; then
|
||||
@@ -102,10 +102,10 @@ jobs:
|
||||
echo "✅ Image size is reasonable"
|
||||
fi
|
||||
|
||||
# ARM64 native build check: Uses GitHub native ARM64 runner (fast!)
|
||||
# ARM64 原生构建检查: 使用 GitHub 原生 ARM64 runner (快速!)
|
||||
docker-build-arm64-native:
|
||||
name: Build Check (arm64 native - backend)
|
||||
runs-on: ubuntu-22.04-arm # Native ARM64 runner
|
||||
runs-on: ubuntu-22.04-arm # 原生 ARM64 runner
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
@@ -113,19 +113,19 @@ jobs:
|
||||
- name: Checkout code
|
||||
uses: actions/checkout@v4
|
||||
|
||||
# Native ARM64 does not need QEMU, builds directly
|
||||
# 原生 ARM64 不需要 QEMU,直接构建
|
||||
- name: Set up Docker Buildx
|
||||
uses: docker/setup-buildx-action@v3
|
||||
|
||||
- name: Build backend image (arm64 native)
|
||||
uses: docker/build-push-action@v5
|
||||
timeout-minutes: 15 # Native builds are faster!
|
||||
timeout-minutes: 15 # 原生构建更快!
|
||||
with:
|
||||
context: .
|
||||
file: ./docker/Dockerfile.backend
|
||||
platforms: linux/arm64
|
||||
push: false
|
||||
load: true # Load locally for testing
|
||||
load: true # 加载到本地,用于测试
|
||||
tags: nofx-backend:pr-test-arm64
|
||||
cache-from: type=gha,scope=backend-arm64
|
||||
cache-to: type=gha,mode=max,scope=backend-arm64
|
||||
@@ -139,12 +139,12 @@ jobs:
|
||||
run: |
|
||||
echo "🧪 Testing ARM64 container startup..."
|
||||
|
||||
# Start container
|
||||
# 启动容器
|
||||
docker run -d --name test-backend-arm64 \
|
||||
--health-cmd="exit 0" \
|
||||
nofx-backend:pr-test-arm64
|
||||
|
||||
# Wait for startup
|
||||
# 等待启动
|
||||
for i in {1..30}; do
|
||||
if docker ps | grep -q test-backend-arm64; then
|
||||
echo "✅ ARM64 container started successfully"
|
||||
@@ -165,14 +165,14 @@ jobs:
|
||||
echo "Using GitHub native ARM64 runner - no QEMU needed!"
|
||||
echo "Build time is ~3x faster than emulation"
|
||||
|
||||
# Aggregate check results
|
||||
# 汇总检查结果
|
||||
check-summary:
|
||||
name: Docker Build Summary
|
||||
needs: [docker-build-amd64, docker-build-arm64-native]
|
||||
runs-on: ubuntu-22.04
|
||||
if: always()
|
||||
permissions:
|
||||
pull-requests: write # For posting comments
|
||||
pull-requests: write # 用于发布评论
|
||||
steps:
|
||||
- name: Check build results
|
||||
id: check
|
||||
@@ -180,7 +180,7 @@ jobs:
|
||||
echo "## 🐳 Docker Build Check Results" >> $GITHUB_STEP_SUMMARY
|
||||
echo "" >> $GITHUB_STEP_SUMMARY
|
||||
|
||||
# Check amd64 build
|
||||
# 检查 amd64 构建
|
||||
if [[ "${{ needs.docker-build-amd64.result }}" == "success" ]]; then
|
||||
echo "✅ **AMD64 builds**: All passed" >> $GITHUB_STEP_SUMMARY
|
||||
AMD64_OK=true
|
||||
@@ -189,7 +189,7 @@ jobs:
|
||||
AMD64_OK=false
|
||||
fi
|
||||
|
||||
# Check arm64 build
|
||||
# 检查 arm64 构建
|
||||
if [[ "${{ needs.docker-build-arm64-native.result }}" == "success" ]]; then
|
||||
echo "✅ **ARM64 build** (native): Backend passed (frontend will be verified after merge)" >> $GITHUB_STEP_SUMMARY
|
||||
ARM64_OK=true
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
name: PR Docker Compose Healthcheck
|
||||
|
||||
# Verify docker-compose.yml healthcheck config works correctly in Alpine containers
|
||||
# 驗證 docker-compose.yml 的 healthcheck 配置在 Alpine 容器中正常工作
|
||||
on:
|
||||
pull_request:
|
||||
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:
|
||||
pull_request:
|
||||
types: [opened, synchronize, reopened]
|
||||
types: [opened, edited, synchronize]
|
||||
|
||||
permissions:
|
||||
pull-requests: write
|
||||
issues: write
|
||||
contents: read
|
||||
|
||||
jobs:
|
||||
label-pr:
|
||||
suggest-template:
|
||||
runs-on: ubuntu-latest
|
||||
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
|
||||
with:
|
||||
github-token: ${{ secrets.GITHUB_TOKEN }}
|
||||
@@ -21,72 +25,166 @@ jobs:
|
||||
owner: context.repo.owner,
|
||||
repo: context.repo.repo,
|
||||
pull_number: context.issue.number,
|
||||
per_page: 100,
|
||||
});
|
||||
|
||||
let goFiles = 0, jsFiles = 0, tsFiles = 0, mdFiles = 0, otherFiles = 0;
|
||||
let additions = 0, deletions = 0;
|
||||
|
||||
for (const file of files) {
|
||||
const name = file.filename.toLowerCase();
|
||||
additions += file.additions || 0;
|
||||
deletions += file.deletions || 0;
|
||||
if (name.endsWith('.go')) goFiles++;
|
||||
else if (name.endsWith('.js') || name.endsWith('.jsx')) jsFiles++;
|
||||
else if (name.endsWith('.ts') || name.endsWith('.tsx') || name.endsWith('.vue')) tsFiles++;
|
||||
else if (name.endsWith('.md')) mdFiles++;
|
||||
const filename = file.filename.toLowerCase();
|
||||
if (filename.endsWith('.go')) goFiles++;
|
||||
else if (filename.endsWith('.js') || filename.endsWith('.jsx')) jsFiles++;
|
||||
else if (filename.endsWith('.ts') || filename.endsWith('.tsx') || filename.endsWith('.vue')) tsFiles++;
|
||||
else if (filename.endsWith('.md')) mdFiles++;
|
||||
else otherFiles++;
|
||||
}
|
||||
|
||||
const totalFiles = goFiles + jsFiles + tsFiles + mdFiles + otherFiles;
|
||||
if (totalFiles === 0) return;
|
||||
if (totalFiles === 0) { console.log('No files changed'); return; }
|
||||
|
||||
// --- Scope label ---
|
||||
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');
|
||||
let suggestedTemplate = null, templateEmoji = '', templateLabel = '';
|
||||
|
||||
// --- Size label (like OpenClaw) ---
|
||||
const totalChanged = additions + deletions;
|
||||
const sizeLabels = ['size: XS', 'size: S', 'size: M', 'size: L', 'size: XL'];
|
||||
let sizeLabel = 'size: XL';
|
||||
if (totalChanged < 50) sizeLabel = 'size: XS';
|
||||
else if (totalChanged < 200) sizeLabel = 'size: S';
|
||||
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' });
|
||||
}
|
||||
}
|
||||
if (goFiles / totalFiles > 0.5) {
|
||||
suggestedTemplate = 'backend'; templateEmoji = '🔧'; templateLabel = 'backend';
|
||||
} else if ((jsFiles + tsFiles) / totalFiles > 0.5) {
|
||||
suggestedTemplate = 'frontend'; templateEmoji = '🎨'; templateLabel = 'frontend';
|
||||
} else if (mdFiles / totalFiles > 0.7) {
|
||||
suggestedTemplate = 'docs'; templateEmoji = '📝'; templateLabel = 'documentation';
|
||||
}
|
||||
|
||||
// Remove stale size labels
|
||||
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({
|
||||
const { data: pr } = await github.rest.pulls.get({
|
||||
owner: context.repo.owner,
|
||||
repo: context.repo.repo,
|
||||
issue_number: context.issue.number,
|
||||
labels: labels,
|
||||
pull_number: context.issue.number,
|
||||
});
|
||||
|
||||
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');
|
||||
}
|
||||
|
||||
18
.gitignore
vendored
18
.gitignore
vendored
@@ -16,7 +16,6 @@ nofx_test
|
||||
# Go 相关
|
||||
*.test
|
||||
*.out
|
||||
.gocache/
|
||||
|
||||
# 操作系统
|
||||
.DS_Store
|
||||
@@ -27,8 +26,6 @@ Thumbs.db
|
||||
*.tmp
|
||||
*.bak
|
||||
*.backup
|
||||
.cache/
|
||||
.gh-config/
|
||||
|
||||
# 环境变量
|
||||
.env
|
||||
@@ -44,7 +41,6 @@ decision_logs/
|
||||
nofx_test
|
||||
|
||||
# Node.js
|
||||
web/node_modules
|
||||
web/node_modules/
|
||||
node_modules/
|
||||
web/dist/
|
||||
@@ -53,9 +49,6 @@ web/.vite/
|
||||
# ESLint 临时报告文件(调试时生成,不纳入版本控制)
|
||||
eslint-*.json
|
||||
|
||||
# 本地 Agent QA seed(个人调试用,不纳入版本控制)
|
||||
docs/qa/fixtures/agent_self_play_seed.zh-CN.json
|
||||
|
||||
# VS code
|
||||
.vscode
|
||||
|
||||
@@ -85,6 +78,7 @@ dist/
|
||||
downloads/
|
||||
eggs/
|
||||
.eggs/
|
||||
lib/
|
||||
lib64/
|
||||
parts/
|
||||
sdist/
|
||||
@@ -130,12 +124,4 @@ dmypy.json
|
||||
# Pyre type checker
|
||||
.pyre/
|
||||
PR_DESCRIPTION.md
|
||||
|
||||
# Go build artifacts
|
||||
/nofx-server
|
||||
.gstack/
|
||||
|
||||
# Local AI agent / skill scaffolding (not part of the runtime app)
|
||||
.agents/
|
||||
skills-lock.json
|
||||
img.png
|
||||
nofx-moltbot
|
||||
|
||||
@@ -1,37 +1,37 @@
|
||||
# Railway All-in-One: Reuse existing GHCR images
|
||||
# Extract content from existing images and merge into a single container
|
||||
# Railway All-in-One: 复用现有 GHCR 镜像
|
||||
# 从现有镜像提取内容,合并到一个容器
|
||||
|
||||
# Extract binary from backend image
|
||||
# 从后端镜像提取二进制
|
||||
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
|
||||
|
||||
# Final image
|
||||
# 最终镜像
|
||||
FROM alpine:latest
|
||||
|
||||
RUN apk add --no-cache ca-certificates tzdata sqlite nginx openssl gettext
|
||||
|
||||
# Copy backend binary
|
||||
# 复制后端二进制
|
||||
COPY --from=backend /app/nofx /app/nofx
|
||||
|
||||
# Copy TA-Lib libraries
|
||||
# 复制 TA-Lib 库
|
||||
COPY --from=backend /usr/local/lib/libta_lib* /usr/local/lib/
|
||||
RUN ldconfig /usr/local/lib 2>/dev/null || true
|
||||
|
||||
# Copy frontend static files
|
||||
# 复制前端静态文件
|
||||
COPY --from=frontend /usr/share/nginx/html /usr/share/nginx/html
|
||||
|
||||
WORKDIR /app
|
||||
RUN mkdir -p /app/data
|
||||
|
||||
# Startup script (includes nginx config generation)
|
||||
# 启动脚本(包含 nginx 配置生成)
|
||||
COPY railway/start.sh /app/start.sh
|
||||
RUN chmod +x /app/start.sh
|
||||
|
||||
ENV DB_PATH=/app/data/data.db
|
||||
|
||||
# Railway automatically sets the PORT environment variable
|
||||
# Railway 会自动设置 PORT 环境变量
|
||||
EXPOSE 8080
|
||||
|
||||
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">
|
||||
<img src="docs/assets/nofx-banner.svg" alt="NOFX — AI trading terminal" width="100%"/>
|
||||
</p>
|
||||
[](https://golang.org/)
|
||||
[](https://reactjs.org/)
|
||||
[](https://www.typescriptlang.org/)
|
||||
[](LICENSE)
|
||||
|
||||
<p align="center">
|
||||
<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>
|
||||
<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>
|
||||
<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>
|
||||
| CONTRIBUTOR AIRDROP PROGRAM |
|
||||
|:----------------------------------:|
|
||||
| Code · Bug Fixes · Issues → Airdrop |
|
||||
| [Learn More](#contributor-airdrop-program) |
|
||||
|
||||
<p align="center">
|
||||
<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>
|
||||
**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)
|
||||
|
||||
<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
|
||||
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.
|
||||
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**
|
||||
Deploy to Railway with one click - no server setup required:
|
||||
|
||||
[](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
|
||||
# Download and start
|
||||
curl -O https://raw.githubusercontent.com/NoFxAiOS/nofx/main/docker-compose.prod.yml
|
||||
docker compose -f docker-compose.prod.yml up -d
|
||||
```
|
||||
|
||||
**Windows** — install [Docker Desktop](https://www.docker.com/products/docker-desktop/), then:
|
||||
|
||||
```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+:
|
||||
Access Web Interface: **http://127.0.0.1:3000**
|
||||
|
||||
```bash
|
||||
git clone https://github.com/NoFxAiOS/nofx.git && cd nofx
|
||||
go build -o nofx && ./nofx # backend
|
||||
cd web && npm install && npm run dev # frontend, in a second terminal
|
||||
# Management commands
|
||||
docker compose -f docker-compose.prod.yml logs -f # View logs
|
||||
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>
|
||||
<summary>Server deployment</summary>
|
||||
|
||||
<br/>
|
||||
|
||||
**HTTP**
|
||||
> **💡 Updates are frequent.** Run this command daily to stay current with the latest features and fixes:
|
||||
|
||||
```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)
|
||||
2. A record → server IP, proxied
|
||||
3. SSL/TLS → Flexible
|
||||
4. `TRANSPORT_ENCRYPTION=true` in `.env`
|
||||
### Manual Installation (For Developers)
|
||||
|
||||
</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
|
||||
|
||||
| | |
|
||||
| :------------------------------------------------------ | :------------------------------------ |
|
||||
| [Getting Started](docs/getting-started/README.md) | Deployment and exchange API guides |
|
||||
| [Architecture](docs/architecture/README.md) | System design and module index |
|
||||
| [Strategy Module](docs/architecture/STRATEGY_MODULE.md) | Coin selection, AI prompts, execution |
|
||||
| [FAQ](docs/guides/faq.en.md) | Common questions |
|
||||
| [Troubleshooting](docs/guides/TROUBLESHOOTING.md) | Diagnosing common issues |
|
||||
| Document | Description |
|
||||
|----------|-------------|
|
||||
| **[Architecture Overview](docs/architecture/README.md)** | System design and module index |
|
||||
| **[Strategy Module](docs/architecture/STRATEGY_MODULE.md)** | Coin selection, data assembly, AI prompts, execution |
|
||||
| **[Backtest Module](docs/architecture/BACKTEST_MODULE.md)** | Historical simulation, metrics, checkpoint/resume |
|
||||
| **[Debate Module](docs/architecture/DEBATE_MODULE.md)** | Multi-AI debate, voting consensus, auto-execution |
|
||||
| **[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
|
||||
|
||||
[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
|
||||
|
||||
import (
|
||||
"log"
|
||||
"net/http"
|
||||
"nofx/config"
|
||||
"nofx/crypto"
|
||||
@@ -52,16 +53,28 @@ func (h *CryptoHandler) HandleGetPublicKey(c *gin.Context) {
|
||||
})
|
||||
}
|
||||
|
||||
// ==================== Encrypted Data Decryption ====================
|
||||
//
|
||||
// SECURITY: there is deliberately NO public decrypt endpoint. Transport
|
||||
// encryption is one-directional — clients encrypt sensitive fields to the
|
||||
// server's RSA public key and the authenticated config-update handlers
|
||||
// (handleUpdateModelConfigs / handleUpdateExchangeConfigs / handleCreateExchange)
|
||||
// decrypt them server-side via cryptoService.DecryptSensitiveData. Exposing a
|
||||
// generic decrypt route would turn the server into a decryption oracle that any
|
||||
// unauthenticated caller could use to recover the plaintext of a captured
|
||||
// ciphertext, defeating the entire transport-encryption layer.
|
||||
// ==================== Encrypted Data Decryption Endpoint ====================
|
||||
|
||||
// HandleDecryptSensitiveData Decrypt encrypted data sent from client
|
||||
func (h *CryptoHandler) HandleDecryptSensitiveData(c *gin.Context) {
|
||||
var payload crypto.EncryptedPayload
|
||||
if err := c.ShouldBindJSON(&payload); err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid request"})
|
||||
return
|
||||
}
|
||||
|
||||
// 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 ====================
|
||||
|
||||
|
||||
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"
|
||||
)
|
||||
|
||||
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
|
||||
// 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) {
|
||||
@@ -35,46 +16,34 @@ func SafeError(c *gin.Context, statusCode int, publicMsg string, internalErr err
|
||||
logger.Errorf("[API Error] %s: %v", publicMsg, internalErr)
|
||||
}
|
||||
|
||||
writeAPIError(c, statusCode, publicMsg, "", nil)
|
||||
}
|
||||
|
||||
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)
|
||||
c.JSON(statusCode, gin.H{"error": publicMsg})
|
||||
}
|
||||
|
||||
// SafeInternalError logs internal error and returns a generic message
|
||||
func SafeInternalError(c *gin.Context, operation string, err error) {
|
||||
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
|
||||
// For validation errors, we can be more specific since they're about user input
|
||||
func SafeBadRequest(c *gin.Context, msg string) {
|
||||
writeAPIError(c, http.StatusBadRequest, msg, "", nil)
|
||||
}
|
||||
|
||||
func SafeBadRequestWithDetails(c *gin.Context, msg, errorKey string, errorParams map[string]string) {
|
||||
writeAPIError(c, http.StatusBadRequest, msg, errorKey, errorParams)
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": msg})
|
||||
}
|
||||
|
||||
// SafeNotFound returns a generic not found error
|
||||
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
|
||||
func SafeUnauthorized(c *gin.Context) {
|
||||
writeAPIError(c, http.StatusUnauthorized, "Unauthorized", "", nil)
|
||||
c.JSON(http.StatusUnauthorized, gin.H{"error": "Unauthorized"})
|
||||
}
|
||||
|
||||
// SafeForbidden returns forbidden error
|
||||
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
|
||||
|
||||
@@ -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,413 +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"`
|
||||
UpdatedAt int64 `json:"updatedAt"`
|
||||
}
|
||||
|
||||
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
|
||||
}
|
||||
|
||||
requestBody := map[string]any{
|
||||
"type": "clearinghouseState",
|
||||
"user": address,
|
||||
}
|
||||
body, err := json.Marshal(requestBody)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to encode Hyperliquid balance 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 balance 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 balance request", "status": resp.StatusCode})
|
||||
return
|
||||
}
|
||||
|
||||
var state hyperliquidClearinghouseState
|
||||
if err := json.Unmarshal(respBody, &state); err != nil {
|
||||
c.JSON(http.StatusBadGateway, gin.H{"error": "failed to parse Hyperliquid balance response"})
|
||||
return
|
||||
}
|
||||
|
||||
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,
|
||||
UpdatedAt: time.Now().UnixMilli(),
|
||||
})
|
||||
}
|
||||
|
||||
// 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
|
||||
}
|
||||
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)
|
||||
}
|
||||
|
||||
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,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,369 +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
|
||||
// 4× equity notional per position: at 10x leverage two full positions
|
||||
// use ~80% of margin — concentrated but solvent.
|
||||
c.RiskControl.BTCETHMaxPositionValueRatio = 4.0
|
||||
c.RiskControl.AltcoinMaxPositionValueRatio = 4.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 at most two positions, 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 != 4 ||
|
||||
trendCfg.RiskControl.AltcoinMaxPositionValueRatio != 4 ||
|
||||
trendCfg.RiskControl.MaxMarginUsage != 1.0 {
|
||||
t.Fatalf("default strategy should size Claw402 opens at 4x equity notional (two positions ≈ 80%% margin 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 (
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"testing"
|
||||
|
||||
"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
|
||||
func TestUpdateTraderRequest_SystemPromptTemplate(t *testing.T) {
|
||||
tests := []struct {
|
||||
|
||||
226
api/strategy.go
226
api/strategy.go
@@ -1,7 +1,6 @@
|
||||
package api
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"net/http"
|
||||
@@ -9,8 +8,6 @@ import (
|
||||
"nofx/logger"
|
||||
"nofx/market"
|
||||
"nofx/mcp"
|
||||
_ "nofx/mcp/payment"
|
||||
_ "nofx/mcp/provider"
|
||||
"nofx/store"
|
||||
"time"
|
||||
|
||||
@@ -21,9 +18,6 @@ import (
|
||||
// validateStrategyConfig validates strategy configuration and returns warnings
|
||||
func validateStrategyConfig(config *store.StrategyConfig) []string {
|
||||
var warnings []string
|
||||
if config.StrategyType == "grid_trading" {
|
||||
return warnings
|
||||
}
|
||||
|
||||
// Validate NofxOS API key if any NofxOS feature is enabled
|
||||
if (config.Indicators.EnableQuantData || config.Indicators.EnableOIRanking ||
|
||||
@@ -35,31 +29,6 @@ func validateStrategyConfig(config *store.StrategyConfig) []string {
|
||||
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)
|
||||
func (s *Server) handlePublicStrategies(c *gin.Context) {
|
||||
strategies, err := s.store.Strategy().ListPublic()
|
||||
@@ -86,7 +55,6 @@ func (s *Server) handlePublicStrategies(c *gin.Context) {
|
||||
if st.ConfigVisible {
|
||||
var config store.StrategyConfig
|
||||
json.Unmarshal([]byte(st.Config), &config)
|
||||
attachPublishConfig(&config, st)
|
||||
item["config"] = config
|
||||
}
|
||||
|
||||
@@ -106,14 +74,6 @@ func (s *Server) handleGetStrategies(c *gin.Context) {
|
||||
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)
|
||||
if err != nil {
|
||||
SafeInternalError(c, "Failed to get strategy list", err)
|
||||
@@ -125,7 +85,6 @@ func (s *Server) handleGetStrategies(c *gin.Context) {
|
||||
for _, st := range strategies {
|
||||
var config store.StrategyConfig
|
||||
json.Unmarshal([]byte(st.Config), &config)
|
||||
attachPublishConfig(&config, st)
|
||||
|
||||
result = append(result, gin.H{
|
||||
"id": st.ID,
|
||||
@@ -164,7 +123,6 @@ func (s *Server) handleGetStrategy(c *gin.Context) {
|
||||
|
||||
var config store.StrategyConfig
|
||||
json.Unmarshal([]byte(strategy.Config), &config)
|
||||
attachPublishConfig(&config, strategy)
|
||||
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"id": strategy.ID,
|
||||
@@ -178,8 +136,7 @@ func (s *Server) handleGetStrategy(c *gin.Context) {
|
||||
})
|
||||
}
|
||||
|
||||
// handleCreateStrategy Create strategy.
|
||||
// If "config" is omitted from the request body, the system default config is used automatically.
|
||||
// handleCreateStrategy Create strategy
|
||||
func (s *Server) handleCreateStrategy(c *gin.Context) {
|
||||
userID := c.GetString("user_id")
|
||||
if userID == "" {
|
||||
@@ -188,12 +145,9 @@ func (s *Server) handleCreateStrategy(c *gin.Context) {
|
||||
}
|
||||
|
||||
var req struct {
|
||||
Name string `json:"name" binding:"required"`
|
||||
Description string `json:"description"`
|
||||
Lang string `json:"lang"` // "zh" or "en", used when config is omitted
|
||||
Config *store.StrategyConfig `json:"config"` // optional — uses default if omitted
|
||||
IsPublic bool `json:"is_public"`
|
||||
ConfigVisible bool `json:"config_visible"`
|
||||
Name string `json:"name" binding:"required"`
|
||||
Description string `json:"description"`
|
||||
Config store.StrategyConfig `json:"config" binding:"required"`
|
||||
}
|
||||
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
@@ -201,29 +155,6 @@ func (s *Server) handleCreateStrategy(c *gin.Context) {
|
||||
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
|
||||
configJSON, err := json.Marshal(req.Config)
|
||||
if err != nil {
|
||||
@@ -238,10 +169,7 @@ func (s *Server) handleCreateStrategy(c *gin.Context) {
|
||||
Description: req.Description,
|
||||
IsActive: false,
|
||||
IsDefault: false,
|
||||
IsPublic: isPublic,
|
||||
// Existing default is true; keep that behavior when no explicit publish config is sent.
|
||||
ConfigVisible: configVisible || !hadPublishConfig,
|
||||
Config: string(configJSON),
|
||||
Config: string(configJSON),
|
||||
}
|
||||
|
||||
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
|
||||
warnings := validateStrategyConfig(req.Config)
|
||||
warnings = append(warnings, store.StrategyClampWarnings(beforeClamp, *req.Config, req.Config.Language)...)
|
||||
warnings := validateStrategyConfig(&req.Config)
|
||||
|
||||
response := gin.H{
|
||||
"id": strategy.ID,
|
||||
@@ -264,10 +191,7 @@ func (s *Server) handleCreateStrategy(c *gin.Context) {
|
||||
c.JSON(http.StatusOK, response)
|
||||
}
|
||||
|
||||
// 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.
|
||||
// handleUpdateStrategy Update strategy
|
||||
func (s *Server) handleUpdateStrategy(c *gin.Context) {
|
||||
userID := c.GetString("user_id")
|
||||
strategyID := c.Param("id")
|
||||
@@ -289,11 +213,11 @@ func (s *Server) handleUpdateStrategy(c *gin.Context) {
|
||||
}
|
||||
|
||||
var req struct {
|
||||
Name string `json:"name"`
|
||||
Description string `json:"description"`
|
||||
Config json.RawMessage `json:"config"` // raw JSON so we can merge
|
||||
IsPublic bool `json:"is_public"`
|
||||
ConfigVisible bool `json:"config_visible"`
|
||||
Name string `json:"name"`
|
||||
Description string `json:"description"`
|
||||
Config store.StrategyConfig `json:"config"`
|
||||
IsPublic bool `json:"is_public"`
|
||||
ConfigVisible bool `json:"config_visible"`
|
||||
}
|
||||
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
@@ -301,40 +225,8 @@ func (s *Server) handleUpdateStrategy(c *gin.Context) {
|
||||
return
|
||||
}
|
||||
|
||||
// Start with the existing config as base — preserves all unmentioned fields.
|
||||
var mergedConfig store.StrategyConfig
|
||||
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)
|
||||
// Serialize configuration
|
||||
configJSON, err := json.Marshal(req.Config)
|
||||
if err != nil {
|
||||
SafeInternalError(c, "Serialize configuration", err)
|
||||
return
|
||||
@@ -343,8 +235,8 @@ func (s *Server) handleUpdateStrategy(c *gin.Context) {
|
||||
strategy := &store.Strategy{
|
||||
ID: strategyID,
|
||||
UserID: userID,
|
||||
Name: name,
|
||||
Description: description,
|
||||
Name: req.Name,
|
||||
Description: req.Description,
|
||||
Config: string(configJSON),
|
||||
IsPublic: req.IsPublic,
|
||||
ConfigVisible: req.ConfigVisible,
|
||||
@@ -355,28 +247,8 @@ func (s *Server) handleUpdateStrategy(c *gin.Context) {
|
||||
return
|
||||
}
|
||||
|
||||
// Token overflow check — block save if all models exceed context limits
|
||||
if mergedConfig.StrategyType == "" || mergedConfig.StrategyType == "ai_trading" {
|
||||
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)...)
|
||||
// Validate configuration and collect warnings
|
||||
warnings := validateStrategyConfig(&req.Config)
|
||||
|
||||
response := gin.H{"message": "Strategy updated successfully"}
|
||||
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 {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": SanitizeError(err, "Failed to delete strategy")})
|
||||
SafeInternalError(c, "Failed to delete strategy", err)
|
||||
return
|
||||
}
|
||||
|
||||
@@ -470,7 +342,6 @@ func (s *Server) handleGetActiveStrategy(c *gin.Context) {
|
||||
|
||||
var config store.StrategyConfig
|
||||
json.Unmarshal([]byte(strategy.Config), &config)
|
||||
attachPublishConfig(&config, strategy)
|
||||
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"id": strategy.ID,
|
||||
@@ -506,9 +377,9 @@ func (s *Server) handlePreviewPrompt(c *gin.Context) {
|
||||
}
|
||||
|
||||
var req struct {
|
||||
Config store.StrategyConfig `json:"config" binding:"required"`
|
||||
AccountEquity float64 `json:"account_equity"`
|
||||
PromptVariant string `json:"prompt_variant"`
|
||||
Config store.StrategyConfig `json:"config" binding:"required"`
|
||||
AccountEquity float64 `json:"account_equity"`
|
||||
PromptVariant string `json:"prompt_variant"`
|
||||
}
|
||||
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
@@ -570,17 +441,8 @@ func (s *Server) handleStrategyTestRun(c *gin.Context) {
|
||||
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
|
||||
engine := kernel.NewStrategyEngine(&req.Config, claw402WalletKey)
|
||||
engine := kernel.NewStrategyEngine(&req.Config)
|
||||
|
||||
// Get candidate coins
|
||||
candidates, err := engine.GetCandidateCoins()
|
||||
@@ -637,7 +499,6 @@ func (s *Server) handleStrategyTestRun(c *gin.Context) {
|
||||
symbols = append(symbols, c.Symbol)
|
||||
}
|
||||
quantDataMap := engine.FetchQuantDataBatch(symbols)
|
||||
vergexDataMap := engine.FetchVergexDataBatch(context.Background(), symbols)
|
||||
|
||||
// Fetch OI ranking data (market-wide position changes)
|
||||
oiRankingData := engine.FetchOIRankingData()
|
||||
@@ -668,7 +529,6 @@ func (s *Server) handleStrategyTestRun(c *gin.Context) {
|
||||
PromptVariant: req.PromptVariant,
|
||||
MarketDataMap: marketDataMap,
|
||||
QuantDataMap: quantDataMap,
|
||||
VergexDataMap: vergexDataMap,
|
||||
OIRankingData: oiRankingData,
|
||||
NetFlowRankingData: netFlowRankingData,
|
||||
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)
|
||||
}
|
||||
|
||||
// Create AI client via registry
|
||||
// Create AI client
|
||||
var aiClient mcp.AIClient
|
||||
provider := model.Provider
|
||||
|
||||
// Convert EncryptedString to string for API key
|
||||
apiKey := string(model.APIKey)
|
||||
|
||||
aiClient := mcp.NewAIClientByProvider(provider)
|
||||
if aiClient == nil {
|
||||
aiClient = mcp.NewClient()
|
||||
}
|
||||
|
||||
// Payment providers ignore custom URL
|
||||
switch provider {
|
||||
case "claw402":
|
||||
aiClient.SetAPIKey(apiKey, "", model.CustomModelName)
|
||||
case "qwen":
|
||||
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:
|
||||
// Use generic client
|
||||
aiClient = mcp.NewClient()
|
||||
aiClient.SetAPIKey(apiKey, model.CustomAPIURL, model.CustomModelName)
|
||||
}
|
||||
|
||||
@@ -763,6 +640,3 @@ func (s *Server) runRealAITest(userID, modelID, systemPrompt, userPrompt string)
|
||||
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:]
|
||||
}
|
||||
|
||||
// SanitizeModelConfigForLog Sanitize model configuration for log output.
|
||||
// Takes the same ModelConfigUpdate type used by the request handler so the two
|
||||
// can never drift out of sync.
|
||||
func SanitizeModelConfigForLog(models map[string]ModelConfigUpdate) map[string]interface{} {
|
||||
// SanitizeModelConfigForLog Sanitize model configuration for log output
|
||||
func SanitizeModelConfigForLog(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"`
|
||||
}) map[string]interface{} {
|
||||
safe := make(map[string]interface{})
|
||||
for modelID, cfg := range models {
|
||||
safe[modelID] = map[string]interface{}{
|
||||
@@ -31,12 +34,19 @@ func SanitizeModelConfigForLog(models map[string]ModelConfigUpdate) map[string]i
|
||||
return safe
|
||||
}
|
||||
|
||||
// SanitizeExchangeConfigForLog Sanitize exchange configuration for log output.
|
||||
// Takes the same ExchangeConfigUpdate type used by the request handler so every
|
||||
// sensitive field is guaranteed to be masked — adding a field to the request
|
||||
// type without masking it here would not compile around this helper, but more
|
||||
// importantly keeps the masking exhaustive.
|
||||
func SanitizeExchangeConfigForLog(exchanges map[string]ExchangeConfigUpdate) map[string]interface{} {
|
||||
// SanitizeExchangeConfigForLog Sanitize exchange configuration for log output
|
||||
func SanitizeExchangeConfigForLog(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"`
|
||||
}) map[string]interface{} {
|
||||
safe := make(map[string]interface{})
|
||||
for exchangeID, cfg := range exchanges {
|
||||
safeExchange := map[string]interface{}{
|
||||
@@ -51,18 +61,12 @@ func SanitizeExchangeConfigForLog(exchanges map[string]ExchangeConfigUpdate) map
|
||||
if cfg.SecretKey != "" {
|
||||
safeExchange["secret_key"] = MaskSensitiveString(cfg.SecretKey)
|
||||
}
|
||||
if cfg.Passphrase != "" {
|
||||
safeExchange["passphrase"] = MaskSensitiveString(cfg.Passphrase)
|
||||
}
|
||||
if cfg.AsterPrivateKey != "" {
|
||||
safeExchange["aster_private_key"] = MaskSensitiveString(cfg.AsterPrivateKey)
|
||||
}
|
||||
if 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
|
||||
if cfg.HyperliquidWalletAddr != "" {
|
||||
|
||||
@@ -1,8 +1,6 @@
|
||||
package api
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
@@ -50,7 +48,12 @@ func TestMaskSensitiveString(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": {
|
||||
Enabled: true,
|
||||
APIKey: "sk-1234567890abcdefghijklmnopqrstuvwxyz",
|
||||
@@ -85,29 +88,32 @@ func TestSanitizeModelConfigForLog(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": {
|
||||
Enabled: true,
|
||||
APIKey: "binance_api_key_1234567890abcdef",
|
||||
SecretKey: "binance_secret_key_1234567890abcdef",
|
||||
Testnet: false,
|
||||
},
|
||||
"okx": {
|
||||
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",
|
||||
LighterWalletAddr: "",
|
||||
LighterPrivateKey: "",
|
||||
},
|
||||
"hyperliquid": {
|
||||
Enabled: true,
|
||||
HyperliquidWalletAddr: "0x1234567890abcdef1234567890abcdef12345678",
|
||||
Testnet: false,
|
||||
LighterWalletAddr: "",
|
||||
LighterPrivateKey: "",
|
||||
},
|
||||
}
|
||||
|
||||
@@ -137,32 +143,6 @@ func TestSanitizeExchangeConfigForLog(t *testing.T) {
|
||||
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
|
||||
hlConfig, ok := result["hyperliquid"].(map[string]interface{})
|
||||
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) {
|
||||
tests := []struct {
|
||||
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
|
||||
|
||||
import (
|
||||
"crypto/rand"
|
||||
"fmt"
|
||||
"log"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"github.com/golang-jwt/jwt/v5"
|
||||
"github.com/google/uuid"
|
||||
"github.com/pquerna/otp/totp"
|
||||
"golang.org/x/crypto/bcrypt"
|
||||
)
|
||||
|
||||
@@ -22,6 +25,9 @@ var tokenBlacklist = struct {
|
||||
// maxBlacklistEntries is the maximum capacity threshold for blacklist
|
||||
const maxBlacklistEntries = 100_000
|
||||
|
||||
// OTPIssuer is the OTP issuer name
|
||||
const OTPIssuer = "nofxAI"
|
||||
|
||||
// SetJWTSecret sets the JWT secret key
|
||||
func SetJWTSecret(secret string) {
|
||||
JWTSecret = []byte(secret)
|
||||
@@ -81,6 +87,30 @@ func CheckPassword(password, hash string) bool {
|
||||
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
|
||||
func GenerateJWT(userID, email string) (string, error) {
|
||||
claims := Claims{
|
||||
@@ -117,3 +147,8 @@ func ValidateJWT(tokenString string) (*Claims, error) {
|
||||
|
||||
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
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"nofx/experience"
|
||||
"nofx/mcp"
|
||||
"nofx/telemetry"
|
||||
"os"
|
||||
"strconv"
|
||||
"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
|
||||
var global *Config
|
||||
|
||||
@@ -25,8 +15,10 @@ var global *Config
|
||||
// Only contains truly global config, trading related config is at trader/strategy level
|
||||
type Config struct {
|
||||
// Service configuration
|
||||
APIServerPort int
|
||||
JWTSecret string
|
||||
APIServerPort int
|
||||
JWTSecret string
|
||||
RegistrationEnabled bool
|
||||
MaxUsers int // Maximum number of users allowed (0 = unlimited, default = 10)
|
||||
|
||||
// Database configuration
|
||||
DBType string // sqlite or postgres
|
||||
@@ -52,29 +44,14 @@ type Config struct {
|
||||
AlpacaAPIKey string // Alpaca API key for US stocks
|
||||
AlpacaSecretKey string // Alpaca secret key
|
||||
TwelveDataKey string // TwelveData API key for forex & metals
|
||||
|
||||
}
|
||||
|
||||
// MustInit initializes global configuration or panics. Use from main() so the
|
||||
// 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.
|
||||
// Init initializes global configuration (from .env)
|
||||
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{
|
||||
APIServerPort: 8080,
|
||||
RegistrationEnabled: true,
|
||||
MaxUsers: 10, // Default: 10 users allowed
|
||||
ExperienceImprovement: true, // Default: enabled to help improve the product
|
||||
// Database defaults
|
||||
DBType: "sqlite",
|
||||
@@ -91,13 +68,17 @@ func initConfig() error {
|
||||
cfg.JWTSecret = strings.TrimSpace(v)
|
||||
}
|
||||
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 != "" {
|
||||
@@ -154,19 +135,17 @@ func initConfig() error {
|
||||
global = cfg
|
||||
|
||||
// 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
|
||||
mcp.TokenUsageCallback = func(usage mcp.TokenUsage) {
|
||||
telemetry.TrackAIUsage(telemetry.AIUsageEvent{
|
||||
experience.TrackAIUsage(experience.AIUsageEvent{
|
||||
ModelProvider: usage.Provider,
|
||||
ModelName: usage.Model,
|
||||
Channel: usage.Channel(),
|
||||
InputTokens: usage.PromptTokens,
|
||||
OutputTokens: usage.CompletionTokens,
|
||||
})
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// Get returns the global configuration
|
||||
|
||||
@@ -282,16 +282,12 @@ func isEncryptedStorageValue(value string) bool {
|
||||
}
|
||||
|
||||
func (cs *CryptoService) DecryptPayload(payload *EncryptedPayload) ([]byte, error) {
|
||||
// 1. Validate timestamp (prevent replay attacks).
|
||||
// The timestamp is mandatory: a missing/zero ts previously skipped this check
|
||||
// entirely, which let a captured ciphertext be replayed indefinitely. The
|
||||
// client (web/src/lib/crypto.ts) always stamps ts, so requiring it is safe.
|
||||
if payload.TS == 0 {
|
||||
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")
|
||||
// 1. Validate timestamp (prevent replay attacks)
|
||||
if payload.TS != 0 {
|
||||
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
|
||||
@@ -459,11 +455,8 @@ func (es EncryptedString) Value() (driver.Value, error) {
|
||||
if globalCryptoService != nil {
|
||||
encrypted, err := globalCryptoService.EncryptForStorage(string(es))
|
||||
if err != nil {
|
||||
// Fail closed: never silently persist a plaintext secret when
|
||||
// encryption was expected to happen. Returning the error aborts the
|
||||
// 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)
|
||||
// If encryption fails, return the original value
|
||||
return string(es), 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
|
||||
container_name: nofx-trading
|
||||
restart: unless-stopped
|
||||
stop_grace_period: 30s # Allow the app 30 seconds for graceful shutdown
|
||||
stop_grace_period: 30s # 允许应用有 30 秒时间优雅关闭
|
||||
ports:
|
||||
- "${NOFX_BACKEND_PORT:-8080}:8080"
|
||||
# pprof profiling is bound to host loopback only; uncomment for local debug.
|
||||
# - "127.0.0.1:6060:6060"
|
||||
- "6060:6060" # pprof profiling
|
||||
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
|
||||
- /etc/localtime:/etc/localtime:ro
|
||||
env_file:
|
||||
@@ -56,4 +49,4 @@ services:
|
||||
|
||||
networks:
|
||||
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 │
|
||||
├─────────────────────────────────────────────────────────────────────────────┤
|
||||
│ │
|
||||
│ ┌─────────────┐ ┌─────────────────────────────────────┐│
|
||||
│ │ Strategy │ │ Live Trading ││
|
||||
│ │ Studio │ │ (Auto Trader) ││
|
||||
│ └──────┬──────┘ └──────────────────┬──────────────────┘│
|
||||
│ │ │ │
|
||||
│ └────────────────────────────┘ │
|
||||
│ ┌─────────────┐ ┌─────────────┐ ┌─────────────┐ ┌─────────────────────┐│
|
||||
│ │ Strategy │ │ Backtest │ │ Debate │ │ Live Trading ││
|
||||
│ │ Studio │ │ Engine │ │ Arena │ │ (Auto Trader) ││
|
||||
│ └──────┬──────┘ └──────┬──────┘ └──────┬──────┘ └──────────┬──────────┘│
|
||||
│ │ │ │ │ │
|
||||
│ └────────────────┴────────────────┴────────────────────┘ │
|
||||
│ │ │
|
||||
│ ┌─────────▼─────────┐ │
|
||||
│ │ Core Services │ │
|
||||
@@ -57,6 +57,8 @@ NOFX is a full-stack AI trading platform for cryptocurrency and US stock markets
|
||||
| Module | Description | Documentation |
|
||||
|--------|-------------|---------------|
|
||||
| **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
|
||||
|
||||
@@ -70,6 +72,26 @@ Complete strategy configuration system including:
|
||||
|
||||
**[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
|
||||
@@ -80,6 +102,8 @@ nofx/
|
||||
├── api/ # HTTP API (Gin framework)
|
||||
├── trader/ # Trading execution layer
|
||||
├── strategy/ # Strategy engine
|
||||
├── backtest/ # Backtest simulation engine
|
||||
├── debate/ # Debate arena engine
|
||||
├── market/ # Market data service
|
||||
├── mcp/ # AI model clients
|
||||
├── store/ # Database operations
|
||||
@@ -119,6 +143,8 @@ nofx/
|
||||
## Quick Links
|
||||
|
||||
- [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
|
||||
- [FAQ](../faq/README.md) - Frequently asked questions
|
||||
|
||||
|
||||
@@ -24,12 +24,12 @@ NOFX 是一个支持加密货币和美股市场的全栈 AI 交易平台:
|
||||
│ NOFX 平台 │
|
||||
├─────────────────────────────────────────────────────────────────────────────┤
|
||||
│ │
|
||||
│ ┌─────────────┐ ┌─────────────────────────────────────┐│
|
||||
│ │ 策略 │ │ 实盘交易 ││
|
||||
│ │ 工作室 │ │ (自动交易员) ││
|
||||
│ └──────┬──────┘ └──────────────────┬──────────────────┘│
|
||||
│ │ │ │
|
||||
│ └────────────────────────────┘ │
|
||||
│ ┌─────────────┐ ┌─────────────┐ ┌─────────────┐ ┌─────────────────────┐│
|
||||
│ │ 策略 │ │ 回测 │ │ 辩论 │ │ 实盘交易 ││
|
||||
│ │ 工作室 │ │ 引擎 │ │ 竞技场 │ │ (自动交易员) ││
|
||||
│ └──────┬──────┘ └──────┬──────┘ └──────┬──────┘ └──────────┬──────────┘│
|
||||
│ │ │ │ │ │
|
||||
│ └────────────────┴────────────────┴────────────────────┘ │
|
||||
│ │ │
|
||||
│ ┌─────────▼─────────┐ │
|
||||
│ │ 核心服务 │ │
|
||||
@@ -57,6 +57,8 @@ NOFX 是一个支持加密货币和美股市场的全栈 AI 交易平台:
|
||||
| 模块 | 描述 | 文档 |
|
||||
|------|------|------|
|
||||
| **策略工作室** | 策略配置、币种选择、数据组装、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)**
|
||||
|
||||
#### 回测模块
|
||||
历史交易模拟引擎:
|
||||
- 多币种、多时间周期回测
|
||||
- AI 决策回放与缓存
|
||||
- 性能指标(夏普比率、最大回撤、胜率)
|
||||
- SSE 实时进度推送
|
||||
- 断点续测支持
|
||||
|
||||
**[阅读完整文档 →](BACKTEST_MODULE.md)**
|
||||
|
||||
#### 辩论模块
|
||||
多 AI 协作决策系统:
|
||||
- 5 种 AI 性格(多头、空头、分析师、逆势者、风控)
|
||||
- 多轮辩论与市场数据上下文
|
||||
- 加权投票与共识算法
|
||||
- 自动执行到实盘交易
|
||||
- SSE 实时流推送
|
||||
|
||||
**[阅读完整文档 →](DEBATE_MODULE.md)**
|
||||
|
||||
---
|
||||
|
||||
## 项目结构
|
||||
@@ -80,6 +102,8 @@ nofx/
|
||||
├── api/ # HTTP API (Gin 框架)
|
||||
├── trader/ # 交易执行层
|
||||
├── strategy/ # 策略引擎
|
||||
├── backtest/ # 回测模拟引擎
|
||||
├── debate/ # 辩论竞技场引擎
|
||||
├── market/ # 行情数据服务
|
||||
├── mcp/ # AI 模型客户端
|
||||
├── store/ # 数据库操作
|
||||
@@ -119,6 +143,8 @@ nofx/
|
||||
## 快速链接
|
||||
|
||||
- [策略模块](STRATEGY_MODULE.md) - 策略如何运作
|
||||
- [回测模块](BACKTEST_MODULE.md) - 回测如何运作
|
||||
- [辩论模块](DEBATE_MODULE.md) - AI 辩论如何运作
|
||||
- [快速开始](../getting-started/README.zh-CN.md) - 部署指南
|
||||
- [常见问题](../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+ ⭐)*
|
||||
@@ -241,7 +241,6 @@ NOFX offers bounties for valuable contributions:
|
||||
- **Want to claim bounty?** → [Bounty Guide](bounty-guide.md)
|
||||
- **Found a security issue?** → [Security Policy](../../SECURITY.md)
|
||||
- **Have questions?** → [Telegram Community](https://t.me/nofx_dev_community)
|
||||
- **Verify official accounts?** → [Official Accounts & Anti-Impersonation](OFFICIAL_ACCOUNTS.md)
|
||||
|
||||
---
|
||||
|
||||
|
||||
@@ -12,11 +12,8 @@ NOFX 文档提供多种语言版本。
|
||||
|----------|-------------|--------|-------------|
|
||||
| 🇬🇧 **English** | [README.md](../../README.md) | ✅ Complete | Core Team |
|
||||
| 🇨🇳 **Chinese (中文)** | [README.md](zh-CN/README.md) | ✅ Complete | Community |
|
||||
| 🇯🇵 **Japanese (日本語)** | [README.md](ja/README.md) | ✅ Complete | Community |
|
||||
| 🇰🇷 **Korean (한국어)** | [README.md](ko/README.md) | ✅ Complete | Community |
|
||||
| 🇷🇺 **Russian (Русский)** | [README.md](ru/README.md) | ✅ Complete | Community |
|
||||
| 🇺🇦 **Ukrainian (Українська)** | [README.md](uk/README.md) | ✅ Complete | Community |
|
||||
| 🇻🇳 **Vietnamese (Tiếng Việt)** | [README.md](vi/README.md) | ✅ Complete | Community |
|
||||
|
||||
---
|
||||
|
||||
@@ -33,16 +30,6 @@ NOFX 文档提供多种语言版本。
|
||||
- **安全政策:** [../../SECURITY.md](../../SECURITY.md#中文)
|
||||
- **常见问题:** [../guides/faq.zh-CN.md](../guides/faq.zh-CN.md)
|
||||
|
||||
### 日本語 🇯🇵
|
||||
- **メイン README:** [ja/README.md](ja/README.md)
|
||||
- **Contributing:** [../../CONTRIBUTING.md](../../CONTRIBUTING.md)
|
||||
- **Security:** [../../SECURITY.md](../../SECURITY.md)
|
||||
|
||||
### 한국어 🇰🇷
|
||||
- **메인 README:** [ko/README.md](ko/README.md)
|
||||
- **Contributing:** [../../CONTRIBUTING.md](../../CONTRIBUTING.md)
|
||||
- **Security:** [../../SECURITY.md](../../SECURITY.md)
|
||||
|
||||
### Русский 🇷🇺
|
||||
- **Основной README:** [ru/README.md](ru/README.md)
|
||||
- **Руководство по участию:** [../../CONTRIBUTING.md](../../CONTRIBUTING.md)
|
||||
@@ -53,11 +40,6 @@ NOFX 文档提供多种语言版本。
|
||||
- **Посібник із внесків:** [../../CONTRIBUTING.md](../../CONTRIBUTING.md)
|
||||
- **Політика безпеки:** [../../SECURITY.md](../../SECURITY.md)
|
||||
|
||||
### Tiếng Việt 🇻🇳
|
||||
- **README chính:** [vi/README.md](vi/README.md)
|
||||
- **Contributing:** [../../CONTRIBUTING.md](../../CONTRIBUTING.md)
|
||||
- **Security:** [../../SECURITY.md](../../SECURITY.md)
|
||||
|
||||
---
|
||||
|
||||
## 🤝 Help with Translations / 帮助翻译
|
||||
@@ -67,7 +49,7 @@ NOFX 文档提供多种语言版本。
|
||||
We welcome translation contributions! / 我们欢迎翻译贡献!
|
||||
|
||||
**What needs translation? / 需要翻译什么?**
|
||||
- ✅ Main README (complete for 7 languages)
|
||||
- ✅ Main README (complete for 4 languages)
|
||||
- 🚧 Deployment guides (partial)
|
||||
- 📋 User guides (needed)
|
||||
- 📋 Contributing guide (needed for RU/UK)
|
||||
@@ -112,11 +94,10 @@ faq.zh-CN.md → Chinese FAQ
|
||||
**Language Codes:**
|
||||
- `en` - English (default, no suffix needed)
|
||||
- `zh-CN` - Simplified Chinese
|
||||
- `ja` - Japanese
|
||||
- `ko` - Korean
|
||||
- `ru` - Russian
|
||||
- `uk` - Ukrainian
|
||||
- `vi` - Vietnamese
|
||||
- `ja` - Japanese *(future)*
|
||||
- `ko` - Korean *(future)*
|
||||
|
||||
### Quality Standards / 质量标准
|
||||
|
||||
|
||||
@@ -1,282 +1,308 @@
|
||||
<p align="center"><strong><a href="https://vergex.trade">vergex.trade</a> の支援を受けています</strong></p>
|
||||
# NOFX - AI トレーディングシステム
|
||||
|
||||
<p align="center">
|
||||
<img src="../../assets/nofx-banner.svg" alt="NOFX — AI トレーディングターミナル" width="100%"/>
|
||||
</p>
|
||||
[](https://golang.org/)
|
||||
[](https://reactjs.org/)
|
||||
[](https://www.typescriptlang.org/)
|
||||
[](LICENSE)
|
||||
|
||||
<p align="center">
|
||||
<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>
|
||||
<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>
|
||||
<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>
|
||||
**言語:** [English](../../../README.md) | [中文](../zh-CN/README.md) | [日本語](README.md)
|
||||
|
||||
<p align="center">
|
||||
<a href="../../../README.md">English</a> ·
|
||||
<a href="../zh-CN/README.md">中文</a> ·
|
||||
<a href="README.md">日本語</a> ·
|
||||
<a href="../ko/README.md">한국어</a> ·
|
||||
<a href="../ru/README.md">Русский</a> ·
|
||||
<a href="../uk/README.md">Українська</a> ·
|
||||
<a href="../vi/README.md">Tiếng Việt</a>
|
||||
</p>
|
||||
---
|
||||
|
||||
<br/>
|
||||
## AI 駆動の暗号通貨取引プラットフォーム
|
||||
|
||||
NOFX は、戦略そのものが言語モデルであるオープンソースのトレーディングターミナルです。各トレーダーは、市場構造を読み取り、判断し、執行し、その根拠を記録するという連続的なループを回し続けます。その間、Go ランタイムがすべての注文を、モデルには上書きできないハードなリスク制限内に抑え込みます。
|
||||
**NOFX** は、複数の AI モデルを使用して暗号通貨先物を自動取引できるオープンソースの AI 取引システムです。Web インターフェースで戦略を設定し、リアルタイムでパフォーマンスを監視し、AI エージェントを競わせて最適な取引アプローチを見つけます。
|
||||
|
||||
トレーダーの構成は自由です。任意のモデル、9 つの取引所のいずれか、任意の戦略を組み合わせられます。複数のトレーダーを並行して走らせ、実現リターンに基づく公開リーダーボードで比較できます。すべては自分のマシン上で動作し、取引所の認証情報は保存時に暗号化され、外部に送信されることはありません。
|
||||
### コア機能
|
||||
|
||||
- **マルチ AI サポート**: DeepSeek、Qwen、GPT、Claude、Gemini、Grok、Kimi を実行 - いつでもモデルを切り替え可能
|
||||
- **マルチ取引所**: Binance、Bybit、OKX、Hyperliquid、Aster DEX、Lighter で統一取引
|
||||
- **ストラテジースタジオ**: コインソース、インジケーター、リスク管理を設定するビジュアル戦略ビルダー
|
||||
- **AI 競争モード**: 複数の AI トレーダーがリアルタイムで競争、パフォーマンスを並べて追跡
|
||||
- **Web ベース設定**: JSON 編集不要 - Web インターフェースですべて設定
|
||||
- **リアルタイムダッシュボード**: ライブポジション、損益追跡、思考連鎖付き AI 決定ログ
|
||||
|
||||
### 公式リンク
|
||||
|
||||
- **公式サイト**: [https://nofxai.com](https://nofxai.com)
|
||||
- **データダッシュボード**: [https://nofxos.ai/dashboard](https://nofxos.ai/dashboard)
|
||||
- **API ドキュメント**: [https://nofxos.ai/api-docs](https://nofxos.ai/api-docs)
|
||||
|
||||
> **リスク警告**: このシステムは実験的です。AI 自動取引には重大なリスクがあります。学習/研究目的または少額でのテストのみを強くお勧めします!
|
||||
|
||||
## 開発者コミュニティ
|
||||
|
||||
Telegram 開発者コミュニティに参加: **[NOFX 開発者コミュニティ](https://t.me/nofx_dev_community)**
|
||||
|
||||
---
|
||||
|
||||
## 始める前に
|
||||
|
||||
NOFXを使用するには以下が必要です:
|
||||
|
||||
1. **取引所アカウント** - サポートされている取引所に登録し、取引権限付きのAPI認証情報を作成
|
||||
2. **AI モデル API キー** - サポートされているプロバイダーから取得(コスト効率の良いDeepSeekを推奨)
|
||||
|
||||
---
|
||||
|
||||
## サポート取引所
|
||||
|
||||
### CEX (中央集権型取引所)
|
||||
|
||||
| 取引所 | ステータス | 登録 (手数料割引) |
|
||||
|----------|--------|-------------------------|
|
||||
| **Binance** | ✅ サポート | [登録](https://www.binance.com/join?ref=NOFXENG) |
|
||||
| **Bybit** | ✅ サポート | [登録](https://partner.bybit.com/b/83856) |
|
||||
| **OKX** | ✅ サポート | [登録](https://www.okx.com/join/1865360) |
|
||||
| **Bitget** | ✅ サポート | [登録](https://www.bitget.com/referral/register?from=referral&clacCode=c8a43172) |
|
||||
|
||||
### Perp-DEX (分散型永久先物取引所)
|
||||
|
||||
| 取引所 | ステータス | 登録 (手数料割引) |
|
||||
|----------|--------|-------------------------|
|
||||
| **Hyperliquid** | ✅ サポート | [登録](https://app.hyperliquid.xyz/join/AITRADING) |
|
||||
| **Aster DEX** | ✅ サポート | [登録](https://www.asterdex.com/en/referral/fdfc0e) |
|
||||
| **Lighter** | ✅ サポート | [登録](https://app.lighter.xyz/?referral=68151432) |
|
||||
|
||||
---
|
||||
|
||||
## サポート AI モデル
|
||||
|
||||
| AI モデル | ステータス | API キー取得 |
|
||||
|----------|--------|-------------|
|
||||
| **DeepSeek** | ✅ サポート | [API キー取得](https://platform.deepseek.com) |
|
||||
| **Qwen** | ✅ サポート | [API キー取得](https://dashscope.console.aliyun.com) |
|
||||
| **OpenAI (GPT)** | ✅ サポート | [API キー取得](https://platform.openai.com) |
|
||||
| **Claude** | ✅ サポート | [API キー取得](https://console.anthropic.com) |
|
||||
| **Gemini** | ✅ サポート | [API キー取得](https://aistudio.google.com) |
|
||||
| **Grok** | ✅ サポート | [API キー取得](https://console.x.ai) |
|
||||
| **Kimi** | ✅ サポート | [API キー取得](https://platform.moonshot.cn) |
|
||||
|
||||
---
|
||||
|
||||
## クイックスタート
|
||||
|
||||
### オプション 1: Docker デプロイ(推奨)
|
||||
|
||||
```bash
|
||||
git clone https://github.com/NoFxAiOS/nofx.git
|
||||
cd nofx
|
||||
chmod +x ./start.sh
|
||||
./start.sh start --build
|
||||
```
|
||||
|
||||
Web インターフェースにアクセス: **http://localhost:3000**
|
||||
|
||||
### 最新版への更新
|
||||
|
||||
> **💡 更新は頻繁です。** 最新の機能と修正を取得するために、毎日このコマンドを実行してください:
|
||||
|
||||
```bash
|
||||
curl -fsSL https://raw.githubusercontent.com/NoFxAiOS/nofx/main/install.sh | bash
|
||||
```
|
||||
|
||||
ターミナルは `http://127.0.0.1:3000` で開きます。
|
||||
このコマンドは最新の公式イメージを取得し、サービスを自動的に再起動します。
|
||||
|
||||
**初回起動**
|
||||
|
||||
1. 登録します — 最初に作成したアカウントがこのインスタンスのオーナーになります。
|
||||
2. ガイド付きの起動手順に従います。自動作成される AI 手数料ウォレットに **$1+ USDC**(Base ネットワーク)を入金し、続いて Hyperliquid を接続して取引資金として **$12+ USDC** を入金します。
|
||||
3. **Autopilot** を開始します。AI は数分ごとに市場をスキャンして自律的に取引し、すべての判断はその場でダッシュボードに表示されます。ワンクリックでいつでも停止できます。
|
||||
|
||||
<br/>
|
||||
|
||||
## 取引所の登録
|
||||
|
||||
NOFX は無料のオープンソースソフトウェアです。以下のパートナーリンク経由で口座を開設すると、取引手数料の割引が受けられるうえ、継続的な開発の資金にもなります。
|
||||
|
||||
| 取引所 | 状態 | 手数料割引付きで登録 |
|
||||
| :---------------------------------------------------------------------------------------------------------------------------- | :----: | :---------------------------------------------------------------------------------- |
|
||||
| <img src="../../../web/public/exchange-icons/binance.jpg" width="20" height="20" style="vertical-align: middle;"/> **Binance** | ✅ | [登録](https://www.binance.com/join?ref=NOFXENG) |
|
||||
| <img src="../../../web/public/exchange-icons/bybit.png" width="20" height="20" style="vertical-align: middle;"/> **Bybit** | ✅ | [登録](https://partner.bybit.com/b/83856) |
|
||||
| <img src="../../../web/public/exchange-icons/okx.svg" width="20" height="20" style="vertical-align: middle;"/> **OKX** | ✅ | [登録](https://www.okx.com/join/1865360) |
|
||||
| <img src="../../../web/public/exchange-icons/hyperliquid.png" width="20" height="20" style="vertical-align: middle;"/> **Hyperliquid** | ✅ | [登録](https://app.hyperliquid.xyz/join/AITRADING) |
|
||||
| <img src="../../../web/public/exchange-icons/bitget.svg" width="20" height="20" style="vertical-align: middle;"/> **Bitget** | ✅ | [登録](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** | ✅ | [登録](https://www.kucoin.com/r/broker/CXEV7XKK) |
|
||||
| <img src="../../../web/public/exchange-icons/gate.svg" width="20" height="20" style="vertical-align: middle;"/> **Gate** | ✅ | [登録](https://www.gatenode.xyz/share/VQBGUAxY) |
|
||||
| <img src="../../../web/public/exchange-icons/aster.svg" width="20" height="20" style="vertical-align: middle;"/> **Aster** | ✅ | [登録](https://www.asterdex.com/en/referral/fdfc0e) |
|
||||
| <img src="../../../web/public/exchange-icons/lighter.png" width="20" height="20" style="vertical-align: middle;"/> **Lighter** | ✅ | [登録](https://app.lighter.xyz/?referral=68151432) |
|
||||
|
||||
<br/>
|
||||
|
||||
## デモ
|
||||
|
||||
https://github.com/user-attachments/assets/3310f495-14c5-4586-a1cc-3d32e44aa505
|
||||
|
||||
<br/>
|
||||
|
||||
## モデルは提案する。ランタイムが決める。
|
||||
|
||||
判断は、[Claw402.ai](https://claw402.ai)・Vergex のデータスタックを読み取る言語モデルから生まれます。全市場を方向バイアスとシグナル強度でランク付けするライブシグナルボード、銘柄ごとの Signal Lab ディープシグナル、群衆の「燃料」と「壁」の位置を映すコストベース・清算ヒートマップ、リアルタイムのネットフロー — これらを生のローソク足とトレーダー自身のライブ実績と突き合わせます。ただし、執行はそうではありません。
|
||||
|
||||
すべての注文は、モデルの手が届かない、コードで強制されるリスク制限を通過します。
|
||||
|
||||
| | |
|
||||
| :----------------------- | :--------------------------------------------------------------------------------- |
|
||||
| ポジション制限 | 同時ポジション数の上限、想定元本は口座資産に対する比率で制限、1 シンボルにつき 1 ポジション |
|
||||
| レバレッジ制限 | モデルの要求とは無関係に、注文サイズの算出時にハードキャップを適用 |
|
||||
| 取引所側の保護 | すべてのエントリー直後に、ストップロスとテイクプロフィットを取引所側に設置 |
|
||||
| ドローダウン自動クローズ | ピークから利益を大きく吐き出したポジションは自動的にクローズ |
|
||||
| 取引スロットリング | 最低保有時間、シンボルごとの再エントリークールダウン、サイクルごと・1 時間ごとのエントリー回数制限 |
|
||||
| セーフモード | モデルの失敗が繰り返された場合、モデルが回復するまで新規エントリーをブロック |
|
||||
| 起動前チェック | トレーダーの開始前に、モデルアクセス、ウォレット資金、戦略、取引所残高を検証 |
|
||||
|
||||
各判断は、モデルの推論全文とともに保存されます。記録の残らないポジションは存在しません。
|
||||
|
||||
<br/>
|
||||
|
||||
## ターミナル
|
||||
|
||||
| | |
|
||||
| :--- | :--- |
|
||||
| **Autopilot** | ガイド付きの起動フロー:資金投入、接続、入金、開始 — 全工程をサーバー側の事前チェックが支えます |
|
||||
| **Strategy Studio** | スタイルプリセット、コインユニバース、インジケーター、レバレッジ、エントリー確信度、カスタムプロンプト |
|
||||
| **Competition** | 実現リターンでランク付けされる公開リーダーボード。各エントリーには使用モデルが明記されます |
|
||||
| **Dashboard** | ライブのポジション、注文、統計、そしてすべての判断の背後にある推論 |
|
||||
|
||||
<details>
|
||||
<summary>スクリーンショット</summary>
|
||||
|
||||
<br/>
|
||||
|
||||
| 概要 | マーケットチャート |
|
||||
| :-----------------------------------------------------: | :-------------------------------------------------------------: |
|
||||
| <img src="../../../screenshots/dashboard-page.png" width="400"/> | <img src="../../../screenshots/dashboard-market-chart.png" width="400"/> |
|
||||
|
||||
| 取引統計 | ポジション履歴 |
|
||||
| :--------------------------------------------------------------: | :-----------------------------------------------------------------: |
|
||||
| <img src="../../../screenshots/dashboard-trading-stats.png" width="400"/> | <img src="../../../screenshots/dashboard-position-history.png" width="400"/> |
|
||||
|
||||
| 戦略エディタ | インジケーター設定 |
|
||||
| :------------------------------------------------------: | :----------------------------------------------------------: |
|
||||
| <img src="../../../screenshots/strategy-studio.png" width="400"/> | <img src="../../../screenshots/strategy-indicators.png" width="400"/> |
|
||||
|
||||
| コンペティション | 設定 |
|
||||
| :-------------------------------------------------------: | :-----------------------------------------------------------: |
|
||||
| <img src="../../../screenshots/competition-page.png" width="400"/> | <img src="../../../screenshots/config-ai-exchanges.png" width="400"/> |
|
||||
|
||||
</details>
|
||||
|
||||
<br/>
|
||||
|
||||
## モデル
|
||||
|
||||
自分の API キーで 8 つのプロバイダーを利用できます — DeepSeek、OpenAI、Claude、Qwen、Gemini、Grok、Kimi、MiniMax。カスタムエンドポイントとカスタムモデル名にも対応しています。
|
||||
|
||||
あるいは、キーを一切使わない方法もあります。[Claw402](https://claw402.ai) は x402 プロトコル上で、モデルの利用量を呼び出しごとに USDC で課金します。Base 上のウォレットひとつが、すべての API キーの代わりになります。
|
||||
|
||||
| プロバイダー | アクセス |
|
||||
| :------- | :----- |
|
||||
| **Claw402** | [公式割引付きの従量課金 AI モデル](https://claw402.ai) |
|
||||
|
||||
## マーケット
|
||||
|
||||
9 つの取引所すべてで暗号資産の無期限先物を取引できます。Hyperliquid では、同じランタイムが暗号資産に加えて、トークン化された米国株、コモディティ、株価指数、FX、プレ IPO 無期限先物 — TSLA、NVDA、GOLD、SPX、EUR、OPENAI — も取引します。
|
||||
|
||||
<br/>
|
||||
|
||||
## アーキテクチャ
|
||||
|
||||
```
|
||||
┌─────────────────────────────────────────────────┐
|
||||
│ 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/>
|
||||
|
||||
## インストール
|
||||
|
||||
**Linux / macOS**
|
||||
### オプション 2: 手動インストール
|
||||
|
||||
```bash
|
||||
# 前提条件: Go 1.21+, Node.js 18+, TA-Lib
|
||||
|
||||
# TA-Lib インストール (macOS)
|
||||
brew install ta-lib
|
||||
|
||||
# クローンとセットアップ
|
||||
git clone https://github.com/NoFxAiOS/nofx.git
|
||||
cd nofx
|
||||
go mod download
|
||||
cd web && npm install && cd ..
|
||||
|
||||
# バックエンド起動
|
||||
go build -o nofx && ./nofx
|
||||
|
||||
# フロントエンド起動(新しいターミナル)
|
||||
cd web && npm run dev
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 初期設定
|
||||
|
||||
1. **AI モデル設定** - AI API キーを追加
|
||||
2. **取引所設定** - 取引所 API 認証情報を設定
|
||||
3. **戦略作成** - ストラテジースタジオで取引戦略を設定
|
||||
4. **トレーダー作成** - AI モデル + 取引所 + 戦略を組み合わせ
|
||||
5. **取引開始** - 設定したトレーダーを起動
|
||||
|
||||
---
|
||||
|
||||
## リスク警告
|
||||
|
||||
1. 暗号通貨市場は非常に変動が激しい - AI の決定は利益を保証しない
|
||||
2. 先物取引はレバレッジを使用 - 損失は元本を超える可能性がある
|
||||
3. 極端な市場状況では清算リスクがある
|
||||
|
||||
---
|
||||
|
||||
## サーバー展開
|
||||
|
||||
### クイックデプロイ (HTTP経由のIP)
|
||||
|
||||
デフォルトでは、トランスポート暗号化は**無効**になっており、HTTPSなしでIPアドレス経由でNOFXにアクセスできます:
|
||||
|
||||
```bash
|
||||
# サーバーにデプロイ
|
||||
curl -fsSL https://raw.githubusercontent.com/NoFxAiOS/nofx/main/install.sh | bash
|
||||
```
|
||||
|
||||
**Railway**
|
||||
`http://YOUR_SERVER_IP:3000` 経由でアクセス - すぐに動作します。
|
||||
|
||||
[](https://railway.com/deploy/nofx?referralCode=nofx)
|
||||
### セキュリティ強化 (HTTPS)
|
||||
|
||||
**Docker**
|
||||
セキュリティを強化するには、`.env`でトランスポート暗号化を有効にします:
|
||||
|
||||
```bash
|
||||
curl -O https://raw.githubusercontent.com/NoFxAiOS/nofx/main/docker-compose.prod.yml
|
||||
docker compose -f docker-compose.prod.yml up -d
|
||||
TRANSPORT_ENCRYPTION=true
|
||||
```
|
||||
|
||||
**Windows** — [Docker Desktop](https://www.docker.com/products/docker-desktop/) をインストールしてから、次を実行します。
|
||||
有効にすると、ブラウザはWeb Crypto APIを使用して転送前にAPIキーを暗号化します。これには以下が必要です:
|
||||
- `https://` - SSLを備えた任意のドメイン
|
||||
- `http://localhost` - ローカル開発
|
||||
|
||||
```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
|
||||
```
|
||||
### Cloudflareを使用した簡単なHTTPSセットアップ
|
||||
|
||||
**ソースからビルド** — Go 1.21+、Node.js 18+ が必要です。
|
||||
1. **ドメインをCloudflareに追加** (無料プランでOK)
|
||||
- [dash.cloudflare.com](https://dash.cloudflare.com) にアクセス
|
||||
- ドメインを追加してネームサーバーを更新
|
||||
|
||||
2. **DNSレコードを作成**
|
||||
- タイプ: `A`
|
||||
- 名前: `nofx` (またはサブドメイン)
|
||||
- コンテンツ: サーバーのIP
|
||||
- プロキシ状態: **Proxied** (オレンジ色の雲)
|
||||
|
||||
3. **SSL/TLSを設定**
|
||||
- SSL/TLS設定に移動
|
||||
- 暗号化モードを **Flexible** に設定
|
||||
|
||||
```
|
||||
User ──[HTTPS]──→ Cloudflare ──[HTTP]──→ Your Server:3000
|
||||
```
|
||||
|
||||
4. **トランスポート暗号化を有効化**
|
||||
```bash
|
||||
# .envを編集して設定
|
||||
TRANSPORT_ENCRYPTION=true
|
||||
```
|
||||
|
||||
5. **完了!** `https://nofx.yourdomain.com` 経由でアクセス
|
||||
|
||||
---
|
||||
|
||||
## 初期設定 (Webインターフェース)
|
||||
|
||||
システムを起動した後、Webインターフェースを通じて設定します:
|
||||
|
||||
1. **AIモデルの設定** - AI APIキーを追加 (DeepSeek、OpenAI など)
|
||||
2. **取引所の設定** - 取引所API認証情報を設定
|
||||
3. **戦略の作成** - ストラテジースタジオで取引戦略を設定
|
||||
4. **トレーダーの作成** - AIモデル + 取引所 + 戦略を組み合わせ
|
||||
5. **取引開始** - 設定したトレーダーを起動
|
||||
|
||||
すべての設定はWebインターフェースで完了 - JSONファイルの編集は不要です。
|
||||
|
||||
---
|
||||
|
||||
## Webインターフェース機能
|
||||
|
||||
### 競争ページ
|
||||
- リアルタイムROIリーダーボード
|
||||
- マルチAIパフォーマンス比較チャート
|
||||
- ライブ損益追跡とランキング
|
||||
|
||||
### ダッシュボード
|
||||
- TradingViewスタイルのローソク足チャート
|
||||
- リアルタイムポジション管理
|
||||
- Chain of Thought推論付きAI決定ログ
|
||||
- エクイティカーブ追跡
|
||||
|
||||
### ストラテジースタジオ
|
||||
- コインソース設定 (静的リスト、AI500プール、OI Top)
|
||||
- テクニカル指標 (EMA、MACD、RSI、ATR、出来高、OI、資金調達率)
|
||||
- リスク管理設定 (レバレッジ、ポジション制限、証拠金使用率)
|
||||
- リアルタイムプロンプトプレビュー付きAIテスト
|
||||
|
||||
---
|
||||
|
||||
## よくある問題
|
||||
|
||||
### TA-Libが見つからない
|
||||
```bash
|
||||
git clone https://github.com/NoFxAiOS/nofx.git && cd nofx
|
||||
go build -o nofx && ./nofx # backend
|
||||
cd web && npm install && npm run dev # frontend, in a second terminal
|
||||
# macOS
|
||||
brew install ta-lib
|
||||
|
||||
# Ubuntu
|
||||
sudo apt-get install libta-lib0-dev
|
||||
```
|
||||
|
||||
**アップデート** — インストールスクリプトを再実行すると、その場でアップグレードされます。
|
||||
### AI APIタイムアウト
|
||||
- APIキーが正しいか確認
|
||||
- ネットワーク接続を確認
|
||||
- システムタイムアウトは120秒
|
||||
|
||||
<details>
|
||||
<summary>サーバーへのデプロイ</summary>
|
||||
### フロントエンドがバックエンドに接続できない
|
||||
- バックエンドが http://localhost:8080 で実行されているか確認
|
||||
- ポートが占有されていないか確認
|
||||
|
||||
<br/>
|
||||
|
||||
**HTTP**
|
||||
|
||||
```bash
|
||||
curl -fsSL https://raw.githubusercontent.com/NoFxAiOS/nofx/main/install.sh | bash
|
||||
# http://YOUR_IP:3000
|
||||
```
|
||||
|
||||
**Cloudflare 経由の HTTPS**
|
||||
|
||||
1. [Cloudflare](https://dash.cloudflare.com)(無料プラン)にドメインを追加
|
||||
2. A レコード → サーバー IP(Proxied を有効化)
|
||||
3. SSL/TLS → Flexible
|
||||
4. `.env` に `TRANSPORT_ENCRYPTION=true` を設定
|
||||
|
||||
</details>
|
||||
|
||||
<br/>
|
||||
|
||||
## ドキュメント
|
||||
|
||||
| | |
|
||||
| :------------------------------------------------------ | :------------------------------------ |
|
||||
| [はじめに](../../getting-started/README.md) | デプロイと取引所 API のガイド |
|
||||
| [アーキテクチャ](../../architecture/README.md) | システム設計とモジュール索引 |
|
||||
| [戦略モジュール](../../architecture/STRATEGY_MODULE.md) | 銘柄選択、AI プロンプト、執行 |
|
||||
| [FAQ](../../guides/faq.en.md) | よくある質問 |
|
||||
| [トラブルシューティング](../../guides/TROUBLESHOOTING.md) | よくある問題の診断 |
|
||||
|
||||
## コミュニティ
|
||||
|
||||
[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) · [ライブダッシュボード](https://vergex.trade/explore)
|
||||
|
||||
## コントリビューション
|
||||
|
||||
コード、ドキュメント、翻訳、バグ報告のいずれも歓迎します。詳細は[貢献ガイド](../../../CONTRIBUTING.md)、[行動規範](../../../CODE_OF_CONDUCT.md)、[セキュリティポリシー](../../../SECURITY.md)をご覧ください。
|
||||
|
||||
NOFX は有意義な貢献を記録しており、エコシステムの成長に応じて貢献者に還元していく予定です。優先度の高い Issue にはより高いウェイトが付きます。
|
||||
|
||||
| 貢献の種類 | ウェイト |
|
||||
| :---------------- | :----: |
|
||||
| ピン留め Issue の PR | ★★★★★★ |
|
||||
| コード(マージ済み PR) | ★★★★★ |
|
||||
| バグ修正 | ★★★★ |
|
||||
| 機能アイデア | ★★★ |
|
||||
| バグ報告 | ★★ |
|
||||
| ドキュメント | ★★ |
|
||||
|
||||
<a href="https://github.com/NoFxAiOS/nofx/graphs/contributors">
|
||||
<img src="https://contrib.rocks/image?repo=NoFxAiOS/nofx" alt="Contributors"/>
|
||||
</a>
|
||||
|
||||
## スポンサー
|
||||
|
||||
<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>
|
||||
|
||||
[スポンサーになる](https://github.com/sponsors/NoFxAiOS)
|
||||
|
||||
<br/>
|
||||
|
||||
NOFX が役に立ったら、スターを付けていただけると、他のトレーダーがこのプロジェクトを見つけやすくなります。
|
||||
|
||||
[](https://star-history.com/#NoFxAiOS/nofx&Date)
|
||||
---
|
||||
|
||||
## ライセンス
|
||||
|
||||
[AGPL-3.0](../../../LICENSE)
|
||||
このプロジェクトは **GNU Affero General Public License v3.0 (AGPL-3.0)** の下でライセンスされています - [LICENSE](LICENSE) ファイルを参照してください。
|
||||
|
||||
<sub>自動売買には大きなリスクが伴います。AI 駆動の戦略は実験的なものであり、損失を出す可能性があります。適切なポジションサイズを守り、各取引所の仕組みを理解し、失っても差し支えのない資金以外では決して取引しないでください。詳細は[免責事項](../../../DISCLAIMER.md)をご覧ください。</sub>
|
||||
---
|
||||
|
||||
## 貢献
|
||||
|
||||
貢献を歓迎します!以下を参照してください:
|
||||
- **[貢献ガイド](CONTRIBUTING.md)** - 開発ワークフローとPRプロセス
|
||||
- **[行動規範](CODE_OF_CONDUCT.md)** - コミュニティガイドライン
|
||||
- **[セキュリティポリシー](SECURITY.md)** - 脆弱性の報告
|
||||
|
||||
---
|
||||
|
||||
## 貢献者エアドロッププログラム
|
||||
|
||||
すべての貢献はGitHubで追跡されます。NOFXが収益を生み出すと、貢献者は貢献に基づいてエアドロップを受け取ります。
|
||||
|
||||
**[ピン留めされたIssue](https://github.com/NoFxAiOS/nofx/issues)を解決するPRは最高報酬を受け取ります!**
|
||||
|
||||
| 貢献タイプ | 重み |
|
||||
|------------------|:------:|
|
||||
| **ピン留めIssue PR** | ⭐⭐⭐⭐⭐⭐ |
|
||||
| **コードコミット** (マージされたPR) | ⭐⭐⭐⭐⭐ |
|
||||
| **バグ修正** | ⭐⭐⭐⭐ |
|
||||
| **機能提案** | ⭐⭐⭐ |
|
||||
| **バグ報告** | ⭐⭐ |
|
||||
| **ドキュメント** | ⭐⭐ |
|
||||
|
||||
---
|
||||
|
||||
## リスク警告
|
||||
|
||||
1. 暗号通貨市場は非常に変動が激しい - AIの決定は利益を保証しない
|
||||
2. 先物取引はレバレッジを使用 - 損失は元本を超える可能性がある
|
||||
3. 極端な市場状況では清算リスクがある
|
||||
|
||||
|
||||
|
||||
## コンタクト
|
||||
|
||||
- **GitHub Issues**: [Issue を提出](https://github.com/NoFxAiOS/nofx/issues)
|
||||
- **開発者コミュニティ**: [Telegram グループ](https://t.me/nofx_dev_community)
|
||||
|
||||
---
|
||||
|
||||
## Star History
|
||||
|
||||
[](https://star-history.com/#NoFxAiOS/nofx&Date)
|
||||
|
||||
@@ -1,282 +1,309 @@
|
||||
<p align="center"><strong><a href="https://vergex.trade">vergex.trade</a>가 지원합니다</strong></p>
|
||||
# NOFX - AI 트레이딩 시스템
|
||||
|
||||
<p align="center">
|
||||
<img src="../../assets/nofx-banner.svg" alt="NOFX — AI trading terminal" width="100%"/>
|
||||
</p>
|
||||
[](https://golang.org/)
|
||||
[](https://reactjs.org/)
|
||||
[](https://www.typescriptlang.org/)
|
||||
[](LICENSE)
|
||||
|
||||
<p align="center">
|
||||
<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>
|
||||
<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>
|
||||
<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>
|
||||
**언어:** [English](../../../README.md) | [中文](../zh-CN/README.md) | [한국어](README.md)
|
||||
|
||||
<p align="center">
|
||||
<a href="../../../README.md">English</a> ·
|
||||
<a href="../zh-CN/README.md">中文</a> ·
|
||||
<a href="../ja/README.md">日本語</a> ·
|
||||
<a href="README.md">한국어</a> ·
|
||||
<a href="../ru/README.md">Русский</a> ·
|
||||
<a href="../uk/README.md">Українська</a> ·
|
||||
<a href="../vi/README.md">Tiếng Việt</a>
|
||||
</p>
|
||||
---
|
||||
|
||||
<br/>
|
||||
## AI 기반 암호화폐 거래 플랫폼
|
||||
|
||||
NOFX는 언어 모델이 곧 전략이 되는 오픈소스 트레이딩 터미널입니다. 각 트레이더는 시장 구조 읽기, 판단, 실행, 근거 기록으로 이어지는 루프를 끊임없이 반복하고, Go 런타임은 모든 주문을 모델이 절대 우회할 수 없는 하드 리스크 한도 안에 묶어 둡니다.
|
||||
**NOFX**는 여러 AI 모델을 실행하여 암호화폐 선물을 자동으로 거래할 수 있는 오픈소스 AI 거래 시스템입니다. 웹 인터페이스를 통해 전략을 구성하고, 실시간으로 성과를 모니터링하며, AI 에이전트들이 최적의 거래 방식을 찾도록 경쟁시킵니다.
|
||||
|
||||
트레이더는 자유롭게 조합할 수 있습니다. 어떤 모델이든, 아홉 개 거래소 중 어디든, 어떤 전략이든 가능합니다. 여러 트레이더를 나란히 돌리면서 실현 수익률 기준의 공개 리더보드에서 비교해 보세요. 모든 것은 사용자의 머신에서 실행되며, 거래소 자격 증명은 저장 시 암호화되고 절대 외부로 나가지 않습니다.
|
||||
### 핵심 기능
|
||||
|
||||
- **다중 AI 지원**: DeepSeek, Qwen, GPT, Claude, Gemini, Grok, Kimi 실행 - 언제든 모델 전환 가능
|
||||
- **다중 거래소**: Binance, Bybit, OKX, Hyperliquid, Aster DEX, Lighter에서 통합 거래
|
||||
- **전략 스튜디오**: 코인 소스, 지표, 리스크 제어를 설정하는 시각적 전략 빌더
|
||||
- **AI 경쟁 모드**: 여러 AI 트레이더가 실시간으로 경쟁, 성과를 나란히 추적
|
||||
- **웹 기반 설정**: JSON 편집 불필요 - 웹 인터페이스에서 모든 설정 완료
|
||||
- **실시간 대시보드**: 실시간 포지션, 손익 추적, 사고의 연쇄가 포함된 AI 결정 로그
|
||||
|
||||
### 공식 링크
|
||||
|
||||
- **공식 웹사이트**: [https://nofxai.com](https://nofxai.com)
|
||||
- **데이터 대시보드**: [https://nofxos.ai/dashboard](https://nofxos.ai/dashboard)
|
||||
- **API 문서**: [https://nofxos.ai/api-docs](https://nofxos.ai/api-docs)
|
||||
|
||||
> **위험 경고**: 이 시스템은 실험적입니다. AI 자동 거래에는 상당한 위험이 있습니다. 학습/연구 목적 또는 소액 테스트만 강력히 권장합니다!
|
||||
|
||||
## 개발자 커뮤니티
|
||||
|
||||
Telegram 개발자 커뮤니티 참여: **[NOFX 개발자 커뮤니티](https://t.me/nofx_dev_community)**
|
||||
|
||||
---
|
||||
|
||||
## 시작하기 전에
|
||||
|
||||
NOFX를 사용하려면 다음이 필요합니다:
|
||||
|
||||
1. **거래소 계정** - 지원되는 거래소에 등록하고 거래 권한이 있는 API 자격 증명 생성
|
||||
2. **AI 모델 API 키** - 지원되는 제공업체에서 획득 (비용 효율성을 위해 DeepSeek 권장)
|
||||
|
||||
---
|
||||
|
||||
## 지원 거래소
|
||||
|
||||
### CEX (중앙화 거래소)
|
||||
|
||||
| 거래소 | 상태 | 등록 (수수료 할인) |
|
||||
|----------|--------|-------------------------|
|
||||
| **Binance** | ✅ 지원 | [등록](https://www.binance.com/join?ref=NOFXENG) |
|
||||
| **Bybit** | ✅ 지원 | [등록](https://partner.bybit.com/b/83856) |
|
||||
| **OKX** | ✅ 지원 | [등록](https://www.okx.com/join/1865360) |
|
||||
| **Bitget** | ✅ 지원 | [등록](https://www.bitget.com/referral/register?from=referral&clacCode=c8a43172) |
|
||||
|
||||
### Perp-DEX (탈중앙화 영구 선물 거래소)
|
||||
|
||||
| 거래소 | 상태 | 등록 (수수료 할인) |
|
||||
|----------|--------|-------------------------|
|
||||
| **Hyperliquid** | ✅ 지원 | [등록](https://app.hyperliquid.xyz/join/AITRADING) |
|
||||
| **Aster DEX** | ✅ 지원 | [등록](https://www.asterdex.com/en/referral/fdfc0e) |
|
||||
| **Lighter** | ✅ 지원 | [등록](https://app.lighter.xyz/?referral=68151432) |
|
||||
|
||||
---
|
||||
|
||||
## 지원 AI 모델
|
||||
|
||||
| AI 모델 | 상태 | API 키 받기 |
|
||||
|----------|--------|-------------|
|
||||
| **DeepSeek** | ✅ 지원 | [API 키 받기](https://platform.deepseek.com) |
|
||||
| **Qwen** | ✅ 지원 | [API 키 받기](https://dashscope.console.aliyun.com) |
|
||||
| **OpenAI (GPT)** | ✅ 지원 | [API 키 받기](https://platform.openai.com) |
|
||||
| **Claude** | ✅ 지원 | [API 키 받기](https://console.anthropic.com) |
|
||||
| **Gemini** | ✅ 지원 | [API 키 받기](https://aistudio.google.com) |
|
||||
| **Grok** | ✅ 지원 | [API 키 받기](https://console.x.ai) |
|
||||
| **Kimi** | ✅ 지원 | [API 키 받기](https://platform.moonshot.cn) |
|
||||
|
||||
---
|
||||
|
||||
## 빠른 시작
|
||||
|
||||
### 옵션 1: Docker 배포 (권장)
|
||||
|
||||
```bash
|
||||
git clone https://github.com/NoFxAiOS/nofx.git
|
||||
cd nofx
|
||||
chmod +x ./start.sh
|
||||
./start.sh start --build
|
||||
```
|
||||
|
||||
웹 인터페이스 접속: **http://localhost:3000**
|
||||
|
||||
### 최신 버전 유지
|
||||
|
||||
> **💡 업데이트가 빈번합니다.** 최신 기능과 수정 사항을 받으려면 매일 이 명령을 실행하세요:
|
||||
|
||||
```bash
|
||||
curl -fsSL https://raw.githubusercontent.com/NoFxAiOS/nofx/main/install.sh | bash
|
||||
```
|
||||
|
||||
터미널은 `http://127.0.0.1:3000`에서 열립니다.
|
||||
이 명령은 최신 공식 이미지를 가져오고 서비스를 자동으로 다시 시작합니다.
|
||||
|
||||
**첫 실행**
|
||||
|
||||
1. 계정을 등록합니다 — 첫 번째 계정이 해당 인스턴스의 소유자가 됩니다.
|
||||
2. 가이드 런치를 따라갑니다. 자동으로 생성된 AI 수수료 지갑에 **$1+ USDC**(Base 네트워크)를 넣은 뒤, Hyperliquid를 연결하고 거래 자금으로 **$12+ USDC**를 입금합니다.
|
||||
3. **Autopilot**을 시작합니다. AI가 몇 분마다 시장을 스캔하며 스스로 거래하고, 모든 결정은 발생하는 즉시 대시보드에 표시됩니다. 언제든 클릭 한 번으로 중지할 수 있습니다.
|
||||
|
||||
<br/>
|
||||
|
||||
## 거래소 등록
|
||||
|
||||
NOFX는 무료 오픈소스입니다. 아래 파트너 링크로 계정을 개설하면 거래 수수료가 할인되며, 프로젝트의 지속적인 개발에도 보탬이 됩니다.
|
||||
|
||||
| 거래소 | 상태 | 수수료 할인 등록 |
|
||||
| :---------------------------------------------------------------------------------------------------------------------------- | :----: | :---------------------------------------------------------------------------------- |
|
||||
| <img src="../../../web/public/exchange-icons/binance.jpg" width="20" height="20" style="vertical-align: middle;"/> **Binance** | ✅ | [등록](https://www.binance.com/join?ref=NOFXENG) |
|
||||
| <img src="../../../web/public/exchange-icons/bybit.png" width="20" height="20" style="vertical-align: middle;"/> **Bybit** | ✅ | [등록](https://partner.bybit.com/b/83856) |
|
||||
| <img src="../../../web/public/exchange-icons/okx.svg" width="20" height="20" style="vertical-align: middle;"/> **OKX** | ✅ | [등록](https://www.okx.com/join/1865360) |
|
||||
| <img src="../../../web/public/exchange-icons/hyperliquid.png" width="20" height="20" style="vertical-align: middle;"/> **Hyperliquid** | ✅ | [등록](https://app.hyperliquid.xyz/join/AITRADING) |
|
||||
| <img src="../../../web/public/exchange-icons/bitget.svg" width="20" height="20" style="vertical-align: middle;"/> **Bitget** | ✅ | [등록](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** | ✅ | [등록](https://www.kucoin.com/r/broker/CXEV7XKK) |
|
||||
| <img src="../../../web/public/exchange-icons/gate.svg" width="20" height="20" style="vertical-align: middle;"/> **Gate** | ✅ | [등록](https://www.gatenode.xyz/share/VQBGUAxY) |
|
||||
| <img src="../../../web/public/exchange-icons/aster.svg" width="20" height="20" style="vertical-align: middle;"/> **Aster** | ✅ | [등록](https://www.asterdex.com/en/referral/fdfc0e) |
|
||||
| <img src="../../../web/public/exchange-icons/lighter.png" width="20" height="20" style="vertical-align: middle;"/> **Lighter** | ✅ | [등록](https://app.lighter.xyz/?referral=68151432) |
|
||||
|
||||
<br/>
|
||||
|
||||
## 데모
|
||||
|
||||
https://github.com/user-attachments/assets/3310f495-14c5-4586-a1cc-3d32e44aa505
|
||||
|
||||
<br/>
|
||||
|
||||
## 모델은 제안하고, 런타임이 결정합니다
|
||||
|
||||
의사결정은 [Claw402.ai](https://claw402.ai) · Vergex 데이터 스택을 읽는 언어 모델에서 나옵니다. 모든 시장을 방향 편향과 시그널 강도로 순위 매기는 실시간 시그널 보드, 종목별 Signal Lab 딥 시그널, 시장의 '연료'와 '벽'이 어디에 있는지 보여주는 코스트·청산 히트맵, 실시간 순유입 — 이를 원시 캔들과 트레이더 자신의 실시간 트랙 레코드로 교차 검증합니다. 그러나 실행은 그렇지 않습니다.
|
||||
|
||||
모든 주문은 모델의 손이 닿지 않는 곳에서 코드로 강제되는 한도를 통과해야 합니다.
|
||||
|
||||
| | |
|
||||
| :----------------------- | :--------------------------------------------------------------------------------- |
|
||||
| 포지션 한도 | 최대 동시 포지션 수, 자본 대비 비율로 상한이 걸린 명목 금액, 심볼당 포지션 1개 |
|
||||
| 레버리지 클램프 | 모델이 무엇을 요청하든 관계없이 주문 크기 산정 시점에 적용되는 하드 캡 |
|
||||
| 거래소 측 보호 | 모든 진입 직후 거래소에 스탑로스와 테이크프로핏을 즉시 배치 |
|
||||
| 드로다운 자동 청산 | 고점 대비 이익을 과도하게 반납한 수익 포지션은 자동으로 청산 |
|
||||
| 거래 스로틀링 | 최소 보유 시간, 심볼별 재진입 쿨다운, 사이클당·시간당 진입 횟수 제한 |
|
||||
| 세이프 모드 | 모델 오류가 반복되면 모델이 복구될 때까지 신규 진입을 차단 |
|
||||
| 런치 프리플라이트 | 트레이더 시작 전에 모델 접근성, 지갑 자금, 전략, 거래소 잔고를 검증 |
|
||||
|
||||
각 결정은 모델의 전체 추론 과정과 함께 저장됩니다. 기록 없는 포지션은 존재하지 않습니다.
|
||||
|
||||
<br/>
|
||||
|
||||
## 터미널
|
||||
|
||||
| | |
|
||||
| :--- | :--- |
|
||||
| **Autopilot** | 가이드 런치: 자금 입금, 연결, 예치, 시작 — 전 과정에 서버 측 프리플라이트 적용 |
|
||||
| **Strategy Studio** | 스타일 프리셋, 코인 유니버스, 지표, 레버리지, 진입 신뢰도, 커스텀 프롬프트 |
|
||||
| **경쟁** | 실현 수익률로 순위가 매겨지는 공개 리더보드, 각 항목마다 사용한 모델 표기 |
|
||||
| **대시보드** | 실시간 포지션, 주문, 통계, 그리고 모든 결정 뒤에 있는 추론 과정 |
|
||||
|
||||
<details>
|
||||
<summary>스크린샷</summary>
|
||||
|
||||
<br/>
|
||||
|
||||
| 개요 | 마켓 차트 |
|
||||
| :-----------------------------------------------------: | :-------------------------------------------------------------: |
|
||||
| <img src="../../../screenshots/dashboard-page.png" width="400"/> | <img src="../../../screenshots/dashboard-market-chart.png" width="400"/> |
|
||||
|
||||
| 거래 통계 | 포지션 기록 |
|
||||
| :--------------------------------------------------------------: | :-----------------------------------------------------------------: |
|
||||
| <img src="../../../screenshots/dashboard-trading-stats.png" width="400"/> | <img src="../../../screenshots/dashboard-position-history.png" width="400"/> |
|
||||
|
||||
| 전략 에디터 | 지표 설정 |
|
||||
| :------------------------------------------------------: | :----------------------------------------------------------: |
|
||||
| <img src="../../../screenshots/strategy-studio.png" width="400"/> | <img src="../../../screenshots/strategy-indicators.png" width="400"/> |
|
||||
|
||||
| 경쟁 | 설정 |
|
||||
| :-------------------------------------------------------: | :-----------------------------------------------------------: |
|
||||
| <img src="../../../screenshots/competition-page.png" width="400"/> | <img src="../../../screenshots/config-ai-exchanges.png" width="400"/> |
|
||||
|
||||
</details>
|
||||
|
||||
<br/>
|
||||
|
||||
## 모델
|
||||
|
||||
자신의 키로 사용할 수 있는 여덟 개 제공업체 — DeepSeek, OpenAI, Claude, Qwen, Gemini, Grok, Kimi, MiniMax — 커스텀 엔드포인트와 모델 이름도 지원합니다.
|
||||
|
||||
키가 전혀 없어도 됩니다. [Claw402](https://claw402.ai)는 x402 프로토콜을 통해 모델 사용량을 호출 단위로 USDC로 정산합니다. Base 네트워크의 지갑 하나가 모든 API 키를 대신합니다.
|
||||
|
||||
| 제공업체 | 이용 방법 |
|
||||
| :------- | :----- |
|
||||
| **Claw402** | [공식 할인이 적용된 종량제 AI 모델](https://claw402.ai) |
|
||||
|
||||
## 시장
|
||||
|
||||
아홉 개 거래소 전체에서 암호화폐 무기한 선물을 거래합니다. Hyperliquid에서는 같은 런타임이 암호화폐와 함께 토큰화된 미국 주식, 원자재, 지수, 외환, 프리 IPO 무기한 선물 — TSLA, NVDA, GOLD, SPX, EUR, OPENAI — 도 거래합니다.
|
||||
|
||||
<br/>
|
||||
|
||||
## 아키텍처
|
||||
|
||||
```
|
||||
┌─────────────────────────────────────────────────┐
|
||||
│ 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/>
|
||||
|
||||
## 설치
|
||||
|
||||
**Linux / macOS**
|
||||
### 옵션 2: 수동 설치
|
||||
|
||||
```bash
|
||||
# 필수 조건: Go 1.21+, Node.js 18+, TA-Lib
|
||||
|
||||
# TA-Lib 설치 (macOS)
|
||||
brew install ta-lib
|
||||
|
||||
# 클론 및 설정
|
||||
git clone https://github.com/NoFxAiOS/nofx.git
|
||||
cd nofx
|
||||
go mod download
|
||||
cd web && npm install && cd ..
|
||||
|
||||
# 백엔드 시작
|
||||
go build -o nofx && ./nofx
|
||||
|
||||
# 프론트엔드 시작 (새 터미널)
|
||||
cd web && npm run dev
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 초기 설정
|
||||
|
||||
1. **AI 모델 설정** - AI API 키 추가
|
||||
2. **거래소 설정** - 거래소 API 자격 증명 설정
|
||||
3. **전략 생성** - 전략 스튜디오에서 거래 전략 구성
|
||||
4. **트레이더 생성** - AI 모델 + 거래소 + 전략 조합
|
||||
5. **거래 시작** - 설정된 트레이더 시작
|
||||
|
||||
---
|
||||
|
||||
## 위험 경고
|
||||
|
||||
1. 암호화폐 시장은 매우 변동성이 높음 - AI 결정이 수익을 보장하지 않음
|
||||
2. 선물 거래는 레버리지 사용 - 손실이 원금을 초과할 수 있음
|
||||
3. 극단적인 시장 상황에서 청산 위험 있음
|
||||
|
||||
---
|
||||
|
||||
## 서버 배포
|
||||
|
||||
### 빠른 배포 (IP를 통한 HTTP)
|
||||
|
||||
기본적으로 전송 암호화가 **비활성화**되어 HTTPS 없이 IP 주소를 통해 NOFX에 액세스할 수 있습니다:
|
||||
|
||||
```bash
|
||||
# 서버에 배포
|
||||
curl -fsSL https://raw.githubusercontent.com/NoFxAiOS/nofx/main/install.sh | bash
|
||||
```
|
||||
|
||||
**Railway**
|
||||
`http://YOUR_SERVER_IP:3000`을 통해 액세스 - 즉시 작동합니다.
|
||||
|
||||
[](https://railway.com/deploy/nofx?referralCode=nofx)
|
||||
### 향상된 보안 (HTTPS)
|
||||
|
||||
**Docker**
|
||||
보안을 강화하려면 `.env`에서 전송 암호화를 활성화하세요:
|
||||
|
||||
```bash
|
||||
curl -O https://raw.githubusercontent.com/NoFxAiOS/nofx/main/docker-compose.prod.yml
|
||||
docker compose -f docker-compose.prod.yml up -d
|
||||
TRANSPORT_ENCRYPTION=true
|
||||
```
|
||||
|
||||
**Windows** — [Docker Desktop](https://www.docker.com/products/docker-desktop/)을 설치한 뒤:
|
||||
활성화되면 브라우저는 Web Crypto API를 사용하여 전송 전에 API 키를 암호화합니다. 이를 위해 필요한 것:
|
||||
- `https://` - SSL이 있는 모든 도메인
|
||||
- `http://localhost` - 로컬 개발
|
||||
|
||||
```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
|
||||
```
|
||||
### Cloudflare를 사용한 빠른 HTTPS 설정
|
||||
|
||||
**소스에서 빌드** — Go 1.21+, Node.js 18+:
|
||||
1. **Cloudflare에 도메인 추가** (무료 플랜 가능)
|
||||
- [dash.cloudflare.com](https://dash.cloudflare.com) 방문
|
||||
- 도메인 추가 및 네임서버 업데이트
|
||||
|
||||
2. **DNS 레코드 생성**
|
||||
- 유형: `A`
|
||||
- 이름: `nofx` (또는 서브도메인)
|
||||
- 콘텐츠: 서버 IP
|
||||
- 프록시 상태: **Proxied** (주황색 구름)
|
||||
|
||||
3. **SSL/TLS 구성**
|
||||
- SSL/TLS 설정으로 이동
|
||||
- 암호화 모드를 **Flexible**로 설정
|
||||
|
||||
```
|
||||
User ──[HTTPS]──→ Cloudflare ──[HTTP]──→ Your Server:3000
|
||||
```
|
||||
|
||||
4. **전송 암호화 활성화**
|
||||
```bash
|
||||
# .env 편집 및 설정
|
||||
TRANSPORT_ENCRYPTION=true
|
||||
```
|
||||
|
||||
5. **완료!** `https://nofx.yourdomain.com`을 통해 액세스
|
||||
|
||||
---
|
||||
|
||||
## 초기 설정 (웹 인터페이스)
|
||||
|
||||
시스템을 시작한 후 웹 인터페이스를 통해 구성합니다:
|
||||
|
||||
1. **AI 모델 구성** - AI API 키 추가 (DeepSeek, OpenAI 등)
|
||||
2. **거래소 구성** - 거래소 API 자격 증명 설정
|
||||
3. **전략 생성** - 전략 스튜디오에서 거래 전략 구성
|
||||
4. **트레이더 생성** - AI 모델 + 거래소 + 전략 결합
|
||||
5. **거래 시작** - 구성된 트레이더 시작
|
||||
|
||||
모든 구성은 웹 인터페이스를 통해 완료 - JSON 파일 편집 불필요.
|
||||
|
||||
---
|
||||
|
||||
## 웹 인터페이스 기능
|
||||
|
||||
### 경쟁 페이지
|
||||
- 실시간 ROI 리더보드
|
||||
- 다중 AI 성능 비교 차트
|
||||
- 실시간 손익 추적 및 순위
|
||||
|
||||
### 대시보드
|
||||
- TradingView 스타일 캔들스틱 차트
|
||||
- 실시간 포지션 관리
|
||||
- Chain of Thought 추론이 포함된 AI 결정 로그
|
||||
- 자본 곡선 추적
|
||||
|
||||
### 전략 스튜디오
|
||||
- 코인 소스 구성 (정적 목록, AI500 풀, OI Top)
|
||||
- 기술 지표 (EMA, MACD, RSI, ATR, 거래량, OI, 펀딩 비율)
|
||||
- 리스크 제어 설정 (레버리지, 포지션 한도, 마진 사용)
|
||||
- 실시간 프롬프트 미리보기를 포함한 AI 테스트
|
||||
|
||||
---
|
||||
|
||||
## 일반적인 문제
|
||||
|
||||
### TA-Lib을 찾을 수 없음
|
||||
```bash
|
||||
git clone https://github.com/NoFxAiOS/nofx.git && cd nofx
|
||||
go build -o nofx && ./nofx # backend
|
||||
cd web && npm install && npm run dev # frontend, in a second terminal
|
||||
# macOS
|
||||
brew install ta-lib
|
||||
|
||||
# Ubuntu
|
||||
sudo apt-get install libta-lib0-dev
|
||||
```
|
||||
|
||||
**업데이트** — 설치 스크립트를 다시 실행하면 제자리에서 업그레이드됩니다.
|
||||
### AI API 타임아웃
|
||||
- API 키가 올바른지 확인
|
||||
- 네트워크 연결 확인
|
||||
- 시스템 타임아웃은 120초
|
||||
|
||||
<details>
|
||||
<summary>서버 배포</summary>
|
||||
### 프론트엔드가 백엔드에 연결할 수 없음
|
||||
- 백엔드가 http://localhost:8080에서 실행 중인지 확인
|
||||
- 포트가 점유되어 있지 않은지 확인
|
||||
|
||||
<br/>
|
||||
|
||||
**HTTP**
|
||||
|
||||
```bash
|
||||
curl -fsSL https://raw.githubusercontent.com/NoFxAiOS/nofx/main/install.sh | bash
|
||||
# http://YOUR_IP:3000
|
||||
```
|
||||
|
||||
**Cloudflare를 통한 HTTPS**
|
||||
|
||||
1. [Cloudflare](https://dash.cloudflare.com)에 도메인 추가(무료 플랜)
|
||||
2. A 레코드 → 서버 IP, Proxied 설정
|
||||
3. SSL/TLS → Flexible
|
||||
4. `.env`에 `TRANSPORT_ENCRYPTION=true` 설정
|
||||
|
||||
</details>
|
||||
|
||||
<br/>
|
||||
|
||||
## 문서
|
||||
|
||||
| | |
|
||||
| :------------------------------------------------------ | :------------------------------------ |
|
||||
| [시작하기](../../getting-started/README.md) | 배포 및 거래소 API 가이드 |
|
||||
| [아키텍처](../../architecture/README.md) | 시스템 설계와 모듈 색인 |
|
||||
| [전략 모듈](../../architecture/STRATEGY_MODULE.md) | 코인 선정, AI 프롬프트, 실행 |
|
||||
| [FAQ](../../guides/faq.en.md) | 자주 묻는 질문 |
|
||||
| [문제 해결](../../guides/TROUBLESHOOTING.md) | 흔한 문제 진단하기 |
|
||||
|
||||
## 커뮤니티
|
||||
|
||||
[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) · [라이브 대시보드](https://vergex.trade/explore)
|
||||
|
||||
## 기여
|
||||
|
||||
코드, 문서, 번역, 버그 리포트 모두 환영합니다 — [기여 가이드](../../../CONTRIBUTING.md), [행동 강령](../../../CODE_OF_CONDUCT.md), [보안 정책](../../../SECURITY.md)을 참고하세요.
|
||||
|
||||
NOFX는 의미 있는 기여를 기록하며, 생태계가 성장함에 따라 기여자에게 보상할 계획입니다. 우선순위 이슈에는 더 높은 가중치가 부여됩니다.
|
||||
|
||||
| 기여 유형 | 가중치 |
|
||||
| :---------------- | :----: |
|
||||
| 고정 이슈 PR | ★★★★★★ |
|
||||
| 코드(머지된 PR) | ★★★★★ |
|
||||
| 버그 수정 | ★★★★ |
|
||||
| 기능 아이디어 | ★★★ |
|
||||
| 버그 리포트 | ★★ |
|
||||
| 문서 | ★★ |
|
||||
|
||||
<a href="https://github.com/NoFxAiOS/nofx/graphs/contributors">
|
||||
<img src="https://contrib.rocks/image?repo=NoFxAiOS/nofx" alt="Contributors"/>
|
||||
</a>
|
||||
|
||||
## 스폰서
|
||||
|
||||
<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>
|
||||
|
||||
[스폰서 되기](https://github.com/sponsors/NoFxAiOS)
|
||||
|
||||
<br/>
|
||||
|
||||
NOFX가 도움이 되었다면, 스타 하나가 다른 트레이더들이 이 프로젝트를 발견하는 데 큰 힘이 됩니다.
|
||||
|
||||
[](https://star-history.com/#NoFxAiOS/nofx&Date)
|
||||
---
|
||||
|
||||
## 라이선스
|
||||
|
||||
[AGPL-3.0](../../../LICENSE)
|
||||
이 프로젝트는 **GNU Affero General Public License v3.0 (AGPL-3.0)** 라이선스에 따라 제공됩니다 - [LICENSE](LICENSE) 파일을 참조하세요.
|
||||
|
||||
<sub>자동매매에는 상당한 위험이 따릅니다. AI 기반 전략은 실험적이며 손실이 발생할 수 있습니다. 포지션 규모를 적절히 관리하고, 각 거래소의 구조를 이해하며, 잃어도 감당할 수 있는 자금으로만 거래하세요. 전체 [면책 조항](../../../DISCLAIMER.md)을 확인하세요.</sub>
|
||||
---
|
||||
|
||||
## 기여
|
||||
|
||||
기여를 환영합니다! 다음을 참조하세요:
|
||||
- **[기여 가이드](CONTRIBUTING.md)** - 개발 워크플로 및 PR 프로세스
|
||||
- **[행동 강령](CODE_OF_CONDUCT.md)** - 커뮤니티 가이드라인
|
||||
- **[보안 정책](SECURITY.md)** - 취약점 보고
|
||||
|
||||
---
|
||||
|
||||
## 기여자 에어드롭 프로그램
|
||||
|
||||
모든 기여는 GitHub에서 추적됩니다. NOFX가 수익을 창출하면 기여자는 기여도에 따라 에어드롭을 받게 됩니다.
|
||||
|
||||
**[고정된 Issue](https://github.com/NoFxAiOS/nofx/issues)를 해결하는 PR은 최고 보상을 받습니다!**
|
||||
|
||||
| 기여 유형 | 가중치 |
|
||||
|------------------|:------:|
|
||||
| **고정된 Issue PR** | ⭐⭐⭐⭐⭐⭐ |
|
||||
| **코드 커밋** (병합된 PR) | ⭐⭐⭐⭐⭐ |
|
||||
| **버그 수정** | ⭐⭐⭐⭐ |
|
||||
| **기능 제안** | ⭐⭐⭐ |
|
||||
| **버그 보고** | ⭐⭐ |
|
||||
| **문서** | ⭐⭐ |
|
||||
|
||||
---
|
||||
|
||||
## 위험 경고
|
||||
|
||||
1. 암호화폐 시장은 매우 변동성이 높음 - AI 결정이 수익을 보장하지 않음
|
||||
2. 선물 거래는 레버리지 사용 - 손실이 원금을 초과할 수 있음
|
||||
3. 극단적인 시장 상황에서 청산 위험 있음
|
||||
|
||||
|
||||
|
||||
|
||||
## 연락처
|
||||
|
||||
- **GitHub Issues**: [Issue 제출](https://github.com/NoFxAiOS/nofx/issues)
|
||||
- **개발자 커뮤니티**: [Telegram 그룹](https://t.me/nofx_dev_community)
|
||||
|
||||
---
|
||||
|
||||
## Star History
|
||||
|
||||
[](https://star-history.com/#NoFxAiOS/nofx&Date)
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user