- 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
441 lines
16 KiB
Python
441 lines
16 KiB
Python
"""Загрузка выгрузки сделок 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
|
||
]
|