We provide all the information about MCP servers via our MCP API.
curl -X GET 'https://glama.ai/api/mcp/v1/servers/knishioka/ib-sec-mcp'
If you have feedback or need assistance with the MCP directory API, please join our Discord server
"""Tests for bond analyzer"""
from datetime import date
from decimal import Decimal
from unittest.mock import MagicMock, patch
import pytest
from ib_sec_mcp.analyzers.bond import BondAnalyzer
from ib_sec_mcp.models.account import Account
from ib_sec_mcp.models.position import Position
from ib_sec_mcp.models.trade import AssetClass, BuySell, Trade
@pytest.fixture
def zero_coupon_bond_position():
"""Zero-coupon bond position with maturity date."""
return Position(
account_id="U1234567",
symbol="US912810SJ88",
description="US T-BOND STRIPS 2035",
asset_class=AssetClass.BOND,
quantity=Decimal("10000"),
mark_price=Decimal("65.50"),
position_value=Decimal("6550"),
average_cost=Decimal("60"),
cost_basis=Decimal("6000"),
unrealized_pnl=Decimal("550"),
currency="USD",
fx_rate_to_base=Decimal("1"),
position_date=date(2025, 1, 31),
maturity_date=date(2035, 1, 15),
coupon_rate=None,
)
@pytest.fixture
def bond_position_no_maturity():
"""Bond position without maturity date."""
return Position(
account_id="U1234567",
symbol="BOND_NO_MAT",
description="BOND WITHOUT MATURITY",
asset_class=AssetClass.BOND,
quantity=Decimal("5000"),
mark_price=Decimal("80"),
position_value=Decimal("4000"),
average_cost=Decimal("75"),
cost_basis=Decimal("3750"),
unrealized_pnl=Decimal("250"),
currency="USD",
fx_rate_to_base=Decimal("1"),
position_date=date(2025, 1, 31),
maturity_date=None,
coupon_rate=None,
)
@pytest.fixture
def bond_position_zero_quantity():
"""Bond position with quantity <= 0 (should get ytm=0, duration=0)."""
return Position(
account_id="U1234567",
symbol="BOND_ZERO_QTY",
description="BOND ZERO QTY",
asset_class=AssetClass.BOND,
quantity=Decimal("0"),
mark_price=Decimal("70"),
position_value=Decimal("0"),
average_cost=Decimal("65"),
cost_basis=Decimal("0"),
unrealized_pnl=Decimal("0"),
currency="USD",
fx_rate_to_base=Decimal("1"),
position_date=date(2025, 1, 31),
maturity_date=date(2030, 6, 15),
coupon_rate=None,
)
@pytest.fixture
def coupon_bond_position():
"""Bond position with coupon rate."""
return Position(
account_id="U1234567",
symbol="US912828ZZ19",
description="US T-NOTE 2.5% 2030",
asset_class=AssetClass.BOND,
quantity=Decimal("5000"),
mark_price=Decimal("95"),
position_value=Decimal("4750"),
average_cost=Decimal("98"),
cost_basis=Decimal("4900"),
unrealized_pnl=Decimal("-150"),
currency="USD",
fx_rate_to_base=Decimal("1"),
position_date=date(2025, 1, 31),
maturity_date=date(2030, 6, 15),
coupon_rate=Decimal("2.5"),
)
@pytest.fixture
def stock_position():
"""A non-bond position (should be filtered out)."""
return Position(
account_id="U1234567",
symbol="AAPL",
description="Apple Inc",
asset_class=AssetClass.STOCK,
quantity=Decimal("10"),
mark_price=Decimal("150"),
position_value=Decimal("1500"),
average_cost=Decimal("120"),
cost_basis=Decimal("1200"),
unrealized_pnl=Decimal("300"),
currency="USD",
fx_rate_to_base=Decimal("1"),
position_date=date(2025, 1, 31),
)
@pytest.fixture
def bond_buy_trade():
"""A bond buy trade."""
return Trade(
account_id="U1234567",
trade_id="BT1",
trade_date=date(2025, 1, 10),
settle_date=date(2025, 1, 12),
symbol="US912810SJ88",
description="US T-BOND STRIPS 2035",
asset_class=AssetClass.BOND,
buy_sell=BuySell.BUY,
quantity=Decimal("10000"),
trade_price=Decimal("60"),
trade_money=Decimal("-6000"),
ib_commission=Decimal("-5.00"),
fifo_pnl_realized=Decimal("0"),
)
@pytest.fixture
def bond_sell_trade():
"""A bond sell trade with realized PnL."""
return Trade(
account_id="U1234567",
trade_id="BT2",
trade_date=date(2025, 1, 20),
settle_date=date(2025, 1, 22),
symbol="US912828ZZ19",
description="US T-NOTE 2.5% 2030",
asset_class=AssetClass.BOND,
buy_sell=BuySell.SELL,
quantity=Decimal("5000"),
trade_price=Decimal("96"),
trade_money=Decimal("4800"),
ib_commission=Decimal("-3.00"),
fifo_pnl_realized=Decimal("150"),
)
@pytest.fixture
def stock_trade():
"""A non-bond trade (should be filtered out)."""
return Trade(
account_id="U1234567",
trade_id="ST1",
trade_date=date(2025, 1, 15),
settle_date=date(2025, 1, 17),
symbol="AAPL",
asset_class=AssetClass.STOCK,
buy_sell=BuySell.BUY,
quantity=Decimal("10"),
trade_price=Decimal("150"),
trade_money=Decimal("-1500"),
ib_commission=Decimal("-1.50"),
fifo_pnl_realized=Decimal("0"),
)
def _mock_date_today(target_date):
"""Create a mock for date.today() in the bond analyzer module."""
mock_date = MagicMock(wraps=date)
mock_date.today.return_value = target_date
mock_date.side_effect = lambda *args, **kwargs: date(*args, **kwargs)
return mock_date
class TestBondAnalyzer:
"""Tests for BondAnalyzer."""
def test_analyzer_name(self, zero_coupon_bond_position, bond_buy_trade):
account = Account(
account_id="U1234567",
from_date=date(2025, 1, 1),
to_date=date(2025, 1, 31),
positions=[zero_coupon_bond_position],
trades=[bond_buy_trade],
)
analyzer = BondAnalyzer(account=account)
mock_date = _mock_date_today(date(2025, 6, 15))
with patch("ib_sec_mcp.analyzers.bond.date", mock_date):
result = analyzer.analyze()
assert result["analyzer"] == "Bond"
def test_no_bonds_returns_false(self, stock_position, stock_trade):
"""No bond positions or trades should return has_bonds=False."""
account = Account(
account_id="U1234567",
from_date=date(2025, 1, 1),
to_date=date(2025, 1, 31),
positions=[stock_position],
trades=[stock_trade],
)
analyzer = BondAnalyzer(account=account)
result = analyzer.analyze()
assert result["has_bonds"] is False
assert "message" in result
def test_no_bonds_empty_account(self):
"""Empty account returns has_bonds=False."""
account = Account(
account_id="U1234567",
from_date=date(2025, 1, 1),
to_date=date(2025, 1, 31),
positions=[],
trades=[],
)
analyzer = BondAnalyzer(account=account)
result = analyzer.analyze()
assert result["has_bonds"] is False
assert "message" in result
def test_has_bonds_with_positions(self, zero_coupon_bond_position):
"""Bond positions should set has_bonds=True."""
account = Account(
account_id="U1234567",
from_date=date(2025, 1, 1),
to_date=date(2025, 1, 31),
positions=[zero_coupon_bond_position],
trades=[],
)
analyzer = BondAnalyzer(account=account)
mock_date = _mock_date_today(date(2025, 6, 15))
with patch("ib_sec_mcp.analyzers.bond.date", mock_date):
result = analyzer.analyze()
assert result["has_bonds"] is True
def test_has_bonds_with_trades_only(self, bond_buy_trade):
"""Bond trades without positions should still set has_bonds=True."""
account = Account(
account_id="U1234567",
from_date=date(2025, 1, 1),
to_date=date(2025, 1, 31),
positions=[],
trades=[bond_buy_trade],
)
analyzer = BondAnalyzer(account=account)
result = analyzer.analyze()
assert result["has_bonds"] is True
assert result["current_holdings_count"] == 0
assert result["completed_trades_count"] == 1
def test_current_holdings(self, zero_coupon_bond_position):
account = Account(
account_id="U1234567",
from_date=date(2025, 1, 1),
to_date=date(2025, 1, 31),
positions=[zero_coupon_bond_position],
trades=[],
)
analyzer = BondAnalyzer(account=account)
mock_date = _mock_date_today(date(2025, 6, 15))
with patch("ib_sec_mcp.analyzers.bond.date", mock_date):
result = analyzer.analyze()
assert result["current_holdings_count"] == 1
holdings = result["current_holdings"]
assert len(holdings) == 1
holding = holdings[0]
assert holding["symbol"] == "US912810SJ88"
assert holding["description"] == "US T-BOND STRIPS 2035"
assert Decimal(holding["quantity"]) == Decimal("10000")
assert Decimal(holding["mark_price"]) == Decimal("65.50")
assert Decimal(holding["position_value"]) == Decimal("6550")
assert Decimal(holding["cost_basis"]) == Decimal("6000")
assert Decimal(holding["unrealized_pnl"]) == Decimal("550")
def test_current_holdings_maturity_info(self, zero_coupon_bond_position):
account = Account(
account_id="U1234567",
from_date=date(2025, 1, 1),
to_date=date(2025, 1, 31),
positions=[zero_coupon_bond_position],
trades=[],
)
analyzer = BondAnalyzer(account=account)
mock_date = _mock_date_today(date(2025, 6, 15))
with patch("ib_sec_mcp.analyzers.bond.date", mock_date):
result = analyzer.analyze()
holding = result["current_holdings"][0]
assert holding["maturity_date"] == "2035-01-15"
years_to_mat = Decimal(holding["years_to_maturity"])
assert years_to_mat > Decimal("0")
# YTM: face_value=10000, current_price=65.50*10000=655000
# ((10000/655000)^(1/9.585) - 1) * 100 ≈ -35.36%
ytm = Decimal(holding["ytm"])
assert ytm < Decimal("0")
assert abs(ytm - Decimal("-35.36")) < Decimal("0.1")
# Duration for zero-coupon = years to maturity
duration = Decimal(holding["duration"])
assert duration == years_to_mat
def test_ytm_calculation(self, zero_coupon_bond_position):
"""Verify YTM is calculated correctly for zero-coupon bond."""
account = Account(
account_id="U1234567",
from_date=date(2025, 1, 1),
to_date=date(2025, 1, 31),
positions=[zero_coupon_bond_position],
trades=[],
)
analyzer = BondAnalyzer(account=account)
mock_date = _mock_date_today(date(2025, 6, 15))
with patch("ib_sec_mcp.analyzers.bond.date", mock_date):
result = analyzer.analyze()
holding = result["current_holdings"][0]
ytm = Decimal(holding["ytm"])
# face_value=10000, current_price=65.50*10000=655000
# current_price >> face_value, so YTM is negative (~-35.36%)
assert ytm < Decimal("0")
assert abs(ytm - Decimal("-35.36")) < Decimal("0.1")
def test_bond_without_maturity(self, bond_position_no_maturity):
"""Bond without maturity: ytm=0, duration=0."""
account = Account(
account_id="U1234567",
from_date=date(2025, 1, 1),
to_date=date(2025, 1, 31),
positions=[bond_position_no_maturity],
trades=[],
)
analyzer = BondAnalyzer(account=account)
mock_date = _mock_date_today(date(2025, 6, 15))
with patch("ib_sec_mcp.analyzers.bond.date", mock_date):
result = analyzer.analyze()
holding = result["current_holdings"][0]
assert Decimal(holding["ytm"]) == Decimal("0")
assert Decimal(holding["duration"]) == Decimal("0")
assert Decimal(holding["years_to_maturity"]) == Decimal("0")
assert holding["maturity_date"] is None
def test_bond_zero_quantity(self, bond_position_zero_quantity):
"""Bond with quantity <= 0: ytm=0, duration=0."""
account = Account(
account_id="U1234567",
from_date=date(2025, 1, 1),
to_date=date(2025, 1, 31),
positions=[bond_position_zero_quantity],
trades=[],
)
analyzer = BondAnalyzer(account=account)
mock_date = _mock_date_today(date(2025, 6, 15))
with patch("ib_sec_mcp.analyzers.bond.date", mock_date):
result = analyzer.analyze()
holding = result["current_holdings"][0]
assert Decimal(holding["ytm"]) == Decimal("0")
assert Decimal(holding["duration"]) == Decimal("0")
def test_completed_trades(self, bond_buy_trade, bond_sell_trade):
account = Account(
account_id="U1234567",
from_date=date(2025, 1, 1),
to_date=date(2025, 1, 31),
positions=[],
trades=[bond_buy_trade, bond_sell_trade],
)
analyzer = BondAnalyzer(account=account)
result = analyzer.analyze()
assert result["completed_trades_count"] == 2
trades = result["completed_trades"]
buy = next(t for t in trades if t["buy_sell"] == "BUY")
assert buy["symbol"] == "US912810SJ88"
assert buy["trade_date"] == "2025-01-10"
assert Decimal(buy["quantity"]) == Decimal("10000")
assert Decimal(buy["price"]) == Decimal("60")
assert Decimal(buy["amount"]) == Decimal("-6000")
assert Decimal(buy["commission"]) == Decimal("-5.00")
assert Decimal(buy["realized_pnl"]) == Decimal("0")
sell = next(t for t in trades if t["buy_sell"] == "SELL")
assert sell["symbol"] == "US912828ZZ19"
assert Decimal(sell["realized_pnl"]) == Decimal("150")
def test_total_bond_value(self, zero_coupon_bond_position, coupon_bond_position):
account = Account(
account_id="U1234567",
from_date=date(2025, 1, 1),
to_date=date(2025, 1, 31),
positions=[zero_coupon_bond_position, coupon_bond_position],
trades=[],
)
analyzer = BondAnalyzer(account=account)
mock_date = _mock_date_today(date(2025, 6, 15))
with patch("ib_sec_mcp.analyzers.bond.date", mock_date):
result = analyzer.analyze()
# 6550 + 4750 = 11300
assert Decimal(result["total_bond_value"]) == Decimal("11300")
def test_total_unrealized_pnl(self, zero_coupon_bond_position, coupon_bond_position):
account = Account(
account_id="U1234567",
from_date=date(2025, 1, 1),
to_date=date(2025, 1, 31),
positions=[zero_coupon_bond_position, coupon_bond_position],
trades=[],
)
analyzer = BondAnalyzer(account=account)
mock_date = _mock_date_today(date(2025, 6, 15))
with patch("ib_sec_mcp.analyzers.bond.date", mock_date):
result = analyzer.analyze()
# 550 + (-150) = 400
assert Decimal(result["total_unrealized_pnl"]) == Decimal("400")
def test_total_realized_pnl(self, bond_buy_trade, bond_sell_trade):
account = Account(
account_id="U1234567",
from_date=date(2025, 1, 1),
to_date=date(2025, 1, 31),
positions=[],
trades=[bond_buy_trade, bond_sell_trade],
)
analyzer = BondAnalyzer(account=account)
result = analyzer.analyze()
# 0 + 150 = 150
assert Decimal(result["total_realized_pnl"]) == Decimal("150")
def test_total_pnl(
self,
zero_coupon_bond_position,
bond_buy_trade,
bond_sell_trade,
):
account = Account(
account_id="U1234567",
from_date=date(2025, 1, 1),
to_date=date(2025, 1, 31),
positions=[zero_coupon_bond_position],
trades=[bond_buy_trade, bond_sell_trade],
)
analyzer = BondAnalyzer(account=account)
mock_date = _mock_date_today(date(2025, 6, 15))
with patch("ib_sec_mcp.analyzers.bond.date", mock_date):
result = analyzer.analyze()
# unrealized=550, realized=0+150=150, total=700
assert Decimal(result["total_pnl"]) == Decimal("700")
def test_filters_non_bond_positions(self, zero_coupon_bond_position, stock_position):
"""Only bond positions appear in current_holdings."""
account = Account(
account_id="U1234567",
from_date=date(2025, 1, 1),
to_date=date(2025, 1, 31),
positions=[zero_coupon_bond_position, stock_position],
trades=[],
)
analyzer = BondAnalyzer(account=account)
mock_date = _mock_date_today(date(2025, 6, 15))
with patch("ib_sec_mcp.analyzers.bond.date", mock_date):
result = analyzer.analyze()
assert result["current_holdings_count"] == 1
assert result["current_holdings"][0]["symbol"] == "US912810SJ88"
def test_filters_non_bond_trades(self, bond_buy_trade, stock_trade):
"""Only bond trades appear in completed_trades."""
account = Account(
account_id="U1234567",
from_date=date(2025, 1, 1),
to_date=date(2025, 1, 31),
positions=[],
trades=[bond_buy_trade, stock_trade],
)
analyzer = BondAnalyzer(account=account)
result = analyzer.analyze()
assert result["completed_trades_count"] == 1
assert result["completed_trades"][0]["symbol"] == "US912810SJ88"
def test_coupon_bond_has_coupon_rate(self, coupon_bond_position):
account = Account(
account_id="U1234567",
from_date=date(2025, 1, 1),
to_date=date(2025, 1, 31),
positions=[coupon_bond_position],
trades=[],
)
analyzer = BondAnalyzer(account=account)
mock_date = _mock_date_today(date(2025, 6, 15))
with patch("ib_sec_mcp.analyzers.bond.date", mock_date):
result = analyzer.analyze()
holding = result["current_holdings"][0]
assert Decimal(holding["coupon_rate"]) == Decimal("2.5")
def test_zero_coupon_bond_coupon_rate_zero(self, zero_coupon_bond_position):
account = Account(
account_id="U1234567",
from_date=date(2025, 1, 1),
to_date=date(2025, 1, 31),
positions=[zero_coupon_bond_position],
trades=[],
)
analyzer = BondAnalyzer(account=account)
mock_date = _mock_date_today(date(2025, 6, 15))
with patch("ib_sec_mcp.analyzers.bond.date", mock_date):
result = analyzer.analyze()
holding = result["current_holdings"][0]
assert holding["coupon_rate"] == "0"
def test_unrealized_pnl_pct(self, zero_coupon_bond_position):
account = Account(
account_id="U1234567",
from_date=date(2025, 1, 1),
to_date=date(2025, 1, 31),
positions=[zero_coupon_bond_position],
trades=[],
)
analyzer = BondAnalyzer(account=account)
mock_date = _mock_date_today(date(2025, 6, 15))
with patch("ib_sec_mcp.analyzers.bond.date", mock_date):
result = analyzer.analyze()
holding = result["current_holdings"][0]
# pnl_percentage = (550 / 6000) * 100
expected_pct = (Decimal("550") / Decimal("6000")) * 100
assert Decimal(holding["unrealized_pnl_pct"]) == expected_pct
def test_multiple_bond_positions(self, zero_coupon_bond_position, coupon_bond_position):
account = Account(
account_id="U1234567",
from_date=date(2025, 1, 1),
to_date=date(2025, 1, 31),
positions=[zero_coupon_bond_position, coupon_bond_position],
trades=[],
)
analyzer = BondAnalyzer(account=account)
mock_date = _mock_date_today(date(2025, 6, 15))
with patch("ib_sec_mcp.analyzers.bond.date", mock_date):
result = analyzer.analyze()
assert result["current_holdings_count"] == 2
symbols = [h["symbol"] for h in result["current_holdings"]]
assert "US912810SJ88" in symbols
assert "US912828ZZ19" in symbols
def test_holding_cusip_isin_fields(self, zero_coupon_bond_position):
account = Account(
account_id="U1234567",
from_date=date(2025, 1, 1),
to_date=date(2025, 1, 31),
positions=[zero_coupon_bond_position],
trades=[],
)
analyzer = BondAnalyzer(account=account)
mock_date = _mock_date_today(date(2025, 6, 15))
with patch("ib_sec_mcp.analyzers.bond.date", mock_date):
result = analyzer.analyze()
holding = result["current_holdings"][0]
# cusip and isin fields should be present (even if None)
assert "cusip" in holding
assert "isin" in holding
def test_decimal_precision(
self,
zero_coupon_bond_position,
bond_buy_trade,
bond_sell_trade,
):
account = Account(
account_id="U1234567",
from_date=date(2025, 1, 1),
to_date=date(2025, 1, 31),
positions=[zero_coupon_bond_position],
trades=[bond_buy_trade, bond_sell_trade],
)
analyzer = BondAnalyzer(account=account)
mock_date = _mock_date_today(date(2025, 6, 15))
with patch("ib_sec_mcp.analyzers.bond.date", mock_date):
result = analyzer.analyze()
# Check summary values
for key in [
"total_bond_value",
"total_unrealized_pnl",
"total_realized_pnl",
"total_pnl",
]:
value_str = result[key]
parsed = Decimal(value_str)
reparsed = str(parsed)
assert "000000001" not in reparsed
# Check holding values
holding = result["current_holdings"][0]
for key in [
"quantity",
"mark_price",
"position_value",
"cost_basis",
"unrealized_pnl",
]:
Decimal(holding[key])
def test_metadata_fields(self, zero_coupon_bond_position):
account = Account(
account_id="U1234567",
from_date=date(2025, 1, 1),
to_date=date(2025, 1, 31),
positions=[zero_coupon_bond_position],
trades=[],
)
analyzer = BondAnalyzer(account=account)
mock_date = _mock_date_today(date(2025, 6, 15))
with patch("ib_sec_mcp.analyzers.bond.date", mock_date):
result = analyzer.analyze()
assert result["account_id"] == "U1234567"
assert result["from_date"] == "2025-01-01"
assert result["to_date"] == "2025-01-31"
assert result["is_multi_account"] is False
def test_no_account_raises(self):
with pytest.raises(ValueError, match="Either portfolio or account"):
BondAnalyzer()