Skip to content

Commit a51a871

Browse files
committed
commit
1 parent fd2c790 commit a51a871

5 files changed

Lines changed: 1016 additions & 3 deletions

File tree

cmd/ibctl/internal/command/holding/holding.go

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,7 @@ import (
1313
"github.com/bufdev/ibctl/cmd/ibctl/internal/command/holding/holdinglist"
1414
"github.com/bufdev/ibctl/cmd/ibctl/internal/command/holding/holdingvalue"
1515
"github.com/bufdev/ibctl/cmd/ibctl/internal/command/holding/lot"
16+
"github.com/bufdev/ibctl/cmd/ibctl/internal/command/holding/projectedincome"
1617
)
1718

1819
// NewCommand returns a new holding command group.
@@ -25,6 +26,7 @@ func NewCommand(name string, builder appext.SubCommandBuilder) *appcmd.Command {
2526
geo.NewCommand("geo", builder),
2627
holdinglist.NewCommand("list", builder),
2728
lot.NewCommand("lot", builder),
29+
projectedincome.NewCommand("projected-income", builder),
2830
holdingvalue.NewCommand("value", builder),
2931
},
3032
}
Lines changed: 265 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,265 @@
1+
// Copyright 2026 Peter Edge
2+
//
3+
// All rights reserved.
4+
5+
// Package projectedincome implements the "holding projected-income" command.
6+
package projectedincome
7+
8+
import (
9+
"context"
10+
"os"
11+
"strings"
12+
"time"
13+
14+
"buf.build/go/app/appcmd"
15+
"buf.build/go/app/appext"
16+
"github.com/bufdev/ibctl/cmd/ibctl/internal/ibctlcmd"
17+
"github.com/bufdev/ibctl/internal/ibctl/ibctlconfig"
18+
"github.com/bufdev/ibctl/internal/ibctl/ibctlfxrates"
19+
"github.com/bufdev/ibctl/internal/ibctl/ibctlmerge"
20+
"github.com/bufdev/ibctl/internal/ibctl/ibctlpath"
21+
"github.com/bufdev/ibctl/internal/ibctl/ibctlprojectedincome"
22+
"github.com/bufdev/ibctl/internal/pkg/cliio"
23+
"github.com/bufdev/ibctl/internal/pkg/moneypb"
24+
"github.com/bufdev/ibctl/internal/pkg/yahoofinance"
25+
"github.com/spf13/pflag"
26+
)
27+
28+
const (
29+
// formatFlagName is the flag name for the output format.
30+
formatFlagName = "format"
31+
// downloadFlagName is the flag name for downloading fresh data before displaying.
32+
downloadFlagName = "download"
33+
// baseCurrencyFlagName is the flag name for the base currency for value conversion.
34+
baseCurrencyFlagName = "base-currency"
35+
)
36+
37+
// NewCommand returns a new projected income command.
38+
func NewCommand(name string, builder appext.SubCommandBuilder) *appcmd.Command {
39+
flags := newFlags()
40+
return &appcmd.Command{
41+
Use: name,
42+
Short: "Project annual and remaining-year income from the portfolio",
43+
Long: `Project estimated income from bonds (coupons), equities (dividends),
44+
and cash (interest), with pre-tax and post-tax estimates.
45+
46+
INCOME SOURCES
47+
48+
Bond coupons Parsed from the bond symbol (e.g., "CVX 2.236 05/11/30" is
49+
a 2.236% coupon). Semiannual payments. Annual income is
50+
face value * coupon rate. Remaining-year income counts
51+
coupon dates between today and December 31.
52+
53+
ETF/stock divs Trailing 12-month per-share dividends fetched from Yahoo
54+
Finance, multiplied by current position quantity.
55+
Remaining-year income is pro-rated by remaining fraction.
56+
57+
Cash interest Cash balance * annual rate from interest_rates config map.
58+
Remaining-year income is pro-rated by remaining fraction.
59+
60+
COLUMNS
61+
62+
GROUP BOND, EQUITY, or CASH
63+
SYMBOL Ticker symbol (currency code for cash)
64+
TYPE COUPON, DIVIDEND, or INTEREST
65+
YIELD % Annualized yield percentage
66+
EST ANNUAL Estimated annual income in base currency
67+
EST REMAINING Estimated remaining calendar year income in base currency
68+
69+
TAX RATES
70+
71+
Tax rates from config: taxes.dividend_tax applied to dividends,
72+
taxes.interest_tax applied to coupons and cash interest.
73+
Cash interest rates from interest_rates config map.`,
74+
Args: appcmd.NoArgs,
75+
Run: builder.NewRunFunc(
76+
func(ctx context.Context, container appext.Container) error {
77+
return run(ctx, container, flags)
78+
},
79+
),
80+
BindFlags: flags.Bind,
81+
}
82+
}
83+
84+
type flags struct {
85+
// Format is the output format (table, csv, json).
86+
Format string
87+
// Download fetches fresh data before displaying.
88+
Download bool
89+
// BaseCurrency is the target currency for value conversion (e.g., "USD", "CAD").
90+
BaseCurrency string
91+
}
92+
93+
func newFlags() *flags {
94+
return &flags{}
95+
}
96+
97+
// Bind registers the flag definitions with the given flag set.
98+
func (f *flags) Bind(flagSet *pflag.FlagSet) {
99+
flagSet.StringVar(&f.Format, formatFlagName, "table", "Output format (table, csv, json)")
100+
flagSet.BoolVar(&f.Download, downloadFlagName, false, "Download fresh data before displaying")
101+
flagSet.StringVar(&f.BaseCurrency, baseCurrencyFlagName, "USD", "Base currency for value conversion (e.g., USD, CAD)")
102+
}
103+
104+
func run(ctx context.Context, container appext.Container, flags *flags) error {
105+
format, err := cliio.ParseFormat(flags.Format)
106+
if err != nil {
107+
return appcmd.NewInvalidArgumentError(err.Error())
108+
}
109+
// Normalize base currency to uppercase for case-insensitive matching.
110+
baseCurrency := strings.ToUpper(flags.BaseCurrency)
111+
// Select the formatting function based on the base currency.
112+
formatBase := formatBaseFunc(baseCurrency)
113+
formatBaseMicros := formatBaseMicrosFunc(baseCurrency)
114+
// Resolve the ibctl directory from the IBKR_DIR environment variable.
115+
dirPath, err := ibctlcmd.DirPath(container)
116+
if err != nil {
117+
return err
118+
}
119+
// Read and validate the configuration file from the base directory.
120+
config, err := ibctlconfig.ReadConfig(dirPath)
121+
if err != nil {
122+
return err
123+
}
124+
// Download fresh data if --download is set.
125+
if flags.Download {
126+
downloader, err := ibctlcmd.NewDownloader(container, dirPath)
127+
if err != nil {
128+
return err
129+
}
130+
if err := downloader.Download(ctx); err != nil {
131+
return err
132+
}
133+
}
134+
// Merge seed lots + Activity Statement CSVs + Flex Query cached data across all accounts.
135+
mergedData, err := ibctlmerge.Merge(
136+
ibctlpath.DataAccountsDirPath(config.DirPath),
137+
ibctlpath.CacheAccountsDirPath(config.DirPath),
138+
ibctlpath.ActivityStatementsDirPath(config.DirPath),
139+
ibctlpath.SeedDirPath(config.DirPath),
140+
config.AccountAliases,
141+
config.Additions,
142+
)
143+
if err != nil {
144+
return err
145+
}
146+
// Load FX rates for base currency conversion.
147+
fxStore := ibctlfxrates.NewStore(ibctlpath.CacheFXDirPath(config.DirPath))
148+
// Create Yahoo Finance client for dividend data.
149+
yahooClient := yahoofinance.NewClient()
150+
// Compute projected income from all positions, dividend data, and cash balances.
151+
result, err := ibctlprojectedincome.GetProjectedIncome(
152+
ctx,
153+
container.Logger(),
154+
mergedData.Positions,
155+
mergedData.CashPositions,
156+
config,
157+
fxStore,
158+
yahooClient,
159+
baseCurrency,
160+
time.Now(),
161+
)
162+
if err != nil {
163+
return err
164+
}
165+
// Write output in the requested format.
166+
writer := os.Stdout
167+
switch format {
168+
case cliio.FormatTable:
169+
return writeTable(writer, result, baseCurrency, formatBase, formatBaseMicros)
170+
case cliio.FormatCSV:
171+
return writeCSV(writer, result, baseCurrency)
172+
case cliio.FormatJSON:
173+
return cliio.WriteJSON(writer, result.Rows...)
174+
default:
175+
return appcmd.NewInvalidArgumentErrorf("unsupported format: %s", format)
176+
}
177+
}
178+
179+
// writeTable writes the projected income as a formatted table with group sections,
180+
// subtotals, and pre/post-tax grand totals.
181+
func writeTable(writer *os.File, result *ibctlprojectedincome.ProjectedIncomeResult, baseCurrency string, formatBase func(string) string, formatBaseMicros func(int64) string) error {
182+
headers := ibctlprojectedincome.ProjectedIncomeHeaders(baseCurrency)
183+
var rows [][]string
184+
// Group rows by their GROUP field for ordered display with subtotals.
185+
groups := []string{ibctlprojectedincome.IncomeGroupBond, ibctlprojectedincome.IncomeGroupEquity, ibctlprojectedincome.IncomeGroupCash}
186+
rowsByGroup := make(map[string][]*ibctlprojectedincome.ProjectedIncomeRow)
187+
for _, row := range result.Rows {
188+
rowsByGroup[row.Group] = append(rowsByGroup[row.Group], row)
189+
}
190+
for _, group := range groups {
191+
groupRows := rowsByGroup[group]
192+
if len(groupRows) == 0 {
193+
continue
194+
}
195+
// Add data rows for this group.
196+
for _, row := range groupRows {
197+
rows = append(rows, ibctlprojectedincome.ProjectedIncomeRowToTableRow(row, formatBase))
198+
}
199+
// Add subtotal row for this group.
200+
for _, subtotal := range result.GroupSubtotals {
201+
if subtotal.Group == group {
202+
// Blank separator row before subtotal.
203+
rows = append(rows, make([]string, len(headers)))
204+
subtotalRow := make([]string, len(headers))
205+
subtotalRow[0] = group + " TOTAL"
206+
subtotalRow[4] = formatBaseMicros(subtotal.AnnualBaseMicros)
207+
subtotalRow[5] = formatBaseMicros(subtotal.RemainingBaseMicros)
208+
rows = append(rows, subtotalRow)
209+
// Blank separator row after subtotal.
210+
rows = append(rows, make([]string, len(headers)))
211+
break
212+
}
213+
}
214+
}
215+
// Append pre-tax and post-tax grand total rows.
216+
grandTotal := result.GrandTotal
217+
preTaxRow := make([]string, len(headers))
218+
preTaxRow[0] = "TOTAL (pre-tax)"
219+
preTaxRow[4] = formatBaseMicros(grandTotal.AnnualPreTaxMicros)
220+
preTaxRow[5] = formatBaseMicros(grandTotal.RemainingPreTaxMicros)
221+
rows = append(rows, preTaxRow)
222+
postTaxRow := make([]string, len(headers))
223+
postTaxRow[0] = "TOTAL (post-tax)"
224+
postTaxRow[4] = formatBaseMicros(grandTotal.AnnualPostTaxMicros)
225+
postTaxRow[5] = formatBaseMicros(grandTotal.RemainingPostTaxMicros)
226+
rows = append(rows, postTaxRow)
227+
return cliio.WriteTable(writer, headers, rows)
228+
}
229+
230+
// writeCSV writes the projected income as CSV records.
231+
func writeCSV(writer *os.File, result *ibctlprojectedincome.ProjectedIncomeResult, baseCurrency string) error {
232+
headers := ibctlprojectedincome.ProjectedIncomeHeaders(baseCurrency)
233+
records := make([][]string, 0, len(result.Rows)+1)
234+
records = append(records, headers)
235+
for _, row := range result.Rows {
236+
records = append(records, ibctlprojectedincome.ProjectedIncomeRowToRow(row))
237+
}
238+
return cliio.WriteCSVRecords(writer, records)
239+
}
240+
241+
// formatBaseFunc returns a formatting function for display values in the given currency.
242+
func formatBaseFunc(baseCurrency string) func(string) string {
243+
switch baseCurrency {
244+
case "USD":
245+
return cliio.FormatUSD
246+
case "CAD":
247+
return cliio.FormatCAD
248+
default:
249+
return func(v string) string { return v }
250+
}
251+
}
252+
253+
// formatBaseMicrosFunc returns a formatting function for micros values in the given currency.
254+
func formatBaseMicrosFunc(baseCurrency string) func(int64) string {
255+
switch baseCurrency {
256+
case "USD":
257+
return cliio.FormatUSDMicros
258+
case "CAD":
259+
return cliio.FormatCADMicros
260+
default:
261+
return func(micros int64) string {
262+
return moneypb.MoneyValueToString(moneypb.MoneyFromMicros(baseCurrency, micros))
263+
}
264+
}
265+
}

internal/ibctl/ibctlconfig/ibctlconfig.go

Lines changed: 33 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -63,6 +63,13 @@ accounts:
6363
# realtime_symbols:
6464
# SHOP: SHOP.TO
6565
# RY: RY.TO
66+
# Annual interest rates on cash balances for projected income.
67+
#
68+
# Optional. Maps currency codes to annual rates (e.g., 0.0312 = 3.12%).
69+
# Used by "holding projected-income" to estimate cash interest income.
70+
# interest_rates:
71+
# USD: 0.0312
72+
# CAD: 0.0154
6673
`
6774

6875
// ExternalConfigV1 is the YAML-serializable configuration file structure for version v1.
@@ -85,6 +92,9 @@ type ExternalConfigV1 struct {
8592
// RealtimeSymbols maps IBKR symbols to Yahoo Finance symbols for --realtime quote lookups.
8693
// Only needed for international tickers where symbols differ (e.g., SHOP → SHOP.TO).
8794
RealtimeSymbols map[string]string `yaml:"realtime_symbols"`
95+
// InterestRates maps currency codes to annual interest rates on cash balances.
96+
// Used by projected-income to estimate cash interest income (e.g., {"USD": 0.0312}).
97+
InterestRates map[string]float64 `yaml:"interest_rates"`
8898
}
8999

90100
// ExternalTaxConfigV1 holds capital gains tax rate configuration.
@@ -93,6 +103,10 @@ type ExternalTaxConfigV1 struct {
93103
STCG float64 `yaml:"stcg"`
94104
// LTCG is the long-term capital gains tax rate (e.g., 0.28 for 28%).
95105
LTCG float64 `yaml:"ltcg"`
106+
// DividendTax is the tax rate on dividend income (e.g., 0.5353 for 53.53%).
107+
DividendTax float64 `yaml:"dividend_tax"`
108+
// InterestTax is the tax rate on interest and bond coupon income (e.g., 0.5353 for 53.53%).
109+
InterestTax float64 `yaml:"interest_tax"`
96110
}
97111

98112
// ExternalCategorizationV1 holds classification metadata for a symbol in v1 config.
@@ -160,6 +174,12 @@ type Config struct {
160174
// RealtimeSymbols maps IBKR symbols to Yahoo Finance symbols for --realtime quote lookups.
161175
// Only needed for international tickers where symbols differ (e.g., SHOP → SHOP.TO).
162176
RealtimeSymbols map[string]string
177+
// TaxRateDividend is the tax rate on dividend income (e.g., 0.5353).
178+
TaxRateDividend float64
179+
// TaxRateInterest is the tax rate on interest and bond coupon income (e.g., 0.5353).
180+
TaxRateInterest float64
181+
// CashInterestRates maps currency codes to annual interest rates on cash balances.
182+
CashInterestRates map[string]float64
163183
}
164184

165185
// SymbolConfig holds classification metadata for a symbol.
@@ -250,22 +270,30 @@ func NewConfigV1(externalConfig ExternalConfigV1, dirPath string) (*Config, erro
250270
}
251271
cashAdjustments[currency] = units*1_000_000 + micros
252272
}
253-
// Extract tax rates if configured.
254-
var taxRateSTCG, taxRateLTCG float64
273+
// Extract tax rates if configured (zero defaults mean no tax impact).
274+
var taxRateSTCG, taxRateLTCG, taxRateDividend, taxRateInterest float64
255275
if externalConfig.Taxes != nil {
256276
taxRateSTCG = externalConfig.Taxes.STCG
257277
taxRateLTCG = externalConfig.Taxes.LTCG
278+
taxRateDividend = externalConfig.Taxes.DividendTax
279+
taxRateInterest = externalConfig.Taxes.InterestTax
258280
}
259281
// Parse and validate additions from non-IBKR brokers.
260282
additions, additionLastPrices, err := parseAdditions(externalConfig.Additions, accountAliases)
261283
if err != nil {
262284
return nil, err
263285
}
264286
// Build realtime symbols map, defaulting to empty if not configured.
287+
// Build realtime symbols map, defaulting to empty if not configured.
265288
realtimeSymbols := externalConfig.RealtimeSymbols
266289
if realtimeSymbols == nil {
267290
realtimeSymbols = make(map[string]string)
268291
}
292+
// Build cash interest rates map, defaulting to empty if not configured.
293+
cashInterestRates := externalConfig.InterestRates
294+
if cashInterestRates == nil {
295+
cashInterestRates = make(map[string]float64)
296+
}
269297
return &Config{
270298
DirPath: dirPath,
271299
IBKRFlexQueryID: externalConfig.FlexQueryID,
@@ -275,9 +303,12 @@ func NewConfigV1(externalConfig ExternalConfigV1, dirPath string) (*Config, erro
275303
CashAdjustments: cashAdjustments,
276304
TaxRateSTCG: taxRateSTCG,
277305
TaxRateLTCG: taxRateLTCG,
306+
TaxRateDividend: taxRateDividend,
307+
TaxRateInterest: taxRateInterest,
278308
Additions: additions,
279309
AdditionLastPrices: additionLastPrices,
280310
RealtimeSymbols: realtimeSymbols,
311+
CashInterestRates: cashInterestRates,
281312
}, nil
282313
}
283314

0 commit comments

Comments
 (0)