"""Разбор выгрузки сделок QUIK (.xlsx, формат «Лист2») — без БД и сети. Формат и решения — docs/project.md, раздел «Выгрузка QUIK», D7, D17, D19. Лист ищем по заголовкам, а не по имени: при пересохранении он может называться как угодно. Колонки — тоже по заголовкам, лишние игнорируем. """ from __future__ import annotations import hashlib import io import re import zipfile from collections.abc import Iterable, Sequence from dataclasses import dataclass, field from datetime import date, datetime, time, timedelta from decimal import Decimal, InvalidOperation from typing import Any from openpyxl import load_workbook SOURCE = "QUIK" BUY = "BUY" SELL = "SELL" # Заголовки «Лист2» -> поле QuikTrade. «Инструмент сокр.» встречается дважды — # берём первую колонку, вторую только сверяем. COLUMNS: dict[str, str] = { "trade_date": "Дата сделки", "trade_time": "Время", "settlement_date": "Дата расчетов", "instrument": "Инструмент сокр.", "operation": "Операция", "account": "Счет", "quantity": "Кол-во", "price": "Цена", "amount": "Объем", "settlement_amount": "Расчетная сумма", "comment": "Комментарий", "settlement_code": "Код расчетов", "order_number": "Заявка", "counterparty": "Орг-я партнера", "currency": "Валюта расчетов", "commission_ts": "Комиссия ТС", "commission_clearing": "Клиринговая комиссия", "commission_exchange": "ФБ комиссия", "commission_tc": "ТЦ комиссия", "commission_broker": "Комиссия брокера", } # Признаки «Лист1»: есть НКД и доходность, нет комментария, заявки и комиссий. _SHEET1_MARKERS = ("НКД", "Доходность") _OPERATIONS = {"купля": BUY, "продажа": SELL} _CURRENCIES = {"SUR": "RUB", "RUB": "RUB", "CNY": "CNY", "USD": "USD"} _COMMISSIONS = ( "commission_ts", "commission_clearing", "commission_exchange", "commission_tc", "commission_broker", ) # Заголовок ищем в первых строках листа: иногда над таблицей есть шапка. _HEADER_SEARCH_ROWS = 10 _EXCEL_EPOCH = date(1899, 12, 30) _MIN_DATE = date(2000, 1, 1) _MAX_DATE = date(2100, 1, 1) class QuikFormatError(ValueError): """Файл целиком не подходит (не xlsx, не тот формат, нет колонок). Текст — для пользователя. """ @dataclass(frozen=True) class QuikTrade: """Одна сделка из выгрузки. ordinal — номер среди непустых строк файла (1, 2, …), стабилен при том же хэше содержимого; row — строка Excel.""" ordinal: int row: int trade_date: date trade_time: time settlement_date: date instrument: str operation: str account: str quantity: Decimal price: Decimal amount: Decimal settlement_amount: Decimal comment: str settlement_code: str order_number: str counterparty: str currency: str commission_ts: Decimal commission_clearing: Decimal commission_exchange: Decimal commission_tc: Decimal commission_broker: Decimal @dataclass(frozen=True) class RowIssue: row: int column: str message: str def __str__(self) -> str: where = f"строка {self.row}" if self.column: where += f", «{self.column}»" return f"{where}: {self.message}" @dataclass class QuikParseResult: sheet: str trades: list[QuikTrade] = field(default_factory=list) errors: list[RowIssue] = field(default_factory=list) warnings: list[RowIssue] = field(default_factory=list) @property def ok(self) -> bool: return not self.errors and bool(self.trades) @property def date_from(self) -> date | None: return min((t.trade_date for t in self.trades), default=None) @property def date_to(self) -> date | None: return max((t.trade_date for t in self.trades), default=None) @property def content_hash(self) -> str | None: """sha256 нормализованных строк (D19); None, если файл с ошибками.""" if not self.ok: return None return content_hash(self.trades) def parse_quik_xlsx(data: bytes) -> QuikParseResult: """Разбирает выгрузку целиком: собирает все ошибки строк сразу. QuikFormatError — если файл вообще не подходит. """ workbook = _open_workbook(data) try: sheet_name, header_row, columns, duplicate = _find_sheet(workbook) result = QuikParseResult(sheet=sheet_name) rows = workbook[sheet_name].iter_rows(min_row=header_row + 1, values_only=True) ordinal = 0 for row_number, values in enumerate(rows, start=header_row + 1): if _is_empty(values): continue ordinal += 1 trade = _parse_row(values, row_number, ordinal, columns, duplicate, result) if trade is not None: result.trades.append(trade) finally: workbook.close() if not result.trades and not result.errors: raise QuikFormatError(f"На листе «{sheet_name}» нет ни одной сделки.") return result def content_hash(trades: Iterable[QuikTrade]) -> str: """Хэш содержимого, а не байтов файла: Excel при пересохранении меняет байты (метки времени в zip), а данные — нет. Номер строки Excel не входит — пустые строки между сделками на хэш не влияют.""" digest = hashlib.sha256() for trade in trades: values = [ trade.trade_date.isoformat(), trade.trade_time.isoformat(), trade.settlement_date.isoformat(), trade.instrument, trade.operation, trade.account, *( _canonical(getattr(trade, name)) for name in ( "quantity", "price", "amount", "settlement_amount", *_COMMISSIONS, ) ), trade.comment, trade.settlement_code, trade.order_number, trade.counterparty, trade.currency, ] digest.update("\x1f".join(values).encode("utf-8")) digest.update(b"\x1e") return digest.hexdigest() # --- поиск листа и колонок --------------------------------------------------- def _open_workbook(data: bytes) -> Any: try: return load_workbook(io.BytesIO(data), read_only=True, data_only=True) except (zipfile.BadZipFile, KeyError, OSError, ValueError) as exc: raise QuikFormatError( "Не удалось открыть файл как .xlsx. Если это .xls или .csv — " "откройте выгрузку в Excel и сохраните как «Книга Excel (.xlsx)»." ) from exc def _find_sheet(workbook: Any) -> tuple[str, int, dict[str, int], int | None]: """-> (лист, строка заголовка, поле -> индекс колонки, индекс дубля «Инструмент сокр.»).""" required = {_norm(title): name for name, title in COLUMNS.items()} best_missing: tuple[str, list[str]] | None = None sheet1_found: str | None = None for ws in workbook.worksheets: for row_number, values in enumerate( ws.iter_rows(max_row=_HEADER_SEARCH_ROWS, values_only=True), start=1 ): headers = [_norm(v) if isinstance(v, str) else "" for v in values] if _norm("Дата сделки") not in headers or _norm("Операция") not in headers: continue columns: dict[str, int] = {} duplicate: int | None = None for index, header in enumerate(headers): name = required.get(header) if name is None: continue if name in columns: if name == "instrument" and duplicate is None: duplicate = index continue columns[name] = index missing = [COLUMNS[name] for name in COLUMNS if name not in columns] if not missing: return ws.title, row_number, columns, duplicate if all(_norm(m) in headers for m in _SHEET1_MARKERS) and ( "comment" not in columns ): sheet1_found = sheet1_found or ws.title elif best_missing is None or len(missing) < len(best_missing[1]): best_missing = (ws.title, missing) break if sheet1_found is not None: raise QuikFormatError( f"Лист «{sheet1_found}» — выгрузка в коротком формате (с НКД и " "доходностью, без комментария, номера заявки и комиссий). Нужна " "таблица сделок QUIK с колонками «Комментарий», «Заявка», " "«Комиссия ТС», «Клиринговая комиссия», «ФБ комиссия», " "«ТЦ комиссия», «Комиссия брокера» (формат «Лист2»)." ) if best_missing is not None: sheet, missing = best_missing raise QuikFormatError( f"На листе «{sheet}» нет колонок: " + ", ".join(f"«{m}»" for m in missing) ) raise QuikFormatError( "Не найдена таблица сделок QUIK: ни на одном листе нет заголовков " "«Дата сделки» и «Операция»." ) def _norm(value: str) -> str: return " ".join(value.replace("ё", "е").replace("Ё", "Е").split()).casefold() def _is_empty(values: Sequence[Any]) -> bool: return all(v is None or (isinstance(v, str) and not v.strip()) for v in values) # --- разбор строки ---------------------------------------------------------- def _parse_row( values: Sequence[Any], row: int, ordinal: int, columns: dict[str, int], duplicate: int | None, result: QuikParseResult, ) -> QuikTrade | None: errors_before = len(result.errors) def cell(name: str) -> Any: index = columns[name] return values[index] if index < len(values) else None def error(name: str, message: str) -> None: result.errors.append(RowIssue(row, COLUMNS[name], message)) def warning(name: str, message: str) -> None: result.warnings.append(RowIssue(row, COLUMNS[name], message)) def required(name: str, parse: Any) -> Any: raw = cell(name) if _blank(raw): error(name, "пусто") return None try: return parse(raw) except ValueError as exc: error(name, str(exc)) return None trade_date = required("trade_date", _to_date) trade_time = required("trade_time", _to_time) settlement_date = required("settlement_date", _to_date) instrument = required("instrument", _to_text) operation = required("operation", _to_operation) quantity = required("quantity", _to_decimal) price = required("price", _to_decimal) amount = required("amount", _to_decimal) settlement_amount = required("settlement_amount", _to_decimal) currency = required("currency", _to_currency) if quantity is not None: if quantity <= 0: error("quantity", f"должно быть больше нуля, а не {quantity}") elif quantity != quantity.to_integral_value(): error("quantity", f"должно быть целым, а не {quantity}") for name, value in ( ("price", price), ("amount", amount), ("settlement_amount", settlement_amount), ): if value is not None and value <= 0: error(name, f"должно быть больше нуля, а не {value}") commissions: dict[str, Decimal] = {} for name in _COMMISSIONS: raw = cell(name) if _blank(raw): commissions[name] = Decimal(0) continue try: value = _to_decimal(raw) except ValueError as exc: error(name, str(exc)) continue if value < 0: error(name, f"не может быть отрицательной: {value}") commissions[name] = value if len(result.errors) > errors_before: return None # Не ошибки: данные загрузить можно, но пользователю стоит посмотреть. if settlement_date < trade_date: warning("settlement_date", "дата расчётов раньше даты сделки") expected_ts = ( commissions["commission_clearing"] + commissions["commission_exchange"] ) if commissions["commission_ts"] != expected_ts: warning( "commission_ts", f"{commissions['commission_ts']} ≠ клиринговая + ФБ ({expected_ts}), " "см. D7", ) if duplicate is not None and duplicate < len(values): second = values[duplicate] if not _blank(second) and _to_text(second) != instrument: warning( "instrument", f"во второй колонке «Инструмент сокр.» другое значение: {second}", ) return QuikTrade( ordinal=ordinal, row=row, trade_date=trade_date, trade_time=trade_time, settlement_date=settlement_date, instrument=instrument, operation=operation, account=_optional_text(cell("account")), quantity=quantity, price=price, amount=amount, settlement_amount=settlement_amount, comment=_optional_text(cell("comment")), settlement_code=_optional_text(cell("settlement_code")), order_number=_optional_text(cell("order_number")), counterparty=_optional_text(cell("counterparty")), currency=currency, **commissions, ) def _blank(value: Any) -> bool: return value is None or (isinstance(value, str) and not value.strip()) def _to_text(value: Any) -> str: if isinstance(value, float) and value.is_integer(): value = int(value) return str(value).strip() def _optional_text(value: Any) -> str: return "" if _blank(value) else _to_text(value) _NUMBER_JUNK = re.compile(r"[\s  ]") def _to_decimal(value: Any) -> Decimal: """Деньги — только Decimal (D15). float из xlsx берём через repr — кратчайшее представление (8217.6, а не 8217.6000000000003638).""" if isinstance(value, bool): raise ValueError(f"не число: {value}") if isinstance(value, int): return Decimal(value) if isinstance(value, float): return Decimal(repr(value)) if isinstance(value, str): text = _NUMBER_JUNK.sub("", value).replace(",", ".") try: number = Decimal(text) except InvalidOperation: raise ValueError(f"не число: «{value}»") from None if not number.is_finite(): raise ValueError(f"не число: «{value}»") return number raise ValueError(f"не число: {value!r}") def _to_date(value: Any) -> date: if isinstance(value, datetime): parsed = value.date() elif isinstance(value, date): parsed = value elif isinstance(value, (int, float)) and not isinstance(value, bool): parsed = _EXCEL_EPOCH + timedelta(days=int(value)) elif isinstance(value, str): parsed = _parse_date_text(value.strip()) else: raise ValueError(f"не дата: {value!r}") if not _MIN_DATE <= parsed < _MAX_DATE: raise ValueError(f"дата вне допустимого диапазона: {parsed:%d.%m.%Y}") return parsed def _parse_date_text(text: str) -> date: for fmt in ("%d.%m.%Y", "%Y-%m-%d"): try: return datetime.strptime(text, fmt).date() except ValueError: continue raise ValueError(f"не дата: «{text}» (ожидается ДД.ММ.ГГГГ)") def _to_time(value: Any) -> time: if isinstance(value, datetime): return value.time().replace(microsecond=0) if isinstance(value, time): return value.replace(microsecond=0) if isinstance(value, timedelta): return _time_from_seconds(round(value.total_seconds())) if isinstance(value, (int, float)) and not isinstance(value, bool): if not 0 <= value < 1: raise ValueError(f"не время: {value} (ожидается доля суток)") return _time_from_seconds(round(value * 86400)) if isinstance(value, str): text = value.strip() for fmt in ("%H:%M:%S", "%H:%M"): try: return datetime.strptime(text, fmt).time() except ValueError: continue raise ValueError(f"не время: «{text}» (ожидается ЧЧ:ММ:СС)") raise ValueError(f"не время: {value!r}") def _time_from_seconds(seconds: int) -> time: seconds = min(seconds, 86399) # 23:59:59.6 не должно стать 24:00:00 return time(seconds // 3600, seconds % 3600 // 60, seconds % 60) def _to_operation(value: Any) -> str: text = _to_text(value) operation = _OPERATIONS.get(text.casefold()) if operation is None: raise ValueError(f"неизвестная операция «{text}» (ожидается Купля/Продажа)") return operation def _to_currency(value: Any) -> str: text = _to_text(value).upper() currency = _CURRENCIES.get(text) if currency is None: raise ValueError( f"валюта «{text}» не поддерживается (ожидается SUR/RUB, CNY, USD)" ) return currency def _canonical(value: Decimal) -> str: # 100, 100.0 и «100,00» — одно и то же число -> одна строка в хэше. return format(value.normalize(), "f")