@@ -11,6 +11,7 @@ package ibctlholdings
1111
1212import (
1313 "sort"
14+ "time"
1415
1516 datav1 "github.com/bufdev/ibctl/internal/gen/proto/go/ibctl/data/v1"
1617 mathv1 "github.com/bufdev/ibctl/internal/gen/proto/go/standard/math/v1"
@@ -19,6 +20,7 @@ import (
1920 "github.com/bufdev/ibctl/internal/ibctl/ibctltaxlot"
2021 "github.com/bufdev/ibctl/internal/pkg/mathpb"
2122 "github.com/bufdev/ibctl/internal/pkg/moneypb"
23+ "github.com/bufdev/ibctl/internal/standard/xtime"
2224)
2325
2426// HoldingsResult contains the holdings overview along with any data
@@ -50,6 +52,10 @@ type HoldingOverview struct {
5052 MarketValueUSD string `json:"market_value_usd,omitempty"`
5153 // UnrealizedPnLUSD is (last price USD - avg price USD) * position.
5254 UnrealizedPnLUSD string `json:"unrealized_pnl_usd,omitempty"`
55+ // STCGUSD is the short-term (<365 days) unrealized P&L in USD, computed per lot.
56+ STCGUSD string `json:"stcg_usd,omitempty"`
57+ // LTCGUSD is the long-term (>=365 days) unrealized P&L in USD, computed per lot.
58+ LTCGUSD string `json:"ltcg_usd,omitempty"`
5359 // Position is the total quantity held.
5460 Position * mathv1.Decimal `json:"position"`
5561 // Category is the user-defined asset category (e.g., "EQUITY").
@@ -64,7 +70,7 @@ type HoldingOverview struct {
6470
6571// HoldingsOverviewHeaders returns the column headers for table/CSV output.
6672func HoldingsOverviewHeaders () []string {
67- return []string {"SYMBOL" , "CURRENCY" , "LAST PRICE" , "AVG PRICE" , "LAST USD" , "AVG USD" , "MKT VAL USD" , "UNRLZD P&L USD" , "POSITION" , "CATEGORY" , "TYPE" , "SECTOR" , "GEO" }
73+ return []string {"SYMBOL" , "CURRENCY" , "LAST PRICE" , "AVG PRICE" , "LAST USD" , "AVG USD" , "MKT VAL USD" , "UNRLZD P&L USD" , "STCG USD" , "LTCG USD" , " POSITION" , "CATEGORY" , "TYPE" , "SECTOR" , "GEO" }
6874}
6975
7076// HoldingOverviewToRow converts a HoldingOverview to a string slice for CSV output.
@@ -79,6 +85,8 @@ func HoldingOverviewToRow(h *HoldingOverview) []string {
7985 h .AveragePriceUSD ,
8086 h .MarketValueUSD ,
8187 h .UnrealizedPnLUSD ,
88+ h .STCGUSD ,
89+ h .LTCGUSD ,
8290 mathpb .ToString (h .Position ),
8391 h .Category ,
8492 h .Type ,
@@ -99,6 +107,8 @@ func HoldingOverviewToTableRow(h *HoldingOverview) []string {
99107 formatUSD (h .AveragePriceUSD ),
100108 formatUSD (h .MarketValueUSD ),
101109 formatUSD (h .UnrealizedPnLUSD ),
110+ formatUSD (h .STCGUSD ),
111+ formatUSD (h .LTCGUSD ),
102112 mathpb .ToString (h .Position ),
103113 h .Category ,
104114 h .Type ,
@@ -107,24 +117,34 @@ func HoldingOverviewToTableRow(h *HoldingOverview) []string {
107117 }
108118}
109119
110- // ComputeTotals sums the MarketValueUSD and UnrealizedPnLUSD across all holdings.
111- // Returns formatted USD strings for the totals row (rounded to cents with $ prefix).
112- func ComputeTotals (holdings []* HoldingOverview ) (string , string ) {
113- var totalMktValMicros , totalPnLMicros int64
120+ // Totals holds the formatted total values for the summary row.
121+ type Totals struct {
122+ // MarketValueUSD is the total market value across all holdings.
123+ MarketValueUSD string
124+ // UnrealizedPnLUSD is the total unrealized P&L across all holdings.
125+ UnrealizedPnLUSD string
126+ // STCGUSD is the total short-term unrealized P&L across all holdings.
127+ STCGUSD string
128+ // LTCGUSD is the total long-term unrealized P&L across all holdings.
129+ LTCGUSD string
130+ }
131+
132+ // ComputeTotals sums the USD value columns across all holdings.
133+ // Returns formatted USD strings (rounded to cents with $ prefix).
134+ func ComputeTotals (holdings []* HoldingOverview ) * Totals {
135+ var totalMktValMicros , totalPnLMicros , totalSTCGMicros , totalLTCGMicros int64
114136 for _ , h := range holdings {
115- if h .MarketValueUSD != "" {
116- if units , micros , err := mathpb . ParseToUnitsMicros (h .MarketValueUSD ); err == nil {
117- totalMktValMicros += units * 1_000_000 + micros
118- }
119- }
120- if h . UnrealizedPnLUSD != "" {
121- if units , micros , err := mathpb . ParseToUnitsMicros ( h . UnrealizedPnLUSD ); err == nil {
122- totalPnLMicros += units * 1_000_000 + micros
123- }
124- }
137+ totalMktValMicros += parseMicros ( h .MarketValueUSD )
138+ totalPnLMicros += parseMicros (h .UnrealizedPnLUSD )
139+ totalSTCGMicros += parseMicros ( h . STCGUSD )
140+ totalLTCGMicros += parseMicros ( h . LTCGUSD )
141+ }
142+ return & Totals {
143+ MarketValueUSD : formatUSDMicros ( totalMktValMicros ),
144+ UnrealizedPnLUSD : formatUSDMicros ( totalPnLMicros ),
145+ STCGUSD : formatUSDMicros ( totalSTCGMicros ),
146+ LTCGUSD : formatUSDMicros ( totalLTCGMicros ),
125147 }
126- return formatUSD (moneypb .MoneyValueToString (moneypb .MoneyFromMicros ("USD" , totalMktValMicros ))),
127- formatUSD (moneypb .MoneyValueToString (moneypb .MoneyFromMicros ("USD" , totalPnLMicros )))
128148}
129149
130150// GetHoldingsOverview computes the holdings overview from trade data using FIFO,
@@ -273,6 +293,77 @@ func GetHoldingsOverview(
273293 holdings = append (holdings , holding )
274294 }
275295
296+ // Compute per-lot STCG/LTCG split from individual tax lots.
297+ // Each lot's P&L is classified as short-term (<365 days) or long-term (>=365 days).
298+ today := xtime.Date {
299+ Year : time .Now ().Year (),
300+ Month : time .Now ().Month (),
301+ Day : time .Now ().Day (),
302+ }
303+ // Build a map of last price USD micros per symbol for lot-level P&L computation.
304+ lastPriceUSDMap := make (map [string ]int64 , len (holdings ))
305+ isBondMap := make (map [string ]bool , len (holdings ))
306+ for _ , h := range holdings {
307+ if h .LastPriceUSD != "" {
308+ lastPriceUSDMap [h .Symbol ] = parseMicros (h .LastPriceUSD )
309+ }
310+ priceData := marketPrices [h .Symbol ]
311+ isBondMap [h .Symbol ] = priceData .money != nil && priceData .money .GetAssetCategory () == "BOND"
312+ }
313+ // Accumulate STCG and LTCG per symbol from individual tax lots.
314+ type gainSplit struct {
315+ stcgMicros int64
316+ ltcgMicros int64
317+ }
318+ gainsBySymbol := make (map [string ]* gainSplit )
319+ for _ , lot := range taxLotResult .TaxLots {
320+ symbol := lot .GetSymbol ()
321+ lastPriceUSDMicros , ok := lastPriceUSDMap [symbol ]
322+ if ! ok || lastPriceUSDMicros == 0 {
323+ continue
324+ }
325+ // Convert lot cost basis to USD.
326+ costUSDMoney , ok := fxStore .ConvertToUSD (lot .GetCostBasisPrice ())
327+ if ! ok {
328+ continue
329+ }
330+ costUSDMicros := moneypb .MoneyToMicros (costUSDMoney )
331+ // Compute per-lot P&L: (last price USD - cost basis USD) * quantity.
332+ pnlPerUnitMicros := lastPriceUSDMicros - costUSDMicros
333+ lotQtyMicros := mathpb .ToMicros (lot .GetQuantity ())
334+ lotQtyUnits := lotQtyMicros / 1_000_000
335+ lotQtyRemainder := lotQtyMicros % 1_000_000
336+ lotPnLMicros := pnlPerUnitMicros * lotQtyUnits + pnlPerUnitMicros * lotQtyRemainder / 1_000_000
337+ // Bond prices are percentages of par — divide P&L by 100.
338+ if isBondMap [symbol ] {
339+ lotPnLMicros /= 100
340+ }
341+ // Classify by holding period.
342+ gs := gainsBySymbol [symbol ]
343+ if gs == nil {
344+ gs = & gainSplit {}
345+ gainsBySymbol [symbol ] = gs
346+ }
347+ longTerm , err := ibctltaxlot .IsLongTerm (lot , today )
348+ if err != nil {
349+ continue
350+ }
351+ if longTerm {
352+ gs .ltcgMicros += lotPnLMicros
353+ } else {
354+ gs .stcgMicros += lotPnLMicros
355+ }
356+ }
357+ // Apply STCG/LTCG values to holdings.
358+ for _ , h := range holdings {
359+ gs := gainsBySymbol [h .Symbol ]
360+ if gs == nil {
361+ continue
362+ }
363+ h .STCGUSD = moneypb .MoneyValueToString (moneypb .MoneyFromMicros ("USD" , gs .stcgMicros ))
364+ h .LTCGUSD = moneypb .MoneyValueToString (moneypb .MoneyFromMicros ("USD" , gs .ltcgMicros ))
365+ }
366+
276367 // Sort by symbol for deterministic output.
277368 sort .Slice (holdings , func (i , j int ) bool {
278369 return holdings [i ].Symbol < holdings [j ].Symbol
@@ -304,3 +395,20 @@ func formatUSD(value string) string {
304395 }
305396 return "$" + formatted
306397}
398+
399+ // formatUSDMicros formats a micros value as a USD string with $ prefix.
400+ func formatUSDMicros (micros int64 ) string {
401+ return formatUSD (moneypb .MoneyValueToString (moneypb .MoneyFromMicros ("USD" , micros )))
402+ }
403+
404+ // parseMicros parses a decimal string to total micros. Returns 0 on error or empty input.
405+ func parseMicros (value string ) int64 {
406+ if value == "" {
407+ return 0
408+ }
409+ units , micros , err := mathpb .ParseToUnitsMicros (value )
410+ if err != nil {
411+ return 0
412+ }
413+ return units * 1_000_000 + micros
414+ }
0 commit comments