Skip to content

Commit 23a0796

Browse files
committed
commit
1 parent 4c802cf commit 23a0796

5 files changed

Lines changed: 149 additions & 10 deletions

File tree

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

Lines changed: 4 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -114,18 +114,17 @@ func run(ctx context.Context, container appext.Container, flags *flags) error {
114114
switch format {
115115
case cliio.FormatTable:
116116
headers := ibctlholdings.HoldingsOverviewHeaders()
117-
rows := make([][]string, 0, len(result.Holdings)+1)
117+
rows := make([][]string, 0, len(result.Holdings))
118118
for _, h := range result.Holdings {
119-
rows = append(rows, ibctlholdings.HoldingOverviewToRow(h))
119+
rows = append(rows, ibctlholdings.HoldingOverviewToTableRow(h))
120120
}
121-
// Append a totals row summing MKT VAL USD and UNRLZD P&L USD.
121+
// Build the totals row aligned to the same columns as the data.
122122
totalMktVal, totalPnL := ibctlholdings.ComputeTotals(result.Holdings)
123123
totalsRow := make([]string, len(headers))
124124
totalsRow[0] = "TOTAL"
125125
totalsRow[6] = totalMktVal
126126
totalsRow[7] = totalPnL
127-
rows = append(rows, totalsRow)
128-
return cliio.WriteTable(writer, headers, rows)
127+
return cliio.WriteTableWithTotals(writer, headers, rows, totalsRow)
129128
case cliio.FormatCSV:
130129
headers := ibctlholdings.HoldingsOverviewHeaders()
131130
records := make([][]string, 0, len(result.Holdings)+1)

internal/ibctl/ibctlconfig/ibctlconfig.go

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -66,6 +66,7 @@ activity_statements_dir: ~/Documents/ibkr-statements
6666
# category: EQUITY
6767
# type: STOCK
6868
# sector: TECH
69+
# geo: US
6970
`
7071

7172
// ExternalConfigV1 is the YAML-serializable configuration file structure for version v1.
@@ -98,6 +99,8 @@ type ExternalSymbolConfigV1 struct {
9899
Type string `yaml:"type"`
99100
// Sector is the sector classification (e.g., "TECH").
100101
Sector string `yaml:"sector"`
102+
// Geo is the geographic classification (e.g., "US", "INTL").
103+
Geo string `yaml:"geo"`
101104
}
102105

103106
// Config is the validated runtime configuration derived from the config file.
@@ -137,6 +140,8 @@ type SymbolConfig struct {
137140
Type string
138141
// Sector is the sector classification (e.g., "TECH").
139142
Sector string
143+
// Geo is the geographic classification (e.g., "US", "INTL").
144+
Geo string
140145
}
141146

142147
// NewConfigV1 validates an ExternalConfigV1 and returns a runtime Config.
@@ -204,6 +209,7 @@ func NewConfigV1(externalConfig ExternalConfigV1) (*Config, error) {
204209
Category: s.Category,
205210
Type: s.Type,
206211
Sector: s.Sector,
212+
Geo: s.Geo,
207213
}
208214
}
209215
return &Config{

internal/ibctl/ibctlholdings/ibctlholdings.go

Lines changed: 51 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -58,14 +58,17 @@ type HoldingOverview struct {
5858
Type string `json:"type,omitempty"`
5959
// Sector is the user-defined sector classification (e.g., "TECH").
6060
Sector string `json:"sector,omitempty"`
61+
// Geo is the user-defined geographic classification (e.g., "US", "INTL").
62+
Geo string `json:"geo,omitempty"`
6163
}
6264

6365
// HoldingsOverviewHeaders returns the column headers for table/CSV output.
6466
func HoldingsOverviewHeaders() []string {
65-
return []string{"SYMBOL", "CURRENCY", "LAST PRICE", "AVG PRICE", "LAST USD", "AVG USD", "MKT VAL USD", "UNRLZD P&L USD", "POSITION", "CATEGORY", "TYPE", "SECTOR"}
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"}
6668
}
6769

68-
// HoldingOverviewToRow converts a HoldingOverview to a string slice for table/CSV output.
70+
// HoldingOverviewToRow converts a HoldingOverview to a string slice for CSV output.
71+
// USD values are kept as raw decimals for machine-readable output.
6972
func HoldingOverviewToRow(h *HoldingOverview) []string {
7073
return []string{
7174
h.Symbol,
@@ -80,11 +83,32 @@ func HoldingOverviewToRow(h *HoldingOverview) []string {
8083
h.Category,
8184
h.Type,
8285
h.Sector,
86+
h.Geo,
87+
}
88+
}
89+
90+
// HoldingOverviewToTableRow converts a HoldingOverview to a string slice for
91+
// table display. USD values are rounded to cents with $ prefix and comma separators.
92+
func HoldingOverviewToTableRow(h *HoldingOverview) []string {
93+
return []string{
94+
h.Symbol,
95+
h.Currency,
96+
h.LastPrice,
97+
h.AveragePrice,
98+
formatUSD(h.LastPriceUSD),
99+
formatUSD(h.AveragePriceUSD),
100+
formatUSD(h.MarketValueUSD),
101+
formatUSD(h.UnrealizedPnLUSD),
102+
mathpb.ToString(h.Position),
103+
h.Category,
104+
h.Type,
105+
h.Sector,
106+
h.Geo,
83107
}
84108
}
85109

86110
// ComputeTotals sums the MarketValueUSD and UnrealizedPnLUSD across all holdings.
87-
// Returns formatted strings for the totals row.
111+
// Returns formatted USD strings for the totals row (rounded to cents with $ prefix).
88112
func ComputeTotals(holdings []*HoldingOverview) (string, string) {
89113
var totalMktValMicros, totalPnLMicros int64
90114
for _, h := range holdings {
@@ -99,8 +123,8 @@ func ComputeTotals(holdings []*HoldingOverview) (string, string) {
99123
}
100124
}
101125
}
102-
return moneypb.MoneyValueToString(moneypb.MoneyFromMicros("USD", totalMktValMicros)),
103-
moneypb.MoneyValueToString(moneypb.MoneyFromMicros("USD", totalPnLMicros))
126+
return formatUSD(moneypb.MoneyValueToString(moneypb.MoneyFromMicros("USD", totalMktValMicros))),
127+
formatUSD(moneypb.MoneyValueToString(moneypb.MoneyFromMicros("USD", totalPnLMicros)))
104128
}
105129

106130
// GetHoldingsOverview computes the holdings overview from trade data using FIFO,
@@ -244,6 +268,7 @@ func GetHoldingsOverview(
244268
holding.Category = symbolConfig.Category
245269
holding.Type = symbolConfig.Type
246270
holding.Sector = symbolConfig.Sector
271+
holding.Geo = symbolConfig.Geo
247272
}
248273
holdings = append(holdings, holding)
249274
}
@@ -258,3 +283,24 @@ func GetHoldingsOverview(
258283
PositionDiscrepancies: discrepancies,
259284
}, nil
260285
}
286+
287+
// *** PRIVATE ***
288+
289+
// formatUSD formats a raw decimal string as a USD value with $ prefix,
290+
// rounded to cents with comma separators (e.g., "$1,234.56", "-$789.01").
291+
// Returns empty string for empty input.
292+
func formatUSD(value string) string {
293+
if value == "" {
294+
return ""
295+
}
296+
decimal, err := mathpb.NewDecimal(value)
297+
if err != nil {
298+
return value
299+
}
300+
formatted := mathpb.Format(decimal, 2)
301+
// Prepend $ after any negative sign.
302+
if len(formatted) > 0 && formatted[0] == '-' {
303+
return "-$" + formatted[1:]
304+
}
305+
return "$" + formatted
306+
}

internal/pkg/cliio/cliio.go

Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -56,6 +56,32 @@ func WriteTable(writer io.Writer, headers []string, rows [][]string) error {
5656
return tw.Flush()
5757
}
5858

59+
// WriteTableWithTotals writes a table followed by a blank line and a totals row,
60+
// all through the same tabwriter so columns align between data and totals.
61+
func WriteTableWithTotals(writer io.Writer, headers []string, rows [][]string, totalsRow []string) error {
62+
tw := tabwriter.NewWriter(writer, 0, 0, 2, ' ', 0)
63+
// Write header row.
64+
if _, err := fmt.Fprintln(tw, strings.Join(headers, "\t")); err != nil {
65+
return err
66+
}
67+
// Write data rows.
68+
for _, row := range rows {
69+
if _, err := fmt.Fprintln(tw, strings.Join(row, "\t")); err != nil {
70+
return err
71+
}
72+
}
73+
// Write a blank separator line with tabs to preserve column alignment.
74+
blankRow := make([]string, len(headers))
75+
if _, err := fmt.Fprintln(tw, strings.Join(blankRow, "\t")); err != nil {
76+
return err
77+
}
78+
// Write the totals row aligned to the same columns.
79+
if _, err := fmt.Fprintln(tw, strings.Join(totalsRow, "\t")); err != nil {
80+
return err
81+
}
82+
return tw.Flush()
83+
}
84+
5985
// WriteCSVRecords writes CSV records to the writer.
6086
func WriteCSVRecords(writer io.Writer, records [][]string) error {
6187
csvWriter := csv.NewWriter(writer)

internal/pkg/mathpb/mathpb.go

Lines changed: 62 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -67,6 +67,68 @@ func ToString(d *mathv1.Decimal) string {
6767
return fmt.Sprintf("%s%d.%s", sign, units, decimalStr)
6868
}
6969

70+
// Format formats a decimal value rounded to the given precision (number of
71+
// decimal places) with comma separators for the integer portion.
72+
// Examples with precision=2: 1234567.891 → "1,234,567.89", -42.5 → "-42.50".
73+
// Examples with precision=0: 1234.5 → "1,235".
74+
func Format(d *mathv1.Decimal, precision int) string {
75+
if d == nil {
76+
if precision > 0 {
77+
return "0." + strings.Repeat("0", precision)
78+
}
79+
return "0"
80+
}
81+
totalMicros := ToMicros(d)
82+
negative := totalMicros < 0
83+
if negative {
84+
totalMicros = -totalMicros
85+
}
86+
// Compute the divisor for the requested precision.
87+
// Micros has 6 decimal places; we need to reduce to the requested precision.
88+
// divisor = 10^(6-precision), rounding half = divisor/2.
89+
divisor := int64(1)
90+
for range 6 - precision {
91+
divisor *= 10
92+
}
93+
// Round to nearest unit at the requested precision.
94+
totalMicros = (totalMicros + divisor/2) / divisor
95+
// Split into integer and fractional parts at the requested precision.
96+
fracDivisor := int64(1)
97+
for range precision {
98+
fracDivisor *= 10
99+
}
100+
intPart := totalMicros / fracDivisor
101+
fracPart := totalMicros % fracDivisor
102+
sign := ""
103+
if negative {
104+
sign = "-"
105+
}
106+
if precision == 0 {
107+
return sign + addCommas(intPart)
108+
}
109+
return fmt.Sprintf("%s%s.%0*d", sign, addCommas(intPart), precision, fracPart)
110+
}
111+
112+
// addCommas inserts comma separators into a non-negative integer (e.g., 1234567 → "1,234,567").
113+
func addCommas(n int64) string {
114+
s := strconv.FormatInt(n, 10)
115+
if len(s) <= 3 {
116+
return s
117+
}
118+
var b strings.Builder
119+
// Determine how many digits are before the first comma.
120+
firstGroup := len(s) % 3
121+
if firstGroup == 0 {
122+
firstGroup = 3
123+
}
124+
b.WriteString(s[:firstGroup])
125+
for i := firstGroup; i < len(s); i += 3 {
126+
b.WriteByte(',')
127+
b.WriteString(s[i : i+3])
128+
}
129+
return b.String()
130+
}
131+
70132
// ParseToUnitsMicros parses a decimal string (e.g., "123.456789") into units and micros.
71133
func ParseToUnitsMicros(value string) (int64, int64, error) {
72134
if value == "" {

0 commit comments

Comments
 (0)