phase-6: typescript introduction

This commit is contained in:
Kazuma
2026-06-04 22:16:48 -04:00
committed by Kazuma
parent de8427d578
commit 2b785aa861
69 changed files with 2323 additions and 1036 deletions
+5 -2
View File
@@ -2,8 +2,11 @@
# #
# FIRST RUN: paste your Setup Token from https://beta-bridge.simplefin.org # FIRST RUN: paste your Setup Token from https://beta-bridge.simplefin.org
# (Settings → Connect an app → copy the token) # (Settings → Connect an app → copy the token)
# ## Get your key at: https://console.anthropic.com
SIMPLEFIN_SETUP_TOKEN=aHR0cHM6Ly9iZXRhLWJyaWRnZS5zaW1wbGVmaW4ub3Jn... ANTHROPIC_API_KEY=
# do not give below details if simplefin is not setup.
SIMPLEFIN_SETUP_TOKEN=
# #
# AFTER FIRST RUN: the Access URL is written here automatically. # AFTER FIRST RUN: the Access URL is written here automatically.
# Remove SIMPLEFIN_SETUP_TOKEN once this appears. # Remove SIMPLEFIN_SETUP_TOKEN once this appears.
+26 -25
View File
@@ -1,14 +1,5 @@
/** /**
* bin/finance.js Personal Finance CLI * bin/finance.ts Personal Finance CLI
*
* Fetches your accounts from SimpleFIN, screens your portfolio holdings,
* and saves a finance-report.html with:
* 1. Net worth + account overview (SimpleFIN)
* 2. Portfolio hold/sell/add advice (screener + crypto prices)
* 3. Spending breakdown (SimpleFIN)
*
* Usage:
* npm run finance
*/ */
import 'dotenv/config'; import 'dotenv/config';
@@ -18,17 +9,19 @@ import { PersonalFinanceAnalyzer } from '../server/finance/PersonalFinanceAnalyz
import { PortfolioAdvisor } from '../server/finance/PortfolioAdvisor.js'; import { PortfolioAdvisor } from '../server/finance/PortfolioAdvisor.js';
import { ScreenerEngine } from '../server/screener/ScreenerEngine.js'; import { ScreenerEngine } from '../server/screener/ScreenerEngine.js';
import { FinanceReporter } from '../server/reporters/FinanceReporter.js'; import { FinanceReporter } from '../server/reporters/FinanceReporter.js';
import type { PortfolioHolding } from '../server/types.js';
const PORTFOLIO_PATH = './portfolio.json'; const PORTFOLIO_PATH = './portfolio.json';
async function main() { async function main(): Promise<void> {
// ── 1. Load portfolio if (!existsSync(PORTFOLIO_PATH))
if (!existsSync(PORTFOLIO_PATH)) {
throw new Error('portfolio.json not found — edit it with your holdings and re-run.'); throw new Error('portfolio.json not found — edit it with your holdings and re-run.');
}
const { holdings } = JSON.parse(readFileSync(PORTFOLIO_PATH, 'utf8')); const { holdings } = JSON.parse(readFileSync(PORTFOLIO_PATH, 'utf8')) as {
const byType = holdings.reduce((acc, h) => { holdings: PortfolioHolding[];
};
const byType = holdings.reduce<Record<string, number>>((acc, h) => {
const t = h.type ?? 'stock'; const t = h.type ?? 'stock';
acc[t] = (acc[t] ?? 0) + 1; acc[t] = (acc[t] ?? 0) + 1;
return acc; return acc;
@@ -39,7 +32,7 @@ async function main() {
.join(', ')}\n`, .join(', ')}\n`,
); );
// ── 2. SimpleFIN accounts (optional) // ── SimpleFIN accounts (optional)
let personalFinance = null; let personalFinance = null;
if (process.env.SIMPLEFIN_ACCESS_URL || process.env.SIMPLEFIN_SETUP_TOKEN) { if (process.env.SIMPLEFIN_ACCESS_URL || process.env.SIMPLEFIN_SETUP_TOKEN) {
try { try {
@@ -50,35 +43,43 @@ async function main() {
personalFinance = new PersonalFinanceAnalyzer().analyse(accounts); personalFinance = new PersonalFinanceAnalyzer().analyse(accounts);
process.stdout.write(` ${accounts.length} accounts loaded\n`); process.stdout.write(` ${accounts.length} accounts loaded\n`);
} catch (err) { } catch (err) {
process.stdout.write(` skipped — ${err.message}\n`); process.stdout.write(` skipped — ${(err as Error).message}\n`);
} }
} else { } else {
console.log(' Add SIMPLEFIN_SETUP_TOKEN to .env for account balances & spending data\n'); console.log(' Add SIMPLEFIN_SETUP_TOKEN to .env for account balances & spending data\n');
} }
// ── 3. Screen stocks & ETFs // ── Screen stocks & ETFs
const screenableTickers = holdings const screenableTickers = holdings
.filter((h) => (h.type ?? 'stock') !== 'crypto') .filter((h) => (h.type ?? 'stock') !== 'crypto')
.map((h) => h.ticker.toUpperCase()); .map((h) => h.ticker.toUpperCase());
let results = { STOCK: [], ETF: [], BOND: [], ERROR: [], marketContext: {} }; let results = {
STOCK: [] as any[],
ETF: [] as any[],
BOND: [] as any[],
ERROR: [] as any[],
marketContext: {} as any,
};
if (screenableTickers.length > 0) { if (screenableTickers.length > 0) {
process.stdout.write(`📊 Screening ${screenableTickers.length} stock/ETF positions...`); process.stdout.write(`📊 Screening ${screenableTickers.length} stock/ETF positions...`);
results = await new ScreenerEngine().screenTickers(screenableTickers); results = (await new ScreenerEngine().screenTickers(screenableTickers)) as any;
process.stdout.write(' done\n'); process.stdout.write(' done\n');
} }
// ── 4. Portfolio advice + crypto prices
process.stdout.write('💡 Generating portfolio advice...'); process.stdout.write('💡 Generating portfolio advice...');
const advice = await new PortfolioAdvisor().advise(holdings, results); const advice = await new PortfolioAdvisor().advise(holdings, results);
process.stdout.write(' done\n'); process.stdout.write(' done\n');
// ── 5. Report const reportPath = new FinanceReporter().generate(
const reportPath = new FinanceReporter().generate(advice, personalFinance, results.marketContext); advice as any,
personalFinance,
results.marketContext,
);
console.log(`\n✅ Finance report: ${reportPath}\n`); console.log(`\n✅ Finance report: ${reportPath}\n`);
} }
main().catch((err) => { main().catch((err) => {
console.error('Failed:', err.message); console.error('Failed:', (err as Error).message);
process.exit(1); process.exit(1);
}); });
+12 -11
View File
@@ -1,5 +1,5 @@
/** /**
* bin/screen.js Market Screener CLI * bin/screen.ts Market Screener CLI
* *
* Fetches today's catalyst tickers from Yahoo Finance news, * Fetches today's catalyst tickers from Yahoo Finance news,
* screens them under both Market-Adjusted and Fundamental lenses, * screens them under both Market-Adjusted and Fundamental lenses,
@@ -16,17 +16,14 @@ import { CatalystAnalyst } from '../server/analyst/CatalystAnalyst.js';
import { ScreenerEngine } from '../server/screener/ScreenerEngine.js'; import { ScreenerEngine } from '../server/screener/ScreenerEngine.js';
import { HtmlReporter } from '../server/reporters/HtmlReporter.js'; import { HtmlReporter } from '../server/reporters/HtmlReporter.js';
const DEFAULT_WATCHLIST = [ const DEFAULT_WATCHLIST: string[] = [
// Stocks
'PLTR', 'PLTR',
'AAPL', 'AAPL',
'MSFT', 'MSFT',
'TSLA', 'TSLA',
'O', 'O',
// ETFs
'VOO', 'VOO',
'QQQ', 'QQQ',
// Bonds
'BND', 'BND',
'LQD', 'LQD',
'TLT', 'TLT',
@@ -37,9 +34,9 @@ const DEFAULT_WATCHLIST = [
'MUB', 'MUB',
]; ];
async function main() { async function main(): Promise<void> {
const args = process.argv.slice(2); const args = process.argv.slice(2);
let tickers = []; let tickers: string[] = [];
if (args.length > 0 && args[0] !== 'watch') { if (args.length > 0 && args[0] !== 'watch') {
tickers = args.map((t) => t.toUpperCase()); tickers = args.map((t) => t.toUpperCase());
@@ -50,7 +47,6 @@ async function main() {
} else { } else {
try { try {
const { tickers: newsTickers, stories } = await new CatalystAnalyst().run(); const { tickers: newsTickers, stories } = await new CatalystAnalyst().run();
if (newsTickers.length === 0) { if (newsTickers.length === 0) {
console.warn("⚠ No tickers in today's news — using default watchlist\n"); console.warn("⚠ No tickers in today's news — using default watchlist\n");
tickers = DEFAULT_WATCHLIST; tickers = DEFAULT_WATCHLIST;
@@ -64,7 +60,9 @@ async function main() {
console.log(`\n📋 Tickers: ${tickers.join(', ')}\n`); console.log(`\n📋 Tickers: ${tickers.join(', ')}\n`);
} }
} catch (err) { } catch (err) {
console.warn(`⚠ Catalyst analysis failed (${err.message}) — using default watchlist\n`); console.warn(
`⚠ Catalyst analysis failed (${(err as Error).message}) — using default watchlist\n`,
);
tickers = DEFAULT_WATCHLIST; tickers = DEFAULT_WATCHLIST;
} }
} }
@@ -72,10 +70,13 @@ async function main() {
try { try {
const { STOCK, ETF, BOND, ERROR, marketContext } = const { STOCK, ETF, BOND, ERROR, marketContext } =
await new ScreenerEngine().screenWithProgress(tickers); await new ScreenerEngine().screenWithProgress(tickers);
const reportPath = new HtmlReporter().generate({ STOCK, ETF, BOND, ERROR }, marketContext); const reportPath = new HtmlReporter().generate(
{ STOCK, ETF, BOND, ERROR } as any,
marketContext,
);
console.log(`\n✅ Done — report saved to: ${reportPath}\n`); console.log(`\n✅ Done — report saved to: ${reportPath}\n`);
} catch (err) { } catch (err) {
console.error('Screener failed:', err.message); console.error('Screener failed:', (err as Error).message);
process.exit(1); process.exit(1);
} }
} }
View File
+553 -1
View File
@@ -15,10 +15,13 @@
"yahoo-finance2": "^3.15.2" "yahoo-finance2": "^3.15.2"
}, },
"devDependencies": { "devDependencies": {
"@types/node": "^22.0.0",
"concurrently": "^10.0.3", "concurrently": "^10.0.3",
"husky": "^9.0.0", "husky": "^9.0.0",
"lint-staged": "^15.0.0", "lint-staged": "^15.0.0",
"prettier": "^3.0.0" "prettier": "^3.0.0",
"tsx": "^4.0.0",
"typescript": "^5.0.0"
} }
}, },
"node_modules/@anthropic-ai/sdk": { "node_modules/@anthropic-ai/sdk": {
@@ -65,6 +68,448 @@
"integrity": "sha512-4nMhecpGlPi0cSzT67L+Tm+GOJqvuk8gqHBziqcUQOarnuIax1z96/gJHCSIz2Z0zhxE6Rzwb3IZXPtFh51j+w==", "integrity": "sha512-4nMhecpGlPi0cSzT67L+Tm+GOJqvuk8gqHBziqcUQOarnuIax1z96/gJHCSIz2Z0zhxE6Rzwb3IZXPtFh51j+w==",
"license": "MIT" "license": "MIT"
}, },
"node_modules/@esbuild/aix-ppc64": {
"version": "0.28.0",
"resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.28.0.tgz",
"integrity": "sha512-lhRUCeuOyJQURhTxl4WkpFTjIsbDayJHih5kZC1giwE+MhIzAb7mEsQMqMf18rHLsrb5qI1tafG20mLxEWcWlA==",
"cpu": [
"ppc64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"aix"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/android-arm": {
"version": "0.28.0",
"resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.28.0.tgz",
"integrity": "sha512-wqh0ByljabXLKHeWXYLqoJ5jKC4XBaw6Hk08OfMrCRd2nP2ZQ5eleDZC41XHyCNgktBGYMbqnrJKq/K/lzPMSQ==",
"cpu": [
"arm"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"android"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/android-arm64": {
"version": "0.28.0",
"resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.28.0.tgz",
"integrity": "sha512-+WzIXQOSaGs33tLEgYPYe/yQHf0WTU0X42Jca3y8NWMbUVhp7rUnw+vAsRC/QiDrdD31IszMrZy+qwPOPjd+rw==",
"cpu": [
"arm64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"android"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/android-x64": {
"version": "0.28.0",
"resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.28.0.tgz",
"integrity": "sha512-+VJggoaKhk2VNNqVL7f6S189UzShHC/mR9EE8rDdSkdpN0KflSwWY/gWjDrNxxisg8Fp1ZCD9jLMo4m0OUfeUA==",
"cpu": [
"x64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"android"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/darwin-arm64": {
"version": "0.28.0",
"resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.28.0.tgz",
"integrity": "sha512-0T+A9WZm+bZ84nZBtk1ckYsOvyA3x7e2Acj1KdVfV4/2tdG4fzUp91YHx+GArWLtwqp77pBXVCPn2We7Letr0Q==",
"cpu": [
"arm64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"darwin"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/darwin-x64": {
"version": "0.28.0",
"resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.28.0.tgz",
"integrity": "sha512-fyzLm/DLDl/84OCfp2f/XQ4flmORsjU7VKt8HLjvIXChJoFFOIL6pLJPH4Yhd1n1gGFF9mPwtlN5Wf82DZs+LQ==",
"cpu": [
"x64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"darwin"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/freebsd-arm64": {
"version": "0.28.0",
"resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.28.0.tgz",
"integrity": "sha512-l9GeW5UZBT9k9brBYI+0WDffcRxgHQD8ShN2Ur4xWq/NFzUKm3k5lsH4PdaRgb2w7mI9u61nr2gI2mLI27Nh3Q==",
"cpu": [
"arm64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"freebsd"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/freebsd-x64": {
"version": "0.28.0",
"resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.28.0.tgz",
"integrity": "sha512-BXoQai/A0wPO6Es3yFJ7APCiKGc1tdAEOgeTNy3SsB491S3aHn4S4r3e976eUnPdU+NbdtmBuLncYir2tMU9Nw==",
"cpu": [
"x64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"freebsd"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/linux-arm": {
"version": "0.28.0",
"resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.28.0.tgz",
"integrity": "sha512-CjaaREJagqJp7iTaNQjjidaNbCKYcd4IDkzbwwxtSvjI7NZm79qiHc8HqciMddQ6CKvJT6aBd8lO9kN/ZudLlw==",
"cpu": [
"arm"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/linux-arm64": {
"version": "0.28.0",
"resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.28.0.tgz",
"integrity": "sha512-RVyzfb3FWsGA55n6WY0MEIEPURL1FcbhFE6BffZEMEekfCzCIMtB5yyDcFnVbTnwk+CLAgTujmV/Lgvih56W+A==",
"cpu": [
"arm64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/linux-ia32": {
"version": "0.28.0",
"resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.28.0.tgz",
"integrity": "sha512-KBnSTt1kxl9x70q+ydterVdl+Cn0H18ngRMRCEQfrbqdUuntQQ0LoMZv47uB97NljZFzY6HcfqEZ2SAyIUTQBQ==",
"cpu": [
"ia32"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/linux-loong64": {
"version": "0.28.0",
"resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.28.0.tgz",
"integrity": "sha512-zpSlUce1mnxzgBADvxKXX5sl8aYQHo2ezvMNI8I0lbblJtp8V4odlm3Yzlj7gPyt3T8ReksE6bK+pT3WD+aJRg==",
"cpu": [
"loong64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/linux-mips64el": {
"version": "0.28.0",
"resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.28.0.tgz",
"integrity": "sha512-2jIfP6mmjkdmeTlsX/9vmdmhBmKADrWqN7zcdtHIeNSCH1SqIoNI63cYsjQR8J+wGa4Y5izRcSHSm8K3QWmk3w==",
"cpu": [
"mips64el"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/linux-ppc64": {
"version": "0.28.0",
"resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.28.0.tgz",
"integrity": "sha512-bc0FE9wWeC0WBm49IQMPSPILRocGTQt3j5KPCA8os6VprfuJ7KD+5PzESSrJ6GmPIPJK965ZJHTUlSA6GNYEhg==",
"cpu": [
"ppc64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/linux-riscv64": {
"version": "0.28.0",
"resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.28.0.tgz",
"integrity": "sha512-SQPZOwoTTT/HXFXQJG/vBX8sOFagGqvZyXcgLA3NhIqcBv1BJU1d46c0rGcrij2B56Z2rNiSLaZOYW5cUk7yLQ==",
"cpu": [
"riscv64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/linux-s390x": {
"version": "0.28.0",
"resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.28.0.tgz",
"integrity": "sha512-SCfR0HN8CEEjnYnySJTd2cw0k9OHB/YFzt5zgJEwa+wL/T/raGWYMBqwDNAC6dqFKmJYZoQBRfHjgwLHGSrn3Q==",
"cpu": [
"s390x"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/linux-x64": {
"version": "0.28.0",
"resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.28.0.tgz",
"integrity": "sha512-us0dSb9iFxIi8srnpl931Nvs65it/Jd2a2K3qs7fz2WfGPHqzfzZTfec7oxZJRNPXPnNYZtanmRc4AL/JwVzHQ==",
"cpu": [
"x64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/netbsd-arm64": {
"version": "0.28.0",
"resolved": "https://registry.npmjs.org/@esbuild/netbsd-arm64/-/netbsd-arm64-0.28.0.tgz",
"integrity": "sha512-CR/RYotgtCKwtftMwJlUU7xCVNg3lMYZ0RzTmAHSfLCXw3NtZtNpswLEj/Kkf6kEL3Gw+BpOekRX0BYCtklhUw==",
"cpu": [
"arm64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"netbsd"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/netbsd-x64": {
"version": "0.28.0",
"resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.28.0.tgz",
"integrity": "sha512-nU1yhmYutL+fQ71Kxnhg8uEOdC0pwEW9entHykTgEbna2pw2dkbFSMeqjjyHZoCmt8SBkOSvV+yNmm94aUrrqw==",
"cpu": [
"x64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"netbsd"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/openbsd-arm64": {
"version": "0.28.0",
"resolved": "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.28.0.tgz",
"integrity": "sha512-cXb5vApOsRsxsEl4mcZ1XY3D4DzcoMxR/nnc4IyqYs0rTI8ZKmW6kyyg+11Z8yvgMfAEldKzP7AdP64HnSC/6g==",
"cpu": [
"arm64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"openbsd"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/openbsd-x64": {
"version": "0.28.0",
"resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.28.0.tgz",
"integrity": "sha512-8wZM2qqtv9UP3mzy7HiGYNH/zjTA355mpeuA+859TyR+e+Tc08IHYpLJuMsfpDJwoLo1ikIJI8jC3GFjnRClzA==",
"cpu": [
"x64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"openbsd"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/openharmony-arm64": {
"version": "0.28.0",
"resolved": "https://registry.npmjs.org/@esbuild/openharmony-arm64/-/openharmony-arm64-0.28.0.tgz",
"integrity": "sha512-FLGfyizszcef5C3YtoyQDACyg95+dndv79i2EekILBofh5wpCa1KuBqOWKrEHZg3zrL3t5ouE5jgr94vA+Wb2w==",
"cpu": [
"arm64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"openharmony"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/sunos-x64": {
"version": "0.28.0",
"resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.28.0.tgz",
"integrity": "sha512-1ZgjUoEdHZZl/YlV76TSCz9Hqj9h9YmMGAgAPYd+q4SicWNX3G5GCyx9uhQWSLcbvPW8Ni7lj4gDa1T40akdlw==",
"cpu": [
"x64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"sunos"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/win32-arm64": {
"version": "0.28.0",
"resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.28.0.tgz",
"integrity": "sha512-Q9StnDmQ/enxnpxCCLSg0oo4+34B9TdXpuyPeTedN/6+iXBJ4J+zwfQI28u/Jl40nOYAxGoNi7mFP40RUtkmUA==",
"cpu": [
"arm64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"win32"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/win32-ia32": {
"version": "0.28.0",
"resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.28.0.tgz",
"integrity": "sha512-zF3ag/gfiCe6U2iczcRzSYJKH1DCI+ByzSENHlM2FcDbEeo5Zd2C86Aq0tKUYAJJ1obRP84ymxIAksZUcdztHA==",
"cpu": [
"ia32"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"win32"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/win32-x64": {
"version": "0.28.0",
"resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.28.0.tgz",
"integrity": "sha512-pEl1bO9mfAmIC+tW5btTmrKaujg3zGtUmWNdCw/xs70FBjwAL3o9OEKNHvNmnyylD6ubxUERiEhdsL0xBQ9efw==",
"cpu": [
"x64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"win32"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@fastify/ajv-compiler": { "node_modules/@fastify/ajv-compiler": {
"version": "4.0.5", "version": "4.0.5",
"resolved": "https://registry.npmjs.org/@fastify/ajv-compiler/-/ajv-compiler-4.0.5.tgz", "resolved": "https://registry.npmjs.org/@fastify/ajv-compiler/-/ajv-compiler-4.0.5.tgz",
@@ -259,6 +704,16 @@
"resolved": "https://registry.npmjs.org/@stablelib/base64/-/base64-1.0.1.tgz", "resolved": "https://registry.npmjs.org/@stablelib/base64/-/base64-1.0.1.tgz",
"integrity": "sha512-1bnPQqSxSuc3Ii6MhBysoWCg58j97aUjuCSZrGSmDxNqtytIi0k8utUenAwTZN4V5mXXYGsVUI9zeBqy+jBOSQ==" "integrity": "sha512-1bnPQqSxSuc3Ii6MhBysoWCg58j97aUjuCSZrGSmDxNqtytIi0k8utUenAwTZN4V5mXXYGsVUI9zeBqy+jBOSQ=="
}, },
"node_modules/@types/node": {
"version": "22.19.19",
"resolved": "https://registry.npmjs.org/@types/node/-/node-22.19.19.tgz",
"integrity": "sha512-dyh/xO2Fh5bYrfWaaqGrRQQGkNdmYw6AmaAUvYeUMNTWQtvb796ikLdmTchRmOlOiIJ1TDXfWgVx1QkUlQ6Hew==",
"dev": true,
"license": "MIT",
"dependencies": {
"undici-types": "~6.21.0"
}
},
"node_modules/abstract-logging": { "node_modules/abstract-logging": {
"version": "2.0.1", "version": "2.0.1",
"resolved": "https://registry.npmjs.org/abstract-logging/-/abstract-logging-2.0.1.tgz", "resolved": "https://registry.npmjs.org/abstract-logging/-/abstract-logging-2.0.1.tgz",
@@ -761,6 +1216,48 @@
"node": ">= 0.4" "node": ">= 0.4"
} }
}, },
"node_modules/esbuild": {
"version": "0.28.0",
"resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.28.0.tgz",
"integrity": "sha512-sNR9MHpXSUV/XB4zmsFKN+QgVG82Cc7+/aaxJ8Adi8hyOac+EXptIp45QBPaVyX3N70664wRbTcLTOemCAnyqw==",
"dev": true,
"hasInstallScript": true,
"license": "MIT",
"bin": {
"esbuild": "bin/esbuild"
},
"engines": {
"node": ">=18"
},
"optionalDependencies": {
"@esbuild/aix-ppc64": "0.28.0",
"@esbuild/android-arm": "0.28.0",
"@esbuild/android-arm64": "0.28.0",
"@esbuild/android-x64": "0.28.0",
"@esbuild/darwin-arm64": "0.28.0",
"@esbuild/darwin-x64": "0.28.0",
"@esbuild/freebsd-arm64": "0.28.0",
"@esbuild/freebsd-x64": "0.28.0",
"@esbuild/linux-arm": "0.28.0",
"@esbuild/linux-arm64": "0.28.0",
"@esbuild/linux-ia32": "0.28.0",
"@esbuild/linux-loong64": "0.28.0",
"@esbuild/linux-mips64el": "0.28.0",
"@esbuild/linux-ppc64": "0.28.0",
"@esbuild/linux-riscv64": "0.28.0",
"@esbuild/linux-s390x": "0.28.0",
"@esbuild/linux-x64": "0.28.0",
"@esbuild/netbsd-arm64": "0.28.0",
"@esbuild/netbsd-x64": "0.28.0",
"@esbuild/openbsd-arm64": "0.28.0",
"@esbuild/openbsd-x64": "0.28.0",
"@esbuild/openharmony-arm64": "0.28.0",
"@esbuild/sunos-x64": "0.28.0",
"@esbuild/win32-arm64": "0.28.0",
"@esbuild/win32-ia32": "0.28.0",
"@esbuild/win32-x64": "0.28.0"
}
},
"node_modules/escalade": { "node_modules/escalade": {
"version": "3.2.0", "version": "3.2.0",
"resolved": "https://registry.npmjs.org/escalade/-/escalade-3.2.0.tgz", "resolved": "https://registry.npmjs.org/escalade/-/escalade-3.2.0.tgz",
@@ -1139,6 +1636,21 @@
"node": ">= 0.8" "node": ">= 0.8"
} }
}, },
"node_modules/fsevents": {
"version": "2.3.3",
"resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz",
"integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==",
"dev": true,
"hasInstallScript": true,
"license": "MIT",
"optional": true,
"os": [
"darwin"
],
"engines": {
"node": "^8.16.0 || ^10.6.0 || >=11.0.0"
}
},
"node_modules/function-bind": { "node_modules/function-bind": {
"version": "1.1.2", "version": "1.1.2",
"resolved": "https://registry.npmjs.org/function-bind/-/function-bind-1.1.2.tgz", "resolved": "https://registry.npmjs.org/function-bind/-/function-bind-1.1.2.tgz",
@@ -2624,6 +3136,25 @@
"integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==", "integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==",
"dev": true "dev": true
}, },
"node_modules/tsx": {
"version": "4.22.4",
"resolved": "https://registry.npmjs.org/tsx/-/tsx-4.22.4.tgz",
"integrity": "sha512-X8EX+XV4QR5xCsrgxaED954zTDfY8KqlDtskKEL0cHhyS/P8b4IFOvGDQpsC9Q1XnLq915wEfwwY/zzskCtmhg==",
"dev": true,
"license": "MIT",
"dependencies": {
"esbuild": "~0.28.0"
},
"bin": {
"tsx": "dist/cli.mjs"
},
"engines": {
"node": ">=18.0.0"
},
"optionalDependencies": {
"fsevents": "~2.3.3"
}
},
"node_modules/type-is": { "node_modules/type-is": {
"version": "2.1.0", "version": "2.1.0",
"resolved": "https://registry.npmjs.org/type-is/-/type-is-2.1.0.tgz", "resolved": "https://registry.npmjs.org/type-is/-/type-is-2.1.0.tgz",
@@ -2655,6 +3186,27 @@
"url": "https://opencollective.com/express" "url": "https://opencollective.com/express"
} }
}, },
"node_modules/typescript": {
"version": "5.9.3",
"resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz",
"integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==",
"dev": true,
"license": "Apache-2.0",
"bin": {
"tsc": "bin/tsc",
"tsserver": "bin/tsserver"
},
"engines": {
"node": ">=14.17"
}
},
"node_modules/undici-types": {
"version": "6.21.0",
"resolved": "https://registry.npmjs.org/undici-types/-/undici-types-6.21.0.tgz",
"integrity": "sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ==",
"dev": true,
"license": "MIT"
},
"node_modules/universalify": { "node_modules/universalify": {
"version": "0.2.0", "version": "0.2.0",
"resolved": "https://registry.npmjs.org/universalify/-/universalify-0.2.0.tgz", "resolved": "https://registry.npmjs.org/universalify/-/universalify-0.2.0.tgz",
+14 -10
View File
@@ -3,19 +3,20 @@
"version": "2.0.0", "version": "2.0.0",
"type": "module", "type": "module",
"scripts": { "scripts": {
"start": "node bin/screen.js", "start": "tsx bin/screen.ts",
"server": "node bin/server.js", "server": "tsx bin/server.ts",
"dev": "concurrently -n api,ui -c cyan,magenta \"node bin/server.js\" \"npm run dev --prefix ui\"", "dev": "concurrently -n api,ui -c cyan,magenta \"tsx bin/server.ts\" \"npm run dev --prefix ui\"",
"ui:install": "npm install --prefix ui --legacy-peer-deps", "ui:install": "npm install --prefix ui --legacy-peer-deps",
"finance": "node bin/finance.js", "finance": "tsx bin/finance.ts",
"test": "node --test --test-reporter=./scripts/summary-reporter.js tests/*.test.js", "typecheck": "tsc --noEmit",
"test:watch": "node --test --watch --test-reporter=spec tests/*.test.js", "test": "tsx --test --test-reporter=./scripts/summary-reporter.js tests/*.test.js",
"format": "prettier --write \"src/**/*.js\" \"bin/**/*.js\" \"tests/**/*.js\"", "test:watch": "tsx --test --watch --test-reporter=spec tests/*.test.js",
"format:check": "prettier --check \"src/**/*.js\" \"bin/**/*.js\" \"tests/**/*.js\"", "format": "prettier --write \"server/**/*.ts\" \"bin/**/*.ts\" \"tests/**/*.js\"",
"format:check": "prettier --check \"server/**/*.ts\" \"bin/**/*.ts\" \"tests/**/*.js\"",
"prepare": "husky" "prepare": "husky"
}, },
"lint-staged": { "lint-staged": {
"*.js": [ "*.{ts,js}": [
"prettier --write" "prettier --write"
] ]
}, },
@@ -27,9 +28,12 @@
"yahoo-finance2": "^3.15.2" "yahoo-finance2": "^3.15.2"
}, },
"devDependencies": { "devDependencies": {
"@types/node": "^22.0.0",
"concurrently": "^10.0.3", "concurrently": "^10.0.3",
"husky": "^9.0.0", "husky": "^9.0.0",
"lint-staged": "^15.0.0", "lint-staged": "^15.0.0",
"prettier": "^3.0.0" "prettier": "^3.0.0",
"tsx": "^4.0.0",
"typescript": "^5.0.0"
} }
} }
-1
View File
@@ -1 +0,0 @@
{ "holdings": [] }
@@ -1,16 +1,32 @@
import { YahooClient } from '../market/YahooClient.js'; import { YahooClient } from '../market/YahooClient.js';
import type { Logger } from '../types.js';
interface Story {
title: string;
publisher: string;
link: string;
relatedTickers: string[];
}
interface CatalystResult {
tickers: string[];
stories: Story[];
}
const NEWS_QUERIES = ['stock market today', 'earnings report', 'market news']; const NEWS_QUERIES = ['stock market today', 'earnings report', 'market news'];
const MAX_STORIES = 15; const MAX_STORIES = 15;
const TICKER_REGEX = /^[A-Z]{1,6}$/; const TICKER_REGEX = /^[A-Z]{1,6}$/;
export class CatalystAnalyst { export class CatalystAnalyst {
constructor({ logger } = {}) { private client: YahooClient;
private logger: Pick<Logger, 'write'>;
constructor({ logger }: { logger?: Pick<Logger, 'write'> } = {}) {
this.client = new YahooClient(); this.client = new YahooClient();
this.logger = logger ?? { write: (msg) => process.stdout.write(msg) }; this.logger = logger ?? { write: (msg: string) => process.stdout.write(msg) };
} }
async run() { async run(): Promise<CatalystResult> {
this.logger.write('🔍 Fetching market news...'); this.logger.write('🔍 Fetching market news...');
const stories = await this._fetchNews(); const stories = await this._fetchNews();
const tickers = this._extractTickers(stories); const tickers = this._extractTickers(stories);
@@ -18,12 +34,15 @@ export class CatalystAnalyst {
return { tickers, stories }; return { tickers, stories };
} }
async _fetchNews() { private async _fetchNews(): Promise<Story[]> {
const seen = new Map(); const seen = new Map<string, Story>();
for (const query of NEWS_QUERIES) { for (const query of NEWS_QUERIES) {
try { try {
const { news = [] } = await this.client.yf.search(query, { newsCount: 8, quotesCount: 0 }); const { news = [] } = await (this.client as any).yf.search(query, {
for (const s of news) { newsCount: 8,
quotesCount: 0,
});
for (const s of news as any[]) {
if (!seen.has(s.title)) { if (!seen.has(s.title)) {
seen.set(s.title, { seen.set(s.title, {
title: s.title, title: s.title,
@@ -40,8 +59,8 @@ export class CatalystAnalyst {
return [...seen.values()].slice(0, MAX_STORIES); return [...seen.values()].slice(0, MAX_STORIES);
} }
_extractTickers(stories) { private _extractTickers(stories: Story[]): string[] {
const tickers = new Set(); const tickers = new Set<string>();
for (const { relatedTickers } of stories) { for (const { relatedTickers } of stories) {
for (const t of relatedTickers) { for (const t of relatedTickers) {
const clean = t.split(':')[0].toUpperCase(); const clean = t.split(':')[0].toUpperCase();
@@ -1,15 +1,10 @@
import Anthropic from '@anthropic-ai/sdk'; import Anthropic from '@anthropic-ai/sdk';
import type { Logger, LLMAnalysis } from '../types.js';
// LLMAnalyst — uses Claude Haiku to analyze news catalyst stories. interface Story {
// title: string;
// Given a list of news headlines and the tickers already identified, publisher?: string;
// it produces: }
// - A concise market summary (2-3 sentences)
// - Industries likely to be affected (beyond the directly mentioned tickers)
// - Up to 5 related tickers worth watching
// - A risk sentiment assessment (BULLISH / NEUTRAL / BEARISH)
//
// Requires ANTHROPIC_API_KEY in environment.
const SYSTEM_PROMPT = `You are a professional equity analyst. You will be given a list of today's market news headlines and the tickers already identified as catalysts. const SYSTEM_PROMPT = `You are a professional equity analyst. You will be given a list of today's market news headlines and the tickers already identified as catalysts.
@@ -32,21 +27,21 @@ Return ONLY valid JSON in this exact shape — no markdown, no explanation:
}`; }`;
export class LLMAnalyst { export class LLMAnalyst {
constructor({ logger } = {}) { private logger: Pick<Logger, 'log' | 'warn'>;
private client: Anthropic | null;
constructor({ logger }: { logger?: Pick<Logger, 'log' | 'warn'> } = {}) {
this.logger = logger ?? { log: console.log, warn: console.warn }; this.logger = logger ?? { log: console.log, warn: console.warn };
this.client = process.env.ANTHROPIC_API_KEY this.client = process.env.ANTHROPIC_API_KEY
? new Anthropic({ apiKey: process.env.ANTHROPIC_API_KEY }) ? new Anthropic({ apiKey: process.env.ANTHROPIC_API_KEY })
: null; : null;
} }
// Analyzes news stories and returns structured market intelligence. async analyze(stories: Story[], existingTickers: string[] = []): Promise<LLMAnalysis | null> {
// Returns null if ANTHROPIC_API_KEY is not set (graceful degradation).
async analyze(stories, existingTickers = []) {
if (!this.client) { if (!this.client) {
this.logger.warn('LLMAnalyst: ANTHROPIC_API_KEY not set — skipping analysis'); this.logger.warn('LLMAnalyst: ANTHROPIC_API_KEY not set — skipping analysis');
return null; return null;
} }
if (!stories?.length) return null; if (!stories?.length) return null;
const headlines = stories const headlines = stories
@@ -64,14 +59,14 @@ export class LLMAnalyst {
messages: [{ role: 'user', content: userMessage }], messages: [{ role: 'user', content: userMessage }],
}); });
const raw = response.content[0]?.text ?? ''; const raw = (response.content[0] as { text?: string })?.text ?? '';
const cleaned = raw const cleaned = raw
.replace(/^```(?:json)?\s*/i, '') .replace(/^```(?:json)?\s*/i, '')
.replace(/```\s*$/i, '') .replace(/```\s*$/i, '')
.trim(); .trim();
return JSON.parse(cleaned); return JSON.parse(cleaned) as LLMAnalysis;
} catch (err) { } catch (err) {
this.logger.warn('LLMAnalyst: analysis failed —', err.message); this.logger.warn('LLMAnalyst: analysis failed —', (err as Error).message);
return null; return null;
} }
} }
-80
View File
@@ -1,80 +0,0 @@
import { readFileSync, writeFileSync, existsSync } from 'fs';
import { randomUUID } from 'crypto';
const STORE_PATH = './market-calls.json';
// MarketCallStore — persists quarterly market thesis entries to market-calls.json.
//
// A market call captures:
// - A written thesis (the reasoning behind the call)
// - Tickers to watch
// - A snapshot of each ticker's price + signal at the time of the call
// - Performance tracking (current vs snapshot price) computed on read
//
// Format:
// {
// "calls": [
// {
// "id": "uuid",
// "title": "Q3 2025 — Rate pivot & tech rotation",
// "quarter": "Q3 2025",
// "date": "2025-07-01",
// "thesis": "The Fed is expected to begin cutting...",
// "tickers": ["AAPL", "MSFT", "TLT"],
// "snapshot": {
// "AAPL": { "price": 195.00, "signal": "✅ Strong Buy", "verdict": "BUY (High Conviction)" }
// },
// "createdAt": "2025-07-01T14:22:00.000Z"
// }
// ]
// }
export class MarketCallStore {
_load() {
if (!existsSync(STORE_PATH)) return { calls: [] };
try {
return JSON.parse(readFileSync(STORE_PATH, 'utf8'));
} catch {
return { calls: [] };
}
}
_save(data) {
writeFileSync(STORE_PATH, JSON.stringify(data, null, 2), 'utf8');
}
list() {
return this._load().calls.sort((a, b) => new Date(b.createdAt) - new Date(a.createdAt));
}
get(id) {
return this._load().calls.find((c) => c.id === id) ?? null;
}
// Create a new call. snapshot is an object keyed by ticker with { price, signal, verdict }.
create({ title, quarter, date, thesis, tickers, snapshot }) {
const data = this._load();
const call = {
id: randomUUID(),
title,
quarter,
date: date ?? new Date().toISOString().slice(0, 10),
thesis,
tickers,
snapshot: snapshot ?? {},
createdAt: new Date().toISOString(),
};
data.calls.push(call);
this._save(data);
return call;
}
delete(id) {
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);
return true;
}
}
+76
View File
@@ -0,0 +1,76 @@
import { readFileSync, writeFileSync, existsSync } from 'fs';
import { randomUUID } from 'crypto';
import type { MarketCall, Signal, TickerSnapshot } from '../types.js';
const STORE_PATH = './market-calls.json';
interface StoreData {
calls: (MarketCall & { createdAt: string })[];
}
interface CreateCallInput {
title: string;
quarter: string;
date?: string;
thesis: string;
tickers: string[];
snapshot?: Record<string, TickerSnapshot>;
}
export class MarketCallStore {
private _load(): StoreData {
if (!existsSync(STORE_PATH)) return { calls: [] };
try {
return JSON.parse(readFileSync(STORE_PATH, 'utf8')) as StoreData;
} catch {
return { calls: [] };
}
}
private _save(data: StoreData): void {
writeFileSync(STORE_PATH, JSON.stringify(data, null, 2), 'utf8');
}
list(): (MarketCall & { createdAt: string })[] {
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;
}
create({
title,
quarter,
date,
thesis,
tickers,
snapshot,
}: CreateCallInput): MarketCall & { createdAt: string } {
const data = this._load();
const call = {
id: randomUUID(),
title,
quarter,
date: date ?? new Date().toISOString().slice(0, 10),
thesis,
tickers,
snapshot: snapshot ?? {},
createdAt: new Date().toISOString(),
};
data.calls.push(call);
this._save(data);
return call;
}
delete(id: string): boolean {
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);
return true;
}
}
@@ -1,7 +1,9 @@
// Credit rating scale (S&P convention). import type { Sector } from './constants.js';
// Bond.js converts letter ratings to these numbers; BondScorer uses them for gate checks.
// ── Credit rating scale (S&P convention) ─────────────────────────────────
// Bond.ts converts letter ratings to these numbers; BondScorer uses them for gate checks.
// Investment grade = BBB (7) and above. // Investment grade = BBB (7) and above.
export const CREDIT_RATING_SCALE = { export const CREDIT_RATING_SCALE: Record<string, number> = {
AAA: 10, AAA: 10,
AA: 9, AA: 9,
A: 8, A: 8,
@@ -14,16 +16,38 @@ export const CREDIT_RATING_SCALE = {
D: 1, D: 1,
}; };
// ── Scoring rule shape ────────────────────────────────────────────────────
interface GateSet extends Record<string, number> {}
interface WeightSet extends Record<string, number> {}
interface ThresholdSet extends Record<string, number> {}
interface RuleBlock {
gates: GateSet;
weights: WeightSet;
thresholds: ThresholdSet;
}
interface StockRules extends RuleBlock {
SECTOR_OVERRIDE: Partial<Record<Sector, Partial<RuleBlock>>>;
}
interface ScoringRulesShape {
STOCK: StockRules;
ETF: RuleBlock;
BOND: RuleBlock;
}
// ───────────────────────────────────────────────────────────────────────────── // ─────────────────────────────────────────────────────────────────────────────
// Fundamental baseline — Graham / value-investing style. // Fundamental baseline — Graham / value-investing style.
// MarketRegime.js overrides the valuation gates for INFLATED-mode analysis. // MarketRegime.ts overrides the valuation gates for INFLATED-mode analysis.
// Sector overrides are structural — they apply in both modes. // Sector overrides are structural — they apply in both modes.
// ───────────────────────────────────────────────────────────────────────────── // ─────────────────────────────────────────────────────────────────────────────
export const ScoringRules = { export const ScoringRules: ScoringRulesShape = {
STOCK: { STOCK: {
gates: { gates: {
maxDebtToEquity: 1.5, // Graham ceiling; 3.0 was too permissive — most distress starts above 2x maxDebtToEquity: 1.5, // Graham ceiling; most distress starts above 2x
minQuickRatio: 0.8, // Raised from 0.5: below 0.8 signals real liquidity stress in non-tech minQuickRatio: 0.8, // below 0.8 signals real liquidity stress in non-tech
maxPERatio: 15, // Graham's actual rule: never pay more than 15x trailing earnings maxPERatio: 15, // Graham's actual rule: never pay more than 15x trailing earnings
maxPegGate: 1.0, // PEG > 1.0 means you're paying full price for growth (Lynch standard) maxPegGate: 1.0, // PEG > 1.0 means you're paying full price for growth (Lynch standard)
}, },
@@ -33,48 +57,36 @@ export const ScoringRules = {
roe: 3, // return on equity — Buffett's primary quality metric roe: 3, // return on equity — Buffett's primary quality metric
peg: 2, // valuation relative to growth peg: 2, // valuation relative to growth
revenue: 2, // revenue growth revenue: 2, // revenue growth
fcf: 3, // raised: FCF is the most manipulation-resistant quality signal fcf: 3, // FCF is the most manipulation-resistant quality signal
}, },
thresholds: { thresholds: {
marginHigh: 15, // lowered from 20: 15% net margin is genuinely excellent across most sectors marginHigh: 15, // 15% net margin is genuinely excellent across most sectors
marginMed: 8, // lowered from 10: 8% is the realistic mid-tier for industrials/retail marginMed: 8, // 8% is the realistic mid-tier for industrials/retail
opMarginHigh: 20, opMarginHigh: 20,
opMarginMed: 10, opMarginMed: 10,
roeHigh: 15, // lowered from 20: sustainable 15% ROE is Buffett-quality; 20% is rare/fleeting roeHigh: 15, // sustainable 15% ROE is Buffett-quality; 20% is rare/fleeting
roeMed: 10, // kept — 10% is the cost-of-equity floor for most businesses roeMed: 10, // 10% is the cost-of-equity floor for most businesses
pegHigh: 0.75, // raised bar: PEG < 0.75 is genuinely cheap relative to growth pegHigh: 0.75, // PEG < 0.75 is genuinely cheap relative to growth
pegMed: 1.0, pegMed: 1.0,
revHigh: 10, // lowered from 15: 10% organic revenue growth is strong for mature cos revHigh: 10, // 10% organic revenue growth is strong for mature cos
revMed: 5, revMed: 5,
fcfHigh: 5, fcfHigh: 5,
fcfMed: 2, fcfMed: 2,
}, },
SECTOR_OVERRIDE: { SECTOR_OVERRIDE: {
// Large-cap tech borrows to fund buybacks — D/E 2.0 is structural, not distress.
// AAPL quick ratio runs ~0.9 by design (aggressive working capital management).
// Raised maxPERatio from 30→35: mega-cap tech comps (MSFT, GOOG) trade 28-35x sustainably.
// Tightened maxPegGate from 2.0→1.5: paying >1.5x PEG for tech rarely ends well long-term.
TECHNOLOGY: { TECHNOLOGY: {
gates: { maxDebtToEquity: 2.0, minQuickRatio: 0.8, maxPERatio: 35, maxPegGate: 1.5 }, gates: { maxDebtToEquity: 2.0, minQuickRatio: 0.8, maxPERatio: 35, maxPegGate: 1.5 },
weights: { margin: 1, opMargin: 3, roe: 3, peg: 3, revenue: 4, fcf: 3 }, weights: { margin: 1, opMargin: 3, roe: 3, peg: 3, revenue: 4, fcf: 3 },
thresholds: { marginHigh: 25, opMarginHigh: 25, roeHigh: 20, pegHigh: 1.0, revHigh: 20 }, thresholds: { marginHigh: 25, opMarginHigh: 25, roeHigh: 20, pegHigh: 1.0, revHigh: 20 },
}, },
// REITs: P/E and PEG are distorted by depreciation — score on yield and P/FFO.
// Raised minYield from 4.0→4.5: 10Y yield at 4.5%+ means REITs must clear that bar to add value.
// Tightened maxPFFO from 15→18: 15 was too tight; well-run REITs (O, VICI) trade 17-22x P/FFO.
// Explicitly zero out weights that don't apply to REITs.
REIT: { REIT: {
gates: { maxDebtToEquity: 6.0, minQuickRatio: 0.1, maxPERatio: 9999, maxPegGate: 9999 }, gates: { maxDebtToEquity: 6.0, minQuickRatio: 0.1, maxPERatio: 9999, maxPegGate: 9999 },
weights: { margin: 0, opMargin: 0, roe: 0, peg: 0, revenue: 0, fcf: 0, yield: 5, pFFO: 3 }, weights: { margin: 0, opMargin: 0, roe: 0, peg: 0, revenue: 0, fcf: 0, yield: 5, pFFO: 3 },
thresholds: { minYield: 4.5, maxPFFO: 20 }, thresholds: { minYield: 4.5, maxPFFO: 20 },
}, },
// Banks: P/E and PEG are distorted by loan loss provisions.
// Price-to-Book is the primary valuation metric.
// Lowered maxPriceToBook from 2.0→1.5: P/B > 1.5 for banks outside crisis recovery is expensive.
// Tightened ROE threshold: 12% is the realistic cost-of-equity for US banks; 10% is break-even.
FINANCIAL: { FINANCIAL: {
gates: { gates: {
maxDebtToEquity: 9999, maxDebtToEquity: 9999,
@@ -87,9 +99,6 @@ export const ScoringRules = {
thresholds: { roeHigh: 15, roeMed: 12, revHigh: 10, revMed: 5 }, thresholds: { roeHigh: 15, roeMed: 12, revHigh: 10, revMed: 5 },
}, },
// Energy: capital-heavy, cyclical. D/E up to 1.5 is normal.
// FCF yield is the primary quality signal (replaces margin); opMargin matters for integrated cos.
// Div yield is scored because energy majors return capital via dividends.
ENERGY: { ENERGY: {
gates: { maxDebtToEquity: 1.5, minQuickRatio: 0.6, maxPERatio: 15, maxPegGate: 1.5 }, gates: { maxDebtToEquity: 1.5, minQuickRatio: 0.6, maxPERatio: 15, maxPegGate: 1.5 },
weights: { margin: 0, opMargin: 3, roe: 2, peg: 1, revenue: 2, fcf: 4, yield: 3 }, weights: { margin: 0, opMargin: 3, roe: 2, peg: 1, revenue: 2, fcf: 4, yield: 3 },
@@ -103,8 +112,6 @@ export const ScoringRules = {
}, },
}, },
// Healthcare: high R&D burn distorts net margin; focus on revenue growth and FCF.
// P/E can be elevated for pipeline names — gate loosened slightly.
HEALTHCARE: { HEALTHCARE: {
gates: { maxDebtToEquity: 1.5, minQuickRatio: 1.0, maxPERatio: 25, maxPegGate: 1.5 }, gates: { maxDebtToEquity: 1.5, minQuickRatio: 1.0, maxPERatio: 25, maxPegGate: 1.5 },
weights: { margin: 1, opMargin: 2, roe: 2, peg: 2, revenue: 4, fcf: 3 }, weights: { margin: 1, opMargin: 2, roe: 2, peg: 2, revenue: 4, fcf: 3 },
@@ -120,11 +127,6 @@ export const ScoringRules = {
}, },
}, },
// Communication Services: META, GOOGL, NFLX, DIS, T, VZ.
// Mix of high-margin platform businesses and capital-heavy telcos/media.
// P/E gate at 25: META and GOOGL sustainably trade 20-25x; below 15 is wrong for platforms.
// High FCF weight: platform businesses are judged on FCF (ad revenue converts 35-40% to FCF).
// Revenue growth matters more than for mature industrials — network effects are the moat.
COMMUNICATION: { COMMUNICATION: {
gates: { maxDebtToEquity: 2.0, minQuickRatio: 0.8, maxPERatio: 25, maxPegGate: 1.5 }, gates: { maxDebtToEquity: 2.0, minQuickRatio: 0.8, maxPERatio: 25, maxPegGate: 1.5 },
weights: { margin: 2, opMargin: 3, roe: 2, peg: 2, revenue: 3, fcf: 4 }, weights: { margin: 2, opMargin: 3, roe: 2, peg: 2, revenue: 3, fcf: 4 },
@@ -144,10 +146,6 @@ export const ScoringRules = {
}, },
}, },
// Consumer Staples: KO, PG, WMT, COST, KR. Slow-growth, recession-resistant.
// Lower revenue growth expectations (2-5% is good for staples).
// Higher margin thresholds — pricing power is the primary moat (not growth).
// D/E tolerance is low — staples should be conservatively financed.
CONSUMER_STAPLES: { CONSUMER_STAPLES: {
gates: { maxDebtToEquity: 1.5, minQuickRatio: 0.5, maxPERatio: 22, maxPegGate: 2.0 }, gates: { maxDebtToEquity: 1.5, minQuickRatio: 0.5, maxPERatio: 22, maxPegGate: 2.0 },
weights: { margin: 3, opMargin: 3, roe: 3, peg: 1, revenue: 1, fcf: 3 }, weights: { margin: 3, opMargin: 3, roe: 3, peg: 1, revenue: 1, fcf: 3 },
@@ -167,10 +165,6 @@ export const ScoringRules = {
}, },
}, },
// Consumer Discretionary: AMZN, HD, MCD, NKE, TSLA. Cyclical, growth-oriented.
// Revenue growth is the primary signal — discretionary spending expands with the economy.
// Margins are thinner than staples (competitive markets); FCF matters for capital return.
// P/E gate relaxed slightly — quality retailers trade at 20-30x on durable FCF.
CONSUMER_DISCRETIONARY: { CONSUMER_DISCRETIONARY: {
gates: { maxDebtToEquity: 2.0, minQuickRatio: 0.5, maxPERatio: 25, maxPegGate: 1.5 }, gates: { maxDebtToEquity: 2.0, minQuickRatio: 0.5, maxPERatio: 25, maxPegGate: 1.5 },
weights: { margin: 2, opMargin: 2, roe: 2, peg: 2, revenue: 4, fcf: 3 }, weights: { margin: 2, opMargin: 2, roe: 2, peg: 2, revenue: 4, fcf: 3 },
@@ -193,24 +187,17 @@ export const ScoringRules = {
}, },
ETF: { ETF: {
// Raised expense gate from 0.5→0.2: with so many sub-0.1% index ETFs available,
// a 0.5% expense ratio is genuinely hard to justify except for niche/active strategies.
gates: { maxExpenseRatio: 0.2 }, gates: { maxExpenseRatio: 0.2 },
weights: { yield: 2, lowCost: 4, fiveYearReturn: 2 }, // cost is #1 predictive factor; 5Y return rewards consistency weights: { yield: 2, lowCost: 4, fiveYearReturn: 2 },
thresholds: { thresholds: {
minYield: 1.5, minYield: 1.5,
maxExpense: 0.05, // 0.05% is achievable for broad market ETFs maxExpense: 0.05,
minVolume: 1000000, // 1M ADV is the real liquidity floor to avoid slippage minVolume: 1_000_000,
minFiveYearReturn: 8.0, // S&P 500 long-run real return ~7-10%; 8% filters underperformers minFiveYearReturn: 8.0,
}, },
}, },
BOND: { BOND: {
// Kept investment-grade floor at BBB — still correct. Below BBB is speculative.
// Raised minSpread from 1.0→1.5: with risk-free at 4.5%, you need >1.5% spread
// to be compensated for credit risk vs just buying Treasuries.
// Tightened maxDuration from 10→7: in a HIGH rate regime, duration > 7 carries
// meaningful rate-sensitivity risk (every 1% rate rise ≈ 7% price loss).
gates: { minCreditRating: 7 }, // BBB = investment-grade floor gates: { minCreditRating: 7 }, // BBB = investment-grade floor
weights: { yieldSpread: 3, duration: 2 }, weights: { yieldSpread: 3, duration: 2 },
thresholds: { minSpread: 1.5, maxDuration: 7 }, thresholds: { minSpread: 1.5, maxDuration: 7 },
@@ -1,17 +1,19 @@
import type { Signal, AssetType, RateRegime } from '../types.js';
export const SIGNAL = { export const SIGNAL = {
STRONG_BUY: '✅ Strong Buy', STRONG_BUY: '✅ Strong Buy' as Signal,
MOMENTUM: '⚡ Momentum', MOMENTUM: '⚡ Momentum' as Signal,
SPECULATION: '⚠️ Speculation', SPECULATION: '⚠️ Speculation' as Signal,
NEUTRAL: '🔄 Neutral', NEUTRAL: '🔄 Neutral' as Signal,
AVOID: '❌ Avoid', AVOID: '❌ Avoid' as Signal,
}; } as const;
export const ASSET_TYPE = { export const ASSET_TYPE = {
STOCK: 'STOCK', STOCK: 'STOCK' as AssetType,
ETF: 'ETF', ETF: 'ETF' as AssetType,
BOND: 'BOND', BOND: 'BOND' as AssetType,
CRYPTO: 'crypto', CRYPTO: 'crypto',
}; } as const;
export const SECTOR = { export const SECTOR = {
TECHNOLOGY: 'TECHNOLOGY', TECHNOLOGY: 'TECHNOLOGY',
@@ -23,20 +25,22 @@ export const SECTOR = {
CONSUMER_STAPLES: 'CONSUMER_STAPLES', CONSUMER_STAPLES: 'CONSUMER_STAPLES',
CONSUMER_DISCRETIONARY: 'CONSUMER_DISCRETIONARY', CONSUMER_DISCRETIONARY: 'CONSUMER_DISCRETIONARY',
GENERAL: 'GENERAL', GENERAL: 'GENERAL',
}; } as const;
export type Sector = (typeof SECTOR)[keyof typeof SECTOR];
export const SCORE_MODE = { export const SCORE_MODE = {
FUNDAMENTAL: 'FUNDAMENTAL', FUNDAMENTAL: 'FUNDAMENTAL',
INFLATED: 'INFLATED', INFLATED: 'INFLATED',
}; } as const;
export const REGIME = { export const REGIME = {
LOW: 'LOW', LOW: 'LOW' as RateRegime,
NORMAL: 'NORMAL', NORMAL: 'NORMAL' as RateRegime,
HIGH: 'HIGH', HIGH: 'HIGH' as RateRegime,
}; } as const;
export const YAHOO_MODULES = [ export const YAHOO_MODULES: string[] = [
'assetProfile', 'assetProfile',
'financialData', 'financialData',
'defaultKeyStatistics', 'defaultKeyStatistics',
@@ -44,7 +48,7 @@ export const YAHOO_MODULES = [
'summaryDetail', 'summaryDetail',
]; ];
export const SIGNAL_ORDER = { export const SIGNAL_ORDER: Record<string, number> = {
[SIGNAL.STRONG_BUY]: 0, [SIGNAL.STRONG_BUY]: 0,
[SIGNAL.MOMENTUM]: 1, [SIGNAL.MOMENTUM]: 1,
[SIGNAL.NEUTRAL]: 2, [SIGNAL.NEUTRAL]: 2,
@@ -1,14 +1,38 @@
// PersonalFinanceAnalyzer interface Transaction {
// amount: number;
// Takes normalised SimpleFIN account data and computes: category: string;
// - Net worth (assets - liabilities) }
// - Cash vs investment allocation
// - Spending by category (last 30 days) interface Account {
// - Top spending categories type: string;
// - Income vs expenses summary 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 { export class PersonalFinanceAnalyzer {
analyse(accounts) { analyse(accounts: Account[]): FinanceAnalysis {
const assets = accounts.filter((a) => !['CREDIT', 'LOAN'].includes(a.type)); const assets = accounts.filter((a) => !['CREDIT', 'LOAN'].includes(a.type));
const liabilities = 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 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); 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 allTx = accounts.flatMap((a) => a.transactions);
const spending = allTx.filter((tx) => tx.amount < 0 && tx.category !== 'Transfer'); const spending = allTx.filter((tx) => tx.amount < 0 && tx.category !== 'Transfer');
const income = allTx.filter((tx) => tx.amount > 0 && tx.category === 'Income'); const income = allTx.filter((tx) => tx.amount > 0 && tx.category === 'Income');
const totalSpend = spending.reduce((s, tx) => s + Math.abs(tx.amount), 0); const totalSpend = spending.reduce((s, tx) => s + Math.abs(tx.amount), 0);
const totalIncome = income.reduce((s, tx) => s + tx.amount, 0); const totalIncome = income.reduce((s, tx) => s + tx.amount, 0);
// Spending by category const byCategory: Record<string, number> = {};
const byCategory = {};
for (const tx of spending) { for (const tx of spending) {
byCategory[tx.category] = (byCategory[tx.category] ?? 0) + Math.abs(tx.amount); 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]) .sort((a, b) => b[1] - a[1])
.map(([category, amount]) => ({ .map(([category, amount]) => ({
category, category,
@@ -1,24 +1,52 @@
import { SIGNAL } from '../config/constants.js'; import { SIGNAL } from '../config/constants.js';
import { YahooClient } from '../market/YahooClient.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 { export class PortfolioAdvisor {
private client: YahooClient;
constructor() { constructor() {
this.client = new YahooClient(); this.client = new YahooClient();
} }
async advise(holdings, screenedResults) { async advise(
// Build result map keyed by both the Yahoo ticker (BRK-B) and the holdings: PortfolioHolding[],
// dot-notation variant (BRK.B) so lookups work regardless of format. screenedResults: ScreenerResult,
const resultMap = {}; ): Promise<AdviceRow[]> {
for (const r of [ const resultMap: Record<string, AssetResult> = {};
...(screenedResults.STOCK ?? []), for (const r of [...screenedResults.STOCK, ...screenedResults.ETF, ...screenedResults.BOND]) {
...(screenedResults.ETF ?? []),
...(screenedResults.BOND ?? []),
]) {
const t = r.asset.ticker; const t = r.asset.ticker;
resultMap[t] = r; resultMap[t] = r;
resultMap[t.replace(/-/g, '.')] = r; // BRK-B → BRK.B resultMap[t.replace(/-/g, '.')] = r;
resultMap[t.replace(/\./g, '-')] = r; // BRK.B → BRK-B 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'));
@@ -26,9 +54,9 @@ export class PortfolioAdvisor {
return holdings.map((holding) => { return holdings.map((holding) => {
const type = (holding.type ?? 'stock').toLowerCase(); const type = (holding.type ?? 'stock').toLowerCase();
const source = holding.source ?? '—'; const source = holding.source ?? '—';
const price = const price: number | null =
type === 'crypto' type === 'crypto'
? cryptoPrices[holding.ticker.toUpperCase()] ? (cryptoPrices[holding.ticker.toUpperCase()] ?? null)
: (resultMap[holding.ticker.toUpperCase()]?.asset?.currentPrice ?? null); : (resultMap[holding.ticker.toUpperCase()]?.asset?.currentPrice ?? null);
return type === 'crypto' 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) { if (!result) {
return this._row(holding, price, source, '—', '—', '—', { return this._row(holding, price, source, '—', '—', '—', {
action: '⚪ Not screened', 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); const { marketValue, totalCost, gainLossPct } = this._position(holding, currentPrice);
return { return {
ticker: holding.ticker, ticker: holding.ticker,
@@ -75,19 +116,20 @@ export class PortfolioAdvisor {
}; };
} }
_position(holding, currentPrice) { private _position(holding: PortfolioHolding, currentPrice: number | null): PositionCalc {
const totalCost = (holding.costBasis * holding.shares).toFixed(2); return {
const marketValue = currentPrice != null ? (currentPrice * holding.shares).toFixed(2) : null; totalCost: (holding.costBasis * holding.shares).toFixed(2),
const gainLossPct = marketValue: currentPrice != null ? (currentPrice * holding.shares).toFixed(2) : null,
currentPrice != null && holding.costBasis > 0 gainLossPct:
? (((currentPrice - holding.costBasis) / holding.costBasis) * 100).toFixed(1) currentPrice != null && holding.costBasis > 0
: null; ? (((currentPrice - holding.costBasis) / holding.costBasis) * 100).toFixed(1)
return { totalCost, marketValue, gainLossPct }; : null,
};
} }
_cryptoAdvice(holding, price) { private _cryptoAdvice(holding: PortfolioHolding, price: number | null): AdviceOutput {
const { gainLossPct } = this._position(holding, price); const { gainLossPct } = this._position(holding, price);
const g = parseFloat(gainLossPct); const g = parseFloat(gainLossPct ?? 'NaN');
if (gainLossPct == null) if (gainLossPct == null)
return { return {
action: '⚪ No price data', 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 { gainLossPct } = this._position(holding, price);
const gain = parseFloat(gainLossPct); const gain = parseFloat(gainLossPct ?? '0');
switch (signal) { switch (signal) {
case SIGNAL.STRONG_BUY: case SIGNAL.STRONG_BUY:
return { return { action: '🟢 Hold & Add', reason: 'Passes both analyses. Strong conviction.' };
action: '🟢 Hold & Add',
reason: 'Passes both analyses. Strong conviction.',
};
case SIGNAL.MOMENTUM: case SIGNAL.MOMENTUM:
return { return {
action: '🟡 Hold', action: '🟡 Hold',
@@ -135,10 +174,7 @@ export class PortfolioAdvisor {
: 'Overvalued fundamentally. Keep position small.', : 'Overvalued fundamentally. Keep position small.',
}; };
case SIGNAL.NEUTRAL: case SIGNAL.NEUTRAL:
return { return { action: '🟡 Hold', reason: 'No clear edge. Review on any catalyst.' };
action: '🟡 Hold',
reason: 'No clear edge. Review on any catalyst.',
};
case SIGNAL.AVOID: case SIGNAL.AVOID:
return { return {
action: gain > 0 ? '🔴 Sell (Take Profits)' : '🔴 Sell (Cut Loss)', action: gain > 0 ? '🔴 Sell (Take Profits)' : '🔴 Sell (Cut Loss)',
@@ -152,12 +188,14 @@ export class PortfolioAdvisor {
} }
} }
async _cryptoPrices(cryptoHoldings) { private async _cryptoPrices(
const prices = {}; holdings: PortfolioHolding[],
for (const h of cryptoHoldings) { ): Promise<Record<string, number | null>> {
const prices: Record<string, number | null> = {};
for (const h of holdings) {
try { try {
const summary = await this.client.fetchSummary(h.ticker); 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 { } catch {
prices[h.ticker.toUpperCase()] = null; prices[h.ticker.toUpperCase()] = null;
} }
@@ -1,22 +1,48 @@
import fs from 'fs'; import fs from 'fs';
import https from 'https'; import https from 'https';
import http from 'http'; import http from 'http';
import type { Logger } from '../../types.js';
// SimpleFIN auth flow: interface SimpleFINOptions {
// 1. You get a Setup Token from https://beta-bridge.simplefin.org logger?: Logger;
// 2. This client decodes it, POSTs once to claim an Access URL onAccessUrlClaimed?: (url: string) => Promise<void> | void;
// 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.
// interface GetAccountsOptions {
// .env configuration: startDate?: number;
// First run: SIMPLEFIN_SETUP_TOKEN=aHR0cHM6Ly8... endDate?: number;
// After that: SIMPLEFIN_ACCESS_URL=https://user:pass@beta-bridge.simplefin.org/simplefin }
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 { export class SimpleFINClient {
// logger: object with .write() / .log() / .warn() — defaults to console. private accessUrl: string | null;
// onAccessUrlClaimed(url): optional callback so the caller can persist the URL private logger: Logger;
// (CLI uses it to write .env; a server would store it elsewhere). private onAccessUrlClaimed: ((url: string) => Promise<void> | void) | null;
constructor({ logger, onAccessUrlClaimed } = {}) {
constructor({ logger, onAccessUrlClaimed }: SimpleFINOptions = {}) {
this.accessUrl = null; this.accessUrl = null;
this.logger = logger ?? { this.logger = logger ?? {
write: (msg) => process.stdout.write(msg), write: (msg) => process.stdout.write(msg),
@@ -26,37 +52,28 @@ export class SimpleFINClient {
this.onAccessUrlClaimed = onAccessUrlClaimed ?? null; this.onAccessUrlClaimed = onAccessUrlClaimed ?? null;
} }
async init() { async init(): Promise<void> {
if (process.env.SIMPLEFIN_ACCESS_URL) { if (process.env.SIMPLEFIN_ACCESS_URL) {
this.accessUrl = process.env.SIMPLEFIN_ACCESS_URL.replace(/\/$/, ''); this.accessUrl = process.env.SIMPLEFIN_ACCESS_URL.replace(/\/$/, '');
return; return;
} }
if (process.env.SIMPLEFIN_SETUP_TOKEN) { 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) { if (this.onAccessUrlClaimed) await this.onAccessUrlClaimed(this.accessUrl);
await this.onAccessUrlClaimed(this.accessUrl);
}
return; return;
} }
throw new Error( throw new Error(
'SimpleFIN not configured.\n' + '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.',
'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.',
); );
} }
async getAccounts(options = {}) { async getAccounts(options: GetAccountsOptions = {}): Promise<SimpleFINData> {
if (!this.accessUrl) await this.init(); 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 endDate = options.endDate ?? Math.floor(Date.now() / 1000);
// fetch() rejects URLs with embedded credentials (user:pass@host). const parsed = new URL(this.accessUrl!);
// Extract them and send as a Basic Auth header instead.
const parsed = new URL(this.accessUrl);
const auth = parsed.username const auth = parsed.username
? 'Basic ' + Buffer.from(`${parsed.username}:${parsed.password}`).toString('base64') ? 'Basic ' + Buffer.from(`${parsed.username}:${parsed.password}`).toString('base64')
: null; : null;
@@ -65,43 +82,34 @@ export class SimpleFINClient {
const cleanBase = parsed.toString().replace(/\/$/, ''); const cleanBase = parsed.toString().replace(/\/$/, '');
const url = `${cleanBase}/accounts?version=2&start-date=${startDate}&end-date=${endDate}`; const url = `${cleanBase}/accounts?version=2&start-date=${startDate}&end-date=${endDate}`;
const response = await fetch(url, { const response = await fetch(url, { headers: auth ? { Authorization: auth } : {} });
headers: auth ? { Authorization: auth } : {},
});
if (!response.ok) { if (!response.ok) {
throw new Error(`SimpleFIN error ${response.status}: ${await response.text()}`); 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) { if (data.errors?.length) {
data.errors.forEach((e) => this.logger.warn(` ⚠ SimpleFIN: ${e}`)); data.errors.forEach((e) => this.logger.warn(` ⚠ SimpleFIN: ${e}`));
} }
return this._normalise(data); return this._normalise(data as { accounts: unknown[]; errors: string[] });
} }
// ── Auth ───────────────────────────────────────────────────────────────────── private async _claimAccessUrl(setupToken: string): Promise<string> {
async _claimAccessUrl(setupToken) {
const claimUrl = Buffer.from(setupToken.trim(), 'base64').toString('utf8').trim(); const claimUrl = Buffer.from(setupToken.trim(), 'base64').toString('utf8').trim();
this.logger.write(`\n🔑 Claiming SimpleFIN access URL...\n → ${claimUrl}\n`); 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')) { if (!accessUrl || !accessUrl.startsWith('http')) {
throw new Error( throw new Error(
`Unexpected response from SimpleFIN: "${accessUrl}"\n` + `Unexpected response from SimpleFIN: "${accessUrl}"\nSetup tokens are one-time use — if already claimed, generate a new one at https://beta-bridge.simplefin.org`,
'Setup 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'); this.logger.write('✅ Access URL received\n');
return accessUrl.trim(); return accessUrl.trim();
} }
_post(url) { private _post(url: string): Promise<string> {
return new Promise((resolve, reject) => { return new Promise((resolve, reject) => {
const parsed = new URL(url); const parsed = new URL(url);
const lib = parsed.protocol === 'https:' ? https : http; const lib = parsed.protocol === 'https:' ? https : http;
@@ -112,30 +120,23 @@ export class SimpleFINClient {
method: 'POST', method: 'POST',
headers: { 'Content-Length': '0', 'Content-Type': 'application/x-www-form-urlencoded' }, headers: { 'Content-Length': '0', 'Content-Type': 'application/x-www-form-urlencoded' },
}; };
const req = lib.request(options, (res) => { const req = lib.request(options, (res) => {
let body = ''; let body = '';
res.on('data', (chunk) => { res.on('data', (chunk: string) => {
body += chunk; body += chunk;
}); });
res.on('end', () => { res.on('end', () => {
if (res.statusCode >= 200 && res.statusCode < 300) { if ((res.statusCode ?? 0) >= 200 && (res.statusCode ?? 0) < 300) resolve(body.trim());
resolve(body.trim()); else reject(new Error(`HTTP ${res.statusCode}: ${body.trim()}`));
} else {
reject(new Error(`HTTP ${res.statusCode}: ${body.trim()}`));
}
}); });
}); });
req.on('error', reject); req.on('error', reject);
req.end(); req.end();
}); });
} }
// ── Normalise ──────────────────────────────────────────────────────────────── private _normalise(data: { accounts: unknown[]; errors: string[] }): SimpleFINData {
const accounts = (data.accounts ?? []).map((acc: any) => ({
_normalise(data) {
const accounts = (data.accounts ?? []).map((acc) => ({
id: acc.id, id: acc.id,
name: acc.name, name: acc.name,
currency: acc.currency ?? 'USD', currency: acc.currency ?? 'USD',
@@ -143,7 +144,7 @@ export class SimpleFINClient {
balanceDate: new Date(acc['balance-date'] * 1000).toISOString().slice(0, 10), balanceDate: new Date(acc['balance-date'] * 1000).toISOString().slice(0, 10),
org: acc.org?.name ?? 'Unknown', org: acc.org?.name ?? 'Unknown',
type: this._classifyAccount(acc.name), type: this._classifyAccount(acc.name),
transactions: (acc.transactions ?? []).map((tx) => ({ transactions: (acc.transactions ?? []).map((tx: any) => ({
id: tx.id, id: tx.id,
date: new Date(tx.posted * 1000).toISOString().slice(0, 10), date: new Date(tx.posted * 1000).toISOString().slice(0, 10),
amount: parseFloat(tx.amount) ?? 0, amount: parseFloat(tx.amount) ?? 0,
@@ -151,11 +152,10 @@ export class SimpleFINClient {
category: this._categorise(tx.description ?? ''), category: this._categorise(tx.description ?? ''),
})), })),
})); }));
return { accounts, errors: data.errors ?? [] }; return { accounts, errors: data.errors ?? [] };
} }
_classifyAccount(name) { private _classifyAccount(name: string): string {
const n = name.toLowerCase(); const n = name.toLowerCase();
if (n.includes('checking') || n.includes('current')) return 'CHECKING'; if (n.includes('checking') || n.includes('current')) return 'CHECKING';
if (n.includes('saving')) return 'SAVINGS'; if (n.includes('saving')) return 'SAVINGS';
@@ -166,7 +166,7 @@ export class SimpleFINClient {
return 'OTHER'; return 'OTHER';
} }
_categorise(description) { private _categorise(description: string): string {
const d = description.toLowerCase(); const d = description.toLowerCase();
if (d.match(/amazon|walmart|target|costco|grocery|whole foods|trader joe/)) return 'Shopping'; 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'; if (d.match(/uber eats|doordash|grubhub|postmates|instacart/)) return 'Delivery';
@@ -181,14 +181,12 @@ export class SimpleFINClient {
return 'Other'; return 'Other';
} }
_daysAgo(n) { private _daysAgo(n: number): number {
return Math.floor((Date.now() - n * 24 * 60 * 60 * 1000) / 1000); 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. export function saveAccessUrlToEnv(accessUrl: string): void {
// Pass this as `onAccessUrlClaimed` when constructing SimpleFINClient in CLI context.
export function saveAccessUrlToEnv(accessUrl) {
try { try {
const existing = fs.existsSync('.env') ? fs.readFileSync('.env', 'utf8') : ''; const existing = fs.existsSync('.env') ? fs.readFileSync('.env', 'utf8') : '';
if (!existing.includes('SIMPLEFIN_ACCESS_URL')) { if (!existing.includes('SIMPLEFIN_ACCESS_URL')) {
-73
View File
@@ -1,73 +0,0 @@
import { YahooClient } from './YahooClient.js';
import { REGIME } from '../config/constants.js';
const TTL_MS = 60 * 60 * 1000;
const DEFAULTS = {
sp500Price: 5000,
riskFreeRate: 4.5,
vixLevel: 20,
rateRegime: REGIME.HIGH,
volatilityRegime: REGIME.NORMAL,
benchmarks: { marketPE: 22, techPE: 30, reitYield: 3.5, igSpread: 1.0 },
};
const rateRegime = (rate) => (rate < 2 ? REGIME.LOW : rate <= 5 ? REGIME.NORMAL : REGIME.HIGH);
const volRegime = (vix) => (vix < 15 ? REGIME.LOW : vix <= 25 ? REGIME.NORMAL : REGIME.HIGH);
const pe = (summary) =>
summary.summaryDetail?.trailingPE ?? summary.defaultKeyStatistics?.forwardPE;
export class BenchmarkProvider {
// logger: object with .warn() — defaults to console so CLI behaviour is unchanged.
constructor({ logger = console } = {}) {
this.client = new YahooClient();
this.cache = { data: null, expiresAt: 0 };
this.logger = logger;
}
async getMarketContext() {
if (this.cache.data && Date.now() < this.cache.expiresAt) return this.cache.data;
try {
const [sp500, tn10y, vix, spy, xlk, xlre, lqd] = await Promise.all([
this.client.fetchSummary('^GSPC'),
this.client.fetchSummary('^TNX'),
this.client.fetchSummary('^VIX'),
this.client.fetchSummary('SPY'),
this.client.fetchSummary('XLK'),
this.client.fetchSummary('XLRE'),
this.client.fetchSummary('LQD'),
]);
const riskFreeRate = tn10y.price?.regularMarketPrice ?? 0;
const sp500Price = sp500.price?.regularMarketPrice ?? 0;
const vixLevel = vix.price?.regularMarketPrice ?? 0;
if (!sp500Price || !riskFreeRate) throw new Error('Invalid market data (zero values)');
const lqdYield = (lqd.summaryDetail?.trailingAnnualDividendYield ?? 0) * 100;
const context = {
sp500Price,
riskFreeRate,
vixLevel,
rateRegime: rateRegime(riskFreeRate),
volatilityRegime: volRegime(vixLevel),
benchmarks: {
marketPE: pe(spy) ?? 22,
techPE: pe(xlk) ?? 30,
reitYield: (xlre.summaryDetail?.trailingAnnualDividendYield ?? 0.035) * 100,
igSpread: Math.max(0.1, lqdYield - riskFreeRate),
},
timestamp: new Date().toISOString(),
};
this.cache = { data: context, expiresAt: Date.now() + TTL_MS };
return context;
} catch (err) {
this.logger.warn('Market data fetch failed, using defaults:', err.message);
return this.cache.data ?? DEFAULTS;
}
}
}
+87
View File
@@ -0,0 +1,87 @@
import { YahooClient } from './YahooClient.js';
import { REGIME } from '../config/constants.js';
import type { MarketContext, Logger } from '../types.js';
const TTL_MS = 60 * 60 * 1000;
const DEFAULTS: MarketContext = {
sp500Price: 5000,
riskFreeRate: 4.5,
vixLevel: 20,
rateRegime: 'HIGH',
volatilityRegime: 'NORMAL',
benchmarks: { marketPE: 22, techPE: 30, reitYield: 3.5, igSpread: 1.0 },
};
const rateRegime = (rate: number): MarketContext['rateRegime'] =>
rate < 2 ? REGIME.LOW : rate <= 5 ? REGIME.NORMAL : REGIME.HIGH;
const volRegime = (vix: number): MarketContext['volatilityRegime'] =>
vix < 15 ? REGIME.LOW : vix <= 25 ? REGIME.NORMAL : REGIME.HIGH;
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const pe = (summary: any): number | null =>
summary?.summaryDetail?.trailingPE ?? summary?.defaultKeyStatistics?.forwardPE ?? null;
interface BenchmarkProviderOptions {
logger?: Logger;
}
export class BenchmarkProvider {
private client: YahooClient;
private cache: { data: MarketContext | null; expiresAt: number };
private logger: Logger;
constructor({ logger }: BenchmarkProviderOptions = {}) {
this.client = new YahooClient();
this.cache = { data: null, expiresAt: 0 };
this.logger = logger ?? (console as unknown as Logger);
}
async getMarketContext(): Promise<MarketContext> {
if (this.cache.data && Date.now() < this.cache.expiresAt) return this.cache.data;
try {
const [sp500, tn10y, vix, spy, xlk, xlre, lqd] = await Promise.all([
this.client.fetchSummary('^GSPC'),
this.client.fetchSummary('^TNX'),
this.client.fetchSummary('^VIX'),
this.client.fetchSummary('SPY'),
this.client.fetchSummary('XLK'),
this.client.fetchSummary('XLRE'),
this.client.fetchSummary('LQD'),
]);
const riskFreeRate =
(sp500 as any)?.price?.regularMarketPrice !== undefined
? ((tn10y as any)?.price?.regularMarketPrice ?? 0)
: 0;
const sp500Price = (sp500 as any)?.price?.regularMarketPrice ?? 0;
const vixLevel = (vix as any)?.price?.regularMarketPrice ?? 0;
if (!sp500Price || !riskFreeRate) throw new Error('Invalid market data (zero values)');
const lqdYield = ((lqd as any)?.summaryDetail?.trailingAnnualDividendYield ?? 0) * 100;
const context: MarketContext = {
sp500Price,
riskFreeRate,
vixLevel,
rateRegime: rateRegime(riskFreeRate),
volatilityRegime: volRegime(vixLevel),
benchmarks: {
marketPE: pe(spy) ?? 22,
techPE: pe(xlk) ?? 30,
reitYield: ((xlre as any)?.summaryDetail?.trailingAnnualDividendYield ?? 0.035) * 100,
igSpread: Math.max(0.1, lqdYield - riskFreeRate),
},
};
this.cache = { data: context, expiresAt: Date.now() + TTL_MS };
return context;
} catch (err) {
this.logger.warn('Market data fetch failed, using defaults:', (err as Error).message);
return this.cache.data ?? DEFAULTS;
}
}
}
@@ -1,8 +1,21 @@
import { SECTOR, ASSET_TYPE, REGIME } from '../config/constants.js'; import { SECTOR, ASSET_TYPE, REGIME } from '../config/constants.js';
import type { MarketContext, AssetType } from '../types.js';
interface InflatedOverrides {
gates: Record<string, number>;
thresholds: Record<string, number>;
}
export class MarketRegime { export class MarketRegime {
constructor(marketContext) { private marketPE: number;
const b = marketContext?.benchmarks ?? {}; private techPE: number;
private reitYield: number;
private igSpread: number;
private rateRegime: string;
private volatilityRegime: string;
constructor(marketContext: Partial<MarketContext>) {
const b = marketContext?.benchmarks ?? ({} as MarketContext['benchmarks']);
this.marketPE = b.marketPE ?? 22; this.marketPE = b.marketPE ?? 22;
this.techPE = b.techPE ?? 30; this.techPE = b.techPE ?? 30;
this.reitYield = b.reitYield ?? 3.5; this.reitYield = b.reitYield ?? 3.5;
@@ -11,18 +24,17 @@ export class MarketRegime {
this.volatilityRegime = marketContext?.volatilityRegime ?? REGIME.NORMAL; this.volatilityRegime = marketContext?.volatilityRegime ?? REGIME.NORMAL;
} }
getInflatedOverrides(type, sector) { getInflatedOverrides(type: AssetType, sector?: string): InflatedOverrides {
if (type === ASSET_TYPE.STOCK) return this._stock(sector); if (type === ASSET_TYPE.STOCK) return this._stock(sector);
if (type === ASSET_TYPE.ETF) return this._etf(); if (type === ASSET_TYPE.ETF) return this._etf();
if (type === ASSET_TYPE.BOND) return this._bond(); if (type === ASSET_TYPE.BOND) return this._bond();
return { gates: {}, thresholds: {} }; return { gates: {}, thresholds: {} };
} }
_stock(sector) { private _stock(sector?: string): InflatedOverrides {
if (sector === SECTOR.REIT) { if (sector === SECTOR.REIT) {
return { return {
gates: {}, gates: {},
// In HIGH rate environment tighten REIT yield floor — REITs must compete harder with bonds.
thresholds: { thresholds: {
minYield: +(this.reitYield * (this.rateRegime === REGIME.HIGH ? 0.95 : 0.85)).toFixed(2), minYield: +(this.reitYield * (this.rateRegime === REGIME.HIGH ? 0.95 : 0.85)).toFixed(2),
maxPFFO: 20, maxPFFO: 20,
@@ -38,8 +50,6 @@ export class MarketRegime {
thresholds: {}, thresholds: {},
}; };
} }
// In HIGH rate environment, compress the P/E tolerance — higher rates mean
// future earnings are discounted more aggressively (lower DCF valuations).
const peMultiplier = this.rateRegime === REGIME.HIGH ? 1.2 : 1.5; const peMultiplier = this.rateRegime === REGIME.HIGH ? 1.2 : 1.5;
return { return {
gates: { gates: {
@@ -50,14 +60,15 @@ export class MarketRegime {
}; };
} }
_etf() { private _etf(): InflatedOverrides {
return { gates: { maxExpenseRatio: 0.75 }, thresholds: { minYield: 0.5 } }; return { gates: { maxExpenseRatio: 0.75 }, thresholds: { minYield: 0.5 } };
} }
_bond() { private _bond(): InflatedOverrides {
// In HIGH rate environment demand a wider spread — the opportunity cost of holding
// corporate bonds over Treasuries is higher when risk-free rate is elevated.
const spreadMultiplier = this.rateRegime === REGIME.HIGH ? 0.9 : 0.8; const spreadMultiplier = this.rateRegime === REGIME.HIGH ? 0.9 : 0.8;
return { gates: {}, thresholds: { minSpread: +(this.igSpread * spreadMultiplier).toFixed(2) } }; return {
gates: {},
thresholds: { minSpread: +(this.igSpread * spreadMultiplier).toFixed(2) },
};
} }
} }
-40
View File
@@ -1,40 +0,0 @@
import YahooFinance from 'yahoo-finance2';
export class YahooClient {
constructor() {
// Instantiate the client as required by v3
this.yf = new YahooFinance({
suppressNotices: ['yahooSurvey'],
});
}
async fetchSummary(ticker, retries = 3, backoff = 1000) {
for (let i = 0; i < retries; i++) {
try {
return await this.yf.quoteSummary(ticker, {
modules: [
'assetProfile',
'financialData',
'defaultKeyStatistics',
'price',
'summaryDetail',
],
});
} catch (error) {
if (i === retries - 1) throw error;
await new Promise((res) => setTimeout(res, backoff * (i + 1)));
}
}
}
// Fetches upcoming earnings dates, ex-dividend date, and dividend date for a ticker.
// Returns null on failure so callers can skip gracefully.
async fetchCalendarEvents(ticker) {
try {
const r = await this.yf.quoteSummary(ticker, { modules: ['calendarEvents'] });
return r.calendarEvents ?? null;
} catch {
return null;
}
}
}
+42
View File
@@ -0,0 +1,42 @@
import YahooFinance from 'yahoo-finance2';
export class YahooClient {
// eslint-disable-next-line @typescript-eslint/no-explicit-any
private yf: any;
constructor() {
this.yf = new (YahooFinance as unknown as new (opts: object) => unknown)({
suppressNotices: ['yahooSurvey'],
});
}
// eslint-disable-next-line @typescript-eslint/no-explicit-any
async fetchSummary(ticker: string, retries = 3, backoff = 1000): Promise<any> {
for (let i = 0; i < retries; i++) {
try {
return await (this.yf as any).quoteSummary(ticker, {
modules: [
'assetProfile',
'financialData',
'defaultKeyStatistics',
'price',
'summaryDetail',
],
});
} catch (error) {
if (i === retries - 1) throw error;
await new Promise<void>((res) => setTimeout(res, backoff * (i + 1)));
}
}
}
// eslint-disable-next-line @typescript-eslint/no-explicit-any
async fetchCalendarEvents(ticker: string): Promise<any | null> {
try {
const r = await (this.yf as any).quoteSummary(ticker, { modules: ['calendarEvents'] });
return r.calendarEvents ?? null;
} catch {
return null;
}
}
}
@@ -1,20 +1,24 @@
import fs from 'fs'; import fs from 'fs';
import path from 'path'; import path from 'path';
import type { MarketContext } from '../types.js';
export class FinanceReporter { export class FinanceReporter {
// Returns the HTML string — useful for server responses. render(advice: unknown[], personalFinance: unknown, marketContext: MarketContext): string {
render(advice, personalFinance, marketContext) {
return this._build(advice, personalFinance, marketContext); return this._build(advice, personalFinance, marketContext);
} }
// Writes to disk and returns the absolute path — used by the CLI. generate(
generate(advice, personalFinance, marketContext, outputPath = './finance-report.html') { advice: unknown[],
personalFinance: unknown,
marketContext: MarketContext,
outputPath = './finance-report.html',
): string {
const html = this._build(advice, personalFinance, marketContext); const html = this._build(advice, personalFinance, marketContext);
fs.writeFileSync(outputPath, html, 'utf8'); fs.writeFileSync(outputPath, html, 'utf8');
return path.resolve(outputPath); return path.resolve(outputPath);
} }
_build(advice, pf, ctx) { _build(advice: unknown, pf: unknown, ctx: unknown) {
const date = new Date().toISOString().slice(0, 10); const date = new Date().toISOString().slice(0, 10);
return `<!DOCTYPE html> return `<!DOCTYPE html>
<html lang="en"> <html lang="en">
@@ -1,17 +1,25 @@
import fs from 'fs'; import fs from 'fs';
import path from 'path'; import path from 'path';
import type { MarketContext } from '../types.js';
// Generates a self-contained HTML report saved to ./screener-report.html // Generates a self-contained HTML report saved to ./screener-report.html
// Console output shows only the signal summary — full breakdown lives here. // Console output shows only the signal summary — full breakdown lives here.
export class HtmlReporter { export class HtmlReporter {
// Returns the HTML string — useful for server responses. render(
render(results, marketContext, personalFinance = null) { results: Record<string, unknown[]>,
marketContext: MarketContext,
personalFinance: unknown = null,
): string {
return this._buildHtml(results, marketContext, personalFinance); return this._buildHtml(results, marketContext, personalFinance);
} }
// Writes to disk and returns the absolute path — used by the CLI. generate(
generate(results, marketContext, personalFinance = null, outputPath = './screener-report.html') { results: Record<string, unknown[]>,
marketContext: MarketContext,
personalFinance: unknown = null,
outputPath = './screener-report.html',
): string {
const html = this._buildHtml(results, marketContext, personalFinance); const html = this._buildHtml(results, marketContext, personalFinance);
fs.writeFileSync(outputPath, html, 'utf8'); fs.writeFileSync(outputPath, html, 'utf8');
return path.resolve(outputPath); return path.resolve(outputPath);
@@ -1,4 +1,4 @@
export const chunkArray = (array, size) => export const chunkArray = <T>(array: T[], size: number): T[][] =>
Array.from({ length: Math.ceil(array.length / size) }, (_, i) => Array.from({ length: Math.ceil(array.length / size) }, (_, i) =>
array.slice(i * size, i * size + size), array.slice(i * size, i * size + size),
); );
-153
View File
@@ -1,153 +0,0 @@
export const mapToStandardFormat = (ticker, summary) => {
const quoteType = summary.price?.quoteType;
const category = (summary.assetProfile?.category || '').toLowerCase();
const yieldVal = summary.summaryDetail?.trailingAnnualDividendYield ?? 0;
// Logic to determine type
const isBond =
category.includes('bond') ||
category.includes('fixed income') ||
category.includes('treasury') ||
(quoteType === 'ETF' && yieldVal > 0.02 && category === ''); // Heuristic fallback
if (quoteType === 'ETF') {
return isBond
? {
type: 'BOND',
ticker,
...mapBondData(summary),
}
: {
type: 'ETF',
ticker,
...mapEtfData(summary),
};
}
// Default to STOCK (covers 'EQUITY' or missing types)
return {
type: 'STOCK',
ticker,
...mapStockData(summary),
};
};
const mapStockData = (summary) => {
const fd = summary.financialData ?? {};
const ks = summary.defaultKeyStatistics ?? {};
const sd = summary.summaryDetail ?? {};
const pr = summary.price ?? {};
const currentPrice = pr.regularMarketPrice ?? 0;
const sharesOutstanding = ks.sharesOutstanding ?? 0;
const operatingCashflow = fd.operatingCashflow ?? 0;
const freeCashflow = fd.freeCashflow ?? 0;
// P/FFO proxy (price / operating cash flow per share) — used for REIT scoring
const pFFO =
operatingCashflow > 0 && sharesOutstanding > 0
? currentPrice / (operatingCashflow / sharesOutstanding)
: null;
// FCF yield = free cash flow per share / price.
// Negative FCF is preserved (not nulled) — a company burning cash should fail the gate,
// not be silently skipped as "no data".
const fcfYield =
freeCashflow !== 0 && sharesOutstanding > 0 && currentPrice > 0
? (freeCashflow / sharesOutstanding / currentPrice) * 100
: null;
// PEG computation: use Yahoo's value first; fall back to trailingPE / earningsGrowth
// earningsGrowth from Yahoo is a decimal (e.g. 0.15 = 15%), convert to whole number first
const yahoosPEG = ks.pegRatio ?? null;
const trailingPE = sd.trailingPE ?? null;
const earningsGrowth = fd.earningsGrowth != null ? fd.earningsGrowth * 100 : null; // now in %
const computedPEG =
trailingPE != null && earningsGrowth > 0 ? +(trailingPE / earningsGrowth).toFixed(2) : null;
const pegRatio = yahoosPEG ?? computedPEG; // prefer Yahoo's, fall back to computed
// Quick ratio — fall back to currentRatio when quickRatio is missing
const quickRatio = fd.quickRatio ?? fd.currentRatio ?? null;
return {
// Valuation — trailing PE is the audited number; forward PE is an analyst estimate
// (historically 10-15% optimistic). Use trailing as primary for fundamental mode.
peRatio: trailingPE ?? ks.forwardPE,
trailingPE,
pegRatio,
priceToBook: ks.priceToBook ?? null,
evToEbitda: ks.enterpriseToEbitda ?? null,
// Profitability
netProfitMargin: fd.profitMargins != null ? fd.profitMargins * 100 : null,
operatingMargin: fd.operatingMargins != null ? fd.operatingMargins * 100 : null,
returnOnEquity: fd.returnOnEquity != null ? fd.returnOnEquity * 100 : null,
// Growth
revenueGrowth: fd.revenueGrowth != null ? fd.revenueGrowth * 100 : null,
earningsGrowth,
// Financial health
debtToEquity: fd.debtToEquity != null ? fd.debtToEquity / 100 : null,
quickRatio,
// Cash flow
fcfYield,
pFFO,
// Income
dividendYield:
sd.trailingAnnualDividendYield != null ? sd.trailingAnnualDividendYield * 100 : null,
// Risk & momentum
beta: sd.beta ?? null,
week52High: sd.fiftyTwoWeekHigh ?? null,
week52Low: sd.fiftyTwoWeekLow ?? null,
currentPrice,
assetProfile: summary.assetProfile || {},
};
};
const mapEtfData = (summary) => ({
expenseRatio: (summary.summaryDetail?.expenseRatio ?? 0) * 100,
totalAssets: summary.summaryDetail?.totalAssets ?? 0,
yield: (summary.summaryDetail?.trailingAnnualDividendYield ?? 0) * 100,
// fiveYearAverageReturn is annualised total return — valid proxy for performance vs peers.
fiveYearReturn: (summary.defaultKeyStatistics?.fiveYearAverageReturn ?? 0) * 100,
// averageVolume from summaryDetail is average daily trading volume — used for liquidity gate.
volume: summary.summaryDetail?.averageVolume ?? summary.price?.averageVolume ?? 0,
currentPrice: summary.price?.regularMarketPrice ?? 0,
});
/**
* Infer credit rating from ETF category string (Yahoo Finance doesn't expose
* bond credit ratings directly). Defaults to BBB (investment grade) when unknown.
*/
const inferCreditRating = (category) => {
const cat = (category || '').toLowerCase();
if (cat.includes('government') || cat.includes('treasury')) return 'AAA';
if (cat.includes('muni')) return 'AA';
if (cat.includes('high yield') || cat.includes('junk')) return 'BB';
if (cat.includes('corporate') || cat.includes('investment grade')) return 'A';
return 'BBB'; // conservative default
};
// Infers approximate effective duration (years) from bond ETF category name.
// Buckets match standard industry classifications (short < 3y, intermediate 3-7y, long > 10y).
const inferDuration = (category) => {
const cat = (category || '').toLowerCase();
if (cat.includes('short') || cat.includes('ultrashort') || cat.includes('1-3')) return 2;
if (cat.includes('intermediate') || cat.includes('3-7') || cat.includes('3-10')) return 5;
if (cat.includes('long') || cat.includes('10+') || cat.includes('20+')) return 18;
if (cat.includes('target maturity') || cat.includes('defined maturity')) return 4;
return 6; // conservative default — typical aggregate bond fund duration
};
const mapBondData = (summary) => ({
yieldToMaturity: (summary.summaryDetail?.yield ?? 0) * 100,
// KNOWN LIMITATION: Yahoo Finance does not expose effective duration via the modules
// we fetch (assetProfile, financialData, defaultKeyStatistics, price, summaryDetail).
// The `fundProfile` module has duration for some funds but requires a separate fetch.
// We use the ETF category name to infer a rough duration bucket as a proxy.
duration: inferDuration(summary.assetProfile?.category),
creditRating: inferCreditRating(summary.assetProfile?.category),
currentPrice: summary.price?.regularMarketPrice ?? 0,
});
+137
View File
@@ -0,0 +1,137 @@
import type { AssetType } from '../types.js';
// Shape of the raw Yahoo Finance summary payload (loosely typed — fields vary by asset)
type YahooSummary = Record<string, Record<string, unknown>>;
interface MappedData {
type: AssetType;
ticker: string;
[key: string]: unknown;
}
export const mapToStandardFormat = (ticker: string, summary: YahooSummary): MappedData => {
const quoteType = summary.price?.quoteType as string | undefined;
const category = ((summary.assetProfile?.category as string) || '').toLowerCase();
const yieldVal = (summary.summaryDetail?.trailingAnnualDividendYield as number) ?? 0;
const isBond =
category.includes('bond') ||
category.includes('fixed income') ||
category.includes('treasury') ||
(quoteType === 'ETF' && yieldVal > 0.02 && category === '');
if (quoteType === 'ETF') {
return isBond
? { type: 'BOND', ticker, ...mapBondData(summary) }
: { type: 'ETF', ticker, ...mapEtfData(summary) };
}
return { type: 'STOCK', ticker, ...mapStockData(summary) };
};
const mapStockData = (summary: YahooSummary) => {
const fd = (summary.financialData ?? {}) as Record<string, number | null>;
const ks = (summary.defaultKeyStatistics ?? {}) as Record<string, number | null>;
const sd = (summary.summaryDetail ?? {}) as Record<string, number | null>;
const pr = (summary.price ?? {}) as Record<string, number | null>;
const currentPrice = pr.regularMarketPrice ?? 0;
const sharesOutstanding = ks.sharesOutstanding ?? 0;
const operatingCashflow = fd.operatingCashflow ?? 0;
const freeCashflow = fd.freeCashflow ?? 0;
// P/FFO proxy — used for REIT scoring
const pFFO =
operatingCashflow != null &&
operatingCashflow > 0 &&
sharesOutstanding != null &&
sharesOutstanding > 0
? (currentPrice as number) / (operatingCashflow / sharesOutstanding)
: null;
// FCF yield — negative FCF preserved so cash-burning companies fail the gate
const fcfYield =
freeCashflow !== 0 &&
sharesOutstanding != null &&
sharesOutstanding > 0 &&
currentPrice != null &&
currentPrice > 0
? ((freeCashflow as number) / (sharesOutstanding as number) / (currentPrice as number)) * 100
: null;
// PEG: prefer Yahoo's value, fall back to trailingPE / earningsGrowth
const yahoosPEG = ks.pegRatio ?? null;
const trailingPE = sd.trailingPE ?? null;
const earningsGrowth = fd.earningsGrowth != null ? (fd.earningsGrowth as number) * 100 : null;
const computedPEG =
trailingPE != null && earningsGrowth != null && earningsGrowth > 0
? +((trailingPE as number) / earningsGrowth).toFixed(2)
: null;
const pegRatio = yahoosPEG ?? computedPEG;
// Quick ratio — fall back to currentRatio when missing
const quickRatio = fd.quickRatio ?? fd.currentRatio ?? null;
return {
peRatio: trailingPE ?? ks.forwardPE,
trailingPE,
pegRatio,
priceToBook: ks.priceToBook ?? null,
evToEbitda: ks.enterpriseToEbitda ?? null,
netProfitMargin: fd.profitMargins != null ? (fd.profitMargins as number) * 100 : null,
operatingMargin: fd.operatingMargins != null ? (fd.operatingMargins as number) * 100 : null,
returnOnEquity: fd.returnOnEquity != null ? (fd.returnOnEquity as number) * 100 : null,
revenueGrowth: fd.revenueGrowth != null ? (fd.revenueGrowth as number) * 100 : null,
earningsGrowth,
debtToEquity: fd.debtToEquity != null ? (fd.debtToEquity as number) / 100 : null,
quickRatio,
fcfYield,
pFFO,
dividendYield:
sd.trailingAnnualDividendYield != null
? (sd.trailingAnnualDividendYield as number) * 100
: null,
beta: sd.beta ?? null,
week52High: sd.fiftyTwoWeekHigh ?? null,
week52Low: sd.fiftyTwoWeekLow ?? null,
currentPrice,
assetProfile: summary.assetProfile || {},
};
};
const mapEtfData = (summary: YahooSummary) => ({
expenseRatio: ((summary.summaryDetail?.expenseRatio as number) ?? 0) * 100,
totalAssets: (summary.summaryDetail?.totalAssets as number) ?? 0,
yield: ((summary.summaryDetail?.trailingAnnualDividendYield as number) ?? 0) * 100,
fiveYearReturn: ((summary.defaultKeyStatistics?.fiveYearAverageReturn as number) ?? 0) * 100,
volume:
(summary.summaryDetail?.averageVolume as number) ??
(summary.price?.averageVolume as number) ??
0,
currentPrice: (summary.price?.regularMarketPrice as number) ?? 0,
});
const inferCreditRating = (category: string | undefined): string => {
const cat = (category || '').toLowerCase();
if (cat.includes('government') || cat.includes('treasury')) return 'AAA';
if (cat.includes('muni')) return 'AA';
if (cat.includes('high yield') || cat.includes('junk')) return 'BB';
if (cat.includes('corporate') || cat.includes('investment grade')) return 'A';
return 'BBB';
};
const inferDuration = (category: string | undefined): number => {
const cat = (category || '').toLowerCase();
if (cat.includes('short') || cat.includes('ultrashort') || cat.includes('1-3')) return 2;
if (cat.includes('intermediate') || cat.includes('3-7') || cat.includes('3-10')) return 5;
if (cat.includes('long') || cat.includes('10+') || cat.includes('20+')) return 18;
if (cat.includes('target maturity') || cat.includes('defined maturity')) return 4;
return 6;
};
const mapBondData = (summary: YahooSummary) => ({
yieldToMaturity: ((summary.summaryDetail?.yield as number) ?? 0) * 100,
duration: inferDuration(summary.assetProfile?.category as string),
creditRating: inferCreditRating(summary.assetProfile?.category as string),
currentPrice: (summary.price?.regularMarketPrice as number) ?? 0,
});
-33
View File
@@ -1,33 +0,0 @@
import { ScoringRules } from '../config/ScoringConfig.js';
import { MarketRegime } from '../market/MarketRegime.js';
import { SCORE_MODE } from '../config/constants.js';
export const RuleMerger = {
getRulesForAsset(type, metrics, marketContext = {}, mode = SCORE_MODE.FUNDAMENTAL) {
const base = ScoringRules[type];
if (!base) throw new Error(`No rules configured for asset type: ${type}`);
let rules = JSON.parse(JSON.stringify(base));
if (type === 'STOCK' && metrics.sector) {
const override = base.SECTOR_OVERRIDE?.[metrics.sector.toUpperCase()];
if (override) {
rules.gates = { ...rules.gates, ...override.gates };
rules.weights = { ...rules.weights, ...override.weights };
rules.thresholds = { ...rules.thresholds, ...override.thresholds };
}
}
delete rules.SECTOR_OVERRIDE;
if (mode === SCORE_MODE.INFLATED) {
const { gates, thresholds } = new MarketRegime(marketContext).getInflatedOverrides(
type,
metrics.sector,
);
rules.gates = { ...rules.gates, ...gates };
rules.thresholds = { ...rules.thresholds, ...thresholds };
}
return rules;
},
};
+49
View File
@@ -0,0 +1,49 @@
import { ScoringRules } from '../config/ScoringConfig.js';
import { MarketRegime } from '../market/MarketRegime.js';
import { SCORE_MODE } from '../config/constants.js';
import type { AssetType, MarketContext } from '../types.js';
interface RuleSet {
gates: Record<string, number>;
weights: Record<string, number>;
thresholds: Record<string, number>;
}
export const RuleMerger = {
getRulesForAsset(
type: AssetType,
metrics: { sector?: string },
marketContext: Partial<MarketContext> = {},
mode: string = SCORE_MODE.FUNDAMENTAL,
): RuleSet {
const base = ScoringRules[type as keyof typeof ScoringRules];
if (!base) throw new Error(`No rules configured for asset type: ${type}`);
// Deep clone to avoid mutating the source config
const rules: RuleSet & { SECTOR_OVERRIDE?: unknown } = JSON.parse(JSON.stringify(base));
if (type === 'STOCK' && metrics.sector) {
const stockBase = ScoringRules.STOCK;
const override =
stockBase.SECTOR_OVERRIDE?.[
metrics.sector.toUpperCase() as keyof typeof stockBase.SECTOR_OVERRIDE
];
if (override) {
rules.gates = { ...rules.gates, ...override.gates };
rules.weights = { ...rules.weights, ...override.weights };
rules.thresholds = { ...rules.thresholds, ...override.thresholds };
}
}
delete rules.SECTOR_OVERRIDE;
if (mode === SCORE_MODE.INFLATED) {
const { gates, thresholds } = new MarketRegime(
marketContext as MarketContext,
).getInflatedOverrides(type, metrics.sector);
rules.gates = { ...rules.gates, ...gates };
rules.thresholds = { ...rules.thresholds, ...thresholds };
}
return rules;
},
};
@@ -10,45 +10,74 @@ import { StockScorer } from './scorers/StockScorer.js';
import { EtfScorer } from './scorers/EtfScorer.js'; import { EtfScorer } from './scorers/EtfScorer.js';
import { BondScorer } from './scorers/BondScorer.js'; import { BondScorer } from './scorers/BondScorer.js';
import { SIGNAL, SIGNAL_ORDER, SCORE_MODE, ASSET_TYPE } from '../config/constants.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 = { const SCORERS: Record<AssetType, typeof StockScorer | typeof EtfScorer | typeof BondScorer> = {
[ASSET_TYPE.STOCK]: StockScorer, [ASSET_TYPE.STOCK]: StockScorer,
[ASSET_TYPE.ETF]: EtfScorer, [ASSET_TYPE.ETF]: EtfScorer,
[ASSET_TYPE.BOND]: BondScorer, [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 { export class ScreenerEngine {
// logger: object with .write() / .log() — defaults to a console shim so CLI behaviour is unchanged. private client: YahooClient;
// Pass a no-op logger ({ write: () => {}, log: () => {} }) in server context. private benchmarkProvider: BenchmarkProvider;
constructor({ logger } = {}) { private logger: Logger;
constructor({ logger }: ScreenerEngineOptions = {}) {
this.client = new YahooClient(); this.client = new YahooClient();
this.benchmarkProvider = new BenchmarkProvider({ logger: logger ?? console }); this.benchmarkProvider = new BenchmarkProvider({
logger: logger ?? (console as unknown as Logger),
});
this.logger = logger ?? { this.logger = logger ?? {
write: (msg) => process.stdout.write(msg), write: (msg: string) => process.stdout.write(msg),
log: (...args) => console.log(...args), 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. // Pure data method — returns structured results. Safe to use in a server route.
async screenTickers(tickers) { async screenTickers(tickers: string[]): Promise<ScreenerResult> {
const marketContext = await this.benchmarkProvider.getMarketContext(); const marketContext = await this.benchmarkProvider.getMarketContext();
const results = { STOCK: [], ETF: [], BOND: [], ERROR: [] }; const results: Omit<ScreenerResult, 'marketContext'> = {
STOCK: [],
ETF: [],
BOND: [],
ERROR: [],
};
for (const chunk of chunkArray(tickers, 5)) { for (const chunk of chunkArray(tickers, 5)) {
const batch = await Promise.all(chunk.map((t) => this._fetch(t))); const batch = await Promise.all(chunk.map((t) => this._fetch(t)));
batch.forEach((data) => this._process(data, marketContext, results)); batch.forEach((data) => this._process(data, marketContext, results));
await new Promise((r) => setTimeout(r, 1000)); await new Promise<void>((r) => setTimeout(r, 1000));
} }
return { ...results, marketContext }; return { ...results, marketContext };
} }
// CLI helper — emits progress to logger, returns structured results. // CLI helper — emits progress to logger, returns structured results.
// The caller (bin/screen.js) is responsible for writing the report. async screenWithProgress(tickers: string[]): Promise<ScreenerResult> {
async screenWithProgress(tickers) {
this.logger.write('⏳ Fetching market context...'); this.logger.write('⏳ Fetching market context...');
const marketContext = await this.benchmarkProvider.getMarketContext(); const marketContext = await this.benchmarkProvider.getMarketContext();
this.logger.write(' done\n'); this.logger.write(' done\n');
const results = { STOCK: [], ETF: [], BOND: [], ERROR: [] }; const results: Omit<ScreenerResult, 'marketContext'> = {
STOCK: [],
ETF: [],
BOND: [],
ERROR: [],
};
const chunks = chunkArray(tickers, 5); const chunks = chunkArray(tickers, 5);
let processed = 0; let processed = 0;
@@ -57,50 +86,60 @@ export class ScreenerEngine {
batch.forEach((data) => this._process(data, marketContext, results)); batch.forEach((data) => this._process(data, marketContext, results));
processed += chunk.length; processed += chunk.length;
this.logger.write(`\r⏳ Screening tickers... ${processed}/${tickers.length}`); this.logger.write(`\r⏳ Screening tickers... ${processed}/${tickers.length}`);
await new Promise((r) => setTimeout(r, 1000)); await new Promise<void>((r) => setTimeout(r, 1000));
} }
this.logger.write('\n'); this.logger.write('\n');
return { ...results, marketContext }; return { ...results, marketContext };
} }
async _fetch(ticker) { private async _fetch(ticker: string): Promise<FetchResult> {
try { try {
const summary = await this.client.fetchSummary(ticker); const summary = await this.client.fetchSummary(ticker);
if (!summary?.price) throw new Error('Empty response from Yahoo'); if (!summary?.price) throw new Error('Empty response from Yahoo');
return mapToStandardFormat(ticker, summary); return mapToStandardFormat(ticker, summary);
} catch (err) { } catch (err) {
return { isError: true, ticker: ticker.toUpperCase(), message: err.message }; return { isError: true, ticker: ticker.toUpperCase(), message: (err as Error).message };
} }
} }
_process(data, marketContext, results) { private _process(
if (data.isError) { data: FetchResult,
results.ERROR.push(data); marketContext: MarketContext,
results: Omit<ScreenerResult, 'marketContext'>,
): void {
if ('isError' in data && data.isError) {
results.ERROR.push({ ticker: data.ticker, message: data.message });
return; return;
} }
try { try {
const asset = this._buildAsset(data); const asset = this._buildAsset(data as ReturnType<typeof mapToStandardFormat>);
const scorer = SCORERS[asset.type]; const scorer = SCORERS[asset.type as AssetType];
if (!scorer) throw new Error(`No scorer for type: ${asset.type}`); if (!scorer) throw new Error(`No scorer for type: ${asset.type}`);
const fundamental = scorer.score( const fundamental = scorer.score(
asset.metrics, asset.metrics as never,
RuleMerger.getRulesForAsset( RuleMerger.getRulesForAsset(
asset.type, asset.type as AssetType,
asset.metrics, asset.metrics as { sector?: string },
marketContext, marketContext,
SCORE_MODE.FUNDAMENTAL, SCORE_MODE.FUNDAMENTAL,
), ),
marketContext, marketContext,
); );
const inflated = scorer.score( const inflated = scorer.score(
asset.metrics, asset.metrics as never,
RuleMerger.getRulesForAsset(asset.type, asset.metrics, marketContext, SCORE_MODE.INFLATED), RuleMerger.getRulesForAsset(
asset.type as AssetType,
asset.metrics as { sector?: string },
marketContext,
SCORE_MODE.INFLATED,
),
marketContext, marketContext,
); );
results[asset.type].push({ (results[asset.type as AssetType] as unknown[]).push({
asset, asset,
fundamental, fundamental,
inflated, inflated,
@@ -108,26 +147,26 @@ export class ScreenerEngine {
}); });
} catch (err) { } catch (err) {
results.ERROR.push({ results.ERROR.push({
ticker: (data.ticker || 'UNKNOWN').toUpperCase(), ticker: ((data as { ticker?: string }).ticker || 'UNKNOWN').toUpperCase(),
message: err.message, message: (err as Error).message,
}); });
} }
} }
_buildAsset(data) { private _buildAsset(data: Record<string, unknown>): Stock | Etf | Bond {
switch ((data.type || ASSET_TYPE.STOCK).toUpperCase()) { switch (((data.type as string) || ASSET_TYPE.STOCK).toUpperCase()) {
case ASSET_TYPE.BOND: case ASSET_TYPE.BOND:
return new Bond(data); return new Bond(data as never);
case ASSET_TYPE.ETF: case ASSET_TYPE.ETF:
return new Etf(data); return new Etf(data as never);
default: default:
return new Stock(data); return new Stock(data as never);
} }
} }
_signal(fundamentalLabel, inflatedLabel) { private _signal(fundamentalLabel: string, inflatedLabel: string): Signal {
const green = (l) => l.startsWith('🟢'); const green = (l: string) => l.startsWith('🟢');
const yellow = (l) => l.startsWith('🟡'); const yellow = (l: string) => l.startsWith('🟡');
if (green(fundamentalLabel)) return SIGNAL.STRONG_BUY; if (green(fundamentalLabel)) return SIGNAL.STRONG_BUY;
if (green(inflatedLabel) && yellow(fundamentalLabel)) return SIGNAL.MOMENTUM; if (green(inflatedLabel) && yellow(fundamentalLabel)) return SIGNAL.MOMENTUM;
if (green(inflatedLabel) && !green(fundamentalLabel)) return SIGNAL.SPECULATION; if (green(inflatedLabel) && !green(fundamentalLabel)) return SIGNAL.SPECULATION;
@@ -135,7 +174,7 @@ export class ScreenerEngine {
return SIGNAL.AVOID; return SIGNAL.AVOID;
} }
signalOrder(signal) { signalOrder(signal: Signal): number {
return SIGNAL_ORDER[signal] ?? 5; return SIGNAL_ORDER[signal] ?? 5;
} }
} }
-19
View File
@@ -1,19 +0,0 @@
export class Asset {
constructor(data) {
this.ticker = (data.ticker || 'UNKNOWN').toUpperCase();
this.currentPrice = data.currentPrice || 0;
this.type = (data.type || 'STOCK').toUpperCase();
}
formatCurrency(val) {
return val ? `$${val.toFixed(2)}` : 'N/A';
}
formatLargeNumber(num) {
if (!num) return 'N/A';
if (num >= 1e12) return `${(num / 1e12).toFixed(2)}T`;
if (num >= 1e9) return `${(num / 1e9).toFixed(2)}B`;
if (num >= 1e6) return `${(num / 1e6).toFixed(2)}M`;
return num.toString();
}
}
+32
View File
@@ -0,0 +1,32 @@
import type { AssetType } from '../../types.js';
interface AssetData {
ticker?: string;
currentPrice?: number;
type?: string;
[key: string]: unknown;
}
export class Asset {
ticker: string;
currentPrice: number;
type: AssetType;
constructor(data: AssetData) {
this.ticker = (data.ticker || 'UNKNOWN').toUpperCase();
this.currentPrice = (data.currentPrice as number) || 0;
this.type = (data.type || 'STOCK').toUpperCase() as AssetType;
}
formatCurrency(val: number | null | undefined): string {
return val ? `$${val.toFixed(2)}` : 'N/A';
}
formatLargeNumber(num: number | null | undefined): string {
if (!num) return 'N/A';
if (num >= 1e12) return `${(num / 1e12).toFixed(2)}T`;
if (num >= 1e9) return `${(num / 1e9).toFixed(2)}B`;
if (num >= 1e6) return `${(num / 1e6).toFixed(2)}M`;
return num.toString();
}
}
@@ -1,22 +1,40 @@
import { CREDIT_RATING_SCALE } from '../../config/ScoringConfig.js'; import { CREDIT_RATING_SCALE } from '../../config/ScoringConfig.js';
import { Asset } from './Asset.js'; import { Asset } from './Asset.js';
interface BondData {
ticker?: string;
currentPrice?: number;
creditRating?: string;
yieldToMaturity?: string | number;
duration?: string | number;
[key: string]: unknown;
}
export interface BondMetrics {
ytm: number;
duration: number;
creditRating: string;
creditRatingNumeric: number;
}
export class Bond extends Asset { export class Bond extends Asset {
constructor(data) { metrics: BondMetrics;
constructor(data: BondData) {
super(data); super(data);
const creditRating = data.creditRating || 'BBB'; const creditRating = data.creditRating || 'BBB';
const creditRatingNumeric = CREDIT_RATING_SCALE[creditRating] ?? 7; const creditRatingNumeric = CREDIT_RATING_SCALE[creditRating] ?? 7;
this.metrics = { this.metrics = {
ytm: parseFloat(data.yieldToMaturity) || 0, ytm: parseFloat(String(data.yieldToMaturity)) || 0,
duration: parseFloat(data.duration) || 0, duration: parseFloat(String(data.duration)) || 0,
creditRating, creditRating,
creditRatingNumeric, creditRatingNumeric,
}; };
} }
getDisplayMetrics() { getDisplayMetrics(): Record<string, string> {
return { return {
Ticker: this.ticker, Ticker: this.ticker,
Type: 'BOND', Type: 'BOND',
-26
View File
@@ -1,26 +0,0 @@
import { Asset } from './Asset.js';
export class Etf extends Asset {
constructor(data) {
super(data);
this.metrics = {
expenseRatio: parseFloat(data.expenseRatio) || 0,
totalAssets: parseFloat(data.totalAssets) || 0,
yield: parseFloat(data.yield) || 0,
volume: parseFloat(data.volume) || 0,
fiveYearReturn: parseFloat(data.fiveYearReturn) || 0,
};
}
getDisplayMetrics() {
return {
Ticker: this.ticker,
Type: 'ETF',
Price: this.formatCurrency(this.currentPrice),
'Exp Ratio%': `${this.metrics.expenseRatio.toFixed(2)}%`,
'Yield%': `${this.metrics.yield.toFixed(2)}%`,
AUM: this.formatLargeNumber(this.metrics.totalAssets),
'5Y Return%': `${this.metrics.fiveYearReturn.toFixed(1)}%`,
};
}
}
+47
View File
@@ -0,0 +1,47 @@
import { Asset } from './Asset.js';
interface EtfData {
ticker?: string;
currentPrice?: number;
expenseRatio?: string | number;
totalAssets?: string | number;
yield?: string | number;
volume?: string | number;
fiveYearReturn?: string | number;
[key: string]: unknown;
}
export interface EtfMetrics {
expenseRatio: number;
totalAssets: number;
yield: number;
volume: number;
fiveYearReturn: number;
}
export class Etf extends Asset {
metrics: EtfMetrics;
constructor(data: EtfData) {
super(data);
this.metrics = {
expenseRatio: parseFloat(String(data.expenseRatio)) || 0,
totalAssets: parseFloat(String(data.totalAssets)) || 0,
yield: parseFloat(String(data.yield)) || 0,
volume: parseFloat(String(data.volume)) || 0,
fiveYearReturn: parseFloat(String(data.fiveYearReturn)) || 0,
};
}
getDisplayMetrics(): Record<string, string> {
return {
Ticker: this.ticker,
Type: 'ETF',
Price: this.formatCurrency(this.currentPrice),
'Exp Ratio%': `${this.metrics.expenseRatio.toFixed(2)}%`,
'Yield%': `${this.metrics.yield.toFixed(2)}%`,
AUM: this.formatLargeNumber(this.metrics.totalAssets),
'5Y Return%': `${this.metrics.fiveYearReturn.toFixed(1)}%`,
};
}
}
@@ -1,48 +1,86 @@
import { Asset } from './Asset.js'; import { Asset } from './Asset.js';
import type { Sector } from '../../config/constants.js';
interface StockData {
ticker?: string;
currentPrice?: number;
assetProfile?: { industry?: string; sector?: string };
peRatio?: number | null;
pegRatio?: number | null;
priceToBook?: number | null;
netProfitMargin?: number | null;
operatingMargin?: number | null;
returnOnEquity?: number | null;
revenueGrowth?: number | null;
earningsGrowth?: number | null;
debtToEquity?: number | null;
quickRatio?: number | null;
fcfYield?: number | null;
pFFO?: number | null;
dividendYield?: number | null;
beta?: number | null;
week52High?: number | null;
week52Low?: number | null;
[key: string]: unknown;
}
export interface StockMetrics {
sector: Sector;
peRatio: number | null;
pegRatio: number | null;
priceToBook: number | null;
netProfitMargin: number | null;
operatingMargin: number | null;
returnOnEquity: number | null;
revenueGrowth: number | null;
earningsGrowth: number | null;
debtToEquity: number | null;
quickRatio: number | null;
fcfYield: number | null;
pFFO: number | null;
dividendYield: number | null;
beta: number | null;
week52High: number | null;
week52Low: number | null;
currentPrice: number;
}
export class Stock extends Asset { export class Stock extends Asset {
constructor(data) { sector: Sector;
metrics: StockMetrics;
constructor(data: StockData) {
super(data); super(data);
// console.log('Data:', data); this.sector = this._mapToStandardSector(data);
this.sector = this._mapToStandardSector(data || {});
this.metrics = { this.metrics = {
sector: this.sector, sector: this.sector,
// Valuation
peRatio: data.peRatio ?? null, peRatio: data.peRatio ?? null,
pegRatio: data.pegRatio ?? null, pegRatio: data.pegRatio ?? null,
priceToBook: data.priceToBook ?? null, priceToBook: data.priceToBook ?? null,
// Profitability
netProfitMargin: data.netProfitMargin ?? null, netProfitMargin: data.netProfitMargin ?? null,
operatingMargin: data.operatingMargin ?? null, operatingMargin: data.operatingMargin ?? null,
returnOnEquity: data.returnOnEquity ?? null, returnOnEquity: data.returnOnEquity ?? null,
// Growth
revenueGrowth: data.revenueGrowth ?? null, revenueGrowth: data.revenueGrowth ?? null,
earningsGrowth: data.earningsGrowth ?? null, earningsGrowth: data.earningsGrowth ?? null,
// Financial health
debtToEquity: data.debtToEquity ?? null, debtToEquity: data.debtToEquity ?? null,
quickRatio: data.quickRatio ?? null, quickRatio: data.quickRatio ?? null,
// Cash flow
fcfYield: data.fcfYield ?? null, fcfYield: data.fcfYield ?? null,
pFFO: data.pFFO ?? null, pFFO: data.pFFO ?? null,
// Income
dividendYield: data.dividendYield ?? null, dividendYield: data.dividendYield ?? null,
// Risk & momentum
beta: data.beta ?? null, beta: data.beta ?? null,
week52High: data.week52High ?? null, week52High: data.week52High ?? null,
week52Low: data.week52Low ?? null, week52Low: data.week52Low ?? null,
currentPrice: data.currentPrice ?? 0, currentPrice: (data.currentPrice as number) || 0,
}; };
} }
_mapToStandardSector(data) { _mapToStandardSector(data: StockData): Sector {
const profile = data.assetProfile || {}; const profile = data.assetProfile ?? {};
const industry = (profile.industry || '').toLowerCase(); const industry = (profile.industry || '').toLowerCase();
const sector = (profile.sector || '').toLowerCase(); const sector = (profile.sector || '').toLowerCase();
const combined = `${industry} ${sector}`; const combined = `${industry} ${sector}`;
// Yahoo Finance sector/industry strings mapped to our internal sector constants.
// Order matters — more specific matches first.
if ( if (
combined.includes('technology') || combined.includes('technology') ||
combined.includes('electronic') || combined.includes('electronic') ||
@@ -72,7 +110,6 @@ export class Stock extends Asset {
combined.includes('medical') combined.includes('medical')
) )
return 'HEALTHCARE'; return 'HEALTHCARE';
// Yahoo calls this "Communication Services" — covers META, GOOGL, NFLX, DIS, T
if ( if (
combined.includes('communication') || combined.includes('communication') ||
combined.includes('media') || combined.includes('media') ||
@@ -100,20 +137,22 @@ export class Stock extends Asset {
return 'GENERAL'; return 'GENERAL';
} }
getDisplayMetrics() { getDisplayMetrics(): Record<string, string | null> {
const fmt = (v, dec = 1, suffix = '') => (v != null ? `${v.toFixed(dec)}${suffix}` : null); const fmt = (v: number | null, dec = 1, suffix = '') =>
v != null ? `${v.toFixed(dec)}${suffix}` : null;
const m = this.metrics; const m = this.metrics;
const w52pos = const w52pos =
m.week52High > 0 && m.week52Low != null && m.currentPrice > 0 m.week52High != null && m.week52High > 0 && m.week52Low != null && m.currentPrice > 0
? (((m.currentPrice - m.week52Low) / (m.week52High - m.week52Low)) * 100).toFixed(0) + '%' ? (((m.currentPrice - m.week52Low) / (m.week52High - m.week52Low)) * 100).toFixed(0) + '%'
: null; : null;
// Only include fields that have actual data — null fields are omitted const display: Record<string, string | null> = {
const display = {
Ticker: this.ticker, Ticker: this.ticker,
Price: this.formatCurrency(this.currentPrice), Price: this.formatCurrency(this.currentPrice),
Sector: this.sector, Sector: this.sector,
}; };
if (m.peRatio != null) display['P/E'] = fmt(m.peRatio, 1); if (m.peRatio != null) display['P/E'] = fmt(m.peRatio, 1);
if (m.pegRatio != null) display['PEG'] = fmt(m.pegRatio, 2); if (m.pegRatio != null) display['PEG'] = fmt(m.pegRatio, 2);
if (m.priceToBook != null) display['P/B'] = fmt(m.priceToBook, 2); if (m.priceToBook != null) display['P/B'] = fmt(m.priceToBook, 2);
@@ -1,5 +1,29 @@
import type { BondMetrics } from '../assets/Bond.js';
import type { MarketContext } from '../../types.js';
interface SanitizedBondMetrics {
ytm: number;
duration: number;
creditRating: string;
creditRatingNumeric: number;
}
interface ScoreOutput {
label: string;
scoreSummary: string;
audit: Record<string, unknown>;
}
export const BondScorer = { export const BondScorer = {
score(m, rules, context) { score(
m: BondMetrics,
rules: {
gates: Record<string, number>;
weights: Record<string, number>;
thresholds: Record<string, number>;
},
context?: MarketContext | null,
): ScoreOutput {
const { gates, weights, thresholds } = rules; const { gates, weights, thresholds } = rules;
const metrics = this._sanitize(m); const metrics = this._sanitize(m);
const riskFreeRate = (context?.riskFreeRate ?? 4.0) / 100; const riskFreeRate = (context?.riskFreeRate ?? 4.0) / 100;
@@ -12,10 +36,9 @@ export const BondScorer = {
}; };
} }
// Convert spread to percentage to match minSpread threshold (e.g. 1.0 = 1%)
const spreadPct = (metrics.ytm - riskFreeRate) * 100; const spreadPct = (metrics.ytm - riskFreeRate) * 100;
const breakdown = { const breakdown: Record<string, number> = {
spread: spreadPct >= thresholds.minSpread ? weights.yieldSpread : -2, spread: spreadPct >= thresholds.minSpread ? weights.yieldSpread : -2,
duration: metrics.duration <= thresholds.maxDuration ? weights.duration : -1, duration: metrics.duration <= thresholds.maxDuration ? weights.duration : -1,
}; };
@@ -28,11 +51,12 @@ export const BondScorer = {
}; };
}, },
_sanitize(m) { _sanitize(m: BondMetrics): SanitizedBondMetrics {
const pct = (v) => parseFloat(typeof v === 'string' ? v.replace('%', '') : v) / 100 || 0; const pct = (v: unknown): number =>
parseFloat(typeof v === 'string' ? v.replace('%', '') : String(v)) / 100 || 0;
return { return {
ytm: pct(m.ytm), ytm: pct(m.ytm),
duration: parseFloat(m.duration) || 0, duration: parseFloat(String(m.duration)) || 0,
creditRating: m.creditRating || 'BBB', creditRating: m.creditRating || 'BBB',
creditRatingNumeric: m.creditRatingNumeric ?? 7, creditRatingNumeric: m.creditRatingNumeric ?? 7,
}; };
@@ -1,22 +1,36 @@
import type { EtfMetrics } from '../assets/Etf.js';
interface ScoreOutput {
label: string;
scoreSummary: string;
audit?: Record<string, unknown>;
}
export const EtfScorer = { export const EtfScorer = {
score(m, rules) { score(
m: EtfMetrics,
rules: {
gates: Record<string, number>;
weights: Record<string, number>;
thresholds: Record<string, number>;
},
): ScoreOutput {
const { gates, weights, thresholds } = rules; const { gates, weights, thresholds } = rules;
const metrics = { const metrics = {
expenseRatio: parseFloat(m.expenseRatio) || 0, expenseRatio: parseFloat(String(m.expenseRatio)) || 0,
yield: parseFloat(m.yield) || 0, yield: parseFloat(String(m.yield)) || 0,
volume: parseFloat(m.volume) || 0, volume: parseFloat(String(m.volume)) || 0,
fiveYearReturn: parseFloat(m.fiveYearReturn) || 0, fiveYearReturn: parseFloat(String(m.fiveYearReturn)) || 0,
}; };
if (metrics.expenseRatio > gates.maxExpenseRatio) { if (metrics.expenseRatio > gates.maxExpenseRatio) {
return { label: '🔴 REJECT', scoreSummary: 'Gate failed: High Expense Ratio' }; return { label: '🔴 REJECT', scoreSummary: 'Gate failed: High Expense Ratio' };
} }
const breakdown = { const breakdown: Record<string, number> = {
cost: metrics.expenseRatio <= thresholds.maxExpense ? weights.lowCost : -3, cost: metrics.expenseRatio <= thresholds.maxExpense ? weights.lowCost : -3,
yield: metrics.yield >= thresholds.minYield ? weights.yield : -1, yield: metrics.yield >= thresholds.minYield ? weights.yield : -1,
vol: metrics.volume >= (thresholds.minVolume ?? 1000000) ? 0 : -2, vol: metrics.volume >= (thresholds.minVolume ?? 1_000_000) ? 0 : -2,
// 5Y return: strong long-term performance vs the ~10% S&P average is rewarded
fiveYearReturn: fiveYearReturn:
thresholds.minFiveYearReturn != null thresholds.minFiveYearReturn != null
? metrics.fiveYearReturn >= thresholds.minFiveYearReturn ? metrics.fiveYearReturn >= thresholds.minFiveYearReturn
@@ -1,15 +1,51 @@
import { SIGNAL } from '../../config/constants.js'; import { SIGNAL } from '../../config/constants.js';
import type { StockMetrics } from '../assets/Stock.js';
const n = (v) => { type NumVal = number | null;
const f = parseFloat(v);
const n = (v: unknown): NumVal => {
const f = parseFloat(String(v));
return !isNaN(f) && f !== 0 ? f : null; return !isNaN(f) && f !== 0 ? f : null;
}; };
const scoreValue = (val, high, med, weight) => (val >= high ? weight : val >= med ? 1 : -1); const scoreValue = (val: number, high: number, med: number, weight: number): number =>
const scorePeg = (val, high, med, weight) => (val <= high ? weight : val <= med ? 1 : -1); val >= high ? weight : val >= med ? 1 : -1;
const scorePeg = (val: number, high: number, med: number, weight: number): number =>
val <= high ? weight : val <= med ? 1 : -1;
interface SanitizedMetrics {
debtToEquity: NumVal;
quickRatio: NumVal;
peRatio: NumVal;
pegRatio: NumVal;
priceToBook: NumVal;
netProfitMargin: NumVal;
operatingMargin: NumVal;
returnOnEquity: NumVal;
revenueGrowth: NumVal;
fcfYield: NumVal;
dividendYield: NumVal;
pFFO: NumVal;
beta: NumVal;
week52Position: NumVal;
}
interface ScoreOutput {
label: string;
scoreSummary: string;
audit: Record<string, unknown>;
}
export const StockScorer = { export const StockScorer = {
score(metrics, rules) { score(
metrics: StockMetrics,
rules: {
gates: Record<string, number>;
weights: Record<string, number>;
thresholds: Record<string, number>;
},
): ScoreOutput {
const { gates, weights, thresholds } = rules; const { gates, weights, thresholds } = rules;
const m = this._sanitize(metrics); const m = this._sanitize(metrics);
@@ -30,7 +66,7 @@ export const StockScorer = {
gates.maxPriceToBook && gates.maxPriceToBook &&
m.priceToBook > gates.maxPriceToBook && m.priceToBook > gates.maxPriceToBook &&
`P/B ${m.priceToBook.toFixed(1)} > ${gates.maxPriceToBook}`, `P/B ${m.priceToBook.toFixed(1)} > ${gates.maxPriceToBook}`,
].filter(Boolean); ].filter(Boolean) as string[];
if (failures.length > 0) { if (failures.length > 0) {
return { return {
@@ -44,14 +80,14 @@ export const StockScorer = {
{ {
key: 'roe', key: 'roe',
active: weights.roe > 0 && m.returnOnEquity != null, active: weights.roe > 0 && m.returnOnEquity != null,
fn: () => scoreValue(m.returnOnEquity, thresholds.roeHigh, thresholds.roeMed, weights.roe), fn: () => scoreValue(m.returnOnEquity!, thresholds.roeHigh, thresholds.roeMed, weights.roe),
}, },
{ {
key: 'opMargin', key: 'opMargin',
active: weights.opMargin > 0 && m.operatingMargin != null, active: weights.opMargin > 0 && m.operatingMargin != null,
fn: () => fn: () =>
scoreValue( scoreValue(
m.operatingMargin, m.operatingMargin!,
thresholds.opMarginHigh, thresholds.opMarginHigh,
thresholds.opMarginMed, thresholds.opMarginMed,
weights.opMargin, weights.opMargin,
@@ -62,7 +98,7 @@ export const StockScorer = {
active: weights.margin > 0 && m.netProfitMargin != null, active: weights.margin > 0 && m.netProfitMargin != null,
fn: () => fn: () =>
scoreValue( scoreValue(
m.netProfitMargin, m.netProfitMargin!,
thresholds.marginHigh, thresholds.marginHigh,
thresholds.marginMed, thresholds.marginMed,
weights.margin, weights.margin,
@@ -71,41 +107,41 @@ export const StockScorer = {
{ {
key: 'peg', key: 'peg',
active: weights.peg > 0 && m.pegRatio != null, active: weights.peg > 0 && m.pegRatio != null,
fn: () => scorePeg(m.pegRatio, thresholds.pegHigh, thresholds.pegMed, weights.peg), fn: () => scorePeg(m.pegRatio!, thresholds.pegHigh, thresholds.pegMed, weights.peg),
}, },
{ {
key: 'revenue', key: 'revenue',
active: weights.revenue > 0 && m.revenueGrowth != null, active: weights.revenue > 0 && m.revenueGrowth != null,
fn: () => fn: () =>
scoreValue(m.revenueGrowth, thresholds.revHigh, thresholds.revMed, weights.revenue), scoreValue(m.revenueGrowth!, thresholds.revHigh, thresholds.revMed, weights.revenue),
}, },
{ {
key: 'fcf', key: 'fcf',
active: weights.fcf > 0 && m.fcfYield != null, active: weights.fcf > 0 && m.fcfYield != null,
fn: () => fn: () =>
scoreValue(m.fcfYield, thresholds.fcfHigh ?? 5, thresholds.fcfMed ?? 2, weights.fcf), scoreValue(m.fcfYield!, thresholds.fcfHigh ?? 5, thresholds.fcfMed ?? 2, weights.fcf),
}, },
{ {
key: 'yield', key: 'yield',
active: (weights.yield ?? 0) > 0 && m.dividendYield != null, active: (weights.yield ?? 0) > 0 && m.dividendYield != null,
fn: () => (m.dividendYield >= (thresholds.minYield ?? 4) ? weights.yield : -1), fn: () => (m.dividendYield! >= (thresholds.minYield ?? 4) ? weights.yield : -1),
}, },
{ {
key: 'pFFO', key: 'pFFO',
active: (weights.pFFO ?? 0) > 0 && m.pFFO != null, active: (weights.pFFO ?? 0) > 0 && m.pFFO != null,
fn: () => (m.pFFO <= (thresholds.maxPFFO ?? 15) ? weights.pFFO : -2), fn: () => (m.pFFO! <= (thresholds.maxPFFO ?? 15) ? weights.pFFO : -2),
}, },
{ {
key: 'priceToBook', key: 'priceToBook',
active: (weights.priceToBook ?? 0) > 0 && m.priceToBook != null, active: (weights.priceToBook ?? 0) > 0 && m.priceToBook != null,
fn: () => scoreValue(1 / m.priceToBook, 1 / 1.0, 1 / 2.0, weights.priceToBook), fn: () => scoreValue(1 / m.priceToBook!, 1 / 1.0, 1 / 2.0, weights.priceToBook),
}, },
]; ];
const breakdown = {}; const breakdown: Record<string, number> = {};
const totalScore = factors.reduce((sum, f) => { const totalScore = factors.reduce((sum, f) => {
if (!f.active) return sum; if (!f.active) return sum;
breakdown[f.key] = f.fn(); breakdown[f.key] = f.fn() as number;
return sum + breakdown[f.key]; return sum + breakdown[f.key];
}, 0); }, 0);
@@ -116,7 +152,7 @@ export const StockScorer = {
m.week52Position != null && m.week52Position != null &&
m.week52Position < 0.1 && m.week52Position < 0.1 &&
'Near 52-week low — potential opportunity', 'Near 52-week low — potential opportunity',
].filter(Boolean); ].filter(Boolean) as string[];
return { return {
label: this._label(totalScore), label: this._label(totalScore),
@@ -125,16 +161,16 @@ export const StockScorer = {
}; };
}, },
_label(score) { _label(score: number): string {
if (score >= 8) return '🟢 BUY (High Conviction)'; if (score >= 8) return '🟢 BUY (High Conviction)';
if (score >= 4) return '🟢 BUY (Speculative)'; if (score >= 4) return '🟢 BUY (Speculative)';
if (score >= 0) return '🟡 HOLD'; if (score >= 0) return '🟡 HOLD';
return '🔴 REJECT'; return '🔴 REJECT';
}, },
_sanitize(m) { _sanitize(m: StockMetrics): SanitizedMetrics {
const w52 = const w52 =
m.week52High > 0 && m.week52Low != null && m.currentPrice > 0 m.week52High != null && m.week52High > 0 && m.week52Low != null && m.currentPrice > 0
? (m.currentPrice - m.week52Low) / (m.week52High - m.week52Low) ? (m.currentPrice - m.week52Low) / (m.week52High - m.week52Low)
: null; : null;
return { return {
+22 -15
View File
@@ -7,18 +7,22 @@ import { YahooClient } from '../market/YahooClient.js';
import { LLMAnalyst } from '../analyst/LLMAnalyst.js'; import { LLMAnalyst } from '../analyst/LLMAnalyst.js';
import { noopLogger } from './utils/logger.js'; import { noopLogger } from './utils/logger.js';
export async function buildApp({ logger = true } = {}) { interface BuildAppOptions {
logger?: boolean;
}
export async function buildApp({ logger = true }: BuildAppOptions = {}) {
const app = Fastify({ logger }); const app = Fastify({ logger });
await app.register(cors, { await app.register(cors, {
origin: process.env.CLIENT_ORIGIN ?? 'http://localhost:5173', origin: process.env.CLIENT_ORIGIN ?? 'http://localhost:5173',
}); });
await app.register(screenerRoutes); await app.register(screenerRoutes as any);
await app.register(financeRoutes); await app.register(financeRoutes as any);
await app.register(callsRoutes); await app.register(callsRoutes as any);
// POST /api/analyze — fetch Yahoo news for tickers and run LLM analysis // POST /api/analyze
app.post('/api/analyze', { app.post('/api/analyze', {
schema: { schema: {
body: { body: {
@@ -29,21 +33,27 @@ export async function buildApp({ logger = true } = {}) {
}, },
}, },
}, },
handler: async (req, reply) => { handler: async (req: any, reply: any) => {
if (!process.env.ANTHROPIC_API_KEY) { if (!process.env.ANTHROPIC_API_KEY) {
return reply.code(400).send({ error: 'ANTHROPIC_API_KEY is not set in .env' }); return reply.code(400).send({ error: 'ANTHROPIC_API_KEY is not set in .env' });
} }
const tickers = req.body.tickers.map((t) => t.toUpperCase()); const tickers = (req.body as { tickers: string[] }).tickers.map((t) => t.toUpperCase());
const client = new YahooClient(); const client = new YahooClient();
const llm = new LLMAnalyst({ logger: noopLogger }); const llm = new LLMAnalyst({ logger: noopLogger });
const seen = new Map(); const seen = new Map<
string,
{ title: string; publisher: string; link: string; relatedTickers: string[] }
>();
await Promise.all( await Promise.all(
tickers.slice(0, 10).map(async (ticker) => { tickers.slice(0, 10).map(async (ticker: string) => {
try { try {
const { news = [] } = await client.yf.search(ticker, { newsCount: 3, quotesCount: 0 }); const { news = [] } = await (client as any).yf.search(ticker, {
for (const s of news) { newsCount: 3,
quotesCount: 0,
});
for (const s of news as any[]) {
if (!seen.has(s.title)) { if (!seen.has(s.title)) {
seen.set(s.title, { seen.set(s.title, {
title: s.title, title: s.title,
@@ -60,10 +70,7 @@ export async function buildApp({ logger = true } = {}) {
); );
const stories = [...seen.values()].slice(0, 15); const stories = [...seen.values()].slice(0, 15);
if (!stories.length) return reply.code(200).send({ analysis: null, reason: 'no_stories' });
if (!stories.length) {
return reply.code(200).send({ analysis: null, reason: 'no_stories' });
}
const analysis = await llm.analyze(stories, tickers); const analysis = await llm.analyze(stories, tickers);
return { analysis }; return { analysis };
@@ -3,10 +3,20 @@ import { ScreenerEngine } from '../../screener/ScreenerEngine.js';
import { YahooClient } from '../../market/YahooClient.js'; import { YahooClient } from '../../market/YahooClient.js';
import { chunkArray } from '../../screener/Chunker.js'; import { chunkArray } from '../../screener/Chunker.js';
import { noopLogger } from '../utils/logger.js'; import { noopLogger } from '../utils/logger.js';
const store = new MarketCallStore(); const store = new MarketCallStore();
// Takes a screener result entry and flattens it to a snapshot record interface SnapshotEntry {
const toSnapshot = (r) => { price: number | null;
signal: string | null;
inflatedVerdict: string | null;
fundamentalVerdict: string | null;
pe: string | null;
roe: string | null;
fcf: string | null;
}
const toSnapshot = (r: any): SnapshotEntry | null => {
if (!r) return null; if (!r) return null;
const m = r.asset?.displayMetrics ?? r.asset?.getDisplayMetrics?.() ?? {}; const m = r.asset?.displayMetrics ?? r.asset?.getDisplayMetrics?.() ?? {};
return { return {
@@ -20,36 +30,31 @@ const toSnapshot = (r) => {
}; };
}; };
export default async function callsRoutes(app) { export default async function callsRoutes(app: any) {
// GET /api/calls — list all market calls (newest first) // GET /api/calls
app.get('/api/calls', async () => { app.get('/api/calls', async () => ({ calls: store.list() }));
return { calls: store.list() };
});
// GET /api/calls/:id — get one call + enrich with current prices for comparison // GET /api/calls/:id
app.get('/api/calls/:id', async (req, reply) => { app.get('/api/calls/:id', async (req: any, reply: any) => {
const call = store.get(req.params.id); const call = store.get((req.params as { id: string }).id);
if (!call) return reply.code(404).send({ error: 'Call not found' }); if (!call) return reply.code(404).send({ error: 'Call not found' });
// Re-screen the tickers to get current prices for comparison const current: Record<string, SnapshotEntry | null> = {};
let current = {};
if (call.tickers.length > 0) { if (call.tickers.length > 0) {
try { try {
const engine = new ScreenerEngine({ logger: noopLogger }); const engine = new ScreenerEngine({ logger: noopLogger });
const results = await engine.screenTickers(call.tickers); const results = await engine.screenTickers(call.tickers);
const all = [...results.STOCK, ...results.ETF, ...results.BOND]; for (const r of [...results.STOCK, ...results.ETF, ...results.BOND]) {
for (const r of all) {
current[r.asset.ticker] = toSnapshot(r); current[r.asset.ticker] = toSnapshot(r);
} }
} catch { } catch {
// Non-fatal — return call without current prices /* non-fatal */
} }
} }
return { ...call, current }; return { ...call, current };
}); });
// POST /api/calls — create a new market call and snapshot current prices // POST /api/calls
app.post('/api/calls', { app.post('/api/calls', {
schema: { schema: {
body: { body: {
@@ -64,58 +69,64 @@ export default async function callsRoutes(app) {
}, },
}, },
}, },
handler: async (req, reply) => { handler: async (req: any, reply: any) => {
const { title, quarter, date, thesis, tickers } = req.body; const { title, quarter, date, thesis, tickers } = req.body as {
const upperTickers = tickers.map((t) => t.toUpperCase()); title: string;
quarter: string;
date?: string;
thesis: string;
tickers: string[];
};
const upperTickers = tickers.map((t: string) => t.toUpperCase());
// Snapshot current screener data for each ticker const snapshot: Record<string, SnapshotEntry | null> = {};
let snapshot = {};
try { try {
const engine = new ScreenerEngine({ logger: noopLogger }); const engine = new ScreenerEngine({ logger: noopLogger });
const results = await engine.screenTickers(upperTickers); const results = await engine.screenTickers(upperTickers);
const all = [...results.STOCK, ...results.ETF, ...results.BOND]; for (const r of [...results.STOCK, ...results.ETF, ...results.BOND]) {
for (const r of all) {
snapshot[r.asset.ticker] = toSnapshot(r); snapshot[r.asset.ticker] = toSnapshot(r);
} }
} catch (err) { } catch (err) {
app.log.warn('Could not snapshot prices for market call:', err.message); app.log.warn('Could not snapshot prices for market call:', (err as Error).message);
} }
const call = store.create({ title, quarter, date, thesis, tickers: upperTickers, snapshot }); const call = store.create({
title,
quarter,
date,
thesis,
tickers: upperTickers,
snapshot: snapshot as any,
});
return reply.code(201).send(call); return reply.code(201).send(call);
}, },
}); });
// DELETE /api/calls/:id // DELETE /api/calls/:id
app.delete('/api/calls/:id', async (req, reply) => { app.delete('/api/calls/:id', async (req: any, reply: any) => {
const deleted = store.delete(req.params.id); const deleted = store.delete((req.params as { id: string }).id);
if (!deleted) return reply.code(404).send({ error: 'Call not found' }); if (!deleted) return reply.code(404).send({ error: 'Call not found' });
return { ok: true }; return { ok: true };
}); });
// GET /api/calls/calendar?tickers=AAPL,MSFT (or omit to use all call tickers) // GET /api/calls/calendar
// Returns upcoming earnings dates, ex-dividend dates and dividend dates per ticker. app.get('/api/calls/calendar', async (req: any) => {
// Fetched in parallel batches of 5 with rate-limit delay.
app.get('/api/calls/calendar', async (req) => {
const client = new YahooClient(); const client = new YahooClient();
// Resolve tickers: from query param, or aggregate all unique tickers across all calls let tickers: string[];
let tickers; if ((req.query as any).tickers) {
if (req.query.tickers) { tickers = String((req.query as any).tickers)
tickers = req.query.tickers
.split(',') .split(',')
.map((t) => t.trim().toUpperCase()) .map((t) => t.trim().toUpperCase())
.filter(Boolean); .filter(Boolean);
} else { } else {
const allCalls = store.list(); const set = new Set(store.list().flatMap((c) => c.tickers));
const set = new Set(allCalls.flatMap((c) => c.tickers));
tickers = [...set]; tickers = [...set];
} }
if (tickers.length === 0) return { events: [] }; if (tickers.length === 0) return { events: [] };
// Fetch calendarEvents in parallel batches const results: Record<string, any> = {};
const results = {};
for (const batch of chunkArray(tickers, 5)) { for (const batch of chunkArray(tickers, 5)) {
await Promise.all( await Promise.all(
batch.map(async (ticker) => { batch.map(async (ticker) => {
@@ -123,17 +134,15 @@ export default async function callsRoutes(app) {
if (cal) results[ticker] = cal; if (cal) results[ticker] = cal;
}), }),
); );
await new Promise((r) => setTimeout(r, 500)); await new Promise<void>((r) => setTimeout(r, 500));
} }
// Flatten into a sorted event list const events: any[] = [];
const events = [];
const now = Date.now(); const now = Date.now();
for (const [ticker, cal] of Object.entries(results)) { for (const [ticker, cal] of Object.entries(results)) {
// Upcoming earnings dates
for (const dateVal of cal.earnings?.earningsDate ?? []) { for (const dateVal of cal.earnings?.earningsDate ?? []) {
const d = new Date(dateVal); const d = new Date(dateVal as string);
events.push({ events.push({
ticker, ticker,
type: 'earnings', type: 'earnings',
@@ -145,8 +154,6 @@ export default async function callsRoutes(app) {
isPast: d.getTime() < now, isPast: d.getTime() < now,
}); });
} }
// Ex-dividend date
if (cal.exDividendDate) { if (cal.exDividendDate) {
const d = new Date(cal.exDividendDate); const d = new Date(cal.exDividendDate);
events.push({ events.push({
@@ -158,8 +165,6 @@ export default async function callsRoutes(app) {
isPast: d.getTime() < now, isPast: d.getTime() < now,
}); });
} }
// Dividend payment date
if (cal.dividendDate) { if (cal.dividendDate) {
const d = new Date(cal.dividendDate); const d = new Date(cal.dividendDate);
events.push({ events.push({
@@ -173,12 +178,11 @@ export default async function callsRoutes(app) {
} }
} }
// Sort: upcoming first, then past
events.sort((a, b) => { events.sort((a, b) => {
if (a.isPast !== b.isPast) return a.isPast ? 1 : -1; if (a.isPast !== b.isPast) return a.isPast ? 1 : -1;
return a.isPast return a.isPast
? new Date(b.date) - new Date(a.date) // most recent past first ? new Date(b.date).getTime() - new Date(a.date).getTime()
: new Date(a.date) - new Date(b.date); // soonest upcoming first : new Date(a.date).getTime() - new Date(b.date).getTime();
}); });
return { events, tickers }; return { events, tickers };
@@ -4,19 +4,22 @@ import { PersonalFinanceAnalyzer } from '../../finance/PersonalFinanceAnalyzer.j
import { PortfolioAdvisor } from '../../finance/PortfolioAdvisor.js'; import { PortfolioAdvisor } from '../../finance/PortfolioAdvisor.js';
import { SimpleFINClient } from '../../finance/clients/SimpleFINClient.js'; import { SimpleFINClient } from '../../finance/clients/SimpleFINClient.js';
import { noopLogger } from '../utils/logger.js'; import { noopLogger } from '../utils/logger.js';
import type { PortfolioHolding } from '../../types.js';
const PORTFOLIO_PATH = './portfolio.json'; const PORTFOLIO_PATH = './portfolio.json';
export default async function financeRoutes(app) { const normalizeYahoo = (t: string) => t.toUpperCase().replace(/\./g, '-');
export default async function financeRoutes(app: any) {
// GET /api/finance/portfolio // GET /api/finance/portfolio
// Returns: { advice, personalFinance, marketContext } app.get('/api/finance/portfolio', async (req: any, reply: any) => {
app.get('/api/finance/portfolio', async (req, reply) => { if (!existsSync(PORTFOLIO_PATH))
if (!existsSync(PORTFOLIO_PATH)) {
return reply.code(404).send({ error: 'portfolio.json not found' }); return reply.code(404).send({ error: 'portfolio.json not found' });
}
const { holdings } = JSON.parse(readFileSync(PORTFOLIO_PATH, 'utf8')); const { holdings } = JSON.parse(readFileSync(PORTFOLIO_PATH, 'utf8')) as {
holdings: PortfolioHolding[];
};
// SimpleFIN is optional — omit if not configured
let personalFinance = null; let personalFinance = null;
if (process.env.SIMPLEFIN_ACCESS_URL) { if (process.env.SIMPLEFIN_ACCESS_URL) {
const client = new SimpleFINClient({ logger: noopLogger }); const client = new SimpleFINClient({ logger: noopLogger });
@@ -24,9 +27,6 @@ export default async function financeRoutes(app) {
personalFinance = new PersonalFinanceAnalyzer().analyse(accounts); personalFinance = new PersonalFinanceAnalyzer().analyse(accounts);
} }
// Normalize dot-notation tickers to Yahoo Finance format (BRK.B → BRK-B)
const normalizeYahoo = (t) => t.toUpperCase().replace(/\./g, '-');
const screenable = holdings const screenable = holdings
.filter((h) => (h.type ?? 'stock') !== 'crypto') .filter((h) => (h.type ?? 'stock') !== 'crypto')
.map((h) => normalizeYahoo(h.ticker)); .map((h) => normalizeYahoo(h.ticker));
@@ -35,16 +35,13 @@ export default async function financeRoutes(app) {
const results = const results =
screenable.length > 0 screenable.length > 0
? await engine.screenTickers(screenable) ? await engine.screenTickers(screenable)
: { STOCK: [], ETF: [], BOND: [], ERROR: [], marketContext: {} }; : { STOCK: [], ETF: [], BOND: [], ERROR: [], marketContext: {} as any };
const advice = await new PortfolioAdvisor().advise(holdings, results); const advice = await new PortfolioAdvisor().advise(holdings, results);
return { advice, personalFinance, marketContext: results.marketContext }; return { advice, personalFinance, marketContext: results.marketContext };
}); });
// POST /api/finance/holdings // POST /api/finance/holdings
// Add or update a single holding in portfolio.json.
// Body: { ticker, shares, costBasis, type, source }
app.post('/api/finance/holdings', { app.post('/api/finance/holdings', {
schema: { schema: {
body: { body: {
@@ -59,23 +56,25 @@ export default async function financeRoutes(app) {
}, },
}, },
}, },
handler: async (req, reply) => { handler: async (req: any, reply: any) => {
const { ticker, shares, costBasis = 0, type = 'stock', source = 'Manual' } = req.body; const {
ticker,
shares,
costBasis = 0,
type = 'stock',
source = 'Manual',
} = req.body as PortfolioHolding;
const normalized = ticker.toUpperCase().trim(); const normalized = ticker.toUpperCase().trim();
const portfolio = existsSync(PORTFOLIO_PATH) const portfolio = existsSync(PORTFOLIO_PATH)
? JSON.parse(readFileSync(PORTFOLIO_PATH, 'utf8')) ? (JSON.parse(readFileSync(PORTFOLIO_PATH, 'utf8')) as { holdings: PortfolioHolding[] })
: { holdings: [] }; : { holdings: [] as PortfolioHolding[] };
const idx = portfolio.holdings.findIndex((h) => h.ticker.toUpperCase() === normalized); const idx = portfolio.holdings.findIndex((h) => h.ticker.toUpperCase() === normalized);
const entry: PortfolioHolding = { ticker: normalized, shares, costBasis, type, source };
const entry = { ticker: normalized, shares, costBasis, type, source }; if (idx >= 0) portfolio.holdings[idx] = entry;
else portfolio.holdings.push(entry);
if (idx >= 0) {
portfolio.holdings[idx] = entry; // update existing
} else {
portfolio.holdings.push(entry); // add new
}
writeFileSync(PORTFOLIO_PATH, JSON.stringify(portfolio, null, 2), 'utf8'); writeFileSync(PORTFOLIO_PATH, JSON.stringify(portfolio, null, 2), 'utf8');
return reply.code(201).send(entry); return reply.code(201).send(entry);
@@ -83,14 +82,14 @@ export default async function financeRoutes(app) {
}); });
// DELETE /api/finance/holdings/:ticker // DELETE /api/finance/holdings/:ticker
// Remove a holding from portfolio.json. app.delete('/api/finance/holdings/:ticker', async (req: any, reply: any) => {
app.delete('/api/finance/holdings/:ticker', async (req, reply) => { const ticker = (req.params as { ticker: string }).ticker.toUpperCase();
const ticker = req.params.ticker.toUpperCase();
if (!existsSync(PORTFOLIO_PATH)) if (!existsSync(PORTFOLIO_PATH))
return reply.code(404).send({ error: 'portfolio.json not found' }); return reply.code(404).send({ error: 'portfolio.json not found' });
const portfolio = JSON.parse(readFileSync(PORTFOLIO_PATH, 'utf8')); const portfolio = JSON.parse(readFileSync(PORTFOLIO_PATH, 'utf8')) as {
holdings: PortfolioHolding[];
};
const before = portfolio.holdings.length; const before = portfolio.holdings.length;
portfolio.holdings = portfolio.holdings.filter((h) => h.ticker.toUpperCase() !== ticker); portfolio.holdings = portfolio.holdings.filter((h) => h.ticker.toUpperCase() !== ticker);
@@ -102,9 +101,8 @@ export default async function financeRoutes(app) {
}); });
// GET /api/finance/market-context // GET /api/finance/market-context
// Returns live benchmark data without running a full screen
app.get('/api/finance/market-context', async () => { app.get('/api/finance/market-context', async () => {
const engine = new ScreenerEngine({ logger: noopLogger }); const engine = new ScreenerEngine({ logger: noopLogger });
return engine.benchmarkProvider.getMarketContext(); return engine['benchmarkProvider'].getMarketContext();
}); });
} }
@@ -1,9 +1,13 @@
import { ScreenerEngine } from '../../screener/ScreenerEngine.js'; import { ScreenerEngine } from '../../screener/ScreenerEngine.js';
import { noopLogger } from '../utils/logger.js'; import { noopLogger } from '../utils/logger.js';
import type { AssetResult } from '../../types.js';
// Class instances don't survive JSON.stringify — call getDisplayMetrics() on the type AnyAsset = AssetResult['asset'] & {
// server so the browser receives plain serializable objects. getDisplayMetrics: () => Record<string, unknown>;
const serializeAssets = (arr) => metrics: unknown;
};
const serializeAssets = (arr: (AssetResult & { asset: AnyAsset })[]) =>
arr.map((r) => ({ arr.map((r) => ({
...r, ...r,
asset: { asset: {
@@ -15,13 +19,9 @@ const serializeAssets = (arr) =>
}, },
})); }));
export default async function screenerRoutes(app) { export default async function screenerRoutes(app: any) {
// Shared engine — BenchmarkProvider caches for 1 hour across requests.
const engine = new ScreenerEngine({ logger: noopLogger }); const engine = new ScreenerEngine({ logger: noopLogger });
// POST /api/screen
// Body: { tickers: string[] }
// Returns: { STOCK, ETF, BOND, ERROR, marketContext }
app.post('/api/screen', { app.post('/api/screen', {
schema: { schema: {
body: { body: {
@@ -32,27 +32,24 @@ export default async function screenerRoutes(app) {
}, },
}, },
}, },
handler: async (req) => { handler: async (req: any) => {
const tickers = req.body.tickers.map((t) => t.toUpperCase()); const tickers = (req.body as { tickers: string[] }).tickers.map((t: string) =>
t.toUpperCase(),
);
const results = await engine.screenTickers(tickers); const results = await engine.screenTickers(tickers);
return { return {
...results, ...results,
STOCK: serializeAssets(results.STOCK), STOCK: serializeAssets(results.STOCK as any),
ETF: serializeAssets(results.ETF), ETF: serializeAssets(results.ETF as any),
BOND: serializeAssets(results.BOND), BOND: serializeAssets(results.BOND as any),
}; };
}, },
}); });
// GET /api/screen/catalysts
// Returns: { tickers, stories, analysis? }
// analysis is present only when ANTHROPIC_API_KEY is set.
app.get('/api/screen/catalysts', async () => { app.get('/api/screen/catalysts', async () => {
const { CatalystAnalyst } = await import('../../analyst/CatalystAnalyst.js'); const { CatalystAnalyst } = await import('../../analyst/CatalystAnalyst.js');
const catalyst = new CatalystAnalyst({ logger: noopLogger }); const catalyst = new CatalystAnalyst({ logger: noopLogger });
const { tickers, stories } = await catalyst.run(); const { tickers, stories } = await catalyst.run();
return { tickers, stories }; return { tickers, stories };
}); });
} }
@@ -1,3 +1,5 @@
import type { Logger } from '../../types.js';
/** /**
* Shared server-side logger utilities. * Shared server-side logger utilities.
* *
@@ -6,7 +8,7 @@
* Pass as { logger: noopLogger } to ScreenerEngine, BenchmarkProvider, * Pass as { logger: noopLogger } to ScreenerEngine, BenchmarkProvider,
* CatalystAnalyst, SimpleFINClient, LLMAnalyst. * CatalystAnalyst, SimpleFINClient, LLMAnalyst.
*/ */
export const noopLogger = { export const noopLogger: Logger = {
write: () => {}, write: () => {},
log: () => {}, log: () => {},
warn: () => {}, warn: () => {},
+135
View File
@@ -0,0 +1,135 @@
// ── Shared domain types ───────────────────────────────────────────────────
// Single source of truth for all cross-cutting interfaces and type aliases.
// Server classes import from here; UI imports from $lib/types.ts (mirrored subset).
// ── Primitives ────────────────────────────────────────────────────────────
export type Signal =
| '✅ Strong Buy'
| '⚡ Momentum'
| '⚠️ Speculation'
| '🔄 Neutral'
| '❌ Avoid';
export type AssetType = 'STOCK' | 'ETF' | 'BOND';
export type ScoreMode = 'inflated' | 'fundamental';
export type RateRegime = 'HIGH' | 'NORMAL' | 'LOW';
export type VolatilityRegime = 'HIGH' | 'NORMAL' | 'LOW';
// ── Market context (live benchmarks from BenchmarkProvider) ───────────────
export interface Benchmarks {
marketPE: number | null;
techPE: number | null;
reitYield: number | null;
igSpread: number | null;
}
export interface MarketContext {
sp500Price: number | null;
riskFreeRate: number | null;
vixLevel: number | null;
rateRegime: RateRegime;
volatilityRegime: VolatilityRegime;
benchmarks: Benchmarks;
}
// ── Scoring ───────────────────────────────────────────────────────────────
export interface ScoringRules {
gates: Record<string, number>;
weights: Record<string, number>;
thresholds: Record<string, number>;
}
export interface ScoreResult {
label: string;
score: number;
scoreSummary: string;
audit: {
gatesPassed: string[];
gatesFailed: string[];
riskFlags: string[];
};
}
// ── Screener results ──────────────────────────────────────────────────────
export interface AssetResult {
asset: {
ticker: string;
currentPrice: number;
type: AssetType;
displayMetrics: Record<string, string | number | null>;
};
signal: Signal;
inflated: ScoreResult;
fundamental: ScoreResult;
}
export interface ScreenerResult {
STOCK: AssetResult[];
ETF: AssetResult[];
BOND: AssetResult[];
ERROR: Array<{ ticker: string; message: string }>;
marketContext: MarketContext;
}
// ── LLM analysis ──────────────────────────────────────────────────────────
export interface AffectedIndustry {
name: string;
reason: string;
}
export interface RelatedTicker {
ticker: string;
reason: string;
}
export interface LLMAnalysis {
summary: string;
sentiment: 'BULLISH' | 'BEARISH' | 'NEUTRAL';
affectedIndustries: AffectedIndustry[];
relatedTickers: RelatedTicker[];
}
// ── Market calls ──────────────────────────────────────────────────────────
export interface TickerSnapshot {
price: number | null;
signal: Signal | null;
}
export interface MarketCall {
id: string;
title: string;
quarter: string;
date: string;
thesis: string;
tickers: string[];
snapshot: Record<string, TickerSnapshot>;
}
// ── Portfolio ─────────────────────────────────────────────────────────────
export type HoldingType = 'stock' | 'etf' | 'bond' | 'crypto';
export interface PortfolioHolding {
ticker: string;
shares: number;
costBasis: number;
source: string;
type: HoldingType;
}
// ── Logger ────────────────────────────────────────────────────────────────
export interface Logger {
write: (msg: string) => void;
log: (...args: unknown[]) => void;
warn: (...args: unknown[]) => void;
}
+14
View File
@@ -0,0 +1,14 @@
{
"compilerOptions": {
"target": "ES2022",
"module": "ES2022",
"moduleResolution": "bundler",
"strict": true,
"skipLibCheck": true,
"noEmit": true,
"allowImportingTsExtensions": true,
"resolveJsonModule": true
},
"include": ["server/**/*", "bin/**/*"],
"exclude": ["node_modules", "ui"]
}
+18
View File
@@ -11,6 +11,7 @@
"@sveltejs/adapter-auto": "^3.0.0", "@sveltejs/adapter-auto": "^3.0.0",
"@sveltejs/kit": "^2.0.0", "@sveltejs/kit": "^2.0.0",
"@sveltejs/vite-plugin-svelte": "^4.0.0", "@sveltejs/vite-plugin-svelte": "^4.0.0",
"@types/node": "^22.0.0",
"sass": "^1.100.0", "sass": "^1.100.0",
"svelte": "^5.0.0", "svelte": "^5.0.0",
"svelte-check": "^4.0.0", "svelte-check": "^4.0.0",
@@ -1248,6 +1249,16 @@
"integrity": "sha512-GhdPgy1el4/ImP05X05Uw4cw2/M93BCUmnEvWZNStlCzEKME4Fkk+YpoA5OiHNQmoS7Cafb8Xa3Pya8m1Qrzeg==", "integrity": "sha512-GhdPgy1el4/ImP05X05Uw4cw2/M93BCUmnEvWZNStlCzEKME4Fkk+YpoA5OiHNQmoS7Cafb8Xa3Pya8m1Qrzeg==",
"dev": true "dev": true
}, },
"node_modules/@types/node": {
"version": "22.19.19",
"resolved": "https://registry.npmjs.org/@types/node/-/node-22.19.19.tgz",
"integrity": "sha512-dyh/xO2Fh5bYrfWaaqGrRQQGkNdmYw6AmaAUvYeUMNTWQtvb796ikLdmTchRmOlOiIJ1TDXfWgVx1QkUlQ6Hew==",
"dev": true,
"license": "MIT",
"dependencies": {
"undici-types": "~6.21.0"
}
},
"node_modules/@types/trusted-types": { "node_modules/@types/trusted-types": {
"version": "2.0.7", "version": "2.0.7",
"resolved": "https://registry.npmjs.org/@types/trusted-types/-/trusted-types-2.0.7.tgz", "resolved": "https://registry.npmjs.org/@types/trusted-types/-/trusted-types-2.0.7.tgz",
@@ -1870,6 +1881,13 @@
"node": ">=14.17" "node": ">=14.17"
} }
}, },
"node_modules/undici-types": {
"version": "6.21.0",
"resolved": "https://registry.npmjs.org/undici-types/-/undici-types-6.21.0.tgz",
"integrity": "sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ==",
"dev": true,
"license": "MIT"
},
"node_modules/vite": { "node_modules/vite": {
"version": "6.4.3", "version": "6.4.3",
"resolved": "https://registry.npmjs.org/vite/-/vite-6.4.3.tgz", "resolved": "https://registry.npmjs.org/vite/-/vite-6.4.3.tgz",
+1
View File
@@ -8,6 +8,7 @@
"preview": "vite preview" "preview": "vite preview"
}, },
"devDependencies": { "devDependencies": {
"@types/node": "^22.0.0",
"@sveltejs/adapter-auto": "^3.0.0", "@sveltejs/adapter-auto": "^3.0.0",
"@sveltejs/kit": "^2.0.0", "@sveltejs/kit": "^2.0.0",
"@sveltejs/vite-plugin-svelte": "^4.0.0", "@sveltejs/vite-plugin-svelte": "^4.0.0",
+3 -2
View File
@@ -1,7 +1,8 @@
<script> <script lang="ts">
import Spinner from '$lib/Spinner.svelte'; import Spinner from '$lib/Spinner.svelte';
import type { SidebarState } from '$lib/types.js';
let { sidebar, onClose } = $props(); let { sidebar, onClose }: { sidebar: SidebarState; onClose: () => void } = $props();
</script> </script>
{#if sidebar.open} {#if sidebar.open}
+14 -3
View File
@@ -1,10 +1,21 @@
<script> <script lang="ts">
import { sigOrd, sorted, verdictShort, vClass } from '$lib/utils.js'; import { sigOrd, sorted } from '$lib/utils.js';
import VerdictPill from '$lib/VerdictPill.svelte'; import VerdictPill from '$lib/VerdictPill.svelte';
import SignalBadge from '$lib/SignalBadge.svelte'; import SignalBadge from '$lib/SignalBadge.svelte';
import Spinner from '$lib/Spinner.svelte'; import Spinner from '$lib/Spinner.svelte';
import type { AssetType, AssetResult } from '$lib/types.js';
let { type, rows, analyzeLoading = false, onAnalyze } = $props(); let {
type,
rows,
analyzeLoading = false,
onAnalyze,
}: {
type: AssetType;
rows: AssetResult[];
analyzeLoading?: boolean;
onAnalyze: () => void;
} = $props();
// Mode state is self-contained — each table independently tracks inflated vs fundamental // Mode state is self-contained — each table independently tracks inflated vs fundamental
let mode = $state('inflated'); let mode = $state('inflated');
+3 -2
View File
@@ -1,7 +1,8 @@
<script> <script lang="ts">
import { untrack } from 'svelte'; import { untrack } from 'svelte';
import type { MarketContext } from '$lib/types.js';
let { ctx, collapsible = false } = $props(); let { ctx, collapsible = false }: { ctx: MarketContext; collapsible?: boolean } = $props();
// Read collapsible once for initial state — untrack avoids a reactive dep on the prop // Read collapsible once for initial state — untrack avoids a reactive dep on the prop
let expanded = $state(untrack(() => !collapsible)); let expanded = $state(untrack(() => !collapsible));
+3 -2
View File
@@ -1,6 +1,7 @@
<script> <script lang="ts">
import { fmtPE } from '$lib/utils.js'; import { fmtPE } from '$lib/utils.js';
let { ctx } = $props(); import type { MarketContext } from '$lib/types.js';
let { ctx }: { ctx: MarketContext } = $props();
// Flat list of chips so the template stays declarative // Flat list of chips so the template stays declarative
const chips = $derived([ const chips = $derived([
+3 -2
View File
@@ -1,5 +1,6 @@
<script> <script lang="ts">
let { signal } = $props(); import type { Signal } from '$lib/types.js';
let { signal }: { signal: Signal | null | undefined } = $props();
const cls = () => { const cls = () => {
if (signal?.includes('Strong')) return 'strong'; if (signal?.includes('Strong')) return 'strong';
+2 -4
View File
@@ -1,7 +1,5 @@
<script> <script lang="ts">
// size: 'sm' | 'md' | 'lg' let { size = 'md', label = null }: { size?: 'sm' | 'md' | 'lg'; label?: string | null } = $props();
// label: optional text shown below (lg only)
let { size = 'md', label = null } = $props();
</script> </script>
{#if size === 'sm'} {#if size === 'sm'}
+2 -2
View File
@@ -1,6 +1,6 @@
<script> <script lang="ts">
import { verdictShort, vClass } from '$lib/utils.js'; import { verdictShort, vClass } from '$lib/utils.js';
let { label } = $props(); let { label }: { label: string | null | undefined } = $props();
</script> </script>
<span class="verdict-pill {vClass(label)}">{verdictShort(label)}</span> <span class="verdict-pill {vClass(label)}">{verdictShort(label)}</span>
+43 -12
View File
@@ -1,6 +1,19 @@
import type {
ScreenerResult,
MarketContext,
MarketCall,
CalendarEvent,
CatalystStory,
LLMAnalysis,
PortfolioHolding,
PortfolioAdvice,
} from '$lib/types.js';
const BASE = '/api'; const BASE = '/api';
export async function screenTickers(tickers) { // ── Screener ──────────────────────────────────────────────────────────────────
export async function screenTickers(tickers: string[]): Promise<ScreenerResult> {
const res = await fetch(`${BASE}/screen`, { const res = await fetch(`${BASE}/screen`, {
method: 'POST', method: 'POST',
headers: { 'Content-Type': 'application/json' }, headers: { 'Content-Type': 'application/json' },
@@ -10,13 +23,13 @@ export async function screenTickers(tickers) {
return res.json(); return res.json();
} }
export async function fetchCatalysts() { export async function fetchCatalysts(): Promise<{ tickers: string[]; stories: CatalystStory[] }> {
const res = await fetch(`${BASE}/screen/catalysts`); const res = await fetch(`${BASE}/screen/catalysts`);
if (!res.ok) throw new Error(await res.text()); if (!res.ok) throw new Error(await res.text());
return res.json(); return res.json();
} }
export async function analyzeTickers(tickers) { export async function analyzeTickers(tickers: string[]): Promise<{ analysis: LLMAnalysis | null }> {
const res = await fetch(`${BASE}/analyze`, { const res = await fetch(`${BASE}/analyze`, {
method: 'POST', method: 'POST',
headers: { 'Content-Type': 'application/json' }, headers: { 'Content-Type': 'application/json' },
@@ -26,13 +39,23 @@ export async function analyzeTickers(tickers) {
return res.json(); return res.json();
} }
export async function fetchPortfolio() { // ── Finance / Portfolio ───────────────────────────────────────────────────────
export async function fetchPortfolio(): Promise<{
advice: PortfolioAdvice[];
holdings: PortfolioHolding[];
marketContext: MarketContext | null;
netWorth: number | null;
error?: string;
}> {
const res = await fetch(`${BASE}/finance/portfolio`); const res = await fetch(`${BASE}/finance/portfolio`);
if (!res.ok) throw new Error(await res.text()); if (!res.ok) throw new Error(await res.text());
return res.json(); return res.json();
} }
export async function addHolding(holding) { export async function addHolding(
holding: Omit<PortfolioHolding, never>,
): Promise<{ holdings: PortfolioHolding[] }> {
const res = await fetch(`${BASE}/finance/holdings`, { const res = await fetch(`${BASE}/finance/holdings`, {
method: 'POST', method: 'POST',
headers: { 'Content-Type': 'application/json' }, headers: { 'Content-Type': 'application/json' },
@@ -42,7 +65,7 @@ export async function addHolding(holding) {
return res.json(); return res.json();
} }
export async function removeHolding(ticker) { export async function removeHolding(ticker: string): Promise<{ holdings: PortfolioHolding[] }> {
const res = await fetch(`${BASE}/finance/holdings/${ticker}`, { const res = await fetch(`${BASE}/finance/holdings/${ticker}`, {
method: 'DELETE', method: 'DELETE',
}); });
@@ -50,7 +73,7 @@ export async function removeHolding(ticker) {
return res.json(); return res.json();
} }
export async function fetchMarketContext() { export async function fetchMarketContext(): Promise<MarketContext> {
const res = await fetch(`${BASE}/finance/market-context`); const res = await fetch(`${BASE}/finance/market-context`);
if (!res.ok) throw new Error(await res.text()); if (!res.ok) throw new Error(await res.text());
return res.json(); return res.json();
@@ -58,19 +81,25 @@ export async function fetchMarketContext() {
// ── Market Calls ────────────────────────────────────────────────────────────── // ── Market Calls ──────────────────────────────────────────────────────────────
export async function fetchCalls() { export async function fetchCalls(): Promise<{ calls: MarketCall[] }> {
const res = await fetch(`${BASE}/calls`); const res = await fetch(`${BASE}/calls`);
if (!res.ok) throw new Error(await res.text()); if (!res.ok) throw new Error(await res.text());
return res.json(); return res.json();
} }
export async function fetchCall(id) { export async function fetchCall(id: string): Promise<MarketCall & { current: ScreenerResult }> {
const res = await fetch(`${BASE}/calls/${id}`); const res = await fetch(`${BASE}/calls/${id}`);
if (!res.ok) throw new Error(await res.text()); if (!res.ok) throw new Error(await res.text());
return res.json(); return res.json();
} }
export async function createCall(payload) { export async function createCall(payload: {
title: string;
quarter: string;
thesis: string;
tickers: string[];
date?: string;
}): Promise<MarketCall> {
const res = await fetch(`${BASE}/calls`, { const res = await fetch(`${BASE}/calls`, {
method: 'POST', method: 'POST',
headers: { 'Content-Type': 'application/json' }, headers: { 'Content-Type': 'application/json' },
@@ -80,13 +109,15 @@ export async function createCall(payload) {
return res.json(); return res.json();
} }
export async function deleteCall(id) { export async function deleteCall(id: string): Promise<{ ok: boolean }> {
const res = await fetch(`${BASE}/calls/${id}`, { method: 'DELETE' }); const res = await fetch(`${BASE}/calls/${id}`, { method: 'DELETE' });
if (!res.ok) throw new Error(await res.text()); if (!res.ok) throw new Error(await res.text());
return res.json(); return res.json();
} }
export async function fetchCallsCalendar(tickers = null) { export async function fetchCallsCalendar(
tickers: string[] | null = null,
): Promise<{ events: CalendarEvent[] }> {
const url = tickers?.length const url = tickers?.length
? `${BASE}/calls/calendar?tickers=${tickers.join(',')}` ? `${BASE}/calls/calendar?tickers=${tickers.join(',')}`
: `${BASE}/calls/calendar`; : `${BASE}/calls/calendar`;
+139
View File
@@ -0,0 +1,139 @@
// ── Shared UI types ───────────────────────────────────────────────────────
// Mirror of the server's domain types, used across Svelte components.
export type Signal =
| '✅ Strong Buy'
| '⚡ Momentum'
| '⚠️ Speculation'
| '🔄 Neutral'
| '❌ Avoid';
export type AssetType = 'STOCK' | 'ETF' | 'BOND';
export type ScoreMode = 'inflated' | 'fundamental';
export interface Benchmarks {
marketPE: number | null;
techPE: number | null;
reitYield: number | null;
igSpread: number | null;
}
export interface MarketContext {
sp500Price: number | null;
riskFreeRate: number | null;
vixLevel: number | null;
rateRegime: 'HIGH' | 'NORMAL' | 'LOW';
volatilityRegime: 'HIGH' | 'NORMAL' | 'LOW';
benchmarks: Benchmarks;
}
export interface ScoreResult {
label: string;
score: number;
scoreSummary: string;
audit: {
riskFlags?: string[];
[key: string]: unknown;
};
}
export interface AssetDisplayMetrics {
Price?: string;
Sector?: string;
'P/E'?: string;
PEG?: string;
'ROE%'?: string;
'OpMgn%'?: string;
'FCF Yld%'?: string;
'D/E'?: string;
'Exp Ratio%'?: string;
'Yield%'?: string;
AUM?: string;
'5Y Return%'?: string;
'YTM%'?: string;
Duration?: string;
Rating?: string;
[key: string]: string | null | undefined;
}
export interface AssetResult {
asset: {
ticker: string;
currentPrice: number;
type: AssetType;
displayMetrics: AssetDisplayMetrics;
};
signal: Signal;
inflated: ScoreResult;
fundamental: ScoreResult;
}
export interface ScreenerResult {
STOCK: AssetResult[];
ETF: AssetResult[];
BOND: AssetResult[];
ERROR: Array<{ ticker: string; message: string }>;
marketContext: MarketContext;
}
export interface LLMAnalysis {
summary: string;
sentiment: 'BULLISH' | 'BEARISH' | 'NEUTRAL';
affectedIndustries: Array<{ name: string; reason: string }>;
relatedTickers: Array<{ ticker: string; reason: string }>;
}
export interface SidebarState {
open: boolean;
loading: boolean;
analysis: LLMAnalysis | null;
type: AssetType | null;
error: string | null;
}
export interface PortfolioHolding {
ticker: string;
shares: number;
costBasis: number;
source: string;
type: 'stock' | 'etf' | 'bond' | 'crypto';
}
export interface TickerSnapshot {
price: number | null;
signal: Signal | null;
}
export interface MarketCall {
id: string;
title: string;
quarter: string;
date: string;
thesis: string;
tickers: string[];
snapshot: Record<string, TickerSnapshot>;
}
export interface CalendarEvent {
ticker: string;
type: 'earnings' | 'dividend';
date: string;
[key: string]: unknown;
}
export interface CatalystStory {
title: string;
link: string;
publisher: string;
publishedAt: string;
relatedTickers: string[];
}
export interface PortfolioAdvice {
ticker: string;
action: 'hold' | 'sell' | 'add' | 'watch';
reason: string;
signal: Signal | null;
currentPrice: number | null;
gainLossPct: number | null;
}
+3 -2
View File
@@ -1,8 +1,9 @@
<script> <script lang="ts">
import { page, navigating } from '$app/stores'; import { page, navigating } from '$app/stores';
import '../styles/app.scss'; import '../styles/app.scss';
import Spinner from '$lib/Spinner.svelte'; import Spinner from '$lib/Spinner.svelte';
let { children } = $props(); import type { Snippet } from 'svelte';
let { children }: { children: Snippet } = $props();
// Resolve active path optimistically — use the destination during navigation // Resolve active path optimistically — use the destination during navigation
// so the nav link highlights immediately on click, not after load completes. // so the nav link highlights immediately on click, not after load completes.
+20 -20
View File
@@ -1,4 +1,4 @@
<script> <script lang="ts">
import { screenTickers, analyzeTickers } from '$lib/api.js'; import { screenTickers, analyzeTickers } from '$lib/api.js';
import { sigOrd, sorted, verdictShort, vClass } from '$lib/utils.js'; import { sigOrd, sorted, verdictShort, vClass } from '$lib/utils.js';
import SignalBadge from '$lib/SignalBadge.svelte'; import SignalBadge from '$lib/SignalBadge.svelte';
@@ -7,23 +7,24 @@
import MarketContextStrip from '$lib/MarketContextStrip.svelte'; import MarketContextStrip from '$lib/MarketContextStrip.svelte';
import AssetTable from '$lib/AssetTable.svelte'; import AssetTable from '$lib/AssetTable.svelte';
import AnalysisSidebar from '$lib/AnalysisSidebar.svelte'; import AnalysisSidebar from '$lib/AnalysisSidebar.svelte';
import type { ScreenerResult, AssetType, SidebarState } from '$lib/types.js';
// Initial data comes from +page.js load (replaces _booted / $effect hack) interface PageData { results: ScreenerResult; catalystInput: string }
let { data } = $props(); let { data }: { data: PageData } = $props();
let input = $state(data.catalystInput); let input: string = $state(data.catalystInput);
let results = $state(data.results); let results: ScreenerResult = $state(data.results);
let screenedAt = $state(new Date().toLocaleTimeString()); let screenedAt: string = $state(new Date().toLocaleTimeString());
let loading = $state(false); let loading: boolean = $state(false);
let loadingCats = $state(false); let loadingCats: boolean = $state(false);
let error = $state(null); let error: string | null = $state(null);
let searchOpen = $state(false); let searchOpen: boolean = $state(false);
// ── LLM Analysis sidebar ──────────────────────────────────────────────── // ── LLM Analysis sidebar ────────────────────────────────────────────────
let sidebar = $state({ open: false, loading: false, analysis: null, type: null, error: null }); let sidebar: SidebarState = $state({ open: false, loading: false, analysis: null, type: null, error: null });
async function runTabAnalysis(type) { async function runTabAnalysis(type: AssetType): Promise<void> {
const tickers = (results?.[type] ?? []).map(r => r.asset.ticker); const tickers = (results?.[type] ?? []).map((r) => r.asset.ticker);
if (!tickers.length) return; if (!tickers.length) return;
sidebar = { open: true, loading: true, analysis: null, type, error: null }; sidebar = { open: true, loading: true, analysis: null, type, error: null };
try { try {
@@ -32,12 +33,12 @@
sidebar = { open: true, loading: false, analysis: res.analysis, type, sidebar = { open: true, loading: false, analysis: res.analysis, type,
error: res.analysis ? null : (reason ?? 'Analysis failed — check server logs for details.') }; error: res.analysis ? null : (reason ?? 'Analysis failed — check server logs for details.') };
} catch (e) { } catch (e) {
sidebar = { open: true, loading: false, analysis: null, type, error: e.message }; sidebar = { open: true, loading: false, analysis: null, type, error: (e as Error).message };
} }
} }
// ── Manual ticker search ───────────────────────────────────────────────── // ── Manual ticker search ─────────────────────────────────────────────────
async function screen() { async function screen(): Promise<void> {
error = null; error = null;
loading = true; loading = true;
try { try {
@@ -45,15 +46,14 @@
results = await screenTickers(tickers); results = await screenTickers(tickers);
screenedAt = new Date().toLocaleTimeString(); screenedAt = new Date().toLocaleTimeString();
} catch (e) { } catch (e) {
error = e.message; error = (e as Error).message;
} finally { } finally {
loading = false; loading = false;
} }
} }
// ── Re-fetch today's catalysts ─────────────────────────────────────────── // ── Re-fetch today's catalysts ───────────────────────────────────────────
// Splits fetch (news) from screen (Yahoo) — each step has its own loading flag. async function reloadCatalysts(): Promise<void> {
async function reloadCatalysts() {
const { fetchCatalysts } = await import('$lib/api.js'); const { fetchCatalysts } = await import('$lib/api.js');
loadingCats = true; loadingCats = true;
error = null; error = null;
@@ -64,7 +64,7 @@
results = await screenTickers(cat.tickers); results = await screenTickers(cat.tickers);
screenedAt = new Date().toLocaleTimeString(); screenedAt = new Date().toLocaleTimeString();
} catch (e) { } catch (e) {
error = e.message; error = (e as Error).message;
} finally { } finally {
loading = false; loading = false;
loadingCats = false; loadingCats = false;
@@ -157,7 +157,7 @@
</section> </section>
<!-- ── Per-type detail tables ────────────────────────────────────── --> <!-- ── Per-type detail tables ────────────────────────────────────── -->
{#each ['STOCK', 'ETF', 'BOND'] as type} {#each (['STOCK', 'ETF', 'BOND'] as const) as type}
{#if results[type]?.length} {#if results[type]?.length}
<AssetTable <AssetTable
{type} {type}
@@ -1,13 +1,14 @@
import { fetchCatalysts, screenTickers } from '$lib/api.js'; import { fetchCatalysts, screenTickers } from '$lib/api.js';
import type { PageLoad } from './$types.js';
// Client-only — the API lives at localhost:3000, not accessible during SSR // Client-only — the API lives at localhost:3000, not accessible during SSR
export const ssr = false; export const ssr = false;
export async function load() { export const load: PageLoad = async () => {
const cat = await fetchCatalysts(); const cat = await fetchCatalysts();
const results = await screenTickers(cat.tickers); const results = await screenTickers(cat.tickers);
return { return {
results, results,
catalystInput: cat.tickers.join(', '), catalystInput: cat.tickers.join(', '),
}; };
} };
-8
View File
@@ -1,8 +0,0 @@
export async function load({ fetch }) {
const [callsRes, calRes] = await Promise.all([fetch('/api/calls'), fetch('/api/calls/calendar')]);
const { calls } = callsRes.ok ? await callsRes.json() : { calls: [] };
const { events } = calRes.ok ? await calRes.json() : { events: [] };
return { calls, events };
}
+30 -13
View File
@@ -1,15 +1,31 @@
<script> <script lang="ts">
import { createCall, deleteCall } from '$lib/api.js'; import { createCall, deleteCall } from '$lib/api.js';
import SignalBadge from '$lib/SignalBadge.svelte'; import SignalBadge from '$lib/SignalBadge.svelte';
import Spinner from '$lib/Spinner.svelte'; import Spinner from '$lib/Spinner.svelte';
import { invalidateAll } from '$app/navigation'; import { invalidateAll } from '$app/navigation';
let { data } = $props(); interface MarketCall {
id: string;
title: string;
quarter: string;
date: string;
thesis: string;
tickers: string[];
snapshot: Record<string, { price: number | null; signal: string | null }>;
}
interface PageData {
calls: MarketCall[];
events: unknown[];
error?: string;
}
let { data }: { data: PageData } = $props();
// New call form state // New call form state
let showForm = $state(false); let showForm: boolean = $state(false);
let saving = $state(false); let saving: boolean = $state(false);
let formError = $state(null); let formError: string|null = $state(null);
let form = $state({ let form = $state({
title: '', title: '',
quarter: currentQuarter(), quarter: currentQuarter(),
@@ -43,28 +59,29 @@
form = { title: '', quarter: currentQuarter(), date: today(), thesis: '', tickers: '' }; form = { title: '', quarter: currentQuarter(), date: today(), thesis: '', tickers: '' };
await invalidateAll(); // re-run load() to refresh the list await invalidateAll(); // re-run load() to refresh the list
} catch (e) { } catch (e) {
formError = e.message; formError = (e as Error).message;
} finally { } finally {
saving = false; saving = false;
} }
} }
async function remove(id) { async function remove(id: string): Promise<void> {
if (!confirm('Delete this market call?')) return; if (!confirm('Delete this market call?')) return;
await deleteCall(id); await deleteCall(id);
await invalidateAll(); await invalidateAll();
} }
const signalColor = s => { const signalColor = (s: string | null | undefined): string => {
if (s?.includes('Strong')) return '#4ade80'; if (s?.includes('Strong')) return '#4ade80';
if (s?.includes('Momentum')) return '#60a5fa'; if (s?.includes('Momentum')) return '#60a5fa';
if (s?.includes('Neutral')) return '#94a3b8'; if (s?.includes('Neutral')) return '#94a3b8';
if (s?.includes('Speculation')) return '#fb923c'; if (s?.includes('Speculation')) return '#fb923c';
return '#f87171'; return '#f87171';
}; };
const eventIcon = type => ({ earnings: '📊', exdividend: '💰', dividend: '💵' })[type] ?? '📅'; type EventType = 'earnings' | 'exdividend' | 'dividend';
const eventColor = type => ({ earnings: '#60a5fa', exdividend: '#facc15', dividend: '#4ade80' })[type] ?? '#94a3b8'; const eventIcon = (type: EventType): string => ({ earnings: '📊', exdividend: '💰', dividend: '💵' })[type] ?? '📅';
const eventColor = (type: EventType): string => ({ earnings: '#60a5fa', exdividend: '#facc15', dividend: '#4ade80' })[type] ?? '#94a3b8';
const upcoming = $derived((data.events ?? []).filter(e => !e.isPast).slice(0, 20)); const upcoming = $derived((data.events ?? []).filter(e => !e.isPast).slice(0, 20));
const past = $derived((data.events ?? []).filter(e => e.isPast).slice(0, 10)); const past = $derived((data.events ?? []).filter(e => e.isPast).slice(0, 10));
+11
View File
@@ -0,0 +1,11 @@
import type { PageLoad } from './$types.js';
import type { MarketCall, CalendarEvent } from '$lib/types.js';
export const load: PageLoad = async ({ fetch }) => {
const [callsRes, calRes] = await Promise.all([fetch('/api/calls'), fetch('/api/calls/calendar')]);
const { calls }: { calls: MarketCall[] } = callsRes.ok ? await callsRes.json() : { calls: [] };
const { events }: { events: CalendarEvent[] } = calRes.ok ? await calRes.json() : { events: [] };
return { calls, events };
};
@@ -1,5 +1,7 @@
export async function load({ fetch, params }) { import type { PageLoad } from './$types.js';
export const load: PageLoad = async ({ fetch, params }) => {
const res = await fetch(`/api/calls/${params.id}`); const res = await fetch(`/api/calls/${params.id}`);
if (!res.ok) return { error: await res.text() }; if (!res.ok) return { error: await res.text() };
return res.json(); return res.json();
} };
+41 -19
View File
@@ -1,28 +1,50 @@
<script> <script lang="ts">
import SignalBadge from '$lib/SignalBadge.svelte'; import SignalBadge from '$lib/SignalBadge.svelte';
import MarketContext from '$lib/MarketContext.svelte'; import MarketContext from '$lib/MarketContext.svelte';
import Spinner from '$lib/Spinner.svelte'; import Spinner from '$lib/Spinner.svelte';
import { addHolding, removeHolding } from '$lib/api.js'; import { addHolding, removeHolding } from '$lib/api.js';
import { sigOrd, fmt, fmtShort, glClass, advClass } from '$lib/utils.js'; import { sigOrd, fmt, fmtShort, glClass, advClass } from '$lib/utils.js';
import type { Signal, MarketContext as MarketContextType, PortfolioHolding } from '$lib/types.js';
interface AdviceRow {
ticker: string;
type: string;
source: string;
shares: number;
costBasis: number;
currentPrice: string | null;
marketValue: string | null;
gainLossPct: string | null;
signal: Signal | null;
advice: string;
reason: string;
}
interface PortfolioData {
advice: AdviceRow[];
marketContext: MarketContextType | null;
personalFinance: Record<string, unknown> | null;
}
let { data: _data } = $props(); // unused — we load client-side let { data: _data } = $props(); // unused — we load client-side
let data = $state(null); let data: PortfolioData | null = $state(null);
let loading = $state(true); let loading: boolean = $state(true);
let refreshing = $state(false); // background refresh — keeps page visible let refreshing: boolean = $state(false);
let loadError = $state(null); let loadError: string | null = $state(null);
// ── Add holding form (new holdings only) ──────────────────────────────────── // ── Add holding form (new holdings only) ────────────────────────────────────
let formOpen = $state(false); let formOpen: boolean = $state(false);
let saving = $state(false); let saving: boolean = $state(false);
let formError = $state(null); let formError: string|null = $state(null);
let form = $state({ ticker: '', shares: '', costBasis: '', type: 'stock', source: 'Robinhood' }); let form = $state({ ticker: '', shares: '', costBasis: '', type: 'stock', source: 'Robinhood' });
// ── Inline row editing ─────────────────────────────────────────────────────── // ── Inline row editing ───────────────────────────────────────────────────────
let inlineEdit = $state(null); // { ticker, shares, costBasis, type, source } or null interface InlineEdit { ticker: string; shares: string; costBasis: string; type: string; source: string }
let inlineSaving = $state(false); let inlineEdit: InlineEdit | null = $state(null);
let inlineSaving: boolean = $state(false);
function startInlineEdit(a) { function startInlineEdit(a: AdviceRow) {
inlineEdit = { inlineEdit = {
ticker: a.ticker, ticker: a.ticker,
shares: String(a.shares), shares: String(a.shares),
@@ -52,7 +74,7 @@
advice: data.advice.map(a => advice: data.advice.map(a =>
a.ticker === updated.ticker a.ticker === updated.ticker
? { ...a, shares: updated.shares, costBasis: updated.costBasis, type: updated.type, source: updated.source, ? { ...a, shares: updated.shares, costBasis: updated.costBasis, type: updated.type, source: updated.source,
marketValue: updated.shares * (parseFloat(a.currentPrice) || 0), marketValue: String(updated.shares * (parseFloat(a.currentPrice ?? '0') || 0)),
gainLossPct: a.currentPrice ? (((parseFloat(a.currentPrice) - updated.costBasis) / updated.costBasis) * 100).toFixed(1) : null } gainLossPct: a.currentPrice ? (((parseFloat(a.currentPrice) - updated.costBasis) / updated.costBasis) * 100).toFixed(1) : null }
: a : a
), ),
@@ -61,7 +83,7 @@
inlineEdit = null; inlineEdit = null;
fetchPortfolioData(false); // background: update prices + signals fetchPortfolioData(false); // background: update prices + signals
} catch (e) { } catch (e) {
loadError = e.message; loadError = (e as Error).message;
} finally { } finally {
inlineSaving = false; inlineSaving = false;
} }
@@ -101,13 +123,13 @@
formOpen = false; formOpen = false;
fetchPortfolioData(false); // background: get real price + signal fetchPortfolioData(false); // background: get real price + signal
} catch (e) { } catch (e) {
formError = e.message; formError = (e as Error).message;
} finally { } finally {
saving = false; saving = false;
} }
} }
async function deleteHolding(ticker) { async function deleteHolding(ticker: string): Promise<void> {
if (!confirm(`Remove ${ticker} from your portfolio?`)) return; if (!confirm(`Remove ${ticker} from your portfolio?`)) return;
// Optimistic remove — drop the row immediately // Optimistic remove — drop the row immediately
if (data?.advice) { if (data?.advice) {
@@ -117,7 +139,7 @@
await removeHolding(ticker); await removeHolding(ticker);
fetchPortfolioData(false); // background: recalculate totals fetchPortfolioData(false); // background: recalculate totals
} catch (e) { } catch (e) {
loadError = e.message; loadError = (e as Error).message;
} }
} }
@@ -128,7 +150,7 @@
fetch('/api/finance/portfolio') fetch('/api/finance/portfolio')
.then(res => res.ok ? res.json() : res.text().then(t => { throw new Error(t); })) .then(res => res.ok ? res.json() : res.text().then(t => { throw new Error(t); }))
.then(json => { data = json; }) .then(json => { data = json; })
.catch(e => { loadError = e.message; }) .catch(e => { loadError = (e as Error).message; })
.finally(() => { loading = false; refreshing = false; }); .finally(() => { loading = false; refreshing = false; });
} }
@@ -143,7 +165,7 @@
let sortCol = $state('ticker'); let sortCol = $state('ticker');
let sortDir = $state(1); // 1 = asc, -1 = desc let sortDir = $state(1); // 1 = asc, -1 = desc
function toggleSort(col) { function toggleSort(col: string): void {
if (sortCol === col) sortDir = sortDir === 1 ? -1 : 1; if (sortCol === col) sortDir = sortDir === 1 ? -1 : 1;
else { sortCol = col; sortDir = 1; } else { sortCol = col; sortDir = 1; }
} }
@@ -169,7 +191,7 @@
}); });
}); });
const sortIcon = (col) => sortCol !== col ? '⇅' : sortDir === 1 ? '↑' : '↓'; const sortIcon = (col: string): string => sortCol !== col ? '⇅' : sortDir === 1 ? '↑' : '↓';
const totalValue = $derived(data?.advice?.reduce((s, a) => s + (parseFloat(a.marketValue) || 0), 0) ?? 0); const totalValue = $derived(data?.advice?.reduce((s, a) => s + (parseFloat(a.marketValue) || 0), 0) ?? 0);
@@ -1,8 +1,10 @@
import type { PageLoad } from './$types.js';
// Disable SSR — data is fetched client-side in the component so navigation // Disable SSR — data is fetched client-side in the component so navigation
// is instant instead of blocking until all Yahoo Finance calls resolve. // is instant instead of blocking until all Yahoo Finance calls resolve.
export const ssr = false; export const ssr = false;
export const prerender = false; export const prerender = false;
export function load() { export const load: PageLoad = () => {
return {}; return {};
} };
+9 -2
View File
@@ -1,10 +1,17 @@
<script> <script lang="ts">
import MarketContext from '$lib/MarketContext.svelte'; import MarketContext from '$lib/MarketContext.svelte';
import SignalBadge from '$lib/SignalBadge.svelte'; import SignalBadge from '$lib/SignalBadge.svelte';
import VerdictPill from '$lib/VerdictPill.svelte'; import VerdictPill from '$lib/VerdictPill.svelte';
import { sorted } from '$lib/utils.js'; import { sorted } from '$lib/utils.js';
import type { AssetResult, MarketContext as MarketContextType } from '$lib/types.js';
let { data } = $props(); interface PageData {
ETF: AssetResult[];
BOND: AssetResult[];
marketContext: MarketContextType | null;
error?: string;
}
let { data }: { data: PageData } = $props();
const SIGNAL_STRONG = '✅ Strong Buy'; const SIGNAL_STRONG = '✅ Strong Buy';
@@ -1,6 +1,9 @@
import type { PageLoad } from './$types.js';
import type { AssetResult, MarketContext } from '$lib/types.js';
// Curated watchlist of well-established, low-cost ETFs and investment-grade bond funds. // Curated watchlist of well-established, low-cost ETFs and investment-grade bond funds.
// Screened for Strong Buy signal under both Market-Adjusted and Fundamental lenses. // Screened for Strong Buy signal under both Market-Adjusted and Fundamental lenses.
const SAFE_WATCHLIST = [ const SAFE_WATCHLIST: string[] = [
// ── Broad Market ETFs // ── Broad Market ETFs
'VOO', // S&P 500 — Vanguard (0.03%) 'VOO', // S&P 500 — Vanguard (0.03%)
'IVV', // S&P 500 — iShares (0.03%) 'IVV', // S&P 500 — iShares (0.03%)
@@ -40,21 +43,28 @@ const SAFE_WATCHLIST = [
'TIP', // TIPS — iShares 'TIP', // TIPS — iShares
]; ];
export async function load({ fetch }) { export const load: PageLoad = async ({ fetch }) => {
const res = await fetch('/api/screen', { const res = await fetch('/api/screen', {
method: 'POST', method: 'POST',
headers: { 'Content-Type': 'application/json' }, headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ tickers: SAFE_WATCHLIST }), body: JSON.stringify({ tickers: SAFE_WATCHLIST }),
}); });
if (!res.ok) if (!res.ok) {
return { ETF: [], BOND: [], ERROR: [], marketContext: null, error: await res.text() }; return {
ETF: [] as AssetResult[],
BOND: [] as AssetResult[],
ERROR: [] as Array<{ ticker: string; message: string }>,
marketContext: null as MarketContext | null,
error: await res.text(),
};
}
const data = await res.json(); const data = await res.json();
return { return {
ETF: data.ETF ?? [], ETF: (data.ETF ?? []) as AssetResult[],
BOND: data.BOND ?? [], BOND: (data.BOND ?? []) as AssetResult[],
ERROR: data.ERROR ?? [], ERROR: (data.ERROR ?? []) as Array<{ ticker: string; message: string }>,
marketContext: data.marketContext ?? null, marketContext: (data.marketContext ?? null) as MarketContext | null,
}; };
} };
+2 -1
View File
@@ -3,6 +3,7 @@
"compilerOptions": { "compilerOptions": {
"strict": true, "strict": true,
"allowJs": true, "allowJs": true,
"checkJs": false "checkJs": false,
"types": ["node"]
} }
} }