phase-8:server code enhancements.

This commit is contained in:
Kazuma
2026-06-05 22:44:04 -04:00
committed by Kazuma
parent 7cb1b03fd5
commit 7f2108129a
15 changed files with 781 additions and 94 deletions
+12 -12
View File
@@ -24,7 +24,7 @@ export class SimpleFINClient {
return;
}
if (process.env.SIMPLEFIN_SETUP_TOKEN) {
this.accessUrl = await this._claimAccessUrl(process.env.SIMPLEFIN_SETUP_TOKEN);
this.accessUrl = await this.claimAccessUrl(process.env.SIMPLEFIN_SETUP_TOKEN);
if (this.onAccessUrlClaimed) await this.onAccessUrlClaimed(this.accessUrl);
return;
}
@@ -36,7 +36,7 @@ export class SimpleFINClient {
async getAccounts(options: GetAccountsOptions = {}): Promise<SimpleFINData> {
if (!this.accessUrl) await this.init();
const startDate = options.startDate ?? this._daysAgo(30);
const startDate = options.startDate ?? this.daysAgo(30);
const endDate = options.endDate ?? Math.floor(Date.now() / 1000);
const parsed = new URL(this.accessUrl!);
@@ -59,13 +59,13 @@ export class SimpleFINClient {
data.errors.forEach((e) => this.logger.warn(` ⚠ SimpleFIN: ${e}`));
}
return this._normalise(data as { accounts: unknown[]; errors: string[] });
return this.normalise(data as { accounts: unknown[]; errors: string[] });
}
private async _claimAccessUrl(setupToken: string): Promise<string> {
private async claimAccessUrl(setupToken: string): Promise<string> {
const claimUrl = Buffer.from(setupToken.trim(), 'base64').toString('utf8').trim();
this.logger.write(`\n🔑 Claiming SimpleFIN access URL...\n → ${claimUrl}\n`);
const accessUrl = await this._post(claimUrl);
const accessUrl = await this.post(claimUrl);
if (!accessUrl || !accessUrl.startsWith('http')) {
throw new Error(
`Unexpected response from SimpleFIN: "${accessUrl}"\nSetup tokens are one-time use — if already claimed, generate a new one at https://beta-bridge.simplefin.org`,
@@ -75,7 +75,7 @@ export class SimpleFINClient {
return accessUrl.trim();
}
private _post(url: string): Promise<string> {
private post(url: string): Promise<string> {
return new Promise((resolve, reject) => {
const parsed = new URL(url);
const lib = parsed.protocol === 'https:' ? https : http;
@@ -101,7 +101,7 @@ export class SimpleFINClient {
});
}
private _normalise(data: { accounts: unknown[]; errors: string[] }): SimpleFINData {
private normalise(data: { accounts: unknown[]; errors: string[] }): SimpleFINData {
const accounts = (data.accounts ?? []).map((acc: any) => ({
id: acc.id,
name: acc.name,
@@ -109,19 +109,19 @@ export class SimpleFINClient {
balance: parseFloat(acc.balance) ?? 0,
balanceDate: new Date(acc['balance-date'] * 1000).toISOString().slice(0, 10),
org: acc.org?.name ?? 'Unknown',
type: this._classifyAccount(acc.name),
type: this.classifyAccount(acc.name),
transactions: (acc.transactions ?? []).map((tx: any) => ({
id: tx.id,
date: new Date(tx.posted * 1000).toISOString().slice(0, 10),
amount: parseFloat(tx.amount) ?? 0,
description: tx.description ?? '',
category: this._categorise(tx.description ?? ''),
category: this.categorise(tx.description ?? ''),
})),
}));
return { accounts, errors: data.errors ?? [] };
}
private _classifyAccount(name: string): string {
private classifyAccount(name: string): string {
const n = name.toLowerCase();
if (n.includes('checking') || n.includes('current')) return 'CHECKING';
if (n.includes('saving')) return 'SAVINGS';
@@ -132,7 +132,7 @@ export class SimpleFINClient {
return 'OTHER';
}
private _categorise(description: string): string {
private categorise(description: string): string {
const d = description.toLowerCase();
if (d.match(/amazon|walmart|target|costco|grocery|whole foods|trader joe/)) return 'Shopping';
if (d.match(/uber eats|doordash|grubhub|postmates|instacart/)) return 'Delivery';
@@ -147,7 +147,7 @@ export class SimpleFINClient {
return 'Other';
}
private _daysAgo(n: number): number {
private daysAgo(n: number): number {
return Math.floor((Date.now() - n * 24 * 60 * 60 * 1000) / 1000);
}
}
+6 -6
View File
@@ -9,12 +9,12 @@ export class Stock extends Asset {
constructor(data: StockData) {
super(data);
this.sector = this._mapToStandardSector(data);
this.sector = this.mapToStandardSector(data);
this.metrics = {
sector: this.sector,
capCategory: this._classifyMarketCap(data.marketCap ?? null),
growthCategory: this._classifyGrowth(
capCategory: this.classifyMarketCap(data.marketCap ?? null),
growthCategory: this.classifyGrowth(
data.revenueGrowth ?? null,
data.earningsGrowth ?? null,
data.dividendYield ?? null,
@@ -52,7 +52,7 @@ export class Stock extends Asset {
// ── Market cap tier classification ──────────────────────────────────────
// Thresholds follow MSCI/Russell institutional convention.
_classifyMarketCap(marketCap: number | null): CapCategory {
classifyMarketCap(marketCap: number | null): CapCategory {
if (marketCap == null) return CAP_CATEGORY.LARGE; // safe default
if (marketCap >= 200e9) return CAP_CATEGORY.MEGA;
if (marketCap >= 10e9) return CAP_CATEGORY.LARGE;
@@ -64,7 +64,7 @@ export class Stock extends Asset {
// ── Growth / style classification ───────────────────────────────────────
// revenueGrowth and earningsGrowth are in percentage form (e.g. 15 = 15%).
// dividendYield is also in percentage form (e.g. 3.5 = 3.5%).
_classifyGrowth(
classifyGrowth(
revenueGrowth: number | null,
earningsGrowth: number | null,
dividendYield: number | null,
@@ -81,7 +81,7 @@ export class Stock extends Asset {
return GROWTH_CATEGORY.STABLE;
}
_mapToStandardSector(data: StockData): Sector {
mapToStandardSector(data: StockData): Sector {
const profile = data.assetProfile ?? {};
const industry = (profile.industry || '').toLowerCase();
const sector = (profile.sector || '').toLowerCase();
+18 -12
View File
@@ -3,29 +3,35 @@ import { randomUUID } from 'crypto';
import type { MarketCall, CreateCallInput, StoreData } from '../types';
export class MarketCallRepository {
private static readonly STORE_PATH = './market-calls.json';
private static readonly DEFAULT_PATH = './market-calls.json';
private _load(): StoreData {
if (!existsSync(MarketCallRepository.STORE_PATH)) return { calls: [] };
private readonly storePath: string;
constructor(storePath?: string) {
this.storePath = storePath ?? MarketCallRepository.DEFAULT_PATH;
}
private load(): StoreData {
if (!existsSync(this.storePath)) return { calls: [] };
try {
return JSON.parse(readFileSync(MarketCallRepository.STORE_PATH, 'utf8')) as StoreData;
return JSON.parse(readFileSync(this.storePath, 'utf8')) as StoreData;
} catch {
return { calls: [] };
}
}
private _save(data: StoreData): void {
writeFileSync(MarketCallRepository.STORE_PATH, JSON.stringify(data, null, 2), 'utf8');
private save(data: StoreData): void {
writeFileSync(this.storePath, JSON.stringify(data, null, 2), 'utf8');
}
list(): (MarketCall & { createdAt: string })[] {
return this._load().calls.sort(
return this.load().calls.sort(
(a, b) => new Date(b.createdAt).getTime() - new Date(a.createdAt).getTime(),
);
}
get(id: string): (MarketCall & { createdAt: string }) | null {
return this._load().calls.find((c) => c.id === id) ?? null;
return this.load().calls.find((c) => c.id === id) ?? null;
}
create({
@@ -36,7 +42,7 @@ export class MarketCallRepository {
tickers,
snapshot,
}: CreateCallInput): MarketCall & { createdAt: string } {
const data = this._load();
const data = this.load();
const call = {
id: randomUUID(),
title,
@@ -48,16 +54,16 @@ export class MarketCallRepository {
createdAt: new Date().toISOString(),
};
data.calls.push(call);
this._save(data);
this.save(data);
return call;
}
delete(id: string): boolean {
const data = this._load();
const data = this.load();
const before = data.calls.length;
data.calls = data.calls.filter((c) => c.id !== id);
if (data.calls.length === before) return false;
this._save(data);
this.save(data);
return true;
}
}
+2 -2
View File
@@ -11,7 +11,7 @@ export class BondScorer {
context?: MarketContext | null,
): ScoreResult {
const { gates, weights, thresholds } = rules;
const metrics = BondScorer._sanitize(m);
const metrics = BondScorer.sanitize(m);
const riskFreeRate = (context?.riskFreeRate ?? 4.0) / 100;
if (metrics.creditRatingNumeric < gates.minCreditRating) {
@@ -37,7 +37,7 @@ export class BondScorer {
};
}
private static _sanitize(m: BondMetrics): SanitizedBondMetrics {
private static sanitize(m: BondMetrics): SanitizedBondMetrics {
const pct = (v: unknown): number =>
parseFloat(typeof v === 'string' ? v.replace('%', '') : String(v)) / 100 || 0;
return {
+4 -4
View File
@@ -22,7 +22,7 @@ export class StockScorer {
},
): ScoreResult {
const { gates, weights, thresholds } = rules;
const m = StockScorer._sanitize(metrics);
const m = StockScorer.sanitize(metrics);
const failures = [
m.debtToEquity != null &&
@@ -208,20 +208,20 @@ export class StockScorer {
].filter(Boolean) as string[];
return {
label: StockScorer._label(totalScore),
label: StockScorer.label(totalScore),
scoreSummary: `Score: ${totalScore}`,
audit: { passedGates: true, breakdown, riskFlags: riskFlags.length ? riskFlags : null },
};
}
private static _label(score: number): string {
private static label(score: number): string {
if (score >= 8) return '🟢 BUY (High Conviction)';
if (score >= 4) return '🟢 BUY (Speculative)';
if (score >= 0) return '🟡 HOLD';
return '🔴 REJECT';
}
private static _sanitize(m: StockMetrics): SanitizedMetrics {
private static sanitize(m: StockMetrics): SanitizedMetrics {
const w52 =
m.week52High != null && m.week52High > 0 && m.week52Low != null && m.currentPrice > 0
? (m.currentPrice - m.week52Low) / (m.week52High - m.week52Low)
+2 -2
View File
@@ -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) {
+6 -6
View File
@@ -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: {},
+15 -17
View File
@@ -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 {
+24 -24
View File
@@ -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;