Skip to content

Commit 979d0ee

Browse files
committed
commit
1 parent 960e033 commit 979d0ee

File tree

7 files changed

+836
-23
lines changed

7 files changed

+836
-23
lines changed

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

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,7 @@ import (
1616
"github.com/bufdev/ibctl/cmd/ibctl/internal/command/holding/holdingvalue"
1717
"github.com/bufdev/ibctl/cmd/ibctl/internal/command/holding/lot"
1818
"github.com/bufdev/ibctl/cmd/ibctl/internal/command/holding/projectedincome"
19+
"github.com/bufdev/ibctl/cmd/ibctl/internal/command/holding/safesell"
1920
)
2021

2122
// NewCommand returns a new holding command group.
@@ -31,6 +32,7 @@ func NewCommand(name string, builder appext.SubCommandBuilder) *appcmd.Command {
3132
lot.NewCommand("lot", builder),
3233
holdingoverview.NewCommand("overview", builder),
3334
projectedincome.NewCommand("projected-income", builder),
35+
safesell.NewCommand("safe-sell", builder),
3436
holdingvalue.NewCommand("value", builder),
3537
},
3638
}
Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,23 @@
1+
// Copyright 2026 Peter Edge
2+
//
3+
// All rights reserved.
4+
5+
// Package safesell implements the "holding safe-sell" command group.
6+
package safesell
7+
8+
import (
9+
"buf.build/go/app/appcmd"
10+
"buf.build/go/app/appext"
11+
"github.com/bufdev/ibctl/cmd/ibctl/internal/command/holding/safesell/safeselllist"
12+
)
13+
14+
// NewCommand returns a new safe-sell command group.
15+
func NewCommand(name string, builder appext.SubCommandBuilder) *appcmd.Command {
16+
return &appcmd.Command{
17+
Use: name,
18+
Short: "Identify positions safe to sell from a tax perspective",
19+
SubCommands: []*appcmd.Command{
20+
safeselllist.NewCommand("list", builder),
21+
},
22+
}
23+
}
Lines changed: 274 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,274 @@
1+
// Copyright 2026 Peter Edge
2+
//
3+
// All rights reserved.
4+
5+
// Package safeselllist implements the "holding safe-sell list" command.
6+
package safeselllist
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/ibctlrealtime"
22+
"github.com/bufdev/ibctl/internal/ibctl/ibctlsafesell"
23+
"github.com/bufdev/ibctl/internal/pkg/cliio"
24+
"github.com/bufdev/ibctl/internal/pkg/moneypb"
25+
"github.com/bufdev/ibctl/internal/pkg/yahoofinance"
26+
"github.com/spf13/pflag"
27+
)
28+
29+
const (
30+
// formatFlagName is the flag name for the output format.
31+
formatFlagName = "format"
32+
// downloadFlagName is the flag name for downloading fresh data before displaying.
33+
downloadFlagName = "download"
34+
// baseCurrencyFlagName is the flag name for the base currency for value conversion.
35+
baseCurrencyFlagName = "base-currency"
36+
// realtimeFlagName is the flag name for fetching real-time quotes and FX rates.
37+
realtimeFlagName = "realtime"
38+
// separateAccountsFlagName is the flag name for per-account FIFO (CRA-style).
39+
separateAccountsFlagName = "separate-accounts"
40+
)
41+
42+
// NewCommand returns a new safe-sell list command.
43+
func NewCommand(name string, builder appext.SubCommandBuilder) *appcmd.Command {
44+
flags := newFlags()
45+
return &appcmd.Command{
46+
Use: name,
47+
Short: "List positions safe to sell from a tax perspective",
48+
Long: `Identify positions that can be sold without triggering significant
49+
short-term capital gains.
50+
51+
Safe-sell walks each symbol's FIFO lots in date order and stops when a
52+
lot's unrealized short-term capital gain meets or exceeds --max-stcg.
53+
Lots before the stop point are sellable if they represent a capital loss
54+
or a long-term capital gain.
55+
56+
This is useful for portfolio simplification: sell individual positions to
57+
harvest losses or realize long-term gains at favorable rates, then
58+
reinvest proceeds into core index fund allocations.
59+
60+
ACCOUNT COMBINING (IRS vs CRA)
61+
62+
By default, lots from all accounts for the same symbol are merged into a
63+
single FIFO queue sorted by date. This is the IRS-correct behavior for
64+
individuals whose accounts include disregarded entities (e.g., single-
65+
member LLCs) — the IRS treats all disregarded-entity holdings as the
66+
individual's own, so FIFO matching spans all accounts.
67+
68+
Use --separate-accounts for CRA-style treatment where each account is an
69+
independent entity with its own FIFO queue. The same symbol may appear
70+
multiple times in the output (once per account) with different P&L. An
71+
ACCOUNT column is shown in this mode.
72+
73+
EXCLUSIONS
74+
75+
Symbols listed under safe_sell.excludes in ibctl.yaml are always omitted.
76+
Use this for core positions you intend to hold regardless of tax status.
77+
78+
STCG THRESHOLD
79+
80+
The safe_sell.max_stcg setting in ibctl.yaml controls tolerance for
81+
short-term capital gains per lot. At the default of 0, any lot with
82+
positive STCG stops the FIFO walk for that symbol. Setting max_stcg to
83+
3000 allows lots with up to $3000 of STCG to pass through, which prevents
84+
small STCG lots from blocking access to loss or LTCG lots behind them in
85+
the queue.
86+
87+
COLUMNS
88+
89+
SYMBOL Ticker symbol.
90+
ACCOUNT Account alias. Only shown with --separate-accounts.
91+
QTY Total quantity across all sellable lots.
92+
QTY REMAINING Quantity not included because the FIFO walk was stopped.
93+
CURRENCY Native currency of cost basis and market price.
94+
AVG PRICE Weighted average cost basis per share (native currency).
95+
MKT PRICE Current market price per share (native currency).
96+
MKT VAL {B} Total current market value of sellable lots (base currency).
97+
P&L {B} Total unrealized P&L of sellable lots (base currency).
98+
STCG {B} Short-term portion of unrealized P&L (base currency).
99+
LTCG {B} Long-term portion of unrealized P&L (base currency).
100+
101+
EXAMPLES
102+
103+
# Strict mode: no STCG tolerance, real-time prices
104+
ibctl holding safe-sell list --realtime
105+
106+
# Per-account FIFO (CRA-style), JSON output
107+
ibctl holding safe-sell list --realtime --separate-accounts --format json`,
108+
Args: appcmd.NoArgs,
109+
Run: builder.NewRunFunc(
110+
func(ctx context.Context, container appext.Container) error {
111+
return run(ctx, container, flags)
112+
},
113+
),
114+
BindFlags: flags.Bind,
115+
}
116+
}
117+
118+
type flags struct {
119+
// Format is the output format (table, csv, json).
120+
Format string
121+
// Download fetches fresh data before displaying.
122+
Download bool
123+
// BaseCurrency is the target currency for value conversion (e.g., "USD", "CAD").
124+
BaseCurrency string
125+
// Realtime fetches real-time quotes and FX rates from Yahoo Finance.
126+
Realtime bool
127+
// SeparateAccounts applies FIFO independently per account (CRA-style).
128+
SeparateAccounts bool
129+
}
130+
131+
func newFlags() *flags {
132+
return &flags{}
133+
}
134+
135+
// Bind registers the flag definitions with the given flag set.
136+
func (f *flags) Bind(flagSet *pflag.FlagSet) {
137+
flagSet.StringVar(&f.Format, formatFlagName, "table", "Output format (table, csv, json)")
138+
flagSet.BoolVar(&f.Download, downloadFlagName, false, "Download fresh data before displaying")
139+
flagSet.StringVar(&f.BaseCurrency, baseCurrencyFlagName, "USD", "Base currency for value conversion (e.g., USD, CAD)")
140+
flagSet.BoolVar(&f.Realtime, realtimeFlagName, false, "Fetch real-time quotes and FX rates from Yahoo Finance")
141+
flagSet.BoolVar(&f.SeparateAccounts, separateAccountsFlagName, false, "Apply FIFO independently per account (CRA-style)")
142+
}
143+
144+
func run(ctx context.Context, container appext.Container, flags *flags) error {
145+
format, err := cliio.ParseFormat(flags.Format)
146+
if err != nil {
147+
return appcmd.NewInvalidArgumentError(err.Error())
148+
}
149+
// Normalize base currency to uppercase for case-insensitive matching.
150+
baseCurrency := strings.ToUpper(flags.BaseCurrency)
151+
// Select the formatting functions based on the base currency.
152+
formatBase := formatBaseFunc(baseCurrency)
153+
formatBaseMicros := formatBaseMicrosFunc(baseCurrency)
154+
// Resolve the ibctl directory from the IBKR_DIR environment variable.
155+
dirPath, err := ibctlcmd.DirPath(container)
156+
if err != nil {
157+
return err
158+
}
159+
// Read and validate the configuration file from the base directory.
160+
config, err := ibctlconfig.ReadConfig(dirPath)
161+
if err != nil {
162+
return err
163+
}
164+
// Download fresh data if --download is set.
165+
if flags.Download {
166+
downloader, err := ibctlcmd.NewDownloader(container, dirPath)
167+
if err != nil {
168+
return err
169+
}
170+
if err := downloader.Download(ctx); err != nil {
171+
return err
172+
}
173+
}
174+
// Merge trade data from all sources.
175+
mergedData, err := ibctlmerge.Merge(
176+
ibctlpath.DataAccountsDirPath(config.DirPath),
177+
ibctlpath.CacheAccountsDirPath(config.DirPath),
178+
ibctlpath.ActivityStatementsDirPath(config.DirPath),
179+
ibctlpath.SeedDirPath(config.DirPath),
180+
config.AccountAliases,
181+
config.Additions,
182+
)
183+
if err != nil {
184+
return err
185+
}
186+
// Load FX rates for base currency conversion.
187+
fxStore := ibctlfxrates.NewStore(ibctlpath.CacheFXDirPath(config.DirPath))
188+
// Override market prices and FX rates with real-time data from Yahoo Finance.
189+
if flags.Realtime {
190+
todayDate := time.Now().Format("2006-01-02")
191+
if err := ibctlrealtime.ApplyOverrides(ctx, container.Logger(), yahoofinance.NewClient(), mergedData.Positions, fxStore, config, baseCurrency, todayDate); err != nil {
192+
return err
193+
}
194+
}
195+
// Get the safe-sell list.
196+
// Get the safe-sell list. The STCG threshold is read from config.SafeSellMaxSTCGMicros.
197+
results, err := ibctlsafesell.GetSafeSellList(
198+
mergedData.Trades,
199+
mergedData.Positions,
200+
config,
201+
fxStore,
202+
baseCurrency,
203+
flags.SeparateAccounts,
204+
)
205+
if err != nil {
206+
return err
207+
}
208+
// Write output in the requested format.
209+
writer := os.Stdout
210+
switch format {
211+
case cliio.FormatTable:
212+
headers := ibctlsafesell.SafeSellHeaders(baseCurrency, flags.SeparateAccounts)
213+
rows := make([][]string, 0, len(results))
214+
for _, r := range results {
215+
rows = append(rows, ibctlsafesell.SafeSellOverviewToTableRow(r, formatBase, flags.SeparateAccounts))
216+
}
217+
// Build totals row.
218+
totals := ibctlsafesell.ComputeSafeSellTotals(results, formatBaseMicros)
219+
totalsRow := make([]string, len(headers))
220+
totalsRow[0] = "TOTAL"
221+
if flags.SeparateAccounts {
222+
// With ACCOUNT column: SYMBOL, ACCOUNT, QTY, QTY REMAINING, CURRENCY, AVG PRICE, MKT PRICE, MKT VAL, P&L, STCG, LTCG, ...
223+
totalsRow[7] = totals.MktValBase
224+
totalsRow[8] = totals.PnLBase
225+
totalsRow[9] = totals.STCGBase
226+
totalsRow[10] = totals.LTCGBase
227+
} else {
228+
// Without ACCOUNT column: SYMBOL, QTY, QTY REMAINING, CURRENCY, AVG PRICE, MKT PRICE, MKT VAL, P&L, STCG, LTCG, ...
229+
totalsRow[6] = totals.MktValBase
230+
totalsRow[7] = totals.PnLBase
231+
totalsRow[8] = totals.STCGBase
232+
totalsRow[9] = totals.LTCGBase
233+
}
234+
return cliio.WriteTableWithTotals(writer, headers, rows, totalsRow)
235+
case cliio.FormatCSV:
236+
headers := ibctlsafesell.SafeSellHeaders(baseCurrency, flags.SeparateAccounts)
237+
records := make([][]string, 0, len(results)+1)
238+
records = append(records, headers)
239+
for _, r := range results {
240+
records = append(records, ibctlsafesell.SafeSellOverviewToRow(r, flags.SeparateAccounts))
241+
}
242+
return cliio.WriteCSVRecords(writer, records)
243+
case cliio.FormatJSON:
244+
return cliio.WriteJSON(writer, results...)
245+
default:
246+
return appcmd.NewInvalidArgumentErrorf("unsupported format: %s", format)
247+
}
248+
}
249+
250+
// formatBaseFunc returns a formatting function for display values in the given currency.
251+
func formatBaseFunc(baseCurrency string) func(string) string {
252+
switch baseCurrency {
253+
case "USD":
254+
return cliio.FormatUSD
255+
case "CAD":
256+
return cliio.FormatCAD
257+
default:
258+
return func(v string) string { return v }
259+
}
260+
}
261+
262+
// formatBaseMicrosFunc returns a formatting function for micros values in the given currency.
263+
func formatBaseMicrosFunc(baseCurrency string) func(int64) string {
264+
switch baseCurrency {
265+
case "USD":
266+
return cliio.FormatUSDMicros
267+
case "CAD":
268+
return cliio.FormatCADMicros
269+
default:
270+
return func(micros int64) string {
271+
return moneypb.MoneyValueToString(moneypb.MoneyFromMicros(baseCurrency, micros))
272+
}
273+
}
274+
}

cmd/ibctl/internal/ibctlcmd/holdingsdata.go

Lines changed: 6 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -369,10 +369,12 @@ func WriteHoldingListTable(writer io.Writer, holdings []*ibctlholdings.HoldingOv
369369
totals := ibctlholdings.ComputeTotals(holdings, formatBaseMicros)
370370
totalsRow := make([]string, len(headers))
371371
totalsRow[0] = "TOTAL"
372-
totalsRow[6] = totals.MarketValueBase
373-
totalsRow[7] = totals.PnLBase
374-
totalsRow[8] = totals.STCGBase
375-
totalsRow[9] = totals.LTCGBase
372+
// Column indices: SYMBOL(0), CURRENCY(1), QTY(2), LAST PRICE(3), AVG PRICE(4),
373+
// LAST B(5), AVG B(6), MKT VAL B(7), P&L B(8), STCG B(9), LTCG B(10), ...
374+
totalsRow[7] = totals.MarketValueBase
375+
totalsRow[8] = totals.PnLBase
376+
totalsRow[9] = totals.STCGBase
377+
totalsRow[10] = totals.LTCGBase
376378
return cliio.WriteTableWithTotals(writer, headers, sections, totalsRow)
377379
}
378380

0 commit comments

Comments
 (0)