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
This commit is contained in:
tinkle-community
2026-03-08 00:19:38 +08:00
parent 73f1fe105d
commit 3168a18c0d
28 changed files with 4673 additions and 118 deletions

228
telegram/agent/agent.go Normal file
View File

@@ -0,0 +1,228 @@
package agent
import (
"encoding/json"
"fmt"
"nofx/auth"
"nofx/logger"
"nofx/mcp"
"nofx/telegram/session"
"strings"
)
const maxIterations = 10
// Agent is a stateful AI agent for one Telegram chat.
// It has a single tool (api_call) and an unbounded decision loop.
type Agent struct {
apiTool *apiCallTool
getLLM func() mcp.AIClient
memory *session.Memory
systemPrompt string
userID string
}
// New creates an Agent for one chat session.
func New(apiPort int, botToken, userID string, getLLM func() mcp.AIClient, systemPrompt string) *Agent {
return &Agent{
apiTool: newAPICallTool(apiPort, botToken),
getLLM: getLLM,
memory: session.NewMemory(getLLM()),
systemPrompt: systemPrompt,
userID: userID,
}
}
// GenerateBotToken creates a long-lived JWT for the bot's internal API calls.
// userID must match the actual registered user's ID so that bot-made changes
// are visible in the frontend (they share the same user namespace).
func GenerateBotToken(userID string) (string, error) {
return auth.GenerateJWT(userID, "bot@internal")
}
// buildAccountContext fetches the live account state (models, exchanges, strategies, traders,
// and per-trader account summary + statistics) via the local API and returns it as a formatted
// string for injection into the LLM context. This gives the LLM immediate awareness of what
// is already configured and the current financial state, so it never asks the user for
// information that already exists.
func (a *Agent) buildAccountContext() string {
type q struct {
label string
path string
}
queries := []q{
{"AI Models", "/api/models"},
{"Exchanges", "/api/exchanges"},
{"Strategies", "/api/strategies"},
{"Traders", "/api/my-traders"},
}
var sb strings.Builder
sb.WriteString(fmt.Sprintf("[Current Account State - Authenticated User ID: %s]\n\n", a.userID))
var tradersJSON string
for _, query := range queries {
result := a.apiTool.execute(&apiRequest{Method: "GET", Path: query.path})
sb.WriteString(fmt.Sprintf("%s:\n%s\n\n", query.label, result))
if query.path == "/api/my-traders" {
tradersJSON = result
}
}
// For each running trader, fetch real-time account balance and trading statistics.
var traders []struct {
TraderID string `json:"trader_id"`
Name string `json:"trader_name"`
IsRunning bool `json:"is_running"`
}
if err := json.Unmarshal([]byte(tradersJSON), &traders); err == nil {
for _, t := range traders {
if !t.IsRunning {
continue
}
acct := a.apiTool.execute(&apiRequest{Method: "GET", Path: "/api/account?trader_id=" + t.TraderID})
sb.WriteString(fmt.Sprintf("Account [%s] (trader_id=%s):\n%s\n\n", t.Name, t.TraderID, acct))
stats := a.apiTool.execute(&apiRequest{Method: "GET", Path: "/api/statistics?trader_id=" + t.TraderID})
sb.WriteString(fmt.Sprintf("Statistics [%s] (trader_id=%s):\n%s\n\n", t.Name, t.TraderID, stats))
}
}
return sb.String()
}
// Run processes one user message through the agent loop.
// Loop: LLM decides -> if <api_call>: execute, append result, loop -> if no tag: return reply.
//
// On the first message of a conversation, the current account state (models, exchanges,
// strategies, traders) is automatically fetched and injected so the LLM knows what is
// already configured without asking the user to repeat themselves.
//
// onChunk is optional. When non-nil, each LLM call is streamed:
// - Chunks are forwarded to onChunk until an <api_call> tag appears in the accumulated text.
// - After an api_call iteration completes, onChunk("⏳") resets the display to a thinking indicator.
// - The final reply is streamed progressively via onChunk.
func (a *Agent) Run(userMessage string, onChunk func(string)) string {
llm := a.getLLM()
if llm == nil {
return "AI assistant unavailable. Please configure an AI model in the Web UI."
}
// Build turn messages: history context prefix + current user message.
// On the very first message (no history), prepend a live account state snapshot so the
// LLM immediately knows what models, exchanges, strategies, and traders are configured.
histCtx := a.memory.BuildContext()
var firstMsg string
if histCtx == "" {
// First message in this conversation — fetch and inject account state.
accountCtx := a.buildAccountContext()
firstMsg = accountCtx + "\n[User Message]\n" + userMessage
} else {
firstMsg = histCtx + "\n---\nUser: " + userMessage
}
turnMsgs := []mcp.Message{mcp.NewUserMessage(firstMsg)}
var lastResp string
for i := 0; i < maxIterations; i++ {
req, err := mcp.NewRequestBuilder().
WithSystemPrompt(a.systemPrompt).
AddConversationHistory(turnMsgs).
Build()
if err != nil {
logger.Errorf("Agent: failed to build request: %v", err)
break
}
var resp string
if onChunk != nil {
// Stream this call; suppress chunks once an <api_call> tag appears.
// Also hold back the last (len("<api_call>")-1) chars of accumulated text to
// avoid showing partial opening tags (e.g. "<", "<ap") before we can detect them.
const tagLen = len("<api_call>") // 10
const safeOffset = tagLen - 1 // 9: max prefix of tag we might have received
var apiTagSeen bool
resp, err = llm.CallWithRequestStream(req, func(accumulated string) {
if apiTagSeen {
return
}
if idx := strings.Index(accumulated, "<api_call>"); idx >= 0 {
apiTagSeen = true
// Forward only the text that appeared before the tag.
if display := strings.TrimSpace(accumulated[:idx]); display != "" {
onChunk(display)
}
return
}
// Forward only the "safe" prefix — hold back the last safeOffset chars
// in case they are the beginning of an <api_call> tag.
if safe := len(accumulated) - safeOffset; safe > 0 {
onChunk(accumulated[:safe])
}
})
} else {
resp, err = llm.CallWithRequest(req)
}
if err != nil {
logger.Errorf("Agent: LLM call failed (iteration %d): %v", i+1, err)
return "AI assistant temporarily unavailable. Please try again."
}
lastResp = resp
apiReq, textBefore := parseAPICall(resp)
if apiReq == nil {
// No api_call tag — LLM gave a final answer (already streamed if onChunk set).
reply := stripAPICallTag(strings.TrimSpace(resp))
a.memory.Add("user", userMessage)
a.memory.Add("assistant", reply)
return reply
}
// api_call iteration — reset display to thinking indicator before executing.
if onChunk != nil {
onChunk("⏳")
}
logger.Infof("Agent: iter=%d %s %s", i+1, apiReq.Method, apiReq.Path)
result := a.apiTool.execute(apiReq)
if textBefore != "" {
turnMsgs = append(turnMsgs, mcp.NewAssistantMessage(textBefore))
}
turnMsgs = append(turnMsgs, mcp.NewUserMessage(
fmt.Sprintf("[API result: %s %s]\n%s", apiReq.Method, apiReq.Path, result),
))
}
// Safety: max iterations reached — ask LLM for a final summary (non-streaming).
logger.Warnf("Agent: max iterations (%d) reached", maxIterations)
turnMsgs = append(turnMsgs, mcp.NewUserMessage("Please summarize the results and give the user a final reply."))
if finalReq, err := mcp.NewRequestBuilder().
WithSystemPrompt(a.systemPrompt).
AddConversationHistory(turnMsgs).
Build(); err == nil {
if finalResp, err := llm.CallWithRequest(finalReq); err == nil {
lastResp = finalResp
}
}
reply := stripAPICallTag(strings.TrimSpace(lastResp))
a.memory.Add("user", userMessage)
a.memory.Add("assistant", reply)
return reply
}
// stripAPICallTag removes any <api_call>...</api_call> fragment from s.
// Used as a defensive layer to ensure tags never leak to the user.
func stripAPICallTag(s string) string {
if idx := strings.Index(s, "<api_call>"); idx >= 0 {
return strings.TrimSpace(s[:idx])
}
return s
}
// ResetMemory clears conversation history (called on /start).
func (a *Agent) ResetMemory() {
a.memory.ResetFull()
}

View File

@@ -0,0 +1,183 @@
package agent
import (
"fmt"
"net/http"
"net/http/httptest"
"strings"
"testing"
"time"
"nofx/mcp"
)
type mockLLM struct {
responses []string
calls int
lastMsgs []mcp.Message
}
func (m *mockLLM) SetAPIKey(_, _, _ string) {}
func (m *mockLLM) SetTimeout(_ time.Duration) {}
func (m *mockLLM) CallWithMessages(_, _ string) (string, error) { return m.next() }
func (m *mockLLM) CallWithRequest(req *mcp.Request) (string, error) {
m.lastMsgs = req.Messages
return m.next()
}
func (m *mockLLM) CallWithRequestStream(req *mcp.Request, onChunk func(string)) (string, error) {
m.lastMsgs = req.Messages
r, err := m.next()
if onChunk != nil {
onChunk(r)
}
return r, err
}
func (m *mockLLM) next() (string, error) {
if m.calls < len(m.responses) {
r := m.responses[m.calls]
m.calls++
return r, nil
}
return "OK", nil
}
func mockGetLLM(llm *mockLLM) func() mcp.AIClient {
return func() mcp.AIClient { return llm }
}
const testPrompt = "You are a test assistant."
// TestAgentDirectReply: LLM replies without api_call — one call, direct reply.
func TestAgentDirectReply(t *testing.T) {
llm := &mockLLM{responses: []string{"Hello! How can I help you?"}}
a := New(8080, "tok", "test-user", mockGetLLM(llm), testPrompt)
reply := a.Run("hello", nil)
if reply != "Hello! How can I help you?" {
t.Fatalf("unexpected reply: %q", reply)
}
if llm.calls != 1 {
t.Fatalf("expected 1 LLM call, got %d", llm.calls)
}
}
// TestAgentAPICall: LLM calls API, gets result, gives final reply — two LLM calls.
func TestAgentAPICall(t *testing.T) {
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.URL.Path == "/api/my-traders" {
w.Write([]byte(`[{"id":"t1","name":"BTC Strategy"}]`)) //nolint:errcheck
return
}
w.WriteHeader(404)
}))
defer srv.Close()
var port int
fmt.Sscanf(srv.Listener.Addr().String(), "127.0.0.1:%d", &port)
llm := &mockLLM{responses: []string{
`Let me check.<api_call>{"method":"GET","path":"/api/my-traders","body":{}}</api_call>`,
"You have one trader: BTC Strategy.",
}}
a := New(port, "tok", "test-user", mockGetLLM(llm), testPrompt)
reply := a.Run("list my traders", nil)
if reply != "You have one trader: BTC Strategy." {
t.Fatalf("unexpected reply: %q", reply)
}
if llm.calls != 2 {
t.Fatalf("expected 2 LLM calls, got %d", llm.calls)
}
}
// TestAgentMultiStep: LLM chains two API calls before final reply — three LLM calls.
func TestAgentMultiStep(t *testing.T) {
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.Write([]byte(`{"ok":true}`)) //nolint:errcheck
}))
defer srv.Close()
var port int
fmt.Sscanf(srv.Listener.Addr().String(), "127.0.0.1:%d", &port)
llm := &mockLLM{responses: []string{
`Checking account.<api_call>{"method":"GET","path":"/api/account","body":{}}</api_call>`,
`Now checking positions.<api_call>{"method":"GET","path":"/api/positions","body":{}}</api_call>`,
"Account looks healthy and no open positions.",
}}
a := New(port, "tok", "test-user", mockGetLLM(llm), testPrompt)
reply := a.Run("show me account status", nil)
if llm.calls != 3 {
t.Fatalf("expected 3 LLM calls (2 api + 1 final), got %d", llm.calls)
}
if reply != "Account looks healthy and no open positions." {
t.Fatalf("unexpected final reply: %q", reply)
}
}
// TestAgentAPIResultInContext: API result must appear in next LLM message.
func TestAgentAPIResultInContext(t *testing.T) {
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.Write([]byte(`{"balance":1234.56}`)) //nolint:errcheck
}))
defer srv.Close()
var port int
fmt.Sscanf(srv.Listener.Addr().String(), "127.0.0.1:%d", &port)
llm := &mockLLM{responses: []string{
`<api_call>{"method":"GET","path":"/api/account","body":{}}</api_call>`,
"Balance is 1234.56 USDT.",
}}
a := New(port, "tok", "test-user", mockGetLLM(llm), testPrompt)
a.Run("show balance", nil)
found := false
for _, msg := range llm.lastMsgs {
if strings.Contains(msg.Content, "API result") || strings.Contains(msg.Content, "balance") {
found = true
break
}
}
if !found {
t.Fatalf("API result not found in subsequent LLM context")
}
}
// TestParseAPICall: unit tests for the XML tag parser.
func TestParseAPICall(t *testing.T) {
t.Run("valid call", func(t *testing.T) {
resp := `Stopping trader.<api_call>{"method":"POST","path":"/api/traders/t1/stop","body":{}}</api_call>`
req, text := parseAPICall(resp)
if req == nil {
t.Fatal("expected api_call, got nil")
}
if req.Method != "POST" || req.Path != "/api/traders/t1/stop" {
t.Fatalf("unexpected req: %+v", req)
}
if text != "Stopping trader." {
t.Fatalf("unexpected text before tag: %q", text)
}
})
t.Run("no call tag", func(t *testing.T) {
req, text := parseAPICall("Just a reply.")
if req != nil {
t.Fatal("expected nil api_call")
}
if text != "Just a reply." {
t.Fatalf("expected original text, got %q", text)
}
})
t.Run("malformed JSON", func(t *testing.T) {
req, _ := parseAPICall(`<api_call>NOT JSON</api_call>`)
if req != nil {
t.Fatal("expected nil for malformed JSON")
}
})
}

109
telegram/agent/apicall.go Normal file
View File

@@ -0,0 +1,109 @@
package agent
import (
"bytes"
"encoding/json"
"fmt"
"io"
"net/http"
"nofx/logger"
"strings"
"time"
)
// apiCallTool executes HTTP requests against the NOFX API server.
// This is the only tool available to the agent.
type apiCallTool struct {
baseURL string
token string
client *http.Client
}
// apiRequest is the parsed structure from the LLM's <api_call> tag.
type apiRequest struct {
Method string `json:"method"`
Path string `json:"path"`
Body map[string]any `json:"body"`
}
func newAPICallTool(port int, token string) *apiCallTool {
return &apiCallTool{
baseURL: fmt.Sprintf("http://127.0.0.1:%d", port),
token: token,
client: &http.Client{Timeout: 30 * time.Second},
}
}
// execute calls the API and returns the response as a string for LLM consumption.
func (t *apiCallTool) execute(req *apiRequest) string {
if req.Method == "" || req.Path == "" {
return "error: method and path are required"
}
if !strings.HasPrefix(req.Path, "/") {
req.Path = "/" + req.Path
}
var bodyReader io.Reader
if req.Method != "GET" && len(req.Body) > 0 {
b, err := json.Marshal(req.Body)
if err != nil {
return fmt.Sprintf("error marshaling body: %v", err)
}
bodyReader = bytes.NewReader(b)
}
httpReq, err := http.NewRequest(req.Method, t.baseURL+req.Path, bodyReader)
if err != nil {
return fmt.Sprintf("error creating request: %v", err)
}
httpReq.Header.Set("Content-Type", "application/json")
httpReq.Header.Set("Authorization", "Bearer "+t.token)
resp, err := t.client.Do(httpReq)
if err != nil {
return fmt.Sprintf("API call failed: %v", err)
}
defer resp.Body.Close()
body, err := io.ReadAll(resp.Body)
if err != nil {
return fmt.Sprintf("error reading response: %v", err)
}
logger.Infof("Agent api_call: %s %s -> %d", req.Method, req.Path, resp.StatusCode)
if resp.StatusCode >= 400 {
return fmt.Sprintf("API error %d: %s", resp.StatusCode, string(body))
}
// Pretty-print JSON for better LLM readability
var v any
if json.Unmarshal(body, &v) == nil {
if pretty, err := json.MarshalIndent(v, "", " "); err == nil {
return string(pretty)
}
}
return string(body)
}
// parseAPICall extracts <api_call>...</api_call> from LLM response.
// Returns (nil, original) if not found or malformed JSON.
func parseAPICall(resp string) (*apiRequest, string) {
const openTag = "<api_call>"
const closeTag = "</api_call>"
start := strings.Index(resp, openTag)
end := strings.Index(resp, closeTag)
if start < 0 || end < 0 || end <= start {
return nil, resp
}
jsonStr := strings.TrimSpace(resp[start+len(openTag) : end])
var req apiRequest
if err := json.Unmarshal([]byte(jsonStr), &req); err != nil {
logger.Warnf("Agent: failed to parse api_call JSON %q: %v", jsonStr, err)
return nil, resp
}
return &req, strings.TrimSpace(resp[:start])
}

78
telegram/agent/manager.go Normal file
View File

@@ -0,0 +1,78 @@
package agent
import (
"nofx/logger"
"nofx/mcp"
"sync"
"time"
)
// Manager holds one Agent per Telegram chat ID.
// Messages for the same chat are serialized (OpenClaw Lane Queue pattern).
type Manager struct {
mu sync.Mutex
agents map[int64]*Agent
lanes map[int64]chan struct{}
apiPort int
botToken string
userID string
getLLM func() mcp.AIClient
systemPrompt string
}
// NewManager creates a Manager. Call api.GetAPIDocs() before this and pass the result as apiDocs.
// userID is the database user ID the bot authenticates as (used in system prompt context).
func NewManager(apiPort int, botToken, userID string, getLLM func() mcp.AIClient, apiDocs string) *Manager {
return &Manager{
agents: make(map[int64]*Agent),
lanes: make(map[int64]chan struct{}),
apiPort: apiPort,
botToken: botToken,
userID: userID,
getLLM: getLLM,
systemPrompt: BuildAgentPrompt(apiDocs, userID),
}
}
// Run processes a message for the given chat ID.
// If the same chat is already processing a message, this call blocks until it completes
// or the lane wait times out (60 s), whichever comes first.
// onChunk is optional — when set, LLM reply chunks are forwarded progressively (SSE streaming).
func (m *Manager) Run(chatID int64, userMessage string, onChunk func(string)) string {
a, lane := m.getOrCreate(chatID)
select {
case lane <- struct{}{}:
case <-time.After(60 * time.Second):
logger.Warnf("Agent: lane wait timeout for chat %d — previous message still processing", chatID)
return "上一条消息仍在处理中,请稍等片刻后再试。"
}
defer func() { <-lane }()
return a.Run(userMessage, onChunk)
}
// Reset clears memory for the given chat (called on /start).
func (m *Manager) Reset(chatID int64) {
m.mu.Lock()
a, ok := m.agents[chatID]
m.mu.Unlock()
if ok {
a.ResetMemory()
}
}
func (m *Manager) getOrCreate(chatID int64) (*Agent, chan struct{}) {
m.mu.Lock()
defer m.mu.Unlock()
a, ok := m.agents[chatID]
if !ok {
a = New(m.apiPort, m.botToken, m.userID, m.getLLM, m.systemPrompt)
m.agents[chatID] = a
}
lane, ok := m.lanes[chatID]
if !ok {
lane = make(chan struct{}, 1) // binary semaphore: one message at a time per chat
m.lanes[chatID] = lane
}
return a, lane
}

107
telegram/agent/prompt.go Normal file
View File

@@ -0,0 +1,107 @@
package agent
import "fmt"
// BuildAgentPrompt constructs the full system prompt with live API documentation injected.
// apiDocs is the output of api.GetAPIDocs() — reflects all currently registered routes with full schemas.
// userID is the actual database user ID the bot authenticates as.
func BuildAgentPrompt(apiDocs, userID string) string {
return fmt.Sprintf(`You are the NOFX quantitative trading system AI assistant.
## Your Identity
- You are authenticated as user ID: %s
- All API calls are made on behalf of this user
- When asked "which user / username / email" — answer with this user ID directly, no API call needed
## Tool: api_call
Append EXACTLY ONE tag at the very end of your reply when you need to call the API:
<api_call>{"method":"GET","path":"/api/xxx","body":{}}</api_call>
Rules:
- The tag must be the LAST thing in your message — nothing after it
- NEVER more than one <api_call> tag per response
- 【CRITICAL】NEVER say "让我查询..."、"现在获取..."、"I will call..."、"Let me check..." — just ACT silently, no narration at all
- method: "GET" | "POST" | "PUT" | "DELETE"
- body: JSON object (use {} for GET requests)
- query parameters go in the path: /api/positions?trader_id=xxx
## NOFX API Documentation
The following API documentation includes full parameter schemas. Use these to understand exactly what each field means and construct correct requests.
%s
## Behavior Rules
1. 【NO NARRATION】Never tell the user what API you are calling. Zero narration. Just act.
2. Only ONE <api_call> tag per response, always at the very end
3. After getting an API result, decide: call another API or give a final reply
4. If the API returns success (2xx), the operation succeeded — do not retry
5. Reply in the same language the user used (中文→中文, English→English)
6. Keep replies concise — show results, not process
7. Ask for ALL required information in ONE message — never ask one field at a time
8. When user provides enough info, act immediately — no confirmation needed
9. Be decisive — infer intent from context, use schema to fill in smart defaults
## Verification Rule (CRITICAL)
After ANY PUT or POST that creates or modifies a resource:
1. Immediately GET the resource to read actual saved values
2. Show the user the KEY fields they care about from the GET response
3. NEVER just say "updated successfully" without showing the actual values
4. If saved values look wrong, correct them automatically
## Error Handling
- 400: explain what was wrong, ask user to correct
- 404: resource doesn't exist, check IDs
- "AI model not enabled": tell user to enable the model first via PUT /api/models
- "Exchange not enabled": tell user to enable the exchange first
- 5xx: server error, ask user to try again
- stream interrupted / unavailable: apologize briefly and ask user to retry
## How to Use the API Schema
All API knowledge comes from the documentation above. Use field descriptions to:
- Know exactly which fields are required vs optional
- Understand semantics and build correct request bodies from natural language
- For StrategyConfig: intelligently fill all fields based on user's trading style
## Account State (injected at conversation start)
At the start of each new conversation, a [Current Account State] block is provided with:
- AI Models: all configured models with their IDs and enabled status
- Exchanges: all configured exchanges with their IDs and enabled status
- Strategies: all existing strategies with their IDs
- Traders: all existing traders with their IDs and running status
Use this to:
- NEVER ask for exchange/model info that is already configured — use the existing IDs directly
- Know instantly if the user has 0 or N resources of each type
- If only one exchange/model exists and user doesn't specify, use it directly without asking
- If multiple exist, list them and ask which one to use
## Common Workflows
**Configure model**: Ask only for api_key. Set enabled:true, send empty strings for URL/model (backend applies provider defaults).
**Configure exchange**: Ask for all required fields in ONE message (see schema). Always set enabled:true.
**Create trader**: GET /api/exchanges + GET /api/models to get IDs → confirm with user → POST /api/traders.
**Create strategy** (most important workflow):
- A strategy is INDEPENDENT of traders. Never GET trader info just to create a strategy.
- If user specifies style + coins (e.g. "BTC trend"), build and POST immediately — no questions needed.
- Build StrategyConfig intelligently from user's description:
- "trend" / "趋势" → enable EMA(20,50), MACD, RSI, multi-timeframe (15m,1h,4h), longer primary TF
- "scalping" / "短线" → enable RSI, ATR, shorter timeframes (1m,3m,5m)
- "conservative" / "保守" → lower leverage (2-3x), higher min confidence (80%%+)
- "BTC/ETH" → set coin_source.source_type="static", static_coins=["BTC/USDT"] or similar
- After POST: GET /api/strategies/:id to verify → show user: name, coins, key indicators, leverage
**Update strategy config**:
1. GET /api/strategies/:id to read current full config
2. Modify only what user asked (keep all other fields)
3. PUT /api/strategies/:id with complete merged config
4. GET /api/strategies/:id to verify → show user actual saved values for changed fields
**Start/stop trader**: GET /api/my-traders first. If only one trader, act directly. If multiple, list and ask.
**Query data**: GET /api/my-traders to get trader_id, then query /api/positions?trader_id=xxx or /api/account?trader_id=xxx etc.`, userID, apiDocs)
}