25 Commits

Author SHA1 Message Date
tinkle-community
a4a81993bb chore: simplify PR template (OpenClaw-style) 2026-03-11 15:57:03 +08:00
tinkle-community
b73617fed3 fix: stop PR template bot from overwriting user-written descriptions
The pr-template-suggester workflow was triggered on opened/edited/synchronize
events and forcefully replaced the PR body with a template when body < 100 chars.
This caused user-written descriptions to be overwritten.

Replace with a lightweight labeler (OpenClaw-style) that:
- Only adds labels (backend/frontend/docs, size: XS/S/M/L/XL)
- Never modifies the PR body
- Simplified unified PR template at .github/pull_request_template.md
2026-03-11 15:56:50 +08:00
tinkle-community
4774348ed6 refactor: centralize x402 payment flow into shared mcp/x402.go
Extract duplicated doRequestWithPayment/call/CallWithRequestFull/buildRequest/
setAuthHeader (~165 lines x3) into shared helpers in mcp/x402.go. Consolidate
shared types (x402v2PaymentRequired, x402AcceptOption, x402Resource) and remove
duplicate Solana types. Fix validAfter to 0 (official SDK standard), drain 402
body before retry, log Payment-Response tx hash, check Payment-Required before
X-Payment-Required.
2026-03-11 15:42:19 +08:00
tinklefund
e638ba8d8f feat: redesign Claw402 model config UI — friendly wallet setup, USDC guide, official logo, nginx no-cache for index.html 2026-03-11 04:37:50 +08:00
tinkle-community
156bf04bcc feat: add Claw402 (claw402.ai) x402 USDC payment provider
Add Claw402Client for claw402.ai's x402 micropayment gateway (Base USDC).
Supports 15+ AI models (GPT-5.4, Claude Opus, DeepSeek, Qwen, Grok, etc.)
with per-model endpoint routing.

- mcp/claw402.go: new client with model→endpoint mapping, x402 v2 payment flow
- mcp/blockrun_base.go: extract shared signX402Payment() for reuse
- Register "claw402" provider in all 6 consumer switch statements:
  api/server.go, api/strategy.go, trader/auto_trader.go,
  telegram/bot.go, debate/engine.go, backtest/ai_client.go
2026-03-10 17:53:13 +08:00
tinkle-community
af250825e7 feat: add blockRun (x402 USDC) support to all AI model consumers
- telegram/bot.go: add blockrun-base, blockrun-sol, minimax to
  clientForProvider; fix newLLMClient to prefer TelegramConfig.ModelID
  over GetDefault; log USDC payment provider usage
- debate/engine.go: add blockrun-base, blockrun-sol to InitializeClients
- api/strategy.go: add blockrun-base, blockrun-sol to runRealAITest
- backtest/ai_client.go: add blockrun-base, blockrun-sol to configureMCPClient
2026-03-10 17:47:19 +08:00
tinkle-community
c5c5ed2a4d merge: resolve conflict in api/server.go (dev → openclaw)
Keep s.route() wrapper style from openclaw, add reset-password route from dev.
2026-03-10 17:04:46 +08:00
tinkle-community
fcb90b77ae Merge branch 'dev' into openclaw 2026-03-10 00:19:42 +08:00
tinkle-community
3ed0aec0ff refactor: single-user web-based setup — replace env config with Settings UI
Move from multi-user env-var config to single-user web-first architecture:
- Add SetupPage for first-time initialization (replaces /register)
- Add SettingsPage for AI models, exchanges, Telegram, and password management
- Enrich all API route schemas with exact ID usage documentation
- Add PUT /user/password endpoint for in-app password changes
- Remove REGISTRATION_ENABLED, MAX_USERS, TELEGRAM_BOT_TOKEN from env config
- Simplify LoginPage design, remove admin mode and registration links
- Telegram bot now resolves user email for identity display
- start.sh no longer runs interactive Telegram setup
2026-03-09 23:55:39 +08:00
tinkle-community
9a3017af6d refactor(telegram): clean onboarding — web UI for setup, Telegram for operations
- /start shows clean status: 'setup required → open web UI' or 'ready → examples'
- Removed tryHandleSetupCommand (no more CLI-style 'configure deepseek sk-xxx')
- Removed automatic language selection on /start (use /lang anytime instead)
- newLLMClient returns nil when no model → clear guard, not fallback
- statusMsg() replaces buildSetupGuide(): two states only (missing config / ready)
- Bot is now purely an operations interface; config lives in the web UI
2026-03-08 20:16:58 +08:00
tinkle-community
aebca4b16c fix(telegram): remove 'default' user fallback — resolve user dynamically
- botUserID no longer captured once at startup (was 'default' if no user yet)
- resolveBotUser() reads first registered user from DB on demand:
  * called on every /start (handles: registered after bot launch)
  * called before every AI message (handles mid-session registration)
- If no user registered: clear English error 'No account found. Please register on the web UI first'
- start.sh: fix set_env_var appending without newline (token was concatenated to prev line)
2026-03-08 19:45:07 +08:00
tinkle-community
767d8629a3 fix(start.sh): translate all user-facing text to English
Entire script was in Chinese. Now English-first throughout:
- startup banner, prompts, success/error messages
- setup_telegram(): English instructions and validation messages
- start(): English next-steps after launch
- stop/restart/clean/update/regenerate-keys/show_help: all English
2026-03-08 19:20:36 +08:00
tinkle-community
ff1ca4460d fix(telegram): use Markdown rendering + simplify language selection condition
- sendMarkdownMsg() helper: sends with ParseMode=Markdown, falls back to plain text
- All formatted messages (langSelectionMsg, buildSetupGuide, helpMessage) now render
  bold text and code blocks correctly in Telegram
- Simplify /start language check: isLangDefault(st) alone is sufficient
  (lang == 'en' && isLangDefault was redundant — GetLanguage returns 'en' when empty)
2026-03-08 18:56:05 +08:00
tinkle-community
d160301359 feat(i18n): bilingual EN/ZH setup guide with language selection
store/telegram_config.go:
- Add Language field to TelegramConfig (persisted in DB)
- Add SetLanguage(lang) and GetLanguage() methods
- Default language: English (en)

telegram/bot.go:
- First /start triggers language selection (1=English, 2=中文)
- /lang command to change language at any time
- awaitingLang state machine handles language choice before any other input
- buildSetupGuide() now fully bilingual (EN/ZH), context-aware:
  Step 1: configure AI model (no model yet)
  Step 2: configure exchange (model OK, no exchange)
  Ready: show full capabilities
- tryHandleSetupCommand() bilingual: 'configure/配置 <provider> <key>'
- helpMessage(lang) fully bilingual
- All error/status messages bilingual

Default: English. isLangDefault() detects whether user has explicitly
chosen a language vs falling back to the 'en' default.
2026-03-08 18:44:38 +08:00
tinkle-community
1bbd4b44ac feat(solo): beginner-friendly onboarding — smart setup guide + direct config commands
start.sh:
- Interactive Telegram Bot Token prompt on first run
- Token format validation (must match 12345:ABC... pattern)
- Friendly step-by-step startup instructions after launch

telegram/bot.go:
- /start now shows context-aware setup guide based on actual config state:
  - No AI model → explains how to configure, lists all providers
  - AI model OK but no exchange → guides to configure exchange via chat
  - All configured → full capabilities welcome message
- New: direct setup commands ('配置 deepseek sk-xxx') bypass LLM entirely
  so AI model can be configured even before any model exists (bootstrap fix)
- All messages now in Chinese (匹配用户语言)

telegram/agent/prompt.go:
- Added first-time setup detection section
- Agent told to never ask user to visit web UI — everything via chat
2026-03-08 18:40:51 +08:00
tinkle-community
b2ce123df1 fix(auth): single-user deployment by default, no open registration
Registration logic redesigned:
- Empty DB (first-time setup): registration always open, no config needed
- After first user exists: registration closed by default
- Multi-user opt-in: set REGISTRATION_ENABLED=true + MAX_USERS=N in .env

Config defaults changed:
- RegistrationEnabled: true → false (closed after first user)
- MaxUsers: 10 → 1 (single-user deployment default)

This eliminates the confusion of multiple users appearing in a personal
deployment where Telegram is bound to a single admin account.
2026-03-08 18:32:50 +08:00
tinkle-community
97f309c9b5 fix(telegram): newLLMClient uses bound user's model, not any user's model
GetAnyEnabled() searched across all users in DB — if user B has an
enabled model, bot could use their API key while acting as user A.

Now uses GetDefault(botUserID) which only looks up the bound user's
enabled model, matching the same user scope as all API calls.
2026-03-08 18:31:05 +08:00
tinkle-community
13d70d2598 fix(telegram): clientForProvider returns correct client for all 7 providers
Previously qwen/kimi/grok/gemini all fell back to DeepSeekClient.
Each provider now gets its own dedicated client with correct default
base URL and model. All 7 providers now fully supported:
openai, deepseek, claude, qwen, kimi, grok, gemini
2026-03-08 17:54:47 +08:00
tinkle-community
138bbb1242 test(mcp): add ClaudeClient wire format tests
Tests cover all Anthropic-specific format conversions:
- system prompt lifted to top-level field
- tools use input_schema (not parameters)
- tool_choice is object {type:auto} not string
- assistant tool calls → content[{type:tool_use}]
- consecutive tool results merged into single user turn
- parseMCPResponseFull: text, tool_use, and error cases
- x-api-key header (not Authorization: Bearer)
- /messages endpoint URL
2026-03-08 17:44:50 +08:00
tinkle-community
ca87dbe3bb fix(telegram): fix claude client dispatch + strategy creation workflow
- telegram/bot.go: clientForProvider now returns NewClaudeClient() for
  'claude' provider (was incorrectly falling back to DeepSeekClient which
  uses OpenAI wire format, breaking Anthropic API calls)

- api/server.go: fix scan_interval_minutes schema default (3, not 60);
  POST /api/strategies now clearly states config is OPTIONAL with complete
  working defaults; POST /api/traders removes redundant GET workflow note

- telegram/agent/prompt.go: simplify strategy creation — just POST {name}
  without config (backend applies full working defaults automatically);
  only include config when user requests custom settings
2026-03-08 17:39:14 +08:00
tinkle-community
ea7b450a7e refactor(mcp): route buildRequestBodyFromRequest through hooks + full Anthropic format
Problem: callWithRequest/Full/Stream all called client.buildRequestBodyFromRequest
directly (not via hooks), so ClaudeClient could never override it. This meant
tool calling sent OpenAI format to Anthropic (wrong field names, wrong roles).

Changes:

mcp/interface.go
- Add buildRequestBodyFromRequest(*Request) map[string]any to clientHooks
- Improve comments: document what each hook group does and why

mcp/client.go
- All three paths (callWithRequest, callWithRequestFull, CallWithRequestStream)
  now call client.hooks.buildRequestBodyFromRequest — ClaudeClient picks up

mcp/claude_client.go
- Full rewrite with format comparison table in package doc
- buildRequestBodyFromRequest: produces correct Anthropic wire format
    * system prompt → top-level "system" field
    * tools: parameters → input_schema, no "type:function" wrapper
    * tool_choice "auto" → {"type":"auto"} object
    * assistant tool calls → content[{type:tool_use, id, name, input}]
    * role=tool results → role=user content[{type:tool_result,...}]
    * consecutive tool results merged into single user turn
- convertMessagesToAnthropic: handles all three message types
- parseMCPResponseFull: extracts text + tool_use blocks
- parseMCPResponse: delegates to parseMCPResponseFull

All mcp and agent tests pass.
2026-03-08 17:29:21 +08:00
tinkle-community
9fcf44af65 refactor(agent): replace XML api_call with native function calling
Migrate the Telegram bot agent from an XML tag hack (<api_call>) to
OpenAI-native function calling via CallWithRequestFull.

Key changes:
- mcp/interface.go: add parseMCPResponseFull to clientHooks interface
- mcp/client.go: route callWithRequestFull through hooks for overridability
- mcp/claude_client.go: override parseMCPResponseFull for Claude response
  format (tool_use blocks instead of choices[].message.tool_calls)
- telegram/agent/agent.go: rewrite Run() to use CallWithRequestFull;
  define api_request tool with JSON Schema; implement tool-call loop
  with role="tool" result messages; remove XML parsing entirely
- telegram/agent/apicall.go: remove parseAPICall (dead code)
- telegram/agent/prompt.go: simplify — remove XML format instructions,
  replace with concise api_request tool usage instructions
- telegram/agent/agent_test.go: rebuild all tests using LLMResponse
  objects; add TestNarrationStructurallyImpossible, TestOnChunkCalledWithFinalReply,
  TestToolCallIDPropagated; remove XML-specific tests

Architecture advantage: with native function calling, the LLM returns
EITHER ToolCalls OR Content — never both. Narration is now structurally
impossible at the protocol level, not just enforced by prompt rules.

All 11 agent tests pass. mcp package tests pass.
2026-03-08 17:10:07 +08:00
tinkle-community
5f47dd13db fix(telegram): eliminate narration, add full-setup workflow and tests
- Rewrite NO NARRATION rule: response is EITHER api_call tag alone OR
  final text reply — no text before api_call under any circumstances
- Ban all narration patterns: 现在我将/好的/正在/I will/Let me etc.
- Add 'create strategy + create trader + start' full setup workflow
- Add 12 automated tests covering:
  - No narration leaking to user (5 narration variants tested)
  - api_call tag never leaks to user
  - Full setup workflow: POST strategy → verify → POST trader → start
  - Start existing trader workflow
  - Max iterations safety, tag stripping, parser edge cases
2026-03-08 16:44:45 +08:00
tinkle-community
b354eb8bf2 Merge branch 'dev' into openclaw
# Conflicts:
#	web/src/components/TraderConfigModal.tsx
#	web/src/i18n/translations.ts
2026-03-08 00:40:15 +08:00
tinkle-community
3168a18c0d feat(telegram): add AI agent bot with streaming and account context
- Add Telegram bot with long-polling and AI agent loop (api_call tool)
- SSE streaming with real-time message editing and  placeholder
- Account state injection at conversation start (models, exchanges,
  strategies, traders, per-trader PnL and statistics)
- Lane semaphore per chat serializes concurrent messages (60s timeout)
- Idle timeout watchdog (60s) prevents hung streaming connections
- Look-ahead buffer prevents partial <api_call> tag leaking to user
- Fix PUT /strategies/:id to merge config (read-then-merge pattern)
- Add route registry with full API schema for LLM documentation
- Add TelegramConfig store and Web UI config modal
- Add GetAnyEnabled to AIModel store for bot LLM client selection
2026-03-08 00:19:38 +08:00
462 changed files with 58087 additions and 82527 deletions

View File

@@ -61,6 +61,6 @@ DB_NAME=nofx
DB_SSLMODE=disable
# Database configuration - SQLite (default)
# 数据库配置 - SQLite默认
DB_TYPE=sqlite
DB_PATH=data/data.db

View File

@@ -0,0 +1,331 @@
name: PR Checks (Advisory)
on:
pull_request:
types: [opened, synchronize, reopened]
branches: [main, dev]
# These checks are advisory only - they won't block PR merging
# Results will be posted as comments to help contributors improve their PRs
permissions:
contents: write
pull-requests: write
checks: write
issues: write
jobs:
pr-info:
name: PR Information
runs-on: ubuntu-latest
steps:
- name: Check PR title format
id: check-title
run: |
PR_TITLE="${{ github.event.pull_request.title }}"
# Check if title follows conventional commits
if echo "$PR_TITLE" | grep -qE "^(feat|fix|docs|style|refactor|perf|test|chore|ci|security)(\(.+\))?: .+"; then
echo "status=✅ Good" >> $GITHUB_OUTPUT
echo "message=PR title follows Conventional Commits format" >> $GITHUB_OUTPUT
else
echo "status=⚠️ Suggestion" >> $GITHUB_OUTPUT
echo "message=Consider using Conventional Commits format: type(scope): description" >> $GITHUB_OUTPUT
fi
- name: Calculate PR size
id: pr-size
run: |
ADDITIONS=${{ github.event.pull_request.additions }}
DELETIONS=${{ github.event.pull_request.deletions }}
TOTAL=$((ADDITIONS + DELETIONS))
if [ $TOTAL -lt 100 ]; then
echo "size=🟢 Small" >> $GITHUB_OUTPUT
echo "label=size: small" >> $GITHUB_OUTPUT
elif [ $TOTAL -lt 500 ]; then
echo "size=🟡 Medium" >> $GITHUB_OUTPUT
echo "label=size: medium" >> $GITHUB_OUTPUT
else
echo "size=🔴 Large" >> $GITHUB_OUTPUT
echo "label=size: large" >> $GITHUB_OUTPUT
echo "suggestion=Consider breaking this into smaller PRs for easier review" >> $GITHUB_OUTPUT
fi
echo "lines=$TOTAL" >> $GITHUB_OUTPUT
- name: Post advisory comment
uses: actions/github-script@v7
with:
script: |
const titleStatus = '${{ steps.check-title.outputs.status }}';
const titleMessage = '${{ steps.check-title.outputs.message }}';
const prSize = '${{ steps.pr-size.outputs.size }}';
const prLines = '${{ steps.pr-size.outputs.lines }}';
const sizeSuggestion = '${{ steps.pr-size.outputs.suggestion }}' || '';
let comment = '## 🤖 PR Advisory Feedback\n\n';
comment += 'Thank you for your contribution! Here\'s some automated feedback to help improve your PR:\n\n';
comment += '### PR Title\n';
comment += titleStatus + ' ' + titleMessage + '\n\n';
comment += '### PR Size\n';
comment += prSize + ' (' + prLines + ' lines changed)\n';
if (sizeSuggestion) {
comment += '\n💡 **Suggestion:** ' + sizeSuggestion + '\n';
}
comment += '\n---\n\n';
comment += '### 📖 New PR Management System\n\n';
comment += 'We\'re introducing a new PR management system! These checks are **advisory only** and won\'t block your PR.\n\n';
comment += '**Want to check your PR against new standards?**\n';
comment += '```bash\n';
comment += '# Run the PR health check tool\n';
comment += './scripts/pr-check.sh\n';
comment += '```\n\n';
comment += 'This tool will:\n';
comment += '- 🔍 Analyze your PR (doesn\'t modify anything)\n';
comment += '- ✅ Show what\'s already good\n';
comment += '- ⚠️ Point out issues\n';
comment += '- 💡 Give specific suggestions on how to fix\n\n';
comment += '**Learn more:**\n';
comment += '- [Migration Guide](https://github.com/NoFxAiOS/nofx/blob/dev/docs/community/MIGRATION_ANNOUNCEMENT.md)\n';
comment += '- [Contributing Guidelines](https://github.com/NoFxAiOS/nofx/blob/dev/CONTRIBUTING.md)\n\n';
comment += '**Questions?** Just ask in the comments! We\'re here to help. 🙏\n\n';
comment += '---\n\n';
comment += '*This is an automated message. It won\'t affect your PR being merged.*';
github.rest.issues.createComment({
issue_number: context.issue.number,
owner: context.repo.owner,
repo: context.repo.repo,
body: comment
});
backend-checks:
name: Backend Checks (Advisory)
runs-on: ubuntu-latest
continue-on-error: true
steps:
- uses: actions/checkout@v4
- name: Set up Go
uses: actions/setup-go@v5
with:
go-version: '1.21'
- name: Install dependencies
run: |
sudo apt-get update
sudo apt-get install -y libta-lib-dev || true
go mod download || true
- name: Check Go formatting
id: go-fmt
continue-on-error: true
run: |
UNFORMATTED=$(gofmt -l . 2>/dev/null || echo "")
if [ -n "$UNFORMATTED" ]; then
echo "status=⚠️ Needs formatting" >> $GITHUB_OUTPUT
echo "files<<EOF" >> $GITHUB_OUTPUT
echo "$UNFORMATTED" | head -10 >> $GITHUB_OUTPUT
echo "EOF" >> $GITHUB_OUTPUT
else
echo "status=✅ Good" >> $GITHUB_OUTPUT
echo "files=" >> $GITHUB_OUTPUT
fi
- name: Run go vet
id: go-vet
continue-on-error: true
run: |
if go vet ./... 2>&1 | tee vet-output.txt; then
echo "status=✅ Good" >> $GITHUB_OUTPUT
echo "output=" >> $GITHUB_OUTPUT
else
echo "status=⚠️ Issues found" >> $GITHUB_OUTPUT
echo "output<<EOF" >> $GITHUB_OUTPUT
cat vet-output.txt | head -20 >> $GITHUB_OUTPUT
echo "EOF" >> $GITHUB_OUTPUT
fi
- name: Run tests
id: go-test
continue-on-error: true
run: |
if go test ./... -v 2>&1 | tee test-output.txt; then
echo "status=✅ Passed" >> $GITHUB_OUTPUT
echo "output=" >> $GITHUB_OUTPUT
else
echo "status=⚠️ Failed" >> $GITHUB_OUTPUT
echo "output<<EOF" >> $GITHUB_OUTPUT
cat test-output.txt | tail -30 >> $GITHUB_OUTPUT
echo "EOF" >> $GITHUB_OUTPUT
fi
- name: Post backend feedback
if: always()
uses: actions/github-script@v7
with:
script: |
const fmtStatus = '${{ steps.go-fmt.outputs.status }}' || '⚠️ Skipped';
const vetStatus = '${{ steps.go-vet.outputs.status }}' || '⚠️ Skipped';
const testStatus = '${{ steps.go-test.outputs.status }}' || '⚠️ Skipped';
const fmtFiles = `${{ steps.go-fmt.outputs.files }}`;
const vetOutput = `${{ steps.go-vet.outputs.output }}`;
const testOutput = `${{ steps.go-test.outputs.output }}`;
let comment = '## 🔧 Backend Checks (Advisory)\n\n';
comment += '### Go Formatting\n';
comment += fmtStatus + '\n';
if (fmtFiles) {
comment += '\nFiles needing formatting:\n```\n' + fmtFiles + '\n```\n';
}
comment += '\n### Go Vet\n';
comment += vetStatus + '\n';
if (vetOutput) {
comment += '\n```\n' + vetOutput.substring(0, 500) + '\n```\n';
}
comment += '\n### Tests\n';
comment += testStatus + '\n';
if (testOutput) {
comment += '\n```\n' + testOutput.substring(0, 1000) + '\n```\n';
}
comment += '\n---\n\n';
comment += '💡 **To fix locally:**\n';
comment += '```bash\n';
comment += '# Format code\n';
comment += 'go fmt ./...\n\n';
comment += '# Check for issues\n';
comment += 'go vet ./...\n\n';
comment += '# Run tests\n';
comment += 'go test ./...\n';
comment += '```\n\n';
comment += '*These checks are advisory and won\'t block merging. Need help? Just ask!*';
github.rest.issues.createComment({
issue_number: context.issue.number,
owner: context.repo.owner,
repo: context.repo.repo,
body: comment
});
frontend-checks:
name: Frontend Checks (Advisory)
runs-on: ubuntu-latest
continue-on-error: true
steps:
- uses: actions/checkout@v4
- name: Set up Node.js
uses: actions/setup-node@v4
with:
node-version: '18'
- name: Check if web directory exists
id: check-web
run: |
if [ -d "web" ]; then
echo "exists=true" >> $GITHUB_OUTPUT
else
echo "exists=false" >> $GITHUB_OUTPUT
fi
- name: Install dependencies
if: steps.check-web.outputs.exists == 'true'
working-directory: ./web
continue-on-error: true
run: npm ci
- name: Run linter
if: steps.check-web.outputs.exists == 'true'
id: lint
working-directory: ./web
continue-on-error: true
run: |
if npm run lint 2>&1 | tee lint-output.txt; then
echo "status=✅ Good" >> $GITHUB_OUTPUT
echo "output=" >> $GITHUB_OUTPUT
else
echo "status=⚠️ Issues found" >> $GITHUB_OUTPUT
echo "output<<EOF" >> $GITHUB_OUTPUT
cat lint-output.txt | head -20 >> $GITHUB_OUTPUT
echo "EOF" >> $GITHUB_OUTPUT
fi
- name: Type check
if: steps.check-web.outputs.exists == 'true'
id: typecheck
working-directory: ./web
continue-on-error: true
run: |
if npm run type-check 2>&1 | tee typecheck-output.txt; then
echo "status=✅ Good" >> $GITHUB_OUTPUT
echo "output=" >> $GITHUB_OUTPUT
else
echo "status=⚠️ Issues found" >> $GITHUB_OUTPUT
echo "output<<EOF" >> $GITHUB_OUTPUT
cat typecheck-output.txt | head -20 >> $GITHUB_OUTPUT
echo "EOF" >> $GITHUB_OUTPUT
fi
- name: Build
if: steps.check-web.outputs.exists == 'true'
id: build
working-directory: ./web
continue-on-error: true
run: |
if npm run build 2>&1 | tee build-output.txt; then
echo "status=✅ Success" >> $GITHUB_OUTPUT
echo "output=" >> $GITHUB_OUTPUT
else
echo "status=⚠️ Failed" >> $GITHUB_OUTPUT
echo "output<<EOF" >> $GITHUB_OUTPUT
cat build-output.txt | tail -20 >> $GITHUB_OUTPUT
echo "EOF" >> $GITHUB_OUTPUT
fi
- name: Post frontend feedback
if: always() && steps.check-web.outputs.exists == 'true'
uses: actions/github-script@v7
with:
script: |
const lintStatus = '${{ steps.lint.outputs.status }}' || '⚠️ Skipped';
const typecheckStatus = '${{ steps.typecheck.outputs.status }}' || '⚠️ Skipped';
const buildStatus = '${{ steps.build.outputs.status }}' || '⚠️ Skipped';
const lintOutput = `${{ steps.lint.outputs.output }}`;
const typecheckOutput = `${{ steps.typecheck.outputs.output }}`;
const buildOutput = `${{ steps.build.outputs.output }}`;
let comment = '## ⚛️ Frontend Checks (Advisory)\n\n';
comment += '### Linting\n';
comment += lintStatus + '\n';
if (lintOutput) {
comment += '\n```\n' + lintOutput.substring(0, 500) + '\n```\n';
}
comment += '\n### Type Checking\n';
comment += typecheckStatus + '\n';
if (typecheckOutput) {
comment += '\n```\n' + typecheckOutput.substring(0, 500) + '\n```\n';
}
comment += '\n### Build\n';
comment += buildStatus + '\n';
if (buildOutput) {
comment += '\n```\n' + buildOutput.substring(0, 500) + '\n```\n';
}
comment += '\n---\n\n';
comment += '💡 **To fix locally:**\n';
comment += '```bash\n';
comment += 'cd web\n\n';
comment += '# Fix linting issues\n';
comment += 'npm run lint -- --fix\n\n';
comment += '# Check types\n';
comment += 'npm run type-check\n\n';
comment += '# Test build\n';
comment += 'npm run build\n';
comment += '```\n\n';
comment += '*These checks are advisory and won\'t block merging. Need help? Just ask!*';
github.rest.issues.createComment({
issue_number: context.issue.number,
owner: context.repo.owner,
repo: context.repo.repo,
body: comment
});

View File

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

View File

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

View File

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

15
.gitignore vendored
View File

@@ -27,8 +27,6 @@ Thumbs.db
*.tmp
*.bak
*.backup
.cache/
.gh-config/
# 环境变量
.env
@@ -44,7 +42,6 @@ decision_logs/
nofx_test
# Node.js
web/node_modules
web/node_modules/
node_modules/
web/dist/
@@ -53,9 +50,6 @@ web/.vite/
# ESLint 临时报告文件(调试时生成,不纳入版本控制)
eslint-*.json
# 本地 Agent QA seed个人调试用不纳入版本控制
docs/qa/fixtures/agent_self_play_seed.zh-CN.json
# VS code
.vscode
@@ -85,6 +79,7 @@ dist/
downloads/
eggs/
.eggs/
lib/
lib64/
parts/
sdist/
@@ -130,11 +125,3 @@ dmypy.json
# Pyre type checker
.pyre/
PR_DESCRIPTION.md
# Go build artifacts
/nofx-server
.gstack/
# Local AI agent / skill scaffolding (not part of the runtime app)
.agents/
skills-lock.json

View File

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

1377
README.ja.md Normal file

File diff suppressed because it is too large Load Diff

628
README.md
View File

@@ -1,10 +1,7 @@
<p align="center"><strong>Backed by <a href="https://vergex.trade">vergex.trade</a></strong></p>
<h1 align="center">NOFX</h1>
<h1 align="center">NOFX — Open Source AI Trading OS</h1>
<p align="center">
<strong>AI trading terminal for global markets.</strong><br/>
<strong>Research, strategy generation, execution, and monitoring for US stocks, commodities, forex, and crypto.</strong>
<strong>The infrastructure layer for AI-powered financial trading.</strong>
</p>
<p align="center">
@@ -17,313 +14,510 @@
<p align="center">
<a href="https://golang.org/"><img src="https://img.shields.io/badge/Go-1.21+-00ADD8?style=flat&logo=go" alt="Go"></a>
<a href="https://reactjs.org/"><img src="https://img.shields.io/badge/React-18+-61DAFB?style=flat&logo=react" alt="React"></a>
<a href="https://www.typescriptlang.org/"><img src="https://img.shields.io/badge/TypeScript-5.0+-3178C6?style=flat&logo=typescript" alt="TypeScript"></a>
</p>
<p align="center">
<a href="README.md">English</a> ·
<a href="docs/i18n/zh-CN/README.md">中文</a> ·
<a href="docs/i18n/ja/README.md">日本語</a> ·
<a href="docs/i18n/ko/README.md">한국어</a> ·
<a href="docs/i18n/ru/README.md">Русский</a> ·
<a href="docs/i18n/uk/README.md">Українська</a> ·
<a href="docs/i18n/vi/README.md">Tiếng Việt</a>
</p>
| CONTRIBUTOR AIRDROP PROGRAM |
|:----------------------------------:|
| Code · Bug Fixes · Issues → Airdrop |
| [Learn More](#contributor-airdrop-program) |
**Languages:** [English](README.md) | [中文](docs/i18n/zh-CN/README.md) | [日本語](docs/i18n/ja/README.md) | [한국어](docs/i18n/ko/README.md) | [Русский](docs/i18n/ru/README.md) | [Українська](docs/i18n/uk/README.md) | [Tiếng Việt](docs/i18n/vi/README.md)
---
NOFX is an open-source AI trading terminal for active traders who want one workspace for market research, strategy development, execution, and portfolio monitoring.
### Supported Markets
The product is built around global liquid markets: US equities, commodity contracts, FX pairs, and digital assets. The AI layer helps translate market intent into watchlists, signals, strategy logic, risk controls, and execution workflows.
| Market | Trading | Status |
|--------|---------|--------|
| 🪙 **Crypto** | BTC, ETH, Altcoins | ✅ Supported |
| 📈 **US Stocks** | AAPL, TSLA, NVDA, etc. | ✅ Supported |
| 💱 **Forex** | EUR/USD, GBP/USD, etc. | ✅ Supported |
| 🥇 **Metals** | Gold, Silver | ✅ Supported |
```bash
curl -fsSL https://raw.githubusercontent.com/NoFxAiOS/nofx/main/install.sh | bash
```
### Core Features
Open **http://127.0.0.1:3000**.
- **Multi-AI Support**: Run DeepSeek, Qwen, GPT, Claude, Gemini, Grok, Kimi - switch models anytime
- **Multi-Exchange**: Trade on Binance, Bybit, OKX, Bitget, KuCoin, Gate, Hyperliquid, Aster DEX, Lighter from one platform
- **Strategy Studio**: Visual strategy builder with coin sources, indicators, and risk controls
- **AI Debate Arena**: Multiple AI models debate trading decisions with different roles (Bull, Bear, Analyst)
- **AI Competition Mode**: Multiple AI traders compete in real-time, track performance side by side
- **Web-Based Config**: No JSON editing - configure everything through the web interface
- **Real-Time Dashboard**: Live positions, P/L tracking, AI decision logs with Chain of Thought
### Core Team
- **Tinkle** - [@Web3Tinkle](https://x.com/Web3Tinkle)
- **Official Twitter** - [@nofx_official](https://x.com/nofx_official)
### Official Links
- **Official Website**: [https://nofxai.com](https://nofxai.com)
- **Data Dashboard**: [https://nofxos.ai/dashboard](https://nofxos.ai/dashboard)
- **API Documentation**: [https://nofxos.ai/api-docs](https://nofxos.ai/api-docs)
> **Risk Warning**: This system is experimental. AI auto-trading carries significant risks. Strongly recommended for learning/research purposes or testing with small amounts only!
## Developer Community
Join our Telegram developer community: **[NOFX Developer Community](https://t.me/nofx_dev_community)**
---
## Register exchanges
## Before You Begin
Use the links below to open trading accounts for crypto and supported US stock, FX, and commodity derivative markets. These routes are part of NOFX partner programs and may include fee discounts or referral benefits.
To use NOFX, you'll need:
| Exchange | Status | Register with fee discount |
| :---------------------------------------------------------------------------------------------------------------------------- | :----: | :---------------------------------------------------------------------------------- |
| <img src="web/public/exchange-icons/binance.jpg" width="20" height="20" style="vertical-align: middle;"/> **Binance** | ✅ | [Register](https://www.binance.com/join?ref=NOFXENG) |
| <img src="web/public/exchange-icons/bybit.png" width="20" height="20" style="vertical-align: middle;"/> **Bybit** | ✅ | [Register](https://partner.bybit.com/b/83856) |
| <img src="web/public/exchange-icons/okx.svg" width="20" height="20" style="vertical-align: middle;"/> **OKX** | ✅ | [Register](https://www.okx.com/join/1865360) |
| <img src="web/public/exchange-icons/hyperliquid.png" width="20" height="20" style="vertical-align: middle;"/> **Hyperliquid** | ✅ | [Register](https://app.hyperliquid.xyz/join/AITRADING) |
| <img src="web/public/exchange-icons/bitget.svg" width="20" height="20" style="vertical-align: middle;"/> **Bitget** | ✅ | [Register](https://www.bitget.com/referral/register?from=referral&clacCode=c8a43172) |
| <img src="web/public/exchange-icons/kucoin.svg" width="20" height="20" style="vertical-align: middle;"/> **KuCoin** | ✅ | [Register](https://www.kucoin.com/r/broker/CXEV7XKK) |
| <img src="web/public/exchange-icons/gate.svg" width="20" height="20" style="vertical-align: middle;"/> **Gate** | ✅ | [Register](https://www.gatenode.xyz/share/VQBGUAxY) |
| <img src="web/public/exchange-icons/aster.svg" width="20" height="20" style="vertical-align: middle;"/> **Aster** | ✅ | [Register](https://www.asterdex.com/en/referral/fdfc0e) |
| <img src="web/public/exchange-icons/lighter.png" width="20" height="20" style="vertical-align: middle;"/> **Lighter** | ✅ | [Register](https://app.lighter.xyz/?referral=68151432) |
1. **Exchange Account** - Register on any supported exchange and create API credentials with trading permissions
2. **AI Model API Key** - Get from any supported provider (DeepSeek recommended for cost-effectiveness)
---
## Quick demo
## Supported Exchanges
<p align="center">
<a href="https://drive.google.com/file/d/1frzw-HDZ3viQvLOQKsAJGc9bT0dXs68D/view">
<img src="screenshots/demo-cover.png" alt="NOFX quick demo video" width="900"/>
</a>
</p>
### CEX (Centralized Exchanges)
<p align="center">
Click the cover image to watch the demo video.
</p>
| Exchange | Status | Register (Fee Discount) |
|:---------|:------:|:------------------------|
| <img src="web/public/exchange-icons/binance.jpg" width="20" height="20" style="vertical-align: middle;"/> **Binance** | ✅ | [Register](https://www.binance.com/join?ref=NOFXENG) |
| <img src="web/public/exchange-icons/bybit.png" width="20" height="20" style="vertical-align: middle;"/> **Bybit** | ✅ | [Register](https://partner.bybit.com/b/83856) |
| <img src="web/public/exchange-icons/okx.svg" width="20" height="20" style="vertical-align: middle;"/> **OKX** | ✅ | [Register](https://www.okx.com/join/1865360) |
| <img src="web/public/exchange-icons/bitget.svg" width="20" height="20" style="vertical-align: middle;"/> **Bitget** | ✅ | [Register](https://www.bitget.com/referral/register?from=referral&clacCode=c8a43172) |
| <img src="web/public/exchange-icons/kucoin.svg" width="20" height="20" style="vertical-align: middle;"/> **KuCoin** | ✅ | [Register](https://www.kucoin.com/r/broker/CXEV7XKK) |
| <img src="web/public/exchange-icons/gate.svg" width="20" height="20" style="vertical-align: middle;"/> **Gate** | ✅ | [Register](https://www.gatenode.xyz/share/VQBGUAxY) |
### Perp-DEX (Decentralized Perpetual Exchanges)
| Exchange | Status | Register (Fee Discount) |
|:---------|:------:|:------------------------|
| <img src="web/public/exchange-icons/hyperliquid.png" width="20" height="20" style="vertical-align: middle;"/> **Hyperliquid** | ✅ | [Register](https://app.hyperliquid.xyz/join/AITRADING) |
| <img src="web/public/exchange-icons/aster.svg" width="20" height="20" style="vertical-align: middle;"/> **Aster DEX** | ✅ | [Register](https://www.asterdex.com/en/referral/fdfc0e) |
| <img src="web/public/exchange-icons/lighter.png" width="20" height="20" style="vertical-align: middle;"/> **Lighter** | ✅ | [Register](https://app.lighter.xyz/?referral=68151432) |
---
## Markets
## Supported AI Models
**US Stocks · Commodities · Forex · Crypto**
NOFX organizes research, strategy construction, execution, and monitoring around multi-asset workflows instead of single-venue screens.
---
## AI model access
NOFX routes AI inference through [Claw402](https://claw402.ai) automatically. Users do not need to configure model providers, manage API keys, or maintain separate AI accounts. The terminal accesses supported models on demand through Claw402's pay-as-you-go infrastructure, with traffic routed through the official discounted channel.
| Provider | Access |
| :------- | :----- |
| **Claw402** | [Access pay-as-you-go AI models with official discount](https://claw402.ai) |
---
## Capabilities
| Capability | Description |
| :-------------------------- | :-------------------------------------------------------------------------- |
| **AI trading terminal** | Unified workspace for US stocks, commodities, forex, and crypto workflows |
| **AI model access** | Unified model access through Claw402-supported providers |
| **Exchange connectivity** | Binance, Bybit, OKX, Hyperliquid, Bitget, KuCoin, Gate, Aster, and Lighter |
| **Strategy Studio** | Market universes, indicators, risk controls, and strategy logic |
| **Model competition** | Compare model-driven traders with live performance and leaderboard tracking |
| **Telegram agent** | Control and monitor the trading assistant through chat |
| **Portfolio dashboard** | Positions, P/L, execution history, and model decision logs |
| AI Model | Status | Get API Key |
|:---------|:------:|:------------|
| <img src="web/public/icons/deepseek.svg" width="20" height="20" style="vertical-align: middle;"/> **DeepSeek** | ✅ | [Get API Key](https://platform.deepseek.com) |
| <img src="web/public/icons/qwen.svg" width="20" height="20" style="vertical-align: middle;"/> **Qwen** | ✅ | [Get API Key](https://dashscope.console.aliyun.com) |
| <img src="web/public/icons/openai.svg" width="20" height="20" style="vertical-align: middle;"/> **OpenAI (GPT)** | ✅ | [Get API Key](https://platform.openai.com) |
| <img src="web/public/icons/claude.svg" width="20" height="20" style="vertical-align: middle;"/> **Claude** | ✅ | [Get API Key](https://console.anthropic.com) |
| <img src="web/public/icons/gemini.svg" width="20" height="20" style="vertical-align: middle;"/> **Gemini** | ✅ | [Get API Key](https://aistudio.google.com) |
| <img src="web/public/icons/grok.svg" width="20" height="20" style="vertical-align: middle;"/> **Grok** | ✅ | [Get API Key](https://console.x.ai) |
| <img src="web/public/icons/kimi.svg" width="20" height="20" style="vertical-align: middle;"/> **Kimi** | ✅ | [Get API Key](https://platform.moonshot.cn) |
---
## Screenshots
<details>
<summary><b>Config Page</b></summary>
### Config Page
| AI Models & Exchanges | Traders List |
|:---:|:---:|
| <img src="screenshots/config-ai-exchanges.png" width="400" alt="Config - AI Models & Exchanges"/> | <img src="screenshots/config-traders-list.png" width="400" alt="Config - Traders List"/> |
| Configuration | Traders List |
| :----------------------------------------------------------: | :----------------------------------------------------------: |
| <img src="screenshots/config-ai-exchanges.png" width="400"/> | <img src="screenshots/config-traders-list.png" width="400"/> |
### Competition & Backtest
| Competition Mode | Backtest Lab |
|:---:|:---:|
| <img src="screenshots/competition-page.png" width="400" alt="Competition Page"/> | <img src="screenshots/backtest-lab.png" width="400" alt="Backtest Lab"/> |
</details>
### Dashboard
| Overview | Market Chart |
|:---:|:---:|
| <img src="screenshots/dashboard-page.png" width="400" alt="Dashboard Overview"/> | <img src="screenshots/dashboard-market-chart.png" width="400" alt="Dashboard Market Chart"/> |
<details>
<summary><b>Dashboard</b></summary>
| Trading Stats | Position History |
|:---:|:---:|
| <img src="screenshots/dashboard-trading-stats.png" width="400" alt="Trading Stats"/> | <img src="screenshots/dashboard-position-history.png" width="400" alt="Position History"/> |
| Overview | Market Chart |
| :-----------------------------------------------------: | :-------------------------------------------------------------: |
| <img src="screenshots/dashboard-page.png" width="400"/> | <img src="screenshots/dashboard-market-chart.png" width="400"/> |
| Positions | Trader Details |
|:---:|:---:|
| <img src="screenshots/dashboard-positions.png" width="400" alt="Dashboard Positions"/> | <img src="screenshots/details-page.png" width="400" alt="Trader Details"/> |
| Trading Stats | Position History |
| :--------------------------------------------------------------: | :-----------------------------------------------------------------: |
| <img src="screenshots/dashboard-trading-stats.png" width="400"/> | <img src="screenshots/dashboard-position-history.png" width="400"/> |
### Strategy Studio
| Strategy Editor | Indicators Config |
|:---:|:---:|
| <img src="screenshots/strategy-studio.png" width="400" alt="Strategy Studio"/> | <img src="screenshots/strategy-indicators.png" width="400" alt="Strategy Indicators"/> |
| Positions | Trader Details |
| :----------------------------------------------------------: | :---------------------------------------------------: |
| <img src="screenshots/dashboard-positions.png" width="400"/> | <img src="screenshots/details-page.png" width="400"/> |
</details>
<details>
<summary><b>Strategy Studio</b></summary>
| Strategy Editor | Indicators Config |
| :------------------------------------------------------: | :----------------------------------------------------------: |
| <img src="screenshots/strategy-studio.png" width="400"/> | <img src="screenshots/strategy-indicators.png" width="400"/> |
</details>
<details>
<summary><b>Competition</b></summary>
| Competition Mode |
| :-------------------------------------------------------: |
| <img src="screenshots/competition-page.png" width="400"/> |
</details>
### Debate Arena
| AI Debate Session | Create Debate |
|:---:|:---:|
| <img src="screenshots/debate-arena.png" width="400" alt="Debate Arena"/> | <img src="screenshots/debate-create.png" width="400" alt="Create Debate"/> |
---
## Install
## Quick Start
### Linux / macOS
### One-Click Install (Local/Server)
**Linux / macOS:**
```bash
curl -fsSL https://raw.githubusercontent.com/NoFxAiOS/nofx/main/install.sh | bash
```
### Railway (Cloud)
That's it! Open **http://127.0.0.1:3000** in your browser.
### One-Click Cloud Deploy (Railway)
Deploy to Railway with one click - no server setup required:
[![Deploy on Railway](https://railway.com/button.svg)](https://railway.com/deploy/nofx?referralCode=nofx)
### Docker
After deployment, Railway will provide a public URL to access your NOFX instance.
### Docker Compose (Manual)
```bash
# Download and start
curl -O https://raw.githubusercontent.com/NoFxAiOS/nofx/main/docker-compose.prod.yml
docker compose -f docker-compose.prod.yml up -d
```
### Windows
Install [Docker Desktop](https://www.docker.com/products/docker-desktop/), then:
```powershell
curl -o docker-compose.prod.yml https://raw.githubusercontent.com/NoFxAiOS/nofx/main/docker-compose.prod.yml
docker compose -f docker-compose.prod.yml up -d
```
### From Source
Access Web Interface: **http://127.0.0.1:3000**
```bash
# Prerequisites: Go 1.21+, Node.js 18+, TA-Lib
# macOS: brew install ta-lib
# Ubuntu: sudo apt-get install libta-lib0-dev
git clone https://github.com/NoFxAiOS/nofx.git && cd nofx
go build -o nofx && ./nofx # backend
cd web && npm install && npm run dev # frontend (new terminal)
# Management commands
docker compose -f docker-compose.prod.yml logs -f # View logs
docker compose -f docker-compose.prod.yml restart # Restart
docker compose -f docker-compose.prod.yml down # Stop
docker compose -f docker-compose.prod.yml pull && docker compose -f docker-compose.prod.yml up -d # Update
```
### Update
### Keeping Updated
> **💡 Updates are frequent.** Run this command daily to stay current with the latest features and fixes:
```bash
curl -fsSL https://raw.githubusercontent.com/NoFxAiOS/nofx/main/install.sh | bash
```
---
This one-liner pulls the latest official images and restarts services automatically.
## Setup
### Manual Installation (For Developers)
**Beginner mode**: Guided onboarding walks new users through model selection, exchange connection, strategy setup, and first deployment.
#### Prerequisites
**Advanced mode**:
1. Configure AI model access
2. Connect exchange credentials
3. Build or import a strategy
4. Create an AI trader profile
5. Launch, monitor, and iterate from the dashboard
All configuration is available from the web UI at **http://127.0.0.1:3000**.
---
## Deploy to server
**HTTP deployment:**
- **Go 1.21+**
- **Node.js 18+**
- **TA-Lib** (technical indicator library)
```bash
# Install TA-Lib
# macOS
brew install ta-lib
# Ubuntu/Debian
sudo apt-get install libta-lib0-dev
```
#### Installation Steps
```bash
# 1. Clone the repository
git clone https://github.com/NoFxAiOS/nofx.git
cd nofx
# 2. Install backend dependencies
go mod download
# 3. Install frontend dependencies
cd web
npm install
cd ..
# 4. Build and start backend
go build -o nofx
./nofx
# 5. Start frontend (new terminal)
cd web
npm run dev
```
Access Web Interface: **http://127.0.0.1:3000**
---
## Windows Installation
### Method 1: Docker Desktop (Recommended)
1. **Install Docker Desktop**
- Download from [docker.com/products/docker-desktop](https://www.docker.com/products/docker-desktop/)
- Run the installer and restart your computer
- Start Docker Desktop and wait for it to be ready
2. **Run NOFX**
```powershell
# Open PowerShell and run:
curl -o docker-compose.prod.yml https://raw.githubusercontent.com/NoFxAiOS/nofx/main/docker-compose.prod.yml
docker compose -f docker-compose.prod.yml up -d
```
3. **Access**: Open **http://127.0.0.1:3000** in your browser
### Method 2: WSL2 (For Development)
1. **Install WSL2**
```powershell
# Open PowerShell as Administrator
wsl --install
```
Restart your computer after installation.
2. **Install Ubuntu from Microsoft Store**
- Open Microsoft Store
- Search "Ubuntu 22.04" and install
- Launch Ubuntu and set up username/password
3. **Install Dependencies in WSL2**
```bash
# Update system
sudo apt update && sudo apt upgrade -y
# Install Go
wget https://go.dev/dl/go1.21.5.linux-amd64.tar.gz
sudo tar -C /usr/local -xzf go1.21.5.linux-amd64.tar.gz
echo 'export PATH=$PATH:/usr/local/go/bin' >> ~/.bashrc
source ~/.bashrc
# Install Node.js
curl -fsSL https://deb.nodesource.com/setup_18.x | sudo -E bash -
sudo apt-get install -y nodejs
# Install TA-Lib
sudo apt-get install -y libta-lib0-dev
# Install Git
sudo apt-get install -y git
```
4. **Clone and Run NOFX**
```bash
git clone https://github.com/NoFxAiOS/nofx.git
cd nofx
# Build and run backend
go build -o nofx && ./nofx
# In another terminal, run frontend
cd web && npm install && npm run dev
```
5. **Access**: Open **http://127.0.0.1:3000** in Windows browser
### Method 3: Docker in WSL2 (Best of Both Worlds)
1. **Install Docker Desktop with WSL2 backend**
- During Docker Desktop installation, enable "Use WSL 2 based engine"
- In Docker Desktop Settings → Resources → WSL Integration, enable your Linux distro
2. **Run from WSL2 terminal**
```bash
curl -fsSL https://raw.githubusercontent.com/NoFxAiOS/nofx/main/install.sh | bash
```
---
## Server Deployment
### Quick Deploy (HTTP via IP)
By default, transport encryption is **disabled**, allowing you to access NOFX via IP address without HTTPS:
```bash
# Deploy to your server
curl -fsSL https://raw.githubusercontent.com/NoFxAiOS/nofx/main/install.sh | bash
# Access via http://YOUR_IP:3000
```
**HTTPS via Cloudflare:**
Access via `http://YOUR_SERVER_IP:3000` - works immediately.
1. Add domain to [Cloudflare](https://dash.cloudflare.com) (free plan)
2. A record → your server IP (Proxied)
3. SSL/TLS → Flexible
4. Set `TRANSPORT_ENCRYPTION=true` in `.env`
### Enhanced Security (HTTPS)
For enhanced security, enable transport encryption in `.env`:
```bash
TRANSPORT_ENCRYPTION=true
```
When enabled, browser uses Web Crypto API to encrypt API keys before transmission. This requires:
- `https://` - Any domain with SSL
- `http://localhost` - Local development
### Quick HTTPS Setup with Cloudflare
1. **Add your domain to Cloudflare** (free plan works)
- Go to [dash.cloudflare.com](https://dash.cloudflare.com)
- Add your domain and update nameservers
2. **Create DNS record**
- Type: `A`
- Name: `nofx` (or your subdomain)
- Content: Your server IP
- Proxy status: **Proxied** (orange cloud)
3. **Configure SSL/TLS**
- Go to SSL/TLS settings
- Set encryption mode to **Flexible**
```
User ──[HTTPS]──→ Cloudflare ──[HTTP]──→ Your Server:3000
```
4. **Enable transport encryption**
```bash
# Edit .env and set
TRANSPORT_ENCRYPTION=true
```
5. **Done!** Access via `https://nofx.yourdomain.com`
---
## Architecture
## Initial Setup (Web Interface)
```
NOFX
┌─────────────────────────────────────────────────┐
│ Trading Terminal │
│ React + TypeScript + TradingView │
│ US Stocks · Commodities · Forex · Crypto │
├─────────────────────────────────────────────────┤
│ API Server (Go) │
├──────────────┬──────────────┬───────────────────┤
│ Strategy │ Telegram │ Trader Runtime │
│ Engine │ Agent │ Risk Controls │
├──────────────┴──────────────┴───────────────────┤
│ AI Model Layer │
│ Unified provider access through Claw402 │
│ Model routing · payment · execution support │
├─────────────────────────────────────────────────┤
│ Exchange Connectivity │
│ Binance · Bybit · OKX · Hyperliquid · Bitget │
│ KuCoin · Gate · Aster · Lighter │
└─────────────────────────────────────────────────┘
```
After starting the system, configure through the web interface:
1. **Configure AI Models** - Add your AI API keys (DeepSeek, OpenAI, etc.)
2. **Configure Exchanges** - Set up exchange API credentials
3. **Create Strategy** - Configure trading strategy in Strategy Studio
4. **Create Trader** - Combine AI model + Exchange + Strategy
5. **Start Trading** - Launch your configured traders
All configuration is done through the web interface - no JSON file editing required.
---
## Docs
## Web Interface Features
| | |
| :------------------------------------------------------ | :------------------------------------ |
| [Architecture](docs/architecture/README.md) | System design and module index |
| [Strategy Module](docs/architecture/STRATEGY_MODULE.md) | Coin selection, AI prompts, execution |
| [FAQ](docs/faq/README.md) | Common questions |
| [Getting Started](docs/getting-started/README.md) | Deployment guide |
### Competition Page
- Real-time ROI leaderboard
- Multi-AI performance comparison charts
- Live P/L tracking and rankings
### Dashboard
- TradingView-style candlestick charts
- Real-time position management
- AI decision logs with Chain of Thought reasoning
- Equity curve tracking
### Strategy Studio
- Coin source configuration (Static list, AI500 pool, OI Top)
- Technical indicators (EMA, MACD, RSI, ATR, Volume, OI, Funding Rate)
- Risk control settings (leverage, position limits, margin usage)
- AI test with real-time prompt preview
### Debate Arena
- Multi-AI debate sessions for trading decisions
- Configurable AI roles (Bull, Bear, Analyst, Contrarian, Risk Manager)
- Multiple rounds of debate with consensus voting
- Auto-execute consensus trades
### Backtest Lab
- 3-step wizard configuration (Model → Parameters → Confirm)
- Real-time progress visualization with animated ring
- Equity curve chart with trade markers
- Trade timeline with card-style display
- Performance metrics (Return, Max DD, Sharpe, Win Rate)
- AI decision trail with Chain of Thought
---
## Common Issues
### TA-Lib not found
```bash
# macOS
brew install ta-lib
# Ubuntu
sudo apt-get install libta-lib0-dev
```
### AI API timeout
- Check if API key is correct
- Check network connection
- System timeout is 120 seconds
### Frontend can't connect to backend
- Ensure backend is running on http://localhost:8080
- Check if port is occupied
---
## Documentation
| Document | Description |
|----------|-------------|
| **[Architecture Overview](docs/architecture/README.md)** | System design and module index |
| **[Strategy Module](docs/architecture/STRATEGY_MODULE.md)** | Coin selection, data assembly, AI prompts, execution |
| **[Backtest Module](docs/architecture/BACKTEST_MODULE.md)** | Historical simulation, metrics, checkpoint/resume |
| **[Debate Module](docs/architecture/DEBATE_MODULE.md)** | Multi-AI debate, voting consensus, auto-execution |
| **[FAQ](docs/faq/README.md)** | Frequently asked questions |
| **[Getting Started](docs/getting-started/README.md)** | Deployment guide |
---
## License
This project is licensed under **GNU Affero General Public License v3.0 (AGPL-3.0)** - See [LICENSE](LICENSE) file.
---
## Contributing
See [Contributing Guide](CONTRIBUTING.md), [Code of Conduct](CODE_OF_CONDUCT.md), and [Security Policy](SECURITY.md).
### Contributor Airdrop Program
NOFX tracks meaningful contributions and intends to reward contributors as the ecosystem grows. Priority issues carry higher reward weight.
| Contribution | Weight |
| :---------------- | :----: |
| Pinned Issue PRs | ★★★★★★ |
| Code (Merged PRs) | ★★★★★ |
| Bug Fixes | ★★★★ |
| Feature Ideas | ★★★ |
| Bug Reports | ★★ |
| Documentation | ★★ |
We welcome contributions! See:
- **[Contributing Guide](CONTRIBUTING.md)** - Development workflow and PR process
- **[Code of Conduct](CODE_OF_CONDUCT.md)** - Community guidelines
- **[Security Policy](SECURITY.md)** - Report vulnerabilities
---
## Links
## Contributor Airdrop Program
| | |
| :-------- | :---------------------------------------------------- |
| Website | [vergex.trade](https://vergex.trade) |
| Dashboard | [vergex.trade/explore](https://vergex.trade/explore) |
| Telegram | [nofx_dev_community](https://t.me/nofx_dev_community) |
| Twitter | [@vergex_ai](https://x.com/vergex_ai) |
All contributions are tracked on GitHub. When NOFX generates revenue, contributors will receive airdrops based on their contributions.
> **Risk warning**: Automated trading involves substantial risk. Use appropriate position sizing, understand each exchange venue, and do not trade funds you cannot afford to lose.
**PRs that resolve [Pinned Issues](https://github.com/NoFxAiOS/nofx/issues) receive the HIGHEST rewards!**
| Contribution Type | Weight |
|------------------|:------:|
| **Pinned Issue PRs** | ⭐⭐⭐⭐⭐⭐ |
| **Code Commits** (Merged PRs) | ⭐⭐⭐⭐⭐ |
| **Bug Fixes** | ⭐⭐⭐⭐ |
| **Feature Suggestions** | ⭐⭐⭐ |
| **Bug Reports** | ⭐⭐ |
| **Documentation** | ⭐⭐ |
---
## Contact
- **GitHub Issues**: [Submit an Issue](https://github.com/NoFxAiOS/nofx/issues)
- **Developer Community**: [Telegram Group](https://t.me/nofx_dev_community)
---
## Sponsors
<a href="https://github.com/pjl914335852-ux"><img src="https://github.com/pjl914335852-ux.png" width="50" height="50" style="border-radius:50%"/></a>
<a href="https://github.com/cat9999aaa"><img src="https://github.com/cat9999aaa.png" width="50" height="50" style="border-radius:50%"/></a>
<a href="https://github.com/1733055465"><img src="https://github.com/1733055465.png" width="50" height="50" style="border-radius:50%"/></a>
<a href="https://github.com/kolal2020"><img src="https://github.com/kolal2020.png" width="50" height="50" style="border-radius:50%"/></a>
<a href="https://github.com/CyberFFarm"><img src="https://github.com/CyberFFarm.png" width="50" height="50" style="border-radius:50%"/></a>
<a href="https://github.com/vip3001003"><img src="https://github.com/vip3001003.png" width="50" height="50" style="border-radius:50%"/></a>
<a href="https://github.com/mrtluh"><img src="https://github.com/mrtluh.png" width="50" height="50" style="border-radius:50%"/></a>
<a href="https://github.com/cpcp1117-source"><img src="https://github.com/cpcp1117-source.png" width="50" height="50" style="border-radius:50%"/></a>
<a href="https://github.com/match-007"><img src="https://github.com/match-007.png" width="50" height="50" style="border-radius:50%"/></a>
<a href="https://github.com/leiwuhen1715"><img src="https://github.com/leiwuhen1715.png" width="50" height="50" style="border-radius:50%"/></a>
<a href="https://github.com/SHAOXIA1991"><img src="https://github.com/SHAOXIA1991.png" width="50" height="50" style="border-radius:50%"/></a>
Thanks to all our sponsors!
<a href="https://github.com/pjl914335852-ux"><img src="https://github.com/pjl914335852-ux.png" width="60" height="60" style="border-radius:50%" alt="pjl914335852-ux" /></a>
<a href="https://github.com/cat9999aaa"><img src="https://github.com/cat9999aaa.png" width="60" height="60" style="border-radius:50%" alt="cat9999aaa" /></a>
<a href="https://github.com/1733055465"><img src="https://github.com/1733055465.png" width="60" height="60" style="border-radius:50%" alt="1733055465" /></a>
<a href="https://github.com/kolal2020"><img src="https://github.com/kolal2020.png" width="60" height="60" style="border-radius:50%" alt="kolal2020" /></a>
<a href="https://github.com/CyberFFarm"><img src="https://github.com/CyberFFarm.png" width="60" height="60" style="border-radius:50%" alt="CyberFFarm" /></a>
<a href="https://github.com/vip3001003"><img src="https://github.com/vip3001003.png" width="60" height="60" style="border-radius:50%" alt="vip3001003" /></a>
<a href="https://github.com/mrtluh"><img src="https://github.com/mrtluh.png" width="60" height="60" style="border-radius:50%" alt="mrtluh" /></a>
<a href="https://github.com/cpcp1117-source"><img src="https://github.com/cpcp1117-source.png" width="60" height="60" style="border-radius:50%" alt="cpcp1117-source" /></a>
<a href="https://github.com/match-007"><img src="https://github.com/match-007.png" width="60" height="60" style="border-radius:50%" alt="match-007" /></a>
<a href="https://github.com/leiwuhen1715"><img src="https://github.com/leiwuhen1715.png" width="60" height="60" style="border-radius:50%" alt="leiwuhen1715" /></a>
<a href="https://github.com/SHAOXIA1991"><img src="https://github.com/SHAOXIA1991.png" width="60" height="60" style="border-radius:50%" alt="SHAOXIA1991" /></a>
[Become a sponsor](https://github.com/sponsors/NoFxAiOS)
## License
---
[AGPL-3.0](LICENSE)
## Star History
[![Star History Chart](https://api.star-history.com/svg?repos=NoFxAiOS/nofx&type=Date)](https://star-history.com/#NoFxAiOS/nofx&Date)

View File

@@ -1,272 +0,0 @@
package agent
import (
"encoding/json"
"fmt"
"strings"
"time"
)
// ActiveSkillSession is the minimal session for the central brain architecture.
// It replaces the old skillSession + ExecutionState combo for management skill flows.
type ActiveSkillSession struct {
SessionID string `json:"session_id"`
UserID int64 `json:"user_id"`
SkillName string `json:"skill_name"`
ActionName string `json:"action_name"`
LegacyPhase string `json:"legacy_phase,omitempty"`
Goal string `json:"goal,omitempty"`
PendingHint *PendingHint `json:"pending_hint,omitempty"`
CollectedFields map[string]any `json:"collected_fields,omitempty"`
LocalHistory []chatMessage `json:"local_history,omitempty"`
UpdatedAt string `json:"updated_at"`
}
type PendingHint struct {
Prompt string `json:"prompt,omitempty"`
HintType string `json:"hint_type,omitempty"`
}
type PendingProposalSession struct {
UserID int64 `json:"user_id"`
SourceUserText string `json:"source_user_text,omitempty"`
ProposalText string `json:"proposal_text,omitempty"`
UpdatedAt string `json:"updated_at,omitempty"`
}
func activeSkillSessionKey(userID int64) string {
return fmt.Sprintf("agent_active_skill_session_%d", userID)
}
func pendingProposalSessionKey(userID int64) string {
return fmt.Sprintf("agent_pending_proposal_session_%d", userID)
}
func (a *Agent) getActiveSkillSession(userID int64) (ActiveSkillSession, bool) {
if a.store == nil {
return ActiveSkillSession{}, false
}
raw, err := a.store.GetSystemConfig(activeSkillSessionKey(userID))
if err != nil || strings.TrimSpace(raw) == "" {
return ActiveSkillSession{}, false
}
var s ActiveSkillSession
if err := json.Unmarshal([]byte(raw), &s); err != nil {
return ActiveSkillSession{}, false
}
if s.SessionID == "" || s.SkillName == "" {
return ActiveSkillSession{}, false
}
s.PendingHint = normalizePendingHint(s.PendingHint)
return s, true
}
func (a *Agent) saveActiveSkillSession(s ActiveSkillSession) {
if a.store == nil {
return
}
s.PendingHint = normalizePendingHint(s.PendingHint)
s.UpdatedAt = time.Now().UTC().Format(time.RFC3339)
data, _ := json.Marshal(s)
_ = a.store.SetSystemConfig(activeSkillSessionKey(s.UserID), string(data))
}
func (a *Agent) clearActiveSkillSession(userID int64) {
if a.store == nil {
return
}
_ = a.store.SetSystemConfig(activeSkillSessionKey(userID), "")
}
func (a *Agent) getPendingProposalSession(userID int64) (PendingProposalSession, bool) {
if a.store == nil {
return PendingProposalSession{}, false
}
raw, err := a.store.GetSystemConfig(pendingProposalSessionKey(userID))
if err != nil || strings.TrimSpace(raw) == "" {
return PendingProposalSession{}, false
}
var s PendingProposalSession
if err := json.Unmarshal([]byte(raw), &s); err != nil {
return PendingProposalSession{}, false
}
if s.UserID == 0 || strings.TrimSpace(s.ProposalText) == "" {
return PendingProposalSession{}, false
}
return s, true
}
func (a *Agent) savePendingProposalSession(s PendingProposalSession) {
if a.store == nil {
return
}
s.UpdatedAt = time.Now().UTC().Format(time.RFC3339)
data, _ := json.Marshal(s)
_ = a.store.SetSystemConfig(pendingProposalSessionKey(s.UserID), string(data))
}
func (a *Agent) clearPendingProposalSession(userID int64) {
if a.store == nil {
return
}
_ = a.store.SetSystemConfig(pendingProposalSessionKey(userID), "")
}
func newActiveSkillSession(userID int64, skill, action string) ActiveSkillSession {
return ActiveSkillSession{
SessionID: fmt.Sprintf("as_%d", time.Now().UnixNano()),
UserID: userID,
SkillName: skill,
ActionName: action,
LegacyPhase: "collecting",
Goal: "",
PendingHint: nil,
CollectedFields: map[string]any{},
UpdatedAt: time.Now().UTC().Format(time.RFC3339),
}
}
func normalizePendingHint(hint *PendingHint) *PendingHint {
if hint == nil {
return nil
}
prompt := strings.TrimSpace(hint.Prompt)
if prompt == "" {
return nil
}
out := &PendingHint{
Prompt: prompt,
HintType: strings.TrimSpace(hint.HintType),
}
return out
}
func pendingHintFromAssistantReply(reply string) *PendingHint {
reply = strings.TrimSpace(reply)
if reply == "" {
return nil
}
hintType := ""
switch {
case strings.Contains(reply, "请选择") || strings.Contains(strings.ToLower(reply), "choose"):
hintType = "choice"
case strings.Contains(reply, "确认") || strings.Contains(strings.ToLower(reply), "confirm"):
hintType = "confirmation"
case strings.HasSuffix(reply, "?") || strings.HasSuffix(reply, ""):
hintType = "question"
}
if hintType == "" {
return nil
}
return &PendingHint{Prompt: reply, HintType: hintType}
}
func setActiveSessionPendingHint(session *ActiveSkillSession, reply string) {
if session == nil {
return
}
session.PendingHint = pendingHintFromAssistantReply(reply)
}
func clearActiveSessionPendingHint(session *ActiveSkillSession) {
if session == nil {
return
}
session.PendingHint = nil
}
func (a *Agent) currentPendingHintText(userID int64) string {
if active, ok := a.getActiveSkillSession(userID); ok && active.PendingHint != nil && strings.TrimSpace(active.PendingHint.Prompt) != "" {
return strings.TrimSpace(active.PendingHint.Prompt)
}
if state := a.getExecutionState(userID); state.Waiting != nil && strings.TrimSpace(state.Waiting.Question) != "" {
return strings.TrimSpace(state.Waiting.Question)
}
if proposal, ok := a.getPendingProposalSession(userID); ok && strings.TrimSpace(proposal.ProposalText) != "" {
return strings.TrimSpace(proposal.ProposalText)
}
return strings.TrimSpace(a.getLastAssistantReply(userID))
}
func activeSessionHasField(s ActiveSkillSession, slot string) bool {
slot = strings.TrimSpace(slot)
if slot == "" {
return false
}
if len(s.CollectedFields) == 0 {
return false
}
switch slot {
case "target_ref":
if value, ok := s.CollectedFields["bulk_scope"]; ok && strings.EqualFold(strings.TrimSpace(fmt.Sprint(value)), "all") {
return true
}
for _, key := range []string{"target_ref", "target_ref_id", "target_ref_name"} {
if value, ok := s.CollectedFields[key]; ok && strings.TrimSpace(fmt.Sprint(value)) != "" {
return true
}
}
return false
case "exchange":
value, ok := s.CollectedFields["exchange_id"]
return ok && strings.TrimSpace(fmt.Sprint(value)) != ""
case "model":
for _, key := range []string{"model_id", "ai_model_id"} {
if value, ok := s.CollectedFields[key]; ok && strings.TrimSpace(fmt.Sprint(value)) != "" {
return true
}
}
return false
case "strategy":
value, ok := s.CollectedFields["strategy_id"]
return ok && strings.TrimSpace(fmt.Sprint(value)) != ""
default:
value, ok := s.CollectedFields[slot]
return ok && strings.TrimSpace(fmt.Sprint(value)) != ""
}
}
// missingRequiredFields returns required slots not yet collected, reading from skill registry.
func missingRequiredFields(s ActiveSkillSession) []string {
def, ok := getSkillDefinition(s.SkillName)
if !ok {
return nil
}
actionDef, ok := def.Actions[s.ActionName]
if !ok {
return nil
}
var missing []string
for _, slot := range actionDef.RequiredSlots {
if !activeSessionHasField(s, slot) {
missing = append(missing, slot)
}
}
return missing
}
// fieldConstraintSummary returns a compact description of missing fields for the LLM prompt.
func fieldConstraintSummary(s ActiveSkillSession) string {
def, ok := getSkillDefinition(s.SkillName)
if !ok {
return ""
}
missing := missingRequiredFields(s)
if len(missing) == 0 {
return ""
}
lines := make([]string, 0, len(missing))
for _, key := range missing {
constraint, ok := def.FieldConstraints[key]
if !ok {
lines = append(lines, fmt.Sprintf("- %s (required)", key))
continue
}
desc := constraint.Description
if len(constraint.Values) > 0 {
desc += fmt.Sprintf(" [options: %s]", strings.Join(constraint.Values, ", "))
}
lines = append(lines, fmt.Sprintf("- %s: %s", key, desc))
}
return strings.Join(lines, "\n")
}

File diff suppressed because it is too large Load Diff

View File

@@ -1,53 +0,0 @@
package agent
import (
"log/slog"
"path/filepath"
"testing"
"nofx/store"
)
func TestLoadAIClientFromStoreUserPrefersModelWithBalance(t *testing.T) {
dbPath := filepath.Join(t.TempDir(), "agent-model-selection.db")
st, err := store.New(dbPath)
if err != nil {
t.Fatalf("create store: %v", err)
}
if err := st.AIModel().UpdateWithName("default", "default_openai", "OpenAI", true, "sk-test", "", "gpt-5.2"); err != nil {
t.Fatalf("create openai model: %v", err)
}
if err := st.AIModel().UpdateWithName("default", "wallet_claw402", "Claw402", true, "0x205d759b80bae1afa31a36c4afaeec0b10378c1c55e3363bcde5a1db75c747ca", "", "glm-5"); err != nil {
t.Fatalf("create claw402 model: %v", err)
}
restoreWalletAddress := agentWalletAddressFromPrivateKey
restoreBalanceQuery := agentQueryUSDCBalanceCached
t.Cleanup(func() {
agentWalletAddressFromPrivateKey = restoreWalletAddress
agentQueryUSDCBalanceCached = restoreBalanceQuery
})
agentWalletAddressFromPrivateKey = func(privateKey string) (string, error) {
if privateKey == "0x205d759b80bae1afa31a36c4afaeec0b10378c1c55e3363bcde5a1db75c747ca" {
return "0xabc", nil
}
return "", nil
}
agentQueryUSDCBalanceCached = func(address string) (float64, error) {
if address == "0xabc" {
return 12.5, nil
}
return 0, nil
}
a := New(nil, st, DefaultConfig(), slog.Default())
_, modelName, ok := a.loadAIClientFromStoreUser("default")
if !ok {
t.Fatalf("expected model selection to succeed")
}
if modelName != "glm-5" {
t.Fatalf("expected model with wallet balance to be selected, got %q", modelName)
}
}

View File

@@ -1,128 +0,0 @@
package agent
import (
"errors"
"log/slog"
"strings"
"testing"
)
func TestAIServiceFailureHighlightsHTMLGatewayResponse(t *testing.T) {
a := New(nil, nil, DefaultConfig(), slog.Default())
msg, err := a.aiServiceFailure("zh", errors.New("fail to parse AI server response: failed to parse response: invalid character '<' looking for beginning of value"))
if err != nil {
t.Fatalf("aiServiceFailure returned error: %v", err)
}
for _, want := range []string{
"当前 AI 服务调用失败",
"上游返回了 HTML 页面或网关/反代错误页",
"custom_api_url",
"不是“未配置模型”",
} {
if !strings.Contains(msg, want) {
t.Fatalf("expected message to contain %q, got: %s", want, msg)
}
}
if strings.Contains(msg, "更可能是模型服务余额不足、接口报错或超时") {
t.Fatalf("html parse error should not use the generic balance/timeout-only guidance: %s", msg)
}
}
func TestAIServiceFailureHighlightsUpstreamEmptyOutputRateLimit(t *testing.T) {
a := New(nil, nil, DefaultConfig(), slog.Default())
msg, err := a.aiServiceFailure("zh", errors.New(`API returned error (status 429): {"error":{"code":"upstream_empty_output","message":"Upstream model returned empty output.","param":null,"type":"rate_limit_error"}}`))
if err != nil {
t.Fatalf("aiServiceFailure returned error: %v", err)
}
for _, want := range []string{
"当前 AI 服务调用失败",
"上游模型没有返回有效内容",
"不应优先归因成“余额不足”",
"切换到另一个可用模型",
} {
if !strings.Contains(msg, want) {
t.Fatalf("expected message to contain %q, got: %s", want, msg)
}
}
if strings.Contains(msg, "更可能是模型服务余额不足、接口报错、鉴权失败或超时") {
t.Fatalf("upstream empty output should not use the generic balance/auth/timeout guidance: %s", msg)
}
}
func TestAIServiceFailureHighlightsBannedAccountAuthFailure(t *testing.T) {
a := New(nil, nil, DefaultConfig(), slog.Default())
msg, err := a.aiServiceFailure("zh", errors.New(`API returned error (status 401): {"error":{"code":"authentication_failed","message":"login failed: USER_IS_BANNED","param":null,"type":"authentication_error"}}`))
if err != nil {
t.Fatalf("aiServiceFailure returned error: %v", err)
}
for _, want := range []string{
"当前 AI 服务调用失败",
"账号被禁用/封禁",
"USER_IS_BANNED",
"换一个可用账号/API Key",
"切换到另一个已启用模型",
} {
if !strings.Contains(msg, want) {
t.Fatalf("expected message to contain %q, got: %s", want, msg)
}
}
for _, unexpected := range []string{"余额不足", "超时"} {
if strings.Contains(msg, unexpected) {
t.Fatalf("banned account auth failure should not mention %q: %s", unexpected, msg)
}
}
}
func TestCompletedPlanFallbackDoesNotExposeFinalSummaryFailure(t *testing.T) {
msg := formatCompletedPlanFallback("zh", []PlanStep{
{
Type: planStepTypeTool,
Status: planStepStatusCompleted,
Title: "创建名为 eeg 的策略",
},
})
if msg == "" {
t.Fatalf("expected fallback message")
}
for _, bad := range []string{"失败", "AI", "稍后"} {
if strings.Contains(msg, bad) {
t.Fatalf("fallback should not expose final summary failure %q: %s", bad, msg)
}
}
if !strings.Contains(msg, "已完成") || !strings.Contains(msg, "创建名为 eeg 的策略") {
t.Fatalf("fallback should summarize completed work, got: %s", msg)
}
}
func TestDeterministicCompletedPlanResponseSkipsLLMForSimpleConfirmation(t *testing.T) {
state := ExecutionState{
Steps: []PlanStep{
{
ID: "create_strategy",
Type: planStepTypeTool,
Status: planStepStatusCompleted,
Title: "创建名为 eeg 的策略",
},
{
ID: "respond",
Type: planStepTypeRespond,
Status: planStepStatusRunning,
Title: "策略创建成功",
Instruction: "确认策略创建成功",
},
},
}
msg := deterministicCompletedPlanResponse("zh", state, state.Steps[1])
if msg == "" {
t.Fatalf("expected deterministic response")
}
if !strings.Contains(msg, "已完成") || !strings.Contains(msg, "创建名为 eeg 的策略") {
t.Fatalf("unexpected deterministic response: %s", msg)
}
}

View File

@@ -1,87 +0,0 @@
package agent
import "strings"
func (a *Agent) executeAtomicSkillTask(storeUserID string, userID int64, lang, text, skill, action string, onEvent func(event, data string)) (string, bool) {
return a.executeAtomicSkillTaskWithSession(storeUserID, userID, lang, text, skillSession{Name: strings.TrimSpace(skill), Action: normalizeAtomicSkillAction(strings.TrimSpace(skill), action), Phase: "collecting"}, onEvent)
}
func (a *Agent) executeAtomicSkillTaskWithSession(storeUserID string, userID int64, lang, text string, session skillSession, onEvent func(event, data string)) (string, bool) {
skill := strings.TrimSpace(session.Name)
action := normalizeAtomicSkillAction(skill, session.Action)
session.Name = skill
session.Action = action
if strings.TrimSpace(session.Phase) == "" {
session.Phase = "collecting"
}
skill = strings.TrimSpace(skill)
action = normalizeAtomicSkillAction(skill, action)
var (
answer string
handled bool
)
switch skill {
case "trader_management":
if action == "create" {
answer, handled = a.handleCreateTraderSkill(storeUserID, userID, lang, text, session)
} else {
answer, handled = a.handleTraderManagementSkill(storeUserID, userID, lang, text, session)
if handled && action == "query_running" {
answer = applyTraderQueryFilter(lang, answer, a.toolListTraders(storeUserID), "running_only")
}
}
case "exchange_management":
answer, handled = a.handleExchangeManagementSkill(storeUserID, userID, lang, text, session)
case "model_management":
answer, handled = a.handleModelManagementSkill(storeUserID, userID, lang, text, session)
case "strategy_management":
answer, handled = a.handleStrategyManagementSkill(storeUserID, userID, lang, text, session)
case "model_diagnosis":
answer, handled = a.handleModelDiagnosisSkill(storeUserID, lang, text), true
case "exchange_diagnosis":
answer, handled = a.handleExchangeDiagnosisSkill(storeUserID, lang, text), true
case "trader_diagnosis":
answer, handled = a.handleTraderDiagnosisSkill(storeUserID, lang, text), true
case "strategy_diagnosis":
answer, handled = a.handleStrategyDiagnosisSkill(storeUserID, lang, text), true
default:
return "", false
}
if handled && onEvent != nil {
label := "atomic_skill:" + skill
if action != "" {
label += ":" + action
}
onEvent(StreamEventTool, label)
emitStreamText(onEvent, answer)
}
return answer, handled
}
func (a *Agent) executeAtomicSkillTaskOutcome(storeUserID string, userID int64, lang, text, skill, action string, onEvent func(event, data string)) (skillOutcome, bool) {
return a.executeAtomicSkillTaskOutcomeWithSession(storeUserID, userID, lang, text, skillSession{Name: strings.TrimSpace(skill), Action: normalizeAtomicSkillAction(strings.TrimSpace(skill), action), Phase: "collecting"}, onEvent)
}
func (a *Agent) executeAtomicSkillTaskOutcomeWithSession(storeUserID string, userID int64, lang, text string, session skillSession, onEvent func(event, data string)) (skillOutcome, bool) {
answer, handled := a.executeAtomicSkillTaskWithSession(storeUserID, userID, lang, text, session, onEvent)
if !handled {
return skillOutcome{}, false
}
skill := strings.TrimSpace(session.Name)
action := normalizeAtomicSkillAction(skill, session.Action)
switch skill {
case "model_diagnosis", "exchange_diagnosis", "trader_diagnosis", "strategy_diagnosis":
return skillOutcome{
Skill: skill,
Action: defaultIfEmpty(action, "diagnose"),
Status: skillOutcomeSuccess,
GoalAchieved: true,
UserMessage: answer,
}, true
default:
return inferSkillOutcome(skill, action, answer, a.getSkillSession(userID), skillDataForAction(storeUserID, skill, action, a)), true
}
}

View File

@@ -1,237 +0,0 @@
package agent
import (
"encoding/json"
"fmt"
"log/slog"
"net/http"
"nofx/safe"
"strings"
"sync"
"time"
)
// Brain handles proactive intelligence: signals, news, market briefs.
type Brain struct {
agent *Agent
logger *slog.Logger
http *http.Client
stopCh chan struct{}
stopOnce sync.Once
recentSignals sync.Map // debounce
}
func NewBrain(agent *Agent, logger *slog.Logger) *Brain {
return &Brain{
agent: agent,
logger: logger,
http: &http.Client{Timeout: 15 * time.Second},
stopCh: make(chan struct{}),
}
}
func (b *Brain) Stop() {
b.stopOnce.Do(func() {
close(b.stopCh)
})
}
// cleanStaleSignals removes debounce entries older than 30 minutes.
func (b *Brain) cleanStaleSignals() {
cutoff := time.Now().Add(-30 * time.Minute)
b.recentSignals.Range(func(key, value any) bool {
if t, ok := value.(time.Time); ok && t.Before(cutoff) {
b.recentSignals.Delete(key)
}
return true
})
}
func (b *Brain) HandleSignal(sig Signal) {
key := fmt.Sprintf("%s:%s", sig.Type, sig.Symbol)
if v, ok := b.recentSignals.Load(key); ok {
if time.Since(v.(time.Time)) < 10*time.Minute {
return
}
}
b.recentSignals.Store(key, time.Now())
emoji := map[string]string{"info": "", "warning": "⚠️", "critical": "🚨"}
e := emoji[sig.Severity]
if e == "" {
e = "📊"
}
b.agent.notifyAll(fmt.Sprintf("%s *%s*\n\n%s", e, sig.Title, sig.Detail))
}
func (b *Brain) StartNewsScan(interval time.Duration) {
seen := make(map[string]bool)
seenOrder := make([]string, 0, 1024)
safe.GoNamed("brain-news-scan", func() {
ticker := time.NewTicker(interval)
defer ticker.Stop()
cleanTick := 0
for {
select {
case <-b.stopCh:
return
case <-ticker.C:
b.scanNews(seen, &seenOrder)
cleanTick++
if cleanTick%6 == 0 { // every ~30 min
b.cleanStaleSignals()
}
}
}
})
}
func (b *Brain) scanNews(seen map[string]bool, seenOrder *[]string) {
resp, err := b.http.Get("https://min-api.cryptocompare.com/data/v2/news/?lang=EN&sortOrder=latest")
if err != nil {
return
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
b.logger.Debug("news API non-200", "status", resp.StatusCode)
return
}
body, err := safe.ReadAllLimited(resp.Body, 1024*1024) // 1MB limit
if err != nil {
return
}
var result struct {
Data []struct {
Title string `json:"title"`
Source string `json:"source"`
URL string `json:"url"`
Body string `json:"body"`
Categories string `json:"categories"`
PublishedOn int64 `json:"published_on"`
} `json:"Data"`
}
if err := json.Unmarshal(body, &result); err != nil {
return
}
bullish := []string{"surge", "rally", "bullish", "breakout", "ath", "pump", "adoption"}
bearish := []string{"crash", "dump", "bearish", "sell-off", "plunge", "hack", "ban", "fraud"}
for _, d := range result.Data {
if seen[d.URL] {
continue
}
seen[d.URL] = true
*seenOrder = append(*seenOrder, d.URL)
if time.Since(time.Unix(d.PublishedOn, 0)) > 10*time.Minute {
continue
}
lower := strings.ToLower(d.Title + " " + d.Body)
bc, brc := 0, 0
for _, w := range bullish {
if strings.Contains(lower, w) {
bc++
}
}
for _, w := range bearish {
if strings.Contains(lower, w) {
brc++
}
}
if bc == 0 && brc == 0 {
continue
}
emoji := "📰"
sentiment := "NEUTRAL"
if bc > brc {
emoji = "🟢"
sentiment = "BULLISH"
}
if brc > bc {
emoji = "🔴"
sentiment = "BEARISH"
}
b.agent.notifyAll(fmt.Sprintf("%s *News*\n\n%s\n\n• Source: %s\n• Sentiment: %s",
emoji, d.Title, d.Source, sentiment))
}
// Evict the oldest half when seen grows large so recent URLs stay deduped deterministically.
if len(seen) > 1000 {
half := len(seen) / 2
for i := 0; i < half && i < len(*seenOrder); i++ {
delete(seen, (*seenOrder)[i])
}
if half < len(*seenOrder) {
*seenOrder = append((*seenOrder)[:0], (*seenOrder)[half:]...)
} else {
*seenOrder = (*seenOrder)[:0]
}
}
}
func (b *Brain) StartMarketBriefs(hours []int) {
safe.GoNamed("brain-market-briefs", func() {
ticker := time.NewTicker(1 * time.Minute)
defer ticker.Stop()
sent := make(map[string]bool)
for {
select {
case <-b.stopCh:
return
case now := <-ticker.C:
key := now.Format("2006-01-02-15")
for _, h := range hours {
if now.Hour() == h && now.Minute() == 30 && !sent[key] {
sent[key] = true
b.sendBrief(h)
}
}
}
}
})
}
func (b *Brain) sendBrief(hour int) {
title := "☀️ *早间市场简报*"
if hour >= 18 {
title = "🌙 *晚间市场简报*"
}
// Fetch BTC/ETH prices for the brief
var btcPrice, ethPrice, btcChg, ethChg string
for _, sym := range []string{"BTCUSDT", "ETHUSDT"} {
resp, err := b.http.Get(fmt.Sprintf("https://fapi.binance.com/fapi/v1/ticker/24hr?symbol=%s", sym))
if err != nil {
continue
}
body, readErr := safe.ReadAllLimited(resp.Body, 64*1024) // 64KB limit
statusOK := resp.StatusCode == http.StatusOK
resp.Body.Close()
if readErr != nil || !statusOK {
continue
}
var t map[string]string
if err := json.Unmarshal(body, &t); err != nil {
continue
}
if sym == "BTCUSDT" {
btcPrice = t["lastPrice"]
btcChg = t["priceChangePercent"]
}
if sym == "ETHUSDT" {
ethPrice = t["lastPrice"]
ethChg = t["priceChangePercent"]
}
}
brief := fmt.Sprintf("%s\n\n• BTC: $%s (%s%%)\n• ETH: $%s (%s%%)\n\n_%s_",
title, btcPrice, btcChg, ethPrice, ethChg, time.Now().Format("2006-01-02 15:04"))
b.agent.notifyAll(brief)
}

File diff suppressed because it is too large Load Diff

View File

@@ -1,116 +0,0 @@
package agent
import (
"context"
"log/slog"
"path/filepath"
"strings"
"testing"
"nofx/store"
)
func TestClearRemovesActiveAndPendingConversationState(t *testing.T) {
dbPath := filepath.Join(t.TempDir(), "agent-clear.db")
st, err := store.New(dbPath)
if err != nil {
t.Fatalf("create store: %v", err)
}
a := New(nil, st, DefaultConfig(), slog.Default())
userID := int64(42)
a.history.Add(userID, "assistant", "之前的回复")
_ = a.saveTaskState(userID, TaskState{CurrentGoal: "配置模型"})
a.saveActiveSkillSession(ActiveSkillSession{
SessionID: "as_test",
UserID: userID,
SkillName: "model_management",
ActionName: "create",
PendingHint: &PendingHint{
Prompt: "请选择 provider",
HintType: "question",
},
})
a.savePendingProposalSession(PendingProposalSession{
UserID: userID,
SourceUserText: "帮我配置模型",
ProposalText: "推荐 claw402你要继续吗",
})
a.saveSetupState(userID, &SetupState{
Step: "await_ai_model",
AIProvider: "claw402",
})
if err := st.SetSystemConfig(skillSessionConfigKey(userID), `{"name":"model_management","action":"create"}`); err != nil {
t.Fatalf("seed skill session: %v", err)
}
a.saveWorkflowSession(userID, WorkflowSession{
Tasks: []WorkflowTask{{
ID: "task_1",
Skill: "model_management",
Action: "create",
Request: "帮我配置模型",
Status: workflowTaskPending,
}},
})
if err := st.SetSystemConfig(ExecutionStateConfigKey(userID), `{"user_id":42,"session_id":"exec_1"}`); err != nil {
t.Fatalf("seed execution state: %v", err)
}
a.saveReferenceMemory(userID, &CurrentReferences{
Model: &EntityReference{ID: "m1", Name: "claw402", Source: "context"},
}, nil)
a.SnapshotManager(userID).Save(SuspendedTask{ResumeHint: "旧任务"})
reply, err := a.HandleMessage(context.Background(), userID, "/clear")
if err != nil {
t.Fatalf("clear returned error: %v", err)
}
if reply == "" {
t.Fatalf("expected clear reply")
}
if got := a.history.Get(userID); len(got) != 0 {
t.Fatalf("history not cleared: %+v", got)
}
if got := a.buildRecentConversationContext(userID, "你好"); got != "" {
t.Fatalf("recent conversation context not cleared: %q", got)
}
if got := a.currentPendingHintText(userID); got != "" {
t.Fatalf("pending hint not cleared: %q", got)
}
if got := a.buildCurrentTurnContext(userID, "zh", "你好"); got != "" {
if strings.Contains(got, "Previous assistant reply:") || strings.Contains(got, "Recent conversation:") {
t.Fatalf("current turn context still contains prior chat memory: %q", got)
}
}
if got := a.buildActiveTaskStateContext(userID, "zh"); got != "" {
t.Fatalf("active task state context not cleared: %q", got)
}
if state := a.getTaskState(userID); state.CurrentGoal != "" || state.ActiveFlow != "" {
t.Fatalf("task state not cleared: %+v", state)
}
if _, ok := a.getActiveSkillSession(userID); ok {
t.Fatalf("active skill session not cleared")
}
if _, ok := a.getPendingProposalSession(userID); ok {
t.Fatalf("pending proposal session not cleared")
}
if session := a.getSkillSession(userID); session.Name != "" {
t.Fatalf("legacy skill session not cleared: %+v", session)
}
if session := a.getWorkflowSession(userID); len(session.Tasks) != 0 {
t.Fatalf("workflow session not cleared: %+v", session)
}
if state := a.getExecutionState(userID); state.SessionID != "" {
t.Fatalf("execution state not cleared: %+v", state)
}
if memory := a.getReferenceMemory(userID); memory.CurrentReferences != nil || len(memory.ReferenceHistory) != 0 {
t.Fatalf("reference memory not cleared: %+v", memory)
}
if stack := a.SnapshotManager(userID).List(); len(stack) != 0 {
t.Fatalf("snapshots not cleared: %+v", stack)
}
if setup := a.getSetupState(userID); setup.Step != "" || setup.AIProvider != "" {
t.Fatalf("setup state not cleared: %+v", setup)
}
}

View File

@@ -1,466 +0,0 @@
package agent
import (
"encoding/json"
"fmt"
"regexp"
"strings"
"nofx/security"
"nofx/store"
)
type ConfigValidationResult struct {
Warnings []string
}
type ConfigValidator interface {
Validate() error
}
var (
openAIAPIKeyPattern = regexp.MustCompile(`^sk-[A-Za-z0-9\-_]{4,}$`)
genericAPIKeyPattern = regexp.MustCompile(`^[A-Za-z0-9_\-]{8,}$`)
hexCredentialPattern = regexp.MustCompile(`^(0x)?[A-Fa-f0-9]{16,}$`)
supportedModelProvider = map[string]struct{}{
"openai": {}, "deepseek": {}, "claude": {}, "gemini": {}, "qwen": {}, "kimi": {}, "grok": {}, "minimax": {}, "claw402": {}, "blockrun-base": {}, "blockrun-sol": {},
}
)
const (
manualTraderScanIntervalMin = 3
manualTraderScanIntervalMax = 60
manualTraderInitialBalance = 100.0
manualLighterAPIKeyIndexMin = 0
manualLighterAPIKeyIndexMax = 255
)
type modelConfigValidator struct {
provider string
enabled bool
apiKey string
customAPIURL string
customModelName string
modelID string
}
func (v modelConfigValidator) Validate() error {
provider := strings.ToLower(strings.TrimSpace(v.provider))
if provider == "" {
return fmt.Errorf("provider is required")
}
if _, ok := supportedModelProvider[provider]; !ok {
return fmt.Errorf("unsupported provider: %s", provider)
}
if trimmed := strings.TrimSpace(v.customAPIURL); trimmed != "" {
if err := security.ValidateURL(strings.TrimSuffix(trimmed, "#")); err != nil {
return fmt.Errorf("invalid custom_api_url: %w", err)
}
}
if v.enabled && !modelConfigUsable(provider, v.modelID, strings.TrimSpace(v.apiKey), strings.TrimSpace(v.customAPIURL), strings.TrimSpace(v.customModelName)) {
return fmt.Errorf("cannot enable model config before a usable API key, URL, and model are configured")
}
if provider == "openai" && strings.TrimSpace(v.apiKey) != "" && !openAIAPIKeyPattern.MatchString(strings.TrimSpace(v.apiKey)) {
return fmt.Errorf("OpenAI API Key format looks invalid")
}
return nil
}
type exchangeConfigValidator struct {
exchangeType string
enabled bool
apiKey string
secretKey string
passphrase string
hyperliquidWalletAddr string
asterUser string
asterSigner string
asterPrivateKey string
lighterWalletAddr string
lighterPrivateKey string
lighterAPIKeyPrivateKey string
}
func (v exchangeConfigValidator) Validate() error {
exchangeType := strings.ToLower(strings.TrimSpace(v.exchangeType))
if exchangeType == "" {
return fmt.Errorf("exchange_type is required")
}
if trimmed := strings.TrimSpace(v.apiKey); trimmed != "" && !genericAPIKeyPattern.MatchString(trimmed) {
return fmt.Errorf("API Key format looks invalid")
}
if trimmed := strings.TrimSpace(v.secretKey); trimmed != "" && !genericAPIKeyPattern.MatchString(trimmed) && !hexCredentialPattern.MatchString(trimmed) {
return fmt.Errorf("Secret format looks invalid")
}
if v.enabled {
missing := store.MissingRequiredExchangeCredentialFields(
exchangeType,
v.apiKey,
v.secretKey,
v.passphrase,
v.hyperliquidWalletAddr,
v.asterUser,
v.asterSigner,
v.asterPrivateKey,
v.lighterWalletAddr,
v.lighterAPIKeyPrivateKey,
)
if len(missing) > 0 {
return fmt.Errorf("cannot enable exchange config before required fields are complete: %s", strings.Join(missing, ", "))
}
}
return nil
}
type traderBindingValidator struct {
store *store.Store
storeUserID string
aiModelID string
exchangeID string
strategyID string
}
func (v traderBindingValidator) Validate() error {
if v.store == nil {
return fmt.Errorf("store unavailable")
}
if strings.TrimSpace(v.aiModelID) == "" {
return fmt.Errorf("ai_model_id is required")
}
if strings.TrimSpace(v.exchangeID) == "" {
return fmt.Errorf("exchange_id is required")
}
model, err := v.store.AIModel().Get(v.storeUserID, strings.TrimSpace(v.aiModelID))
if err != nil {
return fmt.Errorf("invalid ai_model_id: %w", err)
}
if !model.Enabled {
return fmt.Errorf("ai model is disabled")
}
if !modelConfigUsable(model.Provider, model.ID, strings.TrimSpace(string(model.APIKey)), strings.TrimSpace(model.CustomAPIURL), strings.TrimSpace(model.CustomModelName)) {
return fmt.Errorf("ai model config is incomplete")
}
exchange, err := v.store.Exchange().GetByID(v.storeUserID, strings.TrimSpace(v.exchangeID))
if err != nil {
return fmt.Errorf("invalid exchange_id: %w", err)
}
if !exchange.Enabled {
return fmt.Errorf("exchange is disabled")
}
if err := (exchangeConfigValidator{
exchangeType: exchange.ExchangeType,
enabled: exchange.Enabled,
apiKey: strings.TrimSpace(string(exchange.APIKey)),
secretKey: strings.TrimSpace(string(exchange.SecretKey)),
passphrase: strings.TrimSpace(string(exchange.Passphrase)),
hyperliquidWalletAddr: exchange.HyperliquidWalletAddr,
asterUser: exchange.AsterUser,
asterSigner: exchange.AsterSigner,
asterPrivateKey: strings.TrimSpace(string(exchange.AsterPrivateKey)),
lighterWalletAddr: exchange.LighterWalletAddr,
lighterPrivateKey: strings.TrimSpace(string(exchange.LighterPrivateKey)),
lighterAPIKeyPrivateKey: strings.TrimSpace(string(exchange.LighterAPIKeyPrivateKey)),
}).Validate(); err != nil {
return fmt.Errorf("exchange config is incomplete: %w", err)
}
if trimmed := strings.TrimSpace(v.strategyID); trimmed != "" {
if _, err := v.store.Strategy().Get(v.storeUserID, trimmed); err != nil {
return fmt.Errorf("invalid strategy_id: %w", err)
}
}
return nil
}
func (a *Agent) validateModelDraft(storeUserID, modelID, provider string, enabled bool, apiKey, customAPIURL, customModelName string) error {
if a == nil || a.store == nil {
return fmt.Errorf("store unavailable")
}
if strings.TrimSpace(provider) == "" && strings.TrimSpace(modelID) != "" {
model, err := a.store.AIModel().Get(storeUserID, strings.TrimSpace(modelID))
if err != nil {
return err
}
provider = model.Provider
if strings.TrimSpace(apiKey) == "" {
apiKey = strings.TrimSpace(string(model.APIKey))
}
if strings.TrimSpace(customAPIURL) == "" {
customAPIURL = strings.TrimSpace(model.CustomAPIURL)
}
if strings.TrimSpace(customModelName) == "" {
customModelName = strings.TrimSpace(model.CustomModelName)
}
}
return (modelConfigValidator{
provider: provider,
enabled: enabled,
apiKey: apiKey,
customAPIURL: customAPIURL,
customModelName: customModelName,
modelID: modelID,
}).Validate()
}
func (a *Agent) validateExchangeDraft(storeUserID, exchangeID, exchangeType string, enabled bool, apiKey, secretKey, passphrase, hyperliquidWalletAddr, asterUser, asterSigner, asterPrivateKey, lighterWalletAddr, lighterAPIKeyPrivateKey string) error {
if a == nil || a.store == nil {
return fmt.Errorf("store unavailable")
}
if strings.TrimSpace(exchangeType) == "" && strings.TrimSpace(exchangeID) != "" {
exchange, err := a.store.Exchange().GetByID(storeUserID, strings.TrimSpace(exchangeID))
if err != nil {
return err
}
exchangeType = exchange.ExchangeType
if strings.TrimSpace(apiKey) == "" {
apiKey = strings.TrimSpace(string(exchange.APIKey))
}
if strings.TrimSpace(secretKey) == "" {
secretKey = strings.TrimSpace(string(exchange.SecretKey))
}
if strings.TrimSpace(passphrase) == "" {
passphrase = strings.TrimSpace(string(exchange.Passphrase))
}
if strings.TrimSpace(hyperliquidWalletAddr) == "" {
hyperliquidWalletAddr = strings.TrimSpace(exchange.HyperliquidWalletAddr)
}
if strings.TrimSpace(asterUser) == "" {
asterUser = strings.TrimSpace(exchange.AsterUser)
}
if strings.TrimSpace(asterSigner) == "" {
asterSigner = strings.TrimSpace(exchange.AsterSigner)
}
if strings.TrimSpace(asterPrivateKey) == "" {
asterPrivateKey = strings.TrimSpace(string(exchange.AsterPrivateKey))
}
if strings.TrimSpace(lighterWalletAddr) == "" {
lighterWalletAddr = strings.TrimSpace(exchange.LighterWalletAddr)
}
if strings.TrimSpace(lighterAPIKeyPrivateKey) == "" {
lighterAPIKeyPrivateKey = strings.TrimSpace(string(exchange.LighterAPIKeyPrivateKey))
}
}
return (exchangeConfigValidator{
exchangeType: exchangeType,
enabled: enabled,
apiKey: apiKey,
secretKey: secretKey,
passphrase: passphrase,
hyperliquidWalletAddr: hyperliquidWalletAddr,
asterUser: asterUser,
asterSigner: asterSigner,
asterPrivateKey: asterPrivateKey,
lighterWalletAddr: lighterWalletAddr,
lighterAPIKeyPrivateKey: lighterAPIKeyPrivateKey,
}).Validate()
}
func (a *Agent) validateTraderDraft(storeUserID, aiModelID, exchangeID, strategyID string) error {
return (traderBindingValidator{
store: a.store,
storeUserID: storeUserID,
aiModelID: aiModelID,
exchangeID: exchangeID,
strategyID: strategyID,
}).Validate()
}
func formatValidationFeedback(lang, domain string, err error) string {
if err == nil {
return ""
}
raw := strings.TrimSpace(err.Error())
lower := strings.ToLower(raw)
if lang == "zh" {
switch {
case strings.Contains(lower, "openai api key format looks invalid"):
return "这份配置还有问题API Key 格式不对。OpenAI 的 API Key 通常以 `sk-` 开头,请直接发完整 Key我继续帮你补进当前草稿。"
case strings.Contains(lower, "api key format looks invalid"):
return "这份配置还有问题API Key 格式不对。请直接发完整的 API Key不要附带多余说明文字。"
case strings.Contains(lower, "secret format looks invalid"):
return "这份配置还有问题Secret 格式不对。请直接发完整的 Secret 值,不要和 API Key 填反。"
case strings.Contains(lower, "okx requires passphrase"):
return "这份配置还有问题OKX 账户缺少 Passphrase启用前需要补齐。你直接把 Passphrase 发我就行。"
case strings.Contains(lower, "hyperliquid requires wallet address"):
return "这份配置还有问题Hyperliquid 账户缺少钱包地址,启用前需要补齐。"
case strings.Contains(lower, "aster requires user, signer, and private key"):
return "这份配置还有问题Aster 账户还缺 user、signer 和 private key启用前需要补齐。"
case strings.Contains(lower, "lighter requires wallet address and api key private key"):
return "这份配置还有问题Lighter 账户还缺钱包地址和 API key private key启用前需要补齐。"
case strings.Contains(lower, "cannot enable model config before a usable api key, url, and model are configured"):
return "这份配置还有问题:要先把 API Key、接口地址和模型名称配完整才能启用。你可以继续把缺的字段发给我。"
case strings.Contains(lower, "unsupported provider"):
return "这份配置还有问题provider 不在支持范围内。请从 OpenAI、DeepSeek、Claude、Gemini、Qwen、Kimi、Grok、Minimax 里选一个。"
case strings.Contains(lower, "invalid custom_api_url"):
return "这份配置还有问题:接口地址格式不对。请给我完整的 URL或直接说使用默认地址。"
case strings.Contains(lower, "ai model is disabled"):
return "这份配置还有问题:绑定的模型当前是禁用状态。请换一个已启用模型,或先启用这个模型。"
case strings.Contains(lower, "exchange is disabled"):
return "这份配置还有问题:绑定的交易所当前已禁用。请换一个已启用交易所,或先启用这个交易所。"
case strings.Contains(lower, "ai model config is incomplete"):
return "这份配置还有问题:绑定的模型配置还没补完整,暂时不能使用。"
case strings.Contains(lower, "invalid ai_model_id"):
return "这份配置还有问题:模型引用无效。请明确告诉我你要绑定哪个模型。"
case strings.Contains(lower, "invalid exchange_id"):
return "这份配置还有问题:交易所引用无效。请明确告诉我你要绑定哪个交易所。"
case strings.Contains(lower, "invalid strategy_id"):
return "这份配置还有问题:策略引用无效。请明确告诉我你要绑定哪个策略。"
case strings.Contains(lower, "provider is required"):
return "这份配置还缺 provider。请先告诉我你要用哪个模型提供商。"
case strings.Contains(lower, "exchange_type is required"):
return "这份配置还缺交易所类型。请先告诉我你要接哪个交易所。"
}
switch domain {
case "model":
return "这份模型草稿还有问题:" + raw
case "exchange":
return "这份交易所草稿还有问题:" + raw
case "trader":
return "这份交易员草稿还有问题:" + raw
case "strategy":
return "这份策略草稿还有问题:" + raw
default:
return "这份配置还有问题:" + raw
}
}
switch {
case strings.Contains(lower, "openai api key format looks invalid"):
return "This draft still has an issue: the API key format looks wrong. OpenAI keys usually start with `sk-`. Send the full key and I'll keep filling the draft."
case strings.Contains(lower, "api key format looks invalid"):
return "This draft still has an issue: the API key format looks wrong. Send the full API key directly."
case strings.Contains(lower, "secret format looks invalid"):
return "This draft still has an issue: the secret format looks wrong. Send the full secret value directly."
case strings.Contains(lower, "okx requires passphrase"):
return "This draft still has an issue: an OKX config needs a passphrase before it can be enabled. Send the passphrase and I'll keep going."
case strings.Contains(lower, "cannot enable model config before a usable api key, url, and model are configured"):
return "This draft still has an issue: the API key, endpoint URL, and model name must be completed before the config can be enabled."
}
switch domain {
case "model":
return "This model draft still has an issue: " + raw
case "exchange":
return "This exchange draft still has an issue: " + raw
case "trader":
return "This trader draft still has an issue: " + raw
case "strategy":
return "This strategy draft still has an issue: " + raw
default:
return "This draft still has an issue: " + raw
}
}
func normalizeTraderArgsToManualLimits(lang string, args traderUpdateArgs) (traderUpdateArgs, []string) {
warnings := make([]string, 0, 2)
if args.ScanIntervalMinutes != nil {
requested := *args.ScanIntervalMinutes
normalized := requested
if normalized < manualTraderScanIntervalMin {
normalized = manualTraderScanIntervalMin
}
if normalized > manualTraderScanIntervalMax {
normalized = manualTraderScanIntervalMax
}
if normalized != requested {
args.ScanIntervalMinutes = &normalized
if lang == "zh" {
warnings = append(warnings, fmt.Sprintf("扫描间隔手动可配置范围是 %d 到 %d 分钟,已从 %d 调整为 %d", manualTraderScanIntervalMin, manualTraderScanIntervalMax, requested, normalized))
} else {
warnings = append(warnings, fmt.Sprintf("scan interval is limited to %d-%d minutes in the manual config, adjusted from %d to %d", manualTraderScanIntervalMin, manualTraderScanIntervalMax, requested, normalized))
}
}
}
return args, warnings
}
func formatRiskControlAcceptancePrompt(lang string, warnings []string, confirmLabel string) string {
if len(warnings) == 0 {
return ""
}
if lang == "zh" {
lines := []string{
"这些配置超出了手动面板允许的范围,我已经先按风控范围收敛:",
}
for _, warning := range warnings {
lines = append(lines, "- "+warning)
}
lines = append(lines, fmt.Sprintf("如果接受当前范围,回复“%s”也可以继续告诉我你想怎么改。", confirmLabel))
return strings.Join(lines, "\n")
}
lines := []string{
"Some values were outside the manual editor limits, so I normalized them first:",
}
for _, warning := range warnings {
lines = append(lines, "- "+warning)
}
lines = append(lines, fmt.Sprintf("Reply %q to accept these safe values, or keep refining the draft.", confirmLabel))
return strings.Join(lines, "\n")
}
func formatRiskControlRefusalPrompt(lang string, warnings []string, confirmLabel string) string {
if len(warnings) == 0 {
return ""
}
if lang == "zh" {
lines := []string{
"这些配置超出了手动面板允许的范围,本次不会按你给的原值直接保存:",
}
for _, warning := range warnings {
lines = append(lines, "- "+warning)
}
lines = append(lines, fmt.Sprintf("如果接受当前安全范围,回复“%s”也可以继续告诉我你想怎么改。", confirmLabel))
return strings.Join(lines, "\n")
}
lines := []string{
"Some values were outside the manual editor limits, so I did not save the original request as-is:",
}
for _, warning := range warnings {
lines = append(lines, "- "+warning)
}
lines = append(lines, fmt.Sprintf("Reply %q to accept these safe values, or keep refining the draft.", confirmLabel))
return strings.Join(lines, "\n")
}
func marshalStringList(values []string) string {
if len(values) == 0 {
return ""
}
raw, err := json.Marshal(values)
if err != nil {
return ""
}
return string(raw)
}
func unmarshalStringList(raw string) []string {
if strings.TrimSpace(raw) == "" {
return nil
}
var values []string
if err := json.Unmarshal([]byte(raw), &values); err != nil {
return nil
}
return values
}
func normalizeExchangePatchToManualLimits(lang string, patch exchangeUpdatePatch) (exchangeUpdatePatch, []string) {
warnings := make([]string, 0, 1)
if patch.LighterAPIKeyIndex != nil {
requested := *patch.LighterAPIKeyIndex
normalized := requested
if normalized < manualLighterAPIKeyIndexMin {
normalized = manualLighterAPIKeyIndexMin
}
if normalized > manualLighterAPIKeyIndexMax {
normalized = manualLighterAPIKeyIndexMax
}
if normalized != requested {
patch.LighterAPIKeyIndex = &normalized
if lang == "zh" {
warnings = append(warnings, fmt.Sprintf("Lighter API Key Index 手动面板范围是 %d 到 %d已从 %d 调整为 %d", manualLighterAPIKeyIndexMin, manualLighterAPIKeyIndexMax, requested, normalized))
} else {
warnings = append(warnings, fmt.Sprintf("lighter API key index is limited to %d-%d in the manual editor, adjusted from %d to %d", manualLighterAPIKeyIndexMin, manualLighterAPIKeyIndexMax, requested, normalized))
}
}
}
return patch, warnings
}

View File

@@ -1,692 +0,0 @@
package agent
import (
"encoding/json"
"log/slog"
"path/filepath"
"strings"
"testing"
"nofx/store"
)
func TestToolManageModelConfigCreateRequiresCredential(t *testing.T) {
dbPath := filepath.Join(t.TempDir(), "visibility.db")
st, err := store.New(dbPath)
if err != nil {
t.Fatalf("create store: %v", err)
}
a := New(nil, st, DefaultConfig(), slog.Default())
resp := a.toolManageModelConfig("default", `{"action":"create","provider":"deepseek"}`)
if !strings.Contains(resp, `"error":"api_key is required for create"`) {
t.Fatalf("expected missing api_key error, got: %s", resp)
}
}
func TestToolManageModelConfigCreateDefaultsToEnabledLikeManualPage(t *testing.T) {
dbPath := filepath.Join(t.TempDir(), "model-create-enabled.db")
st, err := store.New(dbPath)
if err != nil {
t.Fatalf("create store: %v", err)
}
a := New(nil, st, DefaultConfig(), slog.Default())
resp := a.toolManageModelConfig("default", `{"action":"create","provider":"qwen","name":"qwen","api_key":"sk-test-qwen-123456","custom_model_name":"qwen3-max"}`)
if strings.Contains(resp, `"error"`) {
t.Fatalf("expected create to succeed, got: %s", resp)
}
model, err := st.AIModel().Get("default", "default_qwen")
if err != nil {
t.Fatalf("load created model: %v", err)
}
if !model.Enabled {
t.Fatalf("expected agent-created model to default to enabled so it matches manual creation")
}
}
func TestToolManageModelConfigCreateReusesExistingProviderRecord(t *testing.T) {
dbPath := filepath.Join(t.TempDir(), "model-create-upsert.db")
st, err := store.New(dbPath)
if err != nil {
t.Fatalf("create store: %v", err)
}
a := New(nil, st, DefaultConfig(), slog.Default())
if err := st.AIModel().UpdateWithName("default", "default_qwen", "qwen1", false, "sk-old-qwen-123456", "", "qwen3-max"); err != nil {
t.Fatalf("seed existing qwen model: %v", err)
}
resp := a.toolManageModelConfig("default", `{"action":"create","provider":"qwen","name":"Qwen","api_key":"sk-new-qwen-123456","custom_model_name":"qwen3-max"}`)
if strings.Contains(resp, `"error"`) {
t.Fatalf("expected create to reuse existing qwen config instead of failing, got: %s", resp)
}
models, err := st.AIModel().List("default")
if err != nil {
t.Fatalf("list models: %v", err)
}
qwenCount := 0
for _, model := range models {
if model != nil && model.Provider == "qwen" {
qwenCount++
if model.ID != "default_qwen" {
t.Fatalf("expected existing qwen record to be reused, got model id %q", model.ID)
}
if model.Name != "Qwen" {
t.Fatalf("expected reused qwen record to be renamed, got %q", model.Name)
}
if !model.Enabled {
t.Fatalf("expected reused qwen record to be enabled after agent create")
}
}
}
if qwenCount != 1 {
t.Fatalf("expected exactly one qwen record after reuse, got %d", qwenCount)
}
}
func TestToolManageExchangeConfigCreateDefaultsToEnabledLikeManualPage(t *testing.T) {
dbPath := filepath.Join(t.TempDir(), "exchange-create-enabled.db")
st, err := store.New(dbPath)
if err != nil {
t.Fatalf("create store: %v", err)
}
a := New(nil, st, DefaultConfig(), slog.Default())
resp := a.toolManageExchangeConfig("default", `{"action":"create","exchange_type":"binance","account_name":"Binance Main","api_key":"api-test-123456","secret_key":"secret-test-123456"}`)
if strings.Contains(resp, `"error"`) {
t.Fatalf("expected create to succeed, got: %s", resp)
}
exchanges, err := st.Exchange().List("default")
if err != nil {
t.Fatalf("list exchanges: %v", err)
}
if len(exchanges) != 1 || exchanges[0] == nil {
t.Fatalf("expected one created exchange, got %#v", exchanges)
}
if !exchanges[0].Enabled {
t.Fatalf("expected agent-created exchange to default to enabled so it matches manual creation")
}
}
func TestToolManageExchangeConfigCreateRejectsIncompleteDraft(t *testing.T) {
dbPath := filepath.Join(t.TempDir(), "exchange-create-incomplete.db")
st, err := store.New(dbPath)
if err != nil {
t.Fatalf("create store: %v", err)
}
a := New(nil, st, DefaultConfig(), slog.Default())
resp := a.toolManageExchangeConfig("default", `{"action":"create","exchange_type":"okx","account_name":"OKX Main","api_key":"api-test-123456","secret_key":"secret-test-123456"}`)
if !strings.Contains(resp, `"error"`) || !strings.Contains(resp, "passphrase") {
t.Fatalf("expected incomplete create to be rejected with missing passphrase, got: %s", resp)
}
exchanges, err := st.Exchange().List("default")
if err != nil {
t.Fatalf("list exchanges: %v", err)
}
if len(exchanges) != 0 {
t.Fatalf("expected incomplete exchange not to be persisted, got %#v", exchanges)
}
}
func TestToolGetModelConfigsHidesIncompleteRows(t *testing.T) {
dbPath := filepath.Join(t.TempDir(), "visibility-list.db")
st, err := store.New(dbPath)
if err != nil {
t.Fatalf("create store: %v", err)
}
a := New(nil, st, DefaultConfig(), slog.Default())
if err := st.AIModel().UpdateWithName("default", "default_openai", "OpenAI", false, "", "", ""); err != nil {
t.Fatalf("seed incomplete model: %v", err)
}
if err := st.AIModel().UpdateWithName("default", "default_deepseek", "DeepSeek", false, "sk-test-12345", "", "deepseek-chat"); err != nil {
t.Fatalf("seed configured model: %v", err)
}
resp := a.toolGetModelConfigs("default")
if strings.Contains(resp, `"id":"default_openai"`) {
t.Fatalf("incomplete model should be hidden from tool query: %s", resp)
}
if !strings.Contains(resp, `"id":"default_deepseek"`) {
t.Fatalf("configured model should remain visible: %s", resp)
}
}
func TestToolManageStrategyUpdateRejectsOutOfRangeLeverageBeforeSave(t *testing.T) {
dbPath := filepath.Join(t.TempDir(), "strategy-risk-guard.db")
st, err := store.New(dbPath)
if err != nil {
t.Fatalf("create store: %v", err)
}
a := New(nil, st, DefaultConfig(), slog.Default())
cfg := store.GetDefaultStrategyConfig("zh")
rawCfg, err := json.Marshal(cfg)
if err != nil {
t.Fatalf("marshal strategy config: %v", err)
}
strategy := &store.Strategy{
ID: "strategy-risk-guard",
UserID: "default",
Name: "AI500稳重策略",
Description: "test",
IsPublic: false,
ConfigVisible: true,
Config: string(rawCfg),
}
if err := st.Strategy().Create(strategy); err != nil {
t.Fatalf("create strategy: %v", err)
}
resp := a.toolManageStrategy("default", `{"action":"update","strategy_id":"strategy-risk-guard","config":{"risk_control":{"btc_eth_max_leverage":100,"altcoin_max_leverage":100}}}`)
if !strings.Contains(resp, `不会按你给的原值直接保存`) {
t.Fatalf("expected out-of-range leverage update to be rejected before save, got: %s", resp)
}
updated, err := st.Strategy().Get("default", strategy.ID)
if err != nil {
t.Fatalf("reload strategy: %v", err)
}
parsed, err := updated.ParseConfig()
if err != nil {
t.Fatalf("parse updated strategy config: %v", err)
}
if parsed.RiskControl.BTCETHMaxLeverage != 5 || parsed.RiskControl.AltcoinMaxLeverage != 5 {
t.Fatalf("expected stored leverage to remain unchanged at safe defaults, got btc_eth=%d alt=%d", parsed.RiskControl.BTCETHMaxLeverage, parsed.RiskControl.AltcoinMaxLeverage)
}
}
func TestToolManageStrategyRejectsFixedMinPositionSizeUpdates(t *testing.T) {
dbPath := filepath.Join(t.TempDir(), "strategy-fixed-min-position.db")
st, err := store.New(dbPath)
if err != nil {
t.Fatalf("create store: %v", err)
}
a := New(nil, st, DefaultConfig(), slog.Default())
cfg := store.GetDefaultStrategyConfig("zh")
rawCfg, err := json.Marshal(cfg)
if err != nil {
t.Fatalf("marshal strategy config: %v", err)
}
strategy := &store.Strategy{
ID: "strategy-fixed-min-position",
UserID: "default",
Name: "固定最小开仓策略",
Description: "test",
IsPublic: false,
ConfigVisible: true,
Config: string(rawCfg),
}
if err := st.Strategy().Create(strategy); err != nil {
t.Fatalf("create strategy: %v", err)
}
resp := a.toolManageStrategy("default", `{"action":"update","strategy_id":"strategy-fixed-min-position","config":{"risk_control":{"min_position_size":20}}}`)
if !strings.Contains(resp, "固定值 12 USDT") {
t.Fatalf("expected fixed min position size rejection, got: %s", resp)
}
updated, err := st.Strategy().Get("default", strategy.ID)
if err != nil {
t.Fatalf("reload strategy: %v", err)
}
parsed, err := updated.ParseConfig()
if err != nil {
t.Fatalf("parse updated strategy config: %v", err)
}
if parsed.RiskControl.MinPositionSize != 12 {
t.Fatalf("expected stored min position size to remain fixed at 12, got %v", parsed.RiskControl.MinPositionSize)
}
}
func TestExchangeSkillOptionSummaryMatchesManualPage(t *testing.T) {
dbPath := filepath.Join(t.TempDir(), "exchange-options.db")
st, err := store.New(dbPath)
if err != nil {
t.Fatalf("create store: %v", err)
}
a := New(nil, st, DefaultConfig(), slog.Default())
summary := a.exchangeSkillOptionSummary("zh")
for _, expected := range []string{"Binance", "Bybit", "OKX", "Bitget", "Gate", "KuCoin", "Hyperliquid", "Aster", "Lighter", "Indodax"} {
if !strings.Contains(summary, expected) {
t.Fatalf("expected option %q in summary, got: %s", expected, summary)
}
}
for _, hidden := range []string{"Alpaca", "Forex", "Metals"} {
if strings.Contains(summary, hidden) {
t.Fatalf("did not expect hidden manual-page option %q in summary: %s", hidden, summary)
}
}
}
func TestLoadExchangeOptionsHidesInvisibleExchangeRows(t *testing.T) {
dbPath := filepath.Join(t.TempDir(), "exchange-options-visible.db")
st, err := store.New(dbPath)
if err != nil {
t.Fatalf("create store: %v", err)
}
a := New(nil, st, DefaultConfig(), slog.Default())
if err := store.DB().Create(&store.Exchange{
ID: "hidden-exchange",
UserID: "default",
ExchangeType: "okx",
AccountName: "123413",
Name: "OKX Futures",
Type: "cex",
Enabled: false,
}).Error; err != nil {
t.Fatalf("seed legacy hidden exchange: %v", err)
}
if _, err := st.Exchange().Create("default", "okx", "我的主力OKX账户", true, "api-test", "secret-test", "pass-test", false, "", false, false, "", "", "", "", "", "", 0); err != nil {
t.Fatalf("create visible exchange: %v", err)
}
options := a.loadExchangeOptions("default")
if len(options) != 1 {
t.Fatalf("expected only the visible exchange option, got %+v", options)
}
if options[0].Name != "我的主力OKX账户" {
t.Fatalf("expected visible exchange name, got %+v", options)
}
}
func TestDescribeExchangeIncludesTypeSpecificVisibleFields(t *testing.T) {
dbPath := filepath.Join(t.TempDir(), "exchange-detail.db")
st, err := store.New(dbPath)
if err != nil {
t.Fatalf("create store: %v", err)
}
a := New(nil, st, DefaultConfig(), slog.Default())
hyperID, err := st.Exchange().Create("default", "hyperliquid", "Dex Pro", true, "hyper-api-key", "", "", true, "0xabc", true, false, "", "", "", "", "", "", 0)
if err != nil {
t.Fatalf("seed hyperliquid exchange: %v", err)
}
detail, ok := a.describeExchange("default", "zh", &EntityReference{ID: hyperID})
if !ok {
t.Fatal("expected describeExchange to resolve hyperliquid config")
}
for _, expected := range []string{"交易所配置“Dex Pro”详情", "交易所hyperliquid", "账户名Dex Pro", "API Keytrue", "Hyperliquid 钱包地址0xabc"} {
if !strings.Contains(detail, expected) {
t.Fatalf("expected hyperliquid detail to contain %q, got: %s", expected, detail)
}
}
lighterID, err := st.Exchange().Create("default", "lighter", "Lighter Main", false, "", "", "", false, "", true, false, "", "", "", "wallet-1", "", "lighter-secret", 7)
if err != nil {
t.Fatalf("seed lighter exchange: %v", err)
}
detail, ok = a.describeExchange("default", "zh", &EntityReference{ID: lighterID})
if !ok {
t.Fatal("expected describeExchange to resolve lighter config")
}
for _, expected := range []string{"交易所lighter", "Lighter 钱包地址wallet-1", "Lighter API Key 私钥true", "Lighter API Key Index7"} {
if !strings.Contains(detail, expected) {
t.Fatalf("expected lighter detail to contain %q, got: %s", expected, detail)
}
}
}
func TestSkillVisibleFieldSummaryForExchangeUsesReadableNames(t *testing.T) {
dbPath := filepath.Join(t.TempDir(), "exchange-field-summary.db")
st, err := store.New(dbPath)
if err != nil {
t.Fatalf("create store: %v", err)
}
a := New(nil, st, DefaultConfig(), slog.Default())
summary := a.skillVisibleFieldSummary("default", "zh", "exchange_management", "update")
for _, expected := range []string{"交易所类型", "账户名", "API Key", "Secret", "Passphrase", "Hyperliquid 钱包地址", "Aster User", "Lighter API Key 私钥", "Lighter API Key Index"} {
if !strings.Contains(summary, expected) {
t.Fatalf("expected field label %q in summary, got: %s", expected, summary)
}
}
if strings.Contains(summary, "hyperliquid_wallet_addr") || strings.Contains(summary, "lighter_api_key_private_key") {
t.Fatalf("field summary should use readable labels instead of raw keys: %s", summary)
}
}
func TestSkillVisibleFieldSummaryForStrategyCoversManualPageFields(t *testing.T) {
dbPath := filepath.Join(t.TempDir(), "strategy-field-summary.db")
st, err := store.New(dbPath)
if err != nil {
t.Fatalf("create store: %v", err)
}
a := New(nil, st, DefaultConfig(), slog.Default())
summary := a.skillVisibleFieldSummary("default", "zh", "strategy_management", "update_config")
for _, expected := range []string{"发布到市场", "配置可见", "交易对", "杠杆", "主周期", "多周期时间框架", "NofxOS API key", "角色定义", "自定义 Prompt"} {
if !strings.Contains(summary, expected) {
t.Fatalf("expected field label %q in summary, got: %s", expected, summary)
}
}
if strings.Contains(summary, "最小开仓金额") {
t.Fatalf("strategy field summary should not expose fixed min position size editing: %s", summary)
}
}
func TestStrategyVisibleFieldSummaryUsesTargetStrategyType(t *testing.T) {
dbPath := filepath.Join(t.TempDir(), "strategy-type-field-summary.db")
st, err := store.New(dbPath)
if err != nil {
t.Fatalf("create store: %v", err)
}
cfg := store.GetDefaultStrategyConfig("zh")
cfg.StrategyType = "grid_trading"
cfg.GridConfig = &store.GridStrategyConfig{
Symbol: "ETHUSDT",
GridCount: 12,
TotalInvestment: 1000,
Leverage: 3,
UseATRBounds: true,
ATRMultiplier: 2,
Distribution: "gaussian",
}
raw, err := json.Marshal(cfg)
if err != nil {
t.Fatalf("marshal strategy config: %v", err)
}
strategy := &store.Strategy{
ID: "strategy-grid-fields",
UserID: "default",
Name: "我的第一个网格策略",
Description: "",
IsPublic: false,
ConfigVisible: true,
Config: string(raw),
}
if err := st.Strategy().Create(strategy); err != nil {
t.Fatalf("create strategy: %v", err)
}
a := New(nil, st, DefaultConfig(), slog.Default())
session := skillSession{
Name: "strategy_management",
Action: "update_config",
TargetRef: &EntityReference{
ID: strategy.ID,
Name: strategy.Name,
},
}
resources := a.buildActiveSessionResources("default", session)
if got := resources["target_strategy_type"]; got != "grid_trading" {
t.Fatalf("expected grid strategy type in resources, got: %#v", got)
}
fields, ok := resources["target_editable_fields"].([]string)
if !ok {
t.Fatalf("expected editable field list in resources, got: %#v", resources["target_editable_fields"])
}
joined := strings.Join(fields, ",")
if !strings.Contains(joined, "symbol") || strings.Contains(joined, "source_type") {
t.Fatalf("expected grid-only editable fields in resources, got: %s", joined)
}
}
func TestSkillVisibleFieldSummaryForTraderMatchesManualPanelFields(t *testing.T) {
dbPath := filepath.Join(t.TempDir(), "trader-field-summary.db")
st, err := store.New(dbPath)
if err != nil {
t.Fatalf("create store: %v", err)
}
a := New(nil, st, DefaultConfig(), slog.Default())
summary := a.skillVisibleFieldSummary("default", "zh", "trader_management", "update")
for _, expected := range []string{"交易所", "模型", "策略", "扫描间隔", "全仓模式", "竞技场显示"} {
if !strings.Contains(summary, expected) {
t.Fatalf("expected trader field label %q in summary, got: %s", expected, summary)
}
}
for _, unexpected := range []string{"名称", "初始资金", "初始余额", "杠杆", "交易对", "Prompt", "AI500", "OI Top"} {
if strings.Contains(summary, unexpected) {
t.Fatalf("trader field summary should stay within manual panel fields, got: %s", summary)
}
}
}
func TestToolUpdateTraderRejectsRenameOutsideManualPanel(t *testing.T) {
dbPath := filepath.Join(t.TempDir(), "trader-update-reject-rename.db")
st, err := store.New(dbPath)
if err != nil {
t.Fatalf("create store: %v", err)
}
a := New(nil, st, DefaultConfig(), slog.Default())
if err := st.AIModel().UpdateWithName("default", "default_deepseek", "DeepSeek", true, "sk-test-12345", "", "deepseek-chat"); err != nil {
t.Fatalf("seed model: %v", err)
}
exchangeID, err := st.Exchange().Create("default", "binance", "Main", true, "api-test", "secret-test", "", false, "", false, false, "", "", "", "", "", "", 0)
if err != nil {
t.Fatalf("seed exchange: %v", err)
}
cfg := store.GetDefaultStrategyConfig("zh")
rawCfg, err := json.Marshal(cfg)
if err != nil {
t.Fatalf("marshal strategy config: %v", err)
}
if err := st.Strategy().Create(&store.Strategy{
ID: "strategy-trader-rename",
UserID: "default",
Name: "Rename Strategy",
Description: "test",
IsPublic: false,
ConfigVisible: true,
Config: string(rawCfg),
}); err != nil {
t.Fatalf("seed strategy: %v", err)
}
if err := st.Trader().Create(&store.Trader{
ID: "trader-rename",
UserID: "default",
Name: "原交易员",
AIModelID: "default_deepseek",
ExchangeID: exchangeID,
StrategyID: "strategy-trader-rename",
InitialBalance: 1000,
ScanIntervalMinutes: 5,
IsCrossMargin: true,
ShowInCompetition: true,
}); err != nil {
t.Fatalf("seed trader: %v", err)
}
resp := a.toolManageTrader("default", `{"action":"update","trader_id":"trader-rename","name":"新名字"}`)
if !strings.Contains(resp, "trader rename is not supported here") {
t.Fatalf("expected rename rejection, got: %s", resp)
}
}
func TestToolCreateTraderResponseHidesLegacyTraderTuningFields(t *testing.T) {
dbPath := filepath.Join(t.TempDir(), "trader-create-response-shape.db")
st, err := store.New(dbPath)
if err != nil {
t.Fatalf("create store: %v", err)
}
a := New(nil, st, DefaultConfig(), slog.Default())
if err := st.AIModel().UpdateWithName("default", "default_deepseek", "DeepSeek", true, "sk-test-12345", "", "deepseek-chat"); err != nil {
t.Fatalf("seed model: %v", err)
}
exchangeID, err := st.Exchange().Create("default", "binance", "Main", true, "api-test", "secret-test", "", false, "", false, false, "", "", "", "", "", "", 0)
if err != nil {
t.Fatalf("seed exchange: %v", err)
}
cfg := store.GetDefaultStrategyConfig("zh")
rawCfg, err := json.Marshal(cfg)
if err != nil {
t.Fatalf("marshal strategy config: %v", err)
}
if err := st.Strategy().Create(&store.Strategy{
ID: "strategy-trader-shape",
UserID: "default",
Name: "Shape Strategy",
Description: "test",
IsPublic: false,
ConfigVisible: true,
Config: string(rawCfg),
}); err != nil {
t.Fatalf("seed strategy: %v", err)
}
originalFetcher := traderInitialBalanceFetcher
traderInitialBalanceFetcher = func(exchangeCfg *store.Exchange, userID string) (float64, bool, error) {
return 88.5, true, nil
}
defer func() {
traderInitialBalanceFetcher = originalFetcher
}()
resp := a.toolManageTrader("default", `{"action":"create","name":"形状测试","ai_model_id":"default_deepseek","exchange_id":"`+exchangeID+`","strategy_id":"strategy-trader-shape"}`)
if strings.Contains(resp, `"error"`) {
t.Fatalf("expected trader create to succeed, got: %s", resp)
}
for _, blocked := range []string{"btc_eth_leverage", "altcoin_leverage", "trading_symbols", "custom_prompt", "system_prompt_template"} {
if strings.Contains(resp, blocked) {
t.Fatalf("expected trader create response to hide legacy tuning field %q, got: %s", blocked, resp)
}
}
}
func TestToolCreateTraderAutoReadsInitialBalanceFromExchange(t *testing.T) {
dbPath := filepath.Join(t.TempDir(), "trader-auto-balance.db")
st, err := store.New(dbPath)
if err != nil {
t.Fatalf("create store: %v", err)
}
a := New(nil, st, DefaultConfig(), slog.Default())
if err := st.AIModel().UpdateWithName("default", "default_deepseek", "DeepSeek", true, "sk-test-12345", "", "deepseek-chat"); err != nil {
t.Fatalf("seed model: %v", err)
}
exchangeID, err := st.Exchange().Create("default", "binance", "Main", true, "api-test", "secret-test", "", false, "", false, false, "", "", "", "", "", "", 0)
if err != nil {
t.Fatalf("seed exchange: %v", err)
}
cfg := store.GetDefaultStrategyConfig("zh")
rawCfg, err := json.Marshal(cfg)
if err != nil {
t.Fatalf("marshal strategy config: %v", err)
}
if err := st.Strategy().Create(&store.Strategy{
ID: "strategy-auto-balance",
UserID: "default",
Name: "Auto Balance Strategy",
Description: "test",
IsPublic: false,
ConfigVisible: true,
Config: string(rawCfg),
}); err != nil {
t.Fatalf("seed strategy: %v", err)
}
originalFetcher := traderInitialBalanceFetcher
traderInitialBalanceFetcher = func(exchangeCfg *store.Exchange, userID string) (float64, bool, error) {
if exchangeCfg == nil || exchangeCfg.ID != exchangeID {
t.Fatalf("unexpected exchange config passed to balance fetcher: %#v", exchangeCfg)
}
if userID != "default" {
t.Fatalf("unexpected user id %q", userID)
}
return 4321.25, true, nil
}
defer func() {
traderInitialBalanceFetcher = originalFetcher
}()
resp := a.toolManageTrader("default", `{"action":"create","name":"奶茶","ai_model_id":"default_deepseek","exchange_id":"`+exchangeID+`","strategy_id":"strategy-auto-balance","initial_balance":999}`)
if strings.Contains(resp, `"error"`) {
t.Fatalf("expected trader create to succeed, got: %s", resp)
}
traders, err := st.Trader().List("default")
if err != nil {
t.Fatalf("list traders: %v", err)
}
if len(traders) != 1 {
t.Fatalf("expected one trader, got %d", len(traders))
}
if traders[0].InitialBalance != 4321.25 {
t.Fatalf("expected initial balance to be auto-read from exchange, got %.2f", traders[0].InitialBalance)
}
}
func TestDescribeStrategyIncludesManualPageSections(t *testing.T) {
dbPath := filepath.Join(t.TempDir(), "strategy-detail.db")
st, err := store.New(dbPath)
if err != nil {
t.Fatalf("create store: %v", err)
}
a := New(nil, st, DefaultConfig(), slog.Default())
cfg := store.GetDefaultStrategyConfig("zh")
cfg.StrategyType = "grid_trading"
cfg.GridConfig = &store.GridStrategyConfig{
Symbol: "BTCUSDT",
GridCount: 12,
TotalInvestment: 1500,
Leverage: 4,
UpperPrice: 120000,
LowerPrice: 90000,
UseATRBounds: false,
ATRMultiplier: 2,
Distribution: "gaussian",
MaxDrawdownPct: 15,
StopLossPct: 5,
DailyLossLimitPct: 10,
UseMakerOnly: true,
EnableDirectionAdjust: true,
DirectionBiasRatio: 0.7,
}
rawCfg, err := json.Marshal(cfg)
if err != nil {
t.Fatalf("marshal strategy config: %v", err)
}
strategy := &store.Strategy{
ID: "strategy-detail-1",
UserID: "default",
Name: "Grid Alpha",
Description: "grid strategy for regression",
IsPublic: true,
ConfigVisible: true,
Config: string(rawCfg),
}
if err := st.Strategy().Create(strategy); err != nil {
t.Fatalf("create strategy: %v", err)
}
strategy.ConfigVisible = false
if err := st.Strategy().Update(strategy); err != nil {
t.Fatalf("update strategy visibility: %v", err)
}
detail, ok := a.describeStrategy("default", "zh", &EntityReference{ID: strategy.ID})
if !ok {
t.Fatal("expected describeStrategy to resolve seeded strategy")
}
for _, expected := range []string{
"策略“Grid Alpha”概览",
"发布设置:已发布到市场;配置隐藏",
"网格参数:交易对 BTCUSDT网格 12总投资 1500.00;杠杆 4分布 gaussian",
"网格边界:上沿 120000.0000,下沿 90000.0000",
} {
if !strings.Contains(detail, expected) {
t.Fatalf("expected strategy detail to contain %q, got: %s", expected, detail)
}
}
for _, unexpected := range []string{
"标的来源:",
"NofxOS 数据:",
} {
if strings.Contains(detail, unexpected) {
t.Fatalf("expected grid strategy detail not to contain AI field %q, got: %s", unexpected, detail)
}
}
}

View File

@@ -1,111 +0,0 @@
package agent
type entityFieldMeta struct {
Key string
Keywords []string
ValueType string
ManualEditable bool
AgentUpdatable bool
}
var traderFieldCatalog = []entityFieldMeta{
{Key: "ai_model_id", Keywords: []string{"换模型", "切换模型", "模型"}, ValueType: "entity_ref", ManualEditable: true, AgentUpdatable: true},
{Key: "exchange_id", Keywords: []string{"换交易所", "切换交易所", "交易所"}, ValueType: "entity_ref", ManualEditable: true, AgentUpdatable: true},
{Key: "strategy_id", Keywords: []string{"换策略", "切换策略", "策略"}, ValueType: "entity_ref", ManualEditable: true, AgentUpdatable: true},
{Key: "scan_interval_minutes", Keywords: []string{"扫描间隔", "扫描频率", "scan interval", "scan frequency"}, ValueType: "int", ManualEditable: true, AgentUpdatable: true},
{Key: "is_cross_margin", Keywords: []string{"全仓", "cross margin", "is_cross_margin"}, ValueType: "flag", ManualEditable: true, AgentUpdatable: true},
{Key: "show_in_competition", Keywords: []string{"竞技场显示", "显示在竞技场", "show in competition", "competition"}, ValueType: "flag", ManualEditable: true, AgentUpdatable: true},
}
var modelFieldCatalog = []entityFieldMeta{
{Key: "provider", Keywords: []string{"provider", "模型提供商", "模型厂商", "vendor"}, ValueType: "enum", ManualEditable: true, AgentUpdatable: true},
{Key: "name", Keywords: []string{"名称", "名字", "name"}, ValueType: "name", ManualEditable: true, AgentUpdatable: true},
{Key: "enabled", Keywords: []string{"启用", "禁用", "enable", "disable"}, ValueType: "enabled", AgentUpdatable: true},
{Key: "api_key", Keywords: []string{"api key", "apikey", "api_key"}, ValueType: "credential", ManualEditable: true, AgentUpdatable: true},
{Key: "custom_api_url", Keywords: []string{"url", "endpoint", "地址", "接口"}, ValueType: "url", ManualEditable: true, AgentUpdatable: true},
{Key: "custom_model_name", Keywords: []string{"model name", "模型名称", "模型名"}, ValueType: "model_name", ManualEditable: true, AgentUpdatable: true},
}
var exchangeFieldCatalog = []entityFieldMeta{
{Key: "exchange_type", Keywords: []string{"交易所类型", "交易所", "exchange type", "exchange"}, ValueType: "enum", ManualEditable: true, AgentUpdatable: true},
{Key: "account_name", Keywords: []string{"账户名", "account name"}, ValueType: "account_name", ManualEditable: true, AgentUpdatable: true},
{Key: "enabled", Keywords: []string{"启用", "禁用", "enable", "disable"}, ValueType: "enabled", AgentUpdatable: true},
{Key: "api_key", Keywords: []string{"api key", "apikey", "api_key"}, ValueType: "credential", ManualEditable: true, AgentUpdatable: true},
{Key: "secret_key", Keywords: []string{"secret key", "secret", "secret_key"}, ValueType: "credential", ManualEditable: true, AgentUpdatable: true},
{Key: "passphrase", Keywords: []string{"passphrase", "密码短语"}, ValueType: "credential", ManualEditable: true, AgentUpdatable: true},
{Key: "testnet", Keywords: []string{"testnet", "测试网"}, ValueType: "flag", ManualEditable: true, AgentUpdatable: true},
{Key: "hyperliquid_wallet_addr", Keywords: []string{"hyperliquid wallet", "hyperliquid钱包", "主钱包地址", "wallet address"}, ValueType: "credential", ManualEditable: true, AgentUpdatable: true},
{Key: "aster_user", Keywords: []string{"aster user", "aster用户", "用户地址", "user"}, ValueType: "credential", ManualEditable: true, AgentUpdatable: true},
{Key: "aster_signer", Keywords: []string{"aster signer", "signer"}, ValueType: "credential", ManualEditable: true, AgentUpdatable: true},
{Key: "aster_private_key", Keywords: []string{"aster private key", "aster私钥", "private key"}, ValueType: "credential", ManualEditable: true, AgentUpdatable: true},
{Key: "lighter_wallet_addr", Keywords: []string{"lighter wallet", "lighter钱包", "wallet address"}, ValueType: "credential", ManualEditable: true, AgentUpdatable: true},
{Key: "lighter_api_key_private_key", Keywords: []string{"lighter api key private key", "lighter api key", "api key private key"}, ValueType: "credential", ManualEditable: true, AgentUpdatable: true},
{Key: "lighter_api_key_index", Keywords: []string{"lighter api key index", "lighter索引", "api key index"}, ValueType: "int", ManualEditable: true, AgentUpdatable: true},
}
func fieldKeysByCapability(catalog []entityFieldMeta, include func(entityFieldMeta) bool) []string {
keys := make([]string, 0, len(catalog))
for _, field := range catalog {
if include(field) {
keys = append(keys, field.Key)
}
}
return keys
}
func keywordsForField(catalog []entityFieldMeta, field string) []string {
for _, item := range catalog {
if item.Key == field {
return item.Keywords
}
}
return nil
}
func manualTraderEditableFieldKeys() []string {
return fieldKeysByCapability(traderFieldCatalog, func(field entityFieldMeta) bool {
return field.ManualEditable
})
}
func agentTraderUpdatableFieldKeys() []string {
return fieldKeysByCapability(traderFieldCatalog, func(field entityFieldMeta) bool {
return field.AgentUpdatable
})
}
func manualModelEditableFieldKeys() []string {
return fieldKeysByCapability(modelFieldCatalog, func(field entityFieldMeta) bool {
return field.ManualEditable
})
}
func agentModelUpdatableFieldKeys() []string {
return fieldKeysByCapability(modelFieldCatalog, func(field entityFieldMeta) bool {
return field.AgentUpdatable
})
}
func manualExchangeEditableFieldKeys() []string {
return fieldKeysByCapability(exchangeFieldCatalog, func(field entityFieldMeta) bool {
return field.ManualEditable
})
}
func agentExchangeUpdatableFieldKeys() []string {
return fieldKeysByCapability(exchangeFieldCatalog, func(field entityFieldMeta) bool {
return field.AgentUpdatable
})
}
func traderFieldKeywords(field string) []string {
return keywordsForField(traderFieldCatalog, field)
}
func modelFieldKeywords(field string) []string {
return keywordsForField(modelFieldCatalog, field)
}
func exchangeFieldKeywords(field string) []string {
return keywordsForField(exchangeFieldCatalog, field)
}

View File

@@ -1,650 +0,0 @@
package agent
import (
"encoding/json"
"fmt"
"strings"
"time"
"github.com/google/uuid"
)
const (
executionStatusPlanning = "planning"
executionStatusRunning = "running"
executionStatusWaitingUser = "waiting_user"
executionStatusCompleted = "completed"
executionStatusFailed = "failed"
)
const (
planStepTypeTool = "tool"
planStepTypeReason = "reason"
planStepTypeAskUser = "ask_user"
planStepTypeRespond = "respond"
)
const (
planStepStatusPending = "pending"
planStepStatusRunning = "running"
planStepStatusCompleted = "completed"
planStepStatusFailed = "failed"
)
type ExecutionState struct {
SessionID string `json:"session_id"`
UserID int64 `json:"user_id"`
Goal string `json:"goal"`
Status string `json:"status"`
PlanID string `json:"plan_id"`
Steps []PlanStep `json:"steps,omitempty"`
CurrentStepID string `json:"current_step_id,omitempty"`
CurrentReferences *CurrentReferences `json:"current_references,omitempty"`
ReferenceHistory []ReferenceRecord `json:"reference_history,omitempty"`
DynamicSnapshots []Observation `json:"dynamic_snapshots,omitempty"`
ExecutionLog []Observation `json:"execution_log,omitempty"`
SummaryNotes []Observation `json:"summary_notes,omitempty"`
Waiting *WaitingState `json:"waiting,omitempty"`
Observations []Observation `json:"observations,omitempty"`
FinalAnswer string `json:"final_answer,omitempty"`
LastError string `json:"last_error,omitempty"`
UpdatedAt string `json:"updated_at"`
}
type SuspendedTask struct {
SnapshotID string `json:"snapshot_id,omitempty"`
IntentID string `json:"intent_id,omitempty"`
ParentIntentID string `json:"parent_intent_id,omitempty"`
Kind string `json:"kind,omitempty"`
ResumeHint string `json:"resume_hint,omitempty"`
ResumeOnSuccess bool `json:"resume_on_success,omitempty"`
ResumeTriggers []string `json:"resume_triggers,omitempty"`
SkillSession *skillSession `json:"skill_session,omitempty"`
WorkflowSession *WorkflowSession `json:"workflow_session,omitempty"`
ExecutionState *ExecutionState `json:"execution_state,omitempty"`
LocalHistory []chatMessage `json:"local_history,omitempty"`
SuspendedAt string `json:"suspended_at,omitempty"`
}
type PlanStep struct {
ID string `json:"id"`
Type string `json:"type"`
Title string `json:"title,omitempty"`
Status string `json:"status,omitempty"`
ToolName string `json:"tool_name,omitempty"`
ToolArgs map[string]any `json:"tool_args,omitempty"`
Instruction string `json:"instruction,omitempty"`
RequiresConfirmation bool `json:"requires_confirmation,omitempty"`
OutputSummary string `json:"output_summary,omitempty"`
Error string `json:"error,omitempty"`
}
type Observation struct {
StepID string `json:"step_id,omitempty"`
Kind string `json:"kind"`
Summary string `json:"summary"`
RawJSON string `json:"raw_json,omitempty"`
CreatedAt string `json:"created_at"`
}
type WaitingState struct {
Question string `json:"question,omitempty"`
Intent string `json:"intent,omitempty"`
PendingFields []string `json:"pending_fields,omitempty"`
ConfirmationTarget string `json:"confirmation_target,omitempty"`
CreatedAt string `json:"created_at,omitempty"`
}
type EntityReference struct {
ID string `json:"id,omitempty"`
Name string `json:"name,omitempty"`
Source string `json:"source,omitempty"`
UpdatedAt string `json:"updated_at,omitempty"`
}
type ReferenceRecord struct {
Kind string `json:"kind,omitempty"`
ID string `json:"id,omitempty"`
Name string `json:"name,omitempty"`
Source string `json:"source,omitempty"`
CreatedAt string `json:"created_at,omitempty"`
}
type CurrentReferences struct {
Strategy *EntityReference `json:"strategy,omitempty"`
Trader *EntityReference `json:"trader,omitempty"`
Model *EntityReference `json:"model,omitempty"`
Exchange *EntityReference `json:"exchange,omitempty"`
}
type SnapshotSummary struct {
SnapshotID string `json:"snapshot_id,omitempty"`
IntentID string `json:"intent_id,omitempty"`
ParentIntentID string `json:"parent_intent_id,omitempty"`
Kind string `json:"kind,omitempty"`
ResumeHint string `json:"resume_hint,omitempty"`
SuspendedAt string `json:"suspended_at,omitempty"`
}
type SnapshotManager struct {
agent *Agent
userID int64
}
type executionPlan struct {
Goal string `json:"goal"`
Steps []PlanStep `json:"steps"`
}
const (
executionLogMaxEntries = 8
summaryNotesMaxEntries = 4
)
func ExecutionStateConfigKey(userID int64) string {
return fmt.Sprintf("agent_execution_state_%d", userID)
}
func taskStackConfigKey(userID int64) string {
return fmt.Sprintf("agent_task_stack_%d", userID)
}
func (a *Agent) SnapshotManager(userID int64) SnapshotManager {
return SnapshotManager{agent: a, userID: userID}
}
func (m SnapshotManager) Save(task SuspendedTask) {
if m.agent == nil {
return
}
m.agent.pushTaskStack(m.userID, task)
}
func (m SnapshotManager) Load() (SuspendedTask, bool) {
if m.agent == nil {
return SuspendedTask{}, false
}
return m.agent.popTaskStack(m.userID)
}
func (m SnapshotManager) Peek() (SuspendedTask, bool) {
if m.agent == nil {
return SuspendedTask{}, false
}
return m.agent.peekTaskStack(m.userID)
}
func (m SnapshotManager) List() []SnapshotSummary {
if m.agent == nil {
return nil
}
stack := m.agent.getTaskStack(m.userID)
out := make([]SnapshotSummary, 0, len(stack))
for _, item := range stack {
out = append(out, SnapshotSummary{
SnapshotID: strings.TrimSpace(item.SnapshotID),
IntentID: strings.TrimSpace(item.IntentID),
ParentIntentID: strings.TrimSpace(item.ParentIntentID),
Kind: strings.TrimSpace(item.Kind),
ResumeHint: strings.TrimSpace(item.ResumeHint),
SuspendedAt: strings.TrimSpace(item.SuspendedAt),
})
}
return out
}
func (m SnapshotManager) Stack() []SuspendedTask {
if m.agent == nil {
return nil
}
return m.agent.getTaskStack(m.userID)
}
func (m SnapshotManager) RemoveAt(index int) (SuspendedTask, bool) {
if m.agent == nil {
return SuspendedTask{}, false
}
stack := m.agent.getTaskStack(m.userID)
if index < 0 || index >= len(stack) {
return SuspendedTask{}, false
}
task := stack[index]
stack = append(stack[:index], stack[index+1:]...)
m.agent.saveTaskStack(m.userID, stack)
return task, true
}
func (m SnapshotManager) Clear() {
if m.agent == nil {
return
}
m.agent.clearTaskStack(m.userID)
}
func (a *Agent) getExecutionState(userID int64) ExecutionState {
if a.store == nil {
return ExecutionState{}
}
raw, err := a.store.GetSystemConfig(ExecutionStateConfigKey(userID))
if err != nil {
a.logger.Warn("failed to load execution state", "error", err, "user_id", userID)
return ExecutionState{}
}
raw = strings.TrimSpace(raw)
if raw == "" {
return ExecutionState{}
}
var state ExecutionState
if err := json.Unmarshal([]byte(raw), &state); err != nil {
a.logger.Warn("failed to parse execution state", "error", err, "user_id", userID)
return ExecutionState{}
}
return normalizeExecutionState(state)
}
func (a *Agent) saveExecutionState(state ExecutionState) error {
if a.store == nil {
return fmt.Errorf("store unavailable")
}
state = normalizeExecutionState(state)
if state.SessionID == "" {
return a.store.SetSystemConfig(ExecutionStateConfigKey(state.UserID), "")
}
if state.UserID != 0 && (state.CurrentReferences != nil || len(state.ReferenceHistory) > 0) {
a.saveReferenceMemory(state.UserID, state.CurrentReferences, state.ReferenceHistory)
}
data, err := json.Marshal(state)
if err != nil {
return err
}
return a.store.SetSystemConfig(ExecutionStateConfigKey(state.UserID), string(data))
}
func (a *Agent) clearExecutionState(userID int64) {
if a.store == nil {
return
}
if err := a.store.SetSystemConfig(ExecutionStateConfigKey(userID), ""); err != nil {
a.logger.Warn("failed to clear execution state", "error", err, "user_id", userID)
}
}
func (a *Agent) getTaskStack(userID int64) []SuspendedTask {
if a.store == nil {
return nil
}
raw, err := a.store.GetSystemConfig(taskStackConfigKey(userID))
if err != nil {
a.logger.Warn("failed to load task stack", "error", err, "user_id", userID)
return nil
}
raw = strings.TrimSpace(raw)
if raw == "" {
return nil
}
var stack []SuspendedTask
if err := json.Unmarshal([]byte(raw), &stack); err != nil {
a.logger.Warn("failed to parse task stack", "error", err, "user_id", userID)
return nil
}
return normalizeTaskStack(stack)
}
func (a *Agent) saveTaskStack(userID int64, stack []SuspendedTask) {
if a.store == nil {
return
}
stack = normalizeTaskStack(stack)
if len(stack) == 0 {
_ = a.store.SetSystemConfig(taskStackConfigKey(userID), "")
return
}
data, err := json.Marshal(stack)
if err != nil {
return
}
_ = a.store.SetSystemConfig(taskStackConfigKey(userID), string(data))
}
func (a *Agent) peekTaskStack(userID int64) (SuspendedTask, bool) {
stack := a.getTaskStack(userID)
if len(stack) == 0 {
return SuspendedTask{}, false
}
return stack[len(stack)-1], true
}
func (a *Agent) pushTaskStack(userID int64, task SuspendedTask) {
task = normalizeSuspendedTask(task)
if task.Kind == "" {
return
}
stack := a.getTaskStack(userID)
stack = append(stack, task)
stack = normalizeTaskStack(stack)
a.saveTaskStack(userID, stack)
}
func (a *Agent) popTaskStack(userID int64) (SuspendedTask, bool) {
stack := a.getTaskStack(userID)
if len(stack) == 0 {
return SuspendedTask{}, false
}
task := stack[len(stack)-1]
stack = stack[:len(stack)-1]
a.saveTaskStack(userID, stack)
return task, true
}
func (a *Agent) clearTaskStack(userID int64) {
if a.store == nil {
return
}
_ = a.store.SetSystemConfig(taskStackConfigKey(userID), "")
}
func newExecutionState(userID int64, goal string) ExecutionState {
now := time.Now().UTC().Format(time.RFC3339)
return normalizeExecutionState(ExecutionState{
SessionID: fmt.Sprintf("sess_%d", time.Now().UTC().UnixNano()),
UserID: userID,
Goal: strings.TrimSpace(goal),
Status: executionStatusPlanning,
PlanID: fmt.Sprintf("plan_%d", time.Now().UTC().UnixNano()),
UpdatedAt: now,
})
}
func normalizeExecutionState(state ExecutionState) ExecutionState {
state.Goal = strings.TrimSpace(state.Goal)
state.Status = strings.TrimSpace(state.Status)
state.CurrentStepID = strings.TrimSpace(state.CurrentStepID)
state.FinalAnswer = strings.TrimSpace(state.FinalAnswer)
state.LastError = strings.TrimSpace(state.LastError)
state.CurrentReferences = normalizeCurrentReferences(state.CurrentReferences)
state.ReferenceHistory = normalizeReferenceHistory(state.ReferenceHistory)
state.Waiting = normalizeWaitingState(state.Waiting)
if state.Status == "" && state.SessionID != "" {
state.Status = executionStatusPlanning
}
for i := range state.Steps {
state.Steps[i].ID = strings.TrimSpace(state.Steps[i].ID)
if state.Steps[i].ID == "" {
state.Steps[i].ID = fmt.Sprintf("step_%d", i+1)
}
state.Steps[i].Type = strings.TrimSpace(state.Steps[i].Type)
state.Steps[i].Title = strings.TrimSpace(state.Steps[i].Title)
state.Steps[i].ToolName = strings.TrimSpace(state.Steps[i].ToolName)
state.Steps[i].Instruction = strings.TrimSpace(state.Steps[i].Instruction)
state.Steps[i].OutputSummary = strings.TrimSpace(state.Steps[i].OutputSummary)
state.Steps[i].Error = strings.TrimSpace(state.Steps[i].Error)
if state.Steps[i].Status == "" {
state.Steps[i].Status = planStepStatusPending
}
}
if len(state.Observations) > 0 {
state.ExecutionLog = append(state.ExecutionLog, state.Observations...)
state.Observations = nil
}
state.DynamicSnapshots = normalizeObservationList(state.DynamicSnapshots)
state.ExecutionLog = normalizeObservationList(state.ExecutionLog)
state.SummaryNotes = normalizeObservationList(state.SummaryNotes)
state = compactExecutionLog(state)
if state.UpdatedAt == "" && state.SessionID != "" {
state.UpdatedAt = time.Now().UTC().Format(time.RFC3339)
}
return state
}
func normalizeSuspendedTask(task SuspendedTask) SuspendedTask {
task.SnapshotID = strings.TrimSpace(task.SnapshotID)
task.IntentID = strings.TrimSpace(task.IntentID)
task.ParentIntentID = strings.TrimSpace(task.ParentIntentID)
task.Kind = strings.TrimSpace(task.Kind)
task.ResumeHint = strings.TrimSpace(task.ResumeHint)
task.ResumeTriggers = cleanStringList(task.ResumeTriggers)
task.SuspendedAt = strings.TrimSpace(task.SuspendedAt)
if task.SkillSession != nil {
session := normalizeSkillSession(*task.SkillSession)
if session.Name == "" {
task.SkillSession = nil
} else {
task.SkillSession = &session
}
}
if task.WorkflowSession != nil {
session := normalizeWorkflowSession(*task.WorkflowSession)
if len(session.Tasks) == 0 {
task.WorkflowSession = nil
} else {
task.WorkflowSession = &session
}
}
if task.ExecutionState != nil {
state := normalizeExecutionState(*task.ExecutionState)
if strings.TrimSpace(state.SessionID) == "" {
task.ExecutionState = nil
} else {
task.ExecutionState = &state
}
}
if task.Kind == "" {
switch {
case task.SkillSession != nil:
task.Kind = "skill_session"
case task.WorkflowSession != nil:
task.Kind = "workflow_session"
case task.ExecutionState != nil:
task.Kind = "execution_state"
}
}
if task.Kind == "" {
return SuspendedTask{}
}
if task.SnapshotID == "" {
task.SnapshotID = "snap_" + uuid.NewString()
}
if task.IntentID == "" {
task.IntentID = "intent_" + uuid.NewString()
}
if task.SuspendedAt == "" {
task.SuspendedAt = time.Now().UTC().Format(time.RFC3339)
}
return task
}
func normalizeTaskStack(stack []SuspendedTask) []SuspendedTask {
if len(stack) == 0 {
return nil
}
now := time.Now().UTC()
out := make([]SuspendedTask, 0, len(stack))
for _, item := range stack {
item = normalizeSuspendedTask(item)
if item.Kind == "" {
continue
}
if t, err := time.Parse(time.RFC3339, item.SuspendedAt); err == nil && now.Sub(t) > 24*time.Hour {
continue
}
out = append(out, item)
}
if len(out) == 0 {
return nil
}
if len(out) > 5 {
out = out[len(out)-5:]
}
return out
}
func normalizeWaitingState(waiting *WaitingState) *WaitingState {
if waiting == nil {
return nil
}
waiting.Question = strings.TrimSpace(waiting.Question)
waiting.Intent = strings.TrimSpace(waiting.Intent)
waiting.PendingFields = cleanStringList(waiting.PendingFields)
waiting.ConfirmationTarget = strings.TrimSpace(waiting.ConfirmationTarget)
if waiting.CreatedAt == "" && (waiting.Question != "" || waiting.Intent != "" || len(waiting.PendingFields) > 0 || waiting.ConfirmationTarget != "") {
waiting.CreatedAt = time.Now().UTC().Format(time.RFC3339)
}
if waiting.Question == "" && waiting.Intent == "" && len(waiting.PendingFields) == 0 && waiting.ConfirmationTarget == "" {
return nil
}
return waiting
}
func normalizeEntityReference(ref *EntityReference) *EntityReference {
if ref == nil {
return nil
}
ref.ID = strings.TrimSpace(ref.ID)
ref.Name = strings.TrimSpace(ref.Name)
ref.Source = strings.TrimSpace(ref.Source)
ref.UpdatedAt = strings.TrimSpace(ref.UpdatedAt)
if ref.ID == "" && ref.Name == "" {
return nil
}
if ref.UpdatedAt == "" {
ref.UpdatedAt = time.Now().UTC().Format(time.RFC3339)
}
return ref
}
func normalizeCurrentReferences(refs *CurrentReferences) *CurrentReferences {
if refs == nil {
return nil
}
refs.Strategy = normalizeEntityReference(refs.Strategy)
refs.Trader = normalizeEntityReference(refs.Trader)
refs.Model = normalizeEntityReference(refs.Model)
refs.Exchange = normalizeEntityReference(refs.Exchange)
if refs.Strategy == nil && refs.Trader == nil && refs.Model == nil && refs.Exchange == nil {
return nil
}
return refs
}
func normalizeReferenceHistory(history []ReferenceRecord) []ReferenceRecord {
if len(history) == 0 {
return nil
}
out := make([]ReferenceRecord, 0, len(history))
for _, item := range history {
item.Kind = strings.TrimSpace(item.Kind)
item.ID = strings.TrimSpace(item.ID)
item.Name = strings.TrimSpace(item.Name)
item.Source = strings.TrimSpace(item.Source)
item.CreatedAt = strings.TrimSpace(item.CreatedAt)
if item.Kind == "" || (item.ID == "" && item.Name == "") {
continue
}
if item.CreatedAt == "" {
item.CreatedAt = time.Now().UTC().Format(time.RFC3339)
}
out = append(out, item)
}
if len(out) == 0 {
return nil
}
if len(out) > 12 {
out = out[len(out)-12:]
}
return out
}
func normalizeObservationList(values []Observation) []Observation {
if len(values) == 0 {
return nil
}
out := make([]Observation, 0, len(values))
for _, value := range values {
value.StepID = strings.TrimSpace(value.StepID)
value.Kind = strings.TrimSpace(value.Kind)
value.Summary = strings.TrimSpace(value.Summary)
value.RawJSON = strings.TrimSpace(value.RawJSON)
if value.Kind == "" && value.Summary == "" && value.RawJSON == "" {
continue
}
if value.CreatedAt == "" {
value.CreatedAt = time.Now().UTC().Format(time.RFC3339)
}
out = append(out, value)
}
if len(out) == 0 {
return nil
}
return out
}
func compactExecutionLog(state ExecutionState) ExecutionState {
if len(state.ExecutionLog) <= executionLogMaxEntries {
if len(state.SummaryNotes) > summaryNotesMaxEntries {
state.SummaryNotes = state.SummaryNotes[len(state.SummaryNotes)-summaryNotesMaxEntries:]
}
return state
}
overflow := state.ExecutionLog[:len(state.ExecutionLog)-executionLogMaxEntries]
state.ExecutionLog = state.ExecutionLog[len(state.ExecutionLog)-executionLogMaxEntries:]
summary := summarizeExecutionOverflow(overflow)
if summary != nil {
state.SummaryNotes = append(state.SummaryNotes, *summary)
if len(state.SummaryNotes) > summaryNotesMaxEntries {
state.SummaryNotes = state.SummaryNotes[len(state.SummaryNotes)-summaryNotesMaxEntries:]
}
}
return state
}
func summarizeExecutionOverflow(values []Observation) *Observation {
if len(values) == 0 {
return nil
}
summaries := make([]string, 0, len(values))
for _, value := range values {
label := value.Kind
if label == "" {
label = "observation"
}
if value.Summary != "" {
summaries = append(summaries, fmt.Sprintf("%s: %s", label, value.Summary))
} else if value.RawJSON != "" {
summaries = append(summaries, fmt.Sprintf("%s: %s", label, value.RawJSON))
}
}
if len(summaries) == 0 {
return nil
}
text := strings.Join(summaries, " | ")
if len(text) > 500 {
text = text[:500] + "..."
}
return &Observation{
Kind: "execution_summary",
Summary: text,
CreatedAt: time.Now().UTC().Format(time.RFC3339),
}
}
func appendDynamicSnapshot(state *ExecutionState, obs Observation) {
state.DynamicSnapshots = append(state.DynamicSnapshots, obs)
state.DynamicSnapshots = normalizeObservationList(state.DynamicSnapshots)
}
func appendExecutionLog(state *ExecutionState, obs Observation) {
state.ExecutionLog = append(state.ExecutionLog, obs)
*state = normalizeExecutionState(*state)
}
func buildObservationContext(state ExecutionState) map[string]any {
state = normalizeExecutionState(state)
return map[string]any{
"current_references": state.CurrentReferences,
"dynamic_snapshots": state.DynamicSnapshots,
"execution_log": state.ExecutionLog,
"summary_notes": state.SummaryNotes,
}
}

View File

@@ -1,117 +0,0 @@
package agent
import (
"strings"
"sync"
"time"
)
// chatMessage represents a single message in conversation history.
type chatMessage struct {
Role string `json:"role"` // "user" or "assistant"
Content string `json:"content"`
Timestamp time.Time `json:"timestamp"`
}
// chatHistory stores conversation history per user.
type chatHistory struct {
mu sync.RWMutex
sessions map[int64][]chatMessage
maxTurns int // hard safety cap in messages per user
}
func newChatHistory(maxTurns int) *chatHistory {
if maxTurns <= 0 {
maxTurns = 100 // default hard cap; recent-window trimming is handled separately
}
return &chatHistory{
sessions: make(map[int64][]chatMessage),
maxTurns: maxTurns,
}
}
// Add appends a message to the user's history.
func (h *chatHistory) Add(userID int64, role, content string) {
h.mu.Lock()
defer h.mu.Unlock()
h.sessions[userID] = append(h.sessions[userID], chatMessage{
Role: role,
Content: content,
Timestamp: time.Now(),
})
// Hard safety cap in case summarization is unavailable.
msgs := h.sessions[userID]
if len(msgs) > h.maxTurns {
h.sessions[userID] = msgs[len(msgs)-h.maxTurns:]
}
}
// Get returns the conversation history for a user.
func (h *chatHistory) Get(userID int64) []chatMessage {
h.mu.RLock()
defer h.mu.RUnlock()
msgs := h.sessions[userID]
if msgs == nil {
return nil
}
// Return a copy
result := make([]chatMessage, len(msgs))
copy(result, msgs)
return result
}
func (h *chatHistory) Replace(userID int64, msgs []chatMessage) {
h.mu.Lock()
defer h.mu.Unlock()
if len(msgs) == 0 {
delete(h.sessions, userID)
return
}
if len(msgs) > h.maxTurns {
msgs = msgs[len(msgs)-h.maxTurns:]
}
cloned := make([]chatMessage, len(msgs))
copy(cloned, msgs)
h.sessions[userID] = cloned
}
// Clear resets conversation history for a user.
func (h *chatHistory) Clear(userID int64) {
h.mu.Lock()
defer h.mu.Unlock()
delete(h.sessions, userID)
}
// CleanOld removes sessions older than the given duration.
func (h *chatHistory) CleanOld(maxAge time.Duration) {
h.mu.Lock()
defer h.mu.Unlock()
now := time.Now()
for uid, msgs := range h.sessions {
if len(msgs) > 0 {
lastMsg := msgs[len(msgs)-1]
if now.Sub(lastMsg.Timestamp) > maxAge {
delete(h.sessions, uid)
}
}
}
}
func (a *Agent) getLastAssistantReply(userID int64) string {
if a == nil || a.history == nil {
return ""
}
msgs := a.history.Get(userID)
for i := len(msgs) - 1; i >= 0; i-- {
if strings.EqualFold(strings.TrimSpace(msgs[i].Role), "assistant") {
return strings.TrimSpace(msgs[i].Content)
}
}
return ""
}

View File

@@ -1,88 +0,0 @@
package agent
var i18nMessages = map[string]map[string]string{
"help": {
"zh": "🤖 *NOFXi — 你的 AI 交易 Agent*\n\n" +
"*交易:* 做多 BTC 0.01 x10 · 做空 ETH 0.1 · 平多 BTC · 平空 ETH\n" +
" 也支持 /buy /sell /long /short + 交易对 数量 杠杆\n" +
"*查询:* /positions /balance /pnl /traders\n" +
"*分析:* /analyze BTC\n" +
"*监控:* /watch BTC · /unwatch BTC\n" +
"*策略:* /strategy\n" +
"*系统:* /status /clear /help\n\n" +
"直接跟我说话就行,中英文都可以 💬",
"en": "🤖 *NOFXi — Your AI Trading Agent*\n\n" +
"*Trade:* long BTC 0.01 x10 · short ETH 0.1 · close long BTC · close short ETH\n" +
" Also supports /buy /sell /long /short + symbol qty leverage\n" +
"*Query:* /positions /balance /pnl /traders\n" +
"*Analyze:* /analyze BTC\n" +
"*Monitor:* /watch BTC · /unwatch BTC\n" +
"*Strategy:* /strategy\n" +
"*System:* /status /clear /help\n\n" +
"Just talk to me in any language 💬",
},
"status": {
"zh": "📊 *NOFXi 状态*\n\n• Traders: %d/%d 运行中\n• 监控: %d 个交易对\n• AI: %s\n• 时间: %s",
"en": "📊 *NOFXi Status*\n\n• Traders: %d/%d running\n• Watching: %d symbols\n• AI: %s\n• Time: %s",
},
"no_traders": {
"zh": "📭 暂无 Trader。请在 Web UI 中创建和配置。",
"en": "📭 No traders configured. Create one in Web UI.",
},
"no_running_trader": {
"zh": "⚠️ 没有运行中的 Trader。请在 Web UI 中启动。",
"en": "⚠️ No running trader. Start one in Web UI.",
},
"no_positions": {
"zh": "📭 当前没有持仓。",
"en": "📭 No open positions.",
},
"positions_header": {
"zh": "📊 *当前持仓*\n\n",
"en": "📊 *Open Positions*\n\n",
},
"total_pnl": {
"zh": "💰 *总未实现盈亏: $%.2f*",
"en": "💰 *Total Unrealized P/L: $%.2f*",
},
"balance_header": {
"zh": "💰 *账户余额*\n\n",
"en": "💰 *Account Balances*\n\n",
},
"traders_header": {
"zh": "🤖 *Traders*\n\n",
"en": "🤖 *Traders*\n\n",
},
"trade_usage": {
"zh": "手动下单示例:`做多 BTC 0.01 x10`、`做空 ETH 0.1`、`平多 BTC`、`平空 ETH`。也支持 `/buy BTC 0.01` 或 `/sell ETH 0.5 3x`。下单后需要确认;大额订单要用“确认大额 trade_xxx”。",
"en": "Manual trade examples: `long BTC 0.01 x10`, `short ETH 0.1`, `close long BTC`, `close short ETH`. Also supports `/buy BTC 0.01` or `/sell ETH 0.5 3x`. Orders require confirmation; large orders use `confirm large trade_xxx`.",
},
"invalid_qty": {
"zh": "❓ 无效数量: %s",
"en": "❓ Invalid quantity: %s",
},
"analysis_header": {
"zh": "🔍 *%s 市场分析*",
"en": "🔍 *%s Analysis*",
},
"sentinel_off": {
"zh": "⚠️ Sentinel 未启用。",
"en": "⚠️ Sentinel not enabled.",
},
"system_prompt": {
"zh": "你是 NOFXi一个专业的 AI 交易 Agent。把用户当交易小白用简单清楚的大白话回复先说结论再说下一步。使用少量交易相关 emoji。",
"en": "You are NOFXi, a professional AI trading agent. Treat the user like a trading beginner, use plain language, lead with the conclusion, then the next step. Use a small amount of trading emojis.",
},
}
func (a *Agent) msg(lang, key string) string {
if m, ok := i18nMessages[key]; ok {
if s, ok := m[lang]; ok {
return s
}
if s, ok := m["en"]; ok {
return s
}
}
return key
}

View File

@@ -1,578 +0,0 @@
package agent
import (
"encoding/json"
"fmt"
"sort"
"strings"
)
type llmFlowExtractionTask struct {
Skill string `json:"skill,omitempty"`
Action string `json:"action,omitempty"`
Fields map[string]string `json:"fields,omitempty"`
}
type llmFlowExtractionResult struct {
Intent string `json:"intent,omitempty"`
TargetSnapshotID string `json:"target_snapshot_id,omitempty"`
InlineSubIntent string `json:"inline_sub_intent,omitempty"`
Fields map[string]string `json:"fields,omitempty"`
Tasks []llmFlowExtractionTask `json:"tasks,omitempty"`
Reason string `json:"reason,omitempty"`
}
type llmFlowFieldSpec struct {
Key string `json:"key"`
Description string `json:"description"`
Required bool `json:"required,omitempty"`
}
func buildActiveFlowExtractionPrompt(lang, flowLabel, flowContext string, text string, recentConversationCtx string, currentRefs any, suspendedSnapshots any, extraSections []string) (string, string) {
systemPrompt := `You extract structured continuation input for an active NOFXi flow.
Return JSON only. No markdown.
You must decide one of:
- "continue": the user is continuing the current flow and may have supplied fields
- "switch": the user is switching away to another task
- "cancel": the user is cancelling the current flow
- "instant_reply": the user is only chatting / greeting and no task fields should be written
Rules:
- Prefer "continue" only when the message clearly contributes to the current flow.
- Set target_snapshot_id only when the user is clearly referring to one suspended snapshot from Suspended snapshots JSON.
- For greetings, thanks, and casual chat, use "instant_reply".
- Consider Current references JSON and Suspended snapshots JSON when resolving vague references like "那个", "刚才那个", or "前面那个".
- Treat this as semantic slot filling, not keyword copying.
- Users will often speak in natural language, shorthand, colloquial labels, translated labels, or mild misspellings instead of exact schema keys.
- Your job is to decide which allowed canonical field each value belongs to based on the active flow, field descriptions, current missing fields, and conversation context.
- Never require the user to say the exact internal field key.
- In task.fields, always emit the canonical field keys from Allowed field spec JSON, never aliases, paraphrases, or user wording.
- If the user clearly supplied a value for one allowed field, normalize it to that canonical key before returning JSON.`
sections := []string{
fmt.Sprintf("Language: %s", lang),
fmt.Sprintf("Active flow label: %s", flowLabel),
flowContext,
fmt.Sprintf("Current references JSON: %s", mustMarshalJSON(currentRefs)),
fmt.Sprintf("Suspended snapshots JSON: %s", mustMarshalJSON(suspendedSnapshots)),
}
sections = append(sections, extraSections...)
sections = append(sections, fmt.Sprintf("User message: %s", text), fmt.Sprintf("Recent conversation:\n%s", recentConversationCtx))
return systemPrompt, strings.Join(sections, "\n")
}
func parseLLMFlowExtractionResult(raw string) llmFlowExtractionResult {
out, ok := parseRawFlowExtractionEnvelope(raw)
if !ok {
return llmFlowExtractionResult{}
}
switch out.Intent {
case "continue", "switch", "cancel", "instant_reply":
return out
default:
return llmFlowExtractionResult{}
}
}
func parseRawFlowExtractionEnvelope(raw string) (llmFlowExtractionResult, bool) {
raw = strings.TrimSpace(raw)
raw = strings.TrimPrefix(raw, "```json")
raw = strings.TrimPrefix(raw, "```")
raw = strings.TrimSuffix(raw, "```")
raw = strings.TrimSpace(raw)
var out llmFlowExtractionResult
if err := json.Unmarshal([]byte(raw), &out); err != nil {
start := strings.Index(raw, "{")
end := strings.LastIndex(raw, "}")
if start < 0 || end <= start || json.Unmarshal([]byte(raw[start:end+1]), &out) != nil {
return llmFlowExtractionResult{}, false
}
}
out.Intent = strings.TrimSpace(strings.ToLower(out.Intent))
out.TargetSnapshotID = strings.TrimSpace(out.TargetSnapshotID)
out.Reason = strings.TrimSpace(out.Reason)
if len(out.Fields) > 0 {
clean := make(map[string]string, len(out.Fields))
for key, value := range out.Fields {
key = strings.TrimSpace(key)
value = strings.TrimSpace(value)
if key == "" || value == "" {
continue
}
clean[key] = value
}
out.Fields = clean
}
cleanTasks := make([]llmFlowExtractionTask, 0, len(out.Tasks))
for _, task := range out.Tasks {
task.Skill = strings.TrimSpace(task.Skill)
task.Action = strings.TrimSpace(task.Action)
if len(task.Fields) > 0 {
clean := make(map[string]string, len(task.Fields))
for key, value := range task.Fields {
key = strings.TrimSpace(key)
value = strings.TrimSpace(value)
if key == "" || value == "" {
continue
}
clean[key] = value
}
task.Fields = clean
}
cleanTasks = append(cleanTasks, task)
}
out.Tasks = cleanTasks
return out, out.Intent != ""
}
func filterLLMFlowExtractionFields(result llmFlowExtractionResult, specs []llmFlowFieldSpec) llmFlowExtractionResult {
if len(specs) == 0 {
result.Fields = nil
for i := range result.Tasks {
result.Tasks[i].Fields = nil
}
return result
}
allowed := make(map[string]struct{}, len(specs))
for _, spec := range specs {
key := strings.TrimSpace(spec.Key)
if key != "" {
allowed[key] = struct{}{}
}
}
filter := func(fields map[string]string) map[string]string {
if len(fields) == 0 {
return fields
}
clean := make(map[string]string, len(fields))
for key, value := range fields {
if _, ok := allowed[key]; !ok {
continue
}
clean[key] = value
}
if len(clean) == 0 {
return nil
}
return clean
}
result.Fields = filter(result.Fields)
for i := range result.Tasks {
result.Tasks[i].Fields = filter(result.Tasks[i].Fields)
}
return result
}
func formatConversationMissingFields(lang string, missingFields []string) string {
if len(missingFields) == 0 {
if lang == "zh" {
return "当前没有缺失槽位。"
}
return "There are currently no missing slots."
}
display := make([]string, 0, len(missingFields))
for _, field := range missingFields {
display = append(display, slotDisplayName(field, lang))
}
if lang == "zh" {
return "当前仍缺这些槽位:" + strings.Join(display, "、")
}
return "Current missing slots: " + strings.Join(display, ", ")
}
func skillSessionExtractionContext(session skillSession, lang string) (string, []llmFlowFieldSpec, map[string]string, []string) {
currentStep, _ := currentSkillDAGStep(session)
fieldSpecs := allowedFieldSpecsForSkillSession(session, lang)
currentValues := currentFieldValuesForSkillSession(session)
missing := missingFieldKeysForSkillSession(session)
summary := fmt.Sprintf("Active flow type: skill_session\nSkill: %s\nAction: %s\nCurrent DAG step: %s", session.Name, session.Action, currentStep.ID)
return summary, fieldSpecs, currentValues, missing
}
func allowedFieldSpecsForSkillSession(session skillSession, lang string) []llmFlowFieldSpec {
add := func(out *[]llmFlowFieldSpec, key, description string, required bool) {
*out = append(*out, llmFlowFieldSpec{Key: key, Description: description, Required: required})
}
out := make([]llmFlowFieldSpec, 0, 24)
if actionRequiresSlot(session.Name, session.Action, "target_ref") {
add(&out, "target_ref_id", slotDisplayName("target_ref", lang)+" ID", true)
add(&out, "target_ref_name", slotDisplayName("target_ref", lang), true)
}
if supportsBulkTargetSelection(session.Name, session.Action) {
add(&out, "bulk_scope", "bulk deletion scope, use all only when the user clearly requested all targets", false)
}
switch session.Name {
case "model_management":
required := map[string]bool{"provider": true}
if strings.HasPrefix(session.Action, "update") {
add(&out, "update_field", displayCatalogFieldName("update_field", lang), false)
}
add(&out, "provider", slotDisplayName("provider", lang), required["provider"])
add(&out, "name", displayCatalogFieldName("name", lang), required["name"])
add(&out, "custom_model_name", displayCatalogFieldName("custom_model_name", lang), required["custom_model_name"])
add(&out, "api_key", displayCatalogFieldName("api_key", lang), required["api_key"])
add(&out, "custom_api_url", displayCatalogFieldName("custom_api_url", lang), false)
add(&out, "enabled", displayCatalogFieldName("enabled", lang), false)
case "exchange_management":
required := map[string]bool{"exchange_type": true, "account_name": true}
if strings.HasPrefix(session.Action, "update") {
add(&out, "update_field", displayCatalogFieldName("update_field", lang), false)
}
add(&out, "exchange_type", slotDisplayName("exchange_type", lang), required["exchange_type"])
add(&out, "account_name", displayCatalogFieldName("account_name", lang), required["account_name"])
add(&out, "api_key", displayCatalogFieldName("api_key", lang), false)
add(&out, "secret_key", displayCatalogFieldName("secret_key", lang), false)
add(&out, "passphrase", displayCatalogFieldName("passphrase", lang), false)
add(&out, "testnet", displayCatalogFieldName("testnet", lang), false)
add(&out, "enabled", displayCatalogFieldName("enabled", lang), false)
add(&out, "hyperliquid_wallet_addr", displayCatalogFieldName("hyperliquid_wallet_addr", lang), false)
add(&out, "aster_user", displayCatalogFieldName("aster_user", lang), false)
add(&out, "aster_signer", displayCatalogFieldName("aster_signer", lang), false)
add(&out, "aster_private_key", displayCatalogFieldName("aster_private_key", lang), false)
add(&out, "lighter_wallet_addr", displayCatalogFieldName("lighter_wallet_addr", lang), false)
add(&out, "lighter_api_key_private_key", displayCatalogFieldName("lighter_api_key_private_key", lang), false)
add(&out, "lighter_api_key_index", displayCatalogFieldName("lighter_api_key_index", lang), false)
case "trader_management":
if strings.HasPrefix(session.Action, "update") {
add(&out, "update_field", displayCatalogFieldName("update_field", lang), false)
}
add(&out, "name", slotDisplayName("name", lang), true)
add(&out, "exchange_id", slotDisplayName("exchange", lang)+" ID", false)
add(&out, "exchange_name", slotDisplayName("exchange", lang), true)
add(&out, "model_id", slotDisplayName("model", lang)+" ID", false)
add(&out, "model_name", slotDisplayName("model", lang), true)
add(&out, "strategy_id", slotDisplayName("strategy", lang)+" ID", false)
add(&out, "strategy_name", slotDisplayName("strategy", lang), true)
add(&out, "auto_start", "auto_start", false)
add(&out, "scan_interval_minutes", displayCatalogFieldName("scan_interval_minutes", lang), false)
add(&out, "is_cross_margin", displayCatalogFieldName("is_cross_margin", lang), false)
add(&out, "show_in_competition", displayCatalogFieldName("show_in_competition", lang), false)
case "strategy_management":
if session.Action == "create" || session.Action == "update_config" {
if session.Action == "create" {
add(&out, "strategy_type", "Strategy type. Use ai_trading for AI strategies, including AI500/OI/static coin-source requests; use grid_trading only for grid strategy requests.", false)
}
configPatchDescription := "Partial StrategyConfig JSON patch inferred from the user's strategy intent. Use exact product schema values, not display labels: source_type must be one of static, ai500, oi_top, oi_low; strategy_type must be ai_trading or grid_trading; selected_timeframes must be a JSON array of strings, not a JSON-encoded string."
switch explicitStrategyCreateType(session) {
case "grid_trading":
configPatchDescription += " Current strategy_type is grid_trading: use only top-level strategy_type, grid_config, publish_config, and language. Do not output ai_config or AI fields such as coin_source, indicators, risk_control, timeframes, confidence, or prompt_sections."
case "ai_trading":
configPatchDescription += " Current strategy_type is ai_trading: use top-level strategy_type, ai_config, publish_config, and language. Put coin_source, indicators, risk_control, prompt_sections, and custom_prompt inside ai_config. Do not output grid_config."
default:
configPatchDescription += " Include strategy_type first when the user chooses AI or grid; after strategy_type is known, use only the config branch for that type: grid_config for grid, ai_config for AI."
}
add(&out, "config_patch", configPatchDescription, false)
}
if session.Action == "create" {
add(&out, "awaiting_final_confirmation", "Set true only after you have produced a final user-facing creation summary from the current structured config and are waiting for the user's final confirmation before executing create.", false)
}
if session.Action == "update_prompt" {
add(&out, "prompt", "Full strategy prompt text to write into the strategy custom prompt.", false)
add(&out, "custom_prompt", strategyConfigFieldDisplayName("custom_prompt", lang), false)
}
if session.Action == "update_config" {
return out
}
add(&out, "name", slotDisplayName("name", lang), true)
if session.Action == "create" {
return out
}
keys := manualStrategyEditableFieldKeys()
if strategyType := explicitStrategyCreateType(session); strategyType != "" {
keys = manualStrategyEditableFieldKeysForType(strategyType)
}
for _, key := range keys {
add(&out, key, strategyConfigFieldDisplayName(key, lang), false)
}
}
return out
}
func currentFieldValuesForSkillSession(session skillSession) map[string]string {
values := map[string]string{}
for key, value := range session.Fields {
if trimmed := strings.TrimSpace(value); trimmed != "" {
values[key] = trimmed
}
}
if session.TargetRef != nil {
if session.TargetRef.ID != "" {
values["target_ref_id"] = session.TargetRef.ID
}
if session.TargetRef.Name != "" {
values["target_ref_name"] = session.TargetRef.Name
}
}
for _, key := range []string{"name", "exchange_id", "exchange_name", "model_id", "model_name", "strategy_id", "strategy_name", "auto_start"} {
if value := fieldValue(session, key); value != "" {
values[key] = value
}
}
return values
}
func missingFieldKeysForSkillSession(session skillSession) []string {
missing := make([]string, 0, 8)
switch session.Name {
case "model_management":
if session.Action != "create" && session.Action != "query_list" && session.Action != "query" && session.Action != "query_detail" && session.TargetRef == nil {
missing = append(missing, "target_ref")
}
if strings.HasPrefix(session.Action, "update") {
if session.Action == "update_status" {
if fieldValue(session, "enabled") == "" {
missing = append(missing, "enabled")
}
} else if session.Action == "update_endpoint" {
if fieldValue(session, "custom_api_url") == "" {
missing = append(missing, "custom_api_url")
}
} else {
if fieldValue(session, "update_field") == "" {
missing = append(missing, "update_field")
}
}
} else {
for _, key := range []string{"provider"} {
if fieldValue(session, key) == "" {
missing = append(missing, key)
}
}
if fieldValue(session, "api_key") == "" {
missing = append(missing, "api_key")
}
}
case "exchange_management":
if session.Action != "create" && session.Action != "query_list" && session.Action != "query" && session.Action != "query_detail" && session.TargetRef == nil {
missing = append(missing, "target_ref")
}
if strings.HasPrefix(session.Action, "update") {
if session.Action == "update_status" {
if fieldValue(session, "enabled") == "" {
missing = append(missing, "enabled")
}
} else {
if fieldValue(session, "update_field") == "" {
missing = append(missing, "update_field")
}
}
} else {
for _, key := range []string{"exchange_type", "account_name", "api_key", "secret_key"} {
if fieldValue(session, key) == "" {
missing = append(missing, key)
}
}
}
case "trader_management":
if strings.HasPrefix(session.Action, "update") || strings.HasPrefix(session.Action, "configure_") {
if session.TargetRef == nil {
missing = append(missing, "target_ref")
}
if session.Action == "update_bindings" || session.Action == "configure_strategy" || session.Action == "configure_exchange" || session.Action == "configure_model" {
switch session.Action {
case "configure_strategy":
if fieldValue(session, "strategy_id") == "" {
missing = append(missing, "strategy_name")
}
break
case "configure_exchange":
if fieldValue(session, "exchange_id") == "" {
missing = append(missing, "exchange_name")
}
break
case "configure_model":
if fieldValue(session, "model_id") == "" {
missing = append(missing, "model_name")
}
break
}
if len(missing) > 0 {
break
}
if fieldValue(session, "model_id") == "" && fieldValue(session, "exchange_id") == "" && fieldValue(session, "strategy_id") == "" &&
fieldValue(session, "model_name") == "" && fieldValue(session, "exchange_name") == "" && fieldValue(session, "strategy_name") == "" {
missing = append(missing, "update_field")
}
} else {
if fieldValue(session, "update_field") == "" {
missing = append(missing, "update_field")
}
}
} else {
if fieldValue(session, "name") == "" {
missing = append(missing, "name")
}
if fieldValue(session, "exchange_id") == "" {
missing = append(missing, "exchange_name")
}
if fieldValue(session, "model_id") == "" {
missing = append(missing, "model_name")
}
if fieldValue(session, "strategy_id") == "" {
missing = append(missing, "strategy_name")
}
}
case "strategy_management":
if session.Action != "create" && session.Action != "query_list" && session.Action != "query" && session.Action != "query_detail" && session.TargetRef == nil {
missing = append(missing, "target_ref")
}
switch session.Action {
case "update_name":
if fieldValue(session, "name") == "" {
missing = append(missing, "name")
}
case "update_prompt":
if fieldValue(session, "prompt") == "" && fieldValue(session, "custom_prompt") == "" {
missing = append(missing, "prompt")
}
case "update_config":
if fieldValue(session, "config_patch") == "" {
missing = append(missing, "config_patch")
}
case "create":
if fieldValue(session, "name") == "" {
missing = append(missing, "name")
}
default:
missing = append(missing, "update_field")
}
}
sort.Strings(missing)
return missing
}
func providerExplicitlyMentionedInText(provider, text string) bool {
provider = strings.ToLower(strings.TrimSpace(provider))
lower := strings.ToLower(strings.TrimSpace(text))
if provider == "" || lower == "" {
return false
}
spec, _ := modelProviderSpecByID(provider)
candidates := []string{provider, strings.ToLower(strings.TrimSpace(spec.DisplayName))}
switch provider {
case "blockrun-base":
candidates = append(candidates, "blockrun", "blockrun base", "base wallet")
case "blockrun-sol":
candidates = append(candidates, "blockrun", "blockrun sol", "solana wallet")
case "claw402":
candidates = append(candidates, "claw 402")
}
for _, candidate := range candidates {
candidate = strings.TrimSpace(candidate)
if candidate != "" && strings.Contains(lower, candidate) {
return true
}
}
return false
}
func sanitizeLLMExtractionForSkillSession(text string, session skillSession, result llmFlowExtractionResult) llmFlowExtractionResult {
if session.Name != "model_management" || len(result.Tasks) == 0 {
return result
}
task := result.Tasks[0]
if task.Fields == nil {
return result
}
if provider := strings.TrimSpace(task.Fields["provider"]); provider != "" && !providerExplicitlyMentionedInText(provider, text) {
delete(task.Fields, "provider")
result.Tasks[0] = task
}
return result
}
func (a *Agent) applyLLMExtractionToSkillSession(storeUserID string, session *skillSession, result llmFlowExtractionResult, lang string, text string) {
if session == nil {
return
}
result = sanitizeLLMExtractionForSkillSession(text, *session, result)
if sub := strings.TrimSpace(result.InlineSubIntent); sub == "create_sub_resource" || sub == "edit_sub_resource" {
setField(session, "inline_sub_intent", sub)
}
if len(result.Tasks) == 0 {
return
}
task := result.Tasks[0]
if task.Skill != "" && task.Skill != session.Name {
return
}
if task.Action != "" && session.Action != "" && task.Action != session.Action {
return
}
for key, value := range task.Fields {
value = strings.TrimSpace(value)
if value == "" {
continue
}
switch key {
case "target_ref_id":
if session.TargetRef == nil {
session.TargetRef = &EntityReference{}
}
session.TargetRef.ID = value
if session.TargetRef.Source == "" {
session.TargetRef.Source = "llm_extraction"
}
continue
case "target_ref_name":
if session.TargetRef == nil {
session.TargetRef = &EntityReference{}
}
session.TargetRef.Name = value
if session.TargetRef.Source == "" {
session.TargetRef.Source = "llm_extraction"
}
continue
}
switch session.Name {
case "model_management":
if key == "provider" || key == "name" || key == "custom_model_name" || key == "api_key" || key == "custom_api_url" || key == "enabled" || key == "update_field" {
setField(session, key, value)
}
case "exchange_management":
switch key {
case "exchange_type", "account_name", "api_key", "secret_key", "passphrase", "testnet", "enabled", "update_field":
setField(session, key, value)
}
case "trader_management":
switch key {
case "update_field":
setField(session, key, value)
case "name", "exchange_id", "exchange_name", "model_id", "ai_model_id", "model_name", "strategy_id", "strategy_name", "auto_start":
setField(session, key, value)
case "scan_interval_minutes", "is_cross_margin", "show_in_competition":
setField(session, key, value)
}
case "strategy_management":
if key == "name" {
setField(session, "name", value)
continue
}
if session.Action == "create" || session.Action == "update_config" {
switch key {
case "strategy_type":
if strategyType := parseStrategyTypeValue(value); strategyType != "" {
setStrategyCreateType(session, strategyType)
}
case strategyCreateConfigPatchField:
strategyType := explicitStrategyCreateType(*session)
if strategyType == "" {
strategyType = strategyTypeFromConfigPatchAny(value)
}
if sanitized := sanitizeStrategyCreateConfigPatchForType(value, strategyType); len(sanitized) > 0 {
raw, _ := json.Marshal(sanitized)
setField(session, strategyCreateConfigPatchField, string(raw))
}
}
continue
}
cfg := unmarshalStrategyCreateDraft(fieldValue(*session, strategyCreateDraftConfigField), lang)
if err := applyStrategyConfigPatch(&cfg, key, value); err == nil {
setField(session, strategyCreateDraftConfigField, marshalStrategyCreateDraft(cfg))
}
}
}
}

View File

@@ -1,28 +0,0 @@
package agent
import (
"strings"
"testing"
)
func TestBuildActiveFlowExtractionPromptRequiresCanonicalFieldOutput(t *testing.T) {
systemPrompt, _ := buildActiveFlowExtractionPrompt(
"zh",
"skill_session",
"Active flow type: skill_session\nSkill: exchange_management\nAction: create",
"secret是abc123456",
"",
nil,
nil,
nil,
)
for _, want := range []string{
"Treat this as semantic slot filling, not keyword copying.",
"always emit the canonical field keys from Allowed field spec JSON",
} {
if !strings.Contains(systemPrompt, want) {
t.Fatalf("expected system prompt to contain %q, got:\n%s", want, systemPrompt)
}
}
}

View File

@@ -1,694 +0,0 @@
package agent
import (
"context"
"encoding/json"
"fmt"
"strings"
"nofx/mcp"
)
type unifiedTurnDecision struct {
TopicIntent string `json:"topic_intent,omitempty"`
BusinessAction string `json:"business_action,omitempty"`
TargetSkill string `json:"target_skill,omitempty"`
Tasks []WorkflowTask `json:"tasks,omitempty"`
TargetSnapshotID string `json:"target_snapshot_id,omitempty"`
ContextMode string `json:"context_mode,omitempty"`
ExtractedData map[string]any `json:"extracted_data,omitempty"`
ReplyToUser string `json:"reply_to_user,omitempty"`
Confidence float64 `json:"confidence,omitempty"`
}
func (a *Agent) tryLLMIntentRoute(ctx context.Context, storeUserID string, userID int64, lang, text string, onEvent func(event, data string)) (string, bool, error) {
if a.aiClient == nil {
return "", false, nil
}
text = strings.TrimSpace(text)
if text == "" {
return "", false, nil
}
if decision, ok, err := a.routeTurnUnifiedWithLLM(ctx, userID, lang, text); err == nil && ok {
if answer, handled, execErr := a.executeUnifiedTurnDecision(ctx, storeUserID, userID, lang, text, decision, onEvent); handled || execErr != nil {
return answer, handled, execErr
}
}
return a.tryMinimalBrain(ctx, storeUserID, userID, lang, text, onEvent)
}
func parseUnifiedTurnDecision(raw string) (unifiedTurnDecision, error) {
raw = strings.TrimSpace(raw)
raw = strings.TrimPrefix(raw, "```json")
raw = strings.TrimPrefix(raw, "```")
raw = strings.TrimSuffix(raw, "```")
raw = strings.TrimSpace(raw)
var decision unifiedTurnDecision
if err := json.Unmarshal([]byte(raw), &decision); err == nil {
return normalizeUnifiedTurnDecision(decision), nil
}
start := strings.Index(raw, "{")
end := strings.LastIndex(raw, "}")
if start >= 0 && end > start {
if err := json.Unmarshal([]byte(raw[start:end+1]), &decision); err == nil {
return normalizeUnifiedTurnDecision(decision), nil
}
}
return unifiedTurnDecision{}, fmt.Errorf("invalid unified turn decision json")
}
func normalizeUnifiedTurnDecision(decision unifiedTurnDecision) unifiedTurnDecision {
decision.TopicIntent = strings.TrimSpace(strings.ToLower(decision.TopicIntent))
decision.BusinessAction = strings.TrimSpace(strings.ToLower(decision.BusinessAction))
decision.TargetSkill = strings.TrimSpace(decision.TargetSkill)
decision.TargetSnapshotID = strings.TrimSpace(decision.TargetSnapshotID)
decision.ContextMode = strings.TrimSpace(strings.ToLower(decision.ContextMode))
decision.ReplyToUser = strings.TrimSpace(decision.ReplyToUser)
decision.Tasks = normalizeWorkflowDecomposition(workflowDecomposition{Tasks: decision.Tasks}).Tasks
if decision.ExtractedData == nil {
decision.ExtractedData = map[string]any{}
}
if decision.Confidence < 0 {
decision.Confidence = 0
}
if decision.Confidence > 1 {
decision.Confidence = 1
}
switch decision.TopicIntent {
case "continue", "continue_active":
decision.TopicIntent = "continue_active"
case "start_new", "resume_snapshot", "cancel", "instant_reply":
default:
decision.TopicIntent = ""
}
switch decision.BusinessAction {
case "direct_answer", "new_skill", "skill_tasks", "continue_skill", "planned_agent", "none":
default:
decision.BusinessAction = ""
}
switch decision.ContextMode {
case "use_current", "fresh_context", "resume_snapshot":
default:
decision.ContextMode = "use_current"
}
return decision
}
func (d unifiedTurnDecision) reliable() bool {
if d.TopicIntent == "" || d.BusinessAction == "" {
return false
}
if d.Confidence > 0 && d.Confidence < 0.45 {
return false
}
switch d.BusinessAction {
case "direct_answer":
return strings.TrimSpace(d.ReplyToUser) != ""
case "new_skill":
if len(d.Tasks) > 0 {
return true
}
skill, _ := parseTargetSkill(d.TargetSkill)
return skill != ""
case "skill_tasks":
return len(d.Tasks) > 0
case "continue_skill":
return d.TopicIntent == "continue_active"
case "planned_agent", "none":
return true
default:
return false
}
}
func (a *Agent) routeTurnUnifiedWithLLM(ctx context.Context, userID int64, lang, text string) (unifiedTurnDecision, bool, error) {
systemPrompt, userPrompt := a.buildUnifiedTurnRouterPrompt(userID, lang, text)
stageCtx, cancel := withPlannerStageTimeout(ctx, directReplyTimeout)
defer cancel()
raw, err := a.aiClient.CallWithRequest(&mcp.Request{
Messages: []mcp.Message{
mcp.NewSystemMessage(systemPrompt),
mcp.NewUserMessage(userPrompt),
},
Ctx: stageCtx,
})
if err != nil {
return unifiedTurnDecision{}, false, err
}
decision, err := parseUnifiedTurnDecision(raw)
if err != nil {
return unifiedTurnDecision{}, false, err
}
if !decision.reliable() {
return decision, false, nil
}
return decision, true, nil
}
func (a *Agent) buildUnifiedTurnRouterPrompt(userID int64, lang, text string) (string, string) {
activeSkill := a.getSkillSession(userID)
activeTask, hasActiveTask := a.getActiveSkillSession(userID)
activeWorkflow := a.getWorkflowSession(userID)
activeExec := a.getExecutionState(userID)
pendingProposal, hasPendingProposal := a.getPendingProposalSession(userID)
previousAssistantReply := a.currentPendingHintText(userID)
snapshots := a.SnapshotManager(userID).List()
snapshotJSON, _ := json.Marshal(snapshots)
currentRefs := buildCurrentReferenceSummary(lang, a.semanticCurrentReferences(userID))
recentConversation := a.buildRecentConversationContext(userID, text)
if strings.TrimSpace(recentConversation) == "" {
recentConversation = "(empty)"
}
activeFlowSummary := buildTopLevelActiveFlowSummary(lang, activeSkill, activeTask, hasActiveTask, activeWorkflow, activeExec, pendingProposal, hasPendingProposal)
if strings.TrimSpace(activeFlowSummary) == "" {
activeFlowSummary = "none"
}
activeTaskDetails := "none"
if hasActiveTask {
activeTaskDetails = buildBrainUserPrompt(lang, text, previousAssistantReply, recentConversation, currentRefs, activeTask, true)
}
systemPrompt := prependNOFXiAdvisorPreamble(`You are the unified turn router for NOFXi.
Return JSON only. No markdown.
You must make ONE combined decision for this user turn:
1. Topic/context decision: continue active context, start fresh/new context, resume snapshot, cancel, or direct conversational reply.
2. Business routing decision: answer directly, start/continue a management skill, or hand off to the planner.
3. Context policy: whether downstream modules may use current references, must use fresh context, or must resume a snapshot.
topic_intent values:
- "continue_active": user is answering or continuing the active flow
- "start_new": user starts or switches to a new task/topic
- "resume_snapshot": user wants to resume one suspended snapshot
- "cancel": user cancels the current active flow
- "instant_reply": user only greets, thanks, chats, or asks a direct explanation
business_action values:
- "direct_answer": reply_to_user is the final answer; do not change state
- "skill_tasks": start one or more management/diagnosis skill tasks; tasks is required
- "new_skill": legacy single-skill route; target_skill is required if tasks is empty
- "continue_skill": continue the active skill session
- "planned_agent": hand off to the execution planner/tools
- "none": only valid with cancel when no more action is needed
tasks format for skill_tasks:
- id: "task_1", "task_2", ...
- skill: one available skill name
- action: one available action
- request: the self-contained user-readable subtask
- depends_on: array of task ids, empty when independent
target_skill format for legacy new_skill:
skill_name:action, for example "trader_management:create".
Available skills:
trader_management, exchange_management, model_management, strategy_management,
trader_diagnosis, exchange_diagnosis, model_diagnosis, strategy_diagnosis
Available actions:
create, update, update_name, update_bindings, configure_strategy, configure_exchange, configure_model,
update_status, update_endpoint, update_config, update_prompt, delete, start, stop, activate, duplicate,
query_list, query_detail, query_running
context_mode values:
- "use_current": downstream modules may use current references and recent context
- "fresh_context": the user is switching topic; do not use old current references to fill business fields
- "resume_snapshot": restore target_snapshot_id first
Rules:
- This router decides what context downstream LLMs will see. Be conservative with stale references.
- Treat topic_intent as the primary decision. If the user is naturally responding to the active flow, choose topic_intent="continue_active", business_action="continue_skill", context_mode="use_current"; do not hand off a continuing active flow to planned_agent.
- When an active flow has a previous assistant question, proposal, or confirmation request, reason about what the user's message refers to in that context before deciding it is a new task.
- If the user clearly switches domain/entity, set topic_intent="start_new" and context_mode="fresh_context".
- If the user says "不是交易员,是策略" or similar corrections, use fresh_context.
- If the user answers the previous assistant question, choose continue_active.
- If the user only says "你好", "hi", "谢谢", "收到", choose instant_reply + direct_answer unless it clearly answers a pending task.
- If the user asks a read-only management query, prefer planned_agent unless the answer is already fully available in the provided context.
- Use skill_tasks for clear management tasks such as creating/updating/deleting/configuring trader/model/exchange/strategy.
- If the user request contains multiple management operations, include multiple tasks and depends_on where a later task needs an earlier result.
- If the request contains exactly one management operation, include exactly one task.
- Use planned_agent for multi-step, tool-heavy, market/account, diagnosis, or ambiguous tasks.
- For model_management, "provider" means AI vendor, never an exchange.
- Current references are context only. Do not copy them into extracted_data unless the user explicitly says this/current/that previous one.
- extracted_data must contain only concrete facts from the current user message.
- reply_to_user must be concise and in the user's language.
- confidence should reflect how safe it is to execute this decision without the old router fallback.
Return JSON with this exact shape:
{"topic_intent":"continue_active|start_new|resume_snapshot|cancel|instant_reply","business_action":"direct_answer|skill_tasks|new_skill|continue_skill|planned_agent|none","target_skill":"","tasks":[{"id":"task_1","skill":"","action":"","request":"","depends_on":[]}],"target_snapshot_id":"","context_mode":"use_current|fresh_context|resume_snapshot","extracted_data":{},"reply_to_user":"","confidence":0.0}`)
userPrompt := fmt.Sprintf("Language: %s\nUser message: %s\n\nPrevious assistant reply:\n%s\n\nCurrent reference summary:\n%s\n\nActive flow summary:\n%s\n\nSuspended snapshots JSON:\n%s\n\nRecent conversation:\n%s\n\nManagement domain primer:\n%s\n\nActive task details:\n%s\n",
lang,
text,
defaultIfEmpty(previousAssistantReply, "(empty)"),
currentRefs,
activeFlowSummary,
defaultIfEmpty(string(snapshotJSON), "[]"),
recentConversation,
defaultIfEmpty(buildManagementDomainPrimer(lang), "(empty)"),
activeTaskDetails,
)
return systemPrompt, userPrompt
}
func (a *Agent) executeUnifiedTurnDecision(ctx context.Context, storeUserID string, userID int64, lang, text string, decision unifiedTurnDecision, onEvent func(event, data string)) (string, bool, error) {
if session, ok := a.activeStrategyCreateSession(userID); ok && strategyCreateConfirmationReply(text) {
return a.driveActiveSession(ctx, storeUserID, userID, lang, text, session, onEvent)
}
switch decision.TopicIntent {
case "cancel":
a.clearPendingProposalSession(userID)
if a.hasAnyActiveContext(userID) {
a.clearActiveSkillSession(userID)
a.clearAnyActiveContext(userID)
return a.maybeOfferParentTaskAfterCancel(userID, lang), true, nil
}
if decision.BusinessAction == "direct_answer" && decision.ReplyToUser != "" {
emitBrainReply(onEvent, decision.ReplyToUser)
a.recordSkillInteraction(userID, text, decision.ReplyToUser)
return decision.ReplyToUser, true, nil
}
return "", false, nil
case "resume_snapshot":
a.clearPendingProposalSession(userID)
if a.tryRestoreSuspendedTaskAfterSwitch(userID, text, decision.TargetSnapshotID) {
if decision.BusinessAction == "planned_agent" {
answer, err := a.runPlannedAgentWithContextMode(ctx, storeUserID, userID, lang, text, "use_current", onEvent)
return answer, true, err
}
return a.tryMinimalBrain(ctx, storeUserID, userID, lang, text, onEvent)
}
return "", false, nil
}
if decision.TopicIntent == "continue_active" {
if _, hasProposal := a.getPendingProposalSession(userID); hasProposal && !a.hasAnyActiveContext(userID) {
return a.handlePendingProposalResponse(ctx, storeUserID, userID, lang, text, onEvent)
}
if activeSession, hasActive := a.getActiveSkillSession(userID); hasActive {
decision.ExtractedData = filterExtractedDataForActiveSession(activeSession, decision.ExtractedData, lang)
mergeExtractedData(&activeSession, decision.ExtractedData)
return a.driveActiveSession(ctx, storeUserID, userID, lang, text, activeSession, onEvent)
}
if a.hasAnyActiveContext(userID) {
return a.tryStatePriorityPath(ctx, storeUserID, userID, lang, text, onEvent)
}
}
switch decision.BusinessAction {
case "direct_answer":
if decision.ReplyToUser == "" {
return "", false, nil
}
if decision.TopicIntent == "instant_reply" && a.hasAnyActiveContext(userID) {
return a.replyToActiveFlowInstantReply(ctx, userID, lang, text, onEvent), true, nil
}
if guarded, blocked := guardUnsupportedAsyncPromise(lang, decision.ReplyToUser); blocked {
decision.ReplyToUser = guarded
}
emitBrainReply(onEvent, decision.ReplyToUser)
a.recordSkillInteraction(userID, text, decision.ReplyToUser)
a.runPostResponseMaintenanceAsync(userID)
return decision.ReplyToUser, true, nil
case "new_skill":
if len(decision.Tasks) > 0 {
return a.executeUnifiedSkillTasks(ctx, storeUserID, userID, lang, text, decision, onEvent)
}
skill, action := parseTargetSkill(decision.TargetSkill)
if skill == "" {
return "", false, nil
}
if a.hasAnyActiveContext(userID) && decision.ContextMode == "fresh_context" {
if !a.suspendActiveContexts(userID, lang) {
a.clearSkillSession(userID)
a.clearWorkflowSession(userID)
a.clearExecutionState(userID)
}
a.clearActiveSkillSession(userID)
}
session := newActiveSkillSession(userID, skill, action)
session.Goal = strings.TrimSpace(text)
decision.ExtractedData = filterExtractedDataForActiveSession(session, decision.ExtractedData, lang)
mergeExtractedData(&session, decision.ExtractedData)
return a.driveActiveSession(ctx, storeUserID, userID, lang, text, session, onEvent)
case "skill_tasks":
return a.executeUnifiedSkillTasks(ctx, storeUserID, userID, lang, text, decision, onEvent)
case "continue_skill":
activeSession, hasActive := a.getActiveSkillSession(userID)
if !hasActive {
return "", false, nil
}
decision.ExtractedData = filterExtractedDataForActiveSession(activeSession, decision.ExtractedData, lang)
mergeExtractedData(&activeSession, decision.ExtractedData)
return a.driveActiveSession(ctx, storeUserID, userID, lang, text, activeSession, onEvent)
case "planned_agent":
if session, ok := a.activeStrategyCreateSession(userID); ok {
return a.driveActiveSession(ctx, storeUserID, userID, lang, text, session, onEvent)
}
contextMode := decision.ContextMode
if contextMode == "resume_snapshot" {
contextMode = "use_current"
}
answer, err := a.runPlannedAgentWithContextMode(ctx, storeUserID, userID, lang, text, contextMode, onEvent)
return answer, true, err
case "none":
return "", false, nil
default:
return "", false, nil
}
}
func (a *Agent) executeUnifiedSkillTasks(ctx context.Context, storeUserID string, userID int64, lang, text string, decision unifiedTurnDecision, onEvent func(event, data string)) (string, bool, error) {
tasks := normalizeWorkflowDecomposition(workflowDecomposition{Tasks: decision.Tasks}).Tasks
if len(tasks) == 0 {
return "", false, nil
}
if task, ok := strategyCreateWorkflowTask(tasks); ok {
if a.hasAnyActiveContext(userID) && decision.ContextMode == "fresh_context" {
if !a.suspendActiveContexts(userID, lang) {
a.clearSkillSession(userID)
a.clearWorkflowSession(userID)
a.clearExecutionState(userID)
}
a.clearActiveSkillSession(userID)
}
a.clearWorkflowSession(userID)
a.clearExecutionState(userID)
session := newActiveSkillSession(userID, task.Skill, task.Action)
session.Goal = defaultIfEmpty(strings.TrimSpace(task.Request), strings.TrimSpace(text))
decision.ExtractedData = filterExtractedDataForActiveSession(session, decision.ExtractedData, lang)
mergeExtractedData(&session, decision.ExtractedData)
return a.driveActiveSession(ctx, storeUserID, userID, lang, defaultIfEmpty(task.Request, text), session, onEvent)
}
if a.hasAnyActiveContext(userID) && decision.ContextMode == "fresh_context" {
if !a.suspendActiveContexts(userID, lang) {
a.clearSkillSession(userID)
a.clearWorkflowSession(userID)
a.clearExecutionState(userID)
}
a.clearActiveSkillSession(userID)
}
if len(tasks) == 1 {
task := tasks[0]
session := newActiveSkillSession(userID, task.Skill, task.Action)
session.Goal = defaultIfEmpty(strings.TrimSpace(task.Request), strings.TrimSpace(text))
decision.ExtractedData = filterExtractedDataForActiveSession(session, decision.ExtractedData, lang)
mergeExtractedData(&session, decision.ExtractedData)
return a.driveActiveSession(ctx, storeUserID, userID, lang, defaultIfEmpty(task.Request, text), session, onEvent)
}
session := normalizeWorkflowSession(WorkflowSession{
UserID: userID,
OriginalRequest: strings.TrimSpace(text),
Tasks: tasks,
})
if len(session.Tasks) == 0 {
return "", false, nil
}
a.saveWorkflowSession(userID, session)
return a.maybeAdvanceWorkflow(ctx, storeUserID, userID, lang, session, onEvent)
}
func strategyCreateWorkflowTask(tasks []WorkflowTask) (WorkflowTask, bool) {
for _, task := range tasks {
if strings.TrimSpace(task.Skill) == "strategy_management" && strings.TrimSpace(task.Action) == "create" {
return task, true
}
}
return WorkflowTask{}, false
}
func buildTopLevelActiveFlowSummary(lang string, skill skillSession, activeTask ActiveSkillSession, hasActiveTask bool, workflow WorkflowSession, state ExecutionState, pendingProposal PendingProposalSession, hasPendingProposal bool) string {
lines := make([]string, 0, 8)
if hasActiveTask {
lines = append(lines, fmt.Sprintf("Active task session: %s / %s / phase=%s", activeTask.SkillName, activeTask.ActionName, defaultIfEmpty(activeTask.LegacyPhase, "collecting")))
if strings.TrimSpace(activeTask.Goal) != "" {
lines = append(lines, "Active task goal: "+strings.TrimSpace(activeTask.Goal))
}
if activeTask.PendingHint != nil && strings.TrimSpace(activeTask.PendingHint.Prompt) != "" {
lines = append(lines, "Active task pending hint: "+strings.TrimSpace(activeTask.PendingHint.Prompt))
}
if len(activeTask.CollectedFields) > 0 {
fieldsJSON, _ := json.Marshal(activeTask.CollectedFields)
lines = append(lines, "Active task collected_fields: "+string(fieldsJSON))
}
}
if strings.TrimSpace(skill.Name) != "" {
lines = append(lines, fmt.Sprintf("Active skill session: %s / %s / phase=%s", skill.Name, skill.Action, defaultIfEmpty(skill.Phase, "collecting")))
if routing := buildSkillActionRoutingSummary(lang, skill); routing != "" {
lines = append(lines, routing)
}
}
if hasActiveWorkflowSession(workflow) {
lines = append(lines, fmt.Sprintf("Active workflow: original_request=%s pending_tasks=%d", workflow.OriginalRequest, countPendingWorkflowTasks(workflow)))
}
if hasActiveExecutionState(state) {
lines = append(lines, fmt.Sprintf("Active execution state: status=%s goal=%s", state.Status, state.Goal))
if state.Waiting != nil && strings.TrimSpace(state.Waiting.Question) != "" {
lines = append(lines, "Waiting question: "+strings.TrimSpace(state.Waiting.Question))
}
}
if hasPendingProposal {
lines = append(lines, "Pending assistant proposal awaiting user response.")
if strings.TrimSpace(pendingProposal.SourceUserText) != "" {
lines = append(lines, "Proposal source request: "+strings.TrimSpace(pendingProposal.SourceUserText))
}
lines = append(lines, "Proposal text: "+strings.TrimSpace(pendingProposal.ProposalText))
}
return strings.Join(lines, "\n")
}
func (a *Agent) handlePendingProposalResponse(ctx context.Context, storeUserID string, userID int64, lang, text string, onEvent func(event, data string)) (string, bool, error) {
proposal, ok := a.getPendingProposalSession(userID)
if !ok {
return "", false, nil
}
answer, err := a.runPlannedAgent(ctx, storeUserID, userID, lang, fmt.Sprintf("The user is replying to the assistant's previous proposal.\n\nOriginal user request:\n%s\n\nPrevious assistant proposal:\n%s\n\nCurrent user reply:\n%s", proposal.SourceUserText, proposal.ProposalText, text), onEvent)
if err == nil && strings.TrimSpace(answer) != "" {
a.clearPendingProposalSession(userID)
}
return answer, true, err
}
func countPendingWorkflowTasks(session WorkflowSession) int {
count := 0
for _, task := range session.Tasks {
switch task.Status {
case workflowTaskPending, workflowTaskRunning:
count++
}
}
return count
}
func buildCurrentReferenceSummary(lang string, refs *CurrentReferences) string {
if refs == nil {
if lang == "zh" {
return "- 当前没有明确锁定的操作对象。"
}
return "- No current entity references are locked yet."
}
lines := make([]string, 0, 4)
appendLine := func(kind string, ref *EntityReference) {
if ref == nil {
return
}
name := strings.TrimSpace(defaultIfEmpty(ref.Name, ref.ID))
if name == "" {
return
}
source := formatReferenceSourceLabel(lang, ref.Source)
if lang == "zh" {
line := fmt.Sprintf("- 当前%s: %s", referenceKindDisplayName(lang, kind), name)
if source != "" {
line += fmt.Sprintf("(来源: %s", source)
}
if strings.TrimSpace(ref.ID) != "" && strings.TrimSpace(ref.ID) != name {
line += fmt.Sprintf(" [id=%s]", ref.ID)
}
lines = append(lines, line)
return
}
line := fmt.Sprintf("- Current %s: %s", referenceKindDisplayName(lang, kind), name)
if source != "" {
line += fmt.Sprintf(" (source: %s)", source)
}
if strings.TrimSpace(ref.ID) != "" && strings.TrimSpace(ref.ID) != name {
line += fmt.Sprintf(" [id=%s]", ref.ID)
}
lines = append(lines, line)
}
appendLine("strategy", refs.Strategy)
appendLine("trader", refs.Trader)
appendLine("model", refs.Model)
appendLine("exchange", refs.Exchange)
if len(lines) == 0 {
if lang == "zh" {
return "- 当前没有明确锁定的操作对象。"
}
return "- No current entity references are locked yet."
}
return strings.Join(lines, "\n")
}
func formatReferenceSourceLabel(lang, source string) string {
source = strings.TrimSpace(source)
if source == "" {
return ""
}
if lang == "zh" {
switch source {
case "user_mention":
return "用户提及"
case "tool_output":
return "工具结果"
case "inferred_from_context":
return "上下文推断"
default:
return source
}
}
switch source {
case "user_mention":
return "user mention"
case "tool_output":
return "tool output"
case "inferred_from_context":
return "context inference"
default:
return source
}
}
func hasAnyActiveContext(a *Agent, userID int64) bool {
if a == nil {
return false
}
if _, ok := a.getActiveSkillSession(userID); ok {
return true
}
return a.hasActiveSkillSession(userID) || hasActiveWorkflowSession(a.getWorkflowSession(userID)) || hasActiveExecutionState(a.getExecutionState(userID))
}
func (a *Agent) clearAnyActiveContext(userID int64) bool {
cleared := false
if _, ok := a.getActiveSkillSession(userID); ok {
a.clearActiveSkillSession(userID)
cleared = true
}
if a.hasActiveSkillSession(userID) {
a.clearSkillSession(userID)
cleared = true
}
if hasActiveWorkflowSession(a.getWorkflowSession(userID)) {
a.clearWorkflowSession(userID)
cleared = true
}
if hasActiveExecutionState(a.getExecutionState(userID)) {
a.clearExecutionState(userID)
cleared = true
}
if cleared {
a.SnapshotManager(userID).Clear()
}
return cleared
}
func skillDataForAction(storeUserID, skill, action string, a *Agent) map[string]any {
var raw string
switch skill {
case "trader_management":
if strings.HasPrefix(action, "query") {
raw = a.toolListTraders(storeUserID)
}
case "exchange_management":
if strings.HasPrefix(action, "query") {
raw = a.toolGetExchangeConfigs(storeUserID)
}
case "model_management":
if strings.HasPrefix(action, "query") {
raw = a.toolGetModelConfigs(storeUserID)
}
case "strategy_management":
if strings.HasPrefix(action, "query") {
raw = a.toolGetStrategies(storeUserID)
}
}
if strings.TrimSpace(raw) == "" {
return nil
}
var data map[string]any
if err := json.Unmarshal([]byte(raw), &data); err != nil {
return nil
}
return data
}
func mustMarshalJSON(v any) string {
data, _ := json.Marshal(v)
return string(data)
}
func applyTraderQueryFilter(lang, fallback, raw, filter string) string {
filter = strings.TrimSpace(strings.ToLower(filter))
if filter == "" {
return fallback
}
var payload struct {
Traders []struct {
Name string `json:"name"`
IsRunning bool `json:"is_running"`
} `json:"traders"`
}
if err := json.Unmarshal([]byte(raw), &payload); err != nil {
return fallback
}
switch filter {
case "running_only":
names := make([]string, 0, len(payload.Traders))
for _, trader := range payload.Traders {
if trader.IsRunning {
names = append(names, strings.TrimSpace(trader.Name))
}
}
if lang == "zh" {
if len(names) == 0 {
return "当前没有运行中的交易员。"
}
return fmt.Sprintf("当前有 %d 个运行中的交易员:%s。", len(names), strings.Join(names, "、"))
}
if len(names) == 0 {
return "There are no running traders right now."
}
return fmt.Sprintf("There are %d running traders right now: %s.", len(names), strings.Join(names, ", "))
case "stopped_only":
names := make([]string, 0, len(payload.Traders))
for _, trader := range payload.Traders {
if !trader.IsRunning {
names = append(names, strings.TrimSpace(trader.Name))
}
}
if lang == "zh" {
if len(names) == 0 {
return "当前没有已停止的交易员。"
}
return fmt.Sprintf("当前有 %d 个未运行的交易员:%s。", len(names), strings.Join(names, "、"))
}
if len(names) == 0 {
return "There are no stopped traders right now."
}
return fmt.Sprintf("There are %d stopped traders right now: %s.", len(names), strings.Join(names, ", "))
default:
return fallback
}
}

View File

@@ -1,107 +0,0 @@
package agent
import (
"encoding/json"
"io"
"net/http"
"strings"
"testing"
)
type roundTripFunc func(*http.Request) (*http.Response, error)
func (f roundTripFunc) RoundTrip(req *http.Request) (*http.Response, error) {
return f(req)
}
func TestToolGetMarketSnapshotReturnsRealtimeAnalysisContext(t *testing.T) {
prevBaseURL := binanceFuturesAPIBaseURL
prevClient := marketDataHTTPClient
binanceFuturesAPIBaseURL = "https://example.test"
marketDataHTTPClient = &http.Client{
Transport: roundTripFunc(func(req *http.Request) (*http.Response, error) {
body := ""
switch {
case strings.HasPrefix(req.URL.Path, "/fapi/v1/ticker/24hr"):
body = `{"symbol":"BTCUSDT","lastPrice":"65000","priceChange":"1200","priceChangePercent":"1.88","highPrice":"66000","lowPrice":"63800","volume":"12345","quoteVolume":"800000000","count":98765}`
case strings.HasPrefix(req.URL.Path, "/fapi/v1/premiumIndex"):
body = `{"symbol":"BTCUSDT","markPrice":"65010","indexPrice":"64990","lastFundingRate":"0.00010000","nextFundingTime":1710000000000}`
case strings.HasPrefix(req.URL.Path, "/fapi/v1/openInterest"):
body = `{"symbol":"BTCUSDT","openInterest":"45678.9","time":1710000000000}`
case strings.HasPrefix(req.URL.Path, "/fapi/v1/klines"):
body = `[[1710000000000,"64000","65100","63900","64500","100",1710000899999],[1710000900000,"64500","65500","64400","65000","120",1710001799999]]`
default:
body = `{"error":"not found"}`
}
return &http.Response{
StatusCode: http.StatusOK,
Header: make(http.Header),
Body: io.NopCloser(strings.NewReader(body)),
}, nil
}),
}
defer func() {
binanceFuturesAPIBaseURL = prevBaseURL
marketDataHTTPClient = prevClient
}()
a := New(nil, nil, DefaultConfig(), nil)
raw := a.toolGetMarketSnapshot(`{"symbol":"BTC","interval":"15m","limit":2}`)
var resp struct {
Symbol string `json:"symbol"`
Price float64 `json:"price"`
Ticker24h struct {
PriceChangePercent float64 `json:"price_change_percent"`
} `json:"ticker_24h"`
PerpMetrics struct {
FundingRate float64 `json:"funding_rate"`
OpenInterest float64 `json:"open_interest"`
} `json:"perp_metrics"`
KlineSnapshot struct {
Interval string `json:"interval"`
Limit int `json:"limit"`
PeriodChangePercent float64 `json:"period_change_percent"`
RecentKlines []map[string]any `json:"recent_klines"`
} `json:"kline_snapshot"`
Error string `json:"error"`
}
if err := json.Unmarshal([]byte(raw), &resp); err != nil {
t.Fatalf("failed to parse tool response: %v\nraw=%s", err, raw)
}
if resp.Error != "" {
t.Fatalf("unexpected tool error: %s", resp.Error)
}
if resp.Symbol != "BTCUSDT" {
t.Fatalf("expected normalized symbol BTCUSDT, got %s", resp.Symbol)
}
if resp.Price != 65000 {
t.Fatalf("expected price 65000, got %v", resp.Price)
}
if resp.Ticker24h.PriceChangePercent != 1.88 {
t.Fatalf("expected 24h change 1.88, got %v", resp.Ticker24h.PriceChangePercent)
}
if resp.PerpMetrics.FundingRate != 0.0001 {
t.Fatalf("expected funding rate 0.0001, got %v", resp.PerpMetrics.FundingRate)
}
if resp.PerpMetrics.OpenInterest != 45678.9 {
t.Fatalf("expected open interest 45678.9, got %v", resp.PerpMetrics.OpenInterest)
}
if resp.KlineSnapshot.Interval != "15m" || resp.KlineSnapshot.Limit != 2 {
t.Fatalf("unexpected kline snapshot metadata: %+v", resp.KlineSnapshot)
}
if len(resp.KlineSnapshot.RecentKlines) != 2 {
t.Fatalf("expected 2 klines, got %d", len(resp.KlineSnapshot.RecentKlines))
}
if resp.KlineSnapshot.PeriodChangePercent <= 0 {
t.Fatalf("expected positive period change, got %v", resp.KlineSnapshot.PeriodChangePercent)
}
}
func TestToolGetMarketSnapshotRejectsStockSymbols(t *testing.T) {
a := New(nil, nil, DefaultConfig(), nil)
raw := a.toolGetMarketSnapshot(`{"symbol":"AAPL"}`)
if !strings.Contains(raw, "currently supports crypto symbols only") {
t.Fatalf("expected stock rejection, got: %s", raw)
}
}

View File

@@ -1,468 +0,0 @@
package agent
import (
"context"
"encoding/json"
"fmt"
"strings"
"time"
"nofx/mcp"
)
const (
recentConversationRounds = 6
recentConversationMessages = recentConversationRounds * 2
chatHistoryMaxTurns = recentConversationMessages * 2 // fallback cap when compression is unavailable
taskStateSummaryTokenLimit = 1200
shortTermCompressThreshold = 900
incrementalTaskStateMessages = 6
incrementalTaskStateTokenLimit = 500
)
type DecisionMemory struct {
Action string `json:"action,omitempty"`
Reason string `json:"reason,omitempty"`
StillValid bool `json:"still_valid,omitempty"`
Timestamp string `json:"timestamp,omitempty"`
}
type TaskState struct {
CurrentGoal string `json:"current_goal,omitempty"`
ActiveFlow string `json:"active_flow,omitempty"`
// OpenLoops stores only high-level unresolved issues that still matter across turns.
// Step-level pending work belongs in ExecutionState, not here.
OpenLoops []string `json:"open_loops,omitempty"`
ImportantFacts []string `json:"important_facts,omitempty"`
LastDecision *DecisionMemory `json:"last_decision,omitempty"`
UpdatedAt string `json:"updated_at,omitempty"`
}
func TaskStateConfigKey(userID int64) string {
return fmt.Sprintf("agent_task_state_%d", userID)
}
func (a *Agent) getTaskState(userID int64) TaskState {
if a.store == nil {
return TaskState{}
}
raw, err := a.store.GetSystemConfig(TaskStateConfigKey(userID))
if err != nil {
a.logger.Warn("failed to load task state", "error", err, "user_id", userID)
return TaskState{}
}
raw = strings.TrimSpace(raw)
if raw == "" {
return TaskState{}
}
var state TaskState
if err := json.Unmarshal([]byte(raw), &state); err != nil {
a.logger.Warn("failed to parse task state", "error", err, "user_id", userID)
return TaskState{}
}
return normalizeTaskState(state)
}
func (a *Agent) saveTaskState(userID int64, state TaskState) error {
if a.store == nil {
return fmt.Errorf("store unavailable")
}
state = normalizeTaskState(state)
if isZeroTaskState(state) {
return a.store.SetSystemConfig(TaskStateConfigKey(userID), "")
}
data, err := json.Marshal(state)
if err != nil {
return err
}
return a.store.SetSystemConfig(TaskStateConfigKey(userID), string(data))
}
func (a *Agent) clearTaskState(userID int64) {
if a.store == nil {
return
}
if err := a.store.SetSystemConfig(TaskStateConfigKey(userID), ""); err != nil {
a.logger.Warn("failed to clear task state", "error", err, "user_id", userID)
}
}
func normalizeTaskState(state TaskState) TaskState {
state.CurrentGoal = strings.TrimSpace(state.CurrentGoal)
state.ActiveFlow = strings.TrimSpace(state.ActiveFlow)
state.OpenLoops = filterTaskStateOpenLoops(cleanStringList(state.OpenLoops))
state.ImportantFacts = cleanStringList(state.ImportantFacts)
if state.LastDecision != nil {
state.LastDecision.Action = strings.TrimSpace(state.LastDecision.Action)
state.LastDecision.Reason = strings.TrimSpace(state.LastDecision.Reason)
state.LastDecision.Timestamp = strings.TrimSpace(state.LastDecision.Timestamp)
if state.LastDecision.Timestamp == "" && (state.LastDecision.Action != "" || state.LastDecision.Reason != "") {
state.LastDecision.Timestamp = time.Now().UTC().Format(time.RFC3339)
}
if state.LastDecision.Action == "" && state.LastDecision.Reason == "" {
state.LastDecision = nil
}
}
if state.UpdatedAt == "" && !isZeroTaskState(state) {
state.UpdatedAt = time.Now().UTC().Format(time.RFC3339)
}
return state
}
func isZeroTaskState(state TaskState) bool {
return state.CurrentGoal == "" &&
state.ActiveFlow == "" &&
len(state.OpenLoops) == 0 &&
len(state.ImportantFacts) == 0 &&
state.LastDecision == nil
}
func cleanStringList(values []string) []string {
if len(values) == 0 {
return nil
}
out := make([]string, 0, len(values))
seen := make(map[string]struct{}, len(values))
for _, v := range values {
v = strings.TrimSpace(v)
if v == "" {
continue
}
key := strings.ToLower(v)
if _, ok := seen[key]; ok {
continue
}
seen[key] = struct{}{}
out = append(out, v)
}
if len(out) == 0 {
return nil
}
return out
}
func filterTaskStateOpenLoops(values []string) []string {
if len(values) == 0 {
return nil
}
rejectedPrefixes := []string{
"wait for ",
"waiting for ",
"ask for ",
"call ",
"run ",
"execute ",
"invoke ",
"use tool",
"step ",
}
rejectedContains := []string{
"current step",
"tool call",
"api key",
"api secret",
"secret key",
"passphrase",
"model id",
"exchange id",
}
filtered := make([]string, 0, len(values))
for _, value := range values {
lower := strings.ToLower(strings.TrimSpace(value))
if lower == "" {
continue
}
if matchesAnyPrefix(lower, rejectedPrefixes) || matchesAnyContains(lower, rejectedContains) {
continue
}
filtered = append(filtered, value)
}
if len(filtered) == 0 {
return nil
}
return filtered
}
func matchesAnyPrefix(value string, prefixes []string) bool {
for _, prefix := range prefixes {
if strings.HasPrefix(value, prefix) {
return true
}
}
return false
}
func matchesAnyContains(value string, patterns []string) bool {
for _, pattern := range patterns {
if strings.Contains(value, pattern) {
return true
}
}
return false
}
func buildTaskStateContext(state TaskState) string {
state = normalizeTaskState(state)
if isZeroTaskState(state) {
return ""
}
var sb strings.Builder
sb.WriteString("[Structured Task State - durable, non-derivable context]\n")
if state.CurrentGoal != "" {
sb.WriteString("- Current goal: ")
sb.WriteString(state.CurrentGoal)
sb.WriteString("\n")
}
if state.ActiveFlow != "" {
sb.WriteString("- Active flow: ")
sb.WriteString(state.ActiveFlow)
sb.WriteString("\n")
}
for _, loop := range state.OpenLoops {
sb.WriteString("- High-level open loop: ")
sb.WriteString(loop)
sb.WriteString("\n")
}
for _, fact := range state.ImportantFacts {
sb.WriteString("- Important fact: ")
sb.WriteString(fact)
sb.WriteString("\n")
}
if state.LastDecision != nil {
sb.WriteString("- Last decision: ")
sb.WriteString(state.LastDecision.Action)
if state.LastDecision.Reason != "" {
sb.WriteString(" | reason: ")
sb.WriteString(state.LastDecision.Reason)
}
if state.LastDecision.StillValid {
sb.WriteString(" | still valid")
}
sb.WriteString("\n")
}
return strings.TrimSpace(sb.String())
}
func estimateChatMessagesTokens(msgs []chatMessage) int {
total := 0
for _, msg := range msgs {
total += len([]rune(msg.Content))/3 + 10
}
return total
}
func formatChatMessagesForSummary(msgs []chatMessage) string {
var sb strings.Builder
for _, msg := range msgs {
if strings.TrimSpace(msg.Content) == "" {
continue
}
role := "User"
if msg.Role == "assistant" {
role = "Assistant"
}
sb.WriteString(role)
sb.WriteString(": ")
sb.WriteString(msg.Content)
sb.WriteString("\n")
}
return strings.TrimSpace(sb.String())
}
func (a *Agent) maybeCompressHistory(ctx context.Context, userID int64) {
if a.aiClient == nil || a.history == nil {
return
}
msgs := a.history.Get(userID)
if len(msgs) <= recentConversationMessages {
return
}
if estimateChatMessagesTokens(msgs) <= shortTermCompressThreshold {
return
}
splitAt := len(msgs) - recentConversationMessages
if splitAt <= 0 {
return
}
oldPart := msgs[:splitAt]
recentPart := msgs[splitAt:]
existingState := a.getTaskState(userID)
updatedState, err := a.summarizeConversationToTaskState(ctx, userID, existingState, oldPart)
if err != nil {
a.logger.Warn("failed to compress chat history", "error", err, "user_id", userID)
return
}
if err := a.saveTaskState(userID, updatedState); err != nil {
a.log().Warn("failed to persist task state", "error", err, "user_id", userID)
return
}
a.history.Replace(userID, recentPart)
}
func (a *Agent) maybeUpdateTaskStateIncrementally(ctx context.Context, userID int64) {
if a.aiClient == nil || a.history == nil {
return
}
msgs := a.history.Get(userID)
if len(msgs) < 2 {
return
}
window := msgs
if len(window) > incrementalTaskStateMessages {
window = window[len(window)-incrementalTaskStateMessages:]
}
existingState := a.getTaskState(userID)
updatedState, err := a.summarizeRecentConversationToTaskState(ctx, userID, existingState, window)
if err != nil {
a.log().Warn("failed to incrementally update task state", "error", err, "user_id", userID)
return
}
if err := a.saveTaskState(userID, updatedState); err != nil {
a.log().Warn("failed to persist incremental task state", "error", err, "user_id", userID)
}
}
func (a *Agent) summarizeConversationToTaskState(ctx context.Context, userID int64, existing TaskState, oldPart []chatMessage) (TaskState, error) {
transcript := formatChatMessagesForSummary(oldPart)
if transcript == "" {
return normalizeTaskState(existing), nil
}
existingJSON, err := json.Marshal(normalizeTaskState(existing))
if err != nil {
return TaskState{}, err
}
systemPrompt := `You maintain structured task state for a trading assistant.
Update the task state using the existing state plus archived dialogue.
Return JSON only. Do not return markdown.
Rules:
- Keep only durable, non-derivable context useful for future turns.
- Do not store market prices, balances, positions, or anything tools can fetch again.
- Do not store chit-chat or repeated wording.
- current_goal: the user's active objective, if any.
- active_flow: a named flow such as onboarding, trading_confirmation, market_analysis, or empty.
- open_loops: only high-level unresolved issues that still matter across turns.
- Do not put execution-step pending work into open_loops.
- Bad open_loops examples: "wait for API secret", "call get_exchange_configs", "run step 2", "ask user for exchange_id".
- Good open_loops examples: "finish trader setup after external configuration is ready", "user still wants to complete onboarding".
- important_facts: non-derivable facts worth remembering briefly.
- last_decision: keep only one current relevant decision; omit if none.
- Replace stale items instead of appending blindly.
- If a field is no longer relevant, return it empty or omit it.
- Never invent facts.`
userPrompt := fmt.Sprintf("Existing task state JSON:\n%s\n\nArchived dialogue to compress:\n%s\n\nReturn the new task state JSON with this exact shape:\n{\"current_goal\":\"\",\"active_flow\":\"\",\"open_loops\":[],\"important_facts\":[],\"last_decision\":{\"action\":\"\",\"reason\":\"\",\"still_valid\":false,\"timestamp\":\"\"},\"updated_at\":\"\"}", string(existingJSON), transcript)
req := &mcp.Request{
Messages: []mcp.Message{
mcp.NewSystemMessage(systemPrompt),
mcp.NewUserMessage(userPrompt),
},
Ctx: ctx,
MaxTokens: intPtr(taskStateSummaryTokenLimit),
}
resp, err := a.aiClient.CallWithRequest(req)
if err != nil {
return TaskState{}, err
}
state, err := parseTaskStateJSON(resp)
if err != nil {
return TaskState{}, err
}
state = normalizeTaskState(state)
a.log().Info("compressed chat history into task state", "user_id", userID, "archived_messages", len(oldPart))
return state, nil
}
func (a *Agent) summarizeRecentConversationToTaskState(ctx context.Context, userID int64, existing TaskState, recentPart []chatMessage) (TaskState, error) {
transcript := formatChatMessagesForSummary(recentPart)
if transcript == "" {
return normalizeTaskState(existing), nil
}
existingJSON, err := json.Marshal(normalizeTaskState(existing))
if err != nil {
return TaskState{}, err
}
systemPrompt := `You maintain structured task state for a trading assistant.
Update the task state incrementally using the existing state plus the latest conversation window.
Return JSON only. Do not return markdown.
Rules:
- Capture newly confirmed facts from the latest few turns immediately.
- Preserve important existing facts that still matter; replace stale items when contradicted.
- Keep only durable, non-derivable context useful for the next turns.
- current_goal: the user's active objective right now.
- active_flow: a named flow such as onboarding, trading_confirmation, market_analysis, strategy_debugging, or empty.
- open_loops: only high-level unresolved issues that still matter across turns.
- important_facts: include recently confirmed concrete facts, such as the current trader under discussion, the reported runtime error, the user's claimed config value, or the environment where the issue occurs.
- Do not store execution-step pending work or tool instructions.
- Do not store market prices, balances, or anything tools can fetch again.
- Keep last_decision only if there is a current relevant decision; omit it otherwise.
- Never invent facts.`
userPrompt := fmt.Sprintf("Existing task state JSON:\n%s\n\nLatest conversation window:\n%s\n\nReturn the updated task state JSON with this exact shape:\n{\"current_goal\":\"\",\"active_flow\":\"\",\"open_loops\":[],\"important_facts\":[],\"last_decision\":{\"action\":\"\",\"reason\":\"\",\"still_valid\":false,\"timestamp\":\"\"},\"updated_at\":\"\"}", string(existingJSON), transcript)
req := &mcp.Request{
Messages: []mcp.Message{
mcp.NewSystemMessage(systemPrompt),
mcp.NewUserMessage(userPrompt),
},
Ctx: ctx,
MaxTokens: intPtr(incrementalTaskStateTokenLimit),
}
resp, err := a.aiClient.CallWithRequest(req)
if err != nil {
return TaskState{}, err
}
state, err := parseTaskStateJSON(resp)
if err != nil {
return TaskState{}, err
}
state = normalizeTaskState(state)
a.log().Info("incrementally refreshed task state", "user_id", userID, "window_messages", len(recentPart))
return state, nil
}
func parseTaskStateJSON(raw string) (TaskState, error) {
raw = strings.TrimSpace(raw)
raw = strings.TrimPrefix(raw, "```json")
raw = strings.TrimPrefix(raw, "```")
raw = strings.TrimSuffix(raw, "```")
raw = strings.TrimSpace(raw)
var state TaskState
if err := json.Unmarshal([]byte(raw), &state); err == nil {
return state, nil
}
start := strings.Index(raw, "{")
end := strings.LastIndex(raw, "}")
if start >= 0 && end > start {
if err := json.Unmarshal([]byte(raw[start:end+1]), &state); err == nil {
return state, nil
}
}
return TaskState{}, fmt.Errorf("invalid task state json")
}
func intPtr(v int) *int {
return &v
}

View File

@@ -1,75 +0,0 @@
package agent
import (
"log/slog"
"path/filepath"
"strings"
"testing"
"nofx/store"
)
func TestHandleModelCreateSkillAsksProviderFirstWithClaw402Recommendation(t *testing.T) {
dbPath := filepath.Join(t.TempDir(), "agent-model-create.db")
st, err := store.New(dbPath)
if err != nil {
t.Fatalf("create store: %v", err)
}
a := New(nil, st, DefaultConfig(), slog.Default())
reply := a.handleModelCreateSkill("default", 42, "zh", "请帮我创建一个模型", skillSession{})
for _, want := range []string{
"还缺这些字段:模型提供商",
"可选模型 provider",
"推荐 `claw402`",
"并列可选",
"按次付费",
"Base USDC 钱包支付",
"直接创建 Base 钱包",
"直接扫码充值/支付",
} {
if !strings.Contains(reply, want) {
t.Fatalf("expected reply to contain %q, got: %s", want, reply)
}
}
for _, unexpected := range []string{
"还缺这些字段模型提供商、API Key",
"还缺这些字段:模型提供商、钱包私钥",
"还缺这些字段模型提供商、wallet private key",
} {
if strings.Contains(reply, unexpected) {
t.Fatalf("provider-first reply should not ask for credentials yet: %s", reply)
}
}
}
func TestHandleModelCreateSkillUsesCollectedClaw402PrivateKey(t *testing.T) {
dbPath := filepath.Join(t.TempDir(), "agent-model-create-claw402.db")
st, err := store.New(dbPath)
if err != nil {
t.Fatalf("create store: %v", err)
}
a := New(nil, st, DefaultConfig(), slog.Default())
session := skillSession{
Name: "model_management",
Action: "create",
Phase: "collecting",
Fields: map[string]string{
"provider": "claw402",
"name": "Claw402 (Base USDC)",
"api_key": "0x205d759b80bae1afa31a36c4afaeec0b10378c1c55e3363bcde5a1db75c747ca",
"custom_model_name": "deepseek",
},
}
reply := a.handleModelCreateSkill("default", 42, "zh", "继续", session)
if strings.Contains(reply, "还缺这些字段:钱包私钥") {
t.Fatalf("expected bare private key to be accepted, got: %s", reply)
}
if !strings.Contains(reply, "我先整理了一份模型配置草稿") {
t.Fatalf("expected draft summary after accepting private key, got: %s", reply)
}
}

View File

@@ -1,242 +0,0 @@
package agent
import (
"fmt"
"strings"
)
type modelProviderSpec struct {
ID string
DisplayName string
DefaultModel string
CredentialLabelZH string
CredentialLabelEN string
SupportsCustomAPIURL bool
SupportsCustomModel bool
UsesWalletCredential bool
Recommended bool
RecommendedModelHints []string
}
func supportedModelProviders() []modelProviderSpec {
return []modelProviderSpec{
{ID: "deepseek", DisplayName: "DeepSeek", DefaultModel: "deepseek-chat", CredentialLabelZH: "API Key", CredentialLabelEN: "API key", SupportsCustomAPIURL: true, SupportsCustomModel: true},
{ID: "qwen", DisplayName: "Qwen", DefaultModel: "qwen3-max", CredentialLabelZH: "API Key", CredentialLabelEN: "API key", SupportsCustomAPIURL: true, SupportsCustomModel: true},
{ID: "openai", DisplayName: "OpenAI", DefaultModel: "gpt-5.1", CredentialLabelZH: "API Key", CredentialLabelEN: "API key", SupportsCustomAPIURL: true, SupportsCustomModel: true},
{ID: "claude", DisplayName: "Claude", DefaultModel: "claude-opus-4-6", CredentialLabelZH: "API Key", CredentialLabelEN: "API key", SupportsCustomAPIURL: true, SupportsCustomModel: true},
{ID: "gemini", DisplayName: "Google Gemini", DefaultModel: "gemini-3-pro-preview", CredentialLabelZH: "API Key", CredentialLabelEN: "API key", SupportsCustomAPIURL: true, SupportsCustomModel: true},
{ID: "grok", DisplayName: "Grok (xAI)", DefaultModel: "grok-3-latest", CredentialLabelZH: "API Key", CredentialLabelEN: "API key", SupportsCustomAPIURL: true, SupportsCustomModel: true},
{ID: "kimi", DisplayName: "Kimi (Moonshot)", DefaultModel: "moonshot-v1-auto", CredentialLabelZH: "API Key", CredentialLabelEN: "API key", SupportsCustomAPIURL: true, SupportsCustomModel: true},
{ID: "minimax", DisplayName: "MiniMax", DefaultModel: "MiniMax-M2.5", CredentialLabelZH: "API Key", CredentialLabelEN: "API key", SupportsCustomAPIURL: true, SupportsCustomModel: true},
{
ID: "claw402",
DisplayName: "Claw402 (Base USDC)",
DefaultModel: "deepseek",
CredentialLabelZH: "钱包私钥",
CredentialLabelEN: "wallet private key",
SupportsCustomAPIURL: false,
SupportsCustomModel: true,
UsesWalletCredential: true,
Recommended: true,
RecommendedModelHints: []string{"deepseek", "glm-5", "gpt-5.4", "claude-opus", "qwen-max", "grok-4.1"},
},
{
ID: "blockrun-base",
DisplayName: "BlockRun (Base Wallet)",
DefaultModel: "auto",
CredentialLabelZH: "钱包私钥",
CredentialLabelEN: "wallet private key",
SupportsCustomAPIURL: false,
SupportsCustomModel: false,
UsesWalletCredential: true,
},
{
ID: "blockrun-sol",
DisplayName: "BlockRun (Solana Wallet)",
DefaultModel: "auto",
CredentialLabelZH: "钱包私钥",
CredentialLabelEN: "wallet private key",
SupportsCustomAPIURL: false,
SupportsCustomModel: false,
UsesWalletCredential: true,
},
}
}
func modelProviderSpecByID(provider string) (modelProviderSpec, bool) {
provider = strings.ToLower(strings.TrimSpace(provider))
for _, spec := range supportedModelProviders() {
if spec.ID == provider {
return spec, true
}
}
return modelProviderSpec{}, false
}
func supportedModelProviderIDs() []string {
specs := supportedModelProviders()
out := make([]string, 0, len(specs))
for _, spec := range specs {
out = append(out, spec.ID)
}
return out
}
func defaultModelNameForProvider(provider string) string {
spec, ok := modelProviderSpecByID(provider)
if !ok {
return ""
}
return strings.TrimSpace(spec.DefaultModel)
}
func defaultModelConfigName(provider string) string {
spec, ok := modelProviderSpecByID(provider)
if !ok {
provider = strings.TrimSpace(provider)
if provider == "" {
return ""
}
return provider + " AI"
}
return spec.DisplayName
}
func modelProviderSupportsCustomAPIURL(provider string) bool {
spec, ok := modelProviderSpecByID(provider)
return ok && spec.SupportsCustomAPIURL
}
func modelProviderSupportsCustomModel(provider string) bool {
spec, ok := modelProviderSpecByID(provider)
return ok && spec.SupportsCustomModel
}
func modelProviderCredentialLabel(lang, provider string) string {
spec, ok := modelProviderSpecByID(provider)
if !ok {
if lang == "zh" {
return "API Key"
}
return "API key"
}
if lang == "zh" {
return spec.CredentialLabelZH
}
return spec.CredentialLabelEN
}
func modelProviderSummaryList(lang string) string {
parts := make([]string, 0, len(supportedModelProviders()))
for _, spec := range supportedModelProviders() {
if lang == "zh" {
item := fmt.Sprintf("%s默认 %s", spec.ID, spec.DefaultModel)
if spec.Recommended {
item += " [推荐]"
}
parts = append(parts, item)
continue
}
item := fmt.Sprintf("%s (default %s)", spec.ID, spec.DefaultModel)
if spec.Recommended {
item += " [recommended]"
}
parts = append(parts, item)
}
if lang == "zh" {
return strings.Join(parts, "、")
}
return strings.Join(parts, ", ")
}
func modelProviderChoicePrompt(lang string) string {
if lang == "zh" {
return "可选模型 provider" + modelProviderSummaryList(lang) + "。这些 provider 是并列可选的:你可以直接选 `claw402`、DeepSeek / OpenAI / Claude / Gemini / Qwen / Kimi / Grok / MiniMax 这类 API Key provider或者选 `blockrun-base` / `blockrun-sol` 这类钱包 provider。我们优先推荐 `claw402`,因为它按次付费、用 Base USDC 钱包支付、默认配置更省事。对于第一次使用的新手,也可以直接去产品配置页的模型配置里选择 `claw402`:那里支持直接创建 Base 钱包,并且可以直接扫码充值/支付。请先告诉我你想用哪个 provider。"
}
return "Available model providers: " + modelProviderSummaryList(lang) + ". These providers are peer options: you can choose `claw402`, an API-key provider such as DeepSeek / OpenAI / Claude / Gemini / Qwen / Kimi / Grok / MiniMax, or a wallet-based provider such as `blockrun-base` / `blockrun-sol`. We recommend `claw402` first because it is pay-per-use, uses Base USDC wallet payment, and has the simplest default setup. If this is your first time, you can also open the product's model config page, choose `claw402`, create a Base wallet there directly, and pay by scanning the QR/deposit flow. Tell me which provider you want first."
}
func modelProviderDetailedGuidance(lang, provider string) string {
spec, ok := modelProviderSpecByID(provider)
if !ok {
return ""
}
if lang == "zh" {
lines := []string{
fmt.Sprintf("你现在选的是 %s。", spec.DisplayName),
fmt.Sprintf("- 默认模型名:%s", spec.DefaultModel),
fmt.Sprintf("- 凭证类型:%s", spec.CredentialLabelZH),
}
if spec.SupportsCustomModel {
lines = append(lines, "- `custom_model_name` 可选;留空时默认用上面的默认模型。")
} else {
lines = append(lines, "- 这个 provider 不需要单独填写 `custom_model_name`。")
}
if spec.SupportsCustomAPIURL {
lines = append(lines, "- `custom_api_url` 可选;留空时使用官方默认地址。")
} else {
lines = append(lines, "- 这个 provider 不需要 `custom_api_url`。")
}
if len(spec.RecommendedModelHints) > 0 {
lines = append(lines, "- 常见可选模型:"+strings.Join(spec.RecommendedModelHints, "、"))
}
if provider == "claw402" {
lines = append(lines, "- 这是我们优先推荐的 provider按次付费、Base USDC 钱包支付,对新手最省事。")
lines = append(lines, "- 如果你是第一次用,也可以直接去配置页的模型配置里选择 `claw402`,那里支持直接创建 Base 钱包,并可直接扫码充值/支付。")
}
return strings.Join(lines, "\n")
}
lines := []string{
fmt.Sprintf("You selected %s.", spec.DisplayName),
fmt.Sprintf("- Default model: %s", spec.DefaultModel),
fmt.Sprintf("- Credential type: %s", spec.CredentialLabelEN),
}
if spec.SupportsCustomModel {
lines = append(lines, "- `custom_model_name` is optional; if omitted, the default model will be used.")
} else {
lines = append(lines, "- This provider does not need a separate `custom_model_name`.")
}
if spec.SupportsCustomAPIURL {
lines = append(lines, "- `custom_api_url` is optional; if omitted, the official default endpoint will be used.")
} else {
lines = append(lines, "- This provider does not need `custom_api_url`.")
}
if len(spec.RecommendedModelHints) > 0 {
lines = append(lines, "- Common model choices: "+strings.Join(spec.RecommendedModelHints, ", "))
}
if provider == "claw402" {
lines = append(lines, "- This is our recommended provider: pay-per-use, Base USDC wallet payment, and the easiest setup for first-time users.")
lines = append(lines, "- If this is your first time, you can also open the model config page, choose `claw402`, create a Base wallet there directly, and pay through the QR/deposit flow.")
}
return strings.Join(lines, "\n")
}
func modelProviderCredentialGuidance(lang, provider string) string {
spec, ok := modelProviderSpecByID(provider)
if !ok {
return ""
}
provider = strings.TrimSpace(spec.ID)
if lang == "zh" {
switch provider {
case "claw402":
return "claw402 这里要填的是 Base 链 EVM 钱包私钥。\n- 如果你是第一次用,最省事的方式是直接去配置页的模型配置里选择 `claw402`。\n- 那里可以一键快速创建钱包,界面会直接展示新钱包私钥,并且提供 Base USDC 充值入口。\n- 创建后请立刻备份私钥;系统会用它完成 claw402 支付和模型调用。\n- 如果你已经有 MetaMask、Rabby、Coinbase Wallet 这类 Base/EVM 钱包,也可以从钱包里导出现有私钥再发我。"
case "blockrun-base":
return "blockrun-base 这里要填的是 Base 链 EVM 钱包私钥。你可以从现有 EVM 钱包导出私钥后发我。"
case "blockrun-sol":
return "blockrun-sol 这里要填的是 Solana 钱包私钥。你可以从现有 Solana 钱包导出私钥后发我。"
default:
return fmt.Sprintf("%s 这里要填的是 %s。你把完整值发我就行我会继续当前模型草稿。", spec.DisplayName, spec.CredentialLabelZH)
}
}
switch provider {
case "claw402":
return "For claw402, this field expects a Base-chain EVM wallet private key.\n- If this is your first time, the easiest path is to open the model config page and choose `claw402`.\n- That flow can quickly create a wallet for you, show the new private key, and provide a Base USDC deposit path.\n- Back up the key immediately after creation; the system uses it for claw402 payments and model access.\n- If you already use MetaMask, Rabby, or Coinbase Wallet, you can also export an existing Base/EVM wallet private key and send it to me."
case "blockrun-base":
return "For blockrun-base, this field expects a Base-chain EVM wallet private key. You can export it from an existing EVM wallet and send it to me."
case "blockrun-sol":
return "For blockrun-sol, this field expects a Solana wallet private key. You can export it from an existing Solana wallet and send it to me."
default:
return fmt.Sprintf("For %s, this field expects your %s. Send me the full value and I'll continue the current model draft.", spec.DisplayName, spec.CredentialLabelEN)
}
}

View File

@@ -1,57 +0,0 @@
package agent
import (
"strings"
"testing"
)
func TestModelProviderChoicePromptIncludesRecommendationWithoutAutoSelection(t *testing.T) {
msg := modelProviderChoicePrompt("zh")
for _, want := range []string{
"可选模型 provider",
"claw402",
"DeepSeek",
"OpenAI",
"并列可选",
"blockrun-base",
"直接创建 Base 钱包",
"直接扫码充值/支付",
"请先告诉我你想用哪个 provider",
} {
if !strings.Contains(msg, want) {
t.Fatalf("expected prompt to contain %q, got: %s", want, msg)
}
}
if strings.Contains(msg, "把私钥发给我") {
t.Fatalf("provider choice prompt should not jump ahead to credential collection: %s", msg)
}
}
func TestModelProviderCredentialGuidanceForClaw402MentionsConfigPageWalletFlow(t *testing.T) {
msg := modelProviderCredentialGuidance("zh", "claw402")
for _, want := range []string{
"Base 链 EVM 钱包私钥",
"配置页的模型配置里选择 `claw402`",
"快速创建钱包",
"充值入口",
} {
if !strings.Contains(msg, want) {
t.Fatalf("expected guidance to contain %q, got: %s", want, msg)
}
}
}
func TestModelProviderDetailedGuidanceForClaw402MentionsBeginnerFlow(t *testing.T) {
msg := modelProviderDetailedGuidance("zh", "claw402")
for _, want := range []string{
"优先推荐",
"按次付费",
"Base USDC 钱包支付",
"直接创建 Base 钱包",
"直接扫码充值/支付",
} {
if !strings.Contains(msg, want) {
t.Fatalf("expected detailed guidance to contain %q, got: %s", want, msg)
}
}
}

View File

@@ -1,94 +0,0 @@
package agent
import (
"fmt"
"strconv"
"strings"
)
func isModelWalletBalanceQuestion(text string) bool {
lower := strings.ToLower(strings.TrimSpace(text))
if lower == "" {
return false
}
// Direct wallet address questions: "我的钱包地址", "wallet address", etc.
if containsAny(lower, []string{"钱包", "wallet"}) && containsAny(lower, []string{"地址", "address"}) {
return true
}
// Balance questions with wallet context
if containsAny(lower, []string{"余额", "balance", "usdc"}) &&
containsAny(lower, []string{"钱包", "wallet", "主钱包", "base", "claw402"}) {
return true
}
return false
}
func (a *Agent) handleModelWalletBalanceQuestion(storeUserID, lang, text string) (string, bool) {
if !isModelWalletBalanceQuestion(text) || a == nil || a.store == nil {
return "", false
}
models, err := a.store.AIModel().List(storeUserID)
if err != nil {
if lang == "zh" {
return "我现在读取模型配置失败,暂时查不到 claw402 钱包余额。", true
}
return "I could not read model configs, so I cannot check the claw402 wallet balance right now.", true
}
var matches []safeModelToolConfig
for _, model := range models {
if model == nil || strings.ToLower(strings.TrimSpace(model.Provider)) != "claw402" {
continue
}
matches = append(matches, safeModelForTool(model))
}
if len(matches) == 0 {
if lang == "zh" {
return "当前没有找到 claw402 模型钱包配置。", true
}
return "No claw402 model wallet config was found.", true
}
if lang == "zh" {
lines := []string{"当前 claw402 模型钱包余额:"}
for _, model := range matches {
name := defaultIfEmpty(model.Name, model.ID)
lines = append(lines, fmt.Sprintf("- %s%s USDC", name, defaultIfEmpty(model.BalanceUSDC, "暂时无法读取")))
if strings.TrimSpace(model.WalletAddress) != "" {
lines = append(lines, fmt.Sprintf(" 钱包地址:%s", model.WalletAddress))
}
if balanceIsZero(model.BalanceUSDC) {
if model.Enabled {
lines = append(lines, " 这个模型配置已启用,但钱包余额为 0 USDC这不是“未启用”而是需要先充值 Base USDC 后才能稳定调用。")
} else {
lines = append(lines, " 钱包余额为 0 USDC启用并充值 Base USDC 后才能稳定调用。")
}
}
}
lines = append(lines, "注意:这是 claw402/Base 模型支付钱包余额,不是 OKX/Binance 等交易所账户余额。")
return strings.Join(lines, "\n"), true
}
lines := []string{"Current claw402 model wallet balance:"}
for _, model := range matches {
name := defaultIfEmpty(model.Name, model.ID)
lines = append(lines, fmt.Sprintf("- %s: %s USDC", name, defaultIfEmpty(model.BalanceUSDC, "unavailable")))
if strings.TrimSpace(model.WalletAddress) != "" {
lines = append(lines, fmt.Sprintf(" Wallet address: %s", model.WalletAddress))
}
if balanceIsZero(model.BalanceUSDC) {
lines = append(lines, " This model config may be enabled, but the wallet balance is 0 USDC; recharge Base USDC before relying on it.")
}
}
lines = append(lines, "Note: this is the claw402/Base model payment wallet balance, not an exchange account balance.")
return strings.Join(lines, "\n"), true
}
func balanceIsZero(value string) bool {
trimmed := strings.TrimSpace(value)
if trimmed == "" {
return false
}
parsed, err := strconv.ParseFloat(trimmed, 64)
return err == nil && parsed <= 0
}

View File

@@ -1,604 +0,0 @@
package agent
import (
"fmt"
"strings"
"time"
"golang.org/x/text/cases"
"golang.org/x/text/language"
"nofx/store"
)
var titleCaser = cases.Title(language.English)
const setupExchangeAccountName = "Default"
// Onboard handles first-time setup through natural language.
// When there's no trader configured, the agent guides the user.
// SetupState tracks where the user is in the setup flow.
type SetupState struct {
Step string // "", "await_exchange", "await_api_key", "await_api_secret", "await_passphrase", "await_ai_model", "await_ai_key"
Exchange string
ExchangeID string
APIKey string
APISecret string
Passphrase string
AIProvider string
AIModel string
AIModelID string
AIKey string
AIBaseURL string
}
// needsSetup returns true if no traders are configured.
func (a *Agent) needsSetup() bool {
if a.traderManager == nil {
return true
}
return len(a.traderManager.GetAllTraders()) == 0
}
// getSetupState loads the current setup state from user preferences.
func (a *Agent) getSetupState(userID int64) *SetupState {
if cached, ok := a.setupStates.Load(userID); ok {
if state, ok := cached.(*SetupState); ok && state != nil {
return cloneSetupState(state)
}
}
step, _ := a.store.GetSystemConfig(fmt.Sprintf("setup_step_%d", userID))
if step == "" {
return &SetupState{}
}
return &SetupState{
Step: step,
Exchange: getConfig(a.store, userID, "exchange"),
ExchangeID: getConfig(a.store, userID, "exchange_id"),
AIProvider: getConfig(a.store, userID, "ai_provider"),
AIModel: getConfig(a.store, userID, "ai_model"),
AIModelID: getConfig(a.store, userID, "ai_model_id"),
AIBaseURL: getConfig(a.store, userID, "ai_base_url"),
}
}
func (a *Agent) saveSetupState(userID int64, s *SetupState) {
a.setupStates.Store(userID, cloneSetupState(s))
a.store.SetSystemConfig(fmt.Sprintf("setup_step_%d", userID), s.Step)
setConfig(a.store, userID, "exchange", s.Exchange)
setConfig(a.store, userID, "exchange_id", s.ExchangeID)
setConfig(a.store, userID, "ai_provider", s.AIProvider)
setConfig(a.store, userID, "ai_model", s.AIModel)
setConfig(a.store, userID, "ai_model_id", s.AIModelID)
setConfig(a.store, userID, "ai_base_url", s.AIBaseURL)
}
func (a *Agent) clearSetupState(userID int64) {
a.setupStates.Delete(userID)
for _, k := range []string{"step", "exchange", "exchange_id", "ai_provider", "ai_model", "ai_model_id", "ai_base_url"} {
a.store.SetSystemConfig(fmt.Sprintf("setup_%s_%d", k, userID), "")
}
a.store.SetSystemConfig(fmt.Sprintf("setup_step_%d", userID), "")
}
func getConfig(st *store.Store, uid int64, key string) string {
v, _ := st.GetSystemConfig(fmt.Sprintf("setup_%s_%d", key, uid))
return v
}
func setConfig(st *store.Store, uid int64, key, val string) {
st.SetSystemConfig(fmt.Sprintf("setup_%s_%d", key, uid), val)
}
func cloneSetupState(s *SetupState) *SetupState {
if s == nil {
return &SetupState{}
}
copy := *s
return &copy
}
// handleSetupFlow processes the setup conversation.
// Returns (response, handled). If handled=false, continue to normal routing.
func (a *Agent) handleSetupFlow(userID int64, text string, L string) (string, bool) {
return a.handleSetupFlowForStoreUser("default", userID, text, L)
}
func (a *Agent) handleSetupFlowForStoreUser(storeUserID string, userID int64, text string, L string) (string, bool) {
state := a.getSetupState(userID)
lower := strings.ToLower(text)
// Cancel setup — explicit or implicit (user asking unrelated questions)
if lower == "cancel" || lower == "取消" || lower == "/cancel" {
a.clearSetupState(userID)
return a.setupMsg(L, "cancelled"), true
}
// If in a step that expects a key/secret, check if user is NOT sending a key
// Keys are typically long strings without spaces and Chinese characters
if state.Step == "await_api_key" || state.Step == "await_api_secret" || state.Step == "await_passphrase" || state.Step == "await_ai_key" {
trimmed := strings.TrimSpace(text)
hasChinese := false
for _, r := range trimmed {
if r >= 0x4e00 && r <= 0x9fff {
hasChinese = true
break
}
}
hasSpaces := strings.Contains(trimmed, " ") && !strings.HasPrefix(trimmed, "sk-")
tooShort := len(trimmed) < 8
if hasChinese || hasSpaces || tooShort {
// User is probably asking a question, not providing a key
a.clearSetupState(userID)
if L == "zh" {
return "👌 配置已暂停。我先回答你的问题——\n\n随时发送 *开始配置* 继续配置。", false
}
return "👌 Setup paused. Let me answer your question first—\n\nSend *setup* anytime to continue.", false
}
}
switch state.Step {
case "await_exchange":
return a.handleExchangeChoice(userID, text, state, L)
case "await_api_key":
state.APIKey = strings.TrimSpace(text)
state.Step = "await_api_secret"
a.saveSetupState(userID, state)
return a.setupMsg(L, "ask_secret"), true
case "await_api_secret":
state.APISecret = strings.TrimSpace(text)
// OKX/Bitget/KuCoin need passphrase
if needsPassphrase(state.Exchange) {
state.Step = "await_passphrase"
a.saveSetupState(userID, state)
return a.setupMsg(L, "ask_passphrase"), true
}
exchangeID, err := a.saveSetupExchange(storeUserID, state)
if err != nil {
a.logger.Error("save exchange from setup failed", "error", err, "exchange", state.Exchange, "store_user_id", storeUserID)
if L == "zh" {
return fmt.Sprintf("⚠️ 交易所配置保存失败: %v\n请再试一次或稍后去 Web UI 继续。", err), true
}
return fmt.Sprintf("⚠️ I could not save the exchange settings just now: %v\nPlease try again, or continue later on the web page.", err), true
}
state.ExchangeID = exchangeID
state.Step = "await_ai_model"
a.saveSetupState(userID, state)
if L == "zh" {
return "✅ 交易所配置已保存,在配置页里现在就能看到。\n\n" + a.setupMsg(L, "ask_ai"), true
}
return "✅ Exchange config saved. It should now be visible in the config page.\n\n" + a.setupMsg(L, "ask_ai"), true
case "await_passphrase":
state.Passphrase = strings.TrimSpace(text)
exchangeID, err := a.saveSetupExchange(storeUserID, state)
if err != nil {
a.logger.Error("save exchange from setup failed", "error", err, "exchange", state.Exchange, "store_user_id", storeUserID)
if L == "zh" {
return fmt.Sprintf("⚠️ 交易所配置保存失败: %v\n请再试一次或稍后去 Web UI 继续。", err), true
}
return fmt.Sprintf("⚠️ I could not save the exchange settings just now: %v\nPlease try again, or continue later on the web page.", err), true
}
state.ExchangeID = exchangeID
state.Step = "await_ai_model"
a.saveSetupState(userID, state)
if L == "zh" {
return "✅ 交易所配置已保存,在配置页里现在就能看到。\n\n" + a.setupMsg(L, "ask_ai"), true
}
return "✅ Exchange config saved. It should now be visible in the config page.\n\n" + a.setupMsg(L, "ask_ai"), true
case "await_ai_model":
return a.handleAIChoice(storeUserID, userID, text, state, L)
case "await_ai_key":
state.AIKey = strings.TrimSpace(text)
aiModelID, err := a.saveSetupAIModel(storeUserID, state)
if err != nil {
a.logger.Error("save AI model from setup failed", "error", err, "provider", state.AIProvider, "store_user_id", storeUserID)
if L == "zh" {
return fmt.Sprintf("⚠️ AI 模型配置保存失败: %v\n请再试一次或稍后去 Web UI 继续。", err), true
}
return fmt.Sprintf("⚠️ I could not save the AI model settings just now: %v\nPlease try again, or continue later on the web page.", err), true
}
state.AIModelID = aiModelID
return a.finishSetup(storeUserID, userID, state, L)
}
// Not in setup flow — only enter setup for a tiny set of explicit commands.
// Natural-language configuration requests should go to the planner first,
// including phrases like "开始配置" or "帮我配置交易所".
if isDirectSetupCommand(lower) {
state.Step = "await_exchange"
a.saveSetupState(userID, state)
return a.setupMsg(L, "ask_exchange"), true
}
// Everything else — let normal routing handle it
return "", false
}
func isDirectSetupCommand(text string) bool {
text = strings.ToLower(strings.TrimSpace(text))
if text == "" {
return false
}
switch text {
case "setup", "/setup":
return true
default:
return false
}
}
func (a *Agent) handleExchangeChoice(userID int64, text string, state *SetupState, L string) (string, bool) {
lower := strings.ToLower(strings.TrimSpace(text))
exchanges := map[string]string{
"binance": "binance", "币安": "binance", "1": "binance",
"okx": "okx", "欧易": "okx", "2": "okx",
"bybit": "bybit", "3": "bybit",
"bitget": "bitget", "4": "bitget",
"gate": "gate", "5": "gate",
"kucoin": "kucoin", "库币": "kucoin", "6": "kucoin",
"hyperliquid": "hyperliquid", "7": "hyperliquid",
}
ex, ok := exchanges[lower]
if !ok {
return a.setupMsg(L, "invalid_exchange"), true
}
state.Exchange = ex
state.Step = "await_api_key"
a.saveSetupState(userID, state)
if L == "zh" {
return fmt.Sprintf("✅ 选择了 *%s*\n\n请发送你的 API Key", titleCaser.String(ex)), true
}
return fmt.Sprintf("✅ Selected *%s*\n\nPlease send your API Key:", titleCaser.String(ex)), true
}
func (a *Agent) handleAIChoice(storeUserID string, userID int64, text string, state *SetupState, L string) (string, bool) {
lower := strings.ToLower(strings.TrimSpace(text))
models := map[string]struct{ provider, model, url string }{
"deepseek": {"deepseek", "deepseek-chat", "https://api.deepseek.com/v1"},
"1": {"deepseek", "deepseek-chat", "https://api.deepseek.com/v1"},
"qwen": {"qwen", "qwen-plus", "https://dashscope.aliyuncs.com/compatible-mode/v1"},
"通义": {"qwen", "qwen-plus", "https://dashscope.aliyuncs.com/compatible-mode/v1"},
"2": {"qwen", "qwen-plus", "https://dashscope.aliyuncs.com/compatible-mode/v1"},
"openai": {"openai", "gpt-4o", "https://api.openai.com/v1"},
"gpt": {"openai", "gpt-4o", "https://api.openai.com/v1"},
"3": {"openai", "gpt-4o", "https://api.openai.com/v1"},
"claude": {"claude", "claude-3-5-sonnet-20241022", "https://api.anthropic.com/v1"},
"4": {"claude", "claude-3-5-sonnet-20241022", "https://api.anthropic.com/v1"},
"skip": {"", "", ""},
"跳过": {"", "", ""},
"5": {"", "", ""},
}
choice, ok := models[lower]
if !ok {
return a.setupMsg(L, "invalid_ai"), true
}
if choice.model == "" {
// Skip AI, just create trader with exchange
state.AIProvider = ""
state.AIModel = ""
state.AIModelID = ""
state.AIKey = ""
return a.finishSetup(storeUserID, userID, state, L)
}
state.AIProvider = choice.provider
state.AIModel = choice.model
state.AIBaseURL = choice.url
state.Step = "await_ai_key"
a.saveSetupState(userID, state)
if L == "zh" {
return fmt.Sprintf("✅ AI 模型: *%s*\n\n请发送你的 API Key", choice.model), true
}
return fmt.Sprintf("✅ AI Model: *%s*\n\nPlease send your API Key:", choice.model), true
}
func (a *Agent) finishSetup(storeUserID string, userID int64, state *SetupState, L string) (string, bool) {
// Create exchange in store
a.logger.Info("creating trader from setup",
"exchange", state.Exchange,
"ai_model", state.AIModel,
"store_user_id", storeUserID,
)
// TODO: Use store to create exchange + trader config
// For now, log the config and tell user
a.clearSetupState(userID)
result := ""
maskedKey := maskKey(state.APIKey)
if L == "zh" {
result = fmt.Sprintf("🎉 *配置完成!*\n\n"+
"• 交易所: %s\n"+
"• API Key: %s\n",
titleCaser.String(state.Exchange), maskedKey)
if state.AIModel != "" {
result += fmt.Sprintf("• AI 模型: %s\n", state.AIModel)
}
result += "\n正在创建 Trader..."
} else {
result = fmt.Sprintf("🎉 *Setup Complete!*\n\n"+
"• Exchange: %s\n"+
"• API Key: %s\n",
titleCaser.String(state.Exchange), maskedKey)
if state.AIModel != "" {
result += fmt.Sprintf("• AI Model: %s\n", state.AIModel)
}
result += "\nCreating Trader..."
}
// Actually create the trader via store
err := a.createTraderFromSetupForStoreUser(storeUserID, state)
if err != nil {
a.logger.Error("create trader failed", "error", err)
if L == "zh" {
result += fmt.Sprintf("\n\n⚠ 创建失败: %v\n交易所配置已保存下次配置时可直接复用。\n也可以在 Web UI 中继续完成。", err)
} else {
result += fmt.Sprintf("\n\n⚠ Failed: %v\nYour exchange config was saved, so you can reuse it next time.\nYou can also finish setup in the Web UI.", err)
}
} else {
if L == "zh" {
result += "\n\n✅ Trader 已创建!现在你可以:\n• `/analyze BTC` — 分析市场\n• `/positions` — 查看持仓\n• 或者直接跟我聊天"
} else {
result += "\n\n✅ Trader created! Now you can:\n• `/analyze BTC` — analyze market\n• `/positions` — view positions\n• Or just chat with me"
}
}
return result, true
}
func (a *Agent) createTraderFromSetup(state *SetupState) error {
return a.createTraderFromSetupForStoreUser("default", state)
}
func (a *Agent) createTraderFromSetupForStoreUser(storeUserID string, state *SetupState) error {
if a.store == nil {
return fmt.Errorf("store not available")
}
exchangeID := state.ExchangeID
if exchangeID == "" {
var err error
exchangeID, err = a.saveSetupExchange(storeUserID, state)
if err != nil {
return fmt.Errorf("save exchange: %w", err)
}
}
aiModelID := state.AIModelID
if state.AIModel != "" && state.AIKey != "" && aiModelID == "" {
var err error
aiModelID, err = a.saveSetupAIModel(storeUserID, state)
if err != nil {
a.logger.Error("save AI model", "error", err)
}
}
// Reuse an existing trader if the same exchange/model pair already exists.
existingTraders, err := a.store.Trader().List(storeUserID)
if err != nil {
return fmt.Errorf("list traders: %w", err)
}
for _, existing := range existingTraders {
if existing.ExchangeID == exchangeID && existing.AIModelID == aiModelID {
a.logger.Info("reusing existing trader created via chat setup",
"trader", existing.Name,
"exchange_id", exchangeID,
"ai_model_id", aiModelID,
)
return nil
}
}
// Create trader config
exchangeIDShort := exchangeID
if len(exchangeIDShort) > 8 {
exchangeIDShort = exchangeIDShort[:8]
}
modelPart := aiModelID
if modelPart == "" {
modelPart = "manual"
}
trader := &store.Trader{
ID: fmt.Sprintf("%s_%s_%d", exchangeIDShort, modelPart, time.Now().UnixNano()),
Name: fmt.Sprintf("NOFXi-%s", titleCaser.String(state.Exchange)),
UserID: storeUserID,
ExchangeID: exchangeID,
AIModelID: aiModelID,
IsRunning: false,
}
if err := a.store.Trader().Create(trader); err != nil {
return fmt.Errorf("save trader: %w", err)
}
a.logger.Info("trader created via chat",
"trader", trader.Name,
"exchange", state.Exchange,
"ai", aiModelID,
)
return nil
}
func (a *Agent) saveSetupExchange(storeUserID string, state *SetupState) (string, error) {
if a.store == nil {
return "", fmt.Errorf("store not available")
}
hlWallet := ""
hlUnified := false
passphrase := state.Passphrase
apiKey := state.APIKey
apiSecret := state.APISecret
if state.Exchange == "hyperliquid" {
hlWallet = state.APISecret
apiKey = ""
apiSecret = state.APIKey
}
exchanges, err := a.store.Exchange().List(storeUserID)
if err != nil {
return "", err
}
for _, ex := range exchanges {
if ex.ExchangeType == state.Exchange && ex.AccountName == setupExchangeAccountName {
if err := a.store.Exchange().Update(
storeUserID, ex.ID, true,
apiKey, apiSecret, passphrase,
false,
hlWallet, hlUnified, false,
"", "", "",
"", "", "", 0,
); err != nil {
return "", err
}
return ex.ID, nil
}
}
return a.store.Exchange().Create(
storeUserID,
state.Exchange,
setupExchangeAccountName,
true,
apiKey, apiSecret, passphrase,
false,
hlWallet, hlUnified, false,
"", "", "",
"", "", "", 0,
)
}
func (a *Agent) saveSetupAIModel(storeUserID string, state *SetupState) (string, error) {
if a.store == nil {
return "", fmt.Errorf("store not available")
}
if state.AIProvider == "" {
return "", nil
}
modelID := state.AIProvider
if err := a.store.AIModel().Update(
storeUserID,
modelID,
true,
state.AIKey,
state.AIBaseURL,
state.AIModel,
); err != nil {
return "", err
}
if modelID == state.AIProvider {
modelID = fmt.Sprintf("%s_%s", storeUserID, state.AIProvider)
}
return modelID, nil
}
func maskKey(key string) string {
if len(key) <= 8 {
return "****"
}
return key[:4] + "****" + key[len(key)-4:]
}
func needsPassphrase(exchange string) bool {
return exchange == "okx" || exchange == "bitget" || exchange == "kucoin"
}
func containsAny(s string, words []string) bool {
for _, w := range words {
if strings.Contains(s, w) {
return true
}
}
return false
}
var setupMessages = map[string]map[string]string{
"welcome": {
"zh": "👋 你好!我是 *NOFXi*,你的 AI 交易 Agent。\n\n" +
"我发现你还没有配置交易所,让我帮你搞定吧!\n\n" +
"发送 *开始配置* 或 *setup* 开始\n" +
"发送 *取消* 随时退出",
"en": "👋 Hi! I'm *NOFXi*, your AI trading agent.\n\n" +
"I see you haven't configured an exchange yet. Let me help!\n\n" +
"Send *setup* to begin\n" +
"Send *cancel* to exit anytime",
},
"ask_exchange": {
"zh": "🏦 *选择你的交易所*\n\n" +
"1⃣ Binance币安\n" +
"2⃣ OKX欧易\n" +
"3⃣ Bybit\n" +
"4⃣ Bitget\n" +
"5⃣ Gate\n" +
"6⃣ KuCoin库币\n" +
"7⃣ Hyperliquid\n\n" +
"发送数字或名称选择:",
"en": "🏦 *Choose your exchange*\n\n" +
"1⃣ Binance\n" +
"2⃣ OKX\n" +
"3⃣ Bybit\n" +
"4⃣ Bitget\n" +
"5⃣ Gate\n" +
"6⃣ KuCoin\n" +
"7⃣ Hyperliquid\n\n" +
"Send number or name:",
},
"invalid_exchange": {
"zh": "❓ 没有识别到交易所。请发送数字 1-7 或交易所名称。",
"en": "❓ Exchange not recognized. Send a number 1-7 or exchange name.",
},
"ask_secret": {
"zh": "🔑 收到 API Key。\n\n现在请发送你的 *API Secret*",
"en": "🔑 Got API Key.\n\nNow send your *API Secret*:",
},
"ask_passphrase": {
"zh": "🔐 收到 API Secret。\n\n这个交易所还需要 *Passphrase*,请发送:",
"en": "🔐 Got API Secret.\n\nThis exchange also needs a *Passphrase*. Please send it:",
},
"ask_ai": {
"zh": "🤖 *选择 AI 模型*\n\n" +
"1⃣ DeepSeek推荐便宜好用\n" +
"2⃣ 通义千问 (Qwen)\n" +
"3⃣ OpenAI (GPT-4o)\n" +
"4⃣ Claude\n" +
"5⃣ 跳过(不配置 AI\n\n" +
"发送数字或名称选择:",
"en": "🤖 *Choose AI model*\n\n" +
"1⃣ DeepSeek (recommended, affordable)\n" +
"2⃣ Qwen\n" +
"3⃣ OpenAI (GPT-4o)\n" +
"4⃣ Claude\n" +
"5⃣ Skip (no AI)\n\n" +
"Send number or name:",
},
"invalid_ai": {
"zh": "❓ 没有识别到 AI 模型。请发送数字 1-5 或模型名称。",
"en": "❓ AI model not recognized. Send a number 1-5 or model name.",
},
"cancelled": {
"zh": "👌 配置已取消。随时发送 *开始配置* 重新开始。",
"en": "👌 Setup cancelled. Send *setup* anytime to restart.",
},
}
func (a *Agent) setupMsg(L, key string) string {
if m, ok := setupMessages[key]; ok {
if s, ok := m[L]; ok {
return s
}
return m["en"]
}
return key
}

File diff suppressed because it is too large Load Diff

View File

@@ -1,103 +0,0 @@
package agent
import (
"encoding/json"
"testing"
"nofx/mcp"
)
// plannerToolsForText now always returns the FULL toolset (no per-domain
// trimming) so the LLM can cross-domain reason. The old "if market intent,
// hide manage_trader" filter was making cross-domain questions like "BTC
// dropped, how much am I losing?" impossible to answer because the agent
// couldn't see both market AND position tools in the same turn.
//
// We still trim the giant strategy schema for non-mutation intents because
// that one is genuinely huge and uninformative for read-only use.
func TestPlannerToolsExposeFullSetForMarketIntent(t *testing.T) {
tools := plannerToolsForText("看一下 BTCUSDT 行情和 K线")
names := toolNamesForTest(tools)
// Market tools must be present.
for _, expected := range []string{"get_market_snapshot", "get_market_price", "get_kline"} {
if !containsString(names, expected) {
t.Fatalf("expected market tool %q in %v", expected, names)
}
}
// Cross-domain tools (positions, balance, trader management) must ALSO be
// present so the agent can answer "how much am I losing" follow-ups
// without losing the market context.
for _, expected := range []string{"get_positions", "get_balance", "manage_trader"} {
if !containsString(names, expected) {
t.Fatalf("expected cross-domain tool %q in market context %v", expected, names)
}
}
}
func TestPlannerToolsExposeFullSetForExchangeIntent(t *testing.T) {
tools := plannerToolsForText("帮我添加 okx 交易所 API key")
names := toolNamesForTest(tools)
// At least the exchange management tools must show up.
for _, expected := range []string{"get_exchange_configs", "manage_exchange_config"} {
if !containsString(names, expected) {
t.Fatalf("expected exchange tool %q in %v", expected, names)
}
}
// And the agent still has the broader surface available — adding an
// exchange often leads to "now create a trader" so trader/strategy tools
// must be reachable in the same turn.
for _, expected := range []string{"manage_trader", "get_strategies"} {
if !containsString(names, expected) {
t.Fatalf("expected adjacent tool %q in exchange context %v", expected, names)
}
}
}
func TestPlannerToolsUseCompactManageStrategyForReadIntent(t *testing.T) {
tools := plannerToolsForText("列出我的策略")
tool := findToolForTest(tools, "manage_strategy")
if tool == nil {
t.Fatalf("expected manage_strategy in strategy tools")
}
raw, _ := json.Marshal(tool.Function.Parameters)
if len(raw) > 900 {
t.Fatalf("expected compact strategy schema, got %d bytes", len(raw))
}
if string(raw) == "" || !json.Valid(raw) {
t.Fatalf("expected valid strategy schema JSON")
}
}
func TestPlannerToolsKeepFullManageStrategyForMutationIntent(t *testing.T) {
tools := plannerToolsForText("创建一个 BTC 网格策略")
tool := findToolForTest(tools, "manage_strategy")
if tool == nil {
t.Fatalf("expected manage_strategy in strategy tools")
}
raw, _ := json.Marshal(tool.Function.Parameters)
if len(raw) < 1500 {
t.Fatalf("expected full strategy schema for mutation intent, got %d bytes", len(raw))
}
}
func toolNamesForTest(tools []mcp.Tool) []string {
names := make([]string, 0, len(tools))
for _, tool := range tools {
names = append(names, tool.Function.Name)
}
return names
}
func findToolForTest(tools []mcp.Tool, name string) *mcp.Tool {
for i := range tools {
if tools[i].Function.Name == name {
return &tools[i]
}
}
return nil
}

View File

@@ -1,166 +0,0 @@
package agent
import (
"encoding/json"
"fmt"
"hash/fnv"
"strings"
"time"
)
const maxPersistentPreferenceLength = 500
// PersistentPreference is a durable user instruction shown in the UI and
// injected into the agent context for future conversations.
type PersistentPreference struct {
ID string `json:"id"`
Text string `json:"text"`
CreatedAt string `json:"created_at,omitempty"`
}
func NewPersistentPreference(text string) (PersistentPreference, error) {
text = strings.TrimSpace(text)
if text == "" {
return PersistentPreference{}, fmt.Errorf("text required")
}
if len([]rune(text)) > maxPersistentPreferenceLength {
return PersistentPreference{}, fmt.Errorf("text too long (max %d characters)", maxPersistentPreferenceLength)
}
now := time.Now().UTC()
return PersistentPreference{
ID: now.Format("20060102150405.000000000"),
Text: text,
CreatedAt: now.Format(time.RFC3339),
}, nil
}
// SessionUserIDFromKey maps a stable user key (for example a UUID string from
// auth) to the int64 session id expected by the current agent implementation.
func SessionUserIDFromKey(userKey string) int64 {
if strings.TrimSpace(userKey) == "" {
return 1
}
h := fnv.New64a()
_, _ = h.Write([]byte(userKey))
sum := h.Sum64() & 0x7fffffffffffffff
if sum == 0 {
return 1
}
return int64(sum)
}
func PreferencesConfigKey(userID int64) string {
return fmt.Sprintf("agent_preferences_%d", userID)
}
func (a *Agent) getPersistentPreferences(userID int64) []PersistentPreference {
if a.store == nil {
return nil
}
raw, err := a.store.GetSystemConfig(PreferencesConfigKey(userID))
if err != nil || strings.TrimSpace(raw) == "" {
return nil
}
var prefs []PersistentPreference
if err := json.Unmarshal([]byte(raw), &prefs); err != nil {
a.logger.Warn("failed to parse persistent preferences", "error", err, "user_id", userID)
return nil
}
return prefs
}
func (a *Agent) savePersistentPreferences(userID int64, prefs []PersistentPreference) error {
if a.store == nil {
return fmt.Errorf("store unavailable")
}
data, err := json.Marshal(prefs)
if err != nil {
return err
}
return a.store.SetSystemConfig(PreferencesConfigKey(userID), string(data))
}
func (a *Agent) addPersistentPreference(userID int64, text string) ([]PersistentPreference, PersistentPreference, error) {
created, err := NewPersistentPreference(text)
if err != nil {
return nil, PersistentPreference{}, err
}
prefs := a.getPersistentPreferences(userID)
prefs = append([]PersistentPreference{created}, prefs...)
if len(prefs) > 20 {
prefs = prefs[:20]
}
if err := a.savePersistentPreferences(userID, prefs); err != nil {
return nil, PersistentPreference{}, err
}
return prefs, created, nil
}
func (a *Agent) updatePersistentPreference(userID int64, match, replacement string) ([]PersistentPreference, *PersistentPreference, error) {
match = strings.TrimSpace(match)
replacement = strings.TrimSpace(replacement)
if match == "" || replacement == "" {
return nil, nil, fmt.Errorf("match and replacement are required")
}
prefs := a.getPersistentPreferences(userID)
for i := range prefs {
if prefs[i].ID == match || strings.Contains(strings.ToLower(prefs[i].Text), strings.ToLower(match)) {
prefs[i].Text = replacement
if err := a.savePersistentPreferences(userID, prefs); err != nil {
return nil, nil, err
}
return prefs, &prefs[i], nil
}
}
return prefs, nil, fmt.Errorf("preference not found")
}
func (a *Agent) deletePersistentPreference(userID int64, match string) ([]PersistentPreference, *PersistentPreference, error) {
match = strings.TrimSpace(match)
if match == "" {
return nil, nil, fmt.Errorf("match required")
}
prefs := a.getPersistentPreferences(userID)
filtered := make([]PersistentPreference, 0, len(prefs))
var removed *PersistentPreference
for i := range prefs {
p := prefs[i]
if removed == nil && (p.ID == match || strings.Contains(strings.ToLower(p.Text), strings.ToLower(match))) {
cp := p
removed = &cp
continue
}
filtered = append(filtered, p)
}
if removed == nil {
return prefs, nil, fmt.Errorf("preference not found")
}
if err := a.savePersistentPreferences(userID, filtered); err != nil {
return nil, nil, err
}
return filtered, removed, nil
}
func (a *Agent) buildPersistentPreferencesContext(userID int64) string {
prefs := a.getPersistentPreferences(userID)
if len(prefs) == 0 {
return ""
}
var sb strings.Builder
sb.WriteString("[Persistent User Preferences - follow unless the user explicitly overrides them]\n")
for _, pref := range prefs {
if strings.TrimSpace(pref.Text) == "" {
continue
}
sb.WriteString("- ")
sb.WriteString(pref.Text)
sb.WriteString("\n")
}
return strings.TrimSpace(sb.String())
}

View File

@@ -1,74 +0,0 @@
package agent
import (
"fmt"
"strings"
)
func (a *Agent) buildCurrentTurnContext(userID int64, lang, currentUserText string) string {
var parts []string
previousAssistantReply := strings.TrimSpace(a.currentPendingHintText(userID))
if previousAssistantReply != "" {
parts = append(parts, "Previous assistant reply:\n"+previousAssistantReply)
}
recentConversation := strings.TrimSpace(a.buildRecentConversationContext(userID, currentUserText))
if recentConversation != "" {
parts = append(parts, "Recent conversation:\n"+recentConversation)
}
currentRefs := strings.TrimSpace(buildCurrentReferenceSummary(lang, a.semanticCurrentReferences(userID)))
if currentRefs != "" {
parts = append(parts, "Current references:\n"+currentRefs)
}
return strings.Join(parts, "\n\n")
}
func (a *Agent) buildActiveTaskStateContext(userID int64, lang string) string {
activeSkill := a.getSkillSession(userID)
activeTask, hasActiveTask := a.getActiveSkillSession(userID)
activeWorkflow := a.getWorkflowSession(userID)
activeExec := normalizeExecutionState(a.getExecutionState(userID))
pendingProposal, hasPendingProposal := a.getPendingProposalSession(userID)
lines := []string{}
if hasActiveTask || strings.TrimSpace(activeSkill.Name) != "" || hasActiveWorkflowSession(activeWorkflow) || hasActiveExecutionState(activeExec) || hasPendingProposal {
summary := strings.TrimSpace(buildTopLevelActiveFlowSummary(lang, activeSkill, activeTask, hasActiveTask, activeWorkflow, activeExec, pendingProposal, hasPendingProposal))
if summary != "" {
lines = append(lines, summary)
}
}
taskState := normalizeTaskState(a.getTaskState(userID))
if taskState.CurrentGoal != "" {
lines = append(lines, "Durable goal: "+taskState.CurrentGoal)
}
if taskState.ActiveFlow != "" {
lines = append(lines, "Durable active flow: "+taskState.ActiveFlow)
}
if len(taskState.OpenLoops) > 0 {
limit := len(taskState.OpenLoops)
if limit > 3 {
limit = 3
}
for _, loop := range taskState.OpenLoops[:limit] {
lines = append(lines, "Open loop: "+loop)
}
}
if hasActiveExecutionState(activeExec) {
lines = append(lines, fmt.Sprintf("Execution status: %s", activeExec.Status))
if strings.TrimSpace(activeExec.Goal) != "" {
lines = append(lines, "Execution goal: "+strings.TrimSpace(activeExec.Goal))
}
if activeExec.Waiting != nil && strings.TrimSpace(activeExec.Waiting.Question) != "" {
lines = append(lines, "Waiting question: "+strings.TrimSpace(activeExec.Waiting.Question))
}
if strings.TrimSpace(activeExec.CurrentStepID) != "" {
lines = append(lines, "Current step id: "+strings.TrimSpace(activeExec.CurrentStepID))
}
}
if len(lines) == 0 {
return ""
}
return strings.Join(lines, "\n")
}

View File

@@ -1,25 +0,0 @@
package agent
import "strings"
const nofxiAdvisorSystemPreamble = `You are NOFXi, the core intelligence hub of the NOFX platform.
You understand NOFX's underlying logic, feature boundaries, and quantitative operating model.
Your first duty is not blind execution. You act as the user's senior quantitative advisor so every NOFX configuration is correct, safe, and logically consistent.
When the user runs into a problem, combine the current state with NOFX platform constraints, proactively diagnose what is wrong, and provide concrete next steps.
User-facing response style rules:
- Treat the user like a trading beginner, not a developer.
- Prefer simple, plain language over technical jargon.
- Lead with the conclusion first, then one or two concrete next steps.
- Keep sentences short and easy to scan.
- If you must use a technical term, explain it in everyday words immediately.
- Do not expose internal architecture, tool names, JSON fields, or implementation details unless the user explicitly asks for them.
- When asking follow-up questions, make them specific, friendly, and easy to answer.`
func prependNOFXiAdvisorPreamble(body string) string {
body = strings.TrimSpace(body)
if body == "" {
return nofxiAdvisorSystemPreamble
}
return nofxiAdvisorSystemPreamble + "\n\n" + body
}

View File

@@ -1,101 +0,0 @@
package agent
import (
"encoding/json"
"fmt"
"strings"
"time"
)
type ReferenceMemory struct {
CurrentReferences *CurrentReferences `json:"current_references,omitempty"`
ReferenceHistory []ReferenceRecord `json:"reference_history,omitempty"`
UpdatedAt string `json:"updated_at,omitempty"`
}
func referenceMemoryConfigKey(userID int64) string {
return fmt.Sprintf("agent_reference_memory_%d", userID)
}
func (a *Agent) getReferenceMemory(userID int64) ReferenceMemory {
if a == nil || a.store == nil {
return ReferenceMemory{}
}
raw, err := a.store.GetSystemConfig(referenceMemoryConfigKey(userID))
if err != nil {
return ReferenceMemory{}
}
raw = strings.TrimSpace(raw)
if raw == "" {
return ReferenceMemory{}
}
var memory ReferenceMemory
if err := json.Unmarshal([]byte(raw), &memory); err != nil {
return ReferenceMemory{}
}
memory.CurrentReferences = normalizeCurrentReferences(memory.CurrentReferences)
memory.ReferenceHistory = normalizeReferenceHistory(memory.ReferenceHistory)
return memory
}
func (a *Agent) saveReferenceMemory(userID int64, refs *CurrentReferences, history []ReferenceRecord) {
if a == nil || a.store == nil {
return
}
memory := ReferenceMemory{
CurrentReferences: normalizeCurrentReferences(refs),
ReferenceHistory: normalizeReferenceHistory(history),
UpdatedAt: time.Now().UTC().Format(time.RFC3339),
}
if memory.CurrentReferences == nil && len(memory.ReferenceHistory) == 0 {
_ = a.store.SetSystemConfig(referenceMemoryConfigKey(userID), "")
return
}
data, err := json.Marshal(memory)
if err != nil {
return
}
_ = a.store.SetSystemConfig(referenceMemoryConfigKey(userID), string(data))
}
func (a *Agent) clearReferenceMemory(userID int64) {
if a == nil || a.store == nil {
return
}
_ = a.store.SetSystemConfig(referenceMemoryConfigKey(userID), "")
}
func (a *Agent) semanticCurrentReferences(userID int64) *CurrentReferences {
state := a.getExecutionState(userID)
if refs := normalizeCurrentReferences(state.CurrentReferences); refs != nil {
return refs
}
return a.getReferenceMemory(userID).CurrentReferences
}
func (a *Agent) semanticReferenceHistory(userID int64) []ReferenceRecord {
state := a.getExecutionState(userID)
if history := normalizeReferenceHistory(state.ReferenceHistory); len(history) > 0 {
return history
}
return a.getReferenceMemory(userID).ReferenceHistory
}
func (a *Agent) rememberReferencesFromToolResult(userID int64, toolName, raw string) {
if a == nil {
return
}
memory := a.getReferenceMemory(userID)
state := ExecutionState{
UserID: userID,
CurrentReferences: memory.CurrentReferences,
ReferenceHistory: memory.ReferenceHistory,
}
if !updateCurrentReferencesFromToolResult(&state, toolName, raw) {
return
}
a.saveReferenceMemory(userID, state.CurrentReferences, state.ReferenceHistory)
execState := a.getExecutionState(userID)
execState.CurrentReferences = state.CurrentReferences
a.saveExecutionState(execState)
}

View File

@@ -1,127 +0,0 @@
package agent
import (
"context"
"fmt"
"log/slog"
"nofx/safe"
"strings"
"sync"
"time"
)
type Scheduler struct {
agent *Agent
logger *slog.Logger
stopCh chan struct{}
stopOnce sync.Once
}
func NewScheduler(a *Agent, l *slog.Logger) *Scheduler {
return &Scheduler{agent: a, logger: l, stopCh: make(chan struct{})}
}
func (s *Scheduler) Start(ctx context.Context) {
safe.GoNamed("agent-scheduler", func() {
ticker := time.NewTicker(1 * time.Minute)
defer ticker.Stop()
lastReport := time.Time{}
lastCheck := time.Time{}
for {
select {
case <-ctx.Done():
return
case <-s.stopCh:
return
case now := <-ticker.C:
// Daily report at 21:00
if now.Hour() == 21 && now.Sub(lastReport) > 12*time.Hour {
s.dailyReport()
lastReport = now
}
// Position risk check every 4h
if now.Sub(lastCheck) > 4*time.Hour {
s.riskCheck()
lastCheck = now
}
// Clean expired pending trades every hour.
if now.Minute() == 0 {
if s.agent.pending != nil {
s.agent.pending.CleanExpired()
}
}
}
}
})
}
func (s *Scheduler) Stop() {
s.stopOnce.Do(func() {
close(s.stopCh)
})
}
func (s *Scheduler) dailyReport() {
if s.agent.traderManager == nil {
return
}
traders := s.agent.traderManager.GetAllTraders()
if len(traders) == 0 {
return
}
var sb strings.Builder
sb.WriteString(fmt.Sprintf("📊 *NOFXi 每日报告 — %s*\n\n", time.Now().Format("2006-01-02")))
totalPnL := 0.0
for _, t := range traders {
info, err := t.GetAccountInfo()
if err != nil {
continue
}
equity := toFloat(info["total_equity"])
pnl := toFloat(info["unrealized_pnl"])
sb.WriteString(fmt.Sprintf("• %s: $%.2f (P/L: $%.2f)\n", t.GetName(), equity, pnl))
totalPnL += pnl
}
e := "📈"
if totalPnL < 0 {
e = "📉"
}
sb.WriteString(fmt.Sprintf("\n%s Total P/L: $%.2f", e, totalPnL))
s.agent.notifyAll(sb.String())
}
func (s *Scheduler) riskCheck() {
if s.agent.traderManager == nil {
return
}
var alerts []string
for _, t := range s.agent.traderManager.GetAllTraders() {
positions, err := t.GetPositions()
if err != nil {
continue
}
for _, p := range positions {
pnl := toFloat(p["unrealizedPnl"])
size := toFloat(p["size"])
if size == 0 {
continue
}
entry := toFloat(p["entryPrice"])
if entry > 0 {
pnlPct := (pnl / (entry * size)) * 100
if pnlPct < -5 {
alerts = append(alerts, fmt.Sprintf("⚠️ *%s* %s: %.1f%% ($%.2f)",
p["symbol"], p["side"], pnlPct, pnl))
}
}
}
}
if len(alerts) > 0 {
s.agent.notifyAll("🚨 *持仓风险提醒*\n\n" + strings.Join(alerts, "\n"))
}
}

View File

@@ -1,222 +0,0 @@
package agent
import (
"encoding/json"
"fmt"
"log/slog"
"math"
"net/http"
"nofx/safe"
"strconv"
"strings"
"sync"
"time"
)
type SignalType string
const (
SignalPriceBreakout SignalType = "price_breakout"
SignalVolumeSpike SignalType = "volume_spike"
SignalFundingRate SignalType = "funding_rate"
)
type Signal struct {
Type SignalType
Symbol string
Severity string
Title string
Detail string
Price float64
Change float64
}
type SignalCallback func(Signal)
type Sentinel struct {
mu sync.RWMutex
symbols []string
history map[string][]pricePt
onSignal SignalCallback
http *http.Client
logger *slog.Logger
stopCh chan struct{}
stopOnce sync.Once
}
type pricePt struct {
Price float64
Volume float64
Time time.Time
}
func NewSentinel(symbols []string, cb SignalCallback, logger *slog.Logger) *Sentinel {
return &Sentinel{
symbols: symbols,
history: make(map[string][]pricePt),
onSignal: cb,
http: &http.Client{Timeout: 10 * time.Second},
logger: logger,
stopCh: make(chan struct{}),
}
}
func (s *Sentinel) Start() {
safe.GoNamed("sentinel", func() {
ticker := time.NewTicker(60 * time.Second)
defer ticker.Stop()
s.scan()
for {
select {
case <-s.stopCh:
return
case <-ticker.C:
s.scan()
}
}
})
}
func (s *Sentinel) Stop() { s.stopOnce.Do(func() { close(s.stopCh) }) }
func (s *Sentinel) SymbolCount() int { s.mu.RLock(); defer s.mu.RUnlock(); return len(s.symbols) }
func (s *Sentinel) Symbols() []string {
s.mu.RLock()
defer s.mu.RUnlock()
out := make([]string, len(s.symbols))
copy(out, s.symbols)
return out
}
func (s *Sentinel) AddSymbol(sym string) {
s.mu.Lock()
defer s.mu.Unlock()
for _, x := range s.symbols {
if x == sym {
return
}
}
s.symbols = append(s.symbols, sym)
}
func (s *Sentinel) RemoveSymbol(sym string) {
s.mu.Lock()
defer s.mu.Unlock()
for i, x := range s.symbols {
if x == sym {
s.symbols = append(s.symbols[:i], s.symbols[i+1:]...)
return
}
}
}
func (s *Sentinel) FormatWatchlist(L string) string {
s.mu.RLock()
defer s.mu.RUnlock()
if len(s.symbols) == 0 {
if L == "zh" {
return "📭 监控列表为空。用 `/watch BTC` 添加。"
}
return "📭 Watchlist empty. Use `/watch BTC` to add."
}
var sb strings.Builder
if L == "zh" {
sb.WriteString("👁️ *监控列表*\n\n")
} else {
sb.WriteString("👁️ *Watchlist*\n\n")
}
for _, sym := range s.symbols {
if pts, ok := s.history[sym]; ok && len(pts) > 0 {
last := pts[len(pts)-1]
sb.WriteString(fmt.Sprintf("• *%s*: $%.4f (%s)\n", sym, last.Price, last.Time.Format("15:04")))
} else {
sb.WriteString(fmt.Sprintf("• *%s*: waiting...\n", sym))
}
}
return sb.String()
}
func (s *Sentinel) scan() {
s.mu.RLock()
syms := make([]string, len(s.symbols))
copy(syms, s.symbols)
s.mu.RUnlock()
for _, sym := range syms {
s.check(sym)
}
}
func (s *Sentinel) check(symbol string) {
resp, err := s.http.Get(fmt.Sprintf("https://fapi.binance.com/fapi/v1/ticker/24hr?symbol=%s", symbol))
if err != nil {
return
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
s.logger.Debug("sentinel ticker non-200", "symbol", symbol, "status", resp.StatusCode)
return
}
body, err := safe.ReadAllLimited(resp.Body, 256*1024) // 256KB limit
if err != nil {
return
}
var t map[string]interface{}
if err := json.Unmarshal(body, &t); err != nil {
return
}
price, _ := strconv.ParseFloat(fmt.Sprint(t["lastPrice"]), 64)
vol, _ := strconv.ParseFloat(fmt.Sprint(t["quoteVolume"]), 64)
chg, _ := strconv.ParseFloat(fmt.Sprint(t["priceChangePercent"]), 64)
pt := pricePt{Price: price, Volume: vol, Time: time.Now()}
s.mu.Lock()
h := s.history[symbol]
h = append(h, pt)
if len(h) > 60 {
h = h[len(h)-60:]
}
s.history[symbol] = h
s.mu.Unlock()
if len(h) < 5 {
return
}
// Price breakout (>3% in 5 min)
old := h[len(h)-5]
pct := ((price - old.Price) / old.Price) * 100
if math.Abs(pct) >= 3.0 {
sev := "warning"
if math.Abs(pct) >= 6.0 {
sev = "critical"
}
dir := "📈 拉升"
if pct < 0 {
dir = "📉 下跌"
}
s.emit(Signal{Type: SignalPriceBreakout, Symbol: symbol, Severity: sev,
Title: fmt.Sprintf("%s %s %.1f%%", symbol, dir, math.Abs(pct)),
Detail: fmt.Sprintf("5min: $%.2f → $%.2f (24h: %.1f%%)", old.Price, price, chg),
Price: price, Change: pct})
}
// Volume spike (>3x avg)
if len(h) >= 10 {
var avg float64
for i := 0; i < len(h)-1; i++ {
avg += h[i].Volume
}
avg /= float64(len(h) - 1)
if avg > 0 && vol > avg*3 {
s.emit(Signal{Type: SignalVolumeSpike, Symbol: symbol, Severity: "warning",
Title: fmt.Sprintf("%s 成交量异常 %.1fx", symbol, vol/avg),
Detail: fmt.Sprintf("Price: $%.2f (24h: %.1f%%)", price, chg),
Price: price, Change: chg})
}
}
}
func (s *Sentinel) emit(sig Signal) {
s.logger.Info("signal", "type", sig.Type, "symbol", sig.Symbol, "title", sig.Title)
if s.onSignal != nil {
s.onSignal(sig)
}
}

View File

@@ -1,97 +0,0 @@
package agent
func skillCatalogPrompt(lang string) string {
if lang == "zh" {
return `## 多轮与 Skill-First 工作模式
- 对于高频已知任务,优先按 skill 执行,不要每次从零规划
- 如果用户仍在同一任务里,继续当前 flow不要重新路由
- 只追问继续执行所需的最少必要字段,不要让用户重复已确认信息
- 高风险动作(删除、启动实盘、停止运行中 trader、覆盖关键配置必须单独确认
- 对诊断类问题,优先做“问题归类 -> 可能原因 -> 核查项 -> 下一步建议”
## 当前重点技能
### 1. 模型配置与诊断
- ` + "`skill_model_api_setup`" + `:用户问某个大模型的 API key 去哪申请、base URL 怎么填、model name 怎么填时,给步骤化指导
- ` + "`skill_model_config_diagnosis`" + `:当用户遇到模型配置失败、调用失败、保存后不可用时,优先检查:
1. 是否已启用模型
2. API Key 是否为空
3. custom_api_url 是否为合法 HTTPS 地址
4. custom_model_name 是否为空或填错
5. 保存后是否需要重新加载 trader
- 已知事实:
- 系统会拒绝非 HTTPS 的 custom_api_url
- 已启用模型如果缺少 API Key 或 custom_api_url会导致 agent 不可用
### 2. 交易所配置与诊断
- ` + "`skill_exchange_api_setup`" + `:指导用户创建交易所 API明确需要哪些权限、哪些权限不要开、哪些交易所需要额外字段
- ` + "`skill_exchange_api_diagnosis`" + `:用户遇到 invalid signature、timestamp、permission denied、IP not allowed 时,优先排查:
1. 系统时间是否同步
2. API Key / Secret 是否填反或过期
3. IP 白名单是否包含服务器 IP
4. 是否启用了合约/交易权限
5. OKX 是否遗漏 passphrase
- 已知事实:
- OKX 除 API Key 和 Secret 外还需要 passphrase
- invalid signature / timestamp 常见根因是时间不同步或密钥不匹配
### 3. Trader 启动与运行诊断
- ` + "`skill_trader_start_diagnosis`" + `:当用户说 trader 启动不了、启动后不交易、没有持仓、没有决策时,优先排查:
1. 是否存在可用且启用的模型配置
2. 是否存在可用且启用的交易所配置
3. trader 绑定的 strategy / exchange / model 是否齐全
4. 账户余额和权限是否满足下单要求
5. AI 是否一直返回 wait / hold
- 如果用户问“为什么没有开仓”,要明确区分:
- 系统没启动
- 启动了但 AI 决策为 wait
- 有信号但下单失败
### 4. 交易行为异常诊断
- ` + "`skill_order_execution_diagnosis`" + `:当用户问仓位开不出来、只开单边、杠杆报错时,优先排查:
1. 是否为交易所模式问题(例如 Binance One-way / Hedge Mode
2. 是否为子账户杠杆限制
3. 是否为合约权限或 symbol 不可交易
4. 是否为余额不足或保证金占用过高
- 已知事实:
- Binance 若不是 Hedge Mode可能出现 position side mismatch 或只开单边
- 某些子账户杠杆受限,超过限制会直接报错
### 5. 策略与提示词诊断
- ` + "`skill_strategy_diagnosis`" + `:当用户说策略没生效、提示词不对、预览和实际不一致时,优先建议:
1. 查看当前 strategy 配置
2. 区分策略模板本身和 trader 上的 custom prompt
3. 必要时预览 prompt 或读取当前保存值后再判断
## 回答格式要求
- 诊断类问题尽量按“现象 / 原因 / 先检查什么 / 怎么修复”回答
- 配置指导类问题尽量按步骤回答
- 如果已有工具能验证当前状态,先查再下结论
- 如果结论是推测,必须明确说是“更可能”或“优先怀疑”`
}
return `## Multi-turn and Skill-First Operating Mode
- For high-frequency known tasks, prefer stable skills instead of replanning from scratch
- If the user is still in the same task, continue the active flow
- Ask only for the minimum missing fields required to proceed
- Require explicit confirmation for destructive or financially sensitive actions
- For diagnostic requests, use: issue class -> likely causes -> checks -> next steps
## Priority Skills
- skill_model_api_setup / skill_model_config_diagnosis
- skill_exchange_api_setup / skill_exchange_api_diagnosis
- skill_trader_start_diagnosis
- skill_order_execution_diagnosis
- skill_strategy_diagnosis
Known facts:
- custom_api_url must be a valid HTTPS URL
- OKX requires passphrase in addition to API key and secret
- invalid signature / timestamp often means clock skew or mismatched credentials
- missing enabled model or exchange config can block trader startup
- Binance position-side issues are often caused by One-way Mode vs Hedge Mode
Response style:
- Diagnostics: symptom -> cause -> checks -> fix
- Setup guidance: step-by-step
- Verify with tools when possible before concluding`
}

View File

@@ -1,291 +0,0 @@
package agent
import "strings"
type SkillDAG struct {
SkillName string
Action string
Steps []SkillDAGStep
}
type SkillDAGStep struct {
ID string
Kind string
RequiredFields []string
OptionalFields []string
Next []string
Terminal bool
}
var skillDAGRegistry = buildSkillDAGRegistry()
func buildSkillDAGRegistry() map[string]SkillDAG {
dags := []SkillDAG{
{
SkillName: "trader_management",
Action: "create",
Steps: []SkillDAGStep{
{ID: "resolve_name", Kind: "collect_slot", RequiredFields: []string{"name"}, Next: []string{"resolve_exchange"}},
{ID: "resolve_exchange", Kind: "collect_slot", RequiredFields: []string{"exchange_id"}, OptionalFields: []string{"exchange_name"}, Next: []string{"resolve_model"}},
{ID: "resolve_model", Kind: "collect_slot", RequiredFields: []string{"model_id"}, OptionalFields: []string{"model_name"}, Next: []string{"resolve_strategy"}},
{ID: "resolve_strategy", Kind: "collect_slot", RequiredFields: []string{"strategy_id"}, OptionalFields: []string{"strategy_name"}, Next: []string{"maybe_confirm_start"}},
{ID: "maybe_confirm_start", Kind: "branch", OptionalFields: []string{"auto_start"}, Next: []string{"await_start_confirmation", "execute_create_only"}},
{ID: "await_start_confirmation", Kind: "confirm", RequiredFields: []string{"auto_start"}, Next: []string{"execute_create_and_start", "execute_create_only"}},
{ID: "execute_create_only", Kind: "execute", RequiredFields: []string{"name", "exchange_id", "model_id", "strategy_id"}, Terminal: true},
{ID: "execute_create_and_start", Kind: "execute", RequiredFields: []string{"name", "exchange_id", "model_id", "strategy_id"}, OptionalFields: []string{"auto_start"}, Terminal: true},
},
},
{
SkillName: "trader_management",
Action: "update_bindings",
Steps: []SkillDAGStep{
{ID: "resolve_target", Kind: "resolve_target", RequiredFields: []string{"target_ref"}, Next: []string{"collect_bindings"}},
{ID: "collect_bindings", Kind: "collect_slot", RequiredFields: []string{"binding_update"}, OptionalFields: []string{"ai_model_id", "exchange_id", "strategy_id"}, Next: []string{"execute_update"}},
{ID: "execute_update", Kind: "execute", RequiredFields: []string{"target_ref", "binding_update"}, OptionalFields: []string{"ai_model_id", "exchange_id", "strategy_id"}, Terminal: true},
},
},
{
SkillName: "trader_management",
Action: "configure_strategy",
Steps: []SkillDAGStep{
{ID: "resolve_target", Kind: "resolve_target", RequiredFields: []string{"target_ref"}, Next: []string{"collect_bindings"}},
{ID: "collect_bindings", Kind: "collect_slot", RequiredFields: []string{"binding_update"}, OptionalFields: []string{"strategy_id"}, Next: []string{"execute_update"}},
{ID: "execute_update", Kind: "execute", RequiredFields: []string{"target_ref", "binding_update", "strategy_id"}, Terminal: true},
},
},
{
SkillName: "trader_management",
Action: "configure_exchange",
Steps: []SkillDAGStep{
{ID: "resolve_target", Kind: "resolve_target", RequiredFields: []string{"target_ref"}, Next: []string{"collect_bindings"}},
{ID: "collect_bindings", Kind: "collect_slot", RequiredFields: []string{"binding_update"}, OptionalFields: []string{"exchange_id"}, Next: []string{"execute_update"}},
{ID: "execute_update", Kind: "execute", RequiredFields: []string{"target_ref", "binding_update", "exchange_id"}, Terminal: true},
},
},
{
SkillName: "trader_management",
Action: "configure_model",
Steps: []SkillDAGStep{
{ID: "resolve_target", Kind: "resolve_target", RequiredFields: []string{"target_ref"}, Next: []string{"collect_bindings"}},
{ID: "collect_bindings", Kind: "collect_slot", RequiredFields: []string{"binding_update"}, OptionalFields: []string{"ai_model_id"}, Next: []string{"execute_update"}},
{ID: "execute_update", Kind: "execute", RequiredFields: []string{"target_ref", "binding_update", "ai_model_id"}, Terminal: true},
},
},
{
SkillName: "trader_management",
Action: "start",
Steps: []SkillDAGStep{
{ID: "resolve_target", Kind: "resolve_target", RequiredFields: []string{"target_ref"}, Next: []string{"await_confirmation"}},
{ID: "await_confirmation", Kind: "confirm", RequiredFields: []string{"target_ref"}, Next: []string{"execute_start"}},
{ID: "execute_start", Kind: "execute", RequiredFields: []string{"target_ref"}, Terminal: true},
},
},
{
SkillName: "trader_management",
Action: "stop",
Steps: []SkillDAGStep{
{ID: "resolve_target", Kind: "resolve_target", RequiredFields: []string{"target_ref"}, Next: []string{"await_confirmation"}},
{ID: "await_confirmation", Kind: "confirm", RequiredFields: []string{"target_ref"}, Next: []string{"execute_stop"}},
{ID: "execute_stop", Kind: "execute", RequiredFields: []string{"target_ref"}, Terminal: true},
},
},
{
SkillName: "trader_management",
Action: "delete",
Steps: []SkillDAGStep{
{ID: "resolve_target", Kind: "resolve_target", RequiredFields: []string{"target_ref"}, Next: []string{"await_confirmation"}},
{ID: "await_confirmation", Kind: "confirm", RequiredFields: []string{"target_ref"}, Next: []string{"execute_delete"}},
{ID: "execute_delete", Kind: "execute", RequiredFields: []string{"target_ref"}, Terminal: true},
},
},
{
SkillName: "strategy_management",
Action: "create",
Steps: []SkillDAGStep{
{ID: "resolve_name", Kind: "collect_slot", RequiredFields: []string{"name"}, OptionalFields: []string{"lang", "description", "config"}, Next: []string{"execute_create"}},
{ID: "execute_create", Kind: "execute", RequiredFields: []string{"name"}, OptionalFields: []string{"lang", "description", "config"}, Terminal: true},
},
},
{
SkillName: "strategy_management",
Action: "update_name",
Steps: []SkillDAGStep{
{ID: "resolve_target", Kind: "resolve_target", RequiredFields: []string{"target_ref"}, Next: []string{"collect_name"}},
{ID: "collect_name", Kind: "collect_slot", RequiredFields: []string{"name"}, Next: []string{"execute_update"}},
{ID: "execute_update", Kind: "execute", RequiredFields: []string{"target_ref", "name"}, Terminal: true},
},
},
{
SkillName: "strategy_management",
Action: "update_prompt",
Steps: []SkillDAGStep{
{ID: "resolve_target", Kind: "resolve_target", RequiredFields: []string{"target_ref"}, Next: []string{"collect_prompt"}},
{ID: "collect_prompt", Kind: "collect_slot", RequiredFields: []string{"prompt"}, Next: []string{"load_config"}},
{ID: "load_config", Kind: "load_state", RequiredFields: []string{"target_ref"}, Next: []string{"execute_update"}},
{ID: "execute_update", Kind: "execute", RequiredFields: []string{"target_ref", "prompt"}, Terminal: true},
},
},
{
SkillName: "strategy_management",
Action: "update_config",
Steps: []SkillDAGStep{
{ID: "resolve_target", Kind: "resolve_target", RequiredFields: []string{"target_ref"}, Next: []string{"collect_config_patch"}},
{ID: "collect_config_patch", Kind: "collect_slot", RequiredFields: []string{"config_patch"}, Next: []string{"execute_update"}},
{ID: "execute_update", Kind: "execute", RequiredFields: []string{"target_ref", "config_patch"}, Terminal: true},
},
},
{
SkillName: "strategy_management",
Action: "duplicate",
Steps: []SkillDAGStep{
{ID: "resolve_target", Kind: "resolve_target", RequiredFields: []string{"target_ref"}, Next: []string{"collect_name"}},
{ID: "collect_name", Kind: "collect_slot", RequiredFields: []string{"name"}, Next: []string{"execute_duplicate"}},
{ID: "execute_duplicate", Kind: "execute", RequiredFields: []string{"target_ref", "name"}, Terminal: true},
},
},
{
SkillName: "strategy_management",
Action: "activate",
Steps: []SkillDAGStep{
{ID: "resolve_target", Kind: "resolve_target", RequiredFields: []string{"target_ref"}, Next: []string{"execute_activate"}},
{ID: "execute_activate", Kind: "execute", RequiredFields: []string{"target_ref"}, Terminal: true},
},
},
{
SkillName: "strategy_management",
Action: "delete",
Steps: []SkillDAGStep{
{ID: "resolve_target", Kind: "resolve_target", RequiredFields: []string{"target_ref"}, Next: []string{"await_confirmation"}},
{ID: "await_confirmation", Kind: "confirm", RequiredFields: []string{"target_ref"}, Next: []string{"execute_delete"}},
{ID: "execute_delete", Kind: "execute", RequiredFields: []string{"target_ref"}, Terminal: true},
},
},
{
SkillName: "model_management",
Action: "create",
Steps: []SkillDAGStep{
{ID: "resolve_provider", Kind: "collect_slot", RequiredFields: []string{"provider"}, Next: []string{"collect_optional_fields"}},
{ID: "collect_optional_fields", Kind: "collect_slot", OptionalFields: []string{"name", "custom_api_url", "custom_model_name"}, Next: []string{"execute_create"}},
{ID: "execute_create", Kind: "execute", RequiredFields: []string{"provider"}, OptionalFields: []string{"name", "custom_api_url", "custom_model_name"}, Terminal: true},
},
},
{
SkillName: "model_management",
Action: "update_status",
Steps: []SkillDAGStep{
{ID: "resolve_target", Kind: "resolve_target", RequiredFields: []string{"target_ref"}, Next: []string{"collect_enabled"}},
{ID: "collect_enabled", Kind: "collect_slot", RequiredFields: []string{"enabled"}, Next: []string{"execute_update"}},
{ID: "execute_update", Kind: "execute", RequiredFields: []string{"target_ref", "enabled"}, Terminal: true},
},
},
{
SkillName: "model_management",
Action: "update_endpoint",
Steps: []SkillDAGStep{
{ID: "resolve_target", Kind: "resolve_target", RequiredFields: []string{"target_ref"}, Next: []string{"collect_custom_api_url"}},
{ID: "collect_custom_api_url", Kind: "collect_slot", RequiredFields: []string{"custom_api_url"}, Next: []string{"execute_update"}},
{ID: "execute_update", Kind: "execute", RequiredFields: []string{"target_ref", "custom_api_url"}, Terminal: true},
},
},
{
SkillName: "model_management",
Action: "update_name",
Steps: []SkillDAGStep{
{ID: "resolve_target", Kind: "resolve_target", RequiredFields: []string{"target_ref"}, Next: []string{"collect_custom_model_name"}},
{ID: "collect_custom_model_name", Kind: "collect_slot", RequiredFields: []string{"custom_model_name"}, Next: []string{"execute_update"}},
{ID: "execute_update", Kind: "execute", RequiredFields: []string{"target_ref", "custom_model_name"}, Terminal: true},
},
},
{
SkillName: "model_management",
Action: "delete",
Steps: []SkillDAGStep{
{ID: "resolve_target", Kind: "resolve_target", RequiredFields: []string{"target_ref"}, Next: []string{"await_confirmation"}},
{ID: "await_confirmation", Kind: "confirm", RequiredFields: []string{"target_ref"}, Next: []string{"execute_delete"}},
{ID: "execute_delete", Kind: "execute", RequiredFields: []string{"target_ref"}, Terminal: true},
},
},
{
SkillName: "exchange_management",
Action: "create",
Steps: []SkillDAGStep{
{ID: "resolve_exchange_type", Kind: "collect_slot", RequiredFields: []string{"exchange_type"}, Next: []string{"collect_account_name"}},
{ID: "collect_account_name", Kind: "collect_slot", OptionalFields: []string{"account_name"}, Next: []string{"execute_create"}},
{ID: "execute_create", Kind: "execute", RequiredFields: []string{"exchange_type"}, OptionalFields: []string{"account_name"}, Terminal: true},
},
},
{
SkillName: "exchange_management",
Action: "update_name",
Steps: []SkillDAGStep{
{ID: "resolve_target", Kind: "resolve_target", RequiredFields: []string{"target_ref"}, Next: []string{"collect_account_name"}},
{ID: "collect_account_name", Kind: "collect_slot", RequiredFields: []string{"account_name"}, Next: []string{"execute_update"}},
{ID: "execute_update", Kind: "execute", RequiredFields: []string{"target_ref", "account_name"}, Terminal: true},
},
},
{
SkillName: "exchange_management",
Action: "update_status",
Steps: []SkillDAGStep{
{ID: "resolve_target", Kind: "resolve_target", RequiredFields: []string{"target_ref"}, Next: []string{"collect_enabled"}},
{ID: "collect_enabled", Kind: "collect_slot", RequiredFields: []string{"enabled"}, Next: []string{"execute_update"}},
{ID: "execute_update", Kind: "execute", RequiredFields: []string{"target_ref", "enabled"}, Terminal: true},
},
},
{
SkillName: "exchange_management",
Action: "delete",
Steps: []SkillDAGStep{
{ID: "resolve_target", Kind: "resolve_target", RequiredFields: []string{"target_ref"}, Next: []string{"await_confirmation"}},
{ID: "await_confirmation", Kind: "confirm", RequiredFields: []string{"target_ref"}, Next: []string{"execute_delete"}},
{ID: "execute_delete", Kind: "execute", RequiredFields: []string{"target_ref"}, Terminal: true},
},
},
}
registry := make(map[string]SkillDAG, len(dags))
for _, dag := range dags {
dag = normalizeSkillDAG(dag)
if dag.SkillName == "" || dag.Action == "" {
continue
}
registry[skillDAGKey(dag.SkillName, dag.Action)] = dag
}
return registry
}
func normalizeSkillDAG(dag SkillDAG) SkillDAG {
dag.SkillName = strings.TrimSpace(dag.SkillName)
dag.Action = strings.TrimSpace(dag.Action)
steps := make([]SkillDAGStep, 0, len(dag.Steps))
for _, step := range dag.Steps {
step.ID = strings.TrimSpace(step.ID)
step.Kind = strings.TrimSpace(step.Kind)
step.RequiredFields = cleanStringList(step.RequiredFields)
step.OptionalFields = cleanStringList(step.OptionalFields)
step.Next = cleanStringList(step.Next)
if step.ID == "" {
continue
}
steps = append(steps, step)
}
dag.Steps = steps
return dag
}
func skillDAGKey(skillName, action string) string {
return strings.TrimSpace(skillName) + ":" + strings.TrimSpace(action)
}
func getSkillDAG(skillName, action string) (SkillDAG, bool) {
dag, ok := skillDAGRegistry[skillDAGKey(skillName, action)]
return dag, ok
}
func listSkillDAGs() []SkillDAG {
out := make([]SkillDAG, 0, len(skillDAGRegistry))
for _, dag := range skillDAGRegistry {
out = append(out, dag)
}
return out
}

View File

@@ -1,51 +0,0 @@
package agent
const skillDAGStepField = "_dag_step"
func currentSkillDAGStep(session skillSession) (SkillDAGStep, bool) {
dag, ok := getSkillDAG(session.Name, session.Action)
if !ok || len(dag.Steps) == 0 {
return SkillDAGStep{}, false
}
stepID := fieldValue(session, skillDAGStepField)
if stepID == "" {
return dag.Steps[0], true
}
for _, step := range dag.Steps {
if step.ID == stepID {
return step, true
}
}
return dag.Steps[0], true
}
func setSkillDAGStep(session *skillSession, stepID string) {
ensureSkillFields(session)
if stepID == "" {
delete(session.Fields, skillDAGStepField)
return
}
session.Fields[skillDAGStepField] = stepID
}
func clearSkillDAGStep(session *skillSession) {
if session == nil || session.Fields == nil {
return
}
delete(session.Fields, skillDAGStepField)
}
func advanceSkillDAGStep(session *skillSession, currentStepID string) {
dag, ok := getSkillDAG(session.Name, session.Action)
if !ok {
return
}
for _, step := range dag.Steps {
if step.ID != currentStepID || len(step.Next) == 0 {
continue
}
setSkillDAGStep(session, step.Next[0])
return
}
}

File diff suppressed because it is too large Load Diff

View File

@@ -1,209 +0,0 @@
package agent
import "strings"
func buildSkillDomainPrimer(lang, skillName string) string {
skillName = strings.TrimSpace(skillName)
if skillName == "" {
return ""
}
switch skillName {
case "model_management":
fields := []string{
fieldKnowledgeDisplayName("provider", lang),
displayCatalogFieldName("name", lang),
displayCatalogFieldName("api_key", lang),
displayCatalogFieldName("custom_api_url", lang),
displayCatalogFieldName("custom_model_name", lang),
displayCatalogFieldName("enabled", lang),
}
if lang == "zh" {
return strings.Join([]string{
"### 模型配置领域约束",
"- 当前领域是 AI 模型配置,不是交易所配置。",
"- provider 指模型厂商,不是交易所类型。",
"- 关键字段:" + strings.Join(fields, "、"),
"- 候选 provider" + modelProviderSummaryList(lang),
"- 推荐 providerclaw402。claw402 是 NOFXi 官方推荐方案,按次付费,使用 Base 链 EVM 钱包 + USDC 支付。",
"- 如果用户不确定选哪个 provider可以优先推荐 claw402 并说明其优势,但绝不能替用户自动选中 claw402必须先展示完整 provider 选项并让用户自己选择。",
"- 如果 provider 还没选定,下一步必须先让用户从完整 provider 列表里选一个,不能先收集 API Key、钱包私钥或其他凭证。",
"- 普通 provideropenai/deepseek/claude 等)通常要填 API Keycustom_model_name 和 custom_api_url 可以留空走默认值。",
"- claw402 需要钱包私钥custom_model_name 留空时默认 deepseek。",
"- blockrun-base / blockrun-sol 走钱包私钥模式,不需要 custom_api_urlcustom_model_name 默认 auto。",
}, "\n")
}
return strings.Join([]string{
"### Model Config Domain Guard",
"- The current domain is AI model configuration, not exchange configuration.",
"- provider means the model vendor, not an exchange venue.",
"- Key fields: " + strings.Join(fields, ", "),
"- Supported providers: " + modelProviderSummaryList(lang),
"- Recommended provider: claw402. claw402 is the NOFXi recommended pay-per-use option that uses a Base chain wallet + USDC.",
"- If the user is unsure which provider to pick, you may recommend claw402 and explain its advantages, but you must not auto-select claw402 for them. Show the full provider options first and let the user choose.",
"- If provider is still missing, the next step must be to ask the user to choose one from the full provider list. Do not ask for an API key, wallet private key, or other credentials before the provider is chosen.",
"- Standard providers (openai/deepseek/claude etc.) usually require an API key; `custom_model_name` and `custom_api_url` can be omitted to use defaults.",
"- claw402 uses a wallet private key and defaults to `deepseek` if `custom_model_name` is omitted.",
"- blockrun-base / blockrun-sol use wallet private keys, do not need `custom_api_url`, and default to `auto`.",
}, "\n")
case "exchange_management":
fields := []string{
slotDisplayName("exchange_type", lang),
displayCatalogFieldName("account_name", lang),
displayCatalogFieldName("api_key", lang),
displayCatalogFieldName("secret_key", lang),
displayCatalogFieldName("passphrase", lang),
displayCatalogFieldName("enabled", lang),
}
if lang == "zh" {
return strings.Join([]string{
"### 交易所配置领域约束",
"- 当前领域是交易所账户配置,不是 AI 模型配置。",
"- exchange_type 指交易所类型provider 这个词不应用来代指交易所。",
"- 关键字段:" + strings.Join(fields, "、"),
"- 支持的交易所类型:" + strings.Join(enumOptionValues("exchange_management", "exchange_type"), "、"),
}, "\n")
}
return strings.Join([]string{
"### Exchange Config Domain Guard",
"- The current domain is exchange account configuration, not AI model configuration.",
"- exchange_type means the trading venue. Do not use provider to mean an exchange.",
"- Key fields: " + strings.Join(fields, ", "),
"- Supported exchange types: " + strings.Join(enumOptionValues("exchange_management", "exchange_type"), ", "),
}, "\n")
case "trader_management":
fields := []string{
slotDisplayName("name", lang),
slotDisplayName("exchange", lang),
slotDisplayName("model", lang),
slotDisplayName("strategy", lang),
displayCatalogFieldName("scan_interval_minutes", lang),
}
if lang == "zh" {
return strings.Join([]string{
"### 交易员配置领域约束",
"- 交易员是装配层,负责创建、换绑策略/交易所/模型,以及启动、停止、删除、查询。",
"- 编辑交易员时,默认只处理绑定关系;不要顺手改策略、模型、交易所内部配置。",
"- 交易员初始余额由系统在创建时自动读取绑定交易所账户净值,不接受手动设置、充值或人为改余额。",
"- 若用户要改策略参数、模型配置或交易所凭证,应切到对应 management skill。",
"- 创建交易员时最关键的是:名称、交易所、模型、策略。",
"- 关键字段:" + strings.Join(fields, "、"),
}, "\n")
}
return strings.Join([]string{
"### Trader Config Domain Guard",
"- Traders are the assembly layer: create, rebind strategy/exchange/model, and control lifecycle.",
"- When editing a trader, default to changing bindings only; do not silently edit the internals of the strategy, model, or exchange.",
"- Trader initial balance is auto-read from the bound exchange account equity at creation time; do not ask the user to set, top up, or manually edit trader balance.",
"- If the user wants to change strategy parameters, model config, or exchange credentials, switch to the corresponding management skill.",
"- The key create fields are name, exchange, model, and strategy.",
"- Key fields: " + strings.Join(fields, ", "),
}, "\n")
case "strategy_management":
fields := []string{
slotDisplayName("name", lang),
displayCatalogFieldName("strategy_type", lang),
}
if lang == "zh" {
return strings.Join([]string{
"### 策略配置领域约束",
"- 本领域只处理策略模板。",
"- strategy_type 选项ai_trading、grid_trading。",
"- 用户提到 AI500、OI Top、OI Low、静态币种/固定币种这类选币来源时,属于 ai_trading。",
"- 策略类型确定后,只能使用当前类型的产品编辑页模板。",
"- 策略类型未确定时,只判断类型,不要展示或混合任一分支的具体配置字段。",
"- 关键字段:" + strings.Join(fields, "、"),
}, "\n")
}
return strings.Join([]string{
"### Strategy Config Domain Guard",
"- This domain only handles strategy templates.",
"- strategy_type options: ai_trading, grid_trading.",
"- AI500, OI Top, OI Low, and static coin-source requests imply ai_trading.",
"- Once strategy_type is known, use only that product editor template.",
"- Before strategy_type is known, only determine the type; do not show or mix concrete fields from either branch.",
"- Key fields: " + strings.Join(fields, ", "),
}, "\n")
default:
return ""
}
}
func buildSkillDomainPrimerForSession(lang string, session skillSession) string {
if session.Name != "strategy_management" {
return buildSkillDomainPrimer(lang, session.Name)
}
strategyType := explicitStrategyCreateType(session)
if strategyType == "" {
return buildSkillDomainPrimer(lang, session.Name)
}
if lang == "zh" {
switch strategyType {
case "ai_trading":
return strings.Join([]string{
"### AI 策略模板",
"- 只使用 ai_trading 模板strategy_type + ai_config + publish_config。",
"- config_patch 必须使用产品 schema 原值不要使用展示文案strategy_type=ai_tradingsource_type 只能是 static、ai500、oi_top、oi_low没有 mixed/混合模式。",
"- 时间周期必须输出为产品枚举字符串,例如 1m、3m、5m、15m、1hselected_timeframes 必须是字符串数组,例如 [\"1m\",\"5m\",\"15m\"],不要输出 JSON 字符串。",
"- AI500/OI Top/OI Low 选币数量范围 110static_coins 最多 10 个selected_timeframes 最多 4 个primary_count 1030。",
"- BTC/ETH 最大杠杆 120山寨币最大杠杆 120min_confidence 50100min_risk_reward_ratio 110。",
"- AI 策略创建方案不要展示或询问非 AI 模板字段:投入金额、每笔固定投入、止损、日亏损限制、最大回撤、网格字段。",
}, "\n")
case "grid_trading":
return strings.Join([]string{
"### 网格策略模板",
"- 只使用 grid_trading 模板strategy_type + grid_config + publish_configconfig_patch 必须使用产品 schema 原值strategy_type=grid_trading。",
"- 交易对选项BTCUSDT、ETHUSDT、SOLUSDT、BNBUSDT、XRPUSDT、DOGEUSDT。",
"- grid_count 550total_investment 最小 100leverage 15atr_multiplier 15。",
"- total_investment 是用户实际投入/保证金预算,不是杠杆后的名义仓位;最大名义仓位约等于 total_investment × leverage。用户说“投入/总投入/本金/保证金”时默认映射到 total_investment。",
"- max_drawdown_pct 550stop_loss_pct 120daily_loss_limit_pct 130direction_bias_ratio 0.550.90。",
"- 没有实时行情工具结果时,不要猜当前价格或手动价格上下界;推荐 use_atr_bounds=true 的 ATR 自动边界。",
"- 如果用户让你选择/推荐剩余网格参数,价格区间默认写入 use_atr_bounds=true不要反问用户手动价格区间也不要编造“当前 BTC/ETH 在某价附近”。",
}, "\n")
}
}
switch strategyType {
case "ai_trading":
return strings.Join([]string{
"### AI Strategy Template",
"- Use only ai_trading: strategy_type + ai_config + publish_config.",
"- config_patch must use product schema raw values, not display labels: strategy_type=ai_trading; source_type is only static, ai500, oi_top, or oi_low; no mixed mode.",
"- Timeframes must be product enum strings such as 1m, 3m, 5m, 15m, 1h; selected_timeframes must be a JSON string array such as [\"1m\",\"5m\",\"15m\"], not a JSON-encoded string.",
"- AI500/OI source counts 1-10; static_coins at most 10; selected_timeframes at most 4; primary_count 10-30.",
"- BTC/ETH leverage 1-20; altcoin leverage 1-20; min_confidence 50-100; min_risk_reward_ratio 1-10.",
"- Do not show or ask for non-AI-template fields in AI strategy drafts: investment amount, fixed per-trade amount, stop loss, daily loss limit, max drawdown, or grid fields.",
}, "\n")
case "grid_trading":
return strings.Join([]string{
"### Grid Strategy Template",
"- Use only grid_trading: strategy_type + grid_config + publish_config; config_patch must use product schema raw values with strategy_type=grid_trading.",
"- Symbol options: BTCUSDT, ETHUSDT, SOLUSDT, BNBUSDT, XRPUSDT, DOGEUSDT.",
"- grid_count 5-50; total_investment >=100; leverage 1-5; atr_multiplier 1-5.",
"- total_investment is the user's actual capital/margin budget, not leveraged notional exposure; maximum notional exposure is approximately total_investment * leverage. When the user says investment, capital, amount to put in, or margin, map it to total_investment by default.",
"- max_drawdown_pct 5-50; stop_loss_pct 1-20; daily_loss_limit_pct 1-30; direction_bias_ratio 0.55-0.90.",
"- Without fresh market data, do not guess the current price or manual upper/lower prices; recommend ATR auto bounds with use_atr_bounds=true.",
"- If the user asks you to choose/recommend the remaining grid parameters, default the price range to use_atr_bounds=true; do not ask for manual price bounds or invent statements like the current BTC/ETH price is near a value.",
}, "\n")
}
return buildSkillDomainPrimer(lang, session.Name)
}
func buildManagementDomainPrimer(lang string) string {
if lang == "zh" {
return strings.Join([]string{
"### 管理领域路由速记",
"- 模型/API Key/providermodel_management。",
"- 交易所账户/API 凭证exchange_management。",
"- 交易员创建、启动、停止、绑定策略/模型/交易所trader_management。",
"- 策略模板创建、查看、修改、删除、激活、复制strategy_management。",
"- 这里只用于路由;具体字段和模板只在进入对应 skill 后注入。",
}, "\n")
}
return strings.Join([]string{
"### Management Routing Cheat Sheet",
"- Model/API key/provider: model_management.",
"- Exchange account/API credentials: exchange_management.",
"- Trader create/start/stop/bind strategy/model/exchange: trader_management.",
"- Strategy template create/query/update/delete/activate/duplicate: strategy_management.",
"- This is only for routing; detailed fields/templates are injected after entering the selected skill.",
}, "\n")
}

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

View File

@@ -1,175 +0,0 @@
package agent
import (
"context"
"encoding/json"
"fmt"
"strings"
"nofx/mcp"
)
const (
skillOutcomeSuccess = "success"
skillOutcomeNeedMoreInfo = "need_more_info"
skillOutcomeRecoverableError = "recoverable_error"
skillOutcomeFatalError = "fatal_error"
skillOutcomeNotHandled = "not_handled"
)
type skillOutcome struct {
Skill string `json:"skill"`
Action string `json:"action"`
Status string `json:"status"`
GoalAchieved bool `json:"goal_achieved"`
UserMessage string `json:"user_message,omitempty"`
ErrorCode string `json:"error_code,omitempty"`
Error string `json:"error,omitempty"`
Data map[string]any `json:"data,omitempty"`
}
type taskReviewDecision struct {
Route string `json:"route"`
Answer string `json:"answer,omitempty"`
}
func normalizeAtomicSkillAction(skill, action string) string {
action = strings.TrimSpace(strings.ToLower(action))
switch skill {
case "trader_management":
switch action {
case "query", "query_list":
return "query_list"
case "query_running":
return "query_running"
case "query_detail", "query_strategy_binding", "query_exchange_binding", "query_model_binding":
return action
case "query_binding":
return "query_detail"
case "update", "update_bindings", "configure_strategy", "configure_exchange", "configure_model":
return action
}
case "exchange_management":
switch action {
case "query", "query_list":
return "query_list"
case "query_detail":
return "query_detail"
case "update", "update_name", "update_status":
return action
}
case "model_management":
switch action {
case "query", "query_list":
return "query_list"
case "query_detail":
return "query_detail"
case "update", "update_name", "update_endpoint", "update_status":
return action
}
case "strategy_management":
switch action {
case "query", "query_list":
return "query_list"
case "query_detail":
return "query_detail"
case "update", "update_name", "update_config", "update_prompt":
return action
}
}
return action
}
func inferSkillOutcome(skill, action, answer string, activeSession skillSession, data map[string]any) skillOutcome {
outcome := skillOutcome{
Skill: skill,
Action: action,
Status: skillOutcomeSuccess,
UserMessage: strings.TrimSpace(answer),
Data: data,
}
if activeSession.Name != "" {
outcome.Status = skillOutcomeNeedMoreInfo
outcome.GoalAchieved = false
return outcome
}
lower := strings.ToLower(strings.TrimSpace(answer))
switch {
case lower == "":
outcome.Status = skillOutcomeNotHandled
case strings.Contains(lower, "失败") || strings.Contains(lower, "failed") || strings.Contains(lower, "error"):
outcome.Status = skillOutcomeRecoverableError
outcome.Error = strings.TrimSpace(answer)
default:
outcome.GoalAchieved = true
}
return outcome
}
func parseTaskReviewDecision(raw string) (taskReviewDecision, error) {
raw = strings.TrimSpace(raw)
raw = strings.TrimPrefix(raw, "```json")
raw = strings.TrimPrefix(raw, "```")
raw = strings.TrimSuffix(raw, "```")
raw = strings.TrimSpace(raw)
var decision taskReviewDecision
if err := json.Unmarshal([]byte(raw), &decision); err == nil {
decision.Route = strings.TrimSpace(strings.ToLower(decision.Route))
decision.Answer = strings.TrimSpace(decision.Answer)
return decision, nil
}
start := strings.Index(raw, "{")
end := strings.LastIndex(raw, "}")
if start >= 0 && end > start {
if err := json.Unmarshal([]byte(raw[start:end+1]), &decision); err == nil {
decision.Route = strings.TrimSpace(strings.ToLower(decision.Route))
decision.Answer = strings.TrimSpace(decision.Answer)
return decision, nil
}
}
return taskReviewDecision{}, fmt.Errorf("invalid task review json")
}
func (a *Agent) reviewTaskCompletion(ctx context.Context, userID int64, lang, text string, outcome skillOutcome) (taskReviewDecision, error) {
if a.aiClient == nil {
if outcome.Status == skillOutcomeRecoverableError || outcome.Status == skillOutcomeFatalError || outcome.Status == skillOutcomeNotHandled {
return taskReviewDecision{Route: "replan"}, nil
}
return taskReviewDecision{Route: "complete", Answer: outcome.UserMessage}, nil
}
recentConversationCtx := a.buildRecentConversationContext(userID, text)
outcomeJSON, _ := json.Marshal(outcome)
systemPrompt := `You are the task-level Plan-Execute-Review supervisor for NOFXi.
You are reviewing the JSON result returned by one structured skill execution.
Return JSON only. Do not return markdown.
Rules:
- Decide whether the OVERALL user task is finished, not whether the skill itself ran successfully.
- Use route "complete" only when the user's task is now complete or the best next message is a final user-facing reply.
- Use route "replan" when the user's task is not complete yet and the planner should continue from the new skill outcome.
- Prefer route "replan" for recoverable errors, unmet goals, missing prerequisites, or cases where another skill/tool sequence may help.
- If you choose "complete", produce the final user-facing answer in the user's language.
- ` + cleanUserFacingReplyInstruction + `
Return JSON with this exact shape:
{"route":"complete|replan","answer":""}`
userPrompt := fmt.Sprintf("Language: %s\nUser message: %s\n\nRecent conversation:\n%s\n\nSkill outcome JSON:\n%s", lang, text, recentConversationCtx, string(outcomeJSON))
stageCtx, cancel := withPlannerStageTimeout(ctx, directReplyTimeout)
defer cancel()
raw, err := a.aiClient.CallWithRequest(&mcp.Request{
Messages: []mcp.Message{
mcp.NewSystemMessage(systemPrompt),
mcp.NewUserMessage(userPrompt),
},
Ctx: stageCtx,
})
if err != nil {
return taskReviewDecision{}, err
}
return parseTaskReviewDecision(raw)
}

View File

@@ -1,721 +0,0 @@
package agent
import (
"embed"
"encoding/json"
"fmt"
"sort"
"strings"
"sync"
)
//go:embed skills/*.json
var embeddedSkillDefinitions embed.FS
type SkillDefinition struct {
Name string `json:"name"`
Kind string `json:"kind"`
Domain string `json:"domain"`
Description string `json:"description"`
Intents []string `json:"intents,omitempty"`
Capabilities []string `json:"capabilities,omitempty"`
DynamicRules []string `json:"dynamic_rules,omitempty"`
Actions map[string]SkillActionDefinition `json:"actions,omitempty"`
ToolMapping map[string]string `json:"tool_mapping,omitempty"`
FieldConstraints map[string]SkillFieldConstraint `json:"field_constraints,omitempty"`
ValidationRules []string `json:"validation_rules,omitempty"`
PerExchangeRequiredFields map[string][]string `json:"per_exchange_required_fields,omitempty"`
}
type SkillFieldConstraint struct {
Type string `json:"type,omitempty"`
Required bool `json:"required,omitempty"`
Values []string `json:"values,omitempty"`
Aliases map[string]string `json:"aliases,omitempty"`
Description string `json:"description,omitempty"`
RequiredFor []string `json:"required_for,omitempty"`
Default any `json:"default,omitempty"`
Min *float64 `json:"min,omitempty"`
Max *float64 `json:"max,omitempty"`
MaxLength int `json:"max_length,omitempty"`
MustBeHTTPS bool `json:"must_be_https,omitempty"`
Pattern string `json:"pattern,omitempty"`
}
type SkillActionDefinition struct {
Description string `json:"description,omitempty"`
RequiredSlots []string `json:"required_slots,omitempty"`
OptionalSlots []string `json:"optional_slots,omitempty"`
NeedsConfirmation bool `json:"needs_confirmation,omitempty"`
Goal string `json:"goal,omitempty"`
DynamicRules []string `json:"dynamic_rules,omitempty"`
SuccessOutput string `json:"success_output,omitempty"`
FailureOutput string `json:"failure_output,omitempty"`
}
var skillRegistry = mustLoadSkillRegistry()
var skillContextCache sync.Map
func mustLoadSkillRegistry() map[string]SkillDefinition {
registry, err := loadSkillRegistry()
if err != nil {
panic(err)
}
return registry
}
func loadSkillRegistry() (map[string]SkillDefinition, error) {
entries, err := embeddedSkillDefinitions.ReadDir("skills")
if err != nil {
return nil, err
}
registry := make(map[string]SkillDefinition, len(entries))
for _, entry := range entries {
if entry.IsDir() || !strings.HasSuffix(entry.Name(), ".json") {
continue
}
raw, err := embeddedSkillDefinitions.ReadFile("skills/" + entry.Name())
if err != nil {
return nil, err
}
var def SkillDefinition
if err := json.Unmarshal(raw, &def); err != nil {
return nil, fmt.Errorf("parse skill definition %s: %w", entry.Name(), err)
}
def = normalizeSkillDefinition(def)
if def.Name == "" {
return nil, fmt.Errorf("skill definition %s has empty name", entry.Name())
}
registry[def.Name] = def
}
return registry, nil
}
func normalizeSkillDefinition(def SkillDefinition) SkillDefinition {
def.Name = strings.TrimSpace(def.Name)
def.Kind = strings.TrimSpace(def.Kind)
def.Domain = strings.TrimSpace(def.Domain)
def.Description = strings.TrimSpace(def.Description)
def.Intents = cleanStringList(def.Intents)
def.Capabilities = cleanStringList(def.Capabilities)
def.DynamicRules = cleanStringList(def.DynamicRules)
if len(def.Actions) > 0 {
normalized := make(map[string]SkillActionDefinition, len(def.Actions))
for key, action := range def.Actions {
key = strings.TrimSpace(key)
if key == "" {
continue
}
action.Description = strings.TrimSpace(action.Description)
action.RequiredSlots = cleanStringList(action.RequiredSlots)
action.OptionalSlots = cleanStringList(action.OptionalSlots)
action.Goal = strings.TrimSpace(action.Goal)
action.DynamicRules = cleanStringList(action.DynamicRules)
action.SuccessOutput = strings.TrimSpace(action.SuccessOutput)
action.FailureOutput = strings.TrimSpace(action.FailureOutput)
normalized[key] = action
}
def.Actions = normalized
}
if len(def.ToolMapping) > 0 {
normalized := make(map[string]string, len(def.ToolMapping))
for key, value := range def.ToolMapping {
key = strings.TrimSpace(key)
value = strings.TrimSpace(value)
if key == "" || value == "" {
continue
}
normalized[key] = value
}
def.ToolMapping = normalized
}
if len(def.FieldConstraints) > 0 {
normalized := make(map[string]SkillFieldConstraint, len(def.FieldConstraints))
for key, constraint := range def.FieldConstraints {
key = strings.TrimSpace(key)
if key == "" {
continue
}
constraint.Type = strings.TrimSpace(constraint.Type)
constraint.Values = cleanStringList(constraint.Values)
constraint.RequiredFor = cleanStringList(constraint.RequiredFor)
constraint.Description = strings.TrimSpace(constraint.Description)
if len(constraint.Aliases) > 0 {
aliases := make(map[string]string, len(constraint.Aliases))
for alias, value := range constraint.Aliases {
alias = strings.TrimSpace(alias)
value = strings.TrimSpace(value)
if alias == "" || value == "" {
continue
}
aliases[alias] = value
}
constraint.Aliases = aliases
}
normalized[key] = constraint
}
def.FieldConstraints = normalized
}
def.ValidationRules = cleanStringList(def.ValidationRules)
if len(def.PerExchangeRequiredFields) > 0 {
normalized := make(map[string][]string, len(def.PerExchangeRequiredFields))
for key, fields := range def.PerExchangeRequiredFields {
key = strings.TrimSpace(key)
if key == "" {
continue
}
normalized[key] = cleanStringList(fields)
}
def.PerExchangeRequiredFields = normalized
}
return def
}
func getSkillDefinition(name string) (SkillDefinition, bool) {
def, ok := skillRegistry[strings.TrimSpace(name)]
return def, ok
}
func listSkillNames() []string {
names := make([]string, 0, len(skillRegistry))
for name := range skillRegistry {
names = append(names, name)
}
sort.Strings(names)
return names
}
func buildSkillRoutingSummary(lang string, skillNames []string) string {
lines := make([]string, 0, len(skillNames))
for _, name := range skillNames {
def, ok := getSkillDefinition(name)
if !ok {
continue
}
parts := []string{strings.TrimSpace(def.Description)}
if len(def.DynamicRules) > 0 {
parts = append(parts, strings.Join(def.DynamicRules, " "))
}
switch name {
case "trader_management":
if lang == "zh" {
parts = append(parts, "这个 skill 负责交易员本体和绑定关系;交易员编辑默认只换绑定,不改策略、模型、交易所的内部配置。")
} else {
parts = append(parts, "This skill owns the trader itself and its bindings; trader edits should switch bindings, not mutate the internals of the strategy, model, or exchange.")
}
case "strategy_management":
if lang == "zh" {
parts = append(parts, "策略模板创建后应出现在策略列表/策略页。用户没问运行时,不要主动延伸到交易员绑定。")
} else {
parts = append(parts, "After creation, strategy templates should appear in the strategy list/page. Do not proactively bring up trader binding unless the user asks to run it.")
}
}
lines = append(lines, fmt.Sprintf("- %s: %s", name, strings.Join(cleanStringList(parts), " ")))
}
return strings.Join(lines, "\n")
}
func buildSkillDefinitionSummary(lang string, skillNames []string) string {
lines := make([]string, 0, len(skillNames))
for _, name := range skillNames {
def, ok := getSkillDefinition(name)
if !ok {
continue
}
parts := []string{strings.TrimSpace(def.Description)}
if len(def.Capabilities) > 0 {
if lang == "zh" {
parts = append(parts, "能力: "+strings.Join(def.Capabilities, ""))
} else {
parts = append(parts, "capabilities: "+strings.Join(def.Capabilities, "; "))
}
}
if len(def.DynamicRules) > 0 {
if lang == "zh" {
parts = append(parts, "规则: "+strings.Join(def.DynamicRules, ""))
} else {
parts = append(parts, "rules: "+strings.Join(def.DynamicRules, "; "))
}
}
if action, ok := def.Actions["create"]; ok && len(action.RequiredSlots) > 0 {
if lang == "zh" {
parts = append(parts, "创建必填: "+formatRequiredSlotList(lang, action.RequiredSlots))
} else {
parts = append(parts, "create requires: "+formatRequiredSlotList(lang, action.RequiredSlots))
}
}
switch name {
case "trader_management":
if lang == "zh" {
parts = append(parts, "这个 skill 负责交易员本体和绑定关系;交易员编辑默认只换绑定,不改策略、模型、交易所的内部配置。")
} else {
parts = append(parts, "This skill owns the trader itself and its bindings; trader edits should switch bindings, not mutate the internals of the strategy, model, or exchange.")
}
case "strategy_management":
if lang == "zh" {
parts = append(parts, "策略模板创建后应出现在策略列表/策略页。用户没问运行时,不要主动延伸到交易员绑定。")
} else {
parts = append(parts, "After creation, strategy templates should appear in the strategy list/page. Do not proactively bring up trader binding unless the user asks to run it.")
}
}
lines = append(lines, fmt.Sprintf("- %s: %s", name, strings.Join(cleanStringList(parts), " ")))
}
return strings.Join(lines, "\n")
}
func defaultManagementSkillNames() []string {
return []string{
"trader_management",
"exchange_management",
"model_management",
"strategy_management",
}
}
func buildSkillDependencySummary(lang string, session skillSession) string {
if strings.TrimSpace(session.Name) == "" {
return ""
}
switch session.Name {
case "trader_management":
if session.Action == "create" {
if lang == "zh" {
return "trader_management:create 必须收齐 4 个核心槽位:交易员名称、交易所、模型、策略。后 3 个依赖项都允许两种补法:直接选用户已有可用资源,或在当前主流程里立即新建/启用后再回流继续创建交易员。若用户是在启用、修复或新建这些依赖资源,这仍然是在继续创建交易员主流程,不是新开平级任务。"
}
return "trader_management:create requires 4 core slots: trader name, exchange, model, and strategy. The last 3 dependencies can be satisfied in two ways: choose an existing usable resource, or create/enable one inline and then resume trader creation. If the user is enabling, fixing, or creating one of those dependencies, that is still continuation of the trader creation flow, not a new peer task."
}
if lang == "zh" {
return "当当前对象是交易员时,换绑模型、交易所、策略都属于 trader_management 的继续操作;但如果用户要改这些对象的内部配置,应切到对应 management skill。"
}
return "When the current object is a trader, rebinding its model, exchange, or strategy remains inside trader_management; but if the user wants to change the internals of those resources, switch to the corresponding management skill."
default:
return ""
}
}
func buildSkillActionContractSummary(lang string, session skillSession) string {
if strings.TrimSpace(session.Name) == "" || strings.TrimSpace(session.Action) == "" {
return ""
}
def, ok := getSkillDefinition(session.Name)
if !ok {
return ""
}
action, ok := def.Actions[session.Action]
if !ok {
return ""
}
required := defaultIfEmpty(formatRequiredSlotList(lang, action.RequiredSlots), "无")
goal := strings.TrimSpace(action.Goal)
if goal == "" {
goal = strings.TrimSpace(action.Description)
}
lines := []string{
fmt.Sprintf("### Active Skill Contract: %s:%s", session.Name, session.Action),
}
if lang == "zh" {
lines = append(lines, "- 目标:"+defaultIfEmpty(goal, "按该动作的业务规则完成当前请求。"))
lines = append(lines, "- 必填输入:"+required)
if len(action.DynamicRules) > 0 {
lines = append(lines, "- 动态逻辑规则:")
for i, rule := range action.DynamicRules {
lines = append(lines, fmt.Sprintf(" %d. %s", i+1, rule))
}
}
if action.SuccessOutput != "" || action.FailureOutput != "" {
lines = append(lines, "- 预期输出:"+strings.TrimSpace(strings.Join(cleanStringList([]string{
ifThenElse(action.SuccessOutput != "", "成功:"+action.SuccessOutput, ""),
ifThenElse(action.FailureOutput != "", "失败:"+action.FailureOutput, ""),
}), "")))
}
} else {
lines = append(lines, "- Goal: "+defaultIfEmpty(goal, "Complete the current request under this action's business rules."))
lines = append(lines, "- Required input: "+required)
if len(action.DynamicRules) > 0 {
lines = append(lines, "- Dynamic rules:")
for i, rule := range action.DynamicRules {
lines = append(lines, fmt.Sprintf(" %d. %s", i+1, rule))
}
}
if action.SuccessOutput != "" || action.FailureOutput != "" {
lines = append(lines, "- Expected output: "+strings.TrimSpace(strings.Join(cleanStringList([]string{
ifThenElse(action.SuccessOutput != "", "success: "+action.SuccessOutput, ""),
ifThenElse(action.FailureOutput != "", "failure: "+action.FailureOutput, ""),
}), "; ")))
}
}
return strings.Join(lines, "\n")
}
func ifThenElse[T any](cond bool, a, b T) T {
if cond {
return a
}
return b
}
func buildSkillForbiddenSummary(lang string, skillNames []string) string {
lines := make([]string, 0, len(skillNames))
for _, name := range skillNames {
switch name {
case "trader_management":
if lang == "zh" {
lines = append(lines, "- trader_management 不能直接设计赚钱/不亏钱方案;那类目标应交给 planner。")
lines = append(lines, "- trader_management 不能让用户手动设置、充值或修改交易员余额;交易员初始余额应由系统自动读取绑定交易所净值。")
} else {
lines = append(lines, "- trader_management must not invent a profit-seeking plan; those requests belong to the planner.")
lines = append(lines, "- trader_management must not let the user set, top up, or manually edit trader balance; trader initial balance should be auto-read from the bound exchange equity.")
}
case "exchange_management":
if lang == "zh" {
lines = append(lines, "- exchange_management 只负责保存和修改交易所配置,不负责行情查询、交易执行或诊断 API 报错。")
} else {
lines = append(lines, "- exchange_management only saves and updates exchange configs; it does not do market reads, trading, or API diagnosis.")
}
case "model_management":
if lang == "zh" {
lines = append(lines, "- model_management 只负责保存和修改模型配置,不负责测试连接、诊断上游错误或生成策略方案。")
} else {
lines = append(lines, "- model_management only saves and updates model configs; it does not test connectivity, diagnose upstream failures, or design strategies.")
}
case "strategy_management":
if lang == "zh" {
lines = append(lines, "- strategy_management 只负责模板管理;策略模板不能直接启动运行,运行态属于 trader。")
} else {
lines = append(lines, "- strategy_management only manages templates; strategy templates do not run directly and runtime belongs to traders.")
}
}
}
return strings.Join(lines, "\n")
}
func buildManagementSkillContext(lang string, session *skillSession) string {
key := fmt.Sprintf("full|%s|", lang)
if session != nil {
key = fmt.Sprintf("full|%s|%s|%s", lang, strings.TrimSpace(session.Name), strings.TrimSpace(session.Action))
}
return cachedSkillContext(key, func() string {
parts := make([]string, 0, 3)
if summary := buildSkillDefinitionSummary(lang, defaultManagementSkillNames()); summary != "" {
parts = append(parts, "Management skill summary:\n"+summary)
}
if forbidden := buildSkillForbiddenSummary(lang, defaultManagementSkillNames()); forbidden != "" {
parts = append(parts, "Management skill negative constraints:\n"+forbidden)
}
if session != nil {
if dependency := buildSkillDependencySummary(lang, *session); dependency != "" {
parts = append(parts, "Active skill dependency summary:\n"+dependency)
}
if contract := buildSkillActionContractSummary(lang, *session); contract != "" {
parts = append(parts, contract)
}
}
return strings.Join(parts, "\n\n")
})
}
func buildManagementSkillRoutingContext(lang string) string {
return buildManagementSkillRoutingContextWithSession(lang, nil)
}
func buildSkillActionRoutingSummary(lang string, session skillSession) string {
if strings.TrimSpace(session.Name) == "" || strings.TrimSpace(session.Action) == "" {
return ""
}
def, ok := getSkillDefinition(session.Name)
if !ok {
return ""
}
action, ok := def.Actions[session.Action]
if !ok {
return ""
}
lines := []string{
fmt.Sprintf("### Active skill routing hints: %s:%s", session.Name, session.Action),
}
if goal := strings.TrimSpace(action.Goal); goal != "" {
if lang == "zh" {
lines = append(lines, "- 当前动作目标:"+goal)
} else {
lines = append(lines, "- Current action goal: "+goal)
}
}
if dependency := buildSkillDependencySummary(lang, session); dependency != "" {
if lang == "zh" {
lines = append(lines, "- 当前 flow 依赖提示:"+dependency)
} else {
lines = append(lines, "- Flow dependency hint: "+dependency)
}
}
if len(action.DynamicRules) > 0 {
if lang == "zh" {
lines = append(lines, "- 当前动作动态规则:")
} else {
lines = append(lines, "- Current action dynamic rules:")
}
for i, rule := range action.DynamicRules {
lines = append(lines, fmt.Sprintf(" %d. %s", i+1, rule))
}
}
return strings.Join(lines, "\n")
}
func buildManagementSkillRoutingContextWithSession(lang string, session *skillSession) string {
key := fmt.Sprintf("routing|%s|", lang)
if session != nil {
key = fmt.Sprintf("routing|%s|%s|%s", lang, strings.TrimSpace(session.Name), strings.TrimSpace(session.Action))
}
return cachedSkillContext(key, func() string {
parts := make([]string, 0, 1)
if summary := buildSkillRoutingSummary(lang, defaultManagementSkillNames()); summary != "" {
parts = append(parts, "Management skill summary:\n"+summary)
}
if session != nil {
if summary := buildSkillActionRoutingSummary(lang, *session); summary != "" {
parts = append(parts, summary)
}
}
return strings.Join(parts, "\n\n")
})
}
func buildCurrentSkillExecutionContext(lang string, session skillSession) string {
key := fmt.Sprintf("current|%s|%s|%s", lang, strings.TrimSpace(session.Name), strings.TrimSpace(session.Action))
return cachedSkillContext(key, func() string {
parts := make([]string, 0, 3)
if dependency := buildSkillDependencySummary(lang, session); dependency != "" {
parts = append(parts, "Active skill dependency summary:\n"+dependency)
}
if contract := buildSkillActionContractSummary(lang, session); contract != "" {
parts = append(parts, contract)
}
if knowledge := buildSkillFieldKnowledgeSummary(lang, session); knowledge != "" {
parts = append(parts, knowledge)
}
return strings.Join(parts, "\n\n")
})
}
func buildSkillFieldKnowledgeSummary(lang string, session skillSession) string {
def, ok := getSkillDefinition(session.Name)
if !ok {
return ""
}
action, hasAction := def.Actions[session.Action]
relevant := orderedSkillFieldKeys(def, action, hasAction)
lines := make([]string, 0, len(relevant)+6)
title := "### Active Field Knowledge"
if lang == "zh" {
title = "### 当前字段知识"
}
lines = append(lines, title)
for _, field := range relevant {
constraint, ok := def.FieldConstraints[field]
if !ok {
continue
}
lines = append(lines, formatFieldKnowledgeLine(lang, field, constraint))
}
if len(def.PerExchangeRequiredFields) > 0 {
if lang == "zh" {
lines = append(lines, "- 按交易所类型的必填字段:")
} else {
lines = append(lines, "- Required fields by exchange type:")
}
keys := make([]string, 0, len(def.PerExchangeRequiredFields))
for key := range def.PerExchangeRequiredFields {
keys = append(keys, key)
}
sort.Strings(keys)
for _, key := range keys {
fields := make([]string, 0, len(def.PerExchangeRequiredFields[key]))
for _, field := range def.PerExchangeRequiredFields[key] {
fields = append(fields, fieldKnowledgeDisplayName(field, lang))
}
lines = append(lines, fmt.Sprintf(" - %s: %s", key, strings.Join(fields, "、")))
}
}
if len(def.ValidationRules) > 0 {
if lang == "zh" {
lines = append(lines, "- 关键校验规则:")
} else {
lines = append(lines, "- Key validation rules:")
}
for i, rule := range def.ValidationRules {
lines = append(lines, fmt.Sprintf(" %d. %s", i+1, rule))
}
}
if len(lines) == 1 {
return ""
}
return strings.Join(lines, "\n")
}
func orderedSkillFieldKeys(def SkillDefinition, action SkillActionDefinition, hasAction bool) []string {
keys := make([]string, 0, len(def.FieldConstraints))
seen := map[string]struct{}{}
add := func(field string) {
field = strings.TrimSpace(field)
if field == "" {
return
}
if _, ok := def.FieldConstraints[field]; !ok {
return
}
if _, ok := seen[field]; ok {
return
}
seen[field] = struct{}{}
keys = append(keys, field)
}
if hasAction {
for _, field := range action.RequiredSlots {
add(field)
}
for _, field := range action.OptionalSlots {
add(field)
}
}
if len(keys) == 0 {
for field := range def.FieldConstraints {
add(field)
}
}
return keys
}
func formatFieldKnowledgeLine(lang, field string, constraint SkillFieldConstraint) string {
parts := make([]string, 0, 8)
if constraint.Description != "" {
parts = append(parts, constraint.Description)
}
if constraint.Type != "" {
if lang == "zh" {
parts = append(parts, "类型="+constraint.Type)
} else {
parts = append(parts, "type="+constraint.Type)
}
}
if constraint.Required {
if lang == "zh" {
parts = append(parts, "当前全局必填")
} else {
parts = append(parts, "globally required")
}
}
if len(constraint.Values) > 0 {
label := "可选值="
if lang != "zh" {
label = "values="
}
parts = append(parts, label+strings.Join(constraint.Values, "/"))
}
if len(constraint.RequiredFor) > 0 {
label := "仅这些类型必填="
if lang != "zh" {
label = "required_for="
}
parts = append(parts, label+strings.Join(constraint.RequiredFor, "/"))
}
if len(constraint.Aliases) > 0 {
aliasPairs := make([]string, 0, len(constraint.Aliases))
keys := make([]string, 0, len(constraint.Aliases))
for alias := range constraint.Aliases {
keys = append(keys, alias)
}
sort.Strings(keys)
for _, alias := range keys {
aliasPairs = append(aliasPairs, alias+"->"+constraint.Aliases[alias])
}
label := "别名="
if lang != "zh" {
label = "aliases="
}
parts = append(parts, label+strings.Join(aliasPairs, ", "))
}
if constraint.MustBeHTTPS {
if lang == "zh" {
parts = append(parts, "必须是 HTTPS")
} else {
parts = append(parts, "must be HTTPS")
}
}
if constraint.Min != nil || constraint.Max != nil {
rangeText := ""
switch {
case constraint.Min != nil && constraint.Max != nil:
rangeText = fmt.Sprintf("%.0f~%.0f", *constraint.Min, *constraint.Max)
case constraint.Min != nil:
rangeText = fmt.Sprintf(">=%.0f", *constraint.Min)
case constraint.Max != nil:
rangeText = fmt.Sprintf("<=%.0f", *constraint.Max)
}
if rangeText != "" {
label := "范围="
if lang != "zh" {
label = "range="
}
parts = append(parts, label+rangeText)
}
}
return fmt.Sprintf("- %s: %s", fieldKnowledgeDisplayName(field, lang), strings.Join(cleanStringList(parts), ""))
}
func fieldKnowledgeDisplayName(field, lang string) string {
if lang == "zh" {
switch field {
case "exchange_type":
return "交易所类型"
case "account_name":
return "账户名"
case "provider":
return "模型提供商"
case "custom_model_name":
return "模型名称"
case "custom_api_url":
return "接口地址"
}
}
return displayCatalogFieldName(field, lang)
}
func formatRequiredSlotList(lang string, slots []string) string {
display := make([]string, 0, len(slots))
for _, slot := range cleanStringList(slots) {
display = append(display, slotDisplayName(slot, lang))
}
return strings.Join(display, "、")
}
func missingRequiredActionSlots(skillName, action string, values map[string]string) []string {
runtime, ok := getSkillActionRuntime(skillName, action)
if !ok {
return nil
}
missing := make([]string, 0, len(runtime.Action.RequiredSlots))
for _, slot := range runtime.Action.RequiredSlots {
if strings.TrimSpace(values[slot]) == "" {
missing = append(missing, slot)
}
}
return missing
}
func cachedSkillContext(key string, build func() string) string {
if cached, ok := skillContextCache.Load(key); ok {
if s, ok := cached.(string); ok {
return s
}
}
value := build()
skillContextCache.Store(key, value)
return value
}

View File

@@ -1,244 +0,0 @@
package agent
import (
"fmt"
"strings"
)
type skillActionRuntime struct {
Skill SkillDefinition
Name string
Action SkillActionDefinition
}
func getSkillActionRuntime(skillName, action string) (skillActionRuntime, bool) {
def, ok := getSkillDefinition(skillName)
if !ok {
return skillActionRuntime{}, false
}
action = strings.TrimSpace(action)
if action == "" {
return skillActionRuntime{Skill: def}, true
}
actionDef, ok := def.Actions[action]
if !ok {
return skillActionRuntime{}, false
}
return skillActionRuntime{
Skill: def,
Name: action,
Action: actionDef,
}, true
}
func actionNeedsConfirmation(skillName, action string) bool {
runtime, ok := getSkillActionRuntime(skillName, action)
if !ok {
return false
}
return runtime.Action.NeedsConfirmation
}
func actionRequiresSlot(skillName, action, slot string) bool {
runtime, ok := getSkillActionRuntime(skillName, action)
if !ok {
return false
}
slot = strings.TrimSpace(slot)
for _, candidate := range runtime.Action.RequiredSlots {
if candidate == slot {
return true
}
}
return false
}
func slotDisplayName(slot, lang string) string {
slot = strings.TrimSpace(slot)
if lang != "zh" {
switch slot {
case "target_ref":
return "target"
case "name":
return "name"
case "exchange":
return "exchange"
case "model":
return "model"
case "strategy":
return "strategy"
case "exchange_type":
return "exchange type"
case "provider":
return "provider"
default:
return slot
}
}
switch slot {
case "target_ref":
return "目标对象"
case "name":
return "名称"
case "exchange":
return "交易所"
case "model":
return "模型"
case "strategy":
return "策略"
case "exchange_type":
return "交易所类型"
case "provider":
return "模型提供商"
default:
return slot
}
}
func formatAwaitConfirmationMessage(lang, action, targetLabel string) string {
actionLabel := action
if lang == "zh" {
switch action {
case "start":
actionLabel = "启动"
case "stop":
actionLabel = "停止"
case "delete":
actionLabel = "删除"
case "activate":
actionLabel = "激活"
default:
actionLabel = action
}
return fmt.Sprintf("即将%s“%s”。这是需要确认的操作请回复“确认”继续回复“取消”终止。", actionLabel, targetLabel)
}
return fmt.Sprintf("You are about to %s %q. Please reply 'confirm' to continue or 'cancel' to stop.", actionLabel, targetLabel)
}
func formatTargetConfirmationLabel(lang string, session *skillSession, targetLabel string) string {
targetLabel = strings.TrimSpace(targetLabel)
if session == nil || session.TargetRef == nil || targetLabel == "" {
return targetLabel
}
source := strings.TrimSpace(session.TargetRef.Source)
if source == "" {
return targetLabel
}
if lang == "zh" {
sourceLabel := "系统上下文"
switch source {
case "user_mention":
sourceLabel = "你刚才点名的对象"
case "tool_output":
sourceLabel = "刚刚工具返回的对象"
case "inferred_from_context":
sourceLabel = "上下文推断对象"
}
return fmt.Sprintf("%s当前识别来源%s", targetLabel, sourceLabel)
}
sourceLabel := "context"
switch source {
case "user_mention":
sourceLabel = "your explicit mention"
case "tool_output":
sourceLabel = "recent tool output"
case "inferred_from_context":
sourceLabel = "context inference"
}
return fmt.Sprintf("%s (current reference source: %s)", targetLabel, sourceLabel)
}
func formatStillWaitingConfirmationMessage(lang string) string {
if lang == "zh" {
return "当前流程仍在等待你确认。回复“确认”继续,或“取消”终止。"
}
return "This flow is still waiting for your confirmation."
}
func referenceKindForSkill(skillName string) string {
switch strings.TrimSpace(skillName) {
case "strategy_management":
return "strategy"
case "trader_management":
return "trader"
case "model_management":
return "model"
case "exchange_management":
return "exchange"
default:
return ""
}
}
func referenceKindDisplayName(lang, kind string) string {
if lang == "zh" {
switch kind {
case "strategy":
return "策略"
case "trader":
return "交易员"
case "model":
return "模型"
case "exchange":
return "交易所"
}
return "对象"
}
return kind
}
func (a *Agent) formatConfirmationTargetLabel(userID int64, lang string, session *skillSession, targetLabel string) string {
label := formatTargetConfirmationLabel(lang, session, targetLabel)
if session == nil || session.TargetRef == nil {
return label
}
kind := referenceKindForSkill(session.Name)
if kind == "" {
return label
}
state := a.getExecutionState(userID)
recentNames := map[string]struct{}{}
for _, item := range state.ReferenceHistory {
if item.Kind != kind {
continue
}
name := strings.TrimSpace(defaultIfEmpty(item.Name, item.ID))
if name == "" {
continue
}
recentNames[name] = struct{}{}
}
targetName := strings.TrimSpace(defaultIfEmpty(session.TargetRef.Name, session.TargetRef.ID))
_, inferred := recentNames[targetName]
if targetName == "" {
return label
}
if len(recentNames) <= 1 && strings.TrimSpace(session.TargetRef.Source) != "inferred_from_context" && inferred {
return label
}
if lang == "zh" {
return fmt.Sprintf("%s。系统当前理解你要操作的%s是“%s”。", label, referenceKindDisplayName(lang, kind), targetName)
}
return fmt.Sprintf("%s. The current %s I'm about to operate on is %q.", label, referenceKindDisplayName(lang, kind), targetName)
}
func (a *Agent) beginConfirmationIfNeeded(userID int64, lang string, session *skillSession, targetLabel string) (string, bool) {
if session == nil || !actionNeedsConfirmation(session.Name, session.Action) {
return "", false
}
if session.Phase != "await_confirmation" {
session.Phase = "await_confirmation"
return formatAwaitConfirmationMessage(lang, session.Action, a.formatConfirmationTargetLabel(userID, lang, session, targetLabel)), true
}
return "", false
}
func awaitingConfirmationButNotApproved(lang string, session skillSession, text string) (string, bool) {
if !actionNeedsConfirmation(session.Name, session.Action) || session.Phase != "await_confirmation" {
return "", false
}
if isYesReply(text) {
return "", false
}
return formatStillWaitingConfirmationMessage(lang), true
}

View File

@@ -1,246 +0,0 @@
package agent
import (
"encoding/json"
"strings"
"nofx/store"
)
func (a *Agent) skillVisibleFieldSummary(storeUserID, lang, skillName, action string) string {
fieldNames := make([]string, 0, 20)
add := func(field string) {
field = strings.TrimSpace(field)
if field == "" {
return
}
for _, existing := range fieldNames {
if existing == field {
return
}
}
fieldNames = append(fieldNames, field)
}
switch skillName {
case "model_management":
if lang == "zh" {
add("Provider")
} else {
add("provider")
}
add(displayCatalogFieldName("name", lang))
for _, field := range manualModelEditableFieldKeys() {
add(displayCatalogFieldName(field, lang))
}
case "exchange_management":
add(slotDisplayName("exchange_type", lang))
for _, field := range manualExchangeEditableFieldKeys() {
add(displayCatalogFieldName(field, lang))
}
case "trader_management":
if strings.TrimSpace(action) == "create" {
add(slotDisplayName("name", lang))
}
for _, field := range manualTraderEditableFieldKeys() {
add(displayCatalogFieldName(field, lang))
}
case "strategy_management":
add(slotDisplayName("name", lang))
for _, field := range manualStrategyEditableFieldKeys() {
add(strategyConfigFieldDisplayName(field, lang))
}
}
if len(fieldNames) == 0 {
return ""
}
prefix := "Visible UI fields"
if lang == "zh" {
prefix = "当前可见字段"
}
return prefix + "" + strings.Join(fieldNames, "、")
}
func (a *Agent) strategyTypeForTarget(storeUserID string, target *EntityReference) (string, bool) {
if a == nil || a.store == nil || target == nil {
return "", false
}
var strategy *store.Strategy
var err error
if id := strings.TrimSpace(target.ID); id != "" {
strategy, err = a.store.Strategy().Get(storeUserID, id)
} else if name := strings.TrimSpace(target.Name); name != "" {
strategies, listErr := a.store.Strategy().List(storeUserID)
if listErr != nil {
return "", false
}
for _, item := range strategies {
if item != nil && strings.EqualFold(strings.TrimSpace(item.Name), name) {
strategy = item
break
}
}
} else {
return "", false
}
if err != nil || strategy == nil {
return "", false
}
cfg := store.GetDefaultStrategyConfig("zh")
if strings.TrimSpace(strategy.Config) != "" {
_ = json.Unmarshal([]byte(strategy.Config), &cfg)
}
strategyType := strings.TrimSpace(cfg.StrategyType)
if strategyType == "" {
strategyType = "ai_trading"
}
return strategyType, true
}
func (a *Agent) skillVisibleOptionSummary(storeUserID, lang, skillName, action string) string {
switch skillName {
case "model_management":
return a.modelSkillOptionSummary(lang)
case "exchange_management":
return a.exchangeSkillOptionSummary(lang)
case "trader_management":
return a.traderSkillOptionSummary(storeUserID, lang)
case "strategy_management":
return a.strategySkillOptionSummary(storeUserID, lang)
default:
return ""
}
}
func (a *Agent) modelSkillOptionSummary(lang string) string {
if lang == "zh" {
return modelProviderChoicePrompt(lang)
}
return modelProviderChoicePrompt(lang)
}
func (a *Agent) exchangeSkillOptionSummary(lang string) string {
options := enumOptionValues("exchange_management", "exchange_type")
if len(options) == 0 {
options = []string{"Binance", "Bybit", "OKX", "Bitget", "Gate", "KuCoin", "Hyperliquid", "Aster", "Lighter", "Indodax"}
}
if lang == "zh" {
return "交易所类型选项:" + strings.Join(options, "、")
}
return "Exchange type options: " + strings.Join(options, ", ")
}
func enumOptionValues(skillName, field string) []string {
def, ok := getSkillDefinition(skillName)
if !ok {
return nil
}
constraint, ok := def.FieldConstraints[field]
if !ok || len(constraint.Values) == 0 {
return nil
}
values := make([]string, 0, len(constraint.Values))
for _, value := range constraint.Values {
if value == "" {
continue
}
switch value {
case "openai":
values = append(values, "OpenAI")
case "deepseek":
values = append(values, "DeepSeek")
case "claude":
values = append(values, "Claude")
case "gemini":
values = append(values, "Gemini")
case "qwen":
values = append(values, "Qwen")
case "kimi":
values = append(values, "Kimi")
case "grok":
values = append(values, "Grok")
case "minimax":
values = append(values, "Minimax")
case "binance":
values = append(values, "Binance")
case "okx":
values = append(values, "OKX")
case "bybit":
values = append(values, "Bybit")
case "gate":
values = append(values, "Gate")
case "kucoin":
values = append(values, "KuCoin")
case "bitget":
values = append(values, "Bitget")
case "hyperliquid":
values = append(values, "Hyperliquid")
case "aster":
values = append(values, "Aster")
case "lighter":
values = append(values, "Lighter")
case "indodax":
values = append(values, "Indodax")
default:
values = append(values, value)
}
}
return values
}
func (a *Agent) traderSkillOptionSummary(storeUserID, lang string) string {
parts := []string{
formatSkillOptionList(lang, "可选模型", "Available models", a.loadEnabledModelOptions(storeUserID)),
formatSkillOptionList(lang, "可选交易所", "Available exchanges", a.loadExchangeOptions(storeUserID)),
formatSkillOptionList(lang, "可选策略", "Available strategies", a.loadStrategyOptions(storeUserID)),
}
return strings.Join(filterNonEmptyStrings(parts), "\n")
}
func (a *Agent) strategySkillOptionSummary(storeUserID, lang string) string {
parts := []string{
"",
formatSkillOptionList(lang, "现有策略", "Existing strategies", a.loadStrategyOptions(storeUserID)),
}
sourceOptions := []string{"static", "ai500", "oi_top", "oi_low"}
if lang == "zh" {
parts[0] = "选币来源选项static、ai500、oi_top、oi_low"
} else {
parts[0] = "Coin source options: static, ai500, oi_top, oi_low"
}
_ = sourceOptions
return strings.Join(filterNonEmptyStrings(parts), "\n")
}
func formatSkillOptionList(lang, zhPrefix, enPrefix string, options []traderSkillOption) string {
names := make([]string, 0, len(options))
for _, option := range options {
label := strings.TrimSpace(defaultIfEmpty(option.Name, option.ID))
if label == "" {
continue
}
names = append(names, label)
}
if len(names) == 0 {
if lang == "zh" {
return zhPrefix + ":暂无"
}
return enPrefix + ": none"
}
if lang == "zh" {
return zhPrefix + "" + strings.Join(names, "、")
}
return enPrefix + ": " + strings.Join(names, ", ")
}
func filterNonEmptyStrings(items []string) []string {
out := make([]string, 0, len(items))
for _, item := range items {
item = strings.TrimSpace(item)
if item == "" {
continue
}
out = append(out, item)
}
return out
}

View File

@@ -1,18 +0,0 @@
{
"name": "exchange_diagnosis",
"kind": "diagnosis",
"domain": "exchange",
"description": "当用户反馈交易所 API 连接失败、签名错误、timestamp 异常、权限不足、IP 白名单限制、账户不可用、余额读取失败、下单失败或仓位模式错误等问题时调用。适用于用户在手动配置或运行交易员时遇到的交易所接入与执行故障。不用于创建、修改、删除或查询交易所配置这类管理操作。",
"capabilities": [
"区分凭证缺失、签名错误、时间戳偏差、IP 白名单、权限不足、余额不足、仓位模式和 symbol 不可交易等原因",
"解释不同交易所的必填字段差异,尤其是 OKX/Bitget/KuCoin passphrase、Hyperliquid 钱包地址、Aster signer/private key、Lighter API key private key",
"把交易所原始错误翻译成新手可执行的修复步骤"
],
"dynamic_rules": [
"交易所连接失败优先按顺序排查:配置是否启用 -> 必填凭证是否齐全 -> API Key/Secret/Passphrase 是否填反或过期 -> 系统时间/timestamp -> IP 白名单 -> 合约/交易权限 -> 测试网/主网是否选错。",
"OKX、Bitget、KuCoin 的 passphrase/API 口令不是可选项;如果缺失,必须明确提示补齐。",
"下单失败时继续排查:账户余额/可用保证金 -> 杠杆限制 -> 仓位模式(单向/双向) -> symbol 是否支持合约交易 -> 最小下单金额/数量。",
"Hyperliquid、Aster、Lighter 这类钱包/DEX 配置错误时,不要用 CEX 的 API Key/Secret 逻辑套用;按各自 required fields 解释。",
"诊断回复不得展示完整 API Key、Secret、Passphrase 或私钥。"
]
}

View File

@@ -1,207 +0,0 @@
{
"name": "exchange_management",
"kind": "management",
"domain": "exchange",
"description": "当用户想创建、查看、修改或删除交易所账户配置时调用。适用于用户提到交易所账户、API Key、Secret、Passphrase、测试网开关、启用状态等配置管理需求。不用于排查 invalid signature、timestamp、权限不足、白名单限制等连接或鉴权诊断问题。",
"field_constraints": {
"exchange_type": {
"type": "enum",
"required": true,
"values": ["binance", "bybit", "okx", "bitget", "gate", "kucoin", "hyperliquid", "aster", "lighter", "indodax"],
"aliases": {"币安": "binance", "欧易": "okx", "必安": "binance", "bitget": "bitget", "bitget futures": "bitget", "bitget合约": "bitget", "库币": "kucoin", "gate.io": "gate", "hyper": "hyperliquid", "印尼站": "indodax"},
"description": "交易所类型,必填,决定后续需要哪些凭证字段。"
},
"account_name": {
"type": "string",
"max_length": 50,
"description": "账户显示名称,可选,用于区分同一交易所的多个账户。"
},
"api_key": {
"type": "credential",
"pattern": "^[A-Za-z0-9_\\-]{8,}$",
"description": "交易所 API Key至少 8 位字母数字。"
},
"secret_key": {
"type": "credential",
"pattern": "^([A-Za-z0-9_\\-]{8,}|(0x)?[A-Fa-f0-9]{16,})$",
"description": "交易所 Secret Key至少 8 位字母数字,或十六进制格式。"
},
"passphrase": {
"type": "credential",
"required_for": ["okx", "bitget", "kucoin"],
"description": "OKX、Bitget、KuCoin 专用 Passphrase/API 口令对这些交易所启用前必须填写Binance、Bybit、Gate、Indodax 通常不需要。"
},
"testnet": {
"type": "bool",
"default": false,
"description": "是否使用测试网(沙盒环境),默认 false主网。"
},
"enabled": {
"type": "bool",
"default": true,
"description": "是否启用该交易所配置。只要必要字段齐全并配置成功,就默认启用。"
},
"hyperliquid_wallet_addr": {
"type": "credential",
"required_for": ["hyperliquid"],
"description": "Hyperliquid 主钱包地址Hyperliquid 账户启用前必须填写。"
},
"hyperliquid_unified_account": {
"type": "bool",
"default": false,
"required_for": ["hyperliquid"],
"description": "是否启用 Hyperliquid unified account 模式。"
},
"aster_user": {
"type": "credential",
"required_for": ["aster"],
"description": "Aster 用户地址Aster 账户启用前必须填写。"
},
"aster_signer": {
"type": "credential",
"required_for": ["aster"],
"description": "Aster Signer 地址Aster 账户启用前必须填写。"
},
"aster_private_key": {
"type": "credential",
"required_for": ["aster"],
"description": "Aster 私钥Aster 账户启用前必须填写。"
},
"lighter_wallet_addr": {
"type": "credential",
"required_for": ["lighter"],
"description": "Lighter 钱包地址Lighter 账户启用前必须填写。"
},
"lighter_private_key": {
"type": "credential",
"required_for": ["lighter"],
"description": "Lighter 私钥,某些 Lighter 账户模式下启用前必须填写。"
},
"lighter_api_key_private_key": {
"type": "credential",
"required_for": ["lighter"],
"description": "Lighter API Key 私钥Lighter 账户启用前必须填写。"
},
"lighter_api_key_index": {
"type": "int",
"min": 0,
"max": 255,
"required_for": ["lighter"],
"description": "Lighter API Key Index范围 0255超出范围自动收敛并告知用户。"
}
},
"validation_rules": [
"api_key 格式:至少 8 位字母数字,不符合时提示用户重新输入完整 Key。",
"secret_key 格式:至少 8 位字母数字,或十六进制格式,不符合时提示用户重新输入。",
"OKX 账户启用前必须填写 passphrase否则拒绝启用并提示补填。",
"Bitget 和 KuCoin 页面流程里也需要 passphrase/API 口令,不能回答“没有就留空”;缺失时应明确提示补填。",
"Hyperliquid 创建/更新时应与手动页面保持一致:至少收集 api_key + hyperliquid_wallet_addr。",
"Hyperliquid 账户启用前必须填写 hyperliquid_wallet_addr。",
"若用户使用 Hyperliquid unified account 模式,应明确记录 hyperliquid_unified_account 开关状态。",
"Aster 账户启用前必须填写 aster_user、aster_signer、aster_private_key 三个字段,任一缺失都不能启用。",
"Lighter 账户启用前必须填写 lighter_wallet_addr + lighter_api_key_private_key若当前账户模式还依赖 lighter_private_key也要先补齐后再启用。",
"lighter_api_key_index 超出 0255 时自动收敛到边界值并告知用户。",
"删除操作不可逆,必须先向用户确认再执行。"
],
"per_exchange_required_fields": {
"binance": ["api_key", "secret_key"],
"okx": ["api_key", "secret_key", "passphrase"],
"bybit": ["api_key", "secret_key"],
"bitget": ["api_key", "secret_key", "passphrase"],
"gate": ["api_key", "secret_key"],
"kucoin": ["api_key", "secret_key", "passphrase"],
"indodax": ["api_key", "secret_key"],
"hyperliquid": ["api_key", "hyperliquid_wallet_addr"],
"aster": ["aster_user", "aster_signer", "aster_private_key"],
"lighter": ["lighter_wallet_addr", "lighter_api_key_private_key"]
},
"actions": {
"create": {
"description": "创建新的交易所配置。根据 exchange_type 决定需要收集哪些凭证字段。",
"required_slots": ["exchange_type", "account_name"],
"optional_slots": ["account_name", "api_key", "secret_key", "passphrase", "testnet", "hyperliquid_wallet_addr", "hyperliquid_unified_account", "aster_user", "aster_signer", "aster_private_key", "lighter_wallet_addr", "lighter_private_key", "lighter_api_key_private_key", "lighter_api_key_index"],
"goal": "创建一个可供 trader 绑定使用的交易所配置。",
"dynamic_rules": [
"确认 exchange_type 后,根据 per_exchange_required_fields 决定需要追问哪些凭证字段。",
"Binance/Bybit/Gate/Indodax 需要 API Key + SecretOKX/Bitget/KuCoin 还必须追问 passphraseHyperliquid 必须追问 api_key + 钱包地址,并允许记录 unified account 开关Aster 必须追问 user/signer/private_keyLighter 必须追问钱包地址和 api_key_private_key。",
"如果用户选择 OKX、Bitget 或 KuCoin不能把 passphrase 说成可选项;没有 passphrase 时应停在补字段,不要创建半成品。",
"凭证字段格式不符时,用人话告知用户正确格式,不要静默丢弃。",
"若当前父任务只是缺一个可用交易所,本动作完成后应允许父任务恢复并消费新的 exchange_id。",
"若请求只是在启用已有交易所,不应误走 create应改走 update_status。"
],
"success_output": "返回新 exchange_id 和创建后的交易所配置摘要(类型、账户名、是否启用)。",
"failure_output": "明确指出缺失的必填字段或非法凭证格式,禁止返回含糊的成功信息。"
},
"update": {
"description": "更新已有交易所配置的任意可编辑字段。",
"required_slots": ["target_ref"],
"optional_slots": ["account_name", "api_key", "secret_key", "passphrase", "enabled", "testnet", "hyperliquid_wallet_addr", "hyperliquid_unified_account", "aster_user", "aster_signer", "aster_private_key", "lighter_wallet_addr", "lighter_private_key", "lighter_api_key_private_key", "lighter_api_key_index"],
"goal": "更新一个已有交易所配置的指定字段,而不影响未提及字段。",
"dynamic_rules": [
"只更新用户明确提到的字段,不要覆盖未提及的字段。",
"更新凭证字段时,格式不符则提示用户重新输入。"
],
"success_output": "返回 exchange_id 和更新后的交易所配置摘要。",
"failure_output": "明确指出目标交易所不存在、凭证格式非法,或仍缺哪个字段。"
},
"update_name": {
"description": "修改交易所配置中的账户显示名称字段。",
"required_slots": ["target_ref", "account_name"],
"goal": "修改交易所配置中的账户显示名称,而不影响其他字段。",
"dynamic_rules": [
"若用户同时提到其他字段,应优先走更通用的 update。"
],
"success_output": "返回 exchange_id并明确告知交易所配置已更新。",
"failure_output": "明确指出目标交易所不存在,或新的账户名称仍缺失。"
},
"update_status": {
"description": "修改交易所配置中的启用开关。启用前系统会校验凭证完整性。",
"required_slots": ["target_ref", "enabled"],
"goal": "修改交易所配置中的启用状态字段。",
"dynamic_rules": [
"启用前根据 exchange_type 校验必填凭证是否齐全,不齐全则提示用户补填后再启用。"
],
"success_output": "返回 exchange_id并明确告知交易所配置已更新。",
"failure_output": "明确指出目标交易所不存在、缺少必填凭证,或当前状态切换失败。"
},
"delete": {
"description": "删除交易所配置,不可逆操作,必须确认。",
"required_slots": ["target_ref"],
"needs_confirmation": true,
"goal": "删除一个交易所配置。",
"dynamic_rules": [
"必须在确认后执行,并明确提醒删除不可逆。"
],
"success_output": "返回删除成功结果,并明确告知该交易所配置已被移除。",
"failure_output": "明确指出缺少确认、目标交易所不存在,或删除失败原因。"
},
"query_list": {
"description": "查询所有交易所配置列表,包含类型、账户名、启用状态。",
"goal": "列出当前用户可用的交易所配置,便于后续绑定或选择。",
"dynamic_rules": [
"优先返回类型、账户名、启用状态,不返回敏感凭证明文。"
],
"success_output": "返回交易所配置列表摘要。",
"failure_output": "若列表为空,应明确告知当前没有交易所配置。"
},
"query_detail": {
"description": "查询某个交易所配置的详细信息。",
"required_slots": ["target_ref"],
"goal": "读取一个交易所配置的详细信息和当前状态。",
"dynamic_rules": [
"详情返回中只能暴露凭证存在性,不得返回凭证明文。"
],
"success_output": "返回目标交易所配置的详细摘要。",
"failure_output": "明确指出目标交易所不存在,或当前引用已经失效。"
}
},
"tool_mapping": {
"create": "manage_exchange_config:create",
"update": "manage_exchange_config:update",
"update_name": "manage_exchange_config:update",
"update_status": "manage_exchange_config:update",
"delete": "manage_exchange_config:delete",
"query_list": "get_exchange_configs",
"query_detail": "get_exchange_configs"
}
}

View File

@@ -1,17 +0,0 @@
{
"name": "model_diagnosis",
"kind": "diagnosis",
"domain": "model",
"description": "当用户反馈模型配置失败、API Key 无效、Base URL 非法、模型名不匹配、调用返回错误、模型不可用、claw402 钱包余额不足或支付失败等问题时调用。适用于用户在接入或测试大模型时遇到的配置、兼容性、支付和调用故障。不用于创建、修改、删除或查询模型配置这类管理操作。",
"capabilities": [
"区分模型未启用、凭证缺失、endpoint/model name 配置错误、钱包余额不足、上游限流或网关异常",
"对 claw402 / blockrun-base 这类钱包付费模型解释钱包地址、USDC 余额和支付状态",
"给出不泄露敏感凭证的下一步修复建议"
],
"dynamic_rules": [
"诊断模型不可用时,按顺序检查:是否存在该模型配置 -> enabled 是否为 true -> provider 是否支持 -> 凭证/API Key 或钱包私钥是否存在 -> custom_api_url 是否合法 HTTPS 或可留空 -> custom_model_name 是否有默认值或已填写 -> 钱包余额/支付状态 -> 上游限流、超时或网关错误。",
"claw402 是模型 provider使用 Base USDC 钱包按次付费;余额为 0 USDC 时应明确说需要充值,不要说成“未配置模型”。",
"429/rate_limit_error、空响应、超时不应默认归因为余额不足只有工具结果或错误文本指向余额/支付失败时才这么判断。",
"任何诊断回复都不得展示 API Key、钱包私钥或完整敏感凭证。"
]
}

View File

@@ -1,157 +0,0 @@
{
"name": "model_management",
"kind": "management",
"domain": "model",
"description": "当用户想创建、查看、修改或删除 AI 模型配置时调用。适用于用户提到 provider、API Key、Base URL、模型名称、启用状态等配置管理需求。不用于排查模型调用失败、接口不兼容、鉴权错误、模型不存在等诊断问题。",
"field_constraints": {
"provider": {
"type": "enum",
"required": true,
"values": ["openai", "deepseek", "claude", "gemini", "qwen", "kimi", "grok", "minimax", "claw402", "blockrun-base", "blockrun-sol"],
"description": "模型提供商,必填。决定默认模型、凭证类型以及可选配置项。"
},
"name": {
"type": "string",
"max_length": 50,
"description": "模型配置显示名称,可选,用于区分同一 provider 的多个配置。"
},
"api_key": {
"type": "credential",
"description": "模型凭证。普通 provider 使用 API Keyclaw402 和 blockrun 使用钱包私钥。启用前必须填写。"
},
"custom_api_url": {
"type": "url",
"must_be_https": true,
"description": "自定义 API Base URL必须是合法的 HTTPS 地址。普通 provider 可留空走默认地址claw402 / blockrun 不需要。"
},
"custom_model_name": {
"type": "string",
"description": "实际调用的模型 ID例如 gpt-5.1、deepseek-chat。若 provider 有默认模型,可留空走默认值。"
},
"enabled": {
"type": "bool",
"default": false,
"description": "是否启用该模型配置。启用前必须填写 provider 对应的凭证;若 provider 没有默认模型,还需要 custom_model_name。"
}
},
"validation_rules": [
"provider 必须是支持列表之一openai、deepseek、claude、gemini、qwen、kimi、grok、minimax、claw402、blockrun-base、blockrun-sol。",
"OpenAI 的 api_key 格式校验:必须以 sk- 开头,不符合时提示用户检查 Key 是否完整。",
"custom_api_url 若填写,必须是合法 HTTPS 地址,系统拒绝 HTTP 地址,提示用户改用 HTTPS。",
"启用enabled=true前必须填写 provider 对应的凭证;如果 custom_model_name 留空,则系统应先尝试使用 provider 默认模型。",
"启用enabled=truecustom_api_url 若填写必须是合法 HTTPS 地址;不允许用 HTTP 地址硬启用。",
"claw402 是 AI 模型 provider不是交易所、策略或交易员名称用户说“用 claw402”时应解释为选择/绑定 claw402 模型配置。",
"claw402 使用 Base 链 EVM 钱包 + USDC 按次付费enabled=true 只代表模型配置已启用,不代表钱包一定有余额。",
"claw402 或 blockrun-base 钱包余额为 0 USDC 时,应明确提示“钱包余额不足/需要充值”,不要说成“模型未启用”或静默改用其他模型。",
"用户明确指定某个 provider 或模型时,如果当前不可用,必须先说明不可用原因,再让用户选择修复该模型或改用其他已可用模型;不得静默替换。",
"删除操作不可逆,必须先向用户确认再执行。"
],
"actions": {
"create": {
"description": "创建新的模型配置。",
"required_slots": ["provider"],
"optional_slots": ["name", "api_key", "custom_api_url", "custom_model_name", "enabled"],
"goal": "创建一个可供 trader 绑定使用的模型配置。",
"dynamic_rules": [
"确认 provider 后,先说明该 provider 的默认模型和凭证类型,再按 provider 特性补充追问。",
"普通 provideropenai、deepseek、claude 等)通常需要 api_keycustom_api_url 和 custom_model_name 可留空走默认值。",
"claw402 需要钱包私钥,不需要 custom_api_urlcustom_model_name 留空时默认 deepseek。",
"创建 claw402 后若钱包余额为 0 USDC应提示用户充值 Base USDC 后再用于稳定调用;不要把余额不足误报为配置未启用。",
"blockrun-base 和 blockrun-sol 需要钱包私钥,不需要 custom_api_urlcustom_model_name 留空时默认 auto。",
"若用户提供了 custom_api_url校验是否为合法 HTTPS 地址,不合法则提示修正。",
"OpenAI 的 api_key 不以 sk- 开头时,提示用户检查 Key 格式。",
"若用户要在父任务里使用现有模型,应优先选择当前已启用模型,而不是误开新的 create。",
"若当前父任务只是缺一个可用模型,本动作完成后应允许父任务恢复并消费新的 model_id。"
],
"success_output": "返回 model_id 和创建后的模型配置摘要provider、名称、是否启用。",
"failure_output": "明确指出缺失字段、非法 endpoint 或不支持的 provider禁止只说泛化失败。"
},
"update": {
"description": "更新已有模型配置的任意可编辑字段。",
"required_slots": ["target_ref"],
"optional_slots": ["name", "api_key", "custom_api_url", "custom_model_name", "enabled"],
"goal": "更新一个已有模型配置的指定字段,而不覆盖未提及字段。",
"dynamic_rules": [
"只更新用户明确提到的字段,不要覆盖未提及的字段。",
"如果用户只是想给 trader 改用 claw402不要在模型配置里误改显示名称应把 claw402 作为 provider/model 选择处理。",
"更新 custom_api_url 时校验 HTTPS 格式。",
"更新 api_key 时对 OpenAI 校验 sk- 前缀。"
],
"success_output": "返回 model_id 和更新后的模型配置摘要。",
"failure_output": "明确指出目标模型不存在、provider/endpoint 非法,或仍缺哪个关键字段。"
},
"update_status": {
"description": "启用或禁用模型配置。启用前系统会校验 api_key 和 custom_model_name 是否已填写。",
"required_slots": ["target_ref", "enabled"],
"goal": "切换模型配置的启用状态。",
"dynamic_rules": [
"启用前必须确保 provider 对应凭证已经齐全;若 provider 有默认模型custom_model_name 可按默认值处理。",
"启用 claw402 只校验钱包私钥等配置完整性;若钱包 0 USDC应提示充值但不要把它等同于 enabled=false。"
],
"success_output": "返回 model_id并明确告知该模型已启用或已禁用。",
"failure_output": "明确指出目标模型不存在、缺少启用前必填项,或当前状态切换失败。"
},
"update_endpoint": {
"description": "仅修改模型的 custom_api_url。",
"required_slots": ["target_ref", "custom_api_url"],
"goal": "仅更新模型配置的 custom_api_url。",
"dynamic_rules": [
"custom_api_url 必须是合法 HTTPS 地址;若不合法,先让用户修正而不是继续执行。"
],
"success_output": "返回 model_id并明确告知新的接口地址。",
"failure_output": "明确指出目标模型不存在,或接口地址仍不合法。"
},
"update_name": {
"description": "仅修改模型配置的 custom_model_name实际调用的模型 ID。",
"required_slots": ["target_ref", "custom_model_name"],
"goal": "仅更新模型配置的实际调用模型 ID。",
"dynamic_rules": [
"若用户其实是在改显示名称或 provider应转去更通用的 update而不是误用本动作。"
],
"success_output": "返回 model_id并明确告知新的 custom_model_name。",
"failure_output": "明确指出目标模型不存在,或新的模型 ID 仍未收齐。"
},
"delete": {
"description": "删除模型配置,不可逆操作,必须确认。",
"required_slots": ["target_ref"],
"needs_confirmation": true,
"goal": "删除一个模型配置。",
"dynamic_rules": [
"必须在确认后执行,并明确提醒删除不可逆。"
],
"success_output": "返回删除成功结果,并明确告知该模型配置已被移除。",
"failure_output": "明确指出缺少确认、目标模型不存在,或删除失败原因。"
},
"query_list": {
"description": "查询所有模型配置列表,包含 provider、名称、启用状态。",
"goal": "列出当前用户可见的模型配置,便于后续选择或绑定。",
"dynamic_rules": [
"优先返回 provider、名称、启用状态不返回 API Key 明文。",
"对于 claw402 / blockrun-base若工具结果包含钱包地址或 USDC 余额,应用它解释支付状态;余额不足时要说“需要充值”,不要说“没配置”。"
],
"success_output": "返回模型配置列表摘要。",
"failure_output": "若列表为空,应明确告知当前没有模型配置。"
},
"query_detail": {
"description": "查询某个模型配置的详细信息。",
"required_slots": ["target_ref"],
"goal": "读取一个模型配置的详细信息。",
"dynamic_rules": [
"详情返回中只能暴露 API Key/钱包私钥是否存在,不得返回明文凭证。",
"对于 claw402应区分三种状态配置未启用、钱包凭证缺失、钱包余额不足。"
],
"success_output": "返回目标模型配置的详细摘要。",
"failure_output": "明确指出目标模型不存在,或当前引用已经失效。"
}
},
"tool_mapping": {
"create": "manage_model_config:create",
"update": "manage_model_config:update",
"update_status": "manage_model_config:update",
"update_endpoint": "manage_model_config:update",
"update_name": "manage_model_config:update",
"delete": "manage_model_config:delete",
"query_list": "get_model_configs",
"query_detail": "get_model_configs"
}
}

View File

@@ -1,22 +0,0 @@
{
"name": "strategy_diagnosis",
"kind": "diagnosis",
"domain": "strategy",
"description": "当用户反馈策略未生效、候选币为空、策略输出异常、提示词或配置结果与预期不一致、AI 一直 hold/wait、策略执行表现异常时调用。适用于策略内容、候选币、风控边界和执行效果相关的排障与解释。不用于创建、修改、删除、激活、复制或查询策略模板这类管理操作。",
"capabilities": [
"区分策略模板配置问题、交易员绑定问题、市场数据/候选币问题、AI 决策为 hold/wait、风控拦截和交易所下单失败",
"解释 AI 策略与网格策略的字段边界、页面范围和 System enforced 字段",
"指出策略模板不能直接运行,必须由交易员绑定后执行"
],
"dynamic_rules": [
"策略没生效时,先区分:只是策略模板未被交易员绑定,还是交易员已绑定但运行结果不符合预期。",
"若候选币为空,检查 source_type/static_coins/AI500/OI 榜单/排除币/量化数据开关,不要直接归因为模型问题。",
"若 AI 一直 hold/wait先检查 min_confidence、min_risk_reward_ratio、提示词是否过于保守、行情是否满足入场条件再判断是否需要放宽策略。",
"若交易员绑定了策略但没有下单,应与 trader_diagnosis 协作区分策略无信号、风控拦截和交易所下单失败。",
"策略诊断必须区分可编辑策略字段和 System enforced 字段。AI 智能策略里的 max_positions、btceth_max_position_value_ratio、altcoin_max_position_value_ratio、max_margin_usage、min_position_size 只能解释,不能建议用户修改。",
"如果不开单原因来自最小下单金额、保证金或仓位价值边界,不要建议修改 min_position_size 或 position_size_usd应建议增加账户权益、换更适合小资金的标的、调整可编辑风险偏好或让策略在资金不足时等待。",
"策略页不存在 position_size_usd 这类固定配置项position_size_usd 是 AI 每轮决策输出,不是策略模板字段。不要把 AI 决策里的 position_size_usd 说成可以在策略页手动修改的参数。",
"后台 402/404/EOF 类数据源错误只能作为策略分析质量的辅助影响,不能在决策记录已经显示明确风控/最小金额拒绝时作为主因。",
"策略模板本身不保存交易所、模型、扫描间隔或初始余额;这些问题应引导到 trader/model/exchange 相关诊断。"
]
}

View File

@@ -1,473 +0,0 @@
{
"name": "strategy_management",
"kind": "management",
"domain": "strategy",
"description": "当用户想创建、查看、修改、删除、激活或复制策略模板时调用。",
"field_constraints": {
"name": {
"type": "string",
"required": true,
"max_length": 50,
"description": "策略模板名称,必填,最多 50 个字符。"
},
"description": {
"type": "string",
"description": "策略描述,可选。"
},
"is_public": {
"type": "bool",
"default": false,
"description": "是否发布到策略市场。"
},
"config_visible": {
"type": "bool",
"default": true,
"description": "发布到市场后,是否允许别人查看策略配置。"
},
"lang": {
"type": "enum",
"values": ["zh", "en"],
"default": "zh",
"description": "策略语言zh 或 en影响 AI 决策时使用的语言。"
},
"strategy_type": {
"type": "enum",
"values": ["ai_trading", "grid_trading"],
"description": "策略类型ai_tradingAI 量化)或 grid_trading网格策略。创建策略时必须先由用户选择或从用户话语明确识别不能默认成 ai_trading。"
},
"symbol": {
"type": "enum",
"values": ["BTCUSDT", "ETHUSDT", "SOLUSDT", "BNBUSDT", "XRPUSDT", "DOGEUSDT"],
"description": "网格策略页面交易对下拉选项,只能从 BTCUSDT、ETHUSDT、SOLUSDT、BNBUSDT、XRPUSDT、DOGEUSDT 中选择。用户问“交易对有哪些选项”时,直接列出这些选项。"
},
"source_type": {
"type": "enum",
"values": ["static", "ai500", "oi_top", "oi_low"],
"description": "选币来源类型。static=用户指定静态币池ai500=AI500榜单oi_top=持仓量增长oi_low=持仓量下降。"
},
"static_coins": {
"type": "string_array",
"max_items": 10,
"description": "静态币池,例如 [\"BTCUSDT\", \"ETHUSDT\"]source_type=static 时使用,手动页面最多 10 个。页面支持常规合约币种,也支持 xyz: 前缀资产(如 xyz:TSLA、xyz:GOLD、xyz:XYZ100。"
},
"excluded_coins": {
"type": "string_array",
"description": "排除币列表,所有来源均会排除这些币。"
},
"primary_timeframe": {
"type": "string",
"values": ["1m", "3m", "5m", "15m", "30m", "1h", "2h", "4h", "6h", "8h", "12h", "1d", "3d", "1w"],
"description": "主 K 线周期,例如 5m、15m、1h。"
},
"selected_timeframes": {
"type": "string_array",
"values": ["1m", "3m", "5m", "15m", "30m", "1h", "2h", "4h", "6h", "8h", "12h", "1d", "3d", "1w"],
"max_items": 4,
"description": "多周期分析时间框架列表,例如 [\"5m\",\"15m\",\"1h\"];手动页面最多选择 4 个。"
},
"btceth_max_leverage": {
"type": "int",
"min": 1,
"max": 20,
"description": "BTC/ETH 最大杠杆倍数,范围 120。"
},
"altcoin_max_leverage": {
"type": "int",
"min": 1,
"max": 20,
"description": "山寨币最大杠杆倍数,范围 120。"
},
"min_confidence": {
"type": "int",
"min": 50,
"max": 100,
"description": "最小开仓置信度,手动页面范围 50100数值越高开单越谨慎。"
},
"min_risk_reward_ratio": {
"type": "float",
"min": 1,
"max": 10,
"description": "最小盈亏比,手动页面范围 110步进 0.5;例如 1.5 表示每笔交易至少 1.5 倍风险收益比。"
},
"custom_prompt": {
"type": "text",
"description": "自定义 AI 提示词,追加到策略基础提示词之后。"
},
"role_definition": {
"type": "text",
"description": "AI 角色定义,描述 AI 的交易风格和定位。"
},
"trading_frequency": {
"type": "text",
"description": "交易频率描述,例如:每天最多开 3 笔。"
},
"entry_standards": {
"type": "text",
"description": "入场标准描述,例如:只在趋势明确时开仓。"
},
"decision_process": {
"type": "text",
"description": "决策流程描述,例如:先看大周期趋势,再看小周期入场点。"
},
"grid_count": {
"type": "int",
"min": 5,
"max": 50,
"description": "网格数量grid_trading 类型专用,手动页面范围 550。"
},
"total_investment": {
"type": "float",
"min": 100,
"description": "网格总投入金额grid_trading 类型专用,表示用户实际投入/保证金预算,不是杠杆后的名义仓位;名义仓位约等于 total_investment × leverage。手动页面最小 100 USDT步进 100。"
},
"leverage": {
"type": "int",
"min": 1,
"max": 5,
"description": "网格策略杠杆倍数,手动页面当前范围 15。"
},
"upper_price": {
"type": "float",
"description": "网格上边界价格grid_trading 类型专用。"
},
"lower_price": {
"type": "float",
"description": "网格下边界价格grid_trading 类型专用,必须小于 upper_price。"
},
"distribution": {
"type": "enum",
"values": ["uniform", "gaussian", "pyramid"],
"description": "网格分布方式uniform=均匀gaussian=正态pyramid=金字塔。"
},
"use_atr_bounds": {
"type": "bool",
"default": false,
"description": "网格边界是否改为按 ATR 动态计算。"
},
"atr_multiplier": {
"type": "float",
"min": 1,
"max": 5,
"description": "ATR 边界倍数use_atr_bounds=true 时使用,手动页面范围 15步进 0.5。"
},
"enable_direction_adjust": {
"type": "bool",
"default": false,
"description": "是否启用方向偏置调整。"
},
"direction_bias_ratio": {
"type": "float",
"min": 0.55,
"max": 0.9,
"description": "方向偏置比例,决定多空倾向强弱;手动页面范围 0.550.90,通常以 55%90% 展示。"
},
"max_drawdown_pct": {
"type": "float",
"min": 5,
"max": 50,
"description": "网格策略最大回撤百分比,手动页面范围 550。"
},
"stop_loss_pct": {
"type": "float",
"min": 1,
"max": 20,
"description": "网格策略止损百分比,手动页面范围 120。"
},
"daily_loss_limit_pct": {
"type": "float",
"min": 1,
"max": 30,
"description": "网格策略每日最大亏损比例,手动页面范围 130达到后当天停止新开仓。"
},
"use_maker_only": {
"type": "bool",
"default": false,
"description": "是否优先只挂 maker 单。"
},
"use_ai500": {
"type": "bool",
"default": false,
"description": "是否启用 AI500 榜单作为候选币来源。"
},
"ai500_limit": {
"type": "int",
"min": 1,
"max": 10,
"description": "AI500 榜单选取数量,手动页面范围 110。"
},
"use_oi_top": {
"type": "bool",
"default": false,
"description": "是否启用 OI Top 作为候选币来源。"
},
"oi_top_limit": {
"type": "int",
"min": 1,
"max": 10,
"description": "OI Top 选取数量,手动页面范围 110。"
},
"use_oi_low": {
"type": "bool",
"default": false,
"description": "是否启用 OI Low 作为候选币来源。"
},
"oi_low_limit": {
"type": "int",
"min": 1,
"max": 10,
"description": "OI Low 选取数量,手动页面范围 110。"
},
"primary_count": {
"type": "int",
"min": 10,
"max": 30,
"description": "主周期 K 线样本数量,手动页面范围 1030。"
},
"enable_ema": {
"type": "bool",
"default": false,
"description": "是否启用 EMA 指标。"
},
"enable_macd": {
"type": "bool",
"default": false,
"description": "是否启用 MACD 指标。"
},
"enable_rsi": {
"type": "bool",
"default": false,
"description": "是否启用 RSI 指标。"
},
"enable_atr": {
"type": "bool",
"default": false,
"description": "是否启用 ATR 指标。"
},
"enable_boll": {
"type": "bool",
"default": false,
"description": "是否启用布林带指标。"
},
"enable_volume": {
"type": "bool",
"default": false,
"description": "是否启用成交量指标。"
},
"enable_oi": {
"type": "bool",
"default": false,
"description": "是否启用持仓量指标。"
},
"enable_funding_rate": {
"type": "bool",
"default": false,
"description": "是否启用资金费率指标。"
},
"ema_periods": {
"type": "int_array",
"description": "EMA 周期列表,例如 [9,21,55]。"
},
"rsi_periods": {
"type": "int_array",
"description": "RSI 周期列表。"
},
"atr_periods": {
"type": "int_array",
"description": "ATR 周期列表。"
},
"boll_periods": {
"type": "int_array",
"description": "布林带周期列表。"
},
"nofxos_api_key": {
"type": "credential",
"description": "量化数据 API Key。"
},
"enable_quant_data": {
"type": "bool",
"default": false,
"description": "是否启用量化数据增强。"
},
"enable_quant_oi": {
"type": "bool",
"default": false,
"description": "是否启用量化持仓量数据。"
},
"enable_quant_netflow": {
"type": "bool",
"default": false,
"description": "是否启用量化净流入数据。"
},
"enable_oi_ranking": {
"type": "bool",
"default": false,
"description": "是否启用 OI 排行榜。"
},
"oi_ranking_duration": {
"type": "enum",
"values": ["1h", "4h", "24h"],
"description": "OI 排行榜统计周期,页面选项为 1h、4h、24h。"
},
"oi_ranking_limit": {
"type": "int",
"min": 5,
"max": 20,
"description": "OI 排行榜返回数量,页面选项为 5、10、15、20。"
},
"enable_netflow_ranking": {
"type": "bool",
"default": false,
"description": "是否启用净流入排行榜。"
},
"netflow_ranking_duration": {
"type": "enum",
"values": ["1h", "4h", "24h"],
"description": "净流入排行榜统计周期,页面选项为 1h、4h、24h。"
},
"netflow_ranking_limit": {
"type": "int",
"min": 5,
"max": 20,
"description": "净流入排行榜返回数量,页面选项为 5、10、15、20。"
},
"enable_price_ranking": {
"type": "bool",
"default": false,
"description": "是否启用价格波动排行榜。"
},
"price_ranking_duration": {
"type": "enum",
"values": ["1h", "4h", "24h", "1h,4h,24h"],
"description": "价格排行榜统计周期,页面选项为 1h、4h、24h、1h,4h,24h。"
},
"price_ranking_limit": {
"type": "int",
"min": 5,
"max": 20,
"description": "价格排行榜返回数量,页面选项为 5、10、15、20。"
}
},
"validation_rules": [
"本 skill 只负责策略模板创建、查看、修改、删除、激活和复制。",
"字段选项和范围来自 field_constraints产品行为规则由 active session prompt 负责。"
],
"actions": {
"create": {
"description": "创建策略模板。",
"required_slots": ["name"],
"optional_slots": ["strategy_type", "config_patch"],
"goal": "创建一个可供 trader 绑定使用的策略模板。",
"success_output": "返回 strategy_id 和新策略摘要(名称、类型、主要配置)。",
"failure_output": "明确指出仍缺哪些核心参数,或说明需要先确认的风控收敛结果。"
},
"update": {
"description": "更新策略模板的任意可编辑字段。",
"required_slots": ["target_ref"],
"optional_slots": ["name", "description", "is_public", "config_visible", "config_patch"],
"goal": "更新一个已有策略模板的指定配置,而不覆盖未提及字段。",
"dynamic_rules": [
"只更新用户明确提到的字段,不要覆盖未提及的字段。",
"杠杆超出 120 范围时,自动收敛并告知用户。",
"grid_trading 类型时lower_price 必须小于 upper_price。"
],
"success_output": "返回 strategy_id 和更新后的策略摘要。",
"failure_output": "明确指出目标策略不存在、参数非法,或仍缺哪个关键字段。"
},
"update_name": {
"description": "仅修改策略模板名称。",
"required_slots": ["target_ref", "name"],
"goal": "仅修改策略模板名称。",
"dynamic_rules": [
"若输入里还包含其他配置项,应优先转去更通用的 update 或 update_config。"
],
"success_output": "返回 strategy_id并明确告知新的策略名称。",
"failure_output": "明确指出目标策略不存在,或新的名称仍未收齐。"
},
"update_prompt": {
"description": "仅修改策略的 custom_prompt 或 prompt_sectionsrole_definition、trading_frequency、entry_standards、decision_process。",
"required_slots": ["target_ref"],
"optional_slots": ["custom_prompt", "role_definition", "trading_frequency", "entry_standards", "decision_process"],
"goal": "更新策略模板的提示词相关内容,而不改动其他配置。",
"dynamic_rules": [
"若用户一次修改多个 prompt section应整体应用并在结果里清楚说明。",
"若用户实际是在改纯配置项,应转去 update_config。",
"当需要收集 custom_prompt 或 prompt_sections 等长文本槽位,而用户表达了“交给你”“你帮我写”“你自己设计”等委托生成意图时,严禁再次机械索要原文。",
"此时你必须直接以量化专家身份先拟出一版高质量文本,将生成内容写入对应字段,并在回复里展示草稿让用户确认是否直接采用。"
],
"success_output": "返回 strategy_id并明确告知哪些 prompt 字段已更新。",
"failure_output": "明确指出目标策略不存在,或新的 prompt 内容仍不完整。"
},
"update_config": {
"description": "修改策略的某个具体配置参数(选币来源、指标、风控参数等)。",
"required_slots": ["target_ref"],
"optional_slots": ["config_patch"],
"goal": "修改策略模板中的一个或一组具体配置参数。",
"dynamic_rules": [
"配置变更统一通过 config_patch 表达,字段必须来自当前策略类型的产品模板。",
"字段选项、范围和非策略字段拦截由 active session prompt 与后端 schema 负责。"
],
"success_output": "返回 strategy_id并明确告知已修改的配置字段及其最终值。",
"failure_output": "明确指出目标策略不存在、配置字段非法,或值仍需用户澄清。"
},
"activate": {
"description": "将策略模板设为默认模板(激活)。",
"required_slots": ["target_ref"],
"goal": "将某个策略模板设为默认模板。",
"success_output": "返回 strategy_id并明确告知该策略已被设为默认模板。",
"failure_output": "明确指出目标策略不存在,或激活失败原因。"
},
"duplicate": {
"description": "复制策略模板,生成一个新的同配置模板。",
"required_slots": ["target_ref", "name"],
"goal": "复制一个现有策略模板并生成新的模板名称。",
"dynamic_rules": [
"新名称必须单独收齐;若名称有歧义或为空,应先继续追问。"
],
"success_output": "返回新的 strategy_id并明确告知复制后的策略名称。",
"failure_output": "明确指出目标策略不存在,或新名称仍未收齐。"
},
"delete": {
"description": "删除策略模板,不可逆操作,必须确认。",
"required_slots": ["target_ref"],
"needs_confirmation": true,
"goal": "删除一个策略模板。",
"dynamic_rules": [
"必须在确认后执行,并明确提醒删除不可逆。",
"若策略是默认模板或受系统保护,应向用户解释限制。"
],
"success_output": "返回删除成功结果,并明确告知该策略模板已被移除。",
"failure_output": "明确指出缺少确认、目标策略不存在,或删除失败原因。"
},
"query_list": {
"description": "查询所有策略模板列表,包含名称、类型、是否为默认模板。",
"goal": "列出当前用户可见的策略模板,便于后续选择或绑定。",
"dynamic_rules": [
"优先返回名称、类型、默认状态,不必展开全部详细配置。"
],
"success_output": "返回策略模板列表摘要。",
"failure_output": "若列表为空,应明确告知当前没有策略模板。"
},
"query_detail": {
"description": "查询某个策略模板的详细配置,包括选币来源、指标、风控参数、提示词等。",
"required_slots": ["target_ref"],
"goal": "读取一个策略模板的详细配置。",
"dynamic_rules": [
"若目标有歧义,应先澄清再返回详情。"
],
"success_output": "返回目标策略模板的详细配置摘要。",
"failure_output": "明确指出目标策略不存在,或当前引用已经失效。"
}
},
"tool_mapping": {
"create": "manage_strategy:create",
"update": "manage_strategy:update",
"update_name": "manage_strategy:update",
"update_prompt": "manage_strategy:update",
"update_config": "manage_strategy:update",
"activate": "manage_strategy:activate",
"duplicate": "manage_strategy:duplicate",
"delete": "manage_strategy:delete",
"query_list": "get_strategies",
"query_detail": "get_strategies"
}
}

View File

@@ -1,63 +0,0 @@
{
"name": "trade_execution",
"kind": "execution",
"domain": "trade",
"description": "当用户明确要求开仓、平仓、买入、卖出,或确认待执行的大额订单时调用。负责真实下单前的安全校验、待确认订单、确认执行与交易历史查询。",
"intents": [
"下单交易",
"开多开空",
"平仓",
"确认大额订单",
"查询交易历史"
],
"actions": {
"execute": {
"description": "创建一笔待确认交易。不会直接成交,而是先做风险检查,再给用户确认指令。",
"required_slots": ["action", "symbol", "quantity"],
"optional_slots": ["leverage", "trader_id"],
"needs_confirmation": true,
"goal": "在真实执行前先做风险检查,并给用户一个可确认的待执行订单。",
"dynamic_rules": [
"只有当用户明确要求交易时才允许进入本动作;分析、建议、解释行情都不应触发下单。",
"开仓数量必须大于 0单笔数量硬上限为 1000000超过时直接拒绝。",
"会先按实时价格估算名义价值;单笔名义价值硬上限为 100000 USDT超过时直接拒绝。",
"若单笔名义价值达到 5000 USDT或达到账户权益的 25%,必须标记为大额订单,要求用户发送“确认大额 trade_xxx”后才执行。",
"若单笔名义价值超过账户权益的 100%,直接拒绝,不允许创建待确认订单。",
"加密货币订单的杠杆上限受策略 btceth_max_leverage / altcoin_max_leverage 约束,默认上限为 5x超出时直接拒绝。",
"BTC/ETH 单笔最大仓位价值默认不超过 5 倍账户权益,山寨币默认不超过 1 倍账户权益;若策略里有自定义比例,以策略为准。",
"最小仓位价值固定为 12 USDT这是系统强制项不允许通过 Agent 修改。低于最小值时直接拒绝。",
"创建后的待确认订单默认 5 分钟有效,超时自动失效。"
],
"success_output": "返回 trade_id、估算仓位价值、是否触发大额确认、确认命令和 5 分钟有效期。",
"failure_output": "用简单清楚的话说明是哪条风控挡住了,例如数量过大、仓位太小、杠杆过高、超过权益上限。"
},
"confirm_large_order": {
"description": "确认一笔已创建的大额待执行订单。",
"required_slots": ["trade_id"],
"needs_confirmation": true,
"goal": "在用户明确确认后,执行已通过初步检查的大额订单。",
"dynamic_rules": [
"用户必须发送“确认大额 trade_xxx”或“confirm large trade_xxx”才能执行大额订单。",
"若订单已过期、已不存在,或 trade_id 无效,要直接说明这笔订单已经失效。",
"若用户只发送普通确认,但订单被标记为大额订单,必须继续要求“大额确认”,不能直接放行。"
],
"success_output": "明确告知订单已执行,并展示方向、品种、数量。",
"failure_output": "明确说明订单已过期、风控未通过,或执行失败原因。"
},
"query_history": {
"description": "查询最近的交易历史。",
"optional_slots": ["limit", "trader_id"],
"goal": "让用户快速查看最近成交记录和交易结果。",
"dynamic_rules": [
"优先返回最近几笔最重要的交易,不要一次性给太长的开发者原始日志。",
"若当前没有交易记录,要直接说明当前还没有成交记录。"
],
"success_output": "返回最近交易记录摘要,包括方向、品种、时间和结果。",
"failure_output": "若没有记录或查询失败,要明确告知用户。"
}
},
"tool_mapping": {
"execute": "execute_trade",
"query_history": "get_trade_history"
}
}

View File

@@ -1,39 +0,0 @@
{
"name": "trader_diagnosis",
"kind": "diagnosis",
"domain": "trader",
"description": "当用户反馈交易员无法启动、启动后不交易、反复报错、绑定模型或交易所缺失、运行状态异常、收益或仓位表现异常时调用。适用于交易员运行过程中的排障与原因定位。不用于创建、修改、删除、启动、停止或查询交易员这类管理操作。",
"capabilities": [
"读取交易员当前状态、账户、持仓和最近决策记录",
"读取交易员绑定的策略、模型、交易所配置摘要,并把它们纳入不开单诊断证据包",
"在用户明确指定目标交易员后,读取该交易员最近的后端日志",
"把完整证据合并成适合新手理解的最终原因和下一步行动"
],
"dynamic_rules": [
"当用户问“为什么报错”“为什么不交易”“为什么停了”这类问题时,优先走诊断而不是管理类 skill。",
"如果已经能唯一确定目标交易员,应一次性收集完整诊断证据包:交易员配置/运行状态、绑定策略、绑定模型、绑定交易所、账户权益/可用余额、当前持仓、get_decisions 最近决策记录、get_backend_logs 后台日志。不要只查其中一项就下结论。",
"面向普通用户的诊断回复只说最终原因和该怎么办不要输出证据包清单、工具名、后台日志片段、HTTP 状态码或工程排障过程。",
"诊断结论内部必须区分:直接原因、次要影响、待确认因素。直接原因必须来自最近决策记录、交易所下单结果、风控校验或明确运行状态;后台日志里的零散错误只能作为辅助证据。",
"证据优先级固定为:最近决策记录 > 交易员运行状态/账户/持仓 > 交易所下单结果 > 后台日志。除非最近决策记录本身显示数据获取失败或 AI 决策中断,否则不要让 backend logs 盖过决策记录。",
"交易员不下单的排查顺序固定为:是否运行中 -> 是否已到扫描间隔 -> 策略候选币/行情数据是否为空 -> 最近 AI 决策是否为 hold/wait -> 风控是否拦截 -> 交易所下单是否报错 -> 余额、杠杆、仓位模式或权限是否限制。",
"判断“不下单/不开单”的主因时,最近决策记录优先级高于零散 backend error 日志;如果最近决策显示 wait succeeded应解释为 AI 主动等待;如果最新决策 error_message 显示 opening amount too small / below minimum / must be ≥,应解释为开仓金额低于系统或交易所最小下单门槛。",
"遇到 opening amount too small、position value below minimum、must be ≥ 这类错误时,不要建议用户修改 AI 智能策略的 min_position_size 或 position_size_usd。先说明这是系统/交易所门槛或 System enforced 边界,再建议增加账户权益、换更适合小资金的交易标的、调整可编辑策略偏好,或让策略在资金不足时等待。",
"AI 智能策略里的 System enforced 字段max_positions、btceth_max_position_value_ratio、altcoin_max_position_value_ratio、max_margin_usage、min_position_size只能解释不能建议用户修改如果限制来自这些字段行动建议必须落在产品实际可改项或用户账户/标的选择上。",
"不要只因为 backend logs 里出现 402、404、EOF、payment retry failed 就直接归因为数据服务、订阅到期或付款失败;这些内部异常不应在普通用户回答里出现,除非用户明确追问后台日志或技术细节。",
"402 不要直接翻译成“订阅到期”。在没有钱包余额、支付状态或服务侧确认前,不能说订阅过期;普通用户回答里也不要主动说 402。",
"如果最近决策记录显示 candidate_coins 非空、AI call completed、wait succeeded 或 open_* 决策已生成,则说明核心决策链路并非完全拿不到数据;此时不要把 402/404/EOF 说成不开单主因。",
"行动建议必须对应产品里真实存在且可修改的字段或操作。不要编造策略页不存在的 position_size_usd 参数,不要建议修改 System enforced 字段。",
"如果模型是 claw402 或 blockrun-base应单独检查钱包 USDC 余额;余额不足时应说“支付余额不足/需要充值”,不要泛化成“模型没启用”。",
"如果日志显示 AI 返回 hold/wait应解释为模型判断当前没有足够交易信号不应误判为系统没有运行。",
"如果日志显示下单失败应优先归因到交易所权限、API 凭证、仓位模式、余额、杠杆或 symbol 可交易性,而不是策略没有生效。",
"当用户表达“启动不了”“启动失败”“无法启动”“一启动就报错”“为什么启动不起来”这类启动故障时,只要目标交易员能唯一确定,就优先自动读取 get_backend_logs。",
"当证据中已经出现明确错误原因时,直接用人话解释最终原因和下一步,不要复述原始日志。"
],
"tool_mapping": {
"query_runtime_state": "get_trader_system_status",
"query_positions": "get_positions",
"query_account": "get_account_info",
"query_recent_decisions": "get_decisions",
"query_backend_logs": "get_backend_logs"
}
}

View File

@@ -1,231 +0,0 @@
{
"name": "trader_management",
"kind": "management",
"domain": "trader",
"description": "当用户想创建、查看、修改、删除、启动或停止交易员时调用。交易员是装配层;创建交易员时需要名称以及绑定的交易所、模型、策略。编辑交易员只允许修改手动面板可改的 6 项:绑定交易所、绑定模型、绑定策略、扫描间隔、保证金模式、是否展示到竞技场;不修改这些依赖对象的内部配置,也不在这里改名。若用户要改策略参数、模型配置或交易所凭证,应切到各自的 management skill。创建交易员时交易所、模型、策略既可以直接选择用户已有可用资源也可以在当前主流程里先新建/启用对应资源,再继续完成交易员创建。不用于排查交易员启动失败、未下单、收益异常、仓位异常等诊断问题。",
"intents": [
"创建交易员",
"修改交易员",
"删除交易员",
"启动交易员",
"停止交易员",
"查询交易员"
],
"field_constraints": {
"name": {
"type": "string",
"required": true,
"max_length": 50,
"description": "交易员名称,用于识别和管理,最多 50 个字符。"
},
"exchange_id": {
"type": "entity_ref",
"required": true,
"description": "绑定的交易所配置 ID必须是已存在且已启用的交易所配置。"
},
"ai_model_id": {
"type": "entity_ref",
"required": true,
"description": "绑定的 AI 模型配置 ID必须是已存在且已启用的模型配置。"
},
"strategy_id": {
"type": "entity_ref",
"required": true,
"description": "绑定的策略模板 ID必须是已存在的策略模板。"
},
"scan_interval_minutes": {
"type": "int",
"min": 3,
"max": 60,
"default": 5,
"description": "AI 扫描决策间隔,单位分钟,手动面板可配置范围 360 分钟。超出范围会自动收敛到边界值并告知用户。"
},
"is_cross_margin": {
"type": "bool",
"default": true,
"description": "保证金模式。true = 全仓cross marginfalse = 逐仓isolated margin。"
},
"show_in_competition": {
"type": "bool",
"default": true,
"description": "是否在竞技场中显示该交易员的成绩。"
},
"auto_start": {
"type": "bool",
"default": false,
"description": "创建后是否立即启动交易员。启动前系统会校验绑定的交易所、模型、策略均可用。"
}
},
"validation_rules": [
"exchange_id 对应的交易所配置必须已启用enabled=true否则无法创建或启动交易员。",
"ai_model_id 对应的模型配置必须已启用enabled=true且配置完整api_key、custom_model_name 不为空custom_api_url 若填写必须为合法 HTTPS否则无法创建或启动交易员。",
"strategy_id 对应的策略模板必须存在,否则无法创建交易员。",
"scan_interval_minutes 超出 360 范围时,系统自动收敛到边界值,并通过 LLM 告知用户已调整,询问是否接受。",
"交易员初始余额由系统在创建时自动读取绑定交易所账户净值,不接受用户手动设置、充值或修改。",
"交易员名称不能从模型 provider 自动推断;用户说“用 claw402”表示模型选择不表示交易员名称叫 claw402。",
"用户明确指定模型、交易所或策略时,若该资源不存在、被禁用、配置不完整或钱包余额不足,必须说明具体原因并让用户确认修复或替换;不得静默换成另一个资源。",
"若用户指定 claw402 作为模型,但 claw402 钱包余额为 0 USDC应提示先充值或确认临时改用其他可用模型不得说成 claw402 未启用,除非 enabled 确实为 false。",
"启动交易员前,绑定的模型必须已启用且完整,绑定的交易所也必须已启用且通过对应交易所的完整性校验,否则拒绝启动并明确指出缺哪一项。",
"若绑定的是 OKX 交易所,启用前必须已有 passphrase若绑定的是 Hyperliquid启用前必须已有 wallet_addr若绑定的是 Aster启用前必须已有 user、signer、private_key若绑定的是 Lighter启用前必须已有 wallet_addr 和 api_key_private_key。",
"启动start和停止stop操作属于高风险操作必须先向用户确认再执行。",
"删除delete操作不可逆必须先向用户确认再执行。"
],
"actions": {
"create": {
"description": "创建新的交易员。若缺少交易所、模型或策略,可在当前流程内先选择已有资源,或切去对应 skill 新建/启用后自动回流继续。",
"required_slots": ["name", "exchange", "model", "strategy"],
"optional_slots": ["auto_start", "scan_interval_minutes", "is_cross_margin", "show_in_competition"],
"goal": "创建并初始化一个交易员。",
"dynamic_rules": [
"若用户提到的交易所、模型或策略已经存在且可用,应优先直接补入对应槽位,不要重新创建。",
"如果用户明确指定某个模型 provider如 claw402应先尝试匹配该 provider 对应的模型配置;只有在说明原因并得到用户确认后,才可改用其他模型。",
"若用户没有提供交易员名称,应生成一个来自交易所/策略/方向的清晰名称,或向用户追问;不要把模型 provider、交易所类型或策略字段误用为交易员名称。",
"若依赖资源不存在、被禁用,或用户明确要求新建或启用,禁止直接报缺字段;应切去对应 management:create 或 management:update_status 子任务。",
"子任务成功后,系统会恢复当前交易员草稿并继续补齐剩余槽位。",
"scan_interval_minutes 超出 360 时,自动收敛并告知用户。",
"不要向用户收集或确认初始余额;创建时由系统自动读取绑定交易所账户净值作为初始余额。",
"创建完成后询问用户是否立即启动auto_start启动前再次确认。"
],
"success_output": "返回 trader_id并给出创建结果摘要名称、绑定的交易所/模型/策略、是否已启动)。",
"failure_output": "用人话指出缺失依赖项,或说明当前正在进入哪个依赖子任务。"
},
"update": {
"description": "更新已有交易员,但只处理手动面板允许的字段:换绑策略、交易所、模型,或修改扫描间隔、保证金模式、竞技场显示。",
"required_slots": ["target_ref"],
"optional_slots": ["exchange_id", "ai_model_id", "strategy_id", "scan_interval_minutes", "is_cross_margin", "show_in_competition"],
"goal": "更新一个已有交易员的手动面板字段,但不改动策略、模型、交易所内部配置。",
"dynamic_rules": [
"只更新用户明确提到的字段,不要覆盖未提及的字段。",
"换绑交易所/模型/策略时,新的资源必须已存在且已启用;若是钱包付费模型,还要解释余额不足等支付状态。",
"用户明确要求换成某个模型/交易所/策略时,不能自动选择另一个看起来可用的资源,除非用户确认。",
"如果用户要求改名,应明确告知交易员改名不在这里处理。",
"如果用户实际上是想修改策略参数、模型配置或交易所凭证,不要继续留在 trader update应切到对应 management skill。"
],
"success_output": "返回更新后的 trader_id 与简短配置摘要,明确哪些字段已经生效。",
"failure_output": "明确指出目标交易员不存在、依赖资源不可用,或哪一个字段值仍需用户补充/修正。"
},
"update_bindings": {
"description": "修改交易员手动面板可编辑的字段,可同时修改绑定关系、扫描间隔、保证金模式、竞技场显示。",
"required_slots": ["target_ref"],
"optional_slots": ["exchange_id", "ai_model_id", "strategy_id", "scan_interval_minutes", "is_cross_margin", "show_in_competition"],
"goal": "调整交易员手动面板可编辑的字段,而不改动无关配置。",
"dynamic_rules": [
"新绑定的资源必须已存在且已启用,否则提示用户先启用或新建。",
"当指定模型是 claw402 或 blockrun-base 且钱包余额不足时,应提示充值或让用户确认临时切换模型。",
"扫描间隔超出 360 时,自动收敛并告知用户。"
],
"success_output": "返回 trader_id并明确展示新的模型/交易所/策略绑定结果。",
"failure_output": "明确指出缺少哪个绑定目标,或当前依赖资源为什么不可直接绑定。"
},
"configure_strategy": {
"description": "仅修改交易员绑定的策略。",
"required_slots": ["target_ref", "strategy_id"],
"goal": "为指定交易员换绑一个策略模板。",
"dynamic_rules": [
"若用户提到的是不存在的策略,应优先澄清或引导创建,而不是静默失败。"
],
"success_output": "返回 trader_id并明确告知当前生效的 strategy_id/策略名称。",
"failure_output": "明确指出目标交易员或策略不存在,或策略仍需用户澄清。"
},
"configure_exchange": {
"description": "仅修改交易员绑定的交易所。",
"required_slots": ["target_ref", "exchange_id"],
"goal": "为指定交易员换绑一个交易所配置。",
"dynamic_rules": [
"新的交易所配置必须已启用且可用,否则提示用户先启用或补齐凭证。"
],
"success_output": "返回 trader_id并明确告知当前生效的 exchange_id/交易所名称。",
"failure_output": "明确指出目标交易员或交易所不存在,或交易所当前不可用。"
},
"configure_model": {
"description": "仅修改交易员绑定的 AI 模型。",
"required_slots": ["target_ref", "ai_model_id"],
"goal": "为指定交易员换绑一个 AI 模型配置。",
"dynamic_rules": [
"新的模型配置必须已启用且可调用,否则提示用户先启用或补齐模型配置。",
"若用户指定的是 claw402应优先绑定 claw402只有在钱包余额不足、凭证缺失或配置不可用且用户确认后才允许改绑其他模型。"
],
"success_output": "返回 trader_id并明确告知当前生效的 ai_model_id/模型名称。",
"failure_output": "明确指出目标交易员或模型不存在,或模型当前不可用。"
},
"start": {
"description": "启动交易员,使其开始自动交易。高风险操作,必须确认。",
"required_slots": ["target_ref"],
"needs_confirmation": true,
"goal": "让一个已配置好的交易员进入运行状态。",
"dynamic_rules": [
"启动前系统会自动校验绑定的交易所、模型、策略是否均可用。",
"若绑定模型为 claw402 或 blockrun-base 且钱包余额不足,应提示充值或换模型;不要把它泛化成“模型不可用”。",
"若校验失败,用人话告知用户具体哪个依赖不可用,并引导修复。"
],
"success_output": "返回 trader_id并明确告知交易员已开始运行。",
"failure_output": "明确指出缺少确认、依赖资源不可用,或启动未通过校验。"
},
"stop": {
"description": "停止交易员,使其停止自动交易。高风险操作,必须确认。",
"required_slots": ["target_ref"],
"needs_confirmation": true,
"goal": "让一个运行中的交易员停止自动交易。",
"dynamic_rules": [
"若交易员当前并未运行,也应给用户清晰说明,而不是假装停止成功。"
],
"success_output": "返回 trader_id并明确告知交易员已停止。",
"failure_output": "明确指出缺少确认、目标交易员不存在,或当前状态无法停止。"
},
"delete": {
"description": "删除交易员,不可逆操作,必须确认。支持删除单个、多个或全部交易员。",
"required_slots": [],
"needs_confirmation": true,
"goal": "删除一个、多个或全部交易员及其运行入口。",
"dynamic_rules": [
"必须在确认后执行,并明确提醒该操作不可逆。",
"删除范围可以是单个 target_ref、多个目标或 bulk_scope=all。",
"删除前必须确认目标交易员都已停止;若存在运行中的交易员,不能删除,应要求用户先停止这些交易员。"
],
"success_output": "返回删除成功结果,并明确告知哪些交易员已被移除。",
"failure_output": "明确指出缺少确认、目标交易员不存在、目标仍在运行,或删除失败原因。"
},
"query_list": {
"description": "查询所有交易员列表,包含名称、运行状态、绑定信息。",
"goal": "列出当前用户可见的交易员,并给出足够的摘要用于后续选择。",
"dynamic_rules": [
"优先返回名称、运行状态、绑定的模型/交易所/策略,不要冗余展开全部详情。"
],
"success_output": "返回交易员列表摘要,便于用户继续指定目标对象。",
"failure_output": "若列表为空,应明确告知当前没有交易员,而不是返回模糊空结果。"
},
"query_running": {
"description": "查询当前运行中的交易员列表。",
"goal": "仅列出处于运行状态的交易员。",
"dynamic_rules": [
"若当前没有运行中的交易员,应明确告知为空。"
],
"success_output": "返回当前运行中的交易员列表摘要。",
"failure_output": "若没有运行中的交易员,应明确返回空列表说明。"
},
"query_detail": {
"description": "查询某个交易员的详细配置,包括绑定的交易所、模型、策略、扫描间隔、保证金模式等。",
"required_slots": ["target_ref"],
"goal": "读取一个交易员的详细配置和当前绑定信息。",
"dynamic_rules": [
"若目标对象有歧义,应先澄清再读取详情。"
],
"success_output": "返回目标交易员的详细配置摘要。",
"failure_output": "明确指出目标交易员不存在,或当前引用需要重新指定。"
}
},
"tool_mapping": {
"create": "manage_trader:create",
"update": "manage_trader:update",
"update_bindings": "manage_trader:update",
"configure_strategy": "manage_trader:update",
"configure_exchange": "manage_trader:update",
"configure_model": "manage_trader:update",
"start": "manage_trader:start",
"stop": "manage_trader:stop",
"delete": "manage_trader:delete",
"query_list": "manage_trader:list",
"query_running": "manage_trader:list",
"query_detail": "manage_trader:list"
}
}

View File

@@ -1,444 +0,0 @@
package agent
import (
"nofx/safe"
"fmt"
"io"
"net/http"
"net/url"
"strconv"
"strings"
"time"
"golang.org/x/text/encoding/simplifiedchinese"
"golang.org/x/text/transform"
)
// stockHTTPClient is a shared HTTP client for stock API requests.
// Reused across calls for connection pooling.
var stockHTTPClient = &http.Client{
Timeout: 10 * time.Second,
Transport: &http.Transport{
MaxIdleConns: 10,
MaxIdleConnsPerHost: 5,
IdleConnTimeout: 90 * time.Second,
},
}
// StockQuote holds real-time stock data.
type StockQuote struct {
Name string
Code string
Market string // "A股", "港股", "美股"
Currency string // "CNY", "HKD", "USD"
Open float64
PrevClose float64
Price float64
High float64
Low float64
Volume float64
Turnover float64
Date string
Time string
Change float64
ChangePct float64
// 盘前盘后 (美股)
ExtPrice float64 // 盘前/盘后价格
ExtChangePct float64 // 盘前/盘后涨跌幅%
ExtChange float64 // 盘前/盘后涨跌额
ExtTime string // 盘前/盘后时间
IsExtHours bool // 是否在盘前盘后时段
}
// knownStocks maps Chinese names to stock codes.
var knownStocks = map[string]string{
// A股
"拓维信息": "sz002261", "比亚迪": "sz002594", "宁德时代": "sz300750",
"贵州茅台": "sh600519", "中国平安": "sh601318", "招商银行": "sh600036",
"中芯国际": "sh688981", "工商银行": "sh601398", "建设银行": "sh601939",
"中国银行": "sh601988", "农业银行": "sh601288", "中信证券": "sh600030",
"海康威视": "sz002415", "立讯精密": "sz002475", "东方财富": "sz300059",
"隆基绿能": "sh601012", "长城汽车": "sh601633", "科大讯飞": "sz002230",
"三六零": "sh601360", "中兴通讯": "sz000063",
// 港股
"腾讯": "hk00700", "阿里巴巴": "hk09988", "美团": "hk03690",
"小米": "hk01810", "京东": "hk09618", "网易": "hk09999",
"百度": "hk09888", "快手": "hk01024", "哔哩哔哩": "hk09626",
"理想汽车": "hk02015", "蔚来": "hk09866", "小鹏汽车": "hk09868",
// 华为 is not publicly listed — removed incorrect Tencent fallback
// 美股
"苹果": "gb_aapl", "特斯拉": "gb_tsla", "英伟达": "gb_nvda",
"微软": "gb_msft", "谷歌": "gb_googl", "亚马逊": "gb_amzn",
"meta": "gb_meta", "奈飞": "gb_nflx", "台积电": "gb_tsm",
"拼多多": "gb_pdd", "蔚来汽车": "gb_nio",
}
// US stock ticker mapping
var usTickerMap = map[string]string{
"AAPL": "gb_aapl", "TSLA": "gb_tsla", "NVDA": "gb_nvda", "MSFT": "gb_msft",
"GOOGL": "gb_googl", "AMZN": "gb_amzn", "META": "gb_meta", "NFLX": "gb_nflx",
"TSM": "gb_tsm", "PDD": "gb_pdd", "NIO": "gb_nio", "BABA": "gb_baba",
"JD": "gb_jd", "BIDU": "gb_bidu", "AMD": "gb_amd", "INTC": "gb_intc",
"COIN": "gb_coin", "MARA": "gb_mara", "RIOT": "gb_riot",
}
func resolveStockCode(text string) (string, string) {
// Known Chinese names
for name, code := range knownStocks {
if strings.Contains(text, name) {
return code, name
}
}
// US ticker symbols (uppercase)
upper := strings.ToUpper(text)
for ticker, code := range usTickerMap {
if strings.Contains(upper, ticker) {
return code, ticker
}
}
// 6-digit A-share code
for _, w := range strings.Fields(text) {
w = strings.TrimSpace(w)
if len(w) == 6 {
if _, err := strconv.Atoi(w); err == nil {
prefix := "sz"
if w[0] == '6' || w[0] == '9' { prefix = "sh" }
return prefix + w, w
}
}
// 5-digit HK code
if len(w) == 5 {
if _, err := strconv.Atoi(w); err == nil {
return "hk" + w, w
}
}
}
return "", ""
}
// SearchResult represents a stock search result from Sina suggest API.
type SearchResult struct {
Name string // Display name
Code string // Sina-style code (e.g. sz300750, hk00700, gb_tsla)
Ticker string // Raw ticker (e.g. 300750, 00700, tsla)
Type string // Market type code: 11=A股, 31=港股, 41=美股
Market string // "A股", "港股", "美股"
}
// searchStock queries Sina's suggest API for dynamic stock search.
// Returns matching stocks across A-share, HK, and US markets.
func searchStock(keyword string) ([]SearchResult, error) {
// type=11 (A股), 31 (港股), 41 (美股)
u := fmt.Sprintf("https://suggest3.sinajs.cn/suggest/type=11,31,41&key=%s&name=suggestdata",
url.QueryEscape(keyword))
req, _ := http.NewRequest("GET", u, nil)
req.Header.Set("Referer", "https://finance.sina.com.cn")
resp, err := stockHTTPClient.Do(req)
if err != nil {
return nil, err
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
return nil, fmt.Errorf("stock search API returned status %d", resp.StatusCode)
}
reader := transform.NewReader(io.LimitReader(resp.Body, 256*1024), simplifiedchinese.GBK.NewDecoder())
body, err := safe.ReadAllLimited(reader)
if err != nil {
return nil, err
}
line := string(body)
// Parse: var suggestdata="item1;item2;..."
start := strings.Index(line, "\"")
end := strings.LastIndex(line, "\"")
if start == -1 || end <= start {
return nil, fmt.Errorf("invalid suggest response")
}
data := line[start+1 : end]
if data == "" {
return nil, nil // no results
}
var results []SearchResult
items := strings.Split(data, ";")
for _, item := range items {
item = strings.TrimSpace(item)
if item == "" {
continue
}
fields := strings.Split(item, ",")
if len(fields) < 5 {
continue
}
// fields: [0]=name, [1]=type, [2]=ticker, [3]=sinaCode, [4]=displayName
typeCode := fields[1]
ticker := fields[2]
sinaCode := fields[3]
displayName := fields[4]
if displayName == "" {
displayName = fields[0]
}
var mkt, code string
switch typeCode {
case "11": // A股
mkt = "A股"
code = sinaCode // already like sz300750, sh600519
if code == "" {
// Build from ticker
prefix := "sz"
if len(ticker) == 6 && (ticker[0] == '6' || ticker[0] == '9') {
prefix = "sh"
}
code = prefix + ticker
}
case "31": // 港股
mkt = "港股"
code = "hk" + ticker
case "41": // 美股
mkt = "美股"
code = "gb_" + ticker
default:
continue // skip funds (201), indices, etc.
}
results = append(results, SearchResult{
Name: displayName,
Code: code,
Ticker: ticker,
Type: typeCode,
Market: mkt,
})
}
return results, nil
}
// resolveStockCodeDynamic tries local map first, then falls back to Sina search API.
func resolveStockCodeDynamic(text string) (string, string) {
// First try the static map
code, name := resolveStockCode(text)
if code != "" {
return code, name
}
// Fall back to Sina search API
// Extract a meaningful search keyword from the text
keyword := extractStockKeyword(text)
if keyword == "" {
return "", ""
}
results, err := searchStock(keyword)
if err != nil || len(results) == 0 {
return "", ""
}
// Return the first (best) result
return results[0].Code, results[0].Name
}
// extractStockKeyword extracts a likely stock name/ticker from user text.
func extractStockKeyword(text string) string {
// Remove common prefixes/suffixes that aren't stock names
text = strings.TrimSpace(text)
// If the text itself is short enough, use it directly
// (e.g. "中远海控" or "AAPL")
if len([]rune(text)) <= 10 {
return text
}
// Try to extract quoted terms first: 「xxx」 or "xxx"
quotePairs := [][2]string{
{"「", "」"},
{"\u201c", "\u201d"},
{"\u2018", "\u2019"},
{"\"", "\""},
}
for _, pair := range quotePairs {
if s := strings.Index(text, pair[0]); s >= 0 {
if e := strings.Index(text[s+len(pair[0]):], pair[1]); e >= 0 {
return text[s+len(pair[0]) : s+len(pair[0])+e]
}
}
}
// Look for patterns like "查 XXX", "搜索 XXX", "查一下 XXX"
for _, prefix := range []string{"查一下", "搜索", "查询", "看看", "搜一下", "查", "看", "search ", "find "} {
if idx := strings.Index(text, prefix); idx >= 0 {
rest := strings.TrimSpace(text[idx+len(prefix):])
// Take the first "word" (either Chinese characters or English word)
words := strings.Fields(rest)
if len(words) > 0 {
return words[0]
}
}
}
// Last resort: use first few words
words := strings.Fields(text)
if len(words) > 0 {
return words[0]
}
return ""
}
func fetchStockQuote(code string) (*StockQuote, error) {
url := fmt.Sprintf("https://hq.sinajs.cn/list=%s", code)
req, _ := http.NewRequest("GET", url, nil)
req.Header.Set("Referer", "https://finance.sina.com.cn")
resp, err := stockHTTPClient.Do(req)
if err != nil { return nil, err }
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
return nil, fmt.Errorf("stock quote API returned status %d", resp.StatusCode)
}
reader := transform.NewReader(io.LimitReader(resp.Body, 256*1024), simplifiedchinese.GBK.NewDecoder())
body, err := safe.ReadAllLimited(reader)
if err != nil { return nil, err }
line := string(body)
start := strings.Index(line, "\"")
end := strings.LastIndex(line, "\"")
if start == -1 || end <= start { return nil, fmt.Errorf("invalid response") }
data := line[start+1 : end]
if data == "" { return nil, fmt.Errorf("empty data for %s", code) }
if strings.HasPrefix(code, "sh") || strings.HasPrefix(code, "sz") {
return parseAShare(code, data)
} else if strings.HasPrefix(code, "hk") {
return parseHKShare(code, data)
} else if strings.HasPrefix(code, "gb_") {
return parseUSShare(code, data)
}
return nil, fmt.Errorf("unsupported market: %s", code)
}
func parseAShare(code, data string) (*StockQuote, error) {
f := strings.Split(data, ",")
if len(f) < 32 { return nil, fmt.Errorf("too few fields") }
q := &StockQuote{Name: f[0], Code: code, Market: "A股", Currency: "CNY"}
q.Open, _ = strconv.ParseFloat(f[1], 64)
q.PrevClose, _ = strconv.ParseFloat(f[2], 64)
q.Price, _ = strconv.ParseFloat(f[3], 64)
q.High, _ = strconv.ParseFloat(f[4], 64)
q.Low, _ = strconv.ParseFloat(f[5], 64)
q.Volume, _ = strconv.ParseFloat(f[8], 64)
q.Turnover, _ = strconv.ParseFloat(f[9], 64)
q.Date = f[30]; q.Time = f[31]
if q.PrevClose > 0 { q.Change = q.Price - q.PrevClose; q.ChangePct = (q.Change / q.PrevClose) * 100 }
return q, nil
}
func parseHKShare(code, data string) (*StockQuote, error) {
f := strings.Split(data, ",")
if len(f) < 18 { return nil, fmt.Errorf("too few fields") }
q := &StockQuote{Name: f[1], Code: code, Market: "港股", Currency: "HKD"}
q.PrevClose, _ = strconv.ParseFloat(f[3], 64)
q.Open, _ = strconv.ParseFloat(f[2], 64)
q.High, _ = strconv.ParseFloat(f[4], 64)
q.Low, _ = strconv.ParseFloat(f[5], 64)
q.Price, _ = strconv.ParseFloat(f[6], 64)
q.Change, _ = strconv.ParseFloat(f[7], 64)
q.ChangePct, _ = strconv.ParseFloat(f[8], 64)
q.Turnover, _ = strconv.ParseFloat(f[10], 64)
q.Volume, _ = strconv.ParseFloat(f[11], 64)
if len(f) > 17 { q.Date = f[17]; q.Time = f[17] }
return q, nil
}
func parseUSShare(code, data string) (*StockQuote, error) {
f := strings.Split(data, ",")
if len(f) < 30 { return nil, fmt.Errorf("too few fields") }
q := &StockQuote{Name: f[0], Code: code, Market: "美股", Currency: "USD"}
q.Price, _ = strconv.ParseFloat(f[1], 64)
q.ChangePct, _ = strconv.ParseFloat(f[2], 64)
q.Change, _ = strconv.ParseFloat(f[4], 64)
q.Open, _ = strconv.ParseFloat(f[5], 64)
q.High, _ = strconv.ParseFloat(f[6], 64)
q.Low, _ = strconv.ParseFloat(f[7], 64)
// 52wk high/low
high52, _ := strconv.ParseFloat(f[8], 64)
low52, _ := strconv.ParseFloat(f[9], 64)
q.Volume, _ = strconv.ParseFloat(f[10], 64)
q.Turnover, _ = strconv.ParseFloat(f[11], 64)
if len(f) > 25 { q.Date = f[25]; q.Time = f[26] }
q.PrevClose = q.Price - q.Change
_ = high52; _ = low52
// 盘前盘后数据 (字段21=价格, 22=涨跌幅%, 23=涨跌额, 24=时间)
if len(f) > 24 {
extPrice, _ := strconv.ParseFloat(f[21], 64)
extPct, _ := strconv.ParseFloat(f[22], 64)
extChg, _ := strconv.ParseFloat(f[23], 64)
if extPrice > 0 {
q.ExtPrice = extPrice
q.ExtChangePct = extPct
q.ExtChange = extChg
q.ExtTime = strings.TrimSpace(f[24])
q.IsExtHours = true
}
}
return q, nil
}
func formatStockQuote(q *StockQuote) string {
emoji := "🟢"
if q.ChangePct < 0 { emoji = "🔴" }
sym := "¥"
if q.Currency == "USD" { sym = "$" }
if q.Currency == "HKD" { sym = "HK$" }
volStr := fmt.Sprintf("%.0f", q.Volume)
if q.Volume > 1000000 { volStr = fmt.Sprintf("%.1f万", q.Volume/10000) }
if q.Volume > 100000000 { volStr = fmt.Sprintf("%.2f亿", q.Volume/100000000) }
turnStr := fmt.Sprintf("%.0f", q.Turnover)
if q.Turnover > 100000000 { turnStr = fmt.Sprintf("%.2f亿", q.Turnover/100000000) }
result := fmt.Sprintf(`%s *%s* (%s · %s)
💰 现价: %s%.2f (%+.2f%%)
📊 开盘: %s%.2f | 昨收: %s%.2f
📈 最高: %s%.2f | 最低: %s%.2f
📦 成交: %s | 额: %s
🕐 %s`,
emoji, q.Name, q.Code, q.Market,
sym, q.Price, q.ChangePct,
sym, q.Open, sym, q.PrevClose,
sym, q.High, sym, q.Low,
volStr, turnStr,
q.Date)
// 盘前盘后数据
if q.IsExtHours && q.ExtPrice > 0 {
extEmoji := "🟢"
if q.ExtChangePct < 0 { extEmoji = "🔴" }
extLabel := "🌙 盘后"
if strings.Contains(strings.ToLower(q.ExtTime), "am") {
extLabel = "🌅 盘前"
}
result += fmt.Sprintf("\n%s %s: %s%.2f (%+.2f%%) %s",
extLabel, extEmoji, sym, q.ExtPrice, q.ExtChangePct, q.ExtTime)
}
return result
}

View File

@@ -1,27 +0,0 @@
package agent
import (
"strings"
)
func inferStandaloneStrategyName(text string) string {
value := strings.TrimSpace(text)
if value == "" || len([]rune(value)) > 50 {
return ""
}
if strategyCreateConfirmationReply(value) || strategyCreateDefaultConfigReply(value) || isCancelSkillReply(value) {
return ""
}
if parseStrategyTypeValue(value) != "" {
return ""
}
if containsAny(strings.ToLower(value), []string{"创建", "新建", "create", "grid_trading", "ai_trading"}) {
return ""
}
return value
}
func activeHistoryMessageAsksStrategyName(text string) bool {
lower := strings.ToLower(strings.TrimSpace(text))
return containsAny(lower, []string{"策略名", "名称", "名字", "叫什么", "name"})
}

View File

@@ -1,224 +0,0 @@
package agent
func manualStrategyEditableFieldKeys() []string {
return []string{
"name",
"description",
"is_public",
"config_visible",
"strategy_type",
"symbol",
"grid_count",
"total_investment",
"leverage",
"upper_price",
"lower_price",
"use_atr_bounds",
"atr_multiplier",
"distribution",
"enable_direction_adjust",
"direction_bias_ratio",
"max_drawdown_pct",
"stop_loss_pct",
"daily_loss_limit_pct",
"use_maker_only",
"source_type",
"static_coins",
"excluded_coins",
"use_ai500",
"ai500_limit",
"use_oi_top",
"oi_top_limit",
"use_oi_low",
"oi_low_limit",
"primary_timeframe",
"primary_count",
"selected_timeframes",
"enable_ema",
"enable_macd",
"enable_rsi",
"enable_atr",
"enable_boll",
"enable_volume",
"enable_oi",
"enable_funding_rate",
"ema_periods",
"rsi_periods",
"atr_periods",
"boll_periods",
"nofxos_api_key",
"enable_quant_data",
"enable_quant_oi",
"enable_quant_netflow",
"enable_oi_ranking",
"oi_ranking_duration",
"oi_ranking_limit",
"enable_netflow_ranking",
"netflow_ranking_duration",
"netflow_ranking_limit",
"enable_price_ranking",
"price_ranking_duration",
"price_ranking_limit",
"btceth_max_leverage",
"altcoin_max_leverage",
"min_risk_reward_ratio",
"min_confidence",
"role_definition",
"trading_frequency",
"entry_standards",
"decision_process",
"custom_prompt",
}
}
func manualStrategyEditableFieldKeysForType(strategyType string) []string {
common := []string{
"name",
"description",
"is_public",
"config_visible",
"strategy_type",
}
switch strategyType {
case "grid_trading":
return append(common,
"symbol",
"grid_count",
"total_investment",
"leverage",
"upper_price",
"lower_price",
"use_atr_bounds",
"atr_multiplier",
"distribution",
"enable_direction_adjust",
"direction_bias_ratio",
"max_drawdown_pct",
"stop_loss_pct",
"daily_loss_limit_pct",
"use_maker_only",
)
case "ai_trading":
return append(common,
"source_type",
"static_coins",
"excluded_coins",
"use_ai500",
"ai500_limit",
"use_oi_top",
"oi_top_limit",
"use_oi_low",
"oi_low_limit",
"primary_timeframe",
"primary_count",
"selected_timeframes",
"enable_ema",
"enable_macd",
"enable_rsi",
"enable_atr",
"enable_boll",
"enable_volume",
"enable_oi",
"enable_funding_rate",
"ema_periods",
"rsi_periods",
"atr_periods",
"boll_periods",
"nofxos_api_key",
"enable_quant_data",
"enable_quant_oi",
"enable_quant_netflow",
"enable_oi_ranking",
"oi_ranking_duration",
"oi_ranking_limit",
"enable_netflow_ranking",
"netflow_ranking_duration",
"netflow_ranking_limit",
"enable_price_ranking",
"price_ranking_duration",
"price_ranking_limit",
"btceth_max_leverage",
"altcoin_max_leverage",
"min_risk_reward_ratio",
"min_confidence",
"role_definition",
"trading_frequency",
"entry_standards",
"decision_process",
"custom_prompt",
)
default:
return manualStrategyEditableFieldKeys()
}
}
func agentStrategyUpdatableFieldKeys() []string {
return []string{
"name",
"description",
"is_public",
"config_visible",
"strategy_type",
"symbol",
"grid_count",
"total_investment",
"leverage",
"upper_price",
"lower_price",
"use_atr_bounds",
"atr_multiplier",
"distribution",
"enable_direction_adjust",
"direction_bias_ratio",
"max_drawdown_pct",
"stop_loss_pct",
"daily_loss_limit_pct",
"use_maker_only",
"source_type",
"static_coins",
"excluded_coins",
"use_ai500",
"ai500_limit",
"use_oi_top",
"oi_top_limit",
"use_oi_low",
"oi_low_limit",
"primary_timeframe",
"primary_count",
"selected_timeframes",
"enable_ema",
"enable_macd",
"enable_rsi",
"enable_atr",
"enable_boll",
"enable_volume",
"enable_oi",
"enable_funding_rate",
"ema_periods",
"rsi_periods",
"atr_periods",
"boll_periods",
"nofxos_api_key",
"enable_quant_data",
"enable_quant_oi",
"enable_quant_netflow",
"enable_oi_ranking",
"oi_ranking_duration",
"oi_ranking_limit",
"enable_netflow_ranking",
"netflow_ranking_duration",
"netflow_ranking_limit",
"enable_price_ranking",
"price_ranking_duration",
"price_ranking_limit",
"btceth_max_leverage",
"altcoin_max_leverage",
"min_risk_reward_ratio",
"min_confidence",
"role_definition",
"trading_frequency",
"entry_standards",
"decision_process",
"custom_prompt",
}
}

View File

@@ -1,49 +0,0 @@
package agent
import "strings"
func emitStreamText(onEvent func(event, data string), text string) {
if onEvent == nil {
return
}
for _, chunk := range splitStreamText(text) {
onEvent(StreamEventDelta, chunk)
}
}
func splitStreamText(text string) []string {
text = strings.TrimSpace(text)
if text == "" {
return nil
}
lines := strings.Split(text, "\n")
chunks := make([]string, 0, len(lines)*2)
for _, line := range lines {
line = strings.TrimSpace(line)
if line == "" {
continue
}
start := 0
for i, r := range line {
switch r {
case '。', '', '', '.', '!', '?', ';', '', '', ':', '', ',':
part := strings.TrimSpace(line[start : i+len(string(r))])
if part != "" {
chunks = append(chunks, part)
}
start = i + len(string(r))
}
}
if start < len(line) {
part := strings.TrimSpace(line[start:])
if part != "" {
chunks = append(chunks, part)
}
}
}
if len(chunks) == 0 {
return []string{text}
}
return chunks
}

File diff suppressed because it is too large Load Diff

View File

@@ -1,538 +0,0 @@
package agent
import (
"context"
"encoding/json"
"fmt"
"log/slog"
"math"
"nofx/market"
"nofx/store"
"strings"
"sync"
"time"
)
const (
tradeAbsoluteMaxQuantity = 1_000_000.0
tradeLargeOrderNotionalUSDT = 5_000.0
tradeHardMaxOrderNotionalUSDT = 100_000.0
tradeLargeOrderEquityRatio = 0.25
tradeHardMaxOrderEquityRatio = 1.00
tradeLargeOrderConfirmCommandZH = "确认大额 %s"
tradeLargeOrderConfirmCommandEN = "confirm large %s"
)
type tradeSelectedTrader interface {
GetStrategyConfig() *store.StrategyConfig
GetAccountInfo() (map[string]interface{}, error)
}
type tradeUnderlyingTrader interface {
OpenLong(symbol string, quantity float64, leverage int) (map[string]interface{}, error)
OpenShort(symbol string, quantity float64, leverage int) (map[string]interface{}, error)
CloseLong(symbol string, quantity float64) (map[string]interface{}, error)
CloseShort(symbol string, quantity float64) (map[string]interface{}, error)
GetMarketPrice(symbol string) (float64, error)
}
// TradeAction represents a parsed trade intent from the LLM or user.
type TradeAction struct {
ID string `json:"id"`
Action string `json:"action"` // "open_long", "open_short", "close_long", "close_short"
Symbol string `json:"symbol"` // e.g. "BTCUSDT"
Quantity float64 `json:"quantity"` // amount
Leverage int `json:"leverage"` // leverage multiplier
TraderID string `json:"trader_id"` // which trader to use
Status string `json:"status"` // "pending", "confirmed", "executed", "failed", "expired"
CreatedAt int64 `json:"created_at"`
EstimatedPrice float64 `json:"estimated_price,omitempty"`
EstimatedNotional float64 `json:"estimated_notional,omitempty"`
RequiresLargeOrderConfirmation bool `json:"requires_large_order_confirmation,omitempty"`
Error string `json:"error,omitempty"`
}
// pendingTrades stores pending trade confirmations.
type pendingTrades struct {
mu sync.RWMutex
trades map[string]*TradeAction // id -> trade
}
func newPendingTrades() *pendingTrades {
return &pendingTrades{trades: make(map[string]*TradeAction)}
}
func (p *pendingTrades) Add(t *TradeAction) {
p.mu.Lock()
defer p.mu.Unlock()
p.trades[t.ID] = t
}
func (p *pendingTrades) Get(id string) *TradeAction {
p.mu.RLock()
defer p.mu.RUnlock()
return p.trades[id]
}
func (p *pendingTrades) Remove(id string) {
p.mu.Lock()
defer p.mu.Unlock()
delete(p.trades, id)
}
// CleanExpired removes trades older than 5 minutes.
func (p *pendingTrades) CleanExpired() {
p.mu.Lock()
defer p.mu.Unlock()
cutoff := time.Now().Add(-5 * time.Minute).Unix()
for id, t := range p.trades {
if t.CreatedAt < cutoff {
delete(p.trades, id)
}
}
}
// parseTradeCommand parses natural language trade commands.
// Returns nil if the message is not a trade command.
func parseTradeCommand(text string) *TradeAction {
upper := strings.ToUpper(strings.TrimSpace(text))
// Pattern: "做多 BTC 0.01" / "做空 ETH 0.1" / "long BTC 0.01" / "short ETH 0.1"
// Also: "平多 BTC" / "平空 ETH" / "close long BTC" / "close short ETH"
var action, symbol string
var quantity float64
var leverage int
words := strings.Fields(upper)
if len(words) < 2 {
return nil
}
switch words[0] {
case "做多", "LONG", "BUY":
action = "open_long"
case "做空", "SHORT", "SELL":
action = "open_short"
case "平多":
action = "close_long"
case "平空":
action = "close_short"
case "CLOSE":
if len(words) >= 3 {
switch words[1] {
case "LONG":
action = "close_long"
words = append(words[:1], words[2:]...) // remove "LONG"
case "SHORT":
action = "close_short"
words = append(words[:1], words[2:]...) // remove "SHORT"
}
}
if action == "" {
return nil
}
default:
return nil
}
// Parse symbol
if len(words) < 2 {
return nil
}
symbol = words[1]
// Only append USDT for crypto symbols, not stock tickers
if !isStockSymbol(symbol) && !strings.HasSuffix(symbol, "USDT") {
symbol += "USDT"
}
// Parse quantity (optional)
if len(words) >= 3 {
fmt.Sscanf(words[2], "%f", &quantity)
}
// Parse leverage (optional, "x10" or "10x")
if len(words) >= 4 {
lev := strings.TrimSuffix(strings.TrimPrefix(words[3], "X"), "X")
fmt.Sscanf(lev, "%d", &leverage)
}
if action == "" || symbol == "" {
return nil
}
return &TradeAction{
ID: fmt.Sprintf("trade_%d", time.Now().UnixNano()),
Action: action,
Symbol: symbol,
Quantity: quantity,
Leverage: leverage,
Status: "pending",
CreatedAt: time.Now().Unix(),
}
}
// executeTrade performs the actual trade execution via TraderManager.
func (a *Agent) executeTrade(ctx context.Context, trade *TradeAction) error {
if a.traderManager == nil {
return fmt.Errorf("no trader manager available")
}
wantStock, selectedTrader, underlyingTrader, err := a.resolveTradeExecutionContext(trade)
if err != nil {
return err
}
if err := validateTradeAction(trade, wantStock, selectedTrader, underlyingTrader); err != nil {
return err
}
switch trade.Action {
case "open_long":
if trade.Quantity <= 0 {
return fmt.Errorf("quantity must be > 0")
}
_, err := underlyingTrader.OpenLong(trade.Symbol, trade.Quantity, trade.Leverage)
return err
case "open_short":
if trade.Quantity <= 0 {
return fmt.Errorf("quantity must be > 0")
}
_, err := underlyingTrader.OpenShort(trade.Symbol, trade.Quantity, trade.Leverage)
return err
case "close_long":
_, err := underlyingTrader.CloseLong(trade.Symbol, trade.Quantity)
return err
case "close_short":
_, err := underlyingTrader.CloseShort(trade.Symbol, trade.Quantity)
return err
default:
return fmt.Errorf("unknown action: %s", trade.Action)
}
}
func (a *Agent) resolveTradeExecutionContext(trade *TradeAction) (bool, tradeSelectedTrader, tradeUnderlyingTrader, error) {
if a.traderManager == nil {
return false, nil, nil, fmt.Errorf("no trader manager available")
}
traders := a.traderManager.GetAllTraders()
if len(traders) == 0 {
return false, nil, nil, fmt.Errorf("no traders configured")
}
wantStock := isStockSymbol(trade.Symbol)
for _, t := range traders {
s := t.GetStatus()
running, _ := s["is_running"].(bool)
if !running {
continue
}
ut := t.GetUnderlyingTrader()
if ut == nil {
continue
}
exchange := t.GetExchange()
isAlpaca := exchange == "alpaca"
if wantStock && !isAlpaca {
continue
}
if !wantStock && isAlpaca {
continue
}
return wantStock, t, ut, nil
}
if wantStock {
return true, nil, nil, fmt.Errorf("no running stock trader (Alpaca) found — configure one to trade stocks")
}
return false, nil, nil, fmt.Errorf("no running trader supports trade execution")
}
func validateTradeAction(
trade *TradeAction,
wantStock bool,
selectedTrader tradeSelectedTrader,
underlyingTrader tradeUnderlyingTrader,
) error {
if trade == nil {
return fmt.Errorf("trade is required")
}
if math.IsNaN(trade.Quantity) || math.IsInf(trade.Quantity, 0) {
return fmt.Errorf("quantity must be a finite number")
}
if !strings.HasPrefix(trade.Action, "open_") {
return nil
}
if trade.Quantity <= 0 {
return fmt.Errorf("quantity must be > 0")
}
if trade.Quantity > tradeAbsoluteMaxQuantity {
return fmt.Errorf("quantity %.4f exceeds hard sanity cap %.0f", trade.Quantity, tradeAbsoluteMaxQuantity)
}
price, err := underlyingTrader.GetMarketPrice(trade.Symbol)
if err != nil {
return fmt.Errorf("failed to fetch market price for %s: %w", trade.Symbol, err)
}
if price <= 0 {
return fmt.Errorf("invalid market price for %s", trade.Symbol)
}
positionValue := trade.Quantity * price
trade.EstimatedPrice = price
trade.EstimatedNotional = positionValue
if positionValue > tradeHardMaxOrderNotionalUSDT {
return fmt.Errorf("position value %.2f exceeds hard safety cap %.2f USDT", positionValue, tradeHardMaxOrderNotionalUSDT)
}
var equity float64
if selectedTrader != nil {
accountInfo, err := selectedTrader.GetAccountInfo()
if err != nil {
return fmt.Errorf("failed to load trader account info: %w", err)
}
equity = toFloat(accountInfo["total_equity"])
if equity <= 0 {
equity = toFloat(accountInfo["totalEquity"])
}
if equity <= 0 {
return fmt.Errorf("invalid trader equity for risk validation")
}
if positionValue > equity*tradeHardMaxOrderEquityRatio {
return fmt.Errorf(
"position value %.2f USDT exceeds hard safety cap %.2f USDT (equity %.2f x %.2f)",
positionValue,
equity*tradeHardMaxOrderEquityRatio,
equity,
tradeHardMaxOrderEquityRatio,
)
}
if positionValue >= equity*tradeLargeOrderEquityRatio {
trade.RequiresLargeOrderConfirmation = true
}
}
if positionValue >= tradeLargeOrderNotionalUSDT {
trade.RequiresLargeOrderConfirmation = true
}
if wantStock {
if trade.Leverage < 0 {
return fmt.Errorf("leverage must be >= 0")
}
return nil
}
cfg := store.GetDefaultStrategyConfig("zh")
if selectedTrader != nil && selectedTrader.GetStrategyConfig() != nil {
cfg = *selectedTrader.GetStrategyConfig()
}
riskControl := cfg.RiskControl
maxLeverage := riskControl.AltcoinMaxLeverage
maxPositionValueRatio := riskControl.AltcoinMaxPositionValueRatio
if isMajorTradeSymbol(trade.Symbol) {
maxLeverage = riskControl.BTCETHMaxLeverage
maxPositionValueRatio = riskControl.BTCETHMaxPositionValueRatio
}
if maxLeverage <= 0 {
maxLeverage = 5
}
if trade.Leverage <= 0 {
return fmt.Errorf("leverage must be > 0")
}
if trade.Leverage > maxLeverage {
return fmt.Errorf("leverage exceeds configured limit (%dx > %dx)", trade.Leverage, maxLeverage)
}
minPositionSize := riskControl.MinPositionSize
if minPositionSize <= 0 {
minPositionSize = 12
}
if positionValue < minPositionSize {
return fmt.Errorf("position value %.2f USDT is below configured minimum %.2f USDT", positionValue, minPositionSize)
}
if maxPositionValueRatio <= 0 {
if isBTCETHSymbol(trade.Symbol) {
maxPositionValueRatio = 5.0
} else {
maxPositionValueRatio = 1.0
}
}
maxPositionValue := equity * maxPositionValueRatio
if positionValue > maxPositionValue {
return fmt.Errorf(
"position value %.2f USDT exceeds configured limit %.2f USDT (equity %.2f x %.2f)",
positionValue,
maxPositionValue,
equity,
maxPositionValueRatio,
)
}
return nil
}
func isBTCETHSymbol(symbol string) bool {
symbol = strings.ToUpper(strings.TrimSpace(symbol))
return strings.HasPrefix(symbol, "BTC") || strings.HasPrefix(symbol, "ETH")
}
// isMajorTradeSymbol mirrors trader/auto_trader_risk.isMajorAsset for the
// chat-execute path. BTC/ETH crypto perps and Hyperliquid XYZ assets
// (US stocks, commodities, forex) get the higher BTC/ETH risk tier — their
// per-position caps should not be clamped to the 1x altcoin tier.
func isMajorTradeSymbol(symbol string) bool {
if isBTCETHSymbol(symbol) {
return true
}
return market.IsXyzDexAsset(symbol)
}
// formatTradeConfirmation creates a confirmation message for a pending trade.
func formatTradeConfirmation(trade *TradeAction, lang string) string {
actionNames := map[string]string{
"open_long": "做多 (Long)",
"open_short": "做空 (Short)",
"close_long": "平多 (Close Long)",
"close_short": "平空 (Close Short)",
}
symbol := trade.Symbol
if strings.HasSuffix(symbol, "USDT") {
symbol = strings.TrimSuffix(symbol, "USDT")
}
actionName := actionNames[trade.Action]
if actionName == "" {
actionName = trade.Action
}
if lang == "zh" {
msg := fmt.Sprintf("⚠️ **交易确认**\n\n"+
"操作: %s\n"+
"品种: %s\n", actionName, symbol)
if trade.Quantity > 0 {
msg += fmt.Sprintf("数量: %.4f\n", trade.Quantity)
}
if trade.Leverage > 0 {
msg += fmt.Sprintf("杠杆: %dx\n", trade.Leverage)
}
if trade.EstimatedNotional > 0 {
msg += fmt.Sprintf("估算仓位价值: %.2f USDT\n", trade.EstimatedNotional)
}
if trade.RequiresLargeOrderConfirmation {
msg += fmt.Sprintf("\n⚠ 该订单已触发大额风控,请发送 `"+tradeLargeOrderConfirmCommandZH+"` 执行交易,或忽略取消。", trade.ID)
return msg
}
msg += fmt.Sprintf("\n发送 `确认 %s` 执行交易,或忽略取消。", trade.ID)
return msg
}
msg := fmt.Sprintf("⚠️ **Trade Confirmation**\n\n"+
"Action: %s\n"+
"Symbol: %s\n", actionName, symbol)
if trade.Quantity > 0 {
msg += fmt.Sprintf("Quantity: %.4f\n", trade.Quantity)
}
if trade.Leverage > 0 {
msg += fmt.Sprintf("Leverage: %dx\n", trade.Leverage)
}
if trade.EstimatedNotional > 0 {
msg += fmt.Sprintf("Estimated notional: %.2f USDT\n", trade.EstimatedNotional)
}
if trade.RequiresLargeOrderConfirmation {
msg += fmt.Sprintf("\n⚠ This order triggered high-risk protection. Send `"+tradeLargeOrderConfirmCommandEN+"` to execute, or ignore to cancel.", trade.ID)
return msg
}
msg += fmt.Sprintf("\nSend `confirm %s` to execute, or ignore to cancel.", trade.ID)
return msg
}
// handleTradeConfirmation processes a trade confirmation message.
func (a *Agent) handleTradeConfirmation(ctx context.Context, userID int64, text, lang string) (string, bool) {
upper := strings.ToUpper(strings.TrimSpace(text))
var tradeID string
largeConfirm := false
if strings.HasPrefix(upper, "确认大额 ") || strings.HasPrefix(upper, "CONFIRM LARGE ") {
largeConfirm = true
parts := strings.Fields(text)
if len(parts) >= 2 {
tradeID = parts[len(parts)-1]
}
} else if strings.HasPrefix(upper, "确认 ") || strings.HasPrefix(upper, "CONFIRM ") {
parts := strings.Fields(text)
if len(parts) >= 2 {
tradeID = parts[1]
}
}
if tradeID == "" {
return "", false
}
if a.pending == nil {
return "", false
}
trade := a.pending.Get(tradeID)
if trade == nil {
if lang == "zh" {
return "❌ 交易已过期或不存在。", true
}
return "❌ Trade expired or not found.", true
}
if trade.RequiresLargeOrderConfirmation && !largeConfirm {
if lang == "zh" {
return fmt.Sprintf("⚠️ 这是一笔大额订单,请发送 `"+tradeLargeOrderConfirmCommandZH+"` 继续执行。", trade.ID), true
}
return fmt.Sprintf("⚠️ This is a high-risk order. Send `"+tradeLargeOrderConfirmCommandEN+"` to continue.", trade.ID), true
}
a.pending.Remove(tradeID)
trade.Status = "confirmed"
a.logger.Info("executing trade",
slog.String("id", trade.ID),
slog.String("action", trade.Action),
slog.String("symbol", trade.Symbol),
slog.Float64("quantity", trade.Quantity),
)
err := a.executeTrade(ctx, trade)
if err != nil {
trade.Status = "failed"
trade.Error = err.Error()
if lang == "zh" {
return fmt.Sprintf("❌ 交易执行失败: %s", err.Error()), true
}
return fmt.Sprintf("❌ Trade execution failed: %s", err.Error()), true
}
trade.Status = "executed"
symbol := trade.Symbol
if strings.HasSuffix(symbol, "USDT") {
symbol = strings.TrimSuffix(symbol, "USDT")
}
actionEmoji := "📈"
if strings.Contains(trade.Action, "short") {
actionEmoji = "📉"
}
if strings.Contains(trade.Action, "close") {
actionEmoji = "✅"
}
qtyStr := ""
if trade.Quantity > 0 {
qtyStr = fmt.Sprintf(" %.4f", trade.Quantity)
}
if lang == "zh" {
return fmt.Sprintf("%s 交易已执行!\n%s %s%s", actionEmoji, trade.Action, symbol, qtyStr), true
}
return fmt.Sprintf("%s Trade executed!\n%s %s%s", actionEmoji, trade.Action, symbol, qtyStr), true
}
// marshals trade action to JSON for embedding in responses
func marshalTradeAction(trade *TradeAction) string {
b, _ := json.Marshal(trade)
return string(b)
}

File diff suppressed because it is too large Load Diff

View File

@@ -1,251 +0,0 @@
package agent
import (
"context"
"path/filepath"
"strings"
"testing"
"nofx/store"
)
func TestParseUnifiedTurnDecisionNormalizesContextPolicy(t *testing.T) {
raw := `{
"topic_intent": "start_new",
"business_action": "new_skill",
"target_skill": "strategy_management:update_config",
"context_mode": "fresh_context",
"extracted_data": {"name": "BTC趋势"},
"confidence": 0.82
}`
decision, err := parseUnifiedTurnDecision(raw)
if err != nil {
t.Fatalf("parse unified decision: %v", err)
}
if decision.TopicIntent != "start_new" {
t.Fatalf("expected normalized topic intent, got %q", decision.TopicIntent)
}
if decision.BusinessAction != "new_skill" {
t.Fatalf("expected business action new_skill, got %q", decision.BusinessAction)
}
if decision.ContextMode != "fresh_context" {
t.Fatalf("expected fresh_context, got %q", decision.ContextMode)
}
if !decision.reliable() {
t.Fatalf("expected decision to be reliable: %+v", decision)
}
}
func TestParseUnifiedTurnDecisionAcceptsSkillTaskList(t *testing.T) {
raw := `{
"topic_intent": "start_new",
"business_action": "skill_tasks",
"context_mode": "fresh_context",
"tasks": [
{"id":"task_1","skill":"strategy_management","action":"create","request":"创建高频交易策略","depends_on":[]},
{"id":"task_2","skill":"trader_management","action":"configure_strategy","request":"绑定到交易员","depends_on":["task_1"]}
],
"confidence": 0.86
}`
decision, err := parseUnifiedTurnDecision(raw)
if err != nil {
t.Fatalf("parse unified decision: %v", err)
}
if decision.BusinessAction != "skill_tasks" {
t.Fatalf("expected skill_tasks, got %q", decision.BusinessAction)
}
if len(decision.Tasks) != 2 {
t.Fatalf("expected 2 tasks, got %+v", decision.Tasks)
}
if decision.Tasks[0].Skill != "strategy_management" || decision.Tasks[0].Action != "create" {
t.Fatalf("unexpected first task: %+v", decision.Tasks[0])
}
if !decision.reliable() {
t.Fatalf("expected task-list decision to be reliable: %+v", decision)
}
}
func TestUnifiedTurnDecisionNewSkillCanUseSingleTask(t *testing.T) {
decision := normalizeUnifiedTurnDecision(unifiedTurnDecision{
TopicIntent: "start_new",
BusinessAction: "new_skill",
ContextMode: "fresh_context",
Tasks: []WorkflowTask{{
Skill: "strategy_management",
Action: "create",
Request: "创建高频交易策略",
}},
Confidence: 0.9,
})
if !decision.reliable() {
t.Fatalf("expected new_skill with task list to be reliable: %+v", decision)
}
}
func TestUnifiedTurnDecisionRejectsLowConfidenceAndIncompleteDirectAnswer(t *testing.T) {
lowConfidence := unifiedTurnDecision{
TopicIntent: "start_new",
BusinessAction: "planned_agent",
ContextMode: "fresh_context",
Confidence: 0.2,
}
lowConfidence = normalizeUnifiedTurnDecision(lowConfidence)
if lowConfidence.reliable() {
t.Fatalf("expected low confidence decision to fall back")
}
emptyDirect := unifiedTurnDecision{
TopicIntent: "instant_reply",
BusinessAction: "direct_answer",
ContextMode: "use_current",
Confidence: 0.9,
}
emptyDirect = normalizeUnifiedTurnDecision(emptyDirect)
if emptyDirect.reliable() {
t.Fatalf("expected direct_answer without reply_to_user to fall back")
}
}
func TestExecuteUnifiedTurnDecisionDirectAnswerRecordsHistory(t *testing.T) {
a := New(nil, nil, DefaultConfig(), nil)
userID := int64(101)
decision := normalizeUnifiedTurnDecision(unifiedTurnDecision{
TopicIntent: "instant_reply",
BusinessAction: "direct_answer",
ContextMode: "use_current",
ReplyToUser: "你好,我在。",
Confidence: 0.9,
})
answer, handled, err := a.executeUnifiedTurnDecision(context.Background(), "default", userID, "zh", "你好", decision, nil)
if err != nil {
t.Fatalf("execute unified decision: %v", err)
}
if !handled {
t.Fatal("expected direct answer to be handled")
}
if answer != "你好,我在。" {
t.Fatalf("unexpected answer: %q", answer)
}
history := a.history.Get(userID)
if len(history) != 2 {
t.Fatalf("expected user and assistant history entries, got %d", len(history))
}
if history[0].Role != "user" || history[0].Content != "你好" {
t.Fatalf("unexpected user history entry: %+v", history[0])
}
if history[1].Role != "assistant" || history[1].Content != "你好,我在。" {
t.Fatalf("unexpected assistant history entry: %+v", history[1])
}
}
func TestExecuteUnifiedTurnDecisionContinueActiveDoesNotHandOffToPlanner(t *testing.T) {
dbPath := filepath.Join(t.TempDir(), "continue-active-router.db")
st, err := store.New(dbPath)
if err != nil {
t.Fatalf("create store: %v", err)
}
a := New(nil, st, DefaultConfig(), nil)
userID := int64(102)
session := newActiveSkillSession(userID, "strategy_management", "create")
session.Goal = "创建网格策略"
session.CollectedFields["name"] = "我的网格策略"
session.CollectedFields["strategy_type"] = "grid_trading"
setActiveSessionPendingHint(&session, "现在还需要确认网格交易对、网格数量、总投入、杠杆和价格区间。")
a.saveActiveSkillSession(session)
decision := normalizeUnifiedTurnDecision(unifiedTurnDecision{
TopicIntent: "continue_active",
BusinessAction: "planned_agent",
ContextMode: "use_current",
Confidence: 0.9,
})
answer, handled, err := a.executeUnifiedTurnDecision(context.Background(), "default", userID, "zh", "那你帮我创吧", decision, nil)
if err != nil {
t.Fatalf("execute unified decision: %v", err)
}
if !handled {
t.Fatal("expected active session continuation to be handled")
}
if !strings.Contains(answer, "还缺") || !strings.Contains(answer, "交易对") || strings.Contains(answer, "交易机器人") || strings.Contains(answer, "AI模型和交易所") {
t.Fatalf("expected strategy session to continue without planner/trader handoff, got: %s", answer)
}
if _, ok := a.getActiveSkillSession(userID); !ok {
t.Fatalf("expected strategy active session to remain pending")
}
}
func TestGuardUnexecutedActiveTaskCompletionBlocksCreationClaim(t *testing.T) {
session := ActiveSkillSession{
SkillName: "strategy_management",
ActionName: "create",
}
reply, blocked := guardUnexecutedActiveTaskCompletion("zh", session, "已经创建好了。策略现在就在你的策略列表里。")
if !blocked {
t.Fatalf("expected unexecuted active create completion claim to be blocked")
}
if !strings.Contains(reply, "还没有真正创建") {
t.Fatalf("expected honest not-created reply, got: %s", reply)
}
_, blocked = guardUnexecutedActiveTaskCompletion("zh", session, "我建议先用 BTCUSDT 做新手网格策略。")
if blocked {
t.Fatalf("non-completion proposal should not be blocked")
}
}
func TestGuardUnsupportedAsyncPromiseBlocksFakeDiagnosisProgress(t *testing.T) {
reply, blocked := guardUnsupportedAsyncPromise("zh", "诊断还在进行中,请再稍等一下。我马上分析完“小小”的历史交易记录,找到亏损原因后会立刻告诉您。")
if !blocked {
t.Fatal("expected fake async diagnosis progress to be blocked")
}
for _, want := range []string{"没有后台异步任务", "当前回复"} {
if !strings.Contains(reply, want) {
t.Fatalf("expected guarded reply to contain %q, got: %s", want, reply)
}
}
_, blocked = guardUnsupportedAsyncPromise("zh", "我需要策略名称和历史记录范围,才能开始诊断。")
if blocked {
t.Fatal("missing-info diagnosis reply should not be blocked")
}
_, blocked = guardUnsupportedAsyncPromise("zh", "好的,参数已确认,正在为您创建“餐巾纸”网格策略。")
if !blocked {
t.Fatal("expected fake async strategy create progress to be blocked")
}
}
func TestFinishTaskGuardBlocksFakeCreateProgressPromise(t *testing.T) {
reply, blocked := guardUnsupportedAsyncPromise("zh", "策略正在创建中,请稍等一会儿。创建成功后我会立刻告诉您。")
if !blocked {
t.Fatal("expected fake create progress promise to be blocked")
}
if !strings.Contains(reply, "没有后台异步任务") || !strings.Contains(reply, "实际执行") {
t.Fatalf("expected honest execution correction, got: %s", reply)
}
}
func TestBuildUnifiedTurnRouterPromptNamesContextPolicy(t *testing.T) {
a := New(nil, nil, DefaultConfig(), nil)
systemPrompt, userPrompt := a.buildUnifiedTurnRouterPrompt(42, "zh", "不是交易员,是策略")
for _, want := range []string{
"context_mode values",
"fresh_context",
"downstream modules",
"tasks format",
"skill_tasks",
"topic_intent as the primary decision",
} {
if !strings.Contains(systemPrompt, want) {
t.Fatalf("expected system prompt to contain %q", want)
}
}
if !strings.Contains(userPrompt, "不是交易员,是策略") {
t.Fatalf("expected user prompt to contain current user message")
}
}

View File

@@ -1,3 +0,0 @@
package agent
const cleanUserFacingReplyInstruction = "Your final reply must be clean and easy to understand, with no fluff, no internal jargon, and no unnecessary explanation."

View File

@@ -1,12 +0,0 @@
package agent
import "testing"
func TestCleanUserFacingReplyInstruction(t *testing.T) {
if cleanUserFacingReplyInstruction == "" {
t.Fatal("expected clean user-facing reply instruction to be defined")
}
if got, want := cleanUserFacingReplyInstruction, "Your final reply must be clean and easy to understand, with no fluff, no internal jargon, and no unnecessary explanation."; got != want {
t.Fatalf("unexpected instruction\nwant: %q\ngot: %q", want, got)
}
}

View File

@@ -1,373 +0,0 @@
package agent
import (
"context"
"encoding/json"
"errors"
"fmt"
"io"
"log/slog"
"net/http"
"nofx/safe"
"regexp"
"time"
)
type storeUserIDContextKey struct{}
type sessionPolicyContextKey struct{}
type SessionPolicy struct {
Authenticated bool
IsAdmin bool
CanExecuteTrade bool
CanViewSensitiveSecrets bool
}
// WithStoreUserID annotates an HTTP request context with the authenticated store user ID.
func WithStoreUserID(ctx context.Context, storeUserID string) context.Context {
return context.WithValue(ctx, storeUserIDContextKey{}, storeUserID)
}
func storeUserIDFromContext(ctx context.Context) string {
if v, ok := ctx.Value(storeUserIDContextKey{}).(string); ok && v != "" {
return v
}
return "default"
}
func WithSessionPolicy(ctx context.Context, policy SessionPolicy) context.Context {
return context.WithValue(ctx, sessionPolicyContextKey{}, policy)
}
func sessionPolicyFromContext(ctx context.Context) SessionPolicy {
if v, ok := ctx.Value(sessionPolicyContextKey{}).(SessionPolicy); ok {
return v
}
return SessionPolicy{}
}
// validSymbolRe matches only alphanumeric trading symbols (e.g. BTCUSDT, ETH-USD).
var validSymbolRe = regexp.MustCompile(`^[A-Za-z0-9\-_]{1,20}$`)
// validIntervalRe matches only valid kline intervals (e.g. 1m, 5m, 1h, 4h, 1d, 1w).
var validIntervalRe = regexp.MustCompile(`^[0-9]{1,2}[mhHdDwWM]$`)
// binanceClient is a shared HTTP client for proxying Binance API requests.
// Reused across requests to benefit from connection pooling.
var binanceClient = &http.Client{
Timeout: 10 * time.Second,
Transport: &http.Transport{
MaxIdleConns: 20,
MaxIdleConnsPerHost: 10,
IdleConnTimeout: 90 * time.Second,
},
}
// WebHandler provides HTTP endpoints for the NOFXi agent.
type WebHandler struct {
agent *Agent
logger *slog.Logger
}
func NewWebHandler(agent *Agent, logger *slog.Logger) *WebHandler {
return &WebHandler{agent: agent, logger: logger}
}
// HandleHealth handles GET /api/agent/health.
func (w *WebHandler) HandleHealth(rw http.ResponseWriter, r *http.Request) {
writeJSON(rw, 200, map[string]string{"status": "ok", "agent": "NOFXi", "time": time.Now().Format(time.RFC3339)})
}
// HandleChat handles POST /api/agent/chat.
func (w *WebHandler) HandleChat(rw http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodPost {
http.Error(rw, "method not allowed", 405)
return
}
var req struct {
Message string `json:"message"`
UserID int64 `json:"user_id"`
UserKey string `json:"user_key"`
Lang string `json:"lang"`
}
// Limit request body to 64KB to prevent abuse
if err := json.NewDecoder(io.LimitReader(r.Body, 64*1024)).Decode(&req); err != nil {
writeJSON(rw, 400, map[string]string{"error": "invalid request"})
return
}
if req.Message == "" {
writeJSON(rw, 400, map[string]string{"error": "message required"})
return
}
if req.UserID == 0 {
req.UserID = SessionUserIDFromKey(storeUserIDFromContext(r.Context()))
}
msg := req.Message
if req.Lang != "" {
msg = "[lang:" + req.Lang + "] " + msg
}
ctx, cancel := context.WithTimeout(r.Context(), 55*time.Second)
defer cancel()
resp, err := w.agent.HandleMessageForStoreUser(ctx, storeUserIDFromContext(r.Context()), req.UserID, msg)
if err != nil {
w.logger.Error("agent HandleMessage failed", "error", err, "user_id", req.UserID)
writeJSON(rw, 500, map[string]string{"error": "I ran into a problem while handling that message. Please try again."})
return
}
writeJSON(rw, 200, map[string]string{"response": resp})
}
// HandleChatStream handles POST /api/agent/chat/stream — SSE streaming chat.
// Sends server-sent events with types including planning, plan, step_start,
// step_complete, replan, tool, delta, done, error.
func (w *WebHandler) HandleChatStream(rw http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodPost {
http.Error(rw, "method not allowed", 405)
return
}
var req struct {
Message string `json:"message"`
UserID int64 `json:"user_id"`
UserKey string `json:"user_key"`
Lang string `json:"lang"`
}
if err := json.NewDecoder(io.LimitReader(r.Body, 64*1024)).Decode(&req); err != nil {
writeJSON(rw, 400, map[string]string{"error": "invalid request"})
return
}
if req.Message == "" {
writeJSON(rw, 400, map[string]string{"error": "message required"})
return
}
if req.UserID == 0 {
req.UserID = SessionUserIDFromKey(storeUserIDFromContext(r.Context()))
}
msg := req.Message
if req.Lang != "" {
msg = "[lang:" + req.Lang + "] " + msg
}
// Set SSE headers
rw.Header().Set("Content-Type", "text/event-stream")
rw.Header().Set("Cache-Control", "no-cache")
rw.Header().Set("Connection", "keep-alive")
rw.Header().Set("X-Accel-Buffering", "no") // Disable nginx buffering
rw.WriteHeader(200)
flusher, ok := rw.(http.Flusher)
if !ok {
writeSSE(rw, nil, "error", "streaming not supported")
return
}
ctx, cancel := context.WithTimeout(r.Context(), 120*time.Second)
defer cancel()
resp, err := w.agent.HandleMessageStreamForStoreUser(ctx, storeUserIDFromContext(r.Context()), req.UserID, msg, func(event, data string) {
if ctx.Err() != nil {
return
}
writeSSE(rw, flusher, event, data)
})
if err != nil {
if errors.Is(err, context.Canceled) || errors.Is(err, context.DeadlineExceeded) || ctx.Err() != nil {
w.logger.Info("agent stream cancelled", "user_id", req.UserID, "error", err)
return
}
w.logger.Error("agent HandleMessageStream failed", "error", err, "user_id", req.UserID)
writeSSE(rw, flusher, "error", "I ran into a problem while handling that message. Please try again.")
return
}
if ctx.Err() != nil {
return
}
// Send final done event with complete response
writeSSE(rw, flusher, "done", resp)
}
// writeSSE writes a single SSE event.
func writeSSE(w http.ResponseWriter, flusher http.Flusher, event, data string) {
fmt.Fprintf(w, "event: %s\ndata: %s\n\n", event, sseEscape(data))
if flusher != nil {
flusher.Flush()
}
}
// sseEscape escapes newlines in SSE data (each line needs a "data: " prefix).
func sseEscape(s string) string {
// SSE spec: multi-line data uses multiple "data:" lines
// But we use JSON encoding to avoid this complexity
b, _ := json.Marshal(s)
return string(b)
}
// HandleKlines proxies kline data from Binance.
func (w *WebHandler) HandleKlines(rw http.ResponseWriter, r *http.Request) {
symbol := r.URL.Query().Get("symbol")
if symbol == "" {
symbol = "BTCUSDT"
}
interval := r.URL.Query().Get("interval")
if interval == "" {
interval = "1h"
}
if !validSymbolRe.MatchString(symbol) {
writeJSON(rw, 400, map[string]string{"error": "invalid symbol"})
return
}
if !validIntervalRe.MatchString(interval) {
writeJSON(rw, 400, map[string]string{"error": "invalid interval"})
return
}
proxyBinance(rw, r.Context(), fmt.Sprintf("https://fapi.binance.com/fapi/v1/klines?symbol=%s&interval=%s&limit=300", symbol, interval))
}
// HandleTicker proxies ticker data from Binance.
func (w *WebHandler) HandleTicker(rw http.ResponseWriter, r *http.Request) {
symbol := r.URL.Query().Get("symbol")
if symbol == "" {
symbol = "BTCUSDT"
}
if !validSymbolRe.MatchString(symbol) {
writeJSON(rw, 400, map[string]string{"error": "invalid symbol"})
return
}
proxyBinance(rw, r.Context(), fmt.Sprintf("https://fapi.binance.com/fapi/v1/ticker/24hr?symbol=%s", symbol))
}
// HandleTickers handles GET /api/agent/tickers?symbols=BTCUSDT,ETHUSDT,SOLUSDT
// Batch endpoint: fetches multiple tickers concurrently, returns array.
func (w *WebHandler) HandleTickers(rw http.ResponseWriter, r *http.Request) {
symbolsParam := r.URL.Query().Get("symbols")
if symbolsParam == "" {
symbolsParam = "BTCUSDT,ETHUSDT,SOLUSDT"
}
// Validate symbols
var symbols []string
for _, s := range splitComma(symbolsParam) {
if validSymbolRe.MatchString(s) {
symbols = append(symbols, s)
}
}
if len(symbols) == 0 {
writeJSON(rw, 400, map[string]string{"error": "no valid symbols"})
return
}
if len(symbols) > 20 {
writeJSON(rw, 400, map[string]string{"error": "max 20 symbols"})
return
}
// Fetch all tickers concurrently with context propagation
ctx, cancel := context.WithTimeout(r.Context(), 10*time.Second)
defer cancel()
type result struct {
idx int
data json.RawMessage
}
results := make(chan result, len(symbols))
for i, sym := range symbols {
idx, s := i, sym
safe.GoNamed("ticker-fetch-"+s, func() {
req, err := http.NewRequestWithContext(ctx, "GET",
fmt.Sprintf("https://fapi.binance.com/fapi/v1/ticker/24hr?symbol=%s", s), nil)
if err != nil {
results <- result{idx: idx}
return
}
resp, err := binanceClient.Do(req)
if err != nil {
results <- result{idx: idx}
return
}
defer resp.Body.Close()
if resp.StatusCode != 200 {
results <- result{idx: idx}
return
}
body, err := safe.ReadAllLimited(resp.Body, 16*1024)
if err != nil {
results <- result{idx: idx}
return
}
results <- result{idx: idx, data: body}
})
}
// Collect results in order
ordered := make([]json.RawMessage, len(symbols))
for range symbols {
r := <-results
if r.data != nil {
ordered[r.idx] = r.data
}
}
// Filter out nil entries and write response
out := make([]json.RawMessage, 0, len(ordered))
for _, d := range ordered {
if d != nil {
out = append(out, d)
}
}
rw.Header().Set("Content-Type", "application/json")
json.NewEncoder(rw).Encode(out)
}
// commaRe is pre-compiled for splitComma — avoids recompiling on every call.
var commaRe = regexp.MustCompile(`\s*,\s*`)
// splitComma splits a comma-separated string, trims whitespace, skips empty.
func splitComma(s string) []string {
var parts []string
for _, p := range commaRe.Split(s, -1) {
if p != "" {
parts = append(parts, p)
}
}
return parts
}
func proxyBinance(rw http.ResponseWriter, ctx context.Context, url string) {
req, err := http.NewRequestWithContext(ctx, "GET", url, nil)
if err != nil {
writeJSON(rw, 500, map[string]string{"error": "failed to create request"})
return
}
resp, err := binanceClient.Do(req)
if err != nil {
// Distinguish client cancellation from upstream failures
if ctx.Err() != nil {
return // Client disconnected, no point writing response
}
writeJSON(rw, 502, map[string]string{"error": "upstream request failed"})
return
}
defer resp.Body.Close()
// Forward upstream error status codes instead of silently proxying bad data
if resp.StatusCode != http.StatusOK {
writeJSON(rw, 502, map[string]string{"error": fmt.Sprintf("upstream returned status %d", resp.StatusCode)})
return
}
rw.Header().Set("Content-Type", "application/json")
// CORS is handled by the gin middleware — no need to set it here
// Limit response body to 2MB to prevent memory exhaustion
io.Copy(rw, io.LimitReader(resp.Body, 2*1024*1024))
}
func writeJSON(w http.ResponseWriter, status int, v interface{}) {
w.Header().Set("Content-Type", "application/json")
// CORS is handled by the gin middleware — no need to set it here
w.WriteHeader(status)
json.NewEncoder(w).Encode(v)
}

View File

@@ -1,959 +0,0 @@
package agent
import (
"context"
"encoding/json"
"fmt"
"strings"
"time"
"nofx/mcp"
)
const (
workflowTaskPending = "pending"
workflowTaskRunning = "running"
workflowTaskCompleted = "completed"
workflowTaskFailed = "failed"
)
type WorkflowTask struct {
ID string `json:"id,omitempty"`
Skill string `json:"skill,omitempty"`
Action string `json:"action,omitempty"`
Request string `json:"request,omitempty"`
DependsOn []string `json:"depends_on,omitempty"`
Status string `json:"status,omitempty"`
Error string `json:"error,omitempty"`
}
type WorkflowSession struct {
UserID int64 `json:"user_id"`
OriginalRequest string `json:"original_request,omitempty"`
Tasks []WorkflowTask `json:"tasks,omitempty"`
UpdatedAt string `json:"updated_at,omitempty"`
}
type workflowDecomposition struct {
Tasks []WorkflowTask `json:"tasks"`
}
func workflowSessionConfigKey(userID int64) string {
return fmt.Sprintf("agent_workflow_session_%d", userID)
}
func normalizeWorkflowSession(session WorkflowSession) WorkflowSession {
session.OriginalRequest = strings.TrimSpace(session.OriginalRequest)
normalized := make([]WorkflowTask, 0, len(session.Tasks))
for i, task := range session.Tasks {
task.ID = strings.TrimSpace(task.ID)
if task.ID == "" {
task.ID = fmt.Sprintf("task_%d", i+1)
}
task.Skill = strings.TrimSpace(task.Skill)
task.Action = normalizeAtomicSkillAction(task.Skill, task.Action)
task.Request = strings.TrimSpace(task.Request)
task.DependsOn = cleanStringList(task.DependsOn)
task.Status = strings.TrimSpace(task.Status)
if task.Status == "" {
task.Status = workflowTaskPending
}
task.Error = strings.TrimSpace(task.Error)
if task.Skill == "" || task.Action == "" || task.Request == "" {
continue
}
normalized = append(normalized, task)
}
session.Tasks = normalized
if len(session.Tasks) == 0 {
return WorkflowSession{}
}
if session.UpdatedAt == "" {
session.UpdatedAt = time.Now().UTC().Format(time.RFC3339)
}
return session
}
func (a *Agent) getWorkflowSession(userID int64) WorkflowSession {
if a.store == nil {
return WorkflowSession{}
}
raw, err := a.store.GetSystemConfig(workflowSessionConfigKey(userID))
if err != nil || strings.TrimSpace(raw) == "" {
return WorkflowSession{}
}
var session WorkflowSession
if err := json.Unmarshal([]byte(raw), &session); err != nil {
return WorkflowSession{}
}
return normalizeWorkflowSession(session)
}
func (a *Agent) saveWorkflowSession(userID int64, session WorkflowSession) {
if a.store == nil {
return
}
session = normalizeWorkflowSession(session)
if len(session.Tasks) == 0 {
_ = a.store.SetSystemConfig(workflowSessionConfigKey(userID), "")
return
}
session.UserID = userID
session.UpdatedAt = time.Now().UTC().Format(time.RFC3339)
data, err := json.Marshal(session)
if err != nil {
return
}
_ = a.store.SetSystemConfig(workflowSessionConfigKey(userID), string(data))
}
func (a *Agent) clearWorkflowSession(userID int64) {
if a.store == nil {
return
}
_ = a.store.SetSystemConfig(workflowSessionConfigKey(userID), "")
}
func hasActiveWorkflowSession(session WorkflowSession) bool {
if len(session.Tasks) == 0 {
return false
}
for _, task := range session.Tasks {
if task.Status == workflowTaskPending || task.Status == workflowTaskRunning {
return true
}
}
return false
}
func nextRunnableWorkflowTask(session WorkflowSession) (WorkflowTask, int, bool) {
for i, task := range session.Tasks {
if task.Status != workflowTaskPending && task.Status != workflowTaskRunning {
continue
}
depsReady := true
for _, dep := range task.DependsOn {
ok := false
for _, candidate := range session.Tasks {
if candidate.ID == dep && candidate.Status == workflowTaskCompleted {
ok = true
break
}
}
if !ok {
depsReady = false
break
}
}
if depsReady {
return task, i, true
}
}
return WorkflowTask{}, -1, false
}
func supportedWorkflowSkill(skill, action string) bool {
skill = strings.TrimSpace(skill)
action = normalizeAtomicSkillAction(skill, action)
if skill == "" || action == "" {
return false
}
if _, ok := getSkillDAG(skill, action); ok {
return true
}
if def, ok := getSkillDefinition(skill); ok {
if _, ok := def.Actions[action]; ok {
return true
}
}
switch skill {
case "trader_management", "strategy_management", "model_management", "exchange_management":
if action == "query_running" {
return true
}
}
return false
}
func (a *Agent) handleWorkflowSession(ctx context.Context, storeUserID string, userID int64, lang, text string, session WorkflowSession, onEvent func(event, data string)) (string, bool, error) {
if isExplicitFlowAbort(text) {
a.clearSkillSession(userID)
a.clearWorkflowSession(userID)
return a.maybeOfferParentTaskAfterCancel(userID, lang), true, nil
}
if activeSkill := a.getSkillSession(userID); strings.TrimSpace(activeSkill.Name) != "" {
decision, _ := a.resolveSkillSessionTurn(ctx, userID, lang, text, activeSkill)
switch decision.Intent {
case "cancel":
a.clearSkillSession(userID)
a.clearWorkflowSession(userID)
return a.maybeOfferParentTaskAfterCancel(userID, lang), true, nil
case "instant_reply":
return a.replyToActiveFlowInstantReply(ctx, userID, lang, text, onEvent), true, nil
case "resume_snapshot", "start_new":
if shouldSuspendInterruptedTask(text) || decision.Intent == "resume_snapshot" {
answer, handled, err := a.handoffFromActiveFlow(ctx, storeUserID, userID, lang, text, decision.TargetSnapshotID, onEvent)
return answer, handled, err
}
a.clearSkillSession(userID)
a.clearWorkflowSession(userID)
return "", false, nil
}
answer, handled := a.executeAtomicSkillTask(storeUserID, userID, lang, text, activeSkill.Name, activeSkill.Action, onEvent)
if !handled {
return "", false, nil
}
a.recordSkillInteraction(userID, text, answer)
session = a.getWorkflowSession(userID)
if hasActiveWorkflowSession(session) && strings.TrimSpace(a.getSkillSession(userID).Name) == "" {
session = markCurrentWorkflowTask(session, workflowTaskCompleted, "")
a.saveWorkflowSession(userID, session)
if final, done, err := a.maybeAdvanceWorkflow(ctx, storeUserID, userID, lang, session, onEvent); done || err != nil {
if final != "" && answer != "" {
return answer + "\n\n" + final, true, err
}
if answer != "" {
return answer, true, err
}
return final, true, err
}
}
return answer, true, nil
}
if decision := a.classifyWorkflowSessionInput(ctx, userID, lang, session, text); decision.Intent != "" && decision.Intent != "continue_active" {
switch decision.Intent {
case "cancel":
a.clearWorkflowSession(userID)
return a.maybeOfferParentTaskAfterCancel(userID, lang), true, nil
case "instant_reply":
return a.replyToActiveFlowInstantReply(ctx, userID, lang, text, onEvent), true, nil
case "resume_snapshot", "start_new":
if shouldSuspendInterruptedTask(text) || decision.Intent == "resume_snapshot" {
answer, handled, err := a.handoffFromActiveFlow(ctx, storeUserID, userID, lang, text, decision.TargetSnapshotID, onEvent)
return answer, handled, err
}
a.clearWorkflowSession(userID)
return "", false, nil
}
}
return a.maybeAdvanceWorkflow(ctx, storeUserID, userID, lang, session, onEvent)
}
func (a *Agent) classifyWorkflowSessionInput(ctx context.Context, userID int64, lang string, session WorkflowSession, text string) unifiedFlowDecision {
text = strings.TrimSpace(text)
if text == "" {
return unifiedFlowDecision{Intent: "continue_active"}
}
if isExplicitFlowAbort(text) {
return unifiedFlowDecision{Intent: "cancel"}
}
if isInstantDirectReplyText(text) {
return unifiedFlowDecision{Intent: "instant_reply"}
}
if a == nil || a.aiClient == nil {
if looksLikeNewTopLevelIntent(text) && !strings.EqualFold(text, strings.TrimSpace(session.OriginalRequest)) {
return unifiedFlowDecision{Intent: "start_new"}
}
return unifiedFlowDecision{Intent: "continue_active"}
}
currentTask, _, _ := nextRunnableWorkflowTask(session)
recentConversationCtx := a.buildRecentConversationContext(userID, text)
flowContext := fmt.Sprintf(
"Workflow original request: %s\nCurrent runnable task: %s / %s / %s\nWorkflow tasks JSON: %s",
session.OriginalRequest,
currentTask.Skill,
currentTask.Action,
currentTask.Request,
mustMarshalJSON(session.Tasks),
)
state := a.getExecutionState(userID)
systemPrompt, userPrompt := buildActiveFlowClassifierPrompt(
lang,
"workflow_session",
flowContext,
text,
recentConversationCtx,
state.CurrentReferences,
a.SnapshotManager(userID).List(),
)
stageCtx, cancel := withPlannerStageTimeout(ctx, directReplyTimeout)
defer cancel()
raw, err := a.aiClient.CallWithRequest(&mcp.Request{
Messages: []mcp.Message{
mcp.NewSystemMessage(systemPrompt),
mcp.NewUserMessage(userPrompt),
},
Ctx: stageCtx,
})
if err != nil {
return unifiedFlowDecision{}
}
return unifiedFlowDecisionFromIntent(parseActiveFlowIntentDecision(raw), "")
}
func (a *Agent) maybeAdvanceWorkflow(ctx context.Context, storeUserID string, userID int64, lang string, session WorkflowSession, onEvent func(event, data string)) (string, bool, error) {
task, index, ok := nextRunnableWorkflowTask(session)
if !ok {
summary := a.generateWorkflowSummary(ctx, userID, lang, session)
a.clearWorkflowSession(userID)
if summary == "" {
if lang == "zh" {
summary = "已完成当前任务流。"
} else {
summary = "Completed the current workflow."
}
}
if onEvent != nil {
onEvent(StreamEventPlan, summary)
emitStreamText(onEvent, summary)
}
return summary, true, nil
}
session.Tasks[index].Status = workflowTaskRunning
a.saveWorkflowSession(userID, session)
taskSession := skillSession{Name: task.Skill, Action: task.Action, Phase: "collecting"}
a.saveSkillSession(userID, taskSession)
if onEvent != nil {
onEvent(StreamEventPlan, a.formatWorkflowStatus(lang, session))
onEvent(StreamEventTool, "workflow:"+task.Skill+":"+task.Action)
}
answer, handled := a.executeAtomicSkillTask(storeUserID, userID, lang, task.Request, task.Skill, task.Action, onEvent)
if !handled {
session.Tasks[index].Status = workflowTaskFailed
session.Tasks[index].Error = "task_not_handled"
a.saveWorkflowSession(userID, session)
return "", false, nil
}
a.recordSkillInteraction(userID, task.Request, answer)
if strings.TrimSpace(a.getSkillSession(userID).Name) == "" {
session = a.getWorkflowSession(userID)
session = markCurrentWorkflowTask(session, workflowTaskCompleted, "")
a.saveWorkflowSession(userID, session)
if more, ok, err := a.maybeAdvanceWorkflow(ctx, storeUserID, userID, lang, session, onEvent); ok || err != nil {
if answer != "" && more != "" {
return answer + "\n\n" + more, true, err
}
if answer != "" {
return answer, true, err
}
return more, true, err
}
}
return answer, true, nil
}
func markCurrentWorkflowTask(session WorkflowSession, status, errMsg string) WorkflowSession {
for i := range session.Tasks {
if session.Tasks[i].Status == workflowTaskRunning {
session.Tasks[i].Status = status
session.Tasks[i].Error = strings.TrimSpace(errMsg)
return session
}
}
return session
}
func (a *Agent) formatWorkflowStatus(lang string, session WorkflowSession) string {
parts := make([]string, 0, len(session.Tasks))
for _, task := range session.Tasks {
label := task.Request
if label == "" {
label = task.Skill + ":" + task.Action
}
switch task.Status {
case workflowTaskCompleted:
label = "✓ " + label
case workflowTaskRunning:
label = "→ " + label
default:
label = "· " + label
}
parts = append(parts, label)
}
if lang == "zh" {
return "任务流:" + strings.Join(parts, " | ")
}
return "Workflow: " + strings.Join(parts, " | ")
}
func (a *Agent) generateWorkflowSummary(ctx context.Context, userID int64, lang string, session WorkflowSession) string {
completed := make([]string, 0, len(session.Tasks))
for _, task := range session.Tasks {
if task.Status == workflowTaskCompleted {
completed = append(completed, task.Request)
}
}
if len(completed) == 0 {
return ""
}
if a.aiClient == nil {
if lang == "zh" {
return "已完成这些任务:" + strings.Join(completed, "")
}
return "Completed these tasks: " + strings.Join(completed, "; ")
}
stageCtx, cancel := withPlannerStageTimeout(ctx, directReplyTimeout)
defer cancel()
systemPrompt := `You are summarizing a finished workflow for NOFXi.
Return one short user-facing summary in the user's language.
Do not mention internal DAG, scheduler, or JSON.
` + cleanUserFacingReplyInstruction
userPrompt := fmt.Sprintf("Language: %s\nOriginal request: %s\nCompleted tasks:\n- %s", lang, session.OriginalRequest, strings.Join(completed, "\n- "))
raw, err := a.aiClient.CallWithRequest(&mcp.Request{
Messages: []mcp.Message{
mcp.NewSystemMessage(systemPrompt),
mcp.NewUserMessage(userPrompt),
},
Ctx: stageCtx,
})
if err != nil {
if lang == "zh" {
return "已完成这些任务:" + strings.Join(completed, "")
}
return "Completed these tasks: " + strings.Join(completed, "; ")
}
return strings.TrimSpace(raw)
}
func (a *Agent) decomposeWorkflowIntent(ctx context.Context, userID int64, lang, text string) (workflowDecomposition, error) {
if !looksLikeMultiTaskIntent(text) {
return workflowDecomposition{}, nil
}
if a.aiClient != nil {
if dec, err := a.decomposeWorkflowIntentWithLLM(ctx, userID, lang, text); err == nil && len(dec.Tasks) > 1 {
return dec, nil
}
}
return a.decomposeWorkflowIntentFallback(text), nil
}
func looksLikeMultiTaskIntent(text string) bool {
lower := strings.ToLower(strings.TrimSpace(text))
if lower == "" {
return false
}
connectors := []string{"", ",", "然后", "再", "并且", "并", "同时", "and", "then"}
count := 0
for _, c := range connectors {
if strings.Contains(lower, c) {
count++
}
}
if count > 0 {
return true
}
if looksLikeCompoundStrategyIntent(text) || looksLikeCompoundTraderIntent(text) ||
looksLikeCompoundModelIntent(text) || looksLikeCompoundExchangeIntent(text) {
return true
}
return false
}
func looksLikeCompoundStrategyIntent(text string) bool {
lower := strings.ToLower(strings.TrimSpace(text))
if !hasExplicitManagementDomainCue(text, "strategy") {
return false
}
hasCreate := containsAny(lower, []string{"创建", "新建", "创一个", "创个", "加一个", "create", "new"})
hasConfigUpdate := containsAny(lower, []string{"修改", "更新", "参数", "配置", "prompt", "提示词", "改成", "改为"})
hasLifecycle := containsAny(lower, []string{"激活", "activate", "复制", "duplicate", "删除", "删了", "删掉", "delete"})
hasMetaUpdate := containsAny(lower, []string{"发布", "公开", "可见", "描述", "改成", "改为"})
return (hasCreate && (hasConfigUpdate || hasLifecycle || hasMetaUpdate)) ||
(hasConfigUpdate && hasLifecycle)
}
func looksLikeCompoundTraderIntent(text string) bool {
lower := strings.ToLower(strings.TrimSpace(text))
if !(hasExplicitManagementDomainCue(text, "trader") || hasExplicitCreateIntentForDomain(text, "trader")) {
return false
}
hasCreate := containsAny(lower, []string{"创建", "新建", "创一个", "创个", "create", "new"})
hasBindingsOrConfig := containsAny(lower, []string{"修改", "更新", "换模型", "换交易所", "换策略", "切换模型", "切换交易所", "切换策略", "扫描间隔", "全仓", "逐仓", "竞技场"})
hasLifecycle := containsAny(lower, []string{"启动", "开始", "start", "停止", "stop"})
return (hasCreate && (hasBindingsOrConfig || hasLifecycle)) ||
(hasBindingsOrConfig && hasLifecycle)
}
func looksLikeCompoundModelIntent(text string) bool {
lower := strings.ToLower(strings.TrimSpace(text))
if !hasExplicitManagementDomainCue(text, "model") {
return false
}
hasCreate := containsAny(lower, []string{"创建", "新建", "创一个", "创个", "create", "new"})
hasConfig := containsAny(lower, []string{"修改", "更新", "改", "接口地址", "模型名", "启用", "禁用", "api key"})
hasLifecycle := containsAny(lower, []string{"启用", "禁用", "enable", "disable", "删除", "删了", "删掉", "delete"})
return (hasCreate && (hasConfig || hasLifecycle)) || (hasConfig && hasLifecycle)
}
func looksLikeCompoundExchangeIntent(text string) bool {
lower := strings.ToLower(strings.TrimSpace(text))
if !hasExplicitManagementDomainCue(text, "exchange") {
return false
}
hasCreate := containsAny(lower, []string{"创建", "新建", "创一个", "创个", "create", "new"})
hasConfig := containsAny(lower, []string{"修改", "更新", "改", "账户名", "api key", "secret", "passphrase", "钱包", "启用", "禁用"})
hasLifecycle := containsAny(lower, []string{"启用", "禁用", "enable", "disable", "删除", "删了", "删掉", "delete"})
return (hasCreate && (hasConfig || hasLifecycle)) || (hasConfig && hasLifecycle)
}
func (a *Agent) decomposeWorkflowIntentWithLLM(ctx context.Context, userID int64, lang, text string) (workflowDecomposition, error) {
stageCtx, cancel := withPlannerStageTimeout(ctx, directReplyTimeout)
defer cancel()
systemPrompt := `You decompose one NOFXi user request into a small task graph for execution.
Return JSON only. No markdown.
Only use these skills: trader_management, strategy_management, model_management, exchange_management.
Only use one atomic action per task.
You are the action decomposition layer. Split complex requests into atomic management steps and decide dependencies.
Each task must include:
- id
- skill
- action
- request
- depends_on (array, may be empty)
Rules:
- Prefer atomic actions such as create, update_bindings, configure_strategy, configure_exchange, configure_model, update_status, update_endpoint, update_config, update_prompt, activate, duplicate, start, stop, delete, query_list, query_detail.
- If one request contains create plus follow-up edits in the same skill, split them into multiple tasks.
- If later tasks need an entity created earlier, make the dependency explicit in depends_on.
- Keep each request user-readable and self-contained enough for a single skill handler to execute.
- Do not merge two actions into one task.
- If the request is effectively a single task, return one task only.`
userPrompt := fmt.Sprintf("Language: %s\nUser request: %s", lang, text)
if skillContext := buildManagementSkillRoutingContext(lang); skillContext != "" {
userPrompt += "\n\n" + skillContext
}
raw, err := a.aiClient.CallWithRequest(&mcp.Request{
Messages: []mcp.Message{
mcp.NewSystemMessage(systemPrompt),
mcp.NewUserMessage(userPrompt),
},
Ctx: stageCtx,
})
if err != nil {
return workflowDecomposition{}, err
}
return parseWorkflowDecomposition(raw)
}
func parseWorkflowDecomposition(raw string) (workflowDecomposition, error) {
raw = strings.TrimSpace(raw)
raw = strings.TrimPrefix(raw, "```json")
raw = strings.TrimPrefix(raw, "```")
raw = strings.TrimSuffix(raw, "```")
raw = strings.TrimSpace(raw)
var out workflowDecomposition
if err := json.Unmarshal([]byte(raw), &out); err == nil {
out = normalizeWorkflowDecomposition(out)
return out, nil
}
start := strings.Index(raw, "{")
end := strings.LastIndex(raw, "}")
if start >= 0 && end > start {
if err := json.Unmarshal([]byte(raw[start:end+1]), &out); err == nil {
out = normalizeWorkflowDecomposition(out)
return out, nil
}
}
return workflowDecomposition{}, fmt.Errorf("invalid workflow json")
}
func normalizeWorkflowDecomposition(out workflowDecomposition) workflowDecomposition {
normalized := make([]WorkflowTask, 0, len(out.Tasks))
for i, task := range out.Tasks {
task.ID = strings.TrimSpace(task.ID)
if task.ID == "" {
task.ID = fmt.Sprintf("task_%d", i+1)
}
task.Skill = strings.TrimSpace(task.Skill)
task.Action = normalizeAtomicSkillAction(task.Skill, task.Action)
task.Request = strings.TrimSpace(task.Request)
task.DependsOn = cleanStringList(task.DependsOn)
if !supportedWorkflowSkill(task.Skill, task.Action) || task.Request == "" {
continue
}
task.Status = workflowTaskPending
normalized = append(normalized, task)
}
out.Tasks = normalized
return out
}
func (a *Agent) decomposeWorkflowIntentFallback(text string) workflowDecomposition {
segments := splitWorkflowSegments(text)
tasks := make([]WorkflowTask, 0, len(segments))
nextID := 1
for _, segment := range segments {
prevSkill := ""
if len(tasks) > 0 {
prevSkill = tasks[len(tasks)-1].Skill
}
compound := classifyCompoundWorkflowTasksWithContext(segment, prevSkill)
if len(compound) == 0 {
task, ok := classifyWorkflowTaskWithContext(segment, prevSkill)
if !ok {
continue
}
compound = []WorkflowTask{task}
}
for i := range compound {
compound[i].ID = fmt.Sprintf("task_%d", nextID)
compound[i].Status = workflowTaskPending
if len(tasks) > 0 && len(compound[i].DependsOn) == 0 {
compound[i].DependsOn = []string{tasks[len(tasks)-1].ID}
}
if i > 0 {
compound[i].DependsOn = []string{compound[i-1].ID}
}
tasks = append(tasks, compound[i])
nextID++
}
}
return workflowDecomposition{Tasks: tasks}
}
func classifyCompoundWorkflowTasksWithContext(text, previousSkill string) []WorkflowTask {
if tasks := classifyCompoundWorkflowTasks(text); len(tasks) > 1 {
return tasks
}
switch strings.TrimSpace(previousSkill) {
case "strategy_management":
return classifyContextualStrategyWorkflowTasks(text)
case "trader_management":
return classifyContextualTraderWorkflowTasks(text)
}
return nil
}
func classifyCompoundWorkflowTasks(text string) []WorkflowTask {
segment := strings.TrimSpace(text)
if segment == "" {
return nil
}
if tasks := classifyCompoundStrategyWorkflowTasks(segment); len(tasks) > 1 {
return tasks
}
if tasks := classifyCompoundTraderWorkflowTasks(segment); len(tasks) > 1 {
return tasks
}
if tasks := classifyCompoundModelWorkflowTasks(segment); len(tasks) > 1 {
return tasks
}
if tasks := classifyCompoundExchangeWorkflowTasks(segment); len(tasks) > 1 {
return tasks
}
return nil
}
func classifyContextualStrategyWorkflowTasks(text string) []WorkflowTask {
lower := strings.ToLower(strings.TrimSpace(text))
hasConfig := containsAny(lower, []string{"修改", "更新", "参数", "配置", "prompt", "提示词", "改成", "改为"})
hasActivate := containsAny(lower, []string{"激活", "activate"})
hasDuplicate := containsAny(lower, []string{"复制", "duplicate"})
if !hasConfig && !hasActivate && !hasDuplicate {
return nil
}
var tasks []WorkflowTask
if hasConfig {
action := "update_config"
if containsAny(lower, []string{"prompt", "提示词"}) {
action = "update_prompt"
}
tasks = append(tasks, WorkflowTask{Skill: "strategy_management", Action: action, Request: text})
}
if hasActivate {
tasks = append(tasks, WorkflowTask{Skill: "strategy_management", Action: "activate", Request: text})
}
if hasDuplicate {
tasks = append(tasks, WorkflowTask{Skill: "strategy_management", Action: "duplicate", Request: text})
}
if len(tasks) == 0 {
return nil
}
return tasks
}
func classifyContextualTraderWorkflowTasks(text string) []WorkflowTask {
lower := strings.ToLower(strings.TrimSpace(text))
hasUpdate := containsAny(lower, []string{"修改", "更新", "换模型", "换交易所", "换策略", "切换模型", "切换交易所", "切换策略", "扫描间隔", "全仓", "逐仓", "竞技场"})
hasStart := containsAny(lower, []string{"启动", "开始", "run", "start"})
hasStop := containsAny(lower, []string{"停止", "停掉", "stop", "pause"})
if !hasUpdate && !hasStart && !hasStop {
return nil
}
var tasks []WorkflowTask
if hasUpdate {
tasks = append(tasks, WorkflowTask{Skill: "trader_management", Action: "update_bindings", Request: text})
}
if hasStart {
tasks = append(tasks, WorkflowTask{Skill: "trader_management", Action: "start", Request: text})
}
if hasStop {
tasks = append(tasks, WorkflowTask{Skill: "trader_management", Action: "stop", Request: text})
}
if len(tasks) == 0 {
return nil
}
return tasks
}
func classifyWorkflowTaskWithContext(text, previousSkill string) (WorkflowTask, bool) {
if task, ok := classifyWorkflowTask(text); ok {
return task, true
}
switch strings.TrimSpace(previousSkill) {
case "strategy_management":
if tasks := classifyContextualStrategyWorkflowTasks(text); len(tasks) > 0 {
return tasks[0], true
}
case "trader_management":
if tasks := classifyContextualTraderWorkflowTasks(text); len(tasks) > 0 {
return tasks[0], true
}
}
return WorkflowTask{}, false
}
func classifyCompoundStrategyWorkflowTasks(text string) []WorkflowTask {
if !hasExplicitManagementDomainCue(text, "strategy") {
return nil
}
lower := strings.ToLower(strings.TrimSpace(text))
hasCreate := containsAny(lower, []string{"创建", "新建", "创一个", "创个", "加一个", "create", "new"})
hasConfig := containsAny(lower, []string{"修改", "更新", "参数", "配置", "prompt", "提示词", "改成", "改为"})
hasActivate := containsAny(lower, []string{"激活", "activate"})
hasDuplicate := containsAny(lower, []string{"复制", "duplicate"})
if !hasCreate && !hasConfig && !hasActivate && !hasDuplicate {
return nil
}
var tasks []WorkflowTask
if hasCreate {
tasks = append(tasks, WorkflowTask{Skill: "strategy_management", Action: "create", Request: text})
}
if hasConfig {
action := "update_config"
if containsAny(lower, []string{"prompt", "提示词"}) {
action = "update_prompt"
}
tasks = append(tasks, WorkflowTask{Skill: "strategy_management", Action: action, Request: text})
}
if hasActivate {
tasks = append(tasks, WorkflowTask{Skill: "strategy_management", Action: "activate", Request: text})
}
if hasDuplicate {
tasks = append(tasks, WorkflowTask{Skill: "strategy_management", Action: "duplicate", Request: text})
}
if len(tasks) <= 1 {
return nil
}
return tasks
}
func classifyCompoundTraderWorkflowTasks(text string) []WorkflowTask {
if !(hasExplicitManagementDomainCue(text, "trader") || hasExplicitCreateIntentForDomain(text, "trader")) {
return nil
}
lower := strings.ToLower(strings.TrimSpace(text))
hasCreate := containsAny(lower, []string{"创建", "新建", "创一个", "创个", "create", "new"})
hasUpdate := containsAny(lower, []string{"修改", "更新", "换模型", "换交易所", "换策略", "切换模型", "切换交易所", "切换策略", "扫描间隔", "全仓", "逐仓", "竞技场"})
hasStart := containsAny(lower, []string{"启动", "开始", "run", "start"})
hasStop := containsAny(lower, []string{"停止", "停掉", "stop", "pause"})
var tasks []WorkflowTask
if hasCreate {
tasks = append(tasks, WorkflowTask{Skill: "trader_management", Action: "create", Request: text})
}
if hasUpdate {
tasks = append(tasks, WorkflowTask{Skill: "trader_management", Action: "update_bindings", Request: text})
}
if hasStart {
tasks = append(tasks, WorkflowTask{Skill: "trader_management", Action: "start", Request: text})
}
if hasStop {
tasks = append(tasks, WorkflowTask{Skill: "trader_management", Action: "stop", Request: text})
}
if len(tasks) <= 1 {
return nil
}
return tasks
}
func classifyCompoundModelWorkflowTasks(text string) []WorkflowTask {
if !hasExplicitManagementDomainCue(text, "model") {
return nil
}
lower := strings.ToLower(strings.TrimSpace(text))
hasCreate := containsAny(lower, []string{"创建", "新建", "创一个", "创个", "create", "new"})
hasConfig := containsAny(lower, []string{"修改", "更新", "改", "接口地址", "模型名", "api key"})
hasStatus := containsAny(lower, []string{"启用", "禁用", "enable", "disable"})
var tasks []WorkflowTask
if hasCreate {
tasks = append(tasks, WorkflowTask{Skill: "model_management", Action: "create", Request: text})
}
if hasConfig {
action := "update_endpoint"
tasks = append(tasks, WorkflowTask{Skill: "model_management", Action: action, Request: text})
}
if hasStatus {
tasks = append(tasks, WorkflowTask{Skill: "model_management", Action: "update_status", Request: text})
}
if len(tasks) <= 1 {
return nil
}
return tasks
}
func classifyCompoundExchangeWorkflowTasks(text string) []WorkflowTask {
if !hasExplicitManagementDomainCue(text, "exchange") {
return nil
}
lower := strings.ToLower(strings.TrimSpace(text))
hasCreate := containsAny(lower, []string{"创建", "新建", "创一个", "创个", "create", "new"})
hasConfig := containsAny(lower, []string{"修改", "更新", "改", "账户名", "api key", "secret", "passphrase", "钱包"})
hasStatus := containsAny(lower, []string{"启用", "禁用", "enable", "disable"})
var tasks []WorkflowTask
if hasCreate {
tasks = append(tasks, WorkflowTask{Skill: "exchange_management", Action: "create", Request: text})
}
if hasConfig {
tasks = append(tasks, WorkflowTask{Skill: "exchange_management", Action: "update_name", Request: text})
}
if hasStatus {
tasks = append(tasks, WorkflowTask{Skill: "exchange_management", Action: "update_status", Request: text})
}
if len(tasks) <= 1 {
return nil
}
return tasks
}
func splitWorkflowSegments(text string) []string {
parts := []string{strings.TrimSpace(text)}
separators := []string{"", ",", "然后", "再", "并且", "同时", " and then ", " then ", " and "}
for _, sep := range separators {
next := make([]string, 0, len(parts))
for _, part := range parts {
split := strings.Split(part, sep)
for _, candidate := range split {
candidate = strings.TrimSpace(candidate)
if candidate != "" {
next = append(next, candidate)
}
}
}
parts = next
}
return parts
}
func classifyWorkflowTask(text string) (WorkflowTask, bool) {
segment := strings.TrimSpace(text)
if segment == "" {
return WorkflowTask{}, false
}
lower := strings.ToLower(segment)
switch {
case hasExplicitCreateIntentForDomain(segment, "trader"):
return WorkflowTask{Skill: "trader_management", Action: "create", Request: segment}, true
case hasExplicitManagementDomainCue(segment, "trader"):
action := ""
switch {
case containsAny(lower, []string{"创建", "新建", "创一个", "创个", "create", "new"}):
action = "create"
case containsAny(lower, []string{"启动", "开始", "run", "start"}):
action = "start"
case containsAny(lower, []string{"停止", "停掉", "stop", "pause"}):
action = "stop"
case containsAny(lower, []string{"删除", "删了", "删掉", "delete"}):
action = "delete"
case containsAny(lower, []string{"换模型", "换交易所", "换策略", "切换模型", "切换交易所", "切换策略", "扫描间隔", "全仓", "逐仓", "竞技场"}):
action = "update_bindings"
case containsAny(lower, []string{"修改", "更新", "改"}):
action = "update_bindings"
case containsAny(lower, []string{"详情", "配置", "参数", "what", "detail"}):
action = "query_detail"
case containsAny(lower, []string{"列表", "全部", "哪些", "list"}):
action = "query_list"
}
if supportedWorkflowSkill("trader_management", action) {
return WorkflowTask{Skill: "trader_management", Action: action, Request: segment}, true
}
case hasExplicitManagementDomainCue(segment, "exchange"):
action := ""
switch {
case containsAny(lower, []string{"创建", "新建", "创一个", "创个", "create", "new"}):
action = "create"
case containsAny(lower, []string{"启用", "enable", "禁用", "disable"}):
action = "update_status"
case containsAny(lower, []string{"删除", "删了", "删掉", "delete"}):
action = "delete"
case containsAny(lower, []string{"修改", "更新", "改", "账户名", "api key", "secret", "passphrase", "钱包"}):
action = "update"
case containsAny(lower, []string{"详情", "配置", "参数", "what", "detail"}):
action = "query_detail"
case containsAny(lower, []string{"列表", "全部", "哪些", "list"}):
action = "query_list"
}
if supportedWorkflowSkill("exchange_management", action) {
return WorkflowTask{Skill: "exchange_management", Action: action, Request: segment}, true
}
case hasExplicitManagementDomainCue(segment, "model"):
action := ""
switch {
case containsAny(lower, []string{"创建", "新建", "创一个", "创个", "create", "new"}):
action = "create"
case containsAny(lower, []string{"启用", "enable", "禁用", "disable"}):
action = "update_status"
case containsAny(lower, []string{"删除", "删了", "删掉", "delete"}):
action = "delete"
case containsAny(lower, []string{"接口地址", "endpoint", "url"}):
action = "update_endpoint"
case containsAny(lower, []string{"修改", "更新", "改", "模型名", "api key"}):
action = "update"
case containsAny(lower, []string{"详情", "配置", "参数", "what", "detail"}):
action = "query_detail"
case containsAny(lower, []string{"列表", "全部", "哪些", "list"}):
action = "query_list"
}
if supportedWorkflowSkill("model_management", action) {
return WorkflowTask{Skill: "model_management", Action: action, Request: segment}, true
}
case hasExplicitManagementDomainCue(segment, "strategy"):
action := ""
switch {
case containsAny(lower, []string{"创建", "新建", "创一个", "创个", "create", "new"}):
action = "create"
case containsAny(lower, []string{"激活", "activate"}):
action = "activate"
case containsAny(lower, []string{"复制", "duplicate"}):
action = "duplicate"
case containsAny(lower, []string{"删除", "删了", "删掉", "delete"}):
action = "delete"
case containsAny(lower, []string{"prompt", "提示词"}):
action = "update_prompt"
case containsAny(lower, []string{"修改", "更新", "改", "参数", "配置"}):
action = "update_config"
case containsAny(lower, []string{"详情", "配置", "参数", "what", "detail"}) || hasExplicitStrategyDetailIntent(segment):
action = "query_detail"
case containsAny(lower, []string{"列表", "全部", "哪些", "list"}):
action = "query_list"
}
if action == "" && hasExplicitStrategyDetailIntent(segment) {
action = "query_detail"
}
if supportedWorkflowSkill("strategy_management", action) {
return WorkflowTask{Skill: "strategy_management", Action: action, Request: segment}, true
}
}
return WorkflowTask{}, false
}

922
agents.md
View File

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

View File

@@ -1,110 +0,0 @@
package api
import (
"encoding/json"
"net/http"
"strings"
"nofx/agent"
"github.com/gin-gonic/gin"
)
type agentPreferencePayload struct {
Text string `json:"text"`
}
func (s *Server) handleGetAgentPreferences(c *gin.Context) {
uid := agent.SessionUserIDFromKey(c.GetString("user_id"))
raw, err := s.store.GetSystemConfig(agent.PreferencesConfigKey(uid))
if err != nil || strings.TrimSpace(raw) == "" {
c.JSON(http.StatusOK, gin.H{"preferences": []agent.PersistentPreference{}})
return
}
var prefs []agent.PersistentPreference
if err := json.Unmarshal([]byte(raw), &prefs); err != nil {
c.JSON(http.StatusOK, gin.H{"preferences": []agent.PersistentPreference{}})
return
}
c.JSON(http.StatusOK, gin.H{"preferences": prefs})
}
func (s *Server) handleCreateAgentPreference(c *gin.Context) {
uid := agent.SessionUserIDFromKey(c.GetString("user_id"))
var req agentPreferencePayload
if err := c.ShouldBindJSON(&req); err != nil || strings.TrimSpace(req.Text) == "" {
c.JSON(http.StatusBadRequest, gin.H{"error": "text required"})
return
}
if len([]rune(strings.TrimSpace(req.Text))) > 500 {
c.JSON(http.StatusBadRequest, gin.H{"error": "text too long"})
return
}
created, err := agent.NewPersistentPreference(req.Text)
if err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
prefs := s.loadAgentPreferences(uid)
prefs = append([]agent.PersistentPreference{created}, prefs...)
if len(prefs) > 20 {
prefs = prefs[:20]
}
if err := s.saveAgentPreferences(uid, prefs); err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to save preference"})
return
}
c.JSON(http.StatusOK, gin.H{"preferences": prefs})
}
func (s *Server) handleDeleteAgentPreference(c *gin.Context) {
uid := agent.SessionUserIDFromKey(c.GetString("user_id"))
id := strings.TrimSpace(c.Param("id"))
if id == "" {
c.JSON(http.StatusBadRequest, gin.H{"error": "id required"})
return
}
prefs := s.loadAgentPreferences(uid)
filtered := prefs[:0]
for _, pref := range prefs {
if pref.ID != id {
filtered = append(filtered, pref)
}
}
if err := s.saveAgentPreferences(uid, filtered); err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to delete preference"})
return
}
c.JSON(http.StatusOK, gin.H{"preferences": filtered})
}
func (s *Server) loadAgentPreferences(userID int64) []agent.PersistentPreference {
raw, err := s.store.GetSystemConfig(agent.PreferencesConfigKey(userID))
if err != nil || strings.TrimSpace(raw) == "" {
return []agent.PersistentPreference{}
}
var prefs []agent.PersistentPreference
if err := json.Unmarshal([]byte(raw), &prefs); err != nil {
return []agent.PersistentPreference{}
}
return prefs
}
func (s *Server) saveAgentPreferences(userID int64, prefs []agent.PersistentPreference) error {
data, err := json.Marshal(prefs)
if err != nil {
return err
}
return s.store.SetSystemConfig(agent.PreferencesConfigKey(userID), string(data))
}

View File

@@ -1,42 +0,0 @@
package api
import (
"nofx/agent"
"github.com/gin-gonic/gin"
)
// RegisterAgentHandler registers NOFXi agent API routes on the main router.
// Chat endpoint requires authentication; market data endpoints are public.
func (s *Server) RegisterAgentHandler(h *agent.WebHandler) {
// Chat requires auth — can trigger trades and access account data
s.router.POST("/api/agent/chat", s.authMiddleware(), func(c *gin.Context) {
isAdmin := c.GetString("user_id") == "admin"
ctx := agent.WithStoreUserID(c.Request.Context(), c.GetString("user_id"))
ctx = agent.WithSessionPolicy(ctx, agent.SessionPolicy{
Authenticated: true,
IsAdmin: isAdmin,
CanExecuteTrade: true,
CanViewSensitiveSecrets: false,
})
req := c.Request.WithContext(ctx)
h.HandleChat(c.Writer, req)
})
s.router.POST("/api/agent/chat/stream", s.authMiddleware(), func(c *gin.Context) {
isAdmin := c.GetString("user_id") == "admin"
ctx := agent.WithStoreUserID(c.Request.Context(), c.GetString("user_id"))
ctx = agent.WithSessionPolicy(ctx, agent.SessionPolicy{
Authenticated: true,
IsAdmin: isAdmin,
CanExecuteTrade: true,
CanViewSensitiveSecrets: false,
})
req := c.Request.WithContext(ctx)
h.HandleChatStream(c.Writer, req)
})
// Public endpoints — read-only market data
s.router.GET("/api/agent/health", gin.WrapF(h.HandleHealth))
s.router.GET("/api/agent/klines", gin.WrapF(h.HandleKlines))
s.router.GET("/api/agent/ticker", gin.WrapF(h.HandleTicker))
s.router.GET("/api/agent/tickers", gin.WrapF(h.HandleTickers))
}

863
api/backtest.go Normal file
View File

@@ -0,0 +1,863 @@
package api
import (
"context"
"database/sql"
"encoding/json"
"errors"
"fmt"
"net/http"
"os"
"strconv"
"strings"
"time"
"nofx/backtest"
"nofx/logger"
"nofx/market"
"nofx/provider/nofxos"
"nofx/store"
"github.com/gin-gonic/gin"
)
func (s *Server) registerBacktestRoutes(router *gin.RouterGroup) {
router.POST("/start", s.handleBacktestStart)
router.POST("/pause", s.handleBacktestPause)
router.POST("/resume", s.handleBacktestResume)
router.POST("/stop", s.handleBacktestStop)
router.POST("/label", s.handleBacktestLabel)
router.POST("/delete", s.handleBacktestDelete)
router.GET("/status", s.handleBacktestStatus)
router.GET("/runs", s.handleBacktestRuns)
router.GET("/equity", s.handleBacktestEquity)
router.GET("/trades", s.handleBacktestTrades)
router.GET("/metrics", s.handleBacktestMetrics)
router.GET("/trace", s.handleBacktestTrace)
router.GET("/decisions", s.handleBacktestDecisions)
router.GET("/export", s.handleBacktestExport)
router.GET("/klines", s.handleBacktestKlines)
}
type backtestStartRequest struct {
Config backtest.BacktestConfig `json:"config"`
}
type runIDRequest struct {
RunID string `json:"run_id"`
}
type labelRequest struct {
RunID string `json:"run_id"`
Label string `json:"label"`
}
func (s *Server) handleBacktestStart(c *gin.Context) {
if s.backtestManager == nil {
c.JSON(http.StatusServiceUnavailable, gin.H{"error": "backtest manager unavailable"})
return
}
var req backtestStartRequest
if err := c.ShouldBindJSON(&req); err != nil {
SafeBadRequest(c, "Invalid request parameters")
return
}
cfg := req.Config
if cfg.RunID == "" {
cfg.RunID = "bt_" + time.Now().UTC().Format("20060102_150405")
}
cfg.CustomPrompt = strings.TrimSpace(cfg.CustomPrompt)
cfg.UserID = normalizeUserID(c.GetString("user_id"))
logger.Infof("📊 Backtest request - symbols from request: %v (count=%d), strategyID: %s",
cfg.Symbols, len(cfg.Symbols), cfg.StrategyID)
// Load strategy config if strategy_id is provided
if cfg.StrategyID != "" {
strategy, err := s.store.Strategy().Get(cfg.UserID, cfg.StrategyID)
if err != nil {
SafeBadRequest(c, "Failed to load strategy")
return
}
if strategy == nil {
SafeBadRequest(c, "Strategy not found")
return
}
var strategyConfig store.StrategyConfig
if err := json.Unmarshal([]byte(strategy.Config), &strategyConfig); err != nil {
SafeBadRequest(c, "Failed to parse strategy config")
return
}
cfg.SetLoadedStrategy(&strategyConfig)
logger.Infof("📊 Backtest using saved strategy: %s (%s)", strategy.Name, strategy.ID)
logger.Infof("📊 Strategy coin source: type=%s, use_ai500=%v, use_oi_top=%v, static_coins=%v",
strategyConfig.CoinSource.SourceType,
strategyConfig.CoinSource.UseAI500,
strategyConfig.CoinSource.UseOITop,
strategyConfig.CoinSource.StaticCoins)
// If no symbols provided, fetch from strategy's coin source
if len(cfg.Symbols) == 0 {
symbols, err := s.resolveStrategyCoins(&strategyConfig)
if err != nil {
SafeBadRequest(c, "Failed to resolve coins from strategy")
return
}
cfg.Symbols = symbols
logger.Infof("📊 Resolved %d coins from strategy: %v", len(symbols), symbols)
}
}
if err := s.hydrateBacktestAIConfig(&cfg); err != nil {
SafeBadRequest(c, "Failed to configure AI model")
return
}
logger.Infof("📊 Starting backtest with final config: runID=%s, symbols=%v (count=%d), strategyID=%s",
cfg.RunID, cfg.Symbols, len(cfg.Symbols), cfg.StrategyID)
runner, err := s.backtestManager.Start(context.Background(), cfg)
if err != nil {
SafeError(c, http.StatusBadRequest, "Failed to start backtest", err)
return
}
meta := runner.CurrentMetadata()
c.JSON(http.StatusOK, meta)
}
func (s *Server) handleBacktestPause(c *gin.Context) {
s.handleBacktestControl(c, s.backtestManager.Pause)
}
func (s *Server) handleBacktestResume(c *gin.Context) {
s.handleBacktestControl(c, s.backtestManager.Resume)
}
func (s *Server) handleBacktestStop(c *gin.Context) {
s.handleBacktestControl(c, s.backtestManager.Stop)
}
func (s *Server) handleBacktestControl(c *gin.Context, fn func(string) error) {
if s.backtestManager == nil {
c.JSON(http.StatusServiceUnavailable, gin.H{"error": "backtest manager unavailable"})
return
}
userID := normalizeUserID(c.GetString("user_id"))
var req runIDRequest
if err := c.ShouldBindJSON(&req); err != nil {
SafeBadRequest(c, "Invalid request parameters")
return
}
if req.RunID == "" {
SafeBadRequest(c, "run_id is required")
return
}
if _, err := s.ensureBacktestRunOwnership(req.RunID, userID); writeBacktestAccessError(c, err) {
return
}
if err := fn(req.RunID); err != nil {
SafeError(c, http.StatusBadRequest, "Failed to execute backtest operation", err)
return
}
meta, err := s.backtestManager.LoadMetadata(req.RunID)
if err != nil {
c.JSON(http.StatusOK, gin.H{"message": "ok"})
return
}
c.JSON(http.StatusOK, meta)
}
func (s *Server) handleBacktestLabel(c *gin.Context) {
if s.backtestManager == nil {
c.JSON(http.StatusServiceUnavailable, gin.H{"error": "backtest manager unavailable"})
return
}
var req labelRequest
if err := c.ShouldBindJSON(&req); err != nil {
SafeBadRequest(c, "Invalid request parameters")
return
}
if strings.TrimSpace(req.RunID) == "" {
SafeBadRequest(c, "run_id is required")
return
}
userID := normalizeUserID(c.GetString("user_id"))
if _, err := s.ensureBacktestRunOwnership(req.RunID, userID); writeBacktestAccessError(c, err) {
return
}
meta, err := s.backtestManager.UpdateLabel(req.RunID, req.Label)
if err != nil {
SafeInternalError(c, "Update backtest label", err)
return
}
c.JSON(http.StatusOK, meta)
}
func (s *Server) handleBacktestDelete(c *gin.Context) {
if s.backtestManager == nil {
c.JSON(http.StatusServiceUnavailable, gin.H{"error": "backtest manager unavailable"})
return
}
var req runIDRequest
if err := c.ShouldBindJSON(&req); err != nil {
SafeBadRequest(c, "Invalid request parameters")
return
}
if strings.TrimSpace(req.RunID) == "" {
SafeBadRequest(c, "run_id is required")
return
}
userID := normalizeUserID(c.GetString("user_id"))
if _, err := s.ensureBacktestRunOwnership(req.RunID, userID); writeBacktestAccessError(c, err) {
return
}
if err := s.backtestManager.Delete(req.RunID); err != nil {
SafeInternalError(c, "Delete backtest run", err)
return
}
c.JSON(http.StatusOK, gin.H{"message": "deleted"})
}
func (s *Server) handleBacktestStatus(c *gin.Context) {
if s.backtestManager == nil {
c.JSON(http.StatusServiceUnavailable, gin.H{"error": "backtest manager unavailable"})
return
}
userID := normalizeUserID(c.GetString("user_id"))
runID := c.Query("run_id")
if runID == "" {
c.JSON(http.StatusBadRequest, gin.H{"error": "run_id is required"})
return
}
meta, err := s.ensureBacktestRunOwnership(runID, userID)
if writeBacktestAccessError(c, err) {
return
}
status := s.backtestManager.Status(runID)
if status != nil {
c.JSON(http.StatusOK, status)
return
}
payload := backtest.StatusPayload{
RunID: meta.RunID,
State: meta.State,
ProgressPct: meta.Summary.ProgressPct,
ProcessedBars: meta.Summary.ProcessedBars,
CurrentTime: 0,
DecisionCycle: meta.Summary.ProcessedBars,
Equity: meta.Summary.EquityLast,
UnrealizedPnL: 0,
RealizedPnL: 0,
Note: meta.Summary.LiquidationNote,
LastUpdatedIso: meta.UpdatedAt.Format(time.RFC3339),
}
c.JSON(http.StatusOK, payload)
}
func (s *Server) handleBacktestRuns(c *gin.Context) {
if s.backtestManager == nil {
c.JSON(http.StatusServiceUnavailable, gin.H{"error": "backtest manager unavailable"})
return
}
rawUserID := strings.TrimSpace(c.GetString("user_id"))
userID := normalizeUserID(rawUserID)
filterByUser := rawUserID != "" && rawUserID != "admin"
metas, err := s.backtestManager.ListRuns()
if err != nil {
SafeInternalError(c, "List backtest runs", err)
return
}
stateFilter := strings.ToLower(strings.TrimSpace(c.Query("state")))
search := strings.ToLower(strings.TrimSpace(c.Query("search")))
limit := queryInt(c, "limit", 50)
offset := queryInt(c, "offset", 0)
if limit <= 0 {
limit = 50
}
if offset < 0 {
offset = 0
}
filtered := make([]*backtest.RunMetadata, 0, len(metas))
for _, meta := range metas {
if stateFilter != "" && !strings.EqualFold(string(meta.State), stateFilter) {
continue
}
if search != "" {
target := strings.ToLower(meta.RunID + " " + meta.Summary.DecisionTF + " " + meta.Label + " " + meta.LastError)
if !strings.Contains(target, search) {
continue
}
}
if filterByUser {
owner := strings.TrimSpace(meta.UserID)
if owner != "" && owner != userID {
continue
}
}
filtered = append(filtered, meta)
}
total := len(filtered)
start := offset
if start > total {
start = total
}
end := offset + limit
if end > total {
end = total
}
page := filtered[start:end]
c.JSON(http.StatusOK, gin.H{
"total": total,
"items": page,
})
}
func (s *Server) handleBacktestEquity(c *gin.Context) {
if s.backtestManager == nil {
c.JSON(http.StatusServiceUnavailable, gin.H{"error": "backtest manager unavailable"})
return
}
userID := normalizeUserID(c.GetString("user_id"))
runID := c.Query("run_id")
if runID == "" {
c.JSON(http.StatusBadRequest, gin.H{"error": "run_id is required"})
return
}
if _, err := s.ensureBacktestRunOwnership(runID, userID); writeBacktestAccessError(c, err) {
return
}
timeframe := c.Query("tf")
limit := queryInt(c, "limit", 1000)
points, err := s.backtestManager.LoadEquity(runID, timeframe, limit)
if err != nil {
SafeError(c, http.StatusBadRequest, "Failed to load equity data", err)
return
}
c.JSON(http.StatusOK, points)
}
func (s *Server) handleBacktestTrades(c *gin.Context) {
if s.backtestManager == nil {
c.JSON(http.StatusServiceUnavailable, gin.H{"error": "backtest manager unavailable"})
return
}
userID := normalizeUserID(c.GetString("user_id"))
runID := c.Query("run_id")
if runID == "" {
c.JSON(http.StatusBadRequest, gin.H{"error": "run_id is required"})
return
}
if _, err := s.ensureBacktestRunOwnership(runID, userID); writeBacktestAccessError(c, err) {
return
}
limit := queryInt(c, "limit", 1000)
events, err := s.backtestManager.LoadTrades(runID, limit)
if err != nil {
SafeError(c, http.StatusBadRequest, "Failed to load trades", err)
return
}
c.JSON(http.StatusOK, events)
}
func (s *Server) handleBacktestMetrics(c *gin.Context) {
if s.backtestManager == nil {
c.JSON(http.StatusServiceUnavailable, gin.H{"error": "backtest manager unavailable"})
return
}
userID := normalizeUserID(c.GetString("user_id"))
runID := c.Query("run_id")
if runID == "" {
c.JSON(http.StatusBadRequest, gin.H{"error": "run_id is required"})
return
}
if _, err := s.ensureBacktestRunOwnership(runID, userID); writeBacktestAccessError(c, err) {
return
}
metrics, err := s.backtestManager.GetMetrics(runID)
if err != nil {
if errors.Is(err, sql.ErrNoRows) || errors.Is(err, os.ErrNotExist) {
c.JSON(http.StatusAccepted, gin.H{"error": "metrics not ready yet"})
return
}
SafeError(c, http.StatusBadRequest, "Failed to load metrics", err)
return
}
c.JSON(http.StatusOK, metrics)
}
func (s *Server) handleBacktestTrace(c *gin.Context) {
if s.backtestManager == nil {
c.JSON(http.StatusServiceUnavailable, gin.H{"error": "backtest manager unavailable"})
return
}
userID := normalizeUserID(c.GetString("user_id"))
runID := c.Query("run_id")
if runID == "" {
c.JSON(http.StatusBadRequest, gin.H{"error": "run_id is required"})
return
}
if _, err := s.ensureBacktestRunOwnership(runID, userID); writeBacktestAccessError(c, err) {
return
}
cycle := queryInt(c, "cycle", 0)
record, err := s.backtestManager.GetTrace(runID, cycle)
if err != nil {
SafeNotFound(c, "Trace record")
return
}
c.JSON(http.StatusOK, record)
}
func (s *Server) handleBacktestDecisions(c *gin.Context) {
if s.backtestManager == nil {
c.JSON(http.StatusServiceUnavailable, gin.H{"error": "backtest manager unavailable"})
return
}
userID := normalizeUserID(c.GetString("user_id"))
runID := c.Query("run_id")
if runID == "" {
c.JSON(http.StatusBadRequest, gin.H{"error": "run_id is required"})
return
}
if _, err := s.ensureBacktestRunOwnership(runID, userID); writeBacktestAccessError(c, err) {
return
}
limit := queryInt(c, "limit", 20)
offset := queryInt(c, "offset", 0)
if limit <= 0 {
limit = 20
}
if limit > 200 {
limit = 200
}
if offset < 0 {
offset = 0
}
records, err := backtest.LoadDecisionRecords(runID, limit, offset)
if err != nil {
SafeInternalError(c, "Load decision records", err)
return
}
c.JSON(http.StatusOK, records)
}
func (s *Server) handleBacktestExport(c *gin.Context) {
if s.backtestManager == nil {
c.JSON(http.StatusServiceUnavailable, gin.H{"error": "backtest manager unavailable"})
return
}
userID := normalizeUserID(c.GetString("user_id"))
runID := c.Query("run_id")
if runID == "" {
c.JSON(http.StatusBadRequest, gin.H{"error": "run_id is required"})
return
}
if _, err := s.ensureBacktestRunOwnership(runID, userID); writeBacktestAccessError(c, err) {
return
}
path, err := s.backtestManager.ExportRun(runID)
if err != nil {
SafeError(c, http.StatusBadRequest, "Failed to export backtest", err)
return
}
defer os.Remove(path)
filename := fmt.Sprintf("%s_export.zip", runID)
c.FileAttachment(path, filename)
}
func (s *Server) handleBacktestKlines(c *gin.Context) {
if s.backtestManager == nil {
c.JSON(http.StatusServiceUnavailable, gin.H{"error": "backtest manager unavailable"})
return
}
userID := normalizeUserID(c.GetString("user_id"))
runID := c.Query("run_id")
symbol := c.Query("symbol")
timeframe := c.Query("timeframe")
if runID == "" {
c.JSON(http.StatusBadRequest, gin.H{"error": "run_id is required"})
return
}
if symbol == "" {
c.JSON(http.StatusBadRequest, gin.H{"error": "symbol is required"})
return
}
meta, err := s.ensureBacktestRunOwnership(runID, userID)
if writeBacktestAccessError(c, err) {
return
}
// Load config to get time range
cfg, err := backtest.LoadConfig(runID)
if err != nil {
c.JSON(http.StatusNotFound, gin.H{"error": "failed to load backtest config"})
return
}
// Use decision timeframe if not specified
if timeframe == "" {
timeframe = cfg.DecisionTimeframe
if timeframe == "" {
timeframe = "15m"
}
}
// Fetch klines for the backtest time range
startTime := time.Unix(cfg.StartTS, 0)
endTime := time.Unix(cfg.EndTS, 0)
klines, err := market.GetKlinesRange(symbol, timeframe, startTime, endTime)
if err != nil {
SafeInternalError(c, "Fetch klines", err)
return
}
// Convert to response format
type KlineResponse struct {
Time int64 `json:"time"`
Open float64 `json:"open"`
High float64 `json:"high"`
Low float64 `json:"low"`
Close float64 `json:"close"`
Volume float64 `json:"volume"`
}
result := make([]KlineResponse, len(klines))
for i, k := range klines {
result[i] = KlineResponse{
Time: k.OpenTime / 1000, // Convert to seconds for lightweight-charts
Open: k.Open,
High: k.High,
Low: k.Low,
Close: k.Close,
Volume: k.Volume,
}
}
c.JSON(http.StatusOK, gin.H{
"symbol": symbol,
"timeframe": timeframe,
"start_ts": cfg.StartTS,
"end_ts": cfg.EndTS,
"count": len(result),
"klines": result,
"run_id": meta.RunID,
})
}
func queryInt(c *gin.Context, name string, fallback int) int {
if value := c.Query(name); value != "" {
if v, err := strconv.Atoi(value); err == nil {
return v
}
}
return fallback
}
var errBacktestForbidden = errors.New("backtest run forbidden")
func normalizeUserID(id string) string {
id = strings.TrimSpace(id)
if id == "" {
return "default"
}
return id
}
func (s *Server) ensureBacktestRunOwnership(runID, userID string) (*backtest.RunMetadata, error) {
if s.backtestManager == nil {
return nil, fmt.Errorf("backtest manager unavailable")
}
meta, err := s.backtestManager.LoadMetadata(runID)
if err != nil {
return nil, err
}
if userID == "" || userID == "admin" {
return meta, nil
}
owner := strings.TrimSpace(meta.UserID)
if owner == "" {
return meta, nil
}
if owner != userID {
return nil, errBacktestForbidden
}
return meta, nil
}
func writeBacktestAccessError(c *gin.Context, err error) bool {
if err == nil {
return false
}
switch {
case errors.Is(err, errBacktestForbidden):
SafeForbidden(c, "No permission to access this backtest task")
case errors.Is(err, os.ErrNotExist), errors.Is(err, sql.ErrNoRows):
SafeNotFound(c, "Backtest task")
default:
SafeInternalError(c, "Access backtest", err)
}
return true
}
// resolveStrategyCoins fetches coins based on strategy's coin source configuration
func (s *Server) resolveStrategyCoins(strategyConfig *store.StrategyConfig) ([]string, error) {
if strategyConfig == nil {
return nil, fmt.Errorf("strategy config is nil")
}
coinSource := strategyConfig.CoinSource
var symbols []string
symbolSet := make(map[string]bool)
// Handle empty source_type - check flags for backward compatibility
sourceType := coinSource.SourceType
if sourceType == "" {
if coinSource.UseAI500 && coinSource.UseOITop {
sourceType = "mixed"
} else if coinSource.UseAI500 {
sourceType = "ai500"
} else if coinSource.UseOITop {
sourceType = "oi_top"
} else if len(coinSource.StaticCoins) > 0 {
sourceType = "static"
} else {
return nil, fmt.Errorf("strategy has no coin source configured")
}
logger.Infof("📊 Inferred source_type=%s from flags", sourceType)
}
switch sourceType {
case "static":
for _, sym := range coinSource.StaticCoins {
sym = market.Normalize(sym)
if !symbolSet[sym] {
symbols = append(symbols, sym)
symbolSet[sym] = true
}
}
case "ai500":
limit := coinSource.AI500Limit
if limit <= 0 {
limit = 30
}
logger.Infof("📊 Fetching AI500 coins with limit=%d", limit)
coins, err := nofxos.DefaultClient().GetTopRatedCoins(limit)
if err != nil {
return nil, fmt.Errorf("failed to get AI500 coins: %w", err)
}
logger.Infof("📊 Got %d coins from AI500: %v", len(coins), coins)
for _, sym := range coins {
sym = market.Normalize(sym)
if !symbolSet[sym] {
symbols = append(symbols, sym)
symbolSet[sym] = true
}
}
case "oi_top":
coins, err := nofxos.DefaultClient().GetOITopSymbols()
if err != nil {
return nil, fmt.Errorf("failed to get OI Top coins: %w", err)
}
limit := coinSource.OITopLimit
if limit <= 0 || limit > len(coins) {
limit = len(coins)
}
for i, sym := range coins {
if i >= limit {
break
}
sym = market.Normalize(sym)
if !symbolSet[sym] {
symbols = append(symbols, sym)
symbolSet[sym] = true
}
}
case "mixed":
// Get from AI500
if coinSource.UseAI500 {
limit := coinSource.AI500Limit
if limit <= 0 {
limit = 30
}
coins, err := nofxos.DefaultClient().GetTopRatedCoins(limit)
if err != nil {
logger.Warnf("Failed to get AI500 coins: %v", err)
} else {
for _, sym := range coins {
sym = market.Normalize(sym)
if !symbolSet[sym] {
symbols = append(symbols, sym)
symbolSet[sym] = true
}
}
}
}
// Get from OI Top
if coinSource.UseOITop {
coins, err := nofxos.DefaultClient().GetOITopSymbols()
if err != nil {
logger.Warnf("Failed to get OI Top coins: %v", err)
} else {
limit := coinSource.OITopLimit
if limit <= 0 || limit > len(coins) {
limit = len(coins)
}
for i, sym := range coins {
if i >= limit {
break
}
sym = market.Normalize(sym)
if !symbolSet[sym] {
symbols = append(symbols, sym)
symbolSet[sym] = true
}
}
}
}
// Add static coins
for _, sym := range coinSource.StaticCoins {
sym = market.Normalize(sym)
if !symbolSet[sym] {
symbols = append(symbols, sym)
symbolSet[sym] = true
}
}
default:
return nil, fmt.Errorf("unknown coin source type: %s", sourceType)
}
if len(symbols) == 0 {
return nil, fmt.Errorf("no coins resolved from strategy")
}
logger.Infof("📊 Final resolved symbols: %d coins - %v", len(symbols), symbols)
return symbols, nil
}
func (s *Server) resolveBacktestAIConfig(cfg *backtest.BacktestConfig, userID string) error {
if cfg == nil {
return fmt.Errorf("config is nil")
}
if s.store == nil {
return fmt.Errorf("System database not ready, cannot load AI model configuration")
}
cfg.UserID = normalizeUserID(userID)
return s.hydrateBacktestAIConfig(cfg)
}
func (s *Server) hydrateBacktestAIConfig(cfg *backtest.BacktestConfig) error {
if cfg == nil {
return fmt.Errorf("config is nil")
}
if s.store == nil {
return fmt.Errorf("System database not ready, cannot load AI model configuration")
}
cfg.UserID = normalizeUserID(cfg.UserID)
modelID := strings.TrimSpace(cfg.AIModelID)
var (
model *store.AIModel
err error
)
if modelID != "" {
model, err = s.store.AIModel().Get(cfg.UserID, modelID)
if err != nil {
return fmt.Errorf("Failed to load AI model: %w", err)
}
} else {
model, err = s.store.AIModel().GetDefault(cfg.UserID)
if err != nil {
return fmt.Errorf("No available AI model found: %w", err)
}
cfg.AIModelID = model.ID
}
if !model.Enabled {
return fmt.Errorf("AI model %s is not enabled yet", model.Name)
}
apiKey := strings.TrimSpace(string(model.APIKey))
if apiKey == "" {
return fmt.Errorf("AI model %s is missing API Key, please configure it in the system first", model.Name)
}
provider := strings.ToLower(strings.TrimSpace(model.Provider))
// Ensure provider is never empty or "inherit" - infer from model name if needed
if provider == "" || provider == "inherit" {
modelNameLower := strings.ToLower(model.Name)
if strings.Contains(modelNameLower, "claude") || strings.Contains(modelNameLower, "anthropic") {
provider = "anthropic"
} else if strings.Contains(modelNameLower, "gpt") || strings.Contains(modelNameLower, "openai") {
provider = "openai"
} else if strings.Contains(modelNameLower, "gemini") || strings.Contains(modelNameLower, "google") {
provider = "google"
} else if strings.Contains(modelNameLower, "deepseek") {
provider = "deepseek"
} else if strings.Contains(modelNameLower, "minimax") {
provider = "minimax"
} else if model.CustomAPIURL != "" {
provider = "custom"
} else {
provider = "openai" // default fallback
}
logger.Infof("📊 Inferred AI provider '%s' from model name '%s'", provider, model.Name)
}
cfg.AICfg.Provider = provider
cfg.AICfg.APIKey = apiKey
cfg.AICfg.BaseURL = strings.TrimSpace(model.CustomAPIURL)
modelName := strings.TrimSpace(model.CustomModelName)
if cfg.AICfg.Model == "" {
cfg.AICfg.Model = modelName
}
cfg.AICfg.Model = strings.TrimSpace(cfg.AICfg.Model)
if cfg.AICfg.Provider == "custom" {
if cfg.AICfg.BaseURL == "" {
return fmt.Errorf("Custom AI model requires API URL configuration")
}
if cfg.AICfg.Model == "" {
return fmt.Errorf("Custom AI model requires model name configuration")
}
}
return nil
}

View File

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

635
api/debate.go Normal file
View File

@@ -0,0 +1,635 @@
package api
import (
"encoding/json"
"fmt"
"net/http"
"sync"
"nofx/debate"
"nofx/logger"
"nofx/provider/nofxos"
"nofx/store"
"github.com/gin-gonic/gin"
)
// DebateHandler handles debate-related API requests
type DebateHandler struct {
debateStore *store.DebateStore
strategyStore *store.StrategyStore
aiModelStore *store.AIModelStore
engine *debate.DebateEngine
// Trader manager for execution
traderManager DebateTraderManager
// SSE subscribers
subscribers map[string]map[chan []byte]bool // sessionID -> channels
subscribersMu sync.RWMutex
}
// DebateTraderManager interface for getting trader executors
type DebateTraderManager interface {
GetTraderExecutor(traderID string) (debate.TraderExecutor, error)
}
// NewDebateHandler creates a new DebateHandler
func NewDebateHandler(debateStore *store.DebateStore, strategyStore *store.StrategyStore, aiModelStore *store.AIModelStore) *DebateHandler {
handler := &DebateHandler{
debateStore: debateStore,
strategyStore: strategyStore,
aiModelStore: aiModelStore,
subscribers: make(map[string]map[chan []byte]bool),
}
// Create debate engine with event callbacks
handler.engine = debate.NewDebateEngine(debateStore, strategyStore, aiModelStore)
handler.engine.OnRoundStart = handler.broadcastRoundStart
handler.engine.OnMessage = handler.broadcastMessage
handler.engine.OnRoundEnd = handler.broadcastRoundEnd
handler.engine.OnVote = handler.broadcastVote
handler.engine.OnConsensus = handler.broadcastConsensus
handler.engine.OnError = handler.broadcastError
return handler
}
// CreateDebateRequest represents a request to create a new debate
type CreateDebateRequest struct {
Name string `json:"name" binding:"required"`
StrategyID string `json:"strategy_id" binding:"required"`
Symbol string `json:"symbol"` // Optional: auto-selected based on strategy if empty
MaxRounds int `json:"max_rounds"`
IntervalMinutes int `json:"interval_minutes"`
PromptVariant string `json:"prompt_variant"`
AutoExecute bool `json:"auto_execute"`
TraderID string `json:"trader_id"`
Participants []ParticipantConfig `json:"participants" binding:"required,min=2"`
// OI Ranking data options
EnableOIRanking bool `json:"enable_oi_ranking"` // Whether to include OI ranking data
OIRankingLimit int `json:"oi_ranking_limit"` // Number of OI ranking entries (default 10)
OIDuration string `json:"oi_duration"` // Duration for OI data (1h, 4h, 24h, etc.)
}
// ParticipantConfig represents a participant configuration
type ParticipantConfig struct {
AIModelID string `json:"ai_model_id" binding:"required"`
Personality string `json:"personality" binding:"required"`
}
// HandleListDebates lists all debates for a user
func (h *DebateHandler) HandleListDebates(c *gin.Context) {
userID := c.GetString("user_id")
if userID == "" {
c.JSON(http.StatusUnauthorized, gin.H{"error": "unauthorized"})
return
}
sessions, err := h.debateStore.GetSessionsByUser(userID)
if err != nil {
logger.Errorf("Failed to get debates for user %s: %v", userID, err)
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to get debates"})
return
}
// Return empty array instead of null
if sessions == nil {
sessions = []*store.DebateSession{}
}
c.JSON(http.StatusOK, sessions)
}
// HandleGetDebate gets a specific debate with all details
func (h *DebateHandler) HandleGetDebate(c *gin.Context) {
debateID := c.Param("id")
userID := c.GetString("user_id")
session, err := h.debateStore.GetSessionWithDetails(debateID)
if err != nil {
c.JSON(http.StatusNotFound, gin.H{"error": "debate not found"})
return
}
// Check ownership
if session.UserID != userID {
c.JSON(http.StatusForbidden, gin.H{"error": "access denied"})
return
}
c.JSON(http.StatusOK, session)
}
// HandleCreateDebate creates a new debate
func (h *DebateHandler) HandleCreateDebate(c *gin.Context) {
userID := c.GetString("user_id")
if userID == "" {
c.JSON(http.StatusUnauthorized, gin.H{"error": "unauthorized"})
return
}
var req CreateDebateRequest
if err := c.ShouldBindJSON(&req); err != nil {
SafeBadRequest(c, "Invalid request parameters")
return
}
// Validate strategy exists
strategy, err := h.strategyStore.Get(userID, req.StrategyID)
if err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "strategy not found"})
return
}
// Validate strategy belongs to user or is default
if strategy.UserID != userID && !strategy.IsDefault {
c.JSON(http.StatusForbidden, gin.H{"error": "strategy access denied"})
return
}
// Auto-select symbol based on strategy if not provided
if req.Symbol == "" {
req.Symbol = "BTCUSDT" // default fallback
if strategyConfig, err := strategy.ParseConfig(); err == nil {
coinSource := strategyConfig.CoinSource
switch coinSource.SourceType {
case "static":
if len(coinSource.StaticCoins) > 0 {
req.Symbol = coinSource.StaticCoins[0]
}
case "ai500":
// Fetch from AI500 API
if coins, err := nofxos.DefaultClient().GetTopRatedCoins(1); err == nil && len(coins) > 0 {
req.Symbol = coins[0]
logger.Infof("Fetched coin from AI500 API: %s", req.Symbol)
}
case "oi_top":
// Fetch from OI top API
if coins, err := nofxos.DefaultClient().GetOITopSymbols(); err == nil && len(coins) > 0 {
req.Symbol = coins[0]
logger.Infof("Fetched coin from OI Top API: %s", req.Symbol)
}
case "mixed":
// Try AI500 first, then OI top
if coinSource.UseAI500 {
if coins, err := nofxos.DefaultClient().GetTopRatedCoins(1); err == nil && len(coins) > 0 {
req.Symbol = coins[0]
logger.Infof("Fetched coin from AI500 API (mixed): %s", req.Symbol)
}
} else if coinSource.UseOITop {
if coins, err := nofxos.DefaultClient().GetOITopSymbols(); err == nil && len(coins) > 0 {
req.Symbol = coins[0]
logger.Infof("Fetched coin from OI Top API (mixed): %s", req.Symbol)
}
}
}
logger.Infof("Auto-selected symbol %s for debate based on strategy %s (source_type=%s)",
req.Symbol, strategy.Name, coinSource.SourceType)
}
}
// Set defaults
if req.MaxRounds <= 0 || req.MaxRounds > 5 {
req.MaxRounds = 3
}
if req.IntervalMinutes <= 0 {
req.IntervalMinutes = 5
}
if req.PromptVariant == "" {
req.PromptVariant = "balanced"
}
// Create session
session := &store.DebateSession{
UserID: userID,
Name: req.Name,
StrategyID: req.StrategyID,
Symbol: req.Symbol,
MaxRounds: req.MaxRounds,
IntervalMinutes: req.IntervalMinutes,
PromptVariant: req.PromptVariant,
AutoExecute: req.AutoExecute,
TraderID: req.TraderID,
EnableOIRanking: req.EnableOIRanking,
OIRankingLimit: req.OIRankingLimit,
OIDuration: req.OIDuration,
}
if err := h.debateStore.CreateSession(session); err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to create debate"})
return
}
// Add participants
for i, p := range req.Participants {
// Validate AI model exists and belongs to user
aiModel, err := h.aiModelStore.GetByID(p.AIModelID)
if err != nil {
logger.Warnf("AI model not found: %s", p.AIModelID)
continue
}
if aiModel.UserID != userID {
logger.Warnf("AI model %s does not belong to user", p.AIModelID)
continue
}
// Validate personality
personality := store.DebatePersonality(p.Personality)
if _, ok := store.PersonalityColors[personality]; !ok {
personality = store.PersonalityAnalyst
}
participant := &store.DebateParticipant{
SessionID: session.ID,
AIModelID: p.AIModelID,
AIModelName: aiModel.Name,
Provider: aiModel.Provider,
Personality: personality,
Color: store.PersonalityColors[personality],
SpeakOrder: i,
}
if err := h.debateStore.AddParticipant(participant); err != nil {
logger.Errorf("Failed to add participant: %v", err)
}
}
// Get full session with participants
fullSession, _ := h.debateStore.GetSessionWithDetails(session.ID)
c.JSON(http.StatusCreated, fullSession)
}
// HandleStartDebate starts a debate
func (h *DebateHandler) HandleStartDebate(c *gin.Context) {
debateID := c.Param("id")
userID := c.GetString("user_id")
session, err := h.debateStore.GetSession(debateID)
if err != nil {
c.JSON(http.StatusNotFound, gin.H{"error": "debate not found"})
return
}
if session.UserID != userID {
c.JSON(http.StatusForbidden, gin.H{"error": "access denied"})
return
}
if session.Status != store.DebateStatusPending {
c.JSON(http.StatusBadRequest, gin.H{"error": "debate is not in pending status"})
return
}
// Start debate asynchronously
if err := h.engine.StartDebate(debateID); err != nil {
SafeInternalError(c, "Start debate", err)
return
}
c.JSON(http.StatusOK, gin.H{"message": "debate started", "id": debateID})
}
// HandleCancelDebate cancels a running debate
func (h *DebateHandler) HandleCancelDebate(c *gin.Context) {
debateID := c.Param("id")
userID := c.GetString("user_id")
session, err := h.debateStore.GetSession(debateID)
if err != nil {
c.JSON(http.StatusNotFound, gin.H{"error": "debate not found"})
return
}
if session.UserID != userID {
c.JSON(http.StatusForbidden, gin.H{"error": "access denied"})
return
}
if err := h.engine.CancelDebate(debateID); err != nil {
SafeInternalError(c, "Cancel debate", err)
return
}
c.JSON(http.StatusOK, gin.H{"message": "debate cancelled"})
}
// HandleDeleteDebate deletes a debate
func (h *DebateHandler) HandleDeleteDebate(c *gin.Context) {
debateID := c.Param("id")
userID := c.GetString("user_id")
session, err := h.debateStore.GetSession(debateID)
if err != nil {
c.JSON(http.StatusNotFound, gin.H{"error": "debate not found"})
return
}
if session.UserID != userID {
c.JSON(http.StatusForbidden, gin.H{"error": "access denied"})
return
}
// Don't allow deleting running debates
if session.Status == store.DebateStatusRunning || session.Status == store.DebateStatusVoting {
c.JSON(http.StatusBadRequest, gin.H{"error": "cannot delete running debate"})
return
}
if err := h.debateStore.DeleteSession(debateID); err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to delete debate"})
return
}
c.JSON(http.StatusOK, gin.H{"message": "debate deleted"})
}
// HandleGetMessages gets all messages for a debate
func (h *DebateHandler) HandleGetMessages(c *gin.Context) {
debateID := c.Param("id")
userID := c.GetString("user_id")
session, err := h.debateStore.GetSession(debateID)
if err != nil {
c.JSON(http.StatusNotFound, gin.H{"error": "debate not found"})
return
}
if session.UserID != userID {
c.JSON(http.StatusForbidden, gin.H{"error": "access denied"})
return
}
messages, err := h.debateStore.GetMessages(debateID)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to get messages"})
return
}
c.JSON(http.StatusOK, messages)
}
// HandleGetVotes gets all votes for a debate
func (h *DebateHandler) HandleGetVotes(c *gin.Context) {
debateID := c.Param("id")
userID := c.GetString("user_id")
session, err := h.debateStore.GetSession(debateID)
if err != nil {
c.JSON(http.StatusNotFound, gin.H{"error": "debate not found"})
return
}
if session.UserID != userID {
c.JSON(http.StatusForbidden, gin.H{"error": "access denied"})
return
}
votes, err := h.debateStore.GetVotes(debateID)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to get votes"})
return
}
c.JSON(http.StatusOK, votes)
}
// HandleDebateStream handles SSE streaming for live debate updates
func (h *DebateHandler) HandleDebateStream(c *gin.Context) {
debateID := c.Param("id")
userID := c.GetString("user_id")
session, err := h.debateStore.GetSession(debateID)
if err != nil {
c.JSON(http.StatusNotFound, gin.H{"error": "debate not found"})
return
}
if session.UserID != userID {
c.JSON(http.StatusForbidden, gin.H{"error": "access denied"})
return
}
// Set SSE headers
c.Header("Content-Type", "text/event-stream")
c.Header("Cache-Control", "no-cache")
c.Header("Connection", "keep-alive")
c.Header("Transfer-Encoding", "chunked")
// Create channel for this subscriber
ch := make(chan []byte, 100)
h.addSubscriber(debateID, ch)
defer h.removeSubscriber(debateID, ch)
// Send initial state
initialState, _ := h.debateStore.GetSessionWithDetails(debateID)
initialData, _ := json.Marshal(map[string]interface{}{
"event": "initial",
"data": initialState,
})
c.Writer.Write([]byte(fmt.Sprintf("event: initial\ndata: %s\n\n", initialData)))
c.Writer.Flush()
// Stream updates
clientGone := c.Request.Context().Done()
for {
select {
case <-clientGone:
return
case msg := <-ch:
c.Writer.Write(msg)
c.Writer.Flush()
}
}
}
// SetTraderManager sets the trader manager for executing trades
func (h *DebateHandler) SetTraderManager(tm DebateTraderManager) {
h.traderManager = tm
}
// ExecuteDebateRequest represents a request to execute a debate's consensus
type ExecuteDebateRequest struct {
TraderID string `json:"trader_id" binding:"required"`
}
// HandleExecuteDebate executes the consensus decision from a completed debate
func (h *DebateHandler) HandleExecuteDebate(c *gin.Context) {
debateID := c.Param("id")
userID := c.GetString("user_id")
// Check trader manager is available
if h.traderManager == nil {
c.JSON(http.StatusServiceUnavailable, gin.H{"error": "trading service not available"})
return
}
// Get debate session
session, err := h.debateStore.GetSession(debateID)
if err != nil {
c.JSON(http.StatusNotFound, gin.H{"error": "debate not found"})
return
}
// Check ownership
if session.UserID != userID {
c.JSON(http.StatusForbidden, gin.H{"error": "access denied"})
return
}
// Check status
if session.Status != store.DebateStatusCompleted {
c.JSON(http.StatusBadRequest, gin.H{"error": "debate is not completed"})
return
}
// Parse request
var req ExecuteDebateRequest
if err := c.ShouldBindJSON(&req); err != nil {
SafeBadRequest(c, "Invalid request parameters")
return
}
// Get trader executor
executor, err := h.traderManager.GetTraderExecutor(req.TraderID)
if err != nil {
SafeError(c, http.StatusBadRequest, "Trader not available", err)
return
}
// Execute consensus
if err := h.engine.ExecuteConsensus(debateID, executor); err != nil {
SafeInternalError(c, "Execute consensus", err)
return
}
// Get updated session
updatedSession, _ := h.debateStore.GetSessionWithDetails(debateID)
c.JSON(http.StatusOK, gin.H{
"message": "consensus executed successfully",
"session": updatedSession,
})
}
// GetPersonalities returns available AI personalities
func (h *DebateHandler) HandleGetPersonalities(c *gin.Context) {
personalities := []map[string]interface{}{
{
"id": "bull",
"name": "Aggressive Bull",
"emoji": "🐂",
"color": store.PersonalityColors[store.PersonalityBull],
"description": "Looks for long opportunities, optimistic about market",
},
{
"id": "bear",
"name": "Cautious Bear",
"emoji": "🐻",
"color": store.PersonalityColors[store.PersonalityBear],
"description": "Skeptical, focuses on risks and short opportunities",
},
{
"id": "analyst",
"name": "Data Analyst",
"emoji": "📊",
"color": store.PersonalityColors[store.PersonalityAnalyst],
"description": "Pure technical analysis, neutral and data-driven",
},
{
"id": "contrarian",
"name": "Contrarian",
"emoji": "🔄",
"color": store.PersonalityColors[store.PersonalityContrarian],
"description": "Challenges majority opinion, looks for overlooked opportunities",
},
{
"id": "risk_manager",
"name": "Risk Manager",
"emoji": "🛡️",
"color": store.PersonalityColors[store.PersonalityRiskManager],
"description": "Focuses on position sizing, stop losses, and risk control",
},
}
c.JSON(http.StatusOK, personalities)
}
// SSE broadcast helpers
func (h *DebateHandler) addSubscriber(sessionID string, ch chan []byte) {
h.subscribersMu.Lock()
defer h.subscribersMu.Unlock()
if h.subscribers[sessionID] == nil {
h.subscribers[sessionID] = make(map[chan []byte]bool)
}
h.subscribers[sessionID][ch] = true
}
func (h *DebateHandler) removeSubscriber(sessionID string, ch chan []byte) {
h.subscribersMu.Lock()
defer h.subscribersMu.Unlock()
if h.subscribers[sessionID] != nil {
delete(h.subscribers[sessionID], ch)
close(ch)
}
}
func (h *DebateHandler) broadcast(sessionID string, event string, data interface{}) {
h.subscribersMu.RLock()
defer h.subscribersMu.RUnlock()
subs := h.subscribers[sessionID]
if subs == nil {
return
}
jsonData, err := json.Marshal(data)
if err != nil {
return
}
msg := []byte(fmt.Sprintf("event: %s\ndata: %s\n\n", event, jsonData))
for ch := range subs {
select {
case ch <- msg:
default:
// Channel full, skip
}
}
}
func (h *DebateHandler) broadcastRoundStart(sessionID string, round int) {
h.broadcast(sessionID, "round_start", map[string]interface{}{
"round": round,
"status": "running",
})
}
func (h *DebateHandler) broadcastMessage(sessionID string, msg *store.DebateMessage) {
h.broadcast(sessionID, "message", msg)
}
func (h *DebateHandler) broadcastRoundEnd(sessionID string, round int) {
h.broadcast(sessionID, "round_end", map[string]interface{}{
"round": round,
"status": "completed",
})
}
func (h *DebateHandler) broadcastVote(sessionID string, vote *store.DebateVote) {
h.broadcast(sessionID, "vote", vote)
}
func (h *DebateHandler) broadcastConsensus(sessionID string, decision *store.DebateDecision) {
h.broadcast(sessionID, "consensus", decision)
}
func (h *DebateHandler) broadcastError(sessionID string, err error) {
// Sanitize error message before broadcasting to client
safeMsg := SanitizeError(err, "An error occurred during debate")
h.broadcast(sessionID, "error", map[string]interface{}{
"error": safeMsg,
})
}

View File

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

View File

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

View File

@@ -1,43 +0,0 @@
package api
import (
"net/http"
"github.com/gin-gonic/gin"
)
// handleGetAICosts returns AI charges for a specific trader
func (s *Server) handleGetAICosts(c *gin.Context) {
traderID := c.Query("trader_id")
period := c.DefaultQuery("period", "today")
if traderID == "" {
c.JSON(http.StatusBadRequest, gin.H{"error": "trader_id is required"})
return
}
charges, total, err := s.store.AICharge().GetCharges(traderID, period)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
c.JSON(http.StatusOK, gin.H{
"charges": charges,
"total": total,
"count": len(charges),
})
}
// handleGetAICostsSummary returns AI cost summary across all traders
func (s *Server) handleGetAICostsSummary(c *gin.Context) {
period := c.DefaultQuery("period", "today")
total, count, byModel := s.store.AICharge().GetSummary(period)
c.JSON(http.StatusOK, gin.H{
"total": total,
"count": count,
"by_model": byModel,
})
}

View File

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

View File

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

View File

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

View File

@@ -1,45 +0,0 @@
package api
import (
"testing"
"nofx/crypto"
"nofx/store"
)
func TestSafeExchangeConfigFromStoreIncludesCredentialPresenceFlags(t *testing.T) {
cfg := &store.Exchange{
ID: "ex-1",
ExchangeType: "okx",
AccountName: "OKX Main",
Name: "OKX Main",
Type: "cex",
Enabled: true,
APIKey: crypto.EncryptedString("api-test-123"),
SecretKey: crypto.EncryptedString("secret-test-123"),
Passphrase: crypto.EncryptedString("passphrase-test-123"),
AsterPrivateKey: crypto.EncryptedString("aster-private-key"),
LighterPrivateKey: crypto.EncryptedString("lighter-private-key"),
LighterAPIKeyPrivateKey: crypto.EncryptedString("lighter-api-key-private-key"),
}
safe := safeExchangeConfigFromStore(cfg)
if !safe.HasAPIKey {
t.Fatalf("expected has_api_key to be true")
}
if !safe.HasSecretKey {
t.Fatalf("expected has_secret_key to be true")
}
if !safe.HasPassphrase {
t.Fatalf("expected has_passphrase to be true")
}
if !safe.HasAsterPrivateKey {
t.Fatalf("expected has_aster_private_key to be true")
}
if !safe.HasLighterPrivateKey {
t.Fatalf("expected has_lighter_private_key to be true")
}
if !safe.HasLighterAPIKey {
t.Fatalf("expected has_lighter_api_key_private_key to be true")
}
}

View File

@@ -1,311 +0,0 @@
package api
import (
"bytes"
"encoding/json"
"fmt"
"io"
"net/http"
"strconv"
"strings"
"time"
"github.com/gin-gonic/gin"
)
const (
defaultHyperliquidBuilderAddress = "0x891dc6f05ad47a3c1a05da55e7a7517971faaf0d"
// 0.05% (万5) — matches BuilderInfo.Fee=50 charged at order placement.
// New wallet approvals sign this exact value; existing approvals at the
// prior 0.1% cap remain valid because 0.05% is within their approved max.
defaultHyperliquidBuilderMaxFee = "0.05%"
hyperliquidExchangeURL = "https://api.hyperliquid.xyz/exchange"
hyperliquidInfoURL = "https://api.hyperliquid.xyz/info"
)
type hyperliquidSubmitRequest struct {
Action map[string]any `json:"action" binding:"required"`
Nonce int64 `json:"nonce" binding:"required"`
Signature struct {
R string `json:"r" binding:"required"`
S string `json:"s" binding:"required"`
V int `json:"v"`
} `json:"signature" binding:"required"`
}
type hyperliquidConfigResponse struct {
BuilderAddress string `json:"builderAddress"`
BuilderMaxFee string `json:"builderMaxFee"`
Chain string `json:"chain"`
SignatureChain string `json:"signatureChainId"`
}
type hyperliquidAccountSummary struct {
Address string `json:"address"`
AccountValue float64 `json:"accountValue"`
Withdrawable float64 `json:"withdrawable"`
TotalMarginUsed float64 `json:"totalMarginUsed"`
UnrealizedPnl float64 `json:"unrealizedPnl"`
OpenPositions int `json:"openPositions"`
UpdatedAt int64 `json:"updatedAt"`
}
type hyperliquidClearinghouseState struct {
MarginSummary struct {
AccountValue string `json:"accountValue"`
TotalMarginUsed string `json:"totalMarginUsed"`
} `json:"marginSummary"`
CrossMarginSummary struct {
AccountValue string `json:"accountValue"`
TotalMarginUsed string `json:"totalMarginUsed"`
} `json:"crossMarginSummary"`
Withdrawable string `json:"withdrawable"`
AssetPositions []struct {
Position struct {
Szi string `json:"szi"`
UnrealizedPnl string `json:"unrealizedPnl"`
} `json:"position"`
} `json:"assetPositions"`
}
func hyperliquidBuilderAddress() string {
return defaultHyperliquidBuilderAddress
}
func hyperliquidBuilderMaxFee() string {
return defaultHyperliquidBuilderMaxFee
}
func (s *Server) handleHyperliquidConnectConfig(c *gin.Context) {
c.JSON(http.StatusOK, hyperliquidConfigResponse{
BuilderAddress: hyperliquidBuilderAddress(),
BuilderMaxFee: hyperliquidBuilderMaxFee(),
Chain: "Mainnet",
SignatureChain: "0x66eee",
})
}
func (s *Server) handleHyperliquidAccount(c *gin.Context) {
address := strings.ToLower(strings.TrimSpace(c.Query("address")))
if !isEVMAddress(address) {
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid Hyperliquid wallet address"})
return
}
requestBody := map[string]any{
"type": "clearinghouseState",
"user": address,
}
body, err := json.Marshal(requestBody)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to encode Hyperliquid balance request"})
return
}
req, err := http.NewRequestWithContext(c.Request.Context(), http.MethodPost, hyperliquidInfoURL, bytes.NewReader(body))
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to create Hyperliquid balance request"})
return
}
req.Header.Set("Content-Type", "application/json")
client := &http.Client{Timeout: 20 * time.Second}
resp, err := client.Do(req)
if err != nil {
c.JSON(http.StatusBadGateway, gin.H{"error": "failed to reach Hyperliquid", "detail": err.Error()})
return
}
defer resp.Body.Close()
respBody, _ := io.ReadAll(io.LimitReader(resp.Body, 1<<20))
if resp.StatusCode < 200 || resp.StatusCode >= 300 {
c.JSON(http.StatusBadGateway, gin.H{"error": "Hyperliquid rejected the balance request", "status": resp.StatusCode})
return
}
var state hyperliquidClearinghouseState
if err := json.Unmarshal(respBody, &state); err != nil {
c.JSON(http.StatusBadGateway, gin.H{"error": "failed to parse Hyperliquid balance response"})
return
}
accountValue := parseFloatOrZero(state.MarginSummary.AccountValue)
if accountValue == 0 {
accountValue = parseFloatOrZero(state.CrossMarginSummary.AccountValue)
}
marginUsed := parseFloatOrZero(state.MarginSummary.TotalMarginUsed)
if marginUsed == 0 {
marginUsed = parseFloatOrZero(state.CrossMarginSummary.TotalMarginUsed)
}
var unrealizedPnl float64
openPositions := 0
for _, position := range state.AssetPositions {
size := parseFloatOrZero(position.Position.Szi)
if size != 0 {
openPositions++
}
unrealizedPnl += parseFloatOrZero(position.Position.UnrealizedPnl)
}
c.JSON(http.StatusOK, hyperliquidAccountSummary{
Address: address,
AccountValue: accountValue,
Withdrawable: parseFloatOrZero(state.Withdrawable),
TotalMarginUsed: marginUsed,
UnrealizedPnl: unrealizedPnl,
OpenPositions: openPositions,
UpdatedAt: time.Now().UnixMilli(),
})
}
func (s *Server) handleHyperliquidSubmitExchange(c *gin.Context) {
var req hyperliquidSubmitRequest
if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid Hyperliquid submit payload"})
return
}
if err := validateSubmittedNonce(req.Action, req.Nonce); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
actionType, _ := req.Action["type"].(string)
switch actionType {
case "approveAgent":
if err := validateApproveAgentAction(req.Action); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
case "approveBuilderFee":
if err := validateApproveBuilderFeeAction(req.Action); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
default:
c.JSON(http.StatusBadRequest, gin.H{"error": "unsupported Hyperliquid action"})
return
}
payload := map[string]any{
"action": req.Action,
"nonce": req.Nonce,
"signature": req.Signature,
}
body, err := json.Marshal(payload)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to encode Hyperliquid payload"})
return
}
client := &http.Client{Timeout: 20 * time.Second}
hlReq, err := http.NewRequestWithContext(c.Request.Context(), http.MethodPost, hyperliquidExchangeURL, bytes.NewReader(body))
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to create Hyperliquid request"})
return
}
hlReq.Header.Set("Content-Type", "application/json")
resp, err := client.Do(hlReq)
if err != nil {
c.JSON(http.StatusBadGateway, gin.H{"error": "failed to reach Hyperliquid", "detail": err.Error()})
return
}
defer resp.Body.Close()
respBody, _ := io.ReadAll(io.LimitReader(resp.Body, 1<<20))
var decoded any
if len(respBody) > 0 {
_ = json.Unmarshal(respBody, &decoded)
}
if resp.StatusCode < 200 || resp.StatusCode >= 300 {
c.JSON(http.StatusBadGateway, gin.H{"error": "Hyperliquid rejected the action", "status": resp.StatusCode, "response": decoded})
return
}
c.JSON(http.StatusOK, gin.H{"success": true, "response": decoded})
}
func validateApproveAgentAction(action map[string]any) error {
if strings.TrimSpace(fmt.Sprint(action["agentAddress"])) == "" {
return fmt.Errorf("missing agentAddress")
}
if strings.TrimSpace(fmt.Sprint(action["agentName"])) == "" {
return fmt.Errorf("missing agentName")
}
return validateCommonHyperliquidSignedAction(action)
}
func validateApproveBuilderFeeAction(action map[string]any) error {
builder := strings.ToLower(strings.TrimSpace(fmt.Sprint(action["builder"])))
if builder != hyperliquidBuilderAddress() {
return fmt.Errorf("builder address mismatch")
}
if strings.TrimSpace(fmt.Sprint(action["maxFeeRate"])) != hyperliquidBuilderMaxFee() {
return fmt.Errorf("builder max fee mismatch")
}
return validateCommonHyperliquidSignedAction(action)
}
func validateCommonHyperliquidSignedAction(action map[string]any) error {
if strings.TrimSpace(fmt.Sprint(action["signatureChainId"])) != "0x66eee" {
return fmt.Errorf("invalid signatureChainId")
}
if strings.TrimSpace(fmt.Sprint(action["hyperliquidChain"])) != "Mainnet" {
return fmt.Errorf("invalid hyperliquidChain")
}
if _, err := actionNonce(action); err != nil {
return err
}
return nil
}
func validateSubmittedNonce(action map[string]any, submitted int64) error {
actionValue, err := actionNonce(action)
if err != nil {
return err
}
if actionValue != submitted {
return fmt.Errorf("nonce mismatch")
}
return nil
}
func isEVMAddress(address string) bool {
if len(address) != 42 || !strings.HasPrefix(address, "0x") {
return false
}
for _, char := range address[2:] {
if (char < '0' || char > '9') && (char < 'a' || char > 'f') {
return false
}
}
return true
}
func parseFloatOrZero(value string) float64 {
parsed, err := strconv.ParseFloat(strings.TrimSpace(value), 64)
if err != nil {
return 0
}
return parsed
}
func actionNonce(action map[string]any) (int64, error) {
raw, ok := action["nonce"]
if !ok {
return 0, fmt.Errorf("missing nonce")
}
switch value := raw.(type) {
case float64:
return int64(value), nil
case int64:
return value, nil
case json.Number:
return value.Int64()
case string:
return strconv.ParseInt(value, 10, 64)
default:
return 0, fmt.Errorf("invalid nonce")
}
}

View File

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

View File

@@ -1,344 +0,0 @@
package api
import (
"bufio"
"encoding/hex"
"fmt"
"net/http"
"os"
"path/filepath"
"strings"
"nofx/logger"
"nofx/mcp/payment"
"nofx/wallet"
gethcrypto "github.com/ethereum/go-ethereum/crypto"
"github.com/gin-gonic/gin"
)
type beginnerOnboardingResponse struct {
Address string `json:"address"`
PrivateKey string `json:"private_key"`
Chain string `json:"chain"`
Asset string `json:"asset"`
Provider string `json:"provider"`
DefaultModel string `json:"default_model"`
ConfiguredModelID string `json:"configured_model_id"`
BalanceUSDC string `json:"balance_usdc"`
EnvSaved bool `json:"env_saved"`
EnvPath string `json:"env_path,omitempty"`
ReusedExisting bool `json:"reused_existing"`
EnvWarning string `json:"env_warning,omitempty"`
}
type currentBeginnerWalletResponse struct {
Found bool `json:"found"`
Address string `json:"address,omitempty"`
BalanceUSDC string `json:"balance_usdc,omitempty"`
Source string `json:"source,omitempty"`
Claw402Status string `json:"claw402_status"`
}
func (s *Server) handleBeginnerOnboarding(c *gin.Context) {
userID := c.GetString("user_id")
if userID == "" {
c.JSON(http.StatusUnauthorized, gin.H{"error": "missing user context"})
return
}
privateKey, address, configuredModelID, reusedExisting, err := s.resolveBeginnerWallet(userID)
if err != nil {
logger.Errorf("Failed to resolve beginner wallet for user %s: %v", userID, err)
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to prepare beginner wallet"})
return
}
if !reusedExisting {
if err := s.store.AIModel().Update(userID, "claw402", true, privateKey, "", payment.DefaultClaw402Model); err != nil {
logger.Errorf("Failed to save beginner claw402 config for user %s: %v", userID, err)
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to save beginner model configuration"})
return
}
configuredModelID, err = s.findConfiguredClaw402ModelID(userID)
if err != nil {
logger.Warnf("Could not resolve configured claw402 model id for user %s: %v", userID, err)
}
}
os.Setenv("CLAW402_WALLET_KEY", privateKey)
os.Setenv("CLAW402_WALLET_ADDRESS", address)
os.Setenv("CLAW402_DEFAULT_MODEL", payment.DefaultClaw402Model)
envSaved, envPath, envErr := persistBeginnerWalletEnv(privateKey, address)
resp := beginnerOnboardingResponse{
Address: address,
PrivateKey: privateKey,
Chain: "base",
Asset: "USDC",
Provider: "claw402",
DefaultModel: payment.DefaultClaw402Model,
ConfiguredModelID: configuredModelID,
BalanceUSDC: wallet.QueryUSDCBalanceStr(address),
EnvSaved: envSaved,
EnvPath: envPath,
ReusedExisting: reusedExisting,
}
if envErr != nil {
resp.EnvWarning = envErr.Error()
logger.Warnf("Beginner wallet env persistence warning for user %s: %v", userID, envErr)
}
c.JSON(http.StatusOK, resp)
}
func (s *Server) handleCurrentBeginnerWallet(c *gin.Context) {
userID := c.GetString("user_id")
if userID == "" {
c.JSON(http.StatusUnauthorized, gin.H{"error": "missing user context"})
return
}
claw402Status := checkClaw402Health()
models, err := s.store.AIModel().List(userID)
if err != nil {
logger.Errorf("Failed to load current beginner wallet for user %s: %v", userID, err)
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to load current wallet"})
return
}
for _, model := range models {
if model == nil || model.Provider != "claw402" {
continue
}
privateKey := strings.TrimSpace(model.APIKey.String())
if privateKey == "" {
continue
}
address, addrErr := walletAddressFromPrivateKey(privateKey)
if addrErr != nil {
logger.Warnf("Failed to derive current beginner wallet for user %s: %v", userID, addrErr)
continue
}
c.JSON(http.StatusOK, currentBeginnerWalletResponse{
Found: true,
Address: address,
BalanceUSDC: wallet.QueryUSDCBalanceStr(address),
Source: "model",
Claw402Status: claw402Status,
})
return
}
address := strings.TrimSpace(os.Getenv("CLAW402_WALLET_ADDRESS"))
if address != "" {
c.JSON(http.StatusOK, currentBeginnerWalletResponse{
Found: true,
Address: address,
BalanceUSDC: wallet.QueryUSDCBalanceStr(address),
Source: "env",
Claw402Status: claw402Status,
})
return
}
c.JSON(http.StatusOK, currentBeginnerWalletResponse{
Found: false,
Claw402Status: claw402Status,
})
}
func (s *Server) resolveBeginnerWallet(userID string) (privateKey string, address string, configuredModelID string, reused bool, err error) {
// 1. Check if current user already has a claw402 wallet
models, err := s.store.AIModel().List(userID)
if err != nil {
return "", "", "", false, err
}
for _, model := range models {
if model == nil || model.Provider != "claw402" {
continue
}
existingKey := strings.TrimSpace(model.APIKey.String())
if existingKey == "" {
continue
}
addr, addrErr := walletAddressFromPrivateKey(existingKey)
if addrErr != nil {
logger.Warnf("Existing claw402 key for user %s is invalid, regenerating: %v", userID, addrErr)
break
}
return existingKey, addr, model.ID, true, nil
}
// 2. Check for orphan claw402 wallet from a previous account (e.g. after account reset).
// Adopt it to preserve funds.
orphan, orphanErr := s.store.AIModel().FindOrphanClaw402()
if orphanErr == nil && orphan != nil {
existingKey := strings.TrimSpace(orphan.APIKey.String())
if existingKey != "" {
addr, addrErr := walletAddressFromPrivateKey(existingKey)
if addrErr == nil {
if adoptErr := s.store.AIModel().AdoptModel(orphan.ID, userID); adoptErr != nil {
logger.Warnf("Failed to adopt orphan claw402 wallet for user %s: %v", userID, adoptErr)
} else {
logger.Infof("✓ Adopted orphan claw402 wallet %s for new user %s (address: %s)", orphan.ID, userID, addr)
return existingKey, addr, orphan.ID, true, nil
}
}
}
}
// 3. No existing wallet found — generate a new one
privateKeyObj, genErr := gethcrypto.GenerateKey()
if genErr != nil {
return "", "", "", false, genErr
}
addr := gethcrypto.PubkeyToAddress(privateKeyObj.PublicKey)
keyHex := "0x" + hex.EncodeToString(gethcrypto.FromECDSA(privateKeyObj))
return keyHex, addr.Hex(), "", false, nil
}
func (s *Server) findConfiguredClaw402ModelID(userID string) (string, error) {
models, err := s.store.AIModel().List(userID)
if err != nil {
return "", err
}
for _, model := range models {
if model != nil && model.Provider == "claw402" {
return model.ID, nil
}
}
return "", fmt.Errorf("claw402 model not found")
}
func walletAddressFromPrivateKey(privateKey string) (string, error) {
key := strings.TrimSpace(privateKey)
if !strings.HasPrefix(key, "0x") {
return "", fmt.Errorf("private key must start with 0x")
}
if len(key) != 66 {
return "", fmt.Errorf("private key must be 66 characters")
}
privateKeyObj, err := gethcrypto.HexToECDSA(strings.TrimPrefix(key, "0x"))
if err != nil {
return "", err
}
return gethcrypto.PubkeyToAddress(privateKeyObj.PublicKey).Hex(), nil
}
func persistBeginnerWalletEnv(privateKey string, address string) (bool, string, error) {
paths := uniqueEnvPaths([]string{
".env",
filepath.Join(".", ".env"),
"/app/.env",
})
var lastErr error
for _, path := range paths {
if path == "" {
continue
}
if err := upsertEnvFile(path, map[string]string{
"CLAW402_WALLET_KEY": privateKey,
"CLAW402_WALLET_ADDRESS": address,
"CLAW402_DEFAULT_MODEL": payment.DefaultClaw402Model,
}); err != nil {
lastErr = err
continue
}
return true, path, nil
}
if lastErr == nil {
lastErr = fmt.Errorf("no writable .env path found")
}
return false, "", lastErr
}
func uniqueEnvPaths(paths []string) []string {
seen := make(map[string]struct{}, len(paths))
result := make([]string, 0, len(paths))
for _, path := range paths {
clean := filepath.Clean(path)
if _, ok := seen[clean]; ok {
continue
}
seen[clean] = struct{}{}
result = append(result, clean)
}
return result
}
func upsertEnvFile(path string, values map[string]string) error {
if err := os.MkdirAll(filepath.Dir(path), 0755); err != nil {
return err
}
existingLines := make([]string, 0)
if file, err := os.Open(path); err == nil {
scanner := bufio.NewScanner(file)
for scanner.Scan() {
existingLines = append(existingLines, scanner.Text())
}
file.Close()
if err := scanner.Err(); err != nil {
return err
}
} else if !os.IsNotExist(err) {
return err
}
remaining := make(map[string]string, len(values))
for key, value := range values {
remaining[key] = value
}
updatedLines := make([]string, 0, len(existingLines)+len(values))
for _, line := range existingLines {
trimmed := strings.TrimSpace(line)
if trimmed == "" || strings.HasPrefix(trimmed, "#") || !strings.Contains(line, "=") {
updatedLines = append(updatedLines, line)
continue
}
parts := strings.SplitN(line, "=", 2)
key := strings.TrimSpace(parts[0])
value, ok := remaining[key]
if !ok {
updatedLines = append(updatedLines, line)
continue
}
updatedLines = append(updatedLines, fmt.Sprintf("%s=%s", key, value))
delete(remaining, key)
}
for key, value := range remaining {
updatedLines = append(updatedLines, fmt.Sprintf("%s=%s", key, value))
}
content := strings.Join(updatedLines, "\n")
if content != "" && !strings.HasSuffix(content, "\n") {
content += "\n"
}
if err := os.WriteFile(path, []byte(content), 0600); err != nil {
return err
}
return nil
}

View File

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

View File

@@ -1,105 +0,0 @@
package api
import (
"net/http"
"github.com/gin-gonic/gin"
)
// handleGetTelegramConfig returns current Telegram bot configuration and binding status
func (s *Server) handleGetTelegramConfig(c *gin.Context) {
cfg, err := s.store.TelegramConfig().Get()
if err != nil {
// Not configured yet - return empty state
c.JSON(http.StatusOK, gin.H{
"configured": false,
"is_bound": false,
"token_masked": "",
"username": "",
})
return
}
// Mask bot token for security (show only last 6 chars)
tokenMasked := ""
if cfg.BotToken != "" {
if len(cfg.BotToken) > 6 {
tokenMasked = "***" + cfg.BotToken[len(cfg.BotToken)-6:]
} else {
tokenMasked = "***"
}
}
c.JSON(http.StatusOK, gin.H{
"configured": cfg.BotToken != "",
"is_bound": cfg.ChatID != 0,
"username": cfg.Username,
"bound_at": cfg.BoundAt,
"token_masked": tokenMasked,
"model_id": cfg.ModelID,
})
}
// handleUpdateTelegramConfig saves bot token (+ optional model ID) and triggers bot hot-reload
func (s *Server) handleUpdateTelegramConfig(c *gin.Context) {
var req struct {
BotToken string `json:"bot_token"`
ModelID string `json:"model_id"`
}
if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid request"})
return
}
if req.BotToken == "" {
c.JSON(http.StatusBadRequest, gin.H{"error": "bot_token is required"})
return
}
if err := s.store.TelegramConfig().Save(req.BotToken, req.ModelID); err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to save config"})
return
}
// Signal bot hot-reload if channel is available
if s.telegramReloadCh != nil {
select {
case s.telegramReloadCh <- struct{}{}:
default: // non-blocking
}
}
c.JSON(http.StatusOK, gin.H{"success": true, "message": "Bot token saved. Bot will reload automatically."})
}
// handleUnbindTelegram removes Telegram user binding
func (s *Server) handleUnbindTelegram(c *gin.Context) {
if err := s.store.TelegramConfig().Unbind(); err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to unbind"})
return
}
c.JSON(http.StatusOK, gin.H{"success": true, "message": "Telegram binding removed"})
}
// handleUpdateTelegramModel updates only the AI model used for Telegram replies (no token re-entry needed)
func (s *Server) handleUpdateTelegramModel(c *gin.Context) {
var req struct {
ModelID string `json:"model_id"`
}
if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid request"})
return
}
cfg, err := s.store.TelegramConfig().Get()
if err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "no Telegram config found, save a bot token first"})
return
}
if err := s.store.TelegramConfig().Save(cfg.BotToken, req.ModelID); err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to save model config"})
return
}
c.JSON(http.StatusOK, gin.H{"success": true, "model_id": req.ModelID})
}

View File

@@ -1,905 +0,0 @@
package api
import (
"errors"
"fmt"
"net/http"
"strings"
"time"
"nofx/logger"
"nofx/store"
"github.com/gin-gonic/gin"
"gorm.io/gorm"
)
const (
maxManualBTCETHLeverage = 20
maxManualAltLeverage = 20
)
// AI trader management related structures
type CreateTraderRequest struct {
Name string `json:"name" binding:"required"`
AIModelID string `json:"ai_model_id" binding:"required"`
ExchangeID string `json:"exchange_id" binding:"required"`
StrategyID string `json:"strategy_id"` // Strategy ID (new version)
InitialBalance float64 `json:"initial_balance"`
ScanIntervalMinutes int `json:"scan_interval_minutes"`
IsCrossMargin *bool `json:"is_cross_margin"` // Pointer type, nil means use default value true
ShowInCompetition *bool `json:"show_in_competition"` // Pointer type, nil means use default value true
// The following fields are kept for backward compatibility, new version uses strategy config
BTCETHLeverage int `json:"btc_eth_leverage"`
AltcoinLeverage int `json:"altcoin_leverage"`
TradingSymbols string `json:"trading_symbols"`
CustomPrompt string `json:"custom_prompt"`
OverrideBasePrompt bool `json:"override_base_prompt"`
SystemPromptTemplate string `json:"system_prompt_template"` // System prompt template name
UseAI500 bool `json:"use_ai500"`
UseOITop bool `json:"use_oi_top"`
}
// UpdateTraderRequest Update trader request
type UpdateTraderRequest struct {
Name string `json:"name" binding:"required"`
AIModelID string `json:"ai_model_id" binding:"required"`
ExchangeID string `json:"exchange_id" binding:"required"`
StrategyID string `json:"strategy_id"` // Strategy ID (new version)
InitialBalance float64 `json:"initial_balance"`
ScanIntervalMinutes int `json:"scan_interval_minutes"`
IsCrossMargin *bool `json:"is_cross_margin"`
ShowInCompetition *bool `json:"show_in_competition"`
// The following fields are kept for backward compatibility, new version uses strategy config
BTCETHLeverage int `json:"btc_eth_leverage"`
AltcoinLeverage int `json:"altcoin_leverage"`
TradingSymbols string `json:"trading_symbols"`
CustomPrompt string `json:"custom_prompt"`
OverrideBasePrompt bool `json:"override_base_prompt"`
SystemPromptTemplate string `json:"system_prompt_template"`
}
func formatTraderCreationError(reason, nextStep string) string {
if nextStep == "" {
return fmt.Sprintf("这次未能创建机器人:%s。", reason)
}
return fmt.Sprintf("这次未能创建机器人:%s。%s。", reason, nextStep)
}
func traderCreationRequestError(reason string) string {
return formatTraderCreationError(reason, "请检查你刚刚填写的内容后,再重新提交")
}
func validateTraderLeverageRange(btcEthLeverage, altcoinLeverage int) (string, string) {
if btcEthLeverage < 0 || btcEthLeverage > maxManualBTCETHLeverage {
return traderCreationRequestError("BTC/ETH 杠杆倍数需要在 1 到 20 倍之间"), "trader.create.invalid_btc_eth_leverage"
}
if altcoinLeverage < 0 || altcoinLeverage > maxManualAltLeverage {
return traderCreationRequestError("山寨币杠杆倍数需要在 1 到 20 倍之间"), "trader.create.invalid_altcoin_leverage"
}
return "", ""
}
func isSupportedTraderSymbol(symbol string) bool {
normalized := strings.ToUpper(strings.TrimSpace(symbol))
if normalized == "" {
return true
}
return strings.HasSuffix(normalized, "USDT") || strings.HasSuffix(normalized, "-USDC") || strings.HasPrefix(normalized, "XYZ:")
}
func exchangeDisplayName(exchange *store.Exchange) string {
if exchange == nil {
return "所选交易所账户"
}
if exchange.AccountName != "" {
return fmt.Sprintf("%s%s", exchange.Name, exchange.AccountName)
}
if exchange.Name != "" {
return exchange.Name
}
return "所选交易所账户"
}
func missingExchangeFields(exchange *store.Exchange) []string {
if exchange == nil {
return nil
}
var missing []string
switch exchange.ExchangeType {
case "binance", "bybit", "gate", "indodax":
if exchange.APIKey == "" {
missing = append(missing, "API Key")
}
if exchange.SecretKey == "" {
missing = append(missing, "Secret Key")
}
case "okx", "bitget", "kucoin":
if exchange.APIKey == "" {
missing = append(missing, "API Key")
}
if exchange.SecretKey == "" {
missing = append(missing, "Secret Key")
}
if exchange.Passphrase == "" {
missing = append(missing, "Passphrase")
}
case "hyperliquid":
if exchange.APIKey == "" {
missing = append(missing, "私钥")
}
if strings.TrimSpace(exchange.HyperliquidWalletAddr) == "" {
missing = append(missing, "钱包地址")
}
case "aster":
if strings.TrimSpace(exchange.AsterUser) == "" {
missing = append(missing, "Aster User")
}
if strings.TrimSpace(exchange.AsterSigner) == "" {
missing = append(missing, "Aster Signer")
}
if exchange.AsterPrivateKey == "" {
missing = append(missing, "Aster Private Key")
}
case "lighter":
if strings.TrimSpace(exchange.LighterWalletAddr) == "" {
missing = append(missing, "钱包地址")
}
if exchange.LighterAPIKeyPrivateKey == "" {
missing = append(missing, "API Key Private Key")
}
}
return missing
}
func mapStringPairs(kv ...string) map[string]string {
if len(kv) == 0 {
return nil
}
params := make(map[string]string, len(kv)/2)
for i := 0; i+1 < len(kv); i += 2 {
params[kv[i]] = kv[i+1]
}
return params
}
func validateExchangeForTraderCreation(exchange *store.Exchange) (string, string, map[string]string) {
if exchange == nil {
return formatTraderCreationError("还没有找到你选择的交易所账户", "请前往「设置 > 交易所配置」先添加一个可用账户,再回来创建机器人"),
"trader.create.exchange_not_found", nil
}
if !exchange.Enabled {
return formatTraderCreationError(
fmt.Sprintf("交易所账户「%s」目前处于未启用状态", exchangeDisplayName(exchange)),
"请前往「设置 > 交易所配置」启用该账户后,再重新创建机器人",
), "trader.create.exchange_disabled", mapStringPairs("exchange_name", exchangeDisplayName(exchange))
}
missing := missingExchangeFields(exchange)
if len(missing) > 0 {
return formatTraderCreationError(
fmt.Sprintf("交易所账户「%s」的配置还不完整缺少 %s", exchangeDisplayName(exchange), strings.Join(missing, "、")),
"请前往「设置 > 交易所配置」补全该账户的必填信息后,再重新创建机器人",
), "trader.create.exchange_missing_fields", mapStringPairs(
"exchange_name", exchangeDisplayName(exchange),
"missing_fields", strings.Join(missing, ", "),
)
}
switch exchange.ExchangeType {
case "binance", "bybit", "okx", "bitget", "gate", "kucoin", "hyperliquid", "aster", "lighter", "indodax":
return "", "", nil
default:
return formatTraderCreationError(
fmt.Sprintf("交易所账户「%s」使用了当前版本暂不支持的类型 %s", exchangeDisplayName(exchange), exchange.ExchangeType),
"请改用当前版本支持的交易所账户后,再重新创建机器人",
), "trader.create.exchange_unsupported", mapStringPairs(
"exchange_name", exchangeDisplayName(exchange),
"exchange_type", exchange.ExchangeType,
)
}
}
func classifyTraderSetupReason(reason string) (string, string) {
trimmed := strings.TrimSpace(reason)
if trimmed == "" {
return "", ""
}
lower := strings.ToLower(trimmed)
switch {
case strings.Contains(lower, "failed to parse strategy config"),
strings.Contains(lower, "failed to parse strategy configuration"):
return "trader.reason.strategy_config_invalid", "当前策略配置内容已损坏,系统暂时无法解析"
case strings.Contains(lower, "has no strategy configured"):
return "trader.reason.strategy_missing", "当前机器人缺少有效的交易策略配置"
case strings.Contains(lower, "failed to parse private key"),
(strings.Contains(lower, "invalid hex character") && strings.Contains(lower, "private key")):
return "trader.reason.private_key_invalid", "私钥格式不正确,系统无法识别"
case strings.Contains(lower, "failed to initialize hyperliquid trader"):
return "trader.reason.hyperliquid_init_failed", "Hyperliquid 账户初始化失败,请确认私钥、主钱包地址和 Agent Wallet 配置是否正确"
case strings.Contains(lower, "failed to initialize aster trader"):
return "trader.reason.aster_init_failed", "Aster 账户初始化失败,请确认 Aster User、Signer 和私钥是否正确"
case strings.Contains(lower, "failed to get meta information"):
return "trader.reason.exchange_meta_unavailable", "系统暂时无法从交易所读取账户元信息"
case strings.Contains(lower, "security check failed") && strings.Contains(lower, "agent wallet balance too high"):
return "trader.reason.hyperliquid_agent_balance_too_high", "Hyperliquid Agent Wallet 余额过高,不符合当前安全要求"
case strings.Contains(lower, "failed to initialize account"):
return "trader.reason.exchange_account_init_failed", "交易所账户初始化失败,请确认钱包地址和 API Key 是否匹配"
case strings.Contains(lower, "unsupported trading platform"):
return "trader.reason.exchange_unsupported", "当前交易所类型暂不支持机器人初始化"
case strings.Contains(lower, "initial balance not set and unable to fetch balance from exchange"):
return "trader.reason.exchange_balance_unavailable", "系统暂时无法从交易所读取账户余额"
case strings.Contains(lower, "timeout"), strings.Contains(lower, "no such host"), strings.Contains(lower, "connection refused"):
return "trader.reason.exchange_service_unreachable", "系统暂时无法连接交易所服务"
default:
return "trader.reason.unknown", trimmed
}
}
func humanizeTraderSetupReason(reason string) string {
_, message := classifyTraderSetupReason(reason)
return message
}
func traderSetupReasonParams(err error, fallback string, kv ...string) map[string]string {
params := mapStringPairs(kv...)
rawReason := SanitizeError(err, fallback)
reasonKey, reasonMessage := classifyTraderSetupReason(rawReason)
if reasonMessage == "" && fallback != "" {
reasonMessage = fallback
}
if reasonMessage != "" {
if params == nil {
params = map[string]string{}
}
params["reason"] = reasonMessage
}
if reasonKey != "" {
if params == nil {
params = map[string]string{}
}
params["reason_key"] = reasonKey
}
return params
}
func describeTraderLoadError(traderName string, err error) string {
if err == nil {
return formatTraderCreationError("机器人配置虽然保存了,但运行实例没有成功初始化", "请检查模型、策略和交易所配置是否完整,然后再试一次")
}
reason := humanizeTraderSetupReason(SanitizeError(err, ""))
if reason == "" {
return formatTraderCreationError(
fmt.Sprintf("机器人「%s」在初始化运行实例时没有成功启动", traderName),
"请检查模型、策略和交易所配置是否完整,然后再试一次",
)
}
return formatTraderCreationError(
fmt.Sprintf("机器人「%s」在初始化运行实例时没有成功启动原因是%s", traderName, reason),
"请检查模型、策略和交易所配置是否完整,然后再试一次",
)
}
func describeTraderCreationWarning(traderName string, err error) string {
if err == nil {
return fmt.Sprintf("机器人「%s」已经保存但当前还没有通过启动前校验。请先检查模型、策略和交易所配置修正后再点击启动。", traderName)
}
reason := humanizeTraderSetupReason(SanitizeError(err, ""))
if reason == "" {
return fmt.Sprintf("机器人「%s」已经保存但当前暂时还不能启动。请先检查模型、策略和交易所配置修正后再点击启动。", traderName)
}
return fmt.Sprintf("机器人「%s」已经保存但当前暂时还不能启动原因是%s。请先检查模型、策略和交易所配置修正后再点击启动。", traderName, reason)
}
func describeTraderStartError(traderName string, err error) string {
if err == nil {
return fmt.Sprintf("这次未能启动机器人:机器人「%s」暂时还不能启动。请检查模型、策略和交易所配置后再重新点击启动。", traderName)
}
reason := humanizeTraderSetupReason(SanitizeError(err, ""))
if reason == "" {
return fmt.Sprintf("这次未能启动机器人:机器人「%s」暂时还不能启动。请检查模型、策略和交易所配置后再重新点击启动。", traderName)
}
return fmt.Sprintf("这次未能启动机器人:机器人「%s」暂时还不能启动原因是%s。请检查模型、策略和交易所配置后再重新点击启动。", traderName, reason)
}
func formatTraderStartError(reason, nextStep string) string {
if nextStep == "" {
return fmt.Sprintf("这次未能启动机器人:%s。", reason)
}
return fmt.Sprintf("这次未能启动机器人:%s。%s。", reason, nextStep)
}
// handleCreateTrader Create new AI trader
func (s *Server) handleCreateTrader(c *gin.Context) {
userID := c.GetString("user_id")
var req CreateTraderRequest
if err := c.ShouldBindJSON(&req); err != nil {
SafeBadRequestWithDetails(c, traderCreationRequestError("提交的信息不完整,或者格式不正确"), "trader.create.invalid_request", nil)
return
}
// Validate leverage values against the same limits exposed by manual user config.
if errMsg, errCode := validateTraderLeverageRange(req.BTCETHLeverage, req.AltcoinLeverage); errMsg != "" {
SafeBadRequestWithDetails(c, errMsg, errCode, nil)
return
}
// Validate trading symbol format. Hyperliquid xyz dex markets (stocks,
// commodities, indices, FX, Pre-IPO) are user-facing SYMBOL-USDC pairs,
// while standard crypto/perp markets keep the legacy USDT suffix format.
if req.TradingSymbols != "" {
symbols := strings.Split(req.TradingSymbols, ",")
for _, symbol := range symbols {
symbol = strings.TrimSpace(symbol)
if !isSupportedTraderSymbol(symbol) {
SafeBadRequestWithDetails(c, traderCreationRequestError(
fmt.Sprintf("交易对 %s 的格式不正确,目前只支持 USDT 合约或 Hyperliquid XYZ USDC 标的SYMBOL-USDC", symbol),
), "trader.create.invalid_symbol", mapStringPairs("symbol", symbol))
return
}
}
}
model, err := s.store.AIModel().Get(userID, req.AIModelID)
if err != nil {
if errors.Is(err, gorm.ErrRecordNotFound) {
SafeBadRequestWithDetails(c, formatTraderCreationError("还没有找到你选择的 AI 模型", "请前往「设置 > 模型配置」先添加并启用一个可用模型,再回来创建机器人"), "trader.create.model_not_found", nil)
return
}
SafeError(c, http.StatusInternalServerError,
formatTraderCreationError("暂时无法读取你的 AI 模型配置", "请稍后重试;如果问题持续,再检查本地服务是否正常"),
err,
)
return
}
if !model.Enabled {
SafeBadRequestWithDetails(c, formatTraderCreationError(
fmt.Sprintf("AI 模型「%s」目前还没有启用", model.Name),
"请前往「设置 > 模型配置」启用它后,再重新创建机器人",
), "trader.create.model_disabled", mapStringPairs("model_name", model.Name))
return
}
if model.APIKey == "" {
SafeBadRequestWithDetails(c, formatTraderCreationError(
fmt.Sprintf("AI 模型「%s」缺少 API Key 或支付凭证", model.Name),
"请前往「设置 > 模型配置」补全模型凭证后,再重新创建机器人",
), "trader.create.model_missing_credentials", mapStringPairs("model_name", model.Name))
return
}
if req.StrategyID == "" {
SafeBadRequestWithDetails(c, formatTraderCreationError("你还没有选择交易策略", "请先选择一个策略,再继续创建机器人"), "trader.create.strategy_required", nil)
return
}
if req.StrategyID != "" {
_, err = s.store.Strategy().Get(userID, req.StrategyID)
if err != nil {
if errors.Is(err, gorm.ErrRecordNotFound) {
SafeBadRequestWithDetails(c, formatTraderCreationError("你选择的策略不存在,或者已经被删除了", "请重新选择一个可用策略后,再继续创建机器人"), "trader.create.strategy_not_found", nil)
return
}
SafeError(c, http.StatusInternalServerError,
formatTraderCreationError("暂时无法读取你选择的策略配置", "请稍后重试;如果问题持续,再检查本地服务是否正常"),
err,
)
return
}
}
// Generate trader ID (use short UUID prefix for readability)
exchangeIDShort := req.ExchangeID
if len(exchangeIDShort) > 8 {
exchangeIDShort = exchangeIDShort[:8]
}
traderID := fmt.Sprintf("%s_%s_%d", exchangeIDShort, req.AIModelID, time.Now().Unix())
// Set default values
isCrossMargin := true // Default to cross margin mode
if req.IsCrossMargin != nil {
isCrossMargin = *req.IsCrossMargin
}
showInCompetition := true // Default to show in competition
if req.ShowInCompetition != nil {
showInCompetition = *req.ShowInCompetition
}
// Set leverage default values
btcEthLeverage := 10 // Default value
altcoinLeverage := 5 // Default value
if req.BTCETHLeverage > 0 {
btcEthLeverage = req.BTCETHLeverage
}
if req.AltcoinLeverage > 0 {
altcoinLeverage = req.AltcoinLeverage
}
// Set system prompt template default value
systemPromptTemplate := "default"
if req.SystemPromptTemplate != "" {
systemPromptTemplate = req.SystemPromptTemplate
}
// Set scan interval default value
scanIntervalMinutes := req.ScanIntervalMinutes
if scanIntervalMinutes < 3 {
scanIntervalMinutes = 3 // Default 3 minutes, not allowed to be less than 3
}
// Query exchange actual balance, override user input
actualBalance := req.InitialBalance // Default to use user input
exchanges, err := s.store.Exchange().List(userID)
if err != nil {
SafeError(c, http.StatusInternalServerError,
formatTraderCreationError("暂时无法读取你的交易所配置", "请稍后重试;如果问题持续,再检查本地服务是否正常"),
err,
)
return
}
// Find matching exchange configuration
var exchangeCfg *store.Exchange
for _, ex := range exchanges {
if ex.ID == req.ExchangeID {
exchangeCfg = ex
break
}
}
if exchangeMsg, exchangeErrorKey, exchangeErrorParams := validateExchangeForTraderCreation(exchangeCfg); exchangeMsg != "" {
SafeBadRequestWithDetails(c, exchangeMsg, exchangeErrorKey, exchangeErrorParams)
return
}
{
tempTrader, createErr := buildExchangeProbeTrader(exchangeCfg, userID)
if createErr != nil {
SafeBadRequestWithDetails(c, formatTraderCreationError(
fmt.Sprintf("交易所账户「%s」没有通过初始化校验原因是%s", exchangeDisplayName(exchangeCfg), humanizeTraderSetupReason(SanitizeError(createErr, "配置校验未通过"))),
"请前往「设置 > 交易所配置」检查这个账户的密钥、地址和账户信息是否填写正确",
), "trader.create.exchange_probe_failed", traderSetupReasonParams(createErr, "配置校验未通过",
"exchange_name", exchangeDisplayName(exchangeCfg),
))
return
} else if tempTrader != nil {
// Query actual balance
balanceInfo, balanceErr := tempTrader.GetBalance()
if balanceErr != nil {
logger.Infof("⚠️ Failed to query exchange balance, using user input for initial balance: %v", balanceErr)
} else {
if extractedBalance, found := extractExchangeTotalEquity(balanceInfo); found {
actualBalance = extractedBalance
logger.Infof("✓ Queried exchange total equity: %.2f %s (user input: %.2f)",
actualBalance, accountAssetForExchange(exchangeCfg.ExchangeType), req.InitialBalance)
} else {
logger.Infof("⚠️ Unable to extract total equity from balance info, balanceInfo=%v, using user input for initial balance", balanceInfo)
}
}
}
}
// Create trader configuration (database entity)
logger.Infof("🔧 DEBUG: Starting to create trader config, ID=%s, Name=%s, AIModel=%s, Exchange=%s, StrategyID=%s", traderID, req.Name, req.AIModelID, req.ExchangeID, req.StrategyID)
traderRecord := &store.Trader{
ID: traderID,
UserID: userID,
Name: req.Name,
AIModelID: req.AIModelID,
ExchangeID: req.ExchangeID,
StrategyID: req.StrategyID, // Associated strategy ID (new version)
InitialBalance: actualBalance, // Use actual queried balance
BTCETHLeverage: btcEthLeverage,
AltcoinLeverage: altcoinLeverage,
TradingSymbols: req.TradingSymbols,
UseAI500: req.UseAI500,
UseOITop: req.UseOITop,
CustomPrompt: req.CustomPrompt,
OverrideBasePrompt: req.OverrideBasePrompt,
SystemPromptTemplate: systemPromptTemplate,
IsCrossMargin: isCrossMargin,
ShowInCompetition: showInCompetition,
ScanIntervalMinutes: scanIntervalMinutes,
IsRunning: false,
}
// Save to database
logger.Infof("🔧 DEBUG: Preparing to call CreateTrader")
err = s.store.Trader().Create(traderRecord)
if err != nil {
logger.Infof("❌ Failed to create trader: %v", err)
publicMsg := SanitizeError(err, formatTraderCreationError("机器人配置没有保存成功", "请检查名称、模型、策略和交易所配置后,再试一次"))
statusCode := http.StatusBadRequest
if publicMsg == formatTraderCreationError("机器人配置没有保存成功", "请检查名称、模型、策略和交易所配置后,再试一次") {
statusCode = http.StatusInternalServerError
}
SafeError(c, statusCode, publicMsg, err)
return
}
logger.Infof("🔧 DEBUG: CreateTrader succeeded")
// Immediately load new trader into TraderManager
logger.Infof("🔧 DEBUG: Preparing to call LoadUserTraders")
startupWarning := ""
err = s.traderManager.LoadUserTradersFromStore(s.store, userID)
if err != nil {
logger.Infof("⚠️ Failed to load user traders into memory: %v", err)
startupWarning = describeTraderCreationWarning(req.Name, err)
}
logger.Infof("🔧 DEBUG: LoadUserTraders completed")
if startupWarning == "" {
if loadErr := s.traderManager.GetLoadError(traderID); loadErr != nil {
logger.Infof("⚠️ Trader %s failed to load after creation: %v", traderID, loadErr)
startupWarning = describeTraderCreationWarning(req.Name, loadErr)
}
}
if startupWarning == "" {
if _, getErr := s.traderManager.GetTrader(traderID); getErr != nil {
logger.Infof("⚠️ Trader %s not found in memory after creation: %v", traderID, getErr)
startupWarning = describeTraderCreationWarning(req.Name, getErr)
}
}
logger.Infof("✓ Trader created successfully: %s (model: %s, exchange: %s)", req.Name, req.AIModelID, req.ExchangeID)
c.JSON(http.StatusCreated, gin.H{
"trader_id": traderID,
"trader_name": req.Name,
"ai_model": req.AIModelID,
"is_running": false,
"startup_warning": startupWarning,
})
}
// handleUpdateTrader Update trader configuration
func (s *Server) handleUpdateTrader(c *gin.Context) {
userID := c.GetString("user_id")
traderID := c.Param("id")
var req UpdateTraderRequest
if err := c.ShouldBindJSON(&req); err != nil {
SafeBadRequest(c, "Invalid request parameters")
return
}
// Check if trader exists and belongs to current user
traders, err := s.store.Trader().List(userID)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to get trader list"})
return
}
var existingTrader *store.Trader
for _, t := range traders {
if t.ID == traderID {
existingTrader = t
break
}
}
if existingTrader == nil {
c.JSON(http.StatusNotFound, gin.H{"error": "Trader does not exist"})
return
}
if errMsg, errCode := validateTraderLeverageRange(req.BTCETHLeverage, req.AltcoinLeverage); errMsg != "" {
SafeBadRequestWithDetails(c, errMsg, errCode, nil)
return
}
// Set default values
isCrossMargin := existingTrader.IsCrossMargin // Keep original value
if req.IsCrossMargin != nil {
isCrossMargin = *req.IsCrossMargin
}
showInCompetition := existingTrader.ShowInCompetition // Keep original value
if req.ShowInCompetition != nil {
showInCompetition = *req.ShowInCompetition
}
// Set leverage default values
btcEthLeverage := req.BTCETHLeverage
altcoinLeverage := req.AltcoinLeverage
if btcEthLeverage <= 0 {
btcEthLeverage = existingTrader.BTCETHLeverage // Keep original value
}
if altcoinLeverage <= 0 {
altcoinLeverage = existingTrader.AltcoinLeverage // Keep original value
}
// Set scan interval, allow updates
scanIntervalMinutes := req.ScanIntervalMinutes
logger.Infof("📊 Update trader scan_interval: req=%d, existing=%d", req.ScanIntervalMinutes, existingTrader.ScanIntervalMinutes)
if scanIntervalMinutes <= 0 {
scanIntervalMinutes = existingTrader.ScanIntervalMinutes // Keep original value
} else if scanIntervalMinutes < 3 {
scanIntervalMinutes = 3
}
logger.Infof("📊 Final scan_interval_minutes: %d", scanIntervalMinutes)
// Set system prompt template
systemPromptTemplate := req.SystemPromptTemplate
if systemPromptTemplate == "" {
systemPromptTemplate = existingTrader.SystemPromptTemplate // Keep original value
}
// Handle strategy ID (if not provided, keep original value)
strategyID := req.StrategyID
if strategyID == "" {
strategyID = existingTrader.StrategyID
}
exchangeChanged := req.ExchangeID != "" && req.ExchangeID != existingTrader.ExchangeID
resetInitialBalance := exchangeChanged && req.InitialBalance <= 0
initialBalance := existingTrader.InitialBalance
if req.InitialBalance > 0 {
initialBalance = req.InitialBalance
}
if resetInitialBalance {
initialBalance = 0
}
// Update trader configuration
traderRecord := &store.Trader{
ID: traderID,
UserID: userID,
Name: req.Name,
AIModelID: req.AIModelID,
ExchangeID: req.ExchangeID,
StrategyID: strategyID, // Associated strategy ID
InitialBalance: initialBalance,
BTCETHLeverage: btcEthLeverage,
AltcoinLeverage: altcoinLeverage,
TradingSymbols: req.TradingSymbols,
CustomPrompt: req.CustomPrompt,
OverrideBasePrompt: req.OverrideBasePrompt,
SystemPromptTemplate: systemPromptTemplate,
IsCrossMargin: isCrossMargin,
ShowInCompetition: showInCompetition,
ScanIntervalMinutes: scanIntervalMinutes,
IsRunning: existingTrader.IsRunning, // Keep original value
}
// Check if trader was running before update (we'll restart it after)
wasRunning := false
if existingMemTrader, memErr := s.traderManager.GetTrader(traderID); memErr == nil {
status := existingMemTrader.GetStatus()
if running, ok := status["is_running"].(bool); ok && running {
wasRunning = true
logger.Infof("🔄 Trader %s was running, will restart with new config after update", traderID)
}
}
// Update database
logger.Infof("🔄 Updating trader: ID=%s, Name=%s, AIModelID=%s, StrategyID=%s, ScanInterval=%d min",
traderRecord.ID, traderRecord.Name, traderRecord.AIModelID, traderRecord.StrategyID, scanIntervalMinutes)
err = s.store.Trader().Update(traderRecord)
if err != nil {
SafeInternalError(c, "Failed to update trader", err)
return
}
if resetInitialBalance {
logger.Infof("🔄 Exchange changed for trader %s, resetting stale initial_balance to 0", traderID)
if err := s.store.Trader().UpdateInitialBalance(userID, traderID, 0); err != nil {
SafeInternalError(c, "Failed to reset trader initial balance", err)
return
}
}
// Remove old trader from memory first (this also stops if running)
s.traderManager.RemoveTrader(traderID)
// Reload traders into memory with fresh config
err = s.traderManager.LoadUserTradersFromStore(s.store, userID)
if err != nil {
logger.Infof("⚠️ Failed to reload user traders into memory: %v", err)
}
// If trader was running before, restart it with new config
if wasRunning {
if reloadedTrader, getErr := s.traderManager.GetTrader(traderID); getErr == nil {
go func() {
logger.Infof("▶️ Restarting trader %s with new config...", traderID)
if runErr := reloadedTrader.Run(); runErr != nil {
logger.Infof("❌ Trader %s runtime error: %v", traderID, runErr)
}
}()
}
}
logger.Infof("✓ Trader updated successfully: %s (model: %s, exchange: %s, strategy: %s)", req.Name, req.AIModelID, req.ExchangeID, strategyID)
c.JSON(http.StatusOK, gin.H{
"trader_id": traderID,
"trader_name": req.Name,
"ai_model": req.AIModelID,
"message": "Trader updated successfully",
})
}
// handleDeleteTrader Delete trader
func (s *Server) handleDeleteTrader(c *gin.Context) {
userID := c.GetString("user_id")
traderID := c.Param("id")
// Delete from database
err := s.store.Trader().Delete(userID, traderID)
if err != nil {
SafeInternalError(c, "Failed to delete trader", err)
return
}
// If trader is running, stop it first
if trader, err := s.traderManager.GetTrader(traderID); err == nil {
status := trader.GetStatus()
if isRunning, ok := status["is_running"].(bool); ok && isRunning {
trader.Stop()
logger.Infof("⏹ Stopped running trader: %s", traderID)
}
}
// Remove trader from memory
s.traderManager.RemoveTrader(traderID)
logger.Infof("✓ Trader deleted: %s", traderID)
c.JSON(http.StatusOK, gin.H{"message": "Trader deleted"})
}
// handleStartTrader Start trader
func (s *Server) handleStartTrader(c *gin.Context) {
userID := c.GetString("user_id")
traderID := c.Param("id")
// Verify trader belongs to current user
fullCfg, err := s.store.Trader().GetFullConfig(userID, traderID)
if err != nil {
c.JSON(http.StatusNotFound, gin.H{"error": "Trader does not exist or no access permission"})
return
}
traderName := traderID
if fullCfg != nil && fullCfg.Trader != nil && fullCfg.Trader.Name != "" {
traderName = fullCfg.Trader.Name
}
if fullCfg != nil && fullCfg.Exchange != nil && fullCfg.Exchange.ExchangeType == "hyperliquid" && !fullCfg.Exchange.HyperliquidBuilderApproved {
SafeBadRequestWithDetails(c, formatTraderStartError(
fmt.Sprintf("机器人「%s」的 Hyperliquid 交易授权尚未完成", traderName),
"请重新连接 Hyperliquid 钱包并完成交易授权后,再启动机器人",
), "trader.start.hyperliquid_builder_not_approved", mapStringPairs("trader_name", traderName, "exchange_name", exchangeDisplayName(fullCfg.Exchange)))
return
}
// Check if trader exists in memory and if it's running
existingTrader, _ := s.traderManager.GetTrader(traderID)
if existingTrader != nil {
status := existingTrader.GetStatus()
if isRunning, ok := status["is_running"].(bool); ok && isRunning {
c.JSON(http.StatusBadRequest, gin.H{"error": "Trader is already running"})
return
}
// Trader exists but is stopped - remove from memory to reload fresh config
logger.Infof("🔄 Removing stopped trader %s from memory to reload config...", traderID)
s.traderManager.RemoveTrader(traderID)
}
// Load trader from database (always reload to get latest config)
logger.Infof("🔄 Loading trader %s from database...", traderID)
if loadErr := s.traderManager.LoadUserTradersFromStore(s.store, userID); loadErr != nil {
logger.Infof("❌ Failed to load user traders: %v", loadErr)
SafeErrorWithDetails(c, http.StatusInternalServerError, describeTraderStartError(traderName, loadErr), "trader.start.load_failed", traderSetupReasonParams(loadErr, "", "trader_name", traderName), loadErr)
return
}
trader, err := s.traderManager.GetTrader(traderID)
if err != nil {
if fullCfg != nil && fullCfg.Trader != nil {
// Check strategy
if fullCfg.Strategy == nil {
SafeBadRequestWithDetails(c, describeTraderStartError(traderName, fmt.Errorf("trader has no strategy configured")), "trader.start.strategy_missing", mapStringPairs("trader_name", traderName))
return
}
// Check AI model
if fullCfg.AIModel == nil {
SafeBadRequestWithDetails(c, formatTraderStartError("这个机器人关联的 AI 模型不存在", "请前往「设置 > 模型配置」检查后,再重新点击启动"), "trader.start.model_not_found", mapStringPairs("trader_name", traderName))
return
}
if !fullCfg.AIModel.Enabled {
SafeBadRequestWithDetails(c, formatTraderStartError(
fmt.Sprintf("机器人「%s」关联的 AI 模型「%s」目前还没有启用", traderName, fullCfg.AIModel.Name),
"请前往「设置 > 模型配置」启用它后,再重新点击启动",
), "trader.start.model_disabled", mapStringPairs("trader_name", traderName, "model_name", fullCfg.AIModel.Name))
return
}
// Check exchange
if fullCfg.Exchange == nil {
SafeBadRequestWithDetails(c, formatTraderStartError("这个机器人关联的交易所账户不存在", "请前往「设置 > 交易所配置」检查后,再重新点击启动"), "trader.start.exchange_not_found", mapStringPairs("trader_name", traderName))
return
}
if !fullCfg.Exchange.Enabled {
SafeBadRequestWithDetails(c, formatTraderStartError(
fmt.Sprintf("机器人「%s」关联的交易所账户「%s」目前还没有启用", traderName, exchangeDisplayName(fullCfg.Exchange)),
"请前往「设置 > 交易所配置」启用它后,再重新点击启动",
), "trader.start.exchange_disabled", mapStringPairs("trader_name", traderName, "exchange_name", exchangeDisplayName(fullCfg.Exchange)))
return
}
}
// Check if there's a specific load error
if loadErr := s.traderManager.GetLoadError(traderID); loadErr != nil {
SafeBadRequestWithDetails(c, describeTraderStartError(traderName, loadErr), "trader.start.load_failed", traderSetupReasonParams(loadErr, "", "trader_name", traderName))
return
}
SafeBadRequestWithDetails(c, describeTraderStartError(traderName, err), "trader.start.setup_invalid", traderSetupReasonParams(err, "", "trader_name", traderName))
return
}
// Start trader
go func() {
logger.Infof("▶️ Starting trader %s (%s)", traderID, trader.GetName())
if err := trader.Run(); err != nil {
logger.Infof("❌ Trader %s runtime error: %v", trader.GetName(), err)
}
}()
// Update running status in database
err = s.store.Trader().UpdateStatus(userID, traderID, true)
if err != nil {
logger.Infof("⚠️ Failed to update trader status: %v", err)
}
logger.Infof("✓ Trader %s started", trader.GetName())
c.JSON(http.StatusOK, gin.H{"message": "Trader started"})
}
// handleStopTrader Stop trader
func (s *Server) handleStopTrader(c *gin.Context) {
userID := c.GetString("user_id")
traderID := c.Param("id")
// Verify trader belongs to current user
_, err := s.store.Trader().GetFullConfig(userID, traderID)
if err != nil {
c.JSON(http.StatusNotFound, gin.H{"error": "Trader does not exist or no access permission"})
return
}
trader, err := s.traderManager.GetTrader(traderID)
if err != nil {
c.JSON(http.StatusNotFound, gin.H{"error": "Trader does not exist"})
return
}
// Check if trader is running
status := trader.GetStatus()
if isRunning, ok := status["is_running"].(bool); ok && !isRunning {
c.JSON(http.StatusBadRequest, gin.H{"error": "Trader is already stopped"})
return
}
// Stop trader
trader.Stop()
// Update running status in database
err = s.store.Trader().UpdateStatus(userID, traderID, false)
if err != nil {
logger.Infof("⚠️ Failed to update trader status: %v", err)
}
logger.Infof("⏹ Trader %s stopped", trader.GetName())
c.JSON(http.StatusOK, gin.H{"message": "Trader stopped"})
}

View File

@@ -1,79 +0,0 @@
package api
import (
"net/http"
"nofx/logger"
"github.com/gin-gonic/gin"
)
// handleUpdateTraderPrompt Update trader custom prompt
func (s *Server) handleUpdateTraderPrompt(c *gin.Context) {
traderID := c.Param("id")
userID := c.GetString("user_id")
var req struct {
CustomPrompt string `json:"custom_prompt"`
OverrideBasePrompt bool `json:"override_base_prompt"`
}
if err := c.ShouldBindJSON(&req); err != nil {
SafeBadRequest(c, "Invalid request parameters")
return
}
// Update database
err := s.store.Trader().UpdateCustomPrompt(userID, traderID, req.CustomPrompt, req.OverrideBasePrompt)
if err != nil {
SafeInternalError(c, "Failed to update custom prompt", err)
return
}
// If trader is in memory, update its custom prompt and override settings
trader, err := s.traderManager.GetTrader(traderID)
if err == nil {
trader.SetCustomPrompt(req.CustomPrompt)
trader.SetOverrideBasePrompt(req.OverrideBasePrompt)
logger.Infof("✓ Updated trader %s custom prompt (override base=%v)", trader.GetName(), req.OverrideBasePrompt)
}
c.JSON(http.StatusOK, gin.H{"message": "Custom prompt updated"})
}
// handleToggleCompetition Toggle trader competition visibility
func (s *Server) handleToggleCompetition(c *gin.Context) {
traderID := c.Param("id")
userID := c.GetString("user_id")
var req struct {
ShowInCompetition bool `json:"show_in_competition"`
}
if err := c.ShouldBindJSON(&req); err != nil {
SafeBadRequest(c, "Invalid request parameters")
return
}
// Update database
err := s.store.Trader().UpdateShowInCompetition(userID, traderID, req.ShowInCompetition)
if err != nil {
SafeInternalError(c, "Update competition visibility", err)
return
}
// Update in-memory trader if it exists
if trader, err := s.traderManager.GetTrader(traderID); err == nil {
trader.SetShowInCompetition(req.ShowInCompetition)
}
status := "shown"
if !req.ShowInCompetition {
status = "hidden"
}
logger.Infof("✓ Trader %s competition visibility updated: %s", traderID, status)
c.JSON(http.StatusOK, gin.H{
"message": "Competition visibility updated",
"show_in_competition": req.ShowInCompetition,
})
}

View File

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

View File

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

View File

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

View File

@@ -1,404 +0,0 @@
package api
import (
"fmt"
"net/http"
"strings"
"time"
"nofx/auth"
"nofx/logger"
"nofx/store"
"github.com/gin-gonic/gin"
"github.com/google/uuid"
"gorm.io/gorm"
)
// handleLogout Add current token to blacklist
func (s *Server) handleLogout(c *gin.Context) {
authHeader := c.GetHeader("Authorization")
if authHeader == "" {
c.JSON(http.StatusUnauthorized, gin.H{"error": "Missing Authorization header"})
return
}
parts := strings.Split(authHeader, " ")
if len(parts) != 2 || parts[0] != "Bearer" {
c.JSON(http.StatusUnauthorized, gin.H{"error": "Invalid Authorization format"})
return
}
tokenString := parts[1]
claims, err := auth.ValidateJWT(tokenString)
if err != nil {
c.JSON(http.StatusUnauthorized, gin.H{"error": "Invalid token"})
return
}
var exp time.Time
if claims.ExpiresAt != nil {
exp = claims.ExpiresAt.Time
} else {
exp = time.Now().Add(24 * time.Hour)
}
auth.BlacklistToken(tokenString, exp)
c.JSON(http.StatusOK, gin.H{"message": "Logged out"})
}
// handleRegister Handle user registration request.
// handleRegister allows registration only when no users exist yet (first-time setup).
// This is a single-user system; subsequent registrations are permanently closed.
func (s *Server) handleRegister(c *gin.Context) {
userCount, err := s.store.User().Count()
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to check user count"})
return
}
if userCount > 0 {
c.JSON(http.StatusForbidden, gin.H{"error": "System already initialized"})
return
}
var req struct {
Email string `json:"email" binding:"required,email"`
Password string `json:"password" binding:"required,min=8"`
Lang string `json:"lang"`
}
if err := c.ShouldBindJSON(&req); err != nil {
SafeBadRequest(c, "Invalid request parameters")
return
}
lang := req.Lang
if lang != "zh" && lang != "id" {
lang = "en"
}
// Check if email already exists
_, err = s.store.User().GetByEmail(req.Email)
if err == nil {
c.JSON(http.StatusConflict, gin.H{"error": "Email already registered"})
return
}
// Generate password hash
passwordHash, err := auth.HashPassword(req.Password)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "Password processing failed"})
return
}
// Create user
userID := uuid.New().String()
user := &store.User{
ID: userID,
Email: req.Email,
PasswordHash: passwordHash,
}
err = s.store.User().Create(user)
if err != nil {
SafeInternalError(c, "Failed to create user", err)
return
}
// NOTE: Orphan record adoption was removed for security reasons. Previously,
// after a reset-account call, any new user would inherit the prior owner's
// wallet keys and exchange API credentials — a catastrophic IDOR/takeover
// path. Operators who need to migrate credentials across users must do so
// explicitly via export/import, never via implicit adoption on registration.
// Generate JWT token
token, err := auth.GenerateJWT(user.ID, user.Email)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to generate token"})
return
}
// Initialize default model and exchange configs for user
err = s.initUserDefaultConfigs(user.ID, lang)
if err != nil {
logger.Infof("Failed to initialize user default configs: %v", err)
}
c.JSON(http.StatusOK, gin.H{
"token": token,
"user_id": user.ID,
"email": user.Email,
"message": "Registration successful",
})
}
// dummyPasswordHash is a valid bcrypt hash of a throwaway value. It is compared
// against when the submitted email does not exist so that login takes roughly
// the same time whether or not the account exists — closing the timing side
// channel that would otherwise let an attacker enumerate valid emails (a fast
// "no such user" vs. a slow bcrypt compare). It is not a secret.
const dummyPasswordHash = "$2a$10$0iF0bCoQLJ6Ph1bF.MXwHOW.IMTxQjeEW.w38dctRQAB2kwB6ga1q"
// handleLogin Handle user login request
func (s *Server) handleLogin(c *gin.Context) {
var req struct {
Email string `json:"email" binding:"required,email"`
Password string `json:"password" binding:"required"`
}
if err := c.ShouldBindJSON(&req); err != nil {
SafeBadRequest(c, "Invalid request parameters")
return
}
// Get user information
user, err := s.store.User().GetByEmail(req.Email)
if err != nil {
// Perform a dummy comparison so the response time does not reveal
// whether the email exists (anti user-enumeration), then fail uniformly.
auth.CheckPassword(req.Password, dummyPasswordHash)
c.JSON(http.StatusUnauthorized, gin.H{"error": "Email or password incorrect"})
return
}
// Verify password
if !auth.CheckPassword(req.Password, user.PasswordHash) {
c.JSON(http.StatusUnauthorized, gin.H{"error": "Email or password incorrect"})
return
}
// Issue token directly after password verification.
token, err := auth.GenerateJWT(user.ID, user.Email)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to generate token"})
return
}
c.JSON(http.StatusOK, gin.H{
"token": token,
"user_id": user.ID,
"email": user.Email,
"message": "Login successful",
})
}
// handleChangePassword changes the password for the currently authenticated user.
func (s *Server) handleChangePassword(c *gin.Context) {
userID := c.GetString("user_id")
var req struct {
NewPassword string `json:"new_password" binding:"required,min=8"`
}
if err := c.ShouldBindJSON(&req); err != nil {
SafeBadRequest(c, "new_password is required (min 8 chars)")
return
}
hash, err := auth.HashPassword(req.NewPassword)
if err != nil {
SafeInternalError(c, "Password processing failed", err)
return
}
if err := s.store.User().UpdatePassword(userID, hash); err != nil {
SafeInternalError(c, "Failed to update password", err)
return
}
c.JSON(http.StatusOK, gin.H{"message": "Password updated"})
}
// NOTE: Password and account recovery used to live here as the public,
// unauthenticated handlers handleResetPassword / handleResetAccount. They were
// removed because an unauthenticated recovery endpoint is a remotely
// exploitable auth-bypass on any public-facing deployment: the confirm phrase
// was embedded in the frontend (and echoed back by the API), so it was friction
// rather than authentication. Recovery now lives in the local CLI
// (`nofx reset-password` / `nofx reset-account`, see cli.go), which requires
// shell access to the host — something a remote attacker does not have.
// initUserDefaultConfigs Initialize default configs for new user
func (s *Server) initUserDefaultConfigs(userID string, lang string) error {
if err := s.createDefaultStrategies(userID, lang); err != nil {
logger.Warnf("Failed to create default strategies for user %s: %v", userID, err)
// Non-fatal: user can create strategies manually
}
logger.Infof("✓ User %s registration completed with default strategies", userID)
return nil
}
func (s *Server) createDefaultStrategies(userID string, lang string) error {
type strategyI18n struct {
name, description string
}
type strategyLocale struct {
trend, megaCap, breakout strategyI18n
}
locales := map[string]strategyLocale{
"zh": {
trend: strategyI18n{"美股趋势策略", "开箱即用的 Hyperliquid 美股 USDC 策略。只扫描流动性更好的美股合约,低杠杆、低频率,适合直接创建 Agent 后运行。"},
megaCap: strategyI18n{"美股大盘稳健策略", "开箱即用的 Hyperliquid 美股 USDC 策略。固定关注 AAPL、MSFT、GOOGL、AMZN、META 等大盘股,强调趋势确认和回撤控制。"},
breakout: strategyI18n{"美股突破策略", "开箱即用的 Hyperliquid 美股 USDC 策略。扫描 24h 强势美股,等待突破确认后再开仓,避免频繁追涨。"},
},
"en": {
trend: strategyI18n{"US Stock Trend Strategy", "Ready-to-run Hyperliquid USDC equity strategy. Scans liquid US stock perps with low leverage and low trade frequency, suitable for one-click Agent deployment."},
megaCap: strategyI18n{"US Mega-Cap Steady Strategy", "Ready-to-run Hyperliquid USDC equity strategy. Fixed universe: AAPL, MSFT, GOOGL, AMZN and META, with trend confirmation and drawdown control."},
breakout: strategyI18n{"US Stock Breakout Strategy", "Ready-to-run Hyperliquid USDC equity strategy. Scans 24h strong US stocks and waits for breakout confirmation before entering, avoiding impulsive chasing."},
},
"id": {
trend: strategyI18n{"Strategi Tren Saham AS", "Strategi saham AS USDC Hyperliquid siap jalan. Memindai perp saham AS likuid dengan leverage rendah dan frekuensi rendah."},
megaCap: strategyI18n{"Strategi Stabil Mega-Cap AS", "Strategi saham AS USDC Hyperliquid siap jalan. Universe tetap: AAPL, MSFT, GOOGL, AMZN, META, dengan konfirmasi tren."},
breakout: strategyI18n{"Strategi Breakout Saham AS", "Strategi saham AS USDC Hyperliquid siap jalan. Memindai saham AS kuat 24 jam dan menunggu konfirmasi breakout."},
},
}
locale, ok := locales[lang]
if !ok {
locale = locales["en"]
}
type strategyDef struct {
name string
description string
isActive bool
applyConfig func(*store.StrategyConfig)
}
setStockRank := func(c *store.StrategyConfig, direction string, limit int) {
c.CoinSource.SourceType = "hyper_rank"
c.CoinSource.StaticCoins = nil
c.CoinSource.UseAI500 = false
c.CoinSource.UseOITop = false
c.CoinSource.UseOILow = false
c.CoinSource.UseHyperAll = false
c.CoinSource.UseHyperMain = false
c.CoinSource.HyperRankCategory = "stock"
c.CoinSource.HyperRankDirection = direction
c.CoinSource.HyperRankLimit = limit
}
setStaticStocks := func(c *store.StrategyConfig, symbols []string) {
c.CoinSource.SourceType = "static"
c.CoinSource.StaticCoins = symbols
c.CoinSource.UseAI500 = false
c.CoinSource.UseOITop = false
c.CoinSource.UseOILow = false
c.CoinSource.UseHyperAll = false
c.CoinSource.UseHyperMain = false
}
setStableRisk := func(c *store.StrategyConfig) {
c.RiskControl.MaxPositions = 2
c.RiskControl.BTCETHMaxLeverage = 3
c.RiskControl.AltcoinMaxLeverage = 3
c.RiskControl.BTCETHMaxPositionValueRatio = 2.0
c.RiskControl.AltcoinMaxPositionValueRatio = 0.6
c.RiskControl.MaxMarginUsage = 0.45
c.RiskControl.MinConfidence = 78
c.RiskControl.MinRiskRewardRatio = 3.0
c.Indicators.Klines.PrimaryTimeframe = "15m"
c.Indicators.Klines.LongerTimeframe = "4h"
c.Indicators.Klines.SelectedTimeframes = []string{"15m", "1h", "4h"}
c.Indicators.EnableEMA = true
c.Indicators.EnableMACD = true
c.Indicators.EnableRSI = true
c.Indicators.EnableATR = true
c.Indicators.EnableVolume = true
}
definitions := []strategyDef{
{
name: locale.trend.name,
description: locale.trend.description,
isActive: true,
applyConfig: func(c *store.StrategyConfig) {
setStockRank(c, "volume", 5)
setStableRisk(c)
},
},
{
name: locale.megaCap.name,
description: locale.megaCap.description,
isActive: false,
applyConfig: func(c *store.StrategyConfig) {
setStaticStocks(c, []string{"AAPL-USDC", "MSFT-USDC", "GOOGL-USDC", "AMZN-USDC", "META-USDC"})
setStableRisk(c)
c.RiskControl.MaxPositions = 2
c.RiskControl.MinConfidence = 80
},
},
{
name: locale.breakout.name,
description: locale.breakout.description,
isActive: false,
applyConfig: func(c *store.StrategyConfig) {
setStockRank(c, "gainers", 5)
setStableRisk(c)
c.RiskControl.MinConfidence = 82
c.RiskControl.MinRiskRewardRatio = 3.5
},
},
}
// GetDefaultStrategyConfig only supports zh/en; map id -> en
configLang := lang
if lang == "id" {
configLang = "en"
}
// Pre-build all strategy objects before opening the transaction
var strategies []*store.Strategy
for _, def := range definitions {
config := store.GetDefaultStrategyConfig(configLang)
def.applyConfig(&config)
config.ClampLimits()
strategy := &store.Strategy{
ID: uuid.New().String(),
UserID: userID,
Name: def.name,
Description: def.description,
IsActive: def.isActive,
IsDefault: false,
}
if err := strategy.SetConfig(&config); err != nil {
return fmt.Errorf("failed to set config for strategy %q: %w", def.name, err)
}
strategies = append(strategies, strategy)
}
legacyDefaultNames := []string{
"均衡策略", "稳健策略", "积极策略",
"Balanced Strategy", "Conservative Strategy", "Aggressive Strategy",
"Strategi Seimbang", "Strategi Konservatif", "Strategi Agresif",
}
return s.store.Transaction(func(tx *gorm.DB) error {
// Remove obsolete built-in risk-profile presets for this user. If a trader still
// references one of them, keep it to avoid breaking an existing running setup.
deleteResult := tx.Where("user_id = ? AND name IN ? AND id NOT IN (SELECT strategy_id FROM traders WHERE user_id = ? AND strategy_id IS NOT NULL)", userID, legacyDefaultNames, userID).
Delete(&store.Strategy{})
if deleteResult.Error != nil {
return fmt.Errorf("failed to remove legacy default strategies: %w", deleteResult.Error)
}
if deleteResult.RowsAffected > 0 {
logger.Infof(" ✓ Removed %d legacy default strategy preset(s)", deleteResult.RowsAffected)
}
var activeCount int64
if err := tx.Model(&store.Strategy{}).Where("user_id = ? AND is_active = ?", userID, true).Count(&activeCount).Error; err != nil {
return fmt.Errorf("failed to count active strategies: %w", err)
}
for _, strategy := range strategies {
var existing int64
if err := tx.Model(&store.Strategy{}).Where("user_id = ? AND name = ?", userID, strategy.Name).Count(&existing).Error; err != nil {
return fmt.Errorf("failed to check strategy %q: %w", strategy.Name, err)
}
if existing > 0 {
continue
}
if activeCount > 0 {
strategy.IsActive = false
}
if err := tx.Create(strategy).Error; err != nil {
return fmt.Errorf("failed to create strategy %q: %w", strategy.Name, err)
}
if strategy.IsActive {
activeCount++
}
logger.Infof(" ✓ Created default strategy: %s (active=%v)", strategy.Name, strategy.IsActive)
}
return nil
})
}

View File

@@ -1,151 +0,0 @@
package api
import (
"testing"
"github.com/google/uuid"
"nofx/store"
)
func TestCreateDefaultStrategiesUsesReadyToRunUSStockPresets(t *testing.T) {
st, err := store.New(t.TempDir() + "/nofx.db")
if err != nil {
t.Fatalf("store.New failed: %v", err)
}
t.Cleanup(func() { _ = st.Close() })
s := &Server{store: st}
userID := "user-us-stock-presets"
if err := s.createDefaultStrategies(userID, "zh"); err != nil {
t.Fatalf("createDefaultStrategies failed: %v", err)
}
strategies, err := st.Strategy().List(userID)
if err != nil {
t.Fatalf("List strategies failed: %v", err)
}
if len(strategies) != 3 {
t.Fatalf("expected 3 default strategies, got %d", len(strategies))
}
byName := map[string]*store.Strategy{}
activeCount := 0
for _, strategy := range strategies {
byName[strategy.Name] = strategy
if strategy.IsActive {
activeCount++
}
if strategy.Name == "均衡策略" || strategy.Name == "稳健策略" || strategy.Name == "积极策略" {
t.Fatalf("legacy crypto-style default strategy still present: %s", strategy.Name)
}
}
if activeCount != 1 {
t.Fatalf("expected exactly one active strategy, got %d", activeCount)
}
trend := byName["美股趋势策略"]
if trend == nil || !trend.IsActive {
t.Fatalf("美股趋势策略 should exist and be active")
}
trendCfg, err := trend.ParseConfig()
if err != nil {
t.Fatalf("trend ParseConfig failed: %v", err)
}
if trendCfg.CoinSource.SourceType != "hyper_rank" || trendCfg.CoinSource.HyperRankCategory != "stock" || trendCfg.CoinSource.HyperRankDirection != "volume" {
t.Fatalf("trend strategy should use Hyperliquid stock volume ranking, got %+v", trendCfg.CoinSource)
}
if trendCfg.CoinSource.UseAI500 || trendCfg.RiskControl.MaxPositions > 2 || trendCfg.RiskControl.MaxMarginUsage > 0.45 {
t.Fatalf("trend strategy should be low-risk Hyperliquid native, got coin=%+v risk=%+v", trendCfg.CoinSource, trendCfg.RiskControl)
}
megaCap := byName["美股大盘稳健策略"]
if megaCap == nil {
t.Fatalf("美股大盘稳健策略 should exist")
}
megaCfg, err := megaCap.ParseConfig()
if err != nil {
t.Fatalf("megaCap ParseConfig failed: %v", err)
}
if megaCfg.CoinSource.SourceType != "static" {
t.Fatalf("mega-cap strategy should use static stock symbols, got %+v", megaCfg.CoinSource)
}
wantSymbols := []string{"AAPL-USDC", "MSFT-USDC", "GOOGL-USDC", "AMZN-USDC", "META-USDC"}
if len(megaCfg.CoinSource.StaticCoins) != len(wantSymbols) {
t.Fatalf("unexpected static stock list: %+v", megaCfg.CoinSource.StaticCoins)
}
for i, want := range wantSymbols {
if megaCfg.CoinSource.StaticCoins[i] != want {
t.Fatalf("static stock %d: want %s got %s", i, want, megaCfg.CoinSource.StaticCoins[i])
}
}
}
func TestCreateDefaultStrategiesMigratesLegacyPresetsWithoutOverridingActiveCustom(t *testing.T) {
st, err := store.New(t.TempDir() + "/nofx.db")
if err != nil {
t.Fatalf("store.New failed: %v", err)
}
t.Cleanup(func() { _ = st.Close() })
userID := "user-existing-custom"
legacyCfg := store.GetDefaultStrategyConfig("zh")
legacy := &store.Strategy{
ID: uuid.New().String(),
UserID: userID,
Name: "均衡策略",
Description: "legacy",
IsActive: false,
}
if err := legacy.SetConfig(&legacyCfg); err != nil {
t.Fatalf("legacy SetConfig failed: %v", err)
}
if err := st.Strategy().Create(legacy); err != nil {
t.Fatalf("create legacy failed: %v", err)
}
custom := &store.Strategy{
ID: uuid.New().String(),
UserID: userID,
Name: "aa",
Description: "user custom active strategy",
IsActive: true,
}
if err := custom.SetConfig(&legacyCfg); err != nil {
t.Fatalf("custom SetConfig failed: %v", err)
}
if err := st.Strategy().Create(custom); err != nil {
t.Fatalf("create custom failed: %v", err)
}
s := &Server{store: st}
if err := s.createDefaultStrategies(userID, "zh"); err != nil {
t.Fatalf("createDefaultStrategies failed: %v", err)
}
if err := s.createDefaultStrategies(userID, "zh"); err != nil {
t.Fatalf("second createDefaultStrategies should be idempotent: %v", err)
}
strategies, err := st.Strategy().List(userID)
if err != nil {
t.Fatalf("List strategies failed: %v", err)
}
byName := map[string]int{}
activeNames := []string{}
for _, strategy := range strategies {
byName[strategy.Name]++
if strategy.IsActive {
activeNames = append(activeNames, strategy.Name)
}
}
if byName["均衡策略"] != 0 {
t.Fatalf("legacy preset should be removed, got names=%+v", byName)
}
for _, name := range []string{"美股趋势策略", "美股大盘稳健策略", "美股突破策略"} {
if byName[name] != 1 {
t.Fatalf("expected exactly one %s, got names=%+v", name, byName)
}
}
if len(activeNames) != 1 || activeNames[0] != "aa" {
t.Fatalf("existing active custom strategy should stay the only active one, got %+v", activeNames)
}
}

View File

@@ -1,130 +0,0 @@
package api
import (
"encoding/hex"
"fmt"
"net/http"
"nofx/wallet"
"strings"
"time"
"github.com/ethereum/go-ethereum/crypto"
"github.com/gin-gonic/gin"
)
type walletValidateRequest struct {
PrivateKey string `json:"private_key"`
}
type walletValidateResponse struct {
Valid bool `json:"valid"`
Address string `json:"address,omitempty"`
BalanceUSDC string `json:"balance_usdc,omitempty"`
Claw402Status string `json:"claw402_status"` // "ok", "unreachable", "error"
Error string `json:"error,omitempty"`
}
func (s *Server) handleWalletValidate(c *gin.Context) {
var req walletValidateRequest
if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(http.StatusBadRequest, walletValidateResponse{
Valid: false,
Error: "invalid request body",
})
return
}
pk := req.PrivateKey
// Validate format
if !strings.HasPrefix(pk, "0x") {
c.JSON(http.StatusOK, walletValidateResponse{
Valid: false,
Error: "missing 0x prefix",
})
return
}
if len(pk) != 66 {
c.JSON(http.StatusOK, walletValidateResponse{
Valid: false,
Error: fmt.Sprintf("should be 66 characters, got %d", len(pk)),
})
return
}
hexPart := pk[2:]
if _, err := hex.DecodeString(hexPart); err != nil {
c.JSON(http.StatusOK, walletValidateResponse{
Valid: false,
Error: "contains invalid hex characters",
})
return
}
// Derive address
privateKey, err := crypto.HexToECDSA(hexPart)
if err != nil {
c.JSON(http.StatusOK, walletValidateResponse{
Valid: false,
Error: "invalid private key",
})
return
}
address := crypto.PubkeyToAddress(privateKey.PublicKey)
addrHex := address.Hex()
// Query USDC balance (async-ish, but sequential for simplicity)
balanceStr := wallet.QueryUSDCBalanceStr(addrHex)
// Check claw402 health
claw402Status := checkClaw402Health()
c.JSON(http.StatusOK, walletValidateResponse{
Valid: true,
Address: addrHex,
BalanceUSDC: balanceStr,
Claw402Status: claw402Status,
})
}
type walletGenerateResponse struct {
Address string `json:"address"`
PrivateKey string `json:"private_key"`
}
func (s *Server) handleWalletGenerate(c *gin.Context) {
// Generate new EVM wallet
privateKey, err := crypto.GenerateKey()
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to generate wallet"})
return
}
address := crypto.PubkeyToAddress(privateKey.PublicKey)
privKeyHex := "0x" + hex.EncodeToString(crypto.FromECDSA(privateKey))
c.JSON(http.StatusOK, walletGenerateResponse{
Address: address.Hex(),
PrivateKey: privKeyHex,
})
}
func checkClaw402Health() string {
client := &http.Client{Timeout: 5 * time.Second}
resp, err := client.Get("https://claw402.ai/health")
if err != nil {
return "unreachable"
}
defer resp.Body.Close()
if resp.StatusCode >= 200 && resp.StatusCode < 300 {
return "ok"
}
return "error"
}

View File

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

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