import { SIGNAL } from '../config/constants'; import { YahooFinanceClient } from '../clients/YahooFinanceClient'; import type { PortfolioHolding, Signal, ScreenerResult, AssetResult, AdviceRow, PositionCalc, AdviceOutput, } from '../types'; export class PortfolioAdvisor { constructor(private readonly client: YahooFinanceClient) {} async advise( holdings: PortfolioHolding[], screenedResults: ScreenerResult, ): Promise { const resultMap: Record = {}; for (const r of [...screenedResults.STOCK, ...screenedResults.ETF, ...screenedResults.BOND]) { const t = r.asset.ticker; resultMap[t] = r; resultMap[t.replace(/-/g, '.')] = r; resultMap[t.replace(/\./g, '-')] = r; } const cryptoPrices = await this._cryptoPrices(holdings.filter((h) => h.type === 'crypto')); return holdings.map((holding) => { const type = (holding.type ?? 'stock').toLowerCase(); const source = holding.source ?? '—'; const price: number | null = type === 'crypto' ? (cryptoPrices[holding.ticker.toUpperCase()] ?? null) : (resultMap[holding.ticker.toUpperCase()]?.asset?.currentPrice ?? null); return type === 'crypto' ? this._row(holding, price, source, '—', '—', '—', this._cryptoAdvice(holding, price)) : this._stockRow(holding, price, source, resultMap[holding.ticker.toUpperCase()]); }); } private _stockRow( holding: PortfolioHolding, price: number | null, source: string, result: AssetResult | undefined, ): AdviceRow { if (!result) { return this._row(holding, price, source, '—', '—', '—', { action: '⚪ Not screened', reason: 'No screener data available — Yahoo Finance may not support this ticker.', }); } return this._row( holding, price, source, result.signal, result.inflated.label, result.fundamental.label, this._advice(result.signal, holding, price), ); } private _row( holding: PortfolioHolding, currentPrice: number | null, source: string, signal: Signal | '—', inflated: string, fundamental: string, { action, reason }: AdviceOutput, ): AdviceRow { const { marketValue, totalCost, gainLossPct } = this._position(holding, currentPrice); return { ticker: holding.ticker, type: holding.type ?? 'stock', source, shares: holding.shares, costBasis: holding.costBasis, currentPrice, marketValue, totalCost, gainLossPct, signal, inflated, fundamental, advice: action, reason, }; } private _position(holding: PortfolioHolding, currentPrice: number | null): PositionCalc { return { totalCost: (holding.costBasis * holding.shares).toFixed(2), marketValue: currentPrice != null ? (currentPrice * holding.shares).toFixed(2) : null, gainLossPct: currentPrice != null && holding.costBasis > 0 ? (((currentPrice - holding.costBasis) / holding.costBasis) * 100).toFixed(1) : null, }; } private _cryptoAdvice(holding: PortfolioHolding, price: number | null): AdviceOutput { const { gainLossPct } = this._position(holding, price); const g = parseFloat(gainLossPct ?? 'NaN'); if (gainLossPct == null) return { action: '⚪ No price data', reason: 'Crypto — track price and manage risk manually.', }; if (g > 100) return { action: '🟠 Consider taking profits', reason: 'Up significantly — no fundamental analysis for crypto.', }; if (g < -30) return { action: '🔴 Review position', reason: 'Down significantly — no fundamental analysis for crypto.', }; return { action: '🟡 Hold', reason: 'Crypto — no fundamental analysis. Track price and manage risk manually.', }; } private _advice(signal: Signal, holding: PortfolioHolding, price: number | null): AdviceOutput { const { gainLossPct } = this._position(holding, price); const gain = parseFloat(gainLossPct ?? '0'); switch (signal) { case SIGNAL.STRONG_BUY: return { action: '🟢 Hold & Add', reason: 'Passes both analyses. Strong conviction.' }; case SIGNAL.MOMENTUM: return { action: '🟡 Hold', reason: gain > 30 ? 'Up on momentum — consider partial profit-taking.' : 'Set a stop-loss — not fundamentally justified.', }; case SIGNAL.SPECULATION: return { action: gain > 20 ? '🟠 Reduce Position' : '🟡 Hold (small size)', reason: gain > 20 ? 'In profit on speculation — take partial profits.' : 'Overvalued fundamentally. Keep position small.', }; case SIGNAL.NEUTRAL: return { action: '🟡 Hold', reason: 'No clear edge. Review on any catalyst.' }; case SIGNAL.AVOID: return { action: gain > 0 ? '🔴 Sell (Take Profits)' : '🔴 Sell (Cut Loss)', reason: gain > 0 ? "Fails both analyses — you're in profit, take it." : 'Fails both analyses — stop the loss from growing.', }; default: return { action: '⚪ Review', reason: 'Signal unclear.' }; } } private async _cryptoPrices( holdings: PortfolioHolding[], ): Promise> { const prices: Record = {}; for (const h of holdings) { try { const summary = await this.client.fetchSummary(h.ticker); prices[h.ticker.toUpperCase()] = summary?.price?.regularMarketPrice ?? null; } catch { prices[h.ticker.toUpperCase()] = null; } } return prices; } }