40 Commits

Author SHA1 Message Date
tinkle-community
a3b56a98bf fix(grid): add isRunning checks to prevent trades after Stop() is called 2026-01-18 20:06:41 +08:00
tinkle-community
e5f69bfea6 fix(grid): improve GridRiskPanel layout and fix liquidation data
- Make panel collapsible with summary badges when collapsed
- Use compact 2-column grid layout for detailed info
- Fix auth token key (token -> auth_token)
- Only calculate liquidation distance when position exists
2026-01-18 18:19:15 +08:00
tinkle-community
e198498f3a Merge branch 'dev' into ai-grid 2026-01-18 14:44:28 +08:00
tinkle-community
993db33466 fix: remove hardcoded test wallet address 2026-01-17 23:12:12 +08:00
tinkle-community
7f24a90851 feat(lighter): improve API key validation and market caching
- Add API key validation status tracking
- Add market list caching to reduce API calls
- Improve logging (debug vs info levels)
- Add comprehensive integration tests
- Update trader manager and store for lighter support
2026-01-17 23:07:35 +08:00
tinkle-community
19698529b8 feat(web): integrate GridRiskPanel into TraderDashboardPage 2026-01-17 23:01:30 +08:00
tinkle-community
35fcf17df4 feat(kernel): add box indicators to AI prompt
- Add BoxData field to GridContext
- Add box indicator table to both zh/en prompts
- Show breakout/warning alerts based on price position
2026-01-17 22:05:10 +08:00
tinkle-community
2b1012b85b feat: add grid risk panel with API endpoint
- Task 13: Add GridRiskInfo type to frontend
- Task 14: Add /traders/:id/grid-risk API endpoint
- Task 15: Add GetGridRiskInfo method to AutoTrader
- Task 16: Create GridRiskPanel component with i18n
2026-01-17 22:02:45 +08:00
tinkle-community
826276f58c feat(trader): integrate box breakout detection into grid cycle
- Task 10: Add checkBoxBreakout with 3-candle confirmation
- Task 11: Add checkFalseBreakoutRecovery for 50% position recovery
- Task 12: Add box/breakout/regime fields to GridState
2026-01-17 21:56:37 +08:00
tinkle-community
587efba52c feat(trader): add regime classification and breakout detection
Implements Tasks 6-9 for grid market regime awareness:
- Task 6: classifyRegimeLevel with Bollinger/ATR thresholds
- Task 7: detectBoxBreakout for multi-period box breakouts
- Task 8: confirmBreakout with 3-candle confirmation logic
- Task 9: getBreakoutAction mapping breakout levels to actions
2026-01-17 21:52:34 +08:00
tinkle-community
4642671e77 feat(store): add box and regime fields to grid models 2026-01-17 21:49:30 +08:00
tinkle-community
bd8cc9c176 feat(market): add GetBoxData for multi-period box calculation
Adds calculateBoxData internal function and GetBoxData public API that
fetches 1h klines and computes three Donchian box levels (short/mid/long).
This will be used by the grid trading system to detect market regime.
2026-01-17 21:47:07 +08:00
tinkle-community
7d7493b576 feat(market): add BoxData and RegimeLevel types 2026-01-17 21:44:25 +08:00
tinkle-community
cbe753b9e6 fix(market): handle invalid period in calculateDonchian 2026-01-17 21:43:14 +08:00
tinkle-community
5c79aa451e feat(market): add Donchian channel calculation
Add calculateDonchian function to compute highest high and lowest low
over a specified period. This is the foundation for box (range) detection
in the multi-period box indicator system for grid trading.
2026-01-17 21:39:21 +08:00
tinkle-community
0a2c62885b docs: add grid market regime implementation plan
20 tasks covering:
- Donchian channel calculation
- Box data types and API
- Regime classification (4 levels)
- Breakout detection and handling
- False breakout recovery
- Frontend risk panel
- AI prompt updates
2026-01-17 20:51:08 +08:00
tinkle-community
ac25dd334e docs: add grid market regime detection design
Design for enhanced market state recognition with:
- Multi-dimensional indicators (ATR, Bollinger, EMA, MACD, RSI)
- Multi-period box indicators (72/240/500 1h candles)
- 4-level ranging classification
- Breakout detection and handling
- Frontend risk control panel
2026-01-17 20:46:02 +08:00
tinkle-community
f4cdf2e532 fix(grid): use actual PositionSize sum instead of count in syncGridState heuristic
The position-based heuristic was using `float64(previousFilledCount) * level.OrderQuantity`
which incorrectly assumed uniform order quantities. Since the grid uses weighted distribution
(gaussian, pyramid, uniform) where orders have different quantities, this could lead to
incorrect fill detection.

Now sums the actual PositionSize from filled levels for accurate comparison.
Also adds warning log when GetPositions() fails.
2026-01-14 13:02:58 +08:00
tinkle-community
f6411f05ba fix(grid): improve order state sync logic
- Don't assume missing orders are filled
- Compare position size to determine fill vs cancel
- Properly reset cancelled orders to empty state
- More accurate grid state tracking
2026-01-14 12:58:50 +08:00
tinkle-community
38be361eca fix(grid): recalculate bounds in autoAdjustGrid before reinitializing levels
Critical fix for grid auto-adjustment:
- Recalculate grid bounds (UpperPrice, LowerPrice, GridSpacing) centered
  on current price before reinitializing grid levels
- Preserve filled positions during adjustment by saving and restoring
  them to the closest new level after reinitialization
- Hold mutex lock for the entire adjustment operation to ensure atomicity
- Add locked variants of calculateDefaultBounds, calculateATRBounds, and
  initializeGridLevels to use during adjustment

Without this fix, autoAdjustGrid was using old boundaries when creating
new grid levels, defeating the purpose of auto-adjustment when price
moved significantly.
2026-01-14 12:54:26 +08:00
tinkle-community
584bfae699 feat(grid): add automatic grid adjustment
- New checkGridSkew() detects imbalanced grid
- autoAdjustGrid() reinitializes around current price
- Prevents grid from becoming ineffective after drift
- Triggers when one side is 3x more filled than other
2026-01-14 12:49:22 +08:00
tinkle-community
73789f7fb7 fix(grid): update daily PnL when stop loss is executed
The updateDailyPnL() function was added but never called, leaving
DailyPnL always at 0 and preventing daily loss limit checks from
triggering.

This fix updates DailyPnL and TotalProfit directly in checkAndExecuteStopLoss()
when a stop loss is executed. We update directly rather than calling
updateDailyPnL() because the mutex is already held in that function.
2026-01-14 12:45:31 +08:00
tinkle-community
65f333e73c feat(grid): enforce daily loss limit
- Add checkDailyLossLimit() function to check if daily loss exceeds limit
- Track daily PnL with auto-reset at midnight
- Pause grid when DailyLossLimitPct exceeded
- Add updateDailyPnL() helper for realized PnL tracking
- Prevent excessive single-day losses
2026-01-14 12:39:33 +08:00
tinkle-community
1454ad3112 feat(grid): enforce max drawdown limit with emergency exit
CRITICAL: Add drawdown protection
- New checkMaxDrawdown() function tracks peak equity
- emergencyExit() closes all positions and cancels orders
- Auto-pause grid when MaxDrawdownPct exceeded
- Protect capital from excessive losses
2026-01-14 12:34:45 +08:00
tinkle-community
ec81384b7a feat(grid): add breakout detection and auto-pause
CRITICAL: Detect price breakout from grid range
- New checkBreakout() function to detect upper/lower breakouts
- Auto-pause grid on significant breakout (>2%)
- Cancel all orders when breakout detected
- Prevent continued losses in trending market
- Minor breakouts (1-2%) logged for AI consideration
2026-01-14 12:30:43 +08:00
tinkle-community
c161632e2b feat(grid): implement stop loss execution
CRITICAL: Add code-level stop loss protection
- New checkAndExecuteStopLoss() function
- Checks each filled level against StopLossPct
- Automatically closes positions exceeding stop loss
- Called during every grid state sync
2026-01-14 12:25:59 +08:00
tinkle-community
8ef6045f9d fix(grid): add total position value limit check
CRITICAL: Prevent excessive position accumulation
- New checkTotalPositionLimit() function
- Checks current + pending + new order value
- Rejects orders that would exceed TotalInvestment x Leverage
- Logs clear error messages when limit exceeded
2026-01-14 12:21:43 +08:00
tinkle-community
d7d9dc5c42 fix(grid): prevent CancelOrder from canceling all orders
CRITICAL BUG FIX:
- CancelOrder no longer calls CancelAllOrders
- Try exchange-specific CancelOrder if available
- Return error if individual cancellation not supported
2026-01-14 12:18:24 +08:00
tinkle-community
90509ae783 fix(grid): add leverage setting before order placement
CRITICAL BUG FIX:
- Call SetLeverage() in GridTraderAdapter.PlaceLimitOrder()
- Set leverage during grid initialization
- Log leverage setting results
2026-01-14 12:14:08 +08:00
tinkle-community
937527281e test: add Lighter API authentication tests and diagnostic tools 2026-01-13 14:03:28 +08:00
tinkle-community
2bc45827f3 fix: use auth query parameter instead of Authorization header for Lighter API 2026-01-13 13:51:58 +08:00
tinkle-community
68e8a6e4b0 fix: provide FromAccountIndex and ApiKeyIndex for Lighter nonce auto-fetch
Root cause: SDK requires these fields to fetch nonce from API, otherwise nonce gets cached/stuck
2026-01-13 13:42:14 +08:00
tinkle-community
aa7aa94275 fix: address code review issues for GetOpenOrders
- Add error logging for OKX/Bitget API failures (was silently swallowed)
- Fix Lighter position side logic to handle reduce-only orders
- Change verbose debug logs from Infof to Debugf level
2026-01-13 13:38:09 +08:00
tinkle-community
13189fa3aa feat: implement GetOpenOrders for Aster, OKX, Bitget exchanges
- Aster: uses /fapi/v3/openOrders endpoint
- OKX: uses /api/v5/trade/orders-pending and orders-algo-pending
- Bitget: uses /api/v2/mix/order/orders-pending and orders-plan-pending
2026-01-13 13:34:43 +08:00
tinkle-community
33cf09e7fe fix: correct Lighter API response parsing for GetOpenOrders
- Changed response field from 'data' to 'orders' to match Lighter API
- Updated OrderResponse struct to match Lighter's actual field names
- Fixed field types: price/quantity as strings, is_ask for side
2026-01-13 13:28:18 +08:00
tinkle-community
ef91bec2dd debug: add logging for Lighter GetActiveOrders API call 2026-01-13 13:24:57 +08:00
tinkle-community
2fcbdbab36 fix: implement GetOpenOrders for Lighter exchange 2026-01-13 13:19:11 +08:00
tinkle-community
1786f0ff53 Merge branch 'dev' into ai-grid 2026-01-13 13:10:57 +08:00
tinkle-community
1b47249d57 Merge branch 'dev' into ai-grid 2026-01-13 13:07:01 +08:00
tinkle-community
5fb26c17dc feat: add AI grid trading and market regime classification
- Add GridTrader interface with PlaceLimitOrder, CancelOrder, GetOrderBook
- Implement GridTrader for all exchanges (Binance, Bybit, OKX, Bitget, Hyperliquid, Aster, Lighter)
- Add grid engine with ATR-based boundary calculation and fund distribution
- Add market regime classification documents (Chinese/English)
- Add GridConfigEditor component for frontend configuration
2026-01-13 10:33:02 +08:00
507 changed files with 60281 additions and 74668 deletions

View File

@@ -52,6 +52,10 @@ TRANSPORT_ENCRYPTION=false
# Optional: External Services # Optional: External Services
# =========================================== # ===========================================
# Telegram notifications (optional)
# TELEGRAM_BOT_TOKEN=your-bot-token
# TELEGRAM_CHAT_ID=your-chat-id
DB_TYPE=postgres DB_TYPE=postgres
DB_HOST=10. DB_HOST=10.
DB_PORT=5432 DB_PORT=5432
@@ -61,6 +65,6 @@ DB_NAME=nofx
DB_SSLMODE=disable DB_SSLMODE=disable
# Database configuration - SQLite (default) # 数据库配置 - SQLite默认
DB_TYPE=sqlite DB_TYPE=sqlite
DB_PATH=data/data.db DB_PATH=data/data.db

View File

@@ -1,50 +1,100 @@
## Summary # Pull Request
- Problem: > **📋 Choose Specialized Template**
- What changed: >
- What did NOT change (scope boundary): > We now offer specialized templates for different types of PRs to help you fill out the information faster:
>
> - 🔧 **[Backend PR Template](./PULL_REQUEST_TEMPLATE/backend.md)** - For Go/API/Trading changes
> - 🎨 **[Frontend PR Template](./PULL_REQUEST_TEMPLATE/frontend.md)** - For UI/UX changes
> - 📝 **[Documentation PR Template](./PULL_REQUEST_TEMPLATE/docs.md)** - For documentation updates
> - 📦 **[General PR Template](./PULL_REQUEST_TEMPLATE/general.md)** - For mixed or other changes
>
> **How to use?**
> - When creating a PR, add `?template=backend.md` or other template name to the URL
> - Or simply copy and paste the content from the corresponding template
## Change Type ---
- [ ] Bug fix > **💡 Tip:** Recommended PR title format `type(scope): description`
- [ ] Feature > Example: `feat(trader): add new strategy` | `fix(api): resolve auth issue`
- [ ] Refactoring
- [ ] Docs
- [ ] Security fix
- [ ] Chore / infra
## Scope ---
- [ ] Trading engine / strategies ## 📝 Description
- [ ] MCP / AI clients
- [ ] API / server
- [ ] Telegram bot / agent
- [ ] Web UI / frontend
- [ ] Config / deployment
- [ ] CI/CD / infra
## Linked Issues <!-- Describe your changes in detail -->
---
## 🎯 Type of Change
- [ ] 🐛 Bug fix
- [ ] ✨ New feature
- [ ] 💥 Breaking change
- [ ] 📝 Documentation update
- [ ] 🎨 Code style update
- [ ] ♻️ Refactoring
- [ ] ⚡ Performance improvement
- [ ] ✅ Test update
- [ ] 🔧 Build/config change
- [ ] 🔒 Security fix
---
## 🔗 Related Issues
- Closes # - Closes #
- Related # - Related to #
## Testing ---
What you verified and how: ## 📋 Changes Made
- [ ] `go build ./...` passes <!-- List the specific changes made -->
- [ ] `go test ./...` passes -
- [ ] Manual testing done (describe below) -
## Security Impact ---
- Secrets/keys handling changed? (`Yes/No`) ## 🧪 Testing
- New/changed API endpoints? (`Yes/No`)
- User input validation affected? (`Yes/No`)
## Compatibility - [ ] Tested locally
- [ ] Tests pass
- [ ] Verified no existing functionality broke
- Backward compatible? (`Yes/No`) ---
- Config/env changes? (`Yes/No`)
- Migration needed? (`Yes/No`) ## ✅ Checklist
- If yes, upgrade steps:
### Code Quality
- [ ] Code follows project style
- [ ] Self-review completed
- [ ] Comments added for complex logic
### Documentation
- [ ] Updated relevant documentation
### Git
- [ ] Commits follow conventional format
- [ ] Rebased on latest `dev` branch
- [ ] No merge conflicts
---
## 📚 Additional Notes
<!-- Any additional information or context -->
---
**By submitting this PR, I confirm:**
- [ ] I have read the [Contributing Guidelines](../CONTRIBUTING.md)
- [ ] I agree to the [Code of Conduct](../CODE_OF_CONDUCT.md)
- [ ] My contribution is licensed under AGPL-3.0
---
🌟 **Thank you for your contribution!**

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

View File

@@ -1,7 +1,7 @@
name: PR Docker Build Check name: PR Docker Build Check
# Lightweight build check on PR only, no image push # PR 时只做轻量级构建检查,不推送镜像
# Strategy: Quick verify amd64 + spot check arm64 (backend only) # 策略: 快速验证 amd64 + 抽样检查 arm64 (backend only)
on: on:
pull_request: pull_request:
branches: branches:
@@ -18,7 +18,7 @@ on:
- '.github/workflows/pr-docker-check.yml' - '.github/workflows/pr-docker-check.yml'
jobs: jobs:
# Quick check: amd64 builds for all images # 快速检查: 所有镜像的 amd64 版本
docker-build-amd64: docker-build-amd64:
name: Build Check (amd64) name: Build Check (amd64)
runs-on: ubuntu-22.04 runs-on: ubuntu-22.04
@@ -31,7 +31,7 @@ jobs:
include: include:
- name: backend - name: backend
dockerfile: ./docker/Dockerfile.backend dockerfile: ./docker/Dockerfile.backend
test_run: true # Needs test run test_run: true # 需要测试运行
- name: frontend - name: frontend
dockerfile: ./docker/Dockerfile.frontend dockerfile: ./docker/Dockerfile.frontend
test_run: true test_run: true
@@ -51,7 +51,7 @@ jobs:
file: ${{ matrix.dockerfile }} file: ${{ matrix.dockerfile }}
platforms: linux/amd64 platforms: linux/amd64
push: false push: false
load: true # Load into local Docker for test run load: true # 加载到本地 Docker,用于测试运行
tags: nofx-${{ matrix.name }}:pr-test tags: nofx-${{ matrix.name }}:pr-test
cache-from: type=gha,scope=${{ matrix.name }}-amd64 cache-from: type=gha,scope=${{ matrix.name }}-amd64
cache-to: type=gha,mode=max,scope=${{ matrix.name }}-amd64 cache-to: type=gha,mode=max,scope=${{ matrix.name }}-amd64
@@ -66,12 +66,12 @@ jobs:
run: | run: |
echo "🧪 Testing container startup..." echo "🧪 Testing container startup..."
# Start container # 启动容器
docker run -d --name test-${{ matrix.name }} \ docker run -d --name test-${{ matrix.name }} \
--health-cmd="exit 0" \ --health-cmd="exit 0" \
nofx-${{ matrix.name }}:pr-test nofx-${{ matrix.name }}:pr-test
# Wait for container to start (up to 30 seconds) # 等待容器启动 (最多 30 秒)
for i in {1..30}; do for i in {1..30}; do
if docker ps | grep -q test-${{ matrix.name }}; then if docker ps | grep -q test-${{ matrix.name }}; then
echo "✅ Container started successfully" echo "✅ Container started successfully"
@@ -93,7 +93,7 @@ jobs:
echo "📦 Image size: ${SIZE_MB} MB" echo "📦 Image size: ${SIZE_MB} MB"
# Warning thresholds # 警告阈值
if [ "${{ matrix.name }}" = "backend" ] && [ $SIZE_MB -gt 500 ]; then if [ "${{ matrix.name }}" = "backend" ] && [ $SIZE_MB -gt 500 ]; then
echo "⚠️ Warning: Backend image is larger than 500MB" echo "⚠️ Warning: Backend image is larger than 500MB"
elif [ "${{ matrix.name }}" = "frontend" ] && [ $SIZE_MB -gt 200 ]; then elif [ "${{ matrix.name }}" = "frontend" ] && [ $SIZE_MB -gt 200 ]; then
@@ -102,10 +102,10 @@ jobs:
echo "✅ Image size is reasonable" echo "✅ Image size is reasonable"
fi fi
# ARM64 native build check: Uses GitHub native ARM64 runner (fast!) # ARM64 原生构建检查: 使用 GitHub 原生 ARM64 runner (快速!)
docker-build-arm64-native: docker-build-arm64-native:
name: Build Check (arm64 native - backend) name: Build Check (arm64 native - backend)
runs-on: ubuntu-22.04-arm # Native ARM64 runner runs-on: ubuntu-22.04-arm # 原生 ARM64 runner
permissions: permissions:
contents: read contents: read
@@ -113,19 +113,19 @@ jobs:
- name: Checkout code - name: Checkout code
uses: actions/checkout@v4 uses: actions/checkout@v4
# Native ARM64 does not need QEMU, builds directly # 原生 ARM64 不需要 QEMU,直接构建
- name: Set up Docker Buildx - name: Set up Docker Buildx
uses: docker/setup-buildx-action@v3 uses: docker/setup-buildx-action@v3
- name: Build backend image (arm64 native) - name: Build backend image (arm64 native)
uses: docker/build-push-action@v5 uses: docker/build-push-action@v5
timeout-minutes: 15 # Native builds are faster! timeout-minutes: 15 # 原生构建更快!
with: with:
context: . context: .
file: ./docker/Dockerfile.backend file: ./docker/Dockerfile.backend
platforms: linux/arm64 platforms: linux/arm64
push: false push: false
load: true # Load locally for testing load: true # 加载到本地,用于测试
tags: nofx-backend:pr-test-arm64 tags: nofx-backend:pr-test-arm64
cache-from: type=gha,scope=backend-arm64 cache-from: type=gha,scope=backend-arm64
cache-to: type=gha,mode=max,scope=backend-arm64 cache-to: type=gha,mode=max,scope=backend-arm64
@@ -139,12 +139,12 @@ jobs:
run: | run: |
echo "🧪 Testing ARM64 container startup..." echo "🧪 Testing ARM64 container startup..."
# Start container # 启动容器
docker run -d --name test-backend-arm64 \ docker run -d --name test-backend-arm64 \
--health-cmd="exit 0" \ --health-cmd="exit 0" \
nofx-backend:pr-test-arm64 nofx-backend:pr-test-arm64
# Wait for startup # 等待启动
for i in {1..30}; do for i in {1..30}; do
if docker ps | grep -q test-backend-arm64; then if docker ps | grep -q test-backend-arm64; then
echo "✅ ARM64 container started successfully" echo "✅ ARM64 container started successfully"
@@ -165,14 +165,14 @@ jobs:
echo "Using GitHub native ARM64 runner - no QEMU needed!" echo "Using GitHub native ARM64 runner - no QEMU needed!"
echo "Build time is ~3x faster than emulation" echo "Build time is ~3x faster than emulation"
# Aggregate check results # 汇总检查结果
check-summary: check-summary:
name: Docker Build Summary name: Docker Build Summary
needs: [docker-build-amd64, docker-build-arm64-native] needs: [docker-build-amd64, docker-build-arm64-native]
runs-on: ubuntu-22.04 runs-on: ubuntu-22.04
if: always() if: always()
permissions: permissions:
pull-requests: write # For posting comments pull-requests: write # 用于发布评论
steps: steps:
- name: Check build results - name: Check build results
id: check id: check
@@ -180,7 +180,7 @@ jobs:
echo "## 🐳 Docker Build Check Results" >> $GITHUB_STEP_SUMMARY echo "## 🐳 Docker Build Check Results" >> $GITHUB_STEP_SUMMARY
echo "" >> $GITHUB_STEP_SUMMARY echo "" >> $GITHUB_STEP_SUMMARY
# Check amd64 build # 检查 amd64 构建
if [[ "${{ needs.docker-build-amd64.result }}" == "success" ]]; then if [[ "${{ needs.docker-build-amd64.result }}" == "success" ]]; then
echo "✅ **AMD64 builds**: All passed" >> $GITHUB_STEP_SUMMARY echo "✅ **AMD64 builds**: All passed" >> $GITHUB_STEP_SUMMARY
AMD64_OK=true AMD64_OK=true
@@ -189,7 +189,7 @@ jobs:
AMD64_OK=false AMD64_OK=false
fi fi
# Check arm64 build # 检查 arm64 构建
if [[ "${{ needs.docker-build-arm64-native.result }}" == "success" ]]; then if [[ "${{ needs.docker-build-arm64-native.result }}" == "success" ]]; then
echo "✅ **ARM64 build** (native): Backend passed (frontend will be verified after merge)" >> $GITHUB_STEP_SUMMARY echo "✅ **ARM64 build** (native): Backend passed (frontend will be verified after merge)" >> $GITHUB_STEP_SUMMARY
ARM64_OK=true ARM64_OK=true

View File

@@ -1,6 +1,6 @@
name: PR Docker Compose Healthcheck name: PR Docker Compose Healthcheck
# Verify docker-compose.yml healthcheck config works correctly in Alpine containers # 驗證 docker-compose.yml healthcheck 配置在 Alpine 容器中正常工作
on: on:
pull_request: pull_request:
branches: branches:

View File

@@ -1,18 +1,22 @@
name: PR Labeler name: PR Template Suggester
on: on:
pull_request: pull_request:
types: [opened, synchronize, reopened] types: [opened, edited, synchronize]
permissions: permissions:
pull-requests: write pull-requests: write
issues: write
contents: read contents: read
jobs: jobs:
label-pr: suggest-template:
runs-on: ubuntu-latest runs-on: ubuntu-latest
steps: steps:
- name: Analyze PR and apply labels - name: Checkout code
uses: actions/checkout@v4
- name: Analyze PR files and auto-apply template
uses: actions/github-script@v7 uses: actions/github-script@v7
with: with:
github-token: ${{ secrets.GITHUB_TOKEN }} github-token: ${{ secrets.GITHUB_TOKEN }}
@@ -21,72 +25,166 @@ jobs:
owner: context.repo.owner, owner: context.repo.owner,
repo: context.repo.repo, repo: context.repo.repo,
pull_number: context.issue.number, pull_number: context.issue.number,
per_page: 100,
}); });
let goFiles = 0, jsFiles = 0, tsFiles = 0, mdFiles = 0, otherFiles = 0; let goFiles = 0, jsFiles = 0, tsFiles = 0, mdFiles = 0, otherFiles = 0;
let additions = 0, deletions = 0;
for (const file of files) { for (const file of files) {
const name = file.filename.toLowerCase(); const filename = file.filename.toLowerCase();
additions += file.additions || 0; if (filename.endsWith('.go')) goFiles++;
deletions += file.deletions || 0; else if (filename.endsWith('.js') || filename.endsWith('.jsx')) jsFiles++;
if (name.endsWith('.go')) goFiles++; else if (filename.endsWith('.ts') || filename.endsWith('.tsx') || filename.endsWith('.vue')) tsFiles++;
else if (name.endsWith('.js') || name.endsWith('.jsx')) jsFiles++; else if (filename.endsWith('.md')) mdFiles++;
else if (name.endsWith('.ts') || name.endsWith('.tsx') || name.endsWith('.vue')) tsFiles++;
else if (name.endsWith('.md')) mdFiles++;
else otherFiles++; else otherFiles++;
} }
const totalFiles = goFiles + jsFiles + tsFiles + mdFiles + otherFiles; const totalFiles = goFiles + jsFiles + tsFiles + mdFiles + otherFiles;
if (totalFiles === 0) return; if (totalFiles === 0) { console.log('No files changed'); return; }
// --- Scope label --- let suggestedTemplate = null, templateEmoji = '', templateLabel = '';
const labels = [];
if (goFiles / totalFiles > 0.5) labels.push('backend');
else if ((jsFiles + tsFiles) / totalFiles > 0.5) labels.push('frontend');
else if (mdFiles / totalFiles > 0.7) labels.push('documentation');
else labels.push('fullstack');
// --- Size label (like OpenClaw) --- if (goFiles / totalFiles > 0.5) {
const totalChanged = additions + deletions; suggestedTemplate = 'backend'; templateEmoji = '🔧'; templateLabel = 'backend';
const sizeLabels = ['size: XS', 'size: S', 'size: M', 'size: L', 'size: XL']; } else if ((jsFiles + tsFiles) / totalFiles > 0.5) {
let sizeLabel = 'size: XL'; suggestedTemplate = 'frontend'; templateEmoji = '🎨'; templateLabel = 'frontend';
if (totalChanged < 50) sizeLabel = 'size: XS'; } else if (mdFiles / totalFiles > 0.7) {
else if (totalChanged < 200) sizeLabel = 'size: S'; suggestedTemplate = 'docs'; templateEmoji = '📝'; templateLabel = 'documentation';
else if (totalChanged < 500) sizeLabel = 'size: M';
else if (totalChanged < 1000) sizeLabel = 'size: L';
labels.push(sizeLabel);
// Ensure size labels exist
for (const sl of sizeLabels) {
try {
await github.rest.issues.getLabel({ owner: context.repo.owner, repo: context.repo.repo, name: sl });
} catch (e) {
if (e.status === 404) {
await github.rest.issues.createLabel({ owner: context.repo.owner, repo: context.repo.repo, name: sl, color: 'b76e79' });
}
}
} }
// Remove stale size labels const { data: pr } = await github.rest.pulls.get({
const { data: currentLabels } = await github.rest.issues.listLabelsOnIssue({
owner: context.repo.owner, repo: context.repo.repo, issue_number: context.issue.number,
});
for (const cl of currentLabels) {
if (sizeLabels.includes(cl.name) && cl.name !== sizeLabel) {
await github.rest.issues.removeLabel({
owner: context.repo.owner, repo: context.repo.repo, issue_number: context.issue.number, name: cl.name,
}).catch(() => {});
}
}
// Apply labels
await github.rest.issues.addLabels({
owner: context.repo.owner, owner: context.repo.owner,
repo: context.repo.repo, repo: context.repo.repo,
issue_number: context.issue.number, pull_number: context.issue.number,
labels: labels,
}); });
console.log(`Applied labels: ${labels.join(', ')} (${totalChanged} lines changed)`); const prBody = pr.body || '';
const usesBackendTemplate = prBody.includes('Pull Request - Backend');
const usesFrontendTemplate = prBody.includes('Pull Request - Frontend');
const usesDocsTemplate = prBody.includes('Pull Request - Documentation');
const usesGeneralTemplate = prBody.includes('Pull Request - General');
const usingDefaultTemplate = !usesBackendTemplate && !usesFrontendTemplate && !usesDocsTemplate && !usesGeneralTemplate;
if (templateLabel) {
try {
await github.rest.issues.addLabels({
owner: context.repo.owner,
repo: context.repo.repo,
issue_number: context.issue.number,
labels: [templateLabel]
});
console.log('Added label: ' + templateLabel);
} catch (error) {
console.log('Label might not exist, skipping...');
}
}
function isPRBodyEmpty(body) {
if (!body || body.trim().length < 100) return true;
const hasEmptyDescription = body.includes('**English:**') && body.match(/\*\*English:\*\*\s*\n\s*\n\s*\n/);
const hasEmptyChanges = body.includes('具体变更') && body.match(/\*\*中文:\*\*\s*\n\s*-\s*\n\s*-\s*\n/);
if (hasEmptyDescription || hasEmptyChanges) return true;
const descMatch = body.match(/\*\*English:\*\*[|]\s*\*\*中文:\*\*\s*\n\s*(.+)/);
if (!descMatch || descMatch[1].trim().length < 10) return true;
return false;
}
if (suggestedTemplate && usingDefaultTemplate) {
const shouldAutoApply = isPRBodyEmpty(prBody);
const templatePath = '.github/PULL_REQUEST_TEMPLATE/' + suggestedTemplate + '.md';
if (shouldAutoApply) {
try {
const { data: templateFile } = await github.rest.repos.getContent({
owner: context.repo.owner,
repo: context.repo.repo,
path: templatePath,
ref: context.payload.pull_request.head.ref
});
const templateContent = Buffer.from(templateFile.content, 'base64').toString('utf-8');
await github.rest.pulls.update({
owner: context.repo.owner,
repo: context.repo.repo,
pull_number: context.issue.number,
body: templateContent
});
console.log('Auto-applied ' + suggestedTemplate + ' template');
let fileStats = [];
if (goFiles > 0) fileStats.push('- 🔧 Go files: ' + goFiles);
if (jsFiles > 0) fileStats.push('- 🎨 JavaScript files: ' + jsFiles);
if (tsFiles > 0) fileStats.push('- 🎨 TypeScript files: ' + tsFiles);
if (mdFiles > 0) fileStats.push('- 📝 Markdown files: ' + mdFiles);
if (otherFiles > 0) fileStats.push('- 📦 Other files: ' + otherFiles);
const fileStatsText = fileStats.join('\n');
const notifyComment = '## ' + templateEmoji + ' 已自动应用专用模板 | Auto-Applied Template\n\n' +
'检测到您的PR主要包含 **' + suggestedTemplate + '** 相关的变更,系统已自动为您应用相应的模板。\n\n' +
'Detected that your PR primarily contains **' + suggestedTemplate + '** changes. The appropriate template has been automatically applied.\n\n' +
'**文件统计 | File Statistics**\n' + fileStatsText + '\n\n' +
'**已应用模板 | Applied Template**\n`' + templatePath + '`\n\n' +
'✨ 您现在可以直接在PR描述中填写相关信息了\n\n' +
'✨ You can now fill in the relevant information in the PR description!';
await github.rest.issues.createComment({
owner: context.repo.owner,
repo: context.repo.repo,
issue_number: context.issue.number,
body: notifyComment
});
} catch (error) {
console.log('Failed to fetch or apply template: ' + error.message);
const templateUrl = 'https://raw.githubusercontent.com/' + context.repo.owner + '/' + context.repo.repo + '/dev/.github/PULL_REQUEST_TEMPLATE/' + suggestedTemplate + '.md';
const fallbackComment = '## ' + templateEmoji + ' 建议使用专用模板 | Suggested Template\n\n' +
'您的PR主要包含 **' + suggestedTemplate + '** 相关的变更。\n\n' +
'**推荐模板 | Recommended Template:** `.github/PULL_REQUEST_TEMPLATE/' + suggestedTemplate + '.md`\n\n' +
'**如何使用 | How to use:** [点击查看模板内容](' + templateUrl + ')';
await github.rest.issues.createComment({
owner: context.repo.owner,
repo: context.repo.repo,
issue_number: context.issue.number,
body: fallbackComment
});
}
} else {
console.log('PR body has content, sending suggestion only');
let fileStats = [];
if (goFiles > 0) fileStats.push('- 🔧 Go files: ' + goFiles);
if (jsFiles > 0) fileStats.push('- 🎨 JavaScript files: ' + jsFiles);
if (tsFiles > 0) fileStats.push('- 🎨 TypeScript files: ' + tsFiles);
if (mdFiles > 0) fileStats.push('- 📝 Markdown files: ' + mdFiles);
if (otherFiles > 0) fileStats.push('- 📦 Other files: ' + otherFiles);
const fileStatsText = fileStats.join('\n');
const templateUrl = 'https://raw.githubusercontent.com/' + context.repo.owner + '/' + context.repo.repo + '/dev/.github/PULL_REQUEST_TEMPLATE/' + suggestedTemplate + '.md';
const comment = '## ' + templateEmoji + ' 建议使用专用模板 | Suggested Template\n\n' +
'您的PR主要包含 **' + suggestedTemplate + '** 相关的变更。我们建议使用更适合的模板以简化填写。\n\n' +
'Your PR primarily contains **' + suggestedTemplate + '** changes. We suggest using a more suitable template to simplify filling.\n\n' +
'**文件统计 | File Statistics**\n' + fileStatsText + '\n\n' +
'**推荐模板 | Recommended Template**\n```\n.github/PULL_REQUEST_TEMPLATE/' + suggestedTemplate + '.md\n```\n\n' +
'**如何使用 | How to use**\n' +
'1. 编辑PR描述 | Edit PR description\n' +
'2. 复制 [' + suggestedTemplate + ' 模板内容](' + templateUrl + ') | Copy [' + suggestedTemplate + ' template content](' + templateUrl + ')\n' +
'3. 或在创建PR时使用URL参数 | Or use URL parameter when creating PR\n' +
' `?template=' + suggestedTemplate + '.md`\n\n' +
'_这是一个自动建议您可以继续使用当前模板。_\n\n' +
'_This is an automated suggestion. You may continue using the current template._';
await github.rest.issues.createComment({
owner: context.repo.owner,
repo: context.repo.repo,
issue_number: context.issue.number,
body: comment
});
}
} else if (suggestedTemplate && !usingDefaultTemplate) {
console.log('PR already uses a specific template');
} else {
console.log('No specific template suggestion needed - mixed changes');
}

17
.gitignore vendored
View File

@@ -16,7 +16,6 @@ nofx_test
# Go 相关 # Go 相关
*.test *.test
*.out *.out
.gocache/
# 操作系统 # 操作系统
.DS_Store .DS_Store
@@ -27,8 +26,6 @@ Thumbs.db
*.tmp *.tmp
*.bak *.bak
*.backup *.backup
.cache/
.gh-config/
# 环境变量 # 环境变量
.env .env
@@ -44,7 +41,6 @@ decision_logs/
nofx_test nofx_test
# Node.js # Node.js
web/node_modules
web/node_modules/ web/node_modules/
node_modules/ node_modules/
web/dist/ web/dist/
@@ -53,9 +49,6 @@ web/.vite/
# ESLint 临时报告文件(调试时生成,不纳入版本控制) # ESLint 临时报告文件(调试时生成,不纳入版本控制)
eslint-*.json eslint-*.json
# 本地 Agent QA seed个人调试用不纳入版本控制
docs/qa/fixtures/agent_self_play_seed.zh-CN.json
# VS code # VS code
.vscode .vscode
@@ -85,6 +78,7 @@ dist/
downloads/ downloads/
eggs/ eggs/
.eggs/ .eggs/
lib/
lib64/ lib64/
parts/ parts/
sdist/ sdist/
@@ -130,12 +124,3 @@ dmypy.json
# Pyre type checker # Pyre type checker
.pyre/ .pyre/
PR_DESCRIPTION.md 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

View File

@@ -1,37 +1,37 @@
# Railway All-in-One: Reuse existing GHCR images # Railway All-in-One: 复用现有 GHCR 镜像
# Extract content from existing images and merge into a single container # 从现有镜像提取内容,合并到一个容器
# Extract binary from backend image # 从后端镜像提取二进制
FROM ghcr.io/nofxaios/nofx/nofx-backend:latest AS backend FROM ghcr.io/nofxaios/nofx/nofx-backend:latest AS backend
# Extract static files from frontend image # 从前端镜像提取静态文件
FROM ghcr.io/nofxaios/nofx/nofx-frontend:latest AS frontend FROM ghcr.io/nofxaios/nofx/nofx-frontend:latest AS frontend
# Final image # 最终镜像
FROM alpine:latest FROM alpine:latest
RUN apk add --no-cache ca-certificates tzdata sqlite nginx openssl gettext RUN apk add --no-cache ca-certificates tzdata sqlite nginx openssl gettext
# Copy backend binary # 复制后端二进制
COPY --from=backend /app/nofx /app/nofx COPY --from=backend /app/nofx /app/nofx
# Copy TA-Lib libraries # 复制 TA-Lib
COPY --from=backend /usr/local/lib/libta_lib* /usr/local/lib/ COPY --from=backend /usr/local/lib/libta_lib* /usr/local/lib/
RUN ldconfig /usr/local/lib 2>/dev/null || true RUN ldconfig /usr/local/lib 2>/dev/null || true
# Copy frontend static files # 复制前端静态文件
COPY --from=frontend /usr/share/nginx/html /usr/share/nginx/html COPY --from=frontend /usr/share/nginx/html /usr/share/nginx/html
WORKDIR /app WORKDIR /app
RUN mkdir -p /app/data RUN mkdir -p /app/data
# Startup script (includes nginx config generation) # 启动脚本(包含 nginx 配置生成)
COPY railway/start.sh /app/start.sh COPY railway/start.sh /app/start.sh
RUN chmod +x /app/start.sh RUN chmod +x /app/start.sh
ENV DB_PATH=/app/data/data.db ENV DB_PATH=/app/data/data.db
# Railway automatically sets the PORT environment variable # Railway 会自动设置 PORT 环境变量
EXPOSE 8080 EXPOSE 8080
HEALTHCHECK --interval=30s --timeout=10s --start-period=60s --retries=3 \ HEALTHCHECK --interval=30s --timeout=10s --start-period=60s --retries=3 \

1340
README.ja.md Normal file

File diff suppressed because it is too large Load Diff

624
README.md
View File

@@ -1,321 +1,493 @@
<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"> | CONTRIBUTOR AIRDROP PROGRAM |
<strong>AI trading terminal for global markets.</strong><br/> |:----------------------------------:|
<strong>Research, strategy generation, execution, and monitoring for US stocks, commodities, forex, and crypto.</strong> | Code · Bug Fixes · Issues → Airdrop |
</p> | [Learn More](#contributor-airdrop-program) |
<p align="center"> **Languages:** [English](README.md) | [中文](docs/i18n/zh-CN/README.md) | [日本語](docs/i18n/ja/README.md) | [한국어](docs/i18n/ko/README.md) | [Русский](docs/i18n/ru/README.md) | [Українська](docs/i18n/uk/README.md) | [Tiếng Việt](docs/i18n/vi/README.md)
<a href="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>
--- ---
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 ### Supported Markets
curl -fsSL https://raw.githubusercontent.com/NoFxAiOS/nofx/main/install.sh | bash
```
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 | 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)
| <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) |
--- ---
## Quick demo ## Supported Exchanges
https://github.com/user-attachments/assets/3310f495-14c5-4586-a1cc-3d32e44aa505 ### CEX (Centralized Exchanges)
| Exchange | Status | Register (Fee Discount) |
|----------|--------|-------------------------|
| **Binance** | ✅ Supported | [Register](https://www.binance.com/join?ref=NOFXENG) |
| **Bybit** | ✅ Supported | [Register](https://partner.bybit.com/b/83856) |
| **OKX** | ✅ Supported | [Register](https://www.okx.com/join/1865360) |
| **Bitget** | ✅ Supported | [Register](https://www.bitget.com/referral/register?from=referral&clacCode=c8a43172) |
### Perp-DEX (Decentralized Perpetual Exchanges)
| Exchange | Status | Register (Fee Discount) |
|----------|--------|-------------------------|
| **Hyperliquid** | ✅ Supported | [Register](https://app.hyperliquid.xyz/join/AITRADING) |
| **Aster DEX** | ✅ Supported | [Register](https://www.asterdex.com/en/referral/fdfc0e) |
| **Lighter** | ✅ Supported | [Register](https://app.lighter.xyz/?referral=68151432) |
--- ---
## Markets ## Supported AI Models
**US Stocks · Commodities · Forex · Crypto** | AI Model | Status | Get API Key |
|----------|--------|-------------|
NOFX organizes research, strategy construction, execution, and monitoring around multi-asset workflows instead of single-venue screens. | **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) |
## AI model access | **Gemini** | ✅ Supported | [Get API Key](https://aistudio.google.com) |
| **Grok** | ✅ Supported | [Get API Key](https://console.x.ai) |
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. | **Kimi** | ✅ Supported | [Get API Key](https://platform.moonshot.cn) |
| 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 |
--- ---
## Screenshots ## Screenshots
<details> ### Config Page
<summary><b>Config Page</b></summary> | 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 | ### Competition & Backtest
| :----------------------------------------------------------: | :----------------------------------------------------------: | | Competition Mode | Backtest Lab |
| <img src="screenshots/config-ai-exchanges.png" width="400"/> | <img src="screenshots/config-traders-list.png" width="400"/> | |:---:|:---:|
| <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> | Trading Stats | Position History |
<summary><b>Dashboard</b></summary> |:---:|:---:|
| <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 | | Positions | Trader Details |
| :-----------------------------------------------------: | :-------------------------------------------------------------: | |:---:|:---:|
| <img src="screenshots/dashboard-page.png" width="400"/> | <img src="screenshots/dashboard-market-chart.png" width="400"/> | | <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 | ### Strategy Studio
| :--------------------------------------------------------------: | :-----------------------------------------------------------------: | | Strategy Editor | Indicators Config |
| <img src="screenshots/dashboard-trading-stats.png" width="400"/> | <img src="screenshots/dashboard-position-history.png" width="400"/> | |:---:|:---:|
| <img src="screenshots/strategy-studio.png" width="400" alt="Strategy Studio"/> | <img src="screenshots/strategy-indicators.png" width="400" alt="Strategy Indicators"/> |
| Positions | Trader Details | ### Debate Arena
| :----------------------------------------------------------: | :---------------------------------------------------: | | AI Debate Session | Create Debate |
| <img src="screenshots/dashboard-positions.png" width="400"/> | <img src="screenshots/details-page.png" width="400"/> | |:---:|:---:|
| <img src="screenshots/debate-arena.png" width="400" alt="Debate Arena"/> | <img src="screenshots/debate-create.png" width="400" alt="Create Debate"/> |
</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>
--- ---
## Install ## Quick Start
### Linux / macOS ### One-Click Install (Local/Server)
**Linux / macOS:**
```bash ```bash
curl -fsSL https://raw.githubusercontent.com/NoFxAiOS/nofx/main/install.sh | bash curl -fsSL https://raw.githubusercontent.com/NoFxAiOS/nofx/main/install.sh | bash
``` ```
### 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) [![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 ```bash
# Download and start
curl -O https://raw.githubusercontent.com/NoFxAiOS/nofx/main/docker-compose.prod.yml curl -O https://raw.githubusercontent.com/NoFxAiOS/nofx/main/docker-compose.prod.yml
docker compose -f docker-compose.prod.yml up -d docker compose -f docker-compose.prod.yml up -d
``` ```
### Windows Access Web Interface: **http://127.0.0.1:3000**
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
```bash ```bash
# Prerequisites: Go 1.21+, Node.js 18+, TA-Lib # Management commands
# macOS: brew install ta-lib docker compose -f docker-compose.prod.yml logs -f # View logs
# Ubuntu: sudo apt-get install libta-lib0-dev docker compose -f docker-compose.prod.yml restart # Restart
docker compose -f docker-compose.prod.yml down # Stop
git clone https://github.com/NoFxAiOS/nofx.git && cd nofx docker compose -f docker-compose.prod.yml pull && docker compose -f docker-compose.prod.yml up -d # Update
go build -o nofx && ./nofx # backend
cd web && npm install && npm run dev # frontend (new terminal)
``` ```
### Update ### Keeping Updated
> **💡 Updates are frequent.** Run this command daily to stay current with the latest features and fixes:
```bash ```bash
curl -fsSL https://raw.githubusercontent.com/NoFxAiOS/nofx/main/install.sh | bash curl -fsSL https://raw.githubusercontent.com/NoFxAiOS/nofx/main/install.sh | bash
``` ```
--- 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**: - **Go 1.21+**
- **Node.js 18+**
1. Configure AI model access - **TA-Lib** (technical indicator library)
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:**
```bash ```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 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) ### Enhanced Security (HTTPS)
2. A record → your server IP (Proxied)
3. SSL/TLS → Flexible For enhanced security, enable transport encryption in `.env`:
4. Set `TRANSPORT_ENCRYPTION=true` 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)
``` After starting the system, configure through the web interface:
NOFX
┌─────────────────────────────────────────────────┐ 1. **Configure AI Models** - Add your AI API keys (DeepSeek, OpenAI, etc.)
│ Trading Terminal │ 2. **Configure Exchanges** - Set up exchange API credentials
│ React + TypeScript + TradingView │ 3. **Create Strategy** - Configure trading strategy in Strategy Studio
│ US Stocks · Commodities · Forex · Crypto │ 4. **Create Trader** - Combine AI model + Exchange + Strategy
├─────────────────────────────────────────────────┤ 5. **Start Trading** - Launch your configured traders
│ API Server (Go) │
├──────────────┬──────────────┬───────────────────┤ All configuration is done through the web interface - no JSON file editing required.
│ 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 │
└─────────────────────────────────────────────────┘
```
--- ---
## Docs ## Web Interface Features
| | | ### Competition Page
| :------------------------------------------------------ | :------------------------------------ | - Real-time ROI leaderboard
| [Architecture](docs/architecture/README.md) | System design and module index | - Multi-AI performance comparison charts
| [Strategy Module](docs/architecture/STRATEGY_MODULE.md) | Coin selection, AI prompts, execution | - Live P/L tracking and rankings
| [FAQ](docs/faq/README.md) | Common questions |
| [Getting Started](docs/getting-started/README.md) | Deployment guide | ### 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 ## Contributing
See [Contributing Guide](CONTRIBUTING.md), [Code of Conduct](CODE_OF_CONDUCT.md), and [Security Policy](SECURITY.md). We welcome contributions! See:
- **[Contributing Guide](CONTRIBUTING.md)** - Development workflow and PR process
### Contributor Airdrop Program - **[Code of Conduct](CODE_OF_CONDUCT.md)** - Community guidelines
- **[Security Policy](SECURITY.md)** - Report vulnerabilities
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 | ★★ |
--- ---
## Links ## Contributor Airdrop Program
| | | All contributions are tracked on GitHub. When NOFX generates revenue, contributors will receive airdrops based on their contributions.
| :-------- | :---------------------------------------------------- |
| 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) |
> **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** | ⭐⭐ |
--- ---
## Sponsors ## Contact
<a href="https://github.com/pjl914335852-ux"><img src="https://github.com/pjl914335852-ux.png" width="50" height="50" style="border-radius:50%"/></a> - **GitHub Issues**: [Submit an Issue](https://github.com/NoFxAiOS/nofx/issues)
<a href="https://github.com/cat9999aaa"><img src="https://github.com/cat9999aaa.png" width="50" height="50" style="border-radius:50%"/></a> - **Developer Community**: [Telegram Group](https://t.me/nofx_dev_community)
<a href="https://github.com/1733055465"><img src="https://github.com/1733055465.png" width="50" height="50" style="border-radius:50%"/></a>
<a href="https://github.com/kolal2020"><img src="https://github.com/kolal2020.png" width="50" height="50" style="border-radius:50%"/></a>
<a href="https://github.com/CyberFFarm"><img src="https://github.com/CyberFFarm.png" width="50" height="50" style="border-radius:50%"/></a>
<a href="https://github.com/vip3001003"><img src="https://github.com/vip3001003.png" width="50" height="50" style="border-radius:50%"/></a>
<a href="https://github.com/mrtluh"><img src="https://github.com/mrtluh.png" width="50" height="50" style="border-radius:50%"/></a>
<a href="https://github.com/cpcp1117-source"><img src="https://github.com/cpcp1117-source.png" width="50" height="50" style="border-radius:50%"/></a>
<a href="https://github.com/match-007"><img src="https://github.com/match-007.png" width="50" height="50" style="border-radius:50%"/></a>
<a href="https://github.com/leiwuhen1715"><img src="https://github.com/leiwuhen1715.png" width="50" height="50" style="border-radius:50%"/></a>
<a href="https://github.com/SHAOXIA1991"><img src="https://github.com/SHAOXIA1991.png" width="50" height="50" style="border-radius:50%"/></a>
[Become a sponsor](https://github.com/sponsors/NoFxAiOS) ---
## License ## Star History
[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) [![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 package api
import ( import (
"log"
"net/http" "net/http"
"nofx/config" "nofx/config"
"nofx/crypto" "nofx/crypto"
@@ -52,16 +53,28 @@ func (h *CryptoHandler) HandleGetPublicKey(c *gin.Context) {
}) })
} }
// ==================== Encrypted Data Decryption ==================== // ==================== Encrypted Data Decryption Endpoint ====================
//
// SECURITY: there is deliberately NO public decrypt endpoint. Transport // HandleDecryptSensitiveData Decrypt encrypted data sent from client
// encryption is one-directional — clients encrypt sensitive fields to the func (h *CryptoHandler) HandleDecryptSensitiveData(c *gin.Context) {
// server's RSA public key and the authenticated config-update handlers var payload crypto.EncryptedPayload
// (handleUpdateModelConfigs / handleUpdateExchangeConfigs / handleCreateExchange) if err := c.ShouldBindJSON(&payload); err != nil {
// decrypt them server-side via cryptoService.DecryptSensitiveData. Exposing a c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid request"})
// generic decrypt route would turn the server into a decryption oracle that any return
// unauthenticated caller could use to recover the plaintext of a captured }
// ciphertext, defeating the entire transport-encryption layer.
// Decrypt
decrypted, err := h.cryptoService.DecryptSensitiveData(&payload)
if err != nil {
log.Printf("❌ Decryption failed: %v", err)
c.JSON(http.StatusInternalServerError, gin.H{"error": "Decryption failed"})
return
}
c.JSON(http.StatusOK, map[string]string{
"plaintext": decrypted,
})
}
// ==================== Audit Log Query Endpoint ==================== // ==================== Audit Log Query Endpoint ====================

635
api/debate.go Normal file
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" "nofx/logger"
) )
type APIErrorResponse struct {
Error string `json:"error"`
ErrorKey string `json:"error_key,omitempty"`
ErrorParams map[string]string `json:"error_params,omitempty"`
}
func writeAPIError(c *gin.Context, statusCode int, publicMsg, errorKey string, errorParams map[string]string) {
resp := APIErrorResponse{
Error: publicMsg,
}
if errorKey != "" {
resp.ErrorKey = errorKey
}
if len(errorParams) > 0 {
resp.ErrorParams = errorParams
}
c.JSON(statusCode, resp)
}
// SafeError returns a safe error message without exposing internal details // SafeError returns a safe error message without exposing internal details
// It logs the actual error for debugging but returns a generic message to the client // It logs the actual error for debugging but returns a generic message to the client
func SafeError(c *gin.Context, statusCode int, publicMsg string, internalErr error) { func SafeError(c *gin.Context, statusCode int, publicMsg string, internalErr error) {
@@ -35,46 +16,34 @@ func SafeError(c *gin.Context, statusCode int, publicMsg string, internalErr err
logger.Errorf("[API Error] %s: %v", publicMsg, internalErr) logger.Errorf("[API Error] %s: %v", publicMsg, internalErr)
} }
writeAPIError(c, statusCode, publicMsg, "", nil) c.JSON(statusCode, gin.H{"error": publicMsg})
}
func SafeErrorWithDetails(c *gin.Context, statusCode int, publicMsg, errorKey string, errorParams map[string]string, internalErr error) {
if internalErr != nil {
logger.Errorf("[API Error] %s: %v", publicMsg, internalErr)
}
writeAPIError(c, statusCode, publicMsg, errorKey, errorParams)
} }
// SafeInternalError logs internal error and returns a generic message // SafeInternalError logs internal error and returns a generic message
func SafeInternalError(c *gin.Context, operation string, err error) { func SafeInternalError(c *gin.Context, operation string, err error) {
logger.Errorf("[Internal Error] %s: %v", operation, err) logger.Errorf("[Internal Error] %s: %v", operation, err)
writeAPIError(c, http.StatusInternalServerError, operation+" failed", "", nil) c.JSON(http.StatusInternalServerError, gin.H{"error": operation + " failed"})
} }
// SafeBadRequest returns a safe bad request error // SafeBadRequest returns a safe bad request error
// For validation errors, we can be more specific since they're about user input // For validation errors, we can be more specific since they're about user input
func SafeBadRequest(c *gin.Context, msg string) { func SafeBadRequest(c *gin.Context, msg string) {
writeAPIError(c, http.StatusBadRequest, msg, "", nil) c.JSON(http.StatusBadRequest, gin.H{"error": msg})
}
func SafeBadRequestWithDetails(c *gin.Context, msg, errorKey string, errorParams map[string]string) {
writeAPIError(c, http.StatusBadRequest, msg, errorKey, errorParams)
} }
// SafeNotFound returns a generic not found error // SafeNotFound returns a generic not found error
func SafeNotFound(c *gin.Context, resource string) { func SafeNotFound(c *gin.Context, resource string) {
writeAPIError(c, http.StatusNotFound, resource+" not found", "", nil) c.JSON(http.StatusNotFound, gin.H{"error": resource + " not found"})
} }
// SafeUnauthorized returns unauthorized error // SafeUnauthorized returns unauthorized error
func SafeUnauthorized(c *gin.Context) { func SafeUnauthorized(c *gin.Context) {
writeAPIError(c, http.StatusUnauthorized, "Unauthorized", "", nil) c.JSON(http.StatusUnauthorized, gin.H{"error": "Unauthorized"})
} }
// SafeForbidden returns forbidden error // SafeForbidden returns forbidden error
func SafeForbidden(c *gin.Context, msg string) { func SafeForbidden(c *gin.Context, msg string) {
writeAPIError(c, http.StatusForbidden, msg, "", nil) c.JSON(http.StatusForbidden, gin.H{"error": msg})
} }
// IsSensitiveError checks if an error message contains sensitive information // IsSensitiveError checks if an error message contains sensitive information

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

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,58 +0,0 @@
package api
import (
"net/http"
"strings"
"github.com/gin-gonic/gin"
)
// handleStatisticsFull returns the full set of computed performance metrics for
// a single trader: win rate, profit factor, Sharpe ratio, max drawdown, and the
// average win/loss amounts. These are derived from the trader's CLOSED positions
// via store.Position().GetFullStatsByTraderFilters — the same computation the
// strategy engine feeds to the AI, so the dashboard and the model see identical
// numbers.
//
// The existing GET /statistics endpoint only returns cycle/position counts; this
// endpoint exposes the richer trade-quality metrics the terminal dashboard needs.
func (s *Server) handleStatisticsFull(c *gin.Context) {
_, traderID, err := s.getTraderFromQuery(c)
if err != nil {
SafeBadRequest(c, "Invalid trader ID")
return
}
trader, err := s.traderManager.GetTrader(traderID)
if err != nil {
SafeNotFound(c, "Trader")
return
}
store := trader.GetStore()
if store == nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "Store not available"})
return
}
// Aggregate across the trader's historical IDs exactly like the position
// history endpoint (handler_order.go). One-click "NOFX Autopilot" relaunches
// create fresh trader rows, but the closed positions stay under the old
// generated IDs (which embed userID + "claw402"). Without this, a freshly
// relaunched Autopilot would report only the current incarnation's trades
// instead of its real lifetime history.
userID := c.GetString("user_id")
traderIDs := []string{trader.GetID()}
var traderIDPatterns []string
if strings.EqualFold(strings.TrimSpace(trader.GetName()), "NOFX Autopilot") && strings.TrimSpace(userID) != "" {
traderIDPatterns = append(traderIDPatterns, "%_"+userID+"_claw402_%")
}
stats, err := store.Position().GetFullStatsByTraderFilters(traderIDs, traderIDPatterns)
if err != nil {
SafeInternalError(c, "Get full statistics", err)
return
}
c.JSON(http.StatusOK, stats)
}

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("Failed to create the bot this time: %s.", reason)
}
return fmt.Sprintf("Failed to create the bot this time: %s. %s.", reason, nextStep)
}
func traderCreationRequestError(reason string) string {
return formatTraderCreationError(reason, "Please check the information you just entered and submit again")
}
func validateTraderLeverageRange(btcEthLeverage, altcoinLeverage int) (string, string) {
if btcEthLeverage < 0 || btcEthLeverage > maxManualBTCETHLeverage {
return traderCreationRequestError("BTC/ETH leverage must be between 1x and 20x"), "trader.create.invalid_btc_eth_leverage"
}
if altcoinLeverage < 0 || altcoinLeverage > maxManualAltLeverage {
return traderCreationRequestError("Altcoin leverage must be between 1x and 20x"), "trader.create.invalid_altcoin_leverage"
}
return "", ""
}
func isSupportedTraderSymbol(symbol string) bool {
normalized := strings.ToUpper(strings.TrimSpace(symbol))
if normalized == "" {
return true
}
return strings.HasSuffix(normalized, "USDT") || strings.HasSuffix(normalized, "-USDC") || strings.HasPrefix(normalized, "XYZ:")
}
func exchangeDisplayName(exchange *store.Exchange) string {
if exchange == nil {
return "the selected exchange account"
}
if exchange.AccountName != "" {
return fmt.Sprintf("%s (%s)", exchange.Name, exchange.AccountName)
}
if exchange.Name != "" {
return exchange.Name
}
return "the selected exchange account"
}
func missingExchangeFields(exchange *store.Exchange) []string {
if exchange == nil {
return nil
}
var missing []string
switch exchange.ExchangeType {
case "binance", "bybit", "gate", "indodax":
if exchange.APIKey == "" {
missing = append(missing, "API Key")
}
if exchange.SecretKey == "" {
missing = append(missing, "Secret Key")
}
case "okx", "bitget", "kucoin":
if exchange.APIKey == "" {
missing = append(missing, "API Key")
}
if exchange.SecretKey == "" {
missing = append(missing, "Secret Key")
}
if exchange.Passphrase == "" {
missing = append(missing, "Passphrase")
}
case "hyperliquid":
if exchange.APIKey == "" {
missing = append(missing, "Private Key")
}
if strings.TrimSpace(exchange.HyperliquidWalletAddr) == "" {
missing = append(missing, "Wallet Address")
}
case "aster":
if strings.TrimSpace(exchange.AsterUser) == "" {
missing = append(missing, "Aster User")
}
if strings.TrimSpace(exchange.AsterSigner) == "" {
missing = append(missing, "Aster Signer")
}
if exchange.AsterPrivateKey == "" {
missing = append(missing, "Aster Private Key")
}
case "lighter":
if strings.TrimSpace(exchange.LighterWalletAddr) == "" {
missing = append(missing, "Wallet Address")
}
if exchange.LighterAPIKeyPrivateKey == "" {
missing = append(missing, "API Key Private Key")
}
}
return missing
}
func mapStringPairs(kv ...string) map[string]string {
if len(kv) == 0 {
return nil
}
params := make(map[string]string, len(kv)/2)
for i := 0; i+1 < len(kv); i += 2 {
params[kv[i]] = kv[i+1]
}
return params
}
func validateExchangeForTraderCreation(exchange *store.Exchange) (string, string, map[string]string) {
if exchange == nil {
return formatTraderCreationError("The exchange account you selected was not found", "Please go to \"Settings > Exchange Config\" and add an available account first, then come back to create the bot"),
"trader.create.exchange_not_found", nil
}
if !exchange.Enabled {
return formatTraderCreationError(
fmt.Sprintf("Exchange account \"%s\" is currently disabled", exchangeDisplayName(exchange)),
"Please go to \"Settings > Exchange Config\" to enable this account, then create the bot again",
), "trader.create.exchange_disabled", mapStringPairs("exchange_name", exchangeDisplayName(exchange))
}
missing := missingExchangeFields(exchange)
if len(missing) > 0 {
return formatTraderCreationError(
fmt.Sprintf("The configuration for exchange account \"%s\" is incomplete, missing %s", exchangeDisplayName(exchange), strings.Join(missing, ", ")),
"Please go to \"Settings > Exchange Config\" to complete the required information for this account, then create the bot again",
), "trader.create.exchange_missing_fields", mapStringPairs(
"exchange_name", exchangeDisplayName(exchange),
"missing_fields", strings.Join(missing, ", "),
)
}
switch exchange.ExchangeType {
case "binance", "bybit", "okx", "bitget", "gate", "kucoin", "hyperliquid", "aster", "lighter", "indodax":
return "", "", nil
default:
return formatTraderCreationError(
fmt.Sprintf("Exchange account \"%s\" uses type %s, which is not supported in the current version", exchangeDisplayName(exchange), exchange.ExchangeType),
"Please switch to an exchange account supported by the current version, then create the bot again",
), "trader.create.exchange_unsupported", mapStringPairs(
"exchange_name", exchangeDisplayName(exchange),
"exchange_type", exchange.ExchangeType,
)
}
}
func classifyTraderSetupReason(reason string) (string, string) {
trimmed := strings.TrimSpace(reason)
if trimmed == "" {
return "", ""
}
lower := strings.ToLower(trimmed)
switch {
case strings.Contains(lower, "failed to parse strategy config"),
strings.Contains(lower, "failed to parse strategy configuration"):
return "trader.reason.strategy_config_invalid", "The current strategy configuration is corrupted and the system cannot parse it for now"
case strings.Contains(lower, "has no strategy configured"):
return "trader.reason.strategy_missing", "The current bot is missing a valid trading strategy configuration"
case strings.Contains(lower, "failed to parse private key"),
(strings.Contains(lower, "invalid hex character") && strings.Contains(lower, "private key")):
return "trader.reason.private_key_invalid", "The private key format is incorrect and the system cannot recognize it"
case strings.Contains(lower, "failed to initialize hyperliquid trader"):
return "trader.reason.hyperliquid_init_failed", "Hyperliquid account initialization failed; please confirm the private key, main wallet address, and Agent Wallet configuration are correct"
case strings.Contains(lower, "failed to initialize aster trader"):
return "trader.reason.aster_init_failed", "Aster account initialization failed; please confirm the Aster User, Signer, and private key are correct"
case strings.Contains(lower, "failed to get meta information"):
return "trader.reason.exchange_meta_unavailable", "The system cannot read account meta information from the exchange for now"
case strings.Contains(lower, "security check failed") && strings.Contains(lower, "agent wallet balance too high"):
return "trader.reason.hyperliquid_agent_balance_too_high", "The Hyperliquid Agent Wallet balance is too high and does not meet the current security requirements"
case strings.Contains(lower, "failed to initialize account"):
return "trader.reason.exchange_account_init_failed", "Exchange account initialization failed; please confirm the wallet address and API Key match"
case strings.Contains(lower, "unsupported trading platform"):
return "trader.reason.exchange_unsupported", "The current exchange type does not support bot initialization"
case strings.Contains(lower, "initial balance not set and unable to fetch balance from exchange"):
return "trader.reason.exchange_balance_unavailable", "The system cannot read the account balance from the exchange for now"
case strings.Contains(lower, "timeout"), strings.Contains(lower, "no such host"), strings.Contains(lower, "connection refused"):
return "trader.reason.exchange_service_unreachable", "The system cannot connect to the exchange service for now"
default:
return "trader.reason.unknown", trimmed
}
}
func humanizeTraderSetupReason(reason string) string {
_, message := classifyTraderSetupReason(reason)
return message
}
func traderSetupReasonParams(err error, fallback string, kv ...string) map[string]string {
params := mapStringPairs(kv...)
rawReason := SanitizeError(err, fallback)
reasonKey, reasonMessage := classifyTraderSetupReason(rawReason)
if reasonMessage == "" && fallback != "" {
reasonMessage = fallback
}
if reasonMessage != "" {
if params == nil {
params = map[string]string{}
}
params["reason"] = reasonMessage
}
if reasonKey != "" {
if params == nil {
params = map[string]string{}
}
params["reason_key"] = reasonKey
}
return params
}
func describeTraderLoadError(traderName string, err error) string {
if err == nil {
return formatTraderCreationError("The bot configuration was saved, but the runtime instance failed to initialize", "Please check that the model, strategy, and exchange configuration are complete, then try again")
}
reason := humanizeTraderSetupReason(SanitizeError(err, ""))
if reason == "" {
return formatTraderCreationError(
fmt.Sprintf("Bot \"%s\" failed to start when initializing its runtime instance", traderName),
"Please check that the model, strategy, and exchange configuration are complete, then try again",
)
}
return formatTraderCreationError(
fmt.Sprintf("Bot \"%s\" failed to start when initializing its runtime instance, because: %s", traderName, reason),
"Please check that the model, strategy, and exchange configuration are complete, then try again",
)
}
func describeTraderCreationWarning(traderName string, err error) string {
if err == nil {
return fmt.Sprintf("Bot \"%s\" has been saved, but it has not yet passed the pre-start validation. Please check the model, strategy, and exchange configuration first, then click start after fixing them.", traderName)
}
reason := humanizeTraderSetupReason(SanitizeError(err, ""))
if reason == "" {
return fmt.Sprintf("Bot \"%s\" has been saved, but it cannot start for now. Please check the model, strategy, and exchange configuration first, then click start after fixing them.", traderName)
}
return fmt.Sprintf("Bot \"%s\" has been saved, but it cannot start for now, because: %s. Please check the model, strategy, and exchange configuration first, then click start after fixing them.", traderName, reason)
}
func describeTraderStartError(traderName string, err error) string {
if err == nil {
return fmt.Sprintf("Failed to start the bot this time: bot \"%s\" cannot start for now. Please check the model, strategy, and exchange configuration, then click start again.", traderName)
}
reason := humanizeTraderSetupReason(SanitizeError(err, ""))
if reason == "" {
return fmt.Sprintf("Failed to start the bot this time: bot \"%s\" cannot start for now. Please check the model, strategy, and exchange configuration, then click start again.", traderName)
}
return fmt.Sprintf("Failed to start the bot this time: bot \"%s\" cannot start for now, because: %s. Please check the model, strategy, and exchange configuration, then click start again.", traderName, reason)
}
func formatTraderStartError(reason, nextStep string) string {
if nextStep == "" {
return fmt.Sprintf("Failed to start the bot this time: %s.", reason)
}
return fmt.Sprintf("Failed to start the bot this time: %s. %s.", reason, nextStep)
}
// handleCreateTrader Create new AI trader
func (s *Server) handleCreateTrader(c *gin.Context) {
userID := c.GetString("user_id")
var req CreateTraderRequest
if err := c.ShouldBindJSON(&req); err != nil {
SafeBadRequestWithDetails(c, traderCreationRequestError("The submitted information is incomplete or has an invalid format"), "trader.create.invalid_request", nil)
return
}
// Validate leverage values against the same limits exposed by manual user config.
if errMsg, errCode := validateTraderLeverageRange(req.BTCETHLeverage, req.AltcoinLeverage); errMsg != "" {
SafeBadRequestWithDetails(c, errMsg, errCode, nil)
return
}
// Validate trading symbol format. Hyperliquid xyz dex markets (stocks,
// commodities, indices, FX, Pre-IPO) are user-facing SYMBOL-USDC pairs,
// while standard crypto/perp markets keep the legacy USDT suffix format.
if req.TradingSymbols != "" {
symbols := strings.Split(req.TradingSymbols, ",")
for _, symbol := range symbols {
symbol = strings.TrimSpace(symbol)
if !isSupportedTraderSymbol(symbol) {
SafeBadRequestWithDetails(c, traderCreationRequestError(
fmt.Sprintf("The trading pair %s has an invalid format; only USDT perpetuals or Hyperliquid XYZ USDC instruments (SYMBOL-USDC) are currently supported", symbol),
), "trader.create.invalid_symbol", mapStringPairs("symbol", symbol))
return
}
}
}
model, err := s.store.AIModel().Get(userID, req.AIModelID)
if err != nil {
if errors.Is(err, gorm.ErrRecordNotFound) {
SafeBadRequestWithDetails(c, formatTraderCreationError("The AI model you selected was not found", "Please go to \"Settings > Model Config\" to add and enable an available model first, then come back to create the bot"), "trader.create.model_not_found", nil)
return
}
SafeError(c, http.StatusInternalServerError,
formatTraderCreationError("Unable to read your AI model configuration for now", "Please retry later; if the problem persists, check whether the local service is running normally"),
err,
)
return
}
if !model.Enabled {
SafeBadRequestWithDetails(c, formatTraderCreationError(
fmt.Sprintf("AI model \"%s\" is not enabled yet", model.Name),
"Please go to \"Settings > Model Config\" to enable it, then create the bot again",
), "trader.create.model_disabled", mapStringPairs("model_name", model.Name))
return
}
if model.APIKey == "" {
SafeBadRequestWithDetails(c, formatTraderCreationError(
fmt.Sprintf("AI model \"%s\" is missing an API Key or payment credentials", model.Name),
"Please go to \"Settings > Model Config\" to complete the model credentials, then create the bot again",
), "trader.create.model_missing_credentials", mapStringPairs("model_name", model.Name))
return
}
if req.StrategyID == "" {
SafeBadRequestWithDetails(c, formatTraderCreationError("You have not selected a trading strategy yet", "Please select a strategy first, then continue creating the bot"), "trader.create.strategy_required", nil)
return
}
if req.StrategyID != "" {
_, err = s.store.Strategy().Get(userID, req.StrategyID)
if err != nil {
if errors.Is(err, gorm.ErrRecordNotFound) {
SafeBadRequestWithDetails(c, formatTraderCreationError("The strategy you selected does not exist or has been deleted", "Please select another available strategy, then continue creating the bot"), "trader.create.strategy_not_found", nil)
return
}
SafeError(c, http.StatusInternalServerError,
formatTraderCreationError("Unable to read the strategy configuration you selected for now", "Please retry later; if the problem persists, check whether the local service is running normally"),
err,
)
return
}
}
// Generate trader ID (use short UUID prefix for readability)
exchangeIDShort := req.ExchangeID
if len(exchangeIDShort) > 8 {
exchangeIDShort = exchangeIDShort[:8]
}
traderID := fmt.Sprintf("%s_%s_%d", exchangeIDShort, req.AIModelID, time.Now().Unix())
// Set default values
isCrossMargin := true // Default to cross margin mode
if req.IsCrossMargin != nil {
isCrossMargin = *req.IsCrossMargin
}
showInCompetition := true // Default to show in competition
if req.ShowInCompetition != nil {
showInCompetition = *req.ShowInCompetition
}
// Set leverage default values
btcEthLeverage := 10 // Default value
altcoinLeverage := 5 // Default value
if req.BTCETHLeverage > 0 {
btcEthLeverage = req.BTCETHLeverage
}
if req.AltcoinLeverage > 0 {
altcoinLeverage = req.AltcoinLeverage
}
// Set system prompt template default value
systemPromptTemplate := "default"
if req.SystemPromptTemplate != "" {
systemPromptTemplate = req.SystemPromptTemplate
}
// Set scan interval default value
scanIntervalMinutes := req.ScanIntervalMinutes
if scanIntervalMinutes <= 0 {
scanIntervalMinutes = 15
} else if scanIntervalMinutes < 3 {
scanIntervalMinutes = 3 // Explicit values below 3 minutes are clamped to the minimum.
}
// Query exchange actual balance, override user input
actualBalance := req.InitialBalance // Default to use user input
exchanges, err := s.store.Exchange().List(userID)
if err != nil {
SafeError(c, http.StatusInternalServerError,
formatTraderCreationError("Unable to read your exchange configuration for now", "Please retry later; if the problem persists, check whether the local service is running normally"),
err,
)
return
}
// Find matching exchange configuration
var exchangeCfg *store.Exchange
for _, ex := range exchanges {
if ex.ID == req.ExchangeID {
exchangeCfg = ex
break
}
}
if exchangeMsg, exchangeErrorKey, exchangeErrorParams := validateExchangeForTraderCreation(exchangeCfg); exchangeMsg != "" {
SafeBadRequestWithDetails(c, exchangeMsg, exchangeErrorKey, exchangeErrorParams)
return
}
{
tempTrader, createErr := buildExchangeProbeTrader(exchangeCfg, userID)
if createErr != nil {
SafeBadRequestWithDetails(c, formatTraderCreationError(
fmt.Sprintf("Exchange account \"%s\" did not pass initialization validation, because: %s", exchangeDisplayName(exchangeCfg), humanizeTraderSetupReason(SanitizeError(createErr, "Configuration validation failed"))),
"Please go to \"Settings > Exchange Config\" to check whether this account's keys, address, and account information are entered correctly",
), "trader.create.exchange_probe_failed", traderSetupReasonParams(createErr, "Configuration validation failed",
"exchange_name", exchangeDisplayName(exchangeCfg),
))
return
} else if tempTrader != nil {
// Query actual balance
balanceInfo, balanceErr := tempTrader.GetBalance()
if balanceErr != nil {
logger.Infof("⚠️ Failed to query exchange balance, using user input for initial balance: %v", balanceErr)
} else {
if extractedBalance, found := extractExchangeTotalEquity(balanceInfo); found {
actualBalance = extractedBalance
logger.Infof("✓ Queried exchange total equity: %.2f %s (user input: %.2f)",
actualBalance, accountAssetForExchange(exchangeCfg.ExchangeType), req.InitialBalance)
} else {
logger.Infof("⚠️ Unable to extract total equity from balance info, balanceInfo=%v, using user input for initial balance", balanceInfo)
}
}
}
}
// Create trader configuration (database entity)
logger.Infof("🔧 DEBUG: Starting to create trader config, ID=%s, Name=%s, AIModel=%s, Exchange=%s, StrategyID=%s", traderID, req.Name, req.AIModelID, req.ExchangeID, req.StrategyID)
traderRecord := &store.Trader{
ID: traderID,
UserID: userID,
Name: req.Name,
AIModelID: req.AIModelID,
ExchangeID: req.ExchangeID,
StrategyID: req.StrategyID, // Associated strategy ID (new version)
InitialBalance: actualBalance, // Use actual queried balance
BTCETHLeverage: btcEthLeverage,
AltcoinLeverage: altcoinLeverage,
TradingSymbols: req.TradingSymbols,
UseAI500: req.UseAI500,
UseOITop: req.UseOITop,
CustomPrompt: req.CustomPrompt,
OverrideBasePrompt: req.OverrideBasePrompt,
SystemPromptTemplate: systemPromptTemplate,
IsCrossMargin: isCrossMargin,
ShowInCompetition: showInCompetition,
ScanIntervalMinutes: scanIntervalMinutes,
IsRunning: false,
}
// Save to database
logger.Infof("🔧 DEBUG: Preparing to call CreateTrader")
err = s.store.Trader().Create(traderRecord)
if err != nil {
logger.Infof("❌ Failed to create trader: %v", err)
publicMsg := SanitizeError(err, formatTraderCreationError("The bot configuration was not saved successfully", "Please check the name, model, strategy, and exchange configuration, then try again"))
statusCode := http.StatusBadRequest
if publicMsg == formatTraderCreationError("The bot configuration was not saved successfully", "Please check the name, model, strategy, and exchange configuration, then try again") {
statusCode = http.StatusInternalServerError
}
SafeError(c, statusCode, publicMsg, err)
return
}
logger.Infof("🔧 DEBUG: CreateTrader succeeded")
// Immediately load new trader into TraderManager
logger.Infof("🔧 DEBUG: Preparing to call LoadUserTraders")
startupWarning := ""
err = s.traderManager.LoadUserTradersFromStore(s.store, userID)
if err != nil {
logger.Infof("⚠️ Failed to load user traders into memory: %v", err)
startupWarning = describeTraderCreationWarning(req.Name, err)
}
logger.Infof("🔧 DEBUG: LoadUserTraders completed")
if startupWarning == "" {
if loadErr := s.traderManager.GetLoadError(traderID); loadErr != nil {
logger.Infof("⚠️ Trader %s failed to load after creation: %v", traderID, loadErr)
startupWarning = describeTraderCreationWarning(req.Name, loadErr)
}
}
if startupWarning == "" {
if _, getErr := s.traderManager.GetTrader(traderID); getErr != nil {
logger.Infof("⚠️ Trader %s not found in memory after creation: %v", traderID, getErr)
startupWarning = describeTraderCreationWarning(req.Name, getErr)
}
}
logger.Infof("✓ Trader created successfully: %s (model: %s, exchange: %s)", req.Name, req.AIModelID, req.ExchangeID)
c.JSON(http.StatusCreated, gin.H{
"trader_id": traderID,
"trader_name": req.Name,
"ai_model": req.AIModelID,
"is_running": false,
"startup_warning": startupWarning,
})
}
// handleUpdateTrader Update trader configuration
func (s *Server) handleUpdateTrader(c *gin.Context) {
userID := c.GetString("user_id")
traderID := c.Param("id")
var req UpdateTraderRequest
if err := c.ShouldBindJSON(&req); err != nil {
SafeBadRequest(c, "Invalid request parameters")
return
}
// Check if trader exists and belongs to current user
traders, err := s.store.Trader().List(userID)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to get trader list"})
return
}
var existingTrader *store.Trader
for _, t := range traders {
if t.ID == traderID {
existingTrader = t
break
}
}
if existingTrader == nil {
c.JSON(http.StatusNotFound, gin.H{"error": "Trader does not exist"})
return
}
if errMsg, errCode := validateTraderLeverageRange(req.BTCETHLeverage, req.AltcoinLeverage); errMsg != "" {
SafeBadRequestWithDetails(c, errMsg, errCode, nil)
return
}
// Set default values
isCrossMargin := existingTrader.IsCrossMargin // Keep original value
if req.IsCrossMargin != nil {
isCrossMargin = *req.IsCrossMargin
}
showInCompetition := existingTrader.ShowInCompetition // Keep original value
if req.ShowInCompetition != nil {
showInCompetition = *req.ShowInCompetition
}
// Set leverage default values
btcEthLeverage := req.BTCETHLeverage
altcoinLeverage := req.AltcoinLeverage
if btcEthLeverage <= 0 {
btcEthLeverage = existingTrader.BTCETHLeverage // Keep original value
}
if altcoinLeverage <= 0 {
altcoinLeverage = existingTrader.AltcoinLeverage // Keep original value
}
// Set scan interval, allow updates
scanIntervalMinutes := req.ScanIntervalMinutes
logger.Infof("📊 Update trader scan_interval: req=%d, existing=%d", req.ScanIntervalMinutes, existingTrader.ScanIntervalMinutes)
if scanIntervalMinutes <= 0 {
scanIntervalMinutes = existingTrader.ScanIntervalMinutes // Keep original value
} else if scanIntervalMinutes < 3 {
scanIntervalMinutes = 3
}
logger.Infof("📊 Final scan_interval_minutes: %d", scanIntervalMinutes)
// Set system prompt template
systemPromptTemplate := req.SystemPromptTemplate
if systemPromptTemplate == "" {
systemPromptTemplate = existingTrader.SystemPromptTemplate // Keep original value
}
// Handle strategy ID (if not provided, keep original value)
strategyID := req.StrategyID
if strategyID == "" {
strategyID = existingTrader.StrategyID
}
exchangeChanged := req.ExchangeID != "" && req.ExchangeID != existingTrader.ExchangeID
resetInitialBalance := exchangeChanged && req.InitialBalance <= 0
initialBalance := existingTrader.InitialBalance
if req.InitialBalance > 0 {
initialBalance = req.InitialBalance
}
if resetInitialBalance {
initialBalance = 0
}
// Update trader configuration
traderRecord := &store.Trader{
ID: traderID,
UserID: userID,
Name: req.Name,
AIModelID: req.AIModelID,
ExchangeID: req.ExchangeID,
StrategyID: strategyID, // Associated strategy ID
InitialBalance: initialBalance,
BTCETHLeverage: btcEthLeverage,
AltcoinLeverage: altcoinLeverage,
TradingSymbols: req.TradingSymbols,
CustomPrompt: req.CustomPrompt,
OverrideBasePrompt: req.OverrideBasePrompt,
SystemPromptTemplate: systemPromptTemplate,
IsCrossMargin: isCrossMargin,
ShowInCompetition: showInCompetition,
ScanIntervalMinutes: scanIntervalMinutes,
IsRunning: existingTrader.IsRunning, // Keep original value
}
// Check if trader was running before update (we'll restart it after)
wasRunning := false
if existingMemTrader, memErr := s.traderManager.GetTrader(traderID); memErr == nil {
status := existingMemTrader.GetStatus()
if running, ok := status["is_running"].(bool); ok && running {
wasRunning = true
logger.Infof("🔄 Trader %s was running, will restart with new config after update", traderID)
}
}
// Update database
logger.Infof("🔄 Updating trader: ID=%s, Name=%s, AIModelID=%s, StrategyID=%s, ScanInterval=%d min",
traderRecord.ID, traderRecord.Name, traderRecord.AIModelID, traderRecord.StrategyID, scanIntervalMinutes)
err = s.store.Trader().Update(traderRecord)
if err != nil {
SafeInternalError(c, "Failed to update trader", err)
return
}
if resetInitialBalance {
logger.Infof("🔄 Exchange changed for trader %s, resetting stale initial_balance to 0", traderID)
if err := s.store.Trader().UpdateInitialBalance(userID, traderID, 0); err != nil {
SafeInternalError(c, "Failed to reset trader initial balance", err)
return
}
}
// Remove old trader from memory first (this also stops if running)
s.traderManager.RemoveTrader(traderID)
// Reload traders into memory with fresh config
err = s.traderManager.LoadUserTradersFromStore(s.store, userID)
if err != nil {
logger.Infof("⚠️ Failed to reload user traders into memory: %v", err)
}
// If trader was running before, restart it with new config
if wasRunning {
if reloadedTrader, getErr := s.traderManager.GetTrader(traderID); getErr == nil {
go func() {
logger.Infof("▶️ Restarting trader %s with new config...", traderID)
if runErr := reloadedTrader.Run(); runErr != nil {
logger.Infof("❌ Trader %s runtime error: %v", traderID, runErr)
}
}()
}
}
logger.Infof("✓ Trader updated successfully: %s (model: %s, exchange: %s, strategy: %s)", req.Name, req.AIModelID, req.ExchangeID, strategyID)
c.JSON(http.StatusOK, gin.H{
"trader_id": traderID,
"trader_name": req.Name,
"ai_model": req.AIModelID,
"message": "Trader updated successfully",
})
}
// handleDeleteTrader Delete trader
func (s *Server) handleDeleteTrader(c *gin.Context) {
userID := c.GetString("user_id")
traderID := c.Param("id")
// Delete from database
err := s.store.Trader().Delete(userID, traderID)
if err != nil {
SafeInternalError(c, "Failed to delete trader", err)
return
}
// If trader is running, stop it first
if trader, err := s.traderManager.GetTrader(traderID); err == nil {
status := trader.GetStatus()
if isRunning, ok := status["is_running"].(bool); ok && isRunning {
trader.Stop()
logger.Infof("⏹ Stopped running trader: %s", traderID)
}
}
// Remove trader from memory
s.traderManager.RemoveTrader(traderID)
logger.Infof("✓ Trader deleted: %s", traderID)
c.JSON(http.StatusOK, gin.H{"message": "Trader deleted"})
}
// handleStartTrader Start trader
func (s *Server) handleStartTrader(c *gin.Context) {
userID := c.GetString("user_id")
traderID := c.Param("id")
// Verify trader belongs to current user
fullCfg, err := s.store.Trader().GetFullConfig(userID, traderID)
if err != nil {
c.JSON(http.StatusNotFound, gin.H{"error": "Trader does not exist or no access permission"})
return
}
traderName := traderID
if fullCfg != nil && fullCfg.Trader != nil && fullCfg.Trader.Name != "" {
traderName = fullCfg.Trader.Name
}
if fullCfg != nil && fullCfg.Exchange != nil && fullCfg.Exchange.ExchangeType == "hyperliquid" && !fullCfg.Exchange.HyperliquidBuilderApproved {
SafeBadRequestWithDetails(c, formatTraderStartError(
fmt.Sprintf("The Hyperliquid trading authorization for bot \"%s\" is not yet complete", traderName),
"Please reconnect the Hyperliquid wallet and complete the trading authorization, then start the bot",
), "trader.start.hyperliquid_builder_not_approved", mapStringPairs("trader_name", traderName, "exchange_name", exchangeDisplayName(fullCfg.Exchange)))
return
}
// Check if trader exists in memory and if it's running
existingTrader, _ := s.traderManager.GetTrader(traderID)
if existingTrader != nil {
status := existingTrader.GetStatus()
if isRunning, ok := status["is_running"].(bool); ok && isRunning {
c.JSON(http.StatusBadRequest, gin.H{"error": "Trader is already running"})
return
}
// Trader exists but is stopped - remove from memory to reload fresh config
logger.Infof("🔄 Removing stopped trader %s from memory to reload config...", traderID)
s.traderManager.RemoveTrader(traderID)
}
// Load trader from database (always reload to get latest config)
logger.Infof("🔄 Loading trader %s from database...", traderID)
if loadErr := s.traderManager.LoadUserTradersFromStore(s.store, userID); loadErr != nil {
logger.Infof("❌ Failed to load user traders: %v", loadErr)
SafeErrorWithDetails(c, http.StatusInternalServerError, describeTraderStartError(traderName, loadErr), "trader.start.load_failed", traderSetupReasonParams(loadErr, "", "trader_name", traderName), loadErr)
return
}
trader, err := s.traderManager.GetTrader(traderID)
if err != nil {
if fullCfg != nil && fullCfg.Trader != nil {
// Check strategy
if fullCfg.Strategy == nil {
SafeBadRequestWithDetails(c, describeTraderStartError(traderName, fmt.Errorf("trader has no strategy configured")), "trader.start.strategy_missing", mapStringPairs("trader_name", traderName))
return
}
// Check AI model
if fullCfg.AIModel == nil {
SafeBadRequestWithDetails(c, formatTraderStartError("The AI model associated with this bot does not exist", "Please go to \"Settings > Model Config\" to check, then click start again"), "trader.start.model_not_found", mapStringPairs("trader_name", traderName))
return
}
if !fullCfg.AIModel.Enabled {
SafeBadRequestWithDetails(c, formatTraderStartError(
fmt.Sprintf("The AI model \"%s\" associated with bot \"%s\" is not enabled yet", fullCfg.AIModel.Name, traderName),
"Please go to \"Settings > Model Config\" to enable it, then click start again",
), "trader.start.model_disabled", mapStringPairs("trader_name", traderName, "model_name", fullCfg.AIModel.Name))
return
}
// Check exchange
if fullCfg.Exchange == nil {
SafeBadRequestWithDetails(c, formatTraderStartError("The exchange account associated with this bot does not exist", "Please go to \"Settings > Exchange Config\" to check, then click start again"), "trader.start.exchange_not_found", mapStringPairs("trader_name", traderName))
return
}
if !fullCfg.Exchange.Enabled {
SafeBadRequestWithDetails(c, formatTraderStartError(
fmt.Sprintf("The exchange account \"%s\" associated with bot \"%s\" is not enabled yet", exchangeDisplayName(fullCfg.Exchange), traderName),
"Please go to \"Settings > Exchange Config\" to enable it, then click start again",
), "trader.start.exchange_disabled", mapStringPairs("trader_name", traderName, "exchange_name", exchangeDisplayName(fullCfg.Exchange)))
return
}
}
// Check if there's a specific load error
if loadErr := s.traderManager.GetLoadError(traderID); loadErr != nil {
SafeBadRequestWithDetails(c, describeTraderStartError(traderName, loadErr), "trader.start.load_failed", traderSetupReasonParams(loadErr, "", "trader_name", traderName))
return
}
SafeBadRequestWithDetails(c, describeTraderStartError(traderName, err), "trader.start.setup_invalid", traderSetupReasonParams(err, "", "trader_name", traderName))
return
}
// 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 Auto Strategy", "The only built-in strategy: read the Claw402.ai board each cycle, fetch Signal Lab and cost/liquidation heatmap per candidate, then decide with raw candles."},
},
"en": {
defaultStrategy: strategyI18n{"NOFX Claw402 Auto Strategy", "The only built-in strategy: read the Claw402.ai board each cycle, fetch Signal Lab and cost/liquidation heatmap per candidate, then decide with raw candles."},
},
"id": {
defaultStrategy: strategyI18n{"Strategi Otomatis NOFX Claw402", "Satu strategi bawaan: membaca papan Claw402.ai, mengambil Signal Lab dan heatmap biaya/likuidasi per kandidat, lalu memutuskan dengan candle mentah."},
},
}
locale, ok := locales[lang]
if !ok {
locale = locales["en"]
}
type strategyDef struct {
name string
description string
isActive bool
applyConfig func(*store.StrategyConfig)
}
setClaw402Strategy := func(c *store.StrategyConfig) {
c.CoinSource.SourceType = "vergex_signal"
c.CoinSource.StaticCoins = nil
c.CoinSource.UseAI500 = false
c.CoinSource.UseOITop = false
c.CoinSource.UseOILow = false
c.CoinSource.UseHyperAll = false
c.CoinSource.UseHyperMain = false
c.CoinSource.HyperRankCategory = "all"
c.CoinSource.VergexLimit = 10
c.CoinSource.VergexMarketType = "all"
c.CoinSource.VergexChain = "hyperliquid"
c.RiskControl.MaxPositions = 2
c.RiskControl.BTCETHMaxLeverage = 10
c.RiskControl.AltcoinMaxLeverage = 10
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", "Steady Strategy", "Aggressive Strategy",
"US Stock Trend Strategy", "US Stock Steady Strategy", "US Stock Breakout Strategy",
"Balanced Strategy", "Conservative Strategy", "Aggressive Strategy",
"US Stock Trend Strategy", "US Stock Steady Strategy", "US Stock Breakout Strategy",
"Strategi Seimbang", "Strategi Konservatif", "Strategi Agresif",
"Strategi Tren Saham AS", "Strategi Stabil Saham AS", "Strategi Breakout Saham AS",
}
return s.store.Transaction(func(tx *gorm.DB) error {
// Remove obsolete built-in risk-profile presets for this user. If a trader still
// references one of them, keep it to avoid breaking an existing running setup.
deleteResult := tx.Where("user_id = ? AND name IN ? AND id NOT IN (SELECT strategy_id FROM traders WHERE user_id = ? AND strategy_id IS NOT NULL)", userID, legacyDefaultNames, userID).
Delete(&store.Strategy{})
if deleteResult.Error != nil {
return fmt.Errorf("failed to remove legacy default strategies: %w", deleteResult.Error)
}
if deleteResult.RowsAffected > 0 {
logger.Infof(" ✓ Removed %d legacy default strategy preset(s)", deleteResult.RowsAffected)
}
var activeCount int64
if err := tx.Model(&store.Strategy{}).Where("user_id = ? AND is_active = ?", userID, true).Count(&activeCount).Error; err != nil {
return fmt.Errorf("failed to count active strategies: %w", err)
}
for _, strategy := range strategies {
var existing int64
if err := tx.Model(&store.Strategy{}).Where("user_id = ? AND name = ?", userID, strategy.Name).Count(&existing).Error; err != nil {
return fmt.Errorf("failed to check strategy %q: %w", strategy.Name, err)
}
if existing > 0 {
continue
}
if activeCount > 0 {
strategy.IsActive = false
}
if err := tx.Create(strategy).Error; err != nil {
return fmt.Errorf("failed to create strategy %q: %w", strategy.Name, err)
}
if strategy.IsActive {
activeCount++
}
logger.Infof(" ✓ Created default strategy: %s (active=%v)", strategy.Name, strategy.IsActive)
}
return nil
})
}

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 == "Balanced Strategy" || strategy.Name == "Steady Strategy" || strategy.Name == "Aggressive Strategy" {
t.Fatalf("legacy crypto-style default strategy still present: %s", strategy.Name)
}
}
if activeCount != 1 {
t.Fatalf("expected exactly one active strategy, got %d", activeCount)
}
defaultStrategy := byName["NOFX Claw402 Auto Strategy"]
if defaultStrategy == nil || !defaultStrategy.IsActive {
t.Fatalf("NOFX Claw402 Auto Strategy should exist and be active")
}
trendCfg, err := defaultStrategy.ParseConfig()
if err != nil {
t.Fatalf("default ParseConfig failed: %v", err)
}
if trendCfg.CoinSource.SourceType != "vergex_signal" || trendCfg.CoinSource.VergexLimit != 10 || trendCfg.CoinSource.VergexMarketType != "all" {
t.Fatalf("default strategy should use the Claw402/Vergex all-market signal ranking, got %+v", trendCfg.CoinSource)
}
if trendCfg.CoinSource.UseAI500 || trendCfg.RiskControl.MaxPositions > 2 {
t.Fatalf("default strategy should be Claw402/Vergex native with at most two positions, got coin=%+v risk=%+v", trendCfg.CoinSource, trendCfg.RiskControl)
}
if trendCfg.RiskControl.BTCETHMaxLeverage != 10 || trendCfg.RiskControl.AltcoinMaxLeverage != 10 {
t.Fatalf("default strategy should use 10x leverage for all Claw402 opens, got risk=%+v", trendCfg.RiskControl)
}
if trendCfg.RiskControl.BTCETHMaxPositionValueRatio != 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: "Balanced Strategy",
Description: "legacy",
IsActive: false,
}
if err := legacy.SetConfig(&legacyCfg); err != nil {
t.Fatalf("legacy SetConfig failed: %v", err)
}
if err := st.Strategy().Create(legacy); err != nil {
t.Fatalf("create legacy failed: %v", err)
}
custom := &store.Strategy{
ID: uuid.New().String(),
UserID: userID,
Name: "aa",
Description: "user custom active strategy",
IsActive: true,
}
if err := custom.SetConfig(&legacyCfg); err != nil {
t.Fatalf("custom SetConfig failed: %v", err)
}
if err := st.Strategy().Create(custom); err != nil {
t.Fatalf("create custom failed: %v", err)
}
s := &Server{store: st}
if err := s.createDefaultStrategies(userID, "zh"); err != nil {
t.Fatalf("createDefaultStrategies failed: %v", err)
}
if err := s.createDefaultStrategies(userID, "zh"); err != nil {
t.Fatalf("second createDefaultStrategies should be idempotent: %v", err)
}
strategies, err := st.Strategy().List(userID)
if err != nil {
t.Fatalf("List strategies failed: %v", err)
}
byName := map[string]int{}
activeNames := []string{}
for _, strategy := range strategies {
byName[strategy.Name]++
if strategy.IsActive {
activeNames = append(activeNames, strategy.Name)
}
}
if byName["Balanced Strategy"] != 0 {
t.Fatalf("legacy preset should be removed, got names=%+v", byName)
}
if byName["NOFX Claw402 Auto Strategy"] != 1 {
t.Fatalf("expected exactly one NOFX Claw402 Auto Strategy, got names=%+v", byName)
}
if len(activeNames) != 1 || activeNames[0] != "aa" {
t.Fatalf("existing active custom strategy should stay the only active one, got %+v", activeNames)
}
}

View File

@@ -1,137 +0,0 @@
package api
import (
"context"
"fmt"
"net/http"
"nofx/logger"
"nofx/provider/vergex"
"strings"
"github.com/gin-gonic/gin"
)
func (s *Server) handleVergexSignalRanking(c *gin.Context) {
client, ok := s.newVergexClientForRequest(c)
if !ok {
return
}
data, err := client.GetSignalRanking(context.Background(), vergex.Query{
Chain: strings.TrimSpace(c.Query("chain")),
LiqBand: strings.TrimSpace(c.Query("liqBand")),
})
if err != nil {
logger.Warnf("Vergex signal-ranking failed: %v", err)
c.JSON(http.StatusBadGateway, gin.H{"error": err.Error()})
return
}
limit := parsePositiveInt(c.Query("limit"), vergex.MaxSignalRankingItems)
marketType := strings.TrimSpace(c.Query("marketType"))
items := vergex.FilterSignalRankingItems(data.Items, marketType, limit)
c.JSON(http.StatusOK, gin.H{
"items": items,
"raw": data.Raw,
})
}
func (s *Server) handleVergexSignalLab(c *gin.Context) {
client, ok := s.newVergexClientForRequest(c)
if !ok {
return
}
body, err := client.GetSignalLab(context.Background(), vergex.Query{
MarketType: withDefault(strings.TrimSpace(c.Query("marketType")), vergex.DefaultMarketType),
Symbol: strings.TrimSpace(c.Query("symbol")),
Chain: strings.TrimSpace(c.Query("chain")),
LiqBand: strings.TrimSpace(c.Query("liqBand")),
})
if err != nil {
logger.Warnf("Vergex signal-lab failed: %v", err)
c.JSON(http.StatusBadGateway, gin.H{"error": err.Error()})
return
}
c.Data(http.StatusOK, "application/json; charset=utf-8", body)
}
func (s *Server) handleVergexCostLiquidationHeatmap(c *gin.Context) {
client, ok := s.newVergexClientForRequest(c)
if !ok {
return
}
body, err := client.GetCostLiquidationHeatmap(context.Background(), vergex.Query{
MarketType: withDefault(strings.TrimSpace(c.Query("marketType")), vergex.DefaultMarketType),
Symbol: strings.TrimSpace(c.Query("symbol")),
Chain: strings.TrimSpace(c.Query("chain")),
LiqBand: strings.TrimSpace(c.Query("liqBand")),
})
if err != nil {
logger.Warnf("Vergex cost-liquidation-heatmap failed: %v", err)
c.JSON(http.StatusBadGateway, gin.H{"error": err.Error()})
return
}
c.Data(http.StatusOK, "application/json; charset=utf-8", body)
}
// handleVergexFlowMarkets proxies the Vergex net-flow market ranking (paid x402
// endpoint) using the caller's claw402 wallet. The upstream JSON is passed
// through verbatim: { data: { window, by, inflow: [{ symbol, netFlow,
// buyNotional, sellNotional, trades, latestPrice }, ...] } }.
func (s *Server) handleVergexFlowMarkets(c *gin.Context) {
client, ok := s.newVergexClientForRequest(c)
if !ok {
return
}
chain := withDefault(strings.TrimSpace(c.Query("chain")), "mainnet")
window := withDefault(strings.TrimSpace(c.Query("window")), "1h")
limit := parsePositiveInt(c.Query("limit"), 25)
body, err := client.GetFlowMarkets(context.Background(), chain, window, limit)
if err != nil {
logger.Warnf("Vergex flow-markets failed: %v", err)
c.JSON(http.StatusBadGateway, gin.H{"error": err.Error()})
return
}
c.Data(http.StatusOK, "application/json; charset=utf-8", body)
}
func (s *Server) newVergexClientForRequest(c *gin.Context) (*vergex.Client, bool) {
userID := c.GetString("user_id")
if userID == "" {
c.JSON(http.StatusUnauthorized, gin.H{"error": "Unauthorized"})
return nil, false
}
walletKey, err := s.resolveStrategyDataWalletKey(userID, c.Query("ai_model_id"))
if err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return nil, false
}
if walletKey == "" {
c.JSON(http.StatusBadRequest, gin.H{"error": "claw402 wallet is not configured"})
return nil, false
}
client, err := vergex.NewClient("", walletKey, &logger.MCPLogger{})
if err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return nil, false
}
return client, true
}
func parsePositiveInt(raw string, fallback int) int {
if raw == "" {
return fallback
}
var n int
if _, err := fmt.Sscanf(raw, "%d", &n); err != nil || n <= 0 {
return fallback
}
return n
}
func withDefault(value, fallback string) string {
if strings.TrimSpace(value) == "" {
return fallback
}
return value
}

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

View File

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

View File

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

View File

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

View File

@@ -1,12 +1,15 @@
package auth package auth
import ( import (
"crypto/rand"
"fmt" "fmt"
"log" "log"
"sync" "sync"
"time" "time"
"github.com/golang-jwt/jwt/v5" "github.com/golang-jwt/jwt/v5"
"github.com/google/uuid"
"github.com/pquerna/otp/totp"
"golang.org/x/crypto/bcrypt" "golang.org/x/crypto/bcrypt"
) )
@@ -22,6 +25,9 @@ var tokenBlacklist = struct {
// maxBlacklistEntries is the maximum capacity threshold for blacklist // maxBlacklistEntries is the maximum capacity threshold for blacklist
const maxBlacklistEntries = 100_000 const maxBlacklistEntries = 100_000
// OTPIssuer is the OTP issuer name
const OTPIssuer = "nofxAI"
// SetJWTSecret sets the JWT secret key // SetJWTSecret sets the JWT secret key
func SetJWTSecret(secret string) { func SetJWTSecret(secret string) {
JWTSecret = []byte(secret) JWTSecret = []byte(secret)
@@ -81,6 +87,30 @@ func CheckPassword(password, hash string) bool {
return err == nil return err == nil
} }
// GenerateOTPSecret generates OTP secret
func GenerateOTPSecret() (string, error) {
secret := make([]byte, 20)
_, err := rand.Read(secret)
if err != nil {
return "", err
}
key, err := totp.Generate(totp.GenerateOpts{
Issuer: OTPIssuer,
AccountName: uuid.New().String(),
})
if err != nil {
return "", err
}
return key.Secret(), nil
}
// VerifyOTP verifies OTP code
func VerifyOTP(secret, code string) bool {
return totp.Validate(code, secret)
}
// GenerateJWT generates JWT token // GenerateJWT generates JWT token
func GenerateJWT(userID, email string) (string, error) { func GenerateJWT(userID, email string) (string, error) {
claims := Claims{ claims := Claims{
@@ -117,3 +147,8 @@ func ValidateJWT(tokenString string) (*Claims, error) {
return nil, fmt.Errorf("invalid token") return nil, fmt.Errorf("invalid token")
} }
// GetOTPQRCodeURL gets OTP QR code URL
func GetOTPQRCodeURL(secret, email string) string {
return fmt.Sprintf("otpauth://totp/%s:%s?secret=%s&issuer=%s", OTPIssuer, email, secret, OTPIssuer)
}

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

View File

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

1416
debate/engine.go Normal file

File diff suppressed because it is too large Load Diff

View File

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

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 │ │ NOFX Platform │
├─────────────────────────────────────────────────────────────────────────────┤ ├─────────────────────────────────────────────────────────────────────────────┤
│ │ │ │
│ ┌─────────────┐ ┌─────────────────────────────────────┐│ │ ┌─────────────┐ ┌─────────────┐ ┌─────────────┐ ┌─────────────────────┐│
│ │ Strategy │ │ Live Trading ││ │ │ Strategy │ │ Backtest Debate Live Trading ││
│ │ Studio │ │ (Auto Trader) ││ │ │ Studio │ │ Engine │ │ Arena (Auto Trader) ││
│ └──────┬──────┘ └──────────────────┬──────────────────┘│ │ └──────┬──────┘ └────────────┘ └──────┬──────┘ └──────────┬──────────┘│
│ │ │ │ │ │
│ └────────────────────────────┘ │ └────────────────┴────────────────┴────────────────────┘ │
│ │ │ │ │ │
│ ┌─────────▼─────────┐ │ │ ┌─────────▼─────────┐ │
│ │ Core Services │ │ │ │ Core Services │ │
@@ -57,6 +57,8 @@ NOFX is a full-stack AI trading platform for cryptocurrency and US stock markets
| Module | Description | Documentation | | Module | Description | Documentation |
|--------|-------------|---------------| |--------|-------------|---------------|
| **Strategy Studio** | Strategy configuration, coin selection, data assembly, AI prompts | [STRATEGY_MODULE.md](STRATEGY_MODULE.md) | | **Strategy Studio** | Strategy configuration, coin selection, data assembly, AI prompts | [STRATEGY_MODULE.md](STRATEGY_MODULE.md) |
| **Backtest Engine** | Historical simulation, performance metrics, AI decision replay | [BACKTEST_MODULE.md](BACKTEST_MODULE.md) |
| **Debate Arena** | Multi-AI collaborative decision making with voting consensus | [DEBATE_MODULE.md](DEBATE_MODULE.md) |
### Module Overview ### Module Overview
@@ -70,6 +72,26 @@ Complete strategy configuration system including:
**[Read Full Documentation →](STRATEGY_MODULE.md)** **[Read Full Documentation →](STRATEGY_MODULE.md)**
#### Backtest Module
Historical trading simulation engine:
- Multi-symbol, multi-timeframe backtesting
- AI decision replay with caching
- Performance metrics (Sharpe, drawdown, win rate)
- Real-time progress streaming via SSE
- Checkpoint and resume support
**[Read Full Documentation →](BACKTEST_MODULE.md)**
#### Debate Module
Multi-AI collaborative decision system:
- 5 AI personalities (Bull, Bear, Analyst, Contrarian, Risk Manager)
- Multi-round debate with market context
- Weighted voting and consensus algorithm
- Auto-execution to live trading
- Real-time SSE streaming
**[Read Full Documentation →](DEBATE_MODULE.md)**
--- ---
## Project Structure ## Project Structure
@@ -80,6 +102,8 @@ nofx/
├── api/ # HTTP API (Gin framework) ├── api/ # HTTP API (Gin framework)
├── trader/ # Trading execution layer ├── trader/ # Trading execution layer
├── strategy/ # Strategy engine ├── strategy/ # Strategy engine
├── backtest/ # Backtest simulation engine
├── debate/ # Debate arena engine
├── market/ # Market data service ├── market/ # Market data service
├── mcp/ # AI model clients ├── mcp/ # AI model clients
├── store/ # Database operations ├── store/ # Database operations
@@ -119,6 +143,8 @@ nofx/
## Quick Links ## Quick Links
- [Strategy Module](STRATEGY_MODULE.md) - How strategies work - [Strategy Module](STRATEGY_MODULE.md) - How strategies work
- [Backtest Module](BACKTEST_MODULE.md) - How backtesting works
- [Debate Module](DEBATE_MODULE.md) - How AI debates work
- [Getting Started](../getting-started/README.md) - Setup guide - [Getting Started](../getting-started/README.md) - Setup guide
- [FAQ](../faq/README.md) - Frequently asked questions - [FAQ](../faq/README.md) - Frequently asked questions

View File

@@ -24,12 +24,12 @@ NOFX 是一个支持加密货币和美股市场的全栈 AI 交易平台:
│ NOFX 平台 │ │ NOFX 平台 │
├─────────────────────────────────────────────────────────────────────────────┤ ├─────────────────────────────────────────────────────────────────────────────┤
│ │ │ │
│ ┌─────────────┐ ┌─────────────────────────────────────┐│ │ ┌─────────────┐ ┌─────────────┐ ┌─────────────┐ ┌─────────────────────┐│
│ │ 策略 │ │ 实盘交易 ││ │ │ 策略 │ │ 回测 │ 辩论 │ │ 实盘交易 ││
│ │ 工作室 │ │ (自动交易员) ││ │ │ 工作室 │ │ 引擎 │ │ 竞技场 (自动交易员) ││
│ └──────┬──────┘ └──────────────────┬──────────────────┘│ │ └──────┬──────┘ └────────────┘ └──────┬──────┘ └──────────┬──────────┘│
│ │ │ │ │ │
│ └────────────────────────────┘ │ └────────────────┴────────────────┴────────────────────┘ │
│ │ │ │ │ │
│ ┌─────────▼─────────┐ │ │ ┌─────────▼─────────┐ │
│ │ 核心服务 │ │ │ │ 核心服务 │ │
@@ -57,6 +57,8 @@ NOFX 是一个支持加密货币和美股市场的全栈 AI 交易平台:
| 模块 | 描述 | 文档 | | 模块 | 描述 | 文档 |
|------|------|------| |------|------|------|
| **策略工作室** | 策略配置、币种选择、数据组装、AI 提示词 | [STRATEGY_MODULE.md](STRATEGY_MODULE.md) | | **策略工作室** | 策略配置、币种选择、数据组装、AI 提示词 | [STRATEGY_MODULE.md](STRATEGY_MODULE.md) |
| **回测引擎** | 历史模拟、性能指标、AI 决策回放 | [BACKTEST_MODULE.md](BACKTEST_MODULE.md) |
| **辩论竞技场** | 多 AI 协作决策,投票共识机制 | [DEBATE_MODULE.md](DEBATE_MODULE.md) |
### 模块概览 ### 模块概览
@@ -70,6 +72,26 @@ NOFX 是一个支持加密货币和美股市场的全栈 AI 交易平台:
**[阅读完整文档 →](STRATEGY_MODULE.md)** **[阅读完整文档 →](STRATEGY_MODULE.md)**
#### 回测模块
历史交易模拟引擎:
- 多币种、多时间周期回测
- AI 决策回放与缓存
- 性能指标(夏普比率、最大回撤、胜率)
- SSE 实时进度推送
- 断点续测支持
**[阅读完整文档 →](BACKTEST_MODULE.md)**
#### 辩论模块
多 AI 协作决策系统:
- 5 种 AI 性格(多头、空头、分析师、逆势者、风控)
- 多轮辩论与市场数据上下文
- 加权投票与共识算法
- 自动执行到实盘交易
- SSE 实时流推送
**[阅读完整文档 →](DEBATE_MODULE.md)**
--- ---
## 项目结构 ## 项目结构
@@ -80,6 +102,8 @@ nofx/
├── api/ # HTTP API (Gin 框架) ├── api/ # HTTP API (Gin 框架)
├── trader/ # 交易执行层 ├── trader/ # 交易执行层
├── strategy/ # 策略引擎 ├── strategy/ # 策略引擎
├── backtest/ # 回测模拟引擎
├── debate/ # 辩论竞技场引擎
├── market/ # 行情数据服务 ├── market/ # 行情数据服务
├── mcp/ # AI 模型客户端 ├── mcp/ # AI 模型客户端
├── store/ # 数据库操作 ├── store/ # 数据库操作
@@ -119,6 +143,8 @@ nofx/
## 快速链接 ## 快速链接
- [策略模块](STRATEGY_MODULE.md) - 策略如何运作 - [策略模块](STRATEGY_MODULE.md) - 策略如何运作
- [回测模块](BACKTEST_MODULE.md) - 回测如何运作
- [辩论模块](DEBATE_MODULE.md) - AI 辩论如何运作
- [快速开始](../getting-started/README.zh-CN.md) - 部署指南 - [快速开始](../getting-started/README.zh-CN.md) - 部署指南
- [常见问题](../faq/README.md) - FAQ - [常见问题](../faq/README.md) - FAQ

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) - **Want to claim bounty?** → [Bounty Guide](bounty-guide.md)
- **Found a security issue?** → [Security Policy](../../SECURITY.md) - **Found a security issue?** → [Security Policy](../../SECURITY.md)
- **Have questions?** → [Telegram Community](https://t.me/nofx_dev_community) - **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 | | 🇬🇧 **English** | [README.md](../../README.md) | ✅ Complete | Core Team |
| 🇨🇳 **Chinese (中文)** | [README.md](zh-CN/README.md) | ✅ Complete | Community | | 🇨🇳 **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 | | 🇷🇺 **Russian (Русский)** | [README.md](ru/README.md) | ✅ Complete | Community |
| 🇺🇦 **Ukrainian (Українська)** | [README.md](uk/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#中文) - **安全政策:** [../../SECURITY.md](../../SECURITY.md#中文)
- **常见问题:** [../guides/faq.zh-CN.md](../guides/faq.zh-CN.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) - **Основной README:** [ru/README.md](ru/README.md)
- **Руководство по участию:** [../../CONTRIBUTING.md](../../CONTRIBUTING.md) - **Руководство по участию:** [../../CONTRIBUTING.md](../../CONTRIBUTING.md)
@@ -53,11 +40,6 @@ NOFX 文档提供多种语言版本。
- **Посібник із внесків:** [../../CONTRIBUTING.md](../../CONTRIBUTING.md) - **Посібник із внесків:** [../../CONTRIBUTING.md](../../CONTRIBUTING.md)
- **Політика безпеки:** [../../SECURITY.md](../../SECURITY.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 / 帮助翻译 ## 🤝 Help with Translations / 帮助翻译
@@ -67,7 +49,7 @@ NOFX 文档提供多种语言版本。
We welcome translation contributions! / 我们欢迎翻译贡献! We welcome translation contributions! / 我们欢迎翻译贡献!
**What needs translation? / 需要翻译什么?** **What needs translation? / 需要翻译什么?**
- ✅ Main README (complete for 7 languages) - ✅ Main README (complete for 4 languages)
- 🚧 Deployment guides (partial) - 🚧 Deployment guides (partial)
- 📋 User guides (needed) - 📋 User guides (needed)
- 📋 Contributing guide (needed for RU/UK) - 📋 Contributing guide (needed for RU/UK)
@@ -112,11 +94,10 @@ faq.zh-CN.md → Chinese FAQ
**Language Codes:** **Language Codes:**
- `en` - English (default, no suffix needed) - `en` - English (default, no suffix needed)
- `zh-CN` - Simplified Chinese - `zh-CN` - Simplified Chinese
- `ja` - Japanese
- `ko` - Korean
- `ru` - Russian - `ru` - Russian
- `uk` - Ukrainian - `uk` - Ukrainian
- `vi` - Vietnamese - `ja` - Japanese *(future)*
- `ko` - Korean *(future)*
### Quality Standards / 质量标准 ### Quality Standards / 质量标准

View File

@@ -1,321 +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"> **言語:** [English](../../../README.md) | [中文](../zh-CN/README.md) | [日本語](README.md)
<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>
--- ---
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 ```bash
curl -fsSL https://raw.githubusercontent.com/NoFxAiOS/nofx/main/install.sh | bash curl -fsSL https://raw.githubusercontent.com/NoFxAiOS/nofx/main/install.sh | bash
``` ```
**http://127.0.0.1:3000** を開きます。 このコマンドは最新の公式イメージを取得し、サービスを自動的に再起動します。
--- ### オプション 2: 手動インストール
## 取引所登録
以下のリンクから、暗号資産および対応する米国株、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) |
---
## クイックデモ
https://github.com/user-attachments/assets/3310f495-14c5-4586-a1cc-3d32e44aa505
---
## 市場
**米国株 · コモディティ · 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
```bash ```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 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 ```bash
# サーバーにデプロイ
curl -fsSL https://raw.githubusercontent.com/NoFxAiOS/nofx/main/install.sh | bash curl -fsSL https://raw.githubusercontent.com/NoFxAiOS/nofx/main/install.sh | bash
``` ```
--- `http://YOUR_SERVER_IP:3000` 経由でアクセス - すぐに動作します。
## セットアップ ###キュリティ強化 (HTTPS)
**初心者モード**:ガイド付き onboarding により、モデルアクセス、取引所接続、戦略設定、初回デプロイまで進められます セキュリティを強化するには、`.env`でトランスポート暗号化を有効にします:
**上級モード**
1. AI モデルアクセスを設定
2. 取引所の認証情報を接続
3. 戦略を構築またはインポート
4. AI トレーダープロファイルを作成
5. ダッシュボードから起動、監視、改善
すべての設定は Web UI **http://127.0.0.1:3000** から行えます。
---
## サーバーへのデプロイ
**HTTP デプロイ:**
```bash ```bash
curl -fsSL https://raw.githubusercontent.com/NoFxAiOS/nofx/main/install.sh | bash TRANSPORT_ENCRYPTION=true
# http://YOUR_IP:3000 でアクセス
``` ```
**Cloudflare 経由の HTTPS** 有効にすると、ブラウザはWeb Crypto APIを使用して転送前にAPIキーを暗号化します。これには以下が必要です:
- `https://` - SSLを備えた任意のドメイン
- `http://localhost` - ローカル開発
1. [Cloudflare](https://dash.cloudflare.com)(無料プラン)にドメインを追加 ### Cloudflareを使用した簡単なHTTPSセットアップ
2. A レコードをサーバー IP に設定Proxied
3. SSL/TLS を Flexible に設定 1. **ドメインをCloudflareに追加** (無料プランでOK)
4. `.env``TRANSPORT_ENCRYPTION=true` を設定 - [dash.cloudflare.com](https://dash.cloudflare.com) にアクセス
- ドメインを追加してネームサーバーを更新
2. **DNSレコードを作成**
- タイプ: `A`
- 名前: `nofx` (またはサブドメイン)
- コンテンツ: サーバーのIP
- プロキシ状態: **Proxied** (オレンジ色の雲)
3. **SSL/TLSを設定**
- SSL/TLS設定に移動
- 暗号化モードを **Flexible** に設定
```
User ──[HTTPS]──→ Cloudflare ──[HTTP]──→ Your Server:3000
```
4. **トランスポート暗号化を有効化**
```bash
# .envを編集して設定
TRANSPORT_ENCRYPTION=true
```
5. **完了!** `https://nofx.yourdomain.com` 経由でアクセス
--- ---
## アーキテクチャ ## 初期設定 (Webインターフェース)
``` システムを起動した後、Webインターフェースを通じて設定します:
NOFX
┌─────────────────────────────────────────────────┐ 1. **AIモデルの設定** - AI APIキーを追加 (DeepSeek、OpenAI など)
│ Trading Terminal │ 2. **取引所の設定** - 取引所API認証情報を設定
│ React + TypeScript + TradingView │ 3. **戦略の作成** - ストラテジースタジオで取引戦略を設定
│ US Stocks · Commodities · Forex · Crypto │ 4. **トレーダーの作成** - AIモデル + 取引所 + 戦略を組み合わせ
├─────────────────────────────────────────────────┤ 5. **取引開始** - 設定したトレーダーを起動
│ API Server (Go) │
├──────────────┬──────────────┬───────────────────┤ すべての設定はWebインターフェースで完了 - JSONファイルの編集は不要です。
│ 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インターフェース機能
| | | ### 競争ページ
| :--- | :--- | - リアルタイムROIリーダーボード
| [アーキテクチャ](../../architecture/README.md) | システム設計とモジュール索引 | - マルチAIパフォーマンス比較チャート
| [戦略モジュール](../../architecture/STRATEGY_MODULE.md) | 銘柄選択、AI プロンプト、執行 | - ライブ損益追跡とランキング
| [FAQ](../../faq/README.md) | よくある質問 |
| [はじめに](../../getting-started/README.md) | デプロイガイド | ### ダッシュボード
- 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) を参照してください 貢献を歓迎します!以下を参照してください:
- **[貢献ガイド](CONTRIBUTING.md)** - 開発ワークフローとPRプロセス
### 貢献者プログラム - **[行動規範](CODE_OF_CONDUCT.md)** - コミュニティガイドライン
- **[セキュリティポリシー](SECURITY.md)** - 脆弱性の報告
NOFX は有意義な貢献を記録し、エコシステムの成長に応じて貢献者へ還元する予定です。優先 Issue は高い報酬ウェイトを持ちます。
| Contribution | Weight |
| :--- | :---: |
| Pinned Issue PRs | ★★★★★★ |
| Code (Merged PRs) | ★★★★★ |
| Bug Fixes | ★★★★ |
| Feature Ideas | ★★★ |
| Bug Reports | ★★ |
| Documentation | ★★ |
--- ---
## リンク ## 貢献者エアドロッププログラム
| | | すべての貢献はGitHubで追跡されます。NOFXが収益を生み出すと、貢献者は貢献に基づいてエアドロップを受け取ります。
| :--- | :--- |
| 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) |
> **リスク警告**:自動売買には大きなリスクがあります。適切なポジションサイズを守り、各取引所の仕組みを理解し、失ってもよい資金だけを使用してください。 **[ピン留めされた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> 1. 暗号通貨市場は非常に変動が激しい - AIの決定は利益を保証しない
<a href="https://github.com/cat9999aaa"><img src="https://github.com/cat9999aaa.png" width="50" height="50" style="border-radius:50%"/></a> 2. 先物取引はレバレッジを使用 - 損失は元本を超える可能性がある
<a href="https://github.com/1733055465"><img src="https://github.com/1733055465.png" width="50" height="50" style="border-radius:50%"/></a> 3. 極端な市場状況では清算リスクがある
<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) ## コンタクト
- **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) [![Star History Chart](https://api.star-history.com/svg?repos=NoFxAiOS/nofx&type=Date)](https://star-history.com/#NoFxAiOS/nofx&Date)

View File

@@ -1,321 +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"> **언어:** [English](../../../README.md) | [中文](../zh-CN/README.md) | [한국어](README.md)
<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>
--- ---
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 ```bash
curl -fsSL https://raw.githubusercontent.com/NoFxAiOS/nofx/main/install.sh | bash curl -fsSL https://raw.githubusercontent.com/NoFxAiOS/nofx/main/install.sh | bash
``` ```
**http://127.0.0.1:3000** 을 엽니다. 이 명령은 최신 공식 이미지를 가져오고 서비스를 자동으로 다시 시작합니다.
--- ### 옵션 2: 수동 설치
## 거래소 등록
아래 링크를 통해 암호화폐와 지원되는 미국 주식, 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) |
---
## 빠른 데모
https://github.com/user-attachments/assets/3310f495-14c5-4586-a1cc-3d32e44aa505
---
## 시장
**미국 주식 · 원자재 · 외환 · 암호화폐**
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
```bash ```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 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 ```bash
# 서버에 배포
curl -fsSL https://raw.githubusercontent.com/NoFxAiOS/nofx/main/install.sh | bash curl -fsSL https://raw.githubusercontent.com/NoFxAiOS/nofx/main/install.sh | bash
``` ```
--- `http://YOUR_SERVER_IP:3000`을 통해 액세스 - 즉시 작동합니다.
## 설정 ### 향상된 보안 (HTTPS)
**초보자 모드**: 가이드 온보딩이 모델 액세스, 거래소 연결, 전략 설정, 첫 배포까지 안내합니다. 보안을 강화하려면 `.env`에서 전송 암호화를 활성화하세요:
**고급 모드**:
1. AI 모델 액세스 설정
2. 거래소 인증 정보 연결
3. 전략 생성 또는 가져오기
4. AI 트레이더 프로필 생성
5. 대시보드에서 실행, 모니터링, 개선
모든 설정은 Web UI **http://127.0.0.1:3000** 에서 가능합니다.
---
## 서버에 배포
**HTTP 배포:**
```bash ```bash
curl -fsSL https://raw.githubusercontent.com/NoFxAiOS/nofx/main/install.sh | bash TRANSPORT_ENCRYPTION=true
# http://YOUR_IP:3000 으로 접근
``` ```
**Cloudflare를 통한 HTTPS:** 활성화되면 브라우저는 Web Crypto API를 사용하여 전송 전에 API 키를 암호화합니다. 이를 위해 필요한 것:
- `https://` - SSL이 있는 모든 도메인
- `http://localhost` - 로컬 개발
1. [Cloudflare](https://dash.cloudflare.com)(무료 플랜)에 도메인 추가 ### Cloudflare를 사용한 빠른 HTTPS 설정
2. A 레코드를 서버 IP로 지정(Proxied)
3. SSL/TLS를 Flexible로 설정 1. **Cloudflare에 도메인 추가** (무료 플랜 가능)
4. `.env``TRANSPORT_ENCRYPTION=true` 설정 - [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
┌─────────────────────────────────────────────────┐ 1. **AI 모델 구성** - AI API 키 추가 (DeepSeek, OpenAI 등)
│ Trading Terminal │ 2. **거래소 구성** - 거래소 API 자격 증명 설정
│ React + TypeScript + TradingView │ 3. **전략 생성** - 전략 스튜디오에서 거래 전략 구성
│ US Stocks · Commodities · Forex · Crypto │ 4. **트레이더 생성** - AI 모델 + 거래소 + 전략 결합
├─────────────────────────────────────────────────┤ 5. **거래 시작** - 구성된 트레이더 시작
│ API Server (Go) │
├──────────────┬──────────────┬───────────────────┤ 모든 구성은 웹 인터페이스를 통해 완료 - JSON 파일 편집 불필요.
│ 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 │
└─────────────────────────────────────────────────┘
```
--- ---
## 문서 ## 웹 인터페이스 기능
| | | ### 경쟁 페이지
| :--- | :--- | - 실시간 ROI 리더보드
| [아키텍처](../../architecture/README.md) | 시스템 설계와 모듈 색인 | - 다중 AI 성능 비교 차트
| [전략 모듈](../../architecture/STRATEGY_MODULE.md) | 종목 선택, AI 프롬프트, 실행 | - 실시간 손익 추적 및 순위
| [FAQ](../../faq/README.md) | 자주 묻는 질문 |
| [시작하기](../../getting-started/README.md) | 배포 가이드 | ### 대시보드
- 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)을 확인하세요. 기여를 환영합니다! 다음을 참조하세요:
- **[기여 가이드](CONTRIBUTING.md)** - 개발 워크플로 및 PR 프로세스
### 기여자 프로그램 - **[행동 강령](CODE_OF_CONDUCT.md)** - 커뮤니티 가이드라인
- **[보안 정책](SECURITY.md)** - 취약점 보고
NOFX는 의미 있는 기여를 기록하며 생태계 성장에 따라 기여자에게 보상할 계획입니다. 우선순위 이슈는 더 높은 보상 가중치를 가집니다.
| Contribution | Weight |
| :--- | :---: |
| Pinned Issue PRs | ★★★★★★ |
| Code (Merged PRs) | ★★★★★ |
| Bug Fixes | ★★★★ |
| Feature Ideas | ★★★ |
| Bug Reports | ★★ |
| Documentation | ★★ |
--- ---
## 링크 ## 기여자 에어드롭 프로그램
| | | 모든 기여는 GitHub에서 추적됩니다. NOFX가 수익을 창출하면 기여자는 기여도에 따라 에어드롭을 받게 됩니다.
| :--- | :--- |
| 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) |
> **위험 고지**: 자동매매에는 상당한 위험이 따릅니다. 적절한 포지션 규모를 사용하고 각 거래소 구조를 이해하며 감당 가능한 자금만 거래하세요. **[고정된 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> 1. 암호화폐 시장은 매우 변동성이 높음 - AI 결정이 수익을 보장하지 않음
<a href="https://github.com/cat9999aaa"><img src="https://github.com/cat9999aaa.png" width="50" height="50" style="border-radius:50%"/></a> 2. 선물 거래는 레버리지 사용 - 손실이 원금을 초과할 수 있음
<a href="https://github.com/1733055465"><img src="https://github.com/1733055465.png" width="50" height="50" style="border-radius:50%"/></a> 3. 극단적인 시장 상황에서 청산 위험 있음
<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)
## 연락처
- **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) [![Star History Chart](https://api.star-history.com/svg?repos=NoFxAiOS/nofx&type=Date)](https://star-history.com/#NoFxAiOS/nofx&Date)

View File

@@ -1,321 +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"> **Языки:** [English](../../../README.md) | [中文](../zh-CN/README.md) | [Русский](README.md)
<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>
--- ---
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 ```bash
curl -fsSL https://raw.githubusercontent.com/NoFxAiOS/nofx/main/install.sh | bash curl -fsSL https://raw.githubusercontent.com/NoFxAiOS/nofx/main/install.sh | bash
``` ```
**http://127.0.0.1:3000** 을 엽니다. Эта команда загружает последние официальные образы и автоматически перезапускает сервисы.
--- ### Вариант 2: Ручная установка
## Регистрация на биржах
Используйте ссылки ниже для открытия торговых счетов на крипторынках и поддерживаемых рынках деривативов на акции США, 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) |
---
## Короткая демонстрация
https://github.com/user-attachments/assets/3310f495-14c5-4586-a1cc-3d32e44aa505
---
## Рынки
**Акции США · Сырьевые товары · 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
```bash ```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 go build -o nofx && ./nofx
cd web && npm install && npm run dev
```
### Обновление # Запуск фронтенда (новый терминал)
cd web && npm run dev
```bash
curl -fsSL https://raw.githubusercontent.com/NoFxAiOS/nofx/main/install.sh | bash
``` ```
--- ---
## Настройка ## Начальная настройка
**Режим для новичков**: пошаговый onboarding проводит через доступ к моделям, подключение биржи, настройку стратегии и первый запуск. 1. **Настройка AI моделей** — Добавьте API ключи AI
2. **Настройка бирж** — Установите API учётные данные бирж
**Продвинутый режим**: 3. **Создание стратегии** — Настройте торговую стратегию в Студии стратегий
4. **Создание трейдера** — Объедините AI модель + Биржу + Стратегию
1. Настройте доступ к AI-моделям 5. **Начало торговли** — Запустите настроенных трейдеров
2. Подключите учетные данные биржи
3. Создайте или импортируйте стратегию
4. Создайте профиль AI-трейдера
5. Запустите, мониторьте и улучшайте через дашборд
Все настройки доступны в Web UI по адресу **http://127.0.0.1:3000**.
--- ---
## Развёртывание на сервере ## Предупреждения о рисках
**HTTP-развёртывание:** 1. Криптовалютные рынки крайне волатильны — AI решения не гарантируют прибыль
2. Торговля фьючерсами использует плечо — убытки могут превысить депозит
```bash 3. Экстремальные рыночные условия могут привести к ликвидации
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`
--- ---
## Архитектура ## Лицензия
``` **GNU Affero General Public License v3.0 (AGPL-3.0)**
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 │
└─────────────────────────────────────────────────┘
```
--- ---
## Документация ## Контакты
| | | - **GitHub Issues**: [Создать Issue](https://github.com/NoFxAiOS/nofx/issues)
| :--- | :--- | - **Сообщество разработчиков**: [Telegram группа](https://t.me/nofx_dev_community)
| [Архитектура](../../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)

View File

@@ -1,321 +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"> **Мови:** [English](../../../README.md) | [中文](../zh-CN/README.md) | [Українська](README.md)
<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>
--- ---
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 ```bash
curl -fsSL https://raw.githubusercontent.com/NoFxAiOS/nofx/main/install.sh | bash curl -fsSL https://raw.githubusercontent.com/NoFxAiOS/nofx/main/install.sh | bash
``` ```
**http://127.0.0.1:3000** 을 엽니다. Ця команда завантажує останні офіційні образи та автоматично перезапускає сервіси.
--- ### Варіант 2: Ручна установка
## Реєстрація на біржах
Скористайтеся посиланнями нижче, щоб відкрити торгові акаунти для крипторинків і підтримуваних деривативів на акції США, 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) |
---
## Коротка демонстрація
https://github.com/user-attachments/assets/3310f495-14c5-4586-a1cc-3d32e44aa505
---
## Ринки
**Акції США · Сировинні товари · 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
```bash ```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 go build -o nofx && ./nofx
cd web && npm install && npm run dev
```
### Обновление # Запуск фронтенду (новий термінал)
cd web && npm run dev
```bash
curl -fsSL https://raw.githubusercontent.com/NoFxAiOS/nofx/main/install.sh | bash
``` ```
--- ---
## Настройка ## Початкове налаштування
**Режим для новичков**: пошаговый onboarding проводит через доступ к моделям, подключение биржи, настройку стратегии и первый запуск. 1. **Налаштування AI моделей** — Додайте API ключі AI
2. **Налаштування бірж** — Встановіть API облікові дані бірж
**Продвинутый режим**: 3. **Створення стратегії** — Налаштуйте торгову стратегію в Студії стратегій
4. **Створення трейдера**Об'єднайте AI модель + Біржу + Стратегію
1. Настройте доступ к AI-моделям 5. **Початок торгівлі** — Запустіть налаштованих трейдерів
2. Подключите учетные данные биржи
3. Создайте или импортируйте стратегию
4. Создайте профиль AI-трейдера
5. Запустите, мониторьте и улучшайте через дашборд
Все настройки доступны в Web UI по адресу **http://127.0.0.1:3000**.
--- ---
## Розгортання на сервері ## Попередження про ризики
**HTTP-розгортання:** 1. Криптовалютні ринки надзвичайно волатильні — AI рішення не гарантують прибуток
2. Торгівля ф'ючерсами використовує плече — збитки можуть перевищити депозит
```bash 3. Екстремальні ринкові умови можуть призвести до ліквідації
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`
--- ---
## Архітектура ## Ліцензія
``` **GNU Affero General Public License v3.0 (AGPL-3.0)**
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 │
└─────────────────────────────────────────────────┘
```
--- ---
## Документация ## Контакти
| | | - **GitHub Issues**: [Створити Issue](https://github.com/NoFxAiOS/nofx/issues)
| :--- | :--- | - **Спільнота розробників**: [Telegram група](https://t.me/nofx_dev_community)
| [Архитектура](../../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)

View File

@@ -1,321 +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"> **Ngôn ngữ:** [English](../../../README.md) | [中文](../zh-CN/README.md) | [Tiếng Việt](README.md)
<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>
--- ---
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 ```bash
curl -fsSL https://raw.githubusercontent.com/NoFxAiOS/nofx/main/install.sh | bash curl -fsSL https://raw.githubusercontent.com/NoFxAiOS/nofx/main/install.sh | bash
``` ```
**http://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ụ.
--- ### Tùy chọn 2: Cài đặt Thủ công
## Đă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
https://github.com/user-attachments/assets/3310f495-14c5-4586-a1cc-3d32e44aa505
---
## 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
```bash ```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 # Khởi động backend
```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
go build -o nofx && ./nofx go build -o nofx && ./nofx
cd web && npm install && npm run dev
```
### Cập nhật # Khởi động frontend (terminal mới)
cd web && npm run dev
```bash
curl -fsSL https://raw.githubusercontent.com/NoFxAiOS/nofx/main/install.sh | bash
``` ```
--- ---
## 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. 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
**Chế độ nâng cao**: 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
1. Cấu hình truy cập mô hình AI 5. **Bắt đầu Giao dịch** — Khởi động các trader đã cấu hình
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**.
--- ---
## Triển khai lên máy chủ ## Cảnh Báo Rủi Ro
**Triển khai HTTP:** 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
```bash 3. Điều kiện thị trường cực đoan có thể dẫn đến thanh lý
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`
--- ---
## Kiến trúc ## Giấy Phép
``` **GNU Affero General Public License v3.0 (AGPL-3.0)**
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 │
└─────────────────────────────────────────────────┘
```
--- ---
## Tài liệu ## Liên Hệ
| | | - **GitHub Issues**: [Gửi Issue](https://github.com/NoFxAiOS/nofx/issues)
| :--- | :--- | - **Cộng đồng Nhà phát triển**: [Nhóm Telegram](https://t.me/nofx_dev_community)
| [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)

View File

@@ -1,323 +1,450 @@
<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/)
<p align="center"> [![TypeScript](https://img.shields.io/badge/TypeScript-5.0+-3178C6?style=flat&logo=typescript)](https://www.typescriptlang.org/)
<strong>面向全球市场的 AI 交易终端。</strong><br/> [![License](https://img.shields.io/badge/License-AGPL--3.0-blue.svg)](LICENSE)
<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>
> **语言声明:** 本中文版本文档仅为方便海外华人社区阅读而提供,不代表本软件面向中国大陆、香港、澳门或台湾地区用户开放。如您位于上述地区,请勿使用本软件。 > **语言声明:** 本中文版本文档仅为方便海外华人社区阅读而提供,不代表本软件面向中国大陆、香港、澳门或台湾地区用户开放。如您位于上述地区,请勿使用本软件。
--- | 贡献者空投计划 |
|:----------------------------------:|
| 代码 · Bug修复 · Issue → 空投奖励 |
| [了解更多](#贡献者空投计划) |
NOFX 是一个开源 AI 交易终端,面向需要统一工作区完成市场研究、策略开发、交易执行与组合监控的活跃交易者。 **语言:** [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 层将交易意图转化为观察列表、信号、策略逻辑、风控约束与执行工作流。
```bash
curl -fsSL https://raw.githubusercontent.com/NoFxAiOS/nofx/main/install.sh | bash
```
打开 **http://127.0.0.1:3000**
--- ---
## 注册交易所 ## 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) | - **多 AI 支持**: 运行 DeepSeek、通义千问、GPT、Claude、Gemini、Grok、Kimi - 随时切换模型
| <img src="../../../web/public/exchange-icons/bybit.png" width="20" height="20" style="vertical-align: middle;"/> **Bybit** | ✅ | [注册](https://partner.bybit.com/b/83856) | - **多交易所**: 在 Binance、Bybit、OKX、Hyperliquid、Aster DEX、Lighter 统一交易
| <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) | - **AI 竞赛模式**: 多个 AI 交易员实时竞争,并排追踪表现
| <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) | - **Web 配置**: 无需编辑 JSON - 通过 Web 界面完成所有配置
| <img src="../../../web/public/exchange-icons/kucoin.svg" width="20" height="20" style="vertical-align: middle;"/> **KuCoin** | ✅ | [注册](https://www.kucoin.com/r/broker/CXEV7XKK) | - **实时仪表板**: 实时持仓、盈亏追踪、AI 决策日志与思维链
| <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) |
- **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)**
--- ---
## 快速演示 ## 开始之前
https://github.com/user-attachments/assets/3310f495-14c5-4586-a1cc-3d32e44aa505 使用 NOFX 你需要准备:
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 模型基础设施,并通过官方折扣通道完成路由。 | AI 模型 | 状态 | 获取 API Key |
|----------|--------|-------------|
| 提供商 | 接入 | | **DeepSeek** | ✅ 已支持 | [获取 API Key](https://platform.deepseek.com) |
| :--- | :--- | | **通义千问** | ✅ 已支持 | [获取 API Key](https://dashscope.console.aliyun.com) |
| **Claw402** | [通过官方折扣通道按需使用 AI 模型](https://claw402.ai) | | **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) |
| 能力 | 描述 |
| :--- | :--- |
| **AI 交易终端** | 面向美股、大宗商品、外汇与加密资产的一体化工作区 |
| **AI 模型接入** | 通过 Claw402 自动接入支持的模型供应商 |
| **交易所连接** | Binance、Bybit、OKX、Hyperliquid、Bitget、KuCoin、Gate、Aster、Lighter |
| **策略工作室** | 市场范围、指标、风控与策略逻辑 |
| **模型竞赛** | 比较 AI 交易员的实时表现与排行榜 |
| **Telegram Agent** | 通过聊天控制和监控交易助手 |
| **组合仪表板** | 持仓、盈亏、执行历史与模型决策日志 |
--- ---
## 截图 ## 截图
<details> ### 竞赛模式 - 实时 AI 对战
<summary><b>配置页</b></summary> ![竞赛页面](../../../screenshots/competition-page.png)
*多 AI 排行榜,实时性能对比*
| 配置 | 交易员列表 | ### 仪表板 - 市场图表视图
| :----------------------------------------------------: | :----------------------------------------------------------: | ![仪表板市场图表](../../../screenshots/dashboard-market-chart.png)
| <img src="../../../screenshots/config-ai-exchanges.png" width="400"/> | <img src="../../../screenshots/config-traders-list.png" width="400"/> | *专业交易仪表板TradingView 风格图表*
</details> ### 策略工作室
![策略工作室](../../../screenshots/strategy-studio.png)
<details> *多数据源策略配置与 AI 测试*
<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>
--- ---
## 安装 ## 快速开始
### Linux / macOS ### 一键安装 (本地/服务器)
**Linux / macOS:**
```bash ```bash
curl -fsSL https://raw.githubusercontent.com/NoFxAiOS/nofx/main/install.sh | bash curl -fsSL https://raw.githubusercontent.com/NoFxAiOS/nofx/main/install.sh | bash
``` ```
### Railway云部署 完成!打开浏览器访问 **http://127.0.0.1:3000**
### 一键云部署 (Railway)
一键部署到 Railway - 无需自己搭建服务器:
[![Deploy on Railway](https://railway.com/button.svg)](https://railway.com/deploy/nofx?referralCode=nofx) [![Deploy on Railway](https://railway.com/button.svg)](https://railway.com/deploy/nofx?referralCode=nofx)
### Docker 部署后Railway 会提供一个公网 URL 访问你的 NOFX 实例。
### Docker Compose (手动)
```bash ```bash
# 下载并启动
curl -O https://raw.githubusercontent.com/NoFxAiOS/nofx/main/docker-compose.prod.yml curl -O https://raw.githubusercontent.com/NoFxAiOS/nofx/main/docker-compose.prod.yml
docker compose -f docker-compose.prod.yml up -d docker compose -f docker-compose.prod.yml up -d
``` ```
### Windows 访问 Web 界面: **http://127.0.0.1:3000**
安装 [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 ```bash
# Prerequisites: Go 1.21+, Node.js 18+, TA-Lib # 管理命令
# macOS: brew install ta-lib docker compose -f docker-compose.prod.yml logs -f # 查看日志
# Ubuntu: sudo apt-get install libta-lib0-dev docker compose -f docker-compose.prod.yml restart # 重启
docker compose -f docker-compose.prod.yml down # 停止
git clone https://github.com/NoFxAiOS/nofx.git && cd nofx docker compose -f docker-compose.prod.yml pull && docker compose -f docker-compose.prod.yml up -d # 更新
go build -o nofx && ./nofx
cd web && npm install && npm run dev
``` ```
### 更新 ### 保持更新
> **💡 更新频繁。** 每天运行以下命令以获取最新功能和修复:
```bash ```bash
curl -fsSL https://raw.githubusercontent.com/NoFxAiOS/nofx/main/install.sh | bash curl -fsSL https://raw.githubusercontent.com/NoFxAiOS/nofx/main/install.sh | bash
``` ```
--- 此命令会拉取最新官方镜像并自动重启服务。
## 配置 ### 手动安装 (开发者)
**新手模式**:引导式 onboarding 帮助新用户完成模型访问、交易所连接、策略配置与首次部署。 #### 前置条件
**进阶模式** - **Go 1.21+**
- **Node.js 18+**
1. 配置 AI 模型访问 - **TA-Lib** (技术指标库)
2. 连接交易所凭证
3. 构建或导入策略
4. 创建 AI 交易员配置
5. 在仪表板启动、监控并迭代
所有配置均可在 Web UI **http://127.0.0.1:3000** 完成。
---
## 部署到服务器
**HTTP 部署:**
```bash ```bash
curl -fsSL https://raw.githubusercontent.com/NoFxAiOS/nofx/main/install.sh | bash # 安装 TA-Lib
# 通过 http://YOUR_IP:3000 访问 # macOS
brew install ta-lib
# Ubuntu/Debian
sudo apt-get install libta-lib0-dev
``` ```
**通过 Cloudflare 启用 HTTPS** #### 安装步骤
1. 在 [Cloudflare](https://dash.cloudflare.com)(免费套餐)添加域名 ```bash
2. A 记录指向你的服务器 IP开启代理 # 1. 克隆仓库
3. SSL/TLS 选择 Flexible git clone https://github.com/NoFxAiOS/nofx.git
4.`.env` 中设置 `TRANSPORT_ENCRYPTION=true` 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
┌─────────────────────────────────────────────────┐ 通过 `http://你的服务器IP:3000` 访问 - 立即可用。
│ Trading Terminal │
│ React + TypeScript + TradingView │ ### 增强安全 (HTTPS)
│ US Stocks · Commodities · Forex · Crypto │
├─────────────────────────────────────────────────┤ 如需增强安全性,在 `.env` 中启用传输加密:
│ API Server (Go) │
├──────────────┬──────────────┬───────────────────┤ ```bash
│ Strategy │ Telegram │ Trader Runtime │ TRANSPORT_ENCRYPTION=true
│ 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 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/README.zh-CN.md)** | 系统设计和模块索引 |
| [策略模块](../../architecture/STRATEGY_MODULE.md) | 币种选择、AI 提示词、执行 | | **[策略模块](../../architecture/STRATEGY_MODULE.md)** | 币种选择、数据组装、AI 提示词、执行 |
| [常见问题](../../faq/README.md) | FAQ | | **[回测模块](../../architecture/BACKTEST_MODULE.md)** | 历史模拟、指标计算、断点续测 |
| [快速开始](../../getting-started/README.md) | 部署指南 | | **[辩论模块](../../architecture/DEBATE_MODULE.md)** | 多 AI 辩论、投票共识、自动执行 |
| **[常见问题](../../faq/README.md)** | FAQ |
| **[快速开始](../../getting-started/README.zh-CN.md)** | 部署指南 |
---
## 许可证
本项目采用 **GNU Affero General Public License v3.0 (AGPL-3.0)** 许可 - 详见 [LICENSE](../../../LICENSE) 文件。
--- ---
## 贡献 ## 贡献
查看 [贡献指南](../../../CONTRIBUTING.md)、[行为准则](../../../CODE_OF_CONDUCT.md) 与 [安全政策](../../../SECURITY.md)。 欢迎贡献!查看:
- **[贡献指南](../../../CONTRIBUTING.md)** - 开发流程和 PR 流程
### 贡献者计划 - **[行为准则](../../../CODE_OF_CONDUCT.md)** - 社区准则
- **[安全政策](../../../SECURITY.md)** - 报告漏洞
NOFX 会记录有价值的贡献,并计划在生态增长后回馈贡献者。优先级 Issue 拥有更高奖励权重。
| Contribution | Weight |
| :--- | :---: |
| Pinned Issue PRs | ★★★★★★ |
| Code (Merged PRs) | ★★★★★ |
| Bug Fixes | ★★★★ |
| Feature Ideas | ★★★ |
| Bug Reports | ★★ |
| Documentation | ★★ |
--- ---
## 链接 ## 贡献者空投计划
| | | 所有贡献都在 GitHub 上追踪。当 NOFX 产生收入时,贡献者将根据其贡献获得空投。
| :--- | :--- |
| 官网 | [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) |
> **风险提示**:自动化交易存在重大风险。请控制仓位,理解每个交易场所的机制,不要投入无法承受损失的资金。 **解决 [置顶 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> - **GitHub Issues**: [提交 Issue](https://github.com/NoFxAiOS/nofx/issues)
<a href="https://github.com/cat9999aaa"><img src="https://github.com/cat9999aaa.png" width="50" height="50" style="border-radius:50%"/></a> - **开发者社区**: [Telegram 群组](https://t.me/nofx_dev_community)
<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 ## Star 历史
[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) [![Star History Chart](https://api.star-history.com/svg?repos=NoFxAiOS/nofx&type=Date)](https://star-history.com/#NoFxAiOS/nofx&Date)

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

View File

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

View File

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

View File

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

70
go.mod
View File

@@ -1,32 +1,23 @@
module nofx module nofx
go 1.25.11 go 1.25.3
require ( require (
github.com/adshao/go-binance/v2 v2.8.9 github.com/adshao/go-binance/v2 v2.8.9
github.com/agiledragon/gomonkey/v2 v2.13.0 github.com/agiledragon/gomonkey/v2 v2.13.0
github.com/antihax/optional v1.0.0 github.com/ethereum/go-ethereum v1.16.7
github.com/bybit-exchange/bybit.go.api v0.0.0-20250727214011-c9347d6804d6
github.com/elliottech/lighter-go v0.0.0-20251104171447-78b9b55ebc48
github.com/ethereum/go-ethereum v1.17.3
github.com/gateio/gateapi-go/v6 v6.104.3
github.com/gin-gonic/gin v1.11.0 github.com/gin-gonic/gin v1.11.0
github.com/go-telegram-bot-api/telegram-bot-api/v5 v5.5.1 github.com/go-telegram-bot-api/telegram-bot-api/v5 v5.5.1
github.com/golang-jwt/jwt/v5 v5.3.1 github.com/golang-jwt/jwt/v5 v5.2.0
github.com/google/uuid v1.6.0 github.com/google/uuid v1.6.0
github.com/gorilla/websocket v1.5.3
github.com/joho/godotenv v1.5.1 github.com/joho/godotenv v1.5.1
github.com/lib/pq v1.10.9 github.com/pquerna/otp v1.4.0
github.com/rs/zerolog v1.34.0 github.com/rs/zerolog v1.34.0
github.com/sirupsen/logrus v1.9.3 github.com/sirupsen/logrus v1.9.3
github.com/sonirico/go-hyperliquid v0.36.0 github.com/sonirico/go-hyperliquid v0.26.0
github.com/stretchr/testify v1.11.1 github.com/stretchr/testify v1.11.1
golang.org/x/crypto v0.51.0 golang.org/x/crypto v0.42.0
golang.org/x/net v0.55.0
golang.org/x/term v0.43.0
golang.org/x/text v0.37.0
gorm.io/driver/postgres v1.6.0
gorm.io/driver/sqlite v1.6.0
gorm.io/gorm v1.31.1
modernc.org/sqlite v1.40.0 modernc.org/sqlite v1.40.0
) )
@@ -35,18 +26,23 @@ require (
github.com/armon/go-radix v1.0.0 // indirect github.com/armon/go-radix v1.0.0 // indirect
github.com/bitly/go-simplejson v0.5.1 // indirect github.com/bitly/go-simplejson v0.5.1 // indirect
github.com/bits-and-blooms/bitset v1.24.0 // indirect github.com/bits-and-blooms/bitset v1.24.0 // indirect
github.com/boombuler/barcode v1.0.1-0.20190219062509-6c824513bacc // indirect
github.com/bybit-exchange/bybit.go.api v0.0.0-20250727214011-c9347d6804d6 // indirect
github.com/bytedance/sonic v1.14.0 // indirect github.com/bytedance/sonic v1.14.0 // indirect
github.com/bytedance/sonic/loader v0.3.0 // indirect github.com/bytedance/sonic/loader v0.3.0 // indirect
github.com/cloudwego/base64x v0.1.6 // indirect github.com/cloudwego/base64x v0.1.6 // indirect
github.com/consensys/gnark-crypto v0.19.2 // indirect github.com/consensys/gnark-crypto v0.19.0 // indirect
github.com/crate-crypto/go-eth-kzg v1.5.0 // indirect github.com/crate-crypto/go-eth-kzg v1.4.0 // indirect
github.com/crate-crypto/go-ipa v0.0.0-20240724233137-53bbb0ceb27a // indirect
github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc // indirect github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc // indirect
github.com/decred/dcrd/dcrec/secp256k1/v4 v4.4.0 // indirect github.com/decred/dcrd/dcrec/secp256k1/v4 v4.4.0 // indirect
github.com/dustin/go-humanize v1.0.1 // indirect github.com/dustin/go-humanize v1.0.1 // indirect
github.com/elastic/go-sysinfo v1.15.4 // indirect github.com/elastic/go-sysinfo v1.15.4 // indirect
github.com/elastic/go-windows v1.0.2 // indirect github.com/elastic/go-windows v1.0.2 // indirect
github.com/elliottech/lighter-go v0.0.0-20251104171447-78b9b55ebc48 // indirect
github.com/elliottech/poseidon_crypto v0.0.11 // indirect github.com/elliottech/poseidon_crypto v0.0.11 // indirect
github.com/ethereum/c-kzg-4844/v2 v2.1.6 // indirect github.com/ethereum/c-kzg-4844/v2 v2.1.5 // indirect
github.com/ethereum/go-verkle v0.2.2 // indirect
github.com/gabriel-vasile/mimetype v1.4.8 // indirect github.com/gabriel-vasile/mimetype v1.4.8 // indirect
github.com/gin-contrib/sse v1.1.0 // indirect github.com/gin-contrib/sse v1.1.0 // indirect
github.com/go-playground/locales v0.14.1 // indirect github.com/go-playground/locales v0.14.1 // indirect
@@ -54,11 +50,10 @@ require (
github.com/go-playground/validator/v10 v10.27.0 // indirect github.com/go-playground/validator/v10 v10.27.0 // indirect
github.com/goccy/go-json v0.10.4 // indirect github.com/goccy/go-json v0.10.4 // indirect
github.com/goccy/go-yaml v1.18.0 // indirect github.com/goccy/go-yaml v1.18.0 // indirect
github.com/gorilla/websocket v1.5.3 // indirect
github.com/holiman/uint256 v1.3.2 // indirect github.com/holiman/uint256 v1.3.2 // indirect
github.com/jackc/pgpassfile v1.0.0 // indirect github.com/jackc/pgpassfile v1.0.0 // indirect
github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 // indirect github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 // indirect
github.com/jackc/pgx/v5 v5.9.2 // indirect github.com/jackc/pgx/v5 v5.6.0 // indirect
github.com/jackc/puddle/v2 v2.2.2 // indirect github.com/jackc/puddle/v2 v2.2.2 // indirect
github.com/jinzhu/inflection v1.0.0 // indirect github.com/jinzhu/inflection v1.0.0 // indirect
github.com/jinzhu/now v1.1.5 // indirect github.com/jinzhu/now v1.1.5 // indirect
@@ -67,7 +62,8 @@ require (
github.com/json-iterator/go v1.1.12 // indirect github.com/json-iterator/go v1.1.12 // indirect
github.com/klauspost/cpuid/v2 v2.3.0 // indirect github.com/klauspost/cpuid/v2 v2.3.0 // indirect
github.com/leodido/go-urn v1.4.0 // indirect github.com/leodido/go-urn v1.4.0 // indirect
github.com/mailru/easyjson v0.9.2 // indirect github.com/lib/pq v1.10.9 // indirect
github.com/mailru/easyjson v0.9.1 // indirect
github.com/mattn/go-colorable v0.1.14 // indirect github.com/mattn/go-colorable v0.1.14 // indirect
github.com/mattn/go-isatty v0.0.20 // indirect github.com/mattn/go-isatty v0.0.20 // indirect
github.com/mattn/go-sqlite3 v1.14.32 // indirect github.com/mattn/go-sqlite3 v1.14.32 // indirect
@@ -77,28 +73,36 @@ require (
github.com/pelletier/go-toml/v2 v2.2.4 // indirect github.com/pelletier/go-toml/v2 v2.2.4 // indirect
github.com/pkg/errors v0.9.1 // indirect github.com/pkg/errors v0.9.1 // indirect
github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 // indirect github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 // indirect
github.com/prometheus/procfs v0.19.2 // indirect github.com/prometheus/procfs v0.17.0 // indirect
github.com/quic-go/qpack v0.6.0 // indirect github.com/quic-go/qpack v0.5.1 // indirect
github.com/quic-go/quic-go v0.59.1 // indirect github.com/quic-go/quic-go v0.54.0 // indirect
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec // indirect github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec // indirect
github.com/shopspring/decimal v1.4.0 // indirect github.com/shopspring/decimal v1.4.0 // indirect
github.com/sonirico/vago v0.11.4 // indirect github.com/sonirico/vago v0.10.0 // indirect
github.com/sonirico/vago/lol v0.1.0 // indirect github.com/sonirico/vago/lol v0.0.0-20250901170347-2d1d82c510bd // indirect
github.com/supranational/blst v0.3.16 // indirect github.com/supranational/blst v0.3.16 // indirect
github.com/twitchyliquid64/golang-asm v0.15.1 // indirect github.com/twitchyliquid64/golang-asm v0.15.1 // indirect
github.com/ugorji/go/codec v1.3.0 // indirect github.com/ugorji/go/codec v1.3.0 // indirect
github.com/valyala/fastjson v1.6.10 // indirect github.com/valyala/fastjson v1.6.7 // indirect
github.com/vmihailenco/msgpack/v5 v5.4.1 // indirect github.com/vmihailenco/msgpack/v5 v5.4.1 // indirect
github.com/vmihailenco/tagparser/v2 v2.0.0 // indirect github.com/vmihailenco/tagparser/v2 v2.0.0 // indirect
go.elastic.co/apm/module/apmzerolog/v2 v2.7.2 // indirect go.elastic.co/apm/module/apmzerolog/v2 v2.7.1 // indirect
go.elastic.co/apm/v2 v2.7.2 // indirect go.elastic.co/apm/v2 v2.7.1 // indirect
go.elastic.co/fastjson v1.5.1 // indirect go.elastic.co/fastjson v1.5.1 // indirect
go.uber.org/mock v0.5.0 // indirect
golang.org/x/arch v0.20.0 // indirect golang.org/x/arch v0.20.0 // indirect
golang.org/x/exp v0.0.0-20250620022241-b7579e27df2b // indirect golang.org/x/exp v0.0.0-20250620022241-b7579e27df2b // indirect
golang.org/x/sync v0.20.0 // indirect golang.org/x/mod v0.27.0 // indirect
golang.org/x/sys v0.45.0 // indirect golang.org/x/net v0.43.0 // indirect
google.golang.org/protobuf v1.36.11 // indirect golang.org/x/sync v0.17.0 // indirect
golang.org/x/sys v0.36.0 // indirect
golang.org/x/text v0.29.0 // indirect
golang.org/x/tools v0.36.0 // indirect
google.golang.org/protobuf v1.36.9 // indirect
gopkg.in/yaml.v3 v3.0.1 // indirect gopkg.in/yaml.v3 v3.0.1 // indirect
gorm.io/driver/postgres v1.6.0 // indirect
gorm.io/driver/sqlite v1.6.0 // indirect
gorm.io/gorm v1.31.1 // indirect
howett.net/plist v1.0.1 // indirect howett.net/plist v1.0.1 // indirect
modernc.org/libc v1.66.10 // indirect modernc.org/libc v1.66.10 // indirect
modernc.org/mathutil v1.7.1 // indirect modernc.org/mathutil v1.7.1 // indirect

146
go.sum
View File

@@ -2,18 +2,24 @@ github.com/ProjectZKM/Ziren/crates/go-runtime/zkvm_runtime v0.0.0-20251001021608
github.com/ProjectZKM/Ziren/crates/go-runtime/zkvm_runtime v0.0.0-20251001021608-1fe7b43fc4d6/go.mod h1:ioLG6R+5bUSO1oeGSDxOV3FADARuMoytZCSX6MEMQkI= github.com/ProjectZKM/Ziren/crates/go-runtime/zkvm_runtime v0.0.0-20251001021608-1fe7b43fc4d6/go.mod h1:ioLG6R+5bUSO1oeGSDxOV3FADARuMoytZCSX6MEMQkI=
github.com/StackExchange/wmi v1.2.1 h1:VIkavFPXSjcnS+O8yTq7NI32k0R5Aj+v39y29VYDOSA= github.com/StackExchange/wmi v1.2.1 h1:VIkavFPXSjcnS+O8yTq7NI32k0R5Aj+v39y29VYDOSA=
github.com/StackExchange/wmi v1.2.1/go.mod h1:rcmrprowKIVzvc+NUiLncP2uuArMWLCbu9SBzvHz7e8= github.com/StackExchange/wmi v1.2.1/go.mod h1:rcmrprowKIVzvc+NUiLncP2uuArMWLCbu9SBzvHz7e8=
github.com/adshao/go-binance/v2 v2.8.7 h1:n7jkhwIHMdtd/9ZU2gTqFV15XVSbUCjyFlOUAtTd8uU=
github.com/adshao/go-binance/v2 v2.8.7/go.mod h1:XkkuecSyJKPolaCGf/q4ovJYB3t0P+7RUYTbGr+LMGM=
github.com/adshao/go-binance/v2 v2.8.9 h1:NX+4u/LgEmrjTS7OMWU+9ZgfHKFM61RPhnr9/SqWPhc= github.com/adshao/go-binance/v2 v2.8.9 h1:NX+4u/LgEmrjTS7OMWU+9ZgfHKFM61RPhnr9/SqWPhc=
github.com/adshao/go-binance/v2 v2.8.9/go.mod h1:XkkuecSyJKPolaCGf/q4ovJYB3t0P+7RUYTbGr+LMGM= github.com/adshao/go-binance/v2 v2.8.9/go.mod h1:XkkuecSyJKPolaCGf/q4ovJYB3t0P+7RUYTbGr+LMGM=
github.com/agiledragon/gomonkey/v2 v2.13.0 h1:B24Jg6wBI1iB8EFR1c+/aoTg7QN/Cum7YffG8KMIyYo= github.com/agiledragon/gomonkey/v2 v2.13.0 h1:B24Jg6wBI1iB8EFR1c+/aoTg7QN/Cum7YffG8KMIyYo=
github.com/agiledragon/gomonkey/v2 v2.13.0/go.mod h1:ap1AmDzcVOAz1YpeJ3TCzIgstoaWLA6jbbgxfB4w2iY= github.com/agiledragon/gomonkey/v2 v2.13.0/go.mod h1:ap1AmDzcVOAz1YpeJ3TCzIgstoaWLA6jbbgxfB4w2iY=
github.com/antihax/optional v1.0.0 h1:xK2lYat7ZLaVVcIuj82J8kIro4V6kDe0AUDFboUCwcg=
github.com/antihax/optional v1.0.0/go.mod h1:uupD/76wgC+ih3iEmQUL+0Ugr19nfwCT1kdvxnR2qWY=
github.com/armon/go-radix v1.0.0 h1:F4z6KzEeeQIMeLFa97iZU6vupzoecKdU5TX24SNppXI= github.com/armon/go-radix v1.0.0 h1:F4z6KzEeeQIMeLFa97iZU6vupzoecKdU5TX24SNppXI=
github.com/armon/go-radix v1.0.0/go.mod h1:ufUuZ+zHj4x4TnLV4JWEpy2hxWSpsRywHrMgIH9cCH8= github.com/armon/go-radix v1.0.0/go.mod h1:ufUuZ+zHj4x4TnLV4JWEpy2hxWSpsRywHrMgIH9cCH8=
github.com/bitly/go-simplejson v0.5.0 h1:6IH+V8/tVMab511d5bn4M7EwGXZf9Hj6i2xSwkNEM+Y=
github.com/bitly/go-simplejson v0.5.0/go.mod h1:cXHtHw4XUPsvGaxgjIAn8PhEWG9NfngEKAMDJEczWVA=
github.com/bitly/go-simplejson v0.5.1 h1:xgwPbetQScXt1gh9BmoJ6j9JMr3TElvuIyjR8pgdoow= github.com/bitly/go-simplejson v0.5.1 h1:xgwPbetQScXt1gh9BmoJ6j9JMr3TElvuIyjR8pgdoow=
github.com/bitly/go-simplejson v0.5.1/go.mod h1:YOPVLzCfwK14b4Sff3oP1AmGhI9T9Vsg84etUnlyp+Q= github.com/bitly/go-simplejson v0.5.1/go.mod h1:YOPVLzCfwK14b4Sff3oP1AmGhI9T9Vsg84etUnlyp+Q=
github.com/bits-and-blooms/bitset v1.24.0 h1:H4x4TuulnokZKvHLfzVRTHJfFfnHEeSYJizujEZvmAM= github.com/bits-and-blooms/bitset v1.24.0 h1:H4x4TuulnokZKvHLfzVRTHJfFfnHEeSYJizujEZvmAM=
github.com/bits-and-blooms/bitset v1.24.0/go.mod h1:7hO7Gc7Pp1vODcmWvKMRA9BNmbv6a/7QIWpPxHddWR8= github.com/bits-and-blooms/bitset v1.24.0/go.mod h1:7hO7Gc7Pp1vODcmWvKMRA9BNmbv6a/7QIWpPxHddWR8=
github.com/bmizerany/assert v0.0.0-20160611221934-b7ed37b82869 h1:DDGfHa7BWjL4YnC6+E63dPcxHo2sUxDIu8g3QgEJdRY=
github.com/bmizerany/assert v0.0.0-20160611221934-b7ed37b82869/go.mod h1:Ekp36dRnpXw/yCqJaO+ZrUyxD+3VXMFFr56k5XYrpB4=
github.com/boombuler/barcode v1.0.1-0.20190219062509-6c824513bacc h1:biVzkmvwrH8WK8raXaxBx6fRVTlJILwEwQGL1I/ByEI=
github.com/boombuler/barcode v1.0.1-0.20190219062509-6c824513bacc/go.mod h1:paBWMcWSl3LHKBqUq+rly7CNSldXjb2rDl3JlRe0mD8=
github.com/bybit-exchange/bybit.go.api v0.0.0-20250727214011-c9347d6804d6 h1:41FLQtKmxWEdyjdgrAm9lZFdS0Ax2XsDxkd/fuztsyQ= github.com/bybit-exchange/bybit.go.api v0.0.0-20250727214011-c9347d6804d6 h1:41FLQtKmxWEdyjdgrAm9lZFdS0Ax2XsDxkd/fuztsyQ=
github.com/bybit-exchange/bybit.go.api v0.0.0-20250727214011-c9347d6804d6/go.mod h1:P22TFRynmYRrquJCPalKxZgIIIc9+PkC4kQPeejitsI= github.com/bybit-exchange/bybit.go.api v0.0.0-20250727214011-c9347d6804d6/go.mod h1:P22TFRynmYRrquJCPalKxZgIIIc9+PkC4kQPeejitsI=
github.com/bytedance/sonic v1.14.0 h1:/OfKt8HFw0kh2rj8N0F6C/qPGRESq0BbaNZgcNXXzQQ= github.com/bytedance/sonic v1.14.0 h1:/OfKt8HFw0kh2rj8N0F6C/qPGRESq0BbaNZgcNXXzQQ=
@@ -22,12 +28,15 @@ github.com/bytedance/sonic/loader v0.3.0 h1:dskwH8edlzNMctoruo8FPTJDF3vLtDT0sXZw
github.com/bytedance/sonic/loader v0.3.0/go.mod h1:N8A3vUdtUebEY2/VQC0MyhYeKUFosQU6FxH2JmUe6VI= github.com/bytedance/sonic/loader v0.3.0/go.mod h1:N8A3vUdtUebEY2/VQC0MyhYeKUFosQU6FxH2JmUe6VI=
github.com/cloudwego/base64x v0.1.6 h1:t11wG9AECkCDk5fMSoxmufanudBtJ+/HemLstXDLI2M= github.com/cloudwego/base64x v0.1.6 h1:t11wG9AECkCDk5fMSoxmufanudBtJ+/HemLstXDLI2M=
github.com/cloudwego/base64x v0.1.6/go.mod h1:OFcloc187FXDaYHvrNIjxSe8ncn0OOM8gEHfghB2IPU= github.com/cloudwego/base64x v0.1.6/go.mod h1:OFcloc187FXDaYHvrNIjxSe8ncn0OOM8gEHfghB2IPU=
github.com/consensys/gnark-crypto v0.19.2 h1:qrEAIXq3T4egxqiliFFoNrepkIWVEeIYwt3UL0fvS80= github.com/consensys/gnark-crypto v0.19.0 h1:zXCqeY2txSaMl6G5wFpZzMWJU9HPNh8qxPnYJ1BL9vA=
github.com/consensys/gnark-crypto v0.19.2/go.mod h1:rT23F0XSZqE0mUA0+pRtnL56IbPxs6gp4CeRsBk4XS0= github.com/consensys/gnark-crypto v0.19.0/go.mod h1:rT23F0XSZqE0mUA0+pRtnL56IbPxs6gp4CeRsBk4XS0=
github.com/coreos/go-systemd/v22 v22.5.0/go.mod h1:Y58oyj3AT4RCenI/lSvhwexgC+NSVTIJ3seZv2GcEnc= github.com/coreos/go-systemd/v22 v22.5.0/go.mod h1:Y58oyj3AT4RCenI/lSvhwexgC+NSVTIJ3seZv2GcEnc=
github.com/crate-crypto/go-eth-kzg v1.5.0 h1:FYRiJMJG2iv+2Dy3fi14SVGjcPteZ5HAAUe4YWlJygc= github.com/crate-crypto/go-eth-kzg v1.4.0 h1:WzDGjHk4gFg6YzV0rJOAsTK4z3Qkz5jd4RE3DAvPFkg=
github.com/crate-crypto/go-eth-kzg v1.5.0/go.mod h1:J9/u5sWfznSObptgfa92Jq8rTswn6ahQWEuiLHOjCUI= github.com/crate-crypto/go-eth-kzg v1.4.0/go.mod h1:J9/u5sWfznSObptgfa92Jq8rTswn6ahQWEuiLHOjCUI=
github.com/crate-crypto/go-ipa v0.0.0-20240724233137-53bbb0ceb27a h1:W8mUrRp6NOVl3J+MYp5kPMoUZPp7aOYHtaua31lwRHg=
github.com/crate-crypto/go-ipa v0.0.0-20240724233137-53bbb0ceb27a/go.mod h1:sTwzHBvIzm2RfVCGNEBZgRyjwK40bVoun3ZnGOCafNM=
github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc h1:U9qPSI2PIWSS1VwoXQT9A3Wy9MM3WgvqSxFWenqJduM= github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc h1:U9qPSI2PIWSS1VwoXQT9A3Wy9MM3WgvqSxFWenqJduM=
github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
@@ -47,16 +56,18 @@ github.com/elliottech/poseidon_crypto v0.0.11 h1:iX4rCg0m1XIX/7mhXVUEYUJIdQD57zN
github.com/elliottech/poseidon_crypto v0.0.11/go.mod h1:NhWxSjPGr5JXRuB2Aepl/+ZrbmUG3hvku/GarB1JR8c= github.com/elliottech/poseidon_crypto v0.0.11/go.mod h1:NhWxSjPGr5JXRuB2Aepl/+ZrbmUG3hvku/GarB1JR8c=
github.com/emicklei/dot v1.6.2 h1:08GN+DD79cy/tzN6uLCT84+2Wk9u+wvqP+Hkx/dIR8A= github.com/emicklei/dot v1.6.2 h1:08GN+DD79cy/tzN6uLCT84+2Wk9u+wvqP+Hkx/dIR8A=
github.com/emicklei/dot v1.6.2/go.mod h1:DeV7GvQtIw4h2u73RKBkkFdvVAz0D9fzeJrgPW6gy/s= github.com/emicklei/dot v1.6.2/go.mod h1:DeV7GvQtIw4h2u73RKBkkFdvVAz0D9fzeJrgPW6gy/s=
github.com/ethereum/c-kzg-4844/v2 v2.1.6 h1:xQymkKCT5E2Jiaoqf3v4wsNgjZLY0lRSkZn27fRjSls= github.com/ethereum/c-kzg-4844/v2 v2.1.5 h1:aVtoLK5xwJ6c5RiqO8g8ptJ5KU+2Hdquf6G3aXiHh5s=
github.com/ethereum/c-kzg-4844/v2 v2.1.6/go.mod h1:8HMkUZ5JRv4hpw/XUrYWSQNAUzhHMg2UDb/U+5m+XNw= github.com/ethereum/c-kzg-4844/v2 v2.1.5/go.mod h1:u59hRTTah4Co6i9fDWtiCjTrblJv0UwsqZKCc0GfgUs=
github.com/ethereum/go-ethereum v1.17.3 h1:Ev/sQHH+UdKZHWjuVzhu2pxhi/sXaPZl23Q+Q5LDd4Q= github.com/ethereum/go-ethereum v1.16.5 h1:GZI995PZkzP7ySCxEFaOPzS8+bd8NldE//1qvQDQpe0=
github.com/ethereum/go-ethereum v1.17.3/go.mod h1:f2EhRwqewIZkGoQekywI2Y2RZAMTSavLNkD9qItFy1A= github.com/ethereum/go-ethereum v1.16.5/go.mod h1:kId9vOtlYg3PZk9VwKbGlQmSACB5ESPTBGT+M9zjmok=
github.com/ethereum/go-ethereum v1.16.7 h1:qeM4TvbrWK0UC0tgkZ7NiRsmBGwsjqc64BHo20U59UQ=
github.com/ethereum/go-ethereum v1.16.7/go.mod h1:Fs6QebQbavneQTYcA39PEKv2+zIjX7rPUZ14DER46wk=
github.com/ethereum/go-verkle v0.2.2 h1:I2W0WjnrFUIzzVPwm8ykY+7pL2d4VhlsePn4j7cnFk8=
github.com/ethereum/go-verkle v0.2.2/go.mod h1:M3b90YRnzqKyyzBEWJGqj8Qff4IDeXnzFw0P9bFw3uk=
github.com/ferranbt/fastssz v0.1.4 h1:OCDB+dYDEQDvAgtAGnTSidK1Pe2tW3nFV40XyMkTeDY= github.com/ferranbt/fastssz v0.1.4 h1:OCDB+dYDEQDvAgtAGnTSidK1Pe2tW3nFV40XyMkTeDY=
github.com/ferranbt/fastssz v0.1.4/go.mod h1:Ea3+oeoRGGLGm5shYAeDgu6PGUlcvQhE2fILyD9+tGg= github.com/ferranbt/fastssz v0.1.4/go.mod h1:Ea3+oeoRGGLGm5shYAeDgu6PGUlcvQhE2fILyD9+tGg=
github.com/gabriel-vasile/mimetype v1.4.8 h1:FfZ3gj38NjllZIeJAmMhr+qKL8Wu+nOoI3GqacKw1NM= github.com/gabriel-vasile/mimetype v1.4.8 h1:FfZ3gj38NjllZIeJAmMhr+qKL8Wu+nOoI3GqacKw1NM=
github.com/gabriel-vasile/mimetype v1.4.8/go.mod h1:ByKUIKGjh1ODkGM1asKUbQZOLGrPjydw3hYPU2YU9t8= github.com/gabriel-vasile/mimetype v1.4.8/go.mod h1:ByKUIKGjh1ODkGM1asKUbQZOLGrPjydw3hYPU2YU9t8=
github.com/gateio/gateapi-go/v6 v6.104.3 h1:JQ2+s1pG4bL+JeLQyGy9c7YLr7hxRI8g7vkAuQYl75k=
github.com/gateio/gateapi-go/v6 v6.104.3/go.mod h1:racCcjrdyOUbRDO5eCUGUiyDPrF/ZmwBj/bupPZTVLY=
github.com/gin-contrib/sse v1.1.0 h1:n0w2GMuUpWDVp7qSpvze6fAu9iRxJY4Hmj6AmBOU05w= github.com/gin-contrib/sse v1.1.0 h1:n0w2GMuUpWDVp7qSpvze6fAu9iRxJY4Hmj6AmBOU05w=
github.com/gin-contrib/sse v1.1.0/go.mod h1:hxRZ5gVpWMT7Z0B0gSNYqqsSCNIJMjzvm6fqCz9vjwM= github.com/gin-contrib/sse v1.1.0/go.mod h1:hxRZ5gVpWMT7Z0B0gSNYqqsSCNIJMjzvm6fqCz9vjwM=
github.com/gin-gonic/gin v1.11.0 h1:OW/6PLjyusp2PPXtyxKHU0RbX6I/l28FTdDlae5ueWk= github.com/gin-gonic/gin v1.11.0 h1:OW/6PLjyusp2PPXtyxKHU0RbX6I/l28FTdDlae5ueWk=
@@ -80,8 +91,8 @@ github.com/goccy/go-yaml v1.18.0/go.mod h1:XBurs7gK8ATbW4ZPGKgcbrY1Br56PdM69F7Lk
github.com/godbus/dbus/v5 v5.0.4/go.mod h1:xhWf0FNVPg57R7Z0UbKHbJfkEywrmjJnf7w5xrFpKfA= github.com/godbus/dbus/v5 v5.0.4/go.mod h1:xhWf0FNVPg57R7Z0UbKHbJfkEywrmjJnf7w5xrFpKfA=
github.com/gofrs/flock v0.12.1 h1:MTLVXXHf8ekldpJk3AKicLij9MdwOWkZ+a/jHHZby9E= github.com/gofrs/flock v0.12.1 h1:MTLVXXHf8ekldpJk3AKicLij9MdwOWkZ+a/jHHZby9E=
github.com/gofrs/flock v0.12.1/go.mod h1:9zxTsyu5xtJ9DK+1tFZyibEV7y3uwDxPPfbxeeHCoD0= github.com/gofrs/flock v0.12.1/go.mod h1:9zxTsyu5xtJ9DK+1tFZyibEV7y3uwDxPPfbxeeHCoD0=
github.com/golang-jwt/jwt/v5 v5.3.1 h1:kYf81DTWFe7t+1VvL7eS+jKFVWaUnK9cB1qbwn63YCY= github.com/golang-jwt/jwt/v5 v5.2.0 h1:d/ix8ftRUorsN+5eMIlF4T6J8CAt9rch3My2winC1Jw=
github.com/golang-jwt/jwt/v5 v5.3.1/go.mod h1:fxCRLWMO43lRc8nhHWY6LGqRcf+1gQWArsqaEUEa5bE= github.com/golang-jwt/jwt/v5 v5.2.0/go.mod h1:pqrtFR0X4osieyHYxtmOUWsAWrfe1Q5UVIyoH402zdk=
github.com/golang/mock v1.6.0 h1:ErTB+efbowRARo13NNdxyJji2egdxLGQhRaY+DUumQc= github.com/golang/mock v1.6.0 h1:ErTB+efbowRARo13NNdxyJji2egdxLGQhRaY+DUumQc=
github.com/golang/mock v1.6.0/go.mod h1:p6yTPP+5HYm5mzsMV8JkE6ZKdX+/wYM6Hr+LicevLPs= github.com/golang/mock v1.6.0/go.mod h1:p6yTPP+5HYm5mzsMV8JkE6ZKdX+/wYM6Hr+LicevLPs=
github.com/golang/snappy v1.0.0 h1:Oy607GVXHs7RtbggtPBnr2RmDArIsAefDwvrdWvRhGs= github.com/golang/snappy v1.0.0 h1:Oy607GVXHs7RtbggtPBnr2RmDArIsAefDwvrdWvRhGs=
@@ -104,8 +115,8 @@ github.com/jackc/pgpassfile v1.0.0 h1:/6Hmqy13Ss2zCq62VdNG8tM1wchn8zjSGOBJ6icpsI
github.com/jackc/pgpassfile v1.0.0/go.mod h1:CEx0iS5ambNFdcRtxPj5JhEz+xB6uRky5eyVu/W2HEg= github.com/jackc/pgpassfile v1.0.0/go.mod h1:CEx0iS5ambNFdcRtxPj5JhEz+xB6uRky5eyVu/W2HEg=
github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 h1:iCEnooe7UlwOQYpKFhBabPMi4aNAfoODPEFNiAnClxo= github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 h1:iCEnooe7UlwOQYpKFhBabPMi4aNAfoODPEFNiAnClxo=
github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761/go.mod h1:5TJZWKEWniPve33vlWYSoGYefn3gLQRzjfDlhSJ9ZKM= github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761/go.mod h1:5TJZWKEWniPve33vlWYSoGYefn3gLQRzjfDlhSJ9ZKM=
github.com/jackc/pgx/v5 v5.9.2 h1:3ZhOzMWnR4yJ+RW1XImIPsD1aNSz4T4fyP7zlQb56hw= github.com/jackc/pgx/v5 v5.6.0 h1:SWJzexBzPL5jb0GEsrPMLIsi/3jOo7RHlzTjcAeDrPY=
github.com/jackc/pgx/v5 v5.9.2/go.mod h1:mal1tBGAFfLHvZzaYh77YS/eC6IX9OWbRV1QIIM0Jn4= github.com/jackc/pgx/v5 v5.6.0/go.mod h1:DNZ/vlrUnhWCoFGxHAG8U2ljioxukquj7utPDgtQdTw=
github.com/jackc/puddle/v2 v2.2.2 h1:PR8nw+E/1w0GLuRFSmiioY6UooMp6KJv0/61nB7icHo= github.com/jackc/puddle/v2 v2.2.2 h1:PR8nw+E/1w0GLuRFSmiioY6UooMp6KJv0/61nB7icHo=
github.com/jackc/puddle/v2 v2.2.2/go.mod h1:vriiEXHvEE654aYKXXjOvZM39qJ0q+azkZFrfEOc3H4= github.com/jackc/puddle/v2 v2.2.2/go.mod h1:vriiEXHvEE654aYKXXjOvZM39qJ0q+azkZFrfEOc3H4=
github.com/jessevdk/go-flags v1.4.0/go.mod h1:4FA24M0QyGHXBuZZK/XkWh8h0e1EYbRYJSGM75WSRxI= github.com/jessevdk/go-flags v1.4.0/go.mod h1:4FA24M0QyGHXBuZZK/XkWh8h0e1EYbRYJSGM75WSRxI=
@@ -136,8 +147,8 @@ github.com/leodido/go-urn v1.4.0 h1:WT9HwE9SGECu3lg4d/dIA+jxlljEa1/ffXKmRjqdmIQ=
github.com/leodido/go-urn v1.4.0/go.mod h1:bvxc+MVxLKB4z00jd1z+Dvzr47oO32F/QSNjSBOlFxI= github.com/leodido/go-urn v1.4.0/go.mod h1:bvxc+MVxLKB4z00jd1z+Dvzr47oO32F/QSNjSBOlFxI=
github.com/lib/pq v1.10.9 h1:YXG7RB+JIjhP29X+OtkiDnYaXQwpS4JEWq7dtCCRUEw= github.com/lib/pq v1.10.9 h1:YXG7RB+JIjhP29X+OtkiDnYaXQwpS4JEWq7dtCCRUEw=
github.com/lib/pq v1.10.9/go.mod h1:AlVN5x4E4T544tWzH6hKfbfQvm3HdbOxrmggDNAPY9o= github.com/lib/pq v1.10.9/go.mod h1:AlVN5x4E4T544tWzH6hKfbfQvm3HdbOxrmggDNAPY9o=
github.com/mailru/easyjson v0.9.2 h1:dX8U45hQsZpxd80nLvDGihsQ/OxlvTkVUXH2r/8cb2M= github.com/mailru/easyjson v0.9.1 h1:LbtsOm5WAswyWbvTEOqhypdPeZzHavpZx96/n553mR8=
github.com/mailru/easyjson v0.9.2/go.mod h1:1+xMtQp2MRNVL/V1bOzuP3aP8VNwRW55fQUto+XFtTU= github.com/mailru/easyjson v0.9.1/go.mod h1:1+xMtQp2MRNVL/V1bOzuP3aP8VNwRW55fQUto+XFtTU=
github.com/mattn/go-colorable v0.1.13/go.mod h1:7S9/ev0klgBDR4GtXTXX8a3vIGJpMovkB8vQcUbaXHg= github.com/mattn/go-colorable v0.1.13/go.mod h1:7S9/ev0klgBDR4GtXTXX8a3vIGJpMovkB8vQcUbaXHg=
github.com/mattn/go-colorable v0.1.14 h1:9A9LHSqF/7dyVVX6g0U9cwm9pG3kP9gSzcuIPHPsaIE= github.com/mattn/go-colorable v0.1.14 h1:9A9LHSqF/7dyVVX6g0U9cwm9pG3kP9gSzcuIPHPsaIE=
github.com/mattn/go-colorable v0.1.14/go.mod h1:6LmQG8QLFO4G5z1gPvYEzlUgJ2wF+stgPZH1UqBm1s8= github.com/mattn/go-colorable v0.1.14/go.mod h1:6LmQG8QLFO4G5z1gPvYEzlUgJ2wF+stgPZH1UqBm1s8=
@@ -145,12 +156,15 @@ github.com/mattn/go-isatty v0.0.16/go.mod h1:kYGgaQfpe5nmfYZH+SKPsOc2e4SrIfOl2e/
github.com/mattn/go-isatty v0.0.19/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y= github.com/mattn/go-isatty v0.0.19/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y=
github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY= github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY=
github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y= github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y=
github.com/mattn/go-runewidth v0.0.16 h1:E5ScNMtiwvlvB5paMFdw9p4kSQzbXFikJ5SQO6TULQc=
github.com/mattn/go-runewidth v0.0.16/go.mod h1:Jdepj2loyihRzMpdS35Xk/zdY8IAYHsh153qUoGf23w=
github.com/mattn/go-sqlite3 v1.14.32 h1:JD12Ag3oLy1zQA+BNn74xRgaBbdhbNIDYvQUEuuErjs= github.com/mattn/go-sqlite3 v1.14.32 h1:JD12Ag3oLy1zQA+BNn74xRgaBbdhbNIDYvQUEuuErjs=
github.com/mattn/go-sqlite3 v1.14.32/go.mod h1:Uh1q+B4BYcTPb+yiD3kU8Ct7aC0hY9fxUwlHK0RXw+Y= github.com/mattn/go-sqlite3 v1.14.32/go.mod h1:Uh1q+B4BYcTPb+yiD3kU8Ct7aC0hY9fxUwlHK0RXw+Y=
github.com/minio/sha256-simd v1.0.0 h1:v1ta+49hkWZyvaKwrQB8elexRqm6Y0aMLjCNsrYxo6g= github.com/minio/sha256-simd v1.0.0 h1:v1ta+49hkWZyvaKwrQB8elexRqm6Y0aMLjCNsrYxo6g=
github.com/minio/sha256-simd v1.0.0/go.mod h1:OuYzVNI5vcoYIAmbIvHPl3N3jUzVedXbKy5RFepssQM= github.com/minio/sha256-simd v1.0.0/go.mod h1:OuYzVNI5vcoYIAmbIvHPl3N3jUzVedXbKy5RFepssQM=
github.com/mitchellh/mapstructure v1.4.1 h1:CpVNEelQCZBooIPDn+AR3NpivK/TIKU8bDxdASFVQag= github.com/mitchellh/mapstructure v1.4.1 h1:CpVNEelQCZBooIPDn+AR3NpivK/TIKU8bDxdASFVQag=
github.com/mitchellh/mapstructure v1.4.1/go.mod h1:bFUtVrKA4DC2yAKiSyO/QUcy7e+RRV2QTWOzhPopBRo= github.com/mitchellh/mapstructure v1.4.1/go.mod h1:bFUtVrKA4DC2yAKiSyO/QUcy7e+RRV2QTWOzhPopBRo=
github.com/modern-go/concurrent v0.0.0-20180228061459-e0a39a4cb421 h1:ZqeYNhU3OHLH3mGKHDcjJRFFRrJa6eAM5H+CtDdOsPc=
github.com/modern-go/concurrent v0.0.0-20180228061459-e0a39a4cb421/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q= github.com/modern-go/concurrent v0.0.0-20180228061459-e0a39a4cb421/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q=
github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd h1:TRLaZ9cD/w8PVh93nsPXa1VrQ6jlwL5oN8l14QlcNfg= github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd h1:TRLaZ9cD/w8PVh93nsPXa1VrQ6jlwL5oN8l14QlcNfg=
github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q= github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q=
@@ -158,21 +172,28 @@ github.com/modern-go/reflect2 v1.0.2 h1:xBagoLtFs94CBntxluKeaWgTMpvLxC4ur3nMaC9G
github.com/modern-go/reflect2 v1.0.2/go.mod h1:yWuevngMOJpCy52FWWMvUC8ws7m/LJsjYzDa0/r8luk= github.com/modern-go/reflect2 v1.0.2/go.mod h1:yWuevngMOJpCy52FWWMvUC8ws7m/LJsjYzDa0/r8luk=
github.com/ncruces/go-strftime v0.1.9 h1:bY0MQC28UADQmHmaF5dgpLmImcShSi2kHU9XLdhx/f4= github.com/ncruces/go-strftime v0.1.9 h1:bY0MQC28UADQmHmaF5dgpLmImcShSi2kHU9XLdhx/f4=
github.com/ncruces/go-strftime v0.1.9/go.mod h1:Fwc5htZGVVkseilnfgOVb9mKy6w1naJmn9CehxcKcls= github.com/ncruces/go-strftime v0.1.9/go.mod h1:Fwc5htZGVVkseilnfgOVb9mKy6w1naJmn9CehxcKcls=
github.com/olekukonko/tablewriter v0.0.5 h1:P2Ga83D34wi1o9J6Wh1mRuqd4mF/x/lgBS7N7AbDhec=
github.com/olekukonko/tablewriter v0.0.5/go.mod h1:hPp6KlRPjbx+hW8ykQs1w3UBbZlj6HuIJcUGPhkA7kY=
github.com/pelletier/go-toml/v2 v2.2.4 h1:mye9XuhQ6gvn5h28+VilKrrPoQVanw5PMw/TB0t5Ec4= github.com/pelletier/go-toml/v2 v2.2.4 h1:mye9XuhQ6gvn5h28+VilKrrPoQVanw5PMw/TB0t5Ec4=
github.com/pelletier/go-toml/v2 v2.2.4/go.mod h1:2gIqNv+qfxSVS7cM2xJQKtLSTLUE9V8t9Stt+h56mCY= github.com/pelletier/go-toml/v2 v2.2.4/go.mod h1:2gIqNv+qfxSVS7cM2xJQKtLSTLUE9V8t9Stt+h56mCY=
github.com/pkg/errors v0.9.1 h1:FEBLx1zS214owpjy7qsBeixbURkuhQAwrK5UwLGTwt4= github.com/pkg/errors v0.9.1 h1:FEBLx1zS214owpjy7qsBeixbURkuhQAwrK5UwLGTwt4=
github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0= github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0=
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 h1:Jamvg5psRIccs7FGNTlIRMkT8wgtp5eCXdBlqhYGL6U= github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 h1:Jamvg5psRIccs7FGNTlIRMkT8wgtp5eCXdBlqhYGL6U=
github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
github.com/prometheus/procfs v0.19.2 h1:zUMhqEW66Ex7OXIiDkll3tl9a1ZdilUOd/F6ZXw4Vws= github.com/pquerna/otp v1.4.0 h1:wZvl1TIVxKRThZIBiwOOHOGP/1+nZyWBil9Y2XNEDzg=
github.com/prometheus/procfs v0.19.2/go.mod h1:M0aotyiemPhBCM0z5w87kL22CxfcH05ZpYlu+b4J7mw= github.com/pquerna/otp v1.4.0/go.mod h1:dkJfzwRKNiegxyNb54X/3fLwhCynbMspSyWKnvi1AEg=
github.com/quic-go/qpack v0.6.0 h1:g7W+BMYynC1LbYLSqRt8PBg5Tgwxn214ZZR34VIOjz8= github.com/prometheus/procfs v0.17.0 h1:FuLQ+05u4ZI+SS/w9+BWEM2TXiHKsUQ9TADiRH7DuK0=
github.com/quic-go/qpack v0.6.0/go.mod h1:lUpLKChi8njB4ty2bFLX2x4gzDqXwUpaO1DP9qMDZII= github.com/prometheus/procfs v0.17.0/go.mod h1:oPQLaDAMRbA+u8H5Pbfq+dl3VDAvHxMUOVhe0wYB2zw=
github.com/quic-go/quic-go v0.59.1 h1:0Gmua0HW1Tv7ANR7hUYwRyD0MG5OJfgvYSZasGZzBic= github.com/quic-go/qpack v0.5.1 h1:giqksBPnT/HDtZ6VhtFKgoLOWmlyo9Ei6u9PqzIMbhI=
github.com/quic-go/quic-go v0.59.1/go.mod h1:upnsH4Ju1YkqpLXC305eW3yDZ4NfnNbmQRCMWS58IKU= github.com/quic-go/qpack v0.5.1/go.mod h1:+PC4XFrEskIVkcLzpEkbLqq1uCoxPhQuvK5rH1ZgaEg=
github.com/quic-go/quic-go v0.54.0 h1:6s1YB9QotYI6Ospeiguknbp2Znb/jZYjZLRXn9kMQBg=
github.com/quic-go/quic-go v0.54.0/go.mod h1:e68ZEaCdyviluZmy44P6Iey98v/Wfz6HCjQEm+l8zTY=
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec h1:W09IVJc94icq4NjY3clb7Lk8O1qJ8BdBEF8z0ibU0rE= github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec h1:W09IVJc94icq4NjY3clb7Lk8O1qJ8BdBEF8z0ibU0rE=
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec/go.mod h1:qqbHyh8v60DhA7CoWK5oRCqLrMHRGoxYCSS9EjAz6Eo= github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec/go.mod h1:qqbHyh8v60DhA7CoWK5oRCqLrMHRGoxYCSS9EjAz6Eo=
github.com/rivo/uniseg v0.4.7 h1:WUdvkW8uEhrYfLC4ZzdpI2ztxP1I582+49Oc5Mq64VQ=
github.com/rivo/uniseg v0.4.7/go.mod h1:FN3SvrM+Zdj16jyLfmOkMNblXMcoc8DfTHruCPUcx88=
github.com/rogpeppe/go-internal v1.14.1 h1:UQB4HGPB6osV0SQTLymcB4TgvyWu6ZyliaW0tI/otEQ= github.com/rogpeppe/go-internal v1.14.1 h1:UQB4HGPB6osV0SQTLymcB4TgvyWu6ZyliaW0tI/otEQ=
github.com/rogpeppe/go-internal v1.14.1/go.mod h1:MaRKkUm5W0goXpeCfT7UZI6fk/L7L7so1lCWt35ZSgc= github.com/rogpeppe/go-internal v1.14.1/go.mod h1:MaRKkUm5W0goXpeCfT7UZI6fk/L7L7so1lCWt35ZSgc=
github.com/rs/xid v1.6.0/go.mod h1:7XoLgs4eV+QndskICGsho+ADou8ySMSjJKDIan90Nz0= github.com/rs/xid v1.6.0/go.mod h1:7XoLgs4eV+QndskICGsho+ADou8ySMSjJKDIan90Nz0=
@@ -186,17 +207,21 @@ github.com/sirupsen/logrus v1.9.3 h1:dueUQJ1C2q9oE3F7wvmSGAaVtTmUizReu6fjN8uqzbQ
github.com/sirupsen/logrus v1.9.3/go.mod h1:naHLuLoDiP4jHNo9R0sCBMtWGeIprob74mVsIT4qYEQ= github.com/sirupsen/logrus v1.9.3/go.mod h1:naHLuLoDiP4jHNo9R0sCBMtWGeIprob74mVsIT4qYEQ=
github.com/smartystreets/assertions v0.0.0-20180927180507-b2de0cb4f26d/go.mod h1:OnSkiWE9lh6wB0YB77sQom3nweQdgAjqCqsofrRNTgc= github.com/smartystreets/assertions v0.0.0-20180927180507-b2de0cb4f26d/go.mod h1:OnSkiWE9lh6wB0YB77sQom3nweQdgAjqCqsofrRNTgc=
github.com/smartystreets/goconvey v1.6.4/go.mod h1:syvi0/a8iFYH4r/RixwvyeAJjdLS9QV7WQ/tjFTllLA= github.com/smartystreets/goconvey v1.6.4/go.mod h1:syvi0/a8iFYH4r/RixwvyeAJjdLS9QV7WQ/tjFTllLA=
github.com/sonirico/go-hyperliquid v0.36.0 h1:97aNCFf7PbvXtpCh+kdpqlAmLCyu4vB/USaKL73mSB8= github.com/sonirico/go-hyperliquid v0.17.0 h1:eXYACWupwu41O1VtKw17dqe9oOLQ1A2nRElGhg5Ox+4=
github.com/sonirico/go-hyperliquid v0.36.0/go.mod h1:6UkIfvbqOPtCNcdC2TQ7vVPy5k8pqgoMucbGep4gCuY= github.com/sonirico/go-hyperliquid v0.17.0/go.mod h1:sH51Vsu+tPUwc95TL2MoQ8YXSewLWBEJirgzo7sZx6w=
github.com/sonirico/vago v0.11.4 h1:KlKh5iYYxKii1bhReKDIE10LPz/GPPqAcn4EvZl4t54= github.com/sonirico/go-hyperliquid v0.26.0 h1:C2KjaD2R/AxH1FOPl6W1LyvAx/XUHdTQYgjb4PUcPN0=
github.com/sonirico/vago v0.11.4/go.mod h1:HCfnyPHId7V+zBZ5BLfIsdHIO+ewo6+uhF1N0hxlldc= github.com/sonirico/go-hyperliquid v0.26.0/go.mod h1:SYzazq5hqC8lI1+MgSO0aJVrf0TAfyibp5NjUqnwv2I=
github.com/sonirico/vago/lol v0.1.0 h1:YjI+JAQ6enMYlpoM23w6J+1b11TJ8rqPpuD2NDHdFlA= github.com/sonirico/vago v0.9.0 h1:DF2OWW2Aaf1xPZmnFv79kBrHmjKX3mVvMbP08vERlKo=
github.com/sonirico/vago/lol v0.1.0/go.mod h1:k8CVrcWhKbPSX5821lt8L64z/DaST2TUaxiJOdPaSA0= github.com/sonirico/vago v0.9.0/go.mod h1:fZxV1RzMe2eaZokbbDvuyoOzG3YapzqRQoOiD9VyJH0=
github.com/sonirico/vago v0.10.0 h1:y+4Wo56tK+88a5lUwVrZUO2RRLaPcBgjI5cupKpT1Oc=
github.com/sonirico/vago v0.10.0/go.mod h1:HCfnyPHId7V+zBZ5BLfIsdHIO+ewo6+uhF1N0hxlldc=
github.com/sonirico/vago/lol v0.0.0-20250901170347-2d1d82c510bd h1:rbvNORW8/0AtH/8W/SUwUykbuh2SeQBrNgFLqYpGTWY=
github.com/sonirico/vago/lol v0.0.0-20250901170347-2d1d82c510bd/go.mod h1:pteYccB32seEf19i0TPk7DKdEZdWJ/n9K9DF8AFeXGU=
github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
github.com/stretchr/objx v0.4.0/go.mod h1:YvHI0jy2hoMjB+UWwv71VJQ9isScKT/TqJzVSSt89Yw= github.com/stretchr/objx v0.4.0/go.mod h1:YvHI0jy2hoMjB+UWwv71VJQ9isScKT/TqJzVSSt89Yw=
github.com/stretchr/objx v0.5.0/go.mod h1:Yh+to48EsGEfYuaHDzXPcE3xhTkx73EhmCGUpEOglKo= github.com/stretchr/objx v0.5.0/go.mod h1:Yh+to48EsGEfYuaHDzXPcE3xhTkx73EhmCGUpEOglKo=
github.com/stretchr/objx v0.5.3 h1:jmXUvGomnU1o3W/V5h2VEradbpJDwGrzugQQvL0POH4= github.com/stretchr/objx v0.5.2 h1:xuMeJ0Sdp5ZMRXx/aWO6RZxdr3beISkG5/G/aIRr3pY=
github.com/stretchr/objx v0.5.3/go.mod h1:rDQraq+vQZU7Fde9LOZLr8Tax6zZvy4kuNKF+QYS+U0= github.com/stretchr/objx v0.5.2/go.mod h1:FRsXN1f5AsAjCGJKqEizvkpNtU+EGNCLh3NxZ/8L+MA=
github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI= github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI=
github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
github.com/stretchr/testify v1.7.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= github.com/stretchr/testify v1.7.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
@@ -214,58 +239,57 @@ github.com/twitchyliquid64/golang-asm v0.15.1 h1:SU5vSMR7hnwNxj24w34ZyCi/FmDZTkS
github.com/twitchyliquid64/golang-asm v0.15.1/go.mod h1:a1lVb/DtPvCB8fslRZhAngC2+aY1QWCk3Cedj/Gdt08= github.com/twitchyliquid64/golang-asm v0.15.1/go.mod h1:a1lVb/DtPvCB8fslRZhAngC2+aY1QWCk3Cedj/Gdt08=
github.com/ugorji/go/codec v1.3.0 h1:Qd2W2sQawAfG8XSvzwhBeoGq71zXOC/Q1E9y/wUcsUA= github.com/ugorji/go/codec v1.3.0 h1:Qd2W2sQawAfG8XSvzwhBeoGq71zXOC/Q1E9y/wUcsUA=
github.com/ugorji/go/codec v1.3.0/go.mod h1:pRBVtBSKl77K30Bv8R2P+cLSGaTtex6fsA2Wjqmfxj4= github.com/ugorji/go/codec v1.3.0/go.mod h1:pRBVtBSKl77K30Bv8R2P+cLSGaTtex6fsA2Wjqmfxj4=
github.com/valyala/fastjson v1.6.10 h1:/yjJg8jaVQdYR3arGxPE2X5z89xrlhS0eGXdv+ADTh4= github.com/valyala/fastjson v1.6.4 h1:uAUNq9Z6ymTgGhcm0UynUAB6tlbakBrz6CQFax3BXVQ=
github.com/valyala/fastjson v1.6.10/go.mod h1:e6FubmQouUNP73jtMLmcbxS6ydWIpOfhz34TSfO3JaE= github.com/valyala/fastjson v1.6.4/go.mod h1:CLCAqky6SMuOcxStkYQvblddUtoRxhYMGLrsQns1aXY=
github.com/valyala/fastjson v1.6.7 h1:ZE4tRy0CIkh+qDc5McjatheGX2czdn8slQjomexVpBM=
github.com/valyala/fastjson v1.6.7/go.mod h1:CLCAqky6SMuOcxStkYQvblddUtoRxhYMGLrsQns1aXY=
github.com/vmihailenco/msgpack/v5 v5.4.1 h1:cQriyiUvjTwOHg8QZaPihLWeRAAVoCpE00IUPn0Bjt8= github.com/vmihailenco/msgpack/v5 v5.4.1 h1:cQriyiUvjTwOHg8QZaPihLWeRAAVoCpE00IUPn0Bjt8=
github.com/vmihailenco/msgpack/v5 v5.4.1/go.mod h1:GaZTsDaehaPpQVyxrf5mtQlH+pc21PIudVV/E3rRQok= github.com/vmihailenco/msgpack/v5 v5.4.1/go.mod h1:GaZTsDaehaPpQVyxrf5mtQlH+pc21PIudVV/E3rRQok=
github.com/vmihailenco/tagparser/v2 v2.0.0 h1:y09buUbR+b5aycVFQs/g70pqKVZNBmxwAhO7/IwNM9g= github.com/vmihailenco/tagparser/v2 v2.0.0 h1:y09buUbR+b5aycVFQs/g70pqKVZNBmxwAhO7/IwNM9g=
github.com/vmihailenco/tagparser/v2 v2.0.0/go.mod h1:Wri+At7QHww0WTrCBeu4J6bNtoV6mEfg5OIWRZA9qds= github.com/vmihailenco/tagparser/v2 v2.0.0/go.mod h1:Wri+At7QHww0WTrCBeu4J6bNtoV6mEfg5OIWRZA9qds=
go.elastic.co/apm/module/apmzerolog/v2 v2.7.2 h1:JPgmhFEUDfjvIrfZdWEgkwu5H2Nzhze6GFan+qoUQYo= go.elastic.co/apm/module/apmzerolog/v2 v2.7.1 h1:C9+KrlqS8F4SZFu+ct0Jmv2YLmzDhWsI8htK6exd3vg=
go.elastic.co/apm/module/apmzerolog/v2 v2.7.2/go.mod h1:oQIxTgTMMef1FgFghymN+GCXpWhW6rpQRihV8Gjoi+w= go.elastic.co/apm/module/apmzerolog/v2 v2.7.1/go.mod h1:wXViB7paxMUrERgZrmUb+0FCqgb13Dull1JOOd8Hcj0=
go.elastic.co/apm/v2 v2.7.2 h1:0blxpxOMOcpBTz034RBqvEw806y0CDJwo/ut+2wZsHA= go.elastic.co/apm/v2 v2.7.1 h1:OFjARuESjBsxw7wHrEAnfSVNCHGBATXSI/kPvBARY/A=
go.elastic.co/apm/v2 v2.7.2/go.mod h1:KJcwwsaouDzcLd8EviAO+y8yrfZzD6PhUCEg82bvLV4= go.elastic.co/apm/v2 v2.7.1/go.mod h1:tQhBAjwh93b2leuAdzGwta/sP7Yc7QoKTSjeIHHDuog=
go.elastic.co/fastjson v1.5.1 h1:zeh1xHrFH79aQ6Xsw7YxixvnOdAl3OSv0xch/jRDzko= go.elastic.co/fastjson v1.5.1 h1:zeh1xHrFH79aQ6Xsw7YxixvnOdAl3OSv0xch/jRDzko=
go.elastic.co/fastjson v1.5.1/go.mod h1:WtvH5wz8z9pDOPqNYSYKoLLv/9zCWZLeejHWuvdL/EM= go.elastic.co/fastjson v1.5.1/go.mod h1:WtvH5wz8z9pDOPqNYSYKoLLv/9zCWZLeejHWuvdL/EM=
go.uber.org/mock v0.5.2 h1:LbtPTcP8A5k9WPXj54PPPbjcI4Y6lhyOZXn+VS7wNko= go.uber.org/mock v0.5.0 h1:KAMbZvZPyBPWgD14IrIQ38QCyjwpvVVV6K/bHl1IwQU=
go.uber.org/mock v0.5.2/go.mod h1:wLlUxC2vVTPTaE3UD51E0BGOAElKrILxhVSDYQLld5o= go.uber.org/mock v0.5.0/go.mod h1:ge71pBPLYDk7QIi1LupWxdAykm7KIEFchiOqd6z7qMM=
go.yaml.in/yaml/v4 v4.0.0-rc.3 h1:3h1fjsh1CTAPjW7q/EMe+C8shx5d8ctzZTrLcs/j8Go=
go.yaml.in/yaml/v4 v4.0.0-rc.3/go.mod h1:aZqd9kCMsGL7AuUv/m/PvWLdg5sjJsZ4oHDEnfPPfY0=
golang.org/x/arch v0.20.0 h1:dx1zTU0MAE98U+TQ8BLl7XsJbgze2WnNKF/8tGp/Q6c= golang.org/x/arch v0.20.0 h1:dx1zTU0MAE98U+TQ8BLl7XsJbgze2WnNKF/8tGp/Q6c=
golang.org/x/arch v0.20.0/go.mod h1:bdwinDaKcfZUGpH09BB7ZmOfhalA8lQdzl62l8gGWsk= golang.org/x/arch v0.20.0/go.mod h1:bdwinDaKcfZUGpH09BB7ZmOfhalA8lQdzl62l8gGWsk=
golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w= golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w=
golang.org/x/crypto v0.51.0 h1:IBPXwPfKxY7cWQZ38ZCIRPI50YLeevDLlLnyC5wRGTI= golang.org/x/crypto v0.42.0 h1:chiH31gIWm57EkTXpwnqf8qeuMUi0yekh6mT2AvFlqI=
golang.org/x/crypto v0.51.0/go.mod h1:8AdwkbraGNABw2kOX6YFPs3WM22XqI4EXEd8g+x7Oc8= golang.org/x/crypto v0.42.0/go.mod h1:4+rDnOTJhQCx2q7/j6rAN5XDw8kPjeaXEUR2eL94ix8=
golang.org/x/exp v0.0.0-20250620022241-b7579e27df2b h1:M2rDM6z3Fhozi9O7NWsxAkg/yqS/lQJ6PmkyIV3YP+o= golang.org/x/exp v0.0.0-20250620022241-b7579e27df2b h1:M2rDM6z3Fhozi9O7NWsxAkg/yqS/lQJ6PmkyIV3YP+o=
golang.org/x/exp v0.0.0-20250620022241-b7579e27df2b/go.mod h1:3//PLf8L/X+8b4vuAfHzxeRUl04Adcb341+IGKfnqS8= golang.org/x/exp v0.0.0-20250620022241-b7579e27df2b/go.mod h1:3//PLf8L/X+8b4vuAfHzxeRUl04Adcb341+IGKfnqS8=
golang.org/x/mod v0.35.0 h1:Ww1D637e6Pg+Zb2KrWfHQUnH2dQRLBQyAtpr/haaJeM= golang.org/x/mod v0.27.0 h1:kb+q2PyFnEADO2IEF935ehFUXlWiNjJWtRNgBLSfbxQ=
golang.org/x/mod v0.35.0/go.mod h1:+GwiRhIInF8wPm+4AoT6L0FA1QWAad3OMdTRx4tFYlU= golang.org/x/mod v0.27.0/go.mod h1:rWI627Fq0DEoudcK+MBkNkCe0EetEaDSwJJkCcjpazc=
golang.org/x/net v0.0.0-20190311183353-d8887717615a/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg= golang.org/x/net v0.0.0-20190311183353-d8887717615a/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg=
golang.org/x/net v0.55.0 h1:bcvxaJn3e1U6InsFWt1JUq1aSjnRxLzT2rtD2KfkDF8= golang.org/x/net v0.43.0 h1:lat02VYK2j4aLzMzecihNvTlJNQUq316m2Mr9rnM6YE=
golang.org/x/net v0.55.0/go.mod h1:L5U2KuzuOe1lY7Z+aWVIKK6qEeJXnXV9yzGA+WCHJww= golang.org/x/net v0.43.0/go.mod h1:vhO1fvI4dGsIjh73sWfUVjj3N7CA9WkKJNQm2svM6Jg=
golang.org/x/sync v0.20.0 h1:e0PTpb7pjO8GAtTs2dQ6jYa5BWYlMuX047Dco/pItO4= golang.org/x/sync v0.17.0 h1:l60nONMj9l5drqw6jlhIELNv9I0A4OFgRsG9k2oT9Ug=
golang.org/x/sync v0.20.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0= golang.org/x/sync v0.17.0/go.mod h1:9KTHXmSnoGruLpwFjVSX0lNNA75CykiMECbovNTZqGI=
golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
golang.org/x/sys v0.0.0-20220715151400-c0bba94af5f8/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.0.0-20220715151400-c0bba94af5f8/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.0.0-20220811171246-fbc7d0a398ab/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.0.0-20220811171246-fbc7d0a398ab/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.12.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.12.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.45.0 h1:dO4czNzziLiiXplLQgBCEpCvXQ3dnkn0SdaZSYdQ+FY= golang.org/x/sys v0.36.0 h1:KVRy2GtZBrk1cBYA7MKu5bEZFxQk4NIDV6RLVcC8o0k=
golang.org/x/sys v0.45.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw= golang.org/x/sys v0.36.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks=
golang.org/x/term v0.43.0 h1:S4RLU2sB31O/NCl+zFN9Aru9A/Cq2aqKpTZJ6B+DwT4=
golang.org/x/term v0.43.0/go.mod h1:lrhlHNdQJHO+1qVYiHfFKVuVioJIheAc3fBSMFYEIsk=
golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
golang.org/x/text v0.37.0 h1:Cqjiwd9eSg8e0QAkyCaQTNHFIIzWtidPahFWR83rTrc= golang.org/x/text v0.29.0 h1:1neNs90w9YzJ9BocxfsQNHKuAT4pkghyXc4nhZ6sJvk=
golang.org/x/text v0.37.0/go.mod h1:a5sjxXGs9hsn/AJVwuElvCAo9v8QYLzvavO5z2PiM38= golang.org/x/text v0.29.0/go.mod h1:7MhJOA9CD2qZyOKYazxdYMF85OwPdEr9jTtBpO7ydH4=
golang.org/x/tools v0.0.0-20190328211700-ab21143f2384/go.mod h1:LCzVGOaR6xXOjkQ3onu1FJEFr0SW1gC7cKk1uF8kGRs= golang.org/x/tools v0.0.0-20190328211700-ab21143f2384/go.mod h1:LCzVGOaR6xXOjkQ3onu1FJEFr0SW1gC7cKk1uF8kGRs=
golang.org/x/tools v0.44.0 h1:UP4ajHPIcuMjT1GqzDWRlalUEoY+uzoZKnhOjbIPD2c= golang.org/x/tools v0.36.0 h1:kWS0uv/zsvHEle1LbV5LE8QujrxB3wfQyxHfhOk0Qkg=
golang.org/x/tools v0.44.0/go.mod h1:KA0AfVErSdxRZIsOVipbv3rQhVXTnlU6UhKxHd1seDI= golang.org/x/tools v0.36.0/go.mod h1:WBDiHKJK8YgLHlcQPYQzNCkUxUypCaa5ZegCVutKm+s=
google.golang.org/protobuf v1.36.11 h1:fV6ZwhNocDyBLK0dj+fg8ektcVegBBuEolpbTQyBNVE= google.golang.org/protobuf v1.36.9 h1:w2gp2mA27hUeUzj9Ex9FBjsBm40zfaDtEWow293U7Iw=
google.golang.org/protobuf v1.36.11/go.mod h1:HTf+CrKn2C3g5S8VImy6tdcUvCska2kB7j23XfzDpco= google.golang.org/protobuf v1.36.9/go.mod h1:fuxRtAxBytpl4zzqUh6/eyUujkJdNiuEkXntxiD/uRU=
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk= gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk=
gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q= gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q=
gopkg.in/dnaeon/go-vcr.v4 v4.0.5 h1:I0hpTIvD5rII+8LgYGrHMA2d4SQPoL6u7ZvJakWKsiA=
gopkg.in/dnaeon/go-vcr.v4 v4.0.5/go.mod h1:dRos81TkW9C1WJt6tTaE+uV2Lo8qJT3AG2b35+CB/nQ=
gopkg.in/dnaeon/go-vcr.v4 v4.0.6 h1:PiJkrakkmzc5s7EfBnZOnyiLwi7o7A9fwPzN0X2uwe0= gopkg.in/dnaeon/go-vcr.v4 v4.0.6 h1:PiJkrakkmzc5s7EfBnZOnyiLwi7o7A9fwPzN0X2uwe0=
gopkg.in/dnaeon/go-vcr.v4 v4.0.6/go.mod h1:sbq5oMEcM4PXngbcNbHhzfCP9OdZodLhrbRYoyg09HY=
gopkg.in/yaml.v1 v1.0.0-20140924161607-9f9df34309c0/go.mod h1:WDnlLJ4WF5VGsH/HVa3CI79GS0ol3YnhVnKP89i0kNg= gopkg.in/yaml.v1 v1.0.0-20140924161607-9f9df34309c0/go.mod h1:WDnlLJ4WF5VGsH/HVa3CI79GS0ol3YnhVnKP89i0kNg=
gopkg.in/yaml.v2 v2.4.0 h1:D8xgwECY7CYvx+Y2n4sBz93Jn9JRvxdiyyo8CTfuKaY= gopkg.in/yaml.v2 v2.4.0 h1:D8xgwECY7CYvx+Y2n4sBz93Jn9JRvxdiyyo8CTfuKaY=
gopkg.in/yaml.v2 v2.4.0/go.mod h1:RDklbk79AGWmwhnvt/jBztapEOGDOx6ZbXqjP6csGnQ= gopkg.in/yaml.v2 v2.4.0/go.mod h1:RDklbk79AGWmwhnvt/jBztapEOGDOx6ZbXqjP6csGnQ=

File diff suppressed because it is too large Load Diff

View File

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

View File

@@ -1,36 +0,0 @@
package kernel
import (
"testing"
"nofx/provider/vergex"
)
func TestDirectionalCandidates(t *testing.T) {
e := &StrategyEngine{
vergexRankingCache: map[string]*vergex.SignalRankItem{
"xyz:NVDA": {Symbol: "xyz:NVDA", Bias: "bullish", Rank: 2},
"xyz:AAPL": {Symbol: "xyz:AAPL", Bias: "bullish", Rank: 1},
"BTC": {Symbol: "BTC", Bias: "bearish", Rank: 3},
"ETH": {Symbol: "ETH", Bias: "bearish", Rank: 1},
"SOL": {Symbol: "SOL", Bias: "neutral", Rank: 1},
},
}
bull, bear := e.DirectionalCandidates()
if len(bull) != 2 || bull[0] != "xyz:AAPL" || bull[1] != "xyz:NVDA" {
t.Fatalf("bullish should be rank-ordered [xyz:AAPL xyz:NVDA], got %v", bull)
}
if len(bear) != 2 || bear[0] != "ETH" || bear[1] != "BTC" {
t.Fatalf("bearish should be rank-ordered [ETH BTC], got %v", bear)
}
}
func TestDirectionalCandidatesEmpty(t *testing.T) {
e := &StrategyEngine{}
bull, bear := e.DirectionalCandidates()
if len(bull) != 0 || len(bear) != 0 {
t.Fatalf("empty cache should yield no candidates, got %v %v", bull, bear)
}
}

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