Skip to main content
Glama
jamesnjk

trading-mcp-server

by jamesnjk
README.md
# Trading MCP Server

A read-only [Model Context Protocol](https://modelcontextprotocol.io) server that
exposes two real brokerage accounts — Interactive Brokers (US equities, USD) and
Moomoo (Bursa Malaysia, MYR) — as tools an LLM client can query and reason over.

Ask Claude Desktop *"what was my biggest losing trade this year, and how much of
my portfolio is still in ringgit?"* and it answers from live account data, in one
currency, across two brokers that share no common data model.

---

## The problem

Two brokers, two APIs, two currencies, and no shared vocabulary between them.
IBKR reports fills through a socket API with a few days of execution history;
Moomoo reports Bursa deals through a local gateway with no commission field.
Neither reports realized P&L over an arbitrary window, and neither returns a
sector taxonomy that survives being merged with the other.

Answering "how did I do this year?" therefore isn't a data-fetching problem, it's
a normalization problem — and the interesting engineering is in deciding what the
server should compute rather than pass through.

## Architecture

```
┌──────────────────┐   MCP (stdio)    ┌────────────────────────────────────────┐
│  Claude Desktop  │◄────────────────►│  server.py                             │
│  (or any MCP     │  tools/list      │  6 tools, schemas + model-facing docs   │
│   client)        │  tools/call      │  telemetry: logs/tool_calls.jsonl       │
└──────────────────┘                  └───────────────┬────────────────────────┘
                                                      │  ToolContext
                                      ┌───────────────▼────────────────────────┐
                                      │  tools/                                │
                                      │  positions · trade_history · pnl ·     │
                                      │  exposure · health                     │
                                      └───────────────┬────────────────────────┘
                                                      │
                       ┌──────────────────────────────┼───────────────────────┐
                       │                              │                       │
              ┌────────▼────────┐          ┌──────────▼─────────┐   ┌─────────▼────────┐
              │  core/          │          │  brokers/registry  │   │  core/sectors    │
              │  pnl (FIFO)     │          │  mock ⇄ live       │   │  core/fx         │
              │  models · dates │          └──────────┬─────────┘   └──────────────────┘
              └─────────────────┘                     │
                                     ┌────────────────┼────────────────┐
                                     │                │                │
                            ┌────────▼──────┐ ┌───────▼──────┐ ┌───────▼────────┐
                            │ brokers/ibkr  │ │brokers/moomoo│ │ fixture_adapter│
                            │ ib_async      │ │ futu-api     │ │ frozen JSON    │
                            └────────┬──────┘ └───────┬──────┘ └────────────────┘
                                     │                │
                              TWS / IB Gateway   Moomoo OpenD
                                (127.0.0.1:7497)  (127.0.0.1:11111)
```

The layering rule: **only `brokers/*` knows a vendor API exists**. Everything
above it works on the normalized `Position` / `Trade` types in `core/models.py`,
which is what makes a cross-broker tool possible at all.

## Why MCP rather than a function-calling wrapper

A plain OpenAI/Anthropic function-calling script would answer the same questions.
MCP buys three things that matter once this stops being a demo:

1. **Client-agnostic.** The same server process works in Claude Desktop, Claude
   Code, or any other MCP client without touching the server. The tool contract
   is the integration surface, not a particular vendor's SDK.
2. **Discovery.** Tools, schemas and descriptions are served at runtime via
   `tools/list`. Adding a broker or a tool needs no client-side change, which is
   the difference between a demo and something a second person can use.
3. **Process isolation.** The server owns the broker sockets and credentials; the
   client only ever sees tool results. Nothing about the account reaches the
   model except what a tool chose to return.

## Tools

| Tool | Arguments | Returns |
|---|---|---|
| `get_positions` | `broker?` | Holdings with quantity, avg cost, market value, unrealized P&L, sector, base-currency value |
| `get_portfolio_summary` | — | Cross-broker merged view: per-broker value, cash, net liquidation, top 5 holdings, FX block |
| `get_trade_history` | `symbol?`, `start_date?`, `end_date?`, `broker?`, `limit=100` | Fills newest first, with truncation flag and filter echo |
| `get_realized_pnl` | `start_date?`, `end_date?`, `symbol?`, `broker?`, `group_by=symbol\|broker\|none` | FIFO-matched realized P&L, gross/fees split, per-group ranking, precomputed `biggest_winner`/`biggest_loser`, and the closed-lot audit trail |
| `get_portfolio_exposure_by_sector` | `broker?` | Sector and currency weights with an explicit unclassified share |
| `get_server_health` | — | Gateway reachability plus a positions-vs-fills reconciliation check |

All dates are absolute ISO (`YYYY-MM-DD`) and inclusive at both ends.

## Design decisions

**Realized P&L is computed server-side, not read from the broker.** This is the
central judgment call. IBKR exposes a realized-P&L field, but it is scoped to the
current session, uses IBKR's own lot convention, and cannot be re-cut over an
arbitrary date range; Moomoo exposes nothing comparable. Two broker-reported
figures on two different bases cannot be added together and called a portfolio
number. So `core/pnl.py` matches lots FIFO from the fills, defines net P&L once
(gross minus the closing commission plus the prorated opening commission),
attributes each lot to its **closing** date, and returns the closed lots
themselves so any figure can be traced back to the fills behind it. The cost is
that the answer is only as good as the fill history the broker returns — which is
why `get_server_health` reconciles reported positions against the inventory the
fills imply, and says so when they disagree.

**The date filter selects closed lots, not fills.** A lot closed in 2026 has to
match against the buy that opened it in 2024, so the full history is always
replayed before filtering. Filtering fills first would silently produce a wrong
cost basis — the kind of bug that returns a plausible number.

**Relative dates are the model's job, absolute dates are the server's.** The
server rejects "last quarter" outright. That split means a wrong answer is
attributable: either the model resolved the period wrong (visible in
`logs/tool_calls.jsonl`) or the server computed the range wrong. If the server
guessed at relative periods too, those two failure modes would be
indistinguishable in an eval.

**Sectors come from a curated map, not from the brokers.** IBKR's contract
details give an inconsistent industry/category/subcategory triple; Moomoo's Bursa
snapshots give nothing. Neither can key a merged breakdown. `brokers/fixtures/sectors.json`
maps `BROKER:SYMBOL` onto GICS sector names, and anything missing lands in an
explicit `Unclassified` bucket that is reported as a percentage rather than
quietly shrinking every other sector.

**Errors are results, not exceptions.** Every tool returns
`{"status": "error", "error": {"code", "message", "hint"}}` rather than a
protocol-level failure. The `hint` is written for the model — "start OpenD and
retry", "widen the date range", "Bursa symbols are numeric codes" — because that
text is the only recovery signal it gets. An empty result and a broken gateway
are deliberately different shapes: a true zero comes back as `status: ok` with a
note saying it is a real answer, and a down gateway comes back as
`broker_unavailable` pointing at `get_server_health`.

**FX totals are summed at full precision and rounded once.** Rounding each leg
first made `get_positions` disagree with `get_portfolio_summary` by one cent on
the same portfolio. Caught by a test, not by eyeballing output; see the eval
notes below.

## Failure modes handled

| Situation | Behavior |
|---|---|
| Gateway down (one broker) | `get_portfolio_summary` returns `status: partial`, names the unreachable broker, and totals only what it reached |
| Gateway down (queried directly) | `broker_unavailable` with the specific restart instruction for that gateway |
| Unknown symbol | `symbol_not_found`, with the list of symbols the server does know |
| Symbol known, no trades in range | `status: ok`, empty list, note telling the model to widen the range before concluding "no trading" |
| Invalid or inverted date range | `invalid_input` before any gateway work happens |
| Empty portfolio | `status: ok` with an explicit "this is a valid empty portfolio, not an error" note |
| Truncated broker fill history | `get_server_health` flags positions that disagree with the inventory implied by the fills |
| Unexpected server bug | Contained by the telemetry wrapper, returned as `internal_error` with the failing arguments logged |

Every tool call is logged to `logs/tool_calls.jsonl` — name, arguments, latency,
status, result summary. That log is how the eval attributes a bad answer to tool
selection versus tool output.

## Eval

Full question set, hand-computed ground truth and the arithmetic behind it:
[`eval/questions.md`](eval/questions.md). Results table:
[`eval/results.md`](eval/results.md).

16 questions covering cross-broker aggregation, cross-currency conversion,
period P&L, partial-lot commission proration, a true-zero result, and an unknown
symbol. Ground truth was computed by hand from the fixture book **before** any of
it was run through the server.

**Tools-mode pass (data layer): 16/16.** This runs the reference tool plan for
each question directly against the tool functions and checks the number against
ground truth. It validates FIFO matching, FX, filters and error paths. It says
nothing about whether a model picks the right tool.

```bash
BROKER_MODE=mock .venv/bin/python eval/eval_runner.py
```

**Client-mode pass (tool selection): not yet run.** Measuring whether the model
picks the right tool and resolves "last quarter" correctly requires driving the
questions through Claude Desktop and recording what happened —
`eval/runs/TEMPLATE.json`, then `--mode client`. That pass is a manual step and
is honestly reported here as pending rather than implied by the 16/16 above.

### What the first iteration found

Two defects, both surfaced by tests rather than by reading output:

1. **One-cent disagreement between tools.** `get_positions` converted each MYR
   position to USD and summed the rounded values (24,504.04); `get_portfolio_summary`
   converted the aggregate (24,504.05). Two tools, same portfolio, different
   totals — the sort of inconsistency that makes a user stop trusting every other
   number. Fixed by summing at full precision and rounding once at the boundary
   (`core/fx.py`). Ground truth, computed by hand, agreed with the fixed version.
2. **Mock and live disagreed on caveats.** The live Moomoo adapter reports that
   Bursa deals carry no commission data, so its P&L is gross of fees; the fixture
   adapter dropped that caveat, meaning the eval validated an answer the live
   server would have qualified differently. The fixture now carries the same note
   through the same code path.

3. **A missing gateway hung the call instead of failing it.** Exercising live
   mode with neither gateway running, the futu client's internal retry loop never
   returned, so the tool call blocked indefinitely — the worst possible failure
   for an LLM client, which gets no error to reason about and simply stalls. Both
   adapters now TCP-precheck the gateway (`brokers/_net.py`) and fail in ~0.2s
   with a restart instruction. Found only by running the live path with nothing
   to connect to, which is a case fixtures can never cover.

The first is the more interesting one for the design: it is invisible in any
single tool's output and only appears when two tools are asked the same question
two ways — which is exactly what an LLM client does. The third is the reminder
that mock mode validates logic, not integration.

One further live-mode hazard, fixed pre-emptively rather than found: the futu SDK
prints to stdout, and under stdio transport stdout *is* the JSON-RPC stream, so a
single vendor banner would corrupt the protocol and drop the connection. All futu
calls run inside a redirect that routes their output to stderr.

## Setup

```bash
python3 -m venv .venv
.venv/bin/pip install -r requirements.txt
```

Run against the frozen fixture book (no gateways needed):

```bash
BROKER_MODE=mock .venv/bin/python scripts/smoke_client.py
```

Run the tests and the eval:

```bash
BROKER_MODE=mock .venv/bin/python -m pytest tests -q && BROKER_MODE=mock .venv/bin/python eval/eval_runner.py
```

### Live mode

1. **IBKR** — install TWS or IB Gateway, enable *Configure → API → Settings →
   Enable ActiveX and Socket Clients*, and note the port (7497 TWS paper, 7496
   TWS live, 4002/4001 Gateway paper/live).
2. **Moomoo** — install and log into OpenD with a Bursa-enabled account
   (default port 11111).
3. Set `BROKER_MODE=live` and the relevant `IBKR_*` / `MOOMOO_*` variables
   (see `core/config.py` for the full list).

### Claude Desktop

Add to `claude_desktop_config.json` (see `claude_desktop_config.example.json`):

```json
{
  "mcpServers": {
    "trading-portfolio": {
      "command": "/absolute/path/to/trading-mcp-server/.venv/bin/python",
      "args": ["/absolute/path/to/trading-mcp-server/server.py"],
      "env": { "BROKER_MODE": "mock" }
    }
  }
}
```

Restart Claude Desktop; the six tools appear under the connector. Switch `env`
to `"BROKER_MODE": "live"` once both gateways are running.

## Safety

**Read-only by construction.** The IBKR socket is opened with `readonly=True`, so
the connection itself cannot transmit an order, and no order-placement code path
exists anywhere in this repo. Trade execution is an explicit non-goal.

**Nothing sensitive is committed.** No credentials, tokens, account numbers or
API keys are in this repository. Hosts, ports and account identifiers are read
from environment variables at startup; the committed defaults are the published
vendor defaults. The fixture book in `brokers/fixtures/` is synthetic — invented
positions, prices and fills, with placeholder account ids — not a sanitized copy
of a real account. `logs/` is gitignored.

## Non-goals

- **No trade execution.** Read-only, deliberately.
- **No hosting or deployment.** It runs locally next to the gateways it reads.
- **No custom frontend.** The LLM client is the interface.
- **No benchmark comparison** (`compare_performance` vs S&P 500 / KLCI) — it
  needs a market-data source and a return methodology (time-weighted vs
  money-weighted) that would be its own piece of work, and a hand-waved version
  would be the least trustworthy number in the repo.

## Layout

```
server.py                   MCP entrypoint: tool registration, schemas, model-facing docs
core/       models.py       normalized Position / Trade / RealizedLot
            pnl.py          FIFO lot matching
            fx.py           currency conversion
            sectors.py      curated symbol → sector map
            dates.py        ISO parsing and range validation
            errors.py       domain errors with model-facing hints
            telemetry.py    per-call JSONL logging
            config.py       environment configuration
brokers/    base.py         adapter contract
            ibkr.py         ib_async adapter (readonly socket)
            moomoo.py       futu-api adapter (OpenD)
            fixture_adapter.py  frozen-book adapter for mock mode
            registry.py     mock ⇄ live selection
            fixtures/       synthetic book + sector map
tools/                      the six tools, as plain functions
eval/                       question set, ground truth, runner, results
tests/                      48 tests: FIFO engine, tools, failure modes, FX/dates
scripts/smoke_client.py     minimal MCP client for an end-to-end check
```