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);
}
}