35 KiB
35 KiB
ET-TRD-007: Especificación Técnica - Paper Trading Engine
Version: 1.0.0 Fecha: 2025-12-05 Estado: Pendiente Épica: OQI-003 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
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
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
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
// 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
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);
});
});
});