tests/test_trade_time.py
Rama main · réplica de lectura del repositorio original.
"""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