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
5 changes: 5 additions & 0 deletions Dockerfile.base
Original file line number Diff line number Diff line change
@@ -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
Expand Down
4 changes: 4 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,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';

Expand Down Expand Up @@ -49,6 +51,8 @@ import { JwtAuthGuard } from './auth/guards/jwt-auth.guard';
NotificationsModule,
PortfolioModule,
ActivityModule,
ClaudeTokensModule,
LlmTradesModule,
DebugModule,
],
controllers: [AppController],
Expand Down
33 changes: 33 additions & 0 deletions apps/api-server/src/claude-tokens/claude-tokens.controller.ts
Original file line number Diff line number Diff line change
@@ -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);
}
}
12 changes: 12 additions & 0 deletions apps/api-server/src/claude-tokens/claude-tokens.module.ts
Original file line number Diff line number Diff line change
@@ -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 {}
49 changes: 49 additions & 0 deletions apps/api-server/src/claude-tokens/claude-tokens.service.ts
Original file line number Diff line number Diff line change
@@ -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<string>('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<void> {
await this.prisma.claudeToken.delete({ where: { userId } }).catch(() => {
throw new NotFoundException('Claude token not registered');
Comment on lines +37 to +38
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

suggestion (bug_risk): Catch-all delete error handling masks non-NotFound failures as 404s.

This catch converts every delete failure (including DB/connectivity issues) into a NotFoundException, which hides real errors and misleads clients. Instead, detect the specific "record not found" condition (e.g. Prisma P2025) and only map that to NotFoundException, letting other errors propagate unchanged.

Suggested implementation:

  async delete(userId: string): Promise<void> {
    try {
      await this.prisma.claudeToken.delete({ where: { userId } });
    } catch (error) {
      if (
        error instanceof Prisma.PrismaClientKnownRequestError &&
        error.code === 'P2025'
      ) {
        throw new NotFoundException('Claude token not registered');
      }
      throw error;
    }
    this.logger.log(`Claude token deleted for user ${userId}`);
  }

To compile correctly, ensure that Prisma is imported from @prisma/client at the top of this file. For example, if you currently have:

import { Injectable, Logger, NotFoundException } from '@nestjs/common';

you should also add:

import { Prisma } from '@prisma/client';

Adjust the import location to match your existing import style and ordering.

});
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<string> {
const row = await this.prisma.claudeToken.findUnique({ where: { userId } });
if (!row) throw new NotFoundException('Claude token not registered');
return decrypt(row.encryptedToken, this.masterKey);
}
}
12 changes: 12 additions & 0 deletions apps/api-server/src/claude-tokens/dto/save-claude-token.dto.ts
Original file line number Diff line number Diff line change
@@ -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;
}
16 changes: 14 additions & 2 deletions apps/api-server/src/debug/debug.controller.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,7 @@ export class GetBalancesHandler implements IQueryHandler<GetBalancesQuery> {
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);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,7 @@ export class GetOpenOrdersHandler implements IQueryHandler<GetOpenOrdersQuery> {
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);
Expand Down
47 changes: 47 additions & 0 deletions apps/api-server/src/llm-trades/dto/execute-trade.dto.ts
Original file line number Diff line number Diff line change
@@ -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;
}
26 changes: 26 additions & 0 deletions apps/api-server/src/llm-trades/dto/request-signal.dto.ts
Original file line number Diff line number Diff line change
@@ -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;
}
31 changes: 31 additions & 0 deletions apps/api-server/src/llm-trades/llm-trades.controller.ts
Original file line number Diff line number Diff line change
@@ -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);
}
}
13 changes: 13 additions & 0 deletions apps/api-server/src/llm-trades/llm-trades.module.ts
Original file line number Diff line number Diff line change
@@ -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 {}
Loading