diff --git a/README.md b/README.md index 8a4a5ab..056ac6c 100644 --- a/README.md +++ b/README.md @@ -56,9 +56,28 @@ make create_version ![Selection_018](https://user-images.githubusercontent.com/3057626/83176044-85456180-a125-11ea-994b-6087a78f42f8.png) ## Руководство пользователя -В ответ на команду «мои акции», Оливер назыавет акции из профиля (по умолчанию) в Тинькофф Инвестиции. Если биржа закрыта, в сообщение будет только тикер и количество акций на счёте. Если биржа открыта, то к тикеру и количеству акций добавляется минимальная и максимальная цена за день. -В ответ на команду «мои заявки», Оливер озвучивает список активных заявок на покупку или продажу акций. +### Лимитная заявка на покупку акций + +Чтобы отправить лимитную заявку на покупку акций по заданной цене скажите: «купи 10 лотов НЛМК по цене 120 рублей 30 копеек». + +После этого Оливер попросит подтвердить заявку: «заявка на покупку 10 лотов НЛМК, тикер NLMK, по цене 120 рублей 30 копеек за акцию, сумма сделки 1203 рубля плюс комиссия брокера, для подтверждения скажите подтверждаю». + +Если вы подтвержите намерение, то услышите «заявка на покупку 10 лотов НЛМК по цене 120 рублей 30 копеек создана». + +[Видео](https://youtu.be/EjlN4JI23B0) + +### Мои активные заявки + +Чтобы узнать информацию об активных заявках, скажите: «мои заявки». + +### Мои акции + +Чтобы узнать информацию об акциях на вашем брокерском счёте, скажите: «мои акции». + +Если биржа закрыта, в сообщение будет только тикер и количество акций на счёте. Если биржа открыта, то к тикеру и количеству акций добавляется минимальная и максимальная цена за день. + +### Вспомогательные команды Если вы что-то не расслышали, то скажите «повтори», и Оливер повторит последнюю фразу. Это работает только внутри сессии. diff --git a/composer.lock b/composer.lock index 05878b8..f100eee 100644 --- a/composer.lock +++ b/composer.lock @@ -12,12 +12,12 @@ "source": { "type": "git", "url": "https://github.com/denismosolov/NumToText.git", - "reference": "477b386e60c03aab096f9dc42b1489b5139cf17a" + "reference": "5504673d33806cfc72dd9a0cf5414c7c1505ef9f" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/denismosolov/NumToText/zipball/477b386e60c03aab096f9dc42b1489b5139cf17a", - "reference": "477b386e60c03aab096f9dc42b1489b5139cf17a", + "url": "https://api.github.com/repos/denismosolov/NumToText/zipball/5504673d33806cfc72dd9a0cf5414c7c1505ef9f", + "reference": "5504673d33806cfc72dd9a0cf5414c7c1505ef9f", "shasum": "" }, "require": { @@ -40,6 +40,14 @@ { "name": "Aleksejs Ivanovs", "email": "ivanovs.aleksejs@gmail.com" + }, + { + "name": "Denis Mosolov", + "email": "denismosolov@gmail.com" + }, + { + "name": "Viesturs Kavacs", + "email": "kavackys@gmail.com" } ], "description": "Converts numbers to text representation in various languges.", @@ -53,7 +61,7 @@ "support": { "source": "https://github.com/denismosolov/NumToText/tree/master" }, - "time": "2020-06-13T20:56:48+00:00" + "time": "2020-06-19T08:33:21+00:00" }, { "name": "james.rus52/tinkoffinvest", @@ -543,16 +551,16 @@ }, { "name": "phpdocumentor/type-resolver", - "version": "1.1.0", + "version": "1.2.0", "source": { "type": "git", "url": "https://github.com/phpDocumentor/TypeResolver.git", - "reference": "7462d5f123dfc080dfdf26897032a6513644fc95" + "reference": "30441f2752e493c639526b215ed81d54f369d693" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/phpDocumentor/TypeResolver/zipball/7462d5f123dfc080dfdf26897032a6513644fc95", - "reference": "7462d5f123dfc080dfdf26897032a6513644fc95", + "url": "https://api.github.com/repos/phpDocumentor/TypeResolver/zipball/30441f2752e493c639526b215ed81d54f369d693", + "reference": "30441f2752e493c639526b215ed81d54f369d693", "shasum": "" }, "require": { @@ -566,7 +574,7 @@ "type": "library", "extra": { "branch-alias": { - "dev-master": "1.x-dev" + "dev-1.x": "1.x-dev" } }, "autoload": { @@ -585,7 +593,7 @@ } ], "description": "A PSR-5 based resolver of Class names, Types and Structural Element Names", - "time": "2020-02-18T18:59:58+00:00" + "time": "2020-06-19T20:22:09+00:00" }, { "name": "phpoption/phpoption", @@ -787,16 +795,16 @@ }, { "name": "phpunit/php-file-iterator", - "version": "3.0.1", + "version": "3.0.2", "source": { "type": "git", "url": "https://github.com/sebastianbergmann/php-file-iterator.git", - "reference": "4ac5b3e13df14829daa60a2eb4fdd2f2b7d33cf4" + "reference": "eba15e538f2bb3fe018b7bbb47d2fe32d404bfd2" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/sebastianbergmann/php-file-iterator/zipball/4ac5b3e13df14829daa60a2eb4fdd2f2b7d33cf4", - "reference": "4ac5b3e13df14829daa60a2eb4fdd2f2b7d33cf4", + "url": "https://api.github.com/repos/sebastianbergmann/php-file-iterator/zipball/eba15e538f2bb3fe018b7bbb47d2fe32d404bfd2", + "reference": "eba15e538f2bb3fe018b7bbb47d2fe32d404bfd2", "shasum": "" }, "require": { @@ -839,20 +847,20 @@ "type": "github" } ], - "time": "2020-04-18T05:02:12+00:00" + "time": "2020-06-15T12:54:35+00:00" }, { "name": "phpunit/php-invoker", - "version": "3.0.0", + "version": "3.0.1", "source": { "type": "git", "url": "https://github.com/sebastianbergmann/php-invoker.git", - "reference": "7579d5a1ba7f3ac11c80004d205877911315ae7a" + "reference": "62f696ad0d140e0e513e69eaafdebb674d622b4c" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/sebastianbergmann/php-invoker/zipball/7579d5a1ba7f3ac11c80004d205877911315ae7a", - "reference": "7579d5a1ba7f3ac11c80004d205877911315ae7a", + "url": "https://api.github.com/repos/sebastianbergmann/php-invoker/zipball/62f696ad0d140e0e513e69eaafdebb674d622b4c", + "reference": "62f696ad0d140e0e513e69eaafdebb674d622b4c", "shasum": "" }, "require": { @@ -892,25 +900,34 @@ "keywords": [ "process" ], - "time": "2020-02-07T06:06:11+00:00" + "funding": [ + { + "url": "https://github.com/sebastianbergmann", + "type": "github" + } + ], + "time": "2020-06-15T13:10:07+00:00" }, { "name": "phpunit/php-text-template", - "version": "2.0.0", + "version": "2.0.1", "source": { "type": "git", "url": "https://github.com/sebastianbergmann/php-text-template.git", - "reference": "526dc996cc0ebdfa428cd2dfccd79b7b53fee346" + "reference": "0c69cbf965d5317ba33f24a352539f354a25db09" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/sebastianbergmann/php-text-template/zipball/526dc996cc0ebdfa428cd2dfccd79b7b53fee346", - "reference": "526dc996cc0ebdfa428cd2dfccd79b7b53fee346", + "url": "https://api.github.com/repos/sebastianbergmann/php-text-template/zipball/0c69cbf965d5317ba33f24a352539f354a25db09", + "reference": "0c69cbf965d5317ba33f24a352539f354a25db09", "shasum": "" }, "require": { "php": "^7.3" }, + "require-dev": { + "phpunit/phpunit": "^9.0" + }, "type": "library", "extra": { "branch-alias": { @@ -938,7 +955,13 @@ "keywords": [ "template" ], - "time": "2020-02-01T07:43:44+00:00" + "funding": [ + { + "url": "https://github.com/sebastianbergmann", + "type": "github" + } + ], + "time": "2020-06-15T12:52:43+00:00" }, { "name": "phpunit/php-timer", @@ -997,16 +1020,16 @@ }, { "name": "phpunit/php-token-stream", - "version": "4.0.1", + "version": "4.0.2", "source": { "type": "git", "url": "https://github.com/sebastianbergmann/php-token-stream.git", - "reference": "cdc0db5aed8fbfaf475fbd95bfd7bab83c7a779c" + "reference": "e61c593e9734b47ef462340c24fca8d6a57da14e" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/sebastianbergmann/php-token-stream/zipball/cdc0db5aed8fbfaf475fbd95bfd7bab83c7a779c", - "reference": "cdc0db5aed8fbfaf475fbd95bfd7bab83c7a779c", + "url": "https://api.github.com/repos/sebastianbergmann/php-token-stream/zipball/e61c593e9734b47ef462340c24fca8d6a57da14e", + "reference": "e61c593e9734b47ef462340c24fca8d6a57da14e", "shasum": "" }, "require": { @@ -1048,7 +1071,7 @@ "type": "github" } ], - "time": "2020-05-06T09:56:31+00:00" + "time": "2020-06-16T07:00:44+00:00" }, { "name": "phpunit/phpunit", @@ -1150,16 +1173,16 @@ }, { "name": "sebastian/code-unit", - "version": "1.0.2", + "version": "1.0.3", "source": { "type": "git", "url": "https://github.com/sebastianbergmann/code-unit.git", - "reference": "ac958085bc19fcd1d36425c781ef4cbb5b06e2a5" + "reference": "d650ef9b1fece15ed4d6eaed6e6b469b7b81183a" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/sebastianbergmann/code-unit/zipball/ac958085bc19fcd1d36425c781ef4cbb5b06e2a5", - "reference": "ac958085bc19fcd1d36425c781ef4cbb5b06e2a5", + "url": "https://api.github.com/repos/sebastianbergmann/code-unit/zipball/d650ef9b1fece15ed4d6eaed6e6b469b7b81183a", + "reference": "d650ef9b1fece15ed4d6eaed6e6b469b7b81183a", "shasum": "" }, "require": { @@ -1198,20 +1221,20 @@ "type": "github" } ], - "time": "2020-04-30T05:58:10+00:00" + "time": "2020-06-15T13:11:26+00:00" }, { "name": "sebastian/code-unit-reverse-lookup", - "version": "2.0.0", + "version": "2.0.1", "source": { "type": "git", "url": "https://github.com/sebastianbergmann/code-unit-reverse-lookup.git", - "reference": "5b5dbe0044085ac41df47e79d34911a15b96d82e" + "reference": "c771130f0e8669104a4320b7101a81c2cc2963ef" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/sebastianbergmann/code-unit-reverse-lookup/zipball/5b5dbe0044085ac41df47e79d34911a15b96d82e", - "reference": "5b5dbe0044085ac41df47e79d34911a15b96d82e", + "url": "https://api.github.com/repos/sebastianbergmann/code-unit-reverse-lookup/zipball/c771130f0e8669104a4320b7101a81c2cc2963ef", + "reference": "c771130f0e8669104a4320b7101a81c2cc2963ef", "shasum": "" }, "require": { @@ -1243,20 +1266,26 @@ ], "description": "Looks up which function or method a line of code belongs to", "homepage": "https://github.com/sebastianbergmann/code-unit-reverse-lookup/", - "time": "2020-02-07T06:20:13+00:00" + "funding": [ + { + "url": "https://github.com/sebastianbergmann", + "type": "github" + } + ], + "time": "2020-06-15T12:56:39+00:00" }, { "name": "sebastian/comparator", - "version": "4.0.0", + "version": "4.0.2", "source": { "type": "git", "url": "https://github.com/sebastianbergmann/comparator.git", - "reference": "85b3435da967696ed618ff745f32be3ff4a2b8e8" + "reference": "266d85ef789da8c41f06af4093c43e9798af2784" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/sebastianbergmann/comparator/zipball/85b3435da967696ed618ff745f32be3ff4a2b8e8", - "reference": "85b3435da967696ed618ff745f32be3ff4a2b8e8", + "url": "https://api.github.com/repos/sebastianbergmann/comparator/zipball/266d85ef789da8c41f06af4093c43e9798af2784", + "reference": "266d85ef789da8c41f06af4093c43e9798af2784", "shasum": "" }, "require": { @@ -1307,7 +1336,13 @@ "compare", "equality" ], - "time": "2020-02-07T06:08:51+00:00" + "funding": [ + { + "url": "https://github.com/sebastianbergmann", + "type": "github" + } + ], + "time": "2020-06-15T15:04:48+00:00" }, { "name": "sebastian/diff", @@ -1373,16 +1408,16 @@ }, { "name": "sebastian/environment", - "version": "5.1.0", + "version": "5.1.1", "source": { "type": "git", "url": "https://github.com/sebastianbergmann/environment.git", - "reference": "c753f04d68cd489b6973cf9b4e505e191af3b05c" + "reference": "16eb0fa43e29c33d7f2117ed23072e26fc5ab34e" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/sebastianbergmann/environment/zipball/c753f04d68cd489b6973cf9b4e505e191af3b05c", - "reference": "c753f04d68cd489b6973cf9b4e505e191af3b05c", + "url": "https://api.github.com/repos/sebastianbergmann/environment/zipball/16eb0fa43e29c33d7f2117ed23072e26fc5ab34e", + "reference": "16eb0fa43e29c33d7f2117ed23072e26fc5ab34e", "shasum": "" }, "require": { @@ -1428,20 +1463,20 @@ "type": "github" } ], - "time": "2020-04-14T13:36:52+00:00" + "time": "2020-06-15T13:00:01+00:00" }, { "name": "sebastian/exporter", - "version": "4.0.0", + "version": "4.0.1", "source": { "type": "git", "url": "https://github.com/sebastianbergmann/exporter.git", - "reference": "80c26562e964016538f832f305b2286e1ec29566" + "reference": "d12fbca85da932d01d941b59e4b71a0d559db091" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/sebastianbergmann/exporter/zipball/80c26562e964016538f832f305b2286e1ec29566", - "reference": "80c26562e964016538f832f305b2286e1ec29566", + "url": "https://api.github.com/repos/sebastianbergmann/exporter/zipball/d12fbca85da932d01d941b59e4b71a0d559db091", + "reference": "d12fbca85da932d01d941b59e4b71a0d559db091", "shasum": "" }, "require": { @@ -1495,7 +1530,13 @@ "export", "exporter" ], - "time": "2020-02-07T06:10:52+00:00" + "funding": [ + { + "url": "https://github.com/sebastianbergmann", + "type": "github" + } + ], + "time": "2020-06-15T13:12:44+00:00" }, { "name": "sebastian/global-state", @@ -1553,16 +1594,16 @@ }, { "name": "sebastian/object-enumerator", - "version": "4.0.0", + "version": "4.0.1", "source": { "type": "git", "url": "https://github.com/sebastianbergmann/object-enumerator.git", - "reference": "e67516b175550abad905dc952f43285957ef4363" + "reference": "15f319d67c49fc55ebcdbffb3377433125588455" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/sebastianbergmann/object-enumerator/zipball/e67516b175550abad905dc952f43285957ef4363", - "reference": "e67516b175550abad905dc952f43285957ef4363", + "url": "https://api.github.com/repos/sebastianbergmann/object-enumerator/zipball/15f319d67c49fc55ebcdbffb3377433125588455", + "reference": "15f319d67c49fc55ebcdbffb3377433125588455", "shasum": "" }, "require": { @@ -1596,20 +1637,26 @@ ], "description": "Traverses array structures and object graphs to enumerate all referenced objects", "homepage": "https://github.com/sebastianbergmann/object-enumerator/", - "time": "2020-02-07T06:12:23+00:00" + "funding": [ + { + "url": "https://github.com/sebastianbergmann", + "type": "github" + } + ], + "time": "2020-06-15T13:15:25+00:00" }, { "name": "sebastian/object-reflector", - "version": "2.0.0", + "version": "2.0.1", "source": { "type": "git", "url": "https://github.com/sebastianbergmann/object-reflector.git", - "reference": "f4fd0835cabb0d4a6546d9fe291e5740037aa1e7" + "reference": "14e04b3c25b821cc0702d4837803fe497680b062" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/sebastianbergmann/object-reflector/zipball/f4fd0835cabb0d4a6546d9fe291e5740037aa1e7", - "reference": "f4fd0835cabb0d4a6546d9fe291e5740037aa1e7", + "url": "https://api.github.com/repos/sebastianbergmann/object-reflector/zipball/14e04b3c25b821cc0702d4837803fe497680b062", + "reference": "14e04b3c25b821cc0702d4837803fe497680b062", "shasum": "" }, "require": { @@ -1641,20 +1688,26 @@ ], "description": "Allows reflection of object attributes, including inherited and non-public ones", "homepage": "https://github.com/sebastianbergmann/object-reflector/", - "time": "2020-02-07T06:19:40+00:00" + "funding": [ + { + "url": "https://github.com/sebastianbergmann", + "type": "github" + } + ], + "time": "2020-06-15T13:08:02+00:00" }, { "name": "sebastian/recursion-context", - "version": "4.0.0", + "version": "4.0.1", "source": { "type": "git", "url": "https://github.com/sebastianbergmann/recursion-context.git", - "reference": "cdd86616411fc3062368b720b0425de10bd3d579" + "reference": "a32789e5f0157c10cf216ce6c5136db12a12b847" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/sebastianbergmann/recursion-context/zipball/cdd86616411fc3062368b720b0425de10bd3d579", - "reference": "cdd86616411fc3062368b720b0425de10bd3d579", + "url": "https://api.github.com/repos/sebastianbergmann/recursion-context/zipball/a32789e5f0157c10cf216ce6c5136db12a12b847", + "reference": "a32789e5f0157c10cf216ce6c5136db12a12b847", "shasum": "" }, "require": { @@ -1694,20 +1747,26 @@ ], "description": "Provides functionality to recursively process PHP variables", "homepage": "http://www.github.com/sebastianbergmann/recursion-context", - "time": "2020-02-07T06:18:20+00:00" + "funding": [ + { + "url": "https://github.com/sebastianbergmann", + "type": "github" + } + ], + "time": "2020-06-15T13:06:44+00:00" }, { "name": "sebastian/resource-operations", - "version": "3.0.0", + "version": "3.0.1", "source": { "type": "git", "url": "https://github.com/sebastianbergmann/resource-operations.git", - "reference": "8c98bf0dfa1f9256d0468b9803a1e1df31b6fa98" + "reference": "71421c1745788de4facae1b79af923650bd3ec15" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/sebastianbergmann/resource-operations/zipball/8c98bf0dfa1f9256d0468b9803a1e1df31b6fa98", - "reference": "8c98bf0dfa1f9256d0468b9803a1e1df31b6fa98", + "url": "https://api.github.com/repos/sebastianbergmann/resource-operations/zipball/71421c1745788de4facae1b79af923650bd3ec15", + "reference": "71421c1745788de4facae1b79af923650bd3ec15", "shasum": "" }, "require": { @@ -1739,7 +1798,13 @@ ], "description": "Provides a list of PHP built-in functions that operate on resources", "homepage": "https://www.github.com/sebastianbergmann/resource-operations", - "time": "2020-02-07T06:13:02+00:00" + "funding": [ + { + "url": "https://github.com/sebastianbergmann", + "type": "github" + } + ], + "time": "2020-06-15T13:17:14+00:00" }, { "name": "sebastian/type", @@ -2226,16 +2291,16 @@ }, { "name": "webmozart/assert", - "version": "1.8.0", + "version": "1.9.0", "source": { "type": "git", "url": "https://github.com/webmozart/assert.git", - "reference": "ab2cb0b3b559010b75981b1bdce728da3ee90ad6" + "reference": "9dc4f203e36f2b486149058bade43c851dd97451" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/webmozart/assert/zipball/ab2cb0b3b559010b75981b1bdce728da3ee90ad6", - "reference": "ab2cb0b3b559010b75981b1bdce728da3ee90ad6", + "url": "https://api.github.com/repos/webmozart/assert/zipball/9dc4f203e36f2b486149058bade43c851dd97451", + "reference": "9dc4f203e36f2b486149058bade43c851dd97451", "shasum": "" }, "require": { @@ -2243,6 +2308,7 @@ "symfony/polyfill-ctype": "^1.8" }, "conflict": { + "phpstan/phpstan": "<0.12.20", "vimeo/psalm": "<3.9.1" }, "require-dev": { @@ -2270,7 +2336,7 @@ "check", "validate" ], - "time": "2020-04-18T12:12:48+00:00" + "time": "2020-06-16T10:16:42+00:00" } ], "aliases": [], diff --git a/phpunit.xml b/phpunit.xml index 29c2f64..52addad 100644 --- a/phpunit.xml +++ b/phpunit.xml @@ -6,11 +6,14 @@ tests/Reply/IntroductionTest.php tests/Reply/RepeatTest.php + tests/Reply/LimitOrderBuyStockTest.php + tests/Reply/StocksTest.php tests/Reply/OrdersTest.php tests/Reply/StocksTest.php tests/DeclensionTest.php + tests/PriceTest.php \ No newline at end of file diff --git a/src/Application.php b/src/Application.php index 84e1f40..fac9883 100644 --- a/src/Application.php +++ b/src/Application.php @@ -8,6 +8,7 @@ use Oliver\Reply\Stocks; use jamesRUS52\TinkoffInvest\TIClient; use Oliver\Reply\Introduction; +use Oliver\Reply\LimitOrderBuyStock; use Oliver\Reply\Orders; use Oliver\Reply\Repeat; @@ -76,6 +77,7 @@ public function run(): array new Repeat(), new Stocks($this->client), new Orders($this->client), + new LimitOrderBuyStock($this->client), ]; foreach ($replies as $reply) { $response = $reply->handle($this->event); @@ -101,7 +103,7 @@ public function run(): array return [ 'response' => [ 'text' => 'всё хорошо', - 'end_session' => true, + 'end_session' => false, ], 'version' => '1.0', ]; diff --git a/src/InvalidPriceException.php b/src/InvalidPriceException.php new file mode 100644 index 0000000..b969256 --- /dev/null +++ b/src/InvalidPriceException.php @@ -0,0 +1,9 @@ +currency = $currency; + } + + /** + * Переводит рубли и копейки в float. + * В случае некорректных данных выбрасывает исключение, например: + * 120 рублей 50 копеек - 120.5 + * 120 рублей - 120.0 + * 50 копеек - 0.5 + * 0.5 копейки - 0.05 + * 120 рублей 120 копеек - исключение + * 120 рублей 10 центов - исключение + * 50 копеек 120 рублей - исключение + * 120 лей - исключение + * + * @return float + * @throws Oliver\TIException + */ + public function concat(float $price1, string $currency1, float $price2 = 0.0, string $currency2 = '') + { + if ($price2 === 0.0 && $currency2 === '') { + // ожидаются рубли или копейки + if (in_array($currency1, ['рубль', 'рубля', 'рублей'])) { + // считать допустимым 120.5 рублей + if ($price1 > 0) { + return $price1; + } else { + throw new InvalidPriceException('не понимаю, похоже на отрицательную цену, используйте рубли и копейки, например сто рублей десять копеек.'); + } + } elseif (in_array($currency1, ['копейка', 'копейки', 'копеек'])) { + // не может быть больше 99 копеек + if ($price1 > 0 && $price1 < 100.0) { + return $price1 * 0.01; + } else { + throw new InvalidPriceException('не понимаю цену, не доложно быть больше девяносто девяти копеек, например, сто рублей десять копеек.'); + } + } else { + // @todo: rework + throw new InvalidPriceException('неправильная валюта, используйте рубли и копейки, например сто рублей десять копеек.'); + } + } else { + if ( + in_array($currency1, ['рубль', 'рубля', 'рублей']) && + in_array($currency2, ['копейка', 'копейки', 'копеек']) + ) { + if ($price2 > 0 && $price2 < 100.0) { + return intval($price1) + $price2 * 0.01; + } else { + throw new InvalidPriceException('не понимаю цену, не доложно быть больше девяносто девяти копеек, например, сто рублей десять копеек.'); + } + } else { + throw new InvalidPriceException('не понимаю цену, сначала рубли затем копейки, например, сто рублей десять копеек.'); + } + } + throw new InvalidPriceException('не понимаю цену, пример, сто рублей десять копеек.'); + } +} diff --git a/src/Reply/LimitOrderBuyStock.php b/src/Reply/LimitOrderBuyStock.php new file mode 100644 index 0000000..fa050f5 --- /dev/null +++ b/src/Reply/LimitOrderBuyStock.php @@ -0,0 +1,317 @@ +client = $client; + } + + public function handle(array $event): array + { + $confirmed = isset($event['request']['nlu']['intents']['YANDEX.CONFIRM']) && + $event['request']['nlu']['intents']['YANDEX.CONFIRM']; + $rejected = isset($event['request']['nlu']['intents']['YANDEX.REJECT']) && + $event['request']['nlu']['intents']['YANDEX.REJECT']; + + if ($this->userPlacesOrder($event)) { + return $this->askConfirmation($event); + } elseif ($confirmed && $this->userReplies($event)) { + return $this->createLimitOrder($event); + } elseif ($rejected && $this->userReplies($event)) { + return $this->rejected(); + } elseif (! $confirmed && ! $rejected && $this->userReplies($event)) { + return $this->askConfirmation($event); + } else { + // @todo: hint + return []; + } + + } + + /** + * Создаёт лимитную заявку на покупку акций и сообщает результат пользователю + */ + private function createLimitOrder(array $event): array + { + $text = ''; + try { + // TIOrder + $order = $this->client->sendOrder( + $event['state']['session']['order_details']['figi'], + $event['state']['session']['order_details']['requestedLots'], + TIOperationEnum::BUY, + $event['state']['session']['order_details']['price'], + ); + switch ($order->getStatus()) { + // [ New, PartiallyFill, Fill, Cancelled, Replaced, PendingCancel, Rejected, PendingReplace, PendingNew ] + case 'New': + $text = 'лимитная заявка на покупку создана,'; + break; + case 'PendingNew': + $text = 'лимитная заявка на покупку отправлена,'; + break; + 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 { + // @todo: ???? + } + } + // @todo: Specified security is not found [...] + break; + default: + // @todo: add test case + print $order->getStatus() . "\n"; + $text = 'произошло что-то непонятное, проверьте свои заявки и акции,'; + break; + } + } catch (TIException $te) { + print $te->getMessage() . "\n"; + // Недостаточно активов для сделки [OrderNotAvailable] + if (preg_match('/\[OrderNotAvailable\]/', $te->getMessage())) { + $text = preg_replace('/\[OrderNotAvailable\]/', '', $te->getMessage()); + if (is_null($text)) { + // @todo: ???? + } + } elseif (preg_match('/\[VALIDATION_ERROR\]/', $te->getMessage())) { + if (preg_match('/has invalid scale/', $te->getMessage())) { + $text .= 'недопустимый шаг цены, узнайте минимальный шаг цены для этого инструмента на бирже,'; + } + } else { + $text = 'ошибка при взаимодействии с биржей, попробуйте создать лимитную заявку позже,'; + } + } + + return [ + 'session_state' => [ + 'text' => $text, + 'context' => [], + ], + 'response' => [ + 'text' => $text, + 'tts' => $text, + 'end_session' => false, + ], + 'version' => '1.0', + ]; + } + + /** + * Сообщает пользователю детали заявки, которые удалось узнать + * из запроса пользователя, затем предлагает подтвердить + * или отменить заявку. + */ + private function askConfirmation(array $event): array + { + $price1 = $event['request']['nlu']['intents']['limit.order.buy.stock']['slots']['price1']['value'] ?? 0.0; + $currency1 = $event['request']['nlu']['intents']['limit.order.buy.stock']['slots']['currency1']['value'] ?? ''; + $price2 = $event['request']['nlu']['intents']['limit.order.buy.stock']['slots']['price2']['value'] ?? 0.0; + $currency2 = $event['request']['nlu']['intents']['limit.order.buy.stock']['slots']['currency2']['value'] ?? ''; + $figi = $event['request']['nlu']['intents']['limit.order.buy.stock']['slots']['figi']['value'] ?? ''; + $lots = $event['request']['nlu']['intents']['limit.order.buy.stock']['slots']['requestedLots']['value'] ?? 0; + + $stockPrice = new StockPrice('RUB'); + try { + $price = $stockPrice->concat($price1, $currency1, $price2, $currency2); + } catch (InvalidPriceException $iv) { + return [ + 'session_state' => [ + 'text' => $iv->getMessage(), // @todo: do not use getMessage + 'context' => [ + 'limit_order_buy_stock', + ], + 'order_details' => [ + 'price' => $price, + 'figi' => $figi, + 'requestedLots' => $lots, + ], + ], + 'response' => [ + 'text' => $iv->getMessage(), + 'tts' => $iv->getMessage(), + 'end_session' => false, + ], + 'version' => '1.0', + ]; + } + + $valid = $lots > 0; + if ($valid) { + $instrument = $this->client->getInstrumentByFigi($figi); + // @todo: check minPriceIncrement + $text = sprintf('лимитная заявка на покупку %s,', $instrument->getName()); + $text .= sprintf('тикер: %s,', $instrument->getTicker()); + $text .= sprintf('цена за акцию: %s,', Price::toText($price, [['рублей', 'рубль', 'рубля'], ['копеек', 'копейка', 'копейки']], 'RU')); + $text .= sprintf('количество лотов: %s,', Num::toText($lots, 'RU')); + // @todo: сколько акций в одном лоте + $text .= sprintf('сумма заявки: %s плюс комиссия брокера,', Price::toText($price * $lots * $instrument->getLot(), [['рублей', 'рубль', 'рубля'], ['копеек', 'копейка', 'копейки']], 'RU')); + $text .= 'для подтверждения заявки скажите да, для отмены скажите нет,'; + return [ + 'session_state' => [ + 'text' => $text, + 'context' => [ + 'limit_order_buy_stock', + ], + 'order_details' => [ + 'price' => $price, + 'figi' => $figi, + 'requestedLots' => $lots, + ], + ], + 'response' => [ + 'text' => $text, + 'tts' => $text, + 'end_session' => false, + ], + 'version' => '1.0', + ]; + } else { + // price 120.5 + // currency копеек копеек + // lot -2, 10.5 + $text = ''; + return [ + 'session_state' => [ + 'text' => $text, + 'context' => [ + 'limit_order_buy_stock', + ], + 'order_details' => [], + ], + 'response' => [ + 'text' => $text, + 'tts' => $text, + 'end_session' => false, + ], + 'version' => '1.0', + ]; + } + } + + private function rejected(): array + { + $text = 'заявка отменена'; + return [ + 'session_state' => [ + 'text' => $text, + 'context' => [], + 'order_details' => [], + ], + 'response' => [ + 'text' => $text, + 'tts' => $text, + 'end_session' => false, + ], + 'version' => '1.0', + ]; + } + + /** + * Пользователь ответил что-то невнятное + * Просим подтвердить заявку, либо отменить + */ + private function hint(array $event): array + { + $text = 'чтобы создать заявку скажите подтверждаю или скажите отмена'; + $context = []; + if (isset($event['state']['session']['context'])) { + $context = $event['state']['session']['context']; + } + $details = []; + if (isset($event['state']['session']['order_details'])) { + $details = $event['state']['session']['order_details']; + } + return [ + 'session_state' => [ + 'text' => $text, + 'context' => $context, + 'order_details' => $details, + ], + 'response' => [ + 'text' => $text, + 'end_session' => false, + ], + 'version' => '1.0', + ]; + return [ + 'session_state' => [ + 'text' => $text, + 'context' => [], + 'order_details' => [], + ], + 'response' => [ + 'text' => $text, + 'tts' => $text, + 'end_session' => false, + ], + 'version' => '1.0', + ]; + } + + /** + * Пользователь намеревается создать лимитную заявку + */ + private function userPlacesOrder(array $event): bool + { + return isset($event['request']['nlu']['intents']['limit.order.buy.stock']); + } + + /** + * Пользователь ответил на предложение подтвердить или отменить лимитную заявку + * @throws \Exception + */ + private function userReplies(array $event): bool + { + $context = isset($event['state']['session']['context']) && + is_array($event['state']['session']['context']) && + in_array('limit_order_buy_stock', $event['state']['session']['context']); + // $valid = isset($event['state']['session']['order_details']['figi']) && + // isset($event['state']['session']['order_details']['requestedLots']) && + // isset($event['state']['session']['order_details']['price']); + // if (! $valid) { + // // @todo: залогируй session + // print_r($event['state']['session']); + // throw new \Exception('Невалидные данные в order_details.'); + // } + return $context; + } +} diff --git a/src/Reply/Repeat.php b/src/Reply/Repeat.php index ee849f9..315a90a 100644 --- a/src/Reply/Repeat.php +++ b/src/Reply/Repeat.php @@ -19,10 +19,15 @@ public function handle(array $event): array if (isset($event['state']['session']['context'])) { $context = $event['state']['session']['context']; } + $details = []; + if (isset($event['state']['session']['order_details'])) { + $details = $event['state']['session']['order_details']; + } return [ 'session_state' => [ 'text' => $text, 'context' => $context, + 'order_details' => $details, ], 'response' => [ 'text' => $text, diff --git a/tests/PriceTest.php b/tests/PriceTest.php new file mode 100644 index 0000000..5f753d0 --- /dev/null +++ b/tests/PriceTest.php @@ -0,0 +1,67 @@ +expectException(InvalidPriceException::class); + $this->expectExceptionMessage( + 'неправильная валюта, используйте рубли и копейки, например сто рублей десять копеек.' + ); + $instance = new Price('RUB'); + $instance->concat(1, 'акция'); + } + + public function testInvalidPriceException2(): void + { + $this->expectException(InvalidPriceException::class); + $this->expectExceptionMessage( + 'не понимаю цену, не доложно быть больше девяносто девяти копеек, например, сто рублей десять копеек.' + ); + $instance = new Price('RUB'); + $instance->concat(120.0, 'рублей', 120.0, 'копеек'); + } + + public function testCurrencyOrder(): void + { + $this->expectException(InvalidPriceException::class); + $this->expectExceptionMessage( + 'не понимаю цену, сначала рубли затем копейки, например, сто рублей десять копеек.' + ); + $instance = new Price('RUB'); + $instance->concat(50.0, 'копеек', 120.0, 'рублей'); + } + + public function testCurrency1(): void + { + $instance = new Price('RUB'); + $this->assertEquals( + 120.0, + $instance->concat(120.0, 'рубль') + ); + $this->assertEquals( + 0.01, + $instance->concat(1.0, 'копейка') + ); + $this->assertEquals( + 0.001, + $instance->concat(0.1, 'копейка') + ); + } + + public function testCurrency2(): void + { + $instance = new Price('RUB'); + $this->assertEquals( + 120.5, + $instance->concat(120.0, 'рублей', 50.0, 'копеек') + ); + } +} diff --git a/tests/Reply/LimitOrderBuyStockTest.php b/tests/Reply/LimitOrderBuyStockTest.php new file mode 100644 index 0000000..cc657f1 --- /dev/null +++ b/tests/Reply/LimitOrderBuyStockTest.php @@ -0,0 +1,398 @@ + [ + 'command' => 'подтверждаю', + 'original_utterance' => 'подтверждаю', + 'nlu' => [ + 'tokens' => [ + 'подтверждаю' + ], + 'entities' => [], + 'intents' => [ + 'YANDEX.CONFIRM' => [ + 'slots' => [] + ] + ] + ], + 'markup' => [ + 'dangerous_context' => false + ], + 'type' => 'SimpleUtterance' + ], + 'state' => [ + 'session' => [ + 'text' => 'заявка на покупку 10 лотов НЛМК, тикер NLMK, по цене сто двадцать рублей пятьдесят копеек за акцию. сумма сделки одна тысяча двести пять рублей плюс комиссия брокера. для подтверждения скажите подтверждаю.', + 'context' => [ + 'limit_order_buy_stock', + ], + 'order_details' => [ + 'price' => 120.5, + 'figi' => 'BBG004S681B4', + 'requestedLots' => 10, + 'name' => 'НЛМК', + ] + ], + 'user' => [] + ] + ]; + $order = $this->createStub(TIOrder::class); + $order->method('getStatus') + ->willReturn('New'); + $client = $this->createMock(TIClient::class); + $client->expects($this->once()) + ->method('sendOrder') + ->with( + $this->equalTo('BBG004S681B4'), + $this->equalTo(10), + $this->equalTo(TIOperationEnum::BUY) + )->willReturn($order); + + $limitOrder = new LimitOrderBuyStock($client); + $result = $limitOrder->handle($event); + $this->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']); + $this->assertNotContains('limit_order_buy_stock', $result['session_state']['context']); + $this->assertStringContainsStringIgnoringCase('лимитная заявка на покупку создана', $result['response']['text']); + // @todo: lot, figi and price not in session + + + $order = $this->createStub(TIOrder::class); + $order->method('getStatus') + ->willReturn('PendingNew'); + $client = $this->createMock(TIClient::class); + $client->expects($this->once()) + ->method('sendOrder') + ->with( + $this->equalTo('BBG004S681B4'), + $this->equalTo(10), + $this->equalTo(TIOperationEnum::BUY) + )->willReturn($order); + + $limitOrder = new LimitOrderBuyStock($client); + $result = $limitOrder->handle($event); + $this->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']); + $this->assertNotContains('limit_order_buy_stock', $result['session_state']['context']); + $this->assertStringContainsStringIgnoringCase('лимитная заявка на покупку отправлена', $result['response']['text']); + + + $order = $this->createStub(TIOrder::class); + $order->method('getStatus') + ->willReturn('Rejected'); + $order->method('getRejectReason') + ->willReturn('Unknown'); + $order->method('getMessage') + ->willReturn('ОШИБКА: (579) Для выбранного финансового инструмента цена должна быть не меньше 126.02'); + $client = $this->createMock(TIClient::class); + $client->expects($this->once()) + ->method('sendOrder') + ->with( + $this->equalTo('BBG004S681B4'), + $this->equalTo(10), + $this->equalTo(TIOperationEnum::BUY) + )->willReturn($order); + + $limitOrder = new LimitOrderBuyStock($client); + $result = $limitOrder->handle($event); + $this->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']); + $this->assertNotContains('limit_order_buy_stock', $result['session_state']['context']); + $this->assertStringContainsStringIgnoringCase('лимитная заявка на покупку отклонена системой', $result['response']['text']); + $this->assertStringContainsStringIgnoringCase('для выбранного финансового инструмента цена должна быть не меньше', $result['response']['text']); + $this->assertStringNotContainsStringIgnoringCase('(', $result['response']['text']); + + /* does not work becase TIException extends Exception which is final + $exception = $this->createStub(TIException::class); + $exception->method('getMessage') + ->willReturn('Недостаточно активов для сделки [OrderNotAvailable]'); + $client = $this->createMock(TIClient::class); + $client->expects($this->once()) + ->method('sendOrder') + ->with( + $this->equalTo('BBG004S681B4'), + $this->equalTo(10), + $this->equalTo(TIOperationEnum::BUY) + )->willThrowException($exception); + + $limitOrder = new LimitOrderBuyStock($client); + $result = $limitOrder->handle($event); + $this->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']); + $this->assertNotContains('limit_order_buy_stock', $result['session_state']['context']); + $this->assertStringContainsStringIgnoringCase('недостаточно активов для сделки', $result['response']['text']); + $this->assertStringNotContainsStringIgnoringCase('OrderNotAvailable', $result['response']['text']); + + + $exception = $this->createStub(TIException::class); + $exception->method('getMessage') + ->willReturn('[price]: 129.99 has invalid scale, minPriceIncrement=0.02 [VALIDATION_ERROR]'); + $client = $this->createMock(TIClient::class); + $client->expects($this->once()) + ->method('sendOrder') + ->with( + $this->equalTo('BBG004S681B4'), + $this->equalTo(10), + $this->equalTo(TIOperationEnum::BUY) + )->willThrowException($exception); + + $limitOrder = new LimitOrderBuyStock($client); + $result = $limitOrder->handle($event); + $this->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']); + $this->assertNotContains('limit_order_buy_stock', $result['session_state']['context']); + $this->assertStringContainsStringIgnoringCase('недопустимый шаг цены', $result['response']['text']); + $this->assertStringNotContainsStringIgnoringCase('has invalid scale', $result['response']['text']); + */ + } + + public function testLimitOrderBuyStockReject(): void + { + $event = [ + 'request' => [ + 'command' => 'нет', + 'original_utterance' => 'нет', + 'nlu' => [ + 'tokens' => [ + 'нет' + ], + 'entities' => [], + 'intents' => [ + 'YANDEX.REJECT' => [ + 'slots' => [] + ] + ] + ], + 'markup' => [ + 'dangerous_context' => false + ], + 'type' => 'SimpleUtterance' + ], + 'state' => [ + 'session' => [ + 'text' => 'заявка на покупку 10 лотов НЛМК, тикер NLMK, по цене сто двадцать рублей пятьдесят копеек за акцию. сумма сделки одна тысяча двести пять рублей плюс комиссия брокера. для подтверждения скажите подтверждаю.', + 'context' => [ + 'limit_order_buy_stock', + ], + 'order_details' => [ + 'price' => 120.5, + 'figi' => 'BBG004S681B4', + 'requestedLots' => 10, + 'name' => 'НЛМК', + ] + ], + 'user' => [] + ] + ]; + $client = $this->createMock(TIClient::class); + $client->expects($this->never()) + ->method('sendOrder'); + + $newOrder = new LimitOrderBuyStock($client); + $result = $newOrder->handle($event); + $this->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']); + $this->assertNotContains('limit_order_buy_stock', $result['session_state']['context']); + $this->assertStringContainsStringIgnoringCase('заявка отменена', $result['response']['text']); + // @todo: lot, figi and price not in session + } + + // @todo: + // в ApplicationTest проверить, что этот класс вообще вызывается + public function testLimitOrderBuyStock(): void + { + $event = [ + 'request' => [ + 'command' => 'купи 10 лотов нлмк по цене десять рублей пятьдесят копеек', + 'original_utterance' => 'купи 10 лотов нлмк по цене десять рублей пятьдесят копеек', + 'nlu' => [ + 'tokens' => [ + 'купи', + '10', + 'лотов', + 'нлмк', + 'по', + 'цене', + '100', + 'р', + '50', + 'к' + ], + 'entities' => [ + [ + 'type' => 'YANDEX.NUMBER', + 'tokens' => [ + 'start' => 1, + 'end' => 2 + ], + 'value' => 10 + ], + [ + 'type' => 'YANDEX.NUMBER', + 'tokens' => [ + 'start' => 6, + 'end' => 7 + ], + 'value' => 100 + ], + [ + 'type' => 'YANDEX.NUMBER', + 'tokens' => [ + 'start' => 8, + 'end' => 9 + ], + 'value' => 50 + ], + [ + 'type' => 'YANDEX.NUMBER', + 'tokens' => [ + 'start' => 11, + 'end' => 12 + ], + 'value' => 1 + ] + ], + 'intents' => [ + 'limit.order.buy.stock' => [ + 'slots' => [ + 'currency1' => [ + 'type' => 'YANDEX.STRING', + 'tokens' => [ + 'start' => 7, + 'end' => 8 + ], + 'value' => 'рублей' + ], + 'currency2' => [ + 'type' => 'YANDEX.STRING', + 'tokens' => [ + 'start' => 9, + 'end' => 10 + ], + 'value' => 'копеек' + ], + 'figi' => [ + 'type' => 'FIGI', + 'tokens' => [ + 'start' => 3, + 'end' => 4 + ], + 'value' => 'BBG004S681B4' + ], + 'requestedLots' => [ + 'type' => 'YANDEX.NUMBER', + 'tokens' => [ + 'start' => 1, + 'end' => 2 + ], + 'value' => 10 + ], + 'price1' => [ + 'type' => 'YANDEX.NUMBER', + 'tokens' => [ + 'start' => 6, + 'end' => 7 + ], + 'value' => 100 + ], + 'price2' => [ + 'type' => 'YANDEX.NUMBER', + 'tokens' => [ + 'start' => 8, + 'end' => 9 + ], + 'value' => 50 + ], + ], + ], + ], + ], + ], + ]; + + $client = $this->createStub(TIClient::class); + $client->method('getInstrumentByFigi') + ->willReturn(new TIInstrument( + 'BBG004S681B4', + 'NLMK', + null, + null, + 10, + TICurrencyEnum::RUB, + 'НЛМК', + null, + )); + + $newOrder = new LimitOrderBuyStock($client); + $result = $newOrder->handle($event); + $this->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']); + $this->assertContains('limit_order_buy_stock', $result['session_state']['context']); + $this->assertArrayHasKey('order_details', $result['session_state']); + $this->assertArrayHasKey('price', $result['session_state']['order_details']); + $this->assertEquals(100.5, $result['session_state']['order_details']['price']); + $this->assertArrayHasKey('figi', $result['session_state']['order_details']); + $this->assertEquals('BBG004S681B4', $result['session_state']['order_details']['figi']); + $this->assertArrayHasKey('requestedLots', $result['session_state']['order_details']); + $this->assertEquals(10, $result['session_state']['order_details']['requestedLots']); + $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']); + $this->assertStringContainsStringIgnoringCase('для отмены', $result['response']['text']); + } +}