phase-8g: rate limiting and update readme doc
This commit is contained in:
@@ -163,7 +163,7 @@ ui/ ← SvelteKit dashboard (lives inside this repo, not a
|
|||||||
|
|
||||||
market-calls.json ← persisted market thesis calls (written by MarketCallRepository)
|
market-calls.json ← persisted market thesis calls (written by MarketCallRepository)
|
||||||
portfolio.json ← user's holdings: ticker, shares, costBasis, source, type
|
portfolio.json ← user's holdings: ticker, shares, costBasis, source, type
|
||||||
.env ← SIMPLEFIN_ACCESS_URL or SIMPLEFIN_SETUP_TOKEN, ANTHROPIC_API_KEY
|
.env ← SIMPLEFIN_ACCESS_URL or SIMPLEFIN_SETUP_TOKEN, ANTHROPIC_API_KEY, API_KEY (optional — enables Bearer auth on all routes)
|
||||||
```
|
```
|
||||||
|
|
||||||
---
|
---
|
||||||
@@ -626,9 +626,9 @@ Add one Fastify `inject()` smoke test per route using a fixture for `ScreenerEng
|
|||||||
|
|
||||||
`BenchmarkProvider`'s 1-hour cache is in-memory only — cold start after every restart adds 2–4s latency to the first request. Write the cached `MarketContext` to `.benchmark-cache.json` (or a single-row SQLite table). Read it on boot; only re-fetch if stale.
|
`BenchmarkProvider`'s 1-hour cache is in-memory only — cold start after every restart adds 2–4s latency to the first request. Write the cached `MarketContext` to `.benchmark-cache.json` (or a single-row SQLite table). Read it on boot; only re-fetch if stale.
|
||||||
|
|
||||||
#### 8g — Rate limiting + API key auth
|
#### 8g — Rate limiting + API key auth ✅
|
||||||
|
|
||||||
Add `@fastify/rate-limit` on `/api/screen` and `/api/analyze` (e.g. 10 req/min per IP). Add a simple `Authorization: Bearer <key>` check against an `API_KEY` env var as middleware in `server/app.ts`. Both are single-digit line additions.
|
`@fastify/rate-limit` registered globally in `server/app.ts` (`global: false`, opt-in per route). `/api/screen`, `/api/screen/catalysts`, and `/api/analyze` each carry `config: { rateLimit: { max: 10, timeWindow: '1 minute' } }`. API key enforced via `onRequest` hook when `API_KEY` env var is set (`Authorization: Bearer <key>`); `/health` and OPTIONS are exempt. **Requires `npm install` after adding `@fastify/rate-limit` to dependencies (done in package.json).**
|
||||||
|
|
||||||
#### 8h — Extract `CalendarService`
|
#### 8h — Extract `CalendarService`
|
||||||
|
|
||||||
|
|||||||
@@ -1,143 +1,141 @@
|
|||||||
# Market Screener
|
# Market Screener
|
||||||
|
|
||||||
A Node.js stock screener and personal finance assistant. Screens stocks, ETFs, and bonds using live Yahoo Finance data and scores each asset under two lenses — **Market-Adjusted** (what's acceptable in today's market) and **Fundamental** (strict Graham value-investing) — then compares both to give you an honest signal.
|
A personal stock screener and portfolio tracker. Scores stocks, ETFs, and bonds under two lenses — **Market-Adjusted** (what's acceptable in today's market) and **Fundamental** (strict Graham value-investing) — then compares them to produce an actionable signal. Comes with a live SvelteKit dashboard.
|
||||||
|
|
||||||
Comes with a **Fastify API server** and a companion **SvelteKit dashboard** (`../market-screener-ui`) for an interactive UI, or use it as a CLI to generate HTML reports.
|
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## Quick Start
|
## Table of Contents
|
||||||
|
|
||||||
|
- [Developer Setup](#developer-setup)
|
||||||
|
- [Environment Variables](#environment-variables)
|
||||||
|
- [Commands](#commands)
|
||||||
|
- [Running Tests](#running-tests)
|
||||||
|
- [Project Structure](#project-structure)
|
||||||
|
- [User Guide](#user-guide)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Developer Setup
|
||||||
|
|
||||||
|
### Prerequisites
|
||||||
|
|
||||||
|
- Node.js 20+
|
||||||
|
- npm 10+
|
||||||
|
|
||||||
|
### Install
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
# API + Dashboard (recommended)
|
# Install server dependencies
|
||||||
npm install
|
npm install
|
||||||
cd ../market-screener-ui && npm install && cd ../market_screener
|
|
||||||
npm run dev # starts API on :3000 + UI on :5173
|
|
||||||
# open http://localhost:5173
|
|
||||||
|
|
||||||
# CLI only
|
# Install UI dependencies (first time only)
|
||||||
npm start # screen today's news catalyst tickers → screener-report.html
|
npm run ui:install
|
||||||
|
```
|
||||||
|
|
||||||
|
### Start
|
||||||
|
|
||||||
|
```bash
|
||||||
|
npm run dev
|
||||||
|
```
|
||||||
|
|
||||||
|
This starts both the API server on **port 3000** and the SvelteKit UI on **port 5173** concurrently. Open [http://localhost:5173](http://localhost:5173).
|
||||||
|
|
||||||
|
To run the API server alone:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
npm run server
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Environment Variables
|
||||||
|
|
||||||
|
Create a `.env` file in the project root. None are required to run the app — it works with Yahoo Finance data out of the box. Optional keys unlock additional features.
|
||||||
|
|
||||||
|
### `ANTHROPIC_API_KEY` — LLM news analysis *(optional)*
|
||||||
|
|
||||||
|
Powers the **Analyze** button on each screener section. Without this key the button is disabled.
|
||||||
|
|
||||||
|
1. Go to [console.anthropic.com](https://console.anthropic.com)
|
||||||
|
2. Create an API key under **API Keys**
|
||||||
|
3. Add to `.env`:
|
||||||
|
|
||||||
|
```env
|
||||||
|
ANTHROPIC_API_KEY=sk-ant-...
|
||||||
|
```
|
||||||
|
|
||||||
|
### `SIMPLEFIN_SETUP_TOKEN` — Live bank/brokerage balances *(optional)*
|
||||||
|
|
||||||
|
Powers the personal finance section of the Portfolio page (net worth, account balances, spending breakdown).
|
||||||
|
|
||||||
|
1. Go to [beta-bridge.simplefin.org](https://beta-bridge.simplefin.org) and create a Setup Token
|
||||||
|
2. Add to `.env`:
|
||||||
|
|
||||||
|
```env
|
||||||
|
SIMPLEFIN_SETUP_TOKEN=aHR0cHM6Ly...
|
||||||
|
```
|
||||||
|
|
||||||
|
On first request the token is claimed automatically and the resulting Access URL is saved back to `.env` as `SIMPLEFIN_ACCESS_URL`. Subsequent restarts use the Access URL directly.
|
||||||
|
|
||||||
|
### `API_KEY` — Bearer token auth *(optional)*
|
||||||
|
|
||||||
|
When set, every API route requires `Authorization: Bearer <key>`. Useful when the server is exposed to a network. `/health` and OPTIONS preflight are exempt.
|
||||||
|
|
||||||
|
```env
|
||||||
|
API_KEY=your-secret-key
|
||||||
|
```
|
||||||
|
|
||||||
|
### `CLIENT_ORIGIN` — CORS allowed origin *(optional)*
|
||||||
|
|
||||||
|
Defaults to `http://localhost:5173`. Change if the UI is served from a different origin.
|
||||||
|
|
||||||
|
```env
|
||||||
|
CLIENT_ORIGIN=https://yourdomain.com
|
||||||
|
```
|
||||||
|
|
||||||
|
### Complete `.env` example
|
||||||
|
|
||||||
|
```env
|
||||||
|
ANTHROPIC_API_KEY=sk-ant-...
|
||||||
|
SIMPLEFIN_SETUP_TOKEN=aHR0cHM6Ly...
|
||||||
|
API_KEY=optional-secret
|
||||||
|
CLIENT_ORIGIN=http://localhost:5173
|
||||||
```
|
```
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## Commands
|
## Commands
|
||||||
|
|
||||||
| Command | What it does |
|
| Command | Description |
|
||||||
|---|---|
|
|---|---|
|
||||||
| `npm run dev` | Start API server (port 3000) + SvelteKit UI (port 5173) together |
|
| `npm run dev` | Start API (port 3000) + UI (port 5173) together |
|
||||||
| `npm run server` | Start API server only |
|
| `npm run server` | Start API server only |
|
||||||
| `npm start` | CLI: fetch today's market news, extract tickers, screen them |
|
| `npm run ui:install` | Install UI dependencies (first time / after `git pull`) |
|
||||||
| `npm start -- watch` | CLI: screen the default watchlist |
|
| `npm test` | Run all unit + integration tests |
|
||||||
| `npm start -- AAPL MSFT VOO` | CLI: screen specific tickers |
|
| `npm run test:watch` | Watch mode — re-run on file changes |
|
||||||
| `npm run finance` | CLI: portfolio advice + SimpleFIN → `finance-report.html` |
|
| `npm run typecheck` | TypeScript type check without emitting |
|
||||||
| `npm run import-portfolio -- file.csv` | Import Robinhood/Vanguard/Fidelity CSV into `portfolio.json` |
|
|
||||||
| `npm test` | Run all 61 unit tests |
|
|
||||||
| `npm run test:watch` | Re-run tests on file changes |
|
|
||||||
| `npm run format` | Format all source files with Prettier |
|
| `npm run format` | Format all source files with Prettier |
|
||||||
|
| `npm run format:check` | Check formatting without writing (used in CI) |
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## How the Screener Works
|
## Running Tests
|
||||||
|
|
||||||
Every asset is scored **twice** under different rule sets:
|
|
||||||
|
|
||||||
### Market-Adjusted mode
|
|
||||||
Gates derived from live Yahoo Finance benchmarks — reflects what is acceptable in today's market:
|
|
||||||
|
|
||||||
| Gate | Formula |
|
|
||||||
|---|---|
|
|
||||||
| Stock P/E | S&P 500 P/E (via SPY) × 1.5× (or × 1.2× in HIGH rate regime) |
|
|
||||||
| Tech P/E | XLK sector P/E × 1.3× |
|
|
||||||
| REIT min yield | XLRE dividend yield × 0.85× |
|
|
||||||
| Bond min spread | LQD − TNX spread × 0.80× |
|
|
||||||
|
|
||||||
### Fundamental mode
|
|
||||||
Strict Graham/value-investing gates — reflects genuine value regardless of market conditions:
|
|
||||||
|
|
||||||
| Gate | Value | Rationale |
|
|
||||||
|---|---|---|
|
|
||||||
| Stock P/E | < 15× | Graham's actual rule (trailing earnings) |
|
|
||||||
| Stock PEG | < 1.0 | Lynch standard: PEG > 1.0 = paying full price |
|
|
||||||
| D/E ratio | < 1.5× | Distress typically starts above 2× |
|
|
||||||
| Quick ratio | > 0.8 | Below 0.8 = real liquidity stress |
|
|
||||||
| Bond spread | > 1.5% | Must clear risk-free rate meaningfully |
|
|
||||||
| Bond duration | < 7 years | Rate sensitivity management |
|
|
||||||
|
|
||||||
### Signals
|
|
||||||
|
|
||||||
| Signal | Meaning |
|
|
||||||
|---|---|
|
|
||||||
| ✅ Strong Buy | Passes both lenses — 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 |
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Sector Overrides
|
|
||||||
|
|
||||||
Sector-specific rules apply in both modes (not just inflated):
|
|
||||||
|
|
||||||
| Sector | Key adjustments |
|
|
||||||
|---|---|
|
|
||||||
| **Technology** | P/E up to 35×, D/E up to 2.0 (buybacks), FCF weight raised |
|
|
||||||
| **REIT** | P/E/PEG disabled, scored on dividend yield + P/FFO proxy |
|
|
||||||
| **Financial** | D/E disabled, scored on ROE (≥12%) + P/B (< 1.5×) |
|
|
||||||
| **Energy** | FCF primary signal (weight 4), dividend yield scored |
|
|
||||||
| **Healthcare** | Revenue growth primary, P/E up to 25× (R&D burn) |
|
|
||||||
| **Communication** | FCF primary (META/GOOGL platforms), P/E up to 25× |
|
|
||||||
| **Consumer Staples** | Margin/ROE focus, low revenue growth expectations (2–5%) |
|
|
||||||
| **Consumer Discretionary** | Revenue growth primary, P/E up to 25× |
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## API Server
|
|
||||||
|
|
||||||
```
|
|
||||||
GET /health → { status: "ok" }
|
|
||||||
POST /api/screen → screen tickers
|
|
||||||
body: { tickers: string[] }
|
|
||||||
GET /api/screen/catalysts → Yahoo news → { tickers, stories }
|
|
||||||
GET /api/finance/portfolio → portfolio advice + net worth
|
|
||||||
GET /api/finance/market-context → live benchmark data
|
|
||||||
```
|
|
||||||
|
|
||||||
Set `CLIENT_ORIGIN` env var to allow a different CORS origin (default: `http://localhost:5173`).
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 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` (CLI) or `GET /api/finance/portfolio` (API) screens your holdings and cross-references the screener signal with your gain/loss position to give hold/sell/add advice.
|
|
||||||
|
|
||||||
### SimpleFIN (optional — live bank/brokerage balances)
|
|
||||||
|
|
||||||
1. Get your setup token from [beta-bridge.simplefin.org](https://beta-bridge.simplefin.org)
|
|
||||||
2. Add to `.env`: `SIMPLEFIN_SETUP_TOKEN=aHR0cHM6Ly...`
|
|
||||||
3. On first run the Access URL is claimed and saved to `.env` automatically
|
|
||||||
|
|
||||||
### Importing broker holdings
|
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
npm run import-portfolio -- ~/Downloads/robinhood_holdings.csv
|
npm test
|
||||||
npm run import-portfolio -- ~/Downloads/vanguard_holdings.csv
|
|
||||||
```
|
```
|
||||||
|
|
||||||
Broker is auto-detected from CSV headers. Multiple imports merge into `portfolio.json`.
|
Uses Node's built-in `node:test` runner — no external framework. Tests cover:
|
||||||
|
|
||||||
|
- Scoring rules and gate values (`ScoringConfig`, `RuleMerger`, `MarketRegime`)
|
||||||
|
- Asset scorers (`StockScorer`, `EtfScorer`, `BondScorer`)
|
||||||
|
- Data mapping (`DataMapper`)
|
||||||
|
- Portfolio advice logic (`PortfolioAdvisor`)
|
||||||
|
- LLM response parsing (`LLMAnalyst`)
|
||||||
|
- Repository CRUD (`MarketCallRepository`)
|
||||||
|
- Controller integration tests for all API routes (Fastify `inject()`, zero live network calls)
|
||||||
|
|
||||||
|
Pre-commit hook runs Prettier then tests. Pre-push hook runs tests.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -145,81 +143,154 @@ Broker is auto-detected from CSV headers. Multiple imports merge into `portfolio
|
|||||||
|
|
||||||
```
|
```
|
||||||
bin/
|
bin/
|
||||||
screen.js CLI screener entry point
|
server.ts API server entry point
|
||||||
finance.js CLI personal finance entry point
|
|
||||||
import-portfolio.js Broker CSV importer
|
|
||||||
server.js Fastify API server entry point
|
|
||||||
|
|
||||||
scripts/
|
server/
|
||||||
summary-reporter.js Custom node:test reporter (silent pass, shows failures + summary)
|
app.ts Fastify app factory — wires DI, rate limiting, auth hook
|
||||||
|
controllers/ HTTP layer: parse request → call service → return response
|
||||||
|
services/ Business logic (ScreenerEngine, BenchmarkProvider, PortfolioAdvisor…)
|
||||||
|
repositories/ JSON file persistence (MarketCallRepository, PortfolioRepository)
|
||||||
|
clients/ External API connectors (YahooFinanceClient, SimpleFINClient, AnthropicClient)
|
||||||
|
models/ Domain entities: Stock, Etf, Bond
|
||||||
|
scorers/ Stateless scoring functions: StockScorer, EtfScorer, BondScorer
|
||||||
|
config/ ScoringConfig (all gates/weights), constants
|
||||||
|
types/ TypeScript interfaces, one file per domain
|
||||||
|
|
||||||
src/
|
ui/
|
||||||
config/
|
src/
|
||||||
ScoringConfig.js All gates, weights, thresholds (single source of truth)
|
routes/ SvelteKit pages: /, /portfolio, /calls, /safe-buys
|
||||||
constants.js Shared enums: SIGNAL, ASSET_TYPE, SECTOR, SCORE_MODE, REGIME
|
lib/
|
||||||
|
stores/ Svelte 5 reactive stores (screener.store, portfolio.store)
|
||||||
|
api/ Fetch wrappers for each API domain
|
||||||
|
portfolio/ Portfolio-specific components
|
||||||
|
calls/ Market calls components
|
||||||
|
styles/ Global SCSS design tokens and partials
|
||||||
|
|
||||||
market/
|
tests/ Unit + integration tests
|
||||||
YahooClient.js Wraps yahoo-finance2 v3 with retry + backoff
|
|
||||||
BenchmarkProvider.js Fetches live benchmarks → marketContext (1-hour cache)
|
|
||||||
MarketRegime.js Derives inflated gate overrides from live data + rate regime
|
|
||||||
|
|
||||||
screener/
|
portfolio.json Your holdings (gitignored — create manually or via the UI)
|
||||||
ScreenerEngine.js Orchestrates fetch → score × 2.
|
market-calls.json Persisted market thesis calls (gitignored)
|
||||||
screenTickers() → pure data (server/CLI)
|
.benchmark-cache.json Benchmark data cache — survives server restart (gitignored)
|
||||||
screenWithProgress() → with stdout progress (CLI only)
|
|
||||||
DataMapper.js Normalises Yahoo payload → flat asset objects
|
|
||||||
Uses trailingPE (not forwardPE). Preserves negative FCF.
|
|
||||||
RuleMerger.js Merges base rules + sector overrides + MarketRegime
|
|
||||||
assets/ Stock, Etf, Bond data containers
|
|
||||||
scorers/ StockScorer, EtfScorer, BondScorer (stateless)
|
|
||||||
|
|
||||||
analyst/
|
|
||||||
CatalystAnalyst.js Extracts tickers from Yahoo Finance news headlines
|
|
||||||
|
|
||||||
finance/
|
|
||||||
clients/
|
|
||||||
SimpleFINClient.js Auth + account fetching via Basic Auth header
|
|
||||||
PersonalFinanceAnalyzer.js Net worth, cash vs investments, spending
|
|
||||||
PortfolioAdvisor.js Hold/sell/add advice per holding
|
|
||||||
PortfolioImporter.js Parses Robinhood/Vanguard/Fidelity CSV
|
|
||||||
|
|
||||||
reporters/
|
|
||||||
HtmlReporter.js render() → string | generate() → file (CLI)
|
|
||||||
FinanceReporter.js render() → string | generate() → file (CLI)
|
|
||||||
|
|
||||||
server/
|
|
||||||
app.js Fastify app factory (buildApp)
|
|
||||||
routes/
|
|
||||||
screener.js POST /api/screen, GET /api/screen/catalysts
|
|
||||||
finance.js GET /api/finance/portfolio, GET /api/finance/market-context
|
|
||||||
```
|
```
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## Environment Variables
|
## User Guide
|
||||||
|
|
||||||
```bash
|
### Screener tab
|
||||||
# .env
|
|
||||||
SIMPLEFIN_ACCESS_URL=https://user:pass@beta-bridge.simplefin.org/simplefin
|
|
||||||
# or on first run:
|
|
||||||
SIMPLEFIN_SETUP_TOKEN=aHR0cHM6Ly...
|
|
||||||
|
|
||||||
# Optional server config
|
The main view. On load it automatically fetches today's financial news, extracts the most-mentioned tickers, and screens them.
|
||||||
PORT=3000
|
|
||||||
HOST=0.0.0.0
|
#### Market context strip
|
||||||
CLIENT_ORIGIN=http://localhost:5173 # CORS allowed origin for SvelteKit UI
|
|
||||||
```
|
The row of chips at the top shows live benchmark data fetched from Yahoo Finance:
|
||||||
|
|
||||||
|
| Chip | Meaning |
|
||||||
|
|---|---|
|
||||||
|
| 10Y | 10-year Treasury yield — the risk-free rate |
|
||||||
|
| VIX | Volatility index — market fear gauge |
|
||||||
|
| S&P | S&P 500 index price |
|
||||||
|
| S&P P/E | Trailing P/E of the S&P 500 (via SPY) |
|
||||||
|
| Tech P/E | Trailing P/E of the tech sector (via XLK) |
|
||||||
|
| REIT Yld | REIT dividend yield (via XLRE) |
|
||||||
|
| IG Sprd | Investment-grade bond spread above risk-free (LQD − TNX) |
|
||||||
|
| Rates | Rate regime: LOW / NORMAL / HIGH (based on 10Y yield) |
|
||||||
|
| Vol | Volatility regime: LOW / NORMAL / HIGH (based on VIX) |
|
||||||
|
|
||||||
|
The rate regime affects how strict the Market-Adjusted gates are — in a HIGH rate environment the P/E multiplier compresses and bond spreads tighten.
|
||||||
|
|
||||||
|
#### Signal Summary table
|
||||||
|
|
||||||
|
Quick overview of all screened tickers with their signal, market-adjusted verdict, fundamental verdict, market cap tier, and growth style.
|
||||||
|
|
||||||
|
#### Per-asset detail tables
|
||||||
|
|
||||||
|
Expand each section (STOCK / ETF / BOND) for full metrics: P/E, PEG, ROE, margins, FCF yield, D/E, analyst consensus, DCF intrinsic value, 52-week movement, and more.
|
||||||
|
|
||||||
|
#### Analyze button
|
||||||
|
|
||||||
|
Runs an Anthropic LLM over the latest Yahoo Finance news for assets in that section. Returns a sentiment summary, affected industries, and related tickers to watch. Requires `ANTHROPIC_API_KEY`.
|
||||||
|
|
||||||
|
#### Search tickers
|
||||||
|
|
||||||
|
Click **Search tickers** to screen any custom list — type tickers comma or space separated and press Enter or click Screen.
|
||||||
|
|
||||||
|
#### Signals explained
|
||||||
|
|
||||||
|
| Signal | What it means |
|
||||||
|
|---|---|
|
||||||
|
| ✅ Strong Buy | Passes both Market-Adjusted AND Fundamental gates — genuine value at current prices |
|
||||||
|
| ⚡ Momentum | Passes Market-Adjusted, holds fundamentally — good in the current market but not a bargain |
|
||||||
|
| ⚠️ Speculation | Passes Market-Adjusted, fails Fundamental — priced for perfection, high risk |
|
||||||
|
| 🔄 Neutral | Borderline in one or both lenses — hold, no clear edge |
|
||||||
|
| ❌ Avoid | Fails both lenses |
|
||||||
|
|
||||||
|
#### How scoring works
|
||||||
|
|
||||||
|
Every asset is scored twice:
|
||||||
|
|
||||||
|
**Market-Adjusted** gates move with the market. The stock P/E gate = SPY trailing P/E × 1.5 (compresses to × 1.2 in a HIGH rate regime). Tech P/E = XLK P/E × 1.3. This reflects what the market is currently willing to pay.
|
||||||
|
|
||||||
|
**Fundamental** gates are fixed Graham/value-investing standards that never change:
|
||||||
|
|
||||||
|
| Gate | Threshold | Rationale |
|
||||||
|
|---|---|---|
|
||||||
|
| Stock P/E | < 15× | Graham's actual rule |
|
||||||
|
| Stock PEG | < 1.0 | Lynch: PEG > 1.0 = paying full price |
|
||||||
|
| D/E ratio | < 1.5× | Distress typically starts above 2× |
|
||||||
|
| Quick ratio | > 0.8 | Below 0.8 = real liquidity stress |
|
||||||
|
|
||||||
|
Sector overrides apply in both modes — e.g. tech stocks allow P/E up to 35× and D/E up to 2.0, REITs are scored on yield rather than P/E.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## Testing
|
### Portfolio tab
|
||||||
|
|
||||||
61 unit tests, no external test framework:
|
Track your holdings and get hold/sell/add advice cross-referenced with screener signals.
|
||||||
|
|
||||||
```bash
|
**Adding holdings** — click **+ Add Holding** and fill in ticker, shares, cost basis, asset type, and source broker. Holdings are saved to `portfolio.json` on disk.
|
||||||
npm test # summary output: "✅ 61 tests: 61 passed (0.02s)"
|
|
||||||
npm run test:watch # verbose spec output for development
|
|
||||||
```
|
|
||||||
|
|
||||||
Pre-commit: Prettier (auto-format staged files) + full test suite.
|
**Inline editing** — click the ✎ pencil icon on any row to edit shares, cost basis, type, or source directly in the table.
|
||||||
Pre-push: full test suite.
|
|
||||||
|
**Advice column** — each holding is screened live and the signal is combined with your gain/loss position:
|
||||||
|
|
||||||
|
| Situation | Advice |
|
||||||
|
|---|---|
|
||||||
|
| ✅ Strong Buy signal | Hold & Add |
|
||||||
|
| ⚡ Momentum + > 30% gain | Consider partial profit-taking |
|
||||||
|
| ⚠️ Speculation + > 20% gain | Reduce position |
|
||||||
|
| ❌ Avoid signal + in profit | Sell (Take Profits) |
|
||||||
|
| ❌ Avoid signal + at a loss | Sell (Cut Loss) |
|
||||||
|
| Crypto | Hold / Review position (no fundamental scoring) |
|
||||||
|
|
||||||
|
**Personal finance section** *(requires SimpleFIN)* — when configured, the page also shows net worth, total assets vs liabilities, cash vs investments ratio, monthly income/spend, account balances, and a spending breakdown by category for the last 30 days.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Market Calls tab
|
||||||
|
|
||||||
|
Record and track quarterly investment theses from the day you make the call.
|
||||||
|
|
||||||
|
**Creating a call** — click **+ New Call** and fill in:
|
||||||
|
- **Title** — e.g. "Q3 2025 — Rate pivot & tech rotation"
|
||||||
|
- **Quarter** — the quarter this thesis applies to
|
||||||
|
- **Thesis** — the macro reasoning behind the call (min 10 characters)
|
||||||
|
- **Tickers** — the assets you're watching for this thesis
|
||||||
|
|
||||||
|
When saved, the current price and signal for each ticker are snapshotted automatically.
|
||||||
|
|
||||||
|
**Viewing performance** — click any call card to see the current price and signal for each ticker alongside the original snapshot, so you can measure how the thesis played out.
|
||||||
|
|
||||||
|
**Calendar** — shows upcoming earnings dates, ex-dividend dates, and dividend payment dates for all tickers across your active calls.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Safe Buys tab
|
||||||
|
|
||||||
|
A filtered view showing only tickers with a **✅ Strong Buy** signal across both lenses. A quick watchlist of assets passing the strictest criteria in the current market.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### API rate limits
|
||||||
|
|
||||||
|
`/api/screen`, `/api/screen/catalysts`, and `/api/analyze` are capped at **10 requests per minute** per IP. All other routes allow 60 per minute.
|
||||||
|
|||||||
Generated
+31
@@ -10,6 +10,7 @@
|
|||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@anthropic-ai/sdk": "^0.100.1",
|
"@anthropic-ai/sdk": "^0.100.1",
|
||||||
"@fastify/cors": "^11.2.0",
|
"@fastify/cors": "^11.2.0",
|
||||||
|
"@fastify/rate-limit": "^10.2.1",
|
||||||
"dotenv": "^16.0.0",
|
"dotenv": "^16.0.0",
|
||||||
"fastify": "^5.8.5",
|
"fastify": "^5.8.5",
|
||||||
"yahoo-finance2": "^3.15.2"
|
"yahoo-finance2": "^3.15.2"
|
||||||
@@ -733,6 +734,27 @@
|
|||||||
"node": ">= 10"
|
"node": ">= 10"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/@fastify/rate-limit": {
|
||||||
|
"version": "10.3.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/@fastify/rate-limit/-/rate-limit-10.3.0.tgz",
|
||||||
|
"integrity": "sha512-eIGkG9XKQs0nyynatApA3EVrojHOuq4l6fhB4eeCk4PIOeadvOJz9/4w3vGI44Go17uaXOWEcPkaD8kuKm7g6Q==",
|
||||||
|
"funding": [
|
||||||
|
{
|
||||||
|
"type": "github",
|
||||||
|
"url": "https://github.com/sponsors/fastify"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": "opencollective",
|
||||||
|
"url": "https://opencollective.com/fastify"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"@lukeed/ms": "^2.0.2",
|
||||||
|
"fastify-plugin": "^5.0.0",
|
||||||
|
"toad-cache": "^3.7.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/@hono/node-server": {
|
"node_modules/@hono/node-server": {
|
||||||
"version": "1.19.14",
|
"version": "1.19.14",
|
||||||
"resolved": "https://registry.npmjs.org/@hono/node-server/-/node-server-1.19.14.tgz",
|
"resolved": "https://registry.npmjs.org/@hono/node-server/-/node-server-1.19.14.tgz",
|
||||||
@@ -783,6 +805,15 @@
|
|||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "BSD-3-Clause"
|
"license": "BSD-3-Clause"
|
||||||
},
|
},
|
||||||
|
"node_modules/@lukeed/ms": {
|
||||||
|
"version": "2.0.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/@lukeed/ms/-/ms-2.0.2.tgz",
|
||||||
|
"integrity": "sha512-9I2Zn6+NJLfaGoz9jN3lpwDgAYvfGeNYdbAIjJOqzs4Tpc+VU3Jqq4IofSUBKajiDS8k9fZIg18/z13mpk1bsA==",
|
||||||
|
"license": "MIT",
|
||||||
|
"engines": {
|
||||||
|
"node": ">=8"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/@modelcontextprotocol/sdk": {
|
"node_modules/@modelcontextprotocol/sdk": {
|
||||||
"version": "1.29.0",
|
"version": "1.29.0",
|
||||||
"resolved": "https://registry.npmjs.org/@modelcontextprotocol/sdk/-/sdk-1.29.0.tgz",
|
"resolved": "https://registry.npmjs.org/@modelcontextprotocol/sdk/-/sdk-1.29.0.tgz",
|
||||||
|
|||||||
@@ -24,6 +24,7 @@
|
|||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@anthropic-ai/sdk": "^0.100.1",
|
"@anthropic-ai/sdk": "^0.100.1",
|
||||||
"@fastify/cors": "^11.2.0",
|
"@fastify/cors": "^11.2.0",
|
||||||
|
"@fastify/rate-limit": "^10.2.1",
|
||||||
"dotenv": "^16.0.0",
|
"dotenv": "^16.0.0",
|
||||||
"fastify": "^5.8.5",
|
"fastify": "^5.8.5",
|
||||||
"yahoo-finance2": "^3.15.2"
|
"yahoo-finance2": "^3.15.2"
|
||||||
|
|||||||
+22
-1
@@ -1,5 +1,6 @@
|
|||||||
import Fastify from 'fastify';
|
import Fastify, { type FastifyRequest, type FastifyReply } from 'fastify';
|
||||||
import cors from '@fastify/cors';
|
import cors from '@fastify/cors';
|
||||||
|
import rateLimit from '@fastify/rate-limit';
|
||||||
import { ScreenerController } from './controllers/screener.controller';
|
import { ScreenerController } from './controllers/screener.controller';
|
||||||
import { FinanceController } from './controllers/finance.controller';
|
import { FinanceController } from './controllers/finance.controller';
|
||||||
import { CallsController } from './controllers/calls.controller';
|
import { CallsController } from './controllers/calls.controller';
|
||||||
@@ -31,6 +32,26 @@ export async function buildApp({ logger = true }: BuildAppOptions = {}) {
|
|||||||
origin: process.env.CLIENT_ORIGIN ?? 'http://localhost:5173',
|
origin: process.env.CLIENT_ORIGIN ?? 'http://localhost:5173',
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// ── Rate limiting — applied globally, tightest on expensive routes ───────
|
||||||
|
await app.register(rateLimit, {
|
||||||
|
global: false, // opt-in per route via config.rateLimit
|
||||||
|
max: 60,
|
||||||
|
timeWindow: '1 minute',
|
||||||
|
});
|
||||||
|
|
||||||
|
// ── API key auth — only enforced when API_KEY env var is set ─────────────
|
||||||
|
const API_KEY = process.env.API_KEY;
|
||||||
|
if (API_KEY) {
|
||||||
|
app.addHook('onRequest', async (req: FastifyRequest, reply: FastifyReply) => {
|
||||||
|
// Skip auth for health check and OPTIONS preflight
|
||||||
|
if (req.url === '/health' || req.method === 'OPTIONS') return;
|
||||||
|
const header = req.headers['authorization'] ?? '';
|
||||||
|
if (header !== `Bearer ${API_KEY}`) {
|
||||||
|
return reply.code(401).send({ error: 'Unauthorized' });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
const yahoo = new YahooFinanceClient();
|
const yahoo = new YahooFinanceClient();
|
||||||
const benchmark = new BenchmarkProvider(yahoo, { logger: noopLogger });
|
const benchmark = new BenchmarkProvider(yahoo, { logger: noopLogger });
|
||||||
const engine = new ScreenerEngine(yahoo, benchmark, { logger: noopLogger });
|
const engine = new ScreenerEngine(yahoo, benchmark, { logger: noopLogger });
|
||||||
|
|||||||
@@ -10,7 +10,11 @@ export class AnalyzeController {
|
|||||||
) {}
|
) {}
|
||||||
|
|
||||||
register(app: FastifyInstance): void {
|
register(app: FastifyInstance): void {
|
||||||
app.post('/api/analyze', { schema: analyzeSchema }, this.analyze.bind(this));
|
app.post(
|
||||||
|
'/api/analyze',
|
||||||
|
{ schema: analyzeSchema, config: { rateLimit: { max: 10, timeWindow: '1 minute' } } },
|
||||||
|
this.analyze.bind(this),
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
private async analyze(req: FastifyRequest, reply: FastifyReply) {
|
private async analyze(req: FastifyRequest, reply: FastifyReply) {
|
||||||
|
|||||||
@@ -8,8 +8,16 @@ export class ScreenerController {
|
|||||||
constructor(private readonly engine: ScreenerEngine) {}
|
constructor(private readonly engine: ScreenerEngine) {}
|
||||||
|
|
||||||
register(app: FastifyInstance): void {
|
register(app: FastifyInstance): void {
|
||||||
app.post('/api/screen', { schema: screenSchema }, this.screen.bind(this));
|
app.post(
|
||||||
app.get('/api/screen/catalysts', this.catalysts.bind(this));
|
'/api/screen',
|
||||||
|
{ schema: screenSchema, config: { rateLimit: { max: 10, timeWindow: '1 minute' } } },
|
||||||
|
this.screen.bind(this),
|
||||||
|
);
|
||||||
|
app.get(
|
||||||
|
'/api/screen/catalysts',
|
||||||
|
{ config: { rateLimit: { max: 10, timeWindow: '1 minute' } } },
|
||||||
|
this.catalysts.bind(this),
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
private static serializeAssets(arr: LiveAssetResult[]) {
|
private static serializeAssets(arr: LiveAssetResult[]) {
|
||||||
|
|||||||
Reference in New Issue
Block a user