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>
776 lines
23 KiB
TypeScript
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();
|