-
Notifications
You must be signed in to change notification settings - Fork 443
Commit
This commit does not belong to any branch on this repository, and may belong to a fork outside of the repository.
* Add an exchange that will find a minimal set of conversions from one currency to another.
- Loading branch information
1 parent
cf363f5
commit 19d67df
Showing
2 changed files
with
286 additions
and
0 deletions.
There are no files selected for viewing
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,207 @@ | ||
<?php | ||
|
||
namespace Money\Exchange; | ||
|
||
use Money\Calculator; | ||
use Money\Calculator\BcMathCalculator; | ||
use Money\Calculator\GmpCalculator; | ||
use Money\Calculator\PhpCalculator; | ||
use Money\Currencies; | ||
use Money\Currency; | ||
use Money\CurrencyPair; | ||
use Money\Exception\UnresolvableCurrencyPairException; | ||
use Money\Exchange; | ||
|
||
/** | ||
* Provides a way to get an exchange rate through a minimal set of intermediate conversions. | ||
* | ||
* @author Michael Cordingley <[email protected]> | ||
*/ | ||
final class IndirectExchange implements Exchange | ||
{ | ||
/** | ||
* @var Calculator | ||
*/ | ||
private static $calculator; | ||
|
||
/** | ||
* @var array | ||
*/ | ||
private static $calculators = [ | ||
BcMathCalculator::class, | ||
GmpCalculator::class, | ||
PhpCalculator::class, | ||
]; | ||
|
||
/** | ||
* @var Currencies | ||
*/ | ||
private $currencies; | ||
|
||
/** | ||
* @var Exchange | ||
*/ | ||
private $exchange; | ||
|
||
/** | ||
* @param Exchange $exchange | ||
* @param Currencies $currencies | ||
*/ | ||
public function __construct(Exchange $exchange, Currencies $currencies) | ||
{ | ||
$this->exchange = $exchange; | ||
$this->currencies = $currencies; | ||
} | ||
|
||
/** | ||
* @param string $calculator | ||
*/ | ||
public static function registerCalculator($calculator) | ||
{ | ||
if (is_a($calculator, Calculator::class, true) === false) { | ||
throw new \InvalidArgumentException('Calculator must implement '.Calculator::class); | ||
} | ||
|
||
array_unshift(self::$calculators, $calculator); | ||
} | ||
|
||
/** | ||
* {@inheritdoc} | ||
*/ | ||
public function quote(Currency $baseCurrency, Currency $counterCurrency) | ||
{ | ||
try { | ||
return $this->exchange->quote($baseCurrency, $counterCurrency); | ||
} catch (UnresolvableCurrencyPairException $exception) { | ||
$rate = array_reduce($this->getConversions($baseCurrency, $counterCurrency), function ($carry, CurrencyPair $pair) { | ||
return static::getCalculator()->multiply($carry, $pair->getConversionRatio()); | ||
}, '1.0'); | ||
|
||
return new CurrencyPair($baseCurrency, $counterCurrency, $rate); | ||
} | ||
} | ||
|
||
/** | ||
* @param Currency $baseCurrency | ||
* @param Currency $counterCurrency | ||
* | ||
* @return CurrencyPair[] | ||
* | ||
* @throws UnresolvableCurrencyPairException | ||
*/ | ||
private function getConversions(Currency $baseCurrency, Currency $counterCurrency) | ||
{ | ||
$startNode = $this->initializeNode($baseCurrency); | ||
$startNode->discovered = true; | ||
|
||
$nodes = [$baseCurrency->getCode() => $startNode]; | ||
|
||
$frontier = new \SplQueue(); | ||
$frontier->enqueue($startNode); | ||
|
||
while ($frontier->count()) { | ||
/** @var \stdClass $currentNode */ | ||
$currentNode = $frontier->dequeue(); | ||
|
||
/** @var Currency $currentCurrency */ | ||
$currentCurrency = $currentNode->currency; | ||
|
||
if ($currentCurrency->equals($counterCurrency)) { | ||
return $this->reconstructConversionChain($nodes, $currentNode); | ||
} | ||
|
||
/** @var Currency $candidateCurrency */ | ||
foreach ($this->currencies as $candidateCurrency) { | ||
if (!isset($nodes[$candidateCurrency->getCode()])) { | ||
$nodes[$candidateCurrency->getCode()] = $this->initializeNode($candidateCurrency); | ||
} | ||
|
||
/** @var \stdClass $node */ | ||
$node = $nodes[$candidateCurrency->getCode()]; | ||
|
||
if (!$node->discovered) { | ||
try { | ||
// Check if the candidate is a neighbor. This will throw an exception if it isn't. | ||
$this->exchange->quote($currentCurrency, $candidateCurrency); | ||
|
||
$node->discovered = true; | ||
$node->parent = $currentNode; | ||
|
||
$frontier->enqueue($node); | ||
} catch (UnresolvableCurrencyPairException $exception) { | ||
// Not a neighbor. Move on. | ||
} | ||
} | ||
} | ||
} | ||
|
||
throw UnresolvableCurrencyPairException::createFromCurrencies($baseCurrency, $counterCurrency); | ||
} | ||
|
||
/** | ||
* @param Currency $currency | ||
* | ||
* @return \stdClass | ||
*/ | ||
private function initializeNode(Currency $currency) | ||
{ | ||
$node = new \stdClass(); | ||
|
||
$node->currency = $currency; | ||
$node->discovered = false; | ||
$node->parent = null; | ||
|
||
return $node; | ||
} | ||
|
||
/** | ||
* @param array $currencies | ||
* @param \stdClass $goalNode | ||
* | ||
* @return CurrencyPair[] | ||
*/ | ||
private function reconstructConversionChain(array $currencies, \stdClass $goalNode) | ||
{ | ||
$current = $goalNode; | ||
$conversions = []; | ||
|
||
while ($current->parent) { | ||
$previous = $currencies[$current->parent->currency->getCode()]; | ||
$conversions[] = $this->exchange->quote($previous->currency, $current->currency); | ||
$current = $previous; | ||
} | ||
|
||
return array_reverse($conversions); | ||
} | ||
|
||
/** | ||
* @return Calculator | ||
*/ | ||
private function getCalculator() | ||
{ | ||
if (null === self::$calculator) { | ||
self::$calculator = self::initializeCalculator(); | ||
} | ||
|
||
return self::$calculator; | ||
} | ||
|
||
/** | ||
* @return Calculator | ||
* | ||
* @throws \RuntimeException If cannot find calculator for money calculations | ||
*/ | ||
private static function initializeCalculator() | ||
{ | ||
$calculators = self::$calculators; | ||
|
||
foreach ($calculators as $calculator) { | ||
/** @var Calculator $calculator */ | ||
if ($calculator::supported()) { | ||
return new $calculator(); | ||
} | ||
} | ||
|
||
throw new \RuntimeException('Cannot find calculator for money calculations'); | ||
} | ||
} |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,79 @@ | ||
<?php | ||
|
||
namespace Tests\Money\Exchange; | ||
|
||
use Money\Currencies\ISOCurrencies; | ||
use Money\Currency; | ||
use Money\Exception\UnresolvableCurrencyPairException; | ||
use Money\Exchange\FixedExchange; | ||
use Money\Exchange\IndirectExchange; | ||
|
||
final class IndirectExchangeTest extends \PHPUnit_Framework_TestCase | ||
{ | ||
/** | ||
* @test | ||
*/ | ||
public function it_calculates_a_minimal_chain() | ||
{ | ||
$exchange = $this->createExchange(); | ||
|
||
$pair = $exchange->quote(new Currency('USD'), new Currency('AOA')); | ||
|
||
// USD => EUR => AOA | ||
$this->assertEquals('USD', $pair->getBaseCurrency()->getCode()); | ||
$this->assertEquals('AOA', $pair->getCounterCurrency()->getCode()); | ||
$this->assertEquals(12, $pair->getConversionRatio()); | ||
} | ||
|
||
private function createExchange() | ||
{ | ||
$baseExchange = new FixedExchange([ | ||
'USD' => [ | ||
'AFN' => 2, | ||
'EUR' => 4, | ||
], | ||
'AFN' => [ | ||
'DZD' => 12, | ||
'EUR' => 8, | ||
], | ||
'EUR' => [ | ||
'AOA' => 3, | ||
], | ||
'DZD' => [ | ||
'AOA' => 5, | ||
'USD' => 2, | ||
], | ||
'ARS' => [ | ||
'AOA' => 2, | ||
], | ||
]); | ||
|
||
return new IndirectExchange($baseExchange, new ISOCurrencies()); | ||
} | ||
|
||
/** | ||
* @test | ||
*/ | ||
public function it_calculates_adjacent_nodes() | ||
{ | ||
$exchange = $this->createExchange(); | ||
|
||
$pair = $exchange->quote(new Currency('USD'), new Currency('EUR')); | ||
|
||
$this->assertEquals('USD', $pair->getBaseCurrency()->getCode()); | ||
$this->assertEquals('EUR', $pair->getCounterCurrency()->getCode()); | ||
$this->assertEquals(4, $pair->getConversionRatio()); | ||
} | ||
|
||
/** | ||
* @test | ||
*/ | ||
public function it_throws_when_no_chain_is_found() | ||
{ | ||
$exchange = $this->createExchange(); | ||
|
||
$this->setExpectedException(UnresolvableCurrencyPairException::class); | ||
|
||
$exchange->quote(new Currency('USD'), new Currency('ARS')); | ||
} | ||
} |