Add nominal history and manual quote overrides

This commit is contained in:
Raykov-MS 2026-10-21 11:26:42 +03:00
parent 17c6c8bafd
commit cefde859de
17 changed files with 1293 additions and 35 deletions

View File

@ -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`).
## Переменные окружения

View File

@ -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)

View File

@ -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",
]

View File

@ -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)

54
app/db/models/nominal.py Normal file
View File

@ -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))

View File

@ -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)

View File

@ -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()

View File

@ -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

View File

@ -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()

View File

@ -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")

View File

@ -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}, "

View File

@ -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"):

View File

@ -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 | Отмены загрузки файла, удаления ручных операций и сброса ручных значений (цена, номинал) пока нет |
## Открытые вопросы

View File

@ -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 загрузчика.

View File

@ -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)) == []

View File

@ -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

View File

@ -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)