Add nominal history and manual quote overrides
This commit is contained in:
parent
17c6c8bafd
commit
cefde859de
19
README.md
19
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`).
|
||||
|
||||
## Переменные окружения
|
||||
|
||||
|
||||
111
alembic/versions/a4e81c6f2b97_manual_values.py
Normal file
111
alembic/versions/a4e81c6f2b97_manual_values.py
Normal 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)
|
||||
@ -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",
|
||||
]
|
||||
|
||||
@ -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
54
app/db/models/nominal.py
Normal 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))
|
||||
@ -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)
|
||||
|
||||
88
app/repositories/nominals.py
Normal file
88
app/repositories/nominals.py
Normal 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()
|
||||
@ -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
|
||||
|
||||
@ -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()
|
||||
|
||||
@ -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")
|
||||
|
||||
@ -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}, "
|
||||
|
||||
@ -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"):
|
||||
|
||||
@ -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 | Отмены загрузки файла, удаления ручных операций и сброса ручных значений (цена, номинал) пока нет |
|
||||
|
||||
## Открытые вопросы
|
||||
|
||||
|
||||
@ -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 загрузчика.
|
||||
|
||||
260
tests/unit/test_manual_values.py
Normal file
260
tests/unit/test_manual_values.py
Normal 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)) == []
|
||||
@ -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
|
||||
|
||||
@ -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)
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user