| """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() |
|
|
|
|
| |
| def test_loan_repaid_debits_borrower_credits_patron_and_warms(): |
| g = _game() |
| borrower = g.world.creatures["Mossback"] |
| borrower.pebbles = 1000 |
| g.queue_player_move("lend", target="Mossback", amount=100) |
| g.step() |
| loan = g.world.patron.loans[-1] |
| interest = 100 * LOAN_INTEREST_RATE // 100 |
| assert loan.outstanding == 100 + interest |
| borrower_after_loan = borrower.pebbles |
| purse_before_settle = g.world.patron.purse |
| _step_to_turn(g, loan.due_turn) |
| 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 |
|
|
|
|
| |
| def test_loan_default_drains_borrower_and_sours(): |
| g = _game() |
| g.queue_player_move("lend", target="Bramble", amount=100) |
| g.step() |
| loan = g.world.patron.loans[-1] |
| sentiment_before = ( |
| g.world.creatures["Bramble"].relationships.get("Patron").sentiment |
| ) |
| g.world.creatures["Bramble"].pebbles = 5 |
| _step_to_turn(g, loan.due_turn) |
| assert loan.defaulted |
| assert g.world.creatures["Bramble"].pebbles == 0 |
| 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 |
|
|
|
|
| |
| 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 |
| g.world.last_price["honey"] = 20.0 |
| g.step() |
| pnl = int((9.0 - 20.0) * 5) |
| 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) |
|
|
|
|
| |
| 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 |
| g.world.last_price["honey"] = 20.0 |
| 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 |
| 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" |
|
|
|
|
| |
| 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) |
| assert short_unrealized_pnl(short, g.world.last_price) == expected |
| g.world.patron.purse = 0 |
| realized_before = g.world.patron.realized_pnl |
| g.step() |
| assert not short.open |
| assert g.world.patron.realized_pnl == realized_before + expected |
| realized_after_first = g.world.patron.realized_pnl |
| g.step() |
| assert g.world.patron.realized_pnl == realized_after_first |
|
|
|
|
| |
| def test_bankruptcy_routes_through_exile_and_conserves_the_system(): |
| g = Game(make_random_policy(seed=7), deck_seed=1) |
| |
| 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() |
| loan = g.world.patron.loans[-1] |
| _step_to_turn(g, loan.due_turn - 1) |
| |
| |
| |
| |
| |
| g.world.creatures["Pip"].pebbles = 5 |
| total0 = system_total() |
| g.step() |
| 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 |
| |
| |
| 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()} |
|
|
|
|
| |
| def test_margin_post_equals_short_purse_cost(): |
| assert MARGIN_POST == -MOVE_COSTS["short"]["purse"] |
|
|
|
|
| |
| def test_patron_ruin_sets_flag_and_does_not_raise(): |
| g = _game() |
| g.world.patron.purse = 0 |
| g.world.patron.realized_pnl = -10 |
| assert g.world.patron.net_worth(g.world.last_price) <= 0 |
| g.step() |
| assert g.world.patron.ruined |
| assert any(e["type"] == "bankruptcy" and e["who"] == "Patron" for e in g.world.log) |
|
|
|
|
| |
| 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}) |
| |
| 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 |
|
|