@@ -216,10 +216,221 @@ func unionSymbolSets(sets ...map[string]struct{}) map[string]struct{} {
216216 return result
217217}
218218
219+ // TestSafePlusExcludedPlusUnsafe_MutuallyExclusive verifies that every lot
220+ // appears in exactly one of the three tables, and that the sum of market values
221+ // across all three equals the lot-level total (what holding list shows minus cash).
222+ //
223+ // This test covers the edge cases that caused double-counting and missing symbols:
224+ // - Multi-lot symbol split across safe and unsafe (FIFO stop point)
225+ // - Excluded symbol with both LTCG and STCG lots
226+ // - Symbol with all STCG below threshold but no qualifying lots (loss/LTCG)
227+ // - Symbol entirely at a loss (all safe)
228+ // - Symbol entirely LTCG (all safe)
229+ func TestSafePlusExcludedPlusUnsafe_MutuallyExclusive (t * testing.T ) {
230+ t .Parallel ()
231+ now := time .Now ()
232+ // Helper to create a buy date N days ago.
233+ daysAgo := func (n int ) * timev1.Date {
234+ t := now .AddDate (0 , 0 , - n )
235+ return mustPossibleSaleProtoDate (nil , t .Year (), t .Month (), t .Day ())
236+ }
237+ // Use a wrapper since mustPossibleSaleProtoDate needs *testing.T.
238+ daysAgoDate := func (n int ) * timev1.Date {
239+ tm := now .AddDate (0 , 0 , - n )
240+ d , _ := timepb .NewProtoDate (tm .Year (), tm .Month (), tm .Day ())
241+ return d
242+ }
243+ _ = daysAgo // suppress unused
244+
245+ makeTrade := func (id , symbol string , buyDate * timev1.Date , qty , price int64 ) * datav1.Trade {
246+ return & datav1.Trade {
247+ TradeId : id , AccountAlias : "individual" ,
248+ TradeDate : buyDate , SettleDate : buyDate ,
249+ Symbol : symbol , Side : datav1 .TradeSide_TRADE_SIDE_BUY ,
250+ Quantity : mathpb .FromMicros (qty ),
251+ TradePrice : moneypb .MoneyFromMicros ("USD" , price ),
252+ Proceeds : moneypb .MoneyFromMicros ("USD" , 0 ),
253+ Commission : moneypb .MoneyFromMicros ("USD" , 0 ),
254+ CurrencyCode : "USD" , AssetCategory : "STK" ,
255+ }
256+ }
257+ makePosition := func (symbol string , qty , mktPrice int64 ) * datav1.Position {
258+ return & datav1.Position {
259+ Symbol : symbol , AccountAlias : "individual" , AssetCategory : "STK" ,
260+ Quantity : mathpb .FromMicros (qty ),
261+ CostBasisPrice : moneypb .MoneyFromMicros ("USD" , mktPrice ),
262+ MarketPrice : moneypb .MoneyFromMicros ("USD" , mktPrice ),
263+ MarketValue : moneypb .MoneyFromMicros ("USD" , mktPrice * qty / ibctltaxlot .MicrosFactor ),
264+ CurrencyCode : "USD" ,
265+ }
266+ }
267+
268+ // Current market price for all symbols: $200/share.
269+ const mktPrice = 200_000_000
270+
271+ trades := []* datav1.Trade {
272+ // SPLIT: 2 lots. First bought 400d ago at $150 (LTCG gain, safe).
273+ // Second bought 20d ago at $150 (STCG gain >= threshold, unsafe).
274+ makeTrade ("split-1" , "SPLIT" , daysAgoDate (400 ), 10_000_000 , 150_000_000 ),
275+ makeTrade ("split-2" , "SPLIT" , daysAgoDate (20 ), 5_000_000 , 150_000_000 ),
276+
277+ // EXCL: excluded symbol. 1 lot bought 400d ago at $150 (LTCG, safe-excluded).
278+ // 1 lot bought 20d ago at $150 (STCG gain, unsafe — exclude list doesn't affect unsafe).
279+ makeTrade ("excl-1" , "EXCL" , daysAgoDate (400 ), 10_000_000 , 150_000_000 ),
280+ makeTrade ("excl-2" , "EXCL" , daysAgoDate (20 ), 5_000_000 , 150_000_000 ),
281+
282+ // SMGAIN: 1 lot bought 30d ago at $190 (STCG gain of $10/share = $100 total,
283+ // below max_stcg of 0). No loss or LTCG, so safe doesn't qualify.
284+ // Should appear in unsafe.
285+ makeTrade ("smgain-1" , "SMGAIN" , daysAgoDate (30 ), 10_000_000 , 190_000_000 ),
286+
287+ // ALLLOSS: 1 lot bought 30d ago at $250 (STCG loss). All safe.
288+ makeTrade ("allloss-1" , "ALLLOSS" , daysAgoDate (30 ), 10_000_000 , 250_000_000 ),
289+
290+ // ALLLTCG: 1 lot bought 500d ago at $100 (LTCG gain). All safe.
291+ makeTrade ("allltcg-1" , "ALLLTCG" , daysAgoDate (500 ), 10_000_000 , 100_000_000 ),
292+ }
293+
294+ positions := []* datav1.Position {
295+ makePosition ("SPLIT" , 15_000_000 , mktPrice ),
296+ makePosition ("EXCL" , 15_000_000 , mktPrice ),
297+ makePosition ("SMGAIN" , 10_000_000 , mktPrice ),
298+ makePosition ("ALLLOSS" , 10_000_000 , mktPrice ),
299+ makePosition ("ALLLTCG" , 10_000_000 , mktPrice ),
300+ }
301+
302+ cfg := & ibctlconfig.Config {
303+ // EXCL is excluded from safe.
304+ PossibleSaleExcludeSymbols : map [string ]struct {}{"EXCL" : {}},
305+ PossibleSaleExcludeTypes : map [string ]struct {}{},
306+ // max_stcg=0: any positive STCG stops the safe walk.
307+ PossibleSaleMaxSTCGMicros : 0 ,
308+ SymbolConfigs : map [string ]ibctlconfig.SymbolConfig {},
309+ AdditionLastPrices : map [string ]int64 {},
310+ }
311+ fxStore := ibctlfxrates .NewStore (t .TempDir ())
312+
313+ // Get all three views.
314+ safe , err := GetSafeSellList (trades , positions , cfg , fxStore , "USD" , false , false )
315+ require .NoError (t , err )
316+ excluded , err := GetSafeSellList (trades , positions , cfg , fxStore , "USD" , false , true )
317+ require .NoError (t , err )
318+ unsafe , err := GetUnsafeSellList (trades , positions , cfg , fxStore , "USD" , false )
319+ require .NoError (t , err )
320+
321+ // Build maps of symbol → qty and symbol → mktval per table.
322+ type entry struct {
323+ qty int64
324+ mktVal int64
325+ }
326+ toMap := func (rows []* PossibleSaleOverview ) map [string ]entry {
327+ m := make (map [string ]entry )
328+ for _ , r := range rows {
329+ e := m [r .Symbol ]
330+ e .qty += mathpb .ToMicros (r .Quantity )
331+ e .mktVal += mathpb .ParseMicros (r .MktValBase )
332+ m [r .Symbol ] = e
333+ }
334+ return m
335+ }
336+ safeMap := toMap (safe )
337+ exclMap := toMap (excluded )
338+ unsafeMap := toMap (unsafe )
339+
340+ // Verify each symbol appears in the expected table(s).
341+ // SPLIT: 10 shares safe (LTCG lot), 5 shares unsafe (STCG lot).
342+ require .Contains (t , safeMap , "SPLIT" , "SPLIT LTCG lot should be in safe" )
343+ require .Contains (t , unsafeMap , "SPLIT" , "SPLIT STCG lot should be in unsafe" )
344+ require .NotContains (t , exclMap , "SPLIT" , "SPLIT should not be in excluded" )
345+ require .Equal (t , int64 (10_000_000 ), safeMap ["SPLIT" ].qty , "SPLIT safe qty" )
346+ require .Equal (t , int64 (5_000_000 ), unsafeMap ["SPLIT" ].qty , "SPLIT unsafe qty" )
347+
348+ // EXCL: 10 shares safe-excluded (LTCG lot), 5 shares unsafe (STCG lot).
349+ require .Contains (t , exclMap , "EXCL" , "EXCL LTCG lot should be in safe-excluded" )
350+ require .Contains (t , unsafeMap , "EXCL" , "EXCL STCG lot should be in unsafe" )
351+ require .NotContains (t , safeMap , "EXCL" , "EXCL should not be in safe" )
352+ require .Equal (t , int64 (10_000_000 ), exclMap ["EXCL" ].qty , "EXCL excluded qty" )
353+ require .Equal (t , int64 (5_000_000 ), unsafeMap ["EXCL" ].qty , "EXCL unsafe qty" )
354+
355+ // SMGAIN: all 10 shares unsafe (STCG gain, no qualifying lots for safe).
356+ require .Contains (t , unsafeMap , "SMGAIN" , "SMGAIN should be in unsafe" )
357+ require .NotContains (t , safeMap , "SMGAIN" , "SMGAIN should not be in safe" )
358+ require .NotContains (t , exclMap , "SMGAIN" , "SMGAIN should not be in excluded" )
359+ require .Equal (t , int64 (10_000_000 ), unsafeMap ["SMGAIN" ].qty , "SMGAIN unsafe qty" )
360+
361+ // ALLLOSS: all 10 shares safe (loss).
362+ require .Contains (t , safeMap , "ALLLOSS" , "ALLLOSS should be in safe" )
363+ require .NotContains (t , unsafeMap , "ALLLOSS" , "ALLLOSS should not be in unsafe" )
364+ require .Equal (t , int64 (10_000_000 ), safeMap ["ALLLOSS" ].qty , "ALLLOSS safe qty" )
365+
366+ // ALLLTCG: all 10 shares safe (LTCG).
367+ require .Contains (t , safeMap , "ALLLTCG" , "ALLLTCG should be in safe" )
368+ require .NotContains (t , unsafeMap , "ALLLTCG" , "ALLLTCG should not be in unsafe" )
369+ require .Equal (t , int64 (10_000_000 ), safeMap ["ALLLTCG" ].qty , "ALLLTCG safe qty" )
370+
371+ // Verify the summation invariant: for every symbol, the sum of qty across
372+ // all three tables must equal the total holding qty.
373+ expectedQty := map [string ]int64 {
374+ "SPLIT" : 15_000_000 ,
375+ "EXCL" : 15_000_000 ,
376+ "SMGAIN" : 10_000_000 ,
377+ "ALLLOSS" : 10_000_000 ,
378+ "ALLLTCG" : 10_000_000 ,
379+ }
380+ for sym , expectedQ := range expectedQty {
381+ actualQ := safeMap [sym ].qty + exclMap [sym ].qty + unsafeMap [sym ].qty
382+ require .Equal (t , expectedQ , actualQ , "qty mismatch for %s: safe(%d) + excl(%d) + unsafe(%d) = %d, expected %d" ,
383+ sym , safeMap [sym ].qty , exclMap [sym ].qty , unsafeMap [sym ].qty , actualQ , expectedQ )
384+ }
385+
386+ // Verify market value summation at BOTH per-symbol and aggregate level.
387+ // Compute expected per-symbol market values from lot-level analysis.
388+ analysis , err := ibctltaxlot .PrepareLotAnalysis (& ibctltaxlot.LotAnalysisParams {
389+ Trades : trades ,
390+ Positions : positions ,
391+ AdditionLastPrices : cfg .AdditionLastPrices ,
392+ })
393+ require .NoError (t , err )
394+ // Build expected per-symbol market values from lot-level P&L computation.
395+ expectedMktValBySymbol := make (map [string ]int64 )
396+ for _ , lot := range analysis .TaxLotResult .TaxLots {
397+ pd := analysis .PositionMap [lot .GetSymbol ()]
398+ if pd == nil {
399+ continue
400+ }
401+ pnl , pnlErr := ibctltaxlot .ComputeLotPnL (lot , pd .LastPriceMicros , pd .IsBond , fxStore , "USD" , analysis .Today , analysis .TodayStr )
402+ require .NoError (t , pnlErr )
403+ expectedMktValBySymbol [lot .GetSymbol ()] += pnl .MktValBaseMicros
404+ }
405+ // Per-symbol: sum market value across all three tables and compare to lot-level.
406+ for sym , expectedMV := range expectedMktValBySymbol {
407+ actualMV := safeMap [sym ].mktVal + exclMap [sym ].mktVal + unsafeMap [sym ].mktVal
408+ require .Equal (t , expectedMV , actualMV ,
409+ "market value mismatch for %s: safe(%d) + excl(%d) + unsafe(%d) = %d, expected %d" ,
410+ sym , safeMap [sym ].mktVal , exclMap [sym ].mktVal , unsafeMap [sym ].mktVal , actualMV , expectedMV )
411+ }
412+ // Aggregate: sum across all symbols must also match.
413+ var expectedTotalMktVal , actualTotalMktVal int64
414+ for _ , mv := range expectedMktValBySymbol {
415+ expectedTotalMktVal += mv
416+ }
417+ for _ , rows := range [][]* PossibleSaleOverview {safe , excluded , unsafe } {
418+ for _ , r := range rows {
419+ actualTotalMktVal += mathpb .ParseMicros (r .MktValBase )
420+ }
421+ }
422+ require .Equal (t , expectedTotalMktVal , actualTotalMktVal ,
423+ "total market value across safe+excluded+unsafe must equal lot-level analysis total" )
424+ }
425+
219426// mustPossibleSaleProtoDate creates a proto Date, failing the test on error.
220427func mustPossibleSaleProtoDate (t * testing.T , year int , month time.Month , day int ) * timev1.Date {
221- t .Helper ()
428+ if t != nil {
429+ t .Helper ()
430+ }
222431 d , err := timepb .NewProtoDate (year , month , day )
223- require .NoError (t , err )
432+ if err != nil && t != nil {
433+ require .NoError (t , err )
434+ }
224435 return d
225436}
0 commit comments