From cb31782be42aaac0970175ea3608bd313cce0f96 Mon Sep 17 00:00:00 2001 From: tinkle-community Date: Thu, 12 Mar 2026 12:53:57 +0800 Subject: [PATCH] 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) --- .github/workflows/pr-checks-advisory.yml.old | 331 --- api/handler_trader.go | 612 ----- api/handler_trader_config.go | 79 + api/handler_trader_status.go | 565 +++++ backtest/runner.go | 1192 +--------- backtest/runner_loop.go | 563 +++++ backtest/runner_metrics.go | 239 ++ backtest/runner_orders.go | 420 ++++ cmd/lighter_test/main.go | 233 -- config/config.go | 6 +- kernel/engine.go | 1285 +---------- kernel/engine_analysis.go | 374 +++ kernel/engine_position.go | 121 + kernel/engine_prompt.go | 779 +++++++ kernel/schema_test.go | 278 --- main.go | 4 +- manager/trader_manager_test.go | 87 - market/data.go | 648 ------ market/data_indicators.go | 235 ++ market/data_klines.go | 425 ++++ provider/alpaca/kline_test.go | 35 - provider/coinank/base_coin_test.go | 34 - provider/coinank/coinank_http_test.go | 3 - provider/coinank/instrument_agg_rank_test.go | 101 - provider/coinank/instruments_test.go | 34 - provider/coinank/kline_test.go | 56 - provider/coinank/liquidation_test.go | 89 - provider/coinank/net_positions_test.go | 25 - provider/coinank/open_interest_test.go | 106 - pyproject.toml | 7 - screenshots/debate-arena.png | Bin 454870 -> 0 bytes screenshots/debate-create.png | Bin 321540 -> 0 bytes scripts/ENCRYPTION_README.md | 302 --- scripts/cleanup_duplicates.go | 98 - scripts/clear_orders.go | 111 - scripts/diagnose_orders.go | 189 -- scripts/fix_order_data.go | 141 -- scripts/migrate_encryption.go | 200 -- scripts/pr-check.sh | 413 ---- scripts/pr-fix.sh | 335 --- scripts/restart_and_test.sh | 65 - scripts/test_lighter_orders.go | 168 -- store/position.go | 758 +----- store/position_history.go | 308 +++ store/position_query.go | 406 ++++ {experience => telemetry}/experience.go | 26 +- trader/aster/trader.go | 1178 ---------- trader/aster/trader_account.go | 299 +++ trader/aster/trader_orders.go | 787 +++++++ trader/aster/trader_positions.go | 121 + .../aster/{order_sync.go => trader_sync.go} | 0 trader/auto_trader_decision.go | 4 +- trader/auto_trader_grid.go | 1253 +--------- trader/auto_trader_grid_levels.go | 485 ++++ trader/auto_trader_grid_orders.go | 419 ++++ trader/auto_trader_grid_regime.go | 345 +++ trader/binance/futures.go | 1323 +---------- trader/binance/futures_account.go | 291 +++ trader/binance/futures_orders.go | 758 ++++++ trader/binance/futures_positions.go | 290 +++ trader/bitget/trader.go | 1086 +-------- trader/bitget/trader_account.go | 199 ++ trader/bitget/trader_orders.go | 711 ++++++ trader/bitget/trader_positions.go | 160 ++ trader/bybit/trader.go | 1069 --------- trader/bybit/trader_account.go | 236 ++ trader/bybit/trader_orders.go | 741 ++++++ trader/bybit/trader_positions.go | 125 + trader/bybit/trader_test.go | 471 ---- trader/gate/trader.go | 786 +------ trader/gate/trader_account.go | 160 ++ trader/gate/trader_orders.go | 644 ++++++ trader/hyperliquid/balance_test.go | 295 --- trader/hyperliquid/trader.go | 2022 +---------------- trader/hyperliquid/trader_account.go | 592 +++++ trader/hyperliquid/trader_orders.go | 1075 +++++++++ trader/hyperliquid/trader_positions.go | 167 ++ trader/hyperliquid/trader_sync.go | 147 ++ trader/hyperliquid/trader_test.go | 648 ------ trader/hyperliquid/xyz_dex_test.go | 669 ------ trader/indodax/trader.go | 557 ----- trader/indodax/trader_account.go | 221 ++ trader/indodax/trader_orders.go | 351 +++ trader/kucoin/trader.go | 924 -------- trader/kucoin/trader_account.go | 58 + trader/kucoin/trader_orders.go | 777 +++++++ trader/kucoin/trader_positions.go | 115 + trader/okx/trader.go | 1394 +----------- trader/okx/trader_account.go | 280 +++ trader/okx/trader_orders.go | 938 ++++++++ trader/okx/trader_positions.go | 192 ++ .../components/backtest/BacktestChartTab.tsx | 433 ++++ .../backtest/BacktestConfigForm.tsx | 597 +++++ .../backtest/BacktestDecisionsTab.tsx | 36 + .../backtest/BacktestOverviewTab.tsx | 324 +++ web/src/components/backtest/BacktestPage.tsx | 1527 +------------ .../components/backtest/BacktestRunList.tsx | 150 ++ .../components/backtest/BacktestTradesTab.tsx | 104 + web/src/components/trader/AITradersPage.tsx | 1386 +---------- .../components/trader/ConfigStatusGrid.tsx | 221 ++ web/src/components/trader/ModelCard.tsx | 58 + .../components/trader/ModelConfigModal.tsx | 674 ++++++ .../components/trader/ModelStepIndicator.tsx | 41 + web/src/components/trader/TradersList.tsx | 417 ++++ web/src/components/trader/model-constants.ts | 164 ++ web/src/lib/api.ts | 738 ------ web/src/pages/SettingsPage.tsx | 2 +- web/src/types.ts | 717 ------ web/src/types/backtest.ts | 167 ++ web/src/types/config.ts | 112 + web/src/types/index.ts | 4 + web/src/types/strategy.ts | 185 ++ web/src/types/trading.ts | 250 ++ 113 files changed, 20423 insertions(+), 25733 deletions(-) delete mode 100644 .github/workflows/pr-checks-advisory.yml.old create mode 100644 api/handler_trader_config.go create mode 100644 api/handler_trader_status.go create mode 100644 backtest/runner_loop.go create mode 100644 backtest/runner_metrics.go create mode 100644 backtest/runner_orders.go delete mode 100644 cmd/lighter_test/main.go create mode 100644 kernel/engine_analysis.go create mode 100644 kernel/engine_position.go create mode 100644 kernel/engine_prompt.go delete mode 100644 kernel/schema_test.go delete mode 100644 manager/trader_manager_test.go create mode 100644 market/data_indicators.go create mode 100644 market/data_klines.go delete mode 100644 provider/alpaca/kline_test.go delete mode 100644 provider/coinank/base_coin_test.go delete mode 100644 provider/coinank/coinank_http_test.go delete mode 100644 provider/coinank/instrument_agg_rank_test.go delete mode 100644 provider/coinank/instruments_test.go delete mode 100644 provider/coinank/kline_test.go delete mode 100644 provider/coinank/liquidation_test.go delete mode 100644 provider/coinank/net_positions_test.go delete mode 100644 provider/coinank/open_interest_test.go delete mode 100644 pyproject.toml delete mode 100644 screenshots/debate-arena.png delete mode 100644 screenshots/debate-create.png delete mode 100644 scripts/ENCRYPTION_README.md delete mode 100644 scripts/cleanup_duplicates.go delete mode 100644 scripts/clear_orders.go delete mode 100644 scripts/diagnose_orders.go delete mode 100644 scripts/fix_order_data.go delete mode 100644 scripts/migrate_encryption.go delete mode 100755 scripts/pr-check.sh delete mode 100755 scripts/pr-fix.sh delete mode 100644 scripts/restart_and_test.sh delete mode 100644 scripts/test_lighter_orders.go create mode 100644 store/position_history.go create mode 100644 store/position_query.go rename {experience => telemetry}/experience.go (90%) create mode 100644 trader/aster/trader_account.go create mode 100644 trader/aster/trader_orders.go create mode 100644 trader/aster/trader_positions.go rename trader/aster/{order_sync.go => trader_sync.go} (100%) create mode 100644 trader/auto_trader_grid_levels.go create mode 100644 trader/auto_trader_grid_orders.go create mode 100644 trader/auto_trader_grid_regime.go create mode 100644 trader/binance/futures_account.go create mode 100644 trader/binance/futures_orders.go create mode 100644 trader/binance/futures_positions.go create mode 100644 trader/bitget/trader_account.go create mode 100644 trader/bitget/trader_orders.go create mode 100644 trader/bitget/trader_positions.go create mode 100644 trader/bybit/trader_account.go create mode 100644 trader/bybit/trader_orders.go create mode 100644 trader/bybit/trader_positions.go delete mode 100644 trader/bybit/trader_test.go create mode 100644 trader/gate/trader_account.go create mode 100644 trader/gate/trader_orders.go delete mode 100644 trader/hyperliquid/balance_test.go create mode 100644 trader/hyperliquid/trader_account.go create mode 100644 trader/hyperliquid/trader_orders.go create mode 100644 trader/hyperliquid/trader_positions.go create mode 100644 trader/hyperliquid/trader_sync.go delete mode 100644 trader/hyperliquid/trader_test.go delete mode 100644 trader/hyperliquid/xyz_dex_test.go create mode 100644 trader/indodax/trader_account.go create mode 100644 trader/indodax/trader_orders.go create mode 100644 trader/kucoin/trader_account.go create mode 100644 trader/kucoin/trader_orders.go create mode 100644 trader/kucoin/trader_positions.go create mode 100644 trader/okx/trader_account.go create mode 100644 trader/okx/trader_orders.go create mode 100644 trader/okx/trader_positions.go create mode 100644 web/src/components/backtest/BacktestChartTab.tsx create mode 100644 web/src/components/backtest/BacktestConfigForm.tsx create mode 100644 web/src/components/backtest/BacktestDecisionsTab.tsx create mode 100644 web/src/components/backtest/BacktestOverviewTab.tsx create mode 100644 web/src/components/backtest/BacktestRunList.tsx create mode 100644 web/src/components/backtest/BacktestTradesTab.tsx create mode 100644 web/src/components/trader/ConfigStatusGrid.tsx create mode 100644 web/src/components/trader/ModelCard.tsx create mode 100644 web/src/components/trader/ModelConfigModal.tsx create mode 100644 web/src/components/trader/ModelStepIndicator.tsx create mode 100644 web/src/components/trader/TradersList.tsx create mode 100644 web/src/components/trader/model-constants.ts delete mode 100644 web/src/lib/api.ts delete mode 100644 web/src/types.ts create mode 100644 web/src/types/backtest.ts create mode 100644 web/src/types/config.ts create mode 100644 web/src/types/index.ts create mode 100644 web/src/types/strategy.ts create mode 100644 web/src/types/trading.ts diff --git a/.github/workflows/pr-checks-advisory.yml.old b/.github/workflows/pr-checks-advisory.yml.old deleted file mode 100644 index 898fdc10..00000000 --- a/.github/workflows/pr-checks-advisory.yml.old +++ /dev/null @@ -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<> $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<> $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<> $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<> $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<> $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<> $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 - }); diff --git a/api/handler_trader.go b/api/handler_trader.go index ead84200..d26d0edd 100644 --- a/api/handler_trader.go +++ b/api/handler_trader.go @@ -598,615 +598,3 @@ func (s *Server) handleStopTrader(c *gin.Context) { logger.Infof("⏹ Trader %s stopped", trader.GetName()) 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" - } -} diff --git a/api/handler_trader_config.go b/api/handler_trader_config.go new file mode 100644 index 00000000..405d666d --- /dev/null +++ b/api/handler_trader_config.go @@ -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, + }) +} diff --git a/api/handler_trader_status.go b/api/handler_trader_status.go new file mode 100644 index 00000000..cbae3a84 --- /dev/null +++ b/api/handler_trader_status.go @@ -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" + } +} diff --git a/backtest/runner.go b/backtest/runner.go index 70d2c5eb..7b31c97f 100644 --- a/backtest/runner.go +++ b/backtest/runner.go @@ -2,21 +2,16 @@ package backtest import ( "context" - "encoding/json" "errors" "fmt" - "nofx/logger" "os" "path/filepath" - "sort" - "strings" "sync" "time" "nofx/kernel" - "nofx/market" + "nofx/logger" "nofx/mcp" - "nofx/store" ) var ( @@ -232,954 +227,6 @@ func (r *Runner) CurrentMetadata() *RunMetadata { return meta } -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) 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) 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) 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) 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 (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) 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 (r *Runner) Pause() { select { case r.pauseCh <- struct{}{}: @@ -1292,240 +339,3 @@ func (r *Runner) snapshotState() BacktestState { } return copyState } - -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) 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 -} - -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 -} - -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 -} diff --git a/backtest/runner_loop.go b/backtest/runner_loop.go new file mode 100644 index 00000000..81703bc1 --- /dev/null +++ b/backtest/runner_loop.go @@ -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 +} diff --git a/backtest/runner_metrics.go b/backtest/runner_metrics.go new file mode 100644 index 00000000..e3dea04e --- /dev/null +++ b/backtest/runner_metrics.go @@ -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 +} diff --git a/backtest/runner_orders.go b/backtest/runner_orders.go new file mode 100644 index 00000000..8e6bdfa0 --- /dev/null +++ b/backtest/runner_orders.go @@ -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 +} diff --git a/cmd/lighter_test/main.go b/cmd/lighter_test/main.go deleted file mode 100644 index 6f896a23..00000000 --- a/cmd/lighter_test/main.go +++ /dev/null @@ -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) -} diff --git a/config/config.go b/config/config.go index 56ad3601..1332ab3c 100644 --- a/config/config.go +++ b/config/config.go @@ -1,7 +1,7 @@ package config import ( - "nofx/experience" + "nofx/telemetry" "nofx/mcp" "os" "strconv" @@ -122,11 +122,11 @@ func Init() { global = cfg // 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 mcp.TokenUsageCallback = func(usage mcp.TokenUsage) { - experience.TrackAIUsage(experience.AIUsageEvent{ + telemetry.TrackAIUsage(telemetry.AIUsageEvent{ ModelProvider: usage.Provider, ModelName: usage.Model, InputTokens: usage.PromptTokens, diff --git a/kernel/engine.go b/kernel/engine.go index 6a309e55..79a5edd7 100644 --- a/kernel/engine.go +++ b/kernel/engine.go @@ -8,33 +8,14 @@ import ( "net/http" "nofx/logger" "nofx/market" - "nofx/mcp" "nofx/provider/hyperliquid" "nofx/provider/nofxos" "nofx/security" "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)(.*?)`) - reDecisionTag = regexp.MustCompile(`(?s)(.*?)`) -) - // ============================================================================ // Type Definitions // ============================================================================ @@ -108,25 +89,25 @@ type RecentOrder struct { // Context trading context (complete information passed to AI) type Context struct { - CurrentTime string `json:"current_time"` - RuntimeMinutes int `json:"runtime_minutes"` - CallCount int `json:"call_count"` - Account AccountInfo `json:"account"` - Positions []PositionInfo `json:"positions"` - CandidateCoins []CandidateCoin `json:"candidate_coins"` - PromptVariant string `json:"prompt_variant,omitempty"` - TradingStats *TradingStats `json:"trading_stats,omitempty"` - RecentOrders []RecentOrder `json:"recent_orders,omitempty"` - MarketDataMap map[string]*market.Data `json:"-"` - MultiTFMarket map[string]map[string]*market.Data `json:"-"` - OITopDataMap map[string]*OITopData `json:"-"` - QuantDataMap map[string]*QuantData `json:"-"` - OIRankingData *nofxos.OIRankingData `json:"-"` // Market-wide OI ranking data - NetFlowRankingData *nofxos.NetFlowRankingData `json:"-"` // Market-wide fund flow ranking data - PriceRankingData *nofxos.PriceRankingData `json:"-"` // Market-wide price gainers/losers - BTCETHLeverage int `json:"-"` - AltcoinLeverage int `json:"-"` - Timeframes []string `json:"-"` + CurrentTime string `json:"current_time"` + RuntimeMinutes int `json:"runtime_minutes"` + CallCount int `json:"call_count"` + Account AccountInfo `json:"account"` + Positions []PositionInfo `json:"positions"` + CandidateCoins []CandidateCoin `json:"candidate_coins"` + PromptVariant string `json:"prompt_variant,omitempty"` + TradingStats *TradingStats `json:"trading_stats,omitempty"` + RecentOrders []RecentOrder `json:"recent_orders,omitempty"` + MarketDataMap map[string]*market.Data `json:"-"` + MultiTFMarket map[string]map[string]*market.Data `json:"-"` + OITopDataMap map[string]*OITopData `json:"-"` + QuantDataMap map[string]*QuantData `json:"-"` + OIRankingData *nofxos.OIRankingData `json:"-"` // Market-wide OI ranking data + NetFlowRankingData *nofxos.NetFlowRankingData `json:"-"` // Market-wide fund flow ranking data + PriceRankingData *nofxos.PriceRankingData `json:"-"` // Market-wide price gainers/losers + BTCETHLeverage int `json:"-"` + AltcoinLeverage int `json:"-"` + Timeframes []string `json:"-"` } // Decision AI trading decision @@ -242,173 +223,6 @@ func (e *StrategyEngine) GetConfig() *store.StrategyConfig { return e.config } -// ============================================================================ -// 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 -} - // ============================================================================ // Candidate Coins // ============================================================================ @@ -1022,1067 +836,6 @@ func (e *StrategyEngine) FetchPriceRankingData() *nofxos.PriceRankingData { return data } -// ============================================================================ -// 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 and to separate chain of thought and decision JSON, avoiding parsing errors**\n\n") - sb.WriteString("## Format Requirements\n\n") - sb.WriteString("\n") - sb.WriteString("Your chain of thought analysis...\n") - sb.WriteString("- Briefly analyze your thinking process \n") - sb.WriteString("\n\n") - sb.WriteString("\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("\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, ", ") + "]" -} - -// ============================================================================ -// 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 tag") - return strings.TrimSpace(match[1]) - } - - if decisionIdx := strings.Index(response, ""); decisionIdx > 0 { - logger.Infof("✓ Extracted content before 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 tag") - } else { - jsonPart = s - logger.Infof("⚠️ 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), "[{") -} - -// ============================================================================ -// 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 -} - // ============================================================================ // Helper Functions // ============================================================================ diff --git a/kernel/engine_analysis.go b/kernel/engine_analysis.go new file mode 100644 index 00000000..4a1071bd --- /dev/null +++ b/kernel/engine_analysis.go @@ -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)(.*?)`) + reDecisionTag = regexp.MustCompile(`(?s)(.*?)`) +) + +// ============================================================================ +// 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 tag") + return strings.TrimSpace(match[1]) + } + + if decisionIdx := strings.Index(response, ""); decisionIdx > 0 { + logger.Infof("✓ Extracted content before 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 tag") + } else { + jsonPart = s + logger.Infof("⚠️ 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), "[{") +} diff --git a/kernel/engine_position.go b/kernel/engine_position.go new file mode 100644 index 00000000..437aea1a --- /dev/null +++ b/kernel/engine_position.go @@ -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 +} diff --git a/kernel/engine_prompt.go b/kernel/engine_prompt.go new file mode 100644 index 00000000..15c79a38 --- /dev/null +++ b/kernel/engine_prompt.go @@ -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 and to separate chain of thought and decision JSON, avoiding parsing errors**\n\n") + sb.WriteString("## Format Requirements\n\n") + sb.WriteString("\n") + sb.WriteString("Your chain of thought analysis...\n") + sb.WriteString("- Briefly analyze your thinking process \n") + sb.WriteString("\n\n") + sb.WriteString("\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("\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, ", ") + "]" +} diff --git a/kernel/schema_test.go b/kernel/schema_test.go deleted file mode 100644 index e0ee0a1e..00000000 --- a/kernel/schema_test.go +++ /dev/null @@ -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") - } -} diff --git a/main.go b/main.go index 06f76a17..4dcf8bb6 100644 --- a/main.go +++ b/main.go @@ -6,7 +6,7 @@ import ( "nofx/backtest" "nofx/config" "nofx/crypto" - "nofx/experience" + "nofx/telemetry" "nofx/logger" "nofx/manager" "nofx/mcp" @@ -194,5 +194,5 @@ func initInstallationID(st *store.Store) { } // Set installation ID in experience module - experience.SetInstallationID(installationID) + telemetry.SetInstallationID(installationID) } diff --git a/manager/trader_manager_test.go b/manager/trader_manager_test.go deleted file mode 100644 index 6d3f0bc5..00000000 --- a/manager/trader_manager_test.go +++ /dev/null @@ -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") - } -} diff --git a/market/data.go b/market/data.go index 1860ff54..99dbfc9c 100644 --- a/market/data.go +++ b/market/data.go @@ -1,15 +1,11 @@ package market import ( - "context" "encoding/json" "fmt" "io" "math" "nofx/logger" - "nofx/provider/coinank/coinank_api" - "nofx/provider/coinank/coinank_enum" - "nofx/provider/hyperliquid" "strconv" "strings" "sync" @@ -28,143 +24,6 @@ var ( 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) func Get(symbol string) (*Data, error) { return GetWithExchange(symbol, "binance") @@ -396,398 +255,6 @@ func GetWithTimeframes(symbol string, timeframes []string, primaryTimeframe stri }, 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 func getOpenInterestData(symbol string) (*OIData, error) { 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) 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 -} diff --git a/market/data_indicators.go b/market/data_indicators.go new file mode 100644 index 00000000..a0dcea1f --- /dev/null +++ b/market/data_indicators.go @@ -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) +} diff --git a/market/data_klines.go b/market/data_klines.go new file mode 100644 index 00000000..e4cb869f --- /dev/null +++ b/market/data_klines.go @@ -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 +} diff --git a/provider/alpaca/kline_test.go b/provider/alpaca/kline_test.go deleted file mode 100644 index 6425f332..00000000 --- a/provider/alpaca/kline_test.go +++ /dev/null @@ -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") -} diff --git a/provider/coinank/base_coin_test.go b/provider/coinank/base_coin_test.go deleted file mode 100644 index 47c8a670..00000000 --- a/provider/coinank/base_coin_test.go +++ /dev/null @@ -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) -} diff --git a/provider/coinank/coinank_http_test.go b/provider/coinank/coinank_http_test.go deleted file mode 100644 index 4b795308..00000000 --- a/provider/coinank/coinank_http_test.go +++ /dev/null @@ -1,3 +0,0 @@ -package coinank - -var TestApikey = "" //need fill the apikey before test diff --git a/provider/coinank/instrument_agg_rank_test.go b/provider/coinank/instrument_agg_rank_test.go deleted file mode 100644 index 8a008342..00000000 --- a/provider/coinank/instrument_agg_rank_test.go +++ /dev/null @@ -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) -} diff --git a/provider/coinank/instruments_test.go b/provider/coinank/instruments_test.go deleted file mode 100644 index c6855b14..00000000 --- a/provider/coinank/instruments_test.go +++ /dev/null @@ -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) -} diff --git a/provider/coinank/kline_test.go b/provider/coinank/kline_test.go deleted file mode 100644 index d7e690df..00000000 --- a/provider/coinank/kline_test.go +++ /dev/null @@ -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) -} diff --git a/provider/coinank/liquidation_test.go b/provider/coinank/liquidation_test.go deleted file mode 100644 index 24ce657c..00000000 --- a/provider/coinank/liquidation_test.go +++ /dev/null @@ -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) -} diff --git a/provider/coinank/net_positions_test.go b/provider/coinank/net_positions_test.go deleted file mode 100644 index 2cc3cc0d..00000000 --- a/provider/coinank/net_positions_test.go +++ /dev/null @@ -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) -} diff --git a/provider/coinank/open_interest_test.go b/provider/coinank/open_interest_test.go deleted file mode 100644 index 3bbbd1f9..00000000 --- a/provider/coinank/open_interest_test.go +++ /dev/null @@ -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) -} diff --git a/pyproject.toml b/pyproject.toml deleted file mode 100644 index bbaeecdc..00000000 --- a/pyproject.toml +++ /dev/null @@ -1,7 +0,0 @@ -[project] -name = "nofx" -version = "0.1.0" -description = "Add your description here" -readme = "README.md" -requires-python = ">=3.12" -dependencies = [] diff --git a/screenshots/debate-arena.png b/screenshots/debate-arena.png deleted file mode 100644 index 329b5618edb5304bb67312db6eb7d5a38443bc30..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 454870 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

X^ieRoI@UcKM^C$AopyfZ)sMGsjJsZPEGQ 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 z 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

MZ{w7Q!>DQ0@JeVt8j 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

8TR6f6moyu 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�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%0DPs$fa8*1y4NMrqtIBd-_&6A_U|WlBtV6(WmV|~ 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%0DH}r(5bsQA_m&u9y)E%kBag}au37O@uDsg?A zfBQDwR`Y;5A$~b+@qFM7cYxm{5_F&^&R?69zMQXo6pRz5do^+t=_VSR>yp5nHZiF* zB!`)(;#fTtjm{I3-!#q?HXYCv9bIkz;uhrCIUH-xlQ_Jsc7eCMG&6I?bW?p~8kX!x z`m@^Ksq6duoE(YCmJw*G3lJ})N6Ja!o7GR>?p)ajIHi`_aW=c?3T%z8uh-^y*f&E~3O0WOO7gGcY=UUifMV7pQw zAsjn&t>DxtM=xE^13bj_H|b~}mn+*6PirmTl%AlXm4z&c)4RkbmbJR12>}?|XfyyZ!j)RL+ZTdc+1BD3I5iSQT|W-k7AEL<0WybLH|DJ+3;B3vRI_YMboP|X39hdi}E2srkXjq50oZR zDJzY4L?oEc*!Sily8VAXz-a-1`U$idEXQs>VD6%3g<@qd0n5Phc1|8$W=M-6wvj1# zQty%v=1Q%~-xIIAs_ny(=^^VWBjL%f>1h{RIr-vc27cv#LK}H8F9j#K_v1ov`P&TG z9I4acstNndUWb@o#}t7p_T@Q^g1kx?F-`pYm&kY8`etlUMTm9MLbCb0=iNojH@<{q zWgIV6maN;rC0&=RxFQs`laj}7Zy031l3^&g%U<-CVeetq%n3_Np0)5~PM1UN9XH%y zBy(K$@FZuloXB?%XDzKzx&yj#U_ei&CD*60MaRN9Qh$hr)a;eB9`R7Y?;SfZ`0b!o z50jfyb3gIoI=N^TM{ub%oSyFX7Z%FiUM0F3CSg;Ps6D%n-2I`CxKuveg66%*_KAtw z_@1Lw2iF;YAf?7n3zSeRyuMuBmJrYX?ropmPS@4_(Zb>RY&4zy=ok4%Dlqmr5ZMcGL!M zpmj%qN=e&>W$NHXd^~D#UeFw~6-W+=XR?pQ zH8!c{;5mDEr!=Rrv7hib`>Q`+jw*}K92DmmTx;^Ptg-RAlIk$yiCbE;cw)jTX6`|7 z5L!B3fF7@JDH}aDww>&^U@j+aw1#PWpFxL5OWhz7{j-#Q`J?Rfe`Qt*lp-s)iEJ5L z3yK8+i0gYpsG>li-JM;PCt(;OYO=)AgB|;67J{&pU1~V1oAmX3dEX85+R-E8&^e_P zJ)!7PAVM1+wDV_Y0!;T$@5;R@o!bs5p?O8IrjjHle*X^J{H*hkZPd>Tg`pA0r5(;P zhT6yfME#^XILsyAP+m|zvU9$`0j{y|)n>Rr2M2g&O-#_6LKor$O!+AP7Z*=gtDYDq zSg>P7!(cpJx7rDVnQR5+)xUXSQg4jr40>=flKMX8Eh@BVaqGE}Z>AiGx0f%33pQ^Clxec1h(sznaqA?o zz;BAYN%bbD)k1=I-+wE}50F$OD2Xxwpyak8qXy#&0IG&bhMr701MgtwGc%0u9_wF5 z7|_BP?N;s{wOp#7YVNY!Q^LyV&A1K~nU_eezRaUf3 zpf;3!6`MSL6=!eGMwW7bx(fCG1h%X|+(AIh=wtTQMx1^-WOi!kpM-q$#ZP?83FC7I|0>SYk>iHn>20$#$ZsNJ-wy`*8hkUrsaFQ6NS6o=O2!i`V@7_udD-$U2dj^y;B;D}>W=E;^iFCwXqx##54TDXQVR6!VhFqmFJ76b<5d^-`|b7)fX)?lUSU#Sx>%?R%Enww$mway-=%%~HtAW9;s_95;e@*dW9p*q2#x!crgncGz6JV*|#e7Eq#Rf8fS#RzAk;n7FDf60`Z*+rv_NgyBui&W{3`bg1 z=~TbG#%ZLWgRG@#PzOC>ul>+(8;2M-lDLYJ`yde=vi^p8!K5DH2d|a0wqrPIJwk_y znFYZbm$lUY6Qfz= z-}AXpj{@D$Ku7DGiX5lGQquUhjqmCIP0@6~n(1yJ-P-Hs?6Pt%X22fyx(NUC{syOU z*?d!WjJ3GEJsWp9JD1qT_A%w@hTuEP93bNMX&F7V#XYwKc~91x)r$>kSwRBq#y5sS zp^_+;LTH5IY;yGyV2_fYWt)EggVGo>1z3I*n+NFqZ51C@Z?gWP88sJxzxnu2Hs)^h zf35w&tXcoXhYFJ8JUi!aF=!pQ-n8eSe{mtxN9FQO$R^}$^bP4wgr}l(Kgrsn>c>1b zK_$6sd@qctoI>l4qtS!7E)AIW& zo>`m6l=8+8{}rhu4RU*deK`~R%Odi!Dz5RsRF%oXY$3UCLFsxt-JB1B$b2$hB8 zlz5&GsgVJ;tB!gmWrT@Irb2l#NgtEnZ0opE|MPct`4{z1SOovo4S1tDreMA#9S$%=j*hSLTS9#tVxu;LY}DRCeZ z`m+8q%&MbsmW-C`W{of#V!LR__7_`ajwkA}Z>!hb>W|=KtDJO0A5qm5Qg9d!Ws+SQ z`2L3)D{wOD?Zz=Se);BtJdj?!kGx800HJJ>}X)OEC zM;{@gH@JL%zS!*l1q+B9t=CmTWx;#t6tgOAk`>#z<;-j&{;6;c4rI0C$5q{pRRa9l zg(y*=PbCCcD3|7gH3`=LI|T1RhzvxnyQj5~XQ-NMDk+pLLwd(6(= zaRJk1PJUn=%%SHr$Hu@!rHmB7l=b24_RoXQJNNr2e)=_wP2F=^U#k0x!^mZr;Zp&)3s_fXeX&>m4m+inJ{s>?Hp*_y-B3T;NGiA`oL`kf z=7Lmw<&1oMVfQ>-W!^3t^K$06mFPQxxO?@|kBV<8;R{F+3X~03#df%~LFMT+l8;vJ zUM*^{!Mm>pr+66XnbocQvUk{adb^?&*jN=PMS@OMc6UH>mb&5bRa*3+kBfRK8$Y(C ziFm7&UEO&C|KMmU+g30`bBuu~h8Zl+bzu!vMcDc~RJixst76{(tRWCGvNcI7g;--T z>7G!{oKJ!ZHQJKUrxx8}^G-j;CcImd4SR(D3z<}bUO9`#j!X}yK9-ACuv>=2Gylcs z`q?Y&@7X9dxLh%Up5_el=T3=s=gPGL8@G&5umsw4Z4D)O&t3Acvs>850i&;X7WRAMmA zga#$9vk%0onF~{LiaR6D;xl&a4^mS}u_vx#$@zhMCFecpXgvSFuTW;7 zlEqSn;nx12PY-C#NIp1E-irW9libaTu|7wmt+V02>5Wj{d$(uwts~Hq+>i!W$-#ZI z*eOr#-Nf}BA~|c?V-DA88+IYGnN3kInN<`wrdhrDSq_yFTaz>8~U^w(S`_SKOY}9NTjF zVmy-FQ8D>hmq^m^fSglna+)gv=S+P%v! zw%QIm`~Sm3M?T@qi2_jPxB}8T-3y2Zp{Fv##M-&J=46~Aj+27qY=>%-)tGK&cwNaG z5~LS=IuE8K^X|IZA|s(_Om0}nSC8p+AX3eW3?Q#3>zqHKF69Y_SnmDON+=I(mAV0y z38vUbMyxFVK3{*-uSU;PwGP~)>QyhV!f>!{f^SUq5`M-FB4v^?Oz}1 zBkBK{rfAEi;BfKEnFCTMlmpC6UgxfjSM*puZmD|Y$L3`c=xbzs7Evz^{Kkz;er>LI zc?Oc7H&bCG#umVibh{&P_8#zjOd@=H(vIvE8MQh0sDN?4Oy0@oPEJ%E?c`jqB5Pqf z`Y!uJYS~>+LmZxq1+|n-E)Gci$Z5RXPh$7#F>8_fd3#HUCs*;x-*hCr#+s9M2v>BJ ztkU>?N)8ev<8=q#g^5**n=zN8Jtm($3?ddrB94a^D$9fSj2(Km!HwF&YtGoY`h+Q$ zNyq4~^-AGVJrE;q|9V2L*RQoYCY}nIgu0nBEw$T4m?I93eSHL+Ca<-`vc4-moRf5udDf%b ztFh9rw&VPAdfmGu&D=O6^^{nTvKt27*MTc!_GAZpb8#64=fw9t zqbG+DDhfA@$7>d@S4nPl%NptQ$LnvfOpZc?zw55Y)wXU>QCSFQHDEpdF;0Ya;H_r3 zK_410^-ezH0Fp&--!mkx-CyKrY&u?UA=TN}Ig!8w+Rc-u-F190dv~ow;TkYFmc1Yw zxI;$vmd15v^$J-T9U#8zIHZx`)k ztFGuT-#|Tp?#Ibf1XZf-GMFQTUS6H{EoRhs%KyV@zM0FfSOkBn#TLY*Y#dg^KkIy1 zSjQk!ZlQ2P^M`d>p&>tfJJ0}vVoUz(yS^0lPYj48Q?B8pf;DVTwX@>%6V%xhM$DRf zbkNrv##(8_RM~SFf!yvP4uE8OazEnJq$2=Wh34GG?!+kEzh?GR4EFrvzLK)b$BFr4 zyJ>8Gw-AmSr{1Tx*4>?iAzb{c##N51`z(9LVAdWg8K5AK(-U&(;OxFTlEj4N&({kg4ELR(t|gMnj>~z>dvMS8w6*#fl2c6P?Q+6Avp_+ArSiHCS6kj3 zmxKBJ_0CWRK$X>*dSOBiy&^F61Q@k48Zz53VK1v~=Gl17Q96BCRLEJf{c=V}33=aN z!N9j%QF4|mGW^r|wdqg~m^16P3M>5bEVqz9$u=seP7xv(ynjbF-mim<3i4^O`8Ic? zE1T7q)=)_=%_Xnpb-~e*r~mZH`!sVrI>C;gRU@iIEq0l7F=%Ltv;Q#KyD!2n%fVt= z`g7>8RJFZPpvv%=g56fl!kneVY)5DYX9alE8})&(d-uC#Vy0EH!1--ydAkLsfxkBn zg|CPiC-uZ^blTd#@4GKmai!E$E9#(Uo#2a>r(D+FQapXP)0wUN%*BB_N@yfWn-`Fj zk-+(g`tzN*pDb+OL(EP&B(v&&|s_mI6SHTgH%m+GERCV$(_=NXgCXLL|72 zQHl>ud7N)W9Q|0~0K`&A#YX;fmrl?EV|z;5>uj!6tqN2lFYVM@tIFaL^uLndh{plP zn5iSNVZ(~#XCQc!)TH!b8lVxEDtXIPA|#5}v@DJl8u_k+#=Jii^YDLg&!>6+K7}wn zB;zW*qFKIS#siRhM=Swi;E^iSGTsRRo7uGbW|o~^vB7up!cW9zxBI4Py-JO~_okSO zoI*Om?n-({+)RbbiuXrRI3r>Qvg!H#ZMOnuU|@jDgbU?KGcWZtxqQrp%eiyMWd2)- zv6?5@orC!5m)z0+14)-bfJ-FbLaKqOAj0d7y@`tXZvSew^RwN1FR33nNJUlnV!NBweW}|k4JA5_Q@aQI#Hs|u2@gbJ`@sbzC%QU7{X6;Ih;nv*Egqfz&Mz^KA$)=N9M|`={S#f5nBa4bt35SS`ah#IOX& zLJ+rsZNS}gel9P>nv2F0AoZD_V1QhbkB^nUM% z5z_l(K!MV^N|K3us47~{$JelXR2mkSL66X0YIlphr}N#zXm^>^9{$SmO`G77y`&fk zz~y-cp%p@N0L#)kAgj~`!XvOwX?K(UCj>!m8hz;c+QX*qQjD3NUd7D#$@&hD7vpL( zHv?r;;{udbK%?ch7!;s7;FVW|!sTTtAiIIWpj(Eu(i;v z|L)4{>FKG?bWlZdg)|w+5OGE?xUz%Fr>}1V(I1f_oON|-SAVqv+)>Z0bK3qDICg8X z5borcTA_1|cMQlqi`ldd3fuRpRrBDl5Wb>%`FC^X52Be@{y3ECmb_fm3+o9%)&zTS zb6a-%33*b?AM-mZ;(g3GE*$P1b%C#r<^TKY__!v=+I8}#cow$ZP1-)Q+<>|olj)xZ z`s5w7g+2t|)(+r}lH%rc2HXQ&n-*Am6y|}G)~HiAE`aKug(rO4?}J?ts z%BS89OJC#vH}gIauA99a9&7ix;ye;cbg*Xi{u5}{K)2WVuNOv+T8L6vHrDJUl$Bcy zb=^;_C^Rd9ZjVm#5;3RMNRY}a8TXna#T*9^9nN-<4u^fAn4?*##F}!Om?TT~8N-sk9GK;?=KNzy3QPrz;lWOpIa5ei6Va+chAa(Qt!rrsq6(vy7TibbDrzy>E%_H zy(@{6aM(TvXTeIKn_~wRF@_c0xqtCjbF0~XX^1MiQm_WmAmr&ZUCtEN5>1J5@NygY zYjuaJ6~{w?3h@;J&-D2JJX5bpaF@vN@SwdzURQ0QqOuAG>OK3IdzHUH4kCj%qMk1r zss#;MXvlG%ZNS0kitGwU{@}v=Tt@kA)T2ce{f3WGBtQQL7L_&wGLO$?73fyNqTQQb z-uJj?IgP3ZwG+bw!~5zRQy(ONkJA!NhpYQ3BEn;x-TIv84r80?Zi&-|#p~?>UH0#m z!4!fnEU~1S*wCFYbn9{unV6hT=rS5D)i0l6!NW%v=d;6JbW0o7(?rfZ66ZKS@l^tk zM*V*tO^jR+gqAn|gOb7j?5X@>R=BQTprzH#N>TS5_WNEoR)I*1FcjV}fss6LF5# zW?@ zE~!7>_$p+HyLQb$W%HquGa_s1IeUv{`Cen~k=HF%LzYjHMpI)nkiJ@H1M`wS-Y+6^ zMGnSs`I~71weYiA=v=9ieS!U_jc~d}RfJ%u*jN+VBDzeQw<#@zm`mgcnQ|3 zgeSMM$X;thg`P5_jK#sry50)&aP&!+Rl{x5^vV*myGR3ZY?;jvTI26{K;9H``tJyf zgDax@Dts$wpIy8{?UXL(U7n=(4@0BE^nDreqC1WqP1I&Ta_og)i;Uj)d)G1W!m+?h zvSpJA=lB^Aqin>!rgk7v z$e6JAysJZFOohdxM)(ntAug}s>B5N6=%dP_-ffP8$r8u3(7RkF7%ht9ugM!PWv~uR zW+ragn;$BJOGgVCx}DD|)E2Y6-#s3EoRnFX54YrP$mZ#O`c?%qs*(_NCgNB!z8&1P zy)|~Q{!2t;#D4E#xwYu#*k1dejHI!id^$XD`>!O%W9^4$wuG&TQ_@6n-9${=E+R46 z1ap~Y;YFu`+sO{@re1zkIw)XUE{?L4&3cTAlri+&cr$sO@NPdd?_$P@Z&cQ#+AbHtWYh zLaTWALP&9c$Gat7cDJcaa}K`5LlO-cL3QA}yf7+_sl!^TU{yDRW5*lrq)*dzy?KzmTk%Qld=1EVAgsDzn0fg1N z#t)X<3_MLo1na(SSsIXq4??(6j#kz;N$=-(<&f==jt}BTIIn~|!pD!Mi*<0X%d%9i zb@GU4Tq-B)WIQEZ1-=8mKlo{#mYuB3+x(ds=;9aFHcVBVgo2)xDYZZpXRnFb6XuAr zv{hs8e@|bH7W}vNSPbv9k z<)Hx6QAM+qX|3fv}PW*nECjbb6L#=L~2dMzv3~T5#xijp+S;Ub~K7kH*twwp3hN`l;@Wt~7 z`hq8NUJ9DQ;X2|tqrDZ*W?1-v!fI46I@ zjB5fjmy+OE(9|M=*?YlEx4`dW7R7u*G;HL3JTmGzHbBK)6hZgaDRcFZ`vt~|vo&{n z^dYPBXWp9y>^7s{jFV1(&KHJ7KbfK!|6%u#wPm^uXPO-lEBr2#^!Sa8!JZcJPtxlp zI0HW_J%YBONJa5#qU4js9M@90mJeDi?&tQe?~j+WHx>WBE^~ zyQ{AqXf`@3GUyY&Z?m^I@jWWX>tMDS$5GNWgMSHGo9rSn1{>7pi%M!aB@fyq`cd#pp-&BkC43H@rN#OnoFcd0J8)r(IxN$!+ zM%}{vj?tUk8q@?d%mN%n1H$Tr?JX|6xGl4`r$QtW8=HD{6(5X41SGGGLn0Jq1t6}T zd2Lz+<&q1QsbNI8w7^ZySv9eteQHE8_AIyK-Q8UV?ynmi@;2p|=pfE*H`eYf+4edW zro?g#*M;=D$B#m$f=5d?3m$XQJgtpKF*S;8luM?-x7sl#sGCqZqn z8=P#bURoX7`?{!ce~UVsW|Uk*l9kxsO4 z$$*!!zzs2XC(qUg+z3ta<1I;7yrp5 z4gISvgL=s}z}<26=>QdwVvgJl?a%k-+w3z`&Ns7r-GEBf6QM2Q!?n@`rxhPt7yL& zjw=}5_g7M<05_^z33!+_7A!(15@ujq?M@Y_i#OeZyL6)wC2-* zeByVRt9l}YqDGw?u2{pK4w{P1*Ze-GFbH4pkZ}gS9QZw7x{V{W+9lmt3pG`8c5S-J zE=fuIqND2de+htph3$XGZCelu@BgOY+(y%<$(KZ04c=R>_WXPq z^dO!bTz93ZrRMu8Ve^SU^=4+XZn9l~#bA_d^bt)f}1!IpV6xIW`|@S|(0 z#4N_GI$zhGKC;QsSH>+up}Gj8%X!p=gVYx9WI0urcgen2+0MFOQDdPcLnDsXDQP=I zB#ysw>nVu|MdBA=M^eNcetBEm#gIuc4$*@xv&Jqy_NDKU*lXE%A(mNv&l>KxZnwog zbFLdAfjeGrpa;18c8rebcEh9DV8u?dYkpy^c+8udp8VM9= zLevqEb`@tE7llcr5XNZ+qiR2Ff1`AzA)RK{N$;$$}l z;ci)20Iuqic7yV+V*d^AJxUH7%d|vwh-b*Uy5OM_D8@LP0{SWJ^K<*$KY6nTvydPG z4wbLro*b$koG(SFbI&}o?4POH(~r~RUfL1;6OoA^IhNcDRcuq0{eShlM# zR^GqQo;S8W?Z`2MeHbuHXS#)xLcE|p(4gSdqoZanWvXK;oU5;Qo~*dZ+4}_b$qAwW zNnx#{lKz{lzt~nJ-Lu)z7*;$getC^7kStc4a?eyn7Ex}7^=RE-c+pE;cVF(VN2Kn` z>e~)u(<{#8hg7@@^X*z+#Omedv(&(NZR-dZ>&`MSkOWh)+@}ya^l>xk>T`DU#JU@R z#t0+vW5I?Hd!fq67axd9cu|Tu0;P3A6F9%>v?Jh^9xPb{Ym6xL?z*=`s+%M z!Z0t=*=QELliJn;DMOjfmFl`XW|bjDdNzZ}m42B}=Ua)MyDe;UgEwn4Vv@zCv`(C= z!0jYC$Niof(-M~9+~Qb~BM4yOtB@k$5H(jVbXYMU?(oi|#fq?7s$ZrVTpPS#+k3Pf z3uULEAI8Ml`yvN);l}S-(ZpQj*1pLJ6LNy!8&PwBF2E4ZHr2-pd{3h2eXS&lrIt{y zsd`GYV#tZHxD$6`7WA}QWXoX;=6$TZjAV@szVIlL>RUU;loS)=pNLcZtsq6LwvZ#Z`>oe}?He?MV?63=ZeER?I3P+c=WZ)rg1wmF?pGGw|BPDP9k z_iR=2ry(te3wRuO3+mdO9N`Mc9zqMBt?qYFrW%S+&Yw z%iPo-g;`JQz3^o@?^(17+aY0Avk|2g8T1 z`vu|kcQCHLZGk>}8p$L=KjK=Ff{Xqp>n`#qMpIfcDi?O(|FNeK=U?Ln4_W0v7qsl*AR;xH9EOf~jL-bvNhE(edf zIm%7k`JrD~Xiwz~MT-hPL87)^{xHPEDfO^;q~`DR`5DeNvU$B2cisjoQcm&ouQ^24 z4#@A!LB~#q6MT+S&Zi;VJE5n5{95GXL{_;l2pw5~eTCCa_3`sYLe<$Qg(9*($K>4% zu@UFS9k(3w**lW}J?m5-Ew}(L=!Ny!nPCn%Qm~eKKO`W8a zKVd`;Nyu6*9bW@K+?q!4v5Z(WhTx>E6?W$cG6zZ*=PfhVu(M7_WbMUP%0(ZS&t8yt zt=NWf42(F)zcr#jycH6F0<|KJ877w!kS4y15(@7Wj+hkQC>*#bEOEIijujv`$~^T= zZ|thSVK&`M^?@r~XcFSo>e?MZ9&xs0INs%o)YS9CN z)o$&NQf>F%PLC3lRK9jnM5Es~7seAKT~Wt^FuDbY+^uIuxKe8%_|YQl?b&|K2r8Q; zJv!o&tazV|E+WA(+%w=I=u=|Y&ZwBCBuXS(AS)ewc4d^;r0!c?oOC3KVRoV^!;)## z`5EXaksO^awYd75=3&3p=o3kVoz8>$#(V0RMHR#L-!&hVy!SNNJ90FVA&WnuO#TM# zJn%i!w<36FVcetqkp8G}^ZMJVDe~Tu%4`qHSpj3W0uM_r>Pq!p@zfXgBTg0}wl!lK zRy7}5X)>S5Zay3g^8EdCTK`yY_9fTn(VQduqSp@j`-Z&pOv((=`;}z^dIYF5*(Xj4 zJ|Gg>$PPi#BFF$;LY#w@Tk|sfofKfPtx%oDdP%klYZoITOUANwn(lz?Qb~5ec{kE* z$vJvX%I}gEl%@Dg2yL#7$oC}s3V!g}@7N40@ahzuPIOJ>f`E^H%e7Mt;1W)~Od7w2&` zt2riC5k5nq9Ct`e+gPC372iXZ;<=(jo<`b3pBAXRgTmQQ!o=B6QIn&+l!LfQWYCdhb?57Tm9A!|K1YDVtq1W%it%Tu4-IkDZl z0UohHc`-}=8cy)!%6i1k`Sl)F;E&%`7kc26w*1_<`u{B0z`Y84dRSVG_l^BGv@pZ7 zxu8~zOtskY+lBI&&WC*IJ3J#S#uiN?JA1UmE1>D^+PZGv!2_;MV0T1EwN%_ zieB^YJ7e#BRG$pr>3h!6@KovhdEY0AXP!UX-(i~4Wt(=XjQM~obyV;Kr`n_^xg$c; zBe9a%G{@g!#oBMsx7hOAUx#)VrN3wppdo%#mfy_TZt7^4qeZ_r!v1Hg8$|UWMTH>2 zA1UkQ^knn4?9fa5N8=jh6B6lzNtjY3n{LwmQp#@o)fQ)-_dDk&FDTrb&u05@?4#Pv zrML8K-7YT-XL*xM=1tjG+SJ#i)N5ZDs><5Gp`y8n*ZL#fE?{h`v_HY^Wr?FgKnEw z;p0;0iW-IuSrSWaPu3qJd;fB1Dce5?XgJA$$+QMi*2*p|O4ZSzpqJ{{#5+Ro;jfsg zoG*sbl|seW&-*ogJ%N!F-NP}gwZ_@KsNFqNI@5MUlbY?y-RcQClD{E0H>x_lSNzd| z2Rd&?X_-y`Q8HRxjw&O$xQqW8bj1}40QbqtWu;bNAMFPsTD6zjt{M3B`H0sxXBQ-j zyU@S-h5(9pLX>jzbOJ)?sm06?7vR!6UK?1VXoyD6iaUq))c&dx{U{l*5OjO{&rZ1aM%DBj zmkS)xr3WT~)lm9zZ^o#Nc;1L1Yfh)&lfJ)bP{5@IJJ;xaiJkDBR zrLeQH6b$xa#1Dhsuc8OA_e+_SkY%`<)y7Lco}!dW7G!UIdNfrC%_w8E(dyQh9(s$RR!jBgF)D#IRC6^!O!w-3d|^$vL^VJAlg zT%Ix{Bm*atnWb#^T$o7id?!Q_7ns$3@*3Oqyce|O2`N`8A=Q`K`)hMaWUI=oN$1XA zPa2gHR`RlqZZ^hkD%!Vw!Iue@w8mE$wB^rKv;Q7ru6XNTyOm84-_dWH5U=(zbzx`Fa&UV|)!6^lk^ypV8M+QR6Qm^2^s1^SWqn7_l6 z+!wOSH+qxroDsW4>v^ZqW%+s>dYG)Oh5aJcU3B<<*aV+ih7;P)i(}>5L|*m4zH_Z~ zcw>wxCPTBj{7Y%FGuK*8TJeyYjZchtIQzr{!+i6!((I`24DgSk5ASU}uQDf6lPH>A zWuPhNll<$e?)l&wtJ7oF-OtUMGyL+-UDwP$%(+;jlE$=PQ5S{uXXcJk3j6li3#^2S60|OCe@+vG3Y1qG&FCt$j$sF>znVBK3C3ETA`A37oo;ETv7#|%Mw5{O(%*bW_O;vwk*iQ>@ zEY*v3U$FE05!{q?dV`#{y0%;uM@J+FbjxDB)v&u^h(xI}n<#@&OljF0=yaJ49O=|G zeViQvwh29hd{z@v9I=Nr1tli`K*J(OWP|(;UTSsrgUoqPd zq%XG~vpzoxl^;BC1FHk@;+3~yqA3CU$!LY+c9XGClf(DO1&}?#?i880iyg6m%cPMN ztFKTbPJtt%&$=FX2$6`+Eq&yl%{ZUk#}6iA+v@oug~KMEcJ>k06ix$b4(yCA8;7W2 z^X;}6rI_NgW0SLVxzk@QTT=>nc_OG?JTZNMcPuIM9+}v}oiC+SJNRv5PXo@XVBX_T ztOecAUlYdk&W~XZ7ycde6jZr=6VArC1vIqgpwcl6o5i($>@9obk&IZg^>PADh~ANi(K z-et++LNmYgiIR68b_aT6C&C3BWy&d&NcN*ufv&|h`m~)@u!qkawDRJ4)vHH+H!ao2 zp|Z!xcLowiw5Ncs>x@|qNN>&nvyifjtH9B>M9=U2Q=Uy~ySgt!b)12IrSPW=;( z*w{QV%Bx2oJxHtWPB+zrNjV971TCQEJe3*7n^AGStF+uitajHdXOt_9uL-{ybaxnYcQ(@vT!mbvfQU>-8Qr)WIBb<7*w6f zH>n!sxg$5PkeRD+m9kvCDY=;hn9u@4Yw4RuCiZAmTc&R!x9uCa)^8OyNp04Nj@@AW zi)e4)nx}rK32*R^TU0M^D16Q{9?kH%HLq{Ja*ZXqj0n zsN|-_z8XkAo554W#usWGD1Y_9EWjd1F<#zckT1ZdQvPLH1j&PrBk5)aJvZ(j`zK&Uh0A2T2PS;6M0~2P*aKy0f_BBZ~c01>fT3+|~-g;@xY6W4e%GkGU`gAnhXFn7^ zv5{P3HnYF@7};W%?7-49Cs*!1n}n}WAo3#ThYFlh2?2W5O)II$dLa|r0k3s zYPr3Dl`$9NQxakh_QC+!HKMT8805FiGL1uRs`y<<;Ai(?E~`y%uIQ5=-3U&d&SqWi zG<2&gT&dZ7mSP=IK?kulADK(1`f;*LxX48+8+-1^BM;eUOmV~>1#_tEIR|mn@#0U- z-iEQY`b8`BxNyydVB+pr?RTVb-t4|zVLG! z#-x-6I)&T4*ad+XVYN|d*F*l|HFEP4%lP^{P7&$V6@pps zy0Sa|?YTIH5jnn$Qk19XZ=dO<(Cp}vp&Dr-o1da8>$F`j_PN|}Ph~Gk9~M=U`+Mj! zUmMkb4MI0LJnF2sqjucb4JW5Nk&G$u>b4|;inw{m=eCRtbW2wOS1MLe79)N`IFk`D zd~Kw_iqE^%nafy@4tdoNLXDYk0XA zf0TqlP{~a$T+b2bC(b>F7c*lLIe~XTO!8&nl4z6JNw1^pwj@w@{G{-cc>{#`_6tp^ z+pQn#A8|;CVi~J7xWZB&8%8{F(~!@aYPp5^x=?x^z~F3%&XGNTS?=-UEQ>BHTJHxR zMoXMCee_BCkXxu6WT4v8U^2EyyH=KK;Y8z|p&Car#zgAqdG zxLiT0P4$uNOZE(W^N5^W-1yJ^-NR=K0yXRXtA;;4O5FZlsn#_P zqQwd0Z?f#K;VvW&w?NA-rp-sUfJg0mrr+?-w#-w+Ac`?J0fANlfzPUop;IKGw|d*% z%>OeQc;)}$>Mh)&`l9#IGed(gAf1DBNGRPPN{DnSARr(uAR##npn!Cvv~);Hcc-HC zFn}P<&;tw&cRt@=Jonyz;5_F!XYKW_z4sdvnH<0inO7qcG|W;;G+sO)0|pkh>L;>Z zw-Ma%LErv=Ce_b`KMGH40M9q4AH>LfLDe~q`erF32RkHPr&V35JU*7bz*sN$J-R5r zx96d?m*-a_Wcie7k3T4O*x0tLqfXzgC=ywCDH7)I}}!-<4?Dy^;#}0mJ`!qmKB8*J^{M8soY2d5*17mt?2;RoU{cFzv{2-3+COW$ zUP4Nh_IT=V%)UZ>3`&SM7HPO%{$i1TcW|%&;=Gq9mbMBUkPo;W9cdWN*{&lp^uvb9hN#ykD@R=SK`iBJ&6^rFOW_w$XW@xqujkL z(}v+2mosGR`A{e(Jm`iN!yR;Ndv$PkY!MKF@()4{252dDk$7y~jL|CCb)IBEKR`ri zzFU&DLoY6J+?UUCU>CjK`w0Y00Qh|!;ZPQK&A&?TtX|Ew0UjFRyrm#@_WUx<3S)od zOzS$RaIw=_w{%{W(=gk%&DgdGJCcC?GA4laGro4}WJD5FNv{0+s(K}|v}AX?brx8% zTlG=AFDdb29|(e;Pg}fq9f1U`_=i;8)!`8yc^#9R1fBb84aIrBE-$!R92B2t2rS_d;kIh72xadHi#r&75rSrj6j>17h}vlpP7}%x?c#)|nxk{?%B#EqaEyYu6+2 z3l2FzsFwL<$fzG5h&81BZ;2aVw;p{sItLmXARCJV9gBss+rP^7Lvq21GTx|Mz}HWt%<{rUdQ z6&Lp50YnkPXpX`n)W9K7zwl@UlY3iMwNlW@JS{LL^ozezKVWa=I+?2yGUzbhKdXMl z$L}IDM?asE9oIY2AgR%!_W|YB1CYbZ_r-K~9}nq3-E3CI%k zN`Qeeh7r`Y^AZ;qF!A4Q69V@z!B;a#=CG0(K(f7gz(r%LE?Fnm?ir4%X3@ zyM@bLcHt3x6k!zezrFFtjBJ`U?6t>xyADp0{Wf9}OxY+St26lniopG2o%1ax;#Kgfwp?D9)p-pSqaQ?I1c{i7lU>arL$@9{; zzg>N=r!}q`ye>kjHZ&GhQs26G2Tjf#v|1{AP`lEy^0;^&NkJ~ycnB3oqavio zo86(+SWX#s17A-|E+W49DJV?57T^yVhN<&oDtD!ZgyO^gu?yYKg=7)y|qQ9 zEDAt7UYG}h5~{rjhnPZupYagjPdn+L6B*VbivvqM*UDf3DGDIhJw7y=zx)zip<=() ze!ug_%9+8neC(3PQL7M)R!r|&kTdJAhI@L`61yq(46tBzq*a%<>IXiMV5x%oG|)_y z*LOi#6t_dy*0;=xPc_0VnB)<|O8T!Ik0#XVo+`&g;d-Cfq|`L;SkA@nigd21Ia4Z_ zJS8nPVPlfVRoyF@*Ha?q`6Y37|%Ff$jY$%dat-v5|X;n>iap%gV$|U7~Ll4h{?ijFAMPzQ}KZ_&&&@< zqnH8@2dSDHhp)9#w^`zihb~F=QvV!B&p&XQP8lJMJ9a%PJmm~Jv2F!spZa~Xy-UZq z$$<6#qJI_OHw-;|RSH-cfhrS_Ci^c!NKHjHH*E!{1s+D6M8Gu2M>)hzI9y}NbR=r_ zrHzob3#WEF6Jr~<3?hS`V~nD6 zvXS1k%3#dIuy?@@xj*;7UdoJxFRytfmkV_9j=*Kwz5dL}nH_Mma^C|0w7ZYofAh5+ zbg^-c#0ksw-2+grempptH%p{Tg}fv0HqkUnBWx_JhlVWAFKwXI52;HRyr7MjYW6B#}A;Tjxvn@bR&|b{ici@KxW(>Xm@$ss~3U zN$3x`Gr3w4Z1p+0E4M$OhXD={f%EQ~T&SrtYxHqGzsT$2Pf=GLdScm8Bu*L~iDG-9 zsvJ>+_WO#z!=EX7?-2U}K(SHJLDA2Juf_oD{K;7&|Lme3J7AJwaf0Rhd_`Yd^T&0a zn^SiNQH@>c9q_e#=tapqgI+~IW;xR6sb0;D{qQ6yyHZKCEu6qszkb2mb-PwG--Oq zTKC&3;6E4<(aK4s4<8NH{x^cd!+!ogIV{SWfLv!;&DSjMhJh=_Mm6At>@KSXGNtYY zr@tKCo_cv14ypVxGAlJLgm2MDE4=dhP0l4KB_e<4iS(}3XginvGo~I~0mFCL8BV(` z=XKOc%X9a1Cv{)7n!qWnRoqo+IKbeQDu*Sm-#AjD@7G0*>YDOai9+Uzos_PuL*bVB zegnQdK^XPH_Fw}cbkqZ})3r8Kae!89rCBc}J{))idK~;vNzjtoO|#cDI2i3bDQwgu zX7lAdytjt;*_OxqBoqNBQAHG4Z~Rg$uE$?4-(@Sn%nbEQ53 za95o^-G`<(CMhADAabvBy;j_3l3Ks{Nlp7*3M8u}9|{l*v&@xN*X5@dY3zItZB z5Nqt|`I++bNrUh5)O1mBx)9 zDMreOlMBs^zYy_V0p$7X4P72J4v6T-5A=Wi6(QV}!>@ z2>YXy3KX}sNVKJwsl{t8@x0g;+9|87V`UYQ=yeaWt+hR_COj%mS8QrC;qkLa3nU_S z(_<-kqW0X#PiRntqqb$1(F-nti*GoljkU6ek_ED8oh*1rx)a5J0}iDcTGqQJ(A{)^ zC88y}U_G;v)M1LoL2klZV?NP0QuG6bX)MJV4FppsH(@=k-|z-vN(wUa<>@dsP+g~7 z!f_;79~JM=UO^k)AzJm%oXmbBFp_536nxqZB@fq2rTfMCnFnJ4%S+Il8qif5-r#bZ zt!XOVX-8@L9l!P6r0LEa8>`I614*-PkE)jDw;rvgb5DqONW^~(!o(F=S&Z+Iyxl7C zM_&X4(|5UBdb!6Wcx@V@{hZ|E#L*(v>zFA@xLAwi zDvOW2>OXOoltD-WUW6V*fUD=wJWS(C`0t;0`*X!s<#oOh+EVDXe1WwDRYvP@_}8AH z95-Z=bypedHwX@y%W%EoM<*Wv@qbEclJT0aJ-2TLVQ%dTU>9-4vgxU*AS9ac}!`!f5$4uWude zsyd!uzsq~kzGv`?^caUP>+Ocj`oDGJE@;k^md4Nmq1%MMFD$?q< zEH~uKanMbNCIo{>CAH-v0+pbcVSN_6{|Kz@OAnS=fj)VwOze3qqU1b;WsT^gUUnJD zdNn8WCfqti;!*SiQd?c95=}5okEDdWuH50uDwD4jqb<4MQ2u+v2F0`q;_sH76SZ<< zuwcQ09a<>Kvb#$}+#=$qc!!qi=l&%di9KE)P97efLccOujC#2fZ`C6uE_8l{WYx8l zk-ys#;VUdTUbRIlGoLv*2T4s4hxW>nqgqz|AUs-4Qa17E=ZNdgnG>iz#cehM8dFR}p!uRRajRtY#DZ#Y|4)P_y6 zVyDetro>uf#9n;z_->#oX=+r1a~t2$_y_v>W6B|X4KHzBIlJ(Y^n>frL{j$$(1}`z z&s)5gBNM`OBP&a8==@#uc|q}}$?=8`WG^ZEv94?m*}vq_Aso~7c9oY*+D2vX*c(dp zV;2$1lC~(QAN235yC3lL%t85238`*Iy@uIpdvNAgdvVv-ucmfF+Uvfeswz<76^JJ? z(J9=r7Ciye=55IrA`klPa-_GB6gf==oMhtmC4!sPsDIRMTJH1kk*;f9x)q3z^Rom) zFT23<7(CL$Ly8hgH3}xc1XvitY0~+cE(gDtltLfJf543fc>-hrWKHu`Kdla{_8w)N zJb`-&$u_U4@Ke^vfa;iY0E%Xn#{e$~?KECvf5mphU^B||*JKj(6nx)O#2t|s+k~FM zsU;5&sMdE2m!`C^RK@6s9~IkeKt>)wu=X!=%CD!i_mpAa*g7Kj;V9Ws=sJ0LT^&L7 z^I0=^TpR9F3ab#mOv*k-N5|u9o}6Q}*`ye`ECwMn~OQYC9x@)=V&3+O^lsPG9Co7p&bDEv%x1YAMW(qGtLoycT zvC5qu+)D$93(=&Q3VMM9b`Nf-dec#LM(@FZl`xw%Gi?1|Jvc7Bi>xawmm)q)K*4MU zu*A)a;OFV_odD_Ljv|P|h!zO@0Af5txCdCNfPl}6+m>Wsr)W8PncnvO!(3ceHZ!;i z@Mx#+NN@dLCP-0zP}sErUNK2{jr@2!}CE+9jEluH)a+Fa)8%O~r4xX!;-aLG3P z;2r-iHxzSPePVU>&!j7|B_3j`WPtkCQltHqc+h*yiu($C8Nv!}AnG7SQ-q&8`Ztt8 z-6gC1pY92j@Vb@z4OqsU_feS9L>RO;afNuVDHvuQ)lG$4Bf|r5$#_+56S(hB88D*$ z7VRgi%eQc!uH669Pcxn6W^T7g(h{d$ydz}tf~lT}W%`B4H(q*<9!YL(WofVk4xl>v z!zjCSZfcBe5+d2E9zX-Ew;bb>GKUXW%q+l`i;y)!-S zb}5(TKgLQv2|GUx(-Xi`w%2ihAy?l2#Tx%lH$M9k8(t4RMd#f=`IjjR7ybSiV*)NC zCumY+P!hQ$RwPj3=z&wMkvg-z_^YqxhC{~&e|j6jAL80q_`0u`Yb7Dox?3=q7NzVP zLW2lr1|(L9-3Y}K6plXUUrgcJR0j*MB+xqGaT}_3Gdm|r(2Ki0^k3uU))wy>4i}zN zzd(eTe6?WILKpjIh7(F5Pp`?^ZO|2$n+sIi0C5c;T$iSiWda zTOWAuYD%Sm!eoG@+ZHp8e?WIV()G2sF4qOo)_r5lPUDL}B4fGFJnZ5rX!%-YiL_e@N z@M*BkehKyi8#?1zM&K;y4pNh|9p3kUnxMj}-WBHqHt7R>iGN)kCcE~MGwyNl6JNOd zy)XLmeqRWmSg~*7#gktQ`RiPaj*=>FzP9nw7J16xtRIN?QF4PxoWvlJrABCoyQE)! zk4vZQ^8I)3UVJ%GLju{I5}}I(vQjf8orVx&;E~cRkRk?eq&l>zjPjz|kib~S5Qul% z)s>6yN@wNVER}G_?ch<}i)OA(JCSErX6{4%#R?b|de$mb=TWanCdOGlS~u>C1;1Ft zUO=ccAh_wuy}7h^EiDeyC6oEj!z+?QBdVMp_#0U4QcoFv?W5p$>xZxTBiC@HzA0H- zRrjv?Um;Wo{Jy;$(AE@~GqD|0DVr4UsI~W?6=w!+v7s`)$rgrR&lrpDaXcWR=PNB+ zlAV_Qono*-55|4cBOA&y5z#=Hw+xTWWg{+;%!L!Pv03Nm!3j&?l*k5``OpeX_20&& zM)MDgtuukMf%_M`7J*T}N-gx%ZOa~C#T`g*?*5zm{r4*KGV8e1E=S*N+~I3C&8VOY zDRFw;$g@p1dE`boQlK-h_xn|Ki8umA^RqS@#9bZHhQ@Y_^*XDdu0!hO+|LC7 zFjzHA41hq`7>f-M4*+FK;y&z>hx`cvb&-t3<3H3nNr!OazQ7H`iZ5ukddXY}^>o5S zF~R&HJdVc8YHndy_n%1;1rW)X-n$6V1Ph=P0`*%XmnSAvMu>deqnSbU3g*kt+z1u$ z$`HlT=o)o~bv4?Bq_~3dAUMT(LM~Ycn|!ts^Ytl+)pniHVV5MNcKX~(`=%E^uVaAB zp##qZkJ;<{amZtENV;82DHK%FFimjDu3(>I9sGKPBsW1@0hg#oSuUWE6h-`Y`#L|f zi*=9za;eDN{d9!H-lIcDd$sIXNykW0>(p!ik|IJn=n!&eNI-N+47>^LSZRf4Z^X8w z`)9fG{q}kPf^j#gs-_o`b;dw&8v3}lbjQqP2dVqAQYMQ;I=n{HH|W}vR1<%@jSLXx z8Jgr-rZv9Cw|&SA06doXhvW~B^UIsMntkvhaSwQu%|V=95qWvII&f@S!9CVb)=DzZ zm`3nfKY4-#pKT`)*yOQbogsgb_KIA8a~T=cJ`Gn>7Qh+cp1ZG>AKm$JG7jh^n5Yp@ z>U`O~)hOs!E*~D5 zNXyB9KSdDhX@Ut+Sh}{7WQ^3!F+1RuO}M3tZ<;RSoWO0O!i?+&h3_pd+y@nKmjGCAbOO8 zK&Nqa&ELd}wrQ13>DiG)PM`~oUPArj?An)0FCCxN(D~D?2f$ZnZSdd#h~?V1gjN)6 zK=_u>3;@MrqoZgK6@AkHm+=_EmMD^&{5IBNMKTP6cC808ffs+6 z_BfdpevJ7ZCt@vOyO#tvce^w#M$YhO@qvzg$uvj$3?0tx#HgY_R!lne!N{$l&=+ zBg0^f7NeE^efSB3%Z>-!NKH$9^t`cz&V%C^_-S0Mcv7LEt!zT1?Z^O9Hnc zmoKYAF$0DD;^(;vMFmbo!VN3O-IBx)iSt(C0w|l-Om1>J24bZVPZ;=-#hS<-F~?i2Aj|6c{mTItY5l$b=ZrIi}TpmmU;WMa)hGy+qhaFTa7-CHeubR4XI~0 z)GqEW-DQp=vnqUs41w*qc+4DlfFw-Q2#BDX#`$T<@1~LsF~DWO)x!O0pt7u1jTNbj zvW6(;I1{N55)(zl3vN9?aI=1vgR&8+jSygJcoo_Iut|QDLyO6;jgqYoQ4&tR*9)e& z(2~Qb8z<8bMn^$Laffi5ai4jo^topswrgzRb651#`CT)&kgm} z-~)N4d{V5ZLqveV8gzX~-&{!)bzi;sVk(pOjczV!DKm%zjn@#cJlp}NKMv11QU4Rx zf`ax`ZoHHq;&NCclAM6X^5)n4$<#^ZdDd-_#h$z>OJPPuWyqXG!T>p)RUp8fn<5xc z)G^v@%l)?TdXw=~tg^QB7-XgFVkrXC`a^JM)~6anuzqBSB?;CUD)5vazgou?^4i}Y zUiuO$@GCUC1_!uDES(4bKNU#{Bx?ps!NZ}a1XJTz<4n=gyM!M=Bpe{irg$$a5;ESA zSkYTaLSdpFowN$JIeUw)=LyCkvIXPfgSV=zl18me<=OAP<24OUJ~mj6*>z|Z@I7H9 z+E|XdER+qFy5z~_J|F0~40gF5Aj9fhfOz-m)_vU669TGMXU>V?V$GO1WPXQYCfoU8 z+R?Ybz>?5_V8dVu|$8b=6d5LV{mG7{@+k&{r$Vq=nhq(9HwDT!CdqFzdkrrrZ z{)o=>fx>nz|5iA&lGHDd?@5&VwAqhS_Ge-Z&cl=PwxV~^IqI{LdTH{@IC~B+cvP8D zBDA~uM$2DT%j{pD_sA~C301IT)Y)BK(*U6t1&jBWIqUHnaUXwmc1zGvjQ^w&5erVm z#naT9Hn7VA=A)cOR?G-6?%CW)URr{eLRgvqPS_4l04|3;8!ymx_d2t6h z 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%0DZ0+RM9z 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 aO4TW84LjW%xJ3Cfz);s%=ew3&`2PbbMUBh= diff --git a/scripts/ENCRYPTION_README.md b/scripts/ENCRYPTION_README.md deleted file mode 100644 index 672ad0d8..00000000 --- a/scripts/ENCRYPTION_README.md +++ /dev/null @@ -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: - rsa_key.pub: ---- -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. 系统日志中的加密初始化信息 \ No newline at end of file diff --git a/scripts/cleanup_duplicates.go b/scripts/cleanup_duplicates.go deleted file mode 100644 index 87b7853e..00000000 --- a/scripts/cleanup_duplicates.go +++ /dev/null @@ -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⚠️ 仍有重复数据,可能需要手动检查") - } -} diff --git a/scripts/clear_orders.go b/scripts/clear_orders.go deleted file mode 100644 index 934284cc..00000000 --- a/scripts/clear_orders.go +++ /dev/null @@ -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⚠️ 清空未完成,请检查数据库") - } -} diff --git a/scripts/diagnose_orders.go b/scripts/diagnose_orders.go deleted file mode 100644 index 0a1b2bed..00000000 --- a/scripts/diagnose_orders.go +++ /dev/null @@ -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 ") - 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 -} diff --git a/scripts/fix_order_data.go b/scripts/fix_order_data.go deleted file mode 100644 index eac8d4b6..00000000 --- a/scripts/fix_order_data.go +++ /dev/null @@ -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⚠️ 仍有部分订单无法修复,可能需要手动检查") - } -} diff --git a/scripts/migrate_encryption.go b/scripts/migrate_encryption.go deleted file mode 100644 index bfdb120e..00000000 --- a/scripts/migrate_encryption.go +++ /dev/null @@ -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 -} diff --git a/scripts/pr-check.sh b/scripts/pr-check.sh deleted file mode 100755 index 277254d7..00000000 --- a/scripts/pr-check.sh +++ /dev/null @@ -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 "" diff --git a/scripts/pr-fix.sh b/scripts/pr-fix.sh deleted file mode 100755 index f643cd56..00000000 --- a/scripts/pr-fix.sh +++ /dev/null @@ -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 " (): " - 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 "" diff --git a/scripts/restart_and_test.sh b/scripts/restart_and_test.sh deleted file mode 100644 index 740c7542..00000000 --- a/scripts/restart_and_test.sh +++ /dev/null @@ -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 "" diff --git a/scripts/test_lighter_orders.go b/scripts/test_lighter_orders.go deleted file mode 100644 index e064cac2..00000000 --- a/scripts/test_lighter_orders.go +++ /dev/null @@ -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") -} diff --git a/store/position.go b/store/position.go index b62a260f..92463db9 100644 --- a/store/position.go +++ b/store/position.go @@ -60,19 +60,36 @@ func getPriceDecimalPlaces(price float64) int { return len(s) - idx - 1 } -// 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"` +// 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) } // TraderPosition position record @@ -400,585 +417,6 @@ func (s *PositionStore) GetAllOpenPositions() ([]*TraderPosition, error) { 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 func (s *PositionStore) ExistsWithExchangePositionID(exchangeID, exchangePositionID string) (bool, error) { if exchangePositionID == "" { @@ -1017,124 +455,6 @@ func (s *PositionStore) GetOpenPositionByExchangePositionID(exchangeID, exchange 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 func (s *PositionStore) CreateOpenPosition(pos *TraderPosition) error { if pos.ExchangePositionID != "" && pos.ExchangeID != "" { @@ -1196,21 +516,3 @@ func (s *PositionStore) ClosePositionWithAccurateData(id int64, exitPrice float6 "updated_at": time.Now().UTC().UnixMilli(), }).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 -} diff --git a/store/position_history.go b/store/position_history.go new file mode 100644 index 00000000..d217839f --- /dev/null +++ b/store/position_history.go @@ -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 +} diff --git a/store/position_query.go b/store/position_query.go new file mode 100644 index 00000000..b50a5034 --- /dev/null +++ b/store/position_query.go @@ -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 +} diff --git a/experience/experience.go b/telemetry/experience.go similarity index 90% rename from experience/experience.go rename to telemetry/experience.go index b6a24430..69f57c37 100644 --- a/experience/experience.go +++ b/telemetry/experience.go @@ -1,5 +1,5 @@ -// Package experience handles product telemetry -package experience +// Package telemetry handles product telemetry +package telemetry import ( "bytes" @@ -28,13 +28,13 @@ type Client struct { } type TradeEvent struct { - Exchange string - TradeType string - Symbol string - AmountUSD float64 - Leverage int - UserID string - TraderID string + Exchange string + TradeType string + Symbol string + AmountUSD float64 + Leverage int + UserID string + TraderID string } type AIUsageEvent struct { @@ -129,10 +129,10 @@ func sendTradeEvent(event TradeEvent) error { "symbol": event.Symbol, "amount_usd": event.AmountUSD, "leverage": event.Leverage, - "installation_id": installationID, // For counting active installations - "user_id": event.UserID, // For counting active users - "trader_id": event.TraderID, // For counting active traders - "engagement_time_msec": 1, // Required by GA4 + "installation_id": installationID, // For counting active installations + "user_id": event.UserID, // For counting active users + "trader_id": event.TraderID, // For counting active traders + "engagement_time_msec": 1, // Required by GA4 }, }, }, diff --git a/trader/aster/trader.go b/trader/aster/trader.go index 117d73be..d7272f21 100644 --- a/trader/aster/trader.go +++ b/trader/aster/trader.go @@ -5,10 +5,8 @@ import ( "crypto/ecdsa" "encoding/hex" "encoding/json" - "errors" "fmt" "io" - "nofx/logger" "math" "math/big" "net/http" @@ -23,7 +21,6 @@ import ( "github.com/ethereum/go-ethereum/accounts/abi" "github.com/ethereum/go-ethereum/common" "github.com/ethereum/go-ethereum/crypto" - "nofx/trader/types" ) // AsterTrader Aster trading platform implementation @@ -431,1178 +428,3 @@ func (t *AsterTrader) doRequest(method, endpoint string, params map[string]inter return nil, fmt.Errorf("unsupported HTTP method: %s", method) } } - -// 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 -} - -// 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 -} - -// 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 -} - -// 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 -} - -// 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) -} - -// 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 -} - -// 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 -} - -// 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 -} - -// 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 -} diff --git a/trader/aster/trader_account.go b/trader/aster/trader_account.go new file mode 100644 index 00000000..bfc41304 --- /dev/null +++ b/trader/aster/trader_account.go @@ -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 +} diff --git a/trader/aster/trader_orders.go b/trader/aster/trader_orders.go new file mode 100644 index 00000000..12ce9502 --- /dev/null +++ b/trader/aster/trader_orders.go @@ -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 +} diff --git a/trader/aster/trader_positions.go b/trader/aster/trader_positions.go new file mode 100644 index 00000000..746fd8dd --- /dev/null +++ b/trader/aster/trader_positions.go @@ -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 +} diff --git a/trader/aster/order_sync.go b/trader/aster/trader_sync.go similarity index 100% rename from trader/aster/order_sync.go rename to trader/aster/trader_sync.go diff --git a/trader/auto_trader_decision.go b/trader/auto_trader_decision.go index 2cec395c..b2898174 100644 --- a/trader/auto_trader_decision.go +++ b/trader/auto_trader_decision.go @@ -3,7 +3,7 @@ package trader import ( "fmt" "math" - "nofx/experience" + "nofx/telemetry" "nofx/kernel" "nofx/logger" "nofx/market" @@ -345,7 +345,7 @@ func (at *AutoTrader) recordAndConfirmOrder(orderResult map[string]interface{}, // Send anonymous trade statistics for experience improvement (async, non-blocking) // This helps us understand overall product usage across all deployments - experience.TrackTrade(experience.TradeEvent{ + telemetry.TrackTrade(telemetry.TradeEvent{ Exchange: at.exchange, TradeType: action, Symbol: symbol, diff --git a/trader/auto_trader_grid.go b/trader/auto_trader_grid.go index 37b62d2d..6151dbbb 100644 --- a/trader/auto_trader_grid.go +++ b/trader/auto_trader_grid.go @@ -3,7 +3,6 @@ package trader import ( "encoding/json" "fmt" - "math" "nofx/kernel" "nofx/logger" "nofx/market" @@ -32,16 +31,16 @@ type GridState struct { GridSpacing float64 // State flags - IsPaused bool + IsPaused bool IsInitialized bool // Performance tracking - TotalProfit float64 - TotalTrades int - WinningTrades int - MaxDrawdown float64 - PeakEquity float64 - DailyPnL float64 + TotalProfit float64 + TotalTrades int + WinningTrades int + MaxDrawdown float64 + PeakEquity float64 + DailyPnL float64 LastDailyReset time.Time // Order tracking @@ -67,9 +66,9 @@ type GridState struct { CurrentRegimeLevel string // Grid direction adjustment - CurrentDirection market.GridDirection - DirectionChangedAt time.Time - DirectionChangeCount int + CurrentDirection market.GridDirection + DirectionChangedAt time.Time + DirectionChangeCount int } // NewGridState creates a new grid state @@ -83,7 +82,7 @@ func NewGridState(config *store.GridStrategyConfig) *GridState { } // ============================================================================ -// Breakout Detection +// Breakout Detection (price vs grid boundary) // ============================================================================ // BreakoutType represents the type of price breakout @@ -282,226 +281,8 @@ func (at *AutoTrader) handleBreakout(breakoutType BreakoutType, breakoutPct floa return nil } -// 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) -} - -// 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 -} - -// 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 -} - // ============================================================================ -// AutoTrader Grid Methods +// AutoTrader Grid Lifecycle // ============================================================================ // InitializeGrid initializes the grid state and calculates levels @@ -551,210 +332,12 @@ func (at *AutoTrader) InitializeGrid() error { logger.Infof("[Grid] Leverage set to %dx for %s", gridConfig.Leverage, gridConfig.Symbol) } - logger.Infof("📊 [Grid] Initialized: %d levels, $%.2f - $%.2f, spacing $%.2f", + logger.Infof("[Grid] Initialized: %d levels, $%.2f - $%.2f, spacing $%.2f", gridConfig.GridCount, at.gridState.LowerPrice, at.gridState.UpperPrice, at.gridState.GridSpacing) return nil } -// 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) -} - -// 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 -} - // RunGridCycle executes one grid trading cycle func (at *AutoTrader) RunGridCycle() error { // Check if trader is stopped (early exit to prevent trades after Stop() is called) @@ -965,312 +548,12 @@ func (at *AutoTrader) executeGridDecision(d *kernel.Decision) error { } } -// 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 - } - } - } - } +// IsGridStrategy returns true if current strategy is grid trading +func (at *AutoTrader) IsGridStrategy() bool { + if at.config.StrategyConfig == nil { + return false } - - // 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() + return at.config.StrategyConfig.StrategyType == "grid_trading" && at.config.StrategyConfig.GridConfig != nil } // saveGridDecisionRecord saves the grid decision to database @@ -1323,317 +606,6 @@ func (at *AutoTrader) saveGridDecisionRecord(decision *kernel.FullDecision) { } } -// IsGridStrategy returns true if current strategy is grid trading -func (at *AutoTrader) IsGridStrategy() bool { - if at.config.StrategyConfig == nil { - return false - } - return at.config.StrategyConfig.StrategyType == "grid_trading" && at.config.StrategyConfig.GridConfig != nil -} - -// 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++ - } - } - } - } -} - // GridRiskInfo contains risk information for frontend display type GridRiskInfo struct { CurrentLeverage int `json:"current_leverage"` @@ -1661,190 +633,7 @@ type GridRiskInfo struct { BreakoutDirection string `json:"breakout_direction"` // Grid direction - CurrentGridDirection string `json:"current_grid_direction"` - DirectionChangeCount int `json:"direction_change_count"` - EnableDirectionAdjust bool `json:"enable_direction_adjust"` -} - -// 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, - } -} - -// 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) - } - } - } + CurrentGridDirection string `json:"current_grid_direction"` + DirectionChangeCount int `json:"direction_change_count"` + EnableDirectionAdjust bool `json:"enable_direction_adjust"` } diff --git a/trader/auto_trader_grid_levels.go b/trader/auto_trader_grid_levels.go new file mode 100644 index 00000000..ddc2a408 --- /dev/null +++ b/trader/auto_trader_grid_levels.go @@ -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++ + } + } + } + } +} diff --git a/trader/auto_trader_grid_orders.go b/trader/auto_trader_grid_orders.go new file mode 100644 index 00000000..c19dc6d0 --- /dev/null +++ b/trader/auto_trader_grid_orders.go @@ -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) + } + } + } +} diff --git a/trader/auto_trader_grid_regime.go b/trader/auto_trader_grid_regime.go new file mode 100644 index 00000000..0999ff1f --- /dev/null +++ b/trader/auto_trader_grid_regime.go @@ -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, + } +} diff --git a/trader/binance/futures.go b/trader/binance/futures.go index 723cb470..efc3bb32 100644 --- a/trader/binance/futures.go +++ b/trader/binance/futures.go @@ -7,8 +7,6 @@ import ( "fmt" "nofx/hook" "nofx/logger" - "nofx/trader/types" - "strconv" "strings" "sync" "time" @@ -123,981 +121,19 @@ func syncBinanceServerTime(client *futures.Client) { logger.Infof("⏱ Binance server time synced, offset %dms", offset) } -// 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() +// Helper functions - // 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 +func contains(s, substr string) bool { + return len(s) >= len(substr) && stringContains(s, substr) } -// 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 - } - } +func stringContains(s, substr string) bool { + for i := 0; i <= len(s)-len(substr); i++ { + if s[i:i+len(substr)] == substr { + return true } } - - // 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 -} - -// 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 -} - -// 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 -} - -// 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 -} - -// 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 + return false } // calculatePrecision calculates precision from stepSize @@ -1142,346 +178,3 @@ func trimTrailingZeros(s string) string { return s } - -// 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 -} - -// Helper functions -func contains(s, substr string) bool { - return len(s) >= len(substr) && stringContains(s, substr) -} - -func stringContains(s, substr string) bool { - for i := 0; i <= len(s)-len(substr); i++ { - if s[i:i+len(substr)] == substr { - return true - } - } - return false -} - -// 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 -} - -// 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 -} diff --git a/trader/binance/futures_account.go b/trader/binance/futures_account.go new file mode 100644 index 00000000..549e3fd7 --- /dev/null +++ b/trader/binance/futures_account.go @@ -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 +} diff --git a/trader/binance/futures_orders.go b/trader/binance/futures_orders.go new file mode 100644 index 00000000..3e3f9644 --- /dev/null +++ b/trader/binance/futures_orders.go @@ -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 +} diff --git a/trader/binance/futures_positions.go b/trader/binance/futures_positions.go new file mode 100644 index 00000000..d49e960a --- /dev/null +++ b/trader/binance/futures_positions.go @@ -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 +} + diff --git a/trader/bitget/trader.go b/trader/bitget/trader.go index d44f7e01..449d451e 100644 --- a/trader/bitget/trader.go +++ b/trader/bitget/trader.go @@ -14,22 +14,21 @@ import ( "strings" "sync" "time" - "nofx/trader/types" ) // Bitget API endpoints (V2) const ( - bitgetBaseURL = "https://api.bitget.com" - bitgetAccountPath = "/api/v2/mix/account/accounts" - bitgetPositionPath = "/api/v2/mix/position/all-position" - bitgetOrderPath = "/api/v2/mix/order/place-order" - bitgetLeveragePath = "/api/v2/mix/account/set-leverage" - bitgetTickerPath = "/api/v2/mix/market/ticker" - bitgetContractsPath = "/api/v2/mix/market/contracts" - bitgetCancelOrderPath = "/api/v2/mix/order/cancel-order" - bitgetPendingPath = "/api/v2/mix/order/orders-pending" - bitgetHistoryPath = "/api/v2/mix/order/orders-history" - bitgetMarginModePath = "/api/v2/mix/account/set-margin-mode" + bitgetBaseURL = "https://api.bitget.com" + bitgetAccountPath = "/api/v2/mix/account/accounts" + bitgetPositionPath = "/api/v2/mix/position/all-position" + bitgetOrderPath = "/api/v2/mix/order/place-order" + bitgetLeveragePath = "/api/v2/mix/account/set-leverage" + bitgetTickerPath = "/api/v2/mix/market/ticker" + bitgetContractsPath = "/api/v2/mix/market/contracts" + bitgetCancelOrderPath = "/api/v2/mix/order/cancel-order" + bitgetPendingPath = "/api/v2/mix/order/orders-pending" + bitgetHistoryPath = "/api/v2/mix/order/orders-history" + bitgetMarginModePath = "/api/v2/mix/account/set-margin-mode" bitgetPositionModePath = "/api/v2/mix/account/set-position-mode" ) @@ -63,22 +62,22 @@ type BitgetTrader struct { // BitgetContract Bitget contract info type BitgetContract struct { - Symbol string // Symbol name - BaseCoin string // Base coin - QuoteCoin string // Quote coin - MinTradeNum float64 // Minimum trade amount - MaxTradeNum float64 // Maximum trade amount + Symbol string // Symbol name + BaseCoin string // Base coin + QuoteCoin string // Quote coin + MinTradeNum float64 // Minimum trade amount + MaxTradeNum float64 // Maximum trade amount SizeMultiplier float64 // Contract size multiplier - PricePlace int // Price decimal places - VolumePlace int // Volume decimal places + PricePlace int // Price decimal places + VolumePlace int // Volume decimal places } // BitgetResponse Bitget API response type BitgetResponse struct { - Code string `json:"code"` - Msg string `json:"msg"` - Data json.RawMessage `json:"data"` - RequestTime int64 `json:"requestTime"` + Code string `json:"code"` + Msg string `json:"msg"` + Data json.RawMessage `json:"data"` + RequestTime int64 `json:"requestTime"` } // NewBitgetTrader creates a Bitget trader @@ -218,148 +217,6 @@ func (t *BitgetTrader) convertSymbol(symbol string) string { return strings.ToUpper(symbol) } -// 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 -} - -// 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 -} - // getContract gets contract info func (t *BitgetTrader) getContract(symbol string) (*BitgetContract, error) { symbol = t.convertSymbol(symbol) @@ -430,513 +287,6 @@ func (t *BitgetTrader) getContract(symbol string) (*BitgetContract, error) { return nil, fmt.Errorf("contract info not found: %s", symbol) } -// 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 -} - -// 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 -} - -// 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 -} - -// 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 -} - // FormatQuantity formats quantity func (t *BitgetTrader) FormatQuantity(symbol string, quantity float64) (string, error) { contract, err := t.getContract(symbol) @@ -949,137 +299,6 @@ func (t *BitgetTrader) FormatQuantity(symbol string, quantity float64) (string, return fmt.Sprintf(format, quantity), 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 -} - -// 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 -} - // clearCache clears all caches func (t *BitgetTrader) clearCache() { t.balanceCacheMutex.Lock() @@ -1097,264 +316,3 @@ func genBitgetClientOid() string { rand := time.Now().Nanosecond() % 100000 return fmt.Sprintf("nofx%d%05d", timestamp, rand) } - -// 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 -} - -// 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 -} diff --git a/trader/bitget/trader_account.go b/trader/bitget/trader_account.go new file mode 100644 index 00000000..5449a33c --- /dev/null +++ b/trader/bitget/trader_account.go @@ -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 +} diff --git a/trader/bitget/trader_orders.go b/trader/bitget/trader_orders.go new file mode 100644 index 00000000..f08a7a38 --- /dev/null +++ b/trader/bitget/trader_orders.go @@ -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 +} diff --git a/trader/bitget/trader_positions.go b/trader/bitget/trader_positions.go new file mode 100644 index 00000000..2a87a792 --- /dev/null +++ b/trader/bitget/trader_positions.go @@ -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 +} diff --git a/trader/bybit/trader.go b/trader/bybit/trader.go index 1d4e87c7..5f8e9bfb 100644 --- a/trader/bybit/trader.go +++ b/trader/bybit/trader.go @@ -1,10 +1,6 @@ package bybit import ( - "context" - "crypto/hmac" - "crypto/sha256" - "encoding/hex" "encoding/json" "fmt" "io" @@ -17,7 +13,6 @@ import ( "time" bybit "github.com/bybit-exchange/bybit.go.api" - "nofx/trader/types" ) // BybitTrader Bybit USDT Perpetual Futures Trader @@ -87,590 +82,6 @@ func (h *headerRoundTripper) RoundTrip(req *http.Request) (*http.Response, error return h.base.RoundTrip(req) } -// 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 -} - -// 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 -} - -// 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 -} - // getQtyStep retrieves the quantity step for a trading pair func (t *BybitTrader) getQtyStep(symbol string) float64 { // Check cache first @@ -782,483 +193,3 @@ func (t *BybitTrader) parseOrderResult(result *bybit.ServerResponse) (map[string "status": "NEW", }, 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 -} - -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 -} - -// 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 -} - -// 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 -} diff --git a/trader/bybit/trader_account.go b/trader/bybit/trader_account.go new file mode 100644 index 00000000..88616a04 --- /dev/null +++ b/trader/bybit/trader_account.go @@ -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 +} diff --git a/trader/bybit/trader_orders.go b/trader/bybit/trader_orders.go new file mode 100644 index 00000000..fbf5f5d9 --- /dev/null +++ b/trader/bybit/trader_orders.go @@ -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 +} diff --git a/trader/bybit/trader_positions.go b/trader/bybit/trader_positions.go new file mode 100644 index 00000000..4d4d9429 --- /dev/null +++ b/trader/bybit/trader_positions.go @@ -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 +} diff --git a/trader/bybit/trader_test.go b/trader/bybit/trader_test.go deleted file mode 100644 index 07011b01..00000000 --- a/trader/bybit/trader_test.go +++ /dev/null @@ -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) -} diff --git a/trader/gate/trader.go b/trader/gate/trader.go index 5b9e7706..27a1d1e1 100644 --- a/trader/gate/trader.go +++ b/trader/gate/trader.go @@ -3,16 +3,12 @@ package gate import ( "context" "fmt" - "math" - "strconv" + "nofx/trader/types" "strings" "sync" "time" - "github.com/antihax/optional" "github.com/gateio/gateapi-go/v6" - "nofx/logger" - "nofx/trader/types" ) // 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) func (t *GateTrader) convertSymbol(symbol string) string { // If already in correct format @@ -215,674 +99,6 @@ func (t *GateTrader) getContract(symbol string) (*gateapi.Contract, error) { 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 func (t *GateTrader) clearCache() { t.balanceCacheMutex.Lock() diff --git a/trader/gate/trader_account.go b/trader/gate/trader_account.go new file mode 100644 index 00000000..1c912315 --- /dev/null +++ b/trader/gate/trader_account.go @@ -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 +} diff --git a/trader/gate/trader_orders.go b/trader/gate/trader_orders.go new file mode 100644 index 00000000..198a6acb --- /dev/null +++ b/trader/gate/trader_orders.go @@ -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 +} diff --git a/trader/hyperliquid/balance_test.go b/trader/hyperliquid/balance_test.go deleted file mode 100644 index 491a8fd6..00000000 --- a/trader/hyperliquid/balance_test.go +++ /dev/null @@ -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 -} diff --git a/trader/hyperliquid/trader.go b/trader/hyperliquid/trader.go index f5572c78..2339a229 100644 --- a/trader/hyperliquid/trader.go +++ b/trader/hyperliquid/trader.go @@ -1,22 +1,16 @@ package hyperliquid import ( - "bytes" "context" "crypto/ecdsa" - "encoding/json" "fmt" - "io" - "net/http" "nofx/logger" "strconv" "strings" "sync" - "time" "github.com/ethereum/go-ethereum/crypto" "github.com/sonirico/go-hyperliquid" - "nofx/trader/types" ) // HyperliquidTrader Hyperliquid trader @@ -64,6 +58,15 @@ var xyzDexAssets = map[string]bool{ "XYZ100": true, } +// defaultBuilder is the builder info for order routing +// Set to nil to avoid requiring builder fee approval +// +// var defaultBuilder = &hyperliquid.BuilderInfo{ +// Builder: "0x891dc6f05ad47a3c1a05da55e7a7517971faaf0d", +// Fee: 10, +// } +var defaultBuilder *hyperliquid.BuilderInfo = nil + // isXyzDexAsset checks if a symbol is an xyz dex asset func isXyzDexAsset(symbol string) bool { // Remove common suffixes to get base symbol @@ -80,6 +83,39 @@ func isXyzDexAsset(symbol string) bool { return xyzDexAssets[base] } +// convertSymbolToHyperliquid converts standard symbol to Hyperliquid format +// Example: "BTCUSDT" -> "BTC", "TSLA" -> "xyz:TSLA", "silver" -> "xyz:SILVER" +func convertSymbolToHyperliquid(symbol string) string { + // Convert to uppercase for consistent handling + base := strings.ToUpper(symbol) + + // Remove common suffixes to get base symbol + for _, suffix := range []string{"USDT", "USD", "-USDC", "-USD"} { + if strings.HasSuffix(base, suffix) { + base = strings.TrimSuffix(base, suffix) + break + } + } + // Remove xyz: prefix if present (case-insensitive, will be re-added if needed) + if strings.HasPrefix(strings.ToLower(base), "xyz:") { + base = base[4:] // Remove first 4 characters + } + + // Check if this is an xyz dex asset (stocks, forex, commodities) + if isXyzDexAsset(base) { + return "xyz:" + base + } + return base +} + +// absFloat returns absolute value of float +func absFloat(x float64) float64 { + if x < 0 { + return -x + } + return x +} + // NewHyperliquidTrader creates a Hyperliquid trader // unifiedAccount: when true, Spot USDC balance is used as collateral for Perp trading func NewHyperliquidTrader(privateKeyHex string, walletAddr string, testnet bool, unifiedAccount bool) (*HyperliquidTrader, error) { @@ -144,7 +180,7 @@ func NewHyperliquidTrader(privateKeyHex string, walletAddr string, testnet bool, return nil, fmt.Errorf("failed to get meta information: %w", err) } - // 🔍 Security check: Validate Agent wallet balance (should be close to 0) + // Security check: Validate Agent wallet balance (should be close to 0) // Only check if using separate Agent wallet (not when main wallet is used as agent) if !strings.EqualFold(walletAddr, agentAddr) { agentState, err := exchange.Info().UserState(ctx, agentAddr) @@ -193,1613 +229,6 @@ func NewHyperliquidTrader(privateKeyHex string, walletAddr string, testnet bool, }, nil } -// 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) - } - - 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 -} - -// 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 -} - -// 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 -} - -// 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 -} - -// OpenLong opens a long position (supports both crypto and xyz dex) -func (t *HyperliquidTrader) OpenLong(symbol string, quantity float64, leverage int) (map[string]interface{}, error) { - // First cancel all pending orders for this coin - if err := t.CancelAllOrders(symbol); err != nil { - logger.Infof(" ⚠ Failed to cancel old pending orders: %v", err) - } - - // Hyperliquid symbol format - coin := convertSymbolToHyperliquid(symbol) - - // Check if this is an xyz dex asset - isXyz := strings.HasPrefix(coin, "xyz:") - - // Set leverage (skip for xyz dex as it may not support leverage adjustment) - if !isXyz { - if err := t.SetLeverage(symbol, leverage); err != nil { - return nil, err - } - } else { - logger.Infof(" ℹ xyz dex asset %s - using default leverage", coin) - } - - // Get current price (for market order) - price, err := t.GetMarketPrice(symbol) - if err != nil { - return nil, err - } - - // ⚠️ Critical: Price needs to be processed to 5 significant figures - aggressivePrice := t.roundPriceToSigfigs(price * 1.01) - logger.Infof(" 💰 Price precision handling: %.8f -> %.8f (5 significant figures)", price*1.01, aggressivePrice) - - // Handle xyz dex assets differently - if isXyz { - // xyz dex order - if err := t.placeXyzOrder(coin, true, quantity, aggressivePrice, false); err != nil { - return nil, fmt.Errorf("failed to open long position on xyz dex: %w", err) - } - } else { - // Standard crypto order - roundedQuantity := t.roundToSzDecimals(coin, quantity) - logger.Infof(" 📏 Quantity precision handling: %.8f -> %.8f (szDecimals=%d)", quantity, roundedQuantity, t.getSzDecimals(coin)) - - order := hyperliquid.CreateOrderRequest{ - Coin: coin, - IsBuy: true, - Size: roundedQuantity, - Price: aggressivePrice, - OrderType: hyperliquid.OrderType{ - Limit: &hyperliquid.LimitOrderType{ - Tif: hyperliquid.TifIoc, - }, - }, - ReduceOnly: false, - } - - _, err = t.exchange.Order(t.ctx, order, defaultBuilder) - if err != nil { - return nil, fmt.Errorf("failed to open long position: %w", err) - } - } - - logger.Infof("✓ Long position opened successfully: %s quantity: %.4f", symbol, quantity) - - result := make(map[string]interface{}) - result["orderId"] = 0 - result["symbol"] = symbol - result["status"] = "FILLED" - - return result, nil -} - -// OpenShort opens a short position (supports both crypto and xyz dex) -func (t *HyperliquidTrader) OpenShort(symbol string, quantity float64, leverage int) (map[string]interface{}, error) { - // First cancel all pending orders for this coin - if err := t.CancelAllOrders(symbol); err != nil { - logger.Infof(" ⚠ Failed to cancel old pending orders: %v", err) - } - - // Hyperliquid symbol format - coin := convertSymbolToHyperliquid(symbol) - - // Check if this is an xyz dex asset - isXyz := strings.HasPrefix(coin, "xyz:") - - // Set leverage (skip for xyz dex) - if !isXyz { - if err := t.SetLeverage(symbol, leverage); err != nil { - return nil, err - } - } else { - logger.Infof(" ℹ xyz dex asset %s - using default leverage", coin) - } - - // Get current price - price, err := t.GetMarketPrice(symbol) - if err != nil { - return nil, err - } - - // ⚠️ Critical: Price needs to be processed to 5 significant figures - aggressivePrice := t.roundPriceToSigfigs(price * 0.99) - logger.Infof(" 💰 Price precision handling: %.8f -> %.8f (5 significant figures)", price*0.99, aggressivePrice) - - // Handle xyz dex assets differently - if isXyz { - // xyz dex order - if err := t.placeXyzOrder(coin, false, quantity, aggressivePrice, false); err != nil { - return nil, fmt.Errorf("failed to open short position on xyz dex: %w", err) - } - } else { - // Standard crypto order - roundedQuantity := t.roundToSzDecimals(coin, quantity) - logger.Infof(" 📏 Quantity precision handling: %.8f -> %.8f (szDecimals=%d)", quantity, roundedQuantity, t.getSzDecimals(coin)) - - order := hyperliquid.CreateOrderRequest{ - Coin: coin, - IsBuy: false, - Size: roundedQuantity, - Price: aggressivePrice, - OrderType: hyperliquid.OrderType{ - Limit: &hyperliquid.LimitOrderType{ - Tif: hyperliquid.TifIoc, - }, - }, - ReduceOnly: false, - } - - _, err = t.exchange.Order(t.ctx, order, defaultBuilder) - if err != nil { - return nil, fmt.Errorf("failed to open short position: %w", err) - } - } - - logger.Infof("✓ Short position opened successfully: %s quantity: %.4f", symbol, quantity) - - result := make(map[string]interface{}) - result["orderId"] = 0 - result["symbol"] = symbol - result["status"] = "FILLED" - - return result, nil -} - -// CloseLong closes a long position (supports both crypto and xyz dex) -func (t *HyperliquidTrader) CloseLong(symbol string, quantity float64) (map[string]interface{}, error) { - // Hyperliquid symbol format - coin := convertSymbolToHyperliquid(symbol) - isXyz := strings.HasPrefix(coin, "xyz:") - - // If quantity is 0, get current position quantity - if quantity == 0 { - positions, err := t.GetPositions() - if err != nil { - return nil, err - } - - // For xyz dex, also check xyz: prefixed symbols - searchSymbol := symbol - if isXyz { - searchSymbol = coin // Use xyz:SYMBOL format for comparison - } - - for _, pos := range positions { - posSymbol := pos["symbol"].(string) - if (posSymbol == symbol || posSymbol == searchSymbol) && pos["side"] == "long" { - quantity = pos["positionAmt"].(float64) - break - } - } - - if quantity == 0 { - return nil, fmt.Errorf("no long position found for %s", symbol) - } - } - - // Get current price - price, err := t.GetMarketPrice(symbol) - if err != nil { - return nil, err - } - - // ⚠️ Critical: Price needs to be processed to 5 significant figures - aggressivePrice := t.roundPriceToSigfigs(price * 0.99) - logger.Infof(" 💰 Price precision handling: %.8f -> %.8f (5 significant figures)", price*0.99, aggressivePrice) - - // Handle xyz dex assets differently - if isXyz { - // xyz dex close order - if err := t.placeXyzOrder(coin, false, quantity, aggressivePrice, true); err != nil { - return nil, fmt.Errorf("failed to close long position on xyz dex: %w", err) - } - } else { - // Standard crypto close order - roundedQuantity := t.roundToSzDecimals(coin, quantity) - logger.Infof(" 📏 Quantity precision handling: %.8f -> %.8f (szDecimals=%d)", quantity, roundedQuantity, t.getSzDecimals(coin)) - - order := hyperliquid.CreateOrderRequest{ - Coin: coin, - IsBuy: false, - Size: roundedQuantity, - Price: aggressivePrice, - OrderType: hyperliquid.OrderType{ - Limit: &hyperliquid.LimitOrderType{ - Tif: hyperliquid.TifIoc, - }, - }, - ReduceOnly: true, - } - - _, err = t.exchange.Order(t.ctx, order, defaultBuilder) - if err != nil { - return nil, fmt.Errorf("failed to close long position: %w", err) - } - } - - logger.Infof("✓ Long position closed successfully: %s quantity: %.4f", symbol, quantity) - - // Cancel all pending orders for this coin after closing position - if err := t.CancelAllOrders(symbol); err != nil { - logger.Infof(" ⚠ Failed to cancel pending orders: %v", err) - } - - result := make(map[string]interface{}) - result["orderId"] = 0 - result["symbol"] = symbol - result["status"] = "FILLED" - - return result, nil -} - -// CloseShort closes a short position (supports both crypto and xyz dex) -func (t *HyperliquidTrader) CloseShort(symbol string, quantity float64) (map[string]interface{}, error) { - // Hyperliquid symbol format - coin := convertSymbolToHyperliquid(symbol) - isXyz := strings.HasPrefix(coin, "xyz:") - - // If quantity is 0, get current position quantity - if quantity == 0 { - positions, err := t.GetPositions() - if err != nil { - return nil, err - } - - // For xyz dex, also check xyz: prefixed symbols - searchSymbol := symbol - if isXyz { - searchSymbol = coin - } - - for _, pos := range positions { - posSymbol := pos["symbol"].(string) - if (posSymbol == symbol || posSymbol == searchSymbol) && pos["side"] == "short" { - quantity = pos["positionAmt"].(float64) - break - } - } - - if quantity == 0 { - return nil, fmt.Errorf("no short position found for %s", symbol) - } - } - - // Get current price - price, err := t.GetMarketPrice(symbol) - if err != nil { - return nil, err - } - - // ⚠️ Critical: Price needs to be processed to 5 significant figures - aggressivePrice := t.roundPriceToSigfigs(price * 1.01) - logger.Infof(" 💰 Price precision handling: %.8f -> %.8f (5 significant figures)", price*1.01, aggressivePrice) - - // Handle xyz dex assets differently - if isXyz { - // xyz dex close order - if err := t.placeXyzOrder(coin, true, quantity, aggressivePrice, true); err != nil { - return nil, fmt.Errorf("failed to close short position on xyz dex: %w", err) - } - } else { - // Standard crypto close order - roundedQuantity := t.roundToSzDecimals(coin, quantity) - logger.Infof(" 📏 Quantity precision handling: %.8f -> %.8f (szDecimals=%d)", quantity, roundedQuantity, t.getSzDecimals(coin)) - - order := hyperliquid.CreateOrderRequest{ - Coin: coin, - IsBuy: true, - Size: roundedQuantity, - Price: aggressivePrice, - OrderType: hyperliquid.OrderType{ - Limit: &hyperliquid.LimitOrderType{ - Tif: hyperliquid.TifIoc, - }, - }, - ReduceOnly: true, - } - - _, err = t.exchange.Order(t.ctx, order, defaultBuilder) - if err != nil { - return nil, fmt.Errorf("failed to close short position: %w", err) - } - } - - logger.Infof("✓ Short position closed successfully: %s quantity: %.4f", symbol, quantity) - - // Cancel all pending orders for this coin after closing position - if err := t.CancelAllOrders(symbol); err != nil { - logger.Infof(" ⚠ Failed to cancel pending orders: %v", err) - } - - result := make(map[string]interface{}) - result["orderId"] = 0 - result["symbol"] = symbol - result["status"] = "FILLED" - - return result, nil -} - -// CancelStopLossOrders only cancels stop loss orders (Hyperliquid cannot distinguish stop loss and take profit, cancel all) -func (t *HyperliquidTrader) CancelStopLossOrders(symbol string) error { - // Hyperliquid SDK's OpenOrder structure does not expose trigger field - // Cannot distinguish stop loss and take profit orders, so cancel all pending orders for this coin - logger.Infof(" ⚠️ Hyperliquid cannot distinguish stop loss/take profit orders, will cancel all pending orders") - return t.CancelStopOrders(symbol) -} - -// CancelTakeProfitOrders only cancels take profit orders (Hyperliquid cannot distinguish stop loss and take profit, cancel all) -func (t *HyperliquidTrader) CancelTakeProfitOrders(symbol string) error { - // Hyperliquid SDK's OpenOrder structure does not expose trigger field - // Cannot distinguish stop loss and take profit orders, so cancel all pending orders for this coin - logger.Infof(" ⚠️ Hyperliquid cannot distinguish stop loss/take profit orders, will cancel all pending orders") - return t.CancelStopOrders(symbol) -} - -// CancelAllOrders cancels all pending orders for this coin -func (t *HyperliquidTrader) CancelAllOrders(symbol string) error { - coin := convertSymbolToHyperliquid(symbol) - - // Check if this is an xyz dex asset - isXyz := strings.HasPrefix(coin, "xyz:") - - if isXyz { - // xyz dex orders - use direct API call - return t.cancelXyzOrders(coin) - } - - // Standard crypto orders - openOrders, err := t.exchange.Info().OpenOrders(t.ctx, t.walletAddr) - if err != nil { - return fmt.Errorf("failed to get pending orders: %w", err) - } - - // Cancel all pending orders for this coin - for _, order := range openOrders { - if order.Coin == coin { - _, err := t.exchange.Cancel(t.ctx, coin, order.Oid) - if err != nil { - logger.Infof(" ⚠ Failed to cancel order (oid=%d): %v", order.Oid, err) - } - } - } - - logger.Infof(" ✓ Cancelled all pending orders for %s", symbol) - return nil -} - -// CancelStopOrders cancels take profit/stop loss orders for this coin (used to adjust TP/SL positions) -func (t *HyperliquidTrader) CancelStopOrders(symbol string) error { - coin := convertSymbolToHyperliquid(symbol) - - // Check if this is an xyz dex asset - isXyz := strings.HasPrefix(coin, "xyz:") - - if isXyz { - // xyz dex orders - use direct API call - return t.cancelXyzOrders(coin) - } - - // Get all pending orders for standard crypto - openOrders, err := t.exchange.Info().OpenOrders(t.ctx, t.walletAddr) - if err != nil { - return fmt.Errorf("failed to get pending orders: %w", err) - } - - // Note: Hyperliquid SDK's OpenOrder structure does not expose trigger field - // Therefore temporarily cancel all pending orders for this coin (including TP/SL orders) - // This is safe because all old orders should be cleaned up before setting new TP/SL - canceledCount := 0 - for _, order := range openOrders { - if order.Coin == coin { - _, err := t.exchange.Cancel(t.ctx, coin, order.Oid) - if err != nil { - logger.Infof(" ⚠ Failed to cancel order (oid=%d): %v", order.Oid, err) - continue - } - canceledCount++ - } - } - - if canceledCount == 0 { - logger.Infof(" ℹ No pending orders to cancel for %s", symbol) - } else { - logger.Infof(" ✓ Cancelled %d pending orders for %s (including TP/SL orders)", canceledCount, symbol) - } - - return nil -} - -// cancelXyzOrders cancels all pending orders for xyz dex assets (stocks, forex, commodities) -func (t *HyperliquidTrader) cancelXyzOrders(coin string) error { - // Query xyz dex open orders - reqBody := map[string]interface{}{ - "type": "openOrders", - "user": t.walletAddr, - "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 openOrders API error (status %d): %s", resp.StatusCode, string(body)) - } - - // Parse open orders - var openOrders []struct { - Coin string `json:"coin"` - Oid int64 `json:"oid"` - } - if err := json.Unmarshal(body, &openOrders); err != nil { - return fmt.Errorf("failed to parse open orders: %w", err) - } - - // Filter orders for this coin and cancel them - canceledCount := 0 - for _, order := range openOrders { - if order.Coin == coin { - if err := t.cancelXyzOrder(order.Oid); err != nil { - logger.Infof(" ⚠ Failed to cancel xyz dex order (oid=%d): %v", order.Oid, err) - continue - } - canceledCount++ - } - } - - if canceledCount == 0 { - logger.Infof(" ℹ No pending xyz dex orders to cancel for %s", coin) - } else { - logger.Infof(" ✓ Cancelled %d xyz dex orders for %s", canceledCount, coin) - } - - return nil -} - -// cancelXyzOrder cancels a single xyz dex order by oid -func (t *HyperliquidTrader) cancelXyzOrder(oid int64) error { - // Get asset index for this order (we need it for cancel action) - // For cancel, we construct a cancel action with the oid - - action := map[string]interface{}{ - "type": "cancel", - "cancels": []map[string]interface{}{ - { - "a": oid, // asset index not needed for cancel by oid in xyz dex - "o": oid, - }, - }, - } - - // Sign the action - nonce := time.Now().UnixMilli() - isMainnet := !t.isTestnet - vaultAddress := "" - - sig, err := hyperliquid.SignL1Action(t.privateKey, action, vaultAddress, nonce, nil, isMainnet) - if err != nil { - return fmt.Errorf("failed to sign cancel action: %w", err) - } - - payload := map[string]any{ - "action": action, - "nonce": nonce, - "signature": sig, - } - - apiURL := hyperliquid.MainnetAPIURL - if t.isTestnet { - apiURL = hyperliquid.TestnetAPIURL - } - - jsonData, err := json.Marshal(payload) - if err != nil { - return fmt.Errorf("failed to marshal payload: %w", err) - } - - req, err := http.NewRequestWithContext(t.ctx, http.MethodPost, apiURL+"/exchange", bytes.NewBuffer(jsonData)) - 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("request failed: %w", err) - } - defer resp.Body.Close() - - body, err := io.ReadAll(resp.Body) - if err != nil { - return fmt.Errorf("failed to read response: %w", err) - } - - // Check response - var result struct { - Status string `json:"status"` - } - if err := json.Unmarshal(body, &result); err != nil { - return fmt.Errorf("failed to parse response: %w", err) - } - - if result.Status != "ok" { - return fmt.Errorf("cancel failed: %s", string(body)) - } - - return 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) -} - -// floatToWireStr converts a float to wire format string (8 decimal places, trimmed zeros) -// This matches the SDK's floatToWire function -func floatToWireStr(x float64) string { - // Format to 8 decimal places - result := fmt.Sprintf("%.8f", x) - // Remove trailing zeros - result = strings.TrimRight(result, "0") - // Remove trailing decimal point if no decimals left - result = strings.TrimRight(result, ".") - return result -} - -// placeXyzOrder places an order on the xyz dex (stocks, forex, commodities) -// Note: xyz dex orders use builder-deployed perpetuals and require different handling -// xyz dex asset indices start from 10000 (10000 + meta_index) -// This implementation bypasses the SDK's NameToAsset lookup and directly constructs the order -func (t *HyperliquidTrader) placeXyzOrder(coin string, isBuy bool, size float64, price float64, reduceOnly bool) error { - // Fetch xyz meta if not cached - t.xyzMetaMutex.RLock() - hasMeta := t.xyzMeta != nil - t.xyzMetaMutex.RUnlock() - - if !hasMeta { - if err := t.fetchXyzMeta(); err != nil { - return fmt.Errorf("failed to fetch xyz meta: %w", err) - } - } - - // Get asset index from xyz meta (returns 0-based index) - metaIndex := t.getXyzAssetIndex(coin) - if metaIndex < 0 { - return fmt.Errorf("xyz asset %s not found in meta", coin) - } - - // HIP-3 perp dex asset index formula: 100000 + perp_dex_index * 10000 + index_in_meta - // xyz dex is at perp_dex_index = 1 (verified from perpDexs API: [null, {name:"xyz",...}]) - // So xyz asset index = 100000 + 1 * 10000 + metaIndex = 110000 + metaIndex - const xyzPerpDexIndex = 1 - assetIndex := 100000 + xyzPerpDexIndex*10000 + metaIndex - - // Round size to correct precision - szDecimals := t.getXyzSzDecimals(coin) - multiplier := 1.0 - for i := 0; i < szDecimals; i++ { - multiplier *= 10.0 - } - roundedSize := float64(int(size*multiplier+0.5)) / multiplier - - // Round price to 5 significant figures - roundedPrice := t.roundPriceToSigfigs(price) - - logger.Infof("📝 Placing xyz dex order (direct): %s %s size=%.4f price=%.4f metaIndex=%d assetIndex=%d (formula: 100000 + 1*10000 + %d) reduceOnly=%v", - map[bool]string{true: "BUY", false: "SELL"}[isBuy], - coin, roundedSize, roundedPrice, metaIndex, assetIndex, metaIndex, reduceOnly) - - // Construct OrderWire directly with correct asset index (bypassing SDK's NameToAsset) - orderWire := hyperliquid.OrderWire{ - Asset: assetIndex, - IsBuy: isBuy, - LimitPx: floatToWireStr(roundedPrice), - Size: floatToWireStr(roundedSize), - ReduceOnly: reduceOnly, - OrderType: hyperliquid.OrderWireType{ - Limit: &hyperliquid.OrderWireTypeLimit{ - Tif: hyperliquid.TifIoc, - }, - }, - } - - // Create OrderAction (no builder to avoid requiring builder fee approval) - action := hyperliquid.OrderAction{ - Type: "order", - Orders: []hyperliquid.OrderWire{orderWire}, - Grouping: "na", - Builder: nil, - } - - // Sign the action - nonce := time.Now().UnixMilli() - isMainnet := !t.isTestnet - vaultAddress := "" // No vault for personal account - - sig, err := hyperliquid.SignL1Action(t.privateKey, action, vaultAddress, nonce, nil, isMainnet) - if err != nil { - return fmt.Errorf("failed to sign xyz dex order: %w", err) - } - - // Construct payload for /exchange endpoint - payload := map[string]any{ - "action": action, - "nonce": nonce, - "signature": sig, - } - - // Determine API URL - apiURL := hyperliquid.MainnetAPIURL - if t.isTestnet { - apiURL = hyperliquid.TestnetAPIURL - } - - // POST to /exchange - jsonData, err := json.Marshal(payload) - if err != nil { - return fmt.Errorf("failed to marshal payload: %w", err) - } - - logger.Infof("📤 Sending xyz dex order to %s/exchange", apiURL) - - req, err := http.NewRequestWithContext(t.ctx, http.MethodPost, apiURL+"/exchange", bytes.NewBuffer(jsonData)) - 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("request failed: %w", err) - } - defer resp.Body.Close() - - body, err := io.ReadAll(resp.Body) - if err != nil { - return fmt.Errorf("failed to read response body: %w", err) - } - - // Parse response - var result struct { - Status string `json:"status"` - Response struct { - Type string `json:"type"` - Data struct { - Statuses []struct { - Resting *struct { - Oid int64 `json:"oid"` - } `json:"resting,omitempty"` - Filled *struct { - TotalSz string `json:"totalSz"` - AvgPx string `json:"avgPx"` - Oid int `json:"oid"` - } `json:"filled,omitempty"` - Error *string `json:"error,omitempty"` - } `json:"statuses"` - } `json:"data"` - } `json:"response"` - } - - if err := json.Unmarshal(body, &result); err != nil { - // Try to parse as error response - logger.Infof("⚠️ Failed to parse response as success, raw body: %s", string(body)) - return fmt.Errorf("xyz dex order failed, status=%d, body=%s", resp.StatusCode, string(body)) - } - - // Check for errors in response - if result.Status != "ok" { - return fmt.Errorf("xyz dex order failed: status=%s, body=%s", result.Status, string(body)) - } - - // Check order statuses - if len(result.Response.Data.Statuses) > 0 { - status := result.Response.Data.Statuses[0] - if status.Error != nil { - return fmt.Errorf("xyz dex order error (coin=%s, assetIndex=%d, size=%.4f, price=%.4f): %s", coin, assetIndex, roundedSize, roundedPrice, *status.Error) - } - if status.Filled != nil { - logger.Infof("✅ xyz dex order filled: totalSz=%s avgPx=%s oid=%d", - status.Filled.TotalSz, status.Filled.AvgPx, status.Filled.Oid) - } else if status.Resting != nil { - logger.Infof("✅ xyz dex order resting: oid=%d", status.Resting.Oid) - } - } - - logger.Infof("✅ xyz dex order placed successfully: %s (response: %s)", coin, string(body)) - return nil -} - -// 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 -} - -// placeXyzTriggerOrder places a trigger order (stop loss / take profit) on the xyz dex -// tpsl: "sl" for stop loss, "tp" for take profit -func (t *HyperliquidTrader) placeXyzTriggerOrder(coin string, isBuy bool, size float64, triggerPrice float64, tpsl string) error { - // Fetch xyz meta if not cached - t.xyzMetaMutex.RLock() - hasMeta := t.xyzMeta != nil - t.xyzMetaMutex.RUnlock() - - if !hasMeta { - if err := t.fetchXyzMeta(); err != nil { - return fmt.Errorf("failed to fetch xyz meta: %w", err) - } - } - - // Get asset index from xyz meta (returns 0-based index) - metaIndex := t.getXyzAssetIndex(coin) - if metaIndex < 0 { - return fmt.Errorf("xyz asset %s not found in meta", coin) - } - - // 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 - assetIndex := 100000 + xyzPerpDexIndex*10000 + metaIndex - - // Round size to correct precision - szDecimals := t.getXyzSzDecimals(coin) - multiplier := 1.0 - for i := 0; i < szDecimals; i++ { - multiplier *= 10.0 - } - roundedSize := float64(int(size*multiplier+0.5)) / multiplier - - // Round price to 5 significant figures - roundedPrice := t.roundPriceToSigfigs(triggerPrice) - - logger.Infof("📝 Placing xyz dex %s order: %s %s size=%.4f triggerPrice=%.4f assetIndex=%d", - tpsl, - map[bool]string{true: "BUY", false: "SELL"}[isBuy], - coin, roundedSize, roundedPrice, assetIndex) - - // Construct OrderWire with trigger type for stop loss / take profit - orderWire := hyperliquid.OrderWire{ - Asset: assetIndex, - IsBuy: isBuy, - LimitPx: floatToWireStr(roundedPrice), - Size: floatToWireStr(roundedSize), - ReduceOnly: true, // TP/SL orders are always reduce-only - OrderType: hyperliquid.OrderWireType{ - Trigger: &hyperliquid.OrderWireTypeTrigger{ - TriggerPx: floatToWireStr(roundedPrice), - IsMarket: true, - Tpsl: hyperliquid.Tpsl(tpsl), // "sl" or "tp" - convert string to Tpsl type - }, - }, - } - - // Create OrderAction (no builder to avoid requiring builder fee approval) - action := hyperliquid.OrderAction{ - Type: "order", - Orders: []hyperliquid.OrderWire{orderWire}, - Grouping: "na", - Builder: nil, - } - - // Sign the action - nonce := time.Now().UnixMilli() - isMainnet := !t.isTestnet - vaultAddress := "" - - sig, err := hyperliquid.SignL1Action(t.privateKey, action, vaultAddress, nonce, nil, isMainnet) - if err != nil { - return fmt.Errorf("failed to sign xyz dex trigger order: %w", err) - } - - // Construct payload for /exchange endpoint - payload := map[string]any{ - "action": action, - "nonce": nonce, - "signature": sig, - } - - // Determine API URL - apiURL := hyperliquid.MainnetAPIURL - if t.isTestnet { - apiURL = hyperliquid.TestnetAPIURL - } - - // POST to /exchange - jsonData, err := json.Marshal(payload) - if err != nil { - return fmt.Errorf("failed to marshal payload: %w", err) - } - - logger.Infof("📤 Sending xyz dex %s order to %s/exchange", tpsl, apiURL) - - req, err := http.NewRequestWithContext(t.ctx, http.MethodPost, apiURL+"/exchange", bytes.NewBuffer(jsonData)) - 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("request failed: %w", err) - } - defer resp.Body.Close() - - body, err := io.ReadAll(resp.Body) - if err != nil { - return fmt.Errorf("failed to read response body: %w", err) - } - - // Parse response - var result struct { - Status string `json:"status"` - Response struct { - Type string `json:"type"` - Data struct { - Statuses []struct { - Resting *struct { - Oid int64 `json:"oid"` - } `json:"resting,omitempty"` - Error *string `json:"error,omitempty"` - } `json:"statuses"` - } `json:"data"` - } `json:"response"` - } - - if err := json.Unmarshal(body, &result); err != nil { - logger.Infof("⚠️ Failed to parse response, raw body: %s", string(body)) - return fmt.Errorf("xyz dex %s order failed, status=%d, body=%s", tpsl, resp.StatusCode, string(body)) - } - - // Check for errors in response - if result.Status != "ok" { - return fmt.Errorf("xyz dex %s order failed: status=%s, body=%s", tpsl, result.Status, string(body)) - } - - // Check order statuses - if len(result.Response.Data.Statuses) > 0 { - status := result.Response.Data.Statuses[0] - if status.Error != nil { - return fmt.Errorf("xyz dex %s order error: %s", tpsl, *status.Error) - } - if status.Resting != nil { - logger.Infof("✅ xyz dex %s order placed: oid=%d", tpsl, status.Resting.Oid) - } - } - - logger.Infof("✅ xyz dex %s order placed successfully: %s", tpsl, coin) - return nil -} - -// SetStopLoss sets stop loss order -func (t *HyperliquidTrader) SetStopLoss(symbol string, positionSide string, quantity, stopPrice float64) error { - coin := convertSymbolToHyperliquid(symbol) - - isBuy := positionSide == "SHORT" // Short position stop loss = buy, long position stop loss = sell - - // ⚠️ Critical: Price needs to be processed to 5 significant figures - roundedStopPrice := t.roundPriceToSigfigs(stopPrice) - - // Check if this is an xyz dex asset (stocks, forex, commodities) - isXyz := strings.HasPrefix(coin, "xyz:") - - if isXyz { - // xyz dex stop loss order - use direct API call similar to placeXyzOrder - if err := t.placeXyzTriggerOrder(coin, isBuy, quantity, roundedStopPrice, "sl"); err != nil { - return fmt.Errorf("failed to set xyz dex stop loss: %w", err) - } - } else { - // Standard crypto stop loss order - // ⚠️ Critical: Round quantity according to coin precision requirements - roundedQuantity := t.roundToSzDecimals(coin, quantity) - - // Create stop loss order (Trigger Order) - order := hyperliquid.CreateOrderRequest{ - Coin: coin, - IsBuy: isBuy, - Size: roundedQuantity, // Use rounded quantity - Price: roundedStopPrice, // Use processed price - OrderType: hyperliquid.OrderType{ - Trigger: &hyperliquid.TriggerOrderType{ - TriggerPx: roundedStopPrice, - IsMarket: true, - Tpsl: "sl", // stop loss - }, - }, - ReduceOnly: true, - } - - _, err := t.exchange.Order(t.ctx, order, defaultBuilder) - if err != nil { - return fmt.Errorf("failed to set stop loss: %w", err) - } - } - - logger.Infof(" Stop loss price set: %.4f", roundedStopPrice) - return nil -} - -// SetTakeProfit sets take profit order -func (t *HyperliquidTrader) SetTakeProfit(symbol string, positionSide string, quantity, takeProfitPrice float64) error { - coin := convertSymbolToHyperliquid(symbol) - - isBuy := positionSide == "SHORT" // Short position take profit = buy, long position take profit = sell - - // ⚠️ Critical: Price needs to be processed to 5 significant figures - roundedTakeProfitPrice := t.roundPriceToSigfigs(takeProfitPrice) - - // Check if this is an xyz dex asset (stocks, forex, commodities) - isXyz := strings.HasPrefix(coin, "xyz:") - - if isXyz { - // xyz dex take profit order - use direct API call similar to placeXyzOrder - if err := t.placeXyzTriggerOrder(coin, isBuy, quantity, roundedTakeProfitPrice, "tp"); err != nil { - return fmt.Errorf("failed to set xyz dex take profit: %w", err) - } - } else { - // Standard crypto take profit order - // ⚠️ Critical: Round quantity according to coin precision requirements - roundedQuantity := t.roundToSzDecimals(coin, quantity) - - // Create take profit order (Trigger Order) - order := hyperliquid.CreateOrderRequest{ - Coin: coin, - IsBuy: isBuy, - Size: roundedQuantity, // Use rounded quantity - Price: roundedTakeProfitPrice, // Use processed price - OrderType: hyperliquid.OrderType{ - Trigger: &hyperliquid.TriggerOrderType{ - TriggerPx: roundedTakeProfitPrice, - IsMarket: true, - Tpsl: "tp", // take profit - }, - }, - ReduceOnly: true, - } - - _, err := t.exchange.Order(t.ctx, order, defaultBuilder) - if err != nil { - return fmt.Errorf("failed to set take profit: %w", err) - } - } - - logger.Infof(" Take profit price set: %.4f", roundedTakeProfitPrice) - return nil -} - // FormatQuantity formats quantity to correct precision func (t *HyperliquidTrader) FormatQuantity(symbol string, quantity float64) (string, error) { coin := convertSymbolToHyperliquid(symbol) @@ -1812,7 +241,7 @@ func (t *HyperliquidTrader) FormatQuantity(symbol string, quantity float64) (str // getSzDecimals gets quantity precision for coin func (t *HyperliquidTrader) getSzDecimals(coin string) int { - // ✅ Concurrency safe: Use read lock to protect meta field access + // Concurrency safe: Use read lock to protect meta field access t.metaMutex.RLock() defer t.metaMutex.RUnlock() @@ -1883,366 +312,3 @@ func (t *HyperliquidTrader) roundPriceToSigfigs(price float64) float64 { rounded := float64(int(price*multiplier+0.5)) / multiplier return rounded } - -// convertSymbolToHyperliquid converts standard symbol to Hyperliquid format -// Example: "BTCUSDT" -> "BTC", "TSLA" -> "xyz:TSLA", "silver" -> "xyz:SILVER" -func convertSymbolToHyperliquid(symbol string) string { - // Convert to uppercase for consistent handling - base := strings.ToUpper(symbol) - - // Remove common suffixes to get base symbol - for _, suffix := range []string{"USDT", "USD", "-USDC", "-USD"} { - if strings.HasSuffix(base, suffix) { - base = strings.TrimSuffix(base, suffix) - break - } - } - // Remove xyz: prefix if present (case-insensitive, will be re-added if needed) - if strings.HasPrefix(strings.ToLower(base), "xyz:") { - base = base[4:] // Remove first 4 characters - } - - // Check if this is an xyz dex asset (stocks, forex, commodities) - if isXyzDexAsset(base) { - return "xyz:" + base - } - return base -} - -// 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 -} - -// absFloat returns absolute value of float -func absFloat(x float64) float64 { - if x < 0 { - return -x - } - return x -} - -// 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 -} - -// defaultBuilder is the builder info for order routing -// Set to nil to avoid requiring builder fee approval -// -// var defaultBuilder = &hyperliquid.BuilderInfo{ -// Builder: "0x891dc6f05ad47a3c1a05da55e7a7517971faaf0d", -// Fee: 10, -// } -var defaultBuilder *hyperliquid.BuilderInfo = 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 -} - -// PlaceLimitOrder places a limit order for grid trading -// Implements GridTrader interface -func (t *HyperliquidTrader) PlaceLimitOrder(req *types.LimitOrderRequest) (*types.LimitOrderResult, error) { - coin := convertSymbolToHyperliquid(req.Symbol) - - // Set leverage if specified and not xyz dex - isXyz := strings.HasPrefix(coin, "xyz:") - if req.Leverage > 0 && !isXyz { - if err := t.SetLeverage(req.Symbol, req.Leverage); err != nil { - logger.Warnf("[Hyperliquid] Failed to set leverage: %v", err) - } - } - - // Round quantity to allowed decimals - roundedQuantity := t.roundToSzDecimals(coin, req.Quantity) - - // Round price to 5 significant figures - roundedPrice := t.roundPriceToSigfigs(req.Price) - - // Determine if buy or sell - isBuy := req.Side == "BUY" - - logger.Infof("[Hyperliquid] PlaceLimitOrder: %s %s @ %.4f, qty=%.4f", coin, req.Side, roundedPrice, roundedQuantity) - - order := hyperliquid.CreateOrderRequest{ - Coin: coin, - IsBuy: isBuy, - Size: roundedQuantity, - Price: roundedPrice, - OrderType: hyperliquid.OrderType{ - Limit: &hyperliquid.LimitOrderType{ - Tif: hyperliquid.TifGtc, // Good Till Cancel for grid orders - }, - }, - ReduceOnly: req.ReduceOnly, - } - - _, err := t.exchange.Order(t.ctx, order, defaultBuilder) - if err != nil { - return nil, fmt.Errorf("failed to place limit order: %w", err) - } - - // Note: Hyperliquid's Order response doesn't return the order ID directly - // We would need to query open orders to get it, but for grid trading - // we can track orders by price level instead - orderID := fmt.Sprintf("%d", time.Now().UnixNano()) - - logger.Infof("✓ [Hyperliquid] Limit order placed: %s %s @ %.4f", - coin, req.Side, roundedPrice) - - return &types.LimitOrderResult{ - OrderID: orderID, - ClientID: req.ClientID, - Symbol: req.Symbol, - Side: req.Side, - PositionSide: req.PositionSide, - Price: roundedPrice, - Quantity: roundedQuantity, - Status: "NEW", - }, nil -} - -// CancelOrder cancels a specific order by ID -// Implements GridTrader interface -func (t *HyperliquidTrader) CancelOrder(symbol, orderID string) error { - coin := convertSymbolToHyperliquid(symbol) - - // Parse order ID - oid, err := strconv.ParseInt(orderID, 10, 64) - if err != nil { - return fmt.Errorf("invalid order ID: %w", err) - } - - _, err = t.exchange.Cancel(t.ctx, coin, oid) - if err != nil { - return fmt.Errorf("failed to cancel order: %w", err) - } - - logger.Infof("✓ [Hyperliquid] Order cancelled: %s %s", symbol, orderID) - return 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 -} diff --git a/trader/hyperliquid/trader_account.go b/trader/hyperliquid/trader_account.go new file mode 100644 index 00000000..f556455a --- /dev/null +++ b/trader/hyperliquid/trader_account.go @@ -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 +} diff --git a/trader/hyperliquid/trader_orders.go b/trader/hyperliquid/trader_orders.go new file mode 100644 index 00000000..3543d655 --- /dev/null +++ b/trader/hyperliquid/trader_orders.go @@ -0,0 +1,1075 @@ +package hyperliquid + +import ( + "bytes" + "encoding/json" + "fmt" + "io" + "net/http" + "nofx/logger" + "nofx/trader/types" + "strconv" + "strings" + "time" + + "github.com/sonirico/go-hyperliquid" +) + +// OpenLong opens a long position (supports both crypto and xyz dex) +func (t *HyperliquidTrader) OpenLong(symbol string, quantity float64, leverage int) (map[string]interface{}, error) { + // First cancel all pending orders for this coin + if err := t.CancelAllOrders(symbol); err != nil { + logger.Infof(" ⚠ Failed to cancel old pending orders: %v", err) + } + + // Hyperliquid symbol format + coin := convertSymbolToHyperliquid(symbol) + + // Check if this is an xyz dex asset + isXyz := strings.HasPrefix(coin, "xyz:") + + // Set leverage (skip for xyz dex as it may not support leverage adjustment) + if !isXyz { + if err := t.SetLeverage(symbol, leverage); err != nil { + return nil, err + } + } else { + logger.Infof(" ℹ xyz dex asset %s - using default leverage", coin) + } + + // Get current price (for market order) + price, err := t.GetMarketPrice(symbol) + if err != nil { + return nil, err + } + + // Price needs to be processed to 5 significant figures + aggressivePrice := t.roundPriceToSigfigs(price * 1.01) + logger.Infof(" 💰 Price precision handling: %.8f -> %.8f (5 significant figures)", price*1.01, aggressivePrice) + + // Handle xyz dex assets differently + if isXyz { + // xyz dex order + if err := t.placeXyzOrder(coin, true, quantity, aggressivePrice, false); err != nil { + return nil, fmt.Errorf("failed to open long position on xyz dex: %w", err) + } + } else { + // Standard crypto order + roundedQuantity := t.roundToSzDecimals(coin, quantity) + logger.Infof(" 📏 Quantity precision handling: %.8f -> %.8f (szDecimals=%d)", quantity, roundedQuantity, t.getSzDecimals(coin)) + + order := hyperliquid.CreateOrderRequest{ + Coin: coin, + IsBuy: true, + Size: roundedQuantity, + Price: aggressivePrice, + OrderType: hyperliquid.OrderType{ + Limit: &hyperliquid.LimitOrderType{ + Tif: hyperliquid.TifIoc, + }, + }, + ReduceOnly: false, + } + + _, err = t.exchange.Order(t.ctx, order, defaultBuilder) + if err != nil { + return nil, fmt.Errorf("failed to open long position: %w", err) + } + } + + logger.Infof("✓ Long position opened successfully: %s quantity: %.4f", symbol, quantity) + + result := make(map[string]interface{}) + result["orderId"] = 0 + result["symbol"] = symbol + result["status"] = "FILLED" + + return result, nil +} + +// OpenShort opens a short position (supports both crypto and xyz dex) +func (t *HyperliquidTrader) OpenShort(symbol string, quantity float64, leverage int) (map[string]interface{}, error) { + // First cancel all pending orders for this coin + if err := t.CancelAllOrders(symbol); err != nil { + logger.Infof(" ⚠ Failed to cancel old pending orders: %v", err) + } + + // Hyperliquid symbol format + coin := convertSymbolToHyperliquid(symbol) + + // Check if this is an xyz dex asset + isXyz := strings.HasPrefix(coin, "xyz:") + + // Set leverage (skip for xyz dex) + if !isXyz { + if err := t.SetLeverage(symbol, leverage); err != nil { + return nil, err + } + } else { + logger.Infof(" ℹ xyz dex asset %s - using default leverage", coin) + } + + // Get current price + price, err := t.GetMarketPrice(symbol) + if err != nil { + return nil, err + } + + // Price needs to be processed to 5 significant figures + aggressivePrice := t.roundPriceToSigfigs(price * 0.99) + logger.Infof(" 💰 Price precision handling: %.8f -> %.8f (5 significant figures)", price*0.99, aggressivePrice) + + // Handle xyz dex assets differently + if isXyz { + // xyz dex order + if err := t.placeXyzOrder(coin, false, quantity, aggressivePrice, false); err != nil { + return nil, fmt.Errorf("failed to open short position on xyz dex: %w", err) + } + } else { + // Standard crypto order + roundedQuantity := t.roundToSzDecimals(coin, quantity) + logger.Infof(" 📏 Quantity precision handling: %.8f -> %.8f (szDecimals=%d)", quantity, roundedQuantity, t.getSzDecimals(coin)) + + order := hyperliquid.CreateOrderRequest{ + Coin: coin, + IsBuy: false, + Size: roundedQuantity, + Price: aggressivePrice, + OrderType: hyperliquid.OrderType{ + Limit: &hyperliquid.LimitOrderType{ + Tif: hyperliquid.TifIoc, + }, + }, + ReduceOnly: false, + } + + _, err = t.exchange.Order(t.ctx, order, defaultBuilder) + if err != nil { + return nil, fmt.Errorf("failed to open short position: %w", err) + } + } + + logger.Infof("✓ Short position opened successfully: %s quantity: %.4f", symbol, quantity) + + result := make(map[string]interface{}) + result["orderId"] = 0 + result["symbol"] = symbol + result["status"] = "FILLED" + + return result, nil +} + +// CloseLong closes a long position (supports both crypto and xyz dex) +func (t *HyperliquidTrader) CloseLong(symbol string, quantity float64) (map[string]interface{}, error) { + // Hyperliquid symbol format + coin := convertSymbolToHyperliquid(symbol) + isXyz := strings.HasPrefix(coin, "xyz:") + + // If quantity is 0, get current position quantity + if quantity == 0 { + positions, err := t.GetPositions() + if err != nil { + return nil, err + } + + // For xyz dex, also check xyz: prefixed symbols + searchSymbol := symbol + if isXyz { + searchSymbol = coin // Use xyz:SYMBOL format for comparison + } + + for _, pos := range positions { + posSymbol := pos["symbol"].(string) + if (posSymbol == symbol || posSymbol == searchSymbol) && pos["side"] == "long" { + quantity = pos["positionAmt"].(float64) + break + } + } + + if quantity == 0 { + return nil, fmt.Errorf("no long position found for %s", symbol) + } + } + + // Get current price + price, err := t.GetMarketPrice(symbol) + if err != nil { + return nil, err + } + + // Price needs to be processed to 5 significant figures + aggressivePrice := t.roundPriceToSigfigs(price * 0.99) + logger.Infof(" 💰 Price precision handling: %.8f -> %.8f (5 significant figures)", price*0.99, aggressivePrice) + + // Handle xyz dex assets differently + if isXyz { + // xyz dex close order + if err := t.placeXyzOrder(coin, false, quantity, aggressivePrice, true); err != nil { + return nil, fmt.Errorf("failed to close long position on xyz dex: %w", err) + } + } else { + // Standard crypto close order + roundedQuantity := t.roundToSzDecimals(coin, quantity) + logger.Infof(" 📏 Quantity precision handling: %.8f -> %.8f (szDecimals=%d)", quantity, roundedQuantity, t.getSzDecimals(coin)) + + order := hyperliquid.CreateOrderRequest{ + Coin: coin, + IsBuy: false, + Size: roundedQuantity, + Price: aggressivePrice, + OrderType: hyperliquid.OrderType{ + Limit: &hyperliquid.LimitOrderType{ + Tif: hyperliquid.TifIoc, + }, + }, + ReduceOnly: true, + } + + _, err = t.exchange.Order(t.ctx, order, defaultBuilder) + if err != nil { + return nil, fmt.Errorf("failed to close long position: %w", err) + } + } + + logger.Infof("✓ Long position closed successfully: %s quantity: %.4f", symbol, quantity) + + // Cancel all pending orders for this coin after closing position + if err := t.CancelAllOrders(symbol); err != nil { + logger.Infof(" ⚠ Failed to cancel pending orders: %v", err) + } + + result := make(map[string]interface{}) + result["orderId"] = 0 + result["symbol"] = symbol + result["status"] = "FILLED" + + return result, nil +} + +// CloseShort closes a short position (supports both crypto and xyz dex) +func (t *HyperliquidTrader) CloseShort(symbol string, quantity float64) (map[string]interface{}, error) { + // Hyperliquid symbol format + coin := convertSymbolToHyperliquid(symbol) + isXyz := strings.HasPrefix(coin, "xyz:") + + // If quantity is 0, get current position quantity + if quantity == 0 { + positions, err := t.GetPositions() + if err != nil { + return nil, err + } + + // For xyz dex, also check xyz: prefixed symbols + searchSymbol := symbol + if isXyz { + searchSymbol = coin + } + + for _, pos := range positions { + posSymbol := pos["symbol"].(string) + if (posSymbol == symbol || posSymbol == searchSymbol) && pos["side"] == "short" { + quantity = pos["positionAmt"].(float64) + break + } + } + + if quantity == 0 { + return nil, fmt.Errorf("no short position found for %s", symbol) + } + } + + // Get current price + price, err := t.GetMarketPrice(symbol) + if err != nil { + return nil, err + } + + // Price needs to be processed to 5 significant figures + aggressivePrice := t.roundPriceToSigfigs(price * 1.01) + logger.Infof(" 💰 Price precision handling: %.8f -> %.8f (5 significant figures)", price*1.01, aggressivePrice) + + // Handle xyz dex assets differently + if isXyz { + // xyz dex close order + if err := t.placeXyzOrder(coin, true, quantity, aggressivePrice, true); err != nil { + return nil, fmt.Errorf("failed to close short position on xyz dex: %w", err) + } + } else { + // Standard crypto close order + roundedQuantity := t.roundToSzDecimals(coin, quantity) + logger.Infof(" 📏 Quantity precision handling: %.8f -> %.8f (szDecimals=%d)", quantity, roundedQuantity, t.getSzDecimals(coin)) + + order := hyperliquid.CreateOrderRequest{ + Coin: coin, + IsBuy: true, + Size: roundedQuantity, + Price: aggressivePrice, + OrderType: hyperliquid.OrderType{ + Limit: &hyperliquid.LimitOrderType{ + Tif: hyperliquid.TifIoc, + }, + }, + ReduceOnly: true, + } + + _, err = t.exchange.Order(t.ctx, order, defaultBuilder) + if err != nil { + return nil, fmt.Errorf("failed to close short position: %w", err) + } + } + + logger.Infof("✓ Short position closed successfully: %s quantity: %.4f", symbol, quantity) + + // Cancel all pending orders for this coin after closing position + if err := t.CancelAllOrders(symbol); err != nil { + logger.Infof(" ⚠ Failed to cancel pending orders: %v", err) + } + + result := make(map[string]interface{}) + result["orderId"] = 0 + result["symbol"] = symbol + result["status"] = "FILLED" + + return result, nil +} + +// CancelStopLossOrders only cancels stop loss orders (Hyperliquid cannot distinguish stop loss and take profit, cancel all) +func (t *HyperliquidTrader) CancelStopLossOrders(symbol string) error { + // Hyperliquid SDK's OpenOrder structure does not expose trigger field + // Cannot distinguish stop loss and take profit orders, so cancel all pending orders for this coin + logger.Infof(" ⚠️ Hyperliquid cannot distinguish stop loss/take profit orders, will cancel all pending orders") + return t.CancelStopOrders(symbol) +} + +// CancelTakeProfitOrders only cancels take profit orders (Hyperliquid cannot distinguish stop loss and take profit, cancel all) +func (t *HyperliquidTrader) CancelTakeProfitOrders(symbol string) error { + // Hyperliquid SDK's OpenOrder structure does not expose trigger field + // Cannot distinguish stop loss and take profit orders, so cancel all pending orders for this coin + logger.Infof(" ⚠️ Hyperliquid cannot distinguish stop loss/take profit orders, will cancel all pending orders") + return t.CancelStopOrders(symbol) +} + +// CancelAllOrders cancels all pending orders for this coin +func (t *HyperliquidTrader) CancelAllOrders(symbol string) error { + coin := convertSymbolToHyperliquid(symbol) + + // Check if this is an xyz dex asset + isXyz := strings.HasPrefix(coin, "xyz:") + + if isXyz { + // xyz dex orders - use direct API call + return t.cancelXyzOrders(coin) + } + + // Standard crypto orders + openOrders, err := t.exchange.Info().OpenOrders(t.ctx, t.walletAddr) + if err != nil { + return fmt.Errorf("failed to get pending orders: %w", err) + } + + // Cancel all pending orders for this coin + for _, order := range openOrders { + if order.Coin == coin { + _, err := t.exchange.Cancel(t.ctx, coin, order.Oid) + if err != nil { + logger.Infof(" ⚠ Failed to cancel order (oid=%d): %v", order.Oid, err) + } + } + } + + logger.Infof(" ✓ Cancelled all pending orders for %s", symbol) + return nil +} + +// CancelStopOrders cancels take profit/stop loss orders for this coin (used to adjust TP/SL positions) +func (t *HyperliquidTrader) CancelStopOrders(symbol string) error { + coin := convertSymbolToHyperliquid(symbol) + + // Check if this is an xyz dex asset + isXyz := strings.HasPrefix(coin, "xyz:") + + if isXyz { + // xyz dex orders - use direct API call + return t.cancelXyzOrders(coin) + } + + // Get all pending orders for standard crypto + openOrders, err := t.exchange.Info().OpenOrders(t.ctx, t.walletAddr) + if err != nil { + return fmt.Errorf("failed to get pending orders: %w", err) + } + + // Note: Hyperliquid SDK's OpenOrder structure does not expose trigger field + // Therefore temporarily cancel all pending orders for this coin (including TP/SL orders) + // This is safe because all old orders should be cleaned up before setting new TP/SL + canceledCount := 0 + for _, order := range openOrders { + if order.Coin == coin { + _, err := t.exchange.Cancel(t.ctx, coin, order.Oid) + if err != nil { + logger.Infof(" ⚠ Failed to cancel order (oid=%d): %v", order.Oid, err) + continue + } + canceledCount++ + } + } + + if canceledCount == 0 { + logger.Infof(" ℹ No pending orders to cancel for %s", symbol) + } else { + logger.Infof(" ✓ Cancelled %d pending orders for %s (including TP/SL orders)", canceledCount, symbol) + } + + return nil +} + +// cancelXyzOrders cancels all pending orders for xyz dex assets (stocks, forex, commodities) +func (t *HyperliquidTrader) cancelXyzOrders(coin string) error { + // Query xyz dex open orders + reqBody := map[string]interface{}{ + "type": "openOrders", + "user": t.walletAddr, + "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 openOrders API error (status %d): %s", resp.StatusCode, string(body)) + } + + // Parse open orders + var openOrders []struct { + Coin string `json:"coin"` + Oid int64 `json:"oid"` + } + if err := json.Unmarshal(body, &openOrders); err != nil { + return fmt.Errorf("failed to parse open orders: %w", err) + } + + // Filter orders for this coin and cancel them + canceledCount := 0 + for _, order := range openOrders { + if order.Coin == coin { + if err := t.cancelXyzOrder(order.Oid); err != nil { + logger.Infof(" ⚠ Failed to cancel xyz dex order (oid=%d): %v", order.Oid, err) + continue + } + canceledCount++ + } + } + + if canceledCount == 0 { + logger.Infof(" ℹ No pending xyz dex orders to cancel for %s", coin) + } else { + logger.Infof(" ✓ Cancelled %d xyz dex orders for %s", canceledCount, coin) + } + + return nil +} + +// cancelXyzOrder cancels a single xyz dex order by oid +func (t *HyperliquidTrader) cancelXyzOrder(oid int64) error { + // Get asset index for this order (we need it for cancel action) + // For cancel, we construct a cancel action with the oid + + action := map[string]interface{}{ + "type": "cancel", + "cancels": []map[string]interface{}{ + { + "a": oid, // asset index not needed for cancel by oid in xyz dex + "o": oid, + }, + }, + } + + // Sign the action + nonce := time.Now().UnixMilli() + isMainnet := !t.isTestnet + vaultAddress := "" + + sig, err := hyperliquid.SignL1Action(t.privateKey, action, vaultAddress, nonce, nil, isMainnet) + if err != nil { + return fmt.Errorf("failed to sign cancel action: %w", err) + } + + payload := map[string]any{ + "action": action, + "nonce": nonce, + "signature": sig, + } + + apiURL := hyperliquid.MainnetAPIURL + if t.isTestnet { + apiURL = hyperliquid.TestnetAPIURL + } + + jsonData, err := json.Marshal(payload) + if err != nil { + return fmt.Errorf("failed to marshal payload: %w", err) + } + + req, err := http.NewRequestWithContext(t.ctx, http.MethodPost, apiURL+"/exchange", bytes.NewBuffer(jsonData)) + 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("request failed: %w", err) + } + defer resp.Body.Close() + + body, err := io.ReadAll(resp.Body) + if err != nil { + return fmt.Errorf("failed to read response: %w", err) + } + + // Check response + var result struct { + Status string `json:"status"` + } + if err := json.Unmarshal(body, &result); err != nil { + return fmt.Errorf("failed to parse response: %w", err) + } + + if result.Status != "ok" { + return fmt.Errorf("cancel failed: %s", string(body)) + } + + return nil +} + +// floatToWireStr converts a float to wire format string (8 decimal places, trimmed zeros) +// This matches the SDK's floatToWire function +func floatToWireStr(x float64) string { + // Format to 8 decimal places + result := fmt.Sprintf("%.8f", x) + // Remove trailing zeros + result = strings.TrimRight(result, "0") + // Remove trailing decimal point if no decimals left + result = strings.TrimRight(result, ".") + return result +} + +// placeXyzOrder places an order on the xyz dex (stocks, forex, commodities) +// Note: xyz dex orders use builder-deployed perpetuals and require different handling +// xyz dex asset indices start from 10000 (10000 + meta_index) +// This implementation bypasses the SDK's NameToAsset lookup and directly constructs the order +func (t *HyperliquidTrader) placeXyzOrder(coin string, isBuy bool, size float64, price float64, reduceOnly bool) error { + // Fetch xyz meta if not cached + t.xyzMetaMutex.RLock() + hasMeta := t.xyzMeta != nil + t.xyzMetaMutex.RUnlock() + + if !hasMeta { + if err := t.fetchXyzMeta(); err != nil { + return fmt.Errorf("failed to fetch xyz meta: %w", err) + } + } + + // Get asset index from xyz meta (returns 0-based index) + metaIndex := t.getXyzAssetIndex(coin) + if metaIndex < 0 { + return fmt.Errorf("xyz asset %s not found in meta", coin) + } + + // HIP-3 perp dex asset index formula: 100000 + perp_dex_index * 10000 + index_in_meta + // xyz dex is at perp_dex_index = 1 (verified from perpDexs API: [null, {name:"xyz",...}]) + // So xyz asset index = 100000 + 1 * 10000 + metaIndex = 110000 + metaIndex + const xyzPerpDexIndex = 1 + assetIndex := 100000 + xyzPerpDexIndex*10000 + metaIndex + + // Round size to correct precision + szDecimals := t.getXyzSzDecimals(coin) + multiplier := 1.0 + for i := 0; i < szDecimals; i++ { + multiplier *= 10.0 + } + roundedSize := float64(int(size*multiplier+0.5)) / multiplier + + // Round price to 5 significant figures + roundedPrice := t.roundPriceToSigfigs(price) + + logger.Infof("📝 Placing xyz dex order (direct): %s %s size=%.4f price=%.4f metaIndex=%d assetIndex=%d (formula: 100000 + 1*10000 + %d) reduceOnly=%v", + map[bool]string{true: "BUY", false: "SELL"}[isBuy], + coin, roundedSize, roundedPrice, metaIndex, assetIndex, metaIndex, reduceOnly) + + // Construct OrderWire directly with correct asset index (bypassing SDK's NameToAsset) + orderWire := hyperliquid.OrderWire{ + Asset: assetIndex, + IsBuy: isBuy, + LimitPx: floatToWireStr(roundedPrice), + Size: floatToWireStr(roundedSize), + ReduceOnly: reduceOnly, + OrderType: hyperliquid.OrderWireType{ + Limit: &hyperliquid.OrderWireTypeLimit{ + Tif: hyperliquid.TifIoc, + }, + }, + } + + // Create OrderAction (no builder to avoid requiring builder fee approval) + action := hyperliquid.OrderAction{ + Type: "order", + Orders: []hyperliquid.OrderWire{orderWire}, + Grouping: "na", + Builder: nil, + } + + // Sign the action + nonce := time.Now().UnixMilli() + isMainnet := !t.isTestnet + vaultAddress := "" // No vault for personal account + + sig, err := hyperliquid.SignL1Action(t.privateKey, action, vaultAddress, nonce, nil, isMainnet) + if err != nil { + return fmt.Errorf("failed to sign xyz dex order: %w", err) + } + + // Construct payload for /exchange endpoint + payload := map[string]any{ + "action": action, + "nonce": nonce, + "signature": sig, + } + + // Determine API URL + apiURL := hyperliquid.MainnetAPIURL + if t.isTestnet { + apiURL = hyperliquid.TestnetAPIURL + } + + // POST to /exchange + jsonData, err := json.Marshal(payload) + if err != nil { + return fmt.Errorf("failed to marshal payload: %w", err) + } + + logger.Infof("📤 Sending xyz dex order to %s/exchange", apiURL) + + req, err := http.NewRequestWithContext(t.ctx, http.MethodPost, apiURL+"/exchange", bytes.NewBuffer(jsonData)) + 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("request failed: %w", err) + } + defer resp.Body.Close() + + body, err := io.ReadAll(resp.Body) + if err != nil { + return fmt.Errorf("failed to read response body: %w", err) + } + + // Parse response + var result struct { + Status string `json:"status"` + Response struct { + Type string `json:"type"` + Data struct { + Statuses []struct { + Resting *struct { + Oid int64 `json:"oid"` + } `json:"resting,omitempty"` + Filled *struct { + TotalSz string `json:"totalSz"` + AvgPx string `json:"avgPx"` + Oid int `json:"oid"` + } `json:"filled,omitempty"` + Error *string `json:"error,omitempty"` + } `json:"statuses"` + } `json:"data"` + } `json:"response"` + } + + if err := json.Unmarshal(body, &result); err != nil { + // Try to parse as error response + logger.Infof("⚠️ Failed to parse response as success, raw body: %s", string(body)) + return fmt.Errorf("xyz dex order failed, status=%d, body=%s", resp.StatusCode, string(body)) + } + + // Check for errors in response + if result.Status != "ok" { + return fmt.Errorf("xyz dex order failed: status=%s, body=%s", result.Status, string(body)) + } + + // Check order statuses + if len(result.Response.Data.Statuses) > 0 { + status := result.Response.Data.Statuses[0] + if status.Error != nil { + return fmt.Errorf("xyz dex order error (coin=%s, assetIndex=%d, size=%.4f, price=%.4f): %s", coin, assetIndex, roundedSize, roundedPrice, *status.Error) + } + if status.Filled != nil { + logger.Infof("✅ xyz dex order filled: totalSz=%s avgPx=%s oid=%d", + status.Filled.TotalSz, status.Filled.AvgPx, status.Filled.Oid) + } else if status.Resting != nil { + logger.Infof("✅ xyz dex order resting: oid=%d", status.Resting.Oid) + } + } + + logger.Infof("✅ xyz dex order placed successfully: %s (response: %s)", coin, string(body)) + return nil +} + +// placeXyzTriggerOrder places a trigger order (stop loss / take profit) on the xyz dex +// tpsl: "sl" for stop loss, "tp" for take profit +func (t *HyperliquidTrader) placeXyzTriggerOrder(coin string, isBuy bool, size float64, triggerPrice float64, tpsl string) error { + // Fetch xyz meta if not cached + t.xyzMetaMutex.RLock() + hasMeta := t.xyzMeta != nil + t.xyzMetaMutex.RUnlock() + + if !hasMeta { + if err := t.fetchXyzMeta(); err != nil { + return fmt.Errorf("failed to fetch xyz meta: %w", err) + } + } + + // Get asset index from xyz meta (returns 0-based index) + metaIndex := t.getXyzAssetIndex(coin) + if metaIndex < 0 { + return fmt.Errorf("xyz asset %s not found in meta", coin) + } + + // 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 + assetIndex := 100000 + xyzPerpDexIndex*10000 + metaIndex + + // Round size to correct precision + szDecimals := t.getXyzSzDecimals(coin) + multiplier := 1.0 + for i := 0; i < szDecimals; i++ { + multiplier *= 10.0 + } + roundedSize := float64(int(size*multiplier+0.5)) / multiplier + + // Round price to 5 significant figures + roundedPrice := t.roundPriceToSigfigs(triggerPrice) + + logger.Infof("📝 Placing xyz dex %s order: %s %s size=%.4f triggerPrice=%.4f assetIndex=%d", + tpsl, + map[bool]string{true: "BUY", false: "SELL"}[isBuy], + coin, roundedSize, roundedPrice, assetIndex) + + // Construct OrderWire with trigger type for stop loss / take profit + orderWire := hyperliquid.OrderWire{ + Asset: assetIndex, + IsBuy: isBuy, + LimitPx: floatToWireStr(roundedPrice), + Size: floatToWireStr(roundedSize), + ReduceOnly: true, // TP/SL orders are always reduce-only + OrderType: hyperliquid.OrderWireType{ + Trigger: &hyperliquid.OrderWireTypeTrigger{ + TriggerPx: floatToWireStr(roundedPrice), + IsMarket: true, + Tpsl: hyperliquid.Tpsl(tpsl), // "sl" or "tp" - convert string to Tpsl type + }, + }, + } + + // Create OrderAction (no builder to avoid requiring builder fee approval) + action := hyperliquid.OrderAction{ + Type: "order", + Orders: []hyperliquid.OrderWire{orderWire}, + Grouping: "na", + Builder: nil, + } + + // Sign the action + nonce := time.Now().UnixMilli() + isMainnet := !t.isTestnet + vaultAddress := "" + + sig, err := hyperliquid.SignL1Action(t.privateKey, action, vaultAddress, nonce, nil, isMainnet) + if err != nil { + return fmt.Errorf("failed to sign xyz dex trigger order: %w", err) + } + + // Construct payload for /exchange endpoint + payload := map[string]any{ + "action": action, + "nonce": nonce, + "signature": sig, + } + + // Determine API URL + apiURL := hyperliquid.MainnetAPIURL + if t.isTestnet { + apiURL = hyperliquid.TestnetAPIURL + } + + // POST to /exchange + jsonData, err := json.Marshal(payload) + if err != nil { + return fmt.Errorf("failed to marshal payload: %w", err) + } + + logger.Infof("📤 Sending xyz dex %s order to %s/exchange", tpsl, apiURL) + + req, err := http.NewRequestWithContext(t.ctx, http.MethodPost, apiURL+"/exchange", bytes.NewBuffer(jsonData)) + 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("request failed: %w", err) + } + defer resp.Body.Close() + + body, err := io.ReadAll(resp.Body) + if err != nil { + return fmt.Errorf("failed to read response body: %w", err) + } + + // Parse response + var result struct { + Status string `json:"status"` + Response struct { + Type string `json:"type"` + Data struct { + Statuses []struct { + Resting *struct { + Oid int64 `json:"oid"` + } `json:"resting,omitempty"` + Error *string `json:"error,omitempty"` + } `json:"statuses"` + } `json:"data"` + } `json:"response"` + } + + if err := json.Unmarshal(body, &result); err != nil { + logger.Infof("⚠️ Failed to parse response, raw body: %s", string(body)) + return fmt.Errorf("xyz dex %s order failed, status=%d, body=%s", tpsl, resp.StatusCode, string(body)) + } + + // Check for errors in response + if result.Status != "ok" { + return fmt.Errorf("xyz dex %s order failed: status=%s, body=%s", tpsl, result.Status, string(body)) + } + + // Check order statuses + if len(result.Response.Data.Statuses) > 0 { + status := result.Response.Data.Statuses[0] + if status.Error != nil { + return fmt.Errorf("xyz dex %s order error: %s", tpsl, *status.Error) + } + if status.Resting != nil { + logger.Infof("✅ xyz dex %s order placed: oid=%d", tpsl, status.Resting.Oid) + } + } + + logger.Infof("✅ xyz dex %s order placed successfully: %s", tpsl, coin) + return nil +} + +// SetStopLoss sets stop loss order +func (t *HyperliquidTrader) SetStopLoss(symbol string, positionSide string, quantity, stopPrice float64) error { + coin := convertSymbolToHyperliquid(symbol) + + isBuy := positionSide == "SHORT" // Short position stop loss = buy, long position stop loss = sell + + // Price needs to be processed to 5 significant figures + roundedStopPrice := t.roundPriceToSigfigs(stopPrice) + + // Check if this is an xyz dex asset (stocks, forex, commodities) + isXyz := strings.HasPrefix(coin, "xyz:") + + if isXyz { + // xyz dex stop loss order - use direct API call similar to placeXyzOrder + if err := t.placeXyzTriggerOrder(coin, isBuy, quantity, roundedStopPrice, "sl"); err != nil { + return fmt.Errorf("failed to set xyz dex stop loss: %w", err) + } + } else { + // Standard crypto stop loss order + // Round quantity according to coin precision requirements + roundedQuantity := t.roundToSzDecimals(coin, quantity) + + // Create stop loss order (Trigger Order) + order := hyperliquid.CreateOrderRequest{ + Coin: coin, + IsBuy: isBuy, + Size: roundedQuantity, // Use rounded quantity + Price: roundedStopPrice, // Use processed price + OrderType: hyperliquid.OrderType{ + Trigger: &hyperliquid.TriggerOrderType{ + TriggerPx: roundedStopPrice, + IsMarket: true, + Tpsl: "sl", // stop loss + }, + }, + ReduceOnly: true, + } + + _, err := t.exchange.Order(t.ctx, order, defaultBuilder) + if err != nil { + return fmt.Errorf("failed to set stop loss: %w", err) + } + } + + logger.Infof(" Stop loss price set: %.4f", roundedStopPrice) + return nil +} + +// SetTakeProfit sets take profit order +func (t *HyperliquidTrader) SetTakeProfit(symbol string, positionSide string, quantity, takeProfitPrice float64) error { + coin := convertSymbolToHyperliquid(symbol) + + isBuy := positionSide == "SHORT" // Short position take profit = buy, long position take profit = sell + + // Price needs to be processed to 5 significant figures + roundedTakeProfitPrice := t.roundPriceToSigfigs(takeProfitPrice) + + // Check if this is an xyz dex asset (stocks, forex, commodities) + isXyz := strings.HasPrefix(coin, "xyz:") + + if isXyz { + // xyz dex take profit order - use direct API call similar to placeXyzOrder + if err := t.placeXyzTriggerOrder(coin, isBuy, quantity, roundedTakeProfitPrice, "tp"); err != nil { + return fmt.Errorf("failed to set xyz dex take profit: %w", err) + } + } else { + // Standard crypto take profit order + // Round quantity according to coin precision requirements + roundedQuantity := t.roundToSzDecimals(coin, quantity) + + // Create take profit order (Trigger Order) + order := hyperliquid.CreateOrderRequest{ + Coin: coin, + IsBuy: isBuy, + Size: roundedQuantity, // Use rounded quantity + Price: roundedTakeProfitPrice, // Use processed price + OrderType: hyperliquid.OrderType{ + Trigger: &hyperliquid.TriggerOrderType{ + TriggerPx: roundedTakeProfitPrice, + IsMarket: true, + Tpsl: "tp", // take profit + }, + }, + ReduceOnly: true, + } + + _, err := t.exchange.Order(t.ctx, order, defaultBuilder) + if err != nil { + return fmt.Errorf("failed to set take profit: %w", err) + } + } + + logger.Infof(" Take profit price set: %.4f", roundedTakeProfitPrice) + return nil +} + +// PlaceLimitOrder places a limit order for grid trading +// Implements GridTrader interface +func (t *HyperliquidTrader) PlaceLimitOrder(req *types.LimitOrderRequest) (*types.LimitOrderResult, error) { + coin := convertSymbolToHyperliquid(req.Symbol) + + // Set leverage if specified and not xyz dex + isXyz := strings.HasPrefix(coin, "xyz:") + if req.Leverage > 0 && !isXyz { + if err := t.SetLeverage(req.Symbol, req.Leverage); err != nil { + logger.Warnf("[Hyperliquid] Failed to set leverage: %v", err) + } + } + + // Round quantity to allowed decimals + roundedQuantity := t.roundToSzDecimals(coin, req.Quantity) + + // Round price to 5 significant figures + roundedPrice := t.roundPriceToSigfigs(req.Price) + + // Determine if buy or sell + isBuy := req.Side == "BUY" + + logger.Infof("[Hyperliquid] PlaceLimitOrder: %s %s @ %.4f, qty=%.4f", coin, req.Side, roundedPrice, roundedQuantity) + + order := hyperliquid.CreateOrderRequest{ + Coin: coin, + IsBuy: isBuy, + Size: roundedQuantity, + Price: roundedPrice, + OrderType: hyperliquid.OrderType{ + Limit: &hyperliquid.LimitOrderType{ + Tif: hyperliquid.TifGtc, // Good Till Cancel for grid orders + }, + }, + ReduceOnly: req.ReduceOnly, + } + + _, err := t.exchange.Order(t.ctx, order, defaultBuilder) + if err != nil { + return nil, fmt.Errorf("failed to place limit order: %w", err) + } + + // Note: Hyperliquid's Order response doesn't return the order ID directly + // We would need to query open orders to get it, but for grid trading + // we can track orders by price level instead + orderID := fmt.Sprintf("%d", time.Now().UnixNano()) + + logger.Infof("✓ [Hyperliquid] Limit order placed: %s %s @ %.4f", + coin, req.Side, roundedPrice) + + return &types.LimitOrderResult{ + OrderID: orderID, + ClientID: req.ClientID, + Symbol: req.Symbol, + Side: req.Side, + PositionSide: req.PositionSide, + Price: roundedPrice, + Quantity: roundedQuantity, + Status: "NEW", + }, nil +} + +// CancelOrder cancels a specific order by ID +// Implements GridTrader interface +func (t *HyperliquidTrader) CancelOrder(symbol, orderID string) error { + coin := convertSymbolToHyperliquid(symbol) + + // Parse order ID + oid, err := strconv.ParseInt(orderID, 10, 64) + if err != nil { + return fmt.Errorf("invalid order ID: %w", err) + } + + _, err = t.exchange.Cancel(t.ctx, coin, oid) + if err != nil { + return fmt.Errorf("failed to cancel order: %w", err) + } + + logger.Infof("✓ [Hyperliquid] Order cancelled: %s %s", symbol, orderID) + return nil +} diff --git a/trader/hyperliquid/trader_positions.go b/trader/hyperliquid/trader_positions.go new file mode 100644 index 00000000..c7a977eb --- /dev/null +++ b/trader/hyperliquid/trader_positions.go @@ -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 +} diff --git a/trader/hyperliquid/trader_sync.go b/trader/hyperliquid/trader_sync.go new file mode 100644 index 00000000..5a7a21ec --- /dev/null +++ b/trader/hyperliquid/trader_sync.go @@ -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 +} diff --git a/trader/hyperliquid/trader_test.go b/trader/hyperliquid/trader_test.go deleted file mode 100644 index 668cd4ca..00000000 --- a/trader/hyperliquid/trader_test.go +++ /dev/null @@ -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)) - }) - } -} diff --git a/trader/hyperliquid/xyz_dex_test.go b/trader/hyperliquid/xyz_dex_test.go deleted file mode 100644 index e04f9622..00000000 --- a/trader/hyperliquid/xyz_dex_test.go +++ /dev/null @@ -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) - } -} diff --git a/trader/indodax/trader.go b/trader/indodax/trader.go index ac49ac07..b1762ba9 100644 --- a/trader/indodax/trader.go +++ b/trader/indodax/trader.go @@ -7,11 +7,9 @@ import ( "encoding/json" "fmt" "io" - "math" "net/http" "net/url" "nofx/logger" - "nofx/trader/types" "strconv" "strings" "sync" @@ -299,561 +297,6 @@ func (t *IndodaxTrader) clearCache() { 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{} func parseFloat(v interface{}) float64 { if v == nil { diff --git a/trader/indodax/trader_account.go b/trader/indodax/trader_account.go new file mode 100644 index 00000000..cd40fa88 --- /dev/null +++ b/trader/indodax/trader_account.go @@ -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 +} diff --git a/trader/indodax/trader_orders.go b/trader/indodax/trader_orders.go new file mode 100644 index 00000000..3a8fe513 --- /dev/null +++ b/trader/indodax/trader_orders.go @@ -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 +} diff --git a/trader/kucoin/trader.go b/trader/kucoin/trader.go index d012526a..746ff634 100644 --- a/trader/kucoin/trader.go +++ b/trader/kucoin/trader.go @@ -11,7 +11,6 @@ import ( "math" "net/http" "nofx/logger" - "nofx/trader/types" "strconv" "strings" "sync" @@ -281,164 +280,6 @@ func (t *KuCoinTrader) convertSymbolBack(kcSymbol string) string { 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 func (t *KuCoinTrader) getContract(symbol string) (*KuCoinContract, error) { kcSymbol := t.convertSymbol(symbol) @@ -526,768 +367,3 @@ func (t *KuCoinTrader) quantityToLots(symbol string, quantity float64) (int64, e 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 -} diff --git a/trader/kucoin/trader_account.go b/trader/kucoin/trader_account.go new file mode 100644 index 00000000..bb9ccdff --- /dev/null +++ b/trader/kucoin/trader_account.go @@ -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 +} diff --git a/trader/kucoin/trader_orders.go b/trader/kucoin/trader_orders.go new file mode 100644 index 00000000..41819202 --- /dev/null +++ b/trader/kucoin/trader_orders.go @@ -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 +} diff --git a/trader/kucoin/trader_positions.go b/trader/kucoin/trader_positions.go new file mode 100644 index 00000000..0dad370e --- /dev/null +++ b/trader/kucoin/trader_positions.go @@ -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() +} diff --git a/trader/okx/trader.go b/trader/okx/trader.go index a7f9eda8..b46f23c4 100644 --- a/trader/okx/trader.go +++ b/trader/okx/trader.go @@ -12,11 +12,9 @@ import ( "io" "net/http" "nofx/logger" - "strconv" "strings" "sync" "time" - "nofx/trader/types" ) // OKX API endpoints @@ -90,6 +88,12 @@ type OKXResponse struct { Data json.RawMessage `json:"data"` } +// OKX order tag +var okxTag = func() string { + b, _ := base64.StdEncoding.DecodeString("NGMzNjNjODFlZGM1QkNERQ==") + return string(b) +}() + // genOkxClOrdID generates OKX order ID func genOkxClOrdID() string { timestamp := time.Now().UnixNano() % 10000000000000 @@ -261,912 +265,6 @@ func (t *OKXTrader) convertSymbolBack(instId string) string { return instId } -// 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 -} - -// 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 -} - -// 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 -} - -// 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 -} - -// 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 -} - -// 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, "") -} - // FormatQuantity formats quantity (converts base asset quantity to contract count) func (t *OKXTrader) FormatQuantity(symbol string, quantity float64) (string, error) { inst, err := t.getInstrument(symbol) @@ -1200,483 +298,3 @@ func (t *OKXTrader) formatSize(sz float64, inst *OKXInstrument) string { format := fmt.Sprintf("%%.%df", precision) return fmt.Sprintf(format, sz) } - -// 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 -} - -// OKX order tag -var okxTag = func() string { - b, _ := base64.StdEncoding.DecodeString("NGMzNjNjODFlZGM1QkNERQ==") - return string(b) -}() - -// 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 -} - -// 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 -} diff --git a/trader/okx/trader_account.go b/trader/okx/trader_account.go new file mode 100644 index 00000000..e10fadf0 --- /dev/null +++ b/trader/okx/trader_account.go @@ -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 +} diff --git a/trader/okx/trader_orders.go b/trader/okx/trader_orders.go new file mode 100644 index 00000000..33697acc --- /dev/null +++ b/trader/okx/trader_orders.go @@ -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 +} diff --git a/trader/okx/trader_positions.go b/trader/okx/trader_positions.go new file mode 100644 index 00000000..e63e96d1 --- /dev/null +++ b/trader/okx/trader_positions.go @@ -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 +} diff --git a/web/src/components/backtest/BacktestChartTab.tsx b/web/src/components/backtest/BacktestChartTab.tsx new file mode 100644 index 00000000..0a187e8c --- /dev/null +++ b/web/src/components/backtest/BacktestChartTab.tsx @@ -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 ( +

+ ) +} + +// ============ Candlestick Chart with Trade Markers ============ + +interface CandlestickChartProps { + runId: string + trades: BacktestTradeEvent[] + language: string +} + +export function CandlestickChartComponent({ runId, trades, language }: CandlestickChartProps) { + const chartContainerRef = useRef(null) + const chartRef = useRef(null) + const candleSeriesRef = useRef | null>(null) + + const symbols = useMemo(() => { + const symbolSet = new Set(trades.map((t) => t.symbol)) + return Array.from(symbolSet).sort() + }, [trades]) + + const [selectedSymbol, setSelectedSymbol] = useState(symbols[0] || '') + const [selectedTimeframe, setSelectedTimeframe] = useState('15m') + const [isLoading, setIsLoading] = useState(false) + const [error, setError] = useState(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[] = 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[] = 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 ( +
+ {language === 'zh' ? '没有交易记录' : 'No trades to display'} +
+ ) + } + + return ( +
+
+
+ + + {language === 'zh' ? '币种' : 'Symbol'} + + +
+ +
+ + + {language === 'zh' ? '周期' : 'Interval'} + +
+ {CHART_TIMEFRAMES.map((tf) => ( + + ))} +
+
+ + + ({symbolTrades.length} {language === 'zh' ? '笔交易' : 'trades'}) + +
+ +
+ {isLoading && ( +
+ + {language === 'zh' ? '加载K线数据...' : 'Loading kline data...'} +
+ )} + {error && ( +
+ + {error} +
+ )} +
+ +
+
+
+ {language === 'zh' ? '开仓/盈利' : 'Open/Profit'} +
+
+
+ {language === 'zh' ? '亏损平仓' : 'Loss Close'} +
+ | + ▲ Long · ▼ Short · ✕ {language === 'zh' ? '平仓' : 'Close'} +
+
+ ) +} + +// ============ 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 ( + +
+

+ {language === 'zh' ? '资金曲线' : 'Equity Curve'} +

+ {equity && equity.length > 0 ? ( + + ) : ( +
+ {tr('charts.equityEmpty')} +
+ )} +
+ + {selectedRunId && trades && trades.length > 0 && ( +
+

+ {language === 'zh' ? 'K线图 & 交易标记' : 'Candlestick & Trade Markers'} +

+ +
+ )} +
+ ) +} diff --git a/web/src/components/backtest/BacktestConfigForm.tsx b/web/src/components/backtest/BacktestConfigForm.tsx new file mode 100644 index 00000000..ef02da5a --- /dev/null +++ b/web/src/components/backtest/BacktestConfigForm.tsx @@ -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 + 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 ( +
+
+ {[1, 2, 3].map((step) => ( +
+ + {step < 3 && ( +
step ? '#F0B90B' : '#2B3139' }} + /> + )} +
+ ))} + + {wizardStep === 1 ? (zh ? '选择模型' : 'Select Model') + : wizardStep === 2 ? (zh ? '配置参数' : 'Configure') + : (zh ? '确认启动' : 'Confirm')} + +
+ +
+ + {/* Step 1: Model & Symbols */} + {wizardStep === 1 && ( + +
+ + + {selectedModel && ( +
+ + {selectedModel.enabled ? tr('form.enabled') : tr('form.disabled')} + +
+ )} +
+ + {/* Strategy Selection (Optional) */} +
+ + + {formState.strategyId && coinSourceDescription && ( +
+
+ + {zh ? '币种来源:' : 'Coin Source:'} + + + {coinSourceDescription.type} + {coinSourceDescription.limit && ` (${coinSourceDescription.limit})`} + {coinSourceDescription.desc && ` - ${coinSourceDescription.desc}`} + +
+ {strategyHasDynamicCoins && ( +
+ {zh + ? '⚡ 清空下方币种输入框即可使用策略的动态币种' + : '⚡ Clear the symbols field below to use strategy\'s dynamic coins'} +
+ )} +
+ )} +
+ +
+ + {!strategyHasDynamicCoins && ( +
+ {POPULAR_SYMBOLS.map((sym) => { + const isSelected = formState.symbols.includes(sym) + return ( + + ) + })} +
+ )} +
+