From d24a4cf5da8330f8ec7fa20f327c117bcf820727 Mon Sep 17 00:00:00 2001 From: fray-cloud Date: Fri, 1 May 2026 02:12:18 +0900 Subject: [PATCH 1/7] feat: Binance Futures (USDT-M perpetual) adapter, futures order types, testnet/mainnet network MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit PR2 of 3 in the LLM-driven Binance Futures pivot. Lays the futures execution foundation that PR3 will drive from a Claude-generated trade signal. What's reshaped: - OrderRequest/OrderResult/Order: side is now 'long'|'short' (the user-facing position direction; adapter translates internally to BUY/SELL). Add leverage, marginType, takeProfitPrice, stopLossPrice on request, plus entryPrice, liquidationPrice, tpOrderId, slOrderId on result. New OrderStatus, PositionSide, MarginType, Position, SymbolFilter exports. - ExchangeCredentials gains optional `network: 'mainnet'|'testnet'` so the adapter can pick fapi.binance.com vs testnet.binancefuture.com per call. - ExchangeKey schema: `network` column (default 'mainnet') and unique constraint widened to (userId, exchange, network) so a user can hold one key per network. - Order schema: futures fields (leverage, marginType, positionSide, entryPrice, liquidationPrice, takeProfitPrice, stopLossPrice, tpOrderId, slOrderId, realizedPnl, closedAt). Init migration regenerated. What's new: - BinanceRest rewrite against fapi.binance.com (and testnet.binancefuture.com). Reuses the existing HMAC signing path. Implements setLeverage, setMarginType (-4046 idempotent), setPositionMode (-4059 idempotent), getPosition, closePosition (reduceOnly market), placeStopLoss/placeTakeProfit (STOP_MARKET / TAKE_PROFIT_MARKET with closePosition=true and workingType=MARK_PRICE), getSymbolFilter (LOT_SIZE / PRICE_FILTER / MIN_NOTIONAL via cached exchangeInfo). - Worker saga: ConfigureFuturesAccountStep (one-way mode + margin type + leverage, all idempotent) runs before PlaceOrderStep; AttachTpSlStep runs after fill and inline-compensates by force-closing the position if either TP or SL placement fails — a naked position is the worst-case outcome. UpdateDbStep persists futures fields back to the Order row. - Api-server CreateOrderDto picks up leverage (1-20 clamped), marginType, takeProfitPrice, stopLossPrice. mode is locked to 'real' (paper retired in PR1 — testnet replaces it via ExchangeKey.network). - ClaudeToken and LlmDecisionLog Prisma models added now (unused until PR3) so the next migration is purely additive — keeps PR3 small. - Dev-only POST /debug/futures-test endpoint: takes an exchangeKeyId + symbol/side/quantity/leverage/tp/sl, calls the adapter directly (skips Kafka and the full saga), returns entry result + tp/sl orderIds + the resulting Position. Gated by NODE_ENV !== 'production'. Verified: - pnpm build green across all 9 workspace packages - Prisma migrate dev applied cleanly against fresh volume - docker compose dev: postgres/redis/kafka healthy, api-server /api/health 200, worker-service running, web /markets 200 Plan: PR3 will install Claude Code CLI in the worker image, add ClaudeToken storage + /settings/claude UI, build the LLM trade form, and wire the Claude-driven signal flow on top of this saga. Co-Authored-By: Claude Opus 4.7 (1M context) --- apps/api-server/src/app.module.ts | 2 + apps/api-server/src/debug/debug.controller.ts | 114 +++++ apps/api-server/src/debug/debug.module.ts | 9 + .../commands/create-exchange-key.handler.ts | 5 +- .../dto/create-exchange-key.dto.ts | 13 +- .../src/orders/dto/create-order.dto.ts | 59 ++- .../src/orders/sagas/order-lifecycle-steps.ts | 21 +- .../sagas/order-lifecycle.orchestrator.ts | 4 + .../src/orders/sagas/real-execution-steps.ts | 153 +++++-- .../migration.sql | 58 ++- packages/database/prisma/schema.prisma | 83 +++- .../src/binance/binance.rest.ts | 409 ++++++++++++------ .../src/interfaces/exchange-rest.ts | 33 ++ packages/types/src/exchange.ts | 57 ++- 14 files changed, 806 insertions(+), 214 deletions(-) create mode 100644 apps/api-server/src/debug/debug.controller.ts create mode 100644 apps/api-server/src/debug/debug.module.ts rename packages/database/prisma/migrations/{20260430165222_init => 20260430170658_init}/migration.sql (77%) diff --git a/apps/api-server/src/app.module.ts b/apps/api-server/src/app.module.ts index b324344..1b75501 100644 --- a/apps/api-server/src/app.module.ts +++ b/apps/api-server/src/app.module.ts @@ -13,6 +13,7 @@ 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 { DebugModule } from './debug/debug.module'; import { JwtAuthGuard } from './auth/guards/jwt-auth.guard'; @Module({ @@ -48,6 +49,7 @@ import { JwtAuthGuard } from './auth/guards/jwt-auth.guard'; NotificationsModule, PortfolioModule, ActivityModule, + DebugModule, ], controllers: [AppController], providers: [ 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..6b31f52 --- /dev/null +++ b/apps/api-server/src/debug/debug.controller.ts @@ -0,0 +1,114 @@ +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); + } + if (dto.stopLossPrice) { + sl = await adapter.placeStopLoss(credentials, dto.symbol, dto.side, dto.stopLossPrice); + } + + 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 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/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/worker-service/src/orders/sagas/real-execution-steps.ts b/apps/worker-service/src/orders/sagas/real-execution-steps.ts index ff476b5..68a81f0 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 STOP_MARKET (SL) and TAKE_PROFIT_MARKET (TP) close-position orders + * after the entry has filled. If either fails, compensate force-closes the + * underlying position so it never sits naked. + */ +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; + } + if (result.status !== 'filled' && result.status !== 'partial') { + this.logger.warn(`Entry not filled (status=${result.status}), skipping TP/SL attachment`); + return context; + } + + const adapter = REST_ADAPTERS[order.exchange](); + + let tpOrderId: string | undefined; + let slOrderId: string | undefined; + try { + if (order.takeProfitPrice) { + const tp = await adapter.placeTakeProfit( + credentials, + order.symbol, + order.side, + order.takeProfitPrice, + ); + tpOrderId = tp.orderId; + this.logger.log(`TP attached: ${tp.orderId} @ ${order.takeProfitPrice}`); + } + if (order.stopLossPrice) { + const sl = await adapter.placeStopLoss( + credentials, + order.symbol, + order.side, + order.stopLossPrice, + ); + slOrderId = sl.orderId; + this.logger.log(`SL attached: ${sl.orderId} @ ${order.stopLossPrice}`); + } + return { ...context, tpOrderId, slOrderId }; + } catch (err) { + this.logger.error(`TP/SL attach failed, force-closing position: ${err}`); + try { + await adapter.closePosition(credentials, order.symbol, order.side, result.filledQuantity); + this.logger.warn(`Position force-closed after TP/SL failure`); + } 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/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/schema.prisma b/packages/database/prisma/schema.prisma index 887bba0..343c115 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,44 @@ 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? + 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 +148,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..29558a8 100644 --- a/packages/exchange-adapters/src/binance/binance.rest.ts +++ b/packages/exchange-adapters/src/binance/binance.rest.ts @@ -4,83 +4,139 @@ import { Balance, OrderRequest, OrderResult, + OrderStatus, Market, Candle, + Position, + PositionSide, + MarginType, + SymbolFilter, } 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 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 +145,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 +157,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 +177,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 +200,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 +218,198 @@ 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); + } + + async placeStopLoss( + credentials: ExchangeCredentials, + symbol: string, + side: PositionSide, + stopPrice: string, + ): Promise { + const params: Record = { + symbol, + side: side === 'long' ? 'SELL' : 'BUY', + type: 'STOP_MARKET', + stopPrice, + closePosition: 'true', + workingType: 'MARK_PRICE', + }; + const res = await this.signedRequest(credentials, 'POST', '/fapi/v1/order', params); + const data = (await res.json()) as BinanceFuturesOrderResponse; + return this.mapOrderResult(data); + } + + async placeTakeProfit( + credentials: ExchangeCredentials, + symbol: string, + side: PositionSide, + stopPrice: string, + ): Promise { + const params: Record = { + symbol, + side: side === 'long' ? 'SELL' : 'BUY', + type: 'TAKE_PROFIT_MARKET', + stopPrice, + closePosition: 'true', + workingType: 'MARK_PRICE', + }; + const res = await this.signedRequest(credentials, 'POST', '/fapi/v1/order', params); + const data = (await res.json()) as BinanceFuturesOrderResponse; + return this.mapOrderResult(data); + } + + 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 +442,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..6e1c569 100644 --- a/packages/exchange-adapters/src/interfaces/exchange-rest.ts +++ b/packages/exchange-adapters/src/interfaces/exchange-rest.ts @@ -6,6 +6,10 @@ import { OrderResult, Market, Candle, + Position, + PositionSide, + MarginType, + SymbolFilter, } from '@coin/types'; export interface IExchangeRest { @@ -31,4 +35,33 @@ 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, + ): Promise; + placeTakeProfit( + credentials: ExchangeCredentials, + symbol: string, + side: PositionSide, + stopPrice: string, + ): Promise; + getSymbolFilter(symbol: string): Promise; } diff --git a/packages/types/src/exchange.ts b/packages/types/src/exchange.ts index 40ef38a..5547cdb 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,13 @@ export interface Market { baseAsset: string; quoteAsset: string; } + +export interface SymbolFilter { + symbol: string; + pricePrecision: number; + quantityPrecision: number; + minQty: string; + stepSize: string; + minNotional: string; + tickSize: string; +} From f05a2c53e22509403a1644dd7797ed6d7c61fd5d Mon Sep 17 00:00:00 2001 From: fray-cloud Date: Fri, 1 May 2026 02:35:19 +0900 Subject: [PATCH 2/7] feat: Claude CLI driven LLM trade flow + token UI + 7 risk guards MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit PR3 of 3 in the LLM-driven Binance Futures pivot. Wires user input → Claude signal → futures execution end-to-end. Architecture decision: api-server hosts the Claude CLI subprocess for the synchronous /signal call. The plan's worker-hosted variant would need Kafka request-reply for sync HTTP, which is a heavier pattern than warranted for a single-user app. Token decryption is contained to the LLM call site, then released. Real-trade execution still goes through the existing worker saga. What's added: - Claude Code CLI installed in `coin-base` (used by api-server signal flow and by future worker tooling). `claude --version` runs at base build. - ClaudeTokens module: AES-256-GCM encrypted storage of per-user OAuth tokens (`POST/GET/DELETE /claude-tokens`). Reuses existing encryption helpers and JWT auth. - `/settings/claude` UI: paste token, status badge, replace, delete; links to `claude setup-token` instructions. - LlmCliService (api-server): pure cli-runner spawn helper + queue=1 service with 30s timeout, 1 retry, strict JSON parsing, and TP/SL geometry validation (LONG: sl --- Dockerfile.base | 5 + apps/api-server/nest-cli.json | 3 +- apps/api-server/src/app.module.ts | 4 + .../claude-tokens/claude-tokens.controller.ts | 33 +++ .../src/claude-tokens/claude-tokens.module.ts | 12 + .../claude-tokens/claude-tokens.service.ts | 49 ++++ .../dto/save-claude-token.dto.ts | 12 + .../src/llm-trades/dto/execute-trade.dto.ts | 47 ++++ .../src/llm-trades/dto/request-signal.dto.ts | 26 ++ .../src/llm-trades/llm-trades.controller.ts | 31 +++ .../src/llm-trades/llm-trades.module.ts | 13 + .../src/llm-trades/llm-trades.service.ts | 145 +++++++++++ apps/api-server/src/llm/cli-runner.ts | 86 +++++++ apps/api-server/src/llm/llm-cli.service.ts | 182 ++++++++++++++ apps/api-server/src/llm/llm.module.ts | 8 + .../src/llm/prompts/trading-system.md | 21 ++ apps/web/src/app/llm-trade/page.tsx | 5 + apps/web/src/app/settings/claude/page.tsx | 114 +++++++++ .../components/llm-trade/llm-trade-form.tsx | 225 ++++++++++++++++++ apps/web/src/components/nav-bar.tsx | 7 + apps/web/src/lib/api-client.ts | 82 +++++++ apps/worker-service/src/app.module.ts | 2 + .../src/orders/orders.module.ts | 2 + .../src/orders/orders.service.ts | 20 +- .../src/risk/risk-guard.service.ts | 150 ++++++++++++ apps/worker-service/src/risk/risk.module.ts | 8 + 26 files changed, 1290 insertions(+), 2 deletions(-) create mode 100644 apps/api-server/src/claude-tokens/claude-tokens.controller.ts create mode 100644 apps/api-server/src/claude-tokens/claude-tokens.module.ts create mode 100644 apps/api-server/src/claude-tokens/claude-tokens.service.ts create mode 100644 apps/api-server/src/claude-tokens/dto/save-claude-token.dto.ts create mode 100644 apps/api-server/src/llm-trades/dto/execute-trade.dto.ts create mode 100644 apps/api-server/src/llm-trades/dto/request-signal.dto.ts create mode 100644 apps/api-server/src/llm-trades/llm-trades.controller.ts create mode 100644 apps/api-server/src/llm-trades/llm-trades.module.ts create mode 100644 apps/api-server/src/llm-trades/llm-trades.service.ts create mode 100644 apps/api-server/src/llm/cli-runner.ts create mode 100644 apps/api-server/src/llm/llm-cli.service.ts create mode 100644 apps/api-server/src/llm/llm.module.ts create mode 100644 apps/api-server/src/llm/prompts/trading-system.md create mode 100644 apps/web/src/app/llm-trade/page.tsx create mode 100644 apps/web/src/app/settings/claude/page.tsx create mode 100644 apps/web/src/components/llm-trade/llm-trade-form.tsx create mode 100644 apps/worker-service/src/risk/risk-guard.service.ts create mode 100644 apps/worker-service/src/risk/risk.module.ts diff --git a/Dockerfile.base b/Dockerfile.base index 6ec2820..4074939 100644 --- a/Dockerfile.base +++ b/Dockerfile.base @@ -1,5 +1,10 @@ FROM node:20-alpine RUN corepack enable && corepack prepare pnpm@9.15.4 --activate + +# Claude Code CLI (used by api-server LLM signal flow). Pure Node package, no +# native deps, so it installs cleanly on alpine. +RUN npm install -g @anthropic-ai/claude-code && claude --version + WORKDIR /app # Install all monorepo dependencies diff --git a/apps/api-server/nest-cli.json b/apps/api-server/nest-cli.json index 89d7d6c..265d44c 100644 --- a/apps/api-server/nest-cli.json +++ b/apps/api-server/nest-cli.json @@ -3,6 +3,7 @@ "collection": "@nestjs/schematics", "sourceRoot": "src", "compilerOptions": { - "deleteOutDir": false + "deleteOutDir": false, + "assets": [{ "include": "**/*.md", "outDir": "dist", "watchAssets": true }] } } diff --git a/apps/api-server/src/app.module.ts b/apps/api-server/src/app.module.ts index 1b75501..2e40dff 100644 --- a/apps/api-server/src/app.module.ts +++ b/apps/api-server/src/app.module.ts @@ -13,6 +13,8 @@ import { OrdersModule } from './orders/orders.module'; import { NotificationsModule } from './notifications/notifications.module'; import { PortfolioModule } from './portfolio/portfolio.module'; import { ActivityModule } from './activity/activity.module'; +import { ClaudeTokensModule } from './claude-tokens/claude-tokens.module'; +import { LlmTradesModule } from './llm-trades/llm-trades.module'; import { DebugModule } from './debug/debug.module'; import { JwtAuthGuard } from './auth/guards/jwt-auth.guard'; @@ -49,6 +51,8 @@ import { JwtAuthGuard } from './auth/guards/jwt-auth.guard'; NotificationsModule, PortfolioModule, ActivityModule, + ClaudeTokensModule, + LlmTradesModule, DebugModule, ], controllers: [AppController], diff --git a/apps/api-server/src/claude-tokens/claude-tokens.controller.ts b/apps/api-server/src/claude-tokens/claude-tokens.controller.ts new file mode 100644 index 0000000..c3257c0 --- /dev/null +++ b/apps/api-server/src/claude-tokens/claude-tokens.controller.ts @@ -0,0 +1,33 @@ +import { Body, Controller, Delete, Get, HttpCode, Post, UseGuards } from '@nestjs/common'; +import { ApiBearerAuth, ApiOperation, ApiTags } from '@nestjs/swagger'; +import { JwtAuthGuard } from '../auth/guards/jwt-auth.guard'; +import { CurrentUser } from '../auth/decorators/current-user.decorator'; +import { ClaudeTokensService } from './claude-tokens.service'; +import { SaveClaudeTokenDto } from './dto/save-claude-token.dto'; + +@ApiTags('Claude Tokens') +@ApiBearerAuth() +@UseGuards(JwtAuthGuard) +@Controller('claude-tokens') +export class ClaudeTokensController { + constructor(private readonly service: ClaudeTokensService) {} + + @Get() + @ApiOperation({ summary: 'Check if a Claude OAuth token is registered for the user' }) + status(@CurrentUser() user: { id: string }) { + return this.service.getStatus(user.id); + } + + @Post() + @ApiOperation({ summary: 'Save / replace the user Claude OAuth token (encrypted at rest)' }) + save(@CurrentUser() user: { id: string }, @Body() dto: SaveClaudeTokenDto) { + return this.service.save(user.id, dto.token); + } + + @Delete() + @ApiOperation({ summary: 'Delete the user Claude OAuth token' }) + @HttpCode(204) + async remove(@CurrentUser() user: { id: string }) { + await this.service.delete(user.id); + } +} diff --git a/apps/api-server/src/claude-tokens/claude-tokens.module.ts b/apps/api-server/src/claude-tokens/claude-tokens.module.ts new file mode 100644 index 0000000..fe1c631 --- /dev/null +++ b/apps/api-server/src/claude-tokens/claude-tokens.module.ts @@ -0,0 +1,12 @@ +import { Module } from '@nestjs/common'; +import { PrismaModule } from '../prisma/prisma.module'; +import { ClaudeTokensController } from './claude-tokens.controller'; +import { ClaudeTokensService } from './claude-tokens.service'; + +@Module({ + imports: [PrismaModule], + controllers: [ClaudeTokensController], + providers: [ClaudeTokensService], + exports: [ClaudeTokensService], +}) +export class ClaudeTokensModule {} diff --git a/apps/api-server/src/claude-tokens/claude-tokens.service.ts b/apps/api-server/src/claude-tokens/claude-tokens.service.ts new file mode 100644 index 0000000..a724990 --- /dev/null +++ b/apps/api-server/src/claude-tokens/claude-tokens.service.ts @@ -0,0 +1,49 @@ +import { Injectable, NotFoundException, Logger } from '@nestjs/common'; +import { ConfigService } from '@nestjs/config'; +import { PrismaService } from '../prisma/prisma.service'; +import { encrypt, decrypt } from '@coin/utils'; + +@Injectable() +export class ClaudeTokensService { + private readonly logger = new Logger(ClaudeTokensService.name); + + constructor( + private readonly prisma: PrismaService, + private readonly config: ConfigService, + ) {} + + private get masterKey(): string { + return this.config.getOrThrow('ENCRYPTION_MASTER_KEY'); + } + + async getStatus(userId: string): Promise<{ registered: boolean; updatedAt?: Date }> { + const row = await this.prisma.claudeToken.findUnique({ where: { userId } }); + if (!row) return { registered: false }; + return { registered: true, updatedAt: row.updatedAt }; + } + + async save(userId: string, token: string): Promise<{ updatedAt: Date }> { + const encrypted = encrypt(token, this.masterKey); + const row = await this.prisma.claudeToken.upsert({ + where: { userId }, + update: { encryptedToken: encrypted }, + create: { userId, encryptedToken: encrypted }, + }); + this.logger.log(`Claude token saved for user ${userId}`); + return { updatedAt: row.updatedAt }; + } + + async delete(userId: string): Promise { + await this.prisma.claudeToken.delete({ where: { userId } }).catch(() => { + throw new NotFoundException('Claude token not registered'); + }); + this.logger.log(`Claude token deleted for user ${userId}`); + } + + /** Decrypt and return the raw OAuth token. Use only at LLM call time. */ + async getDecrypted(userId: string): Promise { + const row = await this.prisma.claudeToken.findUnique({ where: { userId } }); + if (!row) throw new NotFoundException('Claude token not registered'); + return decrypt(row.encryptedToken, this.masterKey); + } +} diff --git a/apps/api-server/src/claude-tokens/dto/save-claude-token.dto.ts b/apps/api-server/src/claude-tokens/dto/save-claude-token.dto.ts new file mode 100644 index 0000000..71cdb8b --- /dev/null +++ b/apps/api-server/src/claude-tokens/dto/save-claude-token.dto.ts @@ -0,0 +1,12 @@ +import { IsString, MinLength } from 'class-validator'; +import { ApiProperty } from '@nestjs/swagger'; + +export class SaveClaudeTokenDto { + @ApiProperty({ + description: 'Claude Code OAuth long-lived token (run `claude setup-token`)', + example: 'sk-ant-oat01-...', + }) + @IsString() + @MinLength(20) + token!: string; +} diff --git a/apps/api-server/src/llm-trades/dto/execute-trade.dto.ts b/apps/api-server/src/llm-trades/dto/execute-trade.dto.ts new file mode 100644 index 0000000..b8c389b --- /dev/null +++ b/apps/api-server/src/llm-trades/dto/execute-trade.dto.ts @@ -0,0 +1,47 @@ +import { IsString, IsIn, IsInt, IsOptional, Min, Max } from 'class-validator'; +import { Type } from 'class-transformer'; +import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger'; + +export class ExecuteTradeDto { + @ApiProperty({ description: 'USDT-M perpetual symbol', example: 'BTCUSDT' }) + @IsString() + symbol!: string; + + @ApiProperty({ description: '포지션 방향', enum: ['long', 'short'] }) + @IsIn(['long', 'short']) + side!: 'long' | 'short'; + + @ApiProperty({ description: '베팅비용 (증거금, USDT)', example: 50 }) + @Type(() => Number) + @Min(1) + betUsdt!: number; + + @ApiProperty({ description: '레버리지 (1-20)', example: 5 }) + @Type(() => Number) + @IsInt() + @Min(1) + @Max(20) + leverage!: number; + + @ApiProperty({ description: '익절 가격 (사용자가 LLM 응답에서 override 가능)', example: '67000' }) + @IsString() + takeProfitPrice!: string; + + @ApiProperty({ description: '손절 가격', example: '63000' }) + @IsString() + stopLossPrice!: string; + + @ApiProperty({ + description: '진입가 (LLM 응답 시점의 lastClose, 수량 계산에 사용)', + example: '65000', + }) + @IsString() + entryPrice!: string; + + @ApiPropertyOptional({ + description: '거래소 키 ID (지정하지 않으면 testnet 키 우선)', + }) + @IsOptional() + @IsString() + exchangeKeyId?: string; +} diff --git a/apps/api-server/src/llm-trades/dto/request-signal.dto.ts b/apps/api-server/src/llm-trades/dto/request-signal.dto.ts new file mode 100644 index 0000000..7446f4b --- /dev/null +++ b/apps/api-server/src/llm-trades/dto/request-signal.dto.ts @@ -0,0 +1,26 @@ +import { IsString, IsIn, IsInt, Min, Max } from 'class-validator'; +import { Type } from 'class-transformer'; +import { ApiProperty } from '@nestjs/swagger'; + +const ALLOWED_INTERVALS = ['1m', '5m', '15m', '1h', '4h', '1d'] as const; + +export class RequestSignalDto { + @ApiProperty({ description: 'USDT-M perpetual symbol', example: 'BTCUSDT' }) + @IsString() + symbol!: string; + + @ApiProperty({ + description: '캔들 간격', + enum: ALLOWED_INTERVALS, + example: '5m', + }) + @IsIn(ALLOWED_INTERVALS as unknown as string[]) + interval!: string; + + @ApiProperty({ description: '캔들 개수 (20-200)', example: 50 }) + @Type(() => Number) + @IsInt() + @Min(20) + @Max(200) + candleCount!: number; +} diff --git a/apps/api-server/src/llm-trades/llm-trades.controller.ts b/apps/api-server/src/llm-trades/llm-trades.controller.ts new file mode 100644 index 0000000..7ce80e8 --- /dev/null +++ b/apps/api-server/src/llm-trades/llm-trades.controller.ts @@ -0,0 +1,31 @@ +import { Body, Controller, Post, UseGuards } from '@nestjs/common'; +import { ApiBearerAuth, ApiOperation, ApiTags } from '@nestjs/swagger'; +import { JwtAuthGuard } from '../auth/guards/jwt-auth.guard'; +import { CurrentUser } from '../auth/decorators/current-user.decorator'; +import { LlmTradesService } from './llm-trades.service'; +import { RequestSignalDto } from './dto/request-signal.dto'; +import { ExecuteTradeDto } from './dto/execute-trade.dto'; + +@ApiTags('LLM Trades') +@ApiBearerAuth() +@UseGuards(JwtAuthGuard) +@Controller('llm-trades') +export class LlmTradesController { + constructor(private readonly service: LlmTradesService) {} + + @Post('signal') + @ApiOperation({ + summary: 'Fetch candles + ask Claude for long/short + TP/SL (sync, ~5-10s)', + }) + signal(@CurrentUser() user: { id: string }, @Body() dto: RequestSignalDto) { + return this.service.signal(user.id, dto); + } + + @Post('execute') + @ApiOperation({ + summary: 'Place futures market order with attached TP/SL via worker saga', + }) + execute(@CurrentUser() user: { id: string }, @Body() dto: ExecuteTradeDto) { + return this.service.execute(user.id, dto); + } +} diff --git a/apps/api-server/src/llm-trades/llm-trades.module.ts b/apps/api-server/src/llm-trades/llm-trades.module.ts new file mode 100644 index 0000000..5a2e755 --- /dev/null +++ b/apps/api-server/src/llm-trades/llm-trades.module.ts @@ -0,0 +1,13 @@ +import { Module } from '@nestjs/common'; +import { PrismaModule } from '../prisma/prisma.module'; +import { ClaudeTokensModule } from '../claude-tokens/claude-tokens.module'; +import { LlmModule } from '../llm/llm.module'; +import { LlmTradesController } from './llm-trades.controller'; +import { LlmTradesService } from './llm-trades.service'; + +@Module({ + imports: [PrismaModule, ClaudeTokensModule, LlmModule], + controllers: [LlmTradesController], + providers: [LlmTradesService], +}) +export class LlmTradesModule {} diff --git a/apps/api-server/src/llm-trades/llm-trades.service.ts b/apps/api-server/src/llm-trades/llm-trades.service.ts new file mode 100644 index 0000000..e585897 --- /dev/null +++ b/apps/api-server/src/llm-trades/llm-trades.service.ts @@ -0,0 +1,145 @@ +import { Injectable, Logger, NotFoundException, BadRequestException } from '@nestjs/common'; +import { Kafka, Producer } from 'kafkajs'; +import { KAFKA_TOPICS } from '@coin/kafka-contracts'; +import type { OrderRequestedEvent } from '@coin/kafka-contracts'; +import { BinanceRest } from '@coin/exchange-adapters'; +import type { Candle } from '@coin/types'; +import { randomUUID } from 'crypto'; +import { PrismaService } from '../prisma/prisma.service'; +import { ClaudeTokensService } from '../claude-tokens/claude-tokens.service'; +import { LlmCliService, LlmDecision } from '../llm/llm-cli.service'; +import { RequestSignalDto } from './dto/request-signal.dto'; +import { ExecuteTradeDto } from './dto/execute-trade.dto'; + +@Injectable() +export class LlmTradesService { + private readonly logger = new Logger(LlmTradesService.name); + private readonly binance = new BinanceRest(); + private kafka: Kafka; + private producer: Producer; + private connected = false; + + constructor( + private readonly prisma: PrismaService, + private readonly tokens: ClaudeTokensService, + private readonly llm: LlmCliService, + ) { + this.kafka = new Kafka({ + clientId: 'api-llm-trades', + brokers: (process.env.KAFKA_BROKERS || 'localhost:9092').split(','), + }); + this.producer = this.kafka.producer(); + } + + async onModuleInit() { + await this.producer.connect(); + this.connected = true; + } + + async onModuleDestroy() { + if (this.connected) await this.producer.disconnect(); + } + + async signal( + userId: string, + dto: RequestSignalDto, + ): Promise { + const oauthToken = await this.tokens.getDecrypted(userId); + const candles = await this.binance.getCandles(dto.symbol, dto.interval, dto.candleCount); + if (candles.length === 0) { + throw new BadRequestException(`No candles for ${dto.symbol} @ ${dto.interval}`); + } + const decision = await this.llm.decide({ + oauthToken, + symbol: dto.symbol, + interval: dto.interval, + candles, + }); + const entryPrice = candles[candles.length - 1].close; + + await this.prisma.llmDecisionLog.create({ + data: { + userId, + prompt: `${dto.symbol} ${dto.interval} ${dto.candleCount}`, + rawResponse: decision.rawResponse, + parsedSignal: { + signal: decision.signal, + takeProfitPrice: decision.takeProfitPrice, + stopLossPrice: decision.stopLossPrice, + reasoning: decision.reasoning, + }, + model: decision.model, + latencyMs: decision.latencyMs, + }, + }); + + return { ...decision, entryPrice, candles }; + } + + async execute(userId: string, dto: ExecuteTradeDto): Promise<{ id: string; status: string }> { + // Resolve exchange key. If unspecified, prefer testnet for safety. + const keys = await this.prisma.exchangeKey.findMany({ + where: { userId, exchange: 'binance' }, + }); + if (keys.length === 0) { + throw new NotFoundException('No Binance exchange key registered'); + } + const key = dto.exchangeKeyId + ? keys.find((k) => k.id === dto.exchangeKeyId) + : (keys.find((k) => k.network === 'testnet') ?? keys[0]); + if (!key) throw new NotFoundException('Specified exchange key not found'); + + // Margin × leverage / entry = base-asset quantity + const quantity = ((dto.betUsdt * dto.leverage) / Number(dto.entryPrice)).toFixed(6); + + const order = await this.prisma.order.create({ + data: { + userId, + exchangeKeyId: key.id, + exchange: 'binance', + symbol: dto.symbol, + side: dto.side, + type: 'market', + mode: 'real', + status: 'pending', + quantity, + leverage: dto.leverage, + marginType: 'ISOLATED', + positionSide: dto.side, + takeProfitPrice: dto.takeProfitPrice, + stopLossPrice: dto.stopLossPrice, + }, + }); + + const requestId = randomUUID(); + const event: OrderRequestedEvent = { + requestId, + userId, + exchangeKeyId: key.id, + order: { + exchange: 'binance', + symbol: dto.symbol, + side: dto.side, + type: 'market', + quantity, + leverage: dto.leverage, + marginType: 'ISOLATED', + takeProfitPrice: dto.takeProfitPrice, + stopLossPrice: dto.stopLossPrice, + }, + mode: 'real', + dbOrderId: order.id, + }; + + await this.producer.send({ + topic: KAFKA_TOPICS.TRADING_ORDER_REQUESTED, + messages: [{ key: userId, value: JSON.stringify(event) }], + }); + + this.logger.log( + `LLM trade dispatched: ${order.id} (${dto.side} ${quantity} ${dto.symbol} ${dto.leverage}x, ${key.network})`, + ); + + return { id: order.id, status: 'pending' }; + } +} diff --git a/apps/api-server/src/llm/cli-runner.ts b/apps/api-server/src/llm/cli-runner.ts new file mode 100644 index 0000000..1a972ea --- /dev/null +++ b/apps/api-server/src/llm/cli-runner.ts @@ -0,0 +1,86 @@ +import { spawn } from 'child_process'; + +export interface ClaudeCliOptions { + prompt: string; + oauthToken: string; + systemPromptFile: string; + model?: string; + timeoutMs?: number; +} + +export interface ClaudeCliResult { + stdout: string; + stderr: string; + exitCode: number; + durationMs: number; +} + +/** + * Spawn the `claude` CLI as a subprocess. Pure function — no DI, no logging, + * just process orchestration. Tests can mock spawn. + * + * Flags chosen for non-interactive, deterministic-ish, side-effect-free use: + * - `--bare`: skip ~/.claude/CLAUDE.md and project configs + * - `--tools ""`: disable all tools (no Bash, no Read, etc.) + * - `--output-format json`: parse the wrapper JSON envelope + * - `--append-system-prompt-file`: inject our trading persona + * + * The OAuth token is passed via `CLAUDE_CODE_OAUTH_TOKEN` env so it never + * appears on the command line. + */ +export function runClaudeCli(opts: ClaudeCliOptions): Promise { + return new Promise((resolve, reject) => { + const start = Date.now(); + const args = [ + '-p', + opts.prompt, + '--output-format', + 'json', + '--tools', + '', + '--bare', + '--append-system-prompt-file', + opts.systemPromptFile, + '--model', + opts.model ?? 'claude-sonnet-4-6', + ]; + + const proc = spawn('claude', args, { + env: { + ...process.env, + CLAUDE_CODE_OAUTH_TOKEN: opts.oauthToken, + // Belt-and-suspenders: even with --bare, make sure we don't pick up + // the host's API key by accident. + ANTHROPIC_API_KEY: '', + }, + }); + + let stdout = ''; + let stderr = ''; + proc.stdout.on('data', (d: Buffer) => { + stdout += d.toString('utf8'); + }); + proc.stderr.on('data', (d: Buffer) => { + stderr += d.toString('utf8'); + }); + + const timeoutHandle = setTimeout(() => { + proc.kill('SIGKILL'); + reject(new Error(`claude cli timed out after ${opts.timeoutMs ?? 30_000}ms`)); + }, opts.timeoutMs ?? 30_000); + + proc.on('error', (err) => { + clearTimeout(timeoutHandle); + reject(err); + }); + proc.on('close', (code) => { + clearTimeout(timeoutHandle); + resolve({ + stdout, + stderr, + exitCode: code ?? -1, + durationMs: Date.now() - start, + }); + }); + }); +} diff --git a/apps/api-server/src/llm/llm-cli.service.ts b/apps/api-server/src/llm/llm-cli.service.ts new file mode 100644 index 0000000..bc3c4ce --- /dev/null +++ b/apps/api-server/src/llm/llm-cli.service.ts @@ -0,0 +1,182 @@ +import { Injectable, Logger, BadRequestException } from '@nestjs/common'; +import { join } from 'path'; +import type { Candle } from '@coin/types'; +import { runClaudeCli } from './cli-runner'; + +const SYSTEM_PROMPT_FILE = join(__dirname, 'prompts', 'trading-system.md'); + +export interface LlmDecisionInput { + oauthToken: string; + symbol: string; + interval: string; + candles: Candle[]; +} + +export interface LlmDecision { + signal: 'long' | 'short'; + takeProfitPrice: string; + stopLossPrice: string; + reasoning: string; + rawResponse: string; + latencyMs: number; + model: string; +} + +interface QueueItem { + resolve: (v: LlmDecision) => void; + reject: (e: Error) => void; + input: LlmDecisionInput; +} + +/** + * Wraps the `claude` CLI subprocess with: + * - Concurrency=1 queue (avoid Pro/Max rate-limit collisions, single user) + * - 30s timeout + * - 1 retry on parse failure + * - Strict JSON validation (LONG: sl { + return new Promise((resolve, reject) => { + this.queue.push({ resolve, reject, input }); + void this.drain(); + }); + } + + private async drain(): Promise { + if (this.running) return; + this.running = true; + try { + while (this.queue.length > 0) { + const item = this.queue.shift()!; + try { + const result = await this.runOnce(item.input); + item.resolve(result); + } catch (err) { + item.reject(err as Error); + } + } + } finally { + this.running = false; + } + } + + private async runOnce(input: LlmDecisionInput): Promise { + const userPrompt = this.buildUserPrompt(input); + + let attempt = 0; + let lastError: Error | undefined; + while (attempt < 2) { + attempt++; + try { + const cli = await runClaudeCli({ + prompt: userPrompt, + oauthToken: input.oauthToken, + systemPromptFile: SYSTEM_PROMPT_FILE, + model: this.model, + timeoutMs: 30_000, + }); + + if (cli.exitCode !== 0) { + throw new Error(`claude cli exit ${cli.exitCode}: ${cli.stderr.slice(0, 500)}`); + } + + const decision = this.parse(cli.stdout, input.candles); + this.logger.log( + `LLM decision for ${input.symbol}: ${decision.signal} tp=${decision.takeProfitPrice} sl=${decision.stopLossPrice} (${cli.durationMs}ms)`, + ); + return { + ...decision, + rawResponse: cli.stdout, + latencyMs: cli.durationMs, + model: this.model, + }; + } catch (err) { + lastError = err as Error; + this.logger.warn(`LLM attempt ${attempt} failed: ${lastError.message}`); + } + } + throw lastError ?? new Error('LLM decide failed'); + } + + private buildUserPrompt(input: LlmDecisionInput): string { + const compact = input.candles.map((c) => ({ + t: c.timestamp, + o: c.open, + h: c.high, + l: c.low, + c: c.close, + v: c.volume, + })); + return [ + `Symbol: ${input.symbol}`, + `Interval: ${input.interval}`, + `Candles (${input.candles.length}, oldest → newest):`, + JSON.stringify(compact), + ].join('\n'); + } + + private parse( + cliStdout: string, + candles: Candle[], + ): Omit { + let envelope: { result?: string }; + try { + envelope = JSON.parse(cliStdout) as { result?: string }; + } catch { + throw new BadRequestException('LLM CLI returned non-JSON envelope'); + } + const inner = envelope.result?.trim(); + if (!inner) throw new BadRequestException('LLM returned empty result'); + + let signal: { + signal?: string; + takeProfitPrice?: string; + stopLossPrice?: string; + reasoning?: string; + }; + try { + signal = JSON.parse(inner) as typeof signal; + } catch { + // Try to extract JSON from text (in case model wrapped in prose) + const match = inner.match(/\{[\s\S]*\}/); + if (!match) throw new BadRequestException(`LLM response not JSON: ${inner.slice(0, 200)}`); + signal = JSON.parse(match[0]) as typeof signal; + } + + if (signal.signal !== 'long' && signal.signal !== 'short') { + throw new BadRequestException(`LLM returned invalid signal: ${signal.signal}`); + } + if (!signal.takeProfitPrice || !signal.stopLossPrice) { + throw new BadRequestException('LLM response missing TP or SL'); + } + + const tp = Number(signal.takeProfitPrice); + const sl = Number(signal.stopLossPrice); + const lastClose = Number(candles[candles.length - 1].close); + if (!Number.isFinite(tp) || !Number.isFinite(sl) || !Number.isFinite(lastClose)) { + throw new BadRequestException('LLM returned non-numeric prices'); + } + if (signal.signal === 'long' && !(sl < lastClose && lastClose < tp)) { + throw new BadRequestException(`LONG TP/SL invalid: sl=${sl} entry≈${lastClose} tp=${tp}`); + } + if (signal.signal === 'short' && !(tp < lastClose && lastClose < sl)) { + throw new BadRequestException(`SHORT TP/SL invalid: tp=${tp} entry≈${lastClose} sl=${sl}`); + } + + return { + signal: signal.signal, + takeProfitPrice: signal.takeProfitPrice, + stopLossPrice: signal.stopLossPrice, + reasoning: signal.reasoning ?? '', + }; + } +} diff --git a/apps/api-server/src/llm/llm.module.ts b/apps/api-server/src/llm/llm.module.ts new file mode 100644 index 0000000..dd22f54 --- /dev/null +++ b/apps/api-server/src/llm/llm.module.ts @@ -0,0 +1,8 @@ +import { Module } from '@nestjs/common'; +import { LlmCliService } from './llm-cli.service'; + +@Module({ + providers: [LlmCliService], + exports: [LlmCliService], +}) +export class LlmModule {} diff --git a/apps/api-server/src/llm/prompts/trading-system.md b/apps/api-server/src/llm/prompts/trading-system.md new file mode 100644 index 0000000..8f2a8d9 --- /dev/null +++ b/apps/api-server/src/llm/prompts/trading-system.md @@ -0,0 +1,21 @@ +You are a focused crypto futures trading analyst. + +You will be given the last N candles for a single Binance USDT-M perpetual symbol (timeframe specified in the user message). Your job is to decide a single directional position (`long` or `short`) and recommend take-profit (TP) and stop-loss (SL) prices in absolute USDT. + +Hard rules — your reply must be **exactly one JSON object on a single line**, no prose, no markdown fences: + +``` +{"signal":"long|short","takeProfitPrice":"","stopLossPrice":"","reasoning":""} +``` + +Validation requirements (you MUST satisfy): + +- For `signal=long`: `stopLossPrice < lastClose < takeProfitPrice`. +- For `signal=short`: `takeProfitPrice < lastClose < stopLossPrice`. +- TP and SL must be plausible relative to recent volatility — do not propose targets > 10% away from `lastClose` unless the candles strongly justify it. +- Round prices to 2 decimal places when `lastClose` ≥ 100, else 4 decimal places. +- `reasoning` ≤ 140 chars. Reference an observable feature (trend, support, recent breakout). Do not say "AI" or "I think". + +If the data is too noisy or contradictory to take a side with confidence, still output your best guess and put a hedge in `reasoning` (e.g. "low conviction; tight TP/SL"). + +Output the JSON object and nothing else. diff --git a/apps/web/src/app/llm-trade/page.tsx b/apps/web/src/app/llm-trade/page.tsx new file mode 100644 index 0000000..97887b6 --- /dev/null +++ b/apps/web/src/app/llm-trade/page.tsx @@ -0,0 +1,5 @@ +import { LlmTradeForm } from '@/components/llm-trade/llm-trade-form'; + +export default function LlmTradePage() { + return ; +} diff --git a/apps/web/src/app/settings/claude/page.tsx b/apps/web/src/app/settings/claude/page.tsx new file mode 100644 index 0000000..2e04ffb --- /dev/null +++ b/apps/web/src/app/settings/claude/page.tsx @@ -0,0 +1,114 @@ +'use client'; + +import { useState } from 'react'; +import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'; +import { Card, CardHeader, CardTitle, CardContent } from '@/components/ui/card'; +import { Button } from '@/components/ui/button'; +import { getClaudeTokenStatus, saveClaudeToken, deleteClaudeToken } from '@/lib/api-client'; + +export default function ClaudeSettingsPage() { + const qc = useQueryClient(); + const [token, setToken] = useState(''); + const [error, setError] = useState(''); + const [saved, setSaved] = useState(false); + + const { data: status, isLoading } = useQuery({ + queryKey: ['claudeToken'], + queryFn: getClaudeTokenStatus, + }); + + const saveMutation = useMutation({ + mutationFn: saveClaudeToken, + onSuccess: () => { + setToken(''); + setError(''); + setSaved(true); + qc.invalidateQueries({ queryKey: ['claudeToken'] }); + setTimeout(() => setSaved(false), 2000); + }, + onError: (err: Error) => setError(err.message), + }); + + const deleteMutation = useMutation({ + mutationFn: deleteClaudeToken, + onSuccess: () => { + qc.invalidateQueries({ queryKey: ['claudeToken'] }); + }, + }); + + return ( +
+

Claude OAuth Token

+

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

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

로딩 중...

+ ) : status?.registered ? ( +
+

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

+ +
+ ) : ( +

토큰 미등록

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

{error}

} + {saved &&

저장되었습니다.

} +
+ +
+

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

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

LLM Trade

+

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

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

{error}

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

{signal.response.reasoning}

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

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

+ )} +
+
+ )} +
+ ); +} diff --git a/apps/web/src/components/nav-bar.tsx b/apps/web/src/components/nav-bar.tsx index 16d308c..b9df51e 100644 --- a/apps/web/src/components/nav-bar.tsx +++ b/apps/web/src/components/nav-bar.tsx @@ -61,6 +61,13 @@ export function NavBar() { 대시보드 + + + LLM Trade + { if (!res.ok) throw new Error('Failed to fetch activity'); return res.json(); } + +// --- Claude Tokens --- + +export interface ClaudeTokenStatus { + registered: boolean; + updatedAt?: string; +} + +export async function getClaudeTokenStatus(): Promise { + const res = await apiFetch('/claude-tokens'); + if (!res.ok) throw new Error('Failed to fetch Claude token status'); + return res.json(); +} + +export async function saveClaudeToken(token: string): Promise<{ updatedAt: string }> { + const res = await apiFetch('/claude-tokens', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ token }), + }); + if (!res.ok) { + const body = await res.json().catch(() => ({})); + throw new Error(body.message || 'Failed to save Claude token'); + } + return res.json(); +} + +export async function deleteClaudeToken(): Promise { + const res = await apiFetch('/claude-tokens', { method: 'DELETE' }); + if (!res.ok) throw new Error('Failed to delete Claude token'); +} + +// --- LLM Trades --- + +export interface SignalResponse { + signal: 'long' | 'short'; + takeProfitPrice: string; + stopLossPrice: string; + reasoning: string; + entryPrice: string; + latencyMs: number; + model: string; +} + +export async function requestSignal(input: { + symbol: string; + interval: string; + candleCount: number; +}): Promise { + const res = await apiFetch('/llm-trades/signal', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(input), + }); + if (!res.ok) { + const body = await res.json().catch(() => ({})); + throw new Error(body.message || 'Failed to request signal'); + } + return res.json(); +} + +export async function executeTrade(input: { + symbol: string; + side: 'long' | 'short'; + betUsdt: number; + leverage: number; + takeProfitPrice: string; + stopLossPrice: string; + entryPrice: string; + exchangeKeyId?: string; +}): Promise<{ id: string; status: string }> { + const res = await apiFetch('/llm-trades/execute', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(input), + }); + if (!res.ok) { + const body = await res.json().catch(() => ({})); + throw new Error(body.message || 'Failed to execute trade'); + } + return res.json(); +} diff --git a/apps/worker-service/src/app.module.ts b/apps/worker-service/src/app.module.ts index 251bd39..7ac0db3 100644 --- a/apps/worker-service/src/app.module.ts +++ b/apps/worker-service/src/app.module.ts @@ -3,6 +3,7 @@ import { LoggerModule } from 'nestjs-pino'; import { PrismaModule } from './prisma/prisma.module'; import { ExchangesModule } from './exchanges/exchanges.module'; import { OrdersModule } from './orders/orders.module'; +import { RiskModule } from './risk/risk.module'; @Module({ imports: [ @@ -20,6 +21,7 @@ import { OrdersModule } from './orders/orders.module'; }, }), PrismaModule, + RiskModule, OrdersModule, ExchangesModule, ], diff --git a/apps/worker-service/src/orders/orders.module.ts b/apps/worker-service/src/orders/orders.module.ts index 6c1ed2e..908e444 100644 --- a/apps/worker-service/src/orders/orders.module.ts +++ b/apps/worker-service/src/orders/orders.module.ts @@ -1,7 +1,9 @@ import { Module } from '@nestjs/common'; import { OrdersService } from './orders.service'; +import { RiskModule } from '../risk/risk.module'; @Module({ + imports: [RiskModule], providers: [OrdersService], exports: [OrdersService], }) diff --git a/apps/worker-service/src/orders/orders.service.ts b/apps/worker-service/src/orders/orders.service.ts index aae564d..d6e5969 100644 --- a/apps/worker-service/src/orders/orders.service.ts +++ b/apps/worker-service/src/orders/orders.service.ts @@ -6,6 +6,7 @@ import type { OrderRequestedEvent, OrderResultEvent } from '@coin/kafka-contract import type { OrderResult } from '@coin/types'; import { PrismaService } from '../prisma/prisma.service'; import { executeRealOrderSaga } from './sagas/real-execution-steps'; +import { RiskGuardService } from '../risk/risk-guard.service'; @Injectable() export class OrdersService implements OnModuleInit, OnModuleDestroy { @@ -15,7 +16,10 @@ export class OrdersService implements OnModuleInit, OnModuleDestroy { private producer: Producer; private redis: Redis; - constructor(private readonly prisma: PrismaService) { + constructor( + private readonly prisma: PrismaService, + private readonly riskGuard: RiskGuardService, + ) { this.kafka = new Kafka({ clientId: 'worker-orders', brokers: (process.env.KAFKA_BROKERS || 'localhost:9092').split(','), @@ -91,6 +95,20 @@ export class OrdersService implements OnModuleInit, OnModuleDestroy { 'Paper mode disabled: use Binance Futures Testnet via real mode with network=testnet', ); } + + // Resolve network from the user's exchange key so guards know whether + // mainnet-only checks apply. Cheap DB hit, runs once per order. + const exchangeKey = await this.prisma.exchangeKey.findFirst({ + where: { id: exchangeKeyId, userId }, + select: { network: true }, + }); + const network = (exchangeKey?.network as 'mainnet' | 'testnet') ?? 'mainnet'; + + const guard = await this.riskGuard.checkAll({ userId, network, order }); + if (!guard.ok) { + throw new Error(`Risk guard: ${guard.reason}`); + } + await this.executeRealOrder(event); console.log(`[OrdersService] Order executed OK: ${dbOrderId}`); } catch (err) { diff --git a/apps/worker-service/src/risk/risk-guard.service.ts b/apps/worker-service/src/risk/risk-guard.service.ts new file mode 100644 index 0000000..cc2be41 --- /dev/null +++ b/apps/worker-service/src/risk/risk-guard.service.ts @@ -0,0 +1,150 @@ +import { Injectable, Logger } from '@nestjs/common'; +import Redis from 'ioredis'; +import type { OrderRequest } from '@coin/types'; +import { PrismaService } from '../prisma/prisma.service'; + +export interface GuardCheck { + ok: boolean; + reason?: string; +} + +export interface GuardContext { + userId: string; + network: 'mainnet' | 'testnet'; + order: OrderRequest; + /** Available margin balance in USDT (mainnet only). undefined to skip MAX_BET_PCT. */ + availableUsdt?: number; +} + +/** + * Seven safety guards run before any real-mode order placement. Each returns + * `{ ok: false, reason }` to short-circuit the saga with a user-facing + * message. Mainnet-only guards are no-ops on testnet (where the trader can + * use fake USDT freely). + * + * Tunables come from env so ops can dial them without redeploy: + * KILL_SWITCH_REAL_TRADING ('true' to disable mainnet entirely) + * DAILY_LOSS_LIMIT_USDT (default 50) + * MAX_LEVERAGE (default 20) + * MAX_BET_PCT (default 10) // % of availableUsdt + * LLM_COOLDOWN_SECONDS (default 30) + * MAX_OPEN_POSITIONS_PER_USER (default 1) + */ +@Injectable() +export class RiskGuardService { + private readonly logger = new Logger(RiskGuardService.name); + private redis: Redis; + + constructor(private readonly prisma: PrismaService) { + this.redis = new Redis({ + host: process.env.REDIS_HOST || 'localhost', + port: Number(process.env.REDIS_PORT || 6379), + }); + } + + async checkAll(ctx: GuardContext): Promise { + const checks: Array<() => Promise> = [ + () => this.killSwitch(ctx), + () => this.maxLeverage(ctx), + () => this.cooldown(ctx), + () => this.maxOpenPositions(ctx), + () => this.dailyLossLimit(ctx), + () => this.maxBetPct(ctx), + ]; + for (const check of checks) { + const r = await check(); + if (!r.ok) { + this.logger.warn(`Guard blocked trade: ${r.reason}`); + return r; + } + } + return { ok: true }; + } + + /** 1. Global kill switch for mainnet real trading. */ + private async killSwitch(ctx: GuardContext): Promise { + if (ctx.network === 'testnet') return { ok: true }; + if (process.env.KILL_SWITCH_REAL_TRADING === 'true') { + return { ok: false, reason: 'Real mainnet trading is disabled (KILL_SWITCH_REAL_TRADING)' }; + } + if (process.env.ENABLE_REAL_MAINNET !== 'true') { + return { ok: false, reason: 'Mainnet trading not enabled (ENABLE_REAL_MAINNET=false)' }; + } + return { ok: true }; + } + + /** 2. Hard leverage cap regardless of user input. */ + private async maxLeverage(ctx: GuardContext): Promise { + const max = Number(process.env.MAX_LEVERAGE ?? 20); + if (ctx.order.leverage > max) { + return { ok: false, reason: `Leverage ${ctx.order.leverage}x exceeds cap of ${max}x` }; + } + return { ok: true }; + } + + /** 3. Per-user cooldown between LLM-driven trades to avoid Pro/Max rate limits. */ + private async cooldown(ctx: GuardContext): Promise { + const seconds = Number(process.env.LLM_COOLDOWN_SECONDS ?? 30); + if (seconds <= 0) return { ok: true }; + const key = `llm:cooldown:${ctx.userId}`; + const acquired = await this.redis.set(key, '1', 'EX', seconds, 'NX'); + if (!acquired) { + const ttl = await this.redis.ttl(key); + return { ok: false, reason: `Cooldown active — wait ${ttl}s` }; + } + return { ok: true }; + } + + /** 4. Cap concurrent open positions per user. */ + private async maxOpenPositions(ctx: GuardContext): Promise { + const max = Number(process.env.MAX_OPEN_POSITIONS_PER_USER ?? 1); + const open = await this.prisma.order.count({ + where: { + userId: ctx.userId, + status: { in: ['placed', 'partial', 'filled'] }, + closedAt: null, + }, + }); + if (open >= max) { + return { ok: false, reason: `${open} open position(s) — cap is ${max}` }; + } + return { ok: true }; + } + + /** 5. Daily realized loss ceiling (mainnet only). */ + private async dailyLossLimit(ctx: GuardContext): Promise { + if (ctx.network === 'testnet') return { ok: true }; + const limit = Number(process.env.DAILY_LOSS_LIMIT_USDT ?? 50); + const since = new Date(); + since.setUTCHours(0, 0, 0, 0); + const todayClosed = await this.prisma.order.findMany({ + where: { + userId: ctx.userId, + closedAt: { gte: since }, + realizedPnl: { not: null }, + }, + select: { realizedPnl: true }, + }); + const realized = todayClosed.reduce((sum, o) => sum + Number(o.realizedPnl ?? 0), 0); + if (realized <= -limit) { + return { ok: false, reason: `Daily loss limit hit: ${realized.toFixed(2)} ≤ -${limit} USDT` }; + } + return { ok: true }; + } + + /** 6. Bet must not exceed configured % of available margin (mainnet only). */ + private async maxBetPct(ctx: GuardContext): Promise { + if (ctx.network === 'testnet') return { ok: true }; + if (ctx.availableUsdt === undefined) return { ok: true }; + const pct = Number(process.env.MAX_BET_PCT ?? 10) / 100; + const notional = Number(ctx.order.quantity) * Number(ctx.order.price ?? 0); + const margin = notional / Math.max(ctx.order.leverage, 1); + if (margin > ctx.availableUsdt * pct) { + return { + ok: false, + reason: `Required margin ${margin.toFixed(2)} USDT exceeds ${(pct * 100).toFixed(0)}% of available ${ctx.availableUsdt.toFixed(2)}`, + }; + } + return { ok: true }; + } +} diff --git a/apps/worker-service/src/risk/risk.module.ts b/apps/worker-service/src/risk/risk.module.ts new file mode 100644 index 0000000..1fb4deb --- /dev/null +++ b/apps/worker-service/src/risk/risk.module.ts @@ -0,0 +1,8 @@ +import { Module } from '@nestjs/common'; +import { RiskGuardService } from './risk-guard.service'; + +@Module({ + providers: [RiskGuardService], + exports: [RiskGuardService], +}) +export class RiskModule {} From 74c7395bd0da3dc47a0e874eeac6e4afcadd412c Mon Sep 17 00:00:00 2001 From: fray-cloud Date: Fri, 1 May 2026 05:21:53 +0900 Subject: [PATCH 3/7] fix(llm-trade): make end-to-end LLM Binance Futures flow actually work MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Stack of debugging fixes discovered while running PR3 against a real Binance Futures account. Each one was a separate dead-end before the next was visible, so they're bundled here as one commit that makes the feature work. 1. Encryption master key — `.env.dev` shipped with `ENCRYPTION_MASTER_KEY=` empty, so AES-256-GCM blew up with "Invalid key length" the moment the first ClaudeToken was saved. Confirmed unrelated to this PR but blocked smoke testing; the value is now copied from `.env`. 2. Network awareness on the read path — `GetBalancesHandler` and `GetOpenOrdersHandler` decrypted the ExchangeKey but never read `key.network`, so testnet credentials were always sent at the mainnet base URL and got -2015. Both handlers now thread `network` into `ExchangeCredentials`. 3. Settings UI for testnet vs mainnet — `/settings` (Accounts) had no way to choose network, so users could only register mainnet keys. Added a network toggle (defaulting to **testnet** for safety) and updated the `createExchangeKey` API client to accept it. 4. System prompt assets — `nest-cli.json` `assets` config didn't actually copy `trading-system.md` into `dist/llm/prompts/` under `nest start --watch`, so the CLI runner failed with "no system prompt file". Inlined the prompt into a TS module (`prompts/trading-system.ts`), reverted the assets config, and removed the .md file so there's a single source of truth. 5. Subprocess hygiene for `claude -p` — the spawned CLI inherited an open stdin pipe and stalled 3s waiting on it before exiting 1. Switched to `stdio: ['ignore', 'pipe', 'pipe']` so the CLI sees stdin closed immediately. Also explicitly delete `ANTHROPIC_API_KEY` / `ANTHROPIC_AUTH_TOKEN` from the spawn env so the user OAuth token wins. 6. `--bare` flag — `--bare` skips per-user config but, in this CLI version, it also disables `CLAUDE_CODE_OAUTH_TOKEN` env auth and the CLI returns "Not logged in." Dropped `--bare`; we still pass `--tools ""` to keep the run side-effect free. 7. Lot-size precision — quantity was computed as `(bet × leverage) / entry`.toFixed(6), which Binance rejected with -1111 on BTCUSDT (stepSize 0.001). LlmTradesService now fetches `getSymbolFilter`, floors the raw quantity to the LOT_SIZE step, and returns a clear error if the snapped notional is below MIN_NOTIONAL. 8. Conditional orders moved to algoOrder — Binance migrated TP/SL types to a new endpoint on 2025-11-06; `/fapi/v1/order` now returns -4120 "use Algo Order API endpoints instead" for STOP_MARKET / TAKE_PROFIT_MARKET regardless of params or account region (verified by freqtrade issue #12610 + the change-log entry). Switched `placeStopLoss`/`placeTakeProfit` to `POST /fapi/v1/algoOrder` with the new schema: - required `algoType: 'CONDITIONAL'` - `stopPrice` renamed to `triggerPrice` - response carries `algoId` instead of `orderId` The saga's `AttachTpSlStep` is back to placing real exchange-side TP/SL (no client-side watcher needed) and on attach failure compensates by force-closing the position. End-to-end smoke test against Binance Futures testnet now succeeds: entry MARKET fills, then both TP and SL are attached as conditional algo orders with `algoStatus: NEW` and survive the saga to UpdateDb + PublishResult. Co-Authored-By: Claude Opus 4.7 (1M context) --- apps/api-server/nest-cli.json | 3 +- apps/api-server/src/debug/debug.controller.ts | 16 +++- .../queries/get-balances.handler.ts | 1 + .../queries/get-open-orders.handler.ts | 1 + .../src/llm-trades/llm-trades.service.ts | 25 +++++- apps/api-server/src/llm/cli-runner.ts | 25 +++--- apps/api-server/src/llm/llm-cli.service.ts | 6 +- .../src/llm/prompts/trading-system.md | 21 ----- .../src/llm/prompts/trading-system.ts | 20 +++++ apps/web/src/app/settings/page.tsx | 26 +++++- apps/web/src/lib/api-client.ts | 1 + .../src/orders/sagas/real-execution-steps.ts | 22 ++--- .../src/binance/binance.rest.ts | 86 ++++++++++++++++--- .../src/interfaces/exchange-rest.ts | 2 + 14 files changed, 188 insertions(+), 67 deletions(-) delete mode 100644 apps/api-server/src/llm/prompts/trading-system.md create mode 100644 apps/api-server/src/llm/prompts/trading-system.ts diff --git a/apps/api-server/nest-cli.json b/apps/api-server/nest-cli.json index 265d44c..89d7d6c 100644 --- a/apps/api-server/nest-cli.json +++ b/apps/api-server/nest-cli.json @@ -3,7 +3,6 @@ "collection": "@nestjs/schematics", "sourceRoot": "src", "compilerOptions": { - "deleteOutDir": false, - "assets": [{ "include": "**/*.md", "outDir": "dist", "watchAssets": true }] + "deleteOutDir": false } } diff --git a/apps/api-server/src/debug/debug.controller.ts b/apps/api-server/src/debug/debug.controller.ts index 6b31f52..744de11 100644 --- a/apps/api-server/src/debug/debug.controller.ts +++ b/apps/api-server/src/debug/debug.controller.ts @@ -95,10 +95,22 @@ export class DebugController { let tp: { orderId: string } | undefined; let sl: { orderId: string } | undefined; if (dto.takeProfitPrice) { - tp = await adapter.placeTakeProfit(credentials, dto.symbol, dto.side, dto.takeProfitPrice); + tp = await adapter.placeTakeProfit( + credentials, + dto.symbol, + dto.side, + dto.takeProfitPrice, + dto.quantity, + ); } if (dto.stopLossPrice) { - sl = await adapter.placeStopLoss(credentials, dto.symbol, dto.side, dto.stopLossPrice); + sl = await adapter.placeStopLoss( + credentials, + dto.symbol, + dto.side, + dto.stopLossPrice, + dto.quantity, + ); } const position = await adapter.getPosition(credentials, dto.symbol); diff --git a/apps/api-server/src/exchange-keys/queries/get-balances.handler.ts b/apps/api-server/src/exchange-keys/queries/get-balances.handler.ts index d27ec07..d4248cb 100644 --- a/apps/api-server/src/exchange-keys/queries/get-balances.handler.ts +++ b/apps/api-server/src/exchange-keys/queries/get-balances.handler.ts @@ -34,6 +34,7 @@ export class GetBalancesHandler implements IQueryHandler { const credentials: ExchangeCredentials = { apiKey: decrypt(key.apiKey, this.masterKey), secretKey: decrypt(key.secretKey, this.masterKey), + network: (key.network as 'mainnet' | 'testnet') ?? 'mainnet', }; return adapter.getBalances(credentials); diff --git a/apps/api-server/src/exchange-keys/queries/get-open-orders.handler.ts b/apps/api-server/src/exchange-keys/queries/get-open-orders.handler.ts index 2444149..c7f5895 100644 --- a/apps/api-server/src/exchange-keys/queries/get-open-orders.handler.ts +++ b/apps/api-server/src/exchange-keys/queries/get-open-orders.handler.ts @@ -34,6 +34,7 @@ export class GetOpenOrdersHandler implements IQueryHandler { const credentials: ExchangeCredentials = { apiKey: decrypt(key.apiKey, this.masterKey), secretKey: decrypt(key.secretKey, this.masterKey), + network: (key.network as 'mainnet' | 'testnet') ?? 'mainnet', }; return adapter.getOpenOrders(credentials, symbol); diff --git a/apps/api-server/src/llm-trades/llm-trades.service.ts b/apps/api-server/src/llm-trades/llm-trades.service.ts index e585897..bf1bc0b 100644 --- a/apps/api-server/src/llm-trades/llm-trades.service.ts +++ b/apps/api-server/src/llm-trades/llm-trades.service.ts @@ -89,8 +89,29 @@ export class LlmTradesService { : (keys.find((k) => k.network === 'testnet') ?? keys[0]); if (!key) throw new NotFoundException('Specified exchange key not found'); - // Margin × leverage / entry = base-asset quantity - const quantity = ((dto.betUsdt * dto.leverage) / Number(dto.entryPrice)).toFixed(6); + // Margin × leverage / entry = base-asset quantity. Then snap down to the + // exchange's LOT_SIZE step (Binance rejects with -1111 otherwise) and + // refuse if the snapped notional is below MIN_NOTIONAL. + const rawQty = (dto.betUsdt * dto.leverage) / Number(dto.entryPrice); + const filter = await this.binance.getSymbolFilter(dto.symbol); + const step = Number(filter.stepSize); + if (!step || step <= 0) { + throw new BadRequestException(`No LOT_SIZE filter for ${dto.symbol}`); + } + const stepDecimals = (filter.stepSize.split('.')[1] ?? '').length; + const snapped = Math.floor(rawQty / step) * step; + const quantity = snapped.toFixed(stepDecimals); + const notional = Number(quantity) * Number(dto.entryPrice); + if (notional < Number(filter.minNotional || 0)) { + throw new BadRequestException( + `Notional ${notional.toFixed(2)} USDT < min ${filter.minNotional}; raise bet or leverage`, + ); + } + if (Number(quantity) < Number(filter.minQty || 0)) { + throw new BadRequestException( + `Quantity ${quantity} < minQty ${filter.minQty}; raise bet or leverage`, + ); + } const order = await this.prisma.order.create({ data: { diff --git a/apps/api-server/src/llm/cli-runner.ts b/apps/api-server/src/llm/cli-runner.ts index 1a972ea..c9c0b6d 100644 --- a/apps/api-server/src/llm/cli-runner.ts +++ b/apps/api-server/src/llm/cli-runner.ts @@ -3,7 +3,7 @@ import { spawn } from 'child_process'; export interface ClaudeCliOptions { prompt: string; oauthToken: string; - systemPromptFile: string; + systemPrompt: string; model?: string; timeoutMs?: number; } @@ -38,21 +38,24 @@ export function runClaudeCli(opts: ClaudeCliOptions): Promise { 'json', '--tools', '', - '--bare', - '--append-system-prompt-file', - opts.systemPromptFile, + '--append-system-prompt', + opts.systemPrompt, '--model', opts.model ?? 'claude-sonnet-4-6', ]; + // Strip parent ANTHROPIC_API_KEY so we don't accidentally use the host's + // API key (we want the user's OAuth token to win). + const env = { ...process.env }; + delete env.ANTHROPIC_API_KEY; + delete env.ANTHROPIC_AUTH_TOKEN; + env.CLAUDE_CODE_OAUTH_TOKEN = opts.oauthToken; + const proc = spawn('claude', args, { - env: { - ...process.env, - CLAUDE_CODE_OAUTH_TOKEN: opts.oauthToken, - // Belt-and-suspenders: even with --bare, make sure we don't pick up - // the host's API key by accident. - ANTHROPIC_API_KEY: '', - }, + // stdin: 'ignore' prevents claude CLI from waiting on an open pipe for + // 3s (it inherits-as-pipe by default and reads stdin even with -p). + stdio: ['ignore', 'pipe', 'pipe'], + env, }); let stdout = ''; diff --git a/apps/api-server/src/llm/llm-cli.service.ts b/apps/api-server/src/llm/llm-cli.service.ts index bc3c4ce..2d52333 100644 --- a/apps/api-server/src/llm/llm-cli.service.ts +++ b/apps/api-server/src/llm/llm-cli.service.ts @@ -1,9 +1,7 @@ import { Injectable, Logger, BadRequestException } from '@nestjs/common'; -import { join } from 'path'; import type { Candle } from '@coin/types'; import { runClaudeCli } from './cli-runner'; - -const SYSTEM_PROMPT_FILE = join(__dirname, 'prompts', 'trading-system.md'); +import { TRADING_SYSTEM_PROMPT } from './prompts/trading-system'; export interface LlmDecisionInput { oauthToken: string; @@ -80,7 +78,7 @@ export class LlmCliService { const cli = await runClaudeCli({ prompt: userPrompt, oauthToken: input.oauthToken, - systemPromptFile: SYSTEM_PROMPT_FILE, + systemPrompt: TRADING_SYSTEM_PROMPT, model: this.model, timeoutMs: 30_000, }); diff --git a/apps/api-server/src/llm/prompts/trading-system.md b/apps/api-server/src/llm/prompts/trading-system.md deleted file mode 100644 index 8f2a8d9..0000000 --- a/apps/api-server/src/llm/prompts/trading-system.md +++ /dev/null @@ -1,21 +0,0 @@ -You are a focused crypto futures trading analyst. - -You will be given the last N candles for a single Binance USDT-M perpetual symbol (timeframe specified in the user message). Your job is to decide a single directional position (`long` or `short`) and recommend take-profit (TP) and stop-loss (SL) prices in absolute USDT. - -Hard rules — your reply must be **exactly one JSON object on a single line**, no prose, no markdown fences: - -``` -{"signal":"long|short","takeProfitPrice":"","stopLossPrice":"","reasoning":""} -``` - -Validation requirements (you MUST satisfy): - -- For `signal=long`: `stopLossPrice < lastClose < takeProfitPrice`. -- For `signal=short`: `takeProfitPrice < lastClose < stopLossPrice`. -- TP and SL must be plausible relative to recent volatility — do not propose targets > 10% away from `lastClose` unless the candles strongly justify it. -- Round prices to 2 decimal places when `lastClose` ≥ 100, else 4 decimal places. -- `reasoning` ≤ 140 chars. Reference an observable feature (trend, support, recent breakout). Do not say "AI" or "I think". - -If the data is too noisy or contradictory to take a side with confidence, still output your best guess and put a hedge in `reasoning` (e.g. "low conviction; tight TP/SL"). - -Output the JSON object and nothing else. diff --git a/apps/api-server/src/llm/prompts/trading-system.ts b/apps/api-server/src/llm/prompts/trading-system.ts new file mode 100644 index 0000000..0b12f28 --- /dev/null +++ b/apps/api-server/src/llm/prompts/trading-system.ts @@ -0,0 +1,20 @@ +export const TRADING_SYSTEM_PROMPT = `You are a focused crypto futures trading analyst. + +You will be given the last N candles for a single Binance USDT-M perpetual symbol (timeframe specified in the user message). Your job is to decide a single directional position (long or short) and recommend take-profit (TP) and stop-loss (SL) prices in absolute USDT. + +Hard rules — your reply must be **exactly one JSON object on a single line**, no prose, no markdown fences: + +{"signal":"long|short","takeProfitPrice":"","stopLossPrice":"","reasoning":""} + +Validation requirements (you MUST satisfy): + +- For signal=long: stopLossPrice < lastClose < takeProfitPrice. +- For signal=short: takeProfitPrice < lastClose < stopLossPrice. +- TP and SL must be plausible relative to recent volatility — do not propose targets > 10% away from lastClose unless the candles strongly justify it. +- Round prices to 2 decimal places when lastClose ≥ 100, else 4 decimal places. +- reasoning ≤ 140 chars. Reference an observable feature (trend, support, recent breakout). Do not say "AI" or "I think". + +If the data is too noisy or contradictory to take a side with confidence, still output your best guess and put a hedge in reasoning (e.g. "low conviction; tight TP/SL"). + +Output the JSON object and nothing else. +`; diff --git a/apps/web/src/app/settings/page.tsx b/apps/web/src/app/settings/page.tsx index f4cde64..3eb5e7b 100644 --- a/apps/web/src/app/settings/page.tsx +++ b/apps/web/src/app/settings/page.tsx @@ -114,6 +114,7 @@ function GeneralTab() { function AccountsTab() { const queryClient = useQueryClient(); const [exchange, setExchange] = useState('binance'); + const [network, setNetwork] = useState<'mainnet' | 'testnet'>('testnet'); const [apiKey, setApiKey] = useState(''); const [secretKey, setSecretKey] = useState(''); const [error, setError] = useState(''); @@ -145,7 +146,7 @@ function AccountsTab() {
{ e.preventDefault(); - createMutation.mutate({ exchange, apiKey, secretKey }); + createMutation.mutate({ exchange, network, apiKey, secretKey }); }} className="space-y-4" > @@ -167,6 +168,29 @@ function AccountsTab() { ))} +
+ +
+ {(['testnet', 'mainnet'] as const).map((n) => ( + + ))} +
+

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

+
{ export async function createExchangeKey(data: { exchange: string; + network?: 'mainnet' | 'testnet'; apiKey: string; secretKey: string; }): Promise<{ id: string; exchange: string }> { diff --git a/apps/worker-service/src/orders/sagas/real-execution-steps.ts b/apps/worker-service/src/orders/sagas/real-execution-steps.ts index 68a81f0..2cdfa95 100644 --- a/apps/worker-service/src/orders/sagas/real-execution-steps.ts +++ b/apps/worker-service/src/orders/sagas/real-execution-steps.ts @@ -152,9 +152,10 @@ export class PlaceOrderStep implements SagaStep { } /** - * Attaches STOP_MARKET (SL) and TAKE_PROFIT_MARKET (TP) close-position orders - * after the entry has filled. If either fails, compensate force-closes the - * underlying position so it never sits naked. + * Attaches conditional close orders (TAKE_PROFIT_MARKET / STOP_MARKET) via + * Binance's algoOrder endpoint after the entry has filled. If either + * placement fails, force-close the underlying position so it never sits + * naked. */ export class AttachTpSlStep implements SagaStep { readonly name = 'AttachTpSl'; @@ -170,12 +171,9 @@ export class AttachTpSlStep implements SagaStep { this.logger.log('No TP/SL specified, skipping'); return context; } - if (result.status !== 'filled' && result.status !== 'partial') { - this.logger.warn(`Entry not filled (status=${result.status}), skipping TP/SL attachment`); - return context; - } const adapter = REST_ADAPTERS[order.exchange](); + const filledQty = result.filledQuantity || order.quantity; let tpOrderId: string | undefined; let slOrderId: string | undefined; @@ -186,9 +184,10 @@ export class AttachTpSlStep implements SagaStep { order.symbol, order.side, order.takeProfitPrice, + filledQty, ); tpOrderId = tp.orderId; - this.logger.log(`TP attached: ${tp.orderId} @ ${order.takeProfitPrice}`); + this.logger.log(`TP attached: algoId=${tp.orderId} @ ${order.takeProfitPrice}`); } if (order.stopLossPrice) { const sl = await adapter.placeStopLoss( @@ -196,16 +195,17 @@ export class AttachTpSlStep implements SagaStep { order.symbol, order.side, order.stopLossPrice, + filledQty, ); slOrderId = sl.orderId; - this.logger.log(`SL attached: ${sl.orderId} @ ${order.stopLossPrice}`); + this.logger.log(`SL attached: algoId=${sl.orderId} @ ${order.stopLossPrice}`); } return { ...context, tpOrderId, slOrderId }; } catch (err) { this.logger.error(`TP/SL attach failed, force-closing position: ${err}`); try { - await adapter.closePosition(credentials, order.symbol, order.side, result.filledQuantity); - this.logger.warn(`Position force-closed after TP/SL failure`); + await adapter.closePosition(credentials, order.symbol, order.side, filledQty); + this.logger.warn('Position force-closed after TP/SL failure'); } catch (closeErr) { this.logger.error(`Force-close also failed: ${closeErr}`); } diff --git a/packages/exchange-adapters/src/binance/binance.rest.ts b/packages/exchange-adapters/src/binance/binance.rest.ts index 29558a8..b744279 100644 --- a/packages/exchange-adapters/src/binance/binance.rest.ts +++ b/packages/exchange-adapters/src/binance/binance.rest.ts @@ -61,6 +61,24 @@ interface BinanceFuturesOrderResponse { updateTime?: number; } +interface BinanceFuturesAlgoOrderResponse { + algoId: number; + clientAlgoId?: string; + algoType: 'CONDITIONAL'; + orderType: string; + symbol: string; + side: 'BUY' | 'SELL'; + positionSide?: string; + algoStatus: string; + triggerPrice?: string; + price?: string; + quantity?: string; + closePosition?: boolean; + workingType?: string; + updateTime?: number; + createTime?: number; +} + interface BinanceFuturesPositionResponse { symbol: string; positionAmt: string; @@ -317,23 +335,30 @@ export class BinanceRest implements IExchangeRest { return this.mapOrderResult(data); } + /** + * Conditional close orders (STOP_MARKET / TAKE_PROFIT_MARKET) moved to a + * dedicated endpoint /fapi/v1/algoOrder per Binance's 2025-11-06 mandatory + * migration. Schema differs from /fapi/v1/order: + * - `algoType: 'CONDITIONAL'` is required + * - `stopPrice` is renamed to `triggerPrice` + * - response carries `algoId` (not `orderId`) + * `quantity` is accepted but ignored when `closePosition=true` — we keep + * passing it as a fallback in case Binance changes the contract. + */ async placeStopLoss( credentials: ExchangeCredentials, symbol: string, side: PositionSide, stopPrice: string, + quantity: string, ): Promise { - const params: Record = { + return this.placeAlgoConditional(credentials, { symbol, side: side === 'long' ? 'SELL' : 'BUY', type: 'STOP_MARKET', - stopPrice, - closePosition: 'true', - workingType: 'MARK_PRICE', - }; - const res = await this.signedRequest(credentials, 'POST', '/fapi/v1/order', params); - const data = (await res.json()) as BinanceFuturesOrderResponse; - return this.mapOrderResult(data); + triggerPrice: stopPrice, + quantity, + }); } async placeTakeProfit( @@ -341,18 +366,53 @@ export class BinanceRest implements IExchangeRest { symbol: string, side: PositionSide, stopPrice: string, + quantity: string, ): Promise { - const params: Record = { + return this.placeAlgoConditional(credentials, { symbol, side: side === 'long' ? 'SELL' : 'BUY', type: 'TAKE_PROFIT_MARKET', - stopPrice, + triggerPrice: stopPrice, + quantity, + }); + } + + private async placeAlgoConditional( + credentials: ExchangeCredentials, + opts: { + symbol: string; + side: 'BUY' | 'SELL'; + type: 'STOP_MARKET' | 'TAKE_PROFIT_MARKET'; + triggerPrice: string; + quantity: string; + }, + ): Promise { + const params: Record = { + symbol: opts.symbol, + side: opts.side, + type: opts.type, + algoType: 'CONDITIONAL', + triggerPrice: opts.triggerPrice, closePosition: 'true', workingType: 'MARK_PRICE', }; - const res = await this.signedRequest(credentials, 'POST', '/fapi/v1/order', params); - const data = (await res.json()) as BinanceFuturesOrderResponse; - return this.mapOrderResult(data); + const res = await this.signedRequest(credentials, 'POST', '/fapi/v1/algoOrder', params); + const data = (await res.json()) as BinanceFuturesAlgoOrderResponse; + return { + exchange: this.exchangeId, + orderId: String(data.algoId), + symbol: data.symbol, + side: opts.side === 'BUY' ? 'long' : 'short', + type: 'market', + status: this.mapOrderStatus(data.algoStatus), + quantity: opts.quantity, + filledQuantity: '0', + price: data.triggerPrice ?? '0', + filledPrice: '0', + fee: '0', + feeCurrency: '', + timestamp: data.updateTime ?? Date.now(), + }; } async getSymbolFilter(symbol: string): Promise { diff --git a/packages/exchange-adapters/src/interfaces/exchange-rest.ts b/packages/exchange-adapters/src/interfaces/exchange-rest.ts index 6e1c569..793d5e3 100644 --- a/packages/exchange-adapters/src/interfaces/exchange-rest.ts +++ b/packages/exchange-adapters/src/interfaces/exchange-rest.ts @@ -56,12 +56,14 @@ export interface IExchangeRest { symbol: string, side: PositionSide, stopPrice: string, + quantity: string, ): Promise; placeTakeProfit( credentials: ExchangeCredentials, symbol: string, side: PositionSide, stopPrice: string, + quantity: string, ): Promise; getSymbolFilter(symbol: string): Promise; } From a131a190cbbd95ee207ed453744574a49e4f4561 Mon Sep 17 00:00:00 2001 From: fray-cloud Date: Fri, 1 May 2026 23:09:35 +0900 Subject: [PATCH 4/7] =?UTF-8?q?feat:=20UI/UX=20overhaul=20=E2=80=94=20test?= =?UTF-8?q?net/mainnet=20split,=20USD/KRW=20toggle,=20/orders/[id],=20dash?= =?UTF-8?q?board=20rebuild=20(#97)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Resolves the four day-to-day operational gaps identified in #97 so that the post-PR3 LLM trading flow is actually usable. Backend - Portfolio: replace stale paper/real/all mode with testnet/mainnet/all driven by ExchangeKey.network; returns byNetwork breakdown so the UI can show split totals in one call. - Orders: GET /orders/:id now hydrates the row with the joined LLM decision, live mark price, and unrealized PnL; new POST /orders/:id/close publishes a Kafka close event consumed by a worker saga that calls reduceOnly MARKET closePosition. - Worker close saga also reconciles when the position is already gone on the exchange (TP/SL fired or liquidation): pre-checks getPosition and treats -2022/-4046/-2023/-4045 as "position gone" so the DB still flips to closed. - LLM trades: GET /llm-trades/decisions cursor-paginated history with order outcome joined. - Dashboard: new /dashboard/summary aggregate (today/week PnL split by network, open positions with live mark, last 5 LLM decisions) — single round-trip. - Activity: order item link now points to /orders/${id} (was the deleted /orders index route). Frontend - BaseCurrencyToggle pill in the global nav bar (KRW⇄USD, localStorage). - New formatCurrency helper returning {main, sub} so every price renderer can show primary + alt currency without bespoke math. - /portfolio: testnet/mainnet/all toggle, 모의/실거래 split totals, network badges on assets. - /orders/[id]: candle chart with entry/TP/SL price lines, PnL panel, manual Close Position button (gated to real-mode + open), LLM reasoning card. - /dashboard: PnL cards (today/week × testnet/mainnet), active positions table with one-click close, last 5 LLM decisions with outcome badges. Tests - 16 api-server + 1 worker + 7 web suites green. - New: portfolio network filter, close-order kafka emit, activity link, order detail payload, close-position-saga reconcile paths (TP/SL gone, -2022 rejection, idempotency). Co-Authored-By: Claude Opus 4.7 (1M context) --- .../src/activity/activity.service.test.ts | 39 +++ .../src/activity/activity.service.ts | 2 +- apps/api-server/src/app.module.ts | 2 + .../src/dashboard/dashboard.controller.ts | 19 ++ .../src/dashboard/dashboard.module.ts | 11 + .../src/dashboard/dashboard.service.ts | 126 ++++++++ .../src/llm-trades/llm-trades.controller.ts | 19 +- .../src/llm-trades/llm-trades.service.ts | 33 +++ .../orders/commands/close-order.command.ts | 6 + .../commands/close-order.handler.test.ts | 84 ++++++ .../orders/commands/close-order.handler.ts | 80 +++++ apps/api-server/src/orders/commands/index.ts | 4 +- .../src/orders/orders.controller.ts | 18 +- .../orders/queries/get-order.handler.test.ts | 73 ++++- .../src/orders/queries/get-order.handler.ts | 56 +++- .../portfolio/dto/portfolio-response.dto.ts | 24 +- .../src/portfolio/portfolio.controller.ts | 15 +- .../src/portfolio/portfolio.service.test.ts | 110 +++++++ .../src/portfolio/portfolio.service.ts | 280 ++++++++++-------- .../get-portfolio-summary.handler.test.ts | 13 +- .../queries/get-portfolio-summary.handler.ts | 2 +- .../queries/get-portfolio-summary.query.ts | 4 +- apps/web/src/app/dashboard/page.tsx | 264 ++++++++++++++++- apps/web/src/app/orders/[id]/page.tsx | 218 ++++++++++++++ apps/web/src/app/portfolio/page.tsx | 101 +++++-- .../src/components/base-currency-toggle.tsx | 28 ++ apps/web/src/components/nav-bar.tsx | 2 + apps/web/src/components/order-chart.tsx | 146 +++++++++ apps/web/src/hooks/use-portfolio.ts | 8 +- apps/web/src/lib/api-client.ts | 113 ++++++- apps/web/src/lib/utils.test.ts | 21 +- apps/web/src/lib/utils.ts | 30 ++ apps/web/src/mocks/data/portfolio.ts | 22 ++ .../src/orders/orders.service.ts | 24 +- .../orders/sagas/close-position-saga.test.ts | 160 ++++++++++ .../src/orders/sagas/close-position-saga.ts | 205 +++++++++++++ packages/kafka-contracts/src/events.ts | 6 + packages/kafka-contracts/src/topics.ts | 1 + 38 files changed, 2160 insertions(+), 209 deletions(-) create mode 100644 apps/api-server/src/activity/activity.service.test.ts create mode 100644 apps/api-server/src/dashboard/dashboard.controller.ts create mode 100644 apps/api-server/src/dashboard/dashboard.module.ts create mode 100644 apps/api-server/src/dashboard/dashboard.service.ts create mode 100644 apps/api-server/src/orders/commands/close-order.command.ts create mode 100644 apps/api-server/src/orders/commands/close-order.handler.test.ts create mode 100644 apps/api-server/src/orders/commands/close-order.handler.ts create mode 100644 apps/api-server/src/portfolio/portfolio.service.test.ts create mode 100644 apps/web/src/app/orders/[id]/page.tsx create mode 100644 apps/web/src/components/base-currency-toggle.tsx create mode 100644 apps/web/src/components/order-chart.tsx create mode 100644 apps/worker-service/src/orders/sagas/close-position-saga.test.ts create mode 100644 apps/worker-service/src/orders/sagas/close-position-saga.ts 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..2208e20 100644 --- a/apps/api-server/src/activity/activity.service.ts +++ b/apps/api-server/src/activity/activity.service.ts @@ -53,7 +53,7 @@ export class ActivityService { symbol: o.symbol, status: o.status, side: o.side, - link: '/orders', + link: `/orders/${o.id}`, createdAt: o.createdAt, })); diff --git a/apps/api-server/src/app.module.ts b/apps/api-server/src/app.module.ts index 2e40dff..44c3d15 100644 --- a/apps/api-server/src/app.module.ts +++ b/apps/api-server/src/app.module.ts @@ -15,6 +15,7 @@ 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'; @@ -53,6 +54,7 @@ import { JwtAuthGuard } from './auth/guards/jwt-auth.guard'; ActivityModule, ClaudeTokensModule, LlmTradesModule, + DashboardModule, DebugModule, ], controllers: [AppController], 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/llm-trades/llm-trades.controller.ts b/apps/api-server/src/llm-trades/llm-trades.controller.ts index 7ce80e8..e0be0ea 100644 --- a/apps/api-server/src/llm-trades/llm-trades.controller.ts +++ b/apps/api-server/src/llm-trades/llm-trades.controller.ts @@ -1,5 +1,5 @@ -import { Body, Controller, Post, UseGuards } from '@nestjs/common'; -import { ApiBearerAuth, ApiOperation, ApiTags } from '@nestjs/swagger'; +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'; @@ -28,4 +28,19 @@ export class LlmTradesController { 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.service.ts b/apps/api-server/src/llm-trades/llm-trades.service.ts index bf1bc0b..84dd7e6 100644 --- a/apps/api-server/src/llm-trades/llm-trades.service.ts +++ b/apps/api-server/src/llm-trades/llm-trades.service.ts @@ -163,4 +163,37 @@ export class LlmTradesService { 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/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/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/portfolio/dto/portfolio-response.dto.ts b/apps/api-server/src/portfolio/dto/portfolio-response.dto.ts index dfe1299..f8acde7 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; @@ -31,7 +34,22 @@ class DailyPnlItem { pnl!: number; } +class NetworkBreakdownResponse { + @ApiProperty() totalValueKrw!: 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: '필터된 네트워크', enum: ['testnet', 'mainnet', 'all'] }) + network!: 'testnet' | 'mainnet' | 'all'; + @ApiProperty({ description: '총 자산 가치 (KRW)' }) totalValueKrw!: 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..45374a6 100644 --- a/apps/api-server/src/portfolio/portfolio.service.ts +++ b/apps/api-server/src/portfolio/portfolio.service.ts @@ -10,9 +10,12 @@ 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; @@ -20,6 +23,13 @@ interface PortfolioAsset { pnl: number; } +interface NetworkBreakdown { + totalValueKrw: 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 +54,125 @@ 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 orders joined with exchangeKey so we can split by network + const filledOrders = await this.prisma.order.findMany({ + where: { userId, status: 'filled' }, + 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 valueKrw = 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, + valueKrw, + 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 = { + totalValueKrw: 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.totalValueKrw = assets + .filter((a) => a.network === 'testnet') + .reduce((s, a) => s + a.valueKrw, 0); + testnetBreakdown.unrealizedPnl = assets + .filter((a) => a.network === 'testnet') + .reduce((s, a) => s + a.pnl, 0); + mainnetBreakdown.totalValueKrw = assets + .filter((a) => a.network === 'mainnet') + .reduce((s, a) => s + a.valueKrw, 0); + mainnetBreakdown.unrealizedPnl = assets + .filter((a) => a.network === 'mainnet') + .reduce((s, a) => s + a.pnl, 0); + + let totalValueKrw: number; + let realizedPnl: number; + let unrealizedPnl: number; + let dailyPnl: Array<{ date: string; pnl: number }>; + if (effective === 'testnet') { + ({ totalValueKrw, realizedPnl, unrealizedPnl, dailyPnl } = testnetBreakdown); + } else if (effective === 'mainnet') { + ({ totalValueKrw, realizedPnl, unrealizedPnl, dailyPnl } = mainnetBreakdown); + } else { + totalValueKrw = testnetBreakdown.totalValueKrw + mainnetBreakdown.totalValueKrw; + 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, + totalValueKrw, + realizedPnl, + unrealizedPnl, + assets, + dailyPnl, + byNetwork: { testnet: testnetBreakdown, mainnet: mainnetBreakdown }, + }; } private buildAvgCostMap( @@ -185,7 +187,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 +235,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 +272,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..9fa3ab3 100644 --- a/apps/web/src/app/dashboard/page.tsx +++ b/apps/web/src/app/dashboard/page.tsx @@ -1,20 +1,270 @@ '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 { PnlValue } from '@/components/shared/pnl-value'; +import { closePosition, getDashboardSummary, type DashboardSummary } from '@/lib/api-client'; +import { useBaseCurrency } from '@/hooks/use-base-currency'; +import { useExchangeRate } from '@/hooks/use-exchange-rate'; +import { formatCurrency } from '@/lib/utils'; 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 { currency } = useBaseCurrency(); + const { krwPerUsd } = useExchangeRate(); + + 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 entryFmt = entry != null ? formatCurrency(entry, currency, krwPerUsd) : null; + const markFmt = + p.markPrice != null ? formatCurrency(p.markPrice, currency, krwPerUsd) : 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} + {entryFmt?.main ?? '-'}{markFmt?.main ?? '-'} + {p.unrealizedPnl != 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) { + const pnl = Number(order.realizedPnl ?? 0); + if (pnl > 0) return { label: 'TP / 익절', variant: 'success' }; + if (pnl < 0) return { label: 'SL / 손절', variant: 'error' }; + return { label: '종료', variant: 'muted' }; + } + return { label: '활성', variant: 'info' }; +} 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..e9eaac7 --- /dev/null +++ b/apps/web/src/app/orders/[id]/page.tsx @@ -0,0 +1,218 @@ +'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 { PnlValue } from '@/components/shared/pnl-value'; +import { useBaseCurrency } from '@/hooks/use-base-currency'; +import { useExchangeRate } from '@/hooks/use-exchange-rate'; +import { closePosition, getOrder } from '@/lib/api-client'; +import { formatCurrency } from '@/lib/utils'; + +export default function OrderDetailPage({ params }: { params: Promise<{ id: string }> }) { + const { id } = use(params); + const queryClient = useQueryClient(); + const { currency } = useBaseCurrency(); + const { krwPerUsd } = useExchangeRate(); + + 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; + + const formatUsd = (n: number | null) => { + if (n == null) return '-'; + const { main, sub } = formatCurrency(n, currency, krwPerUsd); + return sub ? ( + + {main} {sub} + + ) : ( + {main} + ); + }; + + return ( +
+
+
+ + Activity + +
+

{order.symbol}

+ {order.side} + {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..e30fb10 100644 --- a/apps/web/src/app/portfolio/page.tsx +++ b/apps/web/src/app/portfolio/page.tsx @@ -4,7 +4,7 @@ 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'; @@ -13,16 +13,21 @@ 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,36 +69,33 @@ export default function PortfolioPage() { ); } + const showSplit = network === 'all'; + return (
-
+

{t('title')}

- {MODES.map((m) => ( + {NETWORKS.map((n) => ( ))}
- {/* Summary Cards */}
@@ -119,7 +121,55 @@ export default function PortfolioPage() {
- {/* P&L Chart */} + {showSplit && ( +
+ + + 모의 (Testnet) + + +
+ {t('totalValue')} + + {formatKrw(data.byNetwork.testnet.totalValueKrw)} + +
+
+ {t('realizedPnl')} + +
+
+ {t('unrealizedPnl')} + +
+
+
+ + + + 실거래 (Mainnet) + + + +
+ {t('totalValue')} + + {formatKrw(data.byNetwork.mainnet.totalValueKrw)} + +
+
+ {t('realizedPnl')} + +
+
+ {t('unrealizedPnl')} + +
+
+
+
+ )} + {data.dailyPnl.length > 0 && ( @@ -131,7 +181,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/components/base-currency-toggle.tsx b/apps/web/src/components/base-currency-toggle.tsx new file mode 100644 index 0000000..8b53f85 --- /dev/null +++ b/apps/web/src/components/base-currency-toggle.tsx @@ -0,0 +1,28 @@ +'use client'; + +import { useBaseCurrency } from '@/hooks/use-base-currency'; + +export function BaseCurrencyToggle() { + const { currency, setCurrency } = useBaseCurrency(); + + return ( +
+ {(['KRW', 'USD'] as const).map((c) => ( + + ))} +
+ ); +} diff --git a/apps/web/src/components/nav-bar.tsx b/apps/web/src/components/nav-bar.tsx index b9df51e..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']; @@ -97,6 +98,7 @@ export function NavBar() { {/* Right section */}
+ {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/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 8f544a8..968f0f9 100644 --- a/apps/web/src/lib/api-client.ts +++ b/apps/web/src/lib/api-client.ts @@ -246,6 +246,51 @@ export async function cancelOrder(id: string): Promise<{ id: string; status: str return res.json(); } +export interface OrderDetail { + order: OrderItem & { + entryPrice: string | null; + takeProfitPrice: string | null; + stopLossPrice: string | null; + realizedPnl: string | null; + closedAt: string | 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 { @@ -273,9 +318,12 @@ 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; @@ -283,18 +331,25 @@ export interface PortfolioAsset { pnl: number; } +export interface NetworkBreakdown { + totalValueKrw: number; + realizedPnl: number; + unrealizedPnl: number; + dailyPnl: Array<{ date: string; pnl: number }>; +} + export interface PortfolioSummary { + network: PortfolioNetwork; totalValueKrw: 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(); @@ -425,6 +480,56 @@ export async function requestSignal(input: { 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; + 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'; 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..03cf8d9 100644 --- a/apps/web/src/mocks/data/portfolio.ts +++ b/apps/web/src/mocks/data/portfolio.ts @@ -1,6 +1,14 @@ import type { PortfolioSummary } from '@/lib/api-client'; +const emptyBreakdown = { + totalValueKrw: 0, + realizedPnl: 0, + unrealizedPnl: 0, + dailyPnl: [] as Array<{ date: string; pnl: number }>, +}; + export const demoPortfolio: PortfolioSummary = { + network: 'all', totalValueKrw: 15_420_000, realizedPnl: 2_850_000, unrealizedPnl: 680_000, @@ -8,6 +16,7 @@ export const demoPortfolio: PortfolioSummary = { { exchange: 'binance', currency: 'BTC', + network: 'mainnet', quantity: '0.007', avgCost: 134_250_000, currentPrice: 136_500_000, @@ -17,6 +26,7 @@ export const demoPortfolio: PortfolioSummary = { { exchange: 'binance', currency: 'ETH', + network: 'mainnet', quantity: '0.1', avgCost: 5_200_000, currentPrice: 5_350_000, @@ -26,6 +36,7 @@ export const demoPortfolio: PortfolioSummary = { { exchange: 'binance', currency: 'SOL', + network: 'testnet', quantity: '5', avgCost: 220_000, currentPrice: 228_000, @@ -35,6 +46,7 @@ export const demoPortfolio: PortfolioSummary = { { exchange: 'binance', currency: 'XRP', + network: 'mainnet', quantity: '500', avgCost: 3_150, currentPrice: 3_280, @@ -44,6 +56,7 @@ export const demoPortfolio: PortfolioSummary = { { exchange: 'binance', currency: 'USDT', + network: 'mainnet', quantity: '5000', avgCost: 1, currentPrice: 1, @@ -60,4 +73,13 @@ export const demoPortfolio: PortfolioSummary = { { date: '2026-04-05', pnl: 990_000 }, { date: '2026-04-06', pnl: 1_400_000 }, ], + byNetwork: { + testnet: { ...emptyBreakdown, totalValueKrw: 1_140_000, unrealizedPnl: 40_000 }, + mainnet: { + ...emptyBreakdown, + totalValueKrw: 14_280_500, + realizedPnl: 2_850_000, + unrealizedPnl: 640_000, + }, + }, }; diff --git a/apps/worker-service/src/orders/orders.service.ts b/apps/worker-service/src/orders/orders.service.ts index d6e5969..a0ec80b 100644 --- a/apps/worker-service/src/orders/orders.service.ts +++ b/apps/worker-service/src/orders/orders.service.ts @@ -2,10 +2,15 @@ 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() @@ -43,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); } 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..06396a0 --- /dev/null +++ b/apps/worker-service/src/orders/sagas/close-position-saga.ts @@ -0,0 +1,205 @@ +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); + 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); + 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); + await emitClosedEvents( + producer, + event, + order, + side, + quantity, + closeResult, + `${side.toUpperCase()} ${quantity} 수동 종료 완료`, + ); +} + +async function markOrderClosed(prisma: PrismaService, orderId: string, realizedPnl: string | null) { + await prisma.order.update({ + where: { id: orderId }, + data: { status: 'closed', closedAt: new Date(), realizedPnl }, + }); +} + +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/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', From 0de2dd35d8ba459186b913bbc68f8efafa503909 Mon Sep 17 00:00:00 2001 From: fray-cloud Date: Fri, 1 May 2026 23:15:38 +0900 Subject: [PATCH 5/7] =?UTF-8?q?feat:=20PositionReconciler=20=E2=80=94=20au?= =?UTF-8?q?to-detect=20TP/SL/liquidation=20fills=20from=20Binance=20(#98)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit PR3 attaches TP/SL via algoOrder, but Binance never pushes fills back to us (no User Data Stream subscription). When TP or SL fires, the position is gone on the exchange while our DB stays at status='filled', closedAt=null — effectively a permanent ghost open position. Manual close (#97) was the only recovery path. This adds a worker-side polling reconciler that closes the loop without introducing a WebSocket dependency. Worker - New PositionReconcilerService runs every 30s (RECONCILE_INTERVAL_MS env override). Queries DB for status='filled' AND closedAt IS NULL AND mode='real' orders and reconciles each. - Per-order reconcile is extracted into a deep, dep-injected reconcileOrder() module so the algorithm is testable in isolation from Kafka/Redis/Prisma wiring. - Algorithm: getPosition() to detect vanished positions, then getIncome() windowed since order.createdAt to compute authoritative realizedPnl from REALIZED_PNL + COMMISSION + FUNDING_FEE rows. INSURANCE_CLEAR rows mean liquidation. With both TP and SL registered, sign of realizedPnl decides take_profit vs stop_loss; otherwise manual_on_exchange. - Race-safe DB write: updateMany with closedAt=null guard so a concurrent manual close can't double-emit notifications. - Auth-failure cooldown: 3 consecutive auth errors per exchange key → Redis 1h cooldown skip, so one bad key doesn't loop forever. - close-position-saga now sets closeReason on its own writes ('manual' for the happy path, 'manual_on_exchange' for already-gone reconciliation). Adapter / types - New BinanceRest.getIncome() backed by /fapi/v1/income with full IncomeType union and IncomeRecord shape exported from @coin/types. - IExchangeRest gains the matching method. Schema - Order.closeReason: String? — nullable, no default (historical rows untouched). - Manual SQL migration only (DB not reachable from this env). API - OrderResponse DTO documents closeReason. - ActivityService description suffixes Korean reason label and uses 'closed' status when closedAt is set. Frontend - New CloseReasonBadge maps each reason to a colored badge. - Order detail header shows it next to status. Dashboard recent decisions outcome map prefers closeReason over the old positive/negative-PnL guess. Tests - reconcileOrder: 8 scenarios — live position skip, TP, SL, liquidation, empty income, lock-held, race-lost (manual won the update), TP/SL unregistered → manual_on_exchange. - close-position-saga and existing api/web suites still green. Resolves #98. Co-Authored-By: Claude Opus 4.7 (1M context) --- .../src/activity/activity.service.ts | 46 ++-- .../src/orders/dto/order-response.dto.ts | 13 ++ apps/web/src/app/dashboard/page.tsx | 10 +- apps/web/src/app/orders/[id]/page.tsx | 4 +- .../web/src/components/close-reason-badge.tsx | 20 ++ apps/web/src/lib/api-client.ts | 10 + .../src/orders/orders.module.ts | 3 +- .../reconciler/position-reconciler.service.ts | 160 ++++++++++++++ .../orders/reconciler/reconcile-order.test.ts | 147 +++++++++++++ .../src/orders/reconciler/reconcile-order.ts | 198 ++++++++++++++++++ .../src/orders/sagas/close-position-saga.ts | 15 +- .../migration.sql | 2 + packages/database/prisma/schema.prisma | 1 + .../src/binance/binance.rest.ts | 47 +++++ .../src/interfaces/exchange-rest.ts | 11 + packages/types/src/exchange.ts | 32 +++ 16 files changed, 698 insertions(+), 21 deletions(-) create mode 100644 apps/web/src/components/close-reason-badge.tsx create mode 100644 apps/worker-service/src/orders/reconciler/position-reconciler.service.ts create mode 100644 apps/worker-service/src/orders/reconciler/reconcile-order.test.ts create mode 100644 apps/worker-service/src/orders/reconciler/reconcile-order.ts create mode 100644 packages/database/prisma/migrations/20260501230000_add_close_reason/migration.sql diff --git a/apps/api-server/src/activity/activity.service.ts b/apps/api-server/src/activity/activity.service.ts index 2208e20..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/${o.id}`, - 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/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/web/src/app/dashboard/page.tsx b/apps/web/src/app/dashboard/page.tsx index 9fa3ab3..66ef283 100644 --- a/apps/web/src/app/dashboard/page.tsx +++ b/apps/web/src/app/dashboard/page.tsx @@ -261,9 +261,15 @@ function describeOutcome(order: DashboardSummary['recentDecisions'][number]['ord 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: 'TP / 익절', variant: 'success' }; - if (pnl < 0) return { label: 'SL / 손절', variant: 'error' }; + 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/orders/[id]/page.tsx b/apps/web/src/app/orders/[id]/page.tsx index e9eaac7..82e1c34 100644 --- a/apps/web/src/app/orders/[id]/page.tsx +++ b/apps/web/src/app/orders/[id]/page.tsx @@ -9,6 +9,7 @@ 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 { PnlValue } from '@/components/shared/pnl-value'; import { useBaseCurrency } from '@/hooks/use-base-currency'; import { useExchangeRate } from '@/hooks/use-exchange-rate'; @@ -96,11 +97,12 @@ export default function OrderDetailPage({ params }: { params: Promise<{ id: stri

{order.symbol}

{order.side} - {order.status} + {order.closedAt ? 'closed' : order.status} {network === 'mainnet' ? '실거래' : '모의'} {order.leverage ? {order.leverage}x : null} +
{canClose && ( 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/lib/api-client.ts b/apps/web/src/lib/api-client.ts index 968f0f9..52dfa8e 100644 --- a/apps/web/src/lib/api-client.ts +++ b/apps/web/src/lib/api-client.ts @@ -246,6 +246,14 @@ 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; @@ -253,6 +261,7 @@ export interface OrderDetail { stopLossPrice: string | null; realizedPnl: string | null; closedAt: string | null; + closeReason: CloseReason | null; leverage: number | null; positionSide: string | null; }; @@ -501,6 +510,7 @@ export interface LlmDecisionItem { stopLossPrice: string | null; realizedPnl: string | null; closedAt: string | null; + closeReason: CloseReason | null; createdAt: string; } | null; } diff --git a/apps/worker-service/src/orders/orders.module.ts b/apps/worker-service/src/orders/orders.module.ts index 908e444..5111f40 100644 --- a/apps/worker-service/src/orders/orders.module.ts +++ b/apps/worker-service/src/orders/orders.module.ts @@ -1,10 +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({ imports: [RiskModule], - providers: [OrdersService], + providers: [OrdersService, PositionReconcilerService], exports: [OrdersService], }) export class OrdersModule {} 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.ts b/apps/worker-service/src/orders/sagas/close-position-saga.ts index 06396a0..f01eb30 100644 --- a/apps/worker-service/src/orders/sagas/close-position-saga.ts +++ b/apps/worker-service/src/orders/sagas/close-position-saga.ts @@ -85,7 +85,7 @@ export async function executeClosePositionSaga( }); if (!livePosBefore) { logger.warn(`Position already gone on exchange — reconciling order ${order.id}`); - await markOrderClosed(prisma, order.id, null); + await markOrderClosed(prisma, order.id, null, 'manual_on_exchange'); await emitClosedEvents( producer, event, @@ -107,7 +107,7 @@ export async function executeClosePositionSaga( } catch (err) { if (isPositionGoneError(err)) { logger.warn(`Close request rejected (position gone): ${err}. Reconciling.`); - await markOrderClosed(prisma, order.id, null); + await markOrderClosed(prisma, order.id, null, 'manual_on_exchange'); await emitClosedEvents( producer, event, @@ -148,7 +148,7 @@ export async function executeClosePositionSaga( realizedPnl = String(((fill - entry) * qty * direction).toFixed(8)); } - await markOrderClosed(prisma, order.id, realizedPnl); + await markOrderClosed(prisma, order.id, realizedPnl, 'manual'); await emitClosedEvents( producer, event, @@ -160,10 +160,15 @@ export async function executeClosePositionSaga( ); } -async function markOrderClosed(prisma: PrismaService, orderId: string, realizedPnl: string | null) { +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 }, + data: { status: 'closed', closedAt: new Date(), realizedPnl, closeReason }, }); } 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 343c115..ff34689 100644 --- a/packages/database/prisma/schema.prisma +++ b/packages/database/prisma/schema.prisma @@ -95,6 +95,7 @@ model Order { 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) diff --git a/packages/exchange-adapters/src/binance/binance.rest.ts b/packages/exchange-adapters/src/binance/binance.rest.ts index b744279..831acaa 100644 --- a/packages/exchange-adapters/src/binance/binance.rest.ts +++ b/packages/exchange-adapters/src/binance/binance.rest.ts @@ -11,6 +11,7 @@ import { PositionSide, MarginType, SymbolFilter, + IncomeRecord, } from '@coin/types'; import { IExchangeRest } from '../interfaces/exchange-rest'; @@ -415,6 +416,52 @@ export class BinanceRest implements IExchangeRest { }; } + /** + * 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); diff --git a/packages/exchange-adapters/src/interfaces/exchange-rest.ts b/packages/exchange-adapters/src/interfaces/exchange-rest.ts index 793d5e3..4bc2c39 100644 --- a/packages/exchange-adapters/src/interfaces/exchange-rest.ts +++ b/packages/exchange-adapters/src/interfaces/exchange-rest.ts @@ -10,6 +10,7 @@ import { PositionSide, MarginType, SymbolFilter, + IncomeRecord, } from '@coin/types'; export interface IExchangeRest { @@ -66,4 +67,14 @@ export interface IExchangeRest { 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/types/src/exchange.ts b/packages/types/src/exchange.ts index 5547cdb..04e1812 100644 --- a/packages/types/src/exchange.ts +++ b/packages/types/src/exchange.ts @@ -127,3 +127,35 @@ export interface SymbolFilter { 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; +} From dbbb0afe6f511bb6d950df62713d8f5dbbfa5727 Mon Sep 17 00:00:00 2001 From: fray-cloud Date: Sat, 2 May 2026 01:22:05 +0900 Subject: [PATCH 6/7] fix(portfolio): include closed orders in realized PnL + render USD via base-currency hooks MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two bugs the user hit when checking testnet portfolio after #98 landed. 1. Realized PnL on the testnet card was always 0 because PortfolioService only queried status='filled'. The close-saga and reconciler both flip status to 'closed' on settlement, so every TP/SL/manual close fell out of the rollup. Fix: WHERE status IN ('filled','closed') so realizedPnl is summed from settled rows. 2. Backend `valueKrw` was misnamed — for futures it's USDT × quantity, i.e. USD. The frontend then ran formatKrw(usdAmount) and printed those USD figures with comma-separators as if they were KRW, so KRW display was effectively broken everywhere (and broke harder when the exchange-rate cache was empty). Backend - Order query in PortfolioService.getSummary now includes 'closed'. - Field rename valueKrw→valueUsd (also totalValueKrw→totalValueUsd) so the contract is honest about the unit. Same in DTOs and the frontend types. Frontend - New and shared components. They read useBaseCurrency + useExchangeRate themselves and route through formatCurrency, so a USD number displays correctly in KRW or USD with a sub-label of the alternate currency. When krwPerUsd=0 (rate not yet loaded) they fall back to USD-only — no more misnamed KRW. - Replaced every formatKrw(usd) / usage: - /portfolio: total / realized / unrealized cards, byNetwork breakdown, asset-table, asset-card-list - /dashboard: today/week PnL cards, open positions table (entry, mark, unrealizedPnl) - /orders/[id]: entry / mark / TP / SL / realized / unrealized rows - Deleted the orphaned + its test/stories. - demoPortfolio mock updated to USD scale. Tests - New: (KRW main + USD sub, USD main + KRW sub, no rate, null) and (sign, color, null) — 7 tests. - All existing api/worker/web suites still green (57 + 13 + 43). Co-Authored-By: Claude Opus 4.7 (1M context) --- .../portfolio/dto/portfolio-response.dto.ts | 10 +-- .../src/portfolio/portfolio.service.ts | 35 +++++----- apps/web/src/app/dashboard/page.tsx | 31 ++++----- apps/web/src/app/orders/[id]/page.tsx | 35 +++------- apps/web/src/app/portfolio/page.tsx | 28 ++++---- .../components/portfolio/asset-card-list.tsx | 18 +++--- .../src/components/portfolio/asset-table.tsx | 38 +++++------ .../components/shared/money-value.test.tsx | 47 ++++++++++++++ .../web/src/components/shared/money-value.tsx | 32 ++++++++++ .../src/components/shared/pnl-money.test.tsx | 30 +++++++++ apps/web/src/components/shared/pnl-money.tsx | 39 +++++++++++ .../components/shared/pnl-value.stories.tsx | 20 ------ .../src/components/shared/pnl-value.test.tsx | 35 ---------- apps/web/src/components/shared/pnl-value.tsx | 18 ------ apps/web/src/lib/api-client.ts | 7 +- apps/web/src/mocks/data/portfolio.ts | 64 +++++++++---------- 16 files changed, 273 insertions(+), 214 deletions(-) create mode 100644 apps/web/src/components/shared/money-value.test.tsx create mode 100644 apps/web/src/components/shared/money-value.tsx create mode 100644 apps/web/src/components/shared/pnl-money.test.tsx create mode 100644 apps/web/src/components/shared/pnl-money.tsx delete mode 100644 apps/web/src/components/shared/pnl-value.stories.tsx delete mode 100644 apps/web/src/components/shared/pnl-value.test.tsx delete mode 100644 apps/web/src/components/shared/pnl-value.tsx 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 f8acde7..71c6909 100644 --- a/apps/api-server/src/portfolio/dto/portfolio-response.dto.ts +++ b/apps/api-server/src/portfolio/dto/portfolio-response.dto.ts @@ -19,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; @@ -35,7 +35,7 @@ class DailyPnlItem { } class NetworkBreakdownResponse { - @ApiProperty() totalValueKrw!: number; + @ApiProperty() totalValueUsd!: number; @ApiProperty() realizedPnl!: number; @ApiProperty() unrealizedPnl!: number; @ApiProperty({ type: [DailyPnlItem] }) dailyPnl!: DailyPnlItem[]; @@ -50,8 +50,8 @@ export class PortfolioSummaryResponse { @ApiProperty({ description: '필터된 네트워크', enum: ['testnet', 'mainnet', 'all'] }) network!: 'testnet' | 'mainnet' | 'all'; - @ApiProperty({ description: '총 자산 가치 (KRW)' }) - totalValueKrw!: number; + @ApiProperty({ description: '총 자산 가치 (USD/USDT)' }) + totalValueUsd!: number; @ApiProperty({ description: '실현 손익' }) realizedPnl!: number; diff --git a/apps/api-server/src/portfolio/portfolio.service.ts b/apps/api-server/src/portfolio/portfolio.service.ts index 45374a6..f82908b 100644 --- a/apps/api-server/src/portfolio/portfolio.service.ts +++ b/apps/api-server/src/portfolio/portfolio.service.ts @@ -19,12 +19,13 @@ interface PortfolioAsset { 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 { - totalValueKrw: number; + totalValueUsd: number; realizedPnl: number; unrealizedPnl: number; dailyPnl: Array<{ date: string; pnl: number }>; @@ -62,9 +63,11 @@ export class PortfolioService { const filteredKeys = effective === 'all' ? keys : keys.filter((k) => (k.network ?? 'mainnet') === effective); - // Filled orders joined with exchangeKey so we can split by network + // 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: 'filled' }, + where: { userId, status: { in: ['filled', 'closed'] } }, include: { exchangeKey: { select: { network: true } } }, orderBy: { createdAt: 'asc' }, }); @@ -98,7 +101,7 @@ export class PortfolioService { const costKey = `${key.exchange}|${bal.currency}`; const avgCost = avgCostMap.get(costKey) ?? 0; - const valueKrw = currentPrice * total; + const valueUsd = currentPrice * total; const pnl = avgCost > 0 ? (currentPrice - avgCost) * total : 0; assets.push({ @@ -108,7 +111,7 @@ export class PortfolioService { quantity: total.toString(), avgCost, currentPrice, - valueKrw, + valueUsd, pnl, }); } @@ -121,7 +124,7 @@ export class PortfolioService { const avgCostMap = this.buildAvgCostMap(rows); const deltas = this.dailyDeltaMap(rows); const summary: NetworkBreakdown = { - totalValueKrw: 0, + totalValueUsd: 0, realizedPnl: this.calculateRealizedPnl(rows, avgCostMap), unrealizedPnl: 0, dailyPnl: this.toCumulative(deltas), @@ -133,29 +136,29 @@ export class PortfolioService { const mainnetView = breakdownFor(ordersByNetwork.mainnet); const testnetBreakdown = testnetView.summary; const mainnetBreakdown = mainnetView.summary; - testnetBreakdown.totalValueKrw = assets + testnetBreakdown.totalValueUsd = assets .filter((a) => a.network === 'testnet') - .reduce((s, a) => s + a.valueKrw, 0); + .reduce((s, a) => s + a.valueUsd, 0); testnetBreakdown.unrealizedPnl = assets .filter((a) => a.network === 'testnet') .reduce((s, a) => s + a.pnl, 0); - mainnetBreakdown.totalValueKrw = assets + mainnetBreakdown.totalValueUsd = assets .filter((a) => a.network === 'mainnet') - .reduce((s, a) => s + a.valueKrw, 0); + .reduce((s, a) => s + a.valueUsd, 0); mainnetBreakdown.unrealizedPnl = assets .filter((a) => a.network === 'mainnet') .reduce((s, a) => s + a.pnl, 0); - let totalValueKrw: number; + let totalValueUsd: number; let realizedPnl: number; let unrealizedPnl: number; let dailyPnl: Array<{ date: string; pnl: number }>; if (effective === 'testnet') { - ({ totalValueKrw, realizedPnl, unrealizedPnl, dailyPnl } = testnetBreakdown); + ({ totalValueUsd, realizedPnl, unrealizedPnl, dailyPnl } = testnetBreakdown); } else if (effective === 'mainnet') { - ({ totalValueKrw, realizedPnl, unrealizedPnl, dailyPnl } = mainnetBreakdown); + ({ totalValueUsd, realizedPnl, unrealizedPnl, dailyPnl } = mainnetBreakdown); } else { - totalValueKrw = testnetBreakdown.totalValueKrw + mainnetBreakdown.totalValueKrw; + totalValueUsd = testnetBreakdown.totalValueUsd + mainnetBreakdown.totalValueUsd; realizedPnl = testnetBreakdown.realizedPnl + mainnetBreakdown.realizedPnl; unrealizedPnl = testnetBreakdown.unrealizedPnl + mainnetBreakdown.unrealizedPnl; const merged = new Map(); @@ -166,7 +169,7 @@ export class PortfolioService { return { network: effective, - totalValueKrw, + totalValueUsd, realizedPnl, unrealizedPnl, assets, diff --git a/apps/web/src/app/dashboard/page.tsx b/apps/web/src/app/dashboard/page.tsx index 66ef283..83abb98 100644 --- a/apps/web/src/app/dashboard/page.tsx +++ b/apps/web/src/app/dashboard/page.tsx @@ -7,11 +7,9 @@ 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 { PnlValue } from '@/components/shared/pnl-value'; +import { MoneyValue } from '@/components/shared/money-value'; +import { PnlMoney } from '@/components/shared/pnl-money'; import { closePosition, getDashboardSummary, type DashboardSummary } from '@/lib/api-client'; -import { useBaseCurrency } from '@/hooks/use-base-currency'; -import { useExchangeRate } from '@/hooks/use-exchange-rate'; -import { formatCurrency } from '@/lib/utils'; export default function DashboardPage() { const { data, isLoading } = useQuery({ @@ -66,14 +64,14 @@ function PnlSection({ pnl }: { pnl: DashboardSummary['pnl'] }) {

모의 (Testnet)

-

- +

+

실거래 (Mainnet)

-

- +

+

@@ -85,8 +83,6 @@ function PnlSection({ pnl }: { pnl: DashboardSummary['pnl'] }) { function OpenPositionsSection({ positions }: { positions: DashboardSummary['openPositions'] }) { const queryClient = useQueryClient(); - const { currency } = useBaseCurrency(); - const { krwPerUsd } = useExchangeRate(); const closeMut = useMutation({ mutationFn: (id: string) => closePosition(id), @@ -128,9 +124,6 @@ function OpenPositionsSection({ positions }: { positions: DashboardSummary['open {positions.map((p) => { const entry = p.entryPrice ? Number(p.entryPrice) : null; - const entryFmt = entry != null ? formatCurrency(entry, currency, krwPerUsd) : null; - const markFmt = - p.markPrice != null ? formatCurrency(p.markPrice, currency, krwPerUsd) : null; const network = p.exchangeKey?.network ?? 'mainnet'; return ( @@ -162,10 +155,14 @@ function OpenPositionsSection({ positions }: { positions: DashboardSummary['open {p.filledQuantity || p.quantity} - {entryFmt?.main ?? '-'} - {markFmt?.main ?? '-'} - - {p.unrealizedPnl != null ? : '-'} + + {entry != null ? : '-'} + + + {p.markPrice != null ? : '-'} + + + {p.mode === 'real' && ( diff --git a/apps/web/src/app/orders/[id]/page.tsx b/apps/web/src/app/orders/[id]/page.tsx index 82e1c34..0d0b372 100644 --- a/apps/web/src/app/orders/[id]/page.tsx +++ b/apps/web/src/app/orders/[id]/page.tsx @@ -10,17 +10,13 @@ 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 { PnlValue } from '@/components/shared/pnl-value'; -import { useBaseCurrency } from '@/hooks/use-base-currency'; -import { useExchangeRate } from '@/hooks/use-exchange-rate'; +import { MoneyValue } from '@/components/shared/money-value'; +import { PnlMoney } from '@/components/shared/pnl-money'; import { closePosition, getOrder } from '@/lib/api-client'; -import { formatCurrency } from '@/lib/utils'; export default function OrderDetailPage({ params }: { params: Promise<{ id: string }> }) { const { id } = use(params); const queryClient = useQueryClient(); - const { currency } = useBaseCurrency(); - const { krwPerUsd } = useExchangeRate(); const { data, isLoading, error } = useQuery({ queryKey: ['order', id], @@ -72,18 +68,6 @@ export default function OrderDetailPage({ params }: { params: Promise<{ id: stri const tpPrice = order.takeProfitPrice ? Number(order.takeProfitPrice) : null; const slPrice = order.stopLossPrice ? Number(order.stopLossPrice) : null; - const formatUsd = (n: number | null) => { - if (n == null) return '-'; - const { main, sub } = formatCurrency(n, currency, krwPerUsd); - return sub ? ( - - {main} {sub} - - ) : ( - {main} - ); - }; - return (
@@ -149,17 +133,14 @@ export default function OrderDetailPage({ params }: { params: Promise<{ id: stri label="수량" value={{order.filledQuantity || order.quantity}} /> - - - - + } /> + } /> + } /> + } /> {order.realizedPnl ? ( - } /> + } /> ) : ( - : -} - /> + } /> )} {order.closedAt && (

{t('totalValue')}

-

{formatKrw(data.totalValueKrw)}

+

+ +

{t('realizedPnl')}

- +

@@ -115,7 +117,7 @@ export default function PortfolioPage() {

{t('unrealizedPnl')}

- +

@@ -130,17 +132,15 @@ export default function PortfolioPage() {
{t('totalValue')} - - {formatKrw(data.byNetwork.testnet.totalValueKrw)} - +
{t('realizedPnl')} - +
{t('unrealizedPnl')} - +
@@ -153,17 +153,15 @@ export default function PortfolioPage() {
{t('totalValue')} - - {formatKrw(data.byNetwork.mainnet.totalValueKrw)} - +
{t('realizedPnl')} - +
{t('unrealizedPnl')} - +
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/lib/api-client.ts b/apps/web/src/lib/api-client.ts index 52dfa8e..d6c0bfe 100644 --- a/apps/web/src/lib/api-client.ts +++ b/apps/web/src/lib/api-client.ts @@ -336,12 +336,13 @@ export interface PortfolioAsset { 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 { - totalValueKrw: number; + totalValueUsd: number; realizedPnl: number; unrealizedPnl: number; dailyPnl: Array<{ date: string; pnl: number }>; @@ -349,7 +350,7 @@ export interface NetworkBreakdown { export interface PortfolioSummary { network: PortfolioNetwork; - totalValueKrw: number; + totalValueUsd: number; realizedPnl: number; unrealizedPnl: number; assets: PortfolioAsset[]; diff --git a/apps/web/src/mocks/data/portfolio.ts b/apps/web/src/mocks/data/portfolio.ts index 03cf8d9..22582fd 100644 --- a/apps/web/src/mocks/data/portfolio.ts +++ b/apps/web/src/mocks/data/portfolio.ts @@ -1,7 +1,7 @@ import type { PortfolioSummary } from '@/lib/api-client'; const emptyBreakdown = { - totalValueKrw: 0, + totalValueUsd: 0, realizedPnl: 0, unrealizedPnl: 0, dailyPnl: [] as Array<{ date: string; pnl: number }>, @@ -9,49 +9,49 @@ const emptyBreakdown = { export const demoPortfolio: PortfolioSummary = { network: 'all', - totalValueKrw: 15_420_000, - realizedPnl: 2_850_000, - unrealizedPnl: 680_000, + 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', @@ -60,26 +60,26 @@ export const demoPortfolio: PortfolioSummary = { 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, totalValueKrw: 1_140_000, unrealizedPnl: 40_000 }, + testnet: { ...emptyBreakdown, totalValueUsd: 850, unrealizedPnl: 25 }, mainnet: { ...emptyBreakdown, - totalValueKrw: 14_280_500, - realizedPnl: 2_850_000, - unrealizedPnl: 640_000, + totalValueUsd: 10_690, + realizedPnl: 2_120, + unrealizedPnl: 485, }, }, }; From 2a2a748d2cce6d6bcd79bccdddb374b0bb1a77ae Mon Sep 17 00:00:00 2001 From: fray-cloud Date: Sat, 2 May 2026 02:16:44 +0900 Subject: [PATCH 7/7] feat(llm-trade): leverage slider, TP/SL %, balance-aware bet input MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Three usability gaps reported on the LLM trade form: 1. TP/SL prices were just numbers — user couldn't tell at a glance how far they were from entry. Now each input shows raw price-distance % plus profit/ROE % (multiplied by leverage), with sign-corrected for short positions so a TP below entry on a short reads as +profit. 2. Leverage was a 1-20 number input that didn't match Binance's actual 1x-125x range or its preferred steps. Replaced with a 1-125 slider plus tick-buttons at 1/25/50/75/100/125 for one-click jumps. 3. Bet input was unbounded — user had no idea what their actual USDT balance was. Added a network toggle (testnet/mainnet), wires up the matching exchange key, fetches its balances via the existing /exchange-keys/:id/balances endpoint, displays free/total USDT, and adds 10/25/50/100% quick-fill buttons. Bet is clamped to free balance and the execute button is disabled when over. Backend: GetExchangeKeysHandler now selects `network` so the frontend can pick the right key per network without an extra round-trip. Tests still green (api 57, worker 13, web 43). Co-Authored-By: Claude Opus 4.7 (1M context) --- .../queries/get-exchange-keys.handler.ts | 1 + .../components/llm-trade/llm-trade-form.tsx | 227 +++++++++++++++--- apps/web/src/lib/api-client.ts | 1 + 3 files changed, 196 insertions(+), 33 deletions(-) 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 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) => { @@ -66,9 +107,30 @@ export function LlmTradeForm() { 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

@@ -111,7 +173,7 @@ export function LlmTradeForm() { ))}
-
+
-
- - setLeverage(Number(e.target.value))} - className="w-full h-9 px-3 rounded-md border border-input bg-transparent text-sm focus:outline-none focus:ring-2 focus:ring-ring" - /> +
+ + {/* Network toggle */} +
+ +
+ {(['testnet', 'mainnet'] as Network[]).map((n) => ( + + ))}
-
- - 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" - /> + {!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 && ( +

잔고를 초과합니다.

+ )}
+ @@ -173,7 +307,7 @@ export function LlmTradeForm() {

{signal.response.reasoning}

-
+
@@ -183,11 +317,22 @@ export function LlmTradeForm() { className="w-full h-9 px-3 rounded-md border border-input bg-muted/50 text-sm font-mono" />
-
- +
+ + = 0 ? 'text-green-500' : 'text-red-500'}`} + title="진입가 대비 가격 차이 (long 기준 양수=익절)" + > + {formatPct(tpPct)} + {tpProfitPct != null && ( + + ({tpProfitPct >= 0 ? '+' : ''} + {tpProfitPct.toFixed(2)}% 손익 · {(tpProfitPct * leverage).toFixed(2)}% ROE) + + )} + +
- +
+ + + {formatPct(slPct)} + {slLossPct != null && ( + + ({slLossPct >= 0 ? '+' : ''} + {slLossPct.toFixed(2)}% 손익 · {(slLossPct * leverage).toFixed(2)}% ROE) + + )} + +
- {executeMutation.data && (

diff --git a/apps/web/src/lib/api-client.ts b/apps/web/src/lib/api-client.ts index d6c0bfe..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; }