Skip to content

Commit 42f3b34

Browse files
committed
commit
1 parent 66046ab commit 42f3b34

File tree

12 files changed

+1066
-145
lines changed

12 files changed

+1066
-145
lines changed
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 trade implements the "trade" command group.
6+
package trade
7+
8+
import (
9+
"buf.build/go/app/appcmd"
10+
"buf.build/go/app/appext"
11+
"github.com/bufdev/ibctl/cmd/ibctl/internal/command/trade/tradelist"
12+
)
13+
14+
// NewCommand returns a new trade command group.
15+
func NewCommand(name string, builder appext.SubCommandBuilder) *appcmd.Command {
16+
return &appcmd.Command{
17+
Use: name,
18+
Short: "Display realized trade information",
19+
SubCommands: []*appcmd.Command{
20+
tradelist.NewCommand("list", builder),
21+
},
22+
}
23+
}
Lines changed: 248 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,248 @@
1+
// Copyright 2026 Peter Edge
2+
//
3+
// All rights reserved.
4+
5+
// Package tradelist implements the "trade list" command.
6+
package tradelist
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/ibctltrades"
22+
"github.com/bufdev/ibctl/internal/pkg/cliio"
23+
"github.com/bufdev/ibctl/internal/pkg/mathpb"
24+
"github.com/bufdev/ibctl/internal/pkg/moneypb"
25+
"github.com/bufdev/ibctl/internal/standard/xtime"
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+
// fromFlagName is the flag name for filtering on sale date (inclusive, YYYYMMDD).
35+
fromFlagName = "from"
36+
// toFlagName is the flag name for filtering on sale date (inclusive, YYYYMMDD).
37+
toFlagName = "to"
38+
// baseCurrencyFlagName is the flag name for the base currency for P&L conversion.
39+
baseCurrencyFlagName = "base-currency"
40+
)
41+
42+
// NewCommand returns a new trade 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 realized trades with FIFO lot matching for tax reporting",
48+
Long: `List realized trades (sells matched to their FIFO buy lots) for tax reporting.
49+
50+
Each row represents one lot match. A sell of 150 shares that consumes two
51+
FIFO lots (100 + 50) produces two rows, each with its own purchase date
52+
and cost basis.
53+
54+
P&L is computed in native currency and the --base-currency. FX rates are
55+
looked up for the specific purchase and sale dates:
56+
57+
Example: Buy 100 shares of VFV.TO (CAD) on 2024-03-15 at C$102.50,
58+
sell on 2025-01-10 at C$115.30, with --base-currency USD:
59+
60+
- Purchase rate CAD→USD on 2024-03-15: 0.7380
61+
- Sale rate CAD→USD on 2025-01-10: 0.6950
62+
- Purchase USD = 102.50 * 0.7380 = $75.64
63+
- Sale USD = 115.30 * 0.6950 = $80.13
64+
- P&L USD = (80.13 - 75.64) * 100 = $449.00
65+
- Holding period: 301 days (< 365) → STCG USD = $449.00
66+
67+
Use --from and --to to filter by sale date (YYYYMMDD format) for a specific
68+
tax year, e.g. --from 20250101 --to 20251231.`,
69+
Args: appcmd.NoArgs,
70+
Run: builder.NewRunFunc(
71+
func(ctx context.Context, container appext.Container) error {
72+
return run(ctx, container, flags)
73+
},
74+
),
75+
BindFlags: flags.Bind,
76+
}
77+
}
78+
79+
type flags struct {
80+
// Dir is the base directory containing ibctl.yaml and data subdirectories.
81+
Dir string
82+
// Format is the output format (table, csv, json).
83+
Format string
84+
// Download fetches fresh data before displaying.
85+
Download bool
86+
// From filters on sale date (inclusive). Format: YYYYMMDD.
87+
From string
88+
// To filters on sale date (inclusive). Format: YYYYMMDD.
89+
To string
90+
// BaseCurrency is the target currency for P&L conversion (e.g., "USD", "CAD").
91+
BaseCurrency string
92+
}
93+
94+
func newFlags() *flags {
95+
return &flags{}
96+
}
97+
98+
// Bind registers the flag definitions with the given flag set.
99+
func (f *flags) Bind(flagSet *pflag.FlagSet) {
100+
flagSet.StringVar(&f.Dir, ibctlcmd.DirFlagName, ".", "The ibctl directory containing ibctl.yaml")
101+
flagSet.StringVar(&f.Format, formatFlagName, "table", "Output format (table, csv, json)")
102+
flagSet.BoolVar(&f.Download, downloadFlagName, false, "Download fresh data before displaying")
103+
flagSet.StringVar(&f.From, fromFlagName, "", "Filter on sale date (inclusive, format: YYYYMMDD)")
104+
flagSet.StringVar(&f.To, toFlagName, "", "Filter on sale date (inclusive, format: YYYYMMDD)")
105+
flagSet.StringVar(&f.BaseCurrency, baseCurrencyFlagName, "USD", "Base currency for P&L conversion (e.g., USD, CAD)")
106+
}
107+
108+
func run(ctx context.Context, container appext.Container, flags *flags) error {
109+
format, err := cliio.ParseFormat(flags.Format)
110+
if err != nil {
111+
return appcmd.NewInvalidArgumentError(err.Error())
112+
}
113+
// Normalize base currency to uppercase for case-insensitive matching.
114+
baseCurrency := strings.ToUpper(flags.BaseCurrency)
115+
// Select the formatting function based on the base currency.
116+
formatBase := formatBaseFunc(baseCurrency)
117+
formatBaseMicros := formatBaseMicrosFunc(baseCurrency)
118+
// Parse --from and --to date filters.
119+
fromDate, err := parseDateFlag(flags.From, fromFlagName)
120+
if err != nil {
121+
return err
122+
}
123+
toDate, err := parseDateFlag(flags.To, toFlagName)
124+
if err != nil {
125+
return err
126+
}
127+
// Read and validate the configuration file from the base directory.
128+
config, err := ibctlconfig.ReadConfig(flags.Dir)
129+
if err != nil {
130+
return err
131+
}
132+
// Download fresh data if --download is set.
133+
if flags.Download {
134+
downloader, err := ibctlcmd.NewDownloader(container, flags.Dir)
135+
if err != nil {
136+
return err
137+
}
138+
if err := downloader.Download(ctx); err != nil {
139+
return err
140+
}
141+
}
142+
// Merge trade data from all sources.
143+
mergedData, err := ibctlmerge.Merge(
144+
ibctlpath.DataAccountsDirPath(config.DirPath),
145+
ibctlpath.CacheAccountsDirPath(config.DirPath),
146+
ibctlpath.ActivityStatementsDirPath(config.DirPath),
147+
ibctlpath.SeedDirPath(config.DirPath),
148+
config.AccountAliases,
149+
config.Additions,
150+
)
151+
if err != nil {
152+
return err
153+
}
154+
// Load FX rates for base currency conversion.
155+
fxStore := ibctlfxrates.NewStore(ibctlpath.CacheFXDirPath(config.DirPath))
156+
// Compute realized trades via FIFO lot matching.
157+
result, err := ibctltrades.GetTradeList(mergedData.Trades, fxStore, baseCurrency, fromDate, toDate)
158+
if err != nil {
159+
return err
160+
}
161+
// Log any unmatched sells as warnings.
162+
logger := container.Logger()
163+
for _, unmatched := range result.UnmatchedSells {
164+
logger.Warn("unmatched sell (buy likely before data window)",
165+
"account", unmatched.AccountAlias,
166+
"symbol", unmatched.Symbol,
167+
"unmatched_quantity", mathpb.ToString(unmatched.UnmatchedQuantity),
168+
)
169+
}
170+
// Write output in the requested format.
171+
writer := os.Stdout
172+
switch format {
173+
case cliio.FormatTable:
174+
headers := ibctltrades.TradeOverviewHeaders(baseCurrency)
175+
rows := make([][]string, 0, len(result.Trades))
176+
for _, t := range result.Trades {
177+
rows = append(rows, ibctltrades.TradeOverviewToTableRow(t, formatBase))
178+
}
179+
// Build totals row with sums for pnl/stcg/ltcg in base currency.
180+
totals := ibctltrades.ComputeTotals(result.Trades, formatBaseMicros)
181+
totalsRow := make([]string, len(headers))
182+
totalsRow[0] = "TOTAL"
183+
// P&L BASE at column 13, STCG BASE at 14, LTCG BASE at 15.
184+
totalsRow[13] = totals.PnLBase
185+
totalsRow[14] = totals.STCGBase
186+
totalsRow[15] = totals.LTCGBase
187+
return cliio.WriteTableWithTotals(writer, headers, rows, totalsRow)
188+
case cliio.FormatCSV:
189+
headers := ibctltrades.TradeOverviewHeaders(baseCurrency)
190+
records := make([][]string, 0, len(result.Trades)+1)
191+
records = append(records, headers)
192+
for _, t := range result.Trades {
193+
records = append(records, ibctltrades.TradeOverviewToRow(t))
194+
}
195+
return cliio.WriteCSVRecords(writer, records)
196+
case cliio.FormatJSON:
197+
return cliio.WriteJSON(writer, result.Trades...)
198+
default:
199+
return appcmd.NewInvalidArgumentErrorf("unsupported format: %s", format)
200+
}
201+
}
202+
203+
// parseDateFlag parses a YYYYMMDD date string into an xtime.Date.
204+
// Returns a zero date if the input is empty.
205+
func parseDateFlag(value string, flagName string) (xtime.Date, error) {
206+
if value == "" {
207+
return xtime.Date{}, nil
208+
}
209+
parsed, err := time.Parse("20060102", value)
210+
if err != nil {
211+
return xtime.Date{}, appcmd.NewInvalidArgumentErrorf("--%s must be in YYYYMMDD format: %s", flagName, value)
212+
}
213+
return xtime.Date{
214+
Year: parsed.Year(),
215+
Month: parsed.Month(),
216+
Day: parsed.Day(),
217+
}, nil
218+
}
219+
220+
// formatBaseFunc returns a formatting function for display values in the given currency.
221+
// Uses currency-specific prefixes ($ for USD, C$ for CAD) when available,
222+
// falls back to raw decimal for other currencies.
223+
func formatBaseFunc(baseCurrency string) func(string) string {
224+
switch baseCurrency {
225+
case "USD":
226+
return cliio.FormatUSD
227+
case "CAD":
228+
return cliio.FormatCAD
229+
default:
230+
// For other currencies, return the raw decimal value.
231+
return func(v string) string { return v }
232+
}
233+
}
234+
235+
// formatBaseMicrosFunc returns a formatting function for micros values in the given currency.
236+
func formatBaseMicrosFunc(baseCurrency string) func(int64) string {
237+
switch baseCurrency {
238+
case "USD":
239+
return cliio.FormatUSDMicros
240+
case "CAD":
241+
return cliio.FormatCADMicros
242+
default:
243+
// For other currencies, format as raw decimal without a prefix.
244+
return func(micros int64) string {
245+
return moneypb.MoneyValueToString(moneypb.MoneyFromMicros(baseCurrency, micros))
246+
}
247+
}
248+
}

cmd/ibctl/main.go

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,7 @@ import (
1414
"github.com/bufdev/ibctl/cmd/ibctl/internal/command/download"
1515
"github.com/bufdev/ibctl/cmd/ibctl/internal/command/holding"
1616
"github.com/bufdev/ibctl/cmd/ibctl/internal/command/probe"
17+
"github.com/bufdev/ibctl/cmd/ibctl/internal/command/trade"
1718
)
1819

1920
func main() {
@@ -39,6 +40,7 @@ Run "ibctl config init" to create a new ibctl directory.`,
3940
download.NewCommand("download", builder),
4041
holding.NewCommand("holding", builder),
4142
probe.NewCommand("probe", builder),
43+
trade.NewCommand("trade", builder),
4244
},
4345
}
4446
}

internal/gen/proto/go/ibctl/data/v1/exchange_rate.pb.go

Lines changed: 17 additions & 7 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

0 commit comments

Comments
 (0)