Broker dashboards show you what you own. They rarely show you what you're actually exposed to.
I wanted answers to questions my broker's UI could not give me: how correlated are my positions with each other, what is my portfolio's beta once cash is accounted for, what was my return if you strip out the money I deposited along the way.
Spreadsheets solve this once and then rot. So I wired the account directly into an LLM through MCP and let it pull live data before every calculation.
Here is the architecture, the code that actually computes the numbers, and — more usefully — the five ways I got wrong answers before I got right ones.
What MCP gives you here
The Model Context Protocol lets an assistant call external tools with structured inputs and outputs. Interactive Brokers exposes an MCP server, so the assistant gets functions like:
-
get_account_positions— current holdings, quantities, market values -
get_account_trades— executed trades with prices and timestamps -
get_price_history— OHLCV bars for a contract -
search_contracts— resolve a ticker into a contract id -
get_pa_performance_all_periods— performance across standard periods
The important part is not that an AI can read your account. It's that the data arrives structured and current, so the analysis code never runs on a stale CSV.
The division of labour that works:
- The assistant calls MCP tools and collects raw data
- The data goes into a Python sandbox
- pandas and numpy do the maths
- The assistant interprets the output
Step 3 matters. Do not let a language model do arithmetic on returns. Make it write code that does the arithmetic.
Getting prices you can actually compare
First trap, and it bites immediately: you cannot look up a contract by ticker string alone.
search_contracts("LLY") returns more than twenty rows — the NYSE primary listing, a German listing, a Mexican listing, a Canadian CDR, plus leveraged ETFs whose symbols merely start with the same letters. Pick the wrong row and you are correlating your portfolio against a thinly traded foreign listing.
Filter on an exact symbol match and the primary listing:
def resolve(rows, symbol, country="US"):
exact = [r for r in rows
if r["symbol"] == symbol
and r.get("country_code") == country
and any(s["security_type"] == "STK" for s in r["sections"])]
if not exact:
raise ValueError(f"no primary listing for {symbol}")
return exact[0]["underlying_contract_id"]
Then pull bars for each holding plus a benchmark, and align them:
import pandas as pd
series = {}
for ticker, cid in contract_ids.items():
bars = get_price_history(cid, period="1y", bar="1w")
s = pd.Series(
{pd.Timestamp(b["t"], unit="ms").normalize(): b["c"] for b in bars}
)
series[ticker] = s
prices = pd.DataFrame(series).dropna(how="any")
That dropna(how="any") is doing real work. Instruments listed at different times, halted days and differing holiday calendars will otherwise leave you computing a correlation over misaligned dates, which produces a number that looks plausible and means nothing.
The calculations
Use log returns. They are additive across time, which makes the drawdown curve and the compounding trivially correct.
import numpy as np
rets = np.log(prices / prices.shift(1)).dropna()
corr = rets.corr()
For portfolio-level numbers you need weights. Include cash as an explicit column with zero return:
weights = pd.Series({
"MSFT": 0.260, "NVDA": 0.198, "AMZN": 0.101,
"GEV": 0.053, "CRSP": 0.006, "POET": 0.005,
"CASH": 0.372,
})
assert abs(weights.sum() - 1) < 1e-9
rets["CASH"] = 0.0
port = (rets[weights.index] * weights).sum(axis=1)
Beta against the benchmark, annualised volatility from weekly data, and maximum drawdown:
cov = np.cov(port, rets["SPY"])
beta = cov[0, 1] / cov[1, 1]
vol_annual = port.std() * np.sqrt(52)
curve = np.exp(port.cumsum())
drawdown = curve / curve.cummax() - 1
max_dd = drawdown.min()
np.exp(port.cumsum()) rather than (1 + port).cumprod() — because these are log returns. Mixing the two conventions is the most common silent error in this kind of script.
Five traps that produce confident wrong answers
Cash destroys your beta, and that is the point. My portfolio showed a beta of 0.98 — pleasantly market-like. Then I recomputed on invested capital only, excluding the 37% sitting in cash, and got 1.56. Both numbers are true. They answer different questions: one is "how does my account move", the other is "how risky are my actual picks". Publish the wrong one and you are lying to yourself about your stock selection.
Deposits silently inflate or deflate your return. If you add money mid-period, naive (end - start) / start attributes your own deposit to performance. You need time-weighted return, which chops the period at every cash flow:
def twr(values, flows):
"""values: account value at each flow date, flows: cash added (+) or withdrawn (-)"""
factors = []
for i in range(1, len(values)):
begin = values[i - 1] + flows[i - 1]
factors.append(values[i] / begin)
return np.prod(factors) - 1
This is why a professional portfolio report quotes TWR: it measures the manager, not the depositor.
Daily bars are noise at small portfolio sizes. With a handful of positions, daily correlations swing wildly week to week. Weekly bars over a year gave me stable, reproducible numbers. Fewer observations, far more signal.
Closed positions vanish. get_account_positions returns what you hold now. Every stock you sold — including the ones you sold at a loss — is simply absent. Any performance analysis built only on current positions has survivorship bias baked in. You need get_account_trades to reconstruct history.
Correlation is not diversification. I was considering adding a position and assumed a new sector meant new risk exposure. The matrix said otherwise: correlation 0.58 with my largest holding and 0.50 with the portfolio as a whole. Meanwhile a position I had thought of as unrelated came back at −0.18 and was doing the actual diversifying. One command answered what a year of intuition had gotten backwards.
What this does not solve
It computes. It does not decide.
The numbers tell you your volatility is 23% against the benchmark's 11%. They do not tell you whether that is appropriate for you. They will happily produce a precise correlation matrix for a portfolio built on a bad thesis.
There is also a discipline risk worth naming: when analysis becomes this cheap, the temptation is to run it constantly and trade on the noise. The correct cadence for portfolio-level statistics is weekly at most. I check mine on Fridays and otherwise leave it alone.
Worth doing?
For a static allocation you rebalance twice a year — probably not. A spreadsheet is fine.
For an actively managed portfolio where you want correct exposure numbers on demand, without maintaining a data pipeline, it removed the friction that used to make me skip the analysis entirely. That is the real gain: not speed, but the fact that the calculation now actually happens.
I keep a public portfolio with the full methodology and weekly numbers — the scoring system I run every position through is written up here.
Top comments (0)