My AI Portfolio Reviewer, Part 1: The Monthly GCP + Claude Pipeline That Reviews My Portfolio So I Don't Have To
A GCP + Claude pipeline that reviews a real portfolio every month and emails the verdict automatically — no manual review, no dashboard to check. Part 1 of a 3-part series.

📖 Preface
This isn’t a typical GCP how-to — it’s the real story behind a personal portfolio I’ve been building for years, and the automation I eventually needed to keep it honest. Three parts, in order: this one is the system itself — the problem, the architecture, why it’s built this way. Part 2 gets into what it actually costs to run Claude inside it, and where that cost came from. Part 3 — still pending real hardware — takes it off my inbox and onto a screen.
Real data throughout, every step numbered so I can point back to exactly what I mean. Let’s get into it. 🚀
🎯 Intro
It’s been close to four years of actively investing across asset classes — equity stocks, REITs, InvITs, gold and silver ETFs — on one simple strategy: pick quality, buy it cheap, and hold it unless the quality itself starts to deteriorate.
That strategy worked. The portfolio was beating its benchmarks. But somewhere around a hundred stocks and tickers in, a real problem came into focus: how do you actually keep tabs on that many positions, every month, to catch the ones quietly losing quality before it’s too late? By hand, that’s not a monthly review anymore — that’s a job I don’t have room for.
So I built a pipeline to do it instead.
Concretely, the gap: a portfolio with real shape now — a locked split across equity, banking, gold, silver, REITs, and InvITs — needs the same question asked, every month, about every holding that’s dropped meaningfully below cost: is the damage to the price, or to the business? Doing that by hand, consistently, across a hundred positions, isn’t a realistic ask of anyone.
🛠️ The approach
A monthly, unattended GCP pipeline: it screens my holdings — currently around 80, and growing every month as I keep adding — applies Buffett’s temporary-vs-permanent-impairment framework to whichever ones dropped far enough to matter, and emails the answer — not a dashboard to remember to open, an email that lands whether or not I go looking for it. The email carries a money-flow ledger (what’s coming in, what’s going out, what stays uninvested), an asset-allocation snapshot against the locked targets, and a check on how evenly the equity picks are actually weighted against each other.
That “locked targets” phrase does real work every cycle. Every new rupee gets allocated against a fixed split across six asset classes — equity, banking, gold, silver, REITs, InvITs — decided once, ahead of time, precisely so a good or bad month never becomes an excuse to freelance the allocation on the fly. The pipeline’s allocation stage checks incoming capital against this split every single run.

Gold and silver share a combined 10% slot, but that doesn’t mean a rigid 50/50 split every month. A gold-silver-ratio (GSR) rule tilts each month’s new gold/silver purchase toward whichever metal is trading cheap relative to the other — a modest lean, not a bet — while keeping the combined 10% target itself locked. Same idea as the impairment check on equities: the target doesn’t move, only the path to it adapts to what the market’s actually offering that month.
I read it. I can override any of it. Nothing executes on its own — this is a recommendation engine, not an autopilot.
🏗️ Architecture

Every box above is a real, deployed piece — not a conceptual sketch. Numbered so the steps below can be referenced directly.
Step 1 — a Claude scheduled routine, not this pipeline itself. Zerodha requires a live login every trading day; that can’t be scripted away without storing a broker password and TOTP secret, which wasn’t a trade-off worth making for a once-a-month convenience.
- Runs on a plain recurring cron (
30 3 25 * *— 9:00am IST, the 25th of every month), not a self-rescheduling one-shot — a fixed cron self-heals if a run is incomplete (no login click that month), it just retries next month on schedule. - Uses its own
ToolSearchto find and call the Kite login tool, presents the authorization link, and pushes a mobile notification so the login isn’t missed. - Pulls raw holdings via the Kite MCP connector’s
get_holdings— no filtering, scoring, or analysis at this stage, deliberately: Step 1’s only job is a clean pull. - Saves the result as a dated CSV/Sheet into a fixed Drive folder (“Zerodha Monthly Holdings”).
- Runs on Haiku 4.5, not a larger model — fetch-and-save has no judgment call in it, so there’s nothing here that would benefit from more reasoning depth.

The routine’s own instructions aren’t shown here on purpose — the config above is real evidence enough without publishing the internal prompt for a personal financial tool.
Step 2 — Google Drive holds that Sheet, named with the pull date. Step 4 checks this filename against the current month before doing anything — if the latest file isn’t from this month, the whole cycle waits rather than processing stale data.

For scale: here’s what that Sheet is actually built from — my real Zerodha holdings screen, 83 positions and counting, every rupee figure masked. This is the ~80 holdings Step 6 screens every month.

Step 3 — Cloud Scheduler fires the next day, the 26th — deliberately one day after Step 1, buffer time for the manual login click to have actually happened. It sends an authenticated HTTP POST to Step 4’s endpoint with an empty body (a request body is only used to override the budget for a manual test trigger).

Step 4 — Cloud Function (Gen2), HTTP-triggered, --no-allow-unauthenticated
(invoked via an identity token, not open to the public internet).
- Checks landing/processing status for this
run_idfirst — idempotent by design, so a retried invocation never re-lands or reprocesses what already succeeded. - Downloads the CSV from Step 2 and lands it via a BigQuery load job (see Step 5).
run_idis derived directly from the Drive filename’s date, not generated separately — one less thing that can drift out of sync.

Step 5 — BigQuery, dataset sudhirkenguva.zerodha_pipeline, four tables:
zerodha_holdings_pipeline— the landing table, one row per holding per run.averaging_screening— Step 6/7’s per-stock trigger and verdict state.claude_usage— one row per Claude API call: model, input/output tokens,cost_usd— the real numbers Part 2 is built on.equity_parity_snapshots— one row per run, tracking the concentration score (HHI) over time.- Loaded with
load_table_from_json(), not the streaming insert API — streaming inserts leave rows in a buffer that blocksUPDATE/DELETEfor a while, which breaks a pattern this pipeline relies on: insert a batch, then update it (e.g. Step 6/7’s verdicts) minutes later in the same run.

📊 Real schema, pulled straight from INFORMATION_SCHEMA — claude_usage:
run_id (STRING) call_type (STRING) tradingsymbol (STRING)
model (STRING) input_tokens (INT64) output_tokens (INT64)
cost_usd (FLOAT64) called_at_ist (DATETIME)
💰 And real rows from an actual production run (run_id=2026-08-20) — API cost data
only, nothing about the underlying portfolio:
| Symbol | Call | Model | In tokens | Out tokens | Cost |
|---|---|---|---|---|---|
| IRCTC | stage_b | sonnet-5 | 78,981 | 1,998 | $0.178 |
| ITC | stage_b | sonnet-5 | 79,968 | 2,861 | $0.189 |
| JYOTIRES | stage_b | sonnet-5 | 75,273 | 2,466 | $0.175 |
| KSOLVES | stage_b | sonnet-5 | 51,484 | 1,509 | $0.118 |
| PIIND | stage_b | sonnet-5 | 44,177 | 1,161 | $0.100 |
| — | gsr_lookup | haiku-4.5 | 9,867 | 138 | $0.011 |
| — | notes | haiku-4.5 | 410 | 108 | $0.001 |
(A few stocks show a second, more expensive row — a retry after an earlier partial failure that same day. Real systems retry; the ledger shows it honestly rather than only keeping the clean run.)
From there, three stages, read straight off Step 5’s data:
-
Step 6 — a free price screen. Pure Python, no API call.
- Flags a holding when it’s ≥15% below average cost (
TRIGGER_PCT_BELOW_AVG_COST) and under 8% of total portfolio value (TRIGGER_MAX_POSITION_WEIGHT_PCT) — the second threshold exists so an already-large position doesn’t get flagged for more capital on top of an existing overweight. - Of ~80 holdings, this typically flags around 5 in a given month.
- Flags a holding when it’s ≥15% below average cost (
-
Step 7 — the judgment call. For each stock Step 6 flagged:
claude-sonnet-5,effort: "low", theweb_searchtool capped at 4 searches with a prompt-enforced floor of 2 (a range, not a fixed number, so the model can still reach for one more search on a messier stock).- Returns structured JSON —
cause_classification,ground_truth_signal,management_response,balance_sheet_health,confidence_tier,confidence_pct,verdict,short_reason,rationale,sources— not prose, so a downstream step never has to re-parse free text. - A second, independent, zero-cost-adjacent signal runs the same cycle regardless
of what Step 6 triggered: a Gold-Silver Ratio check (via a small
claude-haiku-4-5web_searchspot-price lookup) that flags a partial trim when gold/silver are priced at an extreme relative to each other.
- The only step in the whole pipeline that spends money — how much, and what it took to get there, is Part 2.
🔍 Real verdicts from that same run — five real holdings, Claude’s actual reasoning, no rupee figures involved (this is the qualitative half of the call, not the sizing):
Symbol Verdict Confidence Why PIIND candidate Medium (62%) Cyclical export destocking, not moat damage; strong balance sheet supports recovery but timing uncertain. IRCTC candidate Medium (62%) Price hit by regulatory fee-sharing fears and valuation reset, not by any real erosion of IRCTC’s ticketing/catering monopoly. ITC candidate Medium (62%) Price hit by tax/policy noise, not a broken moat; cash-rich balance sheet, cigarette/FMCG franchise intact. KSOLVES candidate Medium (62%) Margin dip is a self-funded growth investment, not moat erosion; cash-rich, debt-free, revenue still growing. JYOTIRES not_candidate Low (38%) Stock is down sharply but cause and fundamentals could not be verified this cycle — don’t add yet. -
Step 8 — deterministic allocation. Plain Python, no LLM.
- Splits the investable pool (monthly budget + any Step 7 exit proceeds) across asset classes by a locked target split, holding back a 20% reserve buffer per class for future headroom.
- Within a class, candidates split proportionally by
confidence_pct— a 78%- and a 62%-confidence candidate get different shares, not the same bucket. - Capped so a class already at or above its target weight gets nothing that cycle, and an underweight class can never be pushed past target in one month.
- Builds the unified ledger — every buy/sell as one row, plus totals — that becomes the email.
💡 Real outcome for that same run: all 4 candidates above qualified, and all 4 got ₹0 allocated. Equity was already sitting above its locked target weight, so the headroom cap correctly capped Step 8’s deployable amount to zero — a qualified candidate with a real Claude verdict still doesn’t get funded if the class is already overweight. Nothing to redact here; the real number is zero.
Step 8 writes the verdict and cost back to Step 5, then hands off to Step 9 (MJML → HTML, built in Python — MJML specifically because it sidesteps a confirmed Gmail-Android rendering bug, wrapping each stacked line in its own table row) and Step 10 (Gmail delivery) — the entire interface is one email.
📧 Rebuilt from the real email’s actual sections and real counts — every rupee figure (budget, cost, in/out/remainder, current/post-recommendation weights) masked, since none of that has appeared anywhere in this article and it isn’t starting here:

⚖️ Key decisions and tradeoffs
Three stages (6/7/8), not one big AI call on everything. The judgment call is the only part that actually needs a model — screening by price and sizing by a fixed formula are both things plain code does exactly as well, for free. Steps 6 and 8 do as much of the work as code possibly can, so Step 7 — the expensive part — only runs on the handful of stocks that genuinely need judgment. (What that discipline actually saved, and what it took to tune Step 7 itself, is Part 2.)
Buffett’s framework specifically, not a generic sentiment check. Averaging into weakness only works when the business is still sound — averaging into something genuinely deteriorating is just throwing good money after bad. “Is the damage to the price, or the business?” is the exact question that tells the two apart.
A locked allocation split, not in-the-moment sizing. In-the-moment sizing is exactly the kind of decision market noise is good at corrupting. Fixing the split in advance, and capping any class at its target weight per cycle, takes that decision out of the moment entirely.
A separate signal for gold and silver. Buffett’s framework is a business-quality question; gold and silver aren’t businesses, so it doesn’t apply to them. They get a Gold-Silver Ratio valuation check instead — a deliberately different signal type, not one framework stretched to cover two different kinds of assets.
A parity check on the equity book. Nothing above protects against slow, unintentional concentration — a few winners quietly becoming a much larger share of the book than any single decision ever intended. That’s arithmetic, not judgment, so it’s not a Claude call — it runs over data that’s already landed, for free, every cycle.
Unattended, not autopilot. The goal was never to remove the decision from me — it was to remove the chance that the review just doesn’t happen. Steps 9 and 10 are the entire interface: an email I read and can override beats a system I’d have to fully trust to act without me.
🧰 Tech stack
- Compute / orchestration: Cloud Functions (Gen2, HTTP-triggered), Cloud Scheduler
- Data: BigQuery — holdings landing, screening state, Claude API cost ledger, equity-parity snapshots
- Secrets: Secret Manager — Claude API and Gmail credentials
- AI: Claude API (
claude-sonnet-5for judgment,claude-haiku-4-5for trivial notes) with theweb_searchtool - Language: Python throughout — the screen, the allocation math, the pipeline orchestration
- Email: MJML → HTML, sent via Gmail
- Source data (Step 1): a separate Claude scheduled routine, Kite MCP connector, Zerodha holdings landed monthly via a Google Drive Sheet
🚧 Current scope and boundaries
In scope today: averaging into existing equity/ETF/REIT/InvIT holdings that have dropped meaningfully below cost, plus a separate gold/silver valuation signal. Out of scope, on purpose, not by oversight: it doesn’t discover new stocks to buy, it doesn’t run a full portfolio-wide exit review independent of a price drop, and it never places a trade. Each of those is a deliberate “not yet” with its own roadmap, not a gap nobody noticed.
Part 2 covers what happened when a paid LLM call went into that unattended, scheduled Stage B — the real cost data, the tuning that worked, the tuning that made things worse, and where a retrieval-augmented approach might go next. Part 3 (on hold until there’s something real to show) covers putting the results on a physical screen.