diff --git a/.github/workflows/run-test.yml b/.github/workflows/run-test.yml index 21ab2aa..ff7f1a9 100644 --- a/.github/workflows/run-test.yml +++ b/.github/workflows/run-test.yml @@ -9,15 +9,15 @@ jobs: fail-fast: false matrix: os: [ubuntu-latest] - php: [8.1, 8.2] + php: [8.2, 8.3, 8.4] prefer: [lowest, stable] steps: - name: Checkout code - uses: actions/checkout@v2 + uses: actions/checkout@v4 - name: Cache dependencies - uses: actions/cache@v2 + uses: actions/cache@v4 with: path: ~/.composer/cache/files key: ${{ runner.os }}-php${{ matrix.php }}-${{ matrix.prefer }}-${{ hashFiles('composer.json') }} @@ -30,7 +30,7 @@ jobs: coverage: xdebug - name: Install dependencies - run: composer update --prefer-${{ matrix.prefer }} --prefer-dist --no-interaction --no-suggest + run: composer update --prefer-${{ matrix.prefer }} --prefer-dist --no-interaction - name: Execute test - run: vendor/bin/phpunit + run: vendor/bin/pest diff --git a/README.md b/README.md index 0bc0f30..a139ec7 100644 --- a/README.md +++ b/README.md @@ -1,420 +1,589 @@ -# Metaapi PHP sdk +# MetaApi PHP SDK -A PHP Package that let you seamlessly perform api call to Metapapi https://metaapi.cloud/ -NOTE: This package does not include all api calls in Metapi. -You can do CopyTrade, Account Managment and Metrics. +A modern PHP SDK for selected MetaApi services: -## Installation - -To install the SDK in your project you need to install the package via composer: +- MetaTrader account management +- Provisioning profiles +- Account replicas +- MetaApi REST terminal API +- CopyFactory +- MetaStats -```bash -composer require victorycodedev/metaapi-cloud-php-sdk -``` +This package does not yet wrap every MetaApi API, but the exposed services are organized around the current SDK entry point and resource classes. -## Usage +Real-time streaming/WebSocket support is planned for a future version. -## Account Management +## Requirements -You can create an instance of the SDK like so for Account Management: -```php -use Victorycodedev\MetaapiCloudPhpSdk\AccountApi; +- PHP 8.2 or newer -$account = new AccountApi('AUTH_TOKEN'); +## Installation +```bash +composer require victorycodedev/metaapi-cloud-php-sdk ``` -All methods throws exceptions when the request is not successful, so be sure to put your code in a try and catch block. - -```php - when statusCode >= 200 && statusCode < 300; -``` +## Quick Start -You can add a trading account and starts a cloud API server for the trading account like so: +Use `MetaApiClient` as the main SDK entry point: ```php +use Victorycodedev\MetaapiCloudPhpSdk\MetaApiClient; +use Victorycodedev\MetaapiCloudPhpSdk\Exceptions\MetaApiException; + +$metaapi = new MetaApiClient('AUTH_TOKEN'); try { - return $account->create([ - "login" => "123456", - "password" => "password", - "name" => "testAccount", - "server" => "ICMarketsSC-Demo", - "platform" => "mt5", - "magic" => 123456 + $accounts = $metaapi->accounts()->accounts([ + 'limit' => 100, + 'offset' => 0, ]); -} catch (\Throwable $th) { - $response = json_decode($th->getMessage()); - return $response->message; -} +} catch (MetaApiException $exception) { + echo $exception->getMessage(); + echo $exception->statusCode(); + print_r($exception->response()); +} ``` -if the request was successful , you will get the the account id and state, else an Exception will be thrown -```php - [ - "id" => "1eda642a-a9a3-457c-99af-3bc5e8d5c4c9", - "state" => "DEPLOYED" - ] -``` +All failed MetaApi responses throw `MetaApiException`. The exception includes the HTTP status code, parsed MetaApi error response, response headers, error id, error name, details and retry-after header when available. -You can read an account by the id +## Regions -```php +Account Management uses MetaApi's global provisioning URL and does not require a region. -try { - return $account->readById("1eda642a-a9a3-457c-99af-3bc5e8d5c4c9"); -} catch (\Throwable $th) { - $response = json_decode($th->getMessage()); - return $response->message; -} +Regional services default to `new-york` and accept region names: +```php +$copyFactory = $metaapi->copyFactory(region: 'london'); +$terminal = $metaapi->terminal(region: 'london'); +$metaStats = $metaapi->metaStats(region: 'london'); ``` - -You can read all trading accounts in your metaapi account +For private/custom regions, pass the service URL: ```php +$copyFactory = $metaapi->copyFactory( + serverUrl: 'https://copyfactory-api-v1.my-region.example.com' +); + +$terminal = $metaapi->terminal( + serverUrl: 'https://mt-client-api-v1.my-region.example.com', + marketDataServerUrl: 'https://mt-market-data-client-api-v1.my-region.example.com' +); +``` -try { - return $account->readAll(); -} catch (\Throwable $th) { - $response = json_decode($th->getMessage()); - return $response->message; -} +## Account Management +```php +$accounts = $metaapi->accounts(); ``` -You can update an account +Create an account: ```php - try { - return $account->update("1eda642a-a9a3-457c-99af-3bc5e8d5c4c9",[ - "password" => "password", - "name" => "testAccount", - "server" => "ICMarketsSC-Demo", + $account = $accounts->create([ + 'login' => '123456', + 'password' => 'password', + 'name' => 'Main MT5 account', + 'server' => 'ICMarketsSC-Demo', + 'platform' => 'mt5', + 'magic' => 123456, + 'type' => 'cloud-g2', ]); -} catch (\Throwable $th) { - $response = json_decode($th->getMessage()); - return $response->message; +} catch (MetaApiException $exception) { + echo $exception->getMessage(); } - ``` -Undeploy an account +Read accounts: ```php - -try { - return $account->unDeploy("1eda642a-a9a3-457c-99af-3bc5e8d5c4c9"); - // you can pass other parameters - return $account->unDeploy("1eda642a-a9a3-457c-99af-3bc5e8d5c4c9", false); -} catch (\Throwable $th) { - $response = json_decode($th->getMessage()); - return $response->message; -} - +$allAccounts = $accounts->accounts([ + 'deploymentStatus' => ['deployed'], + 'type' => ['cloud-g2'], + 'limit' => 100, + 'offset' => 0, +], apiVersion: 2); + +$account = $accounts->readById('account-id'); ``` -Deploy an account +Update and lifecycle operations: ```php - -try { - return $account->deploy("1eda642a-a9a3-457c-99af-3bc5e8d5c4c9"); - // you can pass other parameters - return $account->deploy("1eda642a-a9a3-457c-99af-3bc5e8d5c4c9", false); -} catch (\Throwable $th) { - $response = json_decode($th->getMessage()); - return $response->message; -} - +$accounts->update('account-id', [ + 'name' => 'Updated account name', + 'password' => 'new-password', +]); + +$accounts->deploy('account-id'); +$accounts->undeploy('account-id'); +$accounts->redeploy('account-id'); +$accounts->delete('account-id', executeForAllReplicas: true); ``` -Redeploy an account +Enable account features/APIs: ```php - -try { - return $account->reDeploy("1eda642a-a9a3-457c-99af-3bc5e8d5c4c9"); - // you can pass other parameters - return $account->reDeploy("1eda642a-a9a3-457c-99af-3bc5e8d5c4c9", false); -} catch (\Throwable $th) { - $response = json_decode($th->getMessage()); - return $response->message; -} - +$accounts->enableFeatures('account-id', [ + 'metastatsApiEnabled' => true, + 'riskManagementApiEnabled' => true, + 'reliabilityIncreased' => true, + 'copyFactoryApi' => [ + 'copyFactoryRoles' => ['PROVIDER'], + 'copyFactoryResourceSlots' => 1, + ], +]); ``` -Delete an account +Create a secure configuration link: ```php +$link = $accounts->createConfigurationLink('account-id', ttlInDays: 3); +``` -try { - return $account->delete("1eda642a-a9a3-457c-99af-3bc5e8d5c4c9"); - // you can pass other parameters - return $account->delete("1eda642a-a9a3-457c-99af-3bc5e8d5c4c9", true); -} catch (\Throwable $th) { - $response = json_decode($th->getMessage()); - return $response->message; -} +## Provisioning Profiles +```php +$profiles = $metaapi->provisioningProfiles(); ``` -## CopyFactory -You can create an instance of the SDK like so for Copyfactory: ```php -use Victorycodedev\MetaapiCloudPhpSdk\CopyFactory; +$created = $profiles->createProvisioningProfile([ + 'name' => 'ICMarkets MT5', + 'version' => 5, + 'brokerTimezone' => 'EET', + 'brokerDSTSwitchTimezone' => 'EET', + 'type' => 'mtTerminal', +]); + +$profiles->uploadProvisioningProfileFile( + $created['id'], + 'servers.dat', + '/path/to/servers.dat' +); + +$profile = $profiles->provisioningProfile($created['id']); +``` -$copyfactory = new CopyFactory('AUTH_TOKEN'); +Available methods: -``` +- `provisioningProfiles(array $filters = [], ?int $apiVersion = null)` +- `provisioningProfile(string $profileId)` +- `createProvisioningProfile(array $data)` +- `uploadProvisioningProfileFile(string $profileId, string $fileName, string $filePath)` +- `updateProvisioningProfile(string $profileId, array $data)` +- `deleteProvisioningProfile(string $profileId)` -To generate a strategy id +## Account Replicas ```php +$replicas = $metaapi->accountReplicas(); +``` -try { - return $copyfactory->generateStrategyId(); -} catch (\Throwable $th) { - $response = json_decode($th->getMessage()); - return $response->message; -} - +```php +$replica = $replicas->createReplica( + 'primary-account-id', + [ + 'magic' => 123456, + 'region' => 'london', + ], + transactionId: bin2hex(random_bytes(16)) +); ``` -To get all your strategies +Available methods: -```php +- `replicas(string $accountId)` +- `replica(string $accountId, string $replicaId)` +- `createReplica(string $accountId, array $data, ?string $transactionId = null)` +- `updateReplica(string $accountId, string $replicaId, array $data)` +- `undeployReplica(string $accountId, string $replicaId)` +- `deployReplica(string $accountId, string $replicaId)` +- `redeployReplica(string $accountId, string $replicaId)` +- `deleteReplica(string $accountId, string $replicaId)` +- `generateReplicaCodeSample(string $accountId, string $replicaId, string $platform)` +- `increaseReplicaReliability(string $accountId, string $replicaId)` -try { - return $copyfactory->strategies(); - //you can also pass in other parameters like so - return $copyfactory->strategies(includeRemoved: true, limit: 1000, offset: 0 ); -} catch (\Throwable $th) { - $response = json_decode($th->getMessage()); - return $response->message; -} +## CopyFactory +```php +$copyFactory = $metaapi->copyFactory(region: 'london'); ``` -To get a single strategy +CopyFactory is grouped by resource: ```php +$copyFactory->configuration(); +$copyFactory->webhooks(); +$copyFactory->history(); +$copyFactory->trading(); +$copyFactory->logs(); +$copyFactory->copyTrade(); +``` -try { - return $copyfactory->strategy("strategid"); -} catch (\Throwable $th) { - $response = json_decode($th->getMessage()); - return $response->message; -} +Configuration: +```php +$strategyId = $copyFactory->configuration()->generateStrategyId(); + +$strategies = $copyFactory->configuration()->strategies( + includeRemoved: false, + limit: 1000, + offset: 0 +); + +$copyFactory->configuration()->updateStrategy('strategy-id', [ + 'name' => 'Main strategy', + 'description' => 'Copies my main account', + 'accountId' => 'provider-account-id', +]); + +$copyFactory->configuration()->portfolioStrategies(); + +$copyFactory->configuration()->updateSubscriber('subscriber-account-id', [ + 'name' => 'Main subscriber', + 'subscriptions' => [ + [ + 'strategyId' => 'strategy-id', + 'multiplier' => 1, + ], + ], +]); ``` -To update a strategy +Webhooks: ```php +$copyFactory->webhooks()->create('strategy-id', [ + 'url' => 'https://example.com/copyfactory/webhook', +]); -try { - return $copyfactory->updateStrategy("strategid", [ - "name" => "Test strategy", - "description" => "Some useful description about your strategy", - "accountId" => "105646d8-8c97-4d4d-9b74-413bd66cd4ed" - ]); -} catch (\Throwable $th) { - $response = json_decode($th->getMessage()); - return $response->message; -} - +$copyFactory->webhooks()->list('strategy-id'); +$copyFactory->webhooks()->update('strategy-id', 'webhook-id', ['enabled' => true]); +$copyFactory->webhooks()->remove('strategy-id', 'webhook-id'); ``` -To remove a strategy +History: ```php - -try { - return $copyfactory->removeStrategy("strategid"); -} catch (\Throwable $th) { - $response = json_decode($th->getMessage()); - return $response->message; -} - +$copyFactory->history()->providedTransactions([ + 'from' => '2020-04-20T04:00:00.000Z', + 'till' => '2020-04-20T04:30:00.000Z', +]); + +$copyFactory->history()->subscriptionTransactions([ + 'from' => '2020-04-20T04:00:00.000Z', + 'till' => '2020-04-20T04:30:00.000Z', +]); + +$copyFactory->history()->strategyTransactionsStream('strategy-id'); +$copyFactory->history()->subscriberTransactionsStream('subscriber-id'); ``` -To get all your subscribers +Trading: ```php +$copyFactory->trading()->signals('subscriber-id'); +$copyFactory->trading()->externalSignals('strategy-id'); + +$copyFactory->trading()->updateExternalSignal('strategy-id', 'signal-id', [ + 'symbol' => 'EURUSD', +]); + +$copyFactory->trading()->removeExternalSignal('strategy-id', 'signal-id', [ + 'time' => '2020-08-24T00:00:00.000Z', +]); + +$copyFactory->trading()->stopouts('subscriber-id'); +$copyFactory->trading()->resetSubscriptionStopouts( + 'subscriber-id', + 'strategy-id', + 'day-balance-difference' +); +$copyFactory->trading()->resetSubscriberStopouts( + 'subscriber-id', + 'day-balance-difference' +); +$copyFactory->trading()->stopoutsStream(); +$copyFactory->trading()->resynchronize('subscriber-id'); +``` -try { - return $copyfactory->subscribers(); - //you can also pass in other parameters like so - return $copyfactory->subscribers(includeRemoved: true, limit: 1000, offset: 0 ); -} catch (\Throwable $th) { - $response = json_decode($th->getMessage()); - return $response->message; -} +Logs: +```php +$copyFactory->logs()->userLog('subscriber-id'); +$copyFactory->logs()->userLogStream('subscriber-id'); +$copyFactory->logs()->strategyLog('strategy-id'); +$copyFactory->logs()->strategyLogStream('strategy-id'); ``` -To get a subscriber +## Configure Copy Trading -```php +This configures CopyFactory to copy trades from a provider account into a subscriber account. -try { - return $copyfactory->subscriber("subscriberiId"); -} catch (\Throwable $th) { - $response = json_decode($th->getMessage()); - return $response->message; -} +For production apps, create and store a strategy id yourself, then pass it into the helper. This avoids extra lookup requests. +```php +$result = $copyFactory->copyTrade()->configureCopyTrading( + providerAccountId: 'provider-account-id', + subscriberAccountId: 'subscriber-account-id', + strategyId: 'strategy-id', + strategy: [ + 'name' => 'Main strategy', + 'description' => 'Copies my main trading account', + ], + subscription: [ + 'multiplier' => 1, + ], + subscriber: [ + 'name' => 'Main subscriber', + ], + validateAccountRoles: false +); ``` -To update a subscriber data +If you omit `strategyId`, the SDK generates one. It does not list all strategies unless you explicitly enable reuse: ```php +$result = $copyFactory->copyTrade()->configureCopyTrading( + providerAccountId: 'provider-account-id', + subscriberAccountId: 'subscriber-account-id', + reuseExistingStrategy: true +); +``` -try { - return $copyfactory->updateSubscriber("subsciberId", [ - 'name' => "Copy Trade Subscriber", - 'subscriptions' => [ - [ - 'strategyId' => 'dJZq', - 'multiplier' => 1, - ] - ] - ]); -} catch (\Throwable $th) { - $response = json_decode($th->getMessage()); - return $response->message; -} +If you already fetched account data, pass it in to skip account reads: +```php +$result = $copyFactory->copyTrade()->configureCopyTrading( + providerAccountId: 'provider-public-id', + subscriberAccountId: 'subscriber-public-id', + providerAccount: [ + '_id' => 'provider-internal-id', + 'copyFactoryRoles' => ['PROVIDER'], + ], + subscriberAccount: [ + '_id' => 'subscriber-internal-id', + 'copyFactoryRoles' => ['SUBSCRIBER'], + ], +); ``` -To remove a subscriber +For a quick setup, `copy()` is available as a shortcut: ```php +$result = $copyFactory->copy( + 'provider-account-id', + 'subscriber-account-id', + 'strategy-id' +); +``` -try { - return $copyfactory->removeSubscriber("subsciberId"); -} catch (\Throwable $th) { - $response = json_decode($th->getMessage()); - return $response->message; -} +The shortcut delegates to `configureCopyTrading()`. Use the full method when you need custom strategy/subscriber payloads, validation control or reuse behavior. +## MetaApi REST Terminal API + +```php +$terminal = $metaapi->terminal(region: 'london'); ``` -To delete a subscription +The REST terminal API is grouped by resource: ```php +$terminal->state(); +$terminal->history(); +$terminal->marketData(); +$terminal->margin(); +$terminal->trading(); +$terminal->credits(); +``` -try { - return $copyfactory->deleteSubscription("subsciberId", "strategyId"); -} catch (\Throwable $th) { - $response = json_decode($th->getMessage()); - return $response->message; -} +Read trading terminal state: +```php +$accountInformation = $terminal->state()->accountInformation('account-id'); +$positions = $terminal->state()->positions('account-id'); +$position = $terminal->state()->position('account-id', 'position-id'); +$orders = $terminal->state()->orders('account-id'); +$order = $terminal->state()->order('account-id', 'order-id'); +$serverTime = $terminal->state()->serverTime('account-id'); ``` -## Copy Trade +Retrieve historical data: -To Copy a trade from provider to subscriber. -I recommend you create a strategy before hand and save to your database before you perform a copy trade, but its not compulsory -as the package will create one for you. You can always read all your strategies in your account with the " $copyfactory->strategies()". +```php +$ordersByTicket = $terminal->history()->historyOrdersByTicket('account-id', 'ticket'); +$ordersByPosition = $terminal->history()->historyOrdersByPosition('account-id', 'position-id'); + +$ordersByTime = $terminal->history()->historyOrdersByTimeRange( + 'account-id', + '2020-09-08 22:21:36.000', + '2020-09-09 22:21:36.000', + offset: 0, + limit: 1000 +); + +$dealsByTicket = $terminal->history()->dealsByTicket('account-id', 'ticket'); +$dealsByPosition = $terminal->history()->dealsByPosition('account-id', 'position-id'); +$dealsByTime = $terminal->history()->dealsByTimeRange( + 'account-id', + '2020-09-08 22:21:36.000', + '2020-09-09 22:21:36.000' +); +``` -To Copy trade do: +Retrieve market data: ```php +$symbols = $terminal->marketData()->symbols('account-id'); +$specification = $terminal->marketData()->symbolSpecification('account-id', 'EURUSD'); +$price = $terminal->marketData()->symbolPrice('account-id', 'EURUSD', keepSubscription: true); +$candle = $terminal->marketData()->candle('account-id', 'EURUSD', '1m'); +$tick = $terminal->marketData()->tick('account-id', 'EURUSD'); +$book = $terminal->marketData()->orderBook('account-id', 'EURUSD'); + +$historicalCandles = $terminal->marketData()->historicalCandles( + 'account-id', + 'EURUSD', + '1m', + startTime: '2020-09-08T22:21:36.000Z', + limit: 1000 +); + +$historicalTicks = $terminal->marketData()->historicalTicks( + 'account-id', + 'EURUSD', + startTime: '2020-09-08T22:21:36.000Z', + offset: 0, + limit: 1000 +); +``` -try { - $strategyId = "yd24"; - $providerAccountId = "Enter your provider account ID"; - $subAccountId = "Enter Subscriber Account ID"; +Calculate margin: - return $copyfactory->copy($providerAccountId, $subAccountId, $strategyId); +```php +$margin = $terminal->margin()->calculate('account-id', [ + 'symbol' => 'GBPUSD', + 'type' => 'ORDER_TYPE_BUY', + 'volume' => 0.1, + 'openPrice' => 1.25, +]); +``` - /* - * You can ommit the strategy Id and just copy the trade - * The package will create a strategy as part of the copy process. - */ +Trade: - return $copyfactory->copy($providerAccountId, $subAccountId); +```php +$result = $terminal->trading()->trade('account-id', [ + 'actionType' => 'ORDER_TYPE_BUY', + 'symbol' => 'EURUSD', + 'volume' => 0.01, + 'stopLoss' => 1.09, + 'takeProfit' => 1.11, +]); + +$terminal->trading()->createMarketBuyOrder('account-id', 'EURUSD', 0.01); +$terminal->trading()->createMarketSellOrder('account-id', 'EURUSD', 0.01); +$terminal->trading()->createLimitBuyOrder('account-id', 'EURUSD', 0.01, 1.08); +$terminal->trading()->createStopSellOrder('account-id', 'EURUSD', 0.01, 1.07); +$terminal->trading()->createStopLimitBuyOrder('account-id', 'EURUSD', 0.01, 1.08, 1.081); +$terminal->trading()->modifyPosition('account-id', 'position-id', stopLoss: 1.09); +$terminal->trading()->closePosition('account-id', 'position-id'); +$terminal->trading()->closePositionPartially('account-id', 'position-id', 0.01); +$terminal->trading()->closePositionsBySymbol('account-id', 'EURUSD'); +$terminal->trading()->cancelOrder('account-id', 'order-id'); +``` -} catch (\Throwable $th) { - $response = json_decode($th->getMessage()); - return $response->message; -} +Retrieve CPU credit usage: +```php +$credits = $terminal->credits()->usage('account-id'); ``` -Note: copying a trade will take some seconds to finish, you you can have a loading indicator as feedback. ## MetaStats -You can get metrics for you account - -You can create an instance of the SDK like so for MetaStats: ```php -use Victorycodedev\MetaapiCloudPhpSdk\MetaStats; +$stats = $metaapi->metaStats(region: 'london'); +``` -$stats = new MetaStats('AUTH_TOKEN'); +Calculate metrics: +```php +$metrics = $stats->metrics('account-id', includeOpenPositions: true); ``` -To get metrics: +Get historical trades: ```php +$trades = $stats->historicalTrades( + 'account-id', + '2020-09-08 22:21:36.000', + '2020-09-09 22:21:36.000', + [ + 'limit' => 1000, + 'offset' => 0, + 'updateHistory' => true, + ] +); +``` -try { - return $stats->metrics("accountId"); - // You can pass a boolean as second parameter if you want to include open positions in your metrics - return $stats->metrics("accountId", true); -} catch (\Throwable $th) { - $response = json_decode($th->getMessage()); - return $response->message; -} +Get open trades: +```php +$openTrades = $stats->openTrades('account-id'); ``` +Reset metrics and trade history: -To get open trades for MetaApi account: +```php +$stats->resetMetrics('account-id'); +``` + +## Error Handling ```php +use Victorycodedev\MetaapiCloudPhpSdk\Exceptions\MetaApiException; try { - return $stats->openTrades("accountId"); -} catch (\Throwable $th) { - $response = json_decode($th->getMessage()); - return $response->message; + $account = $metaapi->accounts()->readById('account-id'); +} catch (MetaApiException $exception) { + echo $exception->getMessage(); + echo $exception->statusCode(); + echo $exception->errorName(); + + print_r($exception->details()); + print_r($exception->response()); } - ``` -## Testing - -```php +## Testing +```bash composer test - ``` ## API Reference -All API references can be found on Metaapi documentation website. https://metaapi.cloud/ + +All API references can be found on the MetaApi documentation website: https://metaapi.cloud/ ## Security + If you discover any security related issues, please open an issue. ## Contributing -Pull requests are welcome. +Pull requests are welcome. + +## How Can I Thank You? -## How can I thank you? -Why not star the github repo? I'd love the attention! you can share the link for this repository on Twitter or HackerNews? +Why not star the GitHub repo? You can also share the link for this repository on Twitter or HackerNews. -Don't forget to [follow me on twitter!](https://twitter.com/EfekpoguaVicto4) +Follow me on X: https://x.com/efekpoguavik3 Thanks! Efekpogua Victory. ## License -The MIT License (MIT). Please see [License File](./LICENSE.md) or more information. +The MIT License (MIT). Please see [License File](./LICENSE.md) for more information. diff --git a/composer.json b/composer.json index 18fb17f..d202147 100644 --- a/composer.json +++ b/composer.json @@ -27,14 +27,19 @@ }], "minimum-stability": "stable", "require": { - "php": "^8.1|^8.2", + "php": ">=8.2 <9.0", "guzzlehttp/guzzle": "^7.0" }, "scripts": { - "test": "./vendor/bin/phpunit" + "test": "./vendor/bin/pest", + "test:coverage": "./vendor/bin/pest --coverage" }, - "require-dev": { - "phpunit/phpunit": "^10.0" + "pestphp/pest": "^3.0" + }, + "config": { + "allow-plugins": { + "pestphp/pest-plugin": true + } } -} \ No newline at end of file +} diff --git a/phpunit.xml.dist b/phpunit.xml.dist index 34e66b5..a37b236 100644 --- a/phpunit.xml.dist +++ b/phpunit.xml.dist @@ -1,21 +1,13 @@ - - - - src/ - - - - - - - + - + tests - - - + + + src + + diff --git a/src/AccountApi.php b/src/AccountApi.php deleted file mode 100644 index 6608a19..0000000 --- a/src/AccountApi.php +++ /dev/null @@ -1,21 +0,0 @@ -token = $token; - $this->serverUrl = $serverUrl ?? $this->serverUrl; - $this->http = new Http($this->token, $this->serverUrl); - } -} diff --git a/src/CopyFactory.php b/src/CopyFactory.php index 5d99988..30943cd 100644 --- a/src/CopyFactory.php +++ b/src/CopyFactory.php @@ -2,22 +2,284 @@ namespace Victorycodedev\MetaapiCloudPhpSdk; +use GuzzleHttp\ClientInterface; use Victorycodedev\MetaapiCloudPhpSdk\Resources\Copyfactory\Configuration; use Victorycodedev\MetaapiCloudPhpSdk\Resources\Copyfactory\CopyTrade; +use Victorycodedev\MetaapiCloudPhpSdk\Resources\Copyfactory\History; +use Victorycodedev\MetaapiCloudPhpSdk\Resources\Copyfactory\Logs; +use Victorycodedev\MetaapiCloudPhpSdk\Resources\Copyfactory\Trading; +use Victorycodedev\MetaapiCloudPhpSdk\Resources\Copyfactory\Webhooks; +use Victorycodedev\MetaapiCloudPhpSdk\Resources\AccountManagement\Account; class CopyFactory { - use Configuration; - use CopyTrade; - public Http $http; public string $serverUrl = 'https://copyfactory-api-v1.new-york.agiliumtrade.ai'; - public function __construct(private string $token, string $serverUrl = null) + private Configuration $configuration; + + private CopyTrade $copyTrade; + + private Webhooks $webhooks; + + private History $history; + + private Trading $trading; + + private Logs $logs; + + public function __construct(private string $token, ?string $serverUrl = null, ?ClientInterface $client = null) { - $this->token = $token; $this->serverUrl = $serverUrl ?? $this->serverUrl; - $this->http = new Http($this->token, $this->serverUrl); + $this->http = new Http($this->token, $this->serverUrl, $client); + $this->configuration = new Configuration($this->http); + $accounts = new Account(new Http($this->token, MetaApiClient::DEFAULT_ACCOUNT_MANAGEMENT_URL, $client)); + $this->copyTrade = new CopyTrade($this->http, $this->configuration, $accounts); + $this->webhooks = new Webhooks($this->http); + $this->history = new History($this->http); + $this->trading = new Trading($this->http); + $this->logs = new Logs($this->http); + } + + public function configuration(): Configuration + { + return $this->configuration; + } + + public function copyTrade(): CopyTrade + { + return $this->copyTrade; + } + + public function webhooks(): Webhooks + { + return $this->webhooks; + } + + public function history(): History + { + return $this->history; + } + + public function trading(): Trading + { + return $this->trading; + } + + public function logs(): Logs + { + return $this->logs; + } + + public function generateStrategyId(): array|string|null + { + return $this->configuration->generateStrategyId(); + } + + public function strategies(bool $includeRemoved = false, int $limit = 1000, int $offset = 0): array|string|null + { + return $this->configuration->strategies($includeRemoved, $limit, $offset); + } + + public function strategy(string $strategyId): array|string|null + { + return $this->configuration->strategy($strategyId); + } + + public function portfolioStrategies(bool $includeRemoved = false, int $limit = 1000, int $offset = 0, ?int $apiVersion = null): array|string|null + { + return $this->configuration->portfolioStrategies($includeRemoved, $limit, $offset, $apiVersion); + } + + public function portfolioStrategy(string $portfolioId): array|string|null + { + return $this->configuration->portfolioStrategy($portfolioId); + } + + public function updatePortfolioStrategy(string $portfolioId, array $data): array|string|null + { + return $this->configuration->updatePortfolioStrategy($portfolioId, $data); + } + + public function removePortfolioStrategy(string $portfolioId, array $closeInstructions = []): array|string|null + { + return $this->configuration->removePortfolioStrategy($portfolioId, $closeInstructions); + } + + public function removePortfolioStrategyMember(string $portfolioId, string $strategyId, array $closeInstructions = []): array|string|null + { + return $this->configuration->removePortfolioStrategyMember($portfolioId, $strategyId, $closeInstructions); + } + + public function updateStrategy(string $strategyId, array $data): array|string|null + { + return $this->configuration->updateStrategy($strategyId, $data); + } + + public function removeStrategy(string $strategyId): array|string|null + { + return $this->configuration->removeStrategy($strategyId); + } + + public function subscribers(bool $includeRemoved = false, int $limit = 1000, int $offset = 0): array|string|null + { + return $this->configuration->subscribers($includeRemoved, $limit, $offset); + } + + public function subscriber(string $subscriberId): array|string|null + { + return $this->configuration->subscriber($subscriberId); + } + + public function updateSubscriber(string $subscriberId, array $data): array|string|null + { + return $this->configuration->updateSubscriber($subscriberId, $data); + } + + public function removeSubscriber(string $subscriberId): array|string|null + { + return $this->configuration->removeSubscriber($subscriberId); + } + + public function deleteSubscription(string $subscriberId, string $strategyId): array|string|null + { + return $this->configuration->deleteSubscription($subscriberId, $strategyId); + } + + public function getWebhooks(string $strategyId, array $query = []): array|string|null + { + return $this->webhooks->list($strategyId, $query); + } + + public function createWebhook(string $strategyId, array $data): array|string|null + { + return $this->webhooks->create($strategyId, $data); + } + + public function updateWebhook(string $strategyId, string $webhookId, array $data): array|string|null + { + return $this->webhooks->update($strategyId, $webhookId, $data); + } + + public function removeWebhook(string $strategyId, string $webhookId): array|string|null + { + return $this->webhooks->remove($strategyId, $webhookId); + } + + public function providedTransactions(array $query): array|string|null + { + return $this->history->providedTransactions($query); + } + + public function subscriptionTransactions(array $query): array|string|null + { + return $this->history->subscriptionTransactions($query); + } + + public function strategyTransactionsStream(string $strategyId, array $query = []): array|string|null + { + return $this->history->strategyTransactionsStream($strategyId, $query); + } + + public function subscriberTransactionsStream(string $subscriberId, array $query = []): array|string|null + { + return $this->history->subscriberTransactionsStream($subscriberId, $query); + } + + public function signals(string $subscriberId): array|string|null + { + return $this->trading->signals($subscriberId); + } + + public function externalSignals(string $strategyId): array|string|null + { + return $this->trading->externalSignals($strategyId); + } + + public function updateExternalSignal(string $strategyId, string $signalId, array $data): array|string|null + { + return $this->trading->updateExternalSignal($strategyId, $signalId, $data); + } + + public function removeExternalSignal(string $strategyId, string $signalId, array $data): array|string|null + { + return $this->trading->removeExternalSignal($strategyId, $signalId, $data); + } + + public function stopouts(string $subscriberId): array|string|null + { + return $this->trading->stopouts($subscriberId); + } + + public function resetSubscriptionStopouts(string $subscriberId, string $strategyId, string $reason): array|string|null + { + return $this->trading->resetSubscriptionStopouts($subscriberId, $strategyId, $reason); + } + + public function resetSubscriberStopouts(string $subscriberId, string $reason): array|string|null + { + return $this->trading->resetSubscriberStopouts($subscriberId, $reason); + } + + public function stopoutsStream(array $query = []): array|string|null + { + return $this->trading->stopoutsStream($query); + } + + public function resynchronize(string $subscriberId, array $query = []): array|string|null + { + return $this->trading->resynchronize($subscriberId, $query); + } + + public function userLog(string $subscriberId, array $query = []): array|string|null + { + return $this->logs->userLog($subscriberId, $query); + } + + public function userLogStream(string $subscriberId, array $query = []): array|string|null + { + return $this->logs->userLogStream($subscriberId, $query); + } + + public function strategyLog(string $strategyId, array $query = []): array|string|null + { + return $this->logs->strategyLog($strategyId, $query); + } + + public function strategyLogStream(string $strategyId, array $query = []): array|string|null + { + return $this->logs->strategyLogStream($strategyId, $query); + } + + public function copy(string $providerAccount, string $subscriberAccount, ?string $strategyId = null): array|string + { + return $this->copyTrade->copy($providerAccount, $subscriberAccount, $strategyId); + } + + public function configureCopyTrading( + string $providerAccountId, + string $subscriberAccountId, + ?string $strategyId = null, + array $strategy = [], + array $subscription = [], + array $subscriber = [], + bool $validateAccountRoles = true, + bool $reuseExistingStrategy = false, + ?array $providerAccount = null, + ?array $subscriberAccount = null + ): array { + return $this->copyTrade->configureCopyTrading( + $providerAccountId, + $subscriberAccountId, + $strategyId, + $strategy, + $subscription, + $subscriber, + $validateAccountRoles, + $reuseExistingStrategy, + $providerAccount, + $subscriberAccount + ); } } diff --git a/src/Exceptions/BadRequestException.php b/src/Exceptions/BadRequestException.php deleted file mode 100644 index f90d3d4..0000000 --- a/src/Exceptions/BadRequestException.php +++ /dev/null @@ -1,13 +0,0 @@ -statusCode; + } + + public function response(): array|string|null + { + return $this->response; + } + + public function headers(): array + { + return $this->headers; + } + + public function errorId(): ?int + { + return is_array($this->response) && isset($this->response['id']) + ? (int) $this->response['id'] + : null; + } + + public function errorName(): ?string + { + return is_array($this->response) && isset($this->response['error']) + ? (string) $this->response['error'] + : null; + } + + public function details(): mixed + { + return is_array($this->response) && array_key_exists('details', $this->response) + ? $this->response['details'] + : null; + } + + public function retryAfter(): ?string + { + return $this->headers['Retry-After'][0] ?? $this->headers['retry-after'][0] ?? null; + } +} diff --git a/src/Exceptions/NotFoundException.php b/src/Exceptions/NotFoundException.php deleted file mode 100644 index e6cab0b..0000000 --- a/src/Exceptions/NotFoundException.php +++ /dev/null @@ -1,13 +0,0 @@ -client = $client ?? new Client([ 'base_uri' => $this->baseUrl, @@ -28,57 +24,77 @@ public function __construct(protected string $token, protected string $baseUrl, ]); } - public function get(string $uri): array|string + public function get(string $uri, array $query = [], array $headers = []): array|string|null { - return $this->request('GET', $uri); + return $this->request('GET', $uri, ['query' => $query, 'headers' => $headers]); } - public function post(string $uri, array $payload = []): array|string + public function post(string $uri, array $payload = [], array $headers = [], array $query = []): array|string|null { - return $this->request('POST', $uri, $payload); + return $this->request('POST', $uri, ['json' => $payload, 'headers' => $headers, 'query' => $query]); } - public function put(string $uri, array $payload = []): array|string + public function put(string $uri, array $payload = [], array $headers = [], array $query = []): array|string|null { - return $this->request('PUT', $uri, $payload); + return $this->request('PUT', $uri, ['json' => $payload, 'headers' => $headers, 'query' => $query]); } - public function delete(string $uri, array $payload = []): array|string + public function patch(string $uri, array $payload = [], array $headers = [], array $query = []): array|string|null { - return $this->request('DELETE', $uri, $payload); + return $this->request('PATCH', $uri, ['json' => $payload, 'headers' => $headers, 'query' => $query]); + } + + public function delete(string $uri, array $query = [], array $headers = [], array $payload = []): array|string|null + { + return $this->request('DELETE', $uri, ['query' => $query, 'headers' => $headers, 'json' => $payload]); + } + + public function upload(string $uri, string $filePath, string $fieldName = 'file', array $headers = []): array|string|null + { + return $this->request('PUT', $uri, [ + 'headers' => $headers, + 'multipart' => [ + [ + 'name' => $fieldName, + 'contents' => fopen($filePath, 'r'), + 'filename' => basename($filePath), + ], + ], + ]); } /** * Send a request to the MetaApi API. */ - public function request(string $verb, string $uri, array $payload = []): array|string + public function request(string $verb, string $uri, array $options = []): array|string|null { - $response = $this->client->request( - $verb, - $uri, - empty($payload) ? [] : ['json' => $payload] - ); + $options = $this->normalizeOptions($options); + $response = $this->client->request($verb, $this->resolveUri($uri), $options); if (!$this->isSuccessful($response)) { return $this->handleError($response); } - if ($response->hasHeader('Retry-After')) { - $retryAfter = $response->getHeader('Retry-After')[0]; - $body = array_merge(json_decode((string) $response->getBody(), true), ['retryAfter' => $retryAfter]); - $resBody = json_encode($body); - } else { - $resBody = (string) $response->getBody(); + $body = (string) $response->getBody(); + + if ($body === '') { + return null; + } + + $decoded = json_decode($body, true); + + if (json_last_error() !== JSON_ERROR_NONE) { + return $body; } - if (empty($resBody)) { - $resBody = '{"success": true, "message": "Action successful"}'; + if ($response->hasHeader('Retry-After')) { + $decoded['retryAfter'] = $response->getHeaderLine('Retry-After'); } - return json_decode($resBody, true) ?: $resBody; + return $decoded; } - public function isSuccessful($response): bool + public function isSuccessful(?ResponseInterface $response): bool { if (!$response) { return false; @@ -89,13 +105,52 @@ public function isSuccessful($response): bool public function handleError(ResponseInterface $response): void { - match ($response->getStatusCode()) { - 400 => throw new BadRequestException((string) $response->getBody()), - 401 => throw new UnauthorizedException((string) $response->getBody()), - 403 => throw new ForbiddenRequestException((string) $response->getBody()), - 404 => throw new NotFoundException((string) $response->getBody()), - 429 => throw new TooManyRequestException((string) $response->getBody()), - default => throw new Exception((string) $response->getBody()), - }; + $body = (string) $response->getBody(); + $decoded = json_decode($body, true); + $error = json_last_error() === JSON_ERROR_NONE ? $decoded : $body; + $message = is_array($error) && isset($error['message']) ? (string) $error['message'] : $body; + $code = is_array($error) && isset($error['id']) ? (int) $error['id'] : 0; + $statusCode = $response->getStatusCode(); + $headers = $response->getHeaders(); + + throw new MetaApiException($message ?: 'MetaApi request failed', $code, $statusCode, $error, $headers); + } + + private function normalizeOptions(array $options): array + { + foreach (['headers', 'query', 'json'] as $key) { + if (isset($options[$key]) && $options[$key] === []) { + unset($options[$key]); + } + } + + if (isset($options['query'])) { + $options['query'] = $this->normalizeQuery($options['query']); + } + + return $options; + } + + private function resolveUri(string $uri): string + { + if (str_starts_with($uri, 'http://') || str_starts_with($uri, 'https://')) { + return $uri; + } + + return rtrim($this->baseUrl, '/') . '/' . ltrim($uri, '/'); + } + + private function normalizeQuery(array $query): array + { + return array_filter( + array_map(function (mixed $value): mixed { + if (is_bool($value)) { + return $value ? 'true' : 'false'; + } + + return $value; + }, $query), + static fn(mixed $value): bool => $value !== null + ); } } diff --git a/src/MetaApiClient.php b/src/MetaApiClient.php new file mode 100644 index 0000000..65f3285 --- /dev/null +++ b/src/MetaApiClient.php @@ -0,0 +1,100 @@ + 'https://copyfactory-api-v1.%s.agiliumtrade.ai', + 'metaStats' => 'https://metastats-api-v1.%s.agiliumtrade.ai', + 'terminal' => 'https://mt-client-api-v1.%s.agiliumtrade.ai', + 'marketData' => 'https://mt-market-data-client-api-v1.%s.agiliumtrade.ai', + ]; + + private Http $accountHttp; + + public function __construct( + private readonly string $token, + private readonly ?ClientInterface $client = null, + private readonly array $baseUrls = [] + ) { + $this->accountHttp = new Http( + $this->token, + $this->baseUrl('accountManagement', self::DEFAULT_ACCOUNT_MANAGEMENT_URL), + $this->client + ); + } + + public function accounts(): Account + { + return new Account($this->accountHttp); + } + + public function provisioningProfiles(): ProvisioningProfile + { + return new ProvisioningProfile($this->accountHttp); + } + + public function accountReplicas(): AccountReplica + { + return new AccountReplica($this->accountHttp); + } + + public function copyFactory(?string $region = null, ?string $serverUrl = null): CopyFactory + { + return new CopyFactory( + $this->token, + $serverUrl ?? $this->regionalBaseUrl('copyFactory', $region), + $this->client + ); + } + + public function metaStats(?string $region = null, ?string $serverUrl = null): MetaStats + { + return new MetaStats( + $this->token, + $serverUrl ?? $this->regionalBaseUrl('metaStats', $region), + $this->client + ); + } + + public function terminal(?string $region = null, ?string $serverUrl = null, ?string $marketDataServerUrl = null): TerminalApi + { + return new TerminalApi( + $this->token, + $serverUrl ?? $this->regionalBaseUrl('terminal', $region), + $marketDataServerUrl ?? $this->regionalBaseUrl('marketData', $region), + $this->client + ); + } + + private function baseUrl(string $service, string $default): string + { + return $this->baseUrls[$service] ?? $default; + } + + private function regionalBaseUrl(string $service, ?string $region): string + { + if (isset($this->baseUrls[$service])) { + return $this->baseUrls[$service]; + } + + $region = $this->normalizeRegion($region ?? self::DEFAULT_REGION); + + return sprintf(self::REGIONAL_SERVICE_URLS[$service], $region); + } + + private function normalizeRegion(string $region): string + { + return str_replace('_', '-', strtolower(trim($region))); + } +} diff --git a/src/MetaStats.php b/src/MetaStats.php index 65fd233..2f906d3 100644 --- a/src/MetaStats.php +++ b/src/MetaStats.php @@ -2,19 +2,46 @@ namespace Victorycodedev\MetaapiCloudPhpSdk; +use GuzzleHttp\ClientInterface; use Victorycodedev\MetaapiCloudPhpSdk\Resources\Metastats\Metrics; class MetaStats { - use Metrics; public Http $http; public string $serverUrl = 'https://metastats-api-v1.new-york.agiliumtrade.ai'; - public function __construct(private string $token, string $serverUrl = null) + private Metrics $metrics; + + public function __construct(private string $token, ?string $serverUrl = null, ?ClientInterface $client = null) { - $this->token = $token; $this->serverUrl = $serverUrl ?? $this->serverUrl; - $this->http = new Http($this->token, $this->serverUrl); + $this->http = new Http($this->token, $this->serverUrl, $client); + $this->metrics = new Metrics($this->http); + } + + public function metricResource(): Metrics + { + return $this->metrics; + } + + public function metrics(string $accountId, bool $includeOpenPositions = false): array|string|null + { + return $this->metrics->metrics($accountId, $includeOpenPositions); + } + + public function historicalTrades(string $accountId, string $startTime, string $endTime, array $query = []): array|string|null + { + return $this->metrics->historicalTrades($accountId, $startTime, $endTime, $query); + } + + public function openTrades(string $accountId): array|string|null + { + return $this->metrics->openTrades($accountId); + } + + public function resetMetrics(string $accountId): array|string|null + { + return $this->metrics->resetMetrics($accountId); } } diff --git a/src/Resources/AccountManagement/Account.php b/src/Resources/AccountManagement/Account.php index 4f3e7da..feec126 100644 --- a/src/Resources/AccountManagement/Account.php +++ b/src/Resources/AccountManagement/Account.php @@ -2,45 +2,97 @@ namespace Victorycodedev\MetaapiCloudPhpSdk\Resources\AccountManagement; -trait Account +use Victorycodedev\MetaapiCloudPhpSdk\Http; + +class Account { - public function readById(string $accountId): array|string + public function __construct(private readonly Http $http) {} + + public function readById(string $accountId): array|string|null { return $this->http->get("/users/current/accounts/{$accountId}"); } - public function readAll(): array|string + public function accounts(array $filters = [], ?int $apiVersion = null): array|string|null { - return $this->http->get('/users/current/accounts'); + return $this->http->get('/users/current/accounts', $filters, $this->apiVersionHeader($apiVersion)); } - public function create(array $data): array|string + public function create(array $data, ?string $transactionId = null): array|string|null { - return $this->http->post('/users/current/accounts', $data); + return $this->http->post('/users/current/accounts', $data, $this->transactionHeader($transactionId)); } - public function update(string $accountId, array $data): array|string + public function update(string $accountId, array $data): array|string|null { return $this->http->put("/users/current/accounts/{$accountId}", $data); } - public function unDeploy(string $accountId, bool $executeForAllReplicas = true): array|string + public function undeploy(string $accountId, bool $executeForAllReplicas = true): array|string|null + { + return $this->http->post( + "/users/current/accounts/{$accountId}/undeploy", + query: ['executeForAllReplicas' => $executeForAllReplicas] + ); + } + + public function deploy(string $accountId, bool $executeForAllReplicas = true): array|string|null + { + return $this->http->post( + "/users/current/accounts/{$accountId}/deploy", + query: ['executeForAllReplicas' => $executeForAllReplicas] + ); + } + + public function redeploy(string $accountId, bool $executeForAllReplicas = true): array|string|null + { + return $this->http->post( + "/users/current/accounts/{$accountId}/redeploy", + query: ['executeForAllReplicas' => $executeForAllReplicas] + ); + } + + public function delete(string $accountId, bool $executeForAllReplicas = false): array|string|null + { + return $this->http->delete( + "/users/current/accounts/{$accountId}", + ['executeForAllReplicas' => $executeForAllReplicas] + ); + } + + public function generateCodeSample(string $accountId, string $platform): array|string|null + { + return $this->http->get("/users/current/accounts/{$accountId}/examples/{$platform}"); + } + + public function enableFeatures(string $accountId, array $data): array|string|null + { + return $this->http->post("/users/current/accounts/{$accountId}/enable-account-features", $data); + } + + public function enableCopyFactoryApi(string $accountId, array $copyFactoryRoles, int $copyFactoryResourceSlots = 1): array|string|null { - return $this->http->post("/users/current/accounts/{$accountId}/undeploy?executeForAllReplicas={$executeForAllReplicas}"); + return $this->http->post("/users/current/accounts/{$accountId}/enable-copy-factory-api", [ + 'copyFactoryRoles' => $copyFactoryRoles, + 'copyFactoryResourceSlots' => $copyFactoryResourceSlots, + ]); } - public function deploy(string $accountId, bool $executeForAllReplicas = true): array|string + public function createConfigurationLink(string $accountId, ?int $ttlInDays = null): array|string|null { - return $this->http->post("/users/current/accounts/{$accountId}/deploy?executeForAllReplicas={$executeForAllReplicas}"); + return $this->http->put( + "/users/current/accounts/{$accountId}/configuration-link", + query: ['ttlInDays' => $ttlInDays] + ); } - public function reDeploy(string $accountId, bool $executeForAllReplicas = true): array|string + private function transactionHeader(?string $transactionId): array { - return $this->http->post("/users/current/accounts/{$accountId}/redeploy?executeForAllReplicas={$executeForAllReplicas}"); + return $transactionId ? ['transaction-id' => $transactionId] : []; } - public function delete(string $accountId, bool $executeForAllReplicas = false): array|string + private function apiVersionHeader(?int $apiVersion): array { - return $this->http->delete("/users/current/accounts/{$accountId}/redeploy?executeForAllReplicas={$executeForAllReplicas}"); + return $apiVersion ? ['api-version' => (string) $apiVersion] : []; } } diff --git a/src/Resources/AccountManagement/AccountReplica.php b/src/Resources/AccountManagement/AccountReplica.php new file mode 100644 index 0000000..d3503a8 --- /dev/null +++ b/src/Resources/AccountManagement/AccountReplica.php @@ -0,0 +1,66 @@ +http->get("/users/current/accounts/{$accountId}/replicas"); + } + + public function replica(string $accountId, string $replicaId): array|string|null + { + return $this->http->get("/users/current/accounts/{$accountId}/replicas/{$replicaId}"); + } + + public function createReplica(string $accountId, array $data, ?string $transactionId = null): array|string|null + { + return $this->http->post( + "/users/current/accounts/{$accountId}/replicas", + $data, + $transactionId ? ['transaction-id' => $transactionId] : [] + ); + } + + public function updateReplica(string $accountId, string $replicaId, array $data): array|string|null + { + return $this->http->put("/users/current/accounts/{$accountId}/replicas/{$replicaId}", $data); + } + + public function undeployReplica(string $accountId, string $replicaId): array|string|null + { + return $this->http->post("/users/current/accounts/{$accountId}/replicas/{$replicaId}/undeploy"); + } + + public function deployReplica(string $accountId, string $replicaId): array|string|null + { + return $this->http->post("/users/current/accounts/{$accountId}/replicas/{$replicaId}/deploy"); + } + + public function redeployReplica(string $accountId, string $replicaId): array|string|null + { + return $this->http->post("/users/current/accounts/{$accountId}/replicas/{$replicaId}/redeploy"); + } + + public function deleteReplica(string $accountId, string $replicaId): array|string|null + { + return $this->http->delete("/users/current/accounts/{$accountId}/replicas/{$replicaId}"); + } + + public function generateReplicaCodeSample(string $accountId, string $replicaId, string $platform): array|string|null + { + return $this->http->get("/users/current/accounts/{$accountId}/replicas/{$replicaId}/examples/{$platform}"); + } + + public function increaseReplicaReliability(string $accountId, string $replicaId): array|string|null + { + return $this->http->post("/users/current/accounts/{$accountId}/replicas/{$replicaId}/increase-reliability"); + } +} diff --git a/src/Resources/AccountManagement/ProvisioningProfile.php b/src/Resources/AccountManagement/ProvisioningProfile.php new file mode 100644 index 0000000..89f855d --- /dev/null +++ b/src/Resources/AccountManagement/ProvisioningProfile.php @@ -0,0 +1,46 @@ +http->get( + '/users/current/provisioning-profiles', + $filters, + $apiVersion ? ['api-version' => (string) $apiVersion] : [] + ); + } + + public function provisioningProfile(string $profileId): array|string|null + { + return $this->http->get("/users/current/provisioning-profiles/{$profileId}"); + } + + public function createProvisioningProfile(array $data): array|string|null + { + return $this->http->post('/users/current/provisioning-profiles', $data); + } + + public function uploadProvisioningProfileFile(string $profileId, string $fileName, string $filePath): array|string|null + { + return $this->http->upload("/users/current/provisioning-profiles/{$profileId}/{$fileName}", $filePath); + } + + public function updateProvisioningProfile(string $profileId, array $data): array|string|null + { + return $this->http->put("/users/current/provisioning-profiles/{$profileId}", $data); + } + + public function deleteProvisioningProfile(string $profileId): array|string|null + { + return $this->http->delete("/users/current/provisioning-profiles/{$profileId}"); + } +} diff --git a/src/Resources/Copyfactory/Configuration.php b/src/Resources/Copyfactory/Configuration.php index 4ca2bf7..abab8c2 100644 --- a/src/Resources/Copyfactory/Configuration.php +++ b/src/Resources/Copyfactory/Configuration.php @@ -2,14 +2,20 @@ namespace Victorycodedev\MetaapiCloudPhpSdk\Resources\Copyfactory; -trait Configuration +use Victorycodedev\MetaapiCloudPhpSdk\Http; + +class Configuration { private string $configUrl = 'users/current/configuration'; + public function __construct(private readonly Http $http) + { + } + /* * Generates a new strategy id */ - public function generateStrategyId(): array|string + public function generateStrategyId(): array|string|null { return $this->http->get("/{$this->configUrl}/unused-strategy-id"); } @@ -17,15 +23,32 @@ public function generateStrategyId(): array|string /* * Returns provider strategies the user has configured */ - public function strategies(bool $includeRemoved = false, int $limit = 1000, int $offset = 0): array|string + public function strategies(bool $includeRemoved = false, int $limit = 1000, int $offset = 0): array|string|null + { + return $this->http->get("/{$this->configUrl}/strategies", [ + 'includeRemoved' => $includeRemoved, + 'limit' => $limit, + 'offset' => $offset, + ]); + } + + public function strategiesV2(bool $includeRemoved = false, int $limit = 1000, int $offset = 0): array|string|null { - return $this->http->get("/{$this->configUrl}/strategies?includeRemoved={$includeRemoved}&limit={$limit}&offset={$offset}"); + return $this->http->get( + "/{$this->configUrl}/strategies", + [ + 'includeRemoved' => $includeRemoved, + 'limit' => $limit, + 'offset' => $offset, + ], + ['api-version' => '2'] + ); } /* * Returns provider strategy the user has configured by id */ - public function strategy(string $strategyId): array|string + public function strategy(string $strategyId): array|string|null { return $this->http->get("/{$this->configUrl}/strategies/{$strategyId}"); } @@ -33,7 +56,7 @@ public function strategy(string $strategyId): array|string /* * Updates provider strategy */ - public function updateStrategy(string $strategyId, array $data): array|string + public function updateStrategy(string $strategyId, array $data): array|string|null { return $this->http->put("/{$this->configUrl}/strategies/{$strategyId}", $data); } @@ -41,23 +64,76 @@ public function updateStrategy(string $strategyId, array $data): array|string /* * Deletes provider strategy */ - public function removeStrategy(string $strategyId): array|string + public function removeStrategy(string $strategyId): array|string|null { return $this->http->delete("/{$this->configUrl}/strategies/{$strategyId}"); } + public function portfolioStrategies(bool $includeRemoved = false, int $limit = 1000, int $offset = 0, ?int $apiVersion = null): array|string|null + { + return $this->http->get( + "/{$this->configUrl}/portfolio-strategies", + [ + 'includeRemoved' => $includeRemoved, + 'limit' => $limit, + 'offset' => $offset, + ], + $apiVersion ? ['api-version' => (string) $apiVersion] : [] + ); + } + + public function portfolioStrategy(string $portfolioId): array|string|null + { + return $this->http->get("/{$this->configUrl}/portfolio-strategies/{$portfolioId}"); + } + + public function updatePortfolioStrategy(string $portfolioId, array $data): array|string|null + { + return $this->http->put("/{$this->configUrl}/portfolio-strategies/{$portfolioId}", $data); + } + + public function removePortfolioStrategy(string $portfolioId, array $closeInstructions = []): array|string|null + { + return $this->http->delete("/{$this->configUrl}/portfolio-strategies/{$portfolioId}", payload: $closeInstructions); + } + + public function removePortfolioStrategyMember(string $portfolioId, string $strategyId, array $closeInstructions = []): array|string|null + { + return $this->http->delete( + "/{$this->configUrl}/portfolio-strategies/{$portfolioId}/members/{$strategyId}", + payload: $closeInstructions + ); + } + /* * Returns strategy subscribers the current user provides strategies to */ - public function subscribers(bool $includeRemoved = false, int $limit = 1000, int $offset = 0): array|string + public function subscribers(bool $includeRemoved = false, int $limit = 1000, int $offset = 0): array|string|null + { + return $this->http->get("/{$this->configUrl}/subscribers", [ + 'includeRemoved' => $includeRemoved, + 'limit' => $limit, + 'offset' => $offset, + ]); + } + + public function subscribersV2(bool $includeRemoved = false, int $limit = 1000, int $offset = 0): array|string|null { - return $this->http->get("/{$this->configUrl}/subscribers?includeRemoved={$includeRemoved}&limit={$limit}&offset={$offset}"); + return $this->http->get( + "/{$this->configUrl}/subscribers", + [ + 'includeRemoved' => $includeRemoved, + 'limit' => $limit, + 'offset' => $offset, + ], + ['api-version' => '2'] + ); } /* * Returns CopyFactory subscriber by id */ - public function subscriber(string $subscriberId): array|string + public function subscriber(string $subscriberId): array|string|null { return $this->http->get("/{$this->configUrl}/subscribers/{$subscriberId}"); } @@ -66,7 +142,7 @@ public function subscriber(string $subscriberId): array|string * Updates subscriber configuration */ - public function updateSubscriber(string $subscriberId, array $data): array|string + public function updateSubscriber(string $subscriberId, array $data): array|string|null { return $this->http->put("/{$this->configUrl}/subscribers/{$subscriberId}", $data); } @@ -74,7 +150,7 @@ public function updateSubscriber(string $subscriberId, array $data): array|strin /* * Deletes subscriber configuration */ - public function removeSubscriber(string $subscriberId): array|string + public function removeSubscriber(string $subscriberId): array|string|null { return $this->http->delete("/{$this->configUrl}/subscribers/{$subscriberId}"); } @@ -83,7 +159,7 @@ public function removeSubscriber(string $subscriberId): array|string * Deletes subscription */ - public function deleteSubscription(string $subscriberId, string $strategyId): array|string + public function deleteSubscription(string $subscriberId, string $strategyId): array|string|null { return $this->http->delete("/{$this->configUrl}/subscribers/{$subscriberId}/subscriptions/{$strategyId}"); } diff --git a/src/Resources/Copyfactory/CopyTrade.php b/src/Resources/Copyfactory/CopyTrade.php index 4789d07..6bd0e72 100644 --- a/src/Resources/Copyfactory/CopyTrade.php +++ b/src/Resources/Copyfactory/CopyTrade.php @@ -2,75 +2,118 @@ namespace Victorycodedev\MetaapiCloudPhpSdk\Resources\Copyfactory; -use Victorycodedev\MetaapiCloudPhpSdk\AccountApi; +use Victorycodedev\MetaapiCloudPhpSdk\Http; +use Victorycodedev\MetaapiCloudPhpSdk\Exceptions\MetaApiException; +use Victorycodedev\MetaapiCloudPhpSdk\Resources\AccountManagement\Account; -trait CopyTrade +class CopyTrade { - use Configuration; + public function __construct( + private readonly Http $http, + private readonly Configuration $configuration, + private readonly Account $accounts + ) {} /* - * Create actual copy trade in metaapi.cloud + * Configures CopyFactory to copy a provider strategy into a subscriber account. */ - public function copy(string $providerAccount, string $subscriberAccount, string $strategyId = null): array|string - { - try { - $account = new AccountApi($this->token); + public function configureCopyTrading( + string $providerAccountId, + string $subscriberAccountId, + ?string $strategyId = null, + array $strategy = [], + array $subscription = [], + array $subscriber = [], + bool $validateAccountRoles = true, + bool $reuseExistingStrategy = false, + ?array $providerAccount = null, + ?array $subscriberAccount = null + ): array { + if ($validateAccountRoles || $reuseExistingStrategy) { + $providerAccount ??= $this->accounts->readById($providerAccountId); + } - $masterMetaapiAccount = $account->readById($providerAccount); - $slaveMetaapiAccount = $account->readById($subscriberAccount); + if ($validateAccountRoles) { + $subscriberAccount ??= $this->accounts->readById($subscriberAccountId); + $this->assertAccountRole($providerAccount, 'PROVIDER', $providerAccountId); + $this->assertAccountRole($subscriberAccount, 'SUBSCRIBER', $subscriberAccountId); + } - if (!in_array('PROVIDER', $masterMetaapiAccount['copyFactoryRoles'])) { - $response = "{'message': 'Account {$providerAccount} is not a provider account. Please specify PROVIDER copyFactoryRoles value in your MetaApi account in order to use it in CopyFactory API'}"; + if ($strategyId === null && $reuseExistingStrategy) { + $strategyId = $this->findStrategyIdForAccount($providerAccount['_id'] ?? $providerAccountId); + } - throw new \Exception((string) $response); - } + $strategyId ??= $this->configuration->generateStrategyId()['id']; + $providerMetaApiAccountId = $providerAccount['_id'] ?? $providerAccountId; + $subscriberMetaApiAccountId = $subscriberAccount['_id'] ?? $subscriberAccountId; - if (!in_array('SUBSCRIBER', $slaveMetaapiAccount['copyFactoryRoles'])) { - $response = "{'message': 'Account {$subscriberAccount} is not a subscriber account. Please specify SUBSCRIBER copyFactoryRoles value in your MetaApi account in order to use it in CopyFactory API'}"; + $strategyPayload = array_replace([ + 'name' => 'CopyFactory strategy', + 'description' => 'CopyFactory strategy configured by SDK', + 'accountId' => $providerMetaApiAccountId, + ], $strategy); - throw new \Exception((string) $response); - } + $strategyPayload['accountId'] = $strategyPayload['accountId'] ?? $providerMetaApiAccountId; - // get strategy ID - if (empty($strategyId)) { - $strategies = $this->strategies(); - $strategy = []; - - foreach ($strategies as $value) { - if ($value['accountId'] == $masterMetaapiAccount['_id']) { - $strategy = $value; - break; - } - } - - if (!empty($strategy)) { - $strategyId = $strategy['_id']; - } else { - $strategyId = $this->generateStrategyId()['id']; - } - } + $subscriptionPayload = array_replace([ + 'strategyId' => $strategyId, + 'multiplier' => 1, + ], $subscription); + + $subscriptionPayload['strategyId'] = $subscriptionPayload['strategyId'] ?? $strategyId; + + $subscriberPayload = array_replace([ + 'name' => 'CopyFactory subscriber', + 'subscriptions' => [$subscriptionPayload], + ], $subscriber); + + $subscriberPayload['subscriptions'] = $subscriberPayload['subscriptions'] ?? [$subscriptionPayload]; + + $this->configuration->updateStrategy($strategyId, $strategyPayload); + $this->configuration->updateSubscriber($subscriberMetaApiAccountId, $subscriberPayload); + + return [ + 'message' => 'Copy trading configured successfully', + 'strategyId' => $strategyId, + 'providerAccountId' => $providerMetaApiAccountId, + 'subscriberAccountId' => $subscriberMetaApiAccountId, + ]; + } + + public function copy(string $providerAccount, string $subscriberAccount, ?string $strategyId = null): array|string + { + return $this->configureCopyTrading($providerAccount, $subscriberAccount, $strategyId); + } + + private function assertAccountRole(array $account, string $role, string $accountId): void + { + if (in_array($role, $account['copyFactoryRoles'] ?? [], true)) { + return; + } - // create a strategy being copied - $this->http->put("/users/current/configuration/strategies/{$strategyId}", [ - 'name' => 'Test strategy', - 'description' => 'Some useful description about your strategy', - 'accountId' => $masterMetaapiAccount['_id'], - ]); - - // create subscriber - $this->updateSubscriber($slaveMetaapiAccount['_id'], [ - 'name' => 'Copy Trade Subscriber', - 'subscriptions' => [ - [ - 'strategyId' => $strategyId, - 'multiplier' => 1, - ], + throw new MetaApiException( + "Account {$accountId} is not a {$role} account. Please specify {$role} copyFactoryRoles value in your MetaApi account in order to use it in CopyFactory API", + response: [ + 'error' => 'ValidationError', + 'message' => "Account {$accountId} is not a {$role} account.", + 'details' => [ + 'accountId' => $accountId, + 'requiredRole' => $role, ], - ]); + ] + ); + } + + private function findStrategyIdForAccount(string $accountId): ?string + { + $strategies = $this->configuration->strategies(); - return ['message' => 'Copy trade created successfully']; - } catch (\Throwable $th) { - throw new \Exception($th->getMessage()); + foreach ($strategies as $strategy) { + if (($strategy['accountId'] ?? null) === $accountId) { + return $strategy['_id'] ?? $strategy['id'] ?? null; + } } + + return null; } } diff --git a/src/Resources/Copyfactory/History.php b/src/Resources/Copyfactory/History.php new file mode 100644 index 0000000..dae69ae --- /dev/null +++ b/src/Resources/Copyfactory/History.php @@ -0,0 +1,32 @@ +http->get('/users/current/provided-transactions', $query); + } + + public function subscriptionTransactions(array $query): array|string|null + { + return $this->http->get('/users/current/subscription-transactions', $query); + } + + public function strategyTransactionsStream(string $strategyId, array $query = []): array|string|null + { + return $this->http->get("/users/current/strategies/{$strategyId}/transactions/stream", $query); + } + + public function subscriberTransactionsStream(string $subscriberId, array $query = []): array|string|null + { + return $this->http->get("/users/current/subscribers/{$subscriberId}/transactions/stream", $query); + } +} diff --git a/src/Resources/Copyfactory/Logs.php b/src/Resources/Copyfactory/Logs.php new file mode 100644 index 0000000..3d259f6 --- /dev/null +++ b/src/Resources/Copyfactory/Logs.php @@ -0,0 +1,32 @@ +http->get("/users/current/subscribers/{$subscriberId}/user-log", $query); + } + + public function userLogStream(string $subscriberId, array $query = []): array|string|null + { + return $this->http->get("/users/current/subscribers/{$subscriberId}/user-log/stream", $query); + } + + public function strategyLog(string $strategyId, array $query = []): array|string|null + { + return $this->http->get("/users/current/strategies/{$strategyId}/user-log", $query); + } + + public function strategyLogStream(string $strategyId, array $query = []): array|string|null + { + return $this->http->get("/users/current/strategies/{$strategyId}/user-log/stream", $query); + } +} diff --git a/src/Resources/Copyfactory/Trading.php b/src/Resources/Copyfactory/Trading.php new file mode 100644 index 0000000..b52e70a --- /dev/null +++ b/src/Resources/Copyfactory/Trading.php @@ -0,0 +1,57 @@ +http->get("/users/current/subscribers/{$subscriberId}/signals"); + } + + public function externalSignals(string $strategyId): array|string|null + { + return $this->http->get("/users/current/strategies/{$strategyId}/external-signals"); + } + + public function updateExternalSignal(string $strategyId, string $signalId, array $data): array|string|null + { + return $this->http->put("/users/current/strategies/{$strategyId}/external-signals/{$signalId}", $data); + } + + public function removeExternalSignal(string $strategyId, string $signalId, array $data): array|string|null + { + return $this->http->post("/users/current/strategies/{$strategyId}/external-signals/{$signalId}/remove", $data); + } + + public function stopouts(string $subscriberId): array|string|null + { + return $this->http->get("/users/current/subscribers/{$subscriberId}/stopouts"); + } + + public function resetSubscriptionStopouts(string $subscriberId, string $strategyId, string $reason): array|string|null + { + return $this->http->post("/users/current/subscribers/{$subscriberId}/subscription-strategies/{$strategyId}/stopouts/{$reason}/reset"); + } + + public function resetSubscriberStopouts(string $subscriberId, string $reason): array|string|null + { + return $this->http->post("/users/current/subscribers/{$subscriberId}/stopouts/{$reason}/reset"); + } + + public function stopoutsStream(array $query = []): array|string|null + { + return $this->http->get('/users/current/stopouts/stream', $query); + } + + public function resynchronize(string $subscriberId, array $query = []): array|string|null + { + return $this->http->post("/users/current/subscribers/{$subscriberId}/resynchronize", query: $query); + } +} diff --git a/src/Resources/Copyfactory/Webhooks.php b/src/Resources/Copyfactory/Webhooks.php new file mode 100644 index 0000000..e25ce68 --- /dev/null +++ b/src/Resources/Copyfactory/Webhooks.php @@ -0,0 +1,34 @@ +http->get("/{$this->configUrl}/strategies/{$strategyId}/webhooks", $query); + } + + public function create(string $strategyId, array $data): array|string|null + { + return $this->http->post("/{$this->configUrl}/strategies/{$strategyId}/webhooks", $data); + } + + public function update(string $strategyId, string $webhookId, array $data): array|string|null + { + return $this->http->patch("/{$this->configUrl}/strategies/{$strategyId}/webhooks/{$webhookId}", $data); + } + + public function remove(string $strategyId, string $webhookId): array|string|null + { + return $this->http->delete("/{$this->configUrl}/strategies/{$strategyId}/webhooks/{$webhookId}"); + } +} diff --git a/src/Resources/Metastats/Metrics.php b/src/Resources/Metastats/Metrics.php index 2f8f612..401563f 100644 --- a/src/Resources/Metastats/Metrics.php +++ b/src/Resources/Metastats/Metrics.php @@ -2,21 +2,40 @@ namespace Victorycodedev\MetaapiCloudPhpSdk\Resources\Metastats; -trait Metrics +use Victorycodedev\MetaapiCloudPhpSdk\Http; + +class Metrics { + public function __construct(private readonly Http $http) {} + /* * Calculates and returns a MetaApi account metrics. This API call is billable */ - public function metrics(string $accountId, bool $includeOpenPositions = false): array|string + public function metrics(string $accountId, bool $includeOpenPositions = false): array|string|null { - return $this->http->get("/users/current/accounts/{$accountId}/metrics?includeOpenPositions={$includeOpenPositions}"); + return $this->http->get("/users/current/accounts/{$accountId}/metrics", [ + 'includeOpenPositions' => $includeOpenPositions, + ]); + } + + public function historicalTrades(string $accountId, string $startTime, string $endTime, array $query = []): array|string|null + { + $startTime = rawurlencode($startTime); + $endTime = rawurlencode($endTime); + + return $this->http->get("/users/current/accounts/{$accountId}/historical-trades/{$startTime}/{$endTime}", $query); } /* * Returns open trades for MetaApi account. This API call is not billable */ - public function openTrades(string $accountId): array|string + public function openTrades(string $accountId): array|string|null { return $this->http->get("/users/current/accounts/{$accountId}/open-trades"); } + + public function resetMetrics(string $accountId): array|string|null + { + return $this->http->delete("/users/current/accounts/{$accountId}"); + } } diff --git a/src/Resources/Terminal/Credits.php b/src/Resources/Terminal/Credits.php new file mode 100644 index 0000000..1fb5efd --- /dev/null +++ b/src/Resources/Terminal/Credits.php @@ -0,0 +1,17 @@ +http->get("/users/current/accounts/{$accountId}/credits"); + } +} diff --git a/src/Resources/Terminal/History.php b/src/Resources/Terminal/History.php new file mode 100644 index 0000000..7032ea9 --- /dev/null +++ b/src/Resources/Terminal/History.php @@ -0,0 +1,48 @@ +http->get("/users/current/accounts/{$accountId}/history-orders/ticket/" . Path::segment($ticket)); + } + + public function historyOrdersByPosition(string $accountId, string $positionId): array|string|null + { + return $this->http->get("/users/current/accounts/{$accountId}/history-orders/position/" . Path::segment($positionId)); + } + + public function historyOrdersByTimeRange(string $accountId, string $startTime, string $endTime, int $offset = 0, int $limit = 1000): array|string|null + { + return $this->http->get( + "/users/current/accounts/{$accountId}/history-orders/time/" . Path::segment($startTime) . '/' . Path::segment($endTime), + ['offset' => $offset, 'limit' => $limit] + ); + } + + public function dealsByTicket(string $accountId, string $ticket): array|string|null + { + return $this->http->get("/users/current/accounts/{$accountId}/history-deals/ticket/" . Path::segment($ticket)); + } + + public function dealsByPosition(string $accountId, string $positionId): array|string|null + { + return $this->http->get("/users/current/accounts/{$accountId}/history-deals/position/" . Path::segment($positionId)); + } + + public function dealsByTimeRange(string $accountId, string $startTime, string $endTime, int $offset = 0, int $limit = 1000): array|string|null + { + return $this->http->get( + "/users/current/accounts/{$accountId}/history-deals/time/" . Path::segment($startTime) . '/' . Path::segment($endTime), + ['offset' => $offset, 'limit' => $limit] + ); + } +} diff --git a/src/Resources/Terminal/Margin.php b/src/Resources/Terminal/Margin.php new file mode 100644 index 0000000..f6bed5b --- /dev/null +++ b/src/Resources/Terminal/Margin.php @@ -0,0 +1,17 @@ +http->post("/users/current/accounts/{$accountId}/calculate-margin", $order); + } +} diff --git a/src/Resources/Terminal/MarketData.php b/src/Resources/Terminal/MarketData.php new file mode 100644 index 0000000..dbb91c1 --- /dev/null +++ b/src/Resources/Terminal/MarketData.php @@ -0,0 +1,67 @@ +http->get("/users/current/accounts/{$accountId}/symbols"); + } + + public function symbolSpecification(string $accountId, string $symbol): array|string|null + { + return $this->http->get("/users/current/accounts/{$accountId}/symbols/" . Path::segment($symbol) . '/specification'); + } + + public function symbolPrice(string $accountId, string $symbol, bool $keepSubscription = false): array|string|null + { + return $this->http->get("/users/current/accounts/{$accountId}/symbols/" . Path::segment($symbol) . '/current-price', [ + 'keepSubscription' => $keepSubscription, + ]); + } + + public function candle(string $accountId, string $symbol, string $timeframe, bool $keepSubscription = false): array|string|null + { + return $this->http->get( + "/users/current/accounts/{$accountId}/symbols/" . Path::segment($symbol) . '/current-candles/' . Path::segment($timeframe), + ['keepSubscription' => $keepSubscription] + ); + } + + public function tick(string $accountId, string $symbol, bool $keepSubscription = false): array|string|null + { + return $this->http->get("/users/current/accounts/{$accountId}/symbols/" . Path::segment($symbol) . '/current-tick', [ + 'keepSubscription' => $keepSubscription, + ]); + } + + public function orderBook(string $accountId, string $symbol, bool $keepSubscription = false): array|string|null + { + return $this->http->get("/users/current/accounts/{$accountId}/symbols/" . Path::segment($symbol) . '/current-book', [ + 'keepSubscription' => $keepSubscription, + ]); + } + + public function historicalCandles(string $accountId, string $symbol, string $timeframe, ?string $startTime = null, int $limit = 1000): array|string|null + { + return $this->marketDataHttp->get( + "/users/current/accounts/{$accountId}/historical-market-data/symbols/" . Path::segment($symbol) . '/timeframes/' . Path::segment($timeframe) . '/candles', + ['startTime' => $startTime, 'limit' => $limit] + ); + } + + public function historicalTicks(string $accountId, string $symbol, ?string $startTime = null, int $offset = 0, int $limit = 1000): array|string|null + { + return $this->marketDataHttp->get( + "/users/current/accounts/{$accountId}/historical-market-data/symbols/" . Path::segment($symbol) . '/ticks', + ['startTime' => $startTime, 'offset' => $offset, 'limit' => $limit] + ); + } +} diff --git a/src/Resources/Terminal/Path.php b/src/Resources/Terminal/Path.php new file mode 100644 index 0000000..16fa278 --- /dev/null +++ b/src/Resources/Terminal/Path.php @@ -0,0 +1,11 @@ +http->get("/users/current/accounts/{$accountId}/account-information", [ + 'refreshTerminalState' => $refreshTerminalState, + ]); + } + + public function positions(string $accountId, bool $refreshTerminalState = false): array|string|null + { + return $this->http->get("/users/current/accounts/{$accountId}/positions", [ + 'refreshTerminalState' => $refreshTerminalState, + ]); + } + + public function position(string $accountId, string $positionId, bool $refreshTerminalState = false): array|string|null + { + return $this->http->get("/users/current/accounts/{$accountId}/positions/" . Path::segment($positionId), [ + 'refreshTerminalState' => $refreshTerminalState, + ]); + } + + public function orders(string $accountId, bool $refreshTerminalState = false): array|string|null + { + return $this->http->get("/users/current/accounts/{$accountId}/orders", [ + 'refreshTerminalState' => $refreshTerminalState, + ]); + } + + public function order(string $accountId, string $orderId, bool $refreshTerminalState = false): array|string|null + { + return $this->http->get("/users/current/accounts/{$accountId}/orders/" . Path::segment($orderId), [ + 'refreshTerminalState' => $refreshTerminalState, + ]); + } + + public function serverTime(string $accountId): array|string|null + { + return $this->http->get("/users/current/accounts/{$accountId}/server-time"); + } +} diff --git a/src/Resources/Terminal/Trading.php b/src/Resources/Terminal/Trading.php new file mode 100644 index 0000000..176d37f --- /dev/null +++ b/src/Resources/Terminal/Trading.php @@ -0,0 +1,154 @@ +http->post("/users/current/accounts/{$accountId}/trade", $trade); + } + + public function createMarketBuyOrder(string $accountId, string $symbol, float $volume, ?float $stopLoss = null, ?float $takeProfit = null, array $options = []): array|string|null + { + return $this->trade($accountId, array_merge([ + 'actionType' => 'ORDER_TYPE_BUY', + 'symbol' => $symbol, + 'volume' => $volume, + 'stopLoss' => $stopLoss, + 'takeProfit' => $takeProfit, + ], $options)); + } + + public function createMarketSellOrder(string $accountId, string $symbol, float $volume, ?float $stopLoss = null, ?float $takeProfit = null, array $options = []): array|string|null + { + return $this->trade($accountId, array_merge([ + 'actionType' => 'ORDER_TYPE_SELL', + 'symbol' => $symbol, + 'volume' => $volume, + 'stopLoss' => $stopLoss, + 'takeProfit' => $takeProfit, + ], $options)); + } + + public function createLimitBuyOrder(string $accountId, string $symbol, float $volume, float $openPrice, ?float $stopLoss = null, ?float $takeProfit = null, array $options = []): array|string|null + { + return $this->pendingOrder($accountId, 'ORDER_TYPE_BUY_LIMIT', $symbol, $volume, $openPrice, $stopLoss, $takeProfit, $options); + } + + public function createLimitSellOrder(string $accountId, string $symbol, float $volume, float $openPrice, ?float $stopLoss = null, ?float $takeProfit = null, array $options = []): array|string|null + { + return $this->pendingOrder($accountId, 'ORDER_TYPE_SELL_LIMIT', $symbol, $volume, $openPrice, $stopLoss, $takeProfit, $options); + } + + public function createStopBuyOrder(string $accountId, string $symbol, float $volume, float $openPrice, ?float $stopLoss = null, ?float $takeProfit = null, array $options = []): array|string|null + { + return $this->pendingOrder($accountId, 'ORDER_TYPE_BUY_STOP', $symbol, $volume, $openPrice, $stopLoss, $takeProfit, $options); + } + + public function createStopSellOrder(string $accountId, string $symbol, float $volume, float $openPrice, ?float $stopLoss = null, ?float $takeProfit = null, array $options = []): array|string|null + { + return $this->pendingOrder($accountId, 'ORDER_TYPE_SELL_STOP', $symbol, $volume, $openPrice, $stopLoss, $takeProfit, $options); + } + + public function createStopLimitBuyOrder(string $accountId, string $symbol, float $volume, float $openPrice, float $stopLimitPrice, ?float $stopLoss = null, ?float $takeProfit = null, array $options = []): array|string|null + { + return $this->pendingOrder($accountId, 'ORDER_TYPE_BUY_STOP_LIMIT', $symbol, $volume, $openPrice, $stopLoss, $takeProfit, array_merge([ + 'stopLimitPrice' => $stopLimitPrice, + ], $options)); + } + + public function createStopLimitSellOrder(string $accountId, string $symbol, float $volume, float $openPrice, float $stopLimitPrice, ?float $stopLoss = null, ?float $takeProfit = null, array $options = []): array|string|null + { + return $this->pendingOrder($accountId, 'ORDER_TYPE_SELL_STOP_LIMIT', $symbol, $volume, $openPrice, $stopLoss, $takeProfit, array_merge([ + 'stopLimitPrice' => $stopLimitPrice, + ], $options)); + } + + public function modifyPosition(string $accountId, string $positionId, ?float $stopLoss = null, ?float $takeProfit = null, array $options = []): array|string|null + { + return $this->trade($accountId, array_merge([ + 'actionType' => 'POSITION_MODIFY', + 'positionId' => $positionId, + 'stopLoss' => $stopLoss, + 'takeProfit' => $takeProfit, + ], $options)); + } + + public function closePosition(string $accountId, string $positionId, array $options = []): array|string|null + { + return $this->trade($accountId, array_merge([ + 'actionType' => 'POSITION_CLOSE_ID', + 'positionId' => $positionId, + ], $options)); + } + + public function closePositionPartially(string $accountId, string $positionId, float $volume, array $options = []): array|string|null + { + return $this->trade($accountId, array_merge([ + 'actionType' => 'POSITION_PARTIAL', + 'positionId' => $positionId, + 'volume' => $volume, + ], $options)); + } + + public function closePositionsBySymbol(string $accountId, string $symbol, array $options = []): array|string|null + { + return $this->trade($accountId, array_merge([ + 'actionType' => 'POSITIONS_CLOSE_SYMBOL', + 'symbol' => $symbol, + ], $options)); + } + + public function closeBy(string $accountId, string $positionId, string $closeByPositionId, array $options = []): array|string|null + { + return $this->trade($accountId, array_merge([ + 'actionType' => 'POSITION_CLOSE_BY', + 'positionId' => $positionId, + 'closeByPositionId' => $closeByPositionId, + ], $options)); + } + + public function modifyOrder(string $accountId, string $orderId, float $openPrice, ?float $stopLoss = null, ?float $takeProfit = null, array $options = []): array|string|null + { + return $this->trade($accountId, array_merge([ + 'actionType' => 'ORDER_MODIFY', + 'orderId' => $orderId, + 'openPrice' => $openPrice, + 'stopLoss' => $stopLoss, + 'takeProfit' => $takeProfit, + ], $options)); + } + + public function cancelOrder(string $accountId, string $orderId): array|string|null + { + return $this->trade($accountId, [ + 'actionType' => 'ORDER_CANCEL', + 'orderId' => $orderId, + ]); + } + + private function pendingOrder( + string $accountId, + string $actionType, + string $symbol, + float $volume, + float $openPrice, + ?float $stopLoss, + ?float $takeProfit, + array $options + ): array|string|null { + return $this->trade($accountId, array_merge([ + 'actionType' => $actionType, + 'symbol' => $symbol, + 'volume' => $volume, + 'openPrice' => $openPrice, + 'stopLoss' => $stopLoss, + 'takeProfit' => $takeProfit, + ], $options)); + } +} diff --git a/src/TerminalApi.php b/src/TerminalApi.php new file mode 100644 index 0000000..8e5934b --- /dev/null +++ b/src/TerminalApi.php @@ -0,0 +1,76 @@ +http = new Http($this->token, $this->serverUrl, $client); + $this->marketDataHttp = new Http($this->token, $this->marketDataServerUrl, $client); + $this->state = new State($this->http); + $this->history = new History($this->http); + $this->marketData = new MarketData($this->http, $this->marketDataHttp); + $this->margin = new Margin($this->http); + $this->trading = new Trading($this->http); + $this->credits = new Credits($this->http); + } + + public function state(): State + { + return $this->state; + } + + public function history(): History + { + return $this->history; + } + + public function marketData(): MarketData + { + return $this->marketData; + } + + public function margin(): Margin + { + return $this->margin; + } + + public function trading(): Trading + { + return $this->trading; + } + + public function credits(): Credits + { + return $this->credits; + } +} diff --git a/tests/AccountManagementTest.php b/tests/AccountManagementTest.php new file mode 100644 index 0000000..9f8c9a8 --- /dev/null +++ b/tests/AccountManagementTest.php @@ -0,0 +1,62 @@ +accounts()->accounts([ + 'deploymentStatus' => ['deployed'], + 'limit' => 50, + ], apiVersion: 2); + + $request = $history[0]['request']; + + expect($request)->toHaveSentRequest('GET', '/users/current/accounts'); + expect($request->getHeaderLine('api-version'))->toBe('2'); + expect($request->getUri()->getQuery())->toContain('deploymentStatus%5B0%5D=deployed'); + expect($request->getUri()->getQuery())->toContain('limit=50'); +}); + +it('creates accounts with transaction id headers', function (): void { + $history = []; + $metaapi = metaApiClientWithHistory([new Response(201, [], '{"id":"account-id","state":"DEPLOYED"}')], $history); + + $response = $metaapi->accounts()->create(['name' => 'Demo', 'server' => 'ICMarketsSC-Demo'], '12345678901234567890123456789012'); + + expect($response)->toBe(['id' => 'account-id', 'state' => 'DEPLOYED']); + expect($history[0]['request'])->toHaveSentRequest('POST', '/users/current/accounts'); + expect($history[0]['request']->getHeaderLine('transaction-id'))->toBe('12345678901234567890123456789012'); +}); + +it('deletes accounts using the account endpoint', function (): void { + $history = []; + $metaapi = metaApiClientWithHistory([new Response(204)], $history); + + expect($metaapi->accounts()->delete('account-id', true))->toBeNull(); + expect($history[0]['request'])->toHaveSentRequest('DELETE', '/users/current/accounts/account-id'); + expect($history[0]['request']->getUri()->getQuery())->toBe('executeForAllReplicas=true'); +}); + +it('enables account features', function (): void { + $history = []; + $metaapi = metaApiClientWithHistory([new Response(204)], $history); + + $metaapi->accounts()->enableFeatures('account-id', [ + 'metastatsApiEnabled' => true, + 'reliabilityIncreased' => true, + ]); + + expect($history[0]['request'])->toHaveSentRequest('POST', '/users/current/accounts/account-id/enable-account-features'); +}); + +it('creates account replicas', function (): void { + $history = []; + $metaapi = metaApiClientWithHistory([new Response(201, [], '{"id":"replica-id","state":"DEPLOYED"}')], $history); + + $metaapi->accountReplicas()->createReplica('account-id', ['magic' => 123456, 'region' => 'london'], 'transaction-id'); + + expect($history[0]['request'])->toHaveSentRequest('POST', '/users/current/accounts/account-id/replicas'); + expect($history[0]['request']->getHeaderLine('transaction-id'))->toBe('transaction-id'); +}); diff --git a/tests/ArchitectureTest.php b/tests/ArchitectureTest.php new file mode 100644 index 0000000..5ff4484 --- /dev/null +++ b/tests/ArchitectureTest.php @@ -0,0 +1,15 @@ +isTrait())->toBeFalse(); + expect((new ReflectionClass(CopyTrade::class))->isTrait())->toBeFalse(); + expect((new ReflectionClass(Metrics::class))->isTrait())->toBeFalse(); + expect((new ReflectionClass(State::class))->isTrait())->toBeFalse(); + expect((new ReflectionClass(MarketData::class))->isTrait())->toBeFalse(); +}); diff --git a/tests/CopyFactoryTest.php b/tests/CopyFactoryTest.php new file mode 100644 index 0000000..dbbe69a --- /dev/null +++ b/tests/CopyFactoryTest.php @@ -0,0 +1,214 @@ +configuration()->portfolioStrategies(limit: 25, apiVersion: 2); + $copyFactory->configuration()->portfolioStrategy('portfolio-id'); + $copyFactory->configuration()->updatePortfolioStrategy('portfolio-id', ['name' => 'Portfolio']); + $copyFactory->configuration()->removePortfolioStrategy('portfolio-id', ['closePositions' => true]); + $copyFactory->configuration()->removePortfolioStrategyMember('portfolio-id', 'strategy-id'); + + expect($history[0]['request'])->toHaveSentRequest('GET', '/users/current/configuration/portfolio-strategies'); + expect($history[0]['request']->getHeaderLine('api-version'))->toBe('2'); + expect($history[1]['request'])->toHaveSentRequest('GET', '/users/current/configuration/portfolio-strategies/portfolio-id'); + expect($history[2]['request'])->toHaveSentRequest('PUT', '/users/current/configuration/portfolio-strategies/portfolio-id'); + expect($history[3]['request'])->toHaveSentRequest('DELETE', '/users/current/configuration/portfolio-strategies/portfolio-id'); + expect($history[4]['request'])->toHaveSentRequest('DELETE', '/users/current/configuration/portfolio-strategies/portfolio-id/members/strategy-id'); +}); + +it('supports webhook endpoints', function (): void { + $history = []; + $copyFactory = copyFactoryWithHistory([ + new Response(200, [], '[]'), + new Response(201, [], '{"id":"webhook-id"}'), + new Response(204), + new Response(204), + ], $history); + + $copyFactory->webhooks()->list('strategy-id', ['paginationStyle' => 'classic', 'limit' => 10]); + $copyFactory->webhooks()->create('strategy-id', ['magic' => 100]); + $copyFactory->webhooks()->update('strategy-id', 'webhook-id', ['magic' => 101]); + $copyFactory->webhooks()->remove('strategy-id', 'webhook-id'); + + expect($history[0]['request'])->toHaveSentRequest('GET', '/users/current/configuration/strategies/strategy-id/webhooks'); + expect($history[0]['request']->getUri()->getQuery())->toContain('paginationStyle=classic'); + expect($history[1]['request'])->toHaveSentRequest('POST', '/users/current/configuration/strategies/strategy-id/webhooks'); + expect($history[2]['request'])->toHaveSentRequest('PATCH', '/users/current/configuration/strategies/strategy-id/webhooks/webhook-id'); + expect($history[3]['request'])->toHaveSentRequest('DELETE', '/users/current/configuration/strategies/strategy-id/webhooks/webhook-id'); +}); + +it('supports history endpoints', function (): void { + $history = []; + $copyFactory = copyFactoryWithHistory([ + new Response(200, [], '[]'), + new Response(200, [], '[]'), + new Response(200, [], '[]'), + new Response(200, [], '[]'), + ], $history); + + $query = ['from' => '2020-04-20T04:00:00.000Z', 'till' => '2020-04-20T04:30:00.000Z']; + $copyFactory->history()->providedTransactions($query); + $copyFactory->history()->subscriptionTransactions($query); + $copyFactory->history()->strategyTransactionsStream('strategy-id', ['startTime' => '2020-04-20T04:00:00.000Z']); + $copyFactory->history()->subscriberTransactionsStream('subscriber-id', ['limit' => 50]); + + expect($history[0]['request'])->toHaveSentRequest('GET', '/users/current/provided-transactions'); + expect($history[1]['request'])->toHaveSentRequest('GET', '/users/current/subscription-transactions'); + expect($history[2]['request'])->toHaveSentRequest('GET', '/users/current/strategies/strategy-id/transactions/stream'); + expect($history[3]['request'])->toHaveSentRequest('GET', '/users/current/subscribers/subscriber-id/transactions/stream'); +}); + +it('supports trading endpoints', function (): void { + $history = []; + $copyFactory = copyFactoryWithHistory(array_fill(0, 9, new Response(200, [], '[]')), $history); + + $copyFactory->trading()->signals('subscriber-id'); + $copyFactory->trading()->externalSignals('strategy-id'); + $copyFactory->trading()->updateExternalSignal('strategy-id', 'signal-id', ['symbol' => 'EURUSD']); + $copyFactory->trading()->removeExternalSignal('strategy-id', 'signal-id', ['time' => '2020-08-24T00:00:00.000Z']); + $copyFactory->trading()->stopouts('subscriber-id'); + $copyFactory->trading()->resetSubscriptionStopouts('subscriber-id', 'strategy-id', 'day-balance-difference'); + $copyFactory->trading()->resetSubscriberStopouts('subscriber-id', 'day-balance-difference'); + $copyFactory->trading()->stopoutsStream(['subscriberId' => 'subscriber-id']); + $copyFactory->trading()->resynchronize('subscriber-id', ['strategyId' => ['strategy-id']]); + + expect($history[0]['request'])->toHaveSentRequest('GET', '/users/current/subscribers/subscriber-id/signals'); + expect($history[1]['request'])->toHaveSentRequest('GET', '/users/current/strategies/strategy-id/external-signals'); + expect($history[2]['request'])->toHaveSentRequest('PUT', '/users/current/strategies/strategy-id/external-signals/signal-id'); + expect($history[3]['request'])->toHaveSentRequest('POST', '/users/current/strategies/strategy-id/external-signals/signal-id/remove'); + expect($history[4]['request'])->toHaveSentRequest('GET', '/users/current/subscribers/subscriber-id/stopouts'); + expect($history[5]['request'])->toHaveSentRequest('POST', '/users/current/subscribers/subscriber-id/subscription-strategies/strategy-id/stopouts/day-balance-difference/reset'); + expect($history[6]['request'])->toHaveSentRequest('POST', '/users/current/subscribers/subscriber-id/stopouts/day-balance-difference/reset'); + expect($history[7]['request'])->toHaveSentRequest('GET', '/users/current/stopouts/stream'); + expect($history[8]['request'])->toHaveSentRequest('POST', '/users/current/subscribers/subscriber-id/resynchronize'); +}); + +it('supports log endpoints', function (): void { + $history = []; + $copyFactory = copyFactoryWithHistory(array_fill(0, 4, new Response(200, [], '[]')), $history); + + $copyFactory->logs()->userLog('subscriber-id', ['limit' => 10]); + $copyFactory->logs()->userLogStream('subscriber-id', ['startTime' => '2020-04-20T04:00:00.000Z']); + $copyFactory->logs()->strategyLog('strategy-id', ['offset' => 10]); + $copyFactory->logs()->strategyLogStream('strategy-id', ['limit' => 50]); + + expect($history[0]['request'])->toHaveSentRequest('GET', '/users/current/subscribers/subscriber-id/user-log'); + expect($history[1]['request'])->toHaveSentRequest('GET', '/users/current/subscribers/subscriber-id/user-log/stream'); + expect($history[2]['request'])->toHaveSentRequest('GET', '/users/current/strategies/strategy-id/user-log'); + expect($history[3]['request'])->toHaveSentRequest('GET', '/users/current/strategies/strategy-id/user-log/stream'); +}); + +it('configures copy trading with the fast path when strategy id is supplied and validation is disabled', function (): void { + $history = []; + $copyFactory = copyFactoryWithHistory([ + new Response(204), + new Response(204), + ], $history); + + $response = $copyFactory->copyTrade()->configureCopyTrading( + providerAccountId: 'provider-account-id', + subscriberAccountId: 'subscriber-account-id', + strategyId: 'strategy-id', + strategy: ['name' => 'Main strategy'], + subscription: ['multiplier' => 2], + subscriber: ['name' => 'Main subscriber'], + validateAccountRoles: false + ); + + expect($response)->toMatchArray([ + 'strategyId' => 'strategy-id', + 'providerAccountId' => 'provider-account-id', + 'subscriberAccountId' => 'subscriber-account-id', + ]); + expect($history)->toHaveCount(2); + expect($history[0]['request'])->toHaveSentRequest('PUT', '/users/current/configuration/strategies/strategy-id'); + expect($history[1]['request'])->toHaveSentRequest('PUT', '/users/current/configuration/subscribers/subscriber-account-id'); +}); + +it('generates a strategy id without listing strategies by default', function (): void { + $history = []; + $copyFactory = copyFactoryWithHistory([ + new Response(200, [], '{"_id":"provider-account-id","copyFactoryRoles":["PROVIDER"]}'), + new Response(200, [], '{"_id":"subscriber-account-id","copyFactoryRoles":["SUBSCRIBER"]}'), + new Response(200, [], '{"id":"generated-strategy-id"}'), + new Response(204), + new Response(204), + ], $history); + + $response = $copyFactory->copyTrade()->configureCopyTrading( + providerAccountId: 'provider-account-id', + subscriberAccountId: 'subscriber-account-id' + ); + + expect($response['strategyId'])->toBe('generated-strategy-id'); + expect($history)->toHaveCount(5); + expect($history[0]['request'])->toHaveSentRequest('GET', '/users/current/accounts/provider-account-id'); + expect($history[1]['request'])->toHaveSentRequest('GET', '/users/current/accounts/subscriber-account-id'); + expect($history[2]['request'])->toHaveSentRequest('GET', '/users/current/configuration/unused-strategy-id'); + expect($history[3]['request'])->toHaveSentRequest('PUT', '/users/current/configuration/strategies/generated-strategy-id'); + expect($history[4]['request'])->toHaveSentRequest('PUT', '/users/current/configuration/subscribers/subscriber-account-id'); +}); + +it('only lists strategies when reuse is explicitly enabled', function (): void { + $history = []; + $copyFactory = copyFactoryWithHistory([ + new Response(200, [], '{"_id":"provider-account-id","copyFactoryRoles":["PROVIDER"]}'), + new Response(200, [], '{"_id":"subscriber-account-id","copyFactoryRoles":["SUBSCRIBER"]}'), + new Response(200, [], '[{"_id":"existing-strategy-id","accountId":"provider-account-id"}]'), + new Response(204), + new Response(204), + ], $history); + + $response = $copyFactory->copyTrade()->configureCopyTrading( + providerAccountId: 'provider-account-id', + subscriberAccountId: 'subscriber-account-id', + reuseExistingStrategy: true + ); + + expect($response['strategyId'])->toBe('existing-strategy-id'); + expect($history[2]['request'])->toHaveSentRequest('GET', '/users/current/configuration/strategies'); + expect($history[3]['request'])->toHaveSentRequest('PUT', '/users/current/configuration/strategies/existing-strategy-id'); +}); + +it('can skip account reads when account data is supplied', function (): void { + $history = []; + $copyFactory = copyFactoryWithHistory([ + new Response(200, [], '{"id":"generated-strategy-id"}'), + new Response(204), + new Response(204), + ], $history); + + $copyFactory->copyTrade()->configureCopyTrading( + providerAccountId: 'provider-public-id', + subscriberAccountId: 'subscriber-public-id', + providerAccount: ['_id' => 'provider-internal-id', 'copyFactoryRoles' => ['PROVIDER']], + subscriberAccount: ['_id' => 'subscriber-internal-id', 'copyFactoryRoles' => ['SUBSCRIBER']] + ); + + expect($history)->toHaveCount(3); + expect($history[0]['request'])->toHaveSentRequest('GET', '/users/current/configuration/unused-strategy-id'); + expect($history[1]['request'])->toHaveSentRequest('PUT', '/users/current/configuration/strategies/generated-strategy-id'); + expect($history[2]['request'])->toHaveSentRequest('PUT', '/users/current/configuration/subscribers/subscriber-internal-id'); +}); + +it('throws MetaApiException for local copy trading validation errors', function (): void { + $copyFactory = copyFactoryWithHistory([]); + + $copyFactory->copyTrade()->configureCopyTrading( + providerAccountId: 'provider-account-id', + subscriberAccountId: 'subscriber-account-id', + providerAccount: ['_id' => 'provider-internal-id', 'copyFactoryRoles' => []], + subscriberAccount: ['_id' => 'subscriber-internal-id', 'copyFactoryRoles' => ['SUBSCRIBER']] + ); +})->throws(MetaApiException::class, 'Account provider-account-id is not a PROVIDER account'); diff --git a/tests/HttpTest.php b/tests/HttpTest.php new file mode 100644 index 0000000..d7a9090 --- /dev/null +++ b/tests/HttpTest.php @@ -0,0 +1,70 @@ +get('/users/current/accounts/account-id'))->toBe([ + 'id' => 'account-id', + 'state' => 'DEPLOYED', + ]); +}); + +it('returns null for empty success responses', function (): void { + $http = mockHttp([ + new Response(204), + ]); + + expect($http->put('/users/current/accounts/account-id', ['name' => 'Updated']))->toBeNull(); +}); + +it('normalizes boolean query parameters', function (): void { + $history = []; + $http = mockHttp([ + new Response(200, [], '[]'), + ], $history); + + $http->get('/users/current/accounts', [ + 'deploymentStatus' => ['deployed', 'failed'], + 'includeRemoved' => false, + ]); + + $query = $history[0]['request']->getUri()->getQuery(); + + expect($query)->toContain('includeRemoved=false'); + expect($query)->toContain('deploymentStatus%5B0%5D=deployed'); +}); + +it('throws structured MetaApi exceptions', function (): void { + $http = mockHttp([ + new Response(400, [], '{"id":1,"error":"ValidationError","message":"Invalid account","details":{"code":"E_AUTH"}}'), + ]); + + try { + $http->post('/users/current/accounts', ['name' => 'Broken']); + } catch (MetaApiException $exception) { + expect($exception->statusCode())->toBe(400); + expect($exception->errorId())->toBe(1); + expect($exception->errorName())->toBe('ValidationError'); + expect($exception->details())->toBe(['code' => 'E_AUTH']); + expect($exception->getMessage())->toBe('Invalid account'); + + return; + } + + $this->fail('Expected MetaApiException to be thrown.'); +}); + +it('exposes retry-after headers on rate limit errors', function (): void { + $http = mockHttp([ + new Response(429, ['Retry-After' => '60'], '{"id":1,"error":"TooManyRequestsError","message":"Too many requests"}'), + ]); + + expect(fn () => $http->get('/users/current/accounts')) + ->toThrow(MetaApiException::class, 'Too many requests'); +}); diff --git a/tests/MetaApiClientTest.php b/tests/MetaApiClientTest.php new file mode 100644 index 0000000..37a6ed1 --- /dev/null +++ b/tests/MetaApiClientTest.php @@ -0,0 +1,147 @@ +copyFactory(); + + expect($client->accounts())->toBeInstanceOf(Account::class); + expect($client->provisioningProfiles())->toBeInstanceOf(ProvisioningProfile::class); + expect($client->accountReplicas())->toBeInstanceOf(AccountReplica::class); + expect($copyFactory)->toBeInstanceOf(CopyFactory::class); + expect($copyFactory->configuration())->toBeInstanceOf(Configuration::class); + expect($copyFactory->copyTrade())->toBeInstanceOf(CopyTrade::class); + expect($copyFactory->webhooks())->toBeInstanceOf(Webhooks::class); + expect($copyFactory->history())->toBeInstanceOf(History::class); + expect($copyFactory->trading())->toBeInstanceOf(Trading::class); + expect($copyFactory->logs())->toBeInstanceOf(Logs::class); + expect($client->metaStats())->toBeInstanceOf(MetaStats::class); + expect($client->metaStats()->metricResource())->toBeInstanceOf(Metrics::class); + expect($client->terminal())->toBeInstanceOf(TerminalApi::class); + expect($client->terminal()->state())->toBeInstanceOf(State::class); + expect($client->terminal()->marketData())->toBeInstanceOf(MarketData::class); + expect($client->terminal()->margin())->toBeInstanceOf(Margin::class); + expect($client->terminal()->credits())->toBeInstanceOf(Credits::class); +}); + +it('shares injected clients with account resources', function (): void { + $history = []; + $metaapi = metaApiClientWithHistory([new Response(200, [], '[]')], $history); + + $metaapi->accounts()->accounts(); + + expect($history[0]['request'])->toHaveSentRequest('GET', '/users/current/accounts'); +}); + +it('resolves CopyFactory regional urls', function (): void { + $httpClient = new CapturingClient(); + $client = new MetaApiClient('test-token', $httpClient); + + $client->copyFactory(region: 'london')->strategies(); + + expect($httpClient->lastUri)->toBe('https://copyfactory-api-v1.london.agiliumtrade.ai/users/current/configuration/strategies?includeRemoved=false&limit=1000&offset=0'); +}); + +it('normalizes regional names', function (): void { + $httpClient = new CapturingClient(); + $client = new MetaApiClient('test-token', $httpClient); + + $client->copyFactory(region: 'new_york')->strategies(); + + expect($httpClient->lastUri)->toBe('https://copyfactory-api-v1.new-york.agiliumtrade.ai/users/current/configuration/strategies?includeRemoved=false&limit=1000&offset=0'); +}); + +it('allows custom CopyFactory urls for private regions', function (): void { + $httpClient = new CapturingClient(); + $client = new MetaApiClient('test-token', $httpClient); + + $client->copyFactory(serverUrl: 'https://copyfactory-api-v1.tokyo.example.com')->strategies(); + + expect($httpClient->lastUri)->toBe('https://copyfactory-api-v1.tokyo.example.com/users/current/configuration/strategies?includeRemoved=false&limit=1000&offset=0'); +}); + +it('resolves MetaStats regional urls', function (): void { + $httpClient = new CapturingClient(); + $client = new MetaApiClient('test-token', $httpClient); + + $client->metaStats(region: 'london')->openTrades('account-id'); + + expect($httpClient->lastUri)->toBe('https://metastats-api-v1.london.agiliumtrade.ai/users/current/accounts/account-id/open-trades'); +}); + +it('resolves terminal regional urls', function (): void { + $httpClient = new CapturingClient(); + $client = new MetaApiClient('test-token', $httpClient); + + $client->terminal(region: 'london')->state()->accountInformation('account-id'); + + expect($httpClient->lastUri)->toBe('https://mt-client-api-v1.london.agiliumtrade.ai/users/current/accounts/account-id/account-information?refreshTerminalState=false'); +}); + +it('resolves terminal market data urls separately', function (): void { + $httpClient = new CapturingClient(); + $client = new MetaApiClient('test-token', $httpClient); + + $client->terminal(region: 'london')->marketData()->historicalCandles('account-id', 'EURUSD', '1m'); + + expect($httpClient->lastUri)->toBe('https://mt-market-data-client-api-v1.london.agiliumtrade.ai/users/current/accounts/account-id/historical-market-data/symbols/EURUSD/timeframes/1m/candles?limit=1000'); +}); + +class CapturingClient implements ClientInterface +{ + public string $lastUri = ''; + + public function send(RequestInterface $request, array $options = []): ResponseInterface + { + $this->lastUri = (string) $request->getUri(); + + return new Response(200, [], '[]'); + } + + public function sendAsync(RequestInterface $request, array $options = []): PromiseInterface + { + return Create::promiseFor($this->send($request, $options)); + } + + public function request($method, $uri = '', array $options = []): ResponseInterface + { + $query = isset($options['query']) ? '?' . http_build_query($options['query']) : ''; + $this->lastUri = (string) $uri . $query; + + return new Response(200, [], '[]'); + } + + public function requestAsync($method, $uri = '', array $options = []): PromiseInterface + { + return Create::promiseFor($this->request($method, $uri, $options)); + } + + public function getConfig(?string $option = null): mixed + { + return null; + } +} diff --git a/tests/MetaApiTest.php b/tests/MetaApiTest.php deleted file mode 100644 index e73ad4d..0000000 --- a/tests/MetaApiTest.php +++ /dev/null @@ -1,147 +0,0 @@ -token = 'test-token'; - } - - /** @test */ - public function it_can_instantiate_the_accountapi_object(): void - { - $object = new AccountApi($this->token); - - $this->assertTrue(is_object($object)); - } - - /** @test */ - public function it_can_instantiate_the_copyfactory_object(): void - { - $object = new CopyFactory($this->token); - $this->assertTrue(is_object($object)); - } - - /** @test */ - public function it_can_instantiate_the_metatstats_object(): void - { - $object = new MetaStats($this->token); - $this->assertTrue(is_object($object)); - } - - /** @test */ - public function it_can_make_a_get_request(): void - { - // require the vairables from the Variables.php file - require_once 'Variables.php'; - - // Create a mock. - $mock = new MockHandler([ - new Response(200, [], $jsonResponse), - ]); - - $handlerStack = HandlerStack::create($mock); - - $this->client = new Client(['handler' => $handlerStack, 'headers' => [ - 'Accept' => 'application/json', - ], ]); - - $http = new Http($this->token, '', $this->client); - - $response = $http->get("{$this->acntUrl}/users/current/accounts/1eda642a-a9a3-457c-99af-3bc5e8d5c4c9"); - - $this->assertEquals($arrayResonse, $response); - $this->assertIsArray($response); - } - - /** @test */ - public function it_can_make_a_post_request(): void - { - // Create a mock. - $mock = new MockHandler([ - new Response(200, [], '{ - "id": "1eda642a-a9a3-457c-99af-3bc5e8d5c4c9", - "state": "DEPLOYED" - }'), - ]); - - $handlerStack = HandlerStack::create($mock); - - $this->client = new Client(['handler' => $handlerStack, 'headers' => [ - 'Accept' => 'application/json', - ], ]); - - $http = new Http($this->token, '', $this->client); - - $response = $http->post("{$this->acntUrl}/users/current/accounts"); - $this->assertIsArray($response); - } - - /** @test */ - public function it_can_make_a_put_request(): void - { - // Create a mock. - $mock = new MockHandler([ - new Response(200, [], ''), - ]); - - $handlerStack = HandlerStack::create($mock); - - $this->client = new Client(['handler' => $handlerStack, 'headers' => [ - 'Accept' => 'application/json', - ], ]); - - $http = new Http($this->token, '', $this->client); - - $response = $http->put("{$this->acntUrl}/users/current/accounts/1eda642a-a9a3-457c-99af-3bc5e8d5c4c9", [ - 'password' => 'password', - 'name' => 'testAccount', - 'server' => 'ICMarketsSC-Demo', - ]); - - $this->assertArrayHasKey('success', $response); - } - - /** @test */ - public function it_can_make_a_delete_request(): void - { - // Create a mock. - $mock = new MockHandler([ - new Response(200, [], ''), - ]); - - $handlerStack = HandlerStack::create($mock); - - $this->client = new Client(['handler' => $handlerStack, 'headers' => [ - 'Accept' => 'application/json', - ], ]); - - $http = new Http($this->token, '', $this->client); - - $response = $http->delete("{$this->acntUrl}/users/current/accounts/1eda642a-a9a3-457c-99af-3bc5e8d5c4c9"); - - $this->assertArrayHasKey('success', $response); - } -} diff --git a/tests/MetaStatsTest.php b/tests/MetaStatsTest.php new file mode 100644 index 0000000..80baef1 --- /dev/null +++ b/tests/MetaStatsTest.php @@ -0,0 +1,35 @@ +metrics('account-id', true); + $stats->historicalTrades('account-id', '2020-09-08 22:21:36.000', '2020-09-09 22:21:36.000', [ + 'limit' => 100, + 'offset' => 10, + 'updateHistory' => true, + ]); + $stats->openTrades('account-id'); + $stats->resetMetrics('account-id'); + + expect($history[0]['request'])->toHaveSentRequest('GET', '/users/current/accounts/account-id/metrics'); + expect($history[0]['request']->getUri()->getQuery())->toBe('includeOpenPositions=true'); + expect($history[1]['request'])->toHaveSentRequest('GET', '/users/current/accounts/account-id/historical-trades/2020-09-08%2022%3A21%3A36.000/2020-09-09%2022%3A21%3A36.000'); + expect($history[1]['request']->getUri()->getQuery())->toContain('updateHistory=true'); + expect($history[2]['request'])->toHaveSentRequest('GET', '/users/current/accounts/account-id/open-trades'); + expect($history[3]['request'])->toHaveSentRequest('DELETE', '/users/current/accounts/account-id'); +}); + +it('exposes the MetaStats metrics resource', function (): void { + $stats = metaStatsWithHistory([new Response(200, [], '{"openTrades":[]}')]); + + expect($stats->metricResource()->openTrades('account-id'))->toBe(['openTrades' => []]); +}); diff --git a/tests/Pest.php b/tests/Pest.php new file mode 100644 index 0000000..60a56ec --- /dev/null +++ b/tests/Pest.php @@ -0,0 +1,92 @@ +extend('toHaveSentRequest', function (string $method, string $path): void { + $request = $this->value; + + Assert::assertSame($method, $request->getMethod()); + Assert::assertSame($path, $request->getUri()->getPath()); +}); + +function mockHttp(array $responses, array &$history = []): Http +{ + $mock = new MockHandler($responses); + $stack = HandlerStack::create($mock); + $stack->push(Middleware::history($history)); + + return new Http('test-token', 'https://example.test', new Client([ + 'handler' => $stack, + 'http_errors' => false, + ])); +} + +function metaApiClientWithHistory(array $responses, array &$history = []): MetaApiClient +{ + $mock = new MockHandler($responses); + $stack = HandlerStack::create($mock); + $stack->push(Middleware::history($history)); + + $client = new Client([ + 'handler' => $stack, + 'http_errors' => false, + ]); + + return new MetaApiClient('test-token', $client); +} + +function copyFactoryWithHistory(array $responses, array &$history = []): CopyFactory +{ + $mock = new MockHandler($responses); + $stack = HandlerStack::create($mock); + $stack->push(Middleware::history($history)); + + $client = new Client([ + 'handler' => $stack, + 'http_errors' => false, + ]); + + return new CopyFactory('test-token', 'https://copyfactory-api-v1.new-york.agiliumtrade.ai', $client); +} + +function metaStatsWithHistory(array $responses, array &$history = []): MetaStats +{ + $mock = new MockHandler($responses); + $stack = HandlerStack::create($mock); + $stack->push(Middleware::history($history)); + + $client = new Client([ + 'handler' => $stack, + 'http_errors' => false, + ]); + + return new MetaStats('test-token', 'https://metastats-api-v1.new-york.agiliumtrade.ai', $client); +} + +function terminalWithHistory(array $responses, array &$history = []): TerminalApi +{ + $mock = new MockHandler($responses); + $stack = HandlerStack::create($mock); + $stack->push(Middleware::history($history)); + + $client = new Client([ + 'handler' => $stack, + 'http_errors' => false, + ]); + + return new TerminalApi( + 'test-token', + 'https://mt-client-api-v1.new-york.agiliumtrade.ai', + 'https://mt-market-data-client-api-v1.new-york.agiliumtrade.ai', + $client + ); +} diff --git a/tests/ProvisioningProfileTest.php b/tests/ProvisioningProfileTest.php new file mode 100644 index 0000000..295a598 --- /dev/null +++ b/tests/ProvisioningProfileTest.php @@ -0,0 +1,44 @@ +provisioningProfiles()->provisioningProfiles([ + 'version' => 5, + 'status' => 'new', + 'type' => 'mtTerminal', + ], apiVersion: 2); + + $request = $history[0]['request']; + + expect($request)->toHaveSentRequest('GET', '/users/current/provisioning-profiles'); + expect($request->getHeaderLine('api-version'))->toBe('2'); + expect($request->getUri()->getQuery())->toContain('version=5'); + expect($request->getUri()->getQuery())->toContain('status=new'); +}); + +it('creates provisioning profiles', function (): void { + $history = []; + $metaapi = metaApiClientWithHistory([new Response(201, [], '{"id":"profile-id"}')], $history); + + $response = $metaapi->provisioningProfiles()->createProvisioningProfile([ + 'name' => 'ICMarkets', + 'version' => 5, + 'brokerTimezone' => 'EET', + 'brokerDSTSwitchTimezone' => 'EET', + ]); + + expect($response)->toBe(['id' => 'profile-id']); + expect($history[0]['request'])->toHaveSentRequest('POST', '/users/current/provisioning-profiles'); +}); + +it('deletes provisioning profiles', function (): void { + $history = []; + $metaapi = metaApiClientWithHistory([new Response(204)], $history); + + expect($metaapi->provisioningProfiles()->deleteProvisioningProfile('profile-id'))->toBeNull(); + expect($history[0]['request'])->toHaveSentRequest('DELETE', '/users/current/provisioning-profiles/profile-id'); +}); diff --git a/tests/TerminalApiTest.php b/tests/TerminalApiTest.php new file mode 100644 index 0000000..2aa253d --- /dev/null +++ b/tests/TerminalApiTest.php @@ -0,0 +1,127 @@ +state()->accountInformation('account-id', true); + $terminal->state()->positions('account-id'); + $terminal->state()->position('account-id', 'position/id'); + $terminal->state()->orders('account-id'); + $terminal->state()->order('account-id', 'order/id'); + $terminal->state()->serverTime('account-id'); + + expect($history[0]['request'])->toHaveSentRequest('GET', '/users/current/accounts/account-id/account-information'); + expect($history[0]['request']->getUri()->getQuery())->toBe('refreshTerminalState=true'); + expect($history[1]['request'])->toHaveSentRequest('GET', '/users/current/accounts/account-id/positions'); + expect($history[2]['request'])->toHaveSentRequest('GET', '/users/current/accounts/account-id/positions/position%2Fid'); + expect($history[3]['request'])->toHaveSentRequest('GET', '/users/current/accounts/account-id/orders'); + expect($history[4]['request'])->toHaveSentRequest('GET', '/users/current/accounts/account-id/orders/order%2Fid'); + expect($history[5]['request'])->toHaveSentRequest('GET', '/users/current/accounts/account-id/server-time'); +}); + +it('retrieves historical trading data', function (): void { + $history = []; + $terminal = terminalWithHistory([ + new Response(200, [], '[]'), + new Response(200, [], '[]'), + new Response(200, [], '[]'), + new Response(200, [], '[]'), + new Response(200, [], '[]'), + new Response(200, [], '[]'), + ], $history); + + $terminal->history()->historyOrdersByTicket('account-id', 'ticket/id'); + $terminal->history()->historyOrdersByPosition('account-id', 'position/id'); + $terminal->history()->historyOrdersByTimeRange('account-id', '2020-09-08 22:21:36.000', '2020-09-09 22:21:36.000', 10, 100); + $terminal->history()->dealsByTicket('account-id', 'ticket/id'); + $terminal->history()->dealsByPosition('account-id', 'position/id'); + $terminal->history()->dealsByTimeRange('account-id', '2020-09-08 22:21:36.000', '2020-09-09 22:21:36.000'); + + expect($history[0]['request'])->toHaveSentRequest('GET', '/users/current/accounts/account-id/history-orders/ticket/ticket%2Fid'); + expect($history[1]['request'])->toHaveSentRequest('GET', '/users/current/accounts/account-id/history-orders/position/position%2Fid'); + expect($history[2]['request'])->toHaveSentRequest('GET', '/users/current/accounts/account-id/history-orders/time/2020-09-08%2022%3A21%3A36.000/2020-09-09%2022%3A21%3A36.000'); + expect($history[2]['request']->getUri()->getQuery())->toBe('offset=10&limit=100'); + expect($history[3]['request'])->toHaveSentRequest('GET', '/users/current/accounts/account-id/history-deals/ticket/ticket%2Fid'); + expect($history[4]['request'])->toHaveSentRequest('GET', '/users/current/accounts/account-id/history-deals/position/position%2Fid'); + expect($history[5]['request'])->toHaveSentRequest('GET', '/users/current/accounts/account-id/history-deals/time/2020-09-08%2022%3A21%3A36.000/2020-09-09%2022%3A21%3A36.000'); +}); + +it('retrieves market data', function (): void { + $history = []; + $terminal = terminalWithHistory([ + new Response(200, [], '[]'), + new Response(200, [], '{"symbol":"EURUSD"}'), + new Response(200, [], '{"bid":1.1}'), + new Response(200, [], '{"timeframe":"1m"}'), + new Response(200, [], '{"symbol":"EURUSD"}'), + new Response(200, [], '{"bids":[]}'), + new Response(200, [], '[]'), + new Response(200, [], '[]'), + ], $history); + + $terminal->marketData()->symbols('account-id'); + $terminal->marketData()->symbolSpecification('account-id', 'EUR/USD'); + $terminal->marketData()->symbolPrice('account-id', 'EURUSD', true); + $terminal->marketData()->candle('account-id', 'EURUSD', '1m'); + $terminal->marketData()->tick('account-id', 'EURUSD'); + $terminal->marketData()->orderBook('account-id', 'EURUSD'); + $terminal->marketData()->historicalCandles('account-id', 'EURUSD', '1m', '2020-09-08T22:21:36.000Z', 100); + $terminal->marketData()->historicalTicks('account-id', 'EURUSD', '2020-09-08T22:21:36.000Z', 10, 100); + + expect($history[0]['request'])->toHaveSentRequest('GET', '/users/current/accounts/account-id/symbols'); + expect($history[1]['request'])->toHaveSentRequest('GET', '/users/current/accounts/account-id/symbols/EUR%2FUSD/specification'); + expect($history[2]['request'])->toHaveSentRequest('GET', '/users/current/accounts/account-id/symbols/EURUSD/current-price'); + expect($history[2]['request']->getUri()->getQuery())->toBe('keepSubscription=true'); + expect($history[3]['request'])->toHaveSentRequest('GET', '/users/current/accounts/account-id/symbols/EURUSD/current-candles/1m'); + expect($history[4]['request'])->toHaveSentRequest('GET', '/users/current/accounts/account-id/symbols/EURUSD/current-tick'); + expect($history[5]['request'])->toHaveSentRequest('GET', '/users/current/accounts/account-id/symbols/EURUSD/current-book'); + expect($history[6]['request'])->toHaveSentRequest('GET', '/users/current/accounts/account-id/historical-market-data/symbols/EURUSD/timeframes/1m/candles'); + expect($history[6]['request']->getUri()->getHost())->toBe('mt-market-data-client-api-v1.new-york.agiliumtrade.ai'); + expect($history[6]['request']->getUri()->getQuery())->toBe('startTime=2020-09-08T22%3A21%3A36.000Z&limit=100'); + expect($history[7]['request'])->toHaveSentRequest('GET', '/users/current/accounts/account-id/historical-market-data/symbols/EURUSD/ticks'); + expect($history[7]['request']->getUri()->getQuery())->toBe('startTime=2020-09-08T22%3A21%3A36.000Z&offset=10&limit=100'); +}); + +it('calculates margin, trades and retrieves CPU credit usage', function (): void { + $history = []; + $terminal = terminalWithHistory([ + new Response(200, [], '{"margin":10}'), + new Response(200, [], '{"stringCode":"TRADE_RETCODE_DONE"}'), + new Response(200, [], '{"stringCode":"TRADE_RETCODE_DONE"}'), + new Response(200, [], '{"balance":100}'), + ], $history); + + $terminal->margin()->calculate('account-id', [ + 'symbol' => 'GBPUSD', + 'type' => 'ORDER_TYPE_BUY', + 'volume' => 0.1, + 'openPrice' => 1.25, + ]); + $terminal->trading()->trade('account-id', [ + 'actionType' => 'ORDER_TYPE_BUY', + 'symbol' => 'EURUSD', + 'volume' => 0.01, + ]); + $terminal->trading()->createMarketSellOrder('account-id', 'EURUSD', 0.01); + $terminal->credits()->usage('account-id'); + + expect($history[0]['request'])->toHaveSentRequest('POST', '/users/current/accounts/account-id/calculate-margin'); + expect($history[1]['request'])->toHaveSentRequest('POST', '/users/current/accounts/account-id/trade'); + expect($history[2]['request'])->toHaveSentRequest('POST', '/users/current/accounts/account-id/trade'); + expect(json_decode((string) $history[2]['request']->getBody(), true))->toMatchArray([ + 'actionType' => 'ORDER_TYPE_SELL', + 'symbol' => 'EURUSD', + 'volume' => 0.01, + ]); + expect($history[3]['request'])->toHaveSentRequest('GET', '/users/current/accounts/account-id/credits'); +}); diff --git a/tests/Variables.php b/tests/Variables.php deleted file mode 100644 index 4d4f1b0..0000000 --- a/tests/Variables.php +++ /dev/null @@ -1,61 +0,0 @@ - '1eda642a-a9a3-457c-99af-3bc5e8d5c4c9', - 'login' => '50194988', - 'name' => 'mt5a', - 'server' => 'ICMarketsSC-Demo', - 'magic' => 123456, - 'connectionStatus' => 'DISCONNECTED', - 'state' => 'DEPLOYED', - 'type' => 'cloud-g2', - 'accessToken' => 'VreIBlCEmqngArxSIZKBcuoC7tA0RXsOh4FCiwFaQ5dzo4v8HfWJFP2Opr1MSxC3', - 'region' => 'vint-hill', - 'manualTrades' => true, - 'quoteStreamingIntervalInSeconds' => 2.5, - 'tags' => [], - 'reliability' => 'high', - 'baseCurrency' => 'USD', - 'copyFactoryRoles' => [ - 'PROVIDER', - ], - 'resourceSlots' => 1, - 'copyFactoryResourceSlots' => 4, - 'version' => 4, - 'hash' => 18093, - 'primaryReplica' => true, - 'userId' => '7b17e36a88502fd2faae5dd9d2166873', - 'accountReplicas' => [], - 'metastatsHourlyTarificationEnabled' => false, - 'riskManagementApiEnabled' => false, - 'createdAt' => '2022-12-11T09:09:27.454Z', -];