diff --git a/backend/tests/test_crud.py b/backend/tests/test_crud.py index d7ae484..9f343f6 100644 --- a/backend/tests/test_crud.py +++ b/backend/tests/test_crud.py @@ -137,6 +137,16 @@ def _ensure_utc_aware(dt: datetime | None) -> datetime | None: return dt.astimezone(timezone.utc) +def _validate_timestamp(actual: datetime, expected: datetime, tolerance: timedelta) -> None: + actual_utc = _ensure_utc_aware(actual) + expected_utc = _ensure_utc_aware(expected) + assert actual_utc is not None + assert expected_utc is not None + delta = abs(actual_utc - expected_utc) + assert delta <= tolerance, f"Timestamps differ by {delta}, which exceeds tolerance of {tolerance}" + + +# Trades def test_create_trade_success_with_cycle(session: Session) -> None: user_id = make_user(session) exchange_id = make_exchange(session, user_id) @@ -554,6 +564,7 @@ def test_replace_trade(session: Session) -> None: assert actual_new_trade.replaced_by_trade_id == old_trade_id +# Cycles def test_create_cycle(session: Session) -> None: user_id = make_user(session) exchange_id = make_exchange(session, user_id) @@ -656,6 +667,216 @@ def test_update_cycle_immutable_fields(session: Session) -> None: ) +# Cycle loans +def test_create_cycle_loan_event(session: Session) -> None: + user_id = make_user(session) + exchange_id = make_exchange(session, user_id) + cycle_id = make_cycle(session, user_id, exchange_id) + + loan_data = { + "cycle_id": cycle_id, + "loan_amount_cents": 100000, + "loan_interest_rate_tenth_bps": 5000, # 5% + "notes": "Test loan change for the cycle", + } + + loan_event = crud.create_cycle_loan_event(session, loan_data) + now = datetime.now(timezone.utc) + assert loan_event.id is not None + assert loan_event.cycle_id == cycle_id + assert loan_event.loan_amount_cents == loan_data["loan_amount_cents"] + assert loan_event.loan_interest_rate_tenth_bps == loan_data["loan_interest_rate_tenth_bps"] + assert loan_event.notes == loan_data["notes"] + assert loan_event.effective_date == now.date() + _validate_timestamp(loan_event.created_at, now, timedelta(seconds=1)) + + session.refresh(loan_event) + actual_loan_event = session.get(models.CycleLoanChangeEvents, loan_event.id) + assert actual_loan_event is not None + assert actual_loan_event.cycle_id == cycle_id + assert actual_loan_event.loan_amount_cents == loan_data["loan_amount_cents"] + assert actual_loan_event.loan_interest_rate_tenth_bps == loan_data["loan_interest_rate_tenth_bps"] + assert actual_loan_event.notes == loan_data["notes"] + assert actual_loan_event.effective_date == now.date() + _validate_timestamp(actual_loan_event.created_at, now, timedelta(seconds=1)) + + +def test_get_cycle_loan_events_by_cycle_id(session: Session) -> None: + user_id = make_user(session) + exchange_id = make_exchange(session, user_id) + cycle_id = make_cycle(session, user_id, exchange_id) + + loan_data_1 = { + "cycle_id": cycle_id, + "loan_amount_cents": 100000, + "loan_interest_rate_tenth_bps": 5000, + "notes": "First loan event", + } + yesterday = (datetime.now(timezone.utc) - timedelta(days=1)).date() + loan_data_2 = { + "cycle_id": cycle_id, + "loan_amount_cents": 150000, + "loan_interest_rate_tenth_bps": 4500, + "effective_date": yesterday, + "notes": "Second loan event", + } + + crud.create_cycle_loan_event(session, loan_data_1) + crud.create_cycle_loan_event(session, loan_data_2) + + loan_events = crud.get_loan_events_by_cycle_id(session, cycle_id) + assert len(loan_events) == 2 + notes = [event.notes for event in loan_events] + assert loan_events[0].notes == loan_data_2["notes"] + assert loan_events[0].effective_date == yesterday + assert notes == ["Second loan event", "First loan event"] # Ordered by effective_date desc + + +def test_get_cycle_loan_events_by_cycle_id_same_date(session: Session) -> None: + user_id = make_user(session) + exchange_id = make_exchange(session, user_id) + cycle_id = make_cycle(session, user_id, exchange_id) + + loan_data_1 = { + "cycle_id": cycle_id, + "loan_amount_cents": 100000, + "loan_interest_rate_tenth_bps": 5000, + "notes": "First loan event", + } + loan_data_2 = { + "cycle_id": cycle_id, + "loan_amount_cents": 150000, + "loan_interest_rate_tenth_bps": 4500, + "notes": "Second loan event", + } + + crud.create_cycle_loan_event(session, loan_data_1) + crud.create_cycle_loan_event(session, loan_data_2) + + loan_events = crud.get_loan_events_by_cycle_id(session, cycle_id) + assert len(loan_events) == 2 + notes = [event.notes for event in loan_events] + assert notes == ["First loan event", "Second loan event"] # Ordered by id desc when effective_date is same + + +def test_create_cycle_loan_event_single_field(session: Session) -> None: + user_id = make_user(session) + exchange_id = make_exchange(session, user_id) + cycle_id = make_cycle(session, user_id, exchange_id) + + loan_data = { + "cycle_id": cycle_id, + "loan_amount_cents": 200000, + } + + loan_event = crud.create_cycle_loan_event(session, loan_data) + now = datetime.now(timezone.utc) + assert loan_event.id is not None + assert loan_event.cycle_id == cycle_id + assert loan_event.loan_amount_cents == loan_data["loan_amount_cents"] + assert loan_event.loan_interest_rate_tenth_bps is None + assert loan_event.notes is None + assert loan_event.effective_date == now.date() + _validate_timestamp(loan_event.created_at, now, timedelta(seconds=1)) + + session.refresh(loan_event) + actual_loan_event = session.get(models.CycleLoanChangeEvents, loan_event.id) + assert actual_loan_event is not None + assert actual_loan_event.cycle_id == cycle_id + assert actual_loan_event.loan_amount_cents == loan_data["loan_amount_cents"] + assert actual_loan_event.loan_interest_rate_tenth_bps is None + assert actual_loan_event.notes is None + assert actual_loan_event.effective_date == now.date() + _validate_timestamp(actual_loan_event.created_at, now, timedelta(seconds=1)) + + +def test_create_cycle_daily_accrual(session: Session) -> None: + user_id = make_user(session) + exchange_id = make_exchange(session, user_id) + cycle_id = make_cycle(session, user_id, exchange_id) + today = datetime.now(timezone.utc).date() + accrual_data = { + "cycle_id": cycle_id, + "accrual_date": today, + "accrued_interest_cents": 150, + "notes": "Daily interest accrual", + } + + accrual = crud.create_cycle_daily_accrual(session, cycle_id, accrual_data["accrual_date"], accrual_data["accrued_interest_cents"]) + assert accrual.id is not None + assert accrual.cycle_id == cycle_id + assert accrual.accrual_date == accrual_data["accrual_date"] + assert accrual.accrual_amount_cents == accrual_data["accrued_interest_cents"] + + session.refresh(accrual) + actual_accrual = session.get(models.CycleDailyAccrual, accrual.id) + assert actual_accrual is not None + assert actual_accrual.cycle_id == cycle_id + assert actual_accrual.accrual_date == accrual_data["accrual_date"] + assert actual_accrual.accrual_amount_cents == accrual_data["accrued_interest_cents"] + + +def test_get_cycle_daily_accruals_by_cycle_id(session: Session) -> None: + user_id = make_user(session) + exchange_id = make_exchange(session, user_id) + cycle_id = make_cycle(session, user_id, exchange_id) + + today = datetime.now(timezone.utc).date() + yesterday = today - timedelta(days=1) + + accrual_data_1 = { + "cycle_id": cycle_id, + "accrual_date": yesterday, + "accrued_interest_cents": 100, + } + accrual_data_2 = { + "cycle_id": cycle_id, + "accrual_date": today, + "accrued_interest_cents": 150, + } + + crud.create_cycle_daily_accrual(session, cycle_id, accrual_data_1["accrual_date"], accrual_data_1["accrued_interest_cents"]) + crud.create_cycle_daily_accrual(session, cycle_id, accrual_data_2["accrual_date"], accrual_data_2["accrued_interest_cents"]) + + accruals = crud.get_cycle_daily_accruals_by_cycle_id(session, cycle_id) + assert len(accruals) == 2 + dates = [accrual.accrual_date for accrual in accruals] + assert dates == [yesterday, today] # Ordered by accrual_date asc + + +def test_get_cycle_daily_accruals_by_cycle_id_and_date(session: Session) -> None: + user_id = make_user(session) + exchange_id = make_exchange(session, user_id) + cycle_id = make_cycle(session, user_id, exchange_id) + + today = datetime.now(timezone.utc).date() + yesterday = today - timedelta(days=1) + + accrual_data_1 = { + "cycle_id": cycle_id, + "accrual_date": yesterday, + "accrued_interest_cents": 100, + } + accrual_data_2 = { + "cycle_id": cycle_id, + "accrual_date": today, + "accrued_interest_cents": 150, + } + + crud.create_cycle_daily_accrual(session, cycle_id, accrual_data_1["accrual_date"], accrual_data_1["accrued_interest_cents"]) + crud.create_cycle_daily_accrual(session, cycle_id, accrual_data_2["accrual_date"], accrual_data_2["accrued_interest_cents"]) + + accruals_today = crud.get_cycle_daily_accrual_by_cycle_id_and_date(session, cycle_id, today) + assert accruals_today is not None + assert accruals_today.accrual_date == today + assert accruals_today.accrual_amount_cents == accrual_data_2["accrued_interest_cents"] + + accruals_yesterday = crud.get_cycle_daily_accrual_by_cycle_id_and_date(session, cycle_id, yesterday) + assert accruals_yesterday is not None + assert accruals_yesterday.accrual_date == yesterday + assert accruals_yesterday.accrual_amount_cents == accrual_data_1["accrued_interest_cents"] + + # Exchanges def test_create_exchange(session: Session) -> None: user_id = make_user(session) diff --git a/backend/tests/test_db_migration.py b/backend/tests/test_db_migration.py index f14b2da..69fd63a 100644 --- a/backend/tests/test_db_migration.py +++ b/backend/tests/test_db_migration.py @@ -45,6 +45,8 @@ def test_run_migrations_0_to_1(monkeypatch: pytest.MonkeyPatch) -> None: "loan_interest_rate_tenth_bps": ("INTEGER", 0, 0), "start_date": ("DATE", 1, 0), "end_date": ("DATE", 0, 0), + "latest_interest_accrued_date": ("DATE", 0, 0), + "total_accrued_amount_cents": ("INTEGER", 1, 0), }, "cycle_loan_change_events": { "id": ("INTEGER", 1, 1), @@ -170,6 +172,39 @@ def test_run_migrations_0_to_1(monkeypatch: pytest.MonkeyPatch) -> None: actual_fk_list = [{"table": r[2], "from": r[3], "to": r[4]} for r in fk_rows] for efk in fks: assert efk in actual_fk_list, f"missing FK on {tbl_name}: {efk}" + + # check trades.replaced_by_trade_id self-referential FK + fk_rows = conn.execute(text("PRAGMA foreign_key_list('trades')")).fetchall() + actual_fk_list = [{"table": r[2], "from": r[3], "to": r[4]} for r in fk_rows] + assert {"table": "trades", "from": "replaced_by_trade_id", "to": "id"} in actual_fk_list, ( + "missing self FK trades.replaced_by_trade_id -> trades.id" + ) + + # helper to find unique index on a column + def has_unique_index(table: str, column: str) -> bool: + idx_rows = conn.execute(text(f"PRAGMA index_list('{table}')")).fetchall() + for idx in idx_rows: + idx_name = idx[1] + is_unique = bool(idx[2]) + if not is_unique: + continue + info = conn.execute(text(f"PRAGMA index_info('{idx_name}')")).fetchall() + cols = [r[2] for r in info] + if column in cols: + return True + return False + + assert has_unique_index("trades", "friendly_name"), ( + "expected unique index on trades(friendly_name) per uq_trades_user_friendly_name" + ) + assert has_unique_index("cycles", "friendly_name"), ( + "expected unique index on cycles(friendly_name) per uq_cycles_user_friendly_name" + ) + assert has_unique_index("exchanges", "name"), "expected unique index on exchanges(name) per uq_exchanges_user_name" + assert has_unique_index("sessions", "session_token_hash"), "expected unique index on sessions(session_token_hash)" + assert has_unique_index("cycle_loan_change_events", "related_trade_id"), ( + "expected unique index on cycle_loan_change_events(related_trade_id)" + ) finally: engine.dispose() SQLModel.metadata.clear() diff --git a/backend/trading_journal/crud.py b/backend/trading_journal/crud.py index da8e237..66e2c4f 100644 --- a/backend/trading_journal/crud.py +++ b/backend/trading_journal/crud.py @@ -1,6 +1,6 @@ from __future__ import annotations -from datetime import datetime, timedelta, timezone +from datetime import date, datetime, timedelta, timezone from typing import TYPE_CHECKING, Any, TypeVar, cast from pydantic import BaseModel @@ -13,6 +13,8 @@ if TYPE_CHECKING: from collections.abc import Mapping from enum import Enum + from sqlalchemy.sql.elements import ColumnElement + # Generic enum member type T = TypeVar("T", bound="Enum") @@ -301,6 +303,93 @@ def update_cycle(session: Session, cycle_id: int, update_data: Mapping[str, Any] return cycle +# Cycle loan and interest +def create_cycle_loan_event(session: Session, loan_data: Mapping[str, Any] | BaseModel) -> models.CycleLoanChangeEvents: + data = _data_to_dict(loan_data) + allowed = _allowed_columns(models.CycleLoanChangeEvents) + payload = {k: v for k, v in data.items() if k in allowed} + if "cycle_id" not in payload: + raise ValueError("cycle_id is required") + cycle = session.get(models.Cycles, payload["cycle_id"]) + if cycle is None: + raise ValueError("cycle_id does not exist") + + payload["effective_date"] = payload.get("effective_date") or datetime.now(timezone.utc).date() + payload["created_at"] = datetime.now(timezone.utc) + cle = models.CycleLoanChangeEvents(**payload) + session.add(cle) + try: + session.flush() + except IntegrityError as e: + session.rollback() + raise ValueError("create_cycle_loan_event integrity error") from e + session.refresh(cle) + return cle + + +def get_loan_events_by_cycle_id(session: Session, cycle_id: int) -> list[models.CycleLoanChangeEvents]: + eff_col = cast("ColumnElement", models.CycleLoanChangeEvents.effective_date) + id_col = cast("ColumnElement", models.CycleLoanChangeEvents.id) + statement = ( + select(models.CycleLoanChangeEvents) + .where( + models.CycleLoanChangeEvents.cycle_id == cycle_id, + ) + .order_by(eff_col, id_col.asc()) + ) + return list(session.exec(statement).all()) + + +def create_cycle_daily_accrual(session: Session, cycle_id: int, accrual_date: date, accrual_amount_cents: int) -> models.CycleDailyAccrual: + cycle = session.get(models.Cycles, cycle_id) + if cycle is None: + raise ValueError("cycle_id does not exist") + existing = session.exec( + select(models.CycleDailyAccrual).where( + models.CycleDailyAccrual.cycle_id == cycle_id, + models.CycleDailyAccrual.accrual_date == accrual_date, + ), + ).first() + if existing: + return existing + if accrual_amount_cents < 0: + raise ValueError("accrual_amount_cents must be non-negative") + row = models.CycleDailyAccrual( + cycle_id=cycle_id, + accrual_date=accrual_date, + accrual_amount_cents=accrual_amount_cents, + created_at=datetime.now(timezone.utc), + ) + session.add(row) + try: + session.flush() + except IntegrityError as e: + session.rollback() + raise ValueError("create_cycle_daily_accrual integrity error") from e + session.refresh(row) + return row + + +def get_cycle_daily_accruals_by_cycle_id(session: Session, cycle_id: int) -> list[models.CycleDailyAccrual]: + date_col = cast("ColumnElement", models.CycleDailyAccrual.accrual_date) + statement = ( + select(models.CycleDailyAccrual) + .where( + models.CycleDailyAccrual.cycle_id == cycle_id, + ) + .order_by(date_col.asc()) + ) + return list(session.exec(statement).all()) + + +def get_cycle_daily_accrual_by_cycle_id_and_date(session: Session, cycle_id: int, accrual_date: date) -> models.CycleDailyAccrual | None: + statement = select(models.CycleDailyAccrual).where( + models.CycleDailyAccrual.cycle_id == cycle_id, + models.CycleDailyAccrual.accrual_date == accrual_date, + ) + return session.exec(statement).first() + + # Exchanges IMMUTABLE_EXCHANGE_FIELDS = {"id"} diff --git a/backend/trading_journal/models.py b/backend/trading_journal/models.py index 81d98e1..f060c26 100644 --- a/backend/trading_journal/models.py +++ b/backend/trading_journal/models.py @@ -18,8 +18,10 @@ from sqlmodel import ( class TradeType(str, Enum): SELL_PUT = "SELL_PUT" + CLOSE_SELL_PUT = "CLOSE_SELL_PUT" ASSIGNMENT = "ASSIGNMENT" SELL_CALL = "SELL_CALL" + CLOSE_SELL_CALL = "CLOSE_SELL_CALL" EXERCISE_CALL = "EXERCISE_CALL" LONG_SPOT = "LONG_SPOT" CLOSE_LONG_SPOT = "CLOSE_LONG_SPOT" @@ -117,13 +119,17 @@ class Cycles(SQLModel, table=True): status: CycleStatus = Field(sa_column=Column(Text, nullable=False)) funding_source: FundingSource = Field(sa_column=Column(Text, nullable=True)) capital_exposure_cents: int | None = Field(default=None, nullable=True) - loan_amount_cents: int | None = Field(default=None, nullable=True) - loan_interest_rate_tenth_bps: int | None = Field(default=None, nullable=True) start_date: date = Field(sa_column=Column(Date, nullable=False)) end_date: date | None = Field(default=None, sa_column=Column(Date, nullable=True)) trades: list["Trades"] = Relationship(back_populates="cycle") + loan_amount_cents: int | None = Field(default=None, nullable=True) + loan_interest_rate_tenth_bps: int | None = Field(default=None, nullable=True) + + latest_interest_accrued_date: date | None = Field(default=None, sa_column=Column(Date, nullable=True)) + total_accrued_amount_cents: int = Field(default=0, sa_column=Column(Integer, nullable=False)) + loan_change_events: list["CycleLoanChangeEvents"] = Relationship(back_populates="cycle") daily_accruals: list["CycleDailyAccrual"] = Relationship(back_populates="cycle") @@ -131,7 +137,7 @@ class Cycles(SQLModel, table=True): class CycleLoanChangeEvents(SQLModel, table=True): __tablename__ = "cycle_loan_change_events" # type: ignore[attr-defined] id: int | None = Field(default=None, primary_key=True) - cycle_id: int = Field(foreign_key="cycles.id", nullable=False, index=True) + cycle_id: int = Field(sa_column=Column(Integer, ForeignKey("cycles.id", ondelete="CASCADE"), nullable=False, index=True)) effective_date: date = Field(sa_column=Column(Date, nullable=False)) loan_amount_cents: int | None = Field(default=None, sa_column=Column(Integer, nullable=True)) loan_interest_rate_tenth_bps: int | None = Field(default=None, sa_column=Column(Integer, nullable=True)) @@ -148,7 +154,7 @@ class CycleDailyAccrual(SQLModel, table=True): __table_args__ = (UniqueConstraint("cycle_id", "accrual_date", name="uq_cycle_daily_accruals_cycle_date"),) id: int | None = Field(default=None, primary_key=True) - cycle_id: int = Field(foreign_key="cycles.id", nullable=False, index=True) + cycle_id: int = Field(sa_column=Column(Integer, ForeignKey("cycles.id", ondelete="CASCADE"), nullable=False, index=True)) accrual_date: date = Field(sa_column=Column(Date, nullable=False)) accrual_amount_cents: int = Field(sa_column=Column(Integer, nullable=False)) created_at: datetime = Field(sa_column=Column(DateTime(timezone=True), nullable=False)) diff --git a/backend/trading_journal/models_v1.py b/backend/trading_journal/models_v1.py index 81d98e1..f060c26 100644 --- a/backend/trading_journal/models_v1.py +++ b/backend/trading_journal/models_v1.py @@ -18,8 +18,10 @@ from sqlmodel import ( class TradeType(str, Enum): SELL_PUT = "SELL_PUT" + CLOSE_SELL_PUT = "CLOSE_SELL_PUT" ASSIGNMENT = "ASSIGNMENT" SELL_CALL = "SELL_CALL" + CLOSE_SELL_CALL = "CLOSE_SELL_CALL" EXERCISE_CALL = "EXERCISE_CALL" LONG_SPOT = "LONG_SPOT" CLOSE_LONG_SPOT = "CLOSE_LONG_SPOT" @@ -117,13 +119,17 @@ class Cycles(SQLModel, table=True): status: CycleStatus = Field(sa_column=Column(Text, nullable=False)) funding_source: FundingSource = Field(sa_column=Column(Text, nullable=True)) capital_exposure_cents: int | None = Field(default=None, nullable=True) - loan_amount_cents: int | None = Field(default=None, nullable=True) - loan_interest_rate_tenth_bps: int | None = Field(default=None, nullable=True) start_date: date = Field(sa_column=Column(Date, nullable=False)) end_date: date | None = Field(default=None, sa_column=Column(Date, nullable=True)) trades: list["Trades"] = Relationship(back_populates="cycle") + loan_amount_cents: int | None = Field(default=None, nullable=True) + loan_interest_rate_tenth_bps: int | None = Field(default=None, nullable=True) + + latest_interest_accrued_date: date | None = Field(default=None, sa_column=Column(Date, nullable=True)) + total_accrued_amount_cents: int = Field(default=0, sa_column=Column(Integer, nullable=False)) + loan_change_events: list["CycleLoanChangeEvents"] = Relationship(back_populates="cycle") daily_accruals: list["CycleDailyAccrual"] = Relationship(back_populates="cycle") @@ -131,7 +137,7 @@ class Cycles(SQLModel, table=True): class CycleLoanChangeEvents(SQLModel, table=True): __tablename__ = "cycle_loan_change_events" # type: ignore[attr-defined] id: int | None = Field(default=None, primary_key=True) - cycle_id: int = Field(foreign_key="cycles.id", nullable=False, index=True) + cycle_id: int = Field(sa_column=Column(Integer, ForeignKey("cycles.id", ondelete="CASCADE"), nullable=False, index=True)) effective_date: date = Field(sa_column=Column(Date, nullable=False)) loan_amount_cents: int | None = Field(default=None, sa_column=Column(Integer, nullable=True)) loan_interest_rate_tenth_bps: int | None = Field(default=None, sa_column=Column(Integer, nullable=True)) @@ -148,7 +154,7 @@ class CycleDailyAccrual(SQLModel, table=True): __table_args__ = (UniqueConstraint("cycle_id", "accrual_date", name="uq_cycle_daily_accruals_cycle_date"),) id: int | None = Field(default=None, primary_key=True) - cycle_id: int = Field(foreign_key="cycles.id", nullable=False, index=True) + cycle_id: int = Field(sa_column=Column(Integer, ForeignKey("cycles.id", ondelete="CASCADE"), nullable=False, index=True)) accrual_date: date = Field(sa_column=Column(Date, nullable=False)) accrual_amount_cents: int = Field(sa_column=Column(Integer, nullable=False)) created_at: datetime = Field(sa_column=Column(DateTime(timezone=True), nullable=False))