Skip to content

Commit 6a6d8be

Browse files
committed
commit
1 parent 8e99804 commit 6a6d8be

9 files changed

Lines changed: 650 additions & 593 deletions

File tree

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

Lines changed: 15 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -37,6 +37,8 @@ const (
3737
realtimeFlagName = "realtime"
3838
// separateAccountsFlagName is the flag name for per-account FIFO (CRA-style).
3939
separateAccountsFlagName = "separate-accounts"
40+
// excludedFlagName is the flag name for showing only config-excluded positions.
41+
excludedFlagName = "excluded"
4042
)
4143

4244
// NewCommand returns a new safe-sell list command.
@@ -73,10 +75,15 @@ ACCOUNT column is shown in this mode.
7375
EXCLUSIONS
7476
7577
Symbols and asset types listed under safe_sell.exclude in ibctl.yaml are
76-
always omitted. Use safe_sell.exclude.symbols for specific tickers (e.g.,
77-
VTI, VXUS) and safe_sell.exclude.types for entire asset types (e.g., BOND,
78-
HOUSE). Types are matched against the type field from the categorization
79-
config.
78+
omitted by default. Use safe_sell.exclude.symbols for specific tickers
79+
(e.g., VTI, VXUS) and safe_sell.exclude.types for entire asset types
80+
(e.g., BOND, HOUSE). Types are matched against the type field from the
81+
categorization config.
82+
83+
Use --excluded to invert the filter and show only the excluded positions.
84+
The same FIFO analysis is run on them so you can see what you would be
85+
selling if they were not excluded. The results of safe-sell, safe-sell
86+
--excluded, and unsafe-sell together cover the full holding list.
8087
8188
STCG THRESHOLD
8289
@@ -130,6 +137,8 @@ type flags struct {
130137
Realtime bool
131138
// SeparateAccounts applies FIFO independently per account (CRA-style).
132139
SeparateAccounts bool
140+
// Excluded shows only positions excluded by config instead of the safe-sell candidates.
141+
Excluded bool
133142
}
134143

135144
func newFlags() *flags {
@@ -143,6 +152,7 @@ func (f *flags) Bind(flagSet *pflag.FlagSet) {
143152
flagSet.StringVar(&f.BaseCurrency, baseCurrencyFlagName, "USD", "Base currency for value conversion (e.g., USD, CAD)")
144153
flagSet.BoolVar(&f.Realtime, realtimeFlagName, false, "Fetch real-time quotes and FX rates from Yahoo Finance")
145154
flagSet.BoolVar(&f.SeparateAccounts, separateAccountsFlagName, false, "Apply FIFO independently per account (CRA-style)")
155+
flagSet.BoolVar(&f.Excluded, excludedFlagName, false, "Show only positions excluded by config (inverse of default output)")
146156
}
147157

148158
func run(ctx context.Context, container appext.Container, flags *flags) error {
@@ -204,6 +214,7 @@ func run(ctx context.Context, container appext.Container, flags *flags) error {
204214
fxStore,
205215
baseCurrency,
206216
flags.SeparateAccounts,
217+
flags.Excluded,
207218
)
208219
if err != nil {
209220
return err

cmd/ibctl/internal/ibctlcmd/holdingsdata.go

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -281,10 +281,12 @@ func ComputeYTDTaxSummary(
281281
todayStr := now.Format("2006-01-02")
282282
var taxPaidMicros int64
283283
for currency, amountMicros := range config.TaxPaid {
284+
// Return an error if FX conversion fails — tax remaining would be wrong if we silently skipped.
284285
convertedMicros, ok := fxStore.ConvertOnDate(amountMicros, currency, baseCurrency, todayStr)
285-
if ok {
286-
taxPaidMicros += convertedMicros
286+
if !ok {
287+
return nil, fmt.Errorf("FX conversion failed for tax paid amount: %s to %s on %s", currency, baseCurrency, todayStr)
287288
}
289+
taxPaidMicros += convertedMicros
288290
}
289291
return &YTDTaxSummary{
290292
DividendMicros: totals.DividendBaseMicros,

internal/ibctl/ibctlfxrates/ibctlfxrates.go

Lines changed: 33 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -130,13 +130,25 @@ func (s *Store) ConvertOnDate(valueMicros int64, baseCurrency string, quoteCurre
130130
// Each key in pairRatesMicros is a pair key (e.g., "USD.CAD") and the value
131131
// is the rate in micros. This is used by --realtime to apply fresh rates
132132
// for the current command run without persisting to disk.
133-
func (s *Store) OverrideLatestRates(todayDate string, pairRatesMicros map[string]int64) {
133+
func (s *Store) OverrideLatestRates(todayDate string, pairRatesMicros map[string]int64) error {
134+
// Pre-load any pairs from disk before overriding, so that historical rates
135+
// are available alongside the real-time rate. Without this, a pair created
136+
// here with only today's rate would shadow the on-disk historical data,
137+
// causing ConvertOnDate lookups for past dates (e.g., lot open dates) to fail.
138+
for pairKey := range pairRatesMicros {
139+
base, quote, err := splitPairKey(pairKey)
140+
if err != nil {
141+
return err
142+
}
143+
// loadPair handles its own locking and is a no-op if already loaded.
144+
s.loadPair(base, quote)
145+
}
134146
s.mu.Lock()
135147
defer s.mu.Unlock()
136148
for pairKey, rateMicros := range pairRatesMicros {
137-
pair, found := s.pairs[pairKey]
138-
if !found || pair == nil {
139-
// Create a new pair entry for a pair not yet loaded from disk.
149+
pair := s.pairs[pairKey]
150+
if pair == nil {
151+
// No historical data on disk for this pair — create a new entry.
140152
pair = &pairData{
141153
rates: make(map[string]int64),
142154
}
@@ -149,6 +161,23 @@ func (s *Store) OverrideLatestRates(todayDate string, pairRatesMicros map[string
149161
pair.latestDate = todayDate
150162
pair.rates[todayDate] = rateMicros
151163
}
164+
return nil
165+
}
166+
167+
// splitPairKey splits a pair key like "JPY.USD" into base and quote currency codes.
168+
// Returns an error if the key does not contain exactly one dot separator.
169+
func splitPairKey(pairKey string) (string, string, error) {
170+
for i, c := range pairKey {
171+
if c == '.' {
172+
base := pairKey[:i]
173+
quote := pairKey[i+1:]
174+
if base == "" || quote == "" {
175+
return "", "", fmt.Errorf("malformed FX pair key %q: base and quote must be non-empty", pairKey)
176+
}
177+
return base, quote, nil
178+
}
179+
}
180+
return "", "", fmt.Errorf("malformed FX pair key %q: expected format BASE.QUOTE", pairKey)
152181
}
153182

154183
// *** PRIVATE ***

0 commit comments

Comments
 (0)