phase-6: typescript introduction

This commit is contained in:
Kazuma
2026-06-04 22:16:48 -04:00
parent 16bd95aa85
commit 69d13c3dbe
69 changed files with 2323 additions and 1036 deletions
+205
View File
@@ -0,0 +1,205 @@
import { SIGNAL } from '../config/constants.js';
import { YahooClient } from '../market/YahooClient.js';
import type { PortfolioHolding, Signal, ScreenerResult, AssetResult } from '../types.js';
interface PositionCalc {
totalCost: string;
marketValue: string | null;
gainLossPct: string | null;
}
interface AdviceOutput {
action: string;
reason: string;
}
interface AdviceRow {
ticker: string;
type: string;
source: string;
shares: number;
costBasis: number;
currentPrice: number | null;
marketValue: string | null;
totalCost: string;
gainLossPct: string | null;
signal: Signal | '—';
inflated: string;
fundamental: string;
advice: string;
reason: string;
}
export class PortfolioAdvisor {
private client: YahooClient;
constructor() {
this.client = new YahooClient();
}
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;
}
}