Skip to content

Commit bdba525

Browse files
committed
safe-sell
1 parent 979d0ee commit bdba525

File tree

8 files changed

+199
-38
lines changed

8 files changed

+199
-38
lines changed

README.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -56,6 +56,7 @@ ibctl transaction list --from 20250101 --to 20251231 --base-currency USD
5656
| `ibctl holding lot list` | Display individual FIFO tax lots |
5757
| `ibctl holding category list` | Display holdings aggregated by category |
5858
| `ibctl holding geo list` | Display holdings aggregated by geographic classification |
59+
| `ibctl holding safe-sell list` | Identify positions safe to sell from a tax perspective (loss or LTCG only) |
5960
| `ibctl holding value` | Display portfolio value with estimated tax impact |
6061
| `ibctl transaction list` | List all transactions (buys, sells, dividends, interest, WHT, etc.) chronologically |
6162
| `ibctl transaction sale list` | List realized security sales with FIFO lot matching for tax reporting |

book/src/SUMMARY.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,7 @@
1616
- [holding lot list](commands/holding-lot-list.md)
1717
- [holding category list](commands/holding-category-list.md)
1818
- [holding geo list](commands/holding-geo-list.md)
19+
- [holding safe-sell list](commands/holding-safe-sell-list.md)
1920
- [holding value](commands/holding-value.md)
2021
- [transaction list](commands/transaction-list.md)
2122
- [transaction sale list](commands/transaction-sale-list.md)
Lines changed: 96 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,96 @@
1+
# holding safe-sell list
2+
3+
```
4+
ibctl holding safe-sell list [--dir DIR] [--format FORMAT] [--download] [--realtime] [--separate-accounts] [--base-currency CURRENCY]
5+
```
6+
7+
Identifies positions that can be sold without triggering significant short-term capital gains. Walks each symbol's FIFO lots in date order and stops when a lot's unrealized short-term capital gain meets or exceeds the `safe_sell.max_stcg` threshold from `ibctl.yaml`. Lots before the stop point are sellable if they represent a capital loss or a long-term capital gain.
8+
9+
This is useful for portfolio simplification: sell individual positions to harvest losses or realize long-term gains at favorable rates, then reinvest proceeds into core index fund allocations.
10+
11+
## Account combining (IRS vs CRA)
12+
13+
By default, lots from all accounts for the same symbol are merged into a single FIFO queue sorted by date. This is the IRS-correct behavior for individuals whose accounts include disregarded entities (e.g., single-member LLCs) — the IRS treats all disregarded-entity holdings as the individual's own, so FIFO matching spans all accounts.
14+
15+
Use `--separate-accounts` for CRA-style treatment where each account is an independent entity with its own FIFO queue. The same symbol may appear multiple times in the output (once per account) with different P&L. An ACCOUNT column is shown in this mode.
16+
17+
## Exclusions
18+
19+
Symbols and asset types listed under `safe_sell.exclude` in `ibctl.yaml` are always omitted from output. Use `safe_sell.exclude.symbols` for specific tickers (e.g., VTI, VXUS) and `safe_sell.exclude.types` for entire asset types (e.g., BOND, HOUSE). Types are matched against the `type` field from the `categorization` config.
20+
21+
## STCG threshold
22+
23+
The `safe_sell.max_stcg` setting in `ibctl.yaml` controls tolerance for short-term capital gains per lot, denominated in the base currency. At the default of 0, any lot with positive STCG stops the FIFO walk for that symbol. Setting `max_stcg` to 3000 allows lots with up to $3000 of STCG to pass through, which prevents small STCG lots from blocking access to loss or LTCG lots behind them in the queue.
24+
25+
## Columns
26+
27+
| Column | Description |
28+
|--------|-------------|
29+
| SYMBOL | Ticker symbol |
30+
| ACCOUNT | Account alias (only shown with `--separate-accounts`) |
31+
| QTY | Total quantity across all sellable lots |
32+
| QTY REMAINING | Quantity not included because the FIFO walk was stopped by a lot exceeding the STCG threshold. Zero means all lots for this symbol are sellable. |
33+
| CURRENCY | Native currency of cost basis and market price |
34+
| AVG PRICE | Weighted average cost basis per share in native currency |
35+
| MKT PRICE | Current market price per share in native currency |
36+
| MKT VAL {BASE} | Total current market value of sellable lots in base currency |
37+
| P&L {BASE} | Total unrealized P&L of sellable lots in base currency |
38+
| STCG {BASE} | Short-term portion of unrealized P&L in base currency |
39+
| LTCG {BASE} | Long-term portion of unrealized P&L in base currency |
40+
| CATEGORY | User-defined asset category from configuration |
41+
| TYPE | User-defined asset type |
42+
| SECTOR | User-defined sector classification |
43+
| GEO | User-defined geographic classification |
44+
45+
`{BASE}` is replaced with the `--base-currency` value (e.g., `MKT VAL USD`, `P&L CAD`).
46+
47+
## Sort order
48+
49+
Results are sorted by P&L ascending (losses first, then gains by size).
50+
51+
## Totals row
52+
53+
The table output includes a totals row summing MKT VAL {BASE}, P&L {BASE}, STCG {BASE}, and LTCG {BASE}.
54+
55+
## Configuration
56+
57+
The safe-sell command reads its exclusions and STCG threshold from `ibctl.yaml`:
58+
59+
```yaml
60+
safe_sell:
61+
exclude:
62+
symbols:
63+
- VTI
64+
- VXUS
65+
- BND
66+
types:
67+
- BOND
68+
- HOUSE
69+
max_stcg: 3000
70+
```
71+
72+
See the [Configuration](../configuration.md) page for full details on the `safe_sell` section.
73+
74+
## Examples
75+
76+
```bash
77+
# Strict mode: no STCG tolerance, real-time prices
78+
ibctl holding safe-sell list --realtime
79+
80+
# Per-account FIFO (CRA-style), JSON output
81+
ibctl holding safe-sell list --realtime --separate-accounts --format json
82+
83+
# CSV output in CAD
84+
ibctl holding safe-sell list --realtime --base-currency CAD --format csv
85+
```
86+
87+
## Flags
88+
89+
| Flag | Default | Description |
90+
|------|---------|-------------|
91+
| `--dir` | `.` | The ibctl directory containing `ibctl.yaml` |
92+
| `--format` | `table` | Output format: `table`, `csv`, or `json` |
93+
| `--download` | `false` | Download fresh data before displaying |
94+
| `--realtime` | `false` | Fetch real-time stock quotes and FX rates from Yahoo Finance |
95+
| `--separate-accounts` | `false` | Apply FIFO independently per account (CRA-style) |
96+
| `--base-currency` | `USD` | Base currency for value conversion (case-insensitive, e.g., `USD`, `CAD`) |

book/src/commands/overview.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -34,6 +34,7 @@ All holding commands accept `--base-currency` (default `USD`) to convert values
3434
| `ibctl holding lot list` | List individual FIFO tax lots with per-lot cost basis and P&L |
3535
| `ibctl holding category list` | Aggregate holdings by category with market value and P&L |
3636
| `ibctl holding geo list` | Aggregate holdings by geographic classification with market value and P&L |
37+
| `ibctl holding safe-sell list` | Identify positions safe to sell from a tax perspective (loss or LTCG only) |
3738
| `ibctl holding value` | Display portfolio value with estimated unrealized and YTD realized tax impact |
3839

3940
## Transaction commands

book/src/configuration.md

Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -80,6 +80,19 @@ additions:
8080
realtime_symbols:
8181
BRK B: BRK-B
8282
"700": "0700.HK"
83+
84+
# Safe-sell screener configuration.
85+
# Optional. Controls which positions appear in "holding safe-sell list" output.
86+
safe_sell:
87+
exclude:
88+
symbols:
89+
- VTI
90+
- VXUS
91+
- BND
92+
types:
93+
- BOND
94+
- HOUSE
95+
max_stcg: 3000
8396
```
8497
8598
## Field Reference
@@ -163,3 +176,19 @@ If tax rates are not set, they default to zero and no tax impact is shown. The `
163176
| Value (Yahoo symbol) | The equivalent symbol on Yahoo Finance | `BRK-B`, `0700.HK` |
164177

165178
US-listed securities typically need no mapping. If a symbol is not mapped and Yahoo Finance cannot resolve the IBKR symbol, the command logs a warning and falls back to the cached IBKR price for that symbol.
179+
180+
### `safe_sell`
181+
182+
**Optional.** Configuration for the `holding safe-sell list` command, which identifies positions safe to sell from a tax perspective. See the [holding safe-sell list](commands/holding-safe-sell-list.md) command documentation for full details.
183+
184+
#### `safe_sell.exclude.symbols`
185+
186+
A list of ticker symbols to always exclude from safe-sell output. Use this for core index fund positions you intend to hold regardless of tax treatment (e.g., VTI, VXUS, BND).
187+
188+
#### `safe_sell.exclude.types`
189+
190+
A list of asset types to always exclude from safe-sell output. Matched against the `type` field from the `categorization` config, normalized to uppercase. For example, `BOND` excludes all symbols categorized with `type: BOND`, and `HOUSE` excludes symbols with `type: HOUSE`.
191+
192+
#### `safe_sell.max_stcg`
193+
194+
The maximum acceptable short-term capital gain per lot, denominated in the base currency specified by `--base-currency` (default USD). Lots with STCG at or above this amount stop the FIFO walk for that symbol. Defaults to 0, which means any lot with positive STCG stops the walk. Setting this to 3000 allows lots with up to $3000 of STCG to pass through, preventing small STCG lots from blocking access to loss or LTCG lots behind them in the queue.

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

Lines changed: 5 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -72,8 +72,11 @@ ACCOUNT column is shown in this mode.
7272
7373
EXCLUSIONS
7474
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.
75+
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.
7780
7881
STCG THRESHOLD
7982

internal/ibctl/ibctlconfig/ibctlconfig.go

Lines changed: 56 additions & 32 deletions
Original file line numberDiff line numberDiff line change
@@ -75,10 +75,14 @@ accounts:
7575
# Optional. Symbols listed here are excluded from "holding safe-sell list" output.
7676
# Use this to exclude core positions you intend to hold regardless of tax treatment.
7777
# safe_sell:
78-
# excludes:
79-
# - VTI
80-
# - VXUS
81-
# - BND
78+
# exclude:
79+
# symbols:
80+
# - VTI
81+
# - VXUS
82+
# - BND
83+
# types:
84+
# - BOND
85+
# - HOUSE
8286
# max_stcg: 0
8387
`
8488

@@ -116,9 +120,8 @@ type ExternalConfigV1 struct {
116120

117121
// ExternalSafeSellV1 holds safe-sell screener configuration.
118122
type ExternalSafeSellV1 struct {
119-
// Excludes is the list of symbols to exclude from safe-sell output.
120-
// Useful for core index funds that should never be sold (e.g., VTI, VXUS, BND).
121-
Excludes []string `yaml:"excludes"`
123+
// Exclude configures which symbols and types are excluded from safe-sell output.
124+
Exclude *ExternalSafeSellExcludeV1 `yaml:"exclude"`
122125
// MaxSTCG is the maximum acceptable short-term capital gain per lot, denominated in the
123126
// base currency specified by --base-currency (default USD). Lots with STCG >= this
124127
// amount stop the FIFO walk for that symbol. 0 means no tolerance for any positive
@@ -127,6 +130,15 @@ type ExternalSafeSellV1 struct {
127130
MaxSTCG float64 `yaml:"max_stcg"`
128131
}
129132

133+
// ExternalSafeSellExcludeV1 holds exclusion lists for the safe-sell screener.
134+
type ExternalSafeSellExcludeV1 struct {
135+
// Symbols is the list of ticker symbols to exclude (e.g., VTI, VXUS, BND).
136+
Symbols []string `yaml:"symbols"`
137+
// Types is the list of asset types to exclude (e.g., BOND, HOUSE).
138+
// Matched against the type field from the categorization config.
139+
Types []string `yaml:"types"`
140+
}
141+
130142
// ExternalTaxConfigV1 holds capital gains tax rate configuration.
131143
type ExternalTaxConfigV1 struct {
132144
// STCG is the short-term capital gains tax rate (e.g., 0.408 for 40.8%).
@@ -217,9 +229,13 @@ type Config struct {
217229
// OptionsDataProviders is the ordered list of options data providers for covered call screening.
218230
// Valid values: "marketdata_app", "polygon", "yahoo". Default: ["yahoo"].
219231
OptionsDataProviders []string
220-
// SafeSellExcludes is the set of symbols excluded from the safe-sell screener.
221-
// Built from the safe_sell.excludes config list for O(1) lookup.
222-
SafeSellExcludes map[string]struct{}
232+
// SafeSellExcludeSymbols is the set of symbols excluded from the safe-sell screener.
233+
// Built from the safe_sell.exclude.symbols config list for O(1) lookup.
234+
SafeSellExcludeSymbols map[string]struct{}
235+
// SafeSellExcludeTypes is the set of asset types excluded from the safe-sell screener.
236+
// Built from the safe_sell.exclude.types config list for O(1) lookup.
237+
// Matched against the type field from the categorization config (e.g., "BOND", "HOUSE").
238+
SafeSellExcludeTypes map[string]struct{}
223239
// SafeSellMaxSTCGMicros is the maximum acceptable STCG per lot in micros.
224240
// Lots with STCG >= this stop the FIFO walk. 0 means no positive STCG tolerance.
225241
SafeSellMaxSTCGMicros int64
@@ -360,37 +376,45 @@ func NewConfigV1(externalConfig ExternalConfigV1, dirPath string) (*Config, erro
360376
return nil, fmt.Errorf("invalid options_data_providers value %q, must be \"marketdata_app\", \"polygon\", or \"yahoo\"", providerName)
361377
}
362378
}
363-
// Build safe-sell excludes set for O(1) symbol lookup, and parse max STCG threshold.
364-
safeSellExcludes := make(map[string]struct{})
379+
// Build safe-sell exclude sets for O(1) lookup, and parse max STCG threshold.
380+
safeSellExcludeSymbols := make(map[string]struct{})
381+
safeSellExcludeTypes := make(map[string]struct{})
365382
var safeSellMaxSTCGMicros int64
366383
if externalConfig.SafeSell != nil {
367-
for _, sym := range externalConfig.SafeSell.Excludes {
368-
safeSellExcludes[sym] = struct{}{}
384+
if externalConfig.SafeSell.Exclude != nil {
385+
for _, sym := range externalConfig.SafeSell.Exclude.Symbols {
386+
safeSellExcludeSymbols[sym] = struct{}{}
387+
}
388+
// Normalize types to uppercase to match categorization config.
389+
for _, typ := range externalConfig.SafeSell.Exclude.Types {
390+
safeSellExcludeTypes[strings.ToUpper(typ)] = struct{}{}
391+
}
369392
}
370393
if externalConfig.SafeSell.MaxSTCG < 0 {
371394
return nil, fmt.Errorf("safe_sell.max_stcg must be >= 0, got %f", externalConfig.SafeSell.MaxSTCG)
372395
}
373396
safeSellMaxSTCGMicros = int64(externalConfig.SafeSell.MaxSTCG * 1_000_000)
374397
}
375398
return &Config{
376-
DirPath: dirPath,
377-
IBKRFlexQueryID: externalConfig.FlexQueryID,
378-
AccountAliases: accountAliases,
379-
AccountIDToAlias: accountIDToAlias,
380-
SymbolConfigs: symbolConfigs,
381-
CashAdjustments: cashAdjustments,
382-
TaxRateSTCG: taxRateSTCG,
383-
TaxRateLTCG: taxRateLTCG,
384-
TaxRateDividend: taxRateDividend,
385-
TaxRateInterest: taxRateInterest,
386-
TaxPaid: taxPaid,
387-
Additions: additions,
388-
AdditionLastPrices: additionLastPrices,
389-
RealtimeSymbols: realtimeSymbols,
390-
CashInterestRates: cashInterestRates,
391-
OptionsDataProviders: marketDataProviders,
392-
SafeSellExcludes: safeSellExcludes,
393-
SafeSellMaxSTCGMicros: safeSellMaxSTCGMicros,
399+
DirPath: dirPath,
400+
IBKRFlexQueryID: externalConfig.FlexQueryID,
401+
AccountAliases: accountAliases,
402+
AccountIDToAlias: accountIDToAlias,
403+
SymbolConfigs: symbolConfigs,
404+
CashAdjustments: cashAdjustments,
405+
TaxRateSTCG: taxRateSTCG,
406+
TaxRateLTCG: taxRateLTCG,
407+
TaxRateDividend: taxRateDividend,
408+
TaxRateInterest: taxRateInterest,
409+
TaxPaid: taxPaid,
410+
Additions: additions,
411+
AdditionLastPrices: additionLastPrices,
412+
RealtimeSymbols: realtimeSymbols,
413+
CashInterestRates: cashInterestRates,
414+
OptionsDataProviders: marketDataProviders,
415+
SafeSellExcludeSymbols: safeSellExcludeSymbols,
416+
SafeSellExcludeTypes: safeSellExcludeTypes,
417+
SafeSellMaxSTCGMicros: safeSellMaxSTCGMicros,
394418
}, nil
395419
}
396420

internal/ibctl/ibctlsafesell/ibctlsafesell.go

Lines changed: 10 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -213,8 +213,8 @@ type groupKey struct {
213213
// short-term capital gains. It walks FIFO lots per symbol in date order and stops
214214
// when a lot's unrealized STCG meets or exceeds config.SafeSellMaxSTCGMicros. Lots
215215
// before the stop point that have either a capital loss (PnL < 0) or long-term gain
216-
// (LTCG > 0) qualify as "safe to sell". Symbols in config.SafeSellExcludes are always
217-
// omitted.
216+
// (LTCG > 0) qualify as "safe to sell". Symbols in config.SafeSellExcludeSymbols and
217+
// symbols whose type matches config.SafeSellExcludeTypes are always omitted.
218218
//
219219
// When separateAccounts is false (the default), all trades for a symbol are merged
220220
// into a single FIFO queue regardless of account by rewriting account aliases to a
@@ -306,10 +306,16 @@ func GetSafeSellList(
306306
var results []*SafeSellOverview
307307
for key, lots := range groupedLots {
308308
symbol := key.symbol
309-
// Skip excluded symbols.
310-
if _, excluded := config.SafeSellExcludes[symbol]; excluded {
309+
// Skip symbols excluded by name.
310+
if _, excluded := config.SafeSellExcludeSymbols[symbol]; excluded {
311311
continue
312312
}
313+
// Skip symbols excluded by type (matched against categorization config).
314+
if symbolConfig, ok := config.SymbolConfigs[symbol]; ok {
315+
if _, excluded := config.SafeSellExcludeTypes[symbolConfig.Type]; excluded {
316+
continue
317+
}
318+
}
313319
// Sort lots by open date for FIFO walk (they should already be sorted from
314320
// ComputeTaxLots, but re-sort to be safe).
315321
sort.Slice(lots, func(i, j int) bool {

0 commit comments

Comments
 (0)