// Package portfolio contains the business logic: syncing Kraken data into // the store, and computing per-currency and per-lot profit/loss in the // configured base currency. package portfolio import ( "database/sql" "errors" "fmt" "math" "sort" "time" "cryptomon/internal/config" "cryptomon/internal/crypto" "cryptomon/internal/kraken" "cryptomon/internal/store" ) var ErrNoCredentials = errors.New("no kraken credentials configured") type Service struct { store *store.Store cfg *config.Config pub *kraken.Client // shared public client — keeps its pair/asset cache warm } func New(st *store.Store, cfg *config.Config) *Service { return &Service{store: st, cfg: cfg, pub: kraken.New("", "")} } func (s *Service) privateClient() (*kraken.Client, error) { keyEnc, secretEnc, ok, err := s.store.GetCredentials() if err != nil { return nil, err } if !ok { return nil, ErrNoCredentials } key, err := crypto.Decrypt(s.cfg.AppSecret, keyEnc) if err != nil { return nil, err } secret, err := crypto.Decrypt(s.cfg.AppSecret, secretEnc) if err != nil { return nil, err } return s.pub.WithCredentials(key, secret), nil } func (s *Service) HasCredentials() bool { _, _, ok, _ := s.store.GetCredentials() return ok } func (s *Service) BaseCurrency() string { return s.cfg.BaseCurrency() } func (s *Service) SetBaseCurrency(v string) error { return s.cfg.SetBaseCurrency(v) } func (s *Service) SaveCredentials(apiKey, apiSecret string) error { keyEnc, err := crypto.Encrypt(s.cfg.AppSecret, apiKey) if err != nil { return err } secretEnc, err := crypto.Encrypt(s.cfg.AppSecret, apiSecret) if err != nil { return err } return s.store.SaveCredentials("kraken", keyEnc, secretEnc) } // SyncTrades imports buy and sell trades from Kraken's trade history // (dedup'd by trade id). A no-op if no credentials are configured yet. func (s *Service) SyncTrades() error { c, err := s.privateClient() if errors.Is(err, ErrNoCredentials) { return nil } if err != nil { return err } trades, err := c.TradesHistory() if err != nil { return err } for _, t := range trades { var entryType string switch t.Type { case "buy": entryType = store.EntryBuy case "sell": entryType = store.EntrySell default: continue } if err := s.store.UpsertKrakenEntry(store.LedgerEntry{ ExternalID: t.ID, EntryType: entryType, Pair: t.Pair, Quote: t.Quote, Currency: t.Currency, Amount: t.Vol, Price: t.Price, Fee: t.Fee, OccurredAt: t.Time, }); err != nil { return err } } return nil } // SyncTransfers imports deposits and withdrawals from Kraken's ledger — // both crypto (shown on each currency's own position page) and fiat, i.e. // real-money account funding (shown on the global Transfers page) — dedup'd // by ledger entry id. A no-op if no credentials are configured. func (s *Service) SyncTransfers() error { c, err := s.privateClient() if errors.Is(err, ErrNoCredentials) { return nil } if err != nil { return err } entries, err := c.Ledgers() if err != nil { return err } for _, l := range entries { var entryType string switch l.Type { case "deposit": entryType = store.EntryDeposit case "withdrawal": entryType = store.EntryWithdrawal default: continue } amount := l.Amount if amount < 0 { amount = -amount } if err := s.store.UpsertKrakenEntry(store.LedgerEntry{ ExternalID: l.ID, EntryType: entryType, Currency: l.Currency, Amount: amount, Fee: l.Fee, OccurredAt: l.Time, }); err != nil { return err } } return nil } // SyncBalance refreshes the live Kraken balance cache — the authoritative // current holdings figure when connected (matches Kraken's own dashboard // exactly, unlike deriving it purely from imported trade/ledger history, // which can drift for reasons the API doesn't cleanly expose: staking // rewards, margin, dust, etc). func (s *Service) SyncBalance() error { c, err := s.privateClient() if errors.Is(err, ErrNoCredentials) { return nil } if err != nil { return err } balances, err := c.Balance() if err != nil { return err } now := time.Now() for currency, b := range balances { if kraken.IsFiat(currency) { continue // cash balance, not a crypto position } if err := s.store.UpsertBalance(currency, b.Total, b.Staked, now); err != nil { return err } } return nil } // BackfillQuotes resolves the quote currency for any buy/sell entry saved // before the quote column existed (upgrade path for pre-existing databases). func (s *Service) BackfillQuotes() error { entries, err := s.store.EntriesMissingQuote() if err != nil { return err } for _, e := range entries { _, quote := s.pub.PairAssets(e.Pair) if quote == "" { continue } if err := s.store.UpdateEntryQuote(e.ID, quote); err != nil { return err } } return nil } // NormalizeStakedCurrencies retroactively merges any ledger entry or cached // balance saved under a raw staked-asset code (e.g. "ETH2.S") by an older // build — before Balance()/Ledgers() normalized staked variants into their // liquid currency — into that liquid currency (upgrade path for // pre-existing databases; a fresh sync alone can't fix this since dedup // skips already-imported rows, and the balance cache just gets overwritten // under whatever key Balance() currently reports). func (s *Service) NormalizeStakedCurrencies() error { entries, err := s.store.ListAllEntries() if err != nil { return err } balances, err := s.store.GetAllBalances() if err != nil { return err } done := map[string]bool{} rename := func(currency string) error { base, staked := kraken.NormalizeStakedAsset(currency) if !staked || done[currency] { return nil } done[currency] = true return s.store.RenameCurrency(currency, base) } for _, e := range entries { if err := rename(e.Currency); err != nil { return err } } for currency := range balances { if err := rename(currency); err != nil { return err } } return nil } // AddManualEntry logs a buy, sell, deposit, or withdrawal that didn't come // from Kraken's own history. pair is still required for deposit/withdrawal // so the value can be priced — it just doesn't need a price of its own. func (s *Service) AddManualEntry(entryType, currency, pair string, amount, price, fee float64, occurredAt time.Time) error { quote := "" if pair != "" { _, quote = s.pub.PairAssets(pair) } return s.store.AddManualEntry(store.LedgerEntry{ EntryType: entryType, Pair: pair, Quote: quote, Currency: currency, Amount: amount, Price: price, Fee: fee, OccurredAt: occurredAt, }) } func (s *Service) SetHidden(currency string, hidden bool) error { return s.store.SetHidden(currency, hidden) } func (s *Service) SetFavourite(currency string, favourite bool) error { return s.store.SetFavourite(currency, favourite) } // pairsInUse returns every distinct Kraken pair needed to price something on // the dashboard: pairs from buy/sell entries, plus the best-guess pair // FindPairFor found for any Kraken-balance-only currency (staking rewards, // airdrops, etc, with no recorded trade). func (s *Service) pairsInUse() ([]string, error) { aggs, _, err := s.aggregateCurrencies() if err != nil { return nil, err } seen := map[string]bool{} var pairs []string for _, a := range aggs { if a.pair == "" || seen[a.pair] { continue } seen[a.pair] = true pairs = append(pairs, a.pair) } return pairs, nil } // firstBuyByPair finds the earliest buy per pair, used to scope the // "all-time" delta window. func (s *Service) firstBuyByPair() (map[string]time.Time, error) { entries, err := s.store.ListAllEntries() if err != nil { return nil, err } out := map[string]time.Time{} for _, e := range entries { if e.EntryType != store.EntryBuy { continue } if cur, ok := out[e.Pair]; !ok || e.OccurredAt.Before(cur) { out[e.Pair] = e.OccurredAt } } return out, nil } // SyncPrices refreshes the current price for every currency-pair in use, // the FX rate for every quote currency in use, and records a portfolio- // value snapshot for the history chart. Public endpoints — works with no // credentials. func (s *Service) SyncPrices() error { pairs, err := s.pairsInUse() if err != nil { return err } existing, err := s.store.GetAllPriceCache() if err != nil { return err } now := time.Now() quotes := map[string]bool{} for _, pair := range pairs { price, err := s.pub.Ticker(pair) if err != nil { continue // ponytail: skip on transient API error, next poll retries } pc := existing[pair] pc.Pair = pair pc.Price = price pc.UpdatedAt = now if err := s.store.UpsertPriceCache(pc); err != nil { return err } _, quote := s.pub.PairAssets(pair) if quote != "" { quotes[quote] = true } } // Also cover fiat currencies actually deposited/withdrawn (real-money // account funding), even if no crypto pair happens to quote in them. if entries, err := s.store.ListAllEntries(); err == nil { for _, e := range entries { if kraken.IsFiat(e.Currency) && (e.EntryType == store.EntryDeposit || e.EntryType == store.EntryWithdrawal) { quotes[e.Currency] = true } } } base := s.cfg.BaseCurrency() for quote := range quotes { if quote == base { continue } rate, err := s.pub.FXRate(quote, base) if err != nil { continue } if err := s.store.UpsertPriceCache(store.PriceCache{Pair: fxKey(quote), Price: rate, UpdatedAt: now}); err != nil { return err } } if total, err := s.totalValue(); err == nil { _ = s.store.InsertSnapshot(now, total) } return nil } func fxKey(quote string) string { return "FX:" + quote } // cachedOHLC serves candles from the local candle_cache table, only calling // Kraken when the cache is stale (older than one candle interval, min 1 // minute) or missing the requested history entirely. This is what keeps // rapid timeframe-switching (or several currencies' charts loading at once) // from hammering Kraken's rate limit — repeat requests for the same // pair+interval within the staleness window are served from SQLite. func (s *Service) cachedOHLC(pair string, intervalMinutes int, since time.Time) ([]kraken.Candle, error) { latest, haveLatest, err := s.store.LatestCandleTime(pair, intervalMinutes) if err != nil { return nil, err } earliest, haveEarliest, err := s.store.EarliestCandleTime(pair, intervalMinutes) if err != nil { return nil, err } staleness := time.Duration(intervalMinutes) * time.Minute if staleness < time.Minute { staleness = time.Minute } needsBackfill := haveEarliest && since.Before(earliest) needsTopUp := !haveLatest || time.Since(latest) > staleness if needsBackfill || needsTopUp { fetchSince := since if haveLatest && !needsBackfill { fetchSince = latest // only top up what's missing since last fetch } fresh, err := s.pub.OHLC(pair, intervalMinutes, fetchSince) if err != nil { if !haveLatest { return nil, err // nothing cached at all — this failure is all we have } // Kraken unavailable/rate-limited: degrade to whatever's cached. } else if len(fresh) > 0 { storeCandles := make([]store.Candle, len(fresh)) for i, c := range fresh { storeCandles[i] = store.Candle{Time: c.Time, Open: c.Open, High: c.High, Low: c.Low, Close: c.Close} } if err := s.store.UpsertCandles(pair, intervalMinutes, storeCandles); err != nil { return nil, err } } } cached, err := s.store.GetCandles(pair, intervalMinutes, since) if err != nil { return nil, err } out := make([]kraken.Candle, len(cached)) for i, c := range cached { out[i] = kraken.Candle{Time: c.Time, Open: c.Open, High: c.High, Low: c.Low, Close: c.Close} } return out, nil } // SyncHistory refreshes the 4h/1d/7d/30d/all-time % change for every // currency-pair in use, using Kraken's public OHLC candles. "All-time" is // scoped to since-first-buy (capped at 1 year), not coin genesis. This is a // native-currency percentage — treating FX moves as negligible over the // window is an accepted simplification, not a bug. func (s *Service) SyncHistory() error { pairs, err := s.pairsInUse() if err != nil { return err } firstBuy, err := s.firstBuyByPair() if err != nil { return err } existing, err := s.store.GetAllPriceCache() if err != nil { return err } now := time.Now() for _, pair := range pairs { pc := existing[pair] pc.Pair = pair currentPrice := pc.Price if currentPrice == 0 { if p, err := s.pub.Ticker(pair); err == nil { currentPrice = p pc.Price = p } } if currentPrice == 0 { continue } type window struct { interval int since time.Time dest *sql.NullFloat64 } since := now.Add(-365 * 24 * time.Hour) if fb, ok := firstBuy[pair]; ok && fb.After(since) { since = fb } windows := []window{ {5, now.Add(-4 * time.Hour), &pc.Change4h}, {60, now.Add(-24 * time.Hour), &pc.Change1d}, {240, now.Add(-7 * 24 * time.Hour), &pc.Change7d}, {1440, now.Add(-30 * 24 * time.Hour), &pc.Change30d}, {1440, since, &pc.ChangeAll}, } for _, w := range windows { candles, err := s.cachedOHLC(pair, w.interval, w.since) if err != nil { continue } basis, ok := earliestClose(candles) if !ok { continue } *w.dest = sql.NullFloat64{Float64: pctChange(currentPrice, basis), Valid: true} } pc.UpdatedAt = now if err := s.store.UpsertPriceCache(pc); err != nil { return err } } return nil } func earliestClose(candles []kraken.Candle) (float64, bool) { if len(candles) == 0 { return 0, false } return candles[0].Close, true // OHLC() returns candles sorted ascending by time } func pctChange(current, basis float64) float64 { if basis == 0 { return 0 } return (current - basis) / basis * 100 } func safeDiv(a, b float64) float64 { if b == 0 { return 0 } return a / b } func fromNull(n sql.NullFloat64) *float64 { if !n.Valid { return nil } v := n.Float64 return &v } // fxRate returns the rate to convert an amount in `quote` into the base // currency, falling back to 1:1 if unknown (degrades gracefully rather than // failing the whole dashboard over one missing cross-rate). func fxRate(cache map[string]store.PriceCache, quote, base string) float64 { if quote == "" || quote == base { return 1 } if pc, ok := cache[fxKey(quote)]; ok && pc.Price > 0 { return pc.Price } return 1 } // --- per-currency aggregation, shared by the dashboard and position views --- type currencyAgg struct { pair, quote string buyAmount, buyCostBase float64 // from buy entries only — the basis for cost-basis proration netAmount float64 // ledger-derived fallback: buys - sells + deposits - withdrawals liveAmount float64 liveKnown bool // true when Kraken's live balance covers this currency staked float64 } // currentAmount prefers Kraken's live balance (matches their own dashboard // exactly) and falls back to the ledger-derived running balance for // manually-tracked currencies or when Kraken isn't connected. func (a *currencyAgg) currentAmount() float64 { if a.liveKnown { return a.liveAmount } return a.netAmount } // costBasis prorates the total buy cost down to however much of it is still // held (average-cost method) — e.g. sold half your buys, carry half the // cost. Not FIFO/LIFO lot-matching, but far closer to correct than counting // every historical buy as still fully held. func (a *currencyAgg) costBasis() float64 { if a.buyAmount <= 0 { return 0 } ratio := a.currentAmount() / a.buyAmount if ratio > 1 { ratio = 1 } if ratio < 0 { ratio = 0 } return a.buyCostBase * ratio } func (s *Service) aggregateCurrencies() (map[string]*currencyAgg, map[string]store.PriceCache, error) { entries, err := s.store.ListAllEntries() if err != nil { return nil, nil, err } priceCache, err := s.store.GetAllPriceCache() if err != nil { return nil, nil, err } balances, err := s.store.GetAllBalances() if err != nil { return nil, nil, err } base := s.cfg.BaseCurrency() out := map[string]*currencyAgg{} for _, e := range entries { a, ok := out[e.Currency] if !ok { a = ¤cyAgg{} out[e.Currency] = a } rate := fxRate(priceCache, e.Quote, base) switch e.EntryType { case store.EntryBuy: a.pair, a.quote = e.Pair, e.Quote a.buyAmount += e.Amount a.buyCostBase += (e.Amount*e.Price + e.Fee) * rate a.netAmount += e.Amount case store.EntrySell: a.pair, a.quote = e.Pair, e.Quote a.netAmount -= e.Amount case store.EntryDeposit: a.netAmount += e.Amount case store.EntryWithdrawal: a.netAmount -= e.Amount } } // Merge in Kraken's live balance for every currency it reports — including // ones with no recorded buy/sell/deposit/withdrawal at all (staking // rewards, airdrops, dust). Those get a best-guess pair via FindPairFor // so they can still be priced. for currency, b := range balances { a, ok := out[currency] if !ok { pair, quote := s.pub.FindPairFor(currency) a = ¤cyAgg{pair: pair, quote: quote} out[currency] = a } a.liveAmount = b.Amount a.staked = b.Staked a.liveKnown = true } return out, priceCache, nil } // --- dashboard / position views --- type DashboardRow struct { Currency string Pair string Quote string TotalAmount float64 Staked float64 // portion of TotalAmount that's staked/bonded AvgCost float64 TotalCost float64 CurrentPrice float64 CurrentValue float64 FXRate float64 // multiply a native-quote price by this to get base currency PLDollar float64 PLPercent float64 Change4h *float64 Change1d *float64 Change7d *float64 Change30d *float64 ChangeAll *float64 Hidden bool Favourite bool } func (s *Service) Dashboard(includeHidden bool) ([]DashboardRow, error) { aggs, priceCache, err := s.aggregateCurrencies() if err != nil { return nil, err } flags, err := s.store.CurrencyFlags() if err != nil { return nil, err } base := s.cfg.BaseCurrency() var rows []DashboardRow for currency, a := range aggs { if kraken.IsFiat(currency) { continue // cash funding, not a crypto position — see the Transfers page } f := flags[currency] if f.Hidden && !includeHidden { continue } amount := a.currentAmount() rate := fxRate(priceCache, a.quote, base) pc := priceCache[a.pair] currentValue := amount * pc.Price * rate totalCost := a.costBasis() row := DashboardRow{ Currency: currency, Pair: a.pair, Quote: a.quote, TotalAmount: amount, Staked: a.staked, TotalCost: totalCost, AvgCost: safeDiv(totalCost, amount), CurrentPrice: safeDiv(currentValue, amount), CurrentValue: currentValue, FXRate: rate, Hidden: f.Hidden, Favourite: f.Favourite, Change4h: fromNull(pc.Change4h), Change1d: fromNull(pc.Change1d), Change7d: fromNull(pc.Change7d), Change30d: fromNull(pc.Change30d), ChangeAll: fromNull(pc.ChangeAll), } row.PLDollar = row.CurrentValue - row.TotalCost row.PLPercent = safeDiv(row.PLDollar, row.TotalCost) * 100 rows = append(rows, row) } sort.Slice(rows, func(i, j int) bool { if rows[i].Favourite != rows[j].Favourite { return rows[i].Favourite } return rows[i].Currency < rows[j].Currency }) return rows, nil } func (s *Service) totalValue() (float64, error) { rows, err := s.Dashboard(true) if err != nil { return 0, err } var total float64 for _, r := range rows { total += r.CurrentValue } return total, nil } // CurrencySummary is the same aggregate math as a DashboardRow, for one // currency — used as the position page's header totals. type CurrencySummary struct { Amount, Value, Cost, PLDollar, PLPercent float64 Staked float64 Pair, Quote string FXRate float64 } func (s *Service) CurrencySummary(currency string) (CurrencySummary, error) { aggs, priceCache, err := s.aggregateCurrencies() if err != nil { return CurrencySummary{}, err } a, ok := aggs[currency] if !ok { return CurrencySummary{}, nil } base := s.cfg.BaseCurrency() rate := fxRate(priceCache, a.quote, base) price := priceCache[a.pair].Price sum := CurrencySummary{Amount: a.currentAmount(), Staked: a.staked, Pair: a.pair, Quote: a.quote, FXRate: rate} sum.Value = sum.Amount * price * rate sum.Cost = a.costBasis() sum.PLDollar = sum.Value - sum.Cost sum.PLPercent = safeDiv(sum.PLDollar, sum.Cost) * 100 return sum, nil } // LedgerRow is one buy/sell/deposit/withdrawal entry, priced in the base // currency, for a currency's activity table. // // ponytail: per-buy P/L still assumes that lot is fully held (no FIFO/LIFO // matching against later sells) — the aggregate CurrencySummary above is // the accurate number; a buy row's own P/L is "if you'd held all of this // specific purchase," which can overstate once you've partially sold. type LedgerRow struct { ID int64 Source string EntryType string OccurredAt time.Time Amount float64 Price float64 Fee float64 Cost float64 // buy: amount spent; sell: net proceeds received; else 0 CurrentValue float64 PLDollar float64 // buy only PLPercent float64 // buy only } func (s *Service) Position(currency string) ([]LedgerRow, error) { entries, err := s.store.ListEntries(currency) if err != nil { return nil, err } // deposit/withdrawal entries carry no pair of their own — price them // using whatever pair this currency resolved to (buy/sell history, or // FindPairFor for a balance-only currency). aggs, priceCache, err := s.aggregateCurrencies() if err != nil { return nil, err } fallbackPair, fallbackQuote := "", "" if a, ok := aggs[currency]; ok { fallbackPair, fallbackQuote = a.pair, a.quote } base := s.cfg.BaseCurrency() var out []LedgerRow for _, e := range entries { pair, quote := e.Pair, e.Quote if pair == "" { pair, quote = fallbackPair, fallbackQuote } rate := fxRate(priceCache, quote, base) price := priceCache[pair].Price row := LedgerRow{ ID: e.ID, Source: e.Source, EntryType: e.EntryType, OccurredAt: e.OccurredAt, Amount: e.Amount, Price: e.Price * rate, Fee: e.Fee * rate, CurrentValue: e.Amount * price * rate, } switch e.EntryType { case store.EntryBuy: row.Cost = (e.Amount*e.Price + e.Fee) * rate row.PLDollar = row.CurrentValue - row.Cost row.PLPercent = safeDiv(row.PLDollar, row.Cost) * 100 case store.EntrySell: row.Cost = (e.Amount*e.Price - e.Fee) * rate } out = append(out, row) } return out, nil } // TransferRow is one real-money (fiat) deposit or withdrawal — account // funding, not a crypto position — for the global Transfers page. type TransferRow struct { ID int64 Currency string EntryType string // deposit | withdrawal Source string Amount float64 CurrentValue float64 OccurredAt time.Time } func (s *Service) Transfers() ([]TransferRow, error) { entries, err := s.store.ListAllEntries() if err != nil { return nil, err } priceCache, err := s.store.GetAllPriceCache() if err != nil { return nil, err } base := s.cfg.BaseCurrency() var out []TransferRow for _, e := range entries { if e.EntryType != store.EntryDeposit && e.EntryType != store.EntryWithdrawal { continue } if !kraken.IsFiat(e.Currency) { continue // crypto transfers show on that currency's own position page } rate := fxRate(priceCache, e.Currency, base) out = append(out, TransferRow{ ID: e.ID, Currency: e.Currency, EntryType: e.EntryType, Source: e.Source, Amount: e.Amount, CurrentValue: e.Amount * rate, OccurredAt: e.OccurredAt, }) } return out, nil } // --- charts --- type ChartPoint struct { T int64 `json:"t"` V float64 `json:"v"` } var chartRanges = map[string]time.Duration{ "24h": 24 * time.Hour, "7d": 7 * 24 * time.Hour, "30d": 30 * 24 * time.Hour, "1y": 365 * 24 * time.Hour, } func rangeSince(rangeKey string, earliest time.Time) time.Time { if d, ok := chartRanges[rangeKey]; ok { return time.Now().Add(-d) } return earliest // "all" (or unrecognized) — from the beginning } // PortfolioHistory returns the portfolio's total value over time: recorded // snapshots (accurate, but only cover time since this app started polling) // stitched onto a reconstruction of everything before that, built from each // held currency's own Kraken OHLC history plus its purchase/deposit // timeline — so 7D/30D/1Y/All actually differ from day one, instead of // waiting for enough snapshot history to accumulate. func (s *Service) PortfolioHistory(rangeKey string) ([]ChartPoint, error) { earliestEntry, err := s.earliestEntryTime() if err != nil { return nil, err } since := rangeSince(rangeKey, earliestEntry) snaps, err := s.store.SnapshotsSince(since) if err != nil { return nil, err } reconUntil := time.Now() if len(snaps) > 0 { reconUntil = snaps[0].Time } var points []ChartPoint if since.Before(reconUntil) { recon, err := s.reconstructPortfolioHistory(since, reconUntil) if err == nil { points = append(points, recon...) } // A reconstruction failure (e.g. Kraken unreachable) just means we // show less history than we could — fall through to real snapshots. } for _, sn := range snaps { points = append(points, ChartPoint{T: sn.Time.Unix(), V: sn.Value}) } return points, nil } func (s *Service) earliestEntryTime() (time.Time, error) { entries, err := s.store.ListAllEntries() if err != nil { return time.Time{}, err } if len(entries) == 0 { return time.Now(), nil } earliest := entries[0].OccurredAt for _, e := range entries { if e.OccurredAt.Before(earliest) { earliest = e.OccurredAt } } return earliest, nil } // reconstructPortfolioHistory sums, per Kraken OHLC candle timestamp, every // held currency's (amount-at-that-time * candle close * FX rate). Currencies // share the same candle grid when fetched at the same interval (Kraken // aligns candles to fixed time boundaries), so summing by timestamp works // without an as-of join — the one approximation is a thinly-traded pair // missing a candle at some timestamps, which just omits its contribution // there rather than misaligning everything. func (s *Service) reconstructPortfolioHistory(since, until time.Time) ([]ChartPoint, error) { aggs, priceCache, err := s.aggregateCurrencies() if err != nil { return nil, err } base := s.cfg.BaseCurrency() interval := ohlcIntervalFor(since) totals := map[int64]float64{} for currency, a := range aggs { if kraken.IsFiat(currency) || a.pair == "" { continue } entries, err := s.store.ListEntries(currency) if err != nil { continue } candles, err := s.cachedOHLC(a.pair, interval, since) if err != nil || len(candles) == 0 { continue } rate := fxRate(priceCache, a.quote, base) for _, c := range candles { if c.Time.After(until) { continue } var held float64 for _, e := range entries { if e.OccurredAt.After(c.Time) { continue } switch e.EntryType { case store.EntryBuy, store.EntryDeposit: held += e.Amount case store.EntrySell, store.EntryWithdrawal: held -= e.Amount } } totals[c.Time.Unix()] += held * c.Close * rate } } ts := make([]int64, 0, len(totals)) for t := range totals { ts = append(ts, t) } sort.Slice(ts, func(i, j int) bool { return ts[i] < ts[j] }) points := make([]ChartPoint, len(ts)) for i, t := range ts { points[i] = ChartPoint{T: t, V: totals[t]} } return points, nil } // ohlcIntervalFor picks a Kraken candle granularity (minutes) sized to the // requested window, capping the number of candles Kraken returns. func ohlcIntervalFor(since time.Time) int { span := time.Since(since) switch { case span <= 24*time.Hour: return 15 case span <= 7*24*time.Hour: return 60 case span <= 30*24*time.Hour: return 240 default: return 1440 } } // CurrencyHistory returns the value of the currency's held balance over // time, combining Kraken's public OHLC price history with the actual // buy/sell/deposit/withdrawal timeline (so it reflects your holdings // changing, not just the price). Uses the most recent buy/sell's pair/quote // for the whole series — an approximation if you've traded the same coin // against different quote currencies over time. func (s *Service) CurrencyHistory(currency, rangeKey string) ([]ChartPoint, error) { entries, err := s.store.ListEntries(currency) // DESC by time if err != nil { return nil, err } if len(entries) == 0 { return nil, nil } var pair, quote string for _, e := range entries { if e.Pair != "" { pair, quote = e.Pair, e.Quote break } } if pair == "" { return nil, nil // nothing priced to chart (deposits/withdrawals only) } earliest := entries[len(entries)-1].OccurredAt since := rangeSince(rangeKey, earliest) candles, err := s.cachedOHLC(pair, ohlcIntervalFor(since), since) if err != nil { return nil, fmt.Errorf("fetch price history: %w", err) } priceCache, err := s.store.GetAllPriceCache() if err != nil { return nil, err } rate := fxRate(priceCache, quote, s.cfg.BaseCurrency()) points := make([]ChartPoint, 0, len(candles)) for _, c := range candles { var held float64 for _, e := range entries { if e.OccurredAt.After(c.Time) { continue } switch e.EntryType { case store.EntryBuy, store.EntryDeposit: held += e.Amount case store.EntrySell, store.EntryWithdrawal: held -= e.Amount } } points = append(points, ChartPoint{T: c.Time.Unix(), V: held * c.Close * rate}) } return points, nil } // --- candlestick + technical indicators (the "advanced" chart view) --- type Candle struct { T int64 `json:"t"` O float64 `json:"o"` H float64 `json:"h"` L float64 `json:"l"` C float64 `json:"c"` } type CandleSeries struct { Candles []Candle `json:"candles"` BBUpper []*float64 `json:"bb_upper"` BBMiddle []*float64 `json:"bb_middle"` BBLower []*float64 `json:"bb_lower"` RSI []*float64 `json:"rsi"` } // CandleSeries returns raw OHLC candles plus Bollinger Bands (20, 2) and // RSI (14) computed over them, priced in the pair's native quote currency // (candles are Kraken's actual traded price — converting them to base // currency would distort the bands/RSI shape for no real benefit). // bbPeriod/rsiPeriod are the indicator lookback windows — CandleSeries // fetches extra warm-up candles before the visible range so Bollinger Bands // and RSI are fully populated from the very first displayed candle, instead // of only appearing once enough of the visible range has scrolled by. const ( bbPeriod = 20 rsiPeriod = 14 ) func (s *Service) CandleSeries(currency, rangeKey string) (CandleSeries, error) { pair, err := s.pairFor(currency) if err != nil || pair == "" { return CandleSeries{}, err } since := rangeSince(rangeKey, time.Now().Add(-90*24*time.Hour)) interval := ohlcIntervalFor(since) warmup := bbPeriod if rsiPeriod > warmup { warmup = rsiPeriod } fetchSince := since.Add(-time.Duration(warmup+5) * time.Duration(interval) * time.Minute) raw, err := s.cachedOHLC(pair, interval, fetchSince) if err != nil { return CandleSeries{}, fmt.Errorf("fetch candles: %w", err) } closes := make([]float64, len(raw)) for i, c := range raw { closes[i] = c.Close } bbUpper, bbMiddle, bbLower := bollingerBands(closes, bbPeriod, 2) rsiAll := rsi(closes, rsiPeriod) start := 0 for i, c := range raw { if !c.Time.Before(since) { start = i break } } n := len(raw) - start cs := CandleSeries{ Candles: make([]Candle, n), BBUpper: bbUpper[start:], BBMiddle: bbMiddle[start:], BBLower: bbLower[start:], RSI: rsiAll[start:], } for i := start; i < len(raw); i++ { cs.Candles[i-start] = Candle{T: raw[i].Time.Unix(), O: raw[i].Open, H: raw[i].High, L: raw[i].Low, C: raw[i].Close} } return cs, nil } // pairFor resolves a currency to its priced Kraken pair, from its own // buy/sell history if it has any, else the same best-guess FindPairFor // fallback used for balance-only currencies. func (s *Service) pairFor(currency string) (string, error) { entries, err := s.store.ListEntries(currency) if err != nil { return "", err } for _, e := range entries { if e.Pair != "" { return e.Pair, nil } } aggs, _, err := s.aggregateCurrencies() if err != nil { return "", err } return aggs[currency].pair, nil } func bollingerBands(closes []float64, period int, mult float64) (upper, middle, lower []*float64) { n := len(closes) upper, middle, lower = make([]*float64, n), make([]*float64, n), make([]*float64, n) for i := 0; i < n; i++ { if i+1 < period { continue } window := closes[i+1-period : i+1] var mean float64 for _, v := range window { mean += v } mean /= float64(period) var variance float64 for _, v := range window { variance += (v - mean) * (v - mean) } sd := math.Sqrt(variance / float64(period)) u, m, l := mean+mult*sd, mean, mean-mult*sd upper[i], middle[i], lower[i] = &u, &m, &l } return } // rsi computes Wilder's Relative Strength Index over the given period. func rsi(closes []float64, period int) []*float64 { n := len(closes) out := make([]*float64, n) if n < period+1 { return out } var avgGain, avgLoss float64 for i := 1; i <= period; i++ { change := closes[i] - closes[i-1] if change > 0 { avgGain += change } else { avgLoss -= change } } avgGain /= float64(period) avgLoss /= float64(period) set := func(idx int, gain, loss float64) { v := 100.0 if loss != 0 { v = 100 - (100 / (1 + gain/loss)) } out[idx] = &v } set(period, avgGain, avgLoss) for i := period + 1; i < n; i++ { change := closes[i] - closes[i-1] gain, loss := 0.0, 0.0 if change > 0 { gain = change } else { loss = -change } avgGain = (avgGain*float64(period-1) + gain) / float64(period) avgLoss = (avgLoss*float64(period-1) + loss) / float64(period) set(i, avgGain, avgLoss) } return out }