File size: 13,867 Bytes
76317bb
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
2
3
4
5
6
7
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
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
# -*- coding: utf-8 -*-
"""plotting.py

Automatically generated by Colab.

Original file is located at
    https://colab.research.google.com/drive/1ILADgRrYqkAEj5jyymO50ZvzDzVdfD6g
"""

# plotting.py
# Functions for generating Plotly figures from processed strategy data.

import plotly.express as px
import plotly.graph_objects as go
import pandas as pd
import numpy as np
import traceback
from utils import create_empty_figure # Import helper

def generate_figures_for_strategy(strategy_results):
    """
    Generates standard Plotly figures for a single strategy's results.

    Args:
        strategy_results: Dictionary containing processed data for one strategy,
                          as returned by process_single_file. Expected keys include:
                          'filename', 'equity_df', 'drawdown_df', 'benchmark_df',
                          'trades_df', 'exposure_series', 'turnover_df'.

    Returns:
        A dictionary containing Plotly figure objects:
        'equity_fig', 'drawdown_fig', 'benchmark_fig', 'pnl_hist_fig',
        'duration_hist_fig', 'exposure_fig', 'turnover_fig'.
        Uses empty figures if data is missing or invalid.
    """
    figures = {
        "equity_fig": create_empty_figure("Equity Curve"),
        "drawdown_fig": create_empty_figure("Drawdown Curve"),
        "benchmark_fig": create_empty_figure("Equity vs Benchmark"),
        "pnl_hist_fig": create_empty_figure("P/L Distribution"),
        "duration_hist_fig": create_empty_figure("Trade Duration Distribution"),
        "exposure_fig": create_empty_figure("Exposure"),
        "turnover_fig": create_empty_figure("Portfolio Turnover")
    }
    filename = strategy_results.get("filename", "Strategy") # Get filename for titles

    try:
        # --- Equity Curve ---
        equity_df = strategy_results.get("equity_df")
        if equity_df is not None and not equity_df.empty and 'Time' in equity_df.columns and 'Equity' in equity_df.columns:
             # Ensure Time is datetime
             equity_df['Time'] = pd.to_datetime(equity_df['Time'])
             fig = px.line(equity_df, x='Time', y='Equity', title=f'Equity Curve ({filename})')
             fig.update_layout(yaxis_title="Portfolio Value")
             figures["equity_fig"] = fig

        # --- Drawdown Curve ---
        drawdown_df = strategy_results.get("drawdown_df")
        if drawdown_df is not None and not drawdown_df.empty and 'Time' in drawdown_df.columns and 'Drawdown' in drawdown_df.columns:
             # Ensure Time is datetime
             drawdown_df['Time'] = pd.to_datetime(drawdown_df['Time'])
             # Convert drawdown to percentage for plotting
             drawdown_df['Drawdown_pct'] = drawdown_df['Drawdown'] * 100
             fig = px.area(drawdown_df, x='Time', y='Drawdown_pct', title=f'Drawdown Curve (%) ({filename})', labels={'Drawdown_pct': 'Drawdown (%)'})
             fig.update_layout(yaxis_title="Drawdown (%)")
             figures["drawdown_fig"] = fig

        # --- Equity vs Benchmark ---
        benchmark_df = strategy_results.get("benchmark_df")
        # Requires both equity and benchmark data
        if equity_df is not None and not equity_df.empty and 'Time' in equity_df.columns and 'Equity' in equity_df.columns and \
           benchmark_df is not None and not benchmark_df.empty and 'Time' in benchmark_df.columns and 'Benchmark' in benchmark_df.columns:
            try:
                # Ensure Time columns are datetime
                equity_df['Time'] = pd.to_datetime(equity_df['Time'])
                benchmark_df['Time'] = pd.to_datetime(benchmark_df['Time'])

                # Merge on Time after setting as index
                equity_indexed = equity_df.set_index('Time')['Equity']
                benchmark_indexed = benchmark_df.set_index('Time')['Benchmark']

                # Combine, handling potential different start/end dates
                combined = pd.concat([equity_indexed, benchmark_indexed], axis=1, keys=['Equity', 'Benchmark'], join='outer')

                # Normalize to start at 1 (or 100) for comparison
                # Check if first row has NaN values after outer join
                first_valid_index = combined.first_valid_index()
                if first_valid_index is not None:
                    # Normalize using the first non-NaN value for each column
                    normalized_equity = (combined['Equity'] / combined['Equity'].loc[combined['Equity'].first_valid_index()])#.fillna(method='ffill') # Optional fill
                    normalized_benchmark = (combined['Benchmark'] / combined['Benchmark'].loc[combined['Benchmark'].first_valid_index()])#.fillna(method='ffill') # Optional fill

                    # Create figure and add traces
                    fig = go.Figure()
                    fig.add_trace(go.Scatter(x=normalized_equity.index, y=normalized_equity, mode='lines', name='Strategy Equity'))
                    fig.add_trace(go.Scatter(x=normalized_benchmark.index, y=normalized_benchmark, mode='lines', name='Benchmark'))
                    fig.update_layout(title=f'Normalized Equity vs Benchmark ({filename})', xaxis_title='Date', yaxis_title='Normalized Value (Start = 1)')
                    figures["benchmark_fig"] = fig
                else:
                    print("Could not normalize Equity vs Benchmark: No valid starting point found after merge.")
                    figures["benchmark_fig"] = create_empty_figure(f"Equity vs Benchmark ({filename}) - Normalization Failed")

            except Exception as merge_err:
                 print(f"Error merging/plotting Equity vs Benchmark: {merge_err}")
                 figures["benchmark_fig"] = create_empty_figure(f"Equity vs Benchmark ({filename}) - Error")


        # --- Trade P/L Distribution ---
        trades_df = strategy_results.get("trades_df")
        if trades_df is not None and not trades_df.empty and 'profitLoss' in trades_df.columns:
            # Ensure profitLoss is numeric
            trades_df['profitLoss'] = pd.to_numeric(trades_df['profitLoss'], errors='coerce')
            valid_pnl = trades_df['profitLoss'].dropna()
            if not valid_pnl.empty:
                fig = px.histogram(valid_pnl, title=f'Trade Profit/Loss Distribution ({filename})', labels={'value': 'Profit/Loss'})
                figures["pnl_hist_fig"] = fig

        # --- Trade Duration Distribution ---
        # Uses 'duration_days' calculated in processing.py
        if trades_df is not None and not trades_df.empty and 'duration_days' in trades_df.columns:
             # Ensure duration_days is numeric
             trades_df['duration_days'] = pd.to_numeric(trades_df['duration_days'], errors='coerce')
             valid_duration = trades_df['duration_days'].dropna()
             if not valid_duration.empty:
                fig = px.histogram(valid_duration, title=f'Trade Duration Distribution (Days) ({filename})', labels={'value': 'Duration (Days)'})
                figures["duration_hist_fig"] = fig

        # --- Exposure Chart ---
        # Exposure data format varies; this is a basic example assuming a dict of series
        exposure_series_dict = strategy_results.get("exposure_series")
        if exposure_series_dict and isinstance(exposure_series_dict, dict):
            fig = go.Figure()
            exposure_plotted = False
            for series_name, series_data in exposure_series_dict.items():
                if 'values' in series_data and isinstance(series_data['values'], list):
                    # Process this specific series using the timeseries helper
                    exposure_df = process_timeseries_chart(series_data['values'], series_name)
                    if not exposure_df.empty:
                        # Plot as area chart if 'Exposure' in name, else line
                        plot_type = 'area' if 'Exposure' in series_name else 'scatter'
                        fill_type = 'tozeroy' if plot_type == 'area' else None
                        fig.add_trace(go.Scatter(x=exposure_df.index, y=exposure_df[series_name],
                                                 mode='lines', name=series_name, fill=fill_type))
                        exposure_plotted = True
            if exposure_plotted:
                fig.update_layout(title=f'Exposure ({filename})', xaxis_title='Date', yaxis_title='Value / % Exposure')
                figures["exposure_fig"] = fig
            else:
                 figures["exposure_fig"] = create_empty_figure(f"Exposure ({filename}) - No PlotData")
        else:
             figures["exposure_fig"] = create_empty_figure(f"Exposure ({filename}) - Data Missing/Invalid")


        # --- Portfolio Turnover ---
        turnover_df = strategy_results.get("turnover_df")
        if turnover_df is not None and not turnover_df.empty and 'Time' in turnover_df.columns and 'Turnover' in turnover_df.columns:
             # Ensure Time is datetime
             turnover_df['Time'] = pd.to_datetime(turnover_df['Time'])
             fig = px.line(turnover_df, x='Time', y='Turnover', title=f'Portfolio Turnover ({filename})')
             fig.update_layout(yaxis_title="Turnover")
             figures["turnover_fig"] = fig

    except Exception as e:
        print(f"Error generating figures for {filename}: {e}")
        traceback.print_exc()
        # Keep default empty figures on error

    return figures


def generate_manual_risk_figures(analysis_results, filename="Strategy"):
    """
    Generates Plotly figures from manually calculated risk analysis results.

    Args:
        analysis_results: Dictionary containing results from calculate_manual_risk_stats.
                          Expected keys: 'monthly_returns_table_for_heatmap', 'rolling_vol_df'.
        filename: Name of the strategy for figure titles.

    Returns:
        A dictionary containing Plotly figure objects:
        'monthly_heatmap_fig', 'rolling_vol_fig'.
        Uses empty figures if data is missing or invalid.
    """
    figures = {
        "monthly_heatmap_fig": create_empty_figure(f"Monthly Returns Heatmap ({filename})"),
        "rolling_vol_fig": create_empty_figure(f"Rolling Volatility ({filename})")
    }

    try:
        # --- Monthly Returns Heatmap ---
        # Expects percentages (values * 100) from calculate_manual_risk_stats
        monthly_ret_table = analysis_results.get("monthly_returns_table_for_heatmap")
        if monthly_ret_table is not None and not monthly_ret_table.empty:
            z = monthly_ret_table.values # The percentage values
            x = monthly_ret_table.columns # Month names
            y = monthly_ret_table.index   # Years

            # Create heatmap
            fig = go.Figure(data=go.Heatmap(
                z=z, x=x, y=y,
                colorscale='RdYlGn', # Red-Yellow-Green scale, good for returns
                zmid=0,             # Center color scale around zero
                # Format text labels shown on the heatmap cells
                text=monthly_ret_table.applymap(lambda v: f'{v:.1f}%' if pd.notna(v) else '').values,
                texttemplate="%{text}", # Use the formatted text
                hoverongaps=False,      # Don't show hover info for gaps
                colorbar=dict(title='Monthly Return (%)') # Add color bar title
                ))
            fig.update_layout(
                 title=f'Monthly Returns (%) ({filename})',
                 yaxis_nticks=len(y), # Ensure all years are shown as ticks
                 yaxis_title="Year",
                 yaxis_autorange='reversed' # Show earlier years at the top
                 )
            figures["monthly_heatmap_fig"] = fig

        # --- Rolling Volatility Plot ---
        rolling_vol_df = analysis_results.get("rolling_vol_df")
        # Check if DataFrame exists, is not empty, and has the 'Time' column
        if rolling_vol_df is not None and not rolling_vol_df.empty and 'Time' in rolling_vol_df.columns:
            # Ensure Time is datetime
            rolling_vol_df['Time'] = pd.to_datetime(rolling_vol_df['Time'])

            fig = go.Figure()
            colors = px.colors.qualitative.Plotly # Get a qualitative color sequence
            i = 0 # Color index
            vol_plotted = False
            # Iterate through columns starting with 'vol_'
            for col in rolling_vol_df.columns:
                if col.startswith('vol_'):
                    window_label = col.split('_')[1] # Extract window label (e.g., '3M')
                    # Plot volatility as percentage
                    fig.add_trace(go.Scatter(
                         x=rolling_vol_df['Time'],
                         y=rolling_vol_df[col] * 100, # Convert to percentage
                         mode='lines',
                         name=f'Rolling Vol ({window_label})',
                         line=dict(color=colors[i % len(colors)]) # Cycle through colors
                         ))
                    i += 1
                    vol_plotted = True

            # Update layout if at least one volatility series was plotted
            if vol_plotted:
                fig.update_layout(
                    title=f'Annualized Rolling Volatility ({filename})',
                    xaxis_title='Date',
                    yaxis_title='Volatility (%)' # Y-axis label as percentage
                    )
                figures["rolling_vol_fig"] = fig
            else:
                 figures["rolling_vol_fig"] = create_empty_figure(f"Rolling Volatility ({filename}) - No Plot Data")
        else:
             figures["rolling_vol_fig"] = create_empty_figure(f"Rolling Volatility ({filename}) - Data Missing/Invalid")


    except Exception as e:
        print(f"Error generating manual risk figures for {filename}: {e}")
        traceback.print_exc()
        # Keep default empty figures on error

    return figures