From f05a2c53e22509403a1644dd7797ed6d7c61fd5d Mon Sep 17 00:00:00 2001 From: fray-cloud Date: Fri, 1 May 2026 02:35:19 +0900 Subject: [PATCH 1/2] feat: Claude CLI driven LLM trade flow + token UI + 7 risk guards MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- Dockerfile.base | 5 + apps/api-server/nest-cli.json | 3 +- apps/api-server/src/app.module.ts | 4 + .../claude-tokens/claude-tokens.controller.ts | 33 +++ .../src/claude-tokens/claude-tokens.module.ts | 12 + .../claude-tokens/claude-tokens.service.ts | 49 ++++ .../dto/save-claude-token.dto.ts | 12 + .../src/llm-trades/dto/execute-trade.dto.ts | 47 ++++ .../src/llm-trades/dto/request-signal.dto.ts | 26 ++ .../src/llm-trades/llm-trades.controller.ts | 31 +++ .../src/llm-trades/llm-trades.module.ts | 13 + .../src/llm-trades/llm-trades.service.ts | 145 +++++++++++ apps/api-server/src/llm/cli-runner.ts | 86 +++++++ apps/api-server/src/llm/llm-cli.service.ts | 182 ++++++++++++++ apps/api-server/src/llm/llm.module.ts | 8 + .../src/llm/prompts/trading-system.md | 21 ++ apps/web/src/app/llm-trade/page.tsx | 5 + apps/web/src/app/settings/claude/page.tsx | 114 +++++++++ .../components/llm-trade/llm-trade-form.tsx | 225 ++++++++++++++++++ apps/web/src/components/nav-bar.tsx | 7 + apps/web/src/lib/api-client.ts | 82 +++++++ apps/worker-service/src/app.module.ts | 2 + .../src/orders/orders.module.ts | 2 + .../src/orders/orders.service.ts | 20 +- .../src/risk/risk-guard.service.ts | 150 ++++++++++++ apps/worker-service/src/risk/risk.module.ts | 8 + 26 files changed, 1290 insertions(+), 2 deletions(-) create mode 100644 apps/api-server/src/claude-tokens/claude-tokens.controller.ts create mode 100644 apps/api-server/src/claude-tokens/claude-tokens.module.ts create mode 100644 apps/api-server/src/claude-tokens/claude-tokens.service.ts create mode 100644 apps/api-server/src/claude-tokens/dto/save-claude-token.dto.ts create mode 100644 apps/api-server/src/llm-trades/dto/execute-trade.dto.ts create mode 100644 apps/api-server/src/llm-trades/dto/request-signal.dto.ts create mode 100644 apps/api-server/src/llm-trades/llm-trades.controller.ts create mode 100644 apps/api-server/src/llm-trades/llm-trades.module.ts create mode 100644 apps/api-server/src/llm-trades/llm-trades.service.ts create mode 100644 apps/api-server/src/llm/cli-runner.ts create mode 100644 apps/api-server/src/llm/llm-cli.service.ts create mode 100644 apps/api-server/src/llm/llm.module.ts create mode 100644 apps/api-server/src/llm/prompts/trading-system.md create mode 100644 apps/web/src/app/llm-trade/page.tsx create mode 100644 apps/web/src/app/settings/claude/page.tsx create mode 100644 apps/web/src/components/llm-trade/llm-trade-form.tsx create mode 100644 apps/worker-service/src/risk/risk-guard.service.ts create mode 100644 apps/worker-service/src/risk/risk.module.ts diff --git a/Dockerfile.base b/Dockerfile.base index 6ec2820..4074939 100644 --- a/Dockerfile.base +++ b/Dockerfile.base @@ -1,5 +1,10 @@ FROM node:20-alpine RUN corepack enable && corepack prepare pnpm@9.15.4 --activate + +# Claude Code CLI (used by api-server LLM signal flow). Pure Node package, no +# native deps, so it installs cleanly on alpine. +RUN npm install -g @anthropic-ai/claude-code && claude --version + WORKDIR /app # Install all monorepo dependencies diff --git a/apps/api-server/nest-cli.json b/apps/api-server/nest-cli.json index 89d7d6c..265d44c 100644 --- a/apps/api-server/nest-cli.json +++ b/apps/api-server/nest-cli.json @@ -3,6 +3,7 @@ "collection": "@nestjs/schematics", "sourceRoot": "src", "compilerOptions": { - "deleteOutDir": false + "deleteOutDir": false, + "assets": [{ "include": "**/*.md", "outDir": "dist", "watchAssets": true }] } } diff --git a/apps/api-server/src/app.module.ts b/apps/api-server/src/app.module.ts index 1b75501..2e40dff 100644 --- a/apps/api-server/src/app.module.ts +++ b/apps/api-server/src/app.module.ts @@ -13,6 +13,8 @@ import { OrdersModule } from './orders/orders.module'; import { NotificationsModule } from './notifications/notifications.module'; import { PortfolioModule } from './portfolio/portfolio.module'; import { ActivityModule } from './activity/activity.module'; +import { ClaudeTokensModule } from './claude-tokens/claude-tokens.module'; +import { LlmTradesModule } from './llm-trades/llm-trades.module'; import { DebugModule } from './debug/debug.module'; import { JwtAuthGuard } from './auth/guards/jwt-auth.guard'; @@ -49,6 +51,8 @@ import { JwtAuthGuard } from './auth/guards/jwt-auth.guard'; NotificationsModule, PortfolioModule, ActivityModule, + ClaudeTokensModule, + LlmTradesModule, DebugModule, ], controllers: [AppController], diff --git a/apps/api-server/src/claude-tokens/claude-tokens.controller.ts b/apps/api-server/src/claude-tokens/claude-tokens.controller.ts new file mode 100644 index 0000000..c3257c0 --- /dev/null +++ b/apps/api-server/src/claude-tokens/claude-tokens.controller.ts @@ -0,0 +1,33 @@ +import { Body, Controller, Delete, Get, HttpCode, Post, UseGuards } from '@nestjs/common'; +import { ApiBearerAuth, ApiOperation, ApiTags } from '@nestjs/swagger'; +import { JwtAuthGuard } from '../auth/guards/jwt-auth.guard'; +import { CurrentUser } from '../auth/decorators/current-user.decorator'; +import { ClaudeTokensService } from './claude-tokens.service'; +import { SaveClaudeTokenDto } from './dto/save-claude-token.dto'; + +@ApiTags('Claude Tokens') +@ApiBearerAuth() +@UseGuards(JwtAuthGuard) +@Controller('claude-tokens') +export class ClaudeTokensController { + constructor(private readonly service: ClaudeTokensService) {} + + @Get() + @ApiOperation({ summary: 'Check if a Claude OAuth token is registered for the user' }) + status(@CurrentUser() user: { id: string }) { + return this.service.getStatus(user.id); + } + + @Post() + @ApiOperation({ summary: 'Save / replace the user Claude OAuth token (encrypted at rest)' }) + save(@CurrentUser() user: { id: string }, @Body() dto: SaveClaudeTokenDto) { + return this.service.save(user.id, dto.token); + } + + @Delete() + @ApiOperation({ summary: 'Delete the user Claude OAuth token' }) + @HttpCode(204) + async remove(@CurrentUser() user: { id: string }) { + await this.service.delete(user.id); + } +} diff --git a/apps/api-server/src/claude-tokens/claude-tokens.module.ts b/apps/api-server/src/claude-tokens/claude-tokens.module.ts new file mode 100644 index 0000000..fe1c631 --- /dev/null +++ b/apps/api-server/src/claude-tokens/claude-tokens.module.ts @@ -0,0 +1,12 @@ +import { Module } from '@nestjs/common'; +import { PrismaModule } from '../prisma/prisma.module'; +import { ClaudeTokensController } from './claude-tokens.controller'; +import { ClaudeTokensService } from './claude-tokens.service'; + +@Module({ + imports: [PrismaModule], + controllers: [ClaudeTokensController], + providers: [ClaudeTokensService], + exports: [ClaudeTokensService], +}) +export class ClaudeTokensModule {} diff --git a/apps/api-server/src/claude-tokens/claude-tokens.service.ts b/apps/api-server/src/claude-tokens/claude-tokens.service.ts new file mode 100644 index 0000000..a724990 --- /dev/null +++ b/apps/api-server/src/claude-tokens/claude-tokens.service.ts @@ -0,0 +1,49 @@ +import { Injectable, NotFoundException, Logger } from '@nestjs/common'; +import { ConfigService } from '@nestjs/config'; +import { PrismaService } from '../prisma/prisma.service'; +import { encrypt, decrypt } from '@coin/utils'; + +@Injectable() +export class ClaudeTokensService { + private readonly logger = new Logger(ClaudeTokensService.name); + + constructor( + private readonly prisma: PrismaService, + private readonly config: ConfigService, + ) {} + + private get masterKey(): string { + return this.config.getOrThrow('ENCRYPTION_MASTER_KEY'); + } + + async getStatus(userId: string): Promise<{ registered: boolean; updatedAt?: Date }> { + const row = await this.prisma.claudeToken.findUnique({ where: { userId } }); + if (!row) return { registered: false }; + return { registered: true, updatedAt: row.updatedAt }; + } + + async save(userId: string, token: string): Promise<{ updatedAt: Date }> { + const encrypted = encrypt(token, this.masterKey); + const row = await this.prisma.claudeToken.upsert({ + where: { userId }, + update: { encryptedToken: encrypted }, + create: { userId, encryptedToken: encrypted }, + }); + this.logger.log(`Claude token saved for user ${userId}`); + return { updatedAt: row.updatedAt }; + } + + async delete(userId: string): Promise { + await this.prisma.claudeToken.delete({ where: { userId } }).catch(() => { + throw new NotFoundException('Claude token not registered'); + }); + this.logger.log(`Claude token deleted for user ${userId}`); + } + + /** Decrypt and return the raw OAuth token. Use only at LLM call time. */ + async getDecrypted(userId: string): Promise { + const row = await this.prisma.claudeToken.findUnique({ where: { userId } }); + if (!row) throw new NotFoundException('Claude token not registered'); + return decrypt(row.encryptedToken, this.masterKey); + } +} diff --git a/apps/api-server/src/claude-tokens/dto/save-claude-token.dto.ts b/apps/api-server/src/claude-tokens/dto/save-claude-token.dto.ts new file mode 100644 index 0000000..71cdb8b --- /dev/null +++ b/apps/api-server/src/claude-tokens/dto/save-claude-token.dto.ts @@ -0,0 +1,12 @@ +import { IsString, MinLength } from 'class-validator'; +import { ApiProperty } from '@nestjs/swagger'; + +export class SaveClaudeTokenDto { + @ApiProperty({ + description: 'Claude Code OAuth long-lived token (run `claude setup-token`)', + example: 'sk-ant-oat01-...', + }) + @IsString() + @MinLength(20) + token!: string; +} diff --git a/apps/api-server/src/llm-trades/dto/execute-trade.dto.ts b/apps/api-server/src/llm-trades/dto/execute-trade.dto.ts new file mode 100644 index 0000000..b8c389b --- /dev/null +++ b/apps/api-server/src/llm-trades/dto/execute-trade.dto.ts @@ -0,0 +1,47 @@ +import { IsString, IsIn, IsInt, IsOptional, Min, Max } from 'class-validator'; +import { Type } from 'class-transformer'; +import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger'; + +export class ExecuteTradeDto { + @ApiProperty({ description: 'USDT-M perpetual symbol', example: 'BTCUSDT' }) + @IsString() + symbol!: string; + + @ApiProperty({ description: '포지션 방향', enum: ['long', 'short'] }) + @IsIn(['long', 'short']) + side!: 'long' | 'short'; + + @ApiProperty({ description: '베팅비용 (증거금, USDT)', example: 50 }) + @Type(() => Number) + @Min(1) + betUsdt!: number; + + @ApiProperty({ description: '레버리지 (1-20)', example: 5 }) + @Type(() => Number) + @IsInt() + @Min(1) + @Max(20) + leverage!: number; + + @ApiProperty({ description: '익절 가격 (사용자가 LLM 응답에서 override 가능)', example: '67000' }) + @IsString() + takeProfitPrice!: string; + + @ApiProperty({ description: '손절 가격', example: '63000' }) + @IsString() + stopLossPrice!: string; + + @ApiProperty({ + description: '진입가 (LLM 응답 시점의 lastClose, 수량 계산에 사용)', + example: '65000', + }) + @IsString() + entryPrice!: string; + + @ApiPropertyOptional({ + description: '거래소 키 ID (지정하지 않으면 testnet 키 우선)', + }) + @IsOptional() + @IsString() + exchangeKeyId?: string; +} diff --git a/apps/api-server/src/llm-trades/dto/request-signal.dto.ts b/apps/api-server/src/llm-trades/dto/request-signal.dto.ts new file mode 100644 index 0000000..7446f4b --- /dev/null +++ b/apps/api-server/src/llm-trades/dto/request-signal.dto.ts @@ -0,0 +1,26 @@ +import { IsString, IsIn, IsInt, Min, Max } from 'class-validator'; +import { Type } from 'class-transformer'; +import { ApiProperty } from '@nestjs/swagger'; + +const ALLOWED_INTERVALS = ['1m', '5m', '15m', '1h', '4h', '1d'] as const; + +export class RequestSignalDto { + @ApiProperty({ description: 'USDT-M perpetual symbol', example: 'BTCUSDT' }) + @IsString() + symbol!: string; + + @ApiProperty({ + description: '캔들 간격', + enum: ALLOWED_INTERVALS, + example: '5m', + }) + @IsIn(ALLOWED_INTERVALS as unknown as string[]) + interval!: string; + + @ApiProperty({ description: '캔들 개수 (20-200)', example: 50 }) + @Type(() => Number) + @IsInt() + @Min(20) + @Max(200) + candleCount!: number; +} diff --git a/apps/api-server/src/llm-trades/llm-trades.controller.ts b/apps/api-server/src/llm-trades/llm-trades.controller.ts new file mode 100644 index 0000000..7ce80e8 --- /dev/null +++ b/apps/api-server/src/llm-trades/llm-trades.controller.ts @@ -0,0 +1,31 @@ +import { Body, Controller, Post, UseGuards } from '@nestjs/common'; +import { ApiBearerAuth, ApiOperation, ApiTags } from '@nestjs/swagger'; +import { JwtAuthGuard } from '../auth/guards/jwt-auth.guard'; +import { CurrentUser } from '../auth/decorators/current-user.decorator'; +import { LlmTradesService } from './llm-trades.service'; +import { RequestSignalDto } from './dto/request-signal.dto'; +import { ExecuteTradeDto } from './dto/execute-trade.dto'; + +@ApiTags('LLM Trades') +@ApiBearerAuth() +@UseGuards(JwtAuthGuard) +@Controller('llm-trades') +export class LlmTradesController { + constructor(private readonly service: LlmTradesService) {} + + @Post('signal') + @ApiOperation({ + summary: 'Fetch candles + ask Claude for long/short + TP/SL (sync, ~5-10s)', + }) + signal(@CurrentUser() user: { id: string }, @Body() dto: RequestSignalDto) { + return this.service.signal(user.id, dto); + } + + @Post('execute') + @ApiOperation({ + summary: 'Place futures market order with attached TP/SL via worker saga', + }) + execute(@CurrentUser() user: { id: string }, @Body() dto: ExecuteTradeDto) { + return this.service.execute(user.id, dto); + } +} diff --git a/apps/api-server/src/llm-trades/llm-trades.module.ts b/apps/api-server/src/llm-trades/llm-trades.module.ts new file mode 100644 index 0000000..5a2e755 --- /dev/null +++ b/apps/api-server/src/llm-trades/llm-trades.module.ts @@ -0,0 +1,13 @@ +import { Module } from '@nestjs/common'; +import { PrismaModule } from '../prisma/prisma.module'; +import { ClaudeTokensModule } from '../claude-tokens/claude-tokens.module'; +import { LlmModule } from '../llm/llm.module'; +import { LlmTradesController } from './llm-trades.controller'; +import { LlmTradesService } from './llm-trades.service'; + +@Module({ + imports: [PrismaModule, ClaudeTokensModule, LlmModule], + controllers: [LlmTradesController], + providers: [LlmTradesService], +}) +export class LlmTradesModule {} diff --git a/apps/api-server/src/llm-trades/llm-trades.service.ts b/apps/api-server/src/llm-trades/llm-trades.service.ts new file mode 100644 index 0000000..e585897 --- /dev/null +++ b/apps/api-server/src/llm-trades/llm-trades.service.ts @@ -0,0 +1,145 @@ +import { Injectable, Logger, NotFoundException, BadRequestException } from '@nestjs/common'; +import { Kafka, Producer } from 'kafkajs'; +import { KAFKA_TOPICS } from '@coin/kafka-contracts'; +import type { OrderRequestedEvent } from '@coin/kafka-contracts'; +import { BinanceRest } from '@coin/exchange-adapters'; +import type { Candle } from '@coin/types'; +import { randomUUID } from 'crypto'; +import { PrismaService } from '../prisma/prisma.service'; +import { ClaudeTokensService } from '../claude-tokens/claude-tokens.service'; +import { LlmCliService, LlmDecision } from '../llm/llm-cli.service'; +import { RequestSignalDto } from './dto/request-signal.dto'; +import { ExecuteTradeDto } from './dto/execute-trade.dto'; + +@Injectable() +export class LlmTradesService { + private readonly logger = new Logger(LlmTradesService.name); + private readonly binance = new BinanceRest(); + private kafka: Kafka; + private producer: Producer; + private connected = false; + + constructor( + private readonly prisma: PrismaService, + private readonly tokens: ClaudeTokensService, + private readonly llm: LlmCliService, + ) { + this.kafka = new Kafka({ + clientId: 'api-llm-trades', + brokers: (process.env.KAFKA_BROKERS || 'localhost:9092').split(','), + }); + this.producer = this.kafka.producer(); + } + + async onModuleInit() { + await this.producer.connect(); + this.connected = true; + } + + async onModuleDestroy() { + if (this.connected) await this.producer.disconnect(); + } + + async signal( + userId: string, + dto: RequestSignalDto, + ): Promise { + const oauthToken = await this.tokens.getDecrypted(userId); + const candles = await this.binance.getCandles(dto.symbol, dto.interval, dto.candleCount); + if (candles.length === 0) { + throw new BadRequestException(`No candles for ${dto.symbol} @ ${dto.interval}`); + } + const decision = await this.llm.decide({ + oauthToken, + symbol: dto.symbol, + interval: dto.interval, + candles, + }); + const entryPrice = candles[candles.length - 1].close; + + await this.prisma.llmDecisionLog.create({ + data: { + userId, + prompt: `${dto.symbol} ${dto.interval} ${dto.candleCount}`, + rawResponse: decision.rawResponse, + parsedSignal: { + signal: decision.signal, + takeProfitPrice: decision.takeProfitPrice, + stopLossPrice: decision.stopLossPrice, + reasoning: decision.reasoning, + }, + model: decision.model, + latencyMs: decision.latencyMs, + }, + }); + + return { ...decision, entryPrice, candles }; + } + + async execute(userId: string, dto: ExecuteTradeDto): Promise<{ id: string; status: string }> { + // Resolve exchange key. If unspecified, prefer testnet for safety. + const keys = await this.prisma.exchangeKey.findMany({ + where: { userId, exchange: 'binance' }, + }); + if (keys.length === 0) { + throw new NotFoundException('No Binance exchange key registered'); + } + const key = dto.exchangeKeyId + ? keys.find((k) => k.id === dto.exchangeKeyId) + : (keys.find((k) => k.network === 'testnet') ?? keys[0]); + if (!key) throw new NotFoundException('Specified exchange key not found'); + + // Margin × leverage / entry = base-asset quantity + const quantity = ((dto.betUsdt * dto.leverage) / Number(dto.entryPrice)).toFixed(6); + + const order = await this.prisma.order.create({ + data: { + userId, + exchangeKeyId: key.id, + exchange: 'binance', + symbol: dto.symbol, + side: dto.side, + type: 'market', + mode: 'real', + status: 'pending', + quantity, + leverage: dto.leverage, + marginType: 'ISOLATED', + positionSide: dto.side, + takeProfitPrice: dto.takeProfitPrice, + stopLossPrice: dto.stopLossPrice, + }, + }); + + const requestId = randomUUID(); + const event: OrderRequestedEvent = { + requestId, + userId, + exchangeKeyId: key.id, + order: { + exchange: 'binance', + symbol: dto.symbol, + side: dto.side, + type: 'market', + quantity, + leverage: dto.leverage, + marginType: 'ISOLATED', + takeProfitPrice: dto.takeProfitPrice, + stopLossPrice: dto.stopLossPrice, + }, + mode: 'real', + dbOrderId: order.id, + }; + + await this.producer.send({ + topic: KAFKA_TOPICS.TRADING_ORDER_REQUESTED, + messages: [{ key: userId, value: JSON.stringify(event) }], + }); + + this.logger.log( + `LLM trade dispatched: ${order.id} (${dto.side} ${quantity} ${dto.symbol} ${dto.leverage}x, ${key.network})`, + ); + + return { id: order.id, status: 'pending' }; + } +} diff --git a/apps/api-server/src/llm/cli-runner.ts b/apps/api-server/src/llm/cli-runner.ts new file mode 100644 index 0000000..1a972ea --- /dev/null +++ b/apps/api-server/src/llm/cli-runner.ts @@ -0,0 +1,86 @@ +import { spawn } from 'child_process'; + +export interface ClaudeCliOptions { + prompt: string; + oauthToken: string; + systemPromptFile: string; + model?: string; + timeoutMs?: number; +} + +export interface ClaudeCliResult { + stdout: string; + stderr: string; + exitCode: number; + durationMs: number; +} + +/** + * Spawn the `claude` CLI as a subprocess. Pure function — no DI, no logging, + * just process orchestration. Tests can mock spawn. + * + * Flags chosen for non-interactive, deterministic-ish, side-effect-free use: + * - `--bare`: skip ~/.claude/CLAUDE.md and project configs + * - `--tools ""`: disable all tools (no Bash, no Read, etc.) + * - `--output-format json`: parse the wrapper JSON envelope + * - `--append-system-prompt-file`: inject our trading persona + * + * The OAuth token is passed via `CLAUDE_CODE_OAUTH_TOKEN` env so it never + * appears on the command line. + */ +export function runClaudeCli(opts: ClaudeCliOptions): Promise { + return new Promise((resolve, reject) => { + const start = Date.now(); + const args = [ + '-p', + opts.prompt, + '--output-format', + 'json', + '--tools', + '', + '--bare', + '--append-system-prompt-file', + opts.systemPromptFile, + '--model', + opts.model ?? 'claude-sonnet-4-6', + ]; + + const proc = spawn('claude', args, { + env: { + ...process.env, + CLAUDE_CODE_OAUTH_TOKEN: opts.oauthToken, + // Belt-and-suspenders: even with --bare, make sure we don't pick up + // the host's API key by accident. + ANTHROPIC_API_KEY: '', + }, + }); + + let stdout = ''; + let stderr = ''; + proc.stdout.on('data', (d: Buffer) => { + stdout += d.toString('utf8'); + }); + proc.stderr.on('data', (d: Buffer) => { + stderr += d.toString('utf8'); + }); + + const timeoutHandle = setTimeout(() => { + proc.kill('SIGKILL'); + reject(new Error(`claude cli timed out after ${opts.timeoutMs ?? 30_000}ms`)); + }, opts.timeoutMs ?? 30_000); + + proc.on('error', (err) => { + clearTimeout(timeoutHandle); + reject(err); + }); + proc.on('close', (code) => { + clearTimeout(timeoutHandle); + resolve({ + stdout, + stderr, + exitCode: code ?? -1, + durationMs: Date.now() - start, + }); + }); + }); +} diff --git a/apps/api-server/src/llm/llm-cli.service.ts b/apps/api-server/src/llm/llm-cli.service.ts new file mode 100644 index 0000000..bc3c4ce --- /dev/null +++ b/apps/api-server/src/llm/llm-cli.service.ts @@ -0,0 +1,182 @@ +import { Injectable, Logger, BadRequestException } from '@nestjs/common'; +import { join } from 'path'; +import type { Candle } from '@coin/types'; +import { runClaudeCli } from './cli-runner'; + +const SYSTEM_PROMPT_FILE = join(__dirname, 'prompts', 'trading-system.md'); + +export interface LlmDecisionInput { + oauthToken: string; + symbol: string; + interval: string; + candles: Candle[]; +} + +export interface LlmDecision { + signal: 'long' | 'short'; + takeProfitPrice: string; + stopLossPrice: string; + reasoning: string; + rawResponse: string; + latencyMs: number; + model: string; +} + +interface QueueItem { + resolve: (v: LlmDecision) => void; + reject: (e: Error) => void; + input: LlmDecisionInput; +} + +/** + * Wraps the `claude` CLI subprocess with: + * - Concurrency=1 queue (avoid Pro/Max rate-limit collisions, single user) + * - 30s timeout + * - 1 retry on parse failure + * - Strict JSON validation (LONG: sl { + return new Promise((resolve, reject) => { + this.queue.push({ resolve, reject, input }); + void this.drain(); + }); + } + + private async drain(): Promise { + if (this.running) return; + this.running = true; + try { + while (this.queue.length > 0) { + const item = this.queue.shift()!; + try { + const result = await this.runOnce(item.input); + item.resolve(result); + } catch (err) { + item.reject(err as Error); + } + } + } finally { + this.running = false; + } + } + + private async runOnce(input: LlmDecisionInput): Promise { + const userPrompt = this.buildUserPrompt(input); + + let attempt = 0; + let lastError: Error | undefined; + while (attempt < 2) { + attempt++; + try { + const cli = await runClaudeCli({ + prompt: userPrompt, + oauthToken: input.oauthToken, + systemPromptFile: SYSTEM_PROMPT_FILE, + model: this.model, + timeoutMs: 30_000, + }); + + if (cli.exitCode !== 0) { + throw new Error(`claude cli exit ${cli.exitCode}: ${cli.stderr.slice(0, 500)}`); + } + + const decision = this.parse(cli.stdout, input.candles); + this.logger.log( + `LLM decision for ${input.symbol}: ${decision.signal} tp=${decision.takeProfitPrice} sl=${decision.stopLossPrice} (${cli.durationMs}ms)`, + ); + return { + ...decision, + rawResponse: cli.stdout, + latencyMs: cli.durationMs, + model: this.model, + }; + } catch (err) { + lastError = err as Error; + this.logger.warn(`LLM attempt ${attempt} failed: ${lastError.message}`); + } + } + throw lastError ?? new Error('LLM decide failed'); + } + + private buildUserPrompt(input: LlmDecisionInput): string { + const compact = input.candles.map((c) => ({ + t: c.timestamp, + o: c.open, + h: c.high, + l: c.low, + c: c.close, + v: c.volume, + })); + return [ + `Symbol: ${input.symbol}`, + `Interval: ${input.interval}`, + `Candles (${input.candles.length}, oldest → newest):`, + JSON.stringify(compact), + ].join('\n'); + } + + private parse( + cliStdout: string, + candles: Candle[], + ): Omit { + let envelope: { result?: string }; + try { + envelope = JSON.parse(cliStdout) as { result?: string }; + } catch { + throw new BadRequestException('LLM CLI returned non-JSON envelope'); + } + const inner = envelope.result?.trim(); + if (!inner) throw new BadRequestException('LLM returned empty result'); + + let signal: { + signal?: string; + takeProfitPrice?: string; + stopLossPrice?: string; + reasoning?: string; + }; + try { + signal = JSON.parse(inner) as typeof signal; + } catch { + // Try to extract JSON from text (in case model wrapped in prose) + const match = inner.match(/\{[\s\S]*\}/); + if (!match) throw new BadRequestException(`LLM response not JSON: ${inner.slice(0, 200)}`); + signal = JSON.parse(match[0]) as typeof signal; + } + + if (signal.signal !== 'long' && signal.signal !== 'short') { + throw new BadRequestException(`LLM returned invalid signal: ${signal.signal}`); + } + if (!signal.takeProfitPrice || !signal.stopLossPrice) { + throw new BadRequestException('LLM response missing TP or SL'); + } + + const tp = Number(signal.takeProfitPrice); + const sl = Number(signal.stopLossPrice); + const lastClose = Number(candles[candles.length - 1].close); + if (!Number.isFinite(tp) || !Number.isFinite(sl) || !Number.isFinite(lastClose)) { + throw new BadRequestException('LLM returned non-numeric prices'); + } + if (signal.signal === 'long' && !(sl < lastClose && lastClose < tp)) { + throw new BadRequestException(`LONG TP/SL invalid: sl=${sl} entry≈${lastClose} tp=${tp}`); + } + if (signal.signal === 'short' && !(tp < lastClose && lastClose < sl)) { + throw new BadRequestException(`SHORT TP/SL invalid: tp=${tp} entry≈${lastClose} sl=${sl}`); + } + + return { + signal: signal.signal, + takeProfitPrice: signal.takeProfitPrice, + stopLossPrice: signal.stopLossPrice, + reasoning: signal.reasoning ?? '', + }; + } +} diff --git a/apps/api-server/src/llm/llm.module.ts b/apps/api-server/src/llm/llm.module.ts new file mode 100644 index 0000000..dd22f54 --- /dev/null +++ b/apps/api-server/src/llm/llm.module.ts @@ -0,0 +1,8 @@ +import { Module } from '@nestjs/common'; +import { LlmCliService } from './llm-cli.service'; + +@Module({ + providers: [LlmCliService], + exports: [LlmCliService], +}) +export class LlmModule {} diff --git a/apps/api-server/src/llm/prompts/trading-system.md b/apps/api-server/src/llm/prompts/trading-system.md new file mode 100644 index 0000000..8f2a8d9 --- /dev/null +++ b/apps/api-server/src/llm/prompts/trading-system.md @@ -0,0 +1,21 @@ +You are a focused crypto futures trading analyst. + +You will be given the last N candles for a single Binance USDT-M perpetual symbol (timeframe specified in the user message). Your job is to decide a single directional position (`long` or `short`) and recommend take-profit (TP) and stop-loss (SL) prices in absolute USDT. + +Hard rules — your reply must be **exactly one JSON object on a single line**, no prose, no markdown fences: + +``` +{"signal":"long|short","takeProfitPrice":"","stopLossPrice":"","reasoning":""} +``` + +Validation requirements (you MUST satisfy): + +- For `signal=long`: `stopLossPrice < lastClose < takeProfitPrice`. +- For `signal=short`: `takeProfitPrice < lastClose < stopLossPrice`. +- TP and SL must be plausible relative to recent volatility — do not propose targets > 10% away from `lastClose` unless the candles strongly justify it. +- Round prices to 2 decimal places when `lastClose` ≥ 100, else 4 decimal places. +- `reasoning` ≤ 140 chars. Reference an observable feature (trend, support, recent breakout). Do not say "AI" or "I think". + +If the data is too noisy or contradictory to take a side with confidence, still output your best guess and put a hedge in `reasoning` (e.g. "low conviction; tight TP/SL"). + +Output the JSON object and nothing else. diff --git a/apps/web/src/app/llm-trade/page.tsx b/apps/web/src/app/llm-trade/page.tsx new file mode 100644 index 0000000..97887b6 --- /dev/null +++ b/apps/web/src/app/llm-trade/page.tsx @@ -0,0 +1,5 @@ +import { LlmTradeForm } from '@/components/llm-trade/llm-trade-form'; + +export default function LlmTradePage() { + return ; +} diff --git a/apps/web/src/app/settings/claude/page.tsx b/apps/web/src/app/settings/claude/page.tsx new file mode 100644 index 0000000..2e04ffb --- /dev/null +++ b/apps/web/src/app/settings/claude/page.tsx @@ -0,0 +1,114 @@ +'use client'; + +import { useState } from 'react'; +import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'; +import { Card, CardHeader, CardTitle, CardContent } from '@/components/ui/card'; +import { Button } from '@/components/ui/button'; +import { getClaudeTokenStatus, saveClaudeToken, deleteClaudeToken } from '@/lib/api-client'; + +export default function ClaudeSettingsPage() { + const qc = useQueryClient(); + const [token, setToken] = useState(''); + const [error, setError] = useState(''); + const [saved, setSaved] = useState(false); + + const { data: status, isLoading } = useQuery({ + queryKey: ['claudeToken'], + queryFn: getClaudeTokenStatus, + }); + + const saveMutation = useMutation({ + mutationFn: saveClaudeToken, + onSuccess: () => { + setToken(''); + setError(''); + setSaved(true); + qc.invalidateQueries({ queryKey: ['claudeToken'] }); + setTimeout(() => setSaved(false), 2000); + }, + onError: (err: Error) => setError(err.message), + }); + + const deleteMutation = useMutation({ + mutationFn: deleteClaudeToken, + onSuccess: () => { + qc.invalidateQueries({ queryKey: ['claudeToken'] }); + }, + }); + + return ( +
+

Claude OAuth Token

+

+ LLM 트레이드 신호는 사용자의 Claude Pro/Max 구독을 통해 생성됩니다. 본인 계정에서{' '} + claude setup-token 을 실행하여 + 장기 OAuth 토큰을 발급한 후 아래에 붙여넣으세요. 토큰은 AES-256-GCM으로 암호화되어 저장되며, + 트레이드 신호 호출 시점에만 워커가 복호화합니다. +

+ + + + 상태 + + + {isLoading ? ( +

로딩 중...

+ ) : status?.registered ? ( +
+

+ + 등록됨 + {' '} + + 최종 갱신: {status.updatedAt && new Date(status.updatedAt).toLocaleString()} + +

+ +
+ ) : ( +

토큰 미등록

+ )} +
+
+ + + + + {status?.registered ? '토큰 갱신' : '토큰 등록'} + + + + setToken(e.target.value)} + placeholder="sk-ant-oat01-..." + className="w-full h-9 px-3 rounded-md border border-input bg-transparent text-sm font-mono placeholder:text-muted-foreground focus:outline-none focus:ring-2 focus:ring-ring" + /> + {error &&

{error}

} + {saved &&

저장되었습니다.

} +
+ +
+

+ 발급 방법: 터미널에서{' '} + claude setup-token 실행 → 안내된 + OAuth 흐름 완료 → 출력된 토큰을 위 입력란에 붙여넣기. +

+
+
+
+ ); +} diff --git a/apps/web/src/components/llm-trade/llm-trade-form.tsx b/apps/web/src/components/llm-trade/llm-trade-form.tsx new file mode 100644 index 0000000..0fdb673 --- /dev/null +++ b/apps/web/src/components/llm-trade/llm-trade-form.tsx @@ -0,0 +1,225 @@ +'use client'; + +import { useState } from 'react'; +import { useMutation } from '@tanstack/react-query'; +import { Card, CardHeader, CardTitle, CardContent } from '@/components/ui/card'; +import { Button } from '@/components/ui/button'; +import { requestSignal, executeTrade, type SignalResponse } from '@/lib/api-client'; + +const TOP_SYMBOLS = ['BTCUSDT', 'ETHUSDT', 'SOLUSDT', 'XRPUSDT', 'DOGEUSDT'] as const; +const INTERVALS = ['1m', '5m', '15m', '1h', '4h', '1d'] as const; + +interface SignalState { + response: SignalResponse; + tpOverride: string; + slOverride: string; +} + +export function LlmTradeForm() { + const [symbol, setSymbol] = useState<(typeof TOP_SYMBOLS)[number]>('BTCUSDT'); + const [interval, setInterval] = useState<(typeof INTERVALS)[number]>('5m'); + const [candleCount, setCandleCount] = useState(50); + const [betUsdt, setBetUsdt] = useState(50); + const [leverage, setLeverage] = useState(5); + const [signal, setSignal] = useState(null); + const [error, setError] = useState(''); + + const signalMutation = useMutation({ + mutationFn: requestSignal, + onSuccess: (response) => { + setSignal({ + response, + tpOverride: response.takeProfitPrice, + slOverride: response.stopLossPrice, + }); + setError(''); + }, + onError: (err: Error) => { + setSignal(null); + setError(err.message); + }, + }); + + const executeMutation = useMutation({ + mutationFn: executeTrade, + onError: (err: Error) => setError(err.message), + }); + + const handleSignal = () => { + setError(''); + signalMutation.mutate({ symbol, interval, candleCount }); + }; + + const handleExecute = () => { + if (!signal) return; + if ( + !window.confirm( + `${signal.response.signal.toUpperCase()} ${symbol} ${leverage}x\nbet ${betUsdt} USDT, TP ${signal.tpOverride}, SL ${signal.slOverride}\n\n실행하시겠습니까?`, + ) + ) + return; + executeMutation.mutate({ + symbol, + side: signal.response.signal, + betUsdt, + leverage, + takeProfitPrice: signal.tpOverride, + stopLossPrice: signal.slOverride, + entryPrice: signal.response.entryPrice, + }); + }; + + return ( +
+

LLM Trade

+

+ Claude가 캔들 데이터를 분석해 long/short + TP/SL을 제안합니다. 응답을 확인한 뒤 거래를 + 실행하세요. +

+ + + + 1. 신호 요청 + + +
+
+ + +
+
+ + +
+
+ + setCandleCount(Number(e.target.value))} + className="w-full h-9 px-3 rounded-md border border-input bg-transparent text-sm focus:outline-none focus:ring-2 focus:ring-ring" + /> +
+
+ + setLeverage(Number(e.target.value))} + className="w-full h-9 px-3 rounded-md border border-input bg-transparent text-sm focus:outline-none focus:ring-2 focus:ring-ring" + /> +
+
+ + setBetUsdt(Number(e.target.value))} + className="w-full h-9 px-3 rounded-md border border-input bg-transparent text-sm focus:outline-none focus:ring-2 focus:ring-ring" + /> +
+
+ + {error &&

{error}

} +
+
+ + {signal && ( + + + 2. LLM 응답 + + +
+ + {signal.response.signal.toUpperCase()} + + + ({signal.response.latencyMs}ms · {signal.response.model}) + +
+

{signal.response.reasoning}

+
+
+ + +
+
+
+ + setSignal({ ...signal, tpOverride: e.target.value })} + className="w-full h-9 px-3 rounded-md border border-input bg-transparent text-sm font-mono focus:outline-none focus:ring-2 focus:ring-ring" + /> +
+
+ + setSignal({ ...signal, slOverride: e.target.value })} + className="w-full h-9 px-3 rounded-md border border-input bg-transparent text-sm font-mono focus:outline-none focus:ring-2 focus:ring-ring" + /> +
+
+ + {executeMutation.data && ( +

+ Order #{executeMutation.data.id} 제출됨 — 워커가 처리 중. 결과는 활동 로그에서 확인. +

+ )} +
+
+ )} +
+ ); +} diff --git a/apps/web/src/components/nav-bar.tsx b/apps/web/src/components/nav-bar.tsx index 16d308c..b9df51e 100644 --- a/apps/web/src/components/nav-bar.tsx +++ b/apps/web/src/components/nav-bar.tsx @@ -61,6 +61,13 @@ export function NavBar() { 대시보드 + + + LLM Trade + { if (!res.ok) throw new Error('Failed to fetch activity'); return res.json(); } + +// --- Claude Tokens --- + +export interface ClaudeTokenStatus { + registered: boolean; + updatedAt?: string; +} + +export async function getClaudeTokenStatus(): Promise { + const res = await apiFetch('/claude-tokens'); + if (!res.ok) throw new Error('Failed to fetch Claude token status'); + return res.json(); +} + +export async function saveClaudeToken(token: string): Promise<{ updatedAt: string }> { + const res = await apiFetch('/claude-tokens', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ token }), + }); + if (!res.ok) { + const body = await res.json().catch(() => ({})); + throw new Error(body.message || 'Failed to save Claude token'); + } + return res.json(); +} + +export async function deleteClaudeToken(): Promise { + const res = await apiFetch('/claude-tokens', { method: 'DELETE' }); + if (!res.ok) throw new Error('Failed to delete Claude token'); +} + +// --- LLM Trades --- + +export interface SignalResponse { + signal: 'long' | 'short'; + takeProfitPrice: string; + stopLossPrice: string; + reasoning: string; + entryPrice: string; + latencyMs: number; + model: string; +} + +export async function requestSignal(input: { + symbol: string; + interval: string; + candleCount: number; +}): Promise { + const res = await apiFetch('/llm-trades/signal', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(input), + }); + if (!res.ok) { + const body = await res.json().catch(() => ({})); + throw new Error(body.message || 'Failed to request signal'); + } + return res.json(); +} + +export async function executeTrade(input: { + symbol: string; + side: 'long' | 'short'; + betUsdt: number; + leverage: number; + takeProfitPrice: string; + stopLossPrice: string; + entryPrice: string; + exchangeKeyId?: string; +}): Promise<{ id: string; status: string }> { + const res = await apiFetch('/llm-trades/execute', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(input), + }); + if (!res.ok) { + const body = await res.json().catch(() => ({})); + throw new Error(body.message || 'Failed to execute trade'); + } + return res.json(); +} diff --git a/apps/worker-service/src/app.module.ts b/apps/worker-service/src/app.module.ts index 251bd39..7ac0db3 100644 --- a/apps/worker-service/src/app.module.ts +++ b/apps/worker-service/src/app.module.ts @@ -3,6 +3,7 @@ import { LoggerModule } from 'nestjs-pino'; import { PrismaModule } from './prisma/prisma.module'; import { ExchangesModule } from './exchanges/exchanges.module'; import { OrdersModule } from './orders/orders.module'; +import { RiskModule } from './risk/risk.module'; @Module({ imports: [ @@ -20,6 +21,7 @@ import { OrdersModule } from './orders/orders.module'; }, }), PrismaModule, + RiskModule, OrdersModule, ExchangesModule, ], diff --git a/apps/worker-service/src/orders/orders.module.ts b/apps/worker-service/src/orders/orders.module.ts index 6c1ed2e..908e444 100644 --- a/apps/worker-service/src/orders/orders.module.ts +++ b/apps/worker-service/src/orders/orders.module.ts @@ -1,7 +1,9 @@ import { Module } from '@nestjs/common'; import { OrdersService } from './orders.service'; +import { RiskModule } from '../risk/risk.module'; @Module({ + imports: [RiskModule], providers: [OrdersService], exports: [OrdersService], }) diff --git a/apps/worker-service/src/orders/orders.service.ts b/apps/worker-service/src/orders/orders.service.ts index aae564d..d6e5969 100644 --- a/apps/worker-service/src/orders/orders.service.ts +++ b/apps/worker-service/src/orders/orders.service.ts @@ -6,6 +6,7 @@ import type { OrderRequestedEvent, OrderResultEvent } from '@coin/kafka-contract import type { OrderResult } from '@coin/types'; import { PrismaService } from '../prisma/prisma.service'; import { executeRealOrderSaga } from './sagas/real-execution-steps'; +import { RiskGuardService } from '../risk/risk-guard.service'; @Injectable() export class OrdersService implements OnModuleInit, OnModuleDestroy { @@ -15,7 +16,10 @@ export class OrdersService implements OnModuleInit, OnModuleDestroy { private producer: Producer; private redis: Redis; - constructor(private readonly prisma: PrismaService) { + constructor( + private readonly prisma: PrismaService, + private readonly riskGuard: RiskGuardService, + ) { this.kafka = new Kafka({ clientId: 'worker-orders', brokers: (process.env.KAFKA_BROKERS || 'localhost:9092').split(','), @@ -91,6 +95,20 @@ export class OrdersService implements OnModuleInit, OnModuleDestroy { 'Paper mode disabled: use Binance Futures Testnet via real mode with network=testnet', ); } + + // Resolve network from the user's exchange key so guards know whether + // mainnet-only checks apply. Cheap DB hit, runs once per order. + const exchangeKey = await this.prisma.exchangeKey.findFirst({ + where: { id: exchangeKeyId, userId }, + select: { network: true }, + }); + const network = (exchangeKey?.network as 'mainnet' | 'testnet') ?? 'mainnet'; + + const guard = await this.riskGuard.checkAll({ userId, network, order }); + if (!guard.ok) { + throw new Error(`Risk guard: ${guard.reason}`); + } + await this.executeRealOrder(event); console.log(`[OrdersService] Order executed OK: ${dbOrderId}`); } catch (err) { diff --git a/apps/worker-service/src/risk/risk-guard.service.ts b/apps/worker-service/src/risk/risk-guard.service.ts new file mode 100644 index 0000000..cc2be41 --- /dev/null +++ b/apps/worker-service/src/risk/risk-guard.service.ts @@ -0,0 +1,150 @@ +import { Injectable, Logger } from '@nestjs/common'; +import Redis from 'ioredis'; +import type { OrderRequest } from '@coin/types'; +import { PrismaService } from '../prisma/prisma.service'; + +export interface GuardCheck { + ok: boolean; + reason?: string; +} + +export interface GuardContext { + userId: string; + network: 'mainnet' | 'testnet'; + order: OrderRequest; + /** Available margin balance in USDT (mainnet only). undefined to skip MAX_BET_PCT. */ + availableUsdt?: number; +} + +/** + * Seven safety guards run before any real-mode order placement. Each returns + * `{ ok: false, reason }` to short-circuit the saga with a user-facing + * message. Mainnet-only guards are no-ops on testnet (where the trader can + * use fake USDT freely). + * + * Tunables come from env so ops can dial them without redeploy: + * KILL_SWITCH_REAL_TRADING ('true' to disable mainnet entirely) + * DAILY_LOSS_LIMIT_USDT (default 50) + * MAX_LEVERAGE (default 20) + * MAX_BET_PCT (default 10) // % of availableUsdt + * LLM_COOLDOWN_SECONDS (default 30) + * MAX_OPEN_POSITIONS_PER_USER (default 1) + */ +@Injectable() +export class RiskGuardService { + private readonly logger = new Logger(RiskGuardService.name); + private redis: Redis; + + constructor(private readonly prisma: PrismaService) { + this.redis = new Redis({ + host: process.env.REDIS_HOST || 'localhost', + port: Number(process.env.REDIS_PORT || 6379), + }); + } + + async checkAll(ctx: GuardContext): Promise { + const checks: Array<() => Promise> = [ + () => this.killSwitch(ctx), + () => this.maxLeverage(ctx), + () => this.cooldown(ctx), + () => this.maxOpenPositions(ctx), + () => this.dailyLossLimit(ctx), + () => this.maxBetPct(ctx), + ]; + for (const check of checks) { + const r = await check(); + if (!r.ok) { + this.logger.warn(`Guard blocked trade: ${r.reason}`); + return r; + } + } + return { ok: true }; + } + + /** 1. Global kill switch for mainnet real trading. */ + private async killSwitch(ctx: GuardContext): Promise { + if (ctx.network === 'testnet') return { ok: true }; + if (process.env.KILL_SWITCH_REAL_TRADING === 'true') { + return { ok: false, reason: 'Real mainnet trading is disabled (KILL_SWITCH_REAL_TRADING)' }; + } + if (process.env.ENABLE_REAL_MAINNET !== 'true') { + return { ok: false, reason: 'Mainnet trading not enabled (ENABLE_REAL_MAINNET=false)' }; + } + return { ok: true }; + } + + /** 2. Hard leverage cap regardless of user input. */ + private async maxLeverage(ctx: GuardContext): Promise { + const max = Number(process.env.MAX_LEVERAGE ?? 20); + if (ctx.order.leverage > max) { + return { ok: false, reason: `Leverage ${ctx.order.leverage}x exceeds cap of ${max}x` }; + } + return { ok: true }; + } + + /** 3. Per-user cooldown between LLM-driven trades to avoid Pro/Max rate limits. */ + private async cooldown(ctx: GuardContext): Promise { + const seconds = Number(process.env.LLM_COOLDOWN_SECONDS ?? 30); + if (seconds <= 0) return { ok: true }; + const key = `llm:cooldown:${ctx.userId}`; + const acquired = await this.redis.set(key, '1', 'EX', seconds, 'NX'); + if (!acquired) { + const ttl = await this.redis.ttl(key); + return { ok: false, reason: `Cooldown active — wait ${ttl}s` }; + } + return { ok: true }; + } + + /** 4. Cap concurrent open positions per user. */ + private async maxOpenPositions(ctx: GuardContext): Promise { + const max = Number(process.env.MAX_OPEN_POSITIONS_PER_USER ?? 1); + const open = await this.prisma.order.count({ + where: { + userId: ctx.userId, + status: { in: ['placed', 'partial', 'filled'] }, + closedAt: null, + }, + }); + if (open >= max) { + return { ok: false, reason: `${open} open position(s) — cap is ${max}` }; + } + return { ok: true }; + } + + /** 5. Daily realized loss ceiling (mainnet only). */ + private async dailyLossLimit(ctx: GuardContext): Promise { + if (ctx.network === 'testnet') return { ok: true }; + const limit = Number(process.env.DAILY_LOSS_LIMIT_USDT ?? 50); + const since = new Date(); + since.setUTCHours(0, 0, 0, 0); + const todayClosed = await this.prisma.order.findMany({ + where: { + userId: ctx.userId, + closedAt: { gte: since }, + realizedPnl: { not: null }, + }, + select: { realizedPnl: true }, + }); + const realized = todayClosed.reduce((sum, o) => sum + Number(o.realizedPnl ?? 0), 0); + if (realized <= -limit) { + return { ok: false, reason: `Daily loss limit hit: ${realized.toFixed(2)} ≤ -${limit} USDT` }; + } + return { ok: true }; + } + + /** 6. Bet must not exceed configured % of available margin (mainnet only). */ + private async maxBetPct(ctx: GuardContext): Promise { + if (ctx.network === 'testnet') return { ok: true }; + if (ctx.availableUsdt === undefined) return { ok: true }; + const pct = Number(process.env.MAX_BET_PCT ?? 10) / 100; + const notional = Number(ctx.order.quantity) * Number(ctx.order.price ?? 0); + const margin = notional / Math.max(ctx.order.leverage, 1); + if (margin > ctx.availableUsdt * pct) { + return { + ok: false, + reason: `Required margin ${margin.toFixed(2)} USDT exceeds ${(pct * 100).toFixed(0)}% of available ${ctx.availableUsdt.toFixed(2)}`, + }; + } + return { ok: true }; + } +} diff --git a/apps/worker-service/src/risk/risk.module.ts b/apps/worker-service/src/risk/risk.module.ts new file mode 100644 index 0000000..1fb4deb --- /dev/null +++ b/apps/worker-service/src/risk/risk.module.ts @@ -0,0 +1,8 @@ +import { Module } from '@nestjs/common'; +import { RiskGuardService } from './risk-guard.service'; + +@Module({ + providers: [RiskGuardService], + exports: [RiskGuardService], +}) +export class RiskModule {} From 74c7395bd0da3dc47a0e874eeac6e4afcadd412c Mon Sep 17 00:00:00 2001 From: fray-cloud Date: Fri, 1 May 2026 05:21:53 +0900 Subject: [PATCH 2/2] fix(llm-trade): make end-to-end LLM Binance Futures flow actually work MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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) --- apps/api-server/nest-cli.json | 3 +- apps/api-server/src/debug/debug.controller.ts | 16 +++- .../queries/get-balances.handler.ts | 1 + .../queries/get-open-orders.handler.ts | 1 + .../src/llm-trades/llm-trades.service.ts | 25 +++++- apps/api-server/src/llm/cli-runner.ts | 25 +++--- apps/api-server/src/llm/llm-cli.service.ts | 6 +- .../src/llm/prompts/trading-system.md | 21 ----- .../src/llm/prompts/trading-system.ts | 20 +++++ apps/web/src/app/settings/page.tsx | 26 +++++- apps/web/src/lib/api-client.ts | 1 + .../src/orders/sagas/real-execution-steps.ts | 22 ++--- .../src/binance/binance.rest.ts | 86 ++++++++++++++++--- .../src/interfaces/exchange-rest.ts | 2 + 14 files changed, 188 insertions(+), 67 deletions(-) delete mode 100644 apps/api-server/src/llm/prompts/trading-system.md create mode 100644 apps/api-server/src/llm/prompts/trading-system.ts diff --git a/apps/api-server/nest-cli.json b/apps/api-server/nest-cli.json index 265d44c..89d7d6c 100644 --- a/apps/api-server/nest-cli.json +++ b/apps/api-server/nest-cli.json @@ -3,7 +3,6 @@ "collection": "@nestjs/schematics", "sourceRoot": "src", "compilerOptions": { - "deleteOutDir": false, - "assets": [{ "include": "**/*.md", "outDir": "dist", "watchAssets": true }] + "deleteOutDir": false } } diff --git a/apps/api-server/src/debug/debug.controller.ts b/apps/api-server/src/debug/debug.controller.ts index 6b31f52..744de11 100644 --- a/apps/api-server/src/debug/debug.controller.ts +++ b/apps/api-server/src/debug/debug.controller.ts @@ -95,10 +95,22 @@ export class DebugController { let tp: { orderId: string } | undefined; let sl: { orderId: string } | undefined; if (dto.takeProfitPrice) { - tp = await adapter.placeTakeProfit(credentials, dto.symbol, dto.side, dto.takeProfitPrice); + tp = await adapter.placeTakeProfit( + credentials, + dto.symbol, + dto.side, + dto.takeProfitPrice, + dto.quantity, + ); } if (dto.stopLossPrice) { - sl = await adapter.placeStopLoss(credentials, dto.symbol, dto.side, dto.stopLossPrice); + sl = await adapter.placeStopLoss( + credentials, + dto.symbol, + dto.side, + dto.stopLossPrice, + dto.quantity, + ); } const position = await adapter.getPosition(credentials, dto.symbol); diff --git a/apps/api-server/src/exchange-keys/queries/get-balances.handler.ts b/apps/api-server/src/exchange-keys/queries/get-balances.handler.ts index d27ec07..d4248cb 100644 --- a/apps/api-server/src/exchange-keys/queries/get-balances.handler.ts +++ b/apps/api-server/src/exchange-keys/queries/get-balances.handler.ts @@ -34,6 +34,7 @@ export class GetBalancesHandler implements IQueryHandler { const credentials: ExchangeCredentials = { apiKey: decrypt(key.apiKey, this.masterKey), secretKey: decrypt(key.secretKey, this.masterKey), + network: (key.network as 'mainnet' | 'testnet') ?? 'mainnet', }; return adapter.getBalances(credentials); diff --git a/apps/api-server/src/exchange-keys/queries/get-open-orders.handler.ts b/apps/api-server/src/exchange-keys/queries/get-open-orders.handler.ts index 2444149..c7f5895 100644 --- a/apps/api-server/src/exchange-keys/queries/get-open-orders.handler.ts +++ b/apps/api-server/src/exchange-keys/queries/get-open-orders.handler.ts @@ -34,6 +34,7 @@ export class GetOpenOrdersHandler implements IQueryHandler { const credentials: ExchangeCredentials = { apiKey: decrypt(key.apiKey, this.masterKey), secretKey: decrypt(key.secretKey, this.masterKey), + network: (key.network as 'mainnet' | 'testnet') ?? 'mainnet', }; return adapter.getOpenOrders(credentials, symbol); diff --git a/apps/api-server/src/llm-trades/llm-trades.service.ts b/apps/api-server/src/llm-trades/llm-trades.service.ts index e585897..bf1bc0b 100644 --- a/apps/api-server/src/llm-trades/llm-trades.service.ts +++ b/apps/api-server/src/llm-trades/llm-trades.service.ts @@ -89,8 +89,29 @@ export class LlmTradesService { : (keys.find((k) => k.network === 'testnet') ?? keys[0]); if (!key) throw new NotFoundException('Specified exchange key not found'); - // Margin × leverage / entry = base-asset quantity - const quantity = ((dto.betUsdt * dto.leverage) / Number(dto.entryPrice)).toFixed(6); + // Margin × leverage / entry = base-asset quantity. Then snap down to the + // exchange's LOT_SIZE step (Binance rejects with -1111 otherwise) and + // refuse if the snapped notional is below MIN_NOTIONAL. + const rawQty = (dto.betUsdt * dto.leverage) / Number(dto.entryPrice); + const filter = await this.binance.getSymbolFilter(dto.symbol); + const step = Number(filter.stepSize); + if (!step || step <= 0) { + throw new BadRequestException(`No LOT_SIZE filter for ${dto.symbol}`); + } + const stepDecimals = (filter.stepSize.split('.')[1] ?? '').length; + const snapped = Math.floor(rawQty / step) * step; + const quantity = snapped.toFixed(stepDecimals); + const notional = Number(quantity) * Number(dto.entryPrice); + if (notional < Number(filter.minNotional || 0)) { + throw new BadRequestException( + `Notional ${notional.toFixed(2)} USDT < min ${filter.minNotional}; raise bet or leverage`, + ); + } + if (Number(quantity) < Number(filter.minQty || 0)) { + throw new BadRequestException( + `Quantity ${quantity} < minQty ${filter.minQty}; raise bet or leverage`, + ); + } const order = await this.prisma.order.create({ data: { diff --git a/apps/api-server/src/llm/cli-runner.ts b/apps/api-server/src/llm/cli-runner.ts index 1a972ea..c9c0b6d 100644 --- a/apps/api-server/src/llm/cli-runner.ts +++ b/apps/api-server/src/llm/cli-runner.ts @@ -3,7 +3,7 @@ import { spawn } from 'child_process'; export interface ClaudeCliOptions { prompt: string; oauthToken: string; - systemPromptFile: string; + systemPrompt: string; model?: string; timeoutMs?: number; } @@ -38,21 +38,24 @@ export function runClaudeCli(opts: ClaudeCliOptions): Promise { 'json', '--tools', '', - '--bare', - '--append-system-prompt-file', - opts.systemPromptFile, + '--append-system-prompt', + opts.systemPrompt, '--model', opts.model ?? 'claude-sonnet-4-6', ]; + // Strip parent ANTHROPIC_API_KEY so we don't accidentally use the host's + // API key (we want the user's OAuth token to win). + const env = { ...process.env }; + delete env.ANTHROPIC_API_KEY; + delete env.ANTHROPIC_AUTH_TOKEN; + env.CLAUDE_CODE_OAUTH_TOKEN = opts.oauthToken; + const proc = spawn('claude', args, { - env: { - ...process.env, - CLAUDE_CODE_OAUTH_TOKEN: opts.oauthToken, - // Belt-and-suspenders: even with --bare, make sure we don't pick up - // the host's API key by accident. - ANTHROPIC_API_KEY: '', - }, + // stdin: 'ignore' prevents claude CLI from waiting on an open pipe for + // 3s (it inherits-as-pipe by default and reads stdin even with -p). + stdio: ['ignore', 'pipe', 'pipe'], + env, }); let stdout = ''; diff --git a/apps/api-server/src/llm/llm-cli.service.ts b/apps/api-server/src/llm/llm-cli.service.ts index bc3c4ce..2d52333 100644 --- a/apps/api-server/src/llm/llm-cli.service.ts +++ b/apps/api-server/src/llm/llm-cli.service.ts @@ -1,9 +1,7 @@ import { Injectable, Logger, BadRequestException } from '@nestjs/common'; -import { join } from 'path'; import type { Candle } from '@coin/types'; import { runClaudeCli } from './cli-runner'; - -const SYSTEM_PROMPT_FILE = join(__dirname, 'prompts', 'trading-system.md'); +import { TRADING_SYSTEM_PROMPT } from './prompts/trading-system'; export interface LlmDecisionInput { oauthToken: string; @@ -80,7 +78,7 @@ export class LlmCliService { const cli = await runClaudeCli({ prompt: userPrompt, oauthToken: input.oauthToken, - systemPromptFile: SYSTEM_PROMPT_FILE, + systemPrompt: TRADING_SYSTEM_PROMPT, model: this.model, timeoutMs: 30_000, }); diff --git a/apps/api-server/src/llm/prompts/trading-system.md b/apps/api-server/src/llm/prompts/trading-system.md deleted file mode 100644 index 8f2a8d9..0000000 --- a/apps/api-server/src/llm/prompts/trading-system.md +++ /dev/null @@ -1,21 +0,0 @@ -You are a focused crypto futures trading analyst. - -You will be given the last N candles for a single Binance USDT-M perpetual symbol (timeframe specified in the user message). Your job is to decide a single directional position (`long` or `short`) and recommend take-profit (TP) and stop-loss (SL) prices in absolute USDT. - -Hard rules — your reply must be **exactly one JSON object on a single line**, no prose, no markdown fences: - -``` -{"signal":"long|short","takeProfitPrice":"","stopLossPrice":"","reasoning":""} -``` - -Validation requirements (you MUST satisfy): - -- For `signal=long`: `stopLossPrice < lastClose < takeProfitPrice`. -- For `signal=short`: `takeProfitPrice < lastClose < stopLossPrice`. -- TP and SL must be plausible relative to recent volatility — do not propose targets > 10% away from `lastClose` unless the candles strongly justify it. -- Round prices to 2 decimal places when `lastClose` ≥ 100, else 4 decimal places. -- `reasoning` ≤ 140 chars. Reference an observable feature (trend, support, recent breakout). Do not say "AI" or "I think". - -If the data is too noisy or contradictory to take a side with confidence, still output your best guess and put a hedge in `reasoning` (e.g. "low conviction; tight TP/SL"). - -Output the JSON object and nothing else. diff --git a/apps/api-server/src/llm/prompts/trading-system.ts b/apps/api-server/src/llm/prompts/trading-system.ts new file mode 100644 index 0000000..0b12f28 --- /dev/null +++ b/apps/api-server/src/llm/prompts/trading-system.ts @@ -0,0 +1,20 @@ +export const TRADING_SYSTEM_PROMPT = `You are a focused crypto futures trading analyst. + +You will be given the last N candles for a single Binance USDT-M perpetual symbol (timeframe specified in the user message). Your job is to decide a single directional position (long or short) and recommend take-profit (TP) and stop-loss (SL) prices in absolute USDT. + +Hard rules — your reply must be **exactly one JSON object on a single line**, no prose, no markdown fences: + +{"signal":"long|short","takeProfitPrice":"","stopLossPrice":"","reasoning":""} + +Validation requirements (you MUST satisfy): + +- For signal=long: stopLossPrice < lastClose < takeProfitPrice. +- For signal=short: takeProfitPrice < lastClose < stopLossPrice. +- TP and SL must be plausible relative to recent volatility — do not propose targets > 10% away from lastClose unless the candles strongly justify it. +- Round prices to 2 decimal places when lastClose ≥ 100, else 4 decimal places. +- reasoning ≤ 140 chars. Reference an observable feature (trend, support, recent breakout). Do not say "AI" or "I think". + +If the data is too noisy or contradictory to take a side with confidence, still output your best guess and put a hedge in reasoning (e.g. "low conviction; tight TP/SL"). + +Output the JSON object and nothing else. +`; diff --git a/apps/web/src/app/settings/page.tsx b/apps/web/src/app/settings/page.tsx index f4cde64..3eb5e7b 100644 --- a/apps/web/src/app/settings/page.tsx +++ b/apps/web/src/app/settings/page.tsx @@ -114,6 +114,7 @@ function GeneralTab() { function AccountsTab() { const queryClient = useQueryClient(); const [exchange, setExchange] = useState('binance'); + const [network, setNetwork] = useState<'mainnet' | 'testnet'>('testnet'); const [apiKey, setApiKey] = useState(''); const [secretKey, setSecretKey] = useState(''); const [error, setError] = useState(''); @@ -145,7 +146,7 @@ function AccountsTab() {
{ e.preventDefault(); - createMutation.mutate({ exchange, apiKey, secretKey }); + createMutation.mutate({ exchange, network, apiKey, secretKey }); }} className="space-y-4" > @@ -167,6 +168,29 @@ function AccountsTab() { ))} +
+ +
+ {(['testnet', 'mainnet'] as const).map((n) => ( + + ))} +
+

+ Testnet 키는 testnet.binancefuture.com에서 발급한 것이어야 하며, mainnet 키는 + Futures (선물) 활성화 + IP 화이트리스트 허용된 키여야 합니다. +

+
{ export async function createExchangeKey(data: { exchange: string; + network?: 'mainnet' | 'testnet'; apiKey: string; secretKey: string; }): Promise<{ id: string; exchange: string }> { diff --git a/apps/worker-service/src/orders/sagas/real-execution-steps.ts b/apps/worker-service/src/orders/sagas/real-execution-steps.ts index 68a81f0..2cdfa95 100644 --- a/apps/worker-service/src/orders/sagas/real-execution-steps.ts +++ b/apps/worker-service/src/orders/sagas/real-execution-steps.ts @@ -152,9 +152,10 @@ export class PlaceOrderStep implements SagaStep { } /** - * Attaches STOP_MARKET (SL) and TAKE_PROFIT_MARKET (TP) close-position orders - * after the entry has filled. If either fails, compensate force-closes the - * underlying position so it never sits naked. + * Attaches conditional close orders (TAKE_PROFIT_MARKET / STOP_MARKET) via + * Binance's algoOrder endpoint after the entry has filled. If either + * placement fails, force-close the underlying position so it never sits + * naked. */ export class AttachTpSlStep implements SagaStep { readonly name = 'AttachTpSl'; @@ -170,12 +171,9 @@ export class AttachTpSlStep implements SagaStep { this.logger.log('No TP/SL specified, skipping'); return context; } - if (result.status !== 'filled' && result.status !== 'partial') { - this.logger.warn(`Entry not filled (status=${result.status}), skipping TP/SL attachment`); - return context; - } const adapter = REST_ADAPTERS[order.exchange](); + const filledQty = result.filledQuantity || order.quantity; let tpOrderId: string | undefined; let slOrderId: string | undefined; @@ -186,9 +184,10 @@ export class AttachTpSlStep implements SagaStep { order.symbol, order.side, order.takeProfitPrice, + filledQty, ); tpOrderId = tp.orderId; - this.logger.log(`TP attached: ${tp.orderId} @ ${order.takeProfitPrice}`); + this.logger.log(`TP attached: algoId=${tp.orderId} @ ${order.takeProfitPrice}`); } if (order.stopLossPrice) { const sl = await adapter.placeStopLoss( @@ -196,16 +195,17 @@ export class AttachTpSlStep implements SagaStep { order.symbol, order.side, order.stopLossPrice, + filledQty, ); slOrderId = sl.orderId; - this.logger.log(`SL attached: ${sl.orderId} @ ${order.stopLossPrice}`); + this.logger.log(`SL attached: algoId=${sl.orderId} @ ${order.stopLossPrice}`); } return { ...context, tpOrderId, slOrderId }; } catch (err) { this.logger.error(`TP/SL attach failed, force-closing position: ${err}`); try { - await adapter.closePosition(credentials, order.symbol, order.side, result.filledQuantity); - this.logger.warn(`Position force-closed after TP/SL failure`); + await adapter.closePosition(credentials, order.symbol, order.side, filledQty); + this.logger.warn('Position force-closed after TP/SL failure'); } catch (closeErr) { this.logger.error(`Force-close also failed: ${closeErr}`); } diff --git a/packages/exchange-adapters/src/binance/binance.rest.ts b/packages/exchange-adapters/src/binance/binance.rest.ts index 29558a8..b744279 100644 --- a/packages/exchange-adapters/src/binance/binance.rest.ts +++ b/packages/exchange-adapters/src/binance/binance.rest.ts @@ -61,6 +61,24 @@ interface BinanceFuturesOrderResponse { updateTime?: number; } +interface BinanceFuturesAlgoOrderResponse { + algoId: number; + clientAlgoId?: string; + algoType: 'CONDITIONAL'; + orderType: string; + symbol: string; + side: 'BUY' | 'SELL'; + positionSide?: string; + algoStatus: string; + triggerPrice?: string; + price?: string; + quantity?: string; + closePosition?: boolean; + workingType?: string; + updateTime?: number; + createTime?: number; +} + interface BinanceFuturesPositionResponse { symbol: string; positionAmt: string; @@ -317,23 +335,30 @@ export class BinanceRest implements IExchangeRest { return this.mapOrderResult(data); } + /** + * Conditional close orders (STOP_MARKET / TAKE_PROFIT_MARKET) moved to a + * dedicated endpoint /fapi/v1/algoOrder per Binance's 2025-11-06 mandatory + * migration. Schema differs from /fapi/v1/order: + * - `algoType: 'CONDITIONAL'` is required + * - `stopPrice` is renamed to `triggerPrice` + * - response carries `algoId` (not `orderId`) + * `quantity` is accepted but ignored when `closePosition=true` — we keep + * passing it as a fallback in case Binance changes the contract. + */ async placeStopLoss( credentials: ExchangeCredentials, symbol: string, side: PositionSide, stopPrice: string, + quantity: string, ): Promise { - const params: Record = { + return this.placeAlgoConditional(credentials, { symbol, side: side === 'long' ? 'SELL' : 'BUY', type: 'STOP_MARKET', - stopPrice, - closePosition: 'true', - workingType: 'MARK_PRICE', - }; - const res = await this.signedRequest(credentials, 'POST', '/fapi/v1/order', params); - const data = (await res.json()) as BinanceFuturesOrderResponse; - return this.mapOrderResult(data); + triggerPrice: stopPrice, + quantity, + }); } async placeTakeProfit( @@ -341,18 +366,53 @@ export class BinanceRest implements IExchangeRest { symbol: string, side: PositionSide, stopPrice: string, + quantity: string, ): Promise { - const params: Record = { + return this.placeAlgoConditional(credentials, { symbol, side: side === 'long' ? 'SELL' : 'BUY', type: 'TAKE_PROFIT_MARKET', - stopPrice, + triggerPrice: stopPrice, + quantity, + }); + } + + private async placeAlgoConditional( + credentials: ExchangeCredentials, + opts: { + symbol: string; + side: 'BUY' | 'SELL'; + type: 'STOP_MARKET' | 'TAKE_PROFIT_MARKET'; + triggerPrice: string; + quantity: string; + }, + ): Promise { + const params: Record = { + symbol: opts.symbol, + side: opts.side, + type: opts.type, + algoType: 'CONDITIONAL', + triggerPrice: opts.triggerPrice, closePosition: 'true', workingType: 'MARK_PRICE', }; - const res = await this.signedRequest(credentials, 'POST', '/fapi/v1/order', params); - const data = (await res.json()) as BinanceFuturesOrderResponse; - return this.mapOrderResult(data); + const res = await this.signedRequest(credentials, 'POST', '/fapi/v1/algoOrder', params); + const data = (await res.json()) as BinanceFuturesAlgoOrderResponse; + return { + exchange: this.exchangeId, + orderId: String(data.algoId), + symbol: data.symbol, + side: opts.side === 'BUY' ? 'long' : 'short', + type: 'market', + status: this.mapOrderStatus(data.algoStatus), + quantity: opts.quantity, + filledQuantity: '0', + price: data.triggerPrice ?? '0', + filledPrice: '0', + fee: '0', + feeCurrency: '', + timestamp: data.updateTime ?? Date.now(), + }; } async getSymbolFilter(symbol: string): Promise { diff --git a/packages/exchange-adapters/src/interfaces/exchange-rest.ts b/packages/exchange-adapters/src/interfaces/exchange-rest.ts index 6e1c569..793d5e3 100644 --- a/packages/exchange-adapters/src/interfaces/exchange-rest.ts +++ b/packages/exchange-adapters/src/interfaces/exchange-rest.ts @@ -56,12 +56,14 @@ export interface IExchangeRest { symbol: string, side: PositionSide, stopPrice: string, + quantity: string, ): Promise; placeTakeProfit( credentials: ExchangeCredentials, symbol: string, side: PositionSide, stopPrice: string, + quantity: string, ): Promise; getSymbolFilter(symbol: string): Promise; }