I'm Mayank — a cloud and data engineer at Snowflake with a background in network security. I built this as a personal project to solve a problem I kept running into: investments spread across five brokerages with no unified view and no real analysis. Everything here is from scratch, self-hosted, and open source.
I got tired of checking five different brokerage apps with no unified view and no real analysis. So I built a self-hosted system that connects to all my accounts, enriches every position with market data, and runs it through Claude for buy/sell/hold recommendations — automatically, three times a week.
Here's how it works and what I learned building it.
Architecture
Plaid API
↓
Python pipeline (agent/)
├── fetch holdings across all brokerages
├── enrich positions — RSI, MACD, Bollinger Bands, fundamentals, news
├── Claude analysis — structured buy/sell/hold per position
└── sync to Supabase
↓
Supabase (Postgres)
↓
Next.js Dashboard
The system is split into two components in one monorepo: a Python backend that runs on a schedule, and a Next.js dashboard that reads from Supabase.
Part 1: The Python Pipeline
Connecting accounts with Plaid
Plaid's investment API gives you unified holdings across brokerages — one access token per institution, one schema for everything. The Development tier is free for up to 100 connections, which is more than enough for personal use.
# plaid_config.py
def get_plaid_client() -> plaid_api.PlaidApi:
configuration = plaid.Configuration(
host=plaid.Environment.Development,
api_key={
"clientId": PLAID_CLIENT_ID,
"secret": PLAID_SECRET,
},
)
return plaid_api.PlaidApi(plaid.ApiClient(configuration))
Connecting a new brokerage is a browser-based flow via Plaid Link. I built a lightweight Flask server that serves the Link UI locally, and a connect_real_account.py script that launches it:
# connect_real_account.py (simplified)
@app.route("/create_link_token", methods=["POST"])
def create_link_token():
response = client.link_token_create({
"user": {"client_user_id": "local-user"},
"client_name": "Financial Analyst",
"products": ["investments"],
"country_codes": ["US"],
"language": "en",
})
return jsonify({"link_token": response["link_token"]})
You open http://localhost:5555, click Connect, Plaid handles the brokerage login, and the access token is saved locally. Tokens are encrypted at rest using PBKDF2+Fernet — the salt is stored separately from the ciphertext so neither file alone is useful.
# token_store.py (simplified)
def save_tokens(tokens: dict) -> None:
salt = os.urandom(16)
kdf = PBKDF2HMAC(algorithm=hashes.SHA256(), length=32, salt=salt, iterations=480000)
key = base64.urlsafe_b64encode(kdf.derive(SECRET.encode()))
encrypted = Fernet(key).encrypt(json.dumps(tokens).encode())
Path(SALT_FILE).write_bytes(salt)
Path(TOKEN_FILE).write_bytes(encrypted)
Fetching holdings
Once accounts are connected, fetching holdings is straightforward. The pipeline normalizes across brokerages into a consistent schema:
holdings_response = client.investments_holdings_get({
"access_token": token,
"options": {"account_ids": account_ids}
})
for holding in holdings_response["holdings"]:
security = securities_by_id[holding["security_id"]]
normalized.append({
"brokerage": nickname,
"ticker": security.get("ticker_symbol"),
"name": security.get("name"),
"quantity": holding["quantity"],
"cost_basis": holding.get("cost_basis"),
"value": holding["institution_value"],
"account_type": account_type,
})
One thing worth knowing: Plaid's investment data is previous close, not real-time. That's a Plaid limitation at the Development tier — fine for a 3x/week analysis pipeline, but worth being aware of.
Enriching positions
For every unique ticker, I pull technicals and fundamentals via yfinance and scrape recent news headlines. The enrichment runs in parallel across all tickers:
import yfinance as yf
from pandas_ta import rsi, macd, bbands
def enrich_ticker(ticker: str) -> dict:
stock = yf.Ticker(ticker)
hist = stock.history(period="6mo")
info = stock.info
close = hist["Close"]
rsi_val = rsi(close, length=14).iloc[-1]
macd_df = macd(close)
bb = bbands(close)
return {
"technicals": {
"price": close.iloc[-1],
"sma_50": close.rolling(50).mean().iloc[-1],
"rsi_14": rsi_val,
"macd": macd_df["MACD_12_26_9"].iloc[-1],
"bb_upper": bb["BBU_5_2.0"].iloc[-1],
"bb_lower": bb["BBL_5_2.0"].iloc[-1],
},
"fundamentals": {
"sector": info.get("sector"),
"pe_trailing": info.get("trailingPE"),
"analyst_target": info.get("targetMeanPrice"),
},
"performance": {
"return_1m": ((close.iloc[-1] / close.iloc[-21]) - 1) * 100,
"pct_from_52w_high": ((close.iloc[-1] / close.max()) - 1) * 100,
},
"news": [
{"title": n["title"], "publisher": n["publisher"]}
for n in stock.news[:5]
],
}
Getting Claude to return structured output reliably
This took the most iteration. The key was being extremely explicit in the system prompt — not just asking for JSON, but defining the exact schema including field names, types, and valid enum values.
The system prompt opens with:
You are a senior portfolio analyst. You will receive a full enriched portfolio
and return a JSON object that EXACTLY matches this schema. Do not include
markdown fences, explanation, or any text outside the JSON object.
Schema:
{
"overall_health": "strong" | "moderate" | "weak",
"summary": string,
"action_items": string[],
"recommendations": [
{
"ticker": string,
"action": "BUY" | "SELL" | "HOLD",
"confidence": "high" | "medium" | "low",
"urgency": "immediate" | "soon" | "no_rush",
"thesis": string,
"bull_case": string,
"bear_case": string,
"key_signals": string[],
"risk_factors": string[]
}
]
}
Even with that, Claude occasionally wraps the response in markdown fences. I added a cleaning step:
cleaned = raw_response.strip()
if cleaned.startswith("```
"):
cleaned = cleaned.split("\n", 1)[1]
if cleaned.endswith("
```"):
cleaned = cleaned[:-3].strip()
analysis = json.loads(cleaned)
Real token numbers from 15 actual runs: ~47K input tokens + ~15K output tokens per run. At claude-sonnet-4-6 pricing ($3/M input, $15/M output) that's ~$0.36/run, ~$5/month at 3x/week.
Syncing to Supabase
After each run, the pipeline pushes everything to Supabase: holdings, enrichment data, analysis report, and recommendations. The service role key bypasses RLS, which makes bulk writes clean:
# sync_to_supabase.py (simplified)
def sync_snapshot(snapshot: dict, analysis: dict) -> None:
run = supabase.table("pipeline_runs").insert({
"trigger": "scheduled",
"status": "success",
"model": analysis["model"],
"input_tokens": analysis["usage"]["input_tokens"],
"output_tokens": analysis["usage"]["output_tokens"],
}).execute()
run_id = run.data[0]["id"]
supabase.table("portfolio_snapshots").insert({
"run_id": run_id,
"snapshot_date": today,
"total_value": snapshot["summary"]["total_value"],
...
}).execute()
Scheduling
On macOS, I use launchd rather than cron — it survives reboots and wakes the machine if it's asleep. A template plist is included in the repo:
<!-- scheduler/macos/com.finanalyst.pipeline.plist.template -->
<key>StartCalendarInterval</key>
<array>
<dict>
<key>Weekday</key><integer>1</integer>
<key>Hour</key><integer>7</integer>
<key>Minute</key><integer>0</integer>
</dict>
<!-- Monday, Wednesday, Friday -->
</array>
The installer prints the exact sed and launchctl commands to activate it.
Part 2: The Next.js Dashboard
Database schema
Supabase sits between the pipeline and the dashboard. The schema has six tables: pipeline_runs, portfolio_snapshots, holdings, enrichment, analysis_reports, and recommendations. RLS is enabled on all of them — authenticated users get read access, the service role key (pipeline-only) gets write access.
-- Row Level Security
alter table holdings enable row level security;
create policy "Auth users can read holdings"
on holdings for select to authenticated using (true);
-- service_role bypasses RLS by default — no explicit policy needed
Dashboard features
The dashboard is built with Next.js 14 App Router, Tailwind, Radix UI, and Recharts. Key views:
Net worth card — pulls latest snapshot total, broken down by account category (taxable, retirement, cash)
Allocation chart — recharts pie chart fromasset_types_jsonin the snapshot
Holdings table — per-position gain/loss, colored by performance
AI recommendations — latest analysis report with per-ticker cards showing action, confidence, bull/bear case
RSU widget — grant tracking with live price refresh
RSU price refresh
RSU grants are stored in Supabase. When you hit the Recalc button, a server action fetches the current price from Yahoo Finance — no API key needed — and updates the grant value in real time:
// app/api/rsu-grants/recalc/route.ts
async function fetchPrice(ticker: string): Promise<number | null> {
const url = `https://query1.finance.yahoo.com/v8/finance/chart/${ticker}?interval=1d&range=1d`;
const res = await fetch(url, {
headers: { "User-Agent": "Mozilla/5.0" },
next: { revalidate: 0 },
});
const json = await res.json();
return json?.chart?.result?.[0]?.meta?.regularMarketPrice ?? null;
}
Vested units are computed server-side from the vesting schedule stored as JSONB:
function computeVestedUnits(schedule: RsuVestingEvent[]): number {
const today = new Date();
today.setHours(23, 59, 59, 999);
return schedule
.filter(e => new Date(e.date) <= today)
.reduce((sum, e) => sum + e.units, 0);
}
The Installer
Getting the install experience right took more work than I expected. The goal: someone clones the repo and runs one command that handles everything — Python deps, Node deps, and all API key setup — with zero silent failures.
The wizard validates every key live before writing anything to disk, using Python stdlib only (urllib) so it works before any deps are installed:
# Anthropic — hits /v1/models, zero tokens consumed
req = Request(
"https://api.anthropic.com/v1/models",
headers={"x-api-key": key, "anthropic-version": "2023-06-01"},
)
with urlopen(req, timeout=8) as r:
sys.exit(0 if r.status == 200 else 1)
# Plaid — hits /institutions/get with the actual credentials
payload = json.dumps({
"client_id": client_id, "secret": secret,
"count": 1, "offset": 0, "country_codes": ["US"],
}).encode()
req = Request(f"{base_url}/institutions/get", data=payload,
headers={"Content-Type": "application/json"})
A few things I hit during testing:
macOS ships bash 3.2, which doesn't support associative arrays (declare -A). Had to replace the status tracking with plain variables and helper functions.
Python 3.12 installed via python.org doesn't land in PATH automatically. The installer now searches a list of known locations (/usr/local/bin/python3.12,/Library/Frameworks/...) before giving up.
hatchlingrequires aREADME.mdin the package directory. Moving to a monorepo broke this — fixed by removing thereadmefield frompyproject.toml.
What I'd Do Differently
Add a token estimate before each Claude call. Token count grows with portfolio size. A pre-call estimate would let users catch runaway costs early.
Cache yfinance calls within a run. If the same ticker appears across multiple accounts, it gets enriched multiple times. Deduplication before enrichment would save time and API calls.
Plaid Development throttles refresh. Holdings data is previous close, not real-time. Worth documenting more prominently for anyone expecting live prices.
Links
- GitHub: https://github.com/mkash25/Claude-powered-AI-native-financial-dashboard
- MIT licensed — self-hosted, your financial data stays in your own Supabase instance
bash install.sh— one command sets up everything including the key validation wizard
Not financial advice — treat Claude's output as a starting point for your own research, not a signal to act on.