374 lines
No EOL
11 KiB
TypeScript
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');
|
|
}
|
|
} |