From d24a4cf5da8330f8ec7fa20f327c117bcf820727 Mon Sep 17 00:00:00 2001 From: fray-cloud Date: Fri, 1 May 2026 02:12:18 +0900 Subject: [PATCH] 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; +}