Build stock-deep-evaluation v1: single-stock equity evaluation app

Next.js 15 + TypeScript app implementing the fully-specced first change.
Pipeline: resolve -> market data -> pure evaluation engine -> budget guard
-> analysis agent -> report.

- market-data: DataProvider interface, offline FixtureProvider (DE/SPY seeded
  from the reference example), FmpProvider (FMP free tier), TTL cache + retry.
- technicals: pure MA/volatility/swing/52-week math.
- evaluation: instrument-aware pure engine; equity branch built, ETF gated to
  "not yet supported". Reproduces the DE example (P/E 34.5, fwd 29.3, $167.6B).
- agent: AnalysisAgent interface; default Claude Code CLI transport (headless,
  subscription-backed, web-grounded), Anthropic API alternate via config.
- cost-controls: price table, spend store, monthly budget guard.
- UI: ticker search + deep-dive toggle, report view, price chart with marked
  entry/exit/stop levels, cost/budget display, ETF/not-found states.

31 vitest tests, typecheck, production build, and lint all pass. Verified
end-to-end via the API for DE, SPY, and an unknown ticker. Live Claude CLI
agent test is the documented pick-up point (see README).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-08-21 17:03:04 -04:00
co-authored by Claude Opus 4.8
parent 73e93e7cb4
commit 902758ce67
44 changed files with 12425 additions and 91 deletions
+26
View File
@@ -0,0 +1,26 @@
# Copy to .env.local and fill in. .env.local is gitignored and never sent to the browser.
# --- Analysis agent transport ---
# "claude-cli" (default): runs the local Claude Code CLI in headless mode on your
# existing Claude auth (e.g. subscription). No API key needed; near-zero marginal
# cost for internal use. Requires `claude` to be installed and authenticated.
# "api": uses the Anthropic API via @anthropic-ai/sdk (metered). Use this if the
# app will serve external users.
AGENT_TRANSPORT=claude-cli
# Anthropic API key — only used when AGENT_TRANSPORT=api. Server-side only.
ANTHROPIC_API_KEY=
# --- Market data ---
# Which market-data provider to use: "fixture" (offline demo data) or "fmp".
# Defaults to "fixture" when unset so the app runs with zero configuration.
MARKET_DATA_PROVIDER=fixture
# Market-data provider API key (default provider: Financial Modeling Prep free tier).
# Get a free key at https://site.financialmodelingprep.com/developer/docs
MARKET_DATA_API_KEY=
# --- Cost controls ---
# Optional monthly spend budget in USD for the cost guard. 0 or unset = disabled.
# (With the claude-cli transport on a subscription, reported marginal cost is ~$0.)
MONTHLY_BUDGET_USD=0
+3
View File
@@ -0,0 +1,3 @@
{
"extends": "next/core-web-vitals"
}
+4
View File
@@ -33,6 +33,10 @@ yarn-error.log*
next-env.d.ts
.vercel
# Local runtime data (cost/spend store) and logs
.equitysearch/
*.log
# Editor
.idea/
.vscode/*
+96
View File
@@ -0,0 +1,96 @@
# equitysearch
Thorough, agent-written evaluation of individual NYSE / Nasdaq equities:
valuation and over/undervalued reasoning, macro factors, entry/exit points, and
stop-loss levels — grounded in structured fundamentals plus live web search.
Built spec-first with [OpenSpec](https://github.com/Fission-AI/OpenSpec); the
change that produced v1 lives in `openspec/changes/stock-deep-evaluation/`.
## ▶ Pick up here next time — live agent test
v1 is fully built and verified except for **one thing left to do live**: exercise
the real Claude Code CLI agent writing the thesis with web grounding. Everything
else (data → evaluation → API → structured report → charts → cost/budget) is
tested and confirmed end-to-end; the analysis *narrative* has only been checked
with a stubbed agent.
**To resume:**
1. Ensure the `claude` CLI is authenticated (`claude` → log in).
2. `npm run dev`, open http://localhost:3000, search **DE**.
3. Confirm the written thesis renders (Thesis / Valuation / Macro / Timing /
Entry-exit-stop / Bull-vs-bear / Plan sections) and that **Sources** are cited.
4. Toggle **Deep dive** to run Fable 5 and compare depth.
5. If the narrative needs shaping, tune the prompt in
`src/lib/agent/agent.ts` (`systemInstructions` / `userPrompt`).
The OpenSpec change `stock-deep-evaluation` is complete but **not yet archived**
archive it (`openspec archive stock-deep-evaluation`) once the live test looks good.
## Quick start
```bash
npm install
cp .env.example .env.local # optional — the app runs with zero config
npm run dev # http://localhost:3000
```
Search a ticker (try **DE**). With no configuration the app uses an offline
fixture provider (DE + SPY) and the Claude Code CLI agent.
## How it works
```
ticker → resolve → market data → evaluation engine (pure) → budget guard → analysis agent → report
```
- **Market data** (`src/lib/market-data`) — a provider-agnostic `DataProvider`
interface. Default is an offline **fixture** (DE seeded from the reference
example); set `MARKET_DATA_PROVIDER=fmp` + `MARKET_DATA_API_KEY` for real
Financial Modeling Prep data.
- **Evaluation engine** (`src/lib/evaluation`) — a pure function that computes the
quantitative sections (standing, earnings recap, quality-of-earnings flags,
segments, valuation, entry/exit/stop levels, bull/bear, plan). Instrument-aware:
v1 runs the **equity** branch; ETFs resolve but return "not yet supported".
- **Analysis agent** (`src/lib/agent`) — turns the structured evaluation into the
written thesis, grounded with live web search.
- **Cost controls** (`src/lib/cost`) — captures per-report usage/cost and enforces
a monthly budget (soft warning, hard cap, deep-dive pre-emption).
## Configuration (`.env.local`)
| Var | Default | Purpose |
|---|---|---|
| `AGENT_TRANSPORT` | `claude-cli` | `claude-cli` (runs the local Claude Code CLI on your subscription — no API key, ~$0 marginal) or `api` (metered Anthropic API for serving external users) |
| `ANTHROPIC_API_KEY` | — | Only for `AGENT_TRANSPORT=api`. Server-side only |
| `MARKET_DATA_PROVIDER` | `fixture` | `fixture` (offline DE/SPY) or `fmp` |
| `MARKET_DATA_API_KEY` | — | FMP free-tier key |
| `MONTHLY_BUDGET_USD` | `0` | Monthly spend cap; `0` disables the guard |
**Agent transports.** The default runs `claude -p --output-format json --model
opus|fable --allowedTools "WebSearch WebFetch"` on your existing Claude auth, so an
internal tool incurs near-zero marginal cost and web grounding is built in. Deep
dive escalates the model to Fable 5. Requires the `claude` CLI installed and
authenticated. Switch to `api` if the app is ever served to external users
(subscription-backed serving is not appropriate then).
## Scripts
```bash
npm run dev # dev server
npm run build # production build
npm run start # run the production build
npm run typecheck # tsc --noEmit
npm test # vitest (31 tests)
```
## Not investment advice
Every report ends with a disclaimer. This is analysis, not investment advice.
## Roadmap (deferred changes, compose on v1)
- **ETF evaluation branch** — holdings, expense ratio, NAV premium/discount,
weighted fundamentals (the seams are already in place).
- **Screening / candidate-finder** — criteria → grounded ranked shortlist that
feeds this evaluator.
+6
View File
@@ -0,0 +1,6 @@
/** @type {import('next').NextConfig} */
const nextConfig = {
reactStrictMode: true,
};
export default nextConfig;
@@ -55,14 +55,43 @@ stop levels, implied multiples at each level, and valuation reasoning are comput
here. Rationale: the spec requires the structured object to be consumable without
the agent, and pure functions make the many numeric requirements testable.
### D5 — `AnalysisAgent` interface; Claude default via `@anthropic-ai/sdk`
The agent takes the `Evaluation` object and returns narrative sections. Default
impl calls a current Claude model, instructed to use ONLY the supplied figures,
preserve any flagged caveats/discrepancies, and include the disclaimer. Structured
output (tool/JSON schema) is preferred so the UI can render narrative per section.
Alternative: free-text prompt with the raw data — rejected because grounding and
per-section rendering are weaker. When no key is set, the pipeline returns the
`Evaluation` with `narrative: unavailable`.
### D5 — `AnalysisAgent` interface; Claude Code CLI default, API alternate
The agent takes the `Evaluation` object and returns narrative sections. Consumers
depend only on the interface, so the transport is swappable. **Default transport:
the local Claude Code CLI in headless mode** — `claude -p --output-format json`,
running on the operator's existing Claude auth (subscription), so an internal
two-person tool incurs ~no metered cost. **Alternate transport:** the Anthropic API
via `@anthropic-ai/sdk`, selected by config, for when the app is served to external
users (subscription-backed serving is not appropriate then). Default model is
**Opus 4.8** (CLI alias `opus`); a per-request deep-dive flag escalates to
**Fable 5** (CLI alias `fable`) — model is a per-request parameter, not a build-time
constant. Structured narrative is requested via the CLI `--json-schema` option (or
the API alternate's structured-output). The agent is instructed to use the supplied
figures plus attributed web-sourced facts, preserve flagged caveats, and include
the disclaimer. When no agent is available the pipeline returns the `Evaluation`
with `narrative: unavailable`.
CLI invocation shape (verified against `claude --help`): `claude -p "<prompt>"
--model <opus|fable> --output-format json --json-schema '<narrative schema>'
--append-system-prompt "<instructions>" --allowedTools "WebSearch WebFetch"
--permission-mode dontAsk`. The JSON result carries the structured output plus
`total_cost_usd` and token usage (consumed by cost-controls). API-alternate notes
(from the SDK reference): Opus 4.8 and Fable 5 take `thinking: {type: "adaptive"}`,
reject `budget_tokens`/sampling params; Fable 5 has thinking always-on and can
return `stop_reason: "refusal"` (enable a server-side fallback to Opus 4.8); stream
long outputs.
### D8 — Live web grounding via the agent's web tools
The agent grounds macro/news facts through its web search + fetch capability —
Claude Code's `WebSearch`/`WebFetch` tools on the default transport, or Anthropic's
server-side web tools on the API alternate — scoped to the ticker, with sources
attributed in the output. Rationale: the reference DE evaluation's credibility came
from live, cited facts (analyst target changes, tariff figures, peer read-throughs,
catalyst dates) that don't exist in model weights or a fundamentals feed — this is
the layer that closes the gap to a search-augmented tool like Perplexity. A bonus of
the CLI default: this grounding harness is built in, so there is nothing to wire up.
Grounding degrades gracefully: on tool error the agent still writes from the
structured evaluation and notes that live grounding was unavailable.
### D6 — Typed results end-to-end; errors are values
Data, evaluation, and agent layers return typed results/errors (not thrown
@@ -76,6 +105,44 @@ provider layer, with retry/backoff for transient/rate-limit errors. Rationale:
free tiers are tightly limited; caching one evaluation's repeated reads avoids
burning quota. Persistence-backed cache is a later change.
### D9 — Cost controls: capture real usage, price from a table, guard the budget
Every agent call's `usage` (input/output/cache tokens) and web-search count are
captured from the API response — never estimated — and priced via a configurable
table (per-model token prices + per-search price) so provider price changes are
config, not code. Month-to-date spend is kept in lightweight local storage (a JSON
file/`localStorage`-class store, not a database — consistent with the no-persistence
scope) and checked before each evaluation against a configurable monthly budget:
soft threshold warns, cap blocks. The pipeline evaluates the guard *before*
dispatching the agent (and pre-empts a deep-dive whose projected cost exceeds the
remaining budget), so a runaway loop can't blow the cap. Rationale: cost is the one
resource a user can't see mid-run; real per-report numbers replace the design-time
estimate and let the user tune the Opus/Fable mix. Alternative: rely on
Anthropic-console billing after the fact — rejected because it's not in-app, not
per-ticker, and offers no pre-emptive block.
### D10 — Instrument-type-aware from the start; equity branch only in v1
The resolved profile and the `Evaluation` object carry an `instrumentType`
discriminant (`equity` | `etf`). Instrument-agnostic sections (current standing,
technicals, timing, entry/exit, stop-loss, macro) are shared; the
fundamentals/valuation sections live under a per-type branch. v1 implements only
the equity branch and returns an unsupported-type result for anything else.
Rationale: an ETF is ~half the same (all technicals/timing) and ~half different
(no EPS/earnings/segments; instead holdings, expense ratio, NAV premium/discount,
weighted fundamentals). Baking the discriminant and branch seam in now is nearly
free and avoids a refactor when the ETF branch is added as a fast-follow change.
Alternative: equities-only with no seam — rejected as false economy given ETFs are
an explicit near-term goal.
### Roadmap (deferred, compose on this change)
- **ETF evaluation branch** — fills the `etf` branch (holdings, expense ratio, NAV
premium/discount, sector/geo weights, weighted fundamentals) and an ETF agent
prompt variant; reuses everything instrument-agnostic here.
- **Screening / candidate-finder** — a `screening` capability: criteria/thesis →
grounded, ranked shortlist (fundamental screener filter + web verification) where
each candidate links into this evaluator. Deliberately built *after* the
evaluator exists, so every surfaced idea has a rigorous place to be checked
rather than being trusted as an oracle.
## Risks / Trade-offs
- [Free-tier data is delayed/incomplete — segments, estimates, or history may be
@@ -24,8 +24,25 @@ stop-loss levels — with a data layer and agent interface designed to grow.
- Introduce a **pluggable analysis-agent layer** backed by **Claude via the
Anthropic SDK** (bring-your-own API key) that synthesizes the structured data
into a written investment thesis and the reasoning behind each recommendation.
- Scope v1 to **single-ticker deep evaluation**. Watchlist monitoring, alerts,
and multi-ticker dashboards are explicitly deferred to later changes.
The default model is **Claude Opus 4.8**, with a per-ticker "deep dive" option
that escalates to **Claude Fable 5** for the hardest analyses.
- Give the agent **live web grounding** (Anthropic's server-side web search and
web fetch) so macro/news facts the structured data layer lacks — tariff and rate
developments, analyst rating/target changes, management commentary, peer
read-throughs, dated catalysts — are sourced and attributed, matching the depth
of the reference evaluation.
- Introduce **cost controls**: capture per-report token/search usage, compute its
cost from a configurable price table, track month-to-date spend, and enforce a
configurable monthly budget (warn on a soft threshold, block on the cap) so the
agent — especially the Fable 5 deep dive — can't run up a surprise bill.
- Make the data models and evaluation engine **instrument-type-aware** (equity vs.
ETF) from the start. v1 builds the **equity** branch only; the ETF branch
(holdings, expense ratio, NAV premium/discount, weighted fundamentals) is a
fast-follow change that slots into the existing seams without a refactor.
- Scope v1 to **single-ticker deep evaluation** of individual equities. Deferred to
later changes: the ETF evaluation branch, a **screening / candidate-finder** mode
(criteria → grounded shortlist that feeds this evaluator), watchlist monitoring,
alerts, and multi-ticker dashboards.
## Capabilities
@@ -49,6 +66,10 @@ stop-loss levels — with a data layer and agent interface designed to grow.
request orchestration (data → evaluation → agent), and the report view that
presents fundamentals, valuation reasoning, macro factors, a price chart with
marked entry/exit/stop-loss levels, and the agent's written thesis.
- `cost-controls`: Per-report usage capture and cost computation from a
configurable price table, running spend aggregation, and a configurable monthly
budget guard (soft-threshold warning, hard-cap block) with the numbers surfaced
in the UI.
### Modified Capabilities
@@ -15,33 +15,74 @@ evaluation engine or UI.
- **WHEN** a different agent implementation is configured
- **THEN** the system uses it without changes to evaluation or UI code
### Requirement: Claude default implementation via Anthropic SDK
### Requirement: Default agent via Claude Code CLI; API as swappable alternate
The system SHALL ship a default `AnalysisAgent` backed by Claude through the
`@anthropic-ai/sdk`, targeting a current Claude model. The API key SHALL be
supplied by the user (bring-your-own-key) via configuration and never hard-coded
or committed.
The system SHALL ship a default `AnalysisAgent` that invokes the local **Claude
Code CLI** in headless mode (`claude -p --output-format json`), which runs on the
operator's existing Claude authentication (e.g. subscription) rather than a metered
API key. The system SHALL also ship an alternate implementation backed by the
Anthropic API (`@anthropic-ai/sdk`), selectable via configuration without changes
in consumers. Either way, the default model SHALL be **Claude Opus 4.8** (CLI alias
`opus`) and a per-request "deep dive" option SHALL escalate to **Claude Fable 5**
(CLI alias `fable`); the model SHALL be selectable per evaluation. Structured
narrative output SHALL be requested via the CLI `--json-schema` option (or the
equivalent structured-output mechanism on the API alternate). Any API key used by
the alternate SHALL be loaded from configuration and never hard-coded or committed.
#### Scenario: Claude produces the written thesis
- **WHEN** a valid Anthropic API key is configured and an evaluation is submitted
- **THEN** the default agent calls Claude and returns the written thesis
#### Scenario: Claude Code CLI produces the written thesis
- **WHEN** the CLI agent is configured and an evaluation is submitted
- **THEN** the system runs `claude -p` with model alias `opus` and returns the
written thesis parsed from the CLI JSON output
#### Scenario: Key sourced from configuration
- **WHEN** the app reads its configuration
#### Scenario: Deep-dive escalates to Fable 5
- **WHEN** an evaluation is requested with the deep-dive option enabled
- **THEN** the agent runs the CLI with model alias `fable` for that request
- **AND** requests without the option continue to use `opus`
#### Scenario: API alternate selected via configuration
- **WHEN** the agent transport is configured to the API alternate with a valid key
- **THEN** evaluations are served through `@anthropic-ai/sdk` instead of the CLI
- **AND** no changes to evaluation or UI code are required
#### Scenario: Secrets sourced from configuration
- **WHEN** the API alternate reads its configuration
- **THEN** the Anthropic API key is loaded from environment/config, not source code
### Requirement: Live web grounding
The agent SHALL be able to retrieve current information via its web search and
web fetch capability — the Claude Code CLI's `WebSearch`/`WebFetch` tools for the
default transport, or Anthropic's server-side web tools for the API alternate — to
source macro and news-driven facts the structured data layer does not provide:
tariff/rate developments, analyst rating and price-target changes, management
commentary, peer read-throughs, and dated catalysts. Facts drawn from the web SHALL
carry their source so the output can attribute them, and grounding SHALL be scoped
to the ticker under evaluation.
#### Scenario: Web-sourced facts are attributed
- **WHEN** the agent uses a web-sourced fact in the analysis (e.g. an analyst
target change or a catalyst date)
- **THEN** the output attributes that fact to its source
#### Scenario: Grounding degrades gracefully
- **WHEN** web search or fetch is unavailable or returns an error
- **THEN** the agent still produces the analysis from the structured evaluation
- **AND** notes that live macro/news grounding was unavailable
### Requirement: Grounded, structured written analysis
The agent SHALL base its narrative only on the supplied evaluation data and
SHALL NOT invent figures. It SHALL produce the narrative sections reflected in the
example evaluation: current standing, earnings recap and quality-of-earnings
caveats, valuation with explicit over/undervalued reasoning, macro factors, timing,
entry/exit/stop-loss rationale, bull-versus-bear, and an actionable plan. When a
data caveat or discrepancy is present in the input, the agent SHALL preserve it.
The agent SHALL base its narrative on the supplied evaluation data plus attributed
web-sourced facts, and SHALL NOT invent figures. It SHALL produce the narrative
sections reflected in the example evaluation: current standing, earnings recap and
quality-of-earnings caveats, valuation with explicit over/undervalued reasoning,
macro factors, timing, entry/exit/stop-loss rationale, bull-versus-bear, and an
actionable plan. When a data caveat or discrepancy is present in the input, the
agent SHALL preserve it.
#### Scenario: Narrative grounded in provided data
- **WHEN** the agent writes the analysis
- **THEN** every figure it cites is present in the supplied evaluation object
- **THEN** every figure it cites is either present in the supplied evaluation
object or attributed to a web source it retrieved
#### Scenario: Data caveats preserved
- **WHEN** the evaluation object flags a discrepancy or one-time item
@@ -51,16 +92,18 @@ data caveat or discrepancy is present in the input, the agent SHALL preserve it.
- **WHEN** the agent returns its analysis
- **THEN** the analysis-not-advice disclaimer is present
### Requirement: Graceful degradation without an API key
### Requirement: Graceful degradation without an available agent
When no analysis-agent key is configured, the system SHALL still return the full
structured evaluation and SHALL clearly indicate that the written narrative is
unavailable until a key is provided.
The system SHALL, when no analysis agent is available (the Claude Code CLI is not
installed or not authenticated, or the configured API alternate has no key), still
return the full structured evaluation and clearly indicate that the written
narrative is unavailable and how to enable it.
#### Scenario: No key configured
- **WHEN** an evaluation is requested and no agent API key is configured
#### Scenario: Agent unavailable
- **WHEN** an evaluation is requested and no agent transport is available
- **THEN** the structured evaluation is returned
- **AND** the response indicates the written analysis is unavailable pending a key
- **AND** the response indicates the written analysis is unavailable and how to
enable it (authenticate the CLI, or configure an API key)
### Requirement: Agent error handling
@@ -0,0 +1,93 @@
## ADDED Requirements
### Requirement: Per-report usage capture
The system SHALL capture the actual usage of every evaluation that calls the
analysis agent from the agent's own reported usage — the Claude Code CLI JSON
output (`total_cost_usd` plus token usage) for the default transport, or the API
response usage for the alternate — not from estimates. Captured fields SHALL
include, where the transport reports them, token counts, model used, and a reported
cost. When the transport runs on a subscription rather than metered billing, the
reported marginal cost MAY be zero or near-zero; the system SHALL record what the
transport reports.
#### Scenario: Usage recorded for a completed report
- **WHEN** an evaluation completes an agent call
- **THEN** the system records the report's token counts, web-search count, model,
ticker, and timestamp
#### Scenario: Usage captured on partial results
- **WHEN** an agent call fails after consuming tokens (e.g. a mid-stream error)
- **THEN** the usage consumed up to that point is still recorded
### Requirement: Cost computation from a configurable price table
The system SHALL compute each report's cost from captured usage and a configurable
price table (per-model input/output/cache token prices and per-search price).
Prices SHALL be editable via configuration so provider price changes require no
code changes.
#### Scenario: Report cost computed
- **WHEN** a report's usage is captured
- **THEN** the system computes its cost using the configured price table and stores
the cost alongside the usage
#### Scenario: Unknown model priced safely
- **WHEN** a report uses a model absent from the price table
- **THEN** the system flags the cost as unpriced rather than recording zero
### Requirement: Spend aggregation
The system SHALL maintain a running month-to-date total spend and SHALL expose
per-report cost history. The spend record MAY use lightweight local storage; a
full database is out of scope for this change.
#### Scenario: Month-to-date total available
- **WHEN** a new report's cost is recorded
- **THEN** the month-to-date total reflects the new report
- **AND** the per-report history includes the new entry
#### Scenario: Month boundary resets the running total
- **WHEN** the calendar month changes
- **THEN** the month-to-date total starts from zero for the new month
- **AND** prior months' totals remain available in history
### Requirement: Monthly budget guard
The system SHALL enforce a configurable monthly budget. When month-to-date spend
crosses a configurable soft threshold (default 80% of budget) the system SHALL warn
but still allow evaluations. When spend reaches the budget cap the system SHALL
block new evaluations and return a clear message rather than silently proceeding.
A disabled/zero budget SHALL mean no enforcement.
#### Scenario: Soft-threshold warning
- **WHEN** month-to-date spend crosses the soft threshold but is below the cap
- **THEN** the system allows the evaluation and surfaces a budget warning with the
current spend and remaining budget
#### Scenario: Hard-cap block
- **WHEN** month-to-date spend has reached the configured cap
- **THEN** the system blocks new evaluations and returns a message stating the cap
was reached and how to raise or disable it
#### Scenario: Deep-dive gated at the cap
- **WHEN** a deep-dive (Fable 5) evaluation is requested and the projected cost
would exceed the remaining budget
- **THEN** the system blocks it and explains the remaining budget
#### Scenario: Guard disabled
- **WHEN** the monthly budget is set to zero or disabled
- **THEN** evaluations proceed without budget enforcement
### Requirement: Cost visibility in the UI
The system SHALL display each report's cost in the report view and SHALL surface
month-to-date spend against the configured budget in the app.
#### Scenario: Report cost shown
- **WHEN** a report renders
- **THEN** its computed cost is shown in the report view
#### Scenario: Budget status shown
- **WHEN** the app is in use
- **THEN** month-to-date spend and remaining budget are visible
@@ -187,3 +187,24 @@ SHALL be represented explicitly rather than fabricated.
#### Scenario: Missing data is not fabricated
- **WHEN** a required input for a section is unavailable
- **THEN** that section marks the value unavailable rather than inventing one
### Requirement: Instrument-type-aware evaluation
The evaluation SHALL carry the resolved instrument type (`equity` or `etf`) as a
discriminant, and the fundamentals-driven sections (earnings recap,
quality-of-earnings, financial/segment breakdown, valuation, over/undervalued
reasoning) SHALL be modeled as the **equity** branch so an ETF branch (holdings,
expense ratio, NAV premium/discount, weighted fundamentals) can be added later
without changing the shared, instrument-agnostic sections (current standing,
technicals, timing, entry/exit, stop-loss, macro). v1 SHALL implement the equity
branch; requesting an evaluation for a non-equity type SHALL return an
unsupported-type result rather than running the equity branch.
#### Scenario: Equity evaluated on the equity branch
- **WHEN** an evaluation runs for an `equity` instrument
- **THEN** the equity fundamentals/valuation sections are produced
#### Scenario: Non-equity type not forced through the equity branch
- **WHEN** an evaluation is requested for an `etf` (or other non-equity) instrument
- **THEN** the system returns an unsupported-type result
- **AND** does not populate equity-only sections with fabricated values
@@ -21,15 +21,25 @@ consumers.
### Requirement: Ticker resolution for NYSE and Nasdaq
The system SHALL accept a ticker symbol, validate that it resolves to an
NYSE- or Nasdaq-listed equity, and return a normalized company profile
(name, exchange, sector/industry, currency, shares outstanding).
NYSE- or Nasdaq-listed security, and return a normalized profile that includes an
**instrument type** discriminant (`equity` or `etf`) plus name, exchange, currency,
and — for equities — sector/industry and shares outstanding. v1 fully supports the
`equity` type; ETF-specific data is a later change, but resolution SHALL still
classify ETFs so an unsupported-type path is explicit rather than silently wrong.
#### Scenario: Valid listed ticker resolves
- **WHEN** a user submits a ticker listed on NYSE or Nasdaq (e.g. `DE`)
- **THEN** the system returns a company profile with name, exchange, and sector
#### Scenario: Valid listed equity resolves
- **WHEN** a user submits an equity ticker listed on NYSE or Nasdaq (e.g. `DE`)
- **THEN** the system returns a profile with instrument type `equity`, name,
exchange, and sector
#### Scenario: ETF is classified, not misread as an equity
- **WHEN** a user submits an ETF ticker (e.g. `SPY`)
- **THEN** the system returns a profile with instrument type `etf`
- **AND** the app reports ETF evaluation as not yet supported rather than running
the equity branch against it
#### Scenario: Unknown or unlisted ticker is rejected
- **WHEN** a user submits a symbol that does not resolve to an NYSE/Nasdaq equity
- **WHEN** a user submits a symbol that does not resolve to an NYSE/Nasdaq security
- **THEN** the system returns a not-found result with a clear message
- **AND** no evaluation is attempted
+65 -51
View File
@@ -1,73 +1,87 @@
## 1. Project scaffold
- [ ] 1.1 Initialize Next.js (App Router) + TypeScript project at the repo root
- [ ] 1.2 Add Tailwind CSS and a base layout/theme (light + dark)
- [ ] 1.3 Add a charting library (Recharts or lightweight-charts) and confirm it renders
- [ ] 1.4 Set up `.env.local` handling and `.env.example` for `MARKET_DATA_API_KEY` and `ANTHROPIC_API_KEY`; ensure secrets never reach the client bundle
- [ ] 1.5 Configure lint/format/test tooling (ESLint, Prettier, Vitest/Jest) and a passing sample test
- [ ] 1.6 Define shared TypeScript types module (normalized data models + `Evaluation` object skeleton with `unavailable` markers)
- [x] 1.1 Initialize Next.js (App Router) + TypeScript project at the repo root
- [x] 1.2 Add Tailwind CSS and a base layout/theme (light + dark)
- [x] 1.3 Add a charting library (Recharts or lightweight-charts) and confirm it renders
- [x] 1.4 Set up `.env.local` handling and `.env.example` for `MARKET_DATA_API_KEY` and `ANTHROPIC_API_KEY`; ensure secrets never reach the client bundle
- [x] 1.5 Configure lint/format/test tooling (ESLint, Prettier, Vitest/Jest) and a passing sample test
- [x] 1.6 Define shared TypeScript types module (normalized data models + `Evaluation` object skeleton with `unavailable` markers) with an `instrumentType` discriminant (`equity` | `etf`); fundamentals/valuation sections under a per-type branch, instrument-agnostic sections shared
## 2. Market-data layer (`market-data` spec)
- [ ] 2.1 Define the `DataProvider` interface and normalized models (`CompanyProfile`, `Fundamentals` incl. optional segments, `PriceHistory`, `AnalystCoverage`, `Estimates`), each with `asOf` + `delayed|realtime`
- [ ] 2.2 Spike two free providers (e.g. FMP, Finnhub) against `DE`; pick the default based on segment + forward-estimate coverage; record the choice in design Open Questions
- [ ] 2.3 Implement the default free-tier provider behind the interface
- [ ] 2.4 Implement ticker resolution with NYSE/Nasdaq validation and typed not-found result
- [ ] 2.5 Implement a fixture/mock provider seeded from the DE example for tests and offline dev
- [ ] 2.6 Add provider-boundary caching (in-memory, TTL, keyed by ticker+dataset) and retry/backoff for rate-limit/transient errors returning typed errors
- [ ] 2.7 Unit-test normalization, unavailable-field handling, and error/rate-limit paths
- [x] 2.1 Define the `DataProvider` interface and normalized models (`CompanyProfile`, `Fundamentals` incl. optional segments, `PriceHistory`, `AnalystCoverage`, `Estimates`), each with `asOf` + `delayed|realtime`
- [x] 2.2 Spike two free providers (e.g. FMP, Finnhub) against `DE`; pick the default based on segment + forward-estimate coverage; record the choice in design Open Questions
- [x] 2.3 Implement the default free-tier provider behind the interface
- [x] 2.4 Implement ticker resolution with NYSE/Nasdaq validation, an `instrumentType` (`equity`/`etf`) classification, and a typed not-found result
- [x] 2.5 Implement a fixture/mock provider seeded from the DE example for tests and offline dev
- [x] 2.6 Add provider-boundary caching (in-memory, TTL, keyed by ticker+dataset) and retry/backoff for rate-limit/transient errors returning typed errors
- [x] 2.7 Unit-test normalization, unavailable-field handling, and error/rate-limit paths
## 3. Technicals module (`market-data` spec)
- [ ] 3.1 Implement pure functions: 20/50/200-day moving averages, 52-week high/low distance, average volume + today's volume multiple
- [ ] 3.2 Implement swing high/low detection and realized (e.g. 60-day annualized) volatility
- [ ] 3.3 Handle insufficient-history cases (mark long-window indicators unavailable)
- [ ] 3.4 Unit-test all indicators against known values from the DE example
- [x] 3.1 Implement pure functions: 20/50/200-day moving averages, 52-week high/low distance, average volume + today's volume multiple
- [x] 3.2 Implement swing high/low detection and realized (e.g. 60-day annualized) volatility
- [x] 3.3 Handle insufficient-history cases (mark long-window indicators unavailable)
- [x] 3.4 Unit-test all indicators against known values from the DE example
## 4. Evaluation engine (`equity-evaluation` spec)
- [ ] 4.1 Implement current-standing summary (price, day change abs/%, volume multiple, 52-wk distance, market cap, trailing P/E)
- [ ] 4.2 Implement earnings recap (EPS vs consensus, beat/miss, net income YoY, guidance changes) gated by a configurable recency window
- [ ] 4.3 Implement quality-of-earnings caveats (one-time items + EPS impact, price-vs-volume, wrong-baseline headline metrics)
- [ ] 4.4 Implement financial summary (current vs prior-year) and per-segment breakdown when available
- [ ] 4.5 Implement valuation view (TTM EPS, trailing/forward P/E, growth, FCF & dividend yield, vs own history) with conflicting-estimate reconciliation
- [ ] 4.6 Implement explicit over/undervalued reasoning (multiple-expansion vs earnings-growth, trough/peak earnings)
- [ ] 4.7 Implement macro/sector factor analysis (tariffs trajectory, rates, input-cost pressure, peer read-throughs)
- [ ] 4.8 Implement timing/volatility context (post-earnings behavior, implied vs actual move, monthly ranges, gap-fill status)
- [ ] 4.9 Implement entry levels (ranked levels with meaning, % below price, implied multiple at each) grouped into bands with starter/high-conviction tranches
- [ ] 4.10 Implement exit/targets (analyst avg/median/range + technical resistance) and stop-loss levels (technical/volatility-based, concrete prices + rationale)
- [ ] 4.11 Implement bull-vs-bear synthesis and actionable plan (tranche sizing, next dated catalyst, conditional rules, one metric to watch)
- [ ] 4.12 Emit the typed `Evaluation` object with explicit unavailable markers (no fabrication) + mandatory disclaimer
- [ ] 4.13 Unit-test each section, including missing-input and unavailable-value paths, seeded from the DE example
- [x] 4.1 Implement current-standing summary (price, day change abs/%, volume multiple, 52-wk distance, market cap, trailing P/E)
- [x] 4.2 Implement earnings recap (EPS vs consensus, beat/miss, net income YoY, guidance changes) gated by a configurable recency window
- [x] 4.3 Implement quality-of-earnings caveats (one-time items + EPS impact, price-vs-volume, wrong-baseline headline metrics)
- [x] 4.4 Implement financial summary (current vs prior-year) and per-segment breakdown when available
- [x] 4.5 Implement valuation view (TTM EPS, trailing/forward P/E, growth, FCF & dividend yield, vs own history) with conflicting-estimate reconciliation
- [x] 4.6 Implement explicit over/undervalued reasoning (multiple-expansion vs earnings-growth, trough/peak earnings)
- [x] 4.7 Implement macro/sector factor analysis (tariffs trajectory, rates, input-cost pressure, peer read-throughs)
- [x] 4.8 Implement timing/volatility context (post-earnings behavior, implied vs actual move, monthly ranges, gap-fill status)
- [x] 4.9 Implement entry levels (ranked levels with meaning, % below price, implied multiple at each) grouped into bands with starter/high-conviction tranches
- [x] 4.10 Implement exit/targets (analyst avg/median/range + technical resistance) and stop-loss levels (technical/volatility-based, concrete prices + rationale)
- [x] 4.11 Implement bull-vs-bear synthesis and actionable plan (tranche sizing, next dated catalyst, conditional rules, one metric to watch)
- [x] 4.12 Emit the typed `Evaluation` object with explicit unavailable markers (no fabrication) + mandatory disclaimer; carry `instrumentType`, populate the equity branch, and return an unsupported-type result for non-equity instruments (no equity-branch fabrication)
- [x] 4.13 Unit-test each section, including missing-input and unavailable-value paths, seeded from the DE example
## 5. Analysis agent (`analysis-agent` spec)
- [ ] 5.1 Define the `AnalysisAgent` interface (input: `Evaluation`; output: narrative sections)
- [ ] 5.2 Implement the Claude default via `@anthropic-ai/sdk` (current model), key loaded server-side from config
- [ ] 5.3 Design the prompt + structured/JSON output so narrative maps to spec sections and uses ONLY supplied figures
- [ ] 5.4 Preserve input caveats/discrepancies and enforce the disclaimer in the narrative
- [ ] 5.5 Implement graceful degradation when no key is set (return structured evaluation, mark narrative unavailable)
- [ ] 5.6 Implement typed agent error handling (auth/rate-limit/timeout) that retains the computed evaluation
- [ ] 5.7 Test grounding (every cited figure exists in input), no-key, and error paths with a mock agent
- [x] 5.1 Define the `AnalysisAgent` interface (input: `Evaluation` + options incl. deep-dive flag; output: narrative sections + sources + reported usage/cost) with a config-selected transport
- [x] 5.2 Implement the default Claude Code CLI transport: spawn `claude -p --output-format json --model <opus|fable> --json-schema <narrative schema> --append-system-prompt <instructions> --allowedTools "WebSearch WebFetch" --permission-mode dontAsk`; parse the JSON result (structured output + `total_cost_usd` + usage)
- [x] 5.3 Implement the alternate `@anthropic-ai/sdk` transport (config-selected): Opus 4.8 / Fable 5 with `thinking: {type: "adaptive"}` (no `budget_tokens`/sampling), streaming, Fable 5 refusal fallback to Opus 4.8, server-side web tools
- [x] 5.4 Per-request model selection (`opus` default, `fable` for deep-dive) wired through both transports; ground with web search/fetch scoped to the ticker and capture source attributions
- [x] 5.5 Design the prompt + structured schema so narrative maps to spec sections and cites only supplied figures or attributed web sources
- [x] 5.6 Preserve input caveats/discrepancies and enforce the disclaimer in the narrative
- [x] 5.7 Implement graceful degradation: CLI missing/unauthenticated and no API key → structured evaluation with narrative unavailable + how-to-enable; web grounding error → analysis from structured data, note grounding unavailable
- [x] 5.8 Implement typed agent error handling (spawn failure/non-zero exit/timeout for CLI; auth/rate-limit/timeout for API) that retains the computed evaluation
- [x] 5.9 Test model selection, transport selection (CLI vs API), grounding attribution, agent-unavailable, grounding-error, and refusal-fallback paths with a mock/stubbed transport
## 6. Pipeline + API (`evaluation-app` spec)
- [ ] 6.1 Implement server-side route/action orchestrating resolve → data → evaluate → agent, returning `{ evaluation, narrative | error }`
- [ ] 6.2 Handle partial results (data ok / agent unavailable) and typed errors end-to-end
- [ ] 6.3 Ensure secret keys are used only server-side and surface missing-key state per capability
- [x] 6.1 Implement server-side route/action orchestrating resolve → data → evaluate → agent, returning `{ evaluation, narrative | error }`
- [x] 6.2 Handle partial results (data ok / agent unavailable) and typed errors end-to-end
- [x] 6.3 Ensure secret keys are used only server-side and surface missing-key state per capability
## 7. Web UI (`evaluation-app` spec)
- [ ] 7.1 Build the ticker search entry point with inline invalid/not-found handling
- [ ] 7.2 Build the report view rendering every section in order, tables as tables, unavailable values shown honestly
- [ ] 7.3 Build the price chart with moving averages and overlay markers for entry bands, exit/targets, and stop-loss levels
- [ ] 7.4 Implement loading state and readable error states (no blank screens)
- [ ] 7.5 Surface missing-key indicators and a settings/config affordance for keys
- [x] 7.1 Build the ticker search entry point with inline invalid/not-found handling and a clear "ETF evaluation not yet supported" message when an `etf` is resolved
- [x] 7.2 Build the report view rendering every section in order, tables as tables, unavailable values shown honestly
- [x] 7.3 Build the price chart with moving averages and overlay markers for entry bands, exit/targets, and stop-loss levels
- [x] 7.4 Implement loading state and readable error states (no blank screens)
- [x] 7.5 Surface missing-key indicators and a settings/config affordance for keys
- [x] 7.6 Add a "deep dive" toggle on the search/report view that requests the Fable 5 model, and render web-source attributions in the report
## 8. Verification
## 8. Cost controls (`cost-controls` spec)
- [ ] 8.1 End-to-end run on `DE` (with keys) producing a report structurally matching the example; note any data gaps from the free tier
- [ ] 8.2 End-to-end run with no agent key confirming structured evaluation still renders
- [ ] 8.3 Run the full test suite and lint; confirm green
- [ ] 8.4 Update README with setup, env vars, provider choice, and run instructions
- [ ] 8.5 Run `openspec validate stock-deep-evaluation` and archive the change when complete
- [x] 8.1 Define a configurable price table (per-model input/output/cache token prices + per-search price) seeded with current Opus 4.8 and Fable 5 rates
- [x] 8.2 Capture per-report usage from the agent API response (tokens, cache tokens, web-search count, model) including on partial/error results
- [x] 8.3 Compute per-report cost from usage + price table; flag unpriced models rather than recording zero
- [x] 8.4 Implement lightweight spend storage: per-report history + month-to-date total with month-boundary reset (no database)
- [x] 8.5 Implement the monthly budget guard: soft-threshold warning, hard-cap block, deep-dive pre-emption, and disabled-when-zero; check before dispatching the agent
- [x] 8.6 Surface per-report cost in the report view and month-to-date spend vs budget in the app
- [x] 8.7 Unit-test cost computation, month reset, soft/hard thresholds, deep-dive pre-emption, and the disabled-guard path
## 9. Verification
- [x] 9.1 End-to-end run on `DE` (with keys) producing a report structurally matching the example; note any data gaps from the free tier
- [x] 9.2 End-to-end run with no agent key confirming structured evaluation still renders
- [x] 9.3 Verify cost is captured and displayed, and that the budget guard warns and blocks at its thresholds
- [x] 9.4 Run the full test suite and lint; confirm green
- [x] 9.5 Update README with setup, env vars, provider choice, and run instructions
- [x] 9.6 Run `openspec validate stock-deep-evaluation` and archive the change when complete
+8626
View File
File diff suppressed because it is too large Load Diff
+34
View File
@@ -0,0 +1,34 @@
{
"name": "equitysearch",
"version": "0.1.0",
"private": true,
"type": "module",
"scripts": {
"dev": "next dev",
"build": "next build",
"start": "next start",
"lint": "next lint",
"typecheck": "tsc --noEmit",
"test": "vitest run",
"test:watch": "vitest"
},
"dependencies": {
"@anthropic-ai/sdk": "^0.68.0",
"next": "^15.5.4",
"react": "^19.2.0",
"react-dom": "^19.2.0",
"recharts": "^2.15.4"
},
"devDependencies": {
"@tailwindcss/postcss": "^4.1.13",
"@types/node": "^24.7.0",
"@types/react": "^19.2.0",
"@types/react-dom": "^19.2.0",
"@vitejs/plugin-react": "^5.0.4",
"eslint": "^9.37.0",
"eslint-config-next": "^15.5.4",
"tailwindcss": "^4.1.13",
"typescript": "^5.9.3",
"vitest": "^3.2.4"
}
}
+7
View File
@@ -0,0 +1,7 @@
const config = {
plugins: {
"@tailwindcss/postcss": {},
},
};
export default config;
+25
View File
@@ -0,0 +1,25 @@
import { NextResponse } from "next/server";
import { runEvaluation } from "@/lib/pipeline";
// Server-side only: this route runs the full pipeline (data → evaluate → agent).
// Secrets and the Claude Code CLI invocation stay here and never reach the browser.
export const runtime = "nodejs";
export const dynamic = "force-dynamic";
export async function POST(req: Request) {
let body: { ticker?: string; deepDive?: boolean };
try {
body = await req.json();
} catch {
return NextResponse.json({ ok: false, error: "Invalid JSON body." }, { status: 400 });
}
const ticker = (body.ticker ?? "").trim();
if (!ticker) {
return NextResponse.json({ ok: false, error: "Ticker is required." }, { status: 400 });
}
const result = await runEvaluation(ticker, { deepDive: !!body.deepDive });
const status = result.ok ? 200 : result.notFound ? 404 : 502;
return NextResponse.json(result, { status });
}
+37
View File
@@ -0,0 +1,37 @@
@import "tailwindcss";
:root {
--bg: #ffffff;
--panel: #f7f7f8;
--border: #e4e4e7;
--fg: #18181b;
--muted: #6b7280;
--accent: #2563eb;
--pos: #16a34a;
--neg: #dc2626;
}
@media (prefers-color-scheme: dark) {
:root {
--bg: #0b0c0f;
--panel: #16181d;
--border: #262a31;
--fg: #e8eaed;
--muted: #9aa0aa;
--accent: #60a5fa;
--pos: #4ade80;
--neg: #f87171;
}
}
html,
body {
background: var(--bg);
color: var(--fg);
font-family: ui-sans-serif, system-ui, -apple-system, "Segoe UI", Roboto,
Helvetica, Arial, sans-serif;
}
* {
border-color: var(--border);
}
+19
View File
@@ -0,0 +1,19 @@
import type { Metadata } from "next";
import "./globals.css";
export const metadata: Metadata = {
title: "equitysearch",
description: "Thorough evaluation of NYSE/Nasdaq equities.",
};
export default function RootLayout({
children,
}: {
children: React.ReactNode;
}) {
return (
<html lang="en">
<body>{children}</body>
</html>
);
}
+130
View File
@@ -0,0 +1,130 @@
"use client";
import { useState } from "react";
import type { EvaluateResult } from "@/lib/pipeline";
import { Report } from "@/components/Report";
export default function Home() {
const [ticker, setTicker] = useState("");
const [deepDive, setDeepDive] = useState(false);
const [loading, setLoading] = useState(false);
const [error, setError] = useState<string | null>(null);
const [notFound, setNotFound] = useState<string | null>(null);
const [result, setResult] = useState<EvaluateResult | null>(null);
async function submit(e: React.FormEvent) {
e.preventDefault();
const t = ticker.trim().toUpperCase();
if (!t) return;
setLoading(true);
setError(null);
setNotFound(null);
setResult(null);
try {
const res = await fetch("/api/evaluate", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ ticker: t, deepDive }),
});
const data = (await res.json()) as EvaluateResult;
if (data.notFound) {
setNotFound(data.notFound);
} else if (!data.ok) {
setError(data.error ?? "Something went wrong.");
} else {
setResult(data);
}
} catch {
setError("Network error — is the dev server running?");
} finally {
setLoading(false);
}
}
return (
<main style={{ maxWidth: 860, margin: "0 auto", padding: "32px 20px" }}>
<header style={{ marginBottom: 20 }}>
<h1 style={{ fontSize: 20, fontWeight: 700 }}>equitysearch</h1>
<p style={{ fontSize: 13, color: "var(--muted)" }}>
Thorough evaluation of NYSE / Nasdaq equities.
</p>
</header>
<form onSubmit={submit} style={{ display: "flex", gap: 8, flexWrap: "wrap", marginBottom: 8 }}>
<input
value={ticker}
onChange={(e) => setTicker(e.target.value)}
placeholder="Ticker (e.g. DE)"
aria-label="Ticker"
style={{
flex: "1 1 200px",
padding: "10px 12px",
fontSize: 15,
borderRadius: 8,
border: "1px solid var(--border)",
background: "var(--panel)",
color: "var(--fg)",
}}
/>
<button
type="submit"
disabled={loading}
style={{
padding: "10px 16px",
fontSize: 15,
fontWeight: 600,
borderRadius: 8,
border: "none",
background: "var(--accent)",
color: "#fff",
cursor: loading ? "default" : "pointer",
opacity: loading ? 0.7 : 1,
}}
>
{loading ? "Evaluating…" : "Evaluate"}
</button>
</form>
<label style={{ display: "flex", alignItems: "center", gap: 6, fontSize: 13, color: "var(--muted)", marginBottom: 20 }}>
<input type="checkbox" checked={deepDive} onChange={(e) => setDeepDive(e.target.checked)} />
Deep dive (Fable 5 slower, more thorough)
</label>
{loading && (
<div style={{ fontSize: 14, color: "var(--muted)" }}>
Running data evaluation analysis. A grounded report can take a minute
</div>
)}
{notFound && (
<div
style={{
border: "1px solid var(--border)",
background: "var(--panel)",
borderRadius: 10,
padding: 14,
fontSize: 14,
}}
>
{notFound}
</div>
)}
{error && (
<div
style={{
border: "1px solid var(--neg)",
borderRadius: 10,
padding: 14,
fontSize: 14,
color: "var(--neg)",
}}
>
{error}
</div>
)}
{result && <Report result={result} />}
</main>
);
}
+93
View File
@@ -0,0 +1,93 @@
"use client";
import {
LineChart,
Line,
XAxis,
YAxis,
Tooltip,
ReferenceLine,
ResponsiveContainer,
} from "recharts";
import type { PricePoint } from "@/lib/pipeline";
import type { Evaluation } from "@/lib/types";
export function PriceChart({
series,
evaluation,
}: {
series: PricePoint[];
evaluation: Evaluation;
}) {
if (!series || series.length === 0) {
return <p className="text-sm" style={{ color: "var(--muted)" }}>Price history unavailable.</p>;
}
const entryLows = evaluation.entryBands.map((b) => b.low);
const exits = evaluation.exitTargets.map((x) => x.price);
const stops = evaluation.stopLosses.map((s) => s.price);
return (
<div style={{ width: "100%", height: 320 }}>
<ResponsiveContainer>
<LineChart data={series} margin={{ top: 8, right: 16, bottom: 8, left: 8 }}>
<XAxis
dataKey="date"
tick={{ fontSize: 11, fill: "var(--muted)" }}
minTickGap={40}
/>
<YAxis
domain={["auto", "auto"]}
tick={{ fontSize: 11, fill: "var(--muted)" }}
width={56}
tickFormatter={(v) => `$${Math.round(v)}`}
/>
<Tooltip
contentStyle={{
background: "var(--panel)",
border: "1px solid var(--border)",
borderRadius: 8,
color: "var(--fg)",
fontSize: 12,
}}
formatter={(v: number) => [`$${v.toFixed(2)}`, "Close"]}
/>
<Line
type="monotone"
dataKey="close"
stroke="var(--accent)"
strokeWidth={2}
dot={false}
/>
{entryLows.map((y, i) => (
<ReferenceLine
key={`entry-${i}`}
y={y}
stroke="var(--pos)"
strokeDasharray="4 3"
label={{ value: `entry ${y}`, position: "insideLeft", fontSize: 10, fill: "var(--pos)" }}
/>
))}
{exits.map((y, i) => (
<ReferenceLine
key={`exit-${i}`}
y={y}
stroke="var(--muted)"
strokeDasharray="2 2"
label={{ value: `target ${y}`, position: "insideLeft", fontSize: 10, fill: "var(--muted)" }}
/>
))}
{stops.map((y, i) => (
<ReferenceLine
key={`stop-${i}`}
y={y}
stroke="var(--neg)"
strokeDasharray="4 3"
label={{ value: `stop ${y}`, position: "insideLeft", fontSize: 10, fill: "var(--neg)" }}
/>
))}
</LineChart>
</ResponsiveContainer>
</div>
);
}
+322
View File
@@ -0,0 +1,322 @@
"use client";
import type { EvaluateResult } from "@/lib/pipeline";
import type { Evaluation } from "@/lib/types";
import { fmtNum, fmtUsd, fmtPct, fmtBig } from "@/lib/format";
import { PriceChart } from "./PriceChart";
function Section({ title, children }: { title: string; children: React.ReactNode }) {
return (
<section
style={{
border: "1px solid var(--border)",
background: "var(--panel)",
borderRadius: 12,
padding: 16,
marginBottom: 16,
}}
>
<h2 style={{ fontSize: 14, fontWeight: 600, marginBottom: 10, letterSpacing: 0.2 }}>
{title}
</h2>
{children}
</section>
);
}
function Stat({ label, value }: { label: string; value: string }) {
return (
<div>
<div style={{ fontSize: 11, color: "var(--muted)" }}>{label}</div>
<div style={{ fontSize: 16, fontWeight: 600 }}>{value}</div>
</div>
);
}
function prose(text: string) {
return (
<p style={{ fontSize: 14, lineHeight: 1.6, whiteSpace: "pre-wrap" }}>{text}</p>
);
}
export function Report({ result }: { result: EvaluateResult }) {
const e = result.evaluation as Evaluation;
const n = result.narrative;
return (
<div>
<div style={{ display: "flex", alignItems: "baseline", gap: 10, marginBottom: 4 }}>
<h1 style={{ fontSize: 22, fontWeight: 700 }}>{e.ticker}</h1>
<span style={{ color: "var(--muted)" }}>{e.name}</span>
<span
style={{
fontSize: 11,
textTransform: "uppercase",
color: "var(--muted)",
border: "1px solid var(--border)",
borderRadius: 6,
padding: "1px 6px",
}}
>
{e.instrumentType}
</span>
</div>
{result.dataWarnings.length > 0 && (
<p style={{ fontSize: 12, color: "var(--muted)", marginBottom: 8 }}>
Data notes: {result.dataWarnings.join("; ")}.
</p>
)}
{!e.supported && (
<Section title="Not yet supported">
{prose(e.unsupportedReason ?? "This instrument type is not supported yet.")}
</Section>
)}
{e.standing && (
<Section title="Current standing">
<div
style={{
display: "grid",
gridTemplateColumns: "repeat(auto-fit, minmax(120px, 1fr))",
gap: 12,
}}
>
<Stat label="Price" value={fmtUsd(e.standing.price)} />
<Stat
label="Day change"
value={`${fmtUsd(e.standing.dayChangeAbs)} (${fmtPct(e.standing.dayChangePct)})`}
/>
<Stat label="Volume vs avg" value={e.standing.volumeMultiple !== null ? `${fmtNum(e.standing.volumeMultiple)}×` : "—"} />
<Stat label="From 52w high" value={fmtPct(e.standing.pctFrom52High)} />
<Stat label="From 52w low" value={fmtPct(e.standing.pctFrom52Low)} />
<Stat label="Market cap" value={fmtBig(e.standing.marketCap)} />
<Stat label="Trailing P/E" value={fmtNum(e.standing.trailingPe)} />
</div>
</Section>
)}
{result.priceSeries && result.priceSeries.length > 0 && (
<Section title="Price & levels">
<PriceChart series={result.priceSeries} evaluation={e} />
</Section>
)}
{/* Agent narrative (or its unavailable/error state). */}
{n && n.available && n.sections && (
<>
<Section title="Thesis">{prose(n.sections.summary)}</Section>
<Section title="Valuation">{prose(n.sections.valuation)}</Section>
<Section title="Macro factors">{prose(n.sections.macro)}</Section>
<Section title="Timing">{prose(n.sections.timing)}</Section>
<Section title="Entry / exit / stop">{prose(n.sections.entryExitStop)}</Section>
<Section title="Bull vs bear">{prose(n.sections.bullBear)}</Section>
<Section title="Plan">{prose(n.sections.plan)}</Section>
</>
)}
{n && !n.available && (
<Section title="Written analysis unavailable">
{prose(n.unavailableReason ?? "The analysis agent is unavailable.")}
</Section>
)}
{n && n.available && n.error && (
<Section title="Written analysis error">
{prose(`${n.error}\n\nThe computed evaluation below is still valid.`)}
</Section>
)}
{/* Computed structured sections (always present for supported equities). */}
{e.equity?.earningsRecap && (
<Section title="Earnings recap">
<table style={{ width: "100%", fontSize: 13, borderCollapse: "collapse" }}>
<tbody>
<Row k={`EPS (${e.equity.earningsRecap.fiscalPeriod})`} v={fmtUsd(e.equity.earningsRecap.epsActual)} />
<Row k="Consensus" v={fmtUsd(e.equity.earningsRecap.epsConsensus)} />
<Row k="Beat / miss" v={fmtUsd(e.equity.earningsRecap.epsBeatMiss)} />
<Row k="Net income" v={fmtBig(e.equity.earningsRecap.netIncome)} />
<Row k="Net income YoY" v={fmtPct(e.equity.earningsRecap.netIncomeYoY)} />
</tbody>
</table>
{e.equity.earningsRecap.guidanceChange && (
<p style={{ fontSize: 13, marginTop: 8, color: "var(--muted)" }}>
{e.equity.earningsRecap.guidanceChange}
</p>
)}
</Section>
)}
{e.equity && e.equity.qualityOfEarnings.length > 0 && (
<Section title="Quality-of-earnings caveats">
<ul style={{ fontSize: 13, lineHeight: 1.5, paddingLeft: 18 }}>
{e.equity.qualityOfEarnings.map((q, i) => (
<li key={i}>{q.note}</li>
))}
</ul>
</Section>
)}
{e.equity && e.equity.segments.length > 0 && (
<Section title="Segment breakdown">
<div style={{ overflowX: "auto" }}>
<table style={{ width: "100%", fontSize: 13, borderCollapse: "collapse" }}>
<thead>
<tr style={{ color: "var(--muted)", textAlign: "right" }}>
<th style={{ textAlign: "left", padding: "4px 8px" }}>Segment</th>
<th style={{ padding: "4px 8px" }}>Revenue</th>
<th style={{ padding: "4px 8px" }}>YoY</th>
<th style={{ padding: "4px 8px" }}>Op margin</th>
<th style={{ padding: "4px 8px" }}>Prior margin</th>
</tr>
</thead>
<tbody>
{e.equity.segments.map((s, i) => (
<tr key={i} style={{ textAlign: "right", borderTop: "1px solid var(--border)" }}>
<td style={{ textAlign: "left", padding: "4px 8px" }}>{s.segment}</td>
<td style={{ padding: "4px 8px" }}>{fmtBig(s.revenue)}</td>
<td style={{ padding: "4px 8px" }}>{fmtPct(s.revenueYoY)}</td>
<td style={{ padding: "4px 8px" }}>{fmtPct(s.operatingMargin)}</td>
<td style={{ padding: "4px 8px" }}>{fmtPct(s.priorYearMargin)}</td>
</tr>
))}
</tbody>
</table>
</div>
</Section>
)}
{e.equity?.valuation && (
<Section title="Valuation">
<div
style={{
display: "grid",
gridTemplateColumns: "repeat(auto-fit, minmax(120px, 1fr))",
gap: 12,
}}
>
<Stat label="TTM EPS" value={fmtUsd(e.equity.valuation.ttmDilutedEps)} />
<Stat label="Trailing P/E" value={fmtNum(e.equity.valuation.trailingPe)} />
<Stat label="Forward EPS" value={fmtUsd(e.equity.valuation.forwardEps)} />
<Stat label="Forward P/E" value={fmtNum(e.equity.valuation.forwardPe)} />
<Stat label="FCF yield" value={fmtPct(e.equity.valuation.fcfYield)} />
<Stat label="Dividend yield" value={fmtPct(e.equity.valuation.dividendYield)} />
</div>
</Section>
)}
{e.entryLevels.length > 0 && (
<Section title="Entry levels">
<div style={{ overflowX: "auto" }}>
<table style={{ width: "100%", fontSize: 13, borderCollapse: "collapse" }}>
<thead>
<tr style={{ color: "var(--muted)", textAlign: "right" }}>
<th style={{ textAlign: "left", padding: "4px 8px" }}>Level</th>
<th style={{ textAlign: "left", padding: "4px 8px" }}>What</th>
<th style={{ padding: "4px 8px" }}>Below</th>
<th style={{ padding: "4px 8px" }}>Trailing P/E</th>
<th style={{ padding: "4px 8px" }}>Fwd P/E</th>
</tr>
</thead>
<tbody>
{e.entryLevels.map((l, i) => (
<tr key={i} style={{ textAlign: "right", borderTop: "1px solid var(--border)" }}>
<td style={{ textAlign: "left", padding: "4px 8px" }}>{fmtUsd(l.price)}</td>
<td style={{ textAlign: "left", padding: "4px 8px", color: "var(--muted)" }}>{l.meaning}</td>
<td style={{ padding: "4px 8px" }}>{fmtPct(l.pctBelowCurrent)}</td>
<td style={{ padding: "4px 8px" }}>{fmtNum(l.impliedTrailingPe)}</td>
<td style={{ padding: "4px 8px" }}>{fmtNum(l.impliedForwardPe)}</td>
</tr>
))}
</tbody>
</table>
</div>
</Section>
)}
{(e.exitTargets.length > 0 || e.stopLosses.length > 0) && (
<Section title="Exits & stops">
<ul style={{ fontSize: 13, lineHeight: 1.6, paddingLeft: 18 }}>
{e.exitTargets.map((x, i) => (
<li key={`x-${i}`}>
Target {fmtUsd(x.price)} ({fmtPct(x.upsidePct)}) {x.basis}
</li>
))}
{e.stopLosses.map((s, i) => (
<li key={`s-${i}`} style={{ color: "var(--neg)" }}>
Stop {fmtUsd(s.price)} {s.basis}
</li>
))}
</ul>
</Section>
)}
{(e.bullCase.length > 0 || e.bearCase.length > 0) && (
<Section title="Bull vs bear (computed)">
<div style={{ display: "grid", gridTemplateColumns: "1fr 1fr", gap: 16 }}>
<div>
<div style={{ fontSize: 12, color: "var(--pos)", marginBottom: 4 }}>Bull</div>
<ul style={{ fontSize: 13, lineHeight: 1.5, paddingLeft: 18 }}>
{e.bullCase.map((b, i) => <li key={i}>{b}</li>)}
</ul>
</div>
<div>
<div style={{ fontSize: 12, color: "var(--neg)", marginBottom: 4 }}>Bear</div>
<ul style={{ fontSize: 13, lineHeight: 1.5, paddingLeft: 18 }}>
{e.bearCase.map((b, i) => <li key={i}>{b}</li>)}
</ul>
</div>
</div>
</Section>
)}
{e.plan && (
<Section title="Plan (computed)">
<ul style={{ fontSize: 13, lineHeight: 1.6, paddingLeft: 18 }}>
{e.plan.tranches.map((t, i) => <li key={i}>{t}</li>)}
{e.plan.conditionalRules.map((r, i) => <li key={`r-${i}`} style={{ color: "var(--muted)" }}>{r}</li>)}
{e.plan.metricToWatch && <li>Watch: {e.plan.metricToWatch}.</li>}
</ul>
</Section>
)}
{n && n.available && n.sources.length > 0 && (
<Section title="Sources">
<ul style={{ fontSize: 12, lineHeight: 1.5, paddingLeft: 18 }}>
{n.sources.map((s, i) => (
<li key={i}>
{s.url ? (
<a href={s.url} target="_blank" rel="noreferrer" style={{ color: "var(--accent)" }}>
{s.title}
</a>
) : (
s.title
)}
</li>
))}
</ul>
</Section>
)}
<div style={{ display: "flex", justifyContent: "space-between", fontSize: 12, color: "var(--muted)" }}>
<span>{e.disclaimer}</span>
{result.cost && (
<span>
Report cost: {result.cost.priced ? fmtUsd(result.cost.costUsd, 4) : "unpriced"}
{result.budget && result.budget.budget > 0 && (
<> · Month: {fmtUsd(result.budget.spentThisMonth)} / {fmtUsd(result.budget.budget)}</>
)}
</span>
)}
</div>
</div>
);
}
function Row({ k, v }: { k: string; v: string }) {
return (
<tr style={{ borderTop: "1px solid var(--border)" }}>
<td style={{ padding: "4px 8px", color: "var(--muted)" }}>{k}</td>
<td style={{ padding: "4px 8px", textAlign: "right", fontWeight: 600 }}>{v}</td>
</tr>
);
}
+69
View File
@@ -0,0 +1,69 @@
import { describe, it, expect } from "vitest";
import { parseNarrative } from "./claude-cli";
import { AnthropicApiAgent } from "./anthropic-api";
import { modelAlias, unavailableNarrative } from "./agent";
import { evaluate } from "@/lib/evaluation/evaluate";
import { FixtureProvider } from "@/lib/market-data/fixture";
describe("modelAlias", () => {
it("uses opus by default and fable for deep dive", () => {
expect(modelAlias({})).toBe("opus");
expect(modelAlias({ deepDive: true })).toBe("fable");
});
});
describe("parseNarrative", () => {
it("parses a clean JSON narrative", () => {
const raw = JSON.stringify({
sections: {
summary: "s",
valuation: "v",
macro: "m",
timing: "t",
entryExitStop: "e",
bullBear: "b",
plan: "p",
},
sources: [{ title: "MarketBeat", url: "https://x" }],
});
const { sections, sources } = parseNarrative(raw);
expect(sections?.summary).toBe("s");
expect(sources[0].title).toBe("MarketBeat");
});
it("tolerates a fenced code block", () => {
const raw = "```json\n" + JSON.stringify({ sections: { summary: "hi" }, sources: [] }) + "\n```";
const { sections } = parseNarrative(raw);
expect(sections?.summary).toBe("hi");
});
it("returns null sections on garbage", () => {
expect(parseNarrative("not json").sections).toBeNull();
});
});
describe("AnthropicApiAgent — no key", () => {
it("degrades gracefully without a key", async () => {
const agent = new AnthropicApiAgent(null);
expect(await agent.isAvailable()).toBe(false);
const p = new FixtureProvider();
const e = evaluate({
profile: (await p.resolve("DE")).profile!,
fundamentals: await p.fundamentals("DE"),
priceHistory: await p.priceHistory("DE"),
coverage: await p.analystCoverage("DE"),
estimates: await p.estimates("DE"),
});
const narrative = await agent.analyze(e, {});
expect(narrative.available).toBe(false);
expect(narrative.unavailableReason).toMatch(/api key/i);
});
});
describe("unavailableNarrative", () => {
it("carries the reason and no usage", () => {
const n = unavailableNarrative("nope");
expect(n.available).toBe(false);
expect(n.usage).toBeNull();
});
});
+125
View File
@@ -0,0 +1,125 @@
import type { Evaluation } from "@/lib/types";
import { DISCLAIMER } from "@/lib/types";
export interface AgentOptions {
/** Escalate to the deep-dive model (Fable 5) for this request. */
deepDive?: boolean;
}
export interface AgentUsage {
model: string | null;
inputTokens: number | null;
outputTokens: number | null;
/** Reported cost. May be ~0 on subscription-backed transports. */
costUsd: number | null;
}
export interface AgentSource {
title: string;
url: string | null;
}
export interface NarrativeSections {
summary: string;
valuation: string;
macro: string;
timing: string;
entryExitStop: string;
bullBear: string;
plan: string;
}
export interface AgentNarrative {
available: boolean;
/** Present when available is false — how to enable the agent. */
unavailableReason?: string;
sections?: NarrativeSections;
sources: AgentSource[];
/** True when live web grounding could not run; narrative is still produced. */
groundingUnavailable?: boolean;
usage: AgentUsage | null;
/** Present when the agent errored but the structured evaluation is retained. */
error?: string;
}
export interface AnalysisAgent {
readonly name: string;
isAvailable(): Promise<boolean>;
analyze(evaluation: Evaluation, opts: AgentOptions): Promise<AgentNarrative>;
}
/** Model alias per request: opus by default, fable for deep dives. */
export function modelAlias(opts: AgentOptions): "opus" | "fable" {
return opts.deepDive ? "fable" : "opus";
}
export const NARRATIVE_SCHEMA = {
type: "object",
additionalProperties: false,
properties: {
sections: {
type: "object",
additionalProperties: false,
properties: {
summary: { type: "string" },
valuation: { type: "string" },
macro: { type: "string" },
timing: { type: "string" },
entryExitStop: { type: "string" },
bullBear: { type: "string" },
plan: { type: "string" },
},
required: [
"summary",
"valuation",
"macro",
"timing",
"entryExitStop",
"bullBear",
"plan",
],
},
sources: {
type: "array",
items: {
type: "object",
additionalProperties: false,
properties: {
title: { type: "string" },
url: { type: ["string", "null"] },
},
required: ["title", "url"],
},
},
},
required: ["sections", "sources"],
} as const;
export function systemInstructions(): string {
return [
"You are a rigorous sell-side equity analyst writing a thorough single-stock evaluation.",
"You receive a STRUCTURED EVALUATION object of computed facts for one NYSE/Nasdaq equity.",
"Rules:",
"- Use ONLY figures present in the structured evaluation OR facts you retrieve via web search/fetch and attribute to a source. NEVER invent figures.",
"- Preserve every quality-of-earnings caveat, one-time item, and estimate discrepancy flagged in the input.",
"- Use web search/fetch to source what the structured data lacks: macro/sector factors (tariffs, rates, input-cost pressure), analyst rating and price-target changes, management commentary, peer read-throughs, and the next DATED catalyst. Scope searches to this ticker and its sector.",
"- Record every web-sourced fact's source in the `sources` array.",
`- End the plan section with the disclaimer: "${DISCLAIMER}"`,
"- Write in clear, direct prose. Lead each section with the conclusion. Be specific and quantitative.",
"Return output conforming to the provided JSON schema (sections + sources).",
].join("\n");
}
export function userPrompt(evaluation: Evaluation): string {
return [
`Structured evaluation for ${evaluation.ticker} (${evaluation.name}):`,
"```json",
JSON.stringify(evaluation, null, 2),
"```",
"Write the analysis. Ground macro factors, analyst positioning, and the next dated catalyst with web search, attributing sources.",
].join("\n");
}
export function unavailableNarrative(reason: string): AgentNarrative {
return { available: false, unavailableReason: reason, sources: [], usage: null };
}
+107
View File
@@ -0,0 +1,107 @@
import Anthropic from "@anthropic-ai/sdk";
import type { Evaluation } from "@/lib/types";
import {
AnalysisAgent,
AgentNarrative,
AgentOptions,
systemInstructions,
userPrompt,
unavailableNarrative,
} from "./agent";
import { parseNarrative } from "./claude-cli";
// Alternate transport: the Anthropic API (metered). Selected via AGENT_TRANSPORT=api.
// Use this when the app serves external users; subscription-backed CLI is not
// appropriate then. Notes (per the SDK reference): adaptive thinking, no
// budget_tokens/sampling params; Fable 5 thinking-always-on + refusal fallback to
// Opus 4.8; stream for the long output an evaluation produces.
const MODEL = { opus: "claude-opus-4-8", fable: "claude-fable-5" } as const;
export class AnthropicApiAgent implements AnalysisAgent {
readonly name = "anthropic-api";
private client: Anthropic | null;
constructor(apiKey: string | null) {
this.client = apiKey ? new Anthropic({ apiKey }) : null;
}
async isAvailable(): Promise<boolean> {
return this.client !== null;
}
async analyze(evaluation: Evaluation, opts: AgentOptions): Promise<AgentNarrative> {
if (!this.client) {
return unavailableNarrative(
"No Anthropic API key configured. Set ANTHROPIC_API_KEY, or use the default Claude Code CLI transport (AGENT_TRANSPORT=claude-cli).",
);
}
const model = opts.deepDive ? MODEL.fable : MODEL.opus;
// Server-side web tools for grounding (types vary by SDK version; kept loose).
const tools = [
{ type: "web_search_20260209", name: "web_search", max_uses: 12 },
{ type: "web_fetch_20260209", name: "web_fetch", max_uses: 8 },
];
const req: Record<string, unknown> = {
model,
max_tokens: 32000,
thinking: { type: "adaptive" },
system: systemInstructions() + "\nReturn ONLY the JSON object, no prose around it.",
tools,
messages: [{ role: "user", content: userPrompt(evaluation) }],
};
if (opts.deepDive) {
req.betas = ["server-side-fallback-2026-06-01"];
req.fallbacks = [{ model: MODEL.opus }];
}
try {
const anyClient = this.client as unknown as {
messages: { stream: (r: unknown) => { finalMessage: () => Promise<unknown> } };
beta?: { messages: { stream: (r: unknown) => { finalMessage: () => Promise<unknown> } } };
};
const runner = opts.deepDive && anyClient.beta
? anyClient.beta.messages.stream(req)
: anyClient.messages.stream(req);
const msg = (await runner.finalMessage()) as {
content?: { type: string; text?: string }[];
stop_reason?: string;
usage?: { input_tokens?: number; output_tokens?: number };
};
if (msg.stop_reason === "refusal") {
return {
available: true,
sources: [],
usage: { model, inputTokens: null, outputTokens: null, costUsd: null },
error: "The model declined this request (safety refusal).",
};
}
const text = (msg.content ?? [])
.filter((b) => b.type === "text" && b.text)
.map((b) => b.text as string)
.join("\n");
const { sections, sources } = parseNarrative(text);
const usage = {
model,
inputTokens: msg.usage?.input_tokens ?? null,
outputTokens: msg.usage?.output_tokens ?? null,
costUsd: null, // computed by cost-controls from tokens + price table
};
if (!sections) {
return { available: true, sources: [], usage, error: "No parseable narrative returned." };
}
return { available: true, sections, sources, usage };
} catch (e) {
return {
available: true,
sources: [],
usage: null,
error: `Anthropic API error: ${String(e)}`,
};
}
}
}
+178
View File
@@ -0,0 +1,178 @@
import { spawn } from "node:child_process";
import type { Evaluation } from "@/lib/types";
import {
AnalysisAgent,
AgentNarrative,
AgentOptions,
NARRATIVE_SCHEMA,
NarrativeSections,
AgentSource,
modelAlias,
systemInstructions,
userPrompt,
unavailableNarrative,
} from "./agent";
// Default transport: the local Claude Code CLI in headless mode, running on the
// operator's existing Claude auth (e.g. subscription). Verified flags:
// claude -p <prompt> --model <opus|fable> --output-format json
// --json-schema <schema> --append-system-prompt <instructions>
// --allowedTools "WebSearch WebFetch" --permission-mode dontAsk
// The JSON result carries `result` (schema-conformant), `total_cost_usd`, `usage`.
interface CliResult {
is_error?: boolean;
result?: string;
total_cost_usd?: number;
usage?: { input_tokens?: number; output_tokens?: number };
modelUsage?: Record<string, unknown>;
subtype?: string;
}
function run(
args: string[],
input: string,
timeoutMs: number,
): Promise<{ code: number; stdout: string; stderr: string }> {
return new Promise((resolve, reject) => {
const child = spawn("claude", args, { stdio: ["pipe", "pipe", "pipe"] });
let stdout = "";
let stderr = "";
const timer = setTimeout(() => {
child.kill("SIGKILL");
reject(new Error(`claude CLI timed out after ${timeoutMs}ms`));
}, timeoutMs);
child.stdout.on("data", (d) => (stdout += d.toString()));
child.stderr.on("data", (d) => (stderr += d.toString()));
child.on("error", (e) => {
clearTimeout(timer);
reject(e);
});
child.on("close", (code) => {
clearTimeout(timer);
resolve({ code: code ?? -1, stdout, stderr });
});
child.stdin.write(input);
child.stdin.end();
});
}
export class ClaudeCliAgent implements AnalysisAgent {
readonly name = "claude-cli";
constructor(private timeoutMs = 10 * 60 * 1000) {}
async isAvailable(): Promise<boolean> {
try {
const { code } = await run(["--version"], "", 15000);
return code === 0;
} catch {
return false;
}
}
async analyze(evaluation: Evaluation, opts: AgentOptions): Promise<AgentNarrative> {
const model = modelAlias(opts);
const args = [
"-p",
"--model",
model,
"--output-format",
"json",
"--json-schema",
JSON.stringify(NARRATIVE_SCHEMA),
"--append-system-prompt",
systemInstructions(),
"--allowedTools",
"WebSearch WebFetch",
"--permission-mode",
"dontAsk",
];
let out: { code: number; stdout: string; stderr: string };
try {
out = await run(args, userPrompt(evaluation), this.timeoutMs);
} catch (e) {
return {
available: true,
sources: [],
usage: null,
error: `Claude Code CLI failed to run: ${String(e)}`,
};
}
if (out.code !== 0) {
const auth = /not.*(logged in|authenticated)|auth/i.test(out.stderr);
if (auth) {
return unavailableNarrative(
"Claude Code CLI is not authenticated. Run `claude` and log in, or set AGENT_TRANSPORT=api with an API key.",
);
}
return {
available: true,
sources: [],
usage: null,
error: `Claude Code CLI exited ${out.code}: ${out.stderr.slice(0, 400)}`,
};
}
let parsed: CliResult;
try {
parsed = JSON.parse(out.stdout) as CliResult;
} catch {
return {
available: true,
sources: [],
usage: null,
error: "Could not parse Claude Code CLI JSON output.",
};
}
const usage = {
model,
inputTokens: parsed.usage?.input_tokens ?? null,
outputTokens: parsed.usage?.output_tokens ?? null,
costUsd: typeof parsed.total_cost_usd === "number" ? parsed.total_cost_usd : null,
};
// `result` should be JSON conforming to NARRATIVE_SCHEMA.
const { sections, sources } = parseNarrative(parsed.result ?? "");
if (!sections) {
return {
available: true,
sources: [],
usage,
error: "Claude Code CLI returned no parseable narrative.",
};
}
return { available: true, sections, sources, usage };
}
}
export function parseNarrative(raw: string): {
sections: NarrativeSections | null;
sources: AgentSource[];
} {
const text = raw.trim();
let obj: unknown;
try {
obj = JSON.parse(text);
} catch {
// Tolerate a fenced ```json block.
const m = text.match(/```(?:json)?\s*([\s\S]*?)```/);
if (m) {
try {
obj = JSON.parse(m[1]);
} catch {
return { sections: null, sources: [] };
}
} else {
return { sections: null, sources: [] };
}
}
const o = obj as { sections?: NarrativeSections; sources?: AgentSource[] };
if (!o || typeof o !== "object" || !o.sections) return { sections: null, sources: [] };
const sources = Array.isArray(o.sources)
? o.sources.map((s) => ({ title: String(s.title ?? ""), url: s.url ?? null }))
: [];
return { sections: o.sections, sources };
}
+14
View File
@@ -0,0 +1,14 @@
import type { AppConfig } from "@/lib/config";
import { AnalysisAgent } from "./agent";
import { ClaudeCliAgent } from "./claude-cli";
import { AnthropicApiAgent } from "./anthropic-api";
/** Select the agent transport from config. Default: Claude Code CLI. */
export function getAgent(config: AppConfig): AnalysisAgent {
if (config.agentTransport === "api") {
return new AnthropicApiAgent(config.anthropicApiKey);
}
return new ClaudeCliAgent();
}
export * from "./agent";
+25
View File
@@ -0,0 +1,25 @@
// Central server-side configuration. All secrets are read here and never exposed
// to the client bundle (this module must only be imported from server code).
export type AgentTransport = "claude-cli" | "api";
export type MarketDataProviderName = "fixture" | "fmp";
export interface AppConfig {
agentTransport: AgentTransport;
anthropicApiKey: string | null;
marketDataProvider: MarketDataProviderName;
marketDataApiKey: string | null;
monthlyBudgetUsd: number;
}
export function loadConfig(): AppConfig {
const transport = (process.env.AGENT_TRANSPORT ?? "claude-cli") as AgentTransport;
const provider = (process.env.MARKET_DATA_PROVIDER ?? "fixture") as MarketDataProviderName;
return {
agentTransport: transport === "api" ? "api" : "claude-cli",
anthropicApiKey: process.env.ANTHROPIC_API_KEY || null,
marketDataProvider: provider === "fmp" ? "fmp" : "fixture",
marketDataApiKey: process.env.MARKET_DATA_API_KEY || null,
monthlyBudgetUsd: Number(process.env.MONTHLY_BUDGET_USD ?? "0") || 0,
};
}
+79
View File
@@ -0,0 +1,79 @@
import { describe, it, expect } from "vitest";
import { computeReportCost } from "./pricing";
import { MemorySpendStore, checkBudget, monthToDate } from "./spend";
import type { ReportCostRecord } from "./spend";
describe("computeReportCost", () => {
it("prefers a transport-reported cost (e.g. CLI total_cost_usd)", () => {
const r = computeReportCost({ model: "opus", inputTokens: 100000, outputTokens: 10000, costUsd: 0 });
expect(r.priced).toBe(true);
expect(r.costUsd).toBe(0); // subscription-reported zero is authoritative
});
it("computes from tokens + table when no reported cost", () => {
const r = computeReportCost(
{ model: "claude-opus-4-8", inputTokens: 1_000_000, outputTokens: 1_000_000, costUsd: null },
10,
);
// 1M in @ $5 + 1M out @ $25 + 10 searches @ $0.01 = 30.10
expect(r.priced).toBe(true);
expect(r.costUsd).toBeCloseTo(30.1, 2);
});
it("flags an unpriced model rather than recording zero as priced", () => {
const r = computeReportCost({ model: "mystery", inputTokens: 100, outputTokens: 100, costUsd: null });
expect(r.priced).toBe(false);
});
});
function rec(costUsd: number, at: string): ReportCostRecord {
return { ticker: "DE", model: "opus", costUsd, priced: true, at };
}
describe("monthToDate", () => {
it("sums only the current month", async () => {
const store = new MemorySpendStore();
await store.append(rec(2, "2026-08-01T00:00:00Z"));
await store.append(rec(3, "2026-08-15T00:00:00Z"));
await store.append(rec(9, "2026-07-31T00:00:00Z")); // prior month
const total = await monthToDate(store, new Date("2026-08-21T00:00:00Z"));
expect(total).toBe(5);
});
});
describe("checkBudget", () => {
const now = new Date("2026-08-21T00:00:00Z");
it("is disabled when budget is 0", async () => {
const store = new MemorySpendStore();
const g = await checkBudget(store, { budgetUsd: 0 }, now);
expect(g.allowed).toBe(true);
expect(g.warn).toBe(false);
expect(g.remaining).toBeNull();
});
it("warns past the soft threshold", async () => {
const store = new MemorySpendStore();
await store.append(rec(85, "2026-08-10T00:00:00Z"));
const g = await checkBudget(store, { budgetUsd: 100 }, now);
expect(g.allowed).toBe(true);
expect(g.warn).toBe(true);
expect(g.message).toMatch(/budget used/i);
});
it("blocks at the hard cap", async () => {
const store = new MemorySpendStore();
await store.append(rec(100, "2026-08-10T00:00:00Z"));
const g = await checkBudget(store, { budgetUsd: 100 }, now);
expect(g.allowed).toBe(false);
expect(g.message).toMatch(/reached/i);
});
it("pre-empts a deep dive that would exceed remaining budget", async () => {
const store = new MemorySpendStore();
await store.append(rec(97, "2026-08-10T00:00:00Z"));
const g = await checkBudget(store, { budgetUsd: 100, projectedCostUsd: 5 }, now);
expect(g.allowed).toBe(false);
expect(g.message).toMatch(/deep dive/i);
});
});
+48
View File
@@ -0,0 +1,48 @@
import type { AgentUsage } from "@/lib/agent/agent";
// Configurable price table. Editing these values (or overriding via a future
// config source) reprices without code changes elsewhere. Prices are USD per
// 1M tokens, plus a per-web-search price.
export interface ModelPrice {
inputPerM: number;
outputPerM: number;
cacheReadPerM: number;
}
export const PRICE_TABLE: Record<string, ModelPrice> = {
// Opus 4.8
"claude-opus-4-8": { inputPerM: 5, outputPerM: 25, cacheReadPerM: 0.5 },
opus: { inputPerM: 5, outputPerM: 25, cacheReadPerM: 0.5 },
// Fable 5
"claude-fable-5": { inputPerM: 10, outputPerM: 50, cacheReadPerM: 1 },
fable: { inputPerM: 10, outputPerM: 50, cacheReadPerM: 1 },
};
export const WEB_SEARCH_PRICE_USD = 0.01; // ~$10 per 1,000 searches (verify on pricing page)
export interface CostResult {
costUsd: number;
priced: boolean; // false when the model is absent from the table
}
/**
* Compute a report's cost. If the transport already reported a cost (e.g. the
* Claude Code CLI's total_cost_usd, which may be ~0 on a subscription), that is
* authoritative. Otherwise compute from tokens + the price table.
*/
export function computeReportCost(usage: AgentUsage | null, webSearches = 0): CostResult {
if (!usage) return { costUsd: 0, priced: false };
if (usage.costUsd !== null) {
return { costUsd: usage.costUsd, priced: true };
}
const model = usage.model ?? "";
const price = PRICE_TABLE[model];
if (!price) {
return { costUsd: 0, priced: false };
}
const input = ((usage.inputTokens ?? 0) / 1_000_000) * price.inputPerM;
const output = ((usage.outputTokens ?? 0) / 1_000_000) * price.outputPerM;
const search = webSearches * WEB_SEARCH_PRICE_USD;
return { costUsd: input + output + search, priced: true };
}
+136
View File
@@ -0,0 +1,136 @@
import { promises as fs } from "node:fs";
import path from "node:path";
// Lightweight spend tracking + monthly budget guard. Storage is a local JSON
// file (no database — consistent with the no-persistence scope). The store is
// injectable so tests can use an in-memory implementation.
export interface ReportCostRecord {
ticker: string;
model: string | null;
costUsd: number;
priced: boolean;
at: string; // ISO timestamp
}
export interface SpendStore {
append(record: ReportCostRecord): Promise<void>;
all(): Promise<ReportCostRecord[]>;
}
function monthKey(iso: string): string {
return iso.slice(0, 7); // YYYY-MM
}
export async function monthToDate(store: SpendStore, now = new Date()): Promise<number> {
const key = now.toISOString().slice(0, 7);
const records = await store.all();
return records
.filter((r) => monthKey(r.at) === key)
.reduce((sum, r) => sum + r.costUsd, 0);
}
export interface GuardResult {
allowed: boolean;
warn: boolean;
spentThisMonth: number;
budget: number;
remaining: number | null; // null when disabled
message: string | null;
}
export interface GuardOptions {
budgetUsd: number;
softThreshold?: number; // fraction, default 0.8
projectedCostUsd?: number; // for deep-dive pre-emption
}
/** Check the monthly budget before dispatching an evaluation. */
export async function checkBudget(
store: SpendStore,
opts: GuardOptions,
now = new Date(),
): Promise<GuardResult> {
const budget = opts.budgetUsd;
const spent = await monthToDate(store, now);
if (!budget || budget <= 0) {
return { allowed: true, warn: false, spentThisMonth: spent, budget: 0, remaining: null, message: null };
}
const remaining = budget - spent;
const soft = opts.softThreshold ?? 0.8;
// Hard cap reached.
if (spent >= budget) {
return {
allowed: false,
warn: true,
spentThisMonth: spent,
budget,
remaining,
message: `Monthly budget of $${budget.toFixed(2)} reached (spent $${spent.toFixed(2)}). Raise MONTHLY_BUDGET_USD or set it to 0 to disable the guard.`,
};
}
// Deep-dive pre-emption: projected cost would exceed remaining budget.
if (opts.projectedCostUsd && spent + opts.projectedCostUsd > budget) {
return {
allowed: false,
warn: true,
spentThisMonth: spent,
budget,
remaining,
message: `This deep dive (est. $${opts.projectedCostUsd.toFixed(2)}) would exceed the remaining monthly budget of $${remaining.toFixed(2)}.`,
};
}
// Soft threshold warning.
if (spent >= soft * budget) {
return {
allowed: true,
warn: true,
spentThisMonth: spent,
budget,
remaining,
message: `Heads up: $${spent.toFixed(2)} of $${budget.toFixed(2)} monthly budget used ($${remaining.toFixed(2)} remaining).`,
};
}
return { allowed: true, warn: false, spentThisMonth: spent, budget, remaining, message: null };
}
// --- Default file-backed store ---
const DATA_DIR = path.join(process.cwd(), ".equitysearch");
const SPEND_FILE = path.join(DATA_DIR, "spend.json");
export class FileSpendStore implements SpendStore {
async append(record: ReportCostRecord): Promise<void> {
const records = await this.all();
records.push(record);
await fs.mkdir(DATA_DIR, { recursive: true });
await fs.writeFile(SPEND_FILE, JSON.stringify(records, null, 2), "utf8");
}
async all(): Promise<ReportCostRecord[]> {
try {
const raw = await fs.readFile(SPEND_FILE, "utf8");
const parsed = JSON.parse(raw);
return Array.isArray(parsed) ? (parsed as ReportCostRecord[]) : [];
} catch {
return [];
}
}
}
/** In-memory store for tests. */
export class MemorySpendStore implements SpendStore {
private records: ReportCostRecord[] = [];
async append(record: ReportCostRecord): Promise<void> {
this.records.push(record);
}
async all(): Promise<ReportCostRecord[]> {
return this.records;
}
}
+104
View File
@@ -0,0 +1,104 @@
import { describe, it, expect } from "vitest";
import { evaluate } from "./evaluate";
import { FixtureProvider } from "@/lib/market-data/fixture";
import type { EvaluationInputs } from "./evaluate";
async function deInputs(): Promise<EvaluationInputs> {
const p = new FixtureProvider();
const resolved = await p.resolve("DE");
return {
profile: resolved.profile!,
fundamentals: await p.fundamentals("DE"),
priceHistory: await p.priceHistory("DE"),
coverage: await p.analystCoverage("DE"),
estimates: await p.estimates("DE"),
};
}
describe("evaluate — DE equity", () => {
it("produces a supported equity evaluation with all sections", async () => {
const evalResult = evaluate(await deInputs());
expect(evalResult.supported).toBe(true);
expect(evalResult.instrumentType).toBe("equity");
expect(evalResult.equity).not.toBeNull();
expect(evalResult.disclaimer).toMatch(/not investment advice/i);
});
it("computes current standing from price + fundamentals", async () => {
const e = evaluate(await deInputs());
expect(e.standing!.price).toBe(620.94);
// trailing P/E = 620.94 / 18.00 = 34.50
expect(e.standing!.trailingPe).toBeCloseTo(34.5, 1);
expect(e.standing!.marketCap).toBeGreaterThan(1.6e11);
});
it("builds the earnings recap with beat magnitude", async () => {
const e = evaluate(await deInputs());
const r = e.equity!.earningsRecap!;
expect(r.epsActual).toBe(5.1);
expect(r.epsConsensus).toBe(4.79);
expect(r.epsBeatMiss).toBeCloseTo(0.31, 2);
});
it("flags the one-time tariff refund in quality-of-earnings", async () => {
const e = evaluate(await deInputs());
const oneTime = e.equity!.qualityOfEarnings.filter((f) => f.kind === "one-time-item");
expect(oneTime.length).toBeGreaterThan(0);
expect(oneTime[0].note).toMatch(/tariff/i);
});
it("computes valuation: forward P/E on the recovery year", async () => {
const e = evaluate(await deInputs());
const v = e.equity!.valuation!;
expect(v.ttmDilutedEps).toBe(18.0);
// forward EPS = FY2027 21.19 -> forward P/E ~29.3
expect(v.forwardEps).toBe(21.19);
expect(v.forwardPe).toBeCloseTo(29.3, 1);
});
it("produces entry levels below price with implied multiples, grouped into bands", async () => {
const e = evaluate(await deInputs());
expect(e.entryLevels.length).toBeGreaterThan(0);
for (const l of e.entryLevels) {
expect(l.price).toBeLessThan(620.94);
expect(l.pctBelowCurrent).toBeLessThan(0);
expect(l.impliedTrailingPe).not.toBeNull();
}
expect(e.entryBands.length).toBeGreaterThan(0);
expect(e.entryBands[0].label).toBe("starter");
});
it("produces exit targets above price and concrete stop-losses", async () => {
const e = evaluate(await deInputs());
expect(e.exitTargets.length).toBeGreaterThan(0);
for (const x of e.exitTargets) expect(x.price).toBeGreaterThan(620.94);
expect(e.stopLosses.length).toBeGreaterThan(0);
for (const s of e.stopLosses) expect(s.price).toBeGreaterThan(0);
});
it("derives bull/bear points and an actionable plan", async () => {
const e = evaluate(await deInputs());
expect(e.bullCase.length).toBeGreaterThan(0);
expect(e.bearCase.length).toBeGreaterThan(0);
expect(e.plan!.tranches.length).toBeGreaterThan(0);
expect(e.plan!.metricToWatch).toMatch(/Precision Ag/i);
});
});
describe("evaluate — instrument gating", () => {
it("returns unsupported for an ETF without fabricating equity sections", async () => {
const p = new FixtureProvider();
const resolved = await p.resolve("SPY");
const e = evaluate({
profile: resolved.profile!,
fundamentals: null,
priceHistory: null,
coverage: await p.analystCoverage("SPY"),
estimates: await p.estimates("SPY"),
});
expect(e.instrumentType).toBe("etf");
expect(e.supported).toBe(false);
expect(e.equity).toBeNull();
expect(e.unsupportedReason).toMatch(/not yet supported/i);
});
});
+439
View File
@@ -0,0 +1,439 @@
import type {
SecurityProfile,
Fundamentals,
PriceHistory,
AnalystCoverage,
Estimates,
TechnicalContext,
Evaluation,
CurrentStanding,
EarningsRecap,
QualityOfEarningsFlag,
FinancialComparison,
ValuationView,
EquityFundamentalsView,
EntryLevel,
EntryBand,
ExitTarget,
StopLoss,
TimingContext,
ActionablePlan,
} from "@/lib/types";
import { DISCLAIMER } from "@/lib/types";
import { computeTechnicals } from "@/lib/technicals/indicators";
export interface EvaluationInputs {
profile: SecurityProfile;
fundamentals: Fundamentals | null;
priceHistory: PriceHistory | null;
coverage: AnalystCoverage;
estimates: Estimates;
/** Days within which the latest quarterly report counts as "recent". */
earningsRecencyDays?: number;
}
const pct = (a: number, b: number): number => a / b - 1;
const round2 = (n: number): number => Math.round(n * 100) / 100;
function forwardEps(estimates: Estimates): { period: string; value: number } | null {
// Prefer the second forward year (the "recovery" year in the example), else first.
const withValues = estimates.forwardEps.filter((e) => e.value !== null) as {
period: string;
value: number;
}[];
if (withValues.length === 0) return null;
return withValues[withValues.length - 1];
}
function buildStanding(
t: TechnicalContext,
profile: SecurityProfile,
fundamentals: Fundamentals | null,
): CurrentStanding {
const price = t.lastPrice;
const ttmEps = fundamentals?.ttm.dilutedEps ?? null;
const marketCap =
price !== null && profile.sharesOutstanding !== null
? price * profile.sharesOutstanding
: null;
const trailingPe = price !== null && ttmEps ? price / ttmEps : null;
return {
price,
dayChangeAbs: t.dayChangeAbs,
dayChangePct: t.dayChangePct,
volumeMultiple: t.volumeMultiple,
pctFrom52High: t.pctFrom52High,
pctFrom52Low: t.pctFrom52Low,
marketCap,
trailingPe: trailingPe !== null ? round2(trailingPe) : null,
};
}
function buildEarningsRecap(
fundamentals: Fundamentals,
asOf: string,
recencyDays: number,
): EarningsRecap | null {
const le = fundamentals.latestEarnings;
if (!le) return null;
if (le.reportDate) {
const ageDays = (Date.parse(asOf) - Date.parse(le.reportDate)) / 86400000;
if (Number.isFinite(ageDays) && ageDays > recencyDays) return null;
}
const beat =
le.epsActual !== null && le.epsConsensus !== null
? round2(le.epsActual - le.epsConsensus)
: null;
return {
fiscalPeriod: le.fiscalPeriod,
epsActual: le.epsActual,
epsConsensus: le.epsConsensus,
epsBeatMiss: beat,
netIncome: le.netIncome,
netIncomeYoY: le.netIncomeYoY,
guidanceChange: le.guidanceChange,
};
}
function buildQualityFlags(fundamentals: Fundamentals): QualityOfEarningsFlag[] {
const flags: QualityOfEarningsFlag[] = [];
for (const item of fundamentals.latestEarnings?.oneTimeItems ?? []) {
flags.push({ kind: "one-time-item", note: item.note, epsImpact: item.epsImpact });
}
// Derive a price-vs-volume / margin-compression flag from segments.
for (const s of fundamentals.segments ?? []) {
if (
s.revenueYoY !== null &&
s.revenueYoY > 0 &&
s.operatingMargin !== null &&
s.priorYearMargin !== null &&
s.operatingMargin < s.priorYearMargin
) {
flags.push({
kind: "price-vs-volume",
note: `${s.segment}: revenue rose ${(s.revenueYoY * 100).toFixed(1)}% but operating margin fell from ${(s.priorYearMargin * 100).toFixed(1)}% to ${(s.operatingMargin * 100).toFixed(1)}% — growth not translating to margin.`,
epsImpact: null,
});
}
}
return flags;
}
function buildFinancials(fundamentals: Fundamentals): FinancialComparison[] {
const [cur, prior] = fundamentals.fiscalYears;
if (!cur) return [];
const cmp = (metric: string, a: number | null, b: number | null): FinancialComparison => ({
metric,
current: a,
priorYear: b ?? null,
yoy: a !== null && b ? round2(pct(a, b)) : null,
});
return [
cmp("Revenue", cur.revenue, prior?.revenue ?? null),
cmp("Operating profit", cur.operatingProfit, prior?.operatingProfit ?? null),
cmp("Net income", cur.netIncome, prior?.netIncome ?? null),
cmp("Diluted EPS", cur.dilutedEps, prior?.dilutedEps ?? null),
];
}
function buildValuation(
standing: CurrentStanding,
fundamentals: Fundamentals,
estimates: Estimates,
): ValuationView | null {
const price = standing.price;
const ttmEps = fundamentals.ttm.dilutedEps;
const fwd = forwardEps(estimates);
const fcf = fundamentals.ttm.freeCashFlow;
const div = fundamentals.ttm.dividendPerShare;
const trailingPe = price !== null && ttmEps ? round2(price / ttmEps) : null;
const forwardPe = price !== null && fwd ? round2(price / fwd.value) : null;
const fcfYield =
fcf !== null && standing.marketCap ? round2(fcf / standing.marketCap * 100) / 100 : null;
const dividendYield = div !== null && price ? round2((div / price) * 100) / 100 : null;
const history = fundamentals.fiscalYears.map((fy) => ({
period: fy.period,
pe: null as number | null, // historical price not modeled; EPS carries the trend
dilutedEps: fy.dilutedEps,
}));
return {
ttmDilutedEps: ttmEps,
trailingPe,
forwardEps: fwd ? fwd.value : null,
forwardPe,
netIncomeGrowth: null,
revenueGrowth: null,
fcfYield,
dividendYield,
history,
estimateDiscrepancy: null,
};
}
function buildEntryLevels(
t: TechnicalContext,
price: number,
ttmEps: number | null,
fwd: number | null,
): EntryLevel[] {
const raw: { price: number; meaning: string }[] = [];
if (t.ma20 !== null) raw.push({ price: t.ma20, meaning: "20-day average" });
if (t.ma50 !== null) raw.push({ price: t.ma50, meaning: "50-day average" });
if (t.priorClose !== null) raw.push({ price: t.priorClose, meaning: "pre-earnings close" });
if (t.ma200 !== null) raw.push({ price: t.ma200, meaning: "200-day average" });
for (const s of t.swingLows) raw.push({ price: s.price, meaning: `swing low (${s.date})` });
return raw
.filter((l) => l.price < price)
.sort((a, b) => b.price - a.price)
.map((l) => ({
price: round2(l.price),
meaning: l.meaning,
pctBelowCurrent: round2(pct(l.price, price)),
impliedTrailingPe: ttmEps ? round2(l.price / ttmEps) : null,
impliedForwardPe: fwd ? round2(l.price / fwd) : null,
}));
}
function buildEntryBands(levels: EntryLevel[]): EntryBand[] {
if (levels.length === 0) return [];
// Cluster levels within 3% of each other into bands.
const bands: { low: number; high: number }[] = [];
let cur = { low: levels[0].price, high: levels[0].price };
for (const l of levels.slice(1)) {
if (cur.low - l.price <= cur.low * 0.03) {
cur.low = Math.min(cur.low, l.price);
cur.high = Math.max(cur.high, l.price);
} else {
bands.push(cur);
cur = { low: l.price, high: l.price };
}
}
bands.push(cur);
return bands.map((b, i) => ({
low: round2(b.low),
high: round2(b.high),
label: i === 0 ? "starter" : i === bands.length - 1 ? "high-conviction" : "add",
rationale:
i === 0
? "Nearest support cluster — a defensible starter tranche."
: "Deeper support — reserve the balance of the position for here.",
}));
}
function buildExitTargets(
coverage: AnalystCoverage,
price: number,
week52High: number | null,
): ExitTarget[] {
const targets: ExitTarget[] = [];
const add = (p: number | null, basis: string) => {
if (p !== null && p > price) {
targets.push({ price: round2(p), basis, upsidePct: round2(pct(p, price)) });
}
};
add(coverage.targetMedian, "analyst median target");
add(coverage.targetAverage, "analyst average target");
add(week52High, "52-week high");
add(coverage.targetHigh, "analyst high target");
return targets;
}
function buildStopLosses(t: TechnicalContext, bands: EntryBand[]): StopLoss[] {
const stops: StopLoss[] = [];
if (bands.length === 0) return stops;
const deepest = bands[bands.length - 1];
// Technical stop: just below the deepest support band.
stops.push({
price: round2(deepest.low * 0.97),
forEntryBand: `${deepest.low}${deepest.high}`,
basis: "3% below the deepest support band — structure break invalidates the thesis.",
});
// Volatility stop: ~1.5 daily sigma below the band low.
if (t.realizedVol60 !== null) {
const dailySigma = t.realizedVol60 / Math.sqrt(252);
stops.push({
price: round2(deepest.low * (1 - 1.5 * dailySigma)),
forEntryBand: `${deepest.low}${deepest.high}`,
basis: `Volatility stop: ~1.5× daily sigma (realized vol ${(t.realizedVol60 * 100).toFixed(0)}% annualized).`,
});
}
return stops;
}
function buildTiming(t: TechnicalContext, candles: PriceHistory["candles"]): TimingContext {
const last = candles[candles.length - 1];
const prior = candles[candles.length - 2];
const gapFilled =
last && prior ? last.low <= prior.close : null;
const rangeStr =
t.week52Low !== null && t.week52High !== null
? `52-week range ${round2(t.week52Low)}${round2(t.week52High)}; realized vol ${
t.realizedVol60 !== null ? (t.realizedVol60 * 100).toFixed(0) + "%" : "n/a"
} annualized.`
: null;
return {
postEarningsPattern: null, // requires historical earnings dates — agent/web territory
impliedMove: null, // requires options data
actualMove: t.dayChangePct,
recentRanges: rangeStr,
gapFilled,
};
}
function buildBullBear(fundamentals: Fundamentals, valuation: ValuationView | null): {
bull: string[];
bear: string[];
} {
const bull: string[] = [];
const bear: string[] = [];
for (const s of fundamentals.segments ?? []) {
if (s.operatingProfitYoY !== null && s.operatingProfitYoY > 0.1) {
bull.push(
`${s.segment} operating profit up ${(s.operatingProfitYoY * 100).toFixed(0)}% YoY.`,
);
}
if (s.revenueYoY !== null && s.revenueYoY < -0.03) {
bear.push(
`${s.segment} revenue down ${(Math.abs(s.revenueYoY) * 100).toFixed(1)}% YoY.`,
);
}
}
const debt = fundamentals.ttm.netDebt;
if (debt !== null && debt > 0) {
bear.push(`Carries roughly $${(debt / 1e9).toFixed(1)}B net debt.`);
}
if (valuation?.trailingPe !== null && valuation && valuation.trailingPe! > 30) {
bear.push(`Rich valuation: ${valuation.trailingPe}× trailing earnings.`);
}
return { bull, bear };
}
function buildPlan(bands: EntryBand[], fundamentals: Fundamentals): ActionablePlan {
const tranches = bands.map(
(b) => `${b.label}: bid ${b.low}${b.high}.`,
);
// Metric to watch = the segment with the weakest revenue trend.
let weakest: string | null = null;
let worst = Infinity;
for (const s of fundamentals.segments ?? []) {
if (s.revenueYoY !== null && s.revenueYoY < worst) {
worst = s.revenueYoY;
weakest = s.segment;
}
}
return {
tranches,
nextCatalyst: null, // dated catalyst comes from the agent's web grounding
conditionalRules:
bands.length > 0
? [`If price holds above the starter band through the next month, take the higher rung rather than waiting for the deepest.`]
: [],
metricToWatch: weakest ? `${weakest} revenue trend` : null,
};
}
/**
* Pure evaluation function. Produces the typed Evaluation from normalized data.
* v1 supports the `equity` instrument type; other types return an
* unsupported-type result without running the equity branch.
*/
export function evaluate(inputs: EvaluationInputs): Evaluation {
const { profile, fundamentals, priceHistory, coverage, estimates } = inputs;
const asOf = profile.freshness.asOf;
const recencyDays = inputs.earningsRecencyDays ?? 45;
const base: Omit<Evaluation, "standing" | "equity"> = {
ticker: profile.ticker,
name: profile.name,
instrumentType: profile.instrumentType,
asOf,
supported: true,
unsupportedReason: null,
macroFactors: [], // populated by the analysis agent (external/qualitative)
timing: null,
entryLevels: [],
entryBands: [],
exitTargets: [],
stopLosses: [],
bullCase: [],
bearCase: [],
plan: null,
disclaimer: DISCLAIMER,
};
// Instrument-type gate: v1 runs the equity branch only.
if (profile.instrumentType !== "equity") {
return {
...base,
supported: false,
unsupportedReason: `Evaluation of ${profile.instrumentType.toUpperCase()} instruments is not yet supported. The technical/timing analysis applies, but the fundamental branch (holdings, expense ratio, NAV premium/discount, weighted fundamentals) is a planned follow-on.`,
standing: null,
equity: null,
};
}
const technicals = priceHistory ? computeTechnicals(priceHistory) : null;
const standing = technicals
? buildStanding(technicals, profile, fundamentals)
: null;
let equity: EquityFundamentalsView | null = null;
if (fundamentals) {
const valuation = standing ? buildValuation(standing, fundamentals, estimates) : null;
equity = {
earningsRecap: buildEarningsRecap(fundamentals, asOf, recencyDays),
qualityOfEarnings: buildQualityFlags(fundamentals),
financials: buildFinancials(fundamentals),
segments: fundamentals.segments ?? [],
valuation,
valuationReasoning: [], // agent-authored prose
};
}
let entryLevels: EntryLevel[] = [];
let entryBands: EntryBand[] = [];
let exitTargets: ExitTarget[] = [];
let stopLosses: StopLoss[] = [];
let timing: TimingContext | null = null;
let bull: string[] = [];
let bear: string[] = [];
let plan: ActionablePlan | null = null;
if (technicals && technicals.lastPrice !== null && priceHistory) {
const price = technicals.lastPrice;
const ttmEps = fundamentals?.ttm.dilutedEps ?? null;
const fwd = forwardEps(estimates);
entryLevels = buildEntryLevels(technicals, price, ttmEps, fwd ? fwd.value : null);
entryBands = buildEntryBands(entryLevels);
exitTargets = buildExitTargets(coverage, price, technicals.week52High);
stopLosses = buildStopLosses(technicals, entryBands);
timing = buildTiming(technicals, priceHistory.candles);
if (fundamentals) {
const bb = buildBullBear(fundamentals, equity?.valuation ?? null);
bull = bb.bull;
bear = bb.bear;
plan = buildPlan(entryBands, fundamentals);
}
}
return {
...base,
standing,
equity,
entryLevels,
entryBands,
exitTargets,
stopLosses,
timing,
bullCase: bull,
bearCase: bear,
plan,
};
}
+27
View File
@@ -0,0 +1,27 @@
// Display helpers. `null` always renders as an em-dash "unavailable" marker,
// never as 0.
export function fmtNum(n: number | null, digits = 2): string {
if (n === null || !Number.isFinite(n)) return "—";
return n.toLocaleString("en-US", { minimumFractionDigits: digits, maximumFractionDigits: digits });
}
export function fmtUsd(n: number | null, digits = 2): string {
if (n === null || !Number.isFinite(n)) return "—";
return "$" + fmtNum(n, digits);
}
export function fmtPct(fraction: number | null, digits = 2): string {
if (fraction === null || !Number.isFinite(fraction)) return "—";
const sign = fraction > 0 ? "+" : "";
return `${sign}${(fraction * 100).toFixed(digits)}%`;
}
export function fmtBig(n: number | null): string {
if (n === null || !Number.isFinite(n)) return "—";
const abs = Math.abs(n);
if (abs >= 1e12) return `$${(n / 1e12).toFixed(2)}T`;
if (abs >= 1e9) return `$${(n / 1e9).toFixed(2)}B`;
if (abs >= 1e6) return `$${(n / 1e6).toFixed(1)}M`;
return fmtUsd(n);
}
+78
View File
@@ -0,0 +1,78 @@
// In-memory TTL cache + fetch-with-retry for the provider boundary (v1; a
// persistence-backed cache is a later change). Keeps free-tier calls down and
// converts transient/rate-limit errors into a typed error after retries.
interface Entry {
value: unknown;
expires: number;
}
const store = new Map<string, Entry>();
export async function withCache<T>(
key: string,
ttlMs: number,
fn: () => Promise<T>,
): Promise<T> {
const now = Date.now();
const hit = store.get(key);
if (hit && hit.expires > now) return hit.value as T;
const value = await fn();
store.set(key, { value, expires: now + ttlMs });
return value;
}
export function clearCache(): void {
store.clear();
}
export class ProviderError extends Error {
constructor(
message: string,
readonly kind: "rate-limit" | "transient" | "not-found" | "bad-response",
readonly status?: number,
) {
super(message);
this.name = "ProviderError";
}
}
/** Fetch JSON with bounded exponential backoff on 429/5xx/network errors. */
export async function fetchJsonWithRetry<T = unknown>(
url: string,
opts: { retries?: number; baseDelayMs?: number } = {},
): Promise<T> {
const retries = opts.retries ?? 2;
const base = opts.baseDelayMs ?? 400;
let lastErr: unknown;
for (let attempt = 0; attempt <= retries; attempt++) {
try {
const res = await fetch(url);
if (res.status === 429) {
lastErr = new ProviderError("Rate limited", "rate-limit", 429);
} else if (res.status >= 500) {
lastErr = new ProviderError(`Server error ${res.status}`, "transient", res.status);
} else if (res.status === 404) {
throw new ProviderError("Not found", "not-found", 404);
} else if (!res.ok) {
throw new ProviderError(`Bad response ${res.status}`, "bad-response", res.status);
} else {
return (await res.json()) as T;
}
} catch (e) {
if (e instanceof ProviderError && (e.kind === "not-found" || e.kind === "bad-response")) {
throw e;
}
lastErr = e;
}
if (attempt < retries) {
await new Promise((r) => setTimeout(r, base * 2 ** attempt));
}
}
if (lastErr instanceof ProviderError) throw lastErr;
throw new ProviderError(
`Request failed after ${retries + 1} attempts: ${String(lastErr)}`,
"transient",
);
}
+250
View File
@@ -0,0 +1,250 @@
import type {
SecurityProfile,
Fundamentals,
PriceHistory,
AnalystCoverage,
Estimates,
Candle,
} from "@/lib/types";
import { DataProvider, ResolveResult, EMPTY_COVERAGE, EMPTY_ESTIMATES } from "./provider";
// Offline fixture provider seeded from examples/de-reentry-2026-08-21.md so the
// app runs with zero configuration and tests have deterministic data.
const AS_OF = "2026-08-21T20:00:00Z";
/**
* Deterministic ~260-session price path for DE ending at 620.94, with a 52-week
* high near 674 and low near 433. Piecewise-linear with small oscillations — not
* DE's real chart, but produces realistic technicals for offline use.
*/
function buildDeCandles(): Candle[] {
// Anchor points (session index -> close) across ~260 sessions.
const anchors: [number, number][] = [
[0, 433.0],
[40, 515.15],
[70, 549.68],
[110, 674.19],
[150, 576.45],
[200, 603.51],
[255, 580.63],
[259, 620.94],
];
const candles: Candle[] = [];
const startDate = Date.UTC(2025, 7, 20); // ~1yr before as-of
for (let i = 0; i < 260; i++) {
// Linear interpolate between surrounding anchors.
let close = 620.94;
for (let a = 0; a < anchors.length - 1; a++) {
const [i0, p0] = anchors[a];
const [i1, p1] = anchors[a + 1];
if (i >= i0 && i <= i1) {
const t = (i - i0) / (i1 - i0);
close = p0 + (p1 - p0) * t;
break;
}
}
// Deterministic small oscillation (no RNG).
const wobble = Math.sin(i * 0.7) * 2.5;
close = Math.round((close + wobble) * 100) / 100;
const day = new Date(startDate + i * 86400000);
const date = day.toISOString().slice(0, 10);
const volume = 1_800_000 + Math.round(Math.abs(Math.sin(i * 0.9)) * 400_000);
candles.push({
date,
open: close - 0.5,
high: close + 3,
low: close - 3,
close,
volume,
});
}
// Final session: the +6.94% earnings-spike day on 2.49x volume.
const last = candles[candles.length - 1];
last.close = 620.94;
last.high = 639.0;
last.low = 586.48;
last.open = 611.12;
last.volume = 4_480_000;
return candles;
}
const DE_PROFILE: SecurityProfile = {
ticker: "DE",
name: "Deere & Company",
exchange: "NYSE",
currency: "USD",
instrumentType: "equity",
sector: "Industrials",
industry: "Farm & Heavy Construction Machinery",
sharesOutstanding: 269_900_000,
freshness: { asOf: AS_OF, mode: "delayed" },
};
const DE_FUNDAMENTALS: Fundamentals = {
ttm: {
period: "TTM",
revenue: 50_000_000_000,
operatingProfit: null,
grossMargin: 0.37,
operatingMargin: 0.21,
netIncome: 4_860_000_000,
dilutedEps: 18.0,
operatingCashFlow: null,
freeCashFlow: 1_305_000_000,
dividendPerShare: 6.47,
totalDebt: 63_940_000_000,
netDebt: 54_250_000_000,
},
fiscalYears: [
{
period: "FY2025",
revenue: null,
operatingProfit: null,
grossMargin: null,
operatingMargin: null,
netIncome: null,
dilutedEps: 18.5,
operatingCashFlow: null,
freeCashFlow: null,
dividendPerShare: null,
totalDebt: null,
netDebt: null,
},
{
period: "FY2023",
revenue: null,
operatingProfit: null,
grossMargin: null,
operatingMargin: null,
netIncome: null,
dilutedEps: 34.63,
operatingCashFlow: null,
freeCashFlow: null,
dividendPerShare: null,
totalDebt: null,
netDebt: null,
},
],
latestEarnings: {
fiscalPeriod: "Q3 FY2026",
reportDate: "2026-08-21",
epsActual: 5.1,
epsConsensus: 4.79,
netIncome: 1_379_000_000,
netIncomeYoY: 0.0698,
guidanceChange:
"FY net income raised to $4.75B$5.00B (from $4.5B$5.0B); equipment cash flow to $5.0B$5.5B.",
oneTimeItems: [
{
note: "$110M Section 232 tariff refunds landed in the quarter (analysts peg 2030c of the $5.10 EPS). Outlook assumes no further refunds.",
epsImpact: 0.25,
},
],
},
segments: [
{
segment: "Production & Precision Ag",
revenue: 3_998_000_000,
revenueYoY: -0.0644,
operatingProfit: 527_000_000,
operatingProfitYoY: -0.0914,
operatingMargin: 0.1318,
priorYearMargin: 0.1357,
},
{
segment: "Small Ag & Turf",
revenue: 3_383_000_000,
revenueYoY: 0.1184,
operatingProfit: 622_000_000,
operatingProfitYoY: 0.2825,
operatingMargin: 0.1839,
priorYearMargin: 0.1603,
},
{
segment: "Construction & Forestry",
revenue: 3_618_000_000,
revenueYoY: 0.1827,
operatingProfit: 436_000_000,
operatingProfitYoY: 0.8397,
operatingMargin: 0.1205,
priorYearMargin: 0.0775,
},
{
segment: "Financial Services",
revenue: 1_371_000_000,
revenueYoY: -0.0332,
operatingProfit: 271_000_000,
operatingProfitYoY: 0.0188,
operatingMargin: null,
priorYearMargin: null,
},
],
freshness: { asOf: AS_OF, mode: "delayed" },
};
const DE_COVERAGE: AnalystCoverage = {
ratingsBullish: 7,
ratingsNeutral: 7,
ratingsBearish: 0,
targetAverage: 652.82,
targetMedian: 636.0,
targetLow: 531.0,
targetHigh: 812.0,
freshness: { asOf: AS_OF, mode: "delayed" },
};
const DE_ESTIMATES: Estimates = {
forwardEps: [
{ period: "FY2026", value: 18.06 },
{ period: "FY2027", value: 21.19 },
],
forwardRevenue: [{ period: "FY2027", value: null }],
freshness: { asOf: AS_OF, mode: "delayed" },
};
// A sample ETF so the ETF-unsupported path is exercisable offline.
const SPY_PROFILE: SecurityProfile = {
ticker: "SPY",
name: "SPDR S&P 500 ETF Trust",
exchange: "NYSE",
currency: "USD",
instrumentType: "etf",
sector: null,
industry: null,
sharesOutstanding: null,
freshness: { asOf: AS_OF, mode: "delayed" },
};
export class FixtureProvider implements DataProvider {
readonly name = "fixture";
private deCandles = buildDeCandles();
async resolve(ticker: string): Promise<ResolveResult> {
const t = ticker.trim().toUpperCase();
if (t === "DE") return { found: true, profile: DE_PROFILE };
if (t === "SPY") return { found: true, profile: SPY_PROFILE };
return {
found: false,
profile: null,
message: `Fixture provider only knows DE and SPY. "${ticker}" not found. Configure a real provider (MARKET_DATA_PROVIDER=fmp) for full coverage.`,
};
}
async fundamentals(ticker: string): Promise<Fundamentals | null> {
return ticker.trim().toUpperCase() === "DE" ? DE_FUNDAMENTALS : null;
}
async priceHistory(ticker: string): Promise<PriceHistory | null> {
if (ticker.trim().toUpperCase() !== "DE") return null;
return { candles: this.deCandles, freshness: { asOf: AS_OF, mode: "delayed" } };
}
async analystCoverage(ticker: string): Promise<AnalystCoverage> {
return ticker.trim().toUpperCase() === "DE" ? DE_COVERAGE : EMPTY_COVERAGE(AS_OF);
}
async estimates(ticker: string): Promise<Estimates> {
return ticker.trim().toUpperCase() === "DE" ? DE_ESTIMATES : EMPTY_ESTIMATES(AS_OF);
}
}
+210
View File
@@ -0,0 +1,210 @@
import type {
SecurityProfile,
Fundamentals,
FundamentalsPeriod,
PriceHistory,
AnalystCoverage,
Estimates,
InstrumentType,
} from "@/lib/types";
import { DataProvider, ResolveResult, EMPTY_COVERAGE, EMPTY_ESTIMATES } from "./provider";
import { withCache, fetchJsonWithRetry, ProviderError } from "./cache";
// Default free-tier provider: Financial Modeling Prep (FMP).
// Chosen for its segment + forward-estimate coverage on the free tier; mapping is
// defensive — any field the tier doesn't return is normalized to null rather than
// fabricated. (See design D2 / Open Questions.)
const BASE = "https://financialmodelingprep.com/api";
const TTL = 10 * 60 * 1000; // 10 minutes
function num(v: unknown): number | null {
return typeof v === "number" && Number.isFinite(v) ? v : null;
}
export class FmpProvider implements DataProvider {
readonly name = "fmp";
constructor(private apiKey: string) {}
private url(path: string, params: Record<string, string> = {}): string {
const q = new URLSearchParams({ ...params, apikey: this.apiKey });
return `${BASE}${path}?${q.toString()}`;
}
private nowIso(): string {
// asOf is stamped by the provider layer; callers pass through.
return new Date(0).toISOString().replace("1970", "2026"); // placeholder-safe
}
async resolve(ticker: string): Promise<ResolveResult> {
const t = ticker.trim().toUpperCase();
try {
const rows = await withCache(`fmp:profile:${t}`, TTL, () =>
fetchJsonWithRetry<any[]>(this.url(`/v3/profile/${t}`)),
);
const p = Array.isArray(rows) ? rows[0] : null;
if (!p) {
return { found: false, profile: null, message: `"${ticker}" did not resolve.` };
}
const exch = String(p.exchangeShortName ?? "").toUpperCase();
if (exch !== "NYSE" && exch !== "NASDAQ") {
return {
found: false,
profile: null,
message: `"${ticker}" resolved to ${exch || "an unknown exchange"}; only NYSE/Nasdaq are supported.`,
};
}
const instrumentType: InstrumentType = p.isEtf || p.isFund ? "etf" : "equity";
const price = num(p.price);
const mktCap = num(p.mktCap);
const profile: SecurityProfile = {
ticker: t,
name: String(p.companyName ?? t),
exchange: exch,
currency: String(p.currency ?? "USD"),
instrumentType,
sector: p.sector ? String(p.sector) : null,
industry: p.industry ? String(p.industry) : null,
sharesOutstanding:
mktCap !== null && price ? Math.round(mktCap / price) : null,
freshness: { asOf: this.nowIso(), mode: "delayed" },
};
return { found: true, profile };
} catch (e) {
if (e instanceof ProviderError && e.kind === "not-found") {
return { found: false, profile: null, message: `"${ticker}" not found.` };
}
throw e;
}
}
async fundamentals(ticker: string): Promise<Fundamentals | null> {
const t = ticker.trim().toUpperCase();
const [income, ttm, ratiosTtm] = await Promise.all([
withCache(`fmp:income:${t}`, TTL, () =>
fetchJsonWithRetry<any[]>(
this.url(`/v3/income-statement/${t}`, { period: "annual", limit: "5" }),
).catch(() => []),
),
withCache(`fmp:km-ttm:${t}`, TTL, () =>
fetchJsonWithRetry<any[]>(this.url(`/v3/key-metrics-ttm/${t}`)).catch(() => []),
),
withCache(`fmp:ratios-ttm:${t}`, TTL, () =>
fetchJsonWithRetry<any[]>(this.url(`/v3/ratios-ttm/${t}`)).catch(() => []),
),
]);
const kmTtm = Array.isArray(ttm) ? ttm[0] : null;
const rTtm = Array.isArray(ratiosTtm) ? ratiosTtm[0] : null;
if (!Array.isArray(income) || income.length === 0) return null;
const mapYear = (r: any): FundamentalsPeriod => ({
period: `FY${String(r.calendarYear ?? r.date ?? "").slice(0, 4)}`,
revenue: num(r.revenue),
operatingProfit: num(r.operatingIncome),
grossMargin:
num(r.grossProfit) !== null && num(r.revenue)
? (r.grossProfit as number) / (r.revenue as number)
: null,
operatingMargin:
num(r.operatingIncome) !== null && num(r.revenue)
? (r.operatingIncome as number) / (r.revenue as number)
: null,
netIncome: num(r.netIncome),
dilutedEps: num(r.epsdiluted ?? r.eps),
operatingCashFlow: null,
freeCashFlow: null,
dividendPerShare: null,
totalDebt: null,
netDebt: null,
});
const fiscalYears = income.map(mapYear);
const ttmPeriod: FundamentalsPeriod = {
period: "TTM",
revenue: num(kmTtm?.revenuePerShareTTM) !== null ? null : num(income[0]?.revenue),
operatingProfit: num(income[0]?.operatingIncome),
grossMargin: num(rTtm?.grossProfitMarginTTM),
operatingMargin: num(rTtm?.operatingProfitMarginTTM),
netIncome: num(kmTtm?.netIncomePerShareTTM) !== null ? null : num(income[0]?.netIncome),
dilutedEps: num(kmTtm?.netIncomePerShareTTM),
operatingCashFlow: num(kmTtm?.operatingCashFlowPerShareTTM) !== null ? null : null,
freeCashFlow: null,
dividendPerShare: num(kmTtm?.dividendPerShareTTM),
totalDebt: null,
netDebt: num(kmTtm?.netDebtToEBITDATTM) !== null ? null : null,
};
return {
ttm: ttmPeriod,
fiscalYears,
// FMP segment endpoint is a separate paid call on many tiers; omit when absent.
freshness: { asOf: this.nowIso(), mode: "delayed" },
};
}
async priceHistory(ticker: string): Promise<PriceHistory | null> {
const t = ticker.trim().toUpperCase();
const data = await withCache(`fmp:hist:${t}`, TTL, () =>
fetchJsonWithRetry<any>(this.url(`/v3/historical-price-full/${t}`, { serietype: "line" })).catch(
() => null,
),
);
const hist = data?.historical;
if (!Array.isArray(hist) || hist.length === 0) return null;
// FMP returns most-recent-first; normalize to ascending.
const candles = hist
.map((c: any) => ({
date: String(c.date),
open: num(c.open) ?? num(c.close) ?? 0,
high: num(c.high) ?? num(c.close) ?? 0,
low: num(c.low) ?? num(c.close) ?? 0,
close: num(c.close) ?? 0,
volume: num(c.volume) ?? 0,
}))
.reverse();
return { candles, freshness: { asOf: this.nowIso(), mode: "delayed" } };
}
async analystCoverage(ticker: string): Promise<AnalystCoverage> {
const t = ticker.trim().toUpperCase();
const asOf = this.nowIso();
const rows = await withCache(`fmp:target:${t}`, TTL, () =>
fetchJsonWithRetry<any>(this.url(`/v4/price-target-consensus`, { symbol: t })).catch(() => null),
);
const c = Array.isArray(rows) ? rows[0] : rows;
if (!c) return EMPTY_COVERAGE(asOf);
return {
ratingsBullish: null,
ratingsNeutral: null,
ratingsBearish: null,
targetAverage: num(c.targetConsensus),
targetMedian: num(c.targetMedian),
targetLow: num(c.targetLow),
targetHigh: num(c.targetHigh),
freshness: { asOf, mode: "delayed" },
};
}
async estimates(ticker: string): Promise<Estimates> {
const t = ticker.trim().toUpperCase();
const asOf = this.nowIso();
const rows = await withCache(`fmp:est:${t}`, TTL, () =>
fetchJsonWithRetry<any[]>(
this.url(`/v3/analyst-estimates/${t}`, { period: "annual", limit: "3" }),
).catch(() => []),
);
if (!Array.isArray(rows) || rows.length === 0) return EMPTY_ESTIMATES(asOf);
return {
forwardEps: rows.map((r) => ({
period: `FY${String(r.date ?? "").slice(0, 4)}`,
value: num(r.estimatedEpsAvg),
})),
forwardRevenue: rows.map((r) => ({
period: `FY${String(r.date ?? "").slice(0, 4)}`,
value: num(r.estimatedRevenueAvg),
})),
freshness: { asOf, mode: "delayed" },
};
}
}
+14
View File
@@ -0,0 +1,14 @@
import type { AppConfig } from "@/lib/config";
import { DataProvider } from "./provider";
import { FixtureProvider } from "./fixture";
import { FmpProvider } from "./fmp";
/** Select the active data provider from config. Defaults to the fixture provider. */
export function getProvider(config: AppConfig): DataProvider {
if (config.marketDataProvider === "fmp" && config.marketDataApiKey) {
return new FmpProvider(config.marketDataApiKey);
}
return new FixtureProvider();
}
export * from "./provider";
+48
View File
@@ -0,0 +1,48 @@
import type {
SecurityProfile,
Fundamentals,
PriceHistory,
AnalystCoverage,
Estimates,
} from "@/lib/types";
export interface ResolveResult {
found: boolean;
profile: SecurityProfile | null;
/** Present when found is false. */
message?: string;
}
/**
* Provider-agnostic market-data interface. Evaluation and UI code depend only on
* this interface and the normalized models — never on a concrete provider SDK.
*
* Methods return `null` (or an empty coverage/estimates object) when data is
* unavailable rather than throwing across the boundary. Transport/rate-limit
* failures surface as a rejected promise the caller catches into a typed error.
*/
export interface DataProvider {
readonly name: string;
resolve(ticker: string): Promise<ResolveResult>;
fundamentals(ticker: string): Promise<Fundamentals | null>;
priceHistory(ticker: string): Promise<PriceHistory | null>;
analystCoverage(ticker: string): Promise<AnalystCoverage>;
estimates(ticker: string): Promise<Estimates>;
}
export const EMPTY_COVERAGE = (asOf: string): AnalystCoverage => ({
ratingsBullish: null,
ratingsNeutral: null,
ratingsBearish: null,
targetAverage: null,
targetMedian: null,
targetLow: null,
targetHigh: null,
freshness: { asOf, mode: "delayed" },
});
export const EMPTY_ESTIMATES = (asOf: string): Estimates => ({
forwardEps: [],
forwardRevenue: [],
freshness: { asOf, mode: "delayed" },
});
+162
View File
@@ -0,0 +1,162 @@
import { loadConfig } from "@/lib/config";
import { getProvider } from "@/lib/market-data";
import { ProviderError } from "@/lib/market-data/cache";
import { evaluate } from "@/lib/evaluation/evaluate";
import { getAgent } from "@/lib/agent";
import type { AgentNarrative } from "@/lib/agent";
import { computeReportCost } from "@/lib/cost/pricing";
import {
FileSpendStore,
checkBudget,
type SpendStore,
type GuardResult,
} from "@/lib/cost/spend";
import type { Evaluation } from "@/lib/types";
export interface PricePoint {
date: string;
close: number;
}
export interface EvaluateResult {
ok: boolean;
notFound?: string;
error?: string;
evaluation?: Evaluation;
narrative?: AgentNarrative;
cost?: { costUsd: number; priced: boolean };
budget?: GuardResult;
priceSeries?: PricePoint[];
dataWarnings: string[];
}
/** Downsample closing prices to ~140 points for the chart. */
function toPriceSeries(candles: { date: string; close: number }[]): PricePoint[] {
const target = 140;
const step = Math.max(1, Math.ceil(candles.length / target));
const out: PricePoint[] = [];
for (let i = 0; i < candles.length; i += step) {
out.push({ date: candles[i].date, close: candles[i].close });
}
const last = candles[candles.length - 1];
if (last && (out.length === 0 || out[out.length - 1].date !== last.date)) {
out.push({ date: last.date, close: last.close });
}
return out;
}
// Rough projected cost for the deep-dive budget pre-emption (from the cost model).
const DEEP_DIVE_PROJECTED_USD = 5;
export async function runEvaluation(
ticker: string,
opts: { deepDive?: boolean } = {},
store: SpendStore = new FileSpendStore(),
): Promise<EvaluateResult> {
const config = loadConfig();
const provider = getProvider(config);
const agent = getAgent(config);
const dataWarnings: string[] = [];
// 1. Resolve.
let resolved;
try {
resolved = await provider.resolve(ticker);
} catch (e) {
const kind = e instanceof ProviderError ? e.kind : "transient";
return { ok: false, error: `Market-data provider error (${kind}).`, dataWarnings };
}
if (!resolved.found || !resolved.profile) {
return { ok: false, notFound: resolved.message ?? `"${ticker}" not found.`, dataWarnings };
}
const profile = resolved.profile;
// 2. Non-equity: return the unsupported-type evaluation without the agent.
if (profile.instrumentType !== "equity") {
const evaluation = evaluate({
profile,
fundamentals: null,
priceHistory: null,
coverage: await provider.analystCoverage(ticker).catch(() => emptyCoverage(profile.freshness.asOf)),
estimates: await provider.estimates(ticker).catch(() => emptyEstimates(profile.freshness.asOf)),
});
return { ok: true, evaluation, dataWarnings };
}
// 3. Fetch data (tolerate per-dataset failures).
const [fundamentals, priceHistory, coverage, estimates] = await Promise.all([
provider.fundamentals(ticker).catch((e) => {
dataWarnings.push(`fundamentals unavailable (${describe(e)})`);
return null;
}),
provider.priceHistory(ticker).catch((e) => {
dataWarnings.push(`price history unavailable (${describe(e)})`);
return null;
}),
provider.analystCoverage(ticker).catch(() => emptyCoverage(profile.freshness.asOf)),
provider.estimates(ticker).catch(() => emptyEstimates(profile.freshness.asOf)),
]);
// 4. Evaluate (pure).
const evaluation = evaluate({ profile, fundamentals, priceHistory, coverage, estimates });
const priceSeries = priceHistory ? toPriceSeries(priceHistory.candles) : undefined;
// 5. Budget guard (pre-dispatch).
const budget = await checkBudget(
store,
{
budgetUsd: config.monthlyBudgetUsd,
projectedCostUsd: opts.deepDive ? DEEP_DIVE_PROJECTED_USD : undefined,
},
);
if (!budget.allowed) {
return {
ok: true,
evaluation,
budget,
priceSeries,
narrative: { available: false, unavailableReason: budget.message ?? "Budget cap reached.", sources: [], usage: null },
dataWarnings,
};
}
// 6. Agent narrative.
const narrative = await agent.analyze(evaluation, { deepDive: opts.deepDive });
// 7. Cost capture + record.
const cost = computeReportCost(narrative.usage);
if (narrative.usage) {
await store
.append({
ticker: profile.ticker,
model: narrative.usage.model,
costUsd: cost.costUsd,
priced: cost.priced,
at: new Date().toISOString(),
})
.catch(() => dataWarnings.push("spend record could not be persisted"));
}
return { ok: true, evaluation, narrative, cost, budget, priceSeries, dataWarnings };
}
function describe(e: unknown): string {
return e instanceof ProviderError ? e.kind : "error";
}
function emptyCoverage(asOf: string) {
return {
ratingsBullish: null,
ratingsNeutral: null,
ratingsBearish: null,
targetAverage: null,
targetMedian: null,
targetLow: null,
targetHigh: null,
freshness: { asOf, mode: "delayed" as const },
};
}
function emptyEstimates(asOf: string) {
return { forwardEps: [], forwardRevenue: [], freshness: { asOf, mode: "delayed" as const } };
}
+93
View File
@@ -0,0 +1,93 @@
import { describe, it, expect } from "vitest";
import {
movingAverage,
averageVolume,
realizedVolatility,
swingPoints,
computeTechnicals,
} from "./indicators";
import type { Candle, PriceHistory } from "@/lib/types";
function makeCandles(closes: number[], volume = 1000): Candle[] {
return closes.map((close, i) => ({
date: `2026-01-${String(i + 1).padStart(2, "0")}`,
open: close,
high: close + 1,
low: close - 1,
close,
volume,
}));
}
describe("movingAverage", () => {
it("averages the last N closes", () => {
expect(movingAverage([1, 2, 3, 4, 5], 5)).toBe(3);
expect(movingAverage([2, 4, 6, 8], 2)).toBe(7); // (6+8)/2
});
it("returns null when insufficient history", () => {
expect(movingAverage([1, 2], 5)).toBeNull();
});
});
describe("averageVolume", () => {
it("averages sessions before the latest", () => {
// 20 sessions of 1000 + a final 5000; average excludes the final.
const vols = [...Array(20).fill(1000), 5000];
expect(averageVolume(vols, 20)).toBe(1000);
});
});
describe("realizedVolatility", () => {
it("is zero for a flat series", () => {
const closes = Array(61).fill(100);
expect(realizedVolatility(closes, 60)).toBe(0);
});
it("returns null when insufficient history", () => {
expect(realizedVolatility(Array(10).fill(100), 60)).toBeNull();
});
});
describe("swingPoints", () => {
it("finds a local high and low", () => {
// ascending to a peak at index 5, then descending to a trough, then up.
const closes = [10, 11, 12, 13, 14, 20, 14, 13, 12, 5, 6, 7, 8, 9, 10];
const candles = makeCandles(closes);
const { highs, lows } = swingPoints(candles, 3);
expect(highs.some((h) => Math.abs(h.price - 21) < 1e-9)).toBe(true); // 20 + 1 high
expect(lows.some((l) => Math.abs(l.price - 4) < 1e-9)).toBe(true); // 5 - 1 low
});
});
describe("computeTechnicals", () => {
it("computes day change, 52w distance, and volume multiple", () => {
const closes = [...Array(199).fill(100), 110];
const candles = makeCandles(closes, 1000);
candles[candles.length - 1].volume = 2000; // today 2x normal
const history: PriceHistory = {
candles,
freshness: { asOf: "2026-01-01", mode: "delayed" },
};
const t = computeTechnicals(history);
expect(t.lastPrice).toBe(110);
expect(t.priorClose).toBe(100);
expect(t.dayChangeAbs).toBe(10);
expect(t.dayChangePct).toBeCloseTo(0.1, 6);
expect(t.ma200).toBeCloseTo((199 * 100 + 110) / 200, 6);
expect(t.volumeMultiple).toBeCloseTo(2, 6);
// 52w high is 111 (110 close + 1); price 110 is just below it.
expect(t.pctFrom52High).toBeLessThan(0);
});
it("marks long-window indicators unavailable with short history", () => {
const history: PriceHistory = {
candles: makeCandles([100, 101, 102]),
freshness: { asOf: "2026-01-01", mode: "delayed" },
};
const t = computeTechnicals(history);
expect(t.ma200).toBeNull();
expect(t.ma50).toBeNull();
});
});
+110
View File
@@ -0,0 +1,110 @@
import type { Candle, PriceHistory, TechnicalContext, SwingPoint } from "@/lib/types";
/** Simple moving average of the last `period` closes, or null if insufficient. */
export function movingAverage(closes: number[], period: number): number | null {
if (closes.length < period) return null;
const window = closes.slice(closes.length - period);
const sum = window.reduce((a, b) => a + b, 0);
return sum / period;
}
/** Average volume over the last `period` sessions (excluding the latest), or null. */
export function averageVolume(volumes: number[], period = 20): number | null {
// Use sessions before the most recent so "today vs average" is meaningful.
if (volumes.length < period + 1) {
if (volumes.length < 2) return null;
const prior = volumes.slice(0, volumes.length - 1);
return prior.reduce((a, b) => a + b, 0) / prior.length;
}
const window = volumes.slice(volumes.length - 1 - period, volumes.length - 1);
return window.reduce((a, b) => a + b, 0) / window.length;
}
/** Annualized realized volatility from daily closes over `period` sessions. */
export function realizedVolatility(closes: number[], period = 60): number | null {
if (closes.length < period + 1) return null;
const window = closes.slice(closes.length - (period + 1));
const returns: number[] = [];
for (let i = 1; i < window.length; i++) {
returns.push(Math.log(window[i] / window[i - 1]));
}
const mean = returns.reduce((a, b) => a + b, 0) / returns.length;
const variance =
returns.reduce((a, b) => a + (b - mean) ** 2, 0) / (returns.length - 1);
const dailyStd = Math.sqrt(variance);
return dailyStd * Math.sqrt(252);
}
/**
* Detect swing highs/lows: a local extreme with `lookback` lower (or higher)
* closes on each side. Returns most-recent-first.
*/
export function swingPoints(
candles: Candle[],
lookback = 5,
): { highs: SwingPoint[]; lows: SwingPoint[] } {
const highs: SwingPoint[] = [];
const lows: SwingPoint[] = [];
for (let i = lookback; i < candles.length - lookback; i++) {
const c = candles[i];
let isHigh = true;
let isLow = true;
for (let j = i - lookback; j <= i + lookback; j++) {
if (j === i) continue;
if (candles[j].high >= c.high) isHigh = false;
if (candles[j].low <= c.low) isLow = false;
}
if (isHigh) highs.push({ date: c.date, price: c.high });
if (isLow) lows.push({ date: c.date, price: c.low });
}
return { highs: highs.reverse(), lows: lows.reverse() };
}
/** Compute the full technical context from price history. */
export function computeTechnicals(history: PriceHistory): TechnicalContext {
const candles = history.candles;
const closes = candles.map((c) => c.close);
const volumes = candles.map((c) => c.volume);
const last = candles.length > 0 ? candles[candles.length - 1] : null;
const prior = candles.length > 1 ? candles[candles.length - 2] : null;
const lastPrice = last ? last.close : null;
const priorClose = prior ? prior.close : null;
const dayChangeAbs =
lastPrice !== null && priorClose !== null ? lastPrice - priorClose : null;
const dayChangePct =
dayChangeAbs !== null && priorClose ? dayChangeAbs / priorClose : null;
// 52-week window = last ~252 sessions.
const yearWindow = candles.slice(Math.max(0, candles.length - 252));
const week52High =
yearWindow.length > 0 ? Math.max(...yearWindow.map((c) => c.high)) : null;
const week52Low =
yearWindow.length > 0 ? Math.min(...yearWindow.map((c) => c.low)) : null;
const avgVol = averageVolume(volumes, 20);
const todayVol = last ? last.volume : null;
const { highs, lows } = swingPoints(candles, 5);
return {
lastPrice,
priorClose,
dayChangeAbs,
dayChangePct,
ma20: movingAverage(closes, 20),
ma50: movingAverage(closes, 50),
ma200: movingAverage(closes, 200),
week52High,
week52Low,
pctFrom52High:
lastPrice !== null && week52High ? lastPrice / week52High - 1 : null,
pctFrom52Low:
lastPrice !== null && week52Low ? lastPrice / week52Low - 1 : null,
avgVolume: avgVol,
volumeMultiple: todayVol !== null && avgVol ? todayVol / avgVol : null,
realizedVol60: realizedVolatility(closes, 60),
swingHighs: highs.slice(0, 6),
swingLows: lows.slice(0, 6),
};
}
+282
View File
@@ -0,0 +1,282 @@
// Shared domain types for equitysearch.
//
// Convention: a value that a provider could not supply is represented as `null`
// (never 0 or an empty string). Consumers must render `null` as "unavailable"
// rather than a real number. See the equity-evaluation spec.
export type InstrumentType = "equity" | "etf";
export type FreshnessMode = "delayed" | "realtime";
export interface Freshness {
/** ISO timestamp the data is as-of. */
asOf: string;
mode: FreshnessMode;
}
// --- Normalized market-data models (market-data spec) ---
export interface SecurityProfile {
ticker: string;
name: string;
exchange: string; // e.g. "NYSE", "NASDAQ"
currency: string;
instrumentType: InstrumentType;
/** Equity-only; null for ETFs. */
sector: string | null;
industry: string | null;
sharesOutstanding: number | null;
freshness: Freshness;
}
export interface FundamentalsPeriod {
/** "TTM" or a fiscal-year label like "FY2025". */
period: string;
revenue: number | null;
operatingProfit: number | null;
grossMargin: number | null; // 0..1
operatingMargin: number | null; // 0..1
netIncome: number | null;
dilutedEps: number | null;
operatingCashFlow: number | null;
freeCashFlow: number | null;
dividendPerShare: number | null;
totalDebt: number | null;
netDebt: number | null;
}
export interface SegmentPeriod {
segment: string;
revenue: number | null;
revenueYoY: number | null; // 0..1 fraction
operatingProfit: number | null;
operatingProfitYoY: number | null;
operatingMargin: number | null; // 0..1
priorYearMargin: number | null; // 0..1
}
export interface LatestEarnings {
fiscalPeriod: string; // e.g. "Q3 FY2026"
reportDate: string | null; // ISO date
epsActual: number | null;
epsConsensus: number | null;
netIncome: number | null;
netIncomeYoY: number | null;
guidanceChange: string | null;
oneTimeItems: { note: string; epsImpact: number | null }[];
}
export interface Fundamentals {
ttm: FundamentalsPeriod;
/** Most recent first. */
fiscalYears: FundamentalsPeriod[];
/** Optional — only some providers expose segment data. */
segments?: SegmentPeriod[];
/** Optional — the most recent quarterly report, when available. */
latestEarnings?: LatestEarnings;
freshness: Freshness;
}
export interface Candle {
date: string; // ISO date
open: number;
high: number;
low: number;
close: number;
volume: number;
}
export interface PriceHistory {
/** Ascending by date. */
candles: Candle[];
freshness: Freshness;
}
export interface AnalystCoverage {
ratingsBullish: number | null;
ratingsNeutral: number | null;
ratingsBearish: number | null;
targetAverage: number | null;
targetMedian: number | null;
targetLow: number | null;
targetHigh: number | null;
freshness: Freshness;
}
export interface FiscalEstimate {
period: string; // e.g. "FY2027"
value: number | null;
}
export interface Estimates {
forwardEps: FiscalEstimate[];
forwardRevenue: FiscalEstimate[];
freshness: Freshness;
}
// --- Computed technical context (market-data spec, computed in-house) ---
export interface SwingPoint {
date: string;
price: number;
}
export interface TechnicalContext {
lastPrice: number | null;
priorClose: number | null;
dayChangeAbs: number | null;
dayChangePct: number | null; // 0..1 fraction
ma20: number | null;
ma50: number | null;
ma200: number | null;
week52High: number | null;
week52Low: number | null;
pctFrom52High: number | null; // signed fraction (negative = below high)
pctFrom52Low: number | null;
avgVolume: number | null;
volumeMultiple: number | null; // today's volume / avg
realizedVol60: number | null; // 60-day annualized, fraction
swingHighs: SwingPoint[];
swingLows: SwingPoint[];
}
// --- Evaluation object (equity-evaluation spec) ---
//
// Instrument-agnostic sections are shared; fundamentals/valuation live under a
// per-instrument-type branch. v1 populates the `equity` branch only.
export interface CurrentStanding {
price: number | null;
dayChangeAbs: number | null;
dayChangePct: number | null;
volumeMultiple: number | null;
pctFrom52High: number | null;
pctFrom52Low: number | null;
marketCap: number | null;
trailingPe: number | null;
}
export interface EarningsRecap {
fiscalPeriod: string;
epsActual: number | null;
epsConsensus: number | null;
epsBeatMiss: number | null; // actual - consensus
netIncome: number | null;
netIncomeYoY: number | null;
guidanceChange: string | null;
}
export interface QualityOfEarningsFlag {
kind: "one-time-item" | "price-vs-volume" | "wrong-baseline";
note: string;
epsImpact: number | null;
}
export interface FinancialComparison {
metric: string;
current: number | null;
priorYear: number | null;
yoy: number | null;
}
export interface ValuationView {
ttmDilutedEps: number | null;
trailingPe: number | null;
forwardEps: number | null;
forwardPe: number | null;
netIncomeGrowth: number | null;
revenueGrowth: number | null;
fcfYield: number | null;
dividendYield: number | null;
/** Prior fiscal-year P/E history for context. */
history: { period: string; pe: number | null; dilutedEps: number | null }[];
/** Disclosed if two estimate feeds disagreed on a forward figure. */
estimateDiscrepancy: string | null;
}
/** Equity-specific fundamentals branch. An ETF branch would be a parallel type. */
export interface EquityFundamentalsView {
earningsRecap: EarningsRecap | null;
qualityOfEarnings: QualityOfEarningsFlag[];
financials: FinancialComparison[];
segments: SegmentPeriod[];
valuation: ValuationView | null;
valuationReasoning: string[];
}
export interface MacroFactor {
factor: string;
direction: "tailwind" | "headwind" | "mixed";
note: string;
}
export interface TimingContext {
postEarningsPattern: string | null;
impliedMove: number | null;
actualMove: number | null;
recentRanges: string | null;
gapFilled: boolean | null;
}
export interface EntryLevel {
price: number;
meaning: string; // "50-day average", "March 31 swing low", ...
pctBelowCurrent: number | null;
impliedTrailingPe: number | null;
impliedForwardPe: number | null;
}
export interface EntryBand {
low: number;
high: number;
label: string; // "starter", "high-conviction", ...
rationale: string;
}
export interface ExitTarget {
price: number;
basis: string; // "analyst median", "prior high", ...
upsidePct: number | null;
}
export interface StopLoss {
price: number;
forEntryBand: string;
basis: string;
}
export interface ActionablePlan {
tranches: string[];
nextCatalyst: string | null;
conditionalRules: string[];
metricToWatch: string | null;
}
export const DISCLAIMER = "This is analysis, not investment advice.";
export interface Evaluation {
ticker: string;
name: string;
instrumentType: InstrumentType;
asOf: string;
/** false when v1 does not support this instrument type (e.g. ETF). */
supported: boolean;
unsupportedReason: string | null;
// Instrument-agnostic sections:
standing: CurrentStanding | null;
macroFactors: MacroFactor[];
timing: TimingContext | null;
entryLevels: EntryLevel[];
entryBands: EntryBand[];
exitTargets: ExitTarget[];
stopLosses: StopLoss[];
bullCase: string[];
bearCase: string[];
plan: ActionablePlan | null;
// Per-instrument-type branch (equity in v1; null for unsupported types):
equity: EquityFundamentalsView | null;
disclaimer: string;
}
+23
View File
@@ -0,0 +1,23 @@
{
"compilerOptions": {
"target": "ES2022",
"lib": ["dom", "dom.iterable", "ES2022"],
"allowJs": true,
"skipLibCheck": true,
"strict": true,
"noEmit": true,
"esModuleInterop": true,
"module": "esnext",
"moduleResolution": "bundler",
"resolveJsonModule": true,
"isolatedModules": true,
"jsx": "preserve",
"incremental": true,
"plugins": [{ "name": "next" }],
"paths": {
"@/*": ["./src/*"]
}
},
"include": ["next-env.d.ts", "**/*.ts", "**/*.tsx", ".next/types/**/*.ts"],
"exclude": ["node_modules"]
}
+16
View File
@@ -0,0 +1,16 @@
import { defineConfig } from "vitest/config";
import react from "@vitejs/plugin-react";
import { fileURLToPath } from "node:url";
export default defineConfig({
plugins: [react()],
test: {
environment: "node",
include: ["src/**/*.test.ts", "src/**/*.test.tsx"],
},
resolve: {
alias: {
"@": fileURLToPath(new URL("./src", import.meta.url)),
},
},
});