thousand-token-wood-sim / tests /test_leverage.py
AdmiralTaco's picture
Phase 5: leverage + ruin (loans, margin calls, short P&L, bankruptcy)
ac412d8
Raw
History Blame Contribute Delete
9.09 kB
"""Phase 5 (leverage + ruin): loans, margin calls, short P&L, bankruptcy/exile.
GPU-free and deterministic. Mechanics tests drive the engine with a no-op policy
(so trading never perturbs prices) and control prices / positions / pebbles
directly, mirroring tests/test_information_war.py. Loan settlement is timed off
LOAN_TERM_TURNS; margin calls are driven by writing last_price against an open
short held by the no-op policy.
Run: python -m pytest tests/ -q
"""
import sys
from pathlib import Path
sys.path.insert(0, str(Path(__file__).resolve().parents[1]))
from ttw.dummy import make_random_policy
from ttw.game import Game
from ttw.moves import MOVE_COSTS
from ttw.world import (
EXILE_CHAPTER_TURNS,
LOAN_INTEREST_RATE,
MARGIN_MAINTENANCE,
MARGIN_POST,
ShortPosition,
short_unrealized_pnl,
)
def _noop_policy(world, name):
return [], []
def _game(policy=None, seed=1):
return Game(policy or _noop_policy, deck_seed=seed)
def _step_to_turn(g, turn: int) -> None:
"""Step the no-op world forward until world.turn reaches `turn`."""
while g.world.turn < turn:
g.step()
# --- (1) a loan that is paid in full repays and warms the borrower -------------
def test_loan_repaid_debits_borrower_credits_patron_and_warms():
g = _game()
borrower = g.world.creatures["Mossback"]
borrower.pebbles = 1000 # plenty to repay principal + interest
g.queue_player_move("lend", target="Mossback", amount=100)
g.step() # turn 1: loan issued
loan = g.world.patron.loans[-1]
interest = 100 * LOAN_INTEREST_RATE // 100
assert loan.outstanding == 100 + interest
borrower_after_loan = borrower.pebbles # includes the +100 principal credit
purse_before_settle = g.world.patron.purse
_step_to_turn(g, loan.due_turn) # step to the due turn -> settlement runs
assert loan.repaid
assert borrower.pebbles == borrower_after_loan - (100 + interest)
assert g.world.patron.purse == purse_before_settle + (100 + interest)
assert any(e["type"] == "loan_repaid" and e["borrower"] == "Mossback" for e in g.world.log)
rel = g.world.creatures["Mossback"].relationships.get("Patron")
assert rel is not None and rel.sentiment > 0 # gratitude landed
# --- (2) a borrower drained below the balance defaults, souring it -------------
def test_loan_default_drains_borrower_and_sours():
g = _game()
g.queue_player_move("lend", target="Bramble", amount=100)
g.step() # turn 1: loan issued
loan = g.world.patron.loans[-1]
sentiment_before = (
g.world.creatures["Bramble"].relationships.get("Patron").sentiment
)
g.world.creatures["Bramble"].pebbles = 5 # too poor to clear the balance
_step_to_turn(g, loan.due_turn)
assert loan.defaulted
assert g.world.creatures["Bramble"].pebbles == 0 # paid all it had
assert loan.outstanding > 0
assert any(e["type"] == "loan_defaulted" and e["borrower"] == "Bramble" for e in g.world.log)
rel = g.world.creatures["Bramble"].relationships.get("Patron")
assert rel.sentiment < sentiment_before # resentment landed
# --- (3) a breached short with an empty purse is force-covered -----------------
def test_margin_call_forced_cover_realizes_loss_and_closes():
g = _game()
short = ShortPosition(good="honey", qty=5, entry_price=9.0, margin=MARGIN_POST)
g.world.patron.shorts.append(short)
g.world.patron.purse = 0 # cannot top up
g.world.last_price["honey"] = 20.0 # price rose against the short -> loss
g.step()
pnl = int((9.0 - 20.0) * 5) # -55
assert not short.open
assert g.world.patron.realized_pnl == pnl
cover = [e for e in g.world.log if e["type"] == "margin_call"]
assert cover and cover[-1]["resolution"] == "forced_cover"
assert any(e["type"] == "short_covered" and e["good"] == "honey" for e in g.world.log)
# --- (4) the same breach with a funded purse tops up instead -------------------
def test_margin_call_top_up_keeps_short_open_and_debits_purse():
g = _game()
short = ShortPosition(good="honey", qty=5, entry_price=9.0, margin=MARGIN_POST)
g.world.patron.shorts.append(short)
g.world.patron.purse = 500 # can fund the shortfall
g.world.last_price["honey"] = 20.0 # same breach as the forced-cover case
pnl = short_unrealized_pnl(short, g.world.last_price)
equity = MARGIN_POST + pnl
need = int(MARGIN_MAINTENANCE * MARGIN_POST) - equity
purse_before = g.world.patron.purse
g.step()
assert short.open # the position survives
assert short.margin == MARGIN_POST + need
assert g.world.patron.purse == purse_before - need
call = [e for e in g.world.log if e["type"] == "margin_call"]
assert call and call[-1]["resolution"] == "topped_up"
# --- (5) unrealized P&L formula and a no-double-count realization ---------------
def test_unrealized_pnl_formula_and_clean_realization():
g = _game()
short = ShortPosition(good="honey", qty=4, entry_price=9.0, margin=MARGIN_POST)
g.world.patron.shorts.append(short)
g.world.last_price["honey"] = 15.0
expected = int((9.0 - 15.0) * 4) # -24
assert short_unrealized_pnl(short, g.world.last_price) == expected
g.world.patron.purse = 0 # force the cover so the unrealized P&L is realized
realized_before = g.world.patron.realized_pnl
g.step()
assert not short.open
assert g.world.patron.realized_pnl == realized_before + expected # moved exactly once
realized_after_first = g.world.patron.realized_pnl
g.step() # a closed short must not be re-settled
assert g.world.patron.realized_pnl == realized_after_first
# --- (6) creature bankruptcy routes through reversible exile, conserves --------
def test_bankruptcy_routes_through_exile_and_conserves_the_system():
g = Game(make_random_policy(seed=7), deck_seed=1)
# System invariant for pure loan flows: creature pebbles + purse + open margin.
def system_total():
return (
sum(c.pebbles for c in g.world.creatures.values())
+ g.world.patron.purse
+ sum(s.margin for s in g.world.patron.shorts if s.open)
)
g.queue_player_move("lend", target="Pip", amount=80)
g.step() # turn 1: loan issued (a pure transfer + Patron purse debit)
loan = g.world.patron.loans[-1]
_step_to_turn(g, loan.due_turn - 1) # up to the turn before settlement
# Leave Pip with a few pebbles, below the loan balance: the default pays what
# it has (a transfer) and writes off the rest (a ledger number, not pebbles),
# so the pebble/margin system stays conserved. Capture the baseline AFTER this
# edit so the manual top-down does not itself look like a leak, then assert the
# invariant holds from here across the default, exile, and re-entry.
g.world.creatures["Pip"].pebbles = 5
total0 = system_total()
g.step() # the due turn: default -> bankruptcy candidate -> exile
assert loan.defaulted
assert "Pip" in g.world.exiled
assert "Pip" not in {c.name for c in g.world.alive()}
assert any(e["type"] == "bankruptcy" and e["who"] == "Pip" for e in g.world.log)
assert system_total() == total0 # the default + exile turn conserved the ledger
# Clearing keeps working and pebbles/margin stay conserved across the window
# and the re-entry, because loan settlement only transfers pebbles.
for _ in range(EXILE_CHAPTER_TURNS + 2):
g.step()
for c in g.world.creatures.values():
assert c.pebbles >= 0
assert all(q >= 0 for q in c.inventory.values())
assert system_total() == total0
assert "Pip" in {c.name for c in g.world.alive()} # re-entered intact
# --- (7) constant honesty: a short's margin equals its purse cost --------------
def test_margin_post_equals_short_purse_cost():
assert MARGIN_POST == -MOVE_COSTS["short"]["purse"]
# --- (8) the Patron's own ruin is reachable and does not halt the step ---------
def test_patron_ruin_sets_flag_and_does_not_raise():
g = _game()
g.world.patron.purse = 0
g.world.patron.realized_pnl = -10 # net worth <= 0 with no shorts or loans
assert g.world.patron.net_worth(g.world.last_price) <= 0
g.step() # must not raise
assert g.world.patron.ruined
assert any(e["type"] == "bankruptcy" and e["who"] == "Patron" for e in g.world.log)
# --- (9) lend still pays the target and injects context (Phase 1 intact) -------
def test_lend_still_pays_target_and_injects_context():
g = _game()
target = g.world.creatures["Mossback"]
before = target.pebbles
g.world.pending_moves.append({"kind": "lend", "target": "Mossback", "amount": 50})
# Inspect the credit + context inside the turn, before patron_context is cleared.
from ttw import moves
ev = moves.apply_move(g.world, {"kind": "lend", "target": "Mossback", "amount": 50})
assert ev["applied"]
assert target.pebbles == before + 50
assert any("lent you 50 pebbles" in line for line in target.patron_context)
assert g.world.patron.loans # the additive Loan ledger entry exists