phase-8:server code enhancements.
This commit is contained in:
@@ -21,7 +21,7 @@ export class CatalystAnalyst {
|
||||
|
||||
async run(): Promise<CatalystResult> {
|
||||
this.logger.write('🔍 Fetching market news...');
|
||||
const rawStories = await this._fetchNews();
|
||||
const rawStories = await this.fetchNews();
|
||||
|
||||
if (!rawStories.length) {
|
||||
this.logger.write(' ⚠ all news queries failed — check network or Yahoo rate limit\n');
|
||||
@@ -67,7 +67,7 @@ export class CatalystAnalyst {
|
||||
}));
|
||||
}
|
||||
|
||||
private async _fetchNews(): Promise<YahooNewsItem[]> {
|
||||
private async fetchNews(): Promise<YahooNewsItem[]> {
|
||||
const seen = new Map<string, YahooNewsItem>();
|
||||
let successCount = 0;
|
||||
for (const query of CatalystAnalyst.NEWS_QUERIES) {
|
||||
|
||||
@@ -20,13 +20,13 @@ export class MarketRegime {
|
||||
}
|
||||
|
||||
getInflatedOverrides(type: AssetType, sector?: string): InflatedOverrides {
|
||||
if (type === ASSET_TYPE.STOCK) return this._stock(sector);
|
||||
if (type === ASSET_TYPE.ETF) return this._etf();
|
||||
if (type === ASSET_TYPE.BOND) return this._bond();
|
||||
if (type === ASSET_TYPE.STOCK) return this.stock(sector);
|
||||
if (type === ASSET_TYPE.ETF) return this.etf();
|
||||
if (type === ASSET_TYPE.BOND) return this.bond();
|
||||
return { gates: {}, thresholds: {} };
|
||||
}
|
||||
|
||||
private _stock(sector?: string): InflatedOverrides {
|
||||
private stock(sector?: string): InflatedOverrides {
|
||||
if (sector === SECTOR.REIT) {
|
||||
return {
|
||||
gates: {},
|
||||
@@ -55,11 +55,11 @@ export class MarketRegime {
|
||||
};
|
||||
}
|
||||
|
||||
private _etf(): InflatedOverrides {
|
||||
private etf(): InflatedOverrides {
|
||||
return { gates: { maxExpenseRatio: 0.75 }, thresholds: { minYield: 0.5 } };
|
||||
}
|
||||
|
||||
private _bond(): InflatedOverrides {
|
||||
private bond(): InflatedOverrides {
|
||||
const spreadMultiplier = this.rateRegime === REGIME.HIGH ? 0.9 : 0.8;
|
||||
return {
|
||||
gates: {},
|
||||
|
||||
@@ -25,7 +25,7 @@ export class PortfolioAdvisor {
|
||||
resultMap[t.replace(/\./g, '-')] = r;
|
||||
}
|
||||
|
||||
const cryptoPrices = await this._cryptoPrices(holdings.filter((h) => h.type === 'crypto'));
|
||||
const cryptoPrices = await this.cryptoPrices(holdings.filter((h) => h.type === 'crypto'));
|
||||
|
||||
return holdings.map((holding) => {
|
||||
const type = (holding.type ?? 'stock').toLowerCase();
|
||||
@@ -36,35 +36,35 @@ export class PortfolioAdvisor {
|
||||
: (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()]);
|
||||
? this.row(holding, price, source, '—', '—', '—', this.cryptoAdvice(holding, price))
|
||||
: this.stockRow(holding, price, source, resultMap[holding.ticker.toUpperCase()]);
|
||||
});
|
||||
}
|
||||
|
||||
private _stockRow(
|
||||
private stockRow(
|
||||
holding: PortfolioHolding,
|
||||
price: number | null,
|
||||
source: string,
|
||||
result: AssetResult | undefined,
|
||||
): AdviceRow {
|
||||
if (!result) {
|
||||
return this._row(holding, price, source, '—', '—', '—', {
|
||||
return this.row(holding, price, source, '—', '—', '—', {
|
||||
action: '⚪ Not screened',
|
||||
reason: 'No screener data available — Yahoo Finance may not support this ticker.',
|
||||
});
|
||||
}
|
||||
return this._row(
|
||||
return this.row(
|
||||
holding,
|
||||
price,
|
||||
source,
|
||||
result.signal,
|
||||
result.inflated.label,
|
||||
result.fundamental.label,
|
||||
this._advice(result.signal, holding, price),
|
||||
this.advice(result.signal, holding, price),
|
||||
);
|
||||
}
|
||||
|
||||
private _row(
|
||||
private row(
|
||||
holding: PortfolioHolding,
|
||||
currentPrice: number | null,
|
||||
source: string,
|
||||
@@ -73,7 +73,7 @@ export class PortfolioAdvisor {
|
||||
fundamental: string,
|
||||
{ action, reason }: AdviceOutput,
|
||||
): AdviceRow {
|
||||
const { marketValue, totalCost, gainLossPct } = this._position(holding, currentPrice);
|
||||
const { marketValue, totalCost, gainLossPct } = this.position(holding, currentPrice);
|
||||
return {
|
||||
ticker: holding.ticker,
|
||||
type: holding.type ?? 'stock',
|
||||
@@ -92,7 +92,7 @@ export class PortfolioAdvisor {
|
||||
};
|
||||
}
|
||||
|
||||
private _position(holding: PortfolioHolding, currentPrice: number | null): PositionCalc {
|
||||
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,
|
||||
@@ -103,8 +103,8 @@ export class PortfolioAdvisor {
|
||||
};
|
||||
}
|
||||
|
||||
private _cryptoAdvice(holding: PortfolioHolding, price: number | null): AdviceOutput {
|
||||
const { gainLossPct } = this._position(holding, price);
|
||||
private cryptoAdvice(holding: PortfolioHolding, price: number | null): AdviceOutput {
|
||||
const { gainLossPct } = this.position(holding, price);
|
||||
const g = parseFloat(gainLossPct ?? 'NaN');
|
||||
if (gainLossPct == null)
|
||||
return {
|
||||
@@ -127,8 +127,8 @@ export class PortfolioAdvisor {
|
||||
};
|
||||
}
|
||||
|
||||
private _advice(signal: Signal, holding: PortfolioHolding, price: number | null): AdviceOutput {
|
||||
const { gainLossPct } = this._position(holding, price);
|
||||
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:
|
||||
@@ -164,9 +164,7 @@ export class PortfolioAdvisor {
|
||||
}
|
||||
}
|
||||
|
||||
private async _cryptoPrices(
|
||||
holdings: PortfolioHolding[],
|
||||
): Promise<Record<string, number | null>> {
|
||||
private async cryptoPrices(holdings: PortfolioHolding[]): Promise<Record<string, number | null>> {
|
||||
const prices: Record<string, number | null> = {};
|
||||
for (const h of holdings) {
|
||||
try {
|
||||
|
||||
@@ -44,24 +44,24 @@ export class ScreenerEngine {
|
||||
}
|
||||
|
||||
async screenTickers(tickers: string[]): Promise<ScreenerResult> {
|
||||
return this._screenInternal(tickers, false);
|
||||
return this.screenInternal(tickers, false);
|
||||
}
|
||||
|
||||
async screenWithProgress(tickers: string[]): Promise<ScreenerResult> {
|
||||
return this._screenInternal(tickers, true);
|
||||
return this.screenInternal(tickers, true);
|
||||
}
|
||||
|
||||
private async _screenInternal(tickers: string[], showProgress: boolean): Promise<ScreenerResult> {
|
||||
const marketContext = await this._fetchMarketContext(showProgress);
|
||||
const results = this._initializeResults();
|
||||
private async screenInternal(tickers: string[], showProgress: boolean): Promise<ScreenerResult> {
|
||||
const marketContext = await this.fetchMarketContext(showProgress);
|
||||
const results = this.initializeResults();
|
||||
const chunks = chunkArray(tickers, ScreenerEngine.BATCH_SIZE);
|
||||
let processed = 0;
|
||||
|
||||
for (const chunk of chunks) {
|
||||
await this._processBatch(chunk, marketContext, results);
|
||||
await this.processBatch(chunk, marketContext, results);
|
||||
processed += chunk.length;
|
||||
this._logProgress(showProgress, processed, tickers.length);
|
||||
await this._rateLimitDelay();
|
||||
this.logProgress(showProgress, processed, tickers.length);
|
||||
await this.rateLimitDelay();
|
||||
}
|
||||
|
||||
if (showProgress) {
|
||||
@@ -71,7 +71,7 @@ export class ScreenerEngine {
|
||||
return { ...results, marketContext };
|
||||
}
|
||||
|
||||
private async _fetchMarketContext(showProgress: boolean): Promise<MarketContext> {
|
||||
private async fetchMarketContext(showProgress: boolean): Promise<MarketContext> {
|
||||
if (showProgress) {
|
||||
this.logger.write('⏳ Fetching market context...');
|
||||
}
|
||||
@@ -82,30 +82,30 @@ export class ScreenerEngine {
|
||||
return context;
|
||||
}
|
||||
|
||||
private _initializeResults(): Omit<ScreenerResult, 'marketContext'> {
|
||||
private initializeResults(): Omit<ScreenerResult, 'marketContext'> {
|
||||
return { STOCK: [], ETF: [], BOND: [], ERROR: [] };
|
||||
}
|
||||
|
||||
private async _processBatch(
|
||||
private async processBatch(
|
||||
tickers: string[],
|
||||
marketContext: MarketContext,
|
||||
results: Omit<ScreenerResult, 'marketContext'>,
|
||||
): Promise<void> {
|
||||
const batch = await Promise.all(tickers.map((t) => this._fetch(t)));
|
||||
batch.forEach((data) => this._process(data, marketContext, results));
|
||||
const batch = await Promise.all(tickers.map((t) => this.fetch(t)));
|
||||
batch.forEach((data) => this.process(data, marketContext, results));
|
||||
}
|
||||
|
||||
private _logProgress(showProgress: boolean, processed: number, total: number): void {
|
||||
private logProgress(showProgress: boolean, processed: number, total: number): void {
|
||||
if (showProgress) {
|
||||
this.logger.write(`\r⏳ Screening tickers... ${processed}/${total}`);
|
||||
}
|
||||
}
|
||||
|
||||
private async _rateLimitDelay(): Promise<void> {
|
||||
private async rateLimitDelay(): Promise<void> {
|
||||
await new Promise<void>((r) => setTimeout(r, ScreenerEngine.BATCH_DELAY_MS));
|
||||
}
|
||||
|
||||
private async _fetch(ticker: string): Promise<MappedData | ErrorResult> {
|
||||
private async fetch(ticker: string): Promise<MappedData | ErrorResult> {
|
||||
try {
|
||||
const summary = await this.client.fetchSummary(ticker);
|
||||
if (!summary?.price) throw new Error('Empty response from Yahoo');
|
||||
@@ -115,7 +115,7 @@ export class ScreenerEngine {
|
||||
}
|
||||
}
|
||||
|
||||
private _process(
|
||||
private process(
|
||||
data: MappedData | ErrorResult,
|
||||
marketContext: MarketContext,
|
||||
results: Omit<ScreenerResult, 'marketContext'>,
|
||||
@@ -127,15 +127,15 @@ export class ScreenerEngine {
|
||||
}
|
||||
|
||||
try {
|
||||
const asset = this._buildAsset(data as MappedData);
|
||||
const fundamental = this._score(asset, marketContext, SCORE_MODE.FUNDAMENTAL);
|
||||
const inflated = this._score(asset, marketContext, SCORE_MODE.INFLATED);
|
||||
const asset = this.buildAsset(data as MappedData);
|
||||
const fundamental = this.score(asset, marketContext, SCORE_MODE.FUNDAMENTAL);
|
||||
const inflated = this.score(asset, marketContext, SCORE_MODE.INFLATED);
|
||||
|
||||
(results[asset.type as AssetType] as unknown[]).push({
|
||||
asset,
|
||||
fundamental,
|
||||
inflated,
|
||||
signal: this._signal(fundamental.label, inflated.label),
|
||||
signal: this.signal(fundamental.label, inflated.label),
|
||||
});
|
||||
} catch (err) {
|
||||
results.ERROR.push({
|
||||
@@ -147,7 +147,7 @@ export class ScreenerEngine {
|
||||
|
||||
// Typed scorer dispatch — instanceof narrows the asset so each scorer receives
|
||||
// its exact metrics type. No `as never` or unsafe casts required.
|
||||
private _score(
|
||||
private score(
|
||||
asset: Stock | Etf | Bond,
|
||||
marketContext: MarketContext,
|
||||
mode: string,
|
||||
@@ -165,7 +165,7 @@ export class ScreenerEngine {
|
||||
throw new Error('No scorer for unknown asset type');
|
||||
}
|
||||
|
||||
private _buildAsset(data: Record<string, unknown>): Stock | Etf | Bond {
|
||||
private buildAsset(data: Record<string, unknown>): Stock | Etf | Bond {
|
||||
switch (((data.type as string) || ASSET_TYPE.STOCK).toUpperCase()) {
|
||||
case ASSET_TYPE.BOND:
|
||||
return new Bond(data as BondData);
|
||||
@@ -176,7 +176,7 @@ export class ScreenerEngine {
|
||||
}
|
||||
}
|
||||
|
||||
private _signal(fundamentalLabel: string, inflatedLabel: string): Signal {
|
||||
private signal(fundamentalLabel: string, inflatedLabel: string): Signal {
|
||||
const green = (l: string) => l.startsWith('🟢');
|
||||
const yellow = (l: string) => l.startsWith('🟡');
|
||||
if (green(fundamentalLabel)) return SIGNAL.STRONG_BUY;
|
||||
|
||||
Reference in New Issue
Block a user