# Advisor & Recommendations — Design

Date: 2026-05-17
Status: Implemented

## Problem

firma tracks assets accurately but stops at description. The MCP server
instructions ask the model to "behave like an advisor," yet the advice
is improvised in prose on every turn — unreliable, and invisible to the
CLI. firma should answer the questions a user actually has: *how should
I invest this week — lean aggressive or defensive? what is worth
looking at?* — and answer them the same way through the CLI and through
Claude.

## Principle

**Asset advisory, not investment advisory.** firma advises on the whole
wealth picture — it does not pick stocks. The engine never says "buy X."
It surfaces deterministic conclusions: where capital is idle, where a
drawdown would force a sale, what is moving next to what the user
already owns.

**Analysis is baked into tool results, not prose instructions.** Every
trigger condition and every supporting number is computed in code. The
model narrates the result; it never invents the trigger or the figures.

**Two symmetric arms.** Risk recommendations anchor on *consequence*
(what happens if a plausible drawdown hits). Growth recommendations
anchor on *opportunity cost* (what idle or under-deployed capital costs
per year). Both are arithmetic.

## The risk reframe

A scalar "riskiness" verdict is noise for a high-risk/high-return
investor — concentration is intentional for them. So the engine does not
grade riskiness. It computes **scenario outcomes**: "top position −50%"
→ recomputed net worth, goal year, cash runway.

Two anchors are tolerance-independent and fire regardless of
`risk_tolerance`:

- **Forced-seller risk** — a plausible drawdown (sized from holding
  volatility) drains the cash runway to the point of selling at the
  bottom. Bad for everyone.
- **Goal-feasibility breach** — the same drawdown makes
  `target_net_worth_usd` / `retirement_target_year` mathematically
  unreachable.

`risk_tolerance` is a **severity dial, not a firing gate**: the same
scenario is `warning` for a conservative profile, `info` for an
aggressive one.

## Architecture

A new use-case package `@firma/advisor`, depending on `@firma/domain`
only. Single entry point:

```
analyze(context) -> { stance, moves, watchlist }
```

`context` is a fully-assembled snapshot — holdings, concentration,
trajectory, cash flow (balance + flow), macro snapshot, stress, regime,
FX, and the market data for watchlist sourcing. The advisor fetches
nothing; it is pure. Each recommendation type is a pure rule function:
`context -> Recommendation[]`.

`@firma/brief` stays the **combining layer**: it gathers the context,
calls `analyze`, and assembles the final brief payload alongside the
existing raw sections (movers, news, earnings). The intelligence lives
in `advisor`; `brief` only composes.

## The brief — three layers

Both `firma brief` (CLI) and `get_brief` (MCP) render the same payload.

### 1. Stance — "this week: aggressive or defensive"

A deterministic verdict at the **intersection of market signal and
personal capacity**. The market alone is investment advice; market ∩
personal capacity is asset advice.

| Market (regime / stress) | Personal capacity (runway, goal pace, idle cash) | Stance |
|---|---|---|
| Risk-on | sufficient | **aggressive** — deploy capital |
| Risk-on | thin runway | **mixed** — opportunity exists, shore up runway first |
| Risk-off | thin runway | **defensive** — raise cash, derisk |
| Risk-off | well ahead of goal + thick runway | **contrarian-aggressive** — buy into weakness |

The same market produces a different stance per user — deterministic yet
personalised.

### 2. Moves — ranked recommendations

Six types (tax is out of scope — low value for this user):

- **deploy** — idle cash sitting undeployed for N months; annualised
  opportunity cost. Trigger from balance + flow history.
- **contribution** — income-minus-expense surplus exceeding actual
  contributions.
- **tactical** — regime Risk-on while cash-heavy = under-exposed to
  upside; or Risk-off guidance to hold.
- **derisk** — scenario-based; fires on forced-seller risk or
  goal-feasibility breach (see risk reframe).
- **runway** — cash buffer thin relative to expenses.
- **rebalance** — a position dominates such that a plausible drawdown
  meaningfully delays the goal; framed as scenario, not absolute
  threshold.

Stance orders the list: an aggressive stance floats deploy/tactical;
a defensive stance floats derisk/runway. Ranking is by `severity`.

### 3. Watchlist — new ticker candidates

Discovery surface, not a buy list. Deterministic sources only:

- **peers** of existing holdings (Finnhub peers).
- **sector movers** in sectors the user is exposed to.
- **news-linked** tickers appearing in holdings' news.

Each item states the relation ("peer of NVDA, +6.2% this week"), never
a recommendation to buy.

## Recommendation shape

```
Recommendation {
  type: 'deploy' | 'contribution' | 'tactical' | 'derisk' | 'runway' | 'rebalance'
  severity: 'critical' | 'warning' | 'info'
  headline: string
  rationale: string        // the quantified "why" that triggered it
  action: string           // concrete suggested action
  metrics: Record<string, number>   // structured figures backing it
  drill_down: { tool: string, args?: object } | null
}

WatchlistItem {
  ticker: string
  relation: 'peer' | 'sector_mover' | 'news_linked'
  context: string
  drill_down: { tool: 'show_valuation', args: { ticker } }
}

Stance {
  bias: 'aggressive' | 'mixed' | 'defensive' | 'contrarian_aggressive'
  market_rationale: string
  personal_rationale: string
  inputs: { regime, stress, runway_months, goal_pace, idle_cash_usd }
}
```

## Tool orchestration

The second goal: Claude reliably calls the right tools mid-conversation.
Solved with data, not prose.

- **The brief is a routing table.** Every Move and WatchlistItem carries
  a `drill_down` pointer — the exact tool and args to call next. Claude
  follows the pointer instead of guessing. A pointer is a *promise that
  there is more there*: it is set only when the target tool genuinely
  adds depth the brief omitted. If the brief already fully covered the
  dimension, `drill_down` is `null` — so no wasted call is ever invited.
- **Brief = conclusion, drill-down = full derived data.** The brief
  carries the recommendation (verdict + headline metrics). The
  drill-down tool carries the *complete analysis the recommendation was
  derived from* — every position, every sub-dimension — which the brief
  deliberately omits for brevity. The recommendation itself appears in
  both, identical, for consistency. So a drill-down is a superset, never
  a subset: the extra call always returns something new.
- **Every tool is an advisor.** A `show_*` tool called standalone (no
  brief) still carries its dimension's recommendation, not raw data
  only.
- **Freshness metadata.** The brief payload carries a `freshness` block
  (prices/FX last-synced dates). Stale data is the main orchestration
  failure mode; surfacing it in the result gives the model a concrete
  reason to call `sync` first.
- **server.instructions stays minimal** — three lines: portfolio/market
  question → `get_brief` first; go deeper → follow the `drill_down`
  pointers; `freshness` stale → `sync` first.

## CLI / MCP parity

`@firma/advisor` is shared use-case code, so `firma brief` and
`get_brief` render the identical Stance / Moves / Watchlist payload.

- MCP `drill_down` pointers name MCP tools (`show_concentration`); the
  CLI renders the equivalent as a command hint (`firma show
  concentration`).
- MCP surfaces `freshness` as a field; the CLI renders it as a line and
  suggests `firma sync`.
- The standalone `show_*` tools and their CLI `firma show *` commands
  both carry their dimension's recommendation.

## Behaviour

`get_brief` / `firma brief` gain Stance / Moves / Watchlist on top of
their current sections. The `show_*` tools and commands gain an embedded
recommendation for their dimension. No existing data is removed; the
recommendation layer is additive.

## Testing

- Pure rule functions in `@firma/advisor` are unit-tested per type with
  fixed `context` fixtures — each trigger boundary and each severity
  band.
- Stance matrix tested across the market × capacity grid.
- The CLI/MCP parity harness asserts `firma brief` and `get_brief`
  return the identical advisory payload.
- `yarn typecheck` / `yarn build` / `npx vitest run` green throughout.

## Out of scope

- Tax recommendations — low value for this user.
- Stock-picking or model-generated buy/sell calls — firma surfaces
  deterministic candidates only.

## Branch

`feat/advisor-recommendations` off `main`.
