159 lines
4.8 KiB
TypeScript
159 lines
4.8 KiB
TypeScript
import { createLogger } from '@stock-bot/logger';
|
|
|
|
export interface Position {
|
|
symbol: string;
|
|
quantity: number;
|
|
averagePrice: number;
|
|
currentPrice: number;
|
|
marketValue: number;
|
|
unrealizedPnL: number;
|
|
unrealizedPnLPercent: number;
|
|
costBasis: number;
|
|
lastUpdated: Date;
|
|
}
|
|
|
|
export interface PortfolioSnapshot {
|
|
timestamp: Date;
|
|
totalValue: number;
|
|
cashBalance: number;
|
|
positions: Position[];
|
|
totalReturn: number;
|
|
totalReturnPercent: number;
|
|
dayChange: number;
|
|
dayChangePercent: number;
|
|
}
|
|
|
|
export interface Trade {
|
|
id: string;
|
|
symbol: string;
|
|
quantity: number;
|
|
price: number;
|
|
side: 'buy' | 'sell';
|
|
timestamp: Date;
|
|
commission: number;
|
|
}
|
|
|
|
export class PortfolioManager {
|
|
private logger = createLogger('PortfolioManager');
|
|
private positions: Map<string, Position> = new Map();
|
|
private trades: Trade[] = [];
|
|
private cashBalance: number = 100000; // Starting cash
|
|
|
|
constructor(initialCash: number = 100000) {
|
|
this.cashBalance = initialCash;
|
|
}
|
|
|
|
addTrade(trade: Trade): void {
|
|
this.trades.push(trade);
|
|
this.updatePosition(trade);
|
|
logger.info(`Trade added: ${trade.symbol} ${trade.side} ${trade.quantity} @ ${trade.price}`);
|
|
}
|
|
|
|
private updatePosition(trade: Trade): void {
|
|
const existing = this.positions.get(trade.symbol);
|
|
|
|
if (!existing) {
|
|
// New position
|
|
if (trade.side === 'buy') {
|
|
this.positions.set(trade.symbol, {
|
|
symbol: trade.symbol,
|
|
quantity: trade.quantity,
|
|
averagePrice: trade.price,
|
|
currentPrice: trade.price,
|
|
marketValue: trade.quantity * trade.price,
|
|
unrealizedPnL: 0,
|
|
unrealizedPnLPercent: 0,
|
|
costBasis: trade.quantity * trade.price + trade.commission,
|
|
lastUpdated: trade.timestamp
|
|
});
|
|
this.cashBalance -= (trade.quantity * trade.price + trade.commission);
|
|
}
|
|
return;
|
|
}
|
|
|
|
// Update existing position
|
|
if (trade.side === 'buy') {
|
|
const newQuantity = existing.quantity + trade.quantity;
|
|
const newCostBasis = existing.costBasis + (trade.quantity * trade.price) + trade.commission;
|
|
|
|
existing.quantity = newQuantity;
|
|
existing.averagePrice = (newCostBasis - this.getTotalCommissions(trade.symbol)) / newQuantity;
|
|
existing.costBasis = newCostBasis;
|
|
existing.lastUpdated = trade.timestamp;
|
|
|
|
this.cashBalance -= (trade.quantity * trade.price + trade.commission);
|
|
|
|
} else if (trade.side === 'sell') {
|
|
existing.quantity -= trade.quantity;
|
|
existing.lastUpdated = trade.timestamp;
|
|
|
|
const proceeds = trade.quantity * trade.price - trade.commission;
|
|
this.cashBalance += proceeds;
|
|
|
|
// Remove position if quantity is zero
|
|
if (existing.quantity <= 0) {
|
|
this.positions.delete(trade.symbol);
|
|
}
|
|
}
|
|
}
|
|
|
|
updatePrice(symbol: string, price: number): void {
|
|
const position = this.positions.get(symbol);
|
|
if (position) {
|
|
position.currentPrice = price;
|
|
position.marketValue = position.quantity * price;
|
|
position.unrealizedPnL = position.marketValue - (position.quantity * position.averagePrice);
|
|
position.unrealizedPnLPercent = position.unrealizedPnL / (position.quantity * position.averagePrice) * 100;
|
|
position.lastUpdated = new Date();
|
|
}
|
|
}
|
|
|
|
getPosition(symbol: string): Position | undefined {
|
|
return this.positions.get(symbol);
|
|
}
|
|
|
|
getAllPositions(): Position[] {
|
|
return Array.from(this.positions.values());
|
|
}
|
|
|
|
getPortfolioSnapshot(): PortfolioSnapshot {
|
|
const positions = this.getAllPositions();
|
|
const totalMarketValue = positions.reduce((sum, pos) => sum + pos.marketValue, 0);
|
|
const totalValue = totalMarketValue + this.cashBalance;
|
|
const totalUnrealizedPnL = positions.reduce((sum, pos) => sum + pos.unrealizedPnL, 0);
|
|
|
|
return {
|
|
timestamp: new Date(),
|
|
totalValue,
|
|
cashBalance: this.cashBalance,
|
|
positions,
|
|
totalReturn: totalUnrealizedPnL, // Simplified - should include realized gains
|
|
totalReturnPercent: (totalUnrealizedPnL / (totalValue - totalUnrealizedPnL)) * 100,
|
|
dayChange: 0, // TODO: Calculate from previous day
|
|
dayChangePercent: 0
|
|
};
|
|
}
|
|
|
|
getTrades(symbol?: string): Trade[] {
|
|
if (symbol) {
|
|
return this.trades.filter(trade => trade.symbol === symbol);
|
|
}
|
|
return this.trades;
|
|
}
|
|
|
|
private getTotalCommissions(symbol: string): number {
|
|
return this.trades
|
|
.filter(trade => trade.symbol === symbol)
|
|
.reduce((sum, trade) => sum + trade.commission, 0);
|
|
}
|
|
|
|
getCashBalance(): number {
|
|
return this.cashBalance;
|
|
}
|
|
|
|
getNetLiquidationValue(): number {
|
|
const positions = this.getAllPositions();
|
|
const positionValue = positions.reduce((sum, pos) => sum + pos.marketValue, 0);
|
|
return positionValue + this.cashBalance;
|
|
}
|
|
}
|