@@ -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.
182184type 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.
268274type 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