From 69d13c3dbee43c51c01489910fe99bcaa809812b Mon Sep 17 00:00:00 2001 From: Kazuma Date: Thu, 4 Jun 2026 22:16:48 -0400 Subject: [PATCH] phase-6: typescript introduction --- .env.example | 7 +- bin/{finance.js => finance.ts} | 51 +- bin/{screen.js => screen.ts} | 23 +- bin/{server.js => server.ts} | 0 package-lock.json | 554 +++++++++++++++++- package.json | 24 +- portfolio.json | 1 - ...{CatalystAnalyst.js => CatalystAnalyst.ts} | 37 +- .../analyst/{LLMAnalyst.js => LLMAnalyst.ts} | 31 +- server/calls/MarketCallStore.js | 80 --- server/calls/MarketCallStore.ts | 76 +++ .../{ScoringConfig.js => ScoringConfig.ts} | 97 ++- server/config/{constants.js => constants.ts} | 40 +- ...Analyzer.js => PersonalFinanceAnalyzer.ts} | 50 +- ...ortfolioAdvisor.js => PortfolioAdvisor.ts} | 116 ++-- ...{SimpleFINClient.js => SimpleFINClient.ts} | 122 ++-- server/market/BenchmarkProvider.js | 73 --- server/market/BenchmarkProvider.ts | 87 +++ .../{MarketRegime.js => MarketRegime.ts} | 35 +- server/market/YahooClient.js | 40 -- server/market/YahooClient.ts | 42 ++ ...{FinanceReporter.js => FinanceReporter.ts} | 14 +- .../{HtmlReporter.js => HtmlReporter.ts} | 16 +- server/screener/{Chunker.js => Chunker.ts} | 2 +- server/screener/DataMapper.js | 153 ----- server/screener/DataMapper.ts | 137 +++++ server/screener/RuleMerger.js | 33 -- server/screener/RuleMerger.ts | 49 ++ .../{ScreenerEngine.js => ScreenerEngine.ts} | 115 ++-- server/screener/assets/Asset.js | 19 - server/screener/assets/Asset.ts | 32 + server/screener/assets/{Bond.js => Bond.ts} | 26 +- server/screener/assets/Etf.js | 26 - server/screener/assets/Etf.ts | 47 ++ server/screener/assets/{Stock.js => Stock.ts} | 81 ++- .../scorers/{BondScorer.js => BondScorer.ts} | 36 +- .../scorers/{EtfScorer.js => EtfScorer.ts} | 30 +- .../{StockScorer.js => StockScorer.ts} | 78 ++- server/server/{app.js => app.ts} | 37 +- server/server/routes/{calls.js => calls.ts} | 108 ++-- .../server/routes/{finance.js => finance.ts} | 62 +- .../routes/{screener.js => screener.ts} | 33 +- server/server/utils/{logger.js => logger.ts} | 4 +- server/types.ts | 135 +++++ tsconfig.json | 14 + ui/package-lock.json | 18 + ui/package.json | 1 + ui/src/lib/AnalysisSidebar.svelte | 5 +- ui/src/lib/AssetTable.svelte | 17 +- ui/src/lib/MarketContext.svelte | 5 +- ui/src/lib/MarketContextStrip.svelte | 5 +- ui/src/lib/SignalBadge.svelte | 5 +- ui/src/lib/Spinner.svelte | 6 +- ui/src/lib/VerdictPill.svelte | 4 +- ui/src/lib/{api.js => api.ts} | 55 +- ui/src/lib/types.ts | 139 +++++ ui/src/routes/+layout.svelte | 5 +- ui/src/routes/{+layout.js => +layout.ts} | 0 ui/src/routes/+page.svelte | 40 +- ui/src/routes/{+page.js => +page.ts} | 5 +- ui/src/routes/calls/+page.js | 8 - ui/src/routes/calls/+page.svelte | 43 +- ui/src/routes/calls/+page.ts | 11 + .../routes/calls/[id]/{+page.js => +page.ts} | 6 +- ui/src/routes/portfolio/+page.svelte | 60 +- .../routes/portfolio/{+page.js => +page.ts} | 6 +- ui/src/routes/safe-buys/+page.svelte | 11 +- .../routes/safe-buys/{+page.js => +page.ts} | 28 +- ui/tsconfig.json | 3 +- 69 files changed, 2323 insertions(+), 1036 deletions(-) rename bin/{finance.js => finance.ts} (67%) rename bin/{screen.js => screen.ts} (81%) rename bin/{server.js => server.ts} (100%) delete mode 100644 portfolio.json rename server/analyst/{CatalystAnalyst.js => CatalystAnalyst.ts} (58%) rename server/analyst/{LLMAnalyst.js => LLMAnalyst.ts} (71%) delete mode 100644 server/calls/MarketCallStore.js create mode 100644 server/calls/MarketCallStore.ts rename server/config/{ScoringConfig.js => ScoringConfig.ts} (52%) rename server/config/{constants.js => constants.ts} (51%) rename server/finance/{PersonalFinanceAnalyzer.js => PersonalFinanceAnalyzer.ts} (71%) rename server/finance/{PortfolioAdvisor.js => PortfolioAdvisor.ts} (58%) rename server/finance/clients/{SimpleFINClient.js => SimpleFINClient.ts} (63%) delete mode 100644 server/market/BenchmarkProvider.js create mode 100644 server/market/BenchmarkProvider.ts rename server/market/{MarketRegime.js => MarketRegime.ts} (65%) delete mode 100644 server/market/YahooClient.js create mode 100644 server/market/YahooClient.ts rename server/reporters/{FinanceReporter.js => FinanceReporter.ts} (97%) rename server/reporters/{HtmlReporter.js => HtmlReporter.ts} (97%) rename server/screener/{Chunker.js => Chunker.ts} (63%) delete mode 100644 server/screener/DataMapper.js create mode 100644 server/screener/DataMapper.ts delete mode 100644 server/screener/RuleMerger.js create mode 100644 server/screener/RuleMerger.ts rename server/screener/{ScreenerEngine.js => ScreenerEngine.ts} (53%) delete mode 100644 server/screener/assets/Asset.js create mode 100644 server/screener/assets/Asset.ts rename server/screener/assets/{Bond.js => Bond.ts} (55%) delete mode 100644 server/screener/assets/Etf.js create mode 100644 server/screener/assets/Etf.ts rename server/screener/assets/{Stock.js => Stock.ts} (68%) rename server/screener/scorers/{BondScorer.js => BondScorer.ts} (56%) rename server/screener/scorers/{EtfScorer.js => EtfScorer.ts} (57%) rename server/screener/scorers/{StockScorer.js => StockScorer.ts} (65%) rename server/server/{app.js => app.ts} (64%) rename server/server/routes/{calls.js => calls.ts} (62%) rename server/server/routes/{finance.js => finance.ts} (70%) rename server/server/routes/{screener.js => screener.ts} (57%) rename server/server/utils/{logger.js => logger.ts} (81%) create mode 100644 server/types.ts create mode 100644 tsconfig.json rename ui/src/lib/{api.js => api.ts} (55%) create mode 100644 ui/src/lib/types.ts rename ui/src/routes/{+layout.js => +layout.ts} (100%) rename ui/src/routes/{+page.js => +page.ts} (78%) delete mode 100644 ui/src/routes/calls/+page.js create mode 100644 ui/src/routes/calls/+page.ts rename ui/src/routes/calls/[id]/{+page.js => +page.ts} (53%) rename ui/src/routes/portfolio/{+page.js => +page.ts} (71%) rename ui/src/routes/safe-buys/{+page.js => +page.ts} (70%) diff --git a/.env.example b/.env.example index 53bc189..527ece8 100644 --- a/.env.example +++ b/.env.example @@ -2,8 +2,11 @@ # # FIRST RUN: paste your Setup Token from https://beta-bridge.simplefin.org # (Settings → Connect an app → copy the token) -# -SIMPLEFIN_SETUP_TOKEN=aHR0cHM6Ly9iZXRhLWJyaWRnZS5zaW1wbGVmaW4ub3Jn... +## Get your key at: https://console.anthropic.com +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. # Remove SIMPLEFIN_SETUP_TOKEN once this appears. diff --git a/bin/finance.js b/bin/finance.ts similarity index 67% rename from bin/finance.js rename to bin/finance.ts index 5695647..47d0c42 100644 --- a/bin/finance.js +++ b/bin/finance.ts @@ -1,14 +1,5 @@ /** - * bin/finance.js — 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 + * bin/finance.ts — Personal Finance CLI */ import 'dotenv/config'; @@ -18,17 +9,19 @@ import { PersonalFinanceAnalyzer } from '../server/finance/PersonalFinanceAnalyz import { PortfolioAdvisor } from '../server/finance/PortfolioAdvisor.js'; import { ScreenerEngine } from '../server/screener/ScreenerEngine.js'; import { FinanceReporter } from '../server/reporters/FinanceReporter.js'; +import type { PortfolioHolding } from '../server/types.js'; const PORTFOLIO_PATH = './portfolio.json'; -async function main() { - // ── 1. Load portfolio - if (!existsSync(PORTFOLIO_PATH)) { +async function main(): Promise { + if (!existsSync(PORTFOLIO_PATH)) throw new Error('portfolio.json not found — edit it with your holdings and re-run.'); - } - const { holdings } = JSON.parse(readFileSync(PORTFOLIO_PATH, 'utf8')); - const byType = holdings.reduce((acc, h) => { + const { holdings } = JSON.parse(readFileSync(PORTFOLIO_PATH, 'utf8')) as { + holdings: PortfolioHolding[]; + }; + + const byType = holdings.reduce>((acc, h) => { const t = h.type ?? 'stock'; acc[t] = (acc[t] ?? 0) + 1; return acc; @@ -39,7 +32,7 @@ async function main() { .join(', ')}\n`, ); - // ── 2. SimpleFIN accounts (optional) + // ── SimpleFIN accounts (optional) let personalFinance = null; if (process.env.SIMPLEFIN_ACCESS_URL || process.env.SIMPLEFIN_SETUP_TOKEN) { try { @@ -50,35 +43,43 @@ async function main() { personalFinance = new PersonalFinanceAnalyzer().analyse(accounts); process.stdout.write(` ${accounts.length} accounts loaded\n`); } catch (err) { - process.stdout.write(` skipped — ${err.message}\n`); + process.stdout.write(` skipped — ${(err as Error).message}\n`); } } else { console.log('ℹ Add SIMPLEFIN_SETUP_TOKEN to .env for account balances & spending data\n'); } - // ── 3. Screen stocks & ETFs + // ── Screen stocks & ETFs const screenableTickers = holdings .filter((h) => (h.type ?? 'stock') !== 'crypto') .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) { 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'); } - // ── 4. Portfolio advice + crypto prices process.stdout.write('💡 Generating portfolio advice...'); const advice = await new PortfolioAdvisor().advise(holdings, results); process.stdout.write(' done\n'); - // ── 5. Report - const reportPath = new FinanceReporter().generate(advice, personalFinance, results.marketContext); + const reportPath = new FinanceReporter().generate( + advice as any, + personalFinance, + results.marketContext, + ); console.log(`\n✅ Finance report: ${reportPath}\n`); } main().catch((err) => { - console.error('Failed:', err.message); + console.error('Failed:', (err as Error).message); process.exit(1); }); diff --git a/bin/screen.js b/bin/screen.ts similarity index 81% rename from bin/screen.js rename to bin/screen.ts index db011af..c5a6257 100644 --- a/bin/screen.js +++ b/bin/screen.ts @@ -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, * 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 { HtmlReporter } from '../server/reporters/HtmlReporter.js'; -const DEFAULT_WATCHLIST = [ - // Stocks +const DEFAULT_WATCHLIST: string[] = [ 'PLTR', 'AAPL', 'MSFT', 'TSLA', 'O', - // ETFs 'VOO', 'QQQ', - // Bonds 'BND', 'LQD', 'TLT', @@ -37,9 +34,9 @@ const DEFAULT_WATCHLIST = [ 'MUB', ]; -async function main() { +async function main(): Promise { const args = process.argv.slice(2); - let tickers = []; + let tickers: string[] = []; if (args.length > 0 && args[0] !== 'watch') { tickers = args.map((t) => t.toUpperCase()); @@ -50,7 +47,6 @@ async function main() { } else { try { const { tickers: newsTickers, stories } = await new CatalystAnalyst().run(); - if (newsTickers.length === 0) { console.warn("⚠ No tickers in today's news — using default watchlist\n"); tickers = DEFAULT_WATCHLIST; @@ -64,7 +60,9 @@ async function main() { console.log(`\n📋 Tickers: ${tickers.join(', ')}\n`); } } 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; } } @@ -72,10 +70,13 @@ async function main() { try { const { STOCK, ETF, BOND, ERROR, marketContext } = 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`); } catch (err) { - console.error('Screener failed:', err.message); + console.error('Screener failed:', (err as Error).message); process.exit(1); } } diff --git a/bin/server.js b/bin/server.ts similarity index 100% rename from bin/server.js rename to bin/server.ts diff --git a/package-lock.json b/package-lock.json index 275551a..503409f 100644 --- a/package-lock.json +++ b/package-lock.json @@ -15,10 +15,13 @@ "yahoo-finance2": "^3.15.2" }, "devDependencies": { + "@types/node": "^22.0.0", "concurrently": "^10.0.3", "husky": "^9.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": { @@ -65,6 +68,448 @@ "integrity": "sha512-4nMhecpGlPi0cSzT67L+Tm+GOJqvuk8gqHBziqcUQOarnuIax1z96/gJHCSIz2Z0zhxE6Rzwb3IZXPtFh51j+w==", "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": { "version": "4.0.5", "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", "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": { "version": "2.0.1", "resolved": "https://registry.npmjs.org/abstract-logging/-/abstract-logging-2.0.1.tgz", @@ -761,6 +1216,48 @@ "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": { "version": "3.2.0", "resolved": "https://registry.npmjs.org/escalade/-/escalade-3.2.0.tgz", @@ -1139,6 +1636,21 @@ "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": { "version": "1.1.2", "resolved": "https://registry.npmjs.org/function-bind/-/function-bind-1.1.2.tgz", @@ -2624,6 +3136,25 @@ "integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==", "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": { "version": "2.1.0", "resolved": "https://registry.npmjs.org/type-is/-/type-is-2.1.0.tgz", @@ -2655,6 +3186,27 @@ "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": { "version": "0.2.0", "resolved": "https://registry.npmjs.org/universalify/-/universalify-0.2.0.tgz", diff --git a/package.json b/package.json index 4980077..b690fb9 100644 --- a/package.json +++ b/package.json @@ -3,19 +3,20 @@ "version": "2.0.0", "type": "module", "scripts": { - "start": "node bin/screen.js", - "server": "node bin/server.js", - "dev": "concurrently -n api,ui -c cyan,magenta \"node bin/server.js\" \"npm run dev --prefix ui\"", + "start": "tsx bin/screen.ts", + "server": "tsx bin/server.ts", + "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", - "finance": "node bin/finance.js", - "test": "node --test --test-reporter=./scripts/summary-reporter.js tests/*.test.js", - "test:watch": "node --test --watch --test-reporter=spec tests/*.test.js", - "format": "prettier --write \"src/**/*.js\" \"bin/**/*.js\" \"tests/**/*.js\"", - "format:check": "prettier --check \"src/**/*.js\" \"bin/**/*.js\" \"tests/**/*.js\"", + "finance": "tsx bin/finance.ts", + "typecheck": "tsc --noEmit", + "test": "tsx --test --test-reporter=./scripts/summary-reporter.js tests/*.test.js", + "test:watch": "tsx --test --watch --test-reporter=spec tests/*.test.js", + "format": "prettier --write \"server/**/*.ts\" \"bin/**/*.ts\" \"tests/**/*.js\"", + "format:check": "prettier --check \"server/**/*.ts\" \"bin/**/*.ts\" \"tests/**/*.js\"", "prepare": "husky" }, "lint-staged": { - "*.js": [ + "*.{ts,js}": [ "prettier --write" ] }, @@ -27,9 +28,12 @@ "yahoo-finance2": "^3.15.2" }, "devDependencies": { + "@types/node": "^22.0.0", "concurrently": "^10.0.3", "husky": "^9.0.0", "lint-staged": "^15.0.0", - "prettier": "^3.0.0" + "prettier": "^3.0.0", + "tsx": "^4.0.0", + "typescript": "^5.0.0" } } diff --git a/portfolio.json b/portfolio.json deleted file mode 100644 index 0dc42a4..0000000 --- a/portfolio.json +++ /dev/null @@ -1 +0,0 @@ -{ "holdings": [] } diff --git a/server/analyst/CatalystAnalyst.js b/server/analyst/CatalystAnalyst.ts similarity index 58% rename from server/analyst/CatalystAnalyst.js rename to server/analyst/CatalystAnalyst.ts index f2a5641..630db86 100644 --- a/server/analyst/CatalystAnalyst.js +++ b/server/analyst/CatalystAnalyst.ts @@ -1,16 +1,32 @@ 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 MAX_STORIES = 15; const TICKER_REGEX = /^[A-Z]{1,6}$/; export class CatalystAnalyst { - constructor({ logger } = {}) { + private client: YahooClient; + private logger: Pick; + + constructor({ logger }: { logger?: Pick } = {}) { 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 { this.logger.write('🔍 Fetching market news...'); const stories = await this._fetchNews(); const tickers = this._extractTickers(stories); @@ -18,12 +34,15 @@ export class CatalystAnalyst { return { tickers, stories }; } - async _fetchNews() { - const seen = new Map(); + private async _fetchNews(): Promise { + const seen = new Map(); for (const query of NEWS_QUERIES) { try { - const { news = [] } = await this.client.yf.search(query, { newsCount: 8, quotesCount: 0 }); - for (const s of news) { + const { news = [] } = await (this.client as any).yf.search(query, { + newsCount: 8, + quotesCount: 0, + }); + for (const s of news as any[]) { if (!seen.has(s.title)) { seen.set(s.title, { title: s.title, @@ -40,8 +59,8 @@ export class CatalystAnalyst { return [...seen.values()].slice(0, MAX_STORIES); } - _extractTickers(stories) { - const tickers = new Set(); + private _extractTickers(stories: Story[]): string[] { + const tickers = new Set(); for (const { relatedTickers } of stories) { for (const t of relatedTickers) { const clean = t.split(':')[0].toUpperCase(); diff --git a/server/analyst/LLMAnalyst.js b/server/analyst/LLMAnalyst.ts similarity index 71% rename from server/analyst/LLMAnalyst.js rename to server/analyst/LLMAnalyst.ts index 8e7b4e9..04a540e 100644 --- a/server/analyst/LLMAnalyst.js +++ b/server/analyst/LLMAnalyst.ts @@ -1,15 +1,10 @@ import Anthropic from '@anthropic-ai/sdk'; +import type { Logger, LLMAnalysis } from '../types.js'; -// LLMAnalyst — uses Claude Haiku to analyze news catalyst stories. -// -// Given a list of news headlines and the tickers already identified, -// 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. +interface Story { + title: string; + publisher?: string; +} 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 { - constructor({ logger } = {}) { + private logger: Pick; + private client: Anthropic | null; + + constructor({ logger }: { logger?: Pick } = {}) { this.logger = logger ?? { log: console.log, warn: console.warn }; this.client = process.env.ANTHROPIC_API_KEY ? new Anthropic({ apiKey: process.env.ANTHROPIC_API_KEY }) : null; } - // Analyzes news stories and returns structured market intelligence. - // Returns null if ANTHROPIC_API_KEY is not set (graceful degradation). - async analyze(stories, existingTickers = []) { + async analyze(stories: Story[], existingTickers: string[] = []): Promise { if (!this.client) { this.logger.warn('LLMAnalyst: ANTHROPIC_API_KEY not set — skipping analysis'); return null; } - if (!stories?.length) return null; const headlines = stories @@ -64,14 +59,14 @@ export class LLMAnalyst { messages: [{ role: 'user', content: userMessage }], }); - const raw = response.content[0]?.text ?? ''; + const raw = (response.content[0] as { text?: string })?.text ?? ''; const cleaned = raw .replace(/^```(?:json)?\s*/i, '') .replace(/```\s*$/i, '') .trim(); - return JSON.parse(cleaned); + return JSON.parse(cleaned) as LLMAnalysis; } catch (err) { - this.logger.warn('LLMAnalyst: analysis failed —', err.message); + this.logger.warn('LLMAnalyst: analysis failed —', (err as Error).message); return null; } } diff --git a/server/calls/MarketCallStore.js b/server/calls/MarketCallStore.js deleted file mode 100644 index 1a39095..0000000 --- a/server/calls/MarketCallStore.js +++ /dev/null @@ -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; - } -} diff --git a/server/calls/MarketCallStore.ts b/server/calls/MarketCallStore.ts new file mode 100644 index 0000000..2959323 --- /dev/null +++ b/server/calls/MarketCallStore.ts @@ -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; +} + +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; + } +} diff --git a/server/config/ScoringConfig.js b/server/config/ScoringConfig.ts similarity index 52% rename from server/config/ScoringConfig.js rename to server/config/ScoringConfig.ts index 83c2b9c..7e2b0f6 100644 --- a/server/config/ScoringConfig.js +++ b/server/config/ScoringConfig.ts @@ -1,7 +1,9 @@ -// Credit rating scale (S&P convention). -// Bond.js converts letter ratings to these numbers; BondScorer uses them for gate checks. +import type { Sector } from './constants.js'; + +// ── 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. -export const CREDIT_RATING_SCALE = { +export const CREDIT_RATING_SCALE: Record = { AAA: 10, AA: 9, A: 8, @@ -14,16 +16,38 @@ export const CREDIT_RATING_SCALE = { D: 1, }; +// ── Scoring rule shape ──────────────────────────────────────────────────── + +interface GateSet extends Record {} +interface WeightSet extends Record {} +interface ThresholdSet extends Record {} + +interface RuleBlock { + gates: GateSet; + weights: WeightSet; + thresholds: ThresholdSet; +} + +interface StockRules extends RuleBlock { + SECTOR_OVERRIDE: Partial>>; +} + +interface ScoringRulesShape { + STOCK: StockRules; + ETF: RuleBlock; + BOND: RuleBlock; +} + // ───────────────────────────────────────────────────────────────────────────── // 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. // ───────────────────────────────────────────────────────────────────────────── -export const ScoringRules = { +export const ScoringRules: ScoringRulesShape = { STOCK: { gates: { - maxDebtToEquity: 1.5, // Graham ceiling; 3.0 was too permissive — most distress starts above 2x - minQuickRatio: 0.8, // Raised from 0.5: below 0.8 signals real liquidity stress in non-tech + maxDebtToEquity: 1.5, // Graham ceiling; most distress starts above 2x + 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 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 peg: 2, // valuation relative to 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: { - marginHigh: 15, // lowered from 20: 15% net margin is genuinely excellent across most sectors - marginMed: 8, // lowered from 10: 8% is the realistic mid-tier for industrials/retail + marginHigh: 15, // 15% net margin is genuinely excellent across most sectors + marginMed: 8, // 8% is the realistic mid-tier for industrials/retail opMarginHigh: 20, opMarginMed: 10, - roeHigh: 15, // lowered from 20: sustainable 15% ROE is Buffett-quality; 20% is rare/fleeting - roeMed: 10, // kept — 10% is the cost-of-equity floor for most businesses - pegHigh: 0.75, // raised bar: PEG < 0.75 is genuinely cheap relative to growth + roeHigh: 15, // sustainable 15% ROE is Buffett-quality; 20% is rare/fleeting + roeMed: 10, // 10% is the cost-of-equity floor for most businesses + pegHigh: 0.75, // PEG < 0.75 is genuinely cheap relative to growth 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, fcfHigh: 5, fcfMed: 2, }, 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: { 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 }, 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: { 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 }, 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: { gates: { maxDebtToEquity: 9999, @@ -87,9 +99,6 @@ export const ScoringRules = { 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: { 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 }, @@ -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: { 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 }, @@ -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: { 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 }, @@ -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: { 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 }, @@ -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: { 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 }, @@ -193,24 +187,17 @@ export const ScoringRules = { }, 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 }, - weights: { yield: 2, lowCost: 4, fiveYearReturn: 2 }, // cost is #1 predictive factor; 5Y return rewards consistency + weights: { yield: 2, lowCost: 4, fiveYearReturn: 2 }, thresholds: { minYield: 1.5, - maxExpense: 0.05, // 0.05% is achievable for broad market ETFs - minVolume: 1000000, // 1M ADV is the real liquidity floor to avoid slippage - minFiveYearReturn: 8.0, // S&P 500 long-run real return ~7-10%; 8% filters underperformers + maxExpense: 0.05, + minVolume: 1_000_000, + minFiveYearReturn: 8.0, }, }, 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 weights: { yieldSpread: 3, duration: 2 }, thresholds: { minSpread: 1.5, maxDuration: 7 }, diff --git a/server/config/constants.js b/server/config/constants.ts similarity index 51% rename from server/config/constants.js rename to server/config/constants.ts index 79bcbbb..7ea3281 100644 --- a/server/config/constants.js +++ b/server/config/constants.ts @@ -1,17 +1,19 @@ +import type { Signal, AssetType, RateRegime } from '../types.js'; + export const SIGNAL = { - STRONG_BUY: '✅ Strong Buy', - MOMENTUM: '⚡ Momentum', - SPECULATION: '⚠️ Speculation', - NEUTRAL: '🔄 Neutral', - AVOID: '❌ Avoid', -}; + STRONG_BUY: '✅ Strong Buy' as Signal, + MOMENTUM: '⚡ Momentum' as Signal, + SPECULATION: '⚠️ Speculation' as Signal, + NEUTRAL: '🔄 Neutral' as Signal, + AVOID: '❌ Avoid' as Signal, +} as const; export const ASSET_TYPE = { - STOCK: 'STOCK', - ETF: 'ETF', - BOND: 'BOND', + STOCK: 'STOCK' as AssetType, + ETF: 'ETF' as AssetType, + BOND: 'BOND' as AssetType, CRYPTO: 'crypto', -}; +} as const; export const SECTOR = { TECHNOLOGY: 'TECHNOLOGY', @@ -23,20 +25,22 @@ export const SECTOR = { CONSUMER_STAPLES: 'CONSUMER_STAPLES', CONSUMER_DISCRETIONARY: 'CONSUMER_DISCRETIONARY', GENERAL: 'GENERAL', -}; +} as const; + +export type Sector = (typeof SECTOR)[keyof typeof SECTOR]; export const SCORE_MODE = { FUNDAMENTAL: 'FUNDAMENTAL', INFLATED: 'INFLATED', -}; +} as const; export const REGIME = { - LOW: 'LOW', - NORMAL: 'NORMAL', - HIGH: 'HIGH', -}; + LOW: 'LOW' as RateRegime, + NORMAL: 'NORMAL' as RateRegime, + HIGH: 'HIGH' as RateRegime, +} as const; -export const YAHOO_MODULES = [ +export const YAHOO_MODULES: string[] = [ 'assetProfile', 'financialData', 'defaultKeyStatistics', @@ -44,7 +48,7 @@ export const YAHOO_MODULES = [ 'summaryDetail', ]; -export const SIGNAL_ORDER = { +export const SIGNAL_ORDER: Record = { [SIGNAL.STRONG_BUY]: 0, [SIGNAL.MOMENTUM]: 1, [SIGNAL.NEUTRAL]: 2, diff --git a/server/finance/PersonalFinanceAnalyzer.js b/server/finance/PersonalFinanceAnalyzer.ts similarity index 71% rename from server/finance/PersonalFinanceAnalyzer.js rename to server/finance/PersonalFinanceAnalyzer.ts index f3eaaef..5589824 100644 --- a/server/finance/PersonalFinanceAnalyzer.js +++ b/server/finance/PersonalFinanceAnalyzer.ts @@ -1,14 +1,38 @@ -// PersonalFinanceAnalyzer -// -// Takes normalised SimpleFIN account data and computes: -// - Net worth (assets - liabilities) -// - Cash vs investment allocation -// - Spending by category (last 30 days) -// - Top spending categories -// - Income vs expenses summary +interface Transaction { + amount: number; + category: string; +} + +interface Account { + type: string; + balance: number; + transactions: Transaction[]; + [key: string]: unknown; +} + +interface CategoryBreakdown { + category: string; + amount: number; + pct: string; +} + +interface FinanceAnalysis { + netWorth: number; + totalAssets: number; + totalLiabilities: number; + totalCash: number; + totalInvestments: number; + cashPct: string; + investPct: string; + totalIncome: number; + totalSpend: number; + savingsRate: string | null; + categoryBreakdown: CategoryBreakdown[]; + accounts: Account[]; +} export class PersonalFinanceAnalyzer { - analyse(accounts) { + analyse(accounts: Account[]): FinanceAnalysis { const assets = accounts.filter((a) => !['CREDIT', 'LOAN'].includes(a.type)); const liabilities = accounts.filter((a) => ['CREDIT', 'LOAN'].includes(a.type)); @@ -21,21 +45,19 @@ export class PersonalFinanceAnalyzer { const totalCash = cash.reduce((s, a) => s + Math.max(0, a.balance), 0); const totalInvest = investments.reduce((s, a) => s + Math.max(0, a.balance), 0); - // Aggregate all transactions across accounts const allTx = accounts.flatMap((a) => a.transactions); - const spending = allTx.filter((tx) => tx.amount < 0 && tx.category !== 'Transfer'); const income = allTx.filter((tx) => tx.amount > 0 && tx.category === 'Income'); const totalSpend = spending.reduce((s, tx) => s + Math.abs(tx.amount), 0); const totalIncome = income.reduce((s, tx) => s + tx.amount, 0); - // Spending by category - const byCategory = {}; + const byCategory: Record = {}; for (const tx of spending) { byCategory[tx.category] = (byCategory[tx.category] ?? 0) + Math.abs(tx.amount); } - const categoryBreakdown = Object.entries(byCategory) + + const categoryBreakdown: CategoryBreakdown[] = Object.entries(byCategory) .sort((a, b) => b[1] - a[1]) .map(([category, amount]) => ({ category, diff --git a/server/finance/PortfolioAdvisor.js b/server/finance/PortfolioAdvisor.ts similarity index 58% rename from server/finance/PortfolioAdvisor.js rename to server/finance/PortfolioAdvisor.ts index 9f538fc..391ac72 100644 --- a/server/finance/PortfolioAdvisor.js +++ b/server/finance/PortfolioAdvisor.ts @@ -1,24 +1,52 @@ import { SIGNAL } from '../config/constants.js'; import { YahooClient } from '../market/YahooClient.js'; +import type { PortfolioHolding, Signal, ScreenerResult, AssetResult } from '../types.js'; + +interface PositionCalc { + totalCost: string; + marketValue: string | null; + gainLossPct: string | null; +} + +interface AdviceOutput { + action: string; + reason: string; +} + +interface AdviceRow { + ticker: string; + type: string; + source: string; + shares: number; + costBasis: number; + currentPrice: number | null; + marketValue: string | null; + totalCost: string; + gainLossPct: string | null; + signal: Signal | '—'; + inflated: string; + fundamental: string; + advice: string; + reason: string; +} export class PortfolioAdvisor { + private client: YahooClient; + constructor() { this.client = new YahooClient(); } - async advise(holdings, screenedResults) { - // Build result map keyed by both the Yahoo ticker (BRK-B) and the - // dot-notation variant (BRK.B) so lookups work regardless of format. - const resultMap = {}; - for (const r of [ - ...(screenedResults.STOCK ?? []), - ...(screenedResults.ETF ?? []), - ...(screenedResults.BOND ?? []), - ]) { + async advise( + holdings: PortfolioHolding[], + screenedResults: ScreenerResult, + ): Promise { + const resultMap: Record = {}; + for (const r of [...screenedResults.STOCK, ...screenedResults.ETF, ...screenedResults.BOND]) { const t = r.asset.ticker; resultMap[t] = r; - resultMap[t.replace(/-/g, '.')] = r; // BRK-B → BRK.B - resultMap[t.replace(/\./g, '-')] = r; // BRK.B → BRK-B + resultMap[t.replace(/-/g, '.')] = r; + resultMap[t.replace(/\./g, '-')] = r; } const cryptoPrices = await this._cryptoPrices(holdings.filter((h) => h.type === 'crypto')); @@ -26,9 +54,9 @@ export class PortfolioAdvisor { return holdings.map((holding) => { const type = (holding.type ?? 'stock').toLowerCase(); const source = holding.source ?? '—'; - const price = + const price: number | null = type === 'crypto' - ? cryptoPrices[holding.ticker.toUpperCase()] + ? (cryptoPrices[holding.ticker.toUpperCase()] ?? null) : (resultMap[holding.ticker.toUpperCase()]?.asset?.currentPrice ?? null); return type === 'crypto' @@ -37,7 +65,12 @@ export class PortfolioAdvisor { }); } - _stockRow(holding, price, source, result) { + private _stockRow( + holding: PortfolioHolding, + price: number | null, + source: string, + result: AssetResult | undefined, + ): AdviceRow { if (!result) { return this._row(holding, price, source, '—', '—', '—', { action: '⚪ Not screened', @@ -55,7 +88,15 @@ export class PortfolioAdvisor { ); } - _row(holding, currentPrice, source, signal, inflated, fundamental, { action, reason }) { + private _row( + holding: PortfolioHolding, + currentPrice: number | null, + source: string, + signal: Signal | '—', + inflated: string, + fundamental: string, + { action, reason }: AdviceOutput, + ): AdviceRow { const { marketValue, totalCost, gainLossPct } = this._position(holding, currentPrice); return { ticker: holding.ticker, @@ -75,19 +116,20 @@ export class PortfolioAdvisor { }; } - _position(holding, currentPrice) { - const totalCost = (holding.costBasis * holding.shares).toFixed(2); - const marketValue = currentPrice != null ? (currentPrice * holding.shares).toFixed(2) : null; - const gainLossPct = - currentPrice != null && holding.costBasis > 0 - ? (((currentPrice - holding.costBasis) / holding.costBasis) * 100).toFixed(1) - : null; - return { totalCost, marketValue, gainLossPct }; + private _position(holding: PortfolioHolding, currentPrice: number | null): PositionCalc { + return { + totalCost: (holding.costBasis * holding.shares).toFixed(2), + marketValue: currentPrice != null ? (currentPrice * holding.shares).toFixed(2) : null, + gainLossPct: + currentPrice != null && holding.costBasis > 0 + ? (((currentPrice - holding.costBasis) / holding.costBasis) * 100).toFixed(1) + : null, + }; } - _cryptoAdvice(holding, price) { + private _cryptoAdvice(holding: PortfolioHolding, price: number | null): AdviceOutput { const { gainLossPct } = this._position(holding, price); - const g = parseFloat(gainLossPct); + const g = parseFloat(gainLossPct ?? 'NaN'); if (gainLossPct == null) return { action: '⚪ No price data', @@ -109,15 +151,12 @@ export class PortfolioAdvisor { }; } - _advice(signal, holding, price) { + private _advice(signal: Signal, holding: PortfolioHolding, price: number | null): AdviceOutput { const { gainLossPct } = this._position(holding, price); - const gain = parseFloat(gainLossPct); + const gain = parseFloat(gainLossPct ?? '0'); switch (signal) { case SIGNAL.STRONG_BUY: - return { - action: '🟢 Hold & Add', - reason: 'Passes both analyses. Strong conviction.', - }; + return { action: '🟢 Hold & Add', reason: 'Passes both analyses. Strong conviction.' }; case SIGNAL.MOMENTUM: return { action: '🟡 Hold', @@ -135,10 +174,7 @@ export class PortfolioAdvisor { : 'Overvalued fundamentally. Keep position small.', }; case SIGNAL.NEUTRAL: - return { - action: '🟡 Hold', - reason: 'No clear edge. Review on any catalyst.', - }; + return { action: '🟡 Hold', reason: 'No clear edge. Review on any catalyst.' }; case SIGNAL.AVOID: return { action: gain > 0 ? '🔴 Sell (Take Profits)' : '🔴 Sell (Cut Loss)', @@ -152,12 +188,14 @@ export class PortfolioAdvisor { } } - async _cryptoPrices(cryptoHoldings) { - const prices = {}; - for (const h of cryptoHoldings) { + private async _cryptoPrices( + holdings: PortfolioHolding[], + ): Promise> { + const prices: Record = {}; + for (const h of holdings) { try { const summary = await this.client.fetchSummary(h.ticker); - prices[h.ticker.toUpperCase()] = summary.price?.regularMarketPrice ?? null; + prices[h.ticker.toUpperCase()] = summary?.price?.regularMarketPrice ?? null; } catch { prices[h.ticker.toUpperCase()] = null; } diff --git a/server/finance/clients/SimpleFINClient.js b/server/finance/clients/SimpleFINClient.ts similarity index 63% rename from server/finance/clients/SimpleFINClient.js rename to server/finance/clients/SimpleFINClient.ts index 0decc66..353903a 100644 --- a/server/finance/clients/SimpleFINClient.js +++ b/server/finance/clients/SimpleFINClient.ts @@ -1,22 +1,48 @@ import fs from 'fs'; import https from 'https'; import http from 'http'; +import type { Logger } from '../../types.js'; -// SimpleFIN auth flow: -// 1. You get a Setup Token from https://beta-bridge.simplefin.org -// 2. This client decodes it, POSTs once to claim an Access URL -// 3. The CLI saves it to .env; a server would store it in its own secret store. -// 4. All subsequent requests use the Access URL directly. -// -// .env configuration: -// First run: SIMPLEFIN_SETUP_TOKEN=aHR0cHM6Ly8... -// After that: SIMPLEFIN_ACCESS_URL=https://user:pass@beta-bridge.simplefin.org/simplefin +interface SimpleFINOptions { + logger?: Logger; + onAccessUrlClaimed?: (url: string) => Promise | void; +} + +interface GetAccountsOptions { + startDate?: number; + endDate?: number; +} + +interface Transaction { + id: string; + date: string; + amount: number; + description: string; + category: string; +} + +interface Account { + id: string; + name: string; + currency: string; + balance: number; + balanceDate: string; + org: string; + type: string; + transactions: Transaction[]; +} + +interface SimpleFINData { + accounts: Account[]; + errors: string[]; +} export class SimpleFINClient { - // logger: object with .write() / .log() / .warn() — defaults to console. - // onAccessUrlClaimed(url): optional callback so the caller can persist the URL - // (CLI uses it to write .env; a server would store it elsewhere). - constructor({ logger, onAccessUrlClaimed } = {}) { + private accessUrl: string | null; + private logger: Logger; + private onAccessUrlClaimed: ((url: string) => Promise | void) | null; + + constructor({ logger, onAccessUrlClaimed }: SimpleFINOptions = {}) { this.accessUrl = null; this.logger = logger ?? { write: (msg) => process.stdout.write(msg), @@ -26,37 +52,28 @@ export class SimpleFINClient { this.onAccessUrlClaimed = onAccessUrlClaimed ?? null; } - async init() { + async init(): Promise { if (process.env.SIMPLEFIN_ACCESS_URL) { this.accessUrl = process.env.SIMPLEFIN_ACCESS_URL.replace(/\/$/, ''); return; } - if (process.env.SIMPLEFIN_SETUP_TOKEN) { this.accessUrl = await this._claimAccessUrl(process.env.SIMPLEFIN_SETUP_TOKEN); - if (this.onAccessUrlClaimed) { - await this.onAccessUrlClaimed(this.accessUrl); - } + if (this.onAccessUrlClaimed) await this.onAccessUrlClaimed(this.accessUrl); return; } - throw new Error( - 'SimpleFIN not configured.\n' + - 'Add to .env:\n' + - ' SIMPLEFIN_SETUP_TOKEN=\n' + - 'The Access URL will be saved automatically on first run.', + 'SimpleFIN not configured.\nAdd to .env:\n SIMPLEFIN_SETUP_TOKEN=\nThe Access URL will be saved automatically on first run.', ); } - async getAccounts(options = {}) { + async getAccounts(options: GetAccountsOptions = {}): Promise { if (!this.accessUrl) await this.init(); const startDate = options.startDate ?? this._daysAgo(30); const endDate = options.endDate ?? Math.floor(Date.now() / 1000); - // fetch() rejects URLs with embedded credentials (user:pass@host). - // Extract them and send as a Basic Auth header instead. - const parsed = new URL(this.accessUrl); + const parsed = new URL(this.accessUrl!); const auth = parsed.username ? 'Basic ' + Buffer.from(`${parsed.username}:${parsed.password}`).toString('base64') : null; @@ -65,43 +82,34 @@ export class SimpleFINClient { const cleanBase = parsed.toString().replace(/\/$/, ''); const url = `${cleanBase}/accounts?version=2&start-date=${startDate}&end-date=${endDate}`; - const response = await fetch(url, { - headers: auth ? { Authorization: auth } : {}, - }); + const response = await fetch(url, { headers: auth ? { Authorization: auth } : {} }); if (!response.ok) { throw new Error(`SimpleFIN error ${response.status}: ${await response.text()}`); } - const data = await response.json(); - + const data = (await response.json()) as { accounts?: unknown[]; errors?: string[] }; if (data.errors?.length) { data.errors.forEach((e) => this.logger.warn(` ⚠ SimpleFIN: ${e}`)); } - return this._normalise(data); + return this._normalise(data as { accounts: unknown[]; errors: string[] }); } - // ── Auth ───────────────────────────────────────────────────────────────────── - - async _claimAccessUrl(setupToken) { + private async _claimAccessUrl(setupToken: string): Promise { const claimUrl = Buffer.from(setupToken.trim(), 'base64').toString('utf8').trim(); this.logger.write(`\n🔑 Claiming SimpleFIN access URL...\n → ${claimUrl}\n`); - const accessUrl = await this._post(claimUrl); - if (!accessUrl || !accessUrl.startsWith('http')) { throw new Error( - `Unexpected response from SimpleFIN: "${accessUrl}"\n` + - 'Setup tokens are one-time use — if already claimed, generate a new one at https://beta-bridge.simplefin.org', + `Unexpected response from SimpleFIN: "${accessUrl}"\nSetup tokens are one-time use — if already claimed, generate a new one at https://beta-bridge.simplefin.org`, ); } - this.logger.write('✅ Access URL received\n'); return accessUrl.trim(); } - _post(url) { + private _post(url: string): Promise { return new Promise((resolve, reject) => { const parsed = new URL(url); const lib = parsed.protocol === 'https:' ? https : http; @@ -112,30 +120,23 @@ export class SimpleFINClient { method: 'POST', headers: { 'Content-Length': '0', 'Content-Type': 'application/x-www-form-urlencoded' }, }; - const req = lib.request(options, (res) => { let body = ''; - res.on('data', (chunk) => { + res.on('data', (chunk: string) => { body += chunk; }); res.on('end', () => { - if (res.statusCode >= 200 && res.statusCode < 300) { - resolve(body.trim()); - } else { - reject(new Error(`HTTP ${res.statusCode}: ${body.trim()}`)); - } + if ((res.statusCode ?? 0) >= 200 && (res.statusCode ?? 0) < 300) resolve(body.trim()); + else reject(new Error(`HTTP ${res.statusCode}: ${body.trim()}`)); }); }); - req.on('error', reject); req.end(); }); } - // ── Normalise ──────────────────────────────────────────────────────────────── - - _normalise(data) { - const accounts = (data.accounts ?? []).map((acc) => ({ + private _normalise(data: { accounts: unknown[]; errors: string[] }): SimpleFINData { + const accounts = (data.accounts ?? []).map((acc: any) => ({ id: acc.id, name: acc.name, currency: acc.currency ?? 'USD', @@ -143,7 +144,7 @@ export class SimpleFINClient { balanceDate: new Date(acc['balance-date'] * 1000).toISOString().slice(0, 10), org: acc.org?.name ?? 'Unknown', type: this._classifyAccount(acc.name), - transactions: (acc.transactions ?? []).map((tx) => ({ + transactions: (acc.transactions ?? []).map((tx: any) => ({ id: tx.id, date: new Date(tx.posted * 1000).toISOString().slice(0, 10), amount: parseFloat(tx.amount) ?? 0, @@ -151,11 +152,10 @@ export class SimpleFINClient { category: this._categorise(tx.description ?? ''), })), })); - return { accounts, errors: data.errors ?? [] }; } - _classifyAccount(name) { + private _classifyAccount(name: string): string { const n = name.toLowerCase(); if (n.includes('checking') || n.includes('current')) return 'CHECKING'; if (n.includes('saving')) return 'SAVINGS'; @@ -166,7 +166,7 @@ export class SimpleFINClient { return 'OTHER'; } - _categorise(description) { + private _categorise(description: string): string { const d = description.toLowerCase(); if (d.match(/amazon|walmart|target|costco|grocery|whole foods|trader joe/)) return 'Shopping'; if (d.match(/uber eats|doordash|grubhub|postmates|instacart/)) return 'Delivery'; @@ -181,14 +181,12 @@ export class SimpleFINClient { return 'Other'; } - _daysAgo(n) { + private _daysAgo(n: number): number { return Math.floor((Date.now() - n * 24 * 60 * 60 * 1000) / 1000); } } -// CLI helper — saves the access URL to .env after the setup token is claimed. -// Pass this as `onAccessUrlClaimed` when constructing SimpleFINClient in CLI context. -export function saveAccessUrlToEnv(accessUrl) { +export function saveAccessUrlToEnv(accessUrl: string): void { try { const existing = fs.existsSync('.env') ? fs.readFileSync('.env', 'utf8') : ''; if (!existing.includes('SIMPLEFIN_ACCESS_URL')) { diff --git a/server/market/BenchmarkProvider.js b/server/market/BenchmarkProvider.js deleted file mode 100644 index b9089a4..0000000 --- a/server/market/BenchmarkProvider.js +++ /dev/null @@ -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; - } - } -} diff --git a/server/market/BenchmarkProvider.ts b/server/market/BenchmarkProvider.ts new file mode 100644 index 0000000..4707d1b --- /dev/null +++ b/server/market/BenchmarkProvider.ts @@ -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 { + 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; + } + } +} diff --git a/server/market/MarketRegime.js b/server/market/MarketRegime.ts similarity index 65% rename from server/market/MarketRegime.js rename to server/market/MarketRegime.ts index 5838a5d..175c1cd 100644 --- a/server/market/MarketRegime.js +++ b/server/market/MarketRegime.ts @@ -1,8 +1,21 @@ import { SECTOR, ASSET_TYPE, REGIME } from '../config/constants.js'; +import type { MarketContext, AssetType } from '../types.js'; + +interface InflatedOverrides { + gates: Record; + thresholds: Record; +} export class MarketRegime { - constructor(marketContext) { - const b = marketContext?.benchmarks ?? {}; + private marketPE: number; + private techPE: number; + private reitYield: number; + private igSpread: number; + private rateRegime: string; + private volatilityRegime: string; + + constructor(marketContext: Partial) { + const b = marketContext?.benchmarks ?? ({} as MarketContext['benchmarks']); this.marketPE = b.marketPE ?? 22; this.techPE = b.techPE ?? 30; this.reitYield = b.reitYield ?? 3.5; @@ -11,18 +24,17 @@ export class MarketRegime { 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.ETF) return this._etf(); if (type === ASSET_TYPE.BOND) return this._bond(); return { gates: {}, thresholds: {} }; } - _stock(sector) { + private _stock(sector?: string): InflatedOverrides { if (sector === SECTOR.REIT) { return { gates: {}, - // In HIGH rate environment tighten REIT yield floor — REITs must compete harder with bonds. thresholds: { minYield: +(this.reitYield * (this.rateRegime === REGIME.HIGH ? 0.95 : 0.85)).toFixed(2), maxPFFO: 20, @@ -38,8 +50,6 @@ export class MarketRegime { 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; return { gates: { @@ -50,14 +60,15 @@ export class MarketRegime { }; } - _etf() { + private _etf(): InflatedOverrides { return { gates: { maxExpenseRatio: 0.75 }, thresholds: { minYield: 0.5 } }; } - _bond() { - // 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. + private _bond(): InflatedOverrides { 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) }, + }; } } diff --git a/server/market/YahooClient.js b/server/market/YahooClient.js deleted file mode 100644 index 1ac044f..0000000 --- a/server/market/YahooClient.js +++ /dev/null @@ -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; - } - } -} diff --git a/server/market/YahooClient.ts b/server/market/YahooClient.ts new file mode 100644 index 0000000..fb73ff7 --- /dev/null +++ b/server/market/YahooClient.ts @@ -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 { + 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((res) => setTimeout(res, backoff * (i + 1))); + } + } + } + + // eslint-disable-next-line @typescript-eslint/no-explicit-any + async fetchCalendarEvents(ticker: string): Promise { + try { + const r = await (this.yf as any).quoteSummary(ticker, { modules: ['calendarEvents'] }); + return r.calendarEvents ?? null; + } catch { + return null; + } + } +} diff --git a/server/reporters/FinanceReporter.js b/server/reporters/FinanceReporter.ts similarity index 97% rename from server/reporters/FinanceReporter.js rename to server/reporters/FinanceReporter.ts index 1c412b5..819c447 100644 --- a/server/reporters/FinanceReporter.js +++ b/server/reporters/FinanceReporter.ts @@ -1,20 +1,24 @@ import fs from 'fs'; import path from 'path'; +import type { MarketContext } from '../types.js'; export class FinanceReporter { - // Returns the HTML string — useful for server responses. - render(advice, personalFinance, marketContext) { + render(advice: unknown[], personalFinance: unknown, marketContext: MarketContext): string { return this._build(advice, personalFinance, marketContext); } - // Writes to disk and returns the absolute path — used by the CLI. - generate(advice, personalFinance, marketContext, outputPath = './finance-report.html') { + generate( + advice: unknown[], + personalFinance: unknown, + marketContext: MarketContext, + outputPath = './finance-report.html', + ): string { const html = this._build(advice, personalFinance, marketContext); fs.writeFileSync(outputPath, html, 'utf8'); return path.resolve(outputPath); } - _build(advice, pf, ctx) { + _build(advice: unknown, pf: unknown, ctx: unknown) { const date = new Date().toISOString().slice(0, 10); return ` diff --git a/server/reporters/HtmlReporter.js b/server/reporters/HtmlReporter.ts similarity index 97% rename from server/reporters/HtmlReporter.js rename to server/reporters/HtmlReporter.ts index a782f05..5902ee8 100644 --- a/server/reporters/HtmlReporter.js +++ b/server/reporters/HtmlReporter.ts @@ -1,17 +1,25 @@ import fs from 'fs'; import path from 'path'; +import type { MarketContext } from '../types.js'; // Generates a self-contained HTML report saved to ./screener-report.html // Console output shows only the signal summary — full breakdown lives here. export class HtmlReporter { - // Returns the HTML string — useful for server responses. - render(results, marketContext, personalFinance = null) { + render( + results: Record, + marketContext: MarketContext, + personalFinance: unknown = null, + ): string { return this._buildHtml(results, marketContext, personalFinance); } - // Writes to disk and returns the absolute path — used by the CLI. - generate(results, marketContext, personalFinance = null, outputPath = './screener-report.html') { + generate( + results: Record, + marketContext: MarketContext, + personalFinance: unknown = null, + outputPath = './screener-report.html', + ): string { const html = this._buildHtml(results, marketContext, personalFinance); fs.writeFileSync(outputPath, html, 'utf8'); return path.resolve(outputPath); diff --git a/server/screener/Chunker.js b/server/screener/Chunker.ts similarity index 63% rename from server/screener/Chunker.js rename to server/screener/Chunker.ts index 8ca736c..7af3603 100644 --- a/server/screener/Chunker.js +++ b/server/screener/Chunker.ts @@ -1,4 +1,4 @@ -export const chunkArray = (array, size) => +export const chunkArray = (array: T[], size: number): T[][] => Array.from({ length: Math.ceil(array.length / size) }, (_, i) => array.slice(i * size, i * size + size), ); diff --git a/server/screener/DataMapper.js b/server/screener/DataMapper.js deleted file mode 100644 index ddc6745..0000000 --- a/server/screener/DataMapper.js +++ /dev/null @@ -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, -}); diff --git a/server/screener/DataMapper.ts b/server/screener/DataMapper.ts new file mode 100644 index 0000000..220e25b --- /dev/null +++ b/server/screener/DataMapper.ts @@ -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>; + +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; + const ks = (summary.defaultKeyStatistics ?? {}) as Record; + const sd = (summary.summaryDetail ?? {}) as Record; + const pr = (summary.price ?? {}) as Record; + + 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, +}); diff --git a/server/screener/RuleMerger.js b/server/screener/RuleMerger.js deleted file mode 100644 index 95ce0e1..0000000 --- a/server/screener/RuleMerger.js +++ /dev/null @@ -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; - }, -}; diff --git a/server/screener/RuleMerger.ts b/server/screener/RuleMerger.ts new file mode 100644 index 0000000..c45aa53 --- /dev/null +++ b/server/screener/RuleMerger.ts @@ -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; + weights: Record; + thresholds: Record; +} + +export const RuleMerger = { + getRulesForAsset( + type: AssetType, + metrics: { sector?: string }, + marketContext: Partial = {}, + 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; + }, +}; diff --git a/server/screener/ScreenerEngine.js b/server/screener/ScreenerEngine.ts similarity index 53% rename from server/screener/ScreenerEngine.js rename to server/screener/ScreenerEngine.ts index e592968..7f6c981 100644 --- a/server/screener/ScreenerEngine.js +++ b/server/screener/ScreenerEngine.ts @@ -10,45 +10,74 @@ import { StockScorer } from './scorers/StockScorer.js'; import { EtfScorer } from './scorers/EtfScorer.js'; import { BondScorer } from './scorers/BondScorer.js'; import { SIGNAL, SIGNAL_ORDER, SCORE_MODE, ASSET_TYPE } from '../config/constants.js'; +import type { Logger, MarketContext, Signal, AssetType, ScreenerResult } from '../types.js'; -const SCORERS = { +const SCORERS: Record = { [ASSET_TYPE.STOCK]: StockScorer, [ASSET_TYPE.ETF]: EtfScorer, [ASSET_TYPE.BOND]: BondScorer, }; +interface ScreenerEngineOptions { + logger?: Logger; +} + +interface ErrorResult { + isError: true; + ticker: string; + message: string; +} + +type FetchResult = ReturnType | ErrorResult; + export class ScreenerEngine { - // logger: object with .write() / .log() — defaults to a console shim so CLI behaviour is unchanged. - // Pass a no-op logger ({ write: () => {}, log: () => {} }) in server context. - constructor({ logger } = {}) { + private client: YahooClient; + private benchmarkProvider: BenchmarkProvider; + private logger: Logger; + + constructor({ logger }: ScreenerEngineOptions = {}) { 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 ?? { - write: (msg) => process.stdout.write(msg), - log: (...args) => console.log(...args), + write: (msg: string) => process.stdout.write(msg), + log: (...args: unknown[]) => console.log(...args), + warn: (...args: unknown[]) => console.warn(...args), }; } // Pure data method — returns structured results. Safe to use in a server route. - async screenTickers(tickers) { + async screenTickers(tickers: string[]): Promise { const marketContext = await this.benchmarkProvider.getMarketContext(); - const results = { STOCK: [], ETF: [], BOND: [], ERROR: [] }; + const results: Omit = { + STOCK: [], + ETF: [], + BOND: [], + ERROR: [], + }; + for (const chunk of chunkArray(tickers, 5)) { const batch = await Promise.all(chunk.map((t) => this._fetch(t))); batch.forEach((data) => this._process(data, marketContext, results)); - await new Promise((r) => setTimeout(r, 1000)); + await new Promise((r) => setTimeout(r, 1000)); } + return { ...results, marketContext }; } // CLI helper — emits progress to logger, returns structured results. - // The caller (bin/screen.js) is responsible for writing the report. - async screenWithProgress(tickers) { + async screenWithProgress(tickers: string[]): Promise { this.logger.write('⏳ Fetching market context...'); const marketContext = await this.benchmarkProvider.getMarketContext(); this.logger.write(' done\n'); - const results = { STOCK: [], ETF: [], BOND: [], ERROR: [] }; + const results: Omit = { + STOCK: [], + ETF: [], + BOND: [], + ERROR: [], + }; const chunks = chunkArray(tickers, 5); let processed = 0; @@ -57,50 +86,60 @@ export class ScreenerEngine { batch.forEach((data) => this._process(data, marketContext, results)); processed += chunk.length; this.logger.write(`\r⏳ Screening tickers... ${processed}/${tickers.length}`); - await new Promise((r) => setTimeout(r, 1000)); + await new Promise((r) => setTimeout(r, 1000)); } this.logger.write('\n'); return { ...results, marketContext }; } - async _fetch(ticker) { + private async _fetch(ticker: string): Promise { try { const summary = await this.client.fetchSummary(ticker); if (!summary?.price) throw new Error('Empty response from Yahoo'); return mapToStandardFormat(ticker, summary); } catch (err) { - return { isError: true, ticker: ticker.toUpperCase(), message: err.message }; + return { isError: true, ticker: ticker.toUpperCase(), message: (err as Error).message }; } } - _process(data, marketContext, results) { - if (data.isError) { - results.ERROR.push(data); + private _process( + data: FetchResult, + marketContext: MarketContext, + results: Omit, + ): void { + if ('isError' in data && data.isError) { + results.ERROR.push({ ticker: data.ticker, message: data.message }); return; } + try { - const asset = this._buildAsset(data); - const scorer = SCORERS[asset.type]; + const asset = this._buildAsset(data as ReturnType); + const scorer = SCORERS[asset.type as AssetType]; if (!scorer) throw new Error(`No scorer for type: ${asset.type}`); const fundamental = scorer.score( - asset.metrics, + asset.metrics as never, RuleMerger.getRulesForAsset( - asset.type, - asset.metrics, + asset.type as AssetType, + asset.metrics as { sector?: string }, marketContext, SCORE_MODE.FUNDAMENTAL, ), marketContext, ); const inflated = scorer.score( - asset.metrics, - RuleMerger.getRulesForAsset(asset.type, asset.metrics, marketContext, SCORE_MODE.INFLATED), + asset.metrics as never, + RuleMerger.getRulesForAsset( + asset.type as AssetType, + asset.metrics as { sector?: string }, + marketContext, + SCORE_MODE.INFLATED, + ), marketContext, ); - results[asset.type].push({ + (results[asset.type as AssetType] as unknown[]).push({ asset, fundamental, inflated, @@ -108,26 +147,26 @@ export class ScreenerEngine { }); } catch (err) { results.ERROR.push({ - ticker: (data.ticker || 'UNKNOWN').toUpperCase(), - message: err.message, + ticker: ((data as { ticker?: string }).ticker || 'UNKNOWN').toUpperCase(), + message: (err as Error).message, }); } } - _buildAsset(data) { - switch ((data.type || ASSET_TYPE.STOCK).toUpperCase()) { + private _buildAsset(data: Record): Stock | Etf | Bond { + switch (((data.type as string) || ASSET_TYPE.STOCK).toUpperCase()) { case ASSET_TYPE.BOND: - return new Bond(data); + return new Bond(data as never); case ASSET_TYPE.ETF: - return new Etf(data); + return new Etf(data as never); default: - return new Stock(data); + return new Stock(data as never); } } - _signal(fundamentalLabel, inflatedLabel) { - const green = (l) => l.startsWith('🟢'); - const yellow = (l) => l.startsWith('🟡'); + private _signal(fundamentalLabel: string, inflatedLabel: string): Signal { + const green = (l: string) => l.startsWith('🟢'); + const yellow = (l: string) => l.startsWith('🟡'); if (green(fundamentalLabel)) return SIGNAL.STRONG_BUY; if (green(inflatedLabel) && yellow(fundamentalLabel)) return SIGNAL.MOMENTUM; if (green(inflatedLabel) && !green(fundamentalLabel)) return SIGNAL.SPECULATION; @@ -135,7 +174,7 @@ export class ScreenerEngine { return SIGNAL.AVOID; } - signalOrder(signal) { + signalOrder(signal: Signal): number { return SIGNAL_ORDER[signal] ?? 5; } } diff --git a/server/screener/assets/Asset.js b/server/screener/assets/Asset.js deleted file mode 100644 index 83c5689..0000000 --- a/server/screener/assets/Asset.js +++ /dev/null @@ -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(); - } -} diff --git a/server/screener/assets/Asset.ts b/server/screener/assets/Asset.ts new file mode 100644 index 0000000..c92dcde --- /dev/null +++ b/server/screener/assets/Asset.ts @@ -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(); + } +} diff --git a/server/screener/assets/Bond.js b/server/screener/assets/Bond.ts similarity index 55% rename from server/screener/assets/Bond.js rename to server/screener/assets/Bond.ts index 24f5afb..d615b2c 100644 --- a/server/screener/assets/Bond.js +++ b/server/screener/assets/Bond.ts @@ -1,22 +1,40 @@ import { CREDIT_RATING_SCALE } from '../../config/ScoringConfig.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 { - constructor(data) { + metrics: BondMetrics; + + constructor(data: BondData) { super(data); const creditRating = data.creditRating || 'BBB'; const creditRatingNumeric = CREDIT_RATING_SCALE[creditRating] ?? 7; this.metrics = { - ytm: parseFloat(data.yieldToMaturity) || 0, - duration: parseFloat(data.duration) || 0, + ytm: parseFloat(String(data.yieldToMaturity)) || 0, + duration: parseFloat(String(data.duration)) || 0, creditRating, creditRatingNumeric, }; } - getDisplayMetrics() { + getDisplayMetrics(): Record { return { Ticker: this.ticker, Type: 'BOND', diff --git a/server/screener/assets/Etf.js b/server/screener/assets/Etf.js deleted file mode 100644 index 1096e95..0000000 --- a/server/screener/assets/Etf.js +++ /dev/null @@ -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)}%`, - }; - } -} diff --git a/server/screener/assets/Etf.ts b/server/screener/assets/Etf.ts new file mode 100644 index 0000000..6d36b5f --- /dev/null +++ b/server/screener/assets/Etf.ts @@ -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 { + 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)}%`, + }; + } +} diff --git a/server/screener/assets/Stock.js b/server/screener/assets/Stock.ts similarity index 68% rename from server/screener/assets/Stock.js rename to server/screener/assets/Stock.ts index 5da38cc..f09d64b 100644 --- a/server/screener/assets/Stock.js +++ b/server/screener/assets/Stock.ts @@ -1,48 +1,86 @@ 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 { - constructor(data) { + sector: Sector; + metrics: StockMetrics; + + constructor(data: StockData) { super(data); - // console.log('Data:', data); - this.sector = this._mapToStandardSector(data || {}); + this.sector = this._mapToStandardSector(data); this.metrics = { sector: this.sector, - // Valuation peRatio: data.peRatio ?? null, pegRatio: data.pegRatio ?? null, priceToBook: data.priceToBook ?? null, - // Profitability netProfitMargin: data.netProfitMargin ?? null, operatingMargin: data.operatingMargin ?? null, returnOnEquity: data.returnOnEquity ?? null, - // Growth revenueGrowth: data.revenueGrowth ?? null, earningsGrowth: data.earningsGrowth ?? null, - // Financial health debtToEquity: data.debtToEquity ?? null, quickRatio: data.quickRatio ?? null, - // Cash flow fcfYield: data.fcfYield ?? null, pFFO: data.pFFO ?? null, - // Income dividendYield: data.dividendYield ?? null, - // Risk & momentum beta: data.beta ?? null, week52High: data.week52High ?? null, week52Low: data.week52Low ?? null, - currentPrice: data.currentPrice ?? 0, + currentPrice: (data.currentPrice as number) || 0, }; } - _mapToStandardSector(data) { - const profile = data.assetProfile || {}; + _mapToStandardSector(data: StockData): Sector { + const profile = data.assetProfile ?? {}; const industry = (profile.industry || '').toLowerCase(); const sector = (profile.sector || '').toLowerCase(); const combined = `${industry} ${sector}`; - // Yahoo Finance sector/industry strings mapped to our internal sector constants. - // Order matters — more specific matches first. if ( combined.includes('technology') || combined.includes('electronic') || @@ -72,7 +110,6 @@ export class Stock extends Asset { combined.includes('medical') ) return 'HEALTHCARE'; - // Yahoo calls this "Communication Services" — covers META, GOOGL, NFLX, DIS, T if ( combined.includes('communication') || combined.includes('media') || @@ -100,20 +137,22 @@ export class Stock extends Asset { return 'GENERAL'; } - getDisplayMetrics() { - const fmt = (v, dec = 1, suffix = '') => (v != null ? `${v.toFixed(dec)}${suffix}` : null); + getDisplayMetrics(): Record { + const fmt = (v: number | null, dec = 1, suffix = '') => + v != null ? `${v.toFixed(dec)}${suffix}` : null; const m = this.metrics; + 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) + '%' : null; - // Only include fields that have actual data — null fields are omitted - const display = { + const display: Record = { Ticker: this.ticker, Price: this.formatCurrency(this.currentPrice), Sector: this.sector, }; + if (m.peRatio != null) display['P/E'] = fmt(m.peRatio, 1); if (m.pegRatio != null) display['PEG'] = fmt(m.pegRatio, 2); if (m.priceToBook != null) display['P/B'] = fmt(m.priceToBook, 2); diff --git a/server/screener/scorers/BondScorer.js b/server/screener/scorers/BondScorer.ts similarity index 56% rename from server/screener/scorers/BondScorer.js rename to server/screener/scorers/BondScorer.ts index a29f03d..9aa2e77 100644 --- a/server/screener/scorers/BondScorer.js +++ b/server/screener/scorers/BondScorer.ts @@ -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; +} + export const BondScorer = { - score(m, rules, context) { + score( + m: BondMetrics, + rules: { + gates: Record; + weights: Record; + thresholds: Record; + }, + context?: MarketContext | null, + ): ScoreOutput { const { gates, weights, thresholds } = rules; const metrics = this._sanitize(m); 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 breakdown = { + const breakdown: Record = { spread: spreadPct >= thresholds.minSpread ? weights.yieldSpread : -2, duration: metrics.duration <= thresholds.maxDuration ? weights.duration : -1, }; @@ -28,11 +51,12 @@ export const BondScorer = { }; }, - _sanitize(m) { - const pct = (v) => parseFloat(typeof v === 'string' ? v.replace('%', '') : v) / 100 || 0; + _sanitize(m: BondMetrics): SanitizedBondMetrics { + const pct = (v: unknown): number => + parseFloat(typeof v === 'string' ? v.replace('%', '') : String(v)) / 100 || 0; return { ytm: pct(m.ytm), - duration: parseFloat(m.duration) || 0, + duration: parseFloat(String(m.duration)) || 0, creditRating: m.creditRating || 'BBB', creditRatingNumeric: m.creditRatingNumeric ?? 7, }; diff --git a/server/screener/scorers/EtfScorer.js b/server/screener/scorers/EtfScorer.ts similarity index 57% rename from server/screener/scorers/EtfScorer.js rename to server/screener/scorers/EtfScorer.ts index 87bf1db..a5d4b3f 100644 --- a/server/screener/scorers/EtfScorer.js +++ b/server/screener/scorers/EtfScorer.ts @@ -1,22 +1,36 @@ +import type { EtfMetrics } from '../assets/Etf.js'; + +interface ScoreOutput { + label: string; + scoreSummary: string; + audit?: Record; +} + export const EtfScorer = { - score(m, rules) { + score( + m: EtfMetrics, + rules: { + gates: Record; + weights: Record; + thresholds: Record; + }, + ): ScoreOutput { const { gates, weights, thresholds } = rules; const metrics = { - expenseRatio: parseFloat(m.expenseRatio) || 0, - yield: parseFloat(m.yield) || 0, - volume: parseFloat(m.volume) || 0, - fiveYearReturn: parseFloat(m.fiveYearReturn) || 0, + expenseRatio: parseFloat(String(m.expenseRatio)) || 0, + yield: parseFloat(String(m.yield)) || 0, + volume: parseFloat(String(m.volume)) || 0, + fiveYearReturn: parseFloat(String(m.fiveYearReturn)) || 0, }; if (metrics.expenseRatio > gates.maxExpenseRatio) { return { label: '🔴 REJECT', scoreSummary: 'Gate failed: High Expense Ratio' }; } - const breakdown = { + const breakdown: Record = { cost: metrics.expenseRatio <= thresholds.maxExpense ? weights.lowCost : -3, yield: metrics.yield >= thresholds.minYield ? weights.yield : -1, - vol: metrics.volume >= (thresholds.minVolume ?? 1000000) ? 0 : -2, - // 5Y return: strong long-term performance vs the ~10% S&P average is rewarded + vol: metrics.volume >= (thresholds.minVolume ?? 1_000_000) ? 0 : -2, fiveYearReturn: thresholds.minFiveYearReturn != null ? metrics.fiveYearReturn >= thresholds.minFiveYearReturn diff --git a/server/screener/scorers/StockScorer.js b/server/screener/scorers/StockScorer.ts similarity index 65% rename from server/screener/scorers/StockScorer.js rename to server/screener/scorers/StockScorer.ts index f45c4c1..ef35bdc 100644 --- a/server/screener/scorers/StockScorer.js +++ b/server/screener/scorers/StockScorer.ts @@ -1,15 +1,51 @@ import { SIGNAL } from '../../config/constants.js'; +import type { StockMetrics } from '../assets/Stock.js'; -const n = (v) => { - const f = parseFloat(v); +type NumVal = number | null; + +const n = (v: unknown): NumVal => { + const f = parseFloat(String(v)); return !isNaN(f) && f !== 0 ? f : null; }; -const scoreValue = (val, high, med, weight) => (val >= high ? weight : val >= med ? 1 : -1); -const scorePeg = (val, high, med, weight) => (val <= high ? weight : val <= med ? 1 : -1); +const scoreValue = (val: number, high: number, med: number, weight: number): number => + 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; +} export const StockScorer = { - score(metrics, rules) { + score( + metrics: StockMetrics, + rules: { + gates: Record; + weights: Record; + thresholds: Record; + }, + ): ScoreOutput { const { gates, weights, thresholds } = rules; const m = this._sanitize(metrics); @@ -30,7 +66,7 @@ export const StockScorer = { gates.maxPriceToBook && m.priceToBook > gates.maxPriceToBook && `P/B ${m.priceToBook.toFixed(1)} > ${gates.maxPriceToBook}`, - ].filter(Boolean); + ].filter(Boolean) as string[]; if (failures.length > 0) { return { @@ -44,14 +80,14 @@ export const StockScorer = { { key: 'roe', 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', active: weights.opMargin > 0 && m.operatingMargin != null, fn: () => scoreValue( - m.operatingMargin, + m.operatingMargin!, thresholds.opMarginHigh, thresholds.opMarginMed, weights.opMargin, @@ -62,7 +98,7 @@ export const StockScorer = { active: weights.margin > 0 && m.netProfitMargin != null, fn: () => scoreValue( - m.netProfitMargin, + m.netProfitMargin!, thresholds.marginHigh, thresholds.marginMed, weights.margin, @@ -71,41 +107,41 @@ export const StockScorer = { { key: 'peg', 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', active: weights.revenue > 0 && m.revenueGrowth != null, fn: () => - scoreValue(m.revenueGrowth, thresholds.revHigh, thresholds.revMed, weights.revenue), + scoreValue(m.revenueGrowth!, thresholds.revHigh, thresholds.revMed, weights.revenue), }, { key: 'fcf', active: weights.fcf > 0 && m.fcfYield != null, 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', 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', 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', 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 = {}; const totalScore = factors.reduce((sum, f) => { if (!f.active) return sum; - breakdown[f.key] = f.fn(); + breakdown[f.key] = f.fn() as number; return sum + breakdown[f.key]; }, 0); @@ -116,7 +152,7 @@ export const StockScorer = { m.week52Position != null && m.week52Position < 0.1 && 'Near 52-week low — potential opportunity', - ].filter(Boolean); + ].filter(Boolean) as string[]; return { 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 >= 4) return '🟢 BUY (Speculative)'; if (score >= 0) return '🟡 HOLD'; return '🔴 REJECT'; }, - _sanitize(m) { + _sanitize(m: StockMetrics): SanitizedMetrics { 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) : null; return { diff --git a/server/server/app.js b/server/server/app.ts similarity index 64% rename from server/server/app.js rename to server/server/app.ts index 8d4878c..f1024cf 100644 --- a/server/server/app.js +++ b/server/server/app.ts @@ -7,18 +7,22 @@ import { YahooClient } from '../market/YahooClient.js'; import { LLMAnalyst } from '../analyst/LLMAnalyst.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 }); await app.register(cors, { origin: process.env.CLIENT_ORIGIN ?? 'http://localhost:5173', }); - await app.register(screenerRoutes); - await app.register(financeRoutes); - await app.register(callsRoutes); + await app.register(screenerRoutes as any); + await app.register(financeRoutes as any); + 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', { schema: { 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) { 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 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( - tickers.slice(0, 10).map(async (ticker) => { + tickers.slice(0, 10).map(async (ticker: string) => { try { - const { news = [] } = await client.yf.search(ticker, { newsCount: 3, quotesCount: 0 }); - for (const s of news) { + const { news = [] } = await (client as any).yf.search(ticker, { + newsCount: 3, + quotesCount: 0, + }); + for (const s of news as any[]) { if (!seen.has(s.title)) { seen.set(s.title, { title: s.title, @@ -60,10 +70,7 @@ export async function buildApp({ logger = true } = {}) { ); 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); return { analysis }; diff --git a/server/server/routes/calls.js b/server/server/routes/calls.ts similarity index 62% rename from server/server/routes/calls.js rename to server/server/routes/calls.ts index d92dcbb..24e7ef6 100644 --- a/server/server/routes/calls.js +++ b/server/server/routes/calls.ts @@ -3,10 +3,20 @@ import { ScreenerEngine } from '../../screener/ScreenerEngine.js'; import { YahooClient } from '../../market/YahooClient.js'; import { chunkArray } from '../../screener/Chunker.js'; import { noopLogger } from '../utils/logger.js'; + const store = new MarketCallStore(); -// Takes a screener result entry and flattens it to a snapshot record -const toSnapshot = (r) => { +interface SnapshotEntry { + 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; const m = r.asset?.displayMetrics ?? r.asset?.getDisplayMetrics?.() ?? {}; return { @@ -20,36 +30,31 @@ const toSnapshot = (r) => { }; }; -export default async function callsRoutes(app) { - // GET /api/calls — list all market calls (newest first) - app.get('/api/calls', async () => { - return { calls: store.list() }; - }); +export default async function callsRoutes(app: any) { + // GET /api/calls + app.get('/api/calls', async () => ({ calls: store.list() })); - // GET /api/calls/:id — get one call + enrich with current prices for comparison - app.get('/api/calls/:id', async (req, reply) => { - const call = store.get(req.params.id); + // GET /api/calls/:id + app.get('/api/calls/:id', async (req: any, reply: any) => { + const call = store.get((req.params as { id: string }).id); if (!call) return reply.code(404).send({ error: 'Call not found' }); - // Re-screen the tickers to get current prices for comparison - let current = {}; + const current: Record = {}; if (call.tickers.length > 0) { try { const engine = new ScreenerEngine({ logger: noopLogger }); const results = await engine.screenTickers(call.tickers); - const all = [...results.STOCK, ...results.ETF, ...results.BOND]; - for (const r of all) { + for (const r of [...results.STOCK, ...results.ETF, ...results.BOND]) { current[r.asset.ticker] = toSnapshot(r); } } catch { - // Non-fatal — return call without current prices + /* non-fatal */ } } - return { ...call, current }; }); - // POST /api/calls — create a new market call and snapshot current prices + // POST /api/calls app.post('/api/calls', { schema: { body: { @@ -64,58 +69,64 @@ export default async function callsRoutes(app) { }, }, }, - handler: async (req, reply) => { - const { title, quarter, date, thesis, tickers } = req.body; - const upperTickers = tickers.map((t) => t.toUpperCase()); + handler: async (req: any, reply: any) => { + const { title, quarter, date, thesis, tickers } = req.body as { + 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 - let snapshot = {}; + const snapshot: Record = {}; try { const engine = new ScreenerEngine({ logger: noopLogger }); const results = await engine.screenTickers(upperTickers); - const all = [...results.STOCK, ...results.ETF, ...results.BOND]; - for (const r of all) { + for (const r of [...results.STOCK, ...results.ETF, ...results.BOND]) { snapshot[r.asset.ticker] = toSnapshot(r); } } 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); }, }); // DELETE /api/calls/:id - app.delete('/api/calls/:id', async (req, reply) => { - const deleted = store.delete(req.params.id); + app.delete('/api/calls/:id', async (req: any, reply: any) => { + const deleted = store.delete((req.params as { id: string }).id); if (!deleted) return reply.code(404).send({ error: 'Call not found' }); return { ok: true }; }); - // GET /api/calls/calendar?tickers=AAPL,MSFT (or omit to use all call tickers) - // Returns upcoming earnings dates, ex-dividend dates and dividend dates per ticker. - // Fetched in parallel batches of 5 with rate-limit delay. - app.get('/api/calls/calendar', async (req) => { + // GET /api/calls/calendar + app.get('/api/calls/calendar', async (req: any) => { const client = new YahooClient(); - // Resolve tickers: from query param, or aggregate all unique tickers across all calls - let tickers; - if (req.query.tickers) { - tickers = req.query.tickers + let tickers: string[]; + if ((req.query as any).tickers) { + tickers = String((req.query as any).tickers) .split(',') .map((t) => t.trim().toUpperCase()) .filter(Boolean); } else { - const allCalls = store.list(); - const set = new Set(allCalls.flatMap((c) => c.tickers)); + const set = new Set(store.list().flatMap((c) => c.tickers)); tickers = [...set]; } if (tickers.length === 0) return { events: [] }; - // Fetch calendarEvents in parallel batches - const results = {}; + const results: Record = {}; for (const batch of chunkArray(tickers, 5)) { await Promise.all( batch.map(async (ticker) => { @@ -123,17 +134,15 @@ export default async function callsRoutes(app) { if (cal) results[ticker] = cal; }), ); - await new Promise((r) => setTimeout(r, 500)); + await new Promise((r) => setTimeout(r, 500)); } - // Flatten into a sorted event list - const events = []; + const events: any[] = []; const now = Date.now(); for (const [ticker, cal] of Object.entries(results)) { - // Upcoming earnings dates for (const dateVal of cal.earnings?.earningsDate ?? []) { - const d = new Date(dateVal); + const d = new Date(dateVal as string); events.push({ ticker, type: 'earnings', @@ -145,8 +154,6 @@ export default async function callsRoutes(app) { isPast: d.getTime() < now, }); } - - // Ex-dividend date if (cal.exDividendDate) { const d = new Date(cal.exDividendDate); events.push({ @@ -158,8 +165,6 @@ export default async function callsRoutes(app) { isPast: d.getTime() < now, }); } - - // Dividend payment date if (cal.dividendDate) { const d = new Date(cal.dividendDate); events.push({ @@ -173,12 +178,11 @@ export default async function callsRoutes(app) { } } - // Sort: upcoming first, then past events.sort((a, b) => { if (a.isPast !== b.isPast) return a.isPast ? 1 : -1; return a.isPast - ? new Date(b.date) - new Date(a.date) // most recent past first - : new Date(a.date) - new Date(b.date); // soonest upcoming first + ? new Date(b.date).getTime() - new Date(a.date).getTime() + : new Date(a.date).getTime() - new Date(b.date).getTime(); }); return { events, tickers }; diff --git a/server/server/routes/finance.js b/server/server/routes/finance.ts similarity index 70% rename from server/server/routes/finance.js rename to server/server/routes/finance.ts index 2724b32..9122b52 100644 --- a/server/server/routes/finance.js +++ b/server/server/routes/finance.ts @@ -4,19 +4,22 @@ import { PersonalFinanceAnalyzer } from '../../finance/PersonalFinanceAnalyzer.j import { PortfolioAdvisor } from '../../finance/PortfolioAdvisor.js'; import { SimpleFINClient } from '../../finance/clients/SimpleFINClient.js'; import { noopLogger } from '../utils/logger.js'; +import type { PortfolioHolding } from '../../types.js'; + 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 - // Returns: { advice, personalFinance, marketContext } - app.get('/api/finance/portfolio', async (req, reply) => { - if (!existsSync(PORTFOLIO_PATH)) { + app.get('/api/finance/portfolio', async (req: any, reply: any) => { + if (!existsSync(PORTFOLIO_PATH)) 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; if (process.env.SIMPLEFIN_ACCESS_URL) { const client = new SimpleFINClient({ logger: noopLogger }); @@ -24,9 +27,6 @@ export default async function financeRoutes(app) { 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 .filter((h) => (h.type ?? 'stock') !== 'crypto') .map((h) => normalizeYahoo(h.ticker)); @@ -35,16 +35,13 @@ export default async function financeRoutes(app) { const results = screenable.length > 0 ? await engine.screenTickers(screenable) - : { STOCK: [], ETF: [], BOND: [], ERROR: [], marketContext: {} }; + : { STOCK: [], ETF: [], BOND: [], ERROR: [], marketContext: {} as any }; const advice = await new PortfolioAdvisor().advise(holdings, results); - return { advice, personalFinance, marketContext: results.marketContext }; }); // POST /api/finance/holdings - // Add or update a single holding in portfolio.json. - // Body: { ticker, shares, costBasis, type, source } app.post('/api/finance/holdings', { schema: { body: { @@ -59,23 +56,25 @@ export default async function financeRoutes(app) { }, }, }, - handler: async (req, reply) => { - const { ticker, shares, costBasis = 0, type = 'stock', source = 'Manual' } = req.body; + handler: async (req: any, reply: any) => { + const { + ticker, + shares, + costBasis = 0, + type = 'stock', + source = 'Manual', + } = req.body as PortfolioHolding; const normalized = ticker.toUpperCase().trim(); const portfolio = existsSync(PORTFOLIO_PATH) - ? JSON.parse(readFileSync(PORTFOLIO_PATH, 'utf8')) - : { holdings: [] }; + ? (JSON.parse(readFileSync(PORTFOLIO_PATH, 'utf8')) as { holdings: PortfolioHolding[] }) + : { holdings: [] as PortfolioHolding[] }; 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; // update existing - } else { - portfolio.holdings.push(entry); // add new - } + if (idx >= 0) portfolio.holdings[idx] = entry; + else portfolio.holdings.push(entry); writeFileSync(PORTFOLIO_PATH, JSON.stringify(portfolio, null, 2), 'utf8'); return reply.code(201).send(entry); @@ -83,14 +82,14 @@ export default async function financeRoutes(app) { }); // DELETE /api/finance/holdings/:ticker - // Remove a holding from portfolio.json. - app.delete('/api/finance/holdings/:ticker', async (req, reply) => { - const ticker = req.params.ticker.toUpperCase(); - + app.delete('/api/finance/holdings/:ticker', async (req: any, reply: any) => { + const ticker = (req.params as { ticker: string }).ticker.toUpperCase(); if (!existsSync(PORTFOLIO_PATH)) 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; 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 - // Returns live benchmark data without running a full screen app.get('/api/finance/market-context', async () => { const engine = new ScreenerEngine({ logger: noopLogger }); - return engine.benchmarkProvider.getMarketContext(); + return engine['benchmarkProvider'].getMarketContext(); }); } diff --git a/server/server/routes/screener.js b/server/server/routes/screener.ts similarity index 57% rename from server/server/routes/screener.js rename to server/server/routes/screener.ts index 0fb2b22..65c3627 100644 --- a/server/server/routes/screener.js +++ b/server/server/routes/screener.ts @@ -1,9 +1,13 @@ import { ScreenerEngine } from '../../screener/ScreenerEngine.js'; import { noopLogger } from '../utils/logger.js'; +import type { AssetResult } from '../../types.js'; -// Class instances don't survive JSON.stringify — call getDisplayMetrics() on the -// server so the browser receives plain serializable objects. -const serializeAssets = (arr) => +type AnyAsset = AssetResult['asset'] & { + getDisplayMetrics: () => Record; + metrics: unknown; +}; + +const serializeAssets = (arr: (AssetResult & { asset: AnyAsset })[]) => arr.map((r) => ({ ...r, asset: { @@ -15,13 +19,9 @@ const serializeAssets = (arr) => }, })); -export default async function screenerRoutes(app) { - // Shared engine — BenchmarkProvider caches for 1 hour across requests. +export default async function screenerRoutes(app: any) { const engine = new ScreenerEngine({ logger: noopLogger }); - // POST /api/screen - // Body: { tickers: string[] } - // Returns: { STOCK, ETF, BOND, ERROR, marketContext } app.post('/api/screen', { schema: { body: { @@ -32,27 +32,24 @@ export default async function screenerRoutes(app) { }, }, }, - handler: async (req) => { - const tickers = req.body.tickers.map((t) => t.toUpperCase()); + handler: async (req: any) => { + const tickers = (req.body as { tickers: string[] }).tickers.map((t: string) => + t.toUpperCase(), + ); const results = await engine.screenTickers(tickers); return { ...results, - STOCK: serializeAssets(results.STOCK), - ETF: serializeAssets(results.ETF), - BOND: serializeAssets(results.BOND), + STOCK: serializeAssets(results.STOCK as any), + ETF: serializeAssets(results.ETF as any), + 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 () => { const { CatalystAnalyst } = await import('../../analyst/CatalystAnalyst.js'); - const catalyst = new CatalystAnalyst({ logger: noopLogger }); const { tickers, stories } = await catalyst.run(); - return { tickers, stories }; }); } diff --git a/server/server/utils/logger.js b/server/server/utils/logger.ts similarity index 81% rename from server/server/utils/logger.js rename to server/server/utils/logger.ts index 4e694d2..9a0a797 100644 --- a/server/server/utils/logger.js +++ b/server/server/utils/logger.ts @@ -1,3 +1,5 @@ +import type { Logger } from '../../types.js'; + /** * Shared server-side logger utilities. * @@ -6,7 +8,7 @@ * Pass as { logger: noopLogger } to ScreenerEngine, BenchmarkProvider, * CatalystAnalyst, SimpleFINClient, LLMAnalyst. */ -export const noopLogger = { +export const noopLogger: Logger = { write: () => {}, log: () => {}, warn: () => {}, diff --git a/server/types.ts b/server/types.ts new file mode 100644 index 0000000..86ca568 --- /dev/null +++ b/server/types.ts @@ -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; + weights: Record; + thresholds: Record; +} + +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; + }; + 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; +} + +// ── 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; +} diff --git a/tsconfig.json b/tsconfig.json new file mode 100644 index 0000000..0e4e9f0 --- /dev/null +++ b/tsconfig.json @@ -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"] +} diff --git a/ui/package-lock.json b/ui/package-lock.json index 0b6bda0..5f493eb 100644 --- a/ui/package-lock.json +++ b/ui/package-lock.json @@ -11,6 +11,7 @@ "@sveltejs/adapter-auto": "^3.0.0", "@sveltejs/kit": "^2.0.0", "@sveltejs/vite-plugin-svelte": "^4.0.0", + "@types/node": "^22.0.0", "sass": "^1.100.0", "svelte": "^5.0.0", "svelte-check": "^4.0.0", @@ -1248,6 +1249,16 @@ "integrity": "sha512-GhdPgy1el4/ImP05X05Uw4cw2/M93BCUmnEvWZNStlCzEKME4Fkk+YpoA5OiHNQmoS7Cafb8Xa3Pya8m1Qrzeg==", "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": { "version": "2.0.7", "resolved": "https://registry.npmjs.org/@types/trusted-types/-/trusted-types-2.0.7.tgz", @@ -1870,6 +1881,13 @@ "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": { "version": "6.4.3", "resolved": "https://registry.npmjs.org/vite/-/vite-6.4.3.tgz", diff --git a/ui/package.json b/ui/package.json index ad68b39..1b933eb 100644 --- a/ui/package.json +++ b/ui/package.json @@ -8,6 +8,7 @@ "preview": "vite preview" }, "devDependencies": { + "@types/node": "^22.0.0", "@sveltejs/adapter-auto": "^3.0.0", "@sveltejs/kit": "^2.0.0", "@sveltejs/vite-plugin-svelte": "^4.0.0", diff --git a/ui/src/lib/AnalysisSidebar.svelte b/ui/src/lib/AnalysisSidebar.svelte index 02cb75a..8fcf9bd 100644 --- a/ui/src/lib/AnalysisSidebar.svelte +++ b/ui/src/lib/AnalysisSidebar.svelte @@ -1,7 +1,8 @@ - {#if sidebar.open} diff --git a/ui/src/lib/AssetTable.svelte b/ui/src/lib/AssetTable.svelte index a36f361..27b4518 100644 --- a/ui/src/lib/AssetTable.svelte +++ b/ui/src/lib/AssetTable.svelte @@ -1,10 +1,21 @@ - {#if size === 'sm'} diff --git a/ui/src/lib/VerdictPill.svelte b/ui/src/lib/VerdictPill.svelte index f6c3aab..8630b16 100644 --- a/ui/src/lib/VerdictPill.svelte +++ b/ui/src/lib/VerdictPill.svelte @@ -1,6 +1,6 @@ - {verdictShort(label)} diff --git a/ui/src/lib/api.js b/ui/src/lib/api.ts similarity index 55% rename from ui/src/lib/api.js rename to ui/src/lib/api.ts index 60ace6b..d79da52 100644 --- a/ui/src/lib/api.js +++ b/ui/src/lib/api.ts @@ -1,6 +1,19 @@ +import type { + ScreenerResult, + MarketContext, + MarketCall, + CalendarEvent, + CatalystStory, + LLMAnalysis, + PortfolioHolding, + PortfolioAdvice, +} from '$lib/types.js'; + const BASE = '/api'; -export async function screenTickers(tickers) { +// ── Screener ────────────────────────────────────────────────────────────────── + +export async function screenTickers(tickers: string[]): Promise { const res = await fetch(`${BASE}/screen`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, @@ -10,13 +23,13 @@ export async function screenTickers(tickers) { return res.json(); } -export async function fetchCatalysts() { +export async function fetchCatalysts(): Promise<{ tickers: string[]; stories: CatalystStory[] }> { const res = await fetch(`${BASE}/screen/catalysts`); if (!res.ok) throw new Error(await res.text()); return res.json(); } -export async function analyzeTickers(tickers) { +export async function analyzeTickers(tickers: string[]): Promise<{ analysis: LLMAnalysis | null }> { const res = await fetch(`${BASE}/analyze`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, @@ -26,13 +39,23 @@ export async function analyzeTickers(tickers) { 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`); if (!res.ok) throw new Error(await res.text()); return res.json(); } -export async function addHolding(holding) { +export async function addHolding( + holding: Omit, +): Promise<{ holdings: PortfolioHolding[] }> { const res = await fetch(`${BASE}/finance/holdings`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, @@ -42,7 +65,7 @@ export async function addHolding(holding) { 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}`, { method: 'DELETE', }); @@ -50,7 +73,7 @@ export async function removeHolding(ticker) { return res.json(); } -export async function fetchMarketContext() { +export async function fetchMarketContext(): Promise { const res = await fetch(`${BASE}/finance/market-context`); if (!res.ok) throw new Error(await res.text()); return res.json(); @@ -58,19 +81,25 @@ export async function fetchMarketContext() { // ── Market Calls ────────────────────────────────────────────────────────────── -export async function fetchCalls() { +export async function fetchCalls(): Promise<{ calls: MarketCall[] }> { const res = await fetch(`${BASE}/calls`); if (!res.ok) throw new Error(await res.text()); return res.json(); } -export async function fetchCall(id) { +export async function fetchCall(id: string): Promise { const res = await fetch(`${BASE}/calls/${id}`); if (!res.ok) throw new Error(await res.text()); return res.json(); } -export async function createCall(payload) { +export async function createCall(payload: { + title: string; + quarter: string; + thesis: string; + tickers: string[]; + date?: string; +}): Promise { const res = await fetch(`${BASE}/calls`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, @@ -80,13 +109,15 @@ export async function createCall(payload) { 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' }); if (!res.ok) throw new Error(await res.text()); return res.json(); } -export async function fetchCallsCalendar(tickers = null) { +export async function fetchCallsCalendar( + tickers: string[] | null = null, +): Promise<{ events: CalendarEvent[] }> { const url = tickers?.length ? `${BASE}/calls/calendar?tickers=${tickers.join(',')}` : `${BASE}/calls/calendar`; diff --git a/ui/src/lib/types.ts b/ui/src/lib/types.ts new file mode 100644 index 0000000..9b481aa --- /dev/null +++ b/ui/src/lib/types.ts @@ -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; +} + +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; +} diff --git a/ui/src/routes/+layout.svelte b/ui/src/routes/+layout.svelte index 3bb573e..115a656 100644 --- a/ui/src/routes/+layout.svelte +++ b/ui/src/routes/+layout.svelte @@ -1,8 +1,9 @@ -