@@ -46,6 +46,10 @@ type HoldingOverview struct {
4646 LastPriceUSD string `json:"last_price_usd,omitempty"`
4747 // AveragePriceUSD is the average cost basis price converted to USD.
4848 AveragePriceUSD string `json:"average_price_usd,omitempty"`
49+ // MarketValueUSD is position * last price USD.
50+ MarketValueUSD string `json:"market_value_usd,omitempty"`
51+ // UnrealizedPnLUSD is (last price USD - avg price USD) * position.
52+ UnrealizedPnLUSD string `json:"unrealized_pnl_usd,omitempty"`
4953 // Position is the total quantity held.
5054 Position * mathv1.Decimal `json:"position"`
5155 // Category is the user-defined asset category (e.g., "EQUITY").
@@ -58,7 +62,7 @@ type HoldingOverview struct {
5862
5963// HoldingsOverviewHeaders returns the column headers for table/CSV output.
6064func HoldingsOverviewHeaders () []string {
61- return []string {"SYMBOL" , "CURRENCY" , "LAST PRICE" , "AVG PRICE" , "LAST USD" , "AVG USD" , "POSITION" , "CATEGORY" , "TYPE" , "SECTOR" }
65+ return []string {"SYMBOL" , "CURRENCY" , "LAST PRICE" , "AVG PRICE" , "LAST USD" , "AVG USD" , "MKT VAL USD" , "UNRLZD P&L USD" , " POSITION" , "CATEGORY" , "TYPE" , "SECTOR" }
6266}
6367
6468// HoldingOverviewToRow converts a HoldingOverview to a string slice for table/CSV output.
@@ -70,13 +74,35 @@ func HoldingOverviewToRow(h *HoldingOverview) []string {
7074 h .AveragePrice ,
7175 h .LastPriceUSD ,
7276 h .AveragePriceUSD ,
77+ h .MarketValueUSD ,
78+ h .UnrealizedPnLUSD ,
7379 mathpb .ToString (h .Position ),
7480 h .Category ,
7581 h .Type ,
7682 h .Sector ,
7783 }
7884}
7985
86+ // ComputeTotals sums the MarketValueUSD and UnrealizedPnLUSD across all holdings.
87+ // Returns formatted strings for the totals row.
88+ func ComputeTotals (holdings []* HoldingOverview ) (string , string ) {
89+ var totalMktValMicros , totalPnLMicros int64
90+ for _ , h := range holdings {
91+ if h .MarketValueUSD != "" {
92+ if units , micros , err := mathpb .ParseToUnitsMicros (h .MarketValueUSD ); err == nil {
93+ totalMktValMicros += units * 1_000_000 + micros
94+ }
95+ }
96+ if h .UnrealizedPnLUSD != "" {
97+ if units , micros , err := mathpb .ParseToUnitsMicros (h .UnrealizedPnLUSD ); err == nil {
98+ totalPnLMicros += units * 1_000_000 + micros
99+ }
100+ }
101+ }
102+ return moneypb .MoneyValueToString (moneypb .MoneyFromMicros ("USD" , totalMktValMicros )),
103+ moneypb .MoneyValueToString (moneypb .MoneyFromMicros ("USD" , totalPnLMicros ))
104+ }
105+
80106// GetHoldingsOverview computes the holdings overview from trade data using FIFO,
81107// then verifies against IBKR-reported positions.
82108// The result is a combined view aggregated across all accounts.
@@ -176,16 +202,42 @@ func GetHoldingsOverview(
176202 AveragePrice : moneypb .MoneyValueToString (avgCostMoney ),
177203 Position : mathpb .FromMicros (data .quantityMicros ),
178204 }
179- // Convert prices to USD using the most recent FX rate.
205+ // Convert prices to USD using the most recent FX rate, then compute
206+ // market value and unrealized P&L in USD.
180207 if fxStore != nil {
208+ var lastPriceUSDMicros , avgPriceUSDMicros int64
181209 if priceData .money != nil {
182210 if usdMoney , ok := fxStore .ConvertToUSD (priceData .money .GetMarketPrice ()); ok {
211+ lastPriceUSDMicros = moneypb .MoneyToMicros (usdMoney )
183212 holding .LastPriceUSD = moneypb .MoneyValueToString (usdMoney )
184213 }
185214 }
186215 if usdMoney , ok := fxStore .ConvertToUSD (avgCostMoney ); ok {
216+ avgPriceUSDMicros = moneypb .MoneyToMicros (usdMoney )
187217 holding .AveragePriceUSD = moneypb .MoneyValueToString (usdMoney )
188218 }
219+ // Market value USD = last price USD * position.
220+ // Bond prices are percentages of par, so divide by 100 for bonds.
221+ // Divide quantity first to avoid int64 overflow with large bond face values.
222+ isBond := priceData .money != nil && priceData .money .GetAssetCategory () == "BOND"
223+ if lastPriceUSDMicros != 0 {
224+ qtyRemainder := data .quantityMicros % 1_000_000
225+ mktValMicros := lastPriceUSDMicros * qtyUnits + lastPriceUSDMicros * qtyRemainder / 1_000_000
226+ if isBond {
227+ mktValMicros /= 100
228+ }
229+ holding .MarketValueUSD = moneypb .MoneyValueToString (moneypb .MoneyFromMicros ("USD" , mktValMicros ))
230+ }
231+ // Unrealized P&L USD = (last price USD - avg price USD) * position.
232+ if lastPriceUSDMicros != 0 && avgPriceUSDMicros != 0 {
233+ pnlPerShareMicros := lastPriceUSDMicros - avgPriceUSDMicros
234+ qtyRemainder := data .quantityMicros % 1_000_000
235+ pnlMicros := pnlPerShareMicros * qtyUnits + pnlPerShareMicros * qtyRemainder / 1_000_000
236+ if isBond {
237+ pnlMicros /= 100
238+ }
239+ holding .UnrealizedPnLUSD = moneypb .MoneyValueToString (moneypb .MoneyFromMicros ("USD" , pnlMicros ))
240+ }
189241 }
190242 // Merge symbol classification from config.
191243 if symbolConfig , ok := config .SymbolConfigs [symbol ]; ok {
0 commit comments