DRRK/app/ui/pages/quik_import.py
Raykov-MS c1c73e96f5 Add funding rates, positions, business calendar and xlsx export
- funding_rate table and "Фондирование" page: rate from date, history, rate on date (D27)
- positions by issue: BUY - SELL per portfolio, "Остатки" page (D29)
- quotes are loaded only for securities with an open position (D10)
- business calendar from xmlcalendar.ru: yearly file upload, report date
  and update job use previous working day (D30)
- xlsx download for positions, quotes and funding rate history
- manual operation type as radio buttons
- QUIK instruments with purchase marks ("МТС 2Р-18_1") match the issue,
  file name kept in operation.instrument (D17)
- migrations d3a6f8b1c254, e7b2c9d4a813, f1c4a7e9b352
- project.md: decisions and questions after customer meetings
2026-10-29 11:20:15 +03:00

441 lines
16 KiB
Python
Raw Permalink Blame History

This file contains ambiguous Unicode characters

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: проверка файла, предпросмотр и
сохранение операций одной транзакцией (D17, D19, D20)."""
from __future__ import annotations
import logging
from dataclasses import dataclass
from typing import Any
import streamlit as st
from app.db.base import run_db
from app.imports.quik import QuikFormatError, QuikParseResult, parse_quik_xlsx
from app.services import quik_import
from app.services.quik_import import (
EXCLUDE,
INCLUDE,
UNKNOWN,
ImportContext,
ImportPlan,
PlannedRow,
QuikImportError,
RowStatus,
)
from app.ui.common import as_table, call_db, current_user
logger = logging.getLogger(__name__)
_FILE_KEY = "quik_file"
_UPLOAD_KEY = "quik_upload"
_SAVED_KEY = "quik_saved"
# Сброс поля загрузки после сохранения: у виджета меняется ключ.
_NONCE_KEY = "quik_file_nonce"
_PER_ROW = "Решить по строкам"
_ALL_EXCLUDE = "Исключить все"
_NO_DECISION = "—"
_EXCLUDE_LABEL = "Исключить"
_STATUS_LABELS = {
RowStatus.OWN: "в портфель",
RowStatus.UNKNOWN_INCLUDED: "в портфель (решение пользователя)",
RowStatus.UNKNOWN_EXCLUDED: "исключена",
RowStatus.UNKNOWN_PENDING: "нужно решение",
RowStatus.OTHER: "другой портфель",
RowStatus.LOADED_ELSEWHERE: "уже загружена",
RowStatus.LOADED_HERE: "уже загружена",
}
_TRADE_COLUMNS = {
"row": "Строка",
"trade_date": "Дата",
"trade_time": "Время",
"instrument": "Инструмент",
"isin": "ISIN",
"operation": "Операция",
"quantity": "Кол-во",
"price": "Цена",
"amount": "Объем",
"settlement_amount": "Расчетная сумма",
"currency": "Валюта",
"comment": "Комментарий",
"order_number": "Заявка",
"commission_ts": "Комиссия ТС",
"status": "Статус",
}
_TOTAL_COLUMNS = {
"operation": "Операция",
"currency": "Валюта",
"trades": "Сделок",
"quantity": "Кол-во",
"amount": "Объем",
"settlement_amount": "Расчетная сумма",
}
_COMMISSION_COLUMNS = {
"currency": "Валюта",
"commission_ts": "Комиссия ТС",
"commission_clearing": "Клиринговая",
"commission_exchange": "ФБ",
"commission_tc": "ТЦ",
"commission_broker": "Брокера",
}
@dataclass(frozen=True)
class QuikUpload:
file_name: str
result: QuikParseResult | None
error: str | None = None
def render() -> None:
st.header("Загрузка сделок QUIK")
saved = st.session_state.pop(_SAVED_KEY, None)
if saved:
st.success(saved)
file_key = f"{_FILE_KEY}_{st.session_state.get(_NONCE_KEY, 0)}"
st.file_uploader(
"Выгрузка сделок QUIK (.xlsx, таблица с комментарием, заявкой и комиссиями)",
type=["xlsx"],
key=file_key,
on_change=_on_file_change,
args=(file_key,),
)
upload = st.session_state.get(_UPLOAD_KEY)
if not isinstance(upload, QuikUpload):
return
if upload.error or upload.result is None:
st.error(upload.error or "Не удалось разобрать файл.")
return
_render_result(upload.file_name, upload.result)
def _on_file_change(file_key: str) -> None:
file = st.session_state.get(file_key)
if file is None:
st.session_state.pop(_UPLOAD_KEY, None)
return
try:
upload = QuikUpload(file.name, parse_quik_xlsx(file.getvalue()))
except QuikFormatError as exc:
upload = QuikUpload(file.name, None, str(exc))
except Exception: # noqa: BLE001
logger.exception("Не удалось разобрать выгрузку QUIK %s", file.name)
upload = QuikUpload(
file.name, None, "Не удалось разобрать файл — подробности в логе."
)
st.session_state[_UPLOAD_KEY] = upload
def _render_result(file_name: str, result: QuikParseResult) -> None:
st.caption(
f"{file_name}: лист «{result.sheet}», сделок {len(result.trades)}"
+ (
f", период {result.date_from:%d.%m.%Y} – {result.date_to:%d.%m.%Y}"
if result.trades
else ""
)
)
if result.errors:
st.error(
f"Ошибок в файле: {len(result.errors)}. Исправьте выгрузку и "
"загрузите её заново — файл с ошибками не загружается."
)
st.dataframe(_issues_table(result.errors), hide_index=True)
return
if result.warnings:
st.warning(f"Предупреждений: {len(result.warnings)} — загрузке не мешают.")
with st.expander("Предупреждения"):
st.dataframe(_issues_table(result.warnings), hide_index=True)
context = call_db(
quik_import.load_context(result), "Не удалось проверить файл по базе"
)
if context is None:
return
portfolio_code = _select_portfolio(result, context)
if portfolio_code is None:
return
plan = quik_import.build_plan(result.trades, context, portfolio_code, {})
_render_file_summary(plan, context)
decisions = _render_unknown_rows(plan, result.content_hash or "")
if decisions:
plan = quik_import.build_plan(result.trades, context, portfolio_code, decisions)
_render_plan(plan)
_render_save(plan, file_name, result, decisions)
def _select_portfolio(result: QuikParseResult, context: ImportContext) -> str | None:
codes = [p.code for p in context.portfolios]
names = {p.code: p.name for p in context.portfolios}
suggested = quik_import.suggest_portfolio(result.trades)
return st.selectbox(
"Портфель",
codes,
index=codes.index(suggested) if suggested in codes else None,
format_func=lambda code: f"{code} — {names[code]}",
placeholder="Выберите портфель",
help="По умолчанию — по самому частому комментарию (/uw, /pp). Каждая "
"загрузка берёт только строки своего портфеля.",
)
def _render_file_summary(plan: ImportPlan, context: ImportContext) -> None:
summary = plan.summary
columns = st.columns(len(summary.loaded) + 2)
columns[0].metric("Строк в файле", summary.rows_total)
for column, (code, count) in zip(columns[1:], summary.loaded.items()):
column.metric(f"Загружено в {code}", count)
columns[-1].metric("Не загружено никуда", summary.nowhere)
if plan.duplicate is not None:
batch = plan.duplicate
st.error(
f"Этот файл уже загружен в {batch.portfolio_code} "
f"{batch.created_at:%d.%m.%Y %H:%M} ({batch.created_by}): "
f"строк загружено {batch.rows_loaded}, исключено {batch.rows_excluded}. "
"Повторная загрузка того же файла в тот же портфель запрещена."
)
for batch in plan.overlapping:
st.warning(
f"Период пересекается с другим файлом «{batch.file_name}» в "
f"{batch.portfolio_code} ({batch.date_from:%d.%m.%Y} – "
f"{batch.date_to:%d.%m.%Y}, {batch.created_by}). Проверьте, что "
"сделки не задваиваются."
)
def _render_unknown_rows(plan: ImportPlan, content_hash: str) -> dict[int, str]:
"""Строки без портфеля (пусто, /trde, неизвестный комментарий) — решает
пользователь: отнести к выбранному портфелю или явно исключить (D19)."""
unknown = [row for row in plan.rows if row.status in UNKNOWN]
if not unknown:
return {}
code = plan.portfolio_code
include_label = f"В {code}"
st.subheader(f"Строки без портфеля: {len(unknown)}")
st.caption(
"Пустой или неизвестный комментарий, а также /trde. "
f"Отнесите их к {code} или явно исключите — молча они не пропускаются."
)
key = f"quik_{content_hash[:16]}_{code}"
mode = st.radio(
"Решение",
[_PER_ROW, f"Отнести все к {code}", _ALL_EXCLUDE],
horizontal=True,
key=f"{key}_mode",
label_visibility="collapsed",
)
ordinals = [row.trade.ordinal for row in unknown]
if mode == _ALL_EXCLUDE:
return dict.fromkeys(ordinals, EXCLUDE)
if mode != _PER_ROW:
return dict.fromkeys(ordinals, INCLUDE)
table = [
{"Решение": _NO_DECISION, **row}
for row in as_table([_trade_row(r) for r in unknown], _TRADE_COLUMNS)
]
for row in table:
row.pop("Статус")
edited = st.data_editor(
table,
key=f"{key}_rows",
hide_index=True,
disabled=[c for c in table[0] if c != "Решение"],
column_config={
"Решение": st.column_config.SelectboxColumn(
options=[_NO_DECISION, include_label, _EXCLUDE_LABEL], required=True
)
},
)
decisions = {}
for ordinal, row in zip(ordinals, edited):
if row["Решение"] == include_label:
decisions[ordinal] = INCLUDE
elif row["Решение"] == _EXCLUDE_LABEL:
decisions[ordinal] = EXCLUDE
return decisions
def _render_plan(plan: ImportPlan) -> None:
code = plan.portfolio_code
to_load = plan.to_load
st.subheader(f"Пойдёт в {code}: {len(to_load)}")
if to_load:
st.dataframe(
as_table([_trade_row(r) for r in to_load], _TRADE_COLUMNS),
hide_index=True,
)
trades = [row.trade for row in to_load]
st.dataframe(
as_table(quik_import.totals(trades), _TOTAL_COLUMNS), hide_index=True
)
st.dataframe(
as_table(quik_import.commission_totals(trades), _COMMISSION_COLUMNS),
hide_index=True,
)
st.caption(
"Комиссии показаны по отдельности: «Комиссия ТС» уже включает "
"клиринговую и ФБ, складывать их нельзя. «Комиссии МБ» в отчёте = ТС."
)
if plan.marked_instruments:
st.info(
"С меткой покупки — тот же выпуск, имя из файла сохраняется в "
"операции: "
+ ", ".join(
f"«{name}» → {issue} ({count} стр.)"
for name, (issue, count) in plan.marked_instruments.items()
)
+ "."
)
if plan.unmatched_instruments:
st.warning(
"Не найдены в справочнике бумаг (сопоставление по краткому имени "
"MOEX): "
+ ", ".join(
f"{name} ({count} стр.)"
for name, count in plan.unmatched_instruments.items()
)
+ ". Добавьте их на странице «Бумаги». Имя с меткой покупки "
"(«МТС 2Р-18_1») ищется по части до последнего «_»."
)
for name, isins in plan.ambiguous_instruments.items():
st.warning(
f"«{name}» — несколько бумаг ({', '.join(isins)}). Краткое имя "
"должно однозначно указывать на выпуск — исправьте справочник бумаг."
)
_render_other_rows(plan)
if plan.ready:
st.success(
f"Файл проверен: {len(to_load)} строк в {code}, исключено "
f"{plan.excluded}."
)
elif plan.pending:
st.info(f"Ждут решения строк без портфеля: {plan.pending}.")
def _render_save(
plan: ImportPlan, file_name: str, result: QuikParseResult, decisions: dict[int, str]
) -> None:
code = plan.portfolio_code
rows = [row.trade.ordinal for row in plan.to_load]
if plan.blockers:
st.caption("Загрузить нельзя: " + "; ".join(plan.blockers) + ".")
clicked = st.button(
f"Загрузить в {code}: {len(rows)} стр.",
type="primary",
disabled=not plan.ready,
)
if not clicked:
return
try:
with st.spinner("Сохраняем операции…"):
saved = run_db(
quik_import.save_import(
result,
file_name=file_name,
portfolio_code=code,
decisions=decisions,
expected_rows=rows,
user=current_user(),
)
)
except QuikImportError as exc:
st.error(str(exc))
return
except Exception as exc: # noqa: BLE001
logger.exception("Ошибка сохранения выгрузки QUIK %s в %s", file_name, code)
st.error(f"Не удалось сохранить операции: {exc}")
return
message = (
f"«{file_name}»: загружено в {saved.portfolio_code} {saved.rows_loaded} "
f"стр., исключено {saved.rows_excluded}."
)
rest = len(plan.with_status(RowStatus.OTHER))
if rest:
message += (
f" Строк другого портфеля: {rest} — загрузите этот же файл ещё раз, "
"выбрав другой портфель."
)
st.session_state[_SAVED_KEY] = message
st.session_state.pop(_UPLOAD_KEY, None)
st.session_state[_NONCE_KEY] = st.session_state.get(_NONCE_KEY, 0) + 1
st.rerun()
def _render_other_rows(plan: ImportPlan) -> None:
groups = [
(
"Строки другого портфеля",
plan.with_status(RowStatus.OTHER),
"Загрузите этот же файл, выбрав другой портфель.",
),
(
"Уже загружены из этого файла",
plan.with_status(RowStatus.LOADED_ELSEWHERE, RowStatus.LOADED_HERE),
"",
),
("Исключены", plan.with_status(RowStatus.UNKNOWN_EXCLUDED), ""),
]
for title, rows, hint in groups:
if not rows:
continue
with st.expander(f"{title}: {len(rows)}"):
if hint:
st.caption(hint)
st.dataframe(
as_table([_trade_row(r) for r in rows], _TRADE_COLUMNS),
hide_index=True,
)
def _trade_row(row: PlannedRow) -> dict[str, Any]:
trade = row.trade
status = _STATUS_LABELS[row.status]
if row.loaded_in:
status += f" в {row.loaded_in}"
return {
"row": trade.row,
"trade_date": trade.trade_date.strftime("%d.%m.%Y"),
"trade_time": trade.trade_time.isoformat(),
"instrument": trade.instrument,
"isin": _isin_label(row),
"operation": trade.operation,
"quantity": trade.quantity,
"price": trade.price,
"amount": trade.amount,
"settlement_amount": trade.settlement_amount,
"currency": trade.currency,
"comment": trade.comment,
"order_number": trade.order_number,
"commission_ts": trade.commission_ts,
"status": status,
}
def _isin_label(row: PlannedRow) -> str:
if not row.securities:
return "не найдена"
return ", ".join(s.isin for s in row.securities)
def _issues_table(issues: list[Any]) -> list[dict[str, Any]]:
return [
{"Строка": issue.row, "Колонка": issue.column, "Что не так": issue.message}
for issue in issues
]