phase-2: extract shared utils

This commit is contained in:
Sai Kiran Vella
2026-06-04 11:06:30 -04:00
parent d87f0b8427
commit dc7ee22135
49 changed files with 299 additions and 120 deletions
+167
View File
@@ -0,0 +1,167 @@
import { SIGNAL } from '../config/constants.js';
import { YahooClient } from '../market/YahooClient.js';
export class PortfolioAdvisor {
constructor() {
this.client = new YahooClient();
}
async advise(holdings, screenedResults) {
// Build result map keyed by both the Yahoo ticker (BRK-B) and the
// dot-notation variant (BRK.B) so lookups work regardless of format.
const resultMap = {};
for (const r of [
...(screenedResults.STOCK ?? []),
...(screenedResults.ETF ?? []),
...(screenedResults.BOND ?? []),
]) {
const t = r.asset.ticker;
resultMap[t] = r;
resultMap[t.replace(/-/g, '.')] = r; // BRK-B → BRK.B
resultMap[t.replace(/\./g, '-')] = r; // BRK.B → BRK-B
}
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 =
type === 'crypto'
? cryptoPrices[holding.ticker.toUpperCase()]
: (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()]);
});
}
_stockRow(holding, price, source, result) {
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),
);
}
_row(holding, currentPrice, source, signal, inflated, fundamental, { action, reason }) {
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,
};
}
_position(holding, currentPrice) {
const totalCost = (holding.costBasis * holding.shares).toFixed(2);
const marketValue = currentPrice != null ? (currentPrice * holding.shares).toFixed(2) : null;
const gainLossPct =
currentPrice != null && holding.costBasis > 0
? (((currentPrice - holding.costBasis) / holding.costBasis) * 100).toFixed(1)
: null;
return { totalCost, marketValue, gainLossPct };
}
_cryptoAdvice(holding, price) {
const { gainLossPct } = this._position(holding, price);
const g = parseFloat(gainLossPct);
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.',
};
}
_advice(signal, holding, price) {
const { gainLossPct } = this._position(holding, price);
const gain = parseFloat(gainLossPct);
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.' };
}
}
async _cryptoPrices(cryptoHoldings) {
const prices = {};
for (const h of cryptoHoldings) {
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;
}
}