EvaluationPosition.java

package com.tdmconsult.ete.evaluation;

import static com.tdmconsult.ete.accounting.BalanceRecords.ZERO_AMOUNT;

import com.tdmconsult.ete.masterdata.EvaluationRate;
import java.math.BigDecimal;
import java.math.RoundingMode;
import java.time.LocalDate;
import java.util.ArrayList;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
import java.util.Optional;
import lombok.AccessLevel;
import lombok.Data;
import lombok.RequiredArgsConstructor;
import lombok.Setter;
import lombok.ToString;
import lombok.experimental.Accessors;
import lombok.experimental.Delegate;
import org.springframework.util.Assert;

@Data
@Accessors(chain = true)
@RequiredArgsConstructor
public class EvaluationPosition {

    public static final int INTERNAL_CCY_SCALE = 6;

    @Delegate
    private final EvaluableTransaction source;
    private long consumedQuantity;

    public record Rate(BigDecimal value, LocalDate date, Type type) {

        @SuppressWarnings("checkstyle:LineLength")
        public static Rate of(final EvaluationRate evaluationRate) {
            return switch (evaluationRate.getRateType()) {
                case EOY -> new Rate(evaluationRate.getRate(), LocalDate.of(evaluationRate.getFiscalYear(), 12, 31), Type.EOY);
                case Q1 -> new Rate(evaluationRate.getRate(), LocalDate.of(evaluationRate.getFiscalYear(), 3, 31), Type.QUARTERLY);
                case Q2 -> new Rate(evaluationRate.getRate(), LocalDate.of(evaluationRate.getFiscalYear(), 6, 30), Type.QUARTERLY);
                case Q3 -> new Rate(evaluationRate.getRate(), LocalDate.of(evaluationRate.getFiscalYear(), 9, 30), Type.QUARTERLY);
            };
        }

        public static Rate deal(final EvaluableTransaction deal) {
            final var value = deal.getTotalCost()
                    .divide(new BigDecimal(deal.getQuantity()), INTERNAL_CCY_SCALE, RoundingMode.HALF_UP);
            return new Rate(value, deal.getTradingDate(), Type.OF_DEAL);
        }

        public Rate makeEoyDeprecated() {
            return new Rate(value, date, Type.EOY_DEPRECATED);
        }

        public BigDecimal multiply(final long quantity) {
            return value().multiply(new BigDecimal(quantity));
        }

        public enum Type {
            OF_DEAL, EOY, EOY_DEPRECATED, QUARTERLY
        }
    }

    public record Evaluation(
        int fiscalYear,
        Rate rate,
        long quantity) {
    }

    @Setter(AccessLevel.PACKAGE)
    private Map<Integer, Evaluation> evaluationByFiscalYear = new HashMap<>();

    public EvaluationPosition addEvaluation(final Evaluation eval) {
        evaluationByFiscalYear.put(eval.fiscalYear(), eval);
        return this;
    }

    public EvaluationPosition addEvaluation(
            final int fiscalYear, final Rate rate, final long quantity) {
        return addEvaluation(new Evaluation(fiscalYear, rate, quantity));
    }

    public Optional<Evaluation> maybeAt(final int fiscalYear) {
        return Optional.ofNullable(evaluationByFiscalYear.get(fiscalYear));
    }

    public Evaluation at(final int fiscalYear) {
        return maybeAt(fiscalYear).orElseThrow(() ->
                new IllegalStateException("no evaluation record for " + source.getSecurity() + " in FY " + fiscalYear));
    }

    public Rate getEvaluationRate(final int fiscalYear) {
        Assert.isTrue(fiscalYear >= source.getTradingDate().getYear(),
                "FY query %s where deal is of %s"
                        .formatted(fiscalYear, source.getTradingDate()));
        return at(fiscalYear).rate();
    }

    public BigDecimal getEvaluationAmount(final int fiscalYear) {
        if (fiscalYear < source.getTradingDate().getYear()) {
            return ZERO_AMOUNT;
        }
        final var e = at(fiscalYear);
        return e.rate().value().multiply(new BigDecimal(e.quantity()));
    }

    public BigDecimal getEvaluationDelta(final int fiscalYear) {
        if (fiscalYear < source.getTradingDate().getYear()) {
            return ZERO_AMOUNT;
        }
        final var a = at(fiscalYear);
        return (a.rate().value().subtract(getCostPerShare())).multiply(new BigDecimal(a.quantity()));
    }

    public BigDecimal getCostPerShare() {
        return getTotalCost().divide(new BigDecimal(getQuantity()), INTERNAL_CCY_SCALE, RoundingMode.HALF_UP);
    }

    public BigDecimal getPrice() {
        return getPrincipleAmount() != null
                ? getPrincipleAmount().divide(new BigDecimal(getQuantity()), INTERNAL_CCY_SCALE, RoundingMode.HALF_UP)
                : getTotalCost().divide(new BigDecimal(getQuantity()), INTERNAL_CCY_SCALE, RoundingMode.HALF_UP);
    }

    public BigDecimal getRemainingCost() {
        return getCostPerShare().multiply(
                new BigDecimal(getQuantity() - consumedQuantity))
                .setScale(2, RoundingMode.HALF_UP);
    }

    public long getRemainingQuantity() {
        return source.getQuantity() - consumedQuantity;
    }

    public boolean isFullyConsumed() {
        return getRemainingQuantity() == 0;
    }

    public record Change(EvaluationPosition owner, EvaluationPosition other, long change, long quantity) {

        public long quantityBefore() {
            return quantity;
        }

        public long quantityAfter() {
            return quantity - change;
        }

        public BigDecimal ourChangeAmount() {
            if (owner.getQuantity() == change) {
                return owner.getTotalCost();
            } else {
                return owner.getCostPerShare().multiply(new BigDecimal(change)).setScale(2, RoundingMode.HALF_DOWN);
            }
        }

        public BigDecimal otherChangeAmount() {
            if (other.getQuantity() == change) {
                return other.getTotalCost();
            } else {
                return other.getCostPerShare().multiply(new BigDecimal(change)).setScale(2, RoundingMode.HALF_DOWN);
            }
        }
    }

    @ToString.Exclude
    private final List<Change> change = new ArrayList<>();

    public EvaluationPosition consumes(final EvaluationPosition other) {

        // this.type=SELL, other.type=BUY
        final var consumable = Math.min(this.getRemainingQuantity(), other.getRemainingQuantity());
        change.add(new Change(this, other, consumable, this.getRemainingQuantity()));
        consumedQuantity += consumable;
        Assert.isTrue(consumedQuantity <= getQuantity(), "cannot consume more than I have...");
        other.change.add(new Change(other, this, consumable, other.getRemainingQuantity()));
        other.consumedQuantity += consumable;
        Assert.isTrue(other.consumedQuantity <= other.getQuantity(), "cannot consume more than I have...");
        return this;
    }

    public BigDecimal getProfitLoss() {
        if (isLikeBuy()) {
            return ZERO_AMOUNT;
        }
        final var sumOfBuyers = change.stream()
                .filter(c -> c.other().isBuy())
                .map(Change::otherChangeAmount)
                .reduce(BigDecimal::add).orElse(ZERO_AMOUNT);

        return getTotalCost().subtract(sumOfBuyers).setScale(2, RoundingMode.HALF_UP);
    }
}