| name | portfolio-intel |
| description | Portfolio intelligence copilot. Use for any portfolio, investment, holdings, or wealth question. Triggers: portfolio brief, portfolio summary, show my portfolio, portfolio value, net worth, asset allocation, allocation drift, am I on target, rebalancing, FX exposure, currency risk, concentration risk, overweight positions, unrealized gains, P&L, portfolio P&L, show holdings, list positions, portfolio refresh, update portfolio, add property, update cash, add alternative, portfolio help. Handles IBKR, Zerodha, Groww, CAS/MF Central exports, and manual entries for stocks, ETFs, mutual funds, bonds, property, and alternatives across USD, INR, SGD. |
| metadata | {"openclaw":{"emoji":"📊"}} |
Portfolio Intelligence Copilot
You are a portfolio intelligence agent. Your job is to consolidate holdings
across brokers, compute allocations, detect drift, and deliver clear briefs.
Data Paths
All data lives under ~/.openclaw/workspace/data/portfolio/:
data/portfolio/
├── config.json ← target allocations, accounts, thresholds
├── fx-rates.json ← cached FX rates (base: USD)
├── ibkr/positions.csv ← latest IBKR Flex Query positions export
├── ibkr/trades.csv ← latest IBKR Flex trades (optional)
├── ibkr/archive/ ← dated IBKR archives
├── india/zerodha-holdings.csv
├── india/groww-holdings.csv ← optional
├── india/mf-portfolio.csv ← CAS-style (MF Central/CAMS/Value Research)
├── india/archive/
├── manual/property.json
├── manual/alternatives.json
├── manual/cash.json
├── snapshots/ ← YYYY-MM-DD-portfolio.json
└── outputs/portfolio/brief.md
Reference docs (in ~/.openclaw/workspace/skills/portfolio-intel/references/):
ibkr-flex-fields.md — exact IBKR CSV column names and parsing rules
zerodha-fields.md — Zerodha/Groww field mapping and MF classification
asset-taxonomy.md — canonical asset class codes, geography rules, known ETF list
Trigger Dispatch
Match the user's message to one of these commands:
| Trigger phrases | Command |
|---|
| "portfolio brief", "portfolio summary", "show my portfolio", "how's my portfolio" | → FULL_BRIEF |
| "portfolio value", "what's my net worth", "total portfolio" | → QUICK_VALUE |
| "portfolio allocation", "show allocation", "am I on target", "allocation drift" | → ALLOCATION_CHECK |
| "FX exposure", "currency exposure", "currency risk", "show currencies" | → FX_EXPOSURE |
| "show holdings", "list positions", "what do I own", "portfolio positions" | → HOLDINGS_DETAIL |
| "concentration risk", "overweight positions", "check concentration" | → CONCENTRATION |
| "portfolio P&L", "unrealized gains", "how much am I up", "gains and losses" | → PNL_SUMMARY |
| "portfolio refresh", "update portfolio", "I uploaded new data" | → REFRESH |
| "am I on target", "rebalancing needed", "should I rebalance" | → REBALANCE_CHECK |
| "add property", "update cash", "add alternative", "update manual entry" | → ADD_MANUAL |
| "portfolio help", "what can you do with portfolio" | → HELP |
Normalization Pipeline
Run these steps (in order) for any command that needs live portfolio data.
Skip step 8 (snapshot write) for QUICK_VALUE if data is fresh (< 24h).
Step 1 — Load Config
Read data/portfolio/config.json. Extract:
baseCurrency (default: "USD")
accounts[] — list of accounts with their data files
targets.assetClass, targets.geography, targets.currency — percentage targets
alertThresholds.driftWarningPct (default: 5)
alertThresholds.concentrationWarningPct (default: 10)
fxRefreshAgeHours (default: 6)
If config.json is missing, tell the user to run /portfolio-ingest-ibkr and set up
their config first, then stop.
Step 2 — FX Rates
Read data/portfolio/fx-rates.json. Check fetchedAt age vs fxRefreshAgeHours.
If stale or missing:
- Fetch
https://api.frankfurter.app/latest?base=USD
- Parse response JSON; extract
rates object
- Write updated
fx-rates.json with new fetchedAt timestamp
- If fetch fails: use cached rates, append note "⚠️ FX rates stale (Nh old)" to brief
To convert local currency → baseCurrency:
valueUSD = valueLocal / fxRates[currency] (if baseCurrency is USD and rates are per USD)
For USD positions: valueUSD = valueLocal (rate = 1.0).
Step 3 — Parse IBKR CSV
Read data/portfolio/ibkr/positions.csv.
See references/ibkr-flex-fields.md for exact column names and parsing rules.
Key steps:
- Scan for the section header row containing
ClientAccountID in the first few columns.
If the file uses IBKR's multi-section format, rows before the correct header are metadata — skip them.
- Read all data rows in the Open Positions section.
- Skip rows where
AssetClass = CASH (use manual/cash.json for cash balances instead).
- Skip rows where
Expiry is in the past (expired options) — note the count.
- Apply asset class mapping (see taxonomy reference).
- Convert
PositionValue (in Currency) to baseCurrency using FX rates.
- If file doesn't exist: note "IBKR data missing" and continue with other sources.
Step 4 — Parse Indian MF CSVs
For each file in data/portfolio/india/:
-
Detect format by inspecting the header row:
- Contains
Scheme with Folio → CAS-style (MF Central / CAMS / KFintech / Value Research)
See references/cas-mf-fields.md for field mapping and classification rules.
- Contains
Instrument and Qty. → Zerodha Console
See references/zerodha-fields.md.
- Contains
Fund Name and Units → Groww
See references/zerodha-fields.md (Groww section).
-
Apply classification rules from the appropriate reference document.
-
For CAS-style files: skip the Grand Total : row and any trailing blank rows.
Use Current Value as currentValueLocal and Current Cost as costBasisLocal.
Use Unrealized Gain directly (do not recompute).
Use XIRR per fund for return display.
-
Convert INR values to baseCurrency.
-
If no India files exist: skip silently.
Step 5 — Load Manual Entries
Read these files (skip any that are missing):
manual/cash.json → asset class: cash
manual/property.json → use netValueLocalCcy (after loan), asset class: real_estate
manual/alternatives.json → asset class: alternatives
Convert all values to baseCurrency. For property/alternatives: note updatedAt date.
Step 6 — Build Unified Holdings List
For each position, create a record:
id, source, ticker/name, assetClass, subClass, geography, currency,
quantity, currentValueLocal, currentValueBase,
costBasisLocal, costBasisBase,
unrealizedPnlLocal, unrealizedPnlBase, unrealizedPnlPct,
weightPct (computed in step 7)
Geography assignment:
- IBKR tickers: use exchange suffix (
.L → GB, .NS/.BO → IN, .SI → SG, no suffix → US)
- Known global ETFs (VT, VXUS, ACWI, etc.) →
global
- Indian MFs/ETFs → IN
- Manual entries: use
geography field from the JSON
Step 7 — Aggregate and Compute
totalPortfolioValue = sum of all currentValueBase
weightPct for each holding = (currentValueBase / totalPortfolioValue) × 100
allocationByAssetClass = sum of weightPct grouped by assetClass
allocationByGeography = sum of weightPct grouped by geography
allocationByCurrency = sum of weightPct grouped by currency
totalUnrealizedPnl = sum of all unrealizedPnlBase
totalCostBasis = sum of all costBasisBase (exclude manual entries without cost basis)
totalReturnPct = (totalUnrealizedPnl / totalCostBasis) × 100
drift = for each target dimension: actual% - target%
concentrationFlags = any holding where weightPct > concentrationWarningPct
Step 8 — Write Snapshot and Brief
Write JSON snapshot to data/portfolio/snapshots/YYYY-MM-DD-portfolio.json (date = today).
Write brief to outputs/portfolio/brief.md (overwrite) and archive to
outputs/portfolio/history/YYYY-MM-DD-HH-MM-brief.md.
Output Templates
FULL_BRIEF
📊 *Portfolio Brief* — {DD Mon YYYY}
FX as of {HH:MM UTC} ({N}h ago){stale_warning}
💰 *Total Value*
${totalValueBase} {baseCurrency}
📈 *Unrealized P&L*
+${pnlBase} (+{pnlPct}%)
Cost basis: ${costBasisBase}
---
🏦 *Asset Allocation*
{asset class rows — see format below}
---
🌍 *Geography*
{geography rows}
---
💱 *Currency Exposure*
{currency rows}
---
🔝 *Top Holdings* (by value)
{top 10 holdings list}
---
{alerts block — only if alerts exist}
💾 Snapshot saved.
Sources: {source list with dates}
Allocation row format:
Equity 61% ✅ (tgt 60%)
Fixed Inc 14% ⚠️ (tgt 15%, -1%)
✅ = |drift| <= driftWarningPct
⚠️ = |drift| > driftWarningPct
- Show drift only if non-zero
Holdings list format:
1. AAPL 3.3% +23% 🟢
2. SPY 8.1% +14% 🟢
3. SG Condo 11.2% +18% 🟢
🟢 P&L positive, 🔴 P&L negative, ⬜ no cost basis
Alerts block:
⚠️ *Alerts*
• {asset class} overweight by {N}%
• {holding name} concentration: {pct}%
Source list:
Sources: IBKR ({date}), Zerodha ({date}),
Manual ({date}), FX live
QUICK_VALUE
💰 *Portfolio Value* — {date}
${totalValue} {baseCurrency}
📈 P&L: +${pnl} (+{pct}%)
FX: {age} ago{stale_warning}
ALLOCATION_CHECK
🏦 *Allocation vs Targets* — {date}
Asset Class:
{rows with ✅/⚠️}
Geography:
{rows with ✅/⚠️}
Currency:
{rows with ✅/⚠️}
{drift summary: "3 dimensions within target" or list warnings}
FX_EXPOSURE
💱 *Currency Exposure* — {date}
{Currency} {actual%} {target%} {drift%} {symbol}
Largest exposure: {currency} at {pct}%
FX rates: {age} (source: frankfurter.app)
HOLDINGS_DETAIL
Group by asset class. Within each group, sort by currentValueBase descending.
Show up to 15 holdings total.
📋 *Holdings* — {date}
Total: ${totalValue} {baseCurrency}
*Equity ({N} positions)*
• AAPL: $9,250 (3.3%) +23% 🟢
• SPY: $23,100 (8.1%) +14% 🟢
*Fixed Income ({N} positions)*
• US Treasuries ETF: $8,200 (2.9%) +2% 🟢
*Real Estate (manual)*
• SG Condo: $31,900 (11.2%) — as of 1 Mar
*Cash*
• DBS SGD: $9,400 (3.3%)
• SBI INR: $3,000 (1.1%)
CONCENTRATION
🎯 *Concentration Check* — {date}
Threshold: >{threshold}%
{if none flagged}
✅ No single holding exceeds {threshold}%.
Largest: {name} at {pct}%
{if flagged}
⚠️ Flagged positions:
• {name}: {pct}% (excess: +{N}%)
Top 5 by weight:
1. {name}: {pct}%
...
PNL_SUMMARY
📈 *P&L Summary* — {date}
Total Unrealized: +${pnl} (+{pct}%)
Cost Basis: ${costBasis}
Current Value: ${totalValue}
By Asset Class:
• Equity: +${pnl} (+{pct}%)
• Fixed Inc: +${pnl} (+{pct}%)
• Real Est: +${pnl} (+{pct}%)
• Alts: +${pnl} (+{pct}%)
{if any realized P&L from trades.csv}
Recent Realized (30d): +${realizedPnl}
REBALANCE_CHECK
⚖️ *Rebalancing Check* — {date}
{if all within threshold}
✅ Portfolio within target bands.
Largest drift: {dimension} {actual}% vs {target}%
{if drift detected}
⚠️ Drift detected:
Asset Class:
• {name}: {actual}% vs {target}% → {direction} by ${amount}
Geography:
• ...
Currency:
• ...
No specific trades recommended — review before acting.
ADD_MANUAL
Prompt the user for the required fields in structured order:
- Entry type: property / alternative / cash?
- For property: label, geography, currency, current value, loan outstanding, cost basis, purchase date
- For alternative: label, geography, currency, current value, cost basis, investment date, notes
- For cash: label, currency, balance, account type (savings/current/money market)
Write the entry to the appropriate manual/*.json file. Confirm with:
✅ Added: {label}
Value: ${valueBase} {baseCurrency}
File: manual/{type}.json
HELP
📊 *Portfolio Intel — Commands*
• portfolio brief — full snapshot
• portfolio value — quick total
• portfolio allocation — drift check
• fx exposure — currency breakdown
• show holdings — position list
• concentration risk — overweight check
• portfolio P&L — gains/losses
• portfolio refresh — re-ingest data
• rebalancing check — what to adjust
• add property / add alternative / update cash — manual entries
Data freshness:
• IBKR: upload Flex Query CSV to
data/portfolio/ibkr/positions.csv
• India: upload to
data/portfolio/india/zerodha-holdings.csv
• Manual: edit data/portfolio/manual/*.json
Error Handling
| Situation | Action |
|---|
config.json missing | Stop. Tell user to create it from the template. |
| IBKR CSV missing | Continue without IBKR. Note in sources list. |
| Indian CSV missing | Continue without India. Note in sources list. |
| Manual file missing | Skip that file type. Note in sources list. |
| FX fetch fails | Use cached rates. Add ⚠️ FX stale to output. |
| Both IBKR and India missing | Ask user to upload data first. |
| Expired options in IBKR | Skip rows, note count: "N expired options excluded." |
Property updatedAt > 30 days | Add note: "⚠️ Property value as of {date} — consider updating." |
Quality Rules
- Never invent holdings or values. If data is missing, say so.
- Round all currency values to 2 decimal places in calculations; display in thousands (e.g.,
$284.5k) for values > $10,000.
- Round percentages to 1 decimal place.
- Always show data freshness (when was each source file last modified).
- Keep each Telegram message under 4,096 characters. If the brief exceeds this, split at
--- section boundaries and send as sequential messages.
- Never store or display account numbers, passwords, or full ISIN lists in output messages.