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/src/activity/activity.service.test.ts b/apps/api-server/src/activity/activity.service.test.ts new file mode 100644 index 0000000..f0da72f --- /dev/null +++ b/apps/api-server/src/activity/activity.service.test.ts @@ -0,0 +1,39 @@ +import { describe, it, expect, vi, beforeEach } from 'vitest'; +import { ActivityService } from './activity.service'; + +const mockPrisma = { + order: { findMany: vi.fn() }, + loginHistory: { findMany: vi.fn() }, +}; + +describe('ActivityService', () => { + let svc: ActivityService; + + beforeEach(() => { + vi.clearAllMocks(); + svc = new ActivityService(mockPrisma as never); + }); + + it('주문 활동 항목은 /orders/${id} 로 링크된다', async () => { + mockPrisma.order.findMany.mockResolvedValue([ + { + id: 'order-abc', + side: 'long', + symbol: 'BTCUSDT', + type: 'market', + quantity: '0.01', + filledPrice: '60000', + price: null, + mode: 'real', + exchange: 'binance', + status: 'filled', + createdAt: new Date('2026-04-01T00:00:00Z'), + }, + ]); + mockPrisma.loginHistory.findMany.mockResolvedValue([]); + + const { items } = await svc.getActivity('user-1'); + const orderItem = items.find((i) => i.type === 'order'); + expect(orderItem?.link).toBe('/orders/order-abc'); + }); +}); diff --git a/apps/api-server/src/activity/activity.service.ts b/apps/api-server/src/activity/activity.service.ts index 023fa4b..46c4611 100644 --- a/apps/api-server/src/activity/activity.service.ts +++ b/apps/api-server/src/activity/activity.service.ts @@ -14,6 +14,25 @@ export interface ActivityItem { createdAt: Date; } +function formatCloseReason(reason: string): string { + switch (reason) { + case 'take_profit': + return 'TP 익절'; + case 'stop_loss': + return 'SL 손절'; + case 'liquidation': + return '청산'; + case 'manual': + return '수동 종료'; + case 'manual_on_exchange': + return '거래소에서 종료'; + case 'reconciled_unknown': + return '동기화'; + default: + return reason; + } +} + @Injectable() export class ActivityService { constructor(private readonly prisma: PrismaService) {} @@ -44,18 +63,21 @@ export class ActivityService { }), ]); - const orderItems: ActivityItem[] = orders.map((o) => ({ - id: `order-${o.id}`, - type: 'order' as const, - title: `${o.side.toUpperCase()} ${o.symbol}`, - description: `${o.type} ${o.quantity} @ ${o.filledPrice !== '0' ? o.filledPrice : o.price || 'market'} (${o.mode})`, - exchange: o.exchange, - symbol: o.symbol, - status: o.status, - side: o.side, - link: '/orders', - createdAt: o.createdAt, - })); + const orderItems: ActivityItem[] = orders.map((o) => { + const reasonSuffix = o.closeReason ? ` · ${formatCloseReason(o.closeReason)}` : ''; + return { + id: `order-${o.id}`, + type: 'order' as const, + title: `${o.side.toUpperCase()} ${o.symbol}`, + description: `${o.type} ${o.quantity} @ ${o.filledPrice !== '0' ? o.filledPrice : o.price || 'market'} (${o.mode})${reasonSuffix}`, + exchange: o.exchange, + symbol: o.symbol, + status: o.closedAt ? 'closed' : o.status, + side: o.side, + link: `/orders/${o.id}`, + createdAt: o.createdAt, + }; + }); const loginItems: ActivityItem[] = logins.map((l) => ({ id: `login-${l.id}`, diff --git a/apps/api-server/src/app.module.ts b/apps/api-server/src/app.module.ts index b324344..44c3d15 100644 --- a/apps/api-server/src/app.module.ts +++ b/apps/api-server/src/app.module.ts @@ -13,6 +13,10 @@ 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 { DashboardModule } from './dashboard/dashboard.module'; +import { DebugModule } from './debug/debug.module'; import { JwtAuthGuard } from './auth/guards/jwt-auth.guard'; @Module({ @@ -48,6 +52,10 @@ import { JwtAuthGuard } from './auth/guards/jwt-auth.guard'; NotificationsModule, PortfolioModule, ActivityModule, + ClaudeTokensModule, + LlmTradesModule, + DashboardModule, + DebugModule, ], controllers: [AppController], providers: [ 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/dashboard/dashboard.controller.ts b/apps/api-server/src/dashboard/dashboard.controller.ts new file mode 100644 index 0000000..04bae1c --- /dev/null +++ b/apps/api-server/src/dashboard/dashboard.controller.ts @@ -0,0 +1,19 @@ +import { Controller, Get } from '@nestjs/common'; +import { ApiBearerAuth, ApiOperation, ApiTags } from '@nestjs/swagger'; +import { CurrentUser } from '../auth/decorators/current-user.decorator'; +import { DashboardService } from './dashboard.service'; + +@ApiTags('Dashboard') +@ApiBearerAuth('access-token') +@Controller('dashboard') +export class DashboardController { + constructor(private readonly service: DashboardService) {} + + @Get('summary') + @ApiOperation({ + summary: '대시보드 단일 집계 (오늘/이번주 PnL · 활성 포지션 · 최근 LLM 결정)', + }) + summary(@CurrentUser() user: { id: string }) { + return this.service.getSummary(user.id); + } +} diff --git a/apps/api-server/src/dashboard/dashboard.module.ts b/apps/api-server/src/dashboard/dashboard.module.ts new file mode 100644 index 0000000..d22f8d4 --- /dev/null +++ b/apps/api-server/src/dashboard/dashboard.module.ts @@ -0,0 +1,11 @@ +import { Module } from '@nestjs/common'; +import { PrismaModule } from '../prisma/prisma.module'; +import { DashboardController } from './dashboard.controller'; +import { DashboardService } from './dashboard.service'; + +@Module({ + imports: [PrismaModule], + controllers: [DashboardController], + providers: [DashboardService], +}) +export class DashboardModule {} diff --git a/apps/api-server/src/dashboard/dashboard.service.ts b/apps/api-server/src/dashboard/dashboard.service.ts new file mode 100644 index 0000000..db55968 --- /dev/null +++ b/apps/api-server/src/dashboard/dashboard.service.ts @@ -0,0 +1,126 @@ +import { Injectable } from '@nestjs/common'; +import Redis from 'ioredis'; +import type { Ticker } from '@coin/types'; +import { PrismaService } from '../prisma/prisma.service'; + +export interface NetworkPnlBucket { + testnet: number; + mainnet: number; +} + +@Injectable() +export class DashboardService { + 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 getSummary(userId: string) { + const startOfToday = new Date(); + startOfToday.setHours(0, 0, 0, 0); + const startOfWeek = new Date(startOfToday); + startOfWeek.setDate(startOfWeek.getDate() - 6); + + const [todayOrders, weekOrders, openPositions, recentDecisions] = await Promise.all([ + this.prisma.order.findMany({ + where: { + userId, + status: { in: ['filled', 'closed'] }, + updatedAt: { gte: startOfToday }, + realizedPnl: { not: null }, + }, + include: { exchangeKey: { select: { network: true } } }, + }), + this.prisma.order.findMany({ + where: { + userId, + status: { in: ['filled', 'closed'] }, + updatedAt: { gte: startOfWeek }, + realizedPnl: { not: null }, + }, + include: { exchangeKey: { select: { network: true } } }, + }), + this.prisma.order.findMany({ + where: { userId, status: 'filled', closedAt: null, mode: 'real' }, + include: { exchangeKey: { select: { network: true } } }, + orderBy: { createdAt: 'desc' }, + }), + this.prisma.llmDecisionLog.findMany({ + where: { userId }, + orderBy: { createdAt: 'desc' }, + take: 5, + include: { + order: { + select: { + id: true, + status: true, + symbol: true, + side: true, + entryPrice: true, + takeProfitPrice: true, + stopLossPrice: true, + realizedPnl: true, + closedAt: true, + }, + }, + }, + }), + ]); + + const positionsWithMark = await Promise.all( + openPositions.map(async (p) => { + const markPrice = await this.fetchMarkPrice(p.exchange, p.symbol); + const unrealizedPnl = this.computeUnrealizedPnl(p, markPrice); + return { ...p, markPrice, unrealizedPnl }; + }), + ); + + return { + pnl: { + today: this.bucketByNetwork(todayOrders), + week: this.bucketByNetwork(weekOrders), + }, + openPositions: positionsWithMark, + recentDecisions, + }; + } + + private bucketByNetwork( + rows: Array<{ realizedPnl: string | null; exchangeKey: { network: string } | null }>, + ): NetworkPnlBucket { + const acc: NetworkPnlBucket = { testnet: 0, mainnet: 0 }; + for (const r of rows) { + const v = Number(r.realizedPnl ?? 0); + if (!Number.isFinite(v)) continue; + const net = (r.exchangeKey?.network ?? 'mainnet') as keyof NetworkPnlBucket; + acc[net] += v; + } + acc.testnet = Math.round(acc.testnet * 100) / 100; + acc.mainnet = Math.round(acc.mainnet * 100) / 100; + return acc; + } + + private async fetchMarkPrice(exchange: string, symbol: string): Promise { + const data = await this.redis.get(`ticker:${exchange}:${symbol}`); + if (!data) return null; + const ticker: Ticker = JSON.parse(data); + const n = Number(ticker.price); + return Number.isFinite(n) ? n : null; + } + + private computeUnrealizedPnl( + order: { side: string; entryPrice: string | null; filledQuantity: string }, + markPrice: number | null, + ): number | null { + if (markPrice == null) return null; + const entry = Number(order.entryPrice ?? 0); + const qty = Number(order.filledQuantity ?? 0); + if (!entry || !qty) return null; + const direction = order.side === 'long' ? 1 : -1; + return Math.round((markPrice - entry) * qty * direction * 100) / 100; + } +} diff --git a/apps/api-server/src/debug/debug.controller.ts b/apps/api-server/src/debug/debug.controller.ts new file mode 100644 index 0000000..744de11 --- /dev/null +++ b/apps/api-server/src/debug/debug.controller.ts @@ -0,0 +1,126 @@ +import { Body, Controller, ForbiddenException, Post } from '@nestjs/common'; +import { ApiOperation, ApiTags } from '@nestjs/swagger'; +import { IsIn, IsInt, IsOptional, IsString, Max, Min } from 'class-validator'; +import { Type } from 'class-transformer'; +import { Public } from '../auth/decorators/public.decorator'; +import { PrismaService } from '../prisma/prisma.service'; +import { BinanceRest } from '@coin/exchange-adapters'; +import type { ExchangeCredentials, OrderRequest, PositionSide } from '@coin/types'; +import { decrypt } from '@coin/utils'; + +class FuturesTestDto { + @IsString() + exchangeKeyId!: string; + + @IsString() + symbol!: string; + + @IsIn(['long', 'short']) + side!: PositionSide; + + @IsString() + quantity!: string; + + @Type(() => Number) + @IsInt() + @Min(1) + @Max(20) + leverage!: number; + + @IsOptional() + @IsString() + takeProfitPrice?: string; + + @IsOptional() + @IsString() + stopLossPrice?: string; +} + +/** + * Dev-only futures testnet smoke test endpoint. Bypasses the saga and calls + * the Binance Futures adapter directly so the user can verify their testnet + * key + adapter wiring without going through the full Kafka/saga path. + * + * Disabled in production via NODE_ENV check. + */ +@ApiTags('Debug') +@Public() +@Controller('debug') +export class DebugController { + constructor(private readonly prisma: PrismaService) {} + + @Post('futures-test') + @ApiOperation({ + summary: '[DEV ONLY] Place a futures order directly on Binance via adapter', + }) + async futuresTest(@Body() dto: FuturesTestDto) { + if (process.env.NODE_ENV === 'production') { + throw new ForbiddenException('Debug endpoint disabled in production'); + } + + const masterKey = process.env.ENCRYPTION_MASTER_KEY; + if (!masterKey) throw new Error('ENCRYPTION_MASTER_KEY not configured'); + + const exchangeKey = await this.prisma.exchangeKey.findUnique({ + where: { id: dto.exchangeKeyId }, + }); + if (!exchangeKey) throw new ForbiddenException('Exchange key not found'); + + const credentials: ExchangeCredentials = { + apiKey: decrypt(exchangeKey.apiKey, masterKey), + secretKey: decrypt(exchangeKey.secretKey, masterKey), + network: (exchangeKey.network as 'mainnet' | 'testnet') ?? 'mainnet', + }; + + const adapter = new BinanceRest(); + + await adapter.setPositionMode(credentials, false); + await adapter.setMarginType(credentials, dto.symbol, 'ISOLATED'); + await adapter.setLeverage(credentials, dto.symbol, dto.leverage); + + const order: OrderRequest = { + exchange: 'binance', + symbol: dto.symbol, + side: dto.side, + type: 'market', + quantity: dto.quantity, + leverage: dto.leverage, + marginType: 'ISOLATED', + takeProfitPrice: dto.takeProfitPrice, + stopLossPrice: dto.stopLossPrice, + }; + + const entry = await adapter.placeOrder(credentials, order); + + let tp: { orderId: string } | undefined; + let sl: { orderId: string } | undefined; + if (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, + dto.quantity, + ); + } + + const position = await adapter.getPosition(credentials, dto.symbol); + + return { + network: credentials.network, + entry, + tpOrderId: tp?.orderId, + slOrderId: sl?.orderId, + position, + }; + } +} diff --git a/apps/api-server/src/debug/debug.module.ts b/apps/api-server/src/debug/debug.module.ts new file mode 100644 index 0000000..d1cd659 --- /dev/null +++ b/apps/api-server/src/debug/debug.module.ts @@ -0,0 +1,9 @@ +import { Module } from '@nestjs/common'; +import { PrismaModule } from '../prisma/prisma.module'; +import { DebugController } from './debug.controller'; + +@Module({ + imports: [PrismaModule], + controllers: [DebugController], +}) +export class DebugModule {} diff --git a/apps/api-server/src/exchange-keys/commands/create-exchange-key.handler.ts b/apps/api-server/src/exchange-keys/commands/create-exchange-key.handler.ts index 58a5705..4fcefe5 100644 --- a/apps/api-server/src/exchange-keys/commands/create-exchange-key.handler.ts +++ b/apps/api-server/src/exchange-keys/commands/create-exchange-key.handler.ts @@ -26,10 +26,12 @@ export class CreateExchangeKeyHandler implements ICommandHandler { 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-exchange-keys.handler.ts b/apps/api-server/src/exchange-keys/queries/get-exchange-keys.handler.ts index b5f7e38..8b600ed 100644 --- a/apps/api-server/src/exchange-keys/queries/get-exchange-keys.handler.ts +++ b/apps/api-server/src/exchange-keys/queries/get-exchange-keys.handler.ts @@ -12,6 +12,7 @@ export class GetExchangeKeysHandler 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/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..e0be0ea --- /dev/null +++ b/apps/api-server/src/llm-trades/llm-trades.controller.ts @@ -0,0 +1,46 @@ +import { Body, Controller, Get, Post, Query, UseGuards } from '@nestjs/common'; +import { ApiBearerAuth, ApiOperation, ApiQuery, 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); + } + + @Get('decisions') + @ApiOperation({ + summary: 'List recent LLM decisions for the current user (newest first)', + }) + @ApiQuery({ name: 'limit', required: false, description: 'Page size (default 20, max 100)' }) + @ApiQuery({ name: 'cursor', required: false, description: 'createdAt ISO cursor' }) + decisions( + @CurrentUser() user: { id: string }, + @Query('limit') limit?: string, + @Query('cursor') cursor?: string, + ) { + const parsed = Math.min(100, Math.max(1, limit ? parseInt(limit, 10) : 20)); + return this.service.listDecisions(user.id, parsed, cursor); + } +} 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..84dd7e6 --- /dev/null +++ b/apps/api-server/src/llm-trades/llm-trades.service.ts @@ -0,0 +1,199 @@ +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. 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: { + 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' }; + } + + async listDecisions(userId: string, limit: number, cursor?: string) { + const cursorDate = cursor ? new Date(cursor) : undefined; + const rows = await this.prisma.llmDecisionLog.findMany({ + where: { + userId, + ...(cursorDate ? { createdAt: { lt: cursorDate } } : {}), + }, + orderBy: { createdAt: 'desc' }, + take: limit + 1, + include: { + order: { + select: { + id: true, + status: true, + symbol: true, + side: true, + entryPrice: true, + takeProfitPrice: true, + stopLossPrice: true, + realizedPnl: true, + closedAt: true, + createdAt: true, + }, + }, + }, + }); + + const hasMore = rows.length > limit; + const items = hasMore ? rows.slice(0, limit) : rows; + const nextCursor = hasMore ? items[items.length - 1].createdAt.toISOString() : null; + return { items, nextCursor }; + } +} 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..c9c0b6d --- /dev/null +++ b/apps/api-server/src/llm/cli-runner.ts @@ -0,0 +1,89 @@ +import { spawn } from 'child_process'; + +export interface ClaudeCliOptions { + prompt: string; + oauthToken: string; + systemPrompt: 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', + '', + '--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, { + // 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 = ''; + 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..2d52333 --- /dev/null +++ b/apps/api-server/src/llm/llm-cli.service.ts @@ -0,0 +1,180 @@ +import { Injectable, Logger, BadRequestException } from '@nestjs/common'; +import type { Candle } from '@coin/types'; +import { runClaudeCli } from './cli-runner'; +import { TRADING_SYSTEM_PROMPT } from './prompts/trading-system'; + +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, + systemPrompt: TRADING_SYSTEM_PROMPT, + 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.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/api-server/src/orders/commands/close-order.command.ts b/apps/api-server/src/orders/commands/close-order.command.ts new file mode 100644 index 0000000..bb9eb50 --- /dev/null +++ b/apps/api-server/src/orders/commands/close-order.command.ts @@ -0,0 +1,6 @@ +export class CloseOrderCommand { + constructor( + public readonly userId: string, + public readonly orderId: string, + ) {} +} diff --git a/apps/api-server/src/orders/commands/close-order.handler.test.ts b/apps/api-server/src/orders/commands/close-order.handler.test.ts new file mode 100644 index 0000000..7fc798e --- /dev/null +++ b/apps/api-server/src/orders/commands/close-order.handler.test.ts @@ -0,0 +1,84 @@ +import { describe, it, expect, vi, beforeEach } from 'vitest'; +import { BadRequestException, NotFoundException } from '@nestjs/common'; + +const { mockSend } = vi.hoisted(() => ({ mockSend: vi.fn().mockResolvedValue(undefined) })); + +vi.mock('kafkajs', () => { + class FakeKafka { + producer() { + return { + connect: vi.fn().mockResolvedValue(undefined), + disconnect: vi.fn().mockResolvedValue(undefined), + send: mockSend, + }; + } + } + return { Kafka: FakeKafka }; +}); + +import { CloseOrderHandler } from './close-order.handler'; +import { CloseOrderCommand } from './close-order.command'; + +const mockPrisma = { order: { findFirst: vi.fn() } }; + +describe('CloseOrderHandler', () => { + let handler: CloseOrderHandler; + + beforeEach(() => { + vi.clearAllMocks(); + handler = new CloseOrderHandler(mockPrisma as never); + }); + + it('찾을 수 없으면 NotFoundException', async () => { + mockPrisma.order.findFirst.mockResolvedValue(null); + await expect(handler.execute(new CloseOrderCommand('u', 'x'))).rejects.toThrow( + NotFoundException, + ); + }); + + it('이미 닫힌 주문은 거부', async () => { + mockPrisma.order.findFirst.mockResolvedValue({ + id: 'o', + mode: 'real', + status: 'filled', + closedAt: new Date(), + exchangeKeyId: 'k', + }); + await expect(handler.execute(new CloseOrderCommand('u', 'o'))).rejects.toThrow( + BadRequestException, + ); + }); + + it('체결 안된 주문은 거부', async () => { + mockPrisma.order.findFirst.mockResolvedValue({ + id: 'o', + mode: 'real', + status: 'pending', + closedAt: null, + exchangeKeyId: 'k', + }); + await expect(handler.execute(new CloseOrderCommand('u', 'o'))).rejects.toThrow( + BadRequestException, + ); + }); + + it('체결된 실거래 포지션은 Kafka 이벤트를 발행한다', async () => { + mockPrisma.order.findFirst.mockResolvedValue({ + id: 'o', + mode: 'real', + status: 'filled', + closedAt: null, + exchangeKeyId: 'k', + }); + + const result = await handler.execute(new CloseOrderCommand('u', 'o')); + expect(result).toEqual({ id: 'o', status: 'pending' }); + expect(mockSend).toHaveBeenCalledTimes(1); + const call = mockSend.mock.calls[0][0]; + expect(call.topic).toBe('trading.order.close-requested'); + const payload = JSON.parse(call.messages[0].value); + expect(payload.userId).toBe('u'); + expect(payload.dbOrderId).toBe('o'); + expect(payload.requestId).toBeTruthy(); + }); +}); diff --git a/apps/api-server/src/orders/commands/close-order.handler.ts b/apps/api-server/src/orders/commands/close-order.handler.ts new file mode 100644 index 0000000..7ac15cd --- /dev/null +++ b/apps/api-server/src/orders/commands/close-order.handler.ts @@ -0,0 +1,80 @@ +import { + BadRequestException, + Injectable, + Logger, + NotFoundException, + OnModuleDestroy, + OnModuleInit, +} from '@nestjs/common'; +import { CommandHandler, ICommandHandler } from '@nestjs/cqrs'; +import { Kafka, Producer } from 'kafkajs'; +import { randomUUID } from 'crypto'; +import { KAFKA_TOPICS } from '@coin/kafka-contracts'; +import type { OrderCloseRequestedEvent } from '@coin/kafka-contracts'; +import { PrismaService } from '../../prisma/prisma.service'; +import { CloseOrderCommand } from './close-order.command'; + +@Injectable() +@CommandHandler(CloseOrderCommand) +export class CloseOrderHandler + implements ICommandHandler, OnModuleInit, OnModuleDestroy +{ + private readonly logger = new Logger(CloseOrderHandler.name); + private kafka: Kafka; + private producer: Producer; + private connected = false; + + constructor(private readonly prisma: PrismaService) { + this.kafka = new Kafka({ + clientId: 'api-orders-close', + 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 execute(command: CloseOrderCommand) { + const { userId, orderId } = command; + + const order = await this.prisma.order.findFirst({ + where: { id: orderId, userId }, + }); + if (!order) throw new NotFoundException('Order not found'); + + if (order.mode !== 'real') { + throw new BadRequestException('Only real-mode positions can be manually closed'); + } + if (order.status !== 'filled') { + throw new BadRequestException(`Cannot close order with status: ${order.status}`); + } + if (order.closedAt) { + throw new BadRequestException('Position is already closed'); + } + if (!order.exchangeKeyId) { + throw new BadRequestException('Order missing exchange key — cannot close'); + } + + const requestId = randomUUID(); + const event: OrderCloseRequestedEvent = { + requestId, + userId, + dbOrderId: orderId, + }; + + await this.producer.send({ + topic: KAFKA_TOPICS.TRADING_ORDER_CLOSE_REQUESTED, + messages: [{ key: userId, value: JSON.stringify(event) }], + }); + + this.logger.log(`Close requested: ${orderId} (requestId=${requestId})`); + return { id: orderId, status: 'pending' }; + } +} diff --git a/apps/api-server/src/orders/commands/index.ts b/apps/api-server/src/orders/commands/index.ts index dbdc491..0c96a19 100644 --- a/apps/api-server/src/orders/commands/index.ts +++ b/apps/api-server/src/orders/commands/index.ts @@ -1,7 +1,9 @@ import { CreateOrderHandler } from './create-order.handler'; import { CancelOrderHandler } from './cancel-order.handler'; +import { CloseOrderHandler } from './close-order.handler'; -export const OrderCommandHandlers = [CreateOrderHandler, CancelOrderHandler]; +export const OrderCommandHandlers = [CreateOrderHandler, CancelOrderHandler, CloseOrderHandler]; export { CreateOrderCommand } from './create-order.command'; export { CancelOrderCommand } from './cancel-order.command'; +export { CloseOrderCommand } from './close-order.command'; diff --git a/apps/api-server/src/orders/dto/create-order.dto.ts b/apps/api-server/src/orders/dto/create-order.dto.ts index 2de74ee..382776c 100644 --- a/apps/api-server/src/orders/dto/create-order.dto.ts +++ b/apps/api-server/src/orders/dto/create-order.dto.ts @@ -1,4 +1,5 @@ -import { IsString, IsIn, IsOptional } from 'class-validator'; +import { IsString, IsIn, IsOptional, IsInt, Min, Max } from 'class-validator'; +import { Type } from 'class-transformer'; import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger'; export class CreateOrderDto { @@ -10,43 +11,61 @@ export class CreateOrderDto { @IsIn(['binance']) exchange!: string; - @ApiProperty({ description: '트레이딩 심볼', example: 'BTC/USDT' }) + @ApiProperty({ description: '트레이딩 심볼 (USDT-M perpetual)', example: 'BTCUSDT' }) @IsString() symbol!: string; - @ApiProperty({ description: '주문 방향', example: 'buy', enum: ['buy', 'sell'] }) - @IsIn(['buy', 'sell']) + @ApiProperty({ description: '포지션 방향', example: 'long', enum: ['long', 'short'] }) + @IsIn(['long', 'short']) side!: string; - @ApiProperty({ description: '주문 유형', example: 'limit', enum: ['limit', 'market'] }) - @IsIn(['limit', 'market']) + @ApiProperty({ description: '주문 유형', example: 'market', enum: ['market', 'limit'] }) + @IsIn(['market', 'limit']) type!: string; - @ApiProperty({ description: '주문 수량', example: '0.001' }) + @ApiProperty({ description: '주문 수량 (base asset)', example: '0.001' }) @IsString() quantity!: string; - @ApiPropertyOptional({ - description: '지정가 (지정가 주문 시 필수)', - example: '65000.00', - }) + @ApiPropertyOptional({ description: '지정가 (지정가 주문 시 필수)', example: '65000.00' }) @IsOptional() @IsString() price?: string; - @ApiProperty({ - description: '거래 모드 (모의 또는 실전)', - example: 'paper', - enum: ['paper', 'real'], + @ApiProperty({ description: '레버리지', example: 5 }) + @Type(() => Number) + @IsInt() + @Min(1) + @Max(20) + leverage!: number; + + @ApiPropertyOptional({ + description: '마진 타입', + example: 'ISOLATED', + enum: ['ISOLATED', 'CROSS'], }) - @IsIn(['paper', 'real']) + @IsOptional() + @IsIn(['ISOLATED', 'CROSS']) + marginType?: string; + + @ApiPropertyOptional({ description: '익절 가격 (절대 USDT 가격)', example: '67000' }) + @IsOptional() + @IsString() + takeProfitPrice?: string; + + @ApiPropertyOptional({ description: '손절 가격 (절대 USDT 가격)', example: '63000' }) + @IsOptional() + @IsString() + stopLossPrice?: string; + + @ApiProperty({ description: '거래 모드', example: 'real', enum: ['real'] }) + @IsIn(['real']) mode!: string; - @ApiPropertyOptional({ - description: '실전 거래용 거래소 API 키 ID', + @ApiProperty({ + description: '실거래용 거래소 API 키 ID', example: '550e8400-e29b-41d4-a716-446655440000', }) - @IsOptional() @IsString() - exchangeKeyId?: string; + exchangeKeyId!: string; } diff --git a/apps/api-server/src/orders/dto/order-response.dto.ts b/apps/api-server/src/orders/dto/order-response.dto.ts index 95e3efa..2532899 100644 --- a/apps/api-server/src/orders/dto/order-response.dto.ts +++ b/apps/api-server/src/orders/dto/order-response.dto.ts @@ -45,6 +45,19 @@ export class OrderResponse { @ApiProperty({ description: '수정일시' }) updatedAt!: string; + + @ApiPropertyOptional({ + description: '종료 사유 (TP/SL/청산/수동/거래소-직접 종료/동기화-사유미상)', + enum: [ + 'take_profit', + 'stop_loss', + 'liquidation', + 'manual', + 'manual_on_exchange', + 'reconciled_unknown', + ], + }) + closeReason?: string | null; } export class OrderListResponse { diff --git a/apps/api-server/src/orders/orders.controller.ts b/apps/api-server/src/orders/orders.controller.ts index 0c093c6..6b695cf 100644 --- a/apps/api-server/src/orders/orders.controller.ts +++ b/apps/api-server/src/orders/orders.controller.ts @@ -19,7 +19,7 @@ import { } from '@nestjs/swagger'; import { CommandBus, QueryBus } from '@nestjs/cqrs'; import { CurrentUser } from '../auth/decorators/current-user.decorator'; -import { CreateOrderCommand, CancelOrderCommand } from './commands'; +import { CreateOrderCommand, CancelOrderCommand, CloseOrderCommand } from './commands'; import { GetOrdersQuery, GetOrderQuery } from './queries'; import { CreateOrderDto } from './dto/create-order.dto'; import { OrderResponse, OrderListResponse } from './dto/order-response.dto'; @@ -135,4 +135,20 @@ export class OrdersController { async cancel(@CurrentUser() user: User, @Param('id') id: string) { return this.commandBus.execute(new CancelOrderCommand(user.id, id)); } + + @Post(':id/close') + @HttpCode(HttpStatus.ACCEPTED) + @ApiOperation({ + summary: '체결된 실거래 포지션을 시장가 reduceOnly로 수동 종료', + description: + '체결된 실거래(real) 주문에 대해 reduceOnly MARKET 주문으로 포지션을 수동 종료합니다. Kafka 이벤트가 발행되며 worker가 거래소 종료를 처리합니다.', + }) + @ApiResponse({ status: 202, description: '종료 요청 접수' }) + @ApiResponse({ status: 400, description: '이미 종료된 주문이거나 실거래가 아님' }) + @ApiResponse({ status: 401, description: '인증 필요' }) + @ApiResponse({ status: 404, description: '주문을 찾을 수 없음' }) + @ApiParam({ name: 'id', description: '주문 ID' }) + async close(@CurrentUser() user: User, @Param('id') id: string) { + return this.commandBus.execute(new CloseOrderCommand(user.id, id)); + } } diff --git a/apps/api-server/src/orders/queries/get-order.handler.test.ts b/apps/api-server/src/orders/queries/get-order.handler.test.ts index 45647af..12b2474 100644 --- a/apps/api-server/src/orders/queries/get-order.handler.test.ts +++ b/apps/api-server/src/orders/queries/get-order.handler.test.ts @@ -1,5 +1,15 @@ import { describe, it, expect, vi, beforeEach } from 'vitest'; import { NotFoundException } from '@nestjs/common'; + +const { mockRedisGet } = vi.hoisted(() => ({ mockRedisGet: vi.fn() })); + +vi.mock('ioredis', () => { + class FakeRedis { + get = mockRedisGet; + } + return { default: FakeRedis }; +}); + import { GetOrderHandler } from './get-order.handler'; import { GetOrderQuery } from './get-order.query'; @@ -13,19 +23,66 @@ describe('GetOrderHandler', () => { handler = new GetOrderHandler(mockPrisma as never); }); - it('주문을 반환해야 한다', async () => { - const order = { id: 'order-1', userId: 'user-1', symbol: 'KRW-BTC' }; + it('주문 + 결정 + 마크 가격 + 미실현 PnL을 반환한다', async () => { + const order = { + id: 'order-1', + userId: 'user-1', + exchange: 'binance', + symbol: 'BTCUSDT', + side: 'long', + status: 'filled', + filledQuantity: '0.1', + entryPrice: '60000', + closedAt: null, + llmDecision: { id: 'd-1', model: 'opus' }, + exchangeKey: { network: 'testnet' }, + }; mockPrisma.order.findFirst.mockResolvedValue(order); + mockRedisGet.mockResolvedValue(JSON.stringify({ price: '61000' })); const result = await handler.execute(new GetOrderQuery('user-1', 'order-1')); - expect(result).toEqual(order); + + expect(result.order).toEqual(order); + expect(result.decision).toEqual(order.llmDecision); + expect(result.network).toBe('testnet'); + expect(result.markPrice).toBe(61000); + // (61000 - 60000) * 0.1 * (long → +1) = 100 + expect(result.unrealizedPnl).toBe(100); }); - it('찾을 수 없으면 NotFoundException을 던져야 한다', async () => { - mockPrisma.order.findFirst.mockResolvedValue(null); + it('short 포지션의 미실현 PnL은 부호가 반대다', async () => { + mockPrisma.order.findFirst.mockResolvedValue({ + id: 'o', + side: 'short', + filledQuantity: '0.1', + entryPrice: '60000', + closedAt: null, + llmDecision: null, + exchangeKey: null, + }); + mockRedisGet.mockResolvedValue(JSON.stringify({ price: '59000' })); - await expect(handler.execute(new GetOrderQuery('user-1', 'non-existent'))).rejects.toThrow( - NotFoundException, - ); + const result = await handler.execute(new GetOrderQuery('u', 'o')); + expect(result.unrealizedPnl).toBe(100); + }); + + it('이미 닫힌 주문은 미실현 PnL이 null', async () => { + mockPrisma.order.findFirst.mockResolvedValue({ + side: 'long', + filledQuantity: '0.1', + entryPrice: '60000', + closedAt: new Date(), + llmDecision: null, + exchangeKey: null, + }); + mockRedisGet.mockResolvedValue(JSON.stringify({ price: '61000' })); + + const result = await handler.execute(new GetOrderQuery('u', 'o')); + expect(result.unrealizedPnl).toBeNull(); + }); + + it('찾을 수 없으면 NotFoundException을 던진다', async () => { + mockPrisma.order.findFirst.mockResolvedValue(null); + await expect(handler.execute(new GetOrderQuery('u', 'x'))).rejects.toThrow(NotFoundException); }); }); diff --git a/apps/api-server/src/orders/queries/get-order.handler.ts b/apps/api-server/src/orders/queries/get-order.handler.ts index 22a0f73..19b64ab 100644 --- a/apps/api-server/src/orders/queries/get-order.handler.ts +++ b/apps/api-server/src/orders/queries/get-order.handler.ts @@ -1,17 +1,67 @@ -import { NotFoundException } from '@nestjs/common'; +import { Injectable, NotFoundException } from '@nestjs/common'; import { IQueryHandler, QueryHandler } from '@nestjs/cqrs'; +import Redis from 'ioredis'; +import type { Ticker } from '@coin/types'; import { PrismaService } from '../../prisma/prisma.service'; import { GetOrderQuery } from './get-order.query'; +@Injectable() @QueryHandler(GetOrderQuery) export class GetOrderHandler implements IQueryHandler { - constructor(private readonly prisma: PrismaService) {} + private readonly 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 execute(query: GetOrderQuery) { const order = await this.prisma.order.findFirst({ where: { id: query.orderId, userId: query.userId }, + include: { + llmDecision: true, + exchangeKey: { select: { network: true } }, + }, }); if (!order) throw new NotFoundException('Order not found'); - return order; + + const markPrice = await this.fetchMarkPrice(order.exchange, order.symbol); + const unrealizedPnl = this.computeUnrealizedPnl(order, markPrice); + + return { + order, + decision: order.llmDecision, + network: order.exchangeKey?.network ?? 'mainnet', + markPrice, + unrealizedPnl, + }; + } + + private async fetchMarkPrice(exchange: string, symbol: string): Promise { + const data = await this.redis.get(`ticker:${exchange}:${symbol}`); + if (!data) return null; + const ticker: Ticker = JSON.parse(data); + const n = Number(ticker.price); + return Number.isFinite(n) ? n : null; + } + + private computeUnrealizedPnl( + order: { + side: string; + entryPrice: string | null; + filledQuantity: string; + closedAt: Date | null; + }, + markPrice: number | null, + ): number | null { + if (order.closedAt) return null; + if (markPrice == null) return null; + const entry = Number(order.entryPrice ?? 0); + const qty = Number(order.filledQuantity ?? 0); + if (!entry || !qty) return null; + const direction = order.side === 'long' ? 1 : -1; + return Math.round((markPrice - entry) * qty * direction * 100) / 100; } } diff --git a/apps/api-server/src/orders/sagas/order-lifecycle-steps.ts b/apps/api-server/src/orders/sagas/order-lifecycle-steps.ts index d053cb9..2c6d8e2 100644 --- a/apps/api-server/src/orders/sagas/order-lifecycle-steps.ts +++ b/apps/api-server/src/orders/sagas/order-lifecycle-steps.ts @@ -2,7 +2,7 @@ import { Logger } from '@nestjs/common'; import { Producer } from 'kafkajs'; import { KAFKA_TOPICS } from '@coin/kafka-contracts'; import type { OrderRequestedEvent } from '@coin/kafka-contracts'; -import type { ExchangeId } from '@coin/types'; +import type { ExchangeId, PositionSide, MarginType } from '@coin/types'; import type { SagaStep } from '../../saga/saga-step.interface'; import { PrismaService } from '../../prisma/prisma.service'; import { randomUUID } from 'crypto'; @@ -17,6 +17,10 @@ export interface OrderLifecycleContext { quantity: string; price?: string; exchangeKeyId?: string; + leverage: number; + marginType?: string; + takeProfitPrice?: string; + stopLossPrice?: string; orderId?: string; requestId?: string; } @@ -40,6 +44,11 @@ export class CreateOrderStep implements SagaStep { status: 'pending', quantity: context.quantity, price: context.price || null, + leverage: context.leverage, + marginType: context.marginType ?? 'ISOLATED', + positionSide: context.side, + takeProfitPrice: context.takeProfitPrice, + stopLossPrice: context.stopLossPrice, }, }); @@ -74,10 +83,14 @@ export class PublishOrderRequestStep implements SagaStep order: { exchange: context.exchange as ExchangeId, symbol: context.symbol, - side: context.side as 'buy' | 'sell', - type: context.type as 'limit' | 'market', + side: context.side as PositionSide, + type: context.type as 'market' | 'limit', quantity: context.quantity, price: context.price, + leverage: context.leverage, + marginType: (context.marginType as MarginType | undefined) ?? 'ISOLATED', + takeProfitPrice: context.takeProfitPrice, + stopLossPrice: context.stopLossPrice, }, mode: context.mode as 'paper' | 'real', dbOrderId: context.orderId!, @@ -93,6 +106,6 @@ export class PublishOrderRequestStep implements SagaStep } async compensate(_context: OrderLifecycleContext): Promise { - // noop — Kafka message already sent, worker will handle idempotency + // noop } } diff --git a/apps/api-server/src/orders/sagas/order-lifecycle.orchestrator.ts b/apps/api-server/src/orders/sagas/order-lifecycle.orchestrator.ts index 9ad4413..70f644d 100644 --- a/apps/api-server/src/orders/sagas/order-lifecycle.orchestrator.ts +++ b/apps/api-server/src/orders/sagas/order-lifecycle.orchestrator.ts @@ -55,6 +55,10 @@ export class OrderLifecycleOrchestrator implements OnModuleInit, OnModuleDestroy quantity: dto.quantity, price: dto.price, exchangeKeyId: dto.exchangeKeyId, + leverage: dto.leverage, + marginType: dto.marginType, + takeProfitPrice: dto.takeProfitPrice, + stopLossPrice: dto.stopLossPrice, }; const runner = new SagaStepRunner(this.prisma); diff --git a/apps/api-server/src/portfolio/dto/portfolio-response.dto.ts b/apps/api-server/src/portfolio/dto/portfolio-response.dto.ts index dfe1299..71c6909 100644 --- a/apps/api-server/src/portfolio/dto/portfolio-response.dto.ts +++ b/apps/api-server/src/portfolio/dto/portfolio-response.dto.ts @@ -7,6 +7,9 @@ export class PortfolioAssetResponse { @ApiProperty({ description: '통화' }) currency!: string; + @ApiProperty({ description: '네트워크', enum: ['testnet', 'mainnet'] }) + network!: 'testnet' | 'mainnet'; + @ApiProperty({ description: '수량' }) quantity!: string; @@ -16,8 +19,8 @@ export class PortfolioAssetResponse { @ApiProperty({ description: '현재가' }) currentPrice!: number; - @ApiProperty({ description: '가치 (KRW)' }) - valueKrw!: number; + @ApiProperty({ description: '가치 (USD/USDT, 견적 자산)' }) + valueUsd!: number; @ApiProperty({ description: '손익' }) pnl!: number; @@ -31,9 +34,24 @@ class DailyPnlItem { pnl!: number; } +class NetworkBreakdownResponse { + @ApiProperty() totalValueUsd!: number; + @ApiProperty() realizedPnl!: number; + @ApiProperty() unrealizedPnl!: number; + @ApiProperty({ type: [DailyPnlItem] }) dailyPnl!: DailyPnlItem[]; +} + +class PortfolioByNetworkResponse { + @ApiProperty({ type: NetworkBreakdownResponse }) testnet!: NetworkBreakdownResponse; + @ApiProperty({ type: NetworkBreakdownResponse }) mainnet!: NetworkBreakdownResponse; +} + export class PortfolioSummaryResponse { - @ApiProperty({ description: '총 자산 가치 (KRW)' }) - totalValueKrw!: number; + @ApiProperty({ description: '필터된 네트워크', enum: ['testnet', 'mainnet', 'all'] }) + network!: 'testnet' | 'mainnet' | 'all'; + + @ApiProperty({ description: '총 자산 가치 (USD/USDT)' }) + totalValueUsd!: number; @ApiProperty({ description: '실현 손익' }) realizedPnl!: number; @@ -44,9 +62,9 @@ export class PortfolioSummaryResponse { @ApiProperty({ description: '자산 목록', type: [PortfolioAssetResponse] }) assets!: PortfolioAssetResponse[]; - @ApiProperty({ description: '일별 P&L', type: [DailyPnlItem] }) + @ApiProperty({ description: '일별 누적 P&L', type: [DailyPnlItem] }) dailyPnl!: DailyPnlItem[]; - @ApiProperty({ description: '모드', example: 'all' }) - mode!: string; + @ApiProperty({ description: '네트워크별 분할', type: PortfolioByNetworkResponse }) + byNetwork!: PortfolioByNetworkResponse; } diff --git a/apps/api-server/src/portfolio/portfolio.controller.ts b/apps/api-server/src/portfolio/portfolio.controller.ts index 876ed01..c27897d 100644 --- a/apps/api-server/src/portfolio/portfolio.controller.ts +++ b/apps/api-server/src/portfolio/portfolio.controller.ts @@ -4,6 +4,7 @@ import { PortfolioSummaryResponse } from './dto/portfolio-response.dto'; import { QueryBus } from '@nestjs/cqrs'; import { CurrentUser } from '../auth/decorators/current-user.decorator'; import { GetPortfolioSummaryQuery } from './queries'; +import type { PortfolioNetwork } from './queries/get-portfolio-summary.query'; import type { User } from '@coin/database'; @ApiTags('Portfolio') @@ -14,22 +15,22 @@ export class PortfolioController { @Get('summary') @ApiOperation({ - summary: '모든 거래소의 통합 포트폴리오 요약 조회', + summary: '포트폴리오 요약 (네트워크 분리)', description: - '## 포트폴리오 집계\n\n- **전체(all)**: 실제 거래소 잔고 + 모든 체결 주문 기반 손익\n- **실전(real)**: 실제 거래소 API에서 잔고 조회\n- **모의(paper)**: 모의 주문 이력 기반 가상 잔고 계산\n\n포트폴리오 요약을 조회합니다. 총 자산 가치, 실현/미실현 손익, 자산별 상세 내역을 반환합니다.\n\n- **전체(all)**: 실제 거래소 잔고 + 전체 주문 기반 손익\n- **실전(real)**: 거래소 API에서 실제 잔고 조회\n- **모의(paper)**: 모의 주문 이력 기반 가상 잔고 계산', + '실제 거래소 잔고와 체결된 주문 기반의 손익을 반환합니다. `network`로 testnet/mainnet/all 필터링이 가능하며, all 응답에는 `byNetwork` 분할 합계가 포함됩니다.', }) @ApiResponse({ status: 200, description: '포트폴리오 요약 반환', type: PortfolioSummaryResponse }) @ApiResponse({ status: 401, description: '인증 필요' }) @ApiQuery({ - name: 'mode', + name: 'network', required: false, - enum: ['paper', 'real', 'all'], - description: '거래 모드 필터', + enum: ['testnet', 'mainnet', 'all'], + description: '거래 네트워크 필터', }) async getSummary( @CurrentUser() user: User, - @Query('mode') mode?: 'paper' | 'real' | 'all', + @Query('network') network?: PortfolioNetwork, ): Promise { - return this.queryBus.execute(new GetPortfolioSummaryQuery(user.id, mode)); + return this.queryBus.execute(new GetPortfolioSummaryQuery(user.id, network)); } } diff --git a/apps/api-server/src/portfolio/portfolio.service.test.ts b/apps/api-server/src/portfolio/portfolio.service.test.ts new file mode 100644 index 0000000..e1677f4 --- /dev/null +++ b/apps/api-server/src/portfolio/portfolio.service.test.ts @@ -0,0 +1,110 @@ +import { describe, it, expect, vi, beforeEach } from 'vitest'; + +vi.mock('ioredis', () => { + class FakeRedis { + get = vi.fn().mockResolvedValue(null); + } + return { default: FakeRedis }; +}); + +import { PortfolioService } from './portfolio.service'; + +const mockKeys = [ + { + id: 'k-test', + userId: 'u', + exchange: 'binance', + network: 'testnet', + apiKey: 'enc', + secretKey: 'enc', + }, + { + id: 'k-main', + userId: 'u', + exchange: 'binance', + network: 'mainnet', + apiKey: 'enc', + secretKey: 'enc', + }, +]; + +const mockOrders = [ + { + id: 'o-test', + userId: 'u', + exchange: 'binance', + symbol: 'BTCUSDT', + side: 'long', + status: 'filled', + filledQuantity: '0.01', + filledPrice: '60000', + fee: '0.5', + realizedPnl: '5', + createdAt: new Date('2026-04-01T00:00:00Z'), + exchangeKey: { network: 'testnet' }, + }, + { + id: 'o-main', + userId: 'u', + exchange: 'binance', + symbol: 'BTCUSDT', + side: 'long', + status: 'filled', + filledQuantity: '0.02', + filledPrice: '60000', + fee: '1', + realizedPnl: '10', + createdAt: new Date('2026-04-02T00:00:00Z'), + exchangeKey: { network: 'mainnet' }, + }, +]; + +const mockPrisma = { + exchangeKey: { findMany: vi.fn().mockResolvedValue(mockKeys) }, + order: { findMany: vi.fn().mockResolvedValue(mockOrders) }, +}; +const mockConfig = { getOrThrow: vi.fn().mockReturnValue('master-key') }; + +vi.mock('@coin/utils', () => ({ + decrypt: vi.fn().mockReturnValue('plaintext'), +})); + +vi.mock('@coin/exchange-adapters', () => { + class FakeBinanceRest { + getBalances = vi.fn().mockResolvedValue([]); + } + return { BinanceRest: FakeBinanceRest }; +}); + +describe('PortfolioService.getSummary', () => { + let svc: PortfolioService; + + beforeEach(() => { + vi.clearAllMocks(); + mockPrisma.exchangeKey.findMany.mockResolvedValue(mockKeys); + mockPrisma.order.findMany.mockResolvedValue(mockOrders); + svc = new PortfolioService(mockPrisma as never, mockConfig as never); + }); + + it('network=testnet 은 testnet 합계만 반환한다', async () => { + const result = await svc.getSummary('u', 'testnet'); + expect(result.network).toBe('testnet'); + expect(result.realizedPnl).toBe(5); + expect(result.byNetwork.testnet.realizedPnl).toBe(5); + expect(result.byNetwork.mainnet.realizedPnl).toBe(10); + }); + + it('network=mainnet 은 mainnet 합계만 반환한다', async () => { + const result = await svc.getSummary('u', 'mainnet'); + expect(result.network).toBe('mainnet'); + expect(result.realizedPnl).toBe(10); + }); + + it('network=all 은 합계 + byNetwork 분할을 반환한다', async () => { + const result = await svc.getSummary('u', 'all'); + expect(result.network).toBe('all'); + expect(result.realizedPnl).toBe(15); + expect(result.byNetwork.testnet.realizedPnl).toBe(5); + expect(result.byNetwork.mainnet.realizedPnl).toBe(10); + }); +}); diff --git a/apps/api-server/src/portfolio/portfolio.service.ts b/apps/api-server/src/portfolio/portfolio.service.ts index 95adfde..f82908b 100644 --- a/apps/api-server/src/portfolio/portfolio.service.ts +++ b/apps/api-server/src/portfolio/portfolio.service.ts @@ -10,16 +10,27 @@ const REST_ADAPTERS: Record IExchangeRest> = { binance: () => new BinanceRest(), }; +export type PortfolioNetwork = 'testnet' | 'mainnet' | 'all'; + interface PortfolioAsset { exchange: string; currency: string; + network: 'testnet' | 'mainnet'; quantity: string; avgCost: number; currentPrice: number; - valueKrw: number; + /** Quote-asset value (USDT for Binance Futures). Frontend converts to user's base currency. */ + valueUsd: number; pnl: number; } +interface NetworkBreakdown { + totalValueUsd: number; + realizedPnl: number; + unrealizedPnl: number; + dailyPnl: Array<{ date: string; pnl: number }>; +} + function parseBaseCurrency(_exchange: string, symbol: string): string { for (const quote of ['USDT', 'BUSD', 'USD', 'USDC']) { if (symbol.endsWith(quote)) { @@ -44,133 +55,127 @@ export class PortfolioService { }); } - async getSummary(userId: string, mode?: 'paper' | 'real' | 'all') { - const effectiveMode = mode || 'all'; + async getSummary(userId: string, network?: PortfolioNetwork) { + const effective: PortfolioNetwork = network ?? 'all'; const masterKey = this.config.getOrThrow('ENCRYPTION_MASTER_KEY'); - // Prefetch filled orders, optionally filtered by mode - const orderWhere: { userId: string; status: string; mode?: string } = { - userId, - status: 'filled', - }; - if (effectiveMode !== 'all') { - orderWhere.mode = effectiveMode; - } + const keys = await this.prisma.exchangeKey.findMany({ where: { userId } }); + const filteredKeys = + effective === 'all' ? keys : keys.filter((k) => (k.network ?? 'mainnet') === effective); - const allFilledOrders = await this.prisma.order.findMany({ - where: orderWhere, + // Filled or closed orders joined with exchangeKey so we can split by network. + // 'closed' orders carry realizedPnl set by the close-saga or reconciler; + // excluding them would zero out realized PnL on testnet/mainnet. + const filledOrders = await this.prisma.order.findMany({ + where: { userId, status: { in: ['filled', 'closed'] } }, + include: { exchangeKey: { select: { network: true } } }, orderBy: { createdAt: 'asc' }, }); - // Build avg cost map: key = "exchange|currency" - const avgCostMap = this.buildAvgCostMap(allFilledOrders); + const ordersByNetwork = { + testnet: filledOrders.filter((o) => (o.exchangeKey?.network ?? 'mainnet') === 'testnet'), + mainnet: filledOrders.filter((o) => (o.exchangeKey?.network ?? 'mainnet') === 'mainnet'), + }; const assets: PortfolioAsset[] = []; - - if (effectiveMode === 'paper') { - // Paper mode: compute virtual balances from order history - const virtualBalances = this.computePaperBalances(allFilledOrders); - for (const [key, qty] of virtualBalances.entries()) { - const [exchange, currency] = key.split('|'); - if (qty <= 0) continue; - - const currentPrice = await this.getTickerPrice(exchange, currency); - const avgCost = avgCostMap.get(key) ?? 0; - const valueKrw = currentPrice * qty; - const pnl = avgCost > 0 ? (currentPrice - avgCost) * qty : 0; - - assets.push({ - exchange, - currency, - quantity: qty.toString(), - avgCost, - currentPrice, - valueKrw, - pnl, - }); - } - } else { - // Real or All mode: fetch from exchange APIs - const keys = await this.prisma.exchangeKey.findMany({ - where: { userId }, - }); - - for (const key of keys) { - try { - const credentials: ExchangeCredentials = { - apiKey: decrypt(key.apiKey, masterKey), - secretKey: decrypt(key.secretKey, masterKey), - }; - const adapter = REST_ADAPTERS[key.exchange as ExchangeId](); - const balances = await adapter.getBalances(credentials); - - for (const bal of balances) { - const free = parseFloat(bal.free); - const locked = parseFloat(bal.locked); - const total = free + locked; - if (total <= 0) continue; - - const currentPrice = await this.getTickerPrice(key.exchange, bal.currency); - const costKey = `${key.exchange}|${bal.currency}`; - const avgCost = avgCostMap.get(costKey) ?? 0; - - const valueKrw = currentPrice * total; - const pnl = avgCost > 0 ? (currentPrice - avgCost) * total : 0; - - assets.push({ - exchange: key.exchange, - currency: bal.currency, - quantity: total.toString(), - avgCost, - currentPrice, - valueKrw, - pnl, - }); - } - } catch (err) { - this.logger.warn(`Failed to fetch balances for ${key.exchange}: ${err}`); + for (const key of filteredKeys) { + try { + const credentials: ExchangeCredentials = { + apiKey: decrypt(key.apiKey, masterKey), + secretKey: decrypt(key.secretKey, masterKey), + network: (key.network as 'mainnet' | 'testnet') ?? 'mainnet', + }; + const adapter = REST_ADAPTERS[key.exchange as ExchangeId](); + const balances = await adapter.getBalances(credentials); + + const keyNet: 'testnet' | 'mainnet' = (key.network as 'mainnet' | 'testnet') ?? 'mainnet'; + const avgCostMap = this.buildAvgCostMap(ordersByNetwork[keyNet]); + + for (const bal of balances) { + const free = parseFloat(bal.free); + const locked = parseFloat(bal.locked); + const total = free + locked; + if (total <= 0) continue; + + const currentPrice = await this.getTickerPrice(key.exchange, bal.currency); + const costKey = `${key.exchange}|${bal.currency}`; + const avgCost = avgCostMap.get(costKey) ?? 0; + + const valueUsd = currentPrice * total; + const pnl = avgCost > 0 ? (currentPrice - avgCost) * total : 0; + + assets.push({ + exchange: key.exchange, + currency: bal.currency, + network: keyNet, + quantity: total.toString(), + avgCost, + currentPrice, + valueUsd, + pnl, + }); } + } catch (err) { + this.logger.warn(`Failed to fetch balances for ${key.exchange} (${key.network}): ${err}`); } } - // 2. Calculate realized P&L from prefetched orders - const realizedPnl = this.calculateRealizedPnl(allFilledOrders, avgCostMap); - - // 3. Calculate daily P&L from prefetched orders - const dailyPnl = this.calculateDailyPnl(allFilledOrders); - - const totalValueKrw = assets.reduce((sum, a) => sum + a.valueKrw, 0); - const unrealizedPnl = assets.reduce((sum, a) => sum + a.pnl, 0); - - return { totalValueKrw, realizedPnl, unrealizedPnl, assets, dailyPnl, mode: effectiveMode }; - } - - private computePaperBalances( - orders: Array<{ - exchange: string; - symbol: string; - side: string; - filledQuantity: string; - filledPrice: string; - }>, - ): Map { - const balances = new Map(); - - for (const order of orders) { - const currency = parseBaseCurrency(order.exchange, order.symbol); - const key = `${order.exchange}|${currency}`; - const qty = parseFloat(order.filledQuantity); - if (!Number.isFinite(qty) || qty <= 0) continue; + const breakdownFor = (rows: typeof filledOrders) => { + const avgCostMap = this.buildAvgCostMap(rows); + const deltas = this.dailyDeltaMap(rows); + const summary: NetworkBreakdown = { + totalValueUsd: 0, + realizedPnl: this.calculateRealizedPnl(rows, avgCostMap), + unrealizedPnl: 0, + dailyPnl: this.toCumulative(deltas), + }; + return { summary, deltas }; + }; - const current = balances.get(key) ?? 0; - if (order.side === 'buy') { - balances.set(key, current + qty); - } else { - balances.set(key, current - qty); - } + const testnetView = breakdownFor(ordersByNetwork.testnet); + const mainnetView = breakdownFor(ordersByNetwork.mainnet); + const testnetBreakdown = testnetView.summary; + const mainnetBreakdown = mainnetView.summary; + testnetBreakdown.totalValueUsd = assets + .filter((a) => a.network === 'testnet') + .reduce((s, a) => s + a.valueUsd, 0); + testnetBreakdown.unrealizedPnl = assets + .filter((a) => a.network === 'testnet') + .reduce((s, a) => s + a.pnl, 0); + mainnetBreakdown.totalValueUsd = assets + .filter((a) => a.network === 'mainnet') + .reduce((s, a) => s + a.valueUsd, 0); + mainnetBreakdown.unrealizedPnl = assets + .filter((a) => a.network === 'mainnet') + .reduce((s, a) => s + a.pnl, 0); + + let totalValueUsd: number; + let realizedPnl: number; + let unrealizedPnl: number; + let dailyPnl: Array<{ date: string; pnl: number }>; + if (effective === 'testnet') { + ({ totalValueUsd, realizedPnl, unrealizedPnl, dailyPnl } = testnetBreakdown); + } else if (effective === 'mainnet') { + ({ totalValueUsd, realizedPnl, unrealizedPnl, dailyPnl } = mainnetBreakdown); + } else { + totalValueUsd = testnetBreakdown.totalValueUsd + mainnetBreakdown.totalValueUsd; + realizedPnl = testnetBreakdown.realizedPnl + mainnetBreakdown.realizedPnl; + unrealizedPnl = testnetBreakdown.unrealizedPnl + mainnetBreakdown.unrealizedPnl; + const merged = new Map(); + for (const [d, v] of testnetView.deltas) merged.set(d, (merged.get(d) ?? 0) + v); + for (const [d, v] of mainnetView.deltas) merged.set(d, (merged.get(d) ?? 0) + v); + dailyPnl = this.toCumulative(merged); } - return balances; + return { + network: effective, + totalValueUsd, + realizedPnl, + unrealizedPnl, + assets, + dailyPnl, + byNetwork: { testnet: testnetBreakdown, mainnet: mainnetBreakdown }, + }; } private buildAvgCostMap( @@ -185,7 +190,9 @@ export class PortfolioService { const aggregates = new Map(); for (const order of orders) { - if (order.side !== 'buy') continue; + // Treat futures 'long' as buy, 'short' as sell for spot-style cost basis. + const isBuy = order.side === 'buy' || order.side === 'long'; + if (!isBuy) continue; const qty = parseFloat(order.filledQuantity); const price = parseFloat(order.filledPrice); @@ -231,13 +238,25 @@ export class PortfolioService { filledQuantity: string; filledPrice: string; fee: string; + realizedPnl?: string | null; }>, avgCostMap: Map, ): number { let realized = 0; for (const order of orders) { - if (order.side !== 'sell') continue; + // Prefer Binance-reported realizedPnl when present (futures positions + // closed via TP/SL/manual). Falls back to spot-style cost basis math. + if (order.realizedPnl) { + const r = parseFloat(order.realizedPnl); + if (Number.isFinite(r) && r !== 0) { + realized += r; + continue; + } + } + + const isSell = order.side === 'sell' || order.side === 'short'; + if (!isSell) continue; const qty = parseFloat(order.filledQuantity); const price = parseFloat(order.filledPrice); @@ -256,36 +275,48 @@ export class PortfolioService { return Math.round(realized * 100) / 100; } - private calculateDailyPnl( + private dailyDeltaMap( orders: Array<{ createdAt: Date; side: string; filledQuantity: string; filledPrice: string; fee: string; + realizedPnl?: string | null; }>, - ): Array<{ date: string; pnl: number }> { + ): Map { const dailyMap = new Map(); for (const order of orders) { const date = order.createdAt.toISOString().split('T')[0]; + const current = dailyMap.get(date) || 0; + + if (order.realizedPnl) { + const r = parseFloat(order.realizedPnl); + if (Number.isFinite(r) && r !== 0) { + dailyMap.set(date, current + r); + continue; + } + } + const qty = parseFloat(order.filledQuantity); const price = parseFloat(order.filledPrice); const fee = parseFloat(order.fee); const value = qty * price; + const isSell = order.side === 'sell' || order.side === 'short'; - const current = dailyMap.get(date) || 0; - if (order.side === 'sell') { - dailyMap.set(date, current + value - fee); - } else { - dailyMap.set(date, current - value - fee); - } + dailyMap.set(date, isSell ? current + value - fee : current - value - fee); } + return dailyMap; + } + private toCumulative(deltas: Map): Array<{ date: string; pnl: number }> { let cumulative = 0; - return Array.from(dailyMap.entries()).map(([date, pnl]) => { - cumulative += pnl; - return { date, pnl: Math.round(cumulative * 100) / 100 }; - }); + return Array.from(deltas.entries()) + .sort((a, b) => a[0].localeCompare(b[0])) + .map(([date, pnl]) => { + cumulative += pnl; + return { date, pnl: Math.round(cumulative * 100) / 100 }; + }); } } diff --git a/apps/api-server/src/portfolio/queries/get-portfolio-summary.handler.test.ts b/apps/api-server/src/portfolio/queries/get-portfolio-summary.handler.test.ts index 5e2a632..a492cb5 100644 --- a/apps/api-server/src/portfolio/queries/get-portfolio-summary.handler.test.ts +++ b/apps/api-server/src/portfolio/queries/get-portfolio-summary.handler.test.ts @@ -12,18 +12,15 @@ describe('GetPortfolioSummaryHandler', () => { handler = new GetPortfolioSummaryHandler(mockPortfolioService as never); }); - it('portfolioService.getSummary에 위임해야 한다', async () => { - const summary = { totalValue: 1000, assets: [] }; - mockPortfolioService.getSummary.mockResolvedValue(summary); + it('네트워크 필터를 PortfolioService에 위임한다', async () => { + mockPortfolioService.getSummary.mockResolvedValue({ network: 'testnet' }); - const result = await handler.execute(new GetPortfolioSummaryQuery('user-1', 'paper')); - expect(result).toEqual(summary); - expect(mockPortfolioService.getSummary).toHaveBeenCalledWith('user-1', 'paper'); + await handler.execute(new GetPortfolioSummaryQuery('user-1', 'testnet')); + expect(mockPortfolioService.getSummary).toHaveBeenCalledWith('user-1', 'testnet'); }); - it('모드가 지정되지 않으면 undefined를 전달해야 한다', async () => { + it('네트워크가 지정되지 않으면 undefined를 전달한다', async () => { mockPortfolioService.getSummary.mockResolvedValue({}); - await handler.execute(new GetPortfolioSummaryQuery('user-1')); expect(mockPortfolioService.getSummary).toHaveBeenCalledWith('user-1', undefined); }); diff --git a/apps/api-server/src/portfolio/queries/get-portfolio-summary.handler.ts b/apps/api-server/src/portfolio/queries/get-portfolio-summary.handler.ts index 17b1da4..c18d46f 100644 --- a/apps/api-server/src/portfolio/queries/get-portfolio-summary.handler.ts +++ b/apps/api-server/src/portfolio/queries/get-portfolio-summary.handler.ts @@ -7,6 +7,6 @@ export class GetPortfolioSummaryHandler implements IQueryHandler { - return this.portfolioService.getSummary(query.userId, query.mode); + return this.portfolioService.getSummary(query.userId, query.network); } } diff --git a/apps/api-server/src/portfolio/queries/get-portfolio-summary.query.ts b/apps/api-server/src/portfolio/queries/get-portfolio-summary.query.ts index cc99bbe..d224fed 100644 --- a/apps/api-server/src/portfolio/queries/get-portfolio-summary.query.ts +++ b/apps/api-server/src/portfolio/queries/get-portfolio-summary.query.ts @@ -1,6 +1,8 @@ +export type PortfolioNetwork = 'testnet' | 'mainnet' | 'all'; + export class GetPortfolioSummaryQuery { constructor( public readonly userId: string, - public readonly mode?: 'paper' | 'real' | 'all', + public readonly network?: PortfolioNetwork, ) {} } diff --git a/apps/web/src/app/dashboard/page.tsx b/apps/web/src/app/dashboard/page.tsx index 7c91d4b..83abb98 100644 --- a/apps/web/src/app/dashboard/page.tsx +++ b/apps/web/src/app/dashboard/page.tsx @@ -1,20 +1,273 @@ 'use client'; -import { Card, CardHeader, CardTitle, CardContent } from '@/components/ui/card'; +import Link from 'next/link'; +import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'; +import { ChevronRight, X } from 'lucide-react'; +import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'; +import { Badge } from '@/components/ui/badge'; +import { Button } from '@/components/ui/button'; +import { Skeleton } from '@/components/ui/skeleton'; +import { MoneyValue } from '@/components/shared/money-value'; +import { PnlMoney } from '@/components/shared/pnl-money'; +import { closePosition, getDashboardSummary, type DashboardSummary } from '@/lib/api-client'; export default function DashboardPage() { + const { data, isLoading } = useQuery({ + queryKey: ['dashboard'], + queryFn: getDashboardSummary, + refetchInterval: 10_000, + }); + + if (isLoading) { + return ( +
+ +
+ + +
+ + +
+ ); + } + + if (!data) { + return ( +
+

대시보드를 불러올 수 없습니다.

+
+ ); + } + + return ( +
+

대시보드

+ + + + +
+ ); +} + +function PnlSection({ pnl }: { pnl: DashboardSummary['pnl'] }) { return ( -
+
+ {(['today', 'week'] as const).map((window) => ( + + + + {window === 'today' ? '오늘 실현 손익' : '이번주 실현 손익'} + + + +
+

모의 (Testnet)

+

+ +

+
+
+

실거래 (Mainnet)

+

+ +

+
+
+
+ ))} +
+ ); +} + +function OpenPositionsSection({ positions }: { positions: DashboardSummary['openPositions'] }) { + const queryClient = useQueryClient(); + + const closeMut = useMutation({ + mutationFn: (id: string) => closePosition(id), + onSuccess: () => queryClient.invalidateQueries({ queryKey: ['dashboard'] }), + }); + + if (positions.length === 0) { + return ( - Dashboard + 활성 포지션 -

- LLM-driven Binance Futures trading is being rebuilt. Check back soon. -

+

현재 열려있는 포지션이 없습니다.

-
+ ); + } + + return ( + + + 활성 포지션 ({positions.length}) + + +
+ + + + + + + + + + + + + + {positions.map((p) => { + const entry = p.entryPrice ? Number(p.entryPrice) : null; + const network = p.exchangeKey?.network ?? 'mainnet'; + return ( + + + + + + + + + + ); + })} + +
심볼방향수량진입가현재가미실현 P&L
+ + {p.symbol} + + + + {network === 'mainnet' ? '실거래' : '모의'} + + + + {p.side} + + {p.leverage ? ( + {p.leverage}x + ) : null} + + {p.filledQuantity || p.quantity} + + {entry != null ? : '-'} + + {p.markPrice != null ? : '-'} + + + + {p.mode === 'real' && ( + + )} +
+
+
+
+ ); +} + +function RecentDecisionsSection({ decisions }: { decisions: DashboardSummary['recentDecisions'] }) { + if (decisions.length === 0) { + return ( + + + 최근 LLM 결정 + + +

아직 LLM 시그널 기록이 없습니다.

+
+
+ ); + } + + return ( + + + 최근 LLM 결정 (최대 5건) + + + {decisions.map((d) => { + const outcome = describeOutcome(d.order); + return ( +
+
+
+ {d.order?.symbol ?? '—'} + + {d.parsedSignal.signal} + + + TP {d.parsedSignal.takeProfitPrice} · SL {d.parsedSignal.stopLossPrice} + +
+
+ {outcome.label} + {d.order && ( + + 상세 + + )} +
+
+

+ {d.parsedSignal.reasoning} +

+

+ {new Date(d.createdAt).toLocaleString('ko-KR')} · {d.model} ({d.latencyMs}ms) +

+
+ ); + })} +
+
); } + +type Outcome = { label: string; variant: 'success' | 'error' | 'info' | 'muted' }; + +function describeOutcome(order: DashboardSummary['recentDecisions'][number]['order']): Outcome { + if (!order) return { label: '미실행', variant: 'muted' }; + if (order.status === 'pending') return { label: '진행 중', variant: 'info' }; + if (order.status === 'failed') return { label: '실패', variant: 'error' }; + if (order.closedAt) { + if (order.closeReason === 'take_profit') return { label: 'TP 익절', variant: 'success' }; + if (order.closeReason === 'stop_loss') return { label: 'SL 손절', variant: 'error' }; + if (order.closeReason === 'liquidation') return { label: '청산', variant: 'error' }; + if (order.closeReason === 'manual') return { label: '수동 종료', variant: 'info' }; + if (order.closeReason === 'manual_on_exchange') + return { label: '거래소 종료', variant: 'muted' }; + const pnl = Number(order.realizedPnl ?? 0); + if (pnl > 0) return { label: '익절', variant: 'success' }; + if (pnl < 0) return { label: '손절', variant: 'error' }; + return { label: '종료', variant: 'muted' }; + } + return { label: '활성', variant: 'info' }; +} 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/orders/[id]/page.tsx b/apps/web/src/app/orders/[id]/page.tsx new file mode 100644 index 0000000..0d0b372 --- /dev/null +++ b/apps/web/src/app/orders/[id]/page.tsx @@ -0,0 +1,201 @@ +'use client'; + +import { use } from 'react'; +import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'; +import { ArrowLeft, X } from 'lucide-react'; +import Link from 'next/link'; +import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'; +import { Button } from '@/components/ui/button'; +import { Badge } from '@/components/ui/badge'; +import { Skeleton } from '@/components/ui/skeleton'; +import { OrderChart } from '@/components/order-chart'; +import { CloseReasonBadge } from '@/components/close-reason-badge'; +import { MoneyValue } from '@/components/shared/money-value'; +import { PnlMoney } from '@/components/shared/pnl-money'; +import { closePosition, getOrder } from '@/lib/api-client'; + +export default function OrderDetailPage({ params }: { params: Promise<{ id: string }> }) { + const { id } = use(params); + const queryClient = useQueryClient(); + + const { data, isLoading, error } = useQuery({ + queryKey: ['order', id], + queryFn: () => getOrder(id), + refetchInterval: 5000, + }); + + const closeMut = useMutation({ + mutationFn: () => closePosition(id), + onSuccess: () => { + queryClient.invalidateQueries({ queryKey: ['order', id] }); + }, + }); + + if (isLoading) { + return ( +
+ + +
+ + +
+
+ ); + } + + if (error || !data) { + return ( +
+ + Back + +

주문을 찾을 수 없습니다.

+
+ ); + } + + const { order, decision, network, markPrice, unrealizedPnl } = data; + const isOpen = order.status === 'filled' && !order.closedAt; + const isReal = order.mode === 'real'; + const canClose = isOpen && isReal; + const sideColor = order.side === 'long' ? 'text-green-500' : 'text-red-500'; + + const entryPrice = order.entryPrice ? Number(order.entryPrice) : null; + const tpPrice = order.takeProfitPrice ? Number(order.takeProfitPrice) : null; + const slPrice = order.stopLossPrice ? Number(order.stopLossPrice) : null; + + return ( +
+
+
+ + Activity + +
+

{order.symbol}

+ {order.side} + {order.closedAt ? 'closed' : order.status} + + {network === 'mainnet' ? '실거래' : '모의'} + + {order.leverage ? {order.leverage}x : null} + +
+
+ {canClose && ( + + )} +
+ + {closeMut.isError && ( +

+ {closeMut.error instanceof Error ? closeMut.error.message : '종료 요청 실패'} +

+ )} + {closeMut.isSuccess && ( +

종료 요청이 접수되었습니다. 거래소에서 처리 중...

+ )} + + + + + + + +
+ + + 포지션 + + + {order.filledQuantity || order.quantity}} + /> + } /> + } /> + } /> + } /> + {order.realizedPnl ? ( + } /> + ) : ( + } /> + )} + {order.closedAt && ( + {new Date(order.closedAt).toLocaleString('ko-KR')}} + /> + )} + + + + + + LLM 결정 + + + {decision ? ( + <> + + {decision.model} ({decision.latencyMs}ms) + + } + /> + + {decision.parsedSignal.signal} + + } + /> +
+

근거

+

{decision.parsedSignal.reasoning}

+
+ + ) : ( +

연결된 LLM 결정이 없습니다.

+ )} +
+
+
+
+ ); +} + +function Row({ label, value }: { label: string; value: React.ReactNode }) { + return ( +
+ {label} + {value} +
+ ); +} diff --git a/apps/web/src/app/portfolio/page.tsx b/apps/web/src/app/portfolio/page.tsx index 6da9939..f81ee16 100644 --- a/apps/web/src/app/portfolio/page.tsx +++ b/apps/web/src/app/portfolio/page.tsx @@ -4,25 +4,30 @@ import { useState } from 'react'; import { useQuery } from '@tanstack/react-query'; import { Card, CardHeader, CardTitle, CardContent } from '@/components/ui/card'; import { Button } from '@/components/ui/button'; -import { getPortfolioSummary } from '@/lib/api-client'; +import { getPortfolioSummary, type PortfolioNetwork } from '@/lib/api-client'; import { useTranslations } from 'next-intl'; -import { formatKrw } from '@/lib/utils'; -import { PnlValue } from '@/components/shared/pnl-value'; +import { MoneyValue } from '@/components/shared/money-value'; +import { PnlMoney } from '@/components/shared/pnl-money'; import { PnlChart } from '@/components/shared/pnl-chart'; import { AssetTable } from '@/components/portfolio/asset-table'; import { AssetCardList } from '@/components/portfolio/asset-card-list'; import { Skeleton, SkeletonCard, SkeletonChart, SkeletonTable } from '@/components/ui/skeleton'; -const MODES = ['all', 'real', 'paper'] as const; -type Mode = (typeof MODES)[number]; +const NETWORKS: PortfolioNetwork[] = ['all', 'testnet', 'mainnet']; + +const NETWORK_LABEL: Record = { + all: '전체', + testnet: '모의 (Testnet)', + mainnet: '실거래 (Mainnet)', +}; export default function PortfolioPage() { const t = useTranslations('portfolio'); - const [mode, setMode] = useState('all'); + const [network, setNetwork] = useState('all'); const { data, isLoading } = useQuery({ - queryKey: ['portfolio', mode], - queryFn: () => getPortfolioSummary(mode), + queryKey: ['portfolio', network], + queryFn: () => getPortfolioSummary(network), staleTime: 60_000, }); @@ -32,9 +37,9 @@ export default function PortfolioPage() {
- - - + + +
@@ -64,48 +69,47 @@ export default function PortfolioPage() { ); } + const showSplit = network === 'all'; + return (
-
+

{t('title')}

- {MODES.map((m) => ( + {NETWORKS.map((n) => ( ))}
- {/* Summary Cards */}

{t('totalValue')}

-

{formatKrw(data.totalValueKrw)}

+

+ +

{t('realizedPnl')}

- +

@@ -113,13 +117,57 @@ export default function PortfolioPage() {

{t('unrealizedPnl')}

- +

- {/* P&L Chart */} + {showSplit && ( +
+ + + 모의 (Testnet) + + +
+ {t('totalValue')} + +
+
+ {t('realizedPnl')} + +
+
+ {t('unrealizedPnl')} + +
+
+
+ + + + 실거래 (Mainnet) + + + +
+ {t('totalValue')} + +
+
+ {t('realizedPnl')} + +
+
+ {t('unrealizedPnl')} + +
+
+
+
+ )} + {data.dailyPnl.length > 0 && ( @@ -131,7 +179,6 @@ export default function PortfolioPage() { )} - {/* Assets — card view on mobile, table on desktop */}

{t('assets')}

{data.assets.length > 0 ? ( 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/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 화이트리스트 허용된 키여야 합니다. +

+
+ {(['KRW', 'USD'] as const).map((c) => ( + + ))} +
+ ); +} diff --git a/apps/web/src/components/close-reason-badge.tsx b/apps/web/src/components/close-reason-badge.tsx new file mode 100644 index 0000000..48d0bbc --- /dev/null +++ b/apps/web/src/components/close-reason-badge.tsx @@ -0,0 +1,20 @@ +import { Badge } from '@/components/ui/badge'; +import type { CloseReason } from '@/lib/api-client'; + +const LABEL: Record< + CloseReason, + { text: string; variant: 'success' | 'error' | 'info' | 'muted' | 'warning' } +> = { + take_profit: { text: 'TP 익절', variant: 'success' }, + stop_loss: { text: 'SL 손절', variant: 'error' }, + liquidation: { text: '청산', variant: 'error' }, + manual: { text: '수동 종료', variant: 'info' }, + manual_on_exchange: { text: '거래소에서 종료', variant: 'warning' }, + reconciled_unknown: { text: '동기화', variant: 'muted' }, +}; + +export function CloseReasonBadge({ reason }: { reason: CloseReason | null | undefined }) { + if (!reason) return null; + const meta = LABEL[reason] ?? { text: reason, variant: 'muted' as const }; + return {meta.text}; +} 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..25f0ead --- /dev/null +++ b/apps/web/src/components/llm-trade/llm-trade-form.tsx @@ -0,0 +1,386 @@ +'use client'; + +import { useEffect, useMemo, 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 { Badge } from '@/components/ui/badge'; +import { useExchangeKeys } from '@/hooks/use-exchange-keys'; +import { useBalances } from '@/hooks/use-balances'; +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; +const LEVERAGE_MARKS = [1, 25, 50, 75, 100, 125] as const; +const QUICK_FILL_PCTS = [10, 25, 50, 100] as const; + +type Network = 'testnet' | 'mainnet'; + +interface SignalState { + response: SignalResponse; + tpOverride: string; + slOverride: string; +} + +function pctDiff(price: string | number, entry: string | number): number | null { + const p = Number(price); + const e = Number(entry); + if (!Number.isFinite(p) || !Number.isFinite(e) || e === 0) return null; + return ((p - e) / e) * 100; +} + +function formatPct(p: number | null): string { + if (p == null) return '-'; + const sign = p > 0 ? '+' : ''; + return `${sign}${p.toFixed(2)}%`; +} + +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 [network, setNetwork] = useState('testnet'); + const [betUsdt, setBetUsdt] = useState(50); + const [leverage, setLeverage] = useState(5); + const [signal, setSignal] = useState(null); + const [error, setError] = useState(''); + + const { data: keys = [] } = useExchangeKeys(); + const selectedKey = useMemo( + () => keys.find((k) => k.exchange === 'binance' && k.network === network), + [keys, network], + ); + const { data: balances = [] } = useBalances(selectedKey?.id); + const usdtBalance = useMemo(() => { + const usdt = balances.find((b) => b.currency === 'USDT'); + return usdt ? Number(usdt.free) + Number(usdt.locked) : 0; + }, [balances]); + const usdtFree = useMemo(() => { + const usdt = balances.find((b) => b.currency === 'USDT'); + return usdt ? Number(usdt.free) : 0; + }, [balances]); + + // Clamp bet to free balance whenever balance changes (and bet was over). + useEffect(() => { + if (usdtFree > 0 && betUsdt > usdtFree) setBetUsdt(Math.floor(usdtFree)); + }, [usdtFree, betUsdt]); + + 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, + exchangeKeyId: selectedKey?.id, + }); + }; + + const tpPct = signal ? pctDiff(signal.tpOverride, signal.response.entryPrice) : null; + const slPct = signal ? pctDiff(signal.slOverride, signal.response.entryPrice) : null; + + // Position-side aware: for SHORT, TP is below entry (negative %) and that's + // a profit — display absolute value with explicit profit/loss labels. + const tpProfitPct = signal + ? signal.response.signal === 'long' + ? tpPct + : tpPct == null + ? null + : -tpPct + : null; + const slLossPct = signal + ? signal.response.signal === 'long' + ? slPct + : slPct == null + ? null + : -slPct + : null; + + 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" + /> +
+
+ + {/* Network toggle */} +
+ +
+ {(['testnet', 'mainnet'] as Network[]).map((n) => ( + + ))} +
+ {!selectedKey && ( +

+ {network === 'testnet' ? '테스트넷' : '메인넷'} API 키가 설정에 등록되지 않았습니다. +

+ )} +
+ + {/* Leverage slider */} +
+
+ + {leverage}x +
+ setLeverage(Number(e.target.value))} + className="w-full accent-primary" + /> +
+ {LEVERAGE_MARKS.map((m) => ( + + ))} +
+
+ + {/* Bet amount with balance-aware quick-fill */} +
+
+ + + {selectedKey + ? `잔고 ${usdtFree.toFixed(2)} / ${usdtBalance.toFixed(2)} USDT` + : '키 미등록'} + +
+ 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" + /> +
+ {QUICK_FILL_PCTS.map((pct) => ( + + ))} +
+ {betUsdt > usdtFree && usdtFree > 0 && ( +

잔고를 초과합니다.

+ )} +
+ + + {error &&

{error}

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

{signal.response.reasoning}

+
+
+ + +
+
+
+ + = 0 ? 'text-green-500' : 'text-red-500'}`} + title="진입가 대비 가격 차이 (long 기준 양수=익절)" + > + {formatPct(tpPct)} + {tpProfitPct != null && ( + + ({tpProfitPct >= 0 ? '+' : ''} + {tpProfitPct.toFixed(2)}% 손익 · {(tpProfitPct * leverage).toFixed(2)}% ROE) + + )} + +
+ 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" + /> +
+
+
+ + + {formatPct(slPct)} + {slLossPct != null && ( + + ({slLossPct >= 0 ? '+' : ''} + {slLossPct.toFixed(2)}% 손익 · {(slLossPct * leverage).toFixed(2)}% ROE) + + )} + +
+ 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..d6bb58e 100644 --- a/apps/web/src/components/nav-bar.tsx +++ b/apps/web/src/components/nav-bar.tsx @@ -17,6 +17,7 @@ import { Button, buttonVariants } from '@/components/ui/button'; import { useUser, useLogout } from '@/hooks/use-user'; import { LanguageSwitcher } from '@/components/language-switcher'; import { ThemeToggle } from '@/components/theme-toggle'; +import { BaseCurrencyToggle } from '@/components/base-currency-toggle'; import { isDemo } from '@/lib/demo'; const DEMO_HIDDEN_PATHS = ['/settings']; @@ -61,6 +62,13 @@ export function NavBar() { 대시보드 + + + LLM Trade + + {showUserMenu && ( diff --git a/apps/web/src/components/order-chart.tsx b/apps/web/src/components/order-chart.tsx new file mode 100644 index 0000000..95c5f95 --- /dev/null +++ b/apps/web/src/components/order-chart.tsx @@ -0,0 +1,146 @@ +'use client'; + +import { useEffect, useRef, useState } from 'react'; +import { + createChart, + ColorType, + type IChartApi, + type ISeriesApi, + type IPriceLine, +} from 'lightweight-charts'; +import { useCandles } from '@/hooks/use-candles'; + +const INTERVALS = ['1m', '5m', '15m', '1h', '4h', '1d'] as const; + +interface OrderChartProps { + exchange: string; + symbol: string; + entryPrice: number | null; + takeProfitPrice: number | null; + stopLossPrice: number | null; + height?: number; +} + +export function OrderChart({ + exchange, + symbol, + entryPrice, + takeProfitPrice, + stopLossPrice, + height = 400, +}: OrderChartProps) { + const [interval, setInterval] = useState('15m'); + const containerRef = useRef(null); + const chartRef = useRef(null); + const seriesRef = useRef | null>(null); + const linesRef = useRef([]); + + const { data: candles, isLoading } = useCandles(exchange, symbol, interval); + + useEffect(() => { + if (!containerRef.current) return; + + const chart = createChart(containerRef.current, { + width: containerRef.current.clientWidth, + height, + layout: { + attributionLogo: false, + background: { type: ColorType.Solid, color: 'transparent' }, + textColor: '#9ca3af', + }, + grid: { + vertLines: { color: 'rgba(156, 163, 175, 0.1)' }, + horzLines: { color: 'rgba(156, 163, 175, 0.1)' }, + }, + timeScale: { timeVisible: true, secondsVisible: false }, + }); + + const series = chart.addCandlestickSeries({ + upColor: '#22c55e', + downColor: '#ef4444', + borderUpColor: '#22c55e', + borderDownColor: '#ef4444', + wickUpColor: '#22c55e', + wickDownColor: '#ef4444', + }); + + chartRef.current = chart; + seriesRef.current = series; + + const handleResize = () => { + if (containerRef.current && chartRef.current) { + chartRef.current.applyOptions({ width: containerRef.current.clientWidth }); + } + }; + window.addEventListener('resize', handleResize); + + return () => { + window.removeEventListener('resize', handleResize); + chart.remove(); + chartRef.current = null; + seriesRef.current = null; + linesRef.current = []; + }; + }, [height]); + + useEffect(() => { + if (!seriesRef.current || !candles) return; + seriesRef.current.setData( + candles.map((c) => ({ + time: Math.floor(c.timestamp / 1000) as never, + open: Number(c.open), + high: Number(c.high), + low: Number(c.low), + close: Number(c.close), + })), + ); + }, [candles]); + + useEffect(() => { + const series = seriesRef.current; + if (!series) return; + + for (const line of linesRef.current) series.removePriceLine(line); + linesRef.current = []; + + const add = (price: number | null, color: string, label: string) => { + if (price == null || !Number.isFinite(price)) return; + const line = series.createPriceLine({ + price, + color, + lineWidth: 1, + lineStyle: 2, + axisLabelVisible: true, + title: label, + }); + linesRef.current.push(line); + }; + + add(entryPrice, '#3b82f6', 'Entry'); + add(takeProfitPrice, '#22c55e', 'TP'); + add(stopLossPrice, '#ef4444', 'SL'); + }, [entryPrice, takeProfitPrice, stopLossPrice, candles]); + + return ( +
+
+ {INTERVALS.map((iv) => ( + + ))} +
+
+ {isLoading &&

Loading candles...

} +
+ ); +} diff --git a/apps/web/src/components/portfolio/asset-card-list.tsx b/apps/web/src/components/portfolio/asset-card-list.tsx index e42316e..5fdacd2 100644 --- a/apps/web/src/components/portfolio/asset-card-list.tsx +++ b/apps/web/src/components/portfolio/asset-card-list.tsx @@ -3,8 +3,8 @@ import { useState, useMemo } from 'react'; import { useTranslations } from 'next-intl'; import { Search } from 'lucide-react'; -import { formatKrw } from '@/lib/utils'; -import { PnlValue } from '@/components/shared/pnl-value'; +import { MoneyValue } from '@/components/shared/money-value'; +import { PnlMoney } from '@/components/shared/pnl-money'; import { ExchangeIcon, CoinIcon } from '@/components/icons'; import type { PortfolioAsset } from '@/lib/api-client'; @@ -30,7 +30,9 @@ function AssetCard({ asset }: { asset: PortfolioAsset }) {
-
{formatKrw(asset.valueKrw)}
+
+ +
{t('value')}
@@ -43,14 +45,14 @@ function AssetCard({ asset }: { asset: PortfolioAsset }) {
{t('avgCost')}
-
- {asset.avgCost > 0 ? formatKrw(asset.avgCost) : '-'} +
+ {asset.avgCost > 0 ? : '-'}
{t('current')}
-
- {asset.currentPrice > 0 ? formatKrw(asset.currentPrice) : '-'} +
+ {asset.currentPrice > 0 ? : '-'}
@@ -58,7 +60,7 @@ function AssetCard({ asset }: { asset: PortfolioAsset }) { {/* P&L row */}
{t('pnl')} - +
); diff --git a/apps/web/src/components/portfolio/asset-table.tsx b/apps/web/src/components/portfolio/asset-table.tsx index dca5902..0df3ec8 100644 --- a/apps/web/src/components/portfolio/asset-table.tsx +++ b/apps/web/src/components/portfolio/asset-table.tsx @@ -3,13 +3,14 @@ import { useState, useMemo } from 'react'; import { useTranslations } from 'next-intl'; import { ArrowUpDown, ArrowUp, ArrowDown, Search, LayoutGrid, LayoutList } from 'lucide-react'; -import { formatKrw, cn } from '@/lib/utils'; -import { PnlValue } from '@/components/shared/pnl-value'; +import { cn } from '@/lib/utils'; +import { MoneyValue } from '@/components/shared/money-value'; +import { PnlMoney } from '@/components/shared/pnl-money'; import { ExchangeIcon, CoinIcon } from '@/components/icons'; import { Button } from '@/components/ui/button'; import type { PortfolioAsset } from '@/lib/api-client'; -type AssetSortKey = 'exchange' | 'currency' | 'quantity' | 'valueKrw' | 'pnl'; +type AssetSortKey = 'exchange' | 'currency' | 'quantity' | 'valueUsd' | 'pnl'; type SortDir = 'asc' | 'desc'; type ViewMode = 'card' | 'table'; @@ -66,8 +67,8 @@ function AssetCard({ asset }: { asset: PortfolioAsset }) { {/* Current price */}

{t('current')}

-

- {asset.currentPrice > 0 ? formatKrw(asset.currentPrice) : '-'} +

+ {asset.currentPrice > 0 ? : '-'}

@@ -79,7 +80,9 @@ function AssetCard({ asset }: { asset: PortfolioAsset }) {

{t('value')}

-

{formatKrw(asset.valueKrw)}

+

+ +

@@ -87,10 +90,7 @@ function AssetCard({ asset }: { asset: PortfolioAsset }) {
{t('pnl')}
-
- {asset.pnl > 0 ? '+' : ''} - {formatKrw(asset.pnl)} -
+ {pct !== null && (
{pct > 0 ? '+' : ''} @@ -206,9 +206,9 @@ export function AssetTable({ assets }: AssetTableProps) { {t('avgCost')} {t('current')} - toggleSort('valueKrw')}> + toggleSort('valueUsd')}> {t('value')} - + toggleSort('pnl')}> {t('pnl')} @@ -232,15 +232,17 @@ export function AssetTable({ assets }: AssetTableProps) { {a.quantity} - - {a.avgCost > 0 ? formatKrw(a.avgCost) : '-'} + + {a.avgCost > 0 ? : '-'} + + + {a.currentPrice > 0 ? : '-'} - - {a.currentPrice > 0 ? formatKrw(a.currentPrice) : '-'} + + - {formatKrw(a.valueKrw)} - + ))} diff --git a/apps/web/src/components/shared/money-value.test.tsx b/apps/web/src/components/shared/money-value.test.tsx new file mode 100644 index 0000000..15af952 --- /dev/null +++ b/apps/web/src/components/shared/money-value.test.tsx @@ -0,0 +1,47 @@ +import { describe, it, expect, vi } from 'vitest'; +import { render, screen, cleanup } from '@testing-library/react'; + +const mockState = { currency: 'KRW' as 'KRW' | 'USD', krwPerUsd: 1300 }; + +vi.mock('@/hooks/use-base-currency', () => ({ + useBaseCurrency: () => ({ currency: mockState.currency, setCurrency: () => undefined }), +})); +vi.mock('@/hooks/use-exchange-rate', () => ({ + useExchangeRate: () => ({ krwPerUsd: mockState.krwPerUsd, updatedAt: null, isLoading: false }), +})); + +import { MoneyValue } from './money-value'; + +describe('MoneyValue', () => { + it('KRW 모드: ₩ 메인 + $ 서브', () => { + mockState.currency = 'KRW'; + mockState.krwPerUsd = 1300; + render(); + expect(screen.getByText(/₩/)).toBeInTheDocument(); + expect(screen.getByText(/\$/)).toBeInTheDocument(); + }); + + it('USD 모드: $ 메인 + ₩ 서브', () => { + cleanup(); + mockState.currency = 'USD'; + mockState.krwPerUsd = 1300; + render(); + expect(screen.getByText(/\$/)).toBeInTheDocument(); + expect(screen.getByText(/₩/)).toBeInTheDocument(); + }); + + it('환율 0: USD만 (서브 없음)', () => { + cleanup(); + mockState.currency = 'KRW'; + mockState.krwPerUsd = 0; + render(); + expect(screen.queryByText(/₩/)).not.toBeInTheDocument(); + expect(screen.getByText(/\$/)).toBeInTheDocument(); + }); + + it('null은 dash', () => { + cleanup(); + render(); + expect(screen.getByText('-')).toBeInTheDocument(); + }); +}); diff --git a/apps/web/src/components/shared/money-value.tsx b/apps/web/src/components/shared/money-value.tsx new file mode 100644 index 0000000..826c3d1 --- /dev/null +++ b/apps/web/src/components/shared/money-value.tsx @@ -0,0 +1,32 @@ +'use client'; + +import { useBaseCurrency } from '@/hooks/use-base-currency'; +import { useExchangeRate } from '@/hooks/use-exchange-rate'; +import { formatCurrency } from '@/lib/utils'; + +interface MoneyValueProps { + /** Amount in USD (or USDT — same thing for our purposes). */ + usd: number | null | undefined; + className?: string; + showSub?: boolean; +} + +/** + * Render a USD-denominated value in the user's chosen base currency, + * with a smaller secondary label in the alternate currency. When the + * exchange rate is unavailable we just print USD. + */ +export function MoneyValue({ usd, className, showSub = true }: MoneyValueProps) { + const { currency } = useBaseCurrency(); + const { krwPerUsd } = useExchangeRate(); + + if (usd == null || !Number.isFinite(usd)) return -; + + const { main, sub } = formatCurrency(usd, currency, krwPerUsd); + return ( + + {main} + {showSub && sub && {sub}} + + ); +} diff --git a/apps/web/src/components/shared/pnl-money.test.tsx b/apps/web/src/components/shared/pnl-money.test.tsx new file mode 100644 index 0000000..c21c3e1 --- /dev/null +++ b/apps/web/src/components/shared/pnl-money.test.tsx @@ -0,0 +1,30 @@ +import { describe, it, expect, vi } from 'vitest'; +import { render, screen } from '@testing-library/react'; + +vi.mock('@/hooks/use-base-currency', () => ({ + useBaseCurrency: () => ({ currency: 'KRW', setCurrency: () => undefined }), +})); +vi.mock('@/hooks/use-exchange-rate', () => ({ + useExchangeRate: () => ({ krwPerUsd: 1300, updatedAt: null, isLoading: false }), +})); + +import { PnlMoney } from './pnl-money'; + +describe('PnlMoney', () => { + it('양수 USD를 KRW 메인 + USD sub로 + 부호로 표시', () => { + render(); + expect(screen.getByText(/\+/)).toBeInTheDocument(); + expect(screen.getByText(/₩/)).toBeInTheDocument(); + expect(screen.getByText(/\$/)).toBeInTheDocument(); + }); + + it('음수는 마이너스 부호 + 빨간 색상 클래스', () => { + const { container } = render(); + expect(container.querySelector('.text-red-600, .dark\\:text-red-400')).toBeTruthy(); + }); + + it('null/undefined는 dash로', () => { + render(); + expect(screen.getByText('-')).toBeInTheDocument(); + }); +}); diff --git a/apps/web/src/components/shared/pnl-money.tsx b/apps/web/src/components/shared/pnl-money.tsx new file mode 100644 index 0000000..693c086 --- /dev/null +++ b/apps/web/src/components/shared/pnl-money.tsx @@ -0,0 +1,39 @@ +'use client'; + +import { useBaseCurrency } from '@/hooks/use-base-currency'; +import { useExchangeRate } from '@/hooks/use-exchange-rate'; +import { formatCurrency } from '@/lib/utils'; + +interface PnlMoneyProps { + /** Profit/loss in USD. Sign drives color; '+' is prefixed for positive. */ + usd: number | null | undefined; + showSub?: boolean; + className?: string; +} + +export function PnlMoney({ usd, showSub = true, className }: PnlMoneyProps) { + const { currency } = useBaseCurrency(); + const { krwPerUsd } = useExchangeRate(); + + if (usd == null || !Number.isFinite(usd)) { + return -; + } + + const color = + usd > 0 + ? 'text-green-600 dark:text-green-400' + : usd < 0 + ? 'text-red-600 dark:text-red-400' + : 'text-muted-foreground'; + + const { main, sub } = formatCurrency(usd, currency, krwPerUsd); + const sign = usd > 0 ? '+' : ''; + + return ( + + {sign} + {main} + {showSub && sub && {sub}} + + ); +} diff --git a/apps/web/src/components/shared/pnl-value.stories.tsx b/apps/web/src/components/shared/pnl-value.stories.tsx deleted file mode 100644 index d73de3c..0000000 --- a/apps/web/src/components/shared/pnl-value.stories.tsx +++ /dev/null @@ -1,20 +0,0 @@ -import type { Meta, StoryObj } from '@storybook/react-vite'; -import { PnlValue } from './pnl-value'; - -const meta: Meta = { - title: 'Shared/PnlValue', - component: PnlValue, - argTypes: { - value: { control: 'number' }, - prefix: { control: 'text' }, - }, -}; - -export default meta; -type Story = StoryObj; - -export const Positive: Story = { args: { value: 1500000 } }; -export const Negative: Story = { args: { value: -320000 } }; -export const Zero: Story = { args: { value: 0 } }; -export const Large: Story = { args: { value: 15000000 } }; -export const WithPrefix: Story = { args: { value: 250000, prefix: '₩' } }; diff --git a/apps/web/src/components/shared/pnl-value.test.tsx b/apps/web/src/components/shared/pnl-value.test.tsx deleted file mode 100644 index 25291ed..0000000 --- a/apps/web/src/components/shared/pnl-value.test.tsx +++ /dev/null @@ -1,35 +0,0 @@ -import { describe, it, expect } from 'vitest'; -import { render, screen } from '@testing-library/react'; -import { PnlValue } from './pnl-value'; - -describe('PnlValue', () => { - it('양수이면 초록색과 + 부호를 표시해야 한다', () => { - render(); - const el = screen.getByText(/50,000/); - expect(el.className).toContain('green'); - expect(el.textContent).toContain('+'); - }); - - it('음수이면 빨간색을 표시해야 한다', () => { - render(); - const el = screen.getByText(/30,000/); - expect(el.className).toContain('red'); - }); - - it('0이면 muted 색상을 표시해야 한다', () => { - render(); - const el = screen.getByText('0'); - expect(el.className).toContain('muted'); - }); - - it('prefix를 표시해야 한다', () => { - render(); - const el = screen.getByText(/₩/); - expect(el.textContent).toContain('₩'); - }); - - it('100만 이상이면 M 단위로 포맷해야 한다', () => { - render(); - expect(screen.getByText(/1\.50M/)).toBeInTheDocument(); - }); -}); diff --git a/apps/web/src/components/shared/pnl-value.tsx b/apps/web/src/components/shared/pnl-value.tsx deleted file mode 100644 index 2eca891..0000000 --- a/apps/web/src/components/shared/pnl-value.tsx +++ /dev/null @@ -1,18 +0,0 @@ -import { formatKrw } from '@/lib/utils'; - -export function PnlValue({ value, prefix = '' }: { value: number; prefix?: string }) { - const color = - value > 0 - ? 'text-green-600 dark:text-green-400' - : value < 0 - ? 'text-red-600 dark:text-red-400' - : 'text-muted-foreground'; - const sign = value > 0 ? '+' : ''; - return ( - - {prefix} - {sign} - {formatKrw(value)} - - ); -} diff --git a/apps/web/src/hooks/use-portfolio.ts b/apps/web/src/hooks/use-portfolio.ts index ffd0177..bd6a2cb 100644 --- a/apps/web/src/hooks/use-portfolio.ts +++ b/apps/web/src/hooks/use-portfolio.ts @@ -1,12 +1,12 @@ 'use client'; import { useQuery } from '@tanstack/react-query'; -import { getPortfolioSummary } from '@/lib/api-client'; +import { getPortfolioSummary, type PortfolioNetwork } from '@/lib/api-client'; -export function usePortfolio(mode: 'paper' | 'real' | 'all' = 'all') { +export function usePortfolio(network: PortfolioNetwork = 'all') { return useQuery({ - queryKey: ['portfolio', mode], - queryFn: () => getPortfolioSummary(mode), + queryKey: ['portfolio', network], + queryFn: () => getPortfolioSummary(network), staleTime: 60_000, }); } diff --git a/apps/web/src/lib/api-client.ts b/apps/web/src/lib/api-client.ts index 9923bcf..4a82c9d 100644 --- a/apps/web/src/lib/api-client.ts +++ b/apps/web/src/lib/api-client.ts @@ -103,6 +103,7 @@ export async function getMe() { export interface ExchangeKeyItem { id: string; exchange: string; + network: 'mainnet' | 'testnet'; createdAt: string; updatedAt: string; } @@ -122,6 +123,7 @@ export async function getExchangeKeys(): Promise { export async function createExchangeKey(data: { exchange: string; + network?: 'mainnet' | 'testnet'; apiKey: string; secretKey: string; }): Promise<{ id: string; exchange: string }> { @@ -245,6 +247,60 @@ export async function cancelOrder(id: string): Promise<{ id: string; status: str return res.json(); } +export type CloseReason = + | 'take_profit' + | 'stop_loss' + | 'liquidation' + | 'manual' + | 'manual_on_exchange' + | 'reconciled_unknown'; + +export interface OrderDetail { + order: OrderItem & { + entryPrice: string | null; + takeProfitPrice: string | null; + stopLossPrice: string | null; + realizedPnl: string | null; + closedAt: string | null; + closeReason: CloseReason | null; + leverage: number | null; + positionSide: string | null; + }; + decision: { + id: string; + parsedSignal: { + signal: 'long' | 'short'; + takeProfitPrice: string; + stopLossPrice: string; + reasoning: string; + }; + model: string; + latencyMs: number; + createdAt: string; + } | null; + network: 'testnet' | 'mainnet'; + markPrice: number | null; + unrealizedPnl: number | null; +} + +export async function getOrder(id: string): Promise { + const res = await apiFetch(`/orders/${id}`); + if (!res.ok) { + const body = await res.json().catch(() => ({})); + throw new Error(body.message || 'Failed to fetch order'); + } + return res.json(); +} + +export async function closePosition(id: string): Promise<{ id: string; status: string }> { + const res = await apiFetch(`/orders/${id}/close`, { method: 'POST' }); + if (!res.ok) { + const body = await res.json().catch(() => ({})); + throw new Error(body.message || 'Failed to close position'); + } + return res.json(); +} + // --- Notifications --- export interface NotificationSettingItem { @@ -272,28 +328,39 @@ export async function updateNotificationSettings( // --- Portfolio --- +export type PortfolioNetwork = 'testnet' | 'mainnet' | 'all'; + export interface PortfolioAsset { exchange: string; currency: string; + network: 'testnet' | 'mainnet'; quantity: string; avgCost: number; currentPrice: number; - valueKrw: number; + /** USD/USDT-denominated value. Frontend converts via useExchangeRate when displaying KRW. */ + valueUsd: number; pnl: number; } +export interface NetworkBreakdown { + totalValueUsd: number; + realizedPnl: number; + unrealizedPnl: number; + dailyPnl: Array<{ date: string; pnl: number }>; +} + export interface PortfolioSummary { - totalValueKrw: number; + network: PortfolioNetwork; + totalValueUsd: number; realizedPnl: number; unrealizedPnl: number; assets: PortfolioAsset[]; dailyPnl: Array<{ date: string; pnl: number }>; + byNetwork: { testnet: NetworkBreakdown; mainnet: NetworkBreakdown }; } -export async function getPortfolioSummary( - mode?: 'paper' | 'real' | 'all', -): Promise { - const params = mode ? `?mode=${mode}` : ''; +export async function getPortfolioSummary(network?: PortfolioNetwork): Promise { + const params = network ? `?network=${network}` : ''; const res = await apiFetch(`/portfolio/summary${params}`); if (!res.ok) throw new Error('Failed to fetch portfolio'); return res.json(); @@ -363,3 +430,136 @@ export async function getActivity(cursor?: string): Promise { 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 interface LlmDecisionItem { + id: string; + parsedSignal: { + signal: 'long' | 'short'; + takeProfitPrice: string; + stopLossPrice: string; + reasoning: string; + }; + model: string; + latencyMs: number; + createdAt: string; + order: { + id: string; + status: string; + symbol: string; + side: string; + entryPrice: string | null; + takeProfitPrice: string | null; + stopLossPrice: string | null; + realizedPnl: string | null; + closedAt: string | null; + closeReason: CloseReason | null; + createdAt: string; + } | null; +} + +export interface DashboardSummary { + pnl: { + today: { testnet: number; mainnet: number }; + week: { testnet: number; mainnet: number }; + }; + openPositions: Array< + OrderItem & { + entryPrice: string | null; + takeProfitPrice: string | null; + stopLossPrice: string | null; + leverage: number | null; + markPrice: number | null; + unrealizedPnl: number | null; + exchangeKey: { network: 'testnet' | 'mainnet' } | null; + } + >; + recentDecisions: LlmDecisionItem[]; +} + +export async function getDashboardSummary(): Promise { + const res = await apiFetch('/dashboard/summary'); + if (!res.ok) throw new Error('Failed to fetch dashboard'); + 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/web/src/lib/utils.test.ts b/apps/web/src/lib/utils.test.ts index f4b900c..76093c6 100644 --- a/apps/web/src/lib/utils.test.ts +++ b/apps/web/src/lib/utils.test.ts @@ -1,5 +1,5 @@ import { describe, it, expect } from 'vitest'; -import { cn, formatPrice, formatKrw, formatVolume } from './utils'; +import { cn, formatPrice, formatKrw, formatVolume, formatCurrency } from './utils'; describe('cn', () => { it('클래스 이름을 병합해야 한다', () => { @@ -62,3 +62,22 @@ describe('formatVolume', () => { expect(formatVolume('42.5')).toBe('42.50'); }); }); + +describe('formatCurrency', () => { + it('KRW 모드에서 main=원 표기, sub=달러 표기', () => { + const { main, sub } = formatCurrency(100, 'KRW', 1300); + expect(main).toContain('₩'); + expect(sub).toContain('$'); + }); + + it('USD 모드에서 main=달러 표기, sub=원 표기', () => { + const { main, sub } = formatCurrency(100, 'USD', 1300); + expect(main).toContain('$'); + expect(sub).toContain('₩'); + }); + + it('환율 0이면 sub은 null', () => { + const { sub } = formatCurrency(100, 'KRW', 0); + expect(sub).toBeNull(); + }); +}); diff --git a/apps/web/src/lib/utils.ts b/apps/web/src/lib/utils.ts index 986a350..0f296fa 100644 --- a/apps/web/src/lib/utils.ts +++ b/apps/web/src/lib/utils.ts @@ -25,3 +25,33 @@ export function formatVolume(volume: string): string { if (num >= 1_000) return `${(num / 1_000).toFixed(2)}K`; return num.toFixed(2); } + +export type BaseCurrency = 'KRW' | 'USD'; + +/** + * Render a USD-denominated value with a sub-label in the alternate currency. + * Returns the main string only (no sub) when the rate is unknown. + */ +export function formatCurrency( + usd: number, + baseCurrency: BaseCurrency, + krwPerUsd: number, +): { main: string; sub: string | null } { + if (!Number.isFinite(usd)) return { main: '-', sub: null }; + const usdStr = usd.toLocaleString('ko-KR', { + maximumFractionDigits: Math.abs(usd) >= 1 ? 2 : 6, + }); + if (!krwPerUsd) return { main: `$${usdStr}`, sub: null }; + + if (baseCurrency === 'KRW') { + const krw = usd * krwPerUsd; + return { + main: `${krw < 0 ? '-' : ''}₩${formatKrw(Math.abs(krw))}`, + sub: `$${usdStr}`, + }; + } + return { + main: `${usd < 0 ? '-' : ''}$${Math.abs(usd).toLocaleString('ko-KR', { maximumFractionDigits: Math.abs(usd) >= 1 ? 2 : 6 })}`, + sub: `₩${formatKrw(usd * krwPerUsd)}`, + }; +} diff --git a/apps/web/src/mocks/data/portfolio.ts b/apps/web/src/mocks/data/portfolio.ts index 142c01d..22582fd 100644 --- a/apps/web/src/mocks/data/portfolio.ts +++ b/apps/web/src/mocks/data/portfolio.ts @@ -1,63 +1,85 @@ import type { PortfolioSummary } from '@/lib/api-client'; +const emptyBreakdown = { + totalValueUsd: 0, + realizedPnl: 0, + unrealizedPnl: 0, + dailyPnl: [] as Array<{ date: string; pnl: number }>, +}; + export const demoPortfolio: PortfolioSummary = { - totalValueKrw: 15_420_000, - realizedPnl: 2_850_000, - unrealizedPnl: 680_000, + network: 'all', + totalValueUsd: 11_540, + realizedPnl: 2_120, + unrealizedPnl: 510, assets: [ { exchange: 'binance', currency: 'BTC', + network: 'mainnet', quantity: '0.007', - avgCost: 134_250_000, - currentPrice: 136_500_000, - valueKrw: 955_500, - pnl: 15_750, + avgCost: 100_000, + currentPrice: 102_000, + valueUsd: 714, + pnl: 14, }, { exchange: 'binance', currency: 'ETH', + network: 'mainnet', quantity: '0.1', - avgCost: 5_200_000, - currentPrice: 5_350_000, - valueKrw: 535_000, - pnl: 15_000, + avgCost: 3_900, + currentPrice: 4_000, + valueUsd: 400, + pnl: 10, }, { exchange: 'binance', currency: 'SOL', + network: 'testnet', quantity: '5', - avgCost: 220_000, - currentPrice: 228_000, - valueKrw: 1_140_000, - pnl: 40_000, + avgCost: 165, + currentPrice: 170, + valueUsd: 850, + pnl: 25, }, { exchange: 'binance', currency: 'XRP', + network: 'mainnet', quantity: '500', - avgCost: 3_150, - currentPrice: 3_280, - valueKrw: 1_640_000, - pnl: 65_000, + avgCost: 2.4, + currentPrice: 2.45, + valueUsd: 1_225, + pnl: 25, }, { exchange: 'binance', currency: 'USDT', + network: 'mainnet', quantity: '5000', avgCost: 1, currentPrice: 1, - valueKrw: 7_150_000, + valueUsd: 5_000, pnl: 0, }, ], dailyPnl: [ - { date: '2026-03-31', pnl: 150_000 }, - { date: '2026-04-01', pnl: 470_000 }, - { date: '2026-04-02', pnl: 390_000 }, - { date: '2026-04-03', pnl: 930_000 }, - { date: '2026-04-04', pnl: 1_110_000 }, - { date: '2026-04-05', pnl: 990_000 }, - { date: '2026-04-06', pnl: 1_400_000 }, + { date: '2026-03-31', pnl: 110 }, + { date: '2026-04-01', pnl: 350 }, + { date: '2026-04-02', pnl: 290 }, + { date: '2026-04-03', pnl: 690 }, + { date: '2026-04-04', pnl: 820 }, + { date: '2026-04-05', pnl: 730 }, + { date: '2026-04-06', pnl: 1_040 }, ], + byNetwork: { + testnet: { ...emptyBreakdown, totalValueUsd: 850, unrealizedPnl: 25 }, + mainnet: { + ...emptyBreakdown, + totalValueUsd: 10_690, + realizedPnl: 2_120, + unrealizedPnl: 485, + }, + }, }; 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..5111f40 100644 --- a/apps/worker-service/src/orders/orders.module.ts +++ b/apps/worker-service/src/orders/orders.module.ts @@ -1,8 +1,11 @@ import { Module } from '@nestjs/common'; import { OrdersService } from './orders.service'; +import { PositionReconcilerService } from './reconciler/position-reconciler.service'; +import { RiskModule } from '../risk/risk.module'; @Module({ - providers: [OrdersService], + imports: [RiskModule], + providers: [OrdersService, PositionReconcilerService], exports: [OrdersService], }) export class OrdersModule {} diff --git a/apps/worker-service/src/orders/orders.service.ts b/apps/worker-service/src/orders/orders.service.ts index aae564d..a0ec80b 100644 --- a/apps/worker-service/src/orders/orders.service.ts +++ b/apps/worker-service/src/orders/orders.service.ts @@ -2,10 +2,16 @@ import { Injectable, OnModuleInit, OnModuleDestroy, Logger } from '@nestjs/commo import { Kafka, Consumer, Producer } from 'kafkajs'; import Redis from 'ioredis'; import { KAFKA_TOPICS } from '@coin/kafka-contracts'; -import type { OrderRequestedEvent, OrderResultEvent } from '@coin/kafka-contracts'; +import type { + OrderRequestedEvent, + OrderResultEvent, + OrderCloseRequestedEvent, +} from '@coin/kafka-contracts'; import type { OrderResult } from '@coin/types'; import { PrismaService } from '../prisma/prisma.service'; import { executeRealOrderSaga } from './sagas/real-execution-steps'; +import { executeClosePositionSaga } from './sagas/close-position-saga'; +import { RiskGuardService } from '../risk/risk-guard.service'; @Injectable() export class OrdersService implements OnModuleInit, OnModuleDestroy { @@ -15,7 +21,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(','), @@ -39,14 +48,23 @@ export class OrdersService implements OnModuleInit, OnModuleDestroy { topic: KAFKA_TOPICS.TRADING_ORDER_REQUESTED, fromBeginning: false, }); + await this.consumer.subscribe({ + topic: KAFKA_TOPICS.TRADING_ORDER_CLOSE_REQUESTED, + fromBeginning: false, + }); this.logger.log('Order consumer subscribed'); await this.consumer.run({ - eachMessage: async ({ message }) => { + eachMessage: async ({ topic, message }) => { try { - console.log('[OrdersService] message received'); - const event: OrderRequestedEvent = JSON.parse(message.value!.toString()); - await this.handleOrderRequested(event); + const raw = message.value!.toString(); + if (topic === KAFKA_TOPICS.TRADING_ORDER_CLOSE_REQUESTED) { + const event: OrderCloseRequestedEvent = JSON.parse(raw); + await executeClosePositionSaga(event, this.prisma, this.producer, this.redis); + } else { + const event: OrderRequestedEvent = JSON.parse(raw); + await this.handleOrderRequested(event); + } } catch (err) { console.error('[OrdersService] message processing error:', err); } @@ -91,6 +109,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/orders/reconciler/position-reconciler.service.ts b/apps/worker-service/src/orders/reconciler/position-reconciler.service.ts new file mode 100644 index 0000000..6cd1135 --- /dev/null +++ b/apps/worker-service/src/orders/reconciler/position-reconciler.service.ts @@ -0,0 +1,160 @@ +import { Injectable, Logger, OnModuleInit, OnModuleDestroy } from '@nestjs/common'; +import Redis from 'ioredis'; +import { Kafka, Producer } from 'kafkajs'; +import { KAFKA_TOPICS } from '@coin/kafka-contracts'; +import type { OrderResultEvent, NotificationEvent } from '@coin/kafka-contracts'; +import { BinanceRest, IExchangeRest } from '@coin/exchange-adapters'; +import { decrypt } from '@coin/utils'; +import type { ExchangeId, ExchangeCredentials, IncomeRecord, PositionSide } from '@coin/types'; +import { PrismaService } from '../../prisma/prisma.service'; +import { reconcileOrder, type ReconcileDeps, type ReconcileOutcome } from './reconcile-order'; + +const REST_ADAPTERS: Record IExchangeRest> = { + binance: () => new BinanceRest(), +}; + +const DEFAULT_INTERVAL_MS = 30_000; +const AUTH_FAIL_THRESHOLD = 3; +const AUTH_COOLDOWN_SEC = 3600; + +@Injectable() +export class PositionReconcilerService implements OnModuleInit, OnModuleDestroy { + private readonly logger = new Logger(PositionReconcilerService.name); + private readonly redis: Redis; + private readonly kafka: Kafka; + private readonly producer: Producer; + private timer: ReturnType | null = null; + private running = false; + + constructor(private readonly prisma: PrismaService) { + this.redis = new Redis({ + host: process.env.REDIS_HOST || 'localhost', + port: Number(process.env.REDIS_PORT || 6379), + }); + this.kafka = new Kafka({ + clientId: 'worker-position-reconciler', + brokers: (process.env.KAFKA_BROKERS || 'localhost:9092').split(','), + }); + this.producer = this.kafka.producer(); + } + + async onModuleInit() { + await this.producer.connect(); + const intervalMs = Number(process.env.RECONCILE_INTERVAL_MS) || DEFAULT_INTERVAL_MS; + this.timer = setInterval(() => { + void this.runOnce().catch((err) => this.logger.error(`Reconcile tick failed: ${err}`)); + }, intervalMs); + this.logger.log(`PositionReconciler started (interval=${intervalMs}ms)`); + } + + async onModuleDestroy() { + if (this.timer) clearInterval(this.timer); + await this.producer.disconnect(); + this.redis.disconnect(); + } + + /** + * One reconcile pass over all open real-mode orders. Public so it can be + * driven from tests with deterministic timing. + */ + async runOnce(): Promise { + if (this.running) { + this.logger.debug('Skip overlapping reconcile tick'); + return []; + } + this.running = true; + try { + const orders = await this.prisma.order.findMany({ + where: { mode: 'real', status: 'filled', closedAt: null }, + include: { exchangeKey: true }, + orderBy: { createdAt: 'asc' }, + }); + if (orders.length === 0) return []; + + const masterKey = process.env.ENCRYPTION_MASTER_KEY; + if (!masterKey) throw new Error('ENCRYPTION_MASTER_KEY not configured'); + + const outcomes: ReconcileOutcome[] = []; + for (const order of orders) { + if (!order.exchangeKeyId || !order.exchangeKey) continue; + + const cooldownKey = `reconcile:auth-cooldown:${order.exchangeKeyId}`; + if (await this.redis.get(cooldownKey)) continue; + + const credentials: ExchangeCredentials = { + apiKey: decrypt(order.exchangeKey.apiKey, masterKey), + secretKey: decrypt(order.exchangeKey.secretKey, masterKey), + network: (order.exchangeKey.network as 'mainnet' | 'testnet') ?? 'mainnet', + }; + + const adapter = REST_ADAPTERS[order.exchange as ExchangeId](); + const deps: ReconcileDeps = { + prisma: this.prisma, + redis: this.redis, + getPosition: (s) => adapter.getPosition(credentials, s), + getIncome: (opts) => adapter.getIncome(credentials, opts), + emit: (events) => this.emit(events, order.userId), + }; + + try { + const outcome = await reconcileOrder(order, deps); + outcomes.push(outcome); + if (outcome.action === 'closed') { + this.logger.log( + `Reconciled ${order.id}: ${outcome.reason} realizedPnl=${outcome.realizedPnl ?? 'null'}`, + ); + } + } catch (err) { + if (this.isAuthError(err)) { + await this.bumpAuthFailure(order.exchangeKeyId); + } + this.logger.warn(`Reconcile failed for ${order.id}: ${err}`); + } + } + return outcomes; + } finally { + this.running = false; + } + } + + private async emit( + events: { result?: OrderResultEvent; notification?: NotificationEvent }[], + _userId: string, + ) { + for (const e of events) { + if (e.result) { + await this.producer.send({ + topic: KAFKA_TOPICS.TRADING_ORDER_RESULT, + messages: [{ key: e.result.userId, value: JSON.stringify(e.result) }], + }); + } + if (e.notification) { + await this.producer.send({ + topic: KAFKA_TOPICS.NOTIFICATION_SEND, + messages: [{ key: e.notification.userId, value: JSON.stringify(e.notification) }], + }); + } + } + } + + private isAuthError(err: unknown): boolean { + if (!(err instanceof Error)) return false; + return /-2014|-2015|-1022|API-key|Invalid signature/i.test(err.message); + } + + private async bumpAuthFailure(exchangeKeyId: string) { + const counterKey = `reconcile:auth-fail:${exchangeKeyId}`; + const count = await this.redis.incr(counterKey); + await this.redis.expire(counterKey, AUTH_COOLDOWN_SEC); + if (count >= AUTH_FAIL_THRESHOLD) { + const cooldownKey = `reconcile:auth-cooldown:${exchangeKeyId}`; + await this.redis.set(cooldownKey, '1', 'EX', AUTH_COOLDOWN_SEC); + this.logger.warn( + `Exchange key ${exchangeKeyId} put in 1h auth cooldown after ${count} failures`, + ); + } + } +} + +// Export type aliases for tests +export type { IncomeRecord, PositionSide }; diff --git a/apps/worker-service/src/orders/reconciler/reconcile-order.test.ts b/apps/worker-service/src/orders/reconciler/reconcile-order.test.ts new file mode 100644 index 0000000..83cc40d --- /dev/null +++ b/apps/worker-service/src/orders/reconciler/reconcile-order.test.ts @@ -0,0 +1,147 @@ +import { describe, it, expect, vi, beforeEach } from 'vitest'; +import { reconcileOrder, type ReconcileDeps } from './reconcile-order'; + +function makeOrder(overrides: Partial[0]> = {}) { + return { + id: 'o-1', + userId: 'u', + exchange: 'binance', + symbol: 'BTCUSDT', + side: 'long', + quantity: '0.01', + filledQuantity: '0.01', + entryPrice: '60000', + tpOrderId: 'tp-1', + slOrderId: 'sl-1', + exchangeKeyId: 'k', + createdAt: new Date('2026-05-01T00:00:00Z'), + ...overrides, + }; +} + +function makeDeps(): { + deps: ReconcileDeps; + redis: { set: ReturnType; del: ReturnType }; + prismaUpdate: ReturnType; + emit: ReturnType; + getPosition: ReturnType; + getIncome: ReturnType; +} { + const redis = { + set: vi.fn().mockResolvedValue('OK'), + del: vi.fn().mockResolvedValue(1), + }; + const prismaUpdate = vi.fn().mockResolvedValue({ count: 1 }); + const emit = vi.fn().mockResolvedValue(undefined); + const getPosition = vi.fn(); + const getIncome = vi.fn(); + const deps: ReconcileDeps = { + redis, + prisma: { order: { updateMany: prismaUpdate } }, + getPosition, + getIncome, + emit, + }; + return { deps, redis, prismaUpdate, emit, getPosition, getIncome }; +} + +describe('reconcileOrder', () => { + beforeEach(() => vi.clearAllMocks()); + + it('포지션이 살아있으면 skip', async () => { + const { deps, getPosition, prismaUpdate } = makeDeps(); + getPosition.mockResolvedValue({ + symbol: 'BTCUSDT', + side: 'long', + quantity: '0.01', + }); + + const result = await reconcileOrder(makeOrder(), deps); + + expect(result).toEqual({ action: 'skip', reason: 'live_position' }); + expect(prismaUpdate).not.toHaveBeenCalled(); + }); + + it('TP 발동 (양수 PnL + tp/sl 등록됨) → take_profit', async () => { + const { deps, getPosition, getIncome, prismaUpdate } = makeDeps(); + getPosition.mockResolvedValue(null); + getIncome.mockResolvedValue([ + { symbol: 'BTCUSDT', incomeType: 'REALIZED_PNL', income: '5.5', asset: 'USDT', time: 1 }, + { symbol: 'BTCUSDT', incomeType: 'COMMISSION', income: '-0.05', asset: 'USDT', time: 1 }, + ]); + + const result = await reconcileOrder(makeOrder(), deps); + + expect(result).toMatchObject({ action: 'closed', reason: 'take_profit' }); + expect(prismaUpdate).toHaveBeenCalledWith( + expect.objectContaining({ + where: { id: 'o-1', closedAt: null }, + data: expect.objectContaining({ status: 'closed', closeReason: 'take_profit' }), + }), + ); + }); + + it('SL 발동 (음수 PnL + tp/sl 등록됨) → stop_loss', async () => { + const { deps, getPosition, getIncome } = makeDeps(); + getPosition.mockResolvedValue(null); + getIncome.mockResolvedValue([ + { symbol: 'BTCUSDT', incomeType: 'REALIZED_PNL', income: '-3.0', asset: 'USDT', time: 1 }, + ]); + + const result = await reconcileOrder(makeOrder(), deps); + expect(result).toMatchObject({ action: 'closed', reason: 'stop_loss' }); + }); + + it('INSURANCE_CLEAR row 존재 → liquidation', async () => { + const { deps, getPosition, getIncome } = makeDeps(); + getPosition.mockResolvedValue(null); + getIncome.mockResolvedValue([ + { symbol: 'BTCUSDT', incomeType: 'REALIZED_PNL', income: '-10', asset: 'USDT', time: 1 }, + { symbol: 'BTCUSDT', incomeType: 'INSURANCE_CLEAR', income: '-2', asset: 'USDT', time: 1 }, + ]); + + const result = await reconcileOrder(makeOrder(), deps); + expect(result).toMatchObject({ action: 'closed', reason: 'liquidation' }); + }); + + it('income endpoint이 빈 배열 → reconciled_unknown (다음 tick에 정정)', async () => { + const { deps, getPosition, getIncome } = makeDeps(); + getPosition.mockResolvedValue(null); + getIncome.mockResolvedValue([]); + + const result = await reconcileOrder(makeOrder(), deps); + expect(result).toEqual({ action: 'closed', reason: 'reconciled_unknown', realizedPnl: null }); + }); + + it('Redis 잠금이 잡히면 skip (lock_held) — 동시 실행 보호', async () => { + const { deps, redis } = makeDeps(); + redis.set.mockResolvedValue(null); + + const result = await reconcileOrder(makeOrder(), deps); + expect(result).toEqual({ action: 'skip', reason: 'lock_held' }); + }); + + it('수동 close가 먼저 닫아두면 race_lost — 알림 중복 발송하지 않음', async () => { + const { deps, getPosition, getIncome, prismaUpdate, emit } = makeDeps(); + getPosition.mockResolvedValue(null); + getIncome.mockResolvedValue([ + { symbol: 'BTCUSDT', incomeType: 'REALIZED_PNL', income: '5', asset: 'USDT', time: 1 }, + ]); + prismaUpdate.mockResolvedValue({ count: 0 }); + + const result = await reconcileOrder(makeOrder(), deps); + expect(result).toEqual({ action: 'skip', reason: 'race_lost' }); + expect(emit).not.toHaveBeenCalled(); + }); + + it('TP/SL 미등록 + 양수 PnL → manual_on_exchange (사용자가 거래소에서 직접 닫음)', async () => { + const { deps, getPosition, getIncome } = makeDeps(); + getPosition.mockResolvedValue(null); + getIncome.mockResolvedValue([ + { symbol: 'BTCUSDT', incomeType: 'REALIZED_PNL', income: '7', asset: 'USDT', time: 1 }, + ]); + + const result = await reconcileOrder(makeOrder({ tpOrderId: null, slOrderId: null }), deps); + expect(result).toMatchObject({ action: 'closed', reason: 'manual_on_exchange' }); + }); +}); diff --git a/apps/worker-service/src/orders/reconciler/reconcile-order.ts b/apps/worker-service/src/orders/reconciler/reconcile-order.ts new file mode 100644 index 0000000..e638841 --- /dev/null +++ b/apps/worker-service/src/orders/reconciler/reconcile-order.ts @@ -0,0 +1,198 @@ +import type Redis from 'ioredis'; +import type { OrderResultEvent, NotificationEvent } from '@coin/kafka-contracts'; +import type { IncomeRecord, Position, PositionSide } from '@coin/types'; + +export type CloseReason = + | 'take_profit' + | 'stop_loss' + | 'liquidation' + | 'manual' + | 'manual_on_exchange' + | 'reconciled_unknown'; + +export interface ReconcileOrderInput { + id: string; + userId: string; + exchange: string; + symbol: string; + side: string; + quantity: string; + filledQuantity: string; + entryPrice: string | null; + tpOrderId: string | null; + slOrderId: string | null; + exchangeKeyId: string | null; + createdAt: Date; +} + +export interface ReconcileOrderDelegate { + updateMany(args: { + where: { id: string; closedAt: null }; + data: { + status: string; + closedAt: Date; + realizedPnl: string | null; + closeReason: string; + }; + }): Promise<{ count: number }>; +} + +export interface ReconcileDeps { + prisma: { order: ReconcileOrderDelegate }; + redis: Pick; + getPosition(symbol: string): Promise; + getIncome(opts: { + symbol: string; + startTime: number; + endTime: number; + limit?: number; + }): Promise; + emit(events: { result?: OrderResultEvent; notification?: NotificationEvent }[]): Promise; +} + +export type ReconcileOutcome = + | { action: 'skip'; reason: 'live_position' | 'lock_held' | 'race_lost' } + | { + action: 'closed'; + reason: CloseReason; + realizedPnl: string | null; + }; + +const REASON_LABEL: Record = { + take_profit: 'TP 익절', + stop_loss: 'SL 손절', + liquidation: '청산', + manual: '수동 종료', + manual_on_exchange: '거래소에서 직접 종료', + reconciled_unknown: '동기화 (사유 미상)', +}; + +/** + * Pure (modulo deps) reconcile of a single order. Idempotent via Redis lock. + * + * Flow: + * 1. Acquire lock keyed by order id (NX). If held, skip. + * 2. Query live position. If still open with non-zero qty → skip. + * 3. Query income window since order createdAt; pick rows whose tradeId + * matches our recorded tpOrderId / slOrderId. Use those to derive + * reason. Otherwise classify by income types present. + * 4. Sum REALIZED_PNL + COMMISSION + FUNDING_FEE for the symbol. + * 5. Atomically mark order closed (only if not already closed) and emit + * result + notification events. + */ +export async function reconcileOrder( + order: ReconcileOrderInput, + deps: ReconcileDeps, +): Promise { + const lockKey = `reconcile:order:${order.id}`; + const acquired = await deps.redis.set(lockKey, '1', 'EX', 60, 'NX'); + if (!acquired) return { action: 'skip', reason: 'lock_held' }; + + try { + const live = await deps.getPosition(order.symbol); + if (live && Number(live.quantity) > 0) { + return { action: 'skip', reason: 'live_position' }; + } + + // Lookback window: the entire life of the trade plus a small slack on + // either side so we don't miss a fill that landed micro-seconds before + // our createdAt timestamp. + const startTime = order.createdAt.getTime() - 5_000; + const endTime = Date.now(); + const incomes = await deps.getIncome({ + symbol: order.symbol, + startTime, + endTime, + limit: 1000, + }); + + const { reason, realizedPnl } = classify(order, incomes); + + // Race-safe update — only flip if still open. Returns 0 rows if a + // concurrent writer (manual close) already closed it. + const updated = await deps.prisma.order.updateMany({ + where: { id: order.id, closedAt: null }, + data: { + status: 'closed', + closedAt: new Date(), + realizedPnl, + closeReason: reason, + }, + }); + if (updated.count === 0) { + return { action: 'skip', reason: 'race_lost' }; + } + + await deps.emit([ + { + notification: { + userId: order.userId, + type: 'order_filled', + title: `포지션 종료 | ${order.exchange.toUpperCase()} ${order.symbol}`, + message: `${REASON_LABEL[reason]} — ${realizedPnl ? `PnL ${realizedPnl}` : '실현 손익 미정'}`, + }, + }, + ]); + + return { action: 'closed', reason, realizedPnl }; + } finally { + await deps.redis.del(lockKey).catch(() => undefined); + } +} + +interface Classified { + reason: CloseReason; + realizedPnl: string | null; +} + +function classify(order: ReconcileOrderInput, incomes: IncomeRecord[]): Classified { + const symbolIncomes = incomes.filter((i) => !i.symbol || i.symbol === order.symbol); + + const realized = symbolIncomes.filter((i) => i.incomeType === 'REALIZED_PNL'); + const commissions = symbolIncomes.filter((i) => i.incomeType === 'COMMISSION'); + const funding = symbolIncomes.filter((i) => i.incomeType === 'FUNDING_FEE'); + const insurance = symbolIncomes.filter((i) => i.incomeType === 'INSURANCE_CLEAR'); + + // Liquidation: Binance writes INSURANCE_CLEAR rows on forced close. + if (insurance.length > 0) { + return { + reason: 'liquidation', + realizedPnl: sumPnl([...realized, ...commissions, ...funding, ...insurance]), + }; + } + + // Match TP/SL by tradeId — Binance reports the trade that closed the + // position; tradeId on REALIZED_PNL == the algoOrder's tradeId, but in + // practice we store tpOrderId/slOrderId as algoIds, and those don't + // appear directly. So we use a heuristic: positive PnL → TP, negative → SL, + // ONLY when both tpOrderId and slOrderId were registered. Otherwise mark + // as manual_on_exchange. + if (realized.length > 0) { + const total = sumPnl([...realized, ...commissions, ...funding]); + const totalNum = Number(total); + + if (order.tpOrderId && order.slOrderId) { + const reason: CloseReason = totalNum >= 0 ? 'take_profit' : 'stop_loss'; + return { reason, realizedPnl: total }; + } + return { reason: 'manual_on_exchange', realizedPnl: total }; + } + + // Position closed but no income rows yet (Binance can lag a few seconds). + // Estimate from entry vs… we don't have a fill price here, so leave null + // and let the next tick refine when income lands. + if (order.entryPrice) { + const direction = (order.side as PositionSide) === 'long' ? 1 : -1; + void direction; + } + return { reason: 'reconciled_unknown', realizedPnl: null }; +} + +function sumPnl(records: IncomeRecord[]): string { + let total = 0; + for (const r of records) { + const v = Number(r.income); + if (Number.isFinite(v)) total += v; + } + return total.toFixed(8); +} diff --git a/apps/worker-service/src/orders/sagas/close-position-saga.test.ts b/apps/worker-service/src/orders/sagas/close-position-saga.test.ts new file mode 100644 index 0000000..6d5c9b3 --- /dev/null +++ b/apps/worker-service/src/orders/sagas/close-position-saga.test.ts @@ -0,0 +1,160 @@ +import { describe, it, expect, vi, beforeEach } from 'vitest'; + +const { closePositionMock, getPositionMock } = vi.hoisted(() => ({ + closePositionMock: vi.fn(), + getPositionMock: vi.fn(), +})); + +vi.mock('@coin/exchange-adapters', () => { + class FakeBinanceRest { + closePosition = closePositionMock; + getPosition = getPositionMock; + } + return { BinanceRest: FakeBinanceRest }; +}); + +vi.mock('@coin/utils', () => ({ + decrypt: vi.fn().mockReturnValue('plain'), +})); + +import { executeClosePositionSaga } from './close-position-saga'; + +describe('executeClosePositionSaga', () => { + const order = { + id: 'o-1', + userId: 'u', + exchange: 'binance', + symbol: 'BTCUSDT', + side: 'long', + status: 'filled', + closedAt: null, + exchangeKeyId: 'k', + quantity: '0.01', + filledQuantity: '0.01', + entryPrice: '60000', + }; + const exchangeKey = { id: 'k', userId: 'u', apiKey: 'enc', secretKey: 'enc', network: 'mainnet' }; + const livePosition = { + exchange: 'binance', + symbol: 'BTCUSDT', + side: 'long', + quantity: '0.01', + entryPrice: '60000', + markPrice: '60500', + liquidationPrice: '0', + leverage: 5, + marginType: 'ISOLATED', + unrealizedPnl: '5', + }; + + let prisma: any; + let producer: any; + let redis: any; + + beforeEach(() => { + vi.clearAllMocks(); + process.env.ENCRYPTION_MASTER_KEY = 'master'; + prisma = { + order: { + findFirst: vi.fn().mockResolvedValue(order), + update: vi.fn().mockResolvedValue(undefined), + }, + exchangeKey: { findFirst: vi.fn().mockResolvedValue(exchangeKey) }, + }; + producer = { send: vi.fn().mockResolvedValue(undefined) }; + redis = { set: vi.fn().mockResolvedValue('OK') }; + closePositionMock.mockResolvedValue({ + orderId: 'ex-1', + status: 'filled', + filledPrice: '61000', + filledQuantity: '0.01', + symbol: 'BTCUSDT', + side: 'long', + type: 'market', + price: '0', + fee: '0', + feeCurrency: 'USDT', + timestamp: Date.now(), + }); + getPositionMock.mockResolvedValue(livePosition); + }); + + it('포지션이 살아있으면 closePosition으로 종료한다', async () => { + await executeClosePositionSaga( + { requestId: 'r1', userId: 'u', dbOrderId: 'o-1' }, + prisma, + producer, + redis, + ); + + expect(closePositionMock).toHaveBeenCalledWith( + expect.objectContaining({ network: 'mainnet' }), + 'BTCUSDT', + 'long', + '0.01', + ); + expect(prisma.order.update).toHaveBeenCalledWith( + expect.objectContaining({ + where: { id: 'o-1' }, + data: expect.objectContaining({ status: 'closed' }), + }), + ); + }); + + it('Binance에 포지션이 이미 없으면 closePosition을 부르지 않고 DB만 동기화한다 (TP/SL 사후 처리)', async () => { + getPositionMock.mockResolvedValue(null); + await executeClosePositionSaga( + { requestId: 'r4', userId: 'u', dbOrderId: 'o-1' }, + prisma, + producer, + redis, + ); + expect(closePositionMock).not.toHaveBeenCalled(); + expect(prisma.order.update).toHaveBeenCalledWith( + expect.objectContaining({ + data: expect.objectContaining({ status: 'closed' }), + }), + ); + }); + + it('closePosition이 -2022 reduceOnly rejection을 던지면 DB만 동기화한다', async () => { + closePositionMock.mockRejectedValue( + new Error('Binance error -2022 ReduceOnly Order is rejected'), + ); + await executeClosePositionSaga( + { requestId: 'r5', userId: 'u', dbOrderId: 'o-1' }, + prisma, + producer, + redis, + ); + expect(prisma.order.update).toHaveBeenCalledWith( + expect.objectContaining({ + data: expect.objectContaining({ status: 'closed' }), + }), + ); + }); + + it('이미 닫힌 주문은 closePosition을 호출하지 않는다', async () => { + prisma.order.findFirst.mockResolvedValue({ ...order, closedAt: new Date() }); + await executeClosePositionSaga( + { requestId: 'r2', userId: 'u', dbOrderId: 'o-1' }, + prisma, + producer, + redis, + ); + expect(closePositionMock).not.toHaveBeenCalled(); + expect(prisma.order.update).not.toHaveBeenCalled(); + }); + + it('중복 requestId 잠금이 잡히면 노옵', async () => { + redis.set.mockResolvedValue(null); + await executeClosePositionSaga( + { requestId: 'r3', userId: 'u', dbOrderId: 'o-1' }, + prisma, + producer, + redis, + ); + expect(prisma.order.findFirst).not.toHaveBeenCalled(); + expect(closePositionMock).not.toHaveBeenCalled(); + }); +}); diff --git a/apps/worker-service/src/orders/sagas/close-position-saga.ts b/apps/worker-service/src/orders/sagas/close-position-saga.ts new file mode 100644 index 0000000..f01eb30 --- /dev/null +++ b/apps/worker-service/src/orders/sagas/close-position-saga.ts @@ -0,0 +1,210 @@ +import { Logger } from '@nestjs/common'; +import { Producer } from 'kafkajs'; +import Redis from 'ioredis'; +import { KAFKA_TOPICS } from '@coin/kafka-contracts'; +import type { + OrderCloseRequestedEvent, + OrderResultEvent, + NotificationEvent, +} from '@coin/kafka-contracts'; +import type { ExchangeId, ExchangeCredentials, PositionSide } from '@coin/types'; +import { BinanceRest, IExchangeRest } from '@coin/exchange-adapters'; +import { decrypt } from '@coin/utils'; +import { PrismaService } from '../../prisma/prisma.service'; + +const REST_ADAPTERS: Record IExchangeRest> = { + binance: () => new BinanceRest(), +}; + +// Binance error codes that mean "position no longer exists" — TP/SL already fired, +// liquidation, or another reduceOnly already drained it. In all of these cases the +// physical position is gone and the right action is to mark our order closed. +const POSITION_GONE_CODES = ['-2022', '-4046', '-2023', '-4045']; + +function isPositionGoneError(err: unknown): boolean { + if (!(err instanceof Error)) return false; + return POSITION_GONE_CODES.some((c) => err.message.includes(c)); +} + +export async function executeClosePositionSaga( + event: OrderCloseRequestedEvent, + prisma: PrismaService, + producer: Producer, + redis: Redis, +): Promise { + const logger = new Logger('ClosePositionSaga'); + const lockKey = `saga:close-lock:${event.requestId}`; + const acquired = await redis.set(lockKey, '1', 'EX', 60, 'NX'); + if (!acquired) { + logger.log(`Duplicate close request: ${event.requestId}`); + return; + } + + const order = await prisma.order.findFirst({ + where: { id: event.dbOrderId, userId: event.userId }, + }); + if (!order) { + logger.warn(`Order not found for close: ${event.dbOrderId}`); + return; + } + if (order.closedAt) { + logger.warn(`Order already closed: ${event.dbOrderId}`); + return; + } + if (!order.exchangeKeyId) { + logger.error(`Order ${event.dbOrderId} has no exchangeKeyId; cannot close`); + return; + } + + const exchangeKey = await prisma.exchangeKey.findFirst({ + where: { id: order.exchangeKeyId, userId: event.userId }, + }); + if (!exchangeKey) { + logger.error(`Exchange key not found for close: ${order.exchangeKeyId}`); + return; + } + + const masterKey = process.env.ENCRYPTION_MASTER_KEY; + if (!masterKey) throw new Error('ENCRYPTION_MASTER_KEY not configured'); + + const credentials: ExchangeCredentials = { + apiKey: decrypt(exchangeKey.apiKey, masterKey), + secretKey: decrypt(exchangeKey.secretKey, masterKey), + network: (exchangeKey.network as 'mainnet' | 'testnet') ?? 'mainnet', + }; + + const adapter = REST_ADAPTERS[order.exchange as ExchangeId](); + const side = order.side as PositionSide; + const quantity = order.filledQuantity || order.quantity; + + // 1. If the position is already gone on Binance (TP/SL fired, liquidation, + // or a previous close request succeeded), just reconcile our DB and stop. + const livePosBefore = await adapter.getPosition(credentials, order.symbol).catch((e) => { + logger.warn(`getPosition pre-check failed: ${e}`); + return null; + }); + if (!livePosBefore) { + logger.warn(`Position already gone on exchange — reconciling order ${order.id}`); + await markOrderClosed(prisma, order.id, null, 'manual_on_exchange'); + await emitClosedEvents( + producer, + event, + order, + side, + quantity, + null, + '포지션 종료 (이미 닫혀있던 포지션 동기화)', + ); + return; + } + + let closeResult: Awaited> | null = null; + try { + closeResult = await adapter.closePosition(credentials, order.symbol, side, quantity); + logger.log( + `Close placed: ${order.id} → exchangeOrderId=${closeResult.orderId} status=${closeResult.status}`, + ); + } catch (err) { + if (isPositionGoneError(err)) { + logger.warn(`Close request rejected (position gone): ${err}. Reconciling.`); + await markOrderClosed(prisma, order.id, null, 'manual_on_exchange'); + await emitClosedEvents( + producer, + event, + order, + side, + quantity, + null, + '포지션 종료 (거래소에 포지션 없음)', + ); + return; + } + logger.error(`Close failed for ${order.id}: ${err}`); + await producer.send({ + topic: KAFKA_TOPICS.NOTIFICATION_SEND, + messages: [ + { + key: event.userId, + value: JSON.stringify({ + userId: event.userId, + type: 'order_failed', + title: `포지션 종료 실패 | ${order.exchange.toUpperCase()} ${order.symbol}`, + message: String(err instanceof Error ? err.message : err), + } satisfies NotificationEvent), + }, + ], + }); + throw err; + } + + // 2. Estimate realizedPnl from entry vs close fill (Binance's MARKET reduceOnly + // fills near-instantly, but avgPrice may be 0 in the immediate response). + let realizedPnl: string | null = null; + const entry = Number(order.entryPrice ?? 0); + const fill = Number(closeResult.filledPrice ?? 0); + const qty = Number(quantity); + if (entry && fill && qty) { + const direction = side === 'long' ? 1 : -1; + realizedPnl = String(((fill - entry) * qty * direction).toFixed(8)); + } + + await markOrderClosed(prisma, order.id, realizedPnl, 'manual'); + await emitClosedEvents( + producer, + event, + order, + side, + quantity, + closeResult, + `${side.toUpperCase()} ${quantity} 수동 종료 완료`, + ); +} + +async function markOrderClosed( + prisma: PrismaService, + orderId: string, + realizedPnl: string | null, + closeReason: 'manual' | 'manual_on_exchange' = 'manual', +) { + await prisma.order.update({ + where: { id: orderId }, + data: { status: 'closed', closedAt: new Date(), realizedPnl, closeReason }, + }); +} + +async function emitClosedEvents( + producer: Producer, + event: OrderCloseRequestedEvent, + order: { exchange: string; symbol: string }, + side: PositionSide, + quantity: string, + closeResult: Awaited> | null, + message: string, +) { + if (closeResult) { + const resultEvent: OrderResultEvent = { + requestId: event.requestId, + userId: event.userId, + dbOrderId: event.dbOrderId, + result: { ...closeResult }, + mode: 'real', + }; + await producer.send({ + topic: KAFKA_TOPICS.TRADING_ORDER_RESULT, + messages: [{ key: event.userId, value: JSON.stringify(resultEvent) }], + }); + } + + const notif: NotificationEvent = { + userId: event.userId, + type: 'order_filled', + title: `포지션 종료 | ${order.exchange.toUpperCase()} ${order.symbol}`, + message, + }; + await producer.send({ + topic: KAFKA_TOPICS.NOTIFICATION_SEND, + messages: [{ key: event.userId, value: JSON.stringify(notif) }], + }); + void side; // referenced for type discipline; message already encodes it + void quantity; +} 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 ff476b5..2cdfa95 100644 --- a/apps/worker-service/src/orders/sagas/real-execution-steps.ts +++ b/apps/worker-service/src/orders/sagas/real-execution-steps.ts @@ -3,7 +3,7 @@ import { Producer } from 'kafkajs'; import Redis from 'ioredis'; import { KAFKA_TOPICS } from '@coin/kafka-contracts'; import type { OrderResultEvent, OrderRequestedEvent } from '@coin/kafka-contracts'; -import type { ExchangeId, ExchangeCredentials, OrderResult } from '@coin/types'; +import type { ExchangeId, ExchangeCredentials, OrderResult, MarginType } from '@coin/types'; import { BinanceRest, IExchangeRest } from '@coin/exchange-adapters'; import { decrypt } from '@coin/utils'; import { PrismaService } from '../../prisma/prisma.service'; @@ -16,6 +16,8 @@ export interface RealExecutionContext { event: OrderRequestedEvent; credentials?: ExchangeCredentials; result?: OrderResult; + tpOrderId?: string; + slOrderId?: string; } interface SagaStep { @@ -43,14 +45,49 @@ export class DecryptKeysStep implements SagaStep { const credentials: ExchangeCredentials = { apiKey: decrypt(exchangeKey.apiKey, masterKey), secretKey: decrypt(exchangeKey.secretKey, masterKey), + network: (exchangeKey.network as 'mainnet' | 'testnet') ?? 'mainnet', }; - this.logger.log(`Keys decrypted for ${event.order.exchange}`); + this.logger.log(`Keys decrypted for ${event.order.exchange} (${credentials.network})`); return { ...context, credentials }; } async compensate(_context: RealExecutionContext): Promise { - // noop — nothing to undo for decryption + // noop + } +} + +/** + * Configures Binance Futures account state idempotently: + * - one-way position mode (dualSidePosition=false) + * - margin type per order (default ISOLATED) + * - leverage per order + * + * All three calls swallow Binance's "no need to change" errors so a re-run + * with identical settings is a no-op. + */ +export class ConfigureFuturesAccountStep implements SagaStep { + readonly name = 'ConfigureFuturesAccount'; + private readonly logger = new Logger(ConfigureFuturesAccountStep.name); + + async execute(context: RealExecutionContext): Promise { + const { event, credentials } = context; + if (!credentials) throw new Error('No credentials available'); + + const adapter = REST_ADAPTERS[event.order.exchange](); + const symbol = event.order.symbol; + const marginType: MarginType = event.order.marginType ?? 'ISOLATED'; + const leverage = event.order.leverage ?? 1; + + await adapter.setPositionMode(credentials, false); + await adapter.setMarginType(credentials, symbol, marginType); + await adapter.setLeverage(credentials, symbol, leverage); + this.logger.log(`Configured ${symbol}: marginType=${marginType} leverage=${leverage}x`); + return context; + } + + async compensate(_context: RealExecutionContext): Promise { + // noop — settings are stateful but harmless to leave } } @@ -58,17 +95,12 @@ export class PlaceOrderStep implements SagaStep { readonly name = 'PlaceOrder'; private readonly logger = new Logger(PlaceOrderStep.name); private readonly maxRetries = 2; - private redis: Redis; - - constructor(redis: Redis) { - this.redis = redis; - } async execute(context: RealExecutionContext): Promise { const { event, credentials } = context; if (!credentials) throw new Error('No credentials available'); - const order = { ...event.order }; + const order = event.order; const adapter = REST_ADAPTERS[event.order.exchange](); let lastError: Error | undefined; @@ -79,7 +111,6 @@ export class PlaceOrderStep implements SagaStep { `Order placed on ${event.order.exchange}: ${result.orderId} (${result.status})`, ); - // For market orders, poll until filled (exchanges process async) if (order.type === 'market' && result.status === 'placed' && result.orderId) { for (let poll = 0; poll < 5; poll++) { await new Promise((r) => setTimeout(r, 1000)); @@ -93,8 +124,6 @@ export class PlaceOrderStep implements SagaStep { updated.status === 'partial' || updated.status === 'cancelled' ) { - // Upbit market buy returns 'cancelled' when done (remaining KRW refunded) - // but the adapter maps cancel+executed_volume>0 to 'filled' result = updated; break; } @@ -118,7 +147,74 @@ export class PlaceOrderStep implements SagaStep { } async compensate(_context: RealExecutionContext): Promise { - // noop — exchange order cannot be easily cancelled at this stage + // noop + } +} + +/** + * 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'; + private readonly logger = new Logger(AttachTpSlStep.name); + + async execute(context: RealExecutionContext): Promise { + const { event, credentials, result } = context; + if (!credentials) throw new Error('No credentials available'); + if (!result) throw new Error('No entry order result available'); + + const order = event.order; + if (!order.takeProfitPrice && !order.stopLossPrice) { + this.logger.log('No TP/SL specified, skipping'); + return context; + } + + const adapter = REST_ADAPTERS[order.exchange](); + const filledQty = result.filledQuantity || order.quantity; + + let tpOrderId: string | undefined; + let slOrderId: string | undefined; + try { + if (order.takeProfitPrice) { + const tp = await adapter.placeTakeProfit( + credentials, + order.symbol, + order.side, + order.takeProfitPrice, + filledQty, + ); + tpOrderId = tp.orderId; + this.logger.log(`TP attached: algoId=${tp.orderId} @ ${order.takeProfitPrice}`); + } + if (order.stopLossPrice) { + const sl = await adapter.placeStopLoss( + credentials, + order.symbol, + order.side, + order.stopLossPrice, + filledQty, + ); + slOrderId = sl.orderId; + 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, filledQty); + this.logger.warn('Position force-closed after TP/SL failure'); + } catch (closeErr) { + this.logger.error(`Force-close also failed: ${closeErr}`); + } + throw err; + } + } + + async compensate(_context: RealExecutionContext): Promise { + // already handled inline } } @@ -129,7 +225,7 @@ export class UpdateDbStep implements SagaStep { constructor(private readonly prisma: PrismaService) {} async execute(context: RealExecutionContext): Promise { - const { event, result } = context; + const { event, result, tpOrderId, slOrderId } = context; if (!result) throw new Error('No order result available'); await this.prisma.order.update({ @@ -141,6 +237,14 @@ export class UpdateDbStep implements SagaStep { filledPrice: result.filledPrice, fee: result.fee, feeCurrency: result.feeCurrency, + leverage: event.order.leverage, + marginType: event.order.marginType ?? 'ISOLATED', + positionSide: event.order.side, + entryPrice: result.filledPrice, + takeProfitPrice: event.order.takeProfitPrice, + stopLossPrice: event.order.stopLossPrice, + tpOrderId, + slOrderId, }, }); @@ -165,14 +269,14 @@ export class PublishResultStep implements SagaStep { constructor(private readonly producer: Producer) {} async execute(context: RealExecutionContext): Promise { - const { event, result } = context; + const { event, result, tpOrderId, slOrderId } = context; if (!result) throw new Error('No order result available'); const resultEvent: OrderResultEvent = { requestId: event.requestId, userId: event.userId, dbOrderId: event.dbOrderId, - result, + result: { ...result, tpOrderId, slOrderId }, mode: 'real', }; @@ -186,7 +290,7 @@ export class PublishResultStep implements SagaStep { } async compensate(_context: RealExecutionContext): Promise { - // noop — result message already sent + // noop } } @@ -194,18 +298,14 @@ export async function executeRealOrderSaga( event: OrderRequestedEvent, prisma: PrismaService, producer: Producer, - redis?: Redis, + _redis?: Redis, ): Promise { const logger = new Logger('RealExecutionSaga'); - const redisInstance = - redis || - new Redis({ - host: process.env.REDIS_HOST || 'localhost', - port: Number(process.env.REDIS_PORT || 6379), - }); const steps: SagaStep[] = [ new DecryptKeysStep(prisma), - new PlaceOrderStep(redisInstance), + new ConfigureFuturesAccountStep(), + new PlaceOrderStep(), + new AttachTpSlStep(), new UpdateDbStep(prisma), new PublishResultStep(producer), ]; @@ -219,8 +319,6 @@ export async function executeRealOrderSaga( completedSteps.push(step); } catch (err) { logger.error(`Step "${step.name}" failed: ${err}`); - - // Compensate in reverse order for (let i = completedSteps.length - 1; i >= 0; i--) { try { await completedSteps[i].compensate(context); @@ -228,7 +326,6 @@ export async function executeRealOrderSaga( logger.error(`Compensation "${completedSteps[i].name}" failed: ${compErr}`); } } - throw 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 {} diff --git a/packages/database/prisma/migrations/20260430165222_init/migration.sql b/packages/database/prisma/migrations/20260430170658_init/migration.sql similarity index 77% rename from packages/database/prisma/migrations/20260430165222_init/migration.sql rename to packages/database/prisma/migrations/20260430170658_init/migration.sql index 398430d..2ae7fe2 100644 --- a/packages/database/prisma/migrations/20260430165222_init/migration.sql +++ b/packages/database/prisma/migrations/20260430170658_init/migration.sql @@ -38,6 +38,7 @@ CREATE TABLE "ExchangeKey" ( "id" TEXT NOT NULL, "userId" TEXT NOT NULL, "exchange" TEXT NOT NULL, + "network" TEXT NOT NULL DEFAULT 'mainnet', "apiKey" TEXT NOT NULL, "secretKey" TEXT NOT NULL, "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, @@ -64,6 +65,17 @@ CREATE TABLE "Order" ( "filledPrice" TEXT NOT NULL DEFAULT '0', "fee" TEXT NOT NULL DEFAULT '0', "feeCurrency" TEXT NOT NULL DEFAULT '', + "leverage" INTEGER, + "marginType" TEXT, + "positionSide" TEXT, + "entryPrice" TEXT, + "liquidationPrice" TEXT, + "takeProfitPrice" TEXT, + "stopLossPrice" TEXT, + "tpOrderId" TEXT, + "slOrderId" TEXT, + "realizedPnl" TEXT, + "closedAt" TIMESTAMP(3), "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, "updatedAt" TIMESTAMP(3) NOT NULL, @@ -112,6 +124,32 @@ CREATE TABLE "LoginHistory" ( CONSTRAINT "LoginHistory_pkey" PRIMARY KEY ("id") ); +-- CreateTable +CREATE TABLE "ClaudeToken" ( + "id" TEXT NOT NULL, + "userId" TEXT NOT NULL, + "encryptedToken" TEXT NOT NULL, + "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "updatedAt" TIMESTAMP(3) NOT NULL, + + CONSTRAINT "ClaudeToken_pkey" PRIMARY KEY ("id") +); + +-- CreateTable +CREATE TABLE "LlmDecisionLog" ( + "id" TEXT NOT NULL, + "userId" TEXT NOT NULL, + "orderId" TEXT, + "prompt" TEXT NOT NULL, + "rawResponse" TEXT NOT NULL, + "parsedSignal" JSONB NOT NULL, + "model" TEXT NOT NULL, + "latencyMs" INTEGER NOT NULL, + "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + + CONSTRAINT "LlmDecisionLog_pkey" PRIMARY KEY ("id") +); + -- CreateTable CREATE TABLE "Candle" ( "id" TEXT NOT NULL, @@ -151,7 +189,7 @@ CREATE INDEX "RefreshToken_expiresAt_idx" ON "RefreshToken"("expiresAt"); CREATE INDEX "ExchangeKey_userId_idx" ON "ExchangeKey"("userId"); -- CreateIndex -CREATE UNIQUE INDEX "ExchangeKey_userId_exchange_key" ON "ExchangeKey"("userId", "exchange"); +CREATE UNIQUE INDEX "ExchangeKey_userId_exchange_network_key" ON "ExchangeKey"("userId", "exchange", "network"); -- CreateIndex CREATE INDEX "Order_userId_idx" ON "Order"("userId"); @@ -183,6 +221,15 @@ CREATE INDEX "SagaExecution_sagaType_status_idx" ON "SagaExecution"("sagaType", -- CreateIndex CREATE INDEX "LoginHistory_userId_createdAt_idx" ON "LoginHistory"("userId", "createdAt"); +-- CreateIndex +CREATE UNIQUE INDEX "ClaudeToken_userId_key" ON "ClaudeToken"("userId"); + +-- CreateIndex +CREATE UNIQUE INDEX "LlmDecisionLog_orderId_key" ON "LlmDecisionLog"("orderId"); + +-- CreateIndex +CREATE INDEX "LlmDecisionLog_userId_createdAt_idx" ON "LlmDecisionLog"("userId", "createdAt"); + -- CreateIndex CREATE INDEX "Candle_exchange_symbol_interval_idx" ON "Candle"("exchange", "symbol", "interval"); @@ -212,3 +259,12 @@ ALTER TABLE "NotificationSetting" ADD CONSTRAINT "NotificationSetting_userId_fke -- AddForeignKey ALTER TABLE "LoginHistory" ADD CONSTRAINT "LoginHistory_userId_fkey" FOREIGN KEY ("userId") REFERENCES "User"("id") ON DELETE CASCADE ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "ClaudeToken" ADD CONSTRAINT "ClaudeToken_userId_fkey" FOREIGN KEY ("userId") REFERENCES "User"("id") ON DELETE CASCADE ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "LlmDecisionLog" ADD CONSTRAINT "LlmDecisionLog_userId_fkey" FOREIGN KEY ("userId") REFERENCES "User"("id") ON DELETE CASCADE ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "LlmDecisionLog" ADD CONSTRAINT "LlmDecisionLog_orderId_fkey" FOREIGN KEY ("orderId") REFERENCES "Order"("id") ON DELETE SET NULL ON UPDATE CASCADE; diff --git a/packages/database/prisma/migrations/20260501230000_add_close_reason/migration.sql b/packages/database/prisma/migrations/20260501230000_add_close_reason/migration.sql new file mode 100644 index 0000000..c537a30 --- /dev/null +++ b/packages/database/prisma/migrations/20260501230000_add_close_reason/migration.sql @@ -0,0 +1,2 @@ +-- AlterTable +ALTER TABLE "Order" ADD COLUMN "closeReason" TEXT; diff --git a/packages/database/prisma/schema.prisma b/packages/database/prisma/schema.prisma index 887bba0..ff34689 100644 --- a/packages/database/prisma/schema.prisma +++ b/packages/database/prisma/schema.prisma @@ -22,6 +22,8 @@ model User { orders Order[] notificationSetting NotificationSetting? loginHistory LoginHistory[] + claudeToken ClaudeToken? + llmDecisions LlmDecisionLog[] } model Account { @@ -52,6 +54,7 @@ model ExchangeKey { id String @id @default(cuid()) userId String exchange String + network String @default("mainnet") apiKey String secretKey String createdAt DateTime @default(now()) @@ -59,31 +62,45 @@ model ExchangeKey { orders Order[] user User @relation(fields: [userId], references: [id], onDelete: Cascade) - @@unique([userId, exchange]) + @@unique([userId, exchange, network]) @@index([userId]) } model Order { - id String @id @default(cuid()) - userId String - exchangeKeyId String? - exchange String - symbol String - side String - type String - mode String - status String @default("pending") - quantity String - price String? - exchangeOrderId String? - filledQuantity String @default("0") - filledPrice String @default("0") - fee String @default("0") - feeCurrency String @default("") - createdAt DateTime @default(now()) - updatedAt DateTime @updatedAt - user User @relation(fields: [userId], references: [id], onDelete: Cascade) - exchangeKey ExchangeKey? @relation(fields: [exchangeKeyId], references: [id]) + id String @id @default(cuid()) + userId String + exchangeKeyId String? + exchange String + symbol String + side String // 'long' | 'short' + type String // 'market' | 'limit' + mode String // 'real' (paper retired in PR1; testnet network on ExchangeKey now) + status String @default("pending") + quantity String + price String? + exchangeOrderId String? + filledQuantity String @default("0") + filledPrice String @default("0") + fee String @default("0") + feeCurrency String @default("") + // Futures fields (PR2) + leverage Int? + marginType String? // 'ISOLATED' | 'CROSS' + positionSide String? // 'long' | 'short' (mirror of side, for hedge mode if added later) + entryPrice String? + liquidationPrice String? + takeProfitPrice String? + stopLossPrice String? + tpOrderId String? + slOrderId String? + realizedPnl String? + closedAt DateTime? + closeReason String? // 'take_profit' | 'stop_loss' | 'liquidation' | 'manual' | 'manual_on_exchange' | 'reconciled_unknown' + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt + user User @relation(fields: [userId], references: [id], onDelete: Cascade) + exchangeKey ExchangeKey? @relation(fields: [exchangeKeyId], references: [id]) + llmDecision LlmDecisionLog? @@index([userId]) @@index([userId, status]) @@ -132,6 +149,31 @@ model LoginHistory { @@index([userId, createdAt]) } +model ClaudeToken { + id String @id @default(cuid()) + userId String @unique + encryptedToken String // AES-256-GCM (reuse @coin/utils encryption helpers) + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt + user User @relation(fields: [userId], references: [id], onDelete: Cascade) +} + +model LlmDecisionLog { + id String @id @default(cuid()) + userId String + orderId String? @unique + prompt String @db.Text + rawResponse String @db.Text + parsedSignal Json + model String + latencyMs Int + createdAt DateTime @default(now()) + user User @relation(fields: [userId], references: [id], onDelete: Cascade) + order Order? @relation(fields: [orderId], references: [id], onDelete: SetNull) + + @@index([userId, createdAt]) +} + model Candle { id String @id @default(cuid()) exchange String diff --git a/packages/exchange-adapters/src/binance/binance.rest.ts b/packages/exchange-adapters/src/binance/binance.rest.ts index 9932458..831acaa 100644 --- a/packages/exchange-adapters/src/binance/binance.rest.ts +++ b/packages/exchange-adapters/src/binance/binance.rest.ts @@ -4,83 +4,158 @@ import { Balance, OrderRequest, OrderResult, + OrderStatus, Market, Candle, + Position, + PositionSide, + MarginType, + SymbolFilter, + IncomeRecord, } from '@coin/types'; import { IExchangeRest } from '../interfaces/exchange-rest'; -const BASE_URL = 'https://api.binance.com'; +const FAPI_MAINNET = 'https://fapi.binance.com'; +const FAPI_TESTNET = 'https://testnet.binancefuture.com'; + +function baseUrl(credentials: ExchangeCredentials): string { + return credentials.network === 'testnet' ? FAPI_TESTNET : FAPI_MAINNET; +} + +function publicBaseUrl(network?: 'mainnet' | 'testnet'): string { + return network === 'testnet' ? FAPI_TESTNET : FAPI_MAINNET; +} function parseIntervalMs(interval: string): number { const INTERVAL_MS: Record = { '1m': 60_000, + '3m': 180_000, '5m': 300_000, '15m': 900_000, + '30m': 1_800_000, '1h': 3_600_000, + '2h': 7_200_000, '4h': 14_400_000, + '6h': 21_600_000, + '8h': 28_800_000, + '12h': 43_200_000, '1d': 86_400_000, }; return INTERVAL_MS[interval] ?? 60_000; } -interface BinanceOrderResponse { +interface BinanceFuturesOrderResponse { orderId: number; symbol: string; - side: string; + side: 'BUY' | 'SELL'; + positionSide?: 'LONG' | 'SHORT' | 'BOTH'; type: string; status: string; origQty: string; executedQty: string; price: string; + avgPrice?: string; + stopPrice?: string; + reduceOnly?: boolean; + closePosition?: boolean; time?: number; - transactTime?: number; + 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; + entryPrice: string; + markPrice: string; + liquidationPrice: string; + leverage: string; + marginType: 'isolated' | 'cross'; + unRealizedProfit: string; + positionSide: 'LONG' | 'SHORT' | 'BOTH'; +} + +interface ExchangeInfoSymbol { + symbol: string; + status: string; + baseAsset: string; + quoteAsset: string; + pricePrecision: number; + quantityPrecision: number; + filters: Array<{ + filterType: string; + minPrice?: string; + maxPrice?: string; + tickSize?: string; + minQty?: string; + maxQty?: string; + stepSize?: string; + notional?: string; + }>; } export class BinanceRest implements IExchangeRest { readonly exchangeId = 'binance' as const; - async getBalances(credentials: ExchangeCredentials): Promise { - const res = await this.signedRequest(credentials, 'GET', '/api/v3/account'); - const data = (await res.json()) as { - balances: Array<{ asset: string; free: string; locked: string }>; - }; + private exchangeInfoCache: Map = new Map(); + private static readonly CACHE_TTL_MS = 60 * 60 * 1000; // 1h - return data.balances.map((b) => ({ + async getBalances(credentials: ExchangeCredentials): Promise { + const res = await this.signedRequest(credentials, 'GET', '/fapi/v2/balance'); + const data = (await res.json()) as Array<{ + asset: string; + balance: string; + availableBalance: string; + }>; + + return data.map((b) => ({ exchange: this.exchangeId, currency: b.asset, - free: b.free, - locked: b.locked, + free: b.availableBalance, + locked: String(Number(b.balance) - Number(b.availableBalance)), })); } async getOpenOrders(credentials: ExchangeCredentials, symbol?: string): Promise { const params: Record = {}; if (symbol) params.symbol = symbol; - - const res = await this.signedRequest(credentials, 'GET', '/api/v3/openOrders', params); - const data = (await res.json()) as BinanceOrderResponse[]; - + const res = await this.signedRequest(credentials, 'GET', '/fapi/v1/openOrders', params); + const data = (await res.json()) as BinanceFuturesOrderResponse[]; return data.map((o) => this.mapOrderResult(o)); } async placeOrder(credentials: ExchangeCredentials, order: OrderRequest): Promise { const params: Record = { symbol: order.symbol, - side: order.side.toUpperCase(), + side: order.side === 'long' ? 'BUY' : 'SELL', type: order.type.toUpperCase(), quantity: order.quantity, }; if (order.type === 'limit') { params.timeInForce = 'GTC'; - if (order.price) { - params.price = order.price; - } + if (order.price) params.price = order.price; } - const res = await this.signedRequest(credentials, 'POST', '/api/v3/order', params); - const data = (await res.json()) as BinanceOrderResponse; - + const res = await this.signedRequest(credentials, 'POST', '/fapi/v1/order', params); + const data = (await res.json()) as BinanceFuturesOrderResponse; return this.mapOrderResult(data); } @@ -89,14 +164,10 @@ export class BinanceRest implements IExchangeRest { orderId: string, symbol?: string, ): Promise { - const params: Record = { - orderId, - }; - if (symbol) params.symbol = symbol; - - const res = await this.signedRequest(credentials, 'DELETE', '/api/v3/order', params); - const data = (await res.json()) as BinanceOrderResponse; - + if (!symbol) throw new Error('symbol required for futures cancelOrder'); + const params: Record = { orderId, symbol }; + const res = await this.signedRequest(credentials, 'DELETE', '/fapi/v1/order', params); + const data = (await res.json()) as BinanceFuturesOrderResponse; return this.mapOrderResult(data); } @@ -105,35 +176,16 @@ export class BinanceRest implements IExchangeRest { orderId: string, symbol?: string, ): Promise { - const params: Record = { - orderId, - }; - if (symbol) params.symbol = symbol; - - const res = await this.signedRequest(credentials, 'GET', '/api/v3/order', params); - const data = (await res.json()) as BinanceOrderResponse; - + if (!symbol) throw new Error('symbol required for futures getOrder'); + const params: Record = { orderId, symbol }; + const res = await this.signedRequest(credentials, 'GET', '/fapi/v1/order', params); + const data = (await res.json()) as BinanceFuturesOrderResponse; return this.mapOrderResult(data); } async getMarkets(): Promise { - const res = await fetch(`${BASE_URL}/api/v3/exchangeInfo`); - - if (!res.ok) { - const body = await res.text(); - throw new Error(`Binance API error ${res.status}: ${body}`); - } - - const data = (await res.json()) as { - symbols: Array<{ - symbol: string; - baseAsset: string; - quoteAsset: string; - status: string; - }>; - }; - - return data.symbols + const info = await this.fetchExchangeInfo(); + return info.symbols .filter((s) => s.status === 'TRADING') .map((s) => ({ exchange: this.exchangeId, @@ -144,39 +196,20 @@ export class BinanceRest implements IExchangeRest { } async getCandles(symbol: string, interval: string, limit = 200): Promise { - const res = await fetch( - `${BASE_URL}/api/v3/klines?symbol=${symbol}&interval=${interval}&limit=${limit}`, - ); - if (!res.ok) { - const body = await res.text(); - throw new Error(`Binance API error ${res.status}: ${body}`); - } - const data = (await res.json()) as Array< - [ - number, - string, - string, - string, - string, - string, - number, - string, - number, - string, - string, - string, - ] - >; + const url = `${publicBaseUrl()}/fapi/v1/klines?symbol=${symbol}&interval=${interval}&limit=${limit}`; + const res = await fetch(url); + if (!res.ok) throw new Error(`Binance fapi error ${res.status}: ${await res.text()}`); + const data = (await res.json()) as Array; return data.map((k) => ({ exchange: this.exchangeId, symbol, interval, - open: k[1], - high: k[2], - low: k[3], - close: k[4], - volume: k[5], - timestamp: k[0], + open: String(k[1]), + high: String(k[2]), + low: String(k[3]), + close: String(k[4]), + volume: String(k[5]), + timestamp: Number(k[0]), })); } @@ -186,36 +219,17 @@ export class BinanceRest implements IExchangeRest { startTime: number, endTime: number, ): Promise { - const PAGE_LIMIT = 1000; + const PAGE_LIMIT = 1500; const intervalMs = parseIntervalMs(interval); const allCandles: Candle[] = []; let pageStart = startTime; const MAX_PAGES = 100; for (let page = 0; page < MAX_PAGES; page++) { - const res = await fetch( - `${BASE_URL}/api/v3/klines?symbol=${symbol}&interval=${interval}&limit=${PAGE_LIMIT}&startTime=${pageStart}&endTime=${endTime}`, - ); - if (!res.ok) { - const body = await res.text(); - throw new Error(`Binance API error ${res.status}: ${body}`); - } - const data = (await res.json()) as Array< - [ - number, - string, - string, - string, - string, - string, - number, - string, - number, - string, - string, - string, - ] - >; + const url = `${publicBaseUrl()}/fapi/v1/klines?symbol=${symbol}&interval=${interval}&limit=${PAGE_LIMIT}&startTime=${pageStart}&endTime=${endTime}`; + const res = await fetch(url); + if (!res.ok) throw new Error(`Binance fapi error ${res.status}: ${await res.text()}`); + const data = (await res.json()) as Array; if (data.length === 0) break; for (const k of data) { @@ -223,44 +237,286 @@ export class BinanceRest implements IExchangeRest { exchange: this.exchangeId, symbol, interval, - open: k[1], - high: k[2], - low: k[3], - close: k[4], - volume: k[5], - timestamp: k[0], + open: String(k[1]), + high: String(k[2]), + low: String(k[3]), + close: String(k[4]), + volume: String(k[5]), + timestamp: Number(k[0]), }); } if (data.length < PAGE_LIMIT) break; - pageStart = data[data.length - 1][0] + intervalMs; + pageStart = Number(data[data.length - 1][0]) + intervalMs; if (pageStart > endTime) break; } return allCandles; } - private mapOrderResult(o: BinanceOrderResponse): OrderResult { + // ── Futures-specific ───────────────────────────────────────────── + + async setLeverage( + credentials: ExchangeCredentials, + symbol: string, + leverage: number, + ): Promise { + await this.signedRequest(credentials, 'POST', '/fapi/v1/leverage', { + symbol, + leverage: String(leverage), + }); + } + + async setMarginType( + credentials: ExchangeCredentials, + symbol: string, + marginType: MarginType, + ): Promise { + try { + await this.signedRequest(credentials, 'POST', '/fapi/v1/marginType', { + symbol, + marginType, + }); + } catch (err) { + // -4046 "No need to change margin type" — idempotent success + if (err instanceof Error && err.message.includes('-4046')) return; + throw err; + } + } + + async setPositionMode(credentials: ExchangeCredentials, dualSide: boolean): Promise { + try { + await this.signedRequest(credentials, 'POST', '/fapi/v1/positionSide/dual', { + dualSidePosition: String(dualSide), + }); + } catch (err) { + // -4059 "No need to change position side" — idempotent success + if (err instanceof Error && err.message.includes('-4059')) return; + throw err; + } + } + + async getPosition(credentials: ExchangeCredentials, symbol: string): Promise { + const res = await this.signedRequest(credentials, 'GET', '/fapi/v2/positionRisk', { + symbol, + }); + const data = (await res.json()) as BinanceFuturesPositionResponse[]; + const pos = data.find((p) => p.symbol === symbol && Number(p.positionAmt) !== 0); + if (!pos) return null; + const qty = Number(pos.positionAmt); + return { + exchange: this.exchangeId, + symbol, + side: qty > 0 ? 'long' : 'short', + quantity: String(Math.abs(qty)), + entryPrice: pos.entryPrice, + markPrice: pos.markPrice, + liquidationPrice: pos.liquidationPrice, + leverage: Number(pos.leverage), + marginType: pos.marginType === 'cross' ? 'CROSS' : 'ISOLATED', + unrealizedPnl: pos.unRealizedProfit, + }; + } + + async closePosition( + credentials: ExchangeCredentials, + symbol: string, + side: PositionSide, + quantity: string, + ): Promise { + const params: Record = { + symbol, + side: side === 'long' ? 'SELL' : 'BUY', + type: 'MARKET', + quantity, + reduceOnly: 'true', + }; + const res = await this.signedRequest(credentials, 'POST', '/fapi/v1/order', params); + const data = (await res.json()) as BinanceFuturesOrderResponse; + 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 { + return this.placeAlgoConditional(credentials, { + symbol, + side: side === 'long' ? 'SELL' : 'BUY', + type: 'STOP_MARKET', + triggerPrice: stopPrice, + quantity, + }); + } + + async placeTakeProfit( + credentials: ExchangeCredentials, + symbol: string, + side: PositionSide, + stopPrice: string, + quantity: string, + ): Promise { + return this.placeAlgoConditional(credentials, { + symbol, + side: side === 'long' ? 'SELL' : 'BUY', + type: 'TAKE_PROFIT_MARKET', + 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/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(), + }; + } + + /** + * Binance Futures income endpoint. Used by the position reconciler to + * discover authoritative realized PnL after a TP/SL/liquidation fires. + * Each entry's `tradeId` ties back to the closing trade so callers can + * correlate against tpOrderId / slOrderId stored at entry time. + */ + async getIncome( + credentials: ExchangeCredentials, + opts: { + symbol?: string; + incomeType?: string; + startTime?: number; + endTime?: number; + limit?: number; + }, + ): Promise { + const params: Record = {}; + if (opts.symbol) params.symbol = opts.symbol; + if (opts.incomeType) params.incomeType = opts.incomeType; + if (opts.startTime != null) params.startTime = String(opts.startTime); + if (opts.endTime != null) params.endTime = String(opts.endTime); + if (opts.limit != null) params.limit = String(opts.limit); + + const res = await this.signedRequest(credentials, 'GET', '/fapi/v1/income', params); + const data = (await res.json()) as Array<{ + symbol?: string; + incomeType: string; + income: string; + asset: string; + time: number; + tradeId?: string; + tranId?: string; + info?: string; + }>; + return data.map((r) => ({ + symbol: r.symbol, + incomeType: r.incomeType, + income: r.income, + asset: r.asset, + time: r.time, + tradeId: r.tradeId, + tranId: r.tranId, + info: r.info, + })); + } + + async getSymbolFilter(symbol: string): Promise { + const info = await this.fetchExchangeInfo(); + const sym = info.symbols.find((s) => s.symbol === symbol); + if (!sym) throw new Error(`Symbol not found in exchangeInfo: ${symbol}`); + const lotSize = sym.filters.find((f) => f.filterType === 'LOT_SIZE'); + const priceFilter = sym.filters.find((f) => f.filterType === 'PRICE_FILTER'); + const minNotional = sym.filters.find((f) => f.filterType === 'MIN_NOTIONAL'); + return { + symbol, + pricePrecision: sym.pricePrecision, + quantityPrecision: sym.quantityPrecision, + minQty: lotSize?.minQty ?? '0', + stepSize: lotSize?.stepSize ?? '0', + minNotional: minNotional?.notional ?? '0', + tickSize: priceFilter?.tickSize ?? '0', + }; + } + + // ── Internal ───────────────────────────────────────────────────── + + private async fetchExchangeInfo(network: 'mainnet' | 'testnet' = 'mainnet') { + const cacheKey = network; + const cached = this.exchangeInfoCache.get(cacheKey); + const now = Date.now(); + if (cached && now - cached.ts < BinanceRest.CACHE_TTL_MS) { + return { symbols: cached.symbols }; + } + const url = `${publicBaseUrl(network)}/fapi/v1/exchangeInfo`; + const res = await fetch(url); + if (!res.ok) throw new Error(`Binance fapi exchangeInfo error ${res.status}`); + const data = (await res.json()) as { symbols: ExchangeInfoSymbol[] }; + this.exchangeInfoCache.set(cacheKey, { symbols: data.symbols, ts: now }); + return data; + } + + private mapOrderResult(o: BinanceFuturesOrderResponse): OrderResult { + const side: PositionSide = o.positionSide === 'SHORT' || o.side === 'SELL' ? 'short' : 'long'; return { exchange: this.exchangeId, orderId: String(o.orderId), symbol: o.symbol, - side: o.side.toLowerCase() as 'buy' | 'sell', - type: o.type === 'LIMIT' ? ('limit' as const) : ('market' as const), + side, + type: o.type === 'LIMIT' ? 'limit' : 'market', status: this.mapOrderStatus(o.status), quantity: o.origQty, filledQuantity: o.executedQty, price: o.price, - filledPrice: '0', + filledPrice: o.avgPrice ?? '0', fee: '0', feeCurrency: '', - timestamp: o.transactTime ?? o.time ?? Date.now(), + timestamp: o.updateTime ?? o.time ?? Date.now(), }; } - private mapOrderStatus( - status: string, - ): 'pending' | 'placed' | 'filled' | 'partial' | 'cancelled' | 'failed' { + private mapOrderStatus(status: string): OrderStatus { switch (status) { case 'NEW': return 'placed'; @@ -293,31 +549,27 @@ export class BinanceRest implements IExchangeRest { const signature = createHmac('sha256', credentials.secretKey) .update(params.toString()) .digest('hex'); - params.append('signature', signature); - let url: string; const headers: Record = { 'X-MBX-APIKEY': credentials.apiKey, }; const init: RequestInit = { method, headers }; + let url: string; if (method === 'POST') { - url = `${BASE_URL}${path}`; + url = `${baseUrl(credentials)}${path}`; headers['Content-Type'] = 'application/x-www-form-urlencoded'; init.body = params.toString(); } else { - // GET and DELETE use query params - url = `${BASE_URL}${path}?${params.toString()}`; + url = `${baseUrl(credentials)}${path}?${params.toString()}`; } const res = await fetch(url, init); - if (!res.ok) { const body = await res.text(); - throw new Error(`Binance API error ${res.status}: ${body}`); + throw new Error(`Binance fapi error ${res.status}: ${body}`); } - return res; } } diff --git a/packages/exchange-adapters/src/interfaces/exchange-rest.ts b/packages/exchange-adapters/src/interfaces/exchange-rest.ts index df0462a..4bc2c39 100644 --- a/packages/exchange-adapters/src/interfaces/exchange-rest.ts +++ b/packages/exchange-adapters/src/interfaces/exchange-rest.ts @@ -6,6 +6,11 @@ import { OrderResult, Market, Candle, + Position, + PositionSide, + MarginType, + SymbolFilter, + IncomeRecord, } from '@coin/types'; export interface IExchangeRest { @@ -31,4 +36,45 @@ export interface IExchangeRest { startTime: number, endTime: number, ): Promise; + + // Futures-specific + setLeverage(credentials: ExchangeCredentials, symbol: string, leverage: number): Promise; + setMarginType( + credentials: ExchangeCredentials, + symbol: string, + marginType: MarginType, + ): Promise; + setPositionMode(credentials: ExchangeCredentials, dualSide: boolean): Promise; + getPosition(credentials: ExchangeCredentials, symbol: string): Promise; + closePosition( + credentials: ExchangeCredentials, + symbol: string, + side: PositionSide, + quantity: string, + ): Promise; + placeStopLoss( + credentials: ExchangeCredentials, + 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; + getIncome( + credentials: ExchangeCredentials, + opts: { + symbol?: string; + incomeType?: string; + startTime?: number; + endTime?: number; + limit?: number; + }, + ): Promise; } diff --git a/packages/kafka-contracts/src/events.ts b/packages/kafka-contracts/src/events.ts index 558d793..3f87416 100644 --- a/packages/kafka-contracts/src/events.ts +++ b/packages/kafka-contracts/src/events.ts @@ -17,6 +17,12 @@ export interface OrderResultEvent { mode: 'paper' | 'real'; } +export interface OrderCloseRequestedEvent { + requestId: string; + userId: string; + dbOrderId: string; +} + export interface NotificationEvent { userId: string; type: 'order_filled' | 'order_failed'; diff --git a/packages/kafka-contracts/src/topics.ts b/packages/kafka-contracts/src/topics.ts index 7b44bd9..a849332 100644 --- a/packages/kafka-contracts/src/topics.ts +++ b/packages/kafka-contracts/src/topics.ts @@ -1,6 +1,7 @@ export const KAFKA_TOPICS = { MARKET_TICKER_UPDATED: 'market.ticker.updated', TRADING_ORDER_REQUESTED: 'trading.order.requested', + TRADING_ORDER_CLOSE_REQUESTED: 'trading.order.close-requested', TRADING_ORDER_RESULT: 'trading.order.result', TRADING_POSITION_UPDATED: 'trading.position.updated', NOTIFICATION_SEND: 'notification.send', diff --git a/packages/types/src/exchange.ts b/packages/types/src/exchange.ts index 40ef38a..04e1812 100644 --- a/packages/types/src/exchange.ts +++ b/packages/types/src/exchange.ts @@ -1,8 +1,11 @@ export type ExchangeId = 'binance'; +export type Network = 'mainnet' | 'testnet'; + export interface ExchangeCredentials { apiKey: string; secretKey: string; + network?: Network; } export interface Ticker { @@ -49,22 +52,38 @@ export interface Balance { locked: string; } +export type PositionSide = 'long' | 'short'; +export type MarginType = 'ISOLATED' | 'CROSS'; + +/** + * Futures order request. `side` is the user-facing position direction + * (long/short). The adapter translates to Binance's internal BUY/SELL. + * + * For closing an existing position use closePosition() on the adapter, + * not placeOrder(). + */ export interface OrderRequest { exchange: ExchangeId; symbol: string; - side: 'buy' | 'sell'; - type: 'limit' | 'market'; + side: PositionSide; + type: 'market' | 'limit'; quantity: string; price?: string; + leverage: number; + marginType?: MarginType; + takeProfitPrice?: string; + stopLossPrice?: string; } +export type OrderStatus = 'pending' | 'placed' | 'filled' | 'partial' | 'cancelled' | 'failed'; + export interface OrderResult { exchange: ExchangeId; orderId: string; symbol: string; - side: 'buy' | 'sell'; - type: 'limit' | 'market'; - status: 'pending' | 'placed' | 'filled' | 'partial' | 'cancelled' | 'failed'; + side: PositionSide; + type: 'market' | 'limit'; + status: OrderStatus; quantity: string; filledQuantity: string; price: string; @@ -72,6 +91,24 @@ export interface OrderResult { fee: string; feeCurrency: string; timestamp: number; + entryPrice?: string; + liquidationPrice?: string; + leverage?: number; + tpOrderId?: string; + slOrderId?: string; +} + +export interface Position { + exchange: ExchangeId; + symbol: string; + side: PositionSide; + quantity: string; + entryPrice: string; + markPrice: string; + liquidationPrice: string; + leverage: number; + marginType: MarginType; + unrealizedPnl: string; } export interface Market { @@ -80,3 +117,45 @@ export interface Market { baseAsset: string; quoteAsset: string; } + +export interface SymbolFilter { + symbol: string; + pricePrecision: number; + quantityPrecision: number; + minQty: string; + stepSize: string; + minNotional: string; + tickSize: string; +} + +export type IncomeType = + | 'REALIZED_PNL' + | 'COMMISSION' + | 'FUNDING_FEE' + | 'INSURANCE_CLEAR' + | 'TRANSFER' + | 'WELCOME_BONUS' + | 'REFERRAL_KICKBACK' + | 'COMMISSION_REBATE' + | 'API_REBATE' + | 'CONTEST_REWARD' + | 'CROSS_COLLATERAL_TRANSFER' + | 'OPTIONS_PREMIUM_FEE' + | 'OPTIONS_SETTLE_PROFIT' + | 'INTERNAL_TRANSFER' + | 'AUTO_EXCHANGE' + | 'DELIVERED_SETTELMENT' + | 'COIN_SWAP_DEPOSIT' + | 'COIN_SWAP_WITHDRAW' + | 'POSITION_LIMIT_INCREASE_FEE'; + +export interface IncomeRecord { + symbol?: string; + incomeType: IncomeType | string; + income: string; + asset: string; + time: number; + tradeId?: string; + tranId?: string; + info?: string; +}