Skip to content

Commit ef4e9bd

Browse files
committed
Add YTD realized income tax section to holding overview command
1 parent c34f3ec commit ef4e9bd

1 file changed

Lines changed: 273 additions & 0 deletions

File tree

Lines changed: 273 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,273 @@
1+
// Copyright 2026 Peter Edge
2+
//
3+
// All rights reserved.
4+
5+
// Package holdingoverview implements the "holding overview" command.
6+
// It prints the results of holding list, holding category list, holding geo list,
7+
// and holding value in sequence, separated by newlines.
8+
package holdingoverview
9+
10+
import (
11+
"context"
12+
"encoding/json"
13+
"fmt"
14+
"os"
15+
16+
"buf.build/go/app/appcmd"
17+
"buf.build/go/app/appext"
18+
"github.com/bufdev/ibctl/cmd/ibctl/internal/ibctlcmd"
19+
"github.com/bufdev/ibctl/internal/ibctl/ibctlholdings"
20+
"github.com/bufdev/ibctl/internal/pkg/cliio"
21+
"github.com/spf13/pflag"
22+
)
23+
24+
const (
25+
// formatFlagName is the flag name for the output format.
26+
formatFlagName = "format"
27+
// downloadFlagName is the flag name for downloading fresh data before displaying.
28+
downloadFlagName = "download"
29+
// baseCurrencyFlagName is the flag name for the base currency for value conversion.
30+
baseCurrencyFlagName = "base-currency"
31+
// realtimeFlagName is the flag name for fetching real-time quotes and FX rates.
32+
realtimeFlagName = "realtime"
33+
)
34+
35+
// NewCommand returns a new holding overview command.
36+
func NewCommand(name string, builder appext.SubCommandBuilder) *appcmd.Command {
37+
flags := newFlags()
38+
return &appcmd.Command{
39+
Use: name,
40+
Short: "Display combined portfolio overview",
41+
Long: `Display a combined portfolio overview showing holdings, category breakdown,
42+
geographic breakdown, and portfolio value with estimated tax impact.
43+
44+
Equivalent to running holding list, holding category list, holding geo list,
45+
and holding value in sequence, separated by blank lines. Data is loaded once
46+
and shared across all sections.`,
47+
Args: appcmd.NoArgs,
48+
Run: builder.NewRunFunc(
49+
func(ctx context.Context, container appext.Container) error {
50+
return run(ctx, container, flags)
51+
},
52+
),
53+
BindFlags: flags.Bind,
54+
}
55+
}
56+
57+
type flags struct {
58+
// Format is the output format (table, csv, json).
59+
Format string
60+
// Download fetches fresh data before displaying.
61+
Download bool
62+
// BaseCurrency is the target currency for value conversion (e.g., "USD", "CAD").
63+
BaseCurrency string
64+
// Realtime fetches real-time quotes and FX rates from Yahoo Finance.
65+
Realtime bool
66+
}
67+
68+
func newFlags() *flags {
69+
return &flags{}
70+
}
71+
72+
// Bind registers the flag definitions with the given flag set.
73+
func (f *flags) Bind(flagSet *pflag.FlagSet) {
74+
flagSet.StringVar(&f.Format, formatFlagName, "table", "Output format (table, csv, json)")
75+
flagSet.BoolVar(&f.Download, downloadFlagName, false, "Download fresh data before displaying")
76+
flagSet.StringVar(&f.BaseCurrency, baseCurrencyFlagName, "USD", "Base currency for value conversion (e.g., USD, CAD)")
77+
flagSet.BoolVar(&f.Realtime, realtimeFlagName, false, "Fetch real-time quotes and FX rates from Yahoo Finance")
78+
}
79+
80+
// overviewJSON is the combined JSON output for the overview command.
81+
type overviewJSON struct {
82+
// Holdings is the list of individual holdings.
83+
Holdings []*ibctlholdings.HoldingOverview `json:"holdings"`
84+
// Categories is holdings aggregated by category.
85+
Categories []*ibctlholdings.CategoryOverview `json:"categories"`
86+
// Geos is holdings aggregated by geographic classification.
87+
Geos []*ibctlholdings.GeoOverview `json:"geos"`
88+
// Value is the portfolio value summary with tax impact.
89+
Value *valueJSON `json:"value"`
90+
// YTDTax is the YTD realized income tax summary.
91+
YTDTax *ytdTaxJSON `json:"ytd_tax"`
92+
}
93+
94+
// ytdTaxJSON is the YTD realized income tax summary for JSON output.
95+
type ytdTaxJSON struct {
96+
// DividendMicros is the total YTD dividend income (including WHT) in base currency micros.
97+
DividendMicros int64 `json:"dividend_micros"`
98+
// DividendTaxMicros is the estimated tax on dividend income in micros.
99+
DividendTaxMicros int64 `json:"dividend_tax_micros"`
100+
// InterestMicros is the total YTD interest income in base currency micros.
101+
InterestMicros int64 `json:"interest_micros"`
102+
// InterestTaxMicros is the estimated tax on interest income in micros.
103+
InterestTaxMicros int64 `json:"interest_tax_micros"`
104+
// STCGMicros is the total YTD realized short-term capital gains in base currency micros.
105+
STCGMicros int64 `json:"stcg_micros"`
106+
// STCGTaxMicros is the estimated tax on realized STCG in micros.
107+
STCGTaxMicros int64 `json:"stcg_tax_micros"`
108+
// LTCGMicros is the total YTD realized long-term capital gains in base currency micros.
109+
LTCGMicros int64 `json:"ltcg_micros"`
110+
// LTCGTaxMicros is the estimated tax on realized LTCG in micros.
111+
LTCGTaxMicros int64 `json:"ltcg_tax_micros"`
112+
// TotalTaxOwedMicros is the total estimated tax owed across all income types.
113+
TotalTaxOwedMicros int64 `json:"total_tax_owed_micros"`
114+
// TaxPaidMicros is the total tax already paid, converted to base currency.
115+
TaxPaidMicros int64 `json:"tax_paid_micros"`
116+
// TaxRemainingMicros is the net tax remaining (owed minus paid).
117+
TaxRemainingMicros int64 `json:"tax_remaining_micros"`
118+
// TaxRateDividend is the dividend tax rate from config.
119+
TaxRateDividend float64 `json:"tax_rate_dividend"`
120+
// TaxRateInterest is the interest tax rate from config.
121+
TaxRateInterest float64 `json:"tax_rate_interest"`
122+
// TaxRateSTCG is the short-term capital gains tax rate from config.
123+
TaxRateSTCG float64 `json:"tax_rate_stcg"`
124+
// TaxRateLTCG is the long-term capital gains tax rate from config.
125+
TaxRateLTCG float64 `json:"tax_rate_ltcg"`
126+
}
127+
128+
// valueJSON is the portfolio value summary for JSON output.
129+
type valueJSON struct {
130+
// PortfolioValueMicros is the total portfolio market value in micros.
131+
PortfolioValueMicros int64 `json:"portfolio_value_micros"`
132+
// STCGMicros is the total short-term capital gains in micros.
133+
STCGMicros int64 `json:"stcg_micros"`
134+
// STCGTaxMicros is the estimated short-term capital gains tax in micros.
135+
STCGTaxMicros int64 `json:"stcg_tax_micros"`
136+
// LTCGMicros is the total long-term capital gains in micros.
137+
LTCGMicros int64 `json:"ltcg_micros"`
138+
// LTCGTaxMicros is the estimated long-term capital gains tax in micros.
139+
LTCGTaxMicros int64 `json:"ltcg_tax_micros"`
140+
// TotalTaxMicros is the total estimated tax in micros.
141+
TotalTaxMicros int64 `json:"total_tax_micros"`
142+
// AfterTaxMicros is the after-tax portfolio value in micros.
143+
AfterTaxMicros int64 `json:"after_tax_micros"`
144+
// TaxRateSTCG is the short-term capital gains tax rate from config.
145+
TaxRateSTCG float64 `json:"tax_rate_stcg"`
146+
// TaxRateLTCG is the long-term capital gains tax rate from config.
147+
TaxRateLTCG float64 `json:"tax_rate_ltcg"`
148+
}
149+
150+
func run(ctx context.Context, container appext.Container, flags *flags) error {
151+
format, err := cliio.ParseFormat(flags.Format)
152+
if err != nil {
153+
return appcmd.NewInvalidArgumentError(err.Error())
154+
}
155+
// Load data through the common pipeline (config, merge, FX, optional download/realtime).
156+
data, err := ibctlcmd.LoadHoldingsData(ctx, container, flags.Download, flags.Realtime, flags.BaseCurrency)
157+
if err != nil {
158+
return err
159+
}
160+
// Compute holdings via FIFO from all trade data, verified against IBKR positions.
161+
result, err := ibctlholdings.GetHoldingsOverview(data.MergedData.Trades, data.MergedData.Positions, data.MergedData.CashPositions, data.Config, data.FxStore, data.BaseCurrency)
162+
if err != nil {
163+
return err
164+
}
165+
// Log any data inconsistencies detected during computation.
166+
ibctlcmd.LogHoldingsResult(container, result)
167+
// Aggregate holdings by category and geo with no filters for the overview.
168+
categories := ibctlholdings.GetCategoryList(result.Holdings, "")
169+
geos := ibctlholdings.GetGeoList(result.Holdings, "")
170+
// Compute portfolio value summary for the value section.
171+
summary := ibctlcmd.ComputeValueSummary(result.Holdings, data.Config)
172+
// Compute YTD realized income tax summary.
173+
ytdSummary, err := ibctlcmd.ComputeYTDTaxSummary(data.MergedData, data.Config, data.FxStore, data.BaseCurrency)
174+
if err != nil {
175+
return err
176+
}
177+
// Write output in the requested format.
178+
formatBase := ibctlcmd.FormatBaseFunc(data.BaseCurrency)
179+
formatBaseMicros := ibctlcmd.FormatBaseMicrosFunc(data.BaseCurrency)
180+
writer := os.Stdout
181+
switch format {
182+
case cliio.FormatTable:
183+
// Section 1: holding list table.
184+
if err := ibctlcmd.WriteHoldingListTable(writer, result.Holdings, data.BaseCurrency, formatBase, formatBaseMicros); err != nil {
185+
return err
186+
}
187+
fmt.Fprintln(writer)
188+
// Section 2: holding category list table.
189+
if err := ibctlcmd.WriteCategoryListTable(writer, categories, data.BaseCurrency, formatBase); err != nil {
190+
return err
191+
}
192+
fmt.Fprintln(writer)
193+
// Section 3: holding geo list table.
194+
if err := ibctlcmd.WriteGeoListTable(writer, geos, data.BaseCurrency, formatBase); err != nil {
195+
return err
196+
}
197+
fmt.Fprintln(writer)
198+
// Section 4: holding value text summary.
199+
ibctlcmd.WriteValueSummary(writer, summary, data.Config, formatBaseMicros)
200+
fmt.Fprintln(writer)
201+
// Section 5: YTD realized income tax summary.
202+
ibctlcmd.WriteYTDTaxSummary(writer, ytdSummary, data.Config, formatBaseMicros)
203+
return nil
204+
case cliio.FormatCSV:
205+
// Section 1: holding list CSV.
206+
if err := ibctlcmd.WriteHoldingListCSV(writer, result.Holdings, data.BaseCurrency); err != nil {
207+
return err
208+
}
209+
fmt.Fprintln(writer)
210+
// Section 2: holding category list CSV.
211+
if err := ibctlcmd.WriteCategoryListCSV(writer, categories, data.BaseCurrency); err != nil {
212+
return err
213+
}
214+
fmt.Fprintln(writer)
215+
// Section 3: holding geo list CSV.
216+
if err := ibctlcmd.WriteGeoListCSV(writer, geos, data.BaseCurrency); err != nil {
217+
return err
218+
}
219+
fmt.Fprintln(writer)
220+
// Section 4: holding value text summary.
221+
ibctlcmd.WriteValueSummary(writer, summary, data.Config, formatBaseMicros)
222+
fmt.Fprintln(writer)
223+
// Section 5: YTD realized income tax summary.
224+
ibctlcmd.WriteYTDTaxSummary(writer, ytdSummary, data.Config, formatBaseMicros)
225+
return nil
226+
case cliio.FormatJSON:
227+
// Combined JSON object with all five sections.
228+
overview := &overviewJSON{
229+
Holdings: result.Holdings,
230+
Categories: categories,
231+
Geos: geos,
232+
Value: &valueJSON{
233+
PortfolioValueMicros: summary.TotalValueMicros,
234+
STCGMicros: summary.TotalSTCGMicros,
235+
STCGTaxMicros: summary.STCGTaxMicros,
236+
LTCGMicros: summary.TotalLTCGMicros,
237+
LTCGTaxMicros: summary.LTCGTaxMicros,
238+
TotalTaxMicros: summary.TotalTaxMicros,
239+
AfterTaxMicros: summary.AfterTaxMicros,
240+
TaxRateSTCG: data.Config.TaxRateSTCG,
241+
TaxRateLTCG: data.Config.TaxRateLTCG,
242+
},
243+
YTDTax: &ytdTaxJSON{
244+
DividendMicros: ytdSummary.DividendMicros,
245+
DividendTaxMicros: ytdSummary.DividendTaxMicros,
246+
InterestMicros: ytdSummary.InterestMicros,
247+
InterestTaxMicros: ytdSummary.InterestTaxMicros,
248+
STCGMicros: ytdSummary.STCGMicros,
249+
STCGTaxMicros: ytdSummary.STCGTaxMicros,
250+
LTCGMicros: ytdSummary.LTCGMicros,
251+
LTCGTaxMicros: ytdSummary.LTCGTaxMicros,
252+
TotalTaxOwedMicros: ytdSummary.TotalTaxOwedMicros,
253+
TaxPaidMicros: ytdSummary.TaxPaidMicros,
254+
TaxRemainingMicros: ytdSummary.TaxRemainingMicros,
255+
TaxRateDividend: data.Config.TaxRateDividend,
256+
TaxRateInterest: data.Config.TaxRateInterest,
257+
TaxRateSTCG: data.Config.TaxRateSTCG,
258+
TaxRateLTCG: data.Config.TaxRateLTCG,
259+
},
260+
}
261+
jsonData, err := json.Marshal(overview)
262+
if err != nil {
263+
return err
264+
}
265+
if _, err := writer.Write(jsonData); err != nil {
266+
return err
267+
}
268+
_, err = writer.WriteString("\n")
269+
return err
270+
default:
271+
return appcmd.NewInvalidArgumentErrorf("unsupported format: %s", format)
272+
}
273+
}

0 commit comments

Comments
 (0)