From 19d67df1d4734c55abe044026a16edae2932a8d3 Mon Sep 17 00:00:00 2001 From: Michael Cordingley Date: Mon, 24 Jul 2017 10:03:34 -0400 Subject: [PATCH] Add IndirectExchange (#384) * Add an exchange that will find a minimal set of conversions from one currency to another. --- src/Exchange/IndirectExchange.php | 207 ++++++++++++++++++++++++ tests/Exchange/IndirectExchangeTest.php | 79 +++++++++ 2 files changed, 286 insertions(+) create mode 100644 src/Exchange/IndirectExchange.php create mode 100644 tests/Exchange/IndirectExchangeTest.php diff --git a/src/Exchange/IndirectExchange.php b/src/Exchange/IndirectExchange.php new file mode 100644 index 00000000..2975cc97 --- /dev/null +++ b/src/Exchange/IndirectExchange.php @@ -0,0 +1,207 @@ + + */ +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'); + } +} diff --git a/tests/Exchange/IndirectExchangeTest.php b/tests/Exchange/IndirectExchangeTest.php new file mode 100644 index 00000000..8b6da6ed --- /dev/null +++ b/tests/Exchange/IndirectExchangeTest.php @@ -0,0 +1,79 @@ +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')); + } +}