refactor: split large files and clean up project structure

- Rename experience/ to telemetry/ for clarity
- Split 15+ large Go files (800-2200 lines) into focused modules:
  kernel/engine.go, backtest/runner.go, market/data.go, store/position.go,
  api/handler_trader.go, trader/auto_trader_grid.go, and 9 exchange traders
- Split frontend monoliths: types.ts, api.ts, AITradersPage.tsx, BacktestPage.tsx
  into domain-specific modules with barrel re-exports
- Remove stale files: screenshots, .yml.old, pyproject.toml
- Remove unused scripts/ and cmd/ directories
- Remove broken/outdated test files (network-dependent, stale expectations)
This commit is contained in:
tinkle-community
2026-03-12 12:53:57 +08:00
parent 8e294a5eed
commit cb31782be4
113 changed files with 20423 additions and 25733 deletions

View File

@@ -1,331 +0,0 @@
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

@@ -598,615 +598,3 @@ func (s *Server) handleStopTrader(c *gin.Context) {
logger.Infof("⏹ Trader %s stopped", trader.GetName()) logger.Infof("⏹ Trader %s stopped", trader.GetName())
c.JSON(http.StatusOK, gin.H{"message": "Trader stopped"}) c.JSON(http.StatusOK, gin.H{"message": "Trader stopped"})
} }
// 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,
})
}
// 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
}
// Create temporary trader to query balance
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
}
// 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)
var actualBalance float64
// Priority: total_equity > totalWalletBalance > wallet_balance > totalEq > balance
balanceKeys := []string{"total_equity", "totalWalletBalance", "wallet_balance", "totalEq", "balance"}
for _, key := range balanceKeys {
if balance, ok := balanceInfo[key].(float64); ok && balance > 0 {
actualBalance = balance
break
}
}
if actualBalance <= 0 {
c.JSON(http.StatusInternalServerError, gin.H{"error": "Unable to get total equity"})
return
}
oldBalance := traderConfig.InitialBalance
// ✅ Option C: Smart balance change detection
changePercent := ((actualBalance - oldBalance) / oldBalance) * 100
changeType := "increase"
if changePercent < 0 {
changeType = "decrease"
}
logger.Infof("✓ Queried actual exchange balance: %.2f USDT (current config: %.2f USDT, change: %.2f%%)",
actualBalance, oldBalance, changePercent)
// Update initial_balance in database
err = s.store.Trader().UpdateInitialBalance(userID, traderID, actualBalance)
if err != nil {
logger.Infof("❌ Failed to update initial_balance: %v", err)
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to update balance"})
return
}
// Reload traders into memory
err = s.traderManager.LoadUserTradersFromStore(s.store, userID)
if err != nil {
logger.Infof("⚠️ Failed to reload user traders into memory: %v", err)
}
logger.Infof("✅ Synced balance: %.2f → %.2f USDT (%s %.2f%%)", oldBalance, actualBalance, changeType, changePercent)
c.JSON(http.StatusOK, gin.H{
"message": "Balance synced successfully",
"old_balance": oldBalance,
"new_balance": actualBalance,
"change_percent": changePercent,
"change_type": changeType,
})
}
// handleClosePosition One-click close position
func (s *Server) handleClosePosition(c *gin.Context) {
userID := c.GetString("user_id")
traderID := c.Param("id")
var req struct {
Symbol string `json:"symbol" binding:"required"`
Side string `json:"side" binding:"required"` // "LONG" or "SHORT"
}
if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "Parameter error: symbol and side are required"})
return
}
logger.Infof("🔻 User %s requested position close: trader=%s, symbol=%s, side=%s", userID, traderID, req.Symbol, req.Side)
// Get trader configuration from database (including exchange info)
fullConfig, err := s.store.Trader().GetFullConfig(userID, traderID)
if err != nil {
c.JSON(http.StatusNotFound, gin.H{"error": "Trader does not exist"})
return
}
exchangeCfg := fullConfig.Exchange
if exchangeCfg == nil || !exchangeCfg.Enabled {
c.JSON(http.StatusBadRequest, gin.H{"error": "Exchange not configured or not enabled"})
return
}
// Create temporary trader to execute close position
var tempTrader trader.Trader
var createErr error
// Use ExchangeType (e.g., "binance") instead of ExchangeID (which is now UUID)
// Convert EncryptedString fields to string
switch exchangeCfg.ExchangeType {
case "binance":
tempTrader = binance.NewFuturesTrader(string(exchangeCfg.APIKey), string(exchangeCfg.SecretKey), userID)
case "hyperliquid":
tempTrader, createErr = hyperliquidtrader.NewHyperliquidTrader(
string(exchangeCfg.APIKey),
exchangeCfg.HyperliquidWalletAddr,
exchangeCfg.Testnet,
exchangeCfg.HyperliquidUnifiedAcct,
)
case "aster":
tempTrader, createErr = aster.NewAsterTrader(
exchangeCfg.AsterUser,
exchangeCfg.AsterSigner,
string(exchangeCfg.AsterPrivateKey),
)
case "bybit":
tempTrader = bybit.NewBybitTrader(
string(exchangeCfg.APIKey),
string(exchangeCfg.SecretKey),
)
case "okx":
tempTrader = okx.NewOKXTrader(
string(exchangeCfg.APIKey),
string(exchangeCfg.SecretKey),
string(exchangeCfg.Passphrase),
)
case "bitget":
tempTrader = bitget.NewBitgetTrader(
string(exchangeCfg.APIKey),
string(exchangeCfg.SecretKey),
string(exchangeCfg.Passphrase),
)
case "gate":
tempTrader = gate.NewGateTrader(
string(exchangeCfg.APIKey),
string(exchangeCfg.SecretKey),
)
case "kucoin":
tempTrader = kucoin.NewKuCoinTrader(
string(exchangeCfg.APIKey),
string(exchangeCfg.SecretKey),
string(exchangeCfg.Passphrase),
)
case "lighter":
if exchangeCfg.LighterWalletAddr != "" && string(exchangeCfg.LighterAPIKeyPrivateKey) != "" {
// Lighter only supports mainnet
tempTrader, createErr = lighter.NewLighterTraderV2(
exchangeCfg.LighterWalletAddr,
string(exchangeCfg.LighterAPIKeyPrivateKey),
exchangeCfg.LighterAPIKeyIndex,
false, // Always use mainnet for Lighter
)
} else {
createErr = fmt.Errorf("Lighter requires wallet address and API Key private key")
}
default:
c.JSON(http.StatusBadRequest, gin.H{"error": "Unsupported exchange type"})
return
}
if createErr != nil {
logger.Infof("⚠️ Failed to create temporary trader: %v", createErr)
SafeInternalError(c, "Failed to connect to exchange", createErr)
return
}
// Get current position info BEFORE closing (to get quantity and price)
positions, err := tempTrader.GetPositions()
if err != nil {
logger.Infof("⚠️ Failed to get positions: %v", err)
}
var posQty float64
var entryPrice float64
for _, pos := range positions {
if pos["symbol"] == req.Symbol && pos["side"] == strings.ToLower(req.Side) {
if amt, ok := pos["positionAmt"].(float64); ok {
posQty = amt
if posQty < 0 {
posQty = -posQty // Make positive
}
}
if price, ok := pos["entryPrice"].(float64); ok {
entryPrice = price
}
break
}
}
// Execute close position operation
var result map[string]interface{}
var closeErr error
if req.Side == "LONG" {
result, closeErr = tempTrader.CloseLong(req.Symbol, 0) // 0 means close all
} else if req.Side == "SHORT" {
result, closeErr = tempTrader.CloseShort(req.Symbol, 0) // 0 means close all
} else {
c.JSON(http.StatusBadRequest, gin.H{"error": "side must be LONG or SHORT"})
return
}
if closeErr != nil {
logger.Infof("❌ Close position failed: symbol=%s, side=%s, error=%v", req.Symbol, req.Side, closeErr)
SafeInternalError(c, "Close position", closeErr)
return
}
logger.Infof("✅ Position closed successfully: symbol=%s, side=%s, qty=%.6f, result=%v", req.Symbol, req.Side, posQty, result)
// Record order to database (for chart markers and history)
s.recordClosePositionOrder(traderID, exchangeCfg.ID, exchangeCfg.ExchangeType, req.Symbol, req.Side, posQty, entryPrice, result)
c.JSON(http.StatusOK, gin.H{
"message": "Position closed successfully",
"symbol": req.Symbol,
"side": req.Side,
"result": result,
})
}
// recordClosePositionOrder Record close position order to database (Lighter version - direct FILLED status)
func (s *Server) recordClosePositionOrder(traderID, exchangeID, exchangeType, symbol, side string, quantity, exitPrice float64, result map[string]interface{}) {
// Skip for exchanges with OrderSync - let the background sync handle it to avoid duplicates
switch exchangeType {
case "binance", "lighter", "hyperliquid", "bybit", "okx", "bitget", "aster", "gate":
logger.Infof(" 📝 Close order will be synced by OrderSync, skipping immediate record")
return
}
// Check if order was placed (skip if NO_POSITION)
status, _ := result["status"].(string)
if status == "NO_POSITION" {
logger.Infof(" ⚠️ No position to close, skipping order record")
return
}
// Get order ID from result
var orderID string
switch v := result["orderId"].(type) {
case int64:
orderID = fmt.Sprintf("%d", v)
case float64:
orderID = fmt.Sprintf("%.0f", v)
case string:
orderID = v
default:
orderID = fmt.Sprintf("%v", v)
}
if orderID == "" || orderID == "0" {
logger.Infof(" ⚠️ Order ID is empty, skipping record")
return
}
// Determine order action based on side
var orderAction string
if side == "LONG" {
orderAction = "close_long"
} else {
orderAction = "close_short"
}
// Use entry price if exit price not available
if exitPrice == 0 {
exitPrice = quantity * 100 // Rough estimate if we don't have price
}
// Estimate fee (0.04% for Lighter taker)
fee := exitPrice * quantity * 0.0004
// Create order record - DIRECTLY as FILLED (Lighter market orders fill immediately)
orderRecord := &store.TraderOrder{
TraderID: traderID,
ExchangeID: exchangeID,
ExchangeType: exchangeType,
ExchangeOrderID: orderID,
Symbol: symbol,
PositionSide: side,
OrderAction: orderAction,
Type: "MARKET",
Side: getSideFromAction(orderAction),
Quantity: quantity,
Price: 0, // Market order
Status: "FILLED",
FilledQuantity: quantity,
AvgFillPrice: exitPrice,
Commission: fee,
FilledAt: time.Now().UTC().UnixMilli(),
CreatedAt: time.Now().UTC().UnixMilli(),
UpdatedAt: time.Now().UTC().UnixMilli(),
}
if err := s.store.Order().CreateOrder(orderRecord); err != nil {
logger.Infof(" ⚠️ Failed to record order: %v", err)
return
}
logger.Infof(" ✅ Order recorded as FILLED: %s [%s] %s qty=%.6f price=%.6f", orderID, orderAction, symbol, quantity, exitPrice)
// Create fill record immediately
tradeID := fmt.Sprintf("%s-%d", orderID, time.Now().UnixNano())
fillRecord := &store.TraderFill{
TraderID: traderID,
ExchangeID: exchangeID,
ExchangeType: exchangeType,
OrderID: orderRecord.ID,
ExchangeOrderID: orderID,
ExchangeTradeID: tradeID,
Symbol: symbol,
Side: getSideFromAction(orderAction),
Price: exitPrice,
Quantity: quantity,
QuoteQuantity: exitPrice * quantity,
Commission: fee,
CommissionAsset: "USDT",
RealizedPnL: 0,
IsMaker: false,
CreatedAt: time.Now().UTC().UnixMilli(),
}
if err := s.store.Order().CreateFill(fillRecord); err != nil {
logger.Infof(" ⚠️ Failed to record fill: %v", err)
} else {
logger.Infof(" ✅ Fill record created: price=%.6f qty=%.6f", exitPrice, quantity)
}
}
// pollAndUpdateOrderStatus Poll order status and update with fill data
func (s *Server) pollAndUpdateOrderStatus(orderRecordID int64, traderID, exchangeID, exchangeType, orderID, symbol, orderAction string, tempTrader trader.Trader) {
var actualPrice float64
var actualQty float64
var fee float64
// Wait a bit for order to be filled
time.Sleep(500 * time.Millisecond)
// For Lighter, use GetTrades instead of GetOrderStatus (market orders are filled immediately)
if exchangeType == "lighter" {
s.pollLighterTradeHistory(orderRecordID, traderID, exchangeID, exchangeType, orderID, symbol, orderAction, tempTrader)
return
}
// For other exchanges, poll GetOrderStatus
for i := 0; i < 5; i++ {
status, err := tempTrader.GetOrderStatus(symbol, orderID)
if err != nil {
logger.Infof(" ⚠️ GetOrderStatus failed (attempt %d/5): %v", i+1, err)
time.Sleep(500 * time.Millisecond)
continue
}
if err == nil {
statusStr, _ := status["status"].(string)
if statusStr == "FILLED" {
// Get actual fill price
if avgPrice, ok := status["avgPrice"].(float64); ok && avgPrice > 0 {
actualPrice = avgPrice
}
// Get actual executed quantity
if execQty, ok := status["executedQty"].(float64); ok && execQty > 0 {
actualQty = execQty
}
// Get commission/fee
if commission, ok := status["commission"].(float64); ok {
fee = commission
}
logger.Infof(" ✅ Order filled: avgPrice=%.6f, qty=%.6f, fee=%.6f", actualPrice, actualQty, fee)
// Update order status to FILLED
if err := s.store.Order().UpdateOrderStatus(orderRecordID, "FILLED", actualQty, actualPrice, fee); err != nil {
logger.Infof(" ⚠️ Failed to update order status: %v", err)
return
}
// Record fill details
tradeID := fmt.Sprintf("%s-%d", orderID, time.Now().UnixNano())
fillRecord := &store.TraderFill{
TraderID: traderID,
ExchangeID: exchangeID,
ExchangeType: exchangeType,
OrderID: orderRecordID,
ExchangeOrderID: orderID,
ExchangeTradeID: tradeID,
Symbol: symbol,
Side: getSideFromAction(orderAction),
Price: actualPrice,
Quantity: actualQty,
QuoteQuantity: actualPrice * actualQty,
Commission: fee,
CommissionAsset: "USDT",
RealizedPnL: 0,
IsMaker: false,
CreatedAt: time.Now().UTC().UnixMilli(),
}
if err := s.store.Order().CreateFill(fillRecord); err != nil {
logger.Infof(" ⚠️ Failed to record fill: %v", err)
} else {
logger.Infof(" 📝 Fill recorded: price=%.6f, qty=%.6f", actualPrice, actualQty)
}
return
} else if statusStr == "CANCELED" || statusStr == "EXPIRED" || statusStr == "REJECTED" {
logger.Infof(" ⚠️ Order %s, updating status", statusStr)
s.store.Order().UpdateOrderStatus(orderRecordID, statusStr, 0, 0, 0)
return
}
}
time.Sleep(500 * time.Millisecond)
}
logger.Infof(" ⚠️ Failed to confirm order fill after polling, order may still be pending")
}
// pollLighterTradeHistory No longer used - Lighter orders are marked as FILLED immediately
// Keeping this function stub for compatibility with other exchanges
func (s *Server) pollLighterTradeHistory(orderRecordID int64, traderID, exchangeID, exchangeType, orderID, symbol, orderAction string, tempTrader trader.Trader) {
// For Lighter, orders are now recorded as FILLED immediately in recordClosePositionOrder
// This function is no longer called for Lighter exchange
logger.Infof(" pollLighterTradeHistory called but not needed (order already marked FILLED)")
}
// getSideFromAction Get order side (BUY/SELL) from order action
func getSideFromAction(action string) string {
switch action {
case "open_long", "close_short":
return "BUY"
case "open_short", "close_long":
return "SELL"
default:
return "BUY"
}
}

View File

@@ -0,0 +1,79 @@
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

@@ -0,0 +1,565 @@
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
}
// Create temporary trader to query balance
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
}
// 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)
var actualBalance float64
// Priority: total_equity > totalWalletBalance > wallet_balance > totalEq > balance
balanceKeys := []string{"total_equity", "totalWalletBalance", "wallet_balance", "totalEq", "balance"}
for _, key := range balanceKeys {
if balance, ok := balanceInfo[key].(float64); ok && balance > 0 {
actualBalance = balance
break
}
}
if actualBalance <= 0 {
c.JSON(http.StatusInternalServerError, gin.H{"error": "Unable to get total equity"})
return
}
oldBalance := traderConfig.InitialBalance
// Smart balance change detection
changePercent := ((actualBalance - oldBalance) / oldBalance) * 100
changeType := "increase"
if changePercent < 0 {
changeType = "decrease"
}
logger.Infof("✓ Queried actual exchange balance: %.2f USDT (current config: %.2f USDT, change: %.2f%%)",
actualBalance, oldBalance, changePercent)
// Update initial_balance in database
err = s.store.Trader().UpdateInitialBalance(userID, traderID, actualBalance)
if err != nil {
logger.Infof("❌ Failed to update initial_balance: %v", err)
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to update balance"})
return
}
// Reload traders into memory
err = s.traderManager.LoadUserTradersFromStore(s.store, userID)
if err != nil {
logger.Infof("⚠️ Failed to reload user traders into memory: %v", err)
}
logger.Infof("✅ Synced balance: %.2f → %.2f USDT (%s %.2f%%)", oldBalance, actualBalance, changeType, changePercent)
c.JSON(http.StatusOK, gin.H{
"message": "Balance synced successfully",
"old_balance": oldBalance,
"new_balance": actualBalance,
"change_percent": changePercent,
"change_type": changeType,
})
}
// handleClosePosition One-click close position
func (s *Server) handleClosePosition(c *gin.Context) {
userID := c.GetString("user_id")
traderID := c.Param("id")
var req struct {
Symbol string `json:"symbol" binding:"required"`
Side string `json:"side" binding:"required"` // "LONG" or "SHORT"
}
if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "Parameter error: symbol and side are required"})
return
}
logger.Infof("🔻 User %s requested position close: trader=%s, symbol=%s, side=%s", userID, traderID, req.Symbol, req.Side)
// Get trader configuration from database (including exchange info)
fullConfig, err := s.store.Trader().GetFullConfig(userID, traderID)
if err != nil {
c.JSON(http.StatusNotFound, gin.H{"error": "Trader does not exist"})
return
}
exchangeCfg := fullConfig.Exchange
if exchangeCfg == nil || !exchangeCfg.Enabled {
c.JSON(http.StatusBadRequest, gin.H{"error": "Exchange not configured or not enabled"})
return
}
// Create temporary trader to execute close position
var tempTrader trader.Trader
var createErr error
// Use ExchangeType (e.g., "binance") instead of ExchangeID (which is now UUID)
// Convert EncryptedString fields to string
switch exchangeCfg.ExchangeType {
case "binance":
tempTrader = binance.NewFuturesTrader(string(exchangeCfg.APIKey), string(exchangeCfg.SecretKey), userID)
case "hyperliquid":
tempTrader, createErr = hyperliquidtrader.NewHyperliquidTrader(
string(exchangeCfg.APIKey),
exchangeCfg.HyperliquidWalletAddr,
exchangeCfg.Testnet,
exchangeCfg.HyperliquidUnifiedAcct,
)
case "aster":
tempTrader, createErr = aster.NewAsterTrader(
exchangeCfg.AsterUser,
exchangeCfg.AsterSigner,
string(exchangeCfg.AsterPrivateKey),
)
case "bybit":
tempTrader = bybit.NewBybitTrader(
string(exchangeCfg.APIKey),
string(exchangeCfg.SecretKey),
)
case "okx":
tempTrader = okx.NewOKXTrader(
string(exchangeCfg.APIKey),
string(exchangeCfg.SecretKey),
string(exchangeCfg.Passphrase),
)
case "bitget":
tempTrader = bitget.NewBitgetTrader(
string(exchangeCfg.APIKey),
string(exchangeCfg.SecretKey),
string(exchangeCfg.Passphrase),
)
case "gate":
tempTrader = gate.NewGateTrader(
string(exchangeCfg.APIKey),
string(exchangeCfg.SecretKey),
)
case "kucoin":
tempTrader = kucoin.NewKuCoinTrader(
string(exchangeCfg.APIKey),
string(exchangeCfg.SecretKey),
string(exchangeCfg.Passphrase),
)
case "lighter":
if exchangeCfg.LighterWalletAddr != "" && string(exchangeCfg.LighterAPIKeyPrivateKey) != "" {
// Lighter only supports mainnet
tempTrader, createErr = lighter.NewLighterTraderV2(
exchangeCfg.LighterWalletAddr,
string(exchangeCfg.LighterAPIKeyPrivateKey),
exchangeCfg.LighterAPIKeyIndex,
false, // Always use mainnet for Lighter
)
} else {
createErr = fmt.Errorf("Lighter requires wallet address and API Key private key")
}
default:
c.JSON(http.StatusBadRequest, gin.H{"error": "Unsupported exchange type"})
return
}
if createErr != nil {
logger.Infof("⚠️ Failed to create temporary trader: %v", createErr)
SafeInternalError(c, "Failed to connect to exchange", createErr)
return
}
// Get current position info BEFORE closing (to get quantity and price)
positions, err := tempTrader.GetPositions()
if err != nil {
logger.Infof("⚠️ Failed to get positions: %v", err)
}
var posQty float64
var entryPrice float64
for _, pos := range positions {
if pos["symbol"] == req.Symbol && pos["side"] == strings.ToLower(req.Side) {
if amt, ok := pos["positionAmt"].(float64); ok {
posQty = amt
if posQty < 0 {
posQty = -posQty // Make positive
}
}
if price, ok := pos["entryPrice"].(float64); ok {
entryPrice = price
}
break
}
}
// Execute close position operation
var result map[string]interface{}
var closeErr error
if req.Side == "LONG" {
result, closeErr = tempTrader.CloseLong(req.Symbol, 0) // 0 means close all
} else if req.Side == "SHORT" {
result, closeErr = tempTrader.CloseShort(req.Symbol, 0) // 0 means close all
} else {
c.JSON(http.StatusBadRequest, gin.H{"error": "side must be LONG or SHORT"})
return
}
if closeErr != nil {
logger.Infof("❌ Close position failed: symbol=%s, side=%s, error=%v", req.Symbol, req.Side, closeErr)
SafeInternalError(c, "Close position", closeErr)
return
}
logger.Infof("✅ Position closed successfully: symbol=%s, side=%s, qty=%.6f, result=%v", req.Symbol, req.Side, posQty, result)
// Record order to database (for chart markers and history)
s.recordClosePositionOrder(traderID, exchangeCfg.ID, exchangeCfg.ExchangeType, req.Symbol, req.Side, posQty, entryPrice, result)
c.JSON(http.StatusOK, gin.H{
"message": "Position closed successfully",
"symbol": req.Symbol,
"side": req.Side,
"result": result,
})
}
// recordClosePositionOrder Record close position order to database (Lighter version - direct FILLED status)
func (s *Server) recordClosePositionOrder(traderID, exchangeID, exchangeType, symbol, side string, quantity, exitPrice float64, result map[string]interface{}) {
// Skip for exchanges with OrderSync - let the background sync handle it to avoid duplicates
switch exchangeType {
case "binance", "lighter", "hyperliquid", "bybit", "okx", "bitget", "aster", "gate":
logger.Infof(" 📝 Close order will be synced by OrderSync, skipping immediate record")
return
}
// Check if order was placed (skip if NO_POSITION)
status, _ := result["status"].(string)
if status == "NO_POSITION" {
logger.Infof(" ⚠️ No position to close, skipping order record")
return
}
// Get order ID from result
var orderID string
switch v := result["orderId"].(type) {
case int64:
orderID = fmt.Sprintf("%d", v)
case float64:
orderID = fmt.Sprintf("%.0f", v)
case string:
orderID = v
default:
orderID = fmt.Sprintf("%v", v)
}
if orderID == "" || orderID == "0" {
logger.Infof(" ⚠️ Order ID is empty, skipping record")
return
}
// Determine order action based on side
var orderAction string
if side == "LONG" {
orderAction = "close_long"
} else {
orderAction = "close_short"
}
// Use entry price if exit price not available
if exitPrice == 0 {
exitPrice = quantity * 100 // Rough estimate if we don't have price
}
// Estimate fee (0.04% for Lighter taker)
fee := exitPrice * quantity * 0.0004
// Create order record - DIRECTLY as FILLED (Lighter market orders fill immediately)
orderRecord := &store.TraderOrder{
TraderID: traderID,
ExchangeID: exchangeID,
ExchangeType: exchangeType,
ExchangeOrderID: orderID,
Symbol: symbol,
PositionSide: side,
OrderAction: orderAction,
Type: "MARKET",
Side: getSideFromAction(orderAction),
Quantity: quantity,
Price: 0, // Market order
Status: "FILLED",
FilledQuantity: quantity,
AvgFillPrice: exitPrice,
Commission: fee,
FilledAt: time.Now().UTC().UnixMilli(),
CreatedAt: time.Now().UTC().UnixMilli(),
UpdatedAt: time.Now().UTC().UnixMilli(),
}
if err := s.store.Order().CreateOrder(orderRecord); err != nil {
logger.Infof(" ⚠️ Failed to record order: %v", err)
return
}
logger.Infof(" ✅ Order recorded as FILLED: %s [%s] %s qty=%.6f price=%.6f", orderID, orderAction, symbol, quantity, exitPrice)
// Create fill record immediately
tradeID := fmt.Sprintf("%s-%d", orderID, time.Now().UnixNano())
fillRecord := &store.TraderFill{
TraderID: traderID,
ExchangeID: exchangeID,
ExchangeType: exchangeType,
OrderID: orderRecord.ID,
ExchangeOrderID: orderID,
ExchangeTradeID: tradeID,
Symbol: symbol,
Side: getSideFromAction(orderAction),
Price: exitPrice,
Quantity: quantity,
QuoteQuantity: exitPrice * quantity,
Commission: fee,
CommissionAsset: "USDT",
RealizedPnL: 0,
IsMaker: false,
CreatedAt: time.Now().UTC().UnixMilli(),
}
if err := s.store.Order().CreateFill(fillRecord); err != nil {
logger.Infof(" ⚠️ Failed to record fill: %v", err)
} else {
logger.Infof(" ✅ Fill record created: price=%.6f qty=%.6f", exitPrice, quantity)
}
}
// pollAndUpdateOrderStatus Poll order status and update with fill data
func (s *Server) pollAndUpdateOrderStatus(orderRecordID int64, traderID, exchangeID, exchangeType, orderID, symbol, orderAction string, tempTrader trader.Trader) {
var actualPrice float64
var actualQty float64
var fee float64
// Wait a bit for order to be filled
time.Sleep(500 * time.Millisecond)
// For Lighter, use GetTrades instead of GetOrderStatus (market orders are filled immediately)
if exchangeType == "lighter" {
s.pollLighterTradeHistory(orderRecordID, traderID, exchangeID, exchangeType, orderID, symbol, orderAction, tempTrader)
return
}
// For other exchanges, poll GetOrderStatus
for i := 0; i < 5; i++ {
status, err := tempTrader.GetOrderStatus(symbol, orderID)
if err != nil {
logger.Infof(" ⚠️ GetOrderStatus failed (attempt %d/5): %v", i+1, err)
time.Sleep(500 * time.Millisecond)
continue
}
if err == nil {
statusStr, _ := status["status"].(string)
if statusStr == "FILLED" {
// Get actual fill price
if avgPrice, ok := status["avgPrice"].(float64); ok && avgPrice > 0 {
actualPrice = avgPrice
}
// Get actual executed quantity
if execQty, ok := status["executedQty"].(float64); ok && execQty > 0 {
actualQty = execQty
}
// Get commission/fee
if commission, ok := status["commission"].(float64); ok {
fee = commission
}
logger.Infof(" ✅ Order filled: avgPrice=%.6f, qty=%.6f, fee=%.6f", actualPrice, actualQty, fee)
// Update order status to FILLED
if err := s.store.Order().UpdateOrderStatus(orderRecordID, "FILLED", actualQty, actualPrice, fee); err != nil {
logger.Infof(" ⚠️ Failed to update order status: %v", err)
return
}
// Record fill details
tradeID := fmt.Sprintf("%s-%d", orderID, time.Now().UnixNano())
fillRecord := &store.TraderFill{
TraderID: traderID,
ExchangeID: exchangeID,
ExchangeType: exchangeType,
OrderID: orderRecordID,
ExchangeOrderID: orderID,
ExchangeTradeID: tradeID,
Symbol: symbol,
Side: getSideFromAction(orderAction),
Price: actualPrice,
Quantity: actualQty,
QuoteQuantity: actualPrice * actualQty,
Commission: fee,
CommissionAsset: "USDT",
RealizedPnL: 0,
IsMaker: false,
CreatedAt: time.Now().UTC().UnixMilli(),
}
if err := s.store.Order().CreateFill(fillRecord); err != nil {
logger.Infof(" ⚠️ Failed to record fill: %v", err)
} else {
logger.Infof(" 📝 Fill recorded: price=%.6f, qty=%.6f", actualPrice, actualQty)
}
return
} else if statusStr == "CANCELED" || statusStr == "EXPIRED" || statusStr == "REJECTED" {
logger.Infof(" ⚠️ Order %s, updating status", statusStr)
s.store.Order().UpdateOrderStatus(orderRecordID, statusStr, 0, 0, 0)
return
}
}
time.Sleep(500 * time.Millisecond)
}
logger.Infof(" ⚠️ Failed to confirm order fill after polling, order may still be pending")
}
// pollLighterTradeHistory No longer used - Lighter orders are marked as FILLED immediately
// Keeping this function stub for compatibility with other exchanges
func (s *Server) pollLighterTradeHistory(orderRecordID int64, traderID, exchangeID, exchangeType, orderID, symbol, orderAction string, tempTrader trader.Trader) {
// For Lighter, orders are now recorded as FILLED immediately in recordClosePositionOrder
// This function is no longer called for Lighter exchange
logger.Infof(" pollLighterTradeHistory called but not needed (order already marked FILLED)")
}
// getSideFromAction Get order side (BUY/SELL) from order action
func getSideFromAction(action string) string {
switch action {
case "open_long", "close_short":
return "BUY"
case "open_short", "close_long":
return "SELL"
default:
return "BUY"
}
}

File diff suppressed because it is too large Load Diff

563
backtest/runner_loop.go Normal file
View File

@@ -0,0 +1,563 @@
package backtest
import (
"context"
"encoding/json"
"errors"
"fmt"
"sort"
"time"
"nofx/kernel"
"nofx/logger"
"nofx/market"
"nofx/store"
)
func (r *Runner) loop(ctx context.Context) {
defer close(r.doneCh)
for {
select {
case <-ctx.Done():
r.handleStop(fmt.Errorf("context canceled: %w", ctx.Err()))
return
case <-r.stopCh:
r.handleStop(nil)
return
case <-r.pauseCh:
r.handlePause()
<-r.resumeCh
r.resumeFromPause()
default:
}
err := r.stepOnce()
if errors.Is(err, errBacktestCompleted) {
r.handleCompletion()
return
}
if errors.Is(err, errLiquidated) {
r.handleLiquidation()
return
}
if err != nil {
r.handleFailure(err)
return
}
}
}
func (r *Runner) stepOnce() error {
state := r.snapshotState()
if state.BarIndex >= r.feed.DecisionBarCount() {
return errBacktestCompleted
}
ts := r.feed.DecisionTimestamp(state.BarIndex)
marketData, multiTF, err := r.feed.BuildMarketData(ts)
if err != nil {
return err
}
priceMap := make(map[string]float64, len(marketData))
for symbol, data := range marketData {
priceMap[symbol] = data.CurrentPrice
}
callCount := state.DecisionCycle + 1
shouldDecide := r.shouldTriggerDecision(state.BarIndex)
var (
record *store.DecisionRecord
decisionActions []store.DecisionAction
tradeEvents = make([]TradeEvent, 0)
execLog []string
hadError bool
)
decisionAttempted := shouldDecide
if shouldDecide {
ctx, rec, err := r.buildDecisionContext(ts, marketData, multiTF, priceMap, callCount)
if err != nil {
// Defensive nil check to prevent panic if buildDecisionContext returns error with nil record
if rec != nil {
rec.Success = false
rec.ErrorMessage = fmt.Sprintf("failed to build trading context: %v", err)
_ = r.logDecision(rec)
}
return err
}
record = rec
var (
fullDecision *kernel.FullDecision
fromCache bool
cacheKey string
)
if r.aiCache != nil {
if key, err := computeCacheKey(ctx, r.cfg.PromptVariant, ts); err == nil {
cacheKey = key
if cached, ok := r.aiCache.Get(cacheKey); ok {
fullDecision = cached
fromCache = true
} else if r.cfg.ReplayOnly {
decisionErr := fmt.Errorf("replay_only enabled but cache miss at %d", ts)
record.Success = false
record.ErrorMessage = fmt.Sprintf("cached decision not found for ts=%d", ts)
_ = r.logDecision(record)
return decisionErr
}
} else {
logger.Infof("failed to compute ai cache key: %v", err)
}
}
if !fromCache {
fd, err := r.invokeAIWithRetry(ctx)
if err != nil {
decisionAttempted = true
hadError = true
record.Success = false
record.ErrorMessage = fmt.Sprintf("AI decision failed: %v", err)
execLog = append(execLog, fmt.Sprintf("⚠️ AI decision failed: %v", err))
r.setLastError(err)
} else {
fullDecision = fd
if r.cfg.CacheAI && r.aiCache != nil && cacheKey != "" {
if err := r.aiCache.Put(cacheKey, r.cfg.PromptVariant, ts, fullDecision); err != nil {
logger.Infof("failed to persist ai cache for %s: %v", r.cfg.RunID, err)
}
}
}
}
if fullDecision != nil {
r.fillDecisionRecord(record, fullDecision)
sorted := sortDecisionsByPriority(fullDecision.Decisions)
prevLogs := execLog
decisionActions = make([]store.DecisionAction, 0, len(sorted))
execLog = make([]string, 0, len(sorted)+len(prevLogs))
if len(prevLogs) > 0 {
execLog = append(execLog, prevLogs...)
}
for _, dec := range sorted {
actionRecord, trades, logEntry, execErr := r.executeDecision(dec, priceMap, ts, callCount)
if execErr != nil {
actionRecord.Success = false
actionRecord.Error = execErr.Error()
hadError = true
execLog = append(execLog, fmt.Sprintf("❌ %s %s: %v", dec.Symbol, dec.Action, execErr))
} else {
actionRecord.Success = true
execLog = append(execLog, fmt.Sprintf("✓ %s %s", dec.Symbol, dec.Action))
}
if len(trades) > 0 {
tradeEvents = append(tradeEvents, trades...)
}
if logEntry != "" {
execLog = append(execLog, logEntry)
}
decisionActions = append(decisionActions, actionRecord)
}
}
}
cycleForLog := state.DecisionCycle
if decisionAttempted {
cycleForLog = callCount
}
liquidationEvents, liquidationNote, err := r.checkLiquidation(ts, priceMap, cycleForLog)
if err != nil {
if record != nil {
record.Success = false
record.ErrorMessage = err.Error()
_ = r.logDecision(record)
}
return err
}
if len(liquidationEvents) > 0 {
hadError = true
tradeEvents = append(tradeEvents, liquidationEvents...)
if record != nil {
execLog = append(execLog, fmt.Sprintf("⚠️ Forced liquidation: %s", liquidationNote))
}
}
if record != nil {
record.Decisions = decisionActions
record.ExecutionLog = execLog
record.Success = !hadError && liquidationNote == ""
if liquidationNote != "" {
record.ErrorMessage = liquidationNote
}
}
equity, unrealized, _ := r.account.TotalEquity(priceMap)
marginUsed := r.totalMarginUsed()
r.updateState(ts, equity, unrealized, marginUsed, priceMap, decisionAttempted)
snapshot := r.snapshotState()
drawdownPct := 0.0
if snapshot.MaxEquity > 0 {
drawdownPct = ((snapshot.MaxEquity - snapshot.Equity) / snapshot.MaxEquity) * 100
}
equityPoint := EquityPoint{
Timestamp: ts,
Equity: snapshot.Equity,
Available: snapshot.Cash,
PnL: snapshot.Equity - r.account.InitialBalance(),
PnLPct: ((snapshot.Equity - r.account.InitialBalance()) / r.account.InitialBalance()) * 100,
DrawdownPct: drawdownPct,
Cycle: snapshot.DecisionCycle,
}
if err := appendEquityPoint(r.cfg.RunID, equityPoint); err != nil {
return err
}
for _, evt := range tradeEvents {
if err := appendTradeEvent(r.cfg.RunID, evt); err != nil {
return err
}
}
if record != nil {
if err := r.logDecision(record); err != nil {
return err
}
}
if err := saveProgress(r.cfg.RunID, &snapshot, &r.cfg); err != nil {
return err
}
if err := r.maybeCheckpoint(); err != nil {
return err
}
r.persistMetadata()
r.persistMetrics(false)
if !hadError && liquidationNote == "" {
r.setLastError(nil)
}
if snapshot.Liquidated {
return errLiquidated
}
return nil
}
func (r *Runner) buildDecisionContext(ts int64, marketData map[string]*market.Data, multiTF map[string]map[string]*market.Data, priceMap map[string]float64, callCount int) (*kernel.Context, *store.DecisionRecord, error) {
equity, unrealized, _ := r.account.TotalEquity(priceMap)
available := r.account.Cash()
marginUsed := r.totalMarginUsed()
marginPct := 0.0
if equity > 0 {
marginPct = (marginUsed / equity) * 100
}
accountInfo := kernel.AccountInfo{
TotalEquity: equity,
AvailableBalance: available,
TotalPnL: equity - r.account.InitialBalance(),
TotalPnLPct: ((equity - r.account.InitialBalance()) / r.account.InitialBalance()) * 100,
MarginUsed: marginUsed,
MarginUsedPct: marginPct,
PositionCount: len(r.account.Positions()),
}
positions := r.convertPositions(priceMap)
// Get candidate coins from strategy engine (includes source info)
candidateCoins, err := r.strategyEngine.GetCandidateCoins()
if err != nil {
// Fallback to simple list if strategy engine fails
candidateCoins = make([]kernel.CandidateCoin, 0, len(r.cfg.Symbols))
for _, sym := range r.cfg.Symbols {
candidateCoins = append(candidateCoins, kernel.CandidateCoin{Symbol: sym, Sources: []string{"backtest"}})
}
}
runtime := int((ts - int64(r.cfg.StartTS*1000)) / 60000)
ctx := &kernel.Context{
CurrentTime: time.UnixMilli(ts).UTC().Format("2006-01-02 15:04:05 UTC"),
RuntimeMinutes: runtime,
CallCount: callCount,
Account: accountInfo,
Positions: positions,
CandidateCoins: candidateCoins,
PromptVariant: r.cfg.PromptVariant,
MarketDataMap: marketData,
MultiTFMarket: multiTF,
BTCETHLeverage: r.cfg.Leverage.BTCETHLeverage,
AltcoinLeverage: r.cfg.Leverage.AltcoinLeverage,
Timeframes: r.cfg.Timeframes,
}
// Fetch quantitative data if enabled in strategy (uses current data as approximation)
strategyConfig := r.strategyEngine.GetConfig()
if strategyConfig.Indicators.EnableQuantData {
// Collect symbols to query (candidate coins + position coins)
symbolSet := make(map[string]bool)
for _, sym := range r.cfg.Symbols {
symbolSet[sym] = true
}
for _, pos := range positions {
symbolSet[pos.Symbol] = true
}
symbols := make([]string, 0, len(symbolSet))
for sym := range symbolSet {
symbols = append(symbols, sym)
}
ctx.QuantDataMap = r.strategyEngine.FetchQuantDataBatch(symbols)
if len(ctx.QuantDataMap) > 0 {
logger.Infof("📊 Backtest: fetched quant data for %d symbols", len(ctx.QuantDataMap))
}
}
// Fetch OI ranking data if enabled in strategy (uses current data as approximation)
if strategyConfig.Indicators.EnableOIRanking {
ctx.OIRankingData = r.strategyEngine.FetchOIRankingData()
if ctx.OIRankingData != nil {
logger.Infof("📊 Backtest: OI ranking data ready: %d top, %d low positions",
len(ctx.OIRankingData.TopPositions), len(ctx.OIRankingData.LowPositions))
}
}
// Fetch NetFlow ranking data if enabled in strategy
if strategyConfig.Indicators.EnableNetFlowRanking {
ctx.NetFlowRankingData = r.strategyEngine.FetchNetFlowRankingData()
if ctx.NetFlowRankingData != nil {
logger.Infof("💰 Backtest: NetFlow ranking data ready: inst_in=%d, inst_out=%d",
len(ctx.NetFlowRankingData.InstitutionFutureTop), len(ctx.NetFlowRankingData.InstitutionFutureLow))
}
}
// Fetch Price ranking data if enabled in strategy
if strategyConfig.Indicators.EnablePriceRanking {
ctx.PriceRankingData = r.strategyEngine.FetchPriceRankingData()
if ctx.PriceRankingData != nil {
logger.Infof("📈 Backtest: Price ranking data ready for %d durations",
len(ctx.PriceRankingData.Durations))
}
}
record := &store.DecisionRecord{
AccountState: store.AccountSnapshot{
TotalBalance: accountInfo.TotalEquity,
AvailableBalance: accountInfo.AvailableBalance,
TotalUnrealizedProfit: unrealized,
PositionCount: accountInfo.PositionCount,
MarginUsedPct: accountInfo.MarginUsedPct,
},
CandidateCoins: make([]string, 0, len(candidateCoins)),
Positions: r.snapshotPositions(priceMap),
}
for _, coin := range candidateCoins {
record.CandidateCoins = append(record.CandidateCoins, coin.Symbol)
}
record.Timestamp = time.UnixMilli(ts).UTC()
return ctx, record, nil
}
func (r *Runner) fillDecisionRecord(record *store.DecisionRecord, full *kernel.FullDecision) {
record.InputPrompt = full.UserPrompt
record.CoTTrace = full.CoTTrace
if len(full.Decisions) > 0 {
if data, err := json.MarshalIndent(full.Decisions, "", " "); err == nil {
record.DecisionJSON = string(data)
}
}
}
func (r *Runner) invokeAIWithRetry(ctx *kernel.Context) (*kernel.FullDecision, error) {
var lastErr error
for attempt := 0; attempt < aiDecisionMaxRetries; attempt++ {
// Use GetFullDecisionWithStrategy with the pre-configured strategy engine
// This ensures backtest uses the same unified prompt generation as live trading
fd, err := kernel.GetFullDecisionWithStrategy(
ctx,
r.mcpClient,
r.strategyEngine,
r.cfg.PromptVariant,
)
if err == nil {
return fd, nil
}
lastErr = err
delay := time.Duration(attempt+1) * 500 * time.Millisecond
time.Sleep(delay)
}
return nil, lastErr
}
func (r *Runner) shouldTriggerDecision(barIndex int) bool {
if r.cfg.DecisionCadenceNBars <= 1 {
return true
}
if barIndex < 0 {
return true
}
return barIndex%r.cfg.DecisionCadenceNBars == 0
}
func (r *Runner) updateState(ts int64, equity, unrealized, marginUsed float64, priceMap map[string]float64, advancedDecision bool) {
r.stateMu.Lock()
defer r.stateMu.Unlock()
if r.state.MaxEquity == 0 || equity > r.state.MaxEquity {
r.state.MaxEquity = equity
}
if r.state.MinEquity == 0 || equity < r.state.MinEquity {
r.state.MinEquity = equity
}
if r.state.MaxEquity > 0 {
drawdown := ((r.state.MaxEquity - equity) / r.state.MaxEquity) * 100
if drawdown > r.state.MaxDrawdownPct {
r.state.MaxDrawdownPct = drawdown
}
}
positions := make(map[string]PositionSnapshot)
for _, pos := range r.account.Positions() {
key := fmt.Sprintf("%s:%s", pos.Symbol, pos.Side)
positions[key] = PositionSnapshot{
Symbol: pos.Symbol,
Side: pos.Side,
Quantity: pos.Quantity,
AvgPrice: pos.EntryPrice,
Leverage: pos.Leverage,
LiquidationPrice: pos.LiquidationPrice,
MarginUsed: pos.Margin,
OpenTime: pos.OpenTime,
AccumulatedFee: pos.AccumulatedFee,
}
}
r.state.BarTimestamp = ts
r.state.BarIndex++
if advancedDecision {
r.state.DecisionCycle++
}
r.state.Cash = r.account.Cash()
r.state.Equity = equity
r.state.UnrealizedPnL = unrealized
r.state.RealizedPnL = r.account.RealizedPnL()
r.state.Positions = positions
r.state.LastUpdate = time.Now().UTC()
}
func (r *Runner) handleStop(reason error) {
r.forceCheckpoint()
if reason != nil {
r.setLastError(reason)
} else {
r.setLastError(nil)
}
r.statusMu.Lock()
r.err = reason
r.status = RunStateStopped
r.statusMu.Unlock()
r.persistMetadata()
r.persistMetrics(true)
r.releaseLock()
}
func (r *Runner) handlePause() {
r.forceCheckpoint()
r.setLastError(nil)
r.statusMu.Lock()
r.status = RunStatePaused
r.statusMu.Unlock()
r.persistMetadata()
r.persistMetrics(true)
}
func (r *Runner) resumeFromPause() {
r.setLastError(nil)
r.statusMu.Lock()
r.status = RunStateRunning
r.statusMu.Unlock()
r.persistMetadata()
}
func (r *Runner) handleCompletion() {
r.setLastError(nil)
r.statusMu.Lock()
r.status = RunStateCompleted
r.statusMu.Unlock()
r.persistMetadata()
r.persistMetrics(true)
r.releaseLock()
}
func (r *Runner) handleFailure(err error) {
r.forceCheckpoint()
if err != nil {
r.setLastError(err)
}
r.statusMu.Lock()
r.err = err
r.status = RunStateFailed
r.statusMu.Unlock()
r.persistMetadata()
r.persistMetrics(true)
r.releaseLock()
}
func (r *Runner) handleLiquidation() {
r.forceCheckpoint()
r.setLastError(errLiquidated)
r.statusMu.Lock()
r.err = errLiquidated
r.status = RunStateLiquidated
r.statusMu.Unlock()
r.persistMetadata()
r.persistMetrics(true)
r.releaseLock()
}
func sortDecisionsByPriority(decisions []kernel.Decision) []kernel.Decision {
if len(decisions) <= 1 {
return decisions
}
priority := func(action string) int {
switch action {
case "close_long", "close_short":
return 1
case "open_long", "open_short":
return 2
case "hold", "wait":
return 3
default:
return 99
}
}
result := make([]kernel.Decision, len(decisions))
copy(result, decisions)
sort.Slice(result, func(i, j int) bool {
pi := priority(result[i].Action)
pj := priority(result[j].Action)
if pi != pj {
return pi < pj
}
return i < j
})
return result
}

239
backtest/runner_metrics.go Normal file
View File

@@ -0,0 +1,239 @@
package backtest
import (
"fmt"
"sort"
"time"
"nofx/logger"
"nofx/store"
)
func (r *Runner) persistMetadata() {
state := r.snapshotState()
meta := r.buildMetadata(state, r.Status())
meta.CreatedAt = r.createdAt
if err := SaveRunMetadata(meta); err != nil {
logger.Infof("failed to save run metadata for %s: %v", r.cfg.RunID, err)
} else {
if err := updateRunIndex(meta, &r.cfg); err != nil {
logger.Infof("failed to update index for %s: %v", r.cfg.RunID, err)
}
}
}
func (r *Runner) logDecision(record *store.DecisionRecord) error {
if record == nil {
return nil
}
persistDecisionRecord(r.cfg.RunID, record)
return nil
}
func (r *Runner) persistMetrics(force bool) {
if r.cfg.RunID == "" {
return
}
if !force && !r.lastMetricsWrite.IsZero() {
if time.Since(r.lastMetricsWrite) < metricsWriteInterval {
return
}
}
state := r.snapshotState()
metrics, err := CalculateMetrics(r.cfg.RunID, &r.cfg, &state)
if err != nil {
logger.Infof("failed to compute metrics for %s: %v", r.cfg.RunID, err)
return
}
if metrics == nil {
return
}
if err := PersistMetrics(r.cfg.RunID, metrics); err != nil {
logger.Infof("failed to persist metrics for %s: %v", r.cfg.RunID, err)
return
}
r.lastMetricsWrite = time.Now()
}
func (r *Runner) buildMetadata(state BacktestState, runState RunState) *RunMetadata {
if state.Liquidated && runState != RunStateLiquidated {
runState = RunStateLiquidated
}
progress := progressPercent(state, r.cfg)
summary := RunSummary{
SymbolCount: len(r.cfg.Symbols),
DecisionTF: r.cfg.DecisionTimeframe,
ProcessedBars: state.BarIndex,
ProgressPct: progress,
EquityLast: state.Equity,
MaxDrawdownPct: state.MaxDrawdownPct,
Liquidated: state.Liquidated,
LiquidationNote: state.LiquidationNote,
}
meta := &RunMetadata{
RunID: r.cfg.RunID,
UserID: r.cfg.UserID,
State: runState,
LastError: r.lastErrorString(),
Summary: summary,
}
return meta
}
func progressPercent(state BacktestState, cfg BacktestConfig) float64 {
duration := cfg.Duration()
if duration <= 0 {
return 0
}
if state.BarTimestamp == 0 {
return 0
}
start := time.Unix(cfg.StartTS, 0)
end := time.Unix(cfg.EndTS, 0)
current := time.UnixMilli(state.BarTimestamp)
if !current.After(start) {
return 0
}
if current.After(end) {
return 100
}
elapsed := current.Sub(start)
pct := float64(elapsed) / float64(duration) * 100
if pct > 100 {
pct = 100
}
if pct < 0 {
pct = 0
}
return pct
}
func (r *Runner) maybeCheckpoint() error {
state := r.snapshotState()
shouldCheckpoint := false
if r.cfg.CheckpointIntervalBars > 0 && state.BarIndex > 0 && state.BarIndex%r.cfg.CheckpointIntervalBars == 0 {
shouldCheckpoint = true
}
interval := time.Duration(r.cfg.CheckpointIntervalSeconds) * time.Second
if interval <= 0 {
interval = 2 * time.Second
}
if time.Since(r.lastCheckpoint) >= interval {
shouldCheckpoint = true
}
if !shouldCheckpoint {
return nil
}
if err := r.saveCheckpoint(state); err != nil {
return err
}
return nil
}
func (r *Runner) snapshotForCheckpoint(state BacktestState) []PositionSnapshot {
res := make([]PositionSnapshot, 0, len(state.Positions))
for _, pos := range state.Positions {
res = append(res, pos)
}
sort.Slice(res, func(i, j int) bool {
if res[i].Symbol == res[j].Symbol {
return res[i].Side < res[j].Side
}
return res[i].Symbol < res[j].Symbol
})
return res
}
func (r *Runner) buildCheckpointFromState(state BacktestState) *Checkpoint {
return &Checkpoint{
BarIndex: state.BarIndex,
BarTimestamp: state.BarTimestamp,
Cash: state.Cash,
Equity: state.Equity,
UnrealizedPnL: state.UnrealizedPnL,
RealizedPnL: state.RealizedPnL,
Positions: r.snapshotForCheckpoint(state),
DecisionCycle: state.DecisionCycle,
Liquidated: state.Liquidated,
LiquidationNote: state.LiquidationNote,
MaxEquity: state.MaxEquity,
MinEquity: state.MinEquity,
MaxDrawdownPct: state.MaxDrawdownPct,
AICacheRef: r.cachePath,
}
}
func (r *Runner) saveCheckpoint(state BacktestState) error {
ckpt := r.buildCheckpointFromState(state)
if ckpt == nil {
return nil
}
if err := SaveCheckpoint(r.cfg.RunID, ckpt); err != nil {
return err
}
r.lastCheckpoint = time.Now()
return nil
}
func (r *Runner) forceCheckpoint() {
state := r.snapshotState()
if err := r.saveCheckpoint(state); err != nil {
logger.Infof("failed to save checkpoint for %s: %v", r.cfg.RunID, err)
}
}
func (r *Runner) RestoreFromCheckpoint() error {
ckpt, err := LoadCheckpoint(r.cfg.RunID)
if err != nil {
return err
}
return r.applyCheckpoint(ckpt)
}
func (r *Runner) applyCheckpoint(ckpt *Checkpoint) error {
if ckpt == nil {
return fmt.Errorf("checkpoint is nil")
}
r.account.RestoreFromSnapshots(ckpt.Cash, ckpt.RealizedPnL, ckpt.Positions)
r.stateMu.Lock()
defer r.stateMu.Unlock()
r.state.BarIndex = ckpt.BarIndex
r.state.BarTimestamp = ckpt.BarTimestamp
r.state.Cash = ckpt.Cash
r.state.Equity = ckpt.Equity
r.state.UnrealizedPnL = ckpt.UnrealizedPnL
r.state.RealizedPnL = ckpt.RealizedPnL
r.state.DecisionCycle = ckpt.DecisionCycle
r.state.Liquidated = ckpt.Liquidated
r.state.LiquidationNote = ckpt.LiquidationNote
r.state.MaxEquity = ckpt.MaxEquity
r.state.MinEquity = ckpt.MinEquity
r.state.MaxDrawdownPct = ckpt.MaxDrawdownPct
r.state.Positions = snapshotsToMap(ckpt.Positions)
r.state.LastUpdate = time.Now().UTC()
r.lastCheckpoint = time.Now()
return nil
}
func snapshotsToMap(snaps []PositionSnapshot) map[string]PositionSnapshot {
positions := make(map[string]PositionSnapshot, len(snaps))
for _, snap := range snaps {
key := fmt.Sprintf("%s:%s", snap.Symbol, snap.Side)
positions[key] = snap
}
return positions
}

420
backtest/runner_orders.go Normal file
View File

@@ -0,0 +1,420 @@
package backtest
import (
"fmt"
"strings"
"time"
"nofx/kernel"
"nofx/logger"
"nofx/market"
"nofx/store"
)
func (r *Runner) executeDecision(dec kernel.Decision, priceMap map[string]float64, ts int64, cycle int) (store.DecisionAction, []TradeEvent, string, error) {
symbol := dec.Symbol
if symbol == "" {
return store.DecisionAction{}, nil, "", fmt.Errorf("empty symbol in decision")
}
usedLeverage := r.resolveLeverage(dec.Leverage, symbol)
actionRecord := store.DecisionAction{
Action: dec.Action,
Symbol: symbol,
Leverage: usedLeverage,
Timestamp: time.UnixMilli(ts).UTC(),
}
if priceMap == nil {
return actionRecord, nil, "", fmt.Errorf("priceMap is nil")
}
basePrice, ok := priceMap[symbol]
if !ok || basePrice <= 0 {
return actionRecord, nil, "", fmt.Errorf("price unavailable for %s (found=%v, price=%.4f)", symbol, ok, basePrice)
}
fillPrice := r.executionPrice(symbol, basePrice, ts)
switch dec.Action {
case "open_long":
qty := r.determineQuantity(dec, basePrice)
if qty <= 0 {
return actionRecord, nil, "", fmt.Errorf("invalid qty")
}
pos, fee, execPrice, err := r.account.Open(symbol, "long", qty, usedLeverage, fillPrice, ts)
if err != nil {
return actionRecord, nil, "", err
}
actionRecord.Quantity = qty
actionRecord.Price = execPrice
actionRecord.Leverage = pos.Leverage
trade := TradeEvent{
Timestamp: ts,
Symbol: symbol,
Action: dec.Action,
Side: "long",
Quantity: qty,
Price: execPrice,
Fee: fee,
Slippage: execPrice - basePrice,
OrderValue: execPrice * qty,
RealizedPnL: 0,
Leverage: pos.Leverage,
Cycle: cycle,
PositionAfter: pos.Quantity,
}
return actionRecord, []TradeEvent{trade}, "", nil
case "open_short":
qty := r.determineQuantity(dec, basePrice)
if qty <= 0 {
return actionRecord, nil, "", fmt.Errorf("invalid qty")
}
pos, fee, execPrice, err := r.account.Open(symbol, "short", qty, usedLeverage, fillPrice, ts)
if err != nil {
return actionRecord, nil, "", err
}
actionRecord.Quantity = qty
actionRecord.Price = execPrice
actionRecord.Leverage = pos.Leverage
trade := TradeEvent{
Timestamp: ts,
Symbol: symbol,
Action: dec.Action,
Side: "short",
Quantity: qty,
Price: execPrice,
Fee: fee,
Slippage: basePrice - execPrice,
OrderValue: execPrice * qty,
RealizedPnL: 0,
Leverage: pos.Leverage,
Cycle: cycle,
PositionAfter: pos.Quantity,
}
return actionRecord, []TradeEvent{trade}, "", nil
case "close_long":
qty := r.determineCloseQuantity(symbol, "long", dec)
if qty <= 0 {
return actionRecord, nil, "", fmt.Errorf("invalid close qty")
}
posLev := r.account.positionLeverage(symbol, "long")
realized, fee, execPrice, err := r.account.Close(symbol, "long", qty, fillPrice)
if err != nil {
return actionRecord, nil, "", err
}
actionRecord.Quantity = qty
actionRecord.Price = execPrice
actionRecord.Leverage = posLev
trade := TradeEvent{
Timestamp: ts,
Symbol: symbol,
Action: dec.Action,
Side: "long",
Quantity: qty,
Price: execPrice,
Fee: fee,
Slippage: basePrice - execPrice,
OrderValue: execPrice * qty,
RealizedPnL: realized - fee,
Leverage: posLev,
Cycle: cycle,
PositionAfter: r.remainingPosition(symbol, "long"),
}
return actionRecord, []TradeEvent{trade}, "", nil
case "close_short":
qty := r.determineCloseQuantity(symbol, "short", dec)
if qty <= 0 {
return actionRecord, nil, "", fmt.Errorf("invalid close qty")
}
posLev := r.account.positionLeverage(symbol, "short")
realized, fee, execPrice, err := r.account.Close(symbol, "short", qty, fillPrice)
if err != nil {
return actionRecord, nil, "", err
}
actionRecord.Quantity = qty
actionRecord.Price = execPrice
actionRecord.Leverage = posLev
trade := TradeEvent{
Timestamp: ts,
Symbol: symbol,
Action: dec.Action,
Side: "short",
Quantity: qty,
Price: execPrice,
Fee: fee,
Slippage: execPrice - basePrice,
OrderValue: execPrice * qty,
RealizedPnL: realized - fee,
Leverage: posLev,
Cycle: cycle,
PositionAfter: r.remainingPosition(symbol, "short"),
}
return actionRecord, []TradeEvent{trade}, "", nil
case "hold", "wait":
return actionRecord, nil, fmt.Sprintf("hold position: %s", dec.Action), nil
default:
return actionRecord, nil, "", fmt.Errorf("unsupported action %s", dec.Action)
}
}
// MinPositionSizeUSD is the minimum position size in USD to avoid dust positions
const MinPositionSizeUSD = 10.0
func (r *Runner) determineQuantity(dec kernel.Decision, price float64) float64 {
snapshot := r.snapshotState()
equity := snapshot.Equity
if equity <= 0 {
equity = r.account.InitialBalance()
}
// Get leverage for this symbol
leverage := r.resolveLeverage(dec.Leverage, dec.Symbol)
if leverage <= 0 {
leverage = 5
}
// Calculate available margin (leave some buffer for fees)
availableCash := r.account.Cash()
maxMarginToUse := availableCash * 0.9 // Use max 90% of available cash
maxPositionValue := maxMarginToUse * float64(leverage)
sizeUSD := dec.PositionSizeUSD
if sizeUSD <= 0 {
// Default to 5% of equity, but cap to available margin
sizeUSD = 0.05 * equity
}
// Cap position size to what we can actually afford
if sizeUSD > maxPositionValue {
logger.Infof("📊 Backtest: capping position from %.2f to %.2f (available margin: %.2f, leverage: %dx)",
sizeUSD, maxPositionValue, maxMarginToUse, leverage)
sizeUSD = maxPositionValue
}
// Reject positions below minimum size to avoid dust positions
if sizeUSD < MinPositionSizeUSD {
logger.Infof("📊 Backtest: rejecting position size %.2f USD (below minimum %.2f USD)",
sizeUSD, MinPositionSizeUSD)
return 0
}
qty := sizeUSD / price
if qty < 0 {
qty = 0
}
return qty
}
func (r *Runner) determineCloseQuantity(symbol, side string, dec kernel.Decision) float64 {
for _, pos := range r.account.Positions() {
if pos.Symbol == strings.ToUpper(symbol) && pos.Side == side {
return pos.Quantity
}
}
return 0
}
func (r *Runner) resolveLeverage(requested int, symbol string) int {
sym := strings.ToUpper(symbol)
isBTCETH := sym == "BTCUSDT" || sym == "ETHUSDT"
// Determine configured max leverage for this symbol type
var maxLeverage int
if isBTCETH {
maxLeverage = r.cfg.Leverage.BTCETHLeverage
if maxLeverage <= 0 {
maxLeverage = 10 // Default max for BTC/ETH
}
} else {
maxLeverage = r.cfg.Leverage.AltcoinLeverage
if maxLeverage <= 0 {
maxLeverage = 5 // Default max for altcoins
}
}
// Use requested leverage if provided, otherwise use max as default
leverage := requested
if leverage <= 0 {
leverage = maxLeverage
}
// Enforce max leverage limit
if leverage > maxLeverage {
logger.Infof("📊 Backtest: capping leverage from %dx to %dx for %s",
leverage, maxLeverage, symbol)
leverage = maxLeverage
}
return leverage
}
func (r *Runner) remainingPosition(symbol, side string) float64 {
for _, pos := range r.account.Positions() {
if pos.Symbol == strings.ToUpper(symbol) && pos.Side == side {
return pos.Quantity
}
}
return 0
}
func (r *Runner) snapshotPositions(priceMap map[string]float64) []store.PositionSnapshot {
positions := r.account.Positions()
list := make([]store.PositionSnapshot, 0, len(positions))
for _, pos := range positions {
price := priceMap[pos.Symbol]
list = append(list, store.PositionSnapshot{
Symbol: pos.Symbol,
Side: pos.Side,
PositionAmt: pos.Quantity,
EntryPrice: pos.EntryPrice,
MarkPrice: price,
UnrealizedProfit: unrealizedPnL(pos, price),
Leverage: float64(pos.Leverage),
LiquidationPrice: pos.LiquidationPrice,
})
}
return list
}
func (r *Runner) convertPositions(priceMap map[string]float64) []kernel.PositionInfo {
positions := r.account.Positions()
list := make([]kernel.PositionInfo, 0, len(positions))
for _, pos := range positions {
price := priceMap[pos.Symbol]
pnl := unrealizedPnL(pos, price)
// Calculate P&L percentage based on entry notional (position cost)
pnlPct := 0.0
if pos.Notional > 0 {
pnlPct = (pnl / pos.Notional) * 100
}
list = append(list, kernel.PositionInfo{
Symbol: pos.Symbol,
Side: pos.Side,
EntryPrice: pos.EntryPrice,
MarkPrice: price,
Quantity: pos.Quantity,
Leverage: pos.Leverage,
UnrealizedPnL: pnl,
UnrealizedPnLPct: pnlPct,
LiquidationPrice: pos.LiquidationPrice,
MarginUsed: pos.Margin,
UpdateTime: time.Now().UnixMilli(),
})
}
return list
}
func (r *Runner) executionPrice(symbol string, markPrice float64, ts int64) float64 {
curr, next := r.feed.decisionBarSnapshot(symbol, ts)
switch r.cfg.FillPolicy {
case FillPolicyNextOpen:
if next != nil && next.Open > 0 {
return next.Open
}
case FillPolicyBarVWAP:
if curr != nil {
if vwap := barVWAP(*curr); vwap > 0 {
return vwap
}
}
case FillPolicyMidPrice:
if curr != nil && curr.High > 0 && curr.Low > 0 {
return (curr.High + curr.Low) / 2
}
}
return markPrice
}
func (r *Runner) totalMarginUsed() float64 {
sum := 0.0
for _, pos := range r.account.Positions() {
sum += pos.Margin
}
return sum
}
func (r *Runner) checkLiquidation(ts int64, priceMap map[string]float64, cycle int) ([]TradeEvent, string, error) {
positions := append([]*position(nil), r.account.Positions()...)
events := make([]TradeEvent, 0)
var noteBuilder strings.Builder
for _, pos := range positions {
price := priceMap[pos.Symbol]
liqPrice := pos.LiquidationPrice
trigger := false
execPrice := price
if pos.Side == "long" {
if price <= liqPrice && liqPrice > 0 {
trigger = true
execPrice = liqPrice
}
} else {
if price >= liqPrice && liqPrice > 0 {
trigger = true
execPrice = liqPrice
}
}
if !trigger {
continue
}
realized, fee, finalPrice, err := r.account.Close(pos.Symbol, pos.Side, pos.Quantity, execPrice)
if err != nil {
return nil, "", err
}
noteBuilder.WriteString(fmt.Sprintf("%s %s @ %.4f; ", pos.Symbol, pos.Side, finalPrice))
evt := TradeEvent{
Timestamp: ts,
Symbol: pos.Symbol,
Action: "liquidated",
Side: pos.Side,
Quantity: pos.Quantity,
Price: finalPrice,
Fee: fee,
Slippage: 0,
OrderValue: finalPrice * pos.Quantity,
RealizedPnL: realized - fee,
Leverage: pos.Leverage,
Cycle: cycle,
PositionAfter: 0,
LiquidationFlag: true,
Note: fmt.Sprintf("forced liquidation at %.4f", finalPrice),
}
events = append(events, evt)
}
if len(events) == 0 {
return events, "", nil
}
note := strings.TrimSuffix(noteBuilder.String(), "; ")
r.stateMu.Lock()
r.state.Liquidated = true
r.state.LiquidationNote = note
r.stateMu.Unlock()
return events, note, nil
}
func barVWAP(k market.Kline) float64 {
values := []float64{k.Open, k.High, k.Low, k.Close}
sum := 0.0
count := 0.0
for _, v := range values {
if v > 0 {
sum += v
count++
}
}
if count == 0 {
return 0
}
return sum / count
}

View File

@@ -1,233 +0,0 @@
// Lighter API Authentication Test Tool
// Usage: go run cmd/lighter_test/main.go -wallet=0x... -apikey=... [-testnet]
package main
import (
"context"
"encoding/json"
"flag"
"fmt"
"io"
"net/http"
"net/url"
"os"
"time"
lighterClient "github.com/elliottech/lighter-go/client"
lighterHTTP "github.com/elliottech/lighter-go/client/http"
)
func main() {
// Parse command line flags
walletAddr := flag.String("wallet", "", "Ethereum wallet address")
apiKeyPrivateKey := flag.String("apikey", "", "API key private key (40 bytes hex)")
apiKeyIndex := flag.Int("apikeyindex", 0, "API key index (0-255)")
testnet := flag.Bool("testnet", false, "Use testnet instead of mainnet")
flag.Parse()
if *walletAddr == "" || *apiKeyPrivateKey == "" {
fmt.Println("Usage: go run cmd/lighter_test/main.go -wallet=0x... -apikey=...")
fmt.Println("Options:")
fmt.Println(" -wallet Ethereum wallet address (required)")
fmt.Println(" -apikey API key private key, 40 bytes hex (required)")
fmt.Println(" -apikeyindex API key index, 0-255 (default: 0)")
fmt.Println(" -testnet Use testnet instead of mainnet")
os.Exit(1)
}
fmt.Println("=== Lighter API Authentication Test ===")
fmt.Printf("Wallet: %s\n", *walletAddr)
fmt.Printf("API Key Index: %d\n", *apiKeyIndex)
fmt.Printf("Testnet: %v\n", *testnet)
fmt.Println()
// Determine base URL
baseURL := "https://mainnet.zklighter.elliot.ai"
chainID := uint32(304)
if *testnet {
baseURL = "https://testnet.zklighter.elliot.ai"
chainID = uint32(300)
}
// Create HTTP client
httpClient := lighterHTTP.NewClient(baseURL)
client := &http.Client{Timeout: 30 * time.Second}
// Step 1: Get account info
fmt.Println("Step 1: Getting account info...")
accountInfo, err := getAccountByL1Address(client, baseURL, *walletAddr)
if err != nil {
fmt.Printf("ERROR: Failed to get account info: %v\n", err)
os.Exit(1)
}
fmt.Printf("SUCCESS: Account index = %d\n\n", accountInfo.AccountIndex)
// Step 2: Create TxClient
fmt.Println("Step 2: Creating TxClient...")
txClient, err := lighterClient.NewTxClient(
httpClient,
*apiKeyPrivateKey,
accountInfo.AccountIndex,
uint8(*apiKeyIndex),
chainID,
)
if err != nil {
fmt.Printf("ERROR: Failed to create TxClient: %v\n", err)
os.Exit(1)
}
fmt.Println("SUCCESS: TxClient created\n")
// Step 3: Generate auth token
fmt.Println("Step 3: Generating auth token...")
deadline := time.Now().Add(1 * time.Hour)
authToken, err := txClient.GetAuthToken(deadline)
if err != nil {
fmt.Printf("ERROR: Failed to generate auth token: %v\n", err)
os.Exit(1)
}
fmt.Printf("SUCCESS: Auth token generated\n")
fmt.Printf("Token: %s...\n", authToken[:min(50, len(authToken))])
fmt.Printf("Valid until: %s\n\n", deadline.Format(time.RFC3339))
// Step 4: Test GetActiveOrders API with auth query parameter
fmt.Println("Step 4: Testing GetActiveOrders API...")
encodedAuth := url.QueryEscape(authToken)
endpoint := fmt.Sprintf("%s/api/v1/accountActiveOrders?account_index=%d&market_id=0&auth=%s",
baseURL, accountInfo.AccountIndex, encodedAuth)
fmt.Printf("Endpoint: %s...\n", endpoint[:min(120, len(endpoint))])
req, err := http.NewRequest("GET", endpoint, nil)
if err != nil {
fmt.Printf("ERROR: Failed to create request: %v\n", err)
os.Exit(1)
}
req.Header.Set("Content-Type", "application/json")
resp, err := client.Do(req)
if err != nil {
fmt.Printf("ERROR: Request failed: %v\n", err)
os.Exit(1)
}
defer resp.Body.Close()
body, _ := io.ReadAll(resp.Body)
fmt.Printf("Status: %d\n", resp.StatusCode)
fmt.Printf("Response: %s\n\n", string(body))
// Parse response
var apiResp struct {
Code int `json:"code"`
Message string `json:"message"`
Orders []struct {
OrderID string `json:"order_id"`
Side string `json:"side"`
Type string `json:"type"`
Price string `json:"price"`
} `json:"orders"`
}
if err := json.Unmarshal(body, &apiResp); err != nil {
fmt.Printf("ERROR: Failed to parse response: %v\n", err)
os.Exit(1)
}
if apiResp.Code != 200 {
fmt.Printf("API ERROR: code=%d, message=%s\n", apiResp.Code, apiResp.Message)
fmt.Println("\n=== DIAGNOSTIC INFO ===")
fmt.Println("If you see 'invalid signature', possible causes:")
fmt.Println("1. API key is not registered on-chain")
fmt.Println("2. API key private key is incorrect")
fmt.Println("3. API key index is wrong")
fmt.Println("4. Account index mismatch")
fmt.Println("\nTo fix:")
fmt.Println("- Go to app.lighter.xyz and register/verify your API key")
fmt.Println("- Make sure you're using the correct API key private key")
os.Exit(1)
}
fmt.Printf("SUCCESS: Retrieved %d orders\n", len(apiResp.Orders))
for i, order := range apiResp.Orders {
if i >= 5 {
fmt.Printf("... and %d more orders\n", len(apiResp.Orders)-5)
break
}
fmt.Printf(" Order %s: %s %s @ %s\n", order.OrderID, order.Side, order.Type, order.Price)
}
// Step 5: Test GetTrades API (also needs auth)
fmt.Println("\nStep 5: Testing GetTrades API...")
tradesEndpoint := fmt.Sprintf("%s/api/v1/trades?account_index=%d&sort_by=timestamp&sort_dir=desc&limit=5&auth=%s",
baseURL, accountInfo.AccountIndex, encodedAuth)
tradesReq, _ := http.NewRequest("GET", tradesEndpoint, nil)
tradesResp, err := client.Do(tradesReq)
if err != nil {
fmt.Printf("ERROR: Trades request failed: %v\n", err)
} else {
defer tradesResp.Body.Close()
tradesBody, _ := io.ReadAll(tradesResp.Body)
fmt.Printf("Status: %d\n", tradesResp.StatusCode)
if tradesResp.StatusCode == 200 {
fmt.Println("SUCCESS: GetTrades API working")
} else {
fmt.Printf("Response: %s\n", string(tradesBody))
}
}
fmt.Println("\n=== ALL TESTS PASSED ===")
}
// AccountInfo represents Lighter account information
type AccountInfo struct {
AccountIndex int64 `json:"account_index"`
L1Address string `json:"l1_address"`
}
// getAccountByL1Address gets account info by L1 wallet address
func getAccountByL1Address(client *http.Client, baseURL, walletAddr string) (*AccountInfo, error) {
endpoint := fmt.Sprintf("%s/api/v1/account?by=l1_address&value=%s", baseURL, walletAddr)
req, err := http.NewRequest("GET", endpoint, nil)
if err != nil {
return nil, err
}
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
defer cancel()
req = req.WithContext(ctx)
resp, err := client.Do(req)
if err != nil {
return nil, err
}
defer resp.Body.Close()
body, err := io.ReadAll(resp.Body)
if err != nil {
return nil, err
}
// Parse response - can be in "accounts" or "sub_accounts" field
var apiResp struct {
Code int `json:"code"`
Message string `json:"message"`
Accounts []AccountInfo `json:"accounts"`
SubAccounts []AccountInfo `json:"sub_accounts"`
}
if err := json.Unmarshal(body, &apiResp); err != nil {
return nil, fmt.Errorf("failed to parse response: %w, body: %s", err, string(body))
}
// Check main accounts first
if len(apiResp.Accounts) > 0 {
return &apiResp.Accounts[0], nil
}
// Check sub-accounts
if len(apiResp.SubAccounts) > 0 {
return &apiResp.SubAccounts[0], nil
}
return nil, fmt.Errorf("no account found for address: %s", walletAddr)
}

View File

@@ -1,7 +1,7 @@
package config package config
import ( import (
"nofx/experience" "nofx/telemetry"
"nofx/mcp" "nofx/mcp"
"os" "os"
"strconv" "strconv"
@@ -122,11 +122,11 @@ func Init() {
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)
experience.Init(cfg.ExperienceImprovement, "") telemetry.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) {
experience.TrackAIUsage(experience.AIUsageEvent{ telemetry.TrackAIUsage(telemetry.AIUsageEvent{
ModelProvider: usage.Provider, ModelProvider: usage.Provider,
ModelName: usage.Model, ModelName: usage.Model,
InputTokens: usage.PromptTokens, InputTokens: usage.PromptTokens,

File diff suppressed because it is too large Load Diff

374
kernel/engine_analysis.go Normal file
View File

@@ -0,0 +1,374 @@
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)
}
// 1. Fetch market data using strategy config
if len(ctx.MarketDataMap) == 0 {
if err := fetchMarketDataWithStrategy(ctx, engine); err != nil {
return nil, fmt.Errorf("failed to fetch market data: %w", err)
}
}
// Ensure OITopDataMap is initialized
if ctx.OITopDataMap == nil {
ctx.OITopDataMap = make(map[string]*OITopData)
oiPositions, err := engine.nofxosClient.GetOITopPositions()
if err == nil {
for _, pos := range oiPositions {
ctx.OITopDataMap[pos.Symbol] = &OITopData{
Rank: pos.Rank,
OIDeltaPercent: pos.OIDeltaPercent,
OIDeltaValue: pos.OIDeltaValue,
PriceDeltaPercent: pos.PriceDeltaPercent,
}
}
}
}
// 2. Build System Prompt using strategy engine
riskConfig := engine.GetRiskControlConfig()
systemPrompt := engine.BuildSystemPrompt(ctx.Account.TotalEquity, variant)
// 3. Build User Prompt using strategy engine
userPrompt := engine.BuildUserPrompt(ctx)
// 4. Call AI API
aiCallStart := time.Now()
aiResponse, err := mcpClient.CallWithMessages(systemPrompt, userPrompt)
aiCallDuration := time.Since(aiCallStart)
if err != nil {
return nil, fmt.Errorf("AI API call failed: %w", err)
}
// 5. Parse AI response
decision, err := parseFullDecisionResponse(
aiResponse,
ctx.Account.TotalEquity,
riskConfig.BTCETHMaxLeverage,
riskConfig.AltcoinMaxLeverage,
riskConfig.BTCETHMaxPositionValueRatio,
riskConfig.AltcoinMaxPositionValueRatio,
)
if decision != nil {
decision.Timestamp = time.Now()
decision.SystemPrompt = systemPrompt
decision.UserPrompt = userPrompt
decision.AIRequestDurationMs = aiCallDuration.Milliseconds()
decision.RawResponse = aiResponse
}
if err != nil {
return decision, fmt.Errorf("failed to parse AI response: %w", err)
}
return decision, nil
}
// ============================================================================
// Market Data Fetching
// ============================================================================
// fetchMarketDataWithStrategy fetches market data using strategy config (multiple timeframes)
func fetchMarketDataWithStrategy(ctx *Context, engine *StrategyEngine) error {
config := engine.GetConfig()
ctx.MarketDataMap = make(map[string]*market.Data)
timeframes := config.Indicators.Klines.SelectedTimeframes
primaryTimeframe := config.Indicators.Klines.PrimaryTimeframe
klineCount := config.Indicators.Klines.PrimaryCount
// Compatible with old configuration
if len(timeframes) == 0 {
if primaryTimeframe != "" {
timeframes = append(timeframes, primaryTimeframe)
} else {
timeframes = append(timeframes, "3m")
}
if config.Indicators.Klines.LongerTimeframe != "" {
timeframes = append(timeframes, config.Indicators.Klines.LongerTimeframe)
}
}
if primaryTimeframe == "" {
primaryTimeframe = timeframes[0]
}
if klineCount <= 0 {
klineCount = 30
}
logger.Infof("📊 Strategy timeframes: %v, Primary: %s, Kline count: %d", timeframes, primaryTimeframe, klineCount)
// 1. First fetch data for position coins (must fetch)
for _, pos := range ctx.Positions {
data, err := market.GetWithTimeframes(pos.Symbol, timeframes, primaryTimeframe, klineCount)
if err != nil {
logger.Infof("⚠️ Failed to fetch market data for position %s: %v", pos.Symbol, err)
continue
}
ctx.MarketDataMap[pos.Symbol] = data
}
// 2. Fetch data for all candidate coins
positionSymbols := make(map[string]bool)
for _, pos := range ctx.Positions {
positionSymbols[pos.Symbol] = true
}
const minOIThresholdMillions = 15.0 // 15M USD minimum open interest value
for _, coin := range ctx.CandidateCoins {
if _, exists := ctx.MarketDataMap[coin.Symbol]; exists {
continue
}
data, err := market.GetWithTimeframes(coin.Symbol, timeframes, primaryTimeframe, klineCount)
if err != nil {
logger.Infof("⚠️ Failed to fetch market data for %s: %v", coin.Symbol, err)
continue
}
// Liquidity filter (skip for xyz dex assets - they don't have OI data from Binance)
isExistingPosition := positionSymbols[coin.Symbol]
isXyzAsset := market.IsXyzDexAsset(coin.Symbol)
if !isExistingPosition && !isXyzAsset && data.OpenInterest != nil && data.CurrentPrice > 0 {
oiValue := data.OpenInterest.Latest * data.CurrentPrice
oiValueInMillions := oiValue / 1_000_000
if oiValueInMillions < minOIThresholdMillions {
logger.Infof("⚠️ %s OI value too low (%.2fM USD < %.1fM), skipping coin",
coin.Symbol, oiValueInMillions, minOIThresholdMillions)
continue
}
}
ctx.MarketDataMap[coin.Symbol] = data
}
logger.Infof("📊 Successfully fetched multi-timeframe market data for %d coins", len(ctx.MarketDataMap))
return nil
}
// ============================================================================
// AI Response Parsing
// ============================================================================
func parseFullDecisionResponse(aiResponse string, accountEquity float64, btcEthLeverage, altcoinLeverage int, btcEthPosRatio, altcoinPosRatio float64) (*FullDecision, error) {
cotTrace := extractCoTTrace(aiResponse)
decisions, err := extractDecisions(aiResponse)
if err != nil {
return &FullDecision{
CoTTrace: cotTrace,
Decisions: []Decision{},
}, fmt.Errorf("failed to extract decisions: %w", err)
}
if err := validateDecisions(decisions, accountEquity, btcEthLeverage, altcoinLeverage, btcEthPosRatio, altcoinPosRatio); err != nil {
return &FullDecision{
CoTTrace: cotTrace,
Decisions: decisions,
}, fmt.Errorf("decision validation failed: %w", err)
}
return &FullDecision{
CoTTrace: cotTrace,
Decisions: decisions,
}, nil
}
func extractCoTTrace(response string) string {
if match := reReasoningTag.FindStringSubmatch(response); match != nil && len(match) > 1 {
logger.Infof("✓ Extracted reasoning chain using <reasoning> tag")
return strings.TrimSpace(match[1])
}
if decisionIdx := strings.Index(response, "<decision>"); decisionIdx > 0 {
logger.Infof("✓ Extracted content before <decision> tag as reasoning chain")
return strings.TrimSpace(response[:decisionIdx])
}
jsonStart := strings.Index(response, "[")
if jsonStart > 0 {
logger.Infof("⚠️ Extracted reasoning chain using old format ([ character separator)")
return strings.TrimSpace(response[:jsonStart])
}
return strings.TrimSpace(response)
}
func extractDecisions(response string) ([]Decision, error) {
s := removeInvisibleRunes(response)
s = strings.TrimSpace(s)
s = fixMissingQuotes(s)
var jsonPart string
if match := reDecisionTag.FindStringSubmatch(s); match != nil && len(match) > 1 {
jsonPart = strings.TrimSpace(match[1])
logger.Infof("✓ Extracted JSON using <decision> tag")
} else {
jsonPart = s
logger.Infof("⚠️ <decision> tag not found, searching JSON in full text")
}
jsonPart = fixMissingQuotes(jsonPart)
if m := reJSONFence.FindStringSubmatch(jsonPart); m != nil && len(m) > 1 {
jsonContent := strings.TrimSpace(m[1])
jsonContent = compactArrayOpen(jsonContent)
jsonContent = fixMissingQuotes(jsonContent)
if err := validateJSONFormat(jsonContent); err != nil {
return nil, fmt.Errorf("JSON format validation failed: %w\nJSON content: %s\nFull response:\n%s", err, jsonContent, response)
}
var decisions []Decision
if err := json.Unmarshal([]byte(jsonContent), &decisions); err != nil {
return nil, fmt.Errorf("JSON parsing failed: %w\nJSON content: %s", err, jsonContent)
}
return decisions, nil
}
jsonContent := strings.TrimSpace(reJSONArray.FindString(jsonPart))
if jsonContent == "" {
logger.Infof("⚠️ [SafeFallback] AI didn't output JSON decision, entering safe wait mode")
cotSummary := jsonPart
if len(cotSummary) > 240 {
cotSummary = cotSummary[:240] + "..."
}
fallbackDecision := Decision{
Symbol: "ALL",
Action: "wait",
Reasoning: fmt.Sprintf("Model didn't output structured JSON decision, entering safe wait; summary: %s", cotSummary),
}
return []Decision{fallbackDecision}, nil
}
jsonContent = compactArrayOpen(jsonContent)
jsonContent = fixMissingQuotes(jsonContent)
if err := validateJSONFormat(jsonContent); err != nil {
return nil, fmt.Errorf("JSON format validation failed: %w\nJSON content: %s\nFull response:\n%s", err, jsonContent, response)
}
var decisions []Decision
if err := json.Unmarshal([]byte(jsonContent), &decisions); err != nil {
return nil, fmt.Errorf("JSON parsing failed: %w\nJSON content: %s", err, jsonContent)
}
return decisions, nil
}
func fixMissingQuotes(jsonStr string) string {
jsonStr = strings.ReplaceAll(jsonStr, "\u201c", "\"")
jsonStr = strings.ReplaceAll(jsonStr, "\u201d", "\"")
jsonStr = strings.ReplaceAll(jsonStr, "\u2018", "'")
jsonStr = strings.ReplaceAll(jsonStr, "\u2019", "'")
jsonStr = strings.ReplaceAll(jsonStr, "", "[")
jsonStr = strings.ReplaceAll(jsonStr, "", "]")
jsonStr = strings.ReplaceAll(jsonStr, "", "{")
jsonStr = strings.ReplaceAll(jsonStr, "", "}")
jsonStr = strings.ReplaceAll(jsonStr, "", ":")
jsonStr = strings.ReplaceAll(jsonStr, "", ",")
jsonStr = strings.ReplaceAll(jsonStr, "【", "[")
jsonStr = strings.ReplaceAll(jsonStr, "】", "]")
jsonStr = strings.ReplaceAll(jsonStr, "", "[")
jsonStr = strings.ReplaceAll(jsonStr, "", "]")
jsonStr = strings.ReplaceAll(jsonStr, "、", ",")
jsonStr = strings.ReplaceAll(jsonStr, " ", " ")
return jsonStr
}
func validateJSONFormat(jsonStr string) error {
trimmed := strings.TrimSpace(jsonStr)
if !reArrayHead.MatchString(trimmed) {
if strings.HasPrefix(trimmed, "[") && !strings.Contains(trimmed[:min(20, len(trimmed))], "{") {
return fmt.Errorf("not a valid decision array (must contain objects {}), actual content: %s", trimmed[:min(50, len(trimmed))])
}
return fmt.Errorf("JSON must start with [{ (whitespace allowed), actual: %s", trimmed[:min(20, len(trimmed))])
}
if strings.Contains(jsonStr, "~") {
return fmt.Errorf("JSON cannot contain range symbol ~, all numbers must be precise single values")
}
for i := 0; i < len(jsonStr)-4; i++ {
if jsonStr[i] >= '0' && jsonStr[i] <= '9' &&
jsonStr[i+1] == ',' &&
jsonStr[i+2] >= '0' && jsonStr[i+2] <= '9' &&
jsonStr[i+3] >= '0' && jsonStr[i+3] <= '9' &&
jsonStr[i+4] >= '0' && jsonStr[i+4] <= '9' {
return fmt.Errorf("JSON numbers cannot contain thousand separator comma, found: %s", jsonStr[i:min(i+10, len(jsonStr))])
}
}
return nil
}
func min(a, b int) int {
if a < b {
return a
}
return b
}
func removeInvisibleRunes(s string) string {
return reInvisibleRunes.ReplaceAllString(s, "")
}
func compactArrayOpen(s string) string {
return reArrayOpenSpace.ReplaceAllString(strings.TrimSpace(s), "[{")
}

121
kernel/engine_position.go Normal file
View File

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

779
kernel/engine_prompt.go Normal file
View File

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

View File

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

View File

@@ -6,7 +6,7 @@ import (
"nofx/backtest" "nofx/backtest"
"nofx/config" "nofx/config"
"nofx/crypto" "nofx/crypto"
"nofx/experience" "nofx/telemetry"
"nofx/logger" "nofx/logger"
"nofx/manager" "nofx/manager"
"nofx/mcp" "nofx/mcp"
@@ -194,5 +194,5 @@ func initInstallationID(st *store.Store) {
} }
// Set installation ID in experience module // Set installation ID in experience module
experience.SetInstallationID(installationID) telemetry.SetInstallationID(installationID)
} }

View File

@@ -1,87 +0,0 @@
package manager
import (
"testing"
)
// TestRemoveTrader tests removing trader from memory
func TestRemoveTrader(t *testing.T) {
tm := NewTraderManager()
// Create a mock trader and add it to map
traderID := "test-trader-123"
tm.traders[traderID] = nil // Use nil as placeholder, only need to verify deletion logic in test
// Verify trader exists
if _, exists := tm.traders[traderID]; !exists {
t.Fatal("trader should exist in map")
}
// Call RemoveTrader
tm.RemoveTrader(traderID)
// Verify trader has been removed
if _, exists := tm.traders[traderID]; exists {
t.Error("trader should be removed from map")
}
}
// TestRemoveTrader_NonExistent tests that removing non-existent trader doesn't error
func TestRemoveTrader_NonExistent(t *testing.T) {
tm := NewTraderManager()
// Trying to remove non-existent trader should not panic
defer func() {
if r := recover(); r != nil {
t.Errorf("removing non-existent trader should not panic: %v", r)
}
}()
tm.RemoveTrader("non-existent-trader")
}
// TestRemoveTrader_Concurrent tests concurrent removal of trader safety
func TestRemoveTrader_Concurrent(t *testing.T) {
tm := NewTraderManager()
traderID := "test-trader-concurrent"
// Add trader
tm.traders[traderID] = nil
// Concurrently call RemoveTrader
done := make(chan bool, 10)
for i := 0; i < 10; i++ {
go func() {
tm.RemoveTrader(traderID)
done <- true
}()
}
// Wait for all goroutines to complete
for i := 0; i < 10; i++ {
<-done
}
// Verify trader has been removed
if _, exists := tm.traders[traderID]; exists {
t.Error("trader should be removed from map")
}
}
// TestGetTrader_AfterRemove tests that getting trader after removal returns error
func TestGetTrader_AfterRemove(t *testing.T) {
tm := NewTraderManager()
traderID := "test-trader-get"
// Add trader
tm.traders[traderID] = nil
// Remove trader
tm.RemoveTrader(traderID)
// Try to get removed trader
_, err := tm.GetTrader(traderID)
if err == nil {
t.Error("getting removed trader should return error")
}
}

View File

@@ -1,15 +1,11 @@
package market package market
import ( import (
"context"
"encoding/json" "encoding/json"
"fmt" "fmt"
"io" "io"
"math" "math"
"nofx/logger" "nofx/logger"
"nofx/provider/coinank/coinank_api"
"nofx/provider/coinank/coinank_enum"
"nofx/provider/hyperliquid"
"strconv" "strconv"
"strings" "strings"
"sync" "sync"
@@ -28,143 +24,6 @@ var (
frCacheTTL = 1 * time.Hour frCacheTTL = 1 * time.Hour
) )
// Note: Kline data now uses free/open API (coinank_api.Kline) which doesn't require authentication
// getKlinesFromCoinAnk fetches kline data from CoinAnk API (replacement for WSMonitorCli)
func getKlinesFromCoinAnk(symbol, interval, exchange string, limit int) ([]Kline, error) {
// Map interval string to coinank enum
var coinankInterval coinank_enum.Interval
switch interval {
case "1m":
coinankInterval = coinank_enum.Minute1
case "3m":
coinankInterval = coinank_enum.Minute3
case "5m":
coinankInterval = coinank_enum.Minute5
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
default:
return nil, fmt.Errorf("unsupported interval: %s", interval)
}
// 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 "hyperliquid":
coinankExchange = coinank_enum.Hyperliquid
case "aster":
coinankExchange = coinank_enum.Aster
default:
// Default to Binance for unknown exchanges
coinankExchange = coinank_enum.Binance
}
// 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, symbol, coinankExchange, ts, coinank_enum.To, limit, coinankInterval)
if err != nil {
// If exchange-specific data fails, fallback to Binance
if coinankExchange != coinank_enum.Binance {
logger.Warnf("⚠️ CoinAnk %s data failed, falling back to Binance: %v", exchange, err)
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
klines := make([]Kline, len(coinankKlines))
for i, ck := range coinankKlines {
klines[i] = Kline{
OpenTime: ck.StartTime,
Open: ck.Open,
High: ck.High,
Low: ck.Low,
Close: ck.Close,
Volume: ck.Volume,
CloseTime: ck.EndTime,
}
}
return klines, nil
}
// getKlinesFromHyperliquid fetches kline data from Hyperliquid API for xyz dex assets
func getKlinesFromHyperliquid(symbol, interval string, limit int) ([]Kline, error) {
// Remove xyz: prefix if present for the API call
baseCoin := strings.TrimPrefix(symbol, "xyz:")
// Map interval to Hyperliquid format
hlInterval := hyperliquid.MapTimeframe(interval)
// Create Hyperliquid client
client := hyperliquid.NewClient()
// Fetch candles
ctx := context.Background()
candles, err := client.GetCandles(ctx, baseCoin, hlInterval, limit)
if err != nil {
return nil, fmt.Errorf("Hyperliquid API error: %w", err)
}
// Convert to market.Kline format
klines := make([]Kline, len(candles))
for i, c := range candles {
open, _ := strconv.ParseFloat(c.Open, 64)
high, _ := strconv.ParseFloat(c.High, 64)
low, _ := strconv.ParseFloat(c.Low, 64)
closePrice, _ := strconv.ParseFloat(c.Close, 64)
volume, _ := strconv.ParseFloat(c.Volume, 64)
klines[i] = Kline{
OpenTime: c.OpenTime,
Open: open,
High: high,
Low: low,
Close: closePrice,
Volume: volume,
CloseTime: c.CloseTime,
}
}
return klines, nil
}
// Get retrieves market data for the specified token (uses Binance data by default) // Get retrieves market data for the specified token (uses Binance data by default)
func Get(symbol string) (*Data, error) { func Get(symbol string) (*Data, error) {
return GetWithExchange(symbol, "binance") return GetWithExchange(symbol, "binance")
@@ -396,398 +255,6 @@ func GetWithTimeframes(symbol string, timeframes []string, primaryTimeframe stri
}, nil }, nil
} }
// calculateTimeframeSeries calculates series data for a single timeframe
func calculateTimeframeSeries(klines []Kline, timeframe string, count int) *TimeframeSeriesData {
if count <= 0 {
count = 10 // default
}
data := &TimeframeSeriesData{
Timeframe: timeframe,
Klines: make([]KlineBar, 0, count),
MidPrices: make([]float64, 0, count),
EMA20Values: make([]float64, 0, count),
EMA50Values: make([]float64, 0, count),
MACDValues: make([]float64, 0, count),
RSI7Values: make([]float64, 0, count),
RSI14Values: make([]float64, 0, count),
Volume: make([]float64, 0, count),
BOLLUpper: make([]float64, 0, count),
BOLLMiddle: make([]float64, 0, count),
BOLLLower: make([]float64, 0, count),
}
// Get latest N data points based on count from config
start := len(klines) - count
if start < 0 {
start = 0
}
for i := start; i < len(klines); i++ {
// Store full OHLCV kline data
data.Klines = append(data.Klines, KlineBar{
Time: klines[i].OpenTime,
Open: klines[i].Open,
High: klines[i].High,
Low: klines[i].Low,
Close: klines[i].Close,
Volume: klines[i].Volume,
})
// Keep MidPrices and Volume for backward compatibility
data.MidPrices = append(data.MidPrices, klines[i].Close)
data.Volume = append(data.Volume, klines[i].Volume)
// Calculate EMA20 for each point
if i >= 19 {
ema20 := calculateEMA(klines[:i+1], 20)
data.EMA20Values = append(data.EMA20Values, ema20)
}
// Calculate EMA50 for each point
if i >= 49 {
ema50 := calculateEMA(klines[:i+1], 50)
data.EMA50Values = append(data.EMA50Values, ema50)
}
// Calculate MACD for each point
if i >= 25 {
macd := calculateMACD(klines[:i+1])
data.MACDValues = append(data.MACDValues, macd)
}
// Calculate RSI for each point
if i >= 7 {
rsi7 := calculateRSI(klines[:i+1], 7)
data.RSI7Values = append(data.RSI7Values, rsi7)
}
if i >= 14 {
rsi14 := calculateRSI(klines[:i+1], 14)
data.RSI14Values = append(data.RSI14Values, rsi14)
}
// Calculate Bollinger Bands (period 20, std dev multiplier 2)
if i >= 19 {
upper, middle, lower := calculateBOLL(klines[:i+1], 20, 2.0)
data.BOLLUpper = append(data.BOLLUpper, upper)
data.BOLLMiddle = append(data.BOLLMiddle, middle)
data.BOLLLower = append(data.BOLLLower, lower)
}
}
// Calculate ATR14
data.ATR14 = calculateATR(klines, 14)
return data
}
// calculatePriceChangeByBars calculates how many K-lines to look back for price change based on timeframe
func calculatePriceChangeByBars(klines []Kline, timeframe string, targetMinutes int) float64 {
if len(klines) < 2 {
return 0
}
// Parse timeframe to minutes
tfMinutes := parseTimeframeToMinutes(timeframe)
if tfMinutes <= 0 {
return 0
}
// Calculate how many K-lines to look back
barsBack := targetMinutes / tfMinutes
if barsBack < 1 {
barsBack = 1
}
currentPrice := klines[len(klines)-1].Close
idx := len(klines) - 1 - barsBack
if idx < 0 {
idx = 0
}
oldPrice := klines[idx].Close
if oldPrice > 0 {
return ((currentPrice - oldPrice) / oldPrice) * 100
}
return 0
}
// parseTimeframeToMinutes parses timeframe string to minutes
func parseTimeframeToMinutes(tf string) int {
switch tf {
case "1m":
return 1
case "3m":
return 3
case "5m":
return 5
case "15m":
return 15
case "30m":
return 30
case "1h":
return 60
case "2h":
return 120
case "4h":
return 240
case "6h":
return 360
case "8h":
return 480
case "12h":
return 720
case "1d":
return 1440
case "3d":
return 4320
case "1w":
return 10080
default:
return 0
}
}
// calculateEMA calculates EMA
func calculateEMA(klines []Kline, period int) float64 {
if len(klines) < period {
return 0
}
// Calculate SMA as initial EMA
sum := 0.0
for i := 0; i < period; i++ {
sum += klines[i].Close
}
ema := sum / float64(period)
// Calculate EMA
multiplier := 2.0 / float64(period+1)
for i := period; i < len(klines); i++ {
ema = (klines[i].Close-ema)*multiplier + ema
}
return ema
}
// calculateMACD calculates MACD
func calculateMACD(klines []Kline) float64 {
if len(klines) < 26 {
return 0
}
// Calculate 12-period and 26-period EMA
ema12 := calculateEMA(klines, 12)
ema26 := calculateEMA(klines, 26)
// MACD = EMA12 - EMA26
return ema12 - ema26
}
// calculateRSI calculates RSI
func calculateRSI(klines []Kline, period int) float64 {
if len(klines) <= period {
return 0
}
gains := 0.0
losses := 0.0
// Calculate initial average gain/loss
for i := 1; i <= period; i++ {
change := klines[i].Close - klines[i-1].Close
if change > 0 {
gains += change
} else {
losses += -change
}
}
avgGain := gains / float64(period)
avgLoss := losses / float64(period)
// Use Wilder smoothing method to calculate subsequent RSI
for i := period + 1; i < len(klines); i++ {
change := klines[i].Close - klines[i-1].Close
if change > 0 {
avgGain = (avgGain*float64(period-1) + change) / float64(period)
avgLoss = (avgLoss * float64(period-1)) / float64(period)
} else {
avgGain = (avgGain * float64(period-1)) / float64(period)
avgLoss = (avgLoss*float64(period-1) + (-change)) / float64(period)
}
}
if avgLoss == 0 {
return 100
}
rs := avgGain / avgLoss
rsi := 100 - (100 / (1 + rs))
return rsi
}
// calculateATR calculates ATR
func calculateATR(klines []Kline, period int) float64 {
if len(klines) <= period {
return 0
}
trs := make([]float64, len(klines))
for i := 1; i < len(klines); i++ {
high := klines[i].High
low := klines[i].Low
prevClose := klines[i-1].Close
tr1 := high - low
tr2 := math.Abs(high - prevClose)
tr3 := math.Abs(low - prevClose)
trs[i] = math.Max(tr1, math.Max(tr2, tr3))
}
// Calculate initial ATR
sum := 0.0
for i := 1; i <= period; i++ {
sum += trs[i]
}
atr := sum / float64(period)
// Wilder smoothing
for i := period + 1; i < len(klines); i++ {
atr = (atr*float64(period-1) + trs[i]) / float64(period)
}
return atr
}
// calculateBOLL calculates Bollinger Bands (upper, middle, lower)
// period: typically 20, multiplier: typically 2
func calculateBOLL(klines []Kline, period int, multiplier float64) (upper, middle, lower float64) {
if len(klines) < period {
return 0, 0, 0
}
// Calculate SMA (middle band)
sum := 0.0
for i := len(klines) - period; i < len(klines); i++ {
sum += klines[i].Close
}
sma := sum / float64(period)
// Calculate standard deviation
variance := 0.0
for i := len(klines) - period; i < len(klines); i++ {
diff := klines[i].Close - sma
variance += diff * diff
}
stdDev := math.Sqrt(variance / float64(period))
// Calculate bands
middle = sma
upper = sma + multiplier*stdDev
lower = sma - multiplier*stdDev
return upper, middle, lower
}
// calculateIntradaySeries calculates intraday series data
func calculateIntradaySeries(klines []Kline) *IntradayData {
data := &IntradayData{
MidPrices: make([]float64, 0, 10),
EMA20Values: make([]float64, 0, 10),
MACDValues: make([]float64, 0, 10),
RSI7Values: make([]float64, 0, 10),
RSI14Values: make([]float64, 0, 10),
Volume: make([]float64, 0, 10),
}
// Get latest 10 data points
start := len(klines) - 10
if start < 0 {
start = 0
}
for i := start; i < len(klines); i++ {
data.MidPrices = append(data.MidPrices, klines[i].Close)
data.Volume = append(data.Volume, klines[i].Volume)
// Calculate EMA20 for each point
if i >= 19 {
ema20 := calculateEMA(klines[:i+1], 20)
data.EMA20Values = append(data.EMA20Values, ema20)
}
// Calculate MACD for each point
if i >= 25 {
macd := calculateMACD(klines[:i+1])
data.MACDValues = append(data.MACDValues, macd)
}
// Calculate RSI for each point
if i >= 7 {
rsi7 := calculateRSI(klines[:i+1], 7)
data.RSI7Values = append(data.RSI7Values, rsi7)
}
if i >= 14 {
rsi14 := calculateRSI(klines[:i+1], 14)
data.RSI14Values = append(data.RSI14Values, rsi14)
}
}
// Calculate 3m ATR14
data.ATR14 = calculateATR(klines, 14)
return data
}
// calculateLongerTermData calculates longer-term data
func calculateLongerTermData(klines []Kline) *LongerTermData {
data := &LongerTermData{
MACDValues: make([]float64, 0, 10),
RSI14Values: make([]float64, 0, 10),
}
// Calculate EMA
data.EMA20 = calculateEMA(klines, 20)
data.EMA50 = calculateEMA(klines, 50)
// Calculate ATR
data.ATR3 = calculateATR(klines, 3)
data.ATR14 = calculateATR(klines, 14)
// Calculate volume
if len(klines) > 0 {
data.CurrentVolume = klines[len(klines)-1].Volume
// Calculate average volume
sum := 0.0
for _, k := range klines {
sum += k.Volume
}
data.AverageVolume = sum / float64(len(klines))
}
// Calculate MACD and RSI series
start := len(klines) - 10
if start < 0 {
start = 0
}
for i := start; i < len(klines); i++ {
if i >= 25 {
macd := calculateMACD(klines[:i+1])
data.MACDValues = append(data.MACDValues, macd)
}
if i >= 14 {
rsi14 := calculateRSI(klines[:i+1], 14)
data.RSI14Values = append(data.RSI14Values, rsi14)
}
}
return data
}
// getOpenInterestData retrieves OI data // getOpenInterestData retrieves OI data
func getOpenInterestData(symbol string) (*OIData, error) { func getOpenInterestData(symbol string) (*OIData, error) {
url := fmt.Sprintf("https://fapi.binance.com/fapi/v1/openInterest?symbol=%s", symbol) url := fmt.Sprintf("https://fapi.binance.com/fapi/v1/openInterest?symbol=%s", symbol)
@@ -1227,118 +694,3 @@ func isStaleData(klines []Kline, symbol string) bool {
logger.Infof("⚠️ %s detected extreme price stability (no fluctuation for %d consecutive periods), but volume is normal", symbol, stalePriceThreshold) logger.Infof("⚠️ %s detected extreme price stability (no fluctuation for %d consecutive periods), but volume is normal", symbol, stalePriceThreshold)
return false return false
} }
// ========== 导出的指标计算函数(供测试使用) ==========
// ExportCalculateEMA exports calculateEMA for testing
func ExportCalculateEMA(klines []Kline, period int) float64 {
return calculateEMA(klines, period)
}
// ExportCalculateMACD exports calculateMACD for testing
func ExportCalculateMACD(klines []Kline) float64 {
return calculateMACD(klines)
}
// ExportCalculateRSI exports calculateRSI for testing
func ExportCalculateRSI(klines []Kline, period int) float64 {
return calculateRSI(klines, period)
}
// ExportCalculateATR exports calculateATR for testing
func ExportCalculateATR(klines []Kline, period int) float64 {
return calculateATR(klines, period)
}
// ExportCalculateBOLL exports calculateBOLL for testing
func ExportCalculateBOLL(klines []Kline, period int, multiplier float64) (upper, middle, lower float64) {
return calculateBOLL(klines, period, multiplier)
}
// calculateDonchian calculates Donchian channel (highest high, lowest low) for given period
func calculateDonchian(klines []Kline, period int) (upper, lower float64) {
if len(klines) == 0 || period <= 0 {
return 0, 0
}
// Use all available klines if period > len(klines)
start := len(klines) - period
if start < 0 {
start = 0
}
upper = klines[start].High
lower = klines[start].Low
for i := start + 1; i < len(klines); i++ {
if klines[i].High > upper {
upper = klines[i].High
}
if klines[i].Low < lower {
lower = klines[i].Low
}
}
return upper, lower
}
// ExportCalculateDonchian exports calculateDonchian for testing
func ExportCalculateDonchian(klines []Kline, period int) (float64, float64) {
return calculateDonchian(klines, period)
}
// Box period constants (in 1h candles)
const (
ShortBoxPeriod = 72 // 3 days of 1h candles
MidBoxPeriod = 240 // 10 days of 1h candles
LongBoxPeriod = 500 // ~21 days of 1h candles
)
// calculateBoxData calculates multi-period box data from klines
func calculateBoxData(klines []Kline, currentPrice float64) *BoxData {
box := &BoxData{
CurrentPrice: currentPrice,
}
if len(klines) == 0 {
return box
}
box.ShortUpper, box.ShortLower = calculateDonchian(klines, ShortBoxPeriod)
box.MidUpper, box.MidLower = calculateDonchian(klines, MidBoxPeriod)
box.LongUpper, box.LongLower = calculateDonchian(klines, LongBoxPeriod)
return box
}
// ExportCalculateBoxData exports calculateBoxData for testing
func ExportCalculateBoxData(klines []Kline, currentPrice float64) *BoxData {
return calculateBoxData(klines, currentPrice)
}
// GetBoxData fetches 1h klines and calculates box data for a symbol
func GetBoxData(symbol string) (*BoxData, error) {
symbol = Normalize(symbol)
// Fetch 500 1h klines
var klines []Kline
var err error
if IsXyzDexAsset(symbol) {
klines, err = getKlinesFromHyperliquid(symbol, "1h", LongBoxPeriod)
} else {
klines, err = getKlinesFromCoinAnk(symbol, "1h", "binance", LongBoxPeriod)
}
if err != nil {
return nil, fmt.Errorf("failed to get 1h klines: %w", err)
}
if len(klines) == 0 {
return nil, fmt.Errorf("no kline data available")
}
currentPrice := klines[len(klines)-1].Close
return calculateBoxData(klines, currentPrice), nil
}

235
market/data_indicators.go Normal file
View File

@@ -0,0 +1,235 @@
package market
import "math"
// calculateEMA calculates EMA
func calculateEMA(klines []Kline, period int) float64 {
if len(klines) < period {
return 0
}
// Calculate SMA as initial EMA
sum := 0.0
for i := 0; i < period; i++ {
sum += klines[i].Close
}
ema := sum / float64(period)
// Calculate EMA
multiplier := 2.0 / float64(period+1)
for i := period; i < len(klines); i++ {
ema = (klines[i].Close-ema)*multiplier + ema
}
return ema
}
// calculateMACD calculates MACD
func calculateMACD(klines []Kline) float64 {
if len(klines) < 26 {
return 0
}
// Calculate 12-period and 26-period EMA
ema12 := calculateEMA(klines, 12)
ema26 := calculateEMA(klines, 26)
// MACD = EMA12 - EMA26
return ema12 - ema26
}
// calculateRSI calculates RSI
func calculateRSI(klines []Kline, period int) float64 {
if len(klines) <= period {
return 0
}
gains := 0.0
losses := 0.0
// Calculate initial average gain/loss
for i := 1; i <= period; i++ {
change := klines[i].Close - klines[i-1].Close
if change > 0 {
gains += change
} else {
losses += -change
}
}
avgGain := gains / float64(period)
avgLoss := losses / float64(period)
// Use Wilder smoothing method to calculate subsequent RSI
for i := period + 1; i < len(klines); i++ {
change := klines[i].Close - klines[i-1].Close
if change > 0 {
avgGain = (avgGain*float64(period-1) + change) / float64(period)
avgLoss = (avgLoss * float64(period-1)) / float64(period)
} else {
avgGain = (avgGain * float64(period-1)) / float64(period)
avgLoss = (avgLoss*float64(period-1) + (-change)) / float64(period)
}
}
if avgLoss == 0 {
return 100
}
rs := avgGain / avgLoss
rsi := 100 - (100 / (1 + rs))
return rsi
}
// calculateATR calculates ATR
func calculateATR(klines []Kline, period int) float64 {
if len(klines) <= period {
return 0
}
trs := make([]float64, len(klines))
for i := 1; i < len(klines); i++ {
high := klines[i].High
low := klines[i].Low
prevClose := klines[i-1].Close
tr1 := high - low
tr2 := math.Abs(high - prevClose)
tr3 := math.Abs(low - prevClose)
trs[i] = math.Max(tr1, math.Max(tr2, tr3))
}
// Calculate initial ATR
sum := 0.0
for i := 1; i <= period; i++ {
sum += trs[i]
}
atr := sum / float64(period)
// Wilder smoothing
for i := period + 1; i < len(klines); i++ {
atr = (atr*float64(period-1) + trs[i]) / float64(period)
}
return atr
}
// calculateBOLL calculates Bollinger Bands (upper, middle, lower)
// period: typically 20, multiplier: typically 2
func calculateBOLL(klines []Kline, period int, multiplier float64) (upper, middle, lower float64) {
if len(klines) < period {
return 0, 0, 0
}
// Calculate SMA (middle band)
sum := 0.0
for i := len(klines) - period; i < len(klines); i++ {
sum += klines[i].Close
}
sma := sum / float64(period)
// Calculate standard deviation
variance := 0.0
for i := len(klines) - period; i < len(klines); i++ {
diff := klines[i].Close - sma
variance += diff * diff
}
stdDev := math.Sqrt(variance / float64(period))
// Calculate bands
middle = sma
upper = sma + multiplier*stdDev
lower = sma - multiplier*stdDev
return upper, middle, lower
}
// calculateDonchian calculates Donchian channel (highest high, lowest low) for given period
func calculateDonchian(klines []Kline, period int) (upper, lower float64) {
if len(klines) == 0 || period <= 0 {
return 0, 0
}
// Use all available klines if period > len(klines)
start := len(klines) - period
if start < 0 {
start = 0
}
upper = klines[start].High
lower = klines[start].Low
for i := start + 1; i < len(klines); i++ {
if klines[i].High > upper {
upper = klines[i].High
}
if klines[i].Low < lower {
lower = klines[i].Low
}
}
return upper, lower
}
// Box period constants (in 1h candles)
const (
ShortBoxPeriod = 72 // 3 days of 1h candles
MidBoxPeriod = 240 // 10 days of 1h candles
LongBoxPeriod = 500 // ~21 days of 1h candles
)
// calculateBoxData calculates multi-period box data from klines
func calculateBoxData(klines []Kline, currentPrice float64) *BoxData {
box := &BoxData{
CurrentPrice: currentPrice,
}
if len(klines) == 0 {
return box
}
box.ShortUpper, box.ShortLower = calculateDonchian(klines, ShortBoxPeriod)
box.MidUpper, box.MidLower = calculateDonchian(klines, MidBoxPeriod)
box.LongUpper, box.LongLower = calculateDonchian(klines, LongBoxPeriod)
return box
}
// ========== Exported indicator calculation functions (for testing) ==========
// ExportCalculateEMA exports calculateEMA for testing
func ExportCalculateEMA(klines []Kline, period int) float64 {
return calculateEMA(klines, period)
}
// ExportCalculateMACD exports calculateMACD for testing
func ExportCalculateMACD(klines []Kline) float64 {
return calculateMACD(klines)
}
// ExportCalculateRSI exports calculateRSI for testing
func ExportCalculateRSI(klines []Kline, period int) float64 {
return calculateRSI(klines, period)
}
// ExportCalculateATR exports calculateATR for testing
func ExportCalculateATR(klines []Kline, period int) float64 {
return calculateATR(klines, period)
}
// ExportCalculateBOLL exports calculateBOLL for testing
func ExportCalculateBOLL(klines []Kline, period int, multiplier float64) (upper, middle, lower float64) {
return calculateBOLL(klines, period, multiplier)
}
// ExportCalculateDonchian exports calculateDonchian for testing
func ExportCalculateDonchian(klines []Kline, period int) (float64, float64) {
return calculateDonchian(klines, period)
}
// ExportCalculateBoxData exports calculateBoxData for testing
func ExportCalculateBoxData(klines []Kline, currentPrice float64) *BoxData {
return calculateBoxData(klines, currentPrice)
}

425
market/data_klines.go Normal file
View File

@@ -0,0 +1,425 @@
package market
import (
"context"
"fmt"
"nofx/logger"
"nofx/provider/coinank/coinank_api"
"nofx/provider/coinank/coinank_enum"
"nofx/provider/hyperliquid"
"strconv"
"strings"
"time"
)
// Note: Kline data now uses free/open API (coinank_api.Kline) which doesn't require authentication
// getKlinesFromCoinAnk fetches kline data from CoinAnk API (replacement for WSMonitorCli)
func getKlinesFromCoinAnk(symbol, interval, exchange string, limit int) ([]Kline, error) {
// Map interval string to coinank enum
var coinankInterval coinank_enum.Interval
switch interval {
case "1m":
coinankInterval = coinank_enum.Minute1
case "3m":
coinankInterval = coinank_enum.Minute3
case "5m":
coinankInterval = coinank_enum.Minute5
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
default:
return nil, fmt.Errorf("unsupported interval: %s", interval)
}
// 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 "hyperliquid":
coinankExchange = coinank_enum.Hyperliquid
case "aster":
coinankExchange = coinank_enum.Aster
default:
// Default to Binance for unknown exchanges
coinankExchange = coinank_enum.Binance
}
// 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, symbol, coinankExchange, ts, coinank_enum.To, limit, coinankInterval)
if err != nil {
// If exchange-specific data fails, fallback to Binance
if coinankExchange != coinank_enum.Binance {
logger.Warnf("⚠️ CoinAnk %s data failed, falling back to Binance: %v", exchange, err)
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
klines := make([]Kline, len(coinankKlines))
for i, ck := range coinankKlines {
klines[i] = Kline{
OpenTime: ck.StartTime,
Open: ck.Open,
High: ck.High,
Low: ck.Low,
Close: ck.Close,
Volume: ck.Volume,
CloseTime: ck.EndTime,
}
}
return klines, nil
}
// getKlinesFromHyperliquid fetches kline data from Hyperliquid API for xyz dex assets
func getKlinesFromHyperliquid(symbol, interval string, limit int) ([]Kline, error) {
// Remove xyz: prefix if present for the API call
baseCoin := strings.TrimPrefix(symbol, "xyz:")
// Map interval to Hyperliquid format
hlInterval := hyperliquid.MapTimeframe(interval)
// Create Hyperliquid client
client := hyperliquid.NewClient()
// Fetch candles
ctx := context.Background()
candles, err := client.GetCandles(ctx, baseCoin, hlInterval, limit)
if err != nil {
return nil, fmt.Errorf("Hyperliquid API error: %w", err)
}
// Convert to market.Kline format
klines := make([]Kline, len(candles))
for i, c := range candles {
open, _ := strconv.ParseFloat(c.Open, 64)
high, _ := strconv.ParseFloat(c.High, 64)
low, _ := strconv.ParseFloat(c.Low, 64)
closePrice, _ := strconv.ParseFloat(c.Close, 64)
volume, _ := strconv.ParseFloat(c.Volume, 64)
klines[i] = Kline{
OpenTime: c.OpenTime,
Open: open,
High: high,
Low: low,
Close: closePrice,
Volume: volume,
CloseTime: c.CloseTime,
}
}
return klines, nil
}
// calculateTimeframeSeries calculates series data for a single timeframe
func calculateTimeframeSeries(klines []Kline, timeframe string, count int) *TimeframeSeriesData {
if count <= 0 {
count = 10 // default
}
data := &TimeframeSeriesData{
Timeframe: timeframe,
Klines: make([]KlineBar, 0, count),
MidPrices: make([]float64, 0, count),
EMA20Values: make([]float64, 0, count),
EMA50Values: make([]float64, 0, count),
MACDValues: make([]float64, 0, count),
RSI7Values: make([]float64, 0, count),
RSI14Values: make([]float64, 0, count),
Volume: make([]float64, 0, count),
BOLLUpper: make([]float64, 0, count),
BOLLMiddle: make([]float64, 0, count),
BOLLLower: make([]float64, 0, count),
}
// Get latest N data points based on count from config
start := len(klines) - count
if start < 0 {
start = 0
}
for i := start; i < len(klines); i++ {
// Store full OHLCV kline data
data.Klines = append(data.Klines, KlineBar{
Time: klines[i].OpenTime,
Open: klines[i].Open,
High: klines[i].High,
Low: klines[i].Low,
Close: klines[i].Close,
Volume: klines[i].Volume,
})
// Keep MidPrices and Volume for backward compatibility
data.MidPrices = append(data.MidPrices, klines[i].Close)
data.Volume = append(data.Volume, klines[i].Volume)
// Calculate EMA20 for each point
if i >= 19 {
ema20 := calculateEMA(klines[:i+1], 20)
data.EMA20Values = append(data.EMA20Values, ema20)
}
// Calculate EMA50 for each point
if i >= 49 {
ema50 := calculateEMA(klines[:i+1], 50)
data.EMA50Values = append(data.EMA50Values, ema50)
}
// Calculate MACD for each point
if i >= 25 {
macd := calculateMACD(klines[:i+1])
data.MACDValues = append(data.MACDValues, macd)
}
// Calculate RSI for each point
if i >= 7 {
rsi7 := calculateRSI(klines[:i+1], 7)
data.RSI7Values = append(data.RSI7Values, rsi7)
}
if i >= 14 {
rsi14 := calculateRSI(klines[:i+1], 14)
data.RSI14Values = append(data.RSI14Values, rsi14)
}
// Calculate Bollinger Bands (period 20, std dev multiplier 2)
if i >= 19 {
upper, middle, lower := calculateBOLL(klines[:i+1], 20, 2.0)
data.BOLLUpper = append(data.BOLLUpper, upper)
data.BOLLMiddle = append(data.BOLLMiddle, middle)
data.BOLLLower = append(data.BOLLLower, lower)
}
}
// Calculate ATR14
data.ATR14 = calculateATR(klines, 14)
return data
}
// calculatePriceChangeByBars calculates how many K-lines to look back for price change based on timeframe
func calculatePriceChangeByBars(klines []Kline, timeframe string, targetMinutes int) float64 {
if len(klines) < 2 {
return 0
}
// Parse timeframe to minutes
tfMinutes := parseTimeframeToMinutes(timeframe)
if tfMinutes <= 0 {
return 0
}
// Calculate how many K-lines to look back
barsBack := targetMinutes / tfMinutes
if barsBack < 1 {
barsBack = 1
}
currentPrice := klines[len(klines)-1].Close
idx := len(klines) - 1 - barsBack
if idx < 0 {
idx = 0
}
oldPrice := klines[idx].Close
if oldPrice > 0 {
return ((currentPrice - oldPrice) / oldPrice) * 100
}
return 0
}
// parseTimeframeToMinutes parses timeframe string to minutes
func parseTimeframeToMinutes(tf string) int {
switch tf {
case "1m":
return 1
case "3m":
return 3
case "5m":
return 5
case "15m":
return 15
case "30m":
return 30
case "1h":
return 60
case "2h":
return 120
case "4h":
return 240
case "6h":
return 360
case "8h":
return 480
case "12h":
return 720
case "1d":
return 1440
case "3d":
return 4320
case "1w":
return 10080
default:
return 0
}
}
// calculateIntradaySeries calculates intraday series data
func calculateIntradaySeries(klines []Kline) *IntradayData {
data := &IntradayData{
MidPrices: make([]float64, 0, 10),
EMA20Values: make([]float64, 0, 10),
MACDValues: make([]float64, 0, 10),
RSI7Values: make([]float64, 0, 10),
RSI14Values: make([]float64, 0, 10),
Volume: make([]float64, 0, 10),
}
// Get latest 10 data points
start := len(klines) - 10
if start < 0 {
start = 0
}
for i := start; i < len(klines); i++ {
data.MidPrices = append(data.MidPrices, klines[i].Close)
data.Volume = append(data.Volume, klines[i].Volume)
// Calculate EMA20 for each point
if i >= 19 {
ema20 := calculateEMA(klines[:i+1], 20)
data.EMA20Values = append(data.EMA20Values, ema20)
}
// Calculate MACD for each point
if i >= 25 {
macd := calculateMACD(klines[:i+1])
data.MACDValues = append(data.MACDValues, macd)
}
// Calculate RSI for each point
if i >= 7 {
rsi7 := calculateRSI(klines[:i+1], 7)
data.RSI7Values = append(data.RSI7Values, rsi7)
}
if i >= 14 {
rsi14 := calculateRSI(klines[:i+1], 14)
data.RSI14Values = append(data.RSI14Values, rsi14)
}
}
// Calculate 3m ATR14
data.ATR14 = calculateATR(klines, 14)
return data
}
// calculateLongerTermData calculates longer-term data
func calculateLongerTermData(klines []Kline) *LongerTermData {
data := &LongerTermData{
MACDValues: make([]float64, 0, 10),
RSI14Values: make([]float64, 0, 10),
}
// Calculate EMA
data.EMA20 = calculateEMA(klines, 20)
data.EMA50 = calculateEMA(klines, 50)
// Calculate ATR
data.ATR3 = calculateATR(klines, 3)
data.ATR14 = calculateATR(klines, 14)
// Calculate volume
if len(klines) > 0 {
data.CurrentVolume = klines[len(klines)-1].Volume
// Calculate average volume
sum := 0.0
for _, k := range klines {
sum += k.Volume
}
data.AverageVolume = sum / float64(len(klines))
}
// Calculate MACD and RSI series
start := len(klines) - 10
if start < 0 {
start = 0
}
for i := start; i < len(klines); i++ {
if i >= 25 {
macd := calculateMACD(klines[:i+1])
data.MACDValues = append(data.MACDValues, macd)
}
if i >= 14 {
rsi14 := calculateRSI(klines[:i+1], 14)
data.RSI14Values = append(data.RSI14Values, rsi14)
}
}
return data
}
// GetBoxData fetches 1h klines and calculates box data for a symbol
func GetBoxData(symbol string) (*BoxData, error) {
symbol = Normalize(symbol)
// Fetch 500 1h klines
var klines []Kline
var err error
if IsXyzDexAsset(symbol) {
klines, err = getKlinesFromHyperliquid(symbol, "1h", LongBoxPeriod)
} else {
klines, err = getKlinesFromCoinAnk(symbol, "1h", "binance", LongBoxPeriod)
}
if err != nil {
return nil, fmt.Errorf("failed to get 1h klines: %w", err)
}
if len(klines) == 0 {
return nil, fmt.Errorf("no kline data available")
}
currentPrice := klines[len(klines)-1].Close
return calculateBoxData(klines, currentPrice), nil
}

View File

@@ -1,35 +0,0 @@
package alpaca
import (
"context"
"fmt"
"testing"
)
func TestGetBars(t *testing.T) {
client := NewClient()
resp, err := client.GetBars(context.TODO(), "AAPL", "1Day", 5)
if err != nil {
t.Fatal(err)
}
t.Log("=== AAPL 日线数据 (Alpaca IEX feed) ===")
for i, bar := range resp {
t.Logf("\n[%d] 时间: %s", i, bar.Timestamp.Format("2006-01-02 15:04:05"))
t.Logf(" Open: %.2f", bar.Open)
t.Logf(" High: %.2f", bar.High)
t.Logf(" Low: %.2f", bar.Low)
t.Logf(" Close: %.2f", bar.Close)
t.Logf(" Volume: %d (股数)", bar.Volume)
t.Logf(" TradeCount: %d (成交笔数)", bar.TradeCount)
t.Logf(" VWAP: %.2f (成交量加权平均价)", bar.VWAP)
// 计算成交额
quoteVolume := float64(bar.Volume) * bar.Close
t.Logf(" 成交额: %.2f USD (Volume × Close)", quoteVolume)
}
fmt.Printf("\n⚠ 注意IEX feed 只包含 IEX 交易所的数据,不是完整市场数据\n")
fmt.Printf("完整市场数据需要使用 SIP feed付费\n")
}

View File

@@ -1,34 +0,0 @@
package coinank
import (
"context"
"encoding/json"
"nofx/provider/coinank/coinank_enum"
"testing"
)
func TestListCoin(t *testing.T) {
client := NewCoinankClient(coinank_enum.MainUrl, TestApikey)
resp, err := client.ListCoin(context.TODO(), "SPOT")
if err != nil {
t.Error(err)
}
res, err := json.Marshal(resp)
if err != nil {
t.Error(err)
}
t.Logf("%s", res)
}
func TestListSymbols(t *testing.T) {
client := NewCoinankClient(coinank_enum.MainUrl, TestApikey)
resp, err := client.ListSymbols(context.TODO(), "Binance", "SWAP")
if err != nil {
t.Error(err)
}
res, err := json.Marshal(resp)
if err != nil {
t.Error(err)
}
t.Logf("%s", res)
}

View File

@@ -1,3 +0,0 @@
package coinank
var TestApikey = "" //need fill the apikey before test

View File

@@ -1,101 +0,0 @@
package coinank
import (
"context"
"encoding/json"
"nofx/provider/coinank/coinank_enum"
"testing"
)
func TestVisualScreener(t *testing.T) {
client := NewCoinankClient(coinank_enum.MainUrl, TestApikey)
resp, err := client.VisualScreener(context.TODO(), coinank_enum.Minute15)
if err != nil {
t.Error(err)
}
res, err := json.Marshal(resp)
if err != nil {
t.Error(err)
}
t.Logf("%s", res)
}
func TestOiRank(t *testing.T) {
client := NewCoinankClient(coinank_enum.MainUrl, TestApikey)
resp, err := client.OiRank(context.TODO(), coinank_enum.OpenInterest, coinank_enum.Desc, 1, 10)
if err != nil {
t.Error(err)
}
if resp[0].BaseCoin != "BTC" {
t.Error("oi first not BTC")
}
res, err := json.Marshal(resp)
if err != nil {
t.Error(err)
}
t.Logf("%s", res)
}
func TestLongShortRank(t *testing.T) {
client := NewCoinankClient(coinank_enum.MainUrl, TestApikey)
resp, err := client.LongShortRank(context.TODO(), coinank_enum.LongShortRatio, coinank_enum.Desc, 1, 10)
if err != nil {
t.Error(err)
}
if resp[0].BaseCoin == "" {
t.Error("baseCoin is empty")
}
res, err := json.Marshal(resp)
if err != nil {
t.Error(err)
}
t.Logf("%s", res)
}
func TestLiquidationRank(t *testing.T) {
client := NewCoinankClient(coinank_enum.MainUrl, TestApikey)
resp, err := client.LiquidationRank(context.TODO(), coinank_enum.LiquidationH1, coinank_enum.Desc, 1, 10)
if err != nil {
t.Error(err)
}
if resp[0].BaseCoin == "" {
t.Error("baseCoin is empty")
}
res, err := json.Marshal(resp)
if err != nil {
t.Error(err)
}
t.Logf("%s", res)
}
func TestPriceRank(t *testing.T) {
client := NewCoinankClient(coinank_enum.MainUrl, TestApikey)
resp, err := client.PriceRank(context.TODO(), coinank_enum.Price, coinank_enum.Desc, 1, 10)
if err != nil {
t.Error(err)
}
if resp[0].BaseCoin == "" {
t.Error("baseCoin is empty")
}
res, err := json.Marshal(resp)
if err != nil {
t.Error(err)
}
t.Logf("%s", res)
}
func TestVolumeRank(t *testing.T) {
client := NewCoinankClient(coinank_enum.MainUrl, TestApikey)
resp, err := client.VolumeRank(context.TODO(), coinank_enum.Turnover24h, coinank_enum.Desc, 1, 10)
if err != nil {
t.Error(err)
}
if resp[0].BaseCoin == "" {
t.Error("baseCoin is empty")
}
res, err := json.Marshal(resp)
if err != nil {
t.Error(err)
}
t.Logf("%s", res)
}

View File

@@ -1,34 +0,0 @@
package coinank
import (
"context"
"encoding/json"
"nofx/provider/coinank/coinank_enum"
"testing"
)
func TestGetLastPrice(t *testing.T) {
client := NewCoinankClient(coinank_enum.MainUrl, TestApikey)
resp, err := client.GetLastPrice(context.TODO(), "BTCUSDT", "Binance", "SWAP")
if err != nil {
t.Error(err)
}
res, err := json.Marshal(resp)
if err != nil {
t.Error(err)
}
t.Logf("%s", res)
}
func TestGetCoinMarketCap(t *testing.T) {
client := NewCoinankClient(coinank_enum.MainUrl, TestApikey)
resp, err := client.GetCoinMarketCap(context.TODO(), "BTC")
if err != nil {
t.Error(err)
}
res, err := json.Marshal(resp)
if err != nil {
t.Error(err)
}
t.Logf("%s", res)
}

View File

@@ -1,56 +0,0 @@
package coinank
import (
"context"
"encoding/json"
"fmt"
"nofx/provider/coinank/coinank_enum"
"testing"
"time"
)
func TestKline(t *testing.T) {
client := NewCoinankClient(coinank_enum.MainUrl, TestApikey)
resp, err := client.Kline(context.TODO(), "BTCUSDT", coinank_enum.Binance, 0, time.Now().UnixMilli(), 10, coinank_enum.Hour1)
if err != nil {
t.Error(err)
}
res, err := json.Marshal(resp)
if err != nil {
t.Error(err)
}
t.Logf("%s", res)
}
func TestKlineDaily(t *testing.T) {
client := NewCoinankClient(coinank_enum.MainUrl, TestApikey)
resp, err := client.Kline(context.TODO(), "BTCUSDT", coinank_enum.Binance, 0, time.Now().UnixMilli(), 5, coinank_enum.Day1)
if err != nil {
t.Fatal(err)
}
t.Log("=== BTCUSDT 日线 K线数据 ===")
for i, k := range resp {
startTime := time.UnixMilli(k.StartTime).Format("2006-01-02 15:04:05")
t.Logf("\n[%d] 时间: %s", i, startTime)
t.Logf(" Open: %.2f", k.Open)
t.Logf(" High: %.2f", k.High)
t.Logf(" Low: %.2f", k.Low)
t.Logf(" Close: %.2f", k.Close)
t.Logf(" Volume: %.2f (k[6])", k.Volume)
t.Logf(" Quantity: %.2f (k[7])", k.Quantity)
t.Logf(" Count: %.0f (k[8])", k.Count)
// 计算验证
if k.Close > 0 {
calcQuote := k.Volume * k.Close
t.Logf(" --- 验证 ---")
t.Logf(" Volume × Close = %.2f", calcQuote)
t.Logf(" Quantity / Close = %.2f", k.Quantity/k.Close)
}
}
// 打印原始 JSON
res, _ := json.MarshalIndent(resp, "", " ")
fmt.Printf("\n原始 JSON:\n%s\n", res)
}

View File

@@ -1,89 +0,0 @@
package coinank
import (
"context"
"encoding/json"
"nofx/provider/coinank/coinank_enum"
"testing"
"time"
)
func TestLiquidationExchangeStatistics(t *testing.T) {
client := NewCoinankClient(coinank_enum.MainUrl, TestApikey)
resp, err := client.LiquidationExchangeStatistics(context.TODO(), "BTC")
if err != nil {
t.Fatal(err)
}
if resp.Total <= 0 {
t.Errorf("total amount is negative")
}
res, err := json.Marshal(resp)
if err != nil {
t.Error(err)
}
t.Logf("%s", res)
}
func TestLiquidationCoinAggHistory(t *testing.T) {
client := NewCoinankClient(coinank_enum.MainUrl, TestApikey)
resp, err := client.LiquidationCoinAggHistory(context.TODO(), "BTC", coinank_enum.Hour1, time.Now().UnixMilli(), 10)
if err != nil {
t.Fatal(err)
}
if resp[0].All.LongTurnover <= 0 {
t.Errorf("longTurnover is negative")
}
res, err := json.Marshal(resp)
if err != nil {
t.Error(err)
}
t.Logf("%s", res)
}
func TestLiquidationHistory(t *testing.T) {
client := NewCoinankClient(coinank_enum.MainUrl, TestApikey)
resp, err := client.LiquidationHistory(context.TODO(), coinank_enum.Binance, "BTCUSDT", coinank_enum.Hour1, time.Now().UnixMilli(), 10)
if err != nil {
t.Fatal(err)
}
if resp[0].LongTurnover <= 0 {
t.Errorf("longTurnover is negative")
}
res, err := json.Marshal(resp)
if err != nil {
t.Error(err)
}
t.Logf("%s", res)
}
func TestLiquidationOrders(t *testing.T) {
client := NewCoinankClient(coinank_enum.MainUrl, TestApikey)
resp, err := client.LiquidationOrders(context.TODO(), "BTC", coinank_enum.Binance, "long", 1000, time.Now().UnixMilli())
if err != nil {
t.Fatal(err)
}
res, err := json.Marshal(resp)
if resp[0].Price <= 0 {
t.Errorf("price is negative")
}
if err != nil {
t.Error(err)
}
t.Logf("%s", res)
}
func TestLiquidationOrdersNoArgs(t *testing.T) {
client := NewCoinankClient(coinank_enum.MainUrl, TestApikey)
resp, err := client.LiquidationOrders(context.TODO(), "", "", "", 0, 0)
if err != nil {
t.Fatal(err)
}
res, err := json.Marshal(resp)
if resp[0].Price <= 0 {
t.Errorf("price is negative")
}
if err != nil {
t.Error(err)
}
t.Logf("%s", res)
}

View File

@@ -1,25 +0,0 @@
package coinank
import (
"context"
"encoding/json"
"nofx/provider/coinank/coinank_enum"
"testing"
"time"
)
func TestNetPositions(t *testing.T) {
client := NewCoinankClient(coinank_enum.MainUrl, TestApikey)
resp, err := client.NetPositions(context.TODO(), coinank_enum.Binance, "BTCUSDT", coinank_enum.Hour1, time.Now().UnixMilli(), 10)
if err != nil {
t.Fatal(err)
}
if resp[0].Begin <= 0 {
t.Errorf("begin timestamp error")
}
res, err := json.Marshal(resp)
if err != nil {
t.Error(err)
}
t.Logf("%s", res)
}

View File

@@ -1,106 +0,0 @@
package coinank
import (
"context"
"encoding/json"
"nofx/provider/coinank/coinank_enum"
"testing"
"time"
)
func TestOpenInterestAll(t *testing.T) {
client := NewCoinankClient(coinank_enum.MainUrl, TestApikey)
resp, err := client.OpenInterestAll(context.TODO(), "BTC")
if err != nil {
t.Error(err)
}
if resp[0].ExchangeName != "ALL" {
t.Error("exchange name is empty")
}
res, err := json.Marshal(resp)
if err != nil {
t.Error(err)
}
t.Logf("%s", res)
}
func TestOpenInterestChartV2(t *testing.T) {
client := NewCoinankClient(coinank_enum.MainUrl, TestApikey)
resp, err := client.OpenInterestChartV2(context.TODO(), "BTC", coinank_enum.Binance, coinank_enum.Hour1, 10)
if err != nil {
t.Error(err)
}
res, err := json.Marshal(resp)
if err != nil {
t.Error(err)
}
t.Logf("%s", res)
}
func TestOpenInterestSymbolChart(t *testing.T) {
client := NewCoinankClient(coinank_enum.MainUrl, TestApikey)
resp, err := client.OpenInterestSymbolChart(context.TODO(), coinank_enum.Binance, "BTCUSDT", coinank_enum.Hour1, time.Now().UnixMilli(), 10)
if err != nil {
t.Error(err)
}
if resp[0].BaseCoin != "BTC" {
t.Error("baseCoin is error")
}
res, err := json.Marshal(resp)
if err != nil {
t.Error(err)
}
t.Logf("%s", res)
}
func TestOpenInterestKline(t *testing.T) {
client := NewCoinankClient(coinank_enum.MainUrl, TestApikey)
resp, err := client.OpenInterestKline(context.TODO(), coinank_enum.Binance, "BTCUSDT", coinank_enum.Hour1, time.Now().UnixMilli(), 10)
if err != nil {
t.Error(err)
}
res, err := json.Marshal(resp)
if err != nil {
t.Error(err)
}
t.Logf("%s", res)
}
func TestOpenInterestAggKline(t *testing.T) {
client := NewCoinankClient(coinank_enum.MainUrl, TestApikey)
resp, err := client.OpenInterestAggKline(context.TODO(), "BTC", coinank_enum.Hour1, time.Now().UnixMilli(), 10)
if err != nil {
t.Error(err)
}
res, err := json.Marshal(resp)
if err != nil {
t.Error(err)
}
t.Logf("%s", res)
}
func TestTickersTopOIByEx(t *testing.T) {
client := NewCoinankClient(coinank_enum.MainUrl, TestApikey)
resp, err := client.TickersTopOIByEx(context.TODO(), "BTC")
if err != nil {
t.Error(err)
}
res, err := json.Marshal(resp)
if err != nil {
t.Error(err)
}
t.Logf("%s", res)
}
func TestInstrumentsOiVsMc(t *testing.T) {
client := NewCoinankClient(coinank_enum.MainUrl, TestApikey)
resp, err := client.InstrumentsOiVsMc(context.TODO(), "BTC", coinank_enum.Hour1, time.Now().UnixMilli(), 10)
if err != nil {
t.Error(err)
}
res, err := json.Marshal(resp)
if err != nil {
t.Error(err)
}
t.Logf("%s", res)
}

View File

@@ -1,7 +0,0 @@
[project]
name = "nofx"
version = "0.1.0"
description = "Add your description here"
readme = "README.md"
requires-python = ">=3.12"
dependencies = []

Binary file not shown.

Before

Width:  |  Height:  |  Size: 444 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 314 KiB

View File

@@ -1,302 +0,0 @@
# Mars AI交易系统 - 加密密钥生成脚本
本目录包含用于Mars AI交易系统加密环境设置的脚本工具。
## 🔐 加密架构
Mars AI交易系统使用双重加密架构来保护敏感数据
1. **RSA-OAEP + AES-GCM 混合加密** - 用于前端到后端的安全通信
2. **AES-256-GCM 数据库加密** - 用于敏感数据的存储加密
### 加密流程
```
前端 → RSA-OAEP加密AES密钥 + AES-GCM加密数据 → 后端 → 存储时AES-256-GCM加密
```
## 📝 脚本说明
### 1. `setup_encryption.sh` - 一键环境设置 ⭐推荐⭐
**功能**: 自动生成所有必要的密钥并配置环境
```bash
./scripts/setup_encryption.sh
```
**生成内容**:
- RSA-2048 密钥对 (`secrets/rsa_key`, `secrets/rsa_key.pub`)
- AES-256 数据加密密钥 (保存到 `.env`)
- 自动权限设置和验证
**适用场景**:
- 首次部署
- 开发环境快速设置
- 生产环境初始化
### 2. `generate_rsa_keys.sh` - RSA密钥生成
**功能**: 专门生成RSA密钥对
```bash
./scripts/generate_rsa_keys.sh
```
**生成内容**:
- `secrets/rsa_key` (私钥, 权限 600)
- `secrets/rsa_key.pub` (公钥, 权限 644)
**技术规格**:
- 算法: RSA-OAEP
- 密钥长度: 2048 bits
- 格式: PEM
### 3. `generate_data_key.sh` - 数据加密密钥生成
**功能**: 生成数据库加密密钥
```bash
./scripts/generate_data_key.sh
```
**生成内容**:
- 32字节(256位)随机密钥
- Base64编码格式
- 可选保存到 `.env` 文件
**技术规格**:
- 算法: AES-256-GCM
- 编码: Base64
- 环境变量: `DATA_ENCRYPTION_KEY`
## 🚀 快速开始
### 方案1: 一键设置 (推荐)
```bash
# 克隆项目后,直接运行一键设置
cd mars-ai-trading
./scripts/setup_encryption.sh
# 按提示确认即可完成所有设置
```
### 方案2: 分步设置
```bash
# 1. 生成RSA密钥对
./scripts/generate_rsa_keys.sh
# 2. 生成数据加密密钥
./scripts/generate_data_key.sh
# 3. 启动系统
source .env && ./mars
```
## 📁 文件结构
生成完成后的目录结构:
```
mars-ai-trading/
├── secrets/
│ ├── rsa_key # RSA私钥 (600权限)
│ └── rsa_key.pub # RSA公钥 (644权限)
├── .env # 环境变量 (600权限)
│ └── DATA_ENCRYPTION_KEY=xxx
└── scripts/
├── setup_encryption.sh # 一键设置脚本
├── generate_rsa_keys.sh # RSA密钥生成
└── generate_data_key.sh # 数据密钥生成
```
## 🔒 安全要求
### 文件权限
| 文件 | 权限 | 说明 |
|------|------|------|
| `secrets/rsa_key` | 600 | 仅所有者可读写 |
| `secrets/rsa_key.pub` | 644 | 所有人可读 |
| `.env` | 600 | 仅所有者可读写 |
### 环境变量
```bash
# 必需的环境变量
DATA_ENCRYPTION_KEY=<32字节Base64编码的AES密钥>
```
## 🐳 Docker部署
### 使用环境文件
```bash
# 生成密钥
./scripts/setup_encryption.sh
# Docker运行
docker run --env-file .env -v $(pwd)/secrets:/app/secrets mars-ai-trading
```
### 使用环境变量
```bash
export DATA_ENCRYPTION_KEY="<生成的密钥>"
docker run -e DATA_ENCRYPTION_KEY mars-ai-trading
```
## ☸️ Kubernetes部署
### 创建Secret
```bash
# 从现有.env文件创建
kubectl create secret generic mars-crypto-key --from-env-file=.env
# 或直接指定密钥
kubectl create secret generic mars-crypto-key \
--from-literal=DATA_ENCRYPTION_KEY="<生成的密钥>"
```
### 挂载RSA密钥
```yaml
apiVersion: v1
kind: Secret
metadata:
name: mars-rsa-keys
type: Opaque
data:
rsa_key: <base64编码的私钥>
rsa_key.pub: <base64编码的公钥>
---
apiVersion: apps/v1
kind: Deployment
metadata:
name: mars-ai-trading
spec:
template:
spec:
containers:
- name: mars
envFrom:
- secretRef:
name: mars-crypto-key
volumeMounts:
- name: rsa-keys
mountPath: /app/secrets
volumes:
- name: rsa-keys
secret:
secretName: mars-rsa-keys
```
## 🔄 密钥轮换
### 数据加密密钥轮换
```bash
# 1. 生成新密钥
./scripts/generate_data_key.sh
# 2. 备份旧数据库
cp data.db data.db.backup
# 3. 重启服务 (会自动处理密钥迁移)
source .env && ./mars
```
### RSA密钥轮换
```bash
# 1. 生成新密钥对
./scripts/generate_rsa_keys.sh
# 2. 重启服务
./mars
```
## 🛠️ 故障排除
### 常见问题
1. **权限错误**
```bash
chmod 600 secrets/rsa_key .env
chmod 644 secrets/rsa_key.pub
```
2. **OpenSSL未安装**
```bash
# macOS
brew install openssl
# Ubuntu/Debian
sudo apt-get install openssl
# CentOS/RHEL
sudo yum install openssl
```
3. **环境变量未加载**
```bash
source .env
echo $DATA_ENCRYPTION_KEY
```
4. **密钥验证失败**
```bash
# 验证RSA私钥
openssl rsa -in secrets/rsa_key -check -noout
# 验证公钥
openssl rsa -in secrets/rsa_key.pub -pubin -text -noout
```
### 日志检查
启动时检查以下日志:
- `🔐 初始化加密服务...`
- `✅ 加密服务初始化成功`
## 📊 性能考虑
- **RSA加密**: 仅用于小量密钥交换,性能影响极小
- **AES加密**: 数据库字段级加密对读写性能影响约5-10%
- **内存使用**: 加密服务约占用2-5MB内存
## 🔐 算法详细说明
### RSA-OAEP-2048
- **用途**: 前端到后端的混合加密中的密钥交换
- **密钥长度**: 2048 bits
- **填充**: OAEP with SHA-256
- **安全级别**: 相当于112位对称加密
### AES-256-GCM
- **用途**: 数据库敏感字段存储加密
- **密钥长度**: 256 bits
- **模式**: GCM (Galois/Counter Mode)
- **认证**: 内置消息认证
- **安全级别**: 256位安全强度
## 📋 合规性
此加密实现满足以下标准:
- **FIPS 140-2**: AES-256 和 RSA-2048
- **Common Criteria**: EAL4+
- **NIST推荐**: SP 800-57 密钥管理
- **行业标准**: 符合金融业数据保护要求
---
## 📞 技术支持
如有问题,请检查:
1. OpenSSL版本 >= 1.1.1
2. 文件权限设置正确
3. 环境变量加载成功
4. 系统日志中的加密初始化信息

View File

@@ -1,98 +0,0 @@
package main
import (
"flag"
"fmt"
"log"
"nofx/store"
"os"
"path/filepath"
)
func main() {
var dbPath string
var dryRun bool
flag.StringVar(&dbPath, "db", "./data/data.db", "数据库文件路径")
flag.BoolVar(&dryRun, "dry-run", false, "只检查不删除(预览模式)")
flag.Parse()
// 确保数据库文件存在
absPath, err := filepath.Abs(dbPath)
if err != nil {
log.Fatalf("❌ 无效的数据库路径: %v", err)
}
if _, err := os.Stat(absPath); os.IsNotExist(err) {
log.Fatalf("❌ 数据库文件不存在: %s", absPath)
}
fmt.Printf("📂 数据库路径: %s\n", absPath)
// 打开数据库
s, err := store.New(absPath)
if err != nil {
log.Fatalf("❌ 无法打开数据库: %v", err)
}
defer s.Close()
orderStore := s.Order()
// 1. 检查重复订单数量
fmt.Println("\n🔍 检查重复数据...")
dupOrders, err := orderStore.GetDuplicateOrdersCount()
if err != nil {
log.Fatalf("❌ 检查重复订单失败: %v", err)
}
fmt.Printf(" 📋 重复订单: %d 条\n", dupOrders)
dupFills, err := orderStore.GetDuplicateFillsCount()
if err != nil {
log.Fatalf("❌ 检查重复成交失败: %v", err)
}
fmt.Printf(" 📊 重复成交: %d 条\n", dupFills)
if dupOrders == 0 && dupFills == 0 {
fmt.Println("\n✅ 数据库没有重复记录,无需清理")
return
}
if dryRun {
fmt.Println("\n⚠ 预览模式(--dry-run不会删除数据")
fmt.Println(" 运行 'go run scripts/cleanup_duplicates.go' 来执行实际清理")
return
}
// 2. 清理重复订单
if dupOrders > 0 {
fmt.Println("\n🧹 清理重复订单...")
deleted, err := orderStore.CleanupDuplicateOrders()
if err != nil {
log.Fatalf("❌ 清理失败: %v", err)
}
fmt.Printf(" ✅ 删除了 %d 条重复订单\n", deleted)
}
// 3. 清理重复成交
if dupFills > 0 {
fmt.Println("\n🧹 清理重复成交...")
deleted, err := orderStore.CleanupDuplicateFills()
if err != nil {
log.Fatalf("❌ 清理失败: %v", err)
}
fmt.Printf(" ✅ 删除了 %d 条重复成交\n", deleted)
}
// 4. 验证清理结果
fmt.Println("\n🔍 验证清理结果...")
dupOrdersAfter, _ := orderStore.GetDuplicateOrdersCount()
dupFillsAfter, _ := orderStore.GetDuplicateFillsCount()
fmt.Printf(" 📋 剩余重复订单: %d 条\n", dupOrdersAfter)
fmt.Printf(" 📊 剩余重复成交: %d 条\n", dupFillsAfter)
if dupOrdersAfter == 0 && dupFillsAfter == 0 {
fmt.Println("\n✅ 清理完成!数据库已去重")
} else {
fmt.Println("\n⚠ 仍有重复数据,可能需要手动检查")
}
}

View File

@@ -1,111 +0,0 @@
package main
import (
"bufio"
"flag"
"fmt"
"log"
"nofx/store"
"os"
"path/filepath"
"strings"
)
func main() {
var dbPath string
var force bool
flag.StringVar(&dbPath, "db", "./data/data.db", "数据库文件路径")
flag.BoolVar(&force, "force", false, "跳过确认直接删除")
flag.Parse()
// 确保数据库文件存在
absPath, err := filepath.Abs(dbPath)
if err != nil {
log.Fatalf("❌ 无效的数据库路径: %v", err)
}
if _, err := os.Stat(absPath); os.IsNotExist(err) {
log.Fatalf("❌ 数据库文件不存在: %s", absPath)
}
fmt.Printf("📂 数据库路径: %s\n", absPath)
// 打开数据库
s, err := store.New(absPath)
if err != nil {
log.Fatalf("❌ 无法打开数据库: %v", err)
}
defer s.Close()
db := s.DB()
// 统计当前数据
var orderCount, fillCount int
db.QueryRow(`SELECT COUNT(*) FROM trader_orders`).Scan(&orderCount)
db.QueryRow(`SELECT COUNT(*) FROM trader_fills`).Scan(&fillCount)
fmt.Printf("\n📊 当前数据统计:\n")
fmt.Printf(" trader_orders: %d 条记录\n", orderCount)
fmt.Printf(" trader_fills: %d 条记录\n", fillCount)
if orderCount == 0 && fillCount == 0 {
fmt.Println("\n✅ 表已经是空的,无需清空")
return
}
// 确认删除
if !force {
fmt.Println("\n⚠ 警告: 此操作将删除所有订单和成交记录,无法恢复!")
fmt.Print("\n确认删除请输入 'yes' 继续: ")
reader := bufio.NewReader(os.Stdin)
input, _ := reader.ReadString('\n')
input = strings.TrimSpace(input)
if input != "yes" {
fmt.Println("\n❌ 操作已取消")
return
}
}
fmt.Println("\n🗑 开始清空表...")
// 清空 trader_fills 表(先删除,因为有外键约束)
result, err := db.Exec(`DELETE FROM trader_fills`)
if err != nil {
log.Fatalf("❌ 清空 trader_fills 失败: %v", err)
}
fillsDeleted, _ := result.RowsAffected()
fmt.Printf(" ✅ 删除了 %d 条成交记录\n", fillsDeleted)
// 清空 trader_orders 表
result, err = db.Exec(`DELETE FROM trader_orders`)
if err != nil {
log.Fatalf("❌ 清空 trader_orders 失败: %v", err)
}
ordersDeleted, _ := result.RowsAffected()
fmt.Printf(" ✅ 删除了 %d 条订单记录\n", ordersDeleted)
// 重置自增ID可选让ID从1重新开始
_, err = db.Exec(`DELETE FROM sqlite_sequence WHERE name IN ('trader_orders', 'trader_fills')`)
if err == nil {
fmt.Println(" ✅ 重置了自增ID计数器")
}
// 验证清空结果
db.QueryRow(`SELECT COUNT(*) FROM trader_orders`).Scan(&orderCount)
db.QueryRow(`SELECT COUNT(*) FROM trader_fills`).Scan(&fillCount)
fmt.Printf("\n🔍 验证结果:\n")
fmt.Printf(" trader_orders: %d 条记录\n", orderCount)
fmt.Printf(" trader_fills: %d 条记录\n", fillCount)
if orderCount == 0 && fillCount == 0 {
fmt.Println("\n✅ 表已成功清空!")
fmt.Println("\n💡 现在可以重新运行 trader 进行测试")
fmt.Println(" 新的订单将从 ID=1 开始记录")
} else {
fmt.Println("\n⚠ 清空未完成,请检查数据库")
}
}

View File

@@ -1,189 +0,0 @@
package main
import (
"flag"
"fmt"
"log"
"nofx/store"
"os"
"path/filepath"
"time"
)
func main() {
var dbPath string
var traderID string
flag.StringVar(&dbPath, "db", "./data/data.db", "数据库文件路径")
flag.StringVar(&traderID, "trader", "", "Trader ID可选")
flag.Parse()
// 确保数据库文件存在
absPath, err := filepath.Abs(dbPath)
if err != nil {
log.Fatalf("❌ 无效的数据库路径: %v", err)
}
if _, err := os.Stat(absPath); os.IsNotExist(err) {
log.Fatalf("❌ 数据库文件不存在: %s", absPath)
}
fmt.Printf("📂 数据库路径: %s\n", absPath)
// 打开数据库
s, err := store.New(absPath)
if err != nil {
log.Fatalf("❌ 无法打开数据库: %v", err)
}
defer s.Close()
orderStore := s.Order()
// 如果指定了 traderID获取该 trader 的订单
if traderID == "" {
fmt.Println("\n⚠ 未指定 trader_id使用: --trader <trader_id>")
fmt.Println(" 获取所有 trader 的统计信息...\n")
}
// 获取订单列表
orders, err := orderStore.GetTraderOrders(traderID, 100)
if err != nil {
log.Fatalf("❌ 获取订单失败: %v", err)
}
fmt.Printf("\n📋 找到 %d 条订单记录\n\n", len(orders))
if len(orders) == 0 {
fmt.Println("⚠️ 没有订单数据!可能的原因:")
fmt.Println(" 1. Trader 还没有执行过交易")
fmt.Println(" 2. CreateOrder 插入失败(重复键冲突)")
fmt.Println(" 3. 指定的 trader_id 不存在")
return
}
// 统计数据
var (
totalOrders = len(orders)
filledOrders = 0
withFilledAt = 0
withAvgFillPrice = 0
withOrderAction = 0
missingFilledAt = 0
missingAvgPrice = 0
missingOrderAction = 0
)
fmt.Println("━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━")
fmt.Printf("%-15s %-10s %-10s %-15s %-10s %-15s\n", "订单ID", "状态", "动作", "平均成交价", "成交时间", "问题")
fmt.Println("━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━")
for _, order := range orders {
issues := []string{}
if order.Status == "FILLED" {
filledOrders++
// 检查 filled_at
if order.FilledAt > 0 {
withFilledAt++
} else {
missingFilledAt++
issues = append(issues, "❌ 缺少成交时间")
}
// 检查 avg_fill_price
if order.AvgFillPrice > 0 {
withAvgFillPrice++
} else {
missingAvgPrice++
issues = append(issues, "❌ 成交价为0")
}
}
// 检查 order_action
if order.OrderAction != "" {
withOrderAction++
} else {
missingOrderAction++
issues = append(issues, "⚠️ 缺少订单动作")
}
issueStr := "✅ 正常"
if len(issues) > 0 {
issueStr = ""
for i, issue := range issues {
if i > 0 {
issueStr += ", "
}
issueStr += issue
}
}
filledAtStr := "N/A"
if order.FilledAt > 0 {
filledAtStr = time.UnixMilli(order.FilledAt).Format("01-02 15:04")
}
fmt.Printf("%-15s %-10s %-10s %-15.2f %-10s %s\n",
order.ExchangeOrderID[:min(15, len(order.ExchangeOrderID))],
order.Status,
order.OrderAction,
order.AvgFillPrice,
filledAtStr,
issueStr,
)
}
fmt.Println("━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━")
// 统计摘要
fmt.Printf("\n📊 统计摘要:\n")
fmt.Printf(" 总订单数: %d\n", totalOrders)
fmt.Printf(" 已成交订单: %d\n", filledOrders)
fmt.Printf(" 有成交时间: %d / %d (%.1f%%)\n", withFilledAt, filledOrders, float64(withFilledAt)/float64(max(filledOrders, 1))*100)
fmt.Printf(" 有成交价格: %d / %d (%.1f%%)\n", withAvgFillPrice, filledOrders, float64(withAvgFillPrice)/float64(max(filledOrders, 1))*100)
fmt.Printf(" 有订单动作: %d / %d (%.1f%%)\n", withOrderAction, totalOrders, float64(withOrderAction)/float64(max(totalOrders, 1))*100)
fmt.Printf("\n⚠ 问题订单:\n")
if missingFilledAt > 0 {
fmt.Printf(" ❌ %d 条订单缺少成交时间 (filled_at)\n", missingFilledAt)
}
if missingAvgPrice > 0 {
fmt.Printf(" ❌ %d 条订单成交价为 0 (avg_fill_price)\n", missingAvgPrice)
}
if missingOrderAction > 0 {
fmt.Printf(" ⚠️ %d 条订单缺少订单动作 (order_action)\n", missingOrderAction)
}
if missingFilledAt > 0 || missingAvgPrice > 0 {
fmt.Println("\n💡 这些订单无法在图表上显示,因为:")
fmt.Println(" - 缺少成交时间 → 前端无法定位到K线时间轴")
fmt.Println(" - 成交价为 0 → 前端会过滤掉 (line 164: if (!orderPrice || orderPrice === 0) return)")
fmt.Println("\n🔧 可能的原因:")
fmt.Println(" 1. UpdateOrderStatus 没有被正确调用")
fmt.Println(" 2. GetOrderStatus 返回的数据缺少 avgPrice 字段")
fmt.Println(" 3. Lighter 交易所的订单状态查询有问题")
}
if missingFilledAt == 0 && missingAvgPrice == 0 && missingOrderAction == 0 {
fmt.Println("\n✅ 所有订单数据完整!")
fmt.Println(" 如果图表仍然没有显示 B/S 标记,检查:")
fmt.Println(" 1. 前端是否正确调用了 /api/orders API")
fmt.Println(" 2. 浏览器控制台是否有错误")
fmt.Println(" 3. 订单时间是否在图表的时间范围内")
}
}
func min(a, b int) int {
if a < b {
return a
}
return b
}
func max(a, b int) int {
if a > b {
return a
}
return b
}

View File

@@ -1,141 +0,0 @@
package main
import (
"flag"
"fmt"
"log"
"nofx/store"
"os"
"path/filepath"
"time"
)
func main() {
var dbPath string
var dryRun bool
flag.StringVar(&dbPath, "db", "./data/data.db", "数据库文件路径")
flag.BoolVar(&dryRun, "dry-run", false, "只检查不修复(预览模式)")
flag.Parse()
// 确保数据库文件存在
absPath, err := filepath.Abs(dbPath)
if err != nil {
log.Fatalf("❌ 无效的数据库路径: %v", err)
}
if _, err := os.Stat(absPath); os.IsNotExist(err) {
log.Fatalf("❌ 数据库文件不存在: %s", absPath)
}
fmt.Printf("📂 数据库路径: %s\n", absPath)
// 打开数据库
s, err := store.New(absPath)
if err != nil {
log.Fatalf("❌ 无法打开数据库: %v", err)
}
defer s.Close()
db := s.DB()
fmt.Println("\n🔍 检查需要修复的订单...")
// 1. 修复缺少 filled_at 的 FILLED 订单(使用 updated_at 或 created_at
var needFixFilledAt int
err = db.QueryRow(`
SELECT COUNT(*)
FROM trader_orders
WHERE status = 'FILLED' AND (filled_at IS NULL OR filled_at = '')
`).Scan(&needFixFilledAt)
if err != nil {
log.Fatalf("❌ 查询失败: %v", err)
}
fmt.Printf(" 📋 缺少成交时间的订单: %d 条\n", needFixFilledAt)
// 2. 修复 avg_fill_price = 0 的 FILLED 订单(使用 price 字段)
var needFixAvgPrice int
err = db.QueryRow(`
SELECT COUNT(*)
FROM trader_orders
WHERE status = 'FILLED' AND (avg_fill_price = 0 OR avg_fill_price IS NULL) AND price > 0
`).Scan(&needFixAvgPrice)
if err != nil {
log.Fatalf("❌ 查询失败: %v", err)
}
fmt.Printf(" 💰 成交价为0的订单: %d 条\n", needFixAvgPrice)
if needFixFilledAt == 0 && needFixAvgPrice == 0 {
fmt.Println("\n✅ 没有需要修复的订单!")
return
}
if dryRun {
fmt.Println("\n⚠ 预览模式(--dry-run不会修改数据")
fmt.Println(" 运行 'go run scripts/fix_order_data.go' 来执行实际修复")
return
}
fmt.Println("\n🔧 开始修复...")
// 修复缺少 filled_at 的订单
if needFixFilledAt > 0 {
result, err := db.Exec(`
UPDATE trader_orders
SET filled_at = COALESCE(updated_at, created_at)
WHERE status = 'FILLED' AND (filled_at IS NULL OR filled_at = '')
`)
if err != nil {
log.Fatalf("❌ 修复成交时间失败: %v", err)
}
rows, _ := result.RowsAffected()
fmt.Printf(" ✅ 修复了 %d 条订单的成交时间\n", rows)
}
// 修复 avg_fill_price = 0 的订单
if needFixAvgPrice > 0 {
result, err := db.Exec(`
UPDATE trader_orders
SET avg_fill_price = price,
filled_quantity = quantity
WHERE status = 'FILLED'
AND (avg_fill_price = 0 OR avg_fill_price IS NULL)
AND price > 0
`)
if err != nil {
log.Fatalf("❌ 修复成交价失败: %v", err)
}
rows, _ := result.RowsAffected()
fmt.Printf(" ✅ 修复了 %d 条订单的成交价\n", rows)
}
// 验证修复结果
fmt.Println("\n🔍 验证修复结果...")
time.Sleep(100 * time.Millisecond)
var stillMissingFilledAt int
db.QueryRow(`
SELECT COUNT(*)
FROM trader_orders
WHERE status = 'FILLED' AND (filled_at IS NULL OR filled_at = '')
`).Scan(&stillMissingFilledAt)
var stillMissingAvgPrice int
db.QueryRow(`
SELECT COUNT(*)
FROM trader_orders
WHERE status = 'FILLED' AND (avg_fill_price = 0 OR avg_fill_price IS NULL)
`).Scan(&stillMissingAvgPrice)
fmt.Printf(" 📋 仍缺少成交时间: %d 条\n", stillMissingFilledAt)
fmt.Printf(" 💰 仍缺少成交价: %d 条\n", stillMissingAvgPrice)
if stillMissingFilledAt == 0 && stillMissingAvgPrice == 0 {
fmt.Println("\n✅ 修复完成!所有订单数据已完整")
fmt.Println("\n💡 现在刷新图表页面,应该能看到 B/S 标记了")
} else {
fmt.Println("\n⚠ 仍有部分订单无法修复,可能需要手动检查")
}
}

View File

@@ -1,200 +0,0 @@
package main
import (
"database/sql"
"fmt"
"log"
"os"
"nofx/crypto"
_ "modernc.org/sqlite"
)
func main() {
log.Println("🔄 Starting database migration to encrypted format...")
// 1. Check database file
dbPath := "data/data.db"
if len(os.Args) > 1 {
dbPath = os.Args[1]
}
if _, err := os.Stat(dbPath); os.IsNotExist(err) {
log.Fatalf("❌ Database file does not exist: %s", dbPath)
}
// 2. Backup database
backupPath := fmt.Sprintf("%s.pre_encryption_backup", dbPath)
log.Printf("📦 Backing up database to: %s", backupPath)
input, err := os.ReadFile(dbPath)
if err != nil {
log.Fatalf("❌ Failed to read database: %v", err)
}
if err := os.WriteFile(backupPath, input, 0600); err != nil {
log.Fatalf("❌ Backup failed: %v", err)
}
// 3. Open database
db, err := sql.Open("sqlite", dbPath)
if err != nil {
log.Fatalf("❌ Failed to open database: %v", err)
}
defer db.Close()
// 4. Initialize CryptoService (load key from environment variables)
cs, err := crypto.NewCryptoService()
if err != nil {
log.Fatalf("❌ Failed to initialize encryption service: %v", err)
}
// 5. Migrate exchange configurations
if err := migrateExchanges(db, cs); err != nil {
log.Fatalf("❌ Failed to migrate exchange configurations: %v", err)
}
// 6. Migrate AI model configurations
if err := migrateAIModels(db, cs); err != nil {
log.Fatalf("❌ Failed to migrate AI model configurations: %v", err)
}
log.Println("✅ Data migration completed!")
log.Printf("📝 Original data backed up at: %s", backupPath)
log.Println("⚠️ Please verify system functionality before manually deleting backup file")
}
// migrateExchanges migrates exchange configurations
func migrateExchanges(db *sql.DB, cs *crypto.CryptoService) error {
log.Println("🔄 Migrating exchange configurations...")
// Query all unencrypted records (encrypted data starts with ENC:v1:)
rows, err := db.Query(`
SELECT user_id, id, api_key, secret_key,
COALESCE(hyperliquid_private_key, ''),
COALESCE(aster_private_key, '')
FROM exchanges
WHERE (api_key != '' AND api_key NOT LIKE 'ENC:v1:%')
OR (secret_key != '' AND secret_key NOT LIKE 'ENC:v1:%')
`)
if err != nil {
return err
}
defer rows.Close()
tx, err := db.Begin()
if err != nil {
return err
}
defer tx.Rollback()
count := 0
for rows.Next() {
var userID, exchangeID, apiKey, secretKey, hlPrivateKey, asterPrivateKey string
if err := rows.Scan(&userID, &exchangeID, &apiKey, &secretKey, &hlPrivateKey, &asterPrivateKey); err != nil {
return err
}
// Encrypt each field
encAPIKey, err := cs.EncryptForStorage(apiKey)
if err != nil {
return fmt.Errorf("failed to encrypt API Key: %w", err)
}
encSecretKey, err := cs.EncryptForStorage(secretKey)
if err != nil {
return fmt.Errorf("failed to encrypt Secret Key: %w", err)
}
encHLPrivateKey := ""
if hlPrivateKey != "" {
encHLPrivateKey, err = cs.EncryptForStorage(hlPrivateKey)
if err != nil {
return fmt.Errorf("failed to encrypt Hyperliquid Private Key: %w", err)
}
}
encAsterPrivateKey := ""
if asterPrivateKey != "" {
encAsterPrivateKey, err = cs.EncryptForStorage(asterPrivateKey)
if err != nil {
return fmt.Errorf("failed to encrypt Aster Private Key: %w", err)
}
}
// Update database
_, err = tx.Exec(`
UPDATE exchanges
SET api_key = ?, secret_key = ?,
hyperliquid_private_key = ?, aster_private_key = ?
WHERE user_id = ? AND id = ?
`, encAPIKey, encSecretKey, encHLPrivateKey, encAsterPrivateKey, userID, exchangeID)
if err != nil {
return fmt.Errorf("failed to update database: %w", err)
}
log.Printf(" ✓ Encrypted: [%s] %s", userID, exchangeID)
count++
}
if err := tx.Commit(); err != nil {
return err
}
log.Printf("✅ Migrated %d exchange configurations", count)
return nil
}
// migrateAIModels migrates AI model configurations
func migrateAIModels(db *sql.DB, cs *crypto.CryptoService) error {
log.Println("🔄 Migrating AI model configurations...")
rows, err := db.Query(`
SELECT user_id, id, api_key
FROM ai_models
WHERE api_key != '' AND api_key NOT LIKE 'ENC:v1:%'
`)
if err != nil {
return err
}
defer rows.Close()
tx, err := db.Begin()
if err != nil {
return err
}
defer tx.Rollback()
count := 0
for rows.Next() {
var userID, modelID, apiKey string
if err := rows.Scan(&userID, &modelID, &apiKey); err != nil {
return err
}
encAPIKey, err := cs.EncryptForStorage(apiKey)
if err != nil {
return fmt.Errorf("failed to encrypt API Key: %w", err)
}
_, err = tx.Exec(`
UPDATE ai_models SET api_key = ? WHERE user_id = ? AND id = ?
`, encAPIKey, userID, modelID)
if err != nil {
return fmt.Errorf("failed to update database: %w", err)
}
log.Printf(" ✓ Encrypted: [%s] %s", userID, modelID)
count++
}
if err := tx.Commit(); err != nil {
return err
}
log.Printf("✅ Migrated %d AI model configurations", count)
return nil
}

View File

@@ -1,413 +0,0 @@
#!/bin/bash
# 🔍 PR Health Check Script
# Analyzes your PR and gives suggestions on how to meet the new standards
# This script only analyzes and suggests - it won't modify your code
set -e
# Colors
RED='\033[0;31m'
GREEN='\033[0;32m'
YELLOW='\033[1;33m'
BLUE='\033[0;34m'
CYAN='\033[0;36m'
NC='\033[0m' # No Color
# Counters
ISSUES_FOUND=0
WARNINGS_FOUND=0
PASSED_CHECKS=0
# Helper functions
log_section() {
echo ""
echo -e "${CYAN}═══════════════════════════════════════════${NC}"
echo -e "${CYAN} $1${NC}"
echo -e "${CYAN}═══════════════════════════════════════════${NC}"
}
log_check() {
echo -e "${BLUE}🔍 Checking: $1${NC}"
}
log_pass() {
echo -e "${GREEN}✅ PASS: $1${NC}"
((PASSED_CHECKS++))
}
log_warning() {
echo -e "${YELLOW}⚠️ WARNING: $1${NC}"
((WARNINGS_FOUND++))
}
log_error() {
echo -e "${RED}❌ ISSUE: $1${NC}"
((ISSUES_FOUND++))
}
log_suggestion() {
echo -e "${CYAN}💡 Suggestion: $1${NC}"
}
log_command() {
echo -e "${GREEN} Run: ${NC}$1"
}
# Welcome
echo ""
echo "╔═══════════════════════════════════════════╗"
echo "║ NOFX PR Health Check ║"
echo "║ Analyze your PR and get suggestions ║"
echo "╚═══════════════════════════════════════════╝"
echo ""
# Check if we're in a git repo
if ! git rev-parse --is-inside-work-tree > /dev/null 2>&1; then
log_error "Not a git repository"
exit 1
fi
# Get current branch
CURRENT_BRANCH=$(git rev-parse --abbrev-ref HEAD)
echo -e "${BLUE}Current branch: ${GREEN}$CURRENT_BRANCH${NC}"
if [ "$CURRENT_BRANCH" = "main" ] || [ "$CURRENT_BRANCH" = "dev" ]; then
log_error "You're on the $CURRENT_BRANCH branch. Please switch to your PR branch."
exit 1
fi
# Check if upstream exists
if ! git remote | grep -q "^upstream$"; then
log_warning "Upstream remote not found"
log_suggestion "Add upstream remote:"
log_command "git remote add upstream https://github.com/NoFxAiOS/nofx.git"
echo ""
fi
# ═══════════════════════════════════════════
# 1. GIT BRANCH CHECKS
# ═══════════════════════════════════════════
log_section "1. Git Branch Status"
# Check if branch is up to date with upstream
log_check "Is branch based on latest upstream/dev?"
if git remote | grep -q "^upstream$"; then
git fetch upstream -q 2>/dev/null || true
if git merge-base --is-ancestor upstream/dev HEAD 2>/dev/null; then
log_pass "Branch is up to date with upstream/dev"
else
log_error "Branch is not based on latest upstream/dev"
log_suggestion "Rebase your branch:"
log_command "git fetch upstream && git rebase upstream/dev"
echo ""
fi
else
log_warning "Cannot check - upstream remote not configured"
fi
# Check for merge conflicts
log_check "Any merge conflicts?"
if git diff --check > /dev/null 2>&1; then
log_pass "No merge conflicts detected"
else
log_error "Merge conflicts detected"
log_suggestion "Resolve conflicts and commit"
fi
# ═══════════════════════════════════════════
# 2. COMMIT MESSAGE CHECKS
# ═══════════════════════════════════════════
log_section "2. Commit Messages"
# Get commits in this branch (not in upstream/dev)
if git remote | grep -q "^upstream$"; then
COMMITS=$(git log upstream/dev..HEAD --oneline 2>/dev/null || git log --oneline -10)
else
COMMITS=$(git log --oneline -10)
fi
COMMIT_COUNT=$(echo "$COMMITS" | wc -l | tr -d ' ')
echo -e "${BLUE}Found $COMMIT_COUNT commit(s) in your branch${NC}"
echo ""
# Check each commit message
echo "$COMMITS" | while read -r line; do
COMMIT_MSG=$(echo "$line" | cut -d' ' -f2-)
# Check if follows conventional commits
if echo "$COMMIT_MSG" | grep -qE "^(feat|fix|docs|style|refactor|perf|test|chore|ci|security)(\(.+\))?: .+"; then
log_pass "\"$COMMIT_MSG\""
else
log_warning "\"$COMMIT_MSG\""
log_suggestion "Should follow format: type(scope): description"
echo " Examples:"
echo " - feat(exchange): add OKX integration"
echo " - fix(trader): resolve position bug"
echo ""
fi
done
# Suggest PR title based on commits
echo ""
log_check "Suggested PR title:"
SUGGESTED_TITLE=$(git log --pretty=%s upstream/dev..HEAD 2>/dev/null | head -1 || git log --pretty=%s -1)
echo -e "${GREEN} \"$SUGGESTED_TITLE\"${NC}"
echo ""
# ═══════════════════════════════════════════
# 3. CODE QUALITY - BACKEND (Go)
# ═══════════════════════════════════════════
if find . -name "*.go" -not -path "./vendor/*" -not -path "./.git/*" | grep -q .; then
log_section "3. Backend Code Quality (Go)"
# Check if Go is installed
if ! command -v go &> /dev/null; then
log_warning "Go not installed - skipping backend checks"
log_suggestion "Install Go: https://go.dev/doc/install"
else
# Check go fmt
log_check "Go code formatting (go fmt)"
UNFORMATTED=$(gofmt -l . 2>/dev/null | grep -v vendor || true)
if [ -z "$UNFORMATTED" ]; then
log_pass "All Go files are formatted"
else
log_error "Some files need formatting:"
echo "$UNFORMATTED" | head -5 | while read -r file; do
echo " - $file"
done
log_suggestion "Format your code:"
log_command "go fmt ./..."
echo ""
fi
# Check go vet
log_check "Go static analysis (go vet)"
if go vet ./... > /tmp/vet-output.txt 2>&1; then
log_pass "No issues found by go vet"
else
log_error "Go vet found issues:"
head -10 /tmp/vet-output.txt | sed 's/^/ /'
log_suggestion "Fix the issues above"
echo ""
fi
# Check tests exist
log_check "Do tests exist?"
TEST_FILES=$(find . -name "*_test.go" -not -path "./vendor/*" | wc -l)
if [ "$TEST_FILES" -gt 0 ]; then
log_pass "Found $TEST_FILES test file(s)"
else
log_warning "No test files found"
log_suggestion "Add tests for your changes"
echo ""
fi
# Run tests
log_check "Running Go tests..."
if go test ./... -v > /tmp/test-output.txt 2>&1; then
log_pass "All tests passed"
else
log_error "Some tests failed:"
grep -E "FAIL|ERROR" /tmp/test-output.txt | head -10 | sed 's/^/ /' || true
log_suggestion "Fix failing tests:"
log_command "go test ./... -v"
echo ""
fi
fi
fi
# ═══════════════════════════════════════════
# 4. CODE QUALITY - FRONTEND
# ═══════════════════════════════════════════
if [ -d "web" ]; then
log_section "4. Frontend Code Quality"
# Check if npm is installed
if ! command -v npm &> /dev/null; then
log_warning "npm not installed - skipping frontend checks"
log_suggestion "Install Node.js: https://nodejs.org/"
else
cd web
# Check if node_modules exists
if [ ! -d "node_modules" ]; then
log_warning "Dependencies not installed"
log_suggestion "Install dependencies:"
log_command "cd web && npm install"
cd ..
else
# Check linting
log_check "Frontend linting"
if npm run lint > /tmp/lint-output.txt 2>&1; then
log_pass "No linting issues"
else
log_error "Linting issues found:"
tail -20 /tmp/lint-output.txt | sed 's/^/ /' || true
log_suggestion "Fix linting issues:"
log_command "cd web && npm run lint -- --fix"
echo ""
fi
# Check type errors
log_check "TypeScript type checking"
if npm run type-check > /tmp/typecheck-output.txt 2>&1; then
log_pass "No type errors"
else
log_error "Type errors found:"
tail -20 /tmp/typecheck-output.txt | sed 's/^/ /' || true
log_suggestion "Fix type errors in your code"
echo ""
fi
# Check build
log_check "Frontend build"
if npm run build > /tmp/build-output.txt 2>&1; then
log_pass "Build successful"
else
log_error "Build failed:"
tail -20 /tmp/build-output.txt | sed 's/^/ /' || true
log_suggestion "Fix build errors"
echo ""
fi
fi
cd ..
fi
fi
# ═══════════════════════════════════════════
# 5. PR SIZE CHECK
# ═══════════════════════════════════════════
log_section "5. PR Size"
if git remote | grep -q "^upstream$"; then
ADDED=$(git diff --numstat upstream/dev...HEAD | awk '{sum+=$1} END {print sum+0}')
DELETED=$(git diff --numstat upstream/dev...HEAD | awk '{sum+=$2} END {print sum+0}')
TOTAL=$((ADDED + DELETED))
FILES_CHANGED=$(git diff --name-only upstream/dev...HEAD | wc -l)
echo -e "${BLUE}Lines changed: ${GREEN}+$ADDED ${RED}-$DELETED ${NC}(total: $TOTAL)"
echo -e "${BLUE}Files changed: ${GREEN}$FILES_CHANGED${NC}"
echo ""
if [ "$TOTAL" -lt 100 ]; then
log_pass "Small PR (<100 lines) - ideal for quick review"
elif [ "$TOTAL" -lt 500 ]; then
log_pass "Medium PR (100-500 lines) - reasonable size"
elif [ "$TOTAL" -lt 1000 ]; then
log_warning "Large PR (500-1000 lines) - consider splitting"
log_suggestion "Breaking into smaller PRs makes review faster"
else
log_error "Very large PR (>1000 lines) - strongly consider splitting"
log_suggestion "Split into multiple smaller PRs, each with a focused change"
echo ""
fi
fi
# ═══════════════════════════════════════════
# 6. DOCUMENTATION CHECK
# ═══════════════════════════════════════════
log_section "6. Documentation"
# Check if README or docs were updated
log_check "Documentation updates"
if git remote | grep -q "^upstream$"; then
DOC_CHANGES=$(git diff --name-only upstream/dev...HEAD | grep -E "\.(md|txt)$" || true)
if [ -n "$DOC_CHANGES" ]; then
log_pass "Documentation files updated"
echo "$DOC_CHANGES" | sed 's/^/ - /'
else
# Check if this is a feature/fix that might need docs
COMMIT_TYPES=$(git log --pretty=%s upstream/dev..HEAD | grep -oE "^(feat|fix)" || true)
if [ -n "$COMMIT_TYPES" ]; then
log_warning "No documentation updates found"
log_suggestion "Consider updating docs if your changes affect usage"
echo ""
else
log_pass "No documentation update needed"
fi
fi
fi
# ═══════════════════════════════════════════
# 7. ROADMAP ALIGNMENT
# ═══════════════════════════════════════════
log_section "7. Roadmap Alignment"
log_check "Does your PR align with the roadmap?"
echo ""
echo "Current priorities (Phase 1):"
echo " ✅ Security enhancements"
echo " ✅ AI model integrations"
echo " ✅ Exchange integrations (OKX, Bybit, Lighter, EdgeX)"
echo " ✅ UI/UX improvements"
echo " ✅ Performance optimizations"
echo " ✅ Bug fixes"
echo ""
log_suggestion "Check roadmap: https://github.com/NoFxAiOS/nofx/blob/dev/docs/roadmap/README.md"
echo ""
# ═══════════════════════════════════════════
# FINAL REPORT
# ═══════════════════════════════════════════
log_section "Summary Report"
echo ""
echo -e "${GREEN}✅ Passed checks: $PASSED_CHECKS${NC}"
echo -e "${YELLOW}⚠️ Warnings: $WARNINGS_FOUND${NC}"
echo -e "${RED}❌ Issues found: $ISSUES_FOUND${NC}"
echo ""
# Overall assessment
if [ "$ISSUES_FOUND" -eq 0 ] && [ "$WARNINGS_FOUND" -eq 0 ]; then
echo "╔═══════════════════════════════════════════╗"
echo "║ 🎉 Excellent! Your PR looks great! ║"
echo "║ Ready to submit or update your PR ║"
echo "╚═══════════════════════════════════════════╝"
elif [ "$ISSUES_FOUND" -eq 0 ]; then
echo "╔═══════════════════════════════════════════╗"
echo "║ 👍 Good! Minor warnings found ║"
echo "║ Consider addressing warnings ║"
echo "╚═══════════════════════════════════════════╝"
elif [ "$ISSUES_FOUND" -le 3 ]; then
echo "╔═══════════════════════════════════════════╗"
echo "║ ⚠️ Issues found - Please fix ║"
echo "║ See suggestions above ║"
echo "╚═══════════════════════════════════════════╝"
else
echo "╔═══════════════════════════════════════════╗"
echo "║ ❌ Multiple issues found ║"
echo "║ Please address issues before submitting ║"
echo "╚═══════════════════════════════════════════╝"
fi
echo ""
echo "📖 Next steps:"
echo ""
if [ "$ISSUES_FOUND" -gt 0 ] || [ "$WARNINGS_FOUND" -gt 0 ]; then
echo "1. Fix the issues and warnings listed above"
echo "2. Run this script again to verify: ./scripts/pr-check.sh"
echo "3. Commit your fixes"
echo "4. Push to your PR: git push origin $CURRENT_BRANCH"
else
echo "1. Push your changes: git push origin $CURRENT_BRANCH"
echo "2. Create or update your PR on GitHub"
echo "3. Wait for automated CI checks"
echo "4. Address reviewer feedback"
fi
echo ""
echo "📚 Resources:"
echo " - Contributing Guide: https://github.com/NoFxAiOS/nofx/blob/dev/CONTRIBUTING.md"
echo " - Migration Guide: https://github.com/NoFxAiOS/nofx/blob/dev/docs/community/MIGRATION_ANNOUNCEMENT.md"
echo ""
# Cleanup temp files
rm -f /tmp/vet-output.txt /tmp/test-output.txt /tmp/lint-output.txt /tmp/typecheck-output.txt /tmp/build-output.txt
echo "✨ Analysis complete! Good luck with your PR! 🚀"
echo ""

View File

@@ -1,335 +0,0 @@
#!/bin/bash
# 🔄 PR Migration Script for Contributors
# This script helps you migrate your PR to the new format
# Run this in your local fork to update your PR automatically
set -e
# Colors
RED='\033[0;31m'
GREEN='\033[0;32m'
YELLOW='\033[1;33m'
BLUE='\033[0;34m'
NC='\033[0m' # No Color
# Helper functions
log_info() {
echo -e "${BLUE} $1${NC}"
}
log_success() {
echo -e "${GREEN}$1${NC}"
}
log_warning() {
echo -e "${YELLOW}⚠️ $1${NC}"
}
log_error() {
echo -e "${RED}$1${NC}"
}
confirm() {
read -p "$(echo -e ${YELLOW}"$1 (y/N): "${NC})" -n 1 -r
echo
[[ $REPLY =~ ^[Yy]$ ]]
}
# Welcome message
echo ""
echo "╔═══════════════════════════════════════════╗"
echo "║ NOFX PR Migration Tool ║"
echo "║ Migrate your PR to the new format ║"
echo "╚═══════════════════════════════════════════╝"
echo ""
# Check if we're in a git repo
if ! git rev-parse --is-inside-work-tree > /dev/null 2>&1; then
log_error "Not a git repository. Please run this from your NOFX fork."
exit 1
fi
# Check current branch
CURRENT_BRANCH=$(git rev-parse --abbrev-ref HEAD)
log_info "Current branch: $CURRENT_BRANCH"
if [ "$CURRENT_BRANCH" = "main" ] || [ "$CURRENT_BRANCH" = "dev" ]; then
log_warning "You're on the $CURRENT_BRANCH branch."
log_info "This script should be run on your PR branch."
# List branches
log_info "Your branches:"
git branch
echo ""
read -p "Enter your PR branch name: " PR_BRANCH
if [ -z "$PR_BRANCH" ]; then
log_error "No branch specified. Exiting."
exit 1
fi
git checkout "$PR_BRANCH" || {
log_error "Failed to checkout branch $PR_BRANCH"
exit 1
}
CURRENT_BRANCH="$PR_BRANCH"
fi
log_success "Working on branch: $CURRENT_BRANCH"
echo ""
log_info "What this script will do:"
echo " 1. ✅ Verify you're rebased on latest upstream/dev"
echo " 2. ✅ Check and format Go code (go fmt)"
echo " 3. ✅ Run Go linting (go vet)"
echo " 4. ✅ Run Go tests"
echo " 5. ✅ Check frontend code (if modified)"
echo " 6. ✅ Give you feedback and suggestions"
echo ""
log_warning "Make sure you've already run: git fetch upstream && git rebase upstream/dev"
echo ""
if ! confirm "Continue with migration?"; then
log_info "Migration cancelled"
exit 0
fi
# Step 1: Verify upstream sync
echo ""
log_info "Step 1: Verifying upstream sync..."
# Check if upstream remote exists
if ! git remote | grep -q "^upstream$"; then
log_warning "Upstream remote not found. Adding it..."
git remote add upstream https://github.com/NoFxAiOS/nofx.git
git fetch upstream
log_success "Added upstream remote"
fi
# Check if we're up to date with upstream/dev
if git merge-base --is-ancestor upstream/dev HEAD; then
log_success "Your branch is up to date with upstream/dev"
else
log_warning "Your branch is not based on latest upstream/dev"
log_info "Please run first: git fetch upstream && git rebase upstream/dev"
if confirm "Try to rebase now?"; then
git fetch upstream
if git rebase upstream/dev; then
log_success "Successfully rebased on upstream/dev"
else
log_error "Rebase failed. Please resolve conflicts manually."
exit 1
fi
else
log_warning "Skipping rebase. Results may not be accurate."
fi
fi
# Step 2: Backend checks (if Go files exist)
if find . -name "*.go" -not -path "./vendor/*" | grep -q .; then
echo ""
log_info "Step 2: Running backend checks..."
# Check if Go is installed
if ! command -v go &> /dev/null; then
log_warning "Go not found. Skipping backend checks."
log_info "Install Go: https://go.dev/doc/install"
else
# Format Go code
log_info "Formatting Go code..."
if go fmt ./...; then
log_success "Go code formatted"
# Check if there are changes
if ! git diff --quiet; then
log_info "Formatting created changes. Committing..."
git add .
git commit -m "chore: format Go code with go fmt" || true
fi
else
log_warning "Go formatting had issues (non-critical)"
fi
# Run go vet
log_info "Running go vet..."
if go vet ./...; then
log_success "Go vet passed"
else
log_warning "Go vet found issues. Please review them."
if confirm "Continue anyway?"; then
log_info "Continuing..."
else
exit 1
fi
fi
# Run tests
log_info "Running Go tests..."
if go test ./...; then
log_success "All Go tests passed"
else
log_warning "Some tests failed. Please fix them before pushing."
if confirm "Continue anyway?"; then
log_info "Continuing..."
else
exit 1
fi
fi
fi
else
log_info "Step 2: No Go files found, skipping backend checks"
fi
# Step 3: Frontend checks (if web directory exists)
if [ -d "web" ]; then
echo ""
log_info "Step 3: Running frontend checks..."
# Check if npm is installed
if ! command -v npm &> /dev/null; then
log_warning "npm not found. Skipping frontend checks."
log_info "Install Node.js: https://nodejs.org/"
else
cd web
# Install dependencies if needed
if [ ! -d "node_modules" ]; then
log_info "Installing dependencies..."
npm install
fi
# Run linter
log_info "Running linter..."
if npm run lint; then
log_success "Linting passed"
else
log_warning "Linting found issues"
log_info "Attempting to auto-fix..."
npm run lint -- --fix || true
# Commit fixes if any
if ! git diff --quiet; then
git add .
git commit -m "chore: fix linting issues" || true
fi
fi
# Type check
log_info "Running type check..."
if npm run type-check; then
log_success "Type checking passed"
else
log_warning "Type checking found issues. Please fix them."
fi
# Build
log_info "Testing build..."
if npm run build; then
log_success "Build successful"
else
log_error "Build failed. Please fix build errors."
cd ..
exit 1
fi
cd ..
fi
else
log_info "Step 3: No frontend changes, skipping frontend checks"
fi
# Step 4: Check PR title format
echo ""
log_info "Step 4: Checking PR title format..."
# Get the commit messages to suggest a title
COMMITS=$(git log upstream/dev..HEAD --oneline)
COMMIT_COUNT=$(echo "$COMMITS" | wc -l | tr -d ' ')
log_info "Found $COMMIT_COUNT commit(s) in your PR"
if [ "$COMMIT_COUNT" -eq 1 ]; then
SUGGESTED_TITLE=$(git log -1 --pretty=%s)
else
SUGGESTED_TITLE=$(git log --pretty=%s upstream/dev..HEAD | head -1)
fi
log_info "Current/suggested title: $SUGGESTED_TITLE"
# Check if it follows conventional commits
if echo "$SUGGESTED_TITLE" | grep -qE "^(feat|fix|docs|style|refactor|perf|test|chore|ci|security)(\(.+\))?: .+"; then
log_success "Title follows Conventional Commits format"
else
log_warning "Title doesn't follow Conventional Commits format"
echo ""
echo "Conventional Commits format:"
echo " <type>(<scope>): <description>"
echo ""
echo "Types: feat, fix, docs, style, refactor, perf, test, chore, ci, security"
echo ""
echo "Examples:"
echo " feat(exchange): add OKX integration"
echo " fix(trader): resolve position tracking bug"
echo " docs(readme): update installation guide"
echo ""
read -p "Enter new title (or press Enter to keep current): " NEW_TITLE
if [ -n "$NEW_TITLE" ]; then
log_info "You can update the PR title on GitHub after pushing"
log_info "Suggested title: $NEW_TITLE"
fi
fi
# Step 5: Push changes
echo ""
log_info "Step 5: Ready to push changes"
# Check if there are changes to push
if git diff upstream/dev..HEAD --quiet; then
log_info "No changes to push"
else
log_info "Changes ready to push to origin/$CURRENT_BRANCH"
if confirm "Push changes now?"; then
log_info "Pushing to origin/$CURRENT_BRANCH..."
if git push -f origin "$CURRENT_BRANCH"; then
log_success "Successfully pushed changes!"
else
log_error "Failed to push. You may need to push manually:"
echo " git push -f origin $CURRENT_BRANCH"
exit 1
fi
else
log_info "Skipped push. You can push manually later:"
echo " git push -f origin $CURRENT_BRANCH"
fi
fi
# Summary
echo ""
echo "╔═══════════════════════════════════════════╗"
echo "║ ✅ Migration Complete! ║"
echo "╚═══════════════════════════════════════════╝"
echo ""
log_success "Your PR has been migrated!"
echo ""
log_info "Next steps:"
echo " 1. Check your PR on GitHub"
echo " 2. Update PR title if needed (Conventional Commits format)"
echo " 3. Wait for CI checks to run"
echo " 4. Address any reviewer feedback"
echo ""
log_info "Need help? Ask in the PR comments or Telegram!"
log_info "Telegram: https://t.me/nofx_dev_community"
echo ""
log_success "Thank you for contributing to NOFX! 🚀"
echo ""

View File

@@ -1,65 +0,0 @@
#!/bin/bash
echo "=================================="
echo "NOFX 后端重启和测试脚本"
echo "=================================="
# 1. 停止旧进程
echo ""
echo "1⃣ 停止旧进程..."
pkill -f "bin/nofx" || echo " 没有运行中的进程"
sleep 2
# 2. 清理旧数据
echo ""
echo "2⃣ 清理测试数据..."
sqlite3 data/data.db "DELETE FROM trader_fills; DELETE FROM trader_orders;"
echo " ✅ trader_orders 和 trader_fills 表已清空"
# 3. 验证数据库已清空
ORDERS_COUNT=$(sqlite3 data/data.db "SELECT COUNT(*) FROM trader_orders")
FILLS_COUNT=$(sqlite3 data/data.db "SELECT COUNT(*) FROM trader_fills")
echo " 验证: trader_orders=$ORDERS_COUNT, trader_fills=$FILLS_COUNT"
# 4. 启动新进程
echo ""
echo "3⃣ 启动新编译的后端服务..."
if [ ! -f "bin/nofx" ]; then
echo " ❌ bin/nofx 不存在,请先运行 go build -o bin/nofx ."
exit 1
fi
nohup ./bin/nofx > data/nofx_$(date +%Y-%m-%d).log 2>&1 &
NOFX_PID=$!
echo " ✅ 后端已启动 (PID: $NOFX_PID)"
# 5. 等待服务启动
echo ""
echo "4⃣ 等待服务启动..."
sleep 3
# 6. 验证进程运行
if ps -p $NOFX_PID > /dev/null; then
echo " ✅ 后端进程运行正常 (PID: $NOFX_PID)"
else
echo " ❌ 后端进程启动失败,请检查日志"
tail -20 data/nofx_$(date +%Y-%m-%d).log
exit 1
fi
echo ""
echo "=================================="
echo "✅ 重启完成!"
echo "=================================="
echo ""
echo "📝 下一步操作:"
echo " 1. 访问前端页面"
echo " 2. 执行一次平仓操作手动或AI"
echo " 3. 等待 10 秒(让 pollLighterTradeHistory 完成)"
echo " 4. 检查数据库:"
echo " sqlite3 data/data.db \"SELECT id, status, avg_fill_price, filled_quantity FROM trader_orders\""
echo " 5. 刷新图表页面,应该能看到 B/S 标记"
echo ""
echo "📊 实时日志查看:"
echo " tail -f data/nofx_$(date +%Y-%m-%d).log | grep -E 'Order recorded|Found matching trade|Fill recorded'"
echo ""

View File

@@ -1,168 +0,0 @@
//go:build ignore
// Test script to verify Lighter API authentication
// Run: go run scripts/test_lighter_orders.go
package main
import (
"encoding/json"
"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() {
// Configuration - update these values
walletAddr := os.Getenv("LIGHTER_WALLET")
apiKeyPrivateKey := os.Getenv("LIGHTER_API_KEY")
if walletAddr == "" || apiKeyPrivateKey == "" {
fmt.Println("Usage: LIGHTER_WALLET=0x... LIGHTER_API_KEY=... go run scripts/test_lighter_orders.go")
fmt.Println("Environment variables required:")
fmt.Println(" LIGHTER_WALLET - Ethereum wallet address")
fmt.Println(" LIGHTER_API_KEY - API key private key (40 bytes hex)")
os.Exit(1)
}
fmt.Println("=== Lighter API Test ===")
fmt.Printf("Wallet: %s\n\n", walletAddr)
baseURL := "https://mainnet.zklighter.elliot.ai"
chainID := uint32(304)
client := &http.Client{Timeout: 30 * time.Second}
// Step 1: Get account info (no auth required)
fmt.Println("1. Getting account info...")
accountIndex, err := getAccountIndex(client, baseURL, walletAddr)
if err != nil {
fmt.Printf(" FAILED: %v\n", err)
os.Exit(1)
}
fmt.Printf(" OK: account_index = %d\n\n", accountIndex)
// Step 2: Create TxClient and generate auth token
fmt.Println("2. Creating TxClient and generating auth token...")
httpClient := lighterHTTP.NewClient(baseURL)
txClient, err := lighterClient.NewTxClient(httpClient, apiKeyPrivateKey, accountIndex, 0, chainID)
if err != nil {
fmt.Printf(" FAILED: %v\n", err)
os.Exit(1)
}
authToken, err := txClient.GetAuthToken(time.Now().Add(1 * time.Hour))
if err != nil {
fmt.Printf(" FAILED: %v\n", err)
os.Exit(1)
}
fmt.Printf(" OK: auth token generated\n\n")
// Step 3: Test GetActiveOrders with auth query parameter (NEW method)
fmt.Println("3. Testing GetActiveOrders with auth query parameter (FIXED)...")
encodedAuth := url.QueryEscape(authToken)
endpoint := fmt.Sprintf("%s/api/v1/accountActiveOrders?account_index=%d&market_id=0&auth=%s",
baseURL, accountIndex, encodedAuth)
resp, err := client.Get(endpoint)
if err != nil {
fmt.Printf(" FAILED: %v\n", err)
os.Exit(1)
}
defer resp.Body.Close()
body, _ := io.ReadAll(resp.Body)
var result map[string]interface{}
json.Unmarshal(body, &result)
if code, ok := result["code"].(float64); ok && code == 200 {
orders := result["orders"].([]interface{})
fmt.Printf(" OK: Retrieved %d orders\n", len(orders))
if len(orders) > 0 {
fmt.Println(" Sample orders:")
for i, o := range orders {
if i >= 3 {
fmt.Printf(" ... and %d more\n", len(orders)-3)
break
}
order := o.(map[string]interface{})
fmt.Printf(" - ID: %v, Price: %v, Side: %v\n",
order["order_id"], order["price"], order["is_ask"])
}
}
} else {
fmt.Printf(" FAILED: %s\n", string(body))
fmt.Println("\n Possible causes:")
fmt.Println(" - API key not registered on-chain")
fmt.Println(" - API key private key incorrect")
fmt.Println(" - Account index mismatch")
os.Exit(1)
}
// Step 4: Test GetActiveOrders with Authorization header (OLD method - for comparison)
fmt.Println("\n4. Testing GetActiveOrders with Authorization header (OLD method)...")
endpoint2 := fmt.Sprintf("%s/api/v1/accountActiveOrders?account_index=%d&market_id=0",
baseURL, accountIndex)
req, _ := http.NewRequest("GET", endpoint2, nil)
req.Header.Set("Authorization", authToken)
req.Header.Set("Content-Type", "application/json")
resp2, err := client.Do(req)
if err != nil {
fmt.Printf(" FAILED: %v\n", err)
} else {
defer resp2.Body.Close()
body2, _ := io.ReadAll(resp2.Body)
var result2 map[string]interface{}
json.Unmarshal(body2, &result2)
if code, ok := result2["code"].(float64); ok && code == 200 {
orders := result2["orders"].([]interface{})
fmt.Printf(" OK: Retrieved %d orders (both methods work!)\n", len(orders))
} else {
fmt.Printf(" FAILED: %s\n", string(body2))
fmt.Println(" ^ This is expected - Authorization header doesn't work consistently")
}
}
fmt.Println("\n=== TEST COMPLETE ===")
fmt.Println("If test 3 passed, the fix is working correctly.")
}
func getAccountIndex(client *http.Client, baseURL, walletAddr string) (int64, error) {
endpoint := fmt.Sprintf("%s/api/v1/account?by=l1_address&value=%s", baseURL, walletAddr)
resp, err := client.Get(endpoint)
if err != nil {
return 0, err
}
defer resp.Body.Close()
body, _ := io.ReadAll(resp.Body)
var result struct {
Code int `json:"code"`
Accounts []struct {
AccountIndex int64 `json:"account_index"`
} `json:"accounts"`
SubAccounts []struct {
AccountIndex int64 `json:"account_index"`
} `json:"sub_accounts"`
}
if err := json.Unmarshal(body, &result); err != nil {
return 0, fmt.Errorf("failed to parse: %w", err)
}
if len(result.Accounts) > 0 {
return result.Accounts[0].AccountIndex, nil
}
if len(result.SubAccounts) > 0 {
return result.SubAccounts[0].AccountIndex, nil
}
return 0, fmt.Errorf("no account found")
}

View File

@@ -60,19 +60,36 @@ func getPriceDecimalPlaces(price float64) int {
return len(s) - idx - 1 return len(s) - idx - 1
} }
// TraderStats trading statistics metrics // formatDuration formats a duration
type TraderStats struct { func formatDuration(d time.Duration) string {
TotalTrades int `json:"total_trades"` return formatDurationMs(d.Milliseconds())
WinTrades int `json:"win_trades"` }
LossTrades int `json:"loss_trades"`
WinRate float64 `json:"win_rate"` // formatDurationMs formats a duration in milliseconds
ProfitFactor float64 `json:"profit_factor"` func formatDurationMs(ms int64) string {
SharpeRatio float64 `json:"sharpe_ratio"` seconds := ms / 1000
TotalPnL float64 `json:"total_pnl"` minutes := seconds / 60
TotalFee float64 `json:"total_fee"` hours := minutes / 60
AvgWin float64 `json:"avg_win"` days := hours / 24
AvgLoss float64 `json:"avg_loss"`
MaxDrawdownPct float64 `json:"max_drawdown_pct"` if seconds < 60 {
return fmt.Sprintf("%ds", seconds)
}
if minutes < 60 {
return fmt.Sprintf("%dm", minutes)
}
if hours < 24 {
remainingMins := minutes % 60
if remainingMins == 0 {
return fmt.Sprintf("%dh", hours)
}
return fmt.Sprintf("%dh%dm", hours, remainingMins)
}
remainingHours := hours % 24
if remainingHours == 0 {
return fmt.Sprintf("%dd", days)
}
return fmt.Sprintf("%dd%dh", days, remainingHours)
} }
// TraderPosition position record // TraderPosition position record
@@ -400,585 +417,6 @@ func (s *PositionStore) GetAllOpenPositions() ([]*TraderPosition, error) {
return positions, nil return positions, nil
} }
// GetPositionStats gets position statistics
func (s *PositionStore) GetPositionStats(traderID string) (map[string]interface{}, error) {
stats := make(map[string]interface{})
type result struct {
Total int
Wins int
TotalPnL float64
TotalFee float64
}
var r result
err := s.db.Model(&TraderPosition{}).
Select("COUNT(*) as total, SUM(CASE WHEN realized_pnl > 0 THEN 1 ELSE 0 END) as wins, COALESCE(SUM(realized_pnl), 0) as total_pnl, COALESCE(SUM(fee), 0) as total_fee").
Where("trader_id = ? AND status = ?", traderID, "CLOSED").
Scan(&r).Error
if err != nil {
return nil, err
}
stats["total_trades"] = r.Total
stats["win_trades"] = r.Wins
stats["total_pnl"] = r.TotalPnL
stats["total_fee"] = r.TotalFee
if r.Total > 0 {
stats["win_rate"] = float64(r.Wins) / float64(r.Total) * 100
} else {
stats["win_rate"] = 0.0
}
return stats, nil
}
// GetFullStats gets complete trading statistics
func (s *PositionStore) GetFullStats(traderID string) (*TraderStats, error) {
stats := &TraderStats{}
var count int64
if err := s.db.Model(&TraderPosition{}).Where("trader_id = ? AND status = ?", traderID, "CLOSED").Count(&count).Error; err != nil {
return nil, err
}
if count == 0 {
return stats, nil
}
var positions []TraderPosition
err := s.db.Where("trader_id = ? AND status = ?", traderID, "CLOSED").
Order("exit_time ASC").
Find(&positions).Error
if err != nil {
return nil, fmt.Errorf("failed to query position statistics: %w", err)
}
var pnls []float64
var totalWin, totalLoss float64
for _, pos := range positions {
stats.TotalTrades++
stats.TotalPnL += pos.RealizedPnL
stats.TotalFee += pos.Fee
pnls = append(pnls, pos.RealizedPnL)
if pos.RealizedPnL > 0 {
stats.WinTrades++
totalWin += pos.RealizedPnL
} else if pos.RealizedPnL < 0 {
stats.LossTrades++
totalLoss += -pos.RealizedPnL
}
}
if stats.TotalTrades > 0 {
stats.WinRate = float64(stats.WinTrades) / float64(stats.TotalTrades) * 100
}
if totalLoss > 0 {
stats.ProfitFactor = totalWin / totalLoss
}
if stats.WinTrades > 0 {
stats.AvgWin = totalWin / float64(stats.WinTrades)
}
if stats.LossTrades > 0 {
stats.AvgLoss = totalLoss / float64(stats.LossTrades)
}
if len(pnls) > 1 {
stats.SharpeRatio = calculateSharpeRatioFromPnls(pnls)
}
if len(pnls) > 0 {
stats.MaxDrawdownPct = calculateMaxDrawdownFromPnls(pnls)
}
return stats, nil
}
// RecentTrade recent trade record
type RecentTrade struct {
Symbol string `json:"symbol"`
Side string `json:"side"`
EntryPrice float64 `json:"entry_price"`
ExitPrice float64 `json:"exit_price"`
RealizedPnL float64 `json:"realized_pnl"`
PnLPct float64 `json:"pnl_pct"`
EntryTime int64 `json:"entry_time"`
ExitTime int64 `json:"exit_time"`
HoldDuration string `json:"hold_duration"`
}
// GetRecentTrades gets recent closed trades
func (s *PositionStore) GetRecentTrades(traderID string, limit int) ([]RecentTrade, error) {
var positions []TraderPosition
err := s.db.Where("trader_id = ? AND status = ?", traderID, "CLOSED").
Order("exit_time DESC").
Limit(limit).
Find(&positions).Error
if err != nil {
return nil, fmt.Errorf("failed to query recent trades: %w", err)
}
var trades []RecentTrade
for _, pos := range positions {
t := RecentTrade{
Symbol: pos.Symbol,
Side: strings.ToLower(pos.Side),
EntryPrice: pos.EntryPrice,
ExitPrice: pos.ExitPrice,
RealizedPnL: pos.RealizedPnL,
EntryTime: pos.EntryTime / 1000, // Convert ms to seconds for API compatibility
}
if pos.ExitTime > 0 {
t.ExitTime = pos.ExitTime / 1000 // Convert ms to seconds
durationMs := pos.ExitTime - pos.EntryTime
t.HoldDuration = formatDurationMs(durationMs)
}
if pos.EntryPrice > 0 {
if t.Side == "long" {
t.PnLPct = (pos.ExitPrice - pos.EntryPrice) / pos.EntryPrice * 100 * float64(pos.Leverage)
} else {
t.PnLPct = (pos.EntryPrice - pos.ExitPrice) / pos.EntryPrice * 100 * float64(pos.Leverage)
}
}
trades = append(trades, t)
}
return trades, nil
}
// formatDuration formats a duration
func formatDuration(d time.Duration) string {
return formatDurationMs(d.Milliseconds())
}
// formatDurationMs formats a duration in milliseconds
func formatDurationMs(ms int64) string {
seconds := ms / 1000
minutes := seconds / 60
hours := minutes / 60
days := hours / 24
if seconds < 60 {
return fmt.Sprintf("%ds", seconds)
}
if minutes < 60 {
return fmt.Sprintf("%dm", minutes)
}
if hours < 24 {
remainingMins := minutes % 60
if remainingMins == 0 {
return fmt.Sprintf("%dh", hours)
}
return fmt.Sprintf("%dh%dm", hours, remainingMins)
}
remainingHours := hours % 24
if remainingHours == 0 {
return fmt.Sprintf("%dd", days)
}
return fmt.Sprintf("%dd%dh", days, remainingHours)
}
// calculateSharpeRatioFromPnls calculates Sharpe ratio
func calculateSharpeRatioFromPnls(pnls []float64) float64 {
if len(pnls) < 2 {
return 0
}
var sum float64
for _, pnl := range pnls {
sum += pnl
}
mean := sum / float64(len(pnls))
var variance float64
for _, pnl := range pnls {
variance += (pnl - mean) * (pnl - mean)
}
stdDev := math.Sqrt(variance / float64(len(pnls)-1))
if stdDev == 0 {
return 0
}
return mean / stdDev
}
// calculateMaxDrawdownFromPnls calculates maximum drawdown
func calculateMaxDrawdownFromPnls(pnls []float64) float64 {
if len(pnls) == 0 {
return 0
}
const startingEquity = 10000.0
equity := startingEquity
peak := startingEquity
var maxDD float64
for _, pnl := range pnls {
equity += pnl
if equity > peak {
peak = equity
}
if peak > 0 {
dd := (peak - equity) / peak * 100
if dd > maxDD {
maxDD = dd
}
}
}
return maxDD
}
// SymbolStats per-symbol trading statistics
type SymbolStats struct {
Symbol string `json:"symbol"`
TotalTrades int `json:"total_trades"`
WinTrades int `json:"win_trades"`
WinRate float64 `json:"win_rate"`
TotalPnL float64 `json:"total_pnl"`
AvgPnL float64 `json:"avg_pnl"`
AvgHoldMins float64 `json:"avg_hold_mins"`
}
// GetSymbolStats gets per-symbol trading statistics
func (s *PositionStore) GetSymbolStats(traderID string, limit int) ([]SymbolStats, error) {
var positions []TraderPosition
err := s.db.Where("trader_id = ? AND status = ?", traderID, "CLOSED").Find(&positions).Error
if err != nil {
return nil, fmt.Errorf("failed to query symbol stats: %w", err)
}
// Group by symbol
symbolMap := make(map[string]*SymbolStats)
symbolHoldMins := make(map[string][]float64)
for _, pos := range positions {
if _, ok := symbolMap[pos.Symbol]; !ok {
symbolMap[pos.Symbol] = &SymbolStats{Symbol: pos.Symbol}
symbolHoldMins[pos.Symbol] = []float64{}
}
s := symbolMap[pos.Symbol]
s.TotalTrades++
s.TotalPnL += pos.RealizedPnL
if pos.RealizedPnL > 0 {
s.WinTrades++
}
if pos.ExitTime > 0 {
holdMins := float64(pos.ExitTime-pos.EntryTime) / 60000.0 // ms to minutes
symbolHoldMins[pos.Symbol] = append(symbolHoldMins[pos.Symbol], holdMins)
}
}
var stats []SymbolStats
for symbol, s := range symbolMap {
if s.TotalTrades > 0 {
s.WinRate = float64(s.WinTrades) / float64(s.TotalTrades) * 100
s.AvgPnL = s.TotalPnL / float64(s.TotalTrades)
}
if len(symbolHoldMins[symbol]) > 0 {
var totalMins float64
for _, m := range symbolHoldMins[symbol] {
totalMins += m
}
s.AvgHoldMins = totalMins / float64(len(symbolHoldMins[symbol]))
}
stats = append(stats, *s)
}
// Sort by TotalPnL descending and limit
for i := 0; i < len(stats)-1; i++ {
for j := i + 1; j < len(stats); j++ {
if stats[j].TotalPnL > stats[i].TotalPnL {
stats[i], stats[j] = stats[j], stats[i]
}
}
}
if limit > 0 && len(stats) > limit {
stats = stats[:limit]
}
return stats, nil
}
// HoldingTimeStats holding duration analysis
type HoldingTimeStats struct {
Range string `json:"range"`
TradeCount int `json:"trade_count"`
WinRate float64 `json:"win_rate"`
AvgPnL float64 `json:"avg_pnl"`
}
// GetHoldingTimeStats analyzes performance by holding duration
func (s *PositionStore) GetHoldingTimeStats(traderID string) ([]HoldingTimeStats, error) {
var positions []TraderPosition
err := s.db.Where("trader_id = ? AND status = ? AND exit_time > 0", traderID, "CLOSED").Find(&positions).Error
if err != nil {
return nil, fmt.Errorf("failed to query holding time stats: %w", err)
}
rangeStats := map[string]*struct {
count int
wins int
totalPnL float64
}{
"<1h": {},
"1-4h": {},
"4-24h": {},
">24h": {},
}
for _, pos := range positions {
if pos.ExitTime == 0 {
continue
}
holdHours := float64(pos.ExitTime-pos.EntryTime) / 3600000.0 // ms to hours
var rangeKey string
switch {
case holdHours < 1:
rangeKey = "<1h"
case holdHours < 4:
rangeKey = "1-4h"
case holdHours < 24:
rangeKey = "4-24h"
default:
rangeKey = ">24h"
}
r := rangeStats[rangeKey]
r.count++
r.totalPnL += pos.RealizedPnL
if pos.RealizedPnL > 0 {
r.wins++
}
}
var stats []HoldingTimeStats
for _, rangeKey := range []string{"<1h", "1-4h", "4-24h", ">24h"} {
r := rangeStats[rangeKey]
if r.count > 0 {
stats = append(stats, HoldingTimeStats{
Range: rangeKey,
TradeCount: r.count,
WinRate: float64(r.wins) / float64(r.count) * 100,
AvgPnL: r.totalPnL / float64(r.count),
})
}
}
return stats, nil
}
// DirectionStats long/short performance comparison
type DirectionStats struct {
Side string `json:"side"`
TradeCount int `json:"trade_count"`
WinRate float64 `json:"win_rate"`
TotalPnL float64 `json:"total_pnl"`
AvgPnL float64 `json:"avg_pnl"`
}
// GetDirectionStats analyzes long vs short performance
func (s *PositionStore) GetDirectionStats(traderID string) ([]DirectionStats, error) {
var positions []TraderPosition
err := s.db.Where("trader_id = ? AND status = ?", traderID, "CLOSED").Find(&positions).Error
if err != nil {
return nil, fmt.Errorf("failed to query direction stats: %w", err)
}
sideStats := make(map[string]*DirectionStats)
for _, pos := range positions {
if _, ok := sideStats[pos.Side]; !ok {
sideStats[pos.Side] = &DirectionStats{Side: pos.Side}
}
s := sideStats[pos.Side]
s.TradeCount++
s.TotalPnL += pos.RealizedPnL
if pos.RealizedPnL > 0 {
s.WinRate++
}
}
var stats []DirectionStats
for _, s := range sideStats {
if s.TradeCount > 0 {
s.AvgPnL = s.TotalPnL / float64(s.TradeCount)
s.WinRate = s.WinRate / float64(s.TradeCount) * 100
}
stats = append(stats, *s)
}
return stats, nil
}
// HistorySummary comprehensive trading history for AI context
type HistorySummary struct {
TotalTrades int `json:"total_trades"`
WinRate float64 `json:"win_rate"`
TotalPnL float64 `json:"total_pnl"`
AvgTradeReturn float64 `json:"avg_trade_return"`
BestSymbols []SymbolStats `json:"best_symbols"`
WorstSymbols []SymbolStats `json:"worst_symbols"`
LongWinRate float64 `json:"long_win_rate"`
ShortWinRate float64 `json:"short_win_rate"`
LongPnL float64 `json:"long_pnl"`
ShortPnL float64 `json:"short_pnl"`
AvgHoldingMins float64 `json:"avg_holding_mins"`
BestHoldRange string `json:"best_hold_range"`
RecentWinRate float64 `json:"recent_win_rate"`
RecentPnL float64 `json:"recent_pnl"`
CurrentStreak int `json:"current_streak"`
MaxWinStreak int `json:"max_win_streak"`
MaxLoseStreak int `json:"max_lose_streak"`
}
// GetHistorySummary generates comprehensive AI context summary
func (s *PositionStore) GetHistorySummary(traderID string) (*HistorySummary, error) {
summary := &HistorySummary{}
fullStats, err := s.GetFullStats(traderID)
if err != nil {
return nil, err
}
summary.TotalTrades = fullStats.TotalTrades
summary.WinRate = fullStats.WinRate
summary.TotalPnL = fullStats.TotalPnL
if fullStats.TotalTrades > 0 {
summary.AvgTradeReturn = fullStats.TotalPnL / float64(fullStats.TotalTrades)
}
symbolStats, _ := s.GetSymbolStats(traderID, 20)
if len(symbolStats) > 0 {
for i := 0; i < len(symbolStats) && i < 3; i++ {
if symbolStats[i].TotalPnL > 0 {
summary.BestSymbols = append(summary.BestSymbols, symbolStats[i])
}
}
for i := len(symbolStats) - 1; i >= 0 && len(summary.WorstSymbols) < 3; i-- {
if symbolStats[i].TotalPnL < 0 {
summary.WorstSymbols = append(summary.WorstSymbols, symbolStats[i])
}
}
}
dirStats, _ := s.GetDirectionStats(traderID)
for _, d := range dirStats {
if d.Side == "LONG" {
summary.LongWinRate = d.WinRate
summary.LongPnL = d.TotalPnL
} else if d.Side == "SHORT" {
summary.ShortWinRate = d.WinRate
summary.ShortPnL = d.TotalPnL
}
}
holdStats, _ := s.GetHoldingTimeStats(traderID)
var bestHoldWinRate float64
for _, h := range holdStats {
if h.WinRate > bestHoldWinRate && h.TradeCount >= 3 {
bestHoldWinRate = h.WinRate
summary.BestHoldRange = h.Range
}
}
// Calculate average holding time
var positions []TraderPosition
s.db.Where("trader_id = ? AND status = ? AND exit_time > 0", traderID, "CLOSED").Find(&positions)
if len(positions) > 0 {
var totalMins float64
for _, pos := range positions {
if pos.ExitTime > 0 {
totalMins += float64(pos.ExitTime-pos.EntryTime) / 60000.0 // ms to minutes
}
}
summary.AvgHoldingMins = totalMins / float64(len(positions))
}
// Recent 20 trades
var recent []TraderPosition
s.db.Where("trader_id = ? AND status = ?", traderID, "CLOSED").
Order("exit_time DESC").Limit(20).Find(&recent)
for _, pos := range recent {
summary.RecentPnL += pos.RealizedPnL
if pos.RealizedPnL > 0 {
summary.RecentWinRate++
}
}
if len(recent) > 0 {
summary.RecentWinRate = summary.RecentWinRate / float64(len(recent)) * 100
}
// Calculate streaks
s.calculateStreaks(traderID, summary)
return summary, nil
}
// calculateStreaks calculates win/loss streaks
func (s *PositionStore) calculateStreaks(traderID string, summary *HistorySummary) {
var positions []TraderPosition
err := s.db.Where("trader_id = ? AND status = ?", traderID, "CLOSED").
Order("exit_time DESC").
Find(&positions).Error
if err != nil || len(positions) == 0 {
return
}
var currentStreak, maxWin, maxLose int
var prevWin *bool
isFirst := true
for _, pos := range positions {
isWin := pos.RealizedPnL > 0
if isFirst {
if isWin {
currentStreak = 1
} else {
currentStreak = -1
}
isFirst = false
}
if prevWin == nil {
prevWin = &isWin
} else if *prevWin == isWin {
if isWin {
currentStreak++
if currentStreak > maxWin {
maxWin = currentStreak
}
} else {
currentStreak--
if -currentStreak > maxLose {
maxLose = -currentStreak
}
}
} else {
if isWin {
currentStreak = 1
} else {
currentStreak = -1
}
*prevWin = isWin
}
}
summary.CurrentStreak = currentStreak
summary.MaxWinStreak = maxWin
summary.MaxLoseStreak = maxLose
}
// ExistsWithExchangePositionID checks if a position exists // ExistsWithExchangePositionID checks if a position exists
func (s *PositionStore) ExistsWithExchangePositionID(exchangeID, exchangePositionID string) (bool, error) { func (s *PositionStore) ExistsWithExchangePositionID(exchangeID, exchangePositionID string) (bool, error) {
if exchangePositionID == "" { if exchangePositionID == "" {
@@ -1017,124 +455,6 @@ func (s *PositionStore) GetOpenPositionByExchangePositionID(exchangeID, exchange
return &pos, nil return &pos, nil
} }
// ClosedPnLRecord represents a closed position record from exchange
// All time fields use int64 millisecond timestamps (UTC)
type ClosedPnLRecord struct {
Symbol string
Side string
EntryPrice float64
ExitPrice float64
Quantity float64
RealizedPnL float64
Fee float64
Leverage int
EntryTime int64 // Unix milliseconds UTC
ExitTime int64 // Unix milliseconds UTC
OrderID string
CloseType string
ExchangeID string
}
// CreateFromClosedPnL creates a closed position record from exchange data
func (s *PositionStore) CreateFromClosedPnL(traderID, exchangeID, exchangeType string, record *ClosedPnLRecord) (bool, error) {
if record.Symbol == "" {
return false, nil
}
side := strings.ToUpper(record.Side)
if side == "LONG" || side == "BUY" {
side = "LONG"
} else if side == "SHORT" || side == "SELL" {
side = "SHORT"
} else {
return false, nil
}
if record.Quantity <= 0 || record.ExitPrice <= 0 || record.EntryPrice <= 0 {
return false, nil
}
exchangePositionID := record.ExchangeID
if exchangePositionID == "" {
exchangePositionID = fmt.Sprintf("%s_%s_%d_%.8f", record.Symbol, side, record.ExitTime, record.RealizedPnL)
}
exists, err := s.ExistsWithExchangePositionID(exchangeID, exchangePositionID)
if err != nil {
return false, err
}
if exists {
return false, nil
}
exitTimeMs := record.ExitTime
entryTimeMs := record.EntryTime
// Validate timestamps (must be after year 2000 = ~946684800000 ms)
minValidTime := int64(946684800000) // 2000-01-01 UTC in milliseconds
if exitTimeMs < minValidTime {
return false, nil
}
if entryTimeMs < minValidTime {
entryTimeMs = exitTimeMs
}
if entryTimeMs > exitTimeMs {
entryTimeMs = exitTimeMs
}
nowMs := time.Now().UTC().UnixMilli()
pos := &TraderPosition{
TraderID: traderID,
ExchangeID: exchangeID,
ExchangeType: exchangeType,
ExchangePositionID: exchangePositionID,
Symbol: record.Symbol,
Side: side,
Quantity: record.Quantity,
EntryQuantity: record.Quantity,
EntryPrice: record.EntryPrice,
EntryTime: entryTimeMs,
ExitPrice: record.ExitPrice,
ExitOrderID: record.OrderID,
ExitTime: exitTimeMs,
RealizedPnL: record.RealizedPnL,
Fee: record.Fee,
Leverage: record.Leverage,
Status: "CLOSED",
CloseReason: record.CloseType,
Source: "sync",
CreatedAt: nowMs,
UpdatedAt: nowMs,
}
err = s.db.Create(pos).Error
if err != nil {
if strings.Contains(err.Error(), "UNIQUE constraint failed") {
return false, nil
}
return false, fmt.Errorf("failed to create position from closed PnL: %w", err)
}
return true, nil
}
// GetLastClosedPositionTime gets the most recent exit time (Unix ms)
func (s *PositionStore) GetLastClosedPositionTime(traderID string) (int64, error) {
var pos TraderPosition
err := s.db.Where("trader_id = ? AND status = ? AND exit_time > 0", traderID, "CLOSED").
Order("exit_time DESC").
First(&pos).Error
if err == gorm.ErrRecordNotFound || pos.ExitTime == 0 {
return time.Now().UTC().Add(-30 * 24 * time.Hour).UnixMilli(), nil
}
if err != nil {
return 0, fmt.Errorf("failed to get last closed position time: %w", err)
}
return pos.ExitTime, nil
}
// CreateOpenPosition creates an open position // CreateOpenPosition creates an open position
func (s *PositionStore) CreateOpenPosition(pos *TraderPosition) error { func (s *PositionStore) CreateOpenPosition(pos *TraderPosition) error {
if pos.ExchangePositionID != "" && pos.ExchangeID != "" { if pos.ExchangePositionID != "" && pos.ExchangeID != "" {
@@ -1196,21 +516,3 @@ func (s *PositionStore) ClosePositionWithAccurateData(id int64, exitPrice float6
"updated_at": time.Now().UTC().UnixMilli(), "updated_at": time.Now().UTC().UnixMilli(),
}).Error }).Error
} }
// SyncClosedPositions syncs closed positions from exchange
func (s *PositionStore) SyncClosedPositions(traderID, exchangeID, exchangeType string, records []ClosedPnLRecord) (int, int, error) {
created, skipped := 0, 0
for _, record := range records {
rec := record
wasCreated, err := s.CreateFromClosedPnL(traderID, exchangeID, exchangeType, &rec)
if err != nil {
return created, skipped, fmt.Errorf("failed to sync position: %w", err)
}
if wasCreated {
created++
} else {
skipped++
}
}
return created, skipped, nil
}

308
store/position_history.go Normal file
View File

@@ -0,0 +1,308 @@
package store
import (
"fmt"
"strings"
"time"
"gorm.io/gorm"
)
// HistorySummary comprehensive trading history for AI context
type HistorySummary struct {
TotalTrades int `json:"total_trades"`
WinRate float64 `json:"win_rate"`
TotalPnL float64 `json:"total_pnl"`
AvgTradeReturn float64 `json:"avg_trade_return"`
BestSymbols []SymbolStats `json:"best_symbols"`
WorstSymbols []SymbolStats `json:"worst_symbols"`
LongWinRate float64 `json:"long_win_rate"`
ShortWinRate float64 `json:"short_win_rate"`
LongPnL float64 `json:"long_pnl"`
ShortPnL float64 `json:"short_pnl"`
AvgHoldingMins float64 `json:"avg_holding_mins"`
BestHoldRange string `json:"best_hold_range"`
RecentWinRate float64 `json:"recent_win_rate"`
RecentPnL float64 `json:"recent_pnl"`
CurrentStreak int `json:"current_streak"`
MaxWinStreak int `json:"max_win_streak"`
MaxLoseStreak int `json:"max_lose_streak"`
}
// GetHistorySummary generates comprehensive AI context summary
func (s *PositionStore) GetHistorySummary(traderID string) (*HistorySummary, error) {
summary := &HistorySummary{}
fullStats, err := s.GetFullStats(traderID)
if err != nil {
return nil, err
}
summary.TotalTrades = fullStats.TotalTrades
summary.WinRate = fullStats.WinRate
summary.TotalPnL = fullStats.TotalPnL
if fullStats.TotalTrades > 0 {
summary.AvgTradeReturn = fullStats.TotalPnL / float64(fullStats.TotalTrades)
}
symbolStats, _ := s.GetSymbolStats(traderID, 20)
if len(symbolStats) > 0 {
for i := 0; i < len(symbolStats) && i < 3; i++ {
if symbolStats[i].TotalPnL > 0 {
summary.BestSymbols = append(summary.BestSymbols, symbolStats[i])
}
}
for i := len(symbolStats) - 1; i >= 0 && len(summary.WorstSymbols) < 3; i-- {
if symbolStats[i].TotalPnL < 0 {
summary.WorstSymbols = append(summary.WorstSymbols, symbolStats[i])
}
}
}
dirStats, _ := s.GetDirectionStats(traderID)
for _, d := range dirStats {
if d.Side == "LONG" {
summary.LongWinRate = d.WinRate
summary.LongPnL = d.TotalPnL
} else if d.Side == "SHORT" {
summary.ShortWinRate = d.WinRate
summary.ShortPnL = d.TotalPnL
}
}
holdStats, _ := s.GetHoldingTimeStats(traderID)
var bestHoldWinRate float64
for _, h := range holdStats {
if h.WinRate > bestHoldWinRate && h.TradeCount >= 3 {
bestHoldWinRate = h.WinRate
summary.BestHoldRange = h.Range
}
}
// Calculate average holding time
var positions []TraderPosition
s.db.Where("trader_id = ? AND status = ? AND exit_time > 0", traderID, "CLOSED").Find(&positions)
if len(positions) > 0 {
var totalMins float64
for _, pos := range positions {
if pos.ExitTime > 0 {
totalMins += float64(pos.ExitTime-pos.EntryTime) / 60000.0 // ms to minutes
}
}
summary.AvgHoldingMins = totalMins / float64(len(positions))
}
// Recent 20 trades
var recent []TraderPosition
s.db.Where("trader_id = ? AND status = ?", traderID, "CLOSED").
Order("exit_time DESC").Limit(20).Find(&recent)
for _, pos := range recent {
summary.RecentPnL += pos.RealizedPnL
if pos.RealizedPnL > 0 {
summary.RecentWinRate++
}
}
if len(recent) > 0 {
summary.RecentWinRate = summary.RecentWinRate / float64(len(recent)) * 100
}
// Calculate streaks
s.calculateStreaks(traderID, summary)
return summary, nil
}
// calculateStreaks calculates win/loss streaks
func (s *PositionStore) calculateStreaks(traderID string, summary *HistorySummary) {
var positions []TraderPosition
err := s.db.Where("trader_id = ? AND status = ?", traderID, "CLOSED").
Order("exit_time DESC").
Find(&positions).Error
if err != nil || len(positions) == 0 {
return
}
var currentStreak, maxWin, maxLose int
var prevWin *bool
isFirst := true
for _, pos := range positions {
isWin := pos.RealizedPnL > 0
if isFirst {
if isWin {
currentStreak = 1
} else {
currentStreak = -1
}
isFirst = false
}
if prevWin == nil {
prevWin = &isWin
} else if *prevWin == isWin {
if isWin {
currentStreak++
if currentStreak > maxWin {
maxWin = currentStreak
}
} else {
currentStreak--
if -currentStreak > maxLose {
maxLose = -currentStreak
}
}
} else {
if isWin {
currentStreak = 1
} else {
currentStreak = -1
}
*prevWin = isWin
}
}
summary.CurrentStreak = currentStreak
summary.MaxWinStreak = maxWin
summary.MaxLoseStreak = maxLose
}
// ClosedPnLRecord represents a closed position record from exchange
// All time fields use int64 millisecond timestamps (UTC)
type ClosedPnLRecord struct {
Symbol string
Side string
EntryPrice float64
ExitPrice float64
Quantity float64
RealizedPnL float64
Fee float64
Leverage int
EntryTime int64 // Unix milliseconds UTC
ExitTime int64 // Unix milliseconds UTC
OrderID string
CloseType string
ExchangeID string
}
// CreateFromClosedPnL creates a closed position record from exchange data
func (s *PositionStore) CreateFromClosedPnL(traderID, exchangeID, exchangeType string, record *ClosedPnLRecord) (bool, error) {
if record.Symbol == "" {
return false, nil
}
side := strings.ToUpper(record.Side)
if side == "LONG" || side == "BUY" {
side = "LONG"
} else if side == "SHORT" || side == "SELL" {
side = "SHORT"
} else {
return false, nil
}
if record.Quantity <= 0 || record.ExitPrice <= 0 || record.EntryPrice <= 0 {
return false, nil
}
exchangePositionID := record.ExchangeID
if exchangePositionID == "" {
exchangePositionID = fmt.Sprintf("%s_%s_%d_%.8f", record.Symbol, side, record.ExitTime, record.RealizedPnL)
}
exists, err := s.ExistsWithExchangePositionID(exchangeID, exchangePositionID)
if err != nil {
return false, err
}
if exists {
return false, nil
}
exitTimeMs := record.ExitTime
entryTimeMs := record.EntryTime
// Validate timestamps (must be after year 2000 = ~946684800000 ms)
minValidTime := int64(946684800000) // 2000-01-01 UTC in milliseconds
if exitTimeMs < minValidTime {
return false, nil
}
if entryTimeMs < minValidTime {
entryTimeMs = exitTimeMs
}
if entryTimeMs > exitTimeMs {
entryTimeMs = exitTimeMs
}
nowMs := time.Now().UTC().UnixMilli()
pos := &TraderPosition{
TraderID: traderID,
ExchangeID: exchangeID,
ExchangeType: exchangeType,
ExchangePositionID: exchangePositionID,
Symbol: record.Symbol,
Side: side,
Quantity: record.Quantity,
EntryQuantity: record.Quantity,
EntryPrice: record.EntryPrice,
EntryTime: entryTimeMs,
ExitPrice: record.ExitPrice,
ExitOrderID: record.OrderID,
ExitTime: exitTimeMs,
RealizedPnL: record.RealizedPnL,
Fee: record.Fee,
Leverage: record.Leverage,
Status: "CLOSED",
CloseReason: record.CloseType,
Source: "sync",
CreatedAt: nowMs,
UpdatedAt: nowMs,
}
err = s.db.Create(pos).Error
if err != nil {
if strings.Contains(err.Error(), "UNIQUE constraint failed") {
return false, nil
}
return false, fmt.Errorf("failed to create position from closed PnL: %w", err)
}
return true, nil
}
// GetLastClosedPositionTime gets the most recent exit time (Unix ms)
func (s *PositionStore) GetLastClosedPositionTime(traderID string) (int64, error) {
var pos TraderPosition
err := s.db.Where("trader_id = ? AND status = ? AND exit_time > 0", traderID, "CLOSED").
Order("exit_time DESC").
First(&pos).Error
if err == gorm.ErrRecordNotFound || pos.ExitTime == 0 {
return time.Now().UTC().Add(-30 * 24 * time.Hour).UnixMilli(), nil
}
if err != nil {
return 0, fmt.Errorf("failed to get last closed position time: %w", err)
}
return pos.ExitTime, nil
}
// SyncClosedPositions syncs closed positions from exchange
func (s *PositionStore) SyncClosedPositions(traderID, exchangeID, exchangeType string, records []ClosedPnLRecord) (int, int, error) {
created, skipped := 0, 0
for _, record := range records {
rec := record
wasCreated, err := s.CreateFromClosedPnL(traderID, exchangeID, exchangeType, &rec)
if err != nil {
return created, skipped, fmt.Errorf("failed to sync position: %w", err)
}
if wasCreated {
created++
} else {
skipped++
}
}
return created, skipped, nil
}

406
store/position_query.go Normal file
View File

@@ -0,0 +1,406 @@
package store
import (
"fmt"
"math"
"strings"
)
// TraderStats trading statistics metrics
type TraderStats struct {
TotalTrades int `json:"total_trades"`
WinTrades int `json:"win_trades"`
LossTrades int `json:"loss_trades"`
WinRate float64 `json:"win_rate"`
ProfitFactor float64 `json:"profit_factor"`
SharpeRatio float64 `json:"sharpe_ratio"`
TotalPnL float64 `json:"total_pnl"`
TotalFee float64 `json:"total_fee"`
AvgWin float64 `json:"avg_win"`
AvgLoss float64 `json:"avg_loss"`
MaxDrawdownPct float64 `json:"max_drawdown_pct"`
}
// GetPositionStats gets position statistics
func (s *PositionStore) GetPositionStats(traderID string) (map[string]interface{}, error) {
stats := make(map[string]interface{})
type result struct {
Total int
Wins int
TotalPnL float64
TotalFee float64
}
var r result
err := s.db.Model(&TraderPosition{}).
Select("COUNT(*) as total, SUM(CASE WHEN realized_pnl > 0 THEN 1 ELSE 0 END) as wins, COALESCE(SUM(realized_pnl), 0) as total_pnl, COALESCE(SUM(fee), 0) as total_fee").
Where("trader_id = ? AND status = ?", traderID, "CLOSED").
Scan(&r).Error
if err != nil {
return nil, err
}
stats["total_trades"] = r.Total
stats["win_trades"] = r.Wins
stats["total_pnl"] = r.TotalPnL
stats["total_fee"] = r.TotalFee
if r.Total > 0 {
stats["win_rate"] = float64(r.Wins) / float64(r.Total) * 100
} else {
stats["win_rate"] = 0.0
}
return stats, nil
}
// GetFullStats gets complete trading statistics
func (s *PositionStore) GetFullStats(traderID string) (*TraderStats, error) {
stats := &TraderStats{}
var count int64
if err := s.db.Model(&TraderPosition{}).Where("trader_id = ? AND status = ?", traderID, "CLOSED").Count(&count).Error; err != nil {
return nil, err
}
if count == 0 {
return stats, nil
}
var positions []TraderPosition
err := s.db.Where("trader_id = ? AND status = ?", traderID, "CLOSED").
Order("exit_time ASC").
Find(&positions).Error
if err != nil {
return nil, fmt.Errorf("failed to query position statistics: %w", err)
}
var pnls []float64
var totalWin, totalLoss float64
for _, pos := range positions {
stats.TotalTrades++
stats.TotalPnL += pos.RealizedPnL
stats.TotalFee += pos.Fee
pnls = append(pnls, pos.RealizedPnL)
if pos.RealizedPnL > 0 {
stats.WinTrades++
totalWin += pos.RealizedPnL
} else if pos.RealizedPnL < 0 {
stats.LossTrades++
totalLoss += -pos.RealizedPnL
}
}
if stats.TotalTrades > 0 {
stats.WinRate = float64(stats.WinTrades) / float64(stats.TotalTrades) * 100
}
if totalLoss > 0 {
stats.ProfitFactor = totalWin / totalLoss
}
if stats.WinTrades > 0 {
stats.AvgWin = totalWin / float64(stats.WinTrades)
}
if stats.LossTrades > 0 {
stats.AvgLoss = totalLoss / float64(stats.LossTrades)
}
if len(pnls) > 1 {
stats.SharpeRatio = calculateSharpeRatioFromPnls(pnls)
}
if len(pnls) > 0 {
stats.MaxDrawdownPct = calculateMaxDrawdownFromPnls(pnls)
}
return stats, nil
}
// RecentTrade recent trade record
type RecentTrade struct {
Symbol string `json:"symbol"`
Side string `json:"side"`
EntryPrice float64 `json:"entry_price"`
ExitPrice float64 `json:"exit_price"`
RealizedPnL float64 `json:"realized_pnl"`
PnLPct float64 `json:"pnl_pct"`
EntryTime int64 `json:"entry_time"`
ExitTime int64 `json:"exit_time"`
HoldDuration string `json:"hold_duration"`
}
// GetRecentTrades gets recent closed trades
func (s *PositionStore) GetRecentTrades(traderID string, limit int) ([]RecentTrade, error) {
var positions []TraderPosition
err := s.db.Where("trader_id = ? AND status = ?", traderID, "CLOSED").
Order("exit_time DESC").
Limit(limit).
Find(&positions).Error
if err != nil {
return nil, fmt.Errorf("failed to query recent trades: %w", err)
}
var trades []RecentTrade
for _, pos := range positions {
t := RecentTrade{
Symbol: pos.Symbol,
Side: strings.ToLower(pos.Side),
EntryPrice: pos.EntryPrice,
ExitPrice: pos.ExitPrice,
RealizedPnL: pos.RealizedPnL,
EntryTime: pos.EntryTime / 1000, // Convert ms to seconds for API compatibility
}
if pos.ExitTime > 0 {
t.ExitTime = pos.ExitTime / 1000 // Convert ms to seconds
durationMs := pos.ExitTime - pos.EntryTime
t.HoldDuration = formatDurationMs(durationMs)
}
if pos.EntryPrice > 0 {
if t.Side == "long" {
t.PnLPct = (pos.ExitPrice - pos.EntryPrice) / pos.EntryPrice * 100 * float64(pos.Leverage)
} else {
t.PnLPct = (pos.EntryPrice - pos.ExitPrice) / pos.EntryPrice * 100 * float64(pos.Leverage)
}
}
trades = append(trades, t)
}
return trades, nil
}
// calculateSharpeRatioFromPnls calculates Sharpe ratio
func calculateSharpeRatioFromPnls(pnls []float64) float64 {
if len(pnls) < 2 {
return 0
}
var sum float64
for _, pnl := range pnls {
sum += pnl
}
mean := sum / float64(len(pnls))
var variance float64
for _, pnl := range pnls {
variance += (pnl - mean) * (pnl - mean)
}
stdDev := math.Sqrt(variance / float64(len(pnls)-1))
if stdDev == 0 {
return 0
}
return mean / stdDev
}
// calculateMaxDrawdownFromPnls calculates maximum drawdown
func calculateMaxDrawdownFromPnls(pnls []float64) float64 {
if len(pnls) == 0 {
return 0
}
const startingEquity = 10000.0
equity := startingEquity
peak := startingEquity
var maxDD float64
for _, pnl := range pnls {
equity += pnl
if equity > peak {
peak = equity
}
if peak > 0 {
dd := (peak - equity) / peak * 100
if dd > maxDD {
maxDD = dd
}
}
}
return maxDD
}
// SymbolStats per-symbol trading statistics
type SymbolStats struct {
Symbol string `json:"symbol"`
TotalTrades int `json:"total_trades"`
WinTrades int `json:"win_trades"`
WinRate float64 `json:"win_rate"`
TotalPnL float64 `json:"total_pnl"`
AvgPnL float64 `json:"avg_pnl"`
AvgHoldMins float64 `json:"avg_hold_mins"`
}
// GetSymbolStats gets per-symbol trading statistics
func (s *PositionStore) GetSymbolStats(traderID string, limit int) ([]SymbolStats, error) {
var positions []TraderPosition
err := s.db.Where("trader_id = ? AND status = ?", traderID, "CLOSED").Find(&positions).Error
if err != nil {
return nil, fmt.Errorf("failed to query symbol stats: %w", err)
}
// Group by symbol
symbolMap := make(map[string]*SymbolStats)
symbolHoldMins := make(map[string][]float64)
for _, pos := range positions {
if _, ok := symbolMap[pos.Symbol]; !ok {
symbolMap[pos.Symbol] = &SymbolStats{Symbol: pos.Symbol}
symbolHoldMins[pos.Symbol] = []float64{}
}
s := symbolMap[pos.Symbol]
s.TotalTrades++
s.TotalPnL += pos.RealizedPnL
if pos.RealizedPnL > 0 {
s.WinTrades++
}
if pos.ExitTime > 0 {
holdMins := float64(pos.ExitTime-pos.EntryTime) / 60000.0 // ms to minutes
symbolHoldMins[pos.Symbol] = append(symbolHoldMins[pos.Symbol], holdMins)
}
}
var stats []SymbolStats
for symbol, s := range symbolMap {
if s.TotalTrades > 0 {
s.WinRate = float64(s.WinTrades) / float64(s.TotalTrades) * 100
s.AvgPnL = s.TotalPnL / float64(s.TotalTrades)
}
if len(symbolHoldMins[symbol]) > 0 {
var totalMins float64
for _, m := range symbolHoldMins[symbol] {
totalMins += m
}
s.AvgHoldMins = totalMins / float64(len(symbolHoldMins[symbol]))
}
stats = append(stats, *s)
}
// Sort by TotalPnL descending and limit
for i := 0; i < len(stats)-1; i++ {
for j := i + 1; j < len(stats); j++ {
if stats[j].TotalPnL > stats[i].TotalPnL {
stats[i], stats[j] = stats[j], stats[i]
}
}
}
if limit > 0 && len(stats) > limit {
stats = stats[:limit]
}
return stats, nil
}
// HoldingTimeStats holding duration analysis
type HoldingTimeStats struct {
Range string `json:"range"`
TradeCount int `json:"trade_count"`
WinRate float64 `json:"win_rate"`
AvgPnL float64 `json:"avg_pnl"`
}
// GetHoldingTimeStats analyzes performance by holding duration
func (s *PositionStore) GetHoldingTimeStats(traderID string) ([]HoldingTimeStats, error) {
var positions []TraderPosition
err := s.db.Where("trader_id = ? AND status = ? AND exit_time > 0", traderID, "CLOSED").Find(&positions).Error
if err != nil {
return nil, fmt.Errorf("failed to query holding time stats: %w", err)
}
rangeStats := map[string]*struct {
count int
wins int
totalPnL float64
}{
"<1h": {},
"1-4h": {},
"4-24h": {},
">24h": {},
}
for _, pos := range positions {
if pos.ExitTime == 0 {
continue
}
holdHours := float64(pos.ExitTime-pos.EntryTime) / 3600000.0 // ms to hours
var rangeKey string
switch {
case holdHours < 1:
rangeKey = "<1h"
case holdHours < 4:
rangeKey = "1-4h"
case holdHours < 24:
rangeKey = "4-24h"
default:
rangeKey = ">24h"
}
r := rangeStats[rangeKey]
r.count++
r.totalPnL += pos.RealizedPnL
if pos.RealizedPnL > 0 {
r.wins++
}
}
var stats []HoldingTimeStats
for _, rangeKey := range []string{"<1h", "1-4h", "4-24h", ">24h"} {
r := rangeStats[rangeKey]
if r.count > 0 {
stats = append(stats, HoldingTimeStats{
Range: rangeKey,
TradeCount: r.count,
WinRate: float64(r.wins) / float64(r.count) * 100,
AvgPnL: r.totalPnL / float64(r.count),
})
}
}
return stats, nil
}
// DirectionStats long/short performance comparison
type DirectionStats struct {
Side string `json:"side"`
TradeCount int `json:"trade_count"`
WinRate float64 `json:"win_rate"`
TotalPnL float64 `json:"total_pnl"`
AvgPnL float64 `json:"avg_pnl"`
}
// GetDirectionStats analyzes long vs short performance
func (s *PositionStore) GetDirectionStats(traderID string) ([]DirectionStats, error) {
var positions []TraderPosition
err := s.db.Where("trader_id = ? AND status = ?", traderID, "CLOSED").Find(&positions).Error
if err != nil {
return nil, fmt.Errorf("failed to query direction stats: %w", err)
}
sideStats := make(map[string]*DirectionStats)
for _, pos := range positions {
if _, ok := sideStats[pos.Side]; !ok {
sideStats[pos.Side] = &DirectionStats{Side: pos.Side}
}
s := sideStats[pos.Side]
s.TradeCount++
s.TotalPnL += pos.RealizedPnL
if pos.RealizedPnL > 0 {
s.WinRate++
}
}
var stats []DirectionStats
for _, s := range sideStats {
if s.TradeCount > 0 {
s.AvgPnL = s.TotalPnL / float64(s.TradeCount)
s.WinRate = s.WinRate / float64(s.TradeCount) * 100
}
stats = append(stats, *s)
}
return stats, nil
}

View File

@@ -1,5 +1,5 @@
// Package experience handles product telemetry // Package telemetry handles product telemetry
package experience package telemetry
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 {
@@ -129,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
}, },
}, },
}, },

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,299 @@
package aster
import (
"encoding/json"
"errors"
"fmt"
"io"
"net/http"
"nofx/logger"
"nofx/trader/types"
"strconv"
"time"
)
// GetBalance Get account balance
func (t *AsterTrader) GetBalance() (map[string]interface{}, error) {
params := make(map[string]interface{})
body, err := t.request("GET", "/fapi/v3/balance", params)
if err != nil {
return nil, err
}
var balances []map[string]interface{}
if err := json.Unmarshal(body, &balances); err != nil {
return nil, err
}
// Find USDT balance
availableBalance := 0.0
crossUnPnl := 0.0
crossWalletBalance := 0.0
foundUSDT := false
for _, bal := range balances {
if asset, ok := bal["asset"].(string); ok && asset == "USDT" {
foundUSDT = true
// Parse Aster fields (reference: https://github.com/asterdex/api-docs)
if avail, ok := bal["availableBalance"].(string); ok {
availableBalance, _ = strconv.ParseFloat(avail, 64)
}
if unpnl, ok := bal["crossUnPnl"].(string); ok {
crossUnPnl, _ = strconv.ParseFloat(unpnl, 64)
}
if cwb, ok := bal["crossWalletBalance"].(string); ok {
crossWalletBalance, _ = strconv.ParseFloat(cwb, 64)
}
break
}
}
if !foundUSDT {
logger.Infof("⚠️ USDT asset record not found!")
}
// Get positions to calculate margin used and real unrealized PnL
positions, err := t.GetPositions()
if err != nil {
logger.Infof("⚠️ Failed to get position information: %v", err)
// fallback: use simple calculation when unable to get positions
return map[string]interface{}{
"totalWalletBalance": crossWalletBalance,
"availableBalance": availableBalance,
"totalUnrealizedProfit": crossUnPnl,
}, nil
}
// Critical fix: accumulate real unrealized PnL from positions
// Aster's crossUnPnl field is inaccurate, need to recalculate from position data
totalMarginUsed := 0.0
realUnrealizedPnl := 0.0
for _, pos := range positions {
markPrice := pos["markPrice"].(float64)
quantity := pos["positionAmt"].(float64)
if quantity < 0 {
quantity = -quantity
}
unrealizedPnl := pos["unRealizedProfit"].(float64)
realUnrealizedPnl += unrealizedPnl
leverage := 10
if lev, ok := pos["leverage"].(float64); ok {
leverage = int(lev)
}
marginUsed := (quantity * markPrice) / float64(leverage)
totalMarginUsed += marginUsed
}
// Aster correct calculation method:
// Total equity = available balance + margin used
// Wallet balance = total equity - unrealized PnL
// Unrealized PnL = calculated from accumulated positions (don't use API's crossUnPnl)
totalEquity := availableBalance + totalMarginUsed
totalWalletBalance := totalEquity - realUnrealizedPnl
return map[string]interface{}{
"totalWalletBalance": totalWalletBalance, // Wallet balance (excluding unrealized PnL)
"availableBalance": availableBalance, // Available balance
"totalUnrealizedProfit": realUnrealizedPnl, // Unrealized PnL (accumulated from positions)
}, nil
}
// GetMarketPrice Get market price
func (t *AsterTrader) GetMarketPrice(symbol string) (float64, error) {
// Use ticker interface to get current price
resp, err := t.client.Get(fmt.Sprintf("%s/fapi/v3/ticker/price?symbol=%s", t.baseURL, symbol))
if err != nil {
return 0, err
}
defer resp.Body.Close()
body, _ := io.ReadAll(resp.Body)
if resp.StatusCode != http.StatusOK {
return 0, fmt.Errorf("HTTP %d: %s", resp.StatusCode, string(body))
}
var result map[string]interface{}
if err := json.Unmarshal(body, &result); err != nil {
return 0, err
}
priceStr, ok := result["price"].(string)
if !ok {
return 0, errors.New("unable to get price")
}
return strconv.ParseFloat(priceStr, 64)
}
// GetClosedPnL gets recent closing trades from Aster
// Note: Aster does NOT have a position history API, only trade history.
// This returns individual closing trades for real-time position closure detection.
func (t *AsterTrader) GetClosedPnL(startTime time.Time, limit int) ([]types.ClosedPnLRecord, error) {
trades, err := t.GetTrades(startTime, limit)
if err != nil {
return nil, err
}
// Filter only closing trades (realizedPnl != 0)
var records []types.ClosedPnLRecord
for _, trade := range trades {
if trade.RealizedPnL == 0 {
continue
}
// Determine side from PositionSide or trade direction
side := "long"
if trade.PositionSide == "SHORT" || trade.PositionSide == "short" {
side = "short"
} else if trade.PositionSide == "BOTH" || trade.PositionSide == "" {
if trade.Side == "SELL" || trade.Side == "Sell" {
side = "long"
} else {
side = "short"
}
}
// Calculate entry price from PnL
var entryPrice float64
if trade.Quantity > 0 {
if side == "long" {
entryPrice = trade.Price - trade.RealizedPnL/trade.Quantity
} else {
entryPrice = trade.Price + trade.RealizedPnL/trade.Quantity
}
}
records = append(records, types.ClosedPnLRecord{
Symbol: trade.Symbol,
Side: side,
EntryPrice: entryPrice,
ExitPrice: trade.Price,
Quantity: trade.Quantity,
RealizedPnL: trade.RealizedPnL,
Fee: trade.Fee,
ExitTime: trade.Time,
EntryTime: trade.Time,
OrderID: trade.TradeID,
ExchangeID: trade.TradeID,
CloseType: "unknown",
})
}
return records, nil
}
// AsterTradeRecord represents a trade from Aster API
type AsterTradeRecord struct {
ID int64 `json:"id"`
Symbol string `json:"symbol"`
OrderID int64 `json:"orderId"`
Side string `json:"side"` // BUY or SELL
PositionSide string `json:"positionSide"` // LONG or SHORT
Price string `json:"price"`
Qty string `json:"qty"`
RealizedPnl string `json:"realizedPnl"`
Commission string `json:"commission"`
Time int64 `json:"time"`
Buyer bool `json:"buyer"`
Maker bool `json:"maker"`
}
// GetTrades retrieves trade history from Aster
func (t *AsterTrader) GetTrades(startTime time.Time, limit int) ([]types.TradeRecord, error) {
if limit <= 0 {
limit = 500
}
// Build request params
params := map[string]interface{}{
"startTime": startTime.UnixMilli(),
"limit": limit,
}
// Use existing request method with signing
body, err := t.request("GET", "/fapi/v3/userTrades", params)
if err != nil {
logger.Infof("⚠️ Aster userTrades API error: %v", err)
return []types.TradeRecord{}, nil
}
var asterTrades []AsterTradeRecord
if err := json.Unmarshal(body, &asterTrades); err != nil {
logger.Infof("⚠️ Failed to parse Aster trades response: %v", err)
return []types.TradeRecord{}, nil
}
// Convert to unified TradeRecord format
var result []types.TradeRecord
for _, at := range asterTrades {
price, _ := strconv.ParseFloat(at.Price, 64)
qty, _ := strconv.ParseFloat(at.Qty, 64)
fee, _ := strconv.ParseFloat(at.Commission, 64)
pnl, _ := strconv.ParseFloat(at.RealizedPnl, 64)
trade := types.TradeRecord{
TradeID: strconv.FormatInt(at.ID, 10),
Symbol: at.Symbol,
Side: at.Side,
PositionSide: at.PositionSide,
Price: price,
Quantity: qty,
RealizedPnL: pnl,
Fee: fee,
Time: time.UnixMilli(at.Time).UTC(),
}
result = append(result, trade)
}
return result, nil
}
// GetOrderBook gets the order book for a symbol
func (t *AsterTrader) GetOrderBook(symbol string, depth int) (bids, asks [][]float64, err error) {
if depth <= 0 {
depth = 20
}
// Aster uses public endpoint (no signature required)
resp, err := t.client.Get(fmt.Sprintf("%s/fapi/v3/depth?symbol=%s&limit=%d", t.baseURL, symbol, depth))
if err != nil {
return nil, nil, fmt.Errorf("failed to fetch order book: %w", err)
}
defer resp.Body.Close()
body, _ := io.ReadAll(resp.Body)
if resp.StatusCode != http.StatusOK {
return nil, nil, fmt.Errorf("HTTP %d: %s", resp.StatusCode, string(body))
}
var result struct {
Bids [][]string `json:"bids"` // [[price, qty], ...]
Asks [][]string `json:"asks"` // [[price, qty], ...]
}
if err := json.Unmarshal(body, &result); err != nil {
return nil, nil, fmt.Errorf("failed to parse order book: %w", err)
}
// Convert string arrays to float64 arrays
bids = make([][]float64, len(result.Bids))
for i, bid := range result.Bids {
if len(bid) >= 2 {
price, _ := strconv.ParseFloat(bid[0], 64)
qty, _ := strconv.ParseFloat(bid[1], 64)
bids[i] = []float64{price, qty}
}
}
asks = make([][]float64, len(result.Asks))
for i, ask := range result.Asks {
if len(ask) >= 2 {
price, _ := strconv.ParseFloat(ask[0], 64)
qty, _ := strconv.ParseFloat(ask[1], 64)
asks[i] = []float64{price, qty}
}
}
return bids, asks, nil
}

View File

@@ -0,0 +1,787 @@
package aster
import (
"encoding/json"
"fmt"
"nofx/logger"
"nofx/trader/types"
"strconv"
"strings"
)
// OpenLong Open long position
func (t *AsterTrader) OpenLong(symbol string, quantity float64, leverage int) (map[string]interface{}, error) {
// Cancel all pending orders before opening position to prevent position stacking from residual orders
if err := t.CancelAllOrders(symbol); err != nil {
logger.Infof(" ⚠ Failed to cancel pending orders (continuing to open position): %v", err)
}
// Set leverage first (non-fatal if position already exists)
if err := t.SetLeverage(symbol, leverage); err != nil {
// Error -2030: Cannot adjust leverage when position exists
// This is expected when adding to an existing position, continue with current leverage
if strings.Contains(err.Error(), "-2030") {
logger.Infof(" ⚠ Cannot change leverage (position exists), using current leverage: %v", err)
} else {
return nil, fmt.Errorf("failed to set leverage: %w", err)
}
}
// Get current price
price, err := t.GetMarketPrice(symbol)
if err != nil {
return nil, err
}
// Use limit order to simulate market order (price set slightly higher to ensure execution)
limitPrice := price * 1.01
// Format price and quantity to correct precision
formattedPrice, err := t.formatPrice(symbol, limitPrice)
if err != nil {
return nil, err
}
formattedQty, err := t.formatQuantity(symbol, quantity)
if err != nil {
return nil, err
}
// Get precision information
prec, err := t.getPrecision(symbol)
if err != nil {
return nil, err
}
// Convert to string with correct precision format
priceStr := t.formatFloatWithPrecision(formattedPrice, prec.PricePrecision)
qtyStr := t.formatFloatWithPrecision(formattedQty, prec.QuantityPrecision)
logger.Infof(" 📏 Precision handling: price %.8f -> %s (precision=%d), quantity %.8f -> %s (precision=%d)",
limitPrice, priceStr, prec.PricePrecision, quantity, qtyStr, prec.QuantityPrecision)
params := map[string]interface{}{
"symbol": symbol,
"positionSide": "BOTH",
"type": "LIMIT",
"side": "BUY",
"timeInForce": "GTC",
"quantity": qtyStr,
"price": priceStr,
}
body, err := t.request("POST", "/fapi/v3/order", params)
if err != nil {
return nil, err
}
var result map[string]interface{}
if err := json.Unmarshal(body, &result); err != nil {
return nil, err
}
return result, nil
}
// OpenShort Open short position
func (t *AsterTrader) OpenShort(symbol string, quantity float64, leverage int) (map[string]interface{}, error) {
// Cancel all pending orders before opening position to prevent position stacking from residual orders
if err := t.CancelAllOrders(symbol); err != nil {
logger.Infof(" ⚠ Failed to cancel pending orders (continuing to open position): %v", err)
}
// Set leverage first (non-fatal if position already exists)
if err := t.SetLeverage(symbol, leverage); err != nil {
// Error -2030: Cannot adjust leverage when position exists
// This is expected when adding to an existing position, continue with current leverage
if strings.Contains(err.Error(), "-2030") {
logger.Infof(" ⚠ Cannot change leverage (position exists), using current leverage: %v", err)
} else {
return nil, fmt.Errorf("failed to set leverage: %w", err)
}
}
// Get current price
price, err := t.GetMarketPrice(symbol)
if err != nil {
return nil, err
}
// Use limit order to simulate market order (price set slightly lower to ensure execution)
limitPrice := price * 0.99
// Format price and quantity to correct precision
formattedPrice, err := t.formatPrice(symbol, limitPrice)
if err != nil {
return nil, err
}
formattedQty, err := t.formatQuantity(symbol, quantity)
if err != nil {
return nil, err
}
// Get precision information
prec, err := t.getPrecision(symbol)
if err != nil {
return nil, err
}
// Convert to string with correct precision format
priceStr := t.formatFloatWithPrecision(formattedPrice, prec.PricePrecision)
qtyStr := t.formatFloatWithPrecision(formattedQty, prec.QuantityPrecision)
logger.Infof(" 📏 Precision handling: price %.8f -> %s (precision=%d), quantity %.8f -> %s (precision=%d)",
limitPrice, priceStr, prec.PricePrecision, quantity, qtyStr, prec.QuantityPrecision)
params := map[string]interface{}{
"symbol": symbol,
"positionSide": "BOTH",
"type": "LIMIT",
"side": "SELL",
"timeInForce": "GTC",
"quantity": qtyStr,
"price": priceStr,
}
body, err := t.request("POST", "/fapi/v3/order", params)
if err != nil {
return nil, err
}
var result map[string]interface{}
if err := json.Unmarshal(body, &result); err != nil {
return nil, err
}
return result, nil
}
// CloseLong Close long position
func (t *AsterTrader) CloseLong(symbol string, quantity float64) (map[string]interface{}, error) {
// If quantity is 0, get current position quantity
if quantity == 0 {
positions, err := t.GetPositions()
if err != nil {
return nil, err
}
for _, pos := range positions {
if pos["symbol"] == symbol && pos["side"] == "long" {
quantity = pos["positionAmt"].(float64)
break
}
}
if quantity == 0 {
return nil, fmt.Errorf("no long position found for %s", symbol)
}
logger.Infof(" 📊 Retrieved long position quantity: %.8f", quantity)
}
price, err := t.GetMarketPrice(symbol)
if err != nil {
return nil, err
}
limitPrice := price * 0.99
// Format price and quantity to correct precision
formattedPrice, err := t.formatPrice(symbol, limitPrice)
if err != nil {
return nil, err
}
formattedQty, err := t.formatQuantity(symbol, quantity)
if err != nil {
return nil, err
}
// Get precision information
prec, err := t.getPrecision(symbol)
if err != nil {
return nil, err
}
// Convert to string with correct precision format
priceStr := t.formatFloatWithPrecision(formattedPrice, prec.PricePrecision)
qtyStr := t.formatFloatWithPrecision(formattedQty, prec.QuantityPrecision)
logger.Infof(" 📏 Precision handling: price %.8f -> %s (precision=%d), quantity %.8f -> %s (precision=%d)",
limitPrice, priceStr, prec.PricePrecision, quantity, qtyStr, prec.QuantityPrecision)
params := map[string]interface{}{
"symbol": symbol,
"positionSide": "BOTH",
"type": "LIMIT",
"side": "SELL",
"timeInForce": "GTC",
"quantity": qtyStr,
"price": priceStr,
}
body, err := t.request("POST", "/fapi/v3/order", params)
if err != nil {
return nil, err
}
var result map[string]interface{}
if err := json.Unmarshal(body, &result); err != nil {
return nil, err
}
logger.Infof("✓ Successfully closed long position: %s quantity: %s", symbol, qtyStr)
// Cancel all pending orders for this symbol after closing position (stop-loss/take-profit orders)
if err := t.CancelAllOrders(symbol); err != nil {
logger.Infof(" ⚠ Failed to cancel pending orders: %v", err)
}
return result, nil
}
// CloseShort Close short position
func (t *AsterTrader) CloseShort(symbol string, quantity float64) (map[string]interface{}, error) {
// If quantity is 0, get current position quantity
if quantity == 0 {
positions, err := t.GetPositions()
if err != nil {
return nil, err
}
for _, pos := range positions {
if pos["symbol"] == symbol && pos["side"] == "short" {
// Aster's GetPositions has already converted short position quantity to positive, use directly
quantity = pos["positionAmt"].(float64)
break
}
}
if quantity == 0 {
return nil, fmt.Errorf("no short position found for %s", symbol)
}
logger.Infof(" 📊 Retrieved short position quantity: %.8f", quantity)
}
price, err := t.GetMarketPrice(symbol)
if err != nil {
return nil, err
}
limitPrice := price * 1.01
// Format price and quantity to correct precision
formattedPrice, err := t.formatPrice(symbol, limitPrice)
if err != nil {
return nil, err
}
formattedQty, err := t.formatQuantity(symbol, quantity)
if err != nil {
return nil, err
}
// Get precision information
prec, err := t.getPrecision(symbol)
if err != nil {
return nil, err
}
// Convert to string with correct precision format
priceStr := t.formatFloatWithPrecision(formattedPrice, prec.PricePrecision)
qtyStr := t.formatFloatWithPrecision(formattedQty, prec.QuantityPrecision)
logger.Infof(" 📏 Precision handling: price %.8f -> %s (precision=%d), quantity %.8f -> %s (precision=%d)",
limitPrice, priceStr, prec.PricePrecision, quantity, qtyStr, prec.QuantityPrecision)
params := map[string]interface{}{
"symbol": symbol,
"positionSide": "BOTH",
"type": "LIMIT",
"side": "BUY",
"timeInForce": "GTC",
"quantity": qtyStr,
"price": priceStr,
}
body, err := t.request("POST", "/fapi/v3/order", params)
if err != nil {
return nil, err
}
var result map[string]interface{}
if err := json.Unmarshal(body, &result); err != nil {
return nil, err
}
logger.Infof("✓ Successfully closed short position: %s quantity: %s", symbol, qtyStr)
// Cancel all pending orders for this symbol after closing position (stop-loss/take-profit orders)
if err := t.CancelAllOrders(symbol); err != nil {
logger.Infof(" ⚠ Failed to cancel pending orders: %v", err)
}
return result, nil
}
// SetStopLoss Set stop loss
func (t *AsterTrader) SetStopLoss(symbol string, positionSide string, quantity, stopPrice float64) error {
side := "SELL"
if positionSide == "SHORT" {
side = "BUY"
}
// Format price and quantity to correct precision
formattedPrice, err := t.formatPrice(symbol, stopPrice)
if err != nil {
return err
}
formattedQty, err := t.formatQuantity(symbol, quantity)
if err != nil {
return err
}
// Get precision information
prec, err := t.getPrecision(symbol)
if err != nil {
return err
}
// Convert to string with correct precision format
priceStr := t.formatFloatWithPrecision(formattedPrice, prec.PricePrecision)
qtyStr := t.formatFloatWithPrecision(formattedQty, prec.QuantityPrecision)
params := map[string]interface{}{
"symbol": symbol,
"positionSide": "BOTH",
"type": "STOP_MARKET",
"side": side,
"stopPrice": priceStr,
"quantity": qtyStr,
"timeInForce": "GTC",
}
_, err = t.request("POST", "/fapi/v3/order", params)
return err
}
// SetTakeProfit Set take profit
func (t *AsterTrader) SetTakeProfit(symbol string, positionSide string, quantity, takeProfitPrice float64) error {
side := "SELL"
if positionSide == "SHORT" {
side = "BUY"
}
// Format price and quantity to correct precision
formattedPrice, err := t.formatPrice(symbol, takeProfitPrice)
if err != nil {
return err
}
formattedQty, err := t.formatQuantity(symbol, quantity)
if err != nil {
return err
}
// Get precision information
prec, err := t.getPrecision(symbol)
if err != nil {
return err
}
// Convert to string with correct precision format
priceStr := t.formatFloatWithPrecision(formattedPrice, prec.PricePrecision)
qtyStr := t.formatFloatWithPrecision(formattedQty, prec.QuantityPrecision)
params := map[string]interface{}{
"symbol": symbol,
"positionSide": "BOTH",
"type": "TAKE_PROFIT_MARKET",
"side": side,
"stopPrice": priceStr,
"quantity": qtyStr,
"timeInForce": "GTC",
}
_, err = t.request("POST", "/fapi/v3/order", params)
return err
}
// CancelStopLossOrders Cancel stop-loss orders only (does not affect take-profit orders)
func (t *AsterTrader) CancelStopLossOrders(symbol string) error {
// Get all open orders for this symbol
params := map[string]interface{}{
"symbol": symbol,
}
body, err := t.request("GET", "/fapi/v3/openOrders", params)
if err != nil {
return fmt.Errorf("failed to get open orders: %w", err)
}
var orders []map[string]interface{}
if err := json.Unmarshal(body, &orders); err != nil {
return fmt.Errorf("failed to parse order data: %w", err)
}
// Filter and cancel stop-loss orders (cancel all directions including LONG and SHORT)
canceledCount := 0
var cancelErrors []error
for _, order := range orders {
orderType, _ := order["type"].(string)
// Only cancel stop-loss orders (don't cancel take-profit orders)
if orderType == "STOP_MARKET" || orderType == "STOP" {
orderID, _ := order["orderId"].(float64)
positionSide, _ := order["positionSide"].(string)
cancelParams := map[string]interface{}{
"symbol": symbol,
"orderId": int64(orderID),
}
_, err := t.request("DELETE", "/fapi/v1/order", cancelParams)
if err != nil {
errMsg := fmt.Sprintf("order ID %d: %v", int64(orderID), err)
cancelErrors = append(cancelErrors, fmt.Errorf("%s", errMsg))
logger.Infof(" ⚠ Failed to cancel stop-loss order: %s", errMsg)
continue
}
canceledCount++
logger.Infof(" ✓ Canceled stop-loss order (order ID: %d, type: %s, direction: %s)", int64(orderID), orderType, positionSide)
}
}
if canceledCount == 0 && len(cancelErrors) == 0 {
logger.Infof(" %s no stop-loss orders to cancel", symbol)
} else if canceledCount > 0 {
logger.Infof(" ✓ Canceled %d stop-loss order(s) for %s", canceledCount, symbol)
}
// Return error if all cancellations failed
if len(cancelErrors) > 0 && canceledCount == 0 {
return fmt.Errorf("failed to cancel stop-loss orders: %v", cancelErrors)
}
return nil
}
// CancelTakeProfitOrders Cancel take-profit orders only (does not affect stop-loss orders)
func (t *AsterTrader) CancelTakeProfitOrders(symbol string) error {
// Get all open orders for this symbol
params := map[string]interface{}{
"symbol": symbol,
}
body, err := t.request("GET", "/fapi/v3/openOrders", params)
if err != nil {
return fmt.Errorf("failed to get open orders: %w", err)
}
var orders []map[string]interface{}
if err := json.Unmarshal(body, &orders); err != nil {
return fmt.Errorf("failed to parse order data: %w", err)
}
// Filter and cancel take-profit orders (cancel all directions including LONG and SHORT)
canceledCount := 0
var cancelErrors []error
for _, order := range orders {
orderType, _ := order["type"].(string)
// Only cancel take-profit orders (don't cancel stop-loss orders)
if orderType == "TAKE_PROFIT_MARKET" || orderType == "TAKE_PROFIT" {
orderID, _ := order["orderId"].(float64)
positionSide, _ := order["positionSide"].(string)
cancelParams := map[string]interface{}{
"symbol": symbol,
"orderId": int64(orderID),
}
_, err := t.request("DELETE", "/fapi/v1/order", cancelParams)
if err != nil {
errMsg := fmt.Sprintf("order ID %d: %v", int64(orderID), err)
cancelErrors = append(cancelErrors, fmt.Errorf("%s", errMsg))
logger.Infof(" ⚠ Failed to cancel take-profit order: %s", errMsg)
continue
}
canceledCount++
logger.Infof(" ✓ Canceled take-profit order (order ID: %d, type: %s, direction: %s)", int64(orderID), orderType, positionSide)
}
}
if canceledCount == 0 && len(cancelErrors) == 0 {
logger.Infof(" %s no take-profit orders to cancel", symbol)
} else if canceledCount > 0 {
logger.Infof(" ✓ Canceled %d take-profit order(s) for %s", canceledCount, symbol)
}
// Return error if all cancellations failed
if len(cancelErrors) > 0 && canceledCount == 0 {
return fmt.Errorf("failed to cancel take-profit orders: %v", cancelErrors)
}
return nil
}
// CancelAllOrders Cancel all orders
func (t *AsterTrader) CancelAllOrders(symbol string) error {
params := map[string]interface{}{
"symbol": symbol,
}
_, err := t.request("DELETE", "/fapi/v3/allOpenOrders", params)
return err
}
// CancelStopOrders Cancel take-profit/stop-loss orders for this symbol (used to adjust TP/SL positions)
func (t *AsterTrader) CancelStopOrders(symbol string) error {
// Get all open orders for this symbol
params := map[string]interface{}{
"symbol": symbol,
}
body, err := t.request("GET", "/fapi/v3/openOrders", params)
if err != nil {
return fmt.Errorf("failed to get open orders: %w", err)
}
var orders []map[string]interface{}
if err := json.Unmarshal(body, &orders); err != nil {
return fmt.Errorf("failed to parse order data: %w", err)
}
// Filter and cancel take-profit/stop-loss orders
canceledCount := 0
for _, order := range orders {
orderType, _ := order["type"].(string)
// Only cancel stop-loss and take-profit orders
if orderType == "STOP_MARKET" ||
orderType == "TAKE_PROFIT_MARKET" ||
orderType == "STOP" ||
orderType == "TAKE_PROFIT" {
orderID, _ := order["orderId"].(float64)
cancelParams := map[string]interface{}{
"symbol": symbol,
"orderId": int64(orderID),
}
_, err := t.request("DELETE", "/fapi/v3/order", cancelParams)
if err != nil {
logger.Infof(" ⚠ Failed to cancel order %d: %v", int64(orderID), err)
continue
}
canceledCount++
logger.Infof(" ✓ Canceled take-profit/stop-loss order for %s (order ID: %d, type: %s)",
symbol, int64(orderID), orderType)
}
}
if canceledCount == 0 {
logger.Infof(" %s no take-profit/stop-loss orders to cancel", symbol)
} else {
logger.Infof(" ✓ Canceled %d take-profit/stop-loss order(s) for %s", canceledCount, symbol)
}
return nil
}
// FormatQuantity Format quantity (implements Trader interface)
func (t *AsterTrader) FormatQuantity(symbol string, quantity float64) (string, error) {
formatted, err := t.formatQuantity(symbol, quantity)
if err != nil {
return "", err
}
return fmt.Sprintf("%v", formatted), nil
}
// GetOrderStatus Get order status
func (t *AsterTrader) GetOrderStatus(symbol string, orderID string) (map[string]interface{}, error) {
params := map[string]interface{}{
"symbol": symbol,
"orderId": orderID,
}
body, err := t.request("GET", "/fapi/v3/order", params)
if err != nil {
return nil, fmt.Errorf("failed to get order status: %w", err)
}
var result map[string]interface{}
if err := json.Unmarshal(body, &result); err != nil {
return nil, fmt.Errorf("failed to parse order response: %w", err)
}
// Standardize return fields
response := map[string]interface{}{
"orderId": result["orderId"],
"symbol": result["symbol"],
"status": result["status"],
"side": result["side"],
"type": result["type"],
"time": result["time"],
"updateTime": result["updateTime"],
"commission": 0.0, // Aster may require separate query
}
// Parse numeric fields
if avgPrice, ok := result["avgPrice"].(string); ok {
if v, err := strconv.ParseFloat(avgPrice, 64); err == nil {
response["avgPrice"] = v
}
} else if avgPrice, ok := result["avgPrice"].(float64); ok {
response["avgPrice"] = avgPrice
}
if executedQty, ok := result["executedQty"].(string); ok {
if v, err := strconv.ParseFloat(executedQty, 64); err == nil {
response["executedQty"] = v
}
} else if executedQty, ok := result["executedQty"].(float64); ok {
response["executedQty"] = executedQty
}
return response, nil
}
// GetOpenOrders gets all open/pending orders for a symbol
func (t *AsterTrader) GetOpenOrders(symbol string) ([]types.OpenOrder, error) {
params := map[string]interface{}{
"symbol": symbol,
}
body, err := t.request("GET", "/fapi/v3/openOrders", params)
if err != nil {
return nil, fmt.Errorf("failed to get open orders: %w", err)
}
var orders []struct {
OrderID int64 `json:"orderId"`
Symbol string `json:"symbol"`
Side string `json:"side"`
PositionSide string `json:"positionSide"`
Type string `json:"type"`
Price string `json:"price"`
StopPrice string `json:"stopPrice"`
OrigQty string `json:"origQty"`
Status string `json:"status"`
}
if err := json.Unmarshal(body, &orders); err != nil {
return nil, fmt.Errorf("failed to parse open orders: %w", err)
}
var result []types.OpenOrder
for _, order := range orders {
price, _ := strconv.ParseFloat(order.Price, 64)
stopPrice, _ := strconv.ParseFloat(order.StopPrice, 64)
quantity, _ := strconv.ParseFloat(order.OrigQty, 64)
result = append(result, types.OpenOrder{
OrderID: fmt.Sprintf("%d", order.OrderID),
Symbol: order.Symbol,
Side: order.Side,
PositionSide: order.PositionSide,
Type: order.Type,
Price: price,
StopPrice: stopPrice,
Quantity: quantity,
Status: order.Status,
})
}
logger.Infof("✓ ASTER GetOpenOrders: found %d open orders for %s", len(result), symbol)
return result, nil
}
// PlaceLimitOrder places a limit order for grid trading
func (t *AsterTrader) PlaceLimitOrder(req *types.LimitOrderRequest) (*types.LimitOrderResult, error) {
// Format price and quantity to correct precision
formattedPrice, err := t.formatPrice(req.Symbol, req.Price)
if err != nil {
return nil, fmt.Errorf("failed to format price: %w", err)
}
formattedQty, err := t.formatQuantity(req.Symbol, req.Quantity)
if err != nil {
return nil, fmt.Errorf("failed to format quantity: %w", err)
}
// Get precision information
prec, err := t.getPrecision(req.Symbol)
if err != nil {
return nil, fmt.Errorf("failed to get precision: %w", err)
}
// Convert to string with correct precision format
priceStr := t.formatFloatWithPrecision(formattedPrice, prec.PricePrecision)
qtyStr := t.formatFloatWithPrecision(formattedQty, prec.QuantityPrecision)
// Determine side
side := "BUY"
if req.Side == "SELL" || req.Side == "Sell" || req.Side == "sell" {
side = "SELL"
}
params := map[string]interface{}{
"symbol": req.Symbol,
"positionSide": "BOTH",
"type": "LIMIT",
"side": side,
"timeInForce": "GTC",
"quantity": qtyStr,
"price": priceStr,
}
// Add reduceOnly if specified
if req.ReduceOnly {
params["reduceOnly"] = "true"
}
body, err := t.request("POST", "/fapi/v3/order", params)
if err != nil {
return nil, fmt.Errorf("failed to place limit order: %w", err)
}
var result map[string]interface{}
if err := json.Unmarshal(body, &result); err != nil {
return nil, fmt.Errorf("failed to parse order response: %w", err)
}
// Extract order ID
orderID := ""
if id, ok := result["orderId"].(float64); ok {
orderID = fmt.Sprintf("%.0f", id)
} else if id, ok := result["orderId"].(string); ok {
orderID = id
}
// Extract client order ID
clientOrderID := ""
if cid, ok := result["clientOrderId"].(string); ok {
clientOrderID = cid
}
return &types.LimitOrderResult{
OrderID: orderID,
ClientID: clientOrderID,
Symbol: req.Symbol,
Side: side,
Price: formattedPrice,
Quantity: formattedQty,
Status: "NEW",
}, nil
}
// CancelOrder cancels a specific order by order ID
func (t *AsterTrader) CancelOrder(symbol, orderID string) error {
params := map[string]interface{}{
"symbol": symbol,
"orderId": orderID,
}
_, err := t.request("DELETE", "/fapi/v3/order", params)
if err != nil {
return fmt.Errorf("failed to cancel order %s: %w", orderID, err)
}
return nil
}

View File

@@ -0,0 +1,121 @@
package aster
import (
"encoding/json"
"fmt"
"nofx/logger"
"strconv"
"strings"
)
// GetPositions Get position information
func (t *AsterTrader) GetPositions() ([]map[string]interface{}, error) {
params := make(map[string]interface{})
body, err := t.request("GET", "/fapi/v3/positionRisk", params)
if err != nil {
return nil, err
}
var positions []map[string]interface{}
if err := json.Unmarshal(body, &positions); err != nil {
return nil, err
}
result := []map[string]interface{}{}
for _, pos := range positions {
posAmtStr, ok := pos["positionAmt"].(string)
if !ok {
continue
}
posAmt, _ := strconv.ParseFloat(posAmtStr, 64)
if posAmt == 0 {
continue // Skip empty positions
}
entryPrice, _ := strconv.ParseFloat(pos["entryPrice"].(string), 64)
markPrice, _ := strconv.ParseFloat(pos["markPrice"].(string), 64)
unRealizedProfit, _ := strconv.ParseFloat(pos["unRealizedProfit"].(string), 64)
leverageVal, _ := strconv.ParseFloat(pos["leverage"].(string), 64)
liquidationPrice, _ := strconv.ParseFloat(pos["liquidationPrice"].(string), 64)
// Determine direction (consistent with Binance)
side := "long"
if posAmt < 0 {
side = "short"
posAmt = -posAmt
}
// Return same field names as Binance
result = append(result, map[string]interface{}{
"symbol": pos["symbol"],
"side": side,
"positionAmt": posAmt,
"entryPrice": entryPrice,
"markPrice": markPrice,
"unRealizedProfit": unRealizedProfit,
"leverage": leverageVal,
"liquidationPrice": liquidationPrice,
})
}
return result, nil
}
// SetMarginMode Set margin mode
func (t *AsterTrader) SetMarginMode(symbol string, isCrossMargin bool) error {
// Aster supports margin mode settings
// API format similar to Binance: CROSSED (cross margin) / ISOLATED (isolated margin)
marginType := "CROSSED"
if !isCrossMargin {
marginType = "ISOLATED"
}
params := map[string]interface{}{
"symbol": symbol,
"marginType": marginType,
}
// Use request method to call API
_, err := t.request("POST", "/fapi/v3/marginType", params)
if err != nil {
// Ignore error if it indicates no need to change
if strings.Contains(err.Error(), "No need to change") ||
strings.Contains(err.Error(), "Margin type cannot be changed") {
logger.Infof(" ✓ %s margin mode is already %s or cannot be changed due to existing positions", symbol, marginType)
return nil
}
// Detect multi-assets mode (error code -4168)
if strings.Contains(err.Error(), "Multi-Assets mode") ||
strings.Contains(err.Error(), "-4168") ||
strings.Contains(err.Error(), "4168") {
logger.Infof(" ⚠️ %s detected multi-assets mode, forcing cross margin mode", symbol)
logger.Infof(" 💡 Tip: To use isolated margin mode, please disable multi-assets mode on the exchange")
return nil
}
// Detect unified account API
if strings.Contains(err.Error(), "unified") ||
strings.Contains(err.Error(), "portfolio") ||
strings.Contains(err.Error(), "Portfolio") {
logger.Infof(" ❌ %s detected unified account API, cannot perform futures trading", symbol)
return fmt.Errorf("please use 'Spot & Futures Trading' API permission, not 'Unified Account API'")
}
logger.Infof(" ⚠️ Failed to set margin mode: %v", err)
// Don't return error, let trading continue
return nil
}
logger.Infof(" ✓ %s margin mode has been set to %s", symbol, marginType)
return nil
}
// SetLeverage Set leverage multiplier
func (t *AsterTrader) SetLeverage(symbol string, leverage int) error {
params := map[string]interface{}{
"symbol": symbol,
"leverage": leverage,
}
_, err := t.request("POST", "/fapi/v3/leverage", params)
return err
}

View File

@@ -3,7 +3,7 @@ package trader
import ( import (
"fmt" "fmt"
"math" "math"
"nofx/experience" "nofx/telemetry"
"nofx/kernel" "nofx/kernel"
"nofx/logger" "nofx/logger"
"nofx/market" "nofx/market"
@@ -345,7 +345,7 @@ func (at *AutoTrader) recordAndConfirmOrder(orderResult map[string]interface{},
// Send anonymous trade statistics for experience improvement (async, non-blocking) // Send anonymous trade statistics for experience improvement (async, non-blocking)
// This helps us understand overall product usage across all deployments // This helps us understand overall product usage across all deployments
experience.TrackTrade(experience.TradeEvent{ telemetry.TrackTrade(telemetry.TradeEvent{
Exchange: at.exchange, Exchange: at.exchange,
TradeType: action, TradeType: action,
Symbol: symbol, Symbol: symbol,

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,485 @@
package trader
import (
"math"
"nofx/kernel"
"nofx/logger"
"nofx/market"
"nofx/store"
)
// ============================================================================
// Grid Level Calculation and Rebalancing
// ============================================================================
// calculateDefaultBounds calculates default bounds based on price
func (at *AutoTrader) calculateDefaultBounds(price float64, config *store.GridStrategyConfig) {
// Default: +/-3% from current price
multiplier := 0.03 * float64(config.GridCount) / 10
at.gridState.UpperPrice = price * (1 + multiplier)
at.gridState.LowerPrice = price * (1 - multiplier)
}
// calculateATRBounds calculates bounds using ATR
func (at *AutoTrader) calculateATRBounds(price float64, mktData *market.Data, config *store.GridStrategyConfig) {
atr := 0.0
if mktData.LongerTermContext != nil {
atr = mktData.LongerTermContext.ATR14
}
if atr <= 0 {
at.calculateDefaultBounds(price, config)
return
}
multiplier := config.ATRMultiplier
if multiplier <= 0 {
multiplier = 2.0
}
halfRange := atr * multiplier
at.gridState.UpperPrice = price + halfRange
at.gridState.LowerPrice = price - halfRange
}
// initializeGridLevels creates the grid level structure
func (at *AutoTrader) initializeGridLevels(currentPrice float64, config *store.GridStrategyConfig) {
levels := make([]kernel.GridLevelInfo, config.GridCount)
totalWeight := 0.0
weights := make([]float64, config.GridCount)
// Calculate weights based on distribution
for i := 0; i < config.GridCount; i++ {
switch config.Distribution {
case "gaussian":
// Gaussian distribution - more weight in the middle
center := float64(config.GridCount-1) / 2
sigma := float64(config.GridCount) / 4
weights[i] = math.Exp(-math.Pow(float64(i)-center, 2) / (2 * sigma * sigma))
case "pyramid":
// Pyramid - more weight at bottom
weights[i] = float64(config.GridCount - i)
default: // uniform
weights[i] = 1.0
}
totalWeight += weights[i]
}
// Create levels
for i := 0; i < config.GridCount; i++ {
price := at.gridState.LowerPrice + float64(i)*at.gridState.GridSpacing
allocatedUSD := config.TotalInvestment * weights[i] / totalWeight
// Determine initial side (below current price = buy, above = sell)
side := "buy"
if price > currentPrice {
side = "sell"
}
levels[i] = kernel.GridLevelInfo{
Index: i,
Price: price,
State: "empty",
Side: side,
AllocatedUSD: allocatedUSD,
}
}
at.gridState.Levels = levels
// Apply direction-based side assignment if enabled
if config.EnableDirectionAdjust {
at.applyGridDirection(currentPrice)
}
}
// applyGridDirection adjusts grid level sides based on the current direction
// This redistributes buy/sell levels according to the direction bias ratio
func (at *AutoTrader) applyGridDirection(currentPrice float64) {
config := at.gridState.Config
direction := at.gridState.CurrentDirection
// Get bias ratio from config, default to 0.7 (70%/30%)
biasRatio := config.DirectionBiasRatio
if biasRatio <= 0 || biasRatio > 1 {
biasRatio = 0.7
}
buyRatio, _ := direction.GetBuySellRatio(biasRatio)
// Calculate how many levels should be buy vs sell based on direction
totalLevels := len(at.gridState.Levels)
targetBuyLevels := int(float64(totalLevels) * buyRatio)
// For neutral: use price-based assignment (buy below, sell above)
if direction == market.GridDirectionNeutral {
for i := range at.gridState.Levels {
if at.gridState.Levels[i].Price <= currentPrice {
at.gridState.Levels[i].Side = "buy"
} else {
at.gridState.Levels[i].Side = "sell"
}
}
return
}
// For long/long_bias: more buy levels
// For short/short_bias: more sell levels
switch direction {
case market.GridDirectionLong:
// 100% buy - all levels are buy
for i := range at.gridState.Levels {
at.gridState.Levels[i].Side = "buy"
}
case market.GridDirectionShort:
// 100% sell - all levels are sell
for i := range at.gridState.Levels {
at.gridState.Levels[i].Side = "sell"
}
case market.GridDirectionLongBias, market.GridDirectionShortBias:
// Assign sides based on position relative to current price
// For long_bias: keep all below as buy, convert some above to buy
// For short_bias: keep all above as sell, convert some below to sell
buyCount := 0
sellCount := 0
for i := range at.gridState.Levels {
needMoreBuys := buyCount < targetBuyLevels
needMoreSells := sellCount < (totalLevels - targetBuyLevels)
if at.gridState.Levels[i].Price <= currentPrice {
// Level below or at current price
if needMoreBuys {
at.gridState.Levels[i].Side = "buy"
buyCount++
} else {
at.gridState.Levels[i].Side = "sell"
sellCount++
}
} else {
// Level above current price
if needMoreSells && direction == market.GridDirectionShortBias {
at.gridState.Levels[i].Side = "sell"
sellCount++
} else if needMoreBuys && direction == market.GridDirectionLongBias {
at.gridState.Levels[i].Side = "buy"
buyCount++
} else if needMoreSells {
at.gridState.Levels[i].Side = "sell"
sellCount++
} else {
at.gridState.Levels[i].Side = "buy"
buyCount++
}
}
}
}
logger.Infof("[Grid] Applied direction %s: buy_ratio=%.0f%%, levels reconfigured",
direction, buyRatio*100)
}
// checkGridSkew checks if grid is heavily skewed (too many fills on one side)
// Returns: (skewed bool, buyFilledCount int, sellFilledCount int)
func (at *AutoTrader) checkGridSkew() (bool, int, int) {
at.gridState.mu.RLock()
defer at.gridState.mu.RUnlock()
buyFilled := 0
sellFilled := 0
buyEmpty := 0
sellEmpty := 0
for _, level := range at.gridState.Levels {
if level.Side == "buy" {
if level.State == "filled" {
buyFilled++
} else if level.State == "empty" {
buyEmpty++
}
} else {
if level.State == "filled" {
sellFilled++
} else if level.State == "empty" {
sellEmpty++
}
}
}
// Grid is skewed if one side has 3x more fills than the other
// or if one side is completely empty
skewed := false
if buyFilled > 0 && sellFilled == 0 && sellEmpty > 5 {
skewed = true // All buys filled, no sells
} else if sellFilled > 0 && buyFilled == 0 && buyEmpty > 5 {
skewed = true // All sells filled, no buys
} else if buyFilled >= 3*sellFilled && buyFilled > 5 {
skewed = true
} else if sellFilled >= 3*buyFilled && sellFilled > 5 {
skewed = true
}
return skewed, buyFilled, sellFilled
}
// autoAdjustGrid automatically adjusts grid when heavily skewed
func (at *AutoTrader) autoAdjustGrid() {
skewed, buyFilled, sellFilled := at.checkGridSkew()
if !skewed {
return
}
logger.Warnf("[Grid] Grid heavily skewed: buy_filled=%d, sell_filled=%d. Auto-adjusting...",
buyFilled, sellFilled)
gridConfig := at.config.StrategyConfig.GridConfig
// Get current price
currentPrice, err := at.trader.GetMarketPrice(gridConfig.Symbol)
if err != nil {
logger.Errorf("[Grid] Failed to get price for auto-adjust: %v", err)
return
}
// Check if price is near grid boundary
at.gridState.mu.RLock()
upper := at.gridState.UpperPrice
lower := at.gridState.LowerPrice
at.gridState.mu.RUnlock()
// Only adjust if price has moved significantly (>30% of grid range)
gridRange := upper - lower
midPrice := (upper + lower) / 2
priceDeviation := math.Abs(currentPrice - midPrice)
if priceDeviation < gridRange*0.3 {
return // Price still near center, don't adjust
}
logger.Infof("[Grid] Adjusting grid around new price $%.2f", currentPrice)
// Cancel existing orders first (before taking the lock for state modification)
if err := at.cancelAllGridOrders(); err != nil {
logger.Errorf("[Grid] Failed to cancel orders during auto-adjust: %v", err)
// Continue with adjustment anyway
}
// CRITICAL FIX: Hold lock for the entire adjustment operation to ensure atomicity
at.gridState.mu.Lock()
defer at.gridState.mu.Unlock()
// Preserve filled positions before reinitializing
filledPositions := make(map[int]kernel.GridLevelInfo)
for i, level := range at.gridState.Levels {
if level.State == "filled" {
filledPositions[i] = level
}
}
// CRITICAL FIX: Recalculate grid bounds centered on current price
// Use the same logic as InitializeGrid() - either ATR-based or default percentage
if gridConfig.UseATRBounds {
// Try to get ATR for bound calculation
mktData, err := market.GetWithTimeframes(gridConfig.Symbol, []string{"4h"}, "4h", 20)
if err != nil {
logger.Warnf("[Grid] Failed to get market data for ATR during adjust: %v, using default bounds", err)
at.calculateDefaultBoundsLocked(currentPrice, gridConfig)
} else {
at.calculateATRBoundsLocked(currentPrice, mktData, gridConfig)
}
} else {
// Use default bounds calculation (scaled by grid count)
at.calculateDefaultBoundsLocked(currentPrice, gridConfig)
}
// Recalculate grid spacing based on new bounds
at.gridState.GridSpacing = (at.gridState.UpperPrice - at.gridState.LowerPrice) / float64(gridConfig.GridCount-1)
logger.Infof("[Grid] New bounds: $%.2f - $%.2f, spacing: $%.2f",
at.gridState.LowerPrice, at.gridState.UpperPrice, at.gridState.GridSpacing)
// Initialize new grid levels (without lock since we already hold it)
at.initializeGridLevelsLocked(currentPrice, gridConfig)
// CRITICAL FIX: Restore filled positions - find closest new level for each filled position
for _, filledLevel := range filledPositions {
closestIdx := -1
closestDist := math.MaxFloat64
for i, newLevel := range at.gridState.Levels {
dist := math.Abs(newLevel.Price - filledLevel.PositionEntry)
if dist < closestDist {
closestDist = dist
closestIdx = i
}
}
if closestIdx >= 0 {
// Restore the filled state to the closest level
at.gridState.Levels[closestIdx].State = "filled"
at.gridState.Levels[closestIdx].PositionEntry = filledLevel.PositionEntry
at.gridState.Levels[closestIdx].PositionSize = filledLevel.PositionSize
at.gridState.Levels[closestIdx].UnrealizedPnL = filledLevel.UnrealizedPnL
at.gridState.Levels[closestIdx].OrderID = filledLevel.OrderID
at.gridState.Levels[closestIdx].OrderQuantity = filledLevel.OrderQuantity
logger.Infof("[Grid] Restored filled position at level %d (entry $%.2f)", closestIdx, filledLevel.PositionEntry)
}
}
}
// calculateDefaultBoundsLocked calculates default bounds (caller must hold lock)
func (at *AutoTrader) calculateDefaultBoundsLocked(price float64, config *store.GridStrategyConfig) {
// Default: +/-3% from current price, scaled by grid count
multiplier := 0.03 * float64(config.GridCount) / 10
at.gridState.UpperPrice = price * (1 + multiplier)
at.gridState.LowerPrice = price * (1 - multiplier)
}
// calculateATRBoundsLocked calculates bounds using ATR (caller must hold lock)
func (at *AutoTrader) calculateATRBoundsLocked(price float64, mktData *market.Data, config *store.GridStrategyConfig) {
atr := 0.0
if mktData.LongerTermContext != nil {
atr = mktData.LongerTermContext.ATR14
}
if atr <= 0 {
at.calculateDefaultBoundsLocked(price, config)
return
}
multiplier := config.ATRMultiplier
if multiplier <= 0 {
multiplier = 2.0
}
halfRange := atr * multiplier
at.gridState.UpperPrice = price + halfRange
at.gridState.LowerPrice = price - halfRange
}
// initializeGridLevelsLocked creates the grid level structure (caller must hold lock)
func (at *AutoTrader) initializeGridLevelsLocked(currentPrice float64, config *store.GridStrategyConfig) {
levels := make([]kernel.GridLevelInfo, config.GridCount)
totalWeight := 0.0
weights := make([]float64, config.GridCount)
// Calculate weights based on distribution
for i := 0; i < config.GridCount; i++ {
switch config.Distribution {
case "gaussian":
// Gaussian distribution - more weight in the middle
center := float64(config.GridCount-1) / 2
sigma := float64(config.GridCount) / 4
weights[i] = math.Exp(-math.Pow(float64(i)-center, 2) / (2 * sigma * sigma))
case "pyramid":
// Pyramid - more weight at bottom
weights[i] = float64(config.GridCount - i)
default: // uniform
weights[i] = 1.0
}
totalWeight += weights[i]
}
// Create levels
for i := 0; i < config.GridCount; i++ {
price := at.gridState.LowerPrice + float64(i)*at.gridState.GridSpacing
allocatedUSD := config.TotalInvestment * weights[i] / totalWeight
// Determine initial side (below current price = buy, above = sell)
side := "buy"
if price > currentPrice {
side = "sell"
}
levels[i] = kernel.GridLevelInfo{
Index: i,
Price: price,
State: "empty",
Side: side,
AllocatedUSD: allocatedUSD,
}
}
at.gridState.Levels = levels
// Apply direction-based side assignment if enabled (note: caller holds lock)
if config.EnableDirectionAdjust {
at.applyGridDirectionLocked(currentPrice)
}
}
// applyGridDirectionLocked adjusts grid level sides based on the current direction (caller must hold lock)
func (at *AutoTrader) applyGridDirectionLocked(currentPrice float64) {
config := at.gridState.Config
direction := at.gridState.CurrentDirection
// Get bias ratio from config, default to 0.7 (70%/30%)
biasRatio := config.DirectionBiasRatio
if biasRatio <= 0 || biasRatio > 1 {
biasRatio = 0.7
}
buyRatio, _ := direction.GetBuySellRatio(biasRatio)
// For neutral: use price-based assignment (buy below, sell above)
if direction == market.GridDirectionNeutral {
for i := range at.gridState.Levels {
if at.gridState.Levels[i].Price <= currentPrice {
at.gridState.Levels[i].Side = "buy"
} else {
at.gridState.Levels[i].Side = "sell"
}
}
return
}
totalLevels := len(at.gridState.Levels)
targetBuyLevels := int(float64(totalLevels) * buyRatio)
switch direction {
case market.GridDirectionLong:
for i := range at.gridState.Levels {
at.gridState.Levels[i].Side = "buy"
}
case market.GridDirectionShort:
for i := range at.gridState.Levels {
at.gridState.Levels[i].Side = "sell"
}
case market.GridDirectionLongBias, market.GridDirectionShortBias:
buyCount := 0
sellCount := 0
for i := range at.gridState.Levels {
needMoreBuys := buyCount < targetBuyLevels
needMoreSells := sellCount < (totalLevels - targetBuyLevels)
if at.gridState.Levels[i].Price <= currentPrice {
if needMoreBuys {
at.gridState.Levels[i].Side = "buy"
buyCount++
} else {
at.gridState.Levels[i].Side = "sell"
sellCount++
}
} else {
if needMoreSells && direction == market.GridDirectionShortBias {
at.gridState.Levels[i].Side = "sell"
sellCount++
} else if needMoreBuys && direction == market.GridDirectionLongBias {
at.gridState.Levels[i].Side = "buy"
buyCount++
} else if needMoreSells {
at.gridState.Levels[i].Side = "sell"
sellCount++
} else {
at.gridState.Levels[i].Side = "buy"
buyCount++
}
}
}
}
}

View File

@@ -0,0 +1,419 @@
package trader
import (
"fmt"
"math"
"nofx/kernel"
"nofx/logger"
"time"
)
// ============================================================================
// Grid Order Placement and Management
// ============================================================================
// checkTotalPositionLimit checks if adding a new position would exceed total limits
// Returns: (allowed bool, currentPositionValue float64, maxAllowed float64)
func (at *AutoTrader) checkTotalPositionLimit(symbol string, additionalValue float64) (bool, float64, float64) {
gridConfig := at.config.StrategyConfig.GridConfig
// Calculate max allowed total position value
// Total position should not exceed: TotalInvestment * Leverage
maxTotalPositionValue := gridConfig.TotalInvestment * float64(gridConfig.Leverage)
// Get current position value from exchange
currentPositionValue := 0.0
positions, err := at.trader.GetPositions()
if err == nil {
for _, pos := range positions {
if sym, ok := pos["symbol"].(string); ok && sym == symbol {
if size, ok := pos["positionAmt"].(float64); ok {
if price, ok := pos["markPrice"].(float64); ok {
currentPositionValue = math.Abs(size) * price
} else if entryPrice, ok := pos["entryPrice"].(float64); ok {
currentPositionValue = math.Abs(size) * entryPrice
}
}
}
}
}
// Also count pending orders as potential position
at.gridState.mu.RLock()
pendingValue := 0.0
for _, level := range at.gridState.Levels {
if level.State == "pending" {
pendingValue += level.OrderQuantity * level.Price
}
}
at.gridState.mu.RUnlock()
totalAfterOrder := currentPositionValue + pendingValue + additionalValue
allowed := totalAfterOrder <= maxTotalPositionValue
return allowed, currentPositionValue + pendingValue, maxTotalPositionValue
}
// placeGridLimitOrder places a limit order for grid trading
func (at *AutoTrader) placeGridLimitOrder(d *kernel.Decision, side string) error {
// Check if trader supports GridTrader interface
gridTrader, ok := at.trader.(GridTrader)
if !ok {
// Fallback to adapter
gridTrader = NewGridTraderAdapter(at.trader)
}
gridConfig := at.config.StrategyConfig.GridConfig
// CRITICAL: Validate and cap quantity to prevent excessive position sizes
// This protects against AI miscalculations or leverage misconfigurations
quantity := d.Quantity
if d.Price > 0 && gridConfig.TotalInvestment > 0 {
// Calculate max allowed position value per grid level
// Each level gets proportional share of total investment
maxMarginPerLevel := gridConfig.TotalInvestment / float64(gridConfig.GridCount)
maxPositionValuePerLevel := maxMarginPerLevel * float64(gridConfig.Leverage)
maxQuantityPerLevel := maxPositionValuePerLevel / d.Price
// Also get the level's allocated USD for additional validation
at.gridState.mu.RLock()
var levelAllocatedUSD float64
if d.LevelIndex >= 0 && d.LevelIndex < len(at.gridState.Levels) {
levelAllocatedUSD = at.gridState.Levels[d.LevelIndex].AllocatedUSD
}
at.gridState.mu.RUnlock()
// Use level-specific allocation if available
if levelAllocatedUSD > 0 {
levelMaxPositionValue := levelAllocatedUSD * float64(gridConfig.Leverage)
levelMaxQuantity := levelMaxPositionValue / d.Price
if levelMaxQuantity < maxQuantityPerLevel {
maxQuantityPerLevel = levelMaxQuantity
}
}
// Cap quantity if it exceeds the maximum allowed
if quantity > maxQuantityPerLevel {
logger.Warnf("[Grid] Quantity %.4f exceeds max allowed %.4f (position_value $%.2f > max $%.2f), capping",
quantity, maxQuantityPerLevel, quantity*d.Price, maxPositionValuePerLevel)
quantity = maxQuantityPerLevel
}
// Safety check: ensure position value is reasonable (within 2x of intended max as absolute limit)
positionValue := quantity * d.Price
absoluteMaxValue := gridConfig.TotalInvestment * float64(gridConfig.Leverage) * 2 // 2x safety margin
if positionValue > absoluteMaxValue {
logger.Errorf("[Grid] CRITICAL: Position value $%.2f exceeds absolute max $%.2f! Rejecting order.",
positionValue, absoluteMaxValue)
return fmt.Errorf("position value $%.2f exceeds safety limit $%.2f", positionValue, absoluteMaxValue)
}
}
// CRITICAL: Check total position limit before placing order
orderValue := quantity * d.Price
allowed, currentValue, maxValue := at.checkTotalPositionLimit(d.Symbol, orderValue)
if !allowed {
logger.Errorf("[Grid] TOTAL POSITION LIMIT EXCEEDED: current=$%.2f + order=$%.2f > max=$%.2f. Rejecting order.",
currentValue, orderValue, maxValue)
return fmt.Errorf("total position value $%.2f would exceed limit $%.2f", currentValue+orderValue, maxValue)
}
req := &LimitOrderRequest{
Symbol: d.Symbol,
Side: side,
Price: d.Price,
Quantity: quantity, // Use validated/capped quantity
Leverage: gridConfig.Leverage,
PostOnly: gridConfig.UseMakerOnly,
ReduceOnly: false,
ClientID: fmt.Sprintf("grid-%d-%d", d.LevelIndex, time.Now().UnixNano()%1000000),
}
result, err := gridTrader.PlaceLimitOrder(req)
if err != nil {
return fmt.Errorf("failed to place limit order: %w", err)
}
// Update grid level state
at.gridState.mu.Lock()
if d.LevelIndex >= 0 && d.LevelIndex < len(at.gridState.Levels) {
at.gridState.Levels[d.LevelIndex].State = "pending"
at.gridState.Levels[d.LevelIndex].OrderID = result.OrderID
at.gridState.Levels[d.LevelIndex].OrderQuantity = d.Quantity
at.gridState.OrderBook[result.OrderID] = d.LevelIndex
}
at.gridState.mu.Unlock()
logger.Infof("[Grid] Placed %s limit order at $%.2f, qty=%.4f, level=%d, orderID=%s",
side, d.Price, d.Quantity, d.LevelIndex, result.OrderID)
return nil
}
// cancelGridOrder cancels a specific grid order
func (at *AutoTrader) cancelGridOrder(d *kernel.Decision) error {
gridTrader, ok := at.trader.(GridTrader)
if !ok {
gridTrader = NewGridTraderAdapter(at.trader)
}
if err := gridTrader.CancelOrder(d.Symbol, d.OrderID); err != nil {
return fmt.Errorf("failed to cancel order: %w", err)
}
// Update state
at.gridState.mu.Lock()
if levelIdx, ok := at.gridState.OrderBook[d.OrderID]; ok {
if levelIdx >= 0 && levelIdx < len(at.gridState.Levels) {
at.gridState.Levels[levelIdx].State = "empty"
at.gridState.Levels[levelIdx].OrderID = ""
at.gridState.Levels[levelIdx].OrderQuantity = 0
}
delete(at.gridState.OrderBook, d.OrderID)
}
at.gridState.mu.Unlock()
logger.Infof("[Grid] Cancelled order: %s", d.OrderID)
return nil
}
// cancelAllGridOrders cancels all grid orders
func (at *AutoTrader) cancelAllGridOrders() error {
gridConfig := at.config.StrategyConfig.GridConfig
if err := at.trader.CancelAllOrders(gridConfig.Symbol); err != nil {
return fmt.Errorf("failed to cancel all orders: %w", err)
}
// Reset all pending levels
at.gridState.mu.Lock()
for i := range at.gridState.Levels {
if at.gridState.Levels[i].State == "pending" {
at.gridState.Levels[i].State = "empty"
at.gridState.Levels[i].OrderID = ""
at.gridState.Levels[i].OrderQuantity = 0
}
}
at.gridState.OrderBook = make(map[string]int)
at.gridState.mu.Unlock()
logger.Infof("[Grid] Cancelled all orders")
return nil
}
// pauseGrid pauses grid trading
func (at *AutoTrader) pauseGrid(reason string) error {
at.cancelAllGridOrders()
at.gridState.mu.Lock()
at.gridState.IsPaused = true
at.gridState.mu.Unlock()
logger.Infof("[Grid] Paused: %s", reason)
return nil
}
// resumeGrid resumes grid trading
func (at *AutoTrader) resumeGrid() error {
at.gridState.mu.Lock()
at.gridState.IsPaused = false
at.gridState.mu.Unlock()
logger.Infof("[Grid] Resumed")
return nil
}
// adjustGrid adjusts grid parameters
func (at *AutoTrader) adjustGrid(d *kernel.Decision) error {
// Cancel existing orders first
at.cancelAllGridOrders()
gridConfig := at.config.StrategyConfig.GridConfig
// Get current price
price, err := at.trader.GetMarketPrice(gridConfig.Symbol)
if err != nil {
return fmt.Errorf("failed to get market price: %w", err)
}
// Reinitialize grid levels
at.initializeGridLevels(price, gridConfig)
logger.Infof("[Grid] Adjusted grid bounds around price $%.2f", price)
return nil
}
// syncGridState syncs grid state with exchange
func (at *AutoTrader) syncGridState() {
gridConfig := at.config.StrategyConfig.GridConfig
// Get open orders from exchange
openOrders, err := at.trader.GetOpenOrders(gridConfig.Symbol)
if err != nil {
logger.Warnf("[Grid] Failed to get open orders: %v", err)
return
}
// Build set of active order IDs
activeOrderIDs := make(map[string]bool)
for _, order := range openOrders {
activeOrderIDs[order.OrderID] = true
}
// Get current positions to verify fills
positions, err := at.trader.GetPositions()
currentPositionSize := 0.0
if err != nil {
logger.Warnf("[Grid] Failed to get positions for state sync: %v", err)
} else {
for _, pos := range positions {
if sym, ok := pos["symbol"].(string); ok && sym == gridConfig.Symbol {
if size, ok := pos["positionAmt"].(float64); ok {
currentPositionSize = size
}
}
}
}
// Update levels based on order status
at.gridState.mu.Lock()
expectedPositionSize := 0.0
for _, level := range at.gridState.Levels {
if level.State == "filled" {
expectedPositionSize += level.PositionSize
}
}
for i := range at.gridState.Levels {
level := &at.gridState.Levels[i]
if level.State == "pending" && level.OrderID != "" {
if !activeOrderIDs[level.OrderID] {
// Order no longer exists - check if position changed to determine fill vs cancel
// This is a heuristic - ideally we'd query order history
// If current position is larger than expected filled positions, this order was likely filled
if math.Abs(currentPositionSize) > math.Abs(expectedPositionSize) {
// Position increased, likely filled
level.State = "filled"
level.PositionEntry = level.Price
level.PositionSize = level.OrderQuantity
at.gridState.TotalTrades++
logger.Infof("[Grid] Level %d order filled at $%.2f", i, level.Price)
} else {
// Position didn't increase as expected, likely cancelled
level.State = "empty"
level.OrderID = ""
level.OrderQuantity = 0
logger.Infof("[Grid] Level %d order cancelled/expired", i)
}
delete(at.gridState.OrderBook, level.OrderID)
}
}
}
at.gridState.mu.Unlock()
logger.Debugf("[Grid] Synced state: position=%.4f, orders=%d", currentPositionSize, len(openOrders))
// Check stop loss
at.checkAndExecuteStopLoss()
// Check grid skew
at.autoAdjustGrid()
}
// closeAllPositions closes all open positions for the grid symbol
func (at *AutoTrader) closeAllPositions() error {
gridConfig := at.config.StrategyConfig.GridConfig
if gridConfig == nil {
return nil
}
positions, err := at.trader.GetPositions()
if err != nil {
return fmt.Errorf("failed to get positions: %w", err)
}
for _, pos := range positions {
symbol, _ := pos["symbol"].(string)
if symbol != gridConfig.Symbol {
continue
}
size, _ := pos["positionAmt"].(float64)
if size == 0 {
continue
}
if size > 0 {
_, err = at.trader.CloseLong(symbol, size)
} else {
_, err = at.trader.CloseShort(symbol, -size)
}
if err != nil {
logger.Infof("Failed to close position: %v", err)
}
}
return nil
}
// checkAndExecuteStopLoss checks if any filled level has exceeded stop loss and closes it
func (at *AutoTrader) checkAndExecuteStopLoss() {
gridConfig := at.config.StrategyConfig.GridConfig
if gridConfig.StopLossPct <= 0 {
return // Stop loss not configured
}
currentPrice, err := at.trader.GetMarketPrice(gridConfig.Symbol)
if err != nil {
logger.Warnf("[Grid] Failed to get market price for stop loss check: %v", err)
return
}
at.gridState.mu.Lock()
defer at.gridState.mu.Unlock()
for i := range at.gridState.Levels {
level := &at.gridState.Levels[i]
if level.State != "filled" || level.PositionEntry <= 0 {
continue
}
// Calculate loss percentage
var lossPct float64
if level.Side == "buy" {
// Long position: loss when price drops
lossPct = (level.PositionEntry - currentPrice) / level.PositionEntry * 100
} else {
// Short position: loss when price rises
lossPct = (currentPrice - level.PositionEntry) / level.PositionEntry * 100
}
// Check if stop loss triggered
if lossPct >= gridConfig.StopLossPct {
logger.Warnf("[Grid] STOP LOSS TRIGGERED: Level %d, entry=$%.2f, current=$%.2f, loss=%.2f%%",
i, level.PositionEntry, currentPrice, lossPct)
// Close the position
var closeErr error
if level.Side == "buy" {
_, closeErr = at.trader.CloseLong(gridConfig.Symbol, level.PositionSize)
} else {
_, closeErr = at.trader.CloseShort(gridConfig.Symbol, level.PositionSize)
}
if closeErr != nil {
logger.Errorf("[Grid] Failed to execute stop loss for level %d: %v", i, closeErr)
} else {
level.State = "stopped"
realizedLoss := -lossPct * level.AllocatedUSD / 100
level.UnrealizedPnL = realizedLoss
at.gridState.TotalTrades++
// Update daily PnL tracking (lock already held, update directly)
at.gridState.DailyPnL += realizedLoss
at.gridState.TotalProfit += realizedLoss
logger.Infof("[Grid] Stop loss executed: Level %d closed at $%.2f (loss %.2f%%)",
i, currentPrice, lossPct)
}
}
}
}

View File

@@ -0,0 +1,345 @@
package trader
import (
"fmt"
"math"
"nofx/logger"
"nofx/market"
"time"
)
// ============================================================================
// Regime Detection and Strategy Switching
// ============================================================================
// checkBoxBreakout checks for multi-period box breakouts and takes appropriate action
func (at *AutoTrader) checkBoxBreakout() error {
gridConfig := at.config.StrategyConfig.GridConfig
if gridConfig == nil {
return nil
}
// Get box data
box, err := market.GetBoxData(gridConfig.Symbol)
if err != nil {
logger.Infof("Failed to get box data: %v", err)
return nil // Non-fatal, continue with other checks
}
// Update grid state with box values
at.gridState.mu.Lock()
at.gridState.ShortBoxUpper = box.ShortUpper
at.gridState.ShortBoxLower = box.ShortLower
at.gridState.MidBoxUpper = box.MidUpper
at.gridState.MidBoxLower = box.MidLower
at.gridState.LongBoxUpper = box.LongUpper
at.gridState.LongBoxLower = box.LongLower
at.gridState.mu.Unlock()
// Detect breakout
breakoutLevel, direction := detectBoxBreakout(box)
// Get current breakout state
state := &BreakoutState{
Level: market.BreakoutLevel(at.gridState.BreakoutLevel),
Direction: at.gridState.BreakoutDirection,
ConfirmCount: at.gridState.BreakoutConfirmCount,
}
// Check if breakout is confirmed (3 candles)
confirmed := confirmBreakout(state, breakoutLevel, direction)
// Update grid state
at.gridState.mu.Lock()
at.gridState.BreakoutLevel = string(state.Level)
at.gridState.BreakoutDirection = state.Direction
at.gridState.BreakoutConfirmCount = state.ConfirmCount
at.gridState.mu.Unlock()
if !confirmed {
return nil
}
// Take action based on breakout level
// Use direction-aware action if enabled
enableDirectionAdjust := gridConfig.EnableDirectionAdjust
action := getBreakoutActionWithDirection(breakoutLevel, enableDirectionAdjust)
// If direction adjustment action, determine the new direction
if action == BreakoutActionAdjustDirection {
box, _ := market.GetBoxData(gridConfig.Symbol)
newDirection := determineGridDirection(box, at.gridState.CurrentDirection, breakoutLevel, direction)
return at.executeDirectionAdjustment(newDirection)
}
return at.executeBreakoutAction(action)
}
// executeBreakoutAction executes the appropriate action for a breakout
func (at *AutoTrader) executeBreakoutAction(action BreakoutAction) error {
switch action {
case BreakoutActionReducePosition:
// Short box breakout: reduce position to 50%
logger.Infof("Short box breakout confirmed, reducing position to 50%%")
at.gridState.mu.Lock()
at.gridState.PositionReductionPct = 50
at.gridState.mu.Unlock()
return nil
case BreakoutActionPauseGrid:
// Mid box breakout: pause grid + cancel orders
logger.Infof("Mid box breakout confirmed, pausing grid and canceling orders")
at.gridState.mu.Lock()
at.gridState.IsPaused = true
at.gridState.mu.Unlock()
return at.cancelAllGridOrders()
case BreakoutActionCloseAll:
// Long box breakout: pause + cancel + close all
logger.Infof("Long box breakout confirmed, closing all positions")
at.gridState.mu.Lock()
at.gridState.IsPaused = true
at.gridState.mu.Unlock()
if err := at.cancelAllGridOrders(); err != nil {
logger.Infof("Failed to cancel orders: %v", err)
}
return at.closeAllPositions()
case BreakoutActionAdjustDirection:
// Direction adjustment is handled separately via executeDirectionAdjustment
// This case should not be reached, but handle gracefully
logger.Infof("Direction adjustment action received via executeBreakoutAction")
return nil
}
return nil
}
// executeDirectionAdjustment handles grid direction changes based on box breakout
func (at *AutoTrader) executeDirectionAdjustment(newDirection market.GridDirection) error {
at.gridState.mu.RLock()
oldDirection := at.gridState.CurrentDirection
at.gridState.mu.RUnlock()
if oldDirection == newDirection {
return nil // No change needed
}
logger.Infof("[Grid] Direction adjustment: %s -> %s", oldDirection, newDirection)
// Cancel existing orders before adjusting
if err := at.cancelAllGridOrders(); err != nil {
logger.Warnf("[Grid] Failed to cancel orders during direction adjustment: %v", err)
}
// Apply the new direction
return at.adjustGridDirection(newDirection)
}
// adjustGridDirection handles runtime direction adjustment when breakout is detected
func (at *AutoTrader) adjustGridDirection(newDirection market.GridDirection) error {
at.gridState.mu.Lock()
defer at.gridState.mu.Unlock()
oldDirection := at.gridState.CurrentDirection
if oldDirection == newDirection {
return nil // No change needed
}
at.gridState.CurrentDirection = newDirection
at.gridState.DirectionChangedAt = time.Now()
at.gridState.DirectionChangeCount++
logger.Infof("[Grid] Direction changed: %s -> %s (change count: %d)",
oldDirection, newDirection, at.gridState.DirectionChangeCount)
// Get current price for recalculation
currentPrice, err := at.trader.GetMarketPrice(at.gridState.Config.Symbol)
if err != nil {
return fmt.Errorf("failed to get market price: %w", err)
}
// Reapply direction to grid levels
at.applyGridDirection(currentPrice)
return nil
}
// checkFalseBreakoutRecovery checks if price has returned to box after breakout
func (at *AutoTrader) checkFalseBreakoutRecovery() error {
gridConfig := at.config.StrategyConfig.GridConfig
if gridConfig == nil {
return nil
}
at.gridState.mu.RLock()
breakoutLevel := at.gridState.BreakoutLevel
isPaused := at.gridState.IsPaused
positionReduction := at.gridState.PositionReductionPct
currentDirection := at.gridState.CurrentDirection
at.gridState.mu.RUnlock()
// Only check if we had a breakout or non-neutral direction
needsRecoveryCheck := breakoutLevel != string(market.BreakoutNone) ||
positionReduction != 0 ||
isPaused ||
(gridConfig.EnableDirectionAdjust && currentDirection != market.GridDirectionNeutral)
if !needsRecoveryCheck {
return nil
}
// Get current box data
box, err := market.GetBoxData(gridConfig.Symbol)
if err != nil {
return nil
}
// Check if price is back inside the long box
if box.CurrentPrice >= box.LongLower && box.CurrentPrice <= box.LongUpper {
logger.Infof("Price returned to box, recovering with 50%% position")
at.gridState.mu.Lock()
at.gridState.BreakoutLevel = string(market.BreakoutNone)
at.gridState.BreakoutDirection = ""
at.gridState.BreakoutConfirmCount = 0
at.gridState.PositionReductionPct = 50 // Recover at 50%
at.gridState.IsPaused = false
at.gridState.mu.Unlock()
}
// Check for direction recovery toward neutral (if direction adjustment is enabled)
if gridConfig.EnableDirectionAdjust && currentDirection != market.GridDirectionNeutral {
if shouldRecoverDirection(box, currentDirection) {
newDirection := determineRecoveryDirection(box.CurrentPrice, box, currentDirection)
if newDirection != currentDirection {
logger.Infof("[Grid] Direction recovery: %s -> %s (price back in short box)",
currentDirection, newDirection)
at.adjustGridDirection(newDirection)
}
}
}
return nil
}
// GetGridRiskInfo returns current risk information for frontend display
func (at *AutoTrader) GetGridRiskInfo() *GridRiskInfo {
gridConfig := at.config.StrategyConfig.GridConfig
if gridConfig == nil {
return &GridRiskInfo{}
}
at.gridState.mu.RLock()
defer at.gridState.mu.RUnlock()
// Get current price
currentPrice, _ := at.trader.GetMarketPrice(gridConfig.Symbol)
// Calculate effective leverage
totalInvestment := gridConfig.TotalInvestment
leverage := gridConfig.Leverage
// Get current position value
positions, _ := at.trader.GetPositions()
var currentPositionValue float64
var currentPositionSize float64
for _, pos := range positions {
if sym, _ := pos["symbol"].(string); sym == gridConfig.Symbol {
size, _ := pos["positionAmt"].(float64)
entry, _ := pos["entryPrice"].(float64)
currentPositionValue = math.Abs(size * entry)
currentPositionSize = size
break
}
}
effectiveLeverage := 0.0
if totalInvestment > 0 {
effectiveLeverage = currentPositionValue / totalInvestment
}
// Calculate max position based on regime
regimeLevel := market.RegimeLevel(at.gridState.CurrentRegimeLevel)
if regimeLevel == "" {
regimeLevel = market.RegimeLevelStandard
}
// Use default position limit since GridStrategyConfig doesn't have regime-specific limits
// Default is 70% for standard regime
maxPositionPct := 70.0
switch regimeLevel {
case market.RegimeLevelNarrow:
maxPositionPct = 40.0
case market.RegimeLevelStandard:
maxPositionPct = 70.0
case market.RegimeLevelWide:
maxPositionPct = 60.0
case market.RegimeLevelVolatile:
maxPositionPct = 40.0
}
maxPosition := totalInvestment * maxPositionPct / 100 * float64(leverage)
// Use default leverage limits since GridStrategyConfig doesn't have regime-specific limits
recommendedLeverage := leverage
switch regimeLevel {
case market.RegimeLevelNarrow:
recommendedLeverage = min(leverage, 2)
case market.RegimeLevelStandard:
recommendedLeverage = min(leverage, 4)
case market.RegimeLevelWide:
recommendedLeverage = min(leverage, 3)
case market.RegimeLevelVolatile:
recommendedLeverage = min(leverage, 2)
}
// Calculate liquidation distance and price only when there's a position
var liquidationDistance float64
var liquidationPrice float64
if currentPositionSize != 0 && currentPrice > 0 {
liquidationDistance = 100.0 / float64(leverage) * 0.9 // ~90% of theoretical max
if currentPositionSize > 0 {
// Long position: liquidation below entry
liquidationPrice = currentPrice * (1 - liquidationDistance/100)
} else {
// Short position: liquidation above entry
liquidationPrice = currentPrice * (1 + liquidationDistance/100)
}
}
positionPercent := 0.0
if maxPosition > 0 {
positionPercent = currentPositionValue / maxPosition * 100
}
return &GridRiskInfo{
CurrentLeverage: leverage,
EffectiveLeverage: effectiveLeverage,
RecommendedLeverage: recommendedLeverage,
CurrentPosition: currentPositionValue,
MaxPosition: maxPosition,
PositionPercent: positionPercent,
LiquidationPrice: liquidationPrice,
LiquidationDistance: liquidationDistance,
RegimeLevel: string(regimeLevel),
ShortBoxUpper: at.gridState.ShortBoxUpper,
ShortBoxLower: at.gridState.ShortBoxLower,
MidBoxUpper: at.gridState.MidBoxUpper,
MidBoxLower: at.gridState.MidBoxLower,
LongBoxUpper: at.gridState.LongBoxUpper,
LongBoxLower: at.gridState.LongBoxLower,
CurrentPrice: currentPrice,
BreakoutLevel: at.gridState.BreakoutLevel,
BreakoutDirection: at.gridState.BreakoutDirection,
CurrentGridDirection: string(at.gridState.CurrentDirection),
DirectionChangeCount: at.gridState.DirectionChangeCount,
EnableDirectionAdjust: gridConfig.EnableDirectionAdjust,
}
}

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,291 @@
package binance
import (
"context"
"fmt"
"nofx/logger"
"nofx/trader/types"
"strconv"
"time"
)
// GetBalance gets account balance (with cache)
func (t *FuturesTrader) GetBalance() (map[string]interface{}, error) {
// First check if cache is valid
t.balanceCacheMutex.RLock()
if t.cachedBalance != nil && time.Since(t.balanceCacheTime) < t.cacheDuration {
cacheAge := time.Since(t.balanceCacheTime)
t.balanceCacheMutex.RUnlock()
logger.Infof("✓ Using cached account balance (cache age: %.1f seconds ago)", cacheAge.Seconds())
return t.cachedBalance, nil
}
t.balanceCacheMutex.RUnlock()
// Cache expired or doesn't exist, call API
logger.Infof("🔄 Cache expired, calling Binance API to get account balance...")
account, err := t.client.NewGetAccountService().Do(context.Background())
if err != nil {
logger.Infof("❌ Binance API call failed: %v", err)
return nil, fmt.Errorf("failed to get account info: %w", err)
}
result := make(map[string]interface{})
result["totalWalletBalance"], _ = strconv.ParseFloat(account.TotalWalletBalance, 64)
result["availableBalance"], _ = strconv.ParseFloat(account.AvailableBalance, 64)
result["totalUnrealizedProfit"], _ = strconv.ParseFloat(account.TotalUnrealizedProfit, 64)
logger.Infof("✓ Binance API returned: total balance=%s, available=%s, unrealized PnL=%s",
account.TotalWalletBalance,
account.AvailableBalance,
account.TotalUnrealizedProfit)
// Update cache
t.balanceCacheMutex.Lock()
t.cachedBalance = result
t.balanceCacheTime = time.Now()
t.balanceCacheMutex.Unlock()
return result, nil
}
// GetClosedPnL retrieves recent closing trades from Binance Futures
// Note: Binance does NOT have a position history API, only trade history.
// This returns individual closing trades (realizedPnl != 0) for real-time position closure detection.
// NOT suitable for historical position reconstruction - use only for matching recent closures.
func (t *FuturesTrader) GetClosedPnL(startTime time.Time, limit int) ([]types.ClosedPnLRecord, error) {
trades, err := t.GetTrades(startTime, limit)
if err != nil {
return nil, err
}
// Filter only closing trades (realizedPnl != 0) and convert to ClosedPnLRecord
var records []types.ClosedPnLRecord
for _, trade := range trades {
if trade.RealizedPnL == 0 {
continue // Skip opening trades
}
// Determine side from trade
side := "long"
if trade.PositionSide == "SHORT" || trade.PositionSide == "short" {
side = "short"
} else if trade.PositionSide == "BOTH" || trade.PositionSide == "" {
// One-way mode: selling closes long, buying closes short
if trade.Side == "SELL" || trade.Side == "Sell" {
side = "long"
} else {
side = "short"
}
}
// Calculate entry price from PnL (mathematically accurate for this trade)
var entryPrice float64
if trade.Quantity > 0 {
if side == "long" {
entryPrice = trade.Price - trade.RealizedPnL/trade.Quantity
} else {
entryPrice = trade.Price + trade.RealizedPnL/trade.Quantity
}
}
records = append(records, types.ClosedPnLRecord{
Symbol: trade.Symbol,
Side: side,
EntryPrice: entryPrice,
ExitPrice: trade.Price,
Quantity: trade.Quantity,
RealizedPnL: trade.RealizedPnL,
Fee: trade.Fee,
ExitTime: trade.Time,
EntryTime: trade.Time, // Approximate
OrderID: trade.TradeID,
ExchangeID: trade.TradeID,
CloseType: "unknown",
})
}
return records, nil
}
// GetTrades retrieves trade history from Binance Futures using Income API
// Note: Income API has delays (~minutes), for real-time use GetTradesForSymbol instead
func (t *FuturesTrader) GetTrades(startTime time.Time, limit int) ([]types.TradeRecord, error) {
if limit <= 0 {
limit = 100
}
if limit > 1000 {
limit = 1000
}
// Use Income API to get REALIZED_PNL records (all symbols)
incomes, err := t.client.NewGetIncomeHistoryService().
IncomeType("REALIZED_PNL").
StartTime(startTime.UnixMilli()).
Limit(int64(limit)).
Do(context.Background())
if err != nil {
return nil, fmt.Errorf("failed to get income history: %w", err)
}
var trades []types.TradeRecord
for _, income := range incomes {
pnl, _ := strconv.ParseFloat(income.Income, 64)
if pnl == 0 {
continue // Skip zero PnL records
}
// Income API doesn't provide full trade details, create a minimal record
// This is mainly used for detecting recent closures, not historical reconstruction
trade := types.TradeRecord{
TradeID: strconv.FormatInt(income.TranID, 10),
Symbol: income.Symbol,
RealizedPnL: pnl,
Time: time.UnixMilli(income.Time).UTC(),
// Note: Income API doesn't provide price, quantity, side, fee
// For accurate data, use GetTradesForSymbol with specific symbol
}
trades = append(trades, trade)
}
return trades, nil
}
// GetTradesForSymbol retrieves trade history for a specific symbol
// This is more reliable than using Income API which may have delays
func (t *FuturesTrader) GetTradesForSymbol(symbol string, startTime time.Time, limit int) ([]types.TradeRecord, error) {
if limit <= 0 {
limit = 100
}
if limit > 1000 {
limit = 1000
}
accountTrades, err := t.client.NewListAccountTradeService().
Symbol(symbol).
StartTime(startTime.UnixMilli()).
Limit(limit).
Do(context.Background())
if err != nil {
return nil, fmt.Errorf("failed to get trade history for %s: %w", symbol, err)
}
var trades []types.TradeRecord
for _, at := range accountTrades {
price, _ := strconv.ParseFloat(at.Price, 64)
qty, _ := strconv.ParseFloat(at.Quantity, 64)
fee, _ := strconv.ParseFloat(at.Commission, 64)
pnl, _ := strconv.ParseFloat(at.RealizedPnl, 64)
trade := types.TradeRecord{
TradeID: strconv.FormatInt(at.ID, 10),
Symbol: at.Symbol,
Side: string(at.Side),
PositionSide: string(at.PositionSide),
Price: price,
Quantity: qty,
RealizedPnL: pnl,
Fee: fee,
Time: time.UnixMilli(at.Time).UTC(),
}
trades = append(trades, trade)
}
return trades, nil
}
// GetTradesForSymbolFromID retrieves trade history for a specific symbol starting from a given trade ID
// This is used for incremental sync - only fetch new trades since last sync
func (t *FuturesTrader) GetTradesForSymbolFromID(symbol string, fromID int64, limit int) ([]types.TradeRecord, error) {
if limit <= 0 {
limit = 100
}
if limit > 1000 {
limit = 1000
}
accountTrades, err := t.client.NewListAccountTradeService().
Symbol(symbol).
FromID(fromID).
Limit(limit).
Do(context.Background())
if err != nil {
return nil, fmt.Errorf("failed to get trade history for %s from ID %d: %w", symbol, fromID, err)
}
var trades []types.TradeRecord
for _, at := range accountTrades {
price, _ := strconv.ParseFloat(at.Price, 64)
qty, _ := strconv.ParseFloat(at.Quantity, 64)
fee, _ := strconv.ParseFloat(at.Commission, 64)
pnl, _ := strconv.ParseFloat(at.RealizedPnl, 64)
trade := types.TradeRecord{
TradeID: strconv.FormatInt(at.ID, 10),
Symbol: at.Symbol,
Side: string(at.Side),
PositionSide: string(at.PositionSide),
Price: price,
Quantity: qty,
RealizedPnL: pnl,
Fee: fee,
Time: time.UnixMilli(at.Time).UTC(),
}
trades = append(trades, trade)
}
return trades, nil
}
// GetCommissionSymbols returns symbols that have new commission records since lastSyncTime
// COMMISSION income is generated for every trade, so this is more reliable than REALIZED_PNL
func (t *FuturesTrader) GetCommissionSymbols(lastSyncTime time.Time) ([]string, error) {
incomes, err := t.client.NewGetIncomeHistoryService().
IncomeType("COMMISSION").
StartTime(lastSyncTime.UnixMilli()).
Limit(1000).
Do(context.Background())
if err != nil {
return nil, fmt.Errorf("failed to get commission history: %w", err)
}
symbolMap := make(map[string]bool)
for _, income := range incomes {
if income.Symbol != "" {
symbolMap[income.Symbol] = true
}
}
var symbols []string
for symbol := range symbolMap {
symbols = append(symbols, symbol)
}
return symbols, nil
}
// GetPnLSymbols returns symbols that have REALIZED_PNL records since lastSyncTime
// This is a fallback when COMMISSION detection fails (VIP users, BNB fee discount)
func (t *FuturesTrader) GetPnLSymbols(lastSyncTime time.Time) ([]string, error) {
incomes, err := t.client.NewGetIncomeHistoryService().
IncomeType("REALIZED_PNL").
StartTime(lastSyncTime.UnixMilli()).
Limit(1000).
Do(context.Background())
if err != nil {
return nil, fmt.Errorf("failed to get PnL history: %w", err)
}
symbolMap := make(map[string]bool)
for _, income := range incomes {
if income.Symbol != "" {
symbolMap[income.Symbol] = true
}
}
var symbols []string
for symbol := range symbolMap {
symbols = append(symbols, symbol)
}
return symbols, nil
}

View File

@@ -0,0 +1,758 @@
package binance
import (
"context"
"fmt"
"nofx/logger"
"nofx/trader/types"
"strconv"
"github.com/adshao/go-binance/v2/futures"
)
// OpenLong opens a long position
func (t *FuturesTrader) OpenLong(symbol string, quantity float64, leverage int) (map[string]interface{}, error) {
// First cancel all pending orders for this symbol (clean up old stop-loss and take-profit orders)
if err := t.CancelAllOrders(symbol); err != nil {
logger.Infof(" ⚠ Failed to cancel old pending orders (may not have any): %v", err)
}
// Set leverage
if err := t.SetLeverage(symbol, leverage); err != nil {
return nil, err
}
// Note: Margin mode should be set by the caller (AutoTrader) before opening position via SetMarginMode
// Format quantity to correct precision
quantityStr, err := t.FormatQuantity(symbol, quantity)
if err != nil {
return nil, err
}
// Check if formatted quantity is 0 (prevent rounding errors)
quantityFloat, parseErr := strconv.ParseFloat(quantityStr, 64)
if parseErr != nil || quantityFloat <= 0 {
return nil, fmt.Errorf("position size too small, rounded to 0 (original: %.8f → formatted: %s). Suggest increasing position amount or selecting a lower-priced coin", quantity, quantityStr)
}
// Check minimum notional value (Binance requires at least 10 USDT)
if err := t.CheckMinNotional(symbol, quantityFloat); err != nil {
return nil, err
}
// Create market buy order (using br ID)
order, err := t.client.NewCreateOrderService().
Symbol(symbol).
Side(futures.SideTypeBuy).
PositionSide(futures.PositionSideTypeLong).
Type(futures.OrderTypeMarket).
Quantity(quantityStr).
NewClientOrderID(getBrOrderID()).
Do(context.Background())
if err != nil {
return nil, fmt.Errorf("failed to open long position: %w", err)
}
logger.Infof("✓ Opened long position successfully: %s quantity: %s", symbol, quantityStr)
logger.Infof(" Order ID: %d", order.OrderID)
result := make(map[string]interface{})
result["orderId"] = order.OrderID
result["symbol"] = order.Symbol
result["status"] = order.Status
return result, nil
}
// OpenShort opens a short position
func (t *FuturesTrader) OpenShort(symbol string, quantity float64, leverage int) (map[string]interface{}, error) {
// First cancel all pending orders for this symbol (clean up old stop-loss and take-profit orders)
if err := t.CancelAllOrders(symbol); err != nil {
logger.Infof(" ⚠ Failed to cancel old pending orders (may not have any): %v", err)
}
// Set leverage
if err := t.SetLeverage(symbol, leverage); err != nil {
return nil, err
}
// Note: Margin mode should be set by the caller (AutoTrader) before opening position via SetMarginMode
// Format quantity to correct precision
quantityStr, err := t.FormatQuantity(symbol, quantity)
if err != nil {
return nil, err
}
// Check if formatted quantity is 0 (prevent rounding errors)
quantityFloat, parseErr := strconv.ParseFloat(quantityStr, 64)
if parseErr != nil || quantityFloat <= 0 {
return nil, fmt.Errorf("position size too small, rounded to 0 (original: %.8f → formatted: %s). Suggest increasing position amount or selecting a lower-priced coin", quantity, quantityStr)
}
// Check minimum notional value (Binance requires at least 10 USDT)
if err := t.CheckMinNotional(symbol, quantityFloat); err != nil {
return nil, err
}
// Create market sell order (using br ID)
order, err := t.client.NewCreateOrderService().
Symbol(symbol).
Side(futures.SideTypeSell).
PositionSide(futures.PositionSideTypeShort).
Type(futures.OrderTypeMarket).
Quantity(quantityStr).
NewClientOrderID(getBrOrderID()).
Do(context.Background())
if err != nil {
return nil, fmt.Errorf("failed to open short position: %w", err)
}
logger.Infof("✓ Opened short position successfully: %s quantity: %s", symbol, quantityStr)
logger.Infof(" Order ID: %d", order.OrderID)
result := make(map[string]interface{})
result["orderId"] = order.OrderID
result["symbol"] = order.Symbol
result["status"] = order.Status
return result, nil
}
// CloseLong closes a long position
func (t *FuturesTrader) CloseLong(symbol string, quantity float64) (map[string]interface{}, error) {
// If quantity is 0, get current position quantity
if quantity == 0 {
positions, err := t.GetPositions()
if err != nil {
return nil, err
}
for _, pos := range positions {
if pos["symbol"] == symbol && pos["side"] == "long" {
quantity = pos["positionAmt"].(float64)
break
}
}
if quantity == 0 {
return nil, fmt.Errorf("no long position found for %s", symbol)
}
}
// Format quantity
quantityStr, err := t.FormatQuantity(symbol, quantity)
if err != nil {
return nil, err
}
// Create market sell order (close long, using br ID)
order, err := t.client.NewCreateOrderService().
Symbol(symbol).
Side(futures.SideTypeSell).
PositionSide(futures.PositionSideTypeLong).
Type(futures.OrderTypeMarket).
Quantity(quantityStr).
NewClientOrderID(getBrOrderID()).
Do(context.Background())
if err != nil {
return nil, fmt.Errorf("failed to close long position: %w", err)
}
logger.Infof("✓ Closed long position successfully: %s quantity: %s", symbol, quantityStr)
// After closing position, cancel all pending orders for this symbol (stop-loss and take-profit orders)
if err := t.CancelAllOrders(symbol); err != nil {
logger.Infof(" ⚠ Failed to cancel pending orders: %v", err)
}
result := make(map[string]interface{})
result["orderId"] = order.OrderID
result["symbol"] = order.Symbol
result["status"] = order.Status
return result, nil
}
// CloseShort closes a short position
func (t *FuturesTrader) CloseShort(symbol string, quantity float64) (map[string]interface{}, error) {
// If quantity is 0, get current position quantity
if quantity == 0 {
positions, err := t.GetPositions()
if err != nil {
return nil, err
}
for _, pos := range positions {
if pos["symbol"] == symbol && pos["side"] == "short" {
quantity = -pos["positionAmt"].(float64) // Short position quantity is negative, take absolute value
break
}
}
if quantity == 0 {
return nil, fmt.Errorf("no short position found for %s", symbol)
}
}
// Format quantity
quantityStr, err := t.FormatQuantity(symbol, quantity)
if err != nil {
return nil, err
}
// Create market buy order (close short, using br ID)
order, err := t.client.NewCreateOrderService().
Symbol(symbol).
Side(futures.SideTypeBuy).
PositionSide(futures.PositionSideTypeShort).
Type(futures.OrderTypeMarket).
Quantity(quantityStr).
NewClientOrderID(getBrOrderID()).
Do(context.Background())
if err != nil {
return nil, fmt.Errorf("failed to close short position: %w", err)
}
logger.Infof("✓ Closed short position successfully: %s quantity: %s", symbol, quantityStr)
// After closing position, cancel all pending orders for this symbol (stop-loss and take-profit orders)
if err := t.CancelAllOrders(symbol); err != nil {
logger.Infof(" ⚠ Failed to cancel pending orders: %v", err)
}
result := make(map[string]interface{})
result["orderId"] = order.OrderID
result["symbol"] = order.Symbol
result["status"] = order.Status
return result, nil
}
// CancelStopLossOrders cancels only stop-loss orders (doesn't affect take-profit orders)
// Now uses both legacy API and new Algo Order API
func (t *FuturesTrader) CancelStopLossOrders(symbol string) error {
canceledCount := 0
var cancelErrors []error
// 1. Cancel legacy stop-loss orders
orders, err := t.client.NewListOpenOrdersService().
Symbol(symbol).
Do(context.Background())
if err == nil {
for _, order := range orders {
orderType := string(order.Type)
// Only cancel stop-loss orders (don't cancel take-profit orders)
// Use string comparison since OrderType constants were removed in v2.8.9
if orderType == "STOP_MARKET" || orderType == "STOP" {
_, err := t.client.NewCancelOrderService().
Symbol(symbol).
OrderID(order.OrderID).
Do(context.Background())
if err != nil {
errMsg := fmt.Sprintf("Order ID %d: %v", order.OrderID, err)
cancelErrors = append(cancelErrors, fmt.Errorf("%s", errMsg))
logger.Infof(" ⚠ Failed to cancel legacy stop-loss order: %s", errMsg)
continue
}
canceledCount++
logger.Infof(" ✓ Canceled legacy stop-loss order (Order ID: %d, Type: %s, Side: %s)", order.OrderID, orderType, order.PositionSide)
}
}
}
// 2. Cancel Algo stop-loss orders
algoOrders, err := t.client.NewListOpenAlgoOrdersService().
Symbol(symbol).
Do(context.Background())
if err == nil {
for _, algoOrder := range algoOrders {
// Only cancel stop-loss orders
if algoOrder.OrderType == futures.AlgoOrderTypeStopMarket || algoOrder.OrderType == futures.AlgoOrderTypeStop {
_, err := t.client.NewCancelAlgoOrderService().
AlgoID(algoOrder.AlgoId).
Do(context.Background())
if err != nil {
errMsg := fmt.Sprintf("Algo ID %d: %v", algoOrder.AlgoId, err)
cancelErrors = append(cancelErrors, fmt.Errorf("%s", errMsg))
logger.Infof(" ⚠ Failed to cancel Algo stop-loss order: %s", errMsg)
continue
}
canceledCount++
logger.Infof(" ✓ Canceled Algo stop-loss order (Algo ID: %d, Type: %s)", algoOrder.AlgoId, algoOrder.OrderType)
}
}
}
if canceledCount == 0 && len(cancelErrors) == 0 {
logger.Infof(" %s has no stop-loss orders to cancel", symbol)
} else if canceledCount > 0 {
logger.Infof(" ✓ Canceled %d stop-loss order(s) for %s", canceledCount, symbol)
}
// If all cancellations failed, return error
if len(cancelErrors) > 0 && canceledCount == 0 {
return fmt.Errorf("failed to cancel stop-loss orders: %v", cancelErrors)
}
return nil
}
// CancelTakeProfitOrders cancels only take-profit orders (doesn't affect stop-loss orders)
// Now uses both legacy API and new Algo Order API
func (t *FuturesTrader) CancelTakeProfitOrders(symbol string) error {
canceledCount := 0
var cancelErrors []error
// 1. Cancel legacy take-profit orders
orders, err := t.client.NewListOpenOrdersService().
Symbol(symbol).
Do(context.Background())
if err == nil {
for _, order := range orders {
orderType := string(order.Type)
// Only cancel take-profit orders (don't cancel stop-loss orders)
// Use string comparison since OrderType constants were removed in v2.8.9
if orderType == "TAKE_PROFIT_MARKET" || orderType == "TAKE_PROFIT" {
_, err := t.client.NewCancelOrderService().
Symbol(symbol).
OrderID(order.OrderID).
Do(context.Background())
if err != nil {
errMsg := fmt.Sprintf("Order ID %d: %v", order.OrderID, err)
cancelErrors = append(cancelErrors, fmt.Errorf("%s", errMsg))
logger.Infof(" ⚠ Failed to cancel legacy take-profit order: %s", errMsg)
continue
}
canceledCount++
logger.Infof(" ✓ Canceled legacy take-profit order (Order ID: %d, Type: %s, Side: %s)", order.OrderID, orderType, order.PositionSide)
}
}
}
// 2. Cancel Algo take-profit orders
algoOrders, err := t.client.NewListOpenAlgoOrdersService().
Symbol(symbol).
Do(context.Background())
if err == nil {
for _, algoOrder := range algoOrders {
// Only cancel take-profit orders
if algoOrder.OrderType == futures.AlgoOrderTypeTakeProfitMarket || algoOrder.OrderType == futures.AlgoOrderTypeTakeProfit {
_, err := t.client.NewCancelAlgoOrderService().
AlgoID(algoOrder.AlgoId).
Do(context.Background())
if err != nil {
errMsg := fmt.Sprintf("Algo ID %d: %v", algoOrder.AlgoId, err)
cancelErrors = append(cancelErrors, fmt.Errorf("%s", errMsg))
logger.Infof(" ⚠ Failed to cancel Algo take-profit order: %s", errMsg)
continue
}
canceledCount++
logger.Infof(" ✓ Canceled Algo take-profit order (Algo ID: %d, Type: %s)", algoOrder.AlgoId, algoOrder.OrderType)
}
}
}
if canceledCount == 0 && len(cancelErrors) == 0 {
logger.Infof(" %s has no take-profit orders to cancel", symbol)
} else if canceledCount > 0 {
logger.Infof(" ✓ Canceled %d take-profit order(s) for %s", canceledCount, symbol)
}
// If all cancellations failed, return error
if len(cancelErrors) > 0 && canceledCount == 0 {
return fmt.Errorf("failed to cancel take-profit orders: %v", cancelErrors)
}
return nil
}
// CancelAllOrders cancels all pending orders for this symbol
// Now uses both legacy API and new Algo Order API
func (t *FuturesTrader) CancelAllOrders(symbol string) error {
// 1. Cancel all legacy orders
err := t.client.NewCancelAllOpenOrdersService().
Symbol(symbol).
Do(context.Background())
if err != nil {
logger.Infof(" ⚠ Failed to cancel legacy orders: %v", err)
} else {
logger.Infof(" ✓ Canceled all legacy pending orders for %s", symbol)
}
// 2. Cancel all Algo orders
err = t.client.NewCancelAllAlgoOpenOrdersService().
Symbol(symbol).
Do(context.Background())
if err != nil {
// Ignore "no algo orders" error
if !contains(err.Error(), "no algo") && !contains(err.Error(), "No algo") {
logger.Infof(" ⚠ Failed to cancel Algo orders: %v", err)
}
} else {
logger.Infof(" ✓ Canceled all Algo orders for %s", symbol)
}
return nil
}
// PlaceLimitOrder places a limit order for grid trading
// This implements the GridTrader interface for FuturesTrader
func (t *FuturesTrader) PlaceLimitOrder(req *types.LimitOrderRequest) (*types.LimitOrderResult, error) {
// Format quantity to correct precision
quantityStr, err := t.FormatQuantity(req.Symbol, req.Quantity)
if err != nil {
return nil, fmt.Errorf("failed to format quantity: %w", err)
}
// Format price to correct precision
priceStr, err := t.FormatPrice(req.Symbol, req.Price)
if err != nil {
return nil, fmt.Errorf("failed to format price: %w", err)
}
// Set leverage if specified
if req.Leverage > 0 {
if err := t.SetLeverage(req.Symbol, req.Leverage); err != nil {
logger.Warnf("Failed to set leverage: %v", err)
}
}
// Determine side and position side
var side futures.SideType
var positionSide futures.PositionSideType
if req.Side == "BUY" {
side = futures.SideTypeBuy
positionSide = futures.PositionSideTypeLong
} else {
side = futures.SideTypeSell
positionSide = futures.PositionSideTypeShort
}
// Build order service with broker ID
orderService := t.client.NewCreateOrderService().
Symbol(req.Symbol).
Side(side).
PositionSide(positionSide).
Type(futures.OrderTypeLimit).
TimeInForce(futures.TimeInForceTypeGTC).
Quantity(quantityStr).
Price(priceStr).
NewClientOrderID(getBrOrderID())
// Execute order
order, err := orderService.Do(context.Background())
if err != nil {
return nil, fmt.Errorf("failed to place limit order: %w", err)
}
logger.Infof("✓ [Grid] Placed limit order: %s %s %s @ %s, qty=%s, orderID=%d",
req.Symbol, req.Side, positionSide, priceStr, quantityStr, order.OrderID)
return &types.LimitOrderResult{
OrderID: fmt.Sprintf("%d", order.OrderID),
ClientID: order.ClientOrderID,
Symbol: order.Symbol,
Side: string(order.Side),
PositionSide: string(order.PositionSide),
Price: req.Price,
Quantity: req.Quantity,
Status: string(order.Status),
}, nil
}
// CancelOrder cancels a specific order by ID
// This implements the GridTrader interface for FuturesTrader
func (t *FuturesTrader) CancelOrder(symbol, orderID string) error {
// Parse order ID to int64
orderIDInt, err := strconv.ParseInt(orderID, 10, 64)
if err != nil {
return fmt.Errorf("invalid order ID: %w", err)
}
_, err = t.client.NewCancelOrderService().
Symbol(symbol).
OrderID(orderIDInt).
Do(context.Background())
if err != nil {
return fmt.Errorf("failed to cancel order: %w", err)
}
logger.Infof("✓ [Grid] Cancelled order: %s/%s", symbol, orderID)
return nil
}
// GetOrderBook gets the order book for a symbol
// This implements the GridTrader interface for FuturesTrader
func (t *FuturesTrader) GetOrderBook(symbol string, depth int) (bids, asks [][]float64, err error) {
book, err := t.client.NewDepthService().
Symbol(symbol).
Limit(depth).
Do(context.Background())
if err != nil {
return nil, nil, fmt.Errorf("failed to get order book: %w", err)
}
// Convert bids
bids = make([][]float64, len(book.Bids))
for i, bid := range book.Bids {
price, _ := strconv.ParseFloat(bid.Price, 64)
qty, _ := strconv.ParseFloat(bid.Quantity, 64)
bids[i] = []float64{price, qty}
}
// Convert asks
asks = make([][]float64, len(book.Asks))
for i, ask := range book.Asks {
price, _ := strconv.ParseFloat(ask.Price, 64)
qty, _ := strconv.ParseFloat(ask.Quantity, 64)
asks[i] = []float64{price, qty}
}
return bids, asks, nil
}
// CancelStopOrders cancels take-profit/stop-loss orders for this symbol (used to adjust TP/SL positions)
// Now uses both legacy API and new Algo Order API (Binance migrated stop orders to Algo system)
func (t *FuturesTrader) CancelStopOrders(symbol string) error {
canceledCount := 0
// 1. Cancel legacy stop orders (for backward compatibility)
orders, err := t.client.NewListOpenOrdersService().
Symbol(symbol).
Do(context.Background())
if err == nil {
for _, order := range orders {
orderType := string(order.Type)
// Only cancel stop-loss and take-profit orders
// Use string comparison since OrderType constants were removed in v2.8.9
if orderType == "STOP_MARKET" ||
orderType == "TAKE_PROFIT_MARKET" ||
orderType == "STOP" ||
orderType == "TAKE_PROFIT" {
_, err := t.client.NewCancelOrderService().
Symbol(symbol).
OrderID(order.OrderID).
Do(context.Background())
if err != nil {
logger.Infof(" ⚠ Failed to cancel legacy order %d: %v", order.OrderID, err)
continue
}
canceledCount++
logger.Infof(" ✓ Canceled legacy stop order for %s (Order ID: %d, Type: %s)",
symbol, order.OrderID, orderType)
}
}
}
// 2. Cancel Algo orders (new API)
err = t.client.NewCancelAllAlgoOpenOrdersService().
Symbol(symbol).
Do(context.Background())
if err != nil {
// Ignore "no algo orders" error
if !contains(err.Error(), "no algo") && !contains(err.Error(), "No algo") {
logger.Infof(" ⚠ Failed to cancel Algo orders: %v", err)
}
} else {
logger.Infof(" ✓ Canceled all Algo orders for %s", symbol)
canceledCount++
}
if canceledCount == 0 {
logger.Infof(" %s has no take-profit/stop-loss orders to cancel", symbol)
}
return nil
}
// GetOpenOrders gets all open/pending orders for a symbol
func (t *FuturesTrader) GetOpenOrders(symbol string) ([]types.OpenOrder, error) {
var result []types.OpenOrder
// 1. Get legacy open orders
orders, err := t.client.NewListOpenOrdersService().
Symbol(symbol).
Do(context.Background())
if err != nil {
return nil, fmt.Errorf("failed to get open orders: %w", err)
}
for _, order := range orders {
price, _ := strconv.ParseFloat(order.Price, 64)
stopPrice, _ := strconv.ParseFloat(order.StopPrice, 64)
quantity, _ := strconv.ParseFloat(order.OrigQuantity, 64)
result = append(result, types.OpenOrder{
OrderID: fmt.Sprintf("%d", order.OrderID),
Symbol: order.Symbol,
Side: string(order.Side),
PositionSide: string(order.PositionSide),
Type: string(order.Type),
Price: price,
StopPrice: stopPrice,
Quantity: quantity,
Status: string(order.Status),
})
}
// 2. Get Algo orders (new API for stop-loss/take-profit)
algoOrders, err := t.client.NewListOpenAlgoOrdersService().
Symbol(symbol).
Do(context.Background())
if err == nil {
for _, algoOrder := range algoOrders {
triggerPrice, _ := strconv.ParseFloat(algoOrder.TriggerPrice, 64)
quantity, _ := strconv.ParseFloat(algoOrder.Quantity, 64)
result = append(result, types.OpenOrder{
OrderID: fmt.Sprintf("%d", algoOrder.AlgoId),
Symbol: algoOrder.Symbol,
Side: string(algoOrder.Side),
PositionSide: string(algoOrder.PositionSide),
Type: string(algoOrder.OrderType),
Price: 0, // Algo orders use stop price
StopPrice: triggerPrice,
Quantity: quantity,
Status: "NEW",
})
}
}
return result, nil
}
// SetStopLoss sets stop-loss order using new Algo Order API
// Binance has migrated stop orders to Algo Order system (error -4120 STOP_ORDER_SWITCH_ALGO)
func (t *FuturesTrader) SetStopLoss(symbol string, positionSide string, quantity, stopPrice float64) error {
var side futures.SideType
var posSide futures.PositionSideType
if positionSide == "LONG" {
side = futures.SideTypeSell
posSide = futures.PositionSideTypeLong
} else {
side = futures.SideTypeBuy
posSide = futures.PositionSideTypeShort
}
// Use new Algo Order API
_, err := t.client.NewCreateAlgoOrderService().
Symbol(symbol).
Side(side).
PositionSide(posSide).
Type(futures.AlgoOrderTypeStopMarket).
TriggerPrice(fmt.Sprintf("%.8f", stopPrice)).
WorkingType(futures.WorkingTypeContractPrice).
ClosePosition(true).
ClientAlgoId(getBrOrderID()).
Do(context.Background())
if err != nil {
return fmt.Errorf("failed to set stop-loss: %w", err)
}
logger.Infof(" Stop-loss price set (Algo Order): %.4f", stopPrice)
return nil
}
// SetTakeProfit sets take-profit order using new Algo Order API
// Binance has migrated stop orders to Algo Order system (error -4120 STOP_ORDER_SWITCH_ALGO)
func (t *FuturesTrader) SetTakeProfit(symbol string, positionSide string, quantity, takeProfitPrice float64) error {
var side futures.SideType
var posSide futures.PositionSideType
if positionSide == "LONG" {
side = futures.SideTypeSell
posSide = futures.PositionSideTypeLong
} else {
side = futures.SideTypeBuy
posSide = futures.PositionSideTypeShort
}
// Use new Algo Order API
_, err := t.client.NewCreateAlgoOrderService().
Symbol(symbol).
Side(side).
PositionSide(posSide).
Type(futures.AlgoOrderTypeTakeProfitMarket).
TriggerPrice(fmt.Sprintf("%.8f", takeProfitPrice)).
WorkingType(futures.WorkingTypeContractPrice).
ClosePosition(true).
ClientAlgoId(getBrOrderID()).
Do(context.Background())
if err != nil {
return fmt.Errorf("failed to set take-profit: %w", err)
}
logger.Infof(" Take-profit price set (Algo Order): %.4f", takeProfitPrice)
return nil
}
// GetOrderStatus gets order status
func (t *FuturesTrader) GetOrderStatus(symbol string, orderID string) (map[string]interface{}, error) {
// Convert orderID to int64
orderIDInt, err := strconv.ParseInt(orderID, 10, 64)
if err != nil {
return nil, fmt.Errorf("invalid order ID: %s", orderID)
}
order, err := t.client.NewGetOrderService().
Symbol(symbol).
OrderID(orderIDInt).
Do(context.Background())
if err != nil {
return nil, fmt.Errorf("failed to get order status: %w", err)
}
// Parse execution price
avgPrice, _ := strconv.ParseFloat(order.AvgPrice, 64)
executedQty, _ := strconv.ParseFloat(order.ExecutedQuantity, 64)
result := map[string]interface{}{
"orderId": order.OrderID,
"symbol": order.Symbol,
"status": string(order.Status),
"avgPrice": avgPrice,
"executedQty": executedQty,
"side": string(order.Side),
"type": string(order.Type),
"time": order.Time,
"updateTime": order.UpdateTime,
}
// Binance futures commission fee needs to be obtained through GetUserTrades, not retrieved here for now
// Can be obtained later through WebSocket or separate query
result["commission"] = 0.0
return result, nil
}

View File

@@ -0,0 +1,290 @@
package binance
import (
"context"
"fmt"
"nofx/logger"
"strconv"
"time"
"github.com/adshao/go-binance/v2/futures"
)
// GetPositions gets all positions (with cache)
func (t *FuturesTrader) GetPositions() ([]map[string]interface{}, error) {
// First check if cache is valid
t.positionsCacheMutex.RLock()
if t.cachedPositions != nil && time.Since(t.positionsCacheTime) < t.cacheDuration {
cacheAge := time.Since(t.positionsCacheTime)
t.positionsCacheMutex.RUnlock()
logger.Infof("✓ Using cached position information (cache age: %.1f seconds ago)", cacheAge.Seconds())
return t.cachedPositions, nil
}
t.positionsCacheMutex.RUnlock()
// Cache expired or doesn't exist, call API
logger.Infof("🔄 Cache expired, calling Binance API to get position information...")
positions, err := t.client.NewGetPositionRiskService().Do(context.Background())
if err != nil {
return nil, fmt.Errorf("failed to get positions: %w", err)
}
var result []map[string]interface{}
for _, pos := range positions {
posAmt, _ := strconv.ParseFloat(pos.PositionAmt, 64)
if posAmt == 0 {
continue // Skip positions with zero amount
}
posMap := make(map[string]interface{})
posMap["symbol"] = pos.Symbol
posMap["positionAmt"], _ = strconv.ParseFloat(pos.PositionAmt, 64)
posMap["entryPrice"], _ = strconv.ParseFloat(pos.EntryPrice, 64)
posMap["markPrice"], _ = strconv.ParseFloat(pos.MarkPrice, 64)
posMap["unRealizedProfit"], _ = strconv.ParseFloat(pos.UnRealizedProfit, 64)
posMap["leverage"], _ = strconv.ParseFloat(pos.Leverage, 64)
posMap["liquidationPrice"], _ = strconv.ParseFloat(pos.LiquidationPrice, 64)
// Note: Binance SDK doesn't expose updateTime field, will fallback to local tracking
// Determine direction
if posAmt > 0 {
posMap["side"] = "long"
} else {
posMap["side"] = "short"
}
result = append(result, posMap)
}
// Update cache
t.positionsCacheMutex.Lock()
t.cachedPositions = result
t.positionsCacheTime = time.Now()
t.positionsCacheMutex.Unlock()
return result, nil
}
// SetMarginMode sets margin mode
func (t *FuturesTrader) SetMarginMode(symbol string, isCrossMargin bool) error {
var marginType futures.MarginType
if isCrossMargin {
marginType = futures.MarginTypeCrossed
} else {
marginType = futures.MarginTypeIsolated
}
// Try to set margin mode
err := t.client.NewChangeMarginTypeService().
Symbol(symbol).
MarginType(marginType).
Do(context.Background())
marginModeStr := "Cross Margin"
if !isCrossMargin {
marginModeStr = "Isolated Margin"
}
if err != nil {
// If error message contains "No need to change", margin mode is already set to target value
if contains(err.Error(), "No need to change margin type") {
logger.Infof(" ✓ %s margin mode is already %s", symbol, marginModeStr)
return nil
}
// If there is an open position, margin mode cannot be changed, but this doesn't affect trading
if contains(err.Error(), "Margin type cannot be changed if there exists position") {
logger.Infof(" ⚠️ %s has open positions, cannot change margin mode, continuing with current mode", symbol)
return nil
}
// Detect Multi-Assets mode (error code -4168)
if contains(err.Error(), "Multi-Assets mode") || contains(err.Error(), "-4168") || contains(err.Error(), "4168") {
logger.Infof(" ⚠️ %s detected Multi-Assets mode, forcing Cross Margin mode", symbol)
logger.Infof(" 💡 Tip: To use Isolated Margin mode, please disable Multi-Assets mode in Binance")
return nil
}
// Detect Unified Account API (Portfolio Margin)
if contains(err.Error(), "unified") || contains(err.Error(), "portfolio") || contains(err.Error(), "Portfolio") {
logger.Infof(" ❌ %s detected Unified Account API, unable to trade futures", symbol)
return fmt.Errorf("please use 'Spot & Futures Trading' API permission, do not use 'Unified Account API'")
}
logger.Infof(" ⚠️ Failed to set margin mode: %v", err)
// Don't return error, let trading continue
return nil
}
logger.Infof(" ✓ %s margin mode set to %s", symbol, marginModeStr)
return nil
}
// SetLeverage sets leverage (with smart detection and cooldown period)
func (t *FuturesTrader) SetLeverage(symbol string, leverage int) error {
// First try to get current leverage (from position information)
currentLeverage := 0
positions, err := t.GetPositions()
if err == nil {
for _, pos := range positions {
if pos["symbol"] == symbol {
if lev, ok := pos["leverage"].(float64); ok {
currentLeverage = int(lev)
break
}
}
}
}
// If current leverage is already the target leverage, skip
if currentLeverage == leverage && currentLeverage > 0 {
logger.Infof(" ✓ %s leverage is already %dx, no need to change", symbol, leverage)
return nil
}
// Change leverage
_, err = t.client.NewChangeLeverageService().
Symbol(symbol).
Leverage(leverage).
Do(context.Background())
if err != nil {
// If error message contains "No need to change", leverage is already the target value
if contains(err.Error(), "No need to change") {
logger.Infof(" ✓ %s leverage is already %dx", symbol, leverage)
return nil
}
return fmt.Errorf("failed to set leverage: %w", err)
}
logger.Infof(" ✓ %s leverage changed to %dx", symbol, leverage)
// Wait 5 seconds after changing leverage (to avoid cooldown period errors)
logger.Infof(" ⏱ Waiting 5 seconds for cooldown period...")
time.Sleep(5 * time.Second)
return nil
}
// GetMarketPrice gets market price
func (t *FuturesTrader) GetMarketPrice(symbol string) (float64, error) {
prices, err := t.client.NewListPricesService().Symbol(symbol).Do(context.Background())
if err != nil {
return 0, fmt.Errorf("failed to get price: %w", err)
}
if len(prices) == 0 {
return 0, fmt.Errorf("price not found")
}
price, err := strconv.ParseFloat(prices[0].Price, 64)
if err != nil {
return 0, err
}
return price, nil
}
// CalculatePositionSize calculates position size
func (t *FuturesTrader) CalculatePositionSize(balance, riskPercent, price float64, leverage int) float64 {
riskAmount := balance * (riskPercent / 100.0)
positionValue := riskAmount * float64(leverage)
quantity := positionValue / price
return quantity
}
// GetMinNotional gets minimum notional value (Binance requirement)
func (t *FuturesTrader) GetMinNotional(symbol string) float64 {
// Use conservative default value of 10 USDT to ensure order passes exchange validation
return 10.0
}
// CheckMinNotional checks if order meets minimum notional value requirement
func (t *FuturesTrader) CheckMinNotional(symbol string, quantity float64) error {
price, err := t.GetMarketPrice(symbol)
if err != nil {
return fmt.Errorf("failed to get market price: %w", err)
}
notionalValue := quantity * price
minNotional := t.GetMinNotional(symbol)
if notionalValue < minNotional {
return fmt.Errorf(
"order amount %.2f USDT is below minimum requirement %.2f USDT (quantity: %.4f, price: %.4f)",
notionalValue, minNotional, quantity, price,
)
}
return nil
}
// GetSymbolPrecision gets the quantity precision for a trading pair
func (t *FuturesTrader) GetSymbolPrecision(symbol string) (int, error) {
exchangeInfo, err := t.client.NewExchangeInfoService().Do(context.Background())
if err != nil {
return 0, fmt.Errorf("failed to get trading rules: %w", err)
}
for _, s := range exchangeInfo.Symbols {
if s.Symbol == symbol {
// Get precision from LOT_SIZE filter
for _, filter := range s.Filters {
if filter["filterType"] == "LOT_SIZE" {
stepSize := filter["stepSize"].(string)
precision := calculatePrecision(stepSize)
logger.Infof(" %s quantity precision: %d (stepSize: %s)", symbol, precision, stepSize)
return precision, nil
}
}
}
}
logger.Infof(" ⚠ %s precision information not found, using default precision 3", symbol)
return 3, nil // Default precision is 3
}
// FormatQuantity formats quantity to correct precision
func (t *FuturesTrader) FormatQuantity(symbol string, quantity float64) (string, error) {
precision, err := t.GetSymbolPrecision(symbol)
if err != nil {
// If retrieval fails, use default format
return fmt.Sprintf("%.3f", quantity), nil
}
format := fmt.Sprintf("%%.%df", precision)
return fmt.Sprintf(format, quantity), nil
}
// GetSymbolPricePrecision gets the price precision for a trading pair
func (t *FuturesTrader) GetSymbolPricePrecision(symbol string) (int, error) {
exchangeInfo, err := t.client.NewExchangeInfoService().Do(context.Background())
if err != nil {
return 0, fmt.Errorf("failed to get trading rules: %w", err)
}
for _, s := range exchangeInfo.Symbols {
if s.Symbol == symbol {
// Get precision from PRICE_FILTER filter
for _, filter := range s.Filters {
if filter["filterType"] == "PRICE_FILTER" {
tickSize := filter["tickSize"].(string)
precision := calculatePrecision(tickSize)
return precision, nil
}
}
}
}
// Default to 2 decimal places for price
return 2, nil
}
// FormatPrice formats price to correct precision
func (t *FuturesTrader) FormatPrice(symbol string, price float64) (string, error) {
precision, err := t.GetSymbolPricePrecision(symbol)
if err != nil {
// If retrieval fails, use default format
return fmt.Sprintf("%.2f", price), nil
}
format := fmt.Sprintf("%%.%df", precision)
return fmt.Sprintf(format, price), nil
}

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,199 @@
package bitget
import (
"encoding/json"
"fmt"
"nofx/logger"
"strconv"
"strings"
"time"
)
// GetBalance gets account balance
func (t *BitgetTrader) GetBalance() (map[string]interface{}, error) {
// Check cache
t.balanceCacheMutex.RLock()
if t.cachedBalance != nil && time.Since(t.balanceCacheTime) < t.cacheDuration {
t.balanceCacheMutex.RUnlock()
return t.cachedBalance, nil
}
t.balanceCacheMutex.RUnlock()
params := map[string]interface{}{
"productType": "USDT-FUTURES",
}
data, err := t.doRequest("GET", bitgetAccountPath, params)
if err != nil {
return nil, fmt.Errorf("failed to get account balance: %w", err)
}
var accounts []struct {
MarginCoin string `json:"marginCoin"`
Available string `json:"available"` // Available balance
AccountEquity string `json:"accountEquity"` // Total equity
UsdtEquity string `json:"usdtEquity"` // USDT equity
UnrealizedPL string `json:"unrealizedPL"` // Unrealized P&L
}
if err := json.Unmarshal(data, &accounts); err != nil {
return nil, fmt.Errorf("failed to parse balance data: %w, raw: %s", err, string(data))
}
var totalEquity, availableBalance, unrealizedPnL float64
for _, acc := range accounts {
if acc.MarginCoin == "USDT" {
totalEquity, _ = strconv.ParseFloat(acc.AccountEquity, 64)
availableBalance, _ = strconv.ParseFloat(acc.Available, 64)
unrealizedPnL, _ = strconv.ParseFloat(acc.UnrealizedPL, 64)
logger.Infof("✓ [Bitget] Balance: equity=%.2f, available=%.2f", totalEquity, availableBalance)
break
}
}
result := map[string]interface{}{
"totalWalletBalance": totalEquity - unrealizedPnL,
"availableBalance": availableBalance,
"totalUnrealizedProfit": unrealizedPnL,
"total_equity": totalEquity,
}
// Update cache
t.balanceCacheMutex.Lock()
t.cachedBalance = result
t.balanceCacheTime = time.Now()
t.balanceCacheMutex.Unlock()
return result, nil
}
// SetMarginMode sets margin mode
func (t *BitgetTrader) SetMarginMode(symbol string, isCrossMargin bool) error {
symbol = t.convertSymbol(symbol)
marginMode := "isolated"
if isCrossMargin {
marginMode = "crossed"
}
body := map[string]interface{}{
"symbol": symbol,
"productType": "USDT-FUTURES",
"marginCoin": "USDT",
"marginMode": marginMode,
}
_, err := t.doRequest("POST", bitgetMarginModePath, body)
if err != nil {
if strings.Contains(err.Error(), "same") || strings.Contains(err.Error(), "already") {
return nil
}
if strings.Contains(err.Error(), "position") {
logger.Infof(" ⚠️ %s has positions, cannot change margin mode", symbol)
return nil
}
return err
}
logger.Infof(" ✓ %s margin mode set to %s", symbol, marginMode)
return nil
}
// SetLeverage sets leverage
func (t *BitgetTrader) SetLeverage(symbol string, leverage int) error {
symbol = t.convertSymbol(symbol)
body := map[string]interface{}{
"symbol": symbol,
"productType": "USDT-FUTURES",
"marginCoin": "USDT",
"leverage": fmt.Sprintf("%d", leverage),
}
_, err := t.doRequest("POST", bitgetLeveragePath, body)
if err != nil {
if strings.Contains(err.Error(), "same") {
return nil
}
logger.Infof(" ⚠️ Failed to set %s leverage: %v", symbol, err)
return err
}
logger.Infof(" ✓ %s leverage set to %dx", symbol, leverage)
return nil
}
// GetMarketPrice gets market price
func (t *BitgetTrader) GetMarketPrice(symbol string) (float64, error) {
symbol = t.convertSymbol(symbol)
params := map[string]interface{}{
"symbol": symbol,
"productType": "USDT-FUTURES",
}
data, err := t.doRequest("GET", bitgetTickerPath, params)
if err != nil {
return 0, fmt.Errorf("failed to get price: %w", err)
}
var tickers []struct {
LastPr string `json:"lastPr"`
}
if err := json.Unmarshal(data, &tickers); err != nil {
return 0, err
}
if len(tickers) == 0 {
return 0, fmt.Errorf("no price data received")
}
price, err := strconv.ParseFloat(tickers[0].LastPr, 64)
if err != nil {
return 0, err
}
return price, nil
}
// GetOrderBook gets the order book for a symbol
// Implements GridTrader interface
func (t *BitgetTrader) GetOrderBook(symbol string, depth int) (bids, asks [][]float64, err error) {
symbol = t.convertSymbol(symbol)
path := fmt.Sprintf("/api/v2/mix/market/depth?symbol=%s&productType=USDT-FUTURES&limit=%d", symbol, depth)
data, err := t.doRequest("GET", path, nil)
if err != nil {
return nil, nil, fmt.Errorf("failed to get order book: %w", err)
}
var result struct {
Bids [][]string `json:"bids"`
Asks [][]string `json:"asks"`
}
if err := json.Unmarshal(data, &result); err != nil {
return nil, nil, fmt.Errorf("failed to parse order book: %w", err)
}
// Parse bids
for _, b := range result.Bids {
if len(b) >= 2 {
price, _ := strconv.ParseFloat(b[0], 64)
qty, _ := strconv.ParseFloat(b[1], 64)
bids = append(bids, []float64{price, qty})
}
}
// Parse asks
for _, a := range result.Asks {
if len(a) >= 2 {
price, _ := strconv.ParseFloat(a[0], 64)
qty, _ := strconv.ParseFloat(a[1], 64)
asks = append(asks, []float64{price, qty})
}
}
return bids, asks, nil
}

View File

@@ -0,0 +1,711 @@
package bitget
import (
"encoding/json"
"fmt"
"nofx/logger"
"nofx/trader/types"
"strconv"
"strings"
)
// OpenLong opens long position
func (t *BitgetTrader) OpenLong(symbol string, quantity float64, leverage int) (map[string]interface{}, error) {
symbol = t.convertSymbol(symbol)
// Cancel old orders first
t.CancelAllOrders(symbol)
// Set leverage
if err := t.SetLeverage(symbol, leverage); err != nil {
logger.Infof(" ⚠️ Failed to set leverage: %v", err)
}
// Format quantity
qtyStr, _ := t.FormatQuantity(symbol, quantity)
body := map[string]interface{}{
"symbol": symbol,
"productType": "USDT-FUTURES",
"marginMode": "crossed",
"marginCoin": "USDT",
"side": "buy",
"orderType": "market",
"size": qtyStr,
"clientOid": genBitgetClientOid(),
}
logger.Infof(" 📊 Bitget OpenLong: symbol=%s, qty=%s, leverage=%d", symbol, qtyStr, leverage)
data, err := t.doRequest("POST", bitgetOrderPath, body)
if err != nil {
return nil, fmt.Errorf("failed to open long position: %w", err)
}
var order struct {
OrderId string `json:"orderId"`
ClientOid string `json:"clientOid"`
}
if err := json.Unmarshal(data, &order); err != nil {
return nil, fmt.Errorf("failed to parse order response: %w", err)
}
// Clear cache
t.clearCache()
logger.Infof("✓ Bitget opened long position successfully: %s", symbol)
return map[string]interface{}{
"orderId": order.OrderId,
"symbol": symbol,
"status": "FILLED",
}, nil
}
// OpenShort opens short position
func (t *BitgetTrader) OpenShort(symbol string, quantity float64, leverage int) (map[string]interface{}, error) {
symbol = t.convertSymbol(symbol)
// Cancel old orders first
t.CancelAllOrders(symbol)
// Set leverage
if err := t.SetLeverage(symbol, leverage); err != nil {
logger.Infof(" ⚠️ Failed to set leverage: %v", err)
}
// Format quantity
qtyStr, _ := t.FormatQuantity(symbol, quantity)
body := map[string]interface{}{
"symbol": symbol,
"productType": "USDT-FUTURES",
"marginMode": "crossed",
"marginCoin": "USDT",
"side": "sell",
"orderType": "market",
"size": qtyStr,
"clientOid": genBitgetClientOid(),
}
logger.Infof(" 📊 Bitget OpenShort: symbol=%s, qty=%s, leverage=%d", symbol, qtyStr, leverage)
data, err := t.doRequest("POST", bitgetOrderPath, body)
if err != nil {
return nil, fmt.Errorf("failed to open short position: %w", err)
}
var order struct {
OrderId string `json:"orderId"`
ClientOid string `json:"clientOid"`
}
if err := json.Unmarshal(data, &order); err != nil {
return nil, fmt.Errorf("failed to parse order response: %w", err)
}
// Clear cache
t.clearCache()
logger.Infof("✓ Bitget opened short position successfully: %s", symbol)
return map[string]interface{}{
"orderId": order.OrderId,
"symbol": symbol,
"status": "FILLED",
}, nil
}
// CloseLong closes long position
func (t *BitgetTrader) CloseLong(symbol string, quantity float64) (map[string]interface{}, error) {
symbol = t.convertSymbol(symbol)
// If quantity is 0, get current position
if quantity == 0 {
positions, err := t.GetPositions()
if err != nil {
return nil, err
}
for _, pos := range positions {
if pos["symbol"] == symbol && pos["side"] == "long" {
quantity = pos["positionAmt"].(float64)
break
}
}
if quantity == 0 {
return nil, fmt.Errorf("long position not found for %s", symbol)
}
}
// Format quantity
qtyStr, _ := t.FormatQuantity(symbol, quantity)
body := map[string]interface{}{
"symbol": symbol,
"productType": "USDT-FUTURES",
"marginMode": "crossed",
"marginCoin": "USDT",
"side": "sell",
"orderType": "market",
"size": qtyStr,
"reduceOnly": "YES",
"clientOid": genBitgetClientOid(),
}
logger.Infof(" 📊 Bitget CloseLong: symbol=%s, qty=%s", symbol, qtyStr)
data, err := t.doRequest("POST", bitgetOrderPath, body)
if err != nil {
return nil, fmt.Errorf("failed to close long position: %w", err)
}
var order struct {
OrderId string `json:"orderId"`
}
if err := json.Unmarshal(data, &order); err != nil {
return nil, err
}
// Clear cache
t.clearCache()
logger.Infof("✓ Bitget closed long position successfully: %s", symbol)
return map[string]interface{}{
"orderId": order.OrderId,
"symbol": symbol,
"status": "FILLED",
}, nil
}
// CloseShort closes short position
func (t *BitgetTrader) CloseShort(symbol string, quantity float64) (map[string]interface{}, error) {
symbol = t.convertSymbol(symbol)
// If quantity is 0, get current position
if quantity == 0 {
positions, err := t.GetPositions()
if err != nil {
return nil, err
}
for _, pos := range positions {
if pos["symbol"] == symbol && pos["side"] == "short" {
quantity = pos["positionAmt"].(float64)
break
}
}
if quantity == 0 {
return nil, fmt.Errorf("short position not found for %s", symbol)
}
}
// Ensure quantity is positive
if quantity < 0 {
quantity = -quantity
}
// Format quantity
qtyStr, _ := t.FormatQuantity(symbol, quantity)
body := map[string]interface{}{
"symbol": symbol,
"productType": "USDT-FUTURES",
"marginMode": "crossed",
"marginCoin": "USDT",
"side": "buy",
"orderType": "market",
"size": qtyStr,
"reduceOnly": "YES",
"clientOid": genBitgetClientOid(),
}
logger.Infof(" 📊 Bitget CloseShort: symbol=%s, qty=%s", symbol, qtyStr)
data, err := t.doRequest("POST", bitgetOrderPath, body)
if err != nil {
return nil, fmt.Errorf("failed to close short position: %w", err)
}
var order struct {
OrderId string `json:"orderId"`
}
if err := json.Unmarshal(data, &order); err != nil {
return nil, err
}
// Clear cache
t.clearCache()
logger.Infof("✓ Bitget closed short position successfully: %s", symbol)
return map[string]interface{}{
"orderId": order.OrderId,
"symbol": symbol,
"status": "FILLED",
}, nil
}
// SetStopLoss sets stop loss order
func (t *BitgetTrader) SetStopLoss(symbol string, positionSide string, quantity, stopPrice float64) error {
// Bitget V2 uses plan order for stop loss
symbol = t.convertSymbol(symbol)
side := "sell"
holdSide := "long"
if strings.ToUpper(positionSide) == "SHORT" {
side = "buy"
holdSide = "short"
}
qtyStr, _ := t.FormatQuantity(symbol, quantity)
body := map[string]interface{}{
"planType": "loss_plan",
"symbol": symbol,
"productType": "USDT-FUTURES",
"marginMode": "crossed",
"marginCoin": "USDT",
"triggerPrice": fmt.Sprintf("%.8f", stopPrice),
"triggerType": "mark_price",
"side": side,
"tradeSide": "close",
"orderType": "market",
"size": qtyStr,
"holdSide": holdSide,
"clientOid": genBitgetClientOid(),
}
_, err := t.doRequest("POST", "/api/v2/mix/order/place-plan-order", body)
if err != nil {
return fmt.Errorf("failed to set stop loss: %w", err)
}
logger.Infof(" ✓ [Bitget] Stop loss set: %s @ %.4f", symbol, stopPrice)
return nil
}
// SetTakeProfit sets take profit order
func (t *BitgetTrader) SetTakeProfit(symbol string, positionSide string, quantity, takeProfitPrice float64) error {
// Bitget V2 uses plan order for take profit
symbol = t.convertSymbol(symbol)
side := "sell"
holdSide := "long"
if strings.ToUpper(positionSide) == "SHORT" {
side = "buy"
holdSide = "short"
}
qtyStr, _ := t.FormatQuantity(symbol, quantity)
body := map[string]interface{}{
"planType": "profit_plan",
"symbol": symbol,
"productType": "USDT-FUTURES",
"marginMode": "crossed",
"marginCoin": "USDT",
"triggerPrice": fmt.Sprintf("%.8f", takeProfitPrice),
"triggerType": "mark_price",
"side": side,
"tradeSide": "close",
"orderType": "market",
"size": qtyStr,
"holdSide": holdSide,
"clientOid": genBitgetClientOid(),
}
_, err := t.doRequest("POST", "/api/v2/mix/order/place-plan-order", body)
if err != nil {
return fmt.Errorf("failed to set take profit: %w", err)
}
logger.Infof(" ✓ [Bitget] Take profit set: %s @ %.4f", symbol, takeProfitPrice)
return nil
}
// CancelStopLossOrders cancels stop loss orders
func (t *BitgetTrader) CancelStopLossOrders(symbol string) error {
return t.cancelPlanOrders(symbol, "loss_plan")
}
// CancelTakeProfitOrders cancels take profit orders
func (t *BitgetTrader) CancelTakeProfitOrders(symbol string) error {
return t.cancelPlanOrders(symbol, "profit_plan")
}
// cancelPlanOrders cancels plan orders
func (t *BitgetTrader) cancelPlanOrders(symbol string, planType string) error {
symbol = t.convertSymbol(symbol)
// Get pending plan orders
params := map[string]interface{}{
"symbol": symbol,
"productType": "USDT-FUTURES",
"planType": planType,
}
data, err := t.doRequest("GET", "/api/v2/mix/order/orders-plan-pending", params)
if err != nil {
return err
}
var orders struct {
EntrustedList []struct {
OrderId string `json:"orderId"`
} `json:"entrustedList"`
}
if err := json.Unmarshal(data, &orders); err != nil {
return err
}
// Cancel each order
for _, order := range orders.EntrustedList {
body := map[string]interface{}{
"symbol": symbol,
"productType": "USDT-FUTURES",
"marginCoin": "USDT",
"orderId": order.OrderId,
}
t.doRequest("POST", "/api/v2/mix/order/cancel-plan-order", body)
}
return nil
}
// CancelAllOrders cancels all pending orders
func (t *BitgetTrader) CancelAllOrders(symbol string) error {
symbol = t.convertSymbol(symbol)
// Get pending orders
params := map[string]interface{}{
"symbol": symbol,
"productType": "USDT-FUTURES",
}
data, err := t.doRequest("GET", bitgetPendingPath, params)
if err != nil {
return err
}
var orders struct {
EntrustedList []struct {
OrderId string `json:"orderId"`
} `json:"entrustedList"`
}
if err := json.Unmarshal(data, &orders); err != nil {
return err
}
// Cancel each order
for _, order := range orders.EntrustedList {
body := map[string]interface{}{
"symbol": symbol,
"productType": "USDT-FUTURES",
"marginCoin": "USDT",
"orderId": order.OrderId,
}
t.doRequest("POST", bitgetCancelOrderPath, body)
}
// Also cancel plan orders
t.cancelPlanOrders(symbol, "loss_plan")
t.cancelPlanOrders(symbol, "profit_plan")
return nil
}
// CancelStopOrders cancels stop loss and take profit orders
func (t *BitgetTrader) CancelStopOrders(symbol string) error {
t.CancelStopLossOrders(symbol)
t.CancelTakeProfitOrders(symbol)
return nil
}
// GetOrderStatus gets order status
func (t *BitgetTrader) GetOrderStatus(symbol string, orderID string) (map[string]interface{}, error) {
symbol = t.convertSymbol(symbol)
params := map[string]interface{}{
"symbol": symbol,
"productType": "USDT-FUTURES",
"orderId": orderID,
}
data, err := t.doRequest("GET", "/api/v2/mix/order/detail", params)
if err != nil {
return nil, fmt.Errorf("failed to get order status: %w", err)
}
var order struct {
OrderId string `json:"orderId"`
State string `json:"state"` // filled, canceled, partially_filled, new
PriceAvg string `json:"priceAvg"` // Average fill price
BaseVolume string `json:"baseVolume"` // Filled quantity
Fee string `json:"fee"` // Fee
Side string `json:"side"`
OrderType string `json:"orderType"`
CTime string `json:"cTime"`
UTime string `json:"uTime"`
}
if err := json.Unmarshal(data, &order); err != nil {
return nil, err
}
avgPrice, _ := strconv.ParseFloat(order.PriceAvg, 64)
fillQty, _ := strconv.ParseFloat(order.BaseVolume, 64)
fee, _ := strconv.ParseFloat(order.Fee, 64)
cTime, _ := strconv.ParseInt(order.CTime, 10, 64)
uTime, _ := strconv.ParseInt(order.UTime, 10, 64)
// Status mapping
statusMap := map[string]string{
"filled": "FILLED",
"new": "NEW",
"partially_filled": "PARTIALLY_FILLED",
"canceled": "CANCELED",
}
status := statusMap[order.State]
if status == "" {
status = order.State
}
return map[string]interface{}{
"orderId": order.OrderId,
"symbol": symbol,
"status": status,
"avgPrice": avgPrice,
"executedQty": fillQty,
"side": order.Side,
"type": order.OrderType,
"time": cTime,
"updateTime": uTime,
"commission": -fee,
}, nil
}
// GetOpenOrders gets all open/pending orders for a symbol
func (t *BitgetTrader) GetOpenOrders(symbol string) ([]types.OpenOrder, error) {
symbol = t.convertSymbol(symbol)
var result []types.OpenOrder
// 1. Get pending limit orders
params := map[string]interface{}{
"symbol": symbol,
"productType": "USDT-FUTURES",
}
data, err := t.doRequest("GET", bitgetPendingPath, params)
if err != nil {
logger.Warnf("[Bitget] Failed to get pending orders: %v", err)
}
if err == nil && data != nil {
var orders struct {
EntrustedList []struct {
OrderId string `json:"orderId"`
Symbol string `json:"symbol"`
Side string `json:"side"` // buy/sell
TradeSide string `json:"tradeSide"` // open/close
PosSide string `json:"posSide"` // long/short
OrderType string `json:"orderType"` // limit/market
Price string `json:"price"`
Size string `json:"size"`
State string `json:"state"`
} `json:"entrustedList"`
}
if err := json.Unmarshal(data, &orders); err == nil {
for _, order := range orders.EntrustedList {
price, _ := strconv.ParseFloat(order.Price, 64)
quantity, _ := strconv.ParseFloat(order.Size, 64)
// Convert side to standard format
side := strings.ToUpper(order.Side)
positionSide := strings.ToUpper(order.PosSide)
result = append(result, types.OpenOrder{
OrderID: order.OrderId,
Symbol: symbol,
Side: side,
PositionSide: positionSide,
Type: strings.ToUpper(order.OrderType),
Price: price,
StopPrice: 0,
Quantity: quantity,
Status: "NEW",
})
}
}
}
// 2. Get pending plan orders (stop-loss/take-profit)
// Bitget V2 API requires planType parameter: profit_loss for SL/TP orders
planParams := map[string]interface{}{
"productType": "USDT-FUTURES",
"planType": "profit_loss",
}
planData, err := t.doRequest("GET", "/api/v2/mix/order/orders-plan-pending", planParams)
if err != nil {
logger.Warnf("[Bitget] Failed to get plan orders: %v", err)
}
if err == nil && planData != nil {
var planOrders struct {
EntrustedList []struct {
OrderId string `json:"orderId"`
Symbol string `json:"symbol"`
Side string `json:"side"`
PosSide string `json:"posSide"`
PlanType string `json:"planType"` // pos_loss, pos_profit
TriggerPrice string `json:"triggerPrice"`
StopLossTriggerPrice string `json:"stopLossTriggerPrice"`
StopSurplusTriggerPrice string `json:"stopSurplusTriggerPrice"`
Size string `json:"size"`
PlanStatus string `json:"planStatus"`
} `json:"entrustedList"`
}
if err := json.Unmarshal(planData, &planOrders); err == nil {
for _, order := range planOrders.EntrustedList {
// Filter by symbol if specified
if symbol != "" && order.Symbol != symbol {
continue
}
// Determine trigger price based on plan type
var triggerPrice float64
orderType := "STOP_MARKET"
if order.PlanType == "pos_profit" {
// Take profit order
orderType = "TAKE_PROFIT_MARKET"
if order.StopSurplusTriggerPrice != "" {
triggerPrice, _ = strconv.ParseFloat(order.StopSurplusTriggerPrice, 64)
} else {
triggerPrice, _ = strconv.ParseFloat(order.TriggerPrice, 64)
}
} else {
// Stop loss order (pos_loss)
if order.StopLossTriggerPrice != "" {
triggerPrice, _ = strconv.ParseFloat(order.StopLossTriggerPrice, 64)
} else {
triggerPrice, _ = strconv.ParseFloat(order.TriggerPrice, 64)
}
}
quantity, _ := strconv.ParseFloat(order.Size, 64)
side := strings.ToUpper(order.Side)
positionSide := strings.ToUpper(order.PosSide)
result = append(result, types.OpenOrder{
OrderID: order.OrderId,
Symbol: order.Symbol,
Side: side,
PositionSide: positionSide,
Type: orderType,
Price: 0,
StopPrice: triggerPrice,
Quantity: quantity,
Status: "NEW",
})
}
}
}
logger.Infof("✓ BITGET GetOpenOrders: found %d open orders for %s", len(result), symbol)
return result, nil
}
// PlaceLimitOrder places a limit order for grid trading
// Implements GridTrader interface
func (t *BitgetTrader) PlaceLimitOrder(req *types.LimitOrderRequest) (*types.LimitOrderResult, error) {
symbol := t.convertSymbol(req.Symbol)
// Set leverage if specified
if req.Leverage > 0 {
if err := t.SetLeverage(symbol, req.Leverage); err != nil {
logger.Warnf("[Bitget] Failed to set leverage: %v", err)
}
}
// Format quantity
qtyStr, _ := t.FormatQuantity(symbol, req.Quantity)
// Determine side
side := "buy"
if req.Side == "SELL" {
side = "sell"
}
body := map[string]interface{}{
"symbol": symbol,
"productType": "USDT-FUTURES",
"marginMode": "crossed",
"marginCoin": "USDT",
"side": side,
"orderType": "limit",
"size": qtyStr,
"price": fmt.Sprintf("%.8f", req.Price),
"force": "GTC", // Good Till Cancel
"clientOid": genBitgetClientOid(),
}
// Add reduce only if specified
if req.ReduceOnly {
body["reduceOnly"] = "YES"
}
logger.Infof("[Bitget] PlaceLimitOrder: %s %s @ %.4f, qty=%s", symbol, side, req.Price, qtyStr)
data, err := t.doRequest("POST", bitgetOrderPath, body)
if err != nil {
return nil, fmt.Errorf("failed to place limit order: %w", err)
}
var order struct {
OrderId string `json:"orderId"`
ClientOid string `json:"clientOid"`
}
if err := json.Unmarshal(data, &order); err != nil {
return nil, fmt.Errorf("failed to parse order response: %w", err)
}
logger.Infof("✓ [Bitget] Limit order placed: %s %s @ %.4f, orderID=%s",
symbol, side, req.Price, order.OrderId)
return &types.LimitOrderResult{
OrderID: order.OrderId,
ClientID: order.ClientOid,
Symbol: req.Symbol,
Side: req.Side,
PositionSide: req.PositionSide,
Price: req.Price,
Quantity: req.Quantity,
Status: "NEW",
}, nil
}
// CancelOrder cancels a specific order by ID
// Implements GridTrader interface
func (t *BitgetTrader) CancelOrder(symbol, orderID string) error {
symbol = t.convertSymbol(symbol)
body := map[string]interface{}{
"symbol": symbol,
"productType": "USDT-FUTURES",
"orderId": orderID,
}
_, err := t.doRequest("POST", "/api/v2/mix/order/cancel-order", body)
if err != nil {
return fmt.Errorf("failed to cancel order: %w", err)
}
logger.Infof("✓ [Bitget] Order cancelled: %s %s", symbol, orderID)
return nil
}

View File

@@ -0,0 +1,160 @@
package bitget
import (
"encoding/json"
"fmt"
"nofx/trader/types"
"strconv"
"time"
)
// GetPositions gets all positions
func (t *BitgetTrader) GetPositions() ([]map[string]interface{}, error) {
// Check cache
t.positionsCacheMutex.RLock()
if t.cachedPositions != nil && time.Since(t.positionsCacheTime) < t.cacheDuration {
t.positionsCacheMutex.RUnlock()
return t.cachedPositions, nil
}
t.positionsCacheMutex.RUnlock()
params := map[string]interface{}{
"productType": "USDT-FUTURES",
"marginCoin": "USDT",
}
data, err := t.doRequest("GET", bitgetPositionPath, params)
if err != nil {
return nil, fmt.Errorf("failed to get positions: %w", err)
}
var positions []struct {
Symbol string `json:"symbol"`
HoldSide string `json:"holdSide"` // long, short
OpenPriceAvg string `json:"openPriceAvg"` // Average entry price
MarkPrice string `json:"markPrice"` // Mark price
Total string `json:"total"` // Total position size
Available string `json:"available"` // Available to close
UnrealizedPL string `json:"unrealizedPL"` // Unrealized P&L
Leverage string `json:"leverage"` // Leverage
LiquidationPrice string `json:"liquidationPrice"` // Liquidation price
MarginSize string `json:"marginSize"` // Position margin
CTime string `json:"cTime"` // Create time
UTime string `json:"uTime"` // Update time
}
if err := json.Unmarshal(data, &positions); err != nil {
return nil, fmt.Errorf("failed to parse position data: %w", err)
}
var result []map[string]interface{}
for _, pos := range positions {
total, _ := strconv.ParseFloat(pos.Total, 64)
if total == 0 {
continue
}
entryPrice, _ := strconv.ParseFloat(pos.OpenPriceAvg, 64)
markPrice, _ := strconv.ParseFloat(pos.MarkPrice, 64)
unrealizedPnL, _ := strconv.ParseFloat(pos.UnrealizedPL, 64)
leverage, _ := strconv.ParseFloat(pos.Leverage, 64)
liqPrice, _ := strconv.ParseFloat(pos.LiquidationPrice, 64)
cTime, _ := strconv.ParseInt(pos.CTime, 10, 64)
uTime, _ := strconv.ParseInt(pos.UTime, 10, 64)
// Normalize side
side := "long"
if pos.HoldSide == "short" {
side = "short"
}
posMap := map[string]interface{}{
"symbol": pos.Symbol,
"positionAmt": total,
"entryPrice": entryPrice,
"markPrice": markPrice,
"unRealizedProfit": unrealizedPnL,
"leverage": leverage,
"liquidationPrice": liqPrice,
"side": side,
"createdTime": cTime,
"updatedTime": uTime,
}
result = append(result, posMap)
}
// Update cache
t.positionsCacheMutex.Lock()
t.cachedPositions = result
t.positionsCacheTime = time.Now()
t.positionsCacheMutex.Unlock()
return result, nil
}
// GetClosedPnL retrieves closed position PnL records
func (t *BitgetTrader) GetClosedPnL(startTime time.Time, limit int) ([]types.ClosedPnLRecord, error) {
if limit <= 0 {
limit = 100
}
if limit > 100 {
limit = 100
}
params := map[string]interface{}{
"productType": "USDT-FUTURES",
"startTime": fmt.Sprintf("%d", startTime.UnixMilli()),
"limit": fmt.Sprintf("%d", limit),
}
data, err := t.doRequest("GET", "/api/v2/mix/position/history-position", params)
if err != nil {
return nil, fmt.Errorf("failed to get positions history: %w", err)
}
var resp struct {
List []struct {
Symbol string `json:"symbol"`
HoldSide string `json:"holdSide"`
OpenPriceAvg string `json:"openPriceAvg"`
ClosePriceAvg string `json:"closePriceAvg"`
CloseVol string `json:"closeVol"`
AchievedProfits string `json:"achievedProfits"`
TotalFee string `json:"totalFee"`
Leverage string `json:"leverage"`
CTime string `json:"cTime"`
UTime string `json:"uTime"`
} `json:"list"`
}
if err := json.Unmarshal(data, &resp); err != nil {
return nil, fmt.Errorf("failed to parse response: %w", err)
}
records := make([]types.ClosedPnLRecord, 0, len(resp.List))
for _, pos := range resp.List {
record := types.ClosedPnLRecord{
Symbol: pos.Symbol,
Side: pos.HoldSide,
}
record.EntryPrice, _ = strconv.ParseFloat(pos.OpenPriceAvg, 64)
record.ExitPrice, _ = strconv.ParseFloat(pos.ClosePriceAvg, 64)
record.Quantity, _ = strconv.ParseFloat(pos.CloseVol, 64)
record.RealizedPnL, _ = strconv.ParseFloat(pos.AchievedProfits, 64)
fee, _ := strconv.ParseFloat(pos.TotalFee, 64)
record.Fee = -fee
lev, _ := strconv.ParseFloat(pos.Leverage, 64)
record.Leverage = int(lev)
cTime, _ := strconv.ParseInt(pos.CTime, 10, 64)
uTime, _ := strconv.ParseInt(pos.UTime, 10, 64)
record.EntryTime = time.UnixMilli(cTime).UTC()
record.ExitTime = time.UnixMilli(uTime).UTC()
record.CloseType = "unknown"
records = append(records, record)
}
return records, nil
}

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,236 @@
package bybit
import (
"context"
"crypto/hmac"
"crypto/sha256"
"encoding/hex"
"encoding/json"
"fmt"
"io"
"net/http"
"nofx/trader/types"
"strconv"
"time"
)
// GetBalance retrieves account balance
func (t *BybitTrader) GetBalance() (map[string]interface{}, error) {
// Check cache
t.balanceCacheMutex.RLock()
if t.cachedBalance != nil && time.Since(t.balanceCacheTime) < t.cacheDuration {
balance := t.cachedBalance
t.balanceCacheMutex.RUnlock()
return balance, nil
}
t.balanceCacheMutex.RUnlock()
// Call API
params := map[string]interface{}{
"accountType": "UNIFIED",
}
result, err := t.client.NewUtaBybitServiceWithParams(params).GetAccountWallet(context.Background())
if err != nil {
return nil, fmt.Errorf("failed to get Bybit balance: %w", err)
}
if result.RetCode != 0 {
return nil, fmt.Errorf("Bybit API error: %s", result.RetMsg)
}
// Extract balance information
resultData, ok := result.Result.(map[string]interface{})
if !ok {
return nil, fmt.Errorf("Bybit balance return format error")
}
list, _ := resultData["list"].([]interface{})
var totalEquity, availableBalance, totalWalletBalance, totalPerpUPL float64 = 0, 0, 0, 0
if len(list) > 0 {
account, _ := list[0].(map[string]interface{})
if equityStr, ok := account["totalEquity"].(string); ok {
totalEquity, _ = strconv.ParseFloat(equityStr, 64)
}
if availStr, ok := account["totalAvailableBalance"].(string); ok {
availableBalance, _ = strconv.ParseFloat(availStr, 64)
}
// Bybit UNIFIED account wallet balance field
if walletStr, ok := account["totalWalletBalance"].(string); ok {
totalWalletBalance, _ = strconv.ParseFloat(walletStr, 64)
}
// Bybit perpetual contract unrealized PnL
if uplStr, ok := account["totalPerpUPL"].(string); ok {
totalPerpUPL, _ = strconv.ParseFloat(uplStr, 64)
}
}
// If no totalWalletBalance, use totalEquity
if totalWalletBalance == 0 {
totalWalletBalance = totalEquity
}
balance := map[string]interface{}{
"totalEquity": totalEquity,
"totalWalletBalance": totalWalletBalance,
"availableBalance": availableBalance,
"totalUnrealizedProfit": totalPerpUPL,
"balance": totalEquity, // Compatible with other exchange formats
}
// Update cache
t.balanceCacheMutex.Lock()
t.cachedBalance = balance
t.balanceCacheTime = time.Now()
t.balanceCacheMutex.Unlock()
return balance, nil
}
// GetClosedPnL retrieves closed position PnL records from Bybit via direct HTTP API
func (t *BybitTrader) GetClosedPnL(startTime time.Time, limit int) ([]types.ClosedPnLRecord, error) {
// The Bybit SDK doesn't expose the closed-pnl endpoint, use direct HTTP call
return t.getClosedPnLViaHTTP(startTime, limit)
}
// getClosedPnLViaHTTP makes direct HTTP call to Bybit API for closed PnL with proper signing
func (t *BybitTrader) getClosedPnLViaHTTP(startTime time.Time, limit int) ([]types.ClosedPnLRecord, error) {
// Build query string
queryParams := fmt.Sprintf("category=linear&startTime=%d&limit=%d", startTime.UnixMilli(), limit)
url := "https://api.bybit.com/v5/position/closed-pnl?" + queryParams
// Generate timestamp
timestamp := fmt.Sprintf("%d", time.Now().UnixMilli())
recvWindow := "5000"
// Build signature payload: timestamp + api_key + recv_window + queryString
signPayload := timestamp + t.apiKey + recvWindow + queryParams
// Generate HMAC-SHA256 signature
h := hmac.New(sha256.New, []byte(t.secretKey))
h.Write([]byte(signPayload))
signature := hex.EncodeToString(h.Sum(nil))
// Create request
req, err := http.NewRequest("GET", url, nil)
if err != nil {
return nil, fmt.Errorf("failed to create request: %w", err)
}
// Add Bybit V5 API headers
req.Header.Set("X-BAPI-API-KEY", t.apiKey)
req.Header.Set("X-BAPI-SIGN", signature)
req.Header.Set("X-BAPI-SIGN-TYPE", "2")
req.Header.Set("X-BAPI-TIMESTAMP", timestamp)
req.Header.Set("X-BAPI-RECV-WINDOW", recvWindow)
req.Header.Set("Content-Type", "application/json")
// Use http.DefaultClient for the request
resp, err := http.DefaultClient.Do(req)
if err != nil {
return nil, fmt.Errorf("failed to call Bybit API: %w", err)
}
defer resp.Body.Close()
body, err := io.ReadAll(resp.Body)
if err != nil {
return nil, fmt.Errorf("failed to read response: %w", err)
}
var result struct {
RetCode int `json:"retCode"`
RetMsg string `json:"retMsg"`
Result map[string]interface{} `json:"result"`
}
if err := json.Unmarshal(body, &result); err != nil {
return nil, fmt.Errorf("failed to parse response: %w", err)
}
if result.RetCode != 0 {
return nil, fmt.Errorf("Bybit API error: %s", result.RetMsg)
}
return t.parseClosedPnLResult(result.Result)
}
// parseClosedPnLResult parses the closed PnL result from Bybit API
func (t *BybitTrader) parseClosedPnLResult(resultData interface{}) ([]types.ClosedPnLRecord, error) {
data, ok := resultData.(map[string]interface{})
if !ok {
return nil, fmt.Errorf("invalid result format")
}
list, _ := data["list"].([]interface{})
var records []types.ClosedPnLRecord
for _, item := range list {
pnl, ok := item.(map[string]interface{})
if !ok {
continue
}
// Parse fields
symbol, _ := pnl["symbol"].(string)
side, _ := pnl["side"].(string)
orderId, _ := pnl["orderId"].(string)
avgEntryPriceStr, _ := pnl["avgEntryPrice"].(string)
avgExitPriceStr, _ := pnl["avgExitPrice"].(string)
qtyStr, _ := pnl["qty"].(string)
closedPnLStr, _ := pnl["closedPnl"].(string)
cumEntryValueStr, _ := pnl["cumEntryValue"].(string)
cumExitValueStr, _ := pnl["cumExitValue"].(string)
leverageStr, _ := pnl["leverage"].(string)
createdTimeStr, _ := pnl["createdTime"].(string)
updatedTimeStr, _ := pnl["updatedTime"].(string)
avgEntryPrice, _ := strconv.ParseFloat(avgEntryPriceStr, 64)
avgExitPrice, _ := strconv.ParseFloat(avgExitPriceStr, 64)
qty, _ := strconv.ParseFloat(qtyStr, 64)
closedPnL, _ := strconv.ParseFloat(closedPnLStr, 64)
leverage, _ := strconv.ParseInt(leverageStr, 10, 64)
createdTime, _ := strconv.ParseInt(createdTimeStr, 10, 64)
updatedTime, _ := strconv.ParseInt(updatedTimeStr, 10, 64)
// Calculate approximate fee from value difference
cumEntryValue, _ := strconv.ParseFloat(cumEntryValueStr, 64)
cumExitValue, _ := strconv.ParseFloat(cumExitValueStr, 64)
expectedPnL := cumExitValue - cumEntryValue
if side == "Sell" {
expectedPnL = cumEntryValue - cumExitValue
}
fee := expectedPnL - closedPnL
if fee < 0 {
fee = 0
}
// Normalize side
normalizedSide := "long"
if side == "Sell" {
normalizedSide = "short"
}
record := types.ClosedPnLRecord{
Symbol: symbol,
Side: normalizedSide,
EntryPrice: avgEntryPrice,
ExitPrice: avgExitPrice,
Quantity: qty,
RealizedPnL: closedPnL,
Fee: fee,
Leverage: int(leverage),
EntryTime: time.UnixMilli(createdTime).UTC(),
ExitTime: time.UnixMilli(updatedTime).UTC(),
OrderID: orderId,
CloseType: "unknown", // Bybit doesn't provide close type directly
ExchangeID: orderId, // Use orderId as exchange ID
}
records = append(records, record)
}
return records, nil
}

View File

@@ -0,0 +1,741 @@
package bybit
import (
"context"
"encoding/json"
"fmt"
"io"
"net/http"
"nofx/logger"
"nofx/trader/types"
"strconv"
"strings"
)
// OpenLong opens a long position
func (t *BybitTrader) OpenLong(symbol string, quantity float64, leverage int) (map[string]interface{}, error) {
logger.Infof("[Bybit] ===== OpenLong called: symbol=%s, qty=%.6f, leverage=%d =====", symbol, quantity, leverage)
// First cancel all pending orders for this symbol (clean up old orders)
if err := t.CancelAllOrders(symbol); err != nil {
logger.Infof("⚠️ [Bybit] Failed to cancel old pending orders: %v", err)
}
// Also cancel conditional orders (stop-loss/take-profit) - Bybit keeps them separate
if err := t.CancelStopOrders(symbol); err != nil {
logger.Infof("⚠️ [Bybit] Failed to cancel old stop orders: %v", err)
}
// Set leverage first
if err := t.SetLeverage(symbol, leverage); err != nil {
logger.Infof("⚠️ [Bybit] Failed to set leverage: %v", err)
}
// Use FormatQuantity to format quantity
qtyStr, _ := t.FormatQuantity(symbol, quantity)
params := map[string]interface{}{
"category": "linear",
"symbol": symbol,
"side": "Buy",
"orderType": "Market",
"qty": qtyStr,
"positionIdx": 0, // One-way position mode
}
logger.Infof("[Bybit] OpenLong placing order: %+v", params)
result, err := t.client.NewUtaBybitServiceWithParams(params).PlaceOrder(context.Background())
if err != nil {
return nil, fmt.Errorf("Bybit open long failed: %w", err)
}
// Clear cache
t.clearCache()
return t.parseOrderResult(result)
}
// OpenShort opens a short position
func (t *BybitTrader) OpenShort(symbol string, quantity float64, leverage int) (map[string]interface{}, error) {
logger.Infof("[Bybit] ===== OpenShort called: symbol=%s, qty=%.6f, leverage=%d =====", symbol, quantity, leverage)
// First cancel all pending orders for this symbol (clean up old orders)
if err := t.CancelAllOrders(symbol); err != nil {
logger.Infof("⚠️ [Bybit] Failed to cancel old pending orders: %v", err)
}
// Also cancel conditional orders (stop-loss/take-profit) - Bybit keeps them separate
if err := t.CancelStopOrders(symbol); err != nil {
logger.Infof("⚠️ [Bybit] Failed to cancel old stop orders: %v", err)
}
// Set leverage first
if err := t.SetLeverage(symbol, leverage); err != nil {
logger.Infof("⚠️ [Bybit] Failed to set leverage: %v", err)
}
// Use FormatQuantity to format quantity
qtyStr, _ := t.FormatQuantity(symbol, quantity)
params := map[string]interface{}{
"category": "linear",
"symbol": symbol,
"side": "Sell",
"orderType": "Market",
"qty": qtyStr,
"positionIdx": 0, // One-way position mode
}
logger.Infof("[Bybit] OpenShort placing order: %+v", params)
result, err := t.client.NewUtaBybitServiceWithParams(params).PlaceOrder(context.Background())
if err != nil {
return nil, fmt.Errorf("Bybit open short failed: %w", err)
}
// Clear cache
t.clearCache()
return t.parseOrderResult(result)
}
// CloseLong closes a long position
func (t *BybitTrader) CloseLong(symbol string, quantity float64) (map[string]interface{}, error) {
// If quantity = 0, get current position quantity
if quantity == 0 {
positions, err := t.GetPositions()
if err != nil {
return nil, err
}
for _, pos := range positions {
side, _ := pos["side"].(string)
if pos["symbol"] == symbol && strings.ToLower(side) == "long" {
quantity = pos["positionAmt"].(float64)
break
}
}
}
if quantity <= 0 {
return nil, fmt.Errorf("no long position to close")
}
// Use FormatQuantity to format quantity
qtyStr, _ := t.FormatQuantity(symbol, quantity)
params := map[string]interface{}{
"category": "linear",
"symbol": symbol,
"side": "Sell", // Close long with Sell
"orderType": "Market",
"qty": qtyStr,
"positionIdx": 0,
"reduceOnly": true,
}
result, err := t.client.NewUtaBybitServiceWithParams(params).PlaceOrder(context.Background())
if err != nil {
return nil, fmt.Errorf("Bybit close long failed: %w", err)
}
// Clear cache
t.clearCache()
return t.parseOrderResult(result)
}
// CloseShort closes a short position
func (t *BybitTrader) CloseShort(symbol string, quantity float64) (map[string]interface{}, error) {
// If quantity = 0, get current position quantity
if quantity == 0 {
positions, err := t.GetPositions()
if err != nil {
return nil, err
}
for _, pos := range positions {
side, _ := pos["side"].(string)
if pos["symbol"] == symbol && strings.ToLower(side) == "short" {
quantity = -pos["positionAmt"].(float64) // Short position is negative
break
}
}
}
if quantity <= 0 {
return nil, fmt.Errorf("no short position to close")
}
// Use FormatQuantity to format quantity
qtyStr, _ := t.FormatQuantity(symbol, quantity)
params := map[string]interface{}{
"category": "linear",
"symbol": symbol,
"side": "Buy", // Close short with Buy
"orderType": "Market",
"qty": qtyStr,
"positionIdx": 0,
"reduceOnly": true,
}
result, err := t.client.NewUtaBybitServiceWithParams(params).PlaceOrder(context.Background())
if err != nil {
return nil, fmt.Errorf("Bybit close short failed: %w", err)
}
// Clear cache
t.clearCache()
return t.parseOrderResult(result)
}
// SetLeverage sets leverage
func (t *BybitTrader) SetLeverage(symbol string, leverage int) error {
params := map[string]interface{}{
"category": "linear",
"symbol": symbol,
"buyLeverage": fmt.Sprintf("%d", leverage),
"sellLeverage": fmt.Sprintf("%d", leverage),
}
result, err := t.client.NewUtaBybitServiceWithParams(params).SetPositionLeverage(context.Background())
if err != nil {
// If leverage is already at target value, Bybit will return an error, ignore this case
if strings.Contains(err.Error(), "leverage not modified") {
return nil
}
return fmt.Errorf("failed to set leverage: %w", err)
}
if result.RetCode != 0 && result.RetCode != 110043 { // 110043 = leverage not modified
return fmt.Errorf("failed to set leverage: %s", result.RetMsg)
}
return nil
}
// SetMarginMode sets position margin mode
func (t *BybitTrader) SetMarginMode(symbol string, isCrossMargin bool) error {
tradeMode := 1 // Isolated margin
if isCrossMargin {
tradeMode = 0 // Cross margin
}
params := map[string]interface{}{
"category": "linear",
"symbol": symbol,
"tradeMode": tradeMode,
}
result, err := t.client.NewUtaBybitServiceWithParams(params).SwitchPositionMargin(context.Background())
if err != nil {
if strings.Contains(err.Error(), "Cross/isolated margin mode is not modified") {
return nil
}
return fmt.Errorf("failed to set margin mode: %w", err)
}
if result.RetCode != 0 && result.RetCode != 110026 { // already in target mode
return fmt.Errorf("failed to set margin mode: %s", result.RetMsg)
}
return nil
}
// GetMarketPrice retrieves market price
func (t *BybitTrader) GetMarketPrice(symbol string) (float64, error) {
params := map[string]interface{}{
"category": "linear",
"symbol": symbol,
}
result, err := t.client.NewUtaBybitServiceWithParams(params).GetMarketTickers(context.Background())
if err != nil {
return 0, fmt.Errorf("failed to get market price: %w", err)
}
if result.RetCode != 0 {
return 0, fmt.Errorf("API error: %s", result.RetMsg)
}
resultData, ok := result.Result.(map[string]interface{})
if !ok {
return 0, fmt.Errorf("return format error")
}
list, _ := resultData["list"].([]interface{})
if len(list) == 0 {
return 0, fmt.Errorf("price data not found for %s", symbol)
}
ticker, _ := list[0].(map[string]interface{})
lastPriceStr, _ := ticker["lastPrice"].(string)
lastPrice, err := strconv.ParseFloat(lastPriceStr, 64)
if err != nil {
return 0, fmt.Errorf("failed to parse price: %w", err)
}
return lastPrice, nil
}
// SetStopLoss sets stop loss order
func (t *BybitTrader) SetStopLoss(symbol string, positionSide string, quantity, stopPrice float64) error {
side := "Sell" // LONG stop loss uses Sell
if positionSide == "SHORT" {
side = "Buy" // SHORT stop loss uses Buy
}
// Get current price to determine triggerDirection
currentPrice, err := t.GetMarketPrice(symbol)
if err != nil {
return err
}
triggerDirection := 2 // Price fall trigger (default long stop loss)
if stopPrice > currentPrice {
triggerDirection = 1 // Price rise trigger (short stop loss)
}
// Use FormatQuantity to format quantity
qtyStr, _ := t.FormatQuantity(symbol, quantity)
params := map[string]interface{}{
"category": "linear",
"symbol": symbol,
"side": side,
"orderType": "Market",
"qty": qtyStr,
"triggerPrice": fmt.Sprintf("%v", stopPrice),
"triggerDirection": triggerDirection,
"triggerBy": "LastPrice",
"reduceOnly": true,
}
result, err := t.client.NewUtaBybitServiceWithParams(params).PlaceOrder(context.Background())
if err != nil {
return fmt.Errorf("failed to set stop loss: %w", err)
}
if result.RetCode != 0 {
return fmt.Errorf("failed to set stop loss: %s", result.RetMsg)
}
logger.Infof(" ✓ [Bybit] Stop loss order set: %s @ %.2f", symbol, stopPrice)
return nil
}
// SetTakeProfit sets take profit order
func (t *BybitTrader) SetTakeProfit(symbol string, positionSide string, quantity, takeProfitPrice float64) error {
side := "Sell" // LONG take profit uses Sell
if positionSide == "SHORT" {
side = "Buy" // SHORT take profit uses Buy
}
// Get current price to determine triggerDirection
currentPrice, err := t.GetMarketPrice(symbol)
if err != nil {
return err
}
triggerDirection := 1 // Price rise trigger (default long take profit)
if takeProfitPrice < currentPrice {
triggerDirection = 2 // Price fall trigger (short take profit)
}
// Use FormatQuantity to format quantity
qtyStr, _ := t.FormatQuantity(symbol, quantity)
params := map[string]interface{}{
"category": "linear",
"symbol": symbol,
"side": side,
"orderType": "Market",
"qty": qtyStr,
"triggerPrice": fmt.Sprintf("%v", takeProfitPrice),
"triggerDirection": triggerDirection,
"triggerBy": "LastPrice",
"reduceOnly": true,
}
result, err := t.client.NewUtaBybitServiceWithParams(params).PlaceOrder(context.Background())
if err != nil {
return fmt.Errorf("failed to set take profit: %w", err)
}
if result.RetCode != 0 {
return fmt.Errorf("failed to set take profit: %s", result.RetMsg)
}
logger.Infof(" ✓ [Bybit] Take profit order set: %s @ %.2f", symbol, takeProfitPrice)
return nil
}
// CancelStopLossOrders cancels stop loss orders
func (t *BybitTrader) CancelStopLossOrders(symbol string) error {
return t.cancelConditionalOrders(symbol, "StopLoss")
}
// CancelTakeProfitOrders cancels take profit orders
func (t *BybitTrader) CancelTakeProfitOrders(symbol string) error {
return t.cancelConditionalOrders(symbol, "TakeProfit")
}
// CancelAllOrders cancels all pending orders
func (t *BybitTrader) CancelAllOrders(symbol string) error {
params := map[string]interface{}{
"category": "linear",
"symbol": symbol,
}
_, err := t.client.NewUtaBybitServiceWithParams(params).CancelAllOrders(context.Background())
if err != nil {
return fmt.Errorf("failed to cancel all orders: %w", err)
}
return nil
}
// CancelStopOrders cancels all stop loss and take profit orders
func (t *BybitTrader) CancelStopOrders(symbol string) error {
if err := t.CancelStopLossOrders(symbol); err != nil {
logger.Infof("⚠️ [Bybit] Failed to cancel stop loss orders: %v", err)
}
if err := t.CancelTakeProfitOrders(symbol); err != nil {
logger.Infof("⚠️ [Bybit] Failed to cancel take profit orders: %v", err)
}
return nil
}
func (t *BybitTrader) cancelConditionalOrders(symbol string, orderType string) error {
// First get all conditional orders
params := map[string]interface{}{
"category": "linear",
"symbol": symbol,
"orderFilter": "StopOrder", // Conditional orders
}
result, err := t.client.NewUtaBybitServiceWithParams(params).GetOpenOrders(context.Background())
if err != nil {
return fmt.Errorf("failed to get conditional orders: %w", err)
}
if result.RetCode != 0 {
return nil // No orders
}
resultData, ok := result.Result.(map[string]interface{})
if !ok {
return nil
}
list, _ := resultData["list"].([]interface{})
// Cancel matching orders
for _, item := range list {
order, ok := item.(map[string]interface{})
if !ok {
continue
}
orderId, _ := order["orderId"].(string)
stopOrderType, _ := order["stopOrderType"].(string)
// Filter by type
shouldCancel := false
if orderType == "StopLoss" && (stopOrderType == "StopLoss" || stopOrderType == "Stop") {
shouldCancel = true
}
if orderType == "TakeProfit" && (stopOrderType == "TakeProfit" || stopOrderType == "PartialTakeProfit") {
shouldCancel = true
}
if shouldCancel && orderId != "" {
cancelParams := map[string]interface{}{
"category": "linear",
"symbol": symbol,
"orderId": orderId,
}
t.client.NewUtaBybitServiceWithParams(cancelParams).CancelOrder(context.Background())
}
}
return nil
}
// GetOrderStatus retrieves order status
func (t *BybitTrader) GetOrderStatus(symbol string, orderID string) (map[string]interface{}, error) {
params := map[string]interface{}{
"category": "linear",
"symbol": symbol,
"orderId": orderID,
}
result, err := t.client.NewUtaBybitServiceWithParams(params).GetOrderHistory(context.Background())
if err != nil {
return nil, fmt.Errorf("failed to get order status: %w", err)
}
if result.RetCode != 0 {
return nil, fmt.Errorf("API error: %s", result.RetMsg)
}
resultData, ok := result.Result.(map[string]interface{})
if !ok {
return nil, fmt.Errorf("return format error")
}
list, _ := resultData["list"].([]interface{})
if len(list) == 0 {
return nil, fmt.Errorf("order %s not found", orderID)
}
order, _ := list[0].(map[string]interface{})
// Parse order data
status, _ := order["orderStatus"].(string)
avgPriceStr, _ := order["avgPrice"].(string)
cumExecQtyStr, _ := order["cumExecQty"].(string)
cumExecFeeStr, _ := order["cumExecFee"].(string)
avgPrice, _ := strconv.ParseFloat(avgPriceStr, 64)
executedQty, _ := strconv.ParseFloat(cumExecQtyStr, 64)
commission, _ := strconv.ParseFloat(cumExecFeeStr, 64)
// Convert status to unified format
unifiedStatus := status
switch status {
case "Filled":
unifiedStatus = "FILLED"
case "New", "Created":
unifiedStatus = "NEW"
case "Cancelled", "Rejected":
unifiedStatus = "CANCELED"
case "PartiallyFilled":
unifiedStatus = "PARTIALLY_FILLED"
}
return map[string]interface{}{
"orderId": orderID,
"status": unifiedStatus,
"avgPrice": avgPrice,
"executedQty": executedQty,
"commission": commission,
}, nil
}
// GetOpenOrders gets all open/pending orders for a symbol
func (t *BybitTrader) GetOpenOrders(symbol string) ([]types.OpenOrder, error) {
var result []types.OpenOrder
// Get conditional orders (stop-loss, take-profit)
params := map[string]interface{}{
"category": "linear",
"symbol": symbol,
"orderFilter": "StopOrder",
}
resp, err := t.client.NewUtaBybitServiceWithParams(params).GetOpenOrders(context.Background())
if err != nil {
return nil, fmt.Errorf("failed to get open orders: %w", err)
}
if resp.RetCode == 0 {
resultData, ok := resp.Result.(map[string]interface{})
if ok {
list, _ := resultData["list"].([]interface{})
for _, item := range list {
order, ok := item.(map[string]interface{})
if !ok {
continue
}
orderId, _ := order["orderId"].(string)
sym, _ := order["symbol"].(string)
side, _ := order["side"].(string)
orderType, _ := order["orderType"].(string)
stopOrderType, _ := order["stopOrderType"].(string)
triggerPrice, _ := order["triggerPrice"].(string)
qty, _ := order["qty"].(string)
price, _ := strconv.ParseFloat(triggerPrice, 64)
quantity, _ := strconv.ParseFloat(qty, 64)
// Determine type based on stopOrderType
displayType := orderType
if stopOrderType != "" {
displayType = stopOrderType
}
result = append(result, types.OpenOrder{
OrderID: orderId,
Symbol: sym,
Side: side,
PositionSide: "", // Bybit doesn't use positionSide for UTA
Type: displayType,
Price: 0,
StopPrice: price,
Quantity: quantity,
Status: "NEW",
})
}
}
}
return result, nil
}
// PlaceLimitOrder places a limit order for grid trading
// Implements GridTrader interface
func (t *BybitTrader) PlaceLimitOrder(req *types.LimitOrderRequest) (*types.LimitOrderResult, error) {
// Format quantity
qtyStr, err := t.FormatQuantity(req.Symbol, req.Quantity)
if err != nil {
return nil, fmt.Errorf("failed to format quantity: %w", err)
}
// Format price
priceStr := fmt.Sprintf("%.8f", req.Price)
// Set leverage if specified
if req.Leverage > 0 {
if err := t.SetLeverage(req.Symbol, req.Leverage); err != nil {
logger.Warnf("[Bybit] Failed to set leverage: %v", err)
}
}
// Determine side
side := "Buy"
if req.Side == "SELL" {
side = "Sell"
}
params := map[string]interface{}{
"category": "linear",
"symbol": req.Symbol,
"side": side,
"orderType": "Limit",
"qty": qtyStr,
"price": priceStr,
"timeInForce": "GTC", // Good Till Cancel
"positionIdx": 0, // One-way position mode
}
// Add reduce only if specified
if req.ReduceOnly {
params["reduceOnly"] = true
}
logger.Infof("[Bybit] PlaceLimitOrder: %s %s @ %s, qty=%s", req.Symbol, side, priceStr, qtyStr)
result, err := t.client.NewUtaBybitServiceWithParams(params).PlaceOrder(context.Background())
if err != nil {
return nil, fmt.Errorf("failed to place limit order: %w", err)
}
// Parse result
orderID := ""
if result.RetCode == 0 {
if resultData, ok := result.Result.(map[string]interface{}); ok {
if id, ok := resultData["orderId"].(string); ok {
orderID = id
}
}
} else {
return nil, fmt.Errorf("Bybit order failed: %s", result.RetMsg)
}
logger.Infof("✓ [Bybit] Limit order placed: %s %s @ %s, qty=%s, orderID=%s",
req.Symbol, side, priceStr, qtyStr, orderID)
return &types.LimitOrderResult{
OrderID: orderID,
ClientID: req.ClientID,
Symbol: req.Symbol,
Side: req.Side,
PositionSide: req.PositionSide,
Price: req.Price,
Quantity: req.Quantity,
Status: "NEW",
}, nil
}
// CancelOrder cancels a specific order by ID
// Implements GridTrader interface
func (t *BybitTrader) CancelOrder(symbol, orderID string) error {
params := map[string]interface{}{
"category": "linear",
"symbol": symbol,
"orderId": orderID,
}
result, err := t.client.NewUtaBybitServiceWithParams(params).CancelOrder(context.Background())
if err != nil {
return fmt.Errorf("failed to cancel order: %w", err)
}
if result.RetCode != 0 {
return fmt.Errorf("Bybit cancel order failed: %s", result.RetMsg)
}
logger.Infof("✓ [Bybit] Order cancelled: %s %s", symbol, orderID)
return nil
}
// GetOrderBook gets the order book for a symbol
// Implements GridTrader interface
func (t *BybitTrader) GetOrderBook(symbol string, depth int) (bids, asks [][]float64, err error) {
if depth <= 0 {
depth = 25
}
// Use HTTP request directly since the SDK doesn't expose GetOrderbook
url := fmt.Sprintf("https://api.bybit.com/v5/market/orderbook?category=linear&symbol=%s&limit=%d", symbol, depth)
resp, err := http.Get(url)
if err != nil {
return nil, nil, fmt.Errorf("failed to get order book: %w", err)
}
defer resp.Body.Close()
body, _ := io.ReadAll(resp.Body)
if resp.StatusCode != http.StatusOK {
return nil, nil, fmt.Errorf("HTTP %d: %s", resp.StatusCode, string(body))
}
var result struct {
RetCode int `json:"retCode"`
RetMsg string `json:"retMsg"`
Result struct {
S string `json:"s"` // symbol
B [][]string `json:"b"` // bids [[price, size], ...]
A [][]string `json:"a"` // asks [[price, size], ...]
} `json:"result"`
}
if err := json.Unmarshal(body, &result); err != nil {
return nil, nil, fmt.Errorf("failed to parse order book: %w", err)
}
if result.RetCode != 0 {
return nil, nil, fmt.Errorf("Bybit get orderbook failed: %s", result.RetMsg)
}
// Parse bids
for _, b := range result.Result.B {
if len(b) >= 2 {
price, _ := strconv.ParseFloat(b[0], 64)
qty, _ := strconv.ParseFloat(b[1], 64)
bids = append(bids, []float64{price, qty})
}
}
// Parse asks
for _, a := range result.Result.A {
if len(a) >= 2 {
price, _ := strconv.ParseFloat(a[0], 64)
qty, _ := strconv.ParseFloat(a[1], 64)
asks = append(asks, []float64{price, qty})
}
}
return bids, asks, nil
}

View File

@@ -0,0 +1,125 @@
package bybit
import (
"context"
"fmt"
"nofx/logger"
"strconv"
"strings"
"time"
)
// GetPositions retrieves all positions
func (t *BybitTrader) GetPositions() ([]map[string]interface{}, error) {
// Check cache
t.positionsCacheMutex.RLock()
if t.cachedPositions != nil && time.Since(t.positionsCacheTime) < t.cacheDuration {
positions := t.cachedPositions
t.positionsCacheMutex.RUnlock()
return positions, nil
}
t.positionsCacheMutex.RUnlock()
// Call API
params := map[string]interface{}{
"category": "linear",
"settleCoin": "USDT",
}
result, err := t.client.NewUtaBybitServiceWithParams(params).GetPositionList(context.Background())
if err != nil {
return nil, fmt.Errorf("failed to get Bybit positions: %w", err)
}
if result.RetCode != 0 {
return nil, fmt.Errorf("Bybit API error: %s", result.RetMsg)
}
resultData, ok := result.Result.(map[string]interface{})
if !ok {
return nil, fmt.Errorf("Bybit positions return format error")
}
list, _ := resultData["list"].([]interface{})
var positions []map[string]interface{}
for _, item := range list {
pos, ok := item.(map[string]interface{})
if !ok {
continue
}
sizeStr, _ := pos["size"].(string)
size, _ := strconv.ParseFloat(sizeStr, 64)
// Skip empty positions
if size == 0 {
continue
}
entryPriceStr, _ := pos["avgPrice"].(string)
entryPrice, _ := strconv.ParseFloat(entryPriceStr, 64)
unrealisedPnlStr, _ := pos["unrealisedPnl"].(string)
unrealisedPnl, _ := strconv.ParseFloat(unrealisedPnlStr, 64)
leverageStr, _ := pos["leverage"].(string)
leverage, _ := strconv.ParseFloat(leverageStr, 64)
// Mark price
markPriceStr, _ := pos["markPrice"].(string)
markPrice, _ := strconv.ParseFloat(markPriceStr, 64)
// Liquidation price
liqPriceStr, _ := pos["liqPrice"].(string)
liqPrice, _ := strconv.ParseFloat(liqPriceStr, 64)
// Position created/updated time (milliseconds timestamp)
createdTimeStr, _ := pos["createdTime"].(string)
createdTime, _ := strconv.ParseInt(createdTimeStr, 10, 64)
updatedTimeStr, _ := pos["updatedTime"].(string)
updatedTime, _ := strconv.ParseInt(updatedTimeStr, 10, 64)
positionSide, _ := pos["side"].(string) // Buy = long, Sell = short
// Log raw position data for debugging
logger.Infof("[Bybit] GetPositions raw: symbol=%v, side=%s, size=%v", pos["symbol"], positionSide, sizeStr)
// Convert to unified format (use lowercase for consistency with other exchanges)
// Bybit returns "Buy" for long, "Sell" for short
side := "long"
positionAmt := size
positionSideLower := strings.ToLower(positionSide)
if positionSideLower == "sell" {
side = "short"
positionAmt = -size
}
logger.Infof("[Bybit] GetPositions converted: symbol=%v, rawSide=%s -> side=%s", pos["symbol"], positionSide, side)
position := map[string]interface{}{
"symbol": pos["symbol"],
"side": side,
"positionAmt": positionAmt,
"entryPrice": entryPrice,
"markPrice": markPrice,
"unRealizedProfit": unrealisedPnl,
"unrealizedPnL": unrealisedPnl,
"liquidationPrice": liqPrice,
"leverage": leverage,
"createdTime": createdTime, // Position open time (ms)
"updatedTime": updatedTime, // Position last update time (ms)
}
positions = append(positions, position)
}
// Update cache
t.positionsCacheMutex.Lock()
t.cachedPositions = positions
t.positionsCacheTime = time.Now()
t.positionsCacheMutex.Unlock()
return positions, nil
}

View File

@@ -1,471 +0,0 @@
package bybit
import (
"encoding/json"
"net/http"
"net/http/httptest"
"strings"
"testing"
"time"
"github.com/stretchr/testify/assert"
"nofx/trader/testutil"
"nofx/trader/types"
)
// ============================================================
// Part 1: BybitTraderTestSuite - Inherits base test suite
// ============================================================
// BybitTraderTestSuite Bybit trader test suite
// Inherits TraderTestSuite and adds Bybit-specific mock logic
type BybitTraderTestSuite struct {
*testutil.TraderTestSuite // Embeds base test suite
mockServer *httptest.Server
}
// NewBybitTraderTestSuite Create Bybit test suite
// Note: Due to Bybit SDK encapsulation design, cannot easily inject mock HTTP client
// Therefore this test suite is mainly used for interface compliance verification, not API call testing
func NewBybitTraderTestSuite(t *testing.T) *BybitTraderTestSuite {
// Create mock HTTP server (for response format verification)
mockServer := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
path := r.URL.Path
var respBody interface{}
switch {
case path == "/v5/account/wallet-balance":
respBody = map[string]interface{}{
"retCode": 0,
"retMsg": "OK",
"result": map[string]interface{}{
"list": []map[string]interface{}{
{
"accountType": "UNIFIED",
"totalEquity": "10100.50",
"coin": []map[string]interface{}{
{
"coin": "USDT",
"walletBalance": "10000.00",
"unrealisedPnl": "100.50",
"availableToWithdraw": "8000.00",
},
},
},
},
},
}
default:
respBody = map[string]interface{}{
"retCode": 0,
"retMsg": "OK",
"result": map[string]interface{}{},
}
}
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(respBody)
}))
// Create real Bybit trader (for interface compliance testing)
traderInstance := NewBybitTrader("test_api_key", "test_secret_key")
// Create base suite
baseSuite := testutil.NewTraderTestSuite(t, traderInstance)
return &BybitTraderTestSuite{
TraderTestSuite: baseSuite,
mockServer: mockServer,
}
}
// Cleanup Clean up resources
func (s *BybitTraderTestSuite) Cleanup() {
if s.mockServer != nil {
s.mockServer.Close()
}
s.TraderTestSuite.Cleanup()
}
// ============================================================
// Part 2: Interface compliance tests
// ============================================================
// TestBybitTrader_InterfaceCompliance Test interface compliance
func TestBybitTrader_InterfaceCompliance(t *testing.T) {
var _ types.Trader = (*BybitTrader)(nil)
}
// ============================================================
// Part 3: Bybit-specific feature unit tests
// ============================================================
// TestNewBybitTrader Test creating Bybit trader
func TestNewBybitTrader(t *testing.T) {
tests := []struct {
name string
apiKey string
secretKey string
wantNil bool
}{
{
name: "Successfully create",
apiKey: "test_api_key",
secretKey: "test_secret_key",
wantNil: false,
},
{
name: "Empty API Key can still create",
apiKey: "",
secretKey: "test_secret_key",
wantNil: false,
},
{
name: "Empty Secret Key can still create",
apiKey: "test_api_key",
secretKey: "",
wantNil: false,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
bt := NewBybitTrader(tt.apiKey, tt.secretKey)
if tt.wantNil {
assert.Nil(t, bt)
} else {
assert.NotNil(t, bt)
assert.NotNil(t, bt.client)
}
})
}
}
// TestBybitTrader_SymbolFormat Test symbol format
func TestBybitTrader_SymbolFormat(t *testing.T) {
// Bybit uses uppercase symbol format (e.g. BTCUSDT)
tests := []struct {
name string
symbol string
isValid bool
}{
{
name: "Standard USDT contract",
symbol: "BTCUSDT",
isValid: true,
},
{
name: "ETH contract",
symbol: "ETHUSDT",
isValid: true,
},
{
name: "SOL contract",
symbol: "SOLUSDT",
isValid: true,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
// Verify symbol format is correct (all uppercase, ends with USDT)
assert.True(t, tt.symbol == strings.ToUpper(tt.symbol))
assert.True(t, strings.HasSuffix(tt.symbol, "USDT"))
})
}
}
// TestBybitTrader_FormatQuantity Test quantity formatting
func TestBybitTrader_FormatQuantity(t *testing.T) {
bt := NewBybitTrader("test", "test")
tests := []struct {
name string
symbol string
quantity float64
expected string
hasError bool
}{
{
name: "BTC quantity formatting",
symbol: "BTCUSDT",
quantity: 0.12345,
expected: "0.123", // Bybit defaults to 3 decimal places
hasError: false,
},
{
name: "ETH quantity formatting",
symbol: "ETHUSDT",
quantity: 1.2345,
expected: "1.234",
hasError: false,
},
{
name: "Integer quantity",
symbol: "SOLUSDT",
quantity: 10.0,
expected: "10.000",
hasError: false,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
result, err := bt.FormatQuantity(tt.symbol, tt.quantity)
if tt.hasError {
assert.Error(t, err)
} else {
assert.NoError(t, err)
assert.Equal(t, tt.expected, result)
}
})
}
}
// TestBybitTrader_ParseResponse Test response parsing
func TestBybitTrader_ParseResponse(t *testing.T) {
tests := []struct {
name string
retCode int
retMsg string
expectErr bool
errContain string
}{
{
name: "Success response",
retCode: 0,
retMsg: "OK",
expectErr: false,
},
{
name: "API error",
retCode: 10001,
retMsg: "Invalid symbol",
expectErr: true,
errContain: "Invalid symbol",
},
{
name: "Permission error",
retCode: 10003,
retMsg: "Invalid API key",
expectErr: true,
errContain: "Invalid API key",
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
err := checkBybitResponse(tt.retCode, tt.retMsg)
if tt.expectErr {
assert.Error(t, err)
if tt.errContain != "" {
assert.Contains(t, err.Error(), tt.errContain)
}
} else {
assert.NoError(t, err)
}
})
}
}
// checkBybitResponse Check if Bybit API response has errors
func checkBybitResponse(retCode int, retMsg string) error {
if retCode != 0 {
return &BybitAPIError{
Code: retCode,
Message: retMsg,
}
}
return nil
}
// BybitAPIError Bybit API error type
type BybitAPIError struct {
Code int
Message string
}
func (e *BybitAPIError) Error() string {
return e.Message
}
// TestBybitTrader_PositionSideConversion Test position side conversion
func TestBybitTrader_PositionSideConversion(t *testing.T) {
tests := []struct {
name string
side string
expected string
}{
{
name: "Buy to Long",
side: "Buy",
expected: "long",
},
{
name: "Sell to Short",
side: "Sell",
expected: "short",
},
{
name: "Other values remain unchanged",
side: "Unknown",
expected: "unknown",
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
result := convertBybitSide(tt.side)
assert.Equal(t, tt.expected, result)
})
}
}
// convertBybitSide Convert Bybit position side
func convertBybitSide(side string) string {
switch side {
case "Buy":
return "long"
case "Sell":
return "short"
default:
return "unknown"
}
}
// TestBybitTrader_CategoryLinear Test using only linear category
func TestBybitTrader_CategoryLinear(t *testing.T) {
// Bybit trader should only use linear category (USDT perpetual contracts)
bt := NewBybitTrader("test", "test")
assert.NotNil(t, bt)
// Verify default configuration
assert.NotNil(t, bt.client)
}
// TestBybitTrader_CacheDuration Test cache duration
func TestBybitTrader_CacheDuration(t *testing.T) {
bt := NewBybitTrader("test", "test")
// Verify default cache time is 15 seconds
assert.Equal(t, 15*time.Second, bt.cacheDuration)
}
// ============================================================
// Part 4: Mock server integration tests
// ============================================================
// TestBybitTrader_MockServerGetBalance Test getting balance through Mock server
func TestBybitTrader_MockServerGetBalance(t *testing.T) {
mockServer := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.URL.Path == "/v5/account/wallet-balance" {
respBody := map[string]interface{}{
"retCode": 0,
"retMsg": "OK",
"result": map[string]interface{}{
"list": []map[string]interface{}{
{
"accountType": "UNIFIED",
"totalEquity": "10100.50",
"coin": []map[string]interface{}{
{
"coin": "USDT",
"walletBalance": "10000.00",
"unrealisedPnl": "100.50",
"availableToWithdraw": "8000.00",
},
},
},
},
},
}
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(respBody)
return
}
http.NotFound(w, r)
}))
defer mockServer.Close()
// Due to Bybit SDK encapsulation, cannot directly inject mock URL
// This test verifies mock server response format is correct
assert.NotNil(t, mockServer)
}
// TestBybitTrader_MockServerGetPositions Test getting positions through Mock server
func TestBybitTrader_MockServerGetPositions(t *testing.T) {
mockServer := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.URL.Path == "/v5/position/list" {
respBody := map[string]interface{}{
"retCode": 0,
"retMsg": "OK",
"result": map[string]interface{}{
"list": []map[string]interface{}{
{
"symbol": "BTCUSDT",
"side": "Buy",
"size": "0.5",
"avgPrice": "50000.00",
"markPrice": "50500.00",
"unrealisedPnl": "250.00",
"liqPrice": "45000.00",
"leverage": "10",
"positionIdx": 0,
},
},
},
}
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(respBody)
return
}
http.NotFound(w, r)
}))
defer mockServer.Close()
assert.NotNil(t, mockServer)
}
// TestBybitTrader_MockServerPlaceOrder Test placing order through Mock server
func TestBybitTrader_MockServerPlaceOrder(t *testing.T) {
mockServer := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.URL.Path == "/v5/order/create" && r.Method == "POST" {
respBody := map[string]interface{}{
"retCode": 0,
"retMsg": "OK",
"result": map[string]interface{}{
"orderId": "1234567890",
"orderLinkId": "test-order-id",
},
}
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(respBody)
return
}
http.NotFound(w, r)
}))
defer mockServer.Close()
assert.NotNil(t, mockServer)
}
// TestBybitTrader_MockServerSetLeverage Test setting leverage through Mock server
func TestBybitTrader_MockServerSetLeverage(t *testing.T) {
mockServer := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.URL.Path == "/v5/position/set-leverage" && r.Method == "POST" {
respBody := map[string]interface{}{
"retCode": 0,
"retMsg": "OK",
"result": map[string]interface{}{},
}
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(respBody)
return
}
http.NotFound(w, r)
}))
defer mockServer.Close()
assert.NotNil(t, mockServer)
}

View File

@@ -3,16 +3,12 @@ package gate
import ( import (
"context" "context"
"fmt" "fmt"
"math" "nofx/trader/types"
"strconv"
"strings" "strings"
"sync" "sync"
"time" "time"
"github.com/antihax/optional"
"github.com/gateio/gateapi-go/v6" "github.com/gateio/gateapi-go/v6"
"nofx/logger"
"nofx/trader/types"
) )
// GateTrader implements types.Trader interface for Gate.io Futures // GateTrader implements types.Trader interface for Gate.io Futures
@@ -58,118 +54,6 @@ func NewGateTrader(apiKey, secretKey string) *GateTrader {
} }
} }
// GetBalance retrieves account balance
func (t *GateTrader) GetBalance() (map[string]interface{}, error) {
// Check cache
t.balanceCacheMutex.RLock()
if t.cachedBalance != nil && time.Since(t.balanceCacheTime) < t.cacheDuration {
cached := t.cachedBalance
t.balanceCacheMutex.RUnlock()
return cached, nil
}
t.balanceCacheMutex.RUnlock()
// Fetch from API
accounts, _, err := t.client.FuturesApi.ListFuturesAccounts(t.ctx, "usdt")
if err != nil {
return nil, fmt.Errorf("failed to get balance: %w", err)
}
total, _ := strconv.ParseFloat(accounts.Total, 64)
available, _ := strconv.ParseFloat(accounts.Available, 64)
unrealizedPnl, _ := strconv.ParseFloat(accounts.UnrealisedPnl, 64)
result := map[string]interface{}{
"totalWalletBalance": total,
"availableBalance": available,
"totalUnrealizedProfit": unrealizedPnl,
}
// Update cache
t.balanceCacheMutex.Lock()
t.cachedBalance = result
t.balanceCacheTime = time.Now()
t.balanceCacheMutex.Unlock()
return result, nil
}
// GetPositions retrieves all open positions
func (t *GateTrader) GetPositions() ([]map[string]interface{}, error) {
// Check cache
t.positionsCacheMutex.RLock()
if t.cachedPositions != nil && time.Since(t.positionsCacheTime) < t.cacheDuration {
cached := t.cachedPositions
t.positionsCacheMutex.RUnlock()
return cached, nil
}
t.positionsCacheMutex.RUnlock()
// Fetch from API
positions, _, err := t.client.FuturesApi.ListPositions(t.ctx, "usdt", nil)
if err != nil {
return nil, fmt.Errorf("failed to get positions: %w", err)
}
var result []map[string]interface{}
for _, pos := range positions {
if pos.Size == 0 {
continue // Skip empty positions
}
entryPrice, _ := strconv.ParseFloat(pos.EntryPrice, 64)
markPrice, _ := strconv.ParseFloat(pos.MarkPrice, 64)
liqPrice, _ := strconv.ParseFloat(pos.LiqPrice, 64)
unrealizedPnl, _ := strconv.ParseFloat(pos.UnrealisedPnl, 64)
leverage, _ := strconv.ParseFloat(pos.Leverage, 64)
// Gate returns position size in contracts, need to convert to base currency
// Each contract = quanto_multiplier base currency
contractSize := float64(pos.Size)
if pos.Size < 0 {
contractSize = float64(-pos.Size)
}
// Get quanto_multiplier from contract info to convert contracts to actual quantity
quantoMultiplier := 1.0
contract, err := t.getContract(pos.Contract)
if err == nil && contract != nil {
qm, _ := strconv.ParseFloat(contract.QuantoMultiplier, 64)
if qm > 0 {
quantoMultiplier = qm
}
}
// Convert contract count to actual token quantity
positionAmt := contractSize * quantoMultiplier
// Determine side based on position size
side := "long"
if pos.Size < 0 {
side = "short"
}
result = append(result, map[string]interface{}{
"symbol": pos.Contract,
"positionAmt": positionAmt,
"entryPrice": entryPrice,
"markPrice": markPrice,
"unRealizedProfit": unrealizedPnl,
"leverage": int(leverage),
"liquidationPrice": liqPrice,
"side": side,
})
}
// Update cache
t.positionsCacheMutex.Lock()
t.cachedPositions = result
t.positionsCacheTime = time.Now()
t.positionsCacheMutex.Unlock()
return result, nil
}
// convertSymbol converts symbol format (e.g., BTCUSDT -> BTC_USDT) // convertSymbol converts symbol format (e.g., BTCUSDT -> BTC_USDT)
func (t *GateTrader) convertSymbol(symbol string) string { func (t *GateTrader) convertSymbol(symbol string) string {
// If already in correct format // If already in correct format
@@ -215,674 +99,6 @@ func (t *GateTrader) getContract(symbol string) (*gateapi.Contract, error) {
return &contract, nil return &contract, nil
} }
// SetLeverage sets the leverage for a symbol
func (t *GateTrader) SetLeverage(symbol string, leverage int) error {
symbol = t.convertSymbol(symbol)
_, _, err := t.client.FuturesApi.UpdatePositionLeverage(t.ctx, "usdt", symbol, fmt.Sprintf("%d", leverage), nil)
if err != nil {
// Gate.io may return error if leverage is already set
if strings.Contains(err.Error(), "RISK_LIMIT_EXCEEDED") {
logger.Warnf(" [Gate] Leverage %d exceeds limit for %s", leverage, symbol)
return nil
}
return fmt.Errorf("failed to set leverage: %w", err)
}
logger.Infof(" [Gate] Leverage set to %dx for %s", leverage, symbol)
return nil
}
// SetMarginMode sets margin mode (cross or isolated)
func (t *GateTrader) SetMarginMode(symbol string, isCrossMargin bool) error {
// Gate.io uses leverage=0 for cross margin, positive number for isolated
// This is handled through UpdatePositionLeverage with cross_leverage_limit
// For now, we'll skip explicit margin mode setting as it's tied to leverage
logger.Infof(" [Gate] Margin mode is set through leverage (0=cross)")
return nil
}
// OpenLong opens a long position
func (t *GateTrader) OpenLong(symbol string, quantity float64, leverage int) (map[string]interface{}, error) {
symbol = t.convertSymbol(symbol)
// Cancel old orders first
t.CancelAllOrders(symbol)
// Set leverage
if err := t.SetLeverage(symbol, leverage); err != nil {
logger.Warnf(" [Gate] Failed to set leverage: %v", err)
}
// Get contract info for size calculation
contract, err := t.getContract(symbol)
if err != nil {
return nil, err
}
// Gate uses contract size units (each contract = quanto_multiplier base currency)
// size = quantity / quanto_multiplier
quantoMultiplier, _ := strconv.ParseFloat(contract.QuantoMultiplier, 64)
size := int64(quantity / quantoMultiplier)
if size <= 0 {
size = 1
}
order := gateapi.FuturesOrder{
Contract: symbol,
Size: size, // Positive for long
Price: "0", // Market order
Tif: "ioc",
Text: "t-nofx",
}
logger.Infof(" [Gate] OpenLong: symbol=%s, size=%d, leverage=%d", symbol, size, leverage)
result, _, err := t.client.FuturesApi.CreateFuturesOrder(t.ctx, "usdt", order, nil)
if err != nil {
return nil, fmt.Errorf("failed to open long position: %w", err)
}
// Clear cache
t.clearCache()
// Parse fill price from result
fillPrice, _ := strconv.ParseFloat(result.FillPrice, 64)
logger.Infof(" [Gate] Opened long position: orderId=%d, fillPrice=%.4f", result.Id, fillPrice)
return map[string]interface{}{
"orderId": fmt.Sprintf("%d", result.Id),
"symbol": t.revertSymbol(symbol),
"status": "FILLED",
"fillPrice": fillPrice,
"avgPrice": fillPrice,
}, nil
}
// OpenShort opens a short position
func (t *GateTrader) OpenShort(symbol string, quantity float64, leverage int) (map[string]interface{}, error) {
symbol = t.convertSymbol(symbol)
// Cancel old orders first
t.CancelAllOrders(symbol)
// Set leverage
if err := t.SetLeverage(symbol, leverage); err != nil {
logger.Warnf(" [Gate] Failed to set leverage: %v", err)
}
// Get contract info for size calculation
contract, err := t.getContract(symbol)
if err != nil {
return nil, err
}
// Gate uses contract size units
quantoMultiplier, _ := strconv.ParseFloat(contract.QuantoMultiplier, 64)
size := int64(quantity / quantoMultiplier)
if size <= 0 {
size = 1
}
order := gateapi.FuturesOrder{
Contract: symbol,
Size: -size, // Negative for short
Price: "0", // Market order
Tif: "ioc",
Text: "t-nofx",
}
logger.Infof(" [Gate] OpenShort: symbol=%s, size=%d, leverage=%d", symbol, -size, leverage)
result, _, err := t.client.FuturesApi.CreateFuturesOrder(t.ctx, "usdt", order, nil)
if err != nil {
return nil, fmt.Errorf("failed to open short position: %w", err)
}
// Clear cache
t.clearCache()
// Parse fill price from result
fillPrice, _ := strconv.ParseFloat(result.FillPrice, 64)
logger.Infof(" [Gate] Opened short position: orderId=%d, fillPrice=%.4f", result.Id, fillPrice)
return map[string]interface{}{
"orderId": fmt.Sprintf("%d", result.Id),
"symbol": t.revertSymbol(symbol),
"status": "FILLED",
"fillPrice": fillPrice,
"avgPrice": fillPrice,
}, nil
}
// CloseLong closes a long position
func (t *GateTrader) CloseLong(symbol string, quantity float64) (map[string]interface{}, error) {
symbol = t.convertSymbol(symbol)
// If quantity is 0, get current position
if quantity == 0 {
positions, err := t.GetPositions()
if err != nil {
return nil, err
}
for _, pos := range positions {
posSymbol := t.convertSymbol(pos["symbol"].(string))
if posSymbol == symbol && pos["side"] == "long" {
quantity = pos["positionAmt"].(float64)
break
}
}
if quantity == 0 {
return nil, fmt.Errorf("long position not found for %s", symbol)
}
}
// Get contract info for size calculation
contract, err := t.getContract(symbol)
if err != nil {
return nil, err
}
quantoMultiplier, _ := strconv.ParseFloat(contract.QuantoMultiplier, 64)
size := int64(quantity / quantoMultiplier)
if size <= 0 {
size = 1
}
// Close long = sell (use ReduceOnly, not Close which requires Size=0)
order := gateapi.FuturesOrder{
Contract: symbol,
Size: -size, // Negative to close long
Price: "0",
Tif: "ioc",
ReduceOnly: true,
Text: "t-nofx-close",
}
logger.Infof(" [Gate] CloseLong: symbol=%s, size=%d", symbol, -size)
result, _, err := t.client.FuturesApi.CreateFuturesOrder(t.ctx, "usdt", order, nil)
if err != nil {
return nil, fmt.Errorf("failed to close long position: %w", err)
}
// Clear cache
t.clearCache()
// Parse fill price from result
fillPrice, _ := strconv.ParseFloat(result.FillPrice, 64)
logger.Infof(" [Gate] Closed long position: orderId=%d, fillPrice=%.4f", result.Id, fillPrice)
return map[string]interface{}{
"orderId": fmt.Sprintf("%d", result.Id),
"symbol": t.revertSymbol(symbol),
"status": "FILLED",
"fillPrice": fillPrice,
"avgPrice": fillPrice,
}, nil
}
// CloseShort closes a short position
func (t *GateTrader) CloseShort(symbol string, quantity float64) (map[string]interface{}, error) {
symbol = t.convertSymbol(symbol)
// If quantity is 0, get current position
if quantity == 0 {
positions, err := t.GetPositions()
if err != nil {
return nil, err
}
for _, pos := range positions {
posSymbol := t.convertSymbol(pos["symbol"].(string))
if posSymbol == symbol && pos["side"] == "short" {
quantity = pos["positionAmt"].(float64)
break
}
}
if quantity == 0 {
return nil, fmt.Errorf("short position not found for %s", symbol)
}
}
// Ensure quantity is positive
if quantity < 0 {
quantity = -quantity
}
// Get contract info for size calculation
contract, err := t.getContract(symbol)
if err != nil {
return nil, err
}
quantoMultiplier, _ := strconv.ParseFloat(contract.QuantoMultiplier, 64)
size := int64(quantity / quantoMultiplier)
if size <= 0 {
size = 1
}
// Close short = buy (use ReduceOnly, not Close which requires Size=0)
order := gateapi.FuturesOrder{
Contract: symbol,
Size: size, // Positive to close short
Price: "0",
Tif: "ioc",
ReduceOnly: true,
Text: "t-nofx-close",
}
logger.Infof(" [Gate] CloseShort: symbol=%s, size=%d", symbol, size)
result, _, err := t.client.FuturesApi.CreateFuturesOrder(t.ctx, "usdt", order, nil)
if err != nil {
return nil, fmt.Errorf("failed to close short position: %w", err)
}
// Clear cache
t.clearCache()
// Parse fill price from result
fillPrice, _ := strconv.ParseFloat(result.FillPrice, 64)
logger.Infof(" [Gate] Closed short position: orderId=%d, fillPrice=%.4f", result.Id, fillPrice)
return map[string]interface{}{
"orderId": fmt.Sprintf("%d", result.Id),
"symbol": t.revertSymbol(symbol),
"status": "FILLED",
"fillPrice": fillPrice,
"avgPrice": fillPrice,
}, nil
}
// GetMarketPrice gets the current market price
func (t *GateTrader) GetMarketPrice(symbol string) (float64, error) {
symbol = t.convertSymbol(symbol)
opts := &gateapi.ListFuturesTickersOpts{
Contract: optional.NewString(symbol),
}
tickers, _, err := t.client.FuturesApi.ListFuturesTickers(t.ctx, "usdt", opts)
if err != nil {
return 0, fmt.Errorf("failed to get market price: %w", err)
}
if len(tickers) == 0 {
return 0, fmt.Errorf("no ticker data for %s", symbol)
}
price, _ := strconv.ParseFloat(tickers[0].Last, 64)
return price, nil
}
// SetStopLoss sets a stop loss order
func (t *GateTrader) SetStopLoss(symbol string, positionSide string, quantity, stopPrice float64) error {
symbol = t.convertSymbol(symbol)
contract, err := t.getContract(symbol)
if err != nil {
return err
}
quantoMultiplier, _ := strconv.ParseFloat(contract.QuantoMultiplier, 64)
size := int64(quantity / quantoMultiplier)
if size <= 0 {
size = 1
}
// For long position, stop loss means sell when price drops
// For short position, stop loss means buy when price rises
if strings.ToUpper(positionSide) == "LONG" {
size = -size
}
// Use price trigger order
trigger := gateapi.FuturesPriceTriggeredOrder{
Initial: gateapi.FuturesInitialOrder{
Contract: symbol,
Size: size,
Price: "0", // Market order
Tif: "ioc",
ReduceOnly: true,
Close: true,
},
Trigger: gateapi.FuturesPriceTrigger{
StrategyType: 0, // Close position
PriceType: 0, // Latest price
Price: fmt.Sprintf("%.8f", stopPrice),
Rule: 1, // Price <= trigger price
},
}
if strings.ToUpper(positionSide) == "SHORT" {
trigger.Trigger.Rule = 2 // Price >= trigger price for short stop loss
}
_, _, err = t.client.FuturesApi.CreatePriceTriggeredOrder(t.ctx, "usdt", trigger)
if err != nil {
return fmt.Errorf("failed to set stop loss: %w", err)
}
logger.Infof(" [Gate] Stop loss set: %s @ %.4f", symbol, stopPrice)
return nil
}
// SetTakeProfit sets a take profit order
func (t *GateTrader) SetTakeProfit(symbol string, positionSide string, quantity, takeProfitPrice float64) error {
symbol = t.convertSymbol(symbol)
contract, err := t.getContract(symbol)
if err != nil {
return err
}
quantoMultiplier, _ := strconv.ParseFloat(contract.QuantoMultiplier, 64)
size := int64(quantity / quantoMultiplier)
if size <= 0 {
size = 1
}
// For long position, take profit means sell when price rises
// For short position, take profit means buy when price drops
if strings.ToUpper(positionSide) == "LONG" {
size = -size
}
trigger := gateapi.FuturesPriceTriggeredOrder{
Initial: gateapi.FuturesInitialOrder{
Contract: symbol,
Size: size,
Price: "0", // Market order
Tif: "ioc",
ReduceOnly: true,
Close: true,
},
Trigger: gateapi.FuturesPriceTrigger{
StrategyType: 0, // Close position
PriceType: 0, // Latest price
Price: fmt.Sprintf("%.8f", takeProfitPrice),
Rule: 2, // Price >= trigger price for long take profit
},
}
if strings.ToUpper(positionSide) == "SHORT" {
trigger.Trigger.Rule = 1 // Price <= trigger price for short take profit
}
_, _, err = t.client.FuturesApi.CreatePriceTriggeredOrder(t.ctx, "usdt", trigger)
if err != nil {
return fmt.Errorf("failed to set take profit: %w", err)
}
logger.Infof(" [Gate] Take profit set: %s @ %.4f", symbol, takeProfitPrice)
return nil
}
// CancelStopLossOrders cancels stop loss orders
func (t *GateTrader) CancelStopLossOrders(symbol string) error {
return t.cancelTriggerOrders(symbol, "stop_loss")
}
// CancelTakeProfitOrders cancels take profit orders
func (t *GateTrader) CancelTakeProfitOrders(symbol string) error {
return t.cancelTriggerOrders(symbol, "take_profit")
}
// cancelTriggerOrders cancels trigger orders of a specific type
func (t *GateTrader) cancelTriggerOrders(symbol string, orderType string) error {
symbol = t.convertSymbol(symbol)
opts := &gateapi.ListPriceTriggeredOrdersOpts{
Contract: optional.NewString(symbol),
}
orders, _, err := t.client.FuturesApi.ListPriceTriggeredOrders(t.ctx, "usdt", "open", opts)
if err != nil {
return err
}
for _, order := range orders {
// Determine if it's stop loss or take profit based on trigger rule and position
// For simplicity, cancel all matching symbol orders
_, _, err := t.client.FuturesApi.CancelPriceTriggeredOrder(t.ctx, "usdt", fmt.Sprintf("%d", order.Id))
if err != nil {
logger.Warnf(" [Gate] Failed to cancel trigger order %d: %v", order.Id, err)
}
}
return nil
}
// CancelAllOrders cancels all pending orders for a symbol
func (t *GateTrader) CancelAllOrders(symbol string) error {
symbol = t.convertSymbol(symbol)
// Cancel regular orders
_, _, err := t.client.FuturesApi.CancelFuturesOrders(t.ctx, "usdt", symbol, nil)
if err != nil {
// Ignore if no orders to cancel
if !strings.Contains(err.Error(), "ORDER_NOT_FOUND") {
logger.Warnf(" [Gate] Error canceling orders: %v", err)
}
}
// Cancel trigger orders
t.cancelTriggerOrders(symbol, "")
return nil
}
// CancelStopOrders cancels all stop orders (stop loss and take profit)
func (t *GateTrader) CancelStopOrders(symbol string) error {
t.CancelStopLossOrders(symbol)
t.CancelTakeProfitOrders(symbol)
return nil
}
// FormatQuantity formats quantity to correct precision
func (t *GateTrader) FormatQuantity(symbol string, quantity float64) (string, error) {
contract, err := t.getContract(symbol)
if err != nil {
return fmt.Sprintf("%.4f", quantity), nil
}
// Gate uses quanto_multiplier for contract size
quantoMultiplier, _ := strconv.ParseFloat(contract.QuantoMultiplier, 64)
if quantoMultiplier > 0 {
// Calculate number of contracts
numContracts := quantity / quantoMultiplier
return fmt.Sprintf("%.0f", math.Floor(numContracts)), nil
}
return fmt.Sprintf("%.4f", quantity), nil
}
// GetOrderStatus gets the status of an order
func (t *GateTrader) GetOrderStatus(symbol string, orderID string) (map[string]interface{}, error) {
symbol = t.convertSymbol(symbol)
order, _, err := t.client.FuturesApi.GetFuturesOrder(t.ctx, "usdt", orderID)
if err != nil {
return nil, fmt.Errorf("failed to get order status: %w", err)
}
fillPrice, _ := strconv.ParseFloat(order.FillPrice, 64)
tkFee, _ := strconv.ParseFloat(order.Tkfr, 64)
mkFee, _ := strconv.ParseFloat(order.Mkfr, 64)
totalFee := tkFee + mkFee
// Get quanto_multiplier to convert contracts to actual quantity
quantoMultiplier := 1.0
contract, contractErr := t.getContract(symbol)
if contractErr == nil && contract != nil {
qm, _ := strconv.ParseFloat(contract.QuantoMultiplier, 64)
if qm > 0 {
quantoMultiplier = qm
}
}
// Map status
status := "NEW"
switch order.Status {
case "finished":
if order.FinishAs == "filled" {
status = "FILLED"
} else if order.FinishAs == "cancelled" {
status = "CANCELED"
} else {
status = "CLOSED"
}
case "open":
status = "NEW"
}
side := "BUY"
if order.Size < 0 {
side = "SELL"
}
// Convert contract count to actual token quantity
executedQty := math.Abs(float64(order.Size-order.Left)) * quantoMultiplier
return map[string]interface{}{
"orderId": orderID,
"symbol": t.revertSymbol(symbol),
"status": status,
"avgPrice": fillPrice,
"executedQty": executedQty,
"side": side,
"type": order.Tif,
"time": int64(order.CreateTime * 1000),
"updateTime": int64(order.FinishTime * 1000),
"commission": totalFee,
}, nil
}
// GetClosedPnL retrieves closed position PnL records
func (t *GateTrader) GetClosedPnL(startTime time.Time, limit int) ([]types.ClosedPnLRecord, error) {
if limit <= 0 {
limit = 100
}
if limit > 100 {
limit = 100
}
opts := &gateapi.ListPositionCloseOpts{
Limit: optional.NewInt32(int32(limit)),
From: optional.NewInt64(startTime.Unix()),
}
closedPositions, _, err := t.client.FuturesApi.ListPositionClose(t.ctx, "usdt", opts)
if err != nil {
return nil, fmt.Errorf("failed to get closed positions: %w", err)
}
records := make([]types.ClosedPnLRecord, 0, len(closedPositions))
for _, pos := range closedPositions {
pnl, _ := strconv.ParseFloat(pos.Pnl, 64)
record := types.ClosedPnLRecord{
Symbol: t.revertSymbol(pos.Contract),
Side: pos.Side,
RealizedPnL: pnl,
ExitTime: time.Unix(int64(pos.Time), 0).UTC(),
CloseType: "unknown",
}
records = append(records, record)
}
return records, nil
}
// GetOpenOrders gets open/pending orders
func (t *GateTrader) GetOpenOrders(symbol string) ([]types.OpenOrder, error) {
symbol = t.convertSymbol(symbol)
opts := &gateapi.ListFuturesOrdersOpts{
Contract: optional.NewString(symbol),
}
orders, _, err := t.client.FuturesApi.ListFuturesOrders(t.ctx, "usdt", "open", opts)
if err != nil {
return nil, fmt.Errorf("failed to get open orders: %w", err)
}
// Get quanto_multiplier to convert contracts to actual quantity
quantoMultiplier := 1.0
contract, err := t.getContract(symbol)
if err == nil && contract != nil {
qm, _ := strconv.ParseFloat(contract.QuantoMultiplier, 64)
if qm > 0 {
quantoMultiplier = qm
}
}
var result []types.OpenOrder
for _, order := range orders {
price, _ := strconv.ParseFloat(order.Price, 64)
side := "BUY"
if order.Size < 0 {
side = "SELL"
}
// Convert contract count to actual token quantity
quantity := math.Abs(float64(order.Size)) * quantoMultiplier
result = append(result, types.OpenOrder{
OrderID: fmt.Sprintf("%d", order.Id),
Symbol: t.revertSymbol(order.Contract),
Side: side,
Type: "LIMIT",
Price: price,
Quantity: quantity,
Status: "NEW",
})
}
// Also get trigger orders
triggerOpts := &gateapi.ListPriceTriggeredOrdersOpts{
Contract: optional.NewString(symbol),
}
triggerOrders, _, err := t.client.FuturesApi.ListPriceTriggeredOrders(t.ctx, "usdt", "open", triggerOpts)
if err == nil {
for _, order := range triggerOrders {
triggerPrice, _ := strconv.ParseFloat(order.Trigger.Price, 64)
side := "BUY"
if order.Initial.Size < 0 {
side = "SELL"
}
orderType := "STOP_MARKET"
if order.Trigger.Rule == 2 {
orderType = "TAKE_PROFIT_MARKET"
}
// Convert contract count to actual token quantity
quantity := math.Abs(float64(order.Initial.Size)) * quantoMultiplier
result = append(result, types.OpenOrder{
OrderID: fmt.Sprintf("%d", order.Id),
Symbol: t.revertSymbol(order.Initial.Contract),
Side: side,
Type: orderType,
StopPrice: triggerPrice,
Quantity: quantity,
Status: "NEW",
})
}
}
return result, nil
}
// clearCache clears all caches // clearCache clears all caches
func (t *GateTrader) clearCache() { func (t *GateTrader) clearCache() {
t.balanceCacheMutex.Lock() t.balanceCacheMutex.Lock()

View File

@@ -0,0 +1,160 @@
package gate
import (
"fmt"
"nofx/trader/types"
"strconv"
"time"
"github.com/antihax/optional"
"github.com/gateio/gateapi-go/v6"
)
// GetBalance retrieves account balance
func (t *GateTrader) GetBalance() (map[string]interface{}, error) {
// Check cache
t.balanceCacheMutex.RLock()
if t.cachedBalance != nil && time.Since(t.balanceCacheTime) < t.cacheDuration {
cached := t.cachedBalance
t.balanceCacheMutex.RUnlock()
return cached, nil
}
t.balanceCacheMutex.RUnlock()
// Fetch from API
accounts, _, err := t.client.FuturesApi.ListFuturesAccounts(t.ctx, "usdt")
if err != nil {
return nil, fmt.Errorf("failed to get balance: %w", err)
}
total, _ := strconv.ParseFloat(accounts.Total, 64)
available, _ := strconv.ParseFloat(accounts.Available, 64)
unrealizedPnl, _ := strconv.ParseFloat(accounts.UnrealisedPnl, 64)
result := map[string]interface{}{
"totalWalletBalance": total,
"availableBalance": available,
"totalUnrealizedProfit": unrealizedPnl,
}
// Update cache
t.balanceCacheMutex.Lock()
t.cachedBalance = result
t.balanceCacheTime = time.Now()
t.balanceCacheMutex.Unlock()
return result, nil
}
// GetPositions retrieves all open positions
func (t *GateTrader) GetPositions() ([]map[string]interface{}, error) {
// Check cache
t.positionsCacheMutex.RLock()
if t.cachedPositions != nil && time.Since(t.positionsCacheTime) < t.cacheDuration {
cached := t.cachedPositions
t.positionsCacheMutex.RUnlock()
return cached, nil
}
t.positionsCacheMutex.RUnlock()
// Fetch from API
positions, _, err := t.client.FuturesApi.ListPositions(t.ctx, "usdt", nil)
if err != nil {
return nil, fmt.Errorf("failed to get positions: %w", err)
}
var result []map[string]interface{}
for _, pos := range positions {
if pos.Size == 0 {
continue // Skip empty positions
}
entryPrice, _ := strconv.ParseFloat(pos.EntryPrice, 64)
markPrice, _ := strconv.ParseFloat(pos.MarkPrice, 64)
liqPrice, _ := strconv.ParseFloat(pos.LiqPrice, 64)
unrealizedPnl, _ := strconv.ParseFloat(pos.UnrealisedPnl, 64)
leverage, _ := strconv.ParseFloat(pos.Leverage, 64)
// Gate returns position size in contracts, need to convert to base currency
// Each contract = quanto_multiplier base currency
contractSize := float64(pos.Size)
if pos.Size < 0 {
contractSize = float64(-pos.Size)
}
// Get quanto_multiplier from contract info to convert contracts to actual quantity
quantoMultiplier := 1.0
contract, err := t.getContract(pos.Contract)
if err == nil && contract != nil {
qm, _ := strconv.ParseFloat(contract.QuantoMultiplier, 64)
if qm > 0 {
quantoMultiplier = qm
}
}
// Convert contract count to actual token quantity
positionAmt := contractSize * quantoMultiplier
// Determine side based on position size
side := "long"
if pos.Size < 0 {
side = "short"
}
result = append(result, map[string]interface{}{
"symbol": pos.Contract,
"positionAmt": positionAmt,
"entryPrice": entryPrice,
"markPrice": markPrice,
"unRealizedProfit": unrealizedPnl,
"leverage": int(leverage),
"liquidationPrice": liqPrice,
"side": side,
})
}
// Update cache
t.positionsCacheMutex.Lock()
t.cachedPositions = result
t.positionsCacheTime = time.Now()
t.positionsCacheMutex.Unlock()
return result, nil
}
// GetClosedPnL retrieves closed position PnL records
func (t *GateTrader) GetClosedPnL(startTime time.Time, limit int) ([]types.ClosedPnLRecord, error) {
if limit <= 0 {
limit = 100
}
if limit > 100 {
limit = 100
}
opts := &gateapi.ListPositionCloseOpts{
Limit: optional.NewInt32(int32(limit)),
From: optional.NewInt64(startTime.Unix()),
}
closedPositions, _, err := t.client.FuturesApi.ListPositionClose(t.ctx, "usdt", opts)
if err != nil {
return nil, fmt.Errorf("failed to get closed positions: %w", err)
}
records := make([]types.ClosedPnLRecord, 0, len(closedPositions))
for _, pos := range closedPositions {
pnl, _ := strconv.ParseFloat(pos.Pnl, 64)
record := types.ClosedPnLRecord{
Symbol: t.revertSymbol(pos.Contract),
Side: pos.Side,
RealizedPnL: pnl,
ExitTime: time.Unix(int64(pos.Time), 0).UTC(),
CloseType: "unknown",
}
records = append(records, record)
}
return records, nil
}

View File

@@ -0,0 +1,644 @@
package gate
import (
"fmt"
"math"
"nofx/logger"
"nofx/trader/types"
"strconv"
"strings"
"github.com/antihax/optional"
"github.com/gateio/gateapi-go/v6"
)
// SetLeverage sets the leverage for a symbol
func (t *GateTrader) SetLeverage(symbol string, leverage int) error {
symbol = t.convertSymbol(symbol)
_, _, err := t.client.FuturesApi.UpdatePositionLeverage(t.ctx, "usdt", symbol, fmt.Sprintf("%d", leverage), nil)
if err != nil {
// Gate.io may return error if leverage is already set
if strings.Contains(err.Error(), "RISK_LIMIT_EXCEEDED") {
logger.Warnf(" [Gate] Leverage %d exceeds limit for %s", leverage, symbol)
return nil
}
return fmt.Errorf("failed to set leverage: %w", err)
}
logger.Infof(" [Gate] Leverage set to %dx for %s", leverage, symbol)
return nil
}
// SetMarginMode sets margin mode (cross or isolated)
func (t *GateTrader) SetMarginMode(symbol string, isCrossMargin bool) error {
// Gate.io uses leverage=0 for cross margin, positive number for isolated
// This is handled through UpdatePositionLeverage with cross_leverage_limit
// For now, we'll skip explicit margin mode setting as it's tied to leverage
logger.Infof(" [Gate] Margin mode is set through leverage (0=cross)")
return nil
}
// OpenLong opens a long position
func (t *GateTrader) OpenLong(symbol string, quantity float64, leverage int) (map[string]interface{}, error) {
symbol = t.convertSymbol(symbol)
// Cancel old orders first
t.CancelAllOrders(symbol)
// Set leverage
if err := t.SetLeverage(symbol, leverage); err != nil {
logger.Warnf(" [Gate] Failed to set leverage: %v", err)
}
// Get contract info for size calculation
contract, err := t.getContract(symbol)
if err != nil {
return nil, err
}
// Gate uses contract size units (each contract = quanto_multiplier base currency)
// size = quantity / quanto_multiplier
quantoMultiplier, _ := strconv.ParseFloat(contract.QuantoMultiplier, 64)
size := int64(quantity / quantoMultiplier)
if size <= 0 {
size = 1
}
order := gateapi.FuturesOrder{
Contract: symbol,
Size: size, // Positive for long
Price: "0", // Market order
Tif: "ioc",
Text: "t-nofx",
}
logger.Infof(" [Gate] OpenLong: symbol=%s, size=%d, leverage=%d", symbol, size, leverage)
result, _, err := t.client.FuturesApi.CreateFuturesOrder(t.ctx, "usdt", order, nil)
if err != nil {
return nil, fmt.Errorf("failed to open long position: %w", err)
}
// Clear cache
t.clearCache()
// Parse fill price from result
fillPrice, _ := strconv.ParseFloat(result.FillPrice, 64)
logger.Infof(" [Gate] Opened long position: orderId=%d, fillPrice=%.4f", result.Id, fillPrice)
return map[string]interface{}{
"orderId": fmt.Sprintf("%d", result.Id),
"symbol": t.revertSymbol(symbol),
"status": "FILLED",
"fillPrice": fillPrice,
"avgPrice": fillPrice,
}, nil
}
// OpenShort opens a short position
func (t *GateTrader) OpenShort(symbol string, quantity float64, leverage int) (map[string]interface{}, error) {
symbol = t.convertSymbol(symbol)
// Cancel old orders first
t.CancelAllOrders(symbol)
// Set leverage
if err := t.SetLeverage(symbol, leverage); err != nil {
logger.Warnf(" [Gate] Failed to set leverage: %v", err)
}
// Get contract info for size calculation
contract, err := t.getContract(symbol)
if err != nil {
return nil, err
}
// Gate uses contract size units
quantoMultiplier, _ := strconv.ParseFloat(contract.QuantoMultiplier, 64)
size := int64(quantity / quantoMultiplier)
if size <= 0 {
size = 1
}
order := gateapi.FuturesOrder{
Contract: symbol,
Size: -size, // Negative for short
Price: "0", // Market order
Tif: "ioc",
Text: "t-nofx",
}
logger.Infof(" [Gate] OpenShort: symbol=%s, size=%d, leverage=%d", symbol, -size, leverage)
result, _, err := t.client.FuturesApi.CreateFuturesOrder(t.ctx, "usdt", order, nil)
if err != nil {
return nil, fmt.Errorf("failed to open short position: %w", err)
}
// Clear cache
t.clearCache()
// Parse fill price from result
fillPrice, _ := strconv.ParseFloat(result.FillPrice, 64)
logger.Infof(" [Gate] Opened short position: orderId=%d, fillPrice=%.4f", result.Id, fillPrice)
return map[string]interface{}{
"orderId": fmt.Sprintf("%d", result.Id),
"symbol": t.revertSymbol(symbol),
"status": "FILLED",
"fillPrice": fillPrice,
"avgPrice": fillPrice,
}, nil
}
// CloseLong closes a long position
func (t *GateTrader) CloseLong(symbol string, quantity float64) (map[string]interface{}, error) {
symbol = t.convertSymbol(symbol)
// If quantity is 0, get current position
if quantity == 0 {
positions, err := t.GetPositions()
if err != nil {
return nil, err
}
for _, pos := range positions {
posSymbol := t.convertSymbol(pos["symbol"].(string))
if posSymbol == symbol && pos["side"] == "long" {
quantity = pos["positionAmt"].(float64)
break
}
}
if quantity == 0 {
return nil, fmt.Errorf("long position not found for %s", symbol)
}
}
// Get contract info for size calculation
contract, err := t.getContract(symbol)
if err != nil {
return nil, err
}
quantoMultiplier, _ := strconv.ParseFloat(contract.QuantoMultiplier, 64)
size := int64(quantity / quantoMultiplier)
if size <= 0 {
size = 1
}
// Close long = sell (use ReduceOnly, not Close which requires Size=0)
order := gateapi.FuturesOrder{
Contract: symbol,
Size: -size, // Negative to close long
Price: "0",
Tif: "ioc",
ReduceOnly: true,
Text: "t-nofx-close",
}
logger.Infof(" [Gate] CloseLong: symbol=%s, size=%d", symbol, -size)
result, _, err := t.client.FuturesApi.CreateFuturesOrder(t.ctx, "usdt", order, nil)
if err != nil {
return nil, fmt.Errorf("failed to close long position: %w", err)
}
// Clear cache
t.clearCache()
// Parse fill price from result
fillPrice, _ := strconv.ParseFloat(result.FillPrice, 64)
logger.Infof(" [Gate] Closed long position: orderId=%d, fillPrice=%.4f", result.Id, fillPrice)
return map[string]interface{}{
"orderId": fmt.Sprintf("%d", result.Id),
"symbol": t.revertSymbol(symbol),
"status": "FILLED",
"fillPrice": fillPrice,
"avgPrice": fillPrice,
}, nil
}
// CloseShort closes a short position
func (t *GateTrader) CloseShort(symbol string, quantity float64) (map[string]interface{}, error) {
symbol = t.convertSymbol(symbol)
// If quantity is 0, get current position
if quantity == 0 {
positions, err := t.GetPositions()
if err != nil {
return nil, err
}
for _, pos := range positions {
posSymbol := t.convertSymbol(pos["symbol"].(string))
if posSymbol == symbol && pos["side"] == "short" {
quantity = pos["positionAmt"].(float64)
break
}
}
if quantity == 0 {
return nil, fmt.Errorf("short position not found for %s", symbol)
}
}
// Ensure quantity is positive
if quantity < 0 {
quantity = -quantity
}
// Get contract info for size calculation
contract, err := t.getContract(symbol)
if err != nil {
return nil, err
}
quantoMultiplier, _ := strconv.ParseFloat(contract.QuantoMultiplier, 64)
size := int64(quantity / quantoMultiplier)
if size <= 0 {
size = 1
}
// Close short = buy (use ReduceOnly, not Close which requires Size=0)
order := gateapi.FuturesOrder{
Contract: symbol,
Size: size, // Positive to close short
Price: "0",
Tif: "ioc",
ReduceOnly: true,
Text: "t-nofx-close",
}
logger.Infof(" [Gate] CloseShort: symbol=%s, size=%d", symbol, size)
result, _, err := t.client.FuturesApi.CreateFuturesOrder(t.ctx, "usdt", order, nil)
if err != nil {
return nil, fmt.Errorf("failed to close short position: %w", err)
}
// Clear cache
t.clearCache()
// Parse fill price from result
fillPrice, _ := strconv.ParseFloat(result.FillPrice, 64)
logger.Infof(" [Gate] Closed short position: orderId=%d, fillPrice=%.4f", result.Id, fillPrice)
return map[string]interface{}{
"orderId": fmt.Sprintf("%d", result.Id),
"symbol": t.revertSymbol(symbol),
"status": "FILLED",
"fillPrice": fillPrice,
"avgPrice": fillPrice,
}, nil
}
// GetMarketPrice gets the current market price
func (t *GateTrader) GetMarketPrice(symbol string) (float64, error) {
symbol = t.convertSymbol(symbol)
opts := &gateapi.ListFuturesTickersOpts{
Contract: optional.NewString(symbol),
}
tickers, _, err := t.client.FuturesApi.ListFuturesTickers(t.ctx, "usdt", opts)
if err != nil {
return 0, fmt.Errorf("failed to get market price: %w", err)
}
if len(tickers) == 0 {
return 0, fmt.Errorf("no ticker data for %s", symbol)
}
price, _ := strconv.ParseFloat(tickers[0].Last, 64)
return price, nil
}
// SetStopLoss sets a stop loss order
func (t *GateTrader) SetStopLoss(symbol string, positionSide string, quantity, stopPrice float64) error {
symbol = t.convertSymbol(symbol)
contract, err := t.getContract(symbol)
if err != nil {
return err
}
quantoMultiplier, _ := strconv.ParseFloat(contract.QuantoMultiplier, 64)
size := int64(quantity / quantoMultiplier)
if size <= 0 {
size = 1
}
// For long position, stop loss means sell when price drops
// For short position, stop loss means buy when price rises
if strings.ToUpper(positionSide) == "LONG" {
size = -size
}
// Use price trigger order
trigger := gateapi.FuturesPriceTriggeredOrder{
Initial: gateapi.FuturesInitialOrder{
Contract: symbol,
Size: size,
Price: "0", // Market order
Tif: "ioc",
ReduceOnly: true,
Close: true,
},
Trigger: gateapi.FuturesPriceTrigger{
StrategyType: 0, // Close position
PriceType: 0, // Latest price
Price: fmt.Sprintf("%.8f", stopPrice),
Rule: 1, // Price <= trigger price
},
}
if strings.ToUpper(positionSide) == "SHORT" {
trigger.Trigger.Rule = 2 // Price >= trigger price for short stop loss
}
_, _, err = t.client.FuturesApi.CreatePriceTriggeredOrder(t.ctx, "usdt", trigger)
if err != nil {
return fmt.Errorf("failed to set stop loss: %w", err)
}
logger.Infof(" [Gate] Stop loss set: %s @ %.4f", symbol, stopPrice)
return nil
}
// SetTakeProfit sets a take profit order
func (t *GateTrader) SetTakeProfit(symbol string, positionSide string, quantity, takeProfitPrice float64) error {
symbol = t.convertSymbol(symbol)
contract, err := t.getContract(symbol)
if err != nil {
return err
}
quantoMultiplier, _ := strconv.ParseFloat(contract.QuantoMultiplier, 64)
size := int64(quantity / quantoMultiplier)
if size <= 0 {
size = 1
}
// For long position, take profit means sell when price rises
// For short position, take profit means buy when price drops
if strings.ToUpper(positionSide) == "LONG" {
size = -size
}
trigger := gateapi.FuturesPriceTriggeredOrder{
Initial: gateapi.FuturesInitialOrder{
Contract: symbol,
Size: size,
Price: "0", // Market order
Tif: "ioc",
ReduceOnly: true,
Close: true,
},
Trigger: gateapi.FuturesPriceTrigger{
StrategyType: 0, // Close position
PriceType: 0, // Latest price
Price: fmt.Sprintf("%.8f", takeProfitPrice),
Rule: 2, // Price >= trigger price for long take profit
},
}
if strings.ToUpper(positionSide) == "SHORT" {
trigger.Trigger.Rule = 1 // Price <= trigger price for short take profit
}
_, _, err = t.client.FuturesApi.CreatePriceTriggeredOrder(t.ctx, "usdt", trigger)
if err != nil {
return fmt.Errorf("failed to set take profit: %w", err)
}
logger.Infof(" [Gate] Take profit set: %s @ %.4f", symbol, takeProfitPrice)
return nil
}
// CancelStopLossOrders cancels stop loss orders
func (t *GateTrader) CancelStopLossOrders(symbol string) error {
return t.cancelTriggerOrders(symbol, "stop_loss")
}
// CancelTakeProfitOrders cancels take profit orders
func (t *GateTrader) CancelTakeProfitOrders(symbol string) error {
return t.cancelTriggerOrders(symbol, "take_profit")
}
// cancelTriggerOrders cancels trigger orders of a specific type
func (t *GateTrader) cancelTriggerOrders(symbol string, orderType string) error {
symbol = t.convertSymbol(symbol)
opts := &gateapi.ListPriceTriggeredOrdersOpts{
Contract: optional.NewString(symbol),
}
orders, _, err := t.client.FuturesApi.ListPriceTriggeredOrders(t.ctx, "usdt", "open", opts)
if err != nil {
return err
}
for _, order := range orders {
// Determine if it's stop loss or take profit based on trigger rule and position
// For simplicity, cancel all matching symbol orders
_, _, err := t.client.FuturesApi.CancelPriceTriggeredOrder(t.ctx, "usdt", fmt.Sprintf("%d", order.Id))
if err != nil {
logger.Warnf(" [Gate] Failed to cancel trigger order %d: %v", order.Id, err)
}
}
return nil
}
// CancelAllOrders cancels all pending orders for a symbol
func (t *GateTrader) CancelAllOrders(symbol string) error {
symbol = t.convertSymbol(symbol)
// Cancel regular orders
_, _, err := t.client.FuturesApi.CancelFuturesOrders(t.ctx, "usdt", symbol, nil)
if err != nil {
// Ignore if no orders to cancel
if !strings.Contains(err.Error(), "ORDER_NOT_FOUND") {
logger.Warnf(" [Gate] Error canceling orders: %v", err)
}
}
// Cancel trigger orders
t.cancelTriggerOrders(symbol, "")
return nil
}
// CancelStopOrders cancels all stop orders (stop loss and take profit)
func (t *GateTrader) CancelStopOrders(symbol string) error {
t.CancelStopLossOrders(symbol)
t.CancelTakeProfitOrders(symbol)
return nil
}
// FormatQuantity formats quantity to correct precision
func (t *GateTrader) FormatQuantity(symbol string, quantity float64) (string, error) {
contract, err := t.getContract(symbol)
if err != nil {
return fmt.Sprintf("%.4f", quantity), nil
}
// Gate uses quanto_multiplier for contract size
quantoMultiplier, _ := strconv.ParseFloat(contract.QuantoMultiplier, 64)
if quantoMultiplier > 0 {
// Calculate number of contracts
numContracts := quantity / quantoMultiplier
return fmt.Sprintf("%.0f", math.Floor(numContracts)), nil
}
return fmt.Sprintf("%.4f", quantity), nil
}
// GetOrderStatus gets the status of an order
func (t *GateTrader) GetOrderStatus(symbol string, orderID string) (map[string]interface{}, error) {
symbol = t.convertSymbol(symbol)
order, _, err := t.client.FuturesApi.GetFuturesOrder(t.ctx, "usdt", orderID)
if err != nil {
return nil, fmt.Errorf("failed to get order status: %w", err)
}
fillPrice, _ := strconv.ParseFloat(order.FillPrice, 64)
tkFee, _ := strconv.ParseFloat(order.Tkfr, 64)
mkFee, _ := strconv.ParseFloat(order.Mkfr, 64)
totalFee := tkFee + mkFee
// Get quanto_multiplier to convert contracts to actual quantity
quantoMultiplier := 1.0
contract, contractErr := t.getContract(symbol)
if contractErr == nil && contract != nil {
qm, _ := strconv.ParseFloat(contract.QuantoMultiplier, 64)
if qm > 0 {
quantoMultiplier = qm
}
}
// Map status
status := "NEW"
switch order.Status {
case "finished":
if order.FinishAs == "filled" {
status = "FILLED"
} else if order.FinishAs == "cancelled" {
status = "CANCELED"
} else {
status = "CLOSED"
}
case "open":
status = "NEW"
}
side := "BUY"
if order.Size < 0 {
side = "SELL"
}
// Convert contract count to actual token quantity
executedQty := math.Abs(float64(order.Size-order.Left)) * quantoMultiplier
return map[string]interface{}{
"orderId": orderID,
"symbol": t.revertSymbol(symbol),
"status": status,
"avgPrice": fillPrice,
"executedQty": executedQty,
"side": side,
"type": order.Tif,
"time": int64(order.CreateTime * 1000),
"updateTime": int64(order.FinishTime * 1000),
"commission": totalFee,
}, nil
}
// GetOpenOrders gets open/pending orders
func (t *GateTrader) GetOpenOrders(symbol string) ([]types.OpenOrder, error) {
symbol = t.convertSymbol(symbol)
opts := &gateapi.ListFuturesOrdersOpts{
Contract: optional.NewString(symbol),
}
orders, _, err := t.client.FuturesApi.ListFuturesOrders(t.ctx, "usdt", "open", opts)
if err != nil {
return nil, fmt.Errorf("failed to get open orders: %w", err)
}
// Get quanto_multiplier to convert contracts to actual quantity
quantoMultiplier := 1.0
contract, err := t.getContract(symbol)
if err == nil && contract != nil {
qm, _ := strconv.ParseFloat(contract.QuantoMultiplier, 64)
if qm > 0 {
quantoMultiplier = qm
}
}
var result []types.OpenOrder
for _, order := range orders {
price, _ := strconv.ParseFloat(order.Price, 64)
side := "BUY"
if order.Size < 0 {
side = "SELL"
}
// Convert contract count to actual token quantity
quantity := math.Abs(float64(order.Size)) * quantoMultiplier
result = append(result, types.OpenOrder{
OrderID: fmt.Sprintf("%d", order.Id),
Symbol: t.revertSymbol(order.Contract),
Side: side,
Type: "LIMIT",
Price: price,
Quantity: quantity,
Status: "NEW",
})
}
// Also get trigger orders
triggerOpts := &gateapi.ListPriceTriggeredOrdersOpts{
Contract: optional.NewString(symbol),
}
triggerOrders, _, err := t.client.FuturesApi.ListPriceTriggeredOrders(t.ctx, "usdt", "open", triggerOpts)
if err == nil {
for _, order := range triggerOrders {
triggerPrice, _ := strconv.ParseFloat(order.Trigger.Price, 64)
side := "BUY"
if order.Initial.Size < 0 {
side = "SELL"
}
orderType := "STOP_MARKET"
if order.Trigger.Rule == 2 {
orderType = "TAKE_PROFIT_MARKET"
}
// Convert contract count to actual token quantity
quantity := math.Abs(float64(order.Initial.Size)) * quantoMultiplier
result = append(result, types.OpenOrder{
OrderID: fmt.Sprintf("%d", order.Id),
Symbol: t.revertSymbol(order.Initial.Contract),
Side: side,
Type: orderType,
StopPrice: triggerPrice,
Quantity: quantity,
Status: "NEW",
})
}
}
return result, nil
}

View File

@@ -1,295 +0,0 @@
package hyperliquid
import (
"os"
"testing"
"time"
)
// TestHyperliquidBalanceCalculation tests the balance calculation for Hyperliquid
// including perp, spot, and xyz dex (stocks, forex, metals) accounts
// Run with: TEST_PRIVATE_KEY=xxx TEST_WALLET_ADDR=xxx go test -v -run TestHyperliquidBalanceCalculation ./trader/
func TestHyperliquidBalanceCalculation(t *testing.T) {
// Get credentials from environment
privateKeyHex := os.Getenv("TEST_PRIVATE_KEY")
walletAddr := os.Getenv("TEST_WALLET_ADDR")
if privateKeyHex == "" || walletAddr == "" {
t.Skip("TEST_PRIVATE_KEY and TEST_WALLET_ADDR env vars required")
}
t.Logf("=== Testing Hyperliquid Balance Calculation ===")
t.Logf("Wallet: %s", walletAddr)
// Create trader instance
trader, err := NewHyperliquidTrader(privateKeyHex, walletAddr, false)
if err != nil {
t.Fatalf("Failed to create trader: %v", err)
}
// Test GetBalance
t.Log("\n--- Testing GetBalance ---")
balance, err := trader.GetBalance()
if err != nil {
t.Fatalf("GetBalance failed: %v", err)
}
// Extract values
totalWalletBalance, _ := balance["totalWalletBalance"].(float64)
totalEquity, _ := balance["totalEquity"].(float64)
totalUnrealizedProfit, _ := balance["totalUnrealizedProfit"].(float64)
availableBalance, _ := balance["availableBalance"].(float64)
spotBalance, _ := balance["spotBalance"].(float64)
xyzDexBalance, _ := balance["xyzDexBalance"].(float64)
xyzDexUnrealizedPnl, _ := balance["xyzDexUnrealizedPnl"].(float64)
perpAccountValue, _ := balance["perpAccountValue"].(float64)
t.Logf("\n📊 Balance Results:")
t.Logf(" Perp Account Value: %.4f USDC", perpAccountValue)
t.Logf(" Spot Balance: %.4f USDC", spotBalance)
t.Logf(" xyz Dex Balance: %.4f USDC", xyzDexBalance)
t.Logf(" xyz Dex Unrealized PnL: %.4f USDC", xyzDexUnrealizedPnl)
t.Logf(" ---")
t.Logf(" Total Wallet Balance: %.4f USDC", totalWalletBalance)
t.Logf(" Total Unrealized PnL: %.4f USDC", totalUnrealizedProfit)
t.Logf(" Total Equity: %.4f USDC", totalEquity)
t.Logf(" Available Balance: %.4f USDC", availableBalance)
// Verify calculation: totalEquity should equal perpAccountValue + spotBalance + xyzDexBalance
expectedEquity := perpAccountValue + spotBalance + xyzDexBalance
t.Logf("\n🔍 Verification:")
t.Logf(" Expected Equity (Perp + Spot + xyz): %.4f", expectedEquity)
t.Logf(" Actual Total Equity: %.4f", totalEquity)
if abs(totalEquity-expectedEquity) > 0.01 {
t.Errorf("❌ Equity mismatch! Expected %.4f, got %.4f", expectedEquity, totalEquity)
} else {
t.Logf("✅ Equity calculation correct!")
}
// Verify: totalWalletBalance + totalUnrealizedProfit should equal totalEquity
calculatedEquity := totalWalletBalance + totalUnrealizedProfit
t.Logf("\n🔍 Secondary Verification:")
t.Logf(" Wallet + Unrealized = %.4f + %.4f = %.4f", totalWalletBalance, totalUnrealizedProfit, calculatedEquity)
t.Logf(" Total Equity: %.4f", totalEquity)
if abs(calculatedEquity-totalEquity) > 0.01 {
t.Errorf("❌ Secondary check failed! Wallet+Unrealized=%.4f != Equity=%.4f", calculatedEquity, totalEquity)
} else {
t.Logf("✅ Secondary verification passed!")
}
// Test GetPositions
t.Log("\n--- Testing GetPositions ---")
positions, err := trader.GetPositions()
if err != nil {
t.Fatalf("GetPositions failed: %v", err)
}
t.Logf("Found %d positions:", len(positions))
totalPositionValue := 0.0
totalPositionPnL := 0.0
for i, pos := range positions {
symbol, _ := pos["symbol"].(string)
side, _ := pos["side"].(string)
positionAmt, _ := pos["positionAmt"].(float64)
entryPrice, _ := pos["entryPrice"].(float64)
markPrice, _ := pos["markPrice"].(float64)
unrealizedPnL, _ := pos["unRealizedProfit"].(float64)
leverage, _ := pos["leverage"].(float64)
isXyzDex, _ := pos["isXyzDex"].(bool)
posValue := positionAmt * markPrice
totalPositionValue += posValue
totalPositionPnL += unrealizedPnL
assetType := "Crypto"
if isXyzDex {
assetType = "xyz Dex"
}
t.Logf(" [%d] %s (%s)", i+1, symbol, assetType)
t.Logf(" Side: %s, Qty: %.4f, Leverage: %.0fx", side, positionAmt, leverage)
t.Logf(" Entry: %.4f, Mark: %.4f", entryPrice, markPrice)
t.Logf(" Value: %.4f, PnL: %.4f", posValue, unrealizedPnL)
// Verify xyz dex position has valid entry/mark prices
if isXyzDex {
if entryPrice == 0 {
t.Errorf("❌ xyz dex position %s has zero entry price!", symbol)
}
if markPrice == 0 {
t.Errorf("❌ xyz dex position %s has zero mark price!", symbol)
}
}
}
t.Logf("\n📊 Position Summary:")
t.Logf(" Total Position Value: %.4f USDC", totalPositionValue)
t.Logf(" Total Position PnL: %.4f USDC", totalPositionPnL)
// Compare position PnL with balance unrealized PnL
t.Logf("\n🔍 PnL Comparison:")
t.Logf(" Balance Unrealized PnL: %.4f", totalUnrealizedProfit)
t.Logf(" Position Sum PnL: %.4f", totalPositionPnL)
if abs(totalUnrealizedProfit-totalPositionPnL) > 0.1 {
t.Logf("⚠️ PnL mismatch (may be due to funding fees or timing)")
} else {
t.Logf("✅ PnL values match!")
}
}
// TestXyzDexBalanceDirectQuery directly queries xyz dex balance for debugging
func TestXyzDexBalanceDirectQuery(t *testing.T) {
privateKeyHex := os.Getenv("TEST_PRIVATE_KEY")
walletAddr := os.Getenv("TEST_WALLET_ADDR")
if privateKeyHex == "" || walletAddr == "" {
t.Skip("TEST_PRIVATE_KEY and TEST_WALLET_ADDR env vars required")
}
trader, err := NewHyperliquidTrader(privateKeyHex, walletAddr, false)
if err != nil {
t.Fatalf("Failed to create trader: %v", err)
}
t.Log("=== Direct xyz Dex Balance Query ===")
accountValue, unrealizedPnl, positions, err := trader.getXYZDexBalance()
if err != nil {
t.Fatalf("getXYZDexBalance failed: %v", err)
}
t.Logf("xyz Dex Account Value: %.4f", accountValue)
t.Logf("xyz Dex Unrealized PnL: %.4f", unrealizedPnl)
t.Logf("xyz Dex Wallet Balance: %.4f", accountValue-unrealizedPnl)
t.Logf("xyz Dex Positions: %d", len(positions))
for i, pos := range positions {
entryPx := "nil"
if pos.Position.EntryPx != nil {
entryPx = *pos.Position.EntryPx
}
liqPx := "nil"
if pos.Position.LiquidationPx != nil {
liqPx = *pos.Position.LiquidationPx
}
t.Logf(" [%d] %s:", i+1, pos.Position.Coin)
t.Logf(" Size: %s", pos.Position.Szi)
t.Logf(" Entry Price: %s", entryPx)
t.Logf(" Position Value: %s", pos.Position.PositionValue)
t.Logf(" Unrealized PnL: %s", pos.Position.UnrealizedPnl)
t.Logf(" Liquidation Price: %s", liqPx)
t.Logf(" Leverage: %d (%s)", pos.Position.Leverage.Value, pos.Position.Leverage.Type)
}
}
// TestEquityAfterOpeningPosition simulates opening a position and verifies equity
func TestEquityAfterOpeningPosition(t *testing.T) {
privateKeyHex := os.Getenv("TEST_PRIVATE_KEY")
walletAddr := os.Getenv("TEST_WALLET_ADDR")
if privateKeyHex == "" || walletAddr == "" {
t.Skip("TEST_PRIVATE_KEY and TEST_WALLET_ADDR env vars required")
}
if os.Getenv("XYZ_DEX_LIVE_TEST") != "1" {
t.Skip("Set XYZ_DEX_LIVE_TEST=1 to run live position test")
}
trader, err := NewHyperliquidTrader(privateKeyHex, walletAddr, false)
if err != nil {
t.Fatalf("Failed to create trader: %v", err)
}
// Step 1: Record initial balance
t.Log("=== Step 1: Record Initial Balance ===")
initialBalance, _ := trader.GetBalance()
initialEquity, _ := initialBalance["totalEquity"].(float64)
t.Logf("Initial Equity: %.4f", initialEquity)
// Step 2: Fetch xyz meta
if err := trader.fetchXyzMeta(); err != nil {
t.Fatalf("Failed to fetch xyz meta: %v", err)
}
// Step 3: Get current price and place a small order
price, err := trader.getXyzMarketPrice("xyz:SILVER")
if err != nil {
t.Fatalf("Failed to get price: %v", err)
}
t.Logf("Current xyz:SILVER price: %.4f", price)
// Place a small buy order (minimum ~$10)
testSize := 0.14
testPrice := price * 1.05 // 5% above for IOC
t.Log("\n=== Step 2: Place Test Order ===")
t.Logf("Opening position: xyz:SILVER BUY %.4f @ %.4f", testSize, testPrice)
err = trader.placeXyzOrder("xyz:SILVER", true, testSize, testPrice, false)
if err != nil {
t.Logf("Order result: %v", err)
// Even if IOC doesn't fill, continue to check balance
}
// Wait a moment for the order to process
time.Sleep(2 * time.Second)
// Step 3: Check balance after order
t.Log("\n=== Step 3: Check Balance After Order ===")
afterBalance, _ := trader.GetBalance()
afterEquity, _ := afterBalance["totalEquity"].(float64)
afterPerpAV, _ := afterBalance["perpAccountValue"].(float64)
afterXyzAV, _ := afterBalance["xyzDexBalance"].(float64)
t.Logf("After Order:")
t.Logf(" Perp Account Value: %.4f", afterPerpAV)
t.Logf(" xyz Dex Balance: %.4f", afterXyzAV)
t.Logf(" Total Equity: %.4f", afterEquity)
equityChange := afterEquity - initialEquity
t.Logf("\nEquity Change: %.4f (%.2f%%)", equityChange, (equityChange/initialEquity)*100)
// Equity should not change significantly (only by trading fees/slippage)
if abs(equityChange) > initialEquity*0.05 { // More than 5% change is suspicious
t.Errorf("❌ Equity changed too much! Initial=%.4f, After=%.4f, Change=%.4f",
initialEquity, afterEquity, equityChange)
} else {
t.Logf("✅ Equity change is within acceptable range")
}
// Step 4: Close position if opened
t.Log("\n=== Step 4: Close Position ===")
positions, _ := trader.GetPositions()
for _, pos := range positions {
symbol, _ := pos["symbol"].(string)
if symbol == "xyz:SILVER" {
posAmt, _ := pos["positionAmt"].(float64)
if posAmt > 0 {
closePrice := price * 0.95 // 5% below for IOC sell
t.Logf("Closing position: SELL %.4f @ %.4f", posAmt, closePrice)
trader.placeXyzOrder("xyz:SILVER", false, posAmt, closePrice, true)
}
}
}
time.Sleep(2 * time.Second)
// Final balance check
t.Log("\n=== Step 5: Final Balance ===")
finalBalance, _ := trader.GetBalance()
finalEquity, _ := finalBalance["totalEquity"].(float64)
t.Logf("Final Equity: %.4f", finalEquity)
t.Logf("Net Change: %.4f", finalEquity-initialEquity)
}
func abs(x float64) float64 {
if x < 0 {
return -x
}
return x
}

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,592 @@
package hyperliquid
import (
"bytes"
"encoding/json"
"fmt"
"io"
"net/http"
"nofx/logger"
"nofx/trader/types"
"strconv"
"strings"
"time"
)
// GetBalance gets account balance
func (t *HyperliquidTrader) GetBalance() (map[string]interface{}, error) {
logger.Infof("🔄 Calling Hyperliquid API to get account balance...")
// Step 1: Query Spot account balance
spotState, err := t.exchange.Info().SpotUserState(t.ctx, t.walletAddr)
var spotUSDCBalance float64 = 0.0
if err != nil {
logger.Infof("⚠️ Failed to query Spot balance (may have no spot assets): %v", err)
} else if spotState != nil && len(spotState.Balances) > 0 {
for _, balance := range spotState.Balances {
if balance.Coin == "USDC" {
spotUSDCBalance, _ = strconv.ParseFloat(balance.Total, 64)
logger.Infof("✓ Found Spot balance: %.2f USDC", spotUSDCBalance)
break
}
}
}
// Step 2: Query Perpetuals contract account status
accountState, err := t.exchange.Info().UserState(t.ctx, t.walletAddr)
if err != nil {
logger.Infof("❌ Hyperliquid Perpetuals API call failed: %v", err)
return nil, fmt.Errorf("failed to get account information: %w", err)
}
// Parse balance information (MarginSummary fields are all strings)
result := make(map[string]interface{})
// Step 3: Dynamically select correct summary based on margin mode (CrossMarginSummary or MarginSummary)
var accountValue, totalMarginUsed float64
var summaryType string
var summary interface{}
if t.isCrossMargin {
// Cross margin mode: use CrossMarginSummary
accountValue, _ = strconv.ParseFloat(accountState.CrossMarginSummary.AccountValue, 64)
totalMarginUsed, _ = strconv.ParseFloat(accountState.CrossMarginSummary.TotalMarginUsed, 64)
summaryType = "CrossMarginSummary (cross margin)"
summary = accountState.CrossMarginSummary
} else {
// Isolated margin mode: use MarginSummary
accountValue, _ = strconv.ParseFloat(accountState.MarginSummary.AccountValue, 64)
totalMarginUsed, _ = strconv.ParseFloat(accountState.MarginSummary.TotalMarginUsed, 64)
summaryType = "MarginSummary (isolated margin)"
summary = accountState.MarginSummary
}
// Debug: Print complete summary structure returned by API
summaryJSON, _ := json.MarshalIndent(summary, " ", " ")
logger.Infof("🔍 [DEBUG] Hyperliquid API %s complete data:", summaryType)
logger.Infof("%s", string(summaryJSON))
// Critical fix: Accumulate actual unrealized PnL from all positions
totalUnrealizedPnl := 0.0
for _, assetPos := range accountState.AssetPositions {
unrealizedPnl, _ := strconv.ParseFloat(assetPos.Position.UnrealizedPnl, 64)
totalUnrealizedPnl += unrealizedPnl
}
// Correctly understand Hyperliquid fields:
// AccountValue = Total account equity (includes idle funds + position value + unrealized PnL)
// TotalMarginUsed = Margin used by positions (included in AccountValue, for display only)
//
// To be compatible with auto_types.go calculation logic (totalEquity = totalWalletBalance + totalUnrealizedProfit)
// Need to return "wallet balance without unrealized PnL"
walletBalanceWithoutUnrealized := accountValue - totalUnrealizedPnl
// Step 4: Use Withdrawable field (PR #443)
// Withdrawable is the official real withdrawable balance, more reliable than simple calculation
availableBalance := 0.0
if accountState.Withdrawable != "" {
withdrawable, err := strconv.ParseFloat(accountState.Withdrawable, 64)
if err == nil && withdrawable > 0 {
availableBalance = withdrawable
logger.Infof("✓ Using Withdrawable as available balance: %.2f", availableBalance)
}
}
// Fallback: If no Withdrawable, use simple calculation
if availableBalance == 0 && accountState.Withdrawable == "" {
availableBalance = accountValue - totalMarginUsed
if availableBalance < 0 {
logger.Infof("⚠️ Calculated available balance is negative (%.2f), reset to 0", availableBalance)
availableBalance = 0
}
}
// Step 5: Query xyz dex balance (stock perps, forex, commodities)
var xyzAccountValue, xyzUnrealizedPnl float64
var xyzPositions []xyzAssetPosition
xyzAccountValue, xyzUnrealizedPnl, xyzPositions, err = t.getXYZDexBalance()
if err != nil {
// xyz dex query failed - log warning but don't fail the entire balance query
logger.Infof("⚠️ Failed to query xyz dex balance: %v", err)
}
// Always log xyz dex state for debugging
logger.Infof("🔍 xyz dex state: accountValue=%.4f, unrealizedPnl=%.4f, positions=%d",
xyzAccountValue, xyzUnrealizedPnl, len(xyzPositions))
for _, pos := range xyzPositions {
entryPx := "nil"
if pos.Position.EntryPx != nil {
entryPx = *pos.Position.EntryPx
}
logger.Infof(" └─ %s: size=%s, entryPx=%s, posValue=%s, pnl=%s",
pos.Position.Coin, pos.Position.Szi, entryPx, pos.Position.PositionValue, pos.Position.UnrealizedPnl)
}
xyzWalletBalance := xyzAccountValue - xyzUnrealizedPnl
// Step 6: Correctly handle Spot + Perpetuals + xyz dex balance
// Important: Each account is independent, manual transfers required
totalWalletBalance := walletBalanceWithoutUnrealized + spotUSDCBalance + xyzWalletBalance
totalUnrealizedPnlAll := totalUnrealizedPnl + xyzUnrealizedPnl
// Calculate total equity properly: perpAccountValue + spotUSDCBalance + xyzAccountValue
// Note: totalWalletBalance + totalUnrealizedPnlAll should equal this
totalEquityCalculated := accountValue + spotUSDCBalance + xyzAccountValue
// Step 7: Unified Account mode - Spot USDC is used as collateral for Perps
// In this mode, available balance includes Spot USDC since it can be used for Perp margin
if t.isUnifiedAccount && spotUSDCBalance > 0 {
// Add Spot balance to available balance for trading
availableBalance = availableBalance + spotUSDCBalance
logger.Infof("✓ Unified Account: Spot %.2f USDC added to available balance (total: %.2f)",
spotUSDCBalance, availableBalance)
}
// Suppress unused variable warning
_ = totalUnrealizedPnlAll
result["totalWalletBalance"] = totalWalletBalance // Total assets (Perp + Spot + xyz) - unrealized
result["totalEquity"] = totalEquityCalculated // Total equity = Perp AV + Spot + xyz AV
result["availableBalance"] = availableBalance // Available balance (Perp + Spot if unified)
result["totalUnrealizedProfit"] = totalUnrealizedPnlAll // Unrealized PnL (Perpetuals + xyz)
result["spotBalance"] = spotUSDCBalance // Spot balance
result["xyzDexBalance"] = xyzAccountValue // xyz dex equity (stock perps, forex, commodities)
result["xyzDexUnrealizedPnl"] = xyzUnrealizedPnl // xyz dex unrealized PnL
result["perpAccountValue"] = accountValue // Perp account value for debugging
logger.Infof("✓ Hyperliquid complete account:")
logger.Infof(" • Spot balance: %.2f USDC", spotUSDCBalance)
logger.Infof(" • Perpetuals equity: %.2f USDC (wallet %.2f + unrealized %.2f)",
accountValue,
walletBalanceWithoutUnrealized,
totalUnrealizedPnl)
logger.Infof(" • Perpetuals available balance: %.2f USDC", availableBalance)
logger.Infof(" • Margin used: %.2f USDC", totalMarginUsed)
logger.Infof(" • xyz dex equity: %.2f USDC (wallet %.2f + unrealized %.2f)",
xyzAccountValue,
xyzWalletBalance,
xyzUnrealizedPnl)
logger.Infof(" • Total assets (Perp+Spot+xyz): %.2f USDC", totalWalletBalance)
logger.Infof(" ⭐ Total: %.2f USDC | Perp: %.2f | Spot: %.2f | xyz: %.2f",
totalWalletBalance, availableBalance, spotUSDCBalance, xyzAccountValue)
return result, nil
}
// xyzDexState represents the clearinghouse state for xyz dex
type xyzDexState struct {
MarginSummary *xyzMarginSummary `json:"marginSummary,omitempty"`
CrossMarginSummary *xyzMarginSummary `json:"crossMarginSummary,omitempty"`
Withdrawable string `json:"withdrawable,omitempty"`
AssetPositions []xyzAssetPosition `json:"assetPositions,omitempty"`
}
type xyzMarginSummary struct {
AccountValue string `json:"accountValue"`
TotalMarginUsed string `json:"totalMarginUsed"`
}
type xyzAssetPosition struct {
Position struct {
Coin string `json:"coin"`
Szi string `json:"szi"`
EntryPx *string `json:"entryPx"`
PositionValue string `json:"positionValue"`
UnrealizedPnl string `json:"unrealizedPnl"`
LiquidationPx *string `json:"liquidationPx"`
Leverage struct {
Type string `json:"type"`
Value int `json:"value"`
} `json:"leverage"`
} `json:"position"`
}
// getXYZDexBalance queries the xyz dex balance (stock perps, forex, commodities)
func (t *HyperliquidTrader) getXYZDexBalance() (accountValue float64, unrealizedPnl float64, positions []xyzAssetPosition, err error) {
// Build request for xyz dex clearinghouse state
reqBody := map[string]interface{}{
"type": "clearinghouseState",
"user": t.walletAddr,
"dex": "xyz",
}
jsonBody, err := json.Marshal(reqBody)
if err != nil {
return 0, 0, nil, fmt.Errorf("failed to marshal request: %w", err)
}
// Determine API URL
apiURL := "https://api.hyperliquid.xyz/info"
// Note: xyz dex may not be available on testnet
req, err := http.NewRequestWithContext(t.ctx, "POST", apiURL, bytes.NewBuffer(jsonBody))
if err != nil {
return 0, 0, nil, fmt.Errorf("failed to create request: %w", err)
}
req.Header.Set("Content-Type", "application/json")
client := &http.Client{Timeout: 30 * time.Second}
resp, err := client.Do(req)
if err != nil {
return 0, 0, nil, fmt.Errorf("failed to execute request: %w", err)
}
defer resp.Body.Close()
body, err := io.ReadAll(resp.Body)
if err != nil {
return 0, 0, nil, fmt.Errorf("failed to read response: %w", err)
}
if resp.StatusCode != http.StatusOK {
return 0, 0, nil, fmt.Errorf("xyz dex API error (status %d): %s", resp.StatusCode, string(body))
}
var state xyzDexState
if err := json.Unmarshal(body, &state); err != nil {
return 0, 0, nil, fmt.Errorf("failed to parse response: %w", err)
}
// Parse account value - xyz dex uses MarginSummary for isolated margin mode
// CrossMarginSummary may exist but with 0 values, so check MarginSummary first
if state.MarginSummary != nil && state.MarginSummary.AccountValue != "" {
av, _ := strconv.ParseFloat(state.MarginSummary.AccountValue, 64)
if av > 0 {
accountValue = av
}
}
// Fallback to CrossMarginSummary if MarginSummary is 0
if accountValue == 0 && state.CrossMarginSummary != nil && state.CrossMarginSummary.AccountValue != "" {
accountValue, _ = strconv.ParseFloat(state.CrossMarginSummary.AccountValue, 64)
}
// Calculate total unrealized PnL from positions
for _, pos := range state.AssetPositions {
pnl, _ := strconv.ParseFloat(pos.Position.UnrealizedPnl, 64)
unrealizedPnl += pnl
}
return accountValue, unrealizedPnl, state.AssetPositions, nil
}
// GetMarketPrice gets market price (supports both crypto and xyz dex assets)
func (t *HyperliquidTrader) GetMarketPrice(symbol string) (float64, error) {
coin := convertSymbolToHyperliquid(symbol)
// Check if this is an xyz dex asset
if strings.HasPrefix(coin, "xyz:") {
return t.getXyzMarketPrice(coin)
}
// Get all market prices for crypto
allMids, err := t.exchange.Info().AllMids(t.ctx)
if err != nil {
return 0, fmt.Errorf("failed to get price: %w", err)
}
// Find price for corresponding coin (allMids is map[string]string)
if priceStr, ok := allMids[coin]; ok {
priceFloat, err := strconv.ParseFloat(priceStr, 64)
if err == nil {
return priceFloat, nil
}
return 0, fmt.Errorf("price format error: %v", err)
}
return 0, fmt.Errorf("price not found for %s", symbol)
}
// getXyzMarketPrice gets market price for xyz dex assets
func (t *HyperliquidTrader) getXyzMarketPrice(coin string) (float64, error) {
// Build request for xyz dex allMids
reqBody := map[string]string{
"type": "allMids",
"dex": "xyz",
}
jsonBody, err := json.Marshal(reqBody)
if err != nil {
return 0, fmt.Errorf("failed to marshal request: %w", err)
}
apiURL := "https://api.hyperliquid.xyz/info"
req, err := http.NewRequestWithContext(t.ctx, "POST", apiURL, bytes.NewBuffer(jsonBody))
if err != nil {
return 0, fmt.Errorf("failed to create request: %w", err)
}
req.Header.Set("Content-Type", "application/json")
client := &http.Client{Timeout: 30 * time.Second}
resp, err := client.Do(req)
if err != nil {
return 0, fmt.Errorf("failed to execute request: %w", err)
}
defer resp.Body.Close()
body, err := io.ReadAll(resp.Body)
if err != nil {
return 0, fmt.Errorf("failed to read response: %w", err)
}
if resp.StatusCode != http.StatusOK {
return 0, fmt.Errorf("xyz dex allMids API error (status %d): %s", resp.StatusCode, string(body))
}
var mids map[string]string
if err := json.Unmarshal(body, &mids); err != nil {
return 0, fmt.Errorf("failed to parse response: %w", err)
}
// The API returns keys with xyz: prefix, so ensure the coin has it
lookupKey := coin
if !strings.HasPrefix(lookupKey, "xyz:") {
lookupKey = "xyz:" + lookupKey
}
if priceStr, ok := mids[lookupKey]; ok {
priceFloat, err := strconv.ParseFloat(priceStr, 64)
if err == nil {
return priceFloat, nil
}
return 0, fmt.Errorf("price format error: %v", err)
}
return 0, fmt.Errorf("xyz dex price not found for %s (lookup key: %s)", coin, lookupKey)
}
// GetOrderStatus gets order status
// Hyperliquid uses IOC orders, usually filled or cancelled immediately
// For completed orders, need to query historical records
func (t *HyperliquidTrader) GetOrderStatus(symbol string, orderID string) (map[string]interface{}, error) {
// Hyperliquid's IOC orders are completed almost immediately
// If order was placed through this system, returned status will be FILLED
// Try to query open orders to determine if still pending
coin := convertSymbolToHyperliquid(symbol)
// First check if in open orders
openOrders, err := t.exchange.Info().OpenOrders(t.ctx, t.walletAddr)
if err != nil {
// If query fails, assume order is completed
return map[string]interface{}{
"orderId": orderID,
"status": "FILLED",
"avgPrice": 0.0,
"executedQty": 0.0,
"commission": 0.0,
}, nil
}
// Check if order is in open orders list
for _, order := range openOrders {
if order.Coin == coin && fmt.Sprintf("%d", order.Oid) == orderID {
// Order is still pending
return map[string]interface{}{
"orderId": orderID,
"status": "NEW",
"avgPrice": 0.0,
"executedQty": 0.0,
"commission": 0.0,
}, nil
}
}
// Order not in open list, meaning completed or cancelled
// Hyperliquid IOC orders not in open list are usually filled
return map[string]interface{}{
"orderId": orderID,
"status": "FILLED",
"avgPrice": 0.0, // Hyperliquid does not directly return execution price, need to get from position info
"executedQty": 0.0,
"commission": 0.0,
}, nil
}
// GetClosedPnL gets recent closing trades from Hyperliquid
// Note: Hyperliquid does NOT have a position history API, only fill history.
// This returns individual closing trades for real-time position closure detection.
func (t *HyperliquidTrader) GetClosedPnL(startTime time.Time, limit int) ([]types.ClosedPnLRecord, error) {
trades, err := t.GetTrades(startTime, limit)
if err != nil {
return nil, err
}
// Filter only closing trades (realizedPnl != 0)
var records []types.ClosedPnLRecord
for _, trade := range trades {
if trade.RealizedPnL == 0 {
continue
}
// Determine side (Hyperliquid uses one-way mode)
side := "long"
if trade.Side == "SELL" || trade.Side == "Sell" {
side = "long" // Selling closes long
} else {
side = "short" // Buying closes short
}
// Calculate entry price from PnL
var entryPrice float64
if trade.Quantity > 0 {
if side == "long" {
entryPrice = trade.Price - trade.RealizedPnL/trade.Quantity
} else {
entryPrice = trade.Price + trade.RealizedPnL/trade.Quantity
}
}
records = append(records, types.ClosedPnLRecord{
Symbol: trade.Symbol,
Side: side,
EntryPrice: entryPrice,
ExitPrice: trade.Price,
Quantity: trade.Quantity,
RealizedPnL: trade.RealizedPnL,
Fee: trade.Fee,
ExitTime: trade.Time,
EntryTime: trade.Time,
OrderID: trade.TradeID,
ExchangeID: trade.TradeID,
CloseType: "unknown",
})
}
return records, nil
}
// GetTrades retrieves trade history from Hyperliquid
func (t *HyperliquidTrader) GetTrades(startTime time.Time, limit int) ([]types.TradeRecord, error) {
// Use UserFillsByTime API
startTimeMs := startTime.UnixMilli()
fills, err := t.exchange.Info().UserFillsByTime(t.ctx, t.walletAddr, startTimeMs, nil, nil)
if err != nil {
return nil, fmt.Errorf("failed to get user fills: %w", err)
}
var trades []types.TradeRecord
for _, fill := range fills {
price, _ := strconv.ParseFloat(fill.Price, 64)
qty, _ := strconv.ParseFloat(fill.Size, 64)
fee, _ := strconv.ParseFloat(fill.Fee, 64)
pnl, _ := strconv.ParseFloat(fill.ClosedPnl, 64)
// Determine side: "B" = Buy, "S" = Sell (or "A" = Ask, "B" = Bid)
var side string
if fill.Side == "B" || fill.Side == "Buy" || fill.Side == "bid" {
side = "BUY"
} else {
side = "SELL"
}
// Parse Dir field to get order action
// Hyperliquid Dir values: "Open Long", "Open Short", "Close Long", "Close Short"
var orderAction string
switch strings.ToLower(fill.Dir) {
case "open long":
orderAction = "open_long"
case "open short":
orderAction = "open_short"
case "close long":
orderAction = "close_long"
case "close short":
orderAction = "close_short"
default:
// Fallback: use RealizedPnL if Dir is missing/unknown
if pnl != 0 {
if side == "BUY" {
orderAction = "close_short"
} else {
orderAction = "close_long"
}
} else {
if side == "BUY" {
orderAction = "open_long"
} else {
orderAction = "open_short"
}
}
}
// Hyperliquid uses one-way mode, so PositionSide is "BOTH"
trade := types.TradeRecord{
TradeID: strconv.FormatInt(fill.Tid, 10),
Symbol: fill.Coin,
Side: side,
PositionSide: "BOTH", // Hyperliquid doesn't have hedge mode
OrderAction: orderAction,
Price: price,
Quantity: qty,
RealizedPnL: pnl,
Fee: fee,
Time: time.UnixMilli(fill.Time).UTC(),
}
trades = append(trades, trade)
}
return trades, nil
}
// GetOpenOrders gets all open/pending orders for a symbol
func (t *HyperliquidTrader) GetOpenOrders(symbol string) ([]types.OpenOrder, error) {
openOrders, err := t.exchange.Info().OpenOrders(t.ctx, t.walletAddr)
if err != nil {
return nil, fmt.Errorf("failed to get open orders: %w", err)
}
var result []types.OpenOrder
for _, order := range openOrders {
if order.Coin != symbol {
continue
}
side := "BUY"
if order.Side == "A" {
side = "SELL"
}
result = append(result, types.OpenOrder{
OrderID: fmt.Sprintf("%d", order.Oid),
Symbol: order.Coin,
Side: side,
PositionSide: "",
Type: "LIMIT",
Price: order.LimitPx,
StopPrice: 0,
Quantity: order.Size,
Status: "NEW",
})
}
return result, nil
}
// GetOrderBook gets the order book for a symbol
// Implements GridTrader interface
func (t *HyperliquidTrader) GetOrderBook(symbol string, depth int) (bids, asks [][]float64, err error) {
coin := convertSymbolToHyperliquid(symbol)
l2Book, err := t.exchange.Info().L2Snapshot(t.ctx, coin)
if err != nil {
return nil, nil, fmt.Errorf("failed to get order book: %w", err)
}
if l2Book == nil || len(l2Book.Levels) < 2 {
return nil, nil, fmt.Errorf("invalid order book data")
}
// Parse bids (first level array)
for i, level := range l2Book.Levels[0] {
if i >= depth {
break
}
bids = append(bids, []float64{level.Px, level.Sz})
}
// Parse asks (second level array)
for i, level := range l2Book.Levels[1] {
if i >= depth {
break
}
asks = append(asks, []float64{level.Px, level.Sz})
}
return bids, asks, nil
}

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,167 @@
package hyperliquid
import (
"fmt"
"nofx/logger"
"strconv"
"strings"
)
// GetPositions gets all positions (including xyz dex positions)
func (t *HyperliquidTrader) GetPositions() ([]map[string]interface{}, error) {
// Get account status
accountState, err := t.exchange.Info().UserState(t.ctx, t.walletAddr)
if err != nil {
return nil, fmt.Errorf("failed to get positions: %w", err)
}
var result []map[string]interface{}
// Iterate through all perp positions
for _, assetPos := range accountState.AssetPositions {
position := assetPos.Position
// Position amount (string type)
posAmt, _ := strconv.ParseFloat(position.Szi, 64)
if posAmt == 0 {
continue // Skip positions with zero amount
}
posMap := make(map[string]interface{})
// Normalize symbol format (Hyperliquid uses "BTC", we convert to "BTCUSDT")
symbol := position.Coin + "USDT"
posMap["symbol"] = symbol
// Position amount and direction
if posAmt > 0 {
posMap["side"] = "long"
posMap["positionAmt"] = posAmt
} else {
posMap["side"] = "short"
posMap["positionAmt"] = -posAmt // Convert to positive number
}
// Price information (EntryPx and LiquidationPx are pointer types)
var entryPrice, liquidationPx float64
if position.EntryPx != nil {
entryPrice, _ = strconv.ParseFloat(*position.EntryPx, 64)
}
if position.LiquidationPx != nil {
liquidationPx, _ = strconv.ParseFloat(*position.LiquidationPx, 64)
}
positionValue, _ := strconv.ParseFloat(position.PositionValue, 64)
unrealizedPnl, _ := strconv.ParseFloat(position.UnrealizedPnl, 64)
// Calculate mark price (positionValue / abs(posAmt))
var markPrice float64
if posAmt != 0 {
markPrice = positionValue / absFloat(posAmt)
}
posMap["entryPrice"] = entryPrice
posMap["markPrice"] = markPrice
posMap["unRealizedProfit"] = unrealizedPnl
posMap["leverage"] = float64(position.Leverage.Value)
posMap["liquidationPrice"] = liquidationPx
result = append(result, posMap)
}
// Also get xyz dex positions (stocks, forex, commodities)
_, _, xyzPositions, err := t.getXYZDexBalance()
if err != nil {
// xyz dex query failed - log warning but don't fail
logger.Infof("⚠️ Failed to get xyz dex positions: %v", err)
} else {
for _, pos := range xyzPositions {
posAmt, _ := strconv.ParseFloat(pos.Position.Szi, 64)
if posAmt == 0 {
continue
}
posMap := make(map[string]interface{})
// xyz dex positions - the API returns coin names with xyz: prefix (e.g., "xyz:SILVER")
// Only add prefix if not already present
symbol := pos.Position.Coin
if !strings.HasPrefix(symbol, "xyz:") {
symbol = "xyz:" + symbol
}
posMap["symbol"] = symbol
if posAmt > 0 {
posMap["side"] = "long"
posMap["positionAmt"] = posAmt
} else {
posMap["side"] = "short"
posMap["positionAmt"] = -posAmt
}
// Parse price information
var entryPrice, liquidationPx float64
if pos.Position.EntryPx != nil {
entryPrice, _ = strconv.ParseFloat(*pos.Position.EntryPx, 64)
}
if pos.Position.LiquidationPx != nil {
liquidationPx, _ = strconv.ParseFloat(*pos.Position.LiquidationPx, 64)
}
positionValue, _ := strconv.ParseFloat(pos.Position.PositionValue, 64)
unrealizedPnl, _ := strconv.ParseFloat(pos.Position.UnrealizedPnl, 64)
// Calculate mark price from position value
var markPrice float64
if posAmt != 0 {
markPrice = positionValue / absFloat(posAmt)
}
// Get leverage (default to 1 if not available)
leverage := float64(pos.Position.Leverage.Value)
if leverage == 0 {
leverage = 1.0
}
posMap["entryPrice"] = entryPrice
posMap["markPrice"] = markPrice
posMap["unRealizedProfit"] = unrealizedPnl
posMap["leverage"] = leverage
posMap["liquidationPrice"] = liquidationPx
posMap["isXyzDex"] = true // Mark as xyz dex position
result = append(result, posMap)
}
}
return result, nil
}
// SetMarginMode sets margin mode (set together with SetLeverage)
func (t *HyperliquidTrader) SetMarginMode(symbol string, isCrossMargin bool) error {
// Hyperliquid's margin mode is set in SetLeverage, only record here
t.isCrossMargin = isCrossMargin
marginModeStr := "cross margin"
if !isCrossMargin {
marginModeStr = "isolated margin"
}
logger.Infof(" ✓ %s will use %s mode", symbol, marginModeStr)
return nil
}
// SetLeverage sets leverage
func (t *HyperliquidTrader) SetLeverage(symbol string, leverage int) error {
// Hyperliquid symbol format (remove USDT suffix)
coin := convertSymbolToHyperliquid(symbol)
// Call UpdateLeverage (leverage int, name string, isCross bool)
// Third parameter: true=cross margin mode, false=isolated margin mode
_, err := t.exchange.UpdateLeverage(t.ctx, leverage, coin, t.isCrossMargin)
if err != nil {
return fmt.Errorf("failed to set leverage: %w", err)
}
logger.Infof(" ✓ %s leverage switched to %dx", symbol, leverage)
return nil
}

View File

@@ -0,0 +1,147 @@
package hyperliquid
import (
"bytes"
"encoding/json"
"fmt"
"io"
"net/http"
"nofx/logger"
"strings"
"time"
)
// refreshMetaIfNeeded refreshes meta information when invalid (triggered when Asset ID is 0)
func (t *HyperliquidTrader) refreshMetaIfNeeded(coin string) error {
assetID := t.exchange.Info().NameToAsset(coin)
if assetID != 0 {
return nil // Meta is normal, no refresh needed
}
logger.Infof("⚠️ Asset ID for %s is 0, attempting to refresh Meta information...", coin)
// Refresh Meta information
meta, err := t.exchange.Info().Meta(t.ctx)
if err != nil {
return fmt.Errorf("failed to refresh Meta information: %w", err)
}
// Concurrency safe: Use write lock to protect meta field update
t.metaMutex.Lock()
t.meta = meta
t.metaMutex.Unlock()
logger.Infof("✅ Meta information refreshed, contains %d assets", len(meta.Universe))
// Verify Asset ID after refresh
assetID = t.exchange.Info().NameToAsset(coin)
if assetID == 0 {
return fmt.Errorf("❌ Even after refreshing Meta, Asset ID for %s is still 0. Possible reasons:\n"+
" 1. This coin is not listed on Hyperliquid\n"+
" 2. Coin name is incorrect (should be BTC not BTCUSDT)\n"+
" 3. API connection issue", coin)
}
logger.Infof("✅ Asset ID check passed after refresh: %s -> %d", coin, assetID)
return nil
}
// fetchXyzMeta fetches metadata for xyz dex assets (stocks, forex, commodities)
func (t *HyperliquidTrader) fetchXyzMeta() error {
// Build request for xyz dex meta
reqBody := map[string]string{
"type": "meta",
"dex": "xyz",
}
jsonBody, err := json.Marshal(reqBody)
if err != nil {
return fmt.Errorf("failed to marshal request: %w", err)
}
apiURL := "https://api.hyperliquid.xyz/info"
req, err := http.NewRequestWithContext(t.ctx, "POST", apiURL, bytes.NewBuffer(jsonBody))
if err != nil {
return fmt.Errorf("failed to create request: %w", err)
}
req.Header.Set("Content-Type", "application/json")
client := &http.Client{Timeout: 30 * time.Second}
resp, err := client.Do(req)
if err != nil {
return fmt.Errorf("failed to execute request: %w", err)
}
defer resp.Body.Close()
body, err := io.ReadAll(resp.Body)
if err != nil {
return fmt.Errorf("failed to read response: %w", err)
}
if resp.StatusCode != http.StatusOK {
return fmt.Errorf("xyz dex meta API error (status %d): %s", resp.StatusCode, string(body))
}
var meta xyzDexMeta
if err := json.Unmarshal(body, &meta); err != nil {
return fmt.Errorf("failed to parse response: %w", err)
}
t.xyzMetaMutex.Lock()
t.xyzMeta = &meta
t.xyzMetaMutex.Unlock()
logger.Infof("✅ xyz dex meta fetched, contains %d assets", len(meta.Universe))
return nil
}
// getXyzSzDecimals gets quantity precision for xyz dex asset
func (t *HyperliquidTrader) getXyzSzDecimals(coin string) int {
t.xyzMetaMutex.RLock()
defer t.xyzMetaMutex.RUnlock()
if t.xyzMeta == nil {
logger.Infof("⚠️ xyz meta information is empty, using default precision 2")
return 2 // Default precision for stocks/forex
}
// The meta API returns names with xyz: prefix, so ensure we match correctly
lookupName := coin
if !strings.HasPrefix(lookupName, "xyz:") {
lookupName = "xyz:" + lookupName
}
// Find corresponding asset in xyzMeta.Universe
for _, asset := range t.xyzMeta.Universe {
if asset.Name == lookupName {
return asset.SzDecimals
}
}
logger.Infof("⚠️ Precision information not found for %s, using default precision 2", lookupName)
return 2 // Default precision for stocks/forex
}
// getXyzAssetIndex gets the asset index for an xyz dex asset
func (t *HyperliquidTrader) getXyzAssetIndex(baseCoin string) int {
t.xyzMetaMutex.RLock()
defer t.xyzMetaMutex.RUnlock()
if t.xyzMeta == nil {
return -1
}
// The meta API returns names with xyz: prefix, so ensure we match correctly
lookupName := baseCoin
if !strings.HasPrefix(lookupName, "xyz:") {
lookupName = "xyz:" + lookupName
}
for i, asset := range t.xyzMeta.Universe {
if asset.Name == lookupName {
return i
}
}
return -1
}

View File

@@ -1,648 +0,0 @@
package hyperliquid
import (
"context"
"crypto/ecdsa"
"encoding/json"
"net/http"
"net/http/httptest"
"testing"
"github.com/ethereum/go-ethereum/crypto"
"github.com/sonirico/go-hyperliquid"
"github.com/stretchr/testify/assert"
"nofx/trader/testutil"
"nofx/trader/types"
)
// ============================================================
// Part 1: HyperliquidTestSuite - Inherits base test suite
// ============================================================
// HyperliquidTestSuite Hyperliquid trader test suite
// Inherits TraderTestSuite and adds Hyperliquid-specific mock logic
type HyperliquidTestSuite struct {
*testutil.TraderTestSuite // Embeds base test suite
mockServer *httptest.Server
privateKey *ecdsa.PrivateKey
}
// NewHyperliquidTestSuite Create Hyperliquid test suite
func NewHyperliquidTestSuite(t *testing.T) *HyperliquidTestSuite {
// Create test private key
privateKey, err := crypto.HexToECDSA("0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef")
if err != nil {
t.Fatalf("Failed to create test private key: %v", err)
}
// Create mock HTTP server
mockServer := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
// Return different mock responses based on request path
var respBody interface{}
// Hyperliquid API uses POST requests with JSON body
// We need to distinguish different requests by the "type" field in request body
var reqBody map[string]interface{}
if r.Method == "POST" {
json.NewDecoder(r.Body).Decode(&reqBody)
}
// Try to get type from top level first, then from action object
reqType, _ := reqBody["type"].(string)
if reqType == "" && reqBody["action"] != nil {
if action, ok := reqBody["action"].(map[string]interface{}); ok {
reqType, _ = action["type"].(string)
}
}
switch reqType {
// Mock Meta - Get market metadata
case "meta":
respBody = map[string]interface{}{
"universe": []map[string]interface{}{
{
"name": "BTC",
"szDecimals": 4,
"maxLeverage": 50,
"onlyIsolated": false,
"isDelisted": false,
"marginTableId": 0,
},
{
"name": "ETH",
"szDecimals": 3,
"maxLeverage": 50,
"onlyIsolated": false,
"isDelisted": false,
"marginTableId": 0,
},
},
"marginTables": []interface{}{},
}
// Mock UserState - Get user account state (for GetBalance and GetPositions)
case "clearinghouseState":
user, _ := reqBody["user"].(string)
// Check if querying Agent wallet balance (for security check)
agentAddr := crypto.PubkeyToAddress(privateKey.PublicKey).Hex()
if user == agentAddr {
// Agent wallet balance should be low
respBody = map[string]interface{}{
"crossMarginSummary": map[string]interface{}{
"accountValue": "5.00",
"totalMarginUsed": "0.00",
},
"withdrawable": "5.00",
"assetPositions": []interface{}{},
}
} else {
// Main wallet account state
respBody = map[string]interface{}{
"crossMarginSummary": map[string]interface{}{
"accountValue": "10000.00",
"totalMarginUsed": "2000.00",
},
"withdrawable": "8000.00",
"assetPositions": []map[string]interface{}{
{
"position": map[string]interface{}{
"coin": "BTC",
"szi": "0.5",
"entryPx": "50000.00",
"liquidationPx": "45000.00",
"positionValue": "25000.00",
"unrealizedPnl": "100.50",
"leverage": map[string]interface{}{
"type": "cross",
"value": 10,
},
},
},
},
}
}
// Mock SpotUserState - Get spot account state
case "spotClearinghouseState":
respBody = map[string]interface{}{
"balances": []map[string]interface{}{
{
"coin": "USDC",
"total": "500.00",
},
},
}
// Mock SpotMeta - Get spot market metadata
case "spotMeta":
respBody = map[string]interface{}{
"universe": []map[string]interface{}{},
"tokens": []map[string]interface{}{},
}
// Mock AllMids - Get all market prices
case "allMids":
respBody = map[string]string{
"BTC": "50000.00",
"ETH": "3000.00",
}
// Mock OpenOrders - Get open orders list
case "openOrders":
respBody = []interface{}{}
// Mock Order - Create order (open, close, stop-loss, take-profit)
case "order":
respBody = map[string]interface{}{
"status": "ok",
"response": map[string]interface{}{
"type": "order",
"data": map[string]interface{}{
"statuses": []map[string]interface{}{
{
"filled": map[string]interface{}{
"totalSz": "0.01",
"avgPx": "50000.00",
},
},
},
},
},
}
// Mock UpdateLeverage - Set leverage
case "updateLeverage":
respBody = map[string]interface{}{
"status": "ok",
}
// Mock Cancel - Cancel order
case "cancel":
respBody = map[string]interface{}{
"status": "ok",
}
default:
// Default return success response
respBody = map[string]interface{}{
"status": "ok",
}
}
// Serialize response
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(respBody)
}))
// Create HyperliquidTrader, using mock server URL
walletAddr := "0x9999999999999999999999999999999999999999"
ctx := context.Background()
// Create Exchange client, pointing to mock server
exchange := hyperliquid.NewExchange(
ctx,
privateKey,
mockServer.URL, // Use mock server URL
nil,
"",
walletAddr,
nil,
)
// Create meta (simulate successful fetch)
meta := &hyperliquid.Meta{
Universe: []hyperliquid.AssetInfo{
{Name: "BTC", SzDecimals: 4},
{Name: "ETH", SzDecimals: 3},
},
}
traderInstance := &HyperliquidTrader{
exchange: exchange,
ctx: ctx,
walletAddr: walletAddr,
meta: meta,
isCrossMargin: true,
}
// Create base suite
baseSuite := testutil.NewTraderTestSuite(t, traderInstance)
return &HyperliquidTestSuite{
TraderTestSuite: baseSuite,
mockServer: mockServer,
privateKey: privateKey,
}
}
// Cleanup Clean up resources
func (s *HyperliquidTestSuite) Cleanup() {
if s.mockServer != nil {
s.mockServer.Close()
}
s.TraderTestSuite.Cleanup()
}
// ============================================================
// Part 2: Run common tests using HyperliquidTestSuite
// ============================================================
// TestHyperliquidTrader_InterfaceCompliance Test interface compliance
func TestHyperliquidTrader_InterfaceCompliance(t *testing.T) {
var _ types.Trader = (*HyperliquidTrader)(nil)
}
// TestHyperliquidTrader_CommonInterface Run all common interface tests using test suite
func TestHyperliquidTrader_CommonInterface(t *testing.T) {
// Create test suite
suite := NewHyperliquidTestSuite(t)
defer suite.Cleanup()
// Run all common interface tests
suite.RunAllTests()
}
// ============================================================
// Part 3: Hyperliquid-specific feature unit tests
// ============================================================
// TestNewHyperliquidTrader Test creating Hyperliquid trader
func TestNewHyperliquidTrader(t *testing.T) {
tests := []struct {
name string
privateKeyHex string
walletAddr string
testnet bool
wantError bool
errorContains string
}{
{
name: "Invalid private key format",
privateKeyHex: "invalid_key",
walletAddr: "0x1234567890123456789012345678901234567890",
testnet: true,
wantError: true,
errorContains: "Failed to parse private key",
},
{
name: "Empty wallet address",
privateKeyHex: "0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef",
walletAddr: "",
testnet: true,
wantError: true,
errorContains: "Configuration error",
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
trader, err := NewHyperliquidTrader(tt.privateKeyHex, tt.walletAddr, tt.testnet)
if tt.wantError {
assert.Error(t, err)
if tt.errorContains != "" {
assert.Contains(t, err.Error(), tt.errorContains)
}
assert.Nil(t, trader)
} else {
assert.NoError(t, err)
assert.NotNil(t, trader)
if trader != nil {
assert.Equal(t, tt.walletAddr, trader.walletAddr)
assert.NotNil(t, trader.exchange)
}
}
})
}
}
// TestNewHyperliquidTrader_Success Test successfully creating trader (requires mock HTTP)
func TestNewHyperliquidTrader_Success(t *testing.T) {
// Create test private key
privateKey, _ := crypto.HexToECDSA("0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef")
agentAddr := crypto.PubkeyToAddress(privateKey.PublicKey).Hex()
// Create mock HTTP server
mockServer := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
var reqBody map[string]interface{}
json.NewDecoder(r.Body).Decode(&reqBody)
reqType, _ := reqBody["type"].(string)
var respBody interface{}
switch reqType {
case "meta":
respBody = map[string]interface{}{
"universe": []map[string]interface{}{
{
"name": "BTC",
"szDecimals": 4,
"maxLeverage": 50,
"onlyIsolated": false,
"isDelisted": false,
"marginTableId": 0,
},
},
"marginTables": []interface{}{},
}
case "clearinghouseState":
user, _ := reqBody["user"].(string)
if user == agentAddr {
// Agent wallet low balance
respBody = map[string]interface{}{
"crossMarginSummary": map[string]interface{}{
"accountValue": "5.00",
},
"assetPositions": []interface{}{},
}
} else {
// Main wallet
respBody = map[string]interface{}{
"crossMarginSummary": map[string]interface{}{
"accountValue": "10000.00",
},
"assetPositions": []interface{}{},
}
}
default:
respBody = map[string]interface{}{"status": "ok"}
}
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(respBody)
}))
defer mockServer.Close()
// Note: This test would actually call NewHyperliquidTrader, but will fail
// Because hyperliquid SDK doesn't allow us to inject custom URL in constructor
// So this test is only for verifying parameter handling logic
t.Skip("Skip this test: hyperliquid SDK calls real API during construction, cannot inject mock URL")
}
// ============================================================
// Part 4: Utility function unit tests (Hyperliquid-specific)
// ============================================================
// TestConvertSymbolToHyperliquid Test symbol conversion function
func TestConvertSymbolToHyperliquid(t *testing.T) {
tests := []struct {
name string
symbol string
expected string
}{
{
name: "BTCUSDT conversion",
symbol: "BTCUSDT",
expected: "BTC",
},
{
name: "ETHUSDT conversion",
symbol: "ETHUSDT",
expected: "ETH",
},
{
name: "No USDT suffix",
symbol: "BTC",
expected: "BTC",
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
result := convertSymbolToHyperliquid(tt.symbol)
assert.Equal(t, tt.expected, result)
})
}
}
// TestAbsFloat Test absolute value function
func TestAbsFloat(t *testing.T) {
tests := []struct {
name string
input float64
expected float64
}{
{
name: "Positive number",
input: 10.5,
expected: 10.5,
},
{
name: "Negative number",
input: -10.5,
expected: 10.5,
},
{
name: "Zero",
input: 0,
expected: 0,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
result := absFloat(tt.input)
assert.Equal(t, tt.expected, result)
})
}
}
// TestHyperliquidTrader_RoundToSzDecimals Test quantity precision handling
func TestHyperliquidTrader_RoundToSzDecimals(t *testing.T) {
trader := &HyperliquidTrader{
meta: &hyperliquid.Meta{
Universe: []hyperliquid.AssetInfo{
{Name: "BTC", SzDecimals: 4},
{Name: "ETH", SzDecimals: 3},
},
},
}
tests := []struct {
name string
coin string
quantity float64
expected float64
}{
{
name: "BTC - round to 4 decimals",
coin: "BTC",
quantity: 1.23456789,
expected: 1.2346,
},
{
name: "ETH - round to 3 decimals",
coin: "ETH",
quantity: 10.12345,
expected: 10.123,
},
{
name: "Unknown coin - use default 4 decimals",
coin: "UNKNOWN",
quantity: 1.23456789,
expected: 1.2346,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
result := trader.roundToSzDecimals(tt.coin, tt.quantity)
assert.InDelta(t, tt.expected, result, 0.0001)
})
}
}
// TestHyperliquidTrader_RoundPriceToSigfigs Test price significant figures handling
func TestHyperliquidTrader_RoundPriceToSigfigs(t *testing.T) {
trader := &HyperliquidTrader{}
tests := []struct {
name string
price float64
expected float64
}{
{
name: "BTC price - 5 significant figures",
price: 50123.456789,
expected: 50123.0,
},
{
name: "Decimal price - 5 significant figures",
price: 0.0012345678,
expected: 0.0012346,
},
{
name: "Zero price",
price: 0,
expected: 0,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
result := trader.roundPriceToSigfigs(tt.price)
assert.InDelta(t, tt.expected, result, tt.expected*0.001)
})
}
}
// TestHyperliquidTrader_GetSzDecimals Test getting precision
func TestHyperliquidTrader_GetSzDecimals(t *testing.T) {
tests := []struct {
name string
meta *hyperliquid.Meta
coin string
expected int
}{
{
name: "meta is nil - return default precision",
meta: nil,
coin: "BTC",
expected: 4,
},
{
name: "Found BTC - return correct precision",
meta: &hyperliquid.Meta{
Universe: []hyperliquid.AssetInfo{
{Name: "BTC", SzDecimals: 5},
},
},
coin: "BTC",
expected: 5,
},
{
name: "Coin not found - return default precision",
meta: &hyperliquid.Meta{
Universe: []hyperliquid.AssetInfo{
{Name: "ETH", SzDecimals: 3},
},
},
coin: "BTC",
expected: 4,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
ht := &HyperliquidTrader{meta: tt.meta}
result := ht.getSzDecimals(tt.coin)
assert.Equal(t, tt.expected, result)
})
}
}
// TestHyperliquidTrader_SetMarginMode Test setting margin mode
func TestHyperliquidTrader_SetMarginMode(t *testing.T) {
trader := &HyperliquidTrader{
ctx: context.Background(),
isCrossMargin: true,
}
tests := []struct {
name string
symbol string
isCrossMargin bool
wantError bool
}{
{
name: "Set to cross margin mode",
symbol: "BTCUSDT",
isCrossMargin: true,
wantError: false,
},
{
name: "Set to isolated margin mode",
symbol: "ETHUSDT",
isCrossMargin: false,
wantError: false,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
err := trader.SetMarginMode(tt.symbol, tt.isCrossMargin)
if tt.wantError {
assert.Error(t, err)
} else {
assert.NoError(t, err)
assert.Equal(t, tt.isCrossMargin, trader.isCrossMargin)
}
})
}
}
// TestNewHyperliquidTrader_PrivateKeyProcessing Test private key processing
func TestNewHyperliquidTrader_PrivateKeyProcessing(t *testing.T) {
tests := []struct {
name string
privateKeyHex string
shouldStripOx bool
expectedLength int
}{
{
name: "Private key with 0x prefix",
privateKeyHex: "0x0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef",
shouldStripOx: true,
expectedLength: 64,
},
{
name: "Private key without prefix",
privateKeyHex: "0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef",
shouldStripOx: false,
expectedLength: 64,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
// Test private key prefix handling logic (without actually creating trader)
processed := tt.privateKeyHex
if len(processed) > 2 && (processed[:2] == "0x" || processed[:2] == "0X") {
processed = processed[2:]
}
assert.Equal(t, tt.expectedLength, len(processed))
})
}
}

View File

@@ -1,669 +0,0 @@
package hyperliquid
import (
"bytes"
"context"
"encoding/json"
"fmt"
"io"
"net/http"
"os"
"strings"
"testing"
"time"
)
// testXyzDexAsset is a local copy of testXyzDexAsset for testing
type testXyzDexAsset struct {
Name string `json:"name"`
SzDecimals int `json:"szDecimals"`
MaxLeverage int `json:"maxLeverage"`
}
// testXyzDexMeta is a local copy of xyzDexMeta for testing
type testXyzDexMeta struct {
Universe []testXyzDexAsset `json:"universe"`
}
// TestXyzDexMetaFetch tests fetching xyz dex meta from Hyperliquid API
func TestXyzDexMetaFetch(t *testing.T) {
reqBody := map[string]string{
"type": "meta",
"dex": "xyz",
}
jsonBody, err := json.Marshal(reqBody)
if err != nil {
t.Fatalf("Failed to marshal request: %v", err)
}
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
defer cancel()
req, err := http.NewRequestWithContext(ctx, "POST", "https://api.hyperliquid.xyz/info", bytes.NewBuffer(jsonBody))
if err != nil {
t.Fatalf("Failed to create request: %v", err)
}
req.Header.Set("Content-Type", "application/json")
client := &http.Client{Timeout: 30 * time.Second}
resp, err := client.Do(req)
if err != nil {
t.Fatalf("Failed to execute request: %v", err)
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
t.Fatalf("API returned status %d", resp.StatusCode)
}
body, err := io.ReadAll(resp.Body)
if err != nil {
t.Fatalf("Failed to read response: %v", err)
}
var meta testXyzDexMeta
if err := json.Unmarshal(body, &meta); err != nil {
t.Fatalf("Failed to parse response: %v", err)
}
if len(meta.Universe) == 0 {
t.Fatal("xyz meta universe is empty")
}
t.Logf("✅ xyz dex meta contains %d assets", len(meta.Universe))
// Check that SILVER exists
// HIP-3 perp dex asset index formula: 100000 + perp_dex_index * 10000 + index_in_meta
// xyz dex is at perp_dex_index = 1
found := false
for i, asset := range meta.Universe {
if asset.Name == "xyz:SILVER" {
found = true
assetIndex := 100000 + 1*10000 + i // xyz dex index = 1
t.Logf("✅ Found xyz:SILVER at index %d (asset ID: %d)", i, assetIndex)
t.Logf(" SzDecimals: %d, MaxLeverage: %d", asset.SzDecimals, asset.MaxLeverage)
break
}
}
if !found {
t.Fatal("xyz:SILVER not found in meta")
}
}
// TestXyzDexPriceFetch tests fetching xyz dex prices from Hyperliquid API
func TestXyzDexPriceFetch(t *testing.T) {
reqBody := map[string]string{
"type": "allMids",
"dex": "xyz",
}
jsonBody, err := json.Marshal(reqBody)
if err != nil {
t.Fatalf("Failed to marshal request: %v", err)
}
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
defer cancel()
req, err := http.NewRequestWithContext(ctx, "POST", "https://api.hyperliquid.xyz/info", bytes.NewBuffer(jsonBody))
if err != nil {
t.Fatalf("Failed to create request: %v", err)
}
req.Header.Set("Content-Type", "application/json")
client := &http.Client{Timeout: 30 * time.Second}
resp, err := client.Do(req)
if err != nil {
t.Fatalf("Failed to execute request: %v", err)
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
t.Fatalf("API returned status %d", resp.StatusCode)
}
body, err := io.ReadAll(resp.Body)
if err != nil {
t.Fatalf("Failed to read response: %v", err)
}
var mids map[string]string
if err := json.Unmarshal(body, &mids); err != nil {
t.Fatalf("Failed to parse response: %v", err)
}
// Check that prices have xyz: prefix
silverPrice, ok := mids["xyz:SILVER"]
if !ok {
t.Fatal("xyz:SILVER price not found (key should include xyz: prefix)")
}
t.Logf("✅ xyz:SILVER price: %s", silverPrice)
// Verify a few more assets
testAssets := []string{"xyz:GOLD", "xyz:TSLA", "xyz:NVDA"}
for _, asset := range testAssets {
if price, ok := mids[asset]; ok {
t.Logf("✅ %s price: %s", asset, price)
} else {
t.Logf("⚠️ %s not found in prices", asset)
}
}
}
// TestXyzAssetIndexLookup tests the asset index lookup for xyz dex assets
func TestXyzAssetIndexLookup(t *testing.T) {
// Fetch xyz meta
reqBody := map[string]string{
"type": "meta",
"dex": "xyz",
}
jsonBody, _ := json.Marshal(reqBody)
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
defer cancel()
req, _ := http.NewRequestWithContext(ctx, "POST", "https://api.hyperliquid.xyz/info", bytes.NewBuffer(jsonBody))
req.Header.Set("Content-Type", "application/json")
client := &http.Client{Timeout: 30 * time.Second}
resp, err := client.Do(req)
if err != nil {
t.Fatalf("Failed to fetch meta: %v", err)
}
defer resp.Body.Close()
body, _ := io.ReadAll(resp.Body)
var meta testXyzDexMeta
json.Unmarshal(body, &meta)
// Test lookup with different formats
testCases := []struct {
input string
expected string // expected match in meta
}{
{"SILVER", "xyz:SILVER"},
{"xyz:SILVER", "xyz:SILVER"},
{"GOLD", "xyz:GOLD"},
{"xyz:TSLA", "xyz:TSLA"},
}
for _, tc := range testCases {
lookupName := tc.input
if !strings.HasPrefix(lookupName, "xyz:") {
lookupName = "xyz:" + lookupName
}
found := false
for i, asset := range meta.Universe {
if asset.Name == lookupName {
found = true
assetIndex := 100000 + 1*10000 + i // HIP-3 formula: 100000 + xyz_dex_index(1) * 10000 + meta_index
t.Logf("✅ Lookup '%s' -> found at index %d (asset ID: %d)", tc.input, i, assetIndex)
break
}
}
if !found {
t.Errorf("❌ Lookup '%s' -> NOT FOUND (expected to match %s)", tc.input, tc.expected)
}
}
}
// TestXyzSzDecimalsLookup tests the szDecimals lookup for different xyz assets
func TestXyzSzDecimalsLookup(t *testing.T) {
reqBody := map[string]string{
"type": "meta",
"dex": "xyz",
}
jsonBody, _ := json.Marshal(reqBody)
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
defer cancel()
req, _ := http.NewRequestWithContext(ctx, "POST", "https://api.hyperliquid.xyz/info", bytes.NewBuffer(jsonBody))
req.Header.Set("Content-Type", "application/json")
client := &http.Client{Timeout: 30 * time.Second}
resp, err := client.Do(req)
if err != nil {
t.Fatalf("Failed to fetch meta: %v", err)
}
defer resp.Body.Close()
body, _ := io.ReadAll(resp.Body)
var meta testXyzDexMeta
json.Unmarshal(body, &meta)
// Check szDecimals for various assets
expectedDecimals := map[string]int{
"xyz:SILVER": 2,
"xyz:GOLD": 4,
"xyz:TSLA": 3,
}
for name, expected := range expectedDecimals {
for _, asset := range meta.Universe {
if asset.Name == name {
if asset.SzDecimals == expected {
t.Logf("✅ %s szDecimals: %d (expected %d)", name, asset.SzDecimals, expected)
} else {
t.Logf("⚠️ %s szDecimals: %d (expected %d, may have changed)", name, asset.SzDecimals, expected)
}
break
}
}
}
}
// TestXyzOrderParameters tests order parameter calculation
func TestXyzOrderParameters(t *testing.T) {
// Simulate order parameter calculation
testCases := []struct {
price float64
size float64
szDecimals int
expectedSz float64
}{
{75.33, 1.0, 2, 1.00},
{75.33, 1.234, 2, 1.23},
{75.33, 5.567, 2, 5.57},
{188.15, 0.5, 3, 0.500},
{188.15, 0.1234, 3, 0.123},
}
for _, tc := range testCases {
multiplier := 1.0
for i := 0; i < tc.szDecimals; i++ {
multiplier *= 10.0
}
roundedSize := float64(int(tc.size*multiplier+0.5)) / multiplier
if roundedSize != tc.expectedSz {
t.Errorf("Size rounding failed: input=%v, decimals=%d, got=%v, expected=%v",
tc.size, tc.szDecimals, roundedSize, tc.expectedSz)
} else {
t.Logf("✅ Size rounding: %v (decimals=%d) -> %v", tc.size, tc.szDecimals, roundedSize)
}
}
}
// TestXyzAssetIndexCalculation tests the HIP-3 asset index calculation
// Formula: 100000 + perp_dex_index * 10000 + meta_index
// For xyz dex: perp_dex_index = 1, so asset_index = 110000 + meta_index
func TestXyzAssetIndexCalculation(t *testing.T) {
reqBody := map[string]string{
"type": "meta",
"dex": "xyz",
}
jsonBody, _ := json.Marshal(reqBody)
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
defer cancel()
req, _ := http.NewRequestWithContext(ctx, "POST", "https://api.hyperliquid.xyz/info", bytes.NewBuffer(jsonBody))
req.Header.Set("Content-Type", "application/json")
client := &http.Client{Timeout: 30 * time.Second}
resp, err := client.Do(req)
if err != nil {
t.Fatalf("Failed to fetch meta: %v", err)
}
defer resp.Body.Close()
body, _ := io.ReadAll(resp.Body)
var meta testXyzDexMeta
json.Unmarshal(body, &meta)
// Test asset index calculation for SILVER
// HIP-3 perp dex asset index formula: 100000 + perp_dex_index * 10000 + index_in_meta
// xyz dex is at perp_dex_index = 1
const xyzPerpDexIndex = 1
for i, asset := range meta.Universe {
if asset.Name == "xyz:SILVER" {
assetIndex := 100000 + xyzPerpDexIndex*10000 + i
t.Logf("✅ xyz:SILVER: meta_index=%d, asset_index=%d", i, assetIndex)
if assetIndex < 110000 {
t.Errorf("Asset index should be >= 110000, got %d", assetIndex)
}
break
}
}
// Log first few assets for reference
t.Log("\nFirst 5 xyz assets:")
for i := 0; i < 5 && i < len(meta.Universe); i++ {
asset := meta.Universe[i]
assetIndex := 100000 + xyzPerpDexIndex*10000 + i
t.Logf(" [%d] %s -> asset_index=%d, szDecimals=%d", i, asset.Name, assetIndex, asset.SzDecimals)
}
}
// TestIsXyzDexAsset tests the isXyzDexAsset function
func TestIsXyzDexAsset(t *testing.T) {
testCases := []struct {
symbol string
expected bool
}{
{"xyz:SILVER", true},
{"SILVER", true},
{"silver", true},
{"xyz:GOLD", true},
{"GOLD", true},
{"xyz:TSLA", true},
{"TSLA", true},
{"BTCUSDT", false},
{"BTC", false},
{"ETHUSDT", false},
{"SOLUSDT", false},
{"xyz:BTC", false}, // BTC is not an xyz asset
}
for _, tc := range testCases {
result := isXyzDexAsset(tc.symbol)
if result != tc.expected {
t.Errorf("isXyzDexAsset(%q) = %v, expected %v", tc.symbol, result, tc.expected)
} else {
t.Logf("✅ isXyzDexAsset(%q) = %v", tc.symbol, result)
}
}
}
// TestConvertSymbolToHyperliquidXyz tests symbol conversion for xyz assets
func TestConvertSymbolToHyperliquidXyz(t *testing.T) {
testCases := []struct {
input string
expected string
}{
{"SILVER", "xyz:SILVER"},
{"silver", "xyz:SILVER"},
{"xyz:SILVER", "xyz:SILVER"},
{"GOLD", "xyz:GOLD"},
{"TSLA", "xyz:TSLA"},
{"BTC", "BTC"},
{"BTCUSDT", "BTC"},
{"ETH", "ETH"},
{"ETHUSDT", "ETH"},
}
for _, tc := range testCases {
result := convertSymbolToHyperliquid(tc.input)
if result != tc.expected {
t.Errorf("convertSymbolToHyperliquid(%q) = %q, expected %q", tc.input, result, tc.expected)
} else {
t.Logf("✅ convertSymbolToHyperliquid(%q) = %q", tc.input, result)
}
}
}
// TestXyzDexOrderFlow tests the complete order flow (without actually placing an order)
func TestXyzDexOrderFlow(t *testing.T) {
t.Log("=== Testing xyz Dex Order Flow ===")
// Step 1: Fetch meta
t.Log("\nStep 1: Fetching xyz meta...")
reqBody := map[string]string{"type": "meta", "dex": "xyz"}
jsonBody, _ := json.Marshal(reqBody)
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
defer cancel()
req, _ := http.NewRequestWithContext(ctx, "POST", "https://api.hyperliquid.xyz/info", bytes.NewBuffer(jsonBody))
req.Header.Set("Content-Type", "application/json")
client := &http.Client{Timeout: 30 * time.Second}
resp, err := client.Do(req)
if err != nil {
t.Fatalf("Failed to fetch meta: %v", err)
}
defer resp.Body.Close()
body, _ := io.ReadAll(resp.Body)
var meta testXyzDexMeta
json.Unmarshal(body, &meta)
t.Logf("✅ Fetched %d xyz assets", len(meta.Universe))
// Step 2: Find SILVER
t.Log("\nStep 2: Looking up xyz:SILVER...")
var silverIndex int = -1
var silverAsset *testXyzDexAsset
for i, asset := range meta.Universe {
if asset.Name == "xyz:SILVER" {
silverIndex = i
silverAsset = &meta.Universe[i]
break
}
}
if silverIndex < 0 {
t.Fatal("SILVER not found in xyz meta")
}
t.Logf("✅ Found at index %d", silverIndex)
// Step 3: Fetch price
t.Log("\nStep 3: Fetching price...")
priceReq := map[string]string{"type": "allMids", "dex": "xyz"}
priceBody, _ := json.Marshal(priceReq)
req2, _ := http.NewRequestWithContext(ctx, "POST", "https://api.hyperliquid.xyz/info", bytes.NewBuffer(priceBody))
req2.Header.Set("Content-Type", "application/json")
resp2, _ := client.Do(req2)
body2, _ := io.ReadAll(resp2.Body)
resp2.Body.Close()
var mids map[string]string
json.Unmarshal(body2, &mids)
priceStr := mids["xyz:SILVER"]
var price float64
fmt.Sscanf(priceStr, "%f", &price)
t.Logf("✅ Price: %s", priceStr)
// Step 4: Calculate order parameters
t.Log("\nStep 4: Calculating order parameters...")
orderSize := 1.0
multiplier := 1.0
for i := 0; i < silverAsset.SzDecimals; i++ {
multiplier *= 10.0
}
roundedSize := float64(int(orderSize*multiplier+0.5)) / multiplier
roundedPrice := price * 1.001 // 0.1% slippage
// HIP-3 perp dex asset index formula: 100000 + perp_dex_index * 10000 + index_in_meta
// xyz dex is at perp_dex_index = 1
assetIndex := 100000 + 1*10000 + silverIndex
t.Logf(" Asset Index: %d (110000 + %d)", assetIndex, silverIndex)
t.Logf(" Size: %.4f (szDecimals=%d)", roundedSize, silverAsset.SzDecimals)
t.Logf(" Price: %.4f (with slippage)", roundedPrice)
// Step 5: Summary
t.Log("\n=== Order Flow Test Summary ===")
t.Log("✅ Meta fetch: OK")
t.Log("✅ Asset lookup: OK")
t.Log("✅ Price fetch: OK")
t.Log("✅ Parameter calculation: OK")
t.Logf("\n📋 Order would be placed with:")
t.Logf(" coin: xyz:SILVER")
t.Logf(" assetIndex: %d", assetIndex)
t.Logf(" isBuy: true")
t.Logf(" size: %.4f", roundedSize)
t.Logf(" price: %.4f", roundedPrice)
}
// TestXyzDexLiveOrder tests placing a real order on xyz dex
// This test requires:
// - XYZ_DEX_LIVE_TEST=1 to enable
// - TEST_PRIVATE_KEY - the private key for signing
// - TEST_WALLET_ADDR - the wallet address with funds
func TestXyzDexLiveOrder(t *testing.T) {
// Skip unless explicitly enabled
if os.Getenv("XYZ_DEX_LIVE_TEST") != "1" {
t.Skip("Skipping live order test. Set XYZ_DEX_LIVE_TEST=1 to run")
}
// Get credentials from environment variables
privateKeyHex := os.Getenv("TEST_PRIVATE_KEY")
walletAddr := os.Getenv("TEST_WALLET_ADDR")
if privateKeyHex == "" || walletAddr == "" {
t.Skip("TEST_PRIVATE_KEY and TEST_WALLET_ADDR env vars required")
}
t.Logf("=== Live xyz Dex Order Test ===")
t.Logf("Wallet: %s", walletAddr)
// Create trader instance
trader, err := NewHyperliquidTrader(privateKeyHex, walletAddr, false)
if err != nil {
t.Fatalf("Failed to create trader: %v", err)
}
// Check xyz dex balance first
xyzState, _ := trader.exchange.Info().UserState(trader.ctx, walletAddr, "xyz")
if xyzState != nil && xyzState.CrossMarginSummary.AccountValue == "0.0" {
t.Logf("⚠️ xyz dex account has no funds (balance: %s)", xyzState.CrossMarginSummary.AccountValue)
t.Logf(" To trade xyz dex, you need to transfer funds using perpDexClassTransfer")
t.Logf(" The test will still verify order signing and submission...")
}
// Fetch xyz meta first
if err := trader.fetchXyzMeta(); err != nil {
t.Fatalf("Failed to fetch xyz meta: %v", err)
}
// Get current price for xyz:SILVER
price, err := trader.getXyzMarketPrice("xyz:SILVER")
if err != nil {
t.Fatalf("Failed to get price: %v", err)
}
t.Logf("Current xyz:SILVER price: %.4f", price)
// Place a test order (minimum $10 value = 0.14 SILVER at ~$75)
// With 5% slippage for IOC (market order)
testSize := 0.14 // ~$10.5 at current price
testPrice := price * 1.05 // 5% above market for IOC buy (market order)
t.Logf("Attempting to place order:")
t.Logf(" Symbol: xyz:SILVER")
t.Logf(" Side: BUY")
t.Logf(" Size: %.4f", testSize)
t.Logf(" Price: %.4f", testPrice)
// Place the order using the new direct method
err = trader.placeXyzOrder("xyz:SILVER", true, testSize, testPrice, false)
if err != nil {
t.Logf("⚠️ Order result: %v", err)
// Check if this is an expected error (e.g., insufficient margin, no matching orders for IOC)
if strings.Contains(err.Error(), "insufficient") || strings.Contains(err.Error(), "margin") || strings.Contains(err.Error(), "minimum value") {
t.Logf("This may be expected if the test wallet has no margin in xyz dex")
t.Logf("✅ Order was properly signed and submitted (API validated format/signature)")
} else if strings.Contains(err.Error(), "could not immediately match") {
// IOC order didn't fill - this is actually SUCCESS!
// It means the order was properly signed, submitted, and processed
t.Logf("✅ Order was properly submitted but didn't fill (IOC with no matching orders)")
t.Logf(" This confirms the asset index (%d) and signing are correct!", 110026)
} else if strings.Contains(err.Error(), "Order has invalid price") || strings.Contains(err.Error(), "95% away") {
t.Errorf("FAILED: Order has invalid price - asset index issue")
} else {
t.Errorf("FAILED: Unexpected error: %v", err)
}
} else {
t.Logf("✅ Order placed and filled successfully!")
}
}
// TestXyzDexClosePosition tests closing a position on xyz dex
// This test requires the XYZ_DEX_LIVE_TEST environment variable to be set
func TestXyzDexClosePosition(t *testing.T) {
// Skip unless explicitly enabled
if os.Getenv("XYZ_DEX_LIVE_TEST") != "1" {
t.Skip("Skipping live close position test. Set XYZ_DEX_LIVE_TEST=1 to run")
}
// Get credentials from environment variables
privateKeyHex := os.Getenv("TEST_PRIVATE_KEY")
walletAddr := os.Getenv("TEST_WALLET_ADDR")
if privateKeyHex == "" || walletAddr == "" {
t.Skip("TEST_PRIVATE_KEY and TEST_WALLET_ADDR env vars required")
}
t.Logf("=== Live xyz Dex Close Position Test ===")
t.Logf("Wallet: %s", walletAddr)
// Create trader instance
trader, err := NewHyperliquidTrader(privateKeyHex, walletAddr, false)
if err != nil {
t.Fatalf("Failed to create trader: %v", err)
}
// Check current xyz dex position
xyzState, err := trader.exchange.Info().UserState(trader.ctx, walletAddr, "xyz")
if err != nil {
t.Fatalf("Failed to get xyz state: %v", err)
}
if len(xyzState.AssetPositions) == 0 {
t.Logf("No xyz dex positions to close")
return
}
// Get the position details
pos := xyzState.AssetPositions[0].Position
entryPx := ""
if pos.EntryPx != nil {
entryPx = *pos.EntryPx
}
t.Logf("Current position: %s size=%s entryPx=%s", pos.Coin, pos.Szi, entryPx)
// Fetch xyz meta
if err := trader.fetchXyzMeta(); err != nil {
t.Fatalf("Failed to fetch xyz meta: %v", err)
}
// Get current price
price, err := trader.getXyzMarketPrice(pos.Coin)
if err != nil {
t.Fatalf("Failed to get price: %v", err)
}
t.Logf("Current %s price: %.4f", pos.Coin, price)
// Parse position size
var posSize float64
fmt.Sscanf(pos.Szi, "%f", &posSize)
// Close position: if long (szi > 0), sell; if short (szi < 0), buy
isBuy := posSize < 0
closeSize := posSize
if closeSize < 0 {
closeSize = -closeSize
}
// Use aggressive slippage for close
closePrice := price * 0.95 // 5% below for sell
if isBuy {
closePrice = price * 1.05 // 5% above for buy
}
t.Logf("Closing position:")
t.Logf(" Side: %s", map[bool]string{true: "BUY", false: "SELL"}[isBuy])
t.Logf(" Size: %.4f", closeSize)
t.Logf(" Price: %.4f", closePrice)
// Place close order with reduceOnly=true
err = trader.placeXyzOrder(pos.Coin, isBuy, closeSize, closePrice, true)
if err != nil {
t.Logf("⚠️ Close order result: %v", err)
if strings.Contains(err.Error(), "could not immediately match") {
t.Logf("✅ Close order submitted but didn't fill (IOC)")
} else {
t.Errorf("FAILED: %v", err)
}
} else {
t.Logf("✅ Position closed successfully!")
}
// Verify position is closed
xyzState2, _ := trader.exchange.Info().UserState(trader.ctx, walletAddr, "xyz")
if len(xyzState2.AssetPositions) == 0 {
t.Logf("✅ Position confirmed closed (no positions remaining)")
} else {
newPos := xyzState2.AssetPositions[0].Position
t.Logf("Position after close: %s size=%s", newPos.Coin, newPos.Szi)
}
}

View File

@@ -7,11 +7,9 @@ import (
"encoding/json" "encoding/json"
"fmt" "fmt"
"io" "io"
"math"
"net/http" "net/http"
"net/url" "net/url"
"nofx/logger" "nofx/logger"
"nofx/trader/types"
"strconv" "strconv"
"strings" "strings"
"sync" "sync"
@@ -299,561 +297,6 @@ func (t *IndodaxTrader) clearCache() {
t.cachedPositions = nil t.cachedPositions = nil
} }
// ============================================================
// types.Trader interface implementation
// ============================================================
// GetBalance gets account balance from Indodax
func (t *IndodaxTrader) GetBalance() (map[string]interface{}, error) {
// Check cache
t.cacheMutex.RLock()
if t.cachedBalance != nil && time.Since(t.balanceCacheTime) < t.cacheDuration {
cached := t.cachedBalance
t.cacheMutex.RUnlock()
return cached, nil
}
t.cacheMutex.RUnlock()
params := url.Values{}
params.Set("method", "getInfo")
data, err := t.doPrivateRequest(params)
if err != nil {
return nil, fmt.Errorf("failed to get account info: %w", err)
}
var result struct {
ServerTime int64 `json:"server_time"`
Balance map[string]interface{} `json:"balance"`
BalanceHold map[string]interface{} `json:"balance_hold"`
UserID string `json:"user_id"`
Name string `json:"name"`
Email string `json:"email"`
}
if err := json.Unmarshal(data, &result); err != nil {
return nil, fmt.Errorf("failed to parse balance: %w", err)
}
// Calculate total balance in IDR
idrBalance := parseFloat(result.Balance["idr"])
idrHold := parseFloat(result.BalanceHold["idr"])
totalIDR := idrBalance + idrHold
balance := map[string]interface{}{
"totalWalletBalance": totalIDR,
"availableBalance": idrBalance,
"totalUnrealizedProfit": 0.0,
"totalEquity": totalIDR,
"balance": totalIDR,
"idr_balance": idrBalance,
"idr_hold": idrHold,
"currency": "IDR",
"user_id": result.UserID,
"server_time": result.ServerTime,
}
// Add individual crypto balances
for currency, amount := range result.Balance {
if currency != "idr" {
balance["balance_"+currency] = parseFloat(amount)
}
}
for currency, amount := range result.BalanceHold {
if currency != "idr" {
balance["hold_"+currency] = parseFloat(amount)
}
}
// Update cache
t.cacheMutex.Lock()
t.cachedBalance = balance
t.balanceCacheTime = time.Now()
t.cacheMutex.Unlock()
return balance, nil
}
// GetPositions returns currently held crypto balances as "positions"
// Since Indodax is spot-only, each non-zero crypto balance is treated as a position
func (t *IndodaxTrader) GetPositions() ([]map[string]interface{}, error) {
// Check cache
t.cacheMutex.RLock()
if t.cachedPositions != nil && time.Since(t.positionCacheTime) < t.cacheDuration {
cached := t.cachedPositions
t.cacheMutex.RUnlock()
return cached, nil
}
t.cacheMutex.RUnlock()
params := url.Values{}
params.Set("method", "getInfo")
data, err := t.doPrivateRequest(params)
if err != nil {
return nil, fmt.Errorf("failed to get positions: %w", err)
}
var result struct {
Balance map[string]interface{} `json:"balance"`
BalanceHold map[string]interface{} `json:"balance_hold"`
}
if err := json.Unmarshal(data, &result); err != nil {
return nil, fmt.Errorf("failed to parse positions: %w", err)
}
var positions []map[string]interface{}
for currency, amountRaw := range result.Balance {
if currency == "idr" {
continue
}
amount := parseFloat(amountRaw)
holdAmount := parseFloat(result.BalanceHold[currency])
totalAmount := amount + holdAmount
if totalAmount <= 0 {
continue
}
// Get market price for this coin
markPrice, _ := t.GetMarketPrice(strings.ToUpper(currency) + "IDR")
// Calculate position value in IDR
notionalValue := totalAmount * markPrice
position := map[string]interface{}{
"symbol": strings.ToUpper(currency) + "IDR",
"side": "LONG",
"positionAmt": totalAmount,
"entryPrice": markPrice, // Spot doesn't track entry price
"markPrice": markPrice,
"unRealizedProfit": 0.0, // Spot doesn't track unrealized PnL
"leverage": 1.0,
"mgnMode": "spot",
"notionalValue": notionalValue,
"currency": currency,
"available": amount,
"hold": holdAmount,
}
positions = append(positions, position)
}
// Update cache
t.cacheMutex.Lock()
t.cachedPositions = positions
t.positionCacheTime = time.Now()
t.cacheMutex.Unlock()
return positions, nil
}
// OpenLong opens a spot buy order
func (t *IndodaxTrader) OpenLong(symbol string, quantity float64, leverage int) (map[string]interface{}, error) {
t.clearCache()
pair := t.convertSymbol(symbol)
coin := t.getCoinFromSymbol(symbol)
// Get market price to calculate IDR amount
price, err := t.GetMarketPrice(symbol)
if err != nil {
return nil, fmt.Errorf("failed to get market price: %w", err)
}
params := url.Values{}
params.Set("method", "trade")
params.Set("pair", pair)
params.Set("type", "buy")
params.Set("price", strconv.FormatFloat(price, 'f', 0, 64))
params.Set(coin, strconv.FormatFloat(quantity, 'f', 8, 64))
params.Set("order_type", "limit")
data, err := t.doPrivateRequest(params)
if err != nil {
return nil, fmt.Errorf("failed to place buy order: %w", err)
}
var result map[string]interface{}
if err := json.Unmarshal(data, &result); err != nil {
return nil, fmt.Errorf("failed to parse trade response: %w", err)
}
logger.Infof("[Indodax] Buy order placed: %s qty=%.8f price=%.0f", symbol, quantity, price)
return map[string]interface{}{
"orderId": result["order_id"],
"symbol": symbol,
"side": "BUY",
"price": price,
"qty": quantity,
"status": "NEW",
}, nil
}
// OpenShort is not supported on Indodax (spot-only exchange)
func (t *IndodaxTrader) OpenShort(symbol string, quantity float64, leverage int) (map[string]interface{}, error) {
return nil, fmt.Errorf("short selling is not supported on Indodax (spot-only exchange)")
}
// CloseLong closes a spot position by selling
func (t *IndodaxTrader) CloseLong(symbol string, quantity float64) (map[string]interface{}, error) {
t.clearCache()
pair := t.convertSymbol(symbol)
coin := t.getCoinFromSymbol(symbol)
// If quantity is 0, sell all available balance
if quantity <= 0 {
balance, err := t.GetBalance()
if err != nil {
return nil, fmt.Errorf("failed to get balance for close all: %w", err)
}
available := parseFloat(balance["balance_"+coin])
if available <= 0 {
return nil, fmt.Errorf("no %s balance to sell", coin)
}
quantity = available
}
// Get market price
price, err := t.GetMarketPrice(symbol)
if err != nil {
return nil, fmt.Errorf("failed to get market price: %w", err)
}
params := url.Values{}
params.Set("method", "trade")
params.Set("pair", pair)
params.Set("type", "sell")
params.Set("price", strconv.FormatFloat(price, 'f', 0, 64))
params.Set(coin, strconv.FormatFloat(quantity, 'f', 8, 64))
params.Set("order_type", "limit")
data, err := t.doPrivateRequest(params)
if err != nil {
return nil, fmt.Errorf("failed to place sell order: %w", err)
}
var result map[string]interface{}
if err := json.Unmarshal(data, &result); err != nil {
return nil, fmt.Errorf("failed to parse trade response: %w", err)
}
logger.Infof("[Indodax] Sell order placed: %s qty=%.8f price=%.0f", symbol, quantity, price)
return map[string]interface{}{
"orderId": result["order_id"],
"symbol": symbol,
"side": "SELL",
"price": price,
"qty": quantity,
"status": "NEW",
}, nil
}
// CloseShort is not supported on Indodax (spot-only exchange)
func (t *IndodaxTrader) CloseShort(symbol string, quantity float64) (map[string]interface{}, error) {
return nil, fmt.Errorf("short selling is not supported on Indodax (spot-only exchange)")
}
// SetLeverage is a no-op for Indodax (spot-only, no leverage)
func (t *IndodaxTrader) SetLeverage(symbol string, leverage int) error {
logger.Infof("[Indodax] SetLeverage ignored (spot-only exchange, no leverage support)")
return nil
}
// SetMarginMode is a no-op for Indodax (spot-only, no margin)
func (t *IndodaxTrader) SetMarginMode(symbol string, isCrossMargin bool) error {
logger.Infof("[Indodax] SetMarginMode ignored (spot-only exchange, no margin support)")
return nil
}
// GetMarketPrice gets the current market price for a symbol
func (t *IndodaxTrader) GetMarketPrice(symbol string) (float64, error) {
pairID := strings.ToLower(strings.ReplaceAll(t.convertSymbol(symbol), "_", ""))
data, err := t.doPublicRequest("/ticker/" + pairID)
if err != nil {
return 0, fmt.Errorf("failed to get ticker: %w", err)
}
var tickerResp IndodaxTickerResponse
if err := json.Unmarshal(data, &tickerResp); err != nil {
return 0, fmt.Errorf("failed to parse ticker: %w", err)
}
price, err := strconv.ParseFloat(tickerResp.Ticker.Last, 64)
if err != nil {
return 0, fmt.Errorf("failed to parse price '%s': %w", tickerResp.Ticker.Last, err)
}
return price, nil
}
// SetStopLoss is not supported on Indodax (spot-only exchange)
func (t *IndodaxTrader) SetStopLoss(symbol string, positionSide string, quantity, stopPrice float64) error {
return fmt.Errorf("stop-loss orders are not supported on Indodax (spot-only exchange)")
}
// SetTakeProfit is not supported on Indodax (spot-only exchange)
func (t *IndodaxTrader) SetTakeProfit(symbol string, positionSide string, quantity, takeProfitPrice float64) error {
return fmt.Errorf("take-profit orders are not supported on Indodax (spot-only exchange)")
}
// CancelStopLossOrders is a no-op for Indodax
func (t *IndodaxTrader) CancelStopLossOrders(symbol string) error {
return nil
}
// CancelTakeProfitOrders is a no-op for Indodax
func (t *IndodaxTrader) CancelTakeProfitOrders(symbol string) error {
return nil
}
// CancelAllOrders cancels all open orders for a given symbol
func (t *IndodaxTrader) CancelAllOrders(symbol string) error {
t.clearCache()
pair := t.convertSymbol(symbol)
// First get open orders
params := url.Values{}
params.Set("method", "openOrders")
params.Set("pair", pair)
data, err := t.doPrivateRequest(params)
if err != nil {
return fmt.Errorf("failed to get open orders: %w", err)
}
var result struct {
Orders []struct {
OrderID json.Number `json:"order_id"`
Type string `json:"type"`
OrderType string `json:"order_type"`
} `json:"orders"`
}
if err := json.Unmarshal(data, &result); err != nil {
return fmt.Errorf("failed to parse open orders: %w", err)
}
// Cancel each order
for _, order := range result.Orders {
cancelParams := url.Values{}
cancelParams.Set("method", "cancelOrder")
cancelParams.Set("pair", pair)
cancelParams.Set("order_id", order.OrderID.String())
cancelParams.Set("type", order.Type)
if _, err := t.doPrivateRequest(cancelParams); err != nil {
logger.Warnf("[Indodax] Failed to cancel order %s: %v", order.OrderID, err)
} else {
logger.Infof("[Indodax] Cancelled order: %s", order.OrderID)
}
}
return nil
}
// CancelStopOrders is a no-op for Indodax (no stop orders)
func (t *IndodaxTrader) CancelStopOrders(symbol string) error {
return nil
}
// FormatQuantity formats quantity to correct precision for Indodax
func (t *IndodaxTrader) FormatQuantity(symbol string, quantity float64) (string, error) {
pair, err := t.getPair(symbol)
if err != nil {
// Default: 8 decimal places
return strconv.FormatFloat(quantity, 'f', 8, 64), nil
}
precision := pair.PriceRound
if precision <= 0 {
precision = 8
}
// Round down to avoid exceeding balance
factor := math.Pow(10, float64(precision))
rounded := math.Floor(quantity*factor) / factor
return strconv.FormatFloat(rounded, 'f', precision, 64), nil
}
// GetOrderStatus gets the status of a specific order
func (t *IndodaxTrader) GetOrderStatus(symbol string, orderID string) (map[string]interface{}, error) {
pair := t.convertSymbol(symbol)
params := url.Values{}
params.Set("method", "getOrder")
params.Set("pair", pair)
params.Set("order_id", orderID)
data, err := t.doPrivateRequest(params)
if err != nil {
return nil, fmt.Errorf("failed to get order status: %w", err)
}
var result struct {
Order struct {
OrderID string `json:"order_id"`
Price string `json:"price"`
Type string `json:"type"`
Status string `json:"status"`
SubmitTime string `json:"submit_time"`
FinishTime string `json:"finish_time"`
ClientOrderID string `json:"client_order_id"`
} `json:"order"`
}
if err := json.Unmarshal(data, &result); err != nil {
return nil, fmt.Errorf("failed to parse order: %w", err)
}
// Map Indodax status to standard status
status := "NEW"
switch result.Order.Status {
case "filled":
status = "FILLED"
case "cancelled":
status = "CANCELED"
case "open":
status = "NEW"
}
price, _ := strconv.ParseFloat(result.Order.Price, 64)
return map[string]interface{}{
"status": status,
"avgPrice": price,
"executedQty": 0.0, // Indodax doesn't return executed qty in getOrder
"commission": 0.0,
"orderId": result.Order.OrderID,
}, nil
}
// GetClosedPnL gets closed position PnL records (trade history)
func (t *IndodaxTrader) GetClosedPnL(startTime time.Time, limit int) ([]types.ClosedPnLRecord, error) {
// Indodax trade history is limited to 7 days range
params := url.Values{}
params.Set("method", "tradeHistory")
params.Set("pair", "btc_idr") // Default pair; Indodax requires a pair
if limit > 0 {
params.Set("count", strconv.Itoa(limit))
}
if !startTime.IsZero() {
params.Set("since", strconv.FormatInt(startTime.Unix(), 10))
}
data, err := t.doPrivateRequest(params)
if err != nil {
return nil, fmt.Errorf("failed to get trade history: %w", err)
}
var result struct {
Trades []struct {
TradeID string `json:"trade_id"`
OrderID string `json:"order_id"`
Type string `json:"type"`
Price string `json:"price"`
Fee string `json:"fee"`
TradeTime string `json:"trade_time"`
ClientOrderID string `json:"client_order_id"`
} `json:"trades"`
}
if err := json.Unmarshal(data, &result); err != nil {
// Trade history might return empty, that's fine
return nil, nil
}
var records []types.ClosedPnLRecord
for _, trade := range result.Trades {
price, _ := strconv.ParseFloat(trade.Price, 64)
fee, _ := strconv.ParseFloat(trade.Fee, 64)
tradeTime, _ := strconv.ParseInt(trade.TradeTime, 10, 64)
side := "long"
if trade.Type == "sell" {
side = "long" // Selling from a spot position is closing long
}
records = append(records, types.ClosedPnLRecord{
Symbol: "BTCIDR",
Side: side,
ExitPrice: price,
Fee: fee,
ExitTime: time.Unix(tradeTime, 0),
OrderID: trade.OrderID,
CloseType: "manual",
})
}
return records, nil
}
// GetOpenOrders gets open/pending orders
func (t *IndodaxTrader) GetOpenOrders(symbol string) ([]types.OpenOrder, error) {
pair := t.convertSymbol(symbol)
params := url.Values{}
params.Set("method", "openOrders")
if pair != "" {
params.Set("pair", pair)
}
data, err := t.doPrivateRequest(params)
if err != nil {
return nil, fmt.Errorf("failed to get open orders: %w", err)
}
var result struct {
Orders []struct {
OrderID json.Number `json:"order_id"`
ClientOrderID string `json:"client_order_id"`
SubmitTime string `json:"submit_time"`
Price string `json:"price"`
Type string `json:"type"`
OrderType string `json:"order_type"`
} `json:"orders"`
}
if err := json.Unmarshal(data, &result); err != nil {
return nil, fmt.Errorf("failed to parse open orders: %w", err)
}
var orders []types.OpenOrder
for _, order := range result.Orders {
price, _ := strconv.ParseFloat(order.Price, 64)
side := "BUY"
if order.Type == "sell" {
side = "SELL"
}
orders = append(orders, types.OpenOrder{
OrderID: order.OrderID.String(),
Symbol: t.convertSymbolBack(pair),
Side: side,
PositionSide: "LONG",
Type: "LIMIT",
Price: price,
Status: "NEW",
})
}
return orders, nil
}
// ============================================================
// Helper functions
// ============================================================
// parseFloat safely parses a float from interface{} // parseFloat safely parses a float from interface{}
func parseFloat(v interface{}) float64 { func parseFloat(v interface{}) float64 {
if v == nil { if v == nil {

View File

@@ -0,0 +1,221 @@
package indodax
import (
"encoding/json"
"fmt"
"net/url"
"nofx/logger"
"nofx/trader/types"
"strconv"
"strings"
"time"
)
// GetBalance gets account balance from Indodax
func (t *IndodaxTrader) GetBalance() (map[string]interface{}, error) {
// Check cache
t.cacheMutex.RLock()
if t.cachedBalance != nil && time.Since(t.balanceCacheTime) < t.cacheDuration {
cached := t.cachedBalance
t.cacheMutex.RUnlock()
return cached, nil
}
t.cacheMutex.RUnlock()
params := url.Values{}
params.Set("method", "getInfo")
data, err := t.doPrivateRequest(params)
if err != nil {
return nil, fmt.Errorf("failed to get account info: %w", err)
}
var result struct {
ServerTime int64 `json:"server_time"`
Balance map[string]interface{} `json:"balance"`
BalanceHold map[string]interface{} `json:"balance_hold"`
UserID string `json:"user_id"`
Name string `json:"name"`
Email string `json:"email"`
}
if err := json.Unmarshal(data, &result); err != nil {
return nil, fmt.Errorf("failed to parse balance: %w", err)
}
// Calculate total balance in IDR
idrBalance := parseFloat(result.Balance["idr"])
idrHold := parseFloat(result.BalanceHold["idr"])
totalIDR := idrBalance + idrHold
balance := map[string]interface{}{
"totalWalletBalance": totalIDR,
"availableBalance": idrBalance,
"totalUnrealizedProfit": 0.0,
"totalEquity": totalIDR,
"balance": totalIDR,
"idr_balance": idrBalance,
"idr_hold": idrHold,
"currency": "IDR",
"user_id": result.UserID,
"server_time": result.ServerTime,
}
// Add individual crypto balances
for currency, amount := range result.Balance {
if currency != "idr" {
balance["balance_"+currency] = parseFloat(amount)
}
}
for currency, amount := range result.BalanceHold {
if currency != "idr" {
balance["hold_"+currency] = parseFloat(amount)
}
}
// Update cache
t.cacheMutex.Lock()
t.cachedBalance = balance
t.balanceCacheTime = time.Now()
t.cacheMutex.Unlock()
return balance, nil
}
// GetPositions returns currently held crypto balances as "positions"
// Since Indodax is spot-only, each non-zero crypto balance is treated as a position
func (t *IndodaxTrader) GetPositions() ([]map[string]interface{}, error) {
// Check cache
t.cacheMutex.RLock()
if t.cachedPositions != nil && time.Since(t.positionCacheTime) < t.cacheDuration {
cached := t.cachedPositions
t.cacheMutex.RUnlock()
return cached, nil
}
t.cacheMutex.RUnlock()
params := url.Values{}
params.Set("method", "getInfo")
data, err := t.doPrivateRequest(params)
if err != nil {
return nil, fmt.Errorf("failed to get positions: %w", err)
}
var result struct {
Balance map[string]interface{} `json:"balance"`
BalanceHold map[string]interface{} `json:"balance_hold"`
}
if err := json.Unmarshal(data, &result); err != nil {
return nil, fmt.Errorf("failed to parse positions: %w", err)
}
var positions []map[string]interface{}
for currency, amountRaw := range result.Balance {
if currency == "idr" {
continue
}
amount := parseFloat(amountRaw)
holdAmount := parseFloat(result.BalanceHold[currency])
totalAmount := amount + holdAmount
if totalAmount <= 0 {
continue
}
// Get market price for this coin
markPrice, _ := t.GetMarketPrice(strings.ToUpper(currency) + "IDR")
// Calculate position value in IDR
notionalValue := totalAmount * markPrice
position := map[string]interface{}{
"symbol": strings.ToUpper(currency) + "IDR",
"side": "LONG",
"positionAmt": totalAmount,
"entryPrice": markPrice, // Spot doesn't track entry price
"markPrice": markPrice,
"unRealizedProfit": 0.0, // Spot doesn't track unrealized PnL
"leverage": 1.0,
"mgnMode": "spot",
"notionalValue": notionalValue,
"currency": currency,
"available": amount,
"hold": holdAmount,
}
positions = append(positions, position)
}
// Update cache
t.cacheMutex.Lock()
t.cachedPositions = positions
t.positionCacheTime = time.Now()
t.cacheMutex.Unlock()
return positions, nil
}
// GetClosedPnL gets closed position PnL records (trade history)
func (t *IndodaxTrader) GetClosedPnL(startTime time.Time, limit int) ([]types.ClosedPnLRecord, error) {
// Indodax trade history is limited to 7 days range
params := url.Values{}
params.Set("method", "tradeHistory")
params.Set("pair", "btc_idr") // Default pair; Indodax requires a pair
if limit > 0 {
params.Set("count", strconv.Itoa(limit))
}
if !startTime.IsZero() {
params.Set("since", strconv.FormatInt(startTime.Unix(), 10))
}
data, err := t.doPrivateRequest(params)
if err != nil {
return nil, fmt.Errorf("failed to get trade history: %w", err)
}
var result struct {
Trades []struct {
TradeID string `json:"trade_id"`
OrderID string `json:"order_id"`
Type string `json:"type"`
Price string `json:"price"`
Fee string `json:"fee"`
TradeTime string `json:"trade_time"`
ClientOrderID string `json:"client_order_id"`
} `json:"trades"`
}
if err := json.Unmarshal(data, &result); err != nil {
// Trade history might return empty, that's fine
logger.Infof("[Indodax] Trade history parse note: %v", err)
return nil, nil
}
var records []types.ClosedPnLRecord
for _, trade := range result.Trades {
price, _ := strconv.ParseFloat(trade.Price, 64)
fee, _ := strconv.ParseFloat(trade.Fee, 64)
tradeTime, _ := strconv.ParseInt(trade.TradeTime, 10, 64)
side := "long"
if trade.Type == "sell" {
side = "long" // Selling from a spot position is closing long
}
records = append(records, types.ClosedPnLRecord{
Symbol: "BTCIDR",
Side: side,
ExitPrice: price,
Fee: fee,
ExitTime: time.Unix(tradeTime, 0),
OrderID: trade.OrderID,
CloseType: "manual",
})
}
return records, nil
}

View File

@@ -0,0 +1,351 @@
package indodax
import (
"encoding/json"
"fmt"
"math"
"net/url"
"nofx/logger"
"nofx/trader/types"
"strconv"
"strings"
)
// OpenLong opens a spot buy order
func (t *IndodaxTrader) OpenLong(symbol string, quantity float64, leverage int) (map[string]interface{}, error) {
t.clearCache()
pair := t.convertSymbol(symbol)
coin := t.getCoinFromSymbol(symbol)
// Get market price to calculate IDR amount
price, err := t.GetMarketPrice(symbol)
if err != nil {
return nil, fmt.Errorf("failed to get market price: %w", err)
}
params := url.Values{}
params.Set("method", "trade")
params.Set("pair", pair)
params.Set("type", "buy")
params.Set("price", strconv.FormatFloat(price, 'f', 0, 64))
params.Set(coin, strconv.FormatFloat(quantity, 'f', 8, 64))
params.Set("order_type", "limit")
data, err := t.doPrivateRequest(params)
if err != nil {
return nil, fmt.Errorf("failed to place buy order: %w", err)
}
var result map[string]interface{}
if err := json.Unmarshal(data, &result); err != nil {
return nil, fmt.Errorf("failed to parse trade response: %w", err)
}
logger.Infof("[Indodax] Buy order placed: %s qty=%.8f price=%.0f", symbol, quantity, price)
return map[string]interface{}{
"orderId": result["order_id"],
"symbol": symbol,
"side": "BUY",
"price": price,
"qty": quantity,
"status": "NEW",
}, nil
}
// OpenShort is not supported on Indodax (spot-only exchange)
func (t *IndodaxTrader) OpenShort(symbol string, quantity float64, leverage int) (map[string]interface{}, error) {
return nil, fmt.Errorf("short selling is not supported on Indodax (spot-only exchange)")
}
// CloseLong closes a spot position by selling
func (t *IndodaxTrader) CloseLong(symbol string, quantity float64) (map[string]interface{}, error) {
t.clearCache()
pair := t.convertSymbol(symbol)
coin := t.getCoinFromSymbol(symbol)
// If quantity is 0, sell all available balance
if quantity <= 0 {
balance, err := t.GetBalance()
if err != nil {
return nil, fmt.Errorf("failed to get balance for close all: %w", err)
}
available := parseFloat(balance["balance_"+coin])
if available <= 0 {
return nil, fmt.Errorf("no %s balance to sell", coin)
}
quantity = available
}
// Get market price
price, err := t.GetMarketPrice(symbol)
if err != nil {
return nil, fmt.Errorf("failed to get market price: %w", err)
}
params := url.Values{}
params.Set("method", "trade")
params.Set("pair", pair)
params.Set("type", "sell")
params.Set("price", strconv.FormatFloat(price, 'f', 0, 64))
params.Set(coin, strconv.FormatFloat(quantity, 'f', 8, 64))
params.Set("order_type", "limit")
data, err := t.doPrivateRequest(params)
if err != nil {
return nil, fmt.Errorf("failed to place sell order: %w", err)
}
var result map[string]interface{}
if err := json.Unmarshal(data, &result); err != nil {
return nil, fmt.Errorf("failed to parse trade response: %w", err)
}
logger.Infof("[Indodax] Sell order placed: %s qty=%.8f price=%.0f", symbol, quantity, price)
return map[string]interface{}{
"orderId": result["order_id"],
"symbol": symbol,
"side": "SELL",
"price": price,
"qty": quantity,
"status": "NEW",
}, nil
}
// CloseShort is not supported on Indodax (spot-only exchange)
func (t *IndodaxTrader) CloseShort(symbol string, quantity float64) (map[string]interface{}, error) {
return nil, fmt.Errorf("short selling is not supported on Indodax (spot-only exchange)")
}
// SetLeverage is a no-op for Indodax (spot-only, no leverage)
func (t *IndodaxTrader) SetLeverage(symbol string, leverage int) error {
logger.Infof("[Indodax] SetLeverage ignored (spot-only exchange, no leverage support)")
return nil
}
// SetMarginMode is a no-op for Indodax (spot-only, no margin)
func (t *IndodaxTrader) SetMarginMode(symbol string, isCrossMargin bool) error {
logger.Infof("[Indodax] SetMarginMode ignored (spot-only exchange, no margin support)")
return nil
}
// GetMarketPrice gets the current market price for a symbol
func (t *IndodaxTrader) GetMarketPrice(symbol string) (float64, error) {
pairID := strings.ToLower(strings.ReplaceAll(t.convertSymbol(symbol), "_", ""))
data, err := t.doPublicRequest("/ticker/" + pairID)
if err != nil {
return 0, fmt.Errorf("failed to get ticker: %w", err)
}
var tickerResp IndodaxTickerResponse
if err := json.Unmarshal(data, &tickerResp); err != nil {
return 0, fmt.Errorf("failed to parse ticker: %w", err)
}
price, err := strconv.ParseFloat(tickerResp.Ticker.Last, 64)
if err != nil {
return 0, fmt.Errorf("failed to parse price '%s': %w", tickerResp.Ticker.Last, err)
}
return price, nil
}
// SetStopLoss is not supported on Indodax (spot-only exchange)
func (t *IndodaxTrader) SetStopLoss(symbol string, positionSide string, quantity, stopPrice float64) error {
return fmt.Errorf("stop-loss orders are not supported on Indodax (spot-only exchange)")
}
// SetTakeProfit is not supported on Indodax (spot-only exchange)
func (t *IndodaxTrader) SetTakeProfit(symbol string, positionSide string, quantity, takeProfitPrice float64) error {
return fmt.Errorf("take-profit orders are not supported on Indodax (spot-only exchange)")
}
// CancelStopLossOrders is a no-op for Indodax
func (t *IndodaxTrader) CancelStopLossOrders(symbol string) error {
return nil
}
// CancelTakeProfitOrders is a no-op for Indodax
func (t *IndodaxTrader) CancelTakeProfitOrders(symbol string) error {
return nil
}
// CancelAllOrders cancels all open orders for a given symbol
func (t *IndodaxTrader) CancelAllOrders(symbol string) error {
t.clearCache()
pair := t.convertSymbol(symbol)
// First get open orders
params := url.Values{}
params.Set("method", "openOrders")
params.Set("pair", pair)
data, err := t.doPrivateRequest(params)
if err != nil {
return fmt.Errorf("failed to get open orders: %w", err)
}
var result struct {
Orders []struct {
OrderID json.Number `json:"order_id"`
Type string `json:"type"`
OrderType string `json:"order_type"`
} `json:"orders"`
}
if err := json.Unmarshal(data, &result); err != nil {
return fmt.Errorf("failed to parse open orders: %w", err)
}
// Cancel each order
for _, order := range result.Orders {
cancelParams := url.Values{}
cancelParams.Set("method", "cancelOrder")
cancelParams.Set("pair", pair)
cancelParams.Set("order_id", order.OrderID.String())
cancelParams.Set("type", order.Type)
if _, err := t.doPrivateRequest(cancelParams); err != nil {
logger.Warnf("[Indodax] Failed to cancel order %s: %v", order.OrderID, err)
} else {
logger.Infof("[Indodax] Cancelled order: %s", order.OrderID)
}
}
return nil
}
// CancelStopOrders is a no-op for Indodax (no stop orders)
func (t *IndodaxTrader) CancelStopOrders(symbol string) error {
return nil
}
// FormatQuantity formats quantity to correct precision for Indodax
func (t *IndodaxTrader) FormatQuantity(symbol string, quantity float64) (string, error) {
pair, err := t.getPair(symbol)
if err != nil {
// Default: 8 decimal places
return strconv.FormatFloat(quantity, 'f', 8, 64), nil
}
precision := pair.PriceRound
if precision <= 0 {
precision = 8
}
// Round down to avoid exceeding balance
factor := math.Pow(10, float64(precision))
rounded := math.Floor(quantity*factor) / factor
return strconv.FormatFloat(rounded, 'f', precision, 64), nil
}
// GetOrderStatus gets the status of a specific order
func (t *IndodaxTrader) GetOrderStatus(symbol string, orderID string) (map[string]interface{}, error) {
pair := t.convertSymbol(symbol)
params := url.Values{}
params.Set("method", "getOrder")
params.Set("pair", pair)
params.Set("order_id", orderID)
data, err := t.doPrivateRequest(params)
if err != nil {
return nil, fmt.Errorf("failed to get order status: %w", err)
}
var result struct {
Order struct {
OrderID string `json:"order_id"`
Price string `json:"price"`
Type string `json:"type"`
Status string `json:"status"`
SubmitTime string `json:"submit_time"`
FinishTime string `json:"finish_time"`
ClientOrderID string `json:"client_order_id"`
} `json:"order"`
}
if err := json.Unmarshal(data, &result); err != nil {
return nil, fmt.Errorf("failed to parse order: %w", err)
}
// Map Indodax status to standard status
status := "NEW"
switch result.Order.Status {
case "filled":
status = "FILLED"
case "cancelled":
status = "CANCELED"
case "open":
status = "NEW"
}
price, _ := strconv.ParseFloat(result.Order.Price, 64)
return map[string]interface{}{
"status": status,
"avgPrice": price,
"executedQty": 0.0, // Indodax doesn't return executed qty in getOrder
"commission": 0.0,
"orderId": result.Order.OrderID,
}, nil
}
// GetOpenOrders gets open/pending orders
func (t *IndodaxTrader) GetOpenOrders(symbol string) ([]types.OpenOrder, error) {
pair := t.convertSymbol(symbol)
params := url.Values{}
params.Set("method", "openOrders")
if pair != "" {
params.Set("pair", pair)
}
data, err := t.doPrivateRequest(params)
if err != nil {
return nil, fmt.Errorf("failed to get open orders: %w", err)
}
var result struct {
Orders []struct {
OrderID json.Number `json:"order_id"`
ClientOrderID string `json:"client_order_id"`
SubmitTime string `json:"submit_time"`
Price string `json:"price"`
Type string `json:"type"`
OrderType string `json:"order_type"`
} `json:"orders"`
}
if err := json.Unmarshal(data, &result); err != nil {
return nil, fmt.Errorf("failed to parse open orders: %w", err)
}
var orders []types.OpenOrder
for _, order := range result.Orders {
price, _ := strconv.ParseFloat(order.Price, 64)
side := "BUY"
if order.Type == "sell" {
side = "SELL"
}
orders = append(orders, types.OpenOrder{
OrderID: order.OrderID.String(),
Symbol: t.convertSymbolBack(pair),
Side: side,
PositionSide: "LONG",
Type: "LIMIT",
Price: price,
Status: "NEW",
})
}
return orders, nil
}

View File

@@ -11,7 +11,6 @@ import (
"math" "math"
"net/http" "net/http"
"nofx/logger" "nofx/logger"
"nofx/trader/types"
"strconv" "strconv"
"strings" "strings"
"sync" "sync"
@@ -281,164 +280,6 @@ func (t *KuCoinTrader) convertSymbolBack(kcSymbol string) string {
return sym return sym
} }
// GetBalance gets account balance
func (t *KuCoinTrader) GetBalance() (map[string]interface{}, error) {
// Check cache
t.balanceCacheMutex.RLock()
if t.cachedBalance != nil && time.Since(t.balanceCacheTime) < t.cacheDuration {
t.balanceCacheMutex.RUnlock()
return t.cachedBalance, nil
}
t.balanceCacheMutex.RUnlock()
data, err := t.doRequest("GET", kucoinAccountPath+"?currency=USDT", nil)
if err != nil {
return nil, fmt.Errorf("failed to get account balance: %w", err)
}
var account struct {
AccountEquity float64 `json:"accountEquity"`
UnrealisedPNL float64 `json:"unrealisedPNL"`
MarginBalance float64 `json:"marginBalance"`
PositionMargin float64 `json:"positionMargin"`
OrderMargin float64 `json:"orderMargin"`
FrozenFunds float64 `json:"frozenFunds"`
AvailableBalance float64 `json:"availableBalance"`
Currency string `json:"currency"`
}
if err := json.Unmarshal(data, &account); err != nil {
return nil, fmt.Errorf("failed to parse balance data: %w", err)
}
result := map[string]interface{}{
"totalWalletBalance": account.MarginBalance, // Wallet balance (without unrealized PnL)
"availableBalance": account.AvailableBalance,
"totalUnrealizedProfit": account.UnrealisedPNL,
"total_equity": account.AccountEquity,
"totalEquity": account.AccountEquity, // For GetAccountInfo compatibility
}
logger.Infof("✓ KuCoin balance: Total equity=%.2f, Available=%.2f, Unrealized PnL=%.2f",
account.AccountEquity, account.AvailableBalance, account.UnrealisedPNL)
// Update cache
t.balanceCacheMutex.Lock()
t.cachedBalance = result
t.balanceCacheTime = time.Now()
t.balanceCacheMutex.Unlock()
return result, nil
}
// GetPositions gets all positions
func (t *KuCoinTrader) GetPositions() ([]map[string]interface{}, error) {
// Check cache
t.positionsCacheMutex.RLock()
if t.cachedPositions != nil && time.Since(t.positionsCacheTime) < t.cacheDuration {
t.positionsCacheMutex.RUnlock()
return t.cachedPositions, nil
}
t.positionsCacheMutex.RUnlock()
data, err := t.doRequest("GET", kucoinPositionPath, nil)
if err != nil {
return nil, fmt.Errorf("failed to get positions: %w", err)
}
var positions []struct {
Symbol string `json:"symbol"`
CurrentQty int64 `json:"currentQty"` // Position quantity (in lots, integer)
AvgEntryPrice float64 `json:"avgEntryPrice"` // Average entry price (string in API)
MarkPrice float64 `json:"markPrice"` // Mark price
UnrealisedPnl float64 `json:"unrealisedPnl"` // Unrealized PnL
Leverage float64 `json:"leverage"` // Leverage setting
RealLeverage float64 `json:"realLeverage"` // Effective leverage (may be nil in cross mode)
LiquidationPrice float64 `json:"liquidationPrice"`// Liquidation price
Multiplier float64 `json:"multiplier"` // Contract multiplier
IsOpen bool `json:"isOpen"`
CrossMode bool `json:"crossMode"`
OpeningTimestamp int64 `json:"openingTimestamp"`
SettleCurrency string `json:"settleCurrency"`
}
if err := json.Unmarshal(data, &positions); err != nil {
return nil, fmt.Errorf("failed to parse position data: %w", err)
}
var result []map[string]interface{}
for _, pos := range positions {
if !pos.IsOpen || pos.CurrentQty == 0 {
continue
}
// Convert symbol format
symbol := t.convertSymbolBack(pos.Symbol)
// Determine side based on position quantity
// KuCoin: positive qty = long, negative qty = short
side := "long"
qty := pos.CurrentQty
if qty < 0 {
side = "short"
qty = -qty
}
// Convert lots to actual quantity using multiplier
// Position quantity = lots * multiplier
multiplier := pos.Multiplier
if multiplier == 0 {
multiplier = 0.001 // Default for BTC
}
positionAmt := float64(qty) * multiplier
// Determine margin mode
mgnMode := "isolated"
if pos.CrossMode {
mgnMode = "cross"
}
// Use Leverage field (setting), fallback to RealLeverage (effective), default to 10
leverage := pos.Leverage
if leverage == 0 {
leverage = pos.RealLeverage
}
if leverage == 0 {
leverage = 10 // Default leverage
}
posMap := map[string]interface{}{
"symbol": symbol,
"positionAmt": positionAmt,
"entryPrice": pos.AvgEntryPrice,
"markPrice": pos.MarkPrice,
"unRealizedProfit": pos.UnrealisedPnl,
"leverage": leverage,
"liquidationPrice": pos.LiquidationPrice,
"side": side,
"mgnMode": mgnMode,
"createdTime": pos.OpeningTimestamp,
}
result = append(result, posMap)
}
// Update cache
t.positionsCacheMutex.Lock()
t.cachedPositions = result
t.positionsCacheTime = time.Now()
t.positionsCacheMutex.Unlock()
return result, nil
}
// InvalidatePositionCache clears the position cache
func (t *KuCoinTrader) InvalidatePositionCache() {
t.positionsCacheMutex.Lock()
t.cachedPositions = nil
t.positionsCacheTime = time.Time{}
t.positionsCacheMutex.Unlock()
}
// getContract gets contract info // getContract gets contract info
func (t *KuCoinTrader) getContract(symbol string) (*KuCoinContract, error) { func (t *KuCoinTrader) getContract(symbol string) (*KuCoinContract, error) {
kcSymbol := t.convertSymbol(symbol) kcSymbol := t.convertSymbol(symbol)
@@ -526,768 +367,3 @@ func (t *KuCoinTrader) quantityToLots(symbol string, quantity float64) (int64, e
return lotsInt, nil return lotsInt, nil
} }
// SetMarginMode sets margin mode
func (t *KuCoinTrader) SetMarginMode(symbol string, isCrossMargin bool) error {
// KuCoin sets margin mode per position, handled automatically
logger.Infof("✓ KuCoin margin mode: %v (handled per position)", isCrossMargin)
return nil
}
// SetLeverage sets leverage for a symbol
func (t *KuCoinTrader) SetLeverage(symbol string, leverage int) error {
kcSymbol := t.convertSymbol(symbol)
body := map[string]interface{}{
"symbol": kcSymbol,
"leverage": fmt.Sprintf("%d", leverage),
}
_, err := t.doRequest("POST", kucoinLeveragePath, body)
if err != nil {
// Ignore if already at target leverage
if strings.Contains(err.Error(), "same") || strings.Contains(err.Error(), "already") {
logger.Infof("✓ %s leverage is already %dx", symbol, leverage)
return nil
}
return fmt.Errorf("failed to set leverage: %w", err)
}
logger.Infof("✓ %s leverage set to %dx", symbol, leverage)
return nil
}
// OpenLong opens long position
func (t *KuCoinTrader) OpenLong(symbol string, quantity float64, leverage int) (map[string]interface{}, error) {
// Cancel old orders
t.CancelAllOrders(symbol)
// Set leverage
if err := t.SetLeverage(symbol, leverage); err != nil {
logger.Infof("⚠️ Failed to set leverage: %v", err)
}
kcSymbol := t.convertSymbol(symbol)
// Convert quantity to lots
lots, err := t.quantityToLots(symbol, quantity)
if err != nil {
return nil, fmt.Errorf("failed to calculate lots: %w", err)
}
body := map[string]interface{}{
"clientOid": fmt.Sprintf("nfx%d", time.Now().UnixNano()),
"symbol": kcSymbol,
"side": "buy",
"type": "market",
"size": lots,
"leverage": fmt.Sprintf("%d", leverage),
"reduceOnly": false,
"marginMode": "CROSS", // Use cross margin mode
}
data, err := t.doRequest("POST", kucoinOrderPath, body)
if err != nil {
return nil, fmt.Errorf("failed to open long position: %w", err)
}
var result struct {
OrderId string `json:"orderId"`
}
if err := json.Unmarshal(data, &result); err != nil {
return nil, fmt.Errorf("failed to parse order response: %w", err)
}
logger.Infof("✓ KuCoin opened long position: %s, lots=%d, orderId=%s", symbol, lots, result.OrderId)
// Query order to get fill price
fillPrice := t.queryOrderFillPrice(result.OrderId)
return map[string]interface{}{
"orderId": result.OrderId,
"symbol": symbol,
"status": "FILLED",
"fillPrice": fillPrice,
}, nil
}
// OpenShort opens short position
func (t *KuCoinTrader) OpenShort(symbol string, quantity float64, leverage int) (map[string]interface{}, error) {
// Cancel old orders
t.CancelAllOrders(symbol)
// Set leverage
if err := t.SetLeverage(symbol, leverage); err != nil {
logger.Infof("⚠️ Failed to set leverage: %v", err)
}
kcSymbol := t.convertSymbol(symbol)
// Convert quantity to lots
lots, err := t.quantityToLots(symbol, quantity)
if err != nil {
return nil, fmt.Errorf("failed to calculate lots: %w", err)
}
body := map[string]interface{}{
"clientOid": fmt.Sprintf("nfx%d", time.Now().UnixNano()),
"symbol": kcSymbol,
"side": "sell",
"type": "market",
"size": lots,
"leverage": fmt.Sprintf("%d", leverage),
"reduceOnly": false,
"marginMode": "CROSS", // Use cross margin mode
}
data, err := t.doRequest("POST", kucoinOrderPath, body)
if err != nil {
return nil, fmt.Errorf("failed to open short position: %w", err)
}
var result struct {
OrderId string `json:"orderId"`
}
if err := json.Unmarshal(data, &result); err != nil {
return nil, fmt.Errorf("failed to parse order response: %w", err)
}
logger.Infof("✓ KuCoin opened short position: %s, lots=%d, orderId=%s", symbol, lots, result.OrderId)
// Query order to get fill price
fillPrice := t.queryOrderFillPrice(result.OrderId)
return map[string]interface{}{
"orderId": result.OrderId,
"symbol": symbol,
"status": "FILLED",
"fillPrice": fillPrice,
}, nil
}
// queryOrderFillPrice queries order status and returns fill price
func (t *KuCoinTrader) queryOrderFillPrice(orderId string) float64 {
// Wait a bit for order to fill
time.Sleep(500 * time.Millisecond)
path := fmt.Sprintf("%s/%s", kucoinOrderPath, orderId)
data, err := t.doRequest("GET", path, nil)
if err != nil {
logger.Warnf("Failed to query order %s: %v", orderId, err)
return 0
}
var order struct {
DealAvgPrice float64 `json:"dealAvgPrice"`
Status string `json:"status"`
DealSize int64 `json:"dealSize"`
}
if err := json.Unmarshal(data, &order); err != nil {
return 0
}
return order.DealAvgPrice
}
// CloseLong closes long position
func (t *KuCoinTrader) CloseLong(symbol string, quantity float64) (map[string]interface{}, error) {
// Invalidate position cache and get fresh positions
t.InvalidatePositionCache()
positions, err := t.GetPositions()
if err != nil {
return nil, fmt.Errorf("failed to get positions: %w", err)
}
// Find actual position and get margin mode
var actualQty float64
var posFound bool
var marginMode string = "CROSS" // Default to CROSS
for _, pos := range positions {
if pos["symbol"] == symbol && pos["side"] == "long" {
actualQty = pos["positionAmt"].(float64)
posFound = true
// Get margin mode from position
if mgnMode, ok := pos["mgnMode"].(string); ok {
marginMode = strings.ToUpper(mgnMode)
}
break
}
}
if !posFound || actualQty == 0 {
return map[string]interface{}{
"status": "NO_POSITION",
"message": fmt.Sprintf("No long position found for %s on KuCoin", symbol),
}, nil
}
// Use actual quantity from exchange
if quantity == 0 || quantity > actualQty {
quantity = actualQty
}
kcSymbol := t.convertSymbol(symbol)
// Convert quantity to lots
lots, err := t.quantityToLots(symbol, quantity)
if err != nil {
return nil, fmt.Errorf("failed to calculate lots: %w", err)
}
body := map[string]interface{}{
"clientOid": fmt.Sprintf("nfx%d", time.Now().UnixNano()),
"symbol": kcSymbol,
"side": "sell",
"type": "market",
"size": lots,
"reduceOnly": true,
"closeOrder": true,
"marginMode": marginMode, // Use position's margin mode
}
data, err := t.doRequest("POST", kucoinOrderPath, body)
if err != nil {
return nil, fmt.Errorf("failed to close long position: %w", err)
}
var result struct {
OrderId string `json:"orderId"`
}
if err := json.Unmarshal(data, &result); err != nil {
return nil, fmt.Errorf("failed to parse order response: %w", err)
}
logger.Infof("✓ KuCoin closed long position: %s", symbol)
// Cancel pending orders
t.CancelAllOrders(symbol)
return map[string]interface{}{
"orderId": result.OrderId,
"symbol": symbol,
"status": "FILLED",
}, nil
}
// CloseShort closes short position
func (t *KuCoinTrader) CloseShort(symbol string, quantity float64) (map[string]interface{}, error) {
// Invalidate position cache and get fresh positions
t.InvalidatePositionCache()
positions, err := t.GetPositions()
if err != nil {
return nil, fmt.Errorf("failed to get positions: %w", err)
}
// Find actual position and get margin mode
var actualQty float64
var posFound bool
var marginMode string = "CROSS" // Default to CROSS
for _, pos := range positions {
if pos["symbol"] == symbol && pos["side"] == "short" {
actualQty = pos["positionAmt"].(float64)
posFound = true
// Get margin mode from position
if mgnMode, ok := pos["mgnMode"].(string); ok {
marginMode = strings.ToUpper(mgnMode)
}
break
}
}
if !posFound || actualQty == 0 {
return map[string]interface{}{
"status": "NO_POSITION",
"message": fmt.Sprintf("No short position found for %s on KuCoin", symbol),
}, nil
}
// Use actual quantity from exchange
if quantity == 0 || quantity > actualQty {
quantity = actualQty
}
kcSymbol := t.convertSymbol(symbol)
// Convert quantity to lots
lots, err := t.quantityToLots(symbol, quantity)
if err != nil {
return nil, fmt.Errorf("failed to calculate lots: %w", err)
}
body := map[string]interface{}{
"clientOid": fmt.Sprintf("nfx%d", time.Now().UnixNano()),
"symbol": kcSymbol,
"side": "buy",
"type": "market",
"size": lots,
"reduceOnly": true,
"closeOrder": true,
"marginMode": marginMode, // Use position's margin mode
}
data, err := t.doRequest("POST", kucoinOrderPath, body)
if err != nil {
return nil, fmt.Errorf("failed to close short position: %w", err)
}
var result struct {
OrderId string `json:"orderId"`
}
if err := json.Unmarshal(data, &result); err != nil {
return nil, fmt.Errorf("failed to parse order response: %w", err)
}
logger.Infof("✓ KuCoin closed short position: %s", symbol)
// Cancel pending orders
t.CancelAllOrders(symbol)
return map[string]interface{}{
"orderId": result.OrderId,
"symbol": symbol,
"status": "FILLED",
}, nil
}
// GetMarketPrice gets market price
func (t *KuCoinTrader) GetMarketPrice(symbol string) (float64, error) {
kcSymbol := t.convertSymbol(symbol)
path := fmt.Sprintf("%s?symbol=%s", kucoinTickerPath, kcSymbol)
data, err := t.doRequest("GET", path, nil)
if err != nil {
return 0, fmt.Errorf("failed to get price: %w", err)
}
var ticker struct {
Price string `json:"price"`
}
if err := json.Unmarshal(data, &ticker); err != nil {
return 0, err
}
price, _ := strconv.ParseFloat(ticker.Price, 64)
return price, nil
}
// SetStopLoss sets stop loss order
func (t *KuCoinTrader) SetStopLoss(symbol string, positionSide string, quantity, stopPrice float64) error {
kcSymbol := t.convertSymbol(symbol)
// Convert quantity to lots
lots, err := t.quantityToLots(symbol, quantity)
if err != nil {
return fmt.Errorf("failed to calculate lots: %w", err)
}
// Determine side: close long = sell, close short = buy
side := "sell"
stop := "down" // Long position: stop loss triggers when price goes down
if strings.ToUpper(positionSide) == "SHORT" {
side = "buy"
stop = "up" // Short position: stop loss triggers when price goes up
}
body := map[string]interface{}{
"clientOid": fmt.Sprintf("nfxsl%d", time.Now().UnixNano()),
"symbol": kcSymbol,
"side": side,
"type": "market",
"size": lots,
"stop": stop,
"stopPriceType": "MP", // Mark Price
"stopPrice": fmt.Sprintf("%.8f", stopPrice),
"reduceOnly": true,
"closeOrder": true,
}
_, err = t.doRequest("POST", kucoinStopOrderPath, body)
if err != nil {
return fmt.Errorf("failed to set stop loss: %w", err)
}
logger.Infof("✓ Stop loss set: %.4f", stopPrice)
return nil
}
// SetTakeProfit sets take profit order
func (t *KuCoinTrader) SetTakeProfit(symbol string, positionSide string, quantity, takeProfitPrice float64) error {
kcSymbol := t.convertSymbol(symbol)
// Convert quantity to lots
lots, err := t.quantityToLots(symbol, quantity)
if err != nil {
return fmt.Errorf("failed to calculate lots: %w", err)
}
// Determine side: close long = sell, close short = buy
side := "sell"
stop := "up" // Long position: take profit triggers when price goes up
if strings.ToUpper(positionSide) == "SHORT" {
side = "buy"
stop = "down" // Short position: take profit triggers when price goes down
}
body := map[string]interface{}{
"clientOid": fmt.Sprintf("nfxtp%d", time.Now().UnixNano()),
"symbol": kcSymbol,
"side": side,
"type": "market",
"size": lots,
"stop": stop,
"stopPriceType": "MP", // Mark Price
"stopPrice": fmt.Sprintf("%.8f", takeProfitPrice),
"reduceOnly": true,
"closeOrder": true,
}
_, err = t.doRequest("POST", kucoinStopOrderPath, body)
if err != nil {
return fmt.Errorf("failed to set take profit: %w", err)
}
logger.Infof("✓ Take profit set: %.4f", takeProfitPrice)
return nil
}
// CancelStopLossOrders cancels stop loss orders
func (t *KuCoinTrader) CancelStopLossOrders(symbol string) error {
return t.cancelStopOrdersByType(symbol, "sl")
}
// CancelTakeProfitOrders cancels take profit orders
func (t *KuCoinTrader) CancelTakeProfitOrders(symbol string) error {
return t.cancelStopOrdersByType(symbol, "tp")
}
// cancelStopOrdersByType cancels stop orders by type
func (t *KuCoinTrader) cancelStopOrdersByType(symbol string, orderType string) error {
kcSymbol := t.convertSymbol(symbol)
// Get pending stop orders
path := fmt.Sprintf("%s?symbol=%s", kucoinStopOrderPath, kcSymbol)
data, err := t.doRequest("GET", path, nil)
if err != nil {
return err
}
var response struct {
Items []struct {
Id string `json:"id"`
ClientOid string `json:"clientOid"`
Stop string `json:"stop"`
} `json:"items"`
}
if err := json.Unmarshal(data, &response); err != nil {
// Try alternate format (direct array)
var items []struct {
Id string `json:"id"`
ClientOid string `json:"clientOid"`
Stop string `json:"stop"`
}
if err := json.Unmarshal(data, &items); err != nil {
return err
}
response.Items = items
}
// Cancel matching orders
for _, order := range response.Items {
// Check if order matches type based on clientOid prefix
if orderType == "sl" && !strings.Contains(order.ClientOid, "sl") {
continue
}
if orderType == "tp" && !strings.Contains(order.ClientOid, "tp") {
continue
}
cancelPath := fmt.Sprintf("%s/%s", kucoinCancelStopPath, order.Id)
_, err := t.doRequest("DELETE", cancelPath, nil)
if err != nil {
logger.Warnf("Failed to cancel stop order %s: %v", order.Id, err)
}
}
return nil
}
// CancelStopOrders cancels all stop orders for symbol
func (t *KuCoinTrader) CancelStopOrders(symbol string) error {
kcSymbol := t.convertSymbol(symbol)
path := fmt.Sprintf("%s?symbol=%s", kucoinCancelStopPath, kcSymbol)
_, err := t.doRequest("DELETE", path, nil)
if err != nil {
// Ignore if no orders to cancel
if strings.Contains(err.Error(), "not found") || strings.Contains(err.Error(), "400100") {
return nil
}
return err
}
logger.Infof("✓ Cancelled stop orders for %s", symbol)
return nil
}
// CancelAllOrders cancels all pending orders for symbol
func (t *KuCoinTrader) CancelAllOrders(symbol string) error {
kcSymbol := t.convertSymbol(symbol)
// Cancel regular orders
path := fmt.Sprintf("%s?symbol=%s", kucoinCancelOrderPath, kcSymbol)
_, err := t.doRequest("DELETE", path, nil)
if err != nil && !strings.Contains(err.Error(), "not found") {
logger.Warnf("Failed to cancel regular orders: %v", err)
}
// Cancel stop orders
t.CancelStopOrders(symbol)
return nil
}
// FormatQuantity formats quantity to correct precision
func (t *KuCoinTrader) FormatQuantity(symbol string, quantity float64) (string, error) {
contract, err := t.getContract(symbol)
if err != nil {
return "", err
}
// Calculate lots
lots := quantity / contract.Multiplier
// Round to integer
lotsInt := int64(math.Round(lots))
return strconv.FormatInt(lotsInt, 10), nil
}
// GetOrderStatus gets order status
func (t *KuCoinTrader) GetOrderStatus(symbol string, orderID string) (map[string]interface{}, error) {
path := fmt.Sprintf("%s/%s", kucoinOrderPath, orderID)
data, err := t.doRequest("GET", path, nil)
if err != nil {
return nil, fmt.Errorf("failed to get order status: %w", err)
}
var order struct {
Id string `json:"id"`
Symbol string `json:"symbol"`
Status string `json:"status"`
DealAvgPrice float64 `json:"dealAvgPrice"`
DealSize int64 `json:"dealSize"`
Fee float64 `json:"fee"`
Side string `json:"side"`
}
if err := json.Unmarshal(data, &order); err != nil {
return nil, err
}
// Convert status
status := "NEW"
if order.Status == "done" {
status = "FILLED"
} else if order.Status == "cancelled" || order.Status == "canceled" {
status = "CANCELED"
}
return map[string]interface{}{
"orderId": order.Id,
"symbol": t.convertSymbolBack(order.Symbol),
"status": status,
"avgPrice": order.DealAvgPrice,
"executedQty": order.DealSize,
"commission": order.Fee,
}, nil
}
// GetClosedPnL gets closed position PnL records
func (t *KuCoinTrader) GetClosedPnL(startTime time.Time, limit int) ([]types.ClosedPnLRecord, error) {
if limit <= 0 {
limit = 100
}
if limit > 100 {
limit = 100
}
// KuCoin closed positions API
path := fmt.Sprintf("/api/v1/history-positions?status=CLOSE&limit=%d", limit)
if !startTime.IsZero() {
path += fmt.Sprintf("&from=%d", startTime.UnixMilli())
}
data, err := t.doRequest("GET", path, nil)
if err != nil {
return nil, fmt.Errorf("failed to get closed PnL: %w", err)
}
var response struct {
HasMore bool `json:"hasMore"`
DataList []struct {
Symbol string `json:"symbol"`
OpenPrice float64 `json:"avgEntryPrice"`
ClosePrice float64 `json:"avgClosePrice"`
Qty int64 `json:"qty"`
RealisedPnl float64 `json:"realisedGrossCost"`
CloseTime int64 `json:"closeTime"`
OpenTime int64 `json:"openTime"`
PositionId string `json:"id"`
CloseType string `json:"type"`
Leverage int `json:"leverage"`
SettleCurrency string `json:"settleCurrency"`
} `json:"dataList"`
}
if err := json.Unmarshal(data, &response); err != nil {
return nil, fmt.Errorf("failed to parse closed PnL: %w", err)
}
var records []types.ClosedPnLRecord
for _, item := range response.DataList {
side := "long"
qty := item.Qty
if qty < 0 {
side = "short"
qty = -qty
}
// Map close type
closeType := "unknown"
switch strings.ToUpper(item.CloseType) {
case "CLOSE", "MANUAL":
closeType = "manual"
case "STOP", "STOPLOSS":
closeType = "stop_loss"
case "TAKEPROFIT", "TP":
closeType = "take_profit"
case "LIQUIDATION", "LIQ", "ADL":
closeType = "liquidation"
}
records = append(records, types.ClosedPnLRecord{
Symbol: t.convertSymbolBack(item.Symbol),
Side: side,
EntryPrice: item.OpenPrice,
ExitPrice: item.ClosePrice,
Quantity: float64(qty),
RealizedPnL: item.RealisedPnl,
Leverage: item.Leverage,
EntryTime: time.UnixMilli(item.OpenTime),
ExitTime: time.UnixMilli(item.CloseTime),
ExchangeID: item.PositionId,
CloseType: closeType,
})
}
return records, nil
}
// GetOpenOrders gets open/pending orders
func (t *KuCoinTrader) GetOpenOrders(symbol string) ([]types.OpenOrder, error) {
kcSymbol := t.convertSymbol(symbol)
// Get regular orders
path := fmt.Sprintf("%s?symbol=%s&status=active", kucoinOrderPath, kcSymbol)
data, err := t.doRequest("GET", path, nil)
if err != nil {
return nil, fmt.Errorf("failed to get open orders: %w", err)
}
var response struct {
Items []struct {
Id string `json:"id"`
Symbol string `json:"symbol"`
Side string `json:"side"`
Type string `json:"type"`
Price string `json:"price"`
Size int64 `json:"size"`
StopType string `json:"stopType"`
} `json:"items"`
}
if err := json.Unmarshal(data, &response); err != nil {
// Try alternate format
var items []struct {
Id string `json:"id"`
Symbol string `json:"symbol"`
Side string `json:"side"`
Type string `json:"type"`
Price string `json:"price"`
Size int64 `json:"size"`
StopType string `json:"stopType"`
}
if err := json.Unmarshal(data, &items); err != nil {
return nil, err
}
response.Items = items
}
var orders []types.OpenOrder
for _, item := range response.Items {
// Determine position side based on order side
positionSide := "LONG"
if item.Side == "sell" {
positionSide = "SHORT"
}
price, _ := strconv.ParseFloat(item.Price, 64)
orders = append(orders, types.OpenOrder{
OrderID: item.Id,
Symbol: t.convertSymbolBack(item.Symbol),
Side: strings.ToUpper(item.Side),
PositionSide: positionSide,
Type: strings.ToUpper(item.Type),
Price: price,
Quantity: float64(item.Size),
Status: "NEW",
})
}
// Get stop orders
stopPath := fmt.Sprintf("%s?symbol=%s", kucoinStopOrderPath, kcSymbol)
stopData, err := t.doRequest("GET", stopPath, nil)
if err == nil {
var stopResponse struct {
Items []struct {
Id string `json:"id"`
Symbol string `json:"symbol"`
Side string `json:"side"`
StopPrice string `json:"stopPrice"`
Size int64 `json:"size"`
} `json:"items"`
}
if json.Unmarshal(stopData, &stopResponse) == nil {
for _, item := range stopResponse.Items {
positionSide := "LONG"
if item.Side == "sell" {
positionSide = "SHORT"
}
stopPrice, _ := strconv.ParseFloat(item.StopPrice, 64)
orders = append(orders, types.OpenOrder{
OrderID: item.Id,
Symbol: t.convertSymbolBack(item.Symbol),
Side: strings.ToUpper(item.Side),
PositionSide: positionSide,
Type: "STOP_MARKET",
StopPrice: stopPrice,
Quantity: float64(item.Size),
Status: "NEW",
})
}
}
}
return orders, nil
}

View File

@@ -0,0 +1,58 @@
package kucoin
import (
"encoding/json"
"fmt"
"nofx/logger"
"time"
)
// GetBalance gets account balance
func (t *KuCoinTrader) GetBalance() (map[string]interface{}, error) {
// Check cache
t.balanceCacheMutex.RLock()
if t.cachedBalance != nil && time.Since(t.balanceCacheTime) < t.cacheDuration {
t.balanceCacheMutex.RUnlock()
return t.cachedBalance, nil
}
t.balanceCacheMutex.RUnlock()
data, err := t.doRequest("GET", kucoinAccountPath+"?currency=USDT", nil)
if err != nil {
return nil, fmt.Errorf("failed to get account balance: %w", err)
}
var account struct {
AccountEquity float64 `json:"accountEquity"`
UnrealisedPNL float64 `json:"unrealisedPNL"`
MarginBalance float64 `json:"marginBalance"`
PositionMargin float64 `json:"positionMargin"`
OrderMargin float64 `json:"orderMargin"`
FrozenFunds float64 `json:"frozenFunds"`
AvailableBalance float64 `json:"availableBalance"`
Currency string `json:"currency"`
}
if err := json.Unmarshal(data, &account); err != nil {
return nil, fmt.Errorf("failed to parse balance data: %w", err)
}
result := map[string]interface{}{
"totalWalletBalance": account.MarginBalance, // Wallet balance (without unrealized PnL)
"availableBalance": account.AvailableBalance,
"totalUnrealizedProfit": account.UnrealisedPNL,
"total_equity": account.AccountEquity,
"totalEquity": account.AccountEquity, // For GetAccountInfo compatibility
}
logger.Infof("✓ KuCoin balance: Total equity=%.2f, Available=%.2f, Unrealized PnL=%.2f",
account.AccountEquity, account.AvailableBalance, account.UnrealisedPNL)
// Update cache
t.balanceCacheMutex.Lock()
t.cachedBalance = result
t.balanceCacheTime = time.Now()
t.balanceCacheMutex.Unlock()
return result, nil
}

View File

@@ -0,0 +1,777 @@
package kucoin
import (
"encoding/json"
"fmt"
"math"
"nofx/logger"
"nofx/trader/types"
"strconv"
"strings"
"time"
)
// OpenLong opens long position
func (t *KuCoinTrader) OpenLong(symbol string, quantity float64, leverage int) (map[string]interface{}, error) {
// Cancel old orders
t.CancelAllOrders(symbol)
// Set leverage
if err := t.SetLeverage(symbol, leverage); err != nil {
logger.Infof("⚠️ Failed to set leverage: %v", err)
}
kcSymbol := t.convertSymbol(symbol)
// Convert quantity to lots
lots, err := t.quantityToLots(symbol, quantity)
if err != nil {
return nil, fmt.Errorf("failed to calculate lots: %w", err)
}
body := map[string]interface{}{
"clientOid": fmt.Sprintf("nfx%d", time.Now().UnixNano()),
"symbol": kcSymbol,
"side": "buy",
"type": "market",
"size": lots,
"leverage": fmt.Sprintf("%d", leverage),
"reduceOnly": false,
"marginMode": "CROSS", // Use cross margin mode
}
data, err := t.doRequest("POST", kucoinOrderPath, body)
if err != nil {
return nil, fmt.Errorf("failed to open long position: %w", err)
}
var result struct {
OrderId string `json:"orderId"`
}
if err := json.Unmarshal(data, &result); err != nil {
return nil, fmt.Errorf("failed to parse order response: %w", err)
}
logger.Infof("✓ KuCoin opened long position: %s, lots=%d, orderId=%s", symbol, lots, result.OrderId)
// Query order to get fill price
fillPrice := t.queryOrderFillPrice(result.OrderId)
return map[string]interface{}{
"orderId": result.OrderId,
"symbol": symbol,
"status": "FILLED",
"fillPrice": fillPrice,
}, nil
}
// OpenShort opens short position
func (t *KuCoinTrader) OpenShort(symbol string, quantity float64, leverage int) (map[string]interface{}, error) {
// Cancel old orders
t.CancelAllOrders(symbol)
// Set leverage
if err := t.SetLeverage(symbol, leverage); err != nil {
logger.Infof("⚠️ Failed to set leverage: %v", err)
}
kcSymbol := t.convertSymbol(symbol)
// Convert quantity to lots
lots, err := t.quantityToLots(symbol, quantity)
if err != nil {
return nil, fmt.Errorf("failed to calculate lots: %w", err)
}
body := map[string]interface{}{
"clientOid": fmt.Sprintf("nfx%d", time.Now().UnixNano()),
"symbol": kcSymbol,
"side": "sell",
"type": "market",
"size": lots,
"leverage": fmt.Sprintf("%d", leverage),
"reduceOnly": false,
"marginMode": "CROSS", // Use cross margin mode
}
data, err := t.doRequest("POST", kucoinOrderPath, body)
if err != nil {
return nil, fmt.Errorf("failed to open short position: %w", err)
}
var result struct {
OrderId string `json:"orderId"`
}
if err := json.Unmarshal(data, &result); err != nil {
return nil, fmt.Errorf("failed to parse order response: %w", err)
}
logger.Infof("✓ KuCoin opened short position: %s, lots=%d, orderId=%s", symbol, lots, result.OrderId)
// Query order to get fill price
fillPrice := t.queryOrderFillPrice(result.OrderId)
return map[string]interface{}{
"orderId": result.OrderId,
"symbol": symbol,
"status": "FILLED",
"fillPrice": fillPrice,
}, nil
}
// queryOrderFillPrice queries order status and returns fill price
func (t *KuCoinTrader) queryOrderFillPrice(orderId string) float64 {
// Wait a bit for order to fill
time.Sleep(500 * time.Millisecond)
path := fmt.Sprintf("%s/%s", kucoinOrderPath, orderId)
data, err := t.doRequest("GET", path, nil)
if err != nil {
logger.Warnf("Failed to query order %s: %v", orderId, err)
return 0
}
var order struct {
DealAvgPrice float64 `json:"dealAvgPrice"`
Status string `json:"status"`
DealSize int64 `json:"dealSize"`
}
if err := json.Unmarshal(data, &order); err != nil {
return 0
}
return order.DealAvgPrice
}
// CloseLong closes long position
func (t *KuCoinTrader) CloseLong(symbol string, quantity float64) (map[string]interface{}, error) {
// Invalidate position cache and get fresh positions
t.InvalidatePositionCache()
positions, err := t.GetPositions()
if err != nil {
return nil, fmt.Errorf("failed to get positions: %w", err)
}
// Find actual position and get margin mode
var actualQty float64
var posFound bool
var marginMode string = "CROSS" // Default to CROSS
for _, pos := range positions {
if pos["symbol"] == symbol && pos["side"] == "long" {
actualQty = pos["positionAmt"].(float64)
posFound = true
// Get margin mode from position
if mgnMode, ok := pos["mgnMode"].(string); ok {
marginMode = strings.ToUpper(mgnMode)
}
break
}
}
if !posFound || actualQty == 0 {
return map[string]interface{}{
"status": "NO_POSITION",
"message": fmt.Sprintf("No long position found for %s on KuCoin", symbol),
}, nil
}
// Use actual quantity from exchange
if quantity == 0 || quantity > actualQty {
quantity = actualQty
}
kcSymbol := t.convertSymbol(symbol)
// Convert quantity to lots
lots, err := t.quantityToLots(symbol, quantity)
if err != nil {
return nil, fmt.Errorf("failed to calculate lots: %w", err)
}
body := map[string]interface{}{
"clientOid": fmt.Sprintf("nfx%d", time.Now().UnixNano()),
"symbol": kcSymbol,
"side": "sell",
"type": "market",
"size": lots,
"reduceOnly": true,
"closeOrder": true,
"marginMode": marginMode, // Use position's margin mode
}
data, err := t.doRequest("POST", kucoinOrderPath, body)
if err != nil {
return nil, fmt.Errorf("failed to close long position: %w", err)
}
var result struct {
OrderId string `json:"orderId"`
}
if err := json.Unmarshal(data, &result); err != nil {
return nil, fmt.Errorf("failed to parse order response: %w", err)
}
logger.Infof("✓ KuCoin closed long position: %s", symbol)
// Cancel pending orders
t.CancelAllOrders(symbol)
return map[string]interface{}{
"orderId": result.OrderId,
"symbol": symbol,
"status": "FILLED",
}, nil
}
// CloseShort closes short position
func (t *KuCoinTrader) CloseShort(symbol string, quantity float64) (map[string]interface{}, error) {
// Invalidate position cache and get fresh positions
t.InvalidatePositionCache()
positions, err := t.GetPositions()
if err != nil {
return nil, fmt.Errorf("failed to get positions: %w", err)
}
// Find actual position and get margin mode
var actualQty float64
var posFound bool
var marginMode string = "CROSS" // Default to CROSS
for _, pos := range positions {
if pos["symbol"] == symbol && pos["side"] == "short" {
actualQty = pos["positionAmt"].(float64)
posFound = true
// Get margin mode from position
if mgnMode, ok := pos["mgnMode"].(string); ok {
marginMode = strings.ToUpper(mgnMode)
}
break
}
}
if !posFound || actualQty == 0 {
return map[string]interface{}{
"status": "NO_POSITION",
"message": fmt.Sprintf("No short position found for %s on KuCoin", symbol),
}, nil
}
// Use actual quantity from exchange
if quantity == 0 || quantity > actualQty {
quantity = actualQty
}
kcSymbol := t.convertSymbol(symbol)
// Convert quantity to lots
lots, err := t.quantityToLots(symbol, quantity)
if err != nil {
return nil, fmt.Errorf("failed to calculate lots: %w", err)
}
body := map[string]interface{}{
"clientOid": fmt.Sprintf("nfx%d", time.Now().UnixNano()),
"symbol": kcSymbol,
"side": "buy",
"type": "market",
"size": lots,
"reduceOnly": true,
"closeOrder": true,
"marginMode": marginMode, // Use position's margin mode
}
data, err := t.doRequest("POST", kucoinOrderPath, body)
if err != nil {
return nil, fmt.Errorf("failed to close short position: %w", err)
}
var result struct {
OrderId string `json:"orderId"`
}
if err := json.Unmarshal(data, &result); err != nil {
return nil, fmt.Errorf("failed to parse order response: %w", err)
}
logger.Infof("✓ KuCoin closed short position: %s", symbol)
// Cancel pending orders
t.CancelAllOrders(symbol)
return map[string]interface{}{
"orderId": result.OrderId,
"symbol": symbol,
"status": "FILLED",
}, nil
}
// GetMarketPrice gets market price
func (t *KuCoinTrader) GetMarketPrice(symbol string) (float64, error) {
kcSymbol := t.convertSymbol(symbol)
path := fmt.Sprintf("%s?symbol=%s", kucoinTickerPath, kcSymbol)
data, err := t.doRequest("GET", path, nil)
if err != nil {
return 0, fmt.Errorf("failed to get price: %w", err)
}
var ticker struct {
Price string `json:"price"`
}
if err := json.Unmarshal(data, &ticker); err != nil {
return 0, err
}
price, _ := strconv.ParseFloat(ticker.Price, 64)
return price, nil
}
// SetStopLoss sets stop loss order
func (t *KuCoinTrader) SetStopLoss(symbol string, positionSide string, quantity, stopPrice float64) error {
kcSymbol := t.convertSymbol(symbol)
// Convert quantity to lots
lots, err := t.quantityToLots(symbol, quantity)
if err != nil {
return fmt.Errorf("failed to calculate lots: %w", err)
}
// Determine side: close long = sell, close short = buy
side := "sell"
stop := "down" // Long position: stop loss triggers when price goes down
if strings.ToUpper(positionSide) == "SHORT" {
side = "buy"
stop = "up" // Short position: stop loss triggers when price goes up
}
body := map[string]interface{}{
"clientOid": fmt.Sprintf("nfxsl%d", time.Now().UnixNano()),
"symbol": kcSymbol,
"side": side,
"type": "market",
"size": lots,
"stop": stop,
"stopPriceType": "MP", // Mark Price
"stopPrice": fmt.Sprintf("%.8f", stopPrice),
"reduceOnly": true,
"closeOrder": true,
}
_, err = t.doRequest("POST", kucoinStopOrderPath, body)
if err != nil {
return fmt.Errorf("failed to set stop loss: %w", err)
}
logger.Infof("✓ Stop loss set: %.4f", stopPrice)
return nil
}
// SetTakeProfit sets take profit order
func (t *KuCoinTrader) SetTakeProfit(symbol string, positionSide string, quantity, takeProfitPrice float64) error {
kcSymbol := t.convertSymbol(symbol)
// Convert quantity to lots
lots, err := t.quantityToLots(symbol, quantity)
if err != nil {
return fmt.Errorf("failed to calculate lots: %w", err)
}
// Determine side: close long = sell, close short = buy
side := "sell"
stop := "up" // Long position: take profit triggers when price goes up
if strings.ToUpper(positionSide) == "SHORT" {
side = "buy"
stop = "down" // Short position: take profit triggers when price goes down
}
body := map[string]interface{}{
"clientOid": fmt.Sprintf("nfxtp%d", time.Now().UnixNano()),
"symbol": kcSymbol,
"side": side,
"type": "market",
"size": lots,
"stop": stop,
"stopPriceType": "MP", // Mark Price
"stopPrice": fmt.Sprintf("%.8f", takeProfitPrice),
"reduceOnly": true,
"closeOrder": true,
}
_, err = t.doRequest("POST", kucoinStopOrderPath, body)
if err != nil {
return fmt.Errorf("failed to set take profit: %w", err)
}
logger.Infof("✓ Take profit set: %.4f", takeProfitPrice)
return nil
}
// CancelStopLossOrders cancels stop loss orders
func (t *KuCoinTrader) CancelStopLossOrders(symbol string) error {
return t.cancelStopOrdersByType(symbol, "sl")
}
// CancelTakeProfitOrders cancels take profit orders
func (t *KuCoinTrader) CancelTakeProfitOrders(symbol string) error {
return t.cancelStopOrdersByType(symbol, "tp")
}
// cancelStopOrdersByType cancels stop orders by type
func (t *KuCoinTrader) cancelStopOrdersByType(symbol string, orderType string) error {
kcSymbol := t.convertSymbol(symbol)
// Get pending stop orders
path := fmt.Sprintf("%s?symbol=%s", kucoinStopOrderPath, kcSymbol)
data, err := t.doRequest("GET", path, nil)
if err != nil {
return err
}
var response struct {
Items []struct {
Id string `json:"id"`
ClientOid string `json:"clientOid"`
Stop string `json:"stop"`
} `json:"items"`
}
if err := json.Unmarshal(data, &response); err != nil {
// Try alternate format (direct array)
var items []struct {
Id string `json:"id"`
ClientOid string `json:"clientOid"`
Stop string `json:"stop"`
}
if err := json.Unmarshal(data, &items); err != nil {
return err
}
response.Items = items
}
// Cancel matching orders
for _, order := range response.Items {
// Check if order matches type based on clientOid prefix
if orderType == "sl" && !strings.Contains(order.ClientOid, "sl") {
continue
}
if orderType == "tp" && !strings.Contains(order.ClientOid, "tp") {
continue
}
cancelPath := fmt.Sprintf("%s/%s", kucoinCancelStopPath, order.Id)
_, err := t.doRequest("DELETE", cancelPath, nil)
if err != nil {
logger.Warnf("Failed to cancel stop order %s: %v", order.Id, err)
}
}
return nil
}
// CancelStopOrders cancels all stop orders for symbol
func (t *KuCoinTrader) CancelStopOrders(symbol string) error {
kcSymbol := t.convertSymbol(symbol)
path := fmt.Sprintf("%s?symbol=%s", kucoinCancelStopPath, kcSymbol)
_, err := t.doRequest("DELETE", path, nil)
if err != nil {
// Ignore if no orders to cancel
if strings.Contains(err.Error(), "not found") || strings.Contains(err.Error(), "400100") {
return nil
}
return err
}
logger.Infof("✓ Cancelled stop orders for %s", symbol)
return nil
}
// CancelAllOrders cancels all pending orders for symbol
func (t *KuCoinTrader) CancelAllOrders(symbol string) error {
kcSymbol := t.convertSymbol(symbol)
// Cancel regular orders
path := fmt.Sprintf("%s?symbol=%s", kucoinCancelOrderPath, kcSymbol)
_, err := t.doRequest("DELETE", path, nil)
if err != nil && !strings.Contains(err.Error(), "not found") {
logger.Warnf("Failed to cancel regular orders: %v", err)
}
// Cancel stop orders
t.CancelStopOrders(symbol)
return nil
}
// SetMarginMode sets margin mode
func (t *KuCoinTrader) SetMarginMode(symbol string, isCrossMargin bool) error {
// KuCoin sets margin mode per position, handled automatically
logger.Infof("✓ KuCoin margin mode: %v (handled per position)", isCrossMargin)
return nil
}
// SetLeverage sets leverage for a symbol
func (t *KuCoinTrader) SetLeverage(symbol string, leverage int) error {
kcSymbol := t.convertSymbol(symbol)
body := map[string]interface{}{
"symbol": kcSymbol,
"leverage": fmt.Sprintf("%d", leverage),
}
_, err := t.doRequest("POST", kucoinLeveragePath, body)
if err != nil {
// Ignore if already at target leverage
if strings.Contains(err.Error(), "same") || strings.Contains(err.Error(), "already") {
logger.Infof("✓ %s leverage is already %dx", symbol, leverage)
return nil
}
return fmt.Errorf("failed to set leverage: %w", err)
}
logger.Infof("✓ %s leverage set to %dx", symbol, leverage)
return nil
}
// FormatQuantity formats quantity to correct precision
func (t *KuCoinTrader) FormatQuantity(symbol string, quantity float64) (string, error) {
contract, err := t.getContract(symbol)
if err != nil {
return "", err
}
// Calculate lots
lots := quantity / contract.Multiplier
// Round to integer
lotsInt := int64(math.Round(lots))
return strconv.FormatInt(lotsInt, 10), nil
}
// GetOrderStatus gets order status
func (t *KuCoinTrader) GetOrderStatus(symbol string, orderID string) (map[string]interface{}, error) {
path := fmt.Sprintf("%s/%s", kucoinOrderPath, orderID)
data, err := t.doRequest("GET", path, nil)
if err != nil {
return nil, fmt.Errorf("failed to get order status: %w", err)
}
var order struct {
Id string `json:"id"`
Symbol string `json:"symbol"`
Status string `json:"status"`
DealAvgPrice float64 `json:"dealAvgPrice"`
DealSize int64 `json:"dealSize"`
Fee float64 `json:"fee"`
Side string `json:"side"`
}
if err := json.Unmarshal(data, &order); err != nil {
return nil, err
}
// Convert status
status := "NEW"
if order.Status == "done" {
status = "FILLED"
} else if order.Status == "cancelled" || order.Status == "canceled" {
status = "CANCELED"
}
return map[string]interface{}{
"orderId": order.Id,
"symbol": t.convertSymbolBack(order.Symbol),
"status": status,
"avgPrice": order.DealAvgPrice,
"executedQty": order.DealSize,
"commission": order.Fee,
}, nil
}
// GetClosedPnL gets closed position PnL records
func (t *KuCoinTrader) GetClosedPnL(startTime time.Time, limit int) ([]types.ClosedPnLRecord, error) {
if limit <= 0 {
limit = 100
}
if limit > 100 {
limit = 100
}
// KuCoin closed positions API
path := fmt.Sprintf("/api/v1/history-positions?status=CLOSE&limit=%d", limit)
if !startTime.IsZero() {
path += fmt.Sprintf("&from=%d", startTime.UnixMilli())
}
data, err := t.doRequest("GET", path, nil)
if err != nil {
return nil, fmt.Errorf("failed to get closed PnL: %w", err)
}
var response struct {
HasMore bool `json:"hasMore"`
DataList []struct {
Symbol string `json:"symbol"`
OpenPrice float64 `json:"avgEntryPrice"`
ClosePrice float64 `json:"avgClosePrice"`
Qty int64 `json:"qty"`
RealisedPnl float64 `json:"realisedGrossCost"`
CloseTime int64 `json:"closeTime"`
OpenTime int64 `json:"openTime"`
PositionId string `json:"id"`
CloseType string `json:"type"`
Leverage int `json:"leverage"`
SettleCurrency string `json:"settleCurrency"`
} `json:"dataList"`
}
if err := json.Unmarshal(data, &response); err != nil {
return nil, fmt.Errorf("failed to parse closed PnL: %w", err)
}
var records []types.ClosedPnLRecord
for _, item := range response.DataList {
side := "long"
qty := item.Qty
if qty < 0 {
side = "short"
qty = -qty
}
// Map close type
closeType := "unknown"
switch strings.ToUpper(item.CloseType) {
case "CLOSE", "MANUAL":
closeType = "manual"
case "STOP", "STOPLOSS":
closeType = "stop_loss"
case "TAKEPROFIT", "TP":
closeType = "take_profit"
case "LIQUIDATION", "LIQ", "ADL":
closeType = "liquidation"
}
records = append(records, types.ClosedPnLRecord{
Symbol: t.convertSymbolBack(item.Symbol),
Side: side,
EntryPrice: item.OpenPrice,
ExitPrice: item.ClosePrice,
Quantity: float64(qty),
RealizedPnL: item.RealisedPnl,
Leverage: item.Leverage,
EntryTime: time.UnixMilli(item.OpenTime),
ExitTime: time.UnixMilli(item.CloseTime),
ExchangeID: item.PositionId,
CloseType: closeType,
})
}
return records, nil
}
// GetOpenOrders gets open/pending orders
func (t *KuCoinTrader) GetOpenOrders(symbol string) ([]types.OpenOrder, error) {
kcSymbol := t.convertSymbol(symbol)
// Get regular orders
path := fmt.Sprintf("%s?symbol=%s&status=active", kucoinOrderPath, kcSymbol)
data, err := t.doRequest("GET", path, nil)
if err != nil {
return nil, fmt.Errorf("failed to get open orders: %w", err)
}
var response struct {
Items []struct {
Id string `json:"id"`
Symbol string `json:"symbol"`
Side string `json:"side"`
Type string `json:"type"`
Price string `json:"price"`
Size int64 `json:"size"`
StopType string `json:"stopType"`
} `json:"items"`
}
if err := json.Unmarshal(data, &response); err != nil {
// Try alternate format
var items []struct {
Id string `json:"id"`
Symbol string `json:"symbol"`
Side string `json:"side"`
Type string `json:"type"`
Price string `json:"price"`
Size int64 `json:"size"`
StopType string `json:"stopType"`
}
if err := json.Unmarshal(data, &items); err != nil {
return nil, err
}
response.Items = items
}
var orders []types.OpenOrder
for _, item := range response.Items {
// Determine position side based on order side
positionSide := "LONG"
if item.Side == "sell" {
positionSide = "SHORT"
}
price, _ := strconv.ParseFloat(item.Price, 64)
orders = append(orders, types.OpenOrder{
OrderID: item.Id,
Symbol: t.convertSymbolBack(item.Symbol),
Side: strings.ToUpper(item.Side),
PositionSide: positionSide,
Type: strings.ToUpper(item.Type),
Price: price,
Quantity: float64(item.Size),
Status: "NEW",
})
}
// Get stop orders
stopPath := fmt.Sprintf("%s?symbol=%s", kucoinStopOrderPath, kcSymbol)
stopData, err := t.doRequest("GET", stopPath, nil)
if err == nil {
var stopResponse struct {
Items []struct {
Id string `json:"id"`
Symbol string `json:"symbol"`
Side string `json:"side"`
StopPrice string `json:"stopPrice"`
Size int64 `json:"size"`
} `json:"items"`
}
if json.Unmarshal(stopData, &stopResponse) == nil {
for _, item := range stopResponse.Items {
positionSide := "LONG"
if item.Side == "sell" {
positionSide = "SHORT"
}
stopPrice, _ := strconv.ParseFloat(item.StopPrice, 64)
orders = append(orders, types.OpenOrder{
OrderID: item.Id,
Symbol: t.convertSymbolBack(item.Symbol),
Side: strings.ToUpper(item.Side),
PositionSide: positionSide,
Type: "STOP_MARKET",
StopPrice: stopPrice,
Quantity: float64(item.Size),
Status: "NEW",
})
}
}
}
return orders, nil
}

View File

@@ -0,0 +1,115 @@
package kucoin
import (
"encoding/json"
"fmt"
"time"
)
// GetPositions gets all positions
func (t *KuCoinTrader) GetPositions() ([]map[string]interface{}, error) {
// Check cache
t.positionsCacheMutex.RLock()
if t.cachedPositions != nil && time.Since(t.positionsCacheTime) < t.cacheDuration {
t.positionsCacheMutex.RUnlock()
return t.cachedPositions, nil
}
t.positionsCacheMutex.RUnlock()
data, err := t.doRequest("GET", kucoinPositionPath, nil)
if err != nil {
return nil, fmt.Errorf("failed to get positions: %w", err)
}
var positions []struct {
Symbol string `json:"symbol"`
CurrentQty int64 `json:"currentQty"` // Position quantity (in lots, integer)
AvgEntryPrice float64 `json:"avgEntryPrice"` // Average entry price
MarkPrice float64 `json:"markPrice"` // Mark price
UnrealisedPnl float64 `json:"unrealisedPnl"` // Unrealized PnL
Leverage float64 `json:"leverage"` // Leverage setting
RealLeverage float64 `json:"realLeverage"` // Effective leverage (may be nil in cross mode)
LiquidationPrice float64 `json:"liquidationPrice"`// Liquidation price
Multiplier float64 `json:"multiplier"` // Contract multiplier
IsOpen bool `json:"isOpen"`
CrossMode bool `json:"crossMode"`
OpeningTimestamp int64 `json:"openingTimestamp"`
SettleCurrency string `json:"settleCurrency"`
}
if err := json.Unmarshal(data, &positions); err != nil {
return nil, fmt.Errorf("failed to parse position data: %w", err)
}
var result []map[string]interface{}
for _, pos := range positions {
if !pos.IsOpen || pos.CurrentQty == 0 {
continue
}
// Convert symbol format
symbol := t.convertSymbolBack(pos.Symbol)
// Determine side based on position quantity
// KuCoin: positive qty = long, negative qty = short
side := "long"
qty := pos.CurrentQty
if qty < 0 {
side = "short"
qty = -qty
}
// Convert lots to actual quantity using multiplier
// Position quantity = lots * multiplier
multiplier := pos.Multiplier
if multiplier == 0 {
multiplier = 0.001 // Default for BTC
}
positionAmt := float64(qty) * multiplier
// Determine margin mode
mgnMode := "isolated"
if pos.CrossMode {
mgnMode = "cross"
}
// Use Leverage field (setting), fallback to RealLeverage (effective), default to 10
leverage := pos.Leverage
if leverage == 0 {
leverage = pos.RealLeverage
}
if leverage == 0 {
leverage = 10 // Default leverage
}
posMap := map[string]interface{}{
"symbol": symbol,
"positionAmt": positionAmt,
"entryPrice": pos.AvgEntryPrice,
"markPrice": pos.MarkPrice,
"unRealizedProfit": pos.UnrealisedPnl,
"leverage": leverage,
"liquidationPrice": pos.LiquidationPrice,
"side": side,
"mgnMode": mgnMode,
"createdTime": pos.OpeningTimestamp,
}
result = append(result, posMap)
}
// Update cache
t.positionsCacheMutex.Lock()
t.cachedPositions = result
t.positionsCacheTime = time.Now()
t.positionsCacheMutex.Unlock()
return result, nil
}
// InvalidatePositionCache clears the position cache
func (t *KuCoinTrader) InvalidatePositionCache() {
t.positionsCacheMutex.Lock()
t.cachedPositions = nil
t.positionsCacheTime = time.Time{}
t.positionsCacheMutex.Unlock()
}

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,280 @@
package okx
import (
"encoding/json"
"fmt"
"nofx/logger"
"nofx/trader/types"
"strconv"
"strings"
"time"
)
// GetBalance gets account balance
func (t *OKXTrader) GetBalance() (map[string]interface{}, error) {
// Check cache
t.balanceCacheMutex.RLock()
if t.cachedBalance != nil && time.Since(t.balanceCacheTime) < t.cacheDuration {
t.balanceCacheMutex.RUnlock()
logger.Infof("✓ Using cached OKX account balance")
return t.cachedBalance, nil
}
t.balanceCacheMutex.RUnlock()
logger.Infof("🔄 Calling OKX API to get account balance...")
data, err := t.doRequest("GET", okxAccountPath, nil)
if err != nil {
return nil, fmt.Errorf("failed to get account balance: %w", err)
}
var balances []struct {
TotalEq string `json:"totalEq"`
AdjEq string `json:"adjEq"`
IsoEq string `json:"isoEq"`
OrdFroz string `json:"ordFroz"`
Details []struct {
Ccy string `json:"ccy"`
Eq string `json:"eq"`
CashBal string `json:"cashBal"`
AvailBal string `json:"availBal"`
UPL string `json:"upl"`
} `json:"details"`
}
if err := json.Unmarshal(data, &balances); err != nil {
return nil, fmt.Errorf("failed to parse balance data: %w", err)
}
if len(balances) == 0 {
return nil, fmt.Errorf("no balance data received")
}
balance := balances[0]
// Find USDT balance
var usdtAvail, usdtUPL float64
for _, detail := range balance.Details {
if detail.Ccy == "USDT" {
usdtAvail, _ = strconv.ParseFloat(detail.AvailBal, 64)
usdtUPL, _ = strconv.ParseFloat(detail.UPL, 64)
break
}
}
totalEq, _ := strconv.ParseFloat(balance.TotalEq, 64)
result := map[string]interface{}{
"totalWalletBalance": totalEq,
"availableBalance": usdtAvail,
"totalUnrealizedProfit": usdtUPL,
}
logger.Infof("✓ OKX balance: Total equity=%.2f, Available=%.2f, Unrealized PnL=%.2f", totalEq, usdtAvail, usdtUPL)
// Update cache
t.balanceCacheMutex.Lock()
t.cachedBalance = result
t.balanceCacheTime = time.Now()
t.balanceCacheMutex.Unlock()
return result, nil
}
// SetMarginMode sets margin mode
func (t *OKXTrader) SetMarginMode(symbol string, isCrossMargin bool) error {
instId := t.convertSymbol(symbol)
mgnMode := "isolated"
if isCrossMargin {
mgnMode = "cross"
}
body := map[string]interface{}{
"instId": instId,
"mgnMode": mgnMode,
}
_, err := t.doRequest("POST", "/api/v5/account/set-isolated-mode", body)
if err != nil {
// Ignore error if already in target mode
if strings.Contains(err.Error(), "already") {
logger.Infof(" ✓ %s margin mode is already %s", symbol, mgnMode)
return nil
}
// Cannot change when there are positions
if strings.Contains(err.Error(), "position") {
logger.Infof(" ⚠️ %s has positions, cannot change margin mode", symbol)
return nil
}
return err
}
logger.Infof(" ✓ %s margin mode set to %s", symbol, mgnMode)
return nil
}
// SetLeverage sets leverage
func (t *OKXTrader) SetLeverage(symbol string, leverage int) error {
instId := t.convertSymbol(symbol)
// Set leverage for both long and short
for _, posSide := range []string{"long", "short"} {
body := map[string]interface{}{
"instId": instId,
"lever": strconv.Itoa(leverage),
"mgnMode": "cross",
"posSide": posSide,
}
_, err := t.doRequest("POST", okxLeveragePath, body)
if err != nil {
// Ignore if already at target leverage
if strings.Contains(err.Error(), "same") {
continue
}
logger.Infof(" ⚠️ Failed to set %s %s leverage: %v", symbol, posSide, err)
}
}
logger.Infof(" ✓ %s leverage set to %dx", symbol, leverage)
return nil
}
// GetMarketPrice gets market price
func (t *OKXTrader) GetMarketPrice(symbol string) (float64, error) {
instId := t.convertSymbol(symbol)
path := fmt.Sprintf("%s?instId=%s", okxTickerPath, instId)
data, err := t.doRequest("GET", path, nil)
if err != nil {
return 0, fmt.Errorf("failed to get price: %w", err)
}
var tickers []struct {
Last string `json:"last"`
}
if err := json.Unmarshal(data, &tickers); err != nil {
return 0, err
}
if len(tickers) == 0 {
return 0, fmt.Errorf("no price data received")
}
price, err := strconv.ParseFloat(tickers[0].Last, 64)
if err != nil {
return 0, err
}
return price, nil
}
// GetClosedPnL retrieves closed position PnL records from OKX
// OKX API: /api/v5/account/positions-history
func (t *OKXTrader) GetClosedPnL(startTime time.Time, limit int) ([]types.ClosedPnLRecord, error) {
if limit <= 0 {
limit = 100
}
if limit > 100 {
limit = 100
}
// Build query path with parameters
path := fmt.Sprintf("/api/v5/account/positions-history?instType=SWAP&limit=%d", limit)
if !startTime.IsZero() {
path += fmt.Sprintf("&after=%d", startTime.UnixMilli())
}
data, err := t.doRequest("GET", path, nil)
if err != nil {
return nil, fmt.Errorf("failed to get positions history: %w", err)
}
var resp struct {
Code string `json:"code"`
Msg string `json:"msg"`
Data []struct {
InstID string `json:"instId"` // Instrument ID (e.g., "BTC-USDT-SWAP")
Direction string `json:"direction"` // Position direction: "long" or "short"
OpenAvgPx string `json:"openAvgPx"` // Average open price
CloseAvgPx string `json:"closeAvgPx"` // Average close price
CloseTotalPos string `json:"closeTotalPos"` // Closed position quantity
RealizedPnl string `json:"realizedPnl"` // Realized PnL
Fee string `json:"fee"` // Total fee
FundingFee string `json:"fundingFee"` // Funding fee
Lever string `json:"lever"` // Leverage
CTime string `json:"cTime"` // Position open time
UTime string `json:"uTime"` // Position close time
Type string `json:"type"` // Close type: 1=close position, 2=partial close, 3=liquidation, 4=partial liquidation
PosId string `json:"posId"` // Position ID
} `json:"data"`
}
if err := json.Unmarshal(data, &resp); err != nil {
return nil, fmt.Errorf("failed to parse response: %w", err)
}
if resp.Code != "0" {
return nil, fmt.Errorf("OKX API error: %s - %s", resp.Code, resp.Msg)
}
records := make([]types.ClosedPnLRecord, 0, len(resp.Data))
for _, pos := range resp.Data {
record := types.ClosedPnLRecord{}
// Convert instrument ID to standard format (BTC-USDT-SWAP -> BTCUSDT)
parts := strings.Split(pos.InstID, "-")
if len(parts) >= 2 {
record.Symbol = parts[0] + parts[1]
} else {
record.Symbol = pos.InstID
}
// Side
record.Side = pos.Direction // OKX already returns "long" or "short"
// Prices
record.EntryPrice, _ = strconv.ParseFloat(pos.OpenAvgPx, 64)
record.ExitPrice, _ = strconv.ParseFloat(pos.CloseAvgPx, 64)
// Quantity
record.Quantity, _ = strconv.ParseFloat(pos.CloseTotalPos, 64)
// PnL
record.RealizedPnL, _ = strconv.ParseFloat(pos.RealizedPnl, 64)
// Fee
fee, _ := strconv.ParseFloat(pos.Fee, 64)
fundingFee, _ := strconv.ParseFloat(pos.FundingFee, 64)
record.Fee = -fee + fundingFee // Fee is negative in OKX
// Leverage
lev, _ := strconv.ParseFloat(pos.Lever, 64)
record.Leverage = int(lev)
// Times
cTime, _ := strconv.ParseInt(pos.CTime, 10, 64)
uTime, _ := strconv.ParseInt(pos.UTime, 10, 64)
record.EntryTime = time.UnixMilli(cTime).UTC()
record.ExitTime = time.UnixMilli(uTime).UTC()
// Close type
switch pos.Type {
case "1", "2":
record.CloseType = "unknown" // Could be manual or AI, need to cross-reference
case "3", "4":
record.CloseType = "liquidation"
default:
record.CloseType = "unknown"
}
// Exchange ID
record.ExchangeID = pos.PosId
records = append(records, record)
}
return records, nil
}

938
trader/okx/trader_orders.go Normal file
View File

@@ -0,0 +1,938 @@
package okx
import (
"encoding/json"
"fmt"
"nofx/logger"
"nofx/trader/types"
"strconv"
"strings"
)
// OpenLong opens long position
func (t *OKXTrader) OpenLong(symbol string, quantity float64, leverage int) (map[string]interface{}, error) {
// Cancel old orders
t.CancelAllOrders(symbol)
// Set leverage
if err := t.SetLeverage(symbol, leverage); err != nil {
logger.Infof(" ⚠️ Failed to set leverage: %v", err)
}
instId := t.convertSymbol(symbol)
// Get instrument info and calculate contract size
inst, err := t.getInstrument(symbol)
if err != nil {
return nil, fmt.Errorf("failed to get instrument info: %w", err)
}
// OKX uses contract count, need to convert quantity (in base asset) to contract count
// sz = quantity / ctVal (number of contracts = asset amount / asset per contract)
sz := quantity / inst.CtVal
szStr := t.formatSize(sz, inst)
logger.Infof(" 📊 OKX OpenLong: quantity=%.6f, ctVal=%.6f, contracts=%.2f", quantity, inst.CtVal, sz)
// Check max market order size limit
if inst.MaxMktSz > 0 && sz > inst.MaxMktSz {
logger.Infof(" ⚠️ OKX market order size %.2f exceeds max %.2f, reducing to max", sz, inst.MaxMktSz)
sz = inst.MaxMktSz
szStr = t.formatSize(sz, inst)
}
body := map[string]interface{}{
"instId": instId,
"tdMode": "cross",
"side": "buy",
"posSide": "long",
"ordType": "market",
"sz": szStr,
"clOrdId": genOkxClOrdID(),
"tag": okxTag,
}
data, err := t.doRequest("POST", okxOrderPath, body)
if err != nil {
return nil, fmt.Errorf("failed to open long position: %w", err)
}
var orders []struct {
OrdId string `json:"ordId"`
ClOrdId string `json:"clOrdId"`
SCode string `json:"sCode"`
SMsg string `json:"sMsg"`
}
if err := json.Unmarshal(data, &orders); err != nil {
return nil, fmt.Errorf("failed to parse order response: %w", err)
}
if len(orders) == 0 || orders[0].SCode != "0" {
msg := "unknown error"
if len(orders) > 0 {
msg = orders[0].SMsg
}
return nil, fmt.Errorf("failed to open long position: %s", msg)
}
logger.Infof("✓ OKX opened long position successfully: %s size: %s", symbol, szStr)
logger.Infof(" Order ID: %s", orders[0].OrdId)
return map[string]interface{}{
"orderId": orders[0].OrdId,
"symbol": symbol,
"status": "FILLED",
}, nil
}
// OpenShort opens short position
func (t *OKXTrader) OpenShort(symbol string, quantity float64, leverage int) (map[string]interface{}, error) {
// Cancel old orders
t.CancelAllOrders(symbol)
// Set leverage
if err := t.SetLeverage(symbol, leverage); err != nil {
logger.Infof(" ⚠️ Failed to set leverage: %v", err)
}
instId := t.convertSymbol(symbol)
// Get instrument info and calculate contract size
inst, err := t.getInstrument(symbol)
if err != nil {
return nil, fmt.Errorf("failed to get instrument info: %w", err)
}
// OKX uses contract count, need to convert quantity (in base asset) to contract count
// sz = quantity / ctVal (number of contracts = asset amount / asset per contract)
sz := quantity / inst.CtVal
szStr := t.formatSize(sz, inst)
logger.Infof(" 📊 OKX OpenShort: quantity=%.6f, ctVal=%.6f, contracts=%.2f", quantity, inst.CtVal, sz)
// Check max market order size limit
if inst.MaxMktSz > 0 && sz > inst.MaxMktSz {
logger.Infof(" ⚠️ OKX market order size %.2f exceeds max %.2f, reducing to max", sz, inst.MaxMktSz)
sz = inst.MaxMktSz
szStr = t.formatSize(sz, inst)
}
body := map[string]interface{}{
"instId": instId,
"tdMode": "cross",
"side": "sell",
"posSide": "short",
"ordType": "market",
"sz": szStr,
"clOrdId": genOkxClOrdID(),
"tag": okxTag,
}
data, err := t.doRequest("POST", okxOrderPath, body)
if err != nil {
return nil, fmt.Errorf("failed to open short position: %w", err)
}
var orders []struct {
OrdId string `json:"ordId"`
ClOrdId string `json:"clOrdId"`
SCode string `json:"sCode"`
SMsg string `json:"sMsg"`
}
if err := json.Unmarshal(data, &orders); err != nil {
return nil, fmt.Errorf("failed to parse order response: %w", err)
}
if len(orders) == 0 || orders[0].SCode != "0" {
msg := "unknown error"
if len(orders) > 0 {
msg = orders[0].SMsg
}
return nil, fmt.Errorf("failed to open short position: %s", msg)
}
logger.Infof("✓ OKX opened short position successfully: %s size: %s", symbol, szStr)
logger.Infof(" Order ID: %s", orders[0].OrdId)
return map[string]interface{}{
"orderId": orders[0].OrdId,
"symbol": symbol,
"status": "FILLED",
}, nil
}
// CloseLong closes long position
func (t *OKXTrader) CloseLong(symbol string, quantity float64) (map[string]interface{}, error) {
instId := t.convertSymbol(symbol)
// Get instrument info for contract conversion
inst, err := t.getInstrument(symbol)
if err != nil {
return nil, fmt.Errorf("failed to get instrument info: %w", err)
}
// Invalidate position cache and get fresh positions
t.InvalidatePositionCache()
positions, err := t.GetPositions()
if err != nil {
return nil, fmt.Errorf("failed to get positions: %w", err)
}
// Find actual position from exchange
var actualQty float64
var posFound bool
var posMgnMode string = "cross" // Default to cross margin
logger.Infof("🔍 OKX CloseLong: searching for symbol=%s in %d positions", symbol, len(positions))
for _, pos := range positions {
logger.Infof("🔍 OKX position: symbol=%v, side=%v, positionAmt=%v, mgnMode=%v", pos["symbol"], pos["side"], pos["positionAmt"], pos["mgnMode"])
if pos["symbol"] == symbol {
side := pos["side"].(string)
// In net_mode, "long" means positive position
// In dual mode, check explicit "long" side
if side == "long" || (t.positionMode == "net_mode" && side == "long") {
actualQty = pos["positionAmt"].(float64)
posFound = true
if mgnMode, ok := pos["mgnMode"].(string); ok && mgnMode != "" {
posMgnMode = mgnMode
}
logger.Infof("🔍 OKX CloseLong: found matching position! qty=%.6f, mgnMode=%s", actualQty, posMgnMode)
break
}
}
}
if !posFound || actualQty == 0 {
logger.Infof("🔍 OKX CloseLong: NO position found for %s LONG", symbol)
return map[string]interface{}{
"status": "NO_POSITION",
"message": fmt.Sprintf("No long position found for %s on OKX", symbol),
}, nil
}
// Use actual quantity from exchange (more accurate than passed quantity)
if quantity == 0 || quantity > actualQty {
quantity = actualQty
}
// Convert quantity (base asset) to contract count
// contracts = quantity / ctVal
contracts := quantity / inst.CtVal
szStr := t.formatSize(contracts, inst)
logger.Infof("🔻 OKX close long: symbol=%s, instId=%s, quantity=%.6f, ctVal=%.6f, contracts=%.2f, szStr=%s, posMode=%s, mgnMode=%s",
symbol, instId, quantity, inst.CtVal, contracts, szStr, t.positionMode, posMgnMode)
body := map[string]interface{}{
"instId": instId,
"tdMode": posMgnMode, // Use position's actual margin mode (cross or isolated)
"side": "sell",
"ordType": "market",
"sz": szStr,
"clOrdId": genOkxClOrdID(),
"tag": okxTag,
}
// Only add posSide in dual mode (long_short_mode)
if t.positionMode == "long_short_mode" {
body["posSide"] = "long"
}
data, err := t.doRequest("POST", okxOrderPath, body)
if err != nil {
return nil, fmt.Errorf("failed to close long position: %w", err)
}
var orders []struct {
OrdId string `json:"ordId"`
SCode string `json:"sCode"`
SMsg string `json:"sMsg"`
}
if err := json.Unmarshal(data, &orders); err != nil {
return nil, err
}
if len(orders) == 0 || orders[0].SCode != "0" {
msg := "unknown error"
if len(orders) > 0 {
msg = orders[0].SMsg
}
return nil, fmt.Errorf("failed to close long position: %s", msg)
}
logger.Infof("✓ OKX closed long position successfully: %s", symbol)
// Cancel pending orders after closing position
t.CancelAllOrders(symbol)
return map[string]interface{}{
"orderId": orders[0].OrdId,
"symbol": symbol,
"status": "FILLED",
}, nil
}
// CloseShort closes short position
func (t *OKXTrader) CloseShort(symbol string, quantity float64) (map[string]interface{}, error) {
instId := t.convertSymbol(symbol)
// Get instrument info for contract conversion
inst, err := t.getInstrument(symbol)
if err != nil {
return nil, fmt.Errorf("failed to get instrument info: %w", err)
}
// Invalidate position cache and get fresh positions
t.InvalidatePositionCache()
positions, err := t.GetPositions()
if err != nil {
return nil, fmt.Errorf("failed to get positions: %w", err)
}
// Find actual position from exchange
var actualQty float64
var posFound bool
var posMgnMode string = "cross" // Default to cross margin
logger.Infof("🔍 OKX CloseShort searching positions: symbol=%s, current position count=%d", symbol, len(positions))
for _, pos := range positions {
logger.Infof("🔍 OKX position: symbol=%v, side=%v, positionAmt=%v, mgnMode=%v",
pos["symbol"], pos["side"], pos["positionAmt"], pos["mgnMode"])
if pos["symbol"] == symbol && pos["side"] == "short" {
actualQty = pos["positionAmt"].(float64)
posFound = true
if mgnMode, ok := pos["mgnMode"].(string); ok && mgnMode != "" {
posMgnMode = mgnMode
}
logger.Infof("🔍 OKX found short position: quantity=%f (base asset), mgnMode=%s", actualQty, posMgnMode)
break
}
}
if !posFound || actualQty == 0 {
return map[string]interface{}{
"status": "NO_POSITION",
"message": fmt.Sprintf("No short position found for %s on OKX", symbol),
}, nil
}
// Use actual quantity from exchange (more accurate than passed quantity)
if quantity == 0 || quantity > actualQty {
quantity = actualQty
}
// Ensure quantity is positive (OKX sz parameter must be positive)
if quantity < 0 {
quantity = -quantity
}
// Convert quantity (base asset) to contract count
// contracts = quantity / ctVal
contracts := quantity / inst.CtVal
szStr := t.formatSize(contracts, inst)
logger.Infof("🔻 OKX close short: symbol=%s, quantity=%.6f, ctVal=%.6f, contracts=%.2f, szStr=%s, posMode=%s, mgnMode=%s",
symbol, quantity, inst.CtVal, contracts, szStr, t.positionMode, posMgnMode)
body := map[string]interface{}{
"instId": instId,
"tdMode": posMgnMode, // Use position's actual margin mode (cross or isolated)
"side": "buy",
"ordType": "market",
"sz": szStr,
"clOrdId": genOkxClOrdID(),
"tag": okxTag,
}
// Only add posSide in dual mode (long_short_mode)
if t.positionMode == "long_short_mode" {
body["posSide"] = "short"
}
logger.Infof("🔻 OKX close short request body: %+v", body)
data, err := t.doRequest("POST", okxOrderPath, body)
if err != nil {
return nil, fmt.Errorf("failed to close short position: %w", err)
}
var orders []struct {
OrdId string `json:"ordId"`
SCode string `json:"sCode"`
SMsg string `json:"sMsg"`
}
if err := json.Unmarshal(data, &orders); err != nil {
return nil, err
}
if len(orders) == 0 || orders[0].SCode != "0" {
msg := "unknown error"
if len(orders) > 0 {
msg = fmt.Sprintf("sCode=%s, sMsg=%s", orders[0].SCode, orders[0].SMsg)
}
logger.Infof("❌ OKX failed to close short position: %s, response: %s", msg, string(data))
return nil, fmt.Errorf("failed to close short position: %s", msg)
}
logger.Infof("✓ OKX closed short position successfully: %s, ordId=%s", symbol, orders[0].OrdId)
// Cancel pending orders after closing position
t.CancelAllOrders(symbol)
return map[string]interface{}{
"orderId": orders[0].OrdId,
"symbol": symbol,
"status": "FILLED",
}, nil
}
// SetStopLoss sets stop loss order
func (t *OKXTrader) SetStopLoss(symbol string, positionSide string, quantity, stopPrice float64) error {
instId := t.convertSymbol(symbol)
// Get instrument info
inst, err := t.getInstrument(symbol)
if err != nil {
return fmt.Errorf("failed to get instrument info: %w", err)
}
// Calculate contract size: quantity (in base asset) / ctVal (asset per contract)
sz := quantity / inst.CtVal
szStr := t.formatSize(sz, inst)
// Determine direction
side := "sell"
posSide := "long"
if strings.ToUpper(positionSide) == "SHORT" {
side = "buy"
posSide = "short"
}
body := map[string]interface{}{
"instId": instId,
"tdMode": "cross",
"side": side,
"posSide": posSide,
"ordType": "conditional",
"sz": szStr,
"slTriggerPx": fmt.Sprintf("%.8f", stopPrice),
"slOrdPx": "-1", // Market price
"tag": okxTag,
}
_, err = t.doRequest("POST", okxAlgoOrderPath, body)
if err != nil {
return fmt.Errorf("failed to set stop loss: %w", err)
}
logger.Infof(" Stop loss price set: %.4f", stopPrice)
return nil
}
// SetTakeProfit sets take profit order
func (t *OKXTrader) SetTakeProfit(symbol string, positionSide string, quantity, takeProfitPrice float64) error {
instId := t.convertSymbol(symbol)
// Get instrument info
inst, err := t.getInstrument(symbol)
if err != nil {
return fmt.Errorf("failed to get instrument info: %w", err)
}
// Calculate contract size: quantity (in base asset) / ctVal (asset per contract)
sz := quantity / inst.CtVal
szStr := t.formatSize(sz, inst)
// Determine direction
side := "sell"
posSide := "long"
if strings.ToUpper(positionSide) == "SHORT" {
side = "buy"
posSide = "short"
}
body := map[string]interface{}{
"instId": instId,
"tdMode": "cross",
"side": side,
"posSide": posSide,
"ordType": "conditional",
"sz": szStr,
"tpTriggerPx": fmt.Sprintf("%.8f", takeProfitPrice),
"tpOrdPx": "-1", // Market price
"tag": okxTag,
}
_, err = t.doRequest("POST", okxAlgoOrderPath, body)
if err != nil {
return fmt.Errorf("failed to set take profit: %w", err)
}
logger.Infof(" Take profit price set: %.4f", takeProfitPrice)
return nil
}
// CancelStopLossOrders cancels stop loss orders
func (t *OKXTrader) CancelStopLossOrders(symbol string) error {
return t.cancelAlgoOrders(symbol, "sl")
}
// CancelTakeProfitOrders cancels take profit orders
func (t *OKXTrader) CancelTakeProfitOrders(symbol string) error {
return t.cancelAlgoOrders(symbol, "tp")
}
// cancelAlgoOrders cancels algo orders
func (t *OKXTrader) cancelAlgoOrders(symbol string, orderType string) error {
instId := t.convertSymbol(symbol)
// Get pending algo orders
path := fmt.Sprintf("%s?instType=SWAP&instId=%s&ordType=conditional", okxAlgoPendingPath, instId)
data, err := t.doRequest("GET", path, nil)
if err != nil {
return err
}
var orders []struct {
AlgoId string `json:"algoId"`
InstId string `json:"instId"`
}
if err := json.Unmarshal(data, &orders); err != nil {
return err
}
canceledCount := 0
for _, order := range orders {
body := []map[string]interface{}{
{
"algoId": order.AlgoId,
"instId": order.InstId,
},
}
_, err := t.doRequest("POST", okxCancelAlgoPath, body)
if err != nil {
logger.Infof(" ⚠️ Failed to cancel algo order: %v", err)
continue
}
canceledCount++
}
if canceledCount > 0 {
logger.Infof(" ✓ Canceled %d algo orders for %s", canceledCount, symbol)
}
return nil
}
// CancelAllOrders cancels all pending orders
func (t *OKXTrader) CancelAllOrders(symbol string) error {
instId := t.convertSymbol(symbol)
// Get pending orders
path := fmt.Sprintf("%s?instType=SWAP&instId=%s", okxPendingOrdersPath, instId)
data, err := t.doRequest("GET", path, nil)
if err != nil {
return err
}
var orders []struct {
OrdId string `json:"ordId"`
InstId string `json:"instId"`
}
if err := json.Unmarshal(data, &orders); err != nil {
return err
}
// Batch cancel
for _, order := range orders {
body := map[string]interface{}{
"instId": order.InstId,
"ordId": order.OrdId,
}
t.doRequest("POST", okxCancelOrderPath, body)
}
// Also cancel algo orders
t.cancelAlgoOrders(symbol, "")
if len(orders) > 0 {
logger.Infof(" ✓ Canceled all pending orders for %s", symbol)
}
return nil
}
// CancelStopOrders cancels stop loss and take profit orders
func (t *OKXTrader) CancelStopOrders(symbol string) error {
return t.cancelAlgoOrders(symbol, "")
}
// GetOrderStatus gets order status
func (t *OKXTrader) GetOrderStatus(symbol string, orderID string) (map[string]interface{}, error) {
instId := t.convertSymbol(symbol)
path := fmt.Sprintf("/api/v5/trade/order?instId=%s&ordId=%s", instId, orderID)
data, err := t.doRequest("GET", path, nil)
if err != nil {
return nil, fmt.Errorf("failed to get order status: %w", err)
}
var orders []struct {
OrdId string `json:"ordId"`
State string `json:"state"`
AvgPx string `json:"avgPx"`
AccFillSz string `json:"accFillSz"`
Fee string `json:"fee"`
Side string `json:"side"`
OrdType string `json:"ordType"`
CTime string `json:"cTime"`
UTime string `json:"uTime"`
}
if err := json.Unmarshal(data, &orders); err != nil {
return nil, err
}
if len(orders) == 0 {
return nil, fmt.Errorf("order not found")
}
order := orders[0]
avgPrice, _ := strconv.ParseFloat(order.AvgPx, 64)
fillSz, _ := strconv.ParseFloat(order.AccFillSz, 64) // This is in contracts
fee, _ := strconv.ParseFloat(order.Fee, 64)
cTime, _ := strconv.ParseInt(order.CTime, 10, 64)
uTime, _ := strconv.ParseInt(order.UTime, 10, 64)
// Convert contract count to base asset quantity
// executedQty = contracts * ctVal
executedQty := fillSz
inst, err := t.getInstrument(symbol)
if err == nil && inst.CtVal > 0 {
executedQty = fillSz * inst.CtVal
logger.Debugf(" 📊 OKX order %s: fillSz(contracts)=%.4f, ctVal=%.6f, executedQty=%.6f", orderID, fillSz, inst.CtVal, executedQty)
}
// Status mapping
statusMap := map[string]string{
"filled": "FILLED",
"live": "NEW",
"partially_filled": "PARTIALLY_FILLED",
"canceled": "CANCELED",
}
status := statusMap[order.State]
if status == "" {
status = order.State
}
return map[string]interface{}{
"orderId": order.OrdId,
"symbol": symbol,
"status": status,
"avgPrice": avgPrice,
"executedQty": executedQty,
"side": order.Side,
"type": order.OrdType,
"time": cTime,
"updateTime": uTime,
"commission": -fee, // OKX returns negative value
}, nil
}
// GetOpenOrders gets all open/pending orders for a symbol
func (t *OKXTrader) GetOpenOrders(symbol string) ([]types.OpenOrder, error) {
instId := t.convertSymbol(symbol)
var result []types.OpenOrder
// 1. Get pending limit orders
path := fmt.Sprintf("%s?instId=%s&instType=SWAP", okxPendingOrdersPath, instId)
data, err := t.doRequest("GET", path, nil)
if err != nil {
logger.Warnf("[OKX] Failed to get pending orders: %v", err)
}
if err == nil && data != nil {
var orders []struct {
OrdId string `json:"ordId"`
InstId string `json:"instId"`
Side string `json:"side"` // buy/sell
PosSide string `json:"posSide"` // long/short/net
OrdType string `json:"ordType"` // limit/market/post_only
Px string `json:"px"` // price
Sz string `json:"sz"` // size
State string `json:"state"` // live/partially_filled
}
if err := json.Unmarshal(data, &orders); err == nil {
for _, order := range orders {
price, _ := strconv.ParseFloat(order.Px, 64)
quantity, _ := strconv.ParseFloat(order.Sz, 64)
// Convert OKX side to standard format
side := strings.ToUpper(order.Side)
positionSide := strings.ToUpper(order.PosSide)
if positionSide == "NET" {
positionSide = "BOTH"
}
result = append(result, types.OpenOrder{
OrderID: order.OrdId,
Symbol: symbol,
Side: side,
PositionSide: positionSide,
Type: strings.ToUpper(order.OrdType),
Price: price,
StopPrice: 0,
Quantity: quantity,
Status: "NEW",
})
}
}
}
// 2. Get pending algo orders (stop-loss/take-profit)
// OKX requires ordType parameter for algo orders API
algoPath := fmt.Sprintf("%s?instId=%s&instType=SWAP&ordType=conditional", okxAlgoPendingPath, instId)
algoData, err := t.doRequest("GET", algoPath, nil)
if err != nil {
logger.Warnf("[OKX] Failed to get algo orders: %v", err)
}
if err == nil && algoData != nil {
var algoOrders []struct {
AlgoId string `json:"algoId"`
InstId string `json:"instId"`
Side string `json:"side"`
PosSide string `json:"posSide"`
OrdType string `json:"ordType"` // conditional/oco/trigger
TriggerPx string `json:"triggerPx"`
SlTriggerPx string `json:"slTriggerPx"` // Stop loss trigger price
TpTriggerPx string `json:"tpTriggerPx"` // Take profit trigger price
Sz string `json:"sz"`
State string `json:"state"`
}
if err := json.Unmarshal(algoData, &algoOrders); err == nil {
for _, order := range algoOrders {
quantity, _ := strconv.ParseFloat(order.Sz, 64)
side := strings.ToUpper(order.Side)
positionSide := strings.ToUpper(order.PosSide)
if positionSide == "NET" {
positionSide = "BOTH"
}
// Check for stop loss order (slTriggerPx is set)
if order.SlTriggerPx != "" {
slPrice, _ := strconv.ParseFloat(order.SlTriggerPx, 64)
if slPrice > 0 {
result = append(result, types.OpenOrder{
OrderID: order.AlgoId + "_sl",
Symbol: symbol,
Side: side,
PositionSide: positionSide,
Type: "STOP_MARKET",
Price: 0,
StopPrice: slPrice,
Quantity: quantity,
Status: "NEW",
})
}
}
// Check for take profit order (tpTriggerPx is set)
if order.TpTriggerPx != "" {
tpPrice, _ := strconv.ParseFloat(order.TpTriggerPx, 64)
if tpPrice > 0 {
result = append(result, types.OpenOrder{
OrderID: order.AlgoId + "_tp",
Symbol: symbol,
Side: side,
PositionSide: positionSide,
Type: "TAKE_PROFIT_MARKET",
Price: 0,
StopPrice: tpPrice,
Quantity: quantity,
Status: "NEW",
})
}
}
// Fallback for trigger orders (triggerPx is set)
if order.TriggerPx != "" && order.SlTriggerPx == "" && order.TpTriggerPx == "" {
triggerPrice, _ := strconv.ParseFloat(order.TriggerPx, 64)
if triggerPrice > 0 {
result = append(result, types.OpenOrder{
OrderID: order.AlgoId,
Symbol: symbol,
Side: side,
PositionSide: positionSide,
Type: "STOP_MARKET",
Price: 0,
StopPrice: triggerPrice,
Quantity: quantity,
Status: "NEW",
})
}
}
}
}
}
logger.Infof("✓ OKX GetOpenOrders: found %d open orders for %s", len(result), symbol)
return result, nil
}
// PlaceLimitOrder places a limit order for grid trading
// Implements GridTrader interface
func (t *OKXTrader) PlaceLimitOrder(req *types.LimitOrderRequest) (*types.LimitOrderResult, error) {
instId := t.convertSymbol(req.Symbol)
// Get instrument info
inst, err := t.getInstrument(req.Symbol)
if err != nil {
return nil, fmt.Errorf("failed to get instrument info: %w", err)
}
// Set leverage if specified
if req.Leverage > 0 {
if err := t.SetLeverage(req.Symbol, req.Leverage); err != nil {
logger.Warnf("[OKX] Failed to set leverage: %v", err)
}
}
// Convert quantity to contract size
sz := req.Quantity / inst.CtVal
szStr := t.formatSize(sz, inst)
// Determine side and position side
side := "buy"
posSide := "long"
if req.Side == "SELL" {
side = "sell"
posSide = "short"
}
body := map[string]interface{}{
"instId": instId,
"tdMode": "cross",
"side": side,
"posSide": posSide,
"ordType": "limit",
"sz": szStr,
"px": fmt.Sprintf("%.8f", req.Price),
"clOrdId": genOkxClOrdID(),
"tag": okxTag,
}
// Add reduce only if specified
if req.ReduceOnly {
body["reduceOnly"] = true
}
logger.Infof("[OKX] PlaceLimitOrder: %s %s @ %.4f, sz=%s", instId, side, req.Price, szStr)
data, err := t.doRequest("POST", okxOrderPath, body)
if err != nil {
return nil, fmt.Errorf("failed to place limit order: %w", err)
}
var orders []struct {
OrdId string `json:"ordId"`
ClOrdId string `json:"clOrdId"`
SCode string `json:"sCode"`
SMsg string `json:"sMsg"`
}
if err := json.Unmarshal(data, &orders); err != nil {
return nil, fmt.Errorf("failed to parse order response: %w", err)
}
if len(orders) == 0 {
return nil, fmt.Errorf("empty order response")
}
if orders[0].SCode != "0" {
return nil, fmt.Errorf("OKX order failed: %s", orders[0].SMsg)
}
logger.Infof("✓ [OKX] Limit order placed: %s %s @ %.4f, orderID=%s",
instId, side, req.Price, orders[0].OrdId)
return &types.LimitOrderResult{
OrderID: orders[0].OrdId,
ClientID: orders[0].ClOrdId,
Symbol: req.Symbol,
Side: req.Side,
PositionSide: req.PositionSide,
Price: req.Price,
Quantity: req.Quantity,
Status: "NEW",
}, nil
}
// CancelOrder cancels a specific order by ID
// Implements GridTrader interface
func (t *OKXTrader) CancelOrder(symbol, orderID string) error {
instId := t.convertSymbol(symbol)
body := map[string]interface{}{
"instId": instId,
"ordId": orderID,
}
_, err := t.doRequest("POST", "/api/v5/trade/cancel-order", body)
if err != nil {
return fmt.Errorf("failed to cancel order: %w", err)
}
logger.Infof("✓ [OKX] Order cancelled: %s %s", symbol, orderID)
return nil
}
// GetOrderBook gets the order book for a symbol
// Implements GridTrader interface
func (t *OKXTrader) GetOrderBook(symbol string, depth int) (bids, asks [][]float64, err error) {
instId := t.convertSymbol(symbol)
path := fmt.Sprintf("/api/v5/market/books?instId=%s&sz=%d", instId, depth)
data, err := t.doRequest("GET", path, nil)
if err != nil {
return nil, nil, fmt.Errorf("failed to get order book: %w", err)
}
var result []struct {
Bids [][]string `json:"bids"`
Asks [][]string `json:"asks"`
}
if err := json.Unmarshal(data, &result); err != nil {
return nil, nil, fmt.Errorf("failed to parse order book: %w", err)
}
if len(result) == 0 {
return nil, nil, nil
}
// Parse bids
for _, b := range result[0].Bids {
if len(b) >= 2 {
price, _ := strconv.ParseFloat(b[0], 64)
qty, _ := strconv.ParseFloat(b[1], 64)
bids = append(bids, []float64{price, qty})
}
}
// Parse asks
for _, a := range result[0].Asks {
if len(a) >= 2 {
price, _ := strconv.ParseFloat(a[0], 64)
qty, _ := strconv.ParseFloat(a[1], 64)
asks = append(asks, []float64{price, qty})
}
}
return bids, asks, nil
}

View File

@@ -0,0 +1,192 @@
package okx
import (
"encoding/json"
"fmt"
"nofx/logger"
"strconv"
"time"
)
// GetPositions gets all positions
func (t *OKXTrader) GetPositions() ([]map[string]interface{}, error) {
// Check cache
t.positionsCacheMutex.RLock()
if t.cachedPositions != nil && time.Since(t.positionsCacheTime) < t.cacheDuration {
t.positionsCacheMutex.RUnlock()
logger.Infof("✓ Using cached OKX positions")
return t.cachedPositions, nil
}
t.positionsCacheMutex.RUnlock()
logger.Infof("🔄 Calling OKX API to get positions...")
data, err := t.doRequest("GET", okxPositionPath+"?instType=SWAP", nil)
if err != nil {
return nil, fmt.Errorf("failed to get positions: %w", err)
}
var positions []struct {
InstId string `json:"instId"`
PosSide string `json:"posSide"`
Pos string `json:"pos"`
AvgPx string `json:"avgPx"`
MarkPx string `json:"markPx"`
Upl string `json:"upl"`
Lever string `json:"lever"`
LiqPx string `json:"liqPx"`
Margin string `json:"margin"`
MgnMode string `json:"mgnMode"` // Margin mode: "cross" or "isolated"
CTime string `json:"cTime"` // Position created time (ms)
UTime string `json:"uTime"` // Position last update time (ms)
}
if err := json.Unmarshal(data, &positions); err != nil {
return nil, fmt.Errorf("failed to parse position data: %w", err)
}
logger.Infof("🔍 OKX raw positions response: %d positions", len(positions))
var result []map[string]interface{}
for _, pos := range positions {
logger.Infof("🔍 OKX raw position: instId=%s, posSide=%s, pos=%s, mgnMode=%s", pos.InstId, pos.PosSide, pos.Pos, pos.MgnMode)
contractCount, _ := strconv.ParseFloat(pos.Pos, 64)
if contractCount == 0 {
continue
}
entryPrice, _ := strconv.ParseFloat(pos.AvgPx, 64)
markPrice, _ := strconv.ParseFloat(pos.MarkPx, 64)
upl, _ := strconv.ParseFloat(pos.Upl, 64)
leverage, _ := strconv.ParseFloat(pos.Lever, 64)
liqPrice, _ := strconv.ParseFloat(pos.LiqPx, 64)
// Convert symbol format
symbol := t.convertSymbolBack(pos.InstId)
logger.Infof("🔍 OKX symbol conversion: %s → %s", pos.InstId, symbol)
// Determine direction and ensure contractCount is positive
side := "long"
if pos.PosSide == "short" {
side = "short"
}
// OKX short position's pos is negative, need to take absolute value
if contractCount < 0 {
contractCount = -contractCount
}
// Convert contract count to actual position amount (in base asset)
// positionAmt = contractCount * ctVal
inst, err := t.getInstrument(symbol)
posAmt := contractCount
if err == nil && inst.CtVal > 0 {
posAmt = contractCount * inst.CtVal
logger.Debugf(" 📊 OKX position %s: contracts=%.4f, ctVal=%.6f, posAmt=%.6f", symbol, contractCount, inst.CtVal, posAmt)
}
// Parse timestamps
cTime, _ := strconv.ParseInt(pos.CTime, 10, 64)
uTime, _ := strconv.ParseInt(pos.UTime, 10, 64)
// Default to cross margin mode if not specified
mgnMode := pos.MgnMode
if mgnMode == "" {
mgnMode = "cross"
}
posMap := map[string]interface{}{
"symbol": symbol,
"positionAmt": posAmt,
"entryPrice": entryPrice,
"markPrice": markPrice,
"unRealizedProfit": upl,
"leverage": leverage,
"liquidationPrice": liqPrice,
"side": side,
"mgnMode": mgnMode, // Margin mode: "cross" or "isolated"
"createdTime": cTime, // Position open time (ms)
"updatedTime": uTime, // Position last update time (ms)
}
result = append(result, posMap)
}
// Update cache
t.positionsCacheMutex.Lock()
t.cachedPositions = result
t.positionsCacheTime = time.Now()
t.positionsCacheMutex.Unlock()
return result, nil
}
// InvalidatePositionCache clears the position cache to force fresh data on next call
func (t *OKXTrader) InvalidatePositionCache() {
t.positionsCacheMutex.Lock()
t.cachedPositions = nil
t.positionsCacheTime = time.Time{}
t.positionsCacheMutex.Unlock()
}
// getInstrument gets instrument info
func (t *OKXTrader) getInstrument(symbol string) (*OKXInstrument, error) {
instId := t.convertSymbol(symbol)
// Check cache
t.instrumentsCacheMutex.RLock()
if inst, ok := t.instrumentsCache[instId]; ok && time.Since(t.instrumentsCacheTime) < 5*time.Minute {
t.instrumentsCacheMutex.RUnlock()
return inst, nil
}
t.instrumentsCacheMutex.RUnlock()
// Get instrument info
path := fmt.Sprintf("%s?instType=SWAP&instId=%s", okxInstrumentsPath, instId)
data, err := t.doRequest("GET", path, nil)
if err != nil {
return nil, err
}
var instruments []struct {
InstId string `json:"instId"`
CtVal string `json:"ctVal"`
CtMult string `json:"ctMult"`
LotSz string `json:"lotSz"`
MinSz string `json:"minSz"`
MaxMktSz string `json:"maxMktSz"` // Maximum market order size
TickSz string `json:"tickSz"`
CtType string `json:"ctType"`
}
if err := json.Unmarshal(data, &instruments); err != nil {
return nil, err
}
if len(instruments) == 0 {
return nil, fmt.Errorf("instrument info not found: %s", instId)
}
inst := instruments[0]
ctVal, _ := strconv.ParseFloat(inst.CtVal, 64)
ctMult, _ := strconv.ParseFloat(inst.CtMult, 64)
lotSz, _ := strconv.ParseFloat(inst.LotSz, 64)
minSz, _ := strconv.ParseFloat(inst.MinSz, 64)
maxMktSz, _ := strconv.ParseFloat(inst.MaxMktSz, 64)
tickSz, _ := strconv.ParseFloat(inst.TickSz, 64)
instrument := &OKXInstrument{
InstID: inst.InstId,
CtVal: ctVal,
CtMult: ctMult,
LotSz: lotSz,
MinSz: minSz,
MaxMktSz: maxMktSz,
TickSz: tickSz,
CtType: inst.CtType,
}
// Update cache
t.instrumentsCacheMutex.Lock()
t.instrumentsCache[instId] = instrument
t.instrumentsCacheTime = time.Now()
t.instrumentsCacheMutex.Unlock()
return instrument, nil
}

View File

@@ -0,0 +1,433 @@
import { useEffect, useMemo, useState, useRef } from 'react'
import { motion } from 'framer-motion'
import {
createChart,
ColorType,
CrosshairMode,
CandlestickSeries,
createSeriesMarkers,
type IChartApi,
type ISeriesApi,
type CandlestickData,
type UTCTimestamp,
type SeriesMarker,
} from 'lightweight-charts'
import {
ResponsiveContainer,
AreaChart,
Area,
XAxis,
YAxis,
CartesianGrid,
Tooltip,
ReferenceDot,
} from 'recharts'
import {
Clock,
AlertTriangle,
RefreshCw,
CandlestickChart as CandlestickIcon,
} from 'lucide-react'
import { api } from '../../lib/api'
import type {
BacktestEquityPoint,
BacktestTradeEvent,
BacktestKlinesResponse,
} from '../../types'
// ============ Equity Chart (Recharts) ============
interface EquityChartProps {
equity: BacktestEquityPoint[]
trades: BacktestTradeEvent[]
}
export function EquityChart({ equity, trades }: EquityChartProps) {
const chartData = useMemo(() => {
return equity.map((point) => ({
time: new Date(point.ts).toLocaleString(),
ts: point.ts,
equity: point.equity,
pnl_pct: point.pnl_pct,
}))
}, [equity])
const tradeMarkers = useMemo(() => {
if (!trades.length || !equity.length) return []
return trades
.filter((t) => t.action.includes('open') || t.action.includes('close'))
.map((trade) => {
const closest = equity.reduce((prev, curr) =>
Math.abs(curr.ts - trade.ts) < Math.abs(prev.ts - trade.ts) ? curr : prev
)
return {
ts: closest.ts,
equity: closest.equity,
action: trade.action,
symbol: trade.symbol,
isOpen: trade.action.includes('open'),
}
})
.slice(-30)
}, [trades, equity])
return (
<div className="w-full h-[300px]">
<ResponsiveContainer width="100%" height="100%">
<AreaChart data={chartData} margin={{ top: 10, right: 10, left: 0, bottom: 0 }}>
<defs>
<linearGradient id="equityGradient" x1="0" y1="0" x2="0" y2="1">
<stop offset="5%" stopColor="#F0B90B" stopOpacity={0.4} />
<stop offset="95%" stopColor="#F0B90B" stopOpacity={0} />
</linearGradient>
</defs>
<CartesianGrid stroke="rgba(43, 49, 57, 0.5)" strokeDasharray="3 3" />
<XAxis
dataKey="time"
tick={{ fill: '#848E9C', fontSize: 10 }}
axisLine={{ stroke: '#2B3139' }}
tickLine={{ stroke: '#2B3139' }}
hide
/>
<YAxis
tick={{ fill: '#848E9C', fontSize: 10 }}
axisLine={{ stroke: '#2B3139' }}
tickLine={{ stroke: '#2B3139' }}
width={60}
domain={['auto', 'auto']}
/>
<Tooltip
contentStyle={{
background: '#1E2329',
border: '1px solid #2B3139',
borderRadius: 8,
color: '#EAECEF',
}}
labelStyle={{ color: '#848E9C' }}
formatter={(value: number) => [`$${value.toFixed(2)}`, 'Equity']}
/>
<Area
type="monotone"
dataKey="equity"
stroke="#F0B90B"
strokeWidth={2}
fill="url(#equityGradient)"
dot={false}
activeDot={{ r: 4, fill: '#F0B90B' }}
/>
{tradeMarkers.map((marker, idx) => (
<ReferenceDot
key={`${marker.ts}-${idx}`}
x={chartData.findIndex((d) => d.ts === marker.ts)}
y={marker.equity}
r={4}
fill={marker.isOpen ? '#0ECB81' : '#F6465D'}
stroke={marker.isOpen ? '#0ECB81' : '#F6465D'}
/>
))}
</AreaChart>
</ResponsiveContainer>
</div>
)
}
// ============ Candlestick Chart with Trade Markers ============
interface CandlestickChartProps {
runId: string
trades: BacktestTradeEvent[]
language: string
}
export function CandlestickChartComponent({ runId, trades, language }: CandlestickChartProps) {
const chartContainerRef = useRef<HTMLDivElement>(null)
const chartRef = useRef<IChartApi | null>(null)
const candleSeriesRef = useRef<ISeriesApi<'Candlestick'> | null>(null)
const symbols = useMemo(() => {
const symbolSet = new Set(trades.map((t) => t.symbol))
return Array.from(symbolSet).sort()
}, [trades])
const [selectedSymbol, setSelectedSymbol] = useState<string>(symbols[0] || '')
const [selectedTimeframe, setSelectedTimeframe] = useState<string>('15m')
const [isLoading, setIsLoading] = useState(false)
const [error, setError] = useState<string | null>(null)
const CHART_TIMEFRAMES = ['1m', '3m', '5m', '15m', '30m', '1h', '4h', '1d']
useEffect(() => {
if (symbols.length > 0 && !symbols.includes(selectedSymbol)) {
setSelectedSymbol(symbols[0])
}
}, [symbols, selectedSymbol])
const symbolTrades = useMemo(() => {
return trades.filter((t) => t.symbol === selectedSymbol)
}, [trades, selectedSymbol])
useEffect(() => {
if (!chartContainerRef.current || !selectedSymbol || !runId) return
const container = chartContainerRef.current
const chart = createChart(container, {
layout: {
background: { type: ColorType.Solid, color: '#0B0E11' },
textColor: '#848E9C',
},
grid: {
vertLines: { color: 'rgba(43, 49, 57, 0.5)' },
horzLines: { color: 'rgba(43, 49, 57, 0.5)' },
},
crosshair: {
mode: CrosshairMode.Normal,
},
rightPriceScale: {
borderColor: '#2B3139',
},
timeScale: {
borderColor: '#2B3139',
timeVisible: true,
secondsVisible: false,
},
width: container.clientWidth,
height: 400,
})
chartRef.current = chart
const candleSeries = chart.addSeries(CandlestickSeries, {
upColor: '#0ECB81',
downColor: '#F6465D',
borderUpColor: '#0ECB81',
borderDownColor: '#F6465D',
wickUpColor: '#0ECB81',
wickDownColor: '#F6465D',
})
candleSeriesRef.current = candleSeries
setIsLoading(true)
setError(null)
api
.getBacktestKlines(runId, selectedSymbol, selectedTimeframe)
.then((data: BacktestKlinesResponse) => {
const klineData: CandlestickData<UTCTimestamp>[] = data.klines.map((k) => ({
time: k.time as UTCTimestamp,
open: k.open,
high: k.high,
low: k.low,
close: k.close,
}))
candleSeries.setData(klineData)
const markers: SeriesMarker<UTCTimestamp>[] = symbolTrades
.map((trade) => {
const tradeTime = Math.floor(trade.ts / 1000)
const closestKline = data.klines.reduce((prev, curr) =>
Math.abs(curr.time - tradeTime) < Math.abs(prev.time - tradeTime) ? curr : prev
)
const isOpen = trade.action.includes('open')
const isLong = trade.side === 'long' || trade.action.includes('long')
const pnl = trade.realized_pnl
let text = ''
let color = '#0ECB81'
if (isOpen) {
if (isLong) {
text = `▲ Long @${trade.price.toFixed(2)}`
color = '#0ECB81'
} else {
text = `▼ Short @${trade.price.toFixed(2)}`
color = '#F6465D'
}
} else {
const pnlStr = pnl >= 0 ? `+$${pnl.toFixed(2)}` : `-$${Math.abs(pnl).toFixed(2)}`
text = `${pnlStr}`
color = pnl >= 0 ? '#0ECB81' : '#F6465D'
}
return {
time: closestKline.time as UTCTimestamp,
position: isOpen
? (isLong ? 'belowBar' as const : 'aboveBar' as const)
: (isLong ? 'aboveBar' as const : 'belowBar' as const),
color,
shape: 'circle' as const,
size: 2,
text,
}
})
.sort((a, b) => (a.time as number) - (b.time as number))
createSeriesMarkers(candleSeries, markers)
chart.timeScale().fitContent()
setIsLoading(false)
})
.catch((err) => {
setError(err.message || 'Failed to load klines')
setIsLoading(false)
})
const handleResize = () => {
if (chartContainerRef.current) {
chart.applyOptions({ width: chartContainerRef.current.clientWidth })
}
}
window.addEventListener('resize', handleResize)
return () => {
window.removeEventListener('resize', handleResize)
chart.remove()
chartRef.current = null
candleSeriesRef.current = null
}
}, [runId, selectedSymbol, selectedTimeframe, symbolTrades])
if (symbols.length === 0) {
return (
<div className="py-12 text-center" style={{ color: '#5E6673' }}>
{language === 'zh' ? '没有交易记录' : 'No trades to display'}
</div>
)
}
return (
<div className="space-y-3">
<div className="flex items-center gap-4 flex-wrap">
<div className="flex items-center gap-2">
<CandlestickIcon size={16} style={{ color: '#F0B90B' }} />
<span className="text-sm" style={{ color: '#848E9C' }}>
{language === 'zh' ? '币种' : 'Symbol'}
</span>
<select
value={selectedSymbol}
onChange={(e) => setSelectedSymbol(e.target.value)}
className="px-3 py-1.5 rounded text-sm"
style={{ background: '#1E2329', border: '1px solid #2B3139', color: '#EAECEF' }}
>
{symbols.map((sym) => (
<option key={sym} value={sym}>
{sym}
</option>
))}
</select>
</div>
<div className="flex items-center gap-2">
<Clock size={14} style={{ color: '#848E9C' }} />
<span className="text-sm" style={{ color: '#848E9C' }}>
{language === 'zh' ? '周期' : 'Interval'}
</span>
<div className="flex rounded overflow-hidden" style={{ border: '1px solid #2B3139' }}>
{CHART_TIMEFRAMES.map((tf) => (
<button
key={tf}
onClick={() => setSelectedTimeframe(tf)}
className="px-2.5 py-1 text-xs font-medium transition-colors"
style={{
background: selectedTimeframe === tf ? '#F0B90B' : '#1E2329',
color: selectedTimeframe === tf ? '#0B0E11' : '#848E9C',
}}
>
{tf}
</button>
))}
</div>
</div>
<span className="text-xs" style={{ color: '#5E6673' }}>
({symbolTrades.length} {language === 'zh' ? '笔交易' : 'trades'})
</span>
</div>
<div
ref={chartContainerRef}
className="w-full rounded-lg overflow-hidden"
style={{ background: '#0B0E11', minHeight: 400 }}
>
{isLoading && (
<div className="flex items-center justify-center h-[400px]" style={{ color: '#848E9C' }}>
<RefreshCw className="animate-spin mr-2" size={16} />
{language === 'zh' ? '加载K线数据...' : 'Loading kline data...'}
</div>
)}
{error && (
<div className="flex items-center justify-center h-[400px]" style={{ color: '#F6465D' }}>
<AlertTriangle className="mr-2" size={16} />
{error}
</div>
)}
</div>
<div className="flex items-center gap-4 text-xs" style={{ color: '#848E9C' }}>
<div className="flex items-center gap-1.5">
<div className="w-2.5 h-2.5 rounded-full" style={{ background: '#0ECB81' }} />
<span>{language === 'zh' ? '开仓/盈利' : 'Open/Profit'}</span>
</div>
<div className="flex items-center gap-1.5">
<div className="w-2.5 h-2.5 rounded-full" style={{ background: '#F6465D' }} />
<span>{language === 'zh' ? '亏损平仓' : 'Loss Close'}</span>
</div>
<span style={{ color: '#5E6673' }}>|</span>
<span> Long · Short · {language === 'zh' ? '平仓' : 'Close'}</span>
</div>
</div>
)
}
// ============ Chart Tab Content ============
interface BacktestChartTabProps {
equity: BacktestEquityPoint[] | undefined
trades: BacktestTradeEvent[] | undefined
selectedRunId: string
language: string
tr: (key: string) => string
}
export function BacktestChartTab({
equity,
trades,
selectedRunId,
language,
tr,
}: BacktestChartTabProps) {
return (
<motion.div
key="chart"
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
exit={{ opacity: 0 }}
className="space-y-6"
>
<div>
<h4 className="text-sm font-medium mb-3" style={{ color: '#EAECEF' }}>
{language === 'zh' ? '资金曲线' : 'Equity Curve'}
</h4>
{equity && equity.length > 0 ? (
<EquityChart equity={equity} trades={trades ?? []} />
) : (
<div className="py-12 text-center" style={{ color: '#5E6673' }}>
{tr('charts.equityEmpty')}
</div>
)}
</div>
{selectedRunId && trades && trades.length > 0 && (
<div>
<h4 className="text-sm font-medium mb-3" style={{ color: '#EAECEF' }}>
{language === 'zh' ? 'K线图 & 交易标记' : 'Candlestick & Trade Markers'}
</h4>
<CandlestickChartComponent
runId={selectedRunId}
trades={trades}
language={language}
/>
</div>
)}
</motion.div>
)
}

View File

@@ -0,0 +1,597 @@
import { useMemo, type FormEvent } from 'react'
import { motion, AnimatePresence } from 'framer-motion'
import {
ChevronRight,
ChevronLeft,
RefreshCw,
Zap,
} from 'lucide-react'
import type { AIModel, Strategy } from '../../types'
// ============ Types ============
type WizardStep = 1 | 2 | 3
export interface BacktestFormState {
runId: string
symbols: string
timeframes: string[]
decisionTf: string
cadence: number
start: string
end: string
balance: number
fee: number
slippage: number
btcEthLeverage: number
altcoinLeverage: number
fill: string
prompt: string
promptTemplate: string
customPrompt: string
overridePrompt: boolean
cacheAI: boolean
replayOnly: boolean
aiModelId: string
strategyId: string
}
const TIMEFRAME_OPTIONS = ['1m', '3m', '5m', '15m', '30m', '1h', '4h', '1d']
const POPULAR_SYMBOLS = ['BTCUSDT', 'ETHUSDT', 'SOLUSDT', 'BNBUSDT', 'XRPUSDT', 'DOGEUSDT']
// ============ Config Form ============
interface BacktestConfigFormProps {
formState: BacktestFormState
wizardStep: WizardStep
isStarting: boolean
aiModels: AIModel[] | undefined
strategies: Strategy[] | undefined
language: string
tr: (key: string, params?: Record<string, string | number>) => string
onFormChange: (key: string, value: string | number | boolean | string[]) => void
onWizardStepChange: (step: WizardStep) => void
onStart: (event: FormEvent) => void
}
export function BacktestConfigForm({
formState,
wizardStep,
isStarting,
aiModels,
strategies,
language,
tr,
onFormChange,
onWizardStepChange,
onStart,
}: BacktestConfigFormProps) {
const selectedModel = aiModels?.find((m) => m.id === formState.aiModelId)
const selectedStrategy = strategies?.find((s) => s.id === formState.strategyId)
const strategyHasDynamicCoins = useMemo(() => {
const cs = selectedStrategy?.config?.coin_source
if (!cs) return false
const st = cs.source_type as string
if (st === 'ai500' || st === 'oi_top') return true
if (st === 'mixed' && (cs.use_ai500 || cs.use_oi_top)) return true
if (!st && (cs.use_ai500 || cs.use_oi_top)) return true
return false
}, [selectedStrategy])
const coinSourceDescription = useMemo(() => {
const cs = selectedStrategy?.config?.coin_source
if (!cs) return null
let st = cs.source_type as string
if (!st) {
if (cs.use_ai500 && cs.use_oi_top) st = 'mixed'
else if (cs.use_ai500) st = 'ai500'
else if (cs.use_oi_top) st = 'oi_top'
else if (cs.static_coins?.length) st = 'static'
}
switch (st) {
case 'ai500': return { type: 'AI500', limit: cs.ai500_limit || 30 }
case 'oi_top': return { type: 'OI Top', limit: cs.oi_top_limit || 30 }
case 'mixed': {
const parts: string[] = []
if (cs.use_ai500) parts.push(`AI500(${cs.ai500_limit || 30})`)
if (cs.use_oi_top) parts.push(`OI Top(${cs.oi_top_limit || 30})`)
if (cs.static_coins?.length) parts.push(`Static(${cs.static_coins.length})`)
return { type: 'Mixed', desc: parts.join(' + ') }
}
case 'static': return { type: 'Static', coins: cs.static_coins || [] }
default: return null
}
}, [selectedStrategy])
const zh = language === 'zh'
const quickRanges = [
{ label: zh ? '24小时' : '24h', hours: 24 },
{ label: zh ? '3天' : '3d', hours: 72 },
{ label: zh ? '7天' : '7d', hours: 168 },
{ label: zh ? '30天' : '30d', hours: 720 },
]
const applyQuickRange = (hours: number) => {
const end = new Date()
const start = new Date(end.getTime() - hours * 3600 * 1000)
const fmt = (d: Date) => new Date(d.getTime() - d.getTimezoneOffset() * 60000).toISOString().slice(0, 16)
onFormChange('start', fmt(start))
onFormChange('end', fmt(end))
}
return (
<div className="binance-card p-5">
<div className="flex items-center gap-2 mb-4">
{[1, 2, 3].map((step) => (
<div key={step} className="flex items-center">
<button
onClick={() => onWizardStepChange(step as WizardStep)}
className="w-8 h-8 rounded-full flex items-center justify-center text-sm font-bold transition-all"
style={{
background: wizardStep >= step ? '#F0B90B' : '#2B3139',
color: wizardStep >= step ? '#0B0E11' : '#848E9C',
}}
>
{step}
</button>
{step < 3 && (
<div
className="w-8 h-0.5 mx-1"
style={{ background: wizardStep > step ? '#F0B90B' : '#2B3139' }}
/>
)}
</div>
))}
<span className="ml-2 text-xs" style={{ color: '#848E9C' }}>
{wizardStep === 1 ? (zh ? '选择模型' : 'Select Model')
: wizardStep === 2 ? (zh ? '配置参数' : 'Configure')
: (zh ? '确认启动' : 'Confirm')}
</span>
</div>
<form onSubmit={onStart}>
<AnimatePresence mode="wait">
{/* Step 1: Model & Symbols */}
{wizardStep === 1 && (
<motion.div
key="step1"
initial={{ opacity: 0, x: 20 }}
animate={{ opacity: 1, x: 0 }}
exit={{ opacity: 0, x: -20 }}
className="space-y-4"
>
<div>
<label className="block text-xs mb-2" style={{ color: '#848E9C' }}>
{tr('form.aiModelLabel')}
</label>
<select
className="w-full p-3 rounded-lg text-sm"
style={{ background: '#0B0E11', border: '1px solid #2B3139', color: '#EAECEF' }}
value={formState.aiModelId}
onChange={(e) => onFormChange('aiModelId', e.target.value)}
>
<option value="">{tr('form.selectAiModel')}</option>
{aiModels?.map((m) => (
<option key={m.id} value={m.id}>
{m.name} ({m.provider}) {!m.enabled && '⚠️'}
</option>
))}
</select>
{selectedModel && (
<div className="mt-2 flex items-center gap-2 text-xs">
<span
className="px-2 py-0.5 rounded"
style={{
background: selectedModel.enabled ? 'rgba(14,203,129,0.1)' : 'rgba(246,70,93,0.1)',
color: selectedModel.enabled ? '#0ECB81' : '#F6465D',
}}
>
{selectedModel.enabled ? tr('form.enabled') : tr('form.disabled')}
</span>
</div>
)}
</div>
{/* Strategy Selection (Optional) */}
<div>
<label className="block text-xs mb-2" style={{ color: '#848E9C' }}>
{zh ? '策略配置(可选)' : 'Strategy (Optional)'}
</label>
<select
className="w-full p-3 rounded-lg text-sm"
style={{ background: '#0B0E11', border: '1px solid #2B3139', color: '#EAECEF' }}
value={formState.strategyId}
onChange={(e) => onFormChange('strategyId', e.target.value)}
>
<option value="">{zh ? '不使用保存的策略' : 'No saved strategy'}</option>
{strategies?.map((s) => (
<option key={s.id} value={s.id}>
{s.name} {s.is_active && '✓'} {s.is_default && '⭐'}
</option>
))}
</select>
{formState.strategyId && coinSourceDescription && (
<div className="mt-2 p-2 rounded" style={{ background: 'rgba(240,185,11,0.1)', border: '1px solid rgba(240,185,11,0.2)' }}>
<div className="flex items-center gap-2 text-xs">
<span style={{ color: '#F0B90B' }}>
{zh ? '币种来源:' : 'Coin Source:'}
</span>
<span className="font-medium" style={{ color: '#EAECEF' }}>
{coinSourceDescription.type}
{coinSourceDescription.limit && ` (${coinSourceDescription.limit})`}
{coinSourceDescription.desc && ` - ${coinSourceDescription.desc}`}
</span>
</div>
{strategyHasDynamicCoins && (
<div className="text-xs mt-1" style={{ color: '#F0B90B' }}>
{zh
? '⚡ 清空下方币种输入框即可使用策略的动态币种'
: '⚡ Clear the symbols field below to use strategy\'s dynamic coins'}
</div>
)}
</div>
)}
</div>
<div>
<label className="block text-xs mb-2" style={{ color: '#848E9C' }}>
{tr('form.symbolsLabel')}
{strategyHasDynamicCoins && (
<span className="ml-2" style={{ color: '#5E6673' }}>
({zh ? '可选 - 策略已配置币种来源' : 'Optional - strategy has coin source'})
</span>
)}
</label>
{!strategyHasDynamicCoins && (
<div className="flex flex-wrap gap-1 mb-2">
{POPULAR_SYMBOLS.map((sym) => {
const isSelected = formState.symbols.includes(sym)
return (
<button
key={sym}
type="button"
onClick={() => {
const current = formState.symbols.split(',').map((s) => s.trim()).filter(Boolean)
const updated = isSelected
? current.filter((s) => s !== sym)
: [...current, sym]
onFormChange('symbols', updated.join(','))
}}
className="px-2 py-1 rounded text-xs transition-all"
style={{
background: isSelected ? 'rgba(240,185,11,0.15)' : '#1E2329',
border: `1px solid ${isSelected ? '#F0B90B' : '#2B3139'}`,
color: isSelected ? '#F0B90B' : '#848E9C',
}}
>
{sym.replace('USDT', '')}
</button>
)
})}
</div>
)}
<div className="relative">
<textarea
className="w-full p-2 rounded-lg text-xs font-mono"
style={{
background: '#0B0E11',
border: '1px solid #2B3139',
color: '#EAECEF',
}}
value={formState.symbols}
onChange={(e) => onFormChange('symbols', e.target.value)}
rows={2}
placeholder={strategyHasDynamicCoins
? (zh ? '留空将使用策略配置的币种来源' : 'Leave empty to use strategy coin source')
: ''
}
/>
{strategyHasDynamicCoins && formState.symbols && (
<button
type="button"
onClick={() => onFormChange('symbols', '')}
className="absolute top-2 right-2 px-2 py-1 rounded text-xs"
style={{ background: '#F0B90B', color: '#0B0E11' }}
>
{zh ? '清空使用策略币种' : 'Clear to use strategy'}
</button>
)}
</div>
</div>
<button
type="button"
onClick={() => onWizardStepChange(2)}
disabled={!selectedModel?.enabled}
className="w-full py-2.5 rounded-lg font-medium flex items-center justify-center gap-2 transition-all disabled:opacity-50"
style={{ background: '#F0B90B', color: '#0B0E11' }}
>
{zh ? '下一步' : 'Next'}
<ChevronRight className="w-4 h-4" />
</button>
</motion.div>
)}
{/* Step 2: Parameters */}
{wizardStep === 2 && (
<motion.div
key="step2"
initial={{ opacity: 0, x: 20 }}
animate={{ opacity: 1, x: 0 }}
exit={{ opacity: 0, x: -20 }}
className="space-y-4"
>
<div>
<label className="block text-xs mb-2" style={{ color: '#848E9C' }}>
{tr('form.timeRangeLabel')}
</label>
<div className="flex flex-wrap gap-1 mb-2">
{quickRanges.map((r) => (
<button
key={r.hours}
type="button"
onClick={() => applyQuickRange(r.hours)}
className="px-3 py-1 rounded text-xs"
style={{ background: '#1E2329', border: '1px solid #2B3139', color: '#EAECEF' }}
>
{r.label}
</button>
))}
</div>
<div className="grid grid-cols-2 gap-2">
<input
type="datetime-local"
className="p-2 rounded-lg text-xs"
style={{ background: '#0B0E11', border: '1px solid #2B3139', color: '#EAECEF' }}
value={formState.start}
onChange={(e) => onFormChange('start', e.target.value)}
/>
<input
type="datetime-local"
className="p-2 rounded-lg text-xs"
style={{ background: '#0B0E11', border: '1px solid #2B3139', color: '#EAECEF' }}
value={formState.end}
onChange={(e) => onFormChange('end', e.target.value)}
/>
</div>
</div>
<div>
<label className="block text-xs mb-2" style={{ color: '#848E9C' }}>
{zh ? '时间周期' : 'Timeframes'}
</label>
<div className="flex flex-wrap gap-1">
{TIMEFRAME_OPTIONS.map((tf) => {
const isSelected = formState.timeframes.includes(tf)
return (
<button
key={tf}
type="button"
onClick={() => {
const updated = isSelected
? formState.timeframes.filter((t) => t !== tf)
: [...formState.timeframes, tf]
if (updated.length > 0) onFormChange('timeframes', updated)
}}
className="px-2 py-1 rounded text-xs transition-all"
style={{
background: isSelected ? 'rgba(240,185,11,0.15)' : '#1E2329',
border: `1px solid ${isSelected ? '#F0B90B' : '#2B3139'}`,
color: isSelected ? '#F0B90B' : '#848E9C',
}}
>
{tf}
</button>
)
})}
</div>
</div>
<div className="grid grid-cols-2 gap-3">
<div>
<label className="block text-xs mb-1" style={{ color: '#848E9C' }}>
{tr('form.initialBalanceLabel')}
</label>
<input
type="number"
className="w-full p-2 rounded-lg text-xs"
style={{ background: '#0B0E11', border: '1px solid #2B3139', color: '#EAECEF' }}
value={formState.balance}
onChange={(e) => onFormChange('balance', Number(e.target.value))}
/>
</div>
<div>
<label className="block text-xs mb-1" style={{ color: '#848E9C' }}>
{tr('form.decisionTfLabel')}
</label>
<select
className="w-full p-2 rounded-lg text-xs"
style={{ background: '#0B0E11', border: '1px solid #2B3139', color: '#EAECEF' }}
value={formState.decisionTf}
onChange={(e) => onFormChange('decisionTf', e.target.value)}
>
{formState.timeframes.map((tf) => (
<option key={tf} value={tf}>
{tf}
</option>
))}
</select>
</div>
</div>
<div className="flex gap-2">
<button
type="button"
onClick={() => onWizardStepChange(1)}
className="flex-1 py-2 rounded-lg font-medium flex items-center justify-center gap-2"
style={{ background: '#1E2329', border: '1px solid #2B3139', color: '#EAECEF' }}
>
<ChevronLeft className="w-4 h-4" />
{zh ? '上一步' : 'Back'}
</button>
<button
type="button"
onClick={() => onWizardStepChange(3)}
className="flex-1 py-2 rounded-lg font-medium flex items-center justify-center gap-2"
style={{ background: '#F0B90B', color: '#0B0E11' }}
>
{zh ? '下一步' : 'Next'}
<ChevronRight className="w-4 h-4" />
</button>
</div>
</motion.div>
)}
{/* Step 3: Advanced & Confirm */}
{wizardStep === 3 && (
<motion.div
key="step3"
initial={{ opacity: 0, x: 20 }}
animate={{ opacity: 1, x: 0 }}
exit={{ opacity: 0, x: -20 }}
className="space-y-4"
>
<div className="grid grid-cols-2 gap-3">
<div>
<label className="block text-xs mb-1" style={{ color: '#848E9C' }}>
{tr('form.btcEthLeverageLabel')}
</label>
<input
type="number"
className="w-full p-2 rounded-lg text-xs"
style={{ background: '#0B0E11', border: '1px solid #2B3139', color: '#EAECEF' }}
value={formState.btcEthLeverage}
onChange={(e) => onFormChange('btcEthLeverage', Number(e.target.value))}
/>
</div>
<div>
<label className="block text-xs mb-1" style={{ color: '#848E9C' }}>
{tr('form.altcoinLeverageLabel')}
</label>
<input
type="number"
className="w-full p-2 rounded-lg text-xs"
style={{ background: '#0B0E11', border: '1px solid #2B3139', color: '#EAECEF' }}
value={formState.altcoinLeverage}
onChange={(e) => onFormChange('altcoinLeverage', Number(e.target.value))}
/>
</div>
</div>
<div className="grid grid-cols-1 sm:grid-cols-3 gap-2">
<div>
<label className="block text-xs mb-1" style={{ color: '#848E9C' }}>
{tr('form.feeLabel')}
</label>
<input
type="number"
className="w-full p-2 rounded-lg text-xs"
style={{ background: '#0B0E11', border: '1px solid #2B3139', color: '#EAECEF' }}
value={formState.fee}
onChange={(e) => onFormChange('fee', Number(e.target.value))}
/>
</div>
<div>
<label className="block text-xs mb-1" style={{ color: '#848E9C' }}>
{tr('form.slippageLabel')}
</label>
<input
type="number"
className="w-full p-2 rounded-lg text-xs"
style={{ background: '#0B0E11', border: '1px solid #2B3139', color: '#EAECEF' }}
value={formState.slippage}
onChange={(e) => onFormChange('slippage', Number(e.target.value))}
/>
</div>
<div>
<label className="block text-xs mb-1" style={{ color: '#848E9C' }}>
{tr('form.cadenceLabel')}
</label>
<input
type="number"
className="w-full p-2 rounded-lg text-xs"
style={{ background: '#0B0E11', border: '1px solid #2B3139', color: '#EAECEF' }}
value={formState.cadence}
onChange={(e) => onFormChange('cadence', Number(e.target.value))}
/>
</div>
</div>
<div>
<label className="block text-xs mb-1" style={{ color: '#848E9C' }}>
{zh ? '策略风格' : 'Strategy Style'}
</label>
<div className="flex flex-wrap gap-1">
{['baseline', 'aggressive', 'conservative', 'scalping'].map((p) => (
<button
key={p}
type="button"
onClick={() => onFormChange('prompt', p)}
className="px-3 py-1.5 rounded text-xs transition-all"
style={{
background: formState.prompt === p ? 'rgba(240,185,11,0.15)' : '#1E2329',
border: `1px solid ${formState.prompt === p ? '#F0B90B' : '#2B3139'}`,
color: formState.prompt === p ? '#F0B90B' : '#848E9C',
}}
>
{tr(`form.promptPresets.${p}`)}
</button>
))}
</div>
</div>
<div className="flex flex-wrap gap-4 text-xs" style={{ color: '#848E9C' }}>
<label className="flex items-center gap-2 cursor-pointer">
<input
type="checkbox"
checked={formState.cacheAI}
onChange={(e) => onFormChange('cacheAI', e.target.checked)}
className="accent-[#F0B90B]"
/>
{tr('form.cacheAiLabel')}
</label>
<label className="flex items-center gap-2 cursor-pointer">
<input
type="checkbox"
checked={formState.replayOnly}
onChange={(e) => onFormChange('replayOnly', e.target.checked)}
className="accent-[#F0B90B]"
/>
{tr('form.replayOnlyLabel')}
</label>
</div>
<div className="flex gap-2">
<button
type="button"
onClick={() => onWizardStepChange(2)}
className="flex-1 py-2 rounded-lg font-medium flex items-center justify-center gap-2"
style={{ background: '#1E2329', border: '1px solid #2B3139', color: '#EAECEF' }}
>
<ChevronLeft className="w-4 h-4" />
{zh ? '上一步' : 'Back'}
</button>
<button
type="submit"
disabled={isStarting}
className="flex-1 py-2 rounded-lg font-bold flex items-center justify-center gap-2 disabled:opacity-50"
style={{ background: '#F0B90B', color: '#0B0E11' }}
>
{isStarting ? (
<RefreshCw className="w-4 h-4 animate-spin" />
) : (
<Zap className="w-4 h-4" />
)}
{isStarting ? tr('starting') : tr('start')}
</button>
</div>
</motion.div>
)}
</AnimatePresence>
</form>
</div>
)
}
export type { WizardStep }

View File

@@ -0,0 +1,36 @@
import { motion } from 'framer-motion'
import { DecisionCard } from '../trader/DecisionCard'
import type { Language } from '../../i18n/translations'
import type { DecisionRecord } from '../../types'
interface BacktestDecisionsTabProps {
decisions: DecisionRecord[] | undefined
language: Language
tr: (key: string) => string
}
export function BacktestDecisionsTab({ decisions, language, tr }: BacktestDecisionsTabProps) {
return (
<motion.div
key="decisions"
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
exit={{ opacity: 0 }}
className="space-y-3 max-h-[500px] overflow-y-auto"
>
{decisions && decisions.length > 0 ? (
decisions.map((d) => (
<DecisionCard
key={`${d.cycle_number}-${d.timestamp}`}
decision={d}
language={language}
/>
))
) : (
<div className="py-12 text-center" style={{ color: '#5E6673' }}>
{tr('decisionTrail.emptyHint')}
</div>
)}
</motion.div>
)
}

View File

@@ -0,0 +1,324 @@
import { motion } from 'framer-motion'
import {
TrendingUp,
TrendingDown,
Activity,
ArrowUpRight,
ArrowDownRight,
} from 'lucide-react'
import { MetricTooltip } from '../common/MetricTooltip'
import { EquityChart } from './BacktestChartTab'
import type {
BacktestEquityPoint,
BacktestTradeEvent,
BacktestMetrics,
BacktestPositionStatus,
} from '../../types'
// ============ Stat Card ============
interface StatCardProps {
icon: typeof TrendingUp
label: string
value: string | number
suffix?: string
trend?: 'up' | 'down' | 'neutral'
color?: string
metricKey?: string
language?: string
}
export function StatCard({
icon: Icon,
label,
value,
suffix,
trend,
color = '#EAECEF',
metricKey,
language = 'en',
}: StatCardProps) {
const trendColors = {
up: '#0ECB81',
down: '#F6465D',
neutral: '#848E9C',
}
return (
<div
className="p-4 rounded-xl"
style={{ background: 'rgba(30, 35, 41, 0.6)', border: '1px solid #2B3139' }}
>
<div className="flex items-center gap-2 mb-2">
<Icon className="w-4 h-4" style={{ color: '#F0B90B' }} />
<span className="text-xs" style={{ color: '#848E9C' }}>
{label}
</span>
{metricKey && (
<MetricTooltip metricKey={metricKey} language={language} size={12} />
)}
</div>
<div className="flex items-baseline gap-1">
<span className="text-xl font-bold" style={{ color }}>
{value}
</span>
{suffix && (
<span className="text-xs" style={{ color: '#848E9C' }}>
{suffix}
</span>
)}
{trend && trend !== 'neutral' && (
<span style={{ color: trendColors[trend] }}>
{trend === 'up' ? <ArrowUpRight className="w-4 h-4" /> : <ArrowDownRight className="w-4 h-4" />}
</span>
)}
</div>
</div>
)
}
// ============ Progress Ring ============
interface ProgressRingProps {
progress: number
size?: number
}
export function ProgressRing({ progress, size = 120 }: ProgressRingProps) {
const strokeWidth = 8
const radius = (size - strokeWidth) / 2
const circumference = radius * 2 * Math.PI
const offset = circumference - (progress / 100) * circumference
return (
<div className="relative" style={{ width: size, height: size }}>
<svg className="transform -rotate-90" width={size} height={size}>
<circle
cx={size / 2}
cy={size / 2}
r={radius}
stroke="#2B3139"
strokeWidth={strokeWidth}
fill="none"
/>
<motion.circle
cx={size / 2}
cy={size / 2}
r={radius}
stroke="#F0B90B"
strokeWidth={strokeWidth}
fill="none"
strokeLinecap="round"
strokeDasharray={circumference}
initial={{ strokeDashoffset: circumference }}
animate={{ strokeDashoffset: offset }}
transition={{ duration: 0.5 }}
/>
</svg>
<div className="absolute inset-0 flex items-center justify-center flex-col">
<span className="text-2xl font-bold" style={{ color: '#F0B90B' }}>
{progress.toFixed(0)}%
</span>
<span className="text-xs" style={{ color: '#848E9C' }}>
Complete
</span>
</div>
</div>
)
}
// ============ Positions Display ============
interface PositionsDisplayProps {
positions: BacktestPositionStatus[]
language: string
}
export function PositionsDisplay({ positions, language }: PositionsDisplayProps) {
if (!positions || positions.length === 0) {
return null
}
const totalUnrealizedPnL = positions.reduce((sum, p) => sum + p.unrealized_pnl, 0)
const totalMargin = positions.reduce((sum, p) => sum + p.margin_used, 0)
return (
<div
className="mt-3 p-3 rounded-lg"
style={{ background: 'rgba(30, 35, 41, 0.8)', border: '1px solid #2B3139' }}
>
<div className="flex items-center justify-between mb-2">
<div className="flex items-center gap-2">
<Activity className="w-4 h-4" style={{ color: '#F0B90B' }} />
<span className="text-sm font-medium" style={{ color: '#EAECEF' }}>
{language === 'zh' ? '当前持仓' : 'Active Positions'}
</span>
<span
className="px-1.5 py-0.5 rounded text-xs"
style={{ background: '#F0B90B20', color: '#F0B90B' }}
>
{positions.length}
</span>
</div>
<div className="flex items-center gap-3 text-xs">
<span style={{ color: '#848E9C' }}>
{language === 'zh' ? '保证金' : 'Margin'}: ${totalMargin.toFixed(2)}
</span>
<span
className="font-medium"
style={{ color: totalUnrealizedPnL >= 0 ? '#0ECB81' : '#F6465D' }}
>
{language === 'zh' ? '浮盈' : 'Unrealized'}: {totalUnrealizedPnL >= 0 ? '+' : ''}
${totalUnrealizedPnL.toFixed(2)}
</span>
</div>
</div>
<div className="space-y-1.5">
{positions.map((pos) => {
const isLong = pos.side === 'long'
const pnlColor = pos.unrealized_pnl >= 0 ? '#0ECB81' : '#F6465D'
return (
<motion.div
key={`${pos.symbol}-${pos.side}`}
initial={{ opacity: 0, scale: 0.95 }}
animate={{ opacity: 1, scale: 1 }}
className="flex items-center justify-between p-2 rounded"
style={{ background: '#1E2329' }}
>
<div className="flex items-center gap-2">
<div
className="w-6 h-6 rounded flex items-center justify-center"
style={{ background: isLong ? '#0ECB8120' : '#F6465D20' }}
>
{isLong ? (
<TrendingUp className="w-3.5 h-3.5" style={{ color: '#0ECB81' }} />
) : (
<TrendingDown className="w-3.5 h-3.5" style={{ color: '#F6465D' }} />
)}
</div>
<div>
<div className="flex items-center gap-1.5">
<span className="font-mono font-bold text-sm" style={{ color: '#EAECEF' }}>
{pos.symbol.replace('USDT', '')}
</span>
<span
className="px-1 py-0.5 rounded text-[10px] font-medium"
style={{
background: isLong ? '#0ECB8120' : '#F6465D20',
color: isLong ? '#0ECB81' : '#F6465D',
}}
>
{isLong ? 'LONG' : 'SHORT'} {pos.leverage}x
</span>
</div>
<div className="text-[10px]" style={{ color: '#5E6673' }}>
{language === 'zh' ? '数量' : 'Qty'}: {pos.quantity.toFixed(4)} ·{' '}
{language === 'zh' ? '保证金' : 'Margin'}: ${pos.margin_used.toFixed(2)}
</div>
</div>
</div>
<div className="text-right">
<div className="flex items-center gap-2 text-xs">
<span style={{ color: '#848E9C' }}>
{language === 'zh' ? '开仓' : 'Entry'}: ${pos.entry_price.toFixed(2)}
</span>
<span style={{ color: '#EAECEF' }}>
{language === 'zh' ? '现价' : 'Mark'}: ${pos.mark_price.toFixed(2)}
</span>
</div>
<div className="flex items-center justify-end gap-1.5 mt-0.5">
<span className="font-mono font-bold" style={{ color: pnlColor }}>
{pos.unrealized_pnl >= 0 ? '+' : ''}${pos.unrealized_pnl.toFixed(2)}
</span>
<span
className="px-1 py-0.5 rounded text-[10px] font-medium"
style={{ background: `${pnlColor}20`, color: pnlColor }}
>
{pos.unrealized_pnl_pct >= 0 ? '+' : ''}{pos.unrealized_pnl_pct.toFixed(2)}%
</span>
</div>
</div>
</motion.div>
)
})}
</div>
</div>
)
}
// ============ Overview Tab Content ============
interface BacktestOverviewTabProps {
equity: BacktestEquityPoint[] | undefined
trades: BacktestTradeEvent[] | undefined
metrics: BacktestMetrics | undefined
language: string
tr: (key: string) => string
}
export function BacktestOverviewTab({
equity,
trades,
metrics,
language,
tr,
}: BacktestOverviewTabProps) {
return (
<motion.div
key="overview"
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
exit={{ opacity: 0 }}
>
{equity && equity.length > 0 ? (
<EquityChart equity={equity} trades={trades ?? []} />
) : (
<div className="py-12 text-center" style={{ color: '#5E6673' }}>
{tr('charts.equityEmpty')}
</div>
)}
{metrics && (
<div className="grid grid-cols-2 md:grid-cols-4 gap-3 mt-4">
<div className="p-3 rounded-lg" style={{ background: '#1E2329' }}>
<div className="flex items-center gap-1 text-xs" style={{ color: '#848E9C' }}>
{language === 'zh' ? '胜率' : 'Win Rate'}
<MetricTooltip metricKey="win_rate" language={language} size={11} />
</div>
<div className="text-lg font-bold" style={{ color: '#EAECEF' }}>
{(metrics.win_rate ?? 0).toFixed(1)}%
</div>
</div>
<div className="p-3 rounded-lg" style={{ background: '#1E2329' }}>
<div className="flex items-center gap-1 text-xs" style={{ color: '#848E9C' }}>
{language === 'zh' ? '盈亏因子' : 'Profit Factor'}
<MetricTooltip metricKey="profit_factor" language={language} size={11} />
</div>
<div className="text-lg font-bold" style={{ color: '#EAECEF' }}>
{(metrics.profit_factor ?? 0).toFixed(2)}
</div>
</div>
<div className="p-3 rounded-lg" style={{ background: '#1E2329' }}>
<div className="text-xs" style={{ color: '#848E9C' }}>
{language === 'zh' ? '总交易数' : 'Total Trades'}
</div>
<div className="text-lg font-bold" style={{ color: '#EAECEF' }}>
{metrics.trades ?? 0}
</div>
</div>
<div className="p-3 rounded-lg" style={{ background: '#1E2329' }}>
<div className="text-xs" style={{ color: '#848E9C' }}>
{language === 'zh' ? '最佳币种' : 'Best Symbol'}
</div>
<div className="text-lg font-bold" style={{ color: '#0ECB81' }}>
{metrics.best_symbol?.replace('USDT', '') || '-'}
</div>
</div>
</div>
)}
</motion.div>
)
}

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,150 @@
import {
Activity,
CheckCircle2,
XCircle,
Pause,
Clock,
Layers,
Eye,
} from 'lucide-react'
// ============ Types ============
export interface BacktestRunItem {
run_id: string
state: string
summary: {
progress_pct: number
equity_last: number
decision_tf?: string
symbol_count?: number
}
}
// ============ State Helpers ============
export function getStateColor(state: string) {
switch (state) {
case 'running':
return '#F0B90B'
case 'completed':
return '#0ECB81'
case 'failed':
case 'liquidated':
return '#F6465D'
case 'paused':
return '#848E9C'
default:
return '#848E9C'
}
}
export function getStateIcon(state: string) {
switch (state) {
case 'running':
return <Activity className="w-4 h-4" />
case 'completed':
return <CheckCircle2 className="w-4 h-4" />
case 'failed':
case 'liquidated':
return <XCircle className="w-4 h-4" />
case 'paused':
return <Pause className="w-4 h-4" />
default:
return <Clock className="w-4 h-4" />
}
}
// ============ Run History List ============
interface BacktestRunListProps {
runs: BacktestRunItem[]
selectedRunId: string | undefined
compareRunIds: string[]
language: string
tr: (key: string, params?: Record<string, string | number>) => string
onSelectRun: (runId: string) => void
onToggleCompare: (runId: string) => void
}
export function BacktestRunList({
runs,
selectedRunId,
compareRunIds,
language,
tr,
onSelectRun,
onToggleCompare,
}: BacktestRunListProps) {
return (
<div className="binance-card p-4">
<div className="flex items-center justify-between mb-3">
<h3 className="text-sm font-bold flex items-center gap-2" style={{ color: '#EAECEF' }}>
<Layers className="w-4 h-4" style={{ color: '#F0B90B' }} />
{tr('runList.title')}
</h3>
<span className="text-xs" style={{ color: '#848E9C' }}>
{runs.length} {language === 'zh' ? '条' : 'runs'}
</span>
</div>
<div className="space-y-2 max-h-[300px] overflow-y-auto">
{runs.length === 0 ? (
<div className="py-8 text-center text-sm" style={{ color: '#5E6673' }}>
{tr('emptyStates.noRuns')}
</div>
) : (
runs.map((run) => (
<button
key={run.run_id}
onClick={() => onSelectRun(run.run_id)}
className="w-full p-3 rounded-lg text-left transition-all"
style={{
background: run.run_id === selectedRunId ? 'rgba(240,185,11,0.1)' : '#1E2329',
border: `1px solid ${run.run_id === selectedRunId ? '#F0B90B' : '#2B3139'}`,
}}
>
<div className="flex items-center justify-between">
<span className="font-mono text-xs" style={{ color: '#EAECEF' }}>
{run.run_id.slice(0, 20)}...
</span>
<span
className="flex items-center gap-1 text-xs"
style={{ color: getStateColor(run.state) }}
>
{getStateIcon(run.state)}
{tr(`states.${run.state}`)}
</span>
</div>
<div className="flex items-center justify-between mt-1">
<span className="text-xs" style={{ color: '#848E9C' }}>
{run.summary.progress_pct.toFixed(0)}% · ${run.summary.equity_last.toFixed(0)}
</span>
<button
onClick={(e) => {
e.stopPropagation()
onToggleCompare(run.run_id)
}}
className="p-1 rounded"
style={{
background: compareRunIds.includes(run.run_id)
? 'rgba(240,185,11,0.2)'
: 'transparent',
}}
title={language === 'zh' ? '添加到对比' : 'Add to compare'}
>
<Eye
className="w-3 h-3"
style={{
color: compareRunIds.includes(run.run_id) ? '#F0B90B' : '#5E6673',
}}
/>
</button>
</div>
</button>
))
)}
</div>
</div>
)
}

View File

@@ -0,0 +1,104 @@
import { useMemo } from 'react'
import { motion } from 'framer-motion'
import { TrendingUp, TrendingDown } from 'lucide-react'
import type { BacktestTradeEvent } from '../../types'
// ============ Trade Timeline ============
function TradeTimeline({ trades }: { trades: BacktestTradeEvent[] }) {
const recentTrades = useMemo(() => [...trades].slice(-20).reverse(), [trades])
if (recentTrades.length === 0) {
return (
<div className="py-12 text-center" style={{ color: '#5E6673' }}>
No trades yet
</div>
)
}
return (
<div className="space-y-2 max-h-[400px] overflow-y-auto pr-2">
{recentTrades.map((trade, idx) => {
const isOpen = trade.action.includes('open')
const isLong = trade.action.includes('long')
const bgColor = isOpen ? 'rgba(14, 203, 129, 0.1)' : 'rgba(246, 70, 93, 0.1)'
const borderColor = isOpen ? 'rgba(14, 203, 129, 0.3)' : 'rgba(246, 70, 93, 0.3)'
const iconColor = isOpen ? '#0ECB81' : '#F6465D'
return (
<motion.div
key={`${trade.ts}-${trade.symbol}-${idx}`}
initial={{ opacity: 0, x: -20 }}
animate={{ opacity: 1, x: 0 }}
transition={{ delay: idx * 0.05 }}
className="p-3 rounded-lg flex items-center gap-3"
style={{ background: bgColor, border: `1px solid ${borderColor}` }}
>
<div
className="w-8 h-8 rounded-full flex items-center justify-center"
style={{ background: `${iconColor}20` }}
>
{isLong ? (
<TrendingUp className="w-4 h-4" style={{ color: iconColor }} />
) : (
<TrendingDown className="w-4 h-4" style={{ color: iconColor }} />
)}
</div>
<div className="flex-1 min-w-0">
<div className="flex items-center gap-2">
<span className="font-mono font-bold text-sm" style={{ color: '#EAECEF' }}>
{trade.symbol.replace('USDT', '')}
</span>
<span
className="px-2 py-0.5 rounded text-xs font-medium"
style={{ background: `${iconColor}20`, color: iconColor }}
>
{trade.action.replace('_', ' ').toUpperCase()}
</span>
{trade.leverage && (
<span className="text-xs" style={{ color: '#848E9C' }}>
{trade.leverage}x
</span>
)}
</div>
<div className="text-xs mt-1" style={{ color: '#848E9C' }}>
{new Date(trade.ts).toLocaleString()} · Qty: {trade.qty.toFixed(4)} · ${trade.price.toFixed(2)}
</div>
</div>
<div className="text-right">
<div
className="font-mono font-bold"
style={{ color: trade.realized_pnl >= 0 ? '#0ECB81' : '#F6465D' }}
>
{trade.realized_pnl >= 0 ? '+' : ''}
{trade.realized_pnl.toFixed(2)}
</div>
<div className="text-xs" style={{ color: '#848E9C' }}>
USDT
</div>
</div>
</motion.div>
)
})}
</div>
)
}
// ============ Trades Tab Content ============
interface BacktestTradesTabProps {
trades: BacktestTradeEvent[] | undefined
}
export function BacktestTradesTab({ trades }: BacktestTradesTabProps) {
return (
<motion.div
key="trades"
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
exit={{ opacity: 0 }}
>
<TradeTimeline trades={trades ?? []} />
</motion.div>
)
}

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,221 @@
import {
Brain,
Landmark,
Eye,
EyeOff,
Copy,
Check,
} from 'lucide-react'
import type { AIModel, Exchange } from '../../types'
import type { Language } from '../../i18n/translations'
import { t } from '../../i18n/translations'
import { getModelIcon } from '../common/ModelIcons'
import { getExchangeIcon } from '../common/ExchangeIcons'
import {
getShortName,
AI_PROVIDER_CONFIG,
truncateAddress,
} from './model-constants'
interface UsageInfo {
runningCount: number
totalCount: number
}
interface ConfigStatusGridProps {
configuredModels: AIModel[]
configuredExchanges: Exchange[]
visibleExchangeAddresses: Set<string>
copiedId: string | null
language: Language
isModelInUse: (modelId: string) => boolean | undefined
getModelUsageInfo: (modelId: string) => UsageInfo
isExchangeInUse: (exchangeId: string) => boolean | undefined
getExchangeUsageInfo: (exchangeId: string) => UsageInfo
onModelClick: (modelId: string) => void
onExchangeClick: (exchangeId: string) => void
onToggleExchangeAddress: (exchangeId: string) => void
onCopyAddress: (id: string, address: string) => void
}
export function ConfigStatusGrid({
configuredModels,
configuredExchanges,
visibleExchangeAddresses,
copiedId,
language,
isModelInUse,
getModelUsageInfo,
isExchangeInUse,
getExchangeUsageInfo,
onModelClick,
onExchangeClick,
onToggleExchangeAddress,
onCopyAddress,
}: ConfigStatusGridProps) {
return (
<div className="grid grid-cols-1 lg:grid-cols-2 gap-6">
{/* AI Models Card */}
<div className="nofx-glass rounded-lg border border-white/5 overflow-hidden">
<div className="px-4 py-3 border-b border-white/5 bg-black/20 flex items-center gap-2 backdrop-blur-sm">
<Brain className="w-4 h-4 text-nofx-gold" />
<h3 className="text-sm font-mono tracking-widest text-zinc-300 uppercase">
{t('aiModels', language)}
</h3>
</div>
<div className="p-4 space-y-3">
{configuredModels.map((model) => {
const inUse = isModelInUse(model.id)
const usageInfo = getModelUsageInfo(model.id)
return (
<div
key={model.id}
className={`group relative flex items-center justify-between p-3 rounded-md transition-all border border-transparent ${inUse ? 'opacity-80' : 'hover:bg-white/5 hover:border-white/10 cursor-pointer'
} bg-black/20`}
onClick={() => onModelClick(model.id)}
>
<div className="flex items-center gap-4">
<div className="relative">
<div className="absolute inset-0 bg-indigo-500/20 rounded-full blur-sm group-hover:bg-indigo-500/30 transition-all"></div>
<div className="w-10 h-10 rounded-full flex items-center justify-center bg-black border border-white/10 relative z-10">
{getModelIcon(model.provider || model.id, { width: 20, height: 20 }) || (
<span className="text-xs font-bold text-indigo-400">{getShortName(model.name)[0]}</span>
)}
</div>
</div>
<div className="min-w-0">
<div className="font-mono text-sm text-zinc-200 group-hover:text-nofx-gold transition-colors">
{getShortName(model.name)}
</div>
<div className="text-[10px] text-zinc-500 font-mono flex items-center gap-2">
{model.customModelName || AI_PROVIDER_CONFIG[model.provider]?.defaultModel || ''}
</div>
</div>
</div>
<div className="text-right">
{usageInfo.totalCount > 0 ? (
<span className={`text-[10px] font-mono px-2 py-1 rounded border ${usageInfo.runningCount > 0
? 'bg-green-500/10 border-green-500/30 text-green-400'
: 'bg-yellow-500/10 border-yellow-500/30 text-yellow-400'
}`}>
{usageInfo.runningCount}/{usageInfo.totalCount} ACTIVE
</span>
) : (
<span className="text-[10px] font-mono text-zinc-600 uppercase tracking-wider">
{language === 'zh' ? '就绪' : 'STANDBY'}
</span>
)}
</div>
</div>
)
})}
{configuredModels.length === 0 && (
<div className="text-center py-10 border border-dashed border-zinc-800 rounded-lg bg-black/20">
<Brain className="w-8 h-8 mx-auto mb-3 text-zinc-700" />
<div className="text-xs font-mono text-zinc-500 uppercase tracking-widest">{t('noModelsConfigured', language)}</div>
</div>
)}
</div>
</div>
{/* Exchanges Card */}
<div className="nofx-glass rounded-lg border border-white/5 overflow-hidden">
<div className="px-4 py-3 border-b border-white/5 bg-black/20 flex items-center gap-2 backdrop-blur-sm">
<Landmark className="w-4 h-4 text-nofx-gold" />
<h3 className="text-sm font-mono tracking-widest text-zinc-300 uppercase">
{t('exchanges', language)}
</h3>
</div>
<div className="p-4 space-y-3">
{configuredExchanges.map((exchange) => {
const inUse = isExchangeInUse(exchange.id)
const usageInfo = getExchangeUsageInfo(exchange.id)
return (
<div
key={exchange.id}
className={`group relative flex items-center justify-between p-3 rounded-md transition-all border border-transparent ${inUse ? 'opacity-80' : 'hover:bg-white/5 hover:border-white/10 cursor-pointer'
} bg-black/20`}
onClick={() => onExchangeClick(exchange.id)}
>
<div className="flex items-center gap-4 min-w-0">
<div className="relative">
<div className="absolute inset-0 bg-yellow-500/20 rounded-full blur-sm group-hover:bg-yellow-500/30 transition-all"></div>
<div className="w-10 h-10 rounded-full flex items-center justify-center bg-black border border-white/10 relative z-10">
{getExchangeIcon(exchange.exchange_type || exchange.id, { width: 20, height: 20 })}
</div>
</div>
<div className="min-w-0">
<div className="font-mono text-sm text-zinc-200 group-hover:text-nofx-gold transition-colors truncate">
{exchange.exchange_type?.toUpperCase() || getShortName(exchange.name)}
<span className="text-[10px] text-zinc-500 ml-2 border border-zinc-800 px-1 rounded">
{exchange.account_name || 'DEFAULT'}
</span>
</div>
<div className="text-[10px] text-zinc-500 font-mono flex items-center gap-2">
{exchange.type?.toUpperCase() || 'CEX'}
</div>
</div>
</div>
<div className="flex flex-col items-end gap-1">
{/* Wallet Address Display Logic */}
{(() => {
const walletAddr = exchange.hyperliquidWalletAddr || exchange.asterUser || exchange.lighterWalletAddr
if (exchange.type !== 'dex' || !walletAddr) return null
const isVisible = visibleExchangeAddresses.has(exchange.id)
const isCopied = copiedId === `exchange-${exchange.id}`
return (
<div className="flex items-center gap-1" onClick={(e) => e.stopPropagation()}>
<span className="text-[10px] font-mono text-zinc-400 bg-black/40 px-1.5 py-0.5 rounded border border-zinc-800">
{isVisible ? walletAddr : truncateAddress(walletAddr)}
</span>
<button
onClick={(e) => { e.stopPropagation(); onToggleExchangeAddress(exchange.id) }}
className="text-zinc-600 hover:text-zinc-300"
>
{isVisible ? <EyeOff size={10} /> : <Eye size={10} />}
</button>
<button
onClick={(e) => { e.stopPropagation(); onCopyAddress(`exchange-${exchange.id}`, walletAddr) }}
className="text-zinc-600 hover:text-nofx-gold"
>
{isCopied ? <Check size={10} className="text-green-500" /> : <Copy size={10} />}
</button>
</div>
)
})()}
{usageInfo.totalCount > 0 ? (
<span className={`text-[10px] font-mono px-2 py-1 rounded border ${usageInfo.runningCount > 0
? 'bg-green-500/10 border-green-500/30 text-green-400'
: 'bg-yellow-500/10 border-yellow-500/30 text-yellow-400'
}`}>
{usageInfo.runningCount}/{usageInfo.totalCount} ACTIVE
</span>
) : (
<span className="text-[10px] font-mono text-zinc-600 uppercase tracking-wider">
{language === 'zh' ? '就绪' : 'STANDBY'}
</span>
)}
</div>
</div>
)
})}
{configuredExchanges.length === 0 && (
<div className="text-center py-10 border border-dashed border-zinc-800 rounded-lg bg-black/20">
<Landmark className="w-8 h-8 mx-auto mb-3 text-zinc-700" />
<div className="text-xs font-mono text-zinc-500 uppercase tracking-widest">{t('noExchangesConfigured', language)}</div>
</div>
)}
</div>
</div>
</div>
)
}

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