stock-bot/apps/stock/orchestrator/src/paper/PaperTradingManager.ts

374 lines
No EOL
11 KiB
TypeScript

import { EventEmitter } from 'events';
import { IServiceContainer } from '@stock-bot/di';
import { OrderRequest, Position } from '../types';
import { StorageService } from '../services/StorageService';
import { MarketDataService } from '../services/MarketDataService';
import { ExecutionService } from '../services/ExecutionService';
interface VirtualAccount {
balance: number;
buyingPower: number;
positions: Map<string, VirtualPosition>;
orders: Map<string, VirtualOrder>;
trades: VirtualTrade[];
equity: number;
marginUsed: number;
}
interface VirtualPosition {
symbol: string;
quantity: number;
averagePrice: number;
marketValue: number;
unrealizedPnl: number;
realizedPnl: number;
}
interface VirtualOrder {
id: string;
symbol: string;
side: 'buy' | 'sell';
quantity: number;
orderType: string;
limitPrice?: number;
status: string;
submittedAt: Date;
}
interface VirtualTrade {
orderId: string;
symbol: string;
side: 'buy' | 'sell';
quantity: number;
price: number;
commission: number;
timestamp: Date;
pnl?: number;
}
export class PaperTradingManager extends EventEmitter {
private account: VirtualAccount;
private marketPrices = new Map<string, { bid: number; ask: number }>();
private readonly COMMISSION_RATE = 0.001; // 0.1%
private readonly MARGIN_REQUIREMENT = 0.25; // 25% margin requirement
private container: IServiceContainer;
constructor(
container: IServiceContainer,
private storageService: StorageService,
private marketDataService: MarketDataService,
private executionService: ExecutionService,
initialBalance: number = 100000
) {
super();
this.container = container;
this.account = {
balance: initialBalance,
buyingPower: initialBalance * (1 / this.MARGIN_REQUIREMENT),
positions: new Map(),
orders: new Map(),
trades: [],
equity: initialBalance,
marginUsed: 0
};
this.setupEventListeners();
}
private setupEventListeners(): void {
// Listen for market data updates to track prices
// In real implementation, would connect to market data service
}
updateMarketPrice(symbol: string, bid: number, ask: number): void {
this.marketPrices.set(symbol, { bid, ask });
// Update position values
const position = this.account.positions.get(symbol);
if (position) {
const midPrice = (bid + ask) / 2;
position.marketValue = position.quantity * midPrice;
position.unrealizedPnl = position.quantity * (midPrice - position.averagePrice);
}
// Update account equity
this.updateAccountEquity();
}
async executeOrder(order: OrderRequest): Promise<any> {
// Validate order
const validation = this.validateOrder(order);
if (!validation.valid) {
return {
status: 'rejected',
reason: validation.reason
};
}
// Check buying power
const requiredCapital = this.calculateRequiredCapital(order);
if (requiredCapital > this.account.buyingPower) {
return {
status: 'rejected',
reason: 'Insufficient buying power'
};
}
// Create virtual order
const virtualOrder: VirtualOrder = {
id: `paper_${Date.now()}`,
symbol: order.symbol,
side: order.side,
quantity: order.quantity,
orderType: order.orderType,
limitPrice: order.limitPrice,
status: 'pending',
submittedAt: new Date()
};
this.account.orders.set(virtualOrder.id, virtualOrder);
// Simulate order execution based on type
if (order.orderType === 'market') {
await this.executeMarketOrder(virtualOrder);
} else if (order.orderType === 'limit') {
// Limit orders would be checked periodically
virtualOrder.status = 'accepted';
}
return {
orderId: virtualOrder.id,
status: virtualOrder.status
};
}
private async executeMarketOrder(order: VirtualOrder): Promise<void> {
const marketPrice = this.marketPrices.get(order.symbol);
if (!marketPrice) {
order.status = 'rejected';
this.emit('orderUpdate', {
orderId: order.id,
status: 'rejected',
reason: 'No market data available'
});
return;
}
// Simulate realistic fill with slippage
const fillPrice = order.side === 'buy'
? marketPrice.ask * (1 + this.getSlippage(order.quantity))
: marketPrice.bid * (1 - this.getSlippage(order.quantity));
const commission = fillPrice * order.quantity * this.COMMISSION_RATE;
// Create trade
const trade: VirtualTrade = {
orderId: order.id,
symbol: order.symbol,
side: order.side,
quantity: order.quantity,
price: fillPrice,
commission,
timestamp: new Date()
};
// Update position
this.updatePosition(trade);
// Update account
const totalCost = (fillPrice * order.quantity) + commission;
if (order.side === 'buy') {
this.account.balance -= totalCost;
} else {
this.account.balance += (fillPrice * order.quantity) - commission;
}
// Record trade
this.account.trades.push(trade);
order.status = 'filled';
// Update buying power and margin
this.updateBuyingPower();
// Emit events
this.emit('fill', {
orderId: order.id,
symbol: order.symbol,
side: order.side,
quantity: order.quantity,
price: fillPrice,
commission,
timestamp: new Date()
});
this.emit('orderUpdate', {
orderId: order.id,
status: 'filled'
});
}
private updatePosition(trade: VirtualTrade): void {
const position = this.account.positions.get(trade.symbol) || {
symbol: trade.symbol,
quantity: 0,
averagePrice: 0,
marketValue: 0,
unrealizedPnl: 0,
realizedPnl: 0
};
const oldQuantity = position.quantity;
const oldAvgPrice = position.averagePrice;
if (trade.side === 'buy') {
// Adding to position
const newQuantity = oldQuantity + trade.quantity;
position.averagePrice = oldQuantity >= 0
? ((oldQuantity * oldAvgPrice) + (trade.quantity * trade.price)) / newQuantity
: trade.price;
position.quantity = newQuantity;
} else {
// Reducing position
const newQuantity = oldQuantity - trade.quantity;
if (oldQuantity > 0) {
// Realize P&L on closed portion
const realizedPnl = trade.quantity * (trade.price - oldAvgPrice) - trade.commission;
position.realizedPnl += realizedPnl;
trade.pnl = realizedPnl;
}
position.quantity = newQuantity;
if (Math.abs(newQuantity) < 0.0001) {
// Position closed
this.account.positions.delete(trade.symbol);
return;
}
}
this.account.positions.set(trade.symbol, position);
}
private validateOrder(order: OrderRequest): { valid: boolean; reason?: string } {
if (order.quantity <= 0) {
return { valid: false, reason: 'Invalid quantity' };
}
if (order.orderType === 'limit' && !order.limitPrice) {
return { valid: false, reason: 'Limit price required for limit orders' };
}
return { valid: true };
}
private calculateRequiredCapital(order: OrderRequest): number {
const marketPrice = this.marketPrices.get(order.symbol);
if (!marketPrice) return Infinity;
const price = order.side === 'buy' ? marketPrice.ask : marketPrice.bid;
const notional = price * order.quantity;
const commission = notional * this.COMMISSION_RATE;
const marginRequired = notional * this.MARGIN_REQUIREMENT;
return order.side === 'buy' ? marginRequired + commission : commission;
}
private updateBuyingPower(): void {
let totalMarginUsed = 0;
for (const position of this.account.positions.values()) {
totalMarginUsed += Math.abs(position.marketValue) * this.MARGIN_REQUIREMENT;
}
this.account.marginUsed = totalMarginUsed;
this.account.buyingPower = (this.account.equity - totalMarginUsed) / this.MARGIN_REQUIREMENT;
}
private updateAccountEquity(): void {
let totalUnrealizedPnl = 0;
for (const position of this.account.positions.values()) {
totalUnrealizedPnl += position.unrealizedPnl;
}
this.account.equity = this.account.balance + totalUnrealizedPnl;
}
private getSlippage(quantity: number): number {
// Simple slippage model - increases with order size
const baseSlippage = 0.0001; // 1 basis point
const sizeImpact = quantity / 10000; // Impact increases with size
return baseSlippage + (sizeImpact * 0.0001);
}
checkLimitOrders(): void {
// Called periodically to check if limit orders can be filled
for (const [orderId, order] of this.account.orders) {
if (order.status !== 'accepted' || order.orderType !== 'limit') continue;
const marketPrice = this.marketPrices.get(order.symbol);
if (!marketPrice) continue;
const canFill = order.side === 'buy'
? marketPrice.ask <= order.limitPrice!
: marketPrice.bid >= order.limitPrice!;
if (canFill) {
this.executeMarketOrder(order);
}
}
}
getAccount(): VirtualAccount {
return { ...this.account };
}
getPosition(symbol: string): VirtualPosition | undefined {
return this.account.positions.get(symbol);
}
getAllPositions(): VirtualPosition[] {
return Array.from(this.account.positions.values());
}
getPerformanceMetrics(): any {
const totalTrades = this.account.trades.length;
const winningTrades = this.account.trades.filter(t => t.pnl && t.pnl > 0);
const losingTrades = this.account.trades.filter(t => t.pnl && t.pnl < 0);
const totalPnl = this.account.trades.reduce((sum, t) => sum + (t.pnl || 0), 0);
const totalCommission = this.account.trades.reduce((sum, t) => sum + t.commission, 0);
return {
totalTrades,
winningTrades: winningTrades.length,
losingTrades: losingTrades.length,
winRate: totalTrades > 0 ? (winningTrades.length / totalTrades) * 100 : 0,
totalPnl,
totalCommission,
netPnl: totalPnl - totalCommission,
currentEquity: this.account.equity,
currentPositions: this.account.positions.size
};
}
reset(): void {
const initialBalance = this.account.balance +
Array.from(this.account.positions.values())
.reduce((sum, p) => sum + p.marketValue, 0);
this.account = {
balance: initialBalance,
buyingPower: initialBalance * (1 / this.MARGIN_REQUIREMENT),
positions: new Map(),
orders: new Map(),
trades: [],
equity: initialBalance,
marginUsed: 0
};
this.container.logger.info('Paper trading account reset');
}
}