Skip to content

Commit 21b49f0

Browse files
committed
commit
1 parent 23a0796 commit 21b49f0

2 files changed

Lines changed: 130 additions & 20 deletions

File tree

cmd/ibctl/internal/command/holdings/holdingsoverview/holdingsoverview.go

Lines changed: 5 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -119,11 +119,13 @@ func run(ctx context.Context, container appext.Container, flags *flags) error {
119119
rows = append(rows, ibctlholdings.HoldingOverviewToTableRow(h))
120120
}
121121
// Build the totals row aligned to the same columns as the data.
122-
totalMktVal, totalPnL := ibctlholdings.ComputeTotals(result.Holdings)
122+
totals := ibctlholdings.ComputeTotals(result.Holdings)
123123
totalsRow := make([]string, len(headers))
124124
totalsRow[0] = "TOTAL"
125-
totalsRow[6] = totalMktVal
126-
totalsRow[7] = totalPnL
125+
totalsRow[6] = totals.MarketValueUSD
126+
totalsRow[7] = totals.UnrealizedPnLUSD
127+
totalsRow[8] = totals.STCGUSD
128+
totalsRow[9] = totals.LTCGUSD
127129
return cliio.WriteTableWithTotals(writer, headers, rows, totalsRow)
128130
case cliio.FormatCSV:
129131
headers := ibctlholdings.HoldingsOverviewHeaders()

internal/ibctl/ibctlholdings/ibctlholdings.go

Lines changed: 125 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,7 @@ package ibctlholdings
1111

1212
import (
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.
6672
func 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

Comments
 (0)