Skip to content

Commit a1a1a04

Browse files
committed
Complete multi-account support (phases 5-8)
Merge: read per-account data from v1/<alias>/, include transfers and corporate actions in MergedData, scope trade dedup keys by account. Tax lots: group FIFO by (account_id, symbol) so each account's lots are independent. Add TransfersToSyntheticTrades and TradeTransfersToSyntheticTrades to convert transfer-in records to buy trades for FIFO. Add AccountAlias to UnmatchedSell and PositionDiscrepancy. VerifyPositions compares per (account, symbol). Holdings: combined view aggregates computed positions across accounts by symbol with weighted-average cost basis. Passes transfers and trade transfers through to tax lot computation. README: document new Flex Query sections (Transfers, Trade Transfers, Corporate Actions), accounts config section, per-account data storage, multi-account support section, updated probe command.
1 parent 0210f63 commit a1a1a04

5 files changed

Lines changed: 363 additions & 106 deletions

File tree

README.md

Lines changed: 49 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
# ibctl
22

3-
A CLI tool for analyzing Interactive Brokers (IBKR) holdings and trades. Downloads data via the IBKR Flex Query API, computes FIFO tax lots, and displays holdings with average prices and positions.
3+
A CLI tool for analyzing Interactive Brokers (IBKR) holdings and trades. Downloads data via the IBKR Flex Query API, computes FIFO tax lots, and displays holdings with average prices and positions. Supports multiple IBKR accounts with per-account data storage.
44

55
## Prerequisites
66

@@ -17,10 +17,13 @@ Follow these exact steps in the IBKR portal to create a Flex Query and generate
1717
2. Navigate to **Performance & Reports** > **Flex Queries**.
1818
3. In the **Activity Flex Query** section, click the **+** button in the top right corner of the panel.
1919
4. Set the **Query Name** to something descriptive (e.g., "ibctl").
20-
5. Under **Sections**, add the following three sections, selecting all fields for each:
20+
5. Under **Sections**, add the following sections, selecting all fields for each:
2121
- **Trades**
2222
- **Open Positions**
2323
- **Cash Transactions** (used for FX rate extraction)
24+
- **Transfers (ACATS, Internal)** (captures positions transferred from other brokers)
25+
- **Incoming/Outgoing Trade Transfers** (preserves cost basis and holding period for transferred positions)
26+
- **Corporate Actions** (captures stock splits, mergers, spinoffs)
2427
6. Under **Delivery Configuration**, set:
2528
- **Format**: `XML`
2629
- **Period**: `Last 365 Calendar Days` (this is the maximum; see note below about older history)
@@ -49,7 +52,7 @@ Follow these exact steps in the IBKR portal to create a Flex Query and generate
4952
| Path | Purpose | Override |
5053
|------|---------|----------|
5154
| `ibctl.yaml` | Configuration file in current directory | `--config` flag |
52-
| `<data_dir>/v1/` | Downloaded data cache | Set `data_dir` in config |
55+
| `<data_dir>/v1/<account>/` | Per-account downloaded data cache | Set `data_dir` and `accounts` in config |
5356

5457
## Environment Variables
5558

@@ -65,7 +68,7 @@ Follow these exact steps in the IBKR portal to create a Flex Query and generate
6568
ibctl config init
6669
```
6770

68-
This creates a new `config.yaml` in the configuration directory and prints the file path. Edit it to fill in your Flex Query ID and optional symbol classifications.
71+
This creates a new `ibctl.yaml` in the current directory. Edit it to fill in your Flex Query ID, account mappings, and optional symbol classifications.
6972

7073
### Edit Configuration
7174

@@ -84,6 +87,14 @@ version: v1
8487
data_dir: ~/Documents/ibctl
8588
# The Flex Query ID (required, visible next to your query in IBKR portal).
8689
flex_query_id: "123456"
90+
# Account aliases mapping (required).
91+
# Maps user-chosen aliases to IBKR account IDs.
92+
# Account numbers are confidential — aliases are used in output and directory names.
93+
# Aliases must be lowercase alphanumeric with hyphens.
94+
accounts:
95+
rrsp: "U1234567"
96+
holdco: "U2345678"
97+
individual: "U3456789"
8798
# Directory containing IBKR Activity Statement CSVs (required).
8899
# Organize by account subdirectory. See "Seeding Historical Data" below.
89100
activity_statements_dir: ~/Documents/ibkr-statements
@@ -111,16 +122,19 @@ ibctl config validate
111122
# Set the IBKR token.
112123
export IBKR_FLEX_WEB_SERVICE_TOKEN="your-flex-web-service-token"
113124

114-
# View holdings overview (downloads data automatically if not cached).
125+
# View combined holdings overview (downloads data automatically if not cached).
115126
ibctl holdings overview
116127
ibctl holdings overview --format csv
117128
ibctl holdings overview --format json
118129

119-
# Force re-download of IBKR data.
130+
# Force re-download of IBKR data (all accounts).
120131
ibctl download
132+
133+
# Probe the API to see what data is available per account.
134+
ibctl probe
121135
```
122136

123-
Data is downloaded automatically when commands need it. Use `ibctl download` to force a refresh. Each download merges new data with the existing cache — trades are deduplicated by trade ID, so it is safe to run repeatedly.
137+
Data is downloaded automatically when commands need it. Use `ibctl download` to force a refresh. Each download merges new data with the existing cache — trades are deduplicated by trade ID, so it is safe to run repeatedly. Data is stored per account under `<data_dir>/v1/<account_alias>/`.
124138

125139
## Commands
126140

@@ -130,7 +144,8 @@ Data is downloaded automatically when commands need it. Use `ibctl download` to
130144
| `ibctl config edit` | Edit the configuration file in `$EDITOR` |
131145
| `ibctl config validate` | Validate the configuration file |
132146
| `ibctl download` | Force re-download and cache IBKR data via Flex Query API |
133-
| `ibctl holdings overview` | Display holdings with prices, positions, and classifications |
147+
| `ibctl holdings overview` | Display combined holdings with prices, positions, and classifications |
148+
| `ibctl probe` | Probe the API and show per-account data counts |
134149

135150
## Seeding Historical Data
136151

@@ -143,11 +158,11 @@ IBKR limits all data access (API and portal) to 365 days per request. To get you
143158
mkdir -p ~/Documents/ibkr-statements
144159
```
145160

146-
2. Create a subdirectory for each IBKR account:
161+
2. Create a subdirectory for each IBKR account using your aliases:
147162
```bash
148-
mkdir ~/Documents/ibkr-statements/RRSP
149-
mkdir ~/Documents/ibkr-statements/HoldCo
150-
mkdir ~/Documents/ibkr-statements/Individual
163+
mkdir ~/Documents/ibkr-statements/rrsp
164+
mkdir ~/Documents/ibkr-statements/holdco
165+
mkdir ~/Documents/ibkr-statements/individual
151166
```
152167

153168
3. For each account, log in to [IBKR Account Management](https://www.interactivebrokers.com/portal).
@@ -178,20 +193,35 @@ IBKR limits all data access (API and portal) to 365 days per request. To get you
178193
The Activity Statement CSVs are **seed data** that you manage. ibctl never modifies them. At command time, ibctl:
179194

180195
1. Reads all CSVs from the configured directory (trades, positions, dividends, interest, instrument info)
181-
2. Reads any cached Flex Query data (from `ibctl download`)
182-
3. Merges and deduplicates — Flex Query data takes precedence for overlapping trades
183-
4. Computes tax lots, positions, and holdings from the merged data
196+
2. Reads any cached Flex Query data per account (from `ibctl download`)
197+
3. Merges and deduplicates — CSV data takes precedence for overlapping trades
198+
4. Converts transfers (ACATS) to synthetic trades for FIFO processing
199+
5. Computes tax lots, positions, and holdings from the merged data
184200

185201
To keep data current, the Flex Query API provides the latest 365 days. To add older history, download more CSVs.
186202

203+
## Multi-Account Support
204+
205+
ibctl supports multiple IBKR accounts via the `accounts` section in the config. Each account is identified by an alias (e.g., "rrsp", "holdco") that maps to an IBKR account ID.
206+
207+
- **Downloaded data** is stored per account under `<data_dir>/v1/<alias>/`
208+
- **Holdings overview** shows a combined view aggregated across all accounts
209+
- **Transfers** between accounts and from other brokers (ACATS) are tracked and converted to synthetic trades for accurate FIFO computation
210+
- **Corporate actions** (stock splits, mergers, spinoffs) are captured from the Flex Query API
211+
212+
Account numbers are confidential — only aliases appear in output and directory names.
213+
187214
## Data Storage
188215

189-
Raw API data is cached as protobuf-JSON files under the data directory (`<data_dir>/v1/` as configured in `ibctl.yaml`). Each file stores newline-separated proto JSON (one message per line), serialized using `protojson` with proto field names. Tax lots and derived computations are performed at read time from the merged data (Activity Statement CSVs + cached API data).
216+
Raw API data is cached as protobuf-JSON files under per-account directories (`<data_dir>/v1/<alias>/`). Each file stores newline-separated proto JSON (one message per line), serialized using `protojson` with proto field names. Tax lots and derived computations are performed at read time from the merged data (Activity Statement CSVs + cached API data).
190217

191218
| File | Protobuf Message | Description |
192219
|------|-----------------|-------------|
193-
| `trades.json` | `ibctl.data.v1.Trade` | All trades from the IBKR Flex Query. Each trade includes trade ID, dates, symbol, side (buy/sell), quantity, price, proceeds, commission, currency code, and FIFO realized P&L. |
194-
| `positions.json` | `ibctl.data.v1.Position` | Open positions as reported by IBKR, including quantity, cost basis price, market price, market value, currency code, and unrealized P&L. |
195-
| `exchange_rates.json` | `ibctl.data.v1.ExchangeRate` | Currency exchange rates with date, base/quote currency codes, rate (units + micros), and provider (ibkr or [frankfurter.dev](https://frankfurter.dev)). |
220+
| `<alias>/trades.json` | `ibctl.data.v1.Trade` | All trades for this account. Includes trade ID, account, dates, symbol, side (buy/sell), quantity, price, proceeds, commission, currency code, and FIFO realized P&L. |
221+
| `<alias>/positions.json` | `ibctl.data.v1.Position` | Open positions for this account, including quantity, cost basis price, market price, market value, currency code, and unrealized P&L. |
222+
| `<alias>/transfers.json` | `ibctl.data.v1.Transfer` | Position transfers (ACATS, ATON, FOP, internal) for this account. Transfer-in records are converted to synthetic buy trades for FIFO processing. |
223+
| `<alias>/trade_transfers.json` | `ibctl.data.v1.TradeTransfer` | Transferred trade cost basis records. Preserves original trade date and cost basis for positions transferred from other brokers. |
224+
| `<alias>/corporate_actions.json` | `ibctl.data.v1.CorporateAction` | Corporate action events (stock splits, mergers, spinoffs) for this account. |
225+
| `exchange_rates.json` | `ibctl.data.v1.ExchangeRate` | Currency exchange rates (shared across accounts) with date, base/quote currency codes, rate, and provider (ibkr or [frankfurter.dev](https://frankfurter.dev)). |
196226

197227
Monetary values use `standard.money.v1.Money` with units and micros (6 decimal places). Dates use `standard.time.v1.Date` with year, month, and day fields.

cmd/ibctl/internal/command/holdings/holdingsoverview/holdingsoverview.go

Lines changed: 15 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -81,21 +81,27 @@ func run(ctx context.Context, container appext.Container, flags *flags) error {
8181
if err != nil {
8282
return err
8383
}
84-
// Merge CSV seed data with Flex Query cached data.
85-
dataDirV1Path := config.DataDirV1Path
86-
mergedData, err := ibctlmerge.Merge(csvStatements, dataDirV1Path)
84+
// Merge CSV seed data with Flex Query cached data across all accounts.
85+
mergedData, err := ibctlmerge.Merge(csvStatements, config.DataDirV1Path, config.AccountAliases)
8786
if err != nil {
8887
return err
8988
}
90-
// Compute the holdings overview from merged data.
91-
result, err := ibctlholdings.GetHoldingsOverview(mergedData.Trades, mergedData.Positions, config)
89+
// Compute the combined holdings overview from merged data.
90+
result, err := ibctlholdings.GetHoldingsOverview(
91+
mergedData.Trades,
92+
mergedData.Positions,
93+
mergedData.Transfers,
94+
mergedData.TradeTransfers,
95+
config,
96+
)
9297
if err != nil {
9398
return err
9499
}
95100
// Log any data inconsistencies detected during computation.
96101
logger := container.Logger()
97102
for _, unmatched := range result.UnmatchedSells {
98103
logger.Warn("unmatched sell (buy likely before data window)",
104+
"account", unmatched.AccountAlias,
99105
"symbol", unmatched.Symbol,
100106
"unmatched_quantity", mathpb.ToString(unmatched.UnmatchedQuantity),
101107
)
@@ -134,23 +140,27 @@ func logPositionDiscrepancy(container appext.Container, d ibctltaxlot.PositionDi
134140
switch d.Type {
135141
case ibctltaxlot.DiscrepancyTypeQuantity:
136142
logger.Warn("position quantity mismatch",
143+
"account", d.AccountAlias,
137144
"symbol", d.Symbol,
138145
"computed", d.ComputedValue,
139146
"reported", d.ReportedValue,
140147
)
141148
case ibctltaxlot.DiscrepancyTypeCostBasis:
142149
logger.Warn("position cost basis mismatch",
150+
"account", d.AccountAlias,
143151
"symbol", d.Symbol,
144152
"computed", d.ComputedValue,
145153
"reported", d.ReportedValue,
146154
)
147155
case ibctltaxlot.DiscrepancyTypeComputedOnly:
148156
logger.Warn("position computed but not reported by IBKR",
157+
"account", d.AccountAlias,
149158
"symbol", d.Symbol,
150159
"computed_quantity", d.ComputedValue,
151160
)
152161
case ibctltaxlot.DiscrepancyTypeReportedOnly:
153162
logger.Warn("position reported by IBKR but not in computed data",
163+
"account", d.AccountAlias,
154164
"symbol", d.Symbol,
155165
"reported_quantity", d.ReportedValue,
156166
)

internal/ibctl/ibctlholdings/ibctlholdings.go

Lines changed: 48 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -66,32 +66,71 @@ func HoldingOverviewToRow(h *HoldingOverview) []string {
6666
// GetHoldingsOverview computes the holdings overview from merged trade and position data.
6767
// Trades are used to compute FIFO tax lots and average cost basis.
6868
// Positions provide the latest market prices and are used for verification.
69-
func GetHoldingsOverview(trades []*datav1.Trade, positions []*datav1.Position, config *ibctlconfig.Config) (*HoldingsResult, error) {
70-
// Compute tax lots from merged trades.
71-
taxLotResult, err := ibctltaxlot.ComputeTaxLots(trades)
69+
// Transfers are converted to synthetic trades for FIFO processing.
70+
// The result is a combined view aggregated across all accounts.
71+
func GetHoldingsOverview(
72+
trades []*datav1.Trade,
73+
positions []*datav1.Position,
74+
transfers []*datav1.Transfer,
75+
tradeTransfers []*datav1.TradeTransfer,
76+
config *ibctlconfig.Config,
77+
) (*HoldingsResult, error) {
78+
// Convert transfers and trade transfers to synthetic trades for FIFO processing.
79+
allTrades := make([]*datav1.Trade, 0, len(trades))
80+
allTrades = append(allTrades, trades...)
81+
allTrades = append(allTrades, ibctltaxlot.TransfersToSyntheticTrades(transfers)...)
82+
allTrades = append(allTrades, ibctltaxlot.TradeTransfersToSyntheticTrades(tradeTransfers)...)
83+
// Compute tax lots from all trades (real + synthetic from transfers).
84+
taxLotResult, err := ibctltaxlot.ComputeTaxLots(allTrades)
7285
if err != nil {
7386
return nil, err
7487
}
75-
// Compute positions from tax lots (for average cost basis).
88+
// Compute per-account positions from tax lots.
7689
computedPositions := ibctltaxlot.ComputePositions(taxLotResult.TaxLots)
7790
// Verify computed positions against IBKR-reported positions.
7891
discrepancies := ibctltaxlot.VerifyPositions(computedPositions, positions)
7992

80-
// Build a map of market prices from IBKR-reported positions.
93+
// Build a map of market prices from IBKR-reported positions, keyed by symbol.
94+
// For combined view, we use the latest price from any account that reports it.
8195
marketPrices := make(map[string]string, len(positions))
8296
for _, pos := range positions {
8397
marketPrices[pos.GetSymbol()] = moneypb.MoneyValueToString(pos.GetMarketPrice())
8498
}
8599

86-
// Build holdings overview from computed positions with metadata from config.
87-
var holdings []*HoldingOverview
100+
// Aggregate computed positions across accounts for combined view.
101+
// Group by symbol, sum quantities, weighted-average cost basis.
102+
type combinedData struct {
103+
quantityMicros int64
104+
totalCostMicros int64
105+
currencyCode string
106+
}
107+
combinedMap := make(map[string]*combinedData)
88108
for _, pos := range computedPositions {
89109
symbol := pos.GetSymbol()
110+
data, ok := combinedMap[symbol]
111+
if !ok {
112+
data = &combinedData{currencyCode: pos.GetCurrencyCode()}
113+
combinedMap[symbol] = data
114+
}
115+
qtyMicros := mathpb.ToMicros(pos.GetQuantity())
116+
data.quantityMicros += qtyMicros
117+
// Accumulate total cost for weighted average.
118+
data.totalCostMicros += moneypb.MoneyToMicros(pos.GetAverageCostBasisPrice()) * qtyMicros / 1_000_000
119+
}
120+
121+
// Build holdings overview from aggregated positions.
122+
var holdings []*HoldingOverview
123+
for symbol, data := range combinedMap {
124+
if data.quantityMicros == 0 {
125+
continue
126+
}
127+
// Weighted average cost basis = total cost / total quantity.
128+
avgCostMicros := data.totalCostMicros * 1_000_000 / data.quantityMicros
90129
holding := &HoldingOverview{
91130
Symbol: symbol,
92131
LastPrice: marketPrices[symbol],
93-
AveragePrice: moneypb.MoneyValueToString(pos.GetAverageCostBasisPrice()),
94-
Position: pos.GetQuantity(),
132+
AveragePrice: moneypb.MoneyValueToString(moneypb.MoneyFromMicros(data.currencyCode, avgCostMicros)),
133+
Position: mathpb.FromMicros(data.quantityMicros),
95134
}
96135
// Merge symbol classification from config.
97136
if symbolConfig, ok := config.SymbolConfigs[symbol]; ok {

0 commit comments

Comments
 (0)