feat: Claude CLI driven LLM trade flow + token UI + 7 risk guards#96
Conversation
PR3 of 3 in the LLM-driven Binance Futures pivot. Wires user input → Claude
signal → futures execution end-to-end.
Architecture decision: api-server hosts the Claude CLI subprocess for the
synchronous /signal call. The plan's worker-hosted variant would need Kafka
request-reply for sync HTTP, which is a heavier pattern than warranted for a
single-user app. Token decryption is contained to the LLM call site, then
released. Real-trade execution still goes through the existing worker saga.
What's added:
- Claude Code CLI installed in `coin-base` (used by api-server signal flow
and by future worker tooling). `claude --version` runs at base build.
- ClaudeTokens module: AES-256-GCM encrypted storage of per-user OAuth
tokens (`POST/GET/DELETE /claude-tokens`). Reuses existing encryption
helpers and JWT auth.
- `/settings/claude` UI: paste token, status badge, replace, delete; links
to `claude setup-token` instructions.
- LlmCliService (api-server): pure cli-runner spawn helper + queue=1
service with 30s timeout, 1 retry, strict JSON parsing, and TP/SL
geometry validation (LONG: sl<entry<tp; SHORT: tp<entry<sl). System
prompt at `src/llm/prompts/trading-system.md` ships via Nest assets.
- LlmTrades module: two-step flow.
- `POST /llm-trades/signal` — fetch candles via fapi public, call Claude,
persist `LlmDecisionLog`, return signal+TP/SL+reasoning+entryPrice.
- `POST /llm-trades/execute` — derive base-asset quantity from
bet/leverage/entry, create Order row, publish OrderRequestedEvent on
the existing topic. Picks testnet ExchangeKey by default if user has
one.
- `/llm-trade` UI: 5 inputs (symbol top-10 / interval / candle count /
leverage / bet USDT), Get Signal, response panel with TP/SL override,
confirm dialog, execute. Real-time results piggyback on existing
`order:updated` WS.
- Worker RiskGuardService: seven guards run before any real-mode order:
KILL_SWITCH_REAL_TRADING, ENABLE_REAL_MAINNET, MAX_LEVERAGE (default 20),
LLM_COOLDOWN_SECONDS per-user (Redis SETNX, default 30s),
MAX_OPEN_POSITIONS_PER_USER (default 1), DAILY_LOSS_LIMIT_USDT
(mainnet only, default 50), MAX_BET_PCT of available margin (mainnet
only, default 10%). Mainnet-specific guards are no-ops on testnet.
Hooked into `OrdersService.handleOrderRequested` so all real orders
(LLM or otherwise) clear the gate.
Verified:
- pnpm build green across all 9 workspace packages
- coin-base rebuilt; `claude --version` succeeds inside both api-server and
worker containers
- docker compose dev: postgres/redis/kafka healthy, api-server /api/health
200, web /llm-trade and /settings/claude render
Manual end-to-end smoke (user does once a Claude token is registered):
1. /settings/claude → paste OAuth token from `claude setup-token`
2. /settings → register a Binance Futures **testnet** API key
3. /llm-trade → fill 5 inputs, Get Signal, confirm, Execute
4. Verify position appears on testnet.binancefuture.com with TP/SL attached
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
|
The latest updates on your projects. Learn more about Vercel for GitHub.
|
Reviewer's GuideImplements an end-to-end Claude-CLI–driven futures trading flow (token management, signal generation, and execution) plus a worker-side risk guard layer, and surfaces it via new /settings/claude and /llm-trade web UIs. Sequence diagram for LLM signal generation via Claude CLIsequenceDiagram
actor User
participant WebApp as WebApp_llm_trade_UI
participant Api as ApiServer
participant LlmCtrl as LlmTradesController
participant LlmSvc as LlmTradesService
participant TokenSvc as ClaudeTokensService
participant Binance as BinanceRest
participant LlmCli as LlmCliService
participant ClaudeCLI as Claude_CLI_subprocess
participant Prisma as PrismaService
User->>WebApp: Click "Signal 받기"
WebApp->>Api: POST /llm-trades/signal
Api->>LlmCtrl: Route request
LlmCtrl->>LlmSvc: signal(userId, RequestSignalDto)
LlmSvc->>TokenSvc: getDecrypted(userId)
TokenSvc-->>LlmSvc: oauthToken
LlmSvc->>Binance: getCandles(symbol, interval, candleCount)
Binance-->>LlmSvc: candles
LlmSvc->>LlmCli: decide(oauthToken, symbol, interval, candles)
LlmCli->>ClaudeCLI: runClaudeCli(prompt, oauthToken, systemPromptFile, model, timeoutMs)
ClaudeCLI-->>LlmCli: stdout(JSON envelope), stderr, exitCode
LlmCli-->>LlmSvc: LlmDecision(signal, takeProfitPrice, stopLossPrice, reasoning, rawResponse, latencyMs, model)
LlmSvc->>Prisma: llmDecisionLog.create(...)
Prisma-->>LlmSvc: LlmDecisionLog
LlmSvc-->>LlmCtrl: decision + entryPrice + candles
LlmCtrl-->>Api: HTTP 200 JSON
Api-->>WebApp: SignalResponse
WebApp-->>User: Show signal, TP, SL, reasoning
Sequence diagram for LLM trade execution with worker risk guardssequenceDiagram
actor User
participant WebApp as WebApp_llm_trade_UI
participant Api as ApiServer
participant LlmCtrl as LlmTradesController
participant LlmSvc as LlmTradesService
participant PrismaApi as PrismaService_Api
participant Kafka as KafkaBroker
participant Worker as WorkerService
participant OrdersSvc as OrdersService
participant PrismaWrk as PrismaService_Worker
participant RiskSvc as RiskGuardService
participant Redis as Redis
participant Exchange as BinanceFutures
User->>WebApp: Click "거래 실행"
WebApp->>Api: POST /llm-trades/execute
Api->>LlmCtrl: Route request
LlmCtrl->>LlmSvc: execute(userId, ExecuteTradeDto)
LlmSvc->>PrismaApi: exchangeKey.findMany(userId, exchange=binance)
PrismaApi-->>LlmSvc: exchangeKeys
LlmSvc->>PrismaApi: order.create(..., mode=real, status=pending)
PrismaApi-->>LlmSvc: order
LlmSvc->>Kafka: send(TRADING_ORDER_REQUESTED, OrderRequestedEvent)
Kafka-->>Worker: OrderRequestedEvent
Worker->>OrdersSvc: handleOrderRequested(event)
OrdersSvc->>PrismaWrk: exchangeKey.findFirst(id=exchangeKeyId, userId)
PrismaWrk-->>OrdersSvc: exchangeKey(network)
OrdersSvc->>RiskSvc: checkAll(userId, network, order)
RiskSvc->>Redis: set cooldown key (SETNX EX)
Redis-->>RiskSvc: ok or existing
RiskSvc->>PrismaWrk: order.count(open positions)
PrismaWrk-->>RiskSvc: openCount
RiskSvc->>PrismaWrk: order.findMany(closed today, realizedPnl)
PrismaWrk-->>RiskSvc: todayOrders
RiskSvc-->>OrdersSvc: GuardCheck(ok or reason)
alt GuardCheck not ok
OrdersSvc-->>Worker: throw Error("Risk guard: reason")
Worker-->>Kafka: OrderResultEvent(failed)
else GuardCheck ok
OrdersSvc->>Exchange: executeRealOrderSaga(...)
Exchange-->>OrdersSvc: order result
OrdersSvc->>Kafka: OrderResultEvent(success)
end
Kafka-->>Api: order:updated WebSocket event
Api-->>WebApp: Order status update
WebApp-->>User: Show execution result in activity log
ER diagram for Claude tokens, LLM decision logs, and orderserDiagram
USER ||--o| CLAUDE_TOKEN : has
USER ||--o{ EXCHANGE_KEY : owns
USER ||--o{ ORDER : places
USER ||--o{ LLM_DECISION_LOG : creates
EXCHANGE_KEY ||--o{ ORDER : used_for
USER {
string id
string email
}
CLAUDE_TOKEN {
string userId
string encryptedToken
datetime createdAt
datetime updatedAt
}
LLM_DECISION_LOG {
string id
string userId
string prompt
jsonb parsedSignal
string model
integer latencyMs
text rawResponse
datetime createdAt
}
EXCHANGE_KEY {
string id
string userId
string exchange
string network
}
ORDER {
string id
string userId
string exchangeKeyId
string exchange
string symbol
string side
string type
string mode
string status
numeric quantity
integer leverage
string marginType
string positionSide
numeric takeProfitPrice
numeric stopLossPrice
numeric realizedPnl
datetime closedAt
}
Class diagram for new LLM trading, Claude token, and risk guard componentsclassDiagram
class LlmCliService {
-Logger logger
-string model
-QueueItem[] queue
-boolean running
+Promise~LlmDecision~ decide(oauthToken string, symbol string, interval string, candles Candle[])
-Promise~void~ drain()
-Promise~LlmDecision~ runOnce(oauthToken string, symbol string, interval string, candles Candle[])
-string buildUserPrompt(symbol string, interval string, candles Candle[])
-LlmDecision parse(cliStdout string, candles Candle[])
}
class LlmDecisionInput {
string oauthToken
string symbol
string interval
Candle[] candles
}
class LlmDecision {
string signal
string takeProfitPrice
string stopLossPrice
string reasoning
string rawResponse
number latencyMs
string model
}
class ClaudeCliOptions {
string prompt
string oauthToken
string systemPromptFile
string model
number timeoutMs
}
class ClaudeCliResult {
string stdout
string stderr
number exitCode
number durationMs
}
class LlmModule {
}
class ClaudeTokensService {
-Logger logger
-ConfigService config
-PrismaService prisma
-string masterKey
+Promise~Status~ getStatus(userId string)
+Promise~SaveResult~ save(userId string, token string)
+Promise~void~ delete(userId string)
+Promise~string~ getDecrypted(userId string)
}
class ClaudeTokensController {
-ClaudeTokensService service
+status(user Id)
+save(user Id, dto SaveClaudeTokenDto)
+remove(user Id)
}
class ClaudeTokensModule {
}
class SaveClaudeTokenDto {
string token
}
class RequestSignalDto {
string symbol
string interval
number candleCount
}
class ExecuteTradeDto {
string symbol
string side
number betUsdt
number leverage
string takeProfitPrice
string stopLossPrice
string entryPrice
string exchangeKeyId
}
class LlmTradesService {
-Logger logger
-BinanceRest binance
-Kafka kafka
-Producer producer
-boolean connected
-PrismaService prisma
-ClaudeTokensService tokens
-LlmCliService llm
+Promise~SignalResult~ signal(userId string, dto RequestSignalDto)
+Promise~ExecuteResult~ execute(userId string, dto ExecuteTradeDto)
+Promise~void~ onModuleInit()
+Promise~void~ onModuleDestroy()
}
class LlmTradesController {
-LlmTradesService service
+signal(user Id, dto RequestSignalDto)
+execute(user Id, dto ExecuteTradeDto)
}
class LlmTradesModule {
}
class RiskGuardService {
-Logger logger
-Redis redis
-PrismaService prisma
+Promise~GuardCheck~ checkAll(ctx GuardContext)
-Promise~GuardCheck~ killSwitch(ctx GuardContext)
-Promise~GuardCheck~ maxLeverage(ctx GuardContext)
-Promise~GuardCheck~ cooldown(ctx GuardContext)
-Promise~GuardCheck~ maxOpenPositions(ctx GuardContext)
-Promise~GuardCheck~ dailyLossLimit(ctx GuardContext)
-Promise~GuardCheck~ maxBetPct(ctx GuardContext)
}
class GuardContext {
string userId
string network
OrderRequest order
number availableUsdt
}
class GuardCheck {
boolean ok
string reason
}
class RiskModule {
}
class OrdersService {
-PrismaService prisma
-RiskGuardService riskGuard
+handleOrderRequested(event OrderRequestedEvent)
}
LlmModule --> LlmCliService
LlmTradesModule --> LlmTradesService
LlmTradesModule --> LlmTradesController
LlmTradesModule --> LlmModule
LlmTradesModule --> ClaudeTokensModule
ClaudeTokensModule --> ClaudeTokensService
ClaudeTokensModule --> ClaudeTokensController
ClaudeTokensController --> SaveClaudeTokenDto
LlmTradesService --> RequestSignalDto
LlmTradesService --> ExecuteTradeDto
LlmTradesService --> ClaudeTokensService
LlmTradesService --> LlmCliService
LlmTradesController --> LlmTradesService
RiskModule --> RiskGuardService
OrdersService --> RiskGuardService
LlmCliService --> ClaudeCliOptions
LlmCliService --> ClaudeCliResult
LlmCliService --> LlmDecision
LlmCliService --> LlmDecisionInput
RiskGuardService --> GuardContext
RiskGuardService --> GuardCheck
File-Level Changes
Tips and commandsInteracting with Sourcery
Customizing Your ExperienceAccess your dashboard to:
Getting Help
|
There was a problem hiding this comment.
Hey - I've found 1 issue, and left some high level feedback:
- In
runClaudeCli, the timeout handler callsrejectbut thecloseevent will still later callresolve, so you should track asettledflag or remove listeners after timeout to avoid double-settling the promise. - The
RiskGuardService.cooldownguard currently applies to all real-mode orders; if the intent is to only rate-limit LLM-driven trades, consider threading anisLlmOrderflag through the context so manual trades are not throttled by the same Redis cooldown key.
Prompt for AI Agents
Please address the comments from this code review:
## Overall Comments
- In `runClaudeCli`, the timeout handler calls `reject` but the `close` event will still later call `resolve`, so you should track a `settled` flag or remove listeners after timeout to avoid double-settling the promise.
- The `RiskGuardService.cooldown` guard currently applies to all real-mode orders; if the intent is to only rate-limit LLM-driven trades, consider threading an `isLlmOrder` flag through the context so manual trades are not throttled by the same Redis cooldown key.
## Individual Comments
### Comment 1
<location path="apps/api-server/src/claude-tokens/claude-tokens.service.ts" line_range="37-38" />
<code_context>
+ }
+
+ async delete(userId: string): Promise<void> {
+ await this.prisma.claudeToken.delete({ where: { userId } }).catch(() => {
+ throw new NotFoundException('Claude token not registered');
+ });
+ this.logger.log(`Claude token deleted for user ${userId}`);
</code_context>
<issue_to_address>
**suggestion (bug_risk):** Catch-all delete error handling masks non-NotFound failures as 404s.
This `catch` converts every delete failure (including DB/connectivity issues) into a `NotFoundException`, which hides real errors and misleads clients. Instead, detect the specific "record not found" condition (e.g. Prisma `P2025`) and only map that to `NotFoundException`, letting other errors propagate unchanged.
Suggested implementation:
```typescript
async delete(userId: string): Promise<void> {
try {
await this.prisma.claudeToken.delete({ where: { userId } });
} catch (error) {
if (
error instanceof Prisma.PrismaClientKnownRequestError &&
error.code === 'P2025'
) {
throw new NotFoundException('Claude token not registered');
}
throw error;
}
this.logger.log(`Claude token deleted for user ${userId}`);
}
```
To compile correctly, ensure that `Prisma` is imported from `@prisma/client` at the top of this file. For example, if you currently have:
```ts
import { Injectable, Logger, NotFoundException } from '@nestjs/common';
```
you should also add:
```ts
import { Prisma } from '@prisma/client';
```
Adjust the import location to match your existing import style and ordering.
</issue_to_address>Help me be more useful! Please click 👍 or 👎 on each comment and I'll use the feedback to improve your reviews.
| await this.prisma.claudeToken.delete({ where: { userId } }).catch(() => { | ||
| throw new NotFoundException('Claude token not registered'); |
There was a problem hiding this comment.
suggestion (bug_risk): Catch-all delete error handling masks non-NotFound failures as 404s.
This catch converts every delete failure (including DB/connectivity issues) into a NotFoundException, which hides real errors and misleads clients. Instead, detect the specific "record not found" condition (e.g. Prisma P2025) and only map that to NotFoundException, letting other errors propagate unchanged.
Suggested implementation:
async delete(userId: string): Promise<void> {
try {
await this.prisma.claudeToken.delete({ where: { userId } });
} catch (error) {
if (
error instanceof Prisma.PrismaClientKnownRequestError &&
error.code === 'P2025'
) {
throw new NotFoundException('Claude token not registered');
}
throw error;
}
this.logger.log(`Claude token deleted for user ${userId}`);
}To compile correctly, ensure that Prisma is imported from @prisma/client at the top of this file. For example, if you currently have:
import { Injectable, Logger, NotFoundException } from '@nestjs/common';you should also add:
import { Prisma } from '@prisma/client';Adjust the import location to match your existing import style and ordering.
Stack of debugging fixes discovered while running PR3 against a real Binance
Futures account. Each one was a separate dead-end before the next was visible,
so they're bundled here as one commit that makes the feature work.
1. Encryption master key — `.env.dev` shipped with `ENCRYPTION_MASTER_KEY=`
empty, so AES-256-GCM blew up with "Invalid key length" the moment the
first ClaudeToken was saved. Confirmed unrelated to this PR but blocked
smoke testing; the value is now copied from `.env`.
2. Network awareness on the read path — `GetBalancesHandler` and
`GetOpenOrdersHandler` decrypted the ExchangeKey but never read
`key.network`, so testnet credentials were always sent at the mainnet
base URL and got -2015. Both handlers now thread `network` into
`ExchangeCredentials`.
3. Settings UI for testnet vs mainnet — `/settings` (Accounts) had no way
to choose network, so users could only register mainnet keys. Added a
network toggle (defaulting to **testnet** for safety) and updated the
`createExchangeKey` API client to accept it.
4. System prompt assets — `nest-cli.json` `assets` config didn't
actually copy `trading-system.md` into `dist/llm/prompts/` under
`nest start --watch`, so the CLI runner failed with "no system prompt
file". Inlined the prompt into a TS module (`prompts/trading-system.ts`),
reverted the assets config, and removed the .md file so there's a single
source of truth.
5. Subprocess hygiene for `claude -p` — the spawned CLI inherited an open
stdin pipe and stalled 3s waiting on it before exiting 1. Switched to
`stdio: ['ignore', 'pipe', 'pipe']` so the CLI sees stdin closed
immediately. Also explicitly delete `ANTHROPIC_API_KEY` /
`ANTHROPIC_AUTH_TOKEN` from the spawn env so the user OAuth token wins.
6. `--bare` flag — `--bare` skips per-user config but, in this CLI version,
it also disables `CLAUDE_CODE_OAUTH_TOKEN` env auth and the CLI returns
"Not logged in." Dropped `--bare`; we still pass `--tools ""` to keep
the run side-effect free.
7. Lot-size precision — quantity was computed as
`(bet × leverage) / entry`.toFixed(6), which Binance rejected with -1111
on BTCUSDT (stepSize 0.001). LlmTradesService now fetches
`getSymbolFilter`, floors the raw quantity to the LOT_SIZE step, and
returns a clear error if the snapped notional is below MIN_NOTIONAL.
8. Conditional orders moved to algoOrder — Binance migrated TP/SL types
to a new endpoint on 2025-11-06; `/fapi/v1/order` now returns -4120
"use Algo Order API endpoints instead" for STOP_MARKET /
TAKE_PROFIT_MARKET regardless of params or account region (verified by
freqtrade issue #12610 + the change-log entry). Switched
`placeStopLoss`/`placeTakeProfit` to `POST /fapi/v1/algoOrder` with the
new schema:
- required `algoType: 'CONDITIONAL'`
- `stopPrice` renamed to `triggerPrice`
- response carries `algoId` instead of `orderId`
The saga's `AttachTpSlStep` is back to placing real exchange-side TP/SL
(no client-side watcher needed) and on attach failure compensates by
force-closing the position.
End-to-end smoke test against Binance Futures testnet now succeeds:
entry MARKET fills, then both TP and SL are attached as conditional algo
orders with `algoStatus: NEW` and survive the saga to UpdateDb +
PublishResult.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Summary
PR3 of 3 — wires user input → Claude signal → futures execution end-to-end.
Stacks on top of #95 (PR2). Re-target to `dev` after #94 → #95 → this lands.
Architecture choice
The plan called for the LLM call to live in worker-service. For the synchronous `/signal` call this would have required Kafka request-reply (publish + correlation-id reply consumer + timeout match in a Map), which is heavier than warranted for a single-user app. Decision: api-server hosts the Claude CLI subprocess for the sync path, decrypts the user's OAuth token at the call site, and releases it. Real-trade execution still flows through the existing worker saga (the place where exchange API keys get decrypted) so the trust boundary for actual money movement is unchanged.
What's added
Test plan
Out of scope
🤖 Generated with Claude Code
Summary by Sourcery
Integrate a Claude CLI–driven LLM trading flow end-to-end, from encrypted user token management through signal generation to guarded futures order execution, with a dedicated UI.
New Features:
Enhancements:
Chores: