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