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
This commit is contained in:
Kazuma
2026-06-03 00:02:55 -04:00
parent 19fc052d14
commit cd74497de6
60 changed files with 4610 additions and 796 deletions
+11
View File
@@ -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
+2
View File
@@ -0,0 +1,2 @@
npx lint-staged
npm test
+1
View File
@@ -0,0 +1 @@
npm test
+12
View File
@@ -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"
}
+231
View File
@@ -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 | LQDTNX 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 (34 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.
-46
View File
@@ -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 todays 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.
+143 -38
View File
@@ -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.
+84
View File
@@ -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);
});
+36
View File
@@ -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 -- <file.csv>
*/
import { PortfolioImporter } from '../src/finance/PortfolioImporter.js';
const csvPath = process.argv[2];
if (!csvPath) {
console.error('Usage: npm run import-portfolio -- <path-to-csv>\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);
}
+83
View File
@@ -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);
+82
View File
@@ -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));
+34
View File
@@ -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 -- <path-to-csv>');
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);
}
-32
View File
@@ -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);
+663 -4
View File
@@ -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",
+21 -3
View File
@@ -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"
}
}
+165
View File
@@ -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 35 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 (15 days) | Medium (14 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 1020%
> - **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 | 15 | 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 (15 days) | Confirm the stock isn't already broken (avoid catching falling knives on longs) |
| Medium (14 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** (25%) | 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 1525) | 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. |
+37
View File
@@ -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`;
}
+52
View File
@@ -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];
}
}
-45
View File
@@ -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 };
}
}
}
+122 -29
View File
@@ -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 },
},
};
+48
View File
@@ -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,
};
-23
View File
@@ -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();
}
}
-60
View File
@@ -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),
};
}
}
-49
View File
@@ -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;
},
};
-122
View File
@@ -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);
});
}
}
-63
View File
@@ -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';
},
};
-62
View File
@@ -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';
},
};
-107
View File
@@ -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';
},
};
+62
View File
@@ -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,
};
}
}
+161
View File
@@ -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;
}
}
+249
View File
@@ -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;
}
}
+187
View File
@@ -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=<your setup token from https://beta-bridge.simplefin.org>\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);
}
}
+189
View File
@@ -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=<your setup token from https://beta-bridge.simplefin.org>\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`);
}
}
+73
View File
@@ -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;
}
}
}
+50
View File
@@ -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) } };
}
}
+304
View File
@@ -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 `<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Personal Finance — ${date}</title>
<style>
*, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; }
body { font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif; background: #0f1117; color: #e2e8f0; font-size: 13px; }
h1 { font-size: 20px; font-weight: 600; }
h2 { font-size: 13px; font-weight: 600; text-transform: uppercase; letter-spacing: 0.05em; color: #94a3b8; margin-bottom: 12px; }
.header { padding: 24px 32px 16px; border-bottom: 1px solid #1e293b; display: flex; align-items: center; gap: 16px; }
.pill { background: #1e293b; border-radius: 6px; padding: 4px 12px; font-size: 12px; color: #94a3b8; margin-left: auto; }
.pill span { color: #e2e8f0; font-weight: 600; margin-left: 4px; }
.content { padding: 24px 32px; }
.section { margin-bottom: 40px; }
.grid { display: grid; grid-template-columns: repeat(auto-fill, minmax(160px, 1fr)); gap: 12px; margin-bottom: 24px; }
.card { background: #1e293b; border-radius: 8px; padding: 14px 16px; }
.card-label { font-size: 11px; color: #64748b; text-transform: uppercase; letter-spacing: 0.04em; }
.card-value { font-size: 20px; font-weight: 700; color: #f1f5f9; margin-top: 4px; }
.card-sub { font-size: 11px; color: #64748b; margin-top: 2px; }
table { width: 100%; border-collapse: collapse; }
thead th { text-align: left; padding: 8px 12px; font-size: 11px; font-weight: 600; text-transform: uppercase; letter-spacing: 0.04em; color: #64748b; border-bottom: 1px solid #1e293b; white-space: nowrap; }
tbody tr { border-bottom: 1px solid #1a2233; }
tbody tr:hover { background: #1e293b; }
tbody td { padding: 10px 12px; vertical-align: middle; }
.ticker { font-weight: 700; font-size: 14px; color: #f1f5f9; }
.green { color: #4ade80; }
.yellow { color: #facc15; }
.orange { color: #fb923c; }
.red { color: #f87171; }
.gray { color: #64748b; }
.advice-green { color: #4ade80; font-weight: 600; }
.advice-yellow { color: #facc15; font-weight: 600; }
.advice-orange { color: #fb923c; font-weight: 600; }
.advice-red { color: #f87171; font-weight: 600; }
.reason { color: #94a3b8; font-size: 11px; }
.bar-bg { background: #1e293b; border-radius: 4px; height: 8px; }
.bar-fill { background: #3b82f6; border-radius: 4px; height: 8px; }
.two-col { display: grid; grid-template-columns: 1fr 1fr; gap: 24px; }
</style>
</head>
<body>
<div class="header">
<h1>💰 Personal Finance</h1>
<div class="pill">Date <span>${date}</span></div>
</div>
<div class="content">
${pf ? this._netWorthSection(pf) : ''}
${this._portfolioSection(advice, ctx)}
${pf ? this._spendingSection(pf) : ''}
${pf ? this._accountsSection(pf) : ''}
</div>
</body>
</html>`;
}
// ── Net worth ──────────────────────────────────────────────────────────────
_netWorthSection(pf) {
const f = (n) =>
new Intl.NumberFormat('en-US', {
style: 'currency',
currency: 'USD',
maximumFractionDigits: 0,
}).format(n);
return `
<div class="section">
<h2>Net Worth</h2>
<div class="grid">
${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))}
</div>
</div>`;
}
// ── 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 `<span style="background:${color}22;color:${color};padding:2px 8px;border-radius:4px;font-size:11px;font-weight:600">${s}</span>`;
};
const stockRows = stocks
.map((a) => {
const glClass = parseFloat(a.gainLossPct) >= 0 ? 'green' : 'red';
const advClass = this._adviceClass(a.advice);
return `<tr>
<td class="ticker">${a.ticker}</td>
<td>${sourcePill(a.source)}</td>
<td><span style="background:#1e293b;padding:2px 8px;border-radius:4px;font-size:11px">${a.type}</span></td>
<td>${a.shares}</td>
<td>${f(a.costBasis)}</td>
<td>${f(parseFloat(a.currentPrice))}</td>
<td>${f(parseFloat(a.marketValue))}</td>
<td class="${glClass}">${a.gainLossPct != null ? a.gainLossPct + '%' : '—'}</td>
<td class="gray" style="font-size:11px">${a.signal ?? '—'}</td>
<td class="${advClass}">${a.advice}</td>
<td class="reason">${a.reason}</td>
</tr>`;
})
.join('');
const cryptoRows = crypto
.map((a) => {
const glClass = parseFloat(a.gainLossPct) >= 0 ? 'green' : 'red';
const advClass = this._adviceClass(a.advice);
return `<tr>
<td class="ticker">${a.ticker}</td>
<td>${sourcePill(a.source)}</td>
<td>${a.shares}</td>
<td>${f(a.costBasis)}</td>
<td>${f(parseFloat(a.currentPrice))}</td>
<td>${f(parseFloat(a.marketValue))}</td>
<td class="${glClass}">${a.gainLossPct != null ? a.gainLossPct + '%' : '—'}</td>
<td class="${advClass}">${a.advice}</td>
<td class="reason">${a.reason}</td>
</tr>`;
})
.join('');
return `
<div class="section">
<h2>Portfolio — Hold / Sell / Add Advice</h2>
<div class="grid" style="margin-bottom:16px">
${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')}
</div>
${
stocks.length > 0
? `
<h2 style="margin-bottom:10px">Stocks &amp; ETFs</h2>
<table>
<thead><tr>
<th>Ticker</th><th>Source</th><th>Type</th><th>Shares</th>
<th>Cost Basis</th><th>Current</th><th>Value</th>
<th>G/L</th><th>Signal</th><th>Advice</th><th>Reason</th>
</tr></thead>
<tbody>${stockRows}</tbody>
</table>`
: ''
}
${
crypto.length > 0
? `
<h2 style="margin-top:24px;margin-bottom:10px">Crypto</h2>
<table>
<thead><tr>
<th>Ticker</th><th>Source</th><th>Shares</th>
<th>Cost Basis</th><th>Current</th><th>Value</th>
<th>G/L</th><th>Advice</th><th>Note</th>
</tr></thead>
<tbody>${cryptoRows}</tbody>
</table>`
: ''
}
</div>`;
}
// ── 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) => `
<tr>
<td>${c.category}</td>
<td style="text-align:right">${f(c.amount)}</td>
<td style="text-align:right; color:#94a3b8">${c.pct}%</td>
<td style="width:120px">
<div class="bar-bg"><div class="bar-fill" style="width:${Math.min(c.pct, 100)}%"></div></div>
</td>
</tr>`,
)
.join('');
return `
<div class="section">
<h2>Spending by Category — Last 30 Days</h2>
<table>
<thead><tr><th>Category</th><th style="text-align:right">Amount</th><th style="text-align:right">Share</th><th></th></tr></thead>
<tbody>${rows}</tbody>
</table>
</div>`;
}
// ── Accounts ───────────────────────────────────────────────────────────────
_accountsSection(pf) {
const f = (n) =>
new Intl.NumberFormat('en-US', {
style: 'currency',
currency: 'USD',
}).format(n);
const rows = pf.accounts
.map(
(a) => `
<tr>
<td class="ticker">${a.name}</td>
<td><span style="background:#1e293b;padding:2px 8px;border-radius:4px;font-size:11px">${a.type}</span></td>
<td class="gray">${a.org}</td>
<td style="text-align:right" class="${a.balance >= 0 ? 'green' : 'red'}">${f(a.balance)}</td>
<td class="gray" style="text-align:right">${a.balanceDate}</td>
</tr>`,
)
.join('');
return `
<div class="section">
<h2>Accounts</h2>
<table>
<thead><tr><th>Account</th><th>Type</th><th>Institution</th><th style="text-align:right">Balance</th><th style="text-align:right">Updated</th></tr></thead>
<tbody>${rows}</tbody>
</table>
</div>`;
}
// ── Helpers ────────────────────────────────────────────────────────────────
_card(label, value, colorClass = null, sub = null) {
return `<div class="card">
<div class="card-label">${label}</div>
<div class="card-value ${colorClass ? colorClass : ''}">${value}</div>
${sub ? `<div class="card-sub">${sub}</div>` : ''}
</div>`;
}
_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';
}
}
+392
View File
@@ -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 `<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Market Screener — ${ctx.timestamp?.slice(0, 10) ?? ''}</title>
<style>
*, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; }
body { font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif; background: #0f1117; color: #e2e8f0; font-size: 13px; }
h1 { font-size: 20px; font-weight: 600; }
h2 { font-size: 14px; font-weight: 600; text-transform: uppercase; letter-spacing: 0.05em; color: #94a3b8; margin-bottom: 12px; }
a { color: inherit; text-decoration: none; }
.header { padding: 24px 32px 16px; border-bottom: 1px solid #1e293b; display: flex; align-items: center; gap: 16px; }
.header-meta { display: flex; gap: 24px; margin-left: auto; }
.pill { background: #1e293b; border-radius: 6px; padding: 4px 12px; font-size: 12px; color: #94a3b8; }
.pill span { color: #e2e8f0; font-weight: 600; margin-left: 4px; }
.content { padding: 24px 32px; }
.ctx-grid { display: grid; grid-template-columns: repeat(auto-fill, minmax(160px, 1fr)); gap: 12px; margin-bottom: 32px; }
.ctx-card { background: #1e293b; border-radius: 8px; padding: 14px 16px; }
.ctx-label { font-size: 11px; color: #64748b; text-transform: uppercase; letter-spacing: 0.04em; }
.ctx-value { font-size: 18px; font-weight: 700; color: #f1f5f9; margin-top: 4px; }
.section { margin-bottom: 40px; }
.tabs { display: flex; gap: 0; border-bottom: 1px solid #1e293b; margin-bottom: 16px; }
.tab { padding: 8px 20px; cursor: pointer; border-bottom: 2px solid transparent; font-size: 12px; font-weight: 600; color: #64748b; transition: color 0.15s; }
.tab.active { color: #e2e8f0; border-bottom-color: #3b82f6; }
table { width: 100%; border-collapse: collapse; }
thead th { text-align: left; padding: 8px 12px; font-size: 11px; font-weight: 600; text-transform: uppercase; letter-spacing: 0.04em; color: #64748b; border-bottom: 1px solid #1e293b; white-space: nowrap; }
tbody tr { border-bottom: 1px solid #1a2233; transition: background 0.1s; }
tbody tr:hover { background: #1e293b; }
tbody td { padding: 10px 12px; vertical-align: middle; white-space: nowrap; }
.ticker { font-weight: 700; font-size: 14px; color: #f1f5f9; }
.price { color: #94a3b8; font-variant-numeric: tabular-nums; }
.sector { font-size: 11px; color: #64748b; background: #1e293b; padding: 2px 8px; border-radius: 4px; }
.score { font-weight: 700; font-variant-numeric: tabular-nums; }
.verdict-green { color: #4ade80; }
.verdict-yellow { color: #facc15; }
.verdict-red { color: #f87171; }
.signal-strong { color: #4ade80; font-weight: 700; }
.signal-momentum{ color: #60a5fa; font-weight: 700; }
.signal-neutral { color: #94a3b8; }
.signal-spec { color: #fb923c; font-weight: 700; }
.signal-avoid { color: #f87171; font-weight: 700; }
.pass { color: #4ade80; }
.fail { color: #f87171; }
.flag { color: #fb923c; font-size: 11px; display: block; margin-top: 2px; }
.risk-flags { display: flex; flex-direction: column; gap: 2px; }
.tab-content { display: none; }
.tab-content.active { display: block; }
.no-data { color: #334155; }
</style>
</head>
<body>
<div class="header">
<h1>📊 Market Screener</h1>
<div class="header-meta">
<div class="pill">Date <span>${ctx.timestamp?.slice(0, 10) ?? '—'}</span></div>
<div class="pill">Rate <span>${ctx.rateRegime}</span></div>
<div class="pill">Volatility <span>${ctx.volatilityRegime}</span></div>
</div>
</div>
<div class="content">
<div class="ctx-grid">
${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) ?? '—') + '%')}
</div>
<div class="section">
<h2>Signal Summary</h2>
<table>
<thead><tr><th>Ticker</th><th>Type</th><th>Signal</th><th>Inflated Verdict</th><th>Fundamental Verdict</th></tr></thead>
<tbody>${all
.sort((a, b) => this._sigOrd(a.signal) - this._sigOrd(b.signal))
.map((r) => this._summaryRow(r))
.join('')}</tbody>
</table>
</div>
${['STOCK', 'ETF', 'BOND']
.map((type) => (results[type]?.length ? this._assetSection(type, results[type], b) : ''))
.join('')}
${pf ? this._personalFinanceSection(pf) : ''}
${
results.ERROR?.length
? `
<div class="section">
<h2>Errors</h2>
<table>
<thead><tr><th>Ticker</th><th>Reason</th></tr></thead>
<tbody>${results.ERROR.map((e) => `<tr><td class="ticker">${e.ticker}</td><td class="verdict-red">${e.message}</td></tr>`).join('')}</tbody>
</table>
</div>`
: ''
}
</div>
<script>
document.querySelectorAll('.tabs').forEach((tabs) => {
tabs.querySelectorAll('.tab').forEach((tab) => {
tab.addEventListener('click', () => {
const section = tabs.closest('.section');
tabs.querySelectorAll('.tab').forEach((t) => t.classList.remove('active'));
section.querySelectorAll('.tab-content').forEach((c) => c.classList.remove('active'));
tab.classList.add('active');
section.querySelector('#' + tab.dataset.target).classList.add('active');
});
});
});
</script>
</body>
</html>`;
}
// ── 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 `
<div class="section">
<h2>${type}S</h2>
<div class="tabs">
<div class="tab active" data-target="${inflatedId}">${inflatedLabel}</div>
<div class="tab" data-target="${fundamentalId}">Fundamental (Graham-style)</div>
</div>
<div id="${inflatedId}" class="tab-content active">
${this._table(type, sorted, 'inflated')}
</div>
<div id="${fundamentalId}" class="tab-content">
${this._table(type, sorted, 'fundamental')}
</div>
</div>`;
}
_table(type, items, mode) {
const headers = this._headers(type, items, mode);
const rows = items.map((r) => this._row(type, r, mode, headers)).join('');
return `<table>
<thead><tr>${headers.map((h) => `<th>${h}</th>`).join('')}</tr></thead>
<tbody>${rows}</tbody>
</table>`;
}
// 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
? `<span class="${bd[key] > 0 ? 'pass' : 'fail'}">${bd[key] > 0 ? '✅' : '❌'}</span>`
: '';
const cells = {
Ticker: `<td class="ticker">${m.Ticker}</td>`,
Price: `<td class="price">${m.Price}</td>`,
Verdict: `<td class="${this._verdictClass(v)}">${v}</td>`,
Score: `<td class="score">${s}</td>`,
Sector: `<td><span class="sector">${m.Sector ?? ''}</span></td>`,
'P/E': `<td>${m['P/E'] ?? '<span class="no-data">—</span>'}</td>`,
PEG: `<td>${m.PEG != null ? m.PEG + ' ' + p('peg') : '<span class="no-data">—</span>'}</td>`,
'P/B': `<td>${m['P/B'] ?? '<span class="no-data">—</span>'}</td>`,
'ROE%': `<td>${m['ROE%'] != null ? m['ROE%'] + ' ' + p('roe') : '<span class="no-data">—</span>'}</td>`,
'OpMgn%': `<td>${m['OpMgn%'] != null ? m['OpMgn%'] + ' ' + p('opMargin') : '<span class="no-data">—</span>'}</td>`,
'NetMgn%': `<td>${m['NetMgn%'] != null ? m['NetMgn%'] + ' ' + p('margin') : '<span class="no-data">—</span>'}</td>`,
'Rev%': `<td>${m['Rev%'] != null ? m['Rev%'] + ' ' + p('revenue') : '<span class="no-data">—</span>'}</td>`,
'FCF Yld%': `<td>${m['FCF Yld%'] != null ? m['FCF Yld%'] + ' ' + p('fcf') : '<span class="no-data">—</span>'}</td>`,
'Div%': `<td>${m['Div%'] != null ? m['Div%'] + ' ' + p('yield') : '<span class="no-data">—</span>'}</td>`,
'D/E': `<td>${m['D/E'] ?? '<span class="no-data">—</span>'}</td>`,
Quick: `<td>${m.Quick ?? '<span class="no-data">—</span>'}</td>`,
Beta: `<td>${m.Beta ?? '<span class="no-data">—</span>'}</td>`,
'52W Pos': `<td>${m['52W Pos'] ?? '<span class="no-data">—</span>'}</td>`,
'P/FFO': `<td>${m['P/FFO'] != null ? m['P/FFO'] + ' ' + p('pFFO') : '<span class="no-data">—</span>'}</td>`,
'Risk Flags': `<td class="risk-flags">${rf.map((f) => `<span class="flag">⚠ ${f}</span>`).join('') || '<span class="no-data">—</span>'}</td>`,
// ETF
Expense: `<td>${m['Exp Ratio%'] != null ? m['Exp Ratio%'] + ' ' + p('cost') : '<span class="no-data">—</span>'}</td>`,
Yield: `<td>${m['Yield%'] != null ? m['Yield%'] + ' ' + p('yield') : '<span class="no-data">—</span>'}</td>`,
AUM: `<td>${m.AUM ?? '<span class="no-data">—</span>'}</td>`,
'5Y Ret': `<td>${m['5Y Return%'] ?? '<span class="no-data">—</span>'}</td>`,
// BOND
YTM: `<td>${m['YTM%'] != null ? m['YTM%'] + ' ' + p('spread') : '<span class="no-data">—</span>'}</td>`,
Duration: `<td>${m.Duration != null ? m.Duration + ' ' + p('duration') : '<span class="no-data">—</span>'}</td>`,
Rating: `<td>${m.Rating ?? '<span class="no-data">—</span>'}</td>`,
};
return `<tr>${headers.map((h) => cells[h] ?? `<td>—</td>`).join('')}</tr>`;
}
_summaryRow(r) {
return `<tr>
<td class="ticker">${r.asset.ticker}</td>
<td><span class="sector">${r.asset.type}</span></td>
<td class="${this._signalClass(r.signal)}">${r.signal}</td>
<td class="${this._verdictClass(r.inflated.label)}">${r.inflated.label}</td>
<td class="${this._verdictClass(r.fundamental.label)}">${r.fundamental.label}</td>
</tr>`;
}
// ── Helpers ─────────────────────────────────────────────────────────────────
_ctxCard(label, value) {
return `<div class="ctx-card"><div class="ctx-label">${label}</div><div class="ctx-value">${value}</div></div>`;
}
_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
? `<span class="verdict-green">${fmt(n)}</span>`
: `<span class="verdict-red">${fmt(n)}</span>`;
const accountRows = pf.accounts
.map(
(a) => `
<tr>
<td class="ticker">${a.name}</td>
<td><span class="sector">${a.type}</span></td>
<td class="price">${a.org}</td>
<td style="text-align:right">${sign(a.balance)}</td>
<td class="price" style="text-align:right">${a.balanceDate}</td>
</tr>`,
)
.join('');
const categoryRows = pf.categoryBreakdown
.slice(0, 8)
.map(
(c) => `
<tr>
<td>${c.category}</td>
<td style="text-align:right">${fmt(c.amount)}</td>
<td style="text-align:right; color:#94a3b8">${c.pct}%</td>
<td>
<div style="background:#1e293b;border-radius:4px;height:8px;width:100%;max-width:120px">
<div style="background:#3b82f6;border-radius:4px;height:8px;width:${c.pct}%"></div>
</div>
</td>
</tr>`,
)
.join('');
return `
<div class="section">
<h2>Personal Finance — SimpleFIN</h2>
<div class="ctx-grid" style="margin-bottom:24px">
${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}%`) : ''}
</div>
<div style="display:grid;grid-template-columns:1fr 1fr;gap:24px">
<div>
<h2 style="margin-bottom:12px">Accounts</h2>
<table>
<thead><tr><th>Account</th><th>Type</th><th>Institution</th><th style="text-align:right">Balance</th><th style="text-align:right">Updated</th></tr></thead>
<tbody>${accountRows}</tbody>
</table>
</div>
<div>
<h2 style="margin-bottom:12px">Spending by Category (Last 30 Days)</h2>
<table>
<thead><tr><th>Category</th><th style="text-align:right">Amount</th><th style="text-align:right">%</th><th>Share</th></tr></thead>
<tbody>${categoryRows}</tbody>
</table>
</div>
</div>
</div>`;
}
_sigOrd(signal) {
return (
{
'✅ Strong Buy': 0,
'⚡ Momentum': 1,
'🔄 Neutral': 2,
'⚠️ Speculation': 3,
'❌ Avoid': 4,
}[signal] ?? 5
);
}
}
+4
View File
@@ -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),
);
+133
View File
@@ -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,
});
+33
View File
@@ -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;
},
};
+141
View File
@@ -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;
}
}
+19
View File
@@ -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();
}
}
@@ -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})`,
};
}
}
@@ -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)}%`,
+86
View File
@@ -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;
}
}
+40
View File
@@ -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,
};
},
};
+28
View File
@@ -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 },
};
},
};
+157
View File
@@ -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,
};
},
};
-7
View File
@@ -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;
};
-58
View File
@@ -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,
});
-37
View File
@@ -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;
},
};
+61
View File
@@ -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);
});
+92
View File
@@ -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);
});
+31
View File
@@ -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);
});
+45
View File
@@ -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
});
+49
View File
@@ -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');
});
+66
View File
@@ -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/);
});
+41
View File
@@ -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);
});
+81
View File
@@ -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')));
});