trading-platform-backend-v2/src/modules/trading/services/paper-trading.service.ts
rckrdmrd e45591a0ef feat: Initial commit - Trading Platform Backend
NestJS backend with:
- Authentication (JWT)
- WebSocket real-time support
- ML integration services
- Payments module
- User management

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-18 04:28:47 -06:00

776 lines
23 KiB
TypeScript

/**
* Paper Trading Service
* Simulates trading with virtual funds using PostgreSQL
*/
import { db } from '../../../shared/database';
import { marketService } from './market.service';
import { logger } from '../../../shared/utils/logger';
// ============================================================================
// Types (matching trading.paper_trading_accounts and paper_trading_positions)
// ============================================================================
export type TradeDirection = 'long' | 'short';
export type PositionStatus = 'open' | 'closed' | 'pending';
export interface PaperAccount {
id: string;
userId: string;
name: string;
initialBalance: number;
currentBalance: number;
currency: string;
totalTrades: number;
winningTrades: number;
totalPnl: number;
maxDrawdown: number;
isActive: boolean;
createdAt: Date;
updatedAt: Date;
}
export interface PaperPosition {
id: string;
accountId: string;
userId: string;
symbol: string;
direction: TradeDirection;
lotSize: number;
entryPrice: number;
stopLoss?: number;
takeProfit?: number;
exitPrice?: number;
status: PositionStatus;
openedAt: Date;
closedAt?: Date;
closeReason?: string;
realizedPnl?: number;
createdAt: Date;
updatedAt: Date;
// Calculated fields
currentPrice?: number;
unrealizedPnl?: number;
unrealizedPnlPercent?: number;
}
export interface CreateAccountInput {
name?: string;
initialBalance?: number;
currency?: string;
}
export interface CreatePositionInput {
symbol: string;
direction: TradeDirection;
lotSize: number;
entryPrice?: number; // If not provided, uses market price
stopLoss?: number;
takeProfit?: number;
}
export interface ClosePositionInput {
exitPrice?: number; // If not provided, uses market price
closeReason?: string;
}
export interface AccountSummary {
account: PaperAccount;
openPositions: number;
totalEquity: number;
unrealizedPnl: number;
todayPnl: number;
winRate: number;
}
// ============================================================================
// Helper Functions
// ============================================================================
function mapAccount(row: Record<string, unknown>): PaperAccount {
return {
id: row.id as string,
userId: row.user_id as string,
name: row.name as string,
initialBalance: parseFloat(row.initial_balance as string),
currentBalance: parseFloat(row.current_balance as string),
currency: (row.currency as string).trim(),
totalTrades: row.total_trades as number,
winningTrades: row.winning_trades as number,
totalPnl: parseFloat(row.total_pnl as string),
maxDrawdown: parseFloat(row.max_drawdown as string),
isActive: row.is_active as boolean,
createdAt: new Date(row.created_at as string),
updatedAt: new Date(row.updated_at as string),
};
}
function mapPosition(row: Record<string, unknown>): PaperPosition {
return {
id: row.id as string,
accountId: row.account_id as string,
userId: row.user_id as string,
symbol: row.symbol as string,
direction: row.direction as TradeDirection,
lotSize: parseFloat(row.lot_size as string),
entryPrice: parseFloat(row.entry_price as string),
stopLoss: row.stop_loss ? parseFloat(row.stop_loss as string) : undefined,
takeProfit: row.take_profit ? parseFloat(row.take_profit as string) : undefined,
exitPrice: row.exit_price ? parseFloat(row.exit_price as string) : undefined,
status: row.status as PositionStatus,
openedAt: new Date(row.opened_at as string),
closedAt: row.closed_at ? new Date(row.closed_at as string) : undefined,
closeReason: row.close_reason as string | undefined,
realizedPnl: row.realized_pnl ? parseFloat(row.realized_pnl as string) : undefined,
createdAt: new Date(row.created_at as string),
updatedAt: new Date(row.updated_at as string),
};
}
// ============================================================================
// Paper Trading Service
// ============================================================================
class PaperTradingService {
// ==========================================================================
// Account Management
// ==========================================================================
/**
* Get or create default paper trading account for user
*/
async getOrCreateAccount(userId: string): Promise<PaperAccount> {
// Try to find existing active account
const existing = await db.query<Record<string, unknown>>(
`SELECT * FROM trading.paper_trading_accounts
WHERE user_id = $1 AND is_active = TRUE
ORDER BY created_at DESC LIMIT 1`,
[userId]
);
if (existing.rows.length > 0) {
return mapAccount(existing.rows[0]);
}
// Create new account with default $100,000
return this.createAccount(userId, {});
}
/**
* Create a new paper trading account
*/
async createAccount(userId: string, input: CreateAccountInput): Promise<PaperAccount> {
const result = await db.query<Record<string, unknown>>(
`INSERT INTO trading.paper_trading_accounts
(user_id, name, initial_balance, current_balance, currency)
VALUES ($1, $2, $3, $3, $4)
RETURNING *`,
[
userId,
input.name || 'Paper Account',
input.initialBalance || 100000,
input.currency || 'USD',
]
);
logger.info('[PaperTrading] Account created:', {
userId,
accountId: result.rows[0].id,
initialBalance: input.initialBalance || 100000,
});
return mapAccount(result.rows[0]);
}
/**
* Get account by ID
*/
async getAccount(accountId: string, userId: string): Promise<PaperAccount | null> {
const result = await db.query<Record<string, unknown>>(
`SELECT * FROM trading.paper_trading_accounts WHERE id = $1 AND user_id = $2`,
[accountId, userId]
);
if (result.rows.length === 0) return null;
return mapAccount(result.rows[0]);
}
/**
* Get all accounts for user
*/
async getUserAccounts(userId: string): Promise<PaperAccount[]> {
const result = await db.query<Record<string, unknown>>(
`SELECT * FROM trading.paper_trading_accounts
WHERE user_id = $1
ORDER BY created_at DESC`,
[userId]
);
return result.rows.map(mapAccount);
}
/**
* Reset account to initial state
*/
async resetAccount(accountId: string, userId: string): Promise<PaperAccount | null> {
const client = await db.getClient();
try {
await client.query('BEGIN');
// Get account
const accountResult = await client.query<Record<string, unknown>>(
`SELECT * FROM trading.paper_trading_accounts WHERE id = $1 AND user_id = $2`,
[accountId, userId]
);
if (accountResult.rows.length === 0) {
await client.query('ROLLBACK');
return null;
}
// Close all open positions
await client.query(
`UPDATE trading.paper_trading_positions
SET status = 'closed', closed_at = NOW(), close_reason = 'account_reset'
WHERE account_id = $1 AND status = 'open'`,
[accountId]
);
// Reset account
const result = await client.query<Record<string, unknown>>(
`UPDATE trading.paper_trading_accounts
SET current_balance = initial_balance,
total_trades = 0,
winning_trades = 0,
total_pnl = 0,
max_drawdown = 0,
updated_at = NOW()
WHERE id = $1
RETURNING *`,
[accountId]
);
await client.query('COMMIT');
logger.info('[PaperTrading] Account reset:', { accountId, userId });
return mapAccount(result.rows[0]);
} catch (error) {
await client.query('ROLLBACK');
throw error;
} finally {
client.release();
}
}
// ==========================================================================
// Position Management
// ==========================================================================
/**
* Open a new position
*/
async openPosition(userId: string, input: CreatePositionInput): Promise<PaperPosition> {
// Get or create account
const account = await this.getOrCreateAccount(userId);
// Get market price if not provided
let entryPrice = input.entryPrice;
if (!entryPrice) {
try {
const priceData = await marketService.getPrice(input.symbol);
entryPrice = priceData.price;
} catch {
throw new Error(`Could not get price for ${input.symbol}`);
}
}
// Calculate required margin (simplified: lot_size * entry_price)
const requiredMargin = input.lotSize * entryPrice;
if (requiredMargin > account.currentBalance) {
throw new Error(
`Insufficient balance. Required: ${requiredMargin.toFixed(2)}, Available: ${account.currentBalance.toFixed(2)}`
);
}
const client = await db.getClient();
try {
await client.query('BEGIN');
// Create position
const result = await client.query<Record<string, unknown>>(
`INSERT INTO trading.paper_trading_positions
(account_id, user_id, symbol, direction, lot_size, entry_price, stop_loss, take_profit)
VALUES ($1, $2, $3, $4, $5, $6, $7, $8)
RETURNING *`,
[
account.id,
userId,
input.symbol.toUpperCase(),
input.direction,
input.lotSize,
entryPrice,
input.stopLoss || null,
input.takeProfit || null,
]
);
// Deduct margin from balance
await client.query(
`UPDATE trading.paper_trading_accounts
SET current_balance = current_balance - $1,
updated_at = NOW()
WHERE id = $2`,
[requiredMargin, account.id]
);
await client.query('COMMIT');
logger.info('[PaperTrading] Position opened:', {
positionId: result.rows[0].id,
userId,
symbol: input.symbol,
direction: input.direction,
lotSize: input.lotSize,
entryPrice,
});
return mapPosition(result.rows[0]);
} catch (error) {
await client.query('ROLLBACK');
throw error;
} finally {
client.release();
}
}
/**
* Close a position
*/
async closePosition(
positionId: string,
userId: string,
input: ClosePositionInput = {}
): Promise<PaperPosition | null> {
const client = await db.getClient();
try {
await client.query('BEGIN');
// Get position
const positionResult = await client.query<Record<string, unknown>>(
`SELECT * FROM trading.paper_trading_positions
WHERE id = $1 AND user_id = $2 AND status = 'open'`,
[positionId, userId]
);
if (positionResult.rows.length === 0) {
await client.query('ROLLBACK');
return null;
}
const position = mapPosition(positionResult.rows[0]);
// Get exit price
let exitPrice = input.exitPrice;
if (!exitPrice) {
try {
const priceData = await marketService.getPrice(position.symbol);
exitPrice = priceData.price;
} catch {
throw new Error(`Could not get price for ${position.symbol}`);
}
}
// Calculate P&L
const priceDiff = exitPrice - position.entryPrice;
const realizedPnl =
position.direction === 'long'
? priceDiff * position.lotSize
: -priceDiff * position.lotSize;
// Update position
const result = await client.query<Record<string, unknown>>(
`UPDATE trading.paper_trading_positions
SET status = 'closed',
exit_price = $1,
closed_at = NOW(),
close_reason = $2,
realized_pnl = $3,
updated_at = NOW()
WHERE id = $4
RETURNING *`,
[exitPrice, input.closeReason || 'manual', realizedPnl, positionId]
);
// Update account balance and stats
const marginReturn = position.lotSize * position.entryPrice;
const isWin = realizedPnl > 0;
await client.query(
`UPDATE trading.paper_trading_accounts
SET current_balance = current_balance + $1 + $2,
total_trades = total_trades + 1,
winning_trades = winning_trades + $3,
total_pnl = total_pnl + $2,
updated_at = NOW()
WHERE id = $4`,
[marginReturn, realizedPnl, isWin ? 1 : 0, position.accountId]
);
// Update max drawdown if needed
await this.updateMaxDrawdown(client, position.accountId);
await client.query('COMMIT');
logger.info('[PaperTrading] Position closed:', {
positionId,
userId,
exitPrice,
realizedPnl,
});
return mapPosition(result.rows[0]);
} catch (error) {
await client.query('ROLLBACK');
throw error;
} finally {
client.release();
}
}
/**
* Get position by ID
*/
async getPosition(positionId: string, userId: string): Promise<PaperPosition | null> {
const result = await db.query<Record<string, unknown>>(
`SELECT * FROM trading.paper_trading_positions WHERE id = $1 AND user_id = $2`,
[positionId, userId]
);
if (result.rows.length === 0) return null;
const position = mapPosition(result.rows[0]);
// Add current price and unrealized P&L for open positions
if (position.status === 'open') {
await this.enrichPositionWithMarketData(position);
}
return position;
}
/**
* Get user positions
*/
async getPositions(
userId: string,
options: { accountId?: string; status?: PositionStatus; symbol?: string; limit?: number } = {}
): Promise<PaperPosition[]> {
const conditions: string[] = ['user_id = $1'];
const params: (string | number)[] = [userId];
let paramIndex = 2;
if (options.accountId) {
conditions.push(`account_id = $${paramIndex++}`);
params.push(options.accountId);
}
if (options.status) {
conditions.push(`status = $${paramIndex++}`);
params.push(options.status);
}
if (options.symbol) {
conditions.push(`symbol = $${paramIndex++}`);
params.push(options.symbol.toUpperCase());
}
let query = `SELECT * FROM trading.paper_trading_positions
WHERE ${conditions.join(' AND ')}
ORDER BY opened_at DESC`;
if (options.limit) {
query += ` LIMIT $${paramIndex}`;
params.push(options.limit);
}
const result = await db.query<Record<string, unknown>>(query, params);
const positions = result.rows.map(mapPosition);
// Enrich open positions with market data
for (const position of positions) {
if (position.status === 'open') {
await this.enrichPositionWithMarketData(position);
}
}
return positions;
}
/**
* Update position stop loss / take profit
*/
async updatePosition(
positionId: string,
userId: string,
updates: { stopLoss?: number; takeProfit?: number }
): Promise<PaperPosition | null> {
const fields: string[] = [];
const params: (string | number | null)[] = [];
let paramIndex = 1;
if (updates.stopLoss !== undefined) {
fields.push(`stop_loss = $${paramIndex++}`);
params.push(updates.stopLoss);
}
if (updates.takeProfit !== undefined) {
fields.push(`take_profit = $${paramIndex++}`);
params.push(updates.takeProfit);
}
if (fields.length === 0) {
return this.getPosition(positionId, userId);
}
fields.push(`updated_at = NOW()`);
params.push(positionId, userId);
const result = await db.query<Record<string, unknown>>(
`UPDATE trading.paper_trading_positions
SET ${fields.join(', ')}
WHERE id = $${paramIndex++} AND user_id = $${paramIndex} AND status = 'open'
RETURNING *`,
params
);
if (result.rows.length === 0) return null;
return mapPosition(result.rows[0]);
}
// ==========================================================================
// Account Summary & Analytics
// ==========================================================================
/**
* Get account summary with live data
*/
async getAccountSummary(userId: string, accountId?: string): Promise<AccountSummary | null> {
// Get account
let account: PaperAccount | null;
if (accountId) {
account = await this.getAccount(accountId, userId);
} else {
account = await this.getOrCreateAccount(userId);
}
if (!account) return null;
// Get open positions
const openPositions = await this.getPositions(userId, {
accountId: account.id,
status: 'open',
});
// Calculate unrealized P&L
let unrealizedPnl = 0;
for (const position of openPositions) {
unrealizedPnl += position.unrealizedPnl || 0;
}
// Calculate total equity
const totalEquity = account.currentBalance + unrealizedPnl;
// Calculate today's P&L
const todayStart = new Date();
todayStart.setHours(0, 0, 0, 0);
const todayResult = await db.query<{ today_pnl: string }>(
`SELECT COALESCE(SUM(realized_pnl), 0) as today_pnl
FROM trading.paper_trading_positions
WHERE account_id = $1 AND closed_at >= $2`,
[account.id, todayStart.toISOString()]
);
const todayPnl = parseFloat(todayResult.rows[0].today_pnl) + unrealizedPnl;
// Calculate win rate
const winRate = account.totalTrades > 0
? (account.winningTrades / account.totalTrades) * 100
: 0;
return {
account,
openPositions: openPositions.length,
totalEquity,
unrealizedPnl,
todayPnl,
winRate,
};
}
/**
* Get trade history
*/
async getTradeHistory(
userId: string,
options: {
accountId?: string;
symbol?: string;
startDate?: Date;
endDate?: Date;
limit?: number;
} = {}
): Promise<PaperPosition[]> {
const conditions: string[] = ['user_id = $1', "status = 'closed'"];
const params: (string | number)[] = [userId];
let paramIndex = 2;
if (options.accountId) {
conditions.push(`account_id = $${paramIndex++}`);
params.push(options.accountId);
}
if (options.symbol) {
conditions.push(`symbol = $${paramIndex++}`);
params.push(options.symbol.toUpperCase());
}
if (options.startDate) {
conditions.push(`closed_at >= $${paramIndex++}`);
params.push(options.startDate.toISOString());
}
if (options.endDate) {
conditions.push(`closed_at <= $${paramIndex++}`);
params.push(options.endDate.toISOString());
}
let query = `SELECT * FROM trading.paper_trading_positions
WHERE ${conditions.join(' AND ')}
ORDER BY closed_at DESC`;
if (options.limit) {
query += ` LIMIT $${paramIndex}`;
params.push(options.limit);
}
const result = await db.query<Record<string, unknown>>(query, params);
return result.rows.map(mapPosition);
}
/**
* Get performance statistics
*/
async getPerformanceStats(
userId: string,
accountId?: string
): Promise<{
totalTrades: number;
winningTrades: number;
losingTrades: number;
winRate: number;
totalPnl: number;
averageWin: number;
averageLoss: number;
largestWin: number;
largestLoss: number;
profitFactor: number;
}> {
const account = accountId
? await this.getAccount(accountId, userId)
: await this.getOrCreateAccount(userId);
if (!account) {
throw new Error('Account not found');
}
const result = await db.query<Record<string, string>>(
`SELECT
COUNT(*) as total_trades,
COUNT(*) FILTER (WHERE realized_pnl > 0) as winning_trades,
COUNT(*) FILTER (WHERE realized_pnl <= 0) as losing_trades,
COALESCE(SUM(realized_pnl), 0) as total_pnl,
COALESCE(AVG(realized_pnl) FILTER (WHERE realized_pnl > 0), 0) as avg_win,
COALESCE(AVG(realized_pnl) FILTER (WHERE realized_pnl <= 0), 0) as avg_loss,
COALESCE(MAX(realized_pnl), 0) as largest_win,
COALESCE(MIN(realized_pnl), 0) as largest_loss,
COALESCE(SUM(realized_pnl) FILTER (WHERE realized_pnl > 0), 0) as gross_profit,
COALESCE(ABS(SUM(realized_pnl) FILTER (WHERE realized_pnl < 0)), 1) as gross_loss
FROM trading.paper_trading_positions
WHERE account_id = $1 AND status = 'closed'`,
[account.id]
);
const stats = result.rows[0];
const totalTrades = parseInt(stats.total_trades, 10);
const winningTrades = parseInt(stats.winning_trades, 10);
const grossProfit = parseFloat(stats.gross_profit);
const grossLoss = parseFloat(stats.gross_loss);
return {
totalTrades,
winningTrades,
losingTrades: parseInt(stats.losing_trades, 10),
winRate: totalTrades > 0 ? (winningTrades / totalTrades) * 100 : 0,
totalPnl: parseFloat(stats.total_pnl),
averageWin: parseFloat(stats.avg_win),
averageLoss: parseFloat(stats.avg_loss),
largestWin: parseFloat(stats.largest_win),
largestLoss: parseFloat(stats.largest_loss),
profitFactor: grossLoss > 0 ? grossProfit / grossLoss : grossProfit > 0 ? Infinity : 0,
};
}
// ==========================================================================
// Private Helpers
// ==========================================================================
private async enrichPositionWithMarketData(position: PaperPosition): Promise<void> {
try {
const priceData = await marketService.getPrice(position.symbol);
position.currentPrice = priceData.price;
const priceDiff = priceData.price - position.entryPrice;
position.unrealizedPnl =
position.direction === 'long'
? priceDiff * position.lotSize
: -priceDiff * position.lotSize;
position.unrealizedPnlPercent =
((position.direction === 'long' ? priceDiff : -priceDiff) / position.entryPrice) * 100;
} catch {
// Keep position without market data if fetch fails
logger.debug('[PaperTrading] Could not get price for position:', {
positionId: position.id,
symbol: position.symbol,
});
}
}
private async updateMaxDrawdown(client: { query: typeof db.query }, accountId: string): Promise<void> {
// Calculate max drawdown from equity curve
const result = await client.query<Record<string, string>>(
`WITH equity_changes AS (
SELECT
current_balance as initial,
current_balance + COALESCE(
(SELECT SUM(realized_pnl) FROM trading.paper_trading_positions
WHERE account_id = $1 AND status = 'closed'), 0
) as current
FROM trading.paper_trading_accounts WHERE id = $1
)
SELECT
CASE WHEN initial > 0
THEN GREATEST(0, (initial - current) / initial * 100)
ELSE 0
END as drawdown
FROM equity_changes`,
[accountId]
);
if (result.rows.length > 0) {
const currentDrawdown = parseFloat(result.rows[0].drawdown);
await client.query(
`UPDATE trading.paper_trading_accounts
SET max_drawdown = GREATEST(max_drawdown, $1)
WHERE id = $2`,
[currentDrawdown, accountId]
);
}
}
}
// Export singleton instance
export const paperTradingService = new PaperTradingService();