phase-6: typescript introduction

This commit is contained in:
Sai Kiran Vella
2026-06-04 22:16:48 -04:00
committed by saikiranvella
parent 57625c27d7
commit c160e65bd6
69 changed files with 2323 additions and 1036 deletions
@@ -1,14 +1,38 @@
// PersonalFinanceAnalyzer
//
// Takes normalised SimpleFIN account data and computes:
// - Net worth (assets - liabilities)
// - Cash vs investment allocation
// - Spending by category (last 30 days)
// - Top spending categories
// - Income vs expenses summary
interface Transaction {
amount: number;
category: string;
}
interface Account {
type: string;
balance: number;
transactions: Transaction[];
[key: string]: unknown;
}
interface CategoryBreakdown {
category: string;
amount: number;
pct: string;
}
interface FinanceAnalysis {
netWorth: number;
totalAssets: number;
totalLiabilities: number;
totalCash: number;
totalInvestments: number;
cashPct: string;
investPct: string;
totalIncome: number;
totalSpend: number;
savingsRate: string | null;
categoryBreakdown: CategoryBreakdown[];
accounts: Account[];
}
export class PersonalFinanceAnalyzer {
analyse(accounts) {
analyse(accounts: Account[]): FinanceAnalysis {
const assets = accounts.filter((a) => !['CREDIT', 'LOAN'].includes(a.type));
const liabilities = accounts.filter((a) => ['CREDIT', 'LOAN'].includes(a.type));
@@ -21,21 +45,19 @@ export class PersonalFinanceAnalyzer {
const totalCash = cash.reduce((s, a) => s + Math.max(0, a.balance), 0);
const totalInvest = investments.reduce((s, a) => s + Math.max(0, a.balance), 0);
// Aggregate all transactions across accounts
const allTx = accounts.flatMap((a) => a.transactions);
const spending = allTx.filter((tx) => tx.amount < 0 && tx.category !== 'Transfer');
const income = allTx.filter((tx) => tx.amount > 0 && tx.category === 'Income');
const totalSpend = spending.reduce((s, tx) => s + Math.abs(tx.amount), 0);
const totalIncome = income.reduce((s, tx) => s + tx.amount, 0);
// Spending by category
const byCategory = {};
const byCategory: Record<string, number> = {};
for (const tx of spending) {
byCategory[tx.category] = (byCategory[tx.category] ?? 0) + Math.abs(tx.amount);
}
const categoryBreakdown = Object.entries(byCategory)
const categoryBreakdown: CategoryBreakdown[] = Object.entries(byCategory)
.sort((a, b) => b[1] - a[1])
.map(([category, amount]) => ({
category,
@@ -1,24 +1,52 @@
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, 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 ?? []),
]) {
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; // BRK-B → BRK.B
resultMap[t.replace(/\./g, '-')] = r; // BRK.B → BRK-B
resultMap[t.replace(/-/g, '.')] = r;
resultMap[t.replace(/\./g, '-')] = r;
}
const cryptoPrices = await this._cryptoPrices(holdings.filter((h) => h.type === 'crypto'));
@@ -26,9 +54,9 @@ export class PortfolioAdvisor {
return holdings.map((holding) => {
const type = (holding.type ?? 'stock').toLowerCase();
const source = holding.source ?? '—';
const price =
const price: number | null =
type === 'crypto'
? cryptoPrices[holding.ticker.toUpperCase()]
? (cryptoPrices[holding.ticker.toUpperCase()] ?? null)
: (resultMap[holding.ticker.toUpperCase()]?.asset?.currentPrice ?? null);
return type === 'crypto'
@@ -37,7 +65,12 @@ export class PortfolioAdvisor {
});
}
_stockRow(holding, price, source, result) {
private _stockRow(
holding: PortfolioHolding,
price: number | null,
source: string,
result: AssetResult | undefined,
): AdviceRow {
if (!result) {
return this._row(holding, price, source, '—', '—', '—', {
action: '⚪ Not screened',
@@ -55,7 +88,15 @@ export class PortfolioAdvisor {
);
}
_row(holding, currentPrice, source, signal, inflated, fundamental, { action, reason }) {
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,
@@ -75,19 +116,20 @@ export class PortfolioAdvisor {
};
}
_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 };
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,
};
}
_cryptoAdvice(holding, price) {
private _cryptoAdvice(holding: PortfolioHolding, price: number | null): AdviceOutput {
const { gainLossPct } = this._position(holding, price);
const g = parseFloat(gainLossPct);
const g = parseFloat(gainLossPct ?? 'NaN');
if (gainLossPct == null)
return {
action: '⚪ No price data',
@@ -109,15 +151,12 @@ export class PortfolioAdvisor {
};
}
_advice(signal, holding, price) {
private _advice(signal: Signal, holding: PortfolioHolding, price: number | null): AdviceOutput {
const { gainLossPct } = this._position(holding, price);
const gain = parseFloat(gainLossPct);
const gain = parseFloat(gainLossPct ?? '0');
switch (signal) {
case SIGNAL.STRONG_BUY:
return {
action: '🟢 Hold & Add',
reason: 'Passes both analyses. Strong conviction.',
};
return { action: '🟢 Hold & Add', reason: 'Passes both analyses. Strong conviction.' };
case SIGNAL.MOMENTUM:
return {
action: '🟡 Hold',
@@ -135,10 +174,7 @@ export class PortfolioAdvisor {
: 'Overvalued fundamentally. Keep position small.',
};
case SIGNAL.NEUTRAL:
return {
action: '🟡 Hold',
reason: 'No clear edge. Review on any catalyst.',
};
return { action: '🟡 Hold', reason: 'No clear edge. Review on any catalyst.' };
case SIGNAL.AVOID:
return {
action: gain > 0 ? '🔴 Sell (Take Profits)' : '🔴 Sell (Cut Loss)',
@@ -152,12 +188,14 @@ export class PortfolioAdvisor {
}
}
async _cryptoPrices(cryptoHoldings) {
const prices = {};
for (const h of cryptoHoldings) {
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;
prices[h.ticker.toUpperCase()] = summary?.price?.regularMarketPrice ?? null;
} catch {
prices[h.ticker.toUpperCase()] = null;
}
@@ -1,22 +1,48 @@
import fs from 'fs';
import https from 'https';
import http from 'http';
import type { Logger } from '../../types.js';
// SimpleFIN auth flow:
// 1. You get a Setup Token from https://beta-bridge.simplefin.org
// 2. This client decodes it, POSTs once to claim an Access URL
// 3. The CLI saves it to .env; a server would store it in its own secret store.
// 4. All subsequent requests use the Access URL directly.
//
// .env configuration:
// First run: SIMPLEFIN_SETUP_TOKEN=aHR0cHM6Ly8...
// After that: SIMPLEFIN_ACCESS_URL=https://user:pass@beta-bridge.simplefin.org/simplefin
interface SimpleFINOptions {
logger?: Logger;
onAccessUrlClaimed?: (url: string) => Promise<void> | void;
}
interface GetAccountsOptions {
startDate?: number;
endDate?: number;
}
interface Transaction {
id: string;
date: string;
amount: number;
description: string;
category: string;
}
interface Account {
id: string;
name: string;
currency: string;
balance: number;
balanceDate: string;
org: string;
type: string;
transactions: Transaction[];
}
interface SimpleFINData {
accounts: Account[];
errors: string[];
}
export class SimpleFINClient {
// logger: object with .write() / .log() / .warn() — defaults to console.
// onAccessUrlClaimed(url): optional callback so the caller can persist the URL
// (CLI uses it to write .env; a server would store it elsewhere).
constructor({ logger, onAccessUrlClaimed } = {}) {
private accessUrl: string | null;
private logger: Logger;
private onAccessUrlClaimed: ((url: string) => Promise<void> | void) | null;
constructor({ logger, onAccessUrlClaimed }: SimpleFINOptions = {}) {
this.accessUrl = null;
this.logger = logger ?? {
write: (msg) => process.stdout.write(msg),
@@ -26,37 +52,28 @@ export class SimpleFINClient {
this.onAccessUrlClaimed = onAccessUrlClaimed ?? null;
}
async init() {
async init(): Promise<void> {
if (process.env.SIMPLEFIN_ACCESS_URL) {
this.accessUrl = process.env.SIMPLEFIN_ACCESS_URL.replace(/\/$/, '');
return;
}
if (process.env.SIMPLEFIN_SETUP_TOKEN) {
this.accessUrl = await this._claimAccessUrl(process.env.SIMPLEFIN_SETUP_TOKEN);
if (this.onAccessUrlClaimed) {
await this.onAccessUrlClaimed(this.accessUrl);
}
if (this.onAccessUrlClaimed) await this.onAccessUrlClaimed(this.accessUrl);
return;
}
throw new Error(
'SimpleFIN not configured.\n' +
'Add to .env:\n' +
' SIMPLEFIN_SETUP_TOKEN=<your setup token from https://beta-bridge.simplefin.org>\n' +
'The Access URL will be saved automatically on first run.',
'SimpleFIN not configured.\nAdd to .env:\n SIMPLEFIN_SETUP_TOKEN=<your setup token from https://beta-bridge.simplefin.org>\nThe Access URL will be saved automatically on first run.',
);
}
async getAccounts(options = {}) {
async getAccounts(options: GetAccountsOptions = {}): Promise<SimpleFINData> {
if (!this.accessUrl) await this.init();
const startDate = options.startDate ?? this._daysAgo(30);
const endDate = options.endDate ?? Math.floor(Date.now() / 1000);
// fetch() rejects URLs with embedded credentials (user:pass@host).
// Extract them and send as a Basic Auth header instead.
const parsed = new URL(this.accessUrl);
const parsed = new URL(this.accessUrl!);
const auth = parsed.username
? 'Basic ' + Buffer.from(`${parsed.username}:${parsed.password}`).toString('base64')
: null;
@@ -65,43 +82,34 @@ export class SimpleFINClient {
const cleanBase = parsed.toString().replace(/\/$/, '');
const url = `${cleanBase}/accounts?version=2&start-date=${startDate}&end-date=${endDate}`;
const response = await fetch(url, {
headers: auth ? { Authorization: auth } : {},
});
const response = await fetch(url, { headers: auth ? { Authorization: auth } : {} });
if (!response.ok) {
throw new Error(`SimpleFIN error ${response.status}: ${await response.text()}`);
}
const data = await response.json();
const data = (await response.json()) as { accounts?: unknown[]; errors?: string[] };
if (data.errors?.length) {
data.errors.forEach((e) => this.logger.warn(` ⚠ SimpleFIN: ${e}`));
}
return this._normalise(data);
return this._normalise(data as { accounts: unknown[]; errors: string[] });
}
// ── Auth ─────────────────────────────────────────────────────────────────────
async _claimAccessUrl(setupToken) {
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);
if (!accessUrl || !accessUrl.startsWith('http')) {
throw new Error(
`Unexpected response from SimpleFIN: "${accessUrl}"\n` +
'Setup tokens are one-time use — if already claimed, generate a new one at https://beta-bridge.simplefin.org',
`Unexpected response from SimpleFIN: "${accessUrl}"\nSetup tokens are one-time use — if already claimed, generate a new one at https://beta-bridge.simplefin.org`,
);
}
this.logger.write('✅ Access URL received\n');
return accessUrl.trim();
}
_post(url) {
private _post(url: string): Promise<string> {
return new Promise((resolve, reject) => {
const parsed = new URL(url);
const lib = parsed.protocol === 'https:' ? https : http;
@@ -112,30 +120,23 @@ export class SimpleFINClient {
method: 'POST',
headers: { 'Content-Length': '0', 'Content-Type': 'application/x-www-form-urlencoded' },
};
const req = lib.request(options, (res) => {
let body = '';
res.on('data', (chunk) => {
res.on('data', (chunk: string) => {
body += chunk;
});
res.on('end', () => {
if (res.statusCode >= 200 && res.statusCode < 300) {
resolve(body.trim());
} else {
reject(new Error(`HTTP ${res.statusCode}: ${body.trim()}`));
}
if ((res.statusCode ?? 0) >= 200 && (res.statusCode ?? 0) < 300) resolve(body.trim());
else reject(new Error(`HTTP ${res.statusCode}: ${body.trim()}`));
});
});
req.on('error', reject);
req.end();
});
}
// ── Normalise ────────────────────────────────────────────────────────────────
_normalise(data) {
const accounts = (data.accounts ?? []).map((acc) => ({
private _normalise(data: { accounts: unknown[]; errors: string[] }): SimpleFINData {
const accounts = (data.accounts ?? []).map((acc: any) => ({
id: acc.id,
name: acc.name,
currency: acc.currency ?? 'USD',
@@ -143,7 +144,7 @@ export class SimpleFINClient {
balanceDate: new Date(acc['balance-date'] * 1000).toISOString().slice(0, 10),
org: acc.org?.name ?? 'Unknown',
type: this._classifyAccount(acc.name),
transactions: (acc.transactions ?? []).map((tx) => ({
transactions: (acc.transactions ?? []).map((tx: any) => ({
id: tx.id,
date: new Date(tx.posted * 1000).toISOString().slice(0, 10),
amount: parseFloat(tx.amount) ?? 0,
@@ -151,11 +152,10 @@ export class SimpleFINClient {
category: this._categorise(tx.description ?? ''),
})),
}));
return { accounts, errors: data.errors ?? [] };
}
_classifyAccount(name) {
private _classifyAccount(name: string): string {
const n = name.toLowerCase();
if (n.includes('checking') || n.includes('current')) return 'CHECKING';
if (n.includes('saving')) return 'SAVINGS';
@@ -166,7 +166,7 @@ export class SimpleFINClient {
return 'OTHER';
}
_categorise(description) {
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';
@@ -181,14 +181,12 @@ export class SimpleFINClient {
return 'Other';
}
_daysAgo(n) {
private _daysAgo(n: number): number {
return Math.floor((Date.now() - n * 24 * 60 * 60 * 1000) / 1000);
}
}
// CLI helper — saves the access URL to .env after the setup token is claimed.
// Pass this as `onAccessUrlClaimed` when constructing SimpleFINClient in CLI context.
export function saveAccessUrlToEnv(accessUrl) {
export function saveAccessUrlToEnv(accessUrl: string): void {
try {
const existing = fs.existsSync('.env') ? fs.readFileSync('.env', 'utf8') : '';
if (!existing.includes('SIMPLEFIN_ACCESS_URL')) {