"""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