phase-2: extract shared utils
This commit is contained in:
committed by
saikiranvella
parent
5a4b4aa6d1
commit
d5cf3fc31f
@@ -32,7 +32,7 @@ npm start -- AAPL MSFT VOO # CLI: specific tickers
|
||||
npm run finance # CLI: portfolio advice + SimpleFIN → finance-report.html
|
||||
npm test # run all unit tests (node:test, zero external deps)
|
||||
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 ui:install # install UI dependencies (ui/ subdirectory)
|
||||
```
|
||||
@@ -56,7 +56,7 @@ scripts/
|
||||
prompts/
|
||||
catalyst-analysis.md ← daily catalyst analysis playbook (LLM prompt + workflow)
|
||||
|
||||
src/
|
||||
server/
|
||||
config/
|
||||
ScoringConfig.js ← CREDIT_RATING_SCALE + ScoringRules (single source of truth)
|
||||
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
|
||||
|
||||
`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):**
|
||||
- `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)
|
||||
|
||||
`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) |
|
||||
|---|---|---|
|
||||
@@ -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.
|
||||
- Prefer adjusting `ScoringConfig` or `MarketRegime` over hardcoding numbers in scorers.
|
||||
- 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/`.
|
||||
- `bin/server.js` starts Fastify; `src/server/` contains all route logic.
|
||||
- **Never** call `process.exit()` inside `src/` — 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()`).
|
||||
- All entry points live in `bin/`. Do not add logic to entry points — they call into `server/`.
|
||||
- `bin/server.js` starts Fastify; `server/server/` contains all route logic.
|
||||
- **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 `server/server/routes/screener.js` `serializeAssets()`).
|
||||
|
||||
---
|
||||
|
||||
@@ -381,7 +381,7 @@ All items completed. Additional features delivered alongside cleanup:
|
||||
|
||||
**Cleanup done:**
|
||||
- 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`
|
||||
- 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):**
|
||||
- LLM Analysis button on portfolio page (analyse holdings against current news)
|
||||
|
||||
### Phase 2 — Extract Shared Utilities
|
||||
- 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 2 — Extract Shared Utilities ✅ COMPLETE
|
||||
|
||||
### Phase 3 — Rename `src/` → `server/`
|
||||
- Rename the directory and update all import paths in `bin/`, internal routes, and `CLAUDE.md`
|
||||
- Makes the API layer unambiguous — `src/` conventionally implies "all project source"
|
||||
**Done:**
|
||||
- Created `ui/src/lib/utils.ts` — typed shared pure functions: `sigOrd`, `sorted`, `verdictShort`, `vClass`, `fmtPE`, `fmt`, `fmtShort`, `glClass`, `advClass`. Exports `Signal` type.
|
||||
- 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
|
||||
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
|
||||
|
||||
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`.
|
||||
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`.
|
||||
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
@@ -13,11 +13,11 @@
|
||||
|
||||
import 'dotenv/config';
|
||||
import { readFileSync, existsSync } from 'fs';
|
||||
import { SimpleFINClient, saveAccessUrlToEnv } from '../src/finance/clients/SimpleFINClient.js';
|
||||
import { PersonalFinanceAnalyzer } from '../src/finance/PersonalFinanceAnalyzer.js';
|
||||
import { PortfolioAdvisor } from '../src/finance/PortfolioAdvisor.js';
|
||||
import { ScreenerEngine } from '../src/screener/ScreenerEngine.js';
|
||||
import { FinanceReporter } from '../src/reporters/FinanceReporter.js';
|
||||
import { SimpleFINClient, saveAccessUrlToEnv } from '../server/finance/clients/SimpleFINClient.js';
|
||||
import { PersonalFinanceAnalyzer } from '../server/finance/PersonalFinanceAnalyzer.js';
|
||||
import { PortfolioAdvisor } from '../server/finance/PortfolioAdvisor.js';
|
||||
import { ScreenerEngine } from '../server/screener/ScreenerEngine.js';
|
||||
import { FinanceReporter } from '../server/reporters/FinanceReporter.js';
|
||||
|
||||
const PORTFOLIO_PATH = './portfolio.json';
|
||||
|
||||
|
||||
+3
-3
@@ -12,9 +12,9 @@
|
||||
*/
|
||||
|
||||
import 'dotenv/config';
|
||||
import { CatalystAnalyst } from '../src/analyst/CatalystAnalyst.js';
|
||||
import { ScreenerEngine } from '../src/screener/ScreenerEngine.js';
|
||||
import { HtmlReporter } from '../src/reporters/HtmlReporter.js';
|
||||
import { CatalystAnalyst } from '../server/analyst/CatalystAnalyst.js';
|
||||
import { ScreenerEngine } from '../server/screener/ScreenerEngine.js';
|
||||
import { HtmlReporter } from '../server/reporters/HtmlReporter.js';
|
||||
|
||||
const DEFAULT_WATCHLIST = [
|
||||
// Stocks
|
||||
|
||||
+1
-1
@@ -1,5 +1,5 @@
|
||||
import 'dotenv/config';
|
||||
import { buildApp } from '../src/server/app.js';
|
||||
import { buildApp } from '../server/server/app.js';
|
||||
|
||||
const PORT = process.env.PORT ?? 3000;
|
||||
const HOST = process.env.HOST ?? '0.0.0.0';
|
||||
|
||||
@@ -5,8 +5,7 @@ import financeRoutes from './routes/finance.js';
|
||||
import callsRoutes from './routes/calls.js';
|
||||
import { YahooClient } from '../market/YahooClient.js';
|
||||
import { LLMAnalyst } from '../analyst/LLMAnalyst.js';
|
||||
|
||||
const noopLogger = { write: () => {}, log: () => {}, warn: () => {} };
|
||||
import { noopLogger } from './utils/logger.js';
|
||||
|
||||
export async function buildApp({ logger = true } = {}) {
|
||||
const app = Fastify({ logger });
|
||||
@@ -2,8 +2,7 @@ import { MarketCallStore } from '../../calls/MarketCallStore.js';
|
||||
import { ScreenerEngine } from '../../screener/ScreenerEngine.js';
|
||||
import { YahooClient } from '../../market/YahooClient.js';
|
||||
import { chunkArray } from '../../screener/Chunker.js';
|
||||
|
||||
const noopLogger = { write: () => {}, log: () => {}, warn: () => {} };
|
||||
import { noopLogger } from '../utils/logger.js';
|
||||
const store = new MarketCallStore();
|
||||
|
||||
// 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 { PortfolioAdvisor } from '../../finance/PortfolioAdvisor.js';
|
||||
import { SimpleFINClient } from '../../finance/clients/SimpleFINClient.js';
|
||||
|
||||
const noopLogger = { write: () => {}, log: () => {}, warn: () => {} };
|
||||
import { noopLogger } from '../utils/logger.js';
|
||||
const PORTFOLIO_PATH = './portfolio.json';
|
||||
|
||||
export default async function financeRoutes(app) {
|
||||
@@ -1,6 +1,5 @@
|
||||
import { ScreenerEngine } from '../../screener/ScreenerEngine.js';
|
||||
|
||||
const noopLogger = { write: () => {}, log: () => {}, warn: () => {} };
|
||||
import { noopLogger } from '../utils/logger.js';
|
||||
|
||||
// Class instances don't survive JSON.stringify — call getDisplayMetrics() on the
|
||||
// server so the browser receives plain serializable objects.
|
||||
@@ -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,6 +1,6 @@
|
||||
import { test } from 'node:test';
|
||||
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.
|
||||
// BondScorer._sanitize divides by 100 to convert to decimal before spread calculation.
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { test } from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { mapToStandardFormat } from '../src/screener/DataMapper.js';
|
||||
import { mapToStandardFormat } from '../server/screener/DataMapper.js';
|
||||
|
||||
const base = {
|
||||
price: { quoteType: 'EQUITY', regularMarketPrice: 150 },
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { test } from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { EtfScorer } from '../src/screener/scorers/EtfScorer.js';
|
||||
import { EtfScorer } from '../server/screener/scorers/EtfScorer.js';
|
||||
|
||||
const rules = {
|
||||
gates: { maxExpenseRatio: 0.5 },
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { test } from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { MarketRegime } from '../src/market/MarketRegime.js';
|
||||
import { SECTOR, ASSET_TYPE } from '../src/config/constants.js';
|
||||
import { MarketRegime } from '../server/market/MarketRegime.js';
|
||||
import { SECTOR, ASSET_TYPE } from '../server/config/constants.js';
|
||||
|
||||
const regime = (benchmarks, extra = {}) => new MarketRegime({ benchmarks, ...extra });
|
||||
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { test } from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { PortfolioAdvisor } from '../src/finance/PortfolioAdvisor.js';
|
||||
import { SIGNAL } from '../src/config/constants.js';
|
||||
import { PortfolioAdvisor } from '../server/finance/PortfolioAdvisor.js';
|
||||
import { SIGNAL } from '../server/config/constants.js';
|
||||
|
||||
const advisor = new PortfolioAdvisor();
|
||||
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { test } from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { RuleMerger } from '../src/screener/RuleMerger.js';
|
||||
import { SCORE_MODE } from '../src/config/constants.js';
|
||||
import { RuleMerger } from '../server/screener/RuleMerger.js';
|
||||
import { SCORE_MODE } from '../server/config/constants.js';
|
||||
|
||||
const ctx = {
|
||||
benchmarks: { marketPE: 25, techPE: 32, reitYield: 3.8, igSpread: 1.2 },
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { test } from 'node:test';
|
||||
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', () => {
|
||||
assert.equal(CREDIT_RATING_SCALE.AAA, 10);
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { test } from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { StockScorer } from '../src/screener/scorers/StockScorer.js';
|
||||
import { StockScorer } from '../server/screener/scorers/StockScorer.js';
|
||||
|
||||
const baseRules = {
|
||||
gates: { maxDebtToEquity: 3.0, minQuickRatio: 0.5, maxPERatio: 20, maxPegGate: 1.5 },
|
||||
|
||||
Generated
+104
@@ -12,6 +12,8 @@
|
||||
"@sveltejs/kit": "^2.0.0",
|
||||
"@sveltejs/vite-plugin-svelte": "^4.0.0",
|
||||
"svelte": "^5.0.0",
|
||||
"svelte-check": "^4.0.0",
|
||||
"typescript": "^5.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": {
|
||||
"version": "4.0.4",
|
||||
"resolved": "https://registry.npmjs.org/@sveltejs/vite-plugin-svelte/-/vite-plugin-svelte-4.0.4.tgz",
|
||||
@@ -961,6 +973,22 @@
|
||||
"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": {
|
||||
"version": "2.1.1",
|
||||
"resolved": "https://registry.npmjs.org/clsx/-/clsx-2.1.1.tgz",
|
||||
@@ -1149,6 +1177,16 @@
|
||||
"@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": {
|
||||
"version": "2.0.1",
|
||||
"resolved": "https://registry.npmjs.org/mrmime/-/mrmime-2.0.1.tgz",
|
||||
@@ -1228,6 +1266,20 @@
|
||||
"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": {
|
||||
"version": "4.61.0",
|
||||
"resolved": "https://registry.npmjs.org/rollup/-/rollup-4.61.0.tgz",
|
||||
@@ -1272,6 +1324,19 @@
|
||||
"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": {
|
||||
"version": "3.1.0",
|
||||
"resolved": "https://registry.npmjs.org/set-cookie-parser/-/set-cookie-parser-3.1.0.tgz",
|
||||
@@ -1328,6 +1393,31 @@
|
||||
"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": {
|
||||
"version": "0.2.17",
|
||||
"resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.17.tgz",
|
||||
@@ -1353,6 +1443,20 @@
|
||||
"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": {
|
||||
"version": "6.4.3",
|
||||
"resolved": "https://registry.npmjs.org/vite/-/vite-6.4.3.tgz",
|
||||
|
||||
@@ -12,6 +12,8 @@
|
||||
"@sveltejs/kit": "^2.0.0",
|
||||
"@sveltejs/vite-plugin-svelte": "^4.0.0",
|
||||
"svelte": "^5.0.0",
|
||||
"svelte-check": "^4.0.0",
|
||||
"typescript": "^5.0.0",
|
||||
"vite": "^6.0.0"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,7 +1,10 @@
|
||||
<script>
|
||||
import { untrack } from 'svelte';
|
||||
|
||||
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 b = ctx?.benchmarks ?? {};
|
||||
|
||||
@@ -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';
|
||||
}
|
||||
@@ -1,5 +1,6 @@
|
||||
<script>
|
||||
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 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 setTab = (type, tab) => activeTab = { ...activeTab, [type]: tab };
|
||||
|
||||
@@ -98,8 +80,6 @@
|
||||
const allAssets = $derived(results
|
||||
? sorted([...results.STOCK, ...results.ETF, ...results.BOND])
|
||||
: []);
|
||||
|
||||
const fmtPE = v => v != null ? v + 'x' : '—';
|
||||
</script>
|
||||
|
||||
<div class="page">
|
||||
@@ -340,8 +320,8 @@
|
||||
|
||||
<!-- ── LLM Analysis Sidebar ─────────────────────────────────────────────── -->
|
||||
{#if sidebar.open}
|
||||
<!-- svelte-ignore a11y_click_events_have_key_events a11y_no_static_element_interactions -->
|
||||
<div class="sidebar-backdrop" onclick={closeSidebar}></div>
|
||||
<div class="sidebar-backdrop" role="button" tabindex="-1" aria-label="Close sidebar"
|
||||
onclick={closeSidebar} onkeydown={(e) => e.key === 'Escape' && closeSidebar()}></div>
|
||||
<aside class="sidebar">
|
||||
<div class="sidebar-header">
|
||||
<div class="sidebar-title">
|
||||
|
||||
@@ -3,6 +3,7 @@
|
||||
import MarketContext from '$lib/MarketContext.svelte';
|
||||
import Spinner from '$lib/Spinner.svelte';
|
||||
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
|
||||
|
||||
@@ -142,8 +143,6 @@
|
||||
let sortCol = $state('ticker');
|
||||
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) {
|
||||
if (sortCol === col) sortDir = sortDir === 1 ? -1 : 1;
|
||||
else { sortCol = col; sortDir = 1; }
|
||||
@@ -172,23 +171,6 @@
|
||||
|
||||
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 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-row">
|
||||
<div class="field">
|
||||
<label>Ticker</label>
|
||||
<input bind:value={form.ticker} placeholder="AAPL" maxlength="10" style="text-transform:uppercase" />
|
||||
<label for="form-ticker">Ticker</label>
|
||||
<input id="form-ticker" bind:value={form.ticker} placeholder="AAPL" maxlength="10" style="text-transform:uppercase" />
|
||||
</div>
|
||||
<div class="field">
|
||||
<label>Shares</label>
|
||||
<input bind:value={form.shares} placeholder="10" type="number" min="0" step="any" />
|
||||
<label for="form-shares">Shares</label>
|
||||
<input id="form-shares" bind:value={form.shares} placeholder="10" type="number" min="0" step="any" />
|
||||
</div>
|
||||
<div class="field">
|
||||
<label>Cost Basis / share</label>
|
||||
<input bind:value={form.costBasis} placeholder="150.00" type="number" min="0" step="any" />
|
||||
<label for="form-cost">Cost Basis / share</label>
|
||||
<input id="form-cost" bind:value={form.costBasis} placeholder="150.00" type="number" min="0" step="any" />
|
||||
</div>
|
||||
<div class="field">
|
||||
<label>Type</label>
|
||||
<select bind:value={form.type}>
|
||||
<label for="form-type">Type</label>
|
||||
<select id="form-type" bind:value={form.type}>
|
||||
<option value="stock">Stock</option>
|
||||
<option value="etf">ETF</option>
|
||||
<option value="bond">Bond</option>
|
||||
@@ -242,8 +224,8 @@
|
||||
</select>
|
||||
</div>
|
||||
<div class="field">
|
||||
<label>Source</label>
|
||||
<input bind:value={form.source} placeholder="Robinhood" />
|
||||
<label for="form-source">Source</label>
|
||||
<input id="form-source" bind:value={form.source} placeholder="Robinhood" />
|
||||
</div>
|
||||
<button class="btn-save" onclick={submitHolding} disabled={saving}>
|
||||
{saving ? 'Saving…' : 'Save'}
|
||||
@@ -569,11 +551,6 @@
|
||||
margin-bottom: 14px;
|
||||
}
|
||||
|
||||
.field input.readonly {
|
||||
opacity: 0.5;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
.btn-cancel-edit {
|
||||
background: transparent;
|
||||
border: 1px solid #2d3f55;
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
<script>
|
||||
import MarketContext from '$lib/MarketContext.svelte';
|
||||
import SignalBadge from '$lib/SignalBadge.svelte';
|
||||
import { sorted, verdictShort, vClass } from '$lib/utils.js';
|
||||
|
||||
let { data } = $props();
|
||||
|
||||
@@ -14,22 +15,6 @@
|
||||
const watchEtfs = $derived((data.ETF ?? []).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 totalStrong = $derived(strongEtfs.length + strongBonds.length);
|
||||
</script>
|
||||
|
||||
@@ -0,0 +1,8 @@
|
||||
{
|
||||
"extends": "./.svelte-kit/tsconfig.json",
|
||||
"compilerOptions": {
|
||||
"strict": true,
|
||||
"allowJs": true,
|
||||
"checkJs": false
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user