Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Quantity allocation refactoring and other fixes #64

Merged
merged 3 commits into from
Sep 14, 2023
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -19,21 +19,34 @@ public static function excessiveQuantityAllocated(Quantity $available, Quantity
return new self(sprintf('The allocated quantity %s exceeds the available quantity %s', $allocated, $available));
}

public static function insufficientSameDayQuantity(Quantity $quantity, Quantity $sameDayQuantity): self
public static function insufficientSameDayQuantityToIncrease(Quantity $quantity, Quantity $availableQuantity): self
{
return new self(sprintf(
'Cannot decrease same-day quantity by %s: only %s available',
$quantity,
$sameDayQuantity,
));
return self::insufficientQuantity($quantity, $availableQuantity, 'same-day', 'increase');
}

public static function insufficientSameDayQuantityToDecrease(Quantity $quantity, Quantity $availableQuantity): self
{
return self::insufficientQuantity($quantity, $availableQuantity, 'same-day', 'decrease');
}

public static function insufficientThirtyDayQuantityToIncrease(Quantity $quantity, Quantity $availableQuantity): self
{
return self::insufficientQuantity($quantity, $availableQuantity, '30-day', 'increase');
}

public static function insufficientThirtyDayQuantityToDecrease(Quantity $quantity, Quantity $availableQuantity): self
{
return self::insufficientQuantity($quantity, $availableQuantity, '30-day', 'decrease');
}

public static function insufficientThirtyDayQuantity(Quantity $quantity, Quantity $thirtyDayQuantity): self
private static function insufficientQuantity(Quantity $quantity, Quantity $availableQuantity, string $type, string $action): self
{
return new self(sprintf(
'Cannot decrease 30-day quantity by %s: only %s available',
'Cannot %s %s quantity by %s: only %s available',
$action,
$type,
$quantity,
$thirtyDayQuantity,
$availableQuantity,
));
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -63,8 +63,23 @@ public function section104PoolCostBasis(): FiatAmount
return $this->costBasis->dividedBy($this->quantity)->multipliedBy($this->section104PoolQuantity());
}

/** Increase the same-day quantity and adjust the 30-day quantity accordingly. */
public function increaseSameDayQuantity(Quantity $quantity): self
{
if ($quantity->isGreaterThan($availableQuantity = $this->section104PoolQuantity())) {
throw SharePoolingAssetAcquisitionException::insufficientSameDayQuantityToIncrease($quantity, $availableQuantity);
}

$this->sameDayQuantity = $this->sameDayQuantity->plus($quantity);

return $this;
}

/**
* Increase the same-day quantity and adjust the 30-day quantity accordingly.
*
* @return Quantity the added quantity
*/
public function increaseSameDayQuantityUpToAvailableQuantity(Quantity $quantity): Quantity
Comment on lines 66 to +82
Copy link
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

ℹ️ There are now two separate methods – increaseSameDayQuantity and increaseSameDayQuantityUpToAvailableQuantity.

The former will throw an exception if the specified quantity exceeds the currently unallocated same-day quantity and is used by the QuantityAdjuster service (see further down).

The latter will allocate as much as possible the specified quantity, also reallocating some or all of the 30-day quantity to the same-day quantity as necessary.

{
// Adjust same-day quantity
$quantityToAdd = Quantity::minimum($quantity, $this->availableSameDayQuantity());
Expand All @@ -74,14 +89,14 @@ public function increaseSameDayQuantity(Quantity $quantity): self
$quantityToDeduct = Quantity::minimum($quantityToAdd, $this->thirtyDayQuantity);
$this->thirtyDayQuantity = $this->thirtyDayQuantity->minus($quantityToDeduct);

return $this;
return $quantityToAdd;
Copy link
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

ℹ️ increaseSameDayQuantityUpToAvailableQuantity and increaseThirtyDayQuantityUpToAvailableQuantity (below) now return the quantity that has actually been applied, instead of duplicating that logic in DisposalBuilder

}

/** @throws SharePoolingAssetAcquisitionException */
public function decreaseSameDayQuantity(Quantity $quantity): self
{
if ($quantity->isGreaterThan($this->sameDayQuantity)) {
throw SharePoolingAssetAcquisitionException::insufficientSameDayQuantity($quantity, $this->sameDayQuantity);
throw SharePoolingAssetAcquisitionException::insufficientSameDayQuantityToDecrease($quantity, $this->sameDayQuantity);
}

$this->sameDayQuantity = $this->sameDayQuantity->minus($quantity);
Expand All @@ -90,18 +105,30 @@ public function decreaseSameDayQuantity(Quantity $quantity): self
}

public function increaseThirtyDayQuantity(Quantity $quantity): self
{
if ($quantity->isGreaterThan($availableQuantity = $this->section104PoolQuantity())) {
throw SharePoolingAssetAcquisitionException::insufficientThirtyDayQuantityToIncrease($quantity, $availableQuantity);
}

$this->thirtyDayQuantity = $this->thirtyDayQuantity->plus($quantity);

return $this;
}

/** @return Quantity the added quantity */
public function increaseThirtyDayQuantityUpToAvailableQuantity(Quantity $quantity): Quantity
{
$quantityToAdd = Quantity::minimum($quantity, $this->availableThirtyDayQuantity());
$this->thirtyDayQuantity = $this->thirtyDayQuantity->plus($quantityToAdd);

return $this;
return $quantityToAdd;
}

/** @throws SharePoolingAssetAcquisitionException */
public function decreaseThirtyDayQuantity(Quantity $quantity): self
{
if ($quantity->isGreaterThan($this->thirtyDayQuantity)) {
throw SharePoolingAssetAcquisitionException::insufficientThirtyDayQuantity($quantity, $this->thirtyDayQuantity);
throw SharePoolingAssetAcquisitionException::insufficientThirtyDayQuantityToDecrease($quantity, $this->thirtyDayQuantity);
}

$this->thirtyDayQuantity = $this->thirtyDayQuantity->minus($quantity);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -65,16 +65,6 @@ public function reverse(): SharePoolingAssetDisposals
return new self(array_reverse($this->disposals));
}

public function unprocessed(): SharePoolingAssetDisposals
{
$disposals = array_filter(
$this->disposals,
fn (SharePoolingAssetDisposal $disposal) => ! $disposal->processed,
);

return self::make(...$disposals);
}

public function withAvailableSameDayQuantity(): SharePoolingAssetDisposals
{
$disposals = array_filter(
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -51,8 +51,11 @@ public function hasAvailableSameDayQuantity(): bool
return $this->availableSameDayQuantity()->isGreaterThan('0');
}

/** Return the quantity that is not yet allocated as same-day quantity. */
public function availableSameDayQuantity(): Quantity
{
// Same-day quantity always gets priority, so any quantity that is not yet
// allocated as same-day quantity is technically available as same-day quantity
return $this->quantity->minus($this->sameDayQuantity());
}

Expand All @@ -64,7 +67,7 @@ public function hasAvailableThirtyDayQuantity(): bool
public function availableThirtyDayQuantity(): Quantity
{
// Same-day quantity always gets priority, and it is assumed that the existing
// 30-day quantity has already been matched with priority transactions. That
// 30-day quantity has already been allocated to priority transactions. That
// leaves us with the section 104 pool quantity, which is what we return
return $this->section104PoolQuantity();
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -63,6 +63,11 @@ public function count(): int
return count($this->transactions);
}

public function copy(): self
{
return new self(array_map(fn (SharePoolingAssetTransaction $transaction) => clone $transaction, $this->transactions));
}

public function first(): ?SharePoolingAssetTransaction
{
return $this->transactions[0] ?? null;
Expand Down Expand Up @@ -267,7 +272,7 @@ public function disposalsMadeAfterOrOn(LocalDate $date): SharePoolingAssetDispos
return $this->disposalsMadeAfter($date->minusDays(1));
}

public function disposalsWithThirtyDayQuantityMatchedWith(SharePoolingAssetAcquisition $acquisition): SharePoolingAssetDisposals
public function disposalsWithThirtyDayQuantityAllocatedTo(SharePoolingAssetAcquisition $acquisition): SharePoolingAssetDisposals
{
$transactions = array_filter(
$this->transactions,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@

declare(strict_types=1);

namespace Domain\Aggregates\SharePoolingAsset\Services\DisposalProcessor;
namespace Domain\Aggregates\SharePoolingAsset\Services\DisposalBuilder;

use Brick\DateTime\LocalDate;
use Domain\Aggregates\SharePoolingAsset\Actions\DisposeOfSharePoolingAsset;
Expand All @@ -16,9 +16,9 @@
* This service essentially calculates the cost basis of a disposal by looking at past and future
* transactions, following the various share pooling asset rules (same-day, 30-day, section 104 pool).
*/
final class DisposalProcessor
final class DisposalBuilder
{
public static function process(
public static function make(
DisposeOfSharePoolingAsset $disposal,
SharePoolingAssetTransactions $transactions,
): SharePoolingAssetDisposal {
Expand Down Expand Up @@ -107,11 +107,10 @@ private static function processSameDayAcquisitions(
// Deduct the applied quantity from the same-day acquisitions
$remainder = $availableSameDayQuantity;
foreach ($sameDayAcquisitions as $acquisition) {
$quantityToAllocate = Quantity::minimum($remainder, $acquisition->availableSameDayQuantity());
$quantityToAllocate = $acquisition->increaseSameDayQuantityUpToAvailableQuantity($remainder);
$sameDayQuantityAllocation->allocateQuantity($quantityToAllocate, $acquisition);
$acquisition->increaseSameDayQuantity($remainder);
$remainder = $remainder->minus($quantityToAllocate);
if ($remainder->isZero()) {

if (($remainder = $remainder->minus($quantityToAllocate))->isZero()) {
break;
}
}
Expand Down Expand Up @@ -144,37 +143,13 @@ private static function processAcquisitionsWithinThirtyDays(

foreach ($withinThirtyDaysAcquisitions as $acquisition) {
// Apply the acquisition's cost basis to the disposed of asset up to the remaining quantity
$thirtyDayQuantityToApply = Quantity::minimum($acquisition->availableThirtyDayQuantity(), $remainingQuantity);

// Also deduct same-day disposals with available same-day quantity that haven't been processed yet
$sameDayDisposals = $transactions->disposalsMadeOn($acquisition->date)
->unprocessed()
->withAvailableSameDayQuantity();

foreach ($sameDayDisposals as $disposal) {
$sameDayQuantityToApply = Quantity::minimum($disposal->availableSameDayQuantity(), $thirtyDayQuantityToApply);
$disposal->sameDayQuantityAllocation->allocateQuantity($sameDayQuantityToApply, $acquisition);
$acquisition->increaseSameDayQuantity($sameDayQuantityToApply);
$thirtyDayQuantityToApply = $thirtyDayQuantityToApply->minus($sameDayQuantityToApply);
if ($thirtyDayQuantityToApply->isZero()) {
break;
}
}

if ($thirtyDayQuantityToApply->isZero()) {
continue;
}
Comment on lines -149 to -166
Copy link
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

ℹ️ Disposals are not being added as unprocessed anymore, so this section is now redundant


$quantityToAllocate = $acquisition->increaseThirtyDayQuantityUpToAvailableQuantity($remainingQuantity);
$averageCostBasisPerUnit = $acquisition->averageCostBasisPerUnit();
$costBasis = $costBasis->plus($averageCostBasisPerUnit->multipliedBy($quantityToAllocate));
$thirtyDayQuantityAllocation->allocateQuantity($quantityToAllocate, $acquisition);

$costBasis = $costBasis->plus($averageCostBasisPerUnit->multipliedBy($thirtyDayQuantityToApply));

$thirtyDayQuantityAllocation->allocateQuantity($thirtyDayQuantityToApply, $acquisition);
$acquisition->increaseThirtyDayQuantity($thirtyDayQuantityToApply);
$remainingQuantity = $remainingQuantity->minus($thirtyDayQuantityToApply);

// Continue until there are no more transactions or we've covered all disposed tokens
if ($remainingQuantity->isZero()) {
// Continue until there are no more transactions or we've covered all disposed of tokens
if (($remainingQuantity = $remainingQuantity->minus($quantityToAllocate))->isZero()) {
break;
}
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -12,24 +12,37 @@
use Domain\Aggregates\SharePoolingAsset\Services\QuantityAdjuster\Exceptions\QuantityAdjusterException;
use Domain\Aggregates\SharePoolingAsset\ValueObjects\QuantityAllocation;

/**
* This service restores the quantities from acquisitions that were
* previously matched with a disposal that is now being reverted.
*/
final class QuantityAdjuster
{
/**
* Adjust acquisition quantities based on the disposal's allocated quantities.
*
* @throws SharePoolingAssetAcquisitionException
*/
public static function applyDisposal(
SharePoolingAssetDisposal $disposal,
SharePoolingAssetTransactions $transactions,
): void {
foreach (self::getAcquisitions($disposal->sameDayQuantityAllocation, $transactions) as $acquisition) {
$acquisition->increaseSameDayQuantity($disposal->sameDayQuantityAllocation->quantityAllocatedTo($acquisition));
}

foreach (self::getAcquisitions($disposal->thirtyDayQuantityAllocation, $transactions) as $acquisition) {
$acquisition->increaseThirtyDayQuantity($disposal->thirtyDayQuantityAllocation->quantityAllocatedTo($acquisition));
}
}
Comment on lines +22 to +33
Copy link
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

ℹ️ An acquisition's same-day and 30-day quantities are now only adjusted upon applying a disposal's event, using this method


/**
* Restore acquisition quantities that were previously allocated to a disposal that is now being reverted.
*
* @throws SharePoolingAssetAcquisitionException
*/
public static function revertDisposal(
SharePoolingAssetDisposal $disposal,
SharePoolingAssetTransactions $transactions,
): void {
foreach (self::getAcquisitions($disposal->sameDayQuantityAllocation, $transactions) as $acquisition) {
try {
$acquisition->decreaseSameDayQuantity($disposal->sameDayQuantityAllocation->quantityAllocatedTo($acquisition));
} catch (SharePoolingAssetAcquisitionException) {
// @TODO When re-acquiring within 30 days an asset that was disposed of on the same day it was acquired,
// decreasing the same-day quantity of the concerned acquisitions fails, because at the time the latter
// were recorded within the SharePoolingAssetAcquired events that had no same-day quantity yet
}
$acquisition->decreaseSameDayQuantity($disposal->sameDayQuantityAllocation->quantityAllocatedTo($acquisition));
Comment on lines -26 to +45
Copy link
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

ℹ️ Since an acquisition's quantities are now adjusted upon applying a disposal's event, these quantities are always right and this hack is not necessary anymore

}

foreach (self::getAcquisitions($disposal->thirtyDayQuantityAllocation, $transactions) as $acquisition) {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -38,19 +38,20 @@ private static function addSameDayDisposalsToRevert(
return $disposalsToRevert;
}

// Get same-day disposals with part of their quantity not matched with same-day acquisitions
$sameDayDisposals = $transactions->disposalsMadeOn($date)->withAvailableSameDayQuantity();
// Get all same-day disposals. As the average cost basis of same-day acquisitions is used to
// calculate the cost basis of the disposals, it's simpler to revert them all and start over
$sameDayDisposals = $transactions->disposalsMadeOn($date);

if ($sameDayDisposals->isEmpty()) {
return self::add30DayDisposalsToRevert($disposalsToRevert, $transactions, $date, $remainingQuantity);
}

// As the average cost basis of same-day acquisitions is used to calculate the
// cost basis of the disposals, it's simpler to revert them all and start over
$disposalsToRevert->add(...$sameDayDisposals);

// Deduct what's left (either the whole remaining quantity or the disposals' unmatched
// same-day quantity, whichever is smaller) from the remaining quantity to be matched
// Deduct either the acquisition's remaining quantity or the disposals' unallocated same-day
// quantity (whichever is smaller) from the quantity to be allocated. The trick here is that,
// while we are going to revert and replay all same-day disposals, their same-day quantity
// already allocated to earlier same-day acquisitions must be taken into account, still
$quantityToDeduct = Quantity::minimum($remainingQuantity, $sameDayDisposals->availableSameDayQuantity());
$remainingQuantity = $remainingQuantity->minus($quantityToDeduct);

Expand All @@ -74,8 +75,8 @@ private static function add30DayDisposalsToRevert(
foreach ($pastThirtyDaysDisposals as $disposal) {
$disposalsToRevert->add($disposal);

// Deduct what's left (either the whole remaining quantity or the disposal's available
// 30-day quantity, whichever is smaller) from the remaining quantity to be matched
// Deduct either the acquisition's remaining quantity or the disposal's uncallocated
// 30-day quantity (whichever is smaller) from the quantity to be allocated
$quantityToDeduct = Quantity::minimum($remainingQuantity, $disposal->availableThirtyDayQuantity());
$remainingQuantity = $remainingQuantity->minus($quantityToDeduct);

Expand All @@ -88,36 +89,43 @@ private static function add30DayDisposalsToRevert(
return $disposalsToRevert;
}

/**
* Get processed disposals with 30-day quantity allocated to acquisitions from the same day as the
* current disposal, with same-day quantity that should be allocated to that disposal instead.
*/
public static function disposalsToRevertOnDisposal(
DisposeOfSharePoolingAsset $disposal,
SharePoolingAssetTransactions $transactions,
): SharePoolingAssetDisposals {
$disposalsToRevert = SharePoolingAssetDisposals::make();

// Get processed disposals with 30-day quantity matched with acquisitions on the same
// day as the disposal, with same-day quantity about to be matched with the disposal
// Get the acquisitions from the same day as the disposal and with currently-allocated 30-day quantity
$sameDayAcquisitions = $transactions->acquisitionsMadeOn($disposal->date)->withThirtyDayQuantity();

$remainingQuantity = $disposal->quantity;
foreach ($sameDayAcquisitions as $acquisition) {
// Add disposals up to the disposal's quantity, starting with the most recent ones
$disposalsWithMatchedThirtyDayQuantity = $transactions->processed()
->disposalsWithThirtyDayQuantityMatchedWith($acquisition)
// Add disposals up to the disposal's quantity, starting with the most recent ones. That is
// because older disposals get priority when allocating the 30-day quantity of acquisitions
// made within 30 days of the disposal, so the last disposals in are the first out
$disposalsWithAllocatedThirtyDayQuantity = $transactions
->disposalsWithThirtyDayQuantityAllocatedTo($acquisition)
->reverse();

foreach ($disposalsWithMatchedThirtyDayQuantity as $disposal) {
foreach ($disposalsWithAllocatedThirtyDayQuantity as $disposal) {
$disposalsToRevert->add($disposal);

$quantityToDeduct = Quantity::minimum($disposal->thirtyDayQuantityAllocatedTo($acquisition), $remainingQuantity);
$remainingQuantity = $remainingQuantity->minus($quantityToDeduct);

// Stop as soon as the disposal's quantity has fully been allocated
// Stop as soon as the disposal's quantity has been fully allocated
if ($remainingQuantity->isZero()) {
break 2;
}
}
}

return $disposalsToRevert;
// To maintain the priority of older disposals over the 30-day quantity of acquisitions made
// within 30 days, however, they need to be reverted and replayed in chronological order
return $disposalsToRevert->reverse();
}
}
Loading