trading-platform/docs/02-definicion-modulos/OQI-003-trading-charts/especificaciones/ET-TRD-007-paper-engine.md
rckrdmrd a7cca885f0 feat: Major platform documentation and architecture updates
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>
2026-01-07 05:33:35 -06:00

34 KiB

id title type status priority epic project version created_date updated_date
ET-TRD-007 Especificación Técnica - Paper Trading Engine Technical Specification Done Alta OQI-003 trading-platform 1.0.0 2025-12-05 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 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);
    });
  });
});

Referencias