Skip to content

Commit b99249c

Browse files
committed
commit
1 parent 0b768d0 commit b99249c

File tree

3 files changed

+331
-22
lines changed

3 files changed

+331
-22
lines changed

internal/ibctl/ibctlconfig/ibctlconfig.go

Lines changed: 50 additions & 22 deletions
Original file line numberDiff line numberDiff line change
@@ -102,7 +102,8 @@ type ExternalConfigV1 struct {
102102
Cash map[string]string `yaml:"cash"`
103103
// Taxes configures capital gains tax rates for portfolio value computation.
104104
Taxes *ExternalTaxConfigV1 `yaml:"taxes"`
105-
// Additions is the list of manually added trades from non-IBKR brokers.
105+
// Additions is the list of manually added trades.
106+
// Additions with an IBKR account alias merge into that account.
106107
Additions []ExternalAdditionV1 `yaml:"additions"`
107108
// RealtimeSymbols maps IBKR symbols to Yahoo Finance symbols for --realtime quote lookups.
108109
// Only needed for international tickers where symbols differ (e.g., SHOP → SHOP.TO).
@@ -178,9 +179,11 @@ type ExternalCategorizationV1 struct {
178179
Geo string `yaml:"geo"`
179180
}
180181

181-
// ExternalAdditionV1 holds a manually added trade from a non-IBKR broker.
182+
// ExternalAdditionV1 holds a manually added trade.
183+
// Additions with an IBKR account alias merge into that account for FIFO lot matching.
182184
type ExternalAdditionV1 struct {
183-
// AccountAlias is the account alias for this addition (must not match an IBKR account alias).
185+
// AccountAlias is the account alias for this addition.
186+
// May match an IBKR account alias to merge trades into an existing account.
184187
AccountAlias string `yaml:"account_alias"`
185188
// Symbol is the ticker symbol.
186189
Symbol string `yaml:"symbol"`
@@ -199,6 +202,9 @@ type ExternalAdditionV1 struct {
199202
LastPrice string `yaml:"last_price"`
200203
// TaxExempt indicates whether this trade is in a tax-sheltered account.
201204
TaxExempt bool `yaml:"tax_exempt"`
205+
// UpdateCash adjusts the cash balance for this addition's currency.
206+
// Buys subtract (trade_price * quantity), sells add.
207+
UpdateCash bool `yaml:"update_cash"`
202208
}
203209

204210
// Config is the validated runtime configuration derived from the config file.
@@ -221,7 +227,7 @@ type Config struct {
221227
TaxRateSTCG float64
222228
// TaxRateLTCG is the long-term capital gains tax rate (e.g., 0.28).
223229
TaxRateLTCG float64
224-
// Additions is the list of validated addition trades from non-IBKR brokers.
230+
// Additions is the list of validated manual addition trades.
225231
Additions []Addition
226232
// AdditionLastPrices maps symbols to their fallback last price in micros.
227233
// Used when no IBKR position exists for the symbol.
@@ -264,7 +270,7 @@ type SymbolConfig struct {
264270
Geo string
265271
}
266272

267-
// Addition is a validated addition trade from a non-IBKR broker.
273+
// Addition is a validated manual addition trade.
268274
type Addition struct {
269275
// AccountAlias is the account alias for this addition.
270276
AccountAlias string
@@ -284,6 +290,9 @@ type Addition struct {
284290
LastPrice int64
285291
// TaxExempt indicates whether this trade is in a tax-sheltered account.
286292
TaxExempt bool
293+
// UpdateCash adjusts the cash balance for this addition's currency.
294+
// Buys subtract (trade_price * quantity), sells add.
295+
UpdateCash bool
287296
}
288297

289298
// NewConfigV1 validates an ExternalConfigV1 and returns a runtime Config.
@@ -358,11 +367,15 @@ func NewConfigV1(externalConfig ExternalConfigV1, dirPath string) (*Config, erro
358367
taxPaid[strings.ToUpper(currency)] = units*1_000_000 + micros
359368
}
360369
}
361-
// Parse and validate additions from non-IBKR brokers.
362-
additions, additionLastPrices, err := parseAdditions(externalConfig.Additions, accountAliases)
370+
// Parse and validate additions (manual trades that merge into the portfolio).
371+
additions, additionLastPrices, additionCashAdjustments, err := parseAdditions(externalConfig.Additions)
363372
if err != nil {
364373
return nil, err
365374
}
375+
// Fold in cash adjustments from update_cash additions.
376+
for currency, adjustment := range additionCashAdjustments {
377+
cashAdjustments[currency] += adjustment
378+
}
366379
// Build realtime symbols map, defaulting to empty if not configured.
367380
// Build realtime symbols map, defaulting to empty if not configured.
368381
realtimeSymbols := externalConfig.RealtimeSymbols
@@ -479,33 +492,34 @@ func ValidateConfig(dirPath string) error {
479492
// *** PRIVATE ***
480493

481494
// parseAdditions validates and converts external addition configs to runtime Addition structs.
482-
// Returns the parsed additions and a map of symbol → last price in micros.
483-
func parseAdditions(externalAdditions []ExternalAdditionV1, ibkrAliases map[string]string) ([]Addition, map[string]int64, error) {
495+
// Returns the parsed additions, a map of symbol → last price in micros, and
496+
// a map of currency → cash adjustment in micros from update_cash additions.
497+
func parseAdditions(externalAdditions []ExternalAdditionV1) ([]Addition, map[string]int64, map[string]int64, error) {
484498
additions := make([]Addition, 0, len(externalAdditions))
485499
additionLastPrices := make(map[string]int64)
500+
// Accumulated cash adjustments from update_cash additions, keyed by currency code.
501+
additionCashAdjustments := make(map[string]int64)
486502
for i, ext := range externalAdditions {
487503
if ext.AccountAlias == "" {
488-
return nil, nil, fmt.Errorf("addition[%d]: account_alias is required", i)
489-
}
490-
// Addition account aliases must not match any IBKR account alias.
491-
if _, ok := ibkrAliases[ext.AccountAlias]; ok {
492-
return nil, nil, fmt.Errorf("addition[%d]: account_alias %q conflicts with an IBKR account alias", i, ext.AccountAlias)
504+
return nil, nil, nil, fmt.Errorf("addition[%d]: account_alias is required", i)
493505
}
506+
// Addition account aliases may match IBKR account aliases — those trades
507+
// merge naturally into the IBKR account for FIFO lot matching.
494508
if ext.Symbol == "" {
495-
return nil, nil, fmt.Errorf("addition[%d]: symbol is required", i)
509+
return nil, nil, nil, fmt.Errorf("addition[%d]: symbol is required", i)
496510
}
497511
if ext.CurrencyCode == "" {
498-
return nil, nil, fmt.Errorf("addition[%d]: currency_code is required", i)
512+
return nil, nil, nil, fmt.Errorf("addition[%d]: currency_code is required", i)
499513
}
500514
// Parse trade date in YYYYMMDD format.
501515
tradeDate, err := time.Parse("20060102", ext.TradeDate)
502516
if err != nil {
503-
return nil, nil, fmt.Errorf("addition[%d]: trade_date %q must be YYYYMMDD format: %w", i, ext.TradeDate, err)
517+
return nil, nil, nil, fmt.Errorf("addition[%d]: trade_date %q must be YYYYMMDD format: %w", i, ext.TradeDate, err)
504518
}
505519
// Parse trade price as micros.
506520
tradePriceUnits, tradePriceMicros, err := mathpb.ParseToUnitsMicros(ext.TradePrice)
507521
if err != nil {
508-
return nil, nil, fmt.Errorf("addition[%d]: invalid trade_price %q: %w", i, ext.TradePrice, err)
522+
return nil, nil, nil, fmt.Errorf("addition[%d]: invalid trade_price %q: %w", i, ext.TradePrice, err)
509523
}
510524
// Parse trade side.
511525
var tradeSide datav1.TradeSide
@@ -515,17 +529,17 @@ func parseAdditions(externalAdditions []ExternalAdditionV1, ibkrAliases map[stri
515529
case "sell":
516530
tradeSide = datav1.TradeSide_TRADE_SIDE_SELL
517531
default:
518-
return nil, nil, fmt.Errorf("addition[%d]: trade_side must be \"buy\" or \"sell\", got %q", i, ext.TradeSide)
532+
return nil, nil, nil, fmt.Errorf("addition[%d]: trade_side must be \"buy\" or \"sell\", got %q", i, ext.TradeSide)
519533
}
520534
// Parse quantity as micros.
521535
quantityUnits, quantityMicros, err := mathpb.ParseToUnitsMicros(ext.Quantity)
522536
if err != nil {
523-
return nil, nil, fmt.Errorf("addition[%d]: invalid quantity %q: %w", i, ext.Quantity, err)
537+
return nil, nil, nil, fmt.Errorf("addition[%d]: invalid quantity %q: %w", i, ext.Quantity, err)
524538
}
525539
// Parse last price as micros.
526540
lastPriceUnits, lastPriceMicros, err := mathpb.ParseToUnitsMicros(ext.LastPrice)
527541
if err != nil {
528-
return nil, nil, fmt.Errorf("addition[%d]: invalid last_price %q: %w", i, ext.LastPrice, err)
542+
return nil, nil, nil, fmt.Errorf("addition[%d]: invalid last_price %q: %w", i, ext.LastPrice, err)
529543
}
530544
lastPriceTotalMicros := lastPriceUnits*1_000_000 + lastPriceMicros
531545
additions = append(additions, Addition{
@@ -542,14 +556,28 @@ func parseAdditions(externalAdditions []ExternalAdditionV1, ibkrAliases map[stri
542556
Quantity: quantityUnits*1_000_000 + quantityMicros,
543557
LastPrice: lastPriceTotalMicros,
544558
TaxExempt: ext.TaxExempt,
559+
UpdateCash: ext.UpdateCash,
545560
})
546561
// Store the last price for fallback market price lookup.
547562
// Last addition for a symbol wins (allows overriding per-symbol).
548563
if lastPriceTotalMicros != 0 {
549564
additionLastPrices[ext.Symbol] = lastPriceTotalMicros
550565
}
566+
// Accumulate cash adjustment: buys subtract, sells add.
567+
if ext.UpdateCash {
568+
tradePriceTotalMicros := tradePriceUnits*1_000_000 + tradePriceMicros
569+
quantityTotalMicros := quantityUnits*1_000_000 + quantityMicros
570+
// trade_price is in micros, quantity is in micros, so multiply and divide
571+
// by 1_000_000 to get cost in micros: (price * qty) / 1_000_000.
572+
costMicros := tradePriceTotalMicros * quantityTotalMicros / 1_000_000
573+
if tradeSide == datav1.TradeSide_TRADE_SIDE_BUY {
574+
additionCashAdjustments[ext.CurrencyCode] -= costMicros
575+
} else {
576+
additionCashAdjustments[ext.CurrencyCode] += costMicros
577+
}
578+
}
551579
}
552-
return additions, additionLastPrices, nil
580+
return additions, additionLastPrices, additionCashAdjustments, nil
553581
}
554582

555583
// unmarshalYAMLStrict unmarshals the data as YAML with strict field checking.

0 commit comments

Comments
 (0)