Skip to content

Commit 9e0c98c

Browse files
committed
commit
1 parent 0b8824a commit 9e0c98c

5 files changed

Lines changed: 166 additions & 66 deletions

File tree

README.md

Lines changed: 2 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -186,14 +186,12 @@ To keep data current, the Flex Query API provides the latest 365 days. To add ol
186186

187187
## Data Storage
188188

189-
All 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. `metadata.json` is a single message.
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).
190190

191191
| File | Protobuf Message | Description |
192192
|------|-----------------|-------------|
193193
| `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. |
194194
| `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-
| `tax_lots.json` | `ibctl.data.v1.TaxLot` | FIFO tax lots computed from trades. Each lot tracks symbol, open date, remaining quantity, cost basis price, and currency code. Long-term status (held >= 1 year) is computed dynamically at display time. |
196195
| `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)). |
197-
| `metadata.json` | `ibctl.data.v1.Metadata` | Download timestamp, whether computed positions matched IBKR-reported positions, and any verification discrepancy notes. |
198196

199-
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. Timestamps use `google.protobuf.Timestamp`.
197+
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: 48 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -15,8 +15,10 @@ import (
1515
"github.com/bufdev/ibctl/internal/ibctl/ibctlconfig"
1616
"github.com/bufdev/ibctl/internal/ibctl/ibctlholdings"
1717
"github.com/bufdev/ibctl/internal/ibctl/ibctlmerge"
18+
"github.com/bufdev/ibctl/internal/ibctl/ibctltaxlot"
1819
"github.com/bufdev/ibctl/internal/pkg/cliio"
1920
"github.com/bufdev/ibctl/internal/pkg/ibkractivitycsv"
21+
"github.com/bufdev/ibctl/internal/pkg/mathpb"
2022
"github.com/spf13/pflag"
2123
)
2224

@@ -86,31 +88,71 @@ func run(ctx context.Context, container appext.Container, flags *flags) error {
8688
return err
8789
}
8890
// Compute the holdings overview from merged data.
89-
holdingsOverview, err := ibctlholdings.GetHoldingsOverview(mergedData.Trades, mergedData.Positions, config)
91+
result, err := ibctlholdings.GetHoldingsOverview(mergedData.Trades, mergedData.Positions, config)
9092
if err != nil {
9193
return err
9294
}
95+
// Log any data inconsistencies detected during computation.
96+
logger := container.Logger()
97+
for _, unmatched := range result.UnmatchedSells {
98+
logger.Warn("unmatched sell (buy likely before data window)",
99+
"symbol", unmatched.Symbol,
100+
"unmatched_quantity", mathpb.ToString(unmatched.UnmatchedQuantity),
101+
)
102+
}
103+
for _, discrepancy := range result.PositionDiscrepancies {
104+
logPositionDiscrepancy(container, discrepancy)
105+
}
93106
// Write output in the requested format.
94107
writer := os.Stdout
95108
switch format {
96109
case cliio.FormatTable:
97110
headers := ibctlholdings.HoldingsOverviewHeaders()
98-
rows := make([][]string, 0, len(holdingsOverview))
99-
for _, h := range holdingsOverview {
111+
rows := make([][]string, 0, len(result.Holdings))
112+
for _, h := range result.Holdings {
100113
rows = append(rows, ibctlholdings.HoldingOverviewToRow(h))
101114
}
102115
return cliio.WriteTable(writer, headers, rows)
103116
case cliio.FormatCSV:
104117
headers := ibctlholdings.HoldingsOverviewHeaders()
105-
records := make([][]string, 0, len(holdingsOverview)+1)
118+
records := make([][]string, 0, len(result.Holdings)+1)
106119
records = append(records, headers)
107-
for _, h := range holdingsOverview {
120+
for _, h := range result.Holdings {
108121
records = append(records, ibctlholdings.HoldingOverviewToRow(h))
109122
}
110123
return cliio.WriteCSVRecords(writer, records)
111124
case cliio.FormatJSON:
112-
return cliio.WriteJSON(writer, holdingsOverview...)
125+
return cliio.WriteJSON(writer, result.Holdings...)
113126
default:
114127
return appcmd.NewInvalidArgumentErrorf("unsupported format: %s", format)
115128
}
116129
}
130+
131+
// logPositionDiscrepancy logs a structured position discrepancy as a warning.
132+
func logPositionDiscrepancy(container appext.Container, d ibctltaxlot.PositionDiscrepancy) {
133+
logger := container.Logger()
134+
switch d.Type {
135+
case ibctltaxlot.DiscrepancyTypeQuantity:
136+
logger.Warn("position quantity mismatch",
137+
"symbol", d.Symbol,
138+
"computed", d.ComputedValue,
139+
"reported", d.ReportedValue,
140+
)
141+
case ibctltaxlot.DiscrepancyTypeCostBasis:
142+
logger.Warn("position cost basis mismatch",
143+
"symbol", d.Symbol,
144+
"computed", d.ComputedValue,
145+
"reported", d.ReportedValue,
146+
)
147+
case ibctltaxlot.DiscrepancyTypeComputedOnly:
148+
logger.Warn("position computed but not reported by IBKR",
149+
"symbol", d.Symbol,
150+
"computed_quantity", d.ComputedValue,
151+
)
152+
case ibctltaxlot.DiscrepancyTypeReportedOnly:
153+
logger.Warn("position reported by IBKR but not in computed data",
154+
"symbol", d.Symbol,
155+
"reported_quantity", d.ReportedValue,
156+
)
157+
}
158+
}

internal/ibctl/ibctldownload/ibctldownload.go

Lines changed: 4 additions & 39 deletions
Original file line numberDiff line numberDiff line change
@@ -21,15 +21,13 @@ import (
2121

2222
datav1 "github.com/bufdev/ibctl/internal/gen/proto/go/ibctl/data/v1"
2323
"github.com/bufdev/ibctl/internal/ibctl/ibctlconfig"
24-
"github.com/bufdev/ibctl/internal/ibctl/ibctltaxlot"
2524
"github.com/bufdev/ibctl/internal/pkg/frankfurter"
2625
"github.com/bufdev/ibctl/internal/pkg/ibkrflexquery"
2726
"github.com/bufdev/ibctl/internal/pkg/mathpb"
2827
"github.com/bufdev/ibctl/internal/pkg/moneypb"
2928
"github.com/bufdev/ibctl/internal/pkg/protoio"
3029
"github.com/bufdev/ibctl/internal/pkg/timepb"
3130
"github.com/bufdev/ibctl/internal/standard/xtime"
32-
"google.golang.org/protobuf/types/known/timestamppb"
3331
)
3432

3533
// Downloader is the interface for downloading and caching IBKR data.
@@ -105,11 +103,12 @@ func (d *downloader) Download(ctx context.Context) error {
105103
}
106104

107105
// processAndWrite converts XML data to protos, merges with existing cached data,
108-
// computes tax lots, verifies positions, and writes all JSON data files.
106+
// and writes the raw data files (trades, positions, exchange rates).
109107
// This is idempotent — running it multiple times with overlapping data produces
110108
// the same result. Trades are deduplicated by trade ID, exchange rates by
111109
// date+currency pair. Positions are always overwritten (they are a point-in-time
112-
// snapshot). Tax lots and metadata are recomputed from the full merged trade set.
110+
// snapshot). Tax lots and derived computations happen at read time in commands
111+
// that have access to the full merged data (including Activity Statement CSVs).
113112
func (d *downloader) processAndWrite(
114113
ctx context.Context,
115114
xmlTrades []ibkrflexquery.XMLTrade,
@@ -152,41 +151,7 @@ func (d *downloader) processAndWrite(
152151
return fmt.Errorf("writing exchange rates: %w", err)
153152
}
154153
d.logger.Info("exchange rates written", "count", len(exchangeRates), "path", exchangeRatesPath)
155-
156-
// Compute tax lots from the full merged trade set.
157-
taxLots, err := ibctltaxlot.ComputeTaxLots(trades)
158-
if err != nil {
159-
return fmt.Errorf("computing tax lots: %w", err)
160-
}
161-
taxLotsPath := filepath.Join(d.dataDirV1Path, "tax_lots.json")
162-
if err := protoio.WriteMessagesJSON(taxLotsPath, taxLots); err != nil {
163-
return fmt.Errorf("writing tax lots: %w", err)
164-
}
165-
d.logger.Info("tax lots written", "count", len(taxLots), "path", taxLotsPath)
166-
167-
// Compute positions from tax lots and verify against IBKR-reported positions.
168-
computedPositions := ibctltaxlot.ComputePositions(taxLots)
169-
verificationNotes := ibctltaxlot.VerifyPositions(computedPositions, positions)
170-
positionsVerified := len(verificationNotes) == 0
171-
if !positionsVerified {
172-
for _, note := range verificationNotes {
173-
d.logger.Warn("position verification", "note", note)
174-
}
175-
} else {
176-
d.logger.Info("all positions verified successfully")
177-
}
178-
179-
// Write metadata.json.
180-
metadata := &datav1.Metadata{
181-
DownloadTime: timestamppb.Now(),
182-
PositionsVerified: positionsVerified,
183-
VerificationNotes: verificationNotes,
184-
}
185-
metadataPath := filepath.Join(d.dataDirV1Path, "metadata.json")
186-
if err := protoio.WriteMessageJSON(metadataPath, metadata); err != nil {
187-
return fmt.Errorf("writing metadata: %w", err)
188-
}
189-
d.logger.Info("download complete", "positions_verified", positionsVerified)
154+
d.logger.Info("download complete")
190155
return nil
191156
}
192157

internal/ibctl/ibctlholdings/ibctlholdings.go

Lines changed: 22 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,17 @@ import (
1616
"github.com/bufdev/ibctl/internal/pkg/moneypb"
1717
)
1818

19+
// HoldingsResult contains the holdings overview along with any data
20+
// inconsistencies detected during computation.
21+
type HoldingsResult struct {
22+
// Holdings is the list of holdings for display.
23+
Holdings []*HoldingOverview
24+
// UnmatchedSells records sells that could not be matched to buy lots.
25+
UnmatchedSells []ibctltaxlot.UnmatchedSell
26+
// PositionDiscrepancies records mismatches between computed and IBKR-reported positions.
27+
PositionDiscrepancies []ibctltaxlot.PositionDiscrepancy
28+
}
29+
1930
// HoldingOverview represents a single holding for display.
2031
type HoldingOverview struct {
2132
// Symbol is the ticker symbol.
@@ -54,15 +65,17 @@ func HoldingOverviewToRow(h *HoldingOverview) []string {
5465

5566
// GetHoldingsOverview computes the holdings overview from merged trade and position data.
5667
// Trades are used to compute FIFO tax lots and average cost basis.
57-
// Positions provide the latest market prices.
58-
func GetHoldingsOverview(trades []*datav1.Trade, positions []*datav1.Position, config *ibctlconfig.Config) ([]*HoldingOverview, error) {
68+
// 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) {
5970
// Compute tax lots from merged trades.
60-
taxLots, err := ibctltaxlot.ComputeTaxLots(trades)
71+
taxLotResult, err := ibctltaxlot.ComputeTaxLots(trades)
6172
if err != nil {
6273
return nil, err
6374
}
6475
// Compute positions from tax lots (for average cost basis).
65-
computedPositions := ibctltaxlot.ComputePositions(taxLots)
76+
computedPositions := ibctltaxlot.ComputePositions(taxLotResult.TaxLots)
77+
// Verify computed positions against IBKR-reported positions.
78+
discrepancies := ibctltaxlot.VerifyPositions(computedPositions, positions)
6679

6780
// Build a map of market prices from IBKR-reported positions.
6881
marketPrices := make(map[string]string, len(positions))
@@ -93,5 +106,9 @@ func GetHoldingsOverview(trades []*datav1.Trade, positions []*datav1.Position, c
93106
sort.Slice(holdings, func(i, j int) bool {
94107
return holdings[i].Symbol < holdings[j].Symbol
95108
})
96-
return holdings, nil
109+
return &HoldingsResult{
110+
Holdings: holdings,
111+
UnmatchedSells: taxLotResult.UnmatchedSells,
112+
PositionDiscrepancies: discrepancies,
113+
}, nil
97114
}

0 commit comments

Comments
 (0)