| @@ -12,6 +12,7 @@ | |||||
| "symfony/dotenv": "5.0.*", | "symfony/dotenv": "5.0.*", | ||||
| "symfony/flex": "^1.3.1", | "symfony/flex": "^1.3.1", | ||||
| "symfony/framework-bundle": "5.0.*", | "symfony/framework-bundle": "5.0.*", | ||||
| "symfony/http-client": "5.0.*", | |||||
| "symfony/yaml": "5.0.*" | "symfony/yaml": "5.0.*" | ||||
| }, | }, | ||||
| "require-dev": { | "require-dev": { | ||||
| @@ -4,7 +4,7 @@ | |||||
| "Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies", | "Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies", | ||||
| "This file is @generated automatically" | "This file is @generated automatically" | ||||
| ], | ], | ||||
| "content-hash": "ed2d4bb6d2a2abe584b5f0867281d0ab", | |||||
| "content-hash": "4e5589a47c5b0dc0e0cd3d924e0621aa", | |||||
| "packages": [ | "packages": [ | ||||
| { | { | ||||
| "name": "myclabs/php-enum", | "name": "myclabs/php-enum", | ||||
| @@ -1413,6 +1413,131 @@ | |||||
| "homepage": "https://symfony.com", | "homepage": "https://symfony.com", | ||||
| "time": "2020-03-30T11:42:42+00:00" | "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", | "name": "symfony/http-foundation", | ||||
| "version": "v5.0.7", | "version": "v5.0.7", | ||||
| @@ -23,5 +23,10 @@ services: | |||||
| resource: '../src/Controller' | resource: '../src/Controller' | ||||
| tags: ['controller.service_arguments'] | 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'; | public const API_BASE_URL = 'https://api.kuna.io'; | ||||
| /** | |||||
| * @var ClientInterface | |||||
| */ | |||||
| private ClientInterface $client; | private ClientInterface $client; | ||||
| /** | |||||
| * @var KunaSigner | |||||
| */ | |||||
| private KunaSigner $signer; | private KunaSigner $signer; | ||||
| public function __construct( | 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\Exchanges\MarketInterface; | ||||
| use App\Utils\Money\Currency; | use App\Utils\Money\Currency; | ||||
| use App\Utils\Money\CurrencyPair; | 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 | class KunaMarket implements MarketInterface | ||||
| { | { | ||||
| @@ -48,4 +51,29 @@ class KunaMarket implements MarketInterface | |||||
| { | { | ||||
| return $this->currencyPair; | 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->code = $code; | ||||
| $this->precision = $precision; | $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; | namespace App\Utils\Order\Book; | ||||
| use App\Utils\Money\Money; | |||||
| use App\Utils\Order\Order; | |||||
| class OrderBook | class OrderBook | ||||
| { | { | ||||
| /** | |||||
| * @var array | |||||
| */ | |||||
| private array $bids; | private array $bids; | ||||
| /** | |||||
| * @var array | |||||
| */ | |||||
| private array $asks; | private array $asks; | ||||
| /** | /** | ||||
| * OrderBook constructor. | * OrderBook constructor. | ||||
| * @param Money[] $bids | |||||
| * @param Money[] $asks | |||||
| * @param Order[] $bids | |||||
| * @param Order[] $asks | |||||
| */ | */ | ||||
| public function __construct( | public function __construct( | ||||
| array $bids, | array $bids, | ||||
| @@ -33,4 +27,20 @@ class OrderBook | |||||
| $this->bids = $bids; | $this->bids = $bids; | ||||
| $this->asks = $asks; | $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" | "src/Kernel.php" | ||||
| ] | ] | ||||
| }, | }, | ||||
| "symfony/http-client": { | |||||
| "version": "v5.0.7" | |||||
| }, | |||||
| "symfony/http-client-contracts": { | |||||
| "version": "v2.0.1" | |||||
| }, | |||||
| "symfony/http-foundation": { | "symfony/http-foundation": { | ||||
| "version": "v5.0.7" | "version": "v5.0.7" | ||||
| }, | }, | ||||