Skip to content

Visualization Functions

Plotly chart generation functions.

CEFR Charts

fundedness.viz.waterfall.create_cefr_waterfall(cefr_result, show_liability=True, title='CEFR Calculation Breakdown', height=500, width=None)

Create a waterfall chart showing CEFR calculation breakdown.

Shows: Gross Assets → Tax Haircut → Liquidity Haircut → Reliability Haircut → Net Assets Optionally shows liability comparison.

Parameters:

Name Type Description Default
cefr_result CEFRResult

CEFR calculation result

required
show_liability bool

Whether to show liability PV for comparison

True
title str

Chart title

'CEFR Calculation Breakdown'
height int

Chart height in pixels

500
width int | None

Chart width in pixels (None = responsive)

None

Returns:

Type Description
Figure

Plotly Figure object

Source code in fundedness/viz/waterfall.py
  9
 10
 11
 12
 13
 14
 15
 16
 17
 18
 19
 20
 21
 22
 23
 24
 25
 26
 27
 28
 29
 30
 31
 32
 33
 34
 35
 36
 37
 38
 39
 40
 41
 42
 43
 44
 45
 46
 47
 48
 49
 50
 51
 52
 53
 54
 55
 56
 57
 58
 59
 60
 61
 62
 63
 64
 65
 66
 67
 68
 69
 70
 71
 72
 73
 74
 75
 76
 77
 78
 79
 80
 81
 82
 83
 84
 85
 86
 87
 88
 89
 90
 91
 92
 93
 94
 95
 96
 97
 98
 99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
def create_cefr_waterfall(
    cefr_result: CEFRResult,
    show_liability: bool = True,
    title: str = "CEFR Calculation Breakdown",
    height: int = 500,
    width: int | None = None,
) -> go.Figure:
    """Create a waterfall chart showing CEFR calculation breakdown.

    Shows: Gross Assets → Tax Haircut → Liquidity Haircut → Reliability Haircut → Net Assets
    Optionally shows liability comparison.

    Args:
        cefr_result: CEFR calculation result
        show_liability: Whether to show liability PV for comparison
        title: Chart title
        height: Chart height in pixels
        width: Chart width in pixels (None = responsive)

    Returns:
        Plotly Figure object
    """
    # Prepare data
    labels = [
        "Gross Assets",
        "Tax Haircut",
        "Liquidity Haircut",
        "Reliability Haircut",
        "Net Assets",
    ]
    values = [
        cefr_result.gross_assets,
        -cefr_result.total_tax_haircut,
        -cefr_result.total_liquidity_haircut,
        -cefr_result.total_reliability_haircut,
        cefr_result.net_assets,
    ]
    measures = ["absolute", "relative", "relative", "relative", "total"]

    if show_liability and cefr_result.liability_pv > 0:
        labels.append("Liability PV")
        values.append(cefr_result.liability_pv)
        measures.append("absolute")

    # Create waterfall
    fig = go.Figure(
        go.Waterfall(
            name="CEFR",
            orientation="v",
            measure=measures,
            x=labels,
            textposition="outside",
            text=[f"${abs(v):,.0f}" for v in values],
            y=values,
            connector={"line": {"color": COLORS["neutral_light"]}},
            increasing={"marker": {"color": WATERFALL_COLORS["increase"]}},
            decreasing={"marker": {"color": WATERFALL_COLORS["decrease"]}},
            totals={"marker": {"color": WATERFALL_COLORS["total"]}},
            hovertemplate=(
                "<b>%{x}</b><br>"
                "Value: $%{y:,.0f}<br>"
                "<extra></extra>"
            ),
        )
    )

    # Add liability reference line if shown
    if show_liability and cefr_result.liability_pv > 0:
        fig.add_hline(
            y=cefr_result.liability_pv,
            line_dash="dash",
            line_color=COLORS["danger_secondary"],
            annotation_text=f"Liability PV: ${cefr_result.liability_pv:,.0f}",
            annotation_position="top right",
        )

    # Apply layout
    layout = get_plotly_layout_defaults()
    layout.update({
        "title": {"text": title},
        "height": height,
        "yaxis": {
            "title": "Value ($)",
            "tickformat": "$,.0f",
            "gridcolor": COLORS["neutral_light"],
        },
        "xaxis": {
            "title": "",
        },
        "showlegend": False,
    })

    if width:
        layout["width"] = width

    fig.update_layout(**layout)

    # Add CEFR annotation
    cefr_text = f"CEFR: {cefr_result.cefr:.2f}"
    if cefr_result.is_funded:
        cefr_color = COLORS["success_primary"]
    else:
        cefr_color = COLORS["danger_primary"]

    fig.add_annotation(
        x=0.02,
        y=0.98,
        xref="paper",
        yref="paper",
        text=f"<b>{cefr_text}</b>",
        showarrow=False,
        font={"size": 16, "color": cefr_color},
        bgcolor=COLORS["background"],
        bordercolor=cefr_color,
        borderwidth=2,
        borderpad=8,
    )

    return fig

Simulation Charts

fundedness.viz.fan_chart.create_fan_chart(years, percentiles, title='Wealth Projection', y_label='Portfolio Value ($)', show_median_line=True, show_floor=None, height=500, width=None)

Create a fan chart showing percentile bands over time.

Parameters:

Name Type Description Default
years ndarray

Array of year values (x-axis)

required
percentiles dict[str, ndarray]

Dictionary mapping percentile names to value arrays Expected keys: "P10", "P25", "P50", "P75", "P90"

required
title str

Chart title

'Wealth Projection'
y_label str

Y-axis label

'Portfolio Value ($)'
show_median_line bool

Whether to show a distinct median line

True
show_floor float | None

Optional floor value to show as horizontal line

None
height int

Chart height in pixels

500
width int | None

Chart width in pixels (None = responsive)

None

Returns:

Type Description
Figure

Plotly Figure object

Source code in fundedness/viz/fan_chart.py
  9
 10
 11
 12
 13
 14
 15
 16
 17
 18
 19
 20
 21
 22
 23
 24
 25
 26
 27
 28
 29
 30
 31
 32
 33
 34
 35
 36
 37
 38
 39
 40
 41
 42
 43
 44
 45
 46
 47
 48
 49
 50
 51
 52
 53
 54
 55
 56
 57
 58
 59
 60
 61
 62
 63
 64
 65
 66
 67
 68
 69
 70
 71
 72
 73
 74
 75
 76
 77
 78
 79
 80
 81
 82
 83
 84
 85
 86
 87
 88
 89
 90
 91
 92
 93
 94
 95
 96
 97
 98
 99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
def create_fan_chart(
    years: np.ndarray,
    percentiles: dict[str, np.ndarray],
    title: str = "Wealth Projection",
    y_label: str = "Portfolio Value ($)",
    show_median_line: bool = True,
    show_floor: float | None = None,
    height: int = 500,
    width: int | None = None,
) -> go.Figure:
    """Create a fan chart showing percentile bands over time.

    Args:
        years: Array of year values (x-axis)
        percentiles: Dictionary mapping percentile names to value arrays
            Expected keys: "P10", "P25", "P50", "P75", "P90"
        title: Chart title
        y_label: Y-axis label
        show_median_line: Whether to show a distinct median line
        show_floor: Optional floor value to show as horizontal line
        height: Chart height in pixels
        width: Chart width in pixels (None = responsive)

    Returns:
        Plotly Figure object
    """
    fig = go.Figure()

    # P10-P90 band (outermost)
    if "P10" in percentiles and "P90" in percentiles:
        fig.add_trace(
            go.Scatter(
                x=np.concatenate([years, years[::-1]]),
                y=np.concatenate([percentiles["P90"], percentiles["P10"][::-1]]),
                fill="toself",
                fillcolor="rgba(52, 152, 219, 0.15)",
                line={"width": 0},
                name="P10-P90 Range",
                hoverinfo="skip",
                showlegend=True,
            )
        )

    # P25-P75 band (middle)
    if "P25" in percentiles and "P75" in percentiles:
        fig.add_trace(
            go.Scatter(
                x=np.concatenate([years, years[::-1]]),
                y=np.concatenate([percentiles["P75"], percentiles["P25"][::-1]]),
                fill="toself",
                fillcolor="rgba(52, 152, 219, 0.3)",
                line={"width": 0},
                name="P25-P75 Range",
                hoverinfo="skip",
                showlegend=True,
            )
        )

    # Percentile lines
    percentile_styles = {
        "P90": {"color": COLORS["success_secondary"], "dash": "dot", "width": 1},
        "P75": {"color": COLORS["success_primary"], "dash": "dash", "width": 1},
        "P50": {"color": COLORS["wealth_primary"], "dash": "solid", "width": 3},
        "P25": {"color": COLORS["warning_primary"], "dash": "dash", "width": 1},
        "P10": {"color": COLORS["danger_secondary"], "dash": "dot", "width": 1},
    }

    for pct_name, values in percentiles.items():
        if pct_name in percentile_styles:
            style = percentile_styles[pct_name]
            is_median = pct_name == "P50"

            fig.add_trace(
                go.Scatter(
                    x=years,
                    y=values,
                    mode="lines",
                    name=pct_name,
                    line={
                        "color": style["color"],
                        "dash": style["dash"],
                        "width": style["width"] if not (is_median and show_median_line) else 3,
                    },
                    hovertemplate=(
                        f"<b>{pct_name}</b><br>"
                        "Year: %{x}<br>"
                        "Value: $%{y:,.0f}<br>"
                        "<extra></extra>"
                    ),
                    showlegend=not (pct_name in ["P90", "P75", "P25", "P10"]),
                )
            )

    # Add floor line if specified
    if show_floor is not None:
        fig.add_hline(
            y=show_floor,
            line_dash="dash",
            line_color=COLORS["danger_primary"],
            line_width=2,
            annotation_text=f"Floor: ${show_floor:,.0f}",
            annotation_position="top right",
            annotation_font_color=COLORS["danger_primary"],
        )

    # Apply layout
    layout = get_plotly_layout_defaults()
    layout.update({
        "title": {"text": title},
        "height": height,
        "xaxis": {
            "title": "Year",
            "gridcolor": COLORS["neutral_light"],
            "dtick": 5,
        },
        "yaxis": {
            "title": y_label,
            "tickformat": "$,.0f",
            "gridcolor": COLORS["neutral_light"],
            "rangemode": "tozero",
        },
        "legend": {
            "orientation": "h",
            "yanchor": "bottom",
            "y": 1.02,
            "xanchor": "right",
            "x": 1,
        },
        "hovermode": "x unified",
    })

    if width:
        layout["width"] = width

    fig.update_layout(**layout)

    return fig

fundedness.viz.survival.create_survival_curve(years, survival_prob, floor_survival_prob=None, title='Portfolio Survival Probability', threshold_years=None, height=450, width=None)

Create a survival curve showing probability of not running out of money.

Parameters:

Name Type Description Default
years ndarray

Array of year values

required
survival_prob ndarray

Probability of portfolio survival at each year (above ruin)

required
floor_survival_prob ndarray | None

Probability of being above spending floor at each year

None
title str

Chart title

'Portfolio Survival Probability'
threshold_years list[int] | None

Years to highlight with vertical lines (e.g., [20, 30])

None
height int

Chart height in pixels

450
width int | None

Chart width in pixels

None

Returns:

Type Description
Figure

Plotly Figure object

Source code in fundedness/viz/survival.py
  9
 10
 11
 12
 13
 14
 15
 16
 17
 18
 19
 20
 21
 22
 23
 24
 25
 26
 27
 28
 29
 30
 31
 32
 33
 34
 35
 36
 37
 38
 39
 40
 41
 42
 43
 44
 45
 46
 47
 48
 49
 50
 51
 52
 53
 54
 55
 56
 57
 58
 59
 60
 61
 62
 63
 64
 65
 66
 67
 68
 69
 70
 71
 72
 73
 74
 75
 76
 77
 78
 79
 80
 81
 82
 83
 84
 85
 86
 87
 88
 89
 90
 91
 92
 93
 94
 95
 96
 97
 98
 99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
def create_survival_curve(
    years: np.ndarray,
    survival_prob: np.ndarray,
    floor_survival_prob: np.ndarray | None = None,
    title: str = "Portfolio Survival Probability",
    threshold_years: list[int] | None = None,
    height: int = 450,
    width: int | None = None,
) -> go.Figure:
    """Create a survival curve showing probability of not running out of money.

    Args:
        years: Array of year values
        survival_prob: Probability of portfolio survival at each year (above ruin)
        floor_survival_prob: Probability of being above spending floor at each year
        title: Chart title
        threshold_years: Years to highlight with vertical lines (e.g., [20, 30])
        height: Chart height in pixels
        width: Chart width in pixels

    Returns:
        Plotly Figure object
    """
    fig = go.Figure()

    # Main survival curve (above ruin)
    fig.add_trace(
        go.Scatter(
            x=years,
            y=survival_prob * 100,
            mode="lines",
            name="Above Ruin",
            line={
                "color": COLORS["wealth_primary"],
                "width": 3,
            },
            fill="tozeroy",
            fillcolor="rgba(52, 152, 219, 0.2)",
            hovertemplate=(
                "<b>Year %{x}</b><br>"
                "Survival Probability: %{y:.1f}%<br>"
                "<extra></extra>"
            ),
        )
    )

    # Floor survival curve if provided
    if floor_survival_prob is not None:
        fig.add_trace(
            go.Scatter(
                x=years,
                y=floor_survival_prob * 100,
                mode="lines",
                name="Above Floor",
                line={
                    "color": COLORS["success_primary"],
                    "width": 2,
                    "dash": "dash",
                },
                hovertemplate=(
                    "<b>Year %{x}</b><br>"
                    "Floor Probability: %{y:.1f}%<br>"
                    "<extra></extra>"
                ),
            )
        )

    # Add threshold year markers
    if threshold_years:
        for year in threshold_years:
            if year <= years[-1]:
                idx = np.searchsorted(years, year)
                if idx < len(survival_prob):
                    prob = survival_prob[idx] * 100
                    fig.add_vline(
                        x=year,
                        line_dash="dot",
                        line_color=COLORS["neutral_primary"],
                        annotation_text=f"Year {year}: {prob:.0f}%",
                        annotation_position="top",
                    )

    # Add horizontal reference lines
    for prob_level, label in [(90, "90%"), (75, "75%"), (50, "50%")]:
        fig.add_hline(
            y=prob_level,
            line_dash="dot",
            line_color=COLORS["neutral_light"],
            line_width=1,
        )

    # Apply layout
    layout = get_plotly_layout_defaults()
    layout.update({
        "title": {"text": title},
        "height": height,
        "xaxis": {
            "title": "Years",
            "gridcolor": COLORS["neutral_light"],
            "dtick": 5,
        },
        "yaxis": {
            "title": "Probability (%)",
            "range": [0, 105],
            "gridcolor": COLORS["neutral_light"],
            "ticksuffix": "%",
        },
        "legend": {
            "orientation": "h",
            "yanchor": "bottom",
            "y": 1.02,
            "xanchor": "right",
            "x": 1,
        },
    })

    if width:
        layout["width"] = width

    fig.update_layout(**layout)

    return fig

fundedness.viz.histogram.create_time_distribution_histogram(time_to_event, event_name='Ruin', planning_horizon=None, percentiles_to_show=None, title=None, height=400, width=None)

Create a histogram of time-to-event distribution.

Parameters:

Name Type Description Default
time_to_event ndarray

Array of time values (years until event, inf for no event)

required
event_name str

Name of the event (e.g., "Ruin", "Floor Breach")

'Ruin'
planning_horizon int | None

Maximum planning horizon (for x-axis)

None
percentiles_to_show list[int] | None

Percentiles to mark (e.g., [10, 50, 90])

None
title str | None

Chart title (auto-generated if None)

None
height int

Chart height in pixels

400
width int | None

Chart width in pixels

None

Returns:

Type Description
Figure

Plotly Figure object

Source code in fundedness/viz/histogram.py
  9
 10
 11
 12
 13
 14
 15
 16
 17
 18
 19
 20
 21
 22
 23
 24
 25
 26
 27
 28
 29
 30
 31
 32
 33
 34
 35
 36
 37
 38
 39
 40
 41
 42
 43
 44
 45
 46
 47
 48
 49
 50
 51
 52
 53
 54
 55
 56
 57
 58
 59
 60
 61
 62
 63
 64
 65
 66
 67
 68
 69
 70
 71
 72
 73
 74
 75
 76
 77
 78
 79
 80
 81
 82
 83
 84
 85
 86
 87
 88
 89
 90
 91
 92
 93
 94
 95
 96
 97
 98
 99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
def create_time_distribution_histogram(
    time_to_event: np.ndarray,
    event_name: str = "Ruin",
    planning_horizon: int | None = None,
    percentiles_to_show: list[int] | None = None,
    title: str | None = None,
    height: int = 400,
    width: int | None = None,
) -> go.Figure:
    """Create a histogram of time-to-event distribution.

    Args:
        time_to_event: Array of time values (years until event, inf for no event)
        event_name: Name of the event (e.g., "Ruin", "Floor Breach")
        planning_horizon: Maximum planning horizon (for x-axis)
        percentiles_to_show: Percentiles to mark (e.g., [10, 50, 90])
        title: Chart title (auto-generated if None)
        height: Chart height in pixels
        width: Chart width in pixels

    Returns:
        Plotly Figure object
    """
    # Filter out infinite values (no event occurred)
    finite_times = time_to_event[np.isfinite(time_to_event)]
    never_occurred_count = np.sum(~np.isfinite(time_to_event))
    total_count = len(time_to_event)

    if title is None:
        title = f"Time to {event_name} Distribution"

    fig = go.Figure()

    if len(finite_times) > 0:
        # Determine bins
        max_time = planning_horizon or int(np.ceil(finite_times.max()))
        bins = np.arange(0, max_time + 2, 1)

        # Create histogram
        fig.add_trace(
            go.Histogram(
                x=finite_times,
                xbins={"start": 0, "end": max_time + 1, "size": 1},
                marker_color=COLORS["danger_primary"],
                opacity=0.7,
                name=f"Years to {event_name}",
                hovertemplate=(
                    "<b>Year %{x}</b><br>"
                    "Count: %{y}<br>"
                    "<extra></extra>"
                ),
            )
        )

        # Add percentile lines
        if percentiles_to_show:
            percentile_colors = {
                10: COLORS["danger_secondary"],
                25: COLORS["warning_secondary"],
                50: COLORS["wealth_primary"],
                75: COLORS["success_secondary"],
                90: COLORS["success_primary"],
            }

            for pct in percentiles_to_show:
                value = np.percentile(finite_times, pct)
                color = percentile_colors.get(pct, COLORS["neutral_primary"])
                fig.add_vline(
                    x=value,
                    line_dash="dash",
                    line_color=color,
                    line_width=2,
                    annotation_text=f"P{pct}: {value:.1f}y",
                    annotation_position="top",
                    annotation_font_color=color,
                )

    # Add annotation for "never occurred" count
    if never_occurred_count > 0:
        never_pct = never_occurred_count / total_count * 100
        fig.add_annotation(
            x=0.98,
            y=0.98,
            xref="paper",
            yref="paper",
            text=f"Never {event_name.lower()}ed: {never_pct:.1f}%<br>({never_occurred_count:,} paths)",
            showarrow=False,
            font={"size": 12, "color": COLORS["success_primary"]},
            bgcolor=COLORS["background"],
            bordercolor=COLORS["success_primary"],
            borderwidth=1,
            borderpad=6,
            align="right",
        )

    # Apply layout
    layout = get_plotly_layout_defaults()
    layout.update({
        "title": {"text": title},
        "height": height,
        "xaxis": {
            "title": f"Years to {event_name}",
            "gridcolor": COLORS["neutral_light"],
            "dtick": 5,
        },
        "yaxis": {
            "title": "Number of Paths",
            "gridcolor": COLORS["neutral_light"],
        },
        "showlegend": False,
        "bargap": 0.1,
    })

    if width:
        layout["width"] = width

    fig.update_layout(**layout)

    return fig

Comparison Charts

fundedness.viz.comparison.create_strategy_comparison_chart(years, strategies, metric='wealth_median', title='Strategy Comparison', y_label='Portfolio Value ($)', height=500, width=None)

Create a line chart comparing multiple strategies.

Parameters:

Name Type Description Default
years ndarray

Array of year values

required
strategies dict[str, dict[str, ndarray]]

Dictionary mapping strategy name to metrics dict Each metrics dict should contain arrays for the requested metric

required
metric str

Which metric to plot (e.g., "wealth_median", "spending_median")

'wealth_median'
title str

Chart title

'Strategy Comparison'
y_label str

Y-axis label

'Portfolio Value ($)'
height int

Chart height in pixels

500
width int | None

Chart width in pixels

None

Returns:

Type Description
Figure

Plotly Figure object

Source code in fundedness/viz/comparison.py
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
def create_strategy_comparison_chart(
    years: np.ndarray,
    strategies: dict[str, dict[str, np.ndarray]],
    metric: str = "wealth_median",
    title: str = "Strategy Comparison",
    y_label: str = "Portfolio Value ($)",
    height: int = 500,
    width: int | None = None,
) -> go.Figure:
    """Create a line chart comparing multiple strategies.

    Args:
        years: Array of year values
        strategies: Dictionary mapping strategy name to metrics dict
            Each metrics dict should contain arrays for the requested metric
        metric: Which metric to plot (e.g., "wealth_median", "spending_median")
        title: Chart title
        y_label: Y-axis label
        height: Chart height in pixels
        width: Chart width in pixels

    Returns:
        Plotly Figure object
    """
    fig = go.Figure()

    for i, (name, metrics) in enumerate(strategies.items()):
        if metric not in metrics:
            continue

        color = STRATEGY_COLORS[i % len(STRATEGY_COLORS)]

        fig.add_trace(
            go.Scatter(
                x=years,
                y=metrics[metric],
                mode="lines",
                name=name,
                line={"color": color, "width": 2},
                hovertemplate=(
                    f"<b>{name}</b><br>"
                    "Year: %{x}<br>"
                    "Value: $%{y:,.0f}<br>"
                    "<extra></extra>"
                ),
            )
        )

    # Apply layout
    layout = get_plotly_layout_defaults()
    layout.update({
        "title": {"text": title},
        "height": height,
        "xaxis": {
            "title": "Year",
            "gridcolor": COLORS["neutral_light"],
            "dtick": 5,
        },
        "yaxis": {
            "title": y_label,
            "tickformat": "$,.0f",
            "gridcolor": COLORS["neutral_light"],
        },
        "legend": {
            "orientation": "h",
            "yanchor": "bottom",
            "y": 1.02,
            "xanchor": "right",
            "x": 1,
        },
        "hovermode": "x unified",
    })

    if width:
        layout["width"] = width

    fig.update_layout(**layout)

    return fig

Sensitivity Charts

fundedness.viz.tornado.create_tornado_chart(parameters, low_values, high_values, base_value, parameter_labels=None, title='Sensitivity Analysis', value_label='CEFR', height=500, width=None)

Create a tornado chart for sensitivity analysis.

Parameters:

Name Type Description Default
parameters list[str]

List of parameter names

required
low_values list[float]

Outcome values when parameter is at low end

required
high_values list[float]

Outcome values when parameter is at high end

required
base_value float

Baseline outcome value

required
parameter_labels list[str] | None

Display labels for parameters (uses parameters if None)

None
title str

Chart title

'Sensitivity Analysis'
value_label str

Label for the outcome metric

'CEFR'
height int

Chart height in pixels

500
width int | None

Chart width in pixels

None

Returns:

Type Description
Figure

Plotly Figure object

Source code in fundedness/viz/tornado.py
  8
  9
 10
 11
 12
 13
 14
 15
 16
 17
 18
 19
 20
 21
 22
 23
 24
 25
 26
 27
 28
 29
 30
 31
 32
 33
 34
 35
 36
 37
 38
 39
 40
 41
 42
 43
 44
 45
 46
 47
 48
 49
 50
 51
 52
 53
 54
 55
 56
 57
 58
 59
 60
 61
 62
 63
 64
 65
 66
 67
 68
 69
 70
 71
 72
 73
 74
 75
 76
 77
 78
 79
 80
 81
 82
 83
 84
 85
 86
 87
 88
 89
 90
 91
 92
 93
 94
 95
 96
 97
 98
 99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
def create_tornado_chart(
    parameters: list[str],
    low_values: list[float],
    high_values: list[float],
    base_value: float,
    parameter_labels: list[str] | None = None,
    title: str = "Sensitivity Analysis",
    value_label: str = "CEFR",
    height: int = 500,
    width: int | None = None,
) -> go.Figure:
    """Create a tornado chart for sensitivity analysis.

    Args:
        parameters: List of parameter names
        low_values: Outcome values when parameter is at low end
        high_values: Outcome values when parameter is at high end
        base_value: Baseline outcome value
        parameter_labels: Display labels for parameters (uses parameters if None)
        title: Chart title
        value_label: Label for the outcome metric
        height: Chart height in pixels
        width: Chart width in pixels

    Returns:
        Plotly Figure object
    """
    if parameter_labels is None:
        parameter_labels = parameters

    # Calculate ranges and sort by total impact
    impacts = []
    for i, (low, high) in enumerate(zip(low_values, high_values)):
        low_impact = base_value - low
        high_impact = high - base_value
        total_impact = abs(high - low)
        impacts.append({
            "param": parameter_labels[i],
            "low": low,
            "high": high,
            "low_impact": low_impact,
            "high_impact": high_impact,
            "total_impact": total_impact,
        })

    # Sort by total impact (largest first)
    impacts.sort(key=lambda x: x["total_impact"], reverse=True)

    fig = go.Figure()

    # Low impact bars (extending left from base)
    fig.add_trace(
        go.Bar(
            y=[i["param"] for i in impacts],
            x=[-(base_value - i["low"]) for i in impacts],
            orientation="h",
            name="Low Scenario",
            marker_color=COLORS["danger_primary"],
            text=[f"{i['low']:.2f}" for i in impacts],
            textposition="outside",
            hovertemplate=(
                "<b>%{y}</b><br>"
                f"Low {value_label}: " + "%{customdata:.2f}<br>"
                "<extra></extra>"
            ),
            customdata=[i["low"] for i in impacts],
        )
    )

    # High impact bars (extending right from base)
    fig.add_trace(
        go.Bar(
            y=[i["param"] for i in impacts],
            x=[i["high"] - base_value for i in impacts],
            orientation="h",
            name="High Scenario",
            marker_color=COLORS["success_primary"],
            text=[f"{i['high']:.2f}" for i in impacts],
            textposition="outside",
            hovertemplate=(
                "<b>%{y}</b><br>"
                f"High {value_label}: " + "%{customdata:.2f}<br>"
                "<extra></extra>"
            ),
            customdata=[i["high"] for i in impacts],
        )
    )

    # Add base value line
    fig.add_vline(
        x=0,
        line_color=COLORS["text_primary"],
        line_width=2,
    )

    # Apply layout
    layout = get_plotly_layout_defaults()

    # Calculate x-axis range
    max_deviation = max(
        max(abs(base_value - i["low"]) for i in impacts),
        max(abs(i["high"] - base_value) for i in impacts),
    )
    x_range = [-max_deviation * 1.3, max_deviation * 1.3]

    layout.update({
        "title": {"text": title},
        "height": height,
        "xaxis": {
            "title": f"Change in {value_label} from Base ({base_value:.2f})",
            "gridcolor": COLORS["neutral_light"],
            "range": x_range,
            "zeroline": True,
            "zerolinecolor": COLORS["text_primary"],
            "zerolinewidth": 2,
        },
        "yaxis": {
            "title": "",
            "autorange": "reversed",  # Largest impact at top
        },
        "barmode": "overlay",
        "legend": {
            "orientation": "h",
            "yanchor": "bottom",
            "y": 1.02,
            "xanchor": "right",
            "x": 1,
        },
    })

    if width:
        layout["width"] = width

    fig.update_layout(**layout)

    # Add base value annotation
    fig.add_annotation(
        x=0,
        y=1.1,
        xref="x",
        yref="paper",
        text=f"Base: {base_value:.2f}",
        showarrow=False,
        font={"size": 12, "color": COLORS["text_primary"]},
    )

    return fig