DRRK/app/imports/quik.py

516 lines
19 KiB
Python
Raw Blame History

This file contains invisible Unicode characters

This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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