phase-2: extract shared utils
This commit is contained in:
@@ -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;
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user