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
+180
View File
@@ -0,0 +1,180 @@
import { YahooClient } from '../market/YahooClient.js';
import { BenchmarkProvider } from '../market/BenchmarkProvider.js';
import { mapToStandardFormat } from './DataMapper.js';
import { chunkArray } from './Chunker.js';
import { RuleMerger } from './RuleMerger.js';
import { Stock } from './assets/Stock.js';
import { Etf } from './assets/Etf.js';
import { Bond } from './assets/Bond.js';
import { StockScorer } from './scorers/StockScorer.js';
import { EtfScorer } from './scorers/EtfScorer.js';
import { BondScorer } from './scorers/BondScorer.js';
import { SIGNAL, SIGNAL_ORDER, SCORE_MODE, ASSET_TYPE } from '../config/constants.js';
import type { Logger, MarketContext, Signal, AssetType, ScreenerResult } from '../types.js';
const SCORERS: Record<AssetType, typeof StockScorer | typeof EtfScorer | typeof BondScorer> = {
[ASSET_TYPE.STOCK]: StockScorer,
[ASSET_TYPE.ETF]: EtfScorer,
[ASSET_TYPE.BOND]: BondScorer,
};
interface ScreenerEngineOptions {
logger?: Logger;
}
interface ErrorResult {
isError: true;
ticker: string;
message: string;
}
type FetchResult = ReturnType<typeof mapToStandardFormat> | ErrorResult;
export class ScreenerEngine {
private client: YahooClient;
private benchmarkProvider: BenchmarkProvider;
private logger: Logger;
constructor({ logger }: ScreenerEngineOptions = {}) {
this.client = new YahooClient();
this.benchmarkProvider = new BenchmarkProvider({
logger: logger ?? (console as unknown as Logger),
});
this.logger = logger ?? {
write: (msg: string) => process.stdout.write(msg),
log: (...args: unknown[]) => console.log(...args),
warn: (...args: unknown[]) => console.warn(...args),
};
}
// Pure data method — returns structured results. Safe to use in a server route.
async screenTickers(tickers: string[]): Promise<ScreenerResult> {
const marketContext = await this.benchmarkProvider.getMarketContext();
const results: Omit<ScreenerResult, 'marketContext'> = {
STOCK: [],
ETF: [],
BOND: [],
ERROR: [],
};
for (const chunk of chunkArray(tickers, 5)) {
const batch = await Promise.all(chunk.map((t) => this._fetch(t)));
batch.forEach((data) => this._process(data, marketContext, results));
await new Promise<void>((r) => setTimeout(r, 1000));
}
return { ...results, marketContext };
}
// CLI helper — emits progress to logger, returns structured results.
async screenWithProgress(tickers: string[]): Promise<ScreenerResult> {
this.logger.write('⏳ Fetching market context...');
const marketContext = await this.benchmarkProvider.getMarketContext();
this.logger.write(' done\n');
const results: Omit<ScreenerResult, 'marketContext'> = {
STOCK: [],
ETF: [],
BOND: [],
ERROR: [],
};
const chunks = chunkArray(tickers, 5);
let processed = 0;
for (const chunk of chunks) {
const batch = await Promise.all(chunk.map((t) => this._fetch(t)));
batch.forEach((data) => this._process(data, marketContext, results));
processed += chunk.length;
this.logger.write(`\r⏳ Screening tickers... ${processed}/${tickers.length}`);
await new Promise<void>((r) => setTimeout(r, 1000));
}
this.logger.write('\n');
return { ...results, marketContext };
}
private async _fetch(ticker: string): Promise<FetchResult> {
try {
const summary = await this.client.fetchSummary(ticker);
if (!summary?.price) throw new Error('Empty response from Yahoo');
return mapToStandardFormat(ticker, summary);
} catch (err) {
return { isError: true, ticker: ticker.toUpperCase(), message: (err as Error).message };
}
}
private _process(
data: FetchResult,
marketContext: MarketContext,
results: Omit<ScreenerResult, 'marketContext'>,
): void {
if ('isError' in data && data.isError) {
results.ERROR.push({ ticker: data.ticker, message: data.message });
return;
}
try {
const asset = this._buildAsset(data as ReturnType<typeof mapToStandardFormat>);
const scorer = SCORERS[asset.type as AssetType];
if (!scorer) throw new Error(`No scorer for type: ${asset.type}`);
const fundamental = scorer.score(
asset.metrics as never,
RuleMerger.getRulesForAsset(
asset.type as AssetType,
asset.metrics as { sector?: string },
marketContext,
SCORE_MODE.FUNDAMENTAL,
),
marketContext,
);
const inflated = scorer.score(
asset.metrics as never,
RuleMerger.getRulesForAsset(
asset.type as AssetType,
asset.metrics as { sector?: string },
marketContext,
SCORE_MODE.INFLATED,
),
marketContext,
);
(results[asset.type as AssetType] as unknown[]).push({
asset,
fundamental,
inflated,
signal: this._signal(fundamental.label, inflated.label),
});
} catch (err) {
results.ERROR.push({
ticker: ((data as { ticker?: string }).ticker || 'UNKNOWN').toUpperCase(),
message: (err as Error).message,
});
}
}
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 never);
case ASSET_TYPE.ETF:
return new Etf(data as never);
default:
return new Stock(data as never);
}
}
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;
if (green(inflatedLabel) && yellow(fundamentalLabel)) return SIGNAL.MOMENTUM;
if (green(inflatedLabel) && !green(fundamentalLabel)) return SIGNAL.SPECULATION;
if (yellow(fundamentalLabel) || yellow(inflatedLabel)) return SIGNAL.NEUTRAL;
return SIGNAL.AVOID;
}
signalOrder(signal: Signal): number {
return SIGNAL_ORDER[signal] ?? 5;
}
}