Lotter (ledger-cli helper)

Artifact Content
Login

Artifact 479951789dd32710f8189553b0c9599a78c7f7f1bd79902890a85d963341d669:


package main

import (
	"fmt"
	"log"
	"math/big"
	"time"

	"src.d10.dev/command"
)

type Lot struct {
	name string
	date time.Time

	inventory Amount

	startInventory Amount
	startCost      Amount

	price *big.Rat
}

func NewLot(name string, date time.Time, inventory, basis Amount) *Lot {
	if inventory.Sign() < 1 {
		log.Panicf("lot must have positive inventory (%s)", inventory.String()) // sanity
	}
	if basis.Sign() < 0 {
		log.Panicf("lot must have non-negative basis (%s)", basis.String()) // sanity
	}

	price := new(big.Rat).Quo(basis.Rat, inventory.Rat) // price = (total cost) / (how many)

	this := &Lot{
		name:           name,
		date:           date,
		inventory:      inventory,
		startInventory: inventory,
		startCost:      basis,
		price:          price,
	}

	// sanity
	if this.price.Sign() < 0 {
		log.Panicf("Calculated new lot (%q) price %s = %s / %s", name, this.price, this.startCost, this.startInventory)
	}
	return this
}

func (this *Lot) Sell(delta Amount) (actual, basis Amount) {
	// sanity
	if delta.Sign() > -1 {
		log.Panicf("lot.Sell() expects negative amount, got %s", delta)
	}
	if !delta.Compatible(this.inventory) {
		log.Panic("lot.Sell() account/asset mismatch")
	}

	tmp := new(big.Rat)
	tmp.Add(this.inventory.Rat, delta.Rat) // adding negative delta
	// tmp is now (inventory - amount to sell)
	switch tmp.Sign() {
	case -1:
		// inventory does not cover delta, actual is limited to inventory amount
		actual = this.inventory
		this.inventory = this.inventory.ZeroClone() // nothing remains in inventory
	case 1:
		// inventory has more than enough, put remainder back
		this.inventory.Set(tmp)
		actual = delta.NegClone()
	case 0:
		// exact amount, actual is full delta, set inventory to zero
		actual = delta.NegClone()
		this.inventory = this.inventory.ZeroClone() // nothing remains
	}

	// calculate basis that corresponds to inventory consumed
	basis = this.startCost.ZeroClone()
	basis.Mul(this.price, actual.Rat)
	basis.Neg(basis.Rat) // convention: amount sold is positive, basis is negative

	// sanity
	if actual.Sign() < 1 {
		log.Panic("lot.Sell() calculated:", actual)
	}
	if basis.Sign() > 0 { // Note that 0 basis is allowed (i.e. BCH from hard fork)
		log.Panic("lot.Sell() basis: ", basis, " from price ", this.price)
	}

	return actual, basis
}

type LotFIFO []Lot

func (this *LotFIFO) Len() int { return len(*this) }

func (this *LotFIFO) Buy(lot Lot) {
	this.sanity(lot.inventory)
	*this = append(*this, lot)
}

// Sell consumes inventory and basis from lots.
func (this *LotFIFO) Sell(delta Amount) (lot []Lot, inventory, basis []Amount, err error) {
	this.sanity(delta)
	command.V(1).Infof("lotFIFO.Sell() %s from queue of %d lots", delta.String(), len(*this)) // troubleshoot

	remaining := delta.Clone()

	var l Lot
	for remaining.Sign() != 0 {

		if len(*this) == 0 {
			// We haven't consumed original delta, but the queue is empty.
			err = fmt.Errorf("failed to sell %s (of %s), no remaining inventory", remaining.String(), delta.String())
			return
		}

		l, *this = (*this)[0], (*this)[1:] // pop front

		sold, soldBasis := l.Sell(remaining)

		// sanity
		if sold.Sign() == -1 || soldBasis.Sign() == 1 { // basis may be zero
			log.Panicf("insane sale: sold %s, basis %s", sold, soldBasis)
		}

		command.V(1).Infof("Sold %s (%s basis) from lot %s", sold, soldBasis, l.name)

		lot = append(lot, l)
		inventory = append(inventory, sold)
		basis = append(basis, soldBasis)
		// note that remaining is negative, sold is positive
		remaining.Add(remaining.Rat, sold.Rat)

		if remaining.Sign() > -1 {
			// entire amount has been consumed from inventory
			if remaining.Sign() != 0 { // sanity
				log.Panic("lotFIFO.Sell() remaining:", remaining) // should never be reached
			}
			if l.inventory.Sign() > 0 {
				// push unsold inventory back to queue
				*this = append(LotFIFO{l}, *this...)
			}
		}
	}

	command.V(1).Infof("lotFIFO.Sell() sold %s, %d lots remain", delta.String(), len(*this)) // troubleshoot

	return lot, inventory, basis, err
}

func (this LotFIFO) sanity(delta Amount) {
	if delta.Sign() == 0 {
		log.Panic("attempt to buy/sell zero amount")
	}
	if len(this) == 0 {
		if delta.Sign() < 0 {
			log.Panicf("attempt to sell (%#v %s) from empty inventory (%#v)", delta, delta.Asset, this)
		} else {
			return
		}
	}
	// sanity
	if delta.Asset != this[0].inventory.Asset {
		log.Panicf("currency mismatch: want %q, got %q", delta.Asset, this[0].inventory.Asset)
	}
}