902 lines
31 KiB
Python
902 lines
31 KiB
Python
"""
|
|
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
|