1235 lines
34 KiB
Go
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 = ¤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
|
||
|
|
}
|