"""Builders for test data. Everything here is synthetic. Tickers are deliberately fictional (ALPHA, BETAX, GAMMA, OMEGA, ZULU) so no fixture can be mistaken for a real portfolio, and so a stray copy-paste from production data would stand out immediately. Conventions: * dates accept a `datetime.date` or an ISO string — `d("2024-06-10")` * money accepts str/int/Decimal; floats are converted via `str()` so no binary-float artefacts reach a Numeric column * `add_trade`/`add_dividend` stamp the session's portfolio via `tenancy.owned` unless `portfolio_id=` is passed explicitly (which is how tenancy tests plant rows in a *different* portfolio) """ from __future__ import annotations import datetime as dt from decimal import Decimal from app import tenancy from app.models import ( CorporateAction, Dividend, FxRate, HoldingPref, Instrument, MarketDividend, Portfolio, PortfolioMember, Price, Trade, User, ) def d(value: dt.date | str) -> dt.date: return value if isinstance(value, dt.date) else dt.date.fromisoformat(value) def dec(value) -> Decimal: """Decimal from anything, via str() so 0.1 doesn't become 0.1000000000000000055.""" return value if isinstance(value, Decimal) else Decimal(str(value)) # --------------------------------------------------------------------------- # # Accounts # --------------------------------------------------------------------------- # def make_user(db, email: str, *, name: str | None = None, password_hash: str = "x", is_admin: bool = False, is_active: bool = True, **kwargs) -> User: user = User( email=email.lower(), name=name or email.split("@")[0], password_hash=password_hash, is_admin=is_admin, is_active=is_active, **kwargs, ) db.add(user) db.flush() return user def make_portfolio(db, name: str, *, owner: User | None = None, role: str = "owner") -> Portfolio: portfolio = Portfolio(name=name) db.add(portfolio) db.flush() if owner is not None: db.add(PortfolioMember(portfolio_id=portfolio.id, user_id=owner.id, role=role)) db.flush() return portfolio def add_member(db, portfolio: Portfolio, user: User, role: str = "member") -> PortfolioMember: member = PortfolioMember(portfolio_id=portfolio.id, user_id=user.id, role=role) db.add(member) db.flush() return member # --------------------------------------------------------------------------- # # Market data (shared — never portfolio-scoped) # --------------------------------------------------------------------------- # def make_instrument(db, ticker: str, *, exchange: str = "ASX", asset_class: str = "etf", currency: str = "AUD", name: str | None = None, drp: bool = False, active: bool = True, yahoo_symbol: str | None = "auto") -> Instrument: if yahoo_symbol == "auto": yahoo_symbol = f"{ticker}.AX" if exchange == "ASX" else ticker inst = Instrument( ticker=ticker, exchange=exchange, asset_class=asset_class, currency=currency, name=name, drp=drp, active=active, yahoo_symbol=yahoo_symbol, ) db.add(inst) db.flush() return inst def add_price(db, inst: Instrument, date, close, source: str = "test") -> Price: row = Price(instrument_id=inst.id, date=d(date), close=dec(close), source=source) db.add(row) db.flush() return row def add_prices(db, inst: Instrument, rows) -> None: """`rows` is an iterable of (date, close) pairs.""" for date, close in rows: db.add(Price(instrument_id=inst.id, date=d(date), close=dec(close), source="test")) db.flush() def add_fx(db, pair: str, date, rate) -> FxRate: row = FxRate(pair=pair, date=d(date), rate=dec(rate), source="test") db.add(row) db.flush() return row def add_fx_series(db, pair: str, rows) -> None: for date, rate in rows: db.add(FxRate(pair=pair, date=d(date), rate=dec(rate), source="test")) db.flush() def add_market_dividend(db, inst: Instrument, ex_date, amount) -> MarketDividend: row = MarketDividend(instrument_id=inst.id, ex_date=d(ex_date), amount=dec(amount), source="test") db.add(row) db.flush() return row # --------------------------------------------------------------------------- # # Personal data (portfolio-scoped) # --------------------------------------------------------------------------- # def hold(db, inst: Instrument, portfolio_id: int | None = None) -> HoldingPref: """The instrument on a portfolio's list, as "+ Add an instrument" leaves it: a holding setting. A portfolio lists only what it has — a setting, a trade, a dividend or a place in its plan — so an instrument made with nothing else is in the shared catalogue and on nobody's list.""" if portfolio_id is None: portfolio_id = tenancy.current_portfolio_id(db) or db.query(Portfolio.id) \ .order_by(Portfolio.id).first()[0] pref = HoldingPref(instrument_id=inst.id, portfolio_id=portfolio_id) db.add(pref) db.flush() return pref def add_trade(db, inst: Instrument, date, type: str, quantity, unit_price, *, brokerage=0, fx_rate="auto", note: str | None = None, time: dt.time | None = None, portfolio_id: int | None = None, user_id: int | None = None) -> Trade: """A buy/sell/drp. `fx_rate="auto"` means 1 for AUD, else None (unknown). `time` left out means the column default — market open — exactly as it does for a trade recorded through the form without ticking "set a time". """ if fx_rate == "auto": fx_rate = Decimal(1) if inst.currency == "AUD" else None trade = Trade( instrument_id=inst.id, date=d(date), type=type, quantity=dec(quantity), unit_price=dec(unit_price), brokerage=dec(brokerage), fx_rate=None if fx_rate is None else dec(fx_rate), note=note, **({"time": time} if time is not None else {}), ) if portfolio_id is not None: trade.portfolio_id = portfolio_id trade.user_id = user_id else: tenancy.owned(db, trade) db.add(trade) db.flush() return trade def build_trade(inst: Instrument, date, type: str, quantity, unit_price, *, brokerage=0, time: dt.time | None = None) -> Trade: """An *unsaved* trade — for `balance_after` candidate checks (the backdated sell that must be refused before it ever reaches the database). Unsaved means no id and no column defaults applied, so `time` really is None here unless one is passed — which is the case `trade_order` has to get right, since this is the trade someone is entering right now. """ return Trade( instrument_id=inst.id, date=d(date), type=type, quantity=dec(quantity), unit_price=dec(unit_price), brokerage=dec(brokerage), fx_rate=Decimal(1), time=time, ) def add_dividend(db, inst: Instrument, date, cash_amount, *, fx_rate="auto", franking_credits=None, reinvest_trade: Trade | None = None, note: str | None = None, portfolio_id: int | None = None, user_id: int | None = None) -> Dividend: if fx_rate == "auto": fx_rate = Decimal(1) if inst.currency == "AUD" else None dividend = Dividend( instrument_id=inst.id, date=d(date), cash_amount=dec(cash_amount), fx_rate=None if fx_rate is None else dec(fx_rate), franking_credits=None if franking_credits is None else dec(franking_credits), reinvest_trade=reinvest_trade, note=note, ) if portfolio_id is not None: dividend.portfolio_id = portfolio_id dividend.user_id = user_id else: tenancy.owned(db, dividend) db.add(dividend) db.flush() return dividend def add_action(db, inst: Instrument, date, kind: str, *, ratio=None, amount=None, fx_rate="auto", note: str | None = None, portfolio_id: int | None = None, user_id: int | None = None) -> CorporateAction: """A corporate action. `ratio` is (new, old) for a split: (2, 1) doubles a holding, (1, 10) consolidates it; `amount` is a return of capital's cash or a cost base adjustment, for the whole holding.""" if fx_rate == "auto": fx_rate = Decimal(1) if inst.currency == "AUD" else None action = CorporateAction( instrument_id=inst.id, date=d(date), kind=kind, ratio_new=ratio[0] if ratio else None, ratio_old=ratio[1] if ratio else None, amount=None if amount is None else dec(amount), fx_rate=None if fx_rate is None else dec(fx_rate), note=note, ) if portfolio_id is not None: action.portfolio_id = portfolio_id action.user_id = user_id else: tenancy.owned(db, action) db.add(action) db.flush() return action def add_drp(db, inst: Instrument, date, cash_amount, units, unit_price, **kwargs): """A reinvested distribution: the DRP trade holding the units, plus the dividend row that points at it. This pair is the shape the DRP double-count regression is about — the cash must NOT also count as income, because it is already in the unit balance. Returns (trade, dividend). """ trade = add_trade(db, inst, date, "drp", units, unit_price, **kwargs) dividend = add_dividend(db, inst, date, cash_amount, reinvest_trade=trade, **kwargs) return trade, dividend # --------------------------------------------------------------------------- # # Price calendars # --------------------------------------------------------------------------- # def monthly(start, end) -> list[dt.date]: """First of each month from `start` to `end` inclusive.""" start, end = d(start), d(end) out, cur = [], dt.date(start.year, start.month, 1) while cur <= end: if cur >= start: out.append(cur) cur = dt.date(cur.year + (cur.month == 12), (cur.month % 12) + 1, 1) return out def daily(start, end) -> list[dt.date]: start, end = d(start), d(end) out, cur = [], start while cur <= end: out.append(cur) cur += dt.timedelta(days=1) return out def stepped(calendar: list[dt.date], steps: list[tuple]) -> list[tuple[dt.date, Decimal]]: """Turn a step function into (date, price) rows. `steps` is [(from_date, price), ...] in ascending order: the price applies from that date until the next step. Dates before the first step are skipped. Step prices (rather than a drifting series) keep every intermediate value hand-checkable. """ steps = [(d(when), dec(price)) for when, price in steps] out = [] for day in calendar: current = None for when, price in steps: if day >= when: current = price if current is not None: out.append((day, current)) return out