API reference
Every public HTTP route of the AiTrade backend.
Base URL https://aitrade.thundera.eu. Authenticated routes accept the thundera_token cookie or Authorization: Bearer <jwt>. Chat, Binance account helpers and the activity log are served by the assistant service under /api/chat, /api/binance and /api/system/activity-log; ThunderScript compile/backtest are reached through the backend.
1. Cross-cutting
1.1 Auth
- Public (no auth middleware):
GET /health,GET /api/plans,GET /api/leaderboard, Swagger UIGET /api/docs+GET /api/openapi.json(spec has emptypaths/schemas, only title "AiTrade API" v0.1.0). /metricsis not routed publicly (Prometheus scrapes the service in-cluster).GET /wsrequires authentication (section 3).- Authenticated = every other route (layer
require_authover all protected routers). Token taken from cookiethundera_token, else headerAuthorization: Bearer <jwt>. Cookie wins if both present. - JWT: HS256, secret
THUNDERA_JWT_SECRET, issuer must behttps://auth.thundera.eu, audience*.thundera.eu,expvalidated. Claims:sub(uuid),email,name,roles[], optionalsid,gen,trusted,type,exp. A token withtypeother thanaccess(a refresh token) → 401. - On each request the user is upserted into
app_users(keythundera_id=sub; updatesname,last_seen);planis read fromapp_users.plan(NOT from the JWT claim). Internaluser.id=app_users.id(all data scoped to it). - CSRF check (POST/PUT/PATCH/DELETE on protected routes):
Origin(elseReferer) must be anhttps://URL whose host isthundera.euor ends with.thundera.eu(parsed, not substring-matched:evilthundera.euandaitrade.thundera.eu.attacker.comare rejected). If neither header is present, allowed only when anAuthorizationheader exists. Otherwise →401 {"error":"Unauthorized"}. (Runs before the token check.) - System admin (
/api/system/*):rolesclaim must containsystem-adminorsystem-super-admin(same rule as auth-service/system; a plainadminrole is not enough). Else403 {"error":"Forbidden"}. - Ownership: every id from the client (bot, wallet, BYO LLM, script, position, notification, strategy) is resolved against the caller; a foreign id and an unknown id both give 404 (never 200 and never 403, so existence is not revealed). A database trigger (
bots_ownership_guard) additionally rejects a bot linked to another user's wallet, BYO LLM or script.
1.2 Error envelope
All AppErrors → {"error": "<message>"} with Content-Type: application/json.
| Variant | Status | message |
|---|---|---|
| Unauthorized | 401 | Unauthorized |
| Forbidden | 403 | Forbidden |
| NotFound(m) | 404 | m |
| BadRequest(m) | 400 | m |
| Conflict(m) | 409 | m |
| Internal(m) | 500 | m |
| sqlx error | 500 | Database error (logged) |
| anyhow | 500 | Internal error |
Not in the envelope: axum extractor rejections (malformed JSON → 400, missing/invalid field types → 422, wrong/missing Content-Type: application/json → 415, bad path UUID / bad query → 400) return axum's default plain-text body. /api/plans upstream failures use {"error":"upstream_bad_response"|"upstream_error"|"billing_unavailable"|"internal"}.
1.3 CORS
CorsLayer: allowed origin = any origin ending in .thundera.eu or exactly https://thundera.eu; allow_credentials(true); methods GET, POST, PATCH, DELETE, OPTIONS (note: PUT is not listed, so a cross-origin PUT /api/workspace-state/{device_type} would fail preflight; same-origin unaffected); headers Content-Type, Authorization, Cookie.
1.4 Rate limits
None implemented in the backend (no rate-limit layer/crate in code). Any throttling would be at ingress/edge (unclear). Related caps: bot limit (plan), 10 BYO LLMs per user, /api/plans 60 s cache, upstream timeouts (compile 10 s, backtest 120 s).
1.5 Plan limits
Only enforced in POST /api/bots (bot count). Limit resolution order: user_config_overrides.bot_limit → live GET {AUTH_INTERNAL_URL or http://auth-service.thundera.svc.cluster.local:3000}/api/v1/auth/billing/effective-plan/aitrade (Bearer = user JWT, 3 s) .limits.bot_limit → /billing/plans/by-tier/aitrade/{plan} → hardcoded free=1, pro=2, max=5, thunder=20, other=2. /api/chat additionally rejects plan pro. indicator_limit, instructions_limit, chat_enabled overrides can be set by admin but are not read by any handler in api/* (unclear whether engine uses them).
1.6 Conventions
Decimals (rust_decimal::Decimal) serialize per the crate default (JSON strings unless the serde-float feature is enabled — unclear from source). Timestamps RFC3339 UTC. IDs are UUIDs. Unknown JSON body fields are ignored.
2. Health / Metrics / Docs
| Method Path | Auth | Response |
|---|---|---|
GET /health |
public | {"status":"healthy","service":"aitrade","version":"<CARGO_PKG_VERSION>"} (does not touch DB) |
GET /metrics |
public | Prometheus text (text/plain; version=0.0.4). Metrics: aitrade_requests_total, aitrade_active_bots, aitrade_trades_total, aitrade_active_connections (only requests counter + ws gauge are updated in these files) |
GET /api/docs, /api/openapi.json |
public | Swagger UI / near-empty OpenAPI doc |
3. WebSocket GET /ws
- Auth: required on the upgrade:
thundera_tokencookie (browsers) orAuthorization: Bearer(native). No token → 401. A presentOriginmust be a Thundera origin (same rule as CSRF) → else 403; noOriginis accepted only with a Bearer token. - Server sends
{"type":"hello"}. Client messages (JSON, max 4 KB):{"action":"subscribe","bot_id":…}→{"type":"subscribed","bot_id"}for the caller's own bot, else{"type":"error","error":"Bot not found"};{"action":"unsubscribe","bot_id"};{"action":"ping"}→{"type":"pong"}. Max 20 subscriptions. Every 5 s the server pushes{"type":"bot",bot_id,status,current_capital,total_pnl,total_trades,last_decision_at}per subscribed bot (re-scoped to the caller each time). Input is never echoed.aitrade_active_connectionsgauge inc/dec.
4. Wallets (auth: user)
Wallet types: spot_testnet→https://testnet.binance.vision (testnet), spot_live→https://api.binance.com, futures_testnet→https://testnet.binancefuture.com (testnet), futures_live→https://fapi.binance.com. Keys stored AES-256-GCM encrypted (ENCRYPTION_KEY); never returned.
GET /api/wallets
Response: array of {id, display_name, is_testnet, wallet_type, is_valid, api_key_hint, created_at}; api_key_hint is always •••• (no key material). Ordered newest first.
POST /api/wallets
Body: display_name string req (1..80); binance_api_key string req and binance_secret_key string req (16..256 alphanumeric); wallet_type string opt (one of 4 above); is_testnet bool opt default false (legacy; used only if wallet_type absent → spot_testnet/spot_live).
The key pair is checked with a signed read-only account call against the wallet's own environment (spot /api/v3/account, futures /fapi/v2/account). Response: {"id","status":"created","wallet_type","is_valid":bool,"validation":"valid|invalid|unverified"}. is_valid is true only on a Binance 200; unverified (geo-block 451, network error, 5xx) is stored as false. Each secret is encrypted with its own nonce.
Errors: 400 Invalid wallet_type '<t>'. Allowed: spot_testnet, spot_live, futures_testnet, futures_live; 500 encryption errors. No wallet-count limit.
PATCH /api/wallets/{id}
Body (all opt): display_name (trimmed non-empty, ≤80 else 400 display_name must be 1..80 chars), wallet_type (validated as above; updates base_url+is_testnet).
Changing wallet_type resets is_valid to false. Response {"status":"updated"}; 404 Wallet not found for a foreign or unknown id.
PATCH /api/wallets/{id}/keys
Body: binance_api_key req, binance_secret_key req. Validated like POST against the wallet's own environment.
Response {"status":"updated","is_valid":bool,"validation":"valid|invalid|unverified"}. Errors: 404 Wallet not found; 400 for malformed keys.
DELETE /api/wallets/{id}
Response {"status":"deleted"}. Errors: 409 Cannot delete: N bot(s) are using this wallet. Remove or reassign bots first.; 404 Wallet not found.
5. Bots (auth: user)
BotResponse fields: id, name, status, mode, ai_provider, ai_model, symbols[], indicators[]|null, initial_capital, current_capital, total_pnl, total_trades, winning_trades, last_decision_at, last_regime, last_indicators(json), is_paper (=wallet.is_testnet, default true), created_at, custom_instructions, risk_config(json), mode_config(json), leverage, margin_type, leverage_locked, decide_all_symbols, quote_currency, wallet_id, user_llm_id, supervisor_enabled, supervisor_auto_apply, supervisor_prompt, supervisor_last_run, supervisor_total_runs, supervisor_total_changes, margin_in_positions, unrealized_pnl, total_value (total_value = current_capital + open margin + open unrealized_pnl). Status values seen: active, paused, stopped, deleted.
GET /api/bots
array of BotResponse (newest first)
GET /api/bots/{id}
BotResponse; 404 Bot not found
POST /api/bots
| field | type | req | default / notes |
|---|---|---|---|
| name | string | yes | no validation |
| wallet_id | uuid | yes | not verified to belong to user |
| symbols | string[] | yes | |
| initial_capital | number | yes | also sets current_capital; unrepresentable → 0 |
| mode | string | no | autonomous (not validated on create) |
| ai_provider | string | no | deepseek |
| ai_model | string | no | deepseek-v4-flash |
| custom_instructions | string | no | "" |
| refresh_interval_secs | int | no | 300 (no minimum on create) |
| indicators | string[] | no | [] |
| indicator_mode | string | no | selected |
| risk_config / mode_config | object | no | {} |
| leverage | int | no | 1 (max(1)) |
| margin_type | string | no | spot |
| decide_all_symbols | bool | no | false |
| quote_currency | string | no | USDT |
| user_llm_id | uuid | no | BYO LLM |
| supervisor_enabled / supervisor_auto_apply | bool | no | true / true |
| supervisor_prompt | string | no | "" (no 500 limit on create) |
Response {"id","status":"created"}. Validation: name 1..80, symbols ≤50 upper-case pairs, initial_capital > 0, leverage 1..125, margin_type spot|isolated|cross, mode one of the five (normalised), quote_currency 2-15 upper-case, custom_instructions ≤4000, refresh_interval_secs ≥30. wallet_id and user_llm_id must be the caller's → else 404 Wallet not found / LLM not found. Errors: 409 Bot limit reached (count/limit) (count excludes status='deleted'); 400 validation.
PATCH /api/bots/{id}
Body (all optional): name, mode, custom_instructions, indicators[], indicator_mode, risk_config, mode_config, leverage, margin_type, refresh_interval_secs, leaderboard_opt_in, decide_all_symbols, symbols[], ai_provider, ai_model, user_llm_id.
- mode: normalized (trim, lowercase, spaces/-→_); must be autonomous|dca|grid|momentum|mean_reversion else 400 Invalid mode '<m>'. Valid modes: autonomous, dca, grid, momentum, mean_reversion.
- indicators, indicator_mode, mode_config, refresh_interval_secs, symbols while status=='active' → 400 These fields require a paused bot (changing them invalidates indicator history or orphans positions). Pause first, then edit.
- leverage/margin_type after first trade (leverage_locked) → 400 Leverage and margin type are locked after the bot's first trade. Create a new bot to change them.; leverage clamped max(1); refresh_interval_secs clamped max(30).
- risk_config.max_position_size_pct is renamed to max_position_pct if the latter is absent.
- The whole body is validated (including user_llm_id ownership → 404, paused-only fields, leverage lock) before anything is written.
Response {"status":"updated"}; 404 Bot not found.
DELETE /api/bots/{id}
{"status":"deleted"} (hard DELETE); 404 Bot not found for a foreign or unknown id.
POST /api/bots/{id}/start
{"status":"started"} (sets status='active'; the engine picks it up next cycle); 404 Bot not found for a foreign or unknown id.
POST /api/bots/{id}/pause
{"status":"paused"}; 404 Bot not found for a foreign or unknown id.
POST /api/bots/kill-all
{"status":"all_killed"} (sets all user's active bots to stopped; positions NOT closed — TODO in code)
POST /api/bots/copy/{id}
Copies a leaderboard-opted-in bot (any user's, leaderboard_opt_in=true; no live/testnet filter) into the caller's first wallet (testnet wallets first) with name Copied Bot, initial/current capital 1000, copying mode, ai_provider, ai_model, symbols, indicators, indicator_mode, risk_config. Enforces the plan's bot limit (409).
Response {"id","status":"copied","mode":"paper"} (mode:"paper" is hardcoded; actual paper/live depends on wallet). Errors: 404 Bot not found on leaderboard; 400 Add a wallet first.
6. Scripts & Supervisor (auth: user)
ScriptVersion: {id, bot_id, version, source, is_active, performance_snapshot(json), created_at, created_by, supervisor_reasoning, approved_at, approved_by, declined_at}.
Compile/backtest are proxied to the assistant service ASSISTANT_URL (default http://aitrade-assistant:8090).
POST /api/scripts/compile
Body {source: string} req. Source ≤100 KB. Proxies POST {assistant}/api/thunderscript/compile (10 s) with the caller's JWT. Response: assistant JSON passed through verbatim (code reads errors[], ast; full shape unclear). Errors: 500 Compiler service unavailable: … / Compiler response invalid: ….
GET /api/bots/{id}/scripts
ScriptVersion[] ordered version DESC; 404 Bot not found for a foreign bot.
POST /api/bots/{id}/scripts
Body {source: string}. Verifies ownership (404 Bot not found), upgrades bot_type to thunderscript, compiles via assistant, stores new version (max+1), snapshots performance, makes it active.
Response {"id","version","status":"compiled_and_active"}. Errors: 400 Script errors: [...] (compiler errors, Debug-formatted), 400 Compilation produced no AST, 500 Compiler: … / Compiler response: ….
POST /api/bots/{id}/scripts/{version}/activate (version int)
Response {"status":"activated","version":N}; 404 Script version not found.
POST /api/bots/{id}/scripts/{script_id}/approve
script_id must be a version of this bot, and the bot must be the caller's (404 Script not found otherwise). Deactivates all, activates script_id (sets approved_at/by), sets bot active_script_id, marks approval_request notifications for the script read. Response {"status":"approved"}.
POST /api/bots/{id}/scripts/{script_id}/decline
Sets declined_at; marks notification read. Response {"status":"declined"}; 404 Script not found (script must belong to the caller's bot).
PATCH /api/bots/{id}/supervisor
Body (opt): supervisor_enabled bool, supervisor_auto_apply bool, supervisor_prompt string (≤500 bytes else 400 supervisor_prompt cannot exceed 500 chars). Response {"status":"updated"}; 404 Bot not found.
7. Trades & Activity (auth: user)
GET /api/bots/{id}/trades
Query: limit int default 50 (clamped 1..500), offset int default 0 (≥0).
Response array: {id, bot_id, symbol, side, quantity, price, pnl|null, is_paper, ai_reasoning|null, created_at, leverage|null}; newest first. 404 Bot not found for a foreign bot.
GET /api/bots/{id}/activity
Query: limit default 20 (clamped 1..500; offset ignored). 404 for a foreign bot. Response array: {action, symbol, price, quantity, reasoning, indicators_snapshot, market_snapshot, regime, is_actionable, ai_model_used, conditions_detail, created_at}.
GET /api/bots/{id}/positions
Open positions only: {id, symbol, side, quantity, entry_price, current_price, stop_loss, take_profit, unrealized_pnl, status, opened_at, leverage, margin_used, trailing_stop_enabled, trailing_stop_callback_pct, trailing_stop_activation_price, trailing_stop_highest_price}.
GET /api/bots/{id}/stats
404 Bot not found. Response:
{ account:{total_value, available_cash, margin_in_positions, unrealized_pnl, open_positions_count},
totals:{total_pnl, total_fees, open_fees, close_fees, net_realized}, // net_realized = sum(sell pnl) - open_fees
stats:{average_leverage, average_confidence, biggest_win, biggest_loss, trade_count},
hold_times_seconds:{long, short},
active_positions:[PositionResponse...],
recent_trades:[{id,symbol,side,quantity,price,pnl,notional,fee_amount,leverage,confidence,fee_bnb_used,created_at}] (last 25) }
GET /api/bots/{id}/performance
Latest performance_snapshots row or zeros: {total_pnl, daily_pnl, win_rate, sharpe_ratio, max_drawdown, total_trades} (404 Bot not found for a foreign or unknown bot).
GET /api/trades/export
All user's trades, max 10 000, newest first. Response body CSV text (content-type not set explicitly → text/plain; charset=utf-8): header id,bot_id,symbol,side,quantity,price,pnl,is_paper,leverage,created_at.
POST /api/positions/{id}/close
Manually closes an open position at positions.current_price (fallback entry price); records a sell trade, closes position, updates bot capital/pnl, inserts activity. Does not place an exchange order (DB bookkeeping only, despite comments). Fee 0.05% if leverage>1 else 0.10%.
Body {reason?: string} (body must be sent as JSON object; default reason Manual close (user-initiated)).
Response {"status":"closed","pnl":number,"close_price":number}. Errors: 404 Position not found or already closed.
Blocked symbols
- GET
/api/blocked-symbols→[{symbol, note|null, blocked_at}]ordered by symbol. - POST
/api/blocked-symbolsbody{symbol: string req, note?: string}; symbol trimmed + uppercased, 1..40 chars else 400Invalid symbol; upsert. Response{"status":"blocked","symbol"}. - DELETE
/api/blocked-symbols/{symbol}→{"status":"unblocked","symbol"}(idempotent).
8. Market (auth: user) — all stubs
- GET
/api/market/prices→[]. - GET
/api/market/indicators(querysymbol, indicators, intervalaccepted, ignored) →{"indicators":[]}. - GET
/api/market/regime→{"regime":"unknown","confidence":0}.
9. Sentiment (auth: user) — stub
GET /api/sentiment → hardcoded {fear_greed_index:50, fear_greed_label:"Neutral", btc_funding_rate:0.0001, eth_funding_rate:0.0001, btc_open_interest_change:0.0}.
10. Backtest (auth: user)
POST /api/backtest
Body (all opt): bot_id uuid (ownership check → 404 Bot not found), symbol string, symbols string[], timeframe string (default 1h), days int (default 42), initial_capital number (default 10000), leverage int, source string. Validated: days 1..365, initial_capital > 0, leverage 1..125, ≤10 symbols, source ≤100 KB. Proxied to {assistant}/api/thunderscript/backtest (120 s, synchronous) with the caller's JWT. Response: assistant JSON verbatim (shape unclear). Errors: 500 Backtest service unavailable: … / Backtest response invalid: …. Upstream non-2xx bodies are passed through as 200 if JSON.
11. Marketplace (auth: user for ALL, incl. GET)
MarketplaceStrategy: {id, user_id, author_email, name, description, source, tags[], downloads, upvotes, snapshot_total_pnl, snapshot_total_trades, snapshot_winning_trades, snapshot_window_days, strategy_family, leverage, regime_context, snapshot_win_rate, created_at, user_upvoted}. author_email is masked (a***@domain) except on the caller's own strategies. Full source of public strategies is visible.
GET /api/marketplace
Query: sort (upvotes|recent|performance, else downloads-then-upvotes), q (ILIKE on name/description), tag (exact tag), mine bool (own strategies incl. private; else public only), limit default 50, clamped 1..200. Empty tag/q are ignored; %/_ in q are literal.
GET /api/marketplace/{id}
public strategy or own; 404 Strategy not found.
POST /api/marketplace
Body: name string req (non-empty, ≤120), source string req (non-empty), description string opt (""), tags string[] opt ([]), is_public bool opt (true), bot_id uuid opt (must be the caller's → else 404; attaches snapshot pnl/trades/wins/days/family(mode)/leverage/regime + win_rate). Limits: description ≤4000, source ≤100 KB, ≤10 tags of 1-32 chars. Response {"id","status":"published"}. Errors 400 Name and source are required, Name must be ≤120 chars.
POST /api/marketplace/{id}/download
Increments counter; response {source, name, author_email}; 404 Strategy not found (also for others' private).
POST /api/marketplace/{id}/upvote
Toggle; response {"upvoted":bool,"upvotes":int}. 404 Strategy not found unless the strategy is public or the caller's.
DELETE /api/marketplace/{id}
{"status":"deleted"}; 404 Strategy not found.
12. Chat (auth: user)
POST /api/chat
Served by aitrade-assistant (the ingress routes /api/chat there); the backend has no chat route. See the AI Assistant page for the request/response contract.
13. LLMs — BYO (auth: user; max 10 per user)
Providers: openai, anthropic, google, deepseek, openrouter, openai_compatible, ollama, custom. Default base URLs for the first five; openai_compatible|ollama|custom require base_url.
GET /api/llms
[{id, provider, name, base_url, model, max_tokens, max_context, is_active, created_at, last_used_at, api_key_masked:"•••• stored"}]
POST /api/llms
Body: provider req, name req (non-blank), model req (non-blank), api_key req (trimmed len ≥10), base_url opt (must be https://, no credentials, and a public host: internal names such as localhost, *.svc, *.cluster.local, *.internal and private/loopback/link-local/CGNAT addresses — literal or resolved — are rejected; the engine re-checks at call time), max_tokens opt default 1500 clamped 100..32000, max_context opt default 8000 clamped 1000..1 000 000.
Response {"id","status":"created","api_key_masked":"abc…wxyz"}. Errors 400: Unsupported provider: X, Name is required, Model is required, API key looks too short, base_url is required for this provider, base_url must use https://, base_url must be a public host, You can save at most 10 LLMs. Delete one first.
PATCH /api/llms/{id}
Body (opt): name (non-blank else 400 Name cannot be empty), model, max_tokens (clamped), max_context (clamped), is_active. Response {"status":"updated"}; 404 LLM not found.
PATCH /api/llms/{id}/key
Body {api_key} (≥10 chars else 400). Response {"status":"rotated","api_key_masked"}; 404 LLM not found.
DELETE /api/llms/{id}
{"status":"deleted"}; 404 LLM not found. (Bots referencing it get user_llm_id NULL via FK.)
14. Notifications (auth: user)
Notification: {id, type, title, body|null, read, created_at, metadata|null}.
- GET /api/notifications → {notifications: Notification[] (latest 100), unread: int}.
- POST /api/notifications body {type: string req, title: string req, body?: string} → created Notification. type 1-32 of [a-z_] (the server-only approval_request is rejected), title ≤200, body ≤2000.
- POST /api/notifications/read-all → {"status":"all_read"}.
- PATCH /api/notifications/{id}/read → {"status":"read"}; 404 if not the caller's.
- DELETE /api/notifications/{id} → {"status":"deleted"}; 404 if not the caller's.
- DELETE /api/notifications → {"status":"deleted_all"}.
15. Devices (auth: user)
- POST
/api/devices/tokensbody{token: string req (non-blank, ≤500), platform: "ios"|"android" req, bundle_id?: string, device_name?: string}. Upsert on token (reassigns to caller). Response{"status":"registered"}. Errors 400platform must be 'ios' or 'android',invalid token. - DELETE
/api/devices/tokens/{token}→{"status":"removed"}(only deletes caller's token; idempotent).
16. Workspace state (auth: user)
{device_type} path: "mobile" → mobile; any other value → desktop.
- GET /api/workspace-state/{device_type} → {state: <json>, updated_at: ts|null}; none stored → {state:{}, updated_at:null}.
- PUT /api/workspace-state/{device_type} body {state: any JSON req} → {"status":"ok"}. No size limit in code (axum default body limit 2 MB applies). See CORS note about PUT.
17. Intelligence (auth: user)
Global anonymized strategy_edges aggregates, k-anonymity gated via system_config keys intel_kanon_min_occurrences (default 20) and intel_kanon_min_contributors (default 3). Query struct shared: regime, family, timeframe, limit (each endpoint uses a subset).
- GET /api/intelligence/summary → {total_cells, mature_cells, total_observations, families, symbols} (all int).
- GET /api/intelligence/best-setups query regime, family, timeframe, limit (default 50, max 200) → EdgeRow[] {asset_bucket, symbol(null), timeframe, regime, strategy_family, signature, n, win_rate, avg_ret_pct, contributors} sorted by avg_ret_pct desc.
- GET /api/intelligence/coins query regime, family, limit (50/200; timeframe ignored as filter) → EdgeRow[] with symbol set and signature:"".
- GET /api/intelligence/params query family, regime, limit (default 100, max 300) → [{strategy_family, regime, sl_bucket, tp_bucket, lev_bucket, n, win_rate, avg_ret_pct}].
18. Leaderboard (public)
GET /api/leaderboard — no params. Top 50 bots by latest snapshot total_pnl where leaderboard_opt_in, wallet is not testnet (live only), not deleted. Response [{bot_id, bot_name, ai_model, mode, total_pnl, win_rate, sharpe_ratio, max_drawdown, total_trades}]. DB errors are swallowed → [].
19. Plans (public)
GET /api/plans — proxies https://auth.thundera.eu/api/v1/auth/billing/plans?app=aitrade (5 s timeout), passes through the JSON ({"plans":[...]}; plan fields unclear), 60 s cache; serves last good cache if upstream fails. Errors: 502 {"error":"upstream_bad_response"} / {"error":"upstream_error"}; 503 {"error":"billing_unavailable"}; 500 {"error":"internal"}.
20. System admin (auth: system-admin or system-super-admin; else 403 Forbidden)
- GET
/api/system/config→[{key, value(string), description|null}]. - PATCH
/api/system/configbody{key: string, value: string}both req. Updates an existing key only (unknown key → 404; value ≤2000). - GET
/api/system/users→[{id, email, name, plan, created_at, bot_count}](no pagination). - PATCH
/api/system/users/{id}/config(id=app_users.id) body all opt:bot_limitint,indicator_limitint,instructions_limitint,chat_enabledbool; upsert, null/omitted keeps existing. →{"status":"updated"}. (Unknown user → 404.) - GET
/api/system/ai/providers→[{id, name, display_name, provider_type, base_url, default_model, default_temperature, default_max_tokens, default_max_context, failover_chain, max_retries, is_active, sort_order, notes, has_api_key}]orderedsort_order, name. - POST
/api/system/ai/providersbody:namereq,display_namereq,provider_typereq (openai_compatible|anthropic|google|metaelse 400provider_type must be openai_compatible|anthropic|google|meta),default_modelreq,base_url?,default_temperature?(0.10),default_max_tokens?(2500),default_max_context?(32000),failover_chain?string[] ([]),max_retries?,api_key?(blank ignored),is_active?(true),sort_order?(100),notes?. →{"id","status":"created"}(duplicate name → 500 DB error). - PATCH
/api/system/ai/providers/{id}body all opt:display_name, base_url, default_model, default_temperature, default_max_tokens, default_max_context, failover_chain[], max_retries, is_active, sort_order, notes→{"status":"updated"}(no 404 on unknown id;name/provider_typenot editable). - PATCH
/api/system/ai/providers/{id}/keybody{api_key: string}; blank → clears key →{"status":"cleared"}; else{"status":"rotated"}. - DELETE
/api/system/ai/providers/{id}→{"status":"deleted"}; 404Provider not found. - GET
/api/system/ai/counters→ up to 200 users:[{user_id, email, name, plan, byoai_calls, byoai_failures, byoai_stops, system_calls, system_failures, system_fallbacks, last_call_at}]ordered by total calls desc.