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:
@@ -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
|
||||
@@ -0,0 +1,3 @@
|
||||
{
|
||||
"extends": "next/core-web-vitals"
|
||||
}
|
||||
@@ -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/*
|
||||
|
||||
@@ -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.
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
Generated
+8626
File diff suppressed because it is too large
Load Diff
@@ -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"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
const config = {
|
||||
plugins: {
|
||||
"@tailwindcss/postcss": {},
|
||||
},
|
||||
};
|
||||
|
||||
export default config;
|
||||
@@ -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 });
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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 };
|
||||
}
|
||||
@@ -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)}`,
|
||||
};
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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 };
|
||||
}
|
||||
@@ -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";
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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 };
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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",
|
||||
);
|
||||
}
|
||||
@@ -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 20–30c 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);
|
||||
}
|
||||
}
|
||||
@@ -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" },
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -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";
|
||||
@@ -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" },
|
||||
});
|
||||
@@ -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 } };
|
||||
}
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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),
|
||||
};
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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"]
|
||||
}
|
||||
@@ -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)),
|
||||
},
|
||||
},
|
||||
});
|
||||
Reference in New Issue
Block a user