@@ -12,6 +12,7 @@ | |||
"symfony/dotenv": "5.0.*", | |||
"symfony/flex": "^1.3.1", | |||
"symfony/framework-bundle": "5.0.*", | |||
"symfony/http-client": "5.0.*", | |||
"symfony/yaml": "5.0.*" | |||
}, | |||
"require-dev": { | |||
@@ -4,7 +4,7 @@ | |||
"Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies", | |||
"This file is @generated automatically" | |||
], | |||
"content-hash": "ed2d4bb6d2a2abe584b5f0867281d0ab", | |||
"content-hash": "4e5589a47c5b0dc0e0cd3d924e0621aa", | |||
"packages": [ | |||
{ | |||
"name": "myclabs/php-enum", | |||
@@ -1413,6 +1413,131 @@ | |||
"homepage": "https://symfony.com", | |||
"time": "2020-03-30T11:42:42+00:00" | |||
}, | |||
{ | |||
"name": "symfony/http-client", | |||
"version": "v5.0.7", | |||
"source": { | |||
"type": "git", | |||
"url": "https://github.com/symfony/http-client.git", | |||
"reference": "14d386ae55b699ea9a0ddb872fa5f3e35219bba8" | |||
}, | |||
"dist": { | |||
"type": "zip", | |||
"url": "https://api.github.com/repos/symfony/http-client/zipball/14d386ae55b699ea9a0ddb872fa5f3e35219bba8", | |||
"reference": "14d386ae55b699ea9a0ddb872fa5f3e35219bba8", | |||
"shasum": "" | |||
}, | |||
"require": { | |||
"php": "^7.2.5", | |||
"psr/log": "^1.0", | |||
"symfony/http-client-contracts": "^1.1.8|^2", | |||
"symfony/polyfill-php73": "^1.11", | |||
"symfony/service-contracts": "^1.0|^2" | |||
}, | |||
"provide": { | |||
"php-http/async-client-implementation": "*", | |||
"php-http/client-implementation": "*", | |||
"psr/http-client-implementation": "1.0", | |||
"symfony/http-client-implementation": "1.1" | |||
}, | |||
"require-dev": { | |||
"guzzlehttp/promises": "^1.3.1", | |||
"nyholm/psr7": "^1.0", | |||
"php-http/httplug": "^1.0|^2.0", | |||
"psr/http-client": "^1.0", | |||
"symfony/dependency-injection": "^4.4|^5.0", | |||
"symfony/http-kernel": "^4.4|^5.0", | |||
"symfony/process": "^4.4|^5.0" | |||
}, | |||
"type": "library", | |||
"extra": { | |||
"branch-alias": { | |||
"dev-master": "5.0-dev" | |||
} | |||
}, | |||
"autoload": { | |||
"psr-4": { | |||
"Symfony\\Component\\HttpClient\\": "" | |||
}, | |||
"exclude-from-classmap": [ | |||
"/Tests/" | |||
] | |||
}, | |||
"notification-url": "https://packagist.org/downloads/", | |||
"license": [ | |||
"MIT" | |||
], | |||
"authors": [ | |||
{ | |||
"name": "Nicolas Grekas", | |||
"email": "p@tchwork.com" | |||
}, | |||
{ | |||
"name": "Symfony Community", | |||
"homepage": "https://symfony.com/contributors" | |||
} | |||
], | |||
"description": "Symfony HttpClient component", | |||
"homepage": "https://symfony.com", | |||
"time": "2020-03-27T16:56:45+00:00" | |||
}, | |||
{ | |||
"name": "symfony/http-client-contracts", | |||
"version": "v2.0.1", | |||
"source": { | |||
"type": "git", | |||
"url": "https://github.com/symfony/http-client-contracts.git", | |||
"reference": "378868b61b85c5cac6822d4f84e26999c9f2e881" | |||
}, | |||
"dist": { | |||
"type": "zip", | |||
"url": "https://api.github.com/repos/symfony/http-client-contracts/zipball/378868b61b85c5cac6822d4f84e26999c9f2e881", | |||
"reference": "378868b61b85c5cac6822d4f84e26999c9f2e881", | |||
"shasum": "" | |||
}, | |||
"require": { | |||
"php": "^7.2.5" | |||
}, | |||
"suggest": { | |||
"symfony/http-client-implementation": "" | |||
}, | |||
"type": "library", | |||
"extra": { | |||
"branch-alias": { | |||
"dev-master": "2.0-dev" | |||
} | |||
}, | |||
"autoload": { | |||
"psr-4": { | |||
"Symfony\\Contracts\\HttpClient\\": "" | |||
} | |||
}, | |||
"notification-url": "https://packagist.org/downloads/", | |||
"license": [ | |||
"MIT" | |||
], | |||
"authors": [ | |||
{ | |||
"name": "Nicolas Grekas", | |||
"email": "p@tchwork.com" | |||
}, | |||
{ | |||
"name": "Symfony Community", | |||
"homepage": "https://symfony.com/contributors" | |||
} | |||
], | |||
"description": "Generic abstractions related to HTTP clients", | |||
"homepage": "https://symfony.com", | |||
"keywords": [ | |||
"abstractions", | |||
"contracts", | |||
"decoupling", | |||
"interfaces", | |||
"interoperability", | |||
"standards" | |||
], | |||
"time": "2019-11-26T23:25:11+00:00" | |||
}, | |||
{ | |||
"name": "symfony/http-foundation", | |||
"version": "v5.0.7", | |||
@@ -23,5 +23,10 @@ services: | |||
resource: '../src/Controller' | |||
tags: ['controller.service_arguments'] | |||
# add more service definitions when explicit configuration is needed | |||
# please note that last definitions always *replace* previous ones | |||
App\Exchanges\Kuna\KunaSigner: | |||
bind: | |||
$publicKey: 'test' | |||
$privateKey: 'test' | |||
Http\Client\HttpClient: | |||
class: Nyholm\HttpClient\Client |
@@ -0,0 +1,31 @@ | |||
<?php | |||
declare(strict_types=1); | |||
namespace App\Arbitration\Inner; | |||
use App\Exchanges\MarketInterface; | |||
class ArbitrationChain | |||
{ | |||
/** | |||
* @var MarketInterface[] | |||
*/ | |||
private array $markets; | |||
public function __construct(MarketInterface ...$markets) | |||
{ | |||
$this->markets = $markets; | |||
} | |||
/** | |||
* @return MarketInterface[] | |||
*/ | |||
public function getMarkets(): array | |||
{ | |||
return $this->markets; | |||
} | |||
} |
@@ -0,0 +1,158 @@ | |||
<?php | |||
declare(strict_types=1); | |||
namespace App\Arbitration\Inner\Stuff; | |||
use App\Arbitration\Inner\ArbitrationChain; | |||
use App\Exchanges\ExchangeInterface; | |||
use App\Exchanges\MarketInterface; | |||
use App\Utils\Money\CurrencyPair; | |||
class ArbitrationMarketChainsFinder | |||
{ | |||
/** | |||
* @param ExchangeInterface $exchange | |||
* @param int $maxChainDepth | |||
* @return ArbitrationChain[] | |||
*/ | |||
public function findChainsInExchange(ExchangeInterface $exchange, int $maxChainDepth = 3): array | |||
{ | |||
/** | |||
* @var MarketInterface[] $markets | |||
*/ | |||
$markets = $exchange->getMarkets(); | |||
[$rows, $map] = $this->buildMap($markets); | |||
$cycles = []; | |||
foreach (array_keys($rows) as $row) { | |||
$rowCycles = []; | |||
$this->findCycle($map, [$row], $rowCycles, $maxChainDepth); | |||
$cycles[] = $this->clearDuplicates($rowCycles); | |||
} | |||
$cycles = array_merge(...$cycles); | |||
$cycles = $this->clearDuplicates($cycles); | |||
$result = []; | |||
foreach ($cycles as $cycle) { | |||
$result[] = $this->cycleToChain( | |||
$cycle, | |||
$rows, | |||
$markets | |||
); | |||
} | |||
return $result; | |||
} | |||
protected function cycleToChain(array $cycle, array $rows, array $markets): ArbitrationChain | |||
{ | |||
$cycleMarkets = []; | |||
for($i = 0; $i < \count($cycle); $i++) { | |||
$base = $rows[$cycle[$i]]; | |||
if ($i + 1 >= \count($cycle)) { | |||
$quote = $rows[$cycle[0]]; | |||
} else { | |||
$quote = $rows[$cycle[$i + 1]]; | |||
} | |||
foreach ($markets as $market) { | |||
/** | |||
* @var CurrencyPair $pair | |||
*/ | |||
$pair = $market->getPair(); | |||
if ( | |||
( | |||
$pair->getBase()->isEqual($base) | |||
&& $pair->getQuote()->isEqual($quote) | |||
) | |||
|| ( | |||
$pair->getBase()->isEqual($quote) | |||
&& $pair->getQuote()->isEqual($base) | |||
) | |||
) { | |||
$cycleMarkets[] = $market; | |||
} | |||
} | |||
} | |||
return new ArbitrationChain(...$cycleMarkets); | |||
} | |||
/** | |||
* @param MarketInterface[] $markets | |||
* @return array | |||
*/ | |||
protected function buildMap(array $markets): array | |||
{ | |||
$map = []; | |||
$rows = []; | |||
foreach ($markets as $market) { | |||
$pair = $market->getPair(); | |||
$base = $pair->getBase(); | |||
if (!in_array((string)$base, $rows)) { | |||
$rows[] = $base; | |||
} | |||
$baseId = array_search((string)$base, $rows); | |||
$quote = $pair->getQuote(); | |||
if (!in_array((string)$quote, $rows)) { | |||
$rows[] = $quote; | |||
} | |||
$quoteId = array_search((string)$quote, $rows); | |||
$map[$quoteId][$baseId] = 1; | |||
$map[$baseId][$quoteId] = 1; | |||
} | |||
return [$rows, $map]; | |||
} | |||
protected function clearDuplicates(array $cycles): array | |||
{ | |||
$orderedCycles = []; | |||
foreach ($cycles as $i => $cycle) { | |||
$orderedCycle = $cycle; | |||
sort($orderedCycle); | |||
$orderedCycle = implode(',', $orderedCycle); | |||
if (in_array($orderedCycle, $orderedCycles)) { | |||
unset($cycles[$i]); | |||
continue; | |||
} | |||
$orderedCycles[] = $orderedCycle; | |||
} | |||
return $cycles; | |||
} | |||
protected function findCycle(array $map, array $path, array &$result, int $maxDepth) | |||
{ | |||
if (\count($path) > $maxDepth) { | |||
return; | |||
} | |||
$key = $path[\count($path) - 1]; | |||
$row = $map[$key]; | |||
unset($row[$key]); | |||
foreach ($row as $next => $v) { | |||
if (array_search($next, $path)) { | |||
continue; | |||
} | |||
$nextPath = $path; | |||
$nextPath[] = $next; | |||
if (\count($path) > 2 && $path[0] === $next) { | |||
$result[] = $path; | |||
continue; | |||
} | |||
$this->findCycle($map, $nextPath, $result, $maxDepth); | |||
} | |||
} | |||
} |
@@ -0,0 +1,50 @@ | |||
<?php | |||
declare(strict_types=1); | |||
namespace App\Command; | |||
use App\Arbitration\Inner\Stuff\ArbitrationMarketChainsFinder; | |||
use App\Exchanges\Kuna\KunaExchange; | |||
use App\Exchanges\MarketInterface; | |||
use Symfony\Component\Console\Command\Command; | |||
use Symfony\Component\Console\Input\InputInterface; | |||
use Symfony\Component\Console\Output\OutputInterface; | |||
class TestCommand extends Command | |||
{ | |||
protected static $defaultName = 'test:test'; | |||
/** | |||
* @var KunaExchange | |||
*/ | |||
private KunaExchange $kunaExchange; | |||
public function __construct( | |||
KunaExchange $kunaExchange | |||
) | |||
{ | |||
parent::__construct(self::$defaultName); | |||
$this->kunaExchange = $kunaExchange; | |||
} | |||
public function execute(InputInterface $input, OutputInterface $output) | |||
{ | |||
$finder = new ArbitrationMarketChainsFinder(); | |||
$chains = $finder->findChainsInExchange($this->kunaExchange, 5); | |||
foreach ($chains as $chain) { | |||
echo implode(' -> ', array_map( | |||
function (MarketInterface $market) { | |||
return $market->getPair()->getBase() . '/' . $market->getPair()->getQuote(); | |||
}, | |||
$chain->getMarkets() | |||
)) . PHP_EOL; | |||
} | |||
return 0; | |||
} | |||
} |
@@ -13,13 +13,8 @@ class KunaApiExecutor | |||
{ | |||
public const API_BASE_URL = 'https://api.kuna.io'; | |||
/** | |||
* @var ClientInterface | |||
*/ | |||
private ClientInterface $client; | |||
/** | |||
* @var KunaSigner | |||
*/ | |||
private KunaSigner $signer; | |||
public function __construct( | |||
@@ -42,6 +37,20 @@ class KunaApiExecutor | |||
) | |||
); | |||
return json_decode($response->getBody(), true); | |||
return json_decode((string)$response->getBody(), true); | |||
} | |||
public function getBook(string $symbol): array | |||
{ | |||
$response = $this | |||
->client | |||
->sendRequest( | |||
new Request( | |||
'GET', | |||
self::API_BASE_URL . '/v3/book/' . $symbol | |||
) | |||
); | |||
return json_decode((string)$response->getBody(), true); | |||
} | |||
} |
@@ -9,6 +9,9 @@ namespace App\Exchanges\Kuna; | |||
use App\Exchanges\MarketInterface; | |||
use App\Utils\Money\Currency; | |||
use App\Utils\Money\CurrencyPair; | |||
use App\Utils\Money\Money; | |||
use App\Utils\Order\Book\OrderBook; | |||
use App\Utils\Order\Order; | |||
class KunaMarket implements MarketInterface | |||
{ | |||
@@ -48,4 +51,29 @@ class KunaMarket implements MarketInterface | |||
{ | |||
return $this->currencyPair; | |||
} | |||
public function getOrderBook(): OrderBook | |||
{ | |||
$asks = []; | |||
$bids = []; | |||
foreach ($this->apiExecutor->getBook($this->id) as $orderData) { | |||
$order = new Order( | |||
new Money((string)$orderData[0], $this->currencyPair->getQuote()), | |||
new Money((string)abs($orderData[1]), $this->currencyPair->getBase()), | |||
); | |||
$ordersCount = $orderData[2]; | |||
while ($ordersCount) { | |||
if ($orderData[1] > 0) { | |||
$bids[] = clone $order; | |||
} else { | |||
$asks[] = clone $order; | |||
} | |||
$ordersCount--; | |||
} | |||
} | |||
return new OrderBook($bids, $asks); | |||
} | |||
} |
@@ -19,4 +19,22 @@ class Currency | |||
$this->code = $code; | |||
$this->precision = $precision; | |||
} | |||
public function isEqual(Currency $currency): bool | |||
{ | |||
return $this->code === $currency->getCode(); | |||
} | |||
/** | |||
* @return string | |||
*/ | |||
public function getCode(): string | |||
{ | |||
return $this->code; | |||
} | |||
public function __toString() | |||
{ | |||
return $this->code; | |||
} | |||
} |
@@ -6,24 +6,18 @@ declare(strict_types=1); | |||
namespace App\Utils\Order\Book; | |||
use App\Utils\Money\Money; | |||
use App\Utils\Order\Order; | |||
class OrderBook | |||
{ | |||
/** | |||
* @var array | |||
*/ | |||
private array $bids; | |||
/** | |||
* @var array | |||
*/ | |||
private array $asks; | |||
/** | |||
* OrderBook constructor. | |||
* @param Money[] $bids | |||
* @param Money[] $asks | |||
* @param Order[] $bids | |||
* @param Order[] $asks | |||
*/ | |||
public function __construct( | |||
array $bids, | |||
@@ -33,4 +27,20 @@ class OrderBook | |||
$this->bids = $bids; | |||
$this->asks = $asks; | |||
} | |||
/** | |||
* @return Order[] | |||
*/ | |||
public function getAsks() | |||
{ | |||
return $this->asks; | |||
} | |||
/** | |||
* @return Order[] | |||
*/ | |||
public function getBids() | |||
{ | |||
return $this->bids; | |||
} | |||
} |
@@ -0,0 +1,39 @@ | |||
<?php | |||
declare(strict_types=1); | |||
namespace App\Utils\Order; | |||
use App\Utils\Money\Money; | |||
class Order | |||
{ | |||
private Money $from; | |||
private Money $to; | |||
public function __construct( | |||
Money $from, | |||
Money $to | |||
) | |||
{ | |||
$this->from = $from; | |||
$this->to = $to; | |||
} | |||
/** | |||
* @return Money | |||
*/ | |||
public function getFrom(): Money | |||
{ | |||
return $this->from; | |||
} | |||
/** | |||
* @return Money | |||
*/ | |||
public function getTo(): Money | |||
{ | |||
return $this->to; | |||
} | |||
} |
@@ -116,6 +116,12 @@ | |||
"src/Kernel.php" | |||
] | |||
}, | |||
"symfony/http-client": { | |||
"version": "v5.0.7" | |||
}, | |||
"symfony/http-client-contracts": { | |||
"version": "v2.0.1" | |||
}, | |||
"symfony/http-foundation": { | |||
"version": "v5.0.7" | |||
}, | |||