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:
@@ -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
|
||||
Executable
+2
@@ -0,0 +1,2 @@
|
||||
npx lint-staged
|
||||
npm test
|
||||
Executable
+1
@@ -0,0 +1 @@
|
||||
npm test
|
||||
+12
@@ -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"
|
||||
}
|
||||
@@ -0,0 +1,231 @@
|
||||
# CLAUDE.md
|
||||
|
||||
Guidance for working in this repository.
|
||||
|
||||
## Overview
|
||||
|
||||
`market-screener` is a Node.js CLI tool that screens stocks, ETFs, and bonds by fetching live data from Yahoo Finance and scoring each asset under two lenses:
|
||||
|
||||
- **Market-Adjusted** — gates derived from live Yahoo benchmarks (SPY P/E, XLK P/E, XLRE yield, LQD spread). Reflects what is acceptable in today's inflated market.
|
||||
- **Fundamental** — strict Graham/value-investing style gates from `ScoringConfig`. Reflects genuine value regardless of market conditions.
|
||||
|
||||
The comparison produces a **Signal** (Strong Buy / Momentum / Speculation / Neutral / Avoid).
|
||||
|
||||
ES module project (`"type": "module"`); use `import`/`export`, not `require`.
|
||||
|
||||
## Commands
|
||||
|
||||
```bash
|
||||
npm install # install dependencies (yahoo-finance2, dotenv, prettier)
|
||||
npm start # Yahoo news → catalyst tickers → screener-report.html
|
||||
npm start -- watch # default watchlist
|
||||
npm start -- AAPL MSFT VOO # specific tickers
|
||||
npm run finance # portfolio advice + SimpleFIN → finance-report.html
|
||||
npm run import-portfolio -- holdings.csv # import Robinhood/Vanguard CSV into portfolio.json
|
||||
npm test # run all unit tests (node:test, zero deps)
|
||||
npm run test:watch # watch mode during development
|
||||
npm run format # format all src/bin/tests with Prettier
|
||||
npm run format:check # check formatting without writing (CI)
|
||||
```
|
||||
|
||||
## Project Structure
|
||||
|
||||
```
|
||||
bin/
|
||||
screen.js ← market screener entry point
|
||||
finance.js ← personal finance entry point
|
||||
import-portfolio.js ← broker CSV importer
|
||||
|
||||
prompts/
|
||||
catalyst-analysis.md ← daily catalyst analysis playbook (LLM prompt + workflow)
|
||||
|
||||
src/
|
||||
config/
|
||||
ScoringConfig.js ← CREDIT_RATING_SCALE + ScoringRules (single source of truth)
|
||||
|
||||
market/ ← Yahoo Finance data layer
|
||||
YahooClient.js ← wraps yahoo-finance2 v3, retry + backoff
|
||||
BenchmarkProvider.js ← fetches ^GSPC, ^TNX, ^VIX, SPY, XLK, XLRE, LQD → marketContext
|
||||
MarketRegime.js ← derives INFLATED gate overrides from live benchmarks
|
||||
|
||||
screener/ ← core screening domain
|
||||
ScreenerEngine.js ← orchestrates: fetch → score × 2 → HTML report
|
||||
DataMapper.js ← normalises Yahoo payload → flat asset data object
|
||||
RuleMerger.js ← merges base rules + sector overrides + MarketRegime (INFLATED mode)
|
||||
Chunker.js ← splits ticker list into batches
|
||||
assets/
|
||||
Asset.js ← abstract base: ticker, currentPrice, type, formatting helpers
|
||||
Stock.js ← metrics: peRatio, pegRatio, priceToBook, ROE, opMargin, etc.
|
||||
Etf.js ← metrics: expenseRatio, yield, totalAssets
|
||||
Bond.js ← metrics: ytm, duration, creditRating, creditRatingNumeric
|
||||
scorers/
|
||||
StockScorer.js ← gate checks + weighted registry (ROE, opMargin, margin, peg, rev, fcf)
|
||||
EtfScorer.js ← expense gate + registry (cost, yield, volume)
|
||||
BondScorer.js ← credit gate + spread/duration scoring
|
||||
|
||||
analyst/
|
||||
CatalystAnalyst.js ← fetches Yahoo Finance news, extracts relatedTickers
|
||||
|
||||
finance/
|
||||
clients/
|
||||
SimpleFINClient.js ← claims setup token → access URL, fetches /accounts
|
||||
PersonalFinanceAnalyzer.js ← net worth, cash vs investments, spending by category
|
||||
PortfolioAdvisor.js ← cross-references holdings with screener signals → hold/sell/add advice
|
||||
PortfolioImporter.js ← parses Robinhood/Vanguard/Fidelity CSV → merges into portfolio.json
|
||||
|
||||
reporters/
|
||||
HtmlReporter.js ← generates screener-report.html (tabbed inflated/fundamental views)
|
||||
FinanceReporter.js ← generates finance-report.html (net worth, portfolio, spending)
|
||||
|
||||
portfolio.json ← user's holdings: ticker, shares, costBasis, source, type
|
||||
```
|
||||
|
||||
## Data Flow
|
||||
|
||||
```
|
||||
Yahoo Finance API
|
||||
↓
|
||||
BenchmarkProvider — fetches ^GSPC, ^TNX, ^VIX, SPY, XLK, XLRE, LQD
|
||||
builds marketContext { sp500Price, riskFreeRate, vixLevel,
|
||||
rateRegime, volatilityRegime, benchmarks { marketPE, techPE, reitYield, igSpread } }
|
||||
↓
|
||||
DataMapper — normalises raw Yahoo payload → flat data object with type (STOCK/ETF/BOND)
|
||||
computes: pFFO proxy, FCF yield, computed PEG, 52-week position
|
||||
↓
|
||||
Asset subclass — Stock / Etf / Bond holds metrics, formats display
|
||||
↓
|
||||
RuleMerger × 2 — FUNDAMENTAL mode: ScoringConfig as-is (Graham-style)
|
||||
INFLATED mode: sector override + MarketRegime live gate overrides
|
||||
↓
|
||||
Scorer × 2 — StockScorer / EtfScorer / BondScorer, fully stateless
|
||||
↓
|
||||
ScreenerEngine — derives Signal from comparing both verdicts
|
||||
↓
|
||||
HtmlReporter — screener-report.html: signal summary + two tabbed tables per asset class
|
||||
```
|
||||
|
||||
## Scoring Modes
|
||||
|
||||
| Mode | P/E Gate (general) | Source |
|
||||
|---|---|---|
|
||||
| FUNDAMENTAL | 20x | ScoringConfig (Graham) |
|
||||
| INFLATED | S&P 500 P/E × 1.5 | Live SPY data |
|
||||
|
||||
| Signal | Meaning |
|
||||
|---|---|
|
||||
| ✅ Strong Buy | Passes both fundamental AND inflated gates |
|
||||
| ⚡ Momentum | Passes inflated, holds fundamentally |
|
||||
| ⚠️ Speculation | Passes inflated, fails fundamental |
|
||||
| 🔄 Neutral | Hold territory in one or both lenses |
|
||||
| ❌ Avoid | Fails both |
|
||||
|
||||
## ScoringConfig Structure
|
||||
|
||||
`src/config/ScoringConfig.js` exports two things:
|
||||
|
||||
- `CREDIT_RATING_SCALE` — `{ AAA: 10, AA: 9, ..., D: 1 }`. Used by Bond.js and BondScorer.
|
||||
- `ScoringRules` — per-type `{ gates, weights, thresholds }` + `SECTOR_OVERRIDE` map for STOCK.
|
||||
|
||||
Sector overrides are structural (apply in both modes). MarketRegime overrides valuation gates in INFLATED mode only.
|
||||
|
||||
## Sector Notes
|
||||
|
||||
- **TECHNOLOGY** — `maxDebtToEquity: 2.0` (mega-cap tech borrows for buybacks), `minQuickRatio: 0.8` (AAPL runs ~0.9). P/E and PEG gates inflated from XLK live data.
|
||||
- **REIT** — P/E and PEG disabled (9999). Scored on dividend yield and P/FFO proxy. All base weights (margin, peg, revenue) explicitly zeroed out.
|
||||
- **FINANCIAL** — P/E, PEG, D/E disabled. Scored on ROE + Price-to-Book. Quick ratio gate lowered (0.1).
|
||||
|
||||
## MarketRegime (INFLATED overrides)
|
||||
|
||||
`src/market/MarketRegime.js` derives gate overrides from live benchmarks:
|
||||
|
||||
| Gate | Formula |
|
||||
|---|---|
|
||||
| Stock maxPERatio | SPY trailing P/E × 1.5 |
|
||||
| Tech maxPERatio | XLK P/E × 1.3 |
|
||||
| Tech maxPegGate | XLK P/E ÷ 15 |
|
||||
| REIT minYield | XLRE dividend yield × 0.85 |
|
||||
| Bond minSpread | LQD−TNX spread × 0.80 |
|
||||
| ETF maxExpenseRatio | 0.75% (structural loosening) |
|
||||
|
||||
## Missing Data Convention
|
||||
|
||||
- Missing metrics use `null` (not `0`) in `_sanitize`. Gate checks skip `null` values rather than auto-failing.
|
||||
- `pegRatio` falls back to `trailingPE / earningsGrowth` when Yahoo doesn't provide it.
|
||||
- `quickRatio` falls back to `currentRatio` when missing.
|
||||
|
||||
## SimpleFIN Auth Flow
|
||||
|
||||
1. User gets a Setup Token from https://beta-bridge.simplefin.org
|
||||
2. `SimpleFINClient.init()` base64-decodes it → POSTs once to claim URL → receives Access URL
|
||||
3. Access URL is auto-appended to `.env` as `SIMPLEFIN_ACCESS_URL`
|
||||
4. All subsequent requests use Access URL directly (setup token is one-time use)
|
||||
|
||||
## portfolio.json Format
|
||||
|
||||
```json
|
||||
{
|
||||
"holdings": [
|
||||
{ "ticker": "AAPL", "shares": 10, "costBasis": 150.00, "source": "Robinhood", "type": "stock" },
|
||||
{ "ticker": "VOO", "shares": 8, "costBasis": 380.00, "source": "Vanguard", "type": "etf" },
|
||||
{ "ticker": "BTC-USD", "shares": 0.25, "costBasis": 45000, "source": "Coinbase", "type": "crypto" }
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
`type` values: `stock`, `etf`, `crypto`. Crypto is priced via Yahoo (BTC-USD style) but not fundamentally scored.
|
||||
|
||||
## Tests
|
||||
|
||||
Uses Node's built-in test runner (`node:test` + `node:assert/strict`) — no test framework to install.
|
||||
|
||||
```
|
||||
tests/
|
||||
ScoringConfig.test.js ← CREDIT_RATING_SCALE, gate values, sector overrides
|
||||
RuleMerger.test.js ← FUNDAMENTAL vs INFLATED modes, sector merging
|
||||
MarketRegime.test.js ← inflated override formulas per asset type and sector
|
||||
StockScorer.test.js ← gate failures, scoring labels, risk flags
|
||||
EtfScorer.test.js ← expense gate, score tiers
|
||||
BondScorer.test.js ← credit gate, spread/duration scoring, unit handling
|
||||
DataMapper.test.js ← type detection, PEG computation, null convention
|
||||
PortfolioAdvisor.test.js ← _position gain/loss calc, _advice signal mapping
|
||||
```
|
||||
|
||||
**Key unit to remember:** `ytm` in `Bond.metrics` is stored as a percentage (e.g. `6.5` = 6.5%). `BondScorer._sanitize` divides by 100 before spread calculation. `minSpread` in `ScoringConfig` is also in percentage form (e.g. `1.0` = 1%).
|
||||
|
||||
## Conventions
|
||||
|
||||
- Asset `type` (uppercased) is the routing key across DataMapper, asset classes, `SCORERS` map in ScreenerEngine, and ScoringRules.
|
||||
- Prefer adjusting `ScoringConfig` or `MarketRegime` over hardcoding numbers in scorers.
|
||||
- BenchmarkProvider caches for 1 hour — restart the process to force a fresh fetch during development.
|
||||
- All entry points live in `bin/`. Do not add logic to entry points — they call into `src/`.
|
||||
|
||||
## Planned Enhancements
|
||||
|
||||
### 1. Rate Sensitivity Flag (no new fetches needed)
|
||||
Flag assets exposed to rate changes using `riskFreeRate` and `rateRegime`:
|
||||
- Long-duration bonds (duration > 7) in HIGH rate regime → warn
|
||||
- REITs with high D/E in HIGH rate regime → warn
|
||||
- Growth stocks with negative FCF in HIGH rate regime → warn
|
||||
|
||||
### 2. 52-Week Positioning (data already mapped)
|
||||
`week52High` and `week52Low` are in asset metrics. Add interpretation:
|
||||
- `> 85%` position + high P/E = crowded/momentum trade
|
||||
- `< 15%` position + passing fundamental gates = potential opportunity
|
||||
|
||||
### 3. Sector Rotation Cues (3–4 new fetches)
|
||||
Add XLF, XLE, XLV, XLI to BenchmarkProvider. Compare each sector ETF's 1-year return to S&P 500 to identify leading/lagging sectors.
|
||||
|
||||
### 4. Client/Server Architecture
|
||||
Planned split into:
|
||||
- **Server** (Fastify + BullMQ) — API endpoints, webhook-driven news monitoring, WebSocket for live updates
|
||||
- **Client** (SvelteKit) — interactive dashboard replacing the HTML reports
|
||||
- LLM integration (Claude Haiku) wired into the server for deeper catalyst analysis
|
||||
|
||||
## Adding a New Asset Type
|
||||
|
||||
1. Create a subclass of `Asset` in `src/screener/assets/` with a flat `metrics` object and `getDisplayMetrics()`.
|
||||
2. Add a per-type entry (`gates` / `weights` / `thresholds`) to `ScoringRules` in `ScoringConfig.js`.
|
||||
3. Add inflated overrides in `MarketRegime.getInflatedOverrides()`.
|
||||
4. Create a Scorer in `src/screener/scorers/` exposing `score(metrics, rules, marketContext)`.
|
||||
5. Add a mapper in `DataMapper.js`.
|
||||
6. Wire into `ScreenerEngine`: add `case` in `_buildAsset`, entry in `SCORERS` map.
|
||||
@@ -1,46 +0,0 @@
|
||||
# Market News Analysis & Catalyst Screener
|
||||
|
||||
## 1. High-Alpha Catalyst Analysis Prompt
|
||||
|
||||
Copy and paste this into your LLM daily to filter noise into actionable data:
|
||||
|
||||
> **Role:** You are a Quant-driven Financial Analyst specialized in Catalyst-Driven Trading.
|
||||
>
|
||||
> **Task:** Analyze today’s top 3 high-impact news stories and map them to the specific assets that are structurally forced to respond.
|
||||
>
|
||||
> **Instructions:**
|
||||
>
|
||||
> 1. **Identify the Catalyst:** Select one Macro event, one Sector-wide (regulatory/supply-chain) shift, and one Company-specific surprise.
|
||||
> 2. **Correlation Logic:** For each catalyst, identify:
|
||||
> - **Primary Target:** The ticker directly mentioned.
|
||||
> - **Ripple-Effect Target:** A ticker in the supply chain or direct competitor (The "Alpha" play).
|
||||
> 3. **Quantitative Impact Matrix:** Produce a table with: `Catalyst` | `Tickers (Primary/Ripple)` | `Bias` | `Sensitivity (1-5)` | `Mechanics`.
|
||||
> 4. **Constraint:** Exclude "Market Sentiment" or generic analyst upgrades. Only include events with a measurable impact on valuation or supply chain fundamentals.
|
||||
> 5. **Liquidity Filter:** Do not suggest tickers with daily volume below 500k.
|
||||
|
||||
---
|
||||
|
||||
## 2. Quantitative Impact Matrix (Template)
|
||||
|
||||
Use this table to log the results from the prompt above:
|
||||
|
||||
| Catalyst | Tickers (Primary / Ripple) | Bias | Sensitivity (1-5) | Mechanics |
|
||||
| :----------- | :------------------------- | :-------- | :---------------- | :------------------------ |
|
||||
| [Event Name] | [Ticker1] / [Ticker2] | Bull/Bear | [1-5] | [Concise financial logic] |
|
||||
|
||||
---
|
||||
|
||||
## 3. Implementation Workflow
|
||||
|
||||
1. **Fetch:** Run the prompt above using live news sources (e.g., Bloomberg, Nasdaq, Briefing.com).
|
||||
2. **Screen:** Plug the resulting tickers into your `ScreenerEngine.js`.
|
||||
3. **Validate:** Use your "Verdict Justification" table to verify if the fundamentals (PEG, Margins, Debt) support the AI's suggested bias.
|
||||
4. **Execute:** Monitor the "Ripple-Effect" targets, as they often capture volatility before the broader market catches on.
|
||||
|
||||
---
|
||||
|
||||
## 4. June 2026 Focus Areas
|
||||
|
||||
- **Macro:** Watch the ISM Manufacturing PMI (June 1) and Nonfarm Payrolls (June 5).
|
||||
- **Geopolitical:** Monitor US-Iran negotiations regarding the Strait of Hormuz (impacts Oil/Energy supply chains).
|
||||
- **Sectoral:** Continued AI momentum—look for infrastructure and cybersecurity earnings/guidance.
|
||||
@@ -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.
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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
@@ -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));
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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);
|
||||
Generated
+663
-4
@@ -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
@@ -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"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,165 @@
|
||||
# Market News Analysis & Catalyst Screener
|
||||
|
||||
A structured workflow for converting daily news into actionable trade ideas, validated by the screener's fundamental and market-adjusted analysis.
|
||||
|
||||
---
|
||||
|
||||
## 1. How This Fits Into the Screener Workflow
|
||||
|
||||
```
|
||||
Daily News
|
||||
↓
|
||||
Catalyst Prompt (Section 2) → Generates tickers + bias + horizon
|
||||
↓
|
||||
market_screener (npm start) → Fundamental + Market-Adjusted scoring
|
||||
↓
|
||||
Validation (Section 4) → Is the fundamental thesis intact?
|
||||
↓
|
||||
Decision → Act / Monitor / Discard
|
||||
```
|
||||
|
||||
**Key principle:** The screener doesn't tell you *when* to trade — catalysts do that. The screener tells you whether the *underlying business* supports the trade or whether you're purely momentum-chasing.
|
||||
|
||||
---
|
||||
|
||||
## 2. Catalyst Analysis Prompt
|
||||
|
||||
Copy and paste this into your LLM daily. Provide it with 3–5 news headlines.
|
||||
|
||||
> **Role:** You are a quantitative financial analyst specialising in catalyst-driven trading.
|
||||
>
|
||||
> **Task:** Analyse the provided news and map each story to the assets structurally forced to respond.
|
||||
>
|
||||
> **For each catalyst, identify:**
|
||||
> 1. **Type:** Macro (Fed, rates, GDP) | Sector (regulatory, supply chain, commodity) | Company (earnings, guidance, M&A)
|
||||
> 2. **Primary ticker:** The asset directly impacted.
|
||||
> 3. **Ripple-effect ticker:** A supply chain partner, direct competitor, or sector peer that moves *before* the market catches on. This is the alpha play.
|
||||
> 4. **Bias:** Bull or Bear — with a one-sentence mechanistic reason (not sentiment).
|
||||
> 5. **Horizon:** Short (1–5 days) | Medium (1–4 weeks) | Long (1+ quarter).
|
||||
> 6. **Sensitivity:** How exposed is this ticker to the catalyst?
|
||||
> - **5** — Direct revenue impact > 20% of annual sales
|
||||
> - **4** — Direct revenue impact 10–20%
|
||||
> - **3** — Indirect exposure via cost structure or supply chain
|
||||
> - **2** — Sector correlation, limited direct exposure
|
||||
> - **1** — Macro tailwind/headwind only
|
||||
>
|
||||
> **Constraints:**
|
||||
> - Exclude generic analyst upgrades and "market sentiment" stories.
|
||||
> - Only include events with a measurable impact on valuation or supply chain fundamentals.
|
||||
> - Do not suggest tickers with average daily volume below 500k.
|
||||
> - For Bear plays: require at least one of — elevated short interest (>5% of float), negative earnings revision trend, or sector rotation evidence.
|
||||
|
||||
---
|
||||
|
||||
## 3. Quantitative Impact Matrix
|
||||
|
||||
Output from the prompt above. Log results here before running the screener.
|
||||
|
||||
| Catalyst | Type | Primary | Ripple | Bias | Sensitivity | Horizon | Mechanics |
|
||||
| :--- | :--- | :--- | :--- | :--- | :--- | :--- | :--- |
|
||||
| [Event] | Macro/Sector/Co. | [TICKER] | [TICKER] | Bull/Bear | 1–5 | Short/Med/Long | [One-line financial logic] |
|
||||
|
||||
---
|
||||
|
||||
## 4. Ripple-Effect Reference Map
|
||||
|
||||
When a catalyst hits a primary ticker, these are the typical second-order targets by category.
|
||||
|
||||
| Primary Event | Ripple Targets | Logic |
|
||||
| :--- | :--- | :--- |
|
||||
| **Semis beat** (NVDA, AMD) | TSMC, ASML, AMAT, KLAC | Fab capacity demand follows chip demand |
|
||||
| **Semis miss** | INTC, MU, WDC | Inventory builds at competitors |
|
||||
| **Cloud CapEx guidance up** (MSFT, GOOGL, AMZN) | EQIX, DLR (data center REITs), NFLX infra | Power + cooling demand, bandwidth |
|
||||
| **Oil supply shock** | XOM, CVX (Bull); DAL, UAL (Bear) | Energy input costs hit airlines directly |
|
||||
| **Fed rate hike** | TLT, IEF (Bear); XLF, BRK (Bull) | Long-duration bonds reprice; bank margins expand |
|
||||
| **Fed rate cut** | TLT, XLRE (Bull); XLF (Bear) | REITs re-rate; bank NIM compresses |
|
||||
| **Strong USD** | EEM, multinational exporters (Bear) | Revenue headwind for USD-earners abroad |
|
||||
| **Retail sales miss** | WMT, TGT (Bear); AMZN (neutral/Bull) | Discretionary demand shift to e-commerce |
|
||||
| **Pharma approval** | Competitor biotech (Bear) | Market share displacement |
|
||||
| **Cybersecurity breach (major)** | CRWD, PANW, FTNT (Bull) | Accelerates enterprise security spend |
|
||||
|
||||
---
|
||||
|
||||
## 5. Validation Checklist
|
||||
|
||||
Before acting on a catalyst, run the tickers through the screener and answer:
|
||||
|
||||
### For Bull plays:
|
||||
- [ ] Does it pass the **Market-Adjusted** analysis? (minimum bar — if not, it's pure momentum)
|
||||
- [ ] Does it pass **Fundamental** analysis? (if yes → Strong Buy conviction; if not → Speculation)
|
||||
- [ ] Is FCF yield positive? (sustains the business through the catalyst period)
|
||||
- [ ] Is D/E manageable? (high leverage + catalyst = binary outcome, size accordingly)
|
||||
- [ ] Is the 52-week position below 85%? (if near highs, the market may have priced it in)
|
||||
|
||||
### For Bear plays:
|
||||
- [ ] Does it **fail both** analyses? (confirms the fundamental short thesis)
|
||||
- [ ] Is short interest > 5% of float? (existing agreement in the market)
|
||||
- [ ] Is the horizon realistic? (overvalued stocks can stay overvalued — Bear plays need a catalyst *timeframe*)
|
||||
|
||||
### Horizon vs screener relevance:
|
||||
| Horizon | Use screener for... |
|
||||
| :--- | :--- |
|
||||
| Short (1–5 days) | Confirm the stock isn't already broken (avoid catching falling knives on longs) |
|
||||
| Medium (1–4 weeks) | Gate check — does fundamental quality support a re-rating? |
|
||||
| Long (1+ quarter) | Full weight on both analyses — you need the fundamentals on your side |
|
||||
|
||||
---
|
||||
|
||||
## 6. Current Market Regime Context
|
||||
|
||||
> **This section should be refreshed from `npm start` output before each session.**
|
||||
|
||||
The screener derives the current regime from live Yahoo Finance data on startup:
|
||||
|
||||
| Signal | What it means for catalysts |
|
||||
| :--- | :--- |
|
||||
| **Rate Regime: HIGH** (10Y > 5%) | Long-duration trades are punished. Favour cash-generative, short-horizon plays. Short TLT, long XLF. |
|
||||
| **Rate Regime: NORMAL** (2–5%) | Standard playbook applies. |
|
||||
| **Rate Regime: LOW** (< 2%) | Growth and duration trades work. REITs and long bonds are viable longs. |
|
||||
| **Volatility: HIGH** (VIX > 25) | Position sizes down. Mean-reversion trades outperform momentum. |
|
||||
| **Volatility: NORMAL** (VIX 15–25) | Trend-following works. |
|
||||
| **Volatility: LOW** (VIX < 15) | Risk-on. Momentum and growth outperform. Watch for complacency reversals. |
|
||||
|
||||
---
|
||||
|
||||
## 7. Bear Catalyst Template
|
||||
|
||||
A structured short thesis requires more rigour than a bull thesis. Use this template.
|
||||
|
||||
> **Ticker:** [TICKER]
|
||||
>
|
||||
> **Catalyst:** [What event breaks the bull narrative?]
|
||||
>
|
||||
> **Fundamental support:**
|
||||
> - Fails screener gate: [which gate, e.g. "P/E 120x > inflated gate of 57x"]
|
||||
> - Trend: [revenue decelerating / margins compressing / FCF turning negative]
|
||||
>
|
||||
> **Market structure support (need at least one):**
|
||||
> - Short interest: [X% of float]
|
||||
> - Earnings revision trend: [# of downward revisions last 90 days]
|
||||
> - Sector rotation: [which sector ETF is seeing outflows]
|
||||
>
|
||||
> **Risk to thesis:** [What would invalidate the short — e.g. "earnings beat with raised guidance"]
|
||||
>
|
||||
> **Horizon:** [Short / Medium / Long]
|
||||
> **Stop:** [Price level or event that closes the trade]
|
||||
|
||||
---
|
||||
|
||||
## 8. Adding Catalyst Tickers to the Screener
|
||||
|
||||
Edit `index.js` and add tickers from the Impact Matrix to the `tickers` array, then run:
|
||||
|
||||
```bash
|
||||
npm start
|
||||
```
|
||||
|
||||
The screener will score each ticker under both the **Market-Adjusted** and **Fundamental** lenses and open `screener-report.html` with the full breakdown. Cross-reference the Signal column with your catalyst thesis:
|
||||
|
||||
| Signal | Catalyst interpretation |
|
||||
| :--- | :--- |
|
||||
| ✅ Strong Buy | Fundamental quality + catalyst momentum aligned. Highest conviction. |
|
||||
| ⚡ Momentum | Catalyst works in today's market but price is stretched on fundamentals. Respect the stop. |
|
||||
| ⚠️ Speculation | Pure catalyst play — fundamentals don't support it. Small size, tight stop. |
|
||||
| 🔄 Neutral | Catalyst may be already priced in. Wait for a better entry or skip. |
|
||||
| ❌ Avoid | Screener and catalyst are both negative. Only valid as a Bear trade. |
|
||||
@@ -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`;
|
||||
}
|
||||
@@ -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];
|
||||
}
|
||||
}
|
||||
@@ -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
@@ -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 },
|
||||
},
|
||||
};
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
@@ -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),
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
},
|
||||
};
|
||||
@@ -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);
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -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';
|
||||
},
|
||||
};
|
||||
@@ -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';
|
||||
},
|
||||
};
|
||||
@@ -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';
|
||||
},
|
||||
};
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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`);
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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) } };
|
||||
}
|
||||
}
|
||||
@@ -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 & 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';
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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),
|
||||
);
|
||||
@@ -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,
|
||||
});
|
||||
@@ -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;
|
||||
},
|
||||
};
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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)}%`,
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
};
|
||||
},
|
||||
};
|
||||
@@ -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 },
|
||||
};
|
||||
},
|
||||
};
|
||||
@@ -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,
|
||||
};
|
||||
},
|
||||
};
|
||||
@@ -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;
|
||||
};
|
||||
@@ -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,
|
||||
});
|
||||
@@ -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;
|
||||
},
|
||||
};
|
||||
@@ -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);
|
||||
});
|
||||
@@ -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);
|
||||
});
|
||||
@@ -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);
|
||||
});
|
||||
@@ -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
|
||||
});
|
||||
@@ -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');
|
||||
});
|
||||
@@ -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/);
|
||||
});
|
||||
@@ -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);
|
||||
});
|
||||
@@ -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')));
|
||||
});
|
||||
Reference in New Issue
Block a user