186 lines
5.8 KiB
TypeScript
186 lines
5.8 KiB
TypeScript
import { SIGNAL } from '../config/constants';
|
|
import { YahooFinanceClient } from '../clients/YahooFinanceClient';
|
|
import type {
|
|
PortfolioHolding,
|
|
Signal,
|
|
ScreenerResult,
|
|
AssetResult,
|
|
AdviceRow,
|
|
PositionCalc,
|
|
AdviceOutput,
|
|
} from '../types';
|
|
|
|
export class PortfolioAdvisor {
|
|
private client: YahooFinanceClient;
|
|
|
|
constructor() {
|
|
this.client = new YahooFinanceClient();
|
|
}
|
|
|
|
async advise(
|
|
holdings: PortfolioHolding[],
|
|
screenedResults: ScreenerResult,
|
|
): Promise<AdviceRow[]> {
|
|
const resultMap: Record<string, AssetResult> = {};
|
|
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<Record<string, number | null>> {
|
|
const prices: Record<string, number | null> = {};
|
|
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;
|
|
}
|
|
}
|