feat(reports): протокол и парсеры отчётов Сбера, ВТБ и Snowball CSV
sources/reports/base.py — контракт ReportParser (плагины: sniff/parse, чистые функции без БД). registry.py выбирает парсер по содержимому файла, CSV последним: он узнаёт файл по набору колонок и иначе перехватил бы чужой формат. Комиссия капитализируется в сделку, отдельным событием не эмитится: и Сбер, и ВТБ печатают её дважды — колонками в сделках и строками в движении денег, суммы совпадают, второе прочтение задвоило бы её. Расчётные строки («Сделка от …», «Сальдо расчетов по сделкам») не эмитятся — это денежные ноги уже учтённых сделок. У Сбера таблица «Информация о зачислениях на ИИС» кумулятивна за календарный год и в леджер не идёт, иначе два пересекающихся отчёта задвоили бы пополнения. CSV Snowball сводный по всем брокерам — годится как сверка потоков и сделок на уровне портфеля, но не позиций по счетам; CUSTOM_HOLDING_PRICE не событие, а цена — уходит в meta для price_manual. Обезличивание — anonymize.py + scripts/anonymize_reports.py, секреты собираются по всему корпусу отчётов разом (Snowball цитирует номер договора Сбера в примечании к переводу). test_fixtures_anonymized.py падает, если в tests/fixtures/reports/ вне raw/ найдётся ИНН, ФИО или номер счёта — и по форме (работает в CI без raw/), и по фактическому содержимому raw/, когда оно на месте.
This commit is contained in:
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
+546
@@ -0,0 +1,546 @@
|
||||
Event,Date,Symbol,Price,Quantity,Currency,FeeTax,Exchange,NKD,FeeCurrency,DoNotAdjustCash,Note
|
||||
"CASH_IN","2025-02-26 09:22:54","RUB","1","5000","RUB","0","","","","False","Пополнение счета"
|
||||
"CASH_OUT","2025-02-26 09:22:54","RUB","1","0,002832","RUB","0","","","","False","Эта сделка добавлена с целью корректировки баланса по этой валюте, т.к. баланс по данным брокера (459,38) не соответствует балансу рассчитанному по сделкам (459,38)."
|
||||
"BUY","2025-02-26 09:23:29","DELI","219","10","RUB","6,57","MCX","","","False",""
|
||||
"BUY","2025-02-26 09:24:09","ROSN","581,25","2","RUB","3,49","MCX","","","False",""
|
||||
"BUY","2025-02-26 09:25:23","DELI","219","7","RUB","4,6","MCX","","","False",""
|
||||
"CASH_IN","2025-02-27 19:28:34","RUB","1","3400","RUB","0","","","","False","Пополнение счета"
|
||||
"BUY","2025-02-27 19:28:47","SBERP","307,39","10","RUB","9,22","MCX","","","False",""
|
||||
"BUY","2025-02-28 08:49:02","DELI","211,45","1","RUB","0,63","MCX","","","False",""
|
||||
"CASH_IN","2025-03-12 16:12:53","RUB","1","10000","RUB","0","","","","False","Пополнение счета"
|
||||
"BUY","2025-03-12 16:13:39","DELI","196,3","12","RUB","7,07","MCX","","","False",""
|
||||
"BUY","2025-03-12 16:13:49","DELI","196,3","10","RUB","5,89","MCX","","","False",""
|
||||
"BUY","2025-03-12 16:14:20","DELI","196,4","10","RUB","5,89","MCX","","","False",""
|
||||
"BUY","2025-03-12 16:18:28","RU000A107AM4","929,25","4","RUB","11,15","MCX","5,08","","False",""
|
||||
"CASH_IN","2025-03-12 16:19:39","RUB","1","197,7","RUB","0","","","","False","Пополнение счета"
|
||||
"BUY","2025-03-12 16:19:40","DELI","196,5","1","RUB","0,59","MCX","","","False",""
|
||||
"BUY","2025-03-24 16:28:49","ASTR","427,45","2","RUB","2,56","MCX","","","False",""
|
||||
"CASH_IN","2025-03-24 16:28:49","RUB","1","860,08","RUB","0","","","","False","Пополнение счета"
|
||||
"CASH_IN","2025-03-25 18:00:22","RUB","1","5007,5","RUB","0","","","","False","Пополнение счета"
|
||||
"CASH_IN","2025-03-25 18:02:09","RUB","1","5508,25","RUB","0","","","","False","Пополнение счета"
|
||||
"BUY","2025-03-25 18:17:42","WUSH","217,552","5","RUB","3,26","MCX","","","False",""
|
||||
"BUY","2025-03-25 18:21:55","TATNP","647,5","2","RUB","3,89","MCX","","","False",""
|
||||
"BUY","2025-03-25 18:24:32","SFIN","1538,8","1","RUB","4,62","MCX","","","False",""
|
||||
"BUY","2025-03-25 18:24:41","LENT","1384","1","RUB","4,15","MCX","","","False",""
|
||||
"BUY","2025-03-25 18:25:23","IVAT","187,5","1","RUB","0,56","MCX","","","False",""
|
||||
"BUY","2025-03-26 17:27:00","TPAY","96,5","1","RUB","0","MCX","","","False",""
|
||||
"BUY","2025-04-02 11:22:50","RU000A10B7T7","1000","5","RUB","7,5","MCX","0","","False",""
|
||||
"DIVIDEND","2025-04-09 18:40:43","TPAY","0","1,94","RUB","0","MCX","","","False",""
|
||||
"CASH_IN","2025-04-11 18:24:33","RUB","1","8745,17","RUB","0","","","","False","Пополнение счета"
|
||||
"CASH_OUT","2025-04-11 18:24:33","RUB","1","0,010799","RUB","0","","","","False","Эта сделка добавлена с целью корректировки баланса по этой валюте, т.к. баланс по данным брокера (364,86) не соответствует балансу рассчитанному по сделкам (364,87)."
|
||||
"BUY","2025-04-11 18:25:10","WUSH","181,58","10","RUB","5,45","MCX","","","False",""
|
||||
"BUY","2025-04-11 18:25:27","IVAT","152,99","10","RUB","4,59","MCX","","","False",""
|
||||
"BUY","2025-04-11 18:25:58","ROSN","450,566667","3","RUB","4,06","MCX","","","False",""
|
||||
"BUY","2025-04-11 18:26:54","PIKK","427,3","3","RUB","3,85","MCX","","","False",""
|
||||
"BUY","2025-04-11 18:28:10","CNRU","539,8","3","RUB","4,86","MCX","","","False",""
|
||||
"BUY","2025-04-18 09:52:48","ROSN","475,05","2","RUB","2,85","MCX","","","False",""
|
||||
"CASH_IN","2025-04-25 16:11:11","RUB","1","1626,6","RUB","0","","","","False","Пополнение счета"
|
||||
"CASH_IN","2025-04-25 16:11:25","RUB","1","1626,6","RUB","0","","","","False","Пополнение счета"
|
||||
"BUY","2025-04-28 10:34:11","TPAY","96,76","17","RUB","0","MCX","","","False",""
|
||||
"BUY","2025-04-28 10:35:25","NVTK","1259,3","1","RUB","3,78","MCX","","","False",""
|
||||
"CASH_IN","2025-04-29 19:39:31","RUB","1","2610","RUB","0","","","","False","Пополнение счета"
|
||||
"CASH_IN","2025-04-29 19:39:41","RUB","1","2610","RUB","0","","","","False","Пополнение счета"
|
||||
"BUY","2025-04-30 10:50:36","TPAY","95,47","2","RUB","0","MCX","","","False",""
|
||||
"BUY","2025-04-30 10:50:48","RU000A107AM4","935,8","1","RUB","2,81","MCX","23,23","","False",""
|
||||
"BUY","2025-04-30 10:51:05","DELI","169,4","8","RUB","4,07","MCX","","","False",""
|
||||
"BUY","2025-04-30 10:51:20","CNRU","549","2","RUB","3,29","MCX","","","False",""
|
||||
"BUY","2025-04-30 10:51:32","PIKK","470,8","2","RUB","2,82","MCX","","","False",""
|
||||
"BUY","2025-04-30 10:52:36","APTK","10,412","100","RUB","3,12","MCX","","","False",""
|
||||
"CASH_IN","2025-05-12 19:51:08","RUB","1","5000","RUB","0","","","","False","Пополнение счета"
|
||||
"CASH_IN","2025-05-12 19:51:19","RUB","1","5000","RUB","0","","","","False","Пополнение счета"
|
||||
"BUY","2025-05-14 10:43:57","EUTR","125,25","10","RUB","3,76","MCX","","","False",""
|
||||
"BUY","2025-05-14 10:45:03","DELI","163,6","1","RUB","0,49","MCX","","","False",""
|
||||
"BUY","2025-05-14 10:45:16","ROSN","451,2","8","RUB","10,83","MCX","","","False",""
|
||||
"BUY","2025-05-14 10:45:36","PIKK","490,58","5","RUB","7,36","MCX","","","False",""
|
||||
"BUY","2025-05-14 10:46:01","NVTK","1192,55","2","RUB","7,16","MCX","","","False",""
|
||||
"DIVIDEND","2025-05-15 17:05:35","TPAY","0","25,72","RUB","0","MCX","","","False",""
|
||||
"CASH_IN","2025-05-23 16:12:44","RUB","1","1200","RUB","0","","","","False","Пополнение счета"
|
||||
"CASH_IN","2025-05-23 16:12:54","RUB","1","1200","RUB","0","","","","False","Пополнение счета"
|
||||
"BUY","2025-05-27 08:55:30","DELI","157,65","8","RUB","3,78","MCX","","","False",""
|
||||
"BUY","2025-05-27 08:55:47","DELI","157,65","8","RUB","3,78","MCX","","","False",""
|
||||
"DIVIDEND","2025-05-30 09:12:44","RU000A107AM4","0","165,15","RUB","0","MCX","","","False",""
|
||||
"CASH_IN","2025-06-05 12:46:14","RUB","1","2600","RUB","0","","","","False","Пополнение счета"
|
||||
"CASH_IN","2025-06-05 12:46:24","RUB","1","2600","RUB","0","","","","False","Пополнение счета"
|
||||
"BUY","2025-06-05 16:21:32","RU000A10B7T7","1038","2","RUB","6,23","MCX","44,52","","False",""
|
||||
"BUY","2025-06-05 16:21:43","DELI","169,85","2","RUB","1,02","MCX","","","False",""
|
||||
"BUY","2025-06-05 16:21:53","NVTK","1110,55","2","RUB","6,66","MCX","","","False",""
|
||||
"BUY","2025-06-05 16:22:12","IVAT","128,35","5","RUB","1,93","MCX","","","False",""
|
||||
"DIVIDEND","2025-06-09 15:56:44","TPAY","0","26,82","RUB","0","MCX","","","False",""
|
||||
"DIVIDEND","2025-06-11 12:29:50","SFIN","0","83,5","RUB","9","MCX","","","False",""
|
||||
"CASH_IN","2025-06-12 10:19:15","RUB","1","6000","RUB","0","","","","False","Пополнение счета"
|
||||
"CASH_IN","2025-06-12 10:19:29","RUB","1","6000","RUB","0","","","","False","Пополнение счета"
|
||||
"DIVIDEND","2025-06-19 15:21:06","TATNP","0","86,22","RUB","11","MCX","","","False",""
|
||||
"BUY","2025-06-19 16:22:31","TMOS","6,21","398","RUB","0","MCX","","","False",""
|
||||
"BUY","2025-06-19 16:23:05","TMOS","6,21","398","RUB","0","MCX","","","False",""
|
||||
"BUY","2025-06-19 16:24:05","SBRB","15,845","77","RUB","3,66","MCX","","","False",""
|
||||
"BUY","2025-06-19 16:24:16","SBRB","15,845","77","RUB","3,66","MCX","","","False",""
|
||||
"BUY","2025-06-19 16:25:10","TGLD","10,52","58","RUB","0","MCX","","","False",""
|
||||
"BUY","2025-06-19 16:25:27","TGLD","10,52","58","RUB","0","MCX","","","False",""
|
||||
"BUY","2025-06-19 16:26:09","LQDT","1,7196","356","RUB","1,84","MCX","","","False",""
|
||||
"BUY","2025-06-19 16:26:26","LQDT","1,7196","356","RUB","1,84","MCX","","","False",""
|
||||
"BUY","2025-06-19 16:27:03","TPAY","98,23","12","RUB","0","MCX","","","False",""
|
||||
"BUY","2025-06-19 16:27:09","TPAY","98,23","12","RUB","0","MCX","","","False",""
|
||||
"CASH_IN","2025-06-25 19:07:20","RUB","1","1200","RUB","0","","","","False","Пополнение счета"
|
||||
"CASH_IN","2025-06-25 19:07:31","RUB","1","1200","RUB","0","","","","False","Пополнение счета"
|
||||
"AMORTISATION","2025-06-26 14:00:41","RU000A10B7T7","0","897,82","RUB","0","MCX","","","False",""
|
||||
"DIVIDEND","2025-06-26 14:10:53","RU000A10B7T7","0","407,54","RUB","0","MCX","","","False",""
|
||||
"BUY","2025-06-26 14:22:14","RU000A10B7T7","909,835","2","RUB","5,46","MCX","0,6","","False",""
|
||||
"BUY","2025-06-26 14:22:34","RU000A10B7T7","909,92","1","RUB","2,73","MCX","0,6","","False",""
|
||||
"CASH_IN","2025-07-04 15:45:31","RUB","1","2900","RUB","0","","","","False","Пополнение счета"
|
||||
"CASH_IN","2025-07-04 15:45:52","RUB","1","2900","RUB","0","","","","False","Пополнение счета"
|
||||
"CASH_IN","2025-07-04 23:49:07","RUB","1","2610","RUB","0","","","","False","Пополнение счета"
|
||||
"CASH_IN","2025-07-04 23:49:17","RUB","1","2610","RUB","0","","","","False","Пополнение счета"
|
||||
"DIVIDEND","2025-07-08 12:33:19","TPAY","0","56,4","RUB","0","MCX","","","False",""
|
||||
"DIVIDEND","2025-07-08 13:58:58","TPAY","0","20,15","RUB","0","MCX","","","False",""
|
||||
"BUY","2025-07-08 14:24:10","TMOS","6,2","403","RUB","0","MCX","","","False",""
|
||||
"BUY","2025-07-08 14:24:21","TMOS","6,2","403","RUB","0","MCX","","","False",""
|
||||
"BUY","2025-07-08 14:24:50","TBRU","6,96","286","RUB","0","MCX","","","False",""
|
||||
"BUY","2025-07-08 14:24:58","TBRU","6,96","286","RUB","0","MCX","","","False",""
|
||||
"BUY","2025-07-08 14:25:57","TGLD","10,37","96","RUB","0","MCX","","","False",""
|
||||
"BUY","2025-07-08 14:26:05","TGLD","10,37","96","RUB","0","MCX","","","False",""
|
||||
"BUY","2025-07-08 14:26:34","TPAY","98,56","8","RUB","0","MCX","","","False",""
|
||||
"BUY","2025-07-08 14:26:40","TPAY","98,56","4","RUB","0","MCX","","","False",""
|
||||
"CASH_IN","2025-07-11 21:28:53","RUB","1","5300","RUB","0","","","","False","Пополнение счета"
|
||||
"CASH_IN","2025-07-11 21:29:03","RUB","1","5300","RUB","0","","","","False","Пополнение счета"
|
||||
"BUY","2025-07-11 21:31:35","VTBR","72,84","13","RUB","2,84","MCX","","","False",""
|
||||
"BUY","2025-07-11 21:31:44","VTBR","72,84","13","RUB","2,84","MCX","","","False",""
|
||||
"BUY","2025-07-11 21:32:50","T","3129,4","1","RUB","0","MCX","","","False",""
|
||||
"BUY","2025-07-11 21:32:58","T","3129,3","1","RUB","0","MCX","","","False",""
|
||||
"DIVIDEND","2025-07-15 14:09:51","ASTR","0","6,29","RUB","1","MCX","","","False",""
|
||||
"CASH_IN","2025-07-15 17:22:31","RUB","1","1000","RUB","0","","","","False","Пополнение счета"
|
||||
"CASH_IN","2025-07-15 17:23:34","RUB","1","1000","RUB","0","","","","False","Пополнение счета"
|
||||
"DIVIDEND","2025-07-21 07:36:14","T","0","33","RUB","4","MCX","","","False",""
|
||||
"DIVIDEND","2025-07-21 08:13:36","T","0","33","RUB","4","MCX","","","False",""
|
||||
"DIVIDEND","2025-07-28 20:55:02","RU000A10B7T7","0","161,19","RUB","0","MCX","","","False",""
|
||||
"DIVIDEND","2025-07-28 20:59:19","RU000A10B7T7","0","17,91","RUB","0","MCX","","","False",""
|
||||
"AMORTISATION","2025-07-28 21:02:33","RU000A10B7T7","0","592,11","RUB","0","MCX","","","False",""
|
||||
"AMORTISATION","2025-07-28 21:06:43","RU000A10B7T7","0","65,79","RUB","0","MCX","","","False",""
|
||||
"DIVIDEND","2025-08-01 11:52:28","EUTR","0","141,9","RUB","19","MCX","","","False",""
|
||||
"DIVIDEND","2025-08-01 13:35:14","EUTR","0","30","RUB","4","MCX","","","False",""
|
||||
"DIVIDEND","2025-08-04 14:42:34","SBERP","0","348,4","RUB","45","MCX","","","False",""
|
||||
"DIVIDEND","2025-08-04 19:55:40","ROSN","0","73,4","RUB","10","MCX","","","False",""
|
||||
"DIVIDEND","2025-08-04 20:07:25","ROSN","0","146,8","RUB","19","MCX","","","False",""
|
||||
"DIVIDEND","2025-08-08 15:01:23","TPAY","0","57,07","RUB","0","MCX","","","False",""
|
||||
"DIVIDEND","2025-08-08 15:45:28","TPAY","0","23,43","RUB","0","MCX","","","False",""
|
||||
"SELL","2025-08-08 17:15:27","PIKK","638,7","10","RUB","19,16","MCX","","","False",""
|
||||
"SELL","2025-08-08 17:16:06","IVAT","168,3","6","RUB","3,03","MCX","","","False",""
|
||||
"SELL","2025-08-08 17:16:16","IVAT","168,25","2","RUB","1,01","MCX","","","False",""
|
||||
"CASH_OUT","2025-08-08 17:18:02","RUB","1","9900","RUB","0","","","","False","Вывод со счета"
|
||||
"TAX","2025-08-12 06:03:36","","0","0","RUB","234","","","","False","Корректировка налога"
|
||||
"SELL","2025-08-20 21:40:50","VTBR","77,91","13","RUB","3,04","MCX","","","False",""
|
||||
"SELL","2025-08-20 21:41:08","EUTR","126,4","10","RUB","3,79","MCX","","","False",""
|
||||
"SELL","2025-08-20 21:41:19","LENT","1785,5","1","RUB","5,36","MCX","","","False",""
|
||||
"SELL","2025-08-20 21:41:56","IVAT","169,4","6","RUB","3,05","MCX","","","False",""
|
||||
"SELL","2025-08-20 21:43:13","LQDT","1,7755","356","RUB","1,9","MCX","","","False",""
|
||||
"SELL","2025-08-20 21:51:17","WUSH","140,58","5","RUB","2,11","MCX","","","False",""
|
||||
"BUY","2025-08-21 10:50:16","TGLD","10,69","93","RUB","0","MCX","","","False",""
|
||||
"BUY","2025-08-21 10:50:16","TMOS","6,76","369","RUB","0","MCX","","","False",""
|
||||
"SELL","2025-08-21 11:17:38","TPAY","101,62","40","RUB","0","MCX","","","False",""
|
||||
"BUY","2025-08-21 11:22:38","TMOS","6,75","381","RUB","0","MCX","","","False",""
|
||||
"BUY","2025-08-21 11:22:39","TGLD","10,69","96","RUB","0","MCX","","","False",""
|
||||
"BUY","2025-08-21 12:15:01","TMOS","6,75","251","RUB","0","MCX","","","False",""
|
||||
"BUY","2025-08-21 12:15:01","TPAY","101,61","16","RUB","0","MCX","","","False",""
|
||||
"BUY","2025-08-21 12:15:02","TGLD","10,69","63","RUB","0","MCX","","","False",""
|
||||
"BUY","2025-08-21 12:26:45","SU26212RMFS9","887,83","1","RUB","2,66","MCX","5,79","","False",""
|
||||
"BUY","2025-08-21 12:26:45","TPAY","101,6","7","RUB","0","MCX","","","False",""
|
||||
"BUY","2025-08-21 12:26:46","TGLD","10,69","27","RUB","0","MCX","","","False",""
|
||||
"BUY","2025-08-21 12:26:46","TMOS","6,76","107","RUB","0","MCX","","","False",""
|
||||
"SELL","2025-08-21 12:43:14","ASTR","416,75","2","RUB","2,5","MCX","","","False",""
|
||||
"SELL","2025-08-21 12:46:04","DELI","148,3","68","RUB","30,25","MCX","","","False",""
|
||||
"SELL","2025-08-21 12:46:47","DELI","148,255","10","RUB","4,44","MCX","","","False",""
|
||||
"BUY","2025-08-21 12:55:10","SU26212RMFS9","887,35","4","RUB","10,65","MCX","5,79","","False",""
|
||||
"BUY","2025-08-21 12:55:11","TMOS","6,77","394","RUB","0","MCX","","","False",""
|
||||
"BUY","2025-08-21 12:55:11","TPAY","101,58","26","RUB","0","MCX","","","False",""
|
||||
"BUY","2025-08-21 12:55:12","TGLD","10,71","99","RUB","0","MCX","","","False",""
|
||||
"BUY","2025-08-22 13:40:55","RNFT","124,5","50","RUB","18,68","MCX","","","False",""
|
||||
"SELL","2025-08-22 13:45:06","TATN","675,1","10","RUB","20,25","MCX","","","False",""
|
||||
"FEE","2025-08-23 00:30:53","","0","0","RUB","45","","","","False",""
|
||||
"FEE","2025-08-24 00:32:22","","0","0","RUB","45","","","","False",""
|
||||
"FEE","2025-08-25 00:31:52","","0","0","RUB","45","","","","False",""
|
||||
"FEE","2025-08-26 00:32:25","","0","0","RUB","45","","","","False",""
|
||||
"AMORTISATION","2025-08-26 13:06:06","RU000A10B7T7","0","664,47","RUB","0","MCX","","","False",""
|
||||
"AMORTISATION","2025-08-26 13:07:38","RU000A10B7T7","0","73,83","RUB","0","MCX","","","False",""
|
||||
"DIVIDEND","2025-08-26 13:14:31","RU000A10B7T7","0","153,99","RUB","0","MCX","","","False",""
|
||||
"DIVIDEND","2025-08-26 13:14:58","RU000A10B7T7","0","17,11","RUB","0","MCX","","","False",""
|
||||
"FEE","2025-08-27 00:36:17","","0","0","RUB","45","","","","False",""
|
||||
"FEE","2025-08-28 00:34:11","","0","0","RUB","45","","","","False",""
|
||||
"FEE","2025-08-29 00:33:46","","0","0","RUB","45","","","","False",""
|
||||
"DIVIDEND","2025-08-29 13:54:42","RU000A107AM4","0","165,15","RUB","0","MCX","","","False",""
|
||||
"BUY","2025-08-29 17:24:07","TATN","650,5","10","RUB","19,52","MCX","","","False",""
|
||||
"SELL","2025-08-29 17:24:25","RNFT","121,2","50","RUB","18,18","MCX","","","False",""
|
||||
"SELL","2025-09-01 17:59:16","VTBR","74,54","13","RUB","2,91","MCX","","","False",""
|
||||
"SELL","2025-09-01 17:59:29","NVTK","1239,6","5","RUB","18,59","MCX","","","False",""
|
||||
"SELL","2025-09-01 17:59:39","ROSN","461,32","5","RUB","6,92","MCX","","","False",""
|
||||
"SELL","2025-09-01 18:02:17","IVAT","159,7","2","RUB","0,96","MCX","","","False",""
|
||||
"SELL","2025-09-01 18:02:30","CNRU","607,4","5","RUB","9,11","MCX","","","False",""
|
||||
"SELL","2025-09-01 18:02:39","T","3335,8","1","RUB","0","MCX","","","False",""
|
||||
"SELL","2025-09-01 18:04:27","APTK","9,532","100","RUB","2,86","MCX","","","False",""
|
||||
"SELL","2025-09-01 18:04:35","WUSH","125,45","10","RUB","3,77","MCX","","","False",""
|
||||
"SELL","2025-09-01 18:04:53","RU000A10B7T7","769,384908","1","RUB","2,31","MCX","3,505092","","False",""
|
||||
"TAX","2025-09-01 18:05:10","","0","0","RUB","3","","","","False","Удержание налога"
|
||||
"CASH_OUT","2025-09-01 18:05:10","RUB","1","20358,92","RUB","0","","","","False","Вывод со счета"
|
||||
"TAX_RETURN","2025-09-03 07:21:59","","0","0","RUB","3","","","","False","Корректировка налога"
|
||||
"CASH_IN","2025-09-05 11:23:58","RUB","1","5000","RUB","0","","","","False","Пополнение счета"
|
||||
"DIVIDEND","2025-09-05 17:42:16","TPAY","0","60,89","RUB","0","MCX","","","False",""
|
||||
"DIVIDEND","2025-09-05 18:09:43","TPAY","0","19,82","RUB","0","MCX","","","False",""
|
||||
"BUY","2025-09-08 21:21:46","SU26212RMFS9","884,4","2","RUB","5,31","MCX","9,27","","False",""
|
||||
"BUY","2025-09-08 21:21:47","TMOS","6,76","179","RUB","0","MCX","","","False",""
|
||||
"BUY","2025-09-08 21:21:47","TPAY","100,84","12","RUB","0","MCX","","","False",""
|
||||
"BUY","2025-09-08 21:22:16","SU26212RMFS9","884,4","1","RUB","2,65","MCX","9,27","","False",""
|
||||
"BUY","2025-09-08 21:22:17","TMOS","6,76","107","RUB","0","MCX","","","False",""
|
||||
"BUY","2025-09-08 21:22:17","TPAY","100,84","7","RUB","0","MCX","","","False",""
|
||||
"AMORTISATION","2025-09-26 16:32:06","RU000A10B7T7","0","631,89","RUB","0","MCX","","","False",""
|
||||
"DIVIDEND","2025-09-26 16:33:22","RU000A10B7T7","0","139,95","RUB","0","MCX","","","False",""
|
||||
"CASH_IN","2025-10-05 13:15:58","RUB","1","5000","RUB","0","","","","False","Пополнение счета"
|
||||
"BUY","2025-10-06 20:20:29","SU26212RMFS9","869,77","2","RUB","5,22","MCX","14,68","","False",""
|
||||
"BUY","2025-10-06 20:20:29","TPAY","100,02","12","RUB","0","MCX","","","False",""
|
||||
"BUY","2025-10-06 20:20:30","TMOS","6,17","196","RUB","0","MCX","","","False",""
|
||||
"SELL","2025-10-07 13:23:38","LQDT","1,8159","356","RUB","1,94","MCX","","","False",""
|
||||
"SELL","2025-10-07 13:25:10","TGLD","12,88","154","RUB","0","MCX","","","False",""
|
||||
"SELL","2025-10-07 13:25:31","TMOS","6,21","801","RUB","0","MCX","","","False",""
|
||||
"SELL","2025-10-07 13:25:58","TPAY","100,1","16","RUB","0","MCX","","","False",""
|
||||
"SELL","2025-10-07 13:26:18","SBRB","16,599","77","RUB","3,83","MCX","","","False",""
|
||||
"SELL","2025-10-07 13:26:39","TBRU","7,39","286","RUB","0","MCX","","","False",""
|
||||
"CASH_OUT","2025-10-07 13:27:12","RUB","1","12614,5","RUB","0","","","","False","Вывод со счета"
|
||||
"DIVIDEND","2025-10-08 10:27:11","T","0","35","RUB","4","MCX","","","False",""
|
||||
"DIVIDEND","2025-10-08 16:58:14","TPAY","0","93,27","RUB","0","MCX","","","False",""
|
||||
"DIVIDEND","2025-10-08 17:28:20","TPAY","0","22,24","RUB","0","MCX","","","False",""
|
||||
"TAX","2025-10-09 04:18:26","","0","0","RUB","22","","","","False","Корректировка налога"
|
||||
"AMORTISATION","2025-10-27 18:30:50","RU000A10B7T7","0","570,42","RUB","0","MCX","","","False",""
|
||||
"DIVIDEND","2025-10-27 19:16:05","RU000A10B7T7","0","122,4","RUB","0","MCX","","","False",""
|
||||
"DIVIDEND","2025-10-29 14:52:01","TATNP","0","28,7","RUB","4","MCX","","","False",""
|
||||
"CASH_IN","2025-11-05 11:58:33","RUB","1","5000","RUB","0","","","","False","Пополнение счета"
|
||||
"BUY","2025-11-06 12:12:16","SU26212RMFS9","879,766667","3","RUB","7,92","MCX","20,66999966666667","","False",""
|
||||
"BUY","2025-11-06 12:12:17","TMOS","5,94","327","RUB","0","MCX","","","False",""
|
||||
"BUY","2025-11-06 12:12:17","TPAY","100,49","19","RUB","0","MCX","","","False",""
|
||||
"BUY","2025-11-06 12:12:18","TGLD","13","59","RUB","0","MCX","","","False",""
|
||||
"DIVIDEND","2025-11-10 19:06:16","TPAY","0","95,8","RUB","0","MCX","","","False",""
|
||||
"AMORTISATION","2025-11-26 18:13:31","RU000A10B7T7","0","558,09","RUB","0","MCX","","","False",""
|
||||
"DIVIDEND","2025-11-26 18:21:54","RU000A10B7T7","0","114,39","RUB","0","MCX","","","False",""
|
||||
"CASH_IN","2025-11-27 11:05:13","RUB","1","5818,41","RUB","0","","","","False","Пополнение счета"
|
||||
"BUY","2025-11-27 11:05:14","ASTR","289,1","20","RUB","17,35","MCX","","","False",""
|
||||
"CASH_IN","2025-11-27 11:05:59","RUB","1","3431,27","RUB","0","","","","False","Пополнение счета"
|
||||
"BUY","2025-11-27 11:06:00","IVAT","170,25","20","RUB","10,22","MCX","","","False",""
|
||||
"DIVIDEND","2025-11-28 10:29:07","RU000A107AM4","0","165,15","RUB","0","MCX","","","False",""
|
||||
"CASH_IN","2025-12-05 14:15:14","RUB","1","5000","RUB","0","","","","False","Пополнение счета"
|
||||
"DIVIDEND","2025-12-08 19:27:48","TPAY","0","92,14","RUB","0","MCX","","","False",""
|
||||
"DIVIDEND","2025-12-26 17:14:16","RU000A10B7T7","0","99,18","RUB","0","MCX","","","False",""
|
||||
"AMORTISATION","2025-12-26 17:17:03","RU000A10B7T7","0","475,65","RUB","0","MCX","","","False",""
|
||||
"DIVIDEND","2025-12-29 12:07:40","SFIN","0","902","RUB","117","MCX","","","False",""
|
||||
"CASH_IN","2026-01-05 13:59:37","RUB","1","5000","RUB","0","","","","False","Пополнение счета"
|
||||
"BUY","2026-01-06 10:22:03","TPAY","100,27","34","RUB","0","MCX","","","False",""
|
||||
"BUY","2026-01-06 10:22:04","TMOS","6,45","346","RUB","0","MCX","","","False",""
|
||||
"SELL","2026-01-08 11:41:58","RU000A10B7T7","502,484763","9","RUB","13,57","MCX","4,639681444444444","","False",""
|
||||
"SELL","2026-01-08 11:41:59","RU000A107AM4","991,7","5","RUB","14,88","MCX","15,61","","False",""
|
||||
"SELL","2026-01-08 11:41:59","SFIN","829,2","1","RUB","2,49","MCX","","","False",""
|
||||
"SELL","2026-01-08 11:41:59","T","3255","1","RUB","0","MCX","","","False",""
|
||||
"SELL","2026-01-08 11:42:00","SBERP","297,89","10","RUB","8,94","MCX","","","False",""
|
||||
"SELL","2026-01-08 11:42:00","SU26212RMFS9","890,520769","13","RUB","34,73","MCX","32,83000023076923","","False",""
|
||||
"SELL","2026-01-08 11:42:00","TATNP","535,4","2","RUB","3,21","MCX","","","False",""
|
||||
"SELL","2026-01-08 11:42:01","ROSN","400,45","10","RUB","12,01","MCX","","","False",""
|
||||
"BUY","2026-01-08 11:42:01","TMOS","6,37","2114","RUB","0","MCX","","","False",""
|
||||
"BUY","2026-01-08 11:42:02","SBRB","17,683","173","RUB","9,18","MCX","","","False",""
|
||||
"BUY","2026-01-08 11:42:02","TBRU","7,81","1986","RUB","0","MCX","","","False",""
|
||||
"BUY","2026-01-08 11:42:02","TPAY","100,39","43","RUB","0","MCX","","","False",""
|
||||
"BUY","2026-01-12 18:40:57","TDIV","10,269799","696","RUB","0","MCX","","","False",""
|
||||
"SELL","2026-01-12 18:40:57","TMOS","6,380144","695","RUB","0","MCX","","","False",""
|
||||
"SELL","2026-01-12 18:46:05","TDIV","10,240573","262","RUB","0","MCX","","","False",""
|
||||
"DIVIDEND","2026-01-14 11:09:42","T","0","36","RUB","5","MCX","","","False",""
|
||||
"DIVIDEND","2026-01-20 15:55:25","TPAY","0","136,92","RUB","0","MCX","","","False",""
|
||||
"CASH_IN","2026-02-02 11:12:04","RUB","1","5000","RUB","0","","","","False","Пополнение счета"
|
||||
"CASH_IN","2026-02-02 11:32:35","RUB","1","5566","RUB","0","","","","False","Пополнение счета"
|
||||
"BUY","2026-02-02 11:39:30","TBRU","7,8","302","RUB","0","MCX","","","False",""
|
||||
"BUY","2026-02-02 11:39:30","TOFZ","13,57","767","RUB","0","MCX","","","False",""
|
||||
"BUY","2026-02-02 11:39:30","TPAY","100,08","24","RUB","0","MCX","","","False",""
|
||||
"SELL","2026-02-02 11:40:19","TOFZ","13,56","397","RUB","0","MCX","","","False",""
|
||||
"CASH_IN","2026-02-05 14:25:59","RUB","1","5000","RUB","0","","","","False","Пополнение счета"
|
||||
"BUY","2026-02-09 11:24:18","TDIV","10,42","28","RUB","0","MCX","","","False",""
|
||||
"BUY","2026-02-09 11:24:18","TMOS","6,43","279","RUB","0","MCX","","","False",""
|
||||
"BUY","2026-02-09 11:24:18","TOFZ","13,53","22","RUB","0","MCX","","","False",""
|
||||
"BUY","2026-02-09 11:24:19","SBRB","17,781","16","RUB","0,11","MCX","","","False",""
|
||||
"BUY","2026-02-09 11:24:19","TBRU","7,8","153","RUB","0","MCX","","","False",""
|
||||
"BUY","2026-02-09 11:24:19","TGLD","15,47","38","RUB","0","MCX","","","False",""
|
||||
"BUY","2026-02-09 11:24:19","TPAY","100,19","11","RUB","0","MCX","","","False",""
|
||||
"DIVIDEND","2026-02-09 16:36:01","TPAY","0","201,78","RUB","0","MCX","","","False",""
|
||||
"BUY","2026-02-16 11:03:47","TDIV","10,69","1","RUB","0","MCX","","","False",""
|
||||
"BUY","2026-02-16 11:03:47","TMOS","6,6","12","RUB","0","MCX","","","False",""
|
||||
"BUY","2026-02-16 11:03:48","TBRU","7,9","6","RUB","0","MCX","","","False",""
|
||||
"BUY","2026-02-16 11:03:48","TGLD","15,25","1","RUB","0","MCX","","","False",""
|
||||
"SELL","2026-02-18 11:05:26","SBRB","17,917226","266","RUB","1,91","MCX","","","False",""
|
||||
"BUY","2026-02-18 11:05:26","TMOS","6,56","6","RUB","0","MCX","","","False",""
|
||||
"BUY","2026-02-18 11:05:26","TOFZ","13,82","380","RUB","0","MCX","","","False",""
|
||||
"BUY","2026-02-18 11:05:27","TBRU","7,91","3","RUB","0","MCX","","","False",""
|
||||
"SELL","2026-02-21 10:24:30","IVAT","159,55","20","RUB","1,28","MCX","","","False",""
|
||||
"SELL","2026-02-21 10:24:40","ASTR","259,9","20","RUB","2,08","MCX","","","False",""
|
||||
"TAX","2026-02-21 10:24:58","","0","0","RUB","32","","","","False","Удержание налога по ставке 13%"
|
||||
"CASH_OUT","2026-02-21 10:24:58","RUB","1","8388,99","RUB","0","","","","False","Вывод со счета"
|
||||
"CASH_IN","2026-02-24 00:00:00","RUB","1","0,0024","RUB","0","","","","False","Эта сделка добавлена с целью корректировки баланса по этой валюте, т.к. баланс по данным брокера (16,14) не соответствует балансу рассчитанному по сделкам (16,14)."
|
||||
"CASH_IN","2026-02-24 00:00:00","RUB","1","2000","RUB","0","","","","False",""
|
||||
"BUY","2026-02-24 11:31:18","LQDT","1,9291","1036","RUB","0","MCX","","","False",""
|
||||
"TAX_RETURN","2026-02-26 02:13:39","","0","0","RUB","32","","","","False","Корректировка налога по ставке 13%"
|
||||
"CASH_OUT","2026-03-01 18:17:42","RUB","1","32","RUB","0","","","","False","Вывод со счета"
|
||||
"SELL","2026-03-02 11:31:12","TBRU","7,94","2051","RUB","0","MCX","","","False",""
|
||||
"SELL","2026-03-02 11:31:12","TDIV","10,869698","463","RUB","0","MCX","","","False",""
|
||||
"SELL","2026-03-02 11:31:12","TPAY","100,13","178","RUB","0","MCX","","","False",""
|
||||
"SELL","2026-03-02 11:31:13","TGLD","16,69","303","RUB","0","MCX","","","False",""
|
||||
"BUY","2026-03-02 11:31:13","TMOS","6,73","6144","RUB","0","MCX","","","False",""
|
||||
"BUY","2026-03-02 11:31:14","TMOS","6,73","290","RUB","0","MCX","","","False",""
|
||||
"BUY","2026-03-02 11:31:14","TOFZ","13,8","410","RUB","0","MCX","","","False",""
|
||||
"BUY","2026-03-02 11:31:15","TBRU","7,95","17","RUB","0","MCX","","","False",""
|
||||
"CASH_IN","2026-03-05 14:48:54","RUB","1","5000","RUB","0","","","","False","Пополнение счета"
|
||||
"BUY","2026-03-09 11:01:40","TMOS","6,85","283","RUB","0","MCX","","","False",""
|
||||
"BUY","2026-03-09 11:01:40","TOFZ","13,87","29","RUB","0","MCX","","","False",""
|
||||
"BUY","2026-03-09 11:01:41","TBRU","7,97","17","RUB","0","MCX","","","False",""
|
||||
"BUY","2026-03-09 11:01:41","TGLD","15,9","8","RUB","0","MCX","","","False",""
|
||||
"DIVIDEND","2026-03-10 16:55:28","TPAY","0","199,98","RUB","0","MCX","","","False",""
|
||||
"BUY","2026-03-16 11:35:58","TMOS","6,81","32","RUB","0","MCX","","","False",""
|
||||
"BUY","2026-03-16 11:35:59","TBRU","8,07","1","RUB","0","MCX","","","False",""
|
||||
"BUY","2026-03-16 11:35:59","TOFZ","14,06","3","RUB","0","MCX","","","False",""
|
||||
"BUY","2026-03-23 11:11:19","TMOS","6,76","7","RUB","0","MCX","","","False",""
|
||||
"BUY","2026-03-30 11:53:07","TMOS","6,56","7","RUB","0","MCX","","","False",""
|
||||
"CASH_IN","2026-04-01 14:43:30","RUB","1","1100","RUB","0","","","","False","Пополнение счета"
|
||||
"BUY","2026-04-01 14:47:55","RU000A10BF48","1008","1","RUB","3,02","MCX","7,46","","False",""
|
||||
"CASH_IN","2026-04-01 20:05:46","RUB","1","100000","RUB","0","","","","False","Пополнение счета"
|
||||
"BUY","2026-04-01 20:08:22","SU26207RMFS9","964,82","8","RUB","23,16","MCX","12,73","","False",""
|
||||
"BUY","2026-04-01 20:08:40","SU26207RMFS9","964,81","8","RUB","23,16","MCX","12,73","","False",""
|
||||
"BUY","2026-04-01 20:09:02","SU26207RMFS9","964,84","14","RUB","40,52","MCX","12,73","","False",""
|
||||
"BUY","2026-04-01 20:09:33","SU29016RMFS1","1003,88","10","RUB","30,11","MCX","3,24","","False",""
|
||||
"BUY","2026-04-01 20:09:47","SU29007RMFS0","1020","10","RUB","30,6","MCX","13,89","","False",""
|
||||
"BUY","2026-04-01 20:18:20","RU000A10AT35","1029,1","5","RUB","15,44","MCX","6,62","","False",""
|
||||
"BUY","2026-04-01 20:18:33","RU000A10BF48","1008","4","RUB","12,1","MCX","7,46","","False",""
|
||||
"BUY","2026-04-01 20:21:25","RU000A10CQ85","999,2","5","RUB","14,99","MCX","8,44","","False",""
|
||||
"BUY","2026-04-01 20:22:09","RU000A10B0U0","1070,5","5","RUB","16,06","MCX","1,26","","False",""
|
||||
"BUY","2026-04-01 20:23:59","RU000A10B214","1042,4","5","RUB","15,64","MCX","13,36","","False",""
|
||||
"CUSTOM_HOLDING_PRICE","2026-04-03 00:00:00","SIBN6P4","11353,38392","0","RUB","0","CUSTOM_HOLDING","0","","",""
|
||||
"BUY","2026-04-03 16:38:22","SIBN6P4","11353,38392","2","RUB","68,12","CUSTOM_HOLDING","25,35108","","False",""
|
||||
"CASH_IN","2026-04-05 14:36:58","RUB","1","5000","RUB","0","","","","False","Пополнение счета"
|
||||
"BUY","2026-04-06 11:24:08","TMOS","6,49","558","RUB","0","MCX","","","False",""
|
||||
"BUY","2026-04-06 11:24:09","TMOS","6,49","543","RUB","0","MCX","","","False",""
|
||||
"BUY","2026-04-06 11:24:10","TPAY","100,92","1","RUB","0","MCX","","","False",""
|
||||
"SELL","2026-04-06 11:51:39","TMOS","6,48","539","RUB","0","MCX","","","False",""
|
||||
"DIVIDEND","2026-04-07 15:55:02","RU000A10B214","0","77,05","RUB","0","MCX","","","False",""
|
||||
"CASH_IN","2026-04-08 00:00:00","RUB","1","3000","RUB","0","","","","False",""
|
||||
"BUY","2026-04-08 15:24:35","LQDT","1,9629","1529","RUB","0","MCX","","","False",""
|
||||
"DIVIDEND","2026-04-09 13:13:04","TPAY","0","33,01","RUB","0","MCX","","","False",""
|
||||
"CASH_IN","2026-04-11 21:18:45","RUB","1","5000","RUB","0","","","","False","Пополнение счета"
|
||||
"BUY","2026-04-12 10:34:35","SU26207RMFS9","966,13","5","RUB","14,49","MCX","15,41","","False",""
|
||||
"CASH_IN","2026-04-12 10:46:14","RUB","1","10000","RUB","0","","","","False","Пополнение счета"
|
||||
"BUY","2026-04-12 10:46:24","SU29007RMFS0","1020,5","5","RUB","15,31","MCX","19,63","","False",""
|
||||
"BUY","2026-04-12 10:46:32","SU29016RMFS1","1001,35","5","RUB","15,02","MCX","8,13","","False",""
|
||||
"BUY","2026-04-13 12:54:35","TBRU","8,16","7","RUB","0","MCX","","","False",""
|
||||
"BUY","2026-04-13 12:54:35","TGLD","14,24","4","RUB","0","MCX","","","False",""
|
||||
"BUY","2026-04-13 12:54:35","TMOS","6,45","136","RUB","0","MCX","","","False",""
|
||||
"BUY","2026-04-13 12:54:35","TOFZ","14,09","13","RUB","0","MCX","","","False",""
|
||||
"DIVIDEND","2026-04-13 13:44:05","RU000A10CQ85","0","57,55","RUB","0","MCX","","","False",""
|
||||
"CASH_IN","2026-04-15 22:54:40","RUB","1","1200","RUB","0","","","","False","Пополнение счета"
|
||||
"SPLIT","2026-04-16 03:00:00","T","10","0","RUB","0","MCX","0","","",""
|
||||
"SPLIT","2026-04-16 03:00:00","T","10","0","RUB","0","MCX","0","","",""
|
||||
"CASH_IN","2026-04-16 14:08:35","RUB","1","1100","RUB","0","","","","False","Пополнение счета"
|
||||
"CASH_IN","2026-04-16 14:08:36","RUB","1","1100","RUB","0","","","","False","Пополнение счета"
|
||||
"CASH_IN","2026-04-17 00:00:00","RUB","1","1100","RUB","0","","","","False",""
|
||||
"CASH_IN","2026-04-17 00:00:00","RUB","1","1110","RUB","0","","","","False","ЗАЧИСЛЕНИЕ Д/С"
|
||||
"CASH_OUT","2026-04-17 00:00:00","RUB","1","1017,83999543","RUB","0","","","","False","Эта сделка добавлена с целью корректировки баланса по этой валюте, т.к. баланс по данным брокера (357,15) не соответствует балансу рассчитанному по сделкам (1374,99)."
|
||||
"BUY","2026-04-17 00:00:00","STME","4,285","262","RUB","0,22","MCX","","","False",""
|
||||
"DIVIDEND","2026-04-17 13:25:18","RU000A10BF48","0","69,3","RUB","0","MCX","","","False",""
|
||||
"BUY","2026-04-17 15:04:10","LQDT","1,9714","558","RUB","0","MCX","","","False",""
|
||||
"CASH_IN","2026-04-20 00:00:00","RUB","1","3458,88","RUB","0","","","","False","ПЕРЕВОД Д/С С ДОГОВОРА 8184V30, ТП ОСНОВНОЙ РЫНОК НА ДОГОВОР S930W42, ТП ОСНОВНОЙ РЫНОК"
|
||||
"BUY","2026-04-20 12:23:33","TMOS","6,45629","124","RUB","0","MCX","","","False",""
|
||||
"BUY","2026-04-20 12:23:34","TBRU","8,21","6","RUB","0","MCX","","","False",""
|
||||
"BUY","2026-04-20 12:23:34","TGLD","14,35","4","RUB","0","MCX","","","False",""
|
||||
"BUY","2026-04-20 12:23:34","TOFZ","14,24","12","RUB","0","MCX","","","False",""
|
||||
"BUY","2026-04-20 12:33:50","RU000A10BF48","1008,2","1","RUB","3,02","MCX","2,29","","False",""
|
||||
"BUY","2026-04-20 12:34:00","RU000A10B214","1046,6","1","RUB","3,14","MCX","7,71","","False",""
|
||||
"BUY","2026-04-20 12:34:08","RU000A10CQ85","999,9","1","RUB","3","MCX","4,22","","False",""
|
||||
"BUY","2026-04-20 12:34:16","RU000A10AT35","1026,6","1","RUB","3,08","MCX","1,42","","False",""
|
||||
"DIVIDEND","2026-04-20 17:33:41","RU000A10AT35","0","70,9","RUB","0","MCX","","","False",""
|
||||
"BUY","2026-04-22 00:00:00","SBRB","18,6130117","342","RUB","1,27","MCX","","","False",""
|
||||
"SELL","2026-04-22 00:00:00","STME","4,24500713","701","RUB","0,6","MCX","","","False",""
|
||||
"SELL","2026-04-22 15:04:08","TPAY","101,28","34","RUB","0","MCX","","","False",""
|
||||
"BUY","2026-04-22 15:04:09","TBRU","8,24","2115","RUB","0","MCX","","","False",""
|
||||
"SELL","2026-04-22 15:04:09","TOFZ","14,28","1239","RUB","0","MCX","","","False",""
|
||||
"BUY","2026-04-22 15:04:10","TGLD","14,17","320","RUB","0","MCX","","","False",""
|
||||
"BUY","2026-04-22 15:04:10","TMOS","6,51","3","RUB","0","MCX","","","False",""
|
||||
"SELL","2026-04-22 15:22:42","LQDT","1,9735","3123","RUB","0","MCX","","","False",""
|
||||
"BUY","2026-04-22 15:23:14","EQMX","143,2","43","RUB","1,24","MCX","","","False",""
|
||||
"CASH_IN","2026-04-28 00:00:00","RUB","1","4000","RUB","0","","","","False",""
|
||||
"CASH_IN","2026-04-28 00:00:00","RUB","1","4000,81","RUB","0","","","","False","ЗАЧИСЛЕНИЕ Д/С"
|
||||
"BUY","2026-04-28 00:00:00","SBRB","18,68601852","216","RUB","0,8","MCX","","","False",""
|
||||
"BUY","2026-04-28 11:10:13","EQMX","141,6","28","RUB","0,8","MCX","","","False",""
|
||||
"DIVIDEND","2026-04-28 16:28:09","SIBN6P4","0","131,44","RUB","0","CUSTOM_HOLDING","","","False",""
|
||||
"CASH_IN","2026-05-01 00:16:15","RUB","1","760","RUB","0","","","","False","Пополнение счета"
|
||||
"DIVIDEND","2026-05-04 13:13:02","RU000A10B0U0","0","94,5","RUB","0","MCX","","","False",""
|
||||
"CASH_IN","2026-05-05 13:48:20","RUB","1","5000","RUB","0","","","","False","Пополнение счета"
|
||||
"DIVIDEND","2026-05-07 11:34:21","RU000A10B214","0","92,46","RUB","0","MCX","","","False",""
|
||||
"CASH_IN","2026-05-10 10:27:31","RUB","1","2000","RUB","0","","","","False","Пополнение счета"
|
||||
"BUY","2026-05-11 13:45:38","TMOS","6,36","485","RUB","0","MCX","","","False",""
|
||||
"BUY","2026-05-11 13:45:39","TGLD","13,6","24","RUB","0","MCX","","","False",""
|
||||
"BUY","2026-05-11 13:45:39","TMOS","6,36","464","RUB","0","MCX","","","False",""
|
||||
"CASH_IN","2026-05-12 16:23:54","RUB","1","9000","RUB","0","","","","False","Пополнение счета"
|
||||
"BUY","2026-05-12 16:26:57","RU000A10B0U0","1066,7","5","RUB","16","MCX","8,19","","False",""
|
||||
"BUY","2026-05-12 16:27:09","RU000A10AT35","1031,5","4","RUB","12,38","MCX","11,82","","False",""
|
||||
"BUY","2026-05-12 16:27:44","RU000A10BF48","1008","2","RUB","6,05","MCX","12,19","","False",""
|
||||
"DIVIDEND","2026-05-13 12:22:10","RU000A10CQ85","0","69,06","RUB","0","MCX","","","False",""
|
||||
"SELL","2026-05-18 14:13:35","TMOS","6,3","488","RUB","0","MCX","","","False",""
|
||||
"DIVIDEND","2026-05-18 17:46:19","RU000A10AT35","0","141,8","RUB","0","MCX","","","False",""
|
||||
"DIVIDEND","2026-05-19 12:28:16","RU000A10BF48","0","108,16","RUB","0","MCX","","","False",""
|
||||
"BUY","2026-05-25 14:33:45","TMOS","6,28","98","RUB","0","MCX","","","False",""
|
||||
"BUY","2026-05-25 14:33:46","TBRU","8,32","21","RUB","0","MCX","","","False",""
|
||||
"BUY","2026-05-25 14:33:46","TGLD","12,81","5","RUB","0","MCX","","","False",""
|
||||
"DIVIDEND","2026-05-26 18:44:26","SIBN6P4","0","124,94","RUB","0","CUSTOM_HOLDING","","","False",""
|
||||
"CASH_IN","2026-05-28 00:00:00","RUB","1","4000","RUB","0","","","","False",""
|
||||
"CASH_IN","2026-05-28 00:00:00","RUB","1","4000","RUB","0","","","","False","ЗАЧИСЛЕНИЕ Д/С"
|
||||
"BUY","2026-05-28 00:00:00","SBRB","18,89099057","212","RUB","0,8","MCX","","","False",""
|
||||
"BUY","2026-05-28 11:06:55","EQMX","136,3","29","RUB","0,79","MCX","","","False",""
|
||||
"BUY","2026-06-01 16:08:51","TBRU","8,33","1","RUB","0","MCX","","","False",""
|
||||
"BUY","2026-06-01 16:08:51","TMOS","6,15","5","RUB","0","MCX","","","False",""
|
||||
"DIVIDEND","2026-06-02 12:30:21","RU000A10B0U0","0","189","RUB","0","MCX","","","False",""
|
||||
"CASH_IN","2026-06-05 12:10:51","RUB","1","5000","RUB","0","","","","False","Пополнение счета"
|
||||
"DIVIDEND","2026-06-08 12:41:38","RU000A10B214","0","92,46","RUB","0","MCX","","","False",""
|
||||
"BUY","2026-06-08 14:44:52","TMOS","6,1","588","RUB","0","MCX","","","False",""
|
||||
"BUY","2026-06-08 14:44:52","TMOS","6,1","576","RUB","0","MCX","","","False",""
|
||||
"SELL","2026-06-08 14:47:22","TMOS","6,09","569","RUB","0","MCX","","","False",""
|
||||
"BUY","2026-06-08 15:13:02","TMOS","6,09","151","RUB","0","MCX","","","False",""
|
||||
"BUY","2026-06-08 15:13:03","TBRU","8,36","31","RUB","0","MCX","","","False",""
|
||||
"BUY","2026-06-08 15:13:03","TGLD","12,48","8","RUB","0","MCX","","","False",""
|
||||
"DIVIDEND","2026-06-10 13:22:42","RU000A10CQ85","0","69,06","RUB","0","MCX","","","False",""
|
||||
"BUY","2026-06-15 16:28:53","TMOS","6,06","3","RUB","0","MCX","","","False",""
|
||||
"CASH_IN","2026-06-15 19:47:02","RUB","1","10000","RUB","0","","","","False","Пополнение счета"
|
||||
"BUY","2026-06-15 20:22:47","RU000A10B214","1039,9","4","RUB","12,48","MCX","5,65","","False",""
|
||||
"BUY","2026-06-15 20:22:56","RU000A10BF48","1008","2","RUB","6,05","MCX","0,44","","False",""
|
||||
"BUY","2026-06-15 20:23:06","RU000A10CQ85","1004,2","4","RUB","12,05","MCX","2,68","","False",""
|
||||
"DIVIDEND","2026-06-16 15:47:20","RU000A10BF48","0","106,56","RUB","0","MCX","","","False",""
|
||||
"DIVIDEND","2026-06-18 10:10:16","RU000A10AT35","0","141,8","RUB","0","MCX","","","False",""
|
||||
"BUY","2026-06-22 16:08:34","TBRU","8,28","3","RUB","0","MCX","","","False",""
|
||||
"BUY","2026-06-22 16:08:34","TMOS","5,63","17","RUB","0","MCX","","","False",""
|
||||
"DIVIDEND","2026-06-24 16:34:47","SU29016RMFS1","0","538,05","RUB","0","MCX","","","False",""
|
||||
"DIVIDEND","2026-06-25 19:11:14","SIBN6P4","0","131,06","RUB","0","CUSTOM_HOLDING","","","False",""
|
||||
"BUY","2026-06-26 23:28:57","TMOS","5,51","417","RUB","0","MCX","","","False",""
|
||||
"CASH_IN","2026-06-28 00:00:00","RUB","1","4000","RUB","0","","","","False",""
|
||||
"CASH_IN","2026-06-28 00:00:00","RUB","1","4000","RUB","0","","","","False","ЗАЧИСЛЕНИЕ Д/С"
|
||||
"BUY","2026-06-28 00:00:00","SBRB","18,76201878","213","RUB","0,8","MCX","","","False",""
|
||||
"BUY","2026-06-28 14:13:46","EQMX","121,6","33","RUB","0","MCX","","","False",""
|
||||
"DIVIDEND","2026-06-30 12:00:37","RU000A10B0U0","0","189","RUB","0","MCX","","","False",""
|
||||
"CASH_IN","2026-07-01 12:25:32","RUB","1","1000","RUB","0","","","","False","Пополнение счета"
|
||||
"CASH_IN","2026-07-01 12:25:56","RUB","1","1000","RUB","0","","","","False","Пополнение счета"
|
||||
"BUY","2026-07-01 12:39:46","TGLD","12,17","68","RUB","0","MCX","","","False",""
|
||||
"DIVIDEND","2026-07-07 13:30:58","RU000A10B214","0","154,1","RUB","0","MCX","","","False",""
|
||||
"DIVIDEND","2026-07-10 10:36:35","RU000A10CQ85","0","115,1","RUB","0","MCX","","","False",""
|
||||
"BUY","2026-07-10 10:43:57","RU000A10CQ85","997,1","1","RUB","0,4","MCX","1,53","","False",""
|
||||
"BUY","2026-07-10 10:44:04","RU000A10CQ85","997,2","2","RUB","0,8","MCX","1,53","","False",""
|
||||
"CASH_IN","2026-07-13 11:03:09","RUB","1","10000","RUB","0","","","","False","Пополнение счета"
|
||||
"CASH_IN","2026-07-13 11:03:15","RUB","1","10000","RUB","0","","","","False","Пополнение счета"
|
||||
"CASH_IN","2026-07-13 11:37:10","RUB","1","5000","RUB","0","","","","False","Пополнение счета"
|
||||
"BUY","2026-07-13 12:32:18","TBRU","8,21","217","RUB","0","MCX","","","False",""
|
||||
"BUY","2026-07-13 12:32:18","TMOS","5,29","1523","RUB","0","MCX","","","False",""
|
||||
"BUY","2026-07-13 12:32:19","TGLD","12,23","58","RUB","0","MCX","","","False",""
|
||||
"BUY","2026-07-13 12:33:16","RU000A10B0U0","1044,2","3","RUB","1,25","MCX","9,45","","False",""
|
||||
"BUY","2026-07-14 07:18:33","RU000A10B0U0","1045","2","RUB","0,84","MCX","10,08","","False",""
|
||||
"BUY","2026-07-14 07:18:48","RU000A10CQ85","998,3","2","RUB","0,8","MCX","2,3","","False",""
|
||||
"BUY","2026-07-14 07:18:57","RU000A10AT35","1026,2","5","RUB","2,05","MCX","13,23","","False",""
|
||||
"BUY","2026-07-14 07:19:14","SU29016RMFS1","1000,96","2","RUB","0,8","MCX","8,13","","False",""
|
||||
"DIVIDEND","2026-07-16 12:32:09","RU000A10BF48","0","131,8","RUB","0","MCX","","","False",""
|
||||
"DIVIDEND","2026-07-17 17:42:53","RU000A10AT35","0","212,7","RUB","0","MCX","","","False",""
|
||||
"BUY","2026-07-19 15:39:27","TMOS","4,77","130","RUB","0","MCX","","","False",""
|
||||
"SELL","2026-07-20 13:22:33","TBRU","8,06","290","RUB","0","MCX","","","False",""
|
||||
"CASH_IN","2026-07-23 00:00:00","RUB","1","10000","RUB","0","","","","False","ЗАЧИСЛЕНИЕ Д/С"
|
||||
"BUY","2026-07-23 00:00:00","SBER","263,68","17","RUB","13,45","MCX","","","False",""
|
||||
"BUY","2026-07-23 00:00:00","SBER","263,68","3","RUB","2,37","MCX","","","False",""
|
||||
"BUY","2026-07-24 00:00:00","MOEX","146,28","10","RUB","4,39","MCX","","","False",""
|
||||
"BUY","2026-07-24 00:00:00","MOEX","146,28","20","RUB","8,78","MCX","","","False",""
|
||||
"DIVIDEND","2026-07-28 09:53:12","SIBN6P4","0","138,06","RUB","0","CUSTOM_HOLDING","","","False",""
|
||||
"DIVIDEND","2026-07-30 11:50:58","RU000A10B0U0","0","283,5","RUB","0","MCX","","","False",""
|
||||
"BUY","2026-07-30 12:13:11","SU26207RMFS9","978,99","1","RUB","2,94","MCX","39,52","","False",""
|
||||
"DIVIDEND","2026-08-05 13:31:58","RU000A10B214","0","154,1","RUB","0","MCX","","","False",""
|
||||
"DIVIDEND","2026-08-05 18:29:11","SU26207RMFS9","0","1463,04","RUB","0","MCX","","","False",""
|
||||
"BUY","2026-08-05 22:37:13","SU26207RMFS9","978,88","1","RUB","2,94","MCX","0,22","","False",""
|
||||
"DIVIDEND","2026-08-11 13:40:31","RU000A10CQ85","0","172,65","RUB","0","MCX","","","False",""
|
||||
"BUY","2026-08-13 00:00:00","LKOH","4534,5","1","RUB","14,96","MCX","","","False",""
|
||||
"CASH_IN","2026-08-13 00:00:00","RUB","1","4000","RUB","0","","","","False",""
|
||||
"CASH_IN","2026-08-13 00:00:00","RUB","1","10551,18","RUB","0","","","","False","ПЕРЕВОД Д/С С ДОГОВОРА 8184V30, ТП ОСНОВНОЙ РЫНОК НА ДОГОВОР S930W42, ТП ОСНОВНОЙ РЫНОК"
|
||||
"BUY","2026-08-13 00:00:00","TATN","548,3","9","RUB","16,29","MCX","","","False",""
|
||||
"CASH_IN","2026-08-13 11:05:50","RUB","1","14000","RUB","0","","","","False","Пополнение счета"
|
||||
"CASH_IN","2026-08-13 13:06:52","RUB","1","10000","RUB","0","","","","False","Пополнение счета"
|
||||
"BUY","2026-08-13 15:56:15","EQMX","122,9","33","RUB","0,81","MCX","","","False",""
|
||||
"BUY","2026-08-14 14:13:20","SU29016RMFS1","1000,8","5","RUB","15,01","MCX","20,92","","False",""
|
||||
"BUY","2026-08-14 14:13:42","SU26207RMFS9","979,08","6","RUB","17,62","MCX","2,68","","False",""
|
||||
"DIVIDEND","2026-08-17 13:13:29","RU000A10BF48","0","130,1","RUB","0","MCX","","","False",""
|
||||
"DIVIDEND","2026-08-17 16:37:48","RU000A10AT35","0","212,7","RUB","0","MCX","","","False",""
|
||||
"BUY","2026-08-18 14:52:52","TBRU","8,41","427","RUB","0","MCX","","","False",""
|
||||
"BUY","2026-08-18 14:52:52","TMOS","5,34","1843","RUB","0","MCX","","","False",""
|
||||
"DIVIDEND","2026-08-26 09:18:46","SIBN6P4","0","147,02","RUB","0","CUSTOM_HOLDING","","","False",""
|
||||
"CASH_IN","2026-08-27 11:04:19","RUB","1","450","RUB","0","","","","False","Пополнение счета"
|
||||
"BUY","2026-08-27 11:04:52","TMOS","5,16","121","RUB","0","MCX","","","False",""
|
||||
"CASH_IN","2026-08-28 11:45:37","RUB","1","450","RUB","0","","","","False","Пополнение счета"
|
||||
"CASH_IN","2026-08-29 12:12:09","RUB","1","450","RUB","0","","","","False","Пополнение счета"
|
||||
"CASH_IN","2026-08-30 12:07:12","RUB","1","450","RUB","0","","","","False","Пополнение счета"
|
||||
"BUY","2026-08-31 11:00:16","TBRU","8,44","40","RUB","0","MCX","","","False",""
|
||||
"BUY","2026-08-31 11:00:16","TMOS","5,33","173","RUB","0","MCX","","","False",""
|
||||
"DIVIDEND","2026-08-31 12:29:47","RU000A10B0U0","0","283,5","RUB","0","MCX","","","False",""
|
||||
"CASH_IN","2026-08-31 13:44:09","RUB","1","450","RUB","0","","","","False","Пополнение счета"
|
||||
"BUY","2026-09-01 11:00:11","TBRU","8,46","25","RUB","0","MCX","","","False",""
|
||||
"BUY","2026-09-01 11:00:11","TMOS","5,43","38","RUB","0","MCX","","","False",""
|
||||
"CASH_IN","2026-09-01 12:49:47","RUB","1","450","RUB","0","","","","False","Пополнение счета"
|
||||
"BUY","2026-09-02 11:00:17","TBRU","8,45","18","RUB","0","MCX","","","False",""
|
||||
"BUY","2026-09-02 11:00:17","TMOS","5,4","56","RUB","0","MCX","","","False",""
|
||||
"CASH_IN","2026-09-02 14:10:10","RUB","1","450","RUB","0","","","","False","Пополнение счета"
|
||||
"DIVIDEND","2026-09-03 10:57:34","SU29007RMFS0","0","1307,4","RUB","0","MCX","","","False",""
|
||||
"BUY","2026-09-03 11:00:18","TBRU","8,45","113","RUB","0","MCX","","","False",""
|
||||
"BUY","2026-09-03 11:00:18","TMOS","5,45","425","RUB","0","MCX","","","False",""
|
||||
"BUY","2026-09-03 11:20:02","SU26207RMFS9","980,48","2","RUB","5,88","MCX","6,7","","False",""
|
||||
"CASH_IN","2026-09-03 13:52:30","RUB","1","450","RUB","0","","","","False","Пополнение счета"
|
||||
"BUY","2026-09-04 11:00:21","TMOS","5,58","9","RUB","0","MCX","","","False",""
|
||||
"BUY","2026-09-04 11:00:22","TBRU","8,45","50","RUB","0","MCX","","","False",""
|
||||
"CASH_IN","2026-09-04 11:31:06","RUB","1","450","RUB","0","","","","False","Пополнение счета"
|
||||
"DIVIDEND","2026-09-04 12:06:55","RU000A10B214","0","154,1","RUB","0","MCX","","","False",""
|
||||
"CASH_IN","2026-09-05 13:46:10","RUB","1","450","RUB","0","","","","False","Пополнение счета"
|
||||
"CASH_IN","2026-09-06 14:12:11","RUB","1","450","RUB","0","","","","False","Пополнение счета"
|
||||
"BUY","2026-09-07 11:00:18","TBRU","8,46","66","RUB","0","MCX","","","False",""
|
||||
"BUY","2026-09-07 11:00:18","TMOS","5,58","140","RUB","0","MCX","","","False",""
|
||||
"BUY","2026-09-08 11:00:20","TBRU","8,46","2","RUB","0","MCX","","","False",""
|
||||
"DIVIDEND","2026-09-08 13:02:22","RU000A10CQ85","0","172,65","RUB","0","MCX","","","False",""
|
||||
"CASH_IN","2026-09-11 16:30:24","RUB","1","2300","RUB","0","","","","False","Пополнение счета"
|
||||
"BUY","2026-09-11 18:32:59","TBRU","8,47","97","RUB","0","MCX","","","False",""
|
||||
"BUY","2026-09-11 18:32:59","TGLD","14,32","41","RUB","0","MCX","","","False",""
|
||||
"BUY","2026-09-11 18:32:59","TMOS","5,69","151","RUB","0","MCX","","","False",""
|
||||
"CUSTOM_HOLDING_PRICE","2026-09-12 00:00:00","SIBN6P4","12228,06098","0","RUB","0","CUSTOM_HOLDING","0","","",""
|
||||
"CASH_IN","2026-09-12 11:31:49","RUB","1","450","RUB","0","","","","False","Пополнение счета"
|
||||
"CUSTOM_HOLDING_PRICE","2026-09-13 00:00:00","SIBN6P4","12228,06098","0","RUB","0","CUSTOM_HOLDING","0","","",""
|
||||
"CASH_IN","2026-09-13 13:32:57","RUB","1","450","RUB","0","","","","False","Пополнение счета"
|
||||
"CASH_IN","2026-09-13 14:37:16","RUB","1","10000","RUB","0","","","","False","Пополнение счета"
|
||||
"CUSTOM_HOLDING_PRICE","2026-09-14 00:00:00","SIBN6P4","12320,94504","0","RUB","0","CUSTOM_HOLDING","0","","",""
|
||||
"BUY","2026-09-14 11:00:21","TBRU","8,48","60","RUB","0","MCX","","","False",""
|
||||
"BUY","2026-09-14 11:00:21","TMOS","5,78","10","RUB","0","MCX","","","False",""
|
||||
"BUY","2026-09-14 11:00:22","TGLD","14,15","25","RUB","0","MCX","","","False",""
|
||||
"CASH_IN","2026-09-14 14:16:05","RUB","1","450","RUB","0","","","","False","Пополнение счета"
|
||||
"CUSTOM_HOLDING_PRICE","2026-09-15 00:00:00","SIBN6P4","12284,594","0","RUB","0","CUSTOM_HOLDING","0","","",""
|
||||
"BUY","2026-09-15 11:00:18","TBRU","8,49","39","RUB","0","MCX","","","False",""
|
||||
"BUY","2026-09-15 11:00:18","TGLD","14,2","8","RUB","0","MCX","","","False",""
|
||||
"CASH_IN","2026-09-15 14:34:11","RUB","1","450","RUB","0","","","","False","Пополнение счета"
|
||||
"DIVIDEND","2026-09-15 15:56:39","RU000A10BF48","0","129","RUB","0","MCX","","","False",""
|
||||
"DIVIDEND","2026-09-15 16:51:49","RU000A10AT35","0","212,7","RUB","0","MCX","","","False",""
|
||||
"BUY","2026-09-16 00:00:00","AFLT","32,65","30","RUB","3,22","MCX","0","RUB","False",""
|
||||
"BUY","2026-09-16 00:00:00","AFLT","32,66","100","RUB","10,78","MCX","0","RUB","False",""
|
||||
"BUY","2026-09-16 00:00:00","CHMF","638,4","2","RUB","4,22","MCX","0","RUB","False",""
|
||||
"BUY","2026-09-16 00:00:00","CHMF","639,4","5","RUB","10,55","MCX","0","RUB","False",""
|
||||
"BUY","2026-09-16 00:00:00","MSNG","1,44","1000","RUB","4,75","MCX","0","RUB","False",""
|
||||
"BUY","2026-09-16 00:00:00","MSNG","1,44","2000","RUB","9,49","MCX","0","RUB","False",""
|
||||
"BUY","2026-09-16 00:00:00","MTSS","189,8","2","RUB","1,26","MCX","0","RUB","False",""
|
||||
"BUY","2026-09-16 00:00:00","MTSS","189,1","10","RUB","6,25","MCX","0","RUB","False",""
|
||||
"BUY","2026-09-16 00:00:00","MTSS","190,25","5","RUB","3,14","MCX","0","RUB","False",""
|
||||
"BUY","2026-09-16 00:00:00","PHOR","5309","2","RUB","35,05","MCX","0","RUB","False",""
|
||||
"CASH_IN","2026-09-16 00:00:00","RUB","1","10000","RUB","0","","","","False",""
|
||||
"SELL","2026-09-16 00:00:00","SBRB","19,06","983","RUB","3,75","MCX","0","RUB","False",""
|
||||
"BUY","2026-09-16 11:00:22","TMOS","5,8","80","RUB","0","MCX","","","False",""
|
||||
"CASH_IN","2026-09-16 11:31:47","RUB","1","450","RUB","0","","","","False","Пополнение счета"
|
||||
"CUSTOM_HOLDING_SETTINGS","2026-09-17 00:00:00","SIBN6P4","0","0","RUB","0","CUSTOM_HOLDING","0","","","{@*@Holding@*@:{@*@Note@*@:null,@*@Currency@*@:@*@RUB@*@,@*@Description@*@:@*@\u0413\u0430\u0437\u043F\u0440\u043E\u043C \u041D\u0435\u0444\u0442\u044C 006\u0420-04@*@,@*@DividendTax@*@:null,@*@Sector@*@:@*@Other@*@},@*@Settings@*@:{@*@CustomHoldingType@*@:0,@*@IncomeType@*@:1,@*@FirstIncomeDate@*@:null,@*@GenerateIncome@*@:false,@*@IncomeAmount@*@:null,@*@IncomeReinvestmentType@*@:0,@*@IsIncomeReinvested@*@:false,@*@MaturityDate@*@:null,@*@Period@*@:null,@*@PeriodType@*@:0,@*@NextIncomeDate@*@:null}}"
|
||||
"BUY","2026-09-17 11:00:21","TMOS","5,61","80","RUB","0","MCX","","","False",""
|
||||
"CASH_IN","2026-09-17 11:29:23","RUB","1","450","RUB","0","","","","False","Пополнение счета"
|
||||
"BUY","2026-09-17 16:23:00","RU000A10BK09","1014,4","2","RUB","6,09","MCX","8,33","","False",""
|
||||
"BUY","2026-09-17 16:23:11","RU000A10BK09","1014,4","3","RUB","9,13","MCX","8,33","","False",""
|
||||
"BUY","2026-09-17 16:23:18","RU000A10BK09","1014,5","1","RUB","3,04","MCX","8,33","","False",""
|
||||
"BUY","2026-09-17 16:23:50","RU000A10BF48","1006","4","RUB","12,07","MCX","2,15","","False",""
|
||||
"SELL","2026-09-17 16:24:21","RU000A10CQ85","1000,2","9","RUB","27","MCX","4,22","","False",""
|
||||
"SELL","2026-09-17 16:24:26","RU000A10CQ85","1000,1","1","RUB","3","MCX","4,22","","False",""
|
||||
"SELL","2026-09-17 16:24:30","RU000A10CQ85","1000","5","RUB","15","MCX","4,22","","False",""
|
||||
"BUY","2026-09-17 16:24:46","RU000A10BK09","1014,4","7","RUB","21,3","MCX","8,33","","False",""
|
||||
"BUY","2026-09-17 16:24:54","RU000A10BF48","1006","8","RUB","24,14","MCX","2,15","","False",""
|
||||
|
BIN
Binary file not shown.
BIN
Binary file not shown.
@@ -0,0 +1,420 @@
|
||||
"""The universal event-CSV parser, checked against the real Snowball export.
|
||||
|
||||
The fixture is the only file in the project that covers every brokerage account at once,
|
||||
so these tests double as its documentation: the exact number of rows of each type, which
|
||||
of them deliberately never become ledger events, and what the format's ambiguities were
|
||||
decided to mean. A change in any of those numbers is a change in the import contract, not
|
||||
a test that needs relaxing.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import collections
|
||||
import dataclasses
|
||||
from decimal import Decimal
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
|
||||
from fintracker.models.ledger import EventKind
|
||||
from fintracker.sources.reports.base import BrokerEvent, ParsedReport, fingerprint_key
|
||||
from fintracker.sources.reports.csv_universal import UniversalCsvParser
|
||||
|
||||
FIXTURES = Path(__file__).resolve().parents[1] / "fixtures" / "reports"
|
||||
SNOWBALL = FIXTURES / "snowball"
|
||||
|
||||
HEADER = (
|
||||
"Event,Date,Symbol,Price,Quantity,Currency,FeeTax,Exchange,NKD,FeeCurrency,DoNotAdjustCash,Note"
|
||||
)
|
||||
|
||||
#: What the fixture must yield, per kind. Sums to 535 of its 545 rows: 5 CUSTOM_HOLDING_PRICE
|
||||
#: and 1 CUSTOM_HOLDING_SETTINGS are not events at all, and 4 cash rows are Snowball's own
|
||||
#: balance corrections.
|
||||
EXPECTED_COUNTS = {
|
||||
EventKind.buy: 270,
|
||||
EventKind.sell: 60,
|
||||
EventKind.dividend: 80,
|
||||
EventKind.deposit: 96,
|
||||
EventKind.withdrawal: 5,
|
||||
EventKind.amortization: 9,
|
||||
EventKind.commission: 7,
|
||||
EventKind.tax: 4,
|
||||
EventKind.tax_refund: 2,
|
||||
EventKind.stock_split: 2,
|
||||
}
|
||||
|
||||
|
||||
@pytest.fixture(scope="module")
|
||||
def parser() -> UniversalCsvParser:
|
||||
return UniversalCsvParser()
|
||||
|
||||
|
||||
@pytest.fixture(scope="module")
|
||||
def fixture_path() -> Path:
|
||||
files = sorted(SNOWBALL.glob("*.csv"))
|
||||
assert files, f"нет фикстуры в {SNOWBALL}"
|
||||
return files[0]
|
||||
|
||||
|
||||
@pytest.fixture(scope="module")
|
||||
def report(parser: UniversalCsvParser, fixture_path: Path) -> ParsedReport:
|
||||
return parser.parse(fixture_path.read_bytes(), fixture_path.name)
|
||||
|
||||
|
||||
def _csv(*rows: str) -> bytes:
|
||||
return ("" + "\n".join((HEADER, *rows)) + "\n").encode("utf-8")
|
||||
|
||||
|
||||
def _key(report: ParsedReport, event: BrokerEvent) -> str:
|
||||
"""The dedupe key `ledger/ingest.py` will build for this event (plan §1.6 A)."""
|
||||
return fingerprint_key(
|
||||
report.broker,
|
||||
report.account_external_id,
|
||||
event.kind,
|
||||
event.instrument.key() if event.instrument else "",
|
||||
event.trade_date,
|
||||
event.quantity,
|
||||
event.price,
|
||||
event.currency,
|
||||
amount=event.amount,
|
||||
seq=event.seq,
|
||||
)
|
||||
|
||||
|
||||
def _floats(value: object, path: str) -> list[str]:
|
||||
"""Every float reachable from `value`, by path — money must be Decimal end to end."""
|
||||
if isinstance(value, bool | str | bytes) or value is None:
|
||||
return []
|
||||
if isinstance(value, float):
|
||||
return [f"{path} = {value!r}"]
|
||||
if dataclasses.is_dataclass(value) and not isinstance(value, type):
|
||||
return [
|
||||
bad
|
||||
for f in dataclasses.fields(value)
|
||||
for bad in _floats(getattr(value, f.name), f"{path}.{f.name}")
|
||||
]
|
||||
if isinstance(value, dict):
|
||||
return [bad for k, v in value.items() for bad in _floats(v, f"{path}[{k!r}]")]
|
||||
if isinstance(value, list | tuple | set):
|
||||
return [bad for i, v in enumerate(value) for bad in _floats(v, f"{path}[{i}]")]
|
||||
return []
|
||||
|
||||
|
||||
# --- 1. sniff -------------------------------------------------------------------------
|
||||
|
||||
|
||||
def test_sniff_recognises_the_fixture(parser: UniversalCsvParser, fixture_path: Path) -> None:
|
||||
assert parser.sniff(fixture_path.read_bytes(), fixture_path.name) is True
|
||||
|
||||
|
||||
def test_sniff_ignores_the_filename(parser: UniversalCsvParser) -> None:
|
||||
"""Recognition is by the header, so a renamed export is still recognised..."""
|
||||
data = _csv('"CASH_IN","2025-02-26 09:22:54","RUB","1","5000","RUB","0","","","","False",""')
|
||||
assert parser.sniff(data, "какой-то-файл.csv") is True
|
||||
assert parser.sniff(data, "") is True
|
||||
|
||||
|
||||
def test_sniff_rejects_other_report_formats(parser: UniversalCsvParser) -> None:
|
||||
"""...and a file that merely has an extension we read is not ours."""
|
||||
others = sorted((FIXTURES / "sber").glob("*.html")) + sorted((FIXTURES / "vtb").glob("*.xlsx"))
|
||||
assert others, "нет чужих фикстур для отрицательной проверки"
|
||||
for path in others:
|
||||
assert parser.sniff(path.read_bytes(), path.name) is False, path.name
|
||||
|
||||
|
||||
def test_sniff_rejects_a_csv_with_foreign_columns(parser: UniversalCsvParser) -> None:
|
||||
assert parser.sniff(b"a,b,c\n1,2,3\n", "x.csv") is False
|
||||
assert parser.sniff(b"", "x.csv") is False
|
||||
assert parser.sniff(b"\x00\x01\x02", "x.csv") is False
|
||||
|
||||
|
||||
def test_parse_rejects_a_file_it_cannot_read(parser: UniversalCsvParser) -> None:
|
||||
from fintracker.sources.reports.base import ParseError
|
||||
|
||||
with pytest.raises(ParseError):
|
||||
parser.parse(b"a,b,c\n1,2,3\n", "x.csv")
|
||||
with pytest.raises(ParseError):
|
||||
parser.parse(_csv(), "empty.csv")
|
||||
|
||||
|
||||
# --- 2. what the fixture yields --------------------------------------------------------
|
||||
|
||||
|
||||
def test_report_header(report: ParsedReport) -> None:
|
||||
import datetime
|
||||
|
||||
assert report.broker == "csv"
|
||||
assert report.parser_version == "1"
|
||||
# the file names a portfolio, never an account — see the warning below
|
||||
assert report.account_external_id == "Мой капитал"
|
||||
assert report.period_from == datetime.date(2025, 2, 26)
|
||||
assert report.period_to == datetime.date(2026, 9, 17)
|
||||
|
||||
|
||||
def test_event_counts_by_kind(report: ParsedReport) -> None:
|
||||
counts = collections.Counter(e.kind for e in report.events)
|
||||
assert dict(counts) == EXPECTED_COUNTS
|
||||
assert len(report.events) == 535
|
||||
assert report.meta["row_count"] == 545
|
||||
|
||||
|
||||
def test_instruments_are_deduplicated(report: ParsedReport) -> None:
|
||||
keys = [ref.key() for ref in report.instruments]
|
||||
assert len(keys) == len(set(keys)) == 49
|
||||
# bonds arrive by ISIN, everything else by ticker; cash rows name no instrument
|
||||
assert "ISIN:RU000A10B7T7" in keys
|
||||
assert "TICKER:SBER" in keys
|
||||
assert "TICKER:SU26212RMFS9" in keys, "секунда ОФЗ — не ISIN, а тикер MOEX"
|
||||
assert not any(k.startswith("TICKER:RUB") for k in keys)
|
||||
|
||||
|
||||
# --- 3. decimals ----------------------------------------------------------------------
|
||||
|
||||
|
||||
def test_decimal_comma_is_parsed(parser: UniversalCsvParser) -> None:
|
||||
data = _csv(
|
||||
'"BUY","2025-03-01 10:00:00","SBER","4,48","219","RUB","6,57","MCX","0","RUB","False",""'
|
||||
)
|
||||
(event,) = parser.parse(data, "renamed.csv").events
|
||||
assert event.price == Decimal("4.48")
|
||||
assert event.quantity == Decimal("219")
|
||||
assert event.fee == Decimal("6.57")
|
||||
assert event.amount == Decimal("-987.69") # 4.48 * 219 + 6.57, fee capitalised
|
||||
|
||||
|
||||
def test_no_floats_anywhere_in_the_output(report: ParsedReport) -> None:
|
||||
bad = _floats(report, "report")
|
||||
assert bad == []
|
||||
|
||||
|
||||
def test_empty_numeric_cell_is_none_not_zero(parser: UniversalCsvParser) -> None:
|
||||
"""An absent НКД is not a zero НКД: one is «not a bond», the other is «no interest»."""
|
||||
data = _csv('"BUY","2025-03-01 10:00:00","SBER","100","1","RUB","0","MCX","","","False",""')
|
||||
(event,) = parser.parse(data, "x.csv").events
|
||||
assert event.accrued_interest is None
|
||||
assert event.fee == Decimal(0)
|
||||
|
||||
|
||||
def test_bond_trade_adds_accrued_interest_to_the_money(report: ParsedReport) -> None:
|
||||
"""НКД is paid on top of the price by the buyer — it must be inside `amount`."""
|
||||
buy = next(
|
||||
e
|
||||
for e in report.events
|
||||
if e.kind is EventKind.buy
|
||||
and e.instrument is not None
|
||||
and e.instrument.isin == "RU000A107AM4"
|
||||
and e.accrued_interest == Decimal("5.08")
|
||||
)
|
||||
assert buy.quantity == Decimal(4)
|
||||
assert buy.price == Decimal("929.25")
|
||||
assert buy.amount == -(Decimal(4) * Decimal("929.25") + Decimal("5.08") + Decimal("11.15"))
|
||||
|
||||
|
||||
# --- 4. dividends and coupons ----------------------------------------------------------
|
||||
|
||||
|
||||
def test_dividend_money_lands_in_amount_not_quantity(report: ParsedReport) -> None:
|
||||
dividends = [e for e in report.events if e.kind is EventKind.dividend]
|
||||
assert len(dividends) == 80
|
||||
assert all(e.quantity is None for e in dividends), "Quantity у DIVIDEND — это деньги"
|
||||
assert all(e.price is None for e in dividends)
|
||||
assert all(e.amount > 0 for e in dividends)
|
||||
assert sum(e.amount for e in dividends) == Decimal("12949.07")
|
||||
|
||||
|
||||
def test_bond_dividends_carry_the_coupon_hint(report: ParsedReport) -> None:
|
||||
"""The parser cannot know an asset class, so it hands ingest a suspicion, not a kind."""
|
||||
dividends = [e for e in report.events if e.kind is EventKind.dividend]
|
||||
hinted = [e for e in dividends if e.meta.get("payout_hint") == "coupon"]
|
||||
assert len(hinted) == 44
|
||||
assert all(
|
||||
e.instrument is not None
|
||||
and (
|
||||
(e.instrument.isin or "").startswith("RU000A")
|
||||
or (e.instrument.ticker or "").startswith("SU")
|
||||
)
|
||||
for e in hinted
|
||||
)
|
||||
# a share payout must never be hinted
|
||||
assert all(
|
||||
e.meta.get("payout_hint") is None
|
||||
for e in dividends
|
||||
if e.instrument is not None and e.instrument.ticker in {"T", "ROSN", "SBERP", "TATNP"}
|
||||
)
|
||||
|
||||
|
||||
# --- 5. splits -------------------------------------------------------------------------
|
||||
|
||||
|
||||
def test_split_ratio_and_collapsing_dedupe_key(report: ParsedReport) -> None:
|
||||
"""Two identical SPLIT rows for T are one corporate action seen on two accounts.
|
||||
|
||||
The format has no account column, so both rows land on the single account the user
|
||||
points the import at. They therefore share a `fingerprint_key` on purpose: replaying the
|
||||
ratio twice would multiply the position by 100 instead of by 10. This test pins that
|
||||
behaviour — if a future format gains an account column, it is expected to break.
|
||||
"""
|
||||
splits = [e for e in report.events if e.kind is EventKind.stock_split]
|
||||
assert len(splits) == 2
|
||||
assert {e.meta["split_ratio"] for e in splits} == {Decimal(10)}
|
||||
assert {e.meta["ratio"] for e in splits} == {Decimal(10)}
|
||||
assert all(e.amount == Decimal(0) and e.quantity is None for e in splits)
|
||||
assert all(e.instrument is not None and e.instrument.ticker == "T" for e in splits)
|
||||
assert [e.seq for e in splits] == [0, 0]
|
||||
assert _key(report, splits[0]) == _key(report, splits[1])
|
||||
|
||||
|
||||
def test_every_other_event_keeps_a_distinct_dedupe_key(report: ParsedReport) -> None:
|
||||
"""The split pair is the ONLY intentional collapse in the file.
|
||||
|
||||
In particular the pairs of equal same-day top-ups (two 1 100 ₽ one second apart, two
|
||||
4 000 ₽ that differ only by their note) survive as distinct events: they are real money
|
||||
into two accounts, and netting them would understate the XIRR denominator.
|
||||
"""
|
||||
keys = [_key(report, e) for e in report.events]
|
||||
collapsed = [k for k, n in collections.Counter(keys).items() if n > 1]
|
||||
assert len(collapsed) == 1
|
||||
assert len(set(keys)) == 534
|
||||
same_second = [e for e in report.events if e.kind is EventKind.deposit and e.seq > 0]
|
||||
assert same_second, "в файле есть одинаковые пополнения одного дня — они должны выживать"
|
||||
|
||||
|
||||
def test_seq_is_stable_across_reparsing(parser: UniversalCsvParser, fixture_path: Path) -> None:
|
||||
"""A re-import of the same bytes must reproduce every key, or nothing ever upserts."""
|
||||
data = fixture_path.read_bytes()
|
||||
first = parser.parse(data, fixture_path.name)
|
||||
second = parser.parse(data, "совсем другое имя.csv")
|
||||
assert [_key(first, e) for e in first.events] == [_key(first, e) for e in second.events]
|
||||
|
||||
|
||||
# --- 6. amortisation -------------------------------------------------------------------
|
||||
|
||||
|
||||
def test_amortisation_is_money_on_a_bond(report: ParsedReport) -> None:
|
||||
amortisations = [e for e in report.events if e.kind is EventKind.amortization]
|
||||
assert len(amortisations) == 9
|
||||
assert all(e.amount > 0 for e in amortisations)
|
||||
assert all(e.quantity is None and e.price is None for e in amortisations)
|
||||
assert {e.instrument.isin for e in amortisations if e.instrument} == {"RU000A10B7T7"}
|
||||
assert all(
|
||||
e.instrument is not None and e.instrument.asset_class_hint == "bond" for e in amortisations
|
||||
)
|
||||
assert sum(e.amount for e in amortisations) == Decimal("4530.07")
|
||||
|
||||
|
||||
# --- 7. manual prices ------------------------------------------------------------------
|
||||
|
||||
|
||||
def test_custom_holding_prices_go_to_meta_not_to_events(report: ParsedReport) -> None:
|
||||
"""CUSTOM_HOLDING_PRICE is a `price_manual` row, not a ledger event."""
|
||||
prices = report.meta["manual_prices"]
|
||||
assert len(prices) == 5
|
||||
assert {p["instrument_key"] for p in prices} == {"TICKER:SIBN6P4"}
|
||||
assert {p["currency"] for p in prices} == {"RUB"}
|
||||
assert all(isinstance(p["price"], Decimal) for p in prices)
|
||||
assert max(p["price"] for p in prices) == Decimal("12320.94504")
|
||||
assert all(e.meta.get("csv_event") != "CUSTOM_HOLDING_PRICE" for e in report.events)
|
||||
# the instrument they price must be resolvable, so it is also listed
|
||||
assert "TICKER:SIBN6P4" in {ref.key() for ref in report.instruments}
|
||||
|
||||
|
||||
def test_the_export_has_no_price_for_the_other_unquoted_paper(report: ParsedReport) -> None:
|
||||
"""NDM_TBNK-PP-FIXPRCNT-08.25 is simply absent — this file cannot price it."""
|
||||
named = {(ref.ticker or "") + (ref.isin or "") for ref in report.instruments}
|
||||
assert not any("NDM" in name for name in named)
|
||||
assert not any("NDM" in p["instrument_key"] for p in report.meta["manual_prices"])
|
||||
|
||||
|
||||
def test_custom_holding_settings_describe_an_instrument(report: ParsedReport) -> None:
|
||||
"""Its Note is JSON with `"` rewritten to `@*@` and Cyrillic as \\uXXXX escapes."""
|
||||
ref = next(r for r in report.instruments if r.ticker == "SIBN6P4")
|
||||
assert ref.name == "Газпром Нефть 006Р-04"
|
||||
assert ref.currency == "RUB"
|
||||
assert ref.meta["custom_holding"] is True
|
||||
assert ref.meta["sector"] == "Other"
|
||||
assert ref.meta["settings"]["CustomHoldingType"] == 0
|
||||
assert all(e.meta.get("csv_event") != "CUSTOM_HOLDING_SETTINGS" for e in report.events)
|
||||
|
||||
|
||||
def test_broken_settings_json_is_a_warning_not_a_crash(parser: UniversalCsvParser) -> None:
|
||||
data = _csv(
|
||||
'"CUSTOM_HOLDING_SETTINGS","2026-09-17 00:00:00","XYZ","0","0","RUB","0",'
|
||||
'"CUSTOM_HOLDING","0","","","{@*@Holding@*@:'
|
||||
)
|
||||
result = parser.parse(data, "x.csv")
|
||||
assert result.events == []
|
||||
assert any("не удалось разобрать JSON" in w for w in result.warnings)
|
||||
assert [ref.key() for ref in result.instruments] == ["TICKER:XYZ"]
|
||||
|
||||
|
||||
# --- 8. balance adjustments ------------------------------------------------------------
|
||||
|
||||
|
||||
def test_balance_adjustments_never_become_flows(report: ParsedReport) -> None:
|
||||
"""Snowball's own corrections would feed XIRR an external flow that never happened."""
|
||||
adjustments = report.meta["balance_adjustments"]
|
||||
assert len(adjustments) == 4
|
||||
assert collections.Counter(a["kind"] for a in adjustments) == {"CASH_OUT": 3, "CASH_IN": 1}
|
||||
assert all(isinstance(a["amount"], Decimal) for a in adjustments)
|
||||
# detection is by the note, not by the amount: one correction is 1017,84 ₽ while a
|
||||
# genuine withdrawal in the same file is 32 ₽
|
||||
assert min(abs(a["amount"]) for a in adjustments) == Decimal("0.0024")
|
||||
assert max(abs(a["amount"]) for a in adjustments) == Decimal("1017.83999543")
|
||||
excluded = {(a["kind"], a["d"], abs(a["amount"])) for a in adjustments}
|
||||
for event in report.events:
|
||||
kind = "CASH_IN" if event.kind is EventKind.deposit else "CASH_OUT"
|
||||
assert (kind, event.trade_date, abs(event.amount)) not in excluded
|
||||
assert any("правка остатка" in w for w in report.warnings)
|
||||
|
||||
|
||||
def test_real_cash_flows_survive(report: ParsedReport) -> None:
|
||||
deposits = [e for e in report.events if e.kind is EventKind.deposit]
|
||||
withdrawals = [e for e in report.events if e.kind is EventKind.withdrawal]
|
||||
assert len(deposits) == 96 and len(withdrawals) == 5
|
||||
assert all(e.amount > 0 and e.instrument is None for e in deposits)
|
||||
assert all(e.amount < 0 and e.instrument is None for e in withdrawals)
|
||||
assert sum(e.amount for e in withdrawals) == -Decimal("51294.41")
|
||||
assert sum(e.amount for e in deposits) == Decimal("454058.45")
|
||||
|
||||
|
||||
# --- 9. fees, taxes, refunds -----------------------------------------------------------
|
||||
|
||||
|
||||
def test_fee_tax_and_refund_signs(report: ParsedReport) -> None:
|
||||
fees = [e for e in report.events if e.kind is EventKind.commission]
|
||||
taxes = [e for e in report.events if e.kind is EventKind.tax]
|
||||
refunds = [e for e in report.events if e.kind is EventKind.tax_refund]
|
||||
assert (len(fees), len(taxes), len(refunds)) == (7, 4, 2)
|
||||
assert all(e.instrument is None for e in fees + taxes + refunds)
|
||||
assert all(e.quantity is None and e.price is None for e in fees + taxes + refunds)
|
||||
assert all(e.amount == Decimal(-45) and e.fee == Decimal(45) for e in fees)
|
||||
assert [e.amount for e in taxes] == [Decimal(-234), Decimal(-3), Decimal(-22), Decimal(-32)]
|
||||
assert [e.amount for e in refunds] == [Decimal(3), Decimal(32)]
|
||||
assert all(e.tax == -e.amount for e in taxes)
|
||||
assert all(e.tax == e.amount for e in refunds)
|
||||
|
||||
|
||||
# --- unknown types and reconciliation warnings -----------------------------------------
|
||||
|
||||
|
||||
def test_unknown_event_type_is_skipped_with_a_warning(parser: UniversalCsvParser) -> None:
|
||||
"""Never `EventKind.other`: a silent bucket hides a format that grew a new row type."""
|
||||
data = _csv(
|
||||
'"MARGIN_CALL","2025-03-01 10:00:00","SBER","1","1","RUB","0","MCX","0","","False",""',
|
||||
'"BUY","2025-03-01 10:00:00","SBER","100","1","RUB","0","MCX","0","","False",""',
|
||||
)
|
||||
result = parser.parse(data, "x.csv")
|
||||
assert [e.kind for e in result.events] == [EventKind.buy]
|
||||
assert result.meta["unknown_events"] == {"MARGIN_CALL": 1}
|
||||
assert any("MARGIN_CALL" in w for w in result.warnings)
|
||||
assert not any(e.kind is EventKind.other for e in result.events)
|
||||
|
||||
|
||||
def test_closing_balances_are_absent_and_said_to_be(report: ParsedReport) -> None:
|
||||
assert report.positions_end == []
|
||||
assert report.cash_end == []
|
||||
assert any("positions_end" in w for w in report.warnings)
|
||||
|
||||
|
||||
def test_warns_that_the_target_account_is_the_user_s_to_choose(report: ParsedReport) -> None:
|
||||
assert any("shadow" in w and "все брокерские счета" in w for w in report.warnings)
|
||||
@@ -0,0 +1,56 @@
|
||||
"""Реестр парсеров отчётов: какой файл достаётся какому парсеру (plan §2).
|
||||
|
||||
Проверка выглядит тривиальной, но ловит ровно ту ошибку, которую больше не поймает никто:
|
||||
универсальный CSV узнаёт файл по набору колонок, то есть по определению шире любого
|
||||
брокерского формата, и достаточно поставить его в списке первым, чтобы отчёт Сбера тихо
|
||||
уехал в чужой парсер. Порядок в `PARSERS` — это поведение, а не оформление.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
|
||||
from fintracker.sources.reports import registry
|
||||
|
||||
FIXTURES = Path(__file__).resolve().parents[1] / "fixtures" / "reports"
|
||||
|
||||
CASES = [
|
||||
(FIXTURES / "sber" / "S930W42_11022026_17092026.html", "report_sber"),
|
||||
(FIXTURES / "sber" / "S930W42_01082026_31082026.html", "report_sber"),
|
||||
(FIXTURES / "vtb" / "reportd0235f1e-55ac-fdc8-5c7b-279e4fe26e2b.xlsx", "report_vtb"),
|
||||
(FIXTURES / "vtb" / "report8ef8347a-2deb-e880-959a-d9a8ca12f735.xlsx", "report_vtb"),
|
||||
(next((FIXTURES / "snowball").glob("*.csv")), "csv"),
|
||||
]
|
||||
|
||||
|
||||
@pytest.mark.parametrize("path,expected", CASES, ids=lambda v: getattr(v, "name", v))
|
||||
def test_pick_routes_each_fixture_to_its_parser(path: Path, expected: str) -> None:
|
||||
chosen = registry.pick(path.read_bytes(), path.name)
|
||||
assert chosen is not None, f"{path.name}: ни один парсер не узнал файл"
|
||||
assert chosen.name == expected
|
||||
|
||||
|
||||
def test_every_registered_parser_is_reachable_by_name() -> None:
|
||||
assert set(registry.names()) == {"report_sber", "report_vtb", "csv"}
|
||||
for name in registry.names():
|
||||
assert registry.get(name).name == name
|
||||
|
||||
|
||||
def test_unknown_format_is_a_none_not_an_exception() -> None:
|
||||
"""`pick` возвращает None, и роутер превращает это в 415 — не в 500."""
|
||||
assert registry.pick(b"%PDF-1.4\n%\xe2\xe3\xcf\xd3\n", "statement.pdf") is None
|
||||
assert registry.pick(b"", "empty.txt") is None
|
||||
|
||||
|
||||
def test_parser_metadata_is_filled_in() -> None:
|
||||
"""`parser_name` и `parser_version` уходят в `raw_report_file` — пустых быть не должно."""
|
||||
for parser in registry.PARSERS:
|
||||
assert parser.name and parser.broker and parser.version
|
||||
assert parser.formats
|
||||
|
||||
|
||||
def test_csv_is_last_so_it_cannot_shadow_a_broker_format() -> None:
|
||||
names = registry.names()
|
||||
assert names.index("csv") == len(names) - 1
|
||||
@@ -0,0 +1,362 @@
|
||||
"""Парсер отчётов Сбера на обезличенных фикстурах (фаза 3).
|
||||
|
||||
Фикстуры — два отчёта по одному и тому же счёту: полный (11.02–17.09.2026) и августовский
|
||||
(01.08–31.08.2026). Пара выбрана не случайно: именно перекрывающиеся периоды ломают импорт,
|
||||
если ключ дедупликации выводится из файла, а не из самой операции.
|
||||
|
||||
Главная проверка здесь — арифметическая. Отчёт печатает свои итоги («Итого, RUB» по
|
||||
сделкам, «Пополнение счета» и «Исходящий остаток» в сводке), и распарсенные события обязаны
|
||||
их воспроизвести: пополнения минус покупки плюс продажи = остаток на конец периода. Если
|
||||
парсер потеряет строку, задвоит комиссию или примет расчётную строку за сделку, это
|
||||
равенство разойдётся — а тест на «количество событий» этого не заметит.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from collections import Counter
|
||||
from decimal import Decimal
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
|
||||
from fintracker.models.ledger import EventKind
|
||||
from fintracker.sources.reports.base import BrokerEvent, ParsedReport
|
||||
from fintracker.sources.reports.sber import SberHtmlParser, event_dedupe_key
|
||||
|
||||
FIXTURES = Path(__file__).resolve().parents[1] / "fixtures" / "reports"
|
||||
FULL = FIXTURES / "sber" / "S930W42_11022026_17092026.html"
|
||||
AUGUST = FIXTURES / "sber" / "S930W42_01082026_31082026.html"
|
||||
|
||||
ACCOUNT = "S930W42"
|
||||
#: Второй договор того же брокера — источник переводов д/с, не внешний поток.
|
||||
OTHER_AGREEMENT = "8184V30"
|
||||
|
||||
ZERO = Decimal(0)
|
||||
|
||||
|
||||
@pytest.fixture(scope="module")
|
||||
def parser() -> SberHtmlParser:
|
||||
return SberHtmlParser()
|
||||
|
||||
|
||||
@pytest.fixture(scope="module")
|
||||
def full(parser: SberHtmlParser) -> ParsedReport:
|
||||
return parser.parse(FULL.read_bytes(), FULL.name)
|
||||
|
||||
|
||||
@pytest.fixture(scope="module")
|
||||
def august(parser: SberHtmlParser) -> ParsedReport:
|
||||
return parser.parse(AUGUST.read_bytes(), AUGUST.name)
|
||||
|
||||
|
||||
def total(events: list[BrokerEvent], kind: EventKind) -> Decimal:
|
||||
return sum((e.amount for e in events if e.kind == kind), ZERO)
|
||||
|
||||
|
||||
def of_kind(report: ParsedReport, kind: EventKind) -> list[BrokerEvent]:
|
||||
return [e for e in report.events if e.kind == kind]
|
||||
|
||||
|
||||
# --- 1. распознавание формата ------------------------------------------------------------
|
||||
|
||||
|
||||
def test_sniff_accepts_both_sber_reports(parser: SberHtmlParser) -> None:
|
||||
assert parser.sniff(FULL.read_bytes(), FULL.name)
|
||||
assert parser.sniff(AUGUST.read_bytes(), AUGUST.name)
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"path",
|
||||
sorted((FIXTURES / "vtb").glob("*.xlsx")) + sorted((FIXTURES / "snowball").glob("*.csv")),
|
||||
ids=lambda p: p.suffix,
|
||||
)
|
||||
def test_sniff_rejects_other_formats(parser: SberHtmlParser, path: Path) -> None:
|
||||
assert not parser.sniff(path.read_bytes(), path.name)
|
||||
|
||||
|
||||
def test_sniff_never_raises_on_garbage(parser: SberHtmlParser) -> None:
|
||||
"""`registry.pick` опрашивает парсеры подряд — падение на чужом файле остановило бы перебор."""
|
||||
assert not parser.sniff(b"", "empty.html")
|
||||
assert not parser.sniff(b"\x00\x01\x02not html at all", "junk.html")
|
||||
|
||||
|
||||
# --- 2. шапка и состав ---------------------------------------------------------------------
|
||||
|
||||
|
||||
def test_full_report_header(full: ParsedReport) -> None:
|
||||
assert full.broker == "sber"
|
||||
assert full.account_external_id == ACCOUNT
|
||||
assert (full.period_from.isoformat(), full.period_to.isoformat()) == (
|
||||
"2026-02-11",
|
||||
"2026-09-17",
|
||||
)
|
||||
assert full.meta["opened_at"] == "2026-02-11"
|
||||
|
||||
|
||||
def test_august_report_header(august: ParsedReport) -> None:
|
||||
assert august.account_external_id == ACCOUNT
|
||||
assert (august.period_from.isoformat(), august.period_to.isoformat()) == (
|
||||
"2026-08-01",
|
||||
"2026-08-31",
|
||||
)
|
||||
|
||||
|
||||
def test_full_report_event_counts(full: ParsedReport) -> None:
|
||||
assert Counter(e.kind for e in full.events) == {
|
||||
EventKind.buy: 24,
|
||||
EventKind.deposit: 9,
|
||||
EventKind.sell: 2,
|
||||
}
|
||||
|
||||
|
||||
def test_august_report_event_counts(august: ParsedReport) -> None:
|
||||
assert Counter(e.kind for e in august.events) == {
|
||||
EventKind.buy: 2,
|
||||
EventKind.deposit: 1,
|
||||
}
|
||||
|
||||
|
||||
# --- 3. арифметика: события воспроизводят итоги самого отчёта -------------------------------
|
||||
|
||||
|
||||
def test_trade_totals_match_the_reports_own_total_row(full: ParsedReport) -> None:
|
||||
"""«Итого, RUB» таблицы сделок: 89 205.58 оборота, 138.03 брокеру, 19.54 бирже."""
|
||||
trades = of_kind(full, EventKind.buy) + of_kind(full, EventKind.sell)
|
||||
assert len(trades) == 26
|
||||
|
||||
# Оборот — это «Сумма» сделки без комиссии, а `amount` её уже включает: у покупки
|
||||
# прибавляет, у продажи вычитает. Отсюда знаки при обратном пересчёте.
|
||||
turnover = sum(
|
||||
(-e.amount - (e.fee or ZERO) if e.kind == EventKind.buy else e.amount + (e.fee or ZERO))
|
||||
for e in trades
|
||||
)
|
||||
assert turnover == Decimal("89205.58")
|
||||
assert sum((e.fee or ZERO for e in full.events), ZERO) == Decimal("138.03") + Decimal("19.54")
|
||||
|
||||
|
||||
def test_deposits_match_the_summary_line(full: ParsedReport) -> None:
|
||||
"""«Пополнение счета» сводки считает и переводы с другого договора — как и парсер."""
|
||||
assert total(full.events, EventKind.deposit) == Decimal("49120.87")
|
||||
assert Decimal(full.meta["summary"]["Пополнение счета"]) == Decimal("49120.87")
|
||||
|
||||
|
||||
def test_cash_closes_against_the_reported_balance(full: ParsedReport) -> None:
|
||||
"""Сквозная проверка: сумма денежных эффектов всех событий = исходящий остаток.
|
||||
|
||||
Это единственная проверка, которая ловит и потерянную строку, и лишнюю: пропущенная
|
||||
сделка занижает отток, а расчётная строка «Сделка от …», принятая за сделку, задваивает
|
||||
его. Входящий остаток нулевой, поэтому сумма событий и есть конечный остаток.
|
||||
"""
|
||||
assert Decimal(full.meta["summary"]["Входящий остаток"]) == ZERO
|
||||
assert sum((e.amount for e in full.events), ZERO) == Decimal("3171.34")
|
||||
|
||||
rub = next(c for c in full.cash_end if c.currency == "RUB")
|
||||
assert rub.balance == Decimal("3171.34")
|
||||
|
||||
|
||||
def test_august_cash_closes_from_its_own_opening_balance(august: ParsedReport) -> None:
|
||||
opening = Decimal(august.meta["summary"]["Входящий остаток"])
|
||||
assert opening == Decimal("357.15")
|
||||
rub = next(c for c in august.cash_end if c.currency == "RUB")
|
||||
assert opening + sum((e.amount for e in august.events), ZERO) == rub.balance
|
||||
|
||||
|
||||
# --- 4. комиссии учтены ровно один раз -----------------------------------------------------
|
||||
|
||||
|
||||
def test_commission_is_capitalised_and_never_emitted_separately(full: ParsedReport) -> None:
|
||||
"""Комиссия живёт в `fee` сделки; отдельных `commission`-событий быть не должно.
|
||||
|
||||
`ledger/lots.py` капитализирует `fee` в стоимость лота, поэтому вторая, «денежная»
|
||||
ипостась той же комиссии (строки «Комиссия Брокера от …» в движении денег) ушла бы в
|
||||
леджер повторно — как отток, которого не было.
|
||||
"""
|
||||
assert not of_kind(full, EventKind.commission)
|
||||
assert all(e.fee is None for e in full.events if e.kind == EventKind.deposit)
|
||||
|
||||
fees = sum((e.fee or ZERO for e in full.events), ZERO)
|
||||
assert fees == Decimal("157.57")
|
||||
assert Decimal(full.meta["summary"]["Комиссия брокера"]) == Decimal("-138.03")
|
||||
assert Decimal(full.meta["summary"]["Комиссия биржи"]) == Decimal("-19.54")
|
||||
|
||||
|
||||
def test_parser_reports_no_fee_divergence(full: ParsedReport, august: ParsedReport) -> None:
|
||||
"""Парсер сам сверяет две ипостаси комиссии и предупреждает при расхождении."""
|
||||
for report in (full, august):
|
||||
assert not [w for w in report.warnings if "комиссии расходятся" in w]
|
||||
|
||||
|
||||
# --- 5. дедупликация перекрывающихся периодов ----------------------------------------------
|
||||
|
||||
|
||||
def keys(report: ParsedReport) -> dict[str, BrokerEvent]:
|
||||
return {event_dedupe_key(report, e): e for e in report.events}
|
||||
|
||||
|
||||
def test_august_events_keep_their_keys_in_the_full_report(
|
||||
full: ParsedReport, august: ParsedReport
|
||||
) -> None:
|
||||
"""Каждое августовское событие должно прийти с тем же ключом из полного отчёта.
|
||||
|
||||
Иначе импорт двух пересекающихся отчётов задвоит август — ровно тот случай, который
|
||||
план §1.6 A называет «перекрывающиеся периоды апсертят в ту же строку».
|
||||
"""
|
||||
full_keys, august_keys = keys(full), keys(august)
|
||||
assert set(august_keys) <= set(full_keys), (
|
||||
"у августовских событий появились ключи, которых нет в полном отчёте: "
|
||||
f"{sorted(set(august_keys) - set(full_keys))}"
|
||||
)
|
||||
|
||||
for key, event in august_keys.items():
|
||||
twin = full_keys[key]
|
||||
assert (event.kind, event.trade_date, event.quantity, event.price, event.amount) == (
|
||||
twin.kind,
|
||||
twin.trade_date,
|
||||
twin.quantity,
|
||||
twin.price,
|
||||
twin.amount,
|
||||
), f"ключ {key} указывает на разные операции"
|
||||
|
||||
|
||||
def test_overlap_covers_every_august_operation(august: ParsedReport) -> None:
|
||||
"""Перекрытие не должно оказаться пустым — иначе предыдущий тест проходит вхолостую."""
|
||||
assert len(keys(august)) == len(august.events) == 3
|
||||
|
||||
|
||||
def test_trades_key_on_the_brokers_deal_number(full: ParsedReport) -> None:
|
||||
trades = of_kind(full, EventKind.buy) + of_kind(full, EventKind.sell)
|
||||
assert all(e.trade_no for e in trades)
|
||||
assert len({e.trade_no for e in trades}) == len(trades)
|
||||
|
||||
|
||||
def test_cash_rows_have_stable_keys_without_a_deal_number(full: ParsedReport) -> None:
|
||||
"""У денежных строк номера нет, и ключ обязан оставаться уникальным внутри отчёта."""
|
||||
deposits = of_kind(full, EventKind.deposit)
|
||||
assert all(e.trade_no is None for e in deposits)
|
||||
assert len({event_dedupe_key(full, e) for e in deposits}) == len(deposits)
|
||||
|
||||
|
||||
def test_reparsing_the_same_bytes_is_deterministic(parser: SberHtmlParser) -> None:
|
||||
once = parser.parse(FULL.read_bytes(), FULL.name)
|
||||
twice = parser.parse(FULL.read_bytes(), FULL.name)
|
||||
assert [event_dedupe_key(once, e) for e in once.events] == [
|
||||
event_dedupe_key(twice, e) for e in twice.events
|
||||
]
|
||||
|
||||
|
||||
# --- 6. закрывающие позиции и остатки ------------------------------------------------------
|
||||
|
||||
|
||||
def test_closing_positions(full: ParsedReport) -> None:
|
||||
assert len(full.positions_end) == 10
|
||||
assert sum((p.market_value or ZERO for p in full.positions_end), ZERO) == Decimal("46663.30")
|
||||
|
||||
aeroflot = next(p for p in full.positions_end if p.instrument.isin == "RU0009062285")
|
||||
assert aeroflot.qty == Decimal("130")
|
||||
assert aeroflot.currency == "RUB"
|
||||
|
||||
|
||||
def test_closing_cash_keeps_every_currency(full: ParsedReport) -> None:
|
||||
balances = {c.currency: c.balance for c in full.cash_end}
|
||||
assert balances == {"RUB": Decimal("3171.34"), "EUR": ZERO, "USD": ZERO}
|
||||
|
||||
|
||||
def test_august_closing_snapshot(august: ParsedReport) -> None:
|
||||
assert len(august.positions_end) == 5
|
||||
assert sum((p.market_value or ZERO for p in august.positions_end), ZERO) == Decimal("38739.43")
|
||||
assert next(c for c in august.cash_end if c.currency == "RUB").balance == Decimal("1407.88")
|
||||
|
||||
|
||||
# --- 7. справочник инструментов ------------------------------------------------------------
|
||||
|
||||
|
||||
def test_securities_directory_feeds_instruments(full: ParsedReport) -> None:
|
||||
assert len(full.instruments) >= 9
|
||||
assert all(i.isin and i.ticker for i in full.instruments)
|
||||
assert all(i.asset_class_hint for i in full.instruments)
|
||||
|
||||
sber = next(i for i in full.instruments if i.ticker == "SBER")
|
||||
assert (sber.isin, sber.asset_class_hint) == ("RU0009029540", "share")
|
||||
fund = next(i for i in full.instruments if i.ticker == "STME")
|
||||
assert fund.asset_class_hint in {"etf", "fund"}
|
||||
|
||||
|
||||
def test_trades_reference_instruments_by_isin(full: ParsedReport) -> None:
|
||||
"""Сделка печатает только тикер — ISIN подставляется из справочника того же файла."""
|
||||
trades = of_kind(full, EventKind.buy) + of_kind(full, EventKind.sell)
|
||||
assert all(e.instrument is not None and e.instrument.isin for e in trades)
|
||||
|
||||
|
||||
# --- 8. ловушки формата --------------------------------------------------------------------
|
||||
|
||||
|
||||
def test_iis_contributions_table_is_read_but_not_emitted(
|
||||
full: ParsedReport, august: ParsedReport
|
||||
) -> None:
|
||||
"""Таблица зачислений на ИИС кумулятивна за ГОД, а не за период отчёта.
|
||||
|
||||
Августовский файл перечисляет в ней восемь пополнений начиная с февраля — всё, что
|
||||
случилось до даты его формирования (01.09.2026); в полном отчёте их девять, добавилось
|
||||
сентябрьское. То есть таблица растёт от отчёта к отчёту независимо от периода, и если
|
||||
бы парсер эмитил её строки, импорт августовского файла поверх полного задвоил бы
|
||||
пополнения за полгода. Поэтому её содержимое только пересчитывается, в события не идёт:
|
||||
августовский отчёт даёт ровно одно денежное поступление — то, что реально было в августе.
|
||||
"""
|
||||
assert full.meta["iis_contributions_ignored"] == 9
|
||||
assert august.meta["iis_contributions_ignored"] == 8
|
||||
|
||||
assert len(of_kind(august, EventKind.deposit)) == 1
|
||||
|
||||
|
||||
def test_settlement_lines_are_not_mistaken_for_trades(full: ParsedReport) -> None:
|
||||
"""«Сделка от DD.MM.YYYY» в движении денег — расчёт по уже учтённой сделке."""
|
||||
assert not [e for e in full.events if (e.description or "").startswith("Сделка от")]
|
||||
|
||||
|
||||
def test_transfer_from_another_agreement_keeps_its_counterparty(full: ParsedReport) -> None:
|
||||
"""Перевод с другого договора Сбера — deposit, но с сохранённым источником.
|
||||
|
||||
Без `internal_transfer_from` эти деньги навсегда останутся внешним потоком: заведи
|
||||
пользователь второй договор счётом, XIRR увидел бы пополнение здесь и вывод там как два
|
||||
независимых события.
|
||||
"""
|
||||
transfers = [
|
||||
e
|
||||
for e in of_kind(full, EventKind.deposit)
|
||||
if e.meta.get("internal_transfer_from") == OTHER_AGREEMENT
|
||||
]
|
||||
assert len(transfers) == 2
|
||||
assert sum((e.amount for e in transfers), ZERO) == Decimal("3458.88") + Decimal("10551.18")
|
||||
|
||||
|
||||
def test_missing_coupon_section_is_reported_not_assumed(full: ParsedReport) -> None:
|
||||
"""Раздела «Купонный доход» в этих отчётах нет — это факт для data quality, не молчание."""
|
||||
assert any("Купонный доход" in w for w in full.warnings)
|
||||
|
||||
|
||||
# --- 9. деньги --------------------------------------------------------------------------------
|
||||
|
||||
|
||||
def numbers(report: ParsedReport) -> list[object]:
|
||||
values: list[object] = []
|
||||
for e in report.events:
|
||||
values += [e.amount, e.quantity, e.price, e.fee, e.tax, e.accrued_interest]
|
||||
for p in report.positions_end:
|
||||
values += [p.qty, p.price, p.market_value, p.accrued_interest]
|
||||
values += [c.balance for c in report.cash_end]
|
||||
return values
|
||||
|
||||
|
||||
@pytest.mark.parametrize("name", ["full", "august"])
|
||||
def test_no_floats_anywhere(name: str, request: pytest.FixtureRequest) -> None:
|
||||
"""Деньги — только Decimal (AGENTS.md). Один float здесь означает потерю копеек ниже."""
|
||||
report: ParsedReport = request.getfixturevalue(name)
|
||||
assert not [v for v in numbers(report) if isinstance(v, float)]
|
||||
assert all(isinstance(v, Decimal) for v in numbers(report) if v is not None)
|
||||
|
||||
|
||||
def test_every_event_carries_currency_and_traceability(full: ParsedReport) -> None:
|
||||
for event in full.events:
|
||||
assert event.currency == "RUB"
|
||||
assert event.raw_line_no > 0
|
||||
assert event.meta.get("section")
|
||||
@@ -0,0 +1,410 @@
|
||||
"""VTB xlsx report parser against the two anonymised fixtures.
|
||||
|
||||
The fixtures are the same account rendered twice: the whole life of the IIS (12.02–17.09) and
|
||||
the single month of August, which is a strict subset of it. That overlap is the point — it is
|
||||
exactly the case the dedupe key exists for, so the deal both files contain must produce one
|
||||
key, and the monthly file must not invent a trade the full one does not have.
|
||||
|
||||
Every number asserted here is taken from «Сводная информация по субсчёту Клиента», the
|
||||
broker's own totals block: the parser is trusted only insofar as what it extracted adds up to
|
||||
what VTB printed.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import io
|
||||
import zipfile
|
||||
from collections import Counter
|
||||
from dataclasses import fields, is_dataclass
|
||||
from decimal import Decimal
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
from openpyxl import load_workbook
|
||||
|
||||
from fintracker.models.ledger import EventKind
|
||||
from fintracker.sources.reports.base import ParsedReport
|
||||
from fintracker.sources.reports.vtb import common
|
||||
from fintracker.sources.reports.vtb.xlsx import VtbXlsxParser
|
||||
|
||||
FIXTURES = Path(__file__).resolve().parents[1] / "fixtures" / "reports"
|
||||
FULL = FIXTURES / "vtb" / "reportd0235f1e-55ac-fdc8-5c7b-279e4fe26e2b.xlsx"
|
||||
AUGUST = FIXTURES / "vtb" / "report8ef8347a-2deb-e880-959a-d9a8ca12f735.xlsx"
|
||||
|
||||
#: The August deal, as printed in both files: «№ сделки» B17476399721, 33 EQMX at 122.9.
|
||||
AUGUST_TRADE_NO = "B17476399721"
|
||||
|
||||
|
||||
@pytest.fixture(scope="module")
|
||||
def parser() -> VtbXlsxParser:
|
||||
return VtbXlsxParser()
|
||||
|
||||
|
||||
@pytest.fixture(scope="module")
|
||||
def full(parser: VtbXlsxParser) -> ParsedReport:
|
||||
return parser.parse(FULL.read_bytes(), FULL.name)
|
||||
|
||||
|
||||
@pytest.fixture(scope="module")
|
||||
def august(parser: VtbXlsxParser) -> ParsedReport:
|
||||
return parser.parse(AUGUST.read_bytes(), AUGUST.name)
|
||||
|
||||
|
||||
def trades(report: ParsedReport) -> list:
|
||||
return [event for event in report.events if event.kind in {EventKind.buy, EventKind.sell}]
|
||||
|
||||
|
||||
def count_security_movement_rows(path: Path) -> int:
|
||||
"""Rows of «Движение ценных бумаг» counted straight off the sheet.
|
||||
|
||||
Deliberately independent of the parser: the section is the settlement side of the same
|
||||
deals, so its row count is an outside check that no trade was dropped or doubled.
|
||||
"""
|
||||
workbook = load_workbook(io.BytesIO(path.read_bytes()), read_only=True, data_only=True)
|
||||
sheet = workbook["brokerage_report"]
|
||||
sheet.reset_dimensions()
|
||||
inside = False
|
||||
rows = 0
|
||||
for row in sheet.iter_rows(values_only=True):
|
||||
label = common.normalize_text(row[1] if len(row) > 1 else None)
|
||||
if label == "движение ценных бумаг":
|
||||
inside = True
|
||||
continue
|
||||
if not inside:
|
||||
continue
|
||||
if label in {"заключенные в отчетном периоде сделки с ценными бумагами"}:
|
||||
break
|
||||
if "расчеты по заключенным сделкам" in common.normalize_text(
|
||||
row[16] if len(row) > 16 else None
|
||||
):
|
||||
rows += 1
|
||||
workbook.close()
|
||||
return rows
|
||||
|
||||
|
||||
# -- 1. sniff ----------------------------------------------------------------------------
|
||||
|
||||
|
||||
def test_sniff_accepts_both_vtb_files(parser: VtbXlsxParser) -> None:
|
||||
for path in (FULL, AUGUST):
|
||||
assert parser.sniff(path.read_bytes(), path.name) is True
|
||||
|
||||
|
||||
def test_sniff_rejects_other_brokers(parser: VtbXlsxParser) -> None:
|
||||
others = list((FIXTURES / "sber").glob("*.html")) + list((FIXTURES / "snowball").glob("*.csv"))
|
||||
assert others, "фикстуры других брокеров не найдены — проверка бессмысленна"
|
||||
for path in others:
|
||||
assert parser.sniff(path.read_bytes(), path.name) is False
|
||||
|
||||
|
||||
def test_sniff_never_raises_on_garbage(parser: VtbXlsxParser) -> None:
|
||||
"""A registry walks every parser over every upload, so a False must never be an
|
||||
exception — including for a zip that merely looks like a workbook."""
|
||||
buffer = io.BytesIO()
|
||||
with zipfile.ZipFile(buffer, "w") as archive:
|
||||
archive.writestr("xl/workbook.xml", "<workbook/>")
|
||||
for payload in (b"", b"not a zip", b"PK\x03\x04broken", buffer.getvalue()):
|
||||
assert parser.sniff(payload, "whatever.xlsx") is False
|
||||
|
||||
|
||||
def test_sniff_ignores_the_file_name(parser: VtbXlsxParser) -> None:
|
||||
"""The name is a GUID with no format in it; content decides."""
|
||||
assert parser.sniff(FULL.read_bytes(), "totally-unrelated.bin") is True
|
||||
|
||||
|
||||
# -- 2. the full report ------------------------------------------------------------------
|
||||
|
||||
|
||||
def test_full_report_header(full: ParsedReport) -> None:
|
||||
assert full.broker == "vtb"
|
||||
assert full.account_external_id == "77R593"
|
||||
assert (full.period_from.isoformat(), full.period_to.isoformat()) == (
|
||||
"2026-02-12",
|
||||
"2026-09-17",
|
||||
)
|
||||
assert full.meta["iis_opened_at"] == "2026-02-12"
|
||||
|
||||
|
||||
def test_full_report_trades_come_only_from_concluded_section(full: ParsedReport) -> None:
|
||||
"""«Завершенные» repeats «Заключенные» with the same deal numbers, and «Движение ценных
|
||||
бумаг» settles those same deals — so ten deals must yield ten events, not twenty or
|
||||
thirty."""
|
||||
numbers = [event.trade_no for event in trades(full)]
|
||||
assert len(numbers) == len(set(numbers)) == count_security_movement_rows(FULL) == 10
|
||||
|
||||
|
||||
def test_full_report_parses_without_warnings(full: ParsedReport) -> None:
|
||||
assert full.warnings == []
|
||||
|
||||
|
||||
# -- 3. money ----------------------------------------------------------------------------
|
||||
|
||||
|
||||
def test_full_report_deposits_and_fees(full: ParsedReport) -> None:
|
||||
deposits = [event for event in full.events if event.kind is EventKind.deposit]
|
||||
assert sum(event.amount for event in deposits) == Decimal("26100")
|
||||
assert all(event.amount > 0 for event in deposits), "пополнение увеличивает счёт"
|
||||
|
||||
# The per-deal commissions add up to «Вознаграждение брокера» of the summary block to the
|
||||
# kopeck, which is why they are capitalised into the deals instead of being emitted
|
||||
# separately: `ledger/lots.py` already puts `fee` into the cost of the lot.
|
||||
assert sum((event.fee or Decimal(0)) for event in full.events) == Decimal("3.64")
|
||||
assert Decimal(full.meta["summary"]["вознаграждение брокера"]) == Decimal("-3.64")
|
||||
assert not [event for event in full.events if event.kind is EventKind.commission]
|
||||
|
||||
|
||||
def test_full_report_signs(full: ParsedReport) -> None:
|
||||
for event in trades(full):
|
||||
assert event.quantity is not None
|
||||
if event.kind is EventKind.buy:
|
||||
assert event.quantity > 0 and event.amount < 0
|
||||
else:
|
||||
assert event.quantity < 0 and event.amount > 0
|
||||
|
||||
|
||||
def test_settlement_saldo_is_not_emitted_twice(full: ParsedReport) -> None:
|
||||
"""«Сальдо расчетов по сделкам с ценными бумагами» is the money leg of the trades; the
|
||||
cash section must contribute deposits only."""
|
||||
cash_kinds = Counter(event.kind for event in full.events if event.meta.get("section") == "cash")
|
||||
assert cash_kinds == Counter({EventKind.deposit: 8})
|
||||
|
||||
|
||||
def test_closing_cash_follows_from_the_events(full: ParsedReport) -> None:
|
||||
"""The whole-file check: opening + everything emitted = closing, as VTB states it."""
|
||||
summary = full.meta["summary"]
|
||||
opening = Decimal(summary["входящий остаток денежных средств"])
|
||||
closing = Decimal(summary["исходящий остаток денежных средств"])
|
||||
assert opening + sum(event.amount for event in full.events) == closing == Decimal("120.99")
|
||||
|
||||
|
||||
# -- 4. positions and cash balances -------------------------------------------------------
|
||||
|
||||
|
||||
def test_full_report_positions_end(full: ParsedReport) -> None:
|
||||
positions = {position.instrument.isin: position for position in full.positions_end}
|
||||
assert set(positions) == {"RU000A101EJ5", "RU000A1014L8"}
|
||||
assert positions["RU000A101EJ5"].qty == Decimal("197")
|
||||
assert positions["RU000A1014L8"].qty == Decimal("0")
|
||||
eqmx = positions["RU000A101EJ5"].instrument
|
||||
assert (eqmx.ticker, eqmx.asset_class_hint) == ("EQMX", "etf")
|
||||
assert eqmx.name == "EQMX ETF"
|
||||
assert eqmx.meta["reg_number"] == "3965"
|
||||
assert positions["RU000A101EJ5"].price == Decimal("124.653")
|
||||
|
||||
|
||||
def test_currency_is_normalised_to_iso(full: ParsedReport) -> None:
|
||||
"""VTB prints the pre-ISO `RUR`; the ledger stores three-letter ISO codes only."""
|
||||
assert [(cash.currency, cash.balance) for cash in full.cash_end] == [("RUB", Decimal("120.99"))]
|
||||
codes = {cash.currency for cash in full.cash_end}
|
||||
codes |= {position.currency for position in full.positions_end}
|
||||
codes |= {event.currency for event in full.events}
|
||||
codes |= {event.price_currency for event in trades(full)}
|
||||
assert codes == {"RUB"}
|
||||
|
||||
|
||||
def test_instrument_hint_reaches_trade_events(full: ParsedReport) -> None:
|
||||
"""Only the positions table names the asset class («ПАЙ»); the trades table does not, so
|
||||
the hint is carried over by ISIN."""
|
||||
assert {ref.asset_class_hint for ref in full.instruments} == {"etf"}
|
||||
assert all(
|
||||
event.instrument is not None and event.instrument.asset_class_hint == "etf"
|
||||
for event in trades(full)
|
||||
)
|
||||
|
||||
|
||||
# -- 5. the monthly report and the cross-file dedupe key ----------------------------------
|
||||
|
||||
|
||||
def test_august_report(august: ParsedReport) -> None:
|
||||
assert (august.period_from.isoformat(), august.period_to.isoformat()) == (
|
||||
"2026-08-01",
|
||||
"2026-08-31",
|
||||
)
|
||||
assert august.account_external_id == "77R593"
|
||||
assert august.warnings == []
|
||||
|
||||
deals = trades(august)
|
||||
assert len(deals) == 1
|
||||
deal = deals[0]
|
||||
assert deal.kind is EventKind.buy
|
||||
assert deal.instrument is not None and deal.instrument.isin == "RU000A101EJ5"
|
||||
assert deal.quantity == Decimal("33")
|
||||
assert deal.price == Decimal("122.9")
|
||||
assert deal.trade_no == AUGUST_TRADE_NO
|
||||
# 33 * 122.9 = 4055.70 plus the 0.81 commission capitalised into the deal.
|
||||
assert deal.amount == Decimal("-4056.51")
|
||||
assert deal.fee == Decimal("0.81")
|
||||
|
||||
|
||||
def test_same_deal_has_one_dedupe_key_in_both_reports(
|
||||
full: ParsedReport, august: ParsedReport
|
||||
) -> None:
|
||||
"""The overlapping-period case from plan §1.6 A: re-importing August after the full
|
||||
report must upsert, not duplicate."""
|
||||
keys = {}
|
||||
for report in (full, august):
|
||||
for event in trades(report):
|
||||
if event.trade_no == AUGUST_TRADE_NO:
|
||||
keys[report.period_to] = common.dedupe_key(report.account_external_id, event)
|
||||
assert len(keys) == 2
|
||||
assert len(set(keys.values())) == 1
|
||||
|
||||
|
||||
def test_dedupe_key_uses_the_brokers_own_deal_number(full: ParsedReport) -> None:
|
||||
"""«№ сделки» (Z), not «№ сделки у организатора торгов» (AC).
|
||||
|
||||
The key is `sha1(broker | account | trade_no)` and is therefore already unique within the
|
||||
broker; what it needs is the number VTB prints in every rendering of its own report. The
|
||||
organiser's number is a different string (`17476399721` against `B17476399721`) and is
|
||||
absent for over-the-counter deals in some formats.
|
||||
"""
|
||||
deal = next(event for event in trades(full) if event.trade_no == AUGUST_TRADE_NO)
|
||||
assert deal.meta["exchange_trade_no"] == "17476399721"
|
||||
assert common.dedupe_key("77R593", deal) != common.dedupe_key(
|
||||
"77R593",
|
||||
type(deal)(
|
||||
**{
|
||||
**{field.name: getattr(deal, field.name) for field in fields(deal)},
|
||||
"trade_no": deal.meta["exchange_trade_no"],
|
||||
}
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
def test_cash_rows_get_distinct_fingerprint_keys(full: ParsedReport) -> None:
|
||||
"""Deposits carry no deal number and fall back to a fingerprint; two 4 000 ₽ top-ups in
|
||||
different months must still differ."""
|
||||
keys = [
|
||||
common.dedupe_key(full.account_external_id, event)
|
||||
for event in full.events
|
||||
if event.kind is EventKind.deposit
|
||||
]
|
||||
assert len(set(keys)) == len(keys) == 8
|
||||
|
||||
|
||||
# -- 6. concluded vs completed ------------------------------------------------------------
|
||||
|
||||
|
||||
def test_concluded_and_completed_deal_numbers_agree(full: ParsedReport) -> None:
|
||||
"""Asserted through the parser's own warning channel: a divergence is reported, and for a
|
||||
fully settled period there is none."""
|
||||
assert not [warning for warning in full.warnings if "Завершенных" in warning]
|
||||
parsed = VtbXlsxParser().parse(FULL.read_bytes(), FULL.name)
|
||||
concluded = {event.trade_no for event in trades(parsed)}
|
||||
assert concluded == set(_completed_deal_numbers(FULL))
|
||||
|
||||
|
||||
def _completed_deal_numbers(path: Path) -> list[str]:
|
||||
"""«№ сделки» of «Завершенные…» read straight off the sheet, bypassing the parser."""
|
||||
workbook = load_workbook(io.BytesIO(path.read_bytes()), read_only=True, data_only=True)
|
||||
sheet = workbook["brokerage_report"]
|
||||
sheet.reset_dimensions()
|
||||
inside = False
|
||||
numbers: list[str] = []
|
||||
for row in sheet.iter_rows(values_only=True):
|
||||
label = common.normalize_text(row[1] if len(row) > 1 else None)
|
||||
if label.startswith("завершенные в отчетном периоде сделки с ценными бумагами"):
|
||||
inside = True
|
||||
continue
|
||||
if not inside:
|
||||
continue
|
||||
value = row[25] if len(row) > 25 else None # column Z
|
||||
if value and str(value).strip() and common.normalize_text(value) != "№ сделки":
|
||||
numbers.append(str(value).strip())
|
||||
workbook.close()
|
||||
return numbers
|
||||
|
||||
|
||||
# -- 7. no floats anywhere ----------------------------------------------------------------
|
||||
|
||||
|
||||
def _floats(value: object, path: str = "") -> list[str]:
|
||||
if isinstance(value, bool) or value is None:
|
||||
return []
|
||||
if isinstance(value, float):
|
||||
return [path]
|
||||
if is_dataclass(value) and not isinstance(value, type):
|
||||
found: list[str] = []
|
||||
for field in fields(value):
|
||||
found += _floats(getattr(value, field.name), f"{path}.{field.name}")
|
||||
return found
|
||||
if isinstance(value, dict):
|
||||
return [p for key, item in value.items() for p in _floats(item, f"{path}[{key}]")]
|
||||
if isinstance(value, (list, tuple, set)):
|
||||
return [p for index, item in enumerate(value) for p in _floats(item, f"{path}[{index}]")]
|
||||
return []
|
||||
|
||||
|
||||
@pytest.mark.parametrize("name", ["full", "august"])
|
||||
def test_no_floats_in_output(name: str, request: pytest.FixtureRequest) -> None:
|
||||
"""openpyxl hands back floats for every numeric cell; none of them may survive into the
|
||||
result, or a kopeck disappears into binary rounding on the way to `NUMERIC(24,10)`."""
|
||||
report: ParsedReport = request.getfixturevalue(name)
|
||||
assert _floats(report, name) == []
|
||||
|
||||
|
||||
# -- 8. totals against the summary block --------------------------------------------------
|
||||
|
||||
|
||||
@pytest.mark.parametrize(("name", "expected"), [("full", "-25975.37"), ("august", "-4055.7")])
|
||||
def test_trade_sums_match_the_settlement_saldo(
|
||||
name: str, expected: str, request: pytest.FixtureRequest
|
||||
) -> None:
|
||||
"""Σ «Сумма сделки» (column M) of the purchases minus the sales equals «Сальдо расчетов по
|
||||
сделкам с ценными бумагами» in the summary.
|
||||
|
||||
The commission has to be taken back out of `amount` to make the comparison: VTB's saldo is
|
||||
the money of the deals themselves, while `event.amount` carries the fee inside it by the
|
||||
ledger's convention. The two agree exactly once that is done — the fee lives in the same
|
||||
file as its own «Вознаграждение брокера» line, and it is that line, not the saldo, that
|
||||
accounts for it.
|
||||
"""
|
||||
report: ParsedReport = request.getfixturevalue(name)
|
||||
gross = Decimal(0)
|
||||
for event in trades(report):
|
||||
fee = event.fee or Decimal(0)
|
||||
gross += event.amount + fee if event.kind is EventKind.buy else event.amount - fee
|
||||
assert gross == Decimal(expected)
|
||||
assert gross == Decimal(report.meta["summary"]["сальдо расчетов по сделкам с ценными бумагами"])
|
||||
|
||||
|
||||
def test_position_quantities_match_the_settled_movements(full: ParsedReport) -> None:
|
||||
"""Closing position = what the trades did, since the account opened empty."""
|
||||
by_isin: dict[str, Decimal] = {}
|
||||
for event in trades(full):
|
||||
assert event.instrument is not None and event.instrument.isin
|
||||
assert event.quantity is not None
|
||||
by_isin[event.instrument.isin] = (
|
||||
by_isin.get(event.instrument.isin, Decimal(0)) + event.quantity
|
||||
)
|
||||
for position in full.positions_end:
|
||||
assert position.instrument.isin is not None
|
||||
assert by_isin[position.instrument.isin] == position.qty
|
||||
|
||||
|
||||
# -- parse errors --------------------------------------------------------------------------
|
||||
|
||||
|
||||
def test_unreadable_file_raises_parse_error(parser: VtbXlsxParser) -> None:
|
||||
from fintracker.sources.reports.base import ParseError
|
||||
|
||||
with pytest.raises(ParseError):
|
||||
parser.parse(b"PK\x03\x04 definitely not a workbook", "report.xlsx")
|
||||
|
||||
|
||||
def test_workbook_without_a_period_raises_parse_error(parser: VtbXlsxParser) -> None:
|
||||
"""A file we recognise but cannot place on a timeline must fail loudly rather than import
|
||||
as an empty report."""
|
||||
from openpyxl import Workbook
|
||||
|
||||
from fintracker.sources.reports.base import ParseError
|
||||
|
||||
workbook = Workbook()
|
||||
sheet = workbook.worksheets[0]
|
||||
sheet.title = "brokerage_report"
|
||||
sheet["D1"] = "Отчет Банка ВТБ (ПАО) без периода"
|
||||
buffer = io.BytesIO()
|
||||
workbook.save(buffer)
|
||||
|
||||
with pytest.raises(ParseError):
|
||||
parser.parse(buffer.getvalue(), "report.xlsx")
|
||||
@@ -0,0 +1,168 @@
|
||||
"""Персданные не должны попадать в git (AGENTS.md).
|
||||
|
||||
Отчёты Сбера и ВТБ содержат ФИО, ИНН, номер соглашения и номер лицевого счёта. В
|
||||
репозиторий идут только обезличенные копии, и этот тест — то, что делает правило
|
||||
исполнимым, а не пожеланием: он падает, если в `tests/fixtures/reports/` (вне `raw/`)
|
||||
встречается что-то персональное.
|
||||
|
||||
Две проверки, и вторая важнее первой:
|
||||
|
||||
1. **По форме.** 12-значный ИНН, 20-значный счёт, ФИО кириллицей — ловятся регулярками и
|
||||
без доступа к оригиналу, то есть работают и в CI, где `raw/` нет.
|
||||
2. **По содержанию.** Если `raw/` на месте, из оригиналов извлекаются те же секреты, что
|
||||
вырезает `anonymize.py`, и каждый проверяется на отсутствие в каждой фикстуре и в её
|
||||
имени. Это ловит случай, который первая проверка пропустит: фикстуру, собранную руками
|
||||
мимо скрипта.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import io
|
||||
import re
|
||||
import zipfile
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
|
||||
from fintracker.sources.reports.anonymize import (
|
||||
FAKE_NAME,
|
||||
FAKE_PATRONYMIC,
|
||||
FAKE_SURNAME,
|
||||
RE_ACCOUNT_20,
|
||||
RE_CAPS_FIO,
|
||||
RE_INN,
|
||||
RE_PLACEHOLDER_NUMBER,
|
||||
RE_SHORT_NAME,
|
||||
Secrets,
|
||||
collect_secrets,
|
||||
)
|
||||
|
||||
FIXTURES = Path(__file__).resolve().parent / "fixtures" / "reports"
|
||||
RAW = FIXTURES / "raw"
|
||||
|
||||
ALLOWED_NAMES = {
|
||||
f"{FAKE_SURNAME} {FAKE_NAME[0]}. {FAKE_PATRONYMIC[0]}.",
|
||||
f"{FAKE_SURNAME.upper()} {FAKE_NAME.upper()} {FAKE_PATRONYMIC.upper()}",
|
||||
}
|
||||
|
||||
|
||||
def committed_fixtures() -> list[Path]:
|
||||
"""Every fixture that git tracks: everything under reports/ except raw/."""
|
||||
if not FIXTURES.exists():
|
||||
return []
|
||||
return sorted(
|
||||
p
|
||||
for p in FIXTURES.rglob("*")
|
||||
if p.is_file() and RAW not in p.parents and p != RAW and not p.name.startswith(".")
|
||||
)
|
||||
|
||||
|
||||
def raw_reports() -> list[Path]:
|
||||
if not RAW.exists():
|
||||
return []
|
||||
return sorted(p for p in RAW.rglob("*") if p.is_file() and not p.name.startswith("."))
|
||||
|
||||
|
||||
def raw_secrets() -> Secrets:
|
||||
"""The same corpus-wide secret set the script uses — never a per-file one."""
|
||||
return collect_secrets((p.read_bytes(), p.name) for p in raw_reports())
|
||||
|
||||
|
||||
def readable_text(path: Path) -> str:
|
||||
"""The visible text of a fixture, whatever its container."""
|
||||
data = path.read_bytes()
|
||||
if path.suffix.lower() in {".xlsx", ".xlsm"}:
|
||||
archive = zipfile.ZipFile(io.BytesIO(data))
|
||||
parts = [
|
||||
archive.read(name).decode("utf-8", "replace")
|
||||
for name in archive.namelist()
|
||||
if name.startswith("xl/") and name.endswith(".xml")
|
||||
]
|
||||
return "\n".join(parts)
|
||||
return data.decode("utf-8", "replace")
|
||||
|
||||
|
||||
@pytest.mark.parametrize("path", committed_fixtures(), ids=lambda p: str(p.name))
|
||||
def test_fixture_has_no_personal_shapes(path: Path) -> None:
|
||||
text = readable_text(path)
|
||||
|
||||
inns = [v for v in RE_INN.findall(text) if not RE_PLACEHOLDER_NUMBER.match(v)]
|
||||
assert not inns, f"{path.name}: похоже на ИНН физлица: {inns[:3]}"
|
||||
|
||||
accounts = [v for v in RE_ACCOUNT_20.findall(text) if not RE_PLACEHOLDER_NUMBER.match(v)]
|
||||
assert not accounts, f"{path.name}: похоже на номер счёта: {accounts[:3]}"
|
||||
|
||||
names = {m.group(0) for m in RE_SHORT_NAME.finditer(text)} - ALLOWED_NAMES
|
||||
assert not names, f"{path.name}: ФИО с инициалами: {sorted(names)[:3]}"
|
||||
|
||||
caps = {m.group(0) for m in RE_CAPS_FIO.finditer(text)} - ALLOWED_NAMES
|
||||
assert not caps, f"{path.name}: ФИО заглавными: {sorted(caps)[:3]}"
|
||||
|
||||
|
||||
@pytest.mark.skipif(not RAW.exists(), reason="реальных отчётов нет (CI) — проверка по форме")
|
||||
def test_no_secret_from_raw_leaks_into_fixtures() -> None:
|
||||
"""Ни один секрет, извлечённый из оригиналов, не встречается в коммитимых фикстурах."""
|
||||
secrets = set(raw_secrets().originals)
|
||||
if not secrets:
|
||||
pytest.skip("в оригиналах не нашлось персданных — нечего проверять")
|
||||
|
||||
leaks: list[str] = []
|
||||
for fixture in committed_fixtures():
|
||||
text = readable_text(fixture)
|
||||
for secret in secrets:
|
||||
if secret in text:
|
||||
leaks.append(f"{fixture.name}: содержимое содержит {secret!r}")
|
||||
if secret in fixture.name:
|
||||
leaks.append(f"{fixture.name}: имя файла содержит {secret!r}")
|
||||
assert not leaks, "утечка персданных в git:\n" + "\n".join(leaks)
|
||||
|
||||
|
||||
@pytest.mark.skipif(not RAW.exists(), reason="реальных отчётов нет")
|
||||
def test_fixtures_are_reproducible_from_the_script() -> None:
|
||||
"""Фикстуры совпадают с тем, что сейчас выдаёт скрипт: обезличивание воспроизводимо."""
|
||||
from fintracker.sources.reports.anonymize import anonymize, anonymize_filename
|
||||
|
||||
secrets = raw_secrets()
|
||||
mismatched: list[str] = []
|
||||
for source in raw_reports():
|
||||
clean, _ = anonymize(source.read_bytes(), source.name, secrets)
|
||||
relative = source.relative_to(RAW)
|
||||
target = FIXTURES / relative.parent / anonymize_filename(source.name, secrets)
|
||||
if not target.exists():
|
||||
mismatched.append(f"нет фикстуры {target.name} для {relative}")
|
||||
elif target.read_bytes() != clean:
|
||||
mismatched.append(f"{target.name} не совпадает с выводом скрипта")
|
||||
assert not mismatched, (
|
||||
"перегенерируйте: uv run python scripts/anonymize_reports.py\n" + "\n".join(mismatched)
|
||||
)
|
||||
|
||||
|
||||
def test_raw_reports_are_not_tracked_by_git() -> None:
|
||||
"""`raw/` в .gitignore — иначе всё остальное бессмысленно."""
|
||||
import subprocess
|
||||
|
||||
root = Path(__file__).resolve().parents[2]
|
||||
out = subprocess.run(
|
||||
["git", "ls-files", "backend/tests/fixtures/reports/raw"],
|
||||
cwd=root,
|
||||
capture_output=True,
|
||||
text=True,
|
||||
check=False,
|
||||
)
|
||||
tracked = [
|
||||
line
|
||||
for line in out.stdout.splitlines()
|
||||
if line.strip() and not line.rsplit("/", 1)[-1].startswith(".")
|
||||
]
|
||||
assert not tracked, f"реальные отчёты под контролем версий: {tracked}"
|
||||
|
||||
|
||||
RE_ANY_CYRILLIC_FIO = re.compile(r"[А-ЯЁ][а-яё]+\s+[А-ЯЁ][а-яё]+(?:ович|евич|овна|евна)\b")
|
||||
|
||||
|
||||
@pytest.mark.parametrize("path", committed_fixtures(), ids=lambda p: str(p.name))
|
||||
def test_fixture_has_no_full_patronymic(path: Path) -> None:
|
||||
"""«Иванов Пётр Сергеевич» в обычном регистре — отдельный случай, регистр не спасает."""
|
||||
found = {m.group(0) for m in RE_ANY_CYRILLIC_FIO.finditer(readable_text(path))}
|
||||
found -= {f"{FAKE_NAME} {FAKE_PATRONYMIC}", f"{FAKE_SURNAME} {FAKE_PATRONYMIC}"}
|
||||
assert not found, f"{path.name}: ФИО с отчеством: {sorted(found)[:3]}"
|
||||
Reference in New Issue
Block a user