516 lines
19 KiB
Python
516 lines
19 KiB
Python
"""Разбор выгрузки сделок 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")
|