""" Unit tests for PaperMarginBroker increase_position functionality. Phase 1b of Margin Position Editor Plan. """ import pytest from datetime import datetime, timezone, timedelta from src.brokers.paper_margin_broker import PaperMarginBroker, MarginPosition @pytest.fixture def broker(): """Create a paper margin broker with a mock price provider.""" prices = {'BTC/USDT': 60000.0, 'ETH/USDT': 3000.0} def price_provider(symbol): return prices.get(symbol, 0.0) broker = PaperMarginBroker( price_provider=price_provider, initial_balance=10000.0 ) return broker @pytest.fixture def broker_with_long_position(broker): """Create broker with an existing long position.""" broker.open_position( symbol='BTC/USDT', side='long', collateral=1000.0, leverage=5.0, current_price=60000.0 ) return broker @pytest.fixture def broker_with_short_position(broker): """Create broker with an existing short position.""" broker.open_position( symbol='BTC/USDT', side='short', collateral=1000.0, leverage=5.0, current_price=60000.0 ) return broker class TestIncreasePositionCollateralFirst: """Tests for increase_position using collateral + leverage inputs.""" def test_increase_long_collateral_first(self, broker_with_long_position): """Increase long position using collateral + leverage inputs.""" broker = broker_with_long_position position_before = broker.get_position('BTC/USDT') initial_size = position_before.size initial_collateral = position_before.collateral initial_balance = broker.get_balance() # Increase with 500 USDT at 3x leverage position = broker.increase_position( symbol='BTC/USDT', additional_collateral=500.0, execution_leverage=3.0, current_price=60000.0 ) # Size should increase assert position.size > initial_size expected_add_size = (500.0 * 3.0) / 60000.0 # 0.025 BTC assert abs(position.size - (initial_size + expected_add_size)) < 1e-9 # Collateral should increase assert position.collateral == initial_collateral + 500.0 # Balance should decrease assert broker.get_balance() == initial_balance - 500.0 def test_increase_short_collateral_first(self, broker_with_short_position): """Increase short position using collateral + leverage inputs.""" broker = broker_with_short_position position_before = broker.get_position('BTC/USDT') initial_size = position_before.size initial_collateral = position_before.collateral position = broker.increase_position( symbol='BTC/USDT', additional_collateral=500.0, execution_leverage=3.0, current_price=60000.0 ) # Size should increase (short position) assert position.size > initial_size assert position.side == 'short' # Collateral should increase assert position.collateral == initial_collateral + 500.0 class TestIncreasePositionSizeFirst: """Tests for increase_position using direct size input.""" def test_increase_long_size_first(self, broker_with_long_position): """Increase long position using direct size input.""" broker = broker_with_long_position position_before = broker.get_position('BTC/USDT') initial_size = position_before.size initial_collateral = position_before.collateral # Increase by 0.05 BTC directly position = broker.increase_position( symbol='BTC/USDT', additional_size=0.05, current_price=60000.0 ) # Size should increase by exactly 0.05 assert abs(position.size - (initial_size + 0.05)) < 1e-9 # Collateral should increase (calculated based on existing leverage) assert position.collateral > initial_collateral def test_increase_short_size_first(self, broker_with_short_position): """Increase short position using direct size input.""" broker = broker_with_short_position position_before = broker.get_position('BTC/USDT') initial_size = position_before.size position = broker.increase_position( symbol='BTC/USDT', additional_size=0.03, current_price=60000.0 ) assert abs(position.size - (initial_size + 0.03)) < 1e-9 assert position.side == 'short' class TestWeightedAverageEntry: """Tests for weighted average entry price calculation.""" def test_increase_weighted_average_entry(self, broker): """Entry price is correctly weighted across increases.""" # Open at 60000 broker.open_position( symbol='BTC/USDT', side='long', collateral=1000.0, leverage=3.0, current_price=60000.0 ) position = broker.get_position('BTC/USDT') initial_size = position.size # 3000/60000 = 0.05 BTC initial_entry = position.entry_price # Simulate price change (update price provider) broker._current_prices['BTC/USDT'] = 65000.0 # Increase at new price position = broker.increase_position( symbol='BTC/USDT', additional_collateral=1000.0, execution_leverage=3.0, current_price=65000.0 ) add_size = (1000.0 * 3.0) / 65000.0 # ~0.0462 BTC # Weighted average = (old_value + new_value) / total_size expected_avg = (initial_size * 60000.0 + add_size * 65000.0) / (initial_size + add_size) assert abs(position.entry_price - expected_avg) < 0.01 def test_multiple_increases_weighted_average(self, broker): """Multiple increases maintain correct weighted average.""" broker.open_position( symbol='BTC/USDT', side='long', collateral=1000.0, leverage=2.0, current_price=50000.0 ) # First increase at 55000 broker._current_prices['BTC/USDT'] = 55000.0 broker.increase_position( symbol='BTC/USDT', additional_collateral=500.0, execution_leverage=2.0, current_price=55000.0 ) # Second increase at 60000 broker._current_prices['BTC/USDT'] = 60000.0 position = broker.increase_position( symbol='BTC/USDT', additional_collateral=500.0, execution_leverage=2.0, current_price=60000.0 ) # Calculate expected weighted average # First: 2000/50000 = 0.04 BTC at 50000 # Second: 1000/55000 = ~0.0182 BTC at 55000 # Third: 1000/60000 = ~0.0167 BTC at 60000 # Total value = 2000 + 1000 + 1000 = 4000 # Total size = 0.04 + 0.0182 + 0.0167 = ~0.0749 # Avg entry = 4000 / 0.0749 = ~53,404 assert position.entry_price > 50000 assert position.entry_price < 60000 class TestInterestAccrual: """Tests for interest accrual before merging.""" def test_increase_accrues_interest_before_merge(self, broker_with_long_position): """Interest is locked in before position is merged.""" broker = broker_with_long_position position = broker.get_position('BTC/USDT') # Manually set some accrued interest position.interest_accrued = 5.0 interest_before = position.interest_accrued # Increase position position = broker.increase_position( symbol='BTC/USDT', additional_collateral=500.0, execution_leverage=3.0, current_price=60000.0 ) # Interest should still be there (not reset) assert position.interest_accrued >= interest_before class TestLiquidationPriceRecalculation: """Tests for liquidation price recalculation.""" def test_increase_recalculates_liquidation_price(self, broker_with_long_position): """Liquidation price reflects new effective leverage.""" broker = broker_with_long_position position_before = broker.get_position('BTC/USDT') liq_before = position_before.liquidation_price # Adding more collateral at lower leverage should improve liq price position = broker.increase_position( symbol='BTC/USDT', additional_collateral=2000.0, # Large collateral execution_leverage=2.0, # Low leverage current_price=60000.0 ) # For a long, lower effective leverage = lower liquidation price (safer) # The overall effective leverage should decrease assert position.liquidation_price < liq_before class TestValidationErrors: """Tests for validation and error handling.""" def test_increase_rejects_missing_position(self, broker): """Fails when no position exists for symbol.""" with pytest.raises(ValueError, match="No position for"): broker.increase_position( symbol='BTC/USDT', additional_collateral=500.0, execution_leverage=3.0 ) def test_increase_rejects_insufficient_balance(self, broker_with_long_position): """Fails cleanly when balance cannot cover collateral.""" broker = broker_with_long_position # Try to add more collateral than available balance with pytest.raises(ValueError, match="Insufficient balance"): broker.increase_position( symbol='BTC/USDT', additional_collateral=50000.0, # Way more than balance execution_leverage=3.0 ) def test_increase_rejects_excessive_leverage(self, broker_with_long_position): """Fails when resulting leverage exceeds max.""" broker = broker_with_long_position with pytest.raises(ValueError, match="exceeds max"): broker.increase_position( symbol='BTC/USDT', additional_collateral=500.0, execution_leverage=20.0 # Exceeds 10x max ) def test_increase_rejects_invalid_inputs_both_modes(self, broker_with_long_position): """Cannot provide both collateral+leverage AND size.""" broker = broker_with_long_position with pytest.raises(ValueError, match="not both"): broker.increase_position( symbol='BTC/USDT', additional_collateral=500.0, execution_leverage=3.0, additional_size=0.05 # Both modes provided ) def test_increase_rejects_no_inputs(self, broker_with_long_position): """Must provide either mode's inputs.""" broker = broker_with_long_position with pytest.raises(ValueError, match="Must provide"): broker.increase_position( symbol='BTC/USDT' # No inputs provided ) def test_increase_rejects_negative_collateral(self, broker_with_long_position): """Negative collateral is rejected.""" broker = broker_with_long_position with pytest.raises(ValueError, match="must be positive"): broker.increase_position( symbol='BTC/USDT', additional_collateral=-100.0, execution_leverage=3.0 ) def test_increase_rejects_negative_size(self, broker_with_long_position): """Negative size is rejected.""" broker = broker_with_long_position with pytest.raises(ValueError, match="must be positive"): broker.increase_position( symbol='BTC/USDT', additional_size=-0.05 ) def test_increase_rejects_low_leverage(self, broker_with_long_position): """Leverage below 1x is rejected.""" broker = broker_with_long_position with pytest.raises(ValueError, match="at least 1x"): broker.increase_position( symbol='BTC/USDT', additional_collateral=500.0, execution_leverage=0.5 ) class TestFullLifecycle: """Integration tests for full position lifecycle.""" def test_multiple_increases_then_close(self, broker): """Full lifecycle: open, increase twice, close. P/L correct.""" # Open position at 50000 broker._current_prices['BTC/USDT'] = 50000.0 broker.open_position( symbol='BTC/USDT', side='long', collateral=1000.0, leverage=2.0, current_price=50000.0 ) initial_balance = broker.get_balance() # First increase at 52000 broker._current_prices['BTC/USDT'] = 52000.0 broker.increase_position( symbol='BTC/USDT', additional_collateral=500.0, execution_leverage=2.0, current_price=52000.0 ) # Second increase at 54000 broker._current_prices['BTC/USDT'] = 54000.0 position = broker.increase_position( symbol='BTC/USDT', additional_collateral=500.0, execution_leverage=2.0, current_price=54000.0 ) total_collateral = position.collateral total_size = position.size avg_entry = position.entry_price # Close at 58000 (profit) broker._current_prices['BTC/USDT'] = 58000.0 result = broker.close_position('BTC/USDT', current_price=58000.0) assert result.success assert result.realized_pnl > 0 # Should have profit # Balance should reflect collateral returned + P/L final_balance = broker.get_balance() assert final_balance > initial_balance # Made profit def test_increase_then_partial_reduce(self, broker): """Increase position then partially reduce.""" broker._current_prices['BTC/USDT'] = 60000.0 broker.open_position( symbol='BTC/USDT', side='long', collateral=1000.0, leverage=3.0, current_price=60000.0 ) # Increase broker.increase_position( symbol='BTC/USDT', additional_collateral=500.0, execution_leverage=3.0, current_price=60000.0 ) position = broker.get_position('BTC/USDT') size_after_increase = position.size # Partial reduce reduce_amount = size_after_increase * 0.3 position = broker.reduce_position( symbol='BTC/USDT', reduce_size=reduce_amount, current_price=60000.0 ) assert position is not None assert position.size < size_after_increase assert abs(position.size - (size_after_increase - reduce_amount)) < 1e-9 class TestPreviewIncrease: """Tests for preview_increase method.""" def test_preview_matches_execution(self, broker_with_long_position): """Preview values should match what execution produces.""" broker = broker_with_long_position # Get preview preview = broker.preview_increase( symbol='BTC/USDT', additional_collateral=500.0, execution_leverage=3.0, current_price=60000.0 ) assert preview['valid'] projected = preview['projected'] # Execute the same operation position = broker.increase_position( symbol='BTC/USDT', additional_collateral=500.0, execution_leverage=3.0, current_price=60000.0 ) # Values should match assert abs(position.size - projected['total_size']) < 1e-9 assert abs(position.entry_price - projected['average_entry']) < 0.01 assert abs(position.collateral - projected['total_collateral']) < 0.01 def test_preview_invalid_returns_errors(self, broker_with_long_position): """Preview with invalid inputs returns errors.""" broker = broker_with_long_position preview = broker.preview_increase( symbol='BTC/USDT', additional_collateral=50000.0, # More than balance execution_leverage=3.0 ) assert not preview['valid'] assert len(preview['errors']) > 0 assert 'Insufficient balance' in preview['errors'][0] def test_preview_warnings_for_high_leverage(self, broker_with_long_position): """Preview warns about high leverage.""" broker = broker_with_long_position preview = broker.preview_increase( symbol='BTC/USDT', additional_collateral=100.0, # Small collateral execution_leverage=8.0, # High leverage current_price=60000.0 ) assert preview['valid'] assert len(preview['warnings']) > 0 assert any('5x' in w for w in preview['warnings']) class TestRemoveMargin: """Tests for remove_margin functionality.""" def test_remove_margin_success(self, broker_with_long_position): """Can remove margin when position is healthy.""" broker = broker_with_long_position position = broker.get_position('BTC/USDT') initial_collateral = position.collateral initial_balance = broker.get_balance() # Remove small amount of margin position = broker.remove_margin('BTC/USDT', amount=100.0) assert position.collateral == initial_collateral - 100.0 assert broker.get_balance() == initial_balance + 100.0 def test_remove_margin_rejects_too_much(self, broker_with_long_position): """Cannot remove margin that would breach min margin.""" broker = broker_with_long_position position = broker.get_position('BTC/USDT') # Try to remove almost all collateral with pytest.raises(ValueError, match="max withdrawable"): broker.remove_margin('BTC/USDT', amount=position.collateral * 0.95) def test_preview_remove_margin(self, broker_with_long_position): """Preview remove margin shows correct projected values.""" broker = broker_with_long_position preview = broker.preview_remove_margin('BTC/USDT', amount=100.0) assert preview['valid'] assert preview['projected']['withdrawn_amount'] == 100.0 assert preview['projected']['max_withdrawable'] > 0 class TestPositionHistory: """Tests for position history tracking.""" def test_increase_recorded_in_history(self, broker_with_long_position): """Increase operation is recorded in position history.""" broker = broker_with_long_position history_before = len(broker.get_position_history()) broker.increase_position( symbol='BTC/USDT', additional_collateral=500.0, execution_leverage=3.0, current_price=60000.0 ) history_after = broker.get_position_history() assert len(history_after) == history_before + 1 last_entry = history_after[-1] assert last_entry['type'] == 'increase' assert last_entry['symbol'] == 'BTC/USDT' assert last_entry['size_delta'] > 0 assert last_entry['collateral_delta'] == 500.0 assert 'before' in last_entry assert 'after' in last_entry class TestRemoveMarginHealthCheck: """Tests for remove_margin health-based validation (codex issue #1).""" def test_remove_margin_rejects_when_underwater(self, broker): """ Remove_margin must reject withdrawal when position is underwater. Regression test for codex finding: position opened at 100, marked to 85, should not allow withdrawal that would cause immediate liquidation. """ # Open 5x long at price 100 broker._current_prices['BTC/USDT'] = 100.0 broker.open_position( symbol='BTC/USDT', side='long', collateral=1000.0, leverage=5.0, current_price=100.0 ) # Price drops to 85 - position is now in loss broker._current_prices['BTC/USDT'] = 85.0 broker.update() # Update position with new price position = broker.get_position('BTC/USDT') # Position should be unhealthy - margin_ratio should be low assert position.margin_ratio < 100, f"Expected low margin ratio, got {position.margin_ratio}" # Attempting to withdraw significant collateral should fail with pytest.raises(ValueError, match="max withdrawable"): broker.remove_margin('BTC/USDT', amount=400.0) def test_remove_margin_max_withdrawable_reflects_current_health(self, broker): """Max withdrawable should be based on current price, not entry price.""" broker._current_prices['BTC/USDT'] = 100.0 broker.open_position( symbol='BTC/USDT', side='long', collateral=1000.0, leverage=3.0, current_price=100.0 ) # Price drops significantly broker._current_prices['BTC/USDT'] = 75.0 broker.update() position = broker.get_position('BTC/USDT') # Preview should show very limited withdrawal capacity preview = broker.preview_remove_margin('BTC/USDT', amount=500.0) # Should be invalid - can't withdraw that much when position is underwater assert not preview['valid'], "Should not allow withdrawal when position is in significant loss" def test_remove_margin_allowed_when_profitable(self, broker): """Remove_margin should allow withdrawal when position is profitable.""" broker._current_prices['BTC/USDT'] = 100.0 broker.open_position( symbol='BTC/USDT', side='long', collateral=1000.0, leverage=2.0, # Conservative leverage current_price=100.0 ) # Price rises - position is now profitable broker._current_prices['BTC/USDT'] = 120.0 broker.update() position = broker.get_position('BTC/USDT') assert position.margin_ratio > 100, "Position should be healthy when profitable" # Should be able to withdraw some excess preview = broker.preview_remove_margin('BTC/USDT', amount=100.0) assert preview['valid'], f"Should allow modest withdrawal when profitable: {preview.get('errors')}" def test_preview_remove_margin_accounts_for_elapsed_interest(self, broker): """Preview remove_margin should accrue interest the same way execution does.""" broker._current_prices['BTC/USDT'] = 100.0 broker.open_position( symbol='BTC/USDT', side='long', collateral=1000.0, leverage=5.0, current_price=100.0 ) position = broker.get_position('BTC/USDT') position.last_interest_at = datetime.now(timezone.utc) - timedelta(hours=24) preview = broker.preview_remove_margin('BTC/USDT', amount=495.0) assert not preview['valid'] with pytest.raises(ValueError, match="max withdrawable"): broker.remove_margin('BTC/USDT', amount=495.0) class TestPreviewMarginRatioAccuracy: """Tests for preview margin ratio matching execution (codex issue #2).""" def test_preview_increase_margin_ratio_matches_execution(self, broker_with_long_position): """Preview margin_ratio should match what execution produces.""" broker = broker_with_long_position # Get preview preview = broker.preview_increase( symbol='BTC/USDT', additional_collateral=500.0, execution_leverage=3.0, current_price=60000.0 ) assert preview['valid'] projected_ratio = preview['projected']['margin_ratio'] # Execute position = broker.increase_position( symbol='BTC/USDT', additional_collateral=500.0, execution_leverage=3.0, current_price=60000.0 ) # Margin ratios should be close (allow small rounding differences) assert abs(position.margin_ratio - projected_ratio) < 1.0, \ f"Preview margin_ratio {projected_ratio} doesn't match execution {position.margin_ratio}" def test_preview_add_margin_ratio_matches_execution(self, broker_with_long_position): """Preview add_margin ratio should match execution.""" broker = broker_with_long_position preview = broker.preview_add_margin('BTC/USDT', amount=500.0) assert preview['valid'] projected_ratio = preview['projected']['margin_ratio'] position = broker.add_margin('BTC/USDT', amount=500.0) assert abs(position.margin_ratio - projected_ratio) < 1.0, \ f"Preview margin_ratio {projected_ratio} doesn't match execution {position.margin_ratio}" def test_preview_increase_margin_ratio_matches_execution_after_elapsed_interest(self, broker): """Preview increase should account for accrued interest before execution.""" broker._current_prices['BTC/USDT'] = 100.0 broker.open_position( symbol='BTC/USDT', side='long', collateral=1000.0, leverage=5.0, current_price=100.0 ) position = broker.get_position('BTC/USDT') position.last_interest_at = datetime.now(timezone.utc) - timedelta(hours=24) preview = broker.preview_increase( symbol='BTC/USDT', additional_collateral=100.0, execution_leverage=2.0, current_price=100.0 ) assert preview['valid'] position = broker.increase_position( symbol='BTC/USDT', additional_collateral=100.0, execution_leverage=2.0, current_price=100.0 ) assert abs(position.margin_ratio - preview['projected']['margin_ratio']) < 0.1 def test_preview_add_margin_ratio_matches_execution_after_elapsed_interest(self, broker): """Preview add_margin should match execution after accruing elapsed interest.""" broker._current_prices['BTC/USDT'] = 100.0 broker.open_position( symbol='BTC/USDT', side='long', collateral=1000.0, leverage=5.0, current_price=100.0 ) position = broker.get_position('BTC/USDT') position.last_interest_at = datetime.now(timezone.utc) - timedelta(hours=24) preview = broker.preview_add_margin('BTC/USDT', amount=100.0) assert preview['valid'] position = broker.add_margin('BTC/USDT', amount=100.0) assert abs(position.margin_ratio - preview['projected']['margin_ratio']) < 0.1 def test_preview_reduce_realized_pnl_matches_execution_after_elapsed_interest(self, broker): """Preview reduce should use accrued interest in realized P/L calculations.""" broker._current_prices['BTC/USDT'] = 100.0 broker.open_position( symbol='BTC/USDT', side='long', collateral=1000.0, leverage=5.0, current_price=100.0 ) position = broker.get_position('BTC/USDT') position.last_interest_at = datetime.now(timezone.utc) - timedelta(hours=24) reduce_size = position.size * 0.2 preview = broker.preview_reduce( symbol='BTC/USDT', reduce_size=reduce_size, current_price=100.0 ) assert preview['valid'] balance_before = broker.get_balance() broker.reduce_position( symbol='BTC/USDT', reduce_size=reduce_size, current_price=100.0 ) actual_returned = broker.get_balance() - balance_before assert preview['projected']['interest_paid'] > 0 assert abs(actual_returned - preview['projected']['returned_to_balance']) < 0.1 class TestExecutionReturnsCurrentFields: """Tests for execution returning current market fields (codex issue #3).""" def test_increase_returns_updated_margin_ratio(self, broker_with_long_position): """increase_position should return position with current margin_ratio.""" broker = broker_with_long_position position = broker.increase_position( symbol='BTC/USDT', additional_collateral=500.0, execution_leverage=3.0, current_price=60000.0 ) # margin_ratio should be freshly calculated, not stale assert position.margin_ratio > 0 assert position.current_price == 60000.0 # unrealized_pnl should reflect current price assert position.unrealized_pnl is not None def test_add_margin_returns_updated_margin_ratio(self, broker_with_long_position): """add_margin should return position with current margin_ratio.""" broker = broker_with_long_position position = broker.add_margin('BTC/USDT', amount=500.0) assert position.margin_ratio > 0 assert position.current_price > 0 def test_remove_margin_returns_updated_margin_ratio(self, broker): """remove_margin should return position with current margin_ratio.""" broker._current_prices['BTC/USDT'] = 60000.0 broker.open_position( symbol='BTC/USDT', side='long', collateral=2000.0, # Large collateral for safe withdrawal leverage=2.0, current_price=60000.0 ) # Make position profitable so we can withdraw broker._current_prices['BTC/USDT'] = 65000.0 broker.update() position = broker.remove_margin('BTC/USDT', amount=100.0) assert position.margin_ratio > 0 assert position.current_price == 65000.0 def test_add_margin_returns_accrued_market_fields(self, broker): """add_margin should return values that already include elapsed interest.""" broker._current_prices['BTC/USDT'] = 100.0 broker.open_position( symbol='BTC/USDT', side='long', collateral=1000.0, leverage=5.0, current_price=100.0 ) position = broker.get_position('BTC/USDT') position.last_interest_at = datetime.now(timezone.utc) - timedelta(hours=24) position = broker.add_margin('BTC/USDT', amount=100.0) returned_margin_ratio = position.margin_ratio returned_liquidation_price = position.liquidation_price returned_interest = position.interest_accrued assert returned_interest > 0 broker.update() refreshed = broker.get_position('BTC/USDT') assert abs(refreshed.margin_ratio - returned_margin_ratio) < 0.1 assert abs(refreshed.liquidation_price - returned_liquidation_price) < 0.1 def test_reduce_returns_updated_market_fields_after_elapsed_interest(self, broker): """reduce_position should return fresh market fields after partial close.""" broker._current_prices['BTC/USDT'] = 100.0 broker.open_position( symbol='BTC/USDT', side='long', collateral=1000.0, leverage=5.0, current_price=100.0 ) position = broker.get_position('BTC/USDT') position.last_interest_at = datetime.now(timezone.utc) - timedelta(hours=24) reduced = broker.reduce_position( symbol='BTC/USDT', reduce_size=position.size * 0.2, current_price=100.0 ) returned_margin_ratio = reduced.margin_ratio returned_unrealized_pnl = reduced.unrealized_pnl broker.update() refreshed = broker.get_position('BTC/USDT') assert abs(refreshed.margin_ratio - returned_margin_ratio) < 0.1 assert abs(refreshed.unrealized_pnl - returned_unrealized_pnl) < 0.1