Ctrl K
ComunidadGuías…

tests/test_trade_time.py

Rama main · réplica de lectura del repositorio original.

Archivo original ↗Preguntar a KiBot
"""Intra-day trade ordering.

A trade carries an optional time; left blank it is market open, so nothing
recorded before times existed changes meaning. Ordering within a day is
(date, time, id), with an UNSAVED trade sorting after every saved trade sharing
its date and time — otherwise selling on the day you bought is refused, because
the sell is checked as though it preceded its own buy.

The tests are split deliberately: the first group is the behaviour that bought,
the second the invariants that held before and must still hold. The second is
the guard rail — it would be easy to "fix" same-day selling by dropping the
balance check altogether.
"""

from __future__ import annotations

import datetime as dt

import pytest

import factories as fac
from app import fyreport, queries

MARKET_OPEN = dt.time(10, 0)


@pytest.fixture
def acme(pf):
    return fac.make_instrument(pf, "ACME", name="Acme Industries")


# --------------------------------------------------------------------------- #
# What times bought
# --------------------------------------------------------------------------- #

def test_selling_later_the_same_day_is_allowed(pf, acme):
    """Buying at open and selling that afternoon is an ordinary thing to do,
    and the ledger has no reason to refuse it."""
    buy = fac.add_trade(pf, acme, "2026-03-02", "buy", 100, "5.00")
    # The sell happens after the buy on the same day; once trades carry a time
    # this orders correctly and the balance lands on zero.
    sell = fac.build_trade(acme, "2026-03-02", "sell", 100, "6.00")

    assert queries.balance_after([buy], sell) == 0


def test_a_trade_left_without_a_time_is_treated_as_market_open(pf, acme):
    """Blank means 10:00 rather than midnight, so an unspecified sell still
    lands after an unspecified buy's parcel rather than tying with it."""
    trade = fac.add_trade(pf, acme, "2026-03-02", "buy", 100, "5.00")

    assert trade.time == MARKET_OPEN


def test_explicit_times_order_the_day_regardless_of_entry_order(pf, acme):
    """Someone entering a day's trades out of order must still get the right
    running balance — the time is the truth, not the order they typed them."""
    # Recorded second but happened first.
    sell = fac.add_trade(pf, acme, "2026-03-02", "sell", 60, "6.00",
                         time=dt.time(15, 0))
    buy = fac.add_trade(pf, acme, "2026-03-02", "buy", 100, "5.00",
                        time=dt.time(11, 0))

    assert queries.balance_after([sell, buy]) == 40


def test_fifo_consumes_the_earlier_parcel_of_the_same_day_first(pf, acme):
    """Two parcels bought the same day at different prices must still be
    consumed oldest-first, or the cost base of a later sale is wrong. The
    14:00 parcel is written first: written in time order, a sort on the date
    and the id alone gave the same answer as one on the time."""
    fac.add_trade(pf, acme, "2026-03-02", "buy", 100, "8.00", time=dt.time(14, 0))
    fac.add_trade(pf, acme, "2026-03-02", "buy", 100, "5.00", time=dt.time(10, 0))
    fac.add_trade(pf, acme, "2027-06-01", "sell", 100, "9.00")

    disposal = fyreport.instrument_disposals(acme)[0]

    # The 10:00 parcel at 5.00 is the one consumed: 100 x 5.00 = 500.00 cost base.
    assert disposal.parcels[0].cost_base == 500


# --------------------------------------------------------------------------- #
# What must stay true afterwards
# --------------------------------------------------------------------------- #

def test_selling_before_buying_on_the_same_day_is_still_refused(pf, acme):
    """The obvious wrong way to fix same-day selling is to stop checking. A
    sell that genuinely precedes its buy must still be refused, times or not."""
    buy = fac.add_trade(pf, acme, "2026-03-02", "buy", 100, "5.00")
    # No units held when this sell happens, whichever way the day is ordered.
    early_sell = fac.build_trade(acme, "2026-03-01", "sell", 100, "6.00")

    assert queries.balance_after([buy], early_sell) is None


def test_selling_more_than_was_ever_held_is_still_refused(pf, acme):
    buy = fac.add_trade(pf, acme, "2026-03-02", "buy", 100, "5.00")
    too_big = fac.build_trade(acme, "2026-03-03", "sell", 101, "6.00")

    assert queries.balance_after([buy], too_big) is None


def test_a_backdated_sell_is_still_refused(pf, acme):
    """The running balance is walked in date order precisely so a sell that
    only balances thanks to units bought later is caught."""
    early = fac.add_trade(pf, acme, "2026-01-05", "buy", 10, "5.00")
    later = fac.add_trade(pf, acme, "2026-06-05", "buy", 100, "5.00")
    backdated = fac.build_trade(acme, "2026-02-01", "sell", 50, "6.00")

    assert queries.balance_after([early, later], backdated) is None


def test_ordinary_multi_day_trading_is_unaffected(pf, acme):
    buy = fac.add_trade(pf, acme, "2026-03-02", "buy", 100, "5.00")
    sell = fac.build_trade(acme, "2026-03-03", "sell", 40, "6.00")

    assert queries.balance_after([buy], sell) == 60


def test_trades_sharing_a_day_and_a_time_keep_their_recorded_order(pf, acme):
    """With no time set, both sit at market open, so the tie has to break on
    the id — the order they were recorded in."""
    first = fac.add_trade(pf, acme, "2026-03-02", "buy", 100, "5.00")
    second = fac.add_trade(pf, acme, "2026-03-02", "buy", 50, "6.00")

    assert queries.balance_after([second, first]) == 150


# --------------------------------------------------------------------------- #
# Which clock a time is read on (markettime)
# --------------------------------------------------------------------------- #
# Nothing called `aware`, which stamps a trade's time on the holding page for
# localtime.js to move to the viewer's clock, and the offset it attaches is the
# whole point: an ASX trade in January and one in July are an hour apart.

@pytest.mark.parametrize(("exchange", "day", "offset"), [
    ("ASX", dt.date(2026, 1, 15), "+11:00"), ("ASX", dt.date(2026, 7, 15), "+10:00"),
    ("NASDAQ", dt.date(2026, 1, 15), "-05:00"), ("NASDAQ", dt.date(2026, 7, 15), "-04:00"),
    ("asx", dt.date(2026, 1, 15), "+11:00"),
])
def test_a_trade_is_stamped_with_its_own_days_offset(exchange, day, offset):
    from app import markettime

    stamp = markettime.aware(day, dt.time(14, 5), exchange)

    assert stamp.isoformat() == f"{day.isoformat()}T14:05:00{offset}"


def test_a_trade_with_no_time_is_stamped_at_the_open_and_crypto_not_at_all():
    from app import markettime

    assert markettime.aware(dt.date(2026, 1, 15), None, "ASX").time() == MARKET_OPEN
    assert markettime.aware(dt.date(2026, 1, 15), dt.time(14, 5), "CRYPTO") is None
    assert markettime.aware(dt.date(2026, 1, 15), dt.time(14, 5), "NOWHERE") is None


def test_the_holding_page_stamps_a_trades_time_with_its_offset(client, session_factory):
    from test_routes import bind_to_only_portfolio, make_login

    make_login(client, session_factory)
    with session_factory() as s:
        bind_to_only_portfolio(s)
        acme = fac.make_instrument(s, "ACME")
        fac.add_trade(s, acme, "2026-01-15", "buy", 10, "5.00", time=dt.time(14, 5))
        fac.add_trade(s, acme, "2026-07-15", "buy", 10, "5.00", time=dt.time(14, 5))
        s.commit()

    page = client.get("/holding/ACME", headers={"accept": "text/html"}).text

    assert 'datetime="2026-01-15T14:05:00+11:00"' in page
    assert 'datetime="2026-07-15T14:05:00+10:00"' in page


@pytest.mark.parametrize(("zone", "exchange"), [
    ("", "ASX"), ("Not/AZone", "ASX"), ("Australia/Perth", "CRYPTO")])
def test_a_conversion_it_cannot_make_leaves_the_date_and_time_alone(zone, exchange):
    from app import markettime

    when = (dt.date(2026, 1, 15), dt.time(23, 30))

    assert markettime.to_market(when[1], when[0], zone, exchange) == when