From fad7973f1d0910369ff5e0df411d94b591ae9fee Mon Sep 17 00:00:00 2001 From: Denis Mosolov Date: Mon, 22 Jun 2020 16:56:14 +0300 Subject: [PATCH 1/6] =?UTF-8?q?=D0=A3=D0=B1=D0=B5=D1=80=D0=B8=20=D0=BF?= =?UTF-8?q?=D1=80=D0=BE=D0=B2=D0=B5=D1=80=D0=BA=D1=83=20user=5Fid=20(#38)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Убери проверку user_id --- .env.example | 1 - Makefile | 1 - README.md | 6 +- index.php | 2 - phpunit.xml | 3 - src/Application.php | 10 --- src/Reply/PrivateSkill.php | 31 --------- tests/ApplicationTest.php | 116 ------------------------------- tests/Reply/IntroductionTest.php | 13 ---- tests/Reply/OrdersTest.php | 1 - tests/Reply/RepeatTest.php | 12 ---- tests/Reply/StocksTest.php | 12 ---- 12 files changed, 2 insertions(+), 206 deletions(-) delete mode 100644 src/Reply/PrivateSkill.php delete mode 100644 tests/ApplicationTest.php diff --git a/.env.example b/.env.example index 62fd4a6..ca7a4a7 100644 --- a/.env.example +++ b/.env.example @@ -1,3 +1,2 @@ -SESSION_USER_ID=D7E7XXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXX TINKOFF_OPEN_API_SANDBOX=aaa TINKOFF_OPEN_API_EXCHANGE=bbb \ No newline at end of file diff --git a/Makefile b/Makefile index dedea80..e62a484 100644 --- a/Makefile +++ b/Makefile @@ -37,6 +37,5 @@ create_version: --entrypoint index.main \ --memory 128m \ --execution-timeout 3s \ - --environment="SESSION_USER_ID=${SESSION_USER_ID}" \ --environment="TINKOFF_OPEN_API_EXCHANGE=${TINKOFF_OPEN_API_EXCHANGE}" \ --source-path ./oliver.zip \ No newline at end of file diff --git a/README.md b/README.md index 8a4a5ab..b1695a4 100644 --- a/README.md +++ b/README.md @@ -36,10 +36,6 @@ make test Для тестов используется песочница Tinkoff Invest Open API, если часто запускать тесты, то можно упереться в [ограничения песочницы](https://tinkoffcreditsystems.github.io/invest-openapi/rest/). ## Деплой в Яндекс.Облако -Перед деплоем загляните в файл `.env` в корневой директории проекта и замените идентификатор пользователя Яндекса в `SESSION_USER_ID` на свой. Этот идентификатор используется для аутентификации в навыке, чтобы никто кроме вас не смог запустить ваш навык, зная активационное имя. `SESSION_USER_ID` передаётся в переменную окружения функции в Яндекс.Облаке при запуске `make create_version`. Я не уверен, что это безопасно, поэтому используйте на свой страх и риск. - -Для справки посмотрите session.user.user_id в https://yandex.ru/dev/dialogs/alice/doc/protocol-docpage/#request. - Выпустите [токены OpenAPI](https://tinkoffcreditsystems.github.io/invest-openapi/auth/) для биржи и Sandbox , запишите их в `TINKOFF_OPEN_API_SANDBOX` и `TINKOFF_OPEN_API_EXCHANGE` в файле `.env`. А вот и команда для деплоя кода в Яндекс.Облако. @@ -52,6 +48,8 @@ make create_version ## Навык в Яндекс.Диалоги Справка https://yandex.ru/dev/dialogs/alice/doc/smart-home/start-docpage/ +В поле «Тип доступа» выберите «Приватный». + Не забудьте указать функцию ![Selection_018](https://user-images.githubusercontent.com/3057626/83176044-85456180-a125-11ea-994b-6087a78f42f8.png) diff --git a/index.php b/index.php index 33f35a2..53b73d2 100644 --- a/index.php +++ b/index.php @@ -11,7 +11,6 @@ function main($event, $context): array { - $user_id = $_ENV['SESSION_USER_ID'] ?? ''; $token = $_ENV['TINKOFF_OPEN_API_EXCHANGE'] ?? ''; try { // @todo: на самом деле это лучше запихнуть в Application @@ -34,6 +33,5 @@ function main($event, $context): array $app = new Application(); $app->setClient($client); $app->setEvent($event); - $app->setUserId($user_id); return $app->run(); } \ No newline at end of file diff --git a/phpunit.xml b/phpunit.xml index 29c2f64..242a115 100644 --- a/phpunit.xml +++ b/phpunit.xml @@ -1,8 +1,5 @@ - - tests/ApplicationTest.php - tests/Reply/IntroductionTest.php tests/Reply/RepeatTest.php diff --git a/src/Application.php b/src/Application.php index 84e1f40..fe1ccbb 100644 --- a/src/Application.php +++ b/src/Application.php @@ -4,7 +4,6 @@ namespace Oliver; -use Oliver\Reply\PrivateSkill; use Oliver\Reply\Stocks; use jamesRUS52\TinkoffInvest\TIClient; use Oliver\Reply\Introduction; @@ -56,14 +55,6 @@ public function setClient(TIClient $client): void $this->client = $client; } - /** - * Allowed user id - */ - public function setUserId(string $id): void - { - $this->session_user_id = $id; - } - /** * @see https://yandex.ru/dev/dialogs/alice/doc/protocol-docpage/#response */ @@ -71,7 +62,6 @@ public function run(): array { try { $replies = [ - new PrivateSkill($this->session_user_id), new Introduction(), new Repeat(), new Stocks($this->client), diff --git a/src/Reply/PrivateSkill.php b/src/Reply/PrivateSkill.php deleted file mode 100644 index efc7d2b..0000000 --- a/src/Reply/PrivateSkill.php +++ /dev/null @@ -1,31 +0,0 @@ -session_user_id = $session_user_id; - } - - public function handle(array $event): array - { - $allowed = isset($event['session']['user']['user_id']) && - $event['session']['user']['user_id'] === $this->session_user_id; - if (! $allowed) { - return [ - 'response' => [ - 'text' => 'Это приватный навык. У вас нет доступа. Завершаю сессию.', - 'end_session' => true, - ], - 'version' => '1.0', - ]; - } - return []; - } -} diff --git a/tests/ApplicationTest.php b/tests/ApplicationTest.php deleted file mode 100644 index 1e58130..0000000 --- a/tests/ApplicationTest.php +++ /dev/null @@ -1,116 +0,0 @@ -allowList(['SESSION_USER_ID', 'TINKOFF_OPEN_API_SANDBOX']) - ->make(); - $dotenv = Dotenv::create($repository, __DIR__ . '/../'); - $dotenv->load(); - - $token = $_ENV['TINKOFF_OPEN_API_SANDBOX'] ?? ''; - $client = new TIClient($token, TISiteEnum::SANDBOX); - - $allowed_user_id = $_ENV['SESSION_USER_ID'] ?? ''; - $restricted_user_id = '-'; - - $app = new Application(); - $app->setUserId($restricted_user_id); - $app->setClient($client); - $app->setEvent([ - "meta" => [ - "locale" => "ru-RU", - "timezone" => "Europe/Moscow", - "client_id" => "ru.yandex.searchplugin/5.80 (Samsung Galaxy; Android 4.4)", - "interfaces" => [ - "screen" => [], - "account_linking" => [] - ] - ], - "request" => [ - "command" => "", - "original_utterance" => "", - "type" => "SimpleUtterance", - "markup" => [ - "dangerous_context" => true - ], - "payload" => [], - ], - "session" => [ - "message_id" => 0, - "session_id" => "2eac4854-fce721f3-b845abba-20d60", - "skill_id" => "3ad36498-f5rd-4079-a14b-788652932056", - "user_id" => $allowed_user_id, - "user" => [ - "user_id" => $allowed_user_id, - "access_token" => "AgAAAAAB4vpbAAApoR1oaCd5yR6eiXSHqOGT8dT" - ], - "application" => [ - "application_id" => $allowed_user_id - ], - "new" => true, - ], - "version" => "1.0" - ]); - $result = $app->run(); - $this->assertArrayHasKey('version', $result); - $this->assertArrayHasKey('response', $result); - $this->assertArrayHasKey('text', $result['response']); - $this->assertEquals('Это приватный навык. У вас нет доступа. Завершаю сессию.', $result['response']['text']); - - $app->setUserId($allowed_user_id); - $app->setEvent([ - "meta" => [ - "locale" => "ru-RU", - "timezone" => "Europe/Moscow", - "client_id" => "ru.yandex.searchplugin/5.80 (Samsung Galaxy; Android 4.4)", - "interfaces" => [ - "screen" => [], - "account_linking" => [] - ] - ], - "request" => [ - "command" => "", - "original_utterance" => "", - "type" => "SimpleUtterance", - "markup" => [ - "dangerous_context" => true - ], - "payload" => [], - ], - "session" => [ - "message_id" => 0, - "session_id" => "2eac4854-fce721f3-b845abba-20d60", - "skill_id" => "3ad36498-f5rd-4079-a14b-788652932056", - "user_id" => $allowed_user_id, - "user" => [ - "user_id" => $allowed_user_id, - "access_token" => "AgAAAAAB4vpbAAApoR1oaCd5yR6eiXSHqOGT8dT" - ], - "application" => [ - "application_id" => $allowed_user_id - ], - "new" => true, - ], - "version" => "1.0" - ]); - $result = $app->run(); - $this->assertArrayHasKey('version', $result); - $this->assertArrayHasKey('response', $result); - $this->assertArrayHasKey('text', $result['response']); - $this->assertStringNotContainsStringIgnoringCase('это приватный навык', $result['response']['text']); - } -} diff --git a/tests/Reply/IntroductionTest.php b/tests/Reply/IntroductionTest.php index db155b8..282c401 100644 --- a/tests/Reply/IntroductionTest.php +++ b/tests/Reply/IntroductionTest.php @@ -6,14 +6,12 @@ use PHPUnit\Framework\TestCase; use Oliver\Reply\Introduction; -use Dotenv\Dotenv; final class IntroductionTest extends TestCase { public function testIntro(): void { - $user_id = $_ENV['SESSION_USER_ID'] ?? ''; $event = [ "meta" => [ "locale" => "ru-RU", @@ -34,17 +32,6 @@ public function testIntro(): void "payload" => [], ], "session" => [ - "message_id" => 0, - "session_id" => "2eac4854-fce721f3-b845abba-20d60", - "skill_id" => "3ad36498-f5rd-4079-a14b-788652932056", - "user_id" => $user_id, - "user" => [ - "user_id" => $user_id, - "access_token" => "AgAAAAAB4vpbAAApoR1oaCd5yR6eiXSHqOGT8dT" - ], - "application" => [ - "application_id" => $user_id - ], "new" => true, ], "version" => "1.0" diff --git a/tests/Reply/OrdersTest.php b/tests/Reply/OrdersTest.php index 3afebec..8c741ae 100644 --- a/tests/Reply/OrdersTest.php +++ b/tests/Reply/OrdersTest.php @@ -5,7 +5,6 @@ namespace Oliver; use PHPUnit\Framework\TestCase; -use Dotenv\Dotenv; use jamesRUS52\TinkoffInvest\TIClient; use jamesRUS52\TinkoffInvest\TIOperationEnum; use jamesRUS52\TinkoffInvest\TIAccount; diff --git a/tests/Reply/RepeatTest.php b/tests/Reply/RepeatTest.php index 2b466e0..5368801 100644 --- a/tests/Reply/RepeatTest.php +++ b/tests/Reply/RepeatTest.php @@ -12,7 +12,6 @@ final class RepeatTest extends TestCase public function testIntro(): void { - $user_id = $_ENV['SESSION_USER_ID'] ?? ''; $event = [ "meta" => [ "locale" => "ru-RU", @@ -44,17 +43,6 @@ public function testIntro(): void ], ], "session" => [ - "message_id" => 0, - "session_id" => "2eac4854-fce721f3-b845abba-20d60", - "skill_id" => "3ad36498-f5rd-4079-a14b-788652932056", - "user_id" => $user_id, - "user" => [ - "user_id" => $user_id, - "access_token" => "AgAAAAAB4vpbAAApoR1oaCd5yR6eiXSHqOGT8dT" - ], - "application" => [ - "application_id" => $user_id - ], "new" => true, ], "state" => [ diff --git a/tests/Reply/StocksTest.php b/tests/Reply/StocksTest.php index ccbec12..ed162ed 100644 --- a/tests/Reply/StocksTest.php +++ b/tests/Reply/StocksTest.php @@ -16,7 +16,6 @@ final class StocksTest extends TestCase { public function testSharesTCS(): void { - $user_id = $_ENV['SESSION_USER_ID'] ?? ''; $event = [ "meta" => [ "locale" => "ru-RU", @@ -48,17 +47,6 @@ public function testSharesTCS(): void ], ], "session" => [ - "message_id" => 0, - "session_id" => "2eac4854-fce721f3-b845abba-20d60", - "skill_id" => "3ad36498-f5rd-4079-a14b-788652932056", - "user_id" => $user_id, - "user" => [ - "user_id" => $user_id, - "access_token" => "AgAAAAAB4vpbAAApoR1oaCd5yR6eiXSHqOGT8dT" - ], - "application" => [ - "application_id" => $user_id - ], "new" => true, ], "version" => "1.0" From f1ef7597663b4ff54ca39a02be6413afbe6cd9f2 Mon Sep 17 00:00:00 2001 From: Denis Mosolov Date: Tue, 23 Jun 2020 14:09:33 +0300 Subject: [PATCH 2/6] =?UTF-8?q?=D0=9D=D0=B5=20=D0=B7=D0=B0=D0=B2=D0=B5?= =?UTF-8?q?=D1=80=D1=88=D0=B0=D0=B9=20=D1=81=D0=B5=D1=81=D1=81=D0=B8=D1=8E?= =?UTF-8?q?=20=D0=BF=D1=80=D0=B8=20=D0=BD=D0=B5=D0=B8=D0=B7=D0=B2=D0=B5?= =?UTF-8?q?=D1=81=D1=82=D0=BD=D0=BE=D0=B9=20=D0=BA=D0=BE=D0=BC=D0=B0=D0=BD?= =?UTF-8?q?=D0=B4=D0=B5?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/Application.php | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/Application.php b/src/Application.php index fe1ccbb..89fcd3b 100644 --- a/src/Application.php +++ b/src/Application.php @@ -91,7 +91,7 @@ public function run(): array return [ 'response' => [ 'text' => 'всё хорошо', - 'end_session' => true, + 'end_session' => false, ], 'version' => '1.0', ]; From a16b791e6943e9bd3864a5dc705b1c84212922c3 Mon Sep 17 00:00:00 2001 From: Denis Mosolov Date: Tue, 23 Jun 2020 17:00:21 +0300 Subject: [PATCH 3/6] =?UTF-8?q?=D0=97=D0=B0=D1=8F=D0=B2=D0=BA=D0=B0=20?= =?UTF-8?q?=D0=BD=D0=B0=20=D0=BF=D0=BE=D0=BA=D1=83=D0=BF=D0=BA=D1=83=20?= =?UTF-8?q?=D0=B0=D0=BA=D1=86=D0=B8=D0=B9=20=D0=BF=D0=BE=20=D1=80=D1=8B?= =?UTF-8?q?=D0=BD=D0=BE=D1=87=D0=BD=D0=BE=D0=B9=20=D1=86=D0=B5=D0=BD=D0=B5?= =?UTF-8?q?=20(#40)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Добавь покупку акций по рыночной цене --- README.md | 21 +- intents/entities | 31 +++ intents/market_order/intent | 32 +++ phpunit.xml | 1 + src/Application.php | 2 + src/Reply/MarketOrderBuyStock.php | 304 ++++++++++++++++++++ tests/Reply/MarketOrderBuyStockTest.php | 355 ++++++++++++++++++++++++ tests/intents/market_order/negative | 5 + tests/intents/market_order/positive | 10 + 9 files changed, 759 insertions(+), 2 deletions(-) create mode 100644 intents/entities create mode 100644 intents/market_order/intent create mode 100644 src/Reply/MarketOrderBuyStock.php create mode 100644 tests/Reply/MarketOrderBuyStockTest.php create mode 100644 tests/intents/market_order/negative create mode 100644 tests/intents/market_order/positive diff --git a/README.md b/README.md index b1695a4..f5cdb6e 100644 --- a/README.md +++ b/README.md @@ -54,9 +54,26 @@ make create_version ![Selection_018](https://user-images.githubusercontent.com/3057626/83176044-85456180-a125-11ea-994b-6087a78f42f8.png) ## Руководство пользователя -В ответ на команду «мои акции», Оливер назыавет акции из профиля (по умолчанию) в Тинькофф Инвестиции. Если биржа закрыта, в сообщение будет только тикер и количество акций на счёте. Если биржа открыта, то к тикеру и количеству акций добавляется минимальная и максимальная цена за день. -В ответ на команду «мои заявки», Оливер озвучивает список активных заявок на покупку или продажу акций. +### Покупка акций по рыночной цене + +Чтобы отправить заявку на покупку акций по рыночной цене скажите: «купи 10 лотов НЛМК». + +После этого Оливер попросит подтвердить заявку: «заявка на покупку 10 лотов НЛМК по рыночной цене, тикер NLMK, для подтверждения скажите подтверждаю». + +Если вы подтвердите намерение, то услышите «заявка на покупку 10 лотов НЛМК по рыночной цене создана». + +### Мои активные заявки + +Чтобы узнать информацию об активных заявках, скажите: «мои заявки». + +### Мои акции + +Чтобы узнать информацию об акциях на вашем брокерском счёте, скажите: «мои акции». + +Если биржа закрыта, в сообщение будет только тикер и количество акций на счёте. Если биржа открыта, то к тикеру и количеству акций добавляется минимальная и максимальная цена за день. + +### Вспомогательные команды Если вы что-то не расслышали, то скажите «повтори», и Оливер повторит последнюю фразу. Это работает только внутри сессии. diff --git a/intents/entities b/intents/entities new file mode 100644 index 0000000..4dbcde3 --- /dev/null +++ b/intents/entities @@ -0,0 +1,31 @@ +# чтобы отличать лоты от акций +entity OperationUnit: + values: + lot: + %lemma + лот + share: + %lemma + акция + +# чтобы различать покупку и продажу +entity OperationType: + values: + buy: + %lemma + купи + sell: + %lemma + продай + +# чтобы различать тикеры +entity FIGI: + values: + BBG005DXJS36: + тиньков(банк)? + тинькоф(банк)? + тинькофф(банк)? + ти си эс (груп)? + BBG004S681B4: + нлмк + эн эл эм ка diff --git a/intents/market_order/intent b/intents/market_order/intent new file mode 100644 index 0000000..5dbaaf1 --- /dev/null +++ b/intents/market_order/intent @@ -0,0 +1,32 @@ +# Заявка на покупку или продажу по рыночной цене +# market.order + +slots: + operation: + source: $Operation + type: OperationType + figi: + source: $Stock + type: FIGI + amount: + source: $Amount + type: YANDEX.NUMBER + unit: + source: $Unit + type: OperationUnit +root: + $Operation [$Amount $Unit $Stock $Market?] +$Operation: + $OperationType +$Amount: + $YANDEX.NUMBER +$Unit: + $OperationUnit +$Stock: + $FIGI +$Market: + %lemma + по рынку | по рыночной цене +filler: + %lemma + пожалуйста | алиса | оливер diff --git a/phpunit.xml b/phpunit.xml index 242a115..ca53648 100644 --- a/phpunit.xml +++ b/phpunit.xml @@ -5,6 +5,7 @@ tests/Reply/RepeatTest.php tests/Reply/OrdersTest.php tests/Reply/StocksTest.php + tests/Reply/MarketOrderBuyStockTest.php tests/DeclensionTest.php diff --git a/src/Application.php b/src/Application.php index 89fcd3b..85e222d 100644 --- a/src/Application.php +++ b/src/Application.php @@ -9,6 +9,7 @@ use Oliver\Reply\Introduction; use Oliver\Reply\Orders; use Oliver\Reply\Repeat; +use Oliver\Reply\MarketOrderBuyStock; class Application { @@ -66,6 +67,7 @@ public function run(): array new Repeat(), new Stocks($this->client), new Orders($this->client), + new MarketOrderBuyStock($this->client), ]; foreach ($replies as $reply) { $response = $reply->handle($this->event); diff --git a/src/Reply/MarketOrderBuyStock.php b/src/Reply/MarketOrderBuyStock.php new file mode 100644 index 0000000..c2edf3c --- /dev/null +++ b/src/Reply/MarketOrderBuyStock.php @@ -0,0 +1,304 @@ +client = $client; + } + + public function handle(array $event): array + { + if ($this->order($event)) { + return $this->askForConfirmation($event); + } elseif ($this->confirm($event)) { + return $this->createMarketOrder($event); + } elseif ($this->reject($event)) { + return $this->hint($event); + } elseif ($this->cannotRecognizeConfirmation($event)) { + return $this->askForConfirmation($event); + } else { + return []; + } + } + + private function createMarketOrder(array $event): array + { + $text = ''; + $figi = $event['state']['session']['order_details']['figi'] ?? ''; + $amount = $event['state']['session']['order_details']['amount'] ?? 0; + $amount = intval($amount); + // @todo: validation + try { + $order = $this->client->sendOrder( + $figi, + $amount, + TIOperationEnum::BUY + ); + $text = $this->checkStatus($order); + } catch (TIException $te) { + $text = $this->checkException($te); + } + return [ + 'session_state' => [ + 'text' => $text, + 'context' => [], + 'order_details' => [], + ], + 'response' => [ + 'text' => $text, + 'tts' => $text, + 'end_session' => false, + ], + 'version' => '1.0', + ]; + } + + /** + * @todo: move to a separate class, can be re-used for limit orders + */ + private function checkStatus(TIOrder $order): string + { + switch ($order->getStatus()) { + // [ New, PartiallyFill, Fill, Cancelled, Replaced, PendingCancel, Rejected, PendingReplace, PendingNew ] + case 'New': + return 'заявка на покупку создана,'; + case 'PendingNew': + return 'заявка на покупку отправлена,'; + case 'Rejected': + $text = 'заявка на покупку отклонена системой,'; + // ОШИБКА: (579) Для выбранного финансового инструмента цена должна быть не меньше 126.02 + print $order->getRejectReason() . "\n"; + print $order->getMessage() . "\n"; + if ( + $order->getRejectReason() === 'Unknown' && + preg_match('/ОШИБКА:\s+\(\d+\)/', $order->getMessage()) + ) { + $parts = false; + $parts = preg_split('/ОШИБКА:\s+\(\d+\)/', $order->getMessage()); + if (is_array($parts)) { + $text .= end($parts); + } else { + $text .= 'неизвестная ошибка,'; + } + } + // @todo: Specified security is not found [...] + return $text; + default: + // @todo: add test case + print $order->getStatus() . "\n"; + return 'произошло что-то непонятное, проверьте свои заявки и акции,'; + } + } + + /** + * @todo: move to a separate class, can be re-used for limit orders + */ + private function checkException(TIException $te): string + { + print $te->getMessage() . "\n"; + $text = 'заявка на покупку отклонена системой,'; + // Недостаточно активов для сделки [OrderNotAvailable] + if (preg_match('/\[OrderNotAvailable\]/', $te->getMessage())) { + $text = preg_replace('/\[OrderNotAvailable\]/', '', $te->getMessage()); + if (is_null($text)) { + // @todo: ???? + $text = 'неизвестная ошибка,'; + } + } elseif (preg_match('/\[VALIDATION_ERROR\]/', $te->getMessage())) { + if (preg_match('/has invalid scale/', $te->getMessage())) { + $text .= 'недопустимый шаг цены, узнайте минимальный шаг цены для этого инструмента на бирже,'; + } + } else { + $text = 'ошибка при взаимодействии с биржей, попробуйте создать лимитную заявку позже,'; + } + return $text; + } + + private function askForConfirmation(array $event): array + { + $amount = $event['request']['nlu']['intents']['market.order']['slots']['amount']['value'] ?? + $event['state']['session']['order_details']['amount'] ?? + 0; + $amount = intval($amount); + $figi = $event['request']['nlu']['intents']['market.order']['slots']['figi']['value'] ?? + $event['state']['session']['order_details']['figi'] ?? + 0; + $unit = $event['request']['nlu']['intents']['market.order']['slots']['unit']['value'] ?? + $event['state']['session']['order_details']['unit'] ?? + 0; + // validation + // @todo: move to a separate method? + if ($amount <= 0) { + // @todo: add test case + return $this->replyNegativeValue(); + } + if ($figi == '') { + // @todo: add test case + return $this->replyEmptyFigi(); + } + $instrument = $this->client->getInstrumentByFigi($figi); + if ($unit !== 'lot') { + // @todo: add test case + return $this->replyLotAllowed($instrument); + } + + $text = sprintf('заявка на покупку %s по рыночной цене,', $instrument->getName()); + $text .= sprintf('тикер: %s,', $instrument->getTicker()); + $text .= sprintf('количество лотов: %d,', $amount); + $text .= 'для подтверждения скажите подтверждаю, для отмены скажите нет.'; + return [ + 'session_state' => [ + 'text' => $text, + 'context' => [ + 'market_order_buy_stock', + ], + 'order_details' => [ + 'figi' => $figi, + 'amount' => $amount, + 'unit' => $unit, + 'ticker' => $instrument->getTicker(), // not necessary + 'name' => $instrument->getName(), // not necessary + ], + ], + 'response' => [ + 'text' => $text, + 'tts' => $text, + 'end_session' => false, + ], + 'version' => '1.0', + ]; + } + + private function hint(array $event): array + { + $name = $event['state']['session']['order_details']['name'] ?? 'и название компании'; + $text = 'операция отменена, когда захотить купи акции по рыночной цене,'; + $text .= sprintf('скажите: купи два лота %s,', $name); + return [ + 'session_state' => [ + 'text' => $text, + 'context' => [], + 'order_details' => [], + ], + 'response' => [ + 'text' => $text, + 'tts' => $text, + 'end_session' => false, + ], + 'version' => '1.0', + ]; + } + + private function order(array $event): bool + { + // @todo: validate slots + $operation = $event['request']['nlu']['intents']['market.order']['slots']['operation']['value'] ?? ''; + return $operation === 'buy'; + } + + private function confirm(array $event): bool + { + $confirm = isset($event['request']['nlu']['intents']['YANDEX.CONFIRM']) && + $event['request']['nlu']['intents']['YANDEX.CONFIRM']; + $context = isset($event['state']['session']['context']) && + is_array($event['state']['session']['context']) && + in_array('market_order_buy_stock', $event['state']['session']['context']); + return $confirm && $context; + } + + private function reject(array $event): bool + { + $reject = isset($event['request']['nlu']['intents']['YANDEX.REJECT']) && + $event['request']['nlu']['intents']['YANDEX.REJECT']; + $context = isset($event['state']['session']['context']) && + is_array($event['state']['session']['context']) && + in_array('market_order_buy_stock', $event['state']['session']['context']); + return $reject && $context; + } + + private function cannotRecognizeConfirmation(array $event): bool + { + $confirm = isset($event['request']['nlu']['intents']['YANDEX.CONFIRM']) && + $event['request']['nlu']['intents']['YANDEX.CONFIRM']; + $reject = isset($event['request']['nlu']['intents']['YANDEX.REJECT']) && + $event['request']['nlu']['intents']['YANDEX.REJECT']; + $context = isset($event['state']['session']['context']) && + is_array($event['state']['session']['context']) && + in_array('market_order_buy_stock', $event['state']['session']['context']); + return ! $confirm && ! $reject && $context; + } + + private function replyEmptyFigi(): array + { + $text = 'не могу распознать тикер, повторите, пожалуйста, ещё раз,'; + return [ + 'session_state' => [ + 'text' => $text, + 'context' => [], + 'order_details' => [], + ], + 'response' => [ + 'text' => $text, + 'tts' => $text, + 'end_session' => false, + ], + 'version' => '1.0', + ]; + } + + private function replyNegativeValue(): array + { + $text = 'не могу распознать количество, понимаю только целые числа больше нуля,'; + $text .= 'повторите команду ещё раз,'; + return [ + 'session_state' => [ + 'text' => $text, + 'context' => [], + 'order_details' => [], + ], + 'response' => [ + 'text' => $text, + 'tts' => $text, + 'end_session' => false, + ], + 'version' => '1.0', + ]; + } + + private function replyLotAllowed(TIInstrument $instrument): array + { + $text = 'инструмент продаётся лотами,'; + $text .= sprintf('количество акций в одном лоте: %d,', $instrument->getLot()); + $text .= 'повторите команду ещё раз,'; + $text .= 'вместо акций используйте лоты,'; + return [ + 'session_state' => [ + 'text' => $text, + 'context' => [], + 'order_details' => [], + ], + 'response' => [ + 'text' => $text, + 'tts' => $text, + 'end_session' => false, + ], + 'version' => '1.0', + ]; + } +} diff --git a/tests/Reply/MarketOrderBuyStockTest.php b/tests/Reply/MarketOrderBuyStockTest.php new file mode 100644 index 0000000..f7defda --- /dev/null +++ b/tests/Reply/MarketOrderBuyStockTest.php @@ -0,0 +1,355 @@ +assertArrayHasKey('version', $result); + $this->assertArrayHasKey('response', $result); + $this->assertArrayHasKey('text', $result['response']); + $this->assertArrayHasKey('session_state', $result); + $this->assertArrayHasKey('text', $result['session_state']); + $this->assertArrayHasKey('context', $result['session_state']); + } + + public function testCreateOrder(): void + { + $event = [ + 'session' => [ + 'new' => false + ], + 'request' => [ + 'command' => 'да', + 'original_utterance' => 'да', + 'nlu' => [ + 'tokens' => [ + 'да' + ], + 'entities' => [], + 'intents' => [ + 'YANDEX.CONFIRM' => [ + 'slots' => [] + ] + ], + 'markup' => [ + 'dangerous_context' => false + ], + 'type' => 'SimpleUtterance' + ], + ], + 'state' => [ + 'session' => [ + 'text' => '', + 'context' => [ + 'market_order_buy_stock', + ], + 'order_details' => [ + 'figi' => self::FIGI, + 'amount' => 10, + 'unit' => 'lot', + 'ticker' => 'NLMK', + 'name' => 'НЛМК', + ] + ], + 'user' => [] + ], + 'version' => '1.0' + ]; + + $order = $this->createStub(TIOrder::class); + $order->method('getStatus') + ->willReturn('New'); + $client = $this->createMock(TIClient::class); + $client->expects($this->once()) + ->method('sendOrder') + ->with( + $this->equalTo(self::FIGI), + $this->equalTo(10), + $this->equalTo(TIOperationEnum::BUY), + $this->equalTo(null) // I wonder if it works? + )->willReturn($order); + + $newOrder = new MarketOrderBuyStock($client); + $result = $newOrder->handle($event); + $this->assertStructure($result); + $this->assertNotContains('market_order_buy_stock', $result['session_state']['context']); + $this->assertStringContainsStringIgnoringCase('заявка на покупку', $result['response']['text']); + $this->assertStringContainsStringIgnoringCase('создана', $result['response']['text']); + // @todo: add lot, ticker + // @todo: а что если заявка сразу исполнилась? какой будет статус? + + // @todo: handler errors + } + + public function testAskConfirmation(): void + { + $event = [ + 'session' => [ + 'new' => false + ], + 'request' => [ + 'command' => 'купи 10 лотов нлмк', + 'original_utterance' => 'купи 10 лотов нлмк', + 'nlu' => [ + 'tokens' => [ + 'купи', + '10', + 'лотов', + 'нлмк' + ], + 'entities' => [ + [ + 'type' => 'YANDEX.NUMBER', + 'tokens' => [ + 'start' => 1, + 'end' => 2 + ], + 'value' => 10 + ] + ], + 'intents' => [ + 'market.order' => [ + 'slots' => [ + 'amount' => [ + 'type' => 'YANDEX.NUMBER', + 'tokens' => [ + 'start' => 1, + 'end' => 2 + ], + 'value' => 10 + ], + 'unit' => [ + 'type' => 'OperationUnit', + 'tokens' => [ + 'start' => 2, + 'end' => 3 + ], + 'value' => 'lot' + ], + 'figi' => [ + 'type' => 'FIGI', + 'tokens' => [ + 'start' => 3, + 'end' => 4 + ], + 'value' => self::FIGI + ], + 'operation' => [ + 'type' => 'OperationType', + 'tokens' => [ + 'start' => 0, + 'end' => 1 + ], + 'value' => 'buy' + ] + ] + ] + ] + ], + 'markup' => [ + 'dangerous_context' => false + ], + 'type' => 'SimpleUtterance' + ], + 'state' => [ + 'session' => [ + 'text' => '', + 'context' => [] + ], + 'user' => [] + ], + 'version' => '1.0' + ]; + + $instrument = $this->createStub(TIInstrument::class); + $instrument->method('getName') + ->willReturn('НЛМК'); + $instrument->method('getTicker') + ->willReturn('NLMK'); + $client = $this->createMock(TIClient::class); + $client->expects($this->never()) + ->method('sendOrder'); + $client->method('getInstrumentByFigi') + ->willReturn($instrument); + $newOrder = new MarketOrderBuyStock($client); + $result = $newOrder->handle($event); + $this->assertStructure($result); + $this->assertContains('market_order_buy_stock', $result['session_state']['context']); + $this->assertStringContainsStringIgnoringCase('количество лотов', $result['response']['text']); + $this->assertStringContainsStringIgnoringCase('по рыночной цене', $result['response']['text']); + $this->assertStringContainsStringIgnoringCase('тикер', $result['response']['text']); + $this->assertStringContainsStringIgnoringCase('NLMK', $result['response']['text']); + $this->assertStringContainsStringIgnoringCase('для подтверждения', $result['response']['text']); + $this->assertStringContainsStringIgnoringCase('для отмены', $result['response']['text']); + } + + public function testHint(): void + { + $event = [ + 'session' => [ + 'new' => false + ], + 'request' => [ + 'command' => 'нет', + 'original_utterance' => 'нет', + 'nlu' => [ + 'tokens' => [ + 'нет' + ], + 'entities' => [], + 'intents' => [ + 'YANDEX.REJECT' => [ + 'slots' => [] + ] + ], + 'markup' => [ + 'dangerous_context' => false + ], + 'type' => 'SimpleUtterance' + ], + ], + 'state' => [ + 'session' => [ + 'text' => '', + 'context' => [ + 'market_order_buy_stock', + ], + 'order_details' => [ + 'figi' => self::FIGI, + 'amount' => 10, + 'unit' => 'lot', + 'ticker' => 'NLMK', + 'name' => 'НЛМК', + ] + ], + 'user' => [] + ], + 'version' => '1.0' + ]; + + $client = $this->createMock(TIClient::class); + $client->expects($this->never()) + ->method('sendOrder'); + $newOrder = new MarketOrderBuyStock($client); + $result = $newOrder->handle($event); + $this->assertStructure($result); + $this->assertNotContains('market_order_buy_stock', $result['session_state']['context']); + $this->assertStringContainsStringIgnoringCase('операция отменена', $result['response']['text']); + } + + public function testCannotRecognizeConfirmation(): void + { + $event = [ + 'session' => [ + 'new' => false + ], + 'request' => [ + 'command' => 'ой', + 'original_utterance' => 'ой', + 'nlu' => [ + 'tokens' => [ + 'ой' + ], + 'entities' => [], + 'intents' => [], + 'markup' => [ + 'dangerous_context' => false + ], + 'type' => 'SimpleUtterance' + ], + ], + 'state' => [ + 'session' => [ + 'text' => '', + 'context' => [ + 'market_order_buy_stock', + ], + 'order_details' => [ + 'figi' => self::FIGI, + 'amount' => 10, + 'unit' => 'lot', + 'ticker' => 'NLMK', + 'name' => 'НЛМК', + ] + ], + 'user' => [] + ], + 'version' => '1.0' + ]; + + $instrument = $this->createStub(TIInstrument::class); + $instrument->method('getName') + ->willReturn('НЛМК'); + $instrument->method('getTicker') + ->willReturn('NLMK'); + $client = $this->createMock(TIClient::class); + $client->expects($this->never()) + ->method('sendOrder'); + $client->method('getInstrumentByFigi') + ->willReturn($instrument); + $newOrder = new MarketOrderBuyStock($client); + $result = $newOrder->handle($event); + $this->assertStructure($result); + $this->assertContains('market_order_buy_stock', $result['session_state']['context']); + $this->assertStringContainsStringIgnoringCase('количество лотов', $result['response']['text']); + $this->assertStringContainsStringIgnoringCase('по рыночной цене', $result['response']['text']); + $this->assertStringContainsStringIgnoringCase('тикер', $result['response']['text']); + $this->assertStringContainsStringIgnoringCase('NLMK', $result['response']['text']); + $this->assertStringContainsStringIgnoringCase('для подтверждения', $result['response']['text']); + $this->assertStringContainsStringIgnoringCase('для отмены', $result['response']['text']); + } + + public function testSkip(): void + { + $event = [ + 'session' => [ + 'new' => false + ], + 'request' => [ + 'command' => 'мои акции', + 'original_utterance' => 'мои акции', + 'nlu' => [ + 'tokens' => [ + 'мои', + 'акции' + ], + 'entities' => [], + 'intents' => [], + ], + 'markup' => [ + 'dangerous_context' => false + ], + 'type' => 'SimpleUtterance' + ], + 'state' => [ + 'session' => [ + 'text' => '', + 'context' => [] + ], + 'user' => [] + ], + 'version' => '1.0' + ]; + + $client = $this->createMock(TIClient::class); + $client->expects($this->never()) + ->method('sendOrder'); + $order = new MarketOrderBuyStock($client); + $result = $order->handle($event); + $this->assertIsArray($result); + $this->assertEquals([], $result); + } +} diff --git a/tests/intents/market_order/negative b/tests/intents/market_order/negative new file mode 100644 index 0000000..6ca7a27 --- /dev/null +++ b/tests/intents/market_order/negative @@ -0,0 +1,5 @@ +купи молоко +купи остров +купи билет в кино +возьми кредит в тинькофф +купи яхту у Олега Тинькова diff --git a/tests/intents/market_order/positive b/tests/intents/market_order/positive new file mode 100644 index 0000000..39e4757 --- /dev/null +++ b/tests/intents/market_order/positive @@ -0,0 +1,10 @@ +купи один лот нлмк +купи 10 лотов нлмк +купи 10 лотов нлмк пожалуйста +купи нлмк 10 лотов +купи один лот нлмк +купи 10 лотов нлмк +купи 10 лотов нлмк пожалуйста +купи нлмк 10 лотов +купи нлмк 10 лотов по рыночной цене +купи по рынку 10 лотов нлмк From f8802ed61d8700a2cb41da476db4e4a7efabe052 Mon Sep 17 00:00:00 2001 From: Denis Mosolov Date: Tue, 23 Jun 2020 21:50:42 +0300 Subject: [PATCH 4/6] Fill (#41) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Исправь сообщение после покупки по рынку Добавь ВТБ issue #33 --- intents/entities | 3 +++ src/Reply/MarketOrderBuyStock.php | 4 ++++ tests/Reply/MarketOrderBuyStockTest.php | 5 ++--- 3 files changed, 9 insertions(+), 3 deletions(-) diff --git a/intents/entities b/intents/entities index 4dbcde3..9962497 100644 --- a/intents/entities +++ b/intents/entities @@ -29,3 +29,6 @@ entity FIGI: BBG004S681B4: нлмк эн эл эм ка + BBG004730ZJ9: + (банк)? втб + (банк)? вэ тэ бэ diff --git a/src/Reply/MarketOrderBuyStock.php b/src/Reply/MarketOrderBuyStock.php index c2edf3c..f9912dc 100644 --- a/src/Reply/MarketOrderBuyStock.php +++ b/src/Reply/MarketOrderBuyStock.php @@ -76,6 +76,10 @@ private function checkStatus(TIOrder $order): string { switch ($order->getStatus()) { // [ New, PartiallyFill, Fill, Cancelled, Replaced, PendingCancel, Rejected, PendingReplace, PendingNew ] + case 'Fill': + return 'заявка исполнена,'; // @fixme: $order->getPrice() всегда возвращает null + // @todo: добавь информацию о комиссии брокера + // @todo: добавь информацию о цене case 'New': return 'заявка на покупку создана,'; case 'PendingNew': diff --git a/tests/Reply/MarketOrderBuyStockTest.php b/tests/Reply/MarketOrderBuyStockTest.php index f7defda..b07dff8 100644 --- a/tests/Reply/MarketOrderBuyStockTest.php +++ b/tests/Reply/MarketOrderBuyStockTest.php @@ -71,7 +71,7 @@ public function testCreateOrder(): void $order = $this->createStub(TIOrder::class); $order->method('getStatus') - ->willReturn('New'); + ->willReturn('Fill'); $client = $this->createMock(TIClient::class); $client->expects($this->once()) ->method('sendOrder') @@ -86,8 +86,7 @@ public function testCreateOrder(): void $result = $newOrder->handle($event); $this->assertStructure($result); $this->assertNotContains('market_order_buy_stock', $result['session_state']['context']); - $this->assertStringContainsStringIgnoringCase('заявка на покупку', $result['response']['text']); - $this->assertStringContainsStringIgnoringCase('создана', $result['response']['text']); + $this->assertStringContainsStringIgnoringCase('заявка исполнена', $result['response']['text']); // @todo: add lot, ticker // @todo: а что если заявка сразу исполнилась? какой будет статус? From f99658fd2b7a9ba7b054221ee9e4a75fef3e4e89 Mon Sep 17 00:00:00 2001 From: Denis Mosolov Date: Tue, 23 Jun 2020 22:12:51 +0300 Subject: [PATCH 5/6] =?UTF-8?q?=D0=94=D0=BE=D0=B1=D0=B0=D0=B2=D1=8C=20?= =?UTF-8?q?=D0=B8=D0=BD=D1=82=D0=B5=D0=BD=D1=82=D1=8B=20=D0=9C=D0=BE=D0=B8?= =?UTF-8?q?=20=D0=B0=D0=BA=D1=86=D0=B8=D0=B8=20=D0=B8=20=D0=9C=D0=BE=D0=B8?= =?UTF-8?q?=20=D0=B7=D0=B0=D1=8F=D0=B2=D0=BA=D0=B8=20(#42)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- README.md | 21 +++++++++++++++++++++ intents/my_orders/intent | 8 ++++++++ intents/my_stocks/intent | 8 ++++++++ tests/intents/my_orders/negative | 2 ++ tests/intents/my_orders/positive | 5 +++++ tests/intents/my_stocks/negative | 8 ++++++++ tests/intents/my_stocks/positive | 3 +++ 7 files changed, 55 insertions(+) create mode 100644 intents/my_orders/intent create mode 100644 intents/my_stocks/intent create mode 100644 tests/intents/my_orders/negative create mode 100644 tests/intents/my_orders/positive create mode 100644 tests/intents/my_stocks/negative create mode 100644 tests/intents/my_stocks/positive diff --git a/README.md b/README.md index f5cdb6e..7c6bf1c 100644 --- a/README.md +++ b/README.md @@ -53,6 +53,27 @@ make create_version Не забудьте указать функцию ![Selection_018](https://user-images.githubusercontent.com/3057626/83176044-85456180-a125-11ea-994b-6087a78f42f8.png) +### Сущности + +В настройках навыка выберите подраздел Интенты, найдите Сущности и нажмите Редактировать, появится всплывающее окно с полем ввода. Вставьте содержимое файла `intents/entities` в поле ввода, нажмите Сохранить и закройте окно. + +### Интенты + +В настройках навыка выберите подраздел Интенты, нажмите Создать. В появившемся окне впишите Название: `Заявка на покупку или продажу по рыночной цене`, ID: `market.order`, в поле Грамматика вставьте содержимое файла `intents/market_order/intent`, в поле Положительные тесты вставьте содержимое файла `tests/intents/market_order/positive`, а в поле Отрицательные тесты вставьте содержимое файла `tests/intents/market_order/negative`. Нажмите Сохранить. Первый интент готов. + +По аналогии создайте второй интент. +Название: Мои заявки +ID: my.orders +Грамматика: `intents/my_orders/intent` +Положительные тесты: `tests/intents/my_orders/positive` +Отрицательные тесты: `tests/intents/my_orders/negative` + +Название: Мои акции +ID: my.stocks +Грамматика: `intents/my_stocks/intent` +Положительные тесты: `tests/intents/my_stocks/positive` +Отрицательные тесты: `tests/intents/my_stocks/negative` + ## Руководство пользователя ### Покупка акций по рыночной цене diff --git a/intents/my_orders/intent b/intents/my_orders/intent new file mode 100644 index 0000000..1084a59 --- /dev/null +++ b/intents/my_orders/intent @@ -0,0 +1,8 @@ +# Описание интента "my.orders" +# Эта грамматика позволяет распознать, +# когда пользователь просит список активных заявок +# на брокерском счёте + +root: + мои (активные)? заявки + список (моих)? (активных)? заявок diff --git a/intents/my_stocks/intent b/intents/my_stocks/intent new file mode 100644 index 0000000..b4f9bc6 --- /dev/null +++ b/intents/my_stocks/intent @@ -0,0 +1,8 @@ +# Описание интента "my.stocks" +# Эта грамматика позволяет распознать, +# когда пользователь просит рассказать об акциях +# на своём брокерском счёте + +root: + мои акции + список (моих)? акций diff --git a/tests/intents/my_orders/negative b/tests/intents/my_orders/negative new file mode 100644 index 0000000..3f8c719 --- /dev/null +++ b/tests/intents/my_orders/negative @@ -0,0 +1,2 @@ +список акций +список моих акций \ No newline at end of file diff --git a/tests/intents/my_orders/positive b/tests/intents/my_orders/positive new file mode 100644 index 0000000..16f734b --- /dev/null +++ b/tests/intents/my_orders/positive @@ -0,0 +1,5 @@ +список заявок +список моих заявок +список активных заявок +мои заявки +мои активные заявки \ No newline at end of file diff --git a/tests/intents/my_stocks/negative b/tests/intents/my_stocks/negative new file mode 100644 index 0000000..4c92615 --- /dev/null +++ b/tests/intents/my_stocks/negative @@ -0,0 +1,8 @@ +сколько стоят акции сбербанка +сколько стоит акция сбербанка +акции сбербанка +мой тинькофф +мои яйца +список покупок +баланс +мой баланс \ No newline at end of file diff --git a/tests/intents/my_stocks/positive b/tests/intents/my_stocks/positive new file mode 100644 index 0000000..5c126a4 --- /dev/null +++ b/tests/intents/my_stocks/positive @@ -0,0 +1,3 @@ +мои акции +список акций +список моих акций \ No newline at end of file From 0bb4ae928d1e249fb589e130ac25290dd12fa5e9 Mon Sep 17 00:00:00 2001 From: Denis Mosolov Date: Wed, 24 Jun 2020 11:10:41 +0300 Subject: [PATCH 6/6] =?UTF-8?q?=D0=94=D0=BE=D0=B1=D0=B0=D0=B2=D1=8C=20?= =?UTF-8?q?=D0=BF=D1=80=D0=BE=D0=B4=D0=B0=D0=B6=D1=83=20=D0=B0=D0=BA=D1=86?= =?UTF-8?q?=D0=B8=D0=B9=20=D0=BF=D0=BE=20=D1=80=D1=8B=D0=BD=D0=BE=D1=87?= =?UTF-8?q?=D0=BD=D0=BE=D0=B9=20=D1=86=D0=B5=D0=BD=D0=B5=20(#44)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- README.md | 12 +- phpunit.xml | 1 + src/Application.php | 2 + src/Reply/MarketOrderSellStock.php | 304 ++++++++++++++++++++ tests/Reply/MarketOrderSellStockTest.php | 351 +++++++++++++++++++++++ 5 files changed, 668 insertions(+), 2 deletions(-) create mode 100644 src/Reply/MarketOrderSellStock.php create mode 100644 tests/Reply/MarketOrderSellStockTest.php diff --git a/README.md b/README.md index 7c6bf1c..b568804 100644 --- a/README.md +++ b/README.md @@ -80,9 +80,17 @@ ID: my.stocks Чтобы отправить заявку на покупку акций по рыночной цене скажите: «купи 10 лотов НЛМК». -После этого Оливер попросит подтвердить заявку: «заявка на покупку 10 лотов НЛМК по рыночной цене, тикер NLMK, для подтверждения скажите подтверждаю». +После этого Оливер попросит подтвердить заявку: «заявка на покупку 10 лотов НЛМК по рыночной цене, тикер NLMK, для подтверждения скажите подтверждаю, для отмены скажите нет». -Если вы подтвердите намерение, то услышите «заявка на покупку 10 лотов НЛМК по рыночной цене создана». +Если вы подтвердите намерение, то услышите: «заявка на покупку 10 лотов НЛМК по рыночной цене создана» либо «заявка исполнена». + +### Продажа акций по рыночной цене + +Чтобы отправить заявку на продажу акций по рыночной цене скажите: «продай 10 лотов НЛМК». + +После этого Оливер попросит подтвердить заявку: «заявка на продажу 10 лотов НЛМК по рыночной цене, тикер NLMK, для подтверждения скажите подтверждаю, для отмены скажите нет». + +Если вы подтвердите намерение, то услышите: «заявка на продажу создана» либо «заявка исполнена». ### Мои активные заявки diff --git a/phpunit.xml b/phpunit.xml index ca53648..4a16ab4 100644 --- a/phpunit.xml +++ b/phpunit.xml @@ -6,6 +6,7 @@ tests/Reply/OrdersTest.php tests/Reply/StocksTest.php tests/Reply/MarketOrderBuyStockTest.php + tests/Reply/MarketOrderSellStockTest.php tests/DeclensionTest.php diff --git a/src/Application.php b/src/Application.php index 85e222d..2d5e5f5 100644 --- a/src/Application.php +++ b/src/Application.php @@ -10,6 +10,7 @@ use Oliver\Reply\Orders; use Oliver\Reply\Repeat; use Oliver\Reply\MarketOrderBuyStock; +use Oliver\Reply\MarketOrderSellStock; class Application { @@ -68,6 +69,7 @@ public function run(): array new Stocks($this->client), new Orders($this->client), new MarketOrderBuyStock($this->client), + new MarketOrderSellStock($this->client), ]; foreach ($replies as $reply) { $response = $reply->handle($this->event); diff --git a/src/Reply/MarketOrderSellStock.php b/src/Reply/MarketOrderSellStock.php new file mode 100644 index 0000000..e410b96 --- /dev/null +++ b/src/Reply/MarketOrderSellStock.php @@ -0,0 +1,304 @@ +client = $client; + } + + public function handle(array $event): array + { + if ($this->order($event)) { + return $this->askForConfirmation($event); + } elseif ($this->confirm($event)) { + return $this->createMarketOrder($event); + } elseif ($this->reject($event)) { + return $this->hint($event); + } elseif ($this->cannotRecognizeConfirmation($event)) { + return $this->askForConfirmation($event); + } else { + return []; + } + } + + private function createMarketOrder(array $event): array + { + $text = ''; + $figi = $event['state']['session']['order_details']['figi'] ?? ''; + $amount = $event['state']['session']['order_details']['amount'] ?? 0; + $amount = intval($amount); + // @todo: validation + try { + $order = $this->client->sendOrder( + $figi, + $amount, + TIOperationEnum::SELL + ); + $text = $this->checkStatus($order); + } catch (TIException $te) { + $text = $this->checkException($te); + } + return [ + 'session_state' => [ + 'text' => $text, + 'context' => [], + 'order_details' => [], + ], + 'response' => [ + 'text' => $text, + 'tts' => $text, + 'end_session' => false, + ], + 'version' => '1.0', + ]; + } + + /** + * @todo: move to a separate class, can be re-used for limit orders + */ + private function checkStatus(TIOrder $order): string + { + switch ($order->getStatus()) { + // [ New, PartiallyFill, Fill, Cancelled, Replaced, PendingCancel, Rejected, PendingReplace, PendingNew ] + case 'Fill': + return 'заявка исполнена,'; // @fixme: $order->getPrice() всегда возвращает null + // @todo: добавь информацию о комиссии брокера + // @todo: добавь информацию о цене + case 'New': + return 'заявка на продажу создана,'; + case 'PendingNew': + return 'заявка на продажу отправлена,'; + case 'Rejected': + $text = 'заявка на продажу отклонена системой,'; + print $order->getRejectReason() . "\n"; + print $order->getMessage() . "\n"; + if ( + $order->getRejectReason() === 'Unknown' && + preg_match('/ОШИБКА:\s+\(\d+\)/', $order->getMessage()) + ) { + $parts = false; + $parts = preg_split('/ОШИБКА:\s+\(\d+\)/', $order->getMessage()); + if (is_array($parts)) { + $text .= end($parts); + } else { + $text .= 'неизвестная ошибка,'; + } + } + // @todo: Specified security is not found [...] + return $text; + default: + // @todo: add test case + print $order->getStatus() . "\n"; + return 'произошло что-то непонятное, проверьте свои заявки и акции,'; + } + } + + /** + * @todo: move to a separate class, can be re-used for limit orders + */ + private function checkException(TIException $te): string + { + print $te->getMessage() . "\n"; + $text = 'заявка на продажу отклонена системой,'; + if (preg_match('/\[OrderNotAvailable\]/', $te->getMessage())) { + $text = preg_replace('/\[OrderNotAvailable\]/', '', $te->getMessage()); + if (is_null($text)) { + // @todo: ???? + $text = 'неизвестная ошибка,'; + } + } else { + $text = 'ошибка при взаимодействии с биржей, попробуйте создать лимитную заявку позже,'; + } + return $text; + } + + private function askForConfirmation(array $event): array + { + $amount = $event['request']['nlu']['intents']['market.order']['slots']['amount']['value'] ?? + $event['state']['session']['order_details']['amount'] ?? + 0; + $amount = intval($amount); + $figi = $event['request']['nlu']['intents']['market.order']['slots']['figi']['value'] ?? + $event['state']['session']['order_details']['figi'] ?? + 0; + $unit = $event['request']['nlu']['intents']['market.order']['slots']['unit']['value'] ?? + $event['state']['session']['order_details']['unit'] ?? + 0; + // validation + // @todo: move to a separate method? + if ($amount <= 0) { + // @todo: add test case + return $this->replyNegativeValue(); + } + if ($figi == '') { + // @todo: add test case + return $this->replyEmptyFigi(); + } + $instrument = $this->client->getInstrumentByFigi($figi); + if ($unit !== 'lot') { + // @todo: add test case + return $this->replyLotAllowed($instrument); + } + + $text = sprintf('заявка на продажу %s по рыночной цене,', $instrument->getName()); + $text .= sprintf('тикер: %s,', $instrument->getTicker()); + $text .= sprintf('количество лотов: %d,', $amount); + $text .= 'для подтверждения скажите подтверждаю, для отмены скажите нет.'; + return [ + 'session_state' => [ + 'text' => $text, + 'context' => [ + 'market_order_sell_stock', + ], + 'order_details' => [ + 'figi' => $figi, + 'amount' => $amount, + 'unit' => $unit, + 'ticker' => $instrument->getTicker(), // not necessary + 'name' => $instrument->getName(), // not necessary + ], + ], + 'response' => [ + 'text' => $text, + 'tts' => $text, + 'end_session' => false, + ], + 'version' => '1.0', + ]; + } + + private function hint(array $event): array + { + $name = $event['state']['session']['order_details']['name'] ?? 'и название компании'; + $text = 'операция отменена, когда захотить продать %s по рыночной цене,'; + $text .= sprintf('скажите: продай два лота %s,', $name, $name); + return [ + 'session_state' => [ + 'text' => $text, + 'context' => [], + 'order_details' => [], + ], + 'response' => [ + 'text' => $text, + 'tts' => $text, + 'end_session' => false, + ], + 'version' => '1.0', + ]; + } + + private function order(array $event): bool + { + // @todo: validate slots + $operation = $event['request']['nlu']['intents']['market.order']['slots']['operation']['value'] ?? ''; + return $operation === 'sell'; + } + + private function confirm(array $event): bool + { + $confirm = isset($event['request']['nlu']['intents']['YANDEX.CONFIRM']) && + $event['request']['nlu']['intents']['YANDEX.CONFIRM']; + $context = isset($event['state']['session']['context']) && + is_array($event['state']['session']['context']) && + in_array('market_order_sell_stock', $event['state']['session']['context']); + return $confirm && $context; + } + + private function reject(array $event): bool + { + $reject = isset($event['request']['nlu']['intents']['YANDEX.REJECT']) && + $event['request']['nlu']['intents']['YANDEX.REJECT']; + $context = isset($event['state']['session']['context']) && + is_array($event['state']['session']['context']) && + in_array('market_order_sell_stock', $event['state']['session']['context']); + return $reject && $context; + } + + private function cannotRecognizeConfirmation(array $event): bool + { + $confirm = isset($event['request']['nlu']['intents']['YANDEX.CONFIRM']) && + $event['request']['nlu']['intents']['YANDEX.CONFIRM']; + $reject = isset($event['request']['nlu']['intents']['YANDEX.REJECT']) && + $event['request']['nlu']['intents']['YANDEX.REJECT']; + $context = isset($event['state']['session']['context']) && + is_array($event['state']['session']['context']) && + in_array('market_order_sell_stock', $event['state']['session']['context']); + return ! $confirm && ! $reject && $context; + } + + private function replyEmptyFigi(): array + { + $text = 'не могу распознать тикер, повторите, пожалуйста, ещё раз,'; + return [ + 'session_state' => [ + 'text' => $text, + 'context' => [], + 'order_details' => [], + ], + 'response' => [ + 'text' => $text, + 'tts' => $text, + 'end_session' => false, + ], + 'version' => '1.0', + ]; + } + + private function replyNegativeValue(): array + { + $text = 'не могу распознать количество, понимаю только целые числа больше нуля,'; + $text .= 'повторите команду ещё раз,'; + return [ + 'session_state' => [ + 'text' => $text, + 'context' => [], + 'order_details' => [], + ], + 'response' => [ + 'text' => $text, + 'tts' => $text, + 'end_session' => false, + ], + 'version' => '1.0', + ]; + } + + private function replyLotAllowed(TIInstrument $instrument): array + { + $text = 'инструмент продаётся лотами,'; + $text .= sprintf('количество акций в одном лоте: %d,', $instrument->getLot()); + $text .= 'повторите команду ещё раз,'; + $text .= 'вместо акций используйте лоты,'; + return [ + 'session_state' => [ + 'text' => $text, + 'context' => [], + 'order_details' => [], + ], + 'response' => [ + 'text' => $text, + 'tts' => $text, + 'end_session' => false, + ], + 'version' => '1.0', + ]; + } +} diff --git a/tests/Reply/MarketOrderSellStockTest.php b/tests/Reply/MarketOrderSellStockTest.php new file mode 100644 index 0000000..52b6db9 --- /dev/null +++ b/tests/Reply/MarketOrderSellStockTest.php @@ -0,0 +1,351 @@ +assertArrayHasKey('version', $result); + $this->assertArrayHasKey('response', $result); + $this->assertArrayHasKey('text', $result['response']); + $this->assertArrayHasKey('session_state', $result); + $this->assertArrayHasKey('text', $result['session_state']); + $this->assertArrayHasKey('context', $result['session_state']); + } + + public function testCreateOrder(): void + { + $event = [ + 'session' => [ + 'new' => false + ], + 'request' => [ + 'command' => 'да', + 'original_utterance' => 'да', + 'nlu' => [ + 'tokens' => [ + 'да' + ], + 'entities' => [], + 'intents' => [ + 'YANDEX.CONFIRM' => [ + 'slots' => [] + ] + ], + 'markup' => [ + 'dangerous_context' => false + ], + 'type' => 'SimpleUtterance' + ], + ], + 'state' => [ + 'session' => [ + 'text' => '', + 'context' => [ + 'market_order_sell_stock', + ], + 'order_details' => [ + 'figi' => self::FIGI, + 'amount' => 10, + 'unit' => 'lot', + 'ticker' => 'NLMK', + 'name' => 'НЛМК', + ] + ], + 'user' => [] + ], + 'version' => '1.0' + ]; + + $order = $this->createStub(TIOrder::class); + $order->method('getStatus') + ->willReturn('Fill'); + $client = $this->createMock(TIClient::class); + $client->expects($this->once()) + ->method('sendOrder') + ->with( + $this->equalTo(self::FIGI), + $this->equalTo(10), + $this->equalTo(TIOperationEnum::SELL), + $this->equalTo(null) // I wonder if it works? + )->willReturn($order); + + $newOrder = new MarketOrderSellStock($client); + $result = $newOrder->handle($event); + $this->assertStructure($result); + $this->assertNotContains('market_order_sell_stock', $result['session_state']['context']); + $this->assertStringContainsStringIgnoringCase('заявка исполнена', $result['response']['text']); + } + + public function testAskConfirmation(): void + { + $event = [ + 'session' => [ + 'new' => false + ], + 'request' => [ + 'command' => 'продай 10 лотов нлмк', + 'original_utterance' => 'продай 10 лотов нлмк', + 'nlu' => [ + 'tokens' => [ + 'продай', + '10', + 'лотов', + 'нлмк' + ], + 'entities' => [ + [ + 'type' => 'YANDEX.NUMBER', + 'tokens' => [ + 'start' => 1, + 'end' => 2 + ], + 'value' => 10 + ] + ], + 'intents' => [ + 'market.order' => [ + 'slots' => [ + 'amount' => [ + 'type' => 'YANDEX.NUMBER', + 'tokens' => [ + 'start' => 1, + 'end' => 2 + ], + 'value' => 10 + ], + 'unit' => [ + 'type' => 'OperationUnit', + 'tokens' => [ + 'start' => 2, + 'end' => 3 + ], + 'value' => 'lot' + ], + 'figi' => [ + 'type' => 'FIGI', + 'tokens' => [ + 'start' => 3, + 'end' => 4 + ], + 'value' => self::FIGI + ], + 'operation' => [ + 'type' => 'OperationType', + 'tokens' => [ + 'start' => 0, + 'end' => 1 + ], + 'value' => 'sell' + ] + ] + ] + ] + ], + 'markup' => [ + 'dangerous_context' => false + ], + 'type' => 'SimpleUtterance' + ], + 'state' => [ + 'session' => [ + 'text' => '', + 'context' => [] + ], + 'user' => [] + ], + 'version' => '1.0' + ]; + + $instrument = $this->createStub(TIInstrument::class); + $instrument->method('getName') + ->willReturn('НЛМК'); + $instrument->method('getTicker') + ->willReturn('NLMK'); + $client = $this->createMock(TIClient::class); + $client->expects($this->never()) + ->method('sendOrder'); + $client->method('getInstrumentByFigi') + ->willReturn($instrument); + $newOrder = new MarketOrderSellStock($client); + $result = $newOrder->handle($event); + $this->assertStructure($result); + $this->assertContains('market_order_sell_stock', $result['session_state']['context']); + $this->assertStringContainsStringIgnoringCase('количество лотов', $result['response']['text']); + $this->assertStringContainsStringIgnoringCase('по рыночной цене', $result['response']['text']); + $this->assertStringContainsStringIgnoringCase('тикер', $result['response']['text']); + $this->assertStringContainsStringIgnoringCase('NLMK', $result['response']['text']); + $this->assertStringContainsStringIgnoringCase('для подтверждения', $result['response']['text']); + $this->assertStringContainsStringIgnoringCase('для отмены', $result['response']['text']); + } + + // + public function testHint(): void + { + $event = [ + 'session' => [ + 'new' => false + ], + 'request' => [ + 'command' => 'нет', + 'original_utterance' => 'нет', + 'nlu' => [ + 'tokens' => [ + 'нет' + ], + 'entities' => [], + 'intents' => [ + 'YANDEX.REJECT' => [ + 'slots' => [] + ] + ], + 'markup' => [ + 'dangerous_context' => false + ], + 'type' => 'SimpleUtterance' + ], + ], + 'state' => [ + 'session' => [ + 'text' => '', + 'context' => [ + 'market_order_sell_stock', + ], + 'order_details' => [ + 'figi' => self::FIGI, + 'amount' => 10, + 'unit' => 'lot', + 'ticker' => 'NLMK', + 'name' => 'НЛМК', + ] + ], + 'user' => [] + ], + 'version' => '1.0' + ]; + + $client = $this->createMock(TIClient::class); + $client->expects($this->never()) + ->method('sendOrder'); + $newOrder = new MarketOrderSellStock($client); + $result = $newOrder->handle($event); + $this->assertStructure($result); + $this->assertNotContains('market_order_sell_stock', $result['session_state']['context']); + $this->assertStringContainsStringIgnoringCase('операция отменена', $result['response']['text']); + } + + public function testCannotRecognizeConfirmation(): void + { + $event = [ + 'session' => [ + 'new' => false + ], + 'request' => [ + 'command' => 'ой', + 'original_utterance' => 'ой', + 'nlu' => [ + 'tokens' => [ + 'ой' + ], + 'entities' => [], + 'intents' => [], + 'markup' => [ + 'dangerous_context' => false + ], + 'type' => 'SimpleUtterance' + ], + ], + 'state' => [ + 'session' => [ + 'text' => '', + 'context' => [ + 'market_order_sell_stock', + ], + 'order_details' => [ + 'figi' => self::FIGI, + 'amount' => 10, + 'unit' => 'lot', + 'ticker' => 'NLMK', + 'name' => 'НЛМК', + ] + ], + 'user' => [] + ], + 'version' => '1.0' + ]; + + $instrument = $this->createStub(TIInstrument::class); + $instrument->method('getName') + ->willReturn('НЛМК'); + $instrument->method('getTicker') + ->willReturn('NLMK'); + $client = $this->createMock(TIClient::class); + $client->expects($this->never()) + ->method('sendOrder'); + $client->method('getInstrumentByFigi') + ->willReturn($instrument); + $newOrder = new MarketOrderSellStock($client); + $result = $newOrder->handle($event); + $this->assertStructure($result); + $this->assertContains('market_order_sell_stock', $result['session_state']['context']); + $this->assertStringContainsStringIgnoringCase('количество лотов', $result['response']['text']); + $this->assertStringContainsStringIgnoringCase('по рыночной цене', $result['response']['text']); + $this->assertStringContainsStringIgnoringCase('тикер', $result['response']['text']); + $this->assertStringContainsStringIgnoringCase('NLMK', $result['response']['text']); + $this->assertStringContainsStringIgnoringCase('для подтверждения', $result['response']['text']); + $this->assertStringContainsStringIgnoringCase('для отмены', $result['response']['text']); + } + + public function testSkip(): void + { + $event = [ + 'session' => [ + 'new' => false + ], + 'request' => [ + 'command' => 'мои акции', + 'original_utterance' => 'мои акции', + 'nlu' => [ + 'tokens' => [ + 'мои', + 'акции' + ], + 'entities' => [], + 'intents' => [], + ], + 'markup' => [ + 'dangerous_context' => false + ], + 'type' => 'SimpleUtterance' + ], + 'state' => [ + 'session' => [ + 'text' => '', + 'context' => [] + ], + 'user' => [] + ], + 'version' => '1.0' + ]; + + $client = $this->createMock(TIClient::class); + $client->expects($this->never()) + ->method('sendOrder'); + $order = new MarketOrderSellStock($client); + $result = $order->handle($event); + $this->assertIsArray($result); + $this->assertEquals([], $result); + } +}