Files
cryptomon/internal/portfolio/portfolio.go
T
2026-08-25 06:37:14 +01:00

1235 lines
34 KiB
Go

// 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 = &currencyAgg{}
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 = &currencyAgg{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
}