From 807056cb0ffc7071b71db690974d1e19fbe01393 Mon Sep 17 00:00:00 2001 From: komarov Date: Thu, 23 Apr 2020 02:07:03 +0300 Subject: [PATCH] [WIP] kuna markets fetch & arbitration chains finder --- project/composer.json | 1 + project/composer.lock | 127 +++++++++++++- project/config/services.yaml | 9 +- project/src/Arbitration/ArbitrationChain.php | 31 ++++ .../Stuff/ArbitrationMarketChainsFinder.php | 158 ++++++++++++++++++ project/src/Command/TestCommand.php | 50 ++++++ .../src/Exchanges/Kuna/KunaApiExecutor.php | 23 ++- project/src/Exchanges/Kuna/KunaMarket.php | 28 ++++ project/src/Utils/Money/Currency.php | 18 ++ project/src/Utils/Order/Book/OrderBook.php | 28 +++- project/src/Utils/Order/Order.php | 39 +++++ project/symfony.lock | 6 + 12 files changed, 499 insertions(+), 19 deletions(-) create mode 100644 project/src/Arbitration/ArbitrationChain.php create mode 100644 project/src/Arbitration/Inner/Stuff/ArbitrationMarketChainsFinder.php create mode 100644 project/src/Command/TestCommand.php create mode 100644 project/src/Utils/Order/Order.php diff --git a/project/composer.json b/project/composer.json index 1cc27ae..d778c4f 100644 --- a/project/composer.json +++ b/project/composer.json @@ -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": { diff --git a/project/composer.lock b/project/composer.lock index d19fd05..cb83df0 100644 --- a/project/composer.lock +++ b/project/composer.lock @@ -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", diff --git a/project/config/services.yaml b/project/config/services.yaml index 5c4b417..81e9ad5 100644 --- a/project/config/services.yaml +++ b/project/config/services.yaml @@ -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 \ No newline at end of file diff --git a/project/src/Arbitration/ArbitrationChain.php b/project/src/Arbitration/ArbitrationChain.php new file mode 100644 index 0000000..f50b174 --- /dev/null +++ b/project/src/Arbitration/ArbitrationChain.php @@ -0,0 +1,31 @@ +markets = $markets; + } + + /** + * @return MarketInterface[] + */ + public function getMarkets(): array + { + return $this->markets; + } +} \ No newline at end of file diff --git a/project/src/Arbitration/Inner/Stuff/ArbitrationMarketChainsFinder.php b/project/src/Arbitration/Inner/Stuff/ArbitrationMarketChainsFinder.php new file mode 100644 index 0000000..35e81bf --- /dev/null +++ b/project/src/Arbitration/Inner/Stuff/ArbitrationMarketChainsFinder.php @@ -0,0 +1,158 @@ +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); + } + } +} \ No newline at end of file diff --git a/project/src/Command/TestCommand.php b/project/src/Command/TestCommand.php new file mode 100644 index 0000000..b993d2a --- /dev/null +++ b/project/src/Command/TestCommand.php @@ -0,0 +1,50 @@ +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; + } +} \ No newline at end of file diff --git a/project/src/Exchanges/Kuna/KunaApiExecutor.php b/project/src/Exchanges/Kuna/KunaApiExecutor.php index ee601e0..198bbe2 100644 --- a/project/src/Exchanges/Kuna/KunaApiExecutor.php +++ b/project/src/Exchanges/Kuna/KunaApiExecutor.php @@ -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); } } \ No newline at end of file diff --git a/project/src/Exchanges/Kuna/KunaMarket.php b/project/src/Exchanges/Kuna/KunaMarket.php index 17d3dad..4401223 100644 --- a/project/src/Exchanges/Kuna/KunaMarket.php +++ b/project/src/Exchanges/Kuna/KunaMarket.php @@ -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); + } } \ No newline at end of file diff --git a/project/src/Utils/Money/Currency.php b/project/src/Utils/Money/Currency.php index 3371f6e..4da8551 100644 --- a/project/src/Utils/Money/Currency.php +++ b/project/src/Utils/Money/Currency.php @@ -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; + } } \ No newline at end of file diff --git a/project/src/Utils/Order/Book/OrderBook.php b/project/src/Utils/Order/Book/OrderBook.php index 14132e8..fdbb437 100644 --- a/project/src/Utils/Order/Book/OrderBook.php +++ b/project/src/Utils/Order/Book/OrderBook.php @@ -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; + } } \ No newline at end of file diff --git a/project/src/Utils/Order/Order.php b/project/src/Utils/Order/Order.php new file mode 100644 index 0000000..8441143 --- /dev/null +++ b/project/src/Utils/Order/Order.php @@ -0,0 +1,39 @@ +from = $from; + $this->to = $to; + } + + /** + * @return Money + */ + public function getFrom(): Money + { + return $this->from; + } + + /** + * @return Money + */ + public function getTo(): Money + { + return $this->to; + } +} \ No newline at end of file diff --git a/project/symfony.lock b/project/symfony.lock index 2768b9d..d7ea383 100644 --- a/project/symfony.lock +++ b/project/symfony.lock @@ -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" },