Changes include: - Updated architecture documentation - Enhanced module definitions (OQI-001 to OQI-008) - ML integration documentation updates - Trading strategies documentation - Orchestration and inventory updates - Docker configuration updates 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
1218 lines
34 KiB
Markdown
1218 lines
34 KiB
Markdown
---
|
|
id: "ET-TRD-007"
|
|
title: "Especificación Técnica - Paper Trading Engine"
|
|
type: "Technical Specification"
|
|
status: "Done"
|
|
priority: "Alta"
|
|
epic: "OQI-003"
|
|
project: "trading-platform"
|
|
version: "1.0.0"
|
|
created_date: "2025-12-05"
|
|
updated_date: "2026-01-04"
|
|
---
|
|
|
|
# ET-TRD-007: Especificación Técnica - Paper Trading Engine
|
|
|
|
**Version:** 1.0.0
|
|
**Fecha:** 2025-12-05
|
|
**Estado:** Pendiente
|
|
**Épica:** [OQI-003](../_MAP.md)
|
|
**Requerimiento:** RF-TRD-007
|
|
|
|
---
|
|
|
|
## Resumen
|
|
|
|
Esta especificación detalla la implementación del motor de paper trading simulado, incluyendo ejecución de órdenes, gestión de posiciones, cálculo de PnL en tiempo real, simulación de slippage y comisiones realistas.
|
|
|
|
---
|
|
|
|
## Arquitectura
|
|
|
|
```
|
|
┌─────────────────────────────────────────────────────────────────────────┐
|
|
│ PAPER TRADING ENGINE │
|
|
│ │
|
|
│ ┌──────────────────────────────────────────────────────────────────┐ │
|
|
│ │ Order Execution Layer │ │
|
|
│ │ ┌────────────────┐ ┌──────────────────┐ ┌────────────────┐ │ │
|
|
│ │ │ Order Matcher │─▶│ Slippage │─▶│ Fill Generator │ │ │
|
|
│ │ │ │ │ Calculator │ │ │ │ │
|
|
│ │ └────────────────┘ └──────────────────┘ └────────────────┘ │ │
|
|
│ └──────────────────────────────────────────────────────────────────┘ │
|
|
│ │ │
|
|
│ ▼ │
|
|
│ ┌──────────────────────────────────────────────────────────────────┐ │
|
|
│ │ Position Management │ │
|
|
│ │ ┌────────────────┐ ┌──────────────────┐ ┌────────────────┐ │ │
|
|
│ │ │ Position │ │ PnL Calculator │ │ Risk │ │ │
|
|
│ │ │ Manager │ │ │ │ Manager │ │ │
|
|
│ │ └────────────────┘ └──────────────────┘ └────────────────┘ │ │
|
|
│ └──────────────────────────────────────────────────────────────────┘ │
|
|
│ │ │
|
|
│ ▼ │
|
|
│ ┌──────────────────────────────────────────────────────────────────┐ │
|
|
│ │ Balance Management │ │
|
|
│ │ ┌────────────────┐ ┌──────────────────┐ ┌────────────────┐ │ │
|
|
│ │ │ Balance │ │ Commission │ │ Margin │ │ │
|
|
│ │ │ Tracker │ │ Calculator │ │ Calculator │ │ │
|
|
│ │ └────────────────┘ └──────────────────┘ └────────────────┘ │ │
|
|
│ └──────────────────────────────────────────────────────────────────┘ │
|
|
└─────────────────────────────────────────────────────────────────────────┘
|
|
│
|
|
▼
|
|
┌─────────────────────────────────────────────────────────────────────────┐
|
|
│ DATABASE (PostgreSQL) │
|
|
│ ┌──────────────┐ ┌──────────────┐ ┌──────────────┐ │
|
|
│ │paper_orders │ │paper_positions│ │paper_balances│ │
|
|
│ └──────────────┘ └──────────────┘ └──────────────┘ │
|
|
└─────────────────────────────────────────────────────────────────────────┘
|
|
```
|
|
|
|
---
|
|
|
|
## Componentes Principales
|
|
|
|
### 1. Order Execution Service
|
|
|
|
**Ubicación:** `apps/backend/src/modules/trading/services/order-execution.service.ts`
|
|
|
|
```typescript
|
|
import { PaperOrder, OrderSide, OrderType, OrderStatus } from '../types';
|
|
import { BinanceService } from './binance.service';
|
|
import { BalanceService } from './balance.service';
|
|
import { PositionService } from './position.service';
|
|
import { db } from '@/db';
|
|
|
|
export interface OrderExecutionParams {
|
|
userId: string;
|
|
symbol: string;
|
|
side: OrderSide;
|
|
type: OrderType;
|
|
quantity: number;
|
|
price?: number;
|
|
stopPrice?: number;
|
|
stopLoss?: number;
|
|
takeProfit?: number;
|
|
}
|
|
|
|
export interface ExecutionResult {
|
|
order: PaperOrder;
|
|
fills: Trade[];
|
|
position?: PaperPosition;
|
|
}
|
|
|
|
export class OrderExecutionService {
|
|
private binanceService: BinanceService;
|
|
private balanceService: BalanceService;
|
|
private positionService: PositionService;
|
|
|
|
constructor() {
|
|
this.binanceService = new BinanceService();
|
|
this.balanceService = new BalanceService();
|
|
this.positionService = new PositionService();
|
|
}
|
|
|
|
/**
|
|
* Execute order placement
|
|
*/
|
|
async placeOrder(params: OrderExecutionParams): Promise<ExecutionResult> {
|
|
// Validar orden
|
|
await this.validateOrder(params);
|
|
|
|
// Crear orden en estado pending
|
|
const order = await this.createOrder(params);
|
|
|
|
try {
|
|
// Ejecutar orden según tipo
|
|
const result = await this.executeOrder(order);
|
|
|
|
return result;
|
|
} catch (error) {
|
|
// Marcar orden como rejected
|
|
await this.rejectOrder(order.id, error.message);
|
|
throw error;
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Validate order before execution
|
|
*/
|
|
private async validateOrder(params: OrderExecutionParams): Promise<void> {
|
|
const { userId, symbol, side, quantity, price, type } = params;
|
|
|
|
// Verificar símbolo válido
|
|
const exchangeInfo = await this.binanceService.getExchangeInfo(symbol);
|
|
if (!exchangeInfo.symbols.find((s) => s.symbol === symbol)) {
|
|
throw new Error('Invalid trading symbol');
|
|
}
|
|
|
|
// Verificar cantidad mínima
|
|
const symbolInfo = exchangeInfo.symbols[0];
|
|
const minQty = parseFloat(
|
|
symbolInfo.filters.find((f) => f.filterType === 'LOT_SIZE')?.minQty || '0'
|
|
);
|
|
|
|
if (quantity < minQty) {
|
|
throw new Error(`Minimum order quantity is ${minQty}`);
|
|
}
|
|
|
|
// Verificar balance disponible
|
|
const quoteAsset = symbolInfo.quoteAsset;
|
|
const balance = await this.balanceService.getBalance(userId, quoteAsset);
|
|
|
|
const requiredBalance = this.calculateRequiredBalance(
|
|
side,
|
|
quantity,
|
|
price || (await this.getCurrentPrice(symbol)),
|
|
type
|
|
);
|
|
|
|
if (balance.available < requiredBalance) {
|
|
throw new Error(
|
|
`Insufficient balance. Required: ${requiredBalance}, Available: ${balance.available}`
|
|
);
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Create order record
|
|
*/
|
|
private async createOrder(params: OrderExecutionParams): Promise<PaperOrder> {
|
|
const {
|
|
userId,
|
|
symbol,
|
|
side,
|
|
type,
|
|
quantity,
|
|
price,
|
|
stopPrice,
|
|
} = params;
|
|
|
|
const currentPrice = await this.getCurrentPrice(symbol);
|
|
const quoteQuantity = quantity * (price || currentPrice);
|
|
|
|
const result = await db.query(
|
|
`
|
|
INSERT INTO trading.paper_orders (
|
|
user_id, symbol, side, type, status,
|
|
quantity, remaining_quantity, price, stop_price,
|
|
quote_quantity, time_in_force, placed_at
|
|
)
|
|
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, NOW())
|
|
RETURNING *
|
|
`,
|
|
[
|
|
userId,
|
|
symbol,
|
|
side,
|
|
type,
|
|
'pending',
|
|
quantity,
|
|
quantity,
|
|
price,
|
|
stopPrice,
|
|
quoteQuantity,
|
|
'GTC',
|
|
]
|
|
);
|
|
|
|
return result.rows[0];
|
|
}
|
|
|
|
/**
|
|
* Execute order based on type
|
|
*/
|
|
private async executeOrder(order: PaperOrder): Promise<ExecutionResult> {
|
|
switch (order.type) {
|
|
case 'market':
|
|
return await this.executeMarketOrder(order);
|
|
|
|
case 'limit':
|
|
return await this.executeLimitOrder(order);
|
|
|
|
case 'stop_loss':
|
|
return await this.executeStopLossOrder(order);
|
|
|
|
default:
|
|
throw new Error(`Unsupported order type: ${order.type}`);
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Execute market order immediately
|
|
*/
|
|
private async executeMarketOrder(order: PaperOrder): Promise<ExecutionResult> {
|
|
const currentPrice = await this.getCurrentPrice(order.symbol);
|
|
|
|
// Simular slippage
|
|
const slippage = this.calculateSlippage(order.quantity, order.symbol);
|
|
const executionPrice = this.applySlippage(
|
|
currentPrice,
|
|
order.side,
|
|
slippage
|
|
);
|
|
|
|
// Calcular comisión
|
|
const commission = this.calculateCommission(
|
|
order.quantity,
|
|
executionPrice,
|
|
false // market orders are takers
|
|
);
|
|
|
|
// Crear trade (fill)
|
|
const trade = await this.createTrade({
|
|
orderId: order.id,
|
|
userId: order.userId,
|
|
symbol: order.symbol,
|
|
side: order.side,
|
|
price: executionPrice,
|
|
quantity: order.quantity,
|
|
commission,
|
|
marketPrice: currentPrice,
|
|
slippage,
|
|
isMaker: false,
|
|
});
|
|
|
|
// Actualizar orden a filled
|
|
await this.updateOrder(order.id, {
|
|
status: 'filled',
|
|
filledQuantity: order.quantity,
|
|
remainingQuantity: 0,
|
|
averageFillPrice: executionPrice,
|
|
filledQuoteQuantity: order.quantity * executionPrice,
|
|
commission,
|
|
filledAt: new Date(),
|
|
});
|
|
|
|
// Actualizar balance
|
|
await this.updateBalanceAfterTrade(
|
|
order.userId,
|
|
order.symbol,
|
|
order.side,
|
|
order.quantity,
|
|
executionPrice,
|
|
commission
|
|
);
|
|
|
|
// Crear o actualizar posición
|
|
const position = await this.positionService.processTradeForPosition(trade);
|
|
|
|
return {
|
|
order: { ...order, status: 'filled' },
|
|
fills: [trade],
|
|
position,
|
|
};
|
|
}
|
|
|
|
/**
|
|
* Execute limit order (check if price matches)
|
|
*/
|
|
private async executeLimitOrder(order: PaperOrder): Promise<ExecutionResult> {
|
|
const currentPrice = await this.getCurrentPrice(order.symbol);
|
|
|
|
// Verificar si el precio límite se cumple
|
|
const shouldFill =
|
|
(order.side === 'buy' && currentPrice <= order.price!) ||
|
|
(order.side === 'sell' && currentPrice >= order.price!);
|
|
|
|
if (!shouldFill) {
|
|
// Orden permanece abierta
|
|
await this.updateOrder(order.id, { status: 'open' });
|
|
|
|
// Bloquear balance
|
|
await this.balanceService.lockBalance(
|
|
order.userId,
|
|
this.getQuoteAsset(order.symbol),
|
|
order.quoteQuantity
|
|
);
|
|
|
|
return {
|
|
order: { ...order, status: 'open' },
|
|
fills: [],
|
|
};
|
|
}
|
|
|
|
// Ejecutar como si fuera market order pero sin slippage
|
|
const executionPrice = order.price!;
|
|
const commission = this.calculateCommission(
|
|
order.quantity,
|
|
executionPrice,
|
|
true // limit orders can be makers
|
|
);
|
|
|
|
const trade = await this.createTrade({
|
|
orderId: order.id,
|
|
userId: order.userId,
|
|
symbol: order.symbol,
|
|
side: order.side,
|
|
price: executionPrice,
|
|
quantity: order.quantity,
|
|
commission,
|
|
marketPrice: currentPrice,
|
|
slippage: 0,
|
|
isMaker: true,
|
|
});
|
|
|
|
await this.updateOrder(order.id, {
|
|
status: 'filled',
|
|
filledQuantity: order.quantity,
|
|
remainingQuantity: 0,
|
|
averageFillPrice: executionPrice,
|
|
filledQuoteQuantity: order.quantity * executionPrice,
|
|
commission,
|
|
filledAt: new Date(),
|
|
});
|
|
|
|
await this.updateBalanceAfterTrade(
|
|
order.userId,
|
|
order.symbol,
|
|
order.side,
|
|
order.quantity,
|
|
executionPrice,
|
|
commission
|
|
);
|
|
|
|
const position = await this.positionService.processTradeForPosition(trade);
|
|
|
|
return {
|
|
order: { ...order, status: 'filled' },
|
|
fills: [trade],
|
|
position,
|
|
};
|
|
}
|
|
|
|
/**
|
|
* Stop loss orders are monitored and executed when price reaches stop
|
|
*/
|
|
private async executeStopLossOrder(order: PaperOrder): Promise<ExecutionResult> {
|
|
// Similar to limit order but triggers at stop price
|
|
// This would be handled by a monitoring service that checks prices
|
|
await this.updateOrder(order.id, { status: 'open' });
|
|
|
|
return {
|
|
order: { ...order, status: 'open' },
|
|
fills: [],
|
|
};
|
|
}
|
|
|
|
/**
|
|
* Calculate slippage based on order size
|
|
*/
|
|
private calculateSlippage(quantity: number, symbol: string): number {
|
|
// Simplified slippage model
|
|
// Larger orders have more slippage
|
|
const baseSlippage = 0.0001; // 0.01%
|
|
const volumeFactor = Math.min(quantity / 10, 0.001); // Max 0.1%
|
|
|
|
return baseSlippage + volumeFactor;
|
|
}
|
|
|
|
/**
|
|
* Apply slippage to price
|
|
*/
|
|
private applySlippage(
|
|
price: number,
|
|
side: OrderSide,
|
|
slippage: number
|
|
): number {
|
|
if (side === 'buy') {
|
|
// Buy orders get worse price (higher)
|
|
return price * (1 + slippage);
|
|
} else {
|
|
// Sell orders get worse price (lower)
|
|
return price * (1 - slippage);
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Calculate trading commission
|
|
*/
|
|
private calculateCommission(
|
|
quantity: number,
|
|
price: number,
|
|
isMaker: boolean
|
|
): number {
|
|
const commissionRate = isMaker ? 0.001 : 0.001; // 0.1% for both (Binance standard)
|
|
return quantity * price * commissionRate;
|
|
}
|
|
|
|
/**
|
|
* Create trade record
|
|
*/
|
|
private async createTrade(params: {
|
|
orderId: string;
|
|
userId: string;
|
|
symbol: string;
|
|
side: OrderSide;
|
|
price: number;
|
|
quantity: number;
|
|
commission: number;
|
|
marketPrice: number;
|
|
slippage: number;
|
|
isMaker: boolean;
|
|
}): Promise<Trade> {
|
|
const result = await db.query(
|
|
`
|
|
INSERT INTO trading.paper_trades (
|
|
user_id, order_id, symbol, side, type,
|
|
price, quantity, quote_quantity,
|
|
commission, commission_asset,
|
|
market_price, slippage, is_maker, executed_at
|
|
)
|
|
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, NOW())
|
|
RETURNING *
|
|
`,
|
|
[
|
|
params.userId,
|
|
params.orderId,
|
|
params.symbol,
|
|
params.side,
|
|
'entry', // Will be determined by position service
|
|
params.price,
|
|
params.quantity,
|
|
params.quantity * params.price,
|
|
params.commission,
|
|
'USDT',
|
|
params.marketPrice,
|
|
params.slippage,
|
|
params.isMaker,
|
|
]
|
|
);
|
|
|
|
return result.rows[0];
|
|
}
|
|
|
|
/**
|
|
* Update balance after trade execution
|
|
*/
|
|
private async updateBalanceAfterTrade(
|
|
userId: string,
|
|
symbol: string,
|
|
side: OrderSide,
|
|
quantity: number,
|
|
price: number,
|
|
commission: number
|
|
): Promise<void> {
|
|
const [baseAsset, quoteAsset] = this.parseSymbol(symbol);
|
|
|
|
if (side === 'buy') {
|
|
// Deduct quote asset (e.g., USDT)
|
|
await this.balanceService.deduct(
|
|
userId,
|
|
quoteAsset,
|
|
quantity * price + commission
|
|
);
|
|
|
|
// Add base asset (e.g., BTC)
|
|
await this.balanceService.add(userId, baseAsset, quantity);
|
|
} else {
|
|
// Deduct base asset
|
|
await this.balanceService.deduct(userId, baseAsset, quantity);
|
|
|
|
// Add quote asset (minus commission)
|
|
await this.balanceService.add(
|
|
userId,
|
|
quoteAsset,
|
|
quantity * price - commission
|
|
);
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Get current market price
|
|
*/
|
|
private async getCurrentPrice(symbol: string): Promise<number> {
|
|
const price = await this.binanceService.getCurrentPrice(symbol);
|
|
return parseFloat(price);
|
|
}
|
|
|
|
/**
|
|
* Cancel open order
|
|
*/
|
|
async cancelOrder(orderId: string, userId: string): Promise<PaperOrder> {
|
|
const order = await this.getOrder(orderId);
|
|
|
|
if (order.userId !== userId) {
|
|
throw new Error('Unauthorized');
|
|
}
|
|
|
|
if (!['pending', 'open'].includes(order.status)) {
|
|
throw new Error('Order cannot be cancelled');
|
|
}
|
|
|
|
// Liberar balance bloqueado
|
|
if (order.status === 'open' && order.quoteQuantity > 0) {
|
|
const quoteAsset = this.getQuoteAsset(order.symbol);
|
|
await this.balanceService.unlockBalance(
|
|
userId,
|
|
quoteAsset,
|
|
order.remainingQuantity * (order.price || 0)
|
|
);
|
|
}
|
|
|
|
await this.updateOrder(orderId, {
|
|
status: 'cancelled',
|
|
cancelledAt: new Date(),
|
|
});
|
|
|
|
return { ...order, status: 'cancelled' };
|
|
}
|
|
|
|
// Helper methods
|
|
private parseSymbol(symbol: string): [string, string] {
|
|
// BTCUSDT -> [BTC, USDT]
|
|
// Simplified, should use exchange info
|
|
return [symbol.replace('USDT', ''), 'USDT'];
|
|
}
|
|
|
|
private getQuoteAsset(symbol: string): string {
|
|
return 'USDT'; // Simplified
|
|
}
|
|
|
|
private calculateRequiredBalance(
|
|
side: OrderSide,
|
|
quantity: number,
|
|
price: number,
|
|
type: OrderType
|
|
): number {
|
|
if (side === 'buy') {
|
|
return quantity * price * 1.001; // Include commission buffer
|
|
} else {
|
|
return 0; // For sell orders, we need base asset (not quote)
|
|
}
|
|
}
|
|
|
|
private async getOrder(orderId: string): Promise<PaperOrder> {
|
|
const result = await db.query(
|
|
'SELECT * FROM trading.paper_orders WHERE id = $1',
|
|
[orderId]
|
|
);
|
|
|
|
if (result.rows.length === 0) {
|
|
throw new Error('Order not found');
|
|
}
|
|
|
|
return result.rows[0];
|
|
}
|
|
|
|
private async updateOrder(
|
|
orderId: string,
|
|
updates: Partial<PaperOrder>
|
|
): Promise<void> {
|
|
const fields = Object.keys(updates);
|
|
const values = Object.values(updates);
|
|
|
|
const setClause = fields
|
|
.map((field, index) => `${field} = $${index + 2}`)
|
|
.join(', ');
|
|
|
|
await db.query(
|
|
`UPDATE trading.paper_orders SET ${setClause} WHERE id = $1`,
|
|
[orderId, ...values]
|
|
);
|
|
}
|
|
|
|
private async rejectOrder(orderId: string, reason: string): Promise<void> {
|
|
await db.query(
|
|
`
|
|
UPDATE trading.paper_orders
|
|
SET status = 'rejected', notes = $2, updated_at = NOW()
|
|
WHERE id = $1
|
|
`,
|
|
[orderId, reason]
|
|
);
|
|
}
|
|
}
|
|
```
|
|
|
|
---
|
|
|
|
### 2. Position Service
|
|
|
|
**Ubicación:** `apps/backend/src/modules/trading/services/position.service.ts`
|
|
|
|
```typescript
|
|
import { PaperPosition, Trade, PositionSide, PositionStatus } from '../types';
|
|
import { db } from '@/db';
|
|
|
|
export class PositionService {
|
|
/**
|
|
* Process trade and create/update position
|
|
*/
|
|
async processTradeForPosition(trade: Trade): Promise<PaperPosition> {
|
|
// Buscar posición abierta existente
|
|
const existingPosition = await this.getOpenPosition(
|
|
trade.userId,
|
|
trade.symbol
|
|
);
|
|
|
|
if (!existingPosition) {
|
|
// Crear nueva posición
|
|
return await this.createPosition(trade);
|
|
}
|
|
|
|
// Determinar si es aumento o reducción de posición
|
|
const isIncreasingPosition =
|
|
(existingPosition.side === 'long' && trade.side === 'buy') ||
|
|
(existingPosition.side === 'short' && trade.side === 'sell');
|
|
|
|
if (isIncreasingPosition) {
|
|
return await this.increasePosition(existingPosition, trade);
|
|
} else {
|
|
return await this.reducePosition(existingPosition, trade);
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Create new position
|
|
*/
|
|
private async createPosition(trade: Trade): Promise<PaperPosition> {
|
|
const side: PositionSide = trade.side === 'buy' ? 'long' : 'short';
|
|
|
|
const result = await db.query(
|
|
`
|
|
INSERT INTO trading.paper_positions (
|
|
user_id, symbol, side, status,
|
|
entry_price, entry_quantity, entry_value,
|
|
entry_order_id, current_quantity, average_entry_price,
|
|
total_commission, opened_at
|
|
)
|
|
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, NOW())
|
|
RETURNING *
|
|
`,
|
|
[
|
|
trade.userId,
|
|
trade.symbol,
|
|
side,
|
|
'open',
|
|
trade.price,
|
|
trade.quantity,
|
|
trade.quantity * trade.price,
|
|
trade.orderId,
|
|
trade.quantity,
|
|
trade.price,
|
|
trade.commission,
|
|
]
|
|
);
|
|
|
|
// Actualizar tipo de trade
|
|
await this.updateTradeType(trade.id, 'entry', result.rows[0].id);
|
|
|
|
return result.rows[0];
|
|
}
|
|
|
|
/**
|
|
* Increase existing position
|
|
*/
|
|
private async increasePosition(
|
|
position: PaperPosition,
|
|
trade: Trade
|
|
): Promise<PaperPosition> {
|
|
// Calcular nuevo precio promedio
|
|
const totalValue =
|
|
position.currentQuantity * position.averageEntryPrice +
|
|
trade.quantity * trade.price;
|
|
|
|
const newQuantity = position.currentQuantity + trade.quantity;
|
|
const newAveragePrice = totalValue / newQuantity;
|
|
|
|
await db.query(
|
|
`
|
|
UPDATE trading.paper_positions
|
|
SET
|
|
current_quantity = $2,
|
|
average_entry_price = $3,
|
|
total_commission = total_commission + $4,
|
|
updated_at = NOW()
|
|
WHERE id = $1
|
|
`,
|
|
[position.id, newQuantity, newAveragePrice, trade.commission]
|
|
);
|
|
|
|
await this.updateTradeType(trade.id, 'entry', position.id);
|
|
|
|
return { ...position, currentQuantity: newQuantity, averageEntryPrice: newAveragePrice };
|
|
}
|
|
|
|
/**
|
|
* Reduce or close position
|
|
*/
|
|
private async reducePosition(
|
|
position: PaperPosition,
|
|
trade: Trade
|
|
): Promise<PaperPosition> {
|
|
const quantityReduced = Math.min(trade.quantity, position.currentQuantity);
|
|
const newQuantity = position.currentQuantity - quantityReduced;
|
|
|
|
// Calcular PnL realizado
|
|
const realizedPnl = this.calculateRealizedPnL(
|
|
position.side,
|
|
position.averageEntryPrice,
|
|
trade.price,
|
|
quantityReduced
|
|
);
|
|
|
|
if (newQuantity === 0) {
|
|
// Cerrar posición completamente
|
|
await db.query(
|
|
`
|
|
UPDATE trading.paper_positions
|
|
SET
|
|
status = 'closed',
|
|
current_quantity = 0,
|
|
exit_price = $2,
|
|
exit_quantity = $3,
|
|
exit_value = $4,
|
|
exit_order_id = $5,
|
|
realized_pnl = realized_pnl + $6,
|
|
total_pnl = realized_pnl + $6,
|
|
total_commission = total_commission + $7,
|
|
closed_at = NOW(),
|
|
updated_at = NOW()
|
|
WHERE id = $1
|
|
`,
|
|
[
|
|
position.id,
|
|
trade.price,
|
|
quantityReduced,
|
|
quantityReduced * trade.price,
|
|
trade.orderId,
|
|
realizedPnl,
|
|
trade.commission,
|
|
]
|
|
);
|
|
|
|
await this.updateTradeType(trade.id, 'exit', position.id);
|
|
} else {
|
|
// Reducir posición parcialmente
|
|
await db.query(
|
|
`
|
|
UPDATE trading.paper_positions
|
|
SET
|
|
current_quantity = $2,
|
|
realized_pnl = realized_pnl + $3,
|
|
total_commission = total_commission + $4,
|
|
updated_at = NOW()
|
|
WHERE id = $1
|
|
`,
|
|
[position.id, newQuantity, realizedPnl, trade.commission]
|
|
);
|
|
|
|
await this.updateTradeType(trade.id, 'partial', position.id);
|
|
}
|
|
|
|
return {
|
|
...position,
|
|
currentQuantity: newQuantity,
|
|
realizedPnl: position.realizedPnl + realizedPnl,
|
|
};
|
|
}
|
|
|
|
/**
|
|
* Calculate realized PnL
|
|
*/
|
|
private calculateRealizedPnL(
|
|
side: PositionSide,
|
|
entryPrice: number,
|
|
exitPrice: number,
|
|
quantity: number
|
|
): number {
|
|
if (side === 'long') {
|
|
return (exitPrice - entryPrice) * quantity;
|
|
} else {
|
|
return (entryPrice - exitPrice) * quantity;
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Update unrealized PnL for all open positions
|
|
*/
|
|
async updateUnrealizedPnL(
|
|
userId: string,
|
|
symbol: string,
|
|
currentPrice: number
|
|
): Promise<void> {
|
|
await db.query(
|
|
`
|
|
UPDATE trading.paper_positions
|
|
SET
|
|
unrealized_pnl = CASE
|
|
WHEN side = 'long' THEN (${currentPrice} - average_entry_price) * current_quantity
|
|
ELSE (average_entry_price - ${currentPrice}) * current_quantity
|
|
END,
|
|
total_pnl = realized_pnl + CASE
|
|
WHEN side = 'long' THEN (${currentPrice} - average_entry_price) * current_quantity
|
|
ELSE (average_entry_price - ${currentPrice}) * current_quantity
|
|
END,
|
|
pnl_percentage = (
|
|
(realized_pnl + CASE
|
|
WHEN side = 'long' THEN (${currentPrice} - average_entry_price) * current_quantity
|
|
ELSE (average_entry_price - ${currentPrice}) * current_quantity
|
|
END) / entry_value
|
|
) * 100,
|
|
updated_at = NOW()
|
|
WHERE user_id = $1 AND symbol = $2 AND status = 'open'
|
|
`,
|
|
[userId, symbol]
|
|
);
|
|
}
|
|
|
|
/**
|
|
* Get open position for symbol
|
|
*/
|
|
private async getOpenPosition(
|
|
userId: string,
|
|
symbol: string
|
|
): Promise<PaperPosition | null> {
|
|
const result = await db.query(
|
|
`
|
|
SELECT * FROM trading.paper_positions
|
|
WHERE user_id = $1 AND symbol = $2 AND status = 'open'
|
|
LIMIT 1
|
|
`,
|
|
[userId, symbol]
|
|
);
|
|
|
|
return result.rows[0] || null;
|
|
}
|
|
|
|
/**
|
|
* Update trade type and link to position
|
|
*/
|
|
private async updateTradeType(
|
|
tradeId: string,
|
|
type: 'entry' | 'exit' | 'partial',
|
|
positionId: string
|
|
): Promise<void> {
|
|
await db.query(
|
|
'UPDATE trading.paper_trades SET type = $2, position_id = $3 WHERE id = $1',
|
|
[tradeId, type, positionId]
|
|
);
|
|
}
|
|
}
|
|
```
|
|
|
|
---
|
|
|
|
### 3. Balance Service
|
|
|
|
**Ubicación:** `apps/backend/src/modules/trading/services/balance.service.ts`
|
|
|
|
```typescript
|
|
import { PaperBalance } from '../types';
|
|
import { db } from '@/db';
|
|
|
|
export class BalanceService {
|
|
/**
|
|
* Get balance for specific asset
|
|
*/
|
|
async getBalance(userId: string, asset: string): Promise<PaperBalance> {
|
|
const result = await db.query(
|
|
'SELECT * FROM trading.paper_balances WHERE user_id = $1 AND asset = $2',
|
|
[userId, asset]
|
|
);
|
|
|
|
if (result.rows.length === 0) {
|
|
// Create initial balance if doesn't exist
|
|
return await this.createBalance(userId, asset, 0);
|
|
}
|
|
|
|
return result.rows[0];
|
|
}
|
|
|
|
/**
|
|
* Add to balance
|
|
*/
|
|
async add(userId: string, asset: string, amount: number): Promise<void> {
|
|
await db.query(
|
|
`
|
|
INSERT INTO trading.paper_balances (user_id, asset, total, available)
|
|
VALUES ($1, $2, $3, $3)
|
|
ON CONFLICT (user_id, asset)
|
|
DO UPDATE SET
|
|
total = trading.paper_balances.total + $3,
|
|
available = trading.paper_balances.available + $3,
|
|
updated_at = NOW()
|
|
`,
|
|
[userId, asset, amount]
|
|
);
|
|
}
|
|
|
|
/**
|
|
* Deduct from balance
|
|
*/
|
|
async deduct(userId: string, asset: string, amount: number): Promise<void> {
|
|
const result = await db.query(
|
|
`
|
|
UPDATE trading.paper_balances
|
|
SET
|
|
total = total - $3,
|
|
available = available - $3,
|
|
updated_at = NOW()
|
|
WHERE user_id = $1 AND asset = $2 AND available >= $3
|
|
RETURNING *
|
|
`,
|
|
[userId, asset, amount]
|
|
);
|
|
|
|
if (result.rows.length === 0) {
|
|
throw new Error('Insufficient balance');
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Lock balance (for open orders)
|
|
*/
|
|
async lockBalance(
|
|
userId: string,
|
|
asset: string,
|
|
amount: number
|
|
): Promise<void> {
|
|
const result = await db.query(
|
|
`
|
|
UPDATE trading.paper_balances
|
|
SET
|
|
available = available - $3,
|
|
locked = locked + $3,
|
|
updated_at = NOW()
|
|
WHERE user_id = $1 AND asset = $2 AND available >= $3
|
|
RETURNING *
|
|
`,
|
|
[userId, asset, amount]
|
|
);
|
|
|
|
if (result.rows.length === 0) {
|
|
throw new Error('Insufficient available balance');
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Unlock balance (when order cancelled)
|
|
*/
|
|
async unlockBalance(
|
|
userId: string,
|
|
asset: string,
|
|
amount: number
|
|
): Promise<void> {
|
|
await db.query(
|
|
`
|
|
UPDATE trading.paper_balances
|
|
SET
|
|
available = available + $3,
|
|
locked = locked - $3,
|
|
updated_at = NOW()
|
|
WHERE user_id = $1 AND asset = $2
|
|
`,
|
|
[userId, asset, amount]
|
|
);
|
|
}
|
|
|
|
/**
|
|
* Reset all balances to initial state
|
|
*/
|
|
async resetBalances(
|
|
userId: string,
|
|
initialAmount: number = 10000
|
|
): Promise<void> {
|
|
// Delete all balances
|
|
await db.query('DELETE FROM trading.paper_balances WHERE user_id = $1', [
|
|
userId,
|
|
]);
|
|
|
|
// Create initial USDT balance
|
|
await this.createBalance(userId, 'USDT', initialAmount);
|
|
}
|
|
|
|
private async createBalance(
|
|
userId: string,
|
|
asset: string,
|
|
amount: number
|
|
): Promise<PaperBalance> {
|
|
const result = await db.query(
|
|
`
|
|
INSERT INTO trading.paper_balances (user_id, asset, total, available, locked)
|
|
VALUES ($1, $2, $3, $3, 0)
|
|
RETURNING *
|
|
`,
|
|
[userId, asset, amount]
|
|
);
|
|
|
|
return result.rows[0];
|
|
}
|
|
}
|
|
```
|
|
|
|
---
|
|
|
|
## Configuración
|
|
|
|
```typescript
|
|
// config/trading.config.ts
|
|
|
|
export const tradingConfig = {
|
|
// Comisiones
|
|
commission: {
|
|
maker: 0.001, // 0.1%
|
|
taker: 0.001, // 0.1%
|
|
},
|
|
|
|
// Slippage simulation
|
|
slippage: {
|
|
base: 0.0001, // 0.01% base slippage
|
|
maxVolumeFactor: 0.001, // Additional 0.1% for large orders
|
|
},
|
|
|
|
// Balance inicial
|
|
initialBalance: {
|
|
USDT: 10000,
|
|
},
|
|
|
|
// Límites de orden
|
|
orderLimits: {
|
|
minOrderValue: 10, // USDT
|
|
maxOrderValue: 100000, // USDT
|
|
},
|
|
|
|
// Ejecución de órdenes
|
|
execution: {
|
|
delayMs: 100, // Simular delay de red
|
|
},
|
|
};
|
|
```
|
|
|
|
---
|
|
|
|
## Testing
|
|
|
|
```typescript
|
|
describe('OrderExecutionService', () => {
|
|
let service: OrderExecutionService;
|
|
let userId: string;
|
|
|
|
beforeEach(async () => {
|
|
service = new OrderExecutionService();
|
|
userId = await createTestUser();
|
|
await initializeBalance(userId, 10000);
|
|
});
|
|
|
|
describe('Market Orders', () => {
|
|
it('should execute buy market order', async () => {
|
|
const result = await service.placeOrder({
|
|
userId,
|
|
symbol: 'BTCUSDT',
|
|
side: 'buy',
|
|
type: 'market',
|
|
quantity: 0.1,
|
|
});
|
|
|
|
expect(result.order.status).toBe('filled');
|
|
expect(result.fills.length).toBe(1);
|
|
expect(result.position).toBeDefined();
|
|
expect(result.position.side).toBe('long');
|
|
});
|
|
|
|
it('should apply slippage to market orders', async () => {
|
|
// Mock current price at 50000
|
|
jest.spyOn(service as any, 'getCurrentPrice').mockResolvedValue(50000);
|
|
|
|
const result = await service.placeOrder({
|
|
userId,
|
|
symbol: 'BTCUSDT',
|
|
side: 'buy',
|
|
type: 'market',
|
|
quantity: 0.1,
|
|
});
|
|
|
|
expect(result.fills[0].price).toBeGreaterThan(50000);
|
|
expect(result.fills[0].slippage).toBeGreaterThan(0);
|
|
});
|
|
|
|
it('should reject order with insufficient balance', async () => {
|
|
await expect(
|
|
service.placeOrder({
|
|
userId,
|
|
symbol: 'BTCUSDT',
|
|
side: 'buy',
|
|
type: 'market',
|
|
quantity: 10, // Too large
|
|
})
|
|
).rejects.toThrow('Insufficient balance');
|
|
});
|
|
});
|
|
|
|
describe('Position Management', () => {
|
|
it('should create position on first buy', async () => {
|
|
const result = await service.placeOrder({
|
|
userId,
|
|
symbol: 'BTCUSDT',
|
|
side: 'buy',
|
|
type: 'market',
|
|
quantity: 0.1,
|
|
});
|
|
|
|
expect(result.position.currentQuantity).toBe(0.1);
|
|
expect(result.position.side).toBe('long');
|
|
});
|
|
|
|
it('should average entry price when adding to position', async () => {
|
|
// First buy at 50000
|
|
await service.placeOrder({
|
|
userId,
|
|
symbol: 'BTCUSDT',
|
|
side: 'buy',
|
|
type: 'market',
|
|
quantity: 0.1,
|
|
});
|
|
|
|
// Second buy at 51000
|
|
jest.spyOn(service as any, 'getCurrentPrice').mockResolvedValue(51000);
|
|
|
|
const result = await service.placeOrder({
|
|
userId,
|
|
symbol: 'BTCUSDT',
|
|
side: 'buy',
|
|
type: 'market',
|
|
quantity: 0.1,
|
|
});
|
|
|
|
expect(result.position.currentQuantity).toBe(0.2);
|
|
expect(result.position.averageEntryPrice).toBeCloseTo(50500, 0);
|
|
});
|
|
|
|
it('should calculate realized PnL when closing position', async () => {
|
|
// Buy at 50000
|
|
await service.placeOrder({
|
|
userId,
|
|
symbol: 'BTCUSDT',
|
|
side: 'buy',
|
|
type: 'market',
|
|
quantity: 0.1,
|
|
});
|
|
|
|
// Sell at 52000
|
|
jest.spyOn(service as any, 'getCurrentPrice').mockResolvedValue(52000);
|
|
|
|
const result = await service.placeOrder({
|
|
userId,
|
|
symbol: 'BTCUSDT',
|
|
side: 'sell',
|
|
type: 'market',
|
|
quantity: 0.1,
|
|
});
|
|
|
|
expect(result.position.status).toBe('closed');
|
|
expect(result.position.realizedPnl).toBeGreaterThan(0);
|
|
});
|
|
});
|
|
});
|
|
```
|
|
|
|
---
|
|
|
|
## Referencias
|
|
|
|
- [Order Matching Algorithms](https://en.wikipedia.org/wiki/Order_matching_system)
|
|
- [Position Sizing and Risk Management](https://www.investopedia.com/articles/trading/09/determine-position-size.asp)
|
|
- [Market Microstructure](https://www.investopedia.com/terms/m/microstructure.asp)
|