portfolio

package
v0.2.0 Latest Latest
Warning

This package is not in the latest version of its module.

Go to latest
Published: Oct 20, 2026 License: AGPL-3.0 Imports: 5 Imported by: 0

Documentation

Overview

Package portfolio values the crypto and securities held across every source: what they are worth, what went into them and what they gained, now and day by day, and the French tax on crypto disposals (formulaire 2086, crypto only). Fiat is left out; stablecoins count as crypto. See docs/api.md.

Every function takes the whole history, and the transfers between your own sources as a deposit's id → its withdrawal's id: an app gets both from Fundarr's API with pkg/api (Copy.Ledger), and prices from pkg/price or any function of type Price. See the example.

Example

An app keeps its own copy from the changes feed, then computes the portfolio and the French 2086 form with Fundarr's code.

package main

import (
	"encoding/json"
	"fmt"
	"time"

	"github.com/Alexis-Marcel/fundarr/pkg/api"
	"github.com/Alexis-Marcel/fundarr/pkg/model"
	"github.com/Alexis-Marcel/fundarr/pkg/portfolio"
	"github.com/shopspring/decimal"
)

// Two pages of GET /api/v1/changes: BTC bought on an exchange and moved to
// a wallet, then part of it sent back and sold, and a balance correction
// Fundarr dropped since.
const page1 = `{"changes": [
 {"op": "upsert", "id": "buy", "activity": {"id": "buy", "sourceId": "binance", "time": "2024-01-10T09:00:00Z",
  "type": "trade", "side": "buy", "base": {"asset": {"kind": "crypto", "symbol": "BTC"}, "qty": "0.1"},
  "quote": {"asset": {"kind": "fiat", "symbol": "EUR"}, "qty": "4000"}, "source": {"id": "binance", "name": "Binance", "kind": "binance"}}},
 {"op": "upsert", "id": "out", "activity": {"id": "out", "sourceId": "binance", "time": "2024-02-01T09:00:00Z",
  "type": "withdrawal", "base": {"asset": {"kind": "crypto", "symbol": "BTC"}, "qty": "0.1"}, "transferWith": "in"}},
 {"op": "upsert", "id": "in", "activity": {"id": "in", "sourceId": "ledger", "time": "2024-02-01T09:20:00Z",
  "type": "deposit", "base": {"asset": {"kind": "crypto", "symbol": "BTC"}, "qty": "0.1"}, "transferWith": "out"}},
 {"op": "upsert", "id": "fix", "activity": {"id": "fix", "sourceId": "ledger", "time": "2024-06-01T00:00:00Z",
  "type": "adjustment", "side": "in", "base": {"asset": {"kind": "crypto", "symbol": "BTC"}, "qty": "0.01"}, "correction": true}}
], "cursor": "4e1a9c02.4", "more": false}`

const page2 = `{"changes": [
 {"op": "remove", "id": "fix"},
 {"op": "upsert", "id": "back", "activity": {"id": "back", "sourceId": "ledger", "time": "2025-03-01T09:00:00Z",
  "type": "withdrawal", "base": {"asset": {"kind": "crypto", "symbol": "BTC"}, "qty": "0.05"}, "transferWith": "back-in"}},
 {"op": "upsert", "id": "back-in", "activity": {"id": "back-in", "sourceId": "binance", "time": "2025-03-01T09:30:00Z",
  "type": "deposit", "base": {"asset": {"kind": "crypto", "symbol": "BTC"}, "qty": "0.05"}, "transferWith": "back"}},
 {"op": "upsert", "id": "sale", "activity": {"id": "sale", "sourceId": "binance", "time": "2025-03-02T10:00:00Z",
  "type": "trade", "side": "sell", "base": {"asset": {"kind": "crypto", "symbol": "BTC"}, "qty": "0.05"},
  "quote": {"asset": {"kind": "fiat", "symbol": "EUR"}, "qty": "3000"}}}
], "cursor": "4e1a9c02.8", "more": false}`

// prices: BTC at 40000 € in 2024, 60000 € after; euros are euros. Use
// price.New(…).UnitPrice(ctx, "EUR") for real daily closes.
func prices(a model.Asset, at time.Time) (decimal.Decimal, bool) {
	switch {
	case a.Kind == model.Fiat && a.Symbol == "EUR":
		return decimal.NewFromInt(1), true
	case a.Symbol == "BTC" && at.Year() <= 2024:
		return decimal.NewFromInt(40000), true
	case a.Symbol == "BTC":
		return decimal.NewFromInt(60000), true
	}
	return decimal.Zero, false
}

// An app keeps its own copy from the changes feed, then computes the
// portfolio and the French 2086 form with Fundarr's code.
func main() {
	copy := api.Copy{}
	for _, raw := range []string{page1, page2} {
		var page api.Changes
		if err := json.Unmarshal([]byte(raw), &page); err != nil {
			panic(err)
		}
		copy.Apply(page) // and store page.Cursor for next time
	}
	acts, transfers := copy.Ledger()

	now := portfolio.Now(acts, transfers, prices, time.Date(2025, 12, 31, 0, 0, 0, 0, time.UTC))
	fmt.Println("value", now.Value.StringFixed(2), "cost", now.Cost.StringFixed(2), "realized", now.Realized.StringFixed(2))
	for _, h := range now.Assets {
		fmt.Println(h.Asset, h.Qty, "on", len(h.Sources), "source(s)")
	}

	tax := portfolio.TaxFR(acts, transfers, prices, 2025, false)
	for _, d := range tax.Disposals {
		fmt.Println("2086:", d.Sold.Qty, d.Sold.Asset, "sold", d.Price.StringFixed(2), "gain", d.Gain.StringFixed(2))
	}
	fmt.Println("tax", tax.Tax.StringFixed(2))
}
Output:
value 3000.00 cost 2000.00 realized 1000.00
BTC 0.05 on 1 source(s)
2086: 0.05 BTC sold 3000.00 gain 1000.00
tax 314.00

Index

Examples

Constants

View Source
const MaxPoints = 3700

MaxPoints bounds a history: ten years of days.

Variables

View Source
var Threshold = decimal.NewFromInt(305)

Threshold: disposals totalling this much or less in a year are exempt.

Functions

func Days

func Days(from, to time.Time, step string) []time.Time

Days lists the ends of each step ("day", "week" ending Sunday, "month") from from to to, to included; nil when there would be more than MaxPoints.

func First

func First(acts []model.Activity) time.Time

First is the day of the first activity that touches crypto or a security (zero if none).

func Rate

func Rate(year int) decimal.Decimal

Rate is the flat tax on a year's gains: 12.8 % income tax and the social charges. The LFSS 2026 raised the CSG on capital income (revenus du patrimoine, which these gains are) to 10.6 % from 2025 income: 18.6 % instead of 17.2 %. Before 2019 (article 150 UA): 19 % plus social charges.

Types

type Disposal

type Disposal struct {
	Time                                                    time.Time
	Source                                                  string
	Sold                                                    model.Amount
	Price, Fees, Portfolio, Acquisition, CapitalShare, Gain decimal.Decimal
}

Disposal is one line of the 2086, in euros.

type Holding

type Holding struct {
	Asset                               model.Asset
	Qty, Price, Value, Cost, Unrealized decimal.Decimal
	Share                               decimal.Decimal // of Summary.Value
	Sources                             []Worth         // Qty by source
}

type Point

type Point struct {
	Day             time.Time
	Value, Invested decimal.Decimal
}

Point is the portfolio at the end of a day (UTC).

func History

func History(acts []model.Activity, in map[string]string, price Price, days []time.Time) ([]Point, []model.Asset)

History values what was held at the close of each of days (in order), with the money invested until then.

type Price

type Price func(a model.Asset, at time.Time) (p decimal.Decimal, ok bool)

Price is the unit price of an asset on the day of at, in one currency; ok is false when no price is known.

func Cached

func Cached(p Price) Price

Cached remembers p's answers by asset and day: the same days come back for every point and every disposal.

func WithTrades added in v0.2.0

func WithTrades(acts []model.Activity, price Price, ccy string) Price

WithTrades falls back, for a security price does not know (unlisted, private markets, a provider down), on its last trade in ccy before at in the history: an estimate better than nothing.

type Summary

type Summary struct {
	Value, Invested, Cost, Unrealized, Realized, Rewards decimal.Decimal
	Assets                                               []Holding // largest value first
	Sources                                              []Worth   // largest value first
	Unpriced                                             []model.Asset
}

Summary is the portfolio at one time.

func Now

func Now(acts []model.Activity, in map[string]string, price Price, at time.Time) Summary

Now replays the whole history (in maps a deposit's id to the withdrawal it came from) and values what is held at at.

type Tax

type Tax struct {
	Year      int
	Disposals []Disposal
	// Total of the prices, net gain (or loss) of the year.
	Total, Gain decimal.Decimal
	Exempt      bool
	Tax, Rate   decimal.Decimal
	Notes       []string
}

Tax is a year's 2086, with an estimate of the flat tax.

func TaxFR

func TaxFR(acts []model.Activity, in map[string]string, price Price, year int, income bool) Tax

TaxFR computes year's 2086 from the whole history (in maps a deposit's id to the withdrawal it came from), with price in EUR. income: rewards, airdrops and coins deposited from outside were taxed as income, so their value at reception is part of the acquisition price; otherwise they count for 0.

type Worth

type Worth struct {
	Source string
	Amount decimal.Decimal
}

Worth is a source's part: a quantity or a value.

Jump to

Keyboard shortcuts

? : This menu
/ : Search site
f or F : Jump to
y or Y : Canonical URL