phase-2: extract shared utils

This commit is contained in:
Sai Kiran Vella
2026-06-04 11:06:30 -04:00
parent d87f0b8427
commit dc7ee22135
49 changed files with 299 additions and 120 deletions
+24 -18
View File
@@ -32,7 +32,7 @@ npm start -- AAPL MSFT VOO # CLI: specific tickers
npm run finance # CLI: portfolio advice + SimpleFIN → finance-report.html npm run finance # CLI: portfolio advice + SimpleFIN → finance-report.html
npm test # run all unit tests (node:test, zero external deps) npm test # run all unit tests (node:test, zero external deps)
npm run test:watch # watch mode — uses verbose spec reporter npm run test:watch # watch mode — uses verbose spec reporter
npm run format # format all src/bin/tests with Prettier npm run format # format all server/bin/tests with Prettier
npm run format:check # check formatting without writing (used in CI/pre-commit) npm run format:check # check formatting without writing (used in CI/pre-commit)
npm run ui:install # install UI dependencies (ui/ subdirectory) npm run ui:install # install UI dependencies (ui/ subdirectory)
``` ```
@@ -56,7 +56,7 @@ scripts/
prompts/ prompts/
catalyst-analysis.md ← daily catalyst analysis playbook (LLM prompt + workflow) catalyst-analysis.md ← daily catalyst analysis playbook (LLM prompt + workflow)
src/ server/
config/ config/
ScoringConfig.js ← CREDIT_RATING_SCALE + ScoringRules (single source of truth) ScoringConfig.js ← CREDIT_RATING_SCALE + ScoringRules (single source of truth)
constants.js ← SIGNAL, ASSET_TYPE, SECTOR, SCORE_MODE, REGIME, SIGNAL_ORDER constants.js ← SIGNAL, ASSET_TYPE, SECTOR, SCORE_MODE, REGIME, SIGNAL_ORDER
@@ -199,7 +199,7 @@ CORS is configured for `CLIENT_ORIGIN` env var (default `http://localhost:5173`)
## ScoringConfig Key Values ## ScoringConfig Key Values
`src/config/ScoringConfig.js` — single source of truth for all gates, weights, thresholds. `server/config/ScoringConfig.js` — single source of truth for all gates, weights, thresholds.
**STOCK base gates (Fundamental mode):** **STOCK base gates (Fundamental mode):**
- `maxPERatio: 15` — Graham's actual rule (trailing P/E) - `maxPERatio: 15` — Graham's actual rule (trailing P/E)
@@ -234,7 +234,7 @@ CORS is configured for `CLIENT_ORIGIN` env var (default `http://localhost:5173`)
## MarketRegime (INFLATED overrides) ## MarketRegime (INFLATED overrides)
`src/market/MarketRegime.js` derives gate overrides from live benchmarks and current rate regime: `server/market/MarketRegime.js` derives gate overrides from live benchmarks and current rate regime:
| Gate | Formula (NORMAL rates) | Formula (HIGH rates) | | Gate | Formula (NORMAL rates) | Formula (HIGH rates) |
|---|---|---| |---|---|---|
@@ -365,10 +365,10 @@ Test output: silent on pass, shows only failures + one summary line (`scripts/su
- Asset `type` (uppercased) is the routing key across DataMapper, asset classes, `SCORERS` map, and ScoringRules. - Asset `type` (uppercased) is the routing key across DataMapper, asset classes, `SCORERS` map, and ScoringRules.
- Prefer adjusting `ScoringConfig` or `MarketRegime` over hardcoding numbers in scorers. - Prefer adjusting `ScoringConfig` or `MarketRegime` over hardcoding numbers in scorers.
- BenchmarkProvider caches for 1 hour — restart the server to force a fresh fetch. - BenchmarkProvider caches for 1 hour — restart the server to force a fresh fetch.
- All entry points live in `bin/`. Do not add logic to entry points — they call into `src/`. - All entry points live in `bin/`. Do not add logic to entry points — they call into `server/`.
- `bin/server.js` starts Fastify; `src/server/` contains all route logic. - `bin/server.js` starts Fastify; `server/server/` contains all route logic.
- **Never** call `process.exit()` inside `src/` — only `bin/` may do that. - **Never** call `process.exit()` inside `server/` — only `bin/` may do that.
- Class instances don't survive `JSON.stringify`. Call `getDisplayMetrics()` server-side before returning from API routes (see `src/server/routes/screener.js` `serializeAssets()`). - Class instances don't survive `JSON.stringify`. Call `getDisplayMetrics()` server-side before returning from API routes (see `server/server/routes/screener.js` `serializeAssets()`).
--- ---
@@ -381,7 +381,7 @@ All items completed. Additional features delivered alongside cleanup:
**Cleanup done:** **Cleanup done:**
- Deleted root-level `finance.js`, `import-portfolio.js`, `markdown.md` - Deleted root-level `finance.js`, `import-portfolio.js`, `markdown.md`
- Deleted `src/server/routes/analyze.js` (orphaned route file) - Deleted `server/server/routes/analyze.js` (orphaned route file)
- Removed dead `analysis` state, `analysisOpen` state, and "🤖 AI Market Analysis" panel from `+page.svelte` - Removed dead `analysis` state, `analysisOpen` state, and "🤖 AI Market Analysis" panel from `+page.svelte`
- Fixed `.gitignore``portfolio.json`, `market-calls.json`, `.env` are now excluded from git - Fixed `.gitignore``portfolio.json`, `market-calls.json`, `.env` are now excluded from git
@@ -398,13 +398,19 @@ All items completed. Additional features delivered alongside cleanup:
**Pending (deferred to later):** **Pending (deferred to later):**
- LLM Analysis button on portfolio page (analyse holdings against current news) - LLM Analysis button on portfolio page (analyse holdings against current news)
### Phase 2 — Extract Shared Utilities ### Phase 2 — Extract Shared Utilities ✅ COMPLETE
- Create `ui/src/lib/utils.ts` with all pure functions currently duplicated across pages: `sigOrd`, `sorted`, `verdictShort`, `vClass`, `fmtPE`, `fmt`, `fmtShort`, `glClass`
- Create `src/server/utils/logger.js` with shared `noopLogger` constant (currently copy-pasted in `screener.js` and `app.js`)
### Phase 3 — Rename `src/` → `server/` **Done:**
- Rename the directory and update all import paths in `bin/`, internal routes, and `CLAUDE.md` - Created `ui/src/lib/utils.ts` — typed shared pure functions: `sigOrd`, `sorted`, `verdictShort`, `vClass`, `fmtPE`, `fmt`, `fmtShort`, `glClass`, `advClass`. Exports `Signal` type.
- Makes the API layer unambiguous — `src/` conventionally implies "all project source" - Created `server/server/utils/logger.js` — shared `noopLogger` constant, imported by `screener.js`, `app.js`, `finance.js`, and `calls.js`
- Added TypeScript support to `ui/``tsconfig.json` extending SvelteKit's generated config, `typescript` and `svelte-check` added as dev dependencies
- All three pages (`+page.svelte`, `safe-buys/+page.svelte`, `portfolio/+page.svelte`) now import from `$lib/utils.js` instead of duplicating logic
### Phase 3 — Rename `src/` → `server/` ✅ COMPLETE
**Done:**
- Renamed `src/` to `server/``src/server/` is now `server/server/`
- Updated all import paths in `bin/`, `tests/`, and `CLAUDE.md`
### Phase 4 — SCSS Migration ### Phase 4 — SCSS Migration
Replace per-component `<style>` blocks with a shared token system in `ui/src/styles/`: Replace per-component `<style>` blocks with a shared token system in `ui/src/styles/`:
@@ -455,10 +461,10 @@ SvelteKit supports TypeScript natively — components just need `<script lang="t
## Adding a New Asset Type ## Adding a New Asset Type
1. Create a subclass of `Asset` in `src/screener/assets/` with a flat `metrics` object and `getDisplayMetrics()`. 1. Create a subclass of `Asset` in `server/screener/assets/` with a flat `metrics` object and `getDisplayMetrics()`.
2. Add a per-type entry (`gates` / `weights` / `thresholds`) to `ScoringRules` in `ScoringConfig.js`. 2. Add a per-type entry (`gates` / `weights` / `thresholds`) to `ScoringRules` in `ScoringConfig.js`.
3. Add inflated overrides in `MarketRegime.getInflatedOverrides()`. 3. Add inflated overrides in `MarketRegime.getInflatedOverrides()`.
4. Create a Scorer in `src/screener/scorers/` exposing `score(metrics, rules, marketContext)`. 4. Create a Scorer in `server/screener/scorers/` exposing `score(metrics, rules, marketContext)`.
5. Add a mapper in `DataMapper.js`. 5. Add a mapper in `DataMapper.js`.
6. Wire into `ScreenerEngine`: add `case` in `_buildAsset`, entry in `SCORERS` map. 6. Wire into `ScreenerEngine`: add `case` in `_buildAsset`, entry in `SCORERS` map.
7. Add the new type to `serializeAssets()` handling in `src/server/routes/screener.js`. 7. Add the new type to `serializeAssets()` handling in `server/server/routes/screener.js`.
+5 -5
View File
@@ -13,11 +13,11 @@
import 'dotenv/config'; import 'dotenv/config';
import { readFileSync, existsSync } from 'fs'; import { readFileSync, existsSync } from 'fs';
import { SimpleFINClient, saveAccessUrlToEnv } from '../src/finance/clients/SimpleFINClient.js'; import { SimpleFINClient, saveAccessUrlToEnv } from '../server/finance/clients/SimpleFINClient.js';
import { PersonalFinanceAnalyzer } from '../src/finance/PersonalFinanceAnalyzer.js'; import { PersonalFinanceAnalyzer } from '../server/finance/PersonalFinanceAnalyzer.js';
import { PortfolioAdvisor } from '../src/finance/PortfolioAdvisor.js'; import { PortfolioAdvisor } from '../server/finance/PortfolioAdvisor.js';
import { ScreenerEngine } from '../src/screener/ScreenerEngine.js'; import { ScreenerEngine } from '../server/screener/ScreenerEngine.js';
import { FinanceReporter } from '../src/reporters/FinanceReporter.js'; import { FinanceReporter } from '../server/reporters/FinanceReporter.js';
const PORTFOLIO_PATH = './portfolio.json'; const PORTFOLIO_PATH = './portfolio.json';
+3 -3
View File
@@ -12,9 +12,9 @@
*/ */
import 'dotenv/config'; import 'dotenv/config';
import { CatalystAnalyst } from '../src/analyst/CatalystAnalyst.js'; import { CatalystAnalyst } from '../server/analyst/CatalystAnalyst.js';
import { ScreenerEngine } from '../src/screener/ScreenerEngine.js'; import { ScreenerEngine } from '../server/screener/ScreenerEngine.js';
import { HtmlReporter } from '../src/reporters/HtmlReporter.js'; import { HtmlReporter } from '../server/reporters/HtmlReporter.js';
const DEFAULT_WATCHLIST = [ const DEFAULT_WATCHLIST = [
// Stocks // Stocks
+1 -1
View File
@@ -1,5 +1,5 @@
import 'dotenv/config'; import 'dotenv/config';
import { buildApp } from '../src/server/app.js'; import { buildApp } from '../server/server/app.js';
const PORT = process.env.PORT ?? 3000; const PORT = process.env.PORT ?? 3000;
const HOST = process.env.HOST ?? '0.0.0.0'; const HOST = process.env.HOST ?? '0.0.0.0';
+1 -2
View File
@@ -5,8 +5,7 @@ import financeRoutes from './routes/finance.js';
import callsRoutes from './routes/calls.js'; import callsRoutes from './routes/calls.js';
import { YahooClient } from '../market/YahooClient.js'; import { YahooClient } from '../market/YahooClient.js';
import { LLMAnalyst } from '../analyst/LLMAnalyst.js'; import { LLMAnalyst } from '../analyst/LLMAnalyst.js';
import { noopLogger } from './utils/logger.js';
const noopLogger = { write: () => {}, log: () => {}, warn: () => {} };
export async function buildApp({ logger = true } = {}) { export async function buildApp({ logger = true } = {}) {
const app = Fastify({ logger }); const app = Fastify({ logger });
@@ -2,8 +2,7 @@ import { MarketCallStore } from '../../calls/MarketCallStore.js';
import { ScreenerEngine } from '../../screener/ScreenerEngine.js'; 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';
const noopLogger = { write: () => {}, log: () => {}, warn: () => {} };
const store = new MarketCallStore(); const store = new MarketCallStore();
// Takes a screener result entry and flattens it to a snapshot record // Takes a screener result entry and flattens it to a snapshot record
@@ -3,8 +3,7 @@ import { ScreenerEngine } from '../../screener/ScreenerEngine.js';
import { PersonalFinanceAnalyzer } from '../../finance/PersonalFinanceAnalyzer.js'; import { PersonalFinanceAnalyzer } from '../../finance/PersonalFinanceAnalyzer.js';
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';
const noopLogger = { write: () => {}, log: () => {}, warn: () => {} };
const PORTFOLIO_PATH = './portfolio.json'; const PORTFOLIO_PATH = './portfolio.json';
export default async function financeRoutes(app) { export default async function financeRoutes(app) {
@@ -1,6 +1,5 @@
import { ScreenerEngine } from '../../screener/ScreenerEngine.js'; import { ScreenerEngine } from '../../screener/ScreenerEngine.js';
import { noopLogger } from '../utils/logger.js';
const noopLogger = { write: () => {}, log: () => {}, warn: () => {} };
// Class instances don't survive JSON.stringify — call getDisplayMetrics() on the // Class instances don't survive JSON.stringify — call getDisplayMetrics() on the
// server so the browser receives plain serializable objects. // server so the browser receives plain serializable objects.
+13
View File
@@ -0,0 +1,13 @@
/**
* Shared server-side logger utilities.
*
* noopLogger — silent logger for use in API server context where stdout
* output from screener/analyst classes would pollute the request log.
* Pass as { logger: noopLogger } to ScreenerEngine, BenchmarkProvider,
* CatalystAnalyst, SimpleFINClient, LLMAnalyst.
*/
export const noopLogger = {
write: () => {},
log: () => {},
warn: () => {},
};
+1 -1
View File
@@ -1,6 +1,6 @@
import { test } from 'node:test'; import { test } from 'node:test';
import assert from 'node:assert/strict'; import assert from 'node:assert/strict';
import { BondScorer } from '../src/screener/scorers/BondScorer.js'; import { BondScorer } from '../server/screener/scorers/BondScorer.js';
// ytm is stored as a percentage value (e.g. 6.5 = 6.5%), matching how DataMapper outputs it. // ytm is stored as a percentage value (e.g. 6.5 = 6.5%), matching how DataMapper outputs it.
// BondScorer._sanitize divides by 100 to convert to decimal before spread calculation. // BondScorer._sanitize divides by 100 to convert to decimal before spread calculation.
+1 -1
View File
@@ -1,6 +1,6 @@
import { test } from 'node:test'; import { test } from 'node:test';
import assert from 'node:assert/strict'; import assert from 'node:assert/strict';
import { mapToStandardFormat } from '../src/screener/DataMapper.js'; import { mapToStandardFormat } from '../server/screener/DataMapper.js';
const base = { const base = {
price: { quoteType: 'EQUITY', regularMarketPrice: 150 }, price: { quoteType: 'EQUITY', regularMarketPrice: 150 },
+1 -1
View File
@@ -1,6 +1,6 @@
import { test } from 'node:test'; import { test } from 'node:test';
import assert from 'node:assert/strict'; import assert from 'node:assert/strict';
import { EtfScorer } from '../src/screener/scorers/EtfScorer.js'; import { EtfScorer } from '../server/screener/scorers/EtfScorer.js';
const rules = { const rules = {
gates: { maxExpenseRatio: 0.5 }, gates: { maxExpenseRatio: 0.5 },
+2 -2
View File
@@ -1,7 +1,7 @@
import { test } from 'node:test'; import { test } from 'node:test';
import assert from 'node:assert/strict'; import assert from 'node:assert/strict';
import { MarketRegime } from '../src/market/MarketRegime.js'; import { MarketRegime } from '../server/market/MarketRegime.js';
import { SECTOR, ASSET_TYPE } from '../src/config/constants.js'; import { SECTOR, ASSET_TYPE } from '../server/config/constants.js';
const regime = (benchmarks, extra = {}) => new MarketRegime({ benchmarks, ...extra }); const regime = (benchmarks, extra = {}) => new MarketRegime({ benchmarks, ...extra });
+2 -2
View File
@@ -1,7 +1,7 @@
import { test } from 'node:test'; import { test } from 'node:test';
import assert from 'node:assert/strict'; import assert from 'node:assert/strict';
import { PortfolioAdvisor } from '../src/finance/PortfolioAdvisor.js'; import { PortfolioAdvisor } from '../server/finance/PortfolioAdvisor.js';
import { SIGNAL } from '../src/config/constants.js'; import { SIGNAL } from '../server/config/constants.js';
const advisor = new PortfolioAdvisor(); const advisor = new PortfolioAdvisor();
+2 -2
View File
@@ -1,7 +1,7 @@
import { test } from 'node:test'; import { test } from 'node:test';
import assert from 'node:assert/strict'; import assert from 'node:assert/strict';
import { RuleMerger } from '../src/screener/RuleMerger.js'; import { RuleMerger } from '../server/screener/RuleMerger.js';
import { SCORE_MODE } from '../src/config/constants.js'; import { SCORE_MODE } from '../server/config/constants.js';
const ctx = { const ctx = {
benchmarks: { marketPE: 25, techPE: 32, reitYield: 3.8, igSpread: 1.2 }, benchmarks: { marketPE: 25, techPE: 32, reitYield: 3.8, igSpread: 1.2 },
+1 -1
View File
@@ -1,6 +1,6 @@
import { test } from 'node:test'; import { test } from 'node:test';
import assert from 'node:assert/strict'; import assert from 'node:assert/strict';
import { CREDIT_RATING_SCALE, ScoringRules } from '../src/config/ScoringConfig.js'; import { CREDIT_RATING_SCALE, ScoringRules } from '../server/config/ScoringConfig.js';
test('CREDIT_RATING_SCALE covers full spectrum', () => { test('CREDIT_RATING_SCALE covers full spectrum', () => {
assert.equal(CREDIT_RATING_SCALE.AAA, 10); assert.equal(CREDIT_RATING_SCALE.AAA, 10);
+1 -1
View File
@@ -1,6 +1,6 @@
import { test } from 'node:test'; import { test } from 'node:test';
import assert from 'node:assert/strict'; import assert from 'node:assert/strict';
import { StockScorer } from '../src/screener/scorers/StockScorer.js'; import { StockScorer } from '../server/screener/scorers/StockScorer.js';
const baseRules = { const baseRules = {
gates: { maxDebtToEquity: 3.0, minQuickRatio: 0.5, maxPERatio: 20, maxPegGate: 1.5 }, gates: { maxDebtToEquity: 3.0, minQuickRatio: 0.5, maxPERatio: 20, maxPegGate: 1.5 },
+104
View File
@@ -12,6 +12,8 @@
"@sveltejs/kit": "^2.0.0", "@sveltejs/kit": "^2.0.0",
"@sveltejs/vite-plugin-svelte": "^4.0.0", "@sveltejs/vite-plugin-svelte": "^4.0.0",
"svelte": "^5.0.0", "svelte": "^5.0.0",
"svelte-check": "^4.0.0",
"typescript": "^5.0.0",
"vite": "^6.0.0" "vite": "^6.0.0"
} }
}, },
@@ -875,6 +877,16 @@
} }
} }
}, },
"node_modules/@sveltejs/load-config": {
"version": "0.1.1",
"resolved": "https://registry.npmjs.org/@sveltejs/load-config/-/load-config-0.1.1.tgz",
"integrity": "sha512-BXXm+VOH/9X4N7Dd1iZ2MqA1h7M+9i2noI8QYuLDY8QcN2WHYn7D/VK/+IJNfcAmRw7ACNJ538UT9GXIhnBTiA==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">= 18.0.0"
}
},
"node_modules/@sveltejs/vite-plugin-svelte": { "node_modules/@sveltejs/vite-plugin-svelte": {
"version": "4.0.4", "version": "4.0.4",
"resolved": "https://registry.npmjs.org/@sveltejs/vite-plugin-svelte/-/vite-plugin-svelte-4.0.4.tgz", "resolved": "https://registry.npmjs.org/@sveltejs/vite-plugin-svelte/-/vite-plugin-svelte-4.0.4.tgz",
@@ -961,6 +973,22 @@
"node": ">= 0.4" "node": ">= 0.4"
} }
}, },
"node_modules/chokidar": {
"version": "4.0.3",
"resolved": "https://registry.npmjs.org/chokidar/-/chokidar-4.0.3.tgz",
"integrity": "sha512-Qgzu8kfBvo+cA4962jnP1KkS6Dop5NS6g7R5LFYJr4b8Ub94PPQXUksCw9PvXoeXPRRddRNC5C1JQUR2SMGtnA==",
"dev": true,
"license": "MIT",
"dependencies": {
"readdirp": "^4.0.1"
},
"engines": {
"node": ">= 14.16.0"
},
"funding": {
"url": "https://paulmillr.com/funding/"
}
},
"node_modules/clsx": { "node_modules/clsx": {
"version": "2.1.1", "version": "2.1.1",
"resolved": "https://registry.npmjs.org/clsx/-/clsx-2.1.1.tgz", "resolved": "https://registry.npmjs.org/clsx/-/clsx-2.1.1.tgz",
@@ -1149,6 +1177,16 @@
"@jridgewell/sourcemap-codec": "^1.5.5" "@jridgewell/sourcemap-codec": "^1.5.5"
} }
}, },
"node_modules/mri": {
"version": "1.2.0",
"resolved": "https://registry.npmjs.org/mri/-/mri-1.2.0.tgz",
"integrity": "sha512-tzzskb3bG8LvYGFF/mDTpq3jpI6Q9wc3LEmBaghu+DdCssd1FakN7Bc0hVNmEyGq1bq3RgfkCb3cmQLpNPOroA==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=4"
}
},
"node_modules/mrmime": { "node_modules/mrmime": {
"version": "2.0.1", "version": "2.0.1",
"resolved": "https://registry.npmjs.org/mrmime/-/mrmime-2.0.1.tgz", "resolved": "https://registry.npmjs.org/mrmime/-/mrmime-2.0.1.tgz",
@@ -1228,6 +1266,20 @@
"node": "^10 || ^12 || >=14" "node": "^10 || ^12 || >=14"
} }
}, },
"node_modules/readdirp": {
"version": "4.1.2",
"resolved": "https://registry.npmjs.org/readdirp/-/readdirp-4.1.2.tgz",
"integrity": "sha512-GDhwkLfywWL2s6vEjyhri+eXmfH6j1L7JE27WhqLeYzoh/A3DBaYGEj2H/HFZCn/kMfim73FXxEJTw06WtxQwg==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">= 14.18.0"
},
"funding": {
"type": "individual",
"url": "https://paulmillr.com/funding/"
}
},
"node_modules/rollup": { "node_modules/rollup": {
"version": "4.61.0", "version": "4.61.0",
"resolved": "https://registry.npmjs.org/rollup/-/rollup-4.61.0.tgz", "resolved": "https://registry.npmjs.org/rollup/-/rollup-4.61.0.tgz",
@@ -1272,6 +1324,19 @@
"fsevents": "~2.3.2" "fsevents": "~2.3.2"
} }
}, },
"node_modules/sade": {
"version": "1.8.1",
"resolved": "https://registry.npmjs.org/sade/-/sade-1.8.1.tgz",
"integrity": "sha512-xal3CZX1Xlo/k4ApwCFrHVACi9fBqJ7V+mwhBsuf/1IOKbBy098Fex+Wa/5QMubw09pSZ/u8EY8PWgevJsXp1A==",
"dev": true,
"license": "MIT",
"dependencies": {
"mri": "^1.1.0"
},
"engines": {
"node": ">=6"
}
},
"node_modules/set-cookie-parser": { "node_modules/set-cookie-parser": {
"version": "3.1.0", "version": "3.1.0",
"resolved": "https://registry.npmjs.org/set-cookie-parser/-/set-cookie-parser-3.1.0.tgz", "resolved": "https://registry.npmjs.org/set-cookie-parser/-/set-cookie-parser-3.1.0.tgz",
@@ -1328,6 +1393,31 @@
"node": ">=18" "node": ">=18"
} }
}, },
"node_modules/svelte-check": {
"version": "4.6.0",
"resolved": "https://registry.npmjs.org/svelte-check/-/svelte-check-4.6.0.tgz",
"integrity": "sha512-KhVnDFDSid57mmZtHz8gfW8AAGylOZ0vPnOIzVmAL+urzwK8sBYXRss953gD8T0OdgAQ11mdWhE6uadmtOz8TQ==",
"dev": true,
"license": "MIT",
"dependencies": {
"@jridgewell/trace-mapping": "^0.3.25",
"@sveltejs/load-config": "0.1.1",
"chokidar": "^4.0.1",
"fdir": "^6.2.0",
"picocolors": "^1.0.0",
"sade": "^1.7.4"
},
"bin": {
"svelte-check": "bin/svelte-check"
},
"engines": {
"node": ">= 18.0.0"
},
"peerDependencies": {
"svelte": "^4.0.0 || ^5.0.0-next.0",
"typescript": ">=5.0.0"
}
},
"node_modules/tinyglobby": { "node_modules/tinyglobby": {
"version": "0.2.17", "version": "0.2.17",
"resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.17.tgz", "resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.17.tgz",
@@ -1353,6 +1443,20 @@
"node": ">=6" "node": ">=6"
} }
}, },
"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/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",
+2
View File
@@ -12,6 +12,8 @@
"@sveltejs/kit": "^2.0.0", "@sveltejs/kit": "^2.0.0",
"@sveltejs/vite-plugin-svelte": "^4.0.0", "@sveltejs/vite-plugin-svelte": "^4.0.0",
"svelte": "^5.0.0", "svelte": "^5.0.0",
"svelte-check": "^4.0.0",
"typescript": "^5.0.0",
"vite": "^6.0.0" "vite": "^6.0.0"
} }
} }
+4 -1
View File
@@ -1,7 +1,10 @@
<script> <script>
import { untrack } from 'svelte';
let { ctx, collapsible = false } = $props(); let { ctx, collapsible = false } = $props();
let expanded = $state(!collapsible); // collapsed by default when collapsible=true // Read collapsible once for initial state — untrack avoids a reactive dep on the prop
let expanded = $state(untrack(() => !collapsible));
const cards = $derived.by(() => { const cards = $derived.by(() => {
const b = ctx?.benchmarks ?? {}; const b = ctx?.benchmarks ?? {};
+105
View File
@@ -0,0 +1,105 @@
/**
* Shared pure utility functions used across screener, portfolio, and safe-buys pages.
* All functions are stateless and framework-agnostic.
*/
// ── Signal ordering ───────────────────────────────────────────────────────────
export type Signal =
| '✅ Strong Buy'
| '⚡ Momentum'
| '🔄 Neutral'
| '⚠️ Speculation'
| '❌ Avoid';
const SIGNAL_ORDER: Record<string, number> = {
'✅ Strong Buy': 0,
'⚡ Momentum': 1,
'🔄 Neutral': 2,
'⚠️ Speculation': 3,
'❌ Avoid': 4,
};
/** Returns sort order for a signal string (lower = stronger). Unknown signals → 5. */
export function sigOrd(signal: string | null | undefined): number {
return SIGNAL_ORDER[signal ?? ''] ?? 5;
}
/** Sorts an array of screener result rows by signal strength (strongest first). */
export function sorted<T extends { signal?: string | null }>(arr: T[]): T[] {
return [...(arr ?? [])].sort((a, b) => sigOrd(a.signal) - sigOrd(b.signal));
}
// ── Verdict label helpers ─────────────────────────────────────────────────────
/**
* Converts a long verdict label into a short display string.
* e.g. "🟢 BUY (High Conviction)" → "Strong"
*/
export function verdictShort(label: string | null | undefined): string {
if (!label) return '—';
if (label.includes('High Conviction')) return 'Strong';
if (label.includes('Speculative')) return 'Speculative';
if (label.includes('BUY')) return 'Buy';
if (label.includes('Efficient')) return 'Efficient';
if (label.includes('Attractive')) return 'Attractive';
if (label.includes('Neutral')) return 'Hold';
if (label.includes('REJECT')) return 'Reject';
if (label.includes('Avoid')) return 'Avoid';
return label.replace(/[🟢🟡🔴]/u, '').trim();
}
/**
* Returns a CSS colour class ('green' | 'yellow' | 'red') based on
* the emoji prefix of a verdict label.
*/
export function vClass(label: string | null | undefined): 'green' | 'yellow' | 'red' {
if (label?.startsWith('🟢')) return 'green';
if (label?.startsWith('🟡')) return 'yellow';
return 'red';
}
// ── Number formatters ─────────────────────────────────────────────────────────
/** Formats a P/E ratio — e.g. 22.5 → "22.5x", null → "—" */
export function fmtPE(v: number | null | undefined): string {
return v != null ? v + 'x' : '—';
}
/** Full currency format — e.g. 1234.5 → "$1,234.50" */
export function fmt(n: number | null | undefined): string {
return n != null
? new Intl.NumberFormat('en-US', { style: 'currency', currency: 'USD' }).format(n)
: '—';
}
/** Compact currency format (no cents) — e.g. 1234.5 → "$1,235" */
export function fmtShort(n: number | null | undefined): string {
return n != null
? new Intl.NumberFormat('en-US', {
style: 'currency',
currency: 'USD',
maximumFractionDigits: 0,
}).format(n)
: '—';
}
/**
* Returns 'green' for non-negative G/L percentage, 'red' otherwise.
* Accepts string (e.g. "12.5") or number.
*/
export function glClass(pct: string | number | null | undefined): 'green' | 'red' {
return parseFloat(String(pct ?? 0)) >= 0 ? 'green' : 'red';
}
/**
* Returns a CSS colour class for a portfolio advice string based on its emoji prefix.
* 🟢 → 'green', 🟡 → 'yellow', 🟠 → 'orange', 🔴 → 'red', else 'gray'.
*/
export function advClass(advice: string | null | undefined): 'green' | 'yellow' | 'orange' | 'red' | 'gray' {
if (advice?.includes('🟢')) return 'green';
if (advice?.includes('🟡')) return 'yellow';
if (advice?.includes('🟠')) return 'orange';
if (advice?.includes('🔴')) return 'red';
return 'gray';
}
+3 -23
View File
@@ -1,5 +1,6 @@
<script> <script>
import { screenTickers, fetchCatalysts, analyzeTickers } from '$lib/api.js'; import { screenTickers, fetchCatalysts, analyzeTickers } from '$lib/api.js';
import { sigOrd, sorted, verdictShort, vClass, fmtPE } from '$lib/utils.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';
@@ -72,25 +73,6 @@
} }
} }
const sigOrd = s => ({'✅ Strong Buy':0,'⚡ Momentum':1,'🔄 Neutral':2,'⚠️ Speculation':3,'❌ Avoid':4})[s] ?? 5;
const sorted = arr => [...(arr ?? [])].sort((a, b) => sigOrd(a.signal) - sigOrd(b.signal));
const verdictShort = label => {
if (!label) return '—';
if (label.includes('High Conviction')) return 'Strong';
if (label.includes('Speculative')) return 'Speculative';
if (label.includes('BUY')) return 'Buy';
if (label.includes('Efficient')) return 'Efficient';
if (label.includes('Attractive')) return 'Attractive';
if (label.includes('Neutral')) return 'Hold';
if (label.includes('REJECT')) return 'Reject';
if (label.includes('Avoid')) return 'Avoid';
return label.replace(/[🟢🟡🔴]/u, '').trim();
};
const vClass = label =>
label?.startsWith('🟢') ? 'green' : label?.startsWith('🟡') ? 'yellow' : 'red';
const getTab = type => activeTab[type] ?? 'inflated'; const getTab = type => activeTab[type] ?? 'inflated';
const setTab = (type, tab) => activeTab = { ...activeTab, [type]: tab }; const setTab = (type, tab) => activeTab = { ...activeTab, [type]: tab };
@@ -98,8 +80,6 @@
const allAssets = $derived(results const allAssets = $derived(results
? sorted([...results.STOCK, ...results.ETF, ...results.BOND]) ? sorted([...results.STOCK, ...results.ETF, ...results.BOND])
: []); : []);
const fmtPE = v => v != null ? v + 'x' : '—';
</script> </script>
<div class="page"> <div class="page">
@@ -340,8 +320,8 @@
<!-- ── LLM Analysis Sidebar ─────────────────────────────────────────────── --> <!-- ── LLM Analysis Sidebar ─────────────────────────────────────────────── -->
{#if sidebar.open} {#if sidebar.open}
<!-- svelte-ignore a11y_click_events_have_key_events a11y_no_static_element_interactions --> <div class="sidebar-backdrop" role="button" tabindex="-1" aria-label="Close sidebar"
<div class="sidebar-backdrop" onclick={closeSidebar}></div> onclick={closeSidebar} onkeydown={(e) => e.key === 'Escape' && closeSidebar()}></div>
<aside class="sidebar"> <aside class="sidebar">
<div class="sidebar-header"> <div class="sidebar-header">
<div class="sidebar-title"> <div class="sidebar-title">
+11 -34
View File
@@ -3,6 +3,7 @@
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';
let { data: _data } = $props(); // unused — we load client-side let { data: _data } = $props(); // unused — we load client-side
@@ -142,8 +143,6 @@
let sortCol = $state('ticker'); let sortCol = $state('ticker');
let sortDir = $state(1); // 1 = asc, -1 = desc let sortDir = $state(1); // 1 = asc, -1 = desc
const sigOrd = s => ({'✅ Strong Buy':0,'⚡ Momentum':1,'🔄 Neutral':2,'⚠️ Speculation':3,'❌ Avoid':4})[s] ?? 5;
function toggleSort(col) { function toggleSort(col) {
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; }
@@ -172,23 +171,6 @@
const sortIcon = (col) => sortCol !== col ? '⇅' : sortDir === 1 ? '↑' : '↓'; const sortIcon = (col) => sortCol !== col ? '⇅' : sortDir === 1 ? '↑' : '↓';
const fmt = (n) => n != null
? new Intl.NumberFormat('en-US', { style: 'currency', currency: 'USD' }).format(n)
: '—';
const fmtShort = (n) => n != null
? new Intl.NumberFormat('en-US', { style: 'currency', currency: 'USD', maximumFractionDigits: 0 }).format(n)
: '—';
const glClass = (pct) => parseFloat(pct) >= 0 ? 'green' : 'red';
const advClass = (a) => {
if (a?.includes('🟢')) return 'green';
if (a?.includes('🟡')) return 'yellow';
if (a?.includes('🟠')) return 'orange';
if (a?.includes('🔴')) return 'red';
return 'gray';
};
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);
const totalCost = $derived(data?.advice?.reduce((s, a) => s + (a.costBasis ?? 0) * a.shares, 0) ?? 0); const totalCost = $derived(data?.advice?.reduce((s, a) => s + (a.costBasis ?? 0) * a.shares, 0) ?? 0);
@@ -221,20 +203,20 @@
<div class="form-title">Add Holding</div> <div class="form-title">Add Holding</div>
<div class="form-row"> <div class="form-row">
<div class="field"> <div class="field">
<label>Ticker</label> <label for="form-ticker">Ticker</label>
<input bind:value={form.ticker} placeholder="AAPL" maxlength="10" style="text-transform:uppercase" /> <input id="form-ticker" bind:value={form.ticker} placeholder="AAPL" maxlength="10" style="text-transform:uppercase" />
</div> </div>
<div class="field"> <div class="field">
<label>Shares</label> <label for="form-shares">Shares</label>
<input bind:value={form.shares} placeholder="10" type="number" min="0" step="any" /> <input id="form-shares" bind:value={form.shares} placeholder="10" type="number" min="0" step="any" />
</div> </div>
<div class="field"> <div class="field">
<label>Cost Basis / share</label> <label for="form-cost">Cost Basis / share</label>
<input bind:value={form.costBasis} placeholder="150.00" type="number" min="0" step="any" /> <input id="form-cost" bind:value={form.costBasis} placeholder="150.00" type="number" min="0" step="any" />
</div> </div>
<div class="field"> <div class="field">
<label>Type</label> <label for="form-type">Type</label>
<select bind:value={form.type}> <select id="form-type" bind:value={form.type}>
<option value="stock">Stock</option> <option value="stock">Stock</option>
<option value="etf">ETF</option> <option value="etf">ETF</option>
<option value="bond">Bond</option> <option value="bond">Bond</option>
@@ -242,8 +224,8 @@
</select> </select>
</div> </div>
<div class="field"> <div class="field">
<label>Source</label> <label for="form-source">Source</label>
<input bind:value={form.source} placeholder="Robinhood" /> <input id="form-source" bind:value={form.source} placeholder="Robinhood" />
</div> </div>
<button class="btn-save" onclick={submitHolding} disabled={saving}> <button class="btn-save" onclick={submitHolding} disabled={saving}>
{saving ? 'Saving…' : 'Save'} {saving ? 'Saving…' : 'Save'}
@@ -569,11 +551,6 @@
margin-bottom: 14px; margin-bottom: 14px;
} }
.field input.readonly {
opacity: 0.5;
cursor: not-allowed;
}
.btn-cancel-edit { .btn-cancel-edit {
background: transparent; background: transparent;
border: 1px solid #2d3f55; border: 1px solid #2d3f55;
+1 -16
View File
@@ -1,6 +1,7 @@
<script> <script>
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 { sorted, verdictShort, vClass } from '$lib/utils.js';
let { data } = $props(); let { data } = $props();
@@ -14,22 +15,6 @@
const watchEtfs = $derived((data.ETF ?? []).filter(r => r.signal !== SIGNAL_STRONG)); const watchEtfs = $derived((data.ETF ?? []).filter(r => r.signal !== SIGNAL_STRONG));
const watchBonds = $derived((data.BOND ?? []).filter(r => r.signal !== SIGNAL_STRONG)); const watchBonds = $derived((data.BOND ?? []).filter(r => r.signal !== SIGNAL_STRONG));
const sigOrd = s => ({'✅ Strong Buy':0,'⚡ Momentum':1,'🔄 Neutral':2,'⚠️ Speculation':3,'❌ Avoid':4})[s] ?? 5;
const sorted = arr => [...arr].sort((a, b) => sigOrd(a.signal) - sigOrd(b.signal));
const vClass = label =>
label?.startsWith('🟢') ? 'green' : label?.startsWith('🟡') ? 'yellow' : 'red';
const verdictShort = label => {
if (!label) return '—';
if (label.includes('Efficient')) return 'Efficient';
if (label.includes('Attractive')) return 'Attractive';
if (label.includes('Neutral')) return 'Hold';
if (label.includes('REJECT')) return 'Reject';
if (label.includes('Avoid')) return 'Avoid';
return label.replace(/[🟢🟡🔴]/u, '').trim();
};
const totalScreened = $derived((data.ETF?.length ?? 0) + (data.BOND?.length ?? 0)); const totalScreened = $derived((data.ETF?.length ?? 0) + (data.BOND?.length ?? 0));
const totalStrong = $derived(strongEtfs.length + strongBonds.length); const totalStrong = $derived(strongEtfs.length + strongBonds.length);
</script> </script>
+8
View File
@@ -0,0 +1,8 @@
{
"extends": "./.svelte-kit/tsconfig.json",
"compilerOptions": {
"strict": true,
"allowJs": true,
"checkJs": false
}
}