Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions apps/api-server/src/app.module.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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({
Expand Down Expand Up @@ -48,6 +49,7 @@ import { JwtAuthGuard } from './auth/guards/jwt-auth.guard';
NotificationsModule,
PortfolioModule,
ActivityModule,
DebugModule,
],
controllers: [AppController],
providers: [
Expand Down
114 changes: 114 additions & 0 deletions apps/api-server/src/debug/debug.controller.ts
Original file line number Diff line number Diff line change
@@ -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,
};
}
}
9 changes: 9 additions & 0 deletions apps/api-server/src/debug/debug.module.ts
Original file line number Diff line number Diff line change
@@ -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 {}
Original file line number Diff line number Diff line change
Expand Up @@ -26,10 +26,12 @@ export class CreateExchangeKeyHandler implements ICommandHandler<CreateExchangeK

async execute(command: CreateExchangeKeyCommand) {
const { userId, dto } = command;
const network = ((dto as { network?: string }).network ?? 'mainnet') as 'mainnet' | 'testnet';

const credentials: ExchangeCredentials = {
apiKey: dto.apiKey,
secretKey: dto.secretKey,
network,
};

// Validate key by calling getBalances
Expand All @@ -45,7 +47,7 @@ export class CreateExchangeKeyHandler implements ICommandHandler<CreateExchangeK

const exchangeKey = await this.prisma.exchangeKey.upsert({
where: {
userId_exchange: { userId, exchange: dto.exchange },
userId_exchange_network: { userId, exchange: dto.exchange, network },
},
update: {
apiKey: encrypt(dto.apiKey, this.masterKey),
Expand All @@ -54,6 +56,7 @@ export class CreateExchangeKeyHandler implements ICommandHandler<CreateExchangeK
create: {
userId,
exchange: dto.exchange,
network,
apiKey: encrypt(dto.apiKey, this.masterKey),
secretKey: encrypt(dto.secretKey, this.masterKey),
},
Expand Down
13 changes: 11 additions & 2 deletions apps/api-server/src/exchange-keys/dto/create-exchange-key.dto.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import { IsString, IsIn } from 'class-validator';
import { ApiProperty } from '@nestjs/swagger';
import { IsString, IsIn, IsOptional } from 'class-validator';
import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger';

export class CreateExchangeKeyDto {
@ApiProperty({
Expand All @@ -10,6 +10,15 @@ export class CreateExchangeKeyDto {
@IsIn(['binance'])
exchange!: string;

@ApiPropertyOptional({
description: '네트워크 (mainnet | testnet)',
example: 'testnet',
enum: ['mainnet', 'testnet'],
})
@IsOptional()
@IsIn(['mainnet', 'testnet'])
network?: string;

@ApiProperty({ description: '거래소 API 키', example: 'aB3dEfGhIjKlMnOpQrStUvWxYz012345' })
@IsString()
apiKey!: string;
Expand Down
59 changes: 39 additions & 20 deletions apps/api-server/src/orders/dto/create-order.dto.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import { IsString, IsIn, IsOptional } from 'class-validator';
import { IsString, IsIn, IsOptional, IsInt, Min, Max } from 'class-validator';
import { Type } from 'class-transformer';
import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger';

export class CreateOrderDto {
Expand All @@ -10,43 +11,61 @@ export class CreateOrderDto {
@IsIn(['binance'])
exchange!: string;

@ApiProperty({ description: '트레이딩 심볼', example: 'BTC/USDT' })
@ApiProperty({ description: '트레이딩 심볼 (USDT-M perpetual)', example: 'BTCUSDT' })
@IsString()
symbol!: string;

@ApiProperty({ description: '주문 방향', example: 'buy', enum: ['buy', 'sell'] })
@IsIn(['buy', 'sell'])
@ApiProperty({ description: '포지션 방향', example: 'long', enum: ['long', 'short'] })
@IsIn(['long', 'short'])
side!: string;

@ApiProperty({ description: '주문 유형', example: 'limit', enum: ['limit', 'market'] })
@IsIn(['limit', 'market'])
@ApiProperty({ description: '주문 유형', example: 'market', enum: ['market', 'limit'] })
@IsIn(['market', 'limit'])
type!: string;

@ApiProperty({ description: '주문 수량', example: '0.001' })
@ApiProperty({ description: '주문 수량 (base asset)', example: '0.001' })
@IsString()
quantity!: string;

@ApiPropertyOptional({
description: '지정가 (지정가 주문 시 필수)',
example: '65000.00',
})
@ApiPropertyOptional({ description: '지정가 (지정가 주문 시 필수)', example: '65000.00' })
@IsOptional()
@IsString()
price?: string;

@ApiProperty({
description: '거래 모드 (모의 또는 실전)',
example: 'paper',
enum: ['paper', 'real'],
@ApiProperty({ description: '레버리지', example: 5 })
@Type(() => Number)
@IsInt()
@Min(1)
@Max(20)
leverage!: number;

@ApiPropertyOptional({
description: '마진 타입',
example: 'ISOLATED',
enum: ['ISOLATED', 'CROSS'],
})
@IsIn(['paper', 'real'])
@IsOptional()
@IsIn(['ISOLATED', 'CROSS'])
marginType?: string;

@ApiPropertyOptional({ description: '익절 가격 (절대 USDT 가격)', example: '67000' })
@IsOptional()
@IsString()
takeProfitPrice?: string;

@ApiPropertyOptional({ description: '손절 가격 (절대 USDT 가격)', example: '63000' })
@IsOptional()
@IsString()
stopLossPrice?: string;

@ApiProperty({ description: '거래 모드', example: 'real', enum: ['real'] })
@IsIn(['real'])
mode!: string;

@ApiPropertyOptional({
description: '실전 거래용 거래소 API 키 ID',
@ApiProperty({
description: '실거래용 거래소 API 키 ID',
example: '550e8400-e29b-41d4-a716-446655440000',
})
@IsOptional()
@IsString()
exchangeKeyId?: string;
exchangeKeyId!: string;
}
21 changes: 17 additions & 4 deletions apps/api-server/src/orders/sagas/order-lifecycle-steps.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand All @@ -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;
}
Expand All @@ -40,6 +44,11 @@ export class CreateOrderStep implements SagaStep<OrderLifecycleContext> {
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,
},
});

Expand Down Expand Up @@ -74,10 +83,14 @@ export class PublishOrderRequestStep implements SagaStep<OrderLifecycleContext>
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!,
Expand All @@ -93,6 +106,6 @@ export class PublishOrderRequestStep implements SagaStep<OrderLifecycleContext>
}

async compensate(_context: OrderLifecycleContext): Promise<void> {
// noop — Kafka message already sent, worker will handle idempotency
// noop
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -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<OrderLifecycleContext>(this.prisma);
Expand Down
Loading