From cefde859de592a753b066a7b9250511139f70e2f Mon Sep 17 00:00:00 2001 From: Raykov-MS Date: Wed, 21 Oct 2026 11:26:42 +0300 Subject: [PATCH] Add nominal history and manual quote overrides --- README.md | 19 +- .../versions/a4e81c6f2b97_manual_values.py | 111 ++++++++ app/db/models/__init__.py | 6 +- app/db/models/market_data.py | 27 ++ app/db/models/nominal.py | 54 ++++ app/repositories/market_data.py | 50 +++- app/repositories/nominals.py | 88 ++++++ app/services/market_data.py | 239 ++++++++++++++-- app/services/securities.py | 104 ++++++- app/ui/common.py | 5 + app/ui/pages/market_data.py | 150 +++++++++- app/ui/pages/securities.py | 80 +++++- docs/project.md | 6 +- tests/smoke/test_ui_smoke.py | 64 +++++ tests/unit/test_manual_values.py | 260 ++++++++++++++++++ tests/unit/test_migrations.py | 47 ++++ tests/unit/test_services.py | 18 ++ 17 files changed, 1293 insertions(+), 35 deletions(-) create mode 100644 alembic/versions/a4e81c6f2b97_manual_values.py create mode 100644 app/db/models/nominal.py create mode 100644 app/repositories/nominals.py create mode 100644 tests/unit/test_manual_values.py diff --git a/README.md b/README.md index 7ab6d9f..f8a586c 100644 --- a/README.md +++ b/README.md @@ -3,9 +3,9 @@ Streamlit-сервис, который заменит два Excel-файла с VBA-макросами ДРРК для портфелей облигаций **UW** (андеррайтинг) и **SALES** (продажи). -Сейчас есть: справочник бумаг, котировки MOEX, курс CNY ЦБ, загрузка -выгрузки сделок QUIK в портфель, ручной ввод купонов, амортизации, комиссии -за андеррайтинг и корректировок. Расчёт финрезультата, фондирование и отчёты — +Сейчас есть: справочник бумаг с историей номинала, котировки MOEX с ручной +правкой, курс CNY ЦБ, загрузка выгрузки сделок QUIK в портфель, ручной ввод +купонов, амортизации, комиссии за андеррайтинг и корректировок. Расчёт финрезультата, фондирование и отчёты — следующие итерации (`TODO(BIQ-38115)` в коде). Контекст проекта, принятые решения (D…) и открытые вопросы (Q…) — в @@ -56,6 +56,15 @@ tests/ цена `WAPRICE` -> `MARKETPRICE3`, доходность `YIELDATWAP` -> `YIELDCLOSE` (какое поле взяли — в `*_source`), upsert в `market_data`. Ошибка по одной бумаге не останавливает остальные; ошибка ключа/пути шлюза прерывает обновление. + `FACEVALUE` пишется в историю номинала, если отличается от прошлого значения + MOEX. +- **Ручная правка котировки** (страница «Рыночные данные», D6): цена, + доходность, дюрация на дату с причиной -> `market_data_manual`. Пустое поле — + действует MOEX. В таблице котировок — действующие значения, источник + «ручная»; MOEX-значения остаются в `market_data` и видны рядом с правкой. +- **История номинала** (страница «Бумаги», D11): записи `moex` и `manual` с + датой начала действия. Номинал на дату — последняя запись не позже даты, + на одну дату ручная важнее. - **Курс CNY**: с дня после последнего сохранённого (или с 01.01) по дату — `GET /scripts/XML_dynamic.asp?...&VAL_NM_RQ=R01375`, upsert в `exchange_rate`. - **Загрузка сделок QUIK** (страница «Загрузка сделок», D17, D19): .xlsx -> @@ -100,7 +109,9 @@ event loop на каждый вызов. Пул asyncpg из прошлого lo `exchange_rate` (unique `currency_id, rate_date`; `rate` — курс за `nominal` единиц, как публикует ЦБ), `operation` (сделки, частичный unique `portfolio_id, trade_number`), `external_request_log`, `import_batch` -(загрузки файлов сделок, unique `content_sha256, portfolio_id`). +(загрузки файлов сделок, unique `content_sha256, portfolio_id`), +`security_nominal_history` (unique `security_id, valid_from, source`), +`market_data_manual` (unique `security_id, market_date`). ## Переменные окружения diff --git a/alembic/versions/a4e81c6f2b97_manual_values.py b/alembic/versions/a4e81c6f2b97_manual_values.py new file mode 100644 index 0000000..c51a66f --- /dev/null +++ b/alembic/versions/a4e81c6f2b97_manual_values.py @@ -0,0 +1,111 @@ +"""история номинала, ручные цена/доходность/дюрация на дату + +Revision ID: a4e81c6f2b97 +Revises: 7c2e5a9d1f30 +Create Date: 2026-10-21 00:00:00 + +security_nominal_history (D11), market_data_manual (D6). +""" + +from typing import Sequence, Union + +import sqlalchemy as sa + +from alembic import op +from app.core.config import settings + +# revision identifiers, used by Alembic. +revision: str = "a4e81c6f2b97" +down_revision: Union[str, None] = "7c2e5a9d1f30" +branch_labels: Union[str, Sequence[str], None] = None +depends_on: Union[str, Sequence[str], None] = None + +_schema = settings.DATABASE_SCHEMA or None + +# Типы зафиксированы в ревизии, как и в начальной миграции. +_BIGINT_PK = sa.BigInteger().with_variant(sa.Integer(), "sqlite") +_PRICE = sa.Numeric(18, 6) +_PERCENT = sa.Numeric(12, 6) +_QUANTITY = sa.Numeric(20, 4) + + +def _fk(table_column: str) -> str: + return f"{_schema}.{table_column}" if _schema else table_column + + +def _id() -> sa.Column: + return sa.Column("id", _BIGINT_PK, sa.Identity(), primary_key=True) + + +def _security_id() -> sa.Column: + return sa.Column( + "security_id", + sa.BigInteger(), + sa.ForeignKey(_fk("security.id")), + nullable=False, + ) + + +def _audit() -> list[sa.Column]: + return [ + sa.Column( + "created_at", + sa.DateTime(timezone=True), + server_default=sa.func.now(), + nullable=False, + ), + sa.Column("created_by", sa.String(255), nullable=False), + sa.Column( + "updated_at", + sa.DateTime(timezone=True), + server_default=sa.func.now(), + nullable=False, + ), + sa.Column("updated_by", sa.String(255), nullable=False), + ] + + +def upgrade() -> None: + """Upgrade schema.""" + op.create_table( + "security_nominal_history", + _id(), + _security_id(), + sa.Column("valid_from", sa.Date(), nullable=False), + sa.Column("face_value", _PRICE, nullable=False), + sa.Column("source", sa.String(10), nullable=False), + sa.Column("reason", sa.String(500), nullable=True), + *_audit(), + sa.UniqueConstraint( + "security_id", + "valid_from", + "source", + name="uq_security_nominal_history_date_source", + ), + sa.CheckConstraint( + "source IN ('moex', 'manual')", name="ck_security_nominal_history_source" + ), + schema=_schema, + ) + + op.create_table( + "market_data_manual", + _id(), + _security_id(), + sa.Column("market_date", sa.Date(), nullable=False), + sa.Column("clean_price", _PRICE, nullable=True), + sa.Column("yield_value", _PERCENT, nullable=True), + sa.Column("duration_days", _QUANTITY, nullable=True), + sa.Column("reason", sa.String(500), nullable=False), + *_audit(), + sa.UniqueConstraint( + "security_id", "market_date", name="uq_market_data_manual_security_date" + ), + schema=_schema, + ) + + +def downgrade() -> None: + """Downgrade schema.""" + op.drop_table("market_data_manual", schema=_schema) + op.drop_table("security_nominal_history", schema=_schema) diff --git a/app/db/models/__init__.py b/app/db/models/__init__.py index 256dedd..3328210 100644 --- a/app/db/models/__init__.py +++ b/app/db/models/__init__.py @@ -1,7 +1,8 @@ from app.db.models.exchange_rate import ExchangeRate from app.db.models.external_request_log import ExternalRequestLog from app.db.models.import_batch import ImportBatch -from app.db.models.market_data import MarketData +from app.db.models.market_data import MarketData, MarketDataManual +from app.db.models.nominal import NominalSource, SecurityNominalHistory from app.db.models.operation import Operation from app.db.models.reference import Currency, OperationCode, OperationType, Portfolio from app.db.models.security import Security @@ -12,9 +13,12 @@ __all__ = [ "ExternalRequestLog", "ImportBatch", "MarketData", + "MarketDataManual", + "NominalSource", "Operation", "OperationCode", "OperationType", "Portfolio", "Security", + "SecurityNominalHistory", ] diff --git a/app/db/models/market_data.py b/app/db/models/market_data.py index 9b8cfa7..047d6b9 100644 --- a/app/db/models/market_data.py +++ b/app/db/models/market_data.py @@ -66,3 +66,30 @@ class MarketData(IdMixin, AuditMixin, Base): loaded_at: Mapped[datetime] = mapped_column( DateTime(timezone=True), server_default=func.now(), nullable=False ) + + +class MarketDataManual(IdMixin, AuditMixin, Base): + """Ручные цена, доходность, дюрация бумаги на дату (D6). + + Отдельно от market_data: загрузка MOEX сюда не пишет, значение MOEX + остаётся в market_data. Пустое поле — действует значение MOEX. Повторная + правка на ту же дату перезаписывает строку: updated_* — кто и когда, + reason — почему. + """ + + __tablename__ = "market_data_manual" + __table_args__ = ( + UniqueConstraint( + "security_id", "market_date", name="uq_market_data_manual_security_date" + ), + {"schema": settings.DATABASE_SCHEMA} if settings.DATABASE_SCHEMA else {}, + ) + + security_id: Mapped[int] = mapped_column( + BigInteger, ForeignKey(qualified("security.id")), nullable=False + ) + market_date: Mapped[date] = mapped_column(Date, nullable=False) + clean_price: Mapped[Decimal | None] = mapped_column(Price) + yield_value: Mapped[Decimal | None] = mapped_column(Percent) + duration_days: Mapped[Decimal | None] = mapped_column(Quantity) + reason: Mapped[str] = mapped_column(String(500), nullable=False) diff --git a/app/db/models/nominal.py b/app/db/models/nominal.py new file mode 100644 index 0000000..63dd435 --- /dev/null +++ b/app/db/models/nominal.py @@ -0,0 +1,54 @@ +from __future__ import annotations + +from datetime import date +from decimal import Decimal + +from sqlalchemy import ( + BigInteger, + CheckConstraint, + Date, + ForeignKey, + String, + UniqueConstraint, +) +from sqlalchemy.orm import Mapped, mapped_column + +from app.core.config import settings +from app.db.base import Base +from app.db.types import AuditMixin, IdMixin, Price, qualified + + +class NominalSource: + MOEX = "moex" # FACEVALUE из истории торгов + MANUAL = "manual" + + +class SecurityNominalHistory(IdMixin, AuditMixin, Base): + """Номинал бумаги, действующий с даты valid_from (D11). + + moex пишется при загрузке котировок, только когда FACEVALUE изменился; + manual — ручная правка с причиной. На одну дату может быть по строке + каждого источника, ручная в приоритете. + """ + + __tablename__ = "security_nominal_history" + __table_args__ = ( + UniqueConstraint( + "security_id", + "valid_from", + "source", + name="uq_security_nominal_history_date_source", + ), + CheckConstraint( + "source IN ('moex', 'manual')", name="ck_security_nominal_history_source" + ), + {"schema": settings.DATABASE_SCHEMA} if settings.DATABASE_SCHEMA else {}, + ) + + security_id: Mapped[int] = mapped_column( + BigInteger, ForeignKey(qualified("security.id")), nullable=False + ) + valid_from: Mapped[date] = mapped_column(Date, nullable=False) + face_value: Mapped[Decimal] = mapped_column(Price, nullable=False) + source: Mapped[str] = mapped_column(String(10), nullable=False) + reason: Mapped[str | None] = mapped_column(String(500)) diff --git a/app/repositories/market_data.py b/app/repositories/market_data.py index ed0c511..4a3d39f 100644 --- a/app/repositories/market_data.py +++ b/app/repositories/market_data.py @@ -1,11 +1,12 @@ from __future__ import annotations from datetime import date +from decimal import Decimal from sqlalchemy import func, select from sqlalchemy.ext.asyncio import AsyncSession -from app.db.models import MarketData, Security +from app.db.models import MarketData, MarketDataManual, Security from app.integrations.moex import MarketQuote from app.repositories._upsert import insert_for @@ -59,3 +60,50 @@ class MarketDataRepository: .order_by(Security.isin) ) return [(row, security) for row, security in result.all()] + + async def list_manual_for_date( + self, market_date: date + ) -> list[tuple[MarketDataManual, Security]]: + result = await self.session.execute( + select(MarketDataManual, Security) + .join(Security, Security.id == MarketDataManual.security_id) + .where(MarketDataManual.market_date == market_date) + .order_by(Security.isin) + ) + return [(row, security) for row, security in result.all()] + + async def upsert_manual( + self, + *, + security_id: int, + market_date: date, + clean_price: Decimal | None, + yield_value: Decimal | None, + duration_days: Decimal | None, + reason: str, + user: str, + ) -> None: + """Правка на ту же дату заменяет все три значения: пустое поле — + снова действует значение MOEX.""" + values = { + "clean_price": clean_price, + "yield_value": yield_value, + "duration_days": duration_days, + "reason": reason, + } + stmt = insert_for(self.session, MarketDataManual.__table__).values( + security_id=security_id, + market_date=market_date, + created_by=user, + updated_by=user, + **values, + ) + stmt = stmt.on_conflict_do_update( + index_elements=["security_id", "market_date"], + set_={ + **{name: stmt.excluded[name] for name in values}, + "updated_at": func.now(), + "updated_by": user, + }, + ) + await self.session.execute(stmt) diff --git a/app/repositories/nominals.py b/app/repositories/nominals.py new file mode 100644 index 0000000..8ad15e6 --- /dev/null +++ b/app/repositories/nominals.py @@ -0,0 +1,88 @@ +from __future__ import annotations + +from collections.abc import Sequence +from datetime import date +from decimal import Decimal + +from sqlalchemy import case, func, select +from sqlalchemy.ext.asyncio import AsyncSession + +from app.db.models import NominalSource, SecurityNominalHistory +from app.repositories._upsert import insert_for + +_History = SecurityNominalHistory + +# На одну дату ручное значение важнее значения MOEX (D11). +_MANUAL_FIRST = case((_History.source == NominalSource.MANUAL, 0), else_=1) + + +class NominalHistoryRepository: + def __init__(self, session: AsyncSession) -> None: + self.session = session + + async def effective_on( + self, security_id: int, on_date: date + ) -> SecurityNominalHistory | None: + """Номинал, действующий на дату: последняя запись с valid_from <= даты.""" + result = await self.session.execute( + select(_History) + .where(_History.security_id == security_id, _History.valid_from <= on_date) + .order_by(_History.valid_from.desc(), _MANUAL_FIRST) + .limit(1) + ) + return result.scalar_one_or_none() + + async def last_moex_on( + self, security_id: int, on_date: date + ) -> SecurityNominalHistory | None: + result = await self.session.execute( + select(_History) + .where( + _History.security_id == security_id, + _History.source == NominalSource.MOEX, + _History.valid_from <= on_date, + ) + .order_by(_History.valid_from.desc()) + .limit(1) + ) + return result.scalar_one_or_none() + + async def upsert( + self, + *, + security_id: int, + valid_from: date, + face_value: Decimal, + source: str, + reason: str | None, + user: str, + ) -> None: + stmt = insert_for(self.session, _History.__table__).values( + security_id=security_id, + valid_from=valid_from, + face_value=face_value, + source=source, + reason=reason, + created_by=user, + updated_by=user, + ) + stmt = stmt.on_conflict_do_update( + index_elements=["security_id", "valid_from", "source"], + set_={ + "face_value": stmt.excluded.face_value, + "reason": stmt.excluded.reason, + "updated_at": func.now(), + "updated_by": user, + }, + ) + await self.session.execute(stmt) + + async def list_for_security( + self, security_id: int + ) -> Sequence[SecurityNominalHistory]: + result = await self.session.execute( + select(_History) + .where(_History.security_id == security_id) + .order_by(_History.valid_from.desc(), _MANUAL_FIRST) + ) + return result.scalars().all() diff --git a/app/services/market_data.py b/app/services/market_data.py index ae743f3..e0d99ab 100644 --- a/app/services/market_data.py +++ b/app/services/market_data.py @@ -2,22 +2,32 @@ from __future__ import annotations import logging from dataclasses import dataclass, field -from datetime import date +from datetime import date, datetime from decimal import Decimal from app.db.base import SessionLocal +from app.db.models import NominalSource from app.integrations.gateway import ( GatewayAuthError, GatewayError, GatewayNotFoundError, ) -from app.integrations.moex import MoexClient, MoexDataError +from app.integrations.moex import MarketQuote, MoexClient, MoexDataError from app.repositories.market_data import MarketDataRepository +from app.repositories.nominals import NominalHistoryRepository from app.repositories.securities import SecurityRepository from app.services.request_log import save_request_log logger = logging.getLogger(__name__) +# Источник значения, если действует ручная правка (D6). +MANUAL_SOURCE = "ручная" +_REASON_MAX = 500 + + +class MarketDataError(RuntimeError): + """Ошибка с понятным пользователю текстом.""" + @dataclass class MarketDataUpdateResult: @@ -34,14 +44,17 @@ class MarketDataUpdateResult: @dataclass(frozen=True) class MarketDataRow: + """Действующие на дату значения: ручная правка, иначе MOEX (D6).""" + isin: str name: str - board: str + board: str | None # None — торгов не было, есть только ручная правка clean_price: Decimal | None clean_price_source: str | None yield_value: Decimal | None yield_source: str | None duration_days: Decimal | None + duration_source: str | None accrued_interest: Decimal | None face_value: Decimal | None coupon_rate: Decimal | None @@ -49,6 +62,33 @@ class MarketDataRow: volume: Decimal | None +@dataclass(frozen=True) +class ManualQuoteInput: + isin: str + market_date: date + clean_price: Decimal | None + yield_value: Decimal | None + duration_days: Decimal | None + reason: str + + +@dataclass(frozen=True) +class ManualQuoteRow: + """Ручная правка на дату и значения MOEX за ту же дату рядом.""" + + isin: str + name: str + clean_price: Decimal | None + moex_clean_price: Decimal | None + yield_value: Decimal | None + moex_yield_value: Decimal | None + duration_days: Decimal | None + moex_duration_days: Decimal | None + reason: str + updated_by: str + updated_at: datetime + + async def update( on_date: date, user: str, *, moex: MoexClient | None = None ) -> MarketDataUpdateResult: @@ -58,6 +98,7 @@ async def update( и не останавливает остальные. Исключение — ошибки конфигурации шлюза (ключ/путь): они одинаковы для всех бумаг, поэтому обновление прерывается. Транзакция не держится открытой во время HTTP-запросов. + Ручные правки (market_data_manual) загрузка не трогает (D6). """ moex = moex or MoexClient.from_settings() result = MarketDataUpdateResult(on_date=on_date) @@ -80,6 +121,9 @@ async def update( continue async with SessionLocal() as session: await MarketDataRepository(session).upsert(security.id, quote, user) + previous_nominal = await _record_moex_nominal( + NominalHistoryRepository(session), security.id, quote, user + ) await session.commit() result.updated += 1 price = ( @@ -87,7 +131,13 @@ async def update( if quote.clean_price is not None else "нет цены" ) - result.messages.append(f"{label}: {quote.board}, цена {price}.") + message = f"{label}: {quote.board}, цена {price}." + if previous_nominal is not None: + message += ( + f" Номинал изменился: {_plain(previous_nominal)} -> " + f"{_plain(quote.face_value)}." + ) + result.messages.append(message) except (GatewayAuthError, GatewayNotFoundError) as exc: remaining = len(securities) - index result.errors += remaining @@ -111,24 +161,169 @@ async def update( return result +async def _record_moex_nominal( + nominals: NominalHistoryRepository, + security_id: int, + quote: MarketQuote, + user: str, +) -> Decimal | None: + """FACEVALUE -> история номинала (D11), если он отличается от последнего + значения MOEX на эту дату. Ручные записи в сравнении не участвуют: ручной + номинал действует до следующего изменения номинала на MOEX. + + Возвращает прежний номинал MOEX, если он был и изменился. + """ + if quote.face_value is None: + return None + last = await nominals.last_moex_on(security_id, quote.trade_date) + if last is not None and last.face_value == quote.face_value: + return None + await nominals.upsert( + security_id=security_id, + valid_from=quote.trade_date, + face_value=quote.face_value, + source=NominalSource.MOEX, + reason=None, + user=user, + ) + return last.face_value if last is not None else None + + +def _plain(value: Decimal | None) -> str: + return format(value.normalize(), "f") if value is not None else "—" + + +def _pick( + manual: Decimal | None, moex: Decimal | None, moex_source: str | None +) -> tuple[Decimal | None, str | None]: + if manual is not None: + return manual, MANUAL_SOURCE + if moex is not None: + return moex, moex_source + return None, None + + async def list_for_date(market_date: date) -> list[MarketDataRow]: + """Котировки на дату с учётом ручных правок (D6).""" async with SessionLocal() as session: - rows = await MarketDataRepository(session).list_for_date(market_date) - return [ - MarketDataRow( - isin=security.isin, - name=security.name, - board=row.board, - clean_price=row.clean_price, - clean_price_source=row.clean_price_source, - yield_value=row.yield_value, - yield_source=row.yield_source, - duration_days=row.duration_days, - accrued_interest=row.accrued_interest, - face_value=row.face_value, - coupon_rate=row.coupon_rate, - num_trades=row.num_trades, - volume=row.volume, + repository = MarketDataRepository(session) + quotes = await repository.list_for_date(market_date) + overrides = await repository.list_manual_for_date(market_date) + + securities = {security.id: security for _, security in [*quotes, *overrides]} + moex = {security.id: row for row, security in quotes} + manual = {security.id: row for row, security in overrides} + rows = [] + for security_id, security in securities.items(): + quote = moex.get(security_id) + override = manual.get(security_id) + price, price_source = _pick( + override.clean_price if override else None, + quote.clean_price if quote else None, + quote.clean_price_source if quote else None, ) - for row, security in rows - ] + yield_value, yield_source = _pick( + override.yield_value if override else None, + quote.yield_value if quote else None, + quote.yield_source if quote else None, + ) + duration, duration_source = _pick( + override.duration_days if override else None, + quote.duration_days if quote else None, + "DURATION", + ) + rows.append( + MarketDataRow( + isin=security.isin, + name=security.name, + board=quote.board if quote else None, + clean_price=price, + clean_price_source=price_source, + yield_value=yield_value, + yield_source=yield_source, + duration_days=duration, + duration_source=duration_source, + accrued_interest=quote.accrued_interest if quote else None, + face_value=quote.face_value if quote else None, + coupon_rate=quote.coupon_rate if quote else None, + num_trades=quote.num_trades if quote else None, + volume=quote.volume if quote else None, + ) + ) + return sorted(rows, key=lambda row: row.isin) + + +def validate_manual(value: ManualQuoteInput) -> list[str]: + errors = [] + if ( + value.clean_price is None + and value.yield_value is None + and value.duration_days is None + ): + errors.append("Укажите цену, доходность или дюрацию.") + if value.clean_price is not None and value.clean_price <= 0: + errors.append("Цена должна быть больше нуля.") + if value.duration_days is not None and value.duration_days < 0: + errors.append("Дюрация не может быть отрицательной.") + reason = value.reason.strip() + if not reason: + errors.append("Укажите причину правки.") + elif len(reason) > _REASON_MAX: + errors.append(f"Причина — не длиннее {_REASON_MAX} символов.") + if value.market_date > date.today(): + errors.append("Дата не может быть в будущем.") + return errors + + +async def set_manual_quote(value: ManualQuoteInput, user: str) -> None: + """Ручные значения на дату (D6). Загрузка MOEX их не перезаписывает. + + TODO(BIQ-38115): права на ручные значения — Q16, пока может любой + пользователь; цена при отсутствии торгов — Q14. + """ + errors = validate_manual(value) + if errors: + raise MarketDataError(" ".join(errors)) + async with SessionLocal() as session: + security = await SecurityRepository(session).get_by_isin(value.isin) + if security is None: + raise MarketDataError(f"Бумаги {value.isin} нет в справочнике.") + await MarketDataRepository(session).upsert_manual( + security_id=security.id, + market_date=value.market_date, + clean_price=value.clean_price, + yield_value=value.yield_value, + duration_days=value.duration_days, + reason=value.reason.strip(), + user=user, + ) + await session.commit() + + +async def list_manual_for_date(market_date: date) -> list[ManualQuoteRow]: + async with SessionLocal() as session: + repository = MarketDataRepository(session) + overrides = await repository.list_manual_for_date(market_date) + quotes = { + security.id: row + for row, security in await repository.list_for_date(market_date) + } + rows = [] + for override, security in overrides: + quote = quotes.get(security.id) + rows.append( + ManualQuoteRow( + isin=security.isin, + name=security.name, + clean_price=override.clean_price, + moex_clean_price=quote.clean_price if quote else None, + yield_value=override.yield_value, + moex_yield_value=quote.yield_value if quote else None, + duration_days=override.duration_days, + moex_duration_days=quote.duration_days if quote else None, + reason=override.reason, + updated_by=override.updated_by, + updated_at=override.updated_at, + ) + ) + return rows diff --git a/app/services/securities.py b/app/services/securities.py index e64e706..79d0f78 100644 --- a/app/services/securities.py +++ b/app/services/securities.py @@ -2,18 +2,25 @@ from __future__ import annotations import re from dataclasses import dataclass -from datetime import date +from datetime import date, datetime from decimal import Decimal +from sqlalchemy.ext.asyncio import AsyncSession + from app.db.base import SessionLocal +from app.db.models import NominalSource from app.integrations.gateway import GatewayError from app.integrations.moex import MoexClient, MoexDataError, SecurityInfo +from app.repositories.nominals import NominalHistoryRepository from app.repositories.reference import ReferenceRepository from app.repositories.securities import SecurityRepository from app.services.request_log import save_request_log _ISIN_RE = re.compile(r"^[A-Z]{2}[A-Z0-9]{9}[0-9]$") +NOMINAL_SOURCES = {NominalSource.MOEX: "MOEX", NominalSource.MANUAL: "ручной"} +_REASON_MAX = 500 + # FACEUNIT ISS -> код валюты в справочнике. SUR — исторический код рубля на MOEX. _FACE_UNIT_TO_CURRENCY = {"SUR": "RUB", "RUB": "RUB", "CNY": "CNY", "USD": "USD"} @@ -53,6 +60,24 @@ class SecurityRow: is_active: bool +@dataclass(frozen=True) +class NominalRow: + valid_from: date + face_value: Decimal + source: str + reason: str | None + updated_by: str + updated_at: datetime + + +@dataclass(frozen=True) +class ManualNominalInput: + isin: str + valid_from: date + face_value: Decimal | None + reason: str + + def normalize_isin(isin: str) -> str: value = (isin or "").strip().upper() if not _ISIN_RE.match(value): @@ -150,3 +175,80 @@ async def list_securities() -> list[SecurityRow]: ) for security, currency in rows ] + + +async def _security_id(session: AsyncSession, isin: str) -> int: + security = await SecurityRepository(session).get_by_isin(isin) + if security is None: + raise SecurityServiceError(f"Бумаги {isin} нет в справочнике.") + return security.id + + +async def nominal_history(isin: str) -> list[NominalRow]: + """История номинала бумаги (D11), новые сверху.""" + async with SessionLocal() as session: + security_id = await _security_id(session, isin) + rows = await NominalHistoryRepository(session).list_for_security(security_id) + return [ + NominalRow( + valid_from=row.valid_from, + face_value=row.face_value, + source=NOMINAL_SOURCES.get(row.source, row.source), + reason=row.reason, + updated_by=row.updated_by, + updated_at=row.updated_at, + ) + for row in rows + ] + + +async def nominal_on(isin: str, on_date: date) -> Decimal | None: + """Номинал на дату: последняя запись истории не позже даты, на одну дату + ручная важнее MOEX. + + TODO(BIQ-38115): номинал до первой записи истории (бумаги, амортизированные + до начала загрузки котировок) — Q26; пока None. + """ + async with SessionLocal() as session: + security_id = await _security_id(session, isin) + row = await NominalHistoryRepository(session).effective_on(security_id, on_date) + return row.face_value if row is not None else None + + +def validate_nominal(value: ManualNominalInput) -> list[str]: + errors = [] + if value.face_value is None: + errors.append("Укажите номинал.") + elif value.face_value <= 0: + errors.append("Номинал должен быть больше нуля.") + reason = value.reason.strip() + if not reason: + errors.append("Укажите причину правки.") + elif len(reason) > _REASON_MAX: + errors.append(f"Причина — не длиннее {_REASON_MAX} символов.") + if value.valid_from > date.today(): + errors.append("Дата не может быть в будущем.") + return errors + + +async def set_manual_nominal(value: ManualNominalInput, user: str) -> None: + """Ручной номинал с даты (D11). Повторная правка на ту же дату + перезаписывает ручную запись; запись MOEX остаётся рядом. + + TODO(BIQ-38115): права на ручные значения — Q16, пока может любой + пользователь. + """ + errors = validate_nominal(value) + if errors: + raise SecurityServiceError(" ".join(errors)) + async with SessionLocal() as session: + security_id = await _security_id(session, value.isin) + await NominalHistoryRepository(session).upsert( + security_id=security_id, + valid_from=value.valid_from, + face_value=value.face_value, + source=NominalSource.MANUAL, + reason=value.reason.strip(), + user=user, + ) + await session.commit() diff --git a/app/ui/common.py b/app/ui/common.py index 7c3d354..62bbd1d 100644 --- a/app/ui/common.py +++ b/app/ui/common.py @@ -50,6 +50,11 @@ def as_table(rows: list[Any], columns: dict[str, str]) -> list[dict[str, Any]]: return table +def decimal_text(value: Decimal | None) -> str: + """Decimal -> текст для поля ввода ('' для None), без экспоненты и хвостовых нулей.""" + return "" if value is None else format(value.normalize(), "f") + + def _display(value: Any) -> Any: if isinstance(value, Decimal): return format(value.normalize(), "f") diff --git a/app/ui/pages/market_data.py b/app/ui/pages/market_data.py index 0484161..3dd4140 100644 --- a/app/ui/pages/market_data.py +++ b/app/ui/pages/market_data.py @@ -1,15 +1,28 @@ from __future__ import annotations +import logging from datetime import date +from decimal import Decimal import streamlit as st +from app.db.base import run_db from app.services import fx_rates as fx_rates_service from app.services import market_data as market_data_service +from app.services import securities as securities_service from app.services.dates import previous_business_day from app.services.fx_rates import FxRatesUpdateResult -from app.services.market_data import MarketDataUpdateResult -from app.ui.common import as_table, call_db, current_user +from app.services.manual_operations import ManualOperationError, parse_decimal +from app.services.market_data import ( + ManualQuoteInput, + ManualQuoteRow, + MarketDataError, + MarketDataRow, + MarketDataUpdateResult, +) +from app.ui.common import as_table, call_db, current_user, decimal_text + +logger = logging.getLogger(__name__) _QUOTE_COLUMNS = { "isin": "ISIN", @@ -20,6 +33,7 @@ _QUOTE_COLUMNS = { "yield_value": "Доходность, %", "yield_source": "Источник доходности", "duration_days": "Дюрация, дн.", + "duration_source": "Источник дюрации", "accrued_interest": "НКД", "face_value": "Номинал (тек.)", "coupon_rate": "Купон, %", @@ -27,6 +41,20 @@ _QUOTE_COLUMNS = { "volume": "Объём, шт.", } +_MANUAL_COLUMNS = { + "isin": "ISIN", + "name": "Наименование", + "clean_price": "Цена, ручн.", + "moex_clean_price": "Цена, MOEX", + "yield_value": "Доходность, ручн.", + "moex_yield_value": "Доходность, MOEX", + "duration_days": "Дюрация, ручн.", + "moex_duration_days": "Дюрация, MOEX", + "reason": "Причина", + "updated_by": "Кто", + "updated_at": "Когда", +} + _RATE_COLUMNS = { "rate_date": "Дата", "nominal": "Номинал", @@ -73,6 +101,8 @@ def render() -> None: elif quotes is not None: st.info("За эту дату котировок в базе нет.") + _render_manual(on_date, quotes or []) + st.subheader("Курс CNY (последние)") rates = call_db(fx_rates_service.list_recent(10), "Не удалось загрузить курсы") if rates: @@ -81,6 +111,122 @@ def render() -> None: st.info("Курсов CNY в базе пока нет.") +def _render_manual(on_date: date, quotes: list[MarketDataRow]) -> None: + st.subheader(f"Ручные правки на {on_date:%d.%m.%Y}") + manual = call_db( + market_data_service.list_manual_for_date(on_date), + "Не удалось загрузить ручные правки", + ) + if manual is None: + return + if manual: + st.dataframe(as_table(manual, _MANUAL_COLUMNS), hide_index=True) + else: + st.caption("Ручных правок за эту дату нет.") + + securities = call_db( + securities_service.list_securities(), "Не удалось загрузить список бумаг" + ) + if not securities: + return + names = {s.isin: s.name for s in securities} + with st.expander("Ручная правка цены, доходности, дюрации"): + isin = st.selectbox( + "Бумага", + list(names), + format_func=lambda i: f"{names[i]} ({i})", + key="manual_quote_isin", + ) + current = next((row for row in manual if row.isin == isin), None) + quote = next((row for row in quotes if row.isin == isin), None) + _render_manual_form(on_date, isin, current, quote) + + +def _render_manual_form( + on_date: date, + isin: str, + current: ManualQuoteRow | None, + quote: MarketDataRow | None, +) -> None: + if current is not None: + moex = ( + current.moex_clean_price, + current.moex_yield_value, + current.moex_duration_days, + ) + elif quote is not None: + moex = (quote.clean_price, quote.yield_value, quote.duration_days) + else: + moex = (None, None, None) + st.caption( + "Пустое поле — действует значение MOEX. Загрузка котировок ручные " + "значения не перезаписывает." + ) + + # Ключи с бумагой и датой: при смене бумаги или даты поля заполняются заново. + suffix = f"{isin}_{on_date:%Y%m%d}" + with st.form(f"manual_quote_{suffix}"): + price_text = st.text_input( + "Чистая цена, %", + value=decimal_text(current.clean_price if current else None), + placeholder=_moex_hint(moex[0]), + key=f"manual_price_{suffix}", + ) + yield_text = st.text_input( + "Доходность, %", + value=decimal_text(current.yield_value if current else None), + placeholder=_moex_hint(moex[1]), + key=f"manual_yield_{suffix}", + ) + duration_text = st.text_input( + "Дюрация, дн.", + value=decimal_text(current.duration_days if current else None), + placeholder=_moex_hint(moex[2]), + key=f"manual_duration_{suffix}", + ) + reason = st.text_input( + "Причина", + value=current.reason if current else "", + key=f"manual_reason_{suffix}", + ) + submitted = st.form_submit_button("Сохранить правку", type="primary") + if submitted: + _save_manual(on_date, isin, price_text, yield_text, duration_text, reason) + + +def _moex_hint(value: Decimal | None) -> str: + return f"MOEX: {decimal_text(value)}" if value is not None else "MOEX: нет" + + +def _save_manual( + on_date: date, + isin: str, + price_text: str, + yield_text: str, + duration_text: str, + reason: str, +) -> None: + try: + value = ManualQuoteInput( + isin=isin, + market_date=on_date, + clean_price=parse_decimal(price_text, "Чистая цена"), + yield_value=parse_decimal(yield_text, "Доходность"), + duration_days=parse_decimal(duration_text, "Дюрация"), + reason=reason, + ) + run_db(market_data_service.set_manual_quote(value, current_user())) + except (ManualOperationError, MarketDataError) as exc: + st.error(str(exc)) + return + except Exception as exc: # noqa: BLE001 + logger.exception("Ошибка сохранения ручной котировки %s", isin) + st.error(f"Не удалось сохранить правку: {exc}") + return + st.toast(f"Ручная правка {isin} на {on_date:%d.%m.%Y} сохранена.") + st.rerun() + + def _show_market_result(result: MarketDataUpdateResult) -> None: summary = ( f"Обновлено: {result.updated}, без торгов: {result.no_trades}, " diff --git a/app/ui/pages/securities.py b/app/ui/pages/securities.py index 07ffc39..8424faa 100644 --- a/app/ui/pages/securities.py +++ b/app/ui/pages/securities.py @@ -1,12 +1,19 @@ from __future__ import annotations import logging +from datetime import date import streamlit as st from app.db.base import run_db from app.services import securities as securities_service -from app.services.securities import SecurityPreview, SecurityServiceError +from app.services.manual_operations import ManualOperationError, parse_decimal +from app.services.securities import ( + ManualNominalInput, + SecurityPreview, + SecurityRow, + SecurityServiceError, +) from app.ui.common import as_table, call_db, current_user logger = logging.getLogger(__name__) @@ -26,6 +33,15 @@ _COLUMNS = { "is_active": "Обновлять", } +_NOMINAL_COLUMNS = { + "valid_from": "Действует с", + "face_value": "Номинал", + "source": "Источник", + "reason": "Причина", + "updated_by": "Кто", + "updated_at": "Когда", +} + def render() -> None: st.header("Бумаги") @@ -45,6 +61,68 @@ def render() -> None: if isinstance(preview, SecurityPreview): _render_preview(preview) + if rows: + st.subheader("История номинала") + _render_nominals(rows) + + +def _render_nominals(rows: list[SecurityRow]) -> None: + names = {row.isin: row.name for row in rows} + isin = st.selectbox( + "Бумага", + list(names), + format_func=lambda i: f"{names[i]} ({i})", + key="nominal_isin", + ) + st.caption( + "MOEX — из истории торгов, пишется при загрузке котировок, когда номинал " + "меняется. Ручной на ту же дату важнее и действует до следующего " + "изменения на MOEX." + ) + history = call_db( + securities_service.nominal_history(isin), + "Не удалось загрузить историю номинала", + ) + if history: + st.dataframe(as_table(history, _NOMINAL_COLUMNS), hide_index=True) + elif history is not None: + st.caption("Записей пока нет: номинал появится после загрузки котировок.") + + with st.form("manual_nominal", clear_on_submit=True): + valid_from = st.date_input( + "Действует с", + value=date.today(), + max_value=date.today(), + format="DD.MM.YYYY", + ) + face_value_text = st.text_input("Номинал", placeholder="например 645,19") + reason = st.text_input("Причина") + submitted = st.form_submit_button("Сохранить номинал") + if submitted: + _save_nominal(isin, valid_from, face_value_text, reason) + + +def _save_nominal( + isin: str, valid_from: date, face_value_text: str, reason: str +) -> None: + try: + value = ManualNominalInput( + isin=isin, + valid_from=valid_from, + face_value=parse_decimal(face_value_text, "Номинал"), + reason=reason, + ) + run_db(securities_service.set_manual_nominal(value, current_user())) + except (ManualOperationError, SecurityServiceError) as exc: + st.error(str(exc)) + return + except Exception as exc: # noqa: BLE001 + logger.exception("Ошибка сохранения номинала %s", isin) + st.error(f"Не удалось сохранить номинал: {exc}") + return + st.toast(f"Номинал {isin} с {valid_from:%d.%m.%Y} сохранён.") + st.rerun() + def _render_lookup_form() -> None: with st.form("security_lookup"): diff --git a/docs/project.md b/docs/project.md index e3bd1c5..d34f477 100644 --- a/docs/project.md +++ b/docs/project.md @@ -64,12 +64,12 @@ | D3 | Параметры бумаги и операции хранятся раздельно; объём и цена покупки — в операции BUY | | D4 | «Дата размещения» — дата покупки/размещения у банка, вводит пользователь (по умолчанию сегодня), не дата выпуска на бирже. Дата начала торгов с MOEX — отдельно, справочно. MOEX не запрашиваем, пока дата отчёта ≤ даты размещения | | D5 | «Продать до» — параметр портфеля: SALES +28 дней, UW +364, как в Excel (Q12). Разный срок для покупок одного выпуска — Q24 | -| D6 | Ручная цена на дату в приоритете и не затирается загрузкой MOEX; значение MOEX хранится рядом; кто/когда/причина. Править можно цену, доходность, дюрацию | +| D6 | Ручная цена на дату в приоритете и не затирается загрузкой MOEX; значение MOEX хранится рядом; кто/когда/причина. Править можно цену, доходность, дюрацию. Отдельная таблица `market_data_manual`, пустое поле — действует MOEX; повторная правка на дату перезаписывает | | D7 | Комиссии — пять отдельных полей. «Комиссия ТС» = клиринговая + ФБ (8217,60 = 3492,48 + 4725,12; в файле продаж 2026 — во всех 285 сделках с комиссией). «Комиссии МБ» в отчёте = сумма «Комиссии ТС», как в Excel. Все пять не суммируются — двойной счёт | | D8 | Режимы торгов: TQCB, затем TQOB. Если есть только другие борды (например PACT) — ошибка, а не «торгов не было» (Q14) | | D9 | Курсы CNY грузим с даты самой ранней CNY-операции | | D10 | Котировки обновляем только по бумагам с открытой позицией на дату отчёта | -| D11 | История номинала (`security_nominal_history`) с источником `moex` / `manual`. Заполняется из `FACEVALUE` при загрузке котировок; ручное значение на дату в приоритете, как в D6 (Q26) | +| D11 | История номинала (`security_nominal_history`) с источником `moex` / `manual`. Заполняется из `FACEVALUE` при загрузке котировок, только когда он отличается от последнего значения MOEX; ручное значение на дату в приоритете и действует до следующего изменения на MOEX. До первой записи номинала нет (Q26) | | D12 | Исходные данные храним всегда; текущий день считаем на лету; при закрытии дня — снапшот итогов для T-1, истории и сводного отчёта (Q20) | | D13 | Табличный редактор и редактируемые формулы вне скоупа: параметры в справочниках + экспорт в .xlsx (Q23) | | D14 | Лист `control` и лист РЕПО не переносим. Производственный календарь нужен (Q15) | @@ -84,7 +84,7 @@ | D23 | Дюрация = `DURATION` MOEX / 365, как в макросе | | D24 | Сводный отчёт: портфель MM не делаем; группировка по типу купона (фикс/флоутер); MD и DV01 (сверено на РЭО-02): MD = дюрация_лет / (1 + YTW_текущая / 100) (0,1 / 1,1813 = 0,08465); объём по номиналу = остаток × номинал на дату × FX; DV01 (1 б.п.) = MD × объём × 0,0001 (0,018 млн ₽); «DV01×100» = DV01 × 100 (1,7996); % лимита DV01 = DV01 / 10 млн (0,18 %). Единицы — Q27, AFS и лимиты — Q4 | | D25 | SECID ищем по ISIN один раз при добавлении бумаги и храним в `security`; дальше все запросы к ISS по SECID | -| D26 | Отмены загрузки файла и удаления ручных операций пока нет | +| D26 | Отмены загрузки файла, удаления ручных операций и сброса ручных значений (цена, номинал) пока нет | ## Открытые вопросы diff --git a/tests/smoke/test_ui_smoke.py b/tests/smoke/test_ui_smoke.py index 9ff0825..895e363 100644 --- a/tests/smoke/test_ui_smoke.py +++ b/tests/smoke/test_ui_smoke.py @@ -152,6 +152,70 @@ def test_market_page_update_buttons_and_tables( assert list(rates["За 1 CNY"])[0] == "12.7516" +def _with_reo_quote(moex) -> None: # noqa: ANN001 + from app.db.base import run_db + + run_db(securities.add_by_isin("RU000A106169", USER, moex=moex)) + run_db(market_data.update(date(2026, 10, 8), "system", moex=moex)) + + +def test_securities_page_nominal_history_and_manual_value( + session_local, fake_moex_from_settings +) -> None: + _with_reo_quote(fake_moex_from_settings) + at = AppTest.from_function(_securities_page, default_timeout=TIMEOUT) + at.session_state["user_profile"] = {"email": USER} + at.run() + + history = at.dataframe[1].value + assert list(history["Номинал"]) == ["645.19"] + assert list(history["Источник"]) == ["MOEX"] + + save = next(b for b in at.button if b.label == "Сохранить номинал") + at.text_input[1].input("640,5") # [0] — ISIN в форме добавления + save.click().run() + assert [e.value for e in at.error] == ["Укажите причину правки."] + + at.date_input[0].set_value(date(2026, 10, 9)) + at.text_input[1].input("640,5") + at.text_input[2].input("частичное погашение") + next(b for b in at.button if b.label == "Сохранить номинал").click().run() + + assert not at.exception + assert not at.error, [e.value for e in at.error] + history = at.dataframe[1].value + assert list(history["Номинал"]) == ["640.5", "645.19"] + assert list(history["Источник"]) == ["ручной", "MOEX"] + assert list(history["Причина"])[0] == "частичное погашение" + + +def test_market_page_manual_price(session_local, fake_moex_from_settings) -> None: + _with_reo_quote(fake_moex_from_settings) + at = AppTest.from_function(_market_page, default_timeout=TIMEOUT) + at.session_state["user_profile"] = {"email": USER} + at.run() + at.date_input[0].set_value(date(2026, 10, 8)).run() + + assert any("Ручных правок за эту дату нет" in c.value for c in at.caption) + assert at.text_input[0].placeholder == "MOEX: 101.42" + + at.text_input[0].input("100,1") + at.text_input[3].input("цена по RFQ") + next(b for b in at.button if b.label == "Сохранить правку").click().run() + + assert not at.exception + assert not at.error, [e.value for e in at.error] + quotes = at.dataframe[0].value + assert list(quotes["Чистая цена, %"]) == ["100.1"] + assert list(quotes["Источник цены"]) == ["ручная"] + assert list(quotes["Источник доходности"]) == ["YIELDATWAP"] + manual = at.dataframe[1].value + assert list(manual["Цена, MOEX"]) == ["101.42"] + assert list(manual["Причина"]) == ["цена по RFQ"] + assert list(manual["Кто"]) == [USER] + assert at.text_input[0].value == "100.1" # форма показывает действующую правку + + # --- загрузка сделок QUIK --------------------------------------------------- # AppTest не умеет загружать файлы в file_uploader, поэтому кладём результат # разбора в session_state так же, как это делает on_change загрузчика. diff --git a/tests/unit/test_manual_values.py b/tests/unit/test_manual_values.py new file mode 100644 index 0000000..03b48ed --- /dev/null +++ b/tests/unit/test_manual_values.py @@ -0,0 +1,260 @@ +"""История номинала (D11) и ручные цена/доходность/дюрация (D6) на временном +SQLite. MOEX — фейк на реальной фикстуре РЭО-02 за 08.10.2026.""" + +from __future__ import annotations + +from dataclasses import replace +from datetime import date, timedelta +from decimal import Decimal + +import pytest +from sqlalchemy import select + +from app.db.base import run_db +from app.db.models import MarketData +from app.services import market_data, securities +from app.services.market_data import ( + MANUAL_SOURCE, + ManualQuoteInput, + MarketDataError, + validate_manual, +) +from app.services.securities import ManualNominalInput, SecurityServiceError +from tests.unit.fakes import FakeMoex + +ON_DATE = date(2026, 10, 8) +ISIN = "RU000A106169" +USER = "analyst@rshb.ru" + + +@pytest.fixture +def reo(session_local, fixture_moex: FakeMoex) -> FakeMoex: + """РЭО-02 в справочнике; котировки ещё не загружались.""" + run_db(securities.add_by_isin(ISIN, USER, moex=fixture_moex)) + return fixture_moex + + +def _load( + moex: FakeMoex, on_date: date, **changes +) -> market_data.MarketDataUpdateResult: # noqa: ANN003 + quote = moex._history[ISIN] + moex._history[ISIN] = replace(quote, trade_date=on_date, **changes) + return run_db(market_data.update(on_date, "system", moex=moex)) + + +def _history() -> list[tuple[date, Decimal, str]]: + rows = run_db(securities.nominal_history(ISIN)) + return [(r.valid_from, r.face_value, r.source) for r in rows] + + +def _manual_nominal(valid_from: date, face_value: str, user: str = USER) -> None: + value = ManualNominalInput(ISIN, valid_from, Decimal(face_value), "погашение 10%") + run_db(securities.set_manual_nominal(value, user)) + + +def _manual_quote(**changes) -> ManualQuoteInput: # noqa: ANN003 + values = { + "isin": ISIN, + "market_date": ON_DATE, + "clean_price": None, + "yield_value": None, + "duration_days": None, + "reason": "сделок мало, цена с RFQ", + } + values.update(changes) + return ManualQuoteInput(**values) + + +# --- история номинала ------------------------------------------------------- + + +def test_moex_nominal_is_recorded_only_when_changed(reo: FakeMoex) -> None: + _load(reo, ON_DATE) + _load(reo, ON_DATE) # повторная загрузка той же даты + _load(reo, date(2026, 10, 9)) # номинал тот же + assert _history() == [(ON_DATE, Decimal("645.19"), "MOEX")] + + result = _load(reo, date(2026, 10, 12), face_value=Decimal("580.67")) + + assert _history() == [ + (date(2026, 10, 12), Decimal("580.67"), "MOEX"), + (ON_DATE, Decimal("645.19"), "MOEX"), + ] + assert "Номинал изменился: 645.19 -> 580.67" in result.messages[0] + + +def test_earlier_date_loaded_later_gets_own_record(reo: FakeMoex) -> None: + _load(reo, ON_DATE) + _load(reo, date(2026, 10, 1), face_value=Decimal("1000")) + _load(reo, date(2026, 10, 2), face_value=Decimal("1000")) + + assert _history() == [ + (ON_DATE, Decimal("645.19"), "MOEX"), + (date(2026, 10, 1), Decimal("1000"), "MOEX"), + ] + assert run_db(securities.nominal_on(ISIN, date(2026, 10, 5))) == Decimal("1000") + + +def test_manual_nominal_has_priority_until_next_moex_change(reo: FakeMoex) -> None: + _load(reo, ON_DATE) + _manual_nominal(ON_DATE, "650") + _load(reo, ON_DATE) + _load(reo, date(2026, 10, 9)) + + assert run_db(securities.nominal_on(ISIN, ON_DATE)) == Decimal("650") + assert run_db(securities.nominal_on(ISIN, date(2026, 10, 9))) == Decimal("650") + # До первой записи номинала нет (Q26). + assert run_db(securities.nominal_on(ISIN, date(2026, 10, 7))) is None + + _load(reo, date(2026, 10, 12), face_value=Decimal("580.67")) + assert run_db(securities.nominal_on(ISIN, date(2026, 10, 12))) == Decimal("580.67") + assert _history() == [ + (date(2026, 10, 12), Decimal("580.67"), "MOEX"), + (ON_DATE, Decimal("650"), "ручной"), + (ON_DATE, Decimal("645.19"), "MOEX"), + ] + + +def test_manual_nominal_same_date_is_replaced(reo: FakeMoex) -> None: + _manual_nominal(ON_DATE, "650") + _manual_nominal(ON_DATE, "640", user="head@rshb.ru") + + [row] = run_db(securities.nominal_history(ISIN)) + assert (row.face_value, row.source, row.updated_by) == ( + Decimal("640"), + "ручной", + "head@rshb.ru", + ) + assert row.reason == "погашение 10%" + + +@pytest.mark.parametrize( + ("changes", "message"), + [ + ({"face_value": None}, "Укажите номинал"), + ({"face_value": Decimal("0")}, "больше нуля"), + ({"reason": " "}, "причину"), + ({"valid_from": date.today() + timedelta(days=1)}, "в будущем"), + ], +) +def test_manual_nominal_validation(reo: FakeMoex, changes: dict, message: str) -> None: + value = replace( + ManualNominalInput(ISIN, ON_DATE, Decimal("650"), "причина"), **changes + ) + + with pytest.raises(SecurityServiceError, match=message): + run_db(securities.set_manual_nominal(value, USER)) + assert _history() == [] + + +def test_manual_nominal_unknown_security(session_local) -> None: + value = ManualNominalInput("RU000A999999", ON_DATE, Decimal("650"), "причина") + + with pytest.raises(SecurityServiceError, match="нет в справочнике"): + run_db(securities.set_manual_nominal(value, USER)) + + +# --- ручные цена, доходность, дюрация ---------------------------------------- + + +def test_manual_price_overrides_moex_and_survives_reload( + reo: FakeMoex, session_local +) -> None: + _load(reo, ON_DATE) + run_db( + market_data.set_manual_quote(_manual_quote(clean_price=Decimal("100.1")), USER) + ) + _load(reo, ON_DATE, clean_price=Decimal("101.5")) + + [row] = run_db(market_data.list_for_date(ON_DATE)) + assert (row.clean_price, row.clean_price_source) == ( + Decimal("100.1"), + MANUAL_SOURCE, + ) + assert (row.yield_value, row.yield_source) == (Decimal("17.78"), "YIELDATWAP") + assert (row.duration_days, row.duration_source) == (Decimal("759"), "DURATION") + + [manual] = run_db(market_data.list_manual_for_date(ON_DATE)) + assert manual.clean_price == Decimal("100.1") + assert manual.moex_clean_price == Decimal("101.5") + assert manual.reason == "сделок мало, цена с RFQ" + assert manual.updated_by == USER + + async def _moex_price() -> Decimal: + async with session_local() as session: + return (await session.execute(select(MarketData.clean_price))).scalar_one() + + assert run_db(_moex_price()) == Decimal("101.5") + + +def test_manual_values_without_trades(reo: FakeMoex) -> None: + no_trades = date(2026, 10, 9) + run_db( + market_data.set_manual_quote( + _manual_quote( + market_date=no_trades, + clean_price=Decimal("101"), + duration_days=Decimal("758"), + ), + USER, + ) + ) + + [row] = run_db(market_data.list_for_date(no_trades)) + assert row.board is None + assert (row.clean_price, row.clean_price_source) == (Decimal("101"), MANUAL_SOURCE) + assert (row.yield_value, row.yield_source) == (None, None) + assert (row.duration_days, row.duration_source) == (Decimal("758"), MANUAL_SOURCE) + [manual] = run_db(market_data.list_manual_for_date(no_trades)) + assert manual.moex_clean_price is None + + +def test_manual_quote_edit_replaces_all_fields(reo: FakeMoex) -> None: + _load(reo, ON_DATE) + run_db( + market_data.set_manual_quote(_manual_quote(clean_price=Decimal("100.1")), USER) + ) + run_db( + market_data.set_manual_quote( + _manual_quote(yield_value=Decimal("18.5"), reason="доходность по RFQ"), + "head@rshb.ru", + ) + ) + + [row] = run_db(market_data.list_for_date(ON_DATE)) + assert (row.clean_price, row.clean_price_source) == (Decimal("101.42"), "WAPRICE") + assert (row.yield_value, row.yield_source) == (Decimal("18.5"), MANUAL_SOURCE) + [manual] = run_db(market_data.list_manual_for_date(ON_DATE)) + assert (manual.reason, manual.updated_by) == ("доходность по RFQ", "head@rshb.ru") + + +@pytest.mark.parametrize( + ("changes", "message"), + [ + ({}, "Укажите цену, доходность или дюрацию."), + ({"clean_price": Decimal("0")}, "Цена должна быть больше нуля."), + ({"duration_days": Decimal("-1")}, "Дюрация не может быть отрицательной."), + ({"yield_value": Decimal("-0.5"), "reason": ""}, "Укажите причину правки."), + ( + { + "clean_price": Decimal("100"), + "market_date": date.today() + timedelta(days=1), + }, + "Дата не может быть в будущем.", + ), + ], +) +def test_manual_quote_validation(changes: dict, message: str) -> None: + assert validate_manual(_manual_quote(**changes)) == [message] + + +def test_manual_quote_unknown_security_and_invalid_input(reo: FakeMoex) -> None: + with pytest.raises(MarketDataError, match="нет в справочнике"): + run_db( + market_data.set_manual_quote( + _manual_quote(isin="RU000A999999", clean_price=Decimal("100")), USER + ) + ) + with pytest.raises(MarketDataError, match="Укажите цену"): + run_db(market_data.set_manual_quote(_manual_quote(), USER)) + assert run_db(market_data.list_manual_for_date(ON_DATE)) == [] diff --git a/tests/unit/test_migrations.py b/tests/unit/test_migrations.py index b114cb9..725b268 100644 --- a/tests/unit/test_migrations.py +++ b/tests/unit/test_migrations.py @@ -5,6 +5,7 @@ from pathlib import Path import pytest from sqlalchemy import Identity, create_engine, inspect, text +from sqlalchemy.exc import IntegrityError from alembic import command from alembic.autogenerate import compare_metadata @@ -55,6 +56,8 @@ def test_upgrade_creates_tables_and_seeds(sqlite_url: str) -> None: "operation", "external_request_log", "import_batch", + "security_nominal_history", + "market_data_manual", "alembic_version", } <= tables assert sorted(currencies) == ["CNY", "RUB", "USD"] @@ -129,6 +132,47 @@ def test_operation_save_revision_up_down(sqlite_url: str) -> None: engine.dispose() +def test_manual_values_revision_up_down(sqlite_url: str) -> None: + cfg = _alembic_config() + command.upgrade(cfg, "head") + engine = create_engine(sqlite_url) + + def tables() -> set[str]: + with engine.connect() as conn: + return set(inspect(conn).get_table_names()) + + assert {"security_nominal_history", "market_data_manual"} <= tables() + + command.downgrade(cfg, "7c2e5a9d1f30") + assert not {"security_nominal_history", "market_data_manual"} & tables() + + command.upgrade(cfg, "head") + assert {"security_nominal_history", "market_data_manual"} <= tables() + engine.dispose() + + +def test_nominal_source_is_checked(sqlite_url: str) -> None: + command.upgrade(_alembic_config(), "head") + engine = create_engine(sqlite_url) + with engine.begin() as conn: + conn.execute( + text( + "INSERT INTO security (isin, secid, name, currency_id, is_active, " + "created_by, updated_by) VALUES ('RU000A106169', 'RU000A106169', " + "'РЭО-02', 1, 1, 't', 't')" + ) + ) + with pytest.raises(IntegrityError), engine.begin() as conn: + conn.execute( + text( + "INSERT INTO security_nominal_history (security_id, valid_from, " + "face_value, source, created_by, updated_by) " + "VALUES (1, '2026-10-08', 645.19, 'excel', 't', 't')" + ) + ) + engine.dispose() + + def test_downgrade_removes_everything(sqlite_url: str) -> None: command.upgrade(_alembic_config(), "head") command.downgrade(_alembic_config(), "base") @@ -166,3 +210,6 @@ def test_offline_postgres_sql_uses_schema_jsonb_and_identity(monkeypatch) -> Non assert "REFERENCES drrk.import_batch (id)" in sql assert "CONSTRAINT uq_operation_batch_row UNIQUE" in sql assert "INSERT INTO drrk.operation_type" in sql + assert "CREATE TABLE drrk.security_nominal_history" in sql + assert "CONSTRAINT ck_security_nominal_history_source CHECK" in sql + assert "CREATE TABLE drrk.market_data_manual" in sql diff --git a/tests/unit/test_services.py b/tests/unit/test_services.py index a474024..2f98a4b 100644 --- a/tests/unit/test_services.py +++ b/tests/unit/test_services.py @@ -36,6 +36,7 @@ class FakeDB: market_data: dict[tuple[int, date], tuple[MarketQuote, str]] = field( default_factory=dict ) + nominals: dict[tuple[int, date], Decimal] = field(default_factory=dict) rates: dict[date, tuple[FxRate, str]] = field(default_factory=dict) request_log: list = field(default_factory=list) commits: int = 0 @@ -110,6 +111,20 @@ class FakeMarketDataRepository: self.db.market_data[(security_id, quote.trade_date)] = (quote, user) +class FakeNominalHistoryRepository: + def __init__(self, session: FakeSession) -> None: + self.db = session.db + + async def last_moex_on(self, security_id: int, on_date: date): + dates = [d for (s, d) in self.db.nominals if s == security_id and d <= on_date] + if not dates: + return None + return SimpleNamespace(face_value=self.db.nominals[(security_id, max(dates))]) + + async def upsert(self, *, security_id, valid_from, face_value, **_) -> None: + self.db.nominals[(security_id, valid_from)] = face_value + + class FakeExchangeRateRepository: def __init__(self, session: FakeSession) -> None: self.db = session.db @@ -140,6 +155,9 @@ def db(monkeypatch) -> FakeDB: monkeypatch.setattr(securities, "ReferenceRepository", FakeReferenceRepository) monkeypatch.setattr(market_data, "SecurityRepository", FakeSecurityRepository) monkeypatch.setattr(market_data, "MarketDataRepository", FakeMarketDataRepository) + monkeypatch.setattr( + market_data, "NominalHistoryRepository", FakeNominalHistoryRepository + ) monkeypatch.setattr(fx_rates, "ReferenceRepository", FakeReferenceRepository) monkeypatch.setattr(fx_rates, "ExchangeRateRepository", FakeExchangeRateRepository) monkeypatch.setattr(request_log, "RequestLogRepository", FakeRequestLogRepository)