From cd74497de6e86fdb0e8bd771a3ccdf91600f15e8 Mon Sep 17 00:00:00 2001 From: Kazuma Date: Wed, 3 Jun 2026 00:02:55 -0400 Subject: [PATCH] refactor: restructure to clean architecture fix: restore ScoringConfig improvements lost in refactor commit docs: rewrite README and CLAUDE.md to reflect current architecture code-format code fixes --- .env.example | 11 + .husky/pre-commit | 2 + .husky/pre-push | 1 + .prettierrc | 12 + CLAUDE.md | 231 ++++++ Market_News_Analysis.md | 46 -- README.md | 181 ++++- bin/finance.js | 84 +++ bin/import-portfolio.js | 36 + bin/screen.js | 83 +++ finance.js | 82 +++ import-portfolio.js | 34 + index.js | 32 - package-lock.json | 667 +++++++++++++++++- package.json | 24 +- prompts/catalyst-analysis.md | 165 +++++ scripts/summary-reporter.js | 37 + src/analyst/CatalystAnalyst.js | 52 ++ src/api/BenchmarkProvider.js | 45 -- src/config/ScoringConfig.js | 151 +++- src/config/constants.js | 48 ++ src/core/assets/Asset.js | 23 - src/core/assets/Stock.js | 60 -- src/core/engine/ScoringEngine.js | 49 -- src/core/engine/ScreenerEngine.js | 122 ---- src/core/scorers/BondScorer.js | 63 -- src/core/scorers/EtfScorer.js | 62 -- src/core/scorers/StockScorer.js | 107 --- src/finance/PersonalFinanceAnalyzer.js | 62 ++ src/finance/PortfolioAdvisor.js | 161 +++++ src/finance/PortfolioImporter.js | 249 +++++++ src/finance/SimpleFINClient.js | 187 +++++ src/finance/clients/SimpleFINClient.js | 189 +++++ src/market/BenchmarkProvider.js | 73 ++ src/market/MarketRegime.js | 50 ++ .../yahooClient.js => market/YahooClient.js} | 0 src/reporters/FinanceReporter.js | 304 ++++++++ src/reporters/HtmlReporter.js | 392 ++++++++++ src/screener/Chunker.js | 4 + src/screener/DataMapper.js | 133 ++++ src/screener/RuleMerger.js | 33 + src/screener/ScreenerEngine.js | 141 ++++ src/screener/assets/Asset.js | 19 + src/{core => screener}/assets/Bond.js | 11 +- src/{core => screener}/assets/Etf.js | 9 +- src/screener/assets/Stock.js | 86 +++ src/screener/scorers/BondScorer.js | 40 ++ src/screener/scorers/EtfScorer.js | 28 + src/screener/scorers/StockScorer.js | 157 +++++ src/utils/Chunker.js | 7 - src/utils/DataMapper.js | 58 -- src/utils/RulesMerger.js | 37 - tests/BondScorer.test.js | 61 ++ tests/DataMapper.test.js | 92 +++ tests/EtfScorer.test.js | 31 + tests/MarketRegime.test.js | 45 ++ tests/PortfolioAdvisor.test.js | 49 ++ tests/RuleMerger.test.js | 66 ++ tests/ScoringConfig.test.js | 41 ++ tests/StockScorer.test.js | 81 +++ 60 files changed, 4610 insertions(+), 796 deletions(-) create mode 100644 .env.example create mode 100755 .husky/pre-commit create mode 100755 .husky/pre-push create mode 100644 .prettierrc create mode 100644 CLAUDE.md delete mode 100644 Market_News_Analysis.md create mode 100644 bin/finance.js create mode 100644 bin/import-portfolio.js create mode 100644 bin/screen.js create mode 100644 finance.js create mode 100644 import-portfolio.js delete mode 100644 index.js create mode 100644 prompts/catalyst-analysis.md create mode 100644 scripts/summary-reporter.js create mode 100644 src/analyst/CatalystAnalyst.js delete mode 100644 src/api/BenchmarkProvider.js create mode 100644 src/config/constants.js delete mode 100644 src/core/assets/Asset.js delete mode 100644 src/core/assets/Stock.js delete mode 100644 src/core/engine/ScoringEngine.js delete mode 100644 src/core/engine/ScreenerEngine.js delete mode 100644 src/core/scorers/BondScorer.js delete mode 100644 src/core/scorers/EtfScorer.js delete mode 100644 src/core/scorers/StockScorer.js create mode 100644 src/finance/PersonalFinanceAnalyzer.js create mode 100644 src/finance/PortfolioAdvisor.js create mode 100644 src/finance/PortfolioImporter.js create mode 100644 src/finance/SimpleFINClient.js create mode 100644 src/finance/clients/SimpleFINClient.js create mode 100644 src/market/BenchmarkProvider.js create mode 100644 src/market/MarketRegime.js rename src/{api/yahooClient.js => market/YahooClient.js} (100%) create mode 100644 src/reporters/FinanceReporter.js create mode 100644 src/reporters/HtmlReporter.js create mode 100644 src/screener/Chunker.js create mode 100644 src/screener/DataMapper.js create mode 100644 src/screener/RuleMerger.js create mode 100644 src/screener/ScreenerEngine.js create mode 100644 src/screener/assets/Asset.js rename src/{core => screener}/assets/Bond.js (60%) rename src/{core => screener}/assets/Etf.js (70%) create mode 100644 src/screener/assets/Stock.js create mode 100644 src/screener/scorers/BondScorer.js create mode 100644 src/screener/scorers/EtfScorer.js create mode 100644 src/screener/scorers/StockScorer.js delete mode 100644 src/utils/Chunker.js delete mode 100644 src/utils/DataMapper.js delete mode 100644 src/utils/RulesMerger.js create mode 100644 tests/BondScorer.test.js create mode 100644 tests/DataMapper.test.js create mode 100644 tests/EtfScorer.test.js create mode 100644 tests/MarketRegime.test.js create mode 100644 tests/PortfolioAdvisor.test.js create mode 100644 tests/RuleMerger.test.js create mode 100644 tests/ScoringConfig.test.js create mode 100644 tests/StockScorer.test.js diff --git a/.env.example b/.env.example new file mode 100644 index 0000000..53bc189 --- /dev/null +++ b/.env.example @@ -0,0 +1,11 @@ +# ── SimpleFIN personal finance ─────────────────────────────────────────────── +# +# FIRST RUN: paste your Setup Token from https://beta-bridge.simplefin.org +# (Settings → Connect an app → copy the token) +# +SIMPLEFIN_SETUP_TOKEN=aHR0cHM6Ly9iZXRhLWJyaWRnZS5zaW1wbGVmaW4ub3Jn... +# +# AFTER FIRST RUN: the Access URL is written here automatically. +# Remove SIMPLEFIN_SETUP_TOKEN once this appears. +# +# SIMPLEFIN_ACCESS_URL=https://user:token@beta-bridge.simplefin.org/simplefin diff --git a/.husky/pre-commit b/.husky/pre-commit new file mode 100755 index 0000000..1c0ebca --- /dev/null +++ b/.husky/pre-commit @@ -0,0 +1,2 @@ +npx lint-staged +npm test diff --git a/.husky/pre-push b/.husky/pre-push new file mode 100755 index 0000000..72c4429 --- /dev/null +++ b/.husky/pre-push @@ -0,0 +1 @@ +npm test diff --git a/.prettierrc b/.prettierrc new file mode 100644 index 0000000..ca34704 --- /dev/null +++ b/.prettierrc @@ -0,0 +1,12 @@ +{ + "printWidth": 100, + "tabWidth": 2, + "useTabs": false, + "semi": true, + "singleQuote": true, + "quoteProps": "as-needed", + "trailingComma": "all", + "bracketSpacing": true, + "arrowParens": "always", + "endOfLine": "lf" +} diff --git a/CLAUDE.md b/CLAUDE.md new file mode 100644 index 0000000..254e150 --- /dev/null +++ b/CLAUDE.md @@ -0,0 +1,231 @@ +# CLAUDE.md + +Guidance for working in this repository. + +## Overview + +`market-screener` is a Node.js CLI tool that screens stocks, ETFs, and bonds by fetching live data from Yahoo Finance and scoring each asset under two lenses: + +- **Market-Adjusted** — gates derived from live Yahoo benchmarks (SPY P/E, XLK P/E, XLRE yield, LQD spread). Reflects what is acceptable in today's inflated market. +- **Fundamental** — strict Graham/value-investing style gates from `ScoringConfig`. Reflects genuine value regardless of market conditions. + +The comparison produces a **Signal** (Strong Buy / Momentum / Speculation / Neutral / Avoid). + +ES module project (`"type": "module"`); use `import`/`export`, not `require`. + +## Commands + +```bash +npm install # install dependencies (yahoo-finance2, dotenv, prettier) +npm start # Yahoo news → catalyst tickers → screener-report.html +npm start -- watch # default watchlist +npm start -- AAPL MSFT VOO # specific tickers +npm run finance # portfolio advice + SimpleFIN → finance-report.html +npm run import-portfolio -- holdings.csv # import Robinhood/Vanguard CSV into portfolio.json +npm test # run all unit tests (node:test, zero deps) +npm run test:watch # watch mode during development +npm run format # format all src/bin/tests with Prettier +npm run format:check # check formatting without writing (CI) +``` + +## Project Structure + +``` +bin/ + screen.js ← market screener entry point + finance.js ← personal finance entry point + import-portfolio.js ← broker CSV importer + +prompts/ + catalyst-analysis.md ← daily catalyst analysis playbook (LLM prompt + workflow) + +src/ + config/ + ScoringConfig.js ← CREDIT_RATING_SCALE + ScoringRules (single source of truth) + + market/ ← Yahoo Finance data layer + YahooClient.js ← wraps yahoo-finance2 v3, retry + backoff + BenchmarkProvider.js ← fetches ^GSPC, ^TNX, ^VIX, SPY, XLK, XLRE, LQD → marketContext + MarketRegime.js ← derives INFLATED gate overrides from live benchmarks + + screener/ ← core screening domain + ScreenerEngine.js ← orchestrates: fetch → score × 2 → HTML report + DataMapper.js ← normalises Yahoo payload → flat asset data object + RuleMerger.js ← merges base rules + sector overrides + MarketRegime (INFLATED mode) + Chunker.js ← splits ticker list into batches + assets/ + Asset.js ← abstract base: ticker, currentPrice, type, formatting helpers + Stock.js ← metrics: peRatio, pegRatio, priceToBook, ROE, opMargin, etc. + Etf.js ← metrics: expenseRatio, yield, totalAssets + Bond.js ← metrics: ytm, duration, creditRating, creditRatingNumeric + scorers/ + StockScorer.js ← gate checks + weighted registry (ROE, opMargin, margin, peg, rev, fcf) + EtfScorer.js ← expense gate + registry (cost, yield, volume) + BondScorer.js ← credit gate + spread/duration scoring + + analyst/ + CatalystAnalyst.js ← fetches Yahoo Finance news, extracts relatedTickers + + finance/ + clients/ + SimpleFINClient.js ← claims setup token → access URL, fetches /accounts + PersonalFinanceAnalyzer.js ← net worth, cash vs investments, spending by category + PortfolioAdvisor.js ← cross-references holdings with screener signals → hold/sell/add advice + PortfolioImporter.js ← parses Robinhood/Vanguard/Fidelity CSV → merges into portfolio.json + + reporters/ + HtmlReporter.js ← generates screener-report.html (tabbed inflated/fundamental views) + FinanceReporter.js ← generates finance-report.html (net worth, portfolio, spending) + +portfolio.json ← user's holdings: ticker, shares, costBasis, source, type +``` + +## Data Flow + +``` +Yahoo Finance API + ↓ +BenchmarkProvider — fetches ^GSPC, ^TNX, ^VIX, SPY, XLK, XLRE, LQD + builds marketContext { sp500Price, riskFreeRate, vixLevel, + rateRegime, volatilityRegime, benchmarks { marketPE, techPE, reitYield, igSpread } } + ↓ +DataMapper — normalises raw Yahoo payload → flat data object with type (STOCK/ETF/BOND) + computes: pFFO proxy, FCF yield, computed PEG, 52-week position + ↓ +Asset subclass — Stock / Etf / Bond holds metrics, formats display + ↓ +RuleMerger × 2 — FUNDAMENTAL mode: ScoringConfig as-is (Graham-style) + INFLATED mode: sector override + MarketRegime live gate overrides + ↓ +Scorer × 2 — StockScorer / EtfScorer / BondScorer, fully stateless + ↓ +ScreenerEngine — derives Signal from comparing both verdicts + ↓ +HtmlReporter — screener-report.html: signal summary + two tabbed tables per asset class +``` + +## Scoring Modes + +| Mode | P/E Gate (general) | Source | +|---|---|---| +| FUNDAMENTAL | 20x | ScoringConfig (Graham) | +| INFLATED | S&P 500 P/E × 1.5 | Live SPY data | + +| Signal | Meaning | +|---|---| +| ✅ Strong Buy | Passes both fundamental AND inflated gates | +| ⚡ Momentum | Passes inflated, holds fundamentally | +| ⚠️ Speculation | Passes inflated, fails fundamental | +| 🔄 Neutral | Hold territory in one or both lenses | +| ❌ Avoid | Fails both | + +## ScoringConfig Structure + +`src/config/ScoringConfig.js` exports two things: + +- `CREDIT_RATING_SCALE` — `{ AAA: 10, AA: 9, ..., D: 1 }`. Used by Bond.js and BondScorer. +- `ScoringRules` — per-type `{ gates, weights, thresholds }` + `SECTOR_OVERRIDE` map for STOCK. + +Sector overrides are structural (apply in both modes). MarketRegime overrides valuation gates in INFLATED mode only. + +## Sector Notes + +- **TECHNOLOGY** — `maxDebtToEquity: 2.0` (mega-cap tech borrows for buybacks), `minQuickRatio: 0.8` (AAPL runs ~0.9). P/E and PEG gates inflated from XLK live data. +- **REIT** — P/E and PEG disabled (9999). Scored on dividend yield and P/FFO proxy. All base weights (margin, peg, revenue) explicitly zeroed out. +- **FINANCIAL** — P/E, PEG, D/E disabled. Scored on ROE + Price-to-Book. Quick ratio gate lowered (0.1). + +## MarketRegime (INFLATED overrides) + +`src/market/MarketRegime.js` derives gate overrides from live benchmarks: + +| Gate | Formula | +|---|---| +| Stock maxPERatio | SPY trailing P/E × 1.5 | +| Tech maxPERatio | XLK P/E × 1.3 | +| Tech maxPegGate | XLK P/E ÷ 15 | +| REIT minYield | XLRE dividend yield × 0.85 | +| Bond minSpread | LQD−TNX spread × 0.80 | +| ETF maxExpenseRatio | 0.75% (structural loosening) | + +## Missing Data Convention + +- Missing metrics use `null` (not `0`) in `_sanitize`. Gate checks skip `null` values rather than auto-failing. +- `pegRatio` falls back to `trailingPE / earningsGrowth` when Yahoo doesn't provide it. +- `quickRatio` falls back to `currentRatio` when missing. + +## SimpleFIN Auth Flow + +1. User gets a Setup Token from https://beta-bridge.simplefin.org +2. `SimpleFINClient.init()` base64-decodes it → POSTs once to claim URL → receives Access URL +3. Access URL is auto-appended to `.env` as `SIMPLEFIN_ACCESS_URL` +4. All subsequent requests use Access URL directly (setup token is one-time use) + +## portfolio.json Format + +```json +{ + "holdings": [ + { "ticker": "AAPL", "shares": 10, "costBasis": 150.00, "source": "Robinhood", "type": "stock" }, + { "ticker": "VOO", "shares": 8, "costBasis": 380.00, "source": "Vanguard", "type": "etf" }, + { "ticker": "BTC-USD", "shares": 0.25, "costBasis": 45000, "source": "Coinbase", "type": "crypto" } + ] +} +``` + +`type` values: `stock`, `etf`, `crypto`. Crypto is priced via Yahoo (BTC-USD style) but not fundamentally scored. + +## Tests + +Uses Node's built-in test runner (`node:test` + `node:assert/strict`) — no test framework to install. + +``` +tests/ + ScoringConfig.test.js ← CREDIT_RATING_SCALE, gate values, sector overrides + RuleMerger.test.js ← FUNDAMENTAL vs INFLATED modes, sector merging + MarketRegime.test.js ← inflated override formulas per asset type and sector + StockScorer.test.js ← gate failures, scoring labels, risk flags + EtfScorer.test.js ← expense gate, score tiers + BondScorer.test.js ← credit gate, spread/duration scoring, unit handling + DataMapper.test.js ← type detection, PEG computation, null convention + PortfolioAdvisor.test.js ← _position gain/loss calc, _advice signal mapping +``` + +**Key unit to remember:** `ytm` in `Bond.metrics` is stored as a percentage (e.g. `6.5` = 6.5%). `BondScorer._sanitize` divides by 100 before spread calculation. `minSpread` in `ScoringConfig` is also in percentage form (e.g. `1.0` = 1%). + +## Conventions + +- Asset `type` (uppercased) is the routing key across DataMapper, asset classes, `SCORERS` map in ScreenerEngine, and ScoringRules. +- Prefer adjusting `ScoringConfig` or `MarketRegime` over hardcoding numbers in scorers. +- BenchmarkProvider caches for 1 hour — restart the process to force a fresh fetch during development. +- All entry points live in `bin/`. Do not add logic to entry points — they call into `src/`. + +## Planned Enhancements + +### 1. Rate Sensitivity Flag (no new fetches needed) +Flag assets exposed to rate changes using `riskFreeRate` and `rateRegime`: +- Long-duration bonds (duration > 7) in HIGH rate regime → warn +- REITs with high D/E in HIGH rate regime → warn +- Growth stocks with negative FCF in HIGH rate regime → warn + +### 2. 52-Week Positioning (data already mapped) +`week52High` and `week52Low` are in asset metrics. Add interpretation: +- `> 85%` position + high P/E = crowded/momentum trade +- `< 15%` position + passing fundamental gates = potential opportunity + +### 3. Sector Rotation Cues (3–4 new fetches) +Add XLF, XLE, XLV, XLI to BenchmarkProvider. Compare each sector ETF's 1-year return to S&P 500 to identify leading/lagging sectors. + +### 4. Client/Server Architecture +Planned split into: +- **Server** (Fastify + BullMQ) — API endpoints, webhook-driven news monitoring, WebSocket for live updates +- **Client** (SvelteKit) — interactive dashboard replacing the HTML reports +- LLM integration (Claude Haiku) wired into the server for deeper catalyst analysis + +## Adding a New Asset Type + +1. Create a subclass of `Asset` in `src/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)`. +5. Add a mapper in `DataMapper.js`. +6. Wire into `ScreenerEngine`: add `case` in `_buildAsset`, entry in `SCORERS` map. diff --git a/Market_News_Analysis.md b/Market_News_Analysis.md deleted file mode 100644 index 9780826..0000000 --- a/Market_News_Analysis.md +++ /dev/null @@ -1,46 +0,0 @@ -# Market News Analysis & Catalyst Screener - -## 1. High-Alpha Catalyst Analysis Prompt - -Copy and paste this into your LLM daily to filter noise into actionable data: - -> **Role:** You are a Quant-driven Financial Analyst specialized in Catalyst-Driven Trading. -> -> **Task:** Analyze today’s top 3 high-impact news stories and map them to the specific assets that are structurally forced to respond. -> -> **Instructions:** -> -> 1. **Identify the Catalyst:** Select one Macro event, one Sector-wide (regulatory/supply-chain) shift, and one Company-specific surprise. -> 2. **Correlation Logic:** For each catalyst, identify: -> - **Primary Target:** The ticker directly mentioned. -> - **Ripple-Effect Target:** A ticker in the supply chain or direct competitor (The "Alpha" play). -> 3. **Quantitative Impact Matrix:** Produce a table with: `Catalyst` | `Tickers (Primary/Ripple)` | `Bias` | `Sensitivity (1-5)` | `Mechanics`. -> 4. **Constraint:** Exclude "Market Sentiment" or generic analyst upgrades. Only include events with a measurable impact on valuation or supply chain fundamentals. -> 5. **Liquidity Filter:** Do not suggest tickers with daily volume below 500k. - ---- - -## 2. Quantitative Impact Matrix (Template) - -Use this table to log the results from the prompt above: - -| Catalyst | Tickers (Primary / Ripple) | Bias | Sensitivity (1-5) | Mechanics | -| :----------- | :------------------------- | :-------- | :---------------- | :------------------------ | -| [Event Name] | [Ticker1] / [Ticker2] | Bull/Bear | [1-5] | [Concise financial logic] | - ---- - -## 3. Implementation Workflow - -1. **Fetch:** Run the prompt above using live news sources (e.g., Bloomberg, Nasdaq, Briefing.com). -2. **Screen:** Plug the resulting tickers into your `ScreenerEngine.js`. -3. **Validate:** Use your "Verdict Justification" table to verify if the fundamentals (PEG, Margins, Debt) support the AI's suggested bias. -4. **Execute:** Monitor the "Ripple-Effect" targets, as they often capture volatility before the broader market catches on. - ---- - -## 4. June 2026 Focus Areas - -- **Macro:** Watch the ISM Manufacturing PMI (June 1) and Nonfarm Payrolls (June 5). -- **Geopolitical:** Monitor US-Iran negotiations regarding the Strait of Hormuz (impacts Oil/Energy supply chains). -- **Sectoral:** Continued AI momentum—look for infrastructure and cybersecurity earnings/guidance. diff --git a/README.md b/README.md index 9a12501..8f2946d 100644 --- a/README.md +++ b/README.md @@ -1,58 +1,163 @@ -# Financial Screener & Personal Finance Assistant +# Market Screener & Personal Finance Assistant -## Project Overview +A Node.js CLI tool that screens stocks, ETFs, bonds, and crypto using live Yahoo Finance data. It scores each asset under two lenses — **Market-Adjusted** (what's acceptable in today's inflated market) and **Fundamental** (Graham-style strict value investing) — and gives you an honest signal by comparing both. -This project is a modular, rule-based financial analysis engine designed to evaluate assets and manage personal investment portfolios. It separates data acquisition, strategy configuration, and evaluation logic to provide actionable investment insights. +It also connects to your brokerage accounts via **SimpleFIN** to track net worth, spending, and give hold/sell/add advice on your actual portfolio. --- -## Architecture Structure +## Quick Start -### 1. Data Pipeline (`/src/data/`) - -- **Fetcher:** Handles API communication (e.g., Yahoo Finance). -- **Mapper:** Normalizes disparate API responses into a unified flat object structure. -- **Asset Models (`/models/`):** Defines common properties for `Stock`, `Etf`, and `Bond`. - -### 2. Logic & Configuration (`/src/config/` & `/src/utils/`) - -- **`ScoringConfig.js`:** Houses all thresholds, gates, and weights. -- **`RuleMerger.js`:** Dynamically applies sector-specific overrides to base rules. - -### 3. Evaluation & Personal Assistant (`/src/engine/` & `/src/assistant/`) - -- **`ScoringEngine.js`:** Orchestrates evaluation, applying market context and sector overrides. -- **`PortfolioManager.js` (NEW):** Tracks individual holdings, cost basis, and performance metrics. -- **`AdvisorModule.js` (NEW):** Provides personalized suggestions based on screening results and portfolio health. -- **`EventMonitor.js` (NEW):** Tracks calendar events (Earnings Calls) to trigger alerts. +```bash +npm install +cp .env.example .env # add SIMPLEFIN_SETUP_TOKEN if you have SimpleFIN +npm start # screen today's catalyst tickers from Yahoo news +``` --- -## Data Flow Diagram +## Commands + +| Command | What it does | +|---|---| +| `npm start` | Fetches today's market news, extracts catalyst tickers, screens them | +| `npm start -- watch` | Screens the default watchlist instead | +| `npm start -- AAPL MSFT VOO` | Screens specific tickers | +| `npm run finance` | Portfolio advice + SimpleFIN account data → `finance-report.html` | +| `npm run import-portfolio -- file.csv` | Imports Robinhood/Vanguard/Fidelity CSV into `portfolio.json` | +| `npm test` | Runs all unit tests (51 tests, zero external dependencies) | +| `npm run test:watch` | Re-runs tests on file changes during development | +| `npm run format` | Formats all source files with Prettier | +| `npm run format:check` | Checks formatting without writing (useful in CI) | + +Both commands generate self-contained HTML reports that open in any browser. --- -## Future Enhancements +## How the Screener Works -### Phase 1: Core Engine & Soft Scoring +Every asset is scored twice: -- **Soft Scoring System:** Transition from "Hard Gates" to a weighted point-based system. -- **Market Context Integration:** Automate the `marketContext` parameter by fetching real-time 10Y Treasury Yields. +**Market-Adjusted** — gates derived from live Yahoo Finance benchmarks: +- Stock P/E gate = S&P 500 P/E (via SPY) × 1.5 +- Tech P/E gate = XLK sector P/E × 1.3 +- REIT min yield = XLRE dividend yield × 0.85 +- Bond min spread = LQD − TNX live spread × 0.80 -### Phase 2: Personal Finance Features +**Fundamental** — strict Graham/value-investing gates from `src/config/ScoringConfig.js`: +- Stock P/E < 20x, PEG < 1.5 +- Bond spread > 1.0% above risk-free rate -- **Personal Portfolio Tracking:** Implement a `PortfolioManager` to track custom user holdings, monitor unrealized P&L, and calculate weightings relative to total assets. -- **Automated Financial Coaching:** Develop an `AdvisorModule` that analyzes the portfolio and provides suggestions (e.g., "Reduce exposure to High-Debt REITs," or "Rebalance to increase Technology allocation"). -- **Earnings Call Notification System:** \* Integrate an earnings calendar API. - - Implement a polling or webhook service to monitor for upcoming calls. - - Add a notification service (Email, Push, or CLI log) to alert the user 24 hours prior to a scheduled earnings call. +The comparison produces a **Signal**: -### Phase 3: Infrastructure & Intelligence - -- **Caching Layer:** Use local JSON caching to reduce API overhead. -- **Sentiment Analysis:** Integrate news-scrapers to weight "Buy" signals based on recent headlines. -- **Backtesting Module:** Run historical simulations to test strategy performance. +| Signal | Meaning | +|---|---| +| ✅ Strong Buy | Passes both — genuinely good value | +| ⚡ Momentum | Passes market-adjusted, holds fundamentally | +| ⚠️ Speculation | Passes market-adjusted, fails fundamental — priced for perfection | +| 🔄 Neutral | Hold territory in one or both lenses | +| ❌ Avoid | Fails both | --- -_Maintained by: AI Collaborator_ +## Personal Finance + +Edit `portfolio.json` with your holdings (or import from a broker CSV): + +```json +{ + "holdings": [ + { "ticker": "AAPL", "shares": 10, "costBasis": 150.00, "source": "Robinhood", "type": "stock" }, + { "ticker": "VOO", "shares": 8, "costBasis": 380.00, "source": "Vanguard", "type": "etf" }, + { "ticker": "BTC-USD", "shares": 0.25, "costBasis": 45000, "source": "Coinbase", "type": "crypto" } + ] +} +``` + +`npm run finance` screens your holdings, fetches crypto prices, and generates hold/sell/add advice based on the screener signal crossed with your gain/loss position. + +### SimpleFIN (optional) + +Connects to your real bank and brokerage accounts for net worth, balances, and 30-day spending breakdown. + +1. Get your setup token from [beta-bridge.simplefin.org](https://beta-bridge.simplefin.org) +2. Add to `.env`: `SIMPLEFIN_SETUP_TOKEN=aHR0cHM6Ly...` +3. Run `npm run finance` — the Access URL is claimed and saved automatically + +### Importing broker holdings + +```bash +npm run import-portfolio -- ~/Downloads/robinhood_holdings.csv +npm run import-portfolio -- ~/Downloads/vanguard_holdings.csv +``` + +Broker is auto-detected from CSV headers. Running multiple imports merges them into `portfolio.json`. + +--- + +## Project Structure + +``` +├── bin/ +│ ├── screen.js # Market screener entry point +│ ├── finance.js # Personal finance entry point +│ └── import-portfolio.js # Broker CSV importer +│ +├── prompts/ +│ └── catalyst-analysis.md # Daily catalyst analysis playbook +│ +├── src/ +│ ├── config/ +│ │ └── ScoringConfig.js # All scoring gates, weights, thresholds +│ │ +│ ├── market/ # Yahoo Finance data layer +│ │ ├── YahooClient.js +│ │ ├── BenchmarkProvider.js +│ │ └── MarketRegime.js # Derives inflated gate overrides from live data +│ │ +│ ├── screener/ # Core screening domain +│ │ ├── ScreenerEngine.js +│ │ ├── DataMapper.js +│ │ ├── RuleMerger.js +│ │ ├── Chunker.js +│ │ ├── assets/ # Stock, Etf, Bond data containers +│ │ └── scorers/ # StockScorer, EtfScorer, BondScorer +│ │ +│ ├── analyst/ +│ │ └── CatalystAnalyst.js # Extracts tickers from Yahoo Finance news +│ │ +│ ├── finance/ +│ │ ├── clients/ +│ │ │ └── SimpleFINClient.js +│ │ ├── PersonalFinanceAnalyzer.js +│ │ ├── PortfolioAdvisor.js +│ │ └── PortfolioImporter.js +│ │ +│ └── reporters/ +│ ├── HtmlReporter.js # screener-report.html +│ └── FinanceReporter.js # finance-report.html +│ +├── portfolio.json # Your holdings (edit this) +└── .env # SIMPLEFIN_SETUP_TOKEN / SIMPLEFIN_ACCESS_URL +``` + +--- + +## Metrics Scored per Stock + +| Metric | Source | Why it matters | +|---|---|---| +| P/E ratio | Yahoo forwardPE / trailingPE | Valuation | +| PEG ratio | Yahoo or computed (trailingPE ÷ earningsGrowth) | Valuation vs growth | +| Price-to-Book | Yahoo | Graham's primary value metric | +| ROE | Yahoo returnOnEquity | Buffett's primary quality metric | +| Operating margin | Yahoo operatingMargins | Pricing power | +| Net profit margin | Yahoo profitMargins | Bottom-line profitability | +| Revenue growth | Yahoo revenueGrowth | Top-line momentum | +| FCF yield | Computed (freeCashflow ÷ market cap) | Cash generation quality | +| Debt/Equity | Yahoo debtToEquity | Balance sheet risk | +| Quick ratio | Yahoo quickRatio (falls back to currentRatio) | Liquidity | +| Beta | Yahoo beta | Market sensitivity | +| 52-week position | Yahoo fiftyTwoWeekHigh/Low | Momentum / opportunity flag | + +Sector overrides apply: REIT scores on yield + P/FFO, FINANCIAL on ROE + P/B, TECHNOLOGY with realistic D/E tolerance. diff --git a/bin/finance.js b/bin/finance.js new file mode 100644 index 0000000..55995c1 --- /dev/null +++ b/bin/finance.js @@ -0,0 +1,84 @@ +/** + * bin/finance.js — Personal Finance CLI + * + * Fetches your accounts from SimpleFIN, screens your portfolio holdings, + * and saves a finance-report.html with: + * 1. Net worth + account overview (SimpleFIN) + * 2. Portfolio hold/sell/add advice (screener + crypto prices) + * 3. Spending breakdown (SimpleFIN) + * + * Usage: + * npm run finance + */ + +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'; + +const PORTFOLIO_PATH = './portfolio.json'; + +async function main() { + // ── 1. Load portfolio + if (!existsSync(PORTFOLIO_PATH)) { + throw new Error('portfolio.json not found — edit it with your holdings and re-run.'); + } + + const { holdings } = JSON.parse(readFileSync(PORTFOLIO_PATH, 'utf8')); + const byType = holdings.reduce((acc, h) => { + const t = h.type ?? 'stock'; + acc[t] = (acc[t] ?? 0) + 1; + return acc; + }, {}); + console.log( + `📋 Portfolio: ${holdings.length} positions — ${Object.entries(byType) + .map(([t, n]) => `${n} ${t}`) + .join(', ')}\n`, + ); + + // ── 2. SimpleFIN accounts (optional) + let personalFinance = null; + if (process.env.SIMPLEFIN_ACCESS_URL || process.env.SIMPLEFIN_SETUP_TOKEN) { + try { + process.stdout.write('💰 Fetching SimpleFIN accounts...'); + const client = new SimpleFINClient({ onAccessUrlClaimed: saveAccessUrlToEnv }); + await client.init(); + const { accounts } = await client.getAccounts(); + personalFinance = new PersonalFinanceAnalyzer().analyse(accounts); + process.stdout.write(` ${accounts.length} accounts loaded\n`); + } catch (err) { + process.stdout.write(` skipped — ${err.message}\n`); + } + } else { + console.log('ℹ Add SIMPLEFIN_SETUP_TOKEN to .env for account balances & spending data\n'); + } + + // ── 3. Screen stocks & ETFs + const screenableTickers = holdings + .filter((h) => (h.type ?? 'stock') !== 'crypto') + .map((h) => h.ticker.toUpperCase()); + + let results = { STOCK: [], ETF: [], BOND: [], ERROR: [], marketContext: {} }; + if (screenableTickers.length > 0) { + process.stdout.write(`📊 Screening ${screenableTickers.length} stock/ETF positions...`); + results = await new ScreenerEngine().screenTickers(screenableTickers); + process.stdout.write(' done\n'); + } + + // ── 4. Portfolio advice + crypto prices + process.stdout.write('💡 Generating portfolio advice...'); + const advice = await new PortfolioAdvisor().advise(holdings, results); + process.stdout.write(' done\n'); + + // ── 5. Report + const reportPath = new FinanceReporter().generate(advice, personalFinance, results.marketContext); + console.log(`\n✅ Finance report: ${reportPath}\n`); +} + +main().catch((err) => { + console.error('Failed:', err.message); + process.exit(1); +}); diff --git a/bin/import-portfolio.js b/bin/import-portfolio.js new file mode 100644 index 0000000..e616ecc --- /dev/null +++ b/bin/import-portfolio.js @@ -0,0 +1,36 @@ +/** + * bin/import-portfolio.js — Portfolio CSV Importer + * + * Reads a holdings export from Robinhood, Vanguard, or Fidelity + * and merges the positions into portfolio.json. + * + * Broker is auto-detected from CSV headers. + * Existing entries are updated in-place; new tickers are added. + * + * How to export: + * Robinhood → Account → Statements & History → Export → Holdings + * Vanguard → My Accounts → Holdings → Download (top-right icon) + * Fidelity → Accounts & Trade → Portfolio → Positions → Download CSV + * + * Usage: + * npm run import-portfolio -- + */ + +import { PortfolioImporter } from '../src/finance/PortfolioImporter.js'; + +const csvPath = process.argv[2]; + +if (!csvPath) { + console.error('Usage: npm run import-portfolio -- \n'); + console.error('Examples:'); + console.error(' npm run import-portfolio -- ~/Downloads/robinhood_holdings.csv'); + console.error(' npm run import-portfolio -- ~/Downloads/vanguard_holdings.csv'); + process.exit(1); +} + +try { + new PortfolioImporter().import(csvPath); +} catch (err) { + console.error(`\nImport failed: ${err.message}`); + process.exit(1); +} diff --git a/bin/screen.js b/bin/screen.js new file mode 100644 index 0000000..024c4c0 --- /dev/null +++ b/bin/screen.js @@ -0,0 +1,83 @@ +/** + * bin/screen.js — Market Screener CLI + * + * Fetches today's catalyst tickers from Yahoo Finance news, + * screens them under both Market-Adjusted and Fundamental lenses, + * and saves a full HTML report. + * + * Usage: + * npm start → Yahoo news → catalyst tickers → screen + * npm start -- watch → default watchlist + * npm start -- AAPL MSFT VOO → specific tickers + */ + +import 'dotenv/config'; +import { CatalystAnalyst } from '../src/analyst/CatalystAnalyst.js'; +import { ScreenerEngine } from '../src/screener/ScreenerEngine.js'; +import { HtmlReporter } from '../src/reporters/HtmlReporter.js'; + +const DEFAULT_WATCHLIST = [ + // Stocks + 'PLTR', + 'AAPL', + 'MSFT', + 'TSLA', + 'O', + // ETFs + 'VOO', + 'QQQ', + // Bonds + 'BND', + 'LQD', + 'TLT', + 'IEF', + 'SHY', + 'GOVT', + 'AGG', + 'MUB', +]; + +async function main() { + const args = process.argv.slice(2); + let tickers = []; + + if (args.length > 0 && args[0] !== 'watch') { + tickers = args.map((t) => t.toUpperCase()); + console.log(`📋 Screening: ${tickers.join(', ')}\n`); + } else if (args[0] === 'watch') { + tickers = DEFAULT_WATCHLIST; + console.log(`📋 Screening default watchlist (${tickers.length} tickers)\n`); + } else { + try { + const { tickers: newsTickers, stories } = await new CatalystAnalyst().run(); + + if (newsTickers.length === 0) { + console.warn("⚠ No tickers in today's news — using default watchlist\n"); + tickers = DEFAULT_WATCHLIST; + } else { + tickers = newsTickers; + console.log("\n📰 Stories driving today's screen:"); + stories.slice(0, 5).forEach((s) => { + const tags = s.relatedTickers.slice(0, 3).join(', '); + console.log(` • ${s.title}${tags ? ` [${tags}]` : ''}`); + }); + console.log(`\n📋 Tickers: ${tickers.join(', ')}\n`); + } + } catch (err) { + console.warn(`⚠ Catalyst analysis failed (${err.message}) — using default watchlist\n`); + tickers = DEFAULT_WATCHLIST; + } + } + + try { + const { STOCK, ETF, BOND, ERROR, marketContext } = + await new ScreenerEngine().screenWithProgress(tickers); + const reportPath = new HtmlReporter().generate({ STOCK, ETF, BOND, ERROR }, marketContext); + console.log(`\n✅ Done — report saved to: ${reportPath}\n`); + } catch (err) { + console.error('Screener failed:', err.message); + process.exit(1); + } +} + +main().catch(console.error); diff --git a/finance.js b/finance.js new file mode 100644 index 0000000..d5dd916 --- /dev/null +++ b/finance.js @@ -0,0 +1,82 @@ +// finance.js — Personal Finance Entry Point +// +// Runs independently of the news screener. +// Fetches your accounts from SimpleFIN, screens your portfolio holdings, +// and generates a finance-report.html with: +// +// 1. Net worth + account overview (SimpleFIN) +// 2. Portfolio positions with hold/sell/add advice (screener + crypto prices) +// 3. Spending breakdown (SimpleFIN) +// +// Usage: +// npm run finance + +import 'dotenv/config'; +import { readFileSync, existsSync } from 'fs'; +import { SimpleFINClient } from './src/finance/SimpleFINClient.js'; +import { PersonalFinanceAnalyzer } from './src/finance/PersonalFinanceAnalyzer.js'; +import { PortfolioAdvisor } from './src/finance/PortfolioAdvisor.js'; +import { ScreenerEngine } from './src/core/engine/ScreenerEngine.js'; +import { FinanceReporter } from './src/reporters/FinanceReporter.js'; + +async function main() { + // ── 1. Load portfolio ────────────────────────────────────────────────────── + if (!existsSync('./portfolio.json')) { + console.error('portfolio.json not found. Edit it with your holdings and re-run.'); + process.exit(1); + } + const { holdings } = JSON.parse(readFileSync('./portfolio.json', 'utf8')); + + const byType = holdings.reduce((acc, h) => { + const t = h.type ?? 'stock'; + acc[t] = (acc[t] ?? 0) + 1; + return acc; + }, {}); + console.log( + `📋 Portfolio: ${holdings.length} positions — ${Object.entries(byType) + .map(([t, n]) => `${n} ${t}`) + .join(', ')}\n`, + ); + + // ── 2. Fetch SimpleFIN data ──────────────────────────────────────────────── + let personalFinance = null; + if (process.env.SIMPLEFIN_ACCESS_URL || process.env.SIMPLEFIN_SETUP_TOKEN) { + try { + process.stdout.write('💰 Fetching accounts from SimpleFIN...'); + const client = new SimpleFINClient(); + await client.init(); + const { accounts } = await client.getAccounts(); + personalFinance = new PersonalFinanceAnalyzer().analyse(accounts); + process.stdout.write(` ${accounts.length} accounts loaded\n`); + } catch (err) { + process.stdout.write(` skipped — ${err.message}\n`); + } + } else { + console.log( + 'ℹ SimpleFIN not configured — add SIMPLEFIN_SETUP_TOKEN to .env for account data\n', + ); + } + + // ── 3. Screen stocks + ETFs (crypto handled separately by PortfolioAdvisor) ─ + const screenableTickers = holdings + .filter((h) => (h.type ?? 'stock') !== 'crypto') + .map((h) => h.ticker.toUpperCase()); + + let results = { STOCK: [], ETF: [], BOND: [], ERROR: [], marketContext: {} }; + if (screenableTickers.length > 0) { + process.stdout.write(`📊 Screening ${screenableTickers.length} stock/ETF positions...`); + results = await new ScreenerEngine().screenTickers(screenableTickers); + process.stdout.write(' done\n'); + } + + // ── 4. Fetch crypto prices + generate advice ─────────────────────────────── + process.stdout.write('💡 Generating advice...'); + const advice = await new PortfolioAdvisor().advise(holdings, results); + process.stdout.write(' done\n'); + + // ── 5. Generate report ───────────────────────────────────────────────────── + const reportPath = new FinanceReporter().generate(advice, personalFinance, results.marketContext); + console.log(`\n✅ Finance report saved to: ${reportPath}\n`); +} + +main().catch((err) => console.error('Failed:', err.message)); diff --git a/import-portfolio.js b/import-portfolio.js new file mode 100644 index 0000000..cf8c978 --- /dev/null +++ b/import-portfolio.js @@ -0,0 +1,34 @@ +// import-portfolio.js +// +// Imports holdings from a broker CSV export into portfolio.json. +// +// Usage: +// npm run import-portfolio -- holdings.csv +// +// Supported brokers (auto-detected from headers): +// Robinhood → Account → Statements & History → Export → Holdings +// Vanguard → My Accounts → Holdings → Download (top-right icon) +// Fidelity → Accounts & Trade → Portfolio → Positions → Download CSV +// +// If you have multiple brokers, run the command once per file — +// each import merges into portfolio.json without overwriting previous entries. + +import { PortfolioImporter } from './src/finance/PortfolioImporter.js'; + +const csvPath = process.argv[2]; + +if (!csvPath) { + console.error('Usage: npm run import-portfolio -- '); + console.error(''); + console.error('Examples:'); + console.error(' npm run import-portfolio -- ~/Downloads/robinhood_holdings.csv'); + console.error(' npm run import-portfolio -- ~/Downloads/vanguard_holdings.csv'); + process.exit(1); +} + +try { + new PortfolioImporter().import(csvPath); +} catch (err) { + console.error(`\n======>>>> Import failed <<<<====== ${err.message}`); + process.exit(1); +} diff --git a/index.js b/index.js deleted file mode 100644 index 7f53426..0000000 --- a/index.js +++ /dev/null @@ -1,32 +0,0 @@ -import { ScreenerEngine } from './src/core/engine/ScreenerEngine.js'; - -const tickers = [ - 'PLTR', - 'AAPL', - 'VOO', - 'MSFT', - 'TSLA', - 'QQQ', - 'O', - 'BND', - 'AGG', - 'LQD', - 'GOVT', - 'MUB', - 'SHY', - 'IEF', - 'TLT', -]; - -async function main() { - console.log('🚀 Starting Screener Evaluation...'); - const engine = new ScreenerEngine(); - - try { - await engine.runParallelScreener(tickers); - } catch (err) { - console.error('\n Execution Failed:', err); - } -} - -main().catch(console.error); diff --git a/package-lock.json b/package-lock.json index 26ed448..8c5a4a6 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,14 +1,20 @@ { - "name": "investment-screener", - "version": "1.0.0", + "name": "market-screener", + "version": "2.0.0", "lockfileVersion": 3, "requires": true, "packages": { "": { - "name": "investment-screener", - "version": "1.0.0", + "name": "market-screener", + "version": "2.0.0", "dependencies": { + "dotenv": "^16.0.0", "yahoo-finance2": "^3.15.2" + }, + "devDependencies": { + "husky": "^9.0.0", + "lint-staged": "^15.0.0", + "prettier": "^3.0.0" } }, "node_modules/@deno/shim-deno": { @@ -125,6 +131,45 @@ } } }, + "node_modules/ansi-escapes": { + "version": "7.3.0", + "resolved": "https://registry.npmjs.org/ansi-escapes/-/ansi-escapes-7.3.0.tgz", + "integrity": "sha512-BvU8nYgGQBxcmMuEeUEmNTvrMVjJNSH7RgW24vXexN4Ven6qCvy4TntnvlnwnMLTVlcRQQdbRY8NKnaIoeWDNg==", + "dev": true, + "dependencies": { + "environment": "^1.0.0" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/ansi-regex": { + "version": "6.2.2", + "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-6.2.2.tgz", + "integrity": "sha512-Bq3SmSpyFHaWjPk8If9yc6svM8c56dB5BAtW4Qbw5jHTwwXXcTLoRMkpDJp6VL0XzlWaCHTXrkFURMYmD0sLqg==", + "dev": true, + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/chalk/ansi-regex?sponsor=1" + } + }, + "node_modules/ansi-styles": { + "version": "6.2.3", + "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-6.2.3.tgz", + "integrity": "sha512-4Dj6M28JB+oAH8kFkTLUo+a2jwOFkuqb3yucU0CANcRRUbxS0cP0nZYCGjcc3BNXwRIsUVmDGgzawme7zvJHvg==", + "dev": true, + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/chalk/ansi-styles?sponsor=1" + } + }, "node_modules/body-parser": { "version": "2.2.2", "resolved": "https://registry.npmjs.org/body-parser/-/body-parser-2.2.2.tgz", @@ -149,6 +194,18 @@ "url": "https://opencollective.com/express" } }, + "node_modules/braces": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/braces/-/braces-3.0.3.tgz", + "integrity": "sha512-yQbXgO/OSZVD2IsiLlro+7Hf6Q18EJrKSEsdoMzKePKXct3gvD8oLcOQdIzGupr5Fj+EDe8gO/lxc1BzfMpxvA==", + "dev": true, + "dependencies": { + "fill-range": "^7.1.1" + }, + "engines": { + "node": ">=8" + } + }, "node_modules/bytes": { "version": "3.1.2", "resolved": "https://registry.npmjs.org/bytes/-/bytes-3.1.2.tgz", @@ -187,6 +244,64 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/chalk": { + "version": "5.6.2", + "resolved": "https://registry.npmjs.org/chalk/-/chalk-5.6.2.tgz", + "integrity": "sha512-7NzBL0rN6fMUW+f7A6Io4h40qQlG+xGmtMxfbnH/K7TAtt8JQWVQK+6g0UXKMeVJoyV5EkkNsErQ8pVD3bLHbA==", + "dev": true, + "engines": { + "node": "^12.17.0 || ^14.13 || >=16.0.0" + }, + "funding": { + "url": "https://github.com/chalk/chalk?sponsor=1" + } + }, + "node_modules/cli-cursor": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/cli-cursor/-/cli-cursor-5.0.0.tgz", + "integrity": "sha512-aCj4O5wKyszjMmDT4tZj93kxyydN/K5zPWSCe6/0AV/AA1pqe5ZBIw0a2ZfPQV7lL5/yb5HsUreJ6UFAF1tEQw==", + "dev": true, + "dependencies": { + "restore-cursor": "^5.0.0" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/cli-truncate": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/cli-truncate/-/cli-truncate-4.0.0.tgz", + "integrity": "sha512-nPdaFdQ0h/GEigbPClz11D0v/ZJEwxmeVZGeMo3Z5StPtUTkA9o1lD6QwoirYiSDzbcwn2XcjwmCp68W1IS4TA==", + "dev": true, + "dependencies": { + "slice-ansi": "^5.0.0", + "string-width": "^7.0.0" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/colorette": { + "version": "2.0.20", + "resolved": "https://registry.npmjs.org/colorette/-/colorette-2.0.20.tgz", + "integrity": "sha512-IfEDxwoWIjkeXL1eXcDiow4UbKjhLdq6/EuSVR9GMN7KVH3r9gQ83e73hsz1Nd1T3ijd5xv1wcWRYO+D6kCI2w==", + "dev": true + }, + "node_modules/commander": { + "version": "13.1.0", + "resolved": "https://registry.npmjs.org/commander/-/commander-13.1.0.tgz", + "integrity": "sha512-/rFeCpNJQbhSZjGVwO9RFV3xPqbnERS8MmIQzCtD/zl6gpJuV/bMLuN92oG3F7d8oDEHHRrujSXNUr8fpjntKw==", + "dev": true, + "engines": { + "node": ">=18" + } + }, "node_modules/content-disposition": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/content-disposition/-/content-disposition-1.1.0.tgz", @@ -305,6 +420,18 @@ "node": ">= 0.8" } }, + "node_modules/dotenv": { + "version": "16.6.1", + "resolved": "https://registry.npmjs.org/dotenv/-/dotenv-16.6.1.tgz", + "integrity": "sha512-uBq4egWHTcTt33a72vpSG0z3HnPuIl6NqYcTrKEg2azoEyl2hpW0zqlxysq2pK9HlDIHyHyakeYaYnSAwd8bow==", + "license": "BSD-2-Clause", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://dotenvx.com" + } + }, "node_modules/dunder-proto": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/dunder-proto/-/dunder-proto-1.0.1.tgz", @@ -325,6 +452,12 @@ "integrity": "sha512-WMwm9LhRUo+WUaRN+vRuETqG89IgZphVSNkdFgeb6sS/E4OrDIN7t48CAewSHXc6C8lefD8KKfr5vY61brQlow==", "license": "MIT" }, + "node_modules/emoji-regex": { + "version": "10.6.0", + "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-10.6.0.tgz", + "integrity": "sha512-toUI84YS5YmxW219erniWD0CIVOo46xGKColeNQRgOzDorgBi1v4D71/OFzgD9GO2UGKIv1C3Sp8DAn0+j5w7A==", + "dev": true + }, "node_modules/encodeurl": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/encodeurl/-/encodeurl-2.0.0.tgz", @@ -334,6 +467,18 @@ "node": ">= 0.8" } }, + "node_modules/environment": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/environment/-/environment-1.1.0.tgz", + "integrity": "sha512-xUtoPkMggbz0MPyPiIWr1Kp4aeWJjDZ6SMvURhimjdZgsRuDplF5/s9hcgGhyXMhs+6vpnuoiZ2kFiu3FMnS8Q==", + "dev": true, + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/es-define-property": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/es-define-property/-/es-define-property-1.0.1.tgz", @@ -388,6 +533,12 @@ "node": ">= 0.6" } }, + "node_modules/eventemitter3": { + "version": "5.0.4", + "resolved": "https://registry.npmjs.org/eventemitter3/-/eventemitter3-5.0.4.tgz", + "integrity": "sha512-mlsTRyGaPBjPedk6Bvw+aqbsXDtoAyAzm5MO7JgU+yVRyMQ5O8bD4Kcci7BS85f93veegeCPkL8R4GLClnjLFw==", + "dev": true + }, "node_modules/eventsource": { "version": "3.0.7", "resolved": "https://registry.npmjs.org/eventsource/-/eventsource-3.0.7.tgz", @@ -409,6 +560,29 @@ "node": ">=18.0.0" } }, + "node_modules/execa": { + "version": "8.0.1", + "resolved": "https://registry.npmjs.org/execa/-/execa-8.0.1.tgz", + "integrity": "sha512-VyhnebXciFV2DESc+p6B+y0LjSm0krU4OgJN44qFAhBY0TJ+1V61tYD2+wHusZ6F9n5K+vl8k0sTy7PEfV4qpg==", + "dev": true, + "dependencies": { + "cross-spawn": "^7.0.3", + "get-stream": "^8.0.1", + "human-signals": "^5.0.0", + "is-stream": "^3.0.0", + "merge-stream": "^2.0.0", + "npm-run-path": "^5.1.0", + "onetime": "^6.0.0", + "signal-exit": "^4.1.0", + "strip-final-newline": "^3.0.0" + }, + "engines": { + "node": ">=16.17" + }, + "funding": { + "url": "https://github.com/sindresorhus/execa?sponsor=1" + } + }, "node_modules/express": { "version": "5.2.1", "resolved": "https://registry.npmjs.org/express/-/express-5.2.1.tgz", @@ -544,6 +718,18 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/fill-range": { + "version": "7.1.1", + "resolved": "https://registry.npmjs.org/fill-range/-/fill-range-7.1.1.tgz", + "integrity": "sha512-YsGpe3WHLK8ZYi4tWDg2Jy3ebRz2rXowDxnld4bkQB00cc/1Zw9AWnC0i9ztDJitivtQvaI9KaLyKrc+hBW0yg==", + "dev": true, + "dependencies": { + "to-regex-range": "^5.0.1" + }, + "engines": { + "node": ">=8" + } + }, "node_modules/finalhandler": { "version": "2.1.1", "resolved": "https://registry.npmjs.org/finalhandler/-/finalhandler-2.1.1.tgz", @@ -592,6 +778,18 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/get-east-asian-width": { + "version": "1.6.0", + "resolved": "https://registry.npmjs.org/get-east-asian-width/-/get-east-asian-width-1.6.0.tgz", + "integrity": "sha512-QRbvDIbx6YklUe6RxeTeleMR0yv3cYH6PsPZHcnVn7xv7zO1BHN8r0XETu8n6Ye3Q+ahtSarc3WgtNWmehIBfA==", + "dev": true, + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/get-intrinsic": { "version": "1.3.0", "resolved": "https://registry.npmjs.org/get-intrinsic/-/get-intrinsic-1.3.0.tgz", @@ -629,6 +827,18 @@ "node": ">= 0.4" } }, + "node_modules/get-stream": { + "version": "8.0.1", + "resolved": "https://registry.npmjs.org/get-stream/-/get-stream-8.0.1.tgz", + "integrity": "sha512-VaUJspBffn/LMCJVoMvSAdmscJyS1auj5Zulnn5UoYcY531UWmdwhRWkcGKnGU93m5HSXP9LP2usOryrBtQowA==", + "dev": true, + "engines": { + "node": ">=16" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/gopd": { "version": "1.2.0", "resolved": "https://registry.npmjs.org/gopd/-/gopd-1.2.0.tgz", @@ -694,6 +904,15 @@ "url": "https://opencollective.com/express" } }, + "node_modules/human-signals": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/human-signals/-/human-signals-5.0.0.tgz", + "integrity": "sha512-AXcZb6vzzrFAUE61HnN4mpLqd/cSIwNQjtNWR0euPm6y0iqx3G4gOXaIDdtdDwZmhwe82LA6+zinmW4UBWVePQ==", + "dev": true, + "engines": { + "node": ">=16.17.0" + } + }, "node_modules/humanize-url": { "version": "2.1.1", "resolved": "https://registry.npmjs.org/humanize-url/-/humanize-url-2.1.1.tgz", @@ -706,6 +925,21 @@ "node": ">=8" } }, + "node_modules/husky": { + "version": "9.1.7", + "resolved": "https://registry.npmjs.org/husky/-/husky-9.1.7.tgz", + "integrity": "sha512-5gs5ytaNjBrh5Ow3zrvdUUY+0VxIuWVL4i9irt6friV+BqdCfmV11CQTWMiBYWHbXhco+J1kHfTOUkePhCDvMA==", + "dev": true, + "bin": { + "husky": "bin.js" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/typicode" + } + }, "node_modules/iconv-lite": { "version": "0.7.2", "resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.7.2.tgz", @@ -746,12 +980,45 @@ "node": ">= 0.10" } }, + "node_modules/is-fullwidth-code-point": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/is-fullwidth-code-point/-/is-fullwidth-code-point-4.0.0.tgz", + "integrity": "sha512-O4L094N2/dZ7xqVdrXhh9r1KODPJpFms8B5sGdJLPy664AgvXsreZUyCQQNItZRDlYug4xStLjNp/sz3HvBowQ==", + "dev": true, + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/is-number": { + "version": "7.0.0", + "resolved": "https://registry.npmjs.org/is-number/-/is-number-7.0.0.tgz", + "integrity": "sha512-41Cifkg6e8TylSpdtTpeLVMqvSBEVzTttHvERD741+pnZ8ANv0004MRL43QKPDlK9cGvNp6NZWZUBlbGXYxxng==", + "dev": true, + "engines": { + "node": ">=0.12.0" + } + }, "node_modules/is-promise": { "version": "4.0.0", "resolved": "https://registry.npmjs.org/is-promise/-/is-promise-4.0.0.tgz", "integrity": "sha512-hvpoI6korhJMnej285dSg6nu1+e6uxs7zG3BYAm5byqDsgJNWwxzM6z6iZiAgQR4TJ30JmBTOwqZUw3WlyH3AQ==", "license": "MIT" }, + "node_modules/is-stream": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/is-stream/-/is-stream-3.0.0.tgz", + "integrity": "sha512-LnQR4bZ9IADDRSkvpqMGvt/tEJWclzklNgSw48V5EAaAeDd6qGvN8ei6k5p0tvxSR171VmGyHuTiAOfxAbr8kA==", + "dev": true, + "engines": { + "node": "^12.20.0 || ^14.13.1 || >=16.0.0" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/isexe": { "version": "3.1.5", "resolved": "https://registry.npmjs.org/isexe/-/isexe-3.1.5.tgz", @@ -788,6 +1055,112 @@ "integrity": "sha512-fQhoXdcvc3V28x7C7BMs4P5+kNlgUURe2jmUT1T//oBRMDrqy1QPelJimwZGo7Hg9VPV3EQV5Bnq4hbFy2vetA==", "license": "BSD-2-Clause" }, + "node_modules/lilconfig": { + "version": "3.1.3", + "resolved": "https://registry.npmjs.org/lilconfig/-/lilconfig-3.1.3.tgz", + "integrity": "sha512-/vlFKAoH5Cgt3Ie+JLhRbwOsCQePABiU3tJ1egGvyQ+33R/vcwM2Zl2QR/LzjsBeItPt3oSVXapn+m4nQDvpzw==", + "dev": true, + "engines": { + "node": ">=14" + }, + "funding": { + "url": "https://github.com/sponsors/antonk52" + } + }, + "node_modules/lint-staged": { + "version": "15.5.2", + "resolved": "https://registry.npmjs.org/lint-staged/-/lint-staged-15.5.2.tgz", + "integrity": "sha512-YUSOLq9VeRNAo/CTaVmhGDKG+LBtA8KF1X4K5+ykMSwWST1vDxJRB2kv2COgLb1fvpCo+A/y9A0G0znNVmdx4w==", + "dev": true, + "dependencies": { + "chalk": "^5.4.1", + "commander": "^13.1.0", + "debug": "^4.4.0", + "execa": "^8.0.1", + "lilconfig": "^3.1.3", + "listr2": "^8.2.5", + "micromatch": "^4.0.8", + "pidtree": "^0.6.0", + "string-argv": "^0.3.2", + "yaml": "^2.7.0" + }, + "bin": { + "lint-staged": "bin/lint-staged.js" + }, + "engines": { + "node": ">=18.12.0" + }, + "funding": { + "url": "https://opencollective.com/lint-staged" + } + }, + "node_modules/listr2": { + "version": "8.3.3", + "resolved": "https://registry.npmjs.org/listr2/-/listr2-8.3.3.tgz", + "integrity": "sha512-LWzX2KsqcB1wqQ4AHgYb4RsDXauQiqhjLk+6hjbaeHG4zpjjVAB6wC/gz6X0l+Du1cN3pUB5ZlrvTbhGSNnUQQ==", + "dev": true, + "dependencies": { + "cli-truncate": "^4.0.0", + "colorette": "^2.0.20", + "eventemitter3": "^5.0.1", + "log-update": "^6.1.0", + "rfdc": "^1.4.1", + "wrap-ansi": "^9.0.0" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/log-update": { + "version": "6.1.0", + "resolved": "https://registry.npmjs.org/log-update/-/log-update-6.1.0.tgz", + "integrity": "sha512-9ie8ItPR6tjY5uYJh8K/Zrv/RMZ5VOlOWvtZdEHYSTFKZfIBPQa9tOAEeAWhd+AnIneLJ22w5fjOYtoutpWq5w==", + "dev": true, + "dependencies": { + "ansi-escapes": "^7.0.0", + "cli-cursor": "^5.0.0", + "slice-ansi": "^7.1.0", + "strip-ansi": "^7.1.0", + "wrap-ansi": "^9.0.0" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/log-update/node_modules/is-fullwidth-code-point": { + "version": "5.1.0", + "resolved": "https://registry.npmjs.org/is-fullwidth-code-point/-/is-fullwidth-code-point-5.1.0.tgz", + "integrity": "sha512-5XHYaSyiqADb4RnZ1Bdad6cPp8Toise4TzEjcOYDHZkTCbKgiUl7WTUCpNWHuxmDt91wnsZBc9xinNzopv3JMQ==", + "dev": true, + "dependencies": { + "get-east-asian-width": "^1.3.1" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/log-update/node_modules/slice-ansi": { + "version": "7.1.2", + "resolved": "https://registry.npmjs.org/slice-ansi/-/slice-ansi-7.1.2.tgz", + "integrity": "sha512-iOBWFgUX7caIZiuutICxVgX1SdxwAVFFKwt1EvMYYec/NWO5meOJ6K5uQxhrYBdQJne4KxiqZc+KptFOWFSI9w==", + "dev": true, + "dependencies": { + "ansi-styles": "^6.2.1", + "is-fullwidth-code-point": "^5.0.0" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/chalk/slice-ansi?sponsor=1" + } + }, "node_modules/math-intrinsics": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/math-intrinsics/-/math-intrinsics-1.1.0.tgz", @@ -818,6 +1191,25 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/merge-stream": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/merge-stream/-/merge-stream-2.0.0.tgz", + "integrity": "sha512-abv/qOcuPfk3URPfDzmZU1LKmuw8kT+0nIHvKrKgFrwifol/doWcdA4ZqsWQ8ENrFKkd67Mfpo/LovbIUsbt3w==", + "dev": true + }, + "node_modules/micromatch": { + "version": "4.0.8", + "resolved": "https://registry.npmjs.org/micromatch/-/micromatch-4.0.8.tgz", + "integrity": "sha512-PXwfBhYu0hBCPw8Dn0E+WDYb7af3dSLVWKi3HGv84IdF4TyFoC0ysxFd0Goxw7nSv4T/PzEJQxsYsEiFCKo2BA==", + "dev": true, + "dependencies": { + "braces": "^3.0.3", + "picomatch": "^2.3.1" + }, + "engines": { + "node": ">=8.6" + } + }, "node_modules/mime-db": { "version": "1.54.0", "resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.54.0.tgz", @@ -843,6 +1235,30 @@ "url": "https://opencollective.com/express" } }, + "node_modules/mimic-fn": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/mimic-fn/-/mimic-fn-4.0.0.tgz", + "integrity": "sha512-vqiC06CuhBTUdZH+RYl8sFrL096vA45Ok5ISO6sE/Mr1jRbGH4Csnhi8f3wKVl7x8mO4Au7Ir9D3Oyv1VYMFJw==", + "dev": true, + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/mimic-function": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/mimic-function/-/mimic-function-5.0.1.tgz", + "integrity": "sha512-VP79XUPxV2CigYP3jWwAUFSku2aKqBH7uTAapFWCBqutsbmDo96KY5o8uh6U+/YSIn5OxJnXp73beVkpqMIGhA==", + "dev": true, + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/ms": { "version": "2.1.3", "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", @@ -867,6 +1283,33 @@ "node": ">=8" } }, + "node_modules/npm-run-path": { + "version": "5.3.0", + "resolved": "https://registry.npmjs.org/npm-run-path/-/npm-run-path-5.3.0.tgz", + "integrity": "sha512-ppwTtiJZq0O/ai0z7yfudtBpWIoxM8yE6nHi1X47eFR2EWORqfbu6CnPlNsjeN683eT0qG6H/Pyf9fCcvjnnnQ==", + "dev": true, + "dependencies": { + "path-key": "^4.0.0" + }, + "engines": { + "node": "^12.20.0 || ^14.13.1 || >=16.0.0" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/npm-run-path/node_modules/path-key": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/path-key/-/path-key-4.0.0.tgz", + "integrity": "sha512-haREypq7xkM7ErfgIyA0z+Bj4AGKlMSdlQE2jvJo6huWD1EdkKYV+G/T4nq0YEF2vgTT8kqMFKo1uHn950r4SQ==", + "dev": true, + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/object-assign": { "version": "4.1.1", "resolved": "https://registry.npmjs.org/object-assign/-/object-assign-4.1.1.tgz", @@ -909,6 +1352,21 @@ "wrappy": "1" } }, + "node_modules/onetime": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/onetime/-/onetime-6.0.0.tgz", + "integrity": "sha512-1FlR+gjXK7X+AsAHso35MnyN5KqGwJRi/31ft6x0M194ht7S+rWAvd7PHss9xSKMzE0asv1pyIHaJYq+BbacAQ==", + "dev": true, + "dependencies": { + "mimic-fn": "^4.0.0" + }, + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/parseurl": { "version": "1.3.3", "resolved": "https://registry.npmjs.org/parseurl/-/parseurl-1.3.3.tgz", @@ -937,6 +1395,30 @@ "url": "https://opencollective.com/express" } }, + "node_modules/picomatch": { + "version": "2.3.2", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-2.3.2.tgz", + "integrity": "sha512-V7+vQEJ06Z+c5tSye8S+nHUfI51xoXIXjHQ99cQtKUkQqqO1kO/KCJUfZXuB47h/YBlDhah2H3hdUGXn8ie0oA==", + "dev": true, + "engines": { + "node": ">=8.6" + }, + "funding": { + "url": "https://github.com/sponsors/jonschlinkert" + } + }, + "node_modules/pidtree": { + "version": "0.6.0", + "resolved": "https://registry.npmjs.org/pidtree/-/pidtree-0.6.0.tgz", + "integrity": "sha512-eG2dWTVw5bzqGRztnHExczNxt5VGsE6OwTeCG3fdUf9KBsZzO3R5OIIIzWR+iZA0NtZ+RDVdaoE2dK1cn6jH4g==", + "dev": true, + "bin": { + "pidtree": "bin/pidtree.js" + }, + "engines": { + "node": ">=0.10" + } + }, "node_modules/pkce-challenge": { "version": "5.0.1", "resolved": "https://registry.npmjs.org/pkce-challenge/-/pkce-challenge-5.0.1.tgz", @@ -946,6 +1428,21 @@ "node": ">=16.20.0" } }, + "node_modules/prettier": { + "version": "3.8.3", + "resolved": "https://registry.npmjs.org/prettier/-/prettier-3.8.3.tgz", + "integrity": "sha512-7igPTM53cGHMW8xWuVTydi2KO233VFiTNyF5hLJqpilHfmn8C8gPf+PS7dUT64YcXFbiMGZxS9pCSxL/Dxm/Jw==", + "dev": true, + "bin": { + "prettier": "bin/prettier.cjs" + }, + "engines": { + "node": ">=14" + }, + "funding": { + "url": "https://github.com/prettier/prettier?sponsor=1" + } + }, "node_modules/proxy-addr": { "version": "2.0.7", "resolved": "https://registry.npmjs.org/proxy-addr/-/proxy-addr-2.0.7.tgz", @@ -1040,6 +1537,43 @@ "integrity": "sha512-KigOCHcocU3XODJxsu8i/j8T9tzT4adHiecwORRQ0ZZFcp7ahwXuRU1m+yuO90C5ZUyGeGfocHDI14M3L3yDAQ==", "license": "MIT" }, + "node_modules/restore-cursor": { + "version": "5.1.0", + "resolved": "https://registry.npmjs.org/restore-cursor/-/restore-cursor-5.1.0.tgz", + "integrity": "sha512-oMA2dcrw6u0YfxJQXm342bFKX/E4sG9rbTzO9ptUcR/e8A33cHuvStiYOwH7fszkZlZ1z/ta9AAoPk2F4qIOHA==", + "dev": true, + "dependencies": { + "onetime": "^7.0.0", + "signal-exit": "^4.1.0" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/restore-cursor/node_modules/onetime": { + "version": "7.0.0", + "resolved": "https://registry.npmjs.org/onetime/-/onetime-7.0.0.tgz", + "integrity": "sha512-VXJjc87FScF88uafS3JllDgvAm+c/Slfz06lorj2uAY34rlUu0Nt+v8wreiImcrgAjjIHp1rXpTDlLOGw29WwQ==", + "dev": true, + "dependencies": { + "mimic-function": "^5.0.0" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/rfdc": { + "version": "1.4.1", + "resolved": "https://registry.npmjs.org/rfdc/-/rfdc-1.4.1.tgz", + "integrity": "sha512-q1b3N5QkRUWUl7iyylaaj3kOpIT0N2i9MqIEQXP73GVsN9cw3fdx8X63cEmWhJGi2PPCF23Ijp7ktmd39rawIA==", + "dev": true + }, "node_modules/router": { "version": "2.2.0", "resolved": "https://registry.npmjs.org/router/-/router-2.2.0.tgz", @@ -1206,6 +1740,34 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/signal-exit": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/signal-exit/-/signal-exit-4.1.0.tgz", + "integrity": "sha512-bzyZ1e88w9O1iNJbKnOlvYTrWPDl46O1bG0D3XInv+9tkPrxrN8jUUTiFlDkkmKWgn1M6CfIA13SuGqOa9Korw==", + "dev": true, + "engines": { + "node": ">=14" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/slice-ansi": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/slice-ansi/-/slice-ansi-5.0.0.tgz", + "integrity": "sha512-FC+lgizVPfie0kkhqUScwRu1O/lF6NOgJmlCgK+/LYxDCTk8sGelYaHDhFcDN+Sn3Cv+3VSa4Byeo+IMCzpMgQ==", + "dev": true, + "dependencies": { + "ansi-styles": "^6.0.0", + "is-fullwidth-code-point": "^4.0.0" + }, + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/chalk/slice-ansi?sponsor=1" + } + }, "node_modules/statuses": { "version": "2.0.2", "resolved": "https://registry.npmjs.org/statuses/-/statuses-2.0.2.tgz", @@ -1215,6 +1777,59 @@ "node": ">= 0.8" } }, + "node_modules/string-argv": { + "version": "0.3.2", + "resolved": "https://registry.npmjs.org/string-argv/-/string-argv-0.3.2.tgz", + "integrity": "sha512-aqD2Q0144Z+/RqG52NeHEkZauTAUWJO8c6yTftGJKO3Tja5tUgIfmIl6kExvhtxSDP7fXB6DvzkfMpCd/F3G+Q==", + "dev": true, + "engines": { + "node": ">=0.6.19" + } + }, + "node_modules/string-width": { + "version": "7.2.0", + "resolved": "https://registry.npmjs.org/string-width/-/string-width-7.2.0.tgz", + "integrity": "sha512-tsaTIkKW9b4N+AEj+SVA+WhJzV7/zMhcSu78mLKWSk7cXMOSHsBKFWUs0fWwq8QyK3MgJBQRX6Gbi4kYbdvGkQ==", + "dev": true, + "dependencies": { + "emoji-regex": "^10.3.0", + "get-east-asian-width": "^1.0.0", + "strip-ansi": "^7.1.0" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/strip-ansi": { + "version": "7.2.0", + "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-7.2.0.tgz", + "integrity": "sha512-yDPMNjp4WyfYBkHnjIRLfca1i6KMyGCtsVgoKe/z1+6vukgaENdgGBZt+ZmKPc4gavvEZ5OgHfHdrazhgNyG7w==", + "dev": true, + "dependencies": { + "ansi-regex": "^6.2.2" + }, + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/chalk/strip-ansi?sponsor=1" + } + }, + "node_modules/strip-final-newline": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/strip-final-newline/-/strip-final-newline-3.0.0.tgz", + "integrity": "sha512-dOESqjYr96iWYylGObzd39EuNTa5VJxyvVAEm5Jnh7KGo75V43Hk1odPQkNDyXNmUR6k+gEiDVXnjB8HJ3crXw==", + "dev": true, + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/strip-outer": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/strip-outer/-/strip-outer-1.0.1.tgz", @@ -1245,6 +1860,18 @@ "integrity": "sha512-Je6p7pkk+KMzMv2XXKmAE3McmolOQFdxkKw0R8EYNr7sELW46JqnNeTX8ybPiQgvg1ymCoF8LXs5fzFaZvJPTA==", "license": "MIT" }, + "node_modules/to-regex-range": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/to-regex-range/-/to-regex-range-5.0.1.tgz", + "integrity": "sha512-65P7iz6X5yEr1cwcgvQxbbIw7Uk3gOy5dIdtZ4rDveLqhrdJP+Li/Hx6tyK0NEb+2GCyneCMJiGqrADCSNk8sQ==", + "dev": true, + "dependencies": { + "is-number": "^7.0.0" + }, + "engines": { + "node": ">=8.0" + } + }, "node_modules/toidentifier": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/toidentifier/-/toidentifier-1.0.1.tgz", @@ -1376,6 +2003,23 @@ "node": "^16.13.0 || >=18.0.0" } }, + "node_modules/wrap-ansi": { + "version": "9.0.2", + "resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-9.0.2.tgz", + "integrity": "sha512-42AtmgqjV+X1VpdOfyTGOYRi0/zsoLqtXQckTmqTeybT+BDIbM/Guxo7x3pE2vtpr1ok6xRqM9OpBe+Jyoqyww==", + "dev": true, + "dependencies": { + "ansi-styles": "^6.2.1", + "string-width": "^7.0.0", + "strip-ansi": "^7.1.0" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/chalk/wrap-ansi?sponsor=1" + } + }, "node_modules/wrappy": { "version": "1.0.2", "resolved": "https://registry.npmjs.org/wrappy/-/wrappy-1.0.2.tgz", @@ -1417,6 +2061,21 @@ "node": ">=16" } }, + "node_modules/yaml": { + "version": "2.9.0", + "resolved": "https://registry.npmjs.org/yaml/-/yaml-2.9.0.tgz", + "integrity": "sha512-2AvhNX3mb8zd6Zy7INTtSpl1F15HW6Wnqj0srWlkKLcpYl/gMIMJiyuGq2KeI2YFxUPjdlB+3Lc10seMLtL4cA==", + "dev": true, + "bin": { + "yaml": "bin.mjs" + }, + "engines": { + "node": ">= 14.6" + }, + "funding": { + "url": "https://github.com/sponsors/eemeli" + } + }, "node_modules/zod": { "version": "3.25.76", "resolved": "https://registry.npmjs.org/zod/-/zod-3.25.76.tgz", diff --git a/package.json b/package.json index 128a70e..9890397 100644 --- a/package.json +++ b/package.json @@ -1,11 +1,29 @@ { - "name": "stock-screener", - "version": "1.0.0", + "name": "market-screener", + "version": "2.0.0", "type": "module", "scripts": { - "start": "node index.js" + "start": "node bin/screen.js", + "finance": "node bin/finance.js", + "import-portfolio": "node bin/import-portfolio.js", + "test": "node --test --test-reporter=./scripts/summary-reporter.js tests/*.test.js", + "test:watch": "node --test --watch --test-reporter=spec tests/*.test.js", + "format": "prettier --write \"src/**/*.js\" \"bin/**/*.js\" \"tests/**/*.js\"", + "format:check": "prettier --check \"src/**/*.js\" \"bin/**/*.js\" \"tests/**/*.js\"", + "prepare": "husky" + }, + "lint-staged": { + "*.js": [ + "prettier --write" + ] }, "dependencies": { + "dotenv": "^16.0.0", "yahoo-finance2": "^3.15.2" + }, + "devDependencies": { + "husky": "^9.0.0", + "lint-staged": "^15.0.0", + "prettier": "^3.0.0" } } diff --git a/prompts/catalyst-analysis.md b/prompts/catalyst-analysis.md new file mode 100644 index 0000000..052bab6 --- /dev/null +++ b/prompts/catalyst-analysis.md @@ -0,0 +1,165 @@ +# Market News Analysis & Catalyst Screener + +A structured workflow for converting daily news into actionable trade ideas, validated by the screener's fundamental and market-adjusted analysis. + +--- + +## 1. How This Fits Into the Screener Workflow + +``` +Daily News + ↓ +Catalyst Prompt (Section 2) → Generates tickers + bias + horizon + ↓ +market_screener (npm start) → Fundamental + Market-Adjusted scoring + ↓ +Validation (Section 4) → Is the fundamental thesis intact? + ↓ +Decision → Act / Monitor / Discard +``` + +**Key principle:** The screener doesn't tell you *when* to trade — catalysts do that. The screener tells you whether the *underlying business* supports the trade or whether you're purely momentum-chasing. + +--- + +## 2. Catalyst Analysis Prompt + +Copy and paste this into your LLM daily. Provide it with 3–5 news headlines. + +> **Role:** You are a quantitative financial analyst specialising in catalyst-driven trading. +> +> **Task:** Analyse the provided news and map each story to the assets structurally forced to respond. +> +> **For each catalyst, identify:** +> 1. **Type:** Macro (Fed, rates, GDP) | Sector (regulatory, supply chain, commodity) | Company (earnings, guidance, M&A) +> 2. **Primary ticker:** The asset directly impacted. +> 3. **Ripple-effect ticker:** A supply chain partner, direct competitor, or sector peer that moves *before* the market catches on. This is the alpha play. +> 4. **Bias:** Bull or Bear — with a one-sentence mechanistic reason (not sentiment). +> 5. **Horizon:** Short (1–5 days) | Medium (1–4 weeks) | Long (1+ quarter). +> 6. **Sensitivity:** How exposed is this ticker to the catalyst? +> - **5** — Direct revenue impact > 20% of annual sales +> - **4** — Direct revenue impact 10–20% +> - **3** — Indirect exposure via cost structure or supply chain +> - **2** — Sector correlation, limited direct exposure +> - **1** — Macro tailwind/headwind only +> +> **Constraints:** +> - Exclude generic analyst upgrades and "market sentiment" stories. +> - Only include events with a measurable impact on valuation or supply chain fundamentals. +> - Do not suggest tickers with average daily volume below 500k. +> - For Bear plays: require at least one of — elevated short interest (>5% of float), negative earnings revision trend, or sector rotation evidence. + +--- + +## 3. Quantitative Impact Matrix + +Output from the prompt above. Log results here before running the screener. + +| Catalyst | Type | Primary | Ripple | Bias | Sensitivity | Horizon | Mechanics | +| :--- | :--- | :--- | :--- | :--- | :--- | :--- | :--- | +| [Event] | Macro/Sector/Co. | [TICKER] | [TICKER] | Bull/Bear | 1–5 | Short/Med/Long | [One-line financial logic] | + +--- + +## 4. Ripple-Effect Reference Map + +When a catalyst hits a primary ticker, these are the typical second-order targets by category. + +| Primary Event | Ripple Targets | Logic | +| :--- | :--- | :--- | +| **Semis beat** (NVDA, AMD) | TSMC, ASML, AMAT, KLAC | Fab capacity demand follows chip demand | +| **Semis miss** | INTC, MU, WDC | Inventory builds at competitors | +| **Cloud CapEx guidance up** (MSFT, GOOGL, AMZN) | EQIX, DLR (data center REITs), NFLX infra | Power + cooling demand, bandwidth | +| **Oil supply shock** | XOM, CVX (Bull); DAL, UAL (Bear) | Energy input costs hit airlines directly | +| **Fed rate hike** | TLT, IEF (Bear); XLF, BRK (Bull) | Long-duration bonds reprice; bank margins expand | +| **Fed rate cut** | TLT, XLRE (Bull); XLF (Bear) | REITs re-rate; bank NIM compresses | +| **Strong USD** | EEM, multinational exporters (Bear) | Revenue headwind for USD-earners abroad | +| **Retail sales miss** | WMT, TGT (Bear); AMZN (neutral/Bull) | Discretionary demand shift to e-commerce | +| **Pharma approval** | Competitor biotech (Bear) | Market share displacement | +| **Cybersecurity breach (major)** | CRWD, PANW, FTNT (Bull) | Accelerates enterprise security spend | + +--- + +## 5. Validation Checklist + +Before acting on a catalyst, run the tickers through the screener and answer: + +### For Bull plays: +- [ ] Does it pass the **Market-Adjusted** analysis? (minimum bar — if not, it's pure momentum) +- [ ] Does it pass **Fundamental** analysis? (if yes → Strong Buy conviction; if not → Speculation) +- [ ] Is FCF yield positive? (sustains the business through the catalyst period) +- [ ] Is D/E manageable? (high leverage + catalyst = binary outcome, size accordingly) +- [ ] Is the 52-week position below 85%? (if near highs, the market may have priced it in) + +### For Bear plays: +- [ ] Does it **fail both** analyses? (confirms the fundamental short thesis) +- [ ] Is short interest > 5% of float? (existing agreement in the market) +- [ ] Is the horizon realistic? (overvalued stocks can stay overvalued — Bear plays need a catalyst *timeframe*) + +### Horizon vs screener relevance: +| Horizon | Use screener for... | +| :--- | :--- | +| Short (1–5 days) | Confirm the stock isn't already broken (avoid catching falling knives on longs) | +| Medium (1–4 weeks) | Gate check — does fundamental quality support a re-rating? | +| Long (1+ quarter) | Full weight on both analyses — you need the fundamentals on your side | + +--- + +## 6. Current Market Regime Context + +> **This section should be refreshed from `npm start` output before each session.** + +The screener derives the current regime from live Yahoo Finance data on startup: + +| Signal | What it means for catalysts | +| :--- | :--- | +| **Rate Regime: HIGH** (10Y > 5%) | Long-duration trades are punished. Favour cash-generative, short-horizon plays. Short TLT, long XLF. | +| **Rate Regime: NORMAL** (2–5%) | Standard playbook applies. | +| **Rate Regime: LOW** (< 2%) | Growth and duration trades work. REITs and long bonds are viable longs. | +| **Volatility: HIGH** (VIX > 25) | Position sizes down. Mean-reversion trades outperform momentum. | +| **Volatility: NORMAL** (VIX 15–25) | Trend-following works. | +| **Volatility: LOW** (VIX < 15) | Risk-on. Momentum and growth outperform. Watch for complacency reversals. | + +--- + +## 7. Bear Catalyst Template + +A structured short thesis requires more rigour than a bull thesis. Use this template. + +> **Ticker:** [TICKER] +> +> **Catalyst:** [What event breaks the bull narrative?] +> +> **Fundamental support:** +> - Fails screener gate: [which gate, e.g. "P/E 120x > inflated gate of 57x"] +> - Trend: [revenue decelerating / margins compressing / FCF turning negative] +> +> **Market structure support (need at least one):** +> - Short interest: [X% of float] +> - Earnings revision trend: [# of downward revisions last 90 days] +> - Sector rotation: [which sector ETF is seeing outflows] +> +> **Risk to thesis:** [What would invalidate the short — e.g. "earnings beat with raised guidance"] +> +> **Horizon:** [Short / Medium / Long] +> **Stop:** [Price level or event that closes the trade] + +--- + +## 8. Adding Catalyst Tickers to the Screener + +Edit `index.js` and add tickers from the Impact Matrix to the `tickers` array, then run: + +```bash +npm start +``` + +The screener will score each ticker under both the **Market-Adjusted** and **Fundamental** lenses and open `screener-report.html` with the full breakdown. Cross-reference the Signal column with your catalyst thesis: + +| Signal | Catalyst interpretation | +| :--- | :--- | +| ✅ Strong Buy | Fundamental quality + catalyst momentum aligned. Highest conviction. | +| ⚡ Momentum | Catalyst works in today's market but price is stretched on fundamentals. Respect the stop. | +| ⚠️ Speculation | Pure catalyst play — fundamentals don't support it. Small size, tight stop. | +| 🔄 Neutral | Catalyst may be already priced in. Wait for a better entry or skip. | +| ❌ Avoid | Screener and catalyst are both negative. Only valid as a Bear trade. | diff --git a/scripts/summary-reporter.js b/scripts/summary-reporter.js new file mode 100644 index 0000000..966b050 --- /dev/null +++ b/scripts/summary-reporter.js @@ -0,0 +1,37 @@ +// Minimal test reporter: silent on pass, prints failures in full, ends with one summary line. +export default async function* summaryReporter(source) { + const failures = []; + let passed = 0, + failed = 0, + totalMs = 0; + + for await (const event of source) { + // Skip file-level wrapper events (name ends in .js) — only count individual tests. + if (event.data?.name?.endsWith('.js')) continue; + + if (event.type === 'test:pass') { + passed++; + totalMs += event.data.details?.duration_ms ?? 0; + } else if (event.type === 'test:fail') { + failed++; + totalMs += event.data.details?.duration_ms ?? 0; + const err = event.data.details?.error; + failures.push({ + name: event.data.name, + reason: err?.cause?.message ?? err?.message ?? 'unknown', + }); + } + } + + if (failures.length) { + yield '\nFailed tests:\n'; + for (const f of failures) yield ` ❌ ${f.name}\n ${f.reason}\n`; + yield '\n'; + } + + const status = failed === 0 ? '✅' : '❌'; + const time = (totalMs / 1000).toFixed(2); + yield `${status} ${passed + failed} tests: ${passed} passed`; + if (failed) yield `, ${failed} failed`; + yield ` (${time}s)\n`; +} diff --git a/src/analyst/CatalystAnalyst.js b/src/analyst/CatalystAnalyst.js new file mode 100644 index 0000000..9d9345d --- /dev/null +++ b/src/analyst/CatalystAnalyst.js @@ -0,0 +1,52 @@ +import { YahooClient } from '../market/YahooClient.js'; + +const NEWS_QUERIES = ['stock market today', 'earnings report', 'market news']; +const MAX_STORIES = 15; +const TICKER_REGEX = /^[A-Z]{1,6}$/; + +export class CatalystAnalyst { + constructor() { + this.client = new YahooClient(); + } + + async run() { + process.stdout.write('🔍 Fetching market news...'); + const stories = await this._fetchNews(); + const tickers = this._extractTickers(stories); + process.stdout.write(` ${stories.length} stories, ${tickers.length} tickers\n`); + return { tickers, stories }; + } + + async _fetchNews() { + const seen = new Map(); + for (const query of NEWS_QUERIES) { + try { + const { news = [] } = await this.client.yf.search(query, { newsCount: 8, quotesCount: 0 }); + for (const s of news) { + if (!seen.has(s.title)) { + seen.set(s.title, { + title: s.title, + publisher: s.publisher, + link: s.link, + relatedTickers: s.relatedTickers ?? [], + }); + } + } + } catch { + /* skip failed query */ + } + } + return [...seen.values()].slice(0, MAX_STORIES); + } + + _extractTickers(stories) { + const tickers = new Set(); + for (const { relatedTickers } of stories) { + for (const t of relatedTickers) { + const clean = t.split(':')[0].toUpperCase(); + if (TICKER_REGEX.test(clean)) tickers.add(clean); + } + } + return [...tickers]; + } +} diff --git a/src/api/BenchmarkProvider.js b/src/api/BenchmarkProvider.js deleted file mode 100644 index cff5feb..0000000 --- a/src/api/BenchmarkProvider.js +++ /dev/null @@ -1,45 +0,0 @@ -import { YahooClient } from './YahooClient.js'; - -export class BenchmarkProvider { - constructor() { - this.client = new YahooClient(); - this.cache = { data: null, expiresAt: 0 }; - this.TTL_MS = 60 * 60 * 1000; // Cache for 1 hour - } - - async getMarketContext() { - // 1. Return cached data if still valid - if (this.cache.data && Date.now() < this.cache.expiresAt) { - return this.cache.data; - } - - try { - const [sp500, tn10y] = await Promise.all([ - this.client.fetchSummary('^GSPC'), - this.client.fetchSummary('^TNX'), - ]); - - const context = { - sp500Price: sp500.price?.regularMarketPrice ?? 0, - riskFreeRate: tn10y.price?.regularMarketPrice ?? 0, - timestamp: new Date().toISOString(), - }; - - // 2. Validate data sanity (prevent 0-value errors) - if (context.sp500Price === 0 || context.riskFreeRate === 0) { - throw new Error('Invalid market data received (zero values)'); - } - - // 3. Update cache - this.cache = { data: context, expiresAt: Date.now() + this.TTL_MS }; - return context; - } catch (error) { - console.error( - 'Market data fetch failed, using last known or empty state:', - error, - ); - // If we have stale cache, use it even if expired, otherwise return safe defaults - return this.cache.data || { sp500Price: 4500, riskFreeRate: 4.0 }; - } - } -} diff --git a/src/config/ScoringConfig.js b/src/config/ScoringConfig.js index c7804d2..81907ba 100644 --- a/src/config/ScoringConfig.js +++ b/src/config/ScoringConfig.js @@ -1,54 +1,147 @@ +// Credit rating scale (S&P convention). +// Bond.js converts letter ratings to these numbers; BondScorer uses them for gate checks. +// Investment grade = BBB (7) and above. +export const CREDIT_RATING_SCALE = { + AAA: 10, + AA: 9, + A: 8, + BBB: 7, + BB: 6, + B: 5, + CCC: 4, + CC: 3, + C: 2, + D: 1, +}; + +// ───────────────────────────────────────────────────────────────────────────── +// Fundamental baseline — Graham / value-investing style. +// MarketRegime.js overrides the valuation gates for INFLATED-mode analysis. +// Sector overrides are structural — they apply in both modes. +// ───────────────────────────────────────────────────────────────────────────── export const ScoringRules = { - // --- BASE ASSET CLASSES --- STOCK: { - meta: { version: '1.2', description: 'Value & Growth Hybrid' }, gates: { - maxDebtToEquity: 3.0, - minQuickRatio: 0.25, - maxPERatio: 80, - maxPegGate: 5.0, + maxDebtToEquity: 1.5, // Graham ceiling; 3.0 was too permissive — most distress starts above 2x + minQuickRatio: 0.8, // Raised from 0.5: below 0.8 signals real liquidity stress in non-tech + 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) + }, + weights: { + margin: 2, // net profit margin + opMargin: 2, // operating margin (pricing power) + roe: 3, // return on equity — Buffett's primary quality metric + peg: 2, // valuation relative to growth + revenue: 2, // revenue growth + fcf: 3, // raised: FCF is the most manipulation-resistant quality signal }, - weights: { margin: 3, peg: 2, revenue: 2, fcf: 1 }, thresholds: { - marginHigh: 20, - marginMed: 10, - pegHigh: 1.3, - pegMed: 2.0, - revHigh: 15, + marginHigh: 15, // lowered from 20: 15% net margin is genuinely excellent across most sectors + marginMed: 8, // lowered from 10: 8% is the realistic mid-tier for industrials/retail + opMarginHigh: 20, + opMarginMed: 10, + roeHigh: 15, // lowered from 20: sustainable 15% ROE is Buffett-quality; 20% is rare/fleeting + roeMed: 10, // kept — 10% is the cost-of-equity floor for most businesses + pegHigh: 0.75, // raised bar: PEG < 0.75 is genuinely cheap relative to growth + pegMed: 1.0, + revHigh: 10, // lowered from 15: 10% organic revenue growth is strong for mature cos revMed: 5, + fcfHigh: 5, + fcfMed: 2, }, - // Sector-specific intelligence SECTOR_OVERRIDE: { + // Large-cap tech borrows to fund buybacks — D/E 2.0 is structural, not distress. + // AAPL quick ratio runs ~0.9 by design (aggressive working capital management). + // Raised maxPERatio from 30→35: mega-cap tech comps (MSFT, GOOG) trade 28-35x sustainably. + // Tightened maxPegGate from 2.0→1.5: paying >1.5x PEG for tech rarely ends well long-term. TECHNOLOGY: { - gates: { maxDebtToEquity: 1.0, minQuickRatio: 1.2, maxPegGate: 2.5 }, - weights: { margin: 2, peg: 3, revenue: 4 }, - thresholds: { marginHigh: 30, pegHigh: 1.5, revHigh: 25 }, + gates: { maxDebtToEquity: 2.0, minQuickRatio: 0.8, maxPERatio: 35, maxPegGate: 1.5 }, + weights: { margin: 1, opMargin: 3, roe: 3, peg: 3, revenue: 4, fcf: 3 }, + thresholds: { marginHigh: 25, opMarginHigh: 25, roeHigh: 20, pegHigh: 1.0, revHigh: 20 }, }, + + // REITs: P/E and PEG are distorted by depreciation — score on yield and P/FFO. + // Raised minYield from 4.0→4.5: 10Y yield at 4.5%+ means REITs must clear that bar to add value. + // Tightened maxPFFO from 15→18: 15 was too tight; well-run REITs (O, VICI) trade 17-22x P/FFO. + // Explicitly zero out weights that don't apply to REITs. REIT: { - gates: { maxDebtToEquity: 6.0, minQuickRatio: 0.1 }, - weights: { yield: 5, fcf: 3 }, - thresholds: { minYield: 0.05 }, + gates: { maxDebtToEquity: 6.0, minQuickRatio: 0.1, maxPERatio: 9999, maxPegGate: 9999 }, + weights: { margin: 0, opMargin: 0, roe: 0, peg: 0, revenue: 0, fcf: 0, yield: 5, pFFO: 3 }, + thresholds: { minYield: 4.5, maxPFFO: 20 }, }, + + // Banks: P/E and PEG are distorted by loan loss provisions. + // Price-to-Book is the primary valuation metric. + // Lowered maxPriceToBook from 2.0→1.5: P/B > 1.5 for banks outside crisis recovery is expensive. + // Tightened ROE threshold: 12% is the realistic cost-of-equity for US banks; 10% is break-even. FINANCIAL: { - gates: { minQuickRatio: 0.5 }, - weights: { margin: 1, revenue: 1 }, - thresholds: { marginHigh: 10 }, + gates: { + maxDebtToEquity: 9999, + minQuickRatio: 0.1, + maxPERatio: 9999, + maxPegGate: 9999, + maxPriceToBook: 1.5, + }, + weights: { margin: 0, opMargin: 0, peg: 0, roe: 5, revenue: 1, fcf: 1, priceToBook: 3 }, + thresholds: { roeHigh: 15, roeMed: 12, revHigh: 10, revMed: 5 }, + }, + + // Energy: capital-heavy, cyclical. D/E up to 1.5 is normal. + // FCF yield is the primary quality signal (replaces margin); opMargin matters for integrated cos. + // Div yield is scored because energy majors return capital via dividends. + ENERGY: { + gates: { maxDebtToEquity: 1.5, minQuickRatio: 0.6, maxPERatio: 15, maxPegGate: 1.5 }, + weights: { margin: 0, opMargin: 3, roe: 2, peg: 1, revenue: 2, fcf: 4, yield: 3 }, + thresholds: { + opMarginHigh: 20, + opMarginMed: 10, + roeHigh: 15, + roeMed: 8, + fcfHigh: 8, + fcfMed: 4, + }, + }, + + // Healthcare: high R&D burn distorts net margin; focus on revenue growth and FCF. + // P/E can be elevated for pipeline names — gate loosened slightly. + HEALTHCARE: { + gates: { maxDebtToEquity: 1.5, minQuickRatio: 1.0, maxPERatio: 25, maxPegGate: 1.5 }, + weights: { margin: 1, opMargin: 2, roe: 2, peg: 2, revenue: 4, fcf: 3 }, + thresholds: { + marginHigh: 20, + marginMed: 10, + roeHigh: 20, + roeMed: 12, + revHigh: 15, + revMed: 8, + fcfHigh: 8, + fcfMed: 3, + }, }, }, }, ETF: { - meta: { version: '1.0', description: 'Cost & Yield Efficiency' }, - gates: { maxExpenseRatio: 0.75 }, - weights: { yield: 2, lowCost: 3 }, - thresholds: { minYield: 0.02, maxExpense: 0.2 }, + // Raised expense gate from 0.5→0.2: with so many sub-0.1% index ETFs available, + // a 0.5% expense ratio is genuinely hard to justify except for niche/active strategies. + gates: { maxExpenseRatio: 0.2 }, + weights: { yield: 2, lowCost: 4 }, // raised lowCost weight: cost is the #1 predictive factor for ETF returns + thresholds: { + minYield: 1.5, + maxExpense: 0.05, // lowered from 0.1: 0.05% is achievable for broad market ETFs + minVolume: 1000000, // raised from 500k: 1M ADV is the real liquidity floor to avoid slippage + }, }, BOND: { - meta: { version: '1.0', description: 'Credit Quality & Duration' }, - gates: { minCreditRating: 5 }, + // Kept investment-grade floor at BBB — still correct. Below BBB is speculative. + // Raised minSpread from 1.0→1.5: with risk-free at 4.5%, you need >1.5% spread + // to be compensated for credit risk vs just buying Treasuries. + // Tightened maxDuration from 10→7: in a HIGH rate regime, duration > 7 carries + // meaningful rate-sensitivity risk (every 1% rate rise ≈ 7% price loss). + gates: { minCreditRating: 7 }, // BBB = investment-grade floor weights: { yieldSpread: 3, duration: 2 }, - thresholds: { minSpread: 1.5, maxDuration: 10 }, + thresholds: { minSpread: 1.5, maxDuration: 7 }, }, }; diff --git a/src/config/constants.js b/src/config/constants.js new file mode 100644 index 0000000..1118060 --- /dev/null +++ b/src/config/constants.js @@ -0,0 +1,48 @@ +export const SIGNAL = { + STRONG_BUY: '✅ Strong Buy', + MOMENTUM: '⚡ Momentum', + SPECULATION: '⚠️ Speculation', + NEUTRAL: '🔄 Neutral', + AVOID: '❌ Avoid', +}; + +export const ASSET_TYPE = { + STOCK: 'STOCK', + ETF: 'ETF', + BOND: 'BOND', + CRYPTO: 'crypto', +}; + +export const SECTOR = { + TECHNOLOGY: 'TECHNOLOGY', + REIT: 'REIT', + FINANCIAL: 'FINANCIAL', + GENERAL: 'GENERAL', +}; + +export const SCORE_MODE = { + FUNDAMENTAL: 'FUNDAMENTAL', + INFLATED: 'INFLATED', +}; + +export const REGIME = { + LOW: 'LOW', + NORMAL: 'NORMAL', + HIGH: 'HIGH', +}; + +export const YAHOO_MODULES = [ + 'assetProfile', + 'financialData', + 'defaultKeyStatistics', + 'price', + 'summaryDetail', +]; + +export const SIGNAL_ORDER = { + [SIGNAL.STRONG_BUY]: 0, + [SIGNAL.MOMENTUM]: 1, + [SIGNAL.NEUTRAL]: 2, + [SIGNAL.SPECULATION]: 3, + [SIGNAL.AVOID]: 4, +}; diff --git a/src/core/assets/Asset.js b/src/core/assets/Asset.js deleted file mode 100644 index 9155d22..0000000 --- a/src/core/assets/Asset.js +++ /dev/null @@ -1,23 +0,0 @@ -export class Asset { - constructor(data) { - this.ticker = (data.ticker || 'UNKNOWN').toUpperCase(); - this.currentPrice = data.currentPrice || 0; - this.type = (data.type || 'STOCK').toUpperCase(); // STOCK, ETF, or BOND - - // Store all raw data as a property so it's accessible but not "logic-heavy" - this.rawData = data; - } - - // Pure Formatting Helpers - These are the only "logic" this class should own - formatCurrency(val) { - return val ? `$${val.toFixed(2)}` : 'N/A'; - } - - formatLargeNumber(num) { - if (!num) return 'N/A'; - if (num >= 1e12) return (num / 1e12).toFixed(2) + 'T'; - if (num >= 1e9) return (num / 1e9).toFixed(2) + 'B'; - if (num >= 1e6) return (num / 1e6).toFixed(2) + 'M'; - return num.toString(); - } -} diff --git a/src/core/assets/Stock.js b/src/core/assets/Stock.js deleted file mode 100644 index c1deeff..0000000 --- a/src/core/assets/Stock.js +++ /dev/null @@ -1,60 +0,0 @@ -import { Asset } from './Asset.js'; - -export class Stock extends Asset { - constructor(data) { - super(data); - // console.log('Data:', data); - this.sector = this._mapToStandardSector(data || {}); - - // Financial Metrics - These are now just "state" - this.metrics = { - sector: this.sector, - quickRatio: parseFloat(data.quickRatio) || 0, - debtToEquity: parseFloat(data.debtToEquity) || 0, - fcfGrowth: data.fcfGrowth ?? 'neutral', - revenueGrowth: parseFloat(data.revenueGrowth) || 0, - netProfitMargin: parseFloat(data.netProfitMargin) || 0, - pegRatio: parseFloat(data.pegRatio) || null, - peRatio: parseFloat(data.peRatio) || null, - }; - } - - _mapToStandardSector(data) { - // 1. Safely grab the profile from the data object - const profile = data.assetProfile || {}; - - // 2. Extract values safely - const industry = (profile.industry || '').toLowerCase(); - const sector = (profile.sector || '').toLowerCase(); - const combined = `${industry} ${sector}`; - - // 3. Match logic - if (combined.includes('technology') || combined.includes('electronic')) - return 'TECHNOLOGY'; - if (combined.includes('real estate') || combined.includes('reit')) - return 'REIT'; - if (combined.includes('financial') || combined.includes('bank')) - return 'FINANCIAL'; - - return 'GENERAL'; - } - - // Helper for dashboard display - getDisplayMetrics() { - const formatFcf = (s) => - ({ positive: '🟢', neutral: '🟠', negative: '🔴' })[s] || 'N/A'; - - return { - Ticker: this.ticker, - Price: this.formatCurrency(this.currentPrice), - Sector: this.sector, - 'PE Ratio': this.metrics.peRatio?.toFixed(2) ?? 'N/A', - 'FCF%': formatFcf(this.metrics.fcfGrowth), - 'PEG/Fee': this.metrics.pegRatio?.toFixed(2) ?? 'N/A', - 'Rev%': `${this.metrics.revenueGrowth.toFixed(1)}%`, - 'Marg%': `${this.metrics.netProfitMargin.toFixed(1)}%`, - Quick: this.metrics.quickRatio?.toFixed(2) ?? 'N/A', - 'D/E': this.metrics.debtToEquity.toFixed(2), - }; - } -} diff --git a/src/core/engine/ScoringEngine.js b/src/core/engine/ScoringEngine.js deleted file mode 100644 index 3e240b1..0000000 --- a/src/core/engine/ScoringEngine.js +++ /dev/null @@ -1,49 +0,0 @@ -import { StockScorer } from '../scorers/StockScorer.js'; -import { EtfScorer } from '../scorers/EtfScorer.js'; -import { BondScorer } from '../scorers/BondScorer.js'; -import { ScoringRules } from '../../config/ScoringConfig.js'; - -export const ScoringEngine = { - // Registry of available strategies - _scorers: { - STOCK: StockScorer, - ETF: EtfScorer, - BOND: BondScorer, - }, - - /** - * @param {string} type - 'STOCK', 'ETF', or 'BOND' - * @param {Object} data - The raw metric data - * @param {Object} context - Optional market context (for bonds) - */ - // In ScoringEngine.js - evaluate(type, assetInstance, marketContext = {}) { - const scorer = this._scorers[type]; - - // 1. Get the metrics (this assumes assetInstance has the metrics object) - const metrics = assetInstance.metrics; - - // 2. MERGE: Get sector-specific, merged rules - const finalRules = RuleMerger.getRulesForAsset(type, metrics); - - // 3. ADAPT: Apply market context (Yields, etc.) - const adaptedRules = this._applyMarketContext(finalRules, marketContext); - - // 4. SCORE: Pass the adapted rules to the scorer - return scorer.score(metrics, adaptedRules, marketContext); - }, - - _applyMarketContext(rules, context) { - if (context.tenYearYield > 4.0) { - // Tighten valuation expectations when rates are high - return { - ...rules, - gates: { - ...rules.gates, - maxPERatio: Math.floor(rules.gates.maxPERatio * 0.8), - }, - }; - } - return rules; - }, -}; diff --git a/src/core/engine/ScreenerEngine.js b/src/core/engine/ScreenerEngine.js deleted file mode 100644 index 4130c90..0000000 --- a/src/core/engine/ScreenerEngine.js +++ /dev/null @@ -1,122 +0,0 @@ -import { YahooClient } from '../../api/YahooClient.js'; -import { mapToStandardFormat } from '../../utils/DataMapper.js'; -import { Stock } from '../assets/Stock.js'; -import { Etf } from '../assets/Etf.js'; -import { Bond } from '../assets/Bond.js'; -import { chunkArray } from '../../utils/Chunker.js'; -import { BenchmarkProvider } from '../../api/BenchmarkProvider.js'; -import { RuleMerger } from '../../utils/RulesMerger.js'; -import { StockScorer } from '../scorers/StockScorer.js'; -import { EtfScorer } from '../scorers/EtfScorer.js'; -import { BondScorer } from '../scorers/BondScorer.js'; - -export class ScreenerEngine { - constructor() { - this.client = new YahooClient(); - this.benchmarkProvider = new BenchmarkProvider(); - } - - _createAssetInstance(data) { - const type = (data.type || 'STOCK').toUpperCase(); - switch (type) { - case 'BOND': - return new Bond(data); - case 'ETF': - return new Etf(data); - default: - return new Stock(data); - } - } - - async _fetchAndProcess(ticker) { - try { - const summary = await this.client.fetchSummary(ticker); - if (!summary?.price) throw new Error('Invalid Payload'); - return mapToStandardFormat(ticker, summary); - } catch (error) { - // Return a structured error object that mimics the successful data format - return { - isError: true, - Ticker: ticker.toUpperCase(), - Type: 'STOCK', - Verdict: `🔴 ${error.message}`, - }; - } - } - - async runParallelScreener(tickerList) { - const marketContext = await this.benchmarkProvider.getMarketContext(); - console.log( - `📊 Market Context Loaded: 10Y Yield at ${marketContext.riskFreeRate}%`, - ); - - // Map types to their respective Scorers - const scorers = { STOCK: StockScorer, ETF: EtfScorer, BOND: BondScorer }; - const chunks = chunkArray(tickerList, 5); - const results = {}; - - for (const chunk of chunks) { - const rawDataBatch = await Promise.all( - chunk.map((t) => this._fetchAndProcess(t)), - ); - - rawDataBatch.forEach((data) => { - if (data.isError) { - if (!results['ERROR']) results['ERROR'] = []; - results['ERROR'].push(data); - return; - } - - // 1. Instantiate the lean Data Container (Stock, Etf, or Bond) - const asset = this._createAssetInstance(data); - const type = asset.type; - - // 2. Merge rules (Utility handles sector overrides) - const rules = RuleMerger.getRulesForAsset(type, asset.metrics); - - // 3. Direct Scoring (Bypassing the old circular evaluate() call) - const scorer = scorers[type]; - const scoreResult = scorer.score(asset.metrics, rules, marketContext); - - // 4. Combine display data with the final verdict - const finalResult = { - asset: asset, - Verdict: scoreResult.label, - 'G/O/R': scoreResult.scoreSummary, - }; - - if (!results[type]) results[type] = []; - results[type].push(finalResult); - }); - - await new Promise((resolve) => setTimeout(resolve, 1000)); - } - - this._display(results); - } - - _display(results) { - Object.keys(results).forEach((type) => { - if (type === 'ERROR') { - console.log('--- ERRORS ---', results.ERROR); - return; - } - - // Sort the raw objects (not the mapped objects) - const sortedData = [...results[type]].sort((a, b) => - b.Verdict.localeCompare(a.Verdict), - ); - - console.log(`\n--- ${type} MATRIX ---`); - - // Use the class method to get the display-ready object - const tableData = sortedData.map((item) => ({ - ...item.asset.getDisplayMetrics(), // <--- THIS RE-USES YOUR CLASS METHOD - Verdict: item.Verdict, // Injected from the Engine result - 'G/O/R': item['G/O/R'], // Injected from the Engine result - })); - - console.table(tableData); - }); - } -} diff --git a/src/core/scorers/BondScorer.js b/src/core/scorers/BondScorer.js deleted file mode 100644 index 3b96cbc..0000000 --- a/src/core/scorers/BondScorer.js +++ /dev/null @@ -1,63 +0,0 @@ -import { ScoringRules } from '../../config/ScoringConfig.js'; - -export const BondScorer = { - /** - * @param {Object} m - Metrics (ytm, duration, creditRating) - * @param {Object} context - Market environment (riskFreeRate) - */ - score(m, rules, context) { - const { gates, weights, thresholds } = rules; - const metrics = this._sanitize(m); - - // Safety check for riskFreeRate (ensure it's a decimal, e.g., 0.04) - const riskFreeRate = context?.riskFreeRate ?? 0.04; - const spread = metrics.ytm - riskFreeRate; - - let score = 0; - const breakdown = {}; - - // 1. Spread Logic: If spread is >= 0, it's at least neutral - breakdown.spread = - spread >= thresholds.minSpread ? weights.yieldSpread : -2; - - // 2. Duration Logic - breakdown.duration = - metrics.duration <= thresholds.maxDuration ? weights.duration : -1; - - // 3. Credit Rating Logic (Handling 'N/A') - if (metrics.creditRating === 'Junk') { - score -= 5; - } - - score = Object.values(breakdown).reduce((a, b) => a + b, 0); - - return { - label: this._getLabel(score), - scoreSummary: `Score: ${score}`, - audit: { breakdown }, - }; - }, - - // --- Helpers --- - - _sanitize(m) { - // Convert percentage string '3.95%' to decimal 0.0395 - const parsePercent = (val) => { - if (typeof val === 'string') val = val.replace('%', ''); - return parseFloat(val) / 100 || 0; - }; - - return { - ytm: parsePercent(m.ytm), - duration: parseFloat(m.duration) || 0, - creditRating: - m.creditRating === 'N/A' ? 'InvestmentGrade' : m.creditRating, // Treat N/A as safe - }; - }, - - _getLabel(score) { - if (score >= 4) return '🟢 Attractive'; - if (score >= 1) return '🟡 Neutral'; - return '🔴 Avoid'; - }, -}; diff --git a/src/core/scorers/EtfScorer.js b/src/core/scorers/EtfScorer.js deleted file mode 100644 index 3dc27a8..0000000 --- a/src/core/scorers/EtfScorer.js +++ /dev/null @@ -1,62 +0,0 @@ -import { ScoringRules } from '../../config/ScoringConfig.js'; - -/** - * EtfScorer: Evaluates ETFs with mandatory fee gates and weighted scoring. - */ -export const EtfScorer = { - score(m, rules) { - const { gates, weights, thresholds } = rules; - const metrics = this._sanitize(m); - - // 1. GATE KEEPING: High fees trigger an automatic reject - if (metrics.expenseRatio > gates.maxExpenseRatio) { - return { - label: '🔴 REJECT', - scoreSummary: 'GATE FAILED: High Expense Ratio', - }; - } - - // 2. SCORING REGISTRY - const scoringRegistry = [ - { - key: 'cost', - fn: () => - metrics.expenseRatio <= thresholds.maxExpense ? weights.lowCost : -3, - }, - { - key: 'yield', - fn: () => (metrics.yield >= thresholds.minYield ? weights.yield : -1), - }, - { key: 'vol', fn: () => (metrics.volume >= 100000 ? 0 : -2) }, - ]; - - const breakdown = {}; - const totalScore = scoringRegistry.reduce((sum, item) => { - breakdown[item.key] = item.fn(); - return sum + breakdown[item.key]; - }, 0); - - // 3. RESULT - return { - label: this._getLabel(totalScore), - scoreSummary: `Score: ${totalScore}`, - audit: { passedGates: true, breakdown }, - }; - }, - - // --- Helpers --- - - _sanitize(m) { - return { - expenseRatio: parseFloat(m.expenseRatio) || 0, - yield: parseFloat(m.yield) || 0, - volume: parseFloat(m.volume) || 0, - }; - }, - - _getLabel(score) { - if (score >= 3) return '🟢 Efficient'; - if (score >= 0) return '🟡 Neutral'; - return '🔴 Expensive/Low Yield'; - }, -}; diff --git a/src/core/scorers/StockScorer.js b/src/core/scorers/StockScorer.js deleted file mode 100644 index d7ada40..0000000 --- a/src/core/scorers/StockScorer.js +++ /dev/null @@ -1,107 +0,0 @@ -import { ScoringRules } from '../../config/ScoringConfig.js'; - -export const StockScorer = { - score(m, rules) { - const { gates, weights, thresholds } = rules; - const metrics = this._sanitize(m); - - // 1. DYNAMIC GATE KEEPING - const gateResult = this._checkGates(metrics, gates); - if (!gateResult.passed) - return { label: '🔴 REJECT', scoreSummary: gateResult.reason }; - - // 2. DYNAMIC SCORING REGISTRY - const scoringRegistry = [ - { - key: 'margin', - fn: () => - this._scoreValue( - metrics.netProfitMargin, - thresholds.marginHigh, - thresholds.marginMed, - weights.margin, - ), - }, - { - key: 'peg', - fn: () => - this._scorePeg( - metrics.pegRatio, - thresholds.pegHigh, - thresholds.pegMed, - weights.peg, - ), - }, - { - key: 'rev', - fn: () => - this._scoreValue( - metrics.revenueGrowth, - thresholds.revHigh, - thresholds.revMed, - weights.revenue, - ), - }, - { key: 'fcf', fn: () => (metrics.fcfGrowth === 'positive' ? 2 : -2) }, - ]; - - const breakdown = {}; - const totalScore = scoringRegistry.reduce((sum, item) => { - breakdown[item.key] = item.fn(); - return sum + breakdown[item.key]; - }, 0); - - return { - label: this._getLabel(totalScore), - scoreSummary: `Score: ${totalScore}`, - analystRating: m.analystConsensus || 'N/A', // Add this - audit: { passedGates: true, breakdown }, - }; - }, - - _checkGates(m, g) { - const failures = []; - if (m.debtToEquity > g.maxDebtToEquity) failures.push('Debt/Equity'); - if (m.quickRatio < g.minQuickRatio) failures.push('QuickRatio'); - if (m.peRatio > g.maxPERatio) failures.push('PERatio'); - if (m.pegRatio > g.maxPegGate) failures.push('High Valuation'); - - return { - passed: failures.length === 0, - reason: `GATE FAILED: ${failures.join(', ')}`, - }; - }, - - _scoreValue: (val, high, med, weight) => - val >= high ? weight : val >= med ? 1 : -2, - - _scorePeg: (val, high, med, weight) => - val > 0 && val <= high ? weight : val <= med ? 0 : -2, - - _scoreGradient: (val, high, med, weight) => { - if (val >= high) return weight; - if (val >= med) return Math.round(weight * 0.5); // Partial credit for mid-tier - return -1; // Less punitive than -2 - }, - - _sanitize(m) { - return { - debtToEquity: parseFloat(m.debtToEquity) || 0, - quickRatio: parseFloat(m.quickRatio) || 0, - peRatio: parseFloat(m.peRatio) || 0, - netProfitMargin: parseFloat(m.netProfitMargin) || 0, - pegRatio: parseFloat(m.pegRatio) || 999, - revenueGrowth: parseFloat(m.revenueGrowth) || 0, - fcfGrowth: m.fcfGrowth ?? 'neutral', - dividendYield: parseFloat(m.dividendYield) || 0, - roe: parseFloat(m.roe) || 0, - }; - }, - - _getLabel(score) { - if (score >= 5) return '🟢 BUY (High Conviction)'; - if (score >= 2) return '🟢 BUY (Speculative)'; - if (score < -2) return '🔴 REJECT'; - return '🟡 HOLD'; - }, -}; diff --git a/src/finance/PersonalFinanceAnalyzer.js b/src/finance/PersonalFinanceAnalyzer.js new file mode 100644 index 0000000..f3eaaef --- /dev/null +++ b/src/finance/PersonalFinanceAnalyzer.js @@ -0,0 +1,62 @@ +// PersonalFinanceAnalyzer +// +// Takes normalised SimpleFIN account data and computes: +// - Net worth (assets - liabilities) +// - Cash vs investment allocation +// - Spending by category (last 30 days) +// - Top spending categories +// - Income vs expenses summary + +export class PersonalFinanceAnalyzer { + analyse(accounts) { + const assets = accounts.filter((a) => !['CREDIT', 'LOAN'].includes(a.type)); + const liabilities = accounts.filter((a) => ['CREDIT', 'LOAN'].includes(a.type)); + + const totalAssets = assets.reduce((s, a) => s + Math.max(0, a.balance), 0); + const totalLiabilities = liabilities.reduce((s, a) => s + Math.abs(Math.min(0, a.balance)), 0); + const netWorth = totalAssets - totalLiabilities; + + const cash = accounts.filter((a) => ['CHECKING', 'SAVINGS'].includes(a.type)); + const investments = accounts.filter((a) => a.type === 'INVESTMENT'); + const totalCash = cash.reduce((s, a) => s + Math.max(0, a.balance), 0); + const totalInvest = investments.reduce((s, a) => s + Math.max(0, a.balance), 0); + + // Aggregate all transactions across accounts + const allTx = accounts.flatMap((a) => a.transactions); + + const spending = allTx.filter((tx) => tx.amount < 0 && tx.category !== 'Transfer'); + const income = allTx.filter((tx) => tx.amount > 0 && tx.category === 'Income'); + + const totalSpend = spending.reduce((s, tx) => s + Math.abs(tx.amount), 0); + const totalIncome = income.reduce((s, tx) => s + tx.amount, 0); + + // Spending by category + const byCategory = {}; + for (const tx of spending) { + byCategory[tx.category] = (byCategory[tx.category] ?? 0) + Math.abs(tx.amount); + } + const categoryBreakdown = Object.entries(byCategory) + .sort((a, b) => b[1] - a[1]) + .map(([category, amount]) => ({ + category, + amount, + pct: totalSpend > 0 ? ((amount / totalSpend) * 100).toFixed(1) : '0', + })); + + return { + netWorth, + totalAssets, + totalLiabilities, + totalCash, + totalInvestments: totalInvest, + cashPct: totalAssets > 0 ? ((totalCash / totalAssets) * 100).toFixed(1) : '0', + investPct: totalAssets > 0 ? ((totalInvest / totalAssets) * 100).toFixed(1) : '0', + totalIncome, + totalSpend, + savingsRate: + totalIncome > 0 ? (((totalIncome - totalSpend) / totalIncome) * 100).toFixed(1) : null, + categoryBreakdown, + accounts, + }; + } +} diff --git a/src/finance/PortfolioAdvisor.js b/src/finance/PortfolioAdvisor.js new file mode 100644 index 0000000..1b6bc25 --- /dev/null +++ b/src/finance/PortfolioAdvisor.js @@ -0,0 +1,161 @@ +import { SIGNAL } from '../config/constants.js'; +import { YahooClient } from '../market/YahooClient.js'; + +export class PortfolioAdvisor { + constructor() { + this.client = new YahooClient(); + } + + async advise(holdings, screenedResults) { + const resultMap = Object.fromEntries( + [ + ...(screenedResults.STOCK ?? []), + ...(screenedResults.ETF ?? []), + ...(screenedResults.BOND ?? []), + ].map((r) => [r.asset.ticker, r]), + ); + + const cryptoPrices = await this._cryptoPrices(holdings.filter((h) => h.type === 'crypto')); + + return holdings.map((holding) => { + const type = (holding.type ?? 'stock').toLowerCase(); + const source = holding.source ?? '—'; + const price = + type === 'crypto' + ? cryptoPrices[holding.ticker.toUpperCase()] + : (resultMap[holding.ticker.toUpperCase()]?.asset?.currentPrice ?? null); + + return type === 'crypto' + ? this._row(holding, price, source, '—', '—', '—', this._cryptoAdvice(holding, price)) + : this._stockRow(holding, price, source, resultMap[holding.ticker.toUpperCase()]); + }); + } + + _stockRow(holding, price, source, result) { + if (!result) { + return this._row(holding, price, source, '—', '—', '—', { + action: '⚪ Not screened', + reason: 'Run npm run finance to include this ticker.', + }); + } + return this._row( + holding, + price, + source, + result.signal, + result.inflated.label, + result.fundamental.label, + this._advice(result.signal, holding, price), + ); + } + + _row(holding, currentPrice, source, signal, inflated, fundamental, { action, reason }) { + const { marketValue, totalCost, gainLossPct } = this._position(holding, currentPrice); + return { + ticker: holding.ticker, + type: holding.type ?? 'stock', + source, + shares: holding.shares, + costBasis: holding.costBasis, + currentPrice, + marketValue, + totalCost, + gainLossPct, + signal, + inflated, + fundamental, + advice: action, + reason, + }; + } + + _position(holding, currentPrice) { + const totalCost = (holding.costBasis * holding.shares).toFixed(2); + const marketValue = currentPrice != null ? (currentPrice * holding.shares).toFixed(2) : null; + const gainLossPct = + currentPrice != null && holding.costBasis > 0 + ? (((currentPrice - holding.costBasis) / holding.costBasis) * 100).toFixed(1) + : null; + return { totalCost, marketValue, gainLossPct }; + } + + _cryptoAdvice(holding, price) { + const { gainLossPct } = this._position(holding, price); + const g = parseFloat(gainLossPct); + if (gainLossPct == null) + return { + action: '⚪ No price data', + reason: 'Crypto — track price and manage risk manually.', + }; + if (g > 100) + return { + action: '🟠 Consider taking profits', + reason: 'Up significantly — no fundamental analysis for crypto.', + }; + if (g < -30) + return { + action: '🔴 Review position', + reason: 'Down significantly — no fundamental analysis for crypto.', + }; + return { + action: '🟡 Hold', + reason: 'Crypto — no fundamental analysis. Track price and manage risk manually.', + }; + } + + _advice(signal, holding, price) { + const { gainLossPct } = this._position(holding, price); + const gain = parseFloat(gainLossPct); + switch (signal) { + case SIGNAL.STRONG_BUY: + return { + action: '🟢 Hold & Add', + reason: 'Passes both analyses. Strong conviction.', + }; + case SIGNAL.MOMENTUM: + return { + action: '🟡 Hold', + reason: + gain > 30 + ? 'Up on momentum — consider partial profit-taking.' + : 'Set a stop-loss — not fundamentally justified.', + }; + case SIGNAL.SPECULATION: + return { + action: gain > 20 ? '🟠 Reduce Position' : '🟡 Hold (small size)', + reason: + gain > 20 + ? 'In profit on speculation — take partial profits.' + : 'Overvalued fundamentally. Keep position small.', + }; + case SIGNAL.NEUTRAL: + return { + action: '🟡 Hold', + reason: 'No clear edge. Review on any catalyst.', + }; + case SIGNAL.AVOID: + return { + action: gain > 0 ? '🔴 Sell (Take Profits)' : '🔴 Sell (Cut Loss)', + reason: + gain > 0 + ? "Fails both analyses — you're in profit, take it." + : 'Fails both analyses — stop the loss from growing.', + }; + default: + return { action: '⚪ Review', reason: 'Signal unclear.' }; + } + } + + async _cryptoPrices(cryptoHoldings) { + const prices = {}; + for (const h of cryptoHoldings) { + try { + const summary = await this.client.fetchSummary(h.ticker); + prices[h.ticker.toUpperCase()] = summary.price?.regularMarketPrice ?? null; + } catch { + prices[h.ticker.toUpperCase()] = null; + } + } + return prices; + } +} diff --git a/src/finance/PortfolioImporter.js b/src/finance/PortfolioImporter.js new file mode 100644 index 0000000..cebf0d5 --- /dev/null +++ b/src/finance/PortfolioImporter.js @@ -0,0 +1,249 @@ +import { existsSync, readFileSync, writeFileSync } from 'fs'; + +// PortfolioImporter +// +// Reads a holdings CSV exported from Robinhood, Vanguard, or Fidelity +// and merges the positions into portfolio.json. +// +// How to export: +// Robinhood → Account → Statements & History → Export CSV (choose Holdings) +// Vanguard → My Accounts → Holdings → Download (top-right icon) +// Fidelity → Accounts & Trade → Portfolio → Positions → Download CSV +// +// Broker is auto-detected from the CSV headers. +// Existing portfolio.json entries are updated in-place; new tickers are added. +// Positions with zero shares are removed. + +export class PortfolioImporter { + // ── Broker column maps ────────────────────────────────────────────────────── + // Each broker uses different header names for the same data. + // Listed in priority order — first match wins. + + static BROKERS = [ + { + name: 'Robinhood', + detect: (headers) => + headers.some((h) => /average.?cost/i.test(h) && headers.some((h2) => /quantity/i.test(h2))), + ticker: ['Symbol'], + shares: ['Quantity'], + costBasis: ['Average Cost', 'Average Buy Price', 'Avg Cost'], + }, + { + name: 'Vanguard', + // Vanguard exports use "Ticker Symbol" and "Shares" — cost basis not always present + detect: (headers) => + headers.some((h) => /ticker.? symbol|ticker symbol/i.test(h)) && + headers.some((h) => /^shares$/i.test(h)), + ticker: ['Ticker Symbol', 'Ticker Symbol', 'Symbol', 'Ticker'], + shares: ['Shares', 'Quantity'], + costBasis: [ + 'Average Cost Basis', + 'Cost Basis Per Share', + 'Avg Cost Basis/Share', + 'Average Cost', + ], + }, + { + name: 'Fidelity', + detect: (headers) => + headers.some((h) => /account.?name/i.test(h)) && + headers.some((h) => /symbol/i.test(h)) && + headers.some((h) => /cost.?basis/i.test(h)), + ticker: ['Symbol'], + shares: ['Quantity', 'Shares'], + costBasis: ['Cost Basis Per Share', 'Average Cost Basis', 'Cost Basis'], + }, + { + name: 'Generic', + detect: () => true, // fallback + ticker: ['Symbol', 'Ticker', 'ticker', 'symbol', 'SYMBOL'], + shares: ['Quantity', 'Shares', 'shares', 'quantity', 'QTY', 'Qty'], + costBasis: [ + 'Average Cost', + 'Cost Basis', + 'Avg Cost', + 'Average Buy Price', + 'Cost Per Share', + 'cost_basis', + ], + }, + ]; + + // ── Public API ────────────────────────────────────────────────────────────── + + import(csvPath, portfolioPath = './portfolio.json', source = null) { + if (!existsSync(csvPath)) { + throw new Error(`File not found: ${csvPath}`); + } + + const raw = readFileSync(csvPath, 'utf8'); + const parsed = this._parseCSV(raw); + + if (parsed.length === 0) { + throw new Error('CSV is empty or could not be parsed.'); + } + + const broker = this._detectBroker(parsed[0]); + const brokerName = source ?? broker.name; + console.log(`\n🔍 Detected broker: ${brokerName}`); + + const holdings = this._extractHoldings(parsed, broker, brokerName); + + if (holdings.length === 0) { + throw new Error( + `No valid holdings found.\n` + + `Headers detected: ${Object.keys(parsed[0]).join(', ')}\n` + + `Tip: use --broker to specify manually if auto-detection failed.`, + ); + } + + const merged = this._mergeIntoPortfolio(holdings, portfolioPath); + + console.log(`✅ Imported ${holdings.length} positions from ${broker.name}`); + console.log(` portfolio.json now has ${merged.holdings.length} holdings\n`); + + holdings.forEach((h) => { + const cb = h.costBasis != null ? ` @ $${h.costBasis.toFixed(2)}` : ' (no cost basis)'; + console.log(` ${h.ticker.padEnd(6)} ${h.shares} shares${cb}`); + }); + + return merged; + } + + // ── CSV parser (no external deps) ────────────────────────────────────────── + + _parseCSV(raw) { + const lines = raw.split(/\r?\n/).filter((l) => l.trim()); + if (lines.length < 2) return []; + + // Find the header row — skip metadata rows at the top (Vanguard has these) + // A valid header row has at least one of these keywords + const headerKeywords = /symbol|ticker|shares|quantity|cost|price/i; + let headerIdx = 0; + for (let i = 0; i < Math.min(lines.length, 10); i++) { + if (headerKeywords.test(lines[i])) { + headerIdx = i; + break; + } + } + + const headers = this._splitRow(lines[headerIdx]).map((h) => h.trim().replace(/^"|"$/g, '')); + const rows = []; + + for (let i = headerIdx + 1; i < lines.length; i++) { + const values = this._splitRow(lines[i]).map((v) => v.trim().replace(/^"|"$/g, '')); + if (values.length < 2 || !values[0]) continue; + + const row = {}; + headers.forEach((h, idx) => { + row[h] = values[idx] ?? ''; + }); + rows.push(row); + } + + return rows; + } + + _splitRow(line) { + // Handle quoted CSV fields that may contain commas + const result = []; + let current = ''; + let inQuotes = false; + + for (const ch of line) { + if (ch === '"') { + inQuotes = !inQuotes; + } else if (ch === ',' && !inQuotes) { + result.push(current); + current = ''; + } else { + current += ch; + } + } + result.push(current); + return result; + } + + // ── Broker detection ──────────────────────────────────────────────────────── + + _detectBroker(sampleRow) { + const headers = Object.keys(sampleRow); + return PortfolioImporter.BROKERS.find((b) => b.detect(headers)); + } + + // ── Holdings extraction ───────────────────────────────────────────────────── + + _extractHoldings(rows, broker, source = null) { + const holdings = []; + + for (const row of rows) { + const ticker = this._getField(row, broker.ticker); + const sharesRaw = this._getField(row, broker.shares); + const costRaw = this._getField(row, broker.costBasis); + + // Skip non-ticker rows (totals, cash, blanks, fund names) + if (!ticker || !/^[A-Z]{1,6}$/.test(ticker.toUpperCase().trim())) continue; + + const shares = parseFloat(sharesRaw?.replace(/[,$]/g, '') ?? '0'); + const costBasis = costRaw ? parseFloat(costRaw.replace(/[,$]/g, '')) : null; + + if (isNaN(shares) || shares <= 0) continue; // skip zero/empty positions + + holdings.push({ + ticker: ticker.toUpperCase().trim(), + shares: +shares.toFixed(6), + costBasis: costBasis != null && !isNaN(costBasis) ? +costBasis.toFixed(4) : null, + source: source ?? broker.name, + type: 'stock', // default; user can change to 'etf' or 'crypto' in portfolio.json + }); + } + + return holdings; + } + + _getField(row, candidates) { + const rowKeys = Object.keys(row); + for (const key of candidates) { + // 1. Exact match + if (row[key] !== undefined && row[key] !== '') return row[key]; + // 2. Case-insensitive exact match + const exact = rowKeys.find((k) => k.toLowerCase() === key.toLowerCase()); + if (exact && row[exact] !== '') return row[exact]; + // 3. Normalised match — collapse whitespace and compare + const norm = (s) => s.toLowerCase().replace(/\s+/g, ' ').trim(); + const fuzzy = rowKeys.find((k) => norm(k) === norm(key)); + if (fuzzy && row[fuzzy] !== '') return row[fuzzy]; + } + return null; + } + + // ── Merge into portfolio.json ─────────────────────────────────────────────── + + _mergeIntoPortfolio(newHoldings, portfolioPath) { + const existing = existsSync(portfolioPath) + ? JSON.parse(readFileSync(portfolioPath, 'utf8')) + : { holdings: [] }; + + const holdingMap = Object.fromEntries( + (existing.holdings ?? []).map((h) => [h.ticker.toUpperCase(), h]), + ); + + for (const h of newHoldings) { + if (holdingMap[h.ticker]) { + // Update existing entry — preserve manually set costBasis if CSV has none + holdingMap[h.ticker].shares = h.shares; + if (h.costBasis != null) holdingMap[h.ticker].costBasis = h.costBasis; + } else { + holdingMap[h.ticker] = { + ticker: h.ticker, + shares: h.shares, + costBasis: h.costBasis ?? 0, + }; + } + } + + const merged = { holdings: Object.values(holdingMap) }; + writeFileSync(portfolioPath, JSON.stringify(merged, null, 2), 'utf8'); + return merged; + } +} diff --git a/src/finance/SimpleFINClient.js b/src/finance/SimpleFINClient.js new file mode 100644 index 0000000..c6c0b9e --- /dev/null +++ b/src/finance/SimpleFINClient.js @@ -0,0 +1,187 @@ +import fs from 'fs'; +import https from 'https'; +import http from 'http'; + +// SimpleFINClient +// +// SimpleFIN auth flow: +// 1. You get a Setup Token from https://beta-bridge.simplefin.org +// 2. This client decodes it, POSTs once to claim an Access URL +// 3. The Access URL is saved to .env automatically (setup token is one-time use) +// 4. All subsequent requests use the Access URL directly +// +// .env configuration: +// First run: SIMPLEFIN_SETUP_TOKEN=aHR0cHM6Ly8... (your one-time setup token) +// After that: SIMPLEFIN_ACCESS_URL=https://user:pass@beta-bridge.simplefin.org/simplefin +// (written automatically on first run — keep it, discard the setup token) + +export class SimpleFINClient { + constructor() { + this.accessUrl = null; + } + + async init() { + // Case 1: Access URL already claimed and stored + if (process.env.SIMPLEFIN_ACCESS_URL) { + this.accessUrl = process.env.SIMPLEFIN_ACCESS_URL.replace(/\/$/, ''); + return; + } + + // Case 2: Setup token present — claim it to get the Access URL + if (process.env.SIMPLEFIN_SETUP_TOKEN) { + this.accessUrl = await this._claimAccessUrl(process.env.SIMPLEFIN_SETUP_TOKEN); + this._saveAccessUrl(this.accessUrl); + return; + } + + throw new Error( + 'SimpleFIN not configured.\n' + + 'Add to .env:\n' + + ' SIMPLEFIN_SETUP_TOKEN=\n' + + 'The Access URL will be saved automatically on first run.', + ); + } + + // Fetches all accounts with balances and recent transactions + async getAccounts(options = {}) { + if (!this.accessUrl) await this.init(); + + const startDate = options.startDate ?? this._daysAgo(30); + const endDate = options.endDate ?? Math.floor(Date.now() / 1000); + + const url = `${this.accessUrl}/accounts?version=2&start-date=${startDate}&end-date=${endDate}`; + const response = await fetch(url); + + if (!response.ok) { + throw new Error(`SimpleFIN error ${response.status}: ${await response.text()}`); + } + + const data = await response.json(); + + // Always surface SimpleFIN errors to the user + if (data.errors?.length) { + data.errors.forEach((e) => console.warn(` ⚠ SimpleFIN: ${e}`)); + } + + return this._normalise(data); + } + + // ── Auth ───────────────────────────────────────────────────────────────────── + + async _claimAccessUrl(setupToken) { + // Setup token is a base64-encoded claim URL + const claimUrl = Buffer.from(setupToken.trim(), 'base64').toString('utf8').trim(); + process.stdout.write(`\n🔑 Claiming SimpleFIN access URL...\n → ${claimUrl}\n`); + + const accessUrl = await this._post(claimUrl); + + if (!accessUrl || !accessUrl.startsWith('http')) { + throw new Error( + `Unexpected response from SimpleFIN: "${accessUrl}"\n` + + 'Setup tokens are one-time use — if already claimed, generate a new one at https://beta-bridge.simplefin.org', + ); + } + + process.stdout.write('✅ Access URL received\n'); + return accessUrl.trim(); + } + + // Raw HTTP POST using Node's built-in module (avoids fetch redirect/header quirks) + _post(url) { + return new Promise((resolve, reject) => { + const parsed = new URL(url); + const lib = parsed.protocol === 'https:' ? https : http; + const options = { + hostname: parsed.hostname, + port: parsed.port || (parsed.protocol === 'https:' ? 443 : 80), + path: parsed.pathname + parsed.search, + method: 'POST', + headers: { 'Content-Length': '0', 'Content-Type': 'application/x-www-form-urlencoded' }, + }; + + const req = lib.request(options, (res) => { + let body = ''; + res.on('data', (chunk) => { + body += chunk; + }); + res.on('end', () => { + if (res.statusCode >= 200 && res.statusCode < 300) { + resolve(body.trim()); + } else { + reject(new Error(`HTTP ${res.statusCode}: ${body.trim()}`)); + } + }); + }); + + req.on('error', reject); + req.end(); + }); + } + + // Appends SIMPLEFIN_ACCESS_URL to .env so the setup token isn't re-used + _saveAccessUrl(accessUrl) { + try { + const existing = fs.existsSync('.env') ? fs.readFileSync('.env', 'utf8') : ''; + if (!existing.includes('SIMPLEFIN_ACCESS_URL')) { + fs.appendFileSync('.env', `\nSIMPLEFIN_ACCESS_URL=${accessUrl}\n`); + console.log('✅ Access URL saved to .env — you can remove SIMPLEFIN_SETUP_TOKEN\n'); + } + } catch { + // Non-fatal — just print it so the user can save it manually + console.log(`\nSave this to .env:\nSIMPLEFIN_ACCESS_URL=${accessUrl}\n`); + } + } + + // ── Normalise ──────────────────────────────────────────────────────────────── + + _normalise(data) { + const accounts = (data.accounts ?? []).map((acc) => ({ + id: acc.id, + name: acc.name, + currency: acc.currency ?? 'USD', + balance: parseFloat(acc.balance) ?? 0, + balanceDate: new Date(acc['balance-date'] * 1000).toISOString().slice(0, 10), + org: acc.org?.name ?? 'Unknown', + type: this._classifyAccount(acc.name), + transactions: (acc.transactions ?? []).map((tx) => ({ + id: tx.id, + date: new Date(tx.posted * 1000).toISOString().slice(0, 10), + amount: parseFloat(tx.amount) ?? 0, + description: tx.description ?? '', + category: this._categorise(tx.description ?? ''), + })), + })); + + return { accounts, errors: data.errors ?? [] }; + } + + _classifyAccount(name) { + const n = name.toLowerCase(); + if (n.includes('checking') || n.includes('current')) return 'CHECKING'; + if (n.includes('saving')) return 'SAVINGS'; + if (n.includes('credit') || n.includes('card')) return 'CREDIT'; + if (n.includes('invest') || n.includes('brokerage') || n.includes('401k') || n.includes('ira')) + return 'INVESTMENT'; + if (n.includes('loan') || n.includes('mortgage')) return 'LOAN'; + return 'OTHER'; + } + + _categorise(description) { + const d = description.toLowerCase(); + if (d.match(/amazon|walmart|target|costco|grocery|whole foods|trader joe/)) return 'Shopping'; + if (d.match(/uber eats|doordash|grubhub|postmates|instacart/)) return 'Delivery'; + if (d.match(/netflix|spotify|apple|disney|hulu|youtube/)) return 'Subscriptions'; + if (d.match(/restaurant|cafe|coffee|starbucks|chipotle|mcdonald/)) return 'Dining'; + if (d.match(/shell|chevron|bp|exxon|fuel|gas station/)) return 'Gas'; + if (d.match(/uber|lyft|transit|mta|bart|metro/)) return 'Transport'; + if (d.match(/rent|mortgage|hoa|property/)) return 'Housing'; + if (d.match(/electric|water|internet|phone|at&t|verizon|comcast/)) return 'Utilities'; + if (d.match(/payroll|salary|direct deposit/)) return 'Income'; + if (d.match(/transfer|zelle|venmo|paypal/)) return 'Transfer'; + return 'Other'; + } + + _daysAgo(n) { + return Math.floor((Date.now() - n * 24 * 60 * 60 * 1000) / 1000); + } +} diff --git a/src/finance/clients/SimpleFINClient.js b/src/finance/clients/SimpleFINClient.js new file mode 100644 index 0000000..3463ef5 --- /dev/null +++ b/src/finance/clients/SimpleFINClient.js @@ -0,0 +1,189 @@ +import fs from 'fs'; +import https from 'https'; +import http from 'http'; + +// SimpleFIN auth flow: +// 1. You get a Setup Token from https://beta-bridge.simplefin.org +// 2. This client decodes it, POSTs once to claim an Access URL +// 3. The CLI saves it to .env; a server would store it in its own secret store. +// 4. All subsequent requests use the Access URL directly. +// +// .env configuration: +// First run: SIMPLEFIN_SETUP_TOKEN=aHR0cHM6Ly8... +// After that: SIMPLEFIN_ACCESS_URL=https://user:pass@beta-bridge.simplefin.org/simplefin + +export class SimpleFINClient { + // logger: object with .write() / .log() / .warn() — defaults to console. + // onAccessUrlClaimed(url): optional callback so the caller can persist the URL + // (CLI uses it to write .env; a server would store it elsewhere). + constructor({ logger, onAccessUrlClaimed } = {}) { + this.accessUrl = null; + this.logger = logger ?? { + write: (msg) => process.stdout.write(msg), + log: (...args) => console.log(...args), + warn: (...args) => console.warn(...args), + }; + this.onAccessUrlClaimed = onAccessUrlClaimed ?? null; + } + + async init() { + if (process.env.SIMPLEFIN_ACCESS_URL) { + this.accessUrl = process.env.SIMPLEFIN_ACCESS_URL.replace(/\/$/, ''); + return; + } + + if (process.env.SIMPLEFIN_SETUP_TOKEN) { + this.accessUrl = await this._claimAccessUrl(process.env.SIMPLEFIN_SETUP_TOKEN); + if (this.onAccessUrlClaimed) { + await this.onAccessUrlClaimed(this.accessUrl); + } + return; + } + + throw new Error( + 'SimpleFIN not configured.\n' + + 'Add to .env:\n' + + ' SIMPLEFIN_SETUP_TOKEN=\n' + + 'The Access URL will be saved automatically on first run.', + ); + } + + async getAccounts(options = {}) { + if (!this.accessUrl) await this.init(); + + const startDate = options.startDate ?? this._daysAgo(30); + const endDate = options.endDate ?? Math.floor(Date.now() / 1000); + + const url = `${this.accessUrl}/accounts?version=2&start-date=${startDate}&end-date=${endDate}`; + const response = await fetch(url); + + if (!response.ok) { + throw new Error(`SimpleFIN error ${response.status}: ${await response.text()}`); + } + + const data = await response.json(); + + if (data.errors?.length) { + data.errors.forEach((e) => this.logger.warn(` ⚠ SimpleFIN: ${e}`)); + } + + return this._normalise(data); + } + + // ── Auth ───────────────────────────────────────────────────────────────────── + + async _claimAccessUrl(setupToken) { + const claimUrl = Buffer.from(setupToken.trim(), 'base64').toString('utf8').trim(); + this.logger.write(`\n🔑 Claiming SimpleFIN access URL...\n → ${claimUrl}\n`); + + const accessUrl = await this._post(claimUrl); + + if (!accessUrl || !accessUrl.startsWith('http')) { + throw new Error( + `Unexpected response from SimpleFIN: "${accessUrl}"\n` + + 'Setup tokens are one-time use — if already claimed, generate a new one at https://beta-bridge.simplefin.org', + ); + } + + this.logger.write('✅ Access URL received\n'); + return accessUrl.trim(); + } + + _post(url) { + return new Promise((resolve, reject) => { + const parsed = new URL(url); + const lib = parsed.protocol === 'https:' ? https : http; + const options = { + hostname: parsed.hostname, + port: parsed.port || (parsed.protocol === 'https:' ? 443 : 80), + path: parsed.pathname + parsed.search, + method: 'POST', + headers: { 'Content-Length': '0', 'Content-Type': 'application/x-www-form-urlencoded' }, + }; + + const req = lib.request(options, (res) => { + let body = ''; + res.on('data', (chunk) => { + body += chunk; + }); + res.on('end', () => { + if (res.statusCode >= 200 && res.statusCode < 300) { + resolve(body.trim()); + } else { + reject(new Error(`HTTP ${res.statusCode}: ${body.trim()}`)); + } + }); + }); + + req.on('error', reject); + req.end(); + }); + } + + // ── Normalise ──────────────────────────────────────────────────────────────── + + _normalise(data) { + const accounts = (data.accounts ?? []).map((acc) => ({ + id: acc.id, + name: acc.name, + currency: acc.currency ?? 'USD', + balance: parseFloat(acc.balance) ?? 0, + balanceDate: new Date(acc['balance-date'] * 1000).toISOString().slice(0, 10), + org: acc.org?.name ?? 'Unknown', + type: this._classifyAccount(acc.name), + transactions: (acc.transactions ?? []).map((tx) => ({ + id: tx.id, + date: new Date(tx.posted * 1000).toISOString().slice(0, 10), + amount: parseFloat(tx.amount) ?? 0, + description: tx.description ?? '', + category: this._categorise(tx.description ?? ''), + })), + })); + + return { accounts, errors: data.errors ?? [] }; + } + + _classifyAccount(name) { + const n = name.toLowerCase(); + if (n.includes('checking') || n.includes('current')) return 'CHECKING'; + if (n.includes('saving')) return 'SAVINGS'; + if (n.includes('credit') || n.includes('card')) return 'CREDIT'; + if (n.includes('invest') || n.includes('brokerage') || n.includes('401k') || n.includes('ira')) + return 'INVESTMENT'; + if (n.includes('loan') || n.includes('mortgage')) return 'LOAN'; + return 'OTHER'; + } + + _categorise(description) { + const d = description.toLowerCase(); + if (d.match(/amazon|walmart|target|costco|grocery|whole foods|trader joe/)) return 'Shopping'; + if (d.match(/uber eats|doordash|grubhub|postmates|instacart/)) return 'Delivery'; + if (d.match(/netflix|spotify|apple|disney|hulu|youtube/)) return 'Subscriptions'; + if (d.match(/restaurant|cafe|coffee|starbucks|chipotle|mcdonald/)) return 'Dining'; + if (d.match(/shell|chevron|bp|exxon|fuel|gas station/)) return 'Gas'; + if (d.match(/uber|lyft|transit|mta|bart|metro/)) return 'Transport'; + if (d.match(/rent|mortgage|hoa|property/)) return 'Housing'; + if (d.match(/electric|water|internet|phone|at&t|verizon|comcast/)) return 'Utilities'; + if (d.match(/payroll|salary|direct deposit/)) return 'Income'; + if (d.match(/transfer|zelle|venmo|paypal/)) return 'Transfer'; + return 'Other'; + } + + _daysAgo(n) { + return Math.floor((Date.now() - n * 24 * 60 * 60 * 1000) / 1000); + } +} + +// CLI helper — saves the access URL to .env after the setup token is claimed. +// Pass this as `onAccessUrlClaimed` when constructing SimpleFINClient in CLI context. +export function saveAccessUrlToEnv(accessUrl) { + try { + const existing = fs.existsSync('.env') ? fs.readFileSync('.env', 'utf8') : ''; + if (!existing.includes('SIMPLEFIN_ACCESS_URL')) { + fs.appendFileSync('.env', `\nSIMPLEFIN_ACCESS_URL=${accessUrl}\n`); + console.log('✅ Access URL saved to .env — you can remove SIMPLEFIN_SETUP_TOKEN\n'); + } + } catch { + console.log(`\nSave this to .env:\nSIMPLEFIN_ACCESS_URL=${accessUrl}\n`); + } +} diff --git a/src/market/BenchmarkProvider.js b/src/market/BenchmarkProvider.js new file mode 100644 index 0000000..b9089a4 --- /dev/null +++ b/src/market/BenchmarkProvider.js @@ -0,0 +1,73 @@ +import { YahooClient } from './YahooClient.js'; +import { REGIME } from '../config/constants.js'; + +const TTL_MS = 60 * 60 * 1000; + +const DEFAULTS = { + sp500Price: 5000, + riskFreeRate: 4.5, + vixLevel: 20, + rateRegime: REGIME.HIGH, + volatilityRegime: REGIME.NORMAL, + benchmarks: { marketPE: 22, techPE: 30, reitYield: 3.5, igSpread: 1.0 }, +}; + +const rateRegime = (rate) => (rate < 2 ? REGIME.LOW : rate <= 5 ? REGIME.NORMAL : REGIME.HIGH); +const volRegime = (vix) => (vix < 15 ? REGIME.LOW : vix <= 25 ? REGIME.NORMAL : REGIME.HIGH); + +const pe = (summary) => + summary.summaryDetail?.trailingPE ?? summary.defaultKeyStatistics?.forwardPE; + +export class BenchmarkProvider { + // logger: object with .warn() — defaults to console so CLI behaviour is unchanged. + constructor({ logger = console } = {}) { + this.client = new YahooClient(); + this.cache = { data: null, expiresAt: 0 }; + this.logger = logger; + } + + async getMarketContext() { + if (this.cache.data && Date.now() < this.cache.expiresAt) return this.cache.data; + + try { + const [sp500, tn10y, vix, spy, xlk, xlre, lqd] = await Promise.all([ + this.client.fetchSummary('^GSPC'), + this.client.fetchSummary('^TNX'), + this.client.fetchSummary('^VIX'), + this.client.fetchSummary('SPY'), + this.client.fetchSummary('XLK'), + this.client.fetchSummary('XLRE'), + this.client.fetchSummary('LQD'), + ]); + + const riskFreeRate = tn10y.price?.regularMarketPrice ?? 0; + const sp500Price = sp500.price?.regularMarketPrice ?? 0; + const vixLevel = vix.price?.regularMarketPrice ?? 0; + + if (!sp500Price || !riskFreeRate) throw new Error('Invalid market data (zero values)'); + + const lqdYield = (lqd.summaryDetail?.trailingAnnualDividendYield ?? 0) * 100; + + const context = { + sp500Price, + riskFreeRate, + vixLevel, + rateRegime: rateRegime(riskFreeRate), + volatilityRegime: volRegime(vixLevel), + benchmarks: { + marketPE: pe(spy) ?? 22, + techPE: pe(xlk) ?? 30, + reitYield: (xlre.summaryDetail?.trailingAnnualDividendYield ?? 0.035) * 100, + igSpread: Math.max(0.1, lqdYield - riskFreeRate), + }, + timestamp: new Date().toISOString(), + }; + + this.cache = { data: context, expiresAt: Date.now() + TTL_MS }; + return context; + } catch (err) { + this.logger.warn('Market data fetch failed, using defaults:', err.message); + return this.cache.data ?? DEFAULTS; + } + } +} diff --git a/src/market/MarketRegime.js b/src/market/MarketRegime.js new file mode 100644 index 0000000..1d297ce --- /dev/null +++ b/src/market/MarketRegime.js @@ -0,0 +1,50 @@ +import { SECTOR, ASSET_TYPE } from '../config/constants.js'; + +export class MarketRegime { + constructor(marketContext) { + const b = marketContext?.benchmarks ?? {}; + this.marketPE = b.marketPE ?? 22; + this.techPE = b.techPE ?? 30; + this.reitYield = b.reitYield ?? 3.5; + this.igSpread = b.igSpread ?? 1.0; + } + + getInflatedOverrides(type, sector) { + if (type === ASSET_TYPE.STOCK) return this._stock(sector); + if (type === ASSET_TYPE.ETF) return this._etf(); + if (type === ASSET_TYPE.BOND) return this._bond(); + return { gates: {}, thresholds: {} }; + } + + _stock(sector) { + if (sector === SECTOR.REIT) { + return { + gates: {}, + thresholds: { minYield: +(this.reitYield * 0.85).toFixed(2), maxPFFO: 20 }, + }; + } + if (sector === SECTOR.TECHNOLOGY) { + return { + gates: { + maxPERatio: Math.round(this.techPE * 1.3), + maxPegGate: +(this.techPE / 15).toFixed(1), + }, + thresholds: {}, + }; + } + return { + gates: { + maxPERatio: Math.round(this.marketPE * 1.5), + maxPegGate: +(this.marketPE / 12).toFixed(1), + }, + thresholds: {}, + }; + } + + _etf() { + return { gates: { maxExpenseRatio: 0.75 }, thresholds: { minYield: 0.5 } }; + } + _bond() { + return { gates: {}, thresholds: { minSpread: +(this.igSpread * 0.8).toFixed(2) } }; + } +} diff --git a/src/api/yahooClient.js b/src/market/YahooClient.js similarity index 100% rename from src/api/yahooClient.js rename to src/market/YahooClient.js diff --git a/src/reporters/FinanceReporter.js b/src/reporters/FinanceReporter.js new file mode 100644 index 0000000..1c412b5 --- /dev/null +++ b/src/reporters/FinanceReporter.js @@ -0,0 +1,304 @@ +import fs from 'fs'; +import path from 'path'; + +export class FinanceReporter { + // Returns the HTML string — useful for server responses. + render(advice, personalFinance, marketContext) { + return this._build(advice, personalFinance, marketContext); + } + + // Writes to disk and returns the absolute path — used by the CLI. + generate(advice, personalFinance, marketContext, outputPath = './finance-report.html') { + const html = this._build(advice, personalFinance, marketContext); + fs.writeFileSync(outputPath, html, 'utf8'); + return path.resolve(outputPath); + } + + _build(advice, pf, ctx) { + const date = new Date().toISOString().slice(0, 10); + return ` + + + + +Personal Finance — ${date} + + + +
+

💰 Personal Finance

+
Date ${date}
+
+
+ + ${pf ? this._netWorthSection(pf) : ''} + + ${this._portfolioSection(advice, ctx)} + + ${pf ? this._spendingSection(pf) : ''} + + ${pf ? this._accountsSection(pf) : ''} + +
+ +`; + } + + // ── Net worth ────────────────────────────────────────────────────────────── + + _netWorthSection(pf) { + const f = (n) => + new Intl.NumberFormat('en-US', { + style: 'currency', + currency: 'USD', + maximumFractionDigits: 0, + }).format(n); + return ` +
+

Net Worth

+
+ ${this._card('Net Worth', f(pf.netWorth), pf.netWorth >= 0 ? 'green' : 'red')} + ${this._card('Total Assets', f(pf.totalAssets))} + ${this._card('Liabilities', f(pf.totalLiabilities), 'red')} + ${this._card('Cash & Savings', `${f(pf.totalCash)}`, null, `${pf.cashPct}% of assets`)} + ${this._card('Investments', `${f(pf.totalInvestments)}`, null, `${pf.investPct}% of assets`)} + ${pf.savingsRate != null ? this._card('Savings Rate', `${pf.savingsRate}%`, parseFloat(pf.savingsRate) > 20 ? 'green' : 'yellow') : ''} + ${this._card('Monthly Income', f(pf.totalIncome))} + ${this._card('Monthly Spend', f(pf.totalSpend))} +
+
`; + } + + // ── Portfolio with hold/sell advice ─────────────────────────────────────── + + _portfolioSection(advice, ctx) { + const f = (n) => + n != null + ? new Intl.NumberFormat('en-US', { + style: 'currency', + currency: 'USD', + }).format(n) + : '—'; + const f2 = (n) => + new Intl.NumberFormat('en-US', { + style: 'currency', + currency: 'USD', + maximumFractionDigits: 0, + }).format(n); + const b = ctx?.benchmarks ?? {}; + + const stocks = advice.filter((a) => a.type !== 'crypto'); + const crypto = advice.filter((a) => a.type === 'crypto'); + + const totalValue = advice.reduce((s, a) => s + (parseFloat(a.marketValue) || 0), 0); + const totalCost = advice.reduce((s, a) => s + (a.costBasis ?? 0) * a.shares, 0); + const totalGL = totalValue - totalCost; + const totalGLPct = totalCost > 0 ? ((totalGL / totalCost) * 100).toFixed(1) : null; + + const sourceColors = { + Robinhood: '#22c55e', + Vanguard: '#3b82f6', + Fidelity: '#f59e0b', + Coinbase: '#8b5cf6', + }; + const sourcePill = (s) => { + const color = sourceColors[s] ?? '#64748b'; + return `${s}`; + }; + + const stockRows = stocks + .map((a) => { + const glClass = parseFloat(a.gainLossPct) >= 0 ? 'green' : 'red'; + const advClass = this._adviceClass(a.advice); + return ` + ${a.ticker} + ${sourcePill(a.source)} + ${a.type} + ${a.shares} + ${f(a.costBasis)} + ${f(parseFloat(a.currentPrice))} + ${f(parseFloat(a.marketValue))} + ${a.gainLossPct != null ? a.gainLossPct + '%' : '—'} + ${a.signal ?? '—'} + ${a.advice} + ${a.reason} + `; + }) + .join(''); + + const cryptoRows = crypto + .map((a) => { + const glClass = parseFloat(a.gainLossPct) >= 0 ? 'green' : 'red'; + const advClass = this._adviceClass(a.advice); + return ` + ${a.ticker} + ${sourcePill(a.source)} + ${a.shares} + ${f(a.costBasis)} + ${f(parseFloat(a.currentPrice))} + ${f(parseFloat(a.marketValue))} + ${a.gainLossPct != null ? a.gainLossPct + '%' : '—'} + ${a.advice} + ${a.reason} + `; + }) + .join(''); + + return ` +
+

Portfolio — Hold / Sell / Add Advice

+
+ ${this._card('Total Value', f2(totalValue))} + ${this._card('Total Cost', f2(totalCost))} + ${this._card('Total G/L', f2(totalGL), totalGL >= 0 ? 'green' : 'red', totalGLPct != null ? totalGLPct + '%' : '')} + ${this._card('S&P 500 P/E', (b.marketPE?.toFixed(1) ?? '—') + 'x', null, 'Live benchmark')} +
+ + ${ + stocks.length > 0 + ? ` +

Stocks & ETFs

+ + + + + + + ${stockRows} +
TickerSourceTypeSharesCost BasisCurrentValueG/LSignalAdviceReason
` + : '' + } + + ${ + crypto.length > 0 + ? ` +

Crypto

+ + + + + + + ${cryptoRows} +
TickerSourceSharesCost BasisCurrentValueG/LAdviceNote
` + : '' + } +
`; + } + + // ── Spending breakdown ───────────────────────────────────────────────────── + + _spendingSection(pf) { + const f = (n) => + new Intl.NumberFormat('en-US', { + style: 'currency', + currency: 'USD', + }).format(n); + const rows = pf.categoryBreakdown + .slice(0, 10) + .map( + (c) => ` + + ${c.category} + ${f(c.amount)} + ${c.pct}% + +
+ + `, + ) + .join(''); + + return ` +
+

Spending by Category — Last 30 Days

+ + + ${rows} +
CategoryAmountShare
+
`; + } + + // ── Accounts ─────────────────────────────────────────────────────────────── + + _accountsSection(pf) { + const f = (n) => + new Intl.NumberFormat('en-US', { + style: 'currency', + currency: 'USD', + }).format(n); + const rows = pf.accounts + .map( + (a) => ` + + ${a.name} + ${a.type} + ${a.org} + ${f(a.balance)} + ${a.balanceDate} + `, + ) + .join(''); + + return ` +
+

Accounts

+ + + ${rows} +
AccountTypeInstitutionBalanceUpdated
+
`; + } + + // ── Helpers ──────────────────────────────────────────────────────────────── + + _card(label, value, colorClass = null, sub = null) { + return `
+
${label}
+
${value}
+ ${sub ? `
${sub}
` : ''} +
`; + } + + _adviceClass(advice) { + if (advice?.includes('🟢')) return 'advice-green'; + if (advice?.includes('🟡')) return 'advice-yellow'; + if (advice?.includes('🟠')) return 'advice-orange'; + if (advice?.includes('🔴')) return 'advice-red'; + return 'gray'; + } +} diff --git a/src/reporters/HtmlReporter.js b/src/reporters/HtmlReporter.js new file mode 100644 index 0000000..a782f05 --- /dev/null +++ b/src/reporters/HtmlReporter.js @@ -0,0 +1,392 @@ +import fs from 'fs'; +import path from 'path'; + +// Generates a self-contained HTML report saved to ./screener-report.html +// Console output shows only the signal summary — full breakdown lives here. + +export class HtmlReporter { + // Returns the HTML string — useful for server responses. + render(results, marketContext, personalFinance = null) { + return this._buildHtml(results, marketContext, personalFinance); + } + + // Writes to disk and returns the absolute path — used by the CLI. + generate(results, marketContext, personalFinance = null, outputPath = './screener-report.html') { + const html = this._buildHtml(results, marketContext, personalFinance); + fs.writeFileSync(outputPath, html, 'utf8'); + return path.resolve(outputPath); + } + + // ── HTML builder ──────────────────────────────────────────────────────────── + + _buildHtml(results, ctx, pf = null) { + const b = ctx.benchmarks ?? {}; + const all = [...results.STOCK, ...results.ETF, ...results.BOND]; + + return ` + + + + +Market Screener — ${ctx.timestamp?.slice(0, 10) ?? ''} + + + + +
+

📊 Market Screener

+
+
Date ${ctx.timestamp?.slice(0, 10) ?? '—'}
+
Rate ${ctx.rateRegime}
+
Volatility ${ctx.volatilityRegime}
+
+
+ +
+ +
+ ${this._ctxCard('10Y Yield', (ctx.riskFreeRate?.toFixed(2) ?? '—') + '%')} + ${this._ctxCard('VIX', ctx.vixLevel?.toFixed(1) ?? '—')} + ${this._ctxCard('S&P 500', ctx.sp500Price?.toLocaleString() ?? '—')} + ${this._ctxCard('S&P 500 P/E', (b.marketPE?.toFixed(1) ?? '—') + 'x')} + ${this._ctxCard('Tech P/E', (b.techPE?.toFixed(1) ?? '—') + 'x')} + ${this._ctxCard('REIT Yield', (b.reitYield?.toFixed(2) ?? '—') + '%')} + ${this._ctxCard('IG Spread', (b.igSpread?.toFixed(2) ?? '—') + '%')} +
+ +
+

Signal Summary

+ + + ${all + .sort((a, b) => this._sigOrd(a.signal) - this._sigOrd(b.signal)) + .map((r) => this._summaryRow(r)) + .join('')} +
TickerTypeSignalInflated VerdictFundamental Verdict
+
+ + ${['STOCK', 'ETF', 'BOND'] + .map((type) => (results[type]?.length ? this._assetSection(type, results[type], b) : '')) + .join('')} + + ${pf ? this._personalFinanceSection(pf) : ''} + + ${ + results.ERROR?.length + ? ` +
+

Errors

+ + + ${results.ERROR.map((e) => ``).join('')} +
TickerReason
${e.ticker}${e.message}
+
` + : '' + } + +
+ + + +`; + } + + // ── Section builders ──────────────────────────────────────────────────────── + + _assetSection(type, items, benchmarks) { + const sorted = [...items].sort((a, b) => this._sigOrd(a.signal) - this._sigOrd(b.signal)); + const inflatedId = `${type}-inflated`; + const fundamentalId = `${type}-fundamental`; + + const inflatedLabel = + type === 'STOCK' + ? `Market-Adjusted (P/E gate: ~${benchmarks.marketPE != null ? Math.round(benchmarks.marketPE * 1.5) : '—'}x from live data)` + : 'Market-Adjusted'; + + return ` +
+

${type}S

+
+
${inflatedLabel}
+
Fundamental (Graham-style)
+
+
+ ${this._table(type, sorted, 'inflated')} +
+
+ ${this._table(type, sorted, 'fundamental')} +
+
`; + } + + _table(type, items, mode) { + const headers = this._headers(type, items, mode); + const rows = items.map((r) => this._row(type, r, mode, headers)).join(''); + return ` + ${headers.map((h) => ``).join('')} + ${rows} +
${h}
`; + } + + // Collect only headers that have at least one non-null value across all items + _headers(type, items, mode) { + const base = ['Ticker', 'Price', 'Verdict', 'Score']; + if (type === 'STOCK') { + const metricKeys = [ + 'Sector', + 'P/E', + 'PEG', + 'P/B', + 'ROE%', + 'OpMgn%', + 'NetMgn%', + 'Rev%', + 'FCF Yld%', + 'Div%', + 'D/E', + 'Quick', + 'Beta', + '52W Pos', + 'P/FFO', + ]; + const present = metricKeys.filter((k) => + items.some((r) => r.asset.getDisplayMetrics()[k] != null), + ); + return [...base, ...present, 'Risk Flags']; + } + if (type === 'ETF') return [...base, 'Expense', 'Yield', 'AUM', '5Y Ret']; + if (type === 'BOND') return [...base, 'YTM', 'Duration', 'Rating']; + return base; + } + + _row(type, result, mode, headers) { + const m = result.asset.getDisplayMetrics(); + const bd = result[mode]?.audit?.breakdown ?? {}; + const rf = result[mode]?.audit?.riskFlags ?? []; + const v = result[mode]?.label ?? ''; + const s = result[mode]?.scoreSummary ?? ''; + const p = (key) => + bd[key] != null + ? `${bd[key] > 0 ? '✅' : '❌'}` + : ''; + + const cells = { + Ticker: `${m.Ticker}`, + Price: `${m.Price}`, + Verdict: `${v}`, + Score: `${s}`, + Sector: `${m.Sector ?? ''}`, + 'P/E': `${m['P/E'] ?? ''}`, + PEG: `${m.PEG != null ? m.PEG + ' ' + p('peg') : ''}`, + 'P/B': `${m['P/B'] ?? ''}`, + 'ROE%': `${m['ROE%'] != null ? m['ROE%'] + ' ' + p('roe') : ''}`, + 'OpMgn%': `${m['OpMgn%'] != null ? m['OpMgn%'] + ' ' + p('opMargin') : ''}`, + 'NetMgn%': `${m['NetMgn%'] != null ? m['NetMgn%'] + ' ' + p('margin') : ''}`, + 'Rev%': `${m['Rev%'] != null ? m['Rev%'] + ' ' + p('revenue') : ''}`, + 'FCF Yld%': `${m['FCF Yld%'] != null ? m['FCF Yld%'] + ' ' + p('fcf') : ''}`, + 'Div%': `${m['Div%'] != null ? m['Div%'] + ' ' + p('yield') : ''}`, + 'D/E': `${m['D/E'] ?? ''}`, + Quick: `${m.Quick ?? ''}`, + Beta: `${m.Beta ?? ''}`, + '52W Pos': `${m['52W Pos'] ?? ''}`, + 'P/FFO': `${m['P/FFO'] != null ? m['P/FFO'] + ' ' + p('pFFO') : ''}`, + 'Risk Flags': `${rf.map((f) => `⚠ ${f}`).join('') || ''}`, + // ETF + Expense: `${m['Exp Ratio%'] != null ? m['Exp Ratio%'] + ' ' + p('cost') : ''}`, + Yield: `${m['Yield%'] != null ? m['Yield%'] + ' ' + p('yield') : ''}`, + AUM: `${m.AUM ?? ''}`, + '5Y Ret': `${m['5Y Return%'] ?? ''}`, + // BOND + YTM: `${m['YTM%'] != null ? m['YTM%'] + ' ' + p('spread') : ''}`, + Duration: `${m.Duration != null ? m.Duration + ' ' + p('duration') : ''}`, + Rating: `${m.Rating ?? ''}`, + }; + + return `${headers.map((h) => cells[h] ?? `—`).join('')}`; + } + + _summaryRow(r) { + return ` + ${r.asset.ticker} + ${r.asset.type} + ${r.signal} + ${r.inflated.label} + ${r.fundamental.label} + `; + } + + // ── Helpers ───────────────────────────────────────────────────────────────── + + _ctxCard(label, value) { + return `
${label}
${value}
`; + } + + _verdictClass(label) { + if (label?.startsWith('🟢')) return 'verdict-green'; + if (label?.startsWith('🟡')) return 'verdict-yellow'; + return 'verdict-red'; + } + + _signalClass(signal) { + if (signal?.includes('Strong')) return 'signal-strong'; + if (signal?.includes('Momentum')) return 'signal-momentum'; + if (signal?.includes('Neutral')) return 'signal-neutral'; + if (signal?.includes('Speculation')) return 'signal-spec'; + return 'signal-avoid'; + } + + _personalFinanceSection(pf) { + const fmt = (n) => + new Intl.NumberFormat('en-US', { + style: 'currency', + currency: 'USD', + maximumFractionDigits: 0, + }).format(n); + const sign = (n) => + n >= 0 + ? `${fmt(n)}` + : `${fmt(n)}`; + + const accountRows = pf.accounts + .map( + (a) => ` + + ${a.name} + ${a.type} + ${a.org} + ${sign(a.balance)} + ${a.balanceDate} + `, + ) + .join(''); + + const categoryRows = pf.categoryBreakdown + .slice(0, 8) + .map( + (c) => ` + + ${c.category} + ${fmt(c.amount)} + ${c.pct}% + +
+
+
+ + `, + ) + .join(''); + + return ` +
+

Personal Finance — SimpleFIN

+ +
+ ${this._ctxCard('Net Worth', fmt(pf.netWorth))} + ${this._ctxCard('Total Assets', fmt(pf.totalAssets))} + ${this._ctxCard('Liabilities', fmt(pf.totalLiabilities))} + ${this._ctxCard('Cash', `${fmt(pf.totalCash)} (${pf.cashPct}%)`)} + ${this._ctxCard('Investments', `${fmt(pf.totalInvestments)} (${pf.investPct}%)`)} + ${this._ctxCard('Monthly Income', fmt(pf.totalIncome))} + ${this._ctxCard('Monthly Spend', fmt(pf.totalSpend))} + ${pf.savingsRate != null ? this._ctxCard('Savings Rate', `${pf.savingsRate}%`) : ''} +
+ +
+
+

Accounts

+ + + ${accountRows} +
AccountTypeInstitutionBalanceUpdated
+
+
+

Spending by Category (Last 30 Days)

+ + + ${categoryRows} +
CategoryAmount%Share
+
+
+
`; + } + + _sigOrd(signal) { + return ( + { + '✅ Strong Buy': 0, + '⚡ Momentum': 1, + '🔄 Neutral': 2, + '⚠️ Speculation': 3, + '❌ Avoid': 4, + }[signal] ?? 5 + ); + } +} diff --git a/src/screener/Chunker.js b/src/screener/Chunker.js new file mode 100644 index 0000000..8ca736c --- /dev/null +++ b/src/screener/Chunker.js @@ -0,0 +1,4 @@ +export const chunkArray = (array, size) => + Array.from({ length: Math.ceil(array.length / size) }, (_, i) => + array.slice(i * size, i * size + size), + ); diff --git a/src/screener/DataMapper.js b/src/screener/DataMapper.js new file mode 100644 index 0000000..e02616b --- /dev/null +++ b/src/screener/DataMapper.js @@ -0,0 +1,133 @@ +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 (more meaningful than binary positive/negative) + 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 + peRatio: ks.forwardPE ?? trailingPE, + 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, + fiveYearReturn: summary.defaultKeyStatistics?.fiveYearAverageReturn ?? 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 +}; + +const mapBondData = (summary) => ({ + yieldToMaturity: (summary.summaryDetail?.yield ?? 0) * 100, + // fiveYearAverageReturn is the closest Yahoo proxy for effective duration on bond ETFs + duration: summary.defaultKeyStatistics?.fiveYearAverageReturn ?? 0, + creditRating: inferCreditRating(summary.assetProfile?.category), + currentPrice: summary.price?.regularMarketPrice ?? 0, +}); diff --git a/src/screener/RuleMerger.js b/src/screener/RuleMerger.js new file mode 100644 index 0000000..95ce0e1 --- /dev/null +++ b/src/screener/RuleMerger.js @@ -0,0 +1,33 @@ +import { ScoringRules } from '../config/ScoringConfig.js'; +import { MarketRegime } from '../market/MarketRegime.js'; +import { SCORE_MODE } from '../config/constants.js'; + +export const RuleMerger = { + getRulesForAsset(type, metrics, marketContext = {}, mode = SCORE_MODE.FUNDAMENTAL) { + const base = ScoringRules[type]; + if (!base) throw new Error(`No rules configured for asset type: ${type}`); + + let rules = JSON.parse(JSON.stringify(base)); + + if (type === 'STOCK' && metrics.sector) { + const override = base.SECTOR_OVERRIDE?.[metrics.sector.toUpperCase()]; + if (override) { + rules.gates = { ...rules.gates, ...override.gates }; + rules.weights = { ...rules.weights, ...override.weights }; + rules.thresholds = { ...rules.thresholds, ...override.thresholds }; + } + } + delete rules.SECTOR_OVERRIDE; + + if (mode === SCORE_MODE.INFLATED) { + const { gates, thresholds } = new MarketRegime(marketContext).getInflatedOverrides( + type, + metrics.sector, + ); + rules.gates = { ...rules.gates, ...gates }; + rules.thresholds = { ...rules.thresholds, ...thresholds }; + } + + return rules; + }, +}; diff --git a/src/screener/ScreenerEngine.js b/src/screener/ScreenerEngine.js new file mode 100644 index 0000000..e592968 --- /dev/null +++ b/src/screener/ScreenerEngine.js @@ -0,0 +1,141 @@ +import { YahooClient } from '../market/YahooClient.js'; +import { BenchmarkProvider } from '../market/BenchmarkProvider.js'; +import { mapToStandardFormat } from './DataMapper.js'; +import { chunkArray } from './Chunker.js'; +import { RuleMerger } from './RuleMerger.js'; +import { Stock } from './assets/Stock.js'; +import { Etf } from './assets/Etf.js'; +import { Bond } from './assets/Bond.js'; +import { StockScorer } from './scorers/StockScorer.js'; +import { EtfScorer } from './scorers/EtfScorer.js'; +import { BondScorer } from './scorers/BondScorer.js'; +import { SIGNAL, SIGNAL_ORDER, SCORE_MODE, ASSET_TYPE } from '../config/constants.js'; + +const SCORERS = { + [ASSET_TYPE.STOCK]: StockScorer, + [ASSET_TYPE.ETF]: EtfScorer, + [ASSET_TYPE.BOND]: BondScorer, +}; + +export class ScreenerEngine { + // logger: object with .write() / .log() — defaults to a console shim so CLI behaviour is unchanged. + // Pass a no-op logger ({ write: () => {}, log: () => {} }) in server context. + constructor({ logger } = {}) { + this.client = new YahooClient(); + this.benchmarkProvider = new BenchmarkProvider({ logger: logger ?? console }); + this.logger = logger ?? { + write: (msg) => process.stdout.write(msg), + log: (...args) => console.log(...args), + }; + } + + // Pure data method — returns structured results. Safe to use in a server route. + async screenTickers(tickers) { + const marketContext = await this.benchmarkProvider.getMarketContext(); + const results = { STOCK: [], ETF: [], BOND: [], ERROR: [] }; + for (const chunk of chunkArray(tickers, 5)) { + const batch = await Promise.all(chunk.map((t) => this._fetch(t))); + batch.forEach((data) => this._process(data, marketContext, results)); + await new Promise((r) => setTimeout(r, 1000)); + } + return { ...results, marketContext }; + } + + // CLI helper — emits progress to logger, returns structured results. + // The caller (bin/screen.js) is responsible for writing the report. + async screenWithProgress(tickers) { + this.logger.write('⏳ Fetching market context...'); + const marketContext = await this.benchmarkProvider.getMarketContext(); + this.logger.write(' done\n'); + + const results = { STOCK: [], ETF: [], BOND: [], ERROR: [] }; + const chunks = chunkArray(tickers, 5); + let processed = 0; + + for (const chunk of chunks) { + const batch = await Promise.all(chunk.map((t) => this._fetch(t))); + batch.forEach((data) => this._process(data, marketContext, results)); + processed += chunk.length; + this.logger.write(`\r⏳ Screening tickers... ${processed}/${tickers.length}`); + await new Promise((r) => setTimeout(r, 1000)); + } + + this.logger.write('\n'); + return { ...results, marketContext }; + } + + async _fetch(ticker) { + try { + const summary = await this.client.fetchSummary(ticker); + if (!summary?.price) throw new Error('Empty response from Yahoo'); + return mapToStandardFormat(ticker, summary); + } catch (err) { + return { isError: true, ticker: ticker.toUpperCase(), message: err.message }; + } + } + + _process(data, marketContext, results) { + if (data.isError) { + results.ERROR.push(data); + return; + } + try { + const asset = this._buildAsset(data); + const scorer = SCORERS[asset.type]; + if (!scorer) throw new Error(`No scorer for type: ${asset.type}`); + + const fundamental = scorer.score( + asset.metrics, + RuleMerger.getRulesForAsset( + asset.type, + asset.metrics, + marketContext, + SCORE_MODE.FUNDAMENTAL, + ), + marketContext, + ); + const inflated = scorer.score( + asset.metrics, + RuleMerger.getRulesForAsset(asset.type, asset.metrics, marketContext, SCORE_MODE.INFLATED), + marketContext, + ); + + results[asset.type].push({ + asset, + fundamental, + inflated, + signal: this._signal(fundamental.label, inflated.label), + }); + } catch (err) { + results.ERROR.push({ + ticker: (data.ticker || 'UNKNOWN').toUpperCase(), + message: err.message, + }); + } + } + + _buildAsset(data) { + switch ((data.type || ASSET_TYPE.STOCK).toUpperCase()) { + case ASSET_TYPE.BOND: + return new Bond(data); + case ASSET_TYPE.ETF: + return new Etf(data); + default: + return new Stock(data); + } + } + + _signal(fundamentalLabel, inflatedLabel) { + const green = (l) => l.startsWith('🟢'); + const yellow = (l) => l.startsWith('🟡'); + if (green(fundamentalLabel)) return SIGNAL.STRONG_BUY; + if (green(inflatedLabel) && yellow(fundamentalLabel)) return SIGNAL.MOMENTUM; + if (green(inflatedLabel) && !green(fundamentalLabel)) return SIGNAL.SPECULATION; + if (yellow(fundamentalLabel) || yellow(inflatedLabel)) return SIGNAL.NEUTRAL; + return SIGNAL.AVOID; + } + + signalOrder(signal) { + return SIGNAL_ORDER[signal] ?? 5; + } +} diff --git a/src/screener/assets/Asset.js b/src/screener/assets/Asset.js new file mode 100644 index 0000000..83c5689 --- /dev/null +++ b/src/screener/assets/Asset.js @@ -0,0 +1,19 @@ +export class Asset { + constructor(data) { + this.ticker = (data.ticker || 'UNKNOWN').toUpperCase(); + this.currentPrice = data.currentPrice || 0; + this.type = (data.type || 'STOCK').toUpperCase(); + } + + formatCurrency(val) { + return val ? `$${val.toFixed(2)}` : 'N/A'; + } + + formatLargeNumber(num) { + if (!num) return 'N/A'; + if (num >= 1e12) return `${(num / 1e12).toFixed(2)}T`; + if (num >= 1e9) return `${(num / 1e9).toFixed(2)}B`; + if (num >= 1e6) return `${(num / 1e6).toFixed(2)}M`; + return num.toString(); + } +} diff --git a/src/core/assets/Bond.js b/src/screener/assets/Bond.js similarity index 60% rename from src/core/assets/Bond.js rename to src/screener/assets/Bond.js index 2ec59c7..24f5afb 100644 --- a/src/core/assets/Bond.js +++ b/src/screener/assets/Bond.js @@ -1,18 +1,21 @@ +import { CREDIT_RATING_SCALE } from '../../config/ScoringConfig.js'; import { Asset } from './Asset.js'; export class Bond extends Asset { constructor(data) { super(data); - // Store metrics in a flat object for the ScoringEngine + const creditRating = data.creditRating || 'BBB'; + const creditRatingNumeric = CREDIT_RATING_SCALE[creditRating] ?? 7; + this.metrics = { ytm: parseFloat(data.yieldToMaturity) || 0, duration: parseFloat(data.duration) || 0, - creditRating: data.creditRating || 'N/A', + creditRating, + creditRatingNumeric, }; } - // Helper for dashboard display getDisplayMetrics() { return { Ticker: this.ticker, @@ -20,7 +23,7 @@ export class Bond extends Asset { Price: this.formatCurrency(this.currentPrice), 'YTM%': `${this.metrics.ytm.toFixed(2)}%`, Duration: this.metrics.duration.toFixed(1), - Rating: this.metrics.creditRating, + Rating: `${this.metrics.creditRating} (${this.metrics.creditRatingNumeric})`, }; } } diff --git a/src/core/assets/Etf.js b/src/screener/assets/Etf.js similarity index 70% rename from src/core/assets/Etf.js rename to src/screener/assets/Etf.js index e46c6e6..94a7f44 100644 --- a/src/core/assets/Etf.js +++ b/src/screener/assets/Etf.js @@ -3,25 +3,20 @@ import { Asset } from './Asset.js'; export class Etf extends Asset { constructor(data) { super(data); - - // Store metrics in a flat object for the ScoringEngine this.metrics = { - expRatio: parseFloat(data.expenseRatio) || 0, + expenseRatio: parseFloat(data.expenseRatio) || 0, totalAssets: parseFloat(data.totalAssets) || 0, yield: parseFloat(data.yield) || 0, }; - - // Keep performance metrics for display only this.fiveYearReturn = parseFloat(data.fiveYearReturn) || 0; } - // Helper for dashboard display getDisplayMetrics() { return { Ticker: this.ticker, Type: 'ETF', Price: this.formatCurrency(this.currentPrice), - 'Exp Ratio%': `${this.metrics.expRatio.toFixed(2)}%`, + 'Exp Ratio%': `${this.metrics.expenseRatio.toFixed(2)}%`, 'Yield%': `${this.metrics.yield.toFixed(2)}%`, AUM: this.formatLargeNumber(this.metrics.totalAssets), '5Y Return%': `${this.fiveYearReturn.toFixed(1)}%`, diff --git a/src/screener/assets/Stock.js b/src/screener/assets/Stock.js new file mode 100644 index 0000000..1de9749 --- /dev/null +++ b/src/screener/assets/Stock.js @@ -0,0 +1,86 @@ +import { Asset } from './Asset.js'; + +export class Stock extends Asset { + constructor(data) { + super(data); + // console.log('Data:', data); + this.sector = this._mapToStandardSector(data || {}); + + this.metrics = { + sector: this.sector, + // Valuation + peRatio: data.peRatio ?? null, + pegRatio: data.pegRatio ?? null, + priceToBook: data.priceToBook ?? null, + // Profitability + netProfitMargin: data.netProfitMargin ?? null, + operatingMargin: data.operatingMargin ?? null, + returnOnEquity: data.returnOnEquity ?? null, + // Growth + revenueGrowth: data.revenueGrowth ?? null, + earningsGrowth: data.earningsGrowth ?? null, + // Financial health + debtToEquity: data.debtToEquity ?? null, + quickRatio: data.quickRatio ?? null, + // Cash flow + fcfYield: data.fcfYield ?? null, + pFFO: data.pFFO ?? null, + // Income + dividendYield: data.dividendYield ?? null, + // Risk & momentum + beta: data.beta ?? null, + week52High: data.week52High ?? null, + week52Low: data.week52Low ?? null, + currentPrice: data.currentPrice ?? 0, + }; + } + + _mapToStandardSector(data) { + // 1. Safely grab the profile from the data object + const profile = data.assetProfile || {}; + + // 2. Extract values safely + const industry = (profile.industry || '').toLowerCase(); + const sector = (profile.sector || '').toLowerCase(); + const combined = `${industry} ${sector}`; + + // 3. Match logic + if (combined.includes('technology') || combined.includes('electronic')) return 'TECHNOLOGY'; + if (combined.includes('real estate') || combined.includes('reit')) return 'REIT'; + if (combined.includes('financial') || combined.includes('bank')) return 'FINANCIAL'; + + return 'GENERAL'; + } + + getDisplayMetrics() { + const fmt = (v, dec = 1, suffix = '') => (v != null ? `${v.toFixed(dec)}${suffix}` : null); + const m = this.metrics; + const w52pos = + m.week52High > 0 && m.week52Low != null && m.currentPrice > 0 + ? (((m.currentPrice - m.week52Low) / (m.week52High - m.week52Low)) * 100).toFixed(0) + '%' + : null; + + // Only include fields that have actual data — null fields are omitted + const display = { + Ticker: this.ticker, + Price: this.formatCurrency(this.currentPrice), + Sector: this.sector, + }; + if (m.peRatio != null) display['P/E'] = fmt(m.peRatio, 1); + if (m.pegRatio != null) display['PEG'] = fmt(m.pegRatio, 2); + if (m.priceToBook != null) display['P/B'] = fmt(m.priceToBook, 2); + if (m.returnOnEquity != null) display['ROE%'] = fmt(m.returnOnEquity, 1, '%'); + if (m.operatingMargin != null) display['OpMgn%'] = fmt(m.operatingMargin, 1, '%'); + if (m.netProfitMargin != null) display['NetMgn%'] = fmt(m.netProfitMargin, 1, '%'); + if (m.revenueGrowth != null) display['Rev%'] = fmt(m.revenueGrowth, 1, '%'); + if (m.fcfYield != null) display['FCF Yld%'] = fmt(m.fcfYield, 1, '%'); + if (m.dividendYield != null) display['Div%'] = fmt(m.dividendYield, 2, '%'); + if (m.debtToEquity != null) display['D/E'] = fmt(m.debtToEquity, 2); + if (m.quickRatio != null) display['Quick'] = fmt(m.quickRatio, 2); + if (m.beta != null) display['Beta'] = fmt(m.beta, 2); + if (w52pos != null) display['52W Pos'] = w52pos; + if (m.pFFO != null) display['P/FFO'] = fmt(m.pFFO, 1); + + return display; + } +} diff --git a/src/screener/scorers/BondScorer.js b/src/screener/scorers/BondScorer.js new file mode 100644 index 0000000..a29f03d --- /dev/null +++ b/src/screener/scorers/BondScorer.js @@ -0,0 +1,40 @@ +export const BondScorer = { + score(m, rules, context) { + const { gates, weights, thresholds } = rules; + const metrics = this._sanitize(m); + const riskFreeRate = (context?.riskFreeRate ?? 4.0) / 100; + + if (metrics.creditRatingNumeric < gates.minCreditRating) { + return { + label: '🔴 Avoid', + scoreSummary: `Gate failed: ${metrics.creditRating} (${metrics.creditRatingNumeric}) < ${gates.minCreditRating}`, + audit: { passedGates: false }, + }; + } + + // Convert spread to percentage to match minSpread threshold (e.g. 1.0 = 1%) + const spreadPct = (metrics.ytm - riskFreeRate) * 100; + + const breakdown = { + spread: spreadPct >= thresholds.minSpread ? weights.yieldSpread : -2, + duration: metrics.duration <= thresholds.maxDuration ? weights.duration : -1, + }; + const score = Object.values(breakdown).reduce((a, b) => a + b, 0); + + return { + label: score >= 4 ? '🟢 Attractive' : score >= 1 ? '🟡 Neutral' : '🔴 Avoid', + scoreSummary: `Score: ${score}`, + audit: { breakdown }, + }; + }, + + _sanitize(m) { + const pct = (v) => parseFloat(typeof v === 'string' ? v.replace('%', '') : v) / 100 || 0; + return { + ytm: pct(m.ytm), + duration: parseFloat(m.duration) || 0, + creditRating: m.creditRating || 'BBB', + creditRatingNumeric: m.creditRatingNumeric ?? 7, + }; + }, +}; diff --git a/src/screener/scorers/EtfScorer.js b/src/screener/scorers/EtfScorer.js new file mode 100644 index 0000000..8dce6b2 --- /dev/null +++ b/src/screener/scorers/EtfScorer.js @@ -0,0 +1,28 @@ +export const EtfScorer = { + score(m, rules) { + const { gates, weights, thresholds } = rules; + const metrics = { + expenseRatio: parseFloat(m.expenseRatio) || 0, + yield: parseFloat(m.yield) || 0, + volume: parseFloat(m.volume) || 0, + }; + + if (metrics.expenseRatio > gates.maxExpenseRatio) { + return { label: '🔴 REJECT', scoreSummary: 'Gate failed: High Expense Ratio' }; + } + + const breakdown = { + cost: metrics.expenseRatio <= thresholds.maxExpense ? weights.lowCost : -3, + yield: metrics.yield >= thresholds.minYield ? weights.yield : -1, + vol: metrics.volume >= (thresholds.minVolume ?? 500000) ? 0 : -2, + }; + + const score = Object.values(breakdown).reduce((a, b) => a + b, 0); + + return { + label: score >= 3 ? '🟢 Efficient' : score >= 0 ? '🟡 Neutral' : '🔴 Expensive/Low Yield', + scoreSummary: `Score: ${score}`, + audit: { passedGates: true, breakdown }, + }; + }, +}; diff --git a/src/screener/scorers/StockScorer.js b/src/screener/scorers/StockScorer.js new file mode 100644 index 0000000..f45c4c1 --- /dev/null +++ b/src/screener/scorers/StockScorer.js @@ -0,0 +1,157 @@ +import { SIGNAL } from '../../config/constants.js'; + +const n = (v) => { + const f = parseFloat(v); + return !isNaN(f) && f !== 0 ? f : null; +}; + +const scoreValue = (val, high, med, weight) => (val >= high ? weight : val >= med ? 1 : -1); +const scorePeg = (val, high, med, weight) => (val <= high ? weight : val <= med ? 1 : -1); + +export const StockScorer = { + score(metrics, rules) { + const { gates, weights, thresholds } = rules; + const m = this._sanitize(metrics); + + const failures = [ + m.debtToEquity != null && + m.debtToEquity > gates.maxDebtToEquity && + `D/E ${m.debtToEquity.toFixed(1)} > ${gates.maxDebtToEquity}`, + m.quickRatio != null && + m.quickRatio < gates.minQuickRatio && + `Quick ${m.quickRatio.toFixed(2)} < ${gates.minQuickRatio}`, + m.peRatio != null && + m.peRatio > gates.maxPERatio && + `P/E ${m.peRatio.toFixed(0)} > ${gates.maxPERatio}`, + m.pegRatio != null && + m.pegRatio > gates.maxPegGate && + `PEG ${m.pegRatio.toFixed(1)} > ${gates.maxPegGate}`, + m.priceToBook != null && + gates.maxPriceToBook && + m.priceToBook > gates.maxPriceToBook && + `P/B ${m.priceToBook.toFixed(1)} > ${gates.maxPriceToBook}`, + ].filter(Boolean); + + if (failures.length > 0) { + return { + label: '🔴 REJECT', + scoreSummary: `Gate failed: ${failures.join(' | ')}`, + audit: { passedGates: false, failures }, + }; + } + + const factors = [ + { + key: 'roe', + active: weights.roe > 0 && m.returnOnEquity != null, + fn: () => scoreValue(m.returnOnEquity, thresholds.roeHigh, thresholds.roeMed, weights.roe), + }, + { + key: 'opMargin', + active: weights.opMargin > 0 && m.operatingMargin != null, + fn: () => + scoreValue( + m.operatingMargin, + thresholds.opMarginHigh, + thresholds.opMarginMed, + weights.opMargin, + ), + }, + { + key: 'margin', + active: weights.margin > 0 && m.netProfitMargin != null, + fn: () => + scoreValue( + m.netProfitMargin, + thresholds.marginHigh, + thresholds.marginMed, + weights.margin, + ), + }, + { + key: 'peg', + active: weights.peg > 0 && m.pegRatio != null, + fn: () => scorePeg(m.pegRatio, thresholds.pegHigh, thresholds.pegMed, weights.peg), + }, + { + key: 'revenue', + active: weights.revenue > 0 && m.revenueGrowth != null, + fn: () => + scoreValue(m.revenueGrowth, thresholds.revHigh, thresholds.revMed, weights.revenue), + }, + { + key: 'fcf', + active: weights.fcf > 0 && m.fcfYield != null, + fn: () => + scoreValue(m.fcfYield, thresholds.fcfHigh ?? 5, thresholds.fcfMed ?? 2, weights.fcf), + }, + { + key: 'yield', + active: (weights.yield ?? 0) > 0 && m.dividendYield != null, + fn: () => (m.dividendYield >= (thresholds.minYield ?? 4) ? weights.yield : -1), + }, + { + key: 'pFFO', + active: (weights.pFFO ?? 0) > 0 && m.pFFO != null, + fn: () => (m.pFFO <= (thresholds.maxPFFO ?? 15) ? weights.pFFO : -2), + }, + { + key: 'priceToBook', + active: (weights.priceToBook ?? 0) > 0 && m.priceToBook != null, + fn: () => scoreValue(1 / m.priceToBook, 1 / 1.0, 1 / 2.0, weights.priceToBook), + }, + ]; + + const breakdown = {}; + const totalScore = factors.reduce((sum, f) => { + if (!f.active) return sum; + breakdown[f.key] = f.fn(); + return sum + breakdown[f.key]; + }, 0); + + const riskFlags = [ + m.beta != null && m.beta > 1.5 && `High volatility (β ${m.beta.toFixed(2)})`, + m.beta != null && m.beta < 0 && `Inverse market correlation (β ${m.beta.toFixed(2)})`, + m.week52Position != null && m.week52Position > 0.9 && 'Near 52-week high — crowded trade', + m.week52Position != null && + m.week52Position < 0.1 && + 'Near 52-week low — potential opportunity', + ].filter(Boolean); + + return { + label: this._label(totalScore), + scoreSummary: `Score: ${totalScore}`, + audit: { passedGates: true, breakdown, riskFlags: riskFlags.length ? riskFlags : null }, + }; + }, + + _label(score) { + if (score >= 8) return '🟢 BUY (High Conviction)'; + if (score >= 4) return '🟢 BUY (Speculative)'; + if (score >= 0) return '🟡 HOLD'; + return '🔴 REJECT'; + }, + + _sanitize(m) { + const w52 = + m.week52High > 0 && m.week52Low != null && m.currentPrice > 0 + ? (m.currentPrice - m.week52Low) / (m.week52High - m.week52Low) + : null; + return { + debtToEquity: n(m.debtToEquity), + quickRatio: n(m.quickRatio), + peRatio: n(m.peRatio), + pegRatio: n(m.pegRatio), + priceToBook: n(m.priceToBook), + netProfitMargin: n(m.netProfitMargin), + operatingMargin: n(m.operatingMargin), + returnOnEquity: n(m.returnOnEquity), + revenueGrowth: n(m.revenueGrowth), + fcfYield: n(m.fcfYield), + dividendYield: n(m.dividendYield), + pFFO: n(m.pFFO), + beta: n(m.beta), + week52Position: w52, + }; + }, +}; diff --git a/src/utils/Chunker.js b/src/utils/Chunker.js deleted file mode 100644 index 5d70edb..0000000 --- a/src/utils/Chunker.js +++ /dev/null @@ -1,7 +0,0 @@ -export const chunkArray = (array, size) => { - const result = []; - for (let i = 0; i < array.length; i += size) { - result.push(array.slice(i, i + size)); - } - return result; -}; diff --git a/src/utils/DataMapper.js b/src/utils/DataMapper.js deleted file mode 100644 index 6199b70..0000000 --- a/src/utils/DataMapper.js +++ /dev/null @@ -1,58 +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) => ({ - quickRatio: summary.financialData?.quickRatio ?? 0, - debtToEquity: (summary.financialData?.debtToEquity ?? 0) / 100, - fcfGrowth: - (summary.financialData?.freeCashflow ?? 0) > 0 ? 'positive' : 'negative', - revenueGrowth: (summary.financialData?.revenueGrowth ?? 0) * 100, - netProfitMargin: (summary.financialData?.profitMargins ?? 0) * 100, - pegRatio: summary.defaultKeyStatistics?.pegRatio ?? 0, - peRatio: summary.defaultKeyStatistics?.forwardPE ?? 0, - currentPrice: summary.price?.regularMarketPrice ?? 0, - assetProfile: summary.assetProfile || {}, -}); - -const mapEtfData = (summary) => ({ - expenseRatio: (summary.summaryDetail?.expenseRatio ?? 0) * 100, - totalAssets: summary.summaryDetail?.totalAssets ?? 0, - yield: (summary.summaryDetail?.trailingAnnualDividendYield ?? 0) * 100, - fiveYearReturn: summary.defaultKeyStatistics?.fiveYearAverageReturn ?? 0, - currentPrice: summary.price?.regularMarketPrice ?? 0, -}); - -const mapBondData = (summary) => ({ - yieldToMaturity: (summary.summaryDetail?.yield ?? 0) * 100, - duration: summary.defaultKeyStatistics?.fiveYearAverageReturn ?? 0, - creditRating: summary.assetProfile?.governanceEpochDate ? 'Rated' : 'N/A', - currentPrice: summary.price?.regularMarketPrice ?? 0, -}); diff --git a/src/utils/RulesMerger.js b/src/utils/RulesMerger.js deleted file mode 100644 index eed20d4..0000000 --- a/src/utils/RulesMerger.js +++ /dev/null @@ -1,37 +0,0 @@ -import { ScoringRules } from '../config/ScoringConfig.js'; - -/** - * RuleMerger ensures that we apply sector-specific overrides - * to base asset rules without polluting the individual Asset or Scorer logic. - */ -export const RuleMerger = { - getRulesForAsset(type, metrics) { - // 1. Start with a deep clone of the base rules for this asset type (STOCK, ETF, etc.) - const baseRules = ScoringRules[type]; - if (!baseRules) throw new Error(`No configuration found for type: ${type}`); - - let finalRules = JSON.parse(JSON.stringify(baseRules)); - - // 2. If it's a stock and we have a sector, merge the overrides - if (type === 'STOCK' && metrics.sector) { - const sectorKey = metrics.sector.toUpperCase(); - const overrides = baseRules.SECTOR_OVERRIDE?.[sectorKey]; - - if (overrides) { - // Merge gates, weights, and thresholds deeply - finalRules.gates = { ...finalRules.gates, ...overrides.gates }; - finalRules.weights = { ...finalRules.weights, ...overrides.weights }; - finalRules.thresholds = { - ...finalRules.thresholds, - ...overrides.thresholds, - }; - } - } - - // 3. Cleanup: Remove the override configuration from the final object - // so the Scorer works with a clean, flat rule set. - delete finalRules.SECTOR_OVERRIDE; - - return finalRules; - }, -}; diff --git a/tests/BondScorer.test.js b/tests/BondScorer.test.js new file mode 100644 index 0000000..95a1983 --- /dev/null +++ b/tests/BondScorer.test.js @@ -0,0 +1,61 @@ +import { test } from 'node:test'; +import assert from 'node:assert/strict'; +import { BondScorer } from '../src/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. + +const rules = { + gates: { minCreditRating: 7 }, + weights: { yieldSpread: 3, duration: 2 }, + thresholds: { minSpread: 1.0, maxDuration: 10 }, +}; +const ctx = { riskFreeRate: 4.5 }; + +test('rejects bond below investment-grade floor', () => { + const result = BondScorer.score( + { ytm: 8.0, duration: 5, creditRating: 'BB', creditRatingNumeric: 6 }, + rules, + ctx, + ); + assert.equal(result.label, '🔴 Avoid'); + assert(result.scoreSummary.includes('Gate failed')); +}); + +test('attractive for wide spread and short duration', () => { + // ytm=6.5%, riskFree=4.5% → spreadPct=(0.065-0.045)*100=2.0% >= minSpread 1.0% + const result = BondScorer.score( + { ytm: 6.5, duration: 4, creditRating: 'AA', creditRatingNumeric: 9 }, + rules, + ctx, + ); + assert.equal(result.label, '🟢 Attractive'); +}); + +test('spread calculation: ytm% → decimal, subtract riskFreeRate/100, back to %', () => { + const result = BondScorer.score( + { ytm: 6.5, duration: 5, creditRating: 'AAA', creditRatingNumeric: 10 }, + rules, + ctx, + ); + assert.equal(result.audit.breakdown.spread, rules.weights.yieldSpread); +}); + +test('fails spread when yield barely above risk-free', () => { + // ytm=4.7%, riskFree=4.5% → spreadPct=0.2% < minSpread 1.0% + const result = BondScorer.score( + { ytm: 4.7, duration: 5, creditRating: 'AAA', creditRatingNumeric: 10 }, + rules, + ctx, + ); + assert.equal(result.audit.breakdown.spread, -2); +}); + +test('penalises long duration', () => { + const result = BondScorer.score( + { ytm: 6.5, duration: 15, creditRating: 'AA', creditRatingNumeric: 9 }, + rules, + ctx, + ); + assert.equal(result.audit.breakdown.duration, -1); +}); diff --git a/tests/DataMapper.test.js b/tests/DataMapper.test.js new file mode 100644 index 0000000..7f99a27 --- /dev/null +++ b/tests/DataMapper.test.js @@ -0,0 +1,92 @@ +import { test } from 'node:test'; +import assert from 'node:assert/strict'; +import { mapToStandardFormat } from '../src/screener/DataMapper.js'; + +const base = { + price: { quoteType: 'EQUITY', regularMarketPrice: 150 }, + assetProfile: { sector: 'Technology', industry: 'Software', category: '' }, + financialData: { + quickRatio: 1.2, + debtToEquity: 150, + freeCashflow: 5e9, + revenueGrowth: 0.15, + profitMargins: 0.25, + operatingMargins: 0.3, + returnOnEquity: 0.2, + earningsGrowth: 0.12, + operatingCashflow: 8e9, + }, + defaultKeyStatistics: { pegRatio: null, forwardPE: 28, sharesOutstanding: 1e9, priceToBook: 12 }, + summaryDetail: { + trailingAnnualDividendYield: 0.005, + trailingPE: 30, + beta: 1.2, + fiftyTwoWeekHigh: 200, + fiftyTwoWeekLow: 120, + }, +}; + +test('maps EQUITY quote type to STOCK', () => { + const result = mapToStandardFormat('AAPL', base); + assert.equal(result.type, 'STOCK'); + assert.equal(result.ticker, 'AAPL'); +}); + +test('computes PEG from trailingPE / earningsGrowth when Yahoo returns null', () => { + const result = mapToStandardFormat('AAPL', base); + const expected = +(30 / (0.12 * 100)).toFixed(2); // trailingPE=30, earningsGrowth=12% + assert.equal(result.pegRatio, expected); +}); + +test('uses Yahoo pegRatio when available', () => { + const summary = { + ...base, + defaultKeyStatistics: { ...base.defaultKeyStatistics, pegRatio: 1.5 }, + }; + const result = mapToStandardFormat('AAPL', summary); + assert.equal(result.pegRatio, 1.5); +}); + +test('debtToEquity is divided by 100', () => { + const result = mapToStandardFormat('AAPL', base); + assert.equal(result.debtToEquity, 1.5); // 150 / 100 +}); + +test('maps ETF quoteType to ETF', () => { + const etfSummary = { + ...base, + price: { ...base.price, quoteType: 'ETF' }, + assetProfile: { category: 'Large Blend' }, + }; + const result = mapToStandardFormat('VOO', etfSummary); + assert.equal(result.type, 'ETF'); +}); + +test('classifies bond ETF from category keyword', () => { + const bondSummary = { + ...base, + price: { ...base.price, quoteType: 'ETF' }, + assetProfile: { category: 'Intermediate-Term Bond' }, + }; + const result = mapToStandardFormat('BND', bondSummary); + assert.equal(result.type, 'BOND'); +}); + +test('FCF yield is computed when data available', () => { + const result = mapToStandardFormat('AAPL', base); + assert.notEqual(result.fcfYield, null); + assert(result.fcfYield > 0); +}); + +test('metrics are null (not 0) when data missing', () => { + const sparse = { + price: { quoteType: 'EQUITY', regularMarketPrice: 100 }, + financialData: {}, + defaultKeyStatistics: {}, + summaryDetail: {}, + assetProfile: {}, + }; + const result = mapToStandardFormat('X', sparse); + assert.equal(result.pegRatio, null); + assert.equal(result.quickRatio, null); +}); diff --git a/tests/EtfScorer.test.js b/tests/EtfScorer.test.js new file mode 100644 index 0000000..19d592e --- /dev/null +++ b/tests/EtfScorer.test.js @@ -0,0 +1,31 @@ +import { test } from 'node:test'; +import assert from 'node:assert/strict'; +import { EtfScorer } from '../src/screener/scorers/EtfScorer.js'; + +const rules = { + gates: { maxExpenseRatio: 0.5 }, + weights: { yield: 2, lowCost: 3 }, + thresholds: { minYield: 1.5, maxExpense: 0.1, minVolume: 500000 }, +}; + +test('rejects ETF with expense ratio above gate', () => { + const result = EtfScorer.score({ expenseRatio: 0.8, yield: 2.0 }, rules); + assert.equal(result.label, '🔴 REJECT'); +}); + +test('efficient label for low-cost, high-yield ETF', () => { + const result = EtfScorer.score({ expenseRatio: 0.03, yield: 2.0, volume: 1000000 }, rules); + assert.equal(result.label, '🟢 Efficient'); +}); + +test('neutral when yield is below threshold', () => { + const result = EtfScorer.score({ expenseRatio: 0.03, yield: 0.4, volume: 1000000 }, rules); + assert.equal(result.label, '🟡 Neutral'); +}); + +test('audit breakdown includes cost, yield, vol keys', () => { + const result = EtfScorer.score({ expenseRatio: 0.03, yield: 2.0, volume: 1000000 }, rules); + assert(result.audit.breakdown.cost != null); + assert(result.audit.breakdown.yield != null); + assert(result.audit.breakdown.vol != null); +}); diff --git a/tests/MarketRegime.test.js b/tests/MarketRegime.test.js new file mode 100644 index 0000000..c0af925 --- /dev/null +++ b/tests/MarketRegime.test.js @@ -0,0 +1,45 @@ +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'; + +const regime = (benchmarks) => new MarketRegime({ benchmarks }); + +test('stock inflated P/E = marketPE × 1.5', () => { + const { gates } = regime({ marketPE: 24 }).getInflatedOverrides(ASSET_TYPE.STOCK, SECTOR.GENERAL); + assert.equal(gates.maxPERatio, Math.round(24 * 1.5)); // 36 +}); + +test('tech inflated P/E = techPE × 1.3', () => { + const { gates } = regime({ techPE: 40 }).getInflatedOverrides( + ASSET_TYPE.STOCK, + SECTOR.TECHNOLOGY, + ); + assert.equal(gates.maxPERatio, Math.round(40 * 1.3)); // 52 +}); + +test('REIT inflated minYield = reitYield × 0.85', () => { + const { thresholds } = regime({ reitYield: 4.0 }).getInflatedOverrides( + ASSET_TYPE.STOCK, + SECTOR.REIT, + ); + assert.equal(thresholds.minYield, +(4.0 * 0.85).toFixed(2)); // 3.40 +}); + +test('bond inflated minSpread = igSpread × 0.80', () => { + const { thresholds } = regime({ igSpread: 1.5 }).getInflatedOverrides( + ASSET_TYPE.BOND, + SECTOR.GENERAL, + ); + assert.equal(thresholds.minSpread, +(1.5 * 0.8).toFixed(2)); // 1.20 +}); + +test('ETF inflated loosens expense gate to 0.75', () => { + const { gates } = regime({}).getInflatedOverrides(ASSET_TYPE.ETF); + assert.equal(gates.maxExpenseRatio, 0.75); +}); + +test('falls back to defaults when benchmarks missing', () => { + const { gates } = new MarketRegime({}).getInflatedOverrides(ASSET_TYPE.STOCK, SECTOR.GENERAL); + assert.equal(gates.maxPERatio, Math.round(22 * 1.5)); // default marketPE = 22 +}); diff --git a/tests/PortfolioAdvisor.test.js b/tests/PortfolioAdvisor.test.js new file mode 100644 index 0000000..85edee3 --- /dev/null +++ b/tests/PortfolioAdvisor.test.js @@ -0,0 +1,49 @@ +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'; + +const advisor = new PortfolioAdvisor(); + +test('_position: computes gain/loss correctly', () => { + const pos = advisor._position({ costBasis: 100, shares: 10 }, 150); + assert.equal(pos.gainLossPct, '50.0'); + assert.equal(pos.marketValue, '1500.00'); + assert.equal(pos.totalCost, '1000.00'); +}); + +test('_position: returns null gainLoss when price unavailable', () => { + const pos = advisor._position({ costBasis: 100, shares: 10 }, null); + assert.equal(pos.gainLossPct, null); + assert.equal(pos.marketValue, null); +}); + +test('_advice: Strong Buy → Hold & Add', () => { + const { action } = advisor._advice(SIGNAL.STRONG_BUY, { costBasis: 100, shares: 10 }, 150); + assert.equal(action, '🟢 Hold & Add'); +}); + +test('_advice: Avoid + loss → Sell (Cut Loss)', () => { + const { action } = advisor._advice(SIGNAL.AVOID, { costBasis: 150, shares: 10 }, 100); + assert.equal(action, '🔴 Sell (Cut Loss)'); +}); + +test('_advice: Avoid + profit → Sell (Take Profits)', () => { + const { action } = advisor._advice(SIGNAL.AVOID, { costBasis: 100, shares: 10 }, 150); + assert.equal(action, '🔴 Sell (Take Profits)'); +}); + +test('_advice: Speculation + >20% gain → Reduce Position', () => { + const { action } = advisor._advice(SIGNAL.SPECULATION, { costBasis: 100, shares: 10 }, 125); + assert.equal(action, '🟠 Reduce Position'); +}); + +test('_cryptoAdvice: no price → No price data', () => { + const { action } = advisor._cryptoAdvice({ costBasis: 100, shares: 1 }, null); + assert.equal(action, '⚪ No price data'); +}); + +test('_cryptoAdvice: >100% gain → Consider taking profits', () => { + const { action } = advisor._cryptoAdvice({ costBasis: 10000, shares: 1 }, 25000); + assert.equal(action, '🟠 Consider taking profits'); +}); diff --git a/tests/RuleMerger.test.js b/tests/RuleMerger.test.js new file mode 100644 index 0000000..49eff00 --- /dev/null +++ b/tests/RuleMerger.test.js @@ -0,0 +1,66 @@ +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'; + +const ctx = { + benchmarks: { marketPE: 25, techPE: 32, reitYield: 3.8, igSpread: 1.2 }, +}; + +test('FUNDAMENTAL mode returns Graham-style P/E gate', () => { + const rules = RuleMerger.getRulesForAsset( + 'STOCK', + { sector: 'GENERAL' }, + ctx, + SCORE_MODE.FUNDAMENTAL, + ); + assert.equal(rules.gates.maxPERatio, 15); // updated: Graham's real rule is 15x + assert.equal(rules.gates.maxPegGate, 1.0); // updated: Lynch PEG standard +}); + +test('INFLATED mode loosens P/E gate from live SPY data', () => { + const rules = RuleMerger.getRulesForAsset( + 'STOCK', + { sector: 'GENERAL' }, + ctx, + SCORE_MODE.INFLATED, + ); + assert.equal(rules.gates.maxPERatio, Math.round(25 * 1.5)); // 37 + assert(rules.gates.maxPERatio > 15, 'Inflated P/E should exceed fundamental 15x'); +}); + +test('INFLATED tech P/E gate uses XLK benchmark', () => { + const rules = RuleMerger.getRulesForAsset( + 'STOCK', + { sector: 'TECHNOLOGY' }, + ctx, + SCORE_MODE.INFLATED, + ); + assert.equal(rules.gates.maxPERatio, Math.round(32 * 1.3)); // 42 +}); + +test('Sector override applied before inflated overrides', () => { + const rules = RuleMerger.getRulesForAsset( + 'STOCK', + { sector: 'REIT' }, + ctx, + SCORE_MODE.FUNDAMENTAL, + ); + assert.equal(rules.gates.maxPERatio, 9999); + assert.equal(rules.weights.yield, 5); + assert.equal(rules.weights.margin, 0); +}); + +test('SECTOR_OVERRIDE is deleted from returned rules', () => { + const rules = RuleMerger.getRulesForAsset( + 'STOCK', + { sector: 'GENERAL' }, + ctx, + SCORE_MODE.FUNDAMENTAL, + ); + assert.equal(rules.SECTOR_OVERRIDE, undefined); +}); + +test('throws for unknown asset type', () => { + assert.throws(() => RuleMerger.getRulesForAsset('CRYPTO', {}, ctx), /No rules configured/); +}); diff --git a/tests/ScoringConfig.test.js b/tests/ScoringConfig.test.js new file mode 100644 index 0000000..867da09 --- /dev/null +++ b/tests/ScoringConfig.test.js @@ -0,0 +1,41 @@ +import { test } from 'node:test'; +import assert from 'node:assert/strict'; +import { CREDIT_RATING_SCALE, ScoringRules } from '../src/config/ScoringConfig.js'; + +test('CREDIT_RATING_SCALE covers full spectrum', () => { + assert.equal(CREDIT_RATING_SCALE.AAA, 10); + assert.equal(CREDIT_RATING_SCALE.BBB, 7); + assert.equal(CREDIT_RATING_SCALE.BB, 6); + assert.equal(CREDIT_RATING_SCALE.D, 1); +}); + +test('STOCK base gates are fundamental (Graham-style)', () => { + const { gates } = ScoringRules.STOCK; + assert.equal(gates.maxPERatio, 15); // Graham's actual rule: 15x trailing earnings + assert.equal(gates.maxPegGate, 1.0); // Lynch standard: PEG > 1.0 is paying full price + assert.equal(gates.minQuickRatio, 0.8); // below 0.8 signals liquidity stress +}); + +test('REIT sector override zeroes out irrelevant weights', () => { + const reit = ScoringRules.STOCK.SECTOR_OVERRIDE.REIT; + assert.equal(reit.weights.margin, 0); + assert.equal(reit.weights.peg, 0); + assert.equal(reit.weights.revenue, 0); + assert.equal(reit.weights.yield, 5); +}); + +test('REIT gates disable P/E and PEG', () => { + const reit = ScoringRules.STOCK.SECTOR_OVERRIDE.REIT; + assert.equal(reit.gates.maxPERatio, 9999); + assert.equal(reit.gates.maxPegGate, 9999); +}); + +test('TECHNOLOGY gates are realistic for mega-cap', () => { + const tech = ScoringRules.STOCK.SECTOR_OVERRIDE.TECHNOLOGY; + assert.equal(tech.gates.maxDebtToEquity, 2.0); + assert.equal(tech.gates.minQuickRatio, 0.8); +}); + +test('BOND requires investment-grade floor (BBB = 7)', () => { + assert.equal(ScoringRules.BOND.gates.minCreditRating, 7); +}); diff --git a/tests/StockScorer.test.js b/tests/StockScorer.test.js new file mode 100644 index 0000000..88e3d00 --- /dev/null +++ b/tests/StockScorer.test.js @@ -0,0 +1,81 @@ +import { test } from 'node:test'; +import assert from 'node:assert/strict'; +import { StockScorer } from '../src/screener/scorers/StockScorer.js'; + +const baseRules = { + gates: { maxDebtToEquity: 3.0, minQuickRatio: 0.5, maxPERatio: 20, maxPegGate: 1.5 }, + weights: { margin: 2, opMargin: 2, roe: 3, peg: 2, revenue: 2, fcf: 2 }, + thresholds: { + marginHigh: 20, + marginMed: 10, + opMarginHigh: 20, + opMarginMed: 10, + roeHigh: 20, + roeMed: 10, + pegHigh: 1.0, + pegMed: 1.5, + revHigh: 15, + revMed: 5, + fcfHigh: 5, + fcfMed: 2, + }, +}; + +const pass = { + peRatio: 15, + pegRatio: 1.2, + debtToEquity: 1.0, + quickRatio: 1.0, + returnOnEquity: 22, + operatingMargin: 25, + netProfitMargin: 18, + revenueGrowth: 16, + fcfYield: 6, +}; + +test('rejects on high D/E', () => { + const result = StockScorer.score({ ...pass, debtToEquity: 4.0 }, baseRules); + assert.equal(result.label, '🔴 REJECT'); + assert(result.scoreSummary.includes('D/E')); +}); + +test('rejects on high P/E', () => { + const result = StockScorer.score({ ...pass, peRatio: 25 }, baseRules); + assert.equal(result.label, '🔴 REJECT'); + assert(result.scoreSummary.includes('P/E')); +}); + +test('rejects on high PEG', () => { + const result = StockScorer.score({ ...pass, pegRatio: 2.0 }, baseRules); + assert.equal(result.label, '🔴 REJECT'); +}); + +test('skips gate when metric is null (missing data)', () => { + const result = StockScorer.score({ ...pass, pegRatio: null, peRatio: null }, baseRules); + assert.notEqual(result.label, '🔴 REJECT'); +}); + +test('high-conviction BUY on strong metrics', () => { + const result = StockScorer.score(pass, baseRules); + assert.equal(result.label, '🟢 BUY (High Conviction)'); +}); + +test('audit breakdown contains scored factors', () => { + const result = StockScorer.score(pass, baseRules); + assert(result.audit.passedGates); + assert(result.audit.breakdown.roe != null); + assert(result.audit.breakdown.margin != null); +}); + +test('beta > 1.5 surfaces as risk flag', () => { + const result = StockScorer.score({ ...pass, beta: 2.0 }, baseRules); + assert(result.audit.riskFlags?.some((f) => f.includes('High volatility'))); +}); + +test('near 52-week high surfaces as risk flag', () => { + const result = StockScorer.score( + { ...pass, week52High: 200, week52Low: 100, currentPrice: 195 }, + baseRules, + ); + assert(result.audit.riskFlags?.some((f) => f.includes('52-week high'))); +});