3 Commits

Author SHA1 Message Date
tinklefund
8b4ce279da feat: Add powerful flexible strategy system
Strategy Builder:
- Create strategies from natural language
- Grid trading strategy
- DCA (Dollar Cost Averaging) strategy
- Trend following (EMA crossover) strategy
- Custom rule-based strategies

Strategy Components:
- Entry/exit rules with indicators (RSI, EMA, MACD, etc.)
- Position sizing (fixed, percent, risk-based, kelly)
- Risk management (max drawdown, daily loss limit, cooldown)
- Leverage config (fixed, dynamic, per-symbol, per-volatility)
- Time-based rules (trading hours, hold time limits)
- AI enhancement (confidence threshold, personality)

11 New Tools:
- create_strategy - Natural language strategy creation
- create_grid_strategy - Grid trading setup
- create_dca_strategy - DCA setup
- create_trend_strategy - Trend following setup
- list_smart_strategies - List all strategies
- get_strategy_details - Strategy details
- update_strategy - Modify strategy settings
- activate_strategy - Start trading
- deactivate_strategy - Stop trading
- delete_strategy - Remove strategy
- get_strategy_templates - Show available templates

Total tools now: 24 (13 trading + 11 strategy)
2026-01-30 03:45:10 +08:00
tinklefund
f9d8318869 feat: Add smart trading assistant with context awareness
- Add SmartAgent with automatic context injection
  - Real-time portfolio/position data in every prompt
  - AI knows current state before responding

- Add TradingContext builder
  - Aggregates balance, positions, P&L across all traders
  - Auto-generates alerts (liquidation risk, large loss, etc.)

- Add background Monitor
  - Proactive position monitoring every 30s
  - Detects new positions, closed positions
  - Forwards alerts to Telegram

- Enhanced system prompts
  - Professional trading assistant persona
  - Risk assessment guidelines
  - Clear response formatting rules

Features:
 Context-aware responses
 Proactive risk alerts
 Background monitoring
 Alert broadcasting to Telegram
2026-01-30 03:40:14 +08:00
tinklefund
01ba348841 feat: Add Telegram AI Assistant (moltbot-nofx integration)
- Add assistant package with AI Agent runtime
  - agent.go: Core agent loop with tool calling
  - session.go: Conversation memory management
  - tool.go: Tool interface and base implementation
  - trading_tools.go: Trading-specific tools (13 tools)
  - prompts.go: Trading expert system prompts (EN/ZH)

- Add telegram package for Telegram bot integration
  - bot.go: Telegram bot with rate limiting & access control
  - config.go: Environment-based configuration

- Update main.go to initialize Telegram bot on startup
- Update .env.example with new configuration options
- Add gopkg.in/telebot.v3 dependency

Trading tools available:
- Query: get_balance, get_positions, list_traders, get_trader_status
- Control: start_trader, stop_trader
- Trading: get_market_price, open_long, open_short, close_position
- Config: list_strategies, list_exchanges, list_ai_models
2026-01-30 03:29:22 +08:00
394 changed files with 55234 additions and 47882 deletions

View File

@@ -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

View File

@@ -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!**

View 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
});

View File

@@ -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

View File

@@ -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:

View File

@@ -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');
}

5
.gitignore vendored
View File

@@ -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
@@ -81,6 +78,7 @@ dist/
downloads/
eggs/
.eggs/
lib/
lib64/
parts/
sdist/
@@ -126,3 +124,4 @@ dmypy.json
# Pyre type checker
.pyre/
PR_DESCRIPTION.md
nofx-moltbot

View File

@@ -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 \

View File

@@ -103,43 +103,6 @@ Binance互換の分散型無期限先物取引所
---
## 対応取引所
### CEX中央集権型取引所
| 取引所 | ステータス | 登録(手数料割引) |
|:-------|:----------:|:-------------------|
| <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/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) |
### Perp-DEX分散型無期限取引所
| 取引所 | ステータス | 登録(手数料割引) |
|:-------|:----------:|:-------------------|
| <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/aster.svg" width="20" height="20" style="vertical-align: middle;"/> **Aster DEX** | ✅ | [登録](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) |
---
## 対応AIモデル
| AIモデル | ステータス | APIキー取得 |
|:---------|:----------:|:------------|
| <img src="web/public/icons/deepseek.svg" width="20" height="20" style="vertical-align: middle;"/> **DeepSeek** | ✅ | [APIキー取得](https://platform.deepseek.com) |
| <img src="web/public/icons/qwen.svg" width="20" height="20" style="vertical-align: middle;"/> **Qwen** | ✅ | [APIキー取得](https://dashscope.console.aliyun.com) |
| <img src="web/public/icons/openai.svg" width="20" height="20" style="vertical-align: middle;"/> **OpenAI (GPT)** | ✅ | [APIキー取得](https://platform.openai.com) |
| <img src="web/public/icons/claude.svg" width="20" height="20" style="vertical-align: middle;"/> **Claude** | ✅ | [APIキー取得](https://console.anthropic.com) |
| <img src="web/public/icons/gemini.svg" width="20" height="20" style="vertical-align: middle;"/> **Gemini** | ✅ | [APIキー取得](https://aistudio.google.com) |
| <img src="web/public/icons/grok.svg" width="20" height="20" style="vertical-align: middle;"/> **Grok** | ✅ | [APIキー取得](https://console.x.ai) |
| <img src="web/public/icons/kimi.svg" width="20" height="20" style="vertical-align: middle;"/> **Kimi** | ✅ | [APIキー取得](https://platform.moonshot.cn) |
---
## 📸 スクリーンショット
### 🏆 競争モード - リアルタイムAIバトル

628
README.md
View File

@@ -1,339 +1,513 @@
<h1 align="center">NOFX</h1>
# NOFX - Agentic Trading OS
<p align="center">
<strong>Your personal AI trading assistant.</strong><br/>
<strong>Any market. Any model. Pay with USDC, not API keys.</strong>
</p>
[![Go Version](https://img.shields.io/badge/Go-1.21+-00ADD8?style=flat&logo=go)](https://golang.org/)
[![React](https://img.shields.io/badge/React-18+-61DAFB?style=flat&logo=react)](https://reactjs.org/)
[![TypeScript](https://img.shields.io/badge/TypeScript-5.0+-3178C6?style=flat&logo=typescript)](https://www.typescriptlang.org/)
[![License](https://img.shields.io/badge/License-AGPL--3.0-blue.svg)](LICENSE)
<p align="center">
<a href="https://github.com/NoFxAiOS/nofx/stargazers"><img src="https://img.shields.io/github/stars/NoFxAiOS/nofx?style=for-the-badge" alt="Stars"></a>
<a href="https://github.com/NoFxAiOS/nofx/releases"><img src="https://img.shields.io/github/v/release/NoFxAiOS/nofx?style=for-the-badge" alt="Release"></a>
<a href="https://github.com/NoFxAiOS/nofx/blob/main/LICENSE"><img src="https://img.shields.io/badge/License-AGPL--3.0-blue.svg?style=for-the-badge" alt="License"></a>
<a href="https://t.me/nofx_dev_community"><img src="https://img.shields.io/badge/Telegram-Community-blue?style=for-the-badge&logo=telegram" alt="Telegram"></a>
</p>
| CONTRIBUTOR AIRDROP PROGRAM |
|:----------------------------------:|
| Code · Bug Fixes · Issues → Airdrop |
| [Learn More](#contributor-airdrop-program) |
<p align="center">
<a href="https://golang.org/"><img src="https://img.shields.io/badge/Go-1.21+-00ADD8?style=flat&logo=go" alt="Go"></a>
<a href="https://reactjs.org/"><img src="https://img.shields.io/badge/React-18+-61DAFB?style=flat&logo=react" alt="React"></a>
<a href="https://x402.org"><img src="https://img.shields.io/badge/x402-USDC%20Payments-2775CA?style=flat" alt="x402"></a>
<a href="https://claw402.ai"><img src="https://img.shields.io/badge/Claw402-AI%20Gateway-FF6B35?style=flat" alt="Claw402"></a>
</p>
<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)
---
NOFX is an open-source **autonomous** AI trading assistant. Unlike traditional AI tools that require you to manually configure models, manage API keys, and wire up data sources — NOFX's AI **perceives markets, selects models, and fetches data entirely on its own**. Zero human intervention. You set the strategy, the AI handles everything else.
## AI-Powered Multi-Asset Trading Platform
**Fully autonomous**: The AI decides which model to use, what market data to pull, when to trade — all by itself. No manual model configuration. No juggling API keys for different services. Just fund a USDC wallet and let it run.
**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.
What makes it different: **built-in [x402](https://x402.org) micropayments**. No API keys. Fund a USDC wallet and pay per request. Your wallet is your identity.
### Supported Markets
```bash
curl -fsSL https://raw.githubusercontent.com/NoFxAiOS/nofx/main/install.sh | bash
```
| 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 |
Open **http://127.0.0.1:3000**. Done.
### 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)**
---
## How x402 Works
## Before You Begin
Traditional flow: register account → buy credits → get API key → manage quota → rotate keys.
To use NOFX, you'll need:
x402 flow:
```
Request → 402 (here's the price) → wallet signs USDC → retry → done
```
No accounts. No API keys. No prepaid credits. One wallet, every model.
### Built-in x402 Providers
| Provider | Chain | Models |
|:---------|:------|:-------|
| <img src="web/public/icons/claw402.png" width="20" height="20" style="vertical-align: middle;"/> **[Claw402](https://claw402.ai)** | Base | GPT-5.4, Claude Opus, DeepSeek, Qwen, Grok, Gemini, Kimi — 15+ models |
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)
---
## What It Does
## Supported Exchanges
| Feature | Description |
|:--------|:------------|
| **Multi-AI** | DeepSeek, Qwen, GPT, Claude, Gemini, Grok, Kimi, MiniMax — switch anytime |
| **Multi-Exchange** | Binance, Bybit, OKX, Bitget, KuCoin, Gate, Hyperliquid, Aster, Lighter |
| **Strategy Studio** | Visual builder — coin sources, indicators, risk controls |
| **AI Competition** | AIs compete in real-time, leaderboard ranks performance |
| **Telegram Agent** | Chat with your trading assistant — streaming, tool calling, memory |
| **Dashboard** | Live positions, P/L, AI decision logs with Chain of Thought |
### Markets
Crypto · US Stocks · Forex · Metals
### Exchanges (CEX)
### CEX (Centralized Exchanges)
| Exchange | Status | Register (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/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) |
|----------|--------|-------------------------|
| **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) |
### Exchanges (Perp-DEX)
### Perp-DEX (Decentralized Perpetual Exchanges)
| Exchange | Status | Register (Fee Discount) |
|:---------|:------:|:------------------------|
| <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/aster.svg" width="20" height="20" style="vertical-align: middle;"/> **Aster DEX** | ✅ | [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) |
|----------|--------|-------------------------|
| **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) |
### AI Models (API Key Mode)
---
## Supported AI Models
| AI Model | Status | Get API Key |
|:---------|:------:|:------------|
| <img src="web/public/icons/deepseek.svg" width="20" height="20" style="vertical-align: middle;"/> **DeepSeek** | ✅ | [Get API Key](https://platform.deepseek.com) |
| <img src="web/public/icons/qwen.svg" width="20" height="20" style="vertical-align: middle;"/> **Qwen** | ✅ | [Get API Key](https://dashscope.console.aliyun.com) |
| <img src="web/public/icons/openai.svg" width="20" height="20" style="vertical-align: middle;"/> **OpenAI (GPT)** | ✅ | [Get API Key](https://platform.openai.com) |
| <img src="web/public/icons/claude.svg" width="20" height="20" style="vertical-align: middle;"/> **Claude** | ✅ | [Get API Key](https://console.anthropic.com) |
| <img src="web/public/icons/gemini.svg" width="20" height="20" style="vertical-align: middle;"/> **Gemini** | ✅ | [Get API Key](https://aistudio.google.com) |
| <img src="web/public/icons/grok.svg" width="20" height="20" style="vertical-align: middle;"/> **Grok** | ✅ | [Get API Key](https://console.x.ai) |
| <img src="web/public/icons/kimi.svg" width="20" height="20" style="vertical-align: middle;"/> **Kimi** | ✅ | [Get API Key](https://platform.moonshot.cn) |
| <img src="web/public/icons/minimax.svg" width="20" height="20" style="vertical-align: middle;"/> **MiniMax** | ✅ | [Get API Key](https://platform.minimaxi.com) |
### AI Models (x402 Mode — No API Key)
15+ models via [Claw402](https://claw402.ai) — just a USDC wallet
|----------|--------|-------------|
| **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
<details>
<summary><b>Config Page</b></summary>
### Config Page
| AI Models & Exchanges | Traders List |
|:---:|:---:|
| <img src="screenshots/config-ai-exchanges.png" width="400"/> | <img src="screenshots/config-traders-list.png" width="400"/> |
</details>
| <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"/> |
<details>
<summary><b>Dashboard</b></summary>
### 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"/> | <img src="screenshots/dashboard-market-chart.png" width="400"/> |
| <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"/> | <img src="screenshots/dashboard-position-history.png" width="400"/> |
| <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"/> | <img src="screenshots/details-page.png" width="400"/> |
</details>
<details>
<summary><b>Strategy Studio</b></summary>
| <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"/> | <img src="screenshots/strategy-indicators.png" width="400"/> |
</details>
| <img src="screenshots/strategy-studio.png" width="400" alt="Strategy Studio"/> | <img src="screenshots/strategy-indicators.png" width="400" alt="Strategy Indicators"/> |
<details>
<summary><b>Competition</b></summary>
| Competition Mode |
|:---:|
| <img src="screenshots/competition-page.png" width="400"/> |
</details>
### 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"/> |
---
## Install
## Quick Start
### Linux / macOS
### One-Click Install (Local/Server)
**Linux / macOS:**
```bash
curl -fsSL https://raw.githubusercontent.com/NoFxAiOS/nofx/main/install.sh | bash
```
### Railway (Cloud)
That's it! Open **http://127.0.0.1:3000** in your browser.
### One-Click Cloud Deploy (Railway)
Deploy to Railway with one click - no server setup required:
[![Deploy on Railway](https://railway.com/button.svg)](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
Access Web Interface: **http://127.0.0.1:3000**
```bash
# Prerequisites: Go 1.21+, Node.js 18+, TA-Lib
# macOS: brew install ta-lib
# Ubuntu: sudo apt-get install libta-lib0-dev
git clone https://github.com/NoFxAiOS/nofx.git && cd nofx
go build -o nofx && ./nofx # backend
cd web && npm install && npm run dev # frontend (new 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
### Keeping Updated
> **💡 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
```
---
This one-liner pulls the latest official images and restarts services automatically.
## Setup
### Manual Installation (For Developers)
**Beginner mode**: First-time users get a guided onboarding flow — select beginner mode at registration and the system walks you through AI, exchange, and strategy setup step by step.
#### Prerequisites
**Advanced mode**:
- **Go 1.21+**
- **Node.js 18+**
- **TA-Lib** (technical indicator library)
1. **AI** — Add API keys or configure x402 wallet
2. **Exchange** — Connect exchange API credentials
3. **Strategy** — Build in Strategy Studio
4. **Trader** — Combine AI + Exchange + Strategy
5. **Trade** — Launch from the dashboard
Everything through the web UI at **http://127.0.0.1:3000**.
---
## Deploy to Server
**HTTP (quick):**
```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_IP:3000
```
**HTTPS (Cloudflare):**
1. Add domain to [Cloudflare](https://dash.cloudflare.com) (free plan)
2. A record → your server IP (Proxied)
3. SSL/TLS → Flexible
4. Set `TRANSPORT_ENCRYPTION=true` in `.env`
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`
---
## Architecture
## Initial Setup (Web Interface)
```
NOFX
┌─────────────────────────────────────────────────┐
│ Web Dashboard │
│ React + TypeScript + TradingView │
├─────────────────────────────────────────────────┤
│ API Server (Go) │
├──────────┬──────────┬──────────┬────────────────┤
│ Strategy │ Telegram │
│ Engine │ Agent │
├──────────┴──────────┴──────────┴────────────────┤
│ MCP AI Client Layer │
│ ┌───────────┐ ┌───────────┐ ┌───────────┐ │
│ │ API Key │ │ x402 │ │ │ │
│ │ DeepSeek │ │ Claw402 │ │ │ │
│ │ GPT,Claude │ │ │ │ │ │
│ └───────────┘ └───────────┘ └───────────┘ │
├─────────────────────────────────────────────────┤
│ Exchange Connectors │
│ Binance · Bybit · OKX · Bitget · KuCoin · Gate │
│ Hyperliquid · Aster DEX · Lighter │
└─────────────────────────────────────────────────┘
```
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.
---
## Docs
## Web Interface Features
| | |
|:--|:--|
| [Architecture](docs/architecture/README.md) | System design and module index |
| [Strategy Module](docs/architecture/STRATEGY_MODULE.md) | Coin selection, AI prompts, execution |
| [FAQ](docs/faq/README.md) | Common questions |
| [Getting Started](docs/getting-started/README.md) | Deployment guide |
### 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
| 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 |
---
## License
This project is licensed under **GNU Affero General Public License v3.0 (AGPL-3.0)** - See [LICENSE](LICENSE) file.
---
## Contributing
See [Contributing Guide](CONTRIBUTING.md) · [Code of Conduct](CODE_OF_CONDUCT.md) · [Security Policy](SECURITY.md)
### Contributor Airdrop Program
All contributions are tracked. When NOFX generates revenue, contributors receive airdrops.
**[Pinned Issues](https://github.com/NoFxAiOS/nofx/issues) get the highest rewards.**
| Contribution | Weight |
|:-------------|:------:|
| Pinned Issue PRs | ★★★★★★ |
| Code (Merged PRs) | ★★★★★ |
| Bug Fixes | ★★★★ |
| Feature Ideas | ★★★ |
| Bug Reports | ★★ |
| Documentation | ★★ |
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
---
## Links
## Contributor Airdrop Program
| | |
|:--|:--|
| Website | [nofxai.com](https://nofxai.com) |
| Dashboard | [nofxos.ai/dashboard](https://nofxos.ai/dashboard) |
| API Docs | [nofxos.ai/api-docs](https://nofxos.ai/api-docs) |
| Telegram | [nofx_dev_community](https://t.me/nofx_dev_community) |
| Twitter | [@nofx_official](https://x.com/nofx_official) |
All contributions are tracked on GitHub. When NOFX generates revenue, contributors will receive airdrops based on their contributions.
> **Risk Warning**: AI auto-trading carries significant risks. Recommended for learning/research or small amounts only.
**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
<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>
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)
## License
---
[AGPL-3.0](LICENSE)
## Star History
[![Star History Chart](https://api.star-history.com/svg?repos=NoFxAiOS/nofx&type=Date)](https://star-history.com/#NoFxAiOS/nofx&Date)

861
api/backtest.go Normal file
View 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
}

635
api/debate.go Normal file
View 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,
})
}

View File

@@ -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

View File

@@ -1,381 +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) {
switch exchangeCfg.ExchangeType {
case "binance", "bybit", "gate", "indodax":
if exchangeCfg.APIKey == "" || exchangeCfg.SecretKey == "" {
return exchangeAccountStatusMissingCredentials, "MISSING_REQUIRED_FIELDS", "API key and secret key are required", true
}
case "okx", "bitget", "kucoin":
if exchangeCfg.APIKey == "" || exchangeCfg.SecretKey == "" || exchangeCfg.Passphrase == "" {
return exchangeAccountStatusMissingCredentials, "MISSING_REQUIRED_FIELDS", "API key, secret key, and passphrase are required", true
}
case "hyperliquid":
if exchangeCfg.APIKey == "" || exchangeCfg.HyperliquidWalletAddr == "" {
return exchangeAccountStatusMissingCredentials, "MISSING_REQUIRED_FIELDS", "Private key and wallet address are required", true
}
case "aster":
if exchangeCfg.AsterUser == "" || exchangeCfg.AsterSigner == "" || exchangeCfg.AsterPrivateKey == "" {
return exchangeAccountStatusMissingCredentials, "MISSING_REQUIRED_FIELDS", "Aster user, signer, and private key are required", true
}
case "lighter":
if exchangeCfg.LighterWalletAddr == "" || exchangeCfg.LighterAPIKeyPrivateKey == "" {
return exchangeAccountStatusMissingCredentials, "MISSING_REQUIRED_FIELDS", "Wallet address and API key private key are required", true
}
default:
return exchangeAccountStatusUnavailable, "UNSUPPORTED_EXCHANGE", "Unsupported exchange type", 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] + "..."
}

View File

@@ -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,
})
}

View File

@@ -1,225 +0,0 @@
package api
import (
"encoding/json"
"fmt"
"net/http"
"strings"
"nofx/config"
"nofx/crypto"
"nofx/logger"
"nofx/security"
"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"`
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"`
}
type UpdateModelConfigRequest struct {
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"`
} `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},
{ID: "qwen", Name: "Qwen AI", Provider: "qwen", Enabled: false},
{ID: "openai", Name: "OpenAI", Provider: "openai", Enabled: false},
{ID: "claude", Name: "Claude AI", Provider: "claude", Enabled: false},
{ID: "gemini", Name: "Gemini AI", Provider: "gemini", Enabled: false},
{ID: "grok", Name: "Grok AI", Provider: "grok", Enabled: false},
{ID: "kimi", Name: "Kimi AI", Provider: "kimi", Enabled: false},
{ID: "minimax", Name: "MiniMax AI", Provider: "minimax", Enabled: 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, len(models))
for i, model := range models {
safeModel := SafeModelConfig{
ID: model.ID,
Name: model.Name,
Provider: model.Provider,
Enabled: model.Enabled,
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[i] = safeModel
}
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 {
c.JSON(http.StatusBadRequest, gin.H{"error": fmt.Sprintf("Invalid custom_api_url for model %s: %s", modelID, err.Error())})
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", 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": "claw402", "name": "Claw402 (Base USDC)", "provider": "claw402", "defaultModel": "glm-5"},
}
c.JSON(http.StatusOK, supportedModels)
}

View File

@@ -1,469 +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 Return rate historical data
// Query directly from database, not dependent on trader in memory (so historical data can be retrieved after restart)
func (s *Server) handleEquityHistory(c *gin.Context) {
_, traderID, err := s.getTraderFromQuery(c)
if err != nil {
SafeBadRequest(c, "Invalid trader ID")
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
}
// Use the balance of the first record as initial balance to calculate return rate
initialBalance := snapshots[0].Balance
if initialBalance == 0 {
initialBalance = 1 // Avoid division by zero
}
var history []EquityPoint
for _, snap := range snapshots {
// Calculate PnL percentage
totalPnLPct := 0.0
if initialBalance > 0 {
totalPnLPct = (snap.UnrealizedPnL / initialBalance) * 100
}
history = append(history, EquityPoint{
Timestamp: snap.Timestamp.Format("2006-01-02 15:04:05"),
TotalEquity: snap.TotalEquity,
AvailableBalance: snap.Balance,
TotalPnL: snap.UnrealizedPnL,
TotalPnLPct: totalPnLPct,
PositionCount: snap.PositionCount,
MarginUsedPct: snap.MarginUsedPct,
})
}
c.JSON(http.StatusOK, history)
}
// 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 {
// Calculate PnL percentage: (current_equity - initial_balance) / initial_balance * 100
pnlPct := 0.0
if initialBalance > 0 {
pnlPct = (snap.TotalEquity - initialBalance) / initialBalance * 100
}
history = append(history, map[string]interface{}{
"timestamp": snap.Timestamp,
"total_equity": snap.TotalEquity,
"total_pnl": snap.UnrealizedPnL,
"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 := 0.0
if v, ok := accountInfo["total_equity"].(float64); ok {
totalEquity = v
}
totalPnL := 0.0
if v, ok := accountInfo["total_pnl"].(float64); ok {
totalPnL = v
}
walletBalance := 0.0
if v, ok := accountInfo["wallet_balance"].(float64); ok {
walletBalance = v
}
pnlPct := 0.0
if initialBalance > 0 {
pnlPct = (totalEquity - initialBalance) / initialBalance * 100
}
history = append(history, map[string]interface{}{
"timestamp": now,
"total_equity": totalEquity,
"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)
}

View File

@@ -1,359 +0,0 @@
package api
import (
"encoding/json"
"fmt"
"net/http"
"nofx/config"
"nofx/crypto"
"nofx/logger"
"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"`
Testnet bool `json:"testnet,omitempty"`
HyperliquidWalletAddr string `json:"hyperliquidWalletAddr"` // Hyperliquid wallet address (not sensitive)
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)
}
type UpdateExchangeConfigRequest struct {
Exchanges map[string]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
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"`
} `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
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, len(exchanges))
for i, exchange := range exchanges {
safeExchanges[i] = SafeExchangeConfig{
ID: exchange.ID,
ExchangeType: exchange.ExchangeType,
AccountName: exchange.AccountName,
Name: exchange.Name,
Type: exchange.Type,
Enabled: exchange.Enabled,
Testnet: exchange.Testnet,
HyperliquidWalletAddr: exchange.HyperliquidWalletAddr,
AsterUser: exchange.AsterUser,
AsterSigner: exchange.AsterSigner,
LighterWalletAddr: exchange.LighterWalletAddr,
}
}
c.JSON(http.StatusOK, safeExchanges)
}
// 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 {
// 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, exchangeData.Enabled, exchangeData.APIKey, exchangeData.SecretKey, exchangeData.Passphrase, exchangeData.Testnet, exchangeData.HyperliquidWalletAddr, exchangeData.HyperliquidUnifiedAcct, exchangeData.AsterUser, exchangeData.AsterSigner, exchangeData.AsterPrivateKey, exchangeData.LighterWalletAddr, 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", 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
}
// Create new exchange account
id, err := s.store.Exchange().Create(
userID, req.ExchangeType, req.AccountName, req.Enabled,
req.APIKey, req.SecretKey, req.Passphrase, req.Testnet,
req.HyperliquidWalletAddr, req.HyperliquidUnifiedAcct,
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)
}

View File

@@ -1,392 +0,0 @@
package api
import (
"context"
"fmt"
"net/http"
"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
}
// 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"`
Name string `json:"name"`
Category string `json:"category"` // crypto, stock, forex, commodity, index
MaxLeverage int `json:"maxLeverage,omitempty"`
}
var symbols []SymbolInfo
switch strings.ToLower(exchange) {
case "hyperliquid", "hyperliquid-xyz", "xyz":
// Fetch symbols from Hyperliquid
client := hyperliquid.NewClient()
ctx := context.Background()
// Get crypto perps from default dex
if exchange == "hyperliquid" || exchange == "hyperliquid-xyz" {
mids, err := client.GetAllMids(ctx)
if err == nil {
for symbol := range mids {
// Skip spot tokens (start with @)
if strings.HasPrefix(symbol, "@") {
continue
}
symbols = append(symbols, SymbolInfo{
Symbol: symbol,
Name: symbol,
Category: "crypto",
})
}
}
}
// Get xyz dex symbols (stocks, forex, commodities)
xyzMids, err := client.GetAllMidsXYZ(ctx)
if err == nil {
for symbol := range xyzMids {
// Remove xyz: prefix for display
displaySymbol := strings.TrimPrefix(symbol, "xyz:")
category := "stock"
if displaySymbol == "GOLD" || displaySymbol == "SILVER" {
category = "commodity"
} else if displaySymbol == "EUR" || displaySymbol == "JPY" {
category = "forex"
} else if displaySymbol == "XYZ100" {
category = "index"
}
symbols = append(symbols, SymbolInfo{
Symbol: displaySymbol,
Name: displaySymbol,
Category: category,
})
}
}
default:
c.JSON(http.StatusBadRequest, gin.H{"error": "Unsupported exchange for symbol listing"})
return
}
c.JSON(http.StatusOK, gin.H{
"exchange": exchange,
"symbols": symbols,
"count": len(symbols),
})
}

View File

@@ -1,343 +0,0 @@
package api
import (
"bufio"
"encoding/hex"
"fmt"
"net/http"
"os"
"path/filepath"
"strings"
"nofx/logger"
"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"`
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"`
Source string `json:"source,omitempty"`
Claw402Status string `json:"claw402_status"`
}
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, "", "glm-5"); 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", "glm-5")
envSaved, envPath, envErr := persistBeginnerWalletEnv(privateKey, address)
resp := beginnerOnboardingResponse{
Address: address,
PrivateKey: privateKey,
Chain: "base",
Asset: "USDC",
Provider: "claw402",
DefaultModel: "glm-5",
ConfiguredModelID: configuredModelID,
BalanceUSDC: wallet.QueryUSDCBalanceStr(address),
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
}
c.JSON(http.StatusOK, currentBeginnerWalletResponse{
Found: true,
Address: address,
BalanceUSDC: wallet.QueryUSDCBalanceStr(address),
Source: "model",
Claw402Status: claw402Status,
})
return
}
address := strings.TrimSpace(os.Getenv("CLAW402_WALLET_ADDRESS"))
if address != "" {
c.JSON(http.StatusOK, currentBeginnerWalletResponse{
Found: true,
Address: address,
BalanceUSDC: wallet.QueryUSDCBalanceStr(address),
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": "glm-5",
}); 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
}

View File

@@ -1,402 +0,0 @@
package api
import (
"net/http"
"strconv"
"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
}
// Get closed positions
positions, err := store.Position().GetClosedPositions(trader.GetID(), limit)
if err != nil {
SafeInternalError(c, "Get position history", err)
return
}
// Get statistics
stats, _ := store.Position().GetFullStats(trader.GetID())
// Get symbol stats
symbolStats, _ := store.Position().GetSymbolStats(trader.GetID(), 10)
// Get direction stats
directionStats, _ := store.Position().GetDirectionStats(trader.GetID())
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
fills, err := store.Order().GetOrderFills(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)
}

View File

@@ -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})
}

View File

@@ -1,871 +0,0 @@
package api
import (
"errors"
"fmt"
"net/http"
"strings"
"time"
"nofx/logger"
"nofx/store"
"github.com/gin-gonic/gin"
"gorm.io/gorm"
)
// 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("这次未能创建机器人:%s。", reason)
}
return fmt.Sprintf("这次未能创建机器人:%s。%s。", reason, nextStep)
}
func traderCreationRequestError(reason string) string {
return formatTraderCreationError(reason, "请检查你刚刚填写的内容后,再重新提交")
}
func exchangeDisplayName(exchange *store.Exchange) string {
if exchange == nil {
return "所选交易所账户"
}
if exchange.AccountName != "" {
return fmt.Sprintf("%s%s", exchange.Name, exchange.AccountName)
}
if exchange.Name != "" {
return exchange.Name
}
return "所选交易所账户"
}
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, "私钥")
}
if strings.TrimSpace(exchange.HyperliquidWalletAddr) == "" {
missing = append(missing, "钱包地址")
}
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, "钱包地址")
}
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("还没有找到你选择的交易所账户", "请前往「设置 > 交易所配置」先添加一个可用账户,再回来创建机器人"),
"trader.create.exchange_not_found", nil
}
if !exchange.Enabled {
return formatTraderCreationError(
fmt.Sprintf("交易所账户「%s」目前处于未启用状态", exchangeDisplayName(exchange)),
"请前往「设置 > 交易所配置」启用该账户后,再重新创建机器人",
), "trader.create.exchange_disabled", mapStringPairs("exchange_name", exchangeDisplayName(exchange))
}
missing := missingExchangeFields(exchange)
if len(missing) > 0 {
return formatTraderCreationError(
fmt.Sprintf("交易所账户「%s」的配置还不完整缺少 %s", exchangeDisplayName(exchange), strings.Join(missing, "、")),
"请前往「设置 > 交易所配置」补全该账户的必填信息后,再重新创建机器人",
), "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("交易所账户「%s」使用了当前版本暂不支持的类型 %s", exchangeDisplayName(exchange), exchange.ExchangeType),
"请改用当前版本支持的交易所账户后,再重新创建机器人",
), "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", "当前策略配置内容已损坏,系统暂时无法解析"
case strings.Contains(lower, "has no strategy configured"):
return "trader.reason.strategy_missing", "当前机器人缺少有效的交易策略配置"
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", "私钥格式不正确,系统无法识别"
case strings.Contains(lower, "failed to initialize hyperliquid trader"):
return "trader.reason.hyperliquid_init_failed", "Hyperliquid 账户初始化失败,请确认私钥、主钱包地址和 Agent Wallet 配置是否正确"
case strings.Contains(lower, "failed to initialize aster trader"):
return "trader.reason.aster_init_failed", "Aster 账户初始化失败,请确认 Aster User、Signer 和私钥是否正确"
case strings.Contains(lower, "failed to get meta information"):
return "trader.reason.exchange_meta_unavailable", "系统暂时无法从交易所读取账户元信息"
case strings.Contains(lower, "security check failed") && strings.Contains(lower, "agent wallet balance too high"):
return "trader.reason.hyperliquid_agent_balance_too_high", "Hyperliquid Agent Wallet 余额过高,不符合当前安全要求"
case strings.Contains(lower, "failed to initialize account"):
return "trader.reason.exchange_account_init_failed", "交易所账户初始化失败,请确认钱包地址和 API Key 是否匹配"
case strings.Contains(lower, "unsupported trading platform"):
return "trader.reason.exchange_unsupported", "当前交易所类型暂不支持机器人初始化"
case strings.Contains(lower, "initial balance not set and unable to fetch balance from exchange"):
return "trader.reason.exchange_balance_unavailable", "系统暂时无法从交易所读取账户余额"
case strings.Contains(lower, "timeout"), strings.Contains(lower, "no such host"), strings.Contains(lower, "connection refused"):
return "trader.reason.exchange_service_unreachable", "系统暂时无法连接交易所服务"
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("机器人配置虽然保存了,但运行实例没有成功初始化", "请检查模型、策略和交易所配置是否完整,然后再试一次")
}
reason := humanizeTraderSetupReason(SanitizeError(err, ""))
if reason == "" {
return formatTraderCreationError(
fmt.Sprintf("机器人「%s」在初始化运行实例时没有成功启动", traderName),
"请检查模型、策略和交易所配置是否完整,然后再试一次",
)
}
return formatTraderCreationError(
fmt.Sprintf("机器人「%s」在初始化运行实例时没有成功启动原因是%s", traderName, reason),
"请检查模型、策略和交易所配置是否完整,然后再试一次",
)
}
func describeTraderCreationWarning(traderName string, err error) string {
if err == nil {
return fmt.Sprintf("机器人「%s」已经保存但当前还没有通过启动前校验。请先检查模型、策略和交易所配置修正后再点击启动。", traderName)
}
reason := humanizeTraderSetupReason(SanitizeError(err, ""))
if reason == "" {
return fmt.Sprintf("机器人「%s」已经保存但当前暂时还不能启动。请先检查模型、策略和交易所配置修正后再点击启动。", traderName)
}
return fmt.Sprintf("机器人「%s」已经保存但当前暂时还不能启动原因是%s。请先检查模型、策略和交易所配置修正后再点击启动。", traderName, reason)
}
func describeTraderStartError(traderName string, err error) string {
if err == nil {
return fmt.Sprintf("这次未能启动机器人:机器人「%s」暂时还不能启动。请检查模型、策略和交易所配置后再重新点击启动。", traderName)
}
reason := humanizeTraderSetupReason(SanitizeError(err, ""))
if reason == "" {
return fmt.Sprintf("这次未能启动机器人:机器人「%s」暂时还不能启动。请检查模型、策略和交易所配置后再重新点击启动。", traderName)
}
return fmt.Sprintf("这次未能启动机器人:机器人「%s」暂时还不能启动原因是%s。请检查模型、策略和交易所配置后再重新点击启动。", traderName, reason)
}
func formatTraderStartError(reason, nextStep string) string {
if nextStep == "" {
return fmt.Sprintf("这次未能启动机器人:%s。", reason)
}
return fmt.Sprintf("这次未能启动机器人:%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("提交的信息不完整,或者格式不正确"), "trader.create.invalid_request", nil)
return
}
// Validate leverage values
if req.BTCETHLeverage < 0 || req.BTCETHLeverage > 50 {
SafeBadRequestWithDetails(c, traderCreationRequestError("BTC/ETH 杠杆倍数需要在 1 到 50 倍之间"), "trader.create.invalid_btc_eth_leverage", nil)
return
}
if req.AltcoinLeverage < 0 || req.AltcoinLeverage > 20 {
SafeBadRequestWithDetails(c, traderCreationRequestError("山寨币杠杆倍数需要在 1 到 20 倍之间"), "trader.create.invalid_altcoin_leverage", nil)
return
}
// Validate trading symbol format
if req.TradingSymbols != "" {
symbols := strings.Split(req.TradingSymbols, ",")
for _, symbol := range symbols {
symbol = strings.TrimSpace(symbol)
if symbol != "" && !strings.HasSuffix(strings.ToUpper(symbol), "USDT") {
SafeBadRequestWithDetails(c, traderCreationRequestError(
fmt.Sprintf("交易对 %s 的格式不正确,目前只支持以 USDT 结尾的合约交易对", 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("还没有找到你选择的 AI 模型", "请前往「设置 > 模型配置」先添加并启用一个可用模型,再回来创建机器人"), "trader.create.model_not_found", nil)
return
}
SafeError(c, http.StatusInternalServerError,
formatTraderCreationError("暂时无法读取你的 AI 模型配置", "请稍后重试;如果问题持续,再检查本地服务是否正常"),
err,
)
return
}
if !model.Enabled {
SafeBadRequestWithDetails(c, formatTraderCreationError(
fmt.Sprintf("AI 模型「%s」目前还没有启用", model.Name),
"请前往「设置 > 模型配置」启用它后,再重新创建机器人",
), "trader.create.model_disabled", mapStringPairs("model_name", model.Name))
return
}
if model.APIKey == "" {
SafeBadRequestWithDetails(c, formatTraderCreationError(
fmt.Sprintf("AI 模型「%s」缺少 API Key 或支付凭证", model.Name),
"请前往「设置 > 模型配置」补全模型凭证后,再重新创建机器人",
), "trader.create.model_missing_credentials", mapStringPairs("model_name", model.Name))
return
}
if req.StrategyID == "" {
SafeBadRequestWithDetails(c, formatTraderCreationError("你还没有选择交易策略", "请先选择一个策略,再继续创建机器人"), "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("你选择的策略不存在,或者已经被删除了", "请重新选择一个可用策略后,再继续创建机器人"), "trader.create.strategy_not_found", nil)
return
}
SafeError(c, http.StatusInternalServerError,
formatTraderCreationError("暂时无法读取你选择的策略配置", "请稍后重试;如果问题持续,再检查本地服务是否正常"),
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 < 3 {
scanIntervalMinutes = 3 // Default 3 minutes, not allowed to be less than 3
}
// 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("暂时无法读取你的交易所配置", "请稍后重试;如果问题持续,再检查本地服务是否正常"),
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("交易所账户「%s」没有通过初始化校验原因是%s", exchangeDisplayName(exchangeCfg), humanizeTraderSetupReason(SanitizeError(createErr, "配置校验未通过"))),
"请前往「设置 > 交易所配置」检查这个账户的密钥、地址和账户信息是否填写正确",
), "trader.create.exchange_probe_failed", traderSetupReasonParams(createErr, "配置校验未通过",
"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("机器人配置没有保存成功", "请检查名称、模型、策略和交易所配置后,再试一次"))
statusCode := http.StatusBadRequest
if publicMsg == formatTraderCreationError("机器人配置没有保存成功", "请检查名称、模型、策略和交易所配置后,再试一次") {
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
}
// 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,
"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
}
// 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"})
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("这个机器人关联的 AI 模型不存在", "请前往「设置 > 模型配置」检查后,再重新点击启动"), "trader.start.model_not_found", mapStringPairs("trader_name", traderName))
return
}
if !fullCfg.AIModel.Enabled {
SafeBadRequestWithDetails(c, formatTraderStartError(
fmt.Sprintf("机器人「%s」关联的 AI 模型「%s」目前还没有启用", traderName, fullCfg.AIModel.Name),
"请前往「设置 > 模型配置」启用它后,再重新点击启动",
), "trader.start.model_disabled", mapStringPairs("trader_name", traderName, "model_name", fullCfg.AIModel.Name))
return
}
// Check exchange
if fullCfg.Exchange == nil {
SafeBadRequestWithDetails(c, formatTraderStartError("这个机器人关联的交易所账户不存在", "请前往「设置 > 交易所配置」检查后,再重新点击启动"), "trader.start.exchange_not_found", mapStringPairs("trader_name", traderName))
return
}
if !fullCfg.Exchange.Enabled {
SafeBadRequestWithDetails(c, formatTraderStartError(
fmt.Sprintf("机器人「%s」关联的交易所账户「%s」目前还没有启用", traderName, exchangeDisplayName(fullCfg.Exchange)),
"请前往「设置 > 交易所配置」启用它后,再重新点击启动",
), "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
}
// 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"})
}

View File

@@ -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,
})
}

View File

@@ -1,493 +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)
// Record order to database (for chart markers and history)
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,
})
}
// 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"
}
}

View File

@@ -1,397 +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=6"`
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
}
// Adopt orphan records from previous account (e.g. after account reset)
// This preserves wallet keys and exchange configs so funds are not lost.
s.adoptOrphanRecords(userID)
// 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",
})
}
// 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 {
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"})
}
// handleResetPassword Reset password via email and new password
func (s *Server) handleResetPassword(c *gin.Context) {
var req struct {
Email string `json:"email" binding:"required,email"`
NewPassword string `json:"new_password" binding:"required,min=6"`
}
if err := c.ShouldBindJSON(&req); err != nil {
SafeBadRequest(c, "Invalid request parameters")
return
}
// Query user
user, err := s.store.User().GetByEmail(req.Email)
if err != nil {
c.JSON(http.StatusNotFound, gin.H{"error": "Email does not exist"})
return
}
// Generate new password hash
newPasswordHash, err := auth.HashPassword(req.NewPassword)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "Password processing failed"})
return
}
// Update password
err = s.store.User().UpdatePassword(user.ID, newPasswordHash)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "Password update failed"})
return
}
logger.Infof("✓ User %s password has been reset", user.Email)
c.JSON(http.StatusOK, gin.H{"message": "Password reset successful, please login with new password"})
}
// handleResetAccount clears user authentication data so the system returns to
// uninitialized state for re-registration. Wallet keys (ai_models) are preserved
// so funds are not lost — they will be adopted by the new account during onboarding.
func (s *Server) handleResetAccount(c *gin.Context) {
err := s.store.Transaction(func(tx *gorm.DB) error {
// Delete traders and strategies (config, not funds)
tx.Session(&gorm.Session{AllowGlobalUpdate: true}).Delete(&store.Trader{})
tx.Session(&gorm.Session{AllowGlobalUpdate: true}).Delete(&store.Strategy{})
// Delete users — ai_models and exchanges are intentionally kept
// so wallet private keys and exchange configs survive re-registration
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 {
SafeInternalError(c, "Failed to reset account", err)
return
}
logger.Infof("✓ User accounts cleared (wallets preserved) — system reset to uninitialized")
c.JSON(http.StatusOK, gin.H{"message": "Account reset successful, you can now register a new account"})
}
// adoptOrphanRecords re-assigns ai_models and exchanges whose user_id no longer
// exists in the users table. This happens after account reset so the new user
// inherits the previous wallet keys and exchange configurations.
func (s *Server) adoptOrphanRecords(newUserID string) {
db := s.store.GormDB()
result := db.Model(&store.AIModel{}).
Where("user_id NOT IN (SELECT id FROM users)").
Update("user_id", newUserID)
if result.RowsAffected > 0 {
logger.Infof("✓ Adopted %d orphan ai_model(s) for new user %s", result.RowsAffected, newUserID)
}
result = db.Model(&store.Exchange{}).
Where("user_id NOT IN (SELECT id FROM users)").
Update("user_id", newUserID)
if result.RowsAffected > 0 {
logger.Infof("✓ Adopted %d orphan exchange(s) for new user %s", result.RowsAffected, newUserID)
}
}
// 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 {
balanced, conservative, aggressive strategyI18n
}
locales := map[string]strategyLocale{
"zh": {
balanced: strategyI18n{"均衡策略", "系统默认策略。均衡风险收益适合大多数市场环境。5倍杠杆最多3个仓位。"},
conservative: strategyI18n{"稳健策略", "系统默认策略。低杠杆保守操作优先保护本金。3倍杠杆专注主流资产。"},
aggressive: strategyI18n{"积极策略", "系统默认策略。高杠杆主动交易更广泛的币种选择适合经验丰富的交易者。10倍杠杆最多5个仓位。"},
},
"en": {
balanced: strategyI18n{"Balanced Strategy", "System default strategy. Balanced risk-reward, suitable for most market conditions. 5x leverage, up to 3 positions."},
conservative: strategyI18n{"Conservative Strategy", "System default strategy. Low-leverage conservative trading, capital preservation first. 3x leverage, focused on major assets."},
aggressive: strategyI18n{"Aggressive Strategy", "System default strategy. High-leverage active trading, wider asset selection, for experienced traders. 10x leverage, up to 5 positions."},
},
"id": {
balanced: strategyI18n{"Strategi Seimbang", "Strategi default sistem. Risiko-reward seimbang, cocok untuk sebagian besar kondisi pasar. Leverage 5x, hingga 3 posisi."},
conservative: strategyI18n{"Strategi Konservatif", "Strategi default sistem. Trading konservatif leverage rendah, utamakan perlindungan modal. Leverage 3x, fokus aset utama."},
aggressive: strategyI18n{"Strategi Agresif", "Strategi default sistem. Trading aktif leverage tinggi, pilihan aset lebih luas, untuk trader berpengalaman. Leverage 10x, hingga 5 posisi."},
},
}
locale, ok := locales[lang]
if !ok {
locale = locales["en"]
}
type strategyDef struct {
name string
description string
isActive bool
applyConfig func(*store.StrategyConfig)
}
definitions := []strategyDef{
{
name: locale.balanced.name,
description: locale.balanced.description,
isActive: true,
applyConfig: func(c *store.StrategyConfig) {
// Uses default config as-is
},
},
{
name: locale.conservative.name,
description: locale.conservative.description,
isActive: false,
applyConfig: func(c *store.StrategyConfig) {
c.RiskControl.BTCETHMaxLeverage = 3
c.RiskControl.AltcoinMaxLeverage = 3
c.RiskControl.BTCETHMaxPositionValueRatio = 3.0
c.RiskControl.AltcoinMaxPositionValueRatio = 0.5
c.RiskControl.MinConfidence = 80
c.RiskControl.MinRiskRewardRatio = 4.0
c.Indicators.Klines.SelectedTimeframes = []string{"15m", "1h", "4h"}
c.Indicators.Klines.PrimaryTimeframe = "15m"
},
},
{
name: locale.aggressive.name,
description: locale.aggressive.description,
isActive: false,
applyConfig: func(c *store.StrategyConfig) {
c.RiskControl.BTCETHMaxLeverage = 10
c.RiskControl.AltcoinMaxLeverage = 7
c.RiskControl.MaxPositions = 5
c.RiskControl.AltcoinMaxPositionValueRatio = 2.0
c.RiskControl.MinConfidence = 70
c.CoinSource.AI500Limit = 5
c.CoinSource.UseOITop = true
c.CoinSource.OITopLimit = 5
c.Indicators.Klines.SelectedTimeframes = []string{"3m", "15m", "1h"}
c.Indicators.Klines.PrimaryTimeframe = "3m"
},
},
}
// 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)
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)
}
return s.store.Transaction(func(tx *gorm.DB) error {
for _, strategy := range strategies {
if err := tx.Create(strategy).Error; err != nil {
return fmt.Errorf("failed to create strategy %q: %w", strategy.Name, err)
}
logger.Infof(" ✓ Created default strategy: %s (active=%v)", strategy.Name, strategy.IsActive)
}
return nil
})
}

View File

@@ -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"
}

252
api/register_otp_test.go Normal file
View 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)
})
}
}

View File

@@ -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()
}

File diff suppressed because it is too large Load Diff

View File

@@ -8,8 +8,6 @@ import (
"nofx/logger"
"nofx/market"
"nofx/mcp"
_ "nofx/mcp/payment"
_ "nofx/mcp/provider"
"nofx/store"
"time"
@@ -31,20 +29,6 @@ func validateStrategyConfig(config *store.StrategyConfig) []string {
return warnings
}
// 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()
@@ -152,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 == "" {
@@ -162,10 +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
Name string `json:"name" binding:"required"`
Description string `json:"description"`
Config store.StrategyConfig `json:"config" binding:"required"`
}
if err := c.ShouldBindJSON(&req); err != nil {
@@ -173,16 +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
}
// Serialize configuration
configJSON, err := json.Marshal(req.Config)
if err != nil {
@@ -206,7 +178,7 @@ func (s *Server) handleCreateStrategy(c *gin.Context) {
}
// Validate configuration and collect warnings
warnings := validateStrategyConfig(req.Config)
warnings := validateStrategyConfig(&req.Config)
response := gin.H{
"id": strategy.ID,
@@ -219,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")
@@ -244,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 {
@@ -256,33 +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: top-level sections present in the request overwrite
// their corresponding existing section; absent sections remain unchanged.
if len(req.Config) > 0 && string(req.Config) != "null" {
if err := json.Unmarshal(req.Config, &mergedConfig); err != nil {
SafeBadRequest(c, "Invalid config JSON")
return
}
}
// 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
@@ -291,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,
@@ -303,27 +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)
// Validate configuration and collect warnings
warnings := validateStrategyConfig(&req.Config)
response := gin.H{"message": "Strategy updated successfully"}
if len(warnings) > 0 {
@@ -344,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
}
@@ -452,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 {
@@ -672,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)
}
@@ -697,3 +639,4 @@ func (s *Server) runRealAITest(userID, modelID, systemPrompt, userPrompt string)
return response, nil
}

350
assistant/agent.go Normal file
View 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
View 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
View 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
View 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
View 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
View 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
View 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)
}

View 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
View 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时买入BTCRSI高于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, &params); 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, &params); 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, &params); 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, &params); 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, &params); 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, &params); 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, &params); 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, &params); 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, &params); 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
View 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
View 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, &params); 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, &params); 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, &params); 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, &params); 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, &params); 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, &params); 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, &params); 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, &params); 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, &params); 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
},
)
}

View File

@@ -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
View 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
View 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
View 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
View 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
View 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
View 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
View 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
View 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
View 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 = ""
}
}

View 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
View 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
View 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

File diff suppressed because it is too large Load Diff

561
backtest/storage.go Normal file
View 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
View 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
View 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"`
}

233
cmd/lighter_test/main.go Normal file
View 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)
}

View File

@@ -1,7 +1,7 @@
package config
import (
"nofx/telemetry"
"nofx/experience"
"nofx/mcp"
"os"
"strconv"
@@ -15,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
@@ -42,13 +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
}
// Init initializes global configuration (from .env)
func Init() {
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",
@@ -68,6 +71,16 @@ func Init() {
cfg.JWTSecret = "default-jwt-secret-change-in-production"
}
if v := os.Getenv("REGISTRATION_ENABLED"); v != "" {
cfg.RegistrationEnabled = strings.ToLower(v) == "true"
}
if v := os.Getenv("MAX_USERS"); v != "" {
if maxUsers, err := strconv.Atoi(v); err == nil && maxUsers >= 0 {
cfg.MaxUsers = maxUsers
}
}
if v := os.Getenv("API_SERVER_PORT"); v != "" {
if port, err := strconv.Atoi(v); err == nil && port > 0 {
cfg.APIServerPort = port
@@ -122,14 +135,13 @@ func Init() {
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,
})

1416
debate/engine.go Normal file

File diff suppressed because it is too large Load Diff

View File

@@ -6,12 +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"
- "6060:6060" # pprof profiling
volumes:
- ./.env:/app/.env
- ./data:/app/data
- /etc/localtime:/etc/localtime:ro
env_file:
@@ -50,4 +49,4 @@ services:
networks:
nofx-network:
driver: bridge
driver: bridge

View 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

View 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

View 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.

View 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

View File

@@ -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

View File

@@ -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

View File

@@ -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 60180 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)

View File

@@ -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+ ⭐)*

View File

@@ -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)
---

View File

@@ -1,173 +1,308 @@
<h1 align="center">NOFX</h1>
# NOFX - AI トレーディングシステム
<p align="center">
<strong>あなた専属の AI トレーディングアシスタント。</strong><br/>
<strong>あらゆる市場。あらゆるモデル。API キー不要、USDC で支払い。</strong>
</p>
[![Go Version](https://img.shields.io/badge/Go-1.21+-00ADD8?style=flat&logo=go)](https://golang.org/)
[![React](https://img.shields.io/badge/React-18+-61DAFB?style=flat&logo=react)](https://reactjs.org/)
[![TypeScript](https://img.shields.io/badge/TypeScript-5.0+-3178C6?style=flat&logo=typescript)](https://www.typescriptlang.org/)
[![License](https://img.shields.io/badge/License-AGPL--3.0-blue.svg)](LICENSE)
<p align="center">
<a href="https://github.com/NoFxAiOS/nofx/stargazers"><img src="https://img.shields.io/github/stars/NoFxAiOS/nofx?style=for-the-badge" alt="Stars"></a>
<a href="https://github.com/NoFxAiOS/nofx/releases"><img src="https://img.shields.io/github/v/release/NoFxAiOS/nofx?style=for-the-badge" alt="Release"></a>
<a href="https://github.com/NoFxAiOS/nofx/blob/main/LICENSE"><img src="https://img.shields.io/badge/License-AGPL--3.0-blue.svg?style=for-the-badge" alt="License"></a>
<a href="https://t.me/nofx_dev_community"><img src="https://img.shields.io/badge/Telegram-Community-blue?style=for-the-badge&logo=telegram" alt="Telegram"></a>
</p>
<p align="center">
<a href="https://golang.org/"><img src="https://img.shields.io/badge/Go-1.21+-00ADD8?style=flat&logo=go" alt="Go"></a>
<a href="https://reactjs.org/"><img src="https://img.shields.io/badge/React-18+-61DAFB?style=flat&logo=react" alt="React"></a>
<a href="https://x402.org"><img src="https://img.shields.io/badge/x402-USDC%20Payments-2775CA?style=flat" alt="x402"></a>
<a href="https://claw402.ai"><img src="https://img.shields.io/badge/Claw402-AI%20Gateway-FF6B35?style=flat" alt="Claw402"></a>
</p>
<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>
**言語:** [English](../../../README.md) | [中文](../zh-CN/README.md) | [日本語](README.md)
---
NOFX はオープンソースの**自律型** AI トレーディングアシスタントです。従来の AI ツールのように手動でモデルを設定し、API キーを管理し、データソースを接続する必要はありません — NOFX の AI は**市場を自ら認識し、モデルを自ら選択し、データを自ら取得します**。人間の介入はゼロ。あなたは戦略を設定するだけ、残りは AI が処理します。
## AI 駆動の暗号通貨取引プラットフォーム
**完全自律**: AI がどのモデルを使うか、どの市場データを取得するか、いつ取引するかを自ら判断します。手動のモデル設定不要。複数サービスの API キー管理不要。USDC ウォレットに入金して実行するだけ
**NOFX** は、複数の AI モデルを使用して暗号通貨先物を自動取引できるオープンソースの AI 取引システムです。Web インターフェースで戦略を設定し、リアルタイムでパフォーマンスを監視し、AI エージェントを競わせて最適な取引アプローチを見つけます
他との違い:**[x402](https://x402.org) マイクロペイメント内蔵**。API キー不要。USDC ウォレットに入金してリクエストごとに支払い。ウォレットがあなたの身分証明。
### コア機能
```bash
curl -fsSL https://raw.githubusercontent.com/NoFxAiOS/nofx/main/install.sh | bash
```
- **マルチ AI サポート**: DeepSeek、Qwen、GPT、Claude、Gemini、Grok、Kimi を実行 - いつでもモデルを切り替え可能
- **マルチ取引所**: Binance、Bybit、OKX、Hyperliquid、Aster DEX、Lighter で統一取引
- **ストラテジースタジオ**: コインソース、インジケーター、リスク管理を設定するビジュアル戦略ビルダー
- **AI 競争モード**: 複数の AI トレーダーがリアルタイムで競争、パフォーマンスを並べて追跡
- **Web ベース設定**: JSON 編集不要 - Web インターフェースですべて設定
- **リアルタイムダッシュボード**: ライブポジション、損益追跡、思考連鎖付き AI 決定ログ
**http://127.0.0.1:3000** を開く。完了。
### 公式リンク
- **公式サイト**: [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)**
---
## x402 の仕組み
## 始める前に
従来のフロー:アカウント登録 → クレジット購入 → API キー取得 → クォータ管理 → キーのローテーション。
NOFXを使用するには以下が必要です:
x402 フロー:
```
リクエスト → 402価格提示→ ウォレットが USDC を署名 → リトライ → 完了
```
アカウント不要。API キー不要。前払いクレジット不要。ウォレット1つで全モデル。
### 内蔵 x402 プロバイダー
| プロバイダー | チェーン | モデル |
|:---------|:------|:-------|
| <img src="../../../web/public/icons/claw402.png" width="20" height="20" style="vertical-align: middle;"/> **[Claw402](https://claw402.ai)** | Base | GPT-5.4, Claude Opus, DeepSeek, Qwen, Grok, Gemini, Kimi — 15+ モデル |
1. **取引所アカウント** - サポートされている取引所に登録し、取引権限付きのAPI認証情報を作成
2. **AI モデル API キー** - サポートされているプロバイダーから取得コスト効率の良いDeepSeekを推奨
---
## 機能
## サポート取引所
| 機能 | 説明 |
|:--------|:------------|
| **マルチ AI** | DeepSeek, Qwen, GPT, Claude, Gemini, Grok, Kimi, MiniMax — いつでも切替 |
| **マルチ取引所** | Binance, Bybit, OKX, Bitget, KuCoin, Gate, Hyperliquid, Aster, Lighter |
| **ストラテジースタジオ** | ビジュアルビルダー — コインソース、インジケーター、リスク管理 |
| **AI ディベートアリーナ** | 複数 AI が取引を議論(ブル vs ベア vs アナリスト)、投票、実行 |
| **AI 競争** | AI がリアルタイムで競争、リーダーボードで成績ランキング |
| **Telegram エージェント** | トレーディングアシスタントとチャット — ストリーミング、ツール呼び出し、メモリ |
| **バックテストラボ** | 過去データシミュレーション、エクイティカーブと成績指標 |
| **ダッシュボード** | ライブポジション、損益、Chain of Thought 付き AI 判断ログ |
### 市場
暗号通貨 · 米国株 · FX · 貴金属
### 取引所 (CEX)
### CEX (中央集権型取引所)
| 取引所 | ステータス | 登録 (手数料割引) |
|:---------|:------:|:------------------------|
| <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/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) |
|----------|--------|-------------------------|
| **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)
### Perp-DEX (分散型永久先物取引所)
| 取引所 | ステータス | 登録 (手数料割引) |
|:---------|:------:|:------------------------|
| <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/aster.svg" width="20" height="20" style="vertical-align: middle;"/> **Aster DEX** | ✅ | [登録](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) |
|----------|--------|-------------------------|
| **Hyperliquid** | ✅ サポート | [登録](https://app.hyperliquid.xyz/join/AITRADING) |
| **Aster DEX** | ✅ サポート | [登録](https://www.asterdex.com/en/referral/fdfc0e) |
| **Lighter** | ✅ サポート | [登録](https://app.lighter.xyz/?referral=68151432) |
### AI モデル (API キーモード)
---
## サポート AI モデル
| AI モデル | ステータス | API キー取得 |
|:---------|:------:|:------------|
| <img src="../../../web/public/icons/deepseek.svg" width="20" height="20" style="vertical-align: middle;"/> **DeepSeek** | ✅ | [API キー取得](https://platform.deepseek.com) |
| <img src="../../../web/public/icons/qwen.svg" width="20" height="20" style="vertical-align: middle;"/> **Qwen** | ✅ | [API キー取得](https://dashscope.console.aliyun.com) |
| <img src="../../../web/public/icons/openai.svg" width="20" height="20" style="vertical-align: middle;"/> **OpenAI (GPT)** | ✅ | [API キー取得](https://platform.openai.com) |
| <img src="../../../web/public/icons/claude.svg" width="20" height="20" style="vertical-align: middle;"/> **Claude** | ✅ | [API キー取得](https://console.anthropic.com) |
| <img src="../../../web/public/icons/gemini.svg" width="20" height="20" style="vertical-align: middle;"/> **Gemini** | ✅ | [API キー取得](https://aistudio.google.com) |
| <img src="../../../web/public/icons/grok.svg" width="20" height="20" style="vertical-align: middle;"/> **Grok** | ✅ | [API キー取得](https://console.x.ai) |
| <img src="../../../web/public/icons/kimi.svg" width="20" height="20" style="vertical-align: middle;"/> **Kimi** | ✅ | [API キー取得](https://platform.moonshot.cn) |
| <img src="../../../web/public/icons/minimax.svg" width="20" height="20" style="vertical-align: middle;"/> **MiniMax** | ✅ | [API キー取得](https://platform.minimaxi.com) |
### AI モデル (x402 モード — API キー不要)
15+ モデルを [Claw402](https://claw402.ai) 経由で利用 — USDC ウォレットのみ
|----------|--------|-------------|
| **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) |
---
## インストール
## クイックスタート
### Linux / macOS
### オプション 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
```
### Railway (クラウド)
このコマンドは最新の公式イメージを取得し、サービスを自動的に再起動します。
[![Deploy on Railway](https://railway.com/button.svg)](https://railway.com/deploy/nofx?referralCode=nofx)
### Docker
```bash
curl -O https://raw.githubusercontent.com/NoFxAiOS/nofx/main/docker-compose.prod.yml
docker compose -f docker-compose.prod.yml up -d
```
### ソースから
### オプション 2: 手動インストール
```bash
# 前提条件: Go 1.21+, Node.js 18+, TA-Lib
# macOS: brew install ta-lib
git clone https://github.com/NoFxAiOS/nofx.git && cd nofx
go build -o nofx && ./nofx # バックエンド
cd web && npm install && npm run dev # フロントエンド(新しいターミナル)
# 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
```
---
## リンク
## 初期設定
| | |
|:--|:--|
| ウェブサイト | [nofxai.com](https://nofxai.com) |
| ダッシュボード | [nofxos.ai/dashboard](https://nofxos.ai/dashboard) |
| API ドキュメント | [nofxos.ai/api-docs](https://nofxos.ai/api-docs) |
| Telegram | [nofx_dev_community](https://t.me/nofx_dev_community) |
| Twitter | [@nofx_official](https://x.com/nofx_official) |
> **リスク警告**: AI 自動取引には重大なリスクがあります。学習/研究目的または少額でのテストのみを推奨します。
1. **AI モデル設定** - AI API キーを追加
2. **取引所設定** - 取引所 API 認証情報を設定
3. **戦略作成** - ストラテジースタジオで取引戦略を設定
4. **トレーダー作成** - AI モデル + 取引所 + 戦略を組み合わせ
5. **取引開始** - 設定したトレーダーを起動
---
## License
## リスク警告
[AGPL-3.0](../../../LICENSE)
1. 暗号通貨市場は非常に変動が激しい - AI の決定は利益を保証しない
2. 先物取引はレバレッジを使用 - 損失は元本を超える可能性がある
3. 極端な市場状況では清算リスクがある
---
## サーバー展開
### クイックデプロイ (HTTP経由のIP)
デフォルトでは、トランスポート暗号化は**無効**になっており、HTTPSなしでIPアドレス経由でNOFXにアクセスできます:
```bash
# サーバーにデプロイ
curl -fsSL https://raw.githubusercontent.com/NoFxAiOS/nofx/main/install.sh | bash
```
`http://YOUR_SERVER_IP:3000` 経由でアクセス - すぐに動作します。
### セキュリティ強化 (HTTPS)
セキュリティを強化するには、`.env`でトランスポート暗号化を有効にします:
```bash
TRANSPORT_ENCRYPTION=true
```
有効にすると、ブラウザはWeb Crypto APIを使用して転送前にAPIキーを暗号化します。これには以下が必要です:
- `https://` - SSLを備えた任意のドメイン
- `http://localhost` - ローカル開発
### Cloudflareを使用した簡単なHTTPSセットアップ
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
# macOS
brew install ta-lib
# Ubuntu
sudo apt-get install libta-lib0-dev
```
### AI APIタイムアウト
- APIキーが正しいか確認
- ネットワーク接続を確認
- システムタイムアウトは120秒
### フロントエンドがバックエンドに接続できない
- バックエンドが http://localhost:8080 で実行されているか確認
- ポートが占有されていないか確認
---
## ライセンス
このプロジェクトは **GNU Affero General Public License v3.0 (AGPL-3.0)** の下でライセンスされています - [LICENSE](LICENSE) ファイルを参照してください。
---
## 貢献
貢献を歓迎します!以下を参照してください:
- **[貢献ガイド](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
[![Star History Chart](https://api.star-history.com/svg?repos=NoFxAiOS/nofx&type=Date)](https://star-history.com/#NoFxAiOS/nofx&Date)

View File

@@ -1,173 +1,309 @@
<h1 align="center">NOFX</h1>
# NOFX - AI 트레이딩 시스템
<p align="center">
<strong>당신만의 AI 트레이딩 어시스턴트.</strong><br/>
<strong>모든 시장. 모든 모델. API 키 없이 USDC로 결제.</strong>
</p>
[![Go Version](https://img.shields.io/badge/Go-1.21+-00ADD8?style=flat&logo=go)](https://golang.org/)
[![React](https://img.shields.io/badge/React-18+-61DAFB?style=flat&logo=react)](https://reactjs.org/)
[![TypeScript](https://img.shields.io/badge/TypeScript-5.0+-3178C6?style=flat&logo=typescript)](https://www.typescriptlang.org/)
[![License](https://img.shields.io/badge/License-AGPL--3.0-blue.svg)](LICENSE)
<p align="center">
<a href="https://github.com/NoFxAiOS/nofx/stargazers"><img src="https://img.shields.io/github/stars/NoFxAiOS/nofx?style=for-the-badge" alt="Stars"></a>
<a href="https://github.com/NoFxAiOS/nofx/releases"><img src="https://img.shields.io/github/v/release/NoFxAiOS/nofx?style=for-the-badge" alt="Release"></a>
<a href="https://github.com/NoFxAiOS/nofx/blob/main/LICENSE"><img src="https://img.shields.io/badge/License-AGPL--3.0-blue.svg?style=for-the-badge" alt="License"></a>
<a href="https://t.me/nofx_dev_community"><img src="https://img.shields.io/badge/Telegram-Community-blue?style=for-the-badge&logo=telegram" alt="Telegram"></a>
</p>
<p align="center">
<a href="https://golang.org/"><img src="https://img.shields.io/badge/Go-1.21+-00ADD8?style=flat&logo=go" alt="Go"></a>
<a href="https://reactjs.org/"><img src="https://img.shields.io/badge/React-18+-61DAFB?style=flat&logo=react" alt="React"></a>
<a href="https://x402.org"><img src="https://img.shields.io/badge/x402-USDC%20Payments-2775CA?style=flat" alt="x402"></a>
<a href="https://claw402.ai"><img src="https://img.shields.io/badge/Claw402-AI%20Gateway-FF6B35?style=flat" alt="Claw402"></a>
</p>
<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>
**언어:** [English](../../../README.md) | [中文](../zh-CN/README.md) | [한국어](README.md)
---
NOFX는 오픈소스 **자율형** AI 트레이딩 어시스턴트입니다. 수동으로 모델을 설정하고, API 키를 관리하고, 데이터 소스를 연결해야 하는 기존 AI 도구와 달리 — NOFX의 AI는 **시장을 스스로 인식하고, 모델을 스스로 선택하고, 데이터를 스스로 가져옵니다**. 인간 개입 제로. 전략만 설정하면 나머지는 AI가 처리합니다.
## AI 기반 암호화폐 거래 플랫폼
**완전 자율**: AI가 어떤 모델을 사용할지, 어떤 시장 데이터를 가져올지, 언제 거래할지를 스스로 결정합니다. 수동 모델 설정 불필요. 여러 서비스의 API 키 관리 불필요. USDC 지갑에 충전하고 실행하기만 하면 됩니다.
**NOFX**는 여러 AI 모델을 실행하여 암호화폐 선물을 자동으로 거래할 수 있는 오픈소스 AI 거래 시스템입니다. 웹 인터페이스를 통해 전략을 구성하고,시간으로 성과를 모니터링하며, AI 에이전트들이 최적의 거래 방식을 찾도록 경쟁시킵니다.
차별점: **[x402](https://x402.org) 마이크로 결제 내장**. API 키 불필요. USDC 지갑에 충전하고 요청마다 결제. 지갑이 곧 신원.
### 핵심 기능
```bash
curl -fsSL https://raw.githubusercontent.com/NoFxAiOS/nofx/main/install.sh | bash
```
- **다중 AI 지원**: DeepSeek, Qwen, GPT, Claude, Gemini, Grok, Kimi 실행 - 언제든 모델 전환 가능
- **다중 거래소**: Binance, Bybit, OKX, Hyperliquid, Aster DEX, Lighter에서 통합 거래
- **전략 스튜디오**: 코인 소스, 지표, 리스크 제어를 설정하는 시각적 전략 빌더
- **AI 경쟁 모드**: 여러 AI 트레이더가 실시간으로 경쟁, 성과를 나란히 추적
- **웹 기반 설정**: JSON 편집 불필요 - 웹 인터페이스에서 모든 설정 완료
- **실시간 대시보드**: 실시간 포지션, 손익 추적, 사고의 연쇄가 포함된 AI 결정 로그
**http://127.0.0.1:3000** 을 열면 완료.
### 공식 링크
- **공식 웹사이트**: [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)**
---
## x402 작동 방식
## 시작하기 전에
기존 플로우: 계정 등록 → 크레딧 구매 → API 키 받기 → 쿼터 관리 → 키 교체.
NOFX를 사용하려면 다음이 필요합니다:
x402 플로우:
```
요청 → 402 (가격 제시) → 지갑이 USDC 서명 → 재시도 → 완료
```
계정 불필요. API 키 불필요. 선불 크레딧 불필요. 지갑 하나로 모든 모델.
### 내장 x402 프로바이더
| 프로바이더 | 체인 | 모델 |
|:---------|:------|:-------|
| <img src="../../../web/public/icons/claw402.png" width="20" height="20" style="vertical-align: middle;"/> **[Claw402](https://claw402.ai)** | Base | GPT-5.4, Claude Opus, DeepSeek, Qwen, Grok, Gemini, Kimi — 15+ 모델 |
1. **거래소 계정** - 지원되는 거래소에 등록하고 거래 권한이 있는 API 자격 증명 생성
2. **AI 모델 API 키** - 지원되는 제공업체에서 획득 (비용 효율성을 위해 DeepSeek 권장)
---
## 기능
## 지원 거래소
| 기능 | 설명 |
|:--------|:------------|
| **멀티 AI** | DeepSeek, Qwen, GPT, Claude, Gemini, Grok, Kimi, MiniMax — 언제든 전환 |
| **멀티 거래소** | Binance, Bybit, OKX, Bitget, KuCoin, Gate, Hyperliquid, Aster, Lighter |
| **전략 스튜디오** | 비주얼 빌더 — 코인 소스, 지표, 리스크 관리 |
| **AI 토론 아레나** | 여러 AI가 거래 토론 (강세 vs 약세 vs 분석가), 투표, 실행 |
| **AI 경쟁** | AI가 실시간 경쟁, 리더보드 순위 |
| **Telegram 에이전트** | 트레이딩 어시스턴트와 채팅 — 스트리밍, 도구 호출, 메모리 |
| **백테스트 랩** | 과거 시뮬레이션, 자산 곡선 및 성과 지표 |
| **대시보드** | 실시간 포지션, 손익, Chain of Thought AI 결정 로그 |
### 시장
암호화폐 · 미국 주식 · 외환 · 귀금속
### 거래소 (CEX)
### CEX (중앙화 거래소)
| 거래소 | 상태 | 등록 (수수료 할인) |
|:---------|:------:|:------------------------|
| <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/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) |
|----------|--------|-------------------------|
| **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)
### Perp-DEX (탈중앙화 영구 선물 거래소)
| 거래소 | 상태 | 등록 (수수료 할인) |
|:---------|:------:|:------------------------|
| <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/aster.svg" width="20" height="20" style="vertical-align: middle;"/> **Aster DEX** | ✅ | [등록](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) |
|----------|--------|-------------------------|
| **Hyperliquid** | ✅ 지원 | [등록](https://app.hyperliquid.xyz/join/AITRADING) |
| **Aster DEX** | ✅ 지원 | [등록](https://www.asterdex.com/en/referral/fdfc0e) |
| **Lighter** | ✅ 지원 | [등록](https://app.lighter.xyz/?referral=68151432) |
### AI 모델 (API 키 모드)
---
## 지원 AI 모델
| AI 모델 | 상태 | API 키 받기 |
|:---------|:------:|:------------|
| <img src="../../../web/public/icons/deepseek.svg" width="20" height="20" style="vertical-align: middle;"/> **DeepSeek** | ✅ | [API 키 받기](https://platform.deepseek.com) |
| <img src="../../../web/public/icons/qwen.svg" width="20" height="20" style="vertical-align: middle;"/> **Qwen** | ✅ | [API 키 받기](https://dashscope.console.aliyun.com) |
| <img src="../../../web/public/icons/openai.svg" width="20" height="20" style="vertical-align: middle;"/> **OpenAI (GPT)** | ✅ | [API 키 받기](https://platform.openai.com) |
| <img src="../../../web/public/icons/claude.svg" width="20" height="20" style="vertical-align: middle;"/> **Claude** | ✅ | [API 키 받기](https://console.anthropic.com) |
| <img src="../../../web/public/icons/gemini.svg" width="20" height="20" style="vertical-align: middle;"/> **Gemini** | ✅ | [API 키 받기](https://aistudio.google.com) |
| <img src="../../../web/public/icons/grok.svg" width="20" height="20" style="vertical-align: middle;"/> **Grok** | ✅ | [API 키 받기](https://console.x.ai) |
| <img src="../../../web/public/icons/kimi.svg" width="20" height="20" style="vertical-align: middle;"/> **Kimi** | ✅ | [API 키 받기](https://platform.moonshot.cn) |
| <img src="../../../web/public/icons/minimax.svg" width="20" height="20" style="vertical-align: middle;"/> **MiniMax** | ✅ | [API 키 받기](https://platform.minimaxi.com) |
### AI 모델 (x402 모드 — API 키 불필요)
15+ 모델을 [Claw402](https://claw402.ai)로 이용 — USDC 지갑만 있으면 됩니다
|----------|--------|-------------|
| **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) |
---
## 설치
## 빠른 시작
### Linux / macOS
### 옵션 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
```
### Railway (클라우드)
이 명령은 최신 공식 이미지를 가져오고 서비스를 자동으로 다시 시작합니다.
[![Deploy on Railway](https://railway.com/button.svg)](https://railway.com/deploy/nofx?referralCode=nofx)
### Docker
```bash
curl -O https://raw.githubusercontent.com/NoFxAiOS/nofx/main/docker-compose.prod.yml
docker compose -f docker-compose.prod.yml up -d
```
### 소스에서
### 옵션 2: 수동 설치
```bash
# 필수 조건: Go 1.21+, Node.js 18+, TA-Lib
# macOS: brew install ta-lib
git clone https://github.com/NoFxAiOS/nofx.git && cd nofx
go build -o nofx && ./nofx # 백엔드
cd web && npm install && npm run dev # 프론트엔드 (새 터미널)
# 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
```
---
## 링크
## 초기 설정
| | |
|:--|:--|
| 웹사이트 | [nofxai.com](https://nofxai.com) |
| 대시보드 | [nofxos.ai/dashboard](https://nofxos.ai/dashboard) |
| API 문서 | [nofxos.ai/api-docs](https://nofxos.ai/api-docs) |
| Telegram | [nofx_dev_community](https://t.me/nofx_dev_community) |
| Twitter | [@nofx_official](https://x.com/nofx_official) |
> **위험 경고**: AI 자동 거래에는 상당한 위험이 있습니다. 학습/연구 또는 소액 테스트만 권장합니다.
1. **AI 모델 설정** - AI API 키 추가
2. **거래소 설정** - 거래소 API 자격 증명 설정
3. **전략 생성** - 전략 스튜디오에서 거래 전략 구성
4. **트레이더 생성** - AI 모델 + 거래소 + 전략 조합
5. **거래 시작** - 설정된 트레이더 시작
---
## License
## 위험 경고
[AGPL-3.0](../../../LICENSE)
1. 암호화폐 시장은 매우 변동성이 높음 - AI 결정이 수익을 보장하지 않음
2. 선물 거래는 레버리지 사용 - 손실이 원금을 초과할 수 있음
3. 극단적인 시장 상황에서 청산 위험 있음
---
## 서버 배포
### 빠른 배포 (IP를 통한 HTTP)
기본적으로 전송 암호화가 **비활성화**되어 HTTPS 없이 IP 주소를 통해 NOFX에 액세스할 수 있습니다:
```bash
# 서버에 배포
curl -fsSL https://raw.githubusercontent.com/NoFxAiOS/nofx/main/install.sh | bash
```
`http://YOUR_SERVER_IP:3000`을 통해 액세스 - 즉시 작동합니다.
### 향상된 보안 (HTTPS)
보안을 강화하려면 `.env`에서 전송 암호화를 활성화하세요:
```bash
TRANSPORT_ENCRYPTION=true
```
활성화되면 브라우저는 Web Crypto API를 사용하여 전송 전에 API 키를 암호화합니다. 이를 위해 필요한 것:
- `https://` - SSL이 있는 모든 도메인
- `http://localhost` - 로컬 개발
### Cloudflare를 사용한 빠른 HTTPS 설정
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
# macOS
brew install ta-lib
# Ubuntu
sudo apt-get install libta-lib0-dev
```
### AI API 타임아웃
- API 키가 올바른지 확인
- 네트워크 연결 확인
- 시스템 타임아웃은 120초
### 프론트엔드가 백엔드에 연결할 수 없음
- 백엔드가 http://localhost:8080에서 실행 중인지 확인
- 포트가 점유되어 있지 않은지 확인
---
## 라이선스
이 프로젝트는 **GNU Affero General Public License v3.0 (AGPL-3.0)** 라이선스에 따라 제공됩니다 - [LICENSE](LICENSE) 파일을 참조하세요.
---
## 기여
기여를 환영합니다! 다음을 참조하세요:
- **[기여 가이드](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
[![Star History Chart](https://api.star-history.com/svg?repos=NoFxAiOS/nofx&type=Date)](https://star-history.com/#NoFxAiOS/nofx&Date)

View File

@@ -1,174 +1,156 @@
<h1 align="center">NOFX</h1>
# NOFX - AI Торговая Система
<p align="center">
<strong>Ваш персональный AI торговый ассистент.</strong><br/>
<strong>Любой рынок. Любая модель. Оплата USDC, без API ключей.</strong>
</p>
[![Go Version](https://img.shields.io/badge/Go-1.21+-00ADD8?style=flat&logo=go)](https://golang.org/)
[![React](https://img.shields.io/badge/React-18+-61DAFB?style=flat&logo=react)](https://reactjs.org/)
[![TypeScript](https://img.shields.io/badge/TypeScript-5.0+-3178C6?style=flat&logo=typescript)](https://www.typescriptlang.org/)
[![License](https://img.shields.io/badge/License-AGPL--3.0-blue.svg)](LICENSE)
<p align="center">
<a href="https://github.com/NoFxAiOS/nofx/stargazers"><img src="https://img.shields.io/github/stars/NoFxAiOS/nofx?style=for-the-badge" alt="Stars"></a>
<a href="https://github.com/NoFxAiOS/nofx/releases"><img src="https://img.shields.io/github/v/release/NoFxAiOS/nofx?style=for-the-badge" alt="Release"></a>
<a href="https://github.com/NoFxAiOS/nofx/blob/main/LICENSE"><img src="https://img.shields.io/badge/License-AGPL--3.0-blue.svg?style=for-the-badge" alt="License"></a>
<a href="https://t.me/nofx_dev_community"><img src="https://img.shields.io/badge/Telegram-Community-blue?style=for-the-badge&logo=telegram" alt="Telegram"></a>
</p>
<p align="center">
<a href="https://golang.org/"><img src="https://img.shields.io/badge/Go-1.21+-00ADD8?style=flat&logo=go" alt="Go"></a>
<a href="https://reactjs.org/"><img src="https://img.shields.io/badge/React-18+-61DAFB?style=flat&logo=react" alt="React"></a>
<a href="https://x402.org"><img src="https://img.shields.io/badge/x402-USDC%20Payments-2775CA?style=flat" alt="x402"></a>
<a href="https://claw402.ai"><img src="https://img.shields.io/badge/Claw402-AI%20Gateway-FF6B35?style=flat" alt="Claw402"></a>
</p>
<p align="center">
<a href="../../../README.md">English</a> ·
<a href="../zh-CN/README.md">中文</a> ·
<a href="../ja/README.md">日本語</a> ·
<a href="../ko/README.md">한국어</a> ·
<a href="README.md">Русский</a> ·
<a href="../uk/README.md">Українська</a> ·
<a href="../vi/README.md">Tiếng Việt</a>
</p>
**Языки:** [English](../../../README.md) | [中文](../zh-CN/README.md) | [Русский](README.md)
---
NOFX — это **автономный** AI торговый ассистент с открытым исходным кодом. В отличие от традиционных AI инструментов, где нужно вручную настраивать модели, управлять API ключами и подключать источники данных — AI в NOFX **сам анализирует рынки, выбирает модели и получает данные**. Нулевое вмешательство человека. Вы задаёте стратегию, AI делает всё остальное.
## Криптовалютная торговая платформа на базе ИИ
**Полная автономность**: AI сам решает, какую модель использовать, какие рыночные данные получить, когда торговать. Без ручной настройки моделей. Без жонглирования API ключами разных сервисов. Просто пополните USDC кошелёк и запустите.
**NOFX** — это open-source AI торговая система, позволяющая запускать несколько AI моделей для автоматической торговли криптовалютными фьючерсами. Настраивайте стратегии через веб-интерфейс, отслеживайте эффективность в реальном времени и позвольте AI агентам конкурировать за лучший торговый подход.
Ключевое отличие: **встроенные [x402](https://x402.org) микроплатежи**. Без API ключей. Пополните USDC кошелёк и платите за каждый запрос. Кошелёк — это ваша идентификация.
### Основные функции
```bash
curl -fsSL https://raw.githubusercontent.com/NoFxAiOS/nofx/main/install.sh | bash
```
- **Мульти-AI поддержка**: Запускайте DeepSeek, Qwen, GPT, Claude, Gemini, Grok, Kimi — переключайтесь между моделями в любое время
- **Мульти-биржа**: Торгуйте на Binance, Bybit, OKX, Hyperliquid, Aster DEX, Lighter с единой платформы
- **Студия стратегий**: Визуальный конструктор стратегий с источниками монет, индикаторами и контролем рисков
- **Режим AI-соревнования**: Несколько AI трейдеров соревнуются в реальном времени, отслеживание эффективности бок о бок
- **Веб-конфигурация**: Без редактирования JSON — настройка всего через веб-интерфейс
- **Панель реального времени**: Живые позиции, отслеживание P/L, логи решений AI с цепочкой рассуждений
Откройте **http://127.0.0.1:3000**. Готово.
### Официальные ссылки
- **Официальный сайт**: [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 Developer Community](https://t.me/nofx_dev_community)**
---
## Как работает x402
## Перед началом
Традиционный процесс: регистрация → покупка кредитов → получение API ключа → управление квотой → ротация ключей.
Для использования NOFX вам понадобится:
x402 процесс:
```
Запрос → 402 (вот цена) → кошелёк подписывает USDC → повтор → готово
```
Без аккаунтов. Без API ключей. Без предоплаты. Один кошелёк, все модели.
### Встроенные x402 провайдеры
| Провайдер | Сеть | Модели |
|:---------|:------|:-------|
| <img src="../../../web/public/icons/claw402.png" width="20" height="20" style="vertical-align: middle;"/> **[Claw402](https://claw402.ai)** | Base | GPT-5.4, Claude Opus, DeepSeek, Qwen, Grok, Gemini, Kimi — 15+ моделей |
1. **Аккаунт биржи** - Зарегистрируйтесь на поддерживаемой бирже и создайте API ключи с правами торговли
2. **API ключ AI модели** - Получите от любого поддерживаемого провайдера (рекомендуется DeepSeek для экономии)
---
## Возможности
## Поддерживаемые биржи
| Функция | Описание |
|:--------|:------------|
| **Мульти-AI** | DeepSeek, Qwen, GPT, Claude, Gemini, Grok, Kimi, MiniMax — переключение в любой момент |
| **Мульти-биржа** | Binance, Bybit, OKX, Bitget, KuCoin, Gate, Hyperliquid, Aster, Lighter |
| **Студия стратегий** | Визуальный конструктор — источники монет, индикаторы, контроль рисков |
| **AI Арена дебатов** | Несколько AI обсуждают сделки (Бык vs Медведь vs Аналитик), голосуют, исполняют |
| **AI Соревнование** | AI соревнуются в реальном времени, рейтинг в таблице лидеров |
| **Telegram Агент** | Чат с торговым ассистентом — стриминг, вызов инструментов, память |
| **Лаборатория бэктеста** | Историческая симуляция с кривой капитала и метриками |
| **Панель управления** | Позиции в реальном времени, P/L, логи AI решений с Chain of Thought |
### Рынки
Криптовалюта · Акции США · Форекс · Металлы
### Биржи (CEX)
### CEX (Централизованные биржи)
| Биржа | Статус | Регистрация (скидка) |
|:---------|:------:|:------------------------|
| <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/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) |
|----------|--------|-------------------------|
| **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)
### Perp-DEX (Децентрализованные биржи)
| Биржа | Статус | Регистрация (скидка) |
|:---------|:------:|:------------------------|
| <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/aster.svg" width="20" height="20" style="vertical-align: middle;"/> **Aster DEX** | ✅ | [Регистрация](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) |
|----------|--------|-------------------------|
| **Hyperliquid** | ✅ Поддерживается | [Регистрация](https://app.hyperliquid.xyz/join/AITRADING) |
| **Aster DEX** | ✅ Поддерживается | [Регистрация](https://www.asterdex.com/en/referral/fdfc0e) |
| **Lighter** | ✅ Поддерживается | [Регистрация](https://app.lighter.xyz/?referral=68151432) |
### AI Модели (Режим API ключей)
---
## Поддерживаемые AI модели
| AI Модель | Статус | Получить API ключ |
|:---------|:------:|:------------|
| <img src="../../../web/public/icons/deepseek.svg" width="20" height="20" style="vertical-align: middle;"/> **DeepSeek** | ✅ | [Получить](https://platform.deepseek.com) |
| <img src="../../../web/public/icons/qwen.svg" width="20" height="20" style="vertical-align: middle;"/> **Qwen** | ✅ | [Получить](https://dashscope.console.aliyun.com) |
| <img src="../../../web/public/icons/openai.svg" width="20" height="20" style="vertical-align: middle;"/> **OpenAI (GPT)** | ✅ | [Получить](https://platform.openai.com) |
| <img src="../../../web/public/icons/claude.svg" width="20" height="20" style="vertical-align: middle;"/> **Claude** | ✅ | [Получить](https://console.anthropic.com) |
| <img src="../../../web/public/icons/gemini.svg" width="20" height="20" style="vertical-align: middle;"/> **Gemini** | ✅ | [Получить](https://aistudio.google.com) |
| <img src="../../../web/public/icons/grok.svg" width="20" height="20" style="vertical-align: middle;"/> **Grok** | ✅ | [Получить](https://console.x.ai) |
| <img src="../../../web/public/icons/kimi.svg" width="20" height="20" style="vertical-align: middle;"/> **Kimi** | ✅ | [Получить](https://platform.moonshot.cn) |
| <img src="../../../web/public/icons/minimax.svg" width="20" height="20" style="vertical-align: middle;"/> **MiniMax** | ✅ | [Получить](https://platform.minimaxi.com) |
### AI Модели (Режим x402 — без API ключей)
15+ моделей через [Claw402](https://claw402.ai) — только USDC кошелёк
|----------|--------|-------------|
| **DeepSeek** | ✅ Поддерживается | [Получить](https://platform.deepseek.com) |
| **Qwen** | ✅ Поддерживается | [Получить](https://dashscope.console.aliyun.com) |
| **OpenAI (GPT)** | ✅ Поддерживается | [Получить](https://platform.openai.com) |
| **Claude** | ✅ Поддерживается | [Получить](https://console.anthropic.com) |
| **Gemini** | ✅ Поддерживается | [Получить](https://aistudio.google.com) |
| **Grok** | ✅ Поддерживается | [Получить](https://console.x.ai) |
| **Kimi** | ✅ Поддерживается | [Получить](https://platform.moonshot.cn) |
---
## Установка
## Быстрый старт
### Linux / macOS
### Вариант 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
```
### Railway (Облако)
Эта команда загружает последние официальные образы и автоматически перезапускает сервисы.
[![Deploy on Railway](https://railway.com/button.svg)](https://railway.com/deploy/nofx?referralCode=nofx)
### Docker
```bash
curl -O https://raw.githubusercontent.com/NoFxAiOS/nofx/main/docker-compose.prod.yml
docker compose -f docker-compose.prod.yml up -d
```
### Из исходников
### Вариант 2: Ручная установка
```bash
# Требования: Go 1.21+, Node.js 18+, TA-Lib
# macOS: brew install ta-lib
# Ubuntu: sudo apt-get install libta-lib0-dev
git clone https://github.com/NoFxAiOS/nofx.git && cd nofx
go build -o nofx && ./nofx # бэкенд
cd web && npm install && npm run dev # фронтенд (новый терминал)
# Установка 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
```
---
## Ссылки
## Начальная настройка
| | |
|:--|:--|
| Сайт | [nofxai.com](https://nofxai.com) |
| Панель | [nofxos.ai/dashboard](https://nofxos.ai/dashboard) |
| API Документация | [nofxos.ai/api-docs](https://nofxos.ai/api-docs) |
| Telegram | [nofx_dev_community](https://t.me/nofx_dev_community) |
| Twitter | [@nofx_official](https://x.com/nofx_official) |
1. **Настройка AI моделей** — Добавьте API ключи AI
2. **Настройка бирж** — Установите API учётные данные бирж
3. **Создание стратегии** — Настройте торговую стратегию в Студии стратегий
4. **Создание трейдера** — Объедините AI модель + Биржу + Стратегию
5. **Начало торговли** — Запустите настроенных трейдеров
> **Предупреждение**: AI автоторговля несёт значительные риски. Рекомендуется только для обучения/исследований или тестирования малых сумм.
---
## Предупреждения о рисках
1. Криптовалютные рынки крайне волатильны — AI решения не гарантируют прибыль
2. Торговля фьючерсами использует плечо — убытки могут превысить депозит
3. Экстремальные рыночные условия могут привести к ликвидации
---
## Лицензия
[AGPL-3.0](../../../LICENSE)
**GNU Affero General Public License v3.0 (AGPL-3.0)**
[![Star History Chart](https://api.star-history.com/svg?repos=NoFxAiOS/nofx&type=Date)](https://star-history.com/#NoFxAiOS/nofx&Date)
---
## Контакты
- **GitHub Issues**: [Создать Issue](https://github.com/NoFxAiOS/nofx/issues)
- **Сообщество разработчиков**: [Telegram группа](https://t.me/nofx_dev_community)

View File

@@ -1,174 +1,156 @@
<h1 align="center">NOFX</h1>
# NOFX - AI Торгова Система
<p align="center">
<strong>Ваш персональний AI торговий асистент.</strong><br/>
<strong>Будь-який ринок. Будь-яка модель. Оплата USDC, без API ключів.</strong>
</p>
[![Go Version](https://img.shields.io/badge/Go-1.21+-00ADD8?style=flat&logo=go)](https://golang.org/)
[![React](https://img.shields.io/badge/React-18+-61DAFB?style=flat&logo=react)](https://reactjs.org/)
[![TypeScript](https://img.shields.io/badge/TypeScript-5.0+-3178C6?style=flat&logo=typescript)](https://www.typescriptlang.org/)
[![License](https://img.shields.io/badge/License-AGPL--3.0-blue.svg)](LICENSE)
<p align="center">
<a href="https://github.com/NoFxAiOS/nofx/stargazers"><img src="https://img.shields.io/github/stars/NoFxAiOS/nofx?style=for-the-badge" alt="Stars"></a>
<a href="https://github.com/NoFxAiOS/nofx/releases"><img src="https://img.shields.io/github/v/release/NoFxAiOS/nofx?style=for-the-badge" alt="Release"></a>
<a href="https://github.com/NoFxAiOS/nofx/blob/main/LICENSE"><img src="https://img.shields.io/badge/License-AGPL--3.0-blue.svg?style=for-the-badge" alt="License"></a>
<a href="https://t.me/nofx_dev_community"><img src="https://img.shields.io/badge/Telegram-Community-blue?style=for-the-badge&logo=telegram" alt="Telegram"></a>
</p>
<p align="center">
<a href="https://golang.org/"><img src="https://img.shields.io/badge/Go-1.21+-00ADD8?style=flat&logo=go" alt="Go"></a>
<a href="https://reactjs.org/"><img src="https://img.shields.io/badge/React-18+-61DAFB?style=flat&logo=react" alt="React"></a>
<a href="https://x402.org"><img src="https://img.shields.io/badge/x402-USDC%20Payments-2775CA?style=flat" alt="x402"></a>
<a href="https://claw402.ai"><img src="https://img.shields.io/badge/Claw402-AI%20Gateway-FF6B35?style=flat" alt="Claw402"></a>
</p>
<p align="center">
<a href="../../../README.md">English</a> ·
<a href="../zh-CN/README.md">中文</a> ·
<a href="../ja/README.md">日本語</a> ·
<a href="../ko/README.md">한국어</a> ·
<a href="../ru/README.md">Русский</a> ·
<a href="README.md">Українська</a> ·
<a href="../vi/README.md">Tiếng Việt</a>
</p>
**Мови:** [English](../../../README.md) | [中文](../zh-CN/README.md) | [Українська](README.md)
---
NOFX — це **автономний** AI торговий асистент з відкритим кодом. На відміну від традиційних AI інструментів, де потрібно вручну налаштовувати моделі, керувати API ключами та підключати джерела даних — AI у NOFX **сам аналізує ринки, обирає моделі та отримує дані**. Нульове втручання людини. Ви задаєте стратегію, AI робить все інше.
## Криптовалютна торгова платформа на базі ШІ
**Повна автономність**: AI сам вирішує, яку модель використовувати, які ринкові дані отримати, коли торгувати. Без ручного налаштування моделей. Без жонглювання API ключами різних сервісів. Просто поповніть USDC гаманець і запустіть.
**NOFX** — це open-source AI торгова система, що дозволяє запускати кілька AI моделей для автоматичної торгівлі криптовалютними ф'ючерсами. Налаштовуйте стратегії через веб-інтерфейс, відстежуйте ефективність у реальному часі та дозвольте AI агентам конкурувати за найкращий торговий підхід.
Ключова відмінність: **вбудовані [x402](https://x402.org) мікроплатежі**. Без API ключів. Поповніть USDC гаманець і платіть за кожен запит. Гаманець — це ваша ідентифікація.
### Основні функції
```bash
curl -fsSL https://raw.githubusercontent.com/NoFxAiOS/nofx/main/install.sh | bash
```
- **Мульти-AI підтримка**: Запускайте DeepSeek, Qwen, GPT, Claude, Gemini, Grok, Kimi — перемикайтеся між моделями будь-коли
- **Мульти-біржа**: Торгуйте на Binance, Bybit, OKX, Hyperliquid, Aster DEX, Lighter з єдиної платформи
- **Студія стратегій**: Візуальний конструктор стратегій з джерелами монет, індикаторами та контролем ризиків
- **Режим AI-змагання**: Кілька AI трейдерів змагаються в реальному часі, відстеження ефективності пліч-о-пліч
- **Веб-конфігурація**: Без редагування JSON — налаштування всього через веб-інтерфейс
- **Панель реального часу**: Живі позиції, відстеження P/L, логи рішень AI з ланцюжком міркувань
Відкрийте **http://127.0.0.1:3000**. Готово.
### Офіційні посилання
- **Офіційний сайт**: [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 Developer Community](https://t.me/nofx_dev_community)**
---
## Як працює x402
## Перед початком
Традиційний процес: реєстрація → купівля кредитів → отримання API ключа → управління квотою → ротація ключів.
Для використання NOFX вам знадобиться:
x402 процес:
```
Запит → 402 (ось ціна) → гаманець підписує USDC → повтор → готово
```
Без акаунтів. Без API ключів. Без передоплати. Один гаманець, усі моделі.
### Вбудовані x402 провайдери
| Провайдер | Мережа | Моделі |
|:---------|:------|:-------|
| <img src="../../../web/public/icons/claw402.png" width="20" height="20" style="vertical-align: middle;"/> **[Claw402](https://claw402.ai)** | Base | GPT-5.4, Claude Opus, DeepSeek, Qwen, Grok, Gemini, Kimi — 15+ моделей |
1. **Акаунт біржі** - Зареєструйтесь на підтримуваній біржі та створіть API ключі з правами торгівлі
2. **API ключ AI моделі** - Отримайте від будь-якого підтримуваного провайдера (рекомендується DeepSeek для економії)
---
## Можливості
## Підтримувані біржі
| Функція | Опис |
|:--------|:------------|
| **Мульти-AI** | DeepSeek, Qwen, GPT, Claude, Gemini, Grok, Kimi, MiniMax — перемикання будь-коли |
| **Мульти-біржа** | Binance, Bybit, OKX, Bitget, KuCoin, Gate, Hyperliquid, Aster, Lighter |
| **Студія стратегій** | Візуальний конструктор — джерела монет, індикатори, контроль ризиків |
| **AI Арена дебатів** | Кілька AI обговорюють угоди, голосують, виконують |
| **AI Змагання** | AI змагаються в реальному часі, рейтинг у таблиці лідерів |
| **Telegram Агент** | Чат з торговим асистентом — стрімінг, виклик інструментів, пам'ять |
| **Лабораторія бектесту** | Історична симуляція з кривою капіталу та метриками |
| **Панель управління** | Позиції в реальному часі, P/L, логи AI рішень з Chain of Thought |
### Ринки
Криптовалюта · Акції США · Форекс · Метали
### Біржі (CEX)
### CEX (Централізовані біржі)
| Біржа | Статус | Реєстрація (знижка) |
|:---------|:------:|:------------------------|
| <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/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) |
|----------|--------|-------------------------|
| **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)
### Perp-DEX (Децентралізовані біржі)
| Біржа | Статус | Реєстрація (знижка) |
|:---------|:------:|:------------------------|
| <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/aster.svg" width="20" height="20" style="vertical-align: middle;"/> **Aster DEX** | ✅ | [Реєстрація](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) |
|----------|--------|-------------------------|
| **Hyperliquid** | ✅ Підтримується | [Реєстрація](https://app.hyperliquid.xyz/join/AITRADING) |
| **Aster DEX** | ✅ Підтримується | [Реєстрація](https://www.asterdex.com/en/referral/fdfc0e) |
| **Lighter** | ✅ Підтримується | [Реєстрація](https://app.lighter.xyz/?referral=68151432) |
### AI Моделі (Режим API ключів)
---
## Підтримувані AI моделі
| AI Модель | Статус | Отримати API ключ |
|:---------|:------:|:------------|
| <img src="../../../web/public/icons/deepseek.svg" width="20" height="20" style="vertical-align: middle;"/> **DeepSeek** | ✅ | [Отримати](https://platform.deepseek.com) |
| <img src="../../../web/public/icons/qwen.svg" width="20" height="20" style="vertical-align: middle;"/> **Qwen** | ✅ | [Отримати](https://dashscope.console.aliyun.com) |
| <img src="../../../web/public/icons/openai.svg" width="20" height="20" style="vertical-align: middle;"/> **OpenAI (GPT)** | ✅ | [Отримати](https://platform.openai.com) |
| <img src="../../../web/public/icons/claude.svg" width="20" height="20" style="vertical-align: middle;"/> **Claude** | ✅ | [Отримати](https://console.anthropic.com) |
| <img src="../../../web/public/icons/gemini.svg" width="20" height="20" style="vertical-align: middle;"/> **Gemini** | ✅ | [Отримати](https://aistudio.google.com) |
| <img src="../../../web/public/icons/grok.svg" width="20" height="20" style="vertical-align: middle;"/> **Grok** | ✅ | [Отримати](https://console.x.ai) |
| <img src="../../../web/public/icons/kimi.svg" width="20" height="20" style="vertical-align: middle;"/> **Kimi** | ✅ | [Отримати](https://platform.moonshot.cn) |
| <img src="../../../web/public/icons/minimax.svg" width="20" height="20" style="vertical-align: middle;"/> **MiniMax** | ✅ | [Отримати](https://platform.minimaxi.com) |
### AI Моделі (Режим x402 — без API ключів)
15+ моделей через [Claw402](https://claw402.ai) — лише USDC гаманець
|----------|--------|-------------|
| **DeepSeek** | ✅ Підтримується | [Отримати](https://platform.deepseek.com) |
| **Qwen** | ✅ Підтримується | [Отримати](https://dashscope.console.aliyun.com) |
| **OpenAI (GPT)** | ✅ Підтримується | [Отримати](https://platform.openai.com) |
| **Claude** | ✅ Підтримується | [Отримати](https://console.anthropic.com) |
| **Gemini** | ✅ Підтримується | [Отримати](https://aistudio.google.com) |
| **Grok** | ✅ Підтримується | [Отримати](https://console.x.ai) |
| **Kimi** | ✅ Підтримується | [Отримати](https://platform.moonshot.cn) |
---
## Встановлення
## Швидкий старт
### Linux / macOS
### Варіант 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
```
### Railway (Хмара)
Ця команда завантажує останні офіційні образи та автоматично перезапускає сервіси.
[![Deploy on Railway](https://railway.com/button.svg)](https://railway.com/deploy/nofx?referralCode=nofx)
### Docker
```bash
curl -O https://raw.githubusercontent.com/NoFxAiOS/nofx/main/docker-compose.prod.yml
docker compose -f docker-compose.prod.yml up -d
```
### З вихідного коду
### Варіант 2: Ручна установка
```bash
# Вимоги: Go 1.21+, Node.js 18+, TA-Lib
# macOS: brew install ta-lib
# Ubuntu: sudo apt-get install libta-lib0-dev
git clone https://github.com/NoFxAiOS/nofx.git && cd nofx
go build -o nofx && ./nofx # бекенд
cd web && npm install && npm run dev # фронтенд (новий термінал)
# Встановлення 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
```
---
## Посилання
## Початкове налаштування
| | |
|:--|:--|
| Сайт | [nofxai.com](https://nofxai.com) |
| Панель | [nofxos.ai/dashboard](https://nofxos.ai/dashboard) |
| API Документація | [nofxos.ai/api-docs](https://nofxos.ai/api-docs) |
| Telegram | [nofx_dev_community](https://t.me/nofx_dev_community) |
| Twitter | [@nofx_official](https://x.com/nofx_official) |
1. **Налаштування AI моделей** — Додайте API ключі AI
2. **Налаштування бірж** — Встановіть API облікові дані бірж
3. **Створення стратегії** — Налаштуйте торгову стратегію в Студії стратегій
4. **Створення трейдера**Об'єднайте AI модель + Біржу + Стратегію
5. **Початок торгівлі** — Запустіть налаштованих трейдерів
> **Попередження**: AI автоторгівля несе значні ризики. Рекомендується лише для навчання/досліджень або тестування малих сум.
---
## Попередження про ризики
1. Криптовалютні ринки надзвичайно волатильні — AI рішення не гарантують прибуток
2. Торгівля ф'ючерсами використовує плече — збитки можуть перевищити депозит
3. Екстремальні ринкові умови можуть призвести до ліквідації
---
## Ліцензія
[AGPL-3.0](../../../LICENSE)
**GNU Affero General Public License v3.0 (AGPL-3.0)**
[![Star History Chart](https://api.star-history.com/svg?repos=NoFxAiOS/nofx&type=Date)](https://star-history.com/#NoFxAiOS/nofx&Date)
---
## Контакти
- **GitHub Issues**: [Створити Issue](https://github.com/NoFxAiOS/nofx/issues)
- **Спільнота розробників**: [Telegram група](https://t.me/nofx_dev_community)

View File

@@ -1,172 +1,156 @@
<h1 align="center">NOFX</h1>
# NOFX - Hệ Thống Giao Dịch AI
<p align="center">
<strong>Trợ lý giao dịch AI cá nhân của bạn.</strong><br/>
<strong>Mọi thị trường. Mọi mô hình. Thanh toán USDC, không cần API key.</strong>
</p>
[![Go Version](https://img.shields.io/badge/Go-1.21+-00ADD8?style=flat&logo=go)](https://golang.org/)
[![React](https://img.shields.io/badge/React-18+-61DAFB?style=flat&logo=react)](https://reactjs.org/)
[![TypeScript](https://img.shields.io/badge/TypeScript-5.0+-3178C6?style=flat&logo=typescript)](https://www.typescriptlang.org/)
[![License](https://img.shields.io/badge/License-AGPL--3.0-blue.svg)](LICENSE)
<p align="center">
<a href="https://github.com/NoFxAiOS/nofx/stargazers"><img src="https://img.shields.io/github/stars/NoFxAiOS/nofx?style=for-the-badge" alt="Stars"></a>
<a href="https://github.com/NoFxAiOS/nofx/releases"><img src="https://img.shields.io/github/v/release/NoFxAiOS/nofx?style=for-the-badge" alt="Release"></a>
<a href="https://github.com/NoFxAiOS/nofx/blob/main/LICENSE"><img src="https://img.shields.io/badge/License-AGPL--3.0-blue.svg?style=for-the-badge" alt="License"></a>
<a href="https://t.me/nofx_dev_community"><img src="https://img.shields.io/badge/Telegram-Community-blue?style=for-the-badge&logo=telegram" alt="Telegram"></a>
</p>
<p align="center">
<a href="https://golang.org/"><img src="https://img.shields.io/badge/Go-1.21+-00ADD8?style=flat&logo=go" alt="Go"></a>
<a href="https://reactjs.org/"><img src="https://img.shields.io/badge/React-18+-61DAFB?style=flat&logo=react" alt="React"></a>
<a href="https://x402.org"><img src="https://img.shields.io/badge/x402-USDC%20Payments-2775CA?style=flat" alt="x402"></a>
<a href="https://claw402.ai"><img src="https://img.shields.io/badge/Claw402-AI%20Gateway-FF6B35?style=flat" alt="Claw402"></a>
</p>
<p align="center">
<a href="../../../README.md">English</a> ·
<a href="../zh-CN/README.md">中文</a> ·
<a href="../ja/README.md">日本語</a> ·
<a href="../ko/README.md">한국어</a> ·
<a href="../ru/README.md">Русский</a> ·
<a href="../uk/README.md">Українська</a> ·
<a href="README.md">Tiếng Việt</a>
</p>
**Ngôn ngữ:** [English](../../../README.md) | [中文](../zh-CN/README.md) | [Tiếng Việt](README.md)
---
NOFX là trợ lý giao dịch AI **tự chủ** mã nguồn mở. Không giống các công cụ AI truyền thống yêu cầu bạn cấu hình mô hình thủ công, quản lý API key và kết nối nguồn dữ liệu — AI của NOFX **tự nhận diện thị trường, tự chọn mô hình và tự lấy dữ liệu**. Không cần con người can thiệp. Bạn chỉ cần đặt chiến lược, AI xử lý mọi thứ còn lại.
## Nền Tảng Giao Dịch Crypto Sử Dụng AI
**Hoàn toàn tự chủ**: AI tự quyết định sử dụng mô hình nào, lấy dữ liệu thị trường gì, khi nào giao dịch. Không cần cấu hình mô hình thủ công. Không cần quản lý API key của nhiều dịch vụ. Chỉ cần nạp ví USDC và chạy.
**NOFX** là hệ thống giao dịch AI mã nguồn mở cho phép bạn chạy nhiều mô hình AI để tự động giao dịch hợp đồng tương lai crypto. Cấu hình chiến lược qua giao diện web, theo dõi hiệu suất theo thời gian thực, và để các AI agent cạnh tranh tìm ra phương pháp giao dịch tốt nhất.
Điểm khác biệt: **tích hợp thanh toán vi mô [x402](https://x402.org)**. Không cần API key. Nạp ví USDC và thanh toán theo yêu cầu. Ví chính là danh tính của bạn.
### Tính Năng Chính
```bash
curl -fsSL https://raw.githubusercontent.com/NoFxAiOS/nofx/main/install.sh | bash
```
- **Hỗ trợ Đa AI**: Chạy DeepSeek, Qwen, GPT, Claude, Gemini, Grok, Kimi - chuyển đổi mô hình bất cứ lúc nào
- **Đa Sàn Giao Dịch**: Giao dịch trên Binance, Bybit, OKX, Hyperliquid, Aster DEX, Lighter từ một nền tảng
- **Strategy Studio**: Trình tạo chiến lược trực quan với nguồn coin, chỉ báo và kiểm soát rủi ro
- **Chế Độ Thi Đấu AI**: Nhiều AI trader cạnh tranh theo thời gian thực, theo dõi hiệu suất song song
- **Cấu Hình Web**: Không cần chỉnh sửa JSON - cấu hình mọi thứ qua giao diện web
- **Dashboard Thời Gian Thực**: Vị thế trực tiếp, theo dõi P/L, nhật ký quyết định AI với chuỗi suy luận
Mở **http://127.0.0.1:3000**. Xong.
### Liên Kết Chính Thức
- **Website Chính Thức**: [https://nofxai.com](https://nofxai.com)
- **Bảng Điều Khiển Dữ Liệu**: [https://nofxos.ai/dashboard](https://nofxos.ai/dashboard)
- **Tài Liệu API**: [https://nofxos.ai/api-docs](https://nofxos.ai/api-docs)
> **Cảnh Báo Rủi Ro**: Hệ thống này mang tính thử nghiệm. Giao dịch tự động AI có rủi ro đáng kể. Chỉ nên sử dụng cho mục đích học tập/nghiên cứu hoặc kiểm tra với số tiền nhỏ!
## Cộng Đồng Nhà Phát Triển
Tham gia cộng đồng Telegram: **[NOFX Developer Community](https://t.me/nofx_dev_community)**
---
## x402 hoạt động như thế nào
## Trước Khi Bắt Đầu
Quy trình truyền thống: đăng ký tài khoản → mua credits → lấy API key → quản lý quota → xoay key.
Để sử dụng NOFX, bạn cần:
Quy trình x402:
```
Yêu cầu → 402 (đây là giá) → ví ký USDC → thử lại → xong
```
Không tài khoản. Không API key. Không trả trước. Một ví, tất cả mô hình.
### Nhà cung cấp x402 tích hợp
| Nhà cung cấp | Chain | Mô hình |
|:---------|:------|:-------|
| <img src="../../../web/public/icons/claw402.png" width="20" height="20" style="vertical-align: middle;"/> **[Claw402](https://claw402.ai)** | Base | GPT-5.4, Claude Opus, DeepSeek, Qwen, Grok, Gemini, Kimi — 15+ mô hình |
1. **Tài khoản sàn giao dịch** - Đăng ký trên sàn được hỗ trợ và tạo API key với quyền giao dịch
2. **API Key mô hình AI** - Lấy từ nhà cung cấp được hỗ trợ (khuyến nghị DeepSeek để tiết kiệm chi phí)
---
## Tính năng
## Sàn Giao Dịch Được Hỗ Trợ
| Tính năng | Mô tả |
|:--------|:------------|
| **Đa AI** | DeepSeek, Qwen, GPT, Claude, Gemini, Grok, Kimi, MiniMax — chuyển đổi bất cứ lúc nào |
| **Đa Sàn** | Binance, Bybit, OKX, Bitget, KuCoin, Gate, Hyperliquid, Aster, Lighter |
| **Strategy Studio** | Trình xây dựng trực quan — nguồn coin, chỉ báo, kiểm soát rủi ro |
| **AI Competition** | AI cạnh tranh thời gian thực, bảng xếp hạng hiệu suất |
| **Telegram Agent** | Chat với trợ lý giao dịch — streaming, gọi công cụ, bộ nhớ |
| **Dashboard** | Vị thế trực tiếp, P/L, nhật ký quyết định AI với Chain of Thought |
### Thị trường
Crypto · Cổ phiếu Mỹ · Forex · Kim loại
### Sàn giao dịch (CEX)
### CEX (Sàn Tập Trung)
| Sàn | Trạng thái | Đăng ký (Giảm phí) |
|:---------|:------:|:------------------------|
| <img src="../../../web/public/exchange-icons/binance.jpg" width="20" height="20" style="vertical-align: middle;"/> **Binance** | ✅ | [Đăng ký](https://www.binance.com/join?ref=NOFXENG) |
| <img src="../../../web/public/exchange-icons/bybit.png" width="20" height="20" style="vertical-align: middle;"/> **Bybit** | ✅ | [Đăng ký](https://partner.bybit.com/b/83856) |
| <img src="../../../web/public/exchange-icons/okx.svg" width="20" height="20" style="vertical-align: middle;"/> **OKX** | ✅ | [Đăng ký](https://www.okx.com/join/1865360) |
| <img src="../../../web/public/exchange-icons/bitget.svg" width="20" height="20" style="vertical-align: middle;"/> **Bitget** | ✅ | [Đăng ký](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** | ✅ | [Đăng ký](https://www.kucoin.com/r/broker/CXEV7XKK) |
| <img src="../../../web/public/exchange-icons/gate.svg" width="20" height="20" style="vertical-align: middle;"/> **Gate** | ✅ | [Đăng ký](https://www.gatenode.xyz/share/VQBGUAxY) |
|----------|--------|-------------------------|
| **Binance** | ✅ Hỗ trợ | [Đăng ký](https://www.binance.com/join?ref=NOFXENG) |
| **Bybit** | ✅ Hỗ trợ | [Đăng ký](https://partner.bybit.com/b/83856) |
| **OKX** | ✅ Hỗ trợ | [Đăng ký](https://www.okx.com/join/1865360) |
| **Bitget** | ✅ Hỗ trợ | [Đăng ký](https://www.bitget.com/referral/register?from=referral&clacCode=c8a43172) |
### Sàn giao dịch (Perp-DEX)
### Perp-DEX (Sàn Phi Tập Trung)
| Sàn | Trạng thái | Đăng ký (Giảm phí) |
|:---------|:------:|:------------------------|
| <img src="../../../web/public/exchange-icons/hyperliquid.png" width="20" height="20" style="vertical-align: middle;"/> **Hyperliquid** | ✅ | [Đăng ký](https://app.hyperliquid.xyz/join/AITRADING) |
| <img src="../../../web/public/exchange-icons/aster.svg" width="20" height="20" style="vertical-align: middle;"/> **Aster DEX** | ✅ | [Đăng ký](https://www.asterdex.com/en/referral/fdfc0e) |
| <img src="../../../web/public/exchange-icons/lighter.png" width="20" height="20" style="vertical-align: middle;"/> **Lighter** | ✅ | [Đăng ký](https://app.lighter.xyz/?referral=68151432) |
|----------|--------|-------------------------|
| **Hyperliquid** | ✅ Hỗ trợ | [Đăng ký](https://app.hyperliquid.xyz/join/AITRADING) |
| **Aster DEX** | ✅ Hỗ trợ | [Đăng ký](https://www.asterdex.com/en/referral/fdfc0e) |
| **Lighter** | ✅ Hỗ trợ | [Đăng ký](https://app.lighter.xyz/?referral=68151432) |
### Mô hình AI (Chế độ API Key)
---
## Mô Hình AI Được Hỗ Trợ
| Mô hình AI | Trạng thái | Lấy API Key |
|:---------|:------:|:------------|
| <img src="../../../web/public/icons/deepseek.svg" width="20" height="20" style="vertical-align: middle;"/> **DeepSeek** | ✅ | [Lấy API Key](https://platform.deepseek.com) |
| <img src="../../../web/public/icons/qwen.svg" width="20" height="20" style="vertical-align: middle;"/> **Qwen** | ✅ | [Lấy API Key](https://dashscope.console.aliyun.com) |
| <img src="../../../web/public/icons/openai.svg" width="20" height="20" style="vertical-align: middle;"/> **OpenAI (GPT)** | ✅ | [Lấy API Key](https://platform.openai.com) |
| <img src="../../../web/public/icons/claude.svg" width="20" height="20" style="vertical-align: middle;"/> **Claude** | ✅ | [Lấy API Key](https://console.anthropic.com) |
| <img src="../../../web/public/icons/gemini.svg" width="20" height="20" style="vertical-align: middle;"/> **Gemini** | ✅ | [Lấy API Key](https://aistudio.google.com) |
| <img src="../../../web/public/icons/grok.svg" width="20" height="20" style="vertical-align: middle;"/> **Grok** | ✅ | [Lấy API Key](https://console.x.ai) |
| <img src="../../../web/public/icons/kimi.svg" width="20" height="20" style="vertical-align: middle;"/> **Kimi** | ✅ | [Lấy API Key](https://platform.moonshot.cn) |
| <img src="../../../web/public/icons/minimax.svg" width="20" height="20" style="vertical-align: middle;"/> **MiniMax** | ✅ | [Lấy API Key](https://platform.minimaxi.com) |
### Mô hình AI (Chế độ x402 — Không cần API Key)
15+ mô hình qua [Claw402](https://claw402.ai) — chỉ cần ví USDC
|----------|--------|-------------|
| **DeepSeek** | ✅ Hỗ trợ | [Lấy API Key](https://platform.deepseek.com) |
| **Qwen** | ✅ Hỗ trợ | [Lấy API Key](https://dashscope.console.aliyun.com) |
| **OpenAI (GPT)** | ✅ Hỗ trợ | [Lấy API Key](https://platform.openai.com) |
| **Claude** | ✅ Hỗ trợ | [Lấy API Key](https://console.anthropic.com) |
| **Gemini** | ✅ Hỗ trợ | [Lấy API Key](https://aistudio.google.com) |
| **Grok** | ✅ Hỗ trợ | [Lấy API Key](https://console.x.ai) |
| **Kimi** | ✅ Hỗ trợ | [Lấy API Key](https://platform.moonshot.cn) |
---
## Cài đặt
## Bắt Đầu Nhanh
### Linux / macOS
### Tùy chọn 1: Triển khai Docker (Khuyến nghị)
```bash
git clone https://github.com/NoFxAiOS/nofx.git
cd nofx
chmod +x ./start.sh
./start.sh start --build
```
Truy cập giao diện Web: **http://localhost:3000**
### Cập Nhật Phiên Bản Mới
> **💡 Cập nhật thường xuyên.** Chạy lệnh này hàng ngày để nhận các tính năng và bản sửa lỗi mới nhất:
```bash
curl -fsSL https://raw.githubusercontent.com/NoFxAiOS/nofx/main/install.sh | bash
```
### Railway (Cloud)
Lệnh này tải về image chính thức mới nhất và tự động khởi động lại dịch vụ.
[![Deploy on Railway](https://railway.com/button.svg)](https://railway.com/deploy/nofx?referralCode=nofx)
### Docker
```bash
curl -O https://raw.githubusercontent.com/NoFxAiOS/nofx/main/docker-compose.prod.yml
docker compose -f docker-compose.prod.yml up -d
```
### Từ mã nguồn
### Tùy chọn 2: Cài đặt Thủ công
```bash
# Yêu cầu: Go 1.21+, Node.js 18+, TA-Lib
# macOS: brew install ta-lib
# Ubuntu: sudo apt-get install libta-lib0-dev
git clone https://github.com/NoFxAiOS/nofx.git && cd nofx
go build -o nofx && ./nofx # backend
cd web && npm install && npm run dev # frontend (terminal mới)
# Cài đặt TA-Lib (macOS)
brew install ta-lib
# Clone và thiết lập
git clone https://github.com/NoFxAiOS/nofx.git
cd nofx
go mod download
cd web && npm install && cd ..
# Khởi động backend
go build -o nofx && ./nofx
# Khởi động frontend (terminal mới)
cd web && npm run dev
```
---
## Liên kết
## Thiết Lập Ban Đầu
| | |
|:--|:--|
| Website | [nofxai.com](https://nofxai.com) |
| Dashboard | [nofxos.ai/dashboard](https://nofxos.ai/dashboard) |
| API Docs | [nofxos.ai/api-docs](https://nofxos.ai/api-docs) |
| Telegram | [nofx_dev_community](https://t.me/nofx_dev_community) |
| Twitter | [@nofx_official](https://x.com/nofx_official) |
> **Cảnh báo rủi ro**: Giao dịch tự động AI có rủi ro đáng kể. Chỉ nên sử dụng cho mục đích học tập/nghiên cứu hoặc số tiền nhỏ.
1. **Cấu hình Mô hình AI** — Thêm API key AI
2. **Cấu hình Sàn giao dịch** — Thiết lập thông tin API sàn
3. **Tạo Chiến lược** — Cấu hình chiến lược giao dịch trong Strategy Studio
4. **Tạo Trader** — Kết hợp Mô hình AI + Sàn + Chiến lược
5. **Bắt đầu Giao dịch** — Khởi động các trader đã cấu hình
---
## License
## Cảnh Báo Rủi Ro
[AGPL-3.0](../../../LICENSE)
1. Thị trường crypto biến động cực kỳ mạnh — Quyết định AI không đảm bảo lợi nhuận
2. Giao dịch hợp đồng tương lai sử dụng đòn bẩy — Thua lỗ có thể vượt quá vốn
3. Điều kiện thị trường cực đoan có thể dẫn đến thanh lý
[![Star History Chart](https://api.star-history.com/svg?repos=NoFxAiOS/nofx&type=Date)](https://star-history.com/#NoFxAiOS/nofx&Date)
---
## Giấy Phép
**GNU Affero General Public License v3.0 (AGPL-3.0)**
---
## Liên Hệ
- **GitHub Issues**: [Gửi Issue](https://github.com/NoFxAiOS/nofx/issues)
- **Cộng đồng Nhà phát triển**: [Nhóm Telegram](https://t.me/nofx_dev_community)

View File

@@ -1,238 +1,450 @@
<h1 align="center">NOFX</h1>
# NOFX - AI 交易系统
<p align="center">
<strong>你的个人 AI 交易助手。</strong><br/>
<strong>任何市场。任何模型。用 USDC 付费,无需 API Key。</strong>
</p>
<p align="center">
<a href="https://github.com/NoFxAiOS/nofx/stargazers"><img src="https://img.shields.io/github/stars/NoFxAiOS/nofx?style=for-the-badge" alt="Stars"></a>
<a href="https://github.com/NoFxAiOS/nofx/releases"><img src="https://img.shields.io/github/v/release/NoFxAiOS/nofx?style=for-the-badge" alt="Release"></a>
<a href="https://github.com/NoFxAiOS/nofx/blob/main/LICENSE"><img src="https://img.shields.io/badge/License-AGPL--3.0-blue.svg?style=for-the-badge" alt="License"></a>
<a href="https://t.me/nofx_dev_community"><img src="https://img.shields.io/badge/Telegram-Community-blue?style=for-the-badge&logo=telegram" alt="Telegram"></a>
</p>
<p align="center">
<a href="https://golang.org/"><img src="https://img.shields.io/badge/Go-1.21+-00ADD8?style=flat&logo=go" alt="Go"></a>
<a href="https://reactjs.org/"><img src="https://img.shields.io/badge/React-18+-61DAFB?style=flat&logo=react" alt="React"></a>
<a href="https://x402.org"><img src="https://img.shields.io/badge/x402-USDC%20Payments-2775CA?style=flat" alt="x402"></a>
<a href="https://claw402.ai"><img src="https://img.shields.io/badge/Claw402-AI%20Gateway-FF6B35?style=flat" alt="Claw402"></a>
</p>
<p align="center">
<a href="../../../README.md">English</a> ·
<a href="README.md">中文</a> ·
<a href="../ja/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>
[![Go Version](https://img.shields.io/badge/Go-1.21+-00ADD8?style=flat&logo=go)](https://golang.org/)
[![React](https://img.shields.io/badge/React-18+-61DAFB?style=flat&logo=react)](https://reactjs.org/)
[![TypeScript](https://img.shields.io/badge/TypeScript-5.0+-3178C6?style=flat&logo=typescript)](https://www.typescriptlang.org/)
[![License](https://img.shields.io/badge/License-AGPL--3.0-blue.svg)](LICENSE)
> **语言声明:** 本中文版本文档仅为方便海外华人社区阅读而提供,不代表本软件面向中国大陆、香港、澳门或台湾地区用户开放。如您位于上述地区,请勿使用本软件。
---
| 贡献者空投计划 |
|:----------------------------------:|
| 代码 · Bug修复 · Issue → 空投奖励 |
| [了解更多](#贡献者空投计划) |
NOFX 是一个开源的**自主式** AI 交易助手。与需要手动配置模型、管理 API Key、接入数据源的传统 AI 工具不同 —— NOFX 的 AI **自主感知市场、自选模型、自动获取数据**。零人工干预。你只需设定策略AI 负责一切。
**完全自主**AI 自行决定使用哪个模型、获取什么市场数据、何时交易。无需手动配置模型,无需管理各种服务的 API Key。只需充值 USDC 钱包,一键启动。
核心差异:**内置 [x402](https://x402.org) 微支付协议**。无需 API Key充值 USDC 钱包即可按需付费。钱包就是你的身份。
```bash
curl -fsSL https://raw.githubusercontent.com/NoFxAiOS/nofx/main/install.sh | bash
```
打开 **http://127.0.0.1:3000**,完成。
**语言:** [English](../../../README.md) | [中文](README.md) | [日本語](../ja/README.md) | [한국어](../ko/README.md) | [Русский](../ru/README.md) | [Українська](../uk/README.md) | [Tiếng Việt](../vi/README.md)
---
## x402 如何工作
## AI 驱动的加密货币交易平台
传统流程:注册账号 → 购买额度 → 获取 API Key → 管理配额 → 轮换密钥
**NOFX** 是一个开源的 AI 交易系统,让你可以运行多个 AI 模型自动交易加密货币期货。通过 Web 界面配置策略,实时监控表现,让多个 AI 代理竞争找出最佳交易方案
x402 流程:
### 核心功能
```
请求 → 402返回价格→ 钱包签名 USDC → 重试 → 完成
```
- **多 AI 支持**: 运行 DeepSeek、通义千问、GPT、Claude、Gemini、Grok、Kimi - 随时切换模型
- **多交易所**: 在 Binance、Bybit、OKX、Hyperliquid、Aster DEX、Lighter 统一交易
- **策略工作室**: 可视化策略构建器,配置币种来源、指标和风控参数
- **AI 竞赛模式**: 多个 AI 交易员实时竞争,并排追踪表现
- **Web 配置**: 无需编辑 JSON - 通过 Web 界面完成所有配置
- **实时仪表板**: 实时持仓、盈亏追踪、AI 决策日志与思维链
无需注册。无需 API Key。无需预付费。一个钱包所有模型。
### 核心团队
### 内置 x402 提供商
- **Tinkle** - [@Web3Tinkle](https://x.com/Web3Tinkle)
- **官方 Twitter** - [@nofx_official](https://x.com/nofx_official)
| 提供商 | 链 | 模型 |
|:---------|:------|:-------|
| <img src="../../../web/public/icons/claw402.png" width="20" height="20" style="vertical-align: middle;"/> **[Claw402](https://claw402.ai)** | Base | GPT-5.4、Claude Opus、DeepSeek、Qwen、Grok、Gemini、Kimi — 15+ 模型 |
### 官方链接
- **官网**: [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)**
---
## 功能概览
## 开始之前
| 功能 | 描述 |
|:--------|:------------|
| **多 AI** | DeepSeek、Qwen、GPT、Claude、Gemini、Grok、Kimi、MiniMax — 随时切换 |
| **多交易所** | Binance、Bybit、OKX、Bitget、KuCoin、Gate、Hyperliquid、Aster、Lighter |
| **策略工作室** | 可视化构建器 — 币种来源、指标、风控 |
| **AI 竞赛** | AI 实时竞争,排行榜排名 |
| **Telegram Agent** | 与交易助手对话 — 流式输出、工具调用、记忆 |
| **回测实验室** | 历史模拟,权益曲线和性能指标 |
| **仪表板** | 实时持仓、盈亏、AI 决策日志与思维链 |
使用 NOFX 你需要准备:
### 市场
1. **交易所账户** - 在任意支持的交易所注册并创建具有交易权限的 API 凭证
2. **AI 模型 API Key** - 从任意支持的提供商获取(推荐 DeepSeek性价比最高
加密货币 · 美股 · 外汇 · 贵金属
---
### 交易所 (CEX)
## 支持的交易所
### CEX (中心化交易所)
| 交易所 | 状态 | 注册 (手续费折扣) |
|:---------|:------:|:------------------------|
| <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/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) |
|----------|--------|-------------------------|
| **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)
### Perp-DEX (去中心化永续交易所)
| 交易所 | 状态 | 注册 (手续费折扣) |
|:---------|:------:|:------------------------|
| <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/aster.svg" width="20" height="20" style="vertical-align: middle;"/> **Aster DEX** | ✅ | [注册](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) |
|----------|--------|-------------------------|
| **Hyperliquid** | ✅ 已支持 | [注册](https://app.hyperliquid.xyz/join/AITRADING) |
| **Aster DEX** | ✅ 已支持 | [注册](https://www.asterdex.com/en/referral/fdfc0e) |
| **Lighter** | ✅ 已支持 | [注册](https://app.lighter.xyz/?referral=68151432) |
### AI 模型 (API Key 模式)
---
## 支持的 AI 模型
| AI 模型 | 状态 | 获取 API Key |
|:---------|:------:|:------------|
| <img src="../../../web/public/icons/deepseek.svg" width="20" height="20" style="vertical-align: middle;"/> **DeepSeek** | ✅ | [获取 API Key](https://platform.deepseek.com) |
| <img src="../../../web/public/icons/qwen.svg" width="20" height="20" style="vertical-align: middle;"/> **通义千问** | ✅ | [获取 API Key](https://dashscope.console.aliyun.com) |
| <img src="../../../web/public/icons/openai.svg" width="20" height="20" style="vertical-align: middle;"/> **OpenAI (GPT)** | ✅ | [获取 API Key](https://platform.openai.com) |
| <img src="../../../web/public/icons/claude.svg" width="20" height="20" style="vertical-align: middle;"/> **Claude** | ✅ | [获取 API Key](https://console.anthropic.com) |
| <img src="../../../web/public/icons/gemini.svg" width="20" height="20" style="vertical-align: middle;"/> **Gemini** | ✅ | [获取 API Key](https://aistudio.google.com) |
| <img src="../../../web/public/icons/grok.svg" width="20" height="20" style="vertical-align: middle;"/> **Grok** | ✅ | [获取 API Key](https://console.x.ai) |
| <img src="../../../web/public/icons/kimi.svg" width="20" height="20" style="vertical-align: middle;"/> **Kimi** | ✅ | [获取 API Key](https://platform.moonshot.cn) |
| <img src="../../../web/public/icons/minimax.svg" width="20" height="20" style="vertical-align: middle;"/> **MiniMax** | ✅ | [获取 API Key](https://platform.minimaxi.com) |
### AI 模型 (x402 模式 — 无需 API Key)
15+ 模型通过 [Claw402](https://claw402.ai) 接入 — 只需一个 USDC 钱包
|----------|--------|-------------|
| **DeepSeek** | ✅ 已支持 | [获取 API Key](https://platform.deepseek.com) |
| **通义千问** | ✅ 已支持 | [获取 API Key](https://dashscope.console.aliyun.com) |
| **OpenAI (GPT)** | ✅ 已支持 | [获取 API Key](https://platform.openai.com) |
| **Claude** | ✅ 已支持 | [获取 API Key](https://console.anthropic.com) |
| **Gemini** | ✅ 已支持 | [获取 API Key](https://aistudio.google.com) |
| **Grok** | ✅ 已支持 | [获取 API Key](https://console.x.ai) |
| **Kimi** | ✅ 已支持 | [获取 API Key](https://platform.moonshot.cn) |
---
## 安装
## 截图
### Linux / macOS
### 竞赛模式 - 实时 AI 对战
![竞赛页面](../../../screenshots/competition-page.png)
*多 AI 排行榜,实时性能对比*
### 仪表板 - 市场图表视图
![仪表板市场图表](../../../screenshots/dashboard-market-chart.png)
*专业交易仪表板TradingView 风格图表*
### 策略工作室
![策略工作室](../../../screenshots/strategy-studio.png)
*多数据源策略配置与 AI 测试*
---
## 快速开始
### 一键安装 (本地/服务器)
**Linux / macOS:**
```bash
curl -fsSL https://raw.githubusercontent.com/NoFxAiOS/nofx/main/install.sh | bash
```
### Railway (云部署)
完成!打开浏览器访问 **http://127.0.0.1:3000**
### 一键云部署 (Railway)
一键部署到 Railway - 无需自己搭建服务器:
[![Deploy on Railway](https://railway.com/button.svg)](https://railway.com/deploy/nofx?referralCode=nofx)
### Docker
部署后Railway 会提供一个公网 URL 访问你的 NOFX 实例。
### Docker Compose (手动)
```bash
# 下载并启动
curl -O https://raw.githubusercontent.com/NoFxAiOS/nofx/main/docker-compose.prod.yml
docker compose -f docker-compose.prod.yml up -d
```
### Windows
安装 [Docker Desktop](https://www.docker.com/products/docker-desktop/),然后:
```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
```
### 从源码构建
访问 Web 界面: **http://127.0.0.1:3000**
```bash
# 前置条件: Go 1.21+, Node.js 18+, TA-Lib
# macOS: brew install ta-lib
# Ubuntu: sudo apt-get install libta-lib0-dev
git clone https://github.com/NoFxAiOS/nofx.git && cd nofx
go build -o nofx && ./nofx # 后端
cd web && npm install && npm run dev # 前端 (新终端)
# 管理命令
docker compose -f docker-compose.prod.yml logs -f # 查看日志
docker compose -f docker-compose.prod.yml restart # 重启
docker compose -f docker-compose.prod.yml down # 停止
docker compose -f docker-compose.prod.yml pull && docker compose -f docker-compose.prod.yml up -d # 更新
```
### 更新
### 保持更新
> **💡 更新频繁。** 每天运行以下命令以获取最新功能和修复:
```bash
curl -fsSL https://raw.githubusercontent.com/NoFxAiOS/nofx/main/install.sh | bash
```
此命令会拉取最新官方镜像并自动重启服务。
### 手动安装 (开发者)
#### 前置条件
- **Go 1.21+**
- **Node.js 18+**
- **TA-Lib** (技术指标库)
```bash
# 安装 TA-Lib
# macOS
brew install ta-lib
# Ubuntu/Debian
sudo apt-get install libta-lib0-dev
```
#### 安装步骤
```bash
# 1. 克隆仓库
git clone https://github.com/NoFxAiOS/nofx.git
cd nofx
# 2. 安装后端依赖
go mod download
# 3. 安装前端依赖
cd web
npm install
cd ..
# 4. 构建并启动后端
go build -o nofx
./nofx
# 5. 启动前端 (新终端)
cd web
npm run dev
```
访问 Web 界面: **http://127.0.0.1:3000**
---
## 配置
## Windows 安装
**新手模式**:首次使用的用户可以在注册时选择新手模式,系统会引导你逐步完成 AI、交易所和策略的配置。
### 方法一Docker Desktop推荐
**进阶模式**
1. **安装 Docker Desktop**
- 从 [docker.com/products/docker-desktop](https://www.docker.com/products/docker-desktop/) 下载
- 运行安装程序并重启电脑
- 启动 Docker Desktop 并等待就绪
1. **AI** — 添加 API Key 或配置 x402 钱包
2. **交易所** — 连接交易所 API 凭证
3. **策略** — 在策略工作室构建
4. **交易员** — 组合 AI + 交易所 + 策略
5. **交易** — 从仪表板启动
2. **运行 NOFX**
```powershell
# 打开 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
```
所有操作通过 Web 界面完成:**http://127.0.0.1:3000**
3. **访问**:在浏览器打开 **http://127.0.0.1:3000**
### 方法二WSL2适合开发
1. **安装 WSL2**
```powershell
# 以管理员身份打开 PowerShell
wsl --install
```
安装完成后重启电脑。
2. **从 Microsoft Store 安装 Ubuntu**
- 打开 Microsoft Store
- 搜索 "Ubuntu 22.04" 并安装
- 启动 Ubuntu 并设置用户名/密码
3. **在 WSL2 中安装依赖**
```bash
# 更新系统
sudo apt update && sudo apt upgrade -y
# 安装 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
# 安装 Node.js
curl -fsSL https://deb.nodesource.com/setup_18.x | sudo -E bash -
sudo apt-get install -y nodejs
# 安装 TA-Lib
sudo apt-get install -y libta-lib0-dev
# 安装 Git
sudo apt-get install -y git
```
4. **克隆并运行 NOFX**
```bash
git clone https://github.com/NoFxAiOS/nofx.git
cd nofx
# 构建并运行后端
go build -o nofx && ./nofx
# 在另一个终端运行前端
cd web && npm install && npm run dev
```
5. **访问**:在 Windows 浏览器打开 **http://127.0.0.1:3000**
### 方法三WSL2 + Docker两全其美
1. **安装 Docker Desktop 并启用 WSL2 后端**
- Docker Desktop 安装时勾选 "Use WSL 2 based engine"
- 在 Docker Desktop 设置 → Resources → WSL Integration 中启用你的 Linux 发行版
2. **在 WSL2 终端运行**
```bash
curl -fsSL https://raw.githubusercontent.com/NoFxAiOS/nofx/main/install.sh | bash
```
---
## 服务器部署
### 快速部署 (HTTP/IP 访问)
默认情况下,传输加密已**禁用**,可直接通过 IP 地址访问 NOFX
```bash
# 部署到你的服务器
curl -fsSL https://raw.githubusercontent.com/NoFxAiOS/nofx/main/install.sh | bash
```
通过 `http://你的服务器IP:3000` 访问 - 立即可用。
### 增强安全 (HTTPS)
如需增强安全性,在 `.env` 中启用传输加密:
```bash
TRANSPORT_ENCRYPTION=true
```
启用后,浏览器会使用 Web Crypto API 在传输前加密 API 密钥。此功能需要:
- `https://` - 任何有 SSL 证书的域名
- `http://localhost` - 本地开发
### Cloudflare 快速配置 HTTPS
1. **添加域名到 Cloudflare** (免费计划即可)
- 访问 [dash.cloudflare.com](https://dash.cloudflare.com)
- 添加域名并更新 DNS 服务器
2. **创建 DNS 记录**
- 类型: `A`
- 名称: `nofx` (或你的子域名)
- 内容: 你的服务器 IP
- 代理状态: **已代理** (橙色云朵)
3. **配置 SSL/TLS**
- 进入 SSL/TLS 设置
- 加密模式选择 **灵活**
```
用户 ──[HTTPS]──→ Cloudflare ──[HTTP]──→ 你的服务器:3000
```
4. **启用传输加密**
```bash
# 编辑 .env 并设置
TRANSPORT_ENCRYPTION=true
```
5. **完成!** 通过 `https://nofx.你的域名.com` 访问
---
## 初始配置 (Web 界面)
启动系统后,通过 Web 界面进行配置:
1. **配置 AI 模型** - 添加你的 AI API 密钥 (DeepSeek, OpenAI 等)
2. **配置交易所** - 设置交易所 API 凭证
3. **创建策略** - 在策略工作室配置交易策略
4. **创建交易员** - 组合 AI 模型 + 交易所 + 策略
5. **开始交易** - 启动你配置的交易员
所有配置都通过 Web 界面完成 - 无需编辑 JSON 文件。
---
## Web 界面功能
### 竞赛页面
- 实时 ROI 排行榜
- 多 AI 性能对比图表
- 实时盈亏追踪和排名
### 仪表板
- TradingView 风格 K 线图
- 实时持仓管理
- AI 决策日志与思维链推理
- 权益曲线追踪
### 策略工作室
- 币种来源配置 (静态列表、AI500 池、OI Top)
- 技术指标 (EMA, MACD, RSI, ATR, 成交量, OI, 资金费率)
- 风控设置 (杠杆、仓位限制、保证金使用率)
- AI 测试与实时提示词预览
---
## 常见问题
### TA-Lib 未找到
```bash
# macOS
brew install ta-lib
# Ubuntu
sudo apt-get install libta-lib0-dev
```
### AI API 超时
- 检查 API 密钥是否正确
- 检查网络连接
- 系统超时时间为 120 秒
### 前端无法连接后端
- 确保后端运行在 http://localhost:8080
- 检查端口是否被占用
---
## 文档
| | |
|:--|:--|
| [架构概览](../../architecture/README.md) | 系统设计和模块索引 |
| [策略模块](../../architecture/STRATEGY_MODULE.md) | 币种选择、AI 提示词、执行 |
| [常见问题](../../faq/README.md) | FAQ |
| [快速开始](../../getting-started/README.md) | 部署指南 |
| 文档 | 描述 |
|------|------|
| **[架构概览](../../architecture/README.zh-CN.md)** | 系统设计和模块索引 |
| **[策略模块](../../architecture/STRATEGY_MODULE.md)** | 币种选择、数据组装、AI 提示词、执行 |
| **[回测模块](../../architecture/BACKTEST_MODULE.md)** | 历史模拟、指标计算、断点续测 |
| **[辩论模块](../../architecture/DEBATE_MODULE.md)** | 多 AI 辩论、投票共识、自动执行 |
| **[常见问题](../../faq/README.md)** | FAQ |
| **[快速开始](../../getting-started/README.zh-CN.md)** | 部署指南 |
---
## 许可证
本项目采用 **GNU Affero General Public License v3.0 (AGPL-3.0)** 许可 - 详见 [LICENSE](../../../LICENSE) 文件。
---
## 贡献
查看 [贡献指南](../../../CONTRIBUTING.md) · [行为准则](../../../CODE_OF_CONDUCT.md) · [安全政策](../../../SECURITY.md)
欢迎贡献!查看:
- **[贡献指南](../../../CONTRIBUTING.md)** - 开发流程和 PR 流程
- **[行为准则](../../../CODE_OF_CONDUCT.md)** - 社区准则
- **[安全政策](../../../SECURITY.md)** - 报告漏洞
### 贡献者空投计划
---
所有贡献在 GitHub 上追踪。当 NOFX 产生收入时,贡献者将获得空投。
## 贡献者空投计划
所有贡献都在 GitHub 上追踪。当 NOFX 产生收入时,贡献者将根据其贡献获得空投。
**解决 [置顶 Issue](https://github.com/NoFxAiOS/nofx/issues) 的 PR 获得最高奖励!**
| 贡献类型 | 权重 |
|:-------------|:------:|
| 置顶 Issue PR | ★★★★★★ |
| 代码提交 (合并的 PR) | ★★★★★ |
| Bug 修复 | ★★★★ |
| 功能建议 | ★★★ |
| Bug 报告 | ★★ |
| 文档 | ★★ |
|------------------|:------:|
| **置顶 Issue PR** | ⭐⭐⭐⭐⭐⭐ |
| **代码提交** (合并的 PR) | ⭐⭐⭐⭐⭐ |
| **Bug 修复** | ⭐⭐⭐⭐ |
| **功能建议** | ⭐⭐⭐ |
| **Bug 报告** | ⭐⭐ |
| **文档** | ⭐⭐ |
---
## 链接
## 联系方式
| | |
|:--|:--|
| 官网 | [nofxai.com](https://nofxai.com) |
| 数据面板 | [nofxos.ai/dashboard](https://nofxos.ai/dashboard) |
| API 文档 | [nofxos.ai/api-docs](https://nofxos.ai/api-docs) |
| Telegram | [nofx_dev_community](https://t.me/nofx_dev_community) |
| Twitter | [@nofx_official](https://x.com/nofx_official) |
> **风险提示**: AI 自动交易存在重大风险。建议仅用于学习/研究或小额测试。
- **GitHub Issues**: [提交 Issue](https://github.com/NoFxAiOS/nofx/issues)
- **开发者社区**: [Telegram 群组](https://t.me/nofx_dev_community)
---
## License
[AGPL-3.0](../../../LICENSE)
## Star 历史
[![Star History Chart](https://api.star-history.com/svg?repos=NoFxAiOS/nofx&type=Date)](https://star-history.com/#NoFxAiOS/nofx&Date)

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

View File

@@ -2488,6 +2488,7 @@ KNOWN_ISSUES = [
3. **金融量化**
- Lo, Andrew W. "The Adaptive Markets Hypothesis." Journal of Portfolio Management, 2004.
- Bailey et al. "The Probability of Backtest Overfitting." Journal of Computational Finance, 2014.
### 13.3 代码示例索引

View File

@@ -1,137 +0,0 @@
# 📊 Token 估算分析与候选币种上限指南
> 版本v1.0 | 更新2026-03-27
> 适用:策略配置 · 模型选择 · 候选币种数量决策
---
## 目录
- [Token 估算公式](#-token-估算公式)
- [系统提示词的准确性分析](#-系统提示词的准确性分析)
- [典型配置下的安全币种数量](#-典型配置下的安全币种数量)
- [模型上限参考](#-模型上限参考)
- [MaxCandidateCoins 常量说明](#-maxcandidatecoins-常量说明)
---
## 📐 Token 估算公式
代码入口:`store/strategy.go``EstimateTokens()`
整体结构:
```
total = (staticTokens + N × perCoinTokens) × 1.15
```
其中 `1.15` 为 15% 安全边际。
### 静态部分(与候选币数量无关)
```
SystemPrompt = baseChars / 2zh或 / 4en
baseChars ≈ 3000zh/ 4000en+ 自定义提示段落长度
FixedOverhead = 200 tokens时间戳、账户信息、章节标题
RankingData = (OILimit × 60 + NetFlowLimit × 80 + PriceLimit × durations × 40) / 4
staticTokens = SystemPrompt + FixedOverhead + RankingData
≈ 1500 + 200 + 650 = 2350 tokens默认中文配置
```
### 每枚币的 Token 开销
```
# 每行指标额外字符数I
I = EnableEMA×20 + EnableMACD×30 + EnableRSI×15
+ EnableATR×15 + EnableBOLL×25 + EnableVolume×10
# 每枚币的市场数据 token
marketPerCoin = (T × K × (80 + I) + 100) / 4
↑ T=时间框架数 K=每TF K线数
↑ 100 = OI + 资金费率固定开销
# 每枚币的量化数据 token
quantPerCoin = (EnableQuantOI×300 + EnableQuantNetflow×300) / 4
perCoinTokens = marketPerCoin + quantPerCoin
```
### 反向公式:最大安全币数
```
budget = modelContextLimit × 0.80 / 1.15
maxSafeCoins = floor((budget - staticTokens) / perCoinTokens)
```
---
## 📊 典型配置下的安全币种数量
**基准131K 模型DeepSeek / Grok / Qwen**80% 警戒线
### 三种配置的 perCoinTokens
| 配置 | T | K | I | quantPerCoin | perCoinTokens |
| ------------------------------------------ | --- | --- | --- | ------------ | ------------- |
| **最小**单TF无指标无量化 | 1 | 10 | 0 | 0 | **225** |
| **默认**3TF仅VolumeQuantOI+Netflow | 3 | 20 | 10 | 600 | **1525** |
| **最大**4TF全部指标全量化 | 4 | 30 | 115 | 600 | **6025** |
### 各模型下的最大安全币数
| 模型上限 | 最小配置 | 默认配置 | 最大配置 |
| ------------------------------ | ------------ | ------------ | ----------- |
| 131KDeepSeek / Grok / Qwen | ≥10封顶 | ≥10封顶 | **14** |
| 128KOpenAI GPT-4 | ≥10封顶 | ≥10封顶 | **14** |
| 200KClaude | ≥10封顶 | ≥10封顶 | ≥10封顶 |
| 1MGemini / Minimax | ≥10封顶 | ≥10封顶 | ≥10封顶 |
---
## 🤖 模型上限参考
来源:`store/strategy.go``ModelContextLimits`
| 模型 | Context 上限 | 80% 警戒线 |
| -------- | ------------ | ---------- |
| deepseek | 131,072 | 104,858 |
| openai | 128,000 | 102,400 |
| claude | 200,000 | 160,000 |
| qwen | 131,072 | 104,858 |
| gemini | 1,000,000 | 800,000 |
| grok | 131,072 | 104,858 |
| kimi | 131,072 | 104,858 |
| minimax | 1,000,000 | 800,000 |
---
## 🔒 MaxCandidateCoins 常量说明
来源:`store/strategy.go` 第 14-20 行
```go
const (
MaxCandidateCoins = 10 // UI 硬限制:用户最多设定的候选币数量
MaxPositions = 3 // 最大同时持仓数
MaxTimeframes = 4 // 最大时间框架数
MinKlineCount = 10 // 最少 K 线数
MaxKlineCount = 30 // 最多 K 线数
)
```
### 为什么 MaxCandidateCoins = 10
- **默认配置**下 10 枚币约用 **~20,000 tokens**~15% of 131K完全安全
- **极端配置**4TF + 全指标10 枚币约用 **~72,000 tokens**~55% of 131K仍有充足余量
- 因此 10 是保守且安全的 UI 上限:在所有模型和配置组合下均不会触发 token 限制
### 建议使用范围
| 用户类型 | 建议配置 | 最大建议币数 |
| ------------------- | ----------------------- | ------------ |
| 新手 / 使用默认配置 | 3TF, K=20, 仅 Volume | 10-20 枚 |
| 进阶 / 启用部分指标 | 3TF, K=20, EMA+MACD+RSI | 10-15 枚 |
| 高级 / 全部指标 | 3-4TF, K=20-30, 全指标 | 5-10 枚 |

View File

@@ -1,5 +1,5 @@
// Package telemetry handles product telemetry
package telemetry
// Package experience handles product telemetry
package experience
import (
"bytes"
@@ -28,13 +28,13 @@ type Client struct {
}
type TradeEvent struct {
Exchange string
TradeType string
Symbol string
AmountUSD float64
Leverage int
UserID string
TraderID string
Exchange string
TradeType string
Symbol string
AmountUSD float64
Leverage int
UserID string
TraderID string
}
type AIUsageEvent struct {
@@ -42,7 +42,6 @@ type AIUsageEvent struct {
TraderID string
ModelProvider string // openai, deepseek, anthropic, etc.
ModelName string // gpt-4o, deepseek-chat, claude-3, etc.
Channel string // payment channel: "claw402" or "native"
InputTokens int
OutputTokens int
}
@@ -130,10 +129,10 @@ func sendTradeEvent(event TradeEvent) error {
"symbol": event.Symbol,
"amount_usd": event.AmountUSD,
"leverage": event.Leverage,
"installation_id": installationID, // For counting active installations
"user_id": event.UserID, // For counting active users
"trader_id": event.TraderID, // For counting active traders
"engagement_time_msec": 1, // Required by GA4
"installation_id": installationID, // For counting active installations
"user_id": event.UserID, // For counting active users
"trader_id": event.TraderID, // For counting active traders
"engagement_time_msec": 1, // Required by GA4
},
},
},
@@ -215,7 +214,6 @@ func TrackAIUsage(event AIUsageEvent) {
Params: map[string]interface{}{
"model_provider": event.ModelProvider,
"model_name": event.ModelName,
"channel": event.Channel,
"input_tokens": event.InputTokens,
"output_tokens": event.OutputTokens,
"total_tokens": event.InputTokens + event.OutputTokens,

41
go.mod
View File

@@ -5,32 +5,34 @@ go 1.25.3
require (
github.com/adshao/go-binance/v2 v2.8.9
github.com/agiledragon/gomonkey/v2 v2.13.0
github.com/bybit-exchange/bybit.go.api v0.0.0-20250727214011-c9347d6804d6
github.com/elliottech/lighter-go v0.0.0-20251104171447-78b9b55ebc48
github.com/ethereum/go-ethereum v1.16.7
github.com/gin-gonic/gin v1.11.0
github.com/go-telegram-bot-api/telegram-bot-api/v5 v5.5.1
github.com/golang-jwt/jwt/v5 v5.2.0
github.com/google/uuid v1.6.0
github.com/gorilla/websocket v1.5.3
github.com/joho/godotenv v1.5.1
github.com/lib/pq v1.10.9
github.com/pquerna/otp v1.4.0
github.com/rs/zerolog v1.34.0
github.com/sirupsen/logrus v1.9.3
github.com/sonirico/go-hyperliquid v0.26.0
github.com/stretchr/testify v1.11.1
golang.org/x/crypto v0.42.0
golang.org/x/net v0.43.0
gopkg.in/telebot.v3 v3.3.8
gorm.io/driver/postgres v1.6.0
gorm.io/driver/sqlite v1.6.0
gorm.io/gorm v1.31.1
modernc.org/sqlite v1.40.0
)
require (
filippo.io/edwards25519 v1.0.0-rc.1 // indirect
github.com/ProjectZKM/Ziren/crates/go-runtime/zkvm_runtime v0.0.0-20251001021608-1fe7b43fc4d6 // indirect
github.com/andres-erbsen/clock v0.0.0-20160526145045-9e14626cd129 // indirect
github.com/antihax/optional v1.0.0 // indirect
github.com/armon/go-radix v1.0.0 // indirect
github.com/bitly/go-simplejson v0.5.1 // indirect
github.com/bits-and-blooms/bitset v1.24.0 // indirect
github.com/blendle/zapdriver v1.3.1 // indirect
github.com/boombuler/barcode v1.0.1-0.20190219062509-6c824513bacc // indirect
github.com/bybit-exchange/bybit.go.api v0.0.0-20250727214011-c9347d6804d6 // indirect
github.com/bytedance/sonic v1.14.0 // indirect
github.com/bytedance/sonic/loader v0.3.0 // indirect
github.com/cloudwego/base64x v0.1.6 // indirect
@@ -42,22 +44,17 @@ require (
github.com/dustin/go-humanize v1.0.1 // indirect
github.com/elastic/go-sysinfo v1.15.4 // indirect
github.com/elastic/go-windows v1.0.2 // indirect
github.com/elliottech/lighter-go v0.0.0-20251104171447-78b9b55ebc48 // indirect
github.com/elliottech/poseidon_crypto v0.0.11 // indirect
github.com/ethereum/c-kzg-4844/v2 v2.1.5 // indirect
github.com/ethereum/go-verkle v0.2.2 // indirect
github.com/fatih/color v1.16.0 // indirect
github.com/gabriel-vasile/mimetype v1.4.8 // indirect
github.com/gagliardetto/binary v0.8.0 // indirect
github.com/gagliardetto/solana-go v1.14.0 // indirect
github.com/gagliardetto/treeout v0.1.4 // indirect
github.com/gateio/gateapi-go/v6 v6.104.3 // indirect
github.com/gin-contrib/sse v1.1.0 // indirect
github.com/go-playground/locales v0.14.1 // indirect
github.com/go-playground/universal-translator v0.18.1 // indirect
github.com/go-playground/validator/v10 v10.27.0 // indirect
github.com/goccy/go-json v0.10.4 // indirect
github.com/goccy/go-yaml v1.18.0 // indirect
github.com/gorilla/websocket v1.5.3 // indirect
github.com/holiman/uint256 v1.3.2 // indirect
github.com/jackc/pgpassfile v1.0.0 // indirect
github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 // indirect
@@ -68,20 +65,14 @@ require (
github.com/josharian/intern v1.0.0 // indirect
github.com/jpillora/backoff v1.0.0 // indirect
github.com/json-iterator/go v1.1.12 // indirect
github.com/klauspost/compress v1.16.0 // indirect
github.com/klauspost/cpuid/v2 v2.3.0 // indirect
github.com/leodido/go-urn v1.4.0 // indirect
github.com/lib/pq v1.10.9 // indirect
github.com/logrusorgru/aurora v2.0.3+incompatible // indirect
github.com/mailru/easyjson v0.9.1 // indirect
github.com/mattn/go-colorable v0.1.14 // indirect
github.com/mattn/go-isatty v0.0.20 // indirect
github.com/mattn/go-sqlite3 v1.14.32 // indirect
github.com/mitchellh/go-testing-interface v1.14.1 // indirect
github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd // indirect
github.com/modern-go/reflect2 v1.0.2 // indirect
github.com/mostynb/zstdpool-freelist v0.0.0-20201229113212-927304c0c3b1 // indirect
github.com/mr-tron/base58 v1.2.0 // indirect
github.com/ncruces/go-strftime v0.1.9 // indirect
github.com/pelletier/go-toml/v2 v2.2.4 // indirect
github.com/pkg/errors v0.9.1 // indirect
@@ -93,7 +84,6 @@ require (
github.com/shopspring/decimal v1.4.0 // indirect
github.com/sonirico/vago v0.10.0 // indirect
github.com/sonirico/vago/lol v0.0.0-20250901170347-2d1d82c510bd // indirect
github.com/streamingfast/logging v0.0.0-20230608130331-f22c91403091 // indirect
github.com/supranational/blst v0.3.16 // indirect
github.com/twitchyliquid64/golang-asm v0.15.1 // indirect
github.com/ugorji/go/codec v1.3.0 // indirect
@@ -103,27 +93,16 @@ require (
go.elastic.co/apm/module/apmzerolog/v2 v2.7.1 // indirect
go.elastic.co/apm/v2 v2.7.1 // indirect
go.elastic.co/fastjson v1.5.1 // indirect
go.mongodb.org/mongo-driver v1.12.2 // indirect
go.uber.org/atomic v1.7.0 // indirect
go.uber.org/mock v0.5.0 // indirect
go.uber.org/multierr v1.6.0 // indirect
go.uber.org/ratelimit v0.2.0 // indirect
go.uber.org/zap v1.21.0 // indirect
golang.org/x/arch v0.20.0 // indirect
golang.org/x/exp v0.0.0-20250620022241-b7579e27df2b // indirect
golang.org/x/mod v0.27.0 // indirect
golang.org/x/net v0.43.0 // indirect
golang.org/x/sync v0.17.0 // indirect
golang.org/x/sys v0.36.0 // indirect
golang.org/x/term v0.35.0 // indirect
golang.org/x/text v0.29.0 // indirect
golang.org/x/time v0.9.0 // indirect
golang.org/x/tools v0.36.0 // indirect
google.golang.org/protobuf v1.36.9 // indirect
gopkg.in/yaml.v3 v3.0.1 // indirect
gorm.io/driver/postgres v1.6.0 // indirect
gorm.io/driver/sqlite v1.6.0 // indirect
gorm.io/gorm v1.31.1 // indirect
howett.net/plist v1.0.1 // indirect
modernc.org/libc v1.66.10 // indirect
modernc.org/mathutil v1.7.1 // indirect

877
go.sum

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

View File

@@ -1,401 +0,0 @@
package kernel
import (
"encoding/json"
"fmt"
"nofx/logger"
"nofx/market"
"nofx/mcp"
"nofx/store"
"regexp"
"strings"
"time"
)
// ============================================================================
// Pre-compiled regular expressions (performance optimization)
// ============================================================================
var (
// Safe regex: precisely match ```json code blocks
reJSONFence = regexp.MustCompile(`(?is)` + "```json\\s*(\\[\\s*\\{.*?\\}\\s*\\])\\s*```")
reJSONArray = regexp.MustCompile(`(?is)\[\s*\{.*?\}\s*\]`)
reArrayHead = regexp.MustCompile(`^\[\s*\{`)
reArrayOpenSpace = regexp.MustCompile(`^\[\s+\{`)
reInvisibleRunes = regexp.MustCompile("[\u200B\u200C\u200D\uFEFF]")
// XML tag extraction (supports any characters in reasoning chain)
reReasoningTag = regexp.MustCompile(`(?s)<reasoning>(.*?)</reasoning>`)
reDecisionTag = regexp.MustCompile(`(?s)<decision>(.*?)</decision>`)
)
// ============================================================================
// Entry Functions - Main API
// ============================================================================
// GetFullDecision gets AI's complete trading decision (batch analysis of all coins and positions)
// Uses default strategy configuration - for production use GetFullDecisionWithStrategy with explicit config
func GetFullDecision(ctx *Context, mcpClient mcp.AIClient) (*FullDecision, error) {
defaultConfig := store.GetDefaultStrategyConfig("en")
engine := NewStrategyEngine(&defaultConfig)
return GetFullDecisionWithStrategy(ctx, mcpClient, engine, "")
}
// GetFullDecisionWithStrategy uses StrategyEngine to get AI decision (unified prompt generation)
func GetFullDecisionWithStrategy(ctx *Context, mcpClient mcp.AIClient, engine *StrategyEngine, variant string) (*FullDecision, error) {
if ctx == nil {
return nil, fmt.Errorf("context is nil")
}
if engine == nil {
defaultConfig := store.GetDefaultStrategyConfig("en")
engine = NewStrategyEngine(&defaultConfig)
}
// Clamp strategy limits to prevent token overflow
engineConfig := engine.GetConfig()
engineConfig.ClampLimits()
// Token estimation check — block if exceeding the specific model's context limit
estimate := engineConfig.EstimateTokens()
// Determine context limit for the specific model being used
contextLimit := 131072 // safe default (strictest common limit)
var providerName string
if embedder, ok := mcpClient.(mcp.ClientEmbedder); ok {
base := embedder.BaseClient()
providerName = base.Provider
contextLimit = store.GetContextLimitForClient(base.Provider, base.Model)
}
if estimate.Total > contextLimit {
logger.Errorf("🚫 Token estimate %d exceeds %s context limit %d — blocking analysis",
estimate.Total, providerName, contextLimit)
return nil, fmt.Errorf("estimated %d tokens exceeds model context limit of %d; reduce coins, timeframes, or K-line count",
estimate.Total, contextLimit)
}
if estimate.Total*100/contextLimit >= 80 {
logger.Infof("⚠️ Token estimate %d — approaching %s context limit %d",
estimate.Total, providerName, contextLimit)
}
// 1. Fetch market data using strategy config
if len(ctx.MarketDataMap) == 0 {
if err := fetchMarketDataWithStrategy(ctx, engine); err != nil {
return nil, fmt.Errorf("failed to fetch market data: %w", err)
}
}
// Ensure OITopDataMap is initialized
if ctx.OITopDataMap == nil {
ctx.OITopDataMap = make(map[string]*OITopData)
oiPositions, err := engine.nofxosClient.GetOITopPositions()
if err == nil {
for _, pos := range oiPositions {
ctx.OITopDataMap[pos.Symbol] = &OITopData{
Rank: pos.Rank,
OIDeltaPercent: pos.OIDeltaPercent,
OIDeltaValue: pos.OIDeltaValue,
PriceDeltaPercent: pos.PriceDeltaPercent,
}
}
}
}
// 2. Build System Prompt using strategy engine
riskConfig := engine.GetRiskControlConfig()
systemPrompt := engine.BuildSystemPrompt(ctx.Account.TotalEquity, variant)
// 3. Build User Prompt using strategy engine
userPrompt := engine.BuildUserPrompt(ctx)
// 4. Call AI API
aiCallStart := time.Now()
aiResponse, err := mcpClient.CallWithMessages(systemPrompt, userPrompt)
aiCallDuration := time.Since(aiCallStart)
if err != nil {
return nil, fmt.Errorf("AI API call failed: %w", err)
}
// 5. Parse AI response
decision, err := parseFullDecisionResponse(
aiResponse,
ctx.Account.TotalEquity,
riskConfig.BTCETHMaxLeverage,
riskConfig.AltcoinMaxLeverage,
riskConfig.BTCETHMaxPositionValueRatio,
riskConfig.AltcoinMaxPositionValueRatio,
)
if decision != nil {
decision.Timestamp = time.Now()
decision.SystemPrompt = systemPrompt
decision.UserPrompt = userPrompt
decision.AIRequestDurationMs = aiCallDuration.Milliseconds()
decision.RawResponse = aiResponse
}
if err != nil {
return decision, fmt.Errorf("failed to parse AI response: %w", err)
}
return decision, nil
}
// ============================================================================
// Market Data Fetching
// ============================================================================
// fetchMarketDataWithStrategy fetches market data using strategy config (multiple timeframes)
func fetchMarketDataWithStrategy(ctx *Context, engine *StrategyEngine) error {
config := engine.GetConfig()
ctx.MarketDataMap = make(map[string]*market.Data)
timeframes := config.Indicators.Klines.SelectedTimeframes
primaryTimeframe := config.Indicators.Klines.PrimaryTimeframe
klineCount := config.Indicators.Klines.PrimaryCount
// Compatible with old configuration
if len(timeframes) == 0 {
if primaryTimeframe != "" {
timeframes = append(timeframes, primaryTimeframe)
} else {
timeframes = append(timeframes, "3m")
}
if config.Indicators.Klines.LongerTimeframe != "" {
timeframes = append(timeframes, config.Indicators.Klines.LongerTimeframe)
}
}
if primaryTimeframe == "" {
primaryTimeframe = timeframes[0]
}
if klineCount <= 0 {
klineCount = 30
}
logger.Infof("📊 Strategy timeframes: %v, Primary: %s, Kline count: %d", timeframes, primaryTimeframe, klineCount)
// 1. First fetch data for position coins (must fetch)
for _, pos := range ctx.Positions {
data, err := market.GetWithTimeframes(pos.Symbol, timeframes, primaryTimeframe, klineCount)
if err != nil {
logger.Infof("⚠️ Failed to fetch market data for position %s: %v", pos.Symbol, err)
continue
}
ctx.MarketDataMap[pos.Symbol] = data
}
// 2. Fetch data for all candidate coins
positionSymbols := make(map[string]bool)
for _, pos := range ctx.Positions {
positionSymbols[pos.Symbol] = true
}
const minOIThresholdMillions = 15.0 // 15M USD minimum open interest value
for _, coin := range ctx.CandidateCoins {
if _, exists := ctx.MarketDataMap[coin.Symbol]; exists {
continue
}
data, err := market.GetWithTimeframes(coin.Symbol, timeframes, primaryTimeframe, klineCount)
if err != nil {
logger.Infof("⚠️ Failed to fetch market data for %s: %v", coin.Symbol, err)
continue
}
// Liquidity filter (skip for xyz dex assets - they don't have OI data from Binance)
isExistingPosition := positionSymbols[coin.Symbol]
isXyzAsset := market.IsXyzDexAsset(coin.Symbol)
if !isExistingPosition && !isXyzAsset && data.OpenInterest != nil && data.CurrentPrice > 0 {
oiValue := data.OpenInterest.Latest * data.CurrentPrice
oiValueInMillions := oiValue / 1_000_000
if oiValueInMillions < minOIThresholdMillions {
logger.Infof("⚠️ %s OI value too low (%.2fM USD < %.1fM), skipping coin",
coin.Symbol, oiValueInMillions, minOIThresholdMillions)
continue
}
}
ctx.MarketDataMap[coin.Symbol] = data
}
logger.Infof("📊 Successfully fetched multi-timeframe market data for %d coins", len(ctx.MarketDataMap))
return nil
}
// ============================================================================
// AI Response Parsing
// ============================================================================
func parseFullDecisionResponse(aiResponse string, accountEquity float64, btcEthLeverage, altcoinLeverage int, btcEthPosRatio, altcoinPosRatio float64) (*FullDecision, error) {
cotTrace := extractCoTTrace(aiResponse)
decisions, err := extractDecisions(aiResponse)
if err != nil {
return &FullDecision{
CoTTrace: cotTrace,
Decisions: []Decision{},
}, fmt.Errorf("failed to extract decisions: %w", err)
}
if err := validateDecisions(decisions, accountEquity, btcEthLeverage, altcoinLeverage, btcEthPosRatio, altcoinPosRatio); err != nil {
return &FullDecision{
CoTTrace: cotTrace,
Decisions: decisions,
}, fmt.Errorf("decision validation failed: %w", err)
}
return &FullDecision{
CoTTrace: cotTrace,
Decisions: decisions,
}, nil
}
func extractCoTTrace(response string) string {
if match := reReasoningTag.FindStringSubmatch(response); match != nil && len(match) > 1 {
logger.Infof("✓ Extracted reasoning chain using <reasoning> tag")
return strings.TrimSpace(match[1])
}
if decisionIdx := strings.Index(response, "<decision>"); decisionIdx > 0 {
logger.Infof("✓ Extracted content before <decision> tag as reasoning chain")
return strings.TrimSpace(response[:decisionIdx])
}
jsonStart := strings.Index(response, "[")
if jsonStart > 0 {
logger.Infof("⚠️ Extracted reasoning chain using old format ([ character separator)")
return strings.TrimSpace(response[:jsonStart])
}
return strings.TrimSpace(response)
}
func extractDecisions(response string) ([]Decision, error) {
s := removeInvisibleRunes(response)
s = strings.TrimSpace(s)
s = fixMissingQuotes(s)
var jsonPart string
if match := reDecisionTag.FindStringSubmatch(s); match != nil && len(match) > 1 {
jsonPart = strings.TrimSpace(match[1])
logger.Infof("✓ Extracted JSON using <decision> tag")
} else {
jsonPart = s
logger.Infof("⚠️ <decision> tag not found, searching JSON in full text")
}
jsonPart = fixMissingQuotes(jsonPart)
if m := reJSONFence.FindStringSubmatch(jsonPart); m != nil && len(m) > 1 {
jsonContent := strings.TrimSpace(m[1])
jsonContent = compactArrayOpen(jsonContent)
jsonContent = fixMissingQuotes(jsonContent)
if err := validateJSONFormat(jsonContent); err != nil {
return nil, fmt.Errorf("JSON format validation failed: %w\nJSON content: %s\nFull response:\n%s", err, jsonContent, response)
}
var decisions []Decision
if err := json.Unmarshal([]byte(jsonContent), &decisions); err != nil {
return nil, fmt.Errorf("JSON parsing failed: %w\nJSON content: %s", err, jsonContent)
}
return decisions, nil
}
jsonContent := strings.TrimSpace(reJSONArray.FindString(jsonPart))
if jsonContent == "" {
logger.Infof("⚠️ [SafeFallback] AI didn't output JSON decision, entering safe wait mode")
cotSummary := jsonPart
if len(cotSummary) > 240 {
cotSummary = cotSummary[:240] + "..."
}
fallbackDecision := Decision{
Symbol: "ALL",
Action: "wait",
Reasoning: fmt.Sprintf("Model didn't output structured JSON decision, entering safe wait; summary: %s", cotSummary),
}
return []Decision{fallbackDecision}, nil
}
jsonContent = compactArrayOpen(jsonContent)
jsonContent = fixMissingQuotes(jsonContent)
if err := validateJSONFormat(jsonContent); err != nil {
return nil, fmt.Errorf("JSON format validation failed: %w\nJSON content: %s\nFull response:\n%s", err, jsonContent, response)
}
var decisions []Decision
if err := json.Unmarshal([]byte(jsonContent), &decisions); err != nil {
return nil, fmt.Errorf("JSON parsing failed: %w\nJSON content: %s", err, jsonContent)
}
return decisions, nil
}
func fixMissingQuotes(jsonStr string) string {
jsonStr = strings.ReplaceAll(jsonStr, "\u201c", "\"")
jsonStr = strings.ReplaceAll(jsonStr, "\u201d", "\"")
jsonStr = strings.ReplaceAll(jsonStr, "\u2018", "'")
jsonStr = strings.ReplaceAll(jsonStr, "\u2019", "'")
jsonStr = strings.ReplaceAll(jsonStr, "", "[")
jsonStr = strings.ReplaceAll(jsonStr, "", "]")
jsonStr = strings.ReplaceAll(jsonStr, "", "{")
jsonStr = strings.ReplaceAll(jsonStr, "", "}")
jsonStr = strings.ReplaceAll(jsonStr, "", ":")
jsonStr = strings.ReplaceAll(jsonStr, "", ",")
jsonStr = strings.ReplaceAll(jsonStr, "【", "[")
jsonStr = strings.ReplaceAll(jsonStr, "】", "]")
jsonStr = strings.ReplaceAll(jsonStr, "", "[")
jsonStr = strings.ReplaceAll(jsonStr, "", "]")
jsonStr = strings.ReplaceAll(jsonStr, "、", ",")
jsonStr = strings.ReplaceAll(jsonStr, " ", " ")
return jsonStr
}
func validateJSONFormat(jsonStr string) error {
trimmed := strings.TrimSpace(jsonStr)
if !reArrayHead.MatchString(trimmed) {
if strings.HasPrefix(trimmed, "[") && !strings.Contains(trimmed[:min(20, len(trimmed))], "{") {
return fmt.Errorf("not a valid decision array (must contain objects {}), actual content: %s", trimmed[:min(50, len(trimmed))])
}
return fmt.Errorf("JSON must start with [{ (whitespace allowed), actual: %s", trimmed[:min(20, len(trimmed))])
}
if strings.Contains(jsonStr, "~") {
return fmt.Errorf("JSON cannot contain range symbol ~, all numbers must be precise single values")
}
for i := 0; i < len(jsonStr)-4; i++ {
if jsonStr[i] >= '0' && jsonStr[i] <= '9' &&
jsonStr[i+1] == ',' &&
jsonStr[i+2] >= '0' && jsonStr[i+2] <= '9' &&
jsonStr[i+3] >= '0' && jsonStr[i+3] <= '9' &&
jsonStr[i+4] >= '0' && jsonStr[i+4] <= '9' {
return fmt.Errorf("JSON numbers cannot contain thousand separator comma, found: %s", jsonStr[i:min(i+10, len(jsonStr))])
}
}
return nil
}
func min(a, b int) int {
if a < b {
return a
}
return b
}
func removeInvisibleRunes(s string) string {
return reInvisibleRunes.ReplaceAllString(s, "")
}
func compactArrayOpen(s string) string {
return reArrayOpenSpace.ReplaceAllString(strings.TrimSpace(s), "[{")
}

View File

@@ -1,121 +0,0 @@
package kernel
import (
"fmt"
"nofx/logger"
)
// ============================================================================
// Decision Validation
// ============================================================================
func validateDecisions(decisions []Decision, accountEquity float64, btcEthLeverage, altcoinLeverage int, btcEthPosRatio, altcoinPosRatio float64) error {
for i := range decisions {
if err := validateDecision(&decisions[i], accountEquity, btcEthLeverage, altcoinLeverage, btcEthPosRatio, altcoinPosRatio); err != nil {
return fmt.Errorf("decision #%d validation failed: %w", i+1, err)
}
}
return nil
}
func validateDecision(d *Decision, accountEquity float64, btcEthLeverage, altcoinLeverage int, btcEthPosRatio, altcoinPosRatio float64) error {
validActions := map[string]bool{
"open_long": true,
"open_short": true,
"close_long": true,
"close_short": true,
"hold": true,
"wait": true,
}
if !validActions[d.Action] {
return fmt.Errorf("invalid action: %s", d.Action)
}
if d.Action == "open_long" || d.Action == "open_short" {
maxLeverage := altcoinLeverage
posRatio := altcoinPosRatio
maxPositionValue := accountEquity * posRatio
if d.Symbol == "BTCUSDT" || d.Symbol == "ETHUSDT" {
maxLeverage = btcEthLeverage
posRatio = btcEthPosRatio
maxPositionValue = accountEquity * posRatio
}
if d.Leverage <= 0 {
return fmt.Errorf("leverage must be greater than 0: %d", d.Leverage)
}
if d.Leverage > maxLeverage {
logger.Infof("⚠️ [Leverage Fallback] %s leverage exceeded (%dx > %dx), auto-adjusting to limit %dx",
d.Symbol, d.Leverage, maxLeverage, maxLeverage)
d.Leverage = maxLeverage
}
if d.PositionSizeUSD <= 0 {
return fmt.Errorf("position size must be greater than 0: %.2f", d.PositionSizeUSD)
}
const minPositionSizeGeneral = 12.0
const minPositionSizeBTCETH = 60.0
if d.Symbol == "BTCUSDT" || d.Symbol == "ETHUSDT" {
if d.PositionSizeUSD < minPositionSizeBTCETH {
return fmt.Errorf("%s opening amount too small (%.2f USDT), must be ≥%.2f USDT", d.Symbol, d.PositionSizeUSD, minPositionSizeBTCETH)
}
} else {
if d.PositionSizeUSD < minPositionSizeGeneral {
return fmt.Errorf("opening amount too small (%.2f USDT), must be ≥%.2f USDT", d.PositionSizeUSD, minPositionSizeGeneral)
}
}
tolerance := maxPositionValue * 0.01
if d.PositionSizeUSD > maxPositionValue+tolerance {
if d.Symbol == "BTCUSDT" || d.Symbol == "ETHUSDT" {
return fmt.Errorf("BTC/ETH single coin position value cannot exceed %.0f USDT (%.1fx account equity), actual: %.0f", maxPositionValue, posRatio, d.PositionSizeUSD)
} else {
return fmt.Errorf("altcoin single coin position value cannot exceed %.0f USDT (%.1fx account equity), actual: %.0f", maxPositionValue, posRatio, d.PositionSizeUSD)
}
}
if d.StopLoss <= 0 || d.TakeProfit <= 0 {
return fmt.Errorf("stop loss and take profit must be greater than 0")
}
if d.Action == "open_long" {
if d.StopLoss >= d.TakeProfit {
return fmt.Errorf("for long positions, stop loss price must be less than take profit price")
}
} else {
if d.StopLoss <= d.TakeProfit {
return fmt.Errorf("for short positions, stop loss price must be greater than take profit price")
}
}
var entryPrice float64
if d.Action == "open_long" {
entryPrice = d.StopLoss + (d.TakeProfit-d.StopLoss)*0.2
} else {
entryPrice = d.StopLoss - (d.StopLoss-d.TakeProfit)*0.2
}
var riskPercent, rewardPercent, riskRewardRatio float64
if d.Action == "open_long" {
riskPercent = (entryPrice - d.StopLoss) / entryPrice * 100
rewardPercent = (d.TakeProfit - entryPrice) / entryPrice * 100
if riskPercent > 0 {
riskRewardRatio = rewardPercent / riskPercent
}
} else {
riskPercent = (d.StopLoss - entryPrice) / entryPrice * 100
rewardPercent = (entryPrice - d.TakeProfit) / entryPrice * 100
if riskPercent > 0 {
riskRewardRatio = rewardPercent / riskPercent
}
}
if riskRewardRatio < 3.0 {
return fmt.Errorf("risk/reward ratio too low (%.2f:1), must be ≥3.0:1 [risk: %.2f%% reward: %.2f%%] [stop loss: %.2f take profit: %.2f]",
riskRewardRatio, riskPercent, rewardPercent, d.StopLoss, d.TakeProfit)
}
}
return nil
}

View File

@@ -1,779 +0,0 @@
package kernel
import (
"fmt"
"nofx/market"
"nofx/provider/nofxos"
"nofx/store"
"strings"
"time"
)
// ============================================================================
// Prompt Building - System Prompt
// ============================================================================
// BuildSystemPrompt builds System Prompt according to strategy configuration
func (e *StrategyEngine) BuildSystemPrompt(accountEquity float64, variant string) string {
var sb strings.Builder
riskControl := e.config.RiskControl
promptSections := e.config.PromptSections
// 0. Data Dictionary & Schema (ensure AI understands all fields)
lang := e.GetLanguage()
schemaPrompt := GetSchemaPrompt(lang)
sb.WriteString(schemaPrompt)
sb.WriteString("\n\n")
sb.WriteString("---\n\n")
// 1. Role definition (editable)
if promptSections.RoleDefinition != "" {
sb.WriteString(promptSections.RoleDefinition)
sb.WriteString("\n\n")
} else {
sb.WriteString("# You are a professional cryptocurrency trading AI\n\n")
sb.WriteString("Your task is to make trading decisions based on provided market data.\n\n")
}
// 2. Trading mode variant
switch strings.ToLower(strings.TrimSpace(variant)) {
case "aggressive":
sb.WriteString("## Mode: Aggressive\n- Prioritize capturing trend breakouts, can build positions in batches when confidence ≥ 70\n- Allow higher positions, but must strictly set stop-loss and explain risk-reward ratio\n\n")
case "conservative":
sb.WriteString("## Mode: Conservative\n- Only open positions when multiple signals resonate\n- Prioritize cash preservation, must pause for multiple periods after consecutive losses\n\n")
case "scalping":
sb.WriteString("## Mode: Scalping\n- Focus on short-term momentum, smaller profit targets but require quick action\n- If price doesn't move as expected within two bars, immediately reduce position or stop-loss\n\n")
}
// 3. Hard constraints (risk control)
btcEthPosValueRatio := riskControl.BTCETHMaxPositionValueRatio
if btcEthPosValueRatio <= 0 {
btcEthPosValueRatio = 5.0
}
altcoinPosValueRatio := riskControl.AltcoinMaxPositionValueRatio
if altcoinPosValueRatio <= 0 {
altcoinPosValueRatio = 1.0
}
sb.WriteString("# Hard Constraints (Risk Control)\n\n")
sb.WriteString("## CODE ENFORCED (Backend validation, cannot be bypassed):\n")
sb.WriteString(fmt.Sprintf("- Max Positions: %d coins simultaneously\n", riskControl.MaxPositions))
sb.WriteString(fmt.Sprintf("- Position Value Limit (Altcoins): max %.0f USDT (= equity %.0f × %.1fx)\n",
accountEquity*altcoinPosValueRatio, accountEquity, altcoinPosValueRatio))
sb.WriteString(fmt.Sprintf("- Position Value Limit (BTC/ETH): max %.0f USDT (= equity %.0f × %.1fx)\n",
accountEquity*btcEthPosValueRatio, accountEquity, btcEthPosValueRatio))
sb.WriteString(fmt.Sprintf("- Max Margin Usage: ≤%.0f%%\n", riskControl.MaxMarginUsage*100))
sb.WriteString(fmt.Sprintf("- Min Position Size: ≥%.0f USDT\n\n", riskControl.MinPositionSize))
sb.WriteString("## AI GUIDED (Recommended, you should follow):\n")
sb.WriteString(fmt.Sprintf("- Trading Leverage: Altcoins max %dx | BTC/ETH max %dx\n",
riskControl.AltcoinMaxLeverage, riskControl.BTCETHMaxLeverage))
sb.WriteString(fmt.Sprintf("- Risk-Reward Ratio: ≥1:%.1f (take_profit / stop_loss)\n", riskControl.MinRiskRewardRatio))
sb.WriteString(fmt.Sprintf("- Min Confidence: ≥%d to open position\n\n", riskControl.MinConfidence))
// Position sizing guidance
sb.WriteString("## Position Sizing Guidance\n")
sb.WriteString("Calculate `position_size_usd` based on your confidence and the Position Value Limits above:\n")
sb.WriteString("- High confidence (≥85): Use 80-100%% of max position value limit\n")
sb.WriteString("- Medium confidence (70-84): Use 50-80%% of max position value limit\n")
sb.WriteString("- Low confidence (60-69): Use 30-50%% of max position value limit\n")
sb.WriteString(fmt.Sprintf("- Example: With equity %.0f and BTC/ETH ratio %.1fx, max is %.0f USDT\n",
accountEquity, btcEthPosValueRatio, accountEquity*btcEthPosValueRatio))
sb.WriteString("- **DO NOT** just use available_balance as position_size_usd. Use the Position Value Limits!\n\n")
// 4. Trading frequency (editable)
if promptSections.TradingFrequency != "" {
sb.WriteString(promptSections.TradingFrequency)
sb.WriteString("\n\n")
} else {
sb.WriteString("# ⏱️ Trading Frequency Awareness\n\n")
sb.WriteString("- Excellent traders: 2-4 trades/day ≈ 0.1-0.2 trades/hour\n")
sb.WriteString("- >2 trades/hour = Overtrading\n")
sb.WriteString("- Single position hold time ≥ 30-60 minutes\n")
sb.WriteString("If you find yourself trading every period → standards too low; if closing positions < 30 minutes → too impatient.\n\n")
}
// 5. Entry standards (editable)
if promptSections.EntryStandards != "" {
sb.WriteString(promptSections.EntryStandards)
sb.WriteString("\n\nYou have the following indicator data:\n")
e.writeAvailableIndicators(&sb)
sb.WriteString(fmt.Sprintf("\n**Confidence ≥ %d** required to open positions.\n\n", riskControl.MinConfidence))
} else {
sb.WriteString("# 🎯 Entry Standards (Strict)\n\n")
sb.WriteString("Only open positions when multiple signals resonate. You have:\n")
e.writeAvailableIndicators(&sb)
sb.WriteString(fmt.Sprintf("\nFeel free to use any effective analysis method, but **confidence ≥ %d** required to open positions; avoid low-quality behaviors such as single indicators, contradictory signals, sideways consolidation, reopening immediately after closing, etc.\n\n", riskControl.MinConfidence))
}
// 6. Decision process (editable)
if promptSections.DecisionProcess != "" {
sb.WriteString(promptSections.DecisionProcess)
sb.WriteString("\n\n")
} else {
sb.WriteString("# 📋 Decision Process\n\n")
sb.WriteString("1. Check positions → Should we take profit/stop-loss\n")
sb.WriteString("2. Scan candidate coins + multi-timeframe → Are there strong signals\n")
sb.WriteString("3. Write chain of thought first, then output structured JSON\n\n")
}
// 7. Output format
sb.WriteString("# Output Format (Strictly Follow)\n\n")
sb.WriteString("**Must use XML tags <reasoning> and <decision> to separate chain of thought and decision JSON, avoiding parsing errors**\n\n")
sb.WriteString("## Format Requirements\n\n")
sb.WriteString("<reasoning>\n")
sb.WriteString("Your chain of thought analysis...\n")
sb.WriteString("- Briefly analyze your thinking process \n")
sb.WriteString("</reasoning>\n\n")
sb.WriteString("<decision>\n")
sb.WriteString("Step 2: JSON decision array\n\n")
sb.WriteString("```json\n[\n")
// Use the actual configured position value ratio for BTC/ETH in the example
examplePositionSize := accountEquity * btcEthPosValueRatio
sb.WriteString(fmt.Sprintf(" {\"symbol\": \"BTCUSDT\", \"action\": \"open_short\", \"leverage\": %d, \"position_size_usd\": %.0f, \"stop_loss\": 97000, \"take_profit\": 91000, \"confidence\": 85, \"risk_usd\": 300},\n",
riskControl.BTCETHMaxLeverage, examplePositionSize))
sb.WriteString(" {\"symbol\": \"ETHUSDT\", \"action\": \"close_long\"}\n")
sb.WriteString("]\n```\n")
sb.WriteString("</decision>\n\n")
sb.WriteString("## Field Description\n\n")
sb.WriteString("- `action`: open_long | open_short | close_long | close_short | hold | wait\n")
sb.WriteString(fmt.Sprintf("- `confidence`: 0-100 (opening recommended ≥ %d)\n", riskControl.MinConfidence))
sb.WriteString("- Required when opening: leverage, position_size_usd, stop_loss, take_profit, confidence, risk_usd\n")
sb.WriteString("- **IMPORTANT**: All numeric values must be calculated numbers, NOT formulas/expressions (e.g., use `27.76` not `3000 * 0.01`)\n\n")
// 8. Custom Prompt
if e.config.CustomPrompt != "" {
sb.WriteString("# 📌 Personalized Trading Strategy\n\n")
sb.WriteString(e.config.CustomPrompt)
sb.WriteString("\n\n")
sb.WriteString("Note: The above personalized strategy is a supplement to the basic rules and cannot violate the basic risk control principles.\n")
}
return sb.String()
}
func (e *StrategyEngine) writeAvailableIndicators(sb *strings.Builder) {
indicators := e.config.Indicators
kline := indicators.Klines
sb.WriteString(fmt.Sprintf("- %s price series", kline.PrimaryTimeframe))
if kline.EnableMultiTimeframe {
sb.WriteString(fmt.Sprintf(" + %s K-line series\n", kline.LongerTimeframe))
} else {
sb.WriteString("\n")
}
if indicators.EnableEMA {
sb.WriteString("- EMA indicators")
if len(indicators.EMAPeriods) > 0 {
sb.WriteString(fmt.Sprintf(" (periods: %v)", indicators.EMAPeriods))
}
sb.WriteString("\n")
}
if indicators.EnableMACD {
sb.WriteString("- MACD indicators\n")
}
if indicators.EnableRSI {
sb.WriteString("- RSI indicators")
if len(indicators.RSIPeriods) > 0 {
sb.WriteString(fmt.Sprintf(" (periods: %v)", indicators.RSIPeriods))
}
sb.WriteString("\n")
}
if indicators.EnableATR {
sb.WriteString("- ATR indicators")
if len(indicators.ATRPeriods) > 0 {
sb.WriteString(fmt.Sprintf(" (periods: %v)", indicators.ATRPeriods))
}
sb.WriteString("\n")
}
if indicators.EnableBOLL {
sb.WriteString("- Bollinger Bands (BOLL) - Upper/Middle/Lower bands")
if len(indicators.BOLLPeriods) > 0 {
sb.WriteString(fmt.Sprintf(" (periods: %v)", indicators.BOLLPeriods))
}
sb.WriteString("\n")
}
if indicators.EnableVolume {
sb.WriteString("- Volume data\n")
}
if indicators.EnableOI {
sb.WriteString("- Open Interest (OI) data\n")
}
if indicators.EnableFundingRate {
sb.WriteString("- Funding rate\n")
}
if len(e.config.CoinSource.StaticCoins) > 0 || e.config.CoinSource.UseAI500 || e.config.CoinSource.UseOITop {
sb.WriteString("- AI500 / OI_Top filter tags (if available)\n")
}
if indicators.EnableQuantData {
sb.WriteString("- Quantitative data (institutional/retail fund flow, position changes, multi-period price changes)\n")
}
}
// ============================================================================
// Prompt Building - User Prompt
// ============================================================================
// BuildUserPrompt builds User Prompt based on strategy configuration
func (e *StrategyEngine) BuildUserPrompt(ctx *Context) string {
var sb strings.Builder
// System status
sb.WriteString(fmt.Sprintf("Time: %s | Period: #%d | Runtime: %d minutes\n\n",
ctx.CurrentTime, ctx.CallCount, ctx.RuntimeMinutes))
// BTC market
if btcData, hasBTC := ctx.MarketDataMap["BTCUSDT"]; hasBTC {
sb.WriteString(fmt.Sprintf("BTC: %.2f (1h: %+.2f%%, 4h: %+.2f%%) | MACD: %.4f | RSI: %.2f\n\n",
btcData.CurrentPrice, btcData.PriceChange1h, btcData.PriceChange4h,
btcData.CurrentMACD, btcData.CurrentRSI7))
}
// Account information
sb.WriteString(fmt.Sprintf("Account: Equity %.2f | Balance %.2f (%.1f%%) | PnL %+.2f%% | Margin %.1f%% | Positions %d\n\n",
ctx.Account.TotalEquity,
ctx.Account.AvailableBalance,
(ctx.Account.AvailableBalance/ctx.Account.TotalEquity)*100,
ctx.Account.TotalPnLPct,
ctx.Account.MarginUsedPct,
ctx.Account.PositionCount))
// Recently completed orders (placed before positions to ensure visibility)
if len(ctx.RecentOrders) > 0 {
sb.WriteString("## Recent Completed Trades\n")
for i, order := range ctx.RecentOrders {
resultStr := "Profit"
if order.RealizedPnL < 0 {
resultStr = "Loss"
}
sb.WriteString(fmt.Sprintf("%d. %s %s | Entry %.4f Exit %.4f | %s: %+.2f USDT (%+.2f%%) | %s→%s (%s)\n",
i+1, order.Symbol, order.Side,
order.EntryPrice, order.ExitPrice,
resultStr, order.RealizedPnL, order.PnLPct,
order.EntryTime, order.ExitTime, order.HoldDuration))
}
sb.WriteString("\n")
}
// Historical trading statistics (helps AI understand past performance)
if ctx.TradingStats != nil && ctx.TradingStats.TotalTrades > 0 {
// Get language from strategy config
lang := e.GetLanguage()
// Win/Loss ratio
var winLossRatio float64
if ctx.TradingStats.AvgLoss > 0 {
winLossRatio = ctx.TradingStats.AvgWin / ctx.TradingStats.AvgLoss
}
if lang == LangChinese {
sb.WriteString("## 历史交易统计\n")
sb.WriteString(fmt.Sprintf("总交易: %d 笔 | 盈利因子: %.2f | 夏普比率: %.2f | 盈亏比: %.2f\n",
ctx.TradingStats.TotalTrades,
ctx.TradingStats.ProfitFactor,
ctx.TradingStats.SharpeRatio,
winLossRatio))
sb.WriteString(fmt.Sprintf("总盈亏: %+.2f USDT | 平均盈利: +%.2f | 平均亏损: -%.2f | 最大回撤: %.1f%%\n",
ctx.TradingStats.TotalPnL,
ctx.TradingStats.AvgWin,
ctx.TradingStats.AvgLoss,
ctx.TradingStats.MaxDrawdownPct))
// Performance hints based on profit factor, sharpe, and drawdown
if ctx.TradingStats.ProfitFactor >= 1.5 && ctx.TradingStats.SharpeRatio >= 1 {
sb.WriteString("表现: 良好 - 保持当前策略\n")
} else if ctx.TradingStats.ProfitFactor < 1 {
sb.WriteString("表现: 需改进 - 提高盈亏比,优化止盈止损\n")
} else if ctx.TradingStats.MaxDrawdownPct > 30 {
sb.WriteString("表现: 风险偏高 - 减少仓位,控制回撤\n")
} else {
sb.WriteString("表现: 正常 - 有优化空间\n")
}
} else {
sb.WriteString("## Historical Trading Statistics\n")
sb.WriteString(fmt.Sprintf("Total Trades: %d | Profit Factor: %.2f | Sharpe: %.2f | Win/Loss Ratio: %.2f\n",
ctx.TradingStats.TotalTrades,
ctx.TradingStats.ProfitFactor,
ctx.TradingStats.SharpeRatio,
winLossRatio))
sb.WriteString(fmt.Sprintf("Total PnL: %+.2f USDT | Avg Win: +%.2f | Avg Loss: -%.2f | Max Drawdown: %.1f%%\n",
ctx.TradingStats.TotalPnL,
ctx.TradingStats.AvgWin,
ctx.TradingStats.AvgLoss,
ctx.TradingStats.MaxDrawdownPct))
// Performance hints based on profit factor, sharpe, and drawdown
if ctx.TradingStats.ProfitFactor >= 1.5 && ctx.TradingStats.SharpeRatio >= 1 {
sb.WriteString("Performance: GOOD - maintain current strategy\n")
} else if ctx.TradingStats.ProfitFactor < 1 {
sb.WriteString("Performance: NEEDS IMPROVEMENT - improve win/loss ratio, optimize TP/SL\n")
} else if ctx.TradingStats.MaxDrawdownPct > 30 {
sb.WriteString("Performance: HIGH RISK - reduce position size, control drawdown\n")
} else {
sb.WriteString("Performance: NORMAL - room for optimization\n")
}
}
sb.WriteString("\n")
}
// Position information
if len(ctx.Positions) > 0 {
sb.WriteString("## Current Positions\n")
for i, pos := range ctx.Positions {
sb.WriteString(e.formatPositionInfo(i+1, pos, ctx))
}
} else {
sb.WriteString("Current Positions: None\n\n")
}
// Candidate coins (exclude coins already in positions to avoid duplicate data)
positionSymbols := make(map[string]bool)
for _, pos := range ctx.Positions {
// Normalize symbol to handle both "ETH" and "ETHUSDT" formats
normalizedSymbol := market.Normalize(pos.Symbol)
positionSymbols[normalizedSymbol] = true
}
sb.WriteString(fmt.Sprintf("## Candidate Coins (%d coins)\n\n", len(ctx.MarketDataMap)))
displayedCount := 0
for _, coin := range ctx.CandidateCoins {
// Skip if this coin is already a position (data already shown in positions section)
normalizedCoinSymbol := market.Normalize(coin.Symbol)
if positionSymbols[normalizedCoinSymbol] {
continue
}
marketData, hasData := ctx.MarketDataMap[coin.Symbol]
if !hasData {
continue
}
displayedCount++
sourceTags := e.formatCoinSourceTag(coin.Sources)
sb.WriteString(fmt.Sprintf("### %d. %s%s\n\n", displayedCount, coin.Symbol, sourceTags))
sb.WriteString(e.formatMarketData(marketData))
if ctx.QuantDataMap != nil {
if quantData, hasQuant := ctx.QuantDataMap[coin.Symbol]; hasQuant {
sb.WriteString(e.formatQuantData(quantData))
}
}
sb.WriteString("\n")
}
sb.WriteString("\n")
// Get language for market data formatting
nofxosLang := nofxos.LangEnglish
if e.GetLanguage() == LangChinese {
nofxosLang = nofxos.LangChinese
}
// OI Ranking data (market-wide open interest changes)
if ctx.OIRankingData != nil {
sb.WriteString(nofxos.FormatOIRankingForAI(ctx.OIRankingData, nofxosLang))
}
// NetFlow Ranking data (market-wide fund flow)
if ctx.NetFlowRankingData != nil {
sb.WriteString(nofxos.FormatNetFlowRankingForAI(ctx.NetFlowRankingData, nofxosLang))
}
// Price Ranking data (market-wide gainers/losers)
if ctx.PriceRankingData != nil {
sb.WriteString(nofxos.FormatPriceRankingForAI(ctx.PriceRankingData, nofxosLang))
}
sb.WriteString("---\n\n")
sb.WriteString("Now please analyze and output your decision (Chain of Thought + JSON)\n")
return sb.String()
}
func (e *StrategyEngine) formatPositionInfo(index int, pos PositionInfo, ctx *Context) string {
var sb strings.Builder
holdingDuration := ""
if pos.UpdateTime > 0 {
durationMs := time.Now().UnixMilli() - pos.UpdateTime
durationMin := durationMs / (1000 * 60)
if durationMin < 60 {
holdingDuration = fmt.Sprintf(" | Holding Duration %d min", durationMin)
} else {
durationHour := durationMin / 60
durationMinRemainder := durationMin % 60
holdingDuration = fmt.Sprintf(" | Holding Duration %dh %dm", durationHour, durationMinRemainder)
}
}
positionValue := pos.Quantity * pos.MarkPrice
if positionValue < 0 {
positionValue = -positionValue
}
sb.WriteString(fmt.Sprintf("%d. %s %s | Entry %.4f Current %.4f | Qty %.4f | Position Value %.2f USDT | PnL%+.2f%% | PnL Amount%+.2f USDT | Peak PnL%.2f%% | Leverage %dx | Margin %.0f | Liq Price %.4f%s\n\n",
index, pos.Symbol, strings.ToUpper(pos.Side),
pos.EntryPrice, pos.MarkPrice, pos.Quantity, positionValue, pos.UnrealizedPnLPct, pos.UnrealizedPnL, pos.PeakPnLPct,
pos.Leverage, pos.MarginUsed, pos.LiquidationPrice, holdingDuration))
if marketData, ok := ctx.MarketDataMap[pos.Symbol]; ok {
sb.WriteString(e.formatMarketData(marketData))
if ctx.QuantDataMap != nil {
if quantData, hasQuant := ctx.QuantDataMap[pos.Symbol]; hasQuant {
sb.WriteString(e.formatQuantData(quantData))
}
}
sb.WriteString("\n")
}
return sb.String()
}
func (e *StrategyEngine) formatCoinSourceTag(sources []string) string {
if len(sources) > 1 {
// Multiple signal source combination
hasAI500 := false
hasOITop := false
hasOILow := false
hasHyperAll := false
hasHyperMain := false
for _, s := range sources {
switch s {
case "ai500":
hasAI500 = true
case "oi_top":
hasOITop = true
case "oi_low":
hasOILow = true
case "hyper_all":
hasHyperAll = true
case "hyper_main":
hasHyperMain = true
}
}
if hasAI500 && hasOITop {
return " (AI500+OI_Top dual signal)"
}
if hasAI500 && hasOILow {
return " (AI500+OI_Low dual signal)"
}
if hasOITop && hasOILow {
return " (OI_Top+OI_Low)"
}
if hasHyperMain && hasAI500 {
return " (HyperMain+AI500)"
}
if hasHyperAll || hasHyperMain {
return " (Hyperliquid)"
}
return " (Multiple sources)"
} else if len(sources) == 1 {
switch sources[0] {
case "ai500":
return " (AI500)"
case "oi_top":
return " (OI_Top OI increase)"
case "oi_low":
return " (OI_Low OI decrease)"
case "static":
return " (Manual selection)"
case "hyper_all":
return " (Hyperliquid All)"
case "hyper_main":
return " (Hyperliquid Top20)"
}
}
return ""
}
// ============================================================================
// Market Data Formatting
// ============================================================================
func (e *StrategyEngine) formatMarketData(data *market.Data) string {
var sb strings.Builder
indicators := e.config.Indicators
// Clearly label the coin symbol
sb.WriteString(fmt.Sprintf("=== %s Market Data ===\n\n", data.Symbol))
sb.WriteString(fmt.Sprintf("current_price = %.4f", data.CurrentPrice))
if indicators.EnableEMA {
sb.WriteString(fmt.Sprintf(", current_ema20 = %.3f", data.CurrentEMA20))
}
if indicators.EnableMACD {
sb.WriteString(fmt.Sprintf(", current_macd = %.3f", data.CurrentMACD))
}
if indicators.EnableRSI {
sb.WriteString(fmt.Sprintf(", current_rsi7 = %.3f", data.CurrentRSI7))
}
sb.WriteString("\n\n")
if indicators.EnableOI || indicators.EnableFundingRate {
sb.WriteString(fmt.Sprintf("Additional data for %s:\n\n", data.Symbol))
if indicators.EnableOI && data.OpenInterest != nil {
sb.WriteString(fmt.Sprintf("Open Interest: Latest: %.2f Average: %.2f\n\n",
data.OpenInterest.Latest, data.OpenInterest.Average))
}
if indicators.EnableFundingRate {
sb.WriteString(fmt.Sprintf("Funding Rate: %.2e\n\n", data.FundingRate))
}
}
if len(data.TimeframeData) > 0 {
timeframeOrder := []string{"1m", "3m", "5m", "15m", "30m", "1h", "2h", "4h", "6h", "8h", "12h", "1d", "3d", "1w"}
for _, tf := range timeframeOrder {
if tfData, ok := data.TimeframeData[tf]; ok {
sb.WriteString(fmt.Sprintf("=== %s Timeframe (oldest → latest) ===\n\n", strings.ToUpper(tf)))
e.formatTimeframeSeriesData(&sb, tfData, indicators)
}
}
} else {
// Compatible with old data format
if data.IntradaySeries != nil {
klineConfig := indicators.Klines
sb.WriteString(fmt.Sprintf("Intraday series (%s intervals, oldest → latest):\n\n", klineConfig.PrimaryTimeframe))
if len(data.IntradaySeries.MidPrices) > 0 {
sb.WriteString(fmt.Sprintf("Mid prices: %s\n\n", formatFloatSlice(data.IntradaySeries.MidPrices)))
}
if indicators.EnableEMA && len(data.IntradaySeries.EMA20Values) > 0 {
sb.WriteString(fmt.Sprintf("EMA indicators (20-period): %s\n\n", formatFloatSlice(data.IntradaySeries.EMA20Values)))
}
if indicators.EnableMACD && len(data.IntradaySeries.MACDValues) > 0 {
sb.WriteString(fmt.Sprintf("MACD indicators: %s\n\n", formatFloatSlice(data.IntradaySeries.MACDValues)))
}
if indicators.EnableRSI {
if len(data.IntradaySeries.RSI7Values) > 0 {
sb.WriteString(fmt.Sprintf("RSI indicators (7-Period): %s\n\n", formatFloatSlice(data.IntradaySeries.RSI7Values)))
}
if len(data.IntradaySeries.RSI14Values) > 0 {
sb.WriteString(fmt.Sprintf("RSI indicators (14-Period): %s\n\n", formatFloatSlice(data.IntradaySeries.RSI14Values)))
}
}
if indicators.EnableVolume && len(data.IntradaySeries.Volume) > 0 {
sb.WriteString(fmt.Sprintf("Volume: %s\n\n", formatFloatSlice(data.IntradaySeries.Volume)))
}
if indicators.EnableATR {
sb.WriteString(fmt.Sprintf("3m ATR (14-period): %.3f\n\n", data.IntradaySeries.ATR14))
}
}
if data.LongerTermContext != nil && indicators.Klines.EnableMultiTimeframe {
sb.WriteString(fmt.Sprintf("Longer-term context (%s timeframe):\n\n", indicators.Klines.LongerTimeframe))
if indicators.EnableEMA {
sb.WriteString(fmt.Sprintf("20-Period EMA: %.3f vs. 50-Period EMA: %.3f\n\n",
data.LongerTermContext.EMA20, data.LongerTermContext.EMA50))
}
if indicators.EnableATR {
sb.WriteString(fmt.Sprintf("3-Period ATR: %.3f vs. 14-Period ATR: %.3f\n\n",
data.LongerTermContext.ATR3, data.LongerTermContext.ATR14))
}
if indicators.EnableVolume {
sb.WriteString(fmt.Sprintf("Current Volume: %.3f vs. Average Volume: %.3f\n\n",
data.LongerTermContext.CurrentVolume, data.LongerTermContext.AverageVolume))
}
if indicators.EnableMACD && len(data.LongerTermContext.MACDValues) > 0 {
sb.WriteString(fmt.Sprintf("MACD indicators: %s\n\n", formatFloatSlice(data.LongerTermContext.MACDValues)))
}
if indicators.EnableRSI && len(data.LongerTermContext.RSI14Values) > 0 {
sb.WriteString(fmt.Sprintf("RSI indicators (14-Period): %s\n\n", formatFloatSlice(data.LongerTermContext.RSI14Values)))
}
}
}
return sb.String()
}
func (e *StrategyEngine) formatTimeframeSeriesData(sb *strings.Builder, data *market.TimeframeSeriesData, indicators store.IndicatorConfig) {
if len(data.Klines) > 0 {
sb.WriteString("Time(UTC) Open High Low Close Volume\n")
for i, k := range data.Klines {
t := time.Unix(k.Time/1000, 0).UTC()
timeStr := t.Format("01-02 15:04")
marker := ""
if i == len(data.Klines)-1 {
marker = " <- current"
}
sb.WriteString(fmt.Sprintf("%-14s %-9.4f %-9.4f %-9.4f %-9.4f %-12.2f%s\n",
timeStr, k.Open, k.High, k.Low, k.Close, k.Volume, marker))
}
sb.WriteString("\n")
} else if len(data.MidPrices) > 0 {
sb.WriteString(fmt.Sprintf("Mid prices: %s\n\n", formatFloatSlice(data.MidPrices)))
if indicators.EnableVolume && len(data.Volume) > 0 {
sb.WriteString(fmt.Sprintf("Volume: %s\n\n", formatFloatSlice(data.Volume)))
}
}
if indicators.EnableEMA {
if len(data.EMA20Values) > 0 {
sb.WriteString(fmt.Sprintf("EMA20: %s\n", formatFloatSlice(data.EMA20Values)))
}
if len(data.EMA50Values) > 0 {
sb.WriteString(fmt.Sprintf("EMA50: %s\n", formatFloatSlice(data.EMA50Values)))
}
}
if indicators.EnableMACD && len(data.MACDValues) > 0 {
sb.WriteString(fmt.Sprintf("MACD: %s\n", formatFloatSlice(data.MACDValues)))
}
if indicators.EnableRSI {
if len(data.RSI7Values) > 0 {
sb.WriteString(fmt.Sprintf("RSI7: %s\n", formatFloatSlice(data.RSI7Values)))
}
if len(data.RSI14Values) > 0 {
sb.WriteString(fmt.Sprintf("RSI14: %s\n", formatFloatSlice(data.RSI14Values)))
}
}
if indicators.EnableATR && data.ATR14 > 0 {
sb.WriteString(fmt.Sprintf("ATR14: %.4f\n", data.ATR14))
}
if indicators.EnableBOLL && len(data.BOLLUpper) > 0 {
sb.WriteString(fmt.Sprintf("BOLL Upper: %s\n", formatFloatSlice(data.BOLLUpper)))
sb.WriteString(fmt.Sprintf("BOLL Middle: %s\n", formatFloatSlice(data.BOLLMiddle)))
sb.WriteString(fmt.Sprintf("BOLL Lower: %s\n", formatFloatSlice(data.BOLLLower)))
}
sb.WriteString("\n")
}
func (e *StrategyEngine) formatQuantData(data *QuantData) string {
if data == nil {
return ""
}
indicators := e.config.Indicators
if !indicators.EnableQuantOI && !indicators.EnableQuantNetflow {
return ""
}
var sb strings.Builder
sb.WriteString(fmt.Sprintf("📊 %s Quantitative Data:\n", data.Symbol))
if len(data.PriceChange) > 0 {
sb.WriteString("Price Change: ")
timeframes := []string{"5m", "15m", "1h", "4h", "12h", "24h"}
parts := []string{}
for _, tf := range timeframes {
if v, ok := data.PriceChange[tf]; ok {
parts = append(parts, fmt.Sprintf("%s: %+.4f%%", tf, v*100))
}
}
sb.WriteString(strings.Join(parts, " | "))
sb.WriteString("\n")
}
if indicators.EnableQuantNetflow && data.Netflow != nil {
sb.WriteString("Fund Flow (Netflow):\n")
timeframes := []string{"5m", "15m", "1h", "4h", "12h", "24h"}
if data.Netflow.Institution != nil {
if data.Netflow.Institution.Future != nil && len(data.Netflow.Institution.Future) > 0 {
sb.WriteString(" Institutional Futures:\n")
for _, tf := range timeframes {
if v, ok := data.Netflow.Institution.Future[tf]; ok {
sb.WriteString(fmt.Sprintf(" %s: %s\n", tf, formatFlowValue(v)))
}
}
}
if data.Netflow.Institution.Spot != nil && len(data.Netflow.Institution.Spot) > 0 {
sb.WriteString(" Institutional Spot:\n")
for _, tf := range timeframes {
if v, ok := data.Netflow.Institution.Spot[tf]; ok {
sb.WriteString(fmt.Sprintf(" %s: %s\n", tf, formatFlowValue(v)))
}
}
}
}
if data.Netflow.Personal != nil {
if data.Netflow.Personal.Future != nil && len(data.Netflow.Personal.Future) > 0 {
sb.WriteString(" Retail Futures:\n")
for _, tf := range timeframes {
if v, ok := data.Netflow.Personal.Future[tf]; ok {
sb.WriteString(fmt.Sprintf(" %s: %s\n", tf, formatFlowValue(v)))
}
}
}
if data.Netflow.Personal.Spot != nil && len(data.Netflow.Personal.Spot) > 0 {
sb.WriteString(" Retail Spot:\n")
for _, tf := range timeframes {
if v, ok := data.Netflow.Personal.Spot[tf]; ok {
sb.WriteString(fmt.Sprintf(" %s: %s\n", tf, formatFlowValue(v)))
}
}
}
}
}
if indicators.EnableQuantOI && len(data.OI) > 0 {
for exchange, oiData := range data.OI {
if len(oiData.Delta) > 0 {
sb.WriteString(fmt.Sprintf("Open Interest (%s):\n", exchange))
for _, tf := range []string{"5m", "15m", "1h", "4h", "12h", "24h"} {
if d, ok := oiData.Delta[tf]; ok {
sb.WriteString(fmt.Sprintf(" %s: %+.4f%% (%s)\n", tf, d.OIDeltaPercent, formatFlowValue(d.OIDeltaValue)))
}
}
}
}
}
return sb.String()
}
func formatFlowValue(v float64) string {
sign := ""
if v >= 0 {
sign = "+"
}
absV := v
if absV < 0 {
absV = -absV
}
if absV >= 1e9 {
return fmt.Sprintf("%s%.2fB", sign, v/1e9)
} else if absV >= 1e6 {
return fmt.Sprintf("%s%.2fM", sign, v/1e6)
} else if absV >= 1e3 {
return fmt.Sprintf("%s%.2fK", sign, v/1e3)
}
return fmt.Sprintf("%s%.2f", sign, v)
}
func formatFloatSlice(values []float64) string {
strValues := make([]string, len(values))
for i, v := range values {
strValues[i] = fmt.Sprintf("%.4f", v)
}
return "[" + strings.Join(strValues, ", ") + "]"
}

View File

@@ -10,50 +10,49 @@ import (
)
// ============================================================================
// AI Data Formatter
// AI Data Formatter - AI数据格式化器
// ============================================================================
// Converts trading context into AI-friendly format, ensuring AI fully
// understands the data regardless of language.
// 将交易上下文转换为AI友好的格式确保AI能够100%理解数据
// ============================================================================
// FormatContextForAI formats trading context into AI-readable text (including schema)
// FormatContextForAI 将交易上下文格式化为AI可理解的文本包含Schema
func FormatContextForAI(ctx *Context, lang Language) string {
var sb strings.Builder
// 1. Add schema description (so AI understands data format)
// 1. 添加Schema说明让AI理解数据格式
sb.WriteString(GetSchemaPrompt(lang))
sb.WriteString("\n---\n\n")
// 2. Current state overview
// 2. 当前状态概览
sb.WriteString(formatContextData(ctx, lang))
return sb.String()
}
// FormatContextDataOnly formats context data only, without schema (for use when schema is already present)
// FormatContextDataOnly 仅格式化上下文数据不包含Schema用于已有Schema的场景
func FormatContextDataOnly(ctx *Context, lang Language) string {
return formatContextData(ctx, lang)
}
// formatContextData formats the core data section
// formatContextData 格式化核心数据部分
func formatContextData(ctx *Context, lang Language) string {
var sb strings.Builder
// 1. Current state overview
// 1. 当前状态概览
if lang == LangChinese {
sb.WriteString(formatHeaderZH(ctx))
} else {
sb.WriteString(formatHeaderEN(ctx))
}
// 3. Account information
// 3. 账户信息
if lang == LangChinese {
sb.WriteString(formatAccountZH(ctx))
} else {
sb.WriteString(formatAccountEN(ctx))
}
// 4. Historical trading statistics
// 4. 历史交易统计
if ctx.TradingStats != nil && ctx.TradingStats.TotalTrades > 0 {
if lang == LangChinese {
sb.WriteString(formatTradingStatsZH(ctx.TradingStats))
@@ -62,7 +61,7 @@ func formatContextData(ctx *Context, lang Language) string {
}
}
// 5. Recent trade records
// 5. 最近交易记录
if len(ctx.RecentOrders) > 0 {
if lang == LangChinese {
sb.WriteString(formatRecentTradesZH(ctx.RecentOrders))
@@ -71,7 +70,7 @@ func formatContextData(ctx *Context, lang Language) string {
}
}
// 5. Current positions
// 5. 当前持仓
if len(ctx.Positions) > 0 {
if lang == LangChinese {
sb.WriteString(formatCurrentPositionsZH(ctx))
@@ -80,7 +79,7 @@ func formatContextData(ctx *Context, lang Language) string {
}
}
// 6. Candidate coins (with market data)
// 6. 候选币种(带市场数据)
if len(ctx.CandidateCoins) > 0 {
if lang == LangChinese {
sb.WriteString(formatCandidateCoinsZH(ctx))
@@ -89,7 +88,7 @@ func formatContextData(ctx *Context, lang Language) string {
}
}
// 7. OI ranking data (if available)
// 7. OI排名数据(如果有)
if ctx.OIRankingData != nil {
nofxosLang := nofxos.LangEnglish
if lang == LangChinese {
@@ -101,15 +100,15 @@ func formatContextData(ctx *Context, lang Language) string {
return sb.String()
}
// ========== Chinese Formatting Functions ==========
// ========== 中文格式化函数 ==========
// formatHeaderZH formats header information (Chinese)
// formatHeaderZH 格式化头部信息(中文)
func formatHeaderZH(ctx *Context) string {
return fmt.Sprintf("# 📊 交易决策请求\n\n时间: %s | 周期: #%d | 运行时长: %d 分钟\n\n",
ctx.CurrentTime, ctx.CallCount, ctx.RuntimeMinutes)
}
// formatAccountZH formats account information (Chinese)
// formatAccountZH 格式化账户信息(中文)
func formatAccountZH(ctx *Context) string {
acc := ctx.Account
var sb strings.Builder
@@ -121,7 +120,7 @@ func formatAccountZH(ctx *Context) string {
sb.WriteString(fmt.Sprintf("保证金使用率: %.1f%% | ", acc.MarginUsedPct))
sb.WriteString(fmt.Sprintf("持仓数: %d\n\n", acc.PositionCount))
// Add risk warnings
// 添加风险提示
if acc.MarginUsedPct > 70 {
sb.WriteString("⚠️ **风险警告**: 保证金使用率 > 70%,处于高风险状态!\n\n")
} else if acc.MarginUsedPct > 50 {
@@ -131,25 +130,25 @@ func formatAccountZH(ctx *Context) string {
return sb.String()
}
// formatTradingStatsZH formats historical trading statistics (Chinese)
// formatTradingStatsZH 格式化历史交易统计(中文)
func formatTradingStatsZH(stats *TradingStats) string {
var sb strings.Builder
sb.WriteString("## 历史交易统计\n\n")
// Win/loss ratio calculation
// 盈亏比计算
var winLossRatio float64
if stats.AvgLoss > 0 {
winLossRatio = stats.AvgWin / stats.AvgLoss
}
// Metric definitions (focusing on core metrics, excluding win rate)
// 指标定义说明(去掉胜率,聚焦核心指标)
sb.WriteString("**指标说明**:\n")
sb.WriteString("- 盈利因子: 总盈利 ÷ 总亏损(>1表示盈利>1.5为良好,>2为优秀\n")
sb.WriteString("- 夏普比率: (平均收益 - 无风险收益) ÷ 收益标准差(>1良好>2优秀\n")
sb.WriteString("- 盈亏比: 平均盈利 ÷ 平均亏损(>1.5为良好,>2为优秀\n")
sb.WriteString("- 最大回撤: 资金曲线从峰值到谷底的最大跌幅(<20%为低风险)\n\n")
// Data values
// 数据值
sb.WriteString("**当前数据**:\n")
sb.WriteString(fmt.Sprintf("- 总交易: %d 笔\n", stats.TotalTrades))
sb.WriteString(fmt.Sprintf("- 盈利因子: %.2f\n", stats.ProfitFactor))
@@ -160,10 +159,10 @@ func formatTradingStatsZH(stats *TradingStats) string {
sb.WriteString(fmt.Sprintf("- 平均亏损: -%.2f USDT\n", stats.AvgLoss))
sb.WriteString(fmt.Sprintf("- 最大回撤: %.1f%%\n\n", stats.MaxDrawdownPct))
// Comprehensive analysis and decision guidance
// 综合分析和决策建议
sb.WriteString("**决策参考**:\n")
// Provide specific recommendations based on statistics
// 根据统计数据给出具体建议
if stats.TotalTrades < 10 {
sb.WriteString("- 样本量较小(<10笔统计结果参考意义有限\n")
}
@@ -192,13 +191,13 @@ func formatTradingStatsZH(stats *TradingStats) string {
return sb.String()
}
// formatRecentTradesZH formats recent trades (Chinese)
// formatRecentTradesZH 格式化最近交易(中文)
func formatRecentTradesZH(orders []RecentOrder) string {
var sb strings.Builder
sb.WriteString("## 最近完成的交易\n\n")
for i, order := range orders {
// Determine profit or loss
// 判断盈亏
profitOrLoss := "盈利"
if order.RealizedPnL < 0 {
profitOrLoss = "亏损"
@@ -223,13 +222,13 @@ func formatRecentTradesZH(orders []RecentOrder) string {
return sb.String()
}
// formatCurrentPositionsZH formats current positions (Chinese)
// formatCurrentPositionsZH 格式化当前持仓(中文)
func formatCurrentPositionsZH(ctx *Context) string {
var sb strings.Builder
sb.WriteString("## 当前持仓\n\n")
for i, pos := range ctx.Positions {
// Calculate drawdown
// 计算回撤
drawdown := pos.UnrealizedPnLPct - pos.PeakPnLPct
sb.WriteString(fmt.Sprintf("%d. %s %s | ", i+1, pos.Symbol, strings.ToUpper(pos.Side)))
@@ -243,7 +242,7 @@ func formatCurrentPositionsZH(ctx *Context) string {
sb.WriteString(fmt.Sprintf("保证金 %.0f USDT | ", pos.MarginUsed))
sb.WriteString(fmt.Sprintf("强平价 %.4f\n", pos.LiquidationPrice))
// Add analysis hints
// 添加分析提示
if drawdown < -0.30*pos.PeakPnLPct && pos.PeakPnLPct > 0.02 {
sb.WriteString(fmt.Sprintf(" ⚠️ **止盈提示**: 当前盈亏从峰值 %.2f%% 回撤到 %.2f%%,回撤幅度 %.2f%%,建议考虑止盈\n",
pos.PeakPnLPct, pos.UnrealizedPnLPct, (drawdown/pos.PeakPnLPct)*100))
@@ -253,7 +252,7 @@ func formatCurrentPositionsZH(ctx *Context) string {
sb.WriteString(" ⚠️ **止损提示**: 亏损接近-5%止损线,建议考虑止损\n")
}
// Show current price (if market data available)
// 显示当前价格(如果有市场数据)
if ctx.MarketDataMap != nil {
if mdata, ok := ctx.MarketDataMap[pos.Symbol]; ok {
sb.WriteString(fmt.Sprintf(" 📈 当前价格: %.4f\n", mdata.CurrentPrice))
@@ -266,7 +265,7 @@ func formatCurrentPositionsZH(ctx *Context) string {
return sb.String()
}
// formatCandidateCoinsZH formats candidate coins (Chinese)
// formatCandidateCoinsZH 格式化候选币种(中文)
func formatCandidateCoinsZH(ctx *Context) string {
var sb strings.Builder
sb.WriteString("## 候选币种\n\n")
@@ -274,19 +273,19 @@ func formatCandidateCoinsZH(ctx *Context) string {
for i, coin := range ctx.CandidateCoins {
sb.WriteString(fmt.Sprintf("### %d. %s\n\n", i+1, coin.Symbol))
// Current price
// 当前价格
if ctx.MarketDataMap != nil {
if mdata, ok := ctx.MarketDataMap[coin.Symbol]; ok {
sb.WriteString(fmt.Sprintf("当前价格: %.4f\n\n", mdata.CurrentPrice))
// Kline data (multi-timeframe)
// K线数据(多时间框架)
if mdata.TimeframeData != nil {
sb.WriteString(formatKlineDataZH(coin.Symbol, mdata.TimeframeData, ctx.Timeframes))
}
}
}
// OI data (if available)
// OI数据(如果有)
if ctx.OITopDataMap != nil {
if oiData, ok := ctx.OITopDataMap[coin.Symbol]; ok {
sb.WriteString(fmt.Sprintf("**持仓量变化**: OI排名 #%d | 变化 %+.2f%% (%+.2fM USDT) | 价格变化 %+.2f%%\n\n",
@@ -296,7 +295,7 @@ func formatCandidateCoinsZH(ctx *Context) string {
oiData.PriceDeltaPercent,
))
// OI interpretation
// OI解读
oiChange := "增加"
if oiData.OIDeltaPercent < 0 {
oiChange = "减少"
@@ -315,7 +314,7 @@ func formatCandidateCoinsZH(ctx *Context) string {
return sb.String()
}
// formatKlineDataZH formats kline data (Chinese)
// formatKlineDataZH 格式化K线数据中文
func formatKlineDataZH(symbol string, tfData map[string]*market.TimeframeSeriesData, timeframes []string) string {
var sb strings.Builder
@@ -325,7 +324,7 @@ func formatKlineDataZH(symbol string, tfData map[string]*market.TimeframeSeriesD
sb.WriteString("```\n")
sb.WriteString("时间(UTC) 开盘 最高 最低 收盘 成交量\n")
// Only show the latest 30 klines
// 只显示最近30根K线
startIdx := 0
if len(data.Klines) > 30 {
startIdx = len(data.Klines) - 30
@@ -344,7 +343,7 @@ func formatKlineDataZH(symbol string, tfData map[string]*market.TimeframeSeriesD
))
}
// Mark the last kline
// 标记最后一根K线
if len(data.Klines) > 0 {
sb.WriteString(" <- 当前\n")
}
@@ -357,7 +356,7 @@ func formatKlineDataZH(symbol string, tfData map[string]*market.TimeframeSeriesD
}
// getOIInterpretationZH returns OI change interpretation (Chinese)
// getOIInterpretationZH 获取OI变化解读中文
func getOIInterpretationZH(oiChange, priceChange string) string {
if oiChange == "增加" && priceChange == "上涨" {
return OIInterpretation.OIUp_PriceUp.ZH
@@ -370,15 +369,15 @@ func getOIInterpretationZH(oiChange, priceChange string) string {
}
}
// ========== English Formatting Functions ==========
// ========== 英文格式化函数 ==========
// formatHeaderEN formats header information (English)
// formatHeaderEN 格式化头部信息(英文)
func formatHeaderEN(ctx *Context) string {
return fmt.Sprintf("# 📊 Trading Decision Request\n\nTime: %s | Period: #%d | Runtime: %d minutes\n\n",
ctx.CurrentTime, ctx.CallCount, ctx.RuntimeMinutes)
}
// formatAccountEN formats account information (English)
// formatAccountEN 格式化账户信息(英文)
func formatAccountEN(ctx *Context) string {
acc := ctx.Account
var sb strings.Builder
@@ -400,7 +399,7 @@ func formatAccountEN(ctx *Context) string {
return sb.String()
}
// formatTradingStatsEN formats historical trading statistics (English)
// formatTradingStatsEN 格式化历史交易统计(英文)
func formatTradingStatsEN(stats *TradingStats) string {
var sb strings.Builder
sb.WriteString("## Historical Trading Statistics\n\n")
@@ -461,7 +460,7 @@ func formatTradingStatsEN(stats *TradingStats) string {
return sb.String()
}
// formatRecentTradesEN formats recent trades (English)
// formatRecentTradesEN 格式化最近交易(英文)
func formatRecentTradesEN(orders []RecentOrder) string {
var sb strings.Builder
sb.WriteString("## Recent Completed Trades\n\n")
@@ -491,7 +490,7 @@ func formatRecentTradesEN(orders []RecentOrder) string {
return sb.String()
}
// formatCurrentPositionsEN formats current positions (English)
// formatCurrentPositionsEN 格式化当前持仓(英文)
func formatCurrentPositionsEN(ctx *Context) string {
var sb strings.Builder
sb.WriteString("## Current Positions\n\n")
@@ -532,7 +531,7 @@ func formatCurrentPositionsEN(ctx *Context) string {
return sb.String()
}
// formatCandidateCoinsEN formats candidate coins (English)
// formatCandidateCoinsEN 格式化候选币种(英文)
func formatCandidateCoinsEN(ctx *Context) string {
var sb strings.Builder
sb.WriteString("## Candidate Coins\n\n")
@@ -577,7 +576,7 @@ func formatCandidateCoinsEN(ctx *Context) string {
return sb.String()
}
// formatKlineDataEN formats kline data (English)
// formatKlineDataEN 格式化K线数据英文
func formatKlineDataEN(symbol string, tfData map[string]*market.TimeframeSeriesData, timeframes []string) string {
var sb strings.Builder
@@ -622,7 +621,7 @@ func formatKlineDataEN(symbol string, tfData map[string]*market.TimeframeSeriesD
}
// getOIInterpretationEN returns OI change interpretation (English)
// getOIInterpretationEN 获取OI变化解读英文
func getOIInterpretationEN(oiChange, priceChange string) string {
if oiChange == "increase" && priceChange == "up" {
return OIInterpretation.OIUp_PriceUp.EN

View File

@@ -84,9 +84,6 @@ type GridContext struct {
// Box indicators (Donchian Channels)
BoxData *market.BoxData `json:"box_data,omitempty"`
// Grid direction (neutral, long, short, long_bias, short_bias)
CurrentDirection string `json:"current_direction,omitempty"`
}
// ============================================================================
@@ -282,20 +279,6 @@ func buildGridUserPromptZh(ctx *GridContext) string {
sb.WriteString(fmt.Sprintf("- 活跃订单数: %d\n", ctx.ActiveOrderCount))
sb.WriteString(fmt.Sprintf("- 已成交层数: %d\n", ctx.FilledLevelCount))
sb.WriteString(fmt.Sprintf("- 网格已暂停: %v\n", ctx.IsPaused))
if ctx.CurrentDirection != "" {
directionDescZh := map[string]string{
"neutral": "中性 (50%买+50%卖)",
"long": "做多 (100%买)",
"short": "做空 (100%卖)",
"long_bias": "偏多 (70%买+30%卖)",
"short_bias": "偏空 (30%买+70%卖)",
}
desc := directionDescZh[ctx.CurrentDirection]
if desc == "" {
desc = ctx.CurrentDirection
}
sb.WriteString(fmt.Sprintf("- 网格方向: %s\n", desc))
}
sb.WriteString("\n")
// Grid levels detail
@@ -393,20 +376,6 @@ func buildGridUserPromptEn(ctx *GridContext) string {
sb.WriteString(fmt.Sprintf("- Active Orders: %d\n", ctx.ActiveOrderCount))
sb.WriteString(fmt.Sprintf("- Filled Levels: %d\n", ctx.FilledLevelCount))
sb.WriteString(fmt.Sprintf("- Grid Paused: %v\n", ctx.IsPaused))
if ctx.CurrentDirection != "" {
directionDescEn := map[string]string{
"neutral": "Neutral (50% buy + 50% sell)",
"long": "Long (100% buy)",
"short": "Short (100% sell)",
"long_bias": "Long Bias (70% buy + 30% sell)",
"short_bias": "Short Bias (30% buy + 70% sell)",
}
desc := directionDescEn[ctx.CurrentDirection]
if desc == "" {
desc = ctx.CurrentDirection
}
sb.WriteString(fmt.Sprintf("- Grid Direction: %s\n", desc))
}
sb.WriteString("\n")
// Grid levels detail

View File

@@ -6,22 +6,22 @@ import (
)
// ============================================================================
// AI Prompt Builder
// AI Prompt Builder - AI提示词构建器
// ============================================================================
// Builds complete AI prompts including system prompts and user prompts.
// 构建完整的AI提示词包括系统提示词和用户提示词
// ============================================================================
// PromptBuilder builds AI prompts in the configured language
// PromptBuilder 提示词构建器
type PromptBuilder struct {
lang Language
}
// NewPromptBuilder creates a new prompt builder for the given language
// NewPromptBuilder 创建提示词构建器
func NewPromptBuilder(lang Language) *PromptBuilder {
return &PromptBuilder{lang: lang}
}
// BuildSystemPrompt builds the system prompt
// BuildSystemPrompt 构建系统提示词
func (pb *PromptBuilder) BuildSystemPrompt() string {
if pb.lang == LangChinese {
return pb.buildSystemPromptZH()
@@ -29,19 +29,19 @@ func (pb *PromptBuilder) BuildSystemPrompt() string {
return pb.buildSystemPromptEN()
}
// BuildUserPrompt builds the user prompt with full trading context
// BuildUserPrompt 构建用户提示词(包含完整的交易上下文)
func (pb *PromptBuilder) BuildUserPrompt(ctx *Context) string {
// Use Formatter to format the trading context
// 使用Formatter格式化交易上下文
formattedData := FormatContextForAI(ctx, pb.lang)
// Append decision requirements
// 添加决策要求
if pb.lang == LangChinese {
return formattedData + pb.getDecisionRequirementsZH()
}
return formattedData + pb.getDecisionRequirementsEN()
}
// ========== Chinese Prompts ==========
// ========== 中文提示词 ==========
func (pb *PromptBuilder) buildSystemPromptZH() string {
return `你是一个专业的量化交易AI助手负责分析市场数据并做出交易决策。
@@ -176,7 +176,7 @@ func (pb *PromptBuilder) getDecisionRequirementsZH() string {
**请立即输出你的决策JSON格式**:`
}
// ========== English Prompts ==========
// ========== 英文提示词 ==========
func (pb *PromptBuilder) buildSystemPromptEN() string {
return `You are a professional quantitative trading AI assistant responsible for analyzing market data and making trading decisions.
@@ -311,9 +311,9 @@ func (pb *PromptBuilder) getDecisionRequirementsEN() string {
**Please output your decision (JSON format) immediately**:`
}
// ========== Helper Functions ==========
// ========== 辅助函数 ==========
// FormatDecisionExample formats a decision example (for documentation)
// FormatDecisionExample 格式化决策示例(用于文档)
func FormatDecisionExample(lang Language) string {
example := Decision{
Symbol: "BTCUSDT",
@@ -323,32 +323,32 @@ func FormatDecisionExample(lang Language) string {
StopLoss: 42000,
TakeProfit: 48000,
Confidence: 85,
Reasoning: "Detailed reasoning process...",
Reasoning: "详细的推理过程...",
}
data, _ := json.MarshalIndent([]Decision{example}, "", " ")
return string(data)
}
// ValidateDecisionFormat validates that the decision format is correct
// ValidateDecisionFormat 验证决策格式是否正确
func ValidateDecisionFormat(decisions []Decision) error {
if len(decisions) == 0 {
return fmt.Errorf("decision list cannot be empty")
return fmt.Errorf("决策列表不能为空")
}
for i, d := range decisions {
// Required field checks
// 必需字段检查
if d.Symbol == "" {
return fmt.Errorf("decision #%d: symbol cannot be empty", i+1)
return fmt.Errorf("决策#%d: symbol不能为空", i+1)
}
if d.Action == "" {
return fmt.Errorf("decision #%d: action cannot be empty", i+1)
return fmt.Errorf("决策#%d: action不能为空", i+1)
}
if d.Reasoning == "" {
return fmt.Errorf("decision #%d: reasoning cannot be empty", i+1)
return fmt.Errorf("决策#%d: reasoning不能为空", i+1)
}
// Action type validation
// 动作类型检查
validActions := map[string]bool{
"HOLD": true,
"PARTIAL_CLOSE": true,
@@ -358,16 +358,16 @@ func ValidateDecisionFormat(decisions []Decision) error {
"WAIT": true,
}
if !validActions[d.Action] {
return fmt.Errorf("decision #%d: invalid action type: %s", i+1, d.Action)
return fmt.Errorf("决策#%d: 无效的action类型: %s", i+1, d.Action)
}
// Required parameters for opening new positions
// 开新仓位的必需参数检查
if d.Action == "OPEN_NEW" {
if d.Leverage == 0 {
return fmt.Errorf("decision #%d: OPEN_NEW action requires leverage", i+1)
return fmt.Errorf("决策#%d: OPEN_NEW动作需要提供leverage", i+1)
}
if d.PositionSizeUSD == 0 {
return fmt.Errorf("decision #%d: OPEN_NEW action requires position_size_usd", i+1)
return fmt.Errorf("决策#%d: OPEN_NEW动作需要提供position_size_usd", i+1)
}
}
}

View File

@@ -170,8 +170,8 @@ func TestValidateDecisionFormat(t *testing.T) {
t.Error("Empty decisions should return error")
}
if !strings.Contains(err.Error(), "cannot be empty") {
t.Errorf("Error message should mention 'cannot be empty', got: %v", err)
if !strings.Contains(err.Error(), "不能为空") {
t.Errorf("Error message should mention '不能为空', got: %v", err)
}
})
@@ -238,8 +238,8 @@ func TestValidateDecisionFormat(t *testing.T) {
t.Error("Invalid action should return error")
}
if !strings.Contains(err.Error(), "invalid action") {
t.Errorf("Error should mention 'invalid action', got: %v", err)
if !strings.Contains(err.Error(), "无效的action") {
t.Errorf("Error should mention '无效的action', got: %v", err)
}
})

View File

@@ -1,17 +1,17 @@
package kernel
// ============================================================================
// Trading Data Schema
// Trading Data Schema - 交易数据字典
// ============================================================================
// Bilingual data dictionary supporting Chinese and English.
// Ensures AI can fully understand data formats regardless of language.
// 双语数据字典,支持中文和英文
// 确保AI能够100%理解数据格式,无论使用哪种语言
// ============================================================================
const (
SchemaVersion = "1.0.0"
)
// Language represents the language type
// Language 语言类型
type Language string
const (
@@ -19,20 +19,20 @@ const (
LangEnglish Language = "en-US"
)
// ========== Bilingual Field Definitions ==========
// ========== 双语字段定义 ==========
// BilingualFieldDef defines a field with bilingual name, formula, and description
// BilingualFieldDef 双语字段定义
type BilingualFieldDef struct {
NameZH string // Chinese name
NameZH string // 中文名称
NameEN string // English name
Unit string // unit of measurement
FormulaZH string // Chinese formula
Unit string // 单位
FormulaZH string // 中文公式
FormulaEN string // English formula
DescZH string // Chinese description
DescZH string // 中文描述
DescEN string // English description
}
// GetName returns the field name based on language
// GetName 获取字段名称(根据语言)
func (d BilingualFieldDef) GetName(lang Language) string {
if lang == LangChinese {
return d.NameZH
@@ -40,7 +40,7 @@ func (d BilingualFieldDef) GetName(lang Language) string {
return d.NameEN
}
// GetFormula returns the formula based on language
// GetFormula 获取公式(根据语言)
func (d BilingualFieldDef) GetFormula(lang Language) string {
if lang == LangChinese {
return d.FormulaZH
@@ -48,7 +48,7 @@ func (d BilingualFieldDef) GetFormula(lang Language) string {
return d.FormulaEN
}
// GetDesc returns the description based on language
// GetDesc 获取描述(根据语言)
func (d BilingualFieldDef) GetDesc(lang Language) string {
if lang == LangChinese {
return d.DescZH
@@ -56,9 +56,9 @@ func (d BilingualFieldDef) GetDesc(lang Language) string {
return d.DescEN
}
// ========== Data Dictionary ==========
// ========== 数据字典 ==========
// DataDictionary defines the meaning of all fields
// DataDictionary 数据字典:定义所有字段的含义
var DataDictionary = map[string]map[string]BilingualFieldDef{
"AccountMetrics": {
"Equity": {
@@ -217,18 +217,18 @@ var DataDictionary = map[string]map[string]BilingualFieldDef{
},
}
// ========== Bilingual Rule Definitions ==========
// ========== 双语规则定义 ==========
// BilingualRuleDef defines a trading rule with bilingual description and reason
// BilingualRuleDef 双语规则定义
type BilingualRuleDef struct {
Value interface{} // rule value
DescZH string // Chinese description
Value interface{} // 规则值
DescZH string // 中文描述
DescEN string // English description
ReasonZH string // Chinese reason
ReasonZH string // 中文原因
ReasonEN string // English reason
}
// GetDesc returns the description based on language
// GetDesc 获取描述(根据语言)
func (d BilingualRuleDef) GetDesc(lang Language) string {
if lang == LangChinese {
return d.DescZH
@@ -236,7 +236,7 @@ func (d BilingualRuleDef) GetDesc(lang Language) string {
return d.DescEN
}
// GetReason returns the reason based on language
// GetReason 获取原因(根据语言)
func (d BilingualRuleDef) GetReason(lang Language) string {
if lang == LangChinese {
return d.ReasonZH
@@ -244,9 +244,9 @@ func (d BilingualRuleDef) GetReason(lang Language) string {
return d.ReasonEN
}
// ========== Trading Rules ==========
// ========== 交易规则 ==========
// TradingRules defines the trading rules
// TradingRules 交易规则定义
var TradingRules = struct {
RiskManagement map[string]BilingualRuleDef
EntrySignals map[string]BilingualRuleDef
@@ -340,9 +340,9 @@ var TradingRules = struct {
},
}
// ========== OI Interpretation ==========
// ========== OI解读 ==========
// OIInterpretation defines bilingual market interpretations for OI changes
// OIInterpretation OI变化的市场解读双语
type OIInterpretationType struct {
OIUp_PriceUp struct {
ZH string
@@ -393,9 +393,9 @@ var OIInterpretation = OIInterpretationType{
},
}
// ========== Common Mistakes ==========
// ========== 常见错误 ==========
// CommonMistake defines a common mistake with bilingual fields
// CommonMistake 常见错误定义
type CommonMistake struct {
ErrorZH string
ErrorEN string
@@ -440,9 +440,9 @@ var CommonMistakes = []CommonMistake{
},
}
// ========== Prompt Generation Functions ==========
// ========== Prompt生成函数 ==========
// GetSchemaPrompt generates schema description text for AI prompts
// GetSchemaPrompt 生成Schema说明文本用于AI Prompt
func GetSchemaPrompt(lang Language) string {
if lang == LangChinese {
return getSchemaPromptZH()
@@ -450,36 +450,36 @@ func GetSchemaPrompt(lang Language) string {
return getSchemaPromptEN()
}
// getSchemaPromptZH generates the Chinese prompt
// getSchemaPromptZH 生成中文Prompt
func getSchemaPromptZH() string {
prompt := "# 📖 数据字典与交易规则\n\n"
prompt += "## 📊 字段含义说明\n\n"
// Account metrics
// 账户指标
prompt += "### 账户指标\n"
for key, field := range DataDictionary["AccountMetrics"] {
prompt += formatFieldDefZH(key, field)
}
// Trade metrics
// 交易指标
prompt += "\n### 交易指标\n"
for key, field := range DataDictionary["TradeMetrics"] {
prompt += formatFieldDefZH(key, field)
}
// Position metrics
// 持仓指标
prompt += "\n### 持仓指标\n"
for key, field := range DataDictionary["PositionMetrics"] {
prompt += formatFieldDefZH(key, field)
}
// Market data
// 市场数据
prompt += "\n### 市场数据\n"
for key, field := range DataDictionary["MarketData"] {
prompt += formatFieldDefZH(key, field)
}
// OI interpretation
// OI解读
prompt += "\n## 💹 持仓量(OI)变化解读\n\n"
prompt += "- **OI增加 + 价格上涨**: " + OIInterpretation.OIUp_PriceUp.ZH + "\n"
prompt += "- **OI增加 + 价格下跌**: " + OIInterpretation.OIUp_PriceDown.ZH + "\n"
@@ -489,7 +489,7 @@ func getSchemaPromptZH() string {
return prompt
}
// getSchemaPromptEN generates the English prompt
// getSchemaPromptEN 生成英文Prompt
func getSchemaPromptEN() string {
prompt := "# 📖 Data Dictionary & Trading Rules\n\n"
prompt += "## 📊 Field Definitions\n\n"
@@ -528,7 +528,7 @@ func getSchemaPromptEN() string {
return prompt
}
// formatFieldDefZH formats a field definition in Chinese
// formatFieldDefZH 格式化中文字段定义
func formatFieldDefZH(key string, field BilingualFieldDef) string {
result := "- **" + key + "**" + field.NameZH + ": " + field.DescZH
if field.FormulaZH != "" {
@@ -541,7 +541,7 @@ func formatFieldDefZH(key string, field BilingualFieldDef) string {
return result
}
// formatFieldDefEN formats a field definition in English
// formatFieldDefEN 格式化英文字段定义
func formatFieldDefEN(key string, field BilingualFieldDef) string {
result := "- **" + key + "** (" + field.NameEN + "): " + field.DescEN
if field.FormulaEN != "" {

278
kernel/schema_test.go Normal file
View File

@@ -0,0 +1,278 @@
package kernel
import (
"strings"
"testing"
)
// TestDataDictionary 测试数据字典定义
func TestDataDictionary(t *testing.T) {
// 测试账户指标字典
t.Run("AccountMetrics", func(t *testing.T) {
equity := DataDictionary["AccountMetrics"]["Equity"]
if equity.NameZH != "总权益" {
t.Errorf("Expected NameZH='总权益', got '%s'", equity.NameZH)
}
if equity.NameEN != "Total Equity" {
t.Errorf("Expected NameEN='Total Equity', got '%s'", equity.NameEN)
}
if equity.Unit != "USDT" {
t.Errorf("Expected Unit='USDT', got '%s'", equity.Unit)
}
if equity.GetName(LangChinese) != "总权益" {
t.Errorf("GetName(Chinese) failed")
}
if equity.GetName(LangEnglish) != "Total Equity" {
t.Errorf("GetName(English) failed")
}
})
// 测试持仓指标字典
t.Run("PositionMetrics", func(t *testing.T) {
peakPnL := DataDictionary["PositionMetrics"]["PeakPnL%"]
if peakPnL.NameZH == "" {
t.Error("PeakPnL% NameZH is empty")
}
if peakPnL.NameEN == "" {
t.Error("PeakPnL% NameEN is empty")
}
if !strings.Contains(peakPnL.DescZH, "峰值") {
t.Error("PeakPnL% DescZH should contain '峰值'")
}
})
}
// TestTradingRules 测试交易规则定义
func TestTradingRules(t *testing.T) {
t.Run("RiskManagement", func(t *testing.T) {
maxMargin := TradingRules.RiskManagement["MaxMarginUsage"]
if maxMargin.Value != 0.30 {
t.Errorf("Expected MaxMarginUsage=0.30, got %v", maxMargin.Value)
}
if maxMargin.GetDesc(LangChinese) == "" {
t.Error("MaxMarginUsage DescZH is empty")
}
if maxMargin.GetDesc(LangEnglish) == "" {
t.Error("MaxMarginUsage DescEN is empty")
}
if !strings.Contains(maxMargin.DescZH, "30%") {
t.Error("MaxMarginUsage DescZH should mention 30%")
}
})
t.Run("ExitSignals", func(t *testing.T) {
trailing := TradingRules.ExitSignals["TrailingStop"]
if trailing.Value != 0.30 {
t.Errorf("Expected TrailingStop=0.30, got %v", trailing.Value)
}
if !strings.Contains(trailing.ReasonZH, "止盈") {
t.Error("TrailingStop ReasonZH should mention '止盈'")
}
if !strings.Contains(trailing.ReasonEN, "profit") {
t.Error("TrailingStop ReasonEN should mention 'profit'")
}
})
}
// TestOIInterpretation 测试OI解读
func TestOIInterpretation(t *testing.T) {
t.Run("OI_Up_Price_Up", func(t *testing.T) {
if OIInterpretation.OIUp_PriceUp.ZH == "" {
t.Error("OI Up + Price Up ZH is empty")
}
if OIInterpretation.OIUp_PriceUp.EN == "" {
t.Error("OI Up + Price Up EN is empty")
}
if !strings.Contains(OIInterpretation.OIUp_PriceUp.ZH, "多头") {
t.Error("OI Up + Price Up should indicate bullish trend")
}
})
}
// TestCommonMistakes 测试常见错误定义
func TestCommonMistakes(t *testing.T) {
if len(CommonMistakes) == 0 {
t.Error("CommonMistakes should not be empty")
}
for i, mistake := range CommonMistakes {
if mistake.ErrorZH == "" {
t.Errorf("Mistake #%d ErrorZH is empty", i+1)
}
if mistake.ErrorEN == "" {
t.Errorf("Mistake #%d ErrorEN is empty", i+1)
}
if mistake.CorrectZH == "" {
t.Errorf("Mistake #%d CorrectZH is empty", i+1)
}
if mistake.CorrectEN == "" {
t.Errorf("Mistake #%d CorrectEN is empty", i+1)
}
}
}
// TestGetSchemaPrompt 测试Schema提示词生成
func TestGetSchemaPrompt(t *testing.T) {
t.Run("Chinese", func(t *testing.T) {
prompt := GetSchemaPrompt(LangChinese)
if prompt == "" {
t.Fatal("Chinese schema prompt is empty")
}
// 验证包含关键内容
mustContain := []string{
"数据字典",
"账户指标",
"交易指标",
"持仓指标",
"市场数据",
"持仓量(OI)变化解读",
}
for _, keyword := range mustContain {
if !strings.Contains(prompt, keyword) {
t.Errorf("Chinese prompt should contain '%s'", keyword)
}
}
})
t.Run("English", func(t *testing.T) {
prompt := GetSchemaPrompt(LangEnglish)
if prompt == "" {
t.Fatal("English schema prompt is empty")
}
// 验证包含关键内容
mustContain := []string{
"Data Dictionary",
"Account Metrics",
"Trade Metrics",
"Position Metrics",
"Market Data",
"Open Interest",
}
for _, keyword := range mustContain {
if !strings.Contains(prompt, keyword) {
t.Errorf("English prompt should contain '%s'", keyword)
}
}
})
t.Run("Consistency", func(t *testing.T) {
promptZH := GetSchemaPrompt(LangChinese)
promptEN := GetSchemaPrompt(LangEnglish)
// 两个版本都应该包含相同数量的字段定义
// 虽然内容不同,但结构应该相似
zhLines := strings.Split(promptZH, "\n")
enLines := strings.Split(promptEN, "\n")
// 行数应该大致相当允许10%的差异)
ratio := float64(len(zhLines)) / float64(len(enLines))
if ratio < 0.9 || ratio > 1.1 {
t.Logf("Warning: Line count difference is significant (ZH: %d, EN: %d)",
len(zhLines), len(enLines))
}
})
}
// BenchmarkGetSchemaPrompt 性能测试
func BenchmarkGetSchemaPrompt(b *testing.B) {
b.Run("Chinese", func(b *testing.B) {
for i := 0; i < b.N; i++ {
_ = GetSchemaPrompt(LangChinese)
}
})
b.Run("English", func(b *testing.B) {
for i := 0; i < b.N; i++ {
_ = GetSchemaPrompt(LangEnglish)
}
})
}
// TestFieldDefinitionMethods 测试字段定义方法
func TestFieldDefinitionMethods(t *testing.T) {
field := BilingualFieldDef{
NameZH: "测试字段",
NameEN: "Test Field",
Unit: "USDT",
FormulaZH: "中文公式",
FormulaEN: "English formula",
DescZH: "中文描述",
DescEN: "English description",
}
// 测试GetName
if field.GetName(LangChinese) != "测试字段" {
t.Error("GetName(Chinese) failed")
}
if field.GetName(LangEnglish) != "Test Field" {
t.Error("GetName(English) failed")
}
// 测试GetFormula
if field.GetFormula(LangChinese) != "中文公式" {
t.Error("GetFormula(Chinese) failed")
}
if field.GetFormula(LangEnglish) != "English formula" {
t.Error("GetFormula(English) failed")
}
// 测试GetDesc
if field.GetDesc(LangChinese) != "中文描述" {
t.Error("GetDesc(Chinese) failed")
}
if field.GetDesc(LangEnglish) != "English description" {
t.Error("GetDesc(English) failed")
}
}
// TestRuleDefinitionMethods 测试规则定义方法
func TestRuleDefinitionMethods(t *testing.T) {
rule := BilingualRuleDef{
Value: 0.30,
DescZH: "中文描述",
DescEN: "English description",
ReasonZH: "中文原因",
ReasonEN: "English reason",
}
if rule.GetDesc(LangChinese) != "中文描述" {
t.Error("GetDesc(Chinese) failed")
}
if rule.GetDesc(LangEnglish) != "English description" {
t.Error("GetDesc(English) failed")
}
if rule.GetReason(LangChinese) != "中文原因" {
t.Error("GetReason(Chinese) failed")
}
if rule.GetReason(LangEnglish) != "English reason" {
t.Error("GetReason(English) failed")
}
}

351
llm/qwen_agent.go Normal file
View File

@@ -0,0 +1,351 @@
package llm
import (
"bufio"
"bytes"
"context"
"encoding/json"
"fmt"
"io"
"net/http"
"strings"
"time"
)
// 阿里云 API 配置
const (
DefaultQwenBaseURL = "https://dashscope.aliyuncs.com/api/v1/apps"
// 标准 OpenAI 兼容模式 API
QwenCompatibleURL = "https://dashscope.aliyuncs.com/compatible-mode/v1/chat/completions"
)
// QwenAgent 阿里云百炼智能体客户端
type QwenAgent struct {
AppID string
APIKey string
BaseURL string
SessionID string
Client *http.Client
}
// QwenRequest 请求结构
type QwenRequest struct {
Input QwenInput `json:"input"`
Parameters QwenParameters `json:"parameters,omitempty"`
}
// QwenInput 输入结构
type QwenInput struct {
Prompt string `json:"prompt"`
BizParams map[string]interface{} `json:"biz_params,omitempty"`
}
// QwenParameters 参数结构
type QwenParameters struct {
SessionID string `json:"session_id,omitempty"`
IncrementalOutput bool `json:"incremental_output,omitempty"`
}
// QwenResponse 响应结构
type QwenResponse struct {
Output QwenOutput `json:"output"`
Usage QwenUsage `json:"usage,omitempty"`
RequestID string `json:"request_id"`
Code string `json:"code,omitempty"`
Message string `json:"message,omitempty"`
}
// QwenOutput 输出结构
type QwenOutput struct {
Text string `json:"text"`
FinishReason string `json:"finish_reason,omitempty"`
SessionID string `json:"session_id,omitempty"`
}
// QwenUsage 用量统计
type QwenUsage struct {
InputTokens int `json:"input_tokens"`
OutputTokens int `json:"output_tokens"`
TotalTokens int `json:"total_tokens"`
}
// NewQwenAgent 创建新的智能体客户端
func NewQwenAgent(appID, apiKey string) *QwenAgent {
return &QwenAgent{
AppID: appID,
APIKey: apiKey,
BaseURL: DefaultQwenBaseURL,
Client: &http.Client{
Timeout: 180 * time.Second,
},
}
}
// Chat 同步对话
func (a *QwenAgent) Chat(ctx context.Context, prompt string) (*QwenResponse, error) {
reqBody := QwenRequest{
Input: QwenInput{
Prompt: prompt,
},
Parameters: QwenParameters{
SessionID: a.SessionID,
},
}
jsonData, err := json.Marshal(reqBody)
if err != nil {
return nil, fmt.Errorf("marshal request failed: %w", err)
}
url := fmt.Sprintf("%s/%s/completion", a.BaseURL, a.AppID)
req, err := http.NewRequestWithContext(ctx, "POST", url, bytes.NewBuffer(jsonData))
if err != nil {
return nil, fmt.Errorf("create request failed: %w", err)
}
req.Header.Set("Content-Type", "application/json")
req.Header.Set("Authorization", "Bearer "+a.APIKey)
resp, err := a.Client.Do(req)
if err != nil {
return nil, fmt.Errorf("request failed: %w", err)
}
defer resp.Body.Close()
body, err := io.ReadAll(resp.Body)
if err != nil {
return nil, fmt.Errorf("read response failed: %w", err)
}
var result QwenResponse
if err := json.Unmarshal(body, &result); err != nil {
return nil, fmt.Errorf("unmarshal response failed: %w, body: %s", err, string(body))
}
// 更新 session_id 用于多轮对话
if result.Output.SessionID != "" {
a.SessionID = result.Output.SessionID
}
// 检查 API 错误
if result.Code != "" {
return &result, fmt.Errorf("API error: code=%s, message=%s", result.Code, result.Message)
}
return &result, nil
}
// ChatStream 流式对话
func (a *QwenAgent) ChatStream(ctx context.Context, prompt string, callback func(chunk string)) error {
reqBody := QwenRequest{
Input: QwenInput{
Prompt: prompt,
},
Parameters: QwenParameters{
SessionID: a.SessionID,
IncrementalOutput: true,
},
}
jsonData, err := json.Marshal(reqBody)
if err != nil {
return fmt.Errorf("marshal request failed: %w", err)
}
url := fmt.Sprintf("%s/%s/completion", a.BaseURL, a.AppID)
req, err := http.NewRequestWithContext(ctx, "POST", url, bytes.NewBuffer(jsonData))
if err != nil {
return fmt.Errorf("create request failed: %w", err)
}
req.Header.Set("Content-Type", "application/json")
req.Header.Set("Authorization", "Bearer "+a.APIKey)
req.Header.Set("X-DashScope-SSE", "enable")
resp, err := a.Client.Do(req)
if err != nil {
return fmt.Errorf("request failed: %w", err)
}
defer resp.Body.Close()
reader := bufio.NewReader(resp.Body)
for {
line, err := reader.ReadString('\n')
if err != nil {
if err == io.EOF {
break
}
return fmt.Errorf("read stream failed: %w", err)
}
line = strings.TrimSpace(line)
if !strings.HasPrefix(line, "data:") {
continue
}
data := strings.TrimPrefix(line, "data:")
var chunk QwenResponse
if err := json.Unmarshal([]byte(data), &chunk); err != nil {
continue
}
// 更新 session_id
if chunk.Output.SessionID != "" {
a.SessionID = chunk.Output.SessionID
}
// 回调输出文本
if chunk.Output.Text != "" {
callback(chunk.Output.Text)
}
}
return nil
}
// ChatWithBizParams 带业务参数的对话
func (a *QwenAgent) ChatWithBizParams(ctx context.Context, prompt string, bizParams map[string]interface{}) (*QwenResponse, error) {
reqBody := QwenRequest{
Input: QwenInput{
Prompt: prompt,
BizParams: bizParams,
},
Parameters: QwenParameters{
SessionID: a.SessionID,
},
}
jsonData, err := json.Marshal(reqBody)
if err != nil {
return nil, fmt.Errorf("marshal request failed: %w", err)
}
url := fmt.Sprintf("%s/%s/completion", a.BaseURL, a.AppID)
req, err := http.NewRequestWithContext(ctx, "POST", url, bytes.NewBuffer(jsonData))
if err != nil {
return nil, fmt.Errorf("create request failed: %w", err)
}
req.Header.Set("Content-Type", "application/json")
req.Header.Set("Authorization", "Bearer "+a.APIKey)
resp, err := a.Client.Do(req)
if err != nil {
return nil, fmt.Errorf("request failed: %w", err)
}
defer resp.Body.Close()
body, err := io.ReadAll(resp.Body)
if err != nil {
return nil, fmt.Errorf("read response failed: %w", err)
}
var result QwenResponse
if err := json.Unmarshal(body, &result); err != nil {
return nil, fmt.Errorf("unmarshal response failed: %w, body: %s", err, string(body))
}
if result.Output.SessionID != "" {
a.SessionID = result.Output.SessionID
}
if result.Code != "" {
return &result, fmt.Errorf("API error: code=%s, message=%s", result.Code, result.Message)
}
return &result, nil
}
// ResetSession 重置会话
func (a *QwenAgent) ResetSession() {
a.SessionID = ""
}
// ========== 标准 OpenAI 兼容 API ==========
// ChatCompletionRequest OpenAI 兼容格式请求
type ChatCompletionRequest struct {
Model string `json:"model"`
Messages []ChatCompletionMessage `json:"messages"`
}
// ChatCompletionMessage 消息结构
type ChatCompletionMessage struct {
Role string `json:"role"`
Content string `json:"content"`
}
// ChatCompletionResponse OpenAI 兼容格式响应
type ChatCompletionResponse struct {
ID string `json:"id"`
Model string `json:"model"`
Choices []struct {
Message struct {
Role string `json:"role"`
Content string `json:"content"`
} `json:"message"`
FinishReason string `json:"finish_reason"`
} `json:"choices"`
Usage struct {
PromptTokens int `json:"prompt_tokens"`
CompletionTokens int `json:"completion_tokens"`
TotalTokens int `json:"total_tokens"`
} `json:"usage"`
Error *struct {
Code string `json:"code"`
Message string `json:"message"`
} `json:"error,omitempty"`
}
// ChatWithModel 使用标准 OpenAI 兼容 API 调用指定模型
func (a *QwenAgent) ChatWithModel(ctx context.Context, model, prompt string) (*ChatCompletionResponse, error) {
reqBody := ChatCompletionRequest{
Model: model,
Messages: []ChatCompletionMessage{
{Role: "user", Content: prompt},
},
}
jsonData, err := json.Marshal(reqBody)
if err != nil {
return nil, fmt.Errorf("marshal request failed: %w", err)
}
req, err := http.NewRequestWithContext(ctx, "POST", QwenCompatibleURL, bytes.NewBuffer(jsonData))
if err != nil {
return nil, fmt.Errorf("create request failed: %w", err)
}
req.Header.Set("Content-Type", "application/json")
req.Header.Set("Authorization", "Bearer "+a.APIKey)
resp, err := a.Client.Do(req)
if err != nil {
return nil, fmt.Errorf("request failed: %w", err)
}
defer resp.Body.Close()
body, err := io.ReadAll(resp.Body)
if err != nil {
return nil, fmt.Errorf("read response failed: %w", err)
}
var result ChatCompletionResponse
if err := json.Unmarshal(body, &result); err != nil {
return nil, fmt.Errorf("unmarshal response failed: %w, body: %s", err, string(body))
}
if result.Error != nil {
return &result, fmt.Errorf("API error: code=%s, message=%s", result.Error.Code, result.Error.Message)
}
return &result, nil
}
// GetContent 从响应中获取内容
func (r *ChatCompletionResponse) GetContent() string {
if len(r.Choices) > 0 {
return r.Choices[0].Message.Content
}
return ""
}

425
llm/qwen_agent_test.go Normal file
View File

@@ -0,0 +1,425 @@
package llm
import (
"bytes"
"context"
"encoding/json"
"fmt"
"io"
"net/http"
"os"
"strings"
"testing"
"time"
)
// 阿里云百炼平台配置 (从环境变量获取)
var (
QwenAppID = os.Getenv("QWEN_APP_ID")
QwenAPIKey = os.Getenv("QWEN_API_KEY")
)
// ============== 测试用例 ==============
// TestQwenBasicChat 测试基本同步对话
func TestQwenBasicChat(t *testing.T) {
agent := NewQwenAgent(QwenAppID, QwenAPIKey)
ctx := context.Background()
prompt := "你好,请用一句话介绍你自己"
t.Logf("用户: %s", prompt)
start := time.Now()
resp, err := agent.Chat(ctx, prompt)
elapsed := time.Since(start)
if err != nil {
t.Fatalf("Chat failed: %v", err)
}
if resp.Output.Text == "" {
t.Fatal("Empty response text")
}
t.Logf("助手: %s", resp.Output.Text)
t.Logf("耗时: %v, Token: %d", elapsed, resp.Usage.TotalTokens)
}
// TestQwenStreamChat 测试流式输出
func TestQwenStreamChat(t *testing.T) {
agent := NewQwenAgent(QwenAppID, QwenAPIKey)
ctx := context.Background()
prompt := "请用3句话解释什么是量化交易"
t.Logf("用户: %s", prompt)
var fullText strings.Builder
start := time.Now()
err := agent.ChatStream(ctx, prompt, func(chunk string) {
fullText.WriteString(chunk)
})
elapsed := time.Since(start)
if err != nil {
t.Fatalf("ChatStream failed: %v", err)
}
if fullText.Len() == 0 {
t.Fatal("Empty stream response")
}
t.Logf("助手: %s", fullText.String())
t.Logf("耗时: %v, 字符数: %d", elapsed, fullText.Len())
}
// TestQwenMultiTurn 测试多轮对话(上下文记忆)
func TestQwenMultiTurn(t *testing.T) {
agent := NewQwenAgent(QwenAppID, QwenAPIKey)
ctx := context.Background()
// 第一轮:设置上下文
resp1, err := agent.Chat(ctx, "我叫小明,我是一名 Go 程序员,请记住这些信息")
if err != nil {
t.Fatalf("Round 1 failed: %v", err)
}
t.Logf("[Round 1] 用户: 我叫小明,我是一名 Go 程序员")
t.Logf("[Round 1] 助手: %s", resp1.Output.Text)
t.Logf("[Round 1] SessionID: %s", agent.SessionID)
// 第二轮:验证记忆
resp2, err := agent.Chat(ctx, "请问我叫什么名字?我是做什么的?")
if err != nil {
t.Fatalf("Round 2 failed: %v", err)
}
t.Logf("[Round 2] 用户: 请问我叫什么名字?我是做什么的?")
t.Logf("[Round 2] 助手: %s", resp2.Output.Text)
// 检查是否记住了信息
text := strings.ToLower(resp2.Output.Text)
if !strings.Contains(text, "小明") && !strings.Contains(text, "go") {
t.Logf("警告: 模型可能没有正确记住上下文")
}
}
// TestQwenResetSession 测试重置会话
func TestQwenResetSession(t *testing.T) {
agent := NewQwenAgent(QwenAppID, QwenAPIKey)
ctx := context.Background()
// 建立上下文
resp1, err := agent.Chat(ctx, "记住这个密码: ABC123XYZ")
if err != nil {
t.Fatalf("Setup context failed: %v", err)
}
t.Logf("设置上下文: %s", resp1.Output.Text)
oldSession := agent.SessionID
t.Logf("原 SessionID: %s", oldSession)
// 重置会话
agent.ResetSession()
t.Log("会话已重置")
// 新对话 - 应该不记得之前的内容
resp2, err := agent.Chat(ctx, "我之前告诉你的密码是什么?")
if err != nil {
t.Fatalf("New session chat failed: %v", err)
}
t.Logf("新对话回复: %s", resp2.Output.Text)
t.Logf("新 SessionID: %s", agent.SessionID)
if oldSession == agent.SessionID {
t.Error("Session was not reset properly")
}
}
// TestQwenCodeGeneration 测试代码生成能力
func TestQwenCodeGeneration(t *testing.T) {
agent := NewQwenAgent(QwenAppID, QwenAPIKey)
ctx := context.Background()
prompt := "请用 Go 语言写一个计算移动平均线(MA)的函数,输入是 []float64 价格切片和 int 周期"
t.Logf("用户: %s", prompt)
resp, err := agent.Chat(ctx, prompt)
if err != nil {
t.Fatalf("Code generation failed: %v", err)
}
t.Logf("助手:\n%s", resp.Output.Text)
// 检查是否包含代码特征
text := resp.Output.Text
if !strings.Contains(text, "func") || !strings.Contains(text, "float64") {
t.Log("警告: 响应可能不包含有效的 Go 代码")
}
}
// TestQwenJSONOutput 测试 JSON 格式输出
func TestQwenJSONOutput(t *testing.T) {
agent := NewQwenAgent(QwenAppID, QwenAPIKey)
ctx := context.Background()
prompt := `请分析 BTC 的基本信息,以纯 JSON 格式返回(不要 markdown 代码块),包含以下字段:
{"name": "资产名称", "type": "资产类型", "risk": 1-10的风险等级数字}
只返回 JSON 对象,不要任何其他文字`
t.Logf("用户: %s", prompt)
resp, err := agent.Chat(ctx, prompt)
if err != nil {
t.Fatalf("JSON output test failed: %v", err)
}
t.Logf("助手: %s", resp.Output.Text)
// 尝试解析 JSON
text := resp.Output.Text
// 提取 JSON 部分
start := strings.Index(text, "{")
end := strings.LastIndex(text, "}")
if start != -1 && end != -1 && end > start {
jsonStr := text[start : end+1]
var result map[string]interface{}
if err := json.Unmarshal([]byte(jsonStr), &result); err != nil {
t.Logf("JSON 解析失败: %v", err)
} else {
t.Logf("JSON 解析成功: %+v", result)
}
}
}
// TestQwenLongResponse 测试长文本生成
func TestQwenLongResponse(t *testing.T) {
agent := NewQwenAgent(QwenAppID, QwenAPIKey)
ctx := context.Background()
prompt := "请详细介绍加密货币永续合约交易中的风险管理策略包括止损设置、仓位管理、杠杆选择、资金费率考虑等方面至少500字"
t.Logf("用户: %s", prompt)
start := time.Now()
resp, err := agent.Chat(ctx, prompt)
elapsed := time.Since(start)
if err != nil {
t.Fatalf("Long response test failed: %v", err)
}
text := resp.Output.Text
t.Logf("响应长度: %d 字符", len(text))
t.Logf("耗时: %v", elapsed)
t.Logf("Token 使用: input=%d, output=%d, total=%d",
resp.Usage.InputTokens, resp.Usage.OutputTokens, resp.Usage.TotalTokens)
// 只显示前500字符
if len(text) > 500 {
t.Logf("助手(前500字): %s...", text[:500])
} else {
t.Logf("助手: %s", text)
}
}
// TestQwenTradingScenario 测试交易场景问答
func TestQwenTradingScenario(t *testing.T) {
agent := NewQwenAgent(QwenAppID, QwenAPIKey)
ctx := context.Background()
questions := []string{
"BTC 当前价格 95000 美元RSI 在 75 附近MACD 金叉,你建议现在开多还是开空?简短回答",
"如果我有 10000 USDT想用 10 倍杠杆做多 ETH建议开多大仓位",
"什么是资金费率?正的资金费率对多头有什么影响?",
}
for i, q := range questions {
agent.ResetSession() // 每个问题独立
t.Logf("\n[问题%d] %s", i+1, q)
resp, err := agent.Chat(ctx, q)
if err != nil {
t.Errorf("Question %d failed: %v", i+1, err)
continue
}
// 截取显示
text := resp.Output.Text
if len(text) > 300 {
text = text[:300] + "..."
}
t.Logf("[回答%d] %s", i+1, text)
}
}
// TestQwenErrorHandling 测试错误处理
func TestQwenErrorHandling(t *testing.T) {
ctx := context.Background()
// 测试无效 API Key
t.Run("InvalidAPIKey", func(t *testing.T) {
agent := NewQwenAgent(QwenAppID, "invalid-api-key")
_, err := agent.Chat(ctx, "测试")
if err == nil {
t.Log("警告: 无效 API Key 没有返回错误")
} else {
t.Logf("预期错误: %v", err)
}
})
// 测试无效 App ID
t.Run("InvalidAppID", func(t *testing.T) {
agent := NewQwenAgent("invalid-app-id", QwenAPIKey)
_, err := agent.Chat(ctx, "测试")
if err == nil {
t.Log("警告: 无效 App ID 没有返回错误")
} else {
t.Logf("预期错误: %v", err)
}
})
}
// TestQwenSpecialCharacters 测试特殊字符处理
func TestQwenSpecialCharacters(t *testing.T) {
agent := NewQwenAgent(QwenAppID, QwenAPIKey)
ctx := context.Background()
testCases := []string{
"请解释这个表情: 😀🎉🚀",
"中英文混合: Hello世界",
"特殊符号: <>&\"'",
}
for _, prompt := range testCases {
agent.ResetSession()
t.Logf("用户: %s", prompt)
resp, err := agent.Chat(ctx, prompt)
if err != nil {
t.Errorf("特殊字符测试失败: %v", err)
continue
}
if len(resp.Output.Text) > 100 {
t.Logf("助手: %s...", resp.Output.Text[:100])
} else {
t.Logf("助手: %s", resp.Output.Text)
}
}
}
// TestQwenConcurrentSessions 测试并发会话
func TestQwenConcurrentSessions(t *testing.T) {
agent1 := NewQwenAgent(QwenAppID, QwenAPIKey)
agent2 := NewQwenAgent(QwenAppID, QwenAPIKey)
ctx := context.Background()
// Agent1 对话
resp1, err := agent1.Chat(ctx, "我是 Alice请记住")
if err != nil {
t.Fatalf("Agent1 chat failed: %v", err)
}
t.Logf("[Agent1] 设置: 我是 Alice -> %s", resp1.Output.Text[:min(100, len(resp1.Output.Text))])
// Agent2 对话
resp2, err := agent2.Chat(ctx, "我是 Bob请记住")
if err != nil {
t.Fatalf("Agent2 chat failed: %v", err)
}
t.Logf("[Agent2] 设置: 我是 Bob -> %s", resp2.Output.Text[:min(100, len(resp2.Output.Text))])
// 验证会话隔离
resp1Check, _ := agent1.Chat(ctx, "我叫什么?")
resp2Check, _ := agent2.Chat(ctx, "我叫什么?")
t.Logf("[Agent1] 验证: %s", resp1Check.Output.Text[:min(100, len(resp1Check.Output.Text))])
t.Logf("[Agent2] 验证: %s", resp2Check.Output.Text[:min(100, len(resp2Check.Output.Text))])
if agent1.SessionID == agent2.SessionID {
t.Error("两个 Agent 的 SessionID 不应该相同")
} else {
t.Logf("Session 隔离正常: Agent1=%s..., Agent2=%s...",
agent1.SessionID[:min(20, len(agent1.SessionID))],
agent2.SessionID[:min(20, len(agent2.SessionID))])
}
}
// TestQwenTimeout 测试超时处理
func TestQwenTimeout(t *testing.T) {
agent := NewQwenAgent(QwenAppID, QwenAPIKey)
agent.Client.Timeout = 1 * time.Millisecond // 极短超时
ctx := context.Background()
_, err := agent.Chat(ctx, "测试超时")
if err == nil {
t.Log("警告: 极短超时没有触发错误")
} else {
t.Logf("预期超时错误: %v", err)
}
// 恢复正常超时
agent.Client.Timeout = 120 * time.Second
}
// TestQwenContextCancel 测试上下文取消
func TestQwenContextCancel(t *testing.T) {
agent := NewQwenAgent(QwenAppID, QwenAPIKey)
ctx, cancel := context.WithCancel(context.Background())
cancel() // 立即取消
_, err := agent.Chat(ctx, "测试取消")
if err == nil {
t.Error("取消的上下文应该返回错误")
} else {
t.Logf("预期取消错误: %v", err)
}
}
// TestQwenWithBizParams 测试带业务参数的调用
func TestQwenWithBizParams(t *testing.T) {
agent := NewQwenAgent(QwenAppID, QwenAPIKey)
ctx := context.Background()
// 构造带业务参数的请求
reqBody := QwenRequest{
Input: QwenInput{
Prompt: "根据提供的用户信息,给出个性化的投资建议",
BizParams: map[string]interface{}{
"user_risk_level": "moderate",
"capital": 10000,
"experience": "intermediate",
},
},
}
jsonData, _ := json.Marshal(reqBody)
url := fmt.Sprintf("%s/%s/completion", agent.BaseURL, agent.AppID)
req, _ := http.NewRequestWithContext(ctx, "POST", url, bytes.NewBuffer(jsonData))
req.Header.Set("Content-Type", "application/json")
req.Header.Set("Authorization", "Bearer "+agent.APIKey)
resp, err := agent.Client.Do(req)
if err != nil {
t.Fatalf("Request with biz params failed: %v", err)
}
defer resp.Body.Close()
body, _ := io.ReadAll(resp.Body)
var result QwenResponse
json.Unmarshal(body, &result)
if result.Output.Text != "" {
t.Logf("带业务参数响应: %s", result.Output.Text[:min(200, len(result.Output.Text))])
} else {
t.Logf("响应: %s", string(body))
}
}
func min(a, b int) int {
if a < b {
return a
}
return b
}

737
llm/qwen_indicator_test.go Normal file
View File

@@ -0,0 +1,737 @@
package llm
import (
"context"
"encoding/json"
"fmt"
"math"
"nofx/market"
"nofx/provider/coinank"
"nofx/provider/coinank/coinank_api"
"nofx/provider/coinank/coinank_enum"
"regexp"
"strconv"
"strings"
"testing"
"time"
)
// IndicatorResult AI 计算的指标结果
type IndicatorResult struct {
EMA12 float64 `json:"ema12"`
EMA26 float64 `json:"ema26"`
MACD float64 `json:"macd"`
RSI14 float64 `json:"rsi14"`
BOLLUp float64 `json:"boll_upper"`
BOLLMid float64 `json:"boll_middle"`
BOLLLow float64 `json:"boll_lower"`
ATR14 float64 `json:"atr14"`
SMA20 float64 `json:"sma20"`
}
// 本地计算指标(使用 market 包的函数)
func calculateLocalIndicators(klines []market.Kline) IndicatorResult {
result := IndicatorResult{}
if len(klines) >= 12 {
result.EMA12 = market.ExportCalculateEMA(klines, 12)
}
if len(klines) >= 26 {
result.EMA26 = market.ExportCalculateEMA(klines, 26)
result.MACD = market.ExportCalculateMACD(klines)
}
if len(klines) > 14 {
result.RSI14 = market.ExportCalculateRSI(klines, 14)
}
if len(klines) >= 20 {
result.BOLLUp, result.BOLLMid, result.BOLLLow = market.ExportCalculateBOLL(klines, 20, 2.0)
// SMA20 就是 BOLL 中轨
result.SMA20 = result.BOLLMid
}
if len(klines) > 14 {
result.ATR14 = market.ExportCalculateATR(klines, 14)
}
return result
}
// 格式化 K 线数据为文本,发给 AI
func formatKlinesForAI(klines []market.Kline) string {
var sb strings.Builder
sb.WriteString("以下是K线数据从旧到新排列\n")
sb.WriteString("序号 | 时间 | 开盘价 | 最高价 | 最低价 | 收盘价 | 成交量\n")
sb.WriteString("-----|------|--------|--------|--------|--------|--------\n")
for i, k := range klines {
t := time.UnixMilli(k.OpenTime)
sb.WriteString(fmt.Sprintf("%d | %s | %.2f | %.2f | %.2f | %.2f | %.2f\n",
i+1, t.Format("01-02 15:04"), k.Open, k.High, k.Low, k.Close, k.Volume))
}
return sb.String()
}
// 构建 AI 计算指标的 prompt
func buildIndicatorPrompt(klines []market.Kline) string {
klinesText := formatKlinesForAI(klines)
prompt := fmt.Sprintf(`%s
请根据以上 %d 根K线数据计算以下技术指标使用标准算法
1. EMA1212周期指数移动平均线
2. EMA2626周期指数移动平均线
3. MACDEMA12 - EMA26
4. RSI1414周期相对强弱指标使用Wilder平滑法
5. BOLL布林带20周期2倍标准差上轨、中轨、下轨
6. ATR1414周期平均真实波幅使用Wilder平滑法
7. SMA2020周期简单移动平均线
请严格按照以下 JSON 格式返回结果,不要添加任何其他文字:
{
"ema12": 数值,
"ema26": 数值,
"macd": 数值,
"rsi14": 数值,
"boll_upper": 数值,
"boll_middle": 数值,
"boll_lower": 数值,
"atr14": 数值,
"sma20": 数值
}
注意:
- 所有数值保留2位小数
- EMA计算使用SMA作为初始值乘数为 2/(period+1)
- RSI使用Wilder平滑法
- 只返回JSON不要解释过程`, klinesText, len(klines))
return prompt
}
// 从 AI 响应中提取 JSON
func extractJSONFromResponse(text string) (IndicatorResult, error) {
var result IndicatorResult
// 尝试直接解析
if err := json.Unmarshal([]byte(text), &result); err == nil {
return result, nil
}
// 提取 JSON 部分
re := regexp.MustCompile(`\{[^{}]*"ema12"[^{}]*\}`)
match := re.FindString(text)
if match == "" {
// 尝试更宽松的匹配
start := strings.Index(text, "{")
end := strings.LastIndex(text, "}")
if start != -1 && end != -1 && end > start {
match = text[start : end+1]
}
}
if match == "" {
return result, fmt.Errorf("no JSON found in response: %s", text[:min(200, len(text))])
}
if err := json.Unmarshal([]byte(match), &result); err != nil {
return result, fmt.Errorf("parse JSON failed: %w, json: %s", err, match)
}
return result, nil
}
// 比较两个指标结果,返回误差百分比
func compareIndicators(local, ai IndicatorResult) map[string]float64 {
errors := make(map[string]float64)
calcError := func(name string, localVal, aiVal float64) {
if localVal == 0 {
if aiVal == 0 {
errors[name] = 0
} else {
errors[name] = 100 // 本地为0但AI不为0
}
return
}
errors[name] = math.Abs(localVal-aiVal) / math.Abs(localVal) * 100
}
calcError("EMA12", local.EMA12, ai.EMA12)
calcError("EMA26", local.EMA26, ai.EMA26)
calcError("MACD", local.MACD, ai.MACD)
calcError("RSI14", local.RSI14, ai.RSI14)
calcError("BOLL_UP", local.BOLLUp, ai.BOLLUp)
calcError("BOLL_MID", local.BOLLMid, ai.BOLLMid)
calcError("BOLL_LOW", local.BOLLLow, ai.BOLLLow)
calcError("ATR14", local.ATR14, ai.ATR14)
calcError("SMA20", local.SMA20, ai.SMA20)
return errors
}
// 生成测试用 K 线数据
func generateTestKlines(count int, basePrice float64) []market.Kline {
klines := make([]market.Kline, count)
price := basePrice
now := time.Now()
for i := 0; i < count; i++ {
// 模拟价格波动
change := (float64(i%7) - 3) * 0.5 // -1.5 到 +1.5 的波动
price = price + change
open := price
high := price + math.Abs(change)*0.5 + 0.5
low := price - math.Abs(change)*0.5 - 0.3
close := price + (change * 0.3)
klines[i] = market.Kline{
OpenTime: now.Add(time.Duration(-count+i) * time.Hour).UnixMilli(),
Open: open,
High: high,
Low: low,
Close: close,
Volume: 1000 + float64(i*100),
CloseTime: now.Add(time.Duration(-count+i+1) * time.Hour).UnixMilli(),
}
}
return klines
}
// TestQwenIndicatorCalculation 测试 AI 计算技术指标
func TestQwenIndicatorCalculation(t *testing.T) {
agent := NewQwenAgent(QwenAppID, QwenAPIKey)
ctx := context.Background()
// 生成 30 根测试 K 线
klines := generateTestKlines(30, 95000)
t.Log("===== K线数据 (最后5根) =====")
for i := len(klines) - 5; i < len(klines); i++ {
k := klines[i]
t.Logf(" [%d] O:%.2f H:%.2f L:%.2f C:%.2f", i+1, k.Open, k.High, k.Low, k.Close)
}
// 本地计算
t.Log("\n===== 本地计算结果 =====")
localResult := calculateLocalIndicators(klines)
t.Logf(" EMA12: %.2f", localResult.EMA12)
t.Logf(" EMA26: %.2f", localResult.EMA26)
t.Logf(" MACD: %.2f", localResult.MACD)
t.Logf(" RSI14: %.2f", localResult.RSI14)
t.Logf(" BOLL上轨: %.2f", localResult.BOLLUp)
t.Logf(" BOLL中轨: %.2f", localResult.BOLLMid)
t.Logf(" BOLL下轨: %.2f", localResult.BOLLLow)
t.Logf(" ATR14: %.2f", localResult.ATR14)
t.Logf(" SMA20: %.2f", localResult.SMA20)
// AI 计算
t.Log("\n===== 调用 AI 计算 =====")
prompt := buildIndicatorPrompt(klines)
t.Logf("Prompt 长度: %d 字符", len(prompt))
start := time.Now()
resp, err := agent.Chat(ctx, prompt)
elapsed := time.Since(start)
if err != nil {
t.Fatalf("AI 调用失败: %v", err)
}
t.Logf("AI 响应耗时: %v", elapsed)
t.Logf("AI 原始响应:\n%s", resp.Output.Text)
// 解析 AI 结果
aiResult, err := extractJSONFromResponse(resp.Output.Text)
if err != nil {
t.Fatalf("解析 AI 结果失败: %v", err)
}
t.Log("\n===== AI 计算结果 =====")
t.Logf(" EMA12: %.2f", aiResult.EMA12)
t.Logf(" EMA26: %.2f", aiResult.EMA26)
t.Logf(" MACD: %.2f", aiResult.MACD)
t.Logf(" RSI14: %.2f", aiResult.RSI14)
t.Logf(" BOLL上轨: %.2f", aiResult.BOLLUp)
t.Logf(" BOLL中轨: %.2f", aiResult.BOLLMid)
t.Logf(" BOLL下轨: %.2f", aiResult.BOLLLow)
t.Logf(" ATR14: %.2f", aiResult.ATR14)
t.Logf(" SMA20: %.2f", aiResult.SMA20)
// 对比结果
t.Log("\n===== 误差对比 (%) =====")
errors := compareIndicators(localResult, aiResult)
totalError := 0.0
for name, errPct := range errors {
status := "✓"
if errPct > 5 {
status = "⚠"
}
if errPct > 10 {
status = "✗"
}
t.Logf(" %s %s: %.2f%%", status, name, errPct)
totalError += errPct
}
avgError := totalError / float64(len(errors))
t.Logf("\n 平均误差: %.2f%%", avgError)
if avgError > 10 {
t.Logf("警告: AI 计算误差较大,可能算法理解有差异")
} else if avgError < 5 {
t.Log("AI 计算精度良好!")
}
}
// TestQwenIndicatorWithRealKlines 使用真实 K 线测试
func TestQwenIndicatorWithRealKlines(t *testing.T) {
// 尝试获取真实 K 线数据
client := market.NewAPIClient()
klines, err := client.GetKlines("BTC", "1h", 30)
if err != nil {
t.Skipf("获取真实 K 线失败,跳过测试: %v", err)
return
}
if len(klines) < 26 {
t.Skipf("K 线数量不足: %d", len(klines))
return
}
agent := NewQwenAgent(QwenAppID, QwenAPIKey)
ctx := context.Background()
t.Logf("获取到 %d 根 BTC 1h K线", len(klines))
t.Log("最新价格:", klines[len(klines)-1].Close)
// 本地计算
localResult := calculateLocalIndicators(klines)
t.Log("\n===== 本地计算 =====")
t.Logf(" EMA12: %.2f, EMA26: %.2f, MACD: %.2f", localResult.EMA12, localResult.EMA26, localResult.MACD)
t.Logf(" RSI14: %.2f", localResult.RSI14)
t.Logf(" BOLL: %.2f / %.2f / %.2f", localResult.BOLLUp, localResult.BOLLMid, localResult.BOLLLow)
// AI 计算
prompt := buildIndicatorPrompt(klines)
resp, err := agent.Chat(ctx, prompt)
if err != nil {
t.Fatalf("AI 调用失败: %v", err)
}
t.Log("\n===== AI 响应 =====")
t.Log(resp.Output.Text)
aiResult, err := extractJSONFromResponse(resp.Output.Text)
if err != nil {
t.Logf("解析失败: %v", err)
return
}
// 对比
errors := compareIndicators(localResult, aiResult)
t.Log("\n===== 误差 =====")
for name, errPct := range errors {
t.Logf(" %s: %.2f%%", name, errPct)
}
}
// TestQwenIndicatorMultiTimeframe 测试多个时间周期
func TestQwenIndicatorMultiTimeframe(t *testing.T) {
agent := NewQwenAgent(QwenAppID, QwenAPIKey)
ctx := context.Background()
timeframes := []struct {
name string
count int
price float64
}{
{"5m周期", 30, 95000},
{"1h周期", 50, 95000},
{"4h周期", 40, 95000},
}
for _, tf := range timeframes {
t.Run(tf.name, func(t *testing.T) {
klines := generateTestKlines(tf.count, tf.price)
localResult := calculateLocalIndicators(klines)
// 简化的 prompt
prompt := buildSimpleIndicatorPrompt(klines)
resp, err := agent.Chat(ctx, prompt)
if err != nil {
t.Fatalf("AI 调用失败: %v", err)
}
aiResult, err := extractJSONFromResponse(resp.Output.Text)
if err != nil {
t.Logf("解析失败: %v", err)
t.Logf("AI 响应: %s", resp.Output.Text[:min(500, len(resp.Output.Text))])
return
}
errors := compareIndicators(localResult, aiResult)
// 计算平均误差
total := 0.0
for _, e := range errors {
total += e
}
avgErr := total / float64(len(errors))
t.Logf("本地 MACD: %.2f, AI MACD: %.2f, 误差: %.2f%%", localResult.MACD, aiResult.MACD, errors["MACD"])
t.Logf("本地 RSI: %.2f, AI RSI: %.2f, 误差: %.2f%%", localResult.RSI14, aiResult.RSI14, errors["RSI14"])
t.Logf("平均误差: %.2f%%", avgErr)
})
time.Sleep(2 * time.Second) // 避免请求过快
}
}
// 简化的 prompt
func buildSimpleIndicatorPrompt(klines []market.Kline) string {
// 只提供收盘价序列,减少 token
var prices []string
for _, k := range klines {
prices = append(prices, fmt.Sprintf("%.2f", k.Close))
}
return fmt.Sprintf(`收盘价序列(从旧到新): [%s]
请计算技术指标并返回 JSON
- ema12: 12周期EMA
- ema26: 26周期EMA
- macd: EMA12-EMA26
- rsi14: 14周期RSI(Wilder平滑)
- boll_upper, boll_middle, boll_lower: 20周期BOLL(2倍标准差)
- atr14: 0 (无高低价数据)
- sma20: 20周期SMA
只返回JSON格式{"ema12":数值,"ema26":数值,...}`, strings.Join(prices, ","))
}
// TestQwenIndicatorAccuracy 精度测试:使用简单数据验证算法
func TestQwenIndicatorAccuracy(t *testing.T) {
agent := NewQwenAgent(QwenAppID, QwenAPIKey)
ctx := context.Background()
// 使用简单递增数据,便于验证
prices := []float64{
100, 101, 102, 103, 104, 105, 106, 107, 108, 109, // 1-10
110, 111, 112, 113, 114, 115, 116, 117, 118, 119, // 11-20
120, 121, 122, 123, 124, 125, 126, 127, 128, 129, // 21-30
}
// 构建 K 线
klines := make([]market.Kline, len(prices))
for i, p := range prices {
klines[i] = market.Kline{
Open: p - 0.5,
High: p + 1,
Low: p - 1,
Close: p,
}
}
// 本地计算
localResult := calculateLocalIndicators(klines)
t.Log("===== 简单递增数据测试 =====")
t.Logf("价格序列: %v", prices)
t.Logf("本地计算:")
t.Logf(" SMA20 = %.4f (理论值: 119.5)", localResult.SMA20)
t.Logf(" EMA12 = %.4f", localResult.EMA12)
t.Logf(" RSI14 = %.4f (持续上涨应接近100)", localResult.RSI14)
// AI 计算
var priceStrs []string
for _, p := range prices {
priceStrs = append(priceStrs, strconv.FormatFloat(p, 'f', 0, 64))
}
prompt := fmt.Sprintf(`收盘价序列: [%s]
请计算:
1. SMA20 (20周期简单移动平均)
2. EMA12 (12周期指数移动平均初始值用SMA乘数=2/13)
3. RSI14 (14周期RSIWilder平滑法)
返回JSON: {"sma20":数值,"ema12":数值,"rsi14":数值}
只返回JSON`, strings.Join(priceStrs, ","))
resp, err := agent.Chat(ctx, prompt)
if err != nil {
t.Fatalf("AI 调用失败: %v", err)
}
t.Logf("\nAI 响应: %s", resp.Output.Text)
// 简单解析
var aiSimple struct {
SMA20 float64 `json:"sma20"`
EMA12 float64 `json:"ema12"`
RSI14 float64 `json:"rsi14"`
}
text := resp.Output.Text
start := strings.Index(text, "{")
end := strings.LastIndex(text, "}")
if start != -1 && end > start {
json.Unmarshal([]byte(text[start:end+1]), &aiSimple)
}
t.Logf("\nAI 计算:")
t.Logf(" SMA20 = %.4f", aiSimple.SMA20)
t.Logf(" EMA12 = %.4f", aiSimple.EMA12)
t.Logf(" RSI14 = %.4f", aiSimple.RSI14)
// 验证 SMA20 (理论值应该是 110+...+129 的平均 = 119.5)
expectedSMA := 119.5
if math.Abs(aiSimple.SMA20-expectedSMA) < 0.1 {
t.Log("\n✓ AI 的 SMA20 计算正确!")
} else {
t.Logf("\n✗ AI 的 SMA20 有误差,期望 %.2f", expectedSMA)
}
}
// coinankKlinesToMarket 将 coinank K线转换为 market.Kline
func coinankKlinesToMarket(klines []coinank.KlineResult) []market.Kline {
result := make([]market.Kline, len(klines))
for i, k := range klines {
result[i] = market.Kline{
OpenTime: k.StartTime,
Open: k.Open,
High: k.High,
Low: k.Low,
Close: k.Close,
Volume: k.Volume,
CloseTime: k.EndTime,
}
}
return result
}
// TestQwenETHMultiTimeframe 使用 Coinank 免费 API 获取真实 ETH 数据测试多周期指标
func TestQwenETHMultiTimeframe(t *testing.T) {
ctx := context.Background()
agent := NewQwenAgent(QwenAppID, QwenAPIKey)
// 测试多个时间周期
timeframes := []struct {
name string
interval coinank_enum.Interval
size int
}{
{"5分钟", coinank_enum.Minute5, 50},
{"1小时", coinank_enum.Hour1, 50},
{"4小时", coinank_enum.Hour4, 50},
{"日线", coinank_enum.Day1, 30},
}
now := time.Now()
for _, tf := range timeframes {
t.Run(tf.name, func(t *testing.T) {
// 使用 coinank 免费 API 获取 ETH K线数据
coinankKlines, err := coinank_api.Kline(ctx, "ETHUSDT", coinank_enum.Binance,
now.UnixMilli(), coinank_enum.To, tf.size, tf.interval)
if err != nil {
t.Fatalf("获取 %s K线失败: %v", tf.name, err)
}
if len(coinankKlines) < 26 {
t.Skipf("K线数量不足: %d", len(coinankKlines))
return
}
// 转换为 market.Kline
klines := coinankKlinesToMarket(coinankKlines)
t.Logf("获取到 %d 根 ETH %s K线", len(klines), tf.name)
t.Logf("最新收盘价: %.2f, 时间: %s",
klines[len(klines)-1].Close,
time.UnixMilli(klines[len(klines)-1].CloseTime).Format("2006-01-02 15:04"))
// 本地计算
localResult := calculateLocalIndicators(klines)
t.Log("\n===== 本地计算 =====")
t.Logf(" EMA12: %.2f, EMA26: %.2f, MACD: %.4f",
localResult.EMA12, localResult.EMA26, localResult.MACD)
t.Logf(" RSI14: %.2f", localResult.RSI14)
t.Logf(" BOLL: %.2f / %.2f / %.2f",
localResult.BOLLUp, localResult.BOLLMid, localResult.BOLLLow)
t.Logf(" ATR14: %.4f", localResult.ATR14)
// AI 计算 - 使用简化 prompt只发收盘价
prompt := buildSimpleIndicatorPrompt(klines)
t.Logf("\nPrompt 长度: %d 字符", len(prompt))
start := time.Now()
resp, err := agent.Chat(ctx, prompt)
elapsed := time.Since(start)
if err != nil {
t.Fatalf("AI 调用失败: %v", err)
}
t.Logf("AI 响应耗时: %v", elapsed)
// 解析 AI 结果
aiResult, err := extractJSONFromResponse(resp.Output.Text)
if err != nil {
t.Logf("AI 原始响应:\n%s", resp.Output.Text[:min(500, len(resp.Output.Text))])
t.Fatalf("解析失败: %v", err)
}
t.Log("\n===== AI 计算 =====")
t.Logf(" EMA12: %.2f, EMA26: %.2f, MACD: %.4f",
aiResult.EMA12, aiResult.EMA26, aiResult.MACD)
t.Logf(" RSI14: %.2f", aiResult.RSI14)
t.Logf(" BOLL: %.2f / %.2f / %.2f",
aiResult.BOLLUp, aiResult.BOLLMid, aiResult.BOLLLow)
// 对比误差
t.Log("\n===== 误差对比 =====")
errors := compareIndicators(localResult, aiResult)
totalErr := 0.0
for name, errPct := range errors {
status := "✓"
if errPct > 1 {
status = "⚠"
}
if errPct > 5 {
status = "✗"
}
t.Logf(" %s %-10s: %.2f%%", status, name, errPct)
totalErr += errPct
}
avgErr := totalErr / float64(len(errors))
t.Logf("\n 平均误差: %.2f%%", avgErr)
if avgErr < 1 {
t.Log(" ✓ AI 计算精度优秀!")
} else if avgErr < 5 {
t.Log(" ⚠ AI 计算精度良好")
} else {
t.Log(" ✗ AI 计算误差较大")
}
// 等待避免请求过快
time.Sleep(2 * time.Second)
})
}
}
// TestQwenETHIndicatorComparison ETH 指标对比:使用 Coinank 免费 API + Qwen 标准 API
func TestQwenETHIndicatorComparison(t *testing.T) {
ctx := context.Background()
agent := NewQwenAgent(QwenAppID, QwenAPIKey)
// 使用 coinank 免费 API 获取 ETH 1小时 K线
now := time.Now()
coinankKlines, err := coinank_api.Kline(ctx, "ETHUSDT", coinank_enum.Binance,
now.UnixMilli(), coinank_enum.To, 30, coinank_enum.Hour1)
if err != nil {
t.Fatalf("获取 K线失败: %v", err)
}
// 转换为 market.Kline
klines := coinankKlinesToMarket(coinankKlines)
t.Logf("获取到 %d 根 ETH 1h K线", len(klines))
// 只用收盘价,简化 prompt
var prices []string
for _, k := range klines {
prices = append(prices, fmt.Sprintf("%.2f", k.Close))
}
// 本地计算
localResult := calculateLocalIndicators(klines)
t.Log("\n===== 本地计算结果 =====")
t.Logf("SMA20: %.2f", localResult.SMA20)
t.Logf("EMA12: %.2f", localResult.EMA12)
t.Logf("EMA26: %.2f", localResult.EMA26)
t.Logf("MACD: %.4f", localResult.MACD)
t.Logf("RSI14: %.2f", localResult.RSI14)
// 简化的 AI prompt
prompt := fmt.Sprintf(`ETH 最近30根1小时K线收盘价从旧到新:
[%s]
请计算以下指标并返回纯 JSON:
1. sma20: 最后20个价格的简单移动平均
2. ema12: 12周期EMA初始值用前12个价格的SMA乘数=2/13
3. ema26: 26周期EMA初始值用前26个价格的SMA乘数=2/27
4. macd: EMA12 - EMA26
5. rsi14: 14周期RSIWilder平滑法
只返回JSON格式: {"sma20":数值,"ema12":数值,"ema26":数值,"macd":数值,"rsi14":数值}
不要任何解释文字`, strings.Join(prices, ", "))
t.Logf("\n发送 Prompt (%d 字符)", len(prompt))
// 使用标准 API
resp, err := agent.ChatWithModel(ctx, "qwen-max", prompt)
if err != nil {
t.Fatalf("AI 调用失败: %v", err)
}
aiText := resp.GetContent()
t.Logf("\nAI 响应:\n%s", aiText)
// 解析
var aiResult struct {
SMA20 float64 `json:"sma20"`
EMA12 float64 `json:"ema12"`
EMA26 float64 `json:"ema26"`
MACD float64 `json:"macd"`
RSI14 float64 `json:"rsi14"`
}
start := strings.Index(aiText, "{")
end := strings.LastIndex(aiText, "}")
if start != -1 && end > start {
if err := json.Unmarshal([]byte(aiText[start:end+1]), &aiResult); err != nil {
t.Logf("JSON 解析失败: %v", err)
}
}
t.Log("\n===== AI 计算结果 =====")
t.Logf("SMA20: %.2f", aiResult.SMA20)
t.Logf("EMA12: %.2f", aiResult.EMA12)
t.Logf("EMA26: %.2f", aiResult.EMA26)
t.Logf("MACD: %.4f", aiResult.MACD)
t.Logf("RSI14: %.2f", aiResult.RSI14)
// 计算误差
t.Log("\n===== 误差 =====")
calcErr := func(name string, local, ai float64) {
if local == 0 {
t.Logf(" %s: 本地=0, AI=%.2f", name, ai)
return
}
errPct := math.Abs(local-ai) / math.Abs(local) * 100
status := "✓"
if errPct > 1 {
status = "⚠"
}
if errPct > 5 {
status = "✗"
}
t.Logf(" %s %s: 本地=%.2f, AI=%.2f, 误差=%.2f%%", status, name, local, ai, errPct)
}
calcErr("SMA20", localResult.SMA20, aiResult.SMA20)
calcErr("EMA12", localResult.EMA12, aiResult.EMA12)
calcErr("EMA26", localResult.EMA26, aiResult.EMA26)
calcErr("MACD", localResult.MACD, aiResult.MACD)
calcErr("RSI14", localResult.RSI14, aiResult.RSI14)
}

144
main.go
View File

@@ -2,14 +2,15 @@ package main
import (
"nofx/api"
"nofx/assistant"
"nofx/auth"
"nofx/backtest"
"nofx/config"
"nofx/crypto"
"nofx/telemetry"
"nofx/experience"
"nofx/logger"
"nofx/manager"
_ "nofx/mcp/payment"
_ "nofx/mcp/provider"
"nofx/mcp"
"nofx/store"
"nofx/telegram"
"os"
@@ -79,6 +80,7 @@ func main() {
logger.Fatalf("❌ Failed to initialize database: %v", err)
}
defer st.Close()
backtest.UseDatabaseWithType(st.DB(), st.DBType() == store.DBTypePostgres)
// Initialize installation ID for experience improvement (anonymous statistics)
initInstallationID(st)
@@ -95,8 +97,13 @@ func main() {
// time.Sleep(500 * time.Millisecond)
logger.Info("📊 Using CoinAnk API for all market data (WebSocket cache disabled)")
// Create TraderManager
// Create TraderManager and BacktestManager
traderManager := manager.NewTraderManager()
mcpClient := newSharedMCPClient()
backtestManager := backtest.NewManager(mcpClient)
if err := backtestManager.RestoreRuns(); err != nil {
logger.Warnf("⚠️ Failed to restore backtest history: %v", err)
}
// Load all traders from database to memory (may auto-start traders with IsRunning=true)
if err := traderManager.LoadTradersFromStore(st); err != nil {
@@ -118,31 +125,64 @@ func main() {
if t.IsRunning {
status = "✅ Running"
}
idShort := t.ID
if len(idShort) > 8 {
idShort = idShort[:8]
}
logger.Infof(" • %s [%s] %s - AI Model: %s, Exchange: %s",
t.Name, idShort, status, t.AIModelID, t.ExchangeID)
logger.Infof(" • %s [%s] %s - AI Model: %s, Exchange: %s",
t.Name, t.ID[:8], status, t.AIModelID, t.ExchangeID)
}
}
// Start API server
server := api.NewServer(traderManager, st, cryptoService, cfg.APIServerPort)
// Create hot-reload channel for Telegram bot; wire it to the API server
// so that POST /api/telegram can trigger a bot restart when the token changes.
telegramReloadCh := make(chan struct{}, 1)
server.SetTelegramReloadCh(telegramReloadCh)
server := api.NewServer(traderManager, st, cryptoService, backtestManager, cfg.APIServerPort)
go func() {
if err := server.Start(); err != nil {
logger.Fatalf("❌ Failed to start API server: %v", err)
}
}()
// Start Telegram bot (if TELEGRAM_BOT_TOKEN is configured)
go telegram.Start(cfg, st, telegramReloadCh)
// Initialize and start Telegram bot (if configured)
var telegramBot *telegram.Bot
telegramConfig := telegram.LoadConfigFromEnv()
if telegramConfig.Token != "" {
logger.Info("🤖 Initializing Smart Trading Assistant...")
// Create AI client for the assistant
aiClient := createAssistantAIClient()
if aiClient == nil {
logger.Error("❌ No AI API key configured, Telegram bot disabled")
} else {
// Create Smart AI Agent with trading context awareness
agentConfig := assistant.DefaultAgentConfig()
smartAgent := assistant.NewSmartAgent(aiClient, agentConfig, traderManager, st)
// Register trading tools
tradingTools := assistant.NewTradingTools(traderManager, st)
smartAgent.RegisterTools(tradingTools.GetAllTools()...)
// Register strategy tools
strategyTools := assistant.NewStrategyTools(st)
smartAgent.RegisterTools(strategyTools.GetAllTools()...)
// Create and start Telegram bot
var err error
telegramBot, err = telegram.NewBot(telegramConfig, smartAgent.Agent)
if err != nil {
logger.Errorf("❌ Failed to create Telegram bot: %v", err)
} else {
// Start background monitor with alert forwarding to Telegram
smartAgent.OnAlert(func(alert assistant.Alert) {
telegramBot.BroadcastAlert(alert.Message)
})
smartAgent.StartMonitor()
go telegramBot.Start()
logger.Info("✅ Smart Trading Assistant started successfully")
logger.Info(" 📊 Real-time context injection: enabled")
logger.Info(" 🔍 Background monitoring: enabled")
logger.Info(" ⚠️ Proactive alerts: enabled")
}
}
} else {
logger.Info(" Telegram bot not configured (set TELEGRAM_BOT_TOKEN to enable)")
}
// Wait for interrupt signal
quit := make(chan os.Signal, 1)
@@ -154,11 +194,75 @@ func main() {
<-quit
logger.Info("📴 Shutdown signal received, closing system...")
// Stop Telegram bot
if telegramBot != nil {
telegramBot.Stop()
}
// Stop all traders
traderManager.StopAll()
logger.Info("✅ System shut down safely")
}
// newSharedMCPClient creates a shared MCP AI client (for backtesting)
func newSharedMCPClient() mcp.AIClient {
apiKey := os.Getenv("DEEPSEEK_API_KEY")
if apiKey == "" {
logger.Warn("⚠️ DEEPSEEK_API_KEY not set, AI features will be unavailable")
return nil
}
return mcp.NewDeepSeekClient()
}
// createAssistantAIClient creates an AI client for the Telegram assistant
// Supports multiple providers based on environment configuration
func createAssistantAIClient() mcp.AIClient {
// Try different providers in order of preference
// 1. DeepSeek (cost-effective, recommended)
if apiKey := os.Getenv("DEEPSEEK_API_KEY"); apiKey != "" {
client := mcp.NewDeepSeekClient()
customURL := os.Getenv("DEEPSEEK_API_URL")
customModel := os.Getenv("DEEPSEEK_MODEL")
client.SetAPIKey(apiKey, customURL, customModel)
logger.Info("🧠 Assistant using DeepSeek AI")
return client
}
// 2. Claude
if apiKey := os.Getenv("CLAUDE_API_KEY"); apiKey != "" {
client := mcp.NewClaudeClient()
customURL := os.Getenv("CLAUDE_API_URL")
customModel := os.Getenv("CLAUDE_MODEL")
client.SetAPIKey(apiKey, customURL, customModel)
logger.Info("🧠 Assistant using Claude AI")
return client
}
// 3. OpenAI
if apiKey := os.Getenv("OPENAI_API_KEY"); apiKey != "" {
client := mcp.NewOpenAIClient()
customURL := os.Getenv("OPENAI_API_URL")
customModel := os.Getenv("OPENAI_MODEL")
client.SetAPIKey(apiKey, customURL, customModel)
logger.Info("🧠 Assistant using OpenAI")
return client
}
// 4. Qwen
if apiKey := os.Getenv("QWEN_API_KEY"); apiKey != "" {
client := mcp.NewQwenClient()
customURL := os.Getenv("QWEN_API_URL")
customModel := os.Getenv("QWEN_MODEL")
client.SetAPIKey(apiKey, customURL, customModel)
logger.Info("🧠 Assistant using Qwen AI")
return client
}
logger.Warn("⚠️ No AI API key configured for assistant")
return nil
}
// initInstallationID initializes the anonymous installation ID for experience improvement
// This ID is persisted in database and used for anonymous usage statistics
func initInstallationID(st *store.Store) {
@@ -180,5 +284,5 @@ func initInstallationID(st *store.Store) {
}
// Set installation ID in experience module
telemetry.SetInstallationID(installationID)
experience.SetInstallationID(installationID)
}

View File

@@ -3,6 +3,8 @@ package manager
import (
"context"
"fmt"
"nofx/debate"
"nofx/kernel"
"nofx/logger"
"nofx/store"
"nofx/trader"
@@ -11,6 +13,27 @@ import (
"time"
)
// TraderExecutorAdapter wraps AutoTrader to implement debate.TraderExecutor
type TraderExecutorAdapter struct {
autoTrader *trader.AutoTrader
}
// ExecuteDecision executes a trading decision
func (a *TraderExecutorAdapter) ExecuteDecision(d *kernel.Decision) error {
return a.autoTrader.ExecuteDecision(d)
}
// GetBalance returns account balance
func (a *TraderExecutorAdapter) GetBalance() (map[string]interface{}, error) {
info, err := a.autoTrader.GetAccountInfo()
if err != nil {
return nil, fmt.Errorf("failed to get account info: %w", err)
}
// Log the balance for debugging
logger.Infof("[Debate] GetBalance for trader, result: %+v", info)
return info, nil
}
// CompetitionCache competition data cache
type CompetitionCache struct {
data map[string]interface{}
@@ -384,6 +407,7 @@ func (tm *TraderManager) GetTopTradersData() (map[string]interface{}, error) {
return result, nil
}
// RemoveTrader removes a trader from memory (does not affect database)
// Used to force reload when updating trader configuration
// If the trader is running, it will be stopped first
@@ -640,11 +664,11 @@ func (tm *TraderManager) addTraderFromStore(traderCfg *store.Trader, aiModelCfg
QwenKey: "",
CustomAPIURL: aiModelCfg.CustomAPIURL,
CustomModelName: aiModelCfg.CustomModelName,
ScanInterval: time.Duration(traderCfg.ScanIntervalMinutes) * time.Minute,
InitialBalance: traderCfg.InitialBalance,
IsCrossMargin: traderCfg.IsCrossMargin,
ShowInCompetition: traderCfg.ShowInCompetition,
StrategyConfig: strategyConfig,
ScanInterval: time.Duration(traderCfg.ScanIntervalMinutes) * time.Minute,
InitialBalance: traderCfg.InitialBalance,
IsCrossMargin: traderCfg.IsCrossMargin,
ShowInCompetition: traderCfg.ShowInCompetition,
StrategyConfig: strategyConfig,
}
logger.Infof("📊 Loading trader %s: ScanIntervalMinutes=%d (from DB), ScanInterval=%v",
@@ -666,17 +690,9 @@ func (tm *TraderManager) addTraderFromStore(traderCfg *store.Trader, aiModelCfg
traderConfig.BitgetAPIKey = string(exchangeCfg.APIKey)
traderConfig.BitgetSecretKey = string(exchangeCfg.SecretKey)
traderConfig.BitgetPassphrase = string(exchangeCfg.Passphrase)
case "gate":
traderConfig.GateAPIKey = string(exchangeCfg.APIKey)
traderConfig.GateSecretKey = string(exchangeCfg.SecretKey)
case "kucoin":
traderConfig.KuCoinAPIKey = string(exchangeCfg.APIKey)
traderConfig.KuCoinSecretKey = string(exchangeCfg.SecretKey)
traderConfig.KuCoinPassphrase = string(exchangeCfg.Passphrase)
case "hyperliquid":
traderConfig.HyperliquidPrivateKey = string(exchangeCfg.APIKey)
traderConfig.HyperliquidWalletAddr = exchangeCfg.HyperliquidWalletAddr
traderConfig.HyperliquidUnifiedAcct = exchangeCfg.HyperliquidUnifiedAcct
case "aster":
traderConfig.AsterUser = exchangeCfg.AsterUser
traderConfig.AsterSigner = exchangeCfg.AsterSigner
@@ -687,9 +703,6 @@ func (tm *TraderManager) addTraderFromStore(traderCfg *store.Trader, aiModelCfg
traderConfig.LighterAPIKeyPrivateKey = string(exchangeCfg.LighterAPIKeyPrivateKey)
traderConfig.LighterAPIKeyIndex = exchangeCfg.LighterAPIKeyIndex
traderConfig.LighterTestnet = exchangeCfg.Testnet
case "indodax":
traderConfig.IndodaxAPIKey = string(exchangeCfg.APIKey)
traderConfig.IndodaxSecretKey = string(exchangeCfg.SecretKey)
}
// Set API keys based on AI model (convert EncryptedString to string)
@@ -741,3 +754,12 @@ func (tm *TraderManager) addTraderFromStore(traderCfg *store.Trader, aiModelCfg
return nil
}
// GetTraderExecutor returns a TraderExecutor for the given trader ID
// This is used by the debate module to execute consensus trades
func (tm *TraderManager) GetTraderExecutor(traderID string) (debate.TraderExecutor, error) {
at, err := tm.GetTrader(traderID)
if err != nil {
return nil, err
}
return &TraderExecutorAdapter{autoTrader: at}, nil
}

Some files were not shown because too many files have changed in this diff Show More