|
|
"""Tests for chart components and visualizations.""" |
|
|
|
|
|
import dash_bootstrap_components as dbc |
|
|
import pytest |
|
|
from dash import html |
|
|
|
|
|
from src.folio.app import create_app |
|
|
from src.folio.chart_data import transform_for_exposure_chart, transform_for_treemap |
|
|
from src.folio.components.charts import ( |
|
|
create_dashboard_section, |
|
|
create_exposure_chart, |
|
|
create_position_treemap, |
|
|
) |
|
|
from src.folio.data_model import ( |
|
|
ExposureBreakdown, |
|
|
OptionPosition, |
|
|
PortfolioGroup, |
|
|
PortfolioSummary, |
|
|
StockPosition, |
|
|
) |
|
|
|
|
|
|
|
|
class TestChartComponents: |
|
|
"""Tests for individual chart components.""" |
|
|
|
|
|
def test_create_exposure_chart(self): |
|
|
"""Test that exposure chart can be created correctly.""" |
|
|
|
|
|
chart = create_exposure_chart() |
|
|
|
|
|
|
|
|
assert isinstance(chart, html.Div) |
|
|
|
|
|
|
|
|
graph_component = None |
|
|
button_net_component = None |
|
|
button_beta_component = None |
|
|
|
|
|
|
|
|
def find_components(component): |
|
|
nonlocal graph_component, button_net_component, button_beta_component |
|
|
if hasattr(component, "id"): |
|
|
if component.id == "exposure-chart": |
|
|
graph_component = component |
|
|
elif component.id == "exposure-net-btn": |
|
|
button_net_component = component |
|
|
elif component.id == "exposure-beta-btn": |
|
|
button_beta_component = component |
|
|
|
|
|
|
|
|
if hasattr(component, "children") and component.children: |
|
|
if isinstance(component.children, list): |
|
|
for child in component.children: |
|
|
find_components(child) |
|
|
else: |
|
|
find_components(component.children) |
|
|
|
|
|
find_components(chart) |
|
|
|
|
|
|
|
|
assert graph_component is not None, "Graph component not found" |
|
|
assert button_net_component is not None, "Net exposure button not found" |
|
|
assert button_beta_component is not None, "Beta-adjusted button not found" |
|
|
|
|
|
|
|
|
assert button_net_component.children == "Net Exposure" |
|
|
assert button_beta_component.children == "Beta-Adjusted" |
|
|
|
|
|
def test_create_position_treemap(self): |
|
|
"""Test that position treemap can be created correctly.""" |
|
|
|
|
|
chart = create_position_treemap() |
|
|
|
|
|
|
|
|
assert isinstance(chart, html.Div) |
|
|
|
|
|
|
|
|
graph_found = False |
|
|
hidden_div_found = False |
|
|
hidden_input_found = False |
|
|
hidden_input_value = None |
|
|
|
|
|
|
|
|
for child in chart.children: |
|
|
if hasattr(child, "id") and child.id == "position-treemap": |
|
|
graph_found = True |
|
|
|
|
|
|
|
|
if ( |
|
|
isinstance(child, html.Div) |
|
|
and hasattr(child, "style") |
|
|
and child.style.get("display") == "none" |
|
|
): |
|
|
hidden_div_found = True |
|
|
|
|
|
|
|
|
if ( |
|
|
hasattr(child, "children") |
|
|
and hasattr(child.children, "id") |
|
|
and child.children.id == "treemap-group-by" |
|
|
): |
|
|
hidden_input_found = True |
|
|
hidden_input_value = child.children.value |
|
|
|
|
|
|
|
|
assert graph_found, "Graph component not found" |
|
|
assert hidden_div_found, "Hidden div not found" |
|
|
assert hidden_input_found, "Hidden input not found" |
|
|
|
|
|
|
|
|
assert hidden_input_value == "ticker", "Hidden input value is incorrect" |
|
|
|
|
|
def test_create_dashboard_section(self): |
|
|
"""Test that dashboard section can be created correctly.""" |
|
|
|
|
|
dashboard = create_dashboard_section() |
|
|
|
|
|
|
|
|
assert isinstance(dashboard, html.Div) |
|
|
assert dashboard.id == "dashboard-section" |
|
|
|
|
|
|
|
|
assert len(dashboard.children) == 2 |
|
|
|
|
|
|
|
|
charts_section = dashboard.children[1] |
|
|
assert isinstance(charts_section, dbc.Card) |
|
|
|
|
|
|
|
|
collapse = charts_section.children[1] |
|
|
assert collapse.id == "charts-collapse" |
|
|
assert collapse.is_open is True |
|
|
|
|
|
|
|
|
card_body = collapse.children |
|
|
assert isinstance(card_body, dbc.CardBody) |
|
|
|
|
|
|
|
|
chart_cards = card_body.children |
|
|
assert len(chart_cards) == 3 |
|
|
|
|
|
|
|
|
assert "Market Exposure" in str(chart_cards[0]) |
|
|
assert "Position Size by Exposure" in str(chart_cards[1]) |
|
|
assert "Portfolio Allocation" in str(chart_cards[2]) |
|
|
|
|
|
|
|
|
class TestChartDataTransformation: |
|
|
"""Tests for chart data transformation functions.""" |
|
|
|
|
|
@pytest.fixture |
|
|
def mock_portfolio_summary(self): |
|
|
"""Create a mock portfolio summary for testing.""" |
|
|
|
|
|
long_exposure = ExposureBreakdown( |
|
|
stock_exposure=10000.0, |
|
|
option_delta_exposure=2000.0, |
|
|
total_exposure=12000.0, |
|
|
total_beta_adjusted=14400.0, |
|
|
) |
|
|
|
|
|
short_exposure = ExposureBreakdown( |
|
|
stock_exposure=-5000.0, |
|
|
option_delta_exposure=-1000.0, |
|
|
total_exposure=-6000.0, |
|
|
total_beta_adjusted=-7200.0, |
|
|
) |
|
|
|
|
|
options_exposure = ExposureBreakdown( |
|
|
stock_exposure=0.0, |
|
|
option_delta_exposure=1000.0, |
|
|
total_exposure=1000.0, |
|
|
total_beta_adjusted=1200.0, |
|
|
) |
|
|
|
|
|
|
|
|
return PortfolioSummary( |
|
|
net_market_exposure=6000.0, |
|
|
portfolio_beta=1.2, |
|
|
long_exposure=long_exposure, |
|
|
short_exposure=short_exposure, |
|
|
options_exposure=options_exposure, |
|
|
short_percentage=0.33, |
|
|
cash_like_value=4000.0, |
|
|
cash_like_count=1, |
|
|
cash_percentage=0.4, |
|
|
portfolio_estimate_value=10000.0, |
|
|
) |
|
|
|
|
|
@pytest.fixture |
|
|
def mock_portfolio_groups(self): |
|
|
"""Create mock portfolio groups for testing.""" |
|
|
|
|
|
aapl_stock = StockPosition( |
|
|
ticker="AAPL", |
|
|
quantity=100, |
|
|
market_exposure=5000.0, |
|
|
beta=1.2, |
|
|
beta_adjusted_exposure=6000.0, |
|
|
) |
|
|
|
|
|
msft_stock = StockPosition( |
|
|
ticker="MSFT", |
|
|
quantity=50, |
|
|
market_exposure=3000.0, |
|
|
beta=1.1, |
|
|
beta_adjusted_exposure=3300.0, |
|
|
) |
|
|
|
|
|
|
|
|
aapl_option = OptionPosition( |
|
|
ticker="AAPL", |
|
|
position_type="option", |
|
|
quantity=10, |
|
|
market_exposure=1000.0, |
|
|
beta=1.2, |
|
|
beta_adjusted_exposure=1200.0, |
|
|
strike=150.0, |
|
|
expiry="2023-01-01", |
|
|
option_type="CALL", |
|
|
delta=0.7, |
|
|
delta_exposure=700.0, |
|
|
notional_value=10000.0, |
|
|
underlying_beta=1.2, |
|
|
) |
|
|
|
|
|
|
|
|
aapl_group = PortfolioGroup( |
|
|
ticker="AAPL", |
|
|
stock_position=aapl_stock, |
|
|
option_positions=[aapl_option], |
|
|
net_exposure=5700.0, |
|
|
beta=1.2, |
|
|
beta_adjusted_exposure=6840.0, |
|
|
total_delta_exposure=700.0, |
|
|
options_delta_exposure=700.0, |
|
|
) |
|
|
|
|
|
msft_group = PortfolioGroup( |
|
|
ticker="MSFT", |
|
|
stock_position=msft_stock, |
|
|
option_positions=[], |
|
|
net_exposure=3000.0, |
|
|
beta=1.1, |
|
|
beta_adjusted_exposure=3300.0, |
|
|
total_delta_exposure=0.0, |
|
|
options_delta_exposure=0.0, |
|
|
) |
|
|
|
|
|
return [aapl_group, msft_group] |
|
|
|
|
|
def test_transform_for_exposure_chart(self, mock_portfolio_summary): |
|
|
"""Test that exposure chart data is transformed correctly.""" |
|
|
|
|
|
chart_data = transform_for_exposure_chart( |
|
|
mock_portfolio_summary, use_beta_adjusted=True |
|
|
) |
|
|
|
|
|
|
|
|
assert "data" in chart_data |
|
|
assert "layout" in chart_data |
|
|
|
|
|
|
|
|
data = chart_data["data"] |
|
|
assert len(data) == 1 |
|
|
|
|
|
|
|
|
trace = data[0] |
|
|
assert trace["type"] == "bar" |
|
|
assert len(trace["x"]) == 4 |
|
|
assert len(trace["y"]) == 4 |
|
|
|
|
|
|
|
|
chart_data = transform_for_exposure_chart( |
|
|
mock_portfolio_summary, use_beta_adjusted=False |
|
|
) |
|
|
|
|
|
|
|
|
assert "Beta-Adjusted" not in chart_data["layout"]["title"] |
|
|
|
|
|
def test_transform_for_treemap(self, mock_portfolio_groups): |
|
|
"""Test that treemap data is transformed correctly.""" |
|
|
chart_data = transform_for_treemap(mock_portfolio_groups) |
|
|
|
|
|
|
|
|
assert "data" in chart_data |
|
|
assert "layout" in chart_data |
|
|
|
|
|
|
|
|
data = chart_data["data"] |
|
|
assert len(data) == 1 |
|
|
|
|
|
|
|
|
trace = data[0] |
|
|
assert trace["type"] == "treemap" |
|
|
|
|
|
|
|
|
assert "Portfolio" in trace["labels"] |
|
|
assert "AAPL" in trace["labels"] |
|
|
assert "MSFT" in trace["labels"] |
|
|
|
|
|
|
|
|
values = trace["values"] |
|
|
assert len(values) > 2 |
|
|
|
|
|
|
|
|
class TestChartIntegration: |
|
|
"""Integration tests for chart components.""" |
|
|
|
|
|
@pytest.fixture |
|
|
def mock_portfolio_summary(self): |
|
|
"""Create a mock portfolio summary for testing.""" |
|
|
|
|
|
long_exposure = ExposureBreakdown( |
|
|
stock_exposure=10000.0, |
|
|
option_delta_exposure=2000.0, |
|
|
total_exposure=12000.0, |
|
|
total_beta_adjusted=14400.0, |
|
|
) |
|
|
|
|
|
short_exposure = ExposureBreakdown( |
|
|
stock_exposure=-5000.0, |
|
|
option_delta_exposure=-1000.0, |
|
|
total_exposure=-6000.0, |
|
|
total_beta_adjusted=-7200.0, |
|
|
) |
|
|
|
|
|
options_exposure = ExposureBreakdown( |
|
|
stock_exposure=0.0, |
|
|
option_delta_exposure=1000.0, |
|
|
total_exposure=1000.0, |
|
|
total_beta_adjusted=1200.0, |
|
|
) |
|
|
|
|
|
|
|
|
return PortfolioSummary( |
|
|
net_market_exposure=6000.0, |
|
|
portfolio_beta=1.2, |
|
|
long_exposure=long_exposure, |
|
|
short_exposure=short_exposure, |
|
|
options_exposure=options_exposure, |
|
|
short_percentage=0.33, |
|
|
cash_like_value=4000.0, |
|
|
cash_like_count=1, |
|
|
cash_percentage=0.4, |
|
|
portfolio_estimate_value=10000.0, |
|
|
) |
|
|
|
|
|
@pytest.fixture |
|
|
def mock_portfolio_groups(self): |
|
|
"""Create mock portfolio groups for testing.""" |
|
|
|
|
|
aapl_stock = StockPosition( |
|
|
ticker="AAPL", |
|
|
quantity=100, |
|
|
market_exposure=5000.0, |
|
|
beta=1.2, |
|
|
beta_adjusted_exposure=6000.0, |
|
|
) |
|
|
|
|
|
msft_stock = StockPosition( |
|
|
ticker="MSFT", |
|
|
quantity=50, |
|
|
market_exposure=3000.0, |
|
|
beta=1.1, |
|
|
beta_adjusted_exposure=3300.0, |
|
|
) |
|
|
|
|
|
|
|
|
aapl_option = OptionPosition( |
|
|
ticker="AAPL", |
|
|
position_type="option", |
|
|
quantity=10, |
|
|
market_exposure=1000.0, |
|
|
beta=1.2, |
|
|
beta_adjusted_exposure=1200.0, |
|
|
strike=150.0, |
|
|
expiry="2023-01-01", |
|
|
option_type="CALL", |
|
|
delta=0.7, |
|
|
delta_exposure=700.0, |
|
|
notional_value=10000.0, |
|
|
underlying_beta=1.2, |
|
|
) |
|
|
|
|
|
|
|
|
aapl_group = PortfolioGroup( |
|
|
ticker="AAPL", |
|
|
stock_position=aapl_stock, |
|
|
option_positions=[aapl_option], |
|
|
net_exposure=5700.0, |
|
|
beta=1.2, |
|
|
beta_adjusted_exposure=6840.0, |
|
|
total_delta_exposure=700.0, |
|
|
options_delta_exposure=700.0, |
|
|
) |
|
|
|
|
|
msft_group = PortfolioGroup( |
|
|
ticker="MSFT", |
|
|
stock_position=msft_stock, |
|
|
option_positions=[], |
|
|
net_exposure=3000.0, |
|
|
beta=1.1, |
|
|
beta_adjusted_exposure=3300.0, |
|
|
total_delta_exposure=0.0, |
|
|
options_delta_exposure=0.0, |
|
|
) |
|
|
|
|
|
return [aapl_group, msft_group] |
|
|
|
|
|
def test_chart_callbacks_registration(self): |
|
|
"""Test that chart callbacks are properly registered.""" |
|
|
|
|
|
app = create_app() |
|
|
|
|
|
|
|
|
callbacks = app.callback_map |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
exposure_chart_callback_found = False |
|
|
for callback_id in callbacks.keys(): |
|
|
if "exposure-chart.figure" in callback_id: |
|
|
exposure_chart_callback_found = True |
|
|
break |
|
|
|
|
|
assert exposure_chart_callback_found, "Exposure chart callback not registered" |
|
|
|
|
|
|
|
|
treemap_callback_found = False |
|
|
for callback_id in callbacks.keys(): |
|
|
if "position-treemap.figure" in callback_id: |
|
|
treemap_callback_found = True |
|
|
break |
|
|
|
|
|
assert treemap_callback_found, "Position treemap callback not registered" |
|
|
|
|
|
def test_dashboard_section_in_app_layout(self): |
|
|
"""Test that dashboard section is included in the app layout.""" |
|
|
|
|
|
app = create_app() |
|
|
|
|
|
|
|
|
layout = app.layout |
|
|
|
|
|
|
|
|
expected_ids = [ |
|
|
|
|
|
"exposure-chart", |
|
|
"position-treemap", |
|
|
"allocations-chart", |
|
|
"charts-collapse", |
|
|
"charts-collapse-button", |
|
|
"charts-collapse-icon", |
|
|
"exposure-net-btn", |
|
|
"exposure-beta-btn", |
|
|
"treemap-group-by", |
|
|
] |
|
|
|
|
|
|
|
|
found_ids = set() |
|
|
|
|
|
def find_components(component): |
|
|
"""Recursively find all components with IDs in the layout.""" |
|
|
if hasattr(component, "id") and component.id is not None: |
|
|
found_ids.add(component.id) |
|
|
|
|
|
if hasattr(component, "children"): |
|
|
if isinstance(component.children, list): |
|
|
for child in component.children: |
|
|
find_components(child) |
|
|
elif component.children is not None: |
|
|
find_components(component.children) |
|
|
|
|
|
|
|
|
find_components(layout) |
|
|
|
|
|
|
|
|
for component_id in expected_ids: |
|
|
assert component_id in found_ids, ( |
|
|
f"Component {component_id} not found in layout" |
|
|
) |
|
|
|