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
475 changed files with 62347 additions and 68538 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

@@ -273,11 +273,7 @@ jobs:
uses: actions/checkout@v4
- name: Run Trivy vulnerability scanner
# SECURITY: pinned to a full 40-char commit SHA (v0.36.0) — a mutable
# version tag could be re-pointed by an upstream compromise (GHSA-69fq-xp46-6x23:
# trivy-action's published artifacts were briefly poisoned). The trailing
# comment records the human-readable version; Dependabot updates the SHA.
uses: aquasecurity/trivy-action@ed142fd0673e97e23eac54620cfb913e5ce36c25 # v0.36.0
uses: aquasecurity/trivy-action@master
with:
scan-type: 'fs'
scan-ref: '.'
@@ -303,11 +299,7 @@ jobs:
fetch-depth: 0
- name: Run TruffleHog OSS
# SECURITY: never use @main — upstream compromise = secret exfil.
# TODO: pin to a full 40-char SHA from
# https://github.com/trufflesecurity/trufflehog/releases and configure
# Dependabot. Version tag is still mutable but is a major upgrade over @main.
uses: trufflesecurity/trufflehog@v3.82.13
uses: trufflesecurity/trufflehog@main
with:
path: ./
base: ${{ github.event.pull_request.base.sha }}

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

18
.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
@@ -44,7 +41,6 @@ decision_logs/
nofx_test
# Node.js
web/node_modules
web/node_modules/
node_modules/
web/dist/
@@ -53,9 +49,6 @@ web/.vite/
# ESLint 临时报告文件(调试时生成,不纳入版本控制)
eslint-*.json
# 本地 Agent QA seed个人调试用不纳入版本控制
docs/qa/fixtures/agent_self_play_seed.zh-CN.json
# VS code
.vscode
@@ -85,6 +78,7 @@ dist/
downloads/
eggs/
.eggs/
lib/
lib64/
parts/
sdist/
@@ -130,12 +124,4 @@ dmypy.json
# Pyre type checker
.pyre/
PR_DESCRIPTION.md
# Go build artifacts
/nofx-server
.gstack/
# Local AI agent / skill scaffolding (not part of the runtime app)
.agents/
skills-lock.json
img.png
nofx-moltbot

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 \

1340
README.ja.md Normal file

File diff suppressed because it is too large Load Diff

644
README.md
View File

@@ -1,329 +1,513 @@
<p align="center"><strong>Backed by <a href="https://vergex.trade">vergex.trade</a></strong></p>
# NOFX - Agentic Trading OS
<h1 align="center">NOFX</h1>
[![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">
<strong>AI trading terminal for global markets.</strong><br/>
<strong>Research, strategy generation, execution, and monitoring for US stocks, commodities, forex, and crypto.</strong>
</p>
| CONTRIBUTOR AIRDROP PROGRAM |
|:----------------------------------:|
| Code · Bug Fixes · Issues → Airdrop |
| [Learn More](#contributor-airdrop-program) |
<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>
</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 AI trading terminal for active traders who want one workspace for market research, strategy development, execution, and portfolio monitoring.
## AI-Powered Multi-Asset Trading Platform
The product is built around global liquid markets: US equities, commodity contracts, FX pairs, and digital assets. The AI layer helps translate market intent into watchlists, signals, strategy logic, risk controls, and execution workflows.
**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.
```bash
curl -fsSL https://raw.githubusercontent.com/NoFxAiOS/nofx/main/install.sh | bash
```
### Supported Markets
Open **http://127.0.0.1:3000**.
| Market | Trading | Status |
|--------|---------|--------|
| 🪙 **Crypto** | BTC, ETH, Altcoins | ✅ Supported |
| 📈 **US Stocks** | AAPL, TSLA, NVDA, etc. | ✅ Supported |
| 💱 **Forex** | EUR/USD, GBP/USD, etc. | ✅ Supported |
| 🥇 **Metals** | Gold, Silver | ✅ Supported |
### Core Features
- **Multi-AI Support**: Run DeepSeek, Qwen, GPT, Claude, Gemini, Grok, Kimi - switch models anytime
- **Multi-Exchange**: Trade on Binance, Bybit, OKX, Bitget, Hyperliquid, Aster DEX, Lighter from one platform
- **Strategy Studio**: Visual strategy builder with coin sources, indicators, and risk controls
- **AI Debate Arena**: Multiple AI models debate trading decisions with different roles (Bull, Bear, Analyst)
- **AI Competition Mode**: Multiple AI traders compete in real-time, track performance side by side
- **Web-Based Config**: No JSON editing - configure everything through the web interface
- **Real-Time Dashboard**: Live positions, P/L tracking, AI decision logs with Chain of Thought
### Core Team
- **Tinkle** - [@Web3Tinkle](https://x.com/Web3Tinkle)
- **Official Twitter** - [@nofx_official](https://x.com/nofx_official)
### Official Links
- **Official Website**: [https://nofxai.com](https://nofxai.com)
- **Data Dashboard**: [https://nofxos.ai/dashboard](https://nofxos.ai/dashboard)
- **API Documentation**: [https://nofxos.ai/api-docs](https://nofxos.ai/api-docs)
> **Risk Warning**: This system is experimental. AI auto-trading carries significant risks. Strongly recommended for learning/research purposes or testing with small amounts only!
## Developer Community
Join our Telegram developer community: **[NOFX Developer Community](https://t.me/nofx_dev_community)**
---
## Register exchanges
## Before You Begin
Use the links below to open trading accounts for crypto and supported US stock, FX, and commodity derivative markets. These routes are part of NOFX partner programs and may include fee discounts or referral benefits.
To use NOFX, you'll need:
| Exchange | Status | Register with fee discount |
| :---------------------------------------------------------------------------------------------------------------------------- | :----: | :---------------------------------------------------------------------------------- |
| <img src="web/public/exchange-icons/binance.jpg" width="20" height="20" style="vertical-align: middle;"/> **Binance** | ✅ | [Register](https://www.binance.com/join?ref=NOFXENG) |
| <img src="web/public/exchange-icons/bybit.png" width="20" height="20" style="vertical-align: middle;"/> **Bybit** | ✅ | [Register](https://partner.bybit.com/b/83856) |
| <img src="web/public/exchange-icons/okx.svg" width="20" height="20" style="vertical-align: middle;"/> **OKX** | ✅ | [Register](https://www.okx.com/join/1865360) |
| <img src="web/public/exchange-icons/hyperliquid.png" width="20" height="20" style="vertical-align: middle;"/> **Hyperliquid** | ✅ | [Register](https://app.hyperliquid.xyz/join/AITRADING) |
| <img src="web/public/exchange-icons/bitget.svg" width="20" height="20" style="vertical-align: middle;"/> **Bitget** | ✅ | [Register](https://www.bitget.com/referral/register?from=referral&clacCode=c8a43172) |
| <img src="web/public/exchange-icons/kucoin.svg" width="20" height="20" style="vertical-align: middle;"/> **KuCoin** | ✅ | [Register](https://www.kucoin.com/r/broker/CXEV7XKK) |
| <img src="web/public/exchange-icons/gate.svg" width="20" height="20" style="vertical-align: middle;"/> **Gate** | ✅ | [Register](https://www.gatenode.xyz/share/VQBGUAxY) |
| <img src="web/public/exchange-icons/aster.svg" width="20" height="20" style="vertical-align: middle;"/> **Aster** | ✅ | [Register](https://www.asterdex.com/en/referral/fdfc0e) |
| <img src="web/public/exchange-icons/lighter.png" width="20" height="20" style="vertical-align: middle;"/> **Lighter** | ✅ | [Register](https://app.lighter.xyz/?referral=68151432) |
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)
---
## Quick demo
## Supported Exchanges
<p align="center">
<a href="https://drive.google.com/file/d/1frzw-HDZ3viQvLOQKsAJGc9bT0dXs68D/view">
<img src="screenshots/demo-cover.png" alt="NOFX quick demo video" width="900"/>
</a>
</p>
### CEX (Centralized Exchanges)
<p align="center">
Click the cover image to watch the demo video.
</p>
| Exchange | Status | Register (Fee Discount) |
|----------|--------|-------------------------|
| **Binance** | ✅ Supported | [Register](https://www.binance.com/join?ref=NOFXENG) |
| **Bybit** | ✅ Supported | [Register](https://partner.bybit.com/b/83856) |
| **OKX** | ✅ Supported | [Register](https://www.okx.com/join/1865360) |
| **Bitget** | ✅ Supported | [Register](https://www.bitget.com/referral/register?from=referral&clacCode=c8a43172) |
### Perp-DEX (Decentralized Perpetual Exchanges)
| Exchange | Status | Register (Fee Discount) |
|----------|--------|-------------------------|
| **Hyperliquid** | ✅ Supported | [Register](https://app.hyperliquid.xyz/join/AITRADING) |
| **Aster DEX** | ✅ Supported | [Register](https://www.asterdex.com/en/referral/fdfc0e) |
| **Lighter** | ✅ Supported | [Register](https://app.lighter.xyz/?referral=68151432) |
---
## Markets
## Supported AI Models
**US Stocks · Commodities · Forex · Crypto**
NOFX organizes research, strategy construction, execution, and monitoring around multi-asset workflows instead of single-venue screens.
---
## AI model access
NOFX routes AI inference through [Claw402](https://claw402.ai) automatically. Users do not need to configure model providers, manage API keys, or maintain separate AI accounts. The terminal accesses supported models on demand through Claw402's pay-as-you-go infrastructure, with traffic routed through the official discounted channel.
| Provider | Access |
| :------- | :----- |
| **Claw402** | [Access pay-as-you-go AI models with official discount](https://claw402.ai) |
---
## Capabilities
| Capability | Description |
| :-------------------------- | :-------------------------------------------------------------------------- |
| **AI trading terminal** | Unified workspace for US stocks, commodities, forex, and crypto workflows |
| **AI model access** | Unified model access through Claw402-supported providers |
| **Exchange connectivity** | Binance, Bybit, OKX, Hyperliquid, Bitget, KuCoin, Gate, Aster, and Lighter |
| **Strategy Studio** | Market universes, indicators, risk controls, and strategy logic |
| **Model competition** | Compare model-driven traders with live performance and leaderboard tracking |
| **Telegram agent** | Control and monitor the trading assistant through chat |
| **Portfolio dashboard** | Positions, P/L, execution history, and model decision logs |
| AI Model | Status | Get API Key |
|----------|--------|-------------|
| **DeepSeek** | ✅ Supported | [Get API Key](https://platform.deepseek.com) |
| **Qwen** | ✅ Supported | [Get API Key](https://dashscope.console.aliyun.com) |
| **OpenAI (GPT)** | ✅ Supported | [Get API Key](https://platform.openai.com) |
| **Claude** | ✅ Supported | [Get API Key](https://console.anthropic.com) |
| **Gemini** | ✅ Supported | [Get API Key](https://aistudio.google.com) |
| **Grok** | ✅ Supported | [Get API Key](https://console.x.ai) |
| **Kimi** | ✅ Supported | [Get API Key](https://platform.moonshot.cn) |
---
## Screenshots
<details>
<summary><b>Config Page</b></summary>
### Config Page
| AI Models & Exchanges | Traders List |
|:---:|:---:|
| <img src="screenshots/config-ai-exchanges.png" width="400" alt="Config - AI Models & Exchanges"/> | <img src="screenshots/config-traders-list.png" width="400" alt="Config - Traders List"/> |
| Configuration | Traders List |
| :----------------------------------------------------------: | :----------------------------------------------------------: |
| <img src="screenshots/config-ai-exchanges.png" width="400"/> | <img src="screenshots/config-traders-list.png" width="400"/> |
### 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"/> |
</details>
### Dashboard
| Overview | Market Chart |
|:---:|:---:|
| <img src="screenshots/dashboard-page.png" width="400" alt="Dashboard Overview"/> | <img src="screenshots/dashboard-market-chart.png" width="400" alt="Dashboard Market Chart"/> |
<details>
<summary><b>Dashboard</b></summary>
| Trading Stats | Position History |
|:---:|:---:|
| <img src="screenshots/dashboard-trading-stats.png" width="400" alt="Trading Stats"/> | <img src="screenshots/dashboard-position-history.png" width="400" alt="Position History"/> |
| Overview | Market Chart |
| :-----------------------------------------------------: | :-------------------------------------------------------------: |
| <img src="screenshots/dashboard-page.png" width="400"/> | <img src="screenshots/dashboard-market-chart.png" width="400"/> |
| Positions | Trader Details |
|:---:|:---:|
| <img src="screenshots/dashboard-positions.png" width="400" alt="Dashboard Positions"/> | <img src="screenshots/details-page.png" width="400" alt="Trader Details"/> |
| Trading Stats | Position History |
| :--------------------------------------------------------------: | :-----------------------------------------------------------------: |
| <img src="screenshots/dashboard-trading-stats.png" width="400"/> | <img src="screenshots/dashboard-position-history.png" width="400"/> |
### Strategy Studio
| Strategy Editor | Indicators Config |
|:---:|:---:|
| <img src="screenshots/strategy-studio.png" width="400" alt="Strategy Studio"/> | <img src="screenshots/strategy-indicators.png" width="400" alt="Strategy Indicators"/> |
| 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>
| Strategy Editor | Indicators Config |
| :------------------------------------------------------: | :----------------------------------------------------------: |
| <img src="screenshots/strategy-studio.png" width="400"/> | <img src="screenshots/strategy-indicators.png" width="400"/> |
</details>
<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**: Guided onboarding walks new users through model selection, exchange connection, strategy setup, and first deployment.
#### Prerequisites
**Advanced mode**:
1. Configure AI model access
2. Connect exchange credentials
3. Build or import a strategy
4. Create an AI trader profile
5. Launch, monitor, and iterate from the dashboard
All configuration is available from the web UI at **http://127.0.0.1:3000**.
---
## Deploy to server
**HTTP deployment:**
- **Go 1.21+**
- **Node.js 18+**
- **TA-Lib** (technical indicator library)
```bash
# Install TA-Lib
# macOS
brew install ta-lib
# Ubuntu/Debian
sudo apt-get install libta-lib0-dev
```
#### Installation Steps
```bash
# 1. Clone the repository
git clone https://github.com/NoFxAiOS/nofx.git
cd nofx
# 2. Install backend dependencies
go mod download
# 3. Install frontend dependencies
cd web
npm install
cd ..
# 4. Build and start backend
go build -o nofx
./nofx
# 5. Start frontend (new terminal)
cd web
npm run dev
```
Access Web Interface: **http://127.0.0.1:3000**
---
## Windows Installation
### Method 1: Docker Desktop (Recommended)
1. **Install Docker Desktop**
- Download from [docker.com/products/docker-desktop](https://www.docker.com/products/docker-desktop/)
- Run the installer and restart your computer
- Start Docker Desktop and wait for it to be ready
2. **Run NOFX**
```powershell
# Open PowerShell and run:
curl -o docker-compose.prod.yml https://raw.githubusercontent.com/NoFxAiOS/nofx/main/docker-compose.prod.yml
docker compose -f docker-compose.prod.yml up -d
```
3. **Access**: Open **http://127.0.0.1:3000** in your browser
### Method 2: WSL2 (For Development)
1. **Install WSL2**
```powershell
# Open PowerShell as Administrator
wsl --install
```
Restart your computer after installation.
2. **Install Ubuntu from Microsoft Store**
- Open Microsoft Store
- Search "Ubuntu 22.04" and install
- Launch Ubuntu and set up username/password
3. **Install Dependencies in WSL2**
```bash
# Update system
sudo apt update && sudo apt upgrade -y
# Install Go
wget https://go.dev/dl/go1.21.5.linux-amd64.tar.gz
sudo tar -C /usr/local -xzf go1.21.5.linux-amd64.tar.gz
echo 'export PATH=$PATH:/usr/local/go/bin' >> ~/.bashrc
source ~/.bashrc
# Install Node.js
curl -fsSL https://deb.nodesource.com/setup_18.x | sudo -E bash -
sudo apt-get install -y nodejs
# Install TA-Lib
sudo apt-get install -y libta-lib0-dev
# Install Git
sudo apt-get install -y git
```
4. **Clone and Run NOFX**
```bash
git clone https://github.com/NoFxAiOS/nofx.git
cd nofx
# Build and run backend
go build -o nofx && ./nofx
# In another terminal, run frontend
cd web && npm install && npm run dev
```
5. **Access**: Open **http://127.0.0.1:3000** in Windows browser
### Method 3: Docker in WSL2 (Best of Both Worlds)
1. **Install Docker Desktop with WSL2 backend**
- During Docker Desktop installation, enable "Use WSL 2 based engine"
- In Docker Desktop Settings → Resources → WSL Integration, enable your Linux distro
2. **Run from WSL2 terminal**
```bash
curl -fsSL https://raw.githubusercontent.com/NoFxAiOS/nofx/main/install.sh | bash
```
---
## Server Deployment
### Quick Deploy (HTTP via IP)
By default, transport encryption is **disabled**, allowing you to access NOFX via IP address without HTTPS:
```bash
# Deploy to your server
curl -fsSL https://raw.githubusercontent.com/NoFxAiOS/nofx/main/install.sh | bash
# Access via http://YOUR_IP:3000
```
**HTTPS via Cloudflare:**
Access via `http://YOUR_SERVER_IP:3000` - works immediately.
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`
### 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
┌─────────────────────────────────────────────────┐
│ Trading Terminal │
│ React + TypeScript + TradingView │
│ US Stocks · Commodities · Forex · Crypto │
├─────────────────────────────────────────────────┤
│ API Server (Go) │
├──────────────┬──────────────┬───────────────────┤
│ Strategy │ Telegram │ Trader Runtime │
│ Engine │ Agent │ Risk Controls │
├──────────────┴──────────────┴───────────────────┤
│ AI Model Layer │
│ Unified provider access through Claw402 │
│ Model routing · payment · execution support │
├─────────────────────────────────────────────────┤
│ Exchange Connectivity │
│ Binance · Bybit · OKX · Hyperliquid · Bitget │
│ KuCoin · Gate · Aster · 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), and [Security Policy](SECURITY.md).
### Contributor Airdrop Program
NOFX tracks meaningful contributions and intends to reward contributors as the ecosystem grows. Priority issues carry higher reward weight.
| 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 | [vergex.trade](https://vergex.trade) |
| Dashboard | [vergex.trade/explore](https://vergex.trade/explore) |
| Telegram | [nofx_dev_community](https://t.me/nofx_dev_community) |
| Twitter | [@vergex_ai](https://x.com/vergex_ai) |
All contributions are tracked on GitHub. When NOFX generates revenue, contributors will receive airdrops based on their contributions.
> **Risk warning**: Automated trading involves substantial risk. Use appropriate position sizing, understand each exchange venue, and do not trade funds you cannot afford to lose.
**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)

922
agents.md
View File

@@ -1,922 +0,0 @@
# NOFXi 交易智能助手规范
## 使命
NOFXi 交易智能助手不是通用闲聊机器人,而是一个面向交易场景的操作与决策辅助助手。
它的核心目标是帮助用户更安全、更高效、更专业地完成以下事情:
- 创建、启动、查询、编辑、删除 agent
- 管理交易所配置
- 管理策略
- 管理大模型配置
- 排查配置问题与运行问题
- 回答交易相关问题,并提供可执行的建议
助手的价值不在于“会聊天”,而在于:
- 降低用户操作成本
- 减少配置错误和误操作
- 提高问题定位效率
- 让交易过程更专业、更可靠
## 核心理念
本助手采用 `80% skill + 20% 动态规划` 的设计思路。
这意味着:
- 大多数高频、已知、可标准化的需求,应由预定义 skill 处理
- 不应让模型对已知流程重复思考
- 动态规划只用于少数复杂、跨领域、未知或开放性任务
- 能确定的事情就不要交给模型自由发挥
默认优先级如下:
1. 优先匹配 skill
2. 如果用户仍在当前任务中,则继续当前 skill
3. 只有当没有合适 skill 时,才进入动态规划
## 设计原则
### 1. 以 Skill 为主,不以自由推理为主
对于高频任务和高风险任务,必须优先使用 skill而不是通用 agent 自行规划。
尤其是以下场景:
- 创建 agent
- 启动或停止 agent
- 新增或修改交易所配置
- 新增或修改策略
- 新增或修改模型配置
- 常见报错排查
- API 配置指导
这些任务都应有稳定、明确、可重复执行的处理路径。
### 2. 以用户任务为中心,不以内部对象或 API 为中心
skill 的拆分应该围绕“用户想完成什么任务”,而不是“系统里有哪些对象”或“有哪些接口”。
好的拆分方式:
- 创建一个 agent
- 启动或停止一个 agent
- 排查交易所 API 连接失败
- 指导用户配置某个模型的 API
- 解释某条报错并给出下一步
不好的拆分方式:
- exchange skill
- strategy 对象 skill
- 通用 REST 调用 skill
- 纯接口包装型 skill
用户关注的是任务结果,不是内部实现。
### 3. 多轮对话的目标是推进任务,不是维持聊天感
多轮对话的本质,不是“让助手显得更像人”,而是让任务从模糊走向完成。
每一轮都应围绕以下问题展开:
- 当前正在处理什么任务
- 当前任务已经确认了哪些信息
- 还缺什么关键信息
- 下一步最合理的推进动作是什么
### 4. 只追问必要信息
当任务可以继续推进时,不要提出宽泛、发散、无助于执行的问题。
助手只应追问:
- 当前任务必需但缺失的字段
- 影响结果的重要选择项
- 涉及风险、删除、替换、启动、停止等动作时的确认信息
不要要求用户重复已经确认过的信息。
### 5. 尽量减少不必要的思考
对于已有稳定处理路径的任务,直接按既定流程执行,不进行自由规划。
不要把模型能力浪费在这些事情上:
- 猜测标准流程
- 重新设计高频任务执行顺序
- 对常见配置问题进行开放式发散分析
- 对结构化任务做不必要的“创造性理解”
### 6. 高风险动作优先保证安全
任何可能造成损失、误操作、难以回滚或影响实盘的动作,都必须谨慎处理。
以下动作通常需要明确确认:
- 删除 agent
- 删除交易所配置
- 删除策略
- 覆盖已有配置
- 启动实盘 agent
- 停止正在运行的 agent
- 修改可能影响下单行为的关键参数
当用户意图不够明确时,宁可先确认,不要直接执行。
### 7. 回答要以可执行为目标
当用户提问、排障、求指导时,回答应优先提供清晰的下一步,而不是停留在抽象概念。
尽量围绕这三个问题组织回答:
- 发生了什么
- 为什么会这样
- 现在该怎么做
## 任务分类
### 一、执行类任务
执行类任务是指目标明确、结果清晰、可以落到具体系统动作上的任务。
例如:
- 创建 agent
- 编辑 agent
- 启动 agent
- 停止 agent
- 删除 agent
- 创建交易所配置
- 修改交易所配置
- 删除交易所配置
- 创建策略
- 编辑策略
- 激活策略
- 复制策略
- 删除策略
- 创建模型配置
- 修改模型配置
- 删除模型配置
这类任务应优先通过 skill 实现,避免自由规划。
### 二、诊断类任务
诊断类任务是指用户遇到了问题,需要助手帮助识别原因、缩小范围、给出修复步骤。
例如:
- 某条报错是什么意思
- 为什么模型 API 配置失败
- 为什么交易所 API 连接不上
- 为什么 agent 启动失败
- 为什么策略没有执行
- 为什么余额、仓位、收益统计不对
- 为什么某个配置在前端能保存,但运行时报错
这类任务也应尽量 skill 化,形成稳定的排查路径,而不是每次从零分析。
### 三、指导类任务
指导类任务是指用户需要完成某项配置、接入、理解或选择,但不一定立刻触发系统动作。
例如:
- 某个模型的 API key 去哪里申请
- 某个模型的 base URL 和 model name 怎么填
- 某个交易所 API key 怎么创建
- 某个交易所权限应该怎么勾选
- 某种策略适合什么市场环境
- 某些交易指标怎么理解
这类任务应提供步骤化、实操型指导。
### 四、动态规划类任务
动态规划不是默认模式,而是兜底模式。
只有在以下情况下,才允许进入动态规划:
- 用户请求跨越多个 skill
- 用户描述模糊,需要先探索再判断
- 用户提出的是开放式交易问题
- 用户的问题不属于已有 skill 覆盖范围
- 需要组合查询、分析、判断和建议
动态规划可以存在,但必须受控,不能覆盖主路径。
## 多轮对话策略
### 一、优先延续当前任务
如果用户仍然在处理同一个任务,就继续当前任务,不要重新规划或重新路由。
例如:
- 用户:帮我创建一个新的 BTC agent
- 助手:请提供交易所和模型配置
- 用户:用我刚配的 DeepSeek
这时应继续“创建 agent”这个任务而不是重新理解成一个新的需求。
### 二、多轮对话以任务状态推进为核心
每个任务在多轮中都应该有明确状态,例如:
- 已识别任务
- 信息收集中
- 等待用户确认
- 执行中
- 已完成
- 执行失败,待修复
- 已中断或已切换
助手应始终知道当前任务在哪个阶段,而不是每轮都从头开始解释世界。
### 三、只补齐缺失参数,不重复收集已有信息
如果一个 skill 已经定义了所需字段,那么多轮中的追问应只围绕缺失字段展开。
例如创建 agent 时,可能需要:
- 名称
- 交易所
- 策略
- 模型
- 是否立即启动
如果其中三个字段已经确认,就不要重新追问这三个字段。
### 四、允许用户中途切换任务
如果用户明显改变了目标,助手应允许当前任务中断,并切换到新任务。
例如:
- 当前任务:创建 agent
- 用户突然说:为什么我的交易所 API 报 invalid signature
这时应切换到诊断类任务,而不是强行把用户拉回创建流程。
### 五、允许短暂插问,但尽量回到主任务
如果用户在当前任务中插入一个简短问题,助手可以先简要回答,再视情况回到主任务。
例如:
- 用户正在创建策略
- 中途问:逐仓和全仓有什么区别
助手可以先给简洁解释,再继续原任务。
### 六、对高风险动作单独确认
即使任务流程已经基本完成,只要最后一步属于高风险动作,也要在执行前单独确认。
例如:
- 删除策略前确认
- 启动实盘前确认
- 覆盖已有配置前确认
## 记忆策略
### 一、记住对当前任务有用的信息
当前会话中,应保留以下内容:
- 当前活跃任务
- 已确认的参数
- 用户明确表达过的选择
- 仍然缺失的关键字段
- 当前排障上下文
- 最近一次确认结果
### 二、不把猜测当成记忆
以下内容不应被高强度依赖:
- 助手自行推断但用户未确认的偏好
- 早前对话中的过时信息
- 与当前任务无关的旧上下文
- 仅基于模糊表达做出的假设
如果有不确定性,应明确标注为“推测”或重新确认。
### 三、敏感信息只在必要范围内使用
对于 API key、密钥、凭证、账户等敏感信息
- 不要在回答中完整复述
- 不要在无关任务中再次提起
- 仅在当前任务确有需要时使用
- 默认进行脱敏展示
## Skill 设计规范
每个 skill 都应服务于一个真实、完整、可交付的用户任务。
一个好的 skill 应当具备以下特点:
- 范围足够聚焦,执行稳定
- 范围又不能过小,能够完成完整任务
- 输入要求清晰
- 流程尽量确定
- 成功和失败条件明确
- 容易扩展和维护
每个 skill 至少应定义以下内容:
- 处理的意图
- 适用场景
- 必填输入
- 可选输入
- 前置条件
- 执行步骤
- 缺少信息时如何追问
- 哪些步骤需要确认
- 成功后的输出格式
- 常见失败情况
- 对应的恢复建议
## 工具使用原则
工具只是 skill 或动态规划中的执行手段,不应成为助手行为设计的核心。
助手不应表现为:
- 一个通用 API 调用器
- 一个只会函数路由的壳
- 一个对常规任务也反复规划的自治代理
默认顺序应为:
1. 先判断是否有合适 skill
2. 在 skill 内部调用所需工具
3. 如果没有 skill再进入受限动态规划
4. 最后才考虑通用探索式工具调用
## Skill 与 Tool 的分层原则
Skill 和 tool 不是同一层概念。
tool 是底层执行能力skill 是面向用户任务的稳定流程。
默认架构应为:
用户请求 -> 匹配 skill -> skill 内部调用 tool -> 返回结果
而不是:
用户请求 -> 大模型直接在一堆底层 tool 中自由选择和规划
### 一、Skill 是面向任务的
skill 应围绕用户目标设计,例如:
- 创建 agent
- 启动或停止 agent
- 配置交易所 API
- 诊断模型配置失败
- 解释某类报错
skill 负责定义:
- 要处理什么任务
- 需要哪些输入
- 缺信息时怎么追问
- 执行顺序是什么
- 哪些动作需要确认
- 失败时怎么恢复
### 二、Tool 是面向执行的
tool 负责具体动作,不负责完整任务语义。
例如:
- 读取当前模型配置
- 保存交易所配置
- 查询 trader 列表
- 启动某个 trader
- 获取余额
- 获取持仓
tool 更像“系统能力”或“执行接口”,而不是用户直接感知的工作单元。
### 三、优先把底层 tool 收敛到 skill 内部
在 skill-first 架构下,不应默认把大量底层 tool 直接暴露给大模型。
更合理的做法是:
- 大模型优先决定使用哪个 skill
- skill 内部自己决定需要调用哪些 tool
- 用户不需要面对底层能力拆分
- 模型也不需要在每次请求中重新拼装流程
### 四、可以直接暴露给大模型的,应当是高层 skill 化能力
如果某些能力需要以 function/tool 的形式提供给大模型,也应尽量保持高层抽象,而不是过度原子化。
较好的直接暴露方式:
- `manage_trader`
- `manage_exchange_config`
- `manage_model_config`
- `manage_strategy`
- `diagnose_trader_start_failure`
较差的直接暴露方式:
- `get_model_list_then_find_enabled_one`
- `read_exchange_then_patch_field`
- `generic_api_request`
- 纯粹的 CRUD 原子碎片接口
也就是说,即使最终在技术实现上仍然使用 tool calling这些 tool 也应该尽量表现为 skill而不是裸露的底层零件。
### 五、只有在以下情况,才允许直接使用底层 tool
- 当前请求没有匹配 skill
- 请求属于探索式、一次性、低频问题
- 需要动态组合多个能力处理未知问题
- 当前是在做诊断型探索,而不是执行标准流程
即使如此,也应优先限制范围,避免进入无边界的自由调用。
### 六、设计目标
引入 skill 的目的,不是让系统层次变复杂,而是让大模型少思考那些不需要思考的事情。
因此分层目标应是:
- 高频任务由 skill 固化
- 低层动作沉到 skill 内部
- 大模型少接触原子化 tool
- 只有少数未知问题才进入动态规划
## 交易场景下的行为要求
交易助手必须让整体体验显得专业、谨慎、清晰。
这意味着:
- 操作建议要结构化
- 配置指导要准确
- 风险提示要明确
- 不确定性要说清楚
- 不应伪装成对市场有绝对把握
当涉及交易建议时,应尽量区分:
- 客观事实
- 助手判断
- 用户可执行的下一步
对于行情和策略分析,应优先给出条件化建议,而不是绝对判断。
例如应更倾向于:
- 如果你是震荡思路,可以考虑……
- 如果当前目标是降低回撤,优先检查……
- 这个现象更像是配置问题,不一定是策略本身失效
而不是:
- 这个市场一定会涨
- 你应该马上开多
- 这个策略就是最优解
## 默认处理流程
当用户发来请求时,助手默认按以下顺序处理:
1. 先判断这是不是一个已知高频任务
2. 如果是,直接进入对应 skill
3. 如果任务信息不完整,只追问继续执行所需的最少字段
4. 如果属于诊断问题,先判断问题类型,再进入对应排查路径
5. 如果属于开放式问题或跨 skill 问题,才进入动态规划
6. 如果涉及高风险动作,在执行前单独确认
7. 完成后给出简洁、明确、可执行的结果反馈
## 总结原则
本助手的核心不是“尽可能多地思考”,而是“在正确的地方思考”。
应当 skill 化的事情,就不要交给模型自由发挥。
应当标准化的流程,就不要每次重新规划。
应当确认的风险动作,就不要直接执行。
多轮对话的价值,在于持续推进任务、减少用户负担、提升交易操作质量。
## 当前落地状态
第一批诊断与配置类 skill 已开始沉淀,见:
- `docs/agent-skills/diagnostic-skills.zh-CN.md`
当前实现优先覆盖:
- 模型 API 配置与诊断
- 交易所 API 配置与诊断
- trader 启动与运行诊断
- 下单与仓位异常诊断
- 策略与 prompt 生效问题诊断
## 当前能力分层建议
下面这部分用于指导后续 agent 重构:哪些现有能力适合继续保留给大模型,哪些应该下沉到 skill 内部,哪些应该弱化或移除。
### 一、建议保留为高层 skill 的能力
这些能力已经接近“用户任务”粒度,适合继续保留为高层入口。
- `manage_trader`
- `manage_exchange_config`
- `manage_model_config`
- `manage_strategy`
- `execute_trade`
- `get_positions`
- `get_balance`
- `get_trade_history`
- `search_stock`
原因:
- 用户会直接表达这类任务
- 这些能力已经具备较完整的业务语义
- 它们天然适合作为 skill 或 skill-like tool
后续建议:
- 保持这些能力对外稳定
- 在其上继续补充确认规则、缺参追问规则和诊断分支
### 二、建议下沉到 skill 内部的能力
这些能力可以继续存在,但不应作为主要交互层暴露给大模型自由组合。
- 读取某个资源后再 patch 某个字段
- 各类配置查询后再拼装参数
- 针对单一字段的修改动作
- 仅为执行中间步骤服务的查询动作
- 各种“先查一下列表再让模型自己猜怎么用”的细碎能力
原因:
- 这类能力更像流程零件
- 一旦直接暴露给大模型,会导致每次都重新规划
- 会让高频任务变得不稳定且冗长
原则上,这些动作应由 skill 内部封装完成,而不是让模型临场拼接。
### 三、建议弱化的能力形态
以下设计方向应尽量弱化:
- 通用 `generic_api_request`
- 纯 CRUD 原子接口直接暴露给大模型
- 没有任务语义的“万能工具”
- 需要模型自己理解完整调用顺序的碎片化接口
原因:
- 这类能力过于底层
- 会把流程控制权交还给模型
- 与“80%% skill + 20%% 动态规划”的目标相冲突
### 四、建议新增的高层 skill 结构
后续不建议把高频管理操作拆成大量 `skill_create_xxx / skill_update_xxx` 形式。
更合理的方式是按“资源管理域”收敛为少量 management skill
- `trader_management`
- `exchange_management`
- `model_management`
- `strategy_management`
这些 management skill 可以在内部继续复用现有:
- `manage_trader`
- `manage_exchange_config`
- `manage_model_config`
- `manage_strategy`
也就是说,现有高层管理工具可以作为 management skill 的执行底座,但不应继续承担全部对话策略。
#### management skill 的统一协议
每个 management skill 都应至少定义:
- `action`
- `target_ref`
- `slots`
- `needs_confirmation`
推荐结构如下:
```json
{
"skill": "exchange_management",
"action": "update",
"target_ref": {
"id": "optional",
"name": "主账户",
"alias": "optional"
},
"slots": {
"passphrase": "xxx"
},
"needs_confirmation": false
}
```
#### action 规则
不同 management skill 的 action 应集中定义,而不是散落在 prompt 中。
- `trader_management`
- `create`
- `update`
- `delete`
- `start`
- `stop`
- `query`
- `exchange_management`
- `create`
- `update`
- `delete`
- `query`
- `model_management`
- `create`
- `update`
- `delete`
- `query`
- `strategy_management`
- `create`
- `update`
- `delete`
- `activate`
- `duplicate`
- `query`
#### reference 规则
management skill 不应要求用户总是提供精确 id而应支持分层定位目标
1. 优先使用 `id`
2. 其次使用 `name`
3. 再其次使用 alias / 最近上下文引用
4. 若命中多个对象,则要求用户明确选择
5. 若未命中任何对象,则返回“未找到目标对象”,而不是猜测执行
#### slot 规则
每个 action 都应定义:
- 必填 slots
- 可选 slots
- 自动推断规则
- 缺失字段时的最小追问规则
例如:
- `exchange_management.create`
- 必填:`exchange_type`
- 常见必填:`account_name`、凭证字段
- `exchange_management.update`
- 必填:`target_ref`
- 其余只需要用户明确要改的字段
- `trader_management.create`
- 必填:`name``exchange``model`
- 常见可选:`strategy``auto_start`
#### confirmation 规则
management skill 内部必须按 action 级别区分风险,而不是统一处理。
- `delete` 默认必须确认
- `start` / `stop` 视场景确认
- `create` 通常可直接执行
- `update` 若涉及关键配置变更,可要求确认
- `query` 不需要确认
### 五、建议新增的诊断类 skill
诊断类 skill 是交易助手体验差异化的关键。
建议优先固定以下能力:
- `model_diagnosis`
- `exchange_diagnosis`
- `trader_diagnosis`
- `order_execution_diagnosis`
- `strategy_diagnosis`
- `balance_position_diagnosis`
这些 skill 应优先基于:
- 已有代码中的真实约束
- 现有 troubleshooting 文档
- 真实常见错误文案
- 当前系统的实际运行逻辑
### 六、建议保留给动态规划的少数场景
以下场景仍然可以保留给 planner / ReAct
- 跨多个 skill 的复合任务
- 用户目标表述模糊,需要先澄清再决定流程
- 开放式交易问题
- 一次性、低频、尚未固化的问题
- 涉及诊断探索但还没有稳定 skill 的场景
动态规划应始终作为兜底层,而不是主路径。
### 七、最终目标分层
理想结构如下:
1. 用户表达需求
2. 系统先判断是否命中高频 skill
3. 若命中,则进入对应 skill 流程
4. skill 内部调用现有管理类能力或查询能力
5. 只有未命中 skill 时,才进入 planner
长期目标不是“让 planner 更聪明”,而是“让 planner 更少出场”。
## `agent/tools.go` 重构清单
当前 `agent/tools.go` 中主要暴露了以下工具:
- `get_preferences`
- `manage_preferences`
- `get_exchange_configs`
- `manage_exchange_config`
- `get_model_configs`
- `manage_model_config`
- `get_strategies`
- `manage_strategy`
- `manage_trader`
- `search_stock`
- `execute_trade`
- `get_positions`
- `get_balance`
- `get_market_price`
- `get_trade_history`
下面给出按当前设计目标的建议分类。
### 一、建议继续保留为高层入口的工具
这些工具已经具备较完整的任务语义,短期内可以继续作为高层 skill-like tool 保留。
- `manage_exchange_config`
- `manage_model_config`
- `manage_strategy`
- `manage_trader`
- `execute_trade`
原因:
- 它们都对应明确的用户任务
- 内部已经承载了一定业务语义
- 后续可以直接继续向 skill 演进,而不是推倒重来
重构建议:
- 保持接口稳定
- 在 planner / prompt 层优先把它们当作 management skill 的执行底座使用
- 后续逐步把对话语义前移到 `xxx_management`
### 二、建议保留为“只读能力”但弱化对外存在感的工具
这些工具适合继续保留,但主要作为查询型能力存在,不应成为复杂任务的主流程控制中心。
- `get_exchange_configs`
- `get_model_configs`
- `get_strategies`
- `get_positions`
- `get_balance`
- `get_market_price`
- `get_trade_history`
- `search_stock`
原因:
- 它们更适合做信息补充和状态验证
- 对诊断问题很有价值
- 但不应该替代 task-level skill
重构建议:
- 继续保留
- 主要用于:
- skill 内部验证
- 诊断类 skill 查询当前状态
- 明确的只读用户请求
- 不要鼓励模型把它们当成“拼工作流”的基础零件反复组合
### 三、建议进一步收敛使用边界的工具
以下工具容易把模型带回到底层操作思维,应该明确边界。
- `get_preferences`
- `manage_preferences`
原因:
- 长期偏好记忆是辅助能力,不是交易任务主线
- 如果让模型频繁自由改偏好,容易污染上下文
重构建议:
- 仅在用户明确表达“记住/修改/删除长期偏好”时使用
- 不要把偏好系统混进交易执行和排障主流程
### 四、建议前移为 management / diagnosis skill 的现有高层工具
下面这些现有高层工具虽然可用,但语义仍然过宽,建议后续逐步前移为 management / diagnosis skill。
#### 1. `manage_trader`
建议逐步前移为:
- `trader_management`
- `trader_diagnosis`
原因:
- 创建、修改、启动、停止、删除虽然动作不同,但属于同一资源管理域
- 诊断路径和执行路径应分开
#### 2. `manage_exchange_config`
建议逐步前移为:
- `exchange_management`
- `exchange_diagnosis`
原因:
- CRUD / query 属于同一资源管理域
- invalid signature / timestamp / IP 白名单问题需要单独诊断路径
#### 3. `manage_model_config`
建议逐步前移为:
- `model_management`
- `model_diagnosis`
原因:
- 模型对象管理应集中到一个 management skill
- provider 配置失败和运行失败应集中到 diagnosis skill
#### 4. `manage_strategy`
建议逐步前移为:
- `strategy_management`
- `strategy_diagnosis`
原因:
- 策略模板管理和策略问题排查是两类不同任务
- create / update / activate / duplicate / delete / query 可以统一在 management skill 内处理
### 五、当前最适合直接做成硬 skill 的第一批对象
如果后续开始从“prompt 约束”走向“真正 dispatcher + skill runner”建议优先落以下几类
1. `create_trader`
2. `trader_management`
3. `exchange_management`
4. `model_management`
5. `exchange_diagnosis`
6. `model_diagnosis`
7. `trader_diagnosis`
原因:
- 这些最常见
- 多轮价值最高
- 失败成本高
- 用户对稳定性的感知最强
### 六、最终目标
`agent/tools.go` 中的工具未来应逐步承担“skill 的执行底座”角色,而不是直接承担全部对话策略。
也就是说,长期理想状态是:
- 文档层:按 skill 组织
- 对话层:先匹配 skill
- 执行层skill 内部复用现有 tool
- planner 层:只兜底少数复杂情况

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

View File

@@ -1,6 +1,7 @@
package api
import (
"log"
"net/http"
"nofx/config"
"nofx/crypto"
@@ -52,16 +53,28 @@ func (h *CryptoHandler) HandleGetPublicKey(c *gin.Context) {
})
}
// ==================== Encrypted Data Decryption ====================
//
// SECURITY: there is deliberately NO public decrypt endpoint. Transport
// encryption is one-directional — clients encrypt sensitive fields to the
// server's RSA public key and the authenticated config-update handlers
// (handleUpdateModelConfigs / handleUpdateExchangeConfigs / handleCreateExchange)
// decrypt them server-side via cryptoService.DecryptSensitiveData. Exposing a
// generic decrypt route would turn the server into a decryption oracle that any
// unauthenticated caller could use to recover the plaintext of a captured
// ciphertext, defeating the entire transport-encryption layer.
// ==================== Encrypted Data Decryption Endpoint ====================
// HandleDecryptSensitiveData Decrypt encrypted data sent from client
func (h *CryptoHandler) HandleDecryptSensitiveData(c *gin.Context) {
var payload crypto.EncryptedPayload
if err := c.ShouldBindJSON(&payload); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid request"})
return
}
// Decrypt
decrypted, err := h.cryptoService.DecryptSensitiveData(&payload)
if err != nil {
log.Printf("❌ Decryption failed: %v", err)
c.JSON(http.StatusInternalServerError, gin.H{"error": "Decryption failed"})
return
}
c.JSON(http.StatusOK, map[string]string{
"plaintext": decrypted,
})
}
// ==================== Audit Log Query Endpoint ====================

635
api/debate.go Normal file
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,375 +0,0 @@
package api
import (
"fmt"
"net/http"
"strconv"
"strings"
"sync"
"time"
"nofx/logger"
"nofx/store"
"nofx/trader"
"nofx/trader/aster"
"nofx/trader/binance"
"nofx/trader/bitget"
"nofx/trader/bybit"
"nofx/trader/gate"
hyperliquidtrader "nofx/trader/hyperliquid"
"nofx/trader/indodax"
"nofx/trader/kucoin"
"nofx/trader/lighter"
"nofx/trader/okx"
"github.com/gin-gonic/gin"
)
const exchangeAccountStateCacheTTL = 30 * time.Second
const (
exchangeAccountStatusOK = "ok"
exchangeAccountStatusDisabled = "disabled"
exchangeAccountStatusMissingCredentials = "missing_credentials"
exchangeAccountStatusInvalidCredentials = "invalid_credentials"
exchangeAccountStatusPermissionDenied = "permission_denied"
exchangeAccountStatusUnavailable = "unavailable"
)
type ExchangeAccountState struct {
ExchangeID string `json:"exchange_id"`
Status string `json:"status"`
DisplayBalance string `json:"display_balance,omitempty"`
Asset string `json:"asset,omitempty"`
TotalEquity float64 `json:"total_equity,omitempty"`
AvailableBalance float64 `json:"available_balance,omitempty"`
CheckedAt time.Time `json:"checked_at"`
ErrorCode string `json:"error_code,omitempty"`
ErrorMessage string `json:"error_message,omitempty"`
}
type cachedExchangeAccountStates struct {
states map[string]ExchangeAccountState
cachedAt time.Time
}
type ExchangeAccountStateCache struct {
entries map[string]cachedExchangeAccountStates
mu sync.RWMutex
}
func NewExchangeAccountStateCache() *ExchangeAccountStateCache {
return &ExchangeAccountStateCache{
entries: make(map[string]cachedExchangeAccountStates),
}
}
func (c *ExchangeAccountStateCache) Get(userID string) (map[string]ExchangeAccountState, bool) {
c.mu.RLock()
entry, ok := c.entries[userID]
c.mu.RUnlock()
if !ok || time.Since(entry.cachedAt) >= exchangeAccountStateCacheTTL {
return nil, false
}
return cloneExchangeAccountStates(entry.states), true
}
func (c *ExchangeAccountStateCache) Set(userID string, states map[string]ExchangeAccountState) {
c.mu.Lock()
c.entries[userID] = cachedExchangeAccountStates{
states: cloneExchangeAccountStates(states),
cachedAt: time.Now(),
}
c.mu.Unlock()
}
func (c *ExchangeAccountStateCache) Invalidate(userID string) {
c.mu.Lock()
delete(c.entries, userID)
c.mu.Unlock()
}
func cloneExchangeAccountStates(states map[string]ExchangeAccountState) map[string]ExchangeAccountState {
cloned := make(map[string]ExchangeAccountState, len(states))
for id, state := range states {
cloned[id] = state
}
return cloned
}
func (s *Server) handleGetExchangeAccountStates(c *gin.Context) {
userID := c.GetString("user_id")
states, err := s.getExchangeAccountStates(userID)
if err != nil {
SafeInternalError(c, "Failed to get exchange account states", err)
return
}
c.JSON(http.StatusOK, gin.H{"states": states})
}
func (s *Server) getExchangeAccountStates(userID string) (map[string]ExchangeAccountState, error) {
if cached, ok := s.exchangeAccountStateCache.Get(userID); ok {
return cached, nil
}
exchanges, err := s.store.Exchange().List(userID)
if err != nil {
return nil, err
}
states := make(map[string]ExchangeAccountState, len(exchanges))
if len(exchanges) == 0 {
return states, nil
}
var wg sync.WaitGroup
var mu sync.Mutex
for _, exchangeCfg := range exchanges {
exchangeCfg := exchangeCfg
wg.Add(1)
go func() {
defer wg.Done()
state := probeExchangeAccountState(exchangeCfg, userID)
mu.Lock()
states[exchangeCfg.ID] = state
mu.Unlock()
}()
}
wg.Wait()
s.exchangeAccountStateCache.Set(userID, states)
return cloneExchangeAccountStates(states), nil
}
func probeExchangeAccountState(exchangeCfg *store.Exchange, userID string) ExchangeAccountState {
state := ExchangeAccountState{
ExchangeID: exchangeCfg.ID,
CheckedAt: time.Now().UTC(),
Asset: accountAssetForExchange(exchangeCfg.ExchangeType),
}
if !exchangeCfg.Enabled {
state.Status = exchangeAccountStatusDisabled
state.ErrorCode = "EXCHANGE_DISABLED"
state.ErrorMessage = "Exchange account is disabled"
return state
}
if status, code, message, missing := missingExchangeCredentials(exchangeCfg); missing {
state.Status = status
state.ErrorCode = code
state.ErrorMessage = message
return state
}
tempTrader, err := buildExchangeProbeTrader(exchangeCfg, userID)
if err != nil {
status, code, message := classifyExchangeProbeError(err)
state.Status = status
state.ErrorCode = code
state.ErrorMessage = message
return state
}
balanceInfo, err := tempTrader.GetBalance()
if err != nil {
status, code, message := classifyExchangeProbeError(err)
state.Status = status
state.ErrorCode = code
state.ErrorMessage = message
logger.Infof("⚠️ Failed to probe exchange account %s (%s): %v", exchangeCfg.ID, exchangeCfg.ExchangeType, err)
return state
}
totalEquity, totalFound := extractFirstNumeric(balanceInfo,
"total_equity", "totalEquity", "totalWalletBalance", "wallet_balance", "totalEq", "balance")
availableBalance, availableFound := extractFirstNumeric(balanceInfo,
"available_balance", "availableBalance", "available")
if !totalFound && availableFound {
totalEquity = availableBalance
totalFound = true
}
if !availableFound && totalFound {
availableBalance = totalEquity
availableFound = true
}
if !totalFound && !availableFound {
state.Status = exchangeAccountStatusUnavailable
state.ErrorCode = "BALANCE_NOT_FOUND"
state.ErrorMessage = "Connected but no balance fields were returned"
return state
}
state.Status = exchangeAccountStatusOK
if totalFound {
state.TotalEquity = totalEquity
state.DisplayBalance = formatDisplayBalance(totalEquity, state.Asset)
}
if availableFound {
state.AvailableBalance = availableBalance
if state.DisplayBalance == "" {
state.DisplayBalance = formatDisplayBalance(availableBalance, state.Asset)
}
}
return state
}
func buildExchangeProbeTrader(exchangeCfg *store.Exchange, userID string) (trader.Trader, error) {
switch exchangeCfg.ExchangeType {
case "binance":
return binance.NewFuturesTrader(string(exchangeCfg.APIKey), string(exchangeCfg.SecretKey), userID), nil
case "bybit":
return bybit.NewBybitTrader(string(exchangeCfg.APIKey), string(exchangeCfg.SecretKey)), nil
case "okx":
return okx.NewOKXTrader(string(exchangeCfg.APIKey), string(exchangeCfg.SecretKey), string(exchangeCfg.Passphrase)), nil
case "bitget":
return bitget.NewBitgetTrader(string(exchangeCfg.APIKey), string(exchangeCfg.SecretKey), string(exchangeCfg.Passphrase)), nil
case "gate":
return gate.NewGateTrader(string(exchangeCfg.APIKey), string(exchangeCfg.SecretKey)), nil
case "kucoin":
return kucoin.NewKuCoinTrader(string(exchangeCfg.APIKey), string(exchangeCfg.SecretKey), string(exchangeCfg.Passphrase)), nil
case "indodax":
return indodax.NewIndodaxTrader(string(exchangeCfg.APIKey), string(exchangeCfg.SecretKey)), nil
case "hyperliquid":
return hyperliquidtrader.NewHyperliquidTrader(
string(exchangeCfg.APIKey),
exchangeCfg.HyperliquidWalletAddr,
exchangeCfg.Testnet,
exchangeCfg.HyperliquidUnifiedAcct,
)
case "aster":
return aster.NewAsterTrader(
exchangeCfg.AsterUser,
exchangeCfg.AsterSigner,
string(exchangeCfg.AsterPrivateKey),
)
case "lighter":
return lighter.NewLighterTraderV2(
exchangeCfg.LighterWalletAddr,
string(exchangeCfg.LighterAPIKeyPrivateKey),
exchangeCfg.LighterAPIKeyIndex,
false,
)
default:
return nil, fmt.Errorf("unsupported exchange type: %s", exchangeCfg.ExchangeType)
}
}
func extractExchangeTotalEquity(balanceInfo map[string]interface{}) (float64, bool) {
return extractFirstNumeric(balanceInfo,
"total_equity", "totalEquity", "totalWalletBalance", "wallet_balance", "totalEq", "balance")
}
func extractFirstNumeric(values map[string]interface{}, keys ...string) (float64, bool) {
for _, key := range keys {
raw, ok := values[key]
if !ok {
continue
}
switch v := raw.(type) {
case float64:
return v, true
case float32:
return float64(v), true
case int:
return float64(v), true
case int64:
return float64(v), true
case int32:
return float64(v), true
case string:
parsed, err := strconv.ParseFloat(v, 64)
if err == nil {
return parsed, true
}
}
}
return 0, false
}
func formatDisplayBalance(value float64, asset string) string {
formatted := strconv.FormatFloat(value, 'f', 4, 64)
formatted = strings.TrimRight(strings.TrimRight(formatted, "0"), ".")
if formatted == "" {
formatted = "0"
}
if asset == "" {
return formatted
}
return fmt.Sprintf("%s %s", formatted, asset)
}
func accountAssetForExchange(exchangeType string) string {
switch exchangeType {
case "hyperliquid", "aster", "lighter":
return "USDC"
default:
return "USDT"
}
}
func missingExchangeCredentials(exchangeCfg *store.Exchange) (status string, code string, message string, missing bool) {
missingFields := store.MissingRequiredExchangeCredentialFields(
exchangeCfg.ExchangeType,
string(exchangeCfg.APIKey),
string(exchangeCfg.SecretKey),
string(exchangeCfg.Passphrase),
exchangeCfg.HyperliquidWalletAddr,
exchangeCfg.AsterUser,
exchangeCfg.AsterSigner,
string(exchangeCfg.AsterPrivateKey),
exchangeCfg.LighterWalletAddr,
string(exchangeCfg.LighterAPIKeyPrivateKey),
)
if len(missingFields) > 0 {
if len(missingFields) == 1 && missingFields[0] == "exchange_type" {
return exchangeAccountStatusUnavailable, "UNSUPPORTED_EXCHANGE", "Unsupported exchange type", true
}
return exchangeAccountStatusMissingCredentials, "MISSING_REQUIRED_FIELDS", "Missing required fields: " + strings.Join(missingFields, ", "), true
}
return "", "", "", false
}
func classifyExchangeProbeError(err error) (status string, code string, message string) {
if err == nil {
return exchangeAccountStatusOK, "", ""
}
rawMessage := err.Error()
msg := strings.ToLower(rawMessage)
switch {
case strings.Contains(msg, "unsupported exchange type"):
return exchangeAccountStatusUnavailable, "UNSUPPORTED_EXCHANGE", "Unsupported exchange type"
case strings.Contains(msg, "requires ") || strings.Contains(msg, "missing") || strings.Contains(msg, "empty"):
return exchangeAccountStatusMissingCredentials, "MISSING_REQUIRED_FIELDS", "Exchange credentials are incomplete"
case strings.Contains(msg, "permission") || strings.Contains(msg, "forbidden") || strings.Contains(msg, "no authority") || strings.Contains(msg, "not allowed"):
return exchangeAccountStatusPermissionDenied, "PERMISSION_DENIED", "Exchange account has no permission to read balances"
case strings.Contains(msg, "invalid") || strings.Contains(msg, "signature") || strings.Contains(msg, "unauthorized") || strings.Contains(msg, "api key") || strings.Contains(msg, "api-key") || strings.Contains(msg, "auth"):
return exchangeAccountStatusInvalidCredentials, "INVALID_CREDENTIALS", "Exchange credentials are invalid"
default:
return exchangeAccountStatusUnavailable, "EXCHANGE_UNAVAILABLE", limitErrorMessage(rawMessage)
}
}
func limitErrorMessage(message string) string {
message = strings.TrimSpace(message)
if message == "" {
return "Unable to fetch exchange balance right now"
}
if len(message) <= 160 {
return message
}
return message[:157] + "..."
}

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,256 +0,0 @@
package api
import (
"encoding/json"
"fmt"
"net/http"
"strings"
"nofx/config"
"nofx/crypto"
"nofx/logger"
"nofx/security"
"nofx/store"
"nofx/wallet"
"github.com/gin-gonic/gin"
)
type ModelConfig struct {
ID string `json:"id"`
Name string `json:"name"`
Provider string `json:"provider"`
Enabled bool `json:"enabled"`
APIKey string `json:"apiKey,omitempty"`
CustomAPIURL string `json:"customApiUrl,omitempty"`
}
// SafeModelConfig Safe model configuration structure (does not contain sensitive information)
type SafeModelConfig struct {
ID string `json:"id"`
Name string `json:"name"`
Provider string `json:"provider"`
Enabled bool `json:"enabled"`
HasAPIKey bool `json:"has_api_key"`
CustomAPIURL string `json:"customApiUrl"` // Custom API URL (usually not sensitive)
CustomModelName string `json:"customModelName"` // Custom model name (not sensitive)
WalletAddress string `json:"walletAddress,omitempty"`
BalanceUSDC string `json:"balanceUsdc,omitempty"`
}
// ModelConfigUpdate is a single model's update payload. It is a named type
// (rather than an inline anonymous struct) so the log-sanitizer in utils.go is
// guaranteed to stay in sync with this shape — a mismatch there is what let
// plaintext credentials reach the logs previously.
type ModelConfigUpdate struct {
Enabled bool `json:"enabled"`
APIKey string `json:"api_key"`
CustomAPIURL string `json:"custom_api_url"`
CustomModelName string `json:"custom_model_name"`
}
type UpdateModelConfigRequest struct {
Models map[string]ModelConfigUpdate `json:"models"`
}
// handleGetModelConfigs Get AI model configurations
func (s *Server) handleGetModelConfigs(c *gin.Context) {
userID := c.GetString("user_id")
logger.Infof("🔍 Querying AI model configs for user %s", userID)
models, err := s.store.AIModel().List(userID)
if err != nil {
logger.Infof("❌ Failed to get AI model configs: %v", err)
SafeInternalError(c, "Failed to get AI model configs", err)
return
}
// If no models in database, return default models
if len(models) == 0 {
logger.Infof("⚠️ No AI models in database, returning defaults")
defaultModels := []SafeModelConfig{
{ID: "deepseek", Name: "DeepSeek AI", Provider: "deepseek", Enabled: false, HasAPIKey: false},
{ID: "qwen", Name: "Qwen AI", Provider: "qwen", Enabled: false, HasAPIKey: false},
{ID: "openai", Name: "OpenAI", Provider: "openai", Enabled: false, HasAPIKey: false},
{ID: "claude", Name: "Claude AI", Provider: "claude", Enabled: false, HasAPIKey: false},
{ID: "gemini", Name: "Gemini AI", Provider: "gemini", Enabled: false, HasAPIKey: false},
{ID: "grok", Name: "Grok AI", Provider: "grok", Enabled: false, HasAPIKey: false},
{ID: "kimi", Name: "Kimi AI", Provider: "kimi", Enabled: false, HasAPIKey: false},
{ID: "minimax", Name: "MiniMax AI", Provider: "minimax", Enabled: false, HasAPIKey: false},
}
c.JSON(http.StatusOK, defaultModels)
return
}
logger.Infof("✅ Found %d AI model configs", len(models))
// Convert to safe response structure, remove sensitive information
safeModels := make([]SafeModelConfig, 0, len(models))
for _, model := range models {
if !store.IsVisibleAIModel(model) {
continue
}
safeModel := SafeModelConfig{
ID: model.ID,
Name: model.Name,
Provider: model.Provider,
Enabled: model.Enabled,
HasAPIKey: model.APIKey != "",
CustomAPIURL: model.CustomAPIURL,
CustomModelName: model.CustomModelName,
}
if model.Provider == "claw402" {
if privateKey := strings.TrimSpace(model.APIKey.String()); privateKey != "" {
if walletAddress, addrErr := walletAddressFromPrivateKey(privateKey); addrErr == nil {
safeModel.WalletAddress = walletAddress
safeModel.BalanceUSDC = wallet.QueryUSDCBalanceStr(walletAddress)
} else {
logger.Warnf("⚠️ Failed to derive claw402 wallet address for model %s: %v", model.ID, addrErr)
}
}
}
safeModels = append(safeModels, safeModel)
}
if len(safeModels) == 0 {
logger.Infof("⚠️ No visible AI models in database, returning defaults")
defaultModels := []SafeModelConfig{
{ID: "deepseek", Name: "DeepSeek AI", Provider: "deepseek", Enabled: false, HasAPIKey: false},
{ID: "qwen", Name: "Qwen AI", Provider: "qwen", Enabled: false, HasAPIKey: false},
{ID: "openai", Name: "OpenAI", Provider: "openai", Enabled: false, HasAPIKey: false},
{ID: "claude", Name: "Claude AI", Provider: "claude", Enabled: false, HasAPIKey: false},
{ID: "gemini", Name: "Gemini AI", Provider: "gemini", Enabled: false, HasAPIKey: false},
{ID: "grok", Name: "Grok AI", Provider: "grok", Enabled: false, HasAPIKey: false},
{ID: "kimi", Name: "Kimi AI", Provider: "kimi", Enabled: false, HasAPIKey: false},
{ID: "minimax", Name: "MiniMax AI", Provider: "minimax", Enabled: false, HasAPIKey: false},
}
c.JSON(http.StatusOK, defaultModels)
return
}
c.JSON(http.StatusOK, safeModels)
}
// handleUpdateModelConfigs Update AI model configurations (supports both encrypted and plain text based on config)
func (s *Server) handleUpdateModelConfigs(c *gin.Context) {
userID := c.GetString("user_id")
cfg := config.Get()
// Read raw request body
bodyBytes, err := c.GetRawData()
if err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "Failed to read request body"})
return
}
var req UpdateModelConfigRequest
// Check if transport encryption is enabled
if !cfg.TransportEncryption {
// Transport encryption disabled, accept plain JSON
if err := json.Unmarshal(bodyBytes, &req); err != nil {
logger.Infof("❌ Failed to parse plain JSON request: %v", err)
c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid request format"})
return
}
logger.Infof("📝 Received plain text model config (UserID: %s)", userID)
} else {
// Transport encryption enabled, require encrypted payload
var encryptedPayload crypto.EncryptedPayload
if err := json.Unmarshal(bodyBytes, &encryptedPayload); err != nil {
logger.Infof("❌ Failed to parse encrypted payload: %v", err)
c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid request format, encrypted transmission required"})
return
}
// Verify encrypted data
if encryptedPayload.WrappedKey == "" {
logger.Infof("❌ Detected unencrypted request (UserID: %s)", userID)
c.JSON(http.StatusBadRequest, gin.H{
"error": "This endpoint only supports encrypted transmission, please use encrypted client",
"code": "ENCRYPTION_REQUIRED",
"message": "Encrypted transmission is required for security reasons",
})
return
}
// Decrypt data
decrypted, err := s.cryptoHandler.cryptoService.DecryptSensitiveData(&encryptedPayload)
if err != nil {
logger.Infof("❌ Failed to decrypt model config (UserID: %s): %v", userID, err)
c.JSON(http.StatusBadRequest, gin.H{"error": "Failed to decrypt data"})
return
}
// Parse decrypted data
if err := json.Unmarshal([]byte(decrypted), &req); err != nil {
logger.Infof("❌ Failed to parse decrypted data: %v", err)
c.JSON(http.StatusBadRequest, gin.H{"error": "Failed to parse decrypted data"})
return
}
logger.Infof("🔓 Decrypted model config data (UserID: %s)", userID)
}
// Update each model's configuration and track traders that need reload
tradersToReload := make(map[string]bool)
for modelID, modelData := range req.Models {
// SSRF protection: validate custom_api_url before storing
if modelData.CustomAPIURL != "" {
cleanURL := strings.TrimSuffix(modelData.CustomAPIURL, "#")
if err := security.ValidateURL(cleanURL); err != nil {
logger.Warnf("Invalid custom_api_url for model %s: %v", modelID, err)
c.JSON(http.StatusBadRequest, gin.H{"error": fmt.Sprintf("Invalid custom_api_url for model %s: URL must be a valid HTTPS endpoint", modelID)})
return
}
}
// Find traders using this AI model BEFORE updating
traders, _ := s.store.Trader().ListByAIModelID(userID, modelID)
for _, t := range traders {
tradersToReload[t.ID] = true
}
err := s.store.AIModel().Update(userID, modelID, modelData.Enabled, modelData.APIKey, modelData.CustomAPIURL, modelData.CustomModelName)
if err != nil {
SafeInternalError(c, fmt.Sprintf("Update model %s", modelID), err)
return
}
}
// Remove affected traders from memory BEFORE reloading to pick up new config
for traderID := range tradersToReload {
logger.Infof("🔄 Removing trader %s from memory to reload with new AI model config", traderID)
s.traderManager.RemoveTrader(traderID)
}
// Reload all traders for this user to make new config take effect immediately
err = s.traderManager.LoadUserTradersFromStore(s.store, userID)
if err != nil {
logger.Infof("⚠️ Failed to reload user traders into memory: %v", err)
// Don't return error here since model config was successfully updated to database
}
logger.Infof("✓ AI model config updated: %+v", SanitizeModelConfigForLog(req.Models))
c.JSON(http.StatusOK, gin.H{"message": "Model configuration updated"})
}
// handleGetSupportedModels Get list of AI models supported by the system
func (s *Server) handleGetSupportedModels(c *gin.Context) {
// Return static list of supported AI models with default versions
supportedModels := []map[string]interface{}{
{"id": "deepseek", "name": "DeepSeek", "provider": "deepseek", "defaultModel": "deepseek-chat"},
{"id": "qwen", "name": "Qwen", "provider": "qwen", "defaultModel": "qwen3-max"},
{"id": "openai", "name": "OpenAI", "provider": "openai", "defaultModel": "gpt-5.1"},
{"id": "claude", "name": "Claude", "provider": "claude", "defaultModel": "claude-opus-4-6"},
{"id": "gemini", "name": "Google Gemini", "provider": "gemini", "defaultModel": "gemini-3-pro-preview"},
{"id": "grok", "name": "Grok (xAI)", "provider": "grok", "defaultModel": "grok-3-latest"},
{"id": "kimi", "name": "Kimi (Moonshot)", "provider": "kimi", "defaultModel": "moonshot-v1-auto"},
{"id": "minimax", "name": "MiniMax", "provider": "minimax", "defaultModel": "MiniMax-M2.7"},
{"id": "blockrun-base", "name": "BlockRun (Base Wallet)", "provider": "blockrun-base", "defaultModel": "auto"},
{"id": "blockrun-sol", "name": "BlockRun (Solana Wallet)", "provider": "blockrun-sol", "defaultModel": "auto"},
{"id": "claw402", "name": "Claw402 (Base USDC)", "provider": "claw402", "defaultModel": "deepseek-v4-flash"},
}
c.JSON(http.StatusOK, supportedModels)
}

View File

@@ -1,522 +0,0 @@
package api
import (
"fmt"
"net/http"
"strconv"
"strings"
"time"
"nofx/logger"
"nofx/store"
"github.com/gin-gonic/gin"
)
// handleDecisions Decision log list
func (s *Server) handleDecisions(c *gin.Context) {
_, traderID, err := s.getTraderFromQuery(c)
if err != nil {
SafeBadRequest(c, "Invalid trader ID")
return
}
trader, err := s.traderManager.GetTrader(traderID)
if err != nil {
SafeNotFound(c, "Trader")
return
}
// Get all historical decision records (unlimited)
records, err := trader.GetStore().Decision().GetLatestRecords(trader.GetID(), 10000)
if err != nil {
SafeInternalError(c, "Get decision log", err)
return
}
c.JSON(http.StatusOK, records)
}
// handleLatestDecisions Latest decision logs (newest first, supports limit parameter)
func (s *Server) handleLatestDecisions(c *gin.Context) {
_, traderID, err := s.getTraderFromQuery(c)
if err != nil {
SafeBadRequest(c, "Invalid trader ID")
return
}
trader, err := s.traderManager.GetTrader(traderID)
if err != nil {
SafeNotFound(c, "Trader")
return
}
// Get limit from query parameter, default to 5
limit := 5
if limitStr := c.Query("limit"); limitStr != "" {
if parsedLimit, err := strconv.Atoi(limitStr); err == nil && parsedLimit > 0 {
limit = parsedLimit
if limit > 100 {
limit = 100 // Max 100 to prevent abuse
}
}
}
records, err := trader.GetStore().Decision().GetLatestRecords(trader.GetID(), limit)
if err != nil {
SafeInternalError(c, "Get decision log", err)
return
}
// Reverse array to put newest first (for list display)
// GetLatestRecords returns oldest to newest (for charts), here we need newest to oldest
for i, j := 0, len(records)-1; i < j; i, j = i+1, j-1 {
records[i], records[j] = records[j], records[i]
}
c.JSON(http.StatusOK, records)
}
// handleStatistics Statistics information
func (s *Server) handleStatistics(c *gin.Context) {
_, traderID, err := s.getTraderFromQuery(c)
if err != nil {
SafeBadRequest(c, "Invalid trader ID")
return
}
trader, err := s.traderManager.GetTrader(traderID)
if err != nil {
SafeNotFound(c, "Trader")
return
}
stats, err := trader.GetStore().Decision().GetStatistics(trader.GetID())
if err != nil {
SafeInternalError(c, "Get statistics", err)
return
}
c.JSON(http.StatusOK, stats)
}
// handleCompetition Competition overview (compare all traders)
func (s *Server) handleCompetition(c *gin.Context) {
userID := c.GetString("user_id")
// Ensure user's traders are loaded into memory
err := s.traderManager.LoadUserTradersFromStore(s.store, userID)
if err != nil {
logger.Infof("⚠️ Failed to load traders for user %s: %v", userID, err)
}
competition, err := s.traderManager.GetCompetitionData()
if err != nil {
SafeInternalError(c, "Get competition data", err)
return
}
c.JSON(http.StatusOK, competition)
}
// handleEquityHistory returns equity history for a trader. This endpoint is
// PUBLIC (used by the competition leaderboard), so it cannot use the
// authenticated getTraderFromQuery helper. Instead, it validates that the
// requested trader has explicitly opted into the public competition via
// show_in_competition=true. Traders without that flag are not exposed.
func (s *Server) handleEquityHistory(c *gin.Context) {
traderID := c.Query("trader_id")
if traderID == "" {
SafeBadRequest(c, "trader_id is required")
return
}
trader, err := s.store.Trader().GetByID(traderID)
if err != nil || trader == nil {
SafeNotFound(c, "Trader")
return
}
if !trader.ShowInCompetition {
// Do not leak that a private trader exists; report not found.
SafeNotFound(c, "Trader")
return
}
// Get equity historical data from new equity table
// Every 3 minutes per cycle: 10000 records = about 20 days of data
snapshots, err := s.store.Equity().GetLatest(traderID, 10000)
if err != nil {
SafeInternalError(c, "Get historical data", err)
return
}
if len(snapshots) == 0 {
c.JSON(http.StatusOK, []interface{}{})
return
}
// Build return rate historical data points
type EquityPoint struct {
Timestamp string `json:"timestamp"`
TotalEquity float64 `json:"total_equity"` // Account equity (wallet + unrealized)
AvailableBalance float64 `json:"available_balance"` // Available balance
TotalPnL float64 `json:"total_pnl"` // Total PnL (unrealized PnL)
TotalPnLPct float64 `json:"total_pnl_pct"` // Total PnL percentage
PositionCount int `json:"position_count"` // Position count
MarginUsedPct float64 `json:"margin_used_pct"` // Margin used percentage
}
initialBalance := trader.InitialBalance
if initialBalance <= 0 {
initialBalance = snapshots[0].TotalEquity
}
if initialBalance == 0 {
initialBalance = 1 // Avoid division by zero
}
var history []EquityPoint
var lastSnapshotTime time.Time
for _, snap := range snapshots {
totalPnL := snap.TotalEquity - initialBalance
totalPnLPct := 0.0
if initialBalance > 0 {
totalPnLPct = (totalPnL / initialBalance) * 100
}
history = append(history, EquityPoint{
Timestamp: snap.Timestamp.Format("2006-01-02 15:04:05"),
TotalEquity: snap.TotalEquity,
AvailableBalance: equitySnapshotAvailableBalance(snap),
TotalPnL: totalPnL,
TotalPnLPct: totalPnLPct,
PositionCount: snap.PositionCount,
MarginUsedPct: snap.MarginUsedPct,
})
if snap.Timestamp.After(lastSnapshotTime) {
lastSnapshotTime = snap.Timestamp
}
}
if runtimeTrader, err := s.traderManager.GetTrader(traderID); err == nil {
if accountInfo, err := runtimeTrader.GetAccountInfo(); err == nil && time.Since(lastSnapshotTime) > 30*time.Second {
totalEquity := floatFromMap(accountInfo, "total_equity")
totalPnL := totalEquity - initialBalance
totalPnLPct := 0.0
if initialBalance > 0 {
totalPnLPct = (totalPnL / initialBalance) * 100
}
history = append(history, EquityPoint{
Timestamp: time.Now().UTC().Format("2006-01-02 15:04:05"),
TotalEquity: totalEquity,
AvailableBalance: floatFromMap(accountInfo, "available_balance"),
TotalPnL: totalPnL,
TotalPnLPct: totalPnLPct,
PositionCount: int(floatFromMap(accountInfo, "position_count")),
MarginUsedPct: floatFromMap(accountInfo, "margin_used_pct"),
})
}
}
c.JSON(http.StatusOK, history)
}
func equitySnapshotAvailableBalance(snap *store.EquitySnapshot) float64 {
if snap == nil {
return 0
}
if snap.AvailableBalance != 0 || snap.PositionCount > 0 {
return snap.AvailableBalance
}
return snap.Balance
}
func floatFromMap(values map[string]interface{}, key string) float64 {
if value, ok := values[key].(float64); ok {
return value
}
if value, ok := values[key].(int); ok {
return float64(value)
}
return 0
}
// handlePublicTraderList Get public trader list (no authentication required)
func (s *Server) handlePublicTraderList(c *gin.Context) {
// Get trader information from all users
competition, err := s.traderManager.GetCompetitionData()
if err != nil {
SafeInternalError(c, "Get trader list", err)
return
}
// Get traders array
tradersData, exists := competition["traders"]
if !exists {
c.JSON(http.StatusOK, []map[string]interface{}{})
return
}
traders, ok := tradersData.([]map[string]interface{})
if !ok {
c.JSON(http.StatusInternalServerError, gin.H{
"error": "Trader data format error",
})
return
}
// Return trader basic information, filter sensitive information
result := make([]map[string]interface{}, 0, len(traders))
for _, trader := range traders {
result = append(result, map[string]interface{}{
"trader_id": trader["trader_id"],
"trader_name": trader["trader_name"],
"ai_model": trader["ai_model"],
"exchange": trader["exchange"],
"is_running": trader["is_running"],
"total_equity": trader["total_equity"],
"total_pnl": trader["total_pnl"],
"total_pnl_pct": trader["total_pnl_pct"],
"position_count": trader["position_count"],
"margin_used_pct": trader["margin_used_pct"],
})
}
c.JSON(http.StatusOK, result)
}
// handlePublicCompetition Get public competition data (no authentication required)
func (s *Server) handlePublicCompetition(c *gin.Context) {
competition, err := s.traderManager.GetCompetitionData()
if err != nil {
SafeInternalError(c, "Get competition data", err)
return
}
c.JSON(http.StatusOK, competition)
}
// handleTopTraders Get top 5 trader data (no authentication required, for performance comparison)
func (s *Server) handleTopTraders(c *gin.Context) {
topTraders, err := s.traderManager.GetTopTradersData()
if err != nil {
SafeInternalError(c, "Get top traders data", err)
return
}
c.JSON(http.StatusOK, topTraders)
}
// handleEquityHistoryBatch Batch get return rate historical data for multiple traders (no authentication required, for performance comparison)
// Supports optional 'hours' parameter to filter data by time range (e.g., hours=24 for last 24 hours)
func (s *Server) handleEquityHistoryBatch(c *gin.Context) {
var requestBody struct {
TraderIDs []string `json:"trader_ids"`
Hours int `json:"hours"` // Optional: filter by last N hours (0 = all data)
}
// Try to parse POST request JSON body
if err := c.ShouldBindJSON(&requestBody); err != nil {
// If JSON parse fails, try to get from query parameters (compatible with GET request)
traderIDsParam := c.Query("trader_ids")
if traderIDsParam == "" {
// If no trader_ids specified, return historical data for top 5
topTraders, err := s.traderManager.GetTopTradersData()
if err != nil {
SafeInternalError(c, "Get top traders", err)
return
}
traders, ok := topTraders["traders"].([]map[string]interface{})
if !ok {
c.JSON(http.StatusInternalServerError, gin.H{"error": "Trader data format error"})
return
}
// Extract trader IDs
traderIDs := make([]string, 0, len(traders))
for _, trader := range traders {
if traderID, ok := trader["trader_id"].(string); ok {
traderIDs = append(traderIDs, traderID)
}
}
// Parse hours parameter from query
hoursParam := c.Query("hours")
hours := 0
if hoursParam != "" {
fmt.Sscanf(hoursParam, "%d", &hours)
}
result := s.getEquityHistoryForTraders(traderIDs, hours)
c.JSON(http.StatusOK, result)
return
}
// Parse comma-separated trader IDs
requestBody.TraderIDs = strings.Split(traderIDsParam, ",")
for i := range requestBody.TraderIDs {
requestBody.TraderIDs[i] = strings.TrimSpace(requestBody.TraderIDs[i])
}
// Parse hours parameter from query
hoursParam := c.Query("hours")
if hoursParam != "" {
fmt.Sscanf(hoursParam, "%d", &requestBody.Hours)
}
}
// Limit to maximum 20 traders to prevent oversized requests
if len(requestBody.TraderIDs) > 20 {
requestBody.TraderIDs = requestBody.TraderIDs[:20]
}
result := s.getEquityHistoryForTraders(requestBody.TraderIDs, requestBody.Hours)
c.JSON(http.StatusOK, result)
}
// getEquityHistoryForTraders Get historical data for multiple traders
// Query directly from database, not dependent on trader in memory (so historical data can be retrieved after restart)
// Also appends current real-time data point to ensure chart matches leaderboard
// hours: filter by last N hours (0 = use default limit of 500 records)
func (s *Server) getEquityHistoryForTraders(traderIDs []string, hours int) map[string]interface{} {
result := make(map[string]interface{})
histories := make(map[string]interface{})
errors := make(map[string]string)
// Use a single consistent timestamp for all real-time data points
now := time.Now()
// Pre-fetch initial balances for all traders
initialBalances := make(map[string]float64)
for _, traderID := range traderIDs {
if traderID == "" {
continue
}
// Get trader's initial balance from database (use GetByID which doesn't require userID)
trader, err := s.store.Trader().GetByID(traderID)
if err == nil && trader != nil && trader.InitialBalance > 0 {
initialBalances[traderID] = trader.InitialBalance
}
}
for _, traderID := range traderIDs {
if traderID == "" {
continue
}
// Get equity historical data from new equity table
var snapshots []*store.EquitySnapshot
var err error
if hours > 0 {
// Filter by time range
startTime := now.Add(-time.Duration(hours) * time.Hour)
snapshots, err = s.store.Equity().GetByTimeRange(traderID, startTime, now)
} else {
// Default: get latest 500 records
snapshots, err = s.store.Equity().GetLatest(traderID, 500)
}
if err != nil {
logger.Errorf("[API] Failed to get equity history for %s: %v", traderID, err)
errors[traderID] = "Failed to get historical data"
continue
}
// Get initial balance for calculating PnL percentage
initialBalance := initialBalances[traderID]
if initialBalance <= 0 && len(snapshots) > 0 {
// If no initial balance configured, use the first snapshot's equity as baseline
initialBalance = snapshots[0].TotalEquity
}
// Build return rate historical data with PnL percentage
history := make([]map[string]interface{}, 0, len(snapshots)+1)
var lastSnapshotTime time.Time
for _, snap := range snapshots {
totalPnL := snap.TotalEquity - initialBalance
// Calculate PnL percentage: (current_equity - initial_balance) / initial_balance * 100
pnlPct := 0.0
if initialBalance > 0 {
pnlPct = totalPnL / initialBalance * 100
}
history = append(history, map[string]interface{}{
"timestamp": snap.Timestamp,
"total_equity": snap.TotalEquity,
"available_balance": equitySnapshotAvailableBalance(snap),
"total_pnl": totalPnL,
"total_pnl_pct": pnlPct,
"balance": snap.Balance,
})
if snap.Timestamp.After(lastSnapshotTime) {
lastSnapshotTime = snap.Timestamp
}
}
// Append current real-time data point to ensure chart matches leaderboard
// This ensures the latest point is always current, not from a potentially stale snapshot
if trader, err := s.traderManager.GetTrader(traderID); err == nil {
if accountInfo, err := trader.GetAccountInfo(); err == nil {
// Only append if it's been more than 30 seconds since last snapshot
if now.Sub(lastSnapshotTime) > 30*time.Second {
totalEquity := floatFromMap(accountInfo, "total_equity")
totalPnL := totalEquity - initialBalance
walletBalance := floatFromMap(accountInfo, "wallet_balance")
pnlPct := 0.0
if initialBalance > 0 {
pnlPct = (totalEquity - initialBalance) / initialBalance * 100
}
history = append(history, map[string]interface{}{
"timestamp": now,
"total_equity": totalEquity,
"available_balance": floatFromMap(accountInfo, "available_balance"),
"total_pnl": totalPnL,
"total_pnl_pct": pnlPct,
"balance": walletBalance,
})
}
}
}
histories[traderID] = history
}
result["histories"] = histories
result["count"] = len(histories)
if len(errors) > 0 {
result["errors"] = errors
}
return result
}
// handleGetPublicTraderConfig Get public trader configuration information (no authentication required, does not include sensitive information)
func (s *Server) handleGetPublicTraderConfig(c *gin.Context) {
traderID := c.Param("id")
if traderID == "" {
c.JSON(http.StatusBadRequest, gin.H{"error": "Trader ID cannot be empty"})
return
}
trader, err := s.traderManager.GetTrader(traderID)
if err != nil {
c.JSON(http.StatusNotFound, gin.H{"error": "Trader does not exist"})
return
}
// Get trader status information
status := trader.GetStatus()
// Only return public configuration information, not including sensitive data like API keys
result := map[string]interface{}{
"trader_id": trader.GetID(),
"trader_name": trader.GetName(),
"ai_model": trader.GetAIModel(),
"exchange": trader.GetExchange(),
"is_running": status["is_running"],
"ai_provider": status["ai_provider"],
"start_time": status["start_time"],
}
c.JSON(http.StatusOK, result)
}

View File

@@ -1,494 +0,0 @@
package api
import (
"encoding/json"
"fmt"
"net/http"
"strings"
"nofx/config"
"nofx/crypto"
"nofx/logger"
"nofx/store"
"github.com/gin-gonic/gin"
)
type ExchangeConfig struct {
ID string `json:"id"`
Name string `json:"name"`
Type string `json:"type"` // "cex" or "dex"
Enabled bool `json:"enabled"`
APIKey string `json:"apiKey,omitempty"`
SecretKey string `json:"secretKey,omitempty"`
Testnet bool `json:"testnet,omitempty"`
}
// SafeExchangeConfig Safe exchange configuration structure (does not contain sensitive information)
type SafeExchangeConfig struct {
ID string `json:"id"` // UUID
ExchangeType string `json:"exchange_type"` // "binance", "bybit", "okx", "hyperliquid", "aster", "lighter"
AccountName string `json:"account_name"` // User-defined account name
Name string `json:"name"` // Display name
Type string `json:"type"` // "cex" or "dex"
Enabled bool `json:"enabled"`
HasAPIKey bool `json:"has_api_key"`
HasSecretKey bool `json:"has_secret_key"`
HasPassphrase bool `json:"has_passphrase"`
Testnet bool `json:"testnet,omitempty"`
HyperliquidWalletAddr string `json:"hyperliquidWalletAddr"` // Hyperliquid wallet address (not sensitive)
HyperliquidUnifiedAcct bool `json:"hyperliquidUnifiedAccount"`
HyperliquidBuilderApproved bool `json:"hyperliquidBuilderApproved"`
HasAsterPrivateKey bool `json:"has_aster_private_key"`
AsterUser string `json:"asterUser"` // Aster username (not sensitive)
AsterSigner string `json:"asterSigner"` // Aster signer (not sensitive)
LighterWalletAddr string `json:"lighterWalletAddr"` // LIGHTER wallet address (not sensitive)
HasLighterPrivateKey bool `json:"has_lighter_private_key"`
HasLighterAPIKey bool `json:"has_lighter_api_key_private_key"`
}
func safeExchangeConfigFromStore(exchange *store.Exchange) SafeExchangeConfig {
return SafeExchangeConfig{
ID: exchange.ID,
ExchangeType: exchange.ExchangeType,
AccountName: exchange.AccountName,
Name: exchange.Name,
Type: exchange.Type,
Enabled: exchange.Enabled,
HasAPIKey: exchange.APIKey != "",
HasSecretKey: exchange.SecretKey != "",
HasPassphrase: exchange.Passphrase != "",
Testnet: exchange.Testnet,
HyperliquidWalletAddr: exchange.HyperliquidWalletAddr,
HyperliquidUnifiedAcct: exchange.HyperliquidUnifiedAcct,
HyperliquidBuilderApproved: exchange.HyperliquidBuilderApproved,
HasAsterPrivateKey: exchange.AsterPrivateKey != "",
AsterUser: exchange.AsterUser,
AsterSigner: exchange.AsterSigner,
LighterWalletAddr: exchange.LighterWalletAddr,
HasLighterPrivateKey: exchange.LighterPrivateKey != "",
HasLighterAPIKey: exchange.LighterAPIKeyPrivateKey != "",
}
}
// ExchangeConfigUpdate is a single exchange account's update payload. It is a
// named type (rather than an inline anonymous struct) so the log-sanitizer in
// utils.go is guaranteed to cover every sensitive field — a drift between the
// two shapes is what let passphrases / private keys reach the logs previously.
type ExchangeConfigUpdate struct {
Enabled bool `json:"enabled"`
APIKey string `json:"api_key"`
SecretKey string `json:"secret_key"`
Passphrase string `json:"passphrase"` // OKX specific
Testnet bool `json:"testnet"`
HyperliquidWalletAddr string `json:"hyperliquid_wallet_addr"`
HyperliquidUnifiedAcct *bool `json:"hyperliquid_unified_account"` // Unified Account mode
HyperliquidBuilderApproved *bool `json:"hyperliquid_builder_approved"`
AsterUser string `json:"aster_user"`
AsterSigner string `json:"aster_signer"`
AsterPrivateKey string `json:"aster_private_key"`
LighterWalletAddr string `json:"lighter_wallet_addr"`
LighterPrivateKey string `json:"lighter_private_key"`
LighterAPIKeyPrivateKey string `json:"lighter_api_key_private_key"`
LighterAPIKeyIndex int `json:"lighter_api_key_index"`
}
type UpdateExchangeConfigRequest struct {
Exchanges map[string]ExchangeConfigUpdate `json:"exchanges"`
}
// CreateExchangeRequest request structure for creating a new exchange account
type CreateExchangeRequest struct {
ExchangeType string `json:"exchange_type" binding:"required"` // "binance", "bybit", "okx", "hyperliquid", "aster", "lighter"
AccountName string `json:"account_name"` // User-defined account name
Enabled bool `json:"enabled"`
APIKey string `json:"api_key"`
SecretKey string `json:"secret_key"`
Passphrase string `json:"passphrase"`
Testnet bool `json:"testnet"`
HyperliquidWalletAddr string `json:"hyperliquid_wallet_addr"`
HyperliquidUnifiedAcct *bool `json:"hyperliquid_unified_account"` // Unified Account mode: Spot as Perp collateral
HyperliquidBuilderApproved bool `json:"hyperliquid_builder_approved"`
AsterUser string `json:"aster_user"`
AsterSigner string `json:"aster_signer"`
AsterPrivateKey string `json:"aster_private_key"`
LighterWalletAddr string `json:"lighter_wallet_addr"`
LighterPrivateKey string `json:"lighter_private_key"`
LighterAPIKeyPrivateKey string `json:"lighter_api_key_private_key"`
LighterAPIKeyIndex int `json:"lighter_api_key_index"`
}
// handleGetExchangeConfigs Get exchange configurations
func (s *Server) handleGetExchangeConfigs(c *gin.Context) {
userID := c.GetString("user_id")
logger.Infof("🔍 Querying exchange configs for user %s", userID)
exchanges, err := s.store.Exchange().List(userID)
if err != nil {
SafeInternalError(c, "Failed to get exchange configs", err)
return
}
// If no exchanges in database, return empty array (user needs to create accounts)
if len(exchanges) == 0 {
logger.Infof("⚠️ No exchanges in database for user %s", userID)
c.JSON(http.StatusOK, []SafeExchangeConfig{})
return
}
logger.Infof("✅ Found %d exchange configs", len(exchanges))
// Convert to safe response structure, remove sensitive information
safeExchanges := make([]SafeExchangeConfig, 0, len(exchanges))
for _, exchange := range exchanges {
if !store.IsVisibleExchange(exchange) {
continue
}
safeExchanges = append(safeExchanges, safeExchangeConfigFromStore(exchange))
}
c.JSON(http.StatusOK, safeExchanges)
}
func effectiveHyperliquidUnifiedAccount(exchangeType string, requested *bool, fallback ...bool) bool {
if requested != nil {
return *requested
}
if strings.EqualFold(exchangeType, "hyperliquid") {
if len(fallback) > 0 {
return fallback[0]
}
return true
}
return false
}
// handleUpdateExchangeConfigs Update exchange configurations (supports both encrypted and plain text based on config)
func (s *Server) handleUpdateExchangeConfigs(c *gin.Context) {
userID := c.GetString("user_id")
cfg := config.Get()
// Read raw request body
bodyBytes, err := c.GetRawData()
if err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "Failed to read request body"})
return
}
var req UpdateExchangeConfigRequest
// Check if transport encryption is enabled
if !cfg.TransportEncryption {
// Transport encryption disabled, accept plain JSON
if err := json.Unmarshal(bodyBytes, &req); err != nil {
logger.Infof("❌ Failed to parse plain JSON request: %v", err)
c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid request format"})
return
}
logger.Infof("📝 Received plain text exchange config (UserID: %s)", userID)
} else {
// Transport encryption enabled, require encrypted payload
var encryptedPayload crypto.EncryptedPayload
if err := json.Unmarshal(bodyBytes, &encryptedPayload); err != nil {
logger.Infof("❌ Failed to parse encrypted payload: %v", err)
c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid request format, encrypted transmission required"})
return
}
// Verify encrypted data
if encryptedPayload.WrappedKey == "" {
logger.Infof("❌ Detected unencrypted request (UserID: %s)", userID)
c.JSON(http.StatusBadRequest, gin.H{
"error": "This endpoint only supports encrypted transmission, please use encrypted client",
"code": "ENCRYPTION_REQUIRED",
"message": "Encrypted transmission is required for security reasons",
})
return
}
// Decrypt data
decrypted, err := s.cryptoHandler.cryptoService.DecryptSensitiveData(&encryptedPayload)
if err != nil {
logger.Infof("❌ Failed to decrypt exchange config (UserID: %s): %v", userID, err)
c.JSON(http.StatusBadRequest, gin.H{"error": "Failed to decrypt data"})
return
}
// Parse decrypted data
if err := json.Unmarshal([]byte(decrypted), &req); err != nil {
logger.Infof("❌ Failed to parse decrypted data: %v", err)
c.JSON(http.StatusBadRequest, gin.H{"error": "Failed to parse decrypted data"})
return
}
logger.Infof("🔓 Decrypted exchange config data (UserID: %s)", userID)
}
// Update each exchange's configuration and track traders that need reload
tradersToReload := make(map[string]bool)
for exchangeID, exchangeData := range req.Exchanges {
existing, err := s.store.Exchange().GetByID(userID, exchangeID)
if err != nil {
SafeInternalError(c, fmt.Sprintf("Load exchange %s", exchangeID), err)
return
}
effectiveAPIKey := strings.TrimSpace(exchangeData.APIKey)
if effectiveAPIKey == "" {
effectiveAPIKey = strings.TrimSpace(string(existing.APIKey))
}
effectiveSecretKey := strings.TrimSpace(exchangeData.SecretKey)
if effectiveSecretKey == "" {
effectiveSecretKey = strings.TrimSpace(string(existing.SecretKey))
}
effectivePassphrase := strings.TrimSpace(exchangeData.Passphrase)
if effectivePassphrase == "" {
effectivePassphrase = strings.TrimSpace(string(existing.Passphrase))
}
effectiveAsterPrivateKey := strings.TrimSpace(exchangeData.AsterPrivateKey)
if effectiveAsterPrivateKey == "" {
effectiveAsterPrivateKey = strings.TrimSpace(string(existing.AsterPrivateKey))
}
effectiveLighterAPIKeyPrivateKey := strings.TrimSpace(exchangeData.LighterAPIKeyPrivateKey)
if effectiveLighterAPIKeyPrivateKey == "" {
effectiveLighterAPIKeyPrivateKey = strings.TrimSpace(string(existing.LighterAPIKeyPrivateKey))
}
effectiveHyperliquidWalletAddr := strings.TrimSpace(exchangeData.HyperliquidWalletAddr)
if effectiveHyperliquidWalletAddr == "" {
effectiveHyperliquidWalletAddr = strings.TrimSpace(existing.HyperliquidWalletAddr)
}
effectiveAsterUser := strings.TrimSpace(exchangeData.AsterUser)
if effectiveAsterUser == "" {
effectiveAsterUser = strings.TrimSpace(existing.AsterUser)
}
effectiveAsterSigner := strings.TrimSpace(exchangeData.AsterSigner)
if effectiveAsterSigner == "" {
effectiveAsterSigner = strings.TrimSpace(existing.AsterSigner)
}
effectiveLighterWalletAddr := strings.TrimSpace(exchangeData.LighterWalletAddr)
if effectiveLighterWalletAddr == "" {
effectiveLighterWalletAddr = strings.TrimSpace(existing.LighterWalletAddr)
}
effectiveHyperliquidBuilderApproved := existing.HyperliquidBuilderApproved
if exchangeData.HyperliquidBuilderApproved != nil {
effectiveHyperliquidBuilderApproved = *exchangeData.HyperliquidBuilderApproved
}
effectiveHyperliquidUnifiedAcct := effectiveHyperliquidUnifiedAccount(
existing.ExchangeType,
exchangeData.HyperliquidUnifiedAcct,
existing.HyperliquidUnifiedAcct,
)
if missing := store.MissingRequiredExchangeCredentialFields(
existing.ExchangeType,
effectiveAPIKey,
effectiveSecretKey,
effectivePassphrase,
effectiveHyperliquidWalletAddr,
effectiveAsterUser,
effectiveAsterSigner,
effectiveAsterPrivateKey,
effectiveLighterWalletAddr,
effectiveLighterAPIKeyPrivateKey,
); len(missing) > 0 {
c.JSON(http.StatusBadRequest, gin.H{
"error": fmt.Sprintf("Missing required exchange fields: %s", strings.Join(missing, ", ")),
"missing_fields": missing,
})
return
}
// Find traders using this exchange BEFORE updating
traders, _ := s.store.Trader().ListByExchangeID(userID, exchangeID)
for _, t := range traders {
tradersToReload[t.ID] = true
}
err = s.store.Exchange().Update(userID, exchangeID, true, exchangeData.APIKey, exchangeData.SecretKey, exchangeData.Passphrase, exchangeData.Testnet, effectiveHyperliquidWalletAddr, effectiveHyperliquidUnifiedAcct, effectiveHyperliquidBuilderApproved, effectiveAsterUser, effectiveAsterSigner, exchangeData.AsterPrivateKey, effectiveLighterWalletAddr, exchangeData.LighterPrivateKey, exchangeData.LighterAPIKeyPrivateKey, exchangeData.LighterAPIKeyIndex)
if err != nil {
SafeInternalError(c, fmt.Sprintf("Update exchange %s", exchangeID), err)
return
}
}
s.exchangeAccountStateCache.Invalidate(userID)
// Remove affected traders from memory BEFORE reloading to pick up new config
for traderID := range tradersToReload {
logger.Infof("🔄 Removing trader %s from memory to reload with new exchange config", traderID)
s.traderManager.RemoveTrader(traderID)
}
// Reload all traders for this user to make new config take effect immediately
err = s.traderManager.LoadUserTradersFromStore(s.store, userID)
if err != nil {
logger.Infof("⚠️ Failed to reload user traders into memory: %v", err)
// Don't return error here since exchange config was successfully updated to database
}
logger.Infof("✓ Exchange config updated: %+v", SanitizeExchangeConfigForLog(req.Exchanges))
c.JSON(http.StatusOK, gin.H{"message": "Exchange configuration updated"})
}
// handleCreateExchange Create a new exchange account
func (s *Server) handleCreateExchange(c *gin.Context) {
userID := c.GetString("user_id")
cfg := config.Get()
// Read raw request body
bodyBytes, err := c.GetRawData()
if err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "Failed to read request body"})
return
}
var req CreateExchangeRequest
// Check if transport encryption is enabled
if !cfg.TransportEncryption {
// Transport encryption disabled, accept plain JSON
if err := json.Unmarshal(bodyBytes, &req); err != nil {
logger.Infof("❌ Failed to parse plain JSON request: %v", err)
c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid request format"})
return
}
} else {
// Transport encryption enabled, require encrypted payload
var encryptedPayload crypto.EncryptedPayload
if err := json.Unmarshal(bodyBytes, &encryptedPayload); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid request format, encrypted transmission required"})
return
}
if encryptedPayload.WrappedKey == "" {
c.JSON(http.StatusBadRequest, gin.H{
"error": "This endpoint only supports encrypted transmission",
"code": "ENCRYPTION_REQUIRED",
"message": "Encrypted transmission is required for security reasons",
})
return
}
decrypted, err := s.cryptoHandler.cryptoService.DecryptSensitiveData(&encryptedPayload)
if err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "Failed to decrypt data"})
return
}
if err := json.Unmarshal([]byte(decrypted), &req); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "Failed to parse decrypted data"})
return
}
}
// Validate exchange type
validTypes := map[string]bool{
"binance": true, "bybit": true, "okx": true, "bitget": true,
"hyperliquid": true, "aster": true, "lighter": true, "gate": true, "kucoin": true, "indodax": true,
}
if !validTypes[req.ExchangeType] {
c.JSON(http.StatusBadRequest, gin.H{"error": fmt.Sprintf("Invalid exchange type: %s", req.ExchangeType)})
return
}
if missing := store.MissingRequiredExchangeCredentialFields(
req.ExchangeType,
req.APIKey,
req.SecretKey,
req.Passphrase,
req.HyperliquidWalletAddr,
req.AsterUser,
req.AsterSigner,
req.AsterPrivateKey,
req.LighterWalletAddr,
req.LighterAPIKeyPrivateKey,
); len(missing) > 0 {
c.JSON(http.StatusBadRequest, gin.H{
"error": fmt.Sprintf("Missing required exchange fields: %s", strings.Join(missing, ", ")),
"missing_fields": missing,
})
return
}
// Exchange configs only persist once complete; persisted configs are always enabled.
effectiveHyperliquidUnifiedAcct := effectiveHyperliquidUnifiedAccount(req.ExchangeType, req.HyperliquidUnifiedAcct)
id, err := s.store.Exchange().Create(
userID, req.ExchangeType, req.AccountName, true,
req.APIKey, req.SecretKey, req.Passphrase, req.Testnet,
req.HyperliquidWalletAddr, effectiveHyperliquidUnifiedAcct, req.HyperliquidBuilderApproved,
req.AsterUser, req.AsterSigner, req.AsterPrivateKey,
req.LighterWalletAddr, req.LighterPrivateKey, req.LighterAPIKeyPrivateKey, req.LighterAPIKeyIndex,
)
if err != nil {
logger.Infof("❌ Failed to create exchange account: %v", err)
SafeInternalError(c, "Failed to create exchange account", err)
return
}
s.exchangeAccountStateCache.Invalidate(userID)
logger.Infof("✓ Created exchange account: type=%s, name=%s, id=%s", req.ExchangeType, req.AccountName, id)
c.JSON(http.StatusOK, gin.H{
"message": "Exchange account created",
"id": id,
})
}
// handleDeleteExchange Delete an exchange account
func (s *Server) handleDeleteExchange(c *gin.Context) {
userID := c.GetString("user_id")
exchangeID := c.Param("id")
if exchangeID == "" {
c.JSON(http.StatusBadRequest, gin.H{"error": "Exchange ID is required"})
return
}
// Check if any traders are using this exchange
traders, err := s.store.Trader().List(userID)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to check traders"})
return
}
for _, trader := range traders {
if trader.ExchangeID == exchangeID {
c.JSON(http.StatusBadRequest, gin.H{
"error": "Cannot delete exchange account that is in use by traders",
"trader_id": trader.ID,
"trader_name": trader.Name,
})
return
}
}
// Delete exchange account
err = s.store.Exchange().Delete(userID, exchangeID)
if err != nil {
logger.Infof("❌ Failed to delete exchange account: %v", err)
SafeInternalError(c, "Failed to delete exchange account", err)
return
}
s.exchangeAccountStateCache.Invalidate(userID)
logger.Infof("✓ Deleted exchange account: id=%s", exchangeID)
c.JSON(http.StatusOK, gin.H{"message": "Exchange account deleted"})
}
// handleGetSupportedExchanges Get list of exchanges supported by the system
func (s *Server) handleGetSupportedExchanges(c *gin.Context) {
// Return static list of supported exchange types
// Note: ID is empty for supported exchanges (they are templates, not actual accounts)
supportedExchanges := []SafeExchangeConfig{
{ExchangeType: "binance", Name: "Binance Futures", Type: "cex"},
{ExchangeType: "bybit", Name: "Bybit Futures", Type: "cex"},
{ExchangeType: "okx", Name: "OKX Futures", Type: "cex"},
{ExchangeType: "gate", Name: "Gate.io Futures", Type: "cex"},
{ExchangeType: "kucoin", Name: "KuCoin Futures", Type: "cex"},
{ExchangeType: "hyperliquid", Name: "Hyperliquid", Type: "dex"},
{ExchangeType: "aster", Name: "Aster DEX", Type: "dex"},
{ExchangeType: "lighter", Name: "LIGHTER DEX", Type: "dex"},
{ExchangeType: "alpaca", Name: "Alpaca (US Stocks)", Type: "stock"},
{ExchangeType: "forex", Name: "Forex (TwelveData)", Type: "forex"},
{ExchangeType: "metals", Name: "Metals (TwelveData)", Type: "metals"},
}
c.JSON(http.StatusOK, supportedExchanges)
}

View File

@@ -1,66 +0,0 @@
package api
import (
"testing"
"nofx/crypto"
"nofx/store"
)
func TestSafeExchangeConfigFromStoreIncludesCredentialPresenceFlags(t *testing.T) {
cfg := &store.Exchange{
ID: "ex-1",
ExchangeType: "okx",
AccountName: "OKX Main",
Name: "OKX Main",
Type: "cex",
Enabled: true,
APIKey: crypto.EncryptedString("api-test-123"),
SecretKey: crypto.EncryptedString("secret-test-123"),
Passphrase: crypto.EncryptedString("passphrase-test-123"),
HyperliquidUnifiedAcct: true,
AsterPrivateKey: crypto.EncryptedString("aster-private-key"),
LighterPrivateKey: crypto.EncryptedString("lighter-private-key"),
LighterAPIKeyPrivateKey: crypto.EncryptedString("lighter-api-key-private-key"),
}
safe := safeExchangeConfigFromStore(cfg)
if !safe.HasAPIKey {
t.Fatalf("expected has_api_key to be true")
}
if !safe.HasSecretKey {
t.Fatalf("expected has_secret_key to be true")
}
if !safe.HasPassphrase {
t.Fatalf("expected has_passphrase to be true")
}
if !safe.HasAsterPrivateKey {
t.Fatalf("expected has_aster_private_key to be true")
}
if !safe.HasLighterPrivateKey {
t.Fatalf("expected has_lighter_private_key to be true")
}
if !safe.HasLighterAPIKey {
t.Fatalf("expected has_lighter_api_key_private_key to be true")
}
if !safe.HyperliquidUnifiedAcct {
t.Fatalf("expected hyperliquid unified account to be exposed")
}
}
func TestEffectiveHyperliquidUnifiedAccountDefaultsAndPreserves(t *testing.T) {
if !effectiveHyperliquidUnifiedAccount("hyperliquid", nil) {
t.Fatalf("expected new hyperliquid accounts to default unified account on")
}
if effectiveHyperliquidUnifiedAccount("binance", nil) {
t.Fatalf("expected non-hyperliquid accounts to default unified account off")
}
fallbackFalse := effectiveHyperliquidUnifiedAccount("hyperliquid", nil, false)
if fallbackFalse {
t.Fatalf("expected omitted update field to preserve existing false value")
}
requestedTrue := true
if !effectiveHyperliquidUnifiedAccount("hyperliquid", &requestedTrue, false) {
t.Fatalf("expected explicit true to override existing false value")
}
}

View File

@@ -1,413 +0,0 @@
package api
import (
"bytes"
"encoding/json"
"fmt"
"io"
"net/http"
"regexp"
"strconv"
"strings"
"time"
"github.com/gin-gonic/gin"
)
const (
defaultHyperliquidBuilderAddress = "0x891dc6f05ad47a3c1a05da55e7a7517971faaf0d"
// 0.05% (万5) — matches BuilderInfo.Fee=50 charged at order placement.
// New wallet approvals sign this exact value; existing approvals at the
// prior 0.1% cap remain valid because 0.05% is within their approved max.
defaultHyperliquidBuilderMaxFee = "0.05%"
hyperliquidExchangeURL = "https://api.hyperliquid.xyz/exchange"
hyperliquidInfoURL = "https://api.hyperliquid.xyz/info"
// nofxHyperliquidAgentName must match AGENT_NAME used by the frontend
// approveAgent flow so we can locate the NOFX-managed agent on-chain.
nofxHyperliquidAgentName = "NOFX Agent"
)
type hyperliquidSubmitRequest struct {
Action map[string]any `json:"action" binding:"required"`
Nonce int64 `json:"nonce" binding:"required"`
Signature struct {
R string `json:"r" binding:"required"`
S string `json:"s" binding:"required"`
V int `json:"v"`
} `json:"signature" binding:"required"`
}
type hyperliquidConfigResponse struct {
BuilderAddress string `json:"builderAddress"`
BuilderMaxFee string `json:"builderMaxFee"`
Chain string `json:"chain"`
SignatureChain string `json:"signatureChainId"`
}
type hyperliquidAccountSummary struct {
Address string `json:"address"`
AccountValue float64 `json:"accountValue"`
Withdrawable float64 `json:"withdrawable"`
TotalMarginUsed float64 `json:"totalMarginUsed"`
UnrealizedPnl float64 `json:"unrealizedPnl"`
OpenPositions int `json:"openPositions"`
UpdatedAt int64 `json:"updatedAt"`
}
type hyperliquidAgentInfo struct {
Name string `json:"name"`
Address string `json:"address"`
ValidUntil int64 `json:"validUntil"` // unix milliseconds
}
type hyperliquidAgentResponse struct {
// Agent is the NOFX-managed agent ("NOFX Agent"), nil when none is approved.
Agent *hyperliquidAgentInfo `json:"agent"`
// Agents lists every approved agent for the wallet (for visibility/cleanup).
Agents []hyperliquidAgentInfo `json:"agents"`
}
type hyperliquidClearinghouseState struct {
MarginSummary struct {
AccountValue string `json:"accountValue"`
TotalMarginUsed string `json:"totalMarginUsed"`
} `json:"marginSummary"`
CrossMarginSummary struct {
AccountValue string `json:"accountValue"`
TotalMarginUsed string `json:"totalMarginUsed"`
} `json:"crossMarginSummary"`
Withdrawable string `json:"withdrawable"`
AssetPositions []struct {
Position struct {
Szi string `json:"szi"`
UnrealizedPnl string `json:"unrealizedPnl"`
} `json:"position"`
} `json:"assetPositions"`
}
// agentValidUntilSuffix matches the " valid_until <ms>" suffix Hyperliquid uses
// to encode an agent's expiry inside the agent name. Hyperliquid normally strips
// it from the stored name, but we strip defensively before matching the slot.
var agentValidUntilSuffix = regexp.MustCompile(` valid_until \d+$`)
func baseAgentName(name string) string {
return strings.TrimSpace(agentValidUntilSuffix.ReplaceAllString(name, ""))
}
func hyperliquidBuilderAddress() string {
return defaultHyperliquidBuilderAddress
}
func hyperliquidBuilderMaxFee() string {
return defaultHyperliquidBuilderMaxFee
}
func (s *Server) handleHyperliquidConnectConfig(c *gin.Context) {
c.JSON(http.StatusOK, hyperliquidConfigResponse{
BuilderAddress: hyperliquidBuilderAddress(),
BuilderMaxFee: hyperliquidBuilderMaxFee(),
Chain: "Mainnet",
SignatureChain: "0x66eee",
})
}
func (s *Server) handleHyperliquidAccount(c *gin.Context) {
address := strings.ToLower(strings.TrimSpace(c.Query("address")))
if !isEVMAddress(address) {
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid Hyperliquid wallet address"})
return
}
requestBody := map[string]any{
"type": "clearinghouseState",
"user": address,
}
body, err := json.Marshal(requestBody)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to encode Hyperliquid balance request"})
return
}
req, err := http.NewRequestWithContext(c.Request.Context(), http.MethodPost, hyperliquidInfoURL, bytes.NewReader(body))
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to create Hyperliquid balance request"})
return
}
req.Header.Set("Content-Type", "application/json")
client := &http.Client{Timeout: 20 * time.Second}
resp, err := client.Do(req)
if err != nil {
c.JSON(http.StatusBadGateway, gin.H{"error": "failed to reach Hyperliquid", "detail": err.Error()})
return
}
defer resp.Body.Close()
respBody, _ := io.ReadAll(io.LimitReader(resp.Body, 1<<20))
if resp.StatusCode < 200 || resp.StatusCode >= 300 {
c.JSON(http.StatusBadGateway, gin.H{"error": "Hyperliquid rejected the balance request", "status": resp.StatusCode})
return
}
var state hyperliquidClearinghouseState
if err := json.Unmarshal(respBody, &state); err != nil {
c.JSON(http.StatusBadGateway, gin.H{"error": "failed to parse Hyperliquid balance response"})
return
}
accountValue := parseFloatOrZero(state.MarginSummary.AccountValue)
if accountValue == 0 {
accountValue = parseFloatOrZero(state.CrossMarginSummary.AccountValue)
}
marginUsed := parseFloatOrZero(state.MarginSummary.TotalMarginUsed)
if marginUsed == 0 {
marginUsed = parseFloatOrZero(state.CrossMarginSummary.TotalMarginUsed)
}
var unrealizedPnl float64
openPositions := 0
for _, position := range state.AssetPositions {
size := parseFloatOrZero(position.Position.Szi)
if size != 0 {
openPositions++
}
unrealizedPnl += parseFloatOrZero(position.Position.UnrealizedPnl)
}
c.JSON(http.StatusOK, hyperliquidAccountSummary{
Address: address,
AccountValue: accountValue,
Withdrawable: parseFloatOrZero(state.Withdrawable),
TotalMarginUsed: marginUsed,
UnrealizedPnl: unrealizedPnl,
OpenPositions: openPositions,
UpdatedAt: time.Now().UnixMilli(),
})
}
// handleHyperliquidAgent reports the on-chain approved agents for a wallet,
// including the NOFX agent's validUntil so the UI can show the expiry date and
// warn before the 180-day authorization lapses.
func (s *Server) handleHyperliquidAgent(c *gin.Context) {
address := strings.ToLower(strings.TrimSpace(c.Query("address")))
if !isEVMAddress(address) {
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid Hyperliquid wallet address"})
return
}
body, err := json.Marshal(map[string]any{"type": "extraAgents", "user": address})
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to encode Hyperliquid agent request"})
return
}
req, err := http.NewRequestWithContext(c.Request.Context(), http.MethodPost, hyperliquidInfoURL, bytes.NewReader(body))
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to create Hyperliquid agent request"})
return
}
req.Header.Set("Content-Type", "application/json")
client := &http.Client{Timeout: 20 * time.Second}
resp, err := client.Do(req)
if err != nil {
c.JSON(http.StatusBadGateway, gin.H{"error": "failed to reach Hyperliquid", "detail": err.Error()})
return
}
defer resp.Body.Close()
respBody, _ := io.ReadAll(io.LimitReader(resp.Body, 1<<20))
if resp.StatusCode < 200 || resp.StatusCode >= 300 {
c.JSON(http.StatusBadGateway, gin.H{"error": "Hyperliquid rejected the agent request", "status": resp.StatusCode})
return
}
// extraAgents returns null when no agents are approved.
agents := []hyperliquidAgentInfo{}
if len(respBody) > 0 && string(bytes.TrimSpace(respBody)) != "null" {
if err := json.Unmarshal(respBody, &agents); err != nil {
c.JSON(http.StatusBadGateway, gin.H{"error": "failed to parse Hyperliquid agent response"})
return
}
}
out := hyperliquidAgentResponse{Agents: agents}
for i := range agents {
if strings.EqualFold(baseAgentName(agents[i].Name), nofxHyperliquidAgentName) {
agent := agents[i]
out.Agent = &agent
break
}
}
c.JSON(http.StatusOK, out)
}
func (s *Server) handleHyperliquidSubmitExchange(c *gin.Context) {
var req hyperliquidSubmitRequest
if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid Hyperliquid submit payload"})
return
}
if err := validateSubmittedNonce(req.Action, req.Nonce); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
actionType, _ := req.Action["type"].(string)
switch actionType {
case "approveAgent":
if err := validateApproveAgentAction(req.Action); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
case "approveBuilderFee":
if err := validateApproveBuilderFeeAction(req.Action); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
default:
c.JSON(http.StatusBadRequest, gin.H{"error": "unsupported Hyperliquid action"})
return
}
payload := map[string]any{
"action": req.Action,
"nonce": req.Nonce,
"signature": req.Signature,
}
body, err := json.Marshal(payload)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to encode Hyperliquid payload"})
return
}
client := &http.Client{Timeout: 20 * time.Second}
hlReq, err := http.NewRequestWithContext(c.Request.Context(), http.MethodPost, hyperliquidExchangeURL, bytes.NewReader(body))
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to create Hyperliquid request"})
return
}
hlReq.Header.Set("Content-Type", "application/json")
resp, err := client.Do(hlReq)
if err != nil {
c.JSON(http.StatusBadGateway, gin.H{"error": "failed to reach Hyperliquid", "detail": err.Error()})
return
}
defer resp.Body.Close()
respBody, _ := io.ReadAll(io.LimitReader(resp.Body, 1<<20))
var decoded any
if len(respBody) > 0 {
_ = json.Unmarshal(respBody, &decoded)
}
if resp.StatusCode < 200 || resp.StatusCode >= 300 {
c.JSON(http.StatusBadGateway, gin.H{"error": "Hyperliquid rejected the action", "status": resp.StatusCode, "response": decoded})
return
}
// Hyperliquid returns HTTP 200 even for logical failures, signalling them via
// {"status":"err","response":"<message>"}. Without this check a rejected
// approval (e.g. valid_until past the cap, or an unchanged agent) is reported
// to the user as success while nothing changes on-chain.
var hlResp struct {
Status string `json:"status"`
Response json.RawMessage `json:"response"`
}
if err := json.Unmarshal(respBody, &hlResp); err == nil && strings.EqualFold(hlResp.Status, "err") {
msg := strings.TrimSpace(strings.Trim(string(hlResp.Response), `"`))
if msg == "" {
msg = "Hyperliquid rejected the action"
}
c.JSON(http.StatusBadGateway, gin.H{"error": msg, "response": decoded})
return
}
c.JSON(http.StatusOK, gin.H{"success": true, "response": decoded})
}
func validateApproveAgentAction(action map[string]any) error {
if strings.TrimSpace(fmt.Sprint(action["agentAddress"])) == "" {
return fmt.Errorf("missing agentAddress")
}
if strings.TrimSpace(fmt.Sprint(action["agentName"])) == "" {
return fmt.Errorf("missing agentName")
}
return validateCommonHyperliquidSignedAction(action)
}
func validateApproveBuilderFeeAction(action map[string]any) error {
builder := strings.ToLower(strings.TrimSpace(fmt.Sprint(action["builder"])))
if builder != hyperliquidBuilderAddress() {
return fmt.Errorf("builder address mismatch")
}
if strings.TrimSpace(fmt.Sprint(action["maxFeeRate"])) != hyperliquidBuilderMaxFee() {
return fmt.Errorf("builder max fee mismatch")
}
return validateCommonHyperliquidSignedAction(action)
}
func validateCommonHyperliquidSignedAction(action map[string]any) error {
if strings.TrimSpace(fmt.Sprint(action["signatureChainId"])) != "0x66eee" {
return fmt.Errorf("invalid signatureChainId")
}
if strings.TrimSpace(fmt.Sprint(action["hyperliquidChain"])) != "Mainnet" {
return fmt.Errorf("invalid hyperliquidChain")
}
if _, err := actionNonce(action); err != nil {
return err
}
return nil
}
func validateSubmittedNonce(action map[string]any, submitted int64) error {
actionValue, err := actionNonce(action)
if err != nil {
return err
}
if actionValue != submitted {
return fmt.Errorf("nonce mismatch")
}
return nil
}
func isEVMAddress(address string) bool {
if len(address) != 42 || !strings.HasPrefix(address, "0x") {
return false
}
for _, char := range address[2:] {
if (char < '0' || char > '9') && (char < 'a' || char > 'f') {
return false
}
}
return true
}
func parseFloatOrZero(value string) float64 {
parsed, err := strconv.ParseFloat(strings.TrimSpace(value), 64)
if err != nil {
return 0
}
return parsed
}
func actionNonce(action map[string]any) (int64, error) {
raw, ok := action["nonce"]
if !ok {
return 0, fmt.Errorf("missing nonce")
}
switch value := raw.(type) {
case float64:
return int64(value), nil
case int64:
return value, nil
case json.Number:
return value.Int64()
case string:
return strconv.ParseInt(value, 10, 64)
default:
return 0, fmt.Errorf("invalid nonce")
}
}

View File

@@ -1,548 +0,0 @@
package api
import (
"context"
"fmt"
"net/http"
"sort"
"strconv"
"strings"
"time"
"nofx/logger"
"nofx/market"
"nofx/provider/alpaca"
"nofx/provider/coinank/coinank_api"
"nofx/provider/coinank/coinank_enum"
"nofx/provider/hyperliquid"
"nofx/provider/twelvedata"
"github.com/gin-gonic/gin"
)
// handleKlines K-line data (supports multiple exchanges via coinank)
func (s *Server) handleKlines(c *gin.Context) {
// Get query parameters
symbol := c.Query("symbol")
if symbol == "" {
c.JSON(http.StatusBadRequest, gin.H{"error": "symbol parameter is required"})
return
}
interval := c.DefaultQuery("interval", "5m")
exchange := c.DefaultQuery("exchange", "binance") // Default to binance for backward compatibility
limitStr := c.DefaultQuery("limit", "1000")
limit, err := strconv.Atoi(limitStr)
if err != nil || limit <= 0 {
limit = 1000
}
// Coinank API has a maximum limit of 1500 klines per request
if limit > 1500 {
limit = 1500
}
var klines []market.Kline
exchangeLower := strings.ToLower(exchange)
// Route to appropriate data source based on exchange type
switch exchangeLower {
case "alpaca":
// US Stocks via Alpaca
klines, err = s.getKlinesFromAlpaca(symbol, interval, limit)
if err != nil {
SafeInternalError(c, "Get klines from Alpaca", err)
return
}
case "forex", "metals":
// Forex and Metals via Twelve Data
klines, err = s.getKlinesFromTwelveData(symbol, interval, limit)
if err != nil {
SafeInternalError(c, "Get klines from TwelveData", err)
return
}
case "hyperliquid", "hyperliquid-xyz", "xyz":
// Hyperliquid native API - supports both crypto perps and stock perps (xyz dex)
klines, err = s.getKlinesFromHyperliquid(symbol, interval, limit)
if err != nil {
SafeInternalError(c, "Get klines from Hyperliquid", err)
return
}
default:
// Crypto exchanges via CoinAnk
symbol = market.Normalize(symbol)
klines, err = s.getKlinesFromCoinank(symbol, interval, exchange, limit)
if err != nil {
SafeInternalError(c, "Get klines from CoinAnk", err)
return
}
}
c.JSON(http.StatusOK, klines)
}
// getKlinesFromCoinank fetches kline data from coinank free/open API for multiple exchanges
func (s *Server) getKlinesFromCoinank(symbol, interval, exchange string, limit int) ([]market.Kline, error) {
// Map exchange string to coinank enum
var coinankExchange coinank_enum.Exchange
switch strings.ToLower(exchange) {
case "binance":
coinankExchange = coinank_enum.Binance
case "bybit":
coinankExchange = coinank_enum.Bybit
case "okx":
coinankExchange = coinank_enum.Okex
case "bitget":
coinankExchange = coinank_enum.Bitget
case "gate":
coinankExchange = coinank_enum.Gate
case "aster":
coinankExchange = coinank_enum.Aster
case "lighter":
// Lighter doesn't have direct CoinAnk support, use Binance data as fallback
coinankExchange = coinank_enum.Binance
case "kucoin":
// KuCoin doesn't have direct CoinAnk support, use Binance data as fallback
coinankExchange = coinank_enum.Binance
default:
// For any unknown exchange, default to Binance
logger.Warnf("⚠️ Unknown exchange '%s', defaulting to Binance for CoinAnk", exchange)
coinankExchange = coinank_enum.Binance
}
// Map interval string to coinank enum
var coinankInterval coinank_enum.Interval
switch interval {
case "1s":
coinankInterval = coinank_enum.Second1
case "5s":
coinankInterval = coinank_enum.Second5
case "10s":
coinankInterval = coinank_enum.Second10
case "30s":
coinankInterval = coinank_enum.Second30
case "1m":
coinankInterval = coinank_enum.Minute1
case "3m":
coinankInterval = coinank_enum.Minute3
case "5m":
coinankInterval = coinank_enum.Minute5
case "10m":
coinankInterval = coinank_enum.Minute10
case "15m":
coinankInterval = coinank_enum.Minute15
case "30m":
coinankInterval = coinank_enum.Minute30
case "1h":
coinankInterval = coinank_enum.Hour1
case "2h":
coinankInterval = coinank_enum.Hour2
case "4h":
coinankInterval = coinank_enum.Hour4
case "6h":
coinankInterval = coinank_enum.Hour6
case "8h":
coinankInterval = coinank_enum.Hour8
case "12h":
coinankInterval = coinank_enum.Hour12
case "1d":
coinankInterval = coinank_enum.Day1
case "3d":
coinankInterval = coinank_enum.Day3
case "1w":
coinankInterval = coinank_enum.Week1
case "1M":
coinankInterval = coinank_enum.Month1
default:
return nil, fmt.Errorf("unsupported interval for coinank: %s", interval)
}
// Convert symbol format for different exchanges
// OKX uses "BTC-USDT-SWAP" format instead of "BTCUSDT"
apiSymbol := symbol
if coinankExchange == coinank_enum.Okex {
// Convert BTCUSDT -> BTC-USDT-SWAP
if strings.HasSuffix(symbol, "USDT") {
base := strings.TrimSuffix(symbol, "USDT")
apiSymbol = fmt.Sprintf("%s-USDT-SWAP", base)
}
}
// Call coinank free/open API (no authentication required)
ctx := context.Background()
ts := time.Now().UnixMilli()
// Use "To" side to search backward from current time (get historical klines)
coinankKlines, err := coinank_api.Kline(ctx, apiSymbol, coinankExchange, ts, coinank_enum.To, limit, coinankInterval)
if err != nil {
// Free API doesn't support all exchanges (e.g., OKX, Bitget)
// Fallback to Binance data as reference
if coinankExchange != coinank_enum.Binance {
logger.Warnf("⚠️ CoinAnk free API doesn't support %s, falling back to Binance data", coinankExchange)
coinankKlines, err = coinank_api.Kline(ctx, symbol, coinank_enum.Binance, ts, coinank_enum.To, limit, coinankInterval)
if err != nil {
return nil, fmt.Errorf("coinank API error (fallback): %w", err)
}
} else {
return nil, fmt.Errorf("coinank API error: %w", err)
}
}
// Convert coinank kline format to market.Kline format
// Coinank: Volume = BTC quantity, Quantity = USDT turnover
klines := make([]market.Kline, len(coinankKlines))
for i, ck := range coinankKlines {
klines[i] = market.Kline{
OpenTime: ck.StartTime,
Open: ck.Open,
High: ck.High,
Low: ck.Low,
Close: ck.Close,
Volume: ck.Volume, // BTC quantity
QuoteVolume: ck.Quantity, // USDT turnover
CloseTime: ck.EndTime,
}
}
return klines, nil
}
// getKlinesFromAlpaca fetches kline data from Alpaca API for US stocks
func (s *Server) getKlinesFromAlpaca(symbol, interval string, limit int) ([]market.Kline, error) {
// Create Alpaca client
client := alpaca.NewClient()
// Map interval to Alpaca timeframe format
timeframe := alpaca.MapTimeframe(interval)
// Fetch bars from Alpaca
ctx := context.Background()
bars, err := client.GetBars(ctx, symbol, timeframe, limit)
if err != nil {
return nil, fmt.Errorf("alpaca API error: %w", err)
}
// Convert Alpaca bars to market.Kline format
klines := make([]market.Kline, len(bars))
for i, bar := range bars {
klines[i] = market.Kline{
OpenTime: bar.Timestamp.UnixMilli(),
Open: bar.Open,
High: bar.High,
Low: bar.Low,
Close: bar.Close,
Volume: float64(bar.Volume), // share count
QuoteVolume: float64(bar.Volume) * bar.Close, // turnover = shares * close price (USD)
CloseTime: bar.Timestamp.UnixMilli(),
}
}
return klines, nil
}
// getKlinesFromTwelveData fetches kline data from Twelve Data API for forex and metals
func (s *Server) getKlinesFromTwelveData(symbol, interval string, limit int) ([]market.Kline, error) {
// Create Twelve Data client
client := twelvedata.NewClient()
// Map interval to Twelve Data timeframe format
timeframe := twelvedata.MapTimeframe(interval)
// Fetch time series from Twelve Data
ctx := context.Background()
result, err := client.GetTimeSeries(ctx, symbol, timeframe, limit)
if err != nil {
return nil, fmt.Errorf("twelvedata API error: %w", err)
}
// Convert Twelve Data bars to market.Kline format
// Note: Twelve Data returns bars in reverse order (newest first)
klines := make([]market.Kline, len(result.Values))
for i, bar := range result.Values {
open, high, low, close, volume, timestamp, err := twelvedata.ParseBar(bar)
if err != nil {
logger.Warnf("⚠️ Failed to parse TwelveData bar: %v", err)
continue
}
// Reverse order: put oldest first
idx := len(result.Values) - 1 - i
klines[idx] = market.Kline{
OpenTime: timestamp,
Open: open,
High: high,
Low: low,
Close: close,
Volume: volume,
CloseTime: timestamp,
}
}
return klines, nil
}
// getKlinesFromHyperliquid fetches kline data from Hyperliquid API
// Supports both crypto perps (default dex) and stock perps/forex/commodities (xyz dex)
func (s *Server) getKlinesFromHyperliquid(symbol, interval string, limit int) ([]market.Kline, error) {
// Create Hyperliquid client
client := hyperliquid.NewClient()
// Map interval to Hyperliquid format
timeframe := hyperliquid.MapTimeframe(interval)
// Fetch candles from Hyperliquid
// FormatCoinForAPI will automatically add xyz: prefix for stock perps
ctx := context.Background()
candles, err := client.GetCandles(ctx, symbol, timeframe, limit)
if err != nil {
return nil, fmt.Errorf("hyperliquid API error: %w", err)
}
// Convert Hyperliquid candles to market.Kline format
klines := make([]market.Kline, len(candles))
for i, candle := range candles {
open, _ := strconv.ParseFloat(candle.Open, 64)
high, _ := strconv.ParseFloat(candle.High, 64)
low, _ := strconv.ParseFloat(candle.Low, 64)
close, _ := strconv.ParseFloat(candle.Close, 64)
volume, _ := strconv.ParseFloat(candle.Volume, 64)
klines[i] = market.Kline{
OpenTime: candle.OpenTime,
Open: open,
High: high,
Low: low,
Close: close,
Volume: volume, // contract quantity
QuoteVolume: volume * close, // turnover (USD)
CloseTime: candle.CloseTime,
}
}
return klines, nil
}
func hyperliquidXYZDisplayBase(baseSymbol string) string {
baseSymbol = strings.ToUpper(strings.TrimSpace(baseSymbol))
// User-facing names should be product names, not exchange shorthand tickers.
// Keep the internal symbol separate because Hyperliquid's xyz dex still routes
// orders/candles by the short coin name (for example xyz:SMSN).
fullNames := map[string]string{
"XYZ100": "XYZ100",
"TSLA": "TESLA",
"NVDA": "NVIDIA",
"GOLD": "GOLD",
"HOOD": "ROBINHOOD",
"INTC": "INTEL",
"PLTR": "PALANTIR",
"COIN": "COINBASE",
"META": "META",
"AAPL": "APPLE",
"MSFT": "MICROSOFT",
"ORCL": "ORACLE",
"GOOGL": "GOOGLE",
"AMZN": "AMAZON",
"AMD": "AMD",
"MU": "MICRON",
"SNDK": "SANDISK",
"MSTR": "MICROSTRATEGY",
"CRCL": "CIRCLE",
"NFLX": "NETFLIX",
"COST": "COSTCO",
"LLY": "ELI-LILLY",
"SKHX": "SK-HYNIX",
"TSM": "TSMC",
"JPY": "JPY",
"EUR": "EUR",
"SILVER": "SILVER",
"RIVN": "RIVIAN",
"BABA": "ALIBABA",
"CL": "CRUDE-OIL",
"COPPER": "COPPER",
"NATGAS": "NATURAL-GAS",
"URANIUM": "URANIUM",
"ALUMINIUM": "ALUMINIUM",
"SMSN": "SAMSUNG",
"PLATINUM": "PLATINUM",
"USAR": "USA-RARE-EARTH",
"CRWV": "COREWEAVE",
"URNM": "URNM",
"PALLADIUM": "PALLADIUM",
"DXY": "DOLLAR-INDEX",
"GME": "GAMESTOP",
"KR200": "KOREA-200",
"SOFTBANK": "SOFTBANK",
"JP225": "JAPAN-225",
"HYUNDAI": "HYUNDAI",
"KIOXIA": "KIOXIA",
"EWY": "SOUTH-KOREA-ETF",
"EWJ": "JAPAN-ETF",
"BRENTOIL": "BRENT-OIL",
"VIX": "VIX",
"HIMS": "HIMS-HERS",
"SP500": "S&P-500",
"DKNG": "DRAFTKINGS",
"LITE": "LITECOIN",
"CORN": "CORN",
"XLE": "ENERGY-SECTOR-ETF",
"WHEAT": "WHEAT",
"TTF": "TTF-GAS",
"BX": "BLACKSTONE",
"PURRDAT": "PURRDAT",
"MRVL": "MARVELL",
"RKLB": "ROCKET-LAB",
"BIRD": "BIRD",
"VOL": "VOLATILITY",
"DRAM": "DRAM",
"CBRS": "COINBASE-PRE-IPO",
"EWZ": "BRAZIL-ETF",
"KRW": "KRW",
"ZM": "ZOOM",
"EBAY": "EBAY",
"H100": "H100",
"NIFTY": "NIFTY-50",
"ARM": "ARM",
"EWT": "TAIWAN-ETF",
"GBP": "GBP",
"SPCX": "SPACEX-PRE-IPO",
"IBOV": "IBOVESPA",
"ASML": "ASML",
}
if fullName, ok := fullNames[baseSymbol]; ok {
return fullName
}
return baseSymbol
}
func hyperliquidXYZCategory(baseSymbol string) string {
baseSymbol = strings.ToUpper(strings.TrimSpace(baseSymbol))
switch baseSymbol {
case "GOLD", "SILVER", "CL", "COPPER", "NATGAS", "URANIUM", "ALUMINIUM", "PLATINUM", "PALLADIUM", "BRENTOIL", "CORN", "WHEAT", "TTF":
return "commodity"
case "XYZ100", "SP500", "JP225", "KR200", "DXY", "VIX", "XLE", "EWY", "EWJ", "EWZ", "EWT", "NIFTY", "IBOV":
return "index"
case "EUR", "JPY", "GBP", "KRW":
return "forex"
case "SPCX", "BIRD", "PURRDAT", "H100", "CBRS":
return "pre_ipo"
default:
return "stock"
}
}
func hyperliquidCategoryOrder(category string) int {
switch category {
case "stock":
return 0
case "commodity":
return 1
case "index":
return 2
case "forex":
return 3
case "pre_ipo":
return 4
case "crypto":
return 5
default:
return 99
}
}
// handleSymbols returns available symbols for a given exchange
func (s *Server) handleSymbols(c *gin.Context) {
exchange := c.DefaultQuery("exchange", "hyperliquid")
type SymbolInfo struct {
Symbol string `json:"symbol"`
Display string `json:"display"`
Name string `json:"name"`
Category string `json:"category"` // crypto, stock, forex, commodity, index
Exchange string `json:"exchange"`
Volume24h float64 `json:"volume_24h"`
MarkPrice float64 `json:"mark_price"`
PrevDayPrice float64 `json:"prev_day_price,omitempty"`
Change24hPct float64 `json:"change_24h_pct,omitempty"`
MaxLeverage int `json:"maxLeverage,omitempty"`
SzDecimals int `json:"sz_decimals,omitempty"`
}
var symbols []SymbolInfo
exchangeLower := strings.ToLower(exchange)
switch exchangeLower {
case "hyperliquid", "hyperliquid-xyz", "xyz":
ctx := context.Background()
// hyperliquid-xyz returns the full USDC trading board in product order:
// stocks → commodities → indices → forex → pre-IPO → crypto.
if exchangeLower == "hyperliquid-xyz" || exchangeLower == "xyz" {
xyzCoins, err := hyperliquid.GetPerpDexCoins(ctx, hyperliquid.XYZDex)
if err != nil {
SafeInternalError(c, "Get Hyperliquid XYZ symbols", err)
return
}
for _, coin := range xyzCoins {
baseSymbol := strings.TrimPrefix(coin.Symbol, "xyz:")
displayBase := hyperliquidXYZDisplayBase(baseSymbol)
displaySymbol := displayBase + "-USDC"
tradeSymbol := baseSymbol + "-USDC"
symbols = append(symbols, SymbolInfo{
Symbol: tradeSymbol,
Display: displaySymbol,
Name: displayBase,
Category: hyperliquidXYZCategory(baseSymbol),
Exchange: "hyperliquid-xyz",
Volume24h: coin.Volume24h,
MarkPrice: coin.MarkPrice,
PrevDayPrice: coin.PrevDayPrice,
Change24hPct: coin.Change24hPct,
MaxLeverage: coin.MaxLeverage,
SzDecimals: coin.SzDecimals,
})
}
}
// Crypto perps are shown last; only include them on the combined Hyperliquid board.
if exchangeLower == "hyperliquid" || exchangeLower == "hyperliquid-xyz" {
coins, err := hyperliquid.GetProvider().GetAllCoins(ctx)
if err != nil {
SafeInternalError(c, "Get Hyperliquid symbols", err)
return
}
for _, coin := range coins {
symbols = append(symbols, SymbolInfo{
Symbol: coin.Symbol,
Display: coin.Symbol,
Name: coin.Symbol,
Category: "crypto",
Exchange: "hyperliquid",
Volume24h: coin.Volume24h,
MarkPrice: coin.MarkPrice,
PrevDayPrice: coin.PrevDayPrice,
Change24hPct: coin.Change24hPct,
MaxLeverage: coin.MaxLeverage,
SzDecimals: coin.SzDecimals,
})
}
}
default:
c.JSON(http.StatusBadRequest, gin.H{"error": "Unsupported exchange for symbol listing"})
return
}
sort.SliceStable(symbols, func(i, j int) bool {
ci := hyperliquidCategoryOrder(symbols[i].Category)
cj := hyperliquidCategoryOrder(symbols[j].Category)
if ci != cj {
return ci < cj
}
return symbols[i].Volume24h > symbols[j].Volume24h
})
c.JSON(http.StatusOK, gin.H{
"exchange": exchange,
"symbols": symbols,
"count": len(symbols),
})
}

View File

@@ -1,344 +0,0 @@
package api
import (
"bufio"
"encoding/hex"
"fmt"
"net/http"
"os"
"path/filepath"
"strings"
"nofx/logger"
"nofx/mcp/payment"
"nofx/wallet"
gethcrypto "github.com/ethereum/go-ethereum/crypto"
"github.com/gin-gonic/gin"
)
type beginnerOnboardingResponse struct {
Address string `json:"address"`
PrivateKey string `json:"private_key"`
Chain string `json:"chain"`
Asset string `json:"asset"`
Provider string `json:"provider"`
DefaultModel string `json:"default_model"`
ConfiguredModelID string `json:"configured_model_id"`
BalanceUSDC string `json:"balance_usdc"`
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, "", payment.DefaultClaw402Model); err != nil {
logger.Errorf("Failed to save beginner claw402 config for user %s: %v", userID, err)
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to save beginner model configuration"})
return
}
configuredModelID, err = s.findConfiguredClaw402ModelID(userID)
if err != nil {
logger.Warnf("Could not resolve configured claw402 model id for user %s: %v", userID, err)
}
}
os.Setenv("CLAW402_WALLET_KEY", privateKey)
os.Setenv("CLAW402_WALLET_ADDRESS", address)
os.Setenv("CLAW402_DEFAULT_MODEL", payment.DefaultClaw402Model)
envSaved, envPath, envErr := persistBeginnerWalletEnv(privateKey, address)
resp := beginnerOnboardingResponse{
Address: address,
PrivateKey: privateKey,
Chain: "base",
Asset: "USDC",
Provider: "claw402",
DefaultModel: payment.DefaultClaw402Model,
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": payment.DefaultClaw402Model,
}); err != nil {
lastErr = err
continue
}
return true, path, nil
}
if lastErr == nil {
lastErr = fmt.Errorf("no writable .env path found")
}
return false, "", lastErr
}
func uniqueEnvPaths(paths []string) []string {
seen := make(map[string]struct{}, len(paths))
result := make([]string, 0, len(paths))
for _, path := range paths {
clean := filepath.Clean(path)
if _, ok := seen[clean]; ok {
continue
}
seen[clean] = struct{}{}
result = append(result, clean)
}
return result
}
func upsertEnvFile(path string, values map[string]string) error {
if err := os.MkdirAll(filepath.Dir(path), 0755); err != nil {
return err
}
existingLines := make([]string, 0)
if file, err := os.Open(path); err == nil {
scanner := bufio.NewScanner(file)
for scanner.Scan() {
existingLines = append(existingLines, scanner.Text())
}
file.Close()
if err := scanner.Err(); err != nil {
return err
}
} else if !os.IsNotExist(err) {
return err
}
remaining := make(map[string]string, len(values))
for key, value := range values {
remaining[key] = value
}
updatedLines := make([]string, 0, len(existingLines)+len(values))
for _, line := range existingLines {
trimmed := strings.TrimSpace(line)
if trimmed == "" || strings.HasPrefix(trimmed, "#") || !strings.Contains(line, "=") {
updatedLines = append(updatedLines, line)
continue
}
parts := strings.SplitN(line, "=", 2)
key := strings.TrimSpace(parts[0])
value, ok := remaining[key]
if !ok {
updatedLines = append(updatedLines, line)
continue
}
updatedLines = append(updatedLines, fmt.Sprintf("%s=%s", key, value))
delete(remaining, key)
}
for key, value := range remaining {
updatedLines = append(updatedLines, fmt.Sprintf("%s=%s", key, value))
}
content := strings.Join(updatedLines, "\n")
if content != "" && !strings.HasSuffix(content, "\n") {
content += "\n"
}
if err := os.WriteFile(path, []byte(content), 0600); err != nil {
return err
}
return nil
}

View File

@@ -1,425 +0,0 @@
package api
import (
"net/http"
"strconv"
"strings"
"nofx/logger"
"nofx/market"
"github.com/gin-gonic/gin"
)
// handleTraderList Trader list
func (s *Server) handleTraderList(c *gin.Context) {
userID := c.GetString("user_id")
traders, err := s.store.Trader().List(userID)
if err != nil {
SafeInternalError(c, "Failed to get trader list", err)
return
}
result := make([]map[string]interface{}, 0, len(traders))
for _, trader := range traders {
// Get real-time running status
isRunning := trader.IsRunning
if at, err := s.traderManager.GetTrader(trader.ID); err == nil {
status := at.GetStatus()
if running, ok := status["is_running"].(bool); ok {
isRunning = running
}
}
// Get strategy name if strategy_id is set
var strategyName string
if trader.StrategyID != "" {
if strategy, err := s.store.Strategy().Get(userID, trader.StrategyID); err == nil {
strategyName = strategy.Name
}
}
// Return complete AIModelID (e.g. "admin_deepseek"), don't truncate
// Frontend needs complete ID to verify model exists (consistent with handleGetTraderConfig)
result = append(result, map[string]interface{}{
"trader_id": trader.ID,
"trader_name": trader.Name,
"ai_model": trader.AIModelID, // Use complete ID
"exchange_id": trader.ExchangeID,
"is_running": isRunning,
"show_in_competition": trader.ShowInCompetition,
"initial_balance": trader.InitialBalance,
"strategy_id": trader.StrategyID,
"strategy_name": strategyName,
})
}
c.JSON(http.StatusOK, result)
}
// handleGetTraderConfig Get trader detailed configuration
func (s *Server) handleGetTraderConfig(c *gin.Context) {
userID := c.GetString("user_id")
traderID := c.Param("id")
if traderID == "" {
c.JSON(http.StatusBadRequest, gin.H{"error": "Trader ID cannot be empty"})
return
}
fullCfg, err := s.store.Trader().GetFullConfig(userID, traderID)
if err != nil {
SafeNotFound(c, "Trader config")
return
}
traderConfig := fullCfg.Trader
// Get real-time running status
isRunning := traderConfig.IsRunning
if at, err := s.traderManager.GetTrader(traderID); err == nil {
status := at.GetStatus()
if running, ok := status["is_running"].(bool); ok {
isRunning = running
}
}
// Return complete model ID without conversion, consistent with frontend model list
aiModelID := traderConfig.AIModelID
result := map[string]interface{}{
"trader_id": traderConfig.ID,
"trader_name": traderConfig.Name,
"ai_model": aiModelID,
"exchange_id": traderConfig.ExchangeID,
"strategy_id": traderConfig.StrategyID,
"initial_balance": traderConfig.InitialBalance,
"scan_interval_minutes": traderConfig.ScanIntervalMinutes,
"btc_eth_leverage": traderConfig.BTCETHLeverage,
"altcoin_leverage": traderConfig.AltcoinLeverage,
"trading_symbols": traderConfig.TradingSymbols,
"custom_prompt": traderConfig.CustomPrompt,
"override_base_prompt": traderConfig.OverrideBasePrompt,
"is_cross_margin": traderConfig.IsCrossMargin,
"use_ai500": traderConfig.UseAI500,
"use_oi_top": traderConfig.UseOITop,
"is_running": isRunning,
}
c.JSON(http.StatusOK, result)
}
// handleStatus System status
func (s *Server) handleStatus(c *gin.Context) {
_, traderID, err := s.getTraderFromQuery(c)
if err != nil {
SafeBadRequest(c, "Invalid trader ID")
return
}
trader, err := s.traderManager.GetTrader(traderID)
if err != nil {
SafeNotFound(c, "Trader")
return
}
status := trader.GetStatus()
c.JSON(http.StatusOK, status)
}
// handleAccount Account information
func (s *Server) handleAccount(c *gin.Context) {
_, traderID, err := s.getTraderFromQuery(c)
if err != nil {
SafeBadRequest(c, "Invalid trader ID")
return
}
trader, err := s.traderManager.GetTrader(traderID)
if err != nil {
SafeNotFound(c, "Trader")
return
}
logger.Infof("📊 Received account info request [%s]", trader.GetName())
account, err := trader.GetAccountInfo()
if err != nil {
SafeInternalError(c, "Get account info", err)
return
}
logger.Infof("✓ Returning account info [%s]: equity=%.2f, available=%.2f, pnl=%.2f (%.2f%%)",
trader.GetName(),
account["total_equity"],
account["available_balance"],
account["total_pnl"],
account["total_pnl_pct"])
c.JSON(http.StatusOK, account)
}
// handlePositions Position list
func (s *Server) handlePositions(c *gin.Context) {
_, traderID, err := s.getTraderFromQuery(c)
if err != nil {
SafeBadRequest(c, "Invalid trader ID")
return
}
trader, err := s.traderManager.GetTrader(traderID)
if err != nil {
SafeNotFound(c, "Trader")
return
}
positions, err := trader.GetPositions()
if err != nil {
SafeInternalError(c, "Get positions", err)
return
}
c.JSON(http.StatusOK, positions)
}
// handlePositionHistory Historical closed positions with statistics
func (s *Server) handlePositionHistory(c *gin.Context) {
_, traderID, err := s.getTraderFromQuery(c)
if err != nil {
SafeBadRequest(c, "Invalid trader ID")
return
}
trader, err := s.traderManager.GetTrader(traderID)
if err != nil {
SafeNotFound(c, "Trader")
return
}
// Get optional query parameters
limitStr := c.DefaultQuery("limit", "100")
limit := 100
if l, err := strconv.Atoi(limitStr); err == nil && l > 0 && l <= 500 {
limit = l
}
// Get store
store := trader.GetStore()
if store == nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "Store not available"})
return
}
userID := c.GetString("user_id")
if fullCfg, cfgErr := s.store.Trader().GetFullConfig(userID, traderID); cfgErr == nil && fullCfg.Exchange != nil {
if syncErr := s.syncOrdersFromExchange(
trader.GetUnderlyingTrader(),
trader.GetID(),
fullCfg.Exchange.ID,
fullCfg.Exchange.ExchangeType,
); syncErr != nil {
logger.Infof("⚠️ Position history refresh sync skipped: %v", syncErr)
}
}
traderIDs := []string{trader.GetID()}
var traderIDPatterns []string
if strings.EqualFold(strings.TrimSpace(trader.GetName()), "NOFX Autopilot") && strings.TrimSpace(userID) != "" {
// Older one-click launches created new Autopilot trader rows. When a row was
// deleted, its closed position records remained under the old generated ID.
// The generated Autopilot ID embeds userID + "claw402", so this safely
// restores same-user history continuity without joining deleted rows.
traderIDPatterns = append(traderIDPatterns, "%_"+userID+"_claw402_%")
}
// Get closed positions
positions, err := store.Position().GetClosedPositionsByTraderFilters(traderIDs, traderIDPatterns, limit)
if err != nil {
SafeInternalError(c, "Get position history", err)
return
}
// Get statistics
stats, _ := store.Position().GetFullStatsByTraderFilters(traderIDs, traderIDPatterns)
// Get symbol stats
symbolStats, _ := store.Position().GetSymbolStatsByTraderFilters(traderIDs, traderIDPatterns, 10)
// Get direction stats
directionStats, _ := store.Position().GetDirectionStatsByTraderFilters(traderIDs, traderIDPatterns)
c.JSON(http.StatusOK, gin.H{
"positions": positions,
"stats": stats,
"symbol_stats": symbolStats,
"direction_stats": directionStats,
})
}
// handleTrades Historical trades list
func (s *Server) handleTrades(c *gin.Context) {
_, traderID, err := s.getTraderFromQuery(c)
if err != nil {
SafeBadRequest(c, "Invalid trader ID")
return
}
trader, err := s.traderManager.GetTrader(traderID)
if err != nil {
SafeNotFound(c, "Trader")
return
}
// Get optional query parameters
symbol := c.Query("symbol")
limitStr := c.DefaultQuery("limit", "100")
limit := 100
if l, err := strconv.Atoi(limitStr); err == nil && l > 0 {
limit = l
}
// Normalize symbol (add USDT suffix if not present)
if symbol != "" {
symbol = market.Normalize(symbol)
}
// Get trades from store
store := trader.GetStore()
if store == nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "Store not available"})
return
}
allTrades, err := store.Position().GetRecentTrades(trader.GetID(), limit)
if err != nil {
SafeInternalError(c, "Get trades", err)
return
}
// Filter by symbol if specified
if symbol != "" {
var result []interface{}
for _, trade := range allTrades {
if trade.Symbol == symbol {
result = append(result, trade)
}
}
c.JSON(http.StatusOK, result)
return
}
c.JSON(http.StatusOK, allTrades)
}
// handleOrders Order list (all orders including open, close, stop loss, take profit, etc.)
func (s *Server) handleOrders(c *gin.Context) {
_, traderID, err := s.getTraderFromQuery(c)
if err != nil {
SafeBadRequest(c, "Invalid trader ID")
return
}
trader, err := s.traderManager.GetTrader(traderID)
if err != nil {
SafeNotFound(c, "Trader")
return
}
// Get optional query parameters
symbol := c.Query("symbol")
statusFilter := c.Query("status") // NEW, FILLED, CANCELED, etc.
limitStr := c.DefaultQuery("limit", "100")
limit := 100
if l, err := strconv.Atoi(limitStr); err == nil && l > 0 {
limit = l
}
// Normalize symbol (add USDT suffix if not present)
if symbol != "" {
symbol = market.Normalize(symbol)
}
// Get orders from store
store := trader.GetStore()
if store == nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "Store not available"})
return
}
// Get orders with filters applied at database level
orders, err := store.Order().GetTraderOrdersFiltered(trader.GetID(), symbol, statusFilter, limit)
if err != nil {
SafeInternalError(c, "Get orders", err)
return
}
c.JSON(http.StatusOK, orders)
}
// handleOrderFills Order fill details (all fills for a specific order)
func (s *Server) handleOrderFills(c *gin.Context) {
orderIDStr := c.Param("id")
orderID, err := strconv.ParseInt(orderIDStr, 10, 64)
if err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid order ID"})
return
}
_, traderID, err := s.getTraderFromQuery(c)
if err != nil {
SafeBadRequest(c, "Invalid trader ID")
return
}
trader, err := s.traderManager.GetTrader(traderID)
if err != nil {
SafeNotFound(c, "Trader")
return
}
store := trader.GetStore()
if store == nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "Store not available"})
return
}
// Get fills for this order, scoped to the trader (ownership boundary).
fills, err := store.Order().GetOrderFills(traderID, orderID)
if err != nil {
SafeInternalError(c, "Get order fills", err)
return
}
c.JSON(http.StatusOK, fills)
}
// handleOpenOrders Get open orders (pending SL/TP) from exchange
func (s *Server) handleOpenOrders(c *gin.Context) {
_, traderID, err := s.getTraderFromQuery(c)
if err != nil {
SafeBadRequest(c, "Invalid trader ID")
return
}
trader, err := s.traderManager.GetTrader(traderID)
if err != nil {
SafeNotFound(c, "Trader")
return
}
// Get symbol parameter (required for exchange query)
symbol := c.Query("symbol")
if symbol == "" {
c.JSON(http.StatusBadRequest, gin.H{"error": "symbol parameter is required"})
return
}
// Normalize symbol
symbol = market.Normalize(symbol)
// Get open orders from exchange
openOrders, err := trader.GetOpenOrders(symbol)
if err != nil {
SafeInternalError(c, "Get open orders", err)
return
}
c.JSON(http.StatusOK, openOrders)
}

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,907 +0,0 @@
package api
import (
"errors"
"fmt"
"net/http"
"strings"
"time"
"nofx/logger"
"nofx/store"
"github.com/gin-gonic/gin"
"gorm.io/gorm"
)
const (
maxManualBTCETHLeverage = 20
maxManualAltLeverage = 20
)
// AI trader management related structures
type CreateTraderRequest struct {
Name string `json:"name" binding:"required"`
AIModelID string `json:"ai_model_id" binding:"required"`
ExchangeID string `json:"exchange_id" binding:"required"`
StrategyID string `json:"strategy_id"` // Strategy ID (new version)
InitialBalance float64 `json:"initial_balance"`
ScanIntervalMinutes int `json:"scan_interval_minutes"`
IsCrossMargin *bool `json:"is_cross_margin"` // Pointer type, nil means use default value true
ShowInCompetition *bool `json:"show_in_competition"` // Pointer type, nil means use default value true
// The following fields are kept for backward compatibility, new version uses strategy config
BTCETHLeverage int `json:"btc_eth_leverage"`
AltcoinLeverage int `json:"altcoin_leverage"`
TradingSymbols string `json:"trading_symbols"`
CustomPrompt string `json:"custom_prompt"`
OverrideBasePrompt bool `json:"override_base_prompt"`
SystemPromptTemplate string `json:"system_prompt_template"` // System prompt template name
UseAI500 bool `json:"use_ai500"`
UseOITop bool `json:"use_oi_top"`
}
// UpdateTraderRequest Update trader request
type UpdateTraderRequest struct {
Name string `json:"name" binding:"required"`
AIModelID string `json:"ai_model_id" binding:"required"`
ExchangeID string `json:"exchange_id" binding:"required"`
StrategyID string `json:"strategy_id"` // Strategy ID (new version)
InitialBalance float64 `json:"initial_balance"`
ScanIntervalMinutes int `json:"scan_interval_minutes"`
IsCrossMargin *bool `json:"is_cross_margin"`
ShowInCompetition *bool `json:"show_in_competition"`
// The following fields are kept for backward compatibility, new version uses strategy config
BTCETHLeverage int `json:"btc_eth_leverage"`
AltcoinLeverage int `json:"altcoin_leverage"`
TradingSymbols string `json:"trading_symbols"`
CustomPrompt string `json:"custom_prompt"`
OverrideBasePrompt bool `json:"override_base_prompt"`
SystemPromptTemplate string `json:"system_prompt_template"`
}
func formatTraderCreationError(reason, nextStep string) string {
if nextStep == "" {
return fmt.Sprintf("这次未能创建机器人:%s。", reason)
}
return fmt.Sprintf("这次未能创建机器人:%s。%s。", reason, nextStep)
}
func traderCreationRequestError(reason string) string {
return formatTraderCreationError(reason, "请检查你刚刚填写的内容后,再重新提交")
}
func validateTraderLeverageRange(btcEthLeverage, altcoinLeverage int) (string, string) {
if btcEthLeverage < 0 || btcEthLeverage > maxManualBTCETHLeverage {
return traderCreationRequestError("BTC/ETH 杠杆倍数需要在 1 到 20 倍之间"), "trader.create.invalid_btc_eth_leverage"
}
if altcoinLeverage < 0 || altcoinLeverage > maxManualAltLeverage {
return traderCreationRequestError("山寨币杠杆倍数需要在 1 到 20 倍之间"), "trader.create.invalid_altcoin_leverage"
}
return "", ""
}
func isSupportedTraderSymbol(symbol string) bool {
normalized := strings.ToUpper(strings.TrimSpace(symbol))
if normalized == "" {
return true
}
return strings.HasSuffix(normalized, "USDT") || strings.HasSuffix(normalized, "-USDC") || strings.HasPrefix(normalized, "XYZ:")
}
func exchangeDisplayName(exchange *store.Exchange) string {
if exchange == nil {
return "所选交易所账户"
}
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 against the same limits exposed by manual user config.
if errMsg, errCode := validateTraderLeverageRange(req.BTCETHLeverage, req.AltcoinLeverage); errMsg != "" {
SafeBadRequestWithDetails(c, errMsg, errCode, nil)
return
}
// Validate trading symbol format. Hyperliquid xyz dex markets (stocks,
// commodities, indices, FX, Pre-IPO) are user-facing SYMBOL-USDC pairs,
// while standard crypto/perp markets keep the legacy USDT suffix format.
if req.TradingSymbols != "" {
symbols := strings.Split(req.TradingSymbols, ",")
for _, symbol := range symbols {
symbol = strings.TrimSpace(symbol)
if !isSupportedTraderSymbol(symbol) {
SafeBadRequestWithDetails(c, traderCreationRequestError(
fmt.Sprintf("交易对 %s 的格式不正确,目前只支持 USDT 合约或 Hyperliquid XYZ USDC 标的SYMBOL-USDC", 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 <= 0 {
scanIntervalMinutes = 15
} else if scanIntervalMinutes < 3 {
scanIntervalMinutes = 3 // Explicit values below 3 minutes are clamped to the minimum.
}
// Query exchange actual balance, override user input
actualBalance := req.InitialBalance // Default to use user input
exchanges, err := s.store.Exchange().List(userID)
if err != nil {
SafeError(c, http.StatusInternalServerError,
formatTraderCreationError("暂时无法读取你的交易所配置", "请稍后重试;如果问题持续,再检查本地服务是否正常"),
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
}
if errMsg, errCode := validateTraderLeverageRange(req.BTCETHLeverage, req.AltcoinLeverage); errMsg != "" {
SafeBadRequestWithDetails(c, errMsg, errCode, nil)
return
}
// Set default values
isCrossMargin := existingTrader.IsCrossMargin // Keep original value
if req.IsCrossMargin != nil {
isCrossMargin = *req.IsCrossMargin
}
showInCompetition := existingTrader.ShowInCompetition // Keep original value
if req.ShowInCompetition != nil {
showInCompetition = *req.ShowInCompetition
}
// Set leverage default values
btcEthLeverage := req.BTCETHLeverage
altcoinLeverage := req.AltcoinLeverage
if btcEthLeverage <= 0 {
btcEthLeverage = existingTrader.BTCETHLeverage // Keep original value
}
if altcoinLeverage <= 0 {
altcoinLeverage = existingTrader.AltcoinLeverage // Keep original value
}
// Set scan interval, allow updates
scanIntervalMinutes := req.ScanIntervalMinutes
logger.Infof("📊 Update trader scan_interval: req=%d, existing=%d", req.ScanIntervalMinutes, existingTrader.ScanIntervalMinutes)
if scanIntervalMinutes <= 0 {
scanIntervalMinutes = existingTrader.ScanIntervalMinutes // Keep original value
} else if scanIntervalMinutes < 3 {
scanIntervalMinutes = 3
}
logger.Infof("📊 Final scan_interval_minutes: %d", scanIntervalMinutes)
// Set system prompt template
systemPromptTemplate := req.SystemPromptTemplate
if systemPromptTemplate == "" {
systemPromptTemplate = existingTrader.SystemPromptTemplate // Keep original value
}
// Handle strategy ID (if not provided, keep original value)
strategyID := req.StrategyID
if strategyID == "" {
strategyID = existingTrader.StrategyID
}
exchangeChanged := req.ExchangeID != "" && req.ExchangeID != existingTrader.ExchangeID
resetInitialBalance := exchangeChanged && req.InitialBalance <= 0
initialBalance := existingTrader.InitialBalance
if req.InitialBalance > 0 {
initialBalance = req.InitialBalance
}
if resetInitialBalance {
initialBalance = 0
}
// Update trader configuration
traderRecord := &store.Trader{
ID: traderID,
UserID: userID,
Name: req.Name,
AIModelID: req.AIModelID,
ExchangeID: req.ExchangeID,
StrategyID: strategyID, // Associated strategy ID
InitialBalance: initialBalance,
BTCETHLeverage: btcEthLeverage,
AltcoinLeverage: altcoinLeverage,
TradingSymbols: req.TradingSymbols,
CustomPrompt: req.CustomPrompt,
OverrideBasePrompt: req.OverrideBasePrompt,
SystemPromptTemplate: systemPromptTemplate,
IsCrossMargin: isCrossMargin,
ShowInCompetition: showInCompetition,
ScanIntervalMinutes: scanIntervalMinutes,
IsRunning: existingTrader.IsRunning, // Keep original value
}
// Check if trader was running before update (we'll restart it after)
wasRunning := false
if existingMemTrader, memErr := s.traderManager.GetTrader(traderID); memErr == nil {
status := existingMemTrader.GetStatus()
if running, ok := status["is_running"].(bool); ok && running {
wasRunning = true
logger.Infof("🔄 Trader %s was running, will restart with new config after update", traderID)
}
}
// Update database
logger.Infof("🔄 Updating trader: ID=%s, Name=%s, AIModelID=%s, StrategyID=%s, ScanInterval=%d min",
traderRecord.ID, traderRecord.Name, traderRecord.AIModelID, traderRecord.StrategyID, scanIntervalMinutes)
err = s.store.Trader().Update(traderRecord)
if err != nil {
SafeInternalError(c, "Failed to update trader", err)
return
}
if resetInitialBalance {
logger.Infof("🔄 Exchange changed for trader %s, resetting stale initial_balance to 0", traderID)
if err := s.store.Trader().UpdateInitialBalance(userID, traderID, 0); err != nil {
SafeInternalError(c, "Failed to reset trader initial balance", err)
return
}
}
// Remove old trader from memory first (this also stops if running)
s.traderManager.RemoveTrader(traderID)
// Reload traders into memory with fresh config
err = s.traderManager.LoadUserTradersFromStore(s.store, userID)
if err != nil {
logger.Infof("⚠️ Failed to reload user traders into memory: %v", err)
}
// If trader was running before, restart it with new config
if wasRunning {
if reloadedTrader, getErr := s.traderManager.GetTrader(traderID); getErr == nil {
go func() {
logger.Infof("▶️ Restarting trader %s with new config...", traderID)
if runErr := reloadedTrader.Run(); runErr != nil {
logger.Infof("❌ Trader %s runtime error: %v", traderID, runErr)
}
}()
}
}
logger.Infof("✓ Trader updated successfully: %s (model: %s, exchange: %s, strategy: %s)", req.Name, req.AIModelID, req.ExchangeID, strategyID)
c.JSON(http.StatusOK, gin.H{
"trader_id": traderID,
"trader_name": req.Name,
"ai_model": req.AIModelID,
"message": "Trader updated successfully",
})
}
// handleDeleteTrader Delete trader
func (s *Server) handleDeleteTrader(c *gin.Context) {
userID := c.GetString("user_id")
traderID := c.Param("id")
// Delete from database
err := s.store.Trader().Delete(userID, traderID)
if err != nil {
SafeInternalError(c, "Failed to delete trader", err)
return
}
// If trader is running, stop it first
if trader, err := s.traderManager.GetTrader(traderID); err == nil {
status := trader.GetStatus()
if isRunning, ok := status["is_running"].(bool); ok && isRunning {
trader.Stop()
logger.Infof("⏹ Stopped running trader: %s", traderID)
}
}
// Remove trader from memory
s.traderManager.RemoveTrader(traderID)
logger.Infof("✓ Trader deleted: %s", traderID)
c.JSON(http.StatusOK, gin.H{"message": "Trader deleted"})
}
// handleStartTrader Start trader
func (s *Server) handleStartTrader(c *gin.Context) {
userID := c.GetString("user_id")
traderID := c.Param("id")
// Verify trader belongs to current user
fullCfg, err := s.store.Trader().GetFullConfig(userID, traderID)
if err != nil {
c.JSON(http.StatusNotFound, gin.H{"error": "Trader does not exist or no access permission"})
return
}
traderName := traderID
if fullCfg != nil && fullCfg.Trader != nil && fullCfg.Trader.Name != "" {
traderName = fullCfg.Trader.Name
}
if fullCfg != nil && fullCfg.Exchange != nil && fullCfg.Exchange.ExchangeType == "hyperliquid" && !fullCfg.Exchange.HyperliquidBuilderApproved {
SafeBadRequestWithDetails(c, formatTraderStartError(
fmt.Sprintf("机器人「%s」的 Hyperliquid 交易授权尚未完成", traderName),
"请重新连接 Hyperliquid 钱包并完成交易授权后,再启动机器人",
), "trader.start.hyperliquid_builder_not_approved", mapStringPairs("trader_name", traderName, "exchange_name", exchangeDisplayName(fullCfg.Exchange)))
return
}
// Check if trader exists in memory and if it's running
existingTrader, _ := s.traderManager.GetTrader(traderID)
if existingTrader != nil {
status := existingTrader.GetStatus()
if isRunning, ok := status["is_running"].(bool); ok && isRunning {
c.JSON(http.StatusBadRequest, gin.H{"error": "Trader is already running"})
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,540 +0,0 @@
package api
import (
"fmt"
"net/http"
"strings"
"time"
"nofx/logger"
"nofx/store"
"nofx/trader"
"nofx/trader/aster"
"nofx/trader/binance"
"nofx/trader/bitget"
"nofx/trader/bybit"
"nofx/trader/gate"
hyperliquidtrader "nofx/trader/hyperliquid"
"nofx/trader/kucoin"
"nofx/trader/lighter"
"nofx/trader/okx"
"github.com/gin-gonic/gin"
)
// handleGetGridRiskInfo returns current risk information for a grid trader
func (s *Server) handleGetGridRiskInfo(c *gin.Context) {
traderID := c.Param("id")
autoTrader, err := s.traderManager.GetTrader(traderID)
if err != nil {
c.JSON(http.StatusNotFound, gin.H{"error": "trader not found"})
return
}
riskInfo := autoTrader.GetGridRiskInfo()
c.JSON(http.StatusOK, riskInfo)
}
// handleSyncBalance Sync exchange balance to initial_balance (Option B: Manual Sync + Option C: Smart Detection)
func (s *Server) handleSyncBalance(c *gin.Context) {
userID := c.GetString("user_id")
traderID := c.Param("id")
logger.Infof("🔄 User %s requested balance sync for trader %s", userID, traderID)
// Get trader configuration from database (including exchange info)
fullConfig, err := s.store.Trader().GetFullConfig(userID, traderID)
if err != nil {
c.JSON(http.StatusNotFound, gin.H{"error": "Trader does not exist"})
return
}
traderConfig := fullConfig.Trader
exchangeCfg := fullConfig.Exchange
if exchangeCfg == nil || !exchangeCfg.Enabled {
c.JSON(http.StatusBadRequest, gin.H{"error": "Exchange not configured or not enabled"})
return
}
tempTrader, createErr := buildExchangeProbeTrader(exchangeCfg, userID)
if createErr != nil {
logger.Infof("⚠️ Failed to create temporary trader: %v", createErr)
SafeInternalError(c, "Failed to connect to exchange", createErr)
return
}
// Query actual balance
balanceInfo, balanceErr := tempTrader.GetBalance()
if balanceErr != nil {
logger.Infof("⚠️ Failed to query exchange balance: %v", balanceErr)
SafeInternalError(c, "Failed to query balance", balanceErr)
return
}
// Extract total equity (for P&L calculation, we need total account value, not available balance)
actualBalance, found := extractExchangeTotalEquity(balanceInfo)
if !found {
c.JSON(http.StatusInternalServerError, gin.H{"error": "Unable to get total equity"})
return
}
s.exchangeAccountStateCache.Invalidate(userID)
oldBalance := traderConfig.InitialBalance
// Smart balance change detection
changePercent := ((actualBalance - oldBalance) / oldBalance) * 100
changeType := "increase"
if changePercent < 0 {
changeType = "decrease"
}
logger.Infof("✓ Queried actual exchange balance: %.2f USDT (current config: %.2f USDT, change: %.2f%%)",
actualBalance, oldBalance, changePercent)
// Update initial_balance in database
err = s.store.Trader().UpdateInitialBalance(userID, traderID, actualBalance)
if err != nil {
logger.Infof("❌ Failed to update initial_balance: %v", err)
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to update balance"})
return
}
// Reload traders into memory
err = s.traderManager.LoadUserTradersFromStore(s.store, userID)
if err != nil {
logger.Infof("⚠️ Failed to reload user traders into memory: %v", err)
}
logger.Infof("✅ Synced balance: %.2f → %.2f USDT (%s %.2f%%)", oldBalance, actualBalance, changeType, changePercent)
c.JSON(http.StatusOK, gin.H{
"message": "Balance synced successfully",
"old_balance": oldBalance,
"new_balance": actualBalance,
"change_percent": changePercent,
"change_type": changeType,
})
}
// handleClosePosition One-click close position
func (s *Server) handleClosePosition(c *gin.Context) {
userID := c.GetString("user_id")
traderID := c.Param("id")
var req struct {
Symbol string `json:"symbol" binding:"required"`
Side string `json:"side" binding:"required"` // "LONG" or "SHORT"
}
if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "Parameter error: symbol and side are required"})
return
}
logger.Infof("🔻 User %s requested position close: trader=%s, symbol=%s, side=%s", userID, traderID, req.Symbol, req.Side)
// Get trader configuration from database (including exchange info)
fullConfig, err := s.store.Trader().GetFullConfig(userID, traderID)
if err != nil {
c.JSON(http.StatusNotFound, gin.H{"error": "Trader does not exist"})
return
}
exchangeCfg := fullConfig.Exchange
if exchangeCfg == nil || !exchangeCfg.Enabled {
c.JSON(http.StatusBadRequest, gin.H{"error": "Exchange not configured or not enabled"})
return
}
// Create temporary trader to execute close position
var tempTrader trader.Trader
var createErr error
// Use ExchangeType (e.g., "binance") instead of ExchangeID (which is now UUID)
// Convert EncryptedString fields to string
switch exchangeCfg.ExchangeType {
case "binance":
tempTrader = binance.NewFuturesTrader(string(exchangeCfg.APIKey), string(exchangeCfg.SecretKey), userID)
case "hyperliquid":
tempTrader, createErr = hyperliquidtrader.NewHyperliquidTrader(
string(exchangeCfg.APIKey),
exchangeCfg.HyperliquidWalletAddr,
exchangeCfg.Testnet,
exchangeCfg.HyperliquidUnifiedAcct,
)
case "aster":
tempTrader, createErr = aster.NewAsterTrader(
exchangeCfg.AsterUser,
exchangeCfg.AsterSigner,
string(exchangeCfg.AsterPrivateKey),
)
case "bybit":
tempTrader = bybit.NewBybitTrader(
string(exchangeCfg.APIKey),
string(exchangeCfg.SecretKey),
)
case "okx":
tempTrader = okx.NewOKXTrader(
string(exchangeCfg.APIKey),
string(exchangeCfg.SecretKey),
string(exchangeCfg.Passphrase),
)
case "bitget":
tempTrader = bitget.NewBitgetTrader(
string(exchangeCfg.APIKey),
string(exchangeCfg.SecretKey),
string(exchangeCfg.Passphrase),
)
case "gate":
tempTrader = gate.NewGateTrader(
string(exchangeCfg.APIKey),
string(exchangeCfg.SecretKey),
)
case "kucoin":
tempTrader = kucoin.NewKuCoinTrader(
string(exchangeCfg.APIKey),
string(exchangeCfg.SecretKey),
string(exchangeCfg.Passphrase),
)
case "lighter":
if exchangeCfg.LighterWalletAddr != "" && string(exchangeCfg.LighterAPIKeyPrivateKey) != "" {
// Lighter only supports mainnet
tempTrader, createErr = lighter.NewLighterTraderV2(
exchangeCfg.LighterWalletAddr,
string(exchangeCfg.LighterAPIKeyPrivateKey),
exchangeCfg.LighterAPIKeyIndex,
false, // Always use mainnet for Lighter
)
} else {
createErr = fmt.Errorf("Lighter requires wallet address and API Key private key")
}
default:
c.JSON(http.StatusBadRequest, gin.H{"error": "Unsupported exchange type"})
return
}
if createErr != nil {
logger.Infof("⚠️ Failed to create temporary trader: %v", createErr)
SafeInternalError(c, "Failed to connect to exchange", createErr)
return
}
// Get current position info BEFORE closing (to get quantity and price)
positions, err := tempTrader.GetPositions()
if err != nil {
logger.Infof("⚠️ Failed to get positions: %v", err)
}
var posQty float64
var entryPrice float64
for _, pos := range positions {
if pos["symbol"] == req.Symbol && pos["side"] == strings.ToLower(req.Side) {
if amt, ok := pos["positionAmt"].(float64); ok {
posQty = amt
if posQty < 0 {
posQty = -posQty // Make positive
}
}
if price, ok := pos["entryPrice"].(float64); ok {
entryPrice = price
}
break
}
}
// Execute close position operation
var result map[string]interface{}
var closeErr error
if req.Side == "LONG" {
result, closeErr = tempTrader.CloseLong(req.Symbol, 0) // 0 means close all
} else if req.Side == "SHORT" {
result, closeErr = tempTrader.CloseShort(req.Symbol, 0) // 0 means close all
} else {
c.JSON(http.StatusBadRequest, gin.H{"error": "side must be LONG or SHORT"})
return
}
if closeErr != nil {
logger.Infof("❌ Close position failed: symbol=%s, side=%s, error=%v", req.Symbol, req.Side, closeErr)
SafeInternalError(c, "Close position", closeErr)
return
}
logger.Infof("✅ Position closed successfully: symbol=%s, side=%s, qty=%.6f, result=%v", req.Symbol, req.Side, posQty, result)
// Backfill the just-closed fill immediately. Manual closes may happen while
// the bot runtime is stopped, so the background OrderSync loop is not enough.
if syncErr := s.syncOrdersAfterManualClose(tempTrader, traderID, exchangeCfg.ID, exchangeCfg.ExchangeType); syncErr != nil {
logger.Infof(" ⚠️ Manual close sync failed: %v", syncErr)
s.recordClosePositionOrder(traderID, exchangeCfg.ID, exchangeCfg.ExchangeType, req.Symbol, req.Side, posQty, entryPrice, result)
}
c.JSON(http.StatusOK, gin.H{
"message": "Position closed successfully",
"symbol": req.Symbol,
"side": req.Side,
"result": result,
})
}
func (s *Server) syncOrdersFromExchange(exchangeTrader trader.Trader, traderID, exchangeID, exchangeType string) error {
switch t := exchangeTrader.(type) {
case *binance.FuturesTrader:
return t.SyncOrdersFromBinance(traderID, exchangeID, exchangeType, s.store)
case *hyperliquidtrader.HyperliquidTrader:
return t.SyncOrdersFromHyperliquid(traderID, exchangeID, exchangeType, s.store)
case *aster.AsterTrader:
return t.SyncOrdersFromAster(traderID, exchangeID, exchangeType, s.store)
case *bybit.BybitTrader:
return t.SyncOrdersFromBybit(traderID, exchangeID, exchangeType, s.store)
case *okx.OKXTrader:
return t.SyncOrdersFromOKX(traderID, exchangeID, exchangeType, s.store)
case *bitget.BitgetTrader:
return t.SyncOrdersFromBitget(traderID, exchangeID, exchangeType, s.store)
case *gate.GateTrader:
return t.SyncOrdersFromGate(traderID, exchangeID, exchangeType, s.store)
case *kucoin.KuCoinTrader:
return t.SyncOrdersFromKuCoin(traderID, exchangeID, exchangeType, s.store)
case *lighter.LighterTraderV2:
return t.SyncOrdersFromLighter(traderID, exchangeID, exchangeType, s.store)
default:
return fmt.Errorf("order sync is not available for exchange type %s", exchangeType)
}
}
func (s *Server) syncOrdersAfterManualClose(exchangeTrader trader.Trader, traderID, exchangeID, exchangeType string) error {
var lastErr error
for attempt := 1; attempt <= 4; attempt++ {
if attempt > 1 {
time.Sleep(time.Duration(attempt-1) * 500 * time.Millisecond)
}
if err := s.syncOrdersFromExchange(exchangeTrader, traderID, exchangeID, exchangeType); err != nil {
lastErr = err
continue
}
return nil
}
if lastErr != nil {
return lastErr
}
return fmt.Errorf("manual close sync did not run")
}
// recordClosePositionOrder Record close position order to database (Lighter version - direct FILLED status)
func (s *Server) recordClosePositionOrder(traderID, exchangeID, exchangeType, symbol, side string, quantity, exitPrice float64, result map[string]interface{}) {
// Skip for exchanges with OrderSync - let the background sync handle it to avoid duplicates
switch exchangeType {
case "binance", "lighter", "hyperliquid", "bybit", "okx", "bitget", "aster", "gate":
logger.Infof(" 📝 Close order will be synced by OrderSync, skipping immediate record")
return
}
// Check if order was placed (skip if NO_POSITION)
status, _ := result["status"].(string)
if status == "NO_POSITION" {
logger.Infof(" ⚠️ No position to close, skipping order record")
return
}
// Get order ID from result
var orderID string
switch v := result["orderId"].(type) {
case int64:
orderID = fmt.Sprintf("%d", v)
case float64:
orderID = fmt.Sprintf("%.0f", v)
case string:
orderID = v
default:
orderID = fmt.Sprintf("%v", v)
}
if orderID == "" || orderID == "0" {
logger.Infof(" ⚠️ Order ID is empty, skipping record")
return
}
// Determine order action based on side
var orderAction string
if side == "LONG" {
orderAction = "close_long"
} else {
orderAction = "close_short"
}
// Use entry price if exit price not available
if exitPrice == 0 {
exitPrice = quantity * 100 // Rough estimate if we don't have price
}
// Estimate fee (0.04% for Lighter taker)
fee := exitPrice * quantity * 0.0004
// Create order record - DIRECTLY as FILLED (Lighter market orders fill immediately)
orderRecord := &store.TraderOrder{
TraderID: traderID,
ExchangeID: exchangeID,
ExchangeType: exchangeType,
ExchangeOrderID: orderID,
Symbol: symbol,
PositionSide: side,
OrderAction: orderAction,
Type: "MARKET",
Side: getSideFromAction(orderAction),
Quantity: quantity,
Price: 0, // Market order
Status: "FILLED",
FilledQuantity: quantity,
AvgFillPrice: exitPrice,
Commission: fee,
FilledAt: time.Now().UTC().UnixMilli(),
CreatedAt: time.Now().UTC().UnixMilli(),
UpdatedAt: time.Now().UTC().UnixMilli(),
}
if err := s.store.Order().CreateOrder(orderRecord); err != nil {
logger.Infof(" ⚠️ Failed to record order: %v", err)
return
}
logger.Infof(" ✅ Order recorded as FILLED: %s [%s] %s qty=%.6f price=%.6f", orderID, orderAction, symbol, quantity, exitPrice)
// Create fill record immediately
tradeID := fmt.Sprintf("%s-%d", orderID, time.Now().UnixNano())
fillRecord := &store.TraderFill{
TraderID: traderID,
ExchangeID: exchangeID,
ExchangeType: exchangeType,
OrderID: orderRecord.ID,
ExchangeOrderID: orderID,
ExchangeTradeID: tradeID,
Symbol: symbol,
Side: getSideFromAction(orderAction),
Price: exitPrice,
Quantity: quantity,
QuoteQuantity: exitPrice * quantity,
Commission: fee,
CommissionAsset: "USDT",
RealizedPnL: 0,
IsMaker: false,
CreatedAt: time.Now().UTC().UnixMilli(),
}
if err := s.store.Order().CreateFill(fillRecord); err != nil {
logger.Infof(" ⚠️ Failed to record fill: %v", err)
} else {
logger.Infof(" ✅ Fill record created: price=%.6f qty=%.6f", exitPrice, quantity)
}
}
// pollAndUpdateOrderStatus Poll order status and update with fill data
func (s *Server) pollAndUpdateOrderStatus(orderRecordID int64, traderID, exchangeID, exchangeType, orderID, symbol, orderAction string, tempTrader trader.Trader) {
var actualPrice float64
var actualQty float64
var fee float64
// Wait a bit for order to be filled
time.Sleep(500 * time.Millisecond)
// For Lighter, use GetTrades instead of GetOrderStatus (market orders are filled immediately)
if exchangeType == "lighter" {
s.pollLighterTradeHistory(orderRecordID, traderID, exchangeID, exchangeType, orderID, symbol, orderAction, tempTrader)
return
}
// For other exchanges, poll GetOrderStatus
for i := 0; i < 5; i++ {
status, err := tempTrader.GetOrderStatus(symbol, orderID)
if err != nil {
logger.Infof(" ⚠️ GetOrderStatus failed (attempt %d/5): %v", i+1, err)
time.Sleep(500 * time.Millisecond)
continue
}
if err == nil {
statusStr, _ := status["status"].(string)
if statusStr == "FILLED" {
// Get actual fill price
if avgPrice, ok := status["avgPrice"].(float64); ok && avgPrice > 0 {
actualPrice = avgPrice
}
// Get actual executed quantity
if execQty, ok := status["executedQty"].(float64); ok && execQty > 0 {
actualQty = execQty
}
// Get commission/fee
if commission, ok := status["commission"].(float64); ok {
fee = commission
}
logger.Infof(" ✅ Order filled: avgPrice=%.6f, qty=%.6f, fee=%.6f", actualPrice, actualQty, fee)
// Update order status to FILLED
if err := s.store.Order().UpdateOrderStatus(orderRecordID, "FILLED", actualQty, actualPrice, fee); err != nil {
logger.Infof(" ⚠️ Failed to update order status: %v", err)
return
}
// Record fill details
tradeID := fmt.Sprintf("%s-%d", orderID, time.Now().UnixNano())
fillRecord := &store.TraderFill{
TraderID: traderID,
ExchangeID: exchangeID,
ExchangeType: exchangeType,
OrderID: orderRecordID,
ExchangeOrderID: orderID,
ExchangeTradeID: tradeID,
Symbol: symbol,
Side: getSideFromAction(orderAction),
Price: actualPrice,
Quantity: actualQty,
QuoteQuantity: actualPrice * actualQty,
Commission: fee,
CommissionAsset: "USDT",
RealizedPnL: 0,
IsMaker: false,
CreatedAt: time.Now().UTC().UnixMilli(),
}
if err := s.store.Order().CreateFill(fillRecord); err != nil {
logger.Infof(" ⚠️ Failed to record fill: %v", err)
} else {
logger.Infof(" 📝 Fill recorded: price=%.6f, qty=%.6f", actualPrice, actualQty)
}
return
} else if statusStr == "CANCELED" || statusStr == "EXPIRED" || statusStr == "REJECTED" {
logger.Infof(" ⚠️ Order %s, updating status", statusStr)
s.store.Order().UpdateOrderStatus(orderRecordID, statusStr, 0, 0, 0)
return
}
}
time.Sleep(500 * time.Millisecond)
}
logger.Infof(" ⚠️ Failed to confirm order fill after polling, order may still be pending")
}
// pollLighterTradeHistory No longer used - Lighter orders are marked as FILLED immediately
// Keeping this function stub for compatibility with other exchanges
func (s *Server) pollLighterTradeHistory(orderRecordID int64, traderID, exchangeID, exchangeType, orderID, symbol, orderAction string, tempTrader trader.Trader) {
// For Lighter, orders are now recorded as FILLED immediately in recordClosePositionOrder
// This function is no longer called for Lighter exchange
logger.Infof(" pollLighterTradeHistory called but not needed (order already marked FILLED)")
}
// getSideFromAction Get order side (BUY/SELL) from order action
func getSideFromAction(action string) string {
switch action {
case "open_long", "close_short":
return "BUY"
case "open_short", "close_long":
return "SELL"
default:
return "BUY"
}
}

View File

@@ -1,28 +0,0 @@
package api
import "testing"
func TestIsSupportedTraderSymbol(t *testing.T) {
tests := []struct {
name string
symbol string
want bool
}{
{name: "legacy USDT perp", symbol: "BTCUSDT", want: true},
{name: "legacy USDT perp lowercase", symbol: "ethusdt", want: true},
{name: "Hyperliquid xyz stock USDC pair", symbol: "SMSN-USDC", want: true},
{name: "Hyperliquid xyz commodity USDC pair", symbol: "GOLD-USDC", want: true},
{name: "legacy internal xyz prefix still accepted", symbol: "xyz:SMSN", want: true},
{name: "empty slot ignored", symbol: " ", want: true},
{name: "bare stock without xyz prefix rejected", symbol: "SMSN", want: false},
{name: "unknown non-USDT pair rejected", symbol: "BTCUSD", want: false},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
if got := isSupportedTraderSymbol(tt.symbol); got != tt.want {
t.Fatalf("isSupportedTraderSymbol(%q) = %v, want %v", tt.symbol, got, tt.want)
}
})
}
}

View File

@@ -1,17 +0,0 @@
package api
import "testing"
func TestValidateTraderLeverageRangeMatchesManualLimits(t *testing.T) {
if msg, code := validateTraderLeverageRange(20, 20); msg != "" || code != "" {
t.Fatalf("expected 20/20 leverage to be accepted, got msg=%q code=%q", msg, code)
}
if msg, code := validateTraderLeverageRange(21, 20); msg == "" || code != "trader.create.invalid_btc_eth_leverage" {
t.Fatalf("expected BTC/ETH leverage > 20 to be rejected, got msg=%q code=%q", msg, code)
}
if msg, code := validateTraderLeverageRange(20, 21); msg == "" || code != "trader.create.invalid_altcoin_leverage" {
t.Fatalf("expected altcoin leverage > 20 to be rejected, got msg=%q code=%q", msg, code)
}
}

View File

@@ -1,367 +0,0 @@
package api
import (
"fmt"
"net/http"
"strings"
"time"
"nofx/auth"
"nofx/logger"
"nofx/store"
"github.com/gin-gonic/gin"
"github.com/google/uuid"
"gorm.io/gorm"
)
// handleLogout Add current token to blacklist
func (s *Server) handleLogout(c *gin.Context) {
authHeader := c.GetHeader("Authorization")
if authHeader == "" {
c.JSON(http.StatusUnauthorized, gin.H{"error": "Missing Authorization header"})
return
}
parts := strings.Split(authHeader, " ")
if len(parts) != 2 || parts[0] != "Bearer" {
c.JSON(http.StatusUnauthorized, gin.H{"error": "Invalid Authorization format"})
return
}
tokenString := parts[1]
claims, err := auth.ValidateJWT(tokenString)
if err != nil {
c.JSON(http.StatusUnauthorized, gin.H{"error": "Invalid token"})
return
}
var exp time.Time
if claims.ExpiresAt != nil {
exp = claims.ExpiresAt.Time
} else {
exp = time.Now().Add(24 * time.Hour)
}
auth.BlacklistToken(tokenString, exp)
c.JSON(http.StatusOK, gin.H{"message": "Logged out"})
}
// handleRegister Handle user registration request.
// handleRegister allows registration only when no users exist yet (first-time setup).
// This is a single-user system; subsequent registrations are permanently closed.
func (s *Server) handleRegister(c *gin.Context) {
userCount, err := s.store.User().Count()
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to check user count"})
return
}
if userCount > 0 {
c.JSON(http.StatusForbidden, gin.H{"error": "System already initialized"})
return
}
var req struct {
Email string `json:"email" binding:"required,email"`
Password string `json:"password" binding:"required,min=8"`
Lang string `json:"lang"`
}
if err := c.ShouldBindJSON(&req); err != nil {
SafeBadRequest(c, "Invalid request parameters")
return
}
lang := req.Lang
if lang != "zh" && lang != "id" {
lang = "en"
}
// Check if email already exists
_, err = s.store.User().GetByEmail(req.Email)
if err == nil {
c.JSON(http.StatusConflict, gin.H{"error": "Email already registered"})
return
}
// Generate password hash
passwordHash, err := auth.HashPassword(req.Password)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "Password processing failed"})
return
}
// Create user
userID := uuid.New().String()
user := &store.User{
ID: userID,
Email: req.Email,
PasswordHash: passwordHash,
}
err = s.store.User().Create(user)
if err != nil {
SafeInternalError(c, "Failed to create user", err)
return
}
// NOTE: Orphan record adoption was removed for security reasons. Previously,
// after a reset-account call, any new user would inherit the prior owner's
// wallet keys and exchange API credentials — a catastrophic IDOR/takeover
// path. Operators who need to migrate credentials across users must do so
// explicitly via export/import, never via implicit adoption on registration.
// Generate JWT token
token, err := auth.GenerateJWT(user.ID, user.Email)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to generate token"})
return
}
// Initialize default model and exchange configs for user
err = s.initUserDefaultConfigs(user.ID, lang)
if err != nil {
logger.Infof("Failed to initialize user default configs: %v", err)
}
c.JSON(http.StatusOK, gin.H{
"token": token,
"user_id": user.ID,
"email": user.Email,
"message": "Registration successful",
})
}
// dummyPasswordHash is a valid bcrypt hash of a throwaway value. It is compared
// against when the submitted email does not exist so that login takes roughly
// the same time whether or not the account exists — closing the timing side
// channel that would otherwise let an attacker enumerate valid emails (a fast
// "no such user" vs. a slow bcrypt compare). It is not a secret.
const dummyPasswordHash = "$2a$10$0iF0bCoQLJ6Ph1bF.MXwHOW.IMTxQjeEW.w38dctRQAB2kwB6ga1q"
// handleLogin Handle user login request
func (s *Server) handleLogin(c *gin.Context) {
var req struct {
Email string `json:"email" binding:"required,email"`
Password string `json:"password" binding:"required"`
}
if err := c.ShouldBindJSON(&req); err != nil {
SafeBadRequest(c, "Invalid request parameters")
return
}
// Get user information
user, err := s.store.User().GetByEmail(req.Email)
if err != nil {
// Perform a dummy comparison so the response time does not reveal
// whether the email exists (anti user-enumeration), then fail uniformly.
auth.CheckPassword(req.Password, dummyPasswordHash)
c.JSON(http.StatusUnauthorized, gin.H{"error": "Email or password incorrect"})
return
}
// Verify password
if !auth.CheckPassword(req.Password, user.PasswordHash) {
c.JSON(http.StatusUnauthorized, gin.H{"error": "Email or password incorrect"})
return
}
// Issue token directly after password verification.
token, err := auth.GenerateJWT(user.ID, user.Email)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to generate token"})
return
}
c.JSON(http.StatusOK, gin.H{
"token": token,
"user_id": user.ID,
"email": user.Email,
"message": "Login successful",
})
}
// handleChangePassword changes the password for the currently authenticated user.
func (s *Server) handleChangePassword(c *gin.Context) {
userID := c.GetString("user_id")
var req struct {
NewPassword string `json:"new_password" binding:"required,min=8"`
}
if err := c.ShouldBindJSON(&req); err != nil {
SafeBadRequest(c, "new_password is required (min 8 chars)")
return
}
hash, err := auth.HashPassword(req.NewPassword)
if err != nil {
SafeInternalError(c, "Password processing failed", err)
return
}
if err := s.store.User().UpdatePassword(userID, hash); err != nil {
SafeInternalError(c, "Failed to update password", err)
return
}
c.JSON(http.StatusOK, gin.H{"message": "Password updated"})
}
// NOTE: Password and account recovery used to live here as the public,
// unauthenticated handlers handleResetPassword / handleResetAccount. They were
// removed because an unauthenticated recovery endpoint is a remotely
// exploitable auth-bypass on any public-facing deployment: the confirm phrase
// was embedded in the frontend (and echoed back by the API), so it was friction
// rather than authentication. Recovery now lives in the local CLI
// (`nofx reset-password` / `nofx reset-account`, see cli.go), which requires
// shell access to the host — something a remote attacker does not have.
// initUserDefaultConfigs Initialize default configs for new user
func (s *Server) initUserDefaultConfigs(userID string, lang string) error {
if err := s.createDefaultStrategies(userID, lang); err != nil {
logger.Warnf("Failed to create default strategies for user %s: %v", userID, err)
// Non-fatal: user can create strategies manually
}
logger.Infof("✓ User %s registration completed with default strategies", userID)
return nil
}
func (s *Server) createDefaultStrategies(userID string, lang string) error {
type strategyI18n struct {
name, description string
}
type strategyLocale struct {
defaultStrategy strategyI18n
}
locales := map[string]strategyLocale{
"zh": {
defaultStrategy: strategyI18n{"NOFX Claw402 自动策略", "唯一内置策略:每轮读取 Claw402.ai 榜单,逐个拉取 Signal Lab 与成本/清算热力图,再结合原始 K 线决策。"},
},
"en": {
defaultStrategy: strategyI18n{"NOFX Claw402 Auto Strategy", "The only built-in strategy: read the Claw402.ai board each cycle, fetch Signal Lab and cost/liquidation heatmap per candidate, then decide with raw candles."},
},
"id": {
defaultStrategy: strategyI18n{"Strategi Otomatis NOFX Claw402", "Satu strategi bawaan: membaca papan Claw402.ai, mengambil Signal Lab dan heatmap biaya/likuidasi per kandidat, lalu memutuskan dengan candle mentah."},
},
}
locale, ok := locales[lang]
if !ok {
locale = locales["en"]
}
type strategyDef struct {
name string
description string
isActive bool
applyConfig func(*store.StrategyConfig)
}
setClaw402Strategy := func(c *store.StrategyConfig) {
c.CoinSource.SourceType = "vergex_signal"
c.CoinSource.StaticCoins = nil
c.CoinSource.UseAI500 = false
c.CoinSource.UseOITop = false
c.CoinSource.UseOILow = false
c.CoinSource.UseHyperAll = false
c.CoinSource.UseHyperMain = false
c.CoinSource.HyperRankCategory = "all"
c.CoinSource.VergexLimit = 10
c.CoinSource.VergexMarketType = "all"
c.CoinSource.VergexChain = "hyperliquid"
c.RiskControl.MaxPositions = 2
c.RiskControl.BTCETHMaxLeverage = 10
c.RiskControl.AltcoinMaxLeverage = 10
c.RiskControl.BTCETHMaxPositionValueRatio = 10.0
c.RiskControl.AltcoinMaxPositionValueRatio = 10.0
c.RiskControl.MaxMarginUsage = 1.0
c.RiskControl.MinConfidence = 78
c.RiskControl.MinRiskRewardRatio = 3.0
c.Indicators.Klines.PrimaryTimeframe = "15m"
c.Indicators.Klines.PrimaryCount = 30
c.Indicators.Klines.LongerTimeframe = ""
c.Indicators.Klines.LongerCount = 0
c.Indicators.Klines.EnableMultiTimeframe = false
c.Indicators.Klines.SelectedTimeframes = []string{"15m"}
c.Indicators.EnableRawKlines = true
}
definitions := []strategyDef{
{
name: locale.defaultStrategy.name,
description: locale.defaultStrategy.description,
isActive: true,
applyConfig: func(c *store.StrategyConfig) {
setClaw402Strategy(c)
},
},
}
// GetDefaultStrategyConfig only supports zh/en; map id -> en
configLang := lang
if lang == "id" {
configLang = "en"
}
// Pre-build all strategy objects before opening the transaction
var strategies []*store.Strategy
for _, def := range definitions {
config := store.GetDefaultStrategyConfig(configLang)
def.applyConfig(&config)
config.ClampLimits()
strategy := &store.Strategy{
ID: uuid.New().String(),
UserID: userID,
Name: def.name,
Description: def.description,
IsActive: def.isActive,
IsDefault: false,
}
if err := strategy.SetConfig(&config); err != nil {
return fmt.Errorf("failed to set config for strategy %q: %w", def.name, err)
}
strategies = append(strategies, strategy)
}
legacyDefaultNames := []string{
"均衡策略", "稳健策略", "积极策略",
"美股趋势策略", "美股稳健策略", "美股突破策略",
"Balanced Strategy", "Conservative Strategy", "Aggressive Strategy",
"US Stock Trend Strategy", "US Stock Steady Strategy", "US Stock Breakout Strategy",
"Strategi Seimbang", "Strategi Konservatif", "Strategi Agresif",
"Strategi Tren Saham AS", "Strategi Stabil Saham AS", "Strategi Breakout Saham AS",
}
return s.store.Transaction(func(tx *gorm.DB) error {
// Remove obsolete built-in risk-profile presets for this user. If a trader still
// references one of them, keep it to avoid breaking an existing running setup.
deleteResult := tx.Where("user_id = ? AND name IN ? AND id NOT IN (SELECT strategy_id FROM traders WHERE user_id = ? AND strategy_id IS NOT NULL)", userID, legacyDefaultNames, userID).
Delete(&store.Strategy{})
if deleteResult.Error != nil {
return fmt.Errorf("failed to remove legacy default strategies: %w", deleteResult.Error)
}
if deleteResult.RowsAffected > 0 {
logger.Infof(" ✓ Removed %d legacy default strategy preset(s)", deleteResult.RowsAffected)
}
var activeCount int64
if err := tx.Model(&store.Strategy{}).Where("user_id = ? AND is_active = ?", userID, true).Count(&activeCount).Error; err != nil {
return fmt.Errorf("failed to count active strategies: %w", err)
}
for _, strategy := range strategies {
var existing int64
if err := tx.Model(&store.Strategy{}).Where("user_id = ? AND name = ?", userID, strategy.Name).Count(&existing).Error; err != nil {
return fmt.Errorf("failed to check strategy %q: %w", strategy.Name, err)
}
if existing > 0 {
continue
}
if activeCount > 0 {
strategy.IsActive = false
}
if err := tx.Create(strategy).Error; err != nil {
return fmt.Errorf("failed to create strategy %q: %w", strategy.Name, err)
}
if strategy.IsActive {
activeCount++
}
logger.Infof(" ✓ Created default strategy: %s (active=%v)", strategy.Name, strategy.IsActive)
}
return nil
})
}

View File

@@ -1,136 +0,0 @@
package api
import (
"testing"
"github.com/google/uuid"
"nofx/store"
)
func TestCreateDefaultStrategiesUsesOneReadyToRunClaw402Preset(t *testing.T) {
st, err := store.New(t.TempDir() + "/nofx.db")
if err != nil {
t.Fatalf("store.New failed: %v", err)
}
t.Cleanup(func() { _ = st.Close() })
s := &Server{store: st}
userID := "user-us-stock-presets"
if err := s.createDefaultStrategies(userID, "zh"); err != nil {
t.Fatalf("createDefaultStrategies failed: %v", err)
}
strategies, err := st.Strategy().List(userID)
if err != nil {
t.Fatalf("List strategies failed: %v", err)
}
if len(strategies) != 1 {
t.Fatalf("expected 1 default strategy, got %d", len(strategies))
}
byName := map[string]*store.Strategy{}
activeCount := 0
for _, strategy := range strategies {
byName[strategy.Name] = strategy
if strategy.IsActive {
activeCount++
}
if strategy.Name == "均衡策略" || strategy.Name == "稳健策略" || strategy.Name == "积极策略" {
t.Fatalf("legacy crypto-style default strategy still present: %s", strategy.Name)
}
}
if activeCount != 1 {
t.Fatalf("expected exactly one active strategy, got %d", activeCount)
}
defaultStrategy := byName["NOFX Claw402 自动策略"]
if defaultStrategy == nil || !defaultStrategy.IsActive {
t.Fatalf("NOFX Claw402 自动策略 should exist and be active")
}
trendCfg, err := defaultStrategy.ParseConfig()
if err != nil {
t.Fatalf("default ParseConfig failed: %v", err)
}
if trendCfg.CoinSource.SourceType != "vergex_signal" || trendCfg.CoinSource.VergexLimit != 10 || trendCfg.CoinSource.VergexMarketType != "all" {
t.Fatalf("default strategy should use the Claw402/Vergex all-market signal ranking, got %+v", trendCfg.CoinSource)
}
if trendCfg.CoinSource.UseAI500 || trendCfg.RiskControl.MaxPositions > 2 {
t.Fatalf("default strategy should be Claw402/Vergex native with at most two positions, got coin=%+v risk=%+v", trendCfg.CoinSource, trendCfg.RiskControl)
}
if trendCfg.RiskControl.BTCETHMaxLeverage != 10 || trendCfg.RiskControl.AltcoinMaxLeverage != 10 {
t.Fatalf("default strategy should use 10x leverage for all Claw402 opens, got risk=%+v", trendCfg.RiskControl)
}
if trendCfg.RiskControl.BTCETHMaxPositionValueRatio != 10 ||
trendCfg.RiskControl.AltcoinMaxPositionValueRatio != 10 ||
trendCfg.RiskControl.MaxMarginUsage != 1.0 {
t.Fatalf("default strategy should use full-size 10x notional for Claw402 opens, got risk=%+v", trendCfg.RiskControl)
}
}
func TestCreateDefaultStrategiesMigratesLegacyPresetsWithoutOverridingActiveCustom(t *testing.T) {
st, err := store.New(t.TempDir() + "/nofx.db")
if err != nil {
t.Fatalf("store.New failed: %v", err)
}
t.Cleanup(func() { _ = st.Close() })
userID := "user-existing-custom"
legacyCfg := store.GetDefaultStrategyConfig("zh")
legacy := &store.Strategy{
ID: uuid.New().String(),
UserID: userID,
Name: "均衡策略",
Description: "legacy",
IsActive: false,
}
if err := legacy.SetConfig(&legacyCfg); err != nil {
t.Fatalf("legacy SetConfig failed: %v", err)
}
if err := st.Strategy().Create(legacy); err != nil {
t.Fatalf("create legacy failed: %v", err)
}
custom := &store.Strategy{
ID: uuid.New().String(),
UserID: userID,
Name: "aa",
Description: "user custom active strategy",
IsActive: true,
}
if err := custom.SetConfig(&legacyCfg); err != nil {
t.Fatalf("custom SetConfig failed: %v", err)
}
if err := st.Strategy().Create(custom); err != nil {
t.Fatalf("create custom failed: %v", err)
}
s := &Server{store: st}
if err := s.createDefaultStrategies(userID, "zh"); err != nil {
t.Fatalf("createDefaultStrategies failed: %v", err)
}
if err := s.createDefaultStrategies(userID, "zh"); err != nil {
t.Fatalf("second createDefaultStrategies should be idempotent: %v", err)
}
strategies, err := st.Strategy().List(userID)
if err != nil {
t.Fatalf("List strategies failed: %v", err)
}
byName := map[string]int{}
activeNames := []string{}
for _, strategy := range strategies {
byName[strategy.Name]++
if strategy.IsActive {
activeNames = append(activeNames, strategy.Name)
}
}
if byName["均衡策略"] != 0 {
t.Fatalf("legacy preset should be removed, got names=%+v", byName)
}
if byName["NOFX Claw402 自动策略"] != 1 {
t.Fatalf("expected exactly one NOFX Claw402 自动策略, got names=%+v", byName)
}
if len(activeNames) != 1 || activeNames[0] != "aa" {
t.Fatalf("existing active custom strategy should stay the only active one, got %+v", activeNames)
}
}

View File

@@ -1,115 +0,0 @@
package api
import (
"context"
"fmt"
"net/http"
"nofx/logger"
"nofx/provider/vergex"
"strings"
"github.com/gin-gonic/gin"
)
func (s *Server) handleVergexSignalRanking(c *gin.Context) {
client, ok := s.newVergexClientForRequest(c)
if !ok {
return
}
data, err := client.GetSignalRanking(context.Background(), vergex.Query{
Chain: strings.TrimSpace(c.Query("chain")),
LiqBand: strings.TrimSpace(c.Query("liqBand")),
})
if err != nil {
logger.Warnf("Vergex signal-ranking failed: %v", err)
c.JSON(http.StatusBadGateway, gin.H{"error": err.Error()})
return
}
limit := parsePositiveInt(c.Query("limit"), vergex.MaxSignalRankingItems)
marketType := strings.TrimSpace(c.Query("marketType"))
items := vergex.FilterSignalRankingItems(data.Items, marketType, limit)
c.JSON(http.StatusOK, gin.H{
"items": items,
"raw": data.Raw,
})
}
func (s *Server) handleVergexSignalLab(c *gin.Context) {
client, ok := s.newVergexClientForRequest(c)
if !ok {
return
}
body, err := client.GetSignalLab(context.Background(), vergex.Query{
MarketType: withDefault(strings.TrimSpace(c.Query("marketType")), vergex.DefaultMarketType),
Symbol: strings.TrimSpace(c.Query("symbol")),
Chain: strings.TrimSpace(c.Query("chain")),
LiqBand: strings.TrimSpace(c.Query("liqBand")),
})
if err != nil {
logger.Warnf("Vergex signal-lab failed: %v", err)
c.JSON(http.StatusBadGateway, gin.H{"error": err.Error()})
return
}
c.Data(http.StatusOK, "application/json; charset=utf-8", body)
}
func (s *Server) handleVergexCostLiquidationHeatmap(c *gin.Context) {
client, ok := s.newVergexClientForRequest(c)
if !ok {
return
}
body, err := client.GetCostLiquidationHeatmap(context.Background(), vergex.Query{
MarketType: withDefault(strings.TrimSpace(c.Query("marketType")), vergex.DefaultMarketType),
Symbol: strings.TrimSpace(c.Query("symbol")),
Chain: strings.TrimSpace(c.Query("chain")),
LiqBand: strings.TrimSpace(c.Query("liqBand")),
})
if err != nil {
logger.Warnf("Vergex cost-liquidation-heatmap failed: %v", err)
c.JSON(http.StatusBadGateway, gin.H{"error": err.Error()})
return
}
c.Data(http.StatusOK, "application/json; charset=utf-8", body)
}
func (s *Server) newVergexClientForRequest(c *gin.Context) (*vergex.Client, bool) {
userID := c.GetString("user_id")
if userID == "" {
c.JSON(http.StatusUnauthorized, gin.H{"error": "Unauthorized"})
return nil, false
}
walletKey, err := s.resolveStrategyDataWalletKey(userID, c.Query("ai_model_id"))
if err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return nil, false
}
if walletKey == "" {
c.JSON(http.StatusBadRequest, gin.H{"error": "claw402 wallet is not configured"})
return nil, false
}
client, err := vergex.NewClient("", walletKey, &logger.MCPLogger{})
if err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return nil, false
}
return client, true
}
func parsePositiveInt(raw string, fallback int) int {
if raw == "" {
return fallback
}
var n int
if _, err := fmt.Sscanf(raw, "%d", &n); err != nil || n <= 0 {
return fallback
}
return n
}
func withDefault(value, fallback string) string {
if strings.TrimSpace(value) == "" {
return fallback
}
return value
}

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

View File

@@ -1,101 +0,0 @@
package api
import (
"math"
"net/http"
"sync"
"time"
"github.com/gin-gonic/gin"
)
// ipRateLimiter is a small, dependency-free token-bucket rate limiter keyed by
// client IP. It is used to throttle the unauthenticated auth endpoints
// (login / register) against online brute-force attacks.
//
// Design notes:
// - Per-IP token bucket with lazy refill (no background goroutine).
// - Idle buckets are evicted opportunistically so a flood of distinct source
// IPs (e.g. spoofed X-Forwarded-For) cannot grow the map without bound.
// - This is a throttle, not an authenticator. Behind a reverse proxy the
// effective key is whatever gin's ClientIP() resolves; operators who
// terminate TLS at a proxy should configure trusted proxies so ClientIP()
// reflects the real peer rather than a spoofable header.
type ipRateLimiter struct {
mu sync.Mutex
buckets map[string]*rlBucket
rate float64 // tokens added per second
burst float64 // maximum tokens (and initial fill)
lastGC time.Time
}
type rlBucket struct {
tokens float64
last time.Time
}
// newIPRateLimiter creates a limiter that allows bursts up to `burst` requests
// and then refills at `ratePerSec` tokens/second per client IP.
func newIPRateLimiter(ratePerSec, burst float64) *ipRateLimiter {
return &ipRateLimiter{
buckets: make(map[string]*rlBucket),
rate: ratePerSec,
burst: burst,
}
}
// allow reports whether a request from key is permitted at time now, consuming
// one token when it is.
func (l *ipRateLimiter) allow(key string, now time.Time) bool {
l.mu.Lock()
defer l.mu.Unlock()
// Opportunistic GC: drop buckets idle for >10 minutes. Bounds memory even
// under a spoofed-IP flood without needing a background goroutine.
if l.lastGC.IsZero() {
l.lastGC = now
}
if now.Sub(l.lastGC) > time.Minute {
for k, b := range l.buckets {
if now.Sub(b.last) > 10*time.Minute {
delete(l.buckets, k)
}
}
l.lastGC = now
}
b, ok := l.buckets[key]
if !ok {
b = &rlBucket{tokens: l.burst, last: now}
l.buckets[key] = b
}
// Refill based on elapsed time, capped at burst.
elapsed := now.Sub(b.last).Seconds()
if elapsed > 0 {
b.tokens = math.Min(l.burst, b.tokens+elapsed*l.rate)
b.last = now
}
if b.tokens < 1 {
return false
}
b.tokens--
return true
}
// rateLimitMiddleware throttles requests per client IP, returning 429 when the
// caller exceeds the configured rate.
func rateLimitMiddleware(l *ipRateLimiter) gin.HandlerFunc {
return func(c *gin.Context) {
if !l.allow(c.ClientIP(), time.Now()) {
c.Header("Retry-After", "60")
c.JSON(http.StatusTooManyRequests, gin.H{
"error": "Too many requests. Please slow down and try again in a minute.",
})
c.Abort()
return
}
c.Next()
}
}

View File

@@ -1,54 +0,0 @@
package api
import (
"testing"
"time"
)
// TestIPRateLimiterBurstThenThrottle verifies that a client gets `burst`
// immediate attempts and is then throttled until tokens refill.
func TestIPRateLimiterBurstThenThrottle(t *testing.T) {
// 1 token/sec, burst of 3.
l := newIPRateLimiter(1.0, 3)
now := time.Unix(1_700_000_000, 0)
// First 3 requests in the same instant are allowed (the burst).
for i := 0; i < 3; i++ {
if !l.allow("1.2.3.4", now) {
t.Fatalf("request %d in burst should be allowed", i+1)
}
}
// 4th in the same instant is throttled.
if l.allow("1.2.3.4", now) {
t.Fatalf("request beyond burst should be throttled")
}
// After 1 second, one token refills → exactly one more request allowed.
now = now.Add(time.Second)
if !l.allow("1.2.3.4", now) {
t.Fatalf("one token should have refilled after 1s")
}
if l.allow("1.2.3.4", now) {
t.Fatalf("only one token should refill per second")
}
}
// TestIPRateLimiterIsolatesClients verifies one IP exhausting its bucket does
// not throttle a different IP.
func TestIPRateLimiterIsolatesClients(t *testing.T) {
l := newIPRateLimiter(1.0, 2)
now := time.Unix(1_700_000_000, 0)
// Exhaust IP A.
if !l.allow("10.0.0.1", now) || !l.allow("10.0.0.1", now) {
t.Fatalf("IP A burst should be allowed")
}
if l.allow("10.0.0.1", now) {
t.Fatalf("IP A should be throttled after burst")
}
// IP B is unaffected.
if !l.allow("10.0.0.2", now) {
t.Fatalf("IP B should be allowed regardless of IP A")
}
}

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

@@ -2,38 +2,11 @@ package api
import (
"encoding/json"
"net/http"
"net/http/httptest"
"testing"
"nofx/store"
"github.com/gin-gonic/gin"
)
// TestPublicDecryptRouteNotRegistered is a security regression test: the
// unauthenticated POST /api/crypto/decrypt route was a decryption oracle and
// must never be re-registered. A built server's router must not route to it.
func TestPublicDecryptRouteNotRegistered(t *testing.T) {
gin.SetMode(gin.TestMode)
s := &Server{router: gin.New()}
s.setupRoutes()
for _, r := range s.router.Routes() {
if r.Method == http.MethodPost && r.Path == "/api/crypto/decrypt" {
t.Fatalf("SECURITY REGRESSION: public decryption oracle POST /api/crypto/decrypt is registered")
}
}
// Also assert at the HTTP layer that the route is not handled.
req := httptest.NewRequest(http.MethodPost, "/api/crypto/decrypt", nil)
w := httptest.NewRecorder()
s.router.ServeHTTP(w, req)
if w.Code != http.StatusNotFound {
t.Fatalf("expected 404 for POST /api/crypto/decrypt, got %d", w.Code)
}
}
// TestUpdateTraderRequest_SystemPromptTemplate Test whether SystemPromptTemplate field exists when updating trader
func TestUpdateTraderRequest_SystemPromptTemplate(t *testing.T) {
tests := []struct {

View File

@@ -1,7 +1,6 @@
package api
import (
"context"
"encoding/json"
"fmt"
"net/http"
@@ -9,8 +8,6 @@ import (
"nofx/logger"
"nofx/market"
"nofx/mcp"
_ "nofx/mcp/payment"
_ "nofx/mcp/provider"
"nofx/store"
"time"
@@ -21,9 +18,6 @@ import (
// validateStrategyConfig validates strategy configuration and returns warnings
func validateStrategyConfig(config *store.StrategyConfig) []string {
var warnings []string
if config.StrategyType == "grid_trading" {
return warnings
}
// Validate NofxOS API key if any NofxOS feature is enabled
if (config.Indicators.EnableQuantData || config.Indicators.EnableOIRanking ||
@@ -35,31 +29,6 @@ func validateStrategyConfig(config *store.StrategyConfig) []string {
return warnings
}
func attachPublishConfig(config *store.StrategyConfig, strategy *store.Strategy) {
if config == nil || strategy == nil {
return
}
config.ClampLimits()
config.PublishConfig = &store.PublishStrategyConfig{
IsPublic: strategy.IsPublic,
ConfigVisible: strategy.ConfigVisible,
}
}
// handleEstimateTokens estimates token usage for a strategy config (no auth required, pure computation)
func (s *Server) handleEstimateTokens(c *gin.Context) {
var req struct {
Config store.StrategyConfig `json:"config" binding:"required"`
}
if err := c.ShouldBindJSON(&req); err != nil {
SafeBadRequest(c, "Invalid request parameters")
return
}
estimate := req.Config.EstimateTokens()
c.JSON(http.StatusOK, estimate)
}
// handlePublicStrategies Get public strategies for strategy market (no auth required)
func (s *Server) handlePublicStrategies(c *gin.Context) {
strategies, err := s.store.Strategy().ListPublic()
@@ -86,7 +55,6 @@ func (s *Server) handlePublicStrategies(c *gin.Context) {
if st.ConfigVisible {
var config store.StrategyConfig
json.Unmarshal([]byte(st.Config), &config)
attachPublishConfig(&config, st)
item["config"] = config
}
@@ -106,14 +74,6 @@ func (s *Server) handleGetStrategies(c *gin.Context) {
return
}
lang := c.Query("lang")
if lang == "" {
lang = "zh"
}
if err := s.createDefaultStrategies(userID, lang); err != nil {
logger.Warnf("Failed to sync default strategy presets for user %s: %v", userID, err)
}
strategies, err := s.store.Strategy().List(userID)
if err != nil {
SafeInternalError(c, "Failed to get strategy list", err)
@@ -125,7 +85,6 @@ func (s *Server) handleGetStrategies(c *gin.Context) {
for _, st := range strategies {
var config store.StrategyConfig
json.Unmarshal([]byte(st.Config), &config)
attachPublishConfig(&config, st)
result = append(result, gin.H{
"id": st.ID,
@@ -164,7 +123,6 @@ func (s *Server) handleGetStrategy(c *gin.Context) {
var config store.StrategyConfig
json.Unmarshal([]byte(strategy.Config), &config)
attachPublishConfig(&config, strategy)
c.JSON(http.StatusOK, gin.H{
"id": strategy.ID,
@@ -178,8 +136,7 @@ func (s *Server) handleGetStrategy(c *gin.Context) {
})
}
// handleCreateStrategy Create strategy.
// If "config" is omitted from the request body, the system default config is used automatically.
// handleCreateStrategy Create strategy
func (s *Server) handleCreateStrategy(c *gin.Context) {
userID := c.GetString("user_id")
if userID == "" {
@@ -188,12 +145,9 @@ func (s *Server) handleCreateStrategy(c *gin.Context) {
}
var req struct {
Name string `json:"name" binding:"required"`
Description string `json:"description"`
Lang string `json:"lang"` // "zh" or "en", used when config is omitted
Config *store.StrategyConfig `json:"config"` // optional — uses default if omitted
IsPublic bool `json:"is_public"`
ConfigVisible bool `json:"config_visible"`
Name string `json:"name" binding:"required"`
Description string `json:"description"`
Config store.StrategyConfig `json:"config" binding:"required"`
}
if err := c.ShouldBindJSON(&req); err != nil {
@@ -201,29 +155,6 @@ func (s *Server) handleCreateStrategy(c *gin.Context) {
return
}
// Use default config when none provided
if req.Config == nil {
lang := req.Lang
if lang == "" {
lang = "zh"
}
defaultCfg := store.GetDefaultStrategyConfig(lang)
req.Config = &defaultCfg
}
beforeClamp := *req.Config
req.Config.ClampLimits()
hadPublishConfig := req.Config.PublishConfig != nil
isPublic := req.IsPublic
configVisible := req.ConfigVisible
if hadPublishConfig {
isPublic = req.Config.PublishConfig.IsPublic
configVisible = req.Config.PublishConfig.ConfigVisible
}
req.Config.PublishConfig = &store.PublishStrategyConfig{
IsPublic: isPublic,
ConfigVisible: configVisible,
}
// Serialize configuration
configJSON, err := json.Marshal(req.Config)
if err != nil {
@@ -238,10 +169,7 @@ func (s *Server) handleCreateStrategy(c *gin.Context) {
Description: req.Description,
IsActive: false,
IsDefault: false,
IsPublic: isPublic,
// Existing default is true; keep that behavior when no explicit publish config is sent.
ConfigVisible: configVisible || !hadPublishConfig,
Config: string(configJSON),
Config: string(configJSON),
}
if err := s.store.Strategy().Create(strategy); err != nil {
@@ -250,8 +178,7 @@ func (s *Server) handleCreateStrategy(c *gin.Context) {
}
// Validate configuration and collect warnings
warnings := validateStrategyConfig(req.Config)
warnings = append(warnings, store.StrategyClampWarnings(beforeClamp, *req.Config, req.Config.Language)...)
warnings := validateStrategyConfig(&req.Config)
response := gin.H{
"id": strategy.ID,
@@ -264,10 +191,7 @@ func (s *Server) handleCreateStrategy(c *gin.Context) {
c.JSON(http.StatusOK, response)
}
// handleUpdateStrategy Update strategy.
// The incoming config is merged with the existing one: top-level sections present in the
// request overwrite the corresponding existing sections; absent sections are preserved.
// This prevents partial updates from zeroing out unmentioned fields.
// handleUpdateStrategy Update strategy
func (s *Server) handleUpdateStrategy(c *gin.Context) {
userID := c.GetString("user_id")
strategyID := c.Param("id")
@@ -289,11 +213,11 @@ func (s *Server) handleUpdateStrategy(c *gin.Context) {
}
var req struct {
Name string `json:"name"`
Description string `json:"description"`
Config json.RawMessage `json:"config"` // raw JSON so we can merge
IsPublic bool `json:"is_public"`
ConfigVisible bool `json:"config_visible"`
Name string `json:"name"`
Description string `json:"description"`
Config store.StrategyConfig `json:"config"`
IsPublic bool `json:"is_public"`
ConfigVisible bool `json:"config_visible"`
}
if err := c.ShouldBindJSON(&req); err != nil {
@@ -301,40 +225,8 @@ func (s *Server) handleUpdateStrategy(c *gin.Context) {
return
}
// Start with the existing config as base — preserves all unmentioned fields.
var mergedConfig store.StrategyConfig
if err := json.Unmarshal([]byte(existing.Config), &mergedConfig); err != nil {
// If existing config is corrupt, start from zero
mergedConfig = store.StrategyConfig{}
}
// Apply incoming config on top while preserving nested fields that were not sent.
if len(req.Config) > 0 && string(req.Config) != "null" {
var patch map[string]any
if err := json.Unmarshal(req.Config, &patch); err != nil {
SafeBadRequest(c, "Invalid config JSON")
return
}
mergedConfig, err = store.MergeStrategyConfig(mergedConfig, patch)
if err != nil {
SafeBadRequest(c, "Invalid config JSON")
return
}
}
beforeClamp := mergedConfig
mergedConfig.ClampLimits()
// Preserve existing name/description when not supplied
name := req.Name
if name == "" {
name = existing.Name
}
description := req.Description
if description == "" {
description = existing.Description
}
configJSON, err := json.Marshal(mergedConfig)
// Serialize configuration
configJSON, err := json.Marshal(req.Config)
if err != nil {
SafeInternalError(c, "Serialize configuration", err)
return
@@ -343,8 +235,8 @@ func (s *Server) handleUpdateStrategy(c *gin.Context) {
strategy := &store.Strategy{
ID: strategyID,
UserID: userID,
Name: name,
Description: description,
Name: req.Name,
Description: req.Description,
Config: string(configJSON),
IsPublic: req.IsPublic,
ConfigVisible: req.ConfigVisible,
@@ -355,28 +247,8 @@ func (s *Server) handleUpdateStrategy(c *gin.Context) {
return
}
// Token overflow check — block save if all models exceed context limits
if mergedConfig.StrategyType == "" || mergedConfig.StrategyType == "ai_trading" {
estimate := mergedConfig.EstimateTokens()
allExceed := true
for _, ml := range estimate.ModelLimits {
if ml.UsagePct <= 100 {
allExceed = false
break
}
}
if allExceed && len(estimate.ModelLimits) > 0 {
c.JSON(http.StatusBadRequest, gin.H{
"error": fmt.Sprintf("Estimated %d tokens exceeds all known model context limits. Reduce coins, timeframes, or K-line count.", estimate.Total),
"token_estimate": estimate,
})
return
}
}
// Validate merged configuration and collect warnings
warnings := validateStrategyConfig(&mergedConfig)
warnings = append(warnings, store.StrategyClampWarnings(beforeClamp, mergedConfig, mergedConfig.Language)...)
// Validate configuration and collect warnings
warnings := validateStrategyConfig(&req.Config)
response := gin.H{"message": "Strategy updated successfully"}
if len(warnings) > 0 {
@@ -397,7 +269,7 @@ func (s *Server) handleDeleteStrategy(c *gin.Context) {
}
if err := s.store.Strategy().Delete(userID, strategyID); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": SanitizeError(err, "Failed to delete strategy")})
SafeInternalError(c, "Failed to delete strategy", err)
return
}
@@ -470,7 +342,6 @@ func (s *Server) handleGetActiveStrategy(c *gin.Context) {
var config store.StrategyConfig
json.Unmarshal([]byte(strategy.Config), &config)
attachPublishConfig(&config, strategy)
c.JSON(http.StatusOK, gin.H{
"id": strategy.ID,
@@ -506,9 +377,9 @@ func (s *Server) handlePreviewPrompt(c *gin.Context) {
}
var req struct {
Config store.StrategyConfig `json:"config" binding:"required"`
AccountEquity float64 `json:"account_equity"`
PromptVariant string `json:"prompt_variant"`
Config store.StrategyConfig `json:"config" binding:"required"`
AccountEquity float64 `json:"account_equity"`
PromptVariant string `json:"prompt_variant"`
}
if err := c.ShouldBindJSON(&req); err != nil {
@@ -570,17 +441,8 @@ func (s *Server) handleStrategyTestRun(c *gin.Context) {
req.PromptVariant = "balanced"
}
claw402WalletKey, err := s.resolveStrategyDataWalletKey(userID, req.AIModelID)
if err != nil {
c.JSON(http.StatusBadRequest, gin.H{
"error": err.Error(),
"ai_response": "",
})
return
}
// Create strategy engine to build prompt
engine := kernel.NewStrategyEngine(&req.Config, claw402WalletKey)
engine := kernel.NewStrategyEngine(&req.Config)
// Get candidate coins
candidates, err := engine.GetCandidateCoins()
@@ -637,7 +499,6 @@ func (s *Server) handleStrategyTestRun(c *gin.Context) {
symbols = append(symbols, c.Symbol)
}
quantDataMap := engine.FetchQuantDataBatch(symbols)
vergexDataMap := engine.FetchVergexDataBatch(context.Background(), symbols)
// Fetch OI ranking data (market-wide position changes)
oiRankingData := engine.FetchOIRankingData()
@@ -668,7 +529,6 @@ func (s *Server) handleStrategyTestRun(c *gin.Context) {
PromptVariant: req.PromptVariant,
MarketDataMap: marketDataMap,
QuantDataMap: quantDataMap,
VergexDataMap: vergexDataMap,
OIRankingData: oiRankingData,
NetFlowRankingData: netFlowRankingData,
PriceRankingData: priceRankingData,
@@ -737,20 +597,37 @@ func (s *Server) runRealAITest(userID, modelID, systemPrompt, userPrompt string)
return "", fmt.Errorf("AI model %s is missing API Key", model.Name)
}
// Create AI client via registry
// Create AI client
var aiClient mcp.AIClient
provider := model.Provider
// Convert EncryptedString to string for API key
apiKey := string(model.APIKey)
aiClient := mcp.NewAIClientByProvider(provider)
if aiClient == nil {
aiClient = mcp.NewClient()
}
// Payment providers ignore custom URL
switch provider {
case "claw402":
aiClient.SetAPIKey(apiKey, "", model.CustomModelName)
case "qwen":
aiClient = mcp.NewQwenClient()
aiClient.SetAPIKey(apiKey, model.CustomAPIURL, model.CustomModelName)
case "deepseek":
aiClient = mcp.NewDeepSeekClient()
aiClient.SetAPIKey(apiKey, model.CustomAPIURL, model.CustomModelName)
case "claude":
aiClient = mcp.NewClaudeClient()
aiClient.SetAPIKey(apiKey, model.CustomAPIURL, model.CustomModelName)
case "kimi":
aiClient = mcp.NewKimiClient()
aiClient.SetAPIKey(apiKey, model.CustomAPIURL, model.CustomModelName)
case "gemini":
aiClient = mcp.NewGeminiClient()
aiClient.SetAPIKey(apiKey, model.CustomAPIURL, model.CustomModelName)
case "grok":
aiClient = mcp.NewGrokClient()
aiClient.SetAPIKey(apiKey, model.CustomAPIURL, model.CustomModelName)
case "openai":
aiClient = mcp.NewOpenAIClient()
aiClient.SetAPIKey(apiKey, model.CustomAPIURL, model.CustomModelName)
default:
// Use generic client
aiClient = mcp.NewClient()
aiClient.SetAPIKey(apiKey, model.CustomAPIURL, model.CustomModelName)
}
@@ -763,6 +640,3 @@ func (s *Server) runRealAITest(userID, modelID, systemPrompt, userPrompt string)
return response, nil
}
func (s *Server) resolveStrategyDataWalletKey(userID, selectedModelID string) (string, error) {
return s.store.AIModel().ResolveClaw402WalletKey(userID, selectedModelID)
}

View File

@@ -15,10 +15,13 @@ func MaskSensitiveString(s string) string {
return s[:4] + "****" + s[length-4:]
}
// SanitizeModelConfigForLog Sanitize model configuration for log output.
// Takes the same ModelConfigUpdate type used by the request handler so the two
// can never drift out of sync.
func SanitizeModelConfigForLog(models map[string]ModelConfigUpdate) map[string]interface{} {
// SanitizeModelConfigForLog Sanitize model configuration for log output
func SanitizeModelConfigForLog(models map[string]struct {
Enabled bool `json:"enabled"`
APIKey string `json:"api_key"`
CustomAPIURL string `json:"custom_api_url"`
CustomModelName string `json:"custom_model_name"`
}) map[string]interface{} {
safe := make(map[string]interface{})
for modelID, cfg := range models {
safe[modelID] = map[string]interface{}{
@@ -31,12 +34,19 @@ func SanitizeModelConfigForLog(models map[string]ModelConfigUpdate) map[string]i
return safe
}
// SanitizeExchangeConfigForLog Sanitize exchange configuration for log output.
// Takes the same ExchangeConfigUpdate type used by the request handler so every
// sensitive field is guaranteed to be masked — adding a field to the request
// type without masking it here would not compile around this helper, but more
// importantly keeps the masking exhaustive.
func SanitizeExchangeConfigForLog(exchanges map[string]ExchangeConfigUpdate) map[string]interface{} {
// SanitizeExchangeConfigForLog Sanitize exchange configuration for log output
func SanitizeExchangeConfigForLog(exchanges map[string]struct {
Enabled bool `json:"enabled"`
APIKey string `json:"api_key"`
SecretKey string `json:"secret_key"`
Testnet bool `json:"testnet"`
HyperliquidWalletAddr string `json:"hyperliquid_wallet_addr"`
AsterUser string `json:"aster_user"`
AsterSigner string `json:"aster_signer"`
AsterPrivateKey string `json:"aster_private_key"`
LighterWalletAddr string `json:"lighter_wallet_addr"`
LighterPrivateKey string `json:"lighter_private_key"`
}) map[string]interface{} {
safe := make(map[string]interface{})
for exchangeID, cfg := range exchanges {
safeExchange := map[string]interface{}{
@@ -51,18 +61,12 @@ func SanitizeExchangeConfigForLog(exchanges map[string]ExchangeConfigUpdate) map
if cfg.SecretKey != "" {
safeExchange["secret_key"] = MaskSensitiveString(cfg.SecretKey)
}
if cfg.Passphrase != "" {
safeExchange["passphrase"] = MaskSensitiveString(cfg.Passphrase)
}
if cfg.AsterPrivateKey != "" {
safeExchange["aster_private_key"] = MaskSensitiveString(cfg.AsterPrivateKey)
}
if cfg.LighterPrivateKey != "" {
safeExchange["lighter_private_key"] = MaskSensitiveString(cfg.LighterPrivateKey)
}
if cfg.LighterAPIKeyPrivateKey != "" {
safeExchange["lighter_api_key_private_key"] = MaskSensitiveString(cfg.LighterAPIKeyPrivateKey)
}
// Add non-sensitive fields directly
if cfg.HyperliquidWalletAddr != "" {

View File

@@ -1,8 +1,6 @@
package api
import (
"fmt"
"strings"
"testing"
)
@@ -50,7 +48,12 @@ func TestMaskSensitiveString(t *testing.T) {
}
func TestSanitizeModelConfigForLog(t *testing.T) {
models := map[string]ModelConfigUpdate{
models := map[string]struct {
Enabled bool `json:"enabled"`
APIKey string `json:"api_key"`
CustomAPIURL string `json:"custom_api_url"`
CustomModelName string `json:"custom_model_name"`
}{
"deepseek": {
Enabled: true,
APIKey: "sk-1234567890abcdefghijklmnopqrstuvwxyz",
@@ -85,29 +88,32 @@ func TestSanitizeModelConfigForLog(t *testing.T) {
}
func TestSanitizeExchangeConfigForLog(t *testing.T) {
exchanges := map[string]ExchangeConfigUpdate{
exchanges := map[string]struct {
Enabled bool `json:"enabled"`
APIKey string `json:"api_key"`
SecretKey string `json:"secret_key"`
Testnet bool `json:"testnet"`
HyperliquidWalletAddr string `json:"hyperliquid_wallet_addr"`
AsterUser string `json:"aster_user"`
AsterSigner string `json:"aster_signer"`
AsterPrivateKey string `json:"aster_private_key"`
LighterWalletAddr string `json:"lighter_wallet_addr"`
LighterPrivateKey string `json:"lighter_private_key"`
}{
"binance": {
Enabled: true,
APIKey: "binance_api_key_1234567890abcdef",
SecretKey: "binance_secret_key_1234567890abcdef",
Testnet: false,
},
"okx": {
Enabled: true,
APIKey: "okx_api_key_1234567890abcdef",
SecretKey: "okx_secret_key_1234567890abcdef",
Passphrase: "okx_passphrase_supersecret_value",
},
"lighter": {
Enabled: true,
LighterWalletAddr: "0xabcdef0000000000000000000000000000000000",
LighterPrivateKey: "lighter_private_key_1234567890abcdef",
LighterAPIKeyPrivateKey: "lighter_api_key_private_key_1234567890abcdef",
LighterWalletAddr: "",
LighterPrivateKey: "",
},
"hyperliquid": {
Enabled: true,
HyperliquidWalletAddr: "0x1234567890abcdef1234567890abcdef12345678",
Testnet: false,
LighterWalletAddr: "",
LighterPrivateKey: "",
},
}
@@ -137,32 +143,6 @@ func TestSanitizeExchangeConfigForLog(t *testing.T) {
t.Errorf("expected masked secret_key='bina****cdef', got %q", maskedSecretKey)
}
// Check OKX passphrase is masked (regression: previously not covered)
okxConfig, ok := result["okx"].(map[string]interface{})
if !ok {
t.Fatal("okx config not found or wrong type")
}
maskedPassphrase, ok := okxConfig["passphrase"].(string)
if !ok {
t.Fatal("okx passphrase not found or wrong type")
}
if maskedPassphrase != "okx_****alue" {
t.Errorf("expected masked passphrase='okx_****alue', got %q", maskedPassphrase)
}
// Check Lighter API key private key is masked (regression: previously not covered)
lighterConfig, ok := result["lighter"].(map[string]interface{})
if !ok {
t.Fatal("lighter config not found or wrong type")
}
maskedLighterAPIKey, ok := lighterConfig["lighter_api_key_private_key"].(string)
if !ok {
t.Fatal("lighter_api_key_private_key not found or wrong type")
}
if maskedLighterAPIKey != "ligh****cdef" {
t.Errorf("expected masked lighter_api_key_private_key='ligh****cdef', got %q", maskedLighterAPIKey)
}
// Check Hyperliquid configuration
hlConfig, ok := result["hyperliquid"].(map[string]interface{})
if !ok {
@@ -180,41 +160,6 @@ func TestSanitizeExchangeConfigForLog(t *testing.T) {
}
}
// TestSanitizeExchangeConfigForLog_NoPlaintextSecrets renders the sanitized log
// output exactly as the handler does (`%+v`) and asserts that no plaintext
// secret — including the passphrase and lighter API key private key that were
// historically not redacted — survives into the log line.
func TestSanitizeExchangeConfigForLog_NoPlaintextSecrets(t *testing.T) {
secrets := map[string]string{
"api_key": "binance_api_key_1234567890abcdef",
"secret_key": "binance_secret_key_1234567890abcdef",
"passphrase": "okx_passphrase_supersecret_value",
"aster_private_key": "aster_private_key_1234567890abcdef",
"lighter_private_key": "lighter_private_key_1234567890abcdef",
"lighter_api_key_private_key": "lighter_api_key_private_key_1234567890abcdef",
}
exchanges := map[string]ExchangeConfigUpdate{
"okx": {
Enabled: true,
APIKey: secrets["api_key"],
SecretKey: secrets["secret_key"],
Passphrase: secrets["passphrase"],
AsterPrivateKey: secrets["aster_private_key"],
LighterPrivateKey: secrets["lighter_private_key"],
LighterAPIKeyPrivateKey: secrets["lighter_api_key_private_key"],
},
}
rendered := fmt.Sprintf("%+v", SanitizeExchangeConfigForLog(exchanges))
for field, secret := range secrets {
if strings.Contains(rendered, secret) {
t.Errorf("sanitized log leaked plaintext %s: %q present in %q", field, secret, rendered)
}
}
}
func TestMaskEmail(t *testing.T) {
tests := []struct {
name string

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

228
cli.go
View File

@@ -1,228 +0,0 @@
package main
import (
"bufio"
"errors"
"flag"
"fmt"
"os"
"path/filepath"
"strings"
"nofx/auth"
"nofx/config"
"nofx/crypto"
"nofx/logger"
"nofx/store"
"github.com/joho/godotenv"
"golang.org/x/term"
"gorm.io/gorm"
)
// minResetPasswordLen mirrors the minimum enforced on the authenticated
// password-change path (PUT /api/user/password).
const minResetPasswordLen = 8
// runCLISubcommand dispatches local admin subcommands.
//
// SECURITY: account recovery (reset-password, reset-account) is intentionally
// NOT exposed over HTTP. Performing it requires running this binary on the host,
// which in turn requires shell/file access to the server. A remote attacker on a
// public-facing deployment has only the network — they can reach the API but not
// a local process — so recovery cannot be triggered remotely. This is what makes
// the recovery path safe even when NOFX is deployed on the public internet.
//
// Returns true if a subcommand was recognized and handled (caller should exit).
// Unknown first args fall through to false to preserve the historical behavior
// where `nofx <dbpath>` overrides the SQLite path.
func runCLISubcommand(args []string) bool {
if len(args) == 0 {
return false
}
switch args[0] {
case "reset-password":
runResetPassword(args[1:])
return true
case "reset-account":
runResetAccount(args[1:])
return true
default:
return false
}
}
// openStoreForCLI loads config + encryption and opens the same database the
// server uses, so subcommands operate on the live data.
func openStoreForCLI(dbPathOverride string) (*store.Store, error) {
_ = godotenv.Load()
logger.Init(nil)
config.MustInit()
cfg := config.Get()
if strings.TrimSpace(dbPathOverride) != "" {
cfg.DBPath = dbPathOverride
}
cryptoService, err := crypto.NewCryptoService()
if err != nil {
return nil, fmt.Errorf("initialize encryption service: %w", err)
}
crypto.SetGlobalCryptoService(cryptoService)
if cfg.DBType == "sqlite" {
if dir := filepath.Dir(cfg.DBPath); dir != "." {
if err := os.MkdirAll(dir, 0o755); err != nil {
return nil, fmt.Errorf("create data directory: %w", err)
}
}
}
dbType := store.DBTypeSQLite
if cfg.DBType == "postgres" {
dbType = store.DBTypePostgres
}
return store.NewWithConfig(store.DBConfig{
Type: dbType,
Path: cfg.DBPath,
Host: cfg.DBHost,
Port: cfg.DBPort,
User: cfg.DBUser,
Password: cfg.DBPassword,
DBName: cfg.DBName,
SSLMode: cfg.DBSSLMode,
})
}
// runResetPassword resets the password for a single account from the command
// line. Usage: `nofx reset-password --email you@example.com`.
func runResetPassword(args []string) {
fs := flag.NewFlagSet("reset-password", flag.ExitOnError)
email := fs.String("email", "", "email of the account to reset (required)")
password := fs.String("password", "", "new password (min 8 chars); omit to enter it interactively")
dbPath := fs.String("db", "", "override SQLite DB path (defaults to config / DB_PATH)")
_ = fs.Parse(args)
if strings.TrimSpace(*email) == "" {
fmt.Fprintln(os.Stderr, "error: --email is required")
fmt.Fprintln(os.Stderr, "usage: nofx reset-password --email you@example.com")
os.Exit(2)
}
st, err := openStoreForCLI(*dbPath)
if err != nil {
fmt.Fprintf(os.Stderr, "error: %v\n", err)
os.Exit(1)
}
defer st.Close()
user, err := st.User().GetByEmail(strings.TrimSpace(*email))
if err != nil {
fmt.Fprintf(os.Stderr, "error: no account found for %q\n", strings.TrimSpace(*email))
os.Exit(1)
}
newPassword, err := resolveNewPassword(*password)
if err != nil {
fmt.Fprintf(os.Stderr, "error: %v\n", err)
os.Exit(1)
}
hash, err := auth.HashPassword(newPassword)
if err != nil {
fmt.Fprintf(os.Stderr, "error: failed to hash password: %v\n", err)
os.Exit(1)
}
if err := st.User().UpdatePassword(user.ID, hash); err != nil {
fmt.Fprintf(os.Stderr, "error: failed to update password: %v\n", err)
os.Exit(1)
}
fmt.Printf("✓ Password reset for %s. Log in with the new password.\n", user.Email)
}
// runResetAccount wipes the database back to an uninitialized state. This is the
// destructive "forgot everything" recovery, moved off the public API.
func runResetAccount(args []string) {
fs := flag.NewFlagSet("reset-account", flag.ExitOnError)
dbPath := fs.String("db", "", "override SQLite DB path (defaults to config / DB_PATH)")
yes := fs.Bool("yes", false, "skip the interactive confirmation prompt")
_ = fs.Parse(args)
if !*yes {
fmt.Print("This permanently deletes ALL users, traders, strategies, AI models and\n" +
"exchanges — including wallet keys and exchange credentials.\n" +
"Type 'wipe' to confirm: ")
line, _ := bufio.NewReader(os.Stdin).ReadString('\n')
if strings.TrimSpace(line) != "wipe" {
fmt.Fprintln(os.Stderr, "aborted")
os.Exit(1)
}
}
st, err := openStoreForCLI(*dbPath)
if err != nil {
fmt.Fprintf(os.Stderr, "error: %v\n", err)
os.Exit(1)
}
defer st.Close()
err = st.Transaction(func(tx *gorm.DB) error {
tx.Session(&gorm.Session{AllowGlobalUpdate: true}).Delete(&store.Trader{})
tx.Session(&gorm.Session{AllowGlobalUpdate: true}).Delete(&store.Strategy{})
tx.Session(&gorm.Session{AllowGlobalUpdate: true}).Delete(&store.AIModel{})
tx.Session(&gorm.Session{AllowGlobalUpdate: true}).Delete(&store.Exchange{})
if err := tx.Session(&gorm.Session{AllowGlobalUpdate: true}).Delete(&store.User{}).Error; err != nil {
return fmt.Errorf("failed to delete users: %w", err)
}
return nil
})
if err != nil {
fmt.Fprintf(os.Stderr, "error: failed to reset account: %v\n", err)
os.Exit(1)
}
fmt.Println("✓ System wiped. Register a fresh account and re-import everything.")
}
// resolveNewPassword returns the new password from the --password flag, or
// prompts for it (hidden) on a TTY, or reads a single line from piped stdin.
func resolveNewPassword(flagValue string) (string, error) {
if flagValue != "" {
if len(flagValue) < minResetPasswordLen {
return "", fmt.Errorf("password must be at least %d characters", minResetPasswordLen)
}
return flagValue, nil
}
if term.IsTerminal(int(os.Stdin.Fd())) {
fmt.Printf("New password (min %d chars): ", minResetPasswordLen)
first, err := term.ReadPassword(int(os.Stdin.Fd()))
fmt.Println()
if err != nil {
return "", fmt.Errorf("failed to read password: %w", err)
}
fmt.Print("Confirm new password: ")
second, err := term.ReadPassword(int(os.Stdin.Fd()))
fmt.Println()
if err != nil {
return "", fmt.Errorf("failed to read password: %w", err)
}
if string(first) != string(second) {
return "", errors.New("passwords do not match")
}
if len(first) < minResetPasswordLen {
return "", fmt.Errorf("password must be at least %d characters", minResetPasswordLen)
}
return string(first), nil
}
line, err := bufio.NewReader(os.Stdin).ReadString('\n')
password := strings.TrimRight(line, "\r\n")
if password == "" {
return "", fmt.Errorf("no password provided on stdin: %w", err)
}
if len(password) < minResetPasswordLen {
return "", fmt.Errorf("password must be at least %d characters", minResetPasswordLen)
}
return password, nil
}

View File

@@ -1,120 +0,0 @@
package main
import (
"encoding/json"
"fmt"
"net/http"
"nofx/config"
nofxcrypto "nofx/crypto"
"nofx/store"
hltrader "nofx/trader/hyperliquid"
"os"
"strconv"
"strings"
"time"
"github.com/joho/godotenv"
)
type clearinghouseState struct {
CrossMarginSummary struct {
AccountValue string `json:"accountValue"`
} `json:"crossMarginSummary"`
Withdrawable string `json:"withdrawable"`
AssetPositions []struct {
Position struct {
Coin string `json:"coin"`
Szi string `json:"szi"`
EntryPx string `json:"entryPx"`
PositionValue string `json:"positionValue"`
} `json:"position"`
} `json:"assetPositions"`
}
func fetchState(wallet string) (*clearinghouseState, error) {
body := strings.NewReader(fmt.Sprintf(`{"type":"clearinghouseState","user":%q}`, wallet))
resp, err := http.Post("https://api.hyperliquid.xyz/info", "application/json", body)
if err != nil {
return nil, err
}
defer resp.Body.Close()
var state clearinghouseState
if err := json.NewDecoder(resp.Body).Decode(&state); err != nil {
return nil, err
}
return &state, nil
}
func positionSize(state *clearinghouseState, coin string) float64 {
for _, ap := range state.AssetPositions {
if strings.EqualFold(ap.Position.Coin, coin) {
v, _ := strconv.ParseFloat(ap.Position.Szi, 64)
return v
}
}
return 0
}
func main() {
_ = godotenv.Load()
config.Init()
cryptoService, err := nofxcrypto.NewCryptoService()
if err != nil {
panic(err)
}
nofxcrypto.SetGlobalCryptoService(cryptoService)
cfg := config.Get()
st, err := store.NewWithConfig(store.DBConfig{Type: store.DBTypeSQLite, Path: cfg.DBPath})
if err != nil {
panic(err)
}
defer st.Close()
var ex store.Exchange
if err := st.GormDB().Where("exchange_type = ? AND enabled = ? AND hyperliquid_wallet_addr <> ''", "hyperliquid", true).First(&ex).Error; err != nil {
panic(fmt.Errorf("no enabled Hyperliquid exchange with wallet/private key found: %w", err))
}
if strings.TrimSpace(string(ex.APIKey)) == "" {
panic("Hyperliquid exchange has empty decrypted agent private key")
}
fmt.Printf("E2E exchange=%s account=%s wallet=%s testnet=%v builderApprovedFlag=%v\n", ex.ID, ex.AccountName, ex.HyperliquidWalletAddr, ex.Testnet, ex.HyperliquidBuilderApproved)
before, err := fetchState(ex.HyperliquidWalletAddr)
if err != nil {
panic(err)
}
fmt.Printf("BEFORE accountValue=%s withdrawable=%s HOOD_szi=%.6f\n", before.CrossMarginSummary.AccountValue, before.Withdrawable, positionSize(before, "xyz:HOOD"))
tr, err := hltrader.NewHyperliquidTrader(string(ex.APIKey), ex.HyperliquidWalletAddr, false, ex.HyperliquidUnifiedAcct)
if err != nil {
panic(err)
}
const symbol = "HOOD-USDC"
const qty = 0.15
fmt.Printf("OPEN_LONG symbol=%s qty=%.3f builderRequired=true\n", symbol, qty)
if _, err := tr.OpenLong(symbol, qty, 1); err != nil {
panic(fmt.Errorf("open long failed: %w", err))
}
time.Sleep(2 * time.Second)
mid, _ := fetchState(ex.HyperliquidWalletAddr)
pos := positionSize(mid, "xyz:HOOD")
fmt.Printf("AFTER_OPEN HOOD_szi=%.6f\n", pos)
closeQty := qty
if pos > 0 && pos < closeQty {
closeQty = pos
}
if closeQty > 0 {
fmt.Printf("CLOSE_LONG symbol=%s qty=%.6f builderRequired=true\n", symbol, closeQty)
if _, err := tr.CloseLong(symbol, closeQty); err != nil {
panic(fmt.Errorf("close long failed; manual intervention may be needed for %s size %.6f: %w", symbol, closeQty, err))
}
}
time.Sleep(2 * time.Second)
after, err := fetchState(ex.HyperliquidWalletAddr)
if err != nil {
panic(err)
}
fmt.Printf("AFTER_CLOSE accountValue=%s withdrawable=%s HOOD_szi=%.6f\n", after.CrossMarginSummary.AccountValue, after.Withdrawable, positionSize(after, "xyz:HOOD"))
fmt.Fprintln(os.Stdout, "E2E_BUILDER_FEE_REAL_XYZ_STOCK_TRADE_DONE")
}

233
cmd/lighter_test/main.go Normal file
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,23 +1,13 @@
package config
import (
"fmt"
"nofx/experience"
"nofx/mcp"
"nofx/telemetry"
"os"
"strconv"
"strings"
)
// insecureDefaultJWTSecret is the historical fallback value. Refusing to boot when
// JWT_SECRET matches it (or is missing) prevents the server from silently signing
// tokens with a well-known secret.
const insecureDefaultJWTSecret = "default-jwt-secret-change-in-production"
// minJWTSecretLength is the minimum byte length we accept for HS256 signing keys.
// HS256 keys shorter than 32 bytes are brute-forceable.
const minJWTSecretLength = 32
// Global configuration instance
var global *Config
@@ -25,8 +15,10 @@ var global *Config
// Only contains truly global config, trading related config is at trader/strategy level
type Config struct {
// Service configuration
APIServerPort int
JWTSecret string
APIServerPort int
JWTSecret string
RegistrationEnabled bool
MaxUsers int // Maximum number of users allowed (0 = unlimited, default = 10)
// Database configuration
DBType string // sqlite or postgres
@@ -52,29 +44,14 @@ type Config struct {
AlpacaAPIKey string // Alpaca API key for US stocks
AlpacaSecretKey string // Alpaca secret key
TwelveDataKey string // TwelveData API key for forex & metals
}
// MustInit initializes global configuration or panics. Use from main() so the
// process refuses to start under an insecure config (e.g. default JWT secret).
func MustInit() {
if err := initConfig(); err != nil {
panic(fmt.Sprintf("config: %v", err))
}
}
// Init initializes global configuration (from .env). Prefer MustInit from main.
// Init initializes global configuration (from .env)
func Init() {
if err := initConfig(); err != nil {
// Preserve historical fail-soft behavior for non-main callers (tests, tools);
// the process can still observe the error via Get() returning nil.
fmt.Fprintf(os.Stderr, "config init failed: %v\n", err)
}
}
func initConfig() error {
cfg := &Config{
APIServerPort: 8080,
RegistrationEnabled: true,
MaxUsers: 10, // Default: 10 users allowed
ExperienceImprovement: true, // Default: enabled to help improve the product
// Database defaults
DBType: "sqlite",
@@ -91,13 +68,17 @@ func initConfig() error {
cfg.JWTSecret = strings.TrimSpace(v)
}
if cfg.JWTSecret == "" {
return fmt.Errorf("JWT_SECRET is required (set a random %d+ byte value in .env)", minJWTSecretLength)
cfg.JWTSecret = "default-jwt-secret-change-in-production"
}
if cfg.JWTSecret == insecureDefaultJWTSecret {
return fmt.Errorf("JWT_SECRET matches the insecure default; generate a fresh random value (e.g. `openssl rand -base64 48`)")
if v := os.Getenv("REGISTRATION_ENABLED"); v != "" {
cfg.RegistrationEnabled = strings.ToLower(v) == "true"
}
if len(cfg.JWTSecret) < minJWTSecretLength {
return fmt.Errorf("JWT_SECRET must be at least %d bytes (got %d); generate via `openssl rand -base64 48`", minJWTSecretLength, len(cfg.JWTSecret))
if v := os.Getenv("MAX_USERS"); v != "" {
if maxUsers, err := strconv.Atoi(v); err == nil && maxUsers >= 0 {
cfg.MaxUsers = maxUsers
}
}
if v := os.Getenv("API_SERVER_PORT"); v != "" {
@@ -154,19 +135,17 @@ func initConfig() error {
global = cfg
// Initialize experience improvement (installation ID will be set after database init)
telemetry.Init(cfg.ExperienceImprovement, "")
experience.Init(cfg.ExperienceImprovement, "")
// Set up AI token usage tracking callback
mcp.TokenUsageCallback = func(usage mcp.TokenUsage) {
telemetry.TrackAIUsage(telemetry.AIUsageEvent{
experience.TrackAIUsage(experience.AIUsageEvent{
ModelProvider: usage.Provider,
ModelName: usage.Model,
Channel: usage.Channel(),
InputTokens: usage.PromptTokens,
OutputTokens: usage.CompletionTokens,
})
}
return nil
}
// Get returns the global configuration

View File

@@ -282,16 +282,12 @@ func isEncryptedStorageValue(value string) bool {
}
func (cs *CryptoService) DecryptPayload(payload *EncryptedPayload) ([]byte, error) {
// 1. Validate timestamp (prevent replay attacks).
// The timestamp is mandatory: a missing/zero ts previously skipped this check
// entirely, which let a captured ciphertext be replayed indefinitely. The
// client (web/src/lib/crypto.ts) always stamps ts, so requiring it is safe.
if payload.TS == 0 {
return nil, errors.New("missing timestamp")
}
elapsed := time.Since(time.Unix(payload.TS, 0))
if elapsed > 5*time.Minute || elapsed < -1*time.Minute {
return nil, errors.New("timestamp invalid or expired")
// 1. Validate timestamp (prevent replay attacks)
if payload.TS != 0 {
elapsed := time.Since(time.Unix(payload.TS, 0))
if elapsed > 5*time.Minute || elapsed < -1*time.Minute {
return nil, errors.New("timestamp invalid or expired")
}
}
// 2. Decode base64url
@@ -459,11 +455,8 @@ func (es EncryptedString) Value() (driver.Value, error) {
if globalCryptoService != nil {
encrypted, err := globalCryptoService.EncryptForStorage(string(es))
if err != nil {
// Fail closed: never silently persist a plaintext secret when
// encryption was expected to happen. Returning the error aborts the
// write so a misconfigured/broken crypto service cannot leak
// credentials into the database in cleartext.
return nil, fmt.Errorf("failed to encrypt sensitive field for storage: %w", err)
// If encryption fails, return the original value
return string(es), nil
}
return encrypted, nil
}

1416
debate/engine.go Normal file

File diff suppressed because it is too large Load Diff

View File

@@ -6,18 +6,11 @@ services:
dockerfile: ./docker/Dockerfile.backend
container_name: nofx-trading
restart: unless-stopped
stop_grace_period: 30s # Allow the app 30 seconds for graceful shutdown
stop_grace_period: 30s # 允许应用有 30 秒时间优雅关闭
ports:
- "${NOFX_BACKEND_PORT:-8080}:8080"
# pprof profiling is bound to host loopback only; uncomment for local debug.
# - "127.0.0.1:6060:6060"
- "6060:6060" # pprof profiling
volumes:
# NOTE: .env is bind-mounted so the beginner-onboarding flow
# (persistBeginnerWalletEnv) can write CLAW402_WALLET_* back to the host
# file. Without this mount the wallet is regenerated on every container
# restart. For threat models where the .env file should not be reachable
# via container RCE, deploy via env vars only and remove this mount.
- ./.env:/app/.env
- ./data:/app/data
- /etc/localtime:/etc/localtime:ro
env_file:
@@ -56,4 +49,4 @@ services:
networks:
nofx-network:
driver: bridge
driver: bridge

View File

@@ -1,203 +0,0 @@
# NOFXi 诊断与配置 Skills第一批
这份文档用于沉淀交易智能助手的第一批高频诊断与配置 skill。
目标不是让模型“更会想”,而是让它面对常见问题时,优先走稳定、可复用的排查路径。
## 设计原则
- 优先按 skill 回答,不要对高频问题重复自由规划
- 先归类问题,再给出原因、检查项和修复建议
- 能通过工具验证当前状态时,先查再下结论
- 敏感信息只指导填写,不完整回显
- 对结论不确定时,要明确标注为“更可能”或“优先怀疑”
## skill_model_api_setup
### 适用场景
- 用户问某个大模型的 API key 去哪里申请
- 用户问 base URL 怎么填
- 用户问 model name 怎么填
- 用户问 OpenAI / Claude / Gemini / DeepSeek / Qwen / Kimi / Grok / MiniMax 怎么接入
### 处理策略
1. 先确认用户要配置哪个 provider
2. 告诉用户需要准备的最少字段:
- provider
- API key
- custom_api_url
- custom_model_name
3. 如果系统已有默认地址和默认模型名,优先给推荐值
4. 回答按步骤组织,不要泛泛解释概念
### 已知实现事实
- 系统内置 provider 默认运行配置,见 `agent.resolveModelRuntimeConfig(...)`
- 常见 provider 已有默认 URL 和默认 model name
## skill_model_config_diagnosis
### 适用场景
- 模型保存成功但 agent 仍然不可用
- 提示 AI unavailable
- 提示模型没启用
- 提示 custom_api_url 不合法
- 配置后 trader 不生效
### 优先排查
1. 是否存在已启用模型
2. API key 是否为空
3. custom_api_url 是否为合法 HTTPS 地址
4. custom_model_name 是否为空或不匹配
5. 当前 trader 是否绑定了这个模型
6. 更新模型后是否已触发 trader reload
### 已知实现事实
- 非 HTTPS 的 `custom_api_url` 会被后端拒绝,见 `api/handler_ai_model.go`
- 已启用模型如果缺少 API Key 或 URL会导致 agent 无法就绪,见 `agent.ensureAIClientForStoreUser(...)`
- 更新模型配置后,系统会尝试移除并重载相关 trader使新配置立即生效
### 输出格式
- 现象
- 更可能原因
- 先检查什么
- 下一步怎么修复
## skill_exchange_api_setup
### 适用场景
- 用户要新建交易所 API
- 用户不知道交易所需要哪些权限
- 用户问 API key / secret / passphrase 分别填什么
### 通用处理策略
1. 先确认交易所类型
2. 告知必须权限与禁止权限
3. 告知是否需要额外字段
4. 强调 IP 白名单与权限配置
5. 引导用户回到系统内完成绑定
### 特殊规则
- OKX 除 API Key 和 Secret 外,还需要 passphrase
- Bybit 永续/合约交易需要合约权限
- 不建议开启提现权限
### 参考文档
- `docs/getting-started/okx-api.md`
- `docs/getting-started/bybit-api.md`
## skill_exchange_api_diagnosis
### 适用场景
- `invalid signature`
- `timestamp` 错误
- `IP not allowed`
- `permission denied`
- 交易所连接不上
### 优先排查
1. 系统时间是否同步
2. API Key / Secret 是否正确
3. 是否遗漏额外字段,如 OKX passphrase
4. IP 白名单是否包含当前服务器
5. 是否启用了交易或合约权限
6. 密钥是否过期或已重建
### 已知实现事实
- 时间不同步是 `invalid signature` / `timestamp` 的高频根因,见 `docs/guides/TROUBLESHOOTING.zh-CN.md`
- OKX 的 passphrase 缺失会导致签名相关问题,见 `docs/getting-started/okx-api.md`
### 输出格式
- 报错现象
- 最常见根因
- 优先检查顺序
- 修复步骤
## skill_trader_start_diagnosis
### 适用场景
- trader 启动不了
- trader 启动了但没开始交易
- 页面显示已启动但一直没有动作
- 用户怀疑 strategy / model / exchange 绑定有问题
### 优先排查
1. 是否有已启用的模型配置
2. 是否有已启用的交易所配置
3. trader 是否绑定了 exchange_id / strategy_id / ai_model_id
4. 交易所余额和权限是否满足下单条件
5. AI 最近的决策到底是 wait、hold 还是下单失败
### 回答原则
- 要区分“没启动”“启动了但 AI 选择不交易”“尝试下单但失败”这三类
- 不要把“没开仓”直接等同于“系统故障”
## skill_order_execution_diagnosis
### 适用场景
- 下单失败
- 只开空不开户 / 只开单边
- 杠杆报错
- position side mismatch
### 优先排查
1. 账户模式是否匹配,例如 Binance 是否为 Hedge Mode
2. 是否为子账户杠杆限制
3. 合约权限是否开启
4. 余额、保证金、可交易 symbol 是否满足条件
### 已知实现事实
- Binance 在 One-way Mode 下,可能出现 `position side mismatch` 或单边行为
- 某些子账户杠杆上限较低,超过限制会直接失败
- 这些问题在 `docs/guides/TROUBLESHOOTING.md` 已有明确说明
## skill_strategy_diagnosis
### 适用场景
- 用户说策略没生效
- 用户说 prompt 预览和实际不一致
- 用户说修改策略后 trader 行为没有变化
### 优先排查
1. 当前编辑的是策略模板,还是 trader 的 custom prompt
2. 策略是否真的保存成功
3. 是否需要重新读取当前配置做对比
4. 用户说的“没生效”是指未保存、未绑定,还是运行结果与预期不一致
### 回答原则
- 先明确“对象”再排查strategy template / trader / prompt override
- 如果能读取当前保存值,就不要凭印象判断
## 后续扩展方向
下一批可以继续补:
- `skill_balance_and_position_diagnosis`
- `skill_market_data_diagnosis`
- `skill_prompt_generation_diagnosis`
- `skill_strategy_test_run_diagnosis`
- `skill_exchange_specific_setup_<exchange>`
- `skill_model_provider_setup_<provider>`

View File

@@ -1,613 +0,0 @@
# NOFXi Agent 当前设计说明
## 目的
本文描述当前 NOFXi Agent 的实际设计,而不是早期版本的理想设计。重点回答这些问题:
- 用户消息从哪里进入
- 什么请求会进入 planner
- 当前有哪些记忆层
- planner 如何生成与执行 plan
- tool 现在是怎么设计的
- 动态快照和当前引用分别解决什么问题
- 为什么某些问题会出现“看起来有历史,但模型还是会追问”
本文对应的主要实现文件:
- `agent/agent.go`
- `agent/web.go`
- `api/agent_routes.go`
- `agent/planner_runtime.go`
- `agent/execution_state.go`
- `agent/memory.go`
- `agent/history.go`
- `agent/tools.go`
## 一句话总览
当前 Agent 的运行模型可以概括为:
1. 前端把消息发到 `/api/agent/chat/stream`
2. 后端把登录用户身份放进 context
3. Agent 除 `/clear``/status` 外,其他消息全部进入 planner
4. planner 结合多层记忆、动态快照和 tool schema 生成 plan
5. 执行 plan 中的 `tool / reason / ask_user / respond`
6. 在执行过程中持续更新执行态、短期原话、长期摘要和当前对象引用
## 请求入口
### 前端入口
前端 Agent 页面在:
- `web/src/pages/AgentChatPage.tsx`
当前聊天使用:
- `POST /api/agent/chat/stream`
请求体里会传:
- `message`
- `lang`
- `user_key`
### 后端路由入口
路由注册在:
- `api/agent_routes.go`
这里会:
1. 经过 `authMiddleware`
2. 从登录态里取出 `user_id`
3. 通过 `agent.WithStoreUserID(...)` 写入 request context
### Agent Web Handler
真正的 HTTP handler 在:
- `agent/web.go`
主要入口:
- `HandleChat(...)`
- `HandleChatStream(...)`
再往下进入:
- `HandleMessageForStoreUser(...)`
- `HandleMessageStreamForStoreUser(...)`
## 最外层分流
当前外层分流已经被收口。
`agent/agent.go` 中,除了这两个命令之外,其他输入全部交给 planner
- `/clear`
- `/status`
也就是说,现在这些都不再在外层直接处理:
- setup flow
- trade confirmation
- direct trade regex
- 自然语言配置流程
- 自然语言策略创建
这些都统一进入 planner。
这是当前设计里一个很重要的原则:
- 外层分流越少,行为边界越清晰
- 自然语言理解尽量统一交给 planner + tool
## 当前的 5 层记忆
当前不是 3 层,也不是 4 层,而是 5 层:
1. `chatHistory`
2. `TaskState`
3. `ExecutionState`
4. `CurrentReferences`
5. `Persistent Preferences`
### 1. chatHistory
定义位置:
- `agent/history.go`
作用:
- 保存最近几轮用户 / assistant 原始消息
- 给模型保留最近原话上下文
- 为后续摘要成 `TaskState` 提供原始素材
特点:
- 只保留短期原话
- 内存态
- `/clear` 时清空
适合存:
- 最近几轮对话原文
- 用户的最新措辞
- 刚刚的自然语言上下文
不适合存:
- 长期真相
- 当前外部系统状态
- 当前流程精确执行位置
### 2. TaskState
定义位置:
- `agent/memory.go`
作用:
- 保存跨轮次仍然有意义的高层摘要
- 注入 planner / reasoning / final response
持久化 key
- `agent_task_state_<userID>`
字段:
- `CurrentGoal`
- `ActiveFlow`
- `OpenLoops`
- `ImportantFacts`
- `LastDecision`
- `UpdatedAt`
适合存:
- 当前高层目标
- 跨轮次仍然成立的未闭环事项
- 关键事实
- 最近一次重要决策及其原因
不适合存:
- step 级待办
- “下一步调用哪个 tool”
- 动态余额、持仓、配置存在性
- 任何可以通过 tool 重新读取的实时状态
### 3. ExecutionState
定义位置:
- `agent/execution_state.go`
作用:
- 保存当前 plan 的执行态
- 支持 `ask_user` 之后继续执行
- 保存 plan、当前步骤、执行日志、等待状态等
持久化 key
- `agent_execution_state_<userID>`
当前关键字段:
- `SessionID`
- `Goal`
- `Status`
- `PlanID`
- `Steps`
- `CurrentStepID`
- `DynamicSnapshots`
- `ExecutionLog`
- `SummaryNotes`
- `Waiting`
- `CurrentReferences`
- `FinalAnswer`
- `LastError`
### 4. CurrentReferences
定义位置:
- `agent/execution_state.go`
作用:
- 记录当前对话里“这个 / 那个 / 刚才那个”到底指的是谁
当前支持的引用对象:
- `strategy`
- `trader`
- `model`
- `exchange`
这是为了解决一种常见问题:
- 用户明明前一轮刚说过“激进策略”
- 下一轮说“改一下这个策略”
- 如果没有结构化引用,模型虽然有聊天历史,也容易重新追问
`CurrentReferences` 不是系统状态快照,而是:
- 当前对话焦点对象
- 当前代词绑定对象
### 5. Persistent Preferences
对应工具:
- `get_preferences`
- `manage_preferences`
作用:
- 保存用户长期偏好
适合存:
- 默认中文回复
- 偏好激进风格
- 更关注 BTC / ETH
- 不喜欢高频
- 每天固定时间简报
它和 `TaskState` 的区别是:
- `TaskState` 偏向当前任务摘要
- `Persistent Preferences` 偏向长期用户画像
## DynamicSnapshots 是什么
`DynamicSnapshots` 是当前真实系统状态的快照。
它不是历史,也不是长期记忆,而是 planner 在规划前或执行中插入的“当前事实”。
当前会进入快照的典型信息包括:
- 当前模型配置列表
- 当前交易所配置列表
- 当前策略列表
- 当前 trader 列表
- 当前余额
- 当前持仓
- 最近交易历史
作用:
- 防止 planner 盲信旧结论
- 避免“之前没配置,现在其实已经配好了却还说没有”
- 避免“之前余额是 A现在拿旧 observation 继续回答”
一句话:
- `DynamicSnapshots` = 当前世界里真实有什么
## CurrentReferences 和 DynamicSnapshots 的区别
这两个容易混淆,但职责完全不同。
`DynamicSnapshots`
- 当前系统状态快照
- 是候选集合 / 当前事实
- 例如当前有两个策略:`激进``新策略`
`CurrentReferences`
- 当前对话焦点对象
- 是“这个”到底指谁
- 例如用户现在说的“这个策略”就是 `激进`
可以这样理解:
- `DynamicSnapshots` 是地图
- `CurrentReferences` 是你手指现在指着地图上的哪个点
## Planner 的输入
planner 主逻辑在:
- `agent/planner_runtime.go`
生成计划时,当前会把这些东西一起送给模型:
- 当前用户请求
- tool schema
- `Persistent Preferences`
- `TaskState`
- `ExecutionState`
- `Resume context`
- `Structured waiting state`
- `Observation context`
其中 observation context 不是旧版单数组,而是分层后的:
- `dynamic_snapshots`
- `execution_log`
- `summary_notes`
## Plan 的结构
当前 planner 只允许这 4 类 step
- `tool`
- `reason`
- `ask_user`
- `respond`
这意味着现在的 Agent 不是一个“自由发挥的回复器”,而是:
- 先规划
- 再执行步骤
- 必要时重规划
## 步骤执行流程
`executePlan(...)` 的核心逻辑是:
1. 找下一个 pending step
2. 标记 step 为 running
3. 执行对应类型
4. 写回 `ExecutionState`
5. 必要时触发 replanning
不同 step 类型行为如下:
### tool
- 调内部 tool
- 把结果写入 `ExecutionLog`
- 根据结果更新 `CurrentReferences`
- 必要时触发 replanner
### reason
- 发起一次短 reasoning 调用
- 生成一段简短中间推理
- 写入 `ExecutionLog`
### ask_user
- 进入 `waiting_user`
- 保存 `WaitingState`
- 把问题直接回给用户
### respond
- 生成最终回答
- 标记当前执行完成
## WaitingState 是什么
`WaitingState` 用来解决:
- 用户回复 `是`
- 用户回复 `继续`
- 用户回复 `那个就行`
这类短回复如果没有结构化等待状态,很容易丢上下文。
当前字段包括:
- `Question`
- `Intent`
- `PendingFields`
- `ConfirmationTarget`
- `CreatedAt`
它的作用是:
- 告诉 planner 上一轮到底在等什么
- 让这轮短回复更容易被理解成“对上一问的回答”
## CurrentReferences 如何更新
当前是双路径更新:
### 1. 用户消息命中对象名时更新
如果用户说:
- `修改激进策略`
- `停止 lky`
- `用 DeepSeek`
系统会去当前用户的策略 / trader / model / exchange 列表里尝试匹配名称或 ID。
匹配成功后,更新 `CurrentReferences`
### 2. tool 成功返回对象时更新
比如:
- `manage_strategy(create/update/activate)`
- `manage_trader(create/update)`
- `manage_model_config(update)`
- `manage_exchange_config(update)`
只要 tool 返回了具体对象,系统就会把对应 ID / name 写回当前引用。
## Tool 设计
当前 tool 是“资源型 tool”设计不是“页面动作型 tool”。
### 当前主要工具
配置资源:
- `get_exchange_configs`
- `manage_exchange_config`
- `get_model_configs`
- `manage_model_config`
策略资源:
- `get_strategies`
- `manage_strategy`
trader 资源:
- `manage_trader`
交易 / 查询资源:
- `search_stock`
- `execute_trade`
- `get_positions`
- `get_balance`
- `get_market_price`
- `get_trade_history`
### 为什么这么设计
优点:
- tool schema 稳定
- 行为边界清晰
- planner 更容易学会
- 资源增删改查统一
当前 `manage_strategy` 支持:
- `list`
- `get_default_config`
- `create`
- `update`
- `delete`
- `activate`
- `duplicate`
当前 `manage_trader` 支持:
- `list`
- `create`
- `update`
- `delete`
- `start`
- `stop`
## 为什么“创建策略”不该默认依赖交易所和模型
当前设计里,策略模板应该是独立资源:
- `strategy`
而运行态对象是:
- `trader`
更合理的边界是:
- 创建策略模板:用 `manage_strategy`
- 把策略跑起来:用 `manage_trader`
也就是说:
- 策略不默认依赖交易所和模型
- 只有当用户要求“运行 / 部署 / 创建 trader”时才需要进一步关联 exchange / model / trader
## 当前一个完整例子
用户输入:
`帮我创建一个新的激进策略模板,名字就叫激进。创建完后,再把这个策略绑定到 trader lky。`
当前大致流程:
1. 前端请求 `/api/agent/chat/stream`
2. 后端注入 `store_user_id`
3. Agent 进入 planner
4. planner 刷新动态快照:
- 当前策略
- 当前 trader
5. 生成 plan例如
- `get_strategies`
- `manage_strategy(create)`
- `manage_trader(update)`
- `respond`
6. 执行 `manage_strategy(create)` 后:
- 写入 `ExecutionLog`
- 更新 `CurrentReferences.strategy`
7. 执行 `manage_trader(update)` 时:
- 直接使用刚创建策略的 ID
8. 输出最终回复
如果此后用户继续说:
`把这个策略的 prompt 改激进一点`
系统会优先从 `CurrentReferences.strategy` 理解“这个策略”。
## 为什么看起来“有历史”,模型还是会追问
因为“有聊天历史”不等于“有结构化对象绑定”。
如果没有 `CurrentReferences`
- 模型只能依赖原话文本推断“这个策略”是谁
- 一旦中间插入多条消息,或者有多个候选策略
- 就容易重新追问
所以当前设计里,`CurrentReferences` 是补齐这一块的关键。
## 当前已知限制
### 1. 外层虽然已经大幅收口,但仍然不是纯 graph runtime
现在比之前更统一,但整体仍然是:
- Agent 主入口
- Planner
- Tool 执行
而不是完整 node-graph 引擎。
### 2. ExecutionState 仍然是按 userID 单槽位
这意味着:
- 同一用户的多个并行任务仍然可能相互影响
更彻底的方向应该是:
- 按 thread / session 多实例存储
### 3. CurrentReferences 目前还是轻量实现
当前只覆盖:
- strategy
- trader
- model
- exchange
后面如果要更强,需要考虑:
- 多候选冲突消解
- 昵称映射
- 跨更长会话的稳定实体绑定
## 当前设计的核心思想
一句话总结:
- `chatHistory` 记原话
- `Persistent Preferences` 记长期偏好
- `TaskState` 记高层摘要
- `ExecutionState` 记当前流程
- `DynamicSnapshots` 记当前事实
- `CurrentReferences` 记当前指代对象
- `planner` 决定步骤
- `tools` 执行落地动作
这就是当前 NOFXi Agent 的实际运行设计。

View File

@@ -1,454 +0,0 @@
# NOFXi Agent Memory And Planning Design
## Purpose
This document explains how the current NOFXi agent handles:
- short-term conversation memory
- durable task memory
- durable execution / planning state
- planner execution and replanning
- state reset and resume behavior
The implementation described here is primarily in:
- `agent/history.go`
- `agent/memory.go`
- `agent/execution_state.go`
- `agent/planner_runtime.go`
- `agent/agent.go`
## High-Level Model
The current agent uses three different layers of state:
1. `chatHistory`
Recent in-memory user/assistant turns for the live conversation.
2. `TaskState`
Durable summarized context that should survive beyond recent turns.
3. `ExecutionState`
Durable workflow state for the currently running or recently blocked plan.
These three layers serve different purposes and should not be treated as the same thing.
## State Layers
### 1. `chatHistory`
Defined in `agent/history.go`.
Role:
- stores recent `user` / `assistant` messages in memory
- keyed by `userID`
- used as short-term conversational context
- acts as the source material for later compression into `TaskState`
Characteristics:
- in-memory only
- capped by `maxTurns`
- cleared by `/clear`
- not suitable as durable truth
Typical contents:
- the last few user questions
- the last few assistant replies
- temporary conversational wording
### 2. `TaskState`
Defined in `agent/memory.go`.
Role:
- stores durable, structured, non-derivable context
- persisted through `system_config`
- injected into planning and reasoning prompts
Storage key:
- `agent_task_state_<userID>`
Fields:
- `CurrentGoal`
- `ActiveFlow`
- `OpenLoops`
- `ImportantFacts`
- `LastDecision`
- `UpdatedAt`
Intended contents:
- user goal that still matters across turns
- high-level unresolved issues that still matter across turns
- facts that tools cannot cheaply re-fetch
- latest important decision summary
Explicitly not intended for:
- step-level pending items such as "wait for API key"
- execution actions such as "call get_exchange_configs"
- live balances
- current positions
- current market prices
- mutable configuration availability
Those should be checked from tools at planning time instead of being trusted from old summaries.
### 3. `ExecutionState`
Defined in `agent/execution_state.go`.
Role:
- stores the current execution workflow
- allows the agent to resume after `ask_user`
- persists plan steps, observations, and completion status
Storage key:
- `agent_execution_state_<userID>`
Fields:
- `SessionID`
- `UserID`
- `Goal`
- `Status`
- `PlanID`
- `Steps`
- `CurrentStepID`
- `Observations`
- `FinalAnswer`
- `LastError`
- `UpdatedAt`
This is the planner's working state, not a general memory store.
## Data Flow
### Request Entry
Entry points:
- `HandleMessage(...)`
- `HandleMessageStream(...)`
Flow:
1. user message enters `agent`
2. slash commands and explicit direct branches are handled first
3. all other requests go into planner flow via `thinkAndAct(...)` / `thinkAndActStream(...)`
### Planner Flow
The planner pipeline in `agent/planner_runtime.go` is:
1. append user message into `chatHistory`
2. emit `planning` SSE event
3. load `ExecutionState`
4. optionally reset stale `ExecutionState`
5. optionally refresh dynamic configuration snapshots
6. create a fresh execution plan with the LLM
7. execute steps one by one
8. persist `ExecutionState` after important transitions
9. append assistant answer into `chatHistory`
10. maybe compress old conversation into `TaskState`
## Short-Term vs Durable Memory
### What lives in `chatHistory`
Good fits:
- raw recent messages
- conversational wording
- latest assistant phrasing
Bad fits:
- long-lived truths
- current external system state
### What lives in `TaskState`
Good fits:
- durable goal
- high-level unfinished work that remains relevant across turns
- important facts the user stated
- previous decisions and why they were made
Bad fits:
- pending steps inside the current plan
- execution-level reminders such as "wait for a field" or "call a tool"
- old conclusions about whether tools exist
- old conclusions about whether model/exchange config is present
- live operational state that can change outside the chat
### What lives in `ExecutionState`
Good fits:
- current plan steps
- observations from tool calls
- blocked-on-user-input status
- exact current workflow state
- step-level pending work and block reasons
Bad fits:
- evergreen user profile
- long-term semantic memory
## Planning Logic
### Plan Creation
`createExecutionPlan(...)` sends the following into the planner model:
- available tool definitions
- persistent preferences
- `TaskState` context
- `ExecutionState` JSON
- current user request
The planner must return JSON only with step types:
- `tool`
- `reason`
- `ask_user`
- `respond`
### Step Execution
`executePlan(...)` executes the plan loop:
- `tool`
call tool and append observation
- `reason`
run reasoning sub-call and append observation
- `ask_user`
save `waiting_user` state and return question
- `respond`
generate final answer and mark completed
After each completed step, `replanAfterStep(...)` may:
- continue
- replace remaining steps
- ask user
- finish
## Resume Behavior
When `ExecutionState.Status == waiting_user`, the next user turn is treated as a reply to the pending question.
Current safeguards:
- latest asked question is extracted from the stored plan
- the user reply is appended as a `user_reply` observation
- planner prompt receives explicit `Resume context`
This prevents short replies like `是` from being misread as unrelated fresh intents as often as before.
## Dynamic State Refresh
Configuration and trader management requests are dynamic by nature. Their truth can change outside the current chat, for example:
- user configures exchange in the UI
- user adds model in another tab
- user creates trader elsewhere
Because of that, configuration/trader requests should not trust stale model conclusions.
Current protection in `planner_runtime.go`:
- detects config / trader intent with `isConfigOrTraderIntent(...)`
- clears `TaskState` context from the planner prompt for these requests
- refreshes `ExecutionState.Observations` with fresh snapshots from:
- `toolGetModelConfigs(...)`
- `toolGetExchangeConfigs(...)`
- `toolListTraders(...)`
This makes the planner rely more on current system state and less on older narrative memory.
## Reset Strategy
The system currently resets or weakens stale execution state when:
- user says retry-like phrases such as `再试`, `继续`, `try again`, `continue`
- request is config / trader related and old execution state is failed / completed / waiting
Reset scope:
- `ExecutionState` may be cleared
- `TaskState` is not globally deleted, but it is intentionally ignored for config/trader planning
Manual reset:
- `/clear`
This clears:
- short-term chat history
- task state
- execution state
## Compression Design
`maybeCompressHistory(...)` moves older short-term chat content into `TaskState` when:
- recent message count exceeds the configured window
- estimated token count exceeds the threshold
Compression strategy:
1. keep recent conversation in `chatHistory`
2. summarize older turns into structured `TaskState`
3. persist new `TaskState`
4. replace `chatHistory` with recent slice
Important design rule:
- `TaskState` should keep durable context only
- it should not become a stale copy of mutable operational state
## Current Architecture Diagram
```mermaid
flowchart TD
U[User Message] --> A[HandleMessage / HandleMessageStream]
A --> B{Direct command?}
B -->|Yes| C[Direct branch or slash command]
B -->|No| D[thinkAndAct / thinkAndActStream]
D --> E[Append user turn to chatHistory]
D --> F[Load ExecutionState]
F --> G{waiting_user?}
G -->|Yes| H[Attach user_reply observation]
G -->|No| I[Create fresh ExecutionState]
H --> J[Refresh dynamic snapshots if config/trader intent]
I --> J
J --> K[createExecutionPlan via LLM]
K --> L[Execution plan]
L --> M[executePlan loop]
M --> N[tool step]
M --> O[reason step]
M --> P[ask_user step]
M --> Q[respond step]
N --> R[Append Observation]
O --> R
R --> S[replanAfterStep]
S --> M
P --> T[Persist waiting_user ExecutionState]
T --> UQ[Return question to user]
Q --> V[Persist completed ExecutionState]
V --> W[Append assistant turn to chatHistory]
W --> X[maybeCompressHistory]
X --> Y[Persist TaskState]
Y --> Z[Final response]
```
## Memory Relationship Diagram
```mermaid
flowchart LR
CH[chatHistory\nin-memory\nrecent turns]
TS[TaskState\npersisted summary\nsystem_config]
ES[ExecutionState\npersisted workflow\nsystem_config]
PL[Planner Prompt]
CH -->|recent raw turns| PL
ES -->|current workflow JSON| PL
TS -->|durable structured context| PL
CH -->|old turns compressed| TS
PL -->|plan / observations / status| ES
```
## State Transition Diagram
```mermaid
stateDiagram-v2
[*] --> planning
planning --> running: plan created
running --> waiting_user: ask_user step
waiting_user --> planning: user replies
running --> completed: respond step finished
running --> failed: step error
failed --> planning: retry / continue / config-trader reset
completed --> planning: new relevant request or retry flow
```
## Known Design Tradeoffs
### Strengths
- separates short-term chat from durable task summary
- allows blocked flows to resume
- supports replanning after every meaningful step
- can recover from stale assumptions better for dynamic config/trader requests
### Weaknesses
- `TaskState` is still summary-driven, so summarization quality matters
- planner still depends on model compliance for some transitions
- `ExecutionState` is single-track per user, not multiple concurrent workflows
- config/trader intent detection is heuristic and keyword-based
## Practical Guidance
### When to trust `TaskState`
Trust it for:
- user intent continuity
- open loops
- durable facts
Do not trust it for:
- whether current exchange/model/trader config exists now
- whether a specific operational action is currently possible
### When to trust `ExecutionState`
Trust it for:
- current plan continuity
- exact blocked step
- latest observation chain
Do not trust it blindly when:
- user has changed configuration outside the chat
- the system capabilities changed after deployment
### When to fetch live state again
Always prefer fresh tool snapshots before answering about:
- existing model configs
- existing exchange configs
- existing traders
- whether trader creation can proceed
## Suggested Future Improvements
- add workflow versioning so capability changes invalidate stale `ExecutionState`
- separate `waiting_user_confirmation` from generic `waiting_user`
- introduce code-level handling for short confirmations such as `是`, `好`, `继续`
- move dynamic state refresh from heuristic to explicit planner preflight stage
- support multiple concurrent execution sessions per user if needed

View File

@@ -1,453 +0,0 @@
# NOFXi Agent 记忆与规划设计
## 目的
本文说明当前 NOFXi agent 是如何处理以下能力的:
- 短期对话记忆
- 持久化任务记忆
- 持久化执行态 / 规划态
- planner 的执行与重规划
- 状态重置与恢复
本文主要对应以下实现文件:
- `agent/history.go`
- `agent/memory.go`
- `agent/execution_state.go`
- `agent/planner_runtime.go`
- `agent/agent.go`
## 总体模型
当前 agent 使用三层不同的状态:
1. `chatHistory`
用于保存当前会话最近几轮的原始用户/助手对话,驻留内存。
2. `TaskState`
用于保存跨轮次仍然有价值的结构化摘要,持久化存储。
3. `ExecutionState`
用于保存当前规划流程的执行态,支持流程中断后的继续执行。
这三层职责不同,不能混为一谈。
## 三层状态
### 1. `chatHistory`
定义位置:`agent/history.go`
作用:
-`userID` 保存最近的 `user` / `assistant` 消息
- 作为短期对话上下文
- 作为后续压缩进 `TaskState` 的原始素材
特性:
- 仅在内存中存在
-`maxTurns` 上限
- `/clear` 时会清空
- 不适合作为长期真相来源
典型内容:
- 最近几轮用户问题
- 最近几轮助手回答
- 临时措辞与上下文表达
### 2. `TaskState`
定义位置:`agent/memory.go`
作用:
- 保存持久化、结构化、不可轻易从工具重新推导出的上下文
- 通过 `system_config` 持久化
- 注入到 planner / reasoning prompt 中
存储 key
- `agent_task_state_<userID>`
字段:
- `CurrentGoal`
- `ActiveFlow`
- `OpenLoops`
- `ImportantFacts`
- `LastDecision`
- `UpdatedAt`
适合存放:
- 当前仍有效的用户目标
- 跨轮次仍然成立的高层未闭环问题
- 无法简单通过工具重新读取的重要事实
- 最近一次关键决策及原因
不适合存放:
- “等用户提供 API Key” 这类 step 级待办
- “调用 get_exchange_configs” 这类执行动作
- 实时余额
- 当前持仓
- 当前行情价格
- 是否存在某个配置这类会变化的状态
这些动态信息应该在规划阶段通过工具重新检查,而不是相信旧摘要。
### 3. `ExecutionState`
定义位置:`agent/execution_state.go`
作用:
- 保存当前执行中的工作流状态
- 支持 `ask_user` 之后恢复执行
- 持久化保存计划步骤、观察结果和最终状态
存储 key
- `agent_execution_state_<userID>`
字段:
- `SessionID`
- `UserID`
- `Goal`
- `Status`
- `PlanID`
- `Steps`
- `CurrentStepID`
- `Observations`
- `FinalAnswer`
- `LastError`
- `UpdatedAt`
它是 planner 的“工作态”,不是通用记忆仓库。
## 数据流
### 请求入口
入口函数:
- `HandleMessage(...)`
- `HandleMessageStream(...)`
流程:
1. 用户消息进入 `agent`
2. 优先处理 slash command 和显式直达分支
3. 其余请求进入 planner 流程:`thinkAndAct(...)` / `thinkAndActStream(...)`
### Planner 主流程
`agent/planner_runtime.go` 中的 planner 管线如下:
1. 把用户消息加入 `chatHistory`
2. 发出 `planning` SSE 事件
3. 加载 `ExecutionState`
4. 视情况重置过期的 `ExecutionState`
5. 视情况刷新动态配置快照
6. 调用 LLM 生成新的执行计划
7. 按步骤执行计划
8. 在关键状态变化后持久化 `ExecutionState`
9. 把助手回答加入 `chatHistory`
10. 视情况把旧对话压缩进 `TaskState`
## 短期记忆 vs 持久记忆
### `chatHistory` 里应该放什么
适合:
- 最近原始消息
- 对话措辞
- 最近一轮助手的表达方式
不适合:
- 长期真相
- 外部系统当前状态
### `TaskState` 里应该放什么
适合:
- 持续目标
- 跨轮次仍有意义的高层未闭环事项
- 用户明确讲过的重要事实
- 历史关键决策和原因
不适合:
- 当前 plan 中尚未执行的步骤
- “等待某个字段”“调用某个 tool” 这类执行级待办
- “系统有没有这个工具” 这种过时结论
- “当前有没有模型/交易所配置” 这种可变化状态
- 可以通过工具重新查询到的动态状态
### `ExecutionState` 里应该放什么
适合:
- 当前计划步骤
- 工具调用观察结果
- 当前是否卡在等用户补充信息
- 当前工作流的精确执行位置
- step 级待办和阻塞原因
不适合:
- 长期用户画像
- 通用长期语义记忆
## 规划逻辑
### 计划生成
`createExecutionPlan(...)` 会把以下信息送给 planner 模型:
- 当前可用 tool 定义
- 持久化用户偏好
- `TaskState` 上下文
- `ExecutionState` JSON
- 当前用户请求
planner 必须返回 JSON且步骤类型只能是
- `tool`
- `reason`
- `ask_user`
- `respond`
### 步骤执行
`executePlan(...)` 的执行循环如下:
- `tool`
调用工具并写入 observation
- `reason`
发起 reasoning 子调用并写入 observation
- `ask_user`
保存 `waiting_user` 状态并把问题返回给用户
- `respond`
生成最终回答并标记完成
每个步骤结束后,`replanAfterStep(...)` 还可以决定:
- continue
- replace_remaining
- ask_user
- finish
## 恢复执行
`ExecutionState.Status == waiting_user` 时,下一条用户消息会被视为对上一轮追问的回复。
当前保护机制:
- 从已有 plan 中提取最近一次追问内容
- 将用户回复作为 `user_reply` observation 追加
- 在 planner prompt 中注入显式的 `Resume context`
这样可以减少用户只回复 `是` 这类短消息时,被错误理解成全新意图的情况。
## 动态状态刷新
配置类与 trader 管理类请求本质上是动态请求,它们的真相可能在聊天之外发生变化,例如:
- 用户在 Web UI 中配置了交易所
- 用户在另一个页面新增了模型
- 用户在别处创建了 trader
因此,这类请求不能依赖旧的模型结论。
当前在 `planner_runtime.go` 中的保护措施:
- 通过 `isConfigOrTraderIntent(...)` 检测配置 / trader 意图
- 这类请求在 planner prompt 中不再注入旧 `TaskState`
- 同时刷新 `ExecutionState.Observations` 中的实时快照:
- `toolGetModelConfigs(...)`
- `toolGetExchangeConfigs(...)`
- `toolListTraders(...)`
这样 planner 会更多依赖当前系统状态,而不是依赖旧记忆中的描述。
## 重置策略
当前系统在以下场景会重置或弱化旧执行态:
- 用户说了类似 `再试``继续``try again``continue`
- 当前请求是配置 / trader 相关,并且旧 `ExecutionState` 已经失败 / 完成 / 正在等待用户
重置范围:
- `ExecutionState` 可能会被清空
- `TaskState` 不会整体删除,但在配置 / trader 请求中会被主动忽略
手动清理:
- `/clear`
这条命令会清掉:
- 短期 chat history
- task state
- execution state
## 压缩设计
`maybeCompressHistory(...)` 会在以下条件满足时把旧的短期对话压缩进 `TaskState`
- 最近消息数超过窗口
- 估算 token 数超过阈值
压缩流程:
1. 保留最近若干轮对话在 `chatHistory`
2. 把更早的内容总结成结构化 `TaskState`
3. 持久化新的 `TaskState`
4. 用最近消息切片替换 `chatHistory`
重要设计原则:
- `TaskState` 只保留长期有效上下文
- 不能把它变成动态运营状态的陈旧副本
## 当前架构图
```mermaid
flowchart TD
U[用户消息] --> A[HandleMessage / HandleMessageStream]
A --> B{是否命中直达分支?}
B -->|是| C[直接处理 slash command 或快捷分支]
B -->|否| D[thinkAndAct / thinkAndActStream]
D --> E[写入 chatHistory]
D --> F[加载 ExecutionState]
F --> G{是否 waiting_user?}
G -->|是| H[追加 user_reply observation]
G -->|否| I[创建新的 ExecutionState]
H --> J[若为配置或 trader 请求则刷新动态快照]
I --> J
J --> K[createExecutionPlan 调用 LLM]
K --> L[得到 execution plan]
L --> M[executePlan 循环执行]
M --> N[tool step]
M --> O[reason step]
M --> P[ask_user step]
M --> Q[respond step]
N --> R[写入 Observation]
O --> R
R --> S[replanAfterStep]
S --> M
P --> T[持久化 waiting_user ExecutionState]
T --> UQ[向用户返回追问]
Q --> V[持久化 completed ExecutionState]
V --> W[把 assistant 回复写入 chatHistory]
W --> X[maybeCompressHistory]
X --> Y[持久化 TaskState]
Y --> Z[返回最终回答]
```
## 记忆关系图
```mermaid
flowchart LR
CH[chatHistory\n内存态\n最近对话]
TS[TaskState\n持久化摘要\nsystem_config]
ES[ExecutionState\n持久化执行态\nsystem_config]
PL[Planner Prompt]
CH -->|最近原始对话| PL
ES -->|当前工作流 JSON| PL
TS -->|长期结构化上下文| PL
CH -->|旧消息压缩| TS
PL -->|计划 / 观察 / 状态| ES
```
## 状态转换图
```mermaid
stateDiagram-v2
[*] --> planning
planning --> running: plan created
running --> waiting_user: ask_user step
waiting_user --> planning: user replies
running --> completed: respond step finished
running --> failed: step error
failed --> planning: retry / continue / config-trader reset
completed --> planning: new relevant request or retry flow
```
## 当前设计的取舍
### 优点
- 将短期对话与长期摘要分离
- 支持在 `ask_user` 之后恢复执行
- 每个关键步骤后都支持重规划
- 对配置 / 创建 trader 这类动态请求,已经能更好抵抗旧结论污染
### 缺点
- `TaskState` 的质量仍然依赖总结效果
- 某些恢复逻辑仍依赖模型是否听话
- 每个用户当前只有一条 `ExecutionState`,不支持多个并发工作流
- 配置 / trader 意图识别目前仍是关键词启发式
## 实践建议
### 什么时候该相信 `TaskState`
应该相信它用于:
- 延续用户目标
- 跟踪未完成事项
- 保留长期有效事实
不应该相信它用于:
- 当前是否存在模型 / 交易所 / trader 配置
- 当前是否能够执行某个操作
### 什么时候该相信 `ExecutionState`
应该相信它用于:
- 当前工作流是否仍然连续
- 当前阻塞在哪一步
- 最近的 observation 链条
不应该盲信它用于:
- 用户在聊天外已经修改过配置的场景
- 系统能力或工具集发生变化后的旧结论
### 什么时候必须重新获取实时状态
以下场景应该优先重新通过工具获取:
- 当前模型配置
- 当前交易所配置
- 当前 trader 列表
- 当前是否满足 trader 创建条件
## 后续建议
-`ExecutionState` 增加版本号或能力签名,能力变化时自动失效
-`waiting_user_confirmation` 与通用 `waiting_user` 分开
-`是``好``继续` 这类短确认增加代码级识别
- 将动态快照刷新从启发式升级为显式 planner 预检查阶段
- 如果后续需要,支持一个用户多条并发执行会话

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

@@ -12,11 +12,8 @@ NOFX 文档提供多种语言版本。
|----------|-------------|--------|-------------|
| 🇬🇧 **English** | [README.md](../../README.md) | ✅ Complete | Core Team |
| 🇨🇳 **Chinese (中文)** | [README.md](zh-CN/README.md) | ✅ Complete | Community |
| 🇯🇵 **Japanese (日本語)** | [README.md](ja/README.md) | ✅ Complete | Community |
| 🇰🇷 **Korean (한국어)** | [README.md](ko/README.md) | ✅ Complete | Community |
| 🇷🇺 **Russian (Русский)** | [README.md](ru/README.md) | ✅ Complete | Community |
| 🇺🇦 **Ukrainian (Українська)** | [README.md](uk/README.md) | ✅ Complete | Community |
| 🇻🇳 **Vietnamese (Tiếng Việt)** | [README.md](vi/README.md) | ✅ Complete | Community |
---
@@ -33,16 +30,6 @@ NOFX 文档提供多种语言版本。
- **安全政策:** [../../SECURITY.md](../../SECURITY.md#中文)
- **常见问题:** [../guides/faq.zh-CN.md](../guides/faq.zh-CN.md)
### 日本語 🇯🇵
- **メイン README:** [ja/README.md](ja/README.md)
- **Contributing:** [../../CONTRIBUTING.md](../../CONTRIBUTING.md)
- **Security:** [../../SECURITY.md](../../SECURITY.md)
### 한국어 🇰🇷
- **메인 README:** [ko/README.md](ko/README.md)
- **Contributing:** [../../CONTRIBUTING.md](../../CONTRIBUTING.md)
- **Security:** [../../SECURITY.md](../../SECURITY.md)
### Русский 🇷🇺
- **Основной README:** [ru/README.md](ru/README.md)
- **Руководство по участию:** [../../CONTRIBUTING.md](../../CONTRIBUTING.md)
@@ -53,11 +40,6 @@ NOFX 文档提供多种语言版本。
- **Посібник із внесків:** [../../CONTRIBUTING.md](../../CONTRIBUTING.md)
- **Політика безпеки:** [../../SECURITY.md](../../SECURITY.md)
### Tiếng Việt 🇻🇳
- **README chính:** [vi/README.md](vi/README.md)
- **Contributing:** [../../CONTRIBUTING.md](../../CONTRIBUTING.md)
- **Security:** [../../SECURITY.md](../../SECURITY.md)
---
## 🤝 Help with Translations / 帮助翻译
@@ -67,7 +49,7 @@ NOFX 文档提供多种语言版本。
We welcome translation contributions! / 我们欢迎翻译贡献!
**What needs translation? / 需要翻译什么?**
- ✅ Main README (complete for 7 languages)
- ✅ Main README (complete for 4 languages)
- 🚧 Deployment guides (partial)
- 📋 User guides (needed)
- 📋 Contributing guide (needed for RU/UK)
@@ -112,11 +94,10 @@ faq.zh-CN.md → Chinese FAQ
**Language Codes:**
- `en` - English (default, no suffix needed)
- `zh-CN` - Simplified Chinese
- `ja` - Japanese
- `ko` - Korean
- `ru` - Russian
- `uk` - Ukrainian
- `vi` - Vietnamese
- `ja` - Japanese *(future)*
- `ko` - Korean *(future)*
### Quality Standards / 质量标准

View File

@@ -1,329 +1,308 @@
<p align="center"><strong><a href="https://vergex.trade">vergex.trade</a> によるバックアップ</strong></p>
# NOFX - AI トレーディングシステム
<h1 align="center">NOFX</h1>
[![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">
<strong>グローバル市場向け AI トレーディングターミナル。</strong><br/>
<strong>米国株、コモディティ、FX、暗号資産のリサーチ、戦略生成、執行、モニタリング。</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>
</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 駆動の暗号通貨取引プラットフォーム
対象は米国株、コモディティ契約、FX ペア、デジタル資産などの高流動性グローバル市場です。AI レイヤーは取引意図をウォッチリスト、シグナル、戦略ロジック、リスク制御、執行ワークフローへ変換します。
**NOFX** は、複数の AI モデルを使用して暗号通貨先物を自動取引できるオープンソースの AI 取引システムです。Web インターフェースで戦略を設定し、リアルタイムでパフォーマンスを監視し、AI エージェントを競わせて最適な取引アプローチを見つけます。
### コア機能
- **マルチ AI サポート**: DeepSeek、Qwen、GPT、Claude、Gemini、Grok、Kimi を実行 - いつでもモデルを切り替え可能
- **マルチ取引所**: Binance、Bybit、OKX、Hyperliquid、Aster DEX、Lighter で統一取引
- **ストラテジースタジオ**: コインソース、インジケーター、リスク管理を設定するビジュアル戦略ビルダー
- **AI 競争モード**: 複数の AI トレーダーがリアルタイムで競争、パフォーマンスを並べて追跡
- **Web ベース設定**: JSON 編集不要 - Web インターフェースですべて設定
- **リアルタイムダッシュボード**: ライブポジション、損益追跡、思考連鎖付き AI 決定ログ
### 公式リンク
- **公式サイト**: [https://nofxai.com](https://nofxai.com)
- **データダッシュボード**: [https://nofxos.ai/dashboard](https://nofxos.ai/dashboard)
- **API ドキュメント**: [https://nofxos.ai/api-docs](https://nofxos.ai/api-docs)
> **リスク警告**: このシステムは実験的です。AI 自動取引には重大なリスクがあります。学習/研究目的または少額でのテストのみを強くお勧めします!
## 開発者コミュニティ
Telegram 開発者コミュニティに参加: **[NOFX 開発者コミュニティ](https://t.me/nofx_dev_community)**
---
## 始める前に
NOFXを使用するには以下が必要です:
1. **取引所アカウント** - サポートされている取引所に登録し、取引権限付きのAPI認証情報を作成
2. **AI モデル API キー** - サポートされているプロバイダーから取得コスト効率の良いDeepSeekを推奨
---
## サポート取引所
### CEX (中央集権型取引所)
| 取引所 | ステータス | 登録 (手数料割引) |
|----------|--------|-------------------------|
| **Binance** | ✅ サポート | [登録](https://www.binance.com/join?ref=NOFXENG) |
| **Bybit** | ✅ サポート | [登録](https://partner.bybit.com/b/83856) |
| **OKX** | ✅ サポート | [登録](https://www.okx.com/join/1865360) |
| **Bitget** | ✅ サポート | [登録](https://www.bitget.com/referral/register?from=referral&clacCode=c8a43172) |
### Perp-DEX (分散型永久先物取引所)
| 取引所 | ステータス | 登録 (手数料割引) |
|----------|--------|-------------------------|
| **Hyperliquid** | ✅ サポート | [登録](https://app.hyperliquid.xyz/join/AITRADING) |
| **Aster DEX** | ✅ サポート | [登録](https://www.asterdex.com/en/referral/fdfc0e) |
| **Lighter** | ✅ サポート | [登録](https://app.lighter.xyz/?referral=68151432) |
---
## サポート AI モデル
| AI モデル | ステータス | API キー取得 |
|----------|--------|-------------|
| **DeepSeek** | ✅ サポート | [API キー取得](https://platform.deepseek.com) |
| **Qwen** | ✅ サポート | [API キー取得](https://dashscope.console.aliyun.com) |
| **OpenAI (GPT)** | ✅ サポート | [API キー取得](https://platform.openai.com) |
| **Claude** | ✅ サポート | [API キー取得](https://console.anthropic.com) |
| **Gemini** | ✅ サポート | [API キー取得](https://aistudio.google.com) |
| **Grok** | ✅ サポート | [API キー取得](https://console.x.ai) |
| **Kimi** | ✅ サポート | [API キー取得](https://platform.moonshot.cn) |
---
## クイックスタート
### オプション 1: Docker デプロイ(推奨)
```bash
git clone https://github.com/NoFxAiOS/nofx.git
cd nofx
chmod +x ./start.sh
./start.sh start --build
```
Web インターフェースにアクセス: **http://localhost:3000**
### 最新版への更新
> **💡 更新は頻繁です。** 最新の機能と修正を取得するために、毎日このコマンドを実行してください:
```bash
curl -fsSL https://raw.githubusercontent.com/NoFxAiOS/nofx/main/install.sh | bash
```
**http://127.0.0.1:3000** を開きます。
このコマンドは最新の公式イメージを取得し、サービスを自動的に再起動します。
---
## 取引所登録
以下のリンクから、暗号資産および対応する米国株、FX、コモディティデリバティブ市場向けの取引口座を開設できます。これらは NOFX のパートナープログラム経由で、手数料割引または紹介特典が適用される場合があります。
| 取引所 | 状態 | 手数料割引付き登録 |
| :--- | :---: | :--- |
| <img src="../../../web/public/exchange-icons/binance.jpg" width="20" height="20" style="vertical-align: middle;"/> **Binance** | ✅ | [登録](https://www.binance.com/join?ref=NOFXENG) |
| <img src="../../../web/public/exchange-icons/bybit.png" width="20" height="20" style="vertical-align: middle;"/> **Bybit** | ✅ | [登録](https://partner.bybit.com/b/83856) |
| <img src="../../../web/public/exchange-icons/okx.svg" width="20" height="20" style="vertical-align: middle;"/> **OKX** | ✅ | [登録](https://www.okx.com/join/1865360) |
| <img src="../../../web/public/exchange-icons/hyperliquid.png" width="20" height="20" style="vertical-align: middle;"/> **Hyperliquid** | ✅ | [登録](https://app.hyperliquid.xyz/join/AITRADING) |
| <img src="../../../web/public/exchange-icons/bitget.svg" width="20" height="20" style="vertical-align: middle;"/> **Bitget** | ✅ | [登録](https://www.bitget.com/referral/register?from=referral&clacCode=c8a43172) |
| <img src="../../../web/public/exchange-icons/kucoin.svg" width="20" height="20" style="vertical-align: middle;"/> **KuCoin** | ✅ | [登録](https://www.kucoin.com/r/broker/CXEV7XKK) |
| <img src="../../../web/public/exchange-icons/gate.svg" width="20" height="20" style="vertical-align: middle;"/> **Gate** | ✅ | [登録](https://www.gatenode.xyz/share/VQBGUAxY) |
| <img src="../../../web/public/exchange-icons/aster.svg" width="20" height="20" style="vertical-align: middle;"/> **Aster** | ✅ | [登録](https://www.asterdex.com/en/referral/fdfc0e) |
| <img src="../../../web/public/exchange-icons/lighter.png" width="20" height="20" style="vertical-align: middle;"/> **Lighter** | ✅ | [登録](https://app.lighter.xyz/?referral=68151432) |
---
## クイックデモ
<p align="center">
<a href="https://drive.google.com/file/d/1frzw-HDZ3viQvLOQKsAJGc9bT0dXs68D/view">
<img src="../../../screenshots/demo-cover.png" alt="NOFX quick demo video" width="900"/>
</a>
</p>
<p align="center">
カバー画像をクリックしてデモ動画をご覧ください。
</p>
---
## 市場
**米国株 · コモディティ · FX · 暗号資産**
NOFX は単一取引所の画面ではなく、マルチアセットのリサーチ、戦略構築、執行、監視ワークフローを中心に設計されています。
---
## AI モデルアクセス
NOFX は AI 推論を [Claw402](https://claw402.ai) 経由で自動ルーティングします。ユーザーはモデルプロバイダーの設定、API キー管理、個別 AI アカウントの維持を行う必要がありません。ターミナルは Claw402 の従量課金インフラを使って対応モデルへオンデマンドにアクセスし、公式割引チャネルを通じてルーティングします。
| プロバイダー | アクセス |
| :--- | :--- |
| **Claw402** | [公式割引で従量課金 AI モデルにアクセス](https://claw402.ai) |
---
## 機能
| 機能 | 説明 |
| :--- | :--- |
| **AI トレーディングターミナル** | 米国株、コモディティ、FX、暗号資産向けの統合ワークスペース |
| **AI モデルアクセス** | Claw402 経由で対応プロバイダーへ自動接続 |
| **取引所接続** | Binance、Bybit、OKX、Hyperliquid、Bitget、KuCoin、Gate、Aster、Lighter |
| **Strategy Studio** | 市場ユニバース、インジケーター、リスク制御、戦略ロジック |
| **モデル競争** | AI トレーダーのライブ成績とランキングを比較 |
| **Telegram Agent** | チャットからトレーディングアシスタントを操作・監視 |
| **ポートフォリオダッシュボード** | ポジション、損益、執行履歴、モデル判断ログ |
---
## スクリーンショット
<details>
<summary><b>設定ページ</b></summary>
| 設定 | トレーダー一覧 |
| :----------------------------------------------------: | :----------------------------------------------------------: |
| <img src="../../../screenshots/config-ai-exchanges.png" width="400"/> | <img src="../../../screenshots/config-traders-list.png" width="400"/> |
</details>
<details>
<summary><b>ダッシュボード</b></summary>
| 概要 | マーケットチャート |
| :-----------------------------------------------------: | :-------------------------------------------------------------: |
| <img src="../../../screenshots/dashboard-page.png" width="400"/> | <img src="../../../screenshots/dashboard-market-chart.png" width="400"/> |
| 取引統計 | ポジション履歴 |
| :--------------------------------------------------------------: | :-----------------------------------------------------------------: |
| <img src="../../../screenshots/dashboard-trading-stats.png" width="400"/> | <img src="../../../screenshots/dashboard-position-history.png" width="400"/> |
| ポジション | トレーダー詳細 |
| :----------------------------------------------------------: | :---------------------------------------------------: |
| <img src="../../../screenshots/dashboard-positions.png" width="400"/> | <img src="../../../screenshots/details-page.png" width="400"/> |
</details>
<details>
<summary><b>Strategy Studio</b></summary>
| 戦略エディタ | インジケーター設定 |
| :------------------------------------------------------: | :----------------------------------------------------------: |
| <img src="../../../screenshots/strategy-studio.png" width="400"/> | <img src="../../../screenshots/strategy-indicators.png" width="400"/> |
</details>
<details>
<summary><b>コンペティション</b></summary>
| コンペティションモード |
| :-------------------------------------------------------: |
| <img src="../../../screenshots/competition-page.png" width="400"/> |
</details>
---
## インストール
### Linux / macOS
### オプション 2: 手動インストール
```bash
curl -fsSL https://raw.githubusercontent.com/NoFxAiOS/nofx/main/install.sh | bash
```
# 前提条件: Go 1.21+, Node.js 18+, TA-Lib
### Railwayクラウド
# TA-Lib インストール (macOS)
brew install ta-lib
[![Deploy on Railway](https://railway.com/button.svg)](https://railway.com/deploy/nofx?referralCode=nofx)
# クローンとセットアップ
git clone https://github.com/NoFxAiOS/nofx.git
cd nofx
go mod download
cd web && npm install && cd ..
### Docker
```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
```
### ソースからビルド
```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
cd web && npm install && npm run dev
# フロントエンド起動(新しいターミナル)
cd web && npm run dev
```
### アップデート
---
## 初期設定
1. **AI モデル設定** - AI API キーを追加
2. **取引所設定** - 取引所 API 認証情報を設定
3. **戦略作成** - ストラテジースタジオで取引戦略を設定
4. **トレーダー作成** - AI モデル + 取引所 + 戦略を組み合わせ
5. **取引開始** - 設定したトレーダーを起動
---
## リスク警告
1. 暗号通貨市場は非常に変動が激しい - AI の決定は利益を保証しない
2. 先物取引はレバレッジを使用 - 損失は元本を超える可能性がある
3. 極端な市場状況では清算リスクがある
---
## サーバー展開
### クイックデプロイ (HTTP経由のIP)
デフォルトでは、トランスポート暗号化は**無効**になっており、HTTPSなしでIPアドレス経由でNOFXにアクセスできます:
```bash
# サーバーにデプロイ
curl -fsSL https://raw.githubusercontent.com/NoFxAiOS/nofx/main/install.sh | bash
```
---
`http://YOUR_SERVER_IP:3000` 経由でアクセス - すぐに動作します。
## セットアップ
###キュリティ強化 (HTTPS)
**初心者モード**:ガイド付き onboarding により、モデルアクセス、取引所接続、戦略設定、初回デプロイまで進められます
**上級モード**
1. AI モデルアクセスを設定
2. 取引所の認証情報を接続
3. 戦略を構築またはインポート
4. AI トレーダープロファイルを作成
5. ダッシュボードから起動、監視、改善
すべての設定は Web UI **http://127.0.0.1:3000** から行えます。
---
## サーバーへのデプロイ
**HTTP デプロイ:**
セキュリティを強化するには、`.env`でトランスポート暗号化を有効にします:
```bash
curl -fsSL https://raw.githubusercontent.com/NoFxAiOS/nofx/main/install.sh | bash
# http://YOUR_IP:3000 でアクセス
TRANSPORT_ENCRYPTION=true
```
**Cloudflare 経由の HTTPS**
有効にすると、ブラウザはWeb Crypto APIを使用して転送前にAPIキーを暗号化します。これには以下が必要です:
- `https://` - SSLを備えた任意のドメイン
- `http://localhost` - ローカル開発
1. [Cloudflare](https://dash.cloudflare.com)(無料プラン)にドメインを追加
2. A レコードをサーバー IP に設定Proxied
3. SSL/TLS を Flexible に設定
4. `.env``TRANSPORT_ENCRYPTION=true` を設定
### 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インターフェース)
```
NOFX
┌─────────────────────────────────────────────────┐
│ Trading Terminal │
│ React + TypeScript + TradingView │
│ US Stocks · Commodities · Forex · Crypto │
├─────────────────────────────────────────────────┤
│ API Server (Go) │
├──────────────┬──────────────┬───────────────────┤
│ Strategy │ Telegram │ Trader Runtime │
│ Engine │ Agent │ Risk Controls │
├──────────────┴──────────────┴───────────────────┤
│ AI Model Layer │
│ Unified provider access through Claw402 │
│ Model routing · payment · execution support │
├─────────────────────────────────────────────────┤
│ Exchange Connectivity │
│ Binance · Bybit · OKX · Hyperliquid · Bitget │
│ KuCoin · Gate · Aster · Lighter │
└─────────────────────────────────────────────────┘
```
システムを起動した後、Webインターフェースを通じて設定します:
1. **AIモデルの設定** - AI APIキーを追加 (DeepSeek、OpenAI など)
2. **取引所の設定** - 取引所API認証情報を設定
3. **戦略の作成** - ストラテジースタジオで取引戦略を設定
4. **トレーダーの作成** - AIモデル + 取引所 + 戦略を組み合わせ
5. **取引開始** - 設定したトレーダーを起動
すべての設定はWebインターフェースで完了 - JSONファイルの編集は不要です。
---
## ドキュメント
## Webインターフェース機能
| | |
| :--- | :--- |
| [アーキテクチャ](../../architecture/README.md) | システム設計とモジュール索引 |
| [戦略モジュール](../../architecture/STRATEGY_MODULE.md) | 銘柄選択、AI プロンプト、執行 |
| [FAQ](../../faq/README.md) | よくある質問 |
| [はじめに](../../getting-started/README.md) | デプロイガイド |
### 競争ページ
- リアルタイム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)、[行動規範](../../../CODE_OF_CONDUCT.md)、[セキュリティポリシー](../../../SECURITY.md) を参照してください
### 貢献者プログラム
NOFX は有意義な貢献を記録し、エコシステムの成長に応じて貢献者へ還元する予定です。優先 Issue は高い報酬ウェイトを持ちます。
| Contribution | Weight |
| :--- | :---: |
| Pinned Issue PRs | ★★★★★★ |
| Code (Merged PRs) | ★★★★★ |
| Bug Fixes | ★★★★ |
| Feature Ideas | ★★★ |
| Bug Reports | ★★ |
| Documentation | ★★ |
貢献を歓迎します!以下を参照してください:
- **[貢献ガイド](CONTRIBUTING.md)** - 開発ワークフローとPRプロセス
- **[行動規範](CODE_OF_CONDUCT.md)** - コミュニティガイドライン
- **[セキュリティポリシー](SECURITY.md)** - 脆弱性の報告
---
## リンク
## 貢献者エアドロッププログラム
| | |
| :--- | :--- |
| Website | [vergex.trade](https://vergex.trade) |
| Dashboard | [vergex.trade/explore](https://vergex.trade/explore) |
| Telegram | [nofx_dev_community](https://t.me/nofx_dev_community) |
| Twitter | [@vergex_ai](https://x.com/vergex_ai) |
すべての貢献はGitHubで追跡されます。NOFXが収益を生み出すと、貢献者は貢献に基づいてエアドロップを受け取ります。
> **リスク警告**:自動売買には大きなリスクがあります。適切なポジションサイズを守り、各取引所の仕組みを理解し、失ってもよい資金だけを使用してください。
**[ピン留めされたIssue](https://github.com/NoFxAiOS/nofx/issues)を解決するPRは最高報酬を受け取ります**
| 貢献タイプ | 重み |
|------------------|:------:|
| **ピン留めIssue PR** | ⭐⭐⭐⭐⭐⭐ |
| **コードコミット** (マージされたPR) | ⭐⭐⭐⭐⭐ |
| **バグ修正** | ⭐⭐⭐⭐ |
| **機能提案** | ⭐⭐⭐ |
| **バグ報告** | ⭐⭐ |
| **ドキュメント** | ⭐⭐ |
---
## スポンサー
## リスク警告
<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>
1. 暗号通貨市場は非常に変動が激しい - AIの決定は利益を保証しない
2. 先物取引はレバレッジを使用 - 損失は元本を超える可能性がある
3. 極端な市場状況では清算リスクがある
[スポンサーになる](https://github.com/sponsors/NoFxAiOS)
## License
[AGPL-3.0](../../../LICENSE)
## コンタクト
- **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,329 +1,309 @@
<p align="center"><strong><a href="https://vergex.trade">vergex.trade</a>가 지원합니다</strong></p>
# NOFX - AI 트레이딩 시스템
<h1 align="center">NOFX</h1>
[![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">
<strong>글로벌 시장을 위한 AI 트레이딩 터미널.</strong><br/>
<strong>미국 주식, 원자재, 외환, 암호화폐 리서치, 전략 생성, 실행, 모니터링.</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>
</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 트레이딩 터미널입니다.
## AI 기반 암호화폐 거래 플랫폼
제품은 미국 주식, 원자재 계약, FX 페어, 디지털 자산 등 글로벌 유동성 시장을 중심으로 설계되었습니다. AI 레이어는 거래 의도를 워치리스트, 신호, 전략 로직, 리스크 제어, 실행 워크플로로 변환합니다.
**NOFX**는 여러 AI 모델을 실행하여 암호화폐 선물을 자동으로 거래할 수 있는 오픈소스 AI 거래 시스템입니다. 웹 인터페이스를 통해 전략을 구성하고, 실시간으로 성과를 모니터링하며, AI 에이전트들이 최적의 거래 방식을 찾도록 경쟁시킵니다.
### 핵심 기능
- **다중 AI 지원**: DeepSeek, Qwen, GPT, Claude, Gemini, Grok, Kimi 실행 - 언제든 모델 전환 가능
- **다중 거래소**: Binance, Bybit, OKX, Hyperliquid, Aster DEX, Lighter에서 통합 거래
- **전략 스튜디오**: 코인 소스, 지표, 리스크 제어를 설정하는 시각적 전략 빌더
- **AI 경쟁 모드**: 여러 AI 트레이더가 실시간으로 경쟁, 성과를 나란히 추적
- **웹 기반 설정**: JSON 편집 불필요 - 웹 인터페이스에서 모든 설정 완료
- **실시간 대시보드**: 실시간 포지션, 손익 추적, 사고의 연쇄가 포함된 AI 결정 로그
### 공식 링크
- **공식 웹사이트**: [https://nofxai.com](https://nofxai.com)
- **데이터 대시보드**: [https://nofxos.ai/dashboard](https://nofxos.ai/dashboard)
- **API 문서**: [https://nofxos.ai/api-docs](https://nofxos.ai/api-docs)
> **위험 경고**: 이 시스템은 실험적입니다. AI 자동 거래에는 상당한 위험이 있습니다. 학습/연구 목적 또는 소액 테스트만 강력히 권장합니다!
## 개발자 커뮤니티
Telegram 개발자 커뮤니티 참여: **[NOFX 개발자 커뮤니티](https://t.me/nofx_dev_community)**
---
## 시작하기 전에
NOFX를 사용하려면 다음이 필요합니다:
1. **거래소 계정** - 지원되는 거래소에 등록하고 거래 권한이 있는 API 자격 증명 생성
2. **AI 모델 API 키** - 지원되는 제공업체에서 획득 (비용 효율성을 위해 DeepSeek 권장)
---
## 지원 거래소
### CEX (중앙화 거래소)
| 거래소 | 상태 | 등록 (수수료 할인) |
|----------|--------|-------------------------|
| **Binance** | ✅ 지원 | [등록](https://www.binance.com/join?ref=NOFXENG) |
| **Bybit** | ✅ 지원 | [등록](https://partner.bybit.com/b/83856) |
| **OKX** | ✅ 지원 | [등록](https://www.okx.com/join/1865360) |
| **Bitget** | ✅ 지원 | [등록](https://www.bitget.com/referral/register?from=referral&clacCode=c8a43172) |
### Perp-DEX (탈중앙화 영구 선물 거래소)
| 거래소 | 상태 | 등록 (수수료 할인) |
|----------|--------|-------------------------|
| **Hyperliquid** | ✅ 지원 | [등록](https://app.hyperliquid.xyz/join/AITRADING) |
| **Aster DEX** | ✅ 지원 | [등록](https://www.asterdex.com/en/referral/fdfc0e) |
| **Lighter** | ✅ 지원 | [등록](https://app.lighter.xyz/?referral=68151432) |
---
## 지원 AI 모델
| AI 모델 | 상태 | API 키 받기 |
|----------|--------|-------------|
| **DeepSeek** | ✅ 지원 | [API 키 받기](https://platform.deepseek.com) |
| **Qwen** | ✅ 지원 | [API 키 받기](https://dashscope.console.aliyun.com) |
| **OpenAI (GPT)** | ✅ 지원 | [API 키 받기](https://platform.openai.com) |
| **Claude** | ✅ 지원 | [API 키 받기](https://console.anthropic.com) |
| **Gemini** | ✅ 지원 | [API 키 받기](https://aistudio.google.com) |
| **Grok** | ✅ 지원 | [API 키 받기](https://console.x.ai) |
| **Kimi** | ✅ 지원 | [API 키 받기](https://platform.moonshot.cn) |
---
## 빠른 시작
### 옵션 1: Docker 배포 (권장)
```bash
git clone https://github.com/NoFxAiOS/nofx.git
cd nofx
chmod +x ./start.sh
./start.sh start --build
```
웹 인터페이스 접속: **http://localhost:3000**
### 최신 버전 유지
> **💡 업데이트가 빈번합니다.** 최신 기능과 수정 사항을 받으려면 매일 이 명령을 실행하세요:
```bash
curl -fsSL https://raw.githubusercontent.com/NoFxAiOS/nofx/main/install.sh | bash
```
**http://127.0.0.1:3000** 을 엽니다.
이 명령은 최신 공식 이미지를 가져오고 서비스를 자동으로 다시 시작합니다.
---
## 거래소 등록
아래 링크를 통해 암호화폐와 지원되는 미국 주식, FX, 원자재 파생상품 시장용 거래 계정을 개설할 수 있습니다. 이 링크는 NOFX 파트너 프로그램을 통해 제공되며 수수료 할인 또는 추천 혜택이 포함될 수 있습니다.
| 거래소 | 상태 | 수수료 할인 등록 |
| :--- | :---: | :--- |
| <img src="../../../web/public/exchange-icons/binance.jpg" width="20" height="20" style="vertical-align: middle;"/> **Binance** | ✅ | [등록](https://www.binance.com/join?ref=NOFXENG) |
| <img src="../../../web/public/exchange-icons/bybit.png" width="20" height="20" style="vertical-align: middle;"/> **Bybit** | ✅ | [등록](https://partner.bybit.com/b/83856) |
| <img src="../../../web/public/exchange-icons/okx.svg" width="20" height="20" style="vertical-align: middle;"/> **OKX** | ✅ | [등록](https://www.okx.com/join/1865360) |
| <img src="../../../web/public/exchange-icons/hyperliquid.png" width="20" height="20" style="vertical-align: middle;"/> **Hyperliquid** | ✅ | [등록](https://app.hyperliquid.xyz/join/AITRADING) |
| <img src="../../../web/public/exchange-icons/bitget.svg" width="20" height="20" style="vertical-align: middle;"/> **Bitget** | ✅ | [등록](https://www.bitget.com/referral/register?from=referral&clacCode=c8a43172) |
| <img src="../../../web/public/exchange-icons/kucoin.svg" width="20" height="20" style="vertical-align: middle;"/> **KuCoin** | ✅ | [등록](https://www.kucoin.com/r/broker/CXEV7XKK) |
| <img src="../../../web/public/exchange-icons/gate.svg" width="20" height="20" style="vertical-align: middle;"/> **Gate** | ✅ | [등록](https://www.gatenode.xyz/share/VQBGUAxY) |
| <img src="../../../web/public/exchange-icons/aster.svg" width="20" height="20" style="vertical-align: middle;"/> **Aster** | ✅ | [등록](https://www.asterdex.com/en/referral/fdfc0e) |
| <img src="../../../web/public/exchange-icons/lighter.png" width="20" height="20" style="vertical-align: middle;"/> **Lighter** | ✅ | [등록](https://app.lighter.xyz/?referral=68151432) |
---
## 빠른 데모
<p align="center">
<a href="https://drive.google.com/file/d/1frzw-HDZ3viQvLOQKsAJGc9bT0dXs68D/view">
<img src="../../../screenshots/demo-cover.png" alt="NOFX quick demo video" width="900"/>
</a>
</p>
<p align="center">
커버 이미지를 클릭해 데모 영상을 보세요.
</p>
---
## 시장
**미국 주식 · 원자재 · 외환 · 암호화폐**
NOFX는 단일 거래소 화면이 아니라 멀티에셋 리서치, 전략 구축, 실행, 모니터링 워크플로를 중심으로 구성됩니다.
---
## AI 모델 액세스
NOFX는 AI 추론을 [Claw402](https://claw402.ai)를 통해 자동 라우팅합니다. 사용자는 모델 제공업체를 설정하거나 API 키를 관리하거나 별도 AI 계정을 유지할 필요가 없습니다. 터미널은 Claw402의 사용량 기반 인프라를 통해 지원 모델에 온디맨드로 접근하며 공식 할인 채널로 트래픽을 라우팅합니다.
| 제공업체 | 액세스 |
| :--- | :--- |
| **Claw402** | [공식 할인으로 사용량 기반 AI 모델 이용](https://claw402.ai) |
---
## 기능
| 기능 | 설명 |
| :--- | :--- |
| **AI 트레이딩 터미널** | 미국 주식, 원자재, 외환, 암호화폐 워크플로를 위한 통합 워크스페이스 |
| **AI 모델 액세스** | Claw402를 통해 지원 모델 제공업체에 자동 연결 |
| **거래소 연결** | Binance, Bybit, OKX, Hyperliquid, Bitget, KuCoin, Gate, Aster, Lighter |
| **Strategy Studio** | 시장 유니버스, 지표, 리스크 제어, 전략 로직 |
| **모델 경쟁** | AI 트레이더의 실시간 성과와 리더보드 비교 |
| **Telegram Agent** | 채팅으로 트레이딩 어시스턴트 제어 및 모니터링 |
| **포트폴리오 대시보드** | 포지션, 손익, 실행 기록, 모델 의사결정 로그 |
---
## 스크린샷
<details>
<summary><b>설정 페이지</b></summary>
| 설정 | 트레이더 목록 |
| :----------------------------------------------------: | :----------------------------------------------------------: |
| <img src="../../../screenshots/config-ai-exchanges.png" width="400"/> | <img src="../../../screenshots/config-traders-list.png" width="400"/> |
</details>
<details>
<summary><b>대시보드</b></summary>
| 개요 | 마켓 차트 |
| :-----------------------------------------------------: | :-------------------------------------------------------------: |
| <img src="../../../screenshots/dashboard-page.png" width="400"/> | <img src="../../../screenshots/dashboard-market-chart.png" width="400"/> |
| 거래 통계 | 포지션 기록 |
| :--------------------------------------------------------------: | :-----------------------------------------------------------------: |
| <img src="../../../screenshots/dashboard-trading-stats.png" width="400"/> | <img src="../../../screenshots/dashboard-position-history.png" width="400"/> |
| 포지션 | 트레이더 상세 |
| :----------------------------------------------------------: | :---------------------------------------------------: |
| <img src="../../../screenshots/dashboard-positions.png" width="400"/> | <img src="../../../screenshots/details-page.png" width="400"/> |
</details>
<details>
<summary><b>Strategy Studio</b></summary>
| 전략 에디터 | 지표 설정 |
| :------------------------------------------------------: | :----------------------------------------------------------: |
| <img src="../../../screenshots/strategy-studio.png" width="400"/> | <img src="../../../screenshots/strategy-indicators.png" width="400"/> |
</details>
<details>
<summary><b>경쟁</b></summary>
| 경쟁 모드 |
| :-------------------------------------------------------: |
| <img src="../../../screenshots/competition-page.png" width="400"/> |
</details>
---
## 설치
### Linux / macOS
### 옵션 2: 수동 설치
```bash
curl -fsSL https://raw.githubusercontent.com/NoFxAiOS/nofx/main/install.sh | bash
```
# 필수 조건: Go 1.21+, Node.js 18+, TA-Lib
### Railway(클라우드)
# TA-Lib 설치 (macOS)
brew install ta-lib
[![Deploy on Railway](https://railway.com/button.svg)](https://railway.com/deploy/nofx?referralCode=nofx)
# 클론 및 설정
git clone https://github.com/NoFxAiOS/nofx.git
cd nofx
go mod download
cd web && npm install && cd ..
### Docker
```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
```
### 소스에서 빌드
```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
cd web && npm install && npm run dev
# 프론트엔드 시작 (새 터미널)
cd web && npm run dev
```
### 업데이트
---
## 초기 설정
1. **AI 모델 설정** - AI API 키 추가
2. **거래소 설정** - 거래소 API 자격 증명 설정
3. **전략 생성** - 전략 스튜디오에서 거래 전략 구성
4. **트레이더 생성** - AI 모델 + 거래소 + 전략 조합
5. **거래 시작** - 설정된 트레이더 시작
---
## 위험 경고
1. 암호화폐 시장은 매우 변동성이 높음 - AI 결정이 수익을 보장하지 않음
2. 선물 거래는 레버리지 사용 - 손실이 원금을 초과할 수 있음
3. 극단적인 시장 상황에서 청산 위험 있음
---
## 서버 배포
### 빠른 배포 (IP를 통한 HTTP)
기본적으로 전송 암호화가 **비활성화**되어 HTTPS 없이 IP 주소를 통해 NOFX에 액세스할 수 있습니다:
```bash
# 서버에 배포
curl -fsSL https://raw.githubusercontent.com/NoFxAiOS/nofx/main/install.sh | bash
```
---
`http://YOUR_SERVER_IP:3000`을 통해 액세스 - 즉시 작동합니다.
## 설정
### 향상된 보안 (HTTPS)
**초보자 모드**: 가이드 온보딩이 모델 액세스, 거래소 연결, 전략 설정, 첫 배포까지 안내합니다.
**고급 모드**:
1. AI 모델 액세스 설정
2. 거래소 인증 정보 연결
3. 전략 생성 또는 가져오기
4. AI 트레이더 프로필 생성
5. 대시보드에서 실행, 모니터링, 개선
모든 설정은 Web UI **http://127.0.0.1:3000** 에서 가능합니다.
---
## 서버에 배포
**HTTP 배포:**
보안을 강화하려면 `.env`에서 전송 암호화를 활성화하세요:
```bash
curl -fsSL https://raw.githubusercontent.com/NoFxAiOS/nofx/main/install.sh | bash
# http://YOUR_IP:3000 으로 접근
TRANSPORT_ENCRYPTION=true
```
**Cloudflare를 통한 HTTPS:**
활성화되면 브라우저는 Web Crypto API를 사용하여 전송 전에 API 키를 암호화합니다. 이를 위해 필요한 것:
- `https://` - SSL이 있는 모든 도메인
- `http://localhost` - 로컬 개발
1. [Cloudflare](https://dash.cloudflare.com)(무료 플랜)에 도메인 추가
2. A 레코드를 서버 IP로 지정(Proxied)
3. SSL/TLS를 Flexible로 설정
4. `.env``TRANSPORT_ENCRYPTION=true` 설정
### 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`을 통해 액세스
---
## 아키텍처
## 초기 설정 (웹 인터페이스)
```
NOFX
┌─────────────────────────────────────────────────┐
│ Trading Terminal │
│ React + TypeScript + TradingView │
│ US Stocks · Commodities · Forex · Crypto │
├─────────────────────────────────────────────────┤
│ API Server (Go) │
├──────────────┬──────────────┬───────────────────┤
│ Strategy │ Telegram │ Trader Runtime │
│ Engine │ Agent │ Risk Controls │
├──────────────┴──────────────┴───────────────────┤
│ AI Model Layer │
│ Unified provider access through Claw402 │
│ Model routing · payment · execution support │
├─────────────────────────────────────────────────┤
│ Exchange Connectivity │
│ Binance · Bybit · OKX · Hyperliquid · Bitget │
│ KuCoin · Gate · Aster · Lighter │
└─────────────────────────────────────────────────┘
```
시스템을 시작한 후 웹 인터페이스를 통해 구성합니다:
1. **AI 모델 구성** - AI API 키 추가 (DeepSeek, OpenAI 등)
2. **거래소 구성** - 거래소 API 자격 증명 설정
3. **전략 생성** - 전략 스튜디오에서 거래 전략 구성
4. **트레이더 생성** - AI 모델 + 거래소 + 전략 결합
5. **거래 시작** - 구성된 트레이더 시작
모든 구성은 웹 인터페이스를 통해 완료 - JSON 파일 편집 불필요.
---
## 문서
## 웹 인터페이스 기능
| | |
| :--- | :--- |
| [아키텍처](../../architecture/README.md) | 시스템 설계와 모듈 색인 |
| [전략 모듈](../../architecture/STRATEGY_MODULE.md) | 종목 선택, AI 프롬프트, 실행 |
| [FAQ](../../faq/README.md) | 자주 묻는 질문 |
| [시작하기](../../getting-started/README.md) | 배포 가이드 |
### 경쟁 페이지
- 실시간 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), [행동 강령](../../../CODE_OF_CONDUCT.md), [보안 정책](../../../SECURITY.md)을 확인하세요.
### 기여자 프로그램
NOFX는 의미 있는 기여를 기록하며 생태계 성장에 따라 기여자에게 보상할 계획입니다. 우선순위 이슈는 더 높은 보상 가중치를 가집니다.
| Contribution | Weight |
| :--- | :---: |
| Pinned Issue PRs | ★★★★★★ |
| Code (Merged PRs) | ★★★★★ |
| Bug Fixes | ★★★★ |
| Feature Ideas | ★★★ |
| Bug Reports | ★★ |
| Documentation | ★★ |
기여를 환영합니다! 다음을 참조하세요:
- **[기여 가이드](CONTRIBUTING.md)** - 개발 워크플로 및 PR 프로세스
- **[행동 강령](CODE_OF_CONDUCT.md)** - 커뮤니티 가이드라인
- **[보안 정책](SECURITY.md)** - 취약점 보고
---
## 링크
## 기여자 에어드롭 프로그램
| | |
| :--- | :--- |
| Website | [vergex.trade](https://vergex.trade) |
| Dashboard | [vergex.trade/explore](https://vergex.trade/explore) |
| Telegram | [nofx_dev_community](https://t.me/nofx_dev_community) |
| Twitter | [@vergex_ai](https://x.com/vergex_ai) |
모든 기여는 GitHub에서 추적됩니다. NOFX가 수익을 창출하면 기여자는 기여도에 따라 에어드롭을 받게 됩니다.
> **위험 고지**: 자동매매에는 상당한 위험이 따릅니다. 적절한 포지션 규모를 사용하고 각 거래소 구조를 이해하며 감당 가능한 자금만 거래하세요.
**[고정된 Issue](https://github.com/NoFxAiOS/nofx/issues)를 해결하는 PR은 최고 보상을 받습니다!**
| 기여 유형 | 가중치 |
|------------------|:------:|
| **고정된 Issue PR** | ⭐⭐⭐⭐⭐⭐ |
| **코드 커밋** (병합된 PR) | ⭐⭐⭐⭐⭐ |
| **버그 수정** | ⭐⭐⭐⭐ |
| **기능 제안** | ⭐⭐⭐ |
| **버그 보고** | ⭐⭐ |
| **문서** | ⭐⭐ |
---
## 스폰서
## 위험 경고
<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>
1. 암호화폐 시장은 매우 변동성이 높음 - AI 결정이 수익을 보장하지 않음
2. 선물 거래는 레버리지 사용 - 손실이 원금을 초과할 수 있음
3. 극단적인 시장 상황에서 청산 위험 있음
[스폰서 되기](https://github.com/sponsors/NoFxAiOS)
## License
[AGPL-3.0](../../../LICENSE)
## 연락처
- **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,329 +1,156 @@
<p align="center"><strong>При поддержке <a href="https://vergex.trade">vergex.trade</a></strong></p>
# NOFX - AI Торговая Система
<h1 align="center">NOFX</h1>
[![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">
<strong>AI-терминал для глобальных рынков.</strong><br/>
<strong>Исследования, генерация стратегий, исполнение и мониторинг для акций США, сырьевых товаров, FX и криптоактивов.</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>
</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 — open-source AI-терминал для активных трейдеров, которым нужен единый рабочий контур для анализа рынков, разработки стратегий, исполнения сделок и мониторинга портфеля.
## Криптовалютная торговая платформа на базе ИИ
Продукт ориентирован на ликвидные глобальные рынки: акции США, товарные контракты, валютные пары и цифровые активы. AI-слой превращает торговое намерение в списки наблюдения, сигналы, стратегическую логику, риск-контроль и рабочие процессы исполнения.
**NOFX** — это open-source AI торговая система, позволяющая запускать несколько AI моделей для автоматической торговли криптовалютными фьючерсами. Настраивайте стратегии через веб-интерфейс, отслеживайте эффективность в реальном времени и позвольте AI агентам конкурировать за лучший торговый подход.
### Основные функции
- **Мульти-AI поддержка**: Запускайте DeepSeek, Qwen, GPT, Claude, Gemini, Grok, Kimi — переключайтесь между моделями в любое время
- **Мульти-биржа**: Торгуйте на Binance, Bybit, OKX, Hyperliquid, Aster DEX, Lighter с единой платформы
- **Студия стратегий**: Визуальный конструктор стратегий с источниками монет, индикаторами и контролем рисков
- **Режим AI-соревнования**: Несколько AI трейдеров соревнуются в реальном времени, отслеживание эффективности бок о бок
- **Веб-конфигурация**: Без редактирования JSON — настройка всего через веб-интерфейс
- **Панель реального времени**: Живые позиции, отслеживание P/L, логи решений AI с цепочкой рассуждений
### Официальные ссылки
- **Официальный сайт**: [https://nofxai.com](https://nofxai.com)
- **Панель данных**: [https://nofxos.ai/dashboard](https://nofxos.ai/dashboard)
- **Документация API**: [https://nofxos.ai/api-docs](https://nofxos.ai/api-docs)
> **Предупреждение о рисках**: Эта система экспериментальная. AI автоторговля несёт значительные риски. Настоятельно рекомендуется использовать только для обучения/исследований или тестирования с небольшими суммами!
## Сообщество разработчиков
Присоединяйтесь к Telegram сообществу: **[NOFX Developer Community](https://t.me/nofx_dev_community)**
---
## Перед началом
Для использования NOFX вам понадобится:
1. **Аккаунт биржи** - Зарегистрируйтесь на поддерживаемой бирже и создайте API ключи с правами торговли
2. **API ключ AI модели** - Получите от любого поддерживаемого провайдера (рекомендуется DeepSeek для экономии)
---
## Поддерживаемые биржи
### CEX (Централизованные биржи)
| Биржа | Статус | Регистрация (скидка) |
|----------|--------|-------------------------|
| **Binance** | ✅ Поддерживается | [Регистрация](https://www.binance.com/join?ref=NOFXENG) |
| **Bybit** | ✅ Поддерживается | [Регистрация](https://partner.bybit.com/b/83856) |
| **OKX** | ✅ Поддерживается | [Регистрация](https://www.okx.com/join/1865360) |
| **Bitget** | ✅ Поддерживается | [Регистрация](https://www.bitget.com/referral/register?from=referral&clacCode=c8a43172) |
### Perp-DEX (Децентрализованные биржи)
| Биржа | Статус | Регистрация (скидка) |
|----------|--------|-------------------------|
| **Hyperliquid** | ✅ Поддерживается | [Регистрация](https://app.hyperliquid.xyz/join/AITRADING) |
| **Aster DEX** | ✅ Поддерживается | [Регистрация](https://www.asterdex.com/en/referral/fdfc0e) |
| **Lighter** | ✅ Поддерживается | [Регистрация](https://app.lighter.xyz/?referral=68151432) |
---
## Поддерживаемые AI модели
| AI Модель | Статус | Получить API ключ |
|----------|--------|-------------|
| **DeepSeek** | ✅ Поддерживается | [Получить](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) |
---
## Быстрый старт
### Вариант 1: Docker развёртывание (рекомендуется)
```bash
git clone https://github.com/NoFxAiOS/nofx.git
cd nofx
chmod +x ./start.sh
./start.sh start --build
```
Доступ к веб-интерфейсу: **http://localhost:3000**
### Обновление до последней версии
> **💡 Обновления выходят часто.** Запускайте эту команду ежедневно для получения последних функций и исправлений:
```bash
curl -fsSL https://raw.githubusercontent.com/NoFxAiOS/nofx/main/install.sh | bash
```
**http://127.0.0.1:3000** 을 엽니다.
Эта команда загружает последние официальные образы и автоматически перезапускает сервисы.
---
## Регистрация на биржах
Используйте ссылки ниже для открытия торговых счетов на крипторынках и поддерживаемых рынках деривативов на акции США, FX и сырьевые товары. Эти маршруты относятся к партнерским программам NOFX и могут включать скидки на комиссии или реферальные преимущества.
| Биржа | Статус | Регистрация со скидкой |
| :--- | :---: | :--- |
| <img src="../../../web/public/exchange-icons/binance.jpg" width="20" height="20" style="vertical-align: middle;"/> **Binance** | ✅ | [Регистрация](https://www.binance.com/join?ref=NOFXENG) |
| <img src="../../../web/public/exchange-icons/bybit.png" width="20" height="20" style="vertical-align: middle;"/> **Bybit** | ✅ | [Регистрация](https://partner.bybit.com/b/83856) |
| <img src="../../../web/public/exchange-icons/okx.svg" width="20" height="20" style="vertical-align: middle;"/> **OKX** | ✅ | [Регистрация](https://www.okx.com/join/1865360) |
| <img src="../../../web/public/exchange-icons/hyperliquid.png" width="20" height="20" style="vertical-align: middle;"/> **Hyperliquid** | ✅ | [Регистрация](https://app.hyperliquid.xyz/join/AITRADING) |
| <img src="../../../web/public/exchange-icons/bitget.svg" width="20" height="20" style="vertical-align: middle;"/> **Bitget** | ✅ | [Регистрация](https://www.bitget.com/referral/register?from=referral&clacCode=c8a43172) |
| <img src="../../../web/public/exchange-icons/kucoin.svg" width="20" height="20" style="vertical-align: middle;"/> **KuCoin** | ✅ | [Регистрация](https://www.kucoin.com/r/broker/CXEV7XKK) |
| <img src="../../../web/public/exchange-icons/gate.svg" width="20" height="20" style="vertical-align: middle;"/> **Gate** | ✅ | [Регистрация](https://www.gatenode.xyz/share/VQBGUAxY) |
| <img src="../../../web/public/exchange-icons/aster.svg" width="20" height="20" style="vertical-align: middle;"/> **Aster** | ✅ | [Регистрация](https://www.asterdex.com/en/referral/fdfc0e) |
| <img src="../../../web/public/exchange-icons/lighter.png" width="20" height="20" style="vertical-align: middle;"/> **Lighter** | ✅ | [Регистрация](https://app.lighter.xyz/?referral=68151432) |
---
## Короткая демонстрация
<p align="center">
<a href="https://drive.google.com/file/d/1frzw-HDZ3viQvLOQKsAJGc9bT0dXs68D/view">
<img src="../../../screenshots/demo-cover.png" alt="NOFX quick demo video" width="900"/>
</a>
</p>
<p align="center">
Нажмите на обложку, чтобы посмотреть демо.
</p>
---
## Рынки
**Акции США · Сырьевые товары · FX · Криптоактивы**
NOFX организует исследования, построение стратегий, исполнение и мониторинг вокруг мультиактивных рабочих процессов, а не вокруг одного торгового экрана.
---
## Доступ к AI-моделям
NOFX автоматически маршрутизирует AI-инференс через [Claw402](https://claw402.ai). Пользователям не нужно настраивать провайдеров моделей, управлять API-ключами или поддерживать отдельные AI-аккаунты. Терминал обращается к поддерживаемым моделям по требованию через pay-as-you-go инфраструктуру Claw402 и официальный канал со скидкой.
| Провайдер | Доступ |
| :--- | :--- |
| **Claw402** | [Доступ к AI-моделям по мере использования с официальной скидкой](https://claw402.ai) |
---
## Возможности
| Возможность | Описание |
| :--- | :--- |
| **AI trading terminal** | Единое рабочее пространство для акций США, сырья, FX и криптоактивов |
| **Доступ к AI-моделям** | Автоматический доступ к поддерживаемым провайдерам через Claw402 |
| **Подключение бирж** | Binance, Bybit, OKX, Hyperliquid, Bitget, KuCoin, Gate, Aster, Lighter |
| **Strategy Studio** | Рыночные универсумы, индикаторы, риск-контроль и логика стратегий |
| **Соревнование моделей** | Сравнение AI-трейдеров по live-результатам и таблице лидеров |
| **Telegram Agent** | Управление и мониторинг ассистента через чат |
| **Портфельный дашборд** | Позиции, P/L, история исполнения и логи решений модели |
---
## Скриншоты
<details>
<summary><b>Страница настроек</b></summary>
| Конфигурация | Список трейдеров |
| :----------------------------------------------------------: | :----------------------------------------------------------: |
| <img src="../../../screenshots/config-ai-exchanges.png" width="400"/> | <img src="../../../screenshots/config-traders-list.png" width="400"/> |
</details>
<details>
<summary><b>Дашборд</b></summary>
| Обзор | График рынка |
| :-----------------------------------------------------: | :-------------------------------------------------------------: |
| <img src="../../../screenshots/dashboard-page.png" width="400"/> | <img src="../../../screenshots/dashboard-market-chart.png" width="400"/> |
| Статистика торгов | История позиций |
| :--------------------------------------------------------------: | :-----------------------------------------------------------------: |
| <img src="../../../screenshots/dashboard-trading-stats.png" width="400"/> | <img src="../../../screenshots/dashboard-position-history.png" width="400"/> |
| Позиции | Детали трейдера |
| :----------------------------------------------------------: | :---------------------------------------------------: |
| <img src="../../../screenshots/dashboard-positions.png" width="400"/> | <img src="../../../screenshots/details-page.png" width="400"/> |
</details>
<details>
<summary><b>Strategy Studio</b></summary>
| Редактор стратегий | Настройка индикаторов |
| :------------------------------------------------------: | :----------------------------------------------------------: |
| <img src="../../../screenshots/strategy-studio.png" width="400"/> | <img src="../../../screenshots/strategy-indicators.png" width="400"/> |
</details>
<details>
<summary><b>Соревнование</b></summary>
| Режим соревнования |
| :-------------------------------------------------------: |
| <img src="../../../screenshots/competition-page.png" width="400"/> |
</details>
---
## Установка
### Linux / macOS
### Вариант 2: Ручная установка
```bash
curl -fsSL https://raw.githubusercontent.com/NoFxAiOS/nofx/main/install.sh | bash
```
# Требования: Go 1.21+, Node.js 18+, TA-Lib
### Railway (облако)
# Установка TA-Lib (macOS)
brew install ta-lib
[![Deploy on Railway](https://railway.com/button.svg)](https://railway.com/deploy/nofx?referralCode=nofx)
# Клонирование и настройка
git clone https://github.com/NoFxAiOS/nofx.git
cd nofx
go mod download
cd web && npm install && cd ..
### Docker
```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
```
### Сборка из исходников
```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
cd web && npm install && npm run dev
```
### Обновление
```bash
curl -fsSL https://raw.githubusercontent.com/NoFxAiOS/nofx/main/install.sh | bash
# Запуск фронтенда (новый терминал)
cd web && npm run dev
```
---
## Настройка
## Начальная настройка
**Режим для новичков**: пошаговый onboarding проводит через доступ к моделям, подключение биржи, настройку стратегии и первый запуск.
**Продвинутый режим**:
1. Настройте доступ к AI-моделям
2. Подключите учетные данные биржи
3. Создайте или импортируйте стратегию
4. Создайте профиль AI-трейдера
5. Запустите, мониторьте и улучшайте через дашборд
Все настройки доступны в Web UI по адресу **http://127.0.0.1:3000**.
1. **Настройка AI моделей** — Добавьте API ключи AI
2. **Настройка бирж** — Установите API учётные данные бирж
3. **Создание стратегии** — Настройте торговую стратегию в Студии стратегий
4. **Создание трейдера** — Объедините AI модель + Биржу + Стратегию
5. **Начало торговли** — Запустите настроенных трейдеров
---
## Развёртывание на сервере
## Предупреждения о рисках
**HTTP-развёртывание:**
```bash
curl -fsSL https://raw.githubusercontent.com/NoFxAiOS/nofx/main/install.sh | bash
# Доступ через http://YOUR_IP:3000
```
**HTTPS через Cloudflare:**
1. Добавьте домен в [Cloudflare](https://dash.cloudflare.com) (бесплатный план)
2. A-запись → IP вашего сервера (Proxied)
3. SSL/TLS → Flexible
4. Установите `TRANSPORT_ENCRYPTION=true` в `.env`
1. Криптовалютные рынки крайне волатильны — AI решения не гарантируют прибыль
2. Торговля фьючерсами использует плечо — убытки могут превысить депозит
3. Экстремальные рыночные условия могут привести к ликвидации
---
## Архитектура
## Лицензия
```
NOFX
┌─────────────────────────────────────────────────┐
│ Trading Terminal │
│ React + TypeScript + TradingView │
│ US Stocks · Commodities · Forex · Crypto │
├─────────────────────────────────────────────────┤
│ API Server (Go) │
├──────────────┬──────────────┬───────────────────┤
│ Strategy │ Telegram │ Trader Runtime │
│ Engine │ Agent │ Risk Controls │
├──────────────┴──────────────┴───────────────────┤
│ AI Model Layer │
│ Unified provider access through Claw402 │
│ Model routing · payment · execution support │
├─────────────────────────────────────────────────┤
│ Exchange Connectivity │
│ Binance · Bybit · OKX · Hyperliquid · Bitget │
│ KuCoin · Gate · Aster · Lighter │
└─────────────────────────────────────────────────┘
```
**GNU Affero General Public License v3.0 (AGPL-3.0)**
---
## Документация
## Контакты
| | |
| :--- | :--- |
| [Архитектура](../../architecture/README.md) | Дизайн системы и индекс модулей |
| [Модуль стратегий](../../architecture/STRATEGY_MODULE.md) | Выбор инструментов, AI-промпты, исполнение |
| [FAQ](../../faq/README.md) | Частые вопросы |
| [Начало работы](../../getting-started/README.md) | Гайд по деплою |
---
## Участие
См. [Contributing Guide](../../../CONTRIBUTING.md), [Code of Conduct](../../../CODE_OF_CONDUCT.md) и [Security Policy](../../../SECURITY.md).
### Программа для контрибьюторов
NOFX отслеживает значимые вклады и планирует вознаграждать контрибьюторов по мере роста экосистемы. Приоритетные issues имеют более высокий вес награды.
| Contribution | Weight |
| :--- | :---: |
| Pinned Issue PRs | ★★★★★★ |
| Code (Merged PRs) | ★★★★★ |
| Bug Fixes | ★★★★ |
| Feature Ideas | ★★★ |
| Bug Reports | ★★ |
| Documentation | ★★ |
---
## Ссылки
| | |
| :--- | :--- |
| Website | [vergex.trade](https://vergex.trade) |
| Dashboard | [vergex.trade/explore](https://vergex.trade/explore) |
| Telegram | [nofx_dev_community](https://t.me/nofx_dev_community) |
| Twitter | [@vergex_ai](https://x.com/vergex_ai) |
> **Предупреждение о рисках**: автоматическая торговля связана со значительным риском. Контролируйте размер позиции, понимайте устройство каждой площадки и не торгуйте средствами, потерю которых не можете себе позволить.
---
## Спонсоры
<a href="https://github.com/pjl914335852-ux"><img src="https://github.com/pjl914335852-ux.png" width="50" height="50" style="border-radius:50%"/></a>
<a href="https://github.com/cat9999aaa"><img src="https://github.com/cat9999aaa.png" width="50" height="50" style="border-radius:50%"/></a>
<a href="https://github.com/1733055465"><img src="https://github.com/1733055465.png" width="50" height="50" style="border-radius:50%"/></a>
<a href="https://github.com/kolal2020"><img src="https://github.com/kolal2020.png" width="50" height="50" style="border-radius:50%"/></a>
<a href="https://github.com/CyberFFarm"><img src="https://github.com/CyberFFarm.png" width="50" height="50" style="border-radius:50%"/></a>
<a href="https://github.com/vip3001003"><img src="https://github.com/vip3001003.png" width="50" height="50" style="border-radius:50%"/></a>
<a href="https://github.com/mrtluh"><img src="https://github.com/mrtluh.png" width="50" height="50" style="border-radius:50%"/></a>
<a href="https://github.com/cpcp1117-source"><img src="https://github.com/cpcp1117-source.png" width="50" height="50" style="border-radius:50%"/></a>
<a href="https://github.com/match-007"><img src="https://github.com/match-007.png" width="50" height="50" style="border-radius:50%"/></a>
<a href="https://github.com/leiwuhen1715"><img src="https://github.com/leiwuhen1715.png" width="50" height="50" style="border-radius:50%"/></a>
<a href="https://github.com/SHAOXIA1991"><img src="https://github.com/SHAOXIA1991.png" width="50" height="50" style="border-radius:50%"/></a>
[Стать спонсором](https://github.com/sponsors/NoFxAiOS)
## License
[AGPL-3.0](../../../LICENSE)
[![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,329 +1,156 @@
<p align="center"><strong>За підтримки <a href="https://vergex.trade">vergex.trade</a></strong></p>
# NOFX - AI Торгова Система
<h1 align="center">NOFX</h1>
[![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">
<strong>AI-термінал для глобальних ринків.</strong><br/>
<strong>Дослідження, генерація стратегій, виконання та моніторинг для акцій США, сировинних товарів, FX і криптоактивів.</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>
</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 — open-source AI-термінал для активних трейдерів, яким потрібен єдиний робочий простір для аналізу ринків, розробки стратегій, виконання угод і моніторингу портфеля.
## Криптовалютна торгова платформа на базі ШІ
Продукт орієнтований на ліквідні глобальні ринки: акції США, товарні контракти, валютні пари й цифрові активи. AI-шар перетворює торговий намір на watchlists, сигнали, логіку стратегії, ризик-контроль і робочі процеси виконання.
**NOFX** — це open-source AI торгова система, що дозволяє запускати кілька AI моделей для автоматичної торгівлі криптовалютними ф'ючерсами. Налаштовуйте стратегії через веб-інтерфейс, відстежуйте ефективність у реальному часі та дозвольте AI агентам конкурувати за найкращий торговий підхід.
### Основні функції
- **Мульти-AI підтримка**: Запускайте DeepSeek, Qwen, GPT, Claude, Gemini, Grok, Kimi — перемикайтеся між моделями будь-коли
- **Мульти-біржа**: Торгуйте на Binance, Bybit, OKX, Hyperliquid, Aster DEX, Lighter з єдиної платформи
- **Студія стратегій**: Візуальний конструктор стратегій з джерелами монет, індикаторами та контролем ризиків
- **Режим AI-змагання**: Кілька AI трейдерів змагаються в реальному часі, відстеження ефективності пліч-о-пліч
- **Веб-конфігурація**: Без редагування JSON — налаштування всього через веб-інтерфейс
- **Панель реального часу**: Живі позиції, відстеження P/L, логи рішень AI з ланцюжком міркувань
### Офіційні посилання
- **Офіційний сайт**: [https://nofxai.com](https://nofxai.com)
- **Панель даних**: [https://nofxos.ai/dashboard](https://nofxos.ai/dashboard)
- **Документація API**: [https://nofxos.ai/api-docs](https://nofxos.ai/api-docs)
> **Попередження про ризики**: Ця система експериментальна. AI автоторгівля несе значні ризики. Наполегливо рекомендується використовувати лише для навчання/досліджень або тестування з невеликими сумами!
## Спільнота розробників
Приєднуйтесь до Telegram спільноти: **[NOFX Developer Community](https://t.me/nofx_dev_community)**
---
## Перед початком
Для використання NOFX вам знадобиться:
1. **Акаунт біржі** - Зареєструйтесь на підтримуваній біржі та створіть API ключі з правами торгівлі
2. **API ключ AI моделі** - Отримайте від будь-якого підтримуваного провайдера (рекомендується DeepSeek для економії)
---
## Підтримувані біржі
### CEX (Централізовані біржі)
| Біржа | Статус | Реєстрація (знижка) |
|----------|--------|-------------------------|
| **Binance** | ✅ Підтримується | [Реєстрація](https://www.binance.com/join?ref=NOFXENG) |
| **Bybit** | ✅ Підтримується | [Реєстрація](https://partner.bybit.com/b/83856) |
| **OKX** | ✅ Підтримується | [Реєстрація](https://www.okx.com/join/1865360) |
| **Bitget** | ✅ Підтримується | [Реєстрація](https://www.bitget.com/referral/register?from=referral&clacCode=c8a43172) |
### Perp-DEX (Децентралізовані біржі)
| Біржа | Статус | Реєстрація (знижка) |
|----------|--------|-------------------------|
| **Hyperliquid** | ✅ Підтримується | [Реєстрація](https://app.hyperliquid.xyz/join/AITRADING) |
| **Aster DEX** | ✅ Підтримується | [Реєстрація](https://www.asterdex.com/en/referral/fdfc0e) |
| **Lighter** | ✅ Підтримується | [Реєстрація](https://app.lighter.xyz/?referral=68151432) |
---
## Підтримувані AI моделі
| AI Модель | Статус | Отримати API ключ |
|----------|--------|-------------|
| **DeepSeek** | ✅ Підтримується | [Отримати](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) |
---
## Швидкий старт
### Варіант 1: Docker розгортання (рекомендовано)
```bash
git clone https://github.com/NoFxAiOS/nofx.git
cd nofx
chmod +x ./start.sh
./start.sh start --build
```
Доступ до веб-інтерфейсу: **http://localhost:3000**
### Оновлення до останньої версії
> **💡 Оновлення виходять часто.** Запускайте цю команду щодня для отримання останніх функцій та виправлень:
```bash
curl -fsSL https://raw.githubusercontent.com/NoFxAiOS/nofx/main/install.sh | bash
```
**http://127.0.0.1:3000** 을 엽니다.
Ця команда завантажує останні офіційні образи та автоматично перезапускає сервіси.
---
## Реєстрація на біржах
Скористайтеся посиланнями нижче, щоб відкрити торгові акаунти для крипторинків і підтримуваних деривативів на акції США, FX та сировинні товари. Ці маршрути належать до партнерських програм NOFX і можуть містити знижки на комісії або реферальні переваги.
| Біржа | Статус | Реєстрація зі знижкою |
| :--- | :---: | :--- |
| <img src="../../../web/public/exchange-icons/binance.jpg" width="20" height="20" style="vertical-align: middle;"/> **Binance** | ✅ | [Реєстрація](https://www.binance.com/join?ref=NOFXENG) |
| <img src="../../../web/public/exchange-icons/bybit.png" width="20" height="20" style="vertical-align: middle;"/> **Bybit** | ✅ | [Реєстрація](https://partner.bybit.com/b/83856) |
| <img src="../../../web/public/exchange-icons/okx.svg" width="20" height="20" style="vertical-align: middle;"/> **OKX** | ✅ | [Реєстрація](https://www.okx.com/join/1865360) |
| <img src="../../../web/public/exchange-icons/hyperliquid.png" width="20" height="20" style="vertical-align: middle;"/> **Hyperliquid** | ✅ | [Реєстрація](https://app.hyperliquid.xyz/join/AITRADING) |
| <img src="../../../web/public/exchange-icons/bitget.svg" width="20" height="20" style="vertical-align: middle;"/> **Bitget** | ✅ | [Реєстрація](https://www.bitget.com/referral/register?from=referral&clacCode=c8a43172) |
| <img src="../../../web/public/exchange-icons/kucoin.svg" width="20" height="20" style="vertical-align: middle;"/> **KuCoin** | ✅ | [Реєстрація](https://www.kucoin.com/r/broker/CXEV7XKK) |
| <img src="../../../web/public/exchange-icons/gate.svg" width="20" height="20" style="vertical-align: middle;"/> **Gate** | ✅ | [Реєстрація](https://www.gatenode.xyz/share/VQBGUAxY) |
| <img src="../../../web/public/exchange-icons/aster.svg" width="20" height="20" style="vertical-align: middle;"/> **Aster** | ✅ | [Реєстрація](https://www.asterdex.com/en/referral/fdfc0e) |
| <img src="../../../web/public/exchange-icons/lighter.png" width="20" height="20" style="vertical-align: middle;"/> **Lighter** | ✅ | [Реєстрація](https://app.lighter.xyz/?referral=68151432) |
---
## Коротка демонстрація
<p align="center">
<a href="https://drive.google.com/file/d/1frzw-HDZ3viQvLOQKsAJGc9bT0dXs68D/view">
<img src="../../../screenshots/demo-cover.png" alt="NOFX quick demo video" width="900"/>
</a>
</p>
<p align="center">
Натисніть на обкладинку, щоб переглянути демо.
</p>
---
## Ринки
**Акції США · Сировинні товари · FX · Криптоактиви**
NOFX организует исследования, построение стратегий, исполнение и мониторинг вокруг мультиактивных рабочих процессов, а не вокруг одного торгового экрана.
---
## Доступ до AI-моделей
NOFX автоматично маршрутизує AI inference через [Claw402](https://claw402.ai). Користувачам не потрібно налаштовувати провайдерів моделей, керувати API-ключами або підтримувати окремі AI-акаунти. Термінал звертається до підтримуваних моделей на вимогу через pay-as-you-go інфраструктуру Claw402 та офіційний канал зі знижкою.
| Провайдер | Доступ |
| :--- | :--- |
| **Claw402** | [Доступ до AI-моделей pay-as-you-go з офіційною знижкою](https://claw402.ai) |
---
## Можливості
| Возможность | Описание |
| :--- | :--- |
| **AI trading terminal** | Единое рабочее пространство для акций США, сырья, FX и криптоактивов |
| **Доступ к AI-моделям** | Автоматический доступ к поддерживаемым провайдерам через Claw402 |
| **Подключение бирж** | Binance, Bybit, OKX, Hyperliquid, Bitget, KuCoin, Gate, Aster, Lighter |
| **Strategy Studio** | Рыночные универсумы, индикаторы, риск-контроль и логика стратегий |
| **Соревнование моделей** | Сравнение AI-трейдеров по live-результатам и таблице лидеров |
| **Telegram Agent** | Управление и мониторинг ассистента через чат |
| **Портфельный дашборд** | Позиции, P/L, история исполнения и логи решений модели |
---
## Скріншоти
<details>
<summary><b>Сторінка налаштувань</b></summary>
| Конфігурація | Список трейдерів |
| :----------------------------------------------------------: | :----------------------------------------------------------: |
| <img src="../../../screenshots/config-ai-exchanges.png" width="400"/> | <img src="../../../screenshots/config-traders-list.png" width="400"/> |
</details>
<details>
<summary><b>Дашборд</b></summary>
| Огляд | Графік ринку |
| :-----------------------------------------------------: | :-------------------------------------------------------------: |
| <img src="../../../screenshots/dashboard-page.png" width="400"/> | <img src="../../../screenshots/dashboard-market-chart.png" width="400"/> |
| Статистика торгів | Історія позицій |
| :--------------------------------------------------------------: | :-----------------------------------------------------------------: |
| <img src="../../../screenshots/dashboard-trading-stats.png" width="400"/> | <img src="../../../screenshots/dashboard-position-history.png" width="400"/> |
| Позиції | Деталі трейдера |
| :----------------------------------------------------------: | :---------------------------------------------------: |
| <img src="../../../screenshots/dashboard-positions.png" width="400"/> | <img src="../../../screenshots/details-page.png" width="400"/> |
</details>
<details>
<summary><b>Strategy Studio</b></summary>
| Редактор стратегій | Налаштування індикаторів |
| :------------------------------------------------------: | :----------------------------------------------------------: |
| <img src="../../../screenshots/strategy-studio.png" width="400"/> | <img src="../../../screenshots/strategy-indicators.png" width="400"/> |
</details>
<details>
<summary><b>Змагання</b></summary>
| Режим змагання |
| :-------------------------------------------------------: |
| <img src="../../../screenshots/competition-page.png" width="400"/> |
</details>
---
## Установка
### Linux / macOS
### Варіант 2: Ручна установка
```bash
curl -fsSL https://raw.githubusercontent.com/NoFxAiOS/nofx/main/install.sh | bash
```
# Вимоги: Go 1.21+, Node.js 18+, TA-Lib
### Railway (облако)
# Встановлення TA-Lib (macOS)
brew install ta-lib
[![Deploy on Railway](https://railway.com/button.svg)](https://railway.com/deploy/nofx?referralCode=nofx)
# Клонування та налаштування
git clone https://github.com/NoFxAiOS/nofx.git
cd nofx
go mod download
cd web && npm install && cd ..
### Docker
```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
```
### Сборка из исходников
```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
cd web && npm install && npm run dev
```
### Обновление
```bash
curl -fsSL https://raw.githubusercontent.com/NoFxAiOS/nofx/main/install.sh | bash
# Запуск фронтенду (новий термінал)
cd web && npm run dev
```
---
## Настройка
## Початкове налаштування
**Режим для новичков**: пошаговый onboarding проводит через доступ к моделям, подключение биржи, настройку стратегии и первый запуск.
**Продвинутый режим**:
1. Настройте доступ к AI-моделям
2. Подключите учетные данные биржи
3. Создайте или импортируйте стратегию
4. Создайте профиль AI-трейдера
5. Запустите, мониторьте и улучшайте через дашборд
Все настройки доступны в Web UI по адресу **http://127.0.0.1:3000**.
1. **Налаштування AI моделей** — Додайте API ключі AI
2. **Налаштування бірж** — Встановіть API облікові дані бірж
3. **Створення стратегії** — Налаштуйте торгову стратегію в Студії стратегій
4. **Створення трейдера**Об'єднайте AI модель + Біржу + Стратегію
5. **Початок торгівлі** — Запустіть налаштованих трейдерів
---
## Розгортання на сервері
## Попередження про ризики
**HTTP-розгортання:**
```bash
curl -fsSL https://raw.githubusercontent.com/NoFxAiOS/nofx/main/install.sh | bash
# Доступ через http://YOUR_IP:3000
```
**HTTPS через Cloudflare:**
1. Додайте домен у [Cloudflare](https://dash.cloudflare.com) (безкоштовний план)
2. A-запис → IP вашого сервера (Proxied)
3. SSL/TLS → Flexible
4. Встановіть `TRANSPORT_ENCRYPTION=true` у `.env`
1. Криптовалютні ринки надзвичайно волатильні — AI рішення не гарантують прибуток
2. Торгівля ф'ючерсами використовує плече — збитки можуть перевищити депозит
3. Екстремальні ринкові умови можуть призвести до ліквідації
---
## Архітектура
## Ліцензія
```
NOFX
┌─────────────────────────────────────────────────┐
│ Trading Terminal │
│ React + TypeScript + TradingView │
│ US Stocks · Commodities · Forex · Crypto │
├─────────────────────────────────────────────────┤
│ API Server (Go) │
├──────────────┬──────────────┬───────────────────┤
│ Strategy │ Telegram │ Trader Runtime │
│ Engine │ Agent │ Risk Controls │
├──────────────┴──────────────┴───────────────────┤
│ AI Model Layer │
│ Unified provider access through Claw402 │
│ Model routing · payment · execution support │
├─────────────────────────────────────────────────┤
│ Exchange Connectivity │
│ Binance · Bybit · OKX · Hyperliquid · Bitget │
│ KuCoin · Gate · Aster · Lighter │
└─────────────────────────────────────────────────┘
```
**GNU Affero General Public License v3.0 (AGPL-3.0)**
---
## Документация
## Контакти
| | |
| :--- | :--- |
| [Архитектура](../../architecture/README.md) | Дизайн системы и индекс модулей |
| [Модуль стратегий](../../architecture/STRATEGY_MODULE.md) | Выбор инструментов, AI-промпты, исполнение |
| [FAQ](../../faq/README.md) | Частые вопросы |
| [Начало работы](../../getting-started/README.md) | Гайд по деплою |
---
## Участие
См. [Contributing Guide](../../../CONTRIBUTING.md), [Code of Conduct](../../../CODE_OF_CONDUCT.md) и [Security Policy](../../../SECURITY.md).
### Программа для контрибьюторов
NOFX отслеживает значимые вклады и планирует вознаграждать контрибьюторов по мере роста экосистемы. Приоритетные issues имеют более высокий вес награды.
| Contribution | Weight |
| :--- | :---: |
| Pinned Issue PRs | ★★★★★★ |
| Code (Merged PRs) | ★★★★★ |
| Bug Fixes | ★★★★ |
| Feature Ideas | ★★★ |
| Bug Reports | ★★ |
| Documentation | ★★ |
---
## Посилання
| | |
| :--- | :--- |
| Website | [vergex.trade](https://vergex.trade) |
| Dashboard | [vergex.trade/explore](https://vergex.trade/explore) |
| Telegram | [nofx_dev_community](https://t.me/nofx_dev_community) |
| Twitter | [@vergex_ai](https://x.com/vergex_ai) |
> **Попередження про ризики**: автоматизована торгівля має значний ризик. Контролюйте розмір позиції, розумійте механіку кожного майданчика і не торгуйте коштами, втрату яких не можете собі дозволити.
---
## Спонсори
<a href="https://github.com/pjl914335852-ux"><img src="https://github.com/pjl914335852-ux.png" width="50" height="50" style="border-radius:50%"/></a>
<a href="https://github.com/cat9999aaa"><img src="https://github.com/cat9999aaa.png" width="50" height="50" style="border-radius:50%"/></a>
<a href="https://github.com/1733055465"><img src="https://github.com/1733055465.png" width="50" height="50" style="border-radius:50%"/></a>
<a href="https://github.com/kolal2020"><img src="https://github.com/kolal2020.png" width="50" height="50" style="border-radius:50%"/></a>
<a href="https://github.com/CyberFFarm"><img src="https://github.com/CyberFFarm.png" width="50" height="50" style="border-radius:50%"/></a>
<a href="https://github.com/vip3001003"><img src="https://github.com/vip3001003.png" width="50" height="50" style="border-radius:50%"/></a>
<a href="https://github.com/mrtluh"><img src="https://github.com/mrtluh.png" width="50" height="50" style="border-radius:50%"/></a>
<a href="https://github.com/cpcp1117-source"><img src="https://github.com/cpcp1117-source.png" width="50" height="50" style="border-radius:50%"/></a>
<a href="https://github.com/match-007"><img src="https://github.com/match-007.png" width="50" height="50" style="border-radius:50%"/></a>
<a href="https://github.com/leiwuhen1715"><img src="https://github.com/leiwuhen1715.png" width="50" height="50" style="border-radius:50%"/></a>
<a href="https://github.com/SHAOXIA1991"><img src="https://github.com/SHAOXIA1991.png" width="50" height="50" style="border-radius:50%"/></a>
[Стати спонсором](https://github.com/sponsors/NoFxAiOS)
## License
[AGPL-3.0](../../../LICENSE)
[![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,329 +1,156 @@
<p align="center"><strong>Được hỗ trợ bởi <a href="https://vergex.trade">vergex.trade</a></strong></p>
# NOFX - Hệ Thống Giao Dịch AI
<h1 align="center">NOFX</h1>
[![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">
<strong>Thiết bị đầu cuối giao dịch AI cho thị trường toàn cầu.</strong><br/>
<strong>Nghiên cứu, tạo chiến lược, thực thi và giám sát cho cổ phiếu Mỹ, hàng hóa, ngoại hối và crypto.</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>
</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à thiết bị đầu cuối giao dịch AI mã nguồn mở cho các trader cần một không gian làm việc thống nhất để nghiên cứu thị trường, phát triển chiến lược, thực thi giao dịch và giám sát danh mục.
## Nền Tảng Giao Dịch Crypto Sử Dụng AI
Sản phẩm tập trung vào các thị trường thanh khoản toàn cầu: cổ phiếu Mỹ, hợp đồng hàng hóa, cặp FX và tài sản số. Lớp AI chuyển ý định giao dịch thành watchlist, tín hiệu, logic chiến lược, kiểm soát rủi ro và workflow thực thi.
**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.
### Tính Năng Chính
- **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
### 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)**
---
## Trước Khi Bắt Đầu
Để sử dụng NOFX, bạn cần:
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í)
---
## Sàn Giao Dịch Được Hỗ Trợ
### CEX (Sàn Tập Trung)
| Sàn | Trạng thái | Đăng ký (Giảm phí) |
|----------|--------|-------------------------|
| **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) |
### Perp-DEX (Sàn Phi Tập Trung)
| Sàn | Trạng thái | Đăng ký (Giảm phí) |
|----------|--------|-------------------------|
| **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 Được Hỗ Trợ
| Mô hình AI | Trạng thái | Lấy API Key |
|----------|--------|-------------|
| **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) |
---
## Bắt Đầu Nhanh
### 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
```
**http://127.0.0.1:3000** 을 엽니다.
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ụ.
---
## Đăng ký sàn giao dịch
Sử dụng các liên kết bên dưới để mở tài khoản giao dịch cho crypto và các thị trường phái sinh cổ phiếu Mỹ, FX, hàng hóa được hỗ trợ. Các tuyến này thuộc chương trình đối tác NOFX và có thể bao gồm ưu đãi phí hoặc quyền lợi giới thiệu.
| Sàn | Trạng thái | Đăng ký kèm ưu đãi 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/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/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) |
| <img src="../../../web/public/exchange-icons/aster.svg" width="20" height="20" style="vertical-align: middle;"/> **Aster** | ✅ | [Đă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) |
---
## Demo nhanh
<p align="center">
<a href="https://drive.google.com/file/d/1frzw-HDZ3viQvLOQKsAJGc9bT0dXs68D/view">
<img src="../../../screenshots/demo-cover.png" alt="NOFX quick demo video" width="900"/>
</a>
</p>
<p align="center">
Nhấp vào ảnh bìa để xem video demo.
</p>
---
## Thị trường
**Cổ phiếu Mỹ · Hàng hóa · Ngoại hối · Crypto**
NOFX tổ chức nghiên cứu, xây dựng chiến lược, thực thi và giám sát theo workflow đa tài sản thay vì một màn hình sàn đơn lẻ.
---
## Truy cập mô hình AI
NOFX tự động định tuyến AI inference qua [Claw402](https://claw402.ai). Người dùng không cần cấu hình nhà cung cấp mô hình, quản lý API key hoặc duy trì tài khoản AI riêng. Terminal truy cập các mô hình được hỗ trợ theo nhu cầu qua hạ tầng pay-as-you-go của Claw402 và định tuyến qua kênh ưu đãi chính thức.
| Nhà cung cấp | Truy cập |
| :--- | :--- |
| **Claw402** | [Truy cập mô hình AI pay-as-you-go với ưu đãi chính thức](https://claw402.ai) |
---
## Năng lực
| Năng lực | Mô tả |
| :--- | :--- |
| **AI trading terminal** | Không gian làm việc thống nhất cho cổ phiếu Mỹ, hàng hóa, ngoại hối và crypto |
| **Truy cập mô hình AI** | Tự động kết nối nhà cung cấp được hỗ trợ qua Claw402 |
| **Kết nối sàn** | Binance, Bybit, OKX, Hyperliquid, Bitget, KuCoin, Gate, Aster, Lighter |
| **Strategy Studio** | Universe thị trường, chỉ báo, kiểm soát rủi ro và logic chiến lược |
| **Cạnh tranh mô hình** | So sánh AI trader bằng hiệu suất live và bảng xếp hạng |
| **Telegram Agent** | Điều khiển và giám sát trợ lý giao dịch qua chat |
| **Dashboard danh mục** | Vị thế, P/L, lịch sử thực thi và log quyết định của mô hình |
---
## Ảnh chụp màn hình
<details>
<summary><b>Trang cấu hình</b></summary>
| Cấu hình | Danh sách trader |
| :----------------------------------------------------------: | :----------------------------------------------------------: |
| <img src="../../../screenshots/config-ai-exchanges.png" width="400"/> | <img src="../../../screenshots/config-traders-list.png" width="400"/> |
</details>
<details>
<summary><b>Dashboard</b></summary>
| Tổng quan | Biểu đồ thị trường |
| :-----------------------------------------------------: | :-------------------------------------------------------------: |
| <img src="../../../screenshots/dashboard-page.png" width="400"/> | <img src="../../../screenshots/dashboard-market-chart.png" width="400"/> |
| Thống kê giao dịch | Lịch sử vị thế |
| :--------------------------------------------------------------: | :-----------------------------------------------------------------: |
| <img src="../../../screenshots/dashboard-trading-stats.png" width="400"/> | <img src="../../../screenshots/dashboard-position-history.png" width="400"/> |
| Vị thế | Chi tiết trader |
| :----------------------------------------------------------: | :---------------------------------------------------: |
| <img src="../../../screenshots/dashboard-positions.png" width="400"/> | <img src="../../../screenshots/details-page.png" width="400"/> |
</details>
<details>
<summary><b>Strategy Studio</b></summary>
| Trình soạn chiến lược | Cấu hình chỉ báo |
| :------------------------------------------------------: | :----------------------------------------------------------: |
| <img src="../../../screenshots/strategy-studio.png" width="400"/> | <img src="../../../screenshots/strategy-indicators.png" width="400"/> |
</details>
<details>
<summary><b>Cạnh tranh</b></summary>
| Chế độ cạnh tranh |
| :-------------------------------------------------------: |
| <img src="../../../screenshots/competition-page.png" width="400"/> |
</details>
---
## Cài đặt
### Linux / macOS
### Tùy chọn 2: Cài đặt Thủ công
```bash
curl -fsSL https://raw.githubusercontent.com/NoFxAiOS/nofx/main/install.sh | bash
```
# Yêu cầu: Go 1.21+, Node.js 18+, TA-Lib
### Railway (Cloud)
# Cài đặt TA-Lib (macOS)
brew install ta-lib
[![Deploy on Railway](https://railway.com/button.svg)](https://railway.com/deploy/nofx?referralCode=nofx)
# Clone và thiết lập
git clone https://github.com/NoFxAiOS/nofx.git
cd nofx
go mod download
cd web && npm install && cd ..
### Docker
```bash
curl -O https://raw.githubusercontent.com/NoFxAiOS/nofx/main/docker-compose.prod.yml
docker compose -f docker-compose.prod.yml up -d
```
### Windows
Cài [Docker Desktop](https://www.docker.com/products/docker-desktop/), sau đó:
```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
```
### Build từ source
```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
# Khởi động backend
go build -o nofx && ./nofx
cd web && npm install && npm run dev
```
### Cập nhật
```bash
curl -fsSL https://raw.githubusercontent.com/NoFxAiOS/nofx/main/install.sh | bash
# Khởi động frontend (terminal mới)
cd web && npm run dev
```
---
## Thiết lập
## Thiết Lập Ban Đầu
**Chế độ người mới**: onboarding có hướng dẫn giúp người dùng mới hoàn tất truy cập mô hình, kết nối sàn, cấu hình chiến lược và lần triển khai đầu tiên.
**Chế độ nâng cao**:
1. Cấu hình truy cập mô hình AI
2. Kết nối thông tin xác thực sàn
3. Xây dựng hoặc import chiến lược
4. Tạo hồ sơ AI trader
5. Khởi chạy, giám sát và cải thiện từ dashboard
Tất cả cấu hình có trong Web UI tại **http://127.0.0.1:3000**.
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
---
## Triển khai lên máy chủ
## Cảnh Báo Rủi Ro
**Triển khai HTTP:**
```bash
curl -fsSL https://raw.githubusercontent.com/NoFxAiOS/nofx/main/install.sh | bash
# Truy cập qua http://YOUR_IP:3000
```
**HTTPS qua Cloudflare:**
1. Thêm domain vào [Cloudflare](https://dash.cloudflare.com) (gói miễn phí)
2. Bản ghi A → IP máy chủ của bạn (Proxied)
3. SSL/TLS → Flexible
4. Đặt `TRANSPORT_ENCRYPTION=true` trong `.env`
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ý
---
## Kiến trúc
## Giấy Phép
```
NOFX
┌─────────────────────────────────────────────────┐
│ Trading Terminal │
│ React + TypeScript + TradingView │
│ US Stocks · Commodities · Forex · Crypto │
├─────────────────────────────────────────────────┤
│ API Server (Go) │
├──────────────┬──────────────┬───────────────────┤
│ Strategy │ Telegram │ Trader Runtime │
│ Engine │ Agent │ Risk Controls │
├──────────────┴──────────────┴───────────────────┤
│ AI Model Layer │
│ Unified provider access through Claw402 │
│ Model routing · payment · execution support │
├─────────────────────────────────────────────────┤
│ Exchange Connectivity │
│ Binance · Bybit · OKX · Hyperliquid · Bitget │
│ KuCoin · Gate · Aster · Lighter │
└─────────────────────────────────────────────────┘
```
**GNU Affero General Public License v3.0 (AGPL-3.0)**
---
## Tài liệu
## Liên Hệ
| | |
| :--- | :--- |
| [Kiến trúc](../../architecture/README.md) | Thiết kế hệ thống và chỉ mục module |
| [Module chiến lược](../../architecture/STRATEGY_MODULE.md) | Chọn công cụ, prompt AI, thực thi |
| [FAQ](../../faq/README.md) | Câu hỏi thường gặp |
| [Bắt đầu](../../getting-started/README.md) | Hướng dẫn triển khai |
---
## Đóng góp
Xem [Contributing Guide](../../../CONTRIBUTING.md), [Code of Conduct](../../../CODE_OF_CONDUCT.md), và [Security Policy](../../../SECURITY.md).
### Chương trình contributor
NOFX ghi nhận các đóng góp có ý nghĩa và dự định thưởng cho contributor khi hệ sinh thái phát triển. Issue ưu tiên có trọng số thưởng cao hơn.
| Contribution | Weight |
| :--- | :---: |
| Pinned Issue PRs | ★★★★★★ |
| Code (Merged PRs) | ★★★★★ |
| Bug Fixes | ★★★★ |
| Feature Ideas | ★★★ |
| Bug Reports | ★★ |
| Documentation | ★★ |
---
## Liên kết
| | |
| :--- | :--- |
| Website | [vergex.trade](https://vergex.trade) |
| Dashboard | [vergex.trade/explore](https://vergex.trade/explore) |
| Telegram | [nofx_dev_community](https://t.me/nofx_dev_community) |
| Twitter | [@vergex_ai](https://x.com/vergex_ai) |
> **Cảnh báo rủi ro**: Giao dịch tự động có rủi ro đáng kể. Hãy dùng kích thước vị thế phù hợp, hiểu từng venue và không giao dịch số vốn bạn không thể mất.
---
## Nhà tài trợ
<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>
[Trở thành nhà tài trợ](https://github.com/sponsors/NoFxAiOS)
## License
[AGPL-3.0](../../../LICENSE)
[![Star History Chart](https://api.star-history.com/svg?repos=NoFxAiOS/nofx&type=Date)](https://star-history.com/#NoFxAiOS/nofx&Date)
- **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,331 +1,450 @@
<p align="center"><strong><a href="https://vergex.trade">vergex.trade</a> 支持</strong></p>
# NOFX - AI 交易系统
<h1 align="center">NOFX</h1>
<p align="center">
<strong>面向全球市场的 AI 交易终端。</strong><br/>
<strong>覆盖美股、大宗商品、外汇与加密市场的研究、策略生成、执行与监控。</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>
</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 交易终端,面向需要统一工作区完成市场研究、策略开发、交易执行与组合监控的活跃交易者。
产品围绕全球高流动性市场设计美股、大宗商品合约、外汇货币对与数字资产。AI 层将交易意图转化为观察列表、信号、策略逻辑、风控约束与执行工作流。
```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)
---
## 注册交易所
## AI 驱动的加密货币交易平台
通过以下链接开通交易账户,可交易加密资产以及平台支持的美股、外汇和大宗商品衍生品市场。这些链接来自 NOFX 合作伙伴计划,可能包含手续费折扣或推荐权益
**NOFX** 是一个开源的 AI 交易系统,让你可以运行多个 AI 模型自动交易加密货币期货。通过 Web 界面配置策略,实时监控表现,让多个 AI 代理竞争找出最佳交易方案
| 交易所 | 状态 | 享手续费折扣注册 |
| :--- | :---: | :--- |
| <img src="../../../web/public/exchange-icons/binance.jpg" width="20" height="20" style="vertical-align: middle;"/> **Binance** | ✅ | [注册](https://www.binance.com/join?ref=NOFXENG) |
| <img src="../../../web/public/exchange-icons/bybit.png" width="20" height="20" style="vertical-align: middle;"/> **Bybit** | ✅ | [注册](https://partner.bybit.com/b/83856) |
| <img src="../../../web/public/exchange-icons/okx.svg" width="20" height="20" style="vertical-align: middle;"/> **OKX** | ✅ | [注册](https://www.okx.com/join/1865360) |
| <img src="../../../web/public/exchange-icons/hyperliquid.png" width="20" height="20" style="vertical-align: middle;"/> **Hyperliquid** | ✅ | [注册](https://app.hyperliquid.xyz/join/AITRADING) |
| <img src="../../../web/public/exchange-icons/bitget.svg" width="20" height="20" style="vertical-align: middle;"/> **Bitget** | ✅ | [注册](https://www.bitget.com/referral/register?from=referral&clacCode=c8a43172) |
| <img src="../../../web/public/exchange-icons/kucoin.svg" width="20" height="20" style="vertical-align: middle;"/> **KuCoin** | ✅ | [注册](https://www.kucoin.com/r/broker/CXEV7XKK) |
| <img src="../../../web/public/exchange-icons/gate.svg" width="20" height="20" style="vertical-align: middle;"/> **Gate** | ✅ | [注册](https://www.gatenode.xyz/share/VQBGUAxY) |
| <img src="../../../web/public/exchange-icons/aster.svg" width="20" height="20" style="vertical-align: middle;"/> **Aster** | ✅ | [注册](https://www.asterdex.com/en/referral/fdfc0e) |
| <img src="../../../web/public/exchange-icons/lighter.png" width="20" height="20" style="vertical-align: middle;"/> **Lighter** | ✅ | [注册](https://app.lighter.xyz/?referral=68151432) |
### 核心功能
- **多 AI 支持**: 运行 DeepSeek、通义千问、GPT、Claude、Gemini、Grok、Kimi - 随时切换模型
- **多交易所**: 在 Binance、Bybit、OKX、Hyperliquid、Aster DEX、Lighter 统一交易
- **策略工作室**: 可视化策略构建器,配置币种来源、指标和风控参数
- **AI 竞赛模式**: 多个 AI 交易员实时竞争,并排追踪表现
- **Web 配置**: 无需编辑 JSON - 通过 Web 界面完成所有配置
- **实时仪表板**: 实时持仓、盈亏追踪、AI 决策日志与思维链
### 核心团队
- **Tinkle** - [@Web3Tinkle](https://x.com/Web3Tinkle)
- **官方 Twitter** - [@nofx_official](https://x.com/nofx_official)
### 官方链接
- **官网**: [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)**
---
## 快速演示
## 开始之前
<p align="center">
<a href="https://drive.google.com/file/d/1frzw-HDZ3viQvLOQKsAJGc9bT0dXs68D/view">
<img src="../../../screenshots/demo-cover.png" alt="NOFX quick demo video" width="900"/>
</a>
</p>
使用 NOFX 你需要准备:
<p align="center">
点击封面图观看演示视频。
</p>
1. **交易所账户** - 在任意支持的交易所注册并创建具有交易权限的 API 凭证
2. **AI 模型 API Key** - 从任意支持的提供商获取(推荐 DeepSeek性价比最高
---
## 市场
## 支持的交易所
**美股 · 大宗商品 · 外汇 · 加密资产**
### CEX (中心化交易所)
NOFX 按多资产工作流组织研究、策略构建、执行与监控,而不是停留在单一交易所界面。
| 交易所 | 状态 | 注册 (手续费折扣) |
|----------|--------|-------------------------|
| **Binance** | ✅ 已支持 | [注册](https://www.binance.com/join?ref=NOFXENG) |
| **Bybit** | ✅ 已支持 | [注册](https://partner.bybit.com/b/83856) |
| **OKX** | ✅ 已支持 | [注册](https://www.okx.com/join/1865360) |
| **Bitget** | ✅ 已支持 | [注册](https://www.bitget.com/referral/register?from=referral&clacCode=c8a43172) |
### Perp-DEX (去中心化永续交易所)
| 交易所 | 状态 | 注册 (手续费折扣) |
|----------|--------|-------------------------|
| **Hyperliquid** | ✅ 已支持 | [注册](https://app.hyperliquid.xyz/join/AITRADING) |
| **Aster DEX** | ✅ 已支持 | [注册](https://www.asterdex.com/en/referral/fdfc0e) |
| **Lighter** | ✅ 已支持 | [注册](https://app.lighter.xyz/?referral=68151432) |
---
## AI 模型接入
## 支持的 AI 模型
NOFX 自动通过 [Claw402](https://claw402.ai) 路由 AI 推理请求。用户无需配置大模型供应商、管理 API Key 或维护独立 AI 账户。终端按需按次调用 Claw402 的 AI 模型基础设施,并通过官方折扣通道完成路由。
| 提供商 | 接入 |
| :--- | :--- |
| **Claw402** | [通过官方折扣通道按需使用 AI 模型](https://claw402.ai) |
---
## 能力
| 能力 | 描述 |
| :--- | :--- |
| **AI 交易终端** | 面向美股、大宗商品、外汇与加密资产的一体化工作区 |
| **AI 模型接入** | 通过 Claw402 自动接入支持的模型供应商 |
| **交易所连接** | Binance、Bybit、OKX、Hyperliquid、Bitget、KuCoin、Gate、Aster、Lighter |
| **策略工作室** | 市场范围、指标、风控与策略逻辑 |
| **模型竞赛** | 比较 AI 交易员的实时表现与排行榜 |
| **Telegram Agent** | 通过聊天控制和监控交易助手 |
| **组合仪表板** | 持仓、盈亏、执行历史与模型决策日志 |
| AI 模型 | 状态 | 获取 API Key |
|----------|--------|-------------|
| **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) |
---
## 截图
<details>
<summary><b>配置页</b></summary>
### 竞赛模式 - 实时 AI 对战
![竞赛页面](../../../screenshots/competition-page.png)
*多 AI 排行榜,实时性能对比*
| 配置 | 交易员列表 |
| :----------------------------------------------------: | :----------------------------------------------------------: |
| <img src="../../../screenshots/config-ai-exchanges.png" width="400"/> | <img src="../../../screenshots/config-traders-list.png" width="400"/> |
### 仪表板 - 市场图表视图
![仪表板市场图表](../../../screenshots/dashboard-market-chart.png)
*专业交易仪表板TradingView 风格图表*
</details>
<details>
<summary><b>仪表板</b></summary>
| 概览 | 行情图表 |
| :-----------------------------------------------------: | :-------------------------------------------------------------: |
| <img src="../../../screenshots/dashboard-page.png" width="400"/> | <img src="../../../screenshots/dashboard-market-chart.png" width="400"/> |
| 交易统计 | 持仓历史 |
| :--------------------------------------------------------------: | :-----------------------------------------------------------------: |
| <img src="../../../screenshots/dashboard-trading-stats.png" width="400"/> | <img src="../../../screenshots/dashboard-position-history.png" width="400"/> |
| 持仓 | 交易员详情 |
| :----------------------------------------------------------: | :---------------------------------------------------: |
| <img src="../../../screenshots/dashboard-positions.png" width="400"/> | <img src="../../../screenshots/details-page.png" width="400"/> |
</details>
<details>
<summary><b>策略工作室</b></summary>
| 策略编辑器 | 指标配置 |
| :------------------------------------------------------: | :----------------------------------------------------------: |
| <img src="../../../screenshots/strategy-studio.png" width="400"/> | <img src="../../../screenshots/strategy-indicators.png" width="400"/> |
</details>
<details>
<summary><b>竞赛</b></summary>
| 竞赛模式 |
| :-------------------------------------------------------: |
| <img src="../../../screenshots/competition-page.png" width="400"/> |
</details>
### 策略工作室
![策略工作室](../../../screenshots/strategy-studio.png)
*多数据源策略配置与 AI 测试*
---
## 安装
## 快速开始
### Linux / macOS
### 一键安装 (本地/服务器)
**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
# 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
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
```
---
此命令会拉取最新官方镜像并自动重启服务。
## 配置
### 手动安装 (开发者)
**新手模式**:引导式 onboarding 帮助新用户完成模型访问、交易所连接、策略配置与首次部署。
#### 前置条件
**进阶模式**
1. 配置 AI 模型访问
2. 连接交易所凭证
3. 构建或导入策略
4. 创建 AI 交易员配置
5. 在仪表板启动、监控并迭代
所有配置均可在 Web UI **http://127.0.0.1:3000** 完成。
---
## 部署到服务器
**HTTP 部署:**
- **Go 1.21+**
- **Node.js 18+**
- **TA-Lib** (技术指标库)
```bash
curl -fsSL https://raw.githubusercontent.com/NoFxAiOS/nofx/main/install.sh | bash
# 通过 http://YOUR_IP:3000 访问
# 安装 TA-Lib
# macOS
brew install ta-lib
# Ubuntu/Debian
sudo apt-get install libta-lib0-dev
```
**通过 Cloudflare 启用 HTTPS**
#### 安装步骤
1. 在 [Cloudflare](https://dash.cloudflare.com)(免费套餐)添加域名
2. A 记录指向你的服务器 IP开启代理
3. SSL/TLS 选择 Flexible
4.`.env` 中设置 `TRANSPORT_ENCRYPTION=true`
```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 安装
### 方法一Docker Desktop推荐
1. **安装 Docker Desktop**
- 从 [docker.com/products/docker-desktop](https://www.docker.com/products/docker-desktop/) 下载
- 运行安装程序并重启电脑
- 启动 Docker Desktop 并等待就绪
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
```
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
```
NOFX
┌─────────────────────────────────────────────────┐
│ Trading Terminal │
│ React + TypeScript + TradingView │
│ US Stocks · Commodities · Forex · Crypto │
├─────────────────────────────────────────────────┤
│ API Server (Go) │
├──────────────┬──────────────┬───────────────────┤
│ Strategy │ Telegram │ Trader Runtime │
│ Engine │ Agent │ Risk Controls │
├──────────────┴──────────────┴───────────────────┤
│ AI Model Layer │
│ Unified provider access through Claw402 │
│ Model routing · payment · execution support │
├─────────────────────────────────────────────────┤
│ Exchange Connectivity │
│ Binance · Bybit · OKX · Hyperliquid · Bitget │
│ KuCoin · Gate · Aster · Lighter │
└─────────────────────────────────────────────────┘
通过 `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)。
### 贡献者计划
NOFX 会记录有价值的贡献,并计划在生态增长后回馈贡献者。优先级 Issue 拥有更高奖励权重。
| Contribution | Weight |
| :--- | :---: |
| Pinned Issue PRs | ★★★★★★ |
| Code (Merged PRs) | ★★★★★ |
| Bug Fixes | ★★★★ |
| Feature Ideas | ★★★ |
| Bug Reports | ★★ |
| Documentation | ★★ |
欢迎贡献!查看:
- **[贡献指南](../../../CONTRIBUTING.md)** - 开发流程和 PR 流程
- **[行为准则](../../../CODE_OF_CONDUCT.md)** - 社区准则
- **[安全政策](../../../SECURITY.md)** - 报告漏洞
---
## 链接
## 贡献者空投计划
| | |
| :--- | :--- |
| 官网 | [vergex.trade](https://vergex.trade) |
| Dashboard | [vergex.trade/explore](https://vergex.trade/explore) |
| Telegram | [nofx_dev_community](https://t.me/nofx_dev_community) |
| Twitter | [@vergex_ai](https://x.com/vergex_ai) |
所有贡献都在 GitHub 上追踪。当 NOFX 产生收入时,贡献者将根据其贡献获得空投。
> **风险提示**:自动化交易存在重大风险。请控制仓位,理解每个交易场所的机制,不要投入无法承受损失的资金。
**解决 [置顶 Issue](https://github.com/NoFxAiOS/nofx/issues) 的 PR 获得最高奖励!**
| 贡献类型 | 权重 |
|------------------|:------:|
| **置顶 Issue PR** | ⭐⭐⭐⭐⭐⭐ |
| **代码提交** (合并的 PR) | ⭐⭐⭐⭐⭐ |
| **Bug 修复** | ⭐⭐⭐⭐ |
| **功能建议** | ⭐⭐⭐ |
| **Bug 报告** | ⭐⭐ |
| **文档** | ⭐⭐ |
---
## 赞助者
## 联系方式
<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>
- **GitHub Issues**: [提交 Issue](https://github.com/NoFxAiOS/nofx/issues)
- **开发者社区**: [Telegram 群组](https://t.me/nofx_dev_community)
[成为赞助者](https://github.com/sponsors/NoFxAiOS)
---
## 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)

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