Skip to content

Commit

Permalink
Add IndirectExchange (#384)
Browse files Browse the repository at this point in the history
* Add an exchange that will find a minimal set of conversions from one currency to another.
  • Loading branch information
mcordingley authored and frederikbosch committed Jul 24, 2017
1 parent cf363f5 commit 19d67df
Show file tree
Hide file tree
Showing 2 changed files with 286 additions and 0 deletions.
207 changes: 207 additions & 0 deletions src/Exchange/IndirectExchange.php
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');
}
}
79 changes: 79 additions & 0 deletions tests/Exchange/IndirectExchangeTest.php
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'));
}
}

0 comments on commit 19d67df

Please sign in to comment.