This commit is contained in:
louiscklaw
2025-01-31 19:53:54 +08:00
parent 751cc5f1bf
commit 9137828996
50 changed files with 44582 additions and 0 deletions

View File

@@ -0,0 +1,5 @@
---
tags:
---
# notes

View File

@@ -0,0 +1,27 @@
import dash
from dash import dcc, html
from utils.helpers import load_data
from layouts.main_layout import create_layout
from callbacks.backtest_callbacks import register_backtest_callbacks
from callbacks.strategy_callbacks import register_strategy_callbacks
# Create the Dash app
app = dash.Dash(__name__,
prevent_initial_callbacks=True,
suppress_callback_exceptions=True)
# Load data
try:
full_data = load_data()
except Exception as e:
raise Exception(f"Error: {str(e)}")
# Set up the layout
app.layout = create_layout(full_data)
# Register callbacks
register_backtest_callbacks(app)
register_strategy_callbacks(app)
if __name__ == '__main__':
app.run_server(debug=True)

View File

@@ -0,0 +1,178 @@
from dash import Input, Output, State
from datetime import timedelta
import pandas as pd
import numpy as np
import plotly.graph_objects as go
from plotly.subplots import make_subplots
from models.backtest import backtest
def register_backtest_callbacks(app):
@app.callback(
[Output('backtest-results', 'data'),
Output('combined-heatmap', 'figure'),
Output('forwardtest-results', 'data')],
Input('run-backtest', 'n_clicks'),
[State('bt-end-date', 'date'),
State('window-start', 'value'),
State('window-end', 'value'),
State('window-step', 'value'),
State('threshold-start', 'value'),
State('threshold-end', 'value'),
State('threshold-step', 'value'),
State('time-factor', 'value'),
State('commission', 'value'),
State('heatmap-title', 'value')], # Add new input for title
prevent_initial_call=True
)
def run_backtest_and_create_heatmaps(n_clicks, bt_end_date, window_start, window_end, window_step,
threshold_start, threshold_end, threshold_step, time_factor,
commission, heatmap_title):
if n_clicks == 0:
return {}, {}, {}
try:
# Get data from the global scope
from app import full_data
# Parse inputs
bt_end_date = pd.to_datetime(bt_end_date)
ft_start_date = bt_end_date + timedelta(days=1)
commission = commission / 100
# Create window and threshold lists
window_list = list(range(window_start, window_end, window_step))
threshold_steps = int((threshold_end - threshold_start) / threshold_step)
threshold_list = [round(threshold_start + i * threshold_step, 2)
for i in range(threshold_steps)]
# Split data
bt_df = full_data[:bt_end_date]
ft_df = full_data[ft_start_date:]
# Run backtests
bt_results = [backtest(bt_df, w, t, time_factor, commission, 'heatmap')
for w in window_list for t in threshold_list]
ft_results = [backtest(ft_df, w, t, time_factor, commission, 'heatmap')
for w in window_list for t in threshold_list]
# Reverse threshold list for display
threshold_list.reverse()
# Process results
bt_results_df = pd.DataFrame(bt_results)
ft_results_df = pd.DataFrame(ft_results)
bt_data_table = bt_results_df.pivot(index='threshold', columns='window', values='sharpe')
ft_data_table = ft_results_df.pivot(index='threshold', columns='window', values='sharpe')
# Sort tables
bt_data_table = bt_data_table.sort_index(ascending=False)
ft_data_table = ft_data_table.sort_index(ascending=False)
# Use custom title or default if none provided
bt_title = f"{heatmap_title}_BT_sharpe" if heatmap_title else "Backtest Sharpe Ratio"
ft_title = f"{heatmap_title}_FT_sharpe" if heatmap_title else "Forward Test Sharpe Ratio"
# Create heatmap figure
fig = make_subplots(
rows=1, cols=2,
subplot_titles=(bt_title, ft_title),
horizontal_spacing=0.17,
column_widths=[0.5, 0.5]
)
# Custom colorscale
custom_colorscale = [
[0.0, '#FFFFBF'], # Light yellow
[0.2, '#E0F3F8'], # Pale blue
[0.4, '#ABD9E9'], # Very light blue
[0.6, '#74ADD1'], # Light blue
[0.8, '#4575B4'], # Blue
[1.0, '#313695'] # Deep blue
]
# Add backtest heatmap
fig.add_trace(
go.Heatmap(
z=bt_data_table.values,
x=bt_data_table.columns,
y=np.round(bt_data_table.index, 2),
text=np.round(bt_data_table.values, 2),
texttemplate='%{text}',
textfont={"size": 9},
colorscale=custom_colorscale,
showscale=True,
colorbar=dict(
x=0.45,
thickness=15,
title="Sharpe Ratio",
titleside="right",
len=0.9
)
),
row=1, col=1
)
# Add forward test heatmap
fig.add_trace(
go.Heatmap(
z=ft_data_table.values,
x=ft_data_table.columns,
y=np.round(ft_data_table.index, 2),
text=np.round(ft_data_table.values, 2),
texttemplate='%{text}',
textfont={"size": 9},
colorscale=custom_colorscale,
showscale=True,
colorbar=dict(
x=1.02,
thickness=15,
title="Sharpe Ratio",
titleside="right",
len=0.9
)
),
row=1, col=2
)
# Calculate dimensions
cell_height = 25
cell_width = 15
height = max(cell_height * len(threshold_list), 500)
width = max(cell_width * len(window_list) * 3.2, 1800)
# Update layout
fig.update_layout(
height=height,
width=width,
showlegend=False,
title_x=0.5,
margin=dict(l=50, r=120, t=50, b=50),
paper_bgcolor='white',
plot_bgcolor='white',
font=dict(size=12)
)
# Update axes
for i in range(1, 3):
fig.update_xaxes(
title_text="Window",
tickmode='array',
tickvals=window_list,
ticktext=window_list,
tickangle=45,
row=1, col=i
)
fig.update_yaxes(
title_text="Threshold",
tickmode='array',
tickvals=threshold_list,
ticktext=[f"{x:.2f}" for x in threshold_list],
row=1, col=i
)
return bt_results_df.to_dict('records'), fig, ft_results_df.to_dict('records')
except Exception as e:
print(f"Error in heatmap generation: {str(e)}")
return {}, {}, {}

View File

@@ -0,0 +1,369 @@
import plotly.graph_objects as go
import pandas as pd
from dash import html
from layouts.components import create_metric_card
def create_equity_figure(result_dict):
"""
Creates an equity curve figure comparing strategy performance against benchmark.
"""
fig = go.Figure()
fig.add_trace(go.Scatter(
x=pd.to_datetime(result_dict['Dates']),
y=result_dict['Equity Curve'],
name='Strategy',
line=dict(color='#2C7FB8', width=2)
))
fig.add_trace(go.Scatter(
x=pd.to_datetime(result_dict['Dates']),
y=result_dict['Benchmark'],
name='Benchmark',
line=dict(color='#FC8D59', width=2)
))
fig.update_layout(
title={
'text': "Equity Curve",
'y': 0.95,
'x': 0.5,
'xanchor': 'center',
'yanchor': 'top',
'font': dict(size=16)
},
xaxis=dict(
type="date",
showgrid=True
),
yaxis_title="Cumulative Return",
template='plotly_white',
height=500,
hovermode='x unified',
legend=dict(
yanchor="top",
y=0.99,
xanchor="left",
x=0.01,
bgcolor='rgba(255, 255, 255, 0.8)'
),
margin=dict(l=50, r=50, t=50, b=50)
)
return fig
def create_underwater_figure(result_dict):
"""
Creates an underwater (drawdown) plot.
"""
underwater = pd.Series(result_dict['Equity Curve']) - pd.Series(result_dict['Equity Curve']).cummax()
fig = go.Figure()
fig.add_trace(go.Scatter(
x=pd.to_datetime(result_dict['Dates']),
y=underwater,
fill='tozeroy',
name='Drawdown',
line=dict(color='#2C7FB8', width=2),
fillcolor='rgba(44, 127, 184, 0.3)'
))
fig.update_layout(
title={
'text': "Drawdown",
'y': 0.95,
'x': 0.5,
'xanchor': 'center',
'yanchor': 'top',
'font': dict(size=16)
},
xaxis=dict(
type="date",
showgrid=True
),
yaxis_title="Drawdown",
template='plotly_white',
height=300,
hovermode='x unified',
showlegend=False,
margin=dict(l=50, r=50, t=50, b=50)
)
return fig
def create_yearly_returns_figure(result_dict):
"""
Creates a yearly returns comparison bar plot.
"""
try:
dates = pd.to_datetime(result_dict['Dates'])
daily_returns = result_dict.get('Returns', None)
if daily_returns is None:
daily_returns = pd.Series(result_dict['Equity Curve'], index=dates).diff()
daily_returns = pd.DataFrame({
'equity': daily_returns,
'benchmark': pd.Series(result_dict['Benchmark'], index=dates).diff()
})
yearly_returns = daily_returns.resample('Y').sum()
fig = go.Figure()
fig.add_trace(go.Bar(
x=yearly_returns.index.year,
y=yearly_returns['equity'],
name='Strategy',
marker_color='#2C7FB8'
))
fig.add_trace(go.Bar(
x=yearly_returns.index.year,
y=yearly_returns['benchmark'],
name='Benchmark',
marker_color='#FC8D59'
))
fig.add_hline(y=0, line_dash="dash", line_color="red", opacity=0.5)
fig.update_layout(
title={
'text': "Yearly Returns",
'y': 0.95,
'x': 0.5,
'xanchor': 'center',
'yanchor': 'top',
'font': dict(size=16)
},
yaxis_title="Returns",
barmode='group',
template='plotly_white',
showlegend=True,
legend=dict(
orientation="h",
y=1.1,
x=0.5,
xanchor="center",
yanchor="bottom"
),
height=300,
margin=dict(l=50, r=50, t=80, b=50),
bargap=0.2,
bargroupgap=0.1
)
fig.update_xaxes(
tickangle=0,
dtick=1,
tickfont=dict(size=12),
range=[yearly_returns.index.year.min() - 0.5, yearly_returns.index.year.max() + 0.5]
)
fig.update_yaxes(
title_font=dict(size=12),
tickfont=dict(size=12),
zeroline=True,
zerolinewidth=1,
zerolinecolor='red'
)
return fig
except Exception as e:
print(f"Error in create_yearly_returns_plot: {str(e)}")
return go.Figure()
def create_metric_cards(result_dict):
"""
Creates metric cards displaying strategy performance metrics.
"""
metrics_data = [
('Return (Ann.)', f"{result_dict['Return (Ann.) [%]']:.2f}%", '#e3f2fd'),
('Sharpe Ratio', f"{result_dict['Sharpe Ratio']:.2f}", '#e3f2fd'),
('Calmar Ratio', f"{result_dict['Calmar Ratio']:.2f}", '#e3f2fd'),
('Max Drawdown', f"{result_dict['Max. Drawdown [%]']:.2f}%", '#e3f2fd'),
('Beta', f"{result_dict['Beta']:.3f}", '#e3f2fd')
]
if 'Win Rate [%]' in result_dict:
trading_metrics = [
('Win Rate', f"{result_dict['Win Rate [%]']:.2f}%", '#f1f8e9'),
('Avg Win', f"{result_dict['Avg Win [%]']:.2f}%", '#f1f8e9'),
('Avg Loss', f"{result_dict['Avg Loss [%]']:.2f}%", '#f1f8e9'),
('Number of Trades', f"{result_dict['Number of Trades']}", '#f1f8e9'),
('Long Duration', f"{result_dict['Long Duration [%]']:.2f}%", '#fff3e0'),
('Short Duration', f"{result_dict['Short Duration [%]']:.2f}%", '#fff3e0'),
('Holding Time [%]', f"{result_dict['Turnover [%]']:.2f}%", '#fff3e0'),
('Holding Time', f"{result_dict['Turnover Period']:.2f}", '#fff3e0')
]
metrics_data.extend(trading_metrics)
metrics_style = {
'padding': '12px',
'backgroundColor': 'white',
'borderRadius': '8px',
'boxShadow': '0 1px 3px rgba(0,0,0,0.1)',
'textAlign': 'center',
'minWidth': '150px',
'transition': 'transform 0.2s ease-in-out'
}
metric_cards = []
for label, value, bg_color in metrics_data:
current_style = metrics_style.copy()
current_style['backgroundColor'] = bg_color
metric_cards.append(
html.Div([
html.Div(label, style={
'fontSize': '13px',
'color': '#555',
'fontWeight': '500',
'marginBottom': '4px',
'whiteSpace': 'nowrap'
}),
html.Div(value, style={
'fontSize': '15px',
'fontWeight': 'bold',
'color': '#2c3e50'
})
], style=current_style)
)
return metric_cards
def create_strategy_header(mode, current_selection, start_date, end_date, click_data=None):
"""
Creates the strategy header with parameters and period information.
"""
if mode == 'aggregative' and current_selection:
strategy_buttons = [
html.Div([
html.Span(
f"({s['window']}, {s['threshold']:.2f})",
style={'marginRight': '4px'}
),
html.Button(
"×",
id={'type': 'remove-strategy', 'index': i},
n_clicks=0,
style={
'border': 'none',
'background': 'transparent',
'color': '#666',
'fontSize': '18px',
'fontWeight': 'bold',
'cursor': 'pointer',
'padding': '2px 8px',
'verticalAlign': 'middle',
'lineHeight': '1',
'transition': 'color 0.2s',
'margin': '0 2px',
'borderRadius': '50%',
}
)
], style={
'display': 'inline-flex',
'alignItems': 'center',
'margin': '2px 4px',
'padding': '4px 8px',
'backgroundColor': '#f0f0f0',
'border': '1px solid #ddd',
'borderRadius': '16px',
'fontSize': '14px'
}) for i, s in enumerate(current_selection)
]
return html.Div([
html.Div("Combined Strategy Details",
style={'fontSize': '18px', 'fontWeight': 'bold', 'marginBottom': '8px'}),
html.Div([
html.Span("Parameters: ", style={'marginRight': '8px'}),
html.Div(strategy_buttons, style={'display': 'inline-block'})
], style={'marginBottom': '8px'}),
html.Div(
f"Period: {start_date.strftime('%Y-%m-%d')} to {end_date.strftime('%Y-%m-%d')}"
if start_date and end_date else ""
)
])
elif click_data:
window = click_data['points'][0]['x']
threshold = click_data['points'][0]['y']
return html.Div([
html.Div("Strategy Details",
style={'fontSize': '18px', 'fontWeight': 'bold', 'marginBottom': '8px'}),
html.Div([
html.Span("Parameters: ", style={'marginRight': '8px'}),
html.Span(f"({window}, {threshold:.2f})")
], style={'marginBottom': '8px'}),
html.Div(
f"Period: {start_date.strftime('%Y-%m-%d')} to {end_date.strftime('%Y-%m-%d')}"
if start_date and end_date else ""
)
])
return html.Div("Click on heatmap to see strategy details")
def create_error_message(error_type, start_date=None, end_date=None, error_message=None):
"""
Creates error message displays.
"""
if error_type == "no_trade":
return html.Div([
html.Div("No Trade Data",
style={'fontSize': '18px', 'fontWeight': 'bold', 'marginBottom': '8px'}),
html.Div(
f"There is no trade from {start_date.strftime('%Y-%m-%d')} to {end_date.strftime('%Y-%m-%d')}"
)
])
else:
return html.Div([
html.Div("Error",
style={'fontSize': '18px', 'fontWeight': 'bold', 'marginBottom': '8px'}),
html.Div(f"An error occurred: {error_message}")
])
def create_metrics_component(result_dict):
"""Create metrics component with grid layout using create_metric_card."""
if result_dict is None or not isinstance(result_dict, dict):
return html.Div() # Just return an empty div without styling
try:
# Define metrics in a grid-friendly format
metrics_grid = [
[
('Return (Ann.)', f"{result_dict.get('Return (Ann.) [%]', 0):.2f}%"),
('Sharpe Ratio', f"{result_dict.get('Sharpe Ratio', 0):.2f}")
],
[
('Calmar Ratio', f"{result_dict.get('Calmar Ratio', 0):.2f}"),
('Max Drawdown', f"{result_dict.get('Max. Drawdown [%]', 0):.2f}%")
],
[
('Beta', f"{result_dict.get('Beta', 0):.3f}"),
(None, None)
]
]
# Create grid layout
grid_rows = []
for row in metrics_grid:
row_cells = []
for label, value in row:
if label is not None:
cell = create_metric_card(label, value, '#e3f2fd')
row_cells.append(cell)
grid_rows.append(html.Div(row_cells, style={
'display': 'flex',
'justifyContent': 'flex-start',
'gap': '10px',
'marginBottom': '10px'
}))
return html.Div(grid_rows) # Remove the container styling
except Exception as e:
print(f"Error in create_metrics_component: {str(e)}")
return html.Div() # Return empty div without styling

View File

@@ -0,0 +1,26 @@
from dash import Output
STRATEGY_OUTPUTS = [
Output('strategy-metrics', 'children', allow_duplicate=True),
Output('equity-curve', 'figure', allow_duplicate=True),
Output('underwater-plot', 'figure', allow_duplicate=True),
Output('yearly-returns-plot', 'figure', allow_duplicate=True),
Output('strategy-header', 'children', allow_duplicate=True),
Output('date-range-picker', 'start_date', allow_duplicate=True),
Output('date-range-picker', 'end_date', allow_duplicate=True),
Output('selected-strategies', 'data', allow_duplicate=True),
Output('strategy-clear-selection', 'style', allow_duplicate=True),
Output('selected-count', 'children', allow_duplicate=True)
]
def get_base_style(mode):
return {
'marginLeft': '10px',
'padding': '5px 10px',
'backgroundColor': '#dc3545',
'color': 'white',
'border': 'none',
'borderRadius': '4px',
'cursor': 'pointer',
'display': 'block' if mode == 'aggregative' else 'none'
}

View File

@@ -0,0 +1,97 @@
# callbacks/strategy/helpers.py
from dash import html
import pandas as pd
from models.backtest import backtest, combine_strategies
from utils.helpers import get_default_date_range
from .constants import get_base_style
from .components import (
create_equity_figure,
create_underwater_figure,
create_yearly_returns_figure,
create_metric_cards,
create_strategy_header,
create_error_message
)
def get_default_outputs():
"""Return default outputs when no interaction has occurred."""
return [], {}, {}, {}, html.Div("Click on heatmap to see strategy details"), None, None, [], {'display': 'none'}, ''
def handle_mode_change(trigger_id, mode, current_selection):
"""Handle mode changes and clear selection."""
if trigger_id == 'strategy-clear-selection' or mode == 'single':
current_selection = []
selected_count = f'Selected strategies: {len(current_selection)}' if mode == 'aggregative' else ''
from app import full_data
default_start, default_end = get_default_date_range(full_data)
return [], {}, {}, {}, html.Div("Click on heatmap to see strategy details"), \
default_start, default_end, current_selection, get_base_style(mode), selected_count
def get_date_range(trigger_id, start_date, end_date):
"""Handle date range reset and processing."""
if trigger_id == 'reset-date-range':
from app import full_data
start_date, end_date = get_default_date_range(full_data)
if start_date and end_date:
start_date = pd.to_datetime(start_date)
end_date = pd.to_datetime(end_date)
return start_date, end_date
def get_filtered_data(start_date, end_date):
"""Get data filtered by date range."""
from app import full_data
if start_date and end_date:
return full_data[start_date:end_date]
return full_data
def update_strategy_selection(mode, trigger_id, click_data, current_selection):
"""Update strategy selection based on mode and user interaction."""
if mode == 'aggregative' and trigger_id == 'combined-heatmap' and click_data:
new_strategy = {
'window': click_data['points'][0]['x'],
'threshold': click_data['points'][0]['y']
}
if new_strategy not in current_selection:
return current_selection + [new_strategy]
elif mode == 'single':
return []
return current_selection
def get_backtest_results(mode, current_selection, data, click_data, time_factor, commission):
"""Get backtest results based on mode and selected strategies."""
if mode == 'single' or not current_selection:
window = click_data['points'][0]['x']
threshold = click_data['points'][0]['y']
return backtest(data, window, threshold, time_factor, commission, 'full_report')
return combine_strategies(data, current_selection, time_factor, commission)
def create_output_components(result_dict, mode, current_selection, start_date, end_date, click_data):
"""Create all visual components."""
metric_cards = create_metric_cards(result_dict)
equity_fig = create_equity_figure(result_dict)
underwater_fig = create_underwater_figure(result_dict)
yearly_returns_fig = create_yearly_returns_figure(result_dict)
header_text = create_strategy_header(mode, current_selection, start_date, end_date, click_data)
base_style = get_base_style(mode)
selected_count = f'Selected strategies: {len(current_selection)}' if mode == 'aggregative' else ''
return (metric_cards, equity_fig, underwater_fig, yearly_returns_fig,
header_text, start_date, end_date, current_selection,
base_style, selected_count)
def handle_error(error_type, start_date, end_date, current_selection, mode, error_message=None):
"""Handle various error cases."""
base_style = get_base_style(mode)
selected_count = f'Selected strategies: {len(current_selection)}' if mode == 'aggregative' else ''
if error_type == "zero_division":
error_msg = create_error_message("no_trade", start_date, end_date)
else:
error_msg = create_error_message("error", error_message=error_message)
return [], {}, {}, {}, error_msg, start_date, end_date, current_selection, base_style, selected_count

View File

@@ -0,0 +1,187 @@
from dash import Input, Output, State, html, ALL, callback_context
import dash
from .constants import STRATEGY_OUTPUTS
from .helpers import (
get_filtered_data,
get_backtest_results,
create_output_components
)
import pandas as pd
from .components import (
create_equity_figure,
create_underwater_figure,
create_yearly_returns_figure,
create_metrics_component
)
def register_remove_callback(app):
@app.callback(
[Output('selected-strategies', 'data'),
Output('selected-count', 'children'),
Output('equity-curve', 'figure'),
Output('underwater-plot', 'figure'),
Output('yearly-returns-plot', 'figure'),
Output('strategy-header', 'children'),
Output('strategy-metrics', 'children')],
[Input({'type': 'remove-strategy', 'index': ALL}, 'n_clicks')],
[State('selected-strategies', 'data'),
State('backtest-results', 'data'),
State('time-factor', 'value'),
State('commission', 'value'),
State('date-range-picker', 'start_date'),
State('date-range-picker', 'end_date'),
State('dashboard-mode', 'value')],
prevent_initial_call=True
)
def remove_strategy(n_clicks_list, current_selection, backtest_results,
time_factor, commission, start_date, end_date, mode):
if not callback_context.triggered:
raise dash.exceptions.PreventUpdate
trigger = callback_context.triggered[0]
if trigger['value'] is None or trigger['value'] == 0:
raise dash.exceptions.PreventUpdate
button_id = eval(trigger['prop_id'].split('.')[0])
index_to_remove = button_id['index']
# Remove the strategy at the specified index
updated_selection = [
strategy for i, strategy in enumerate(current_selection)
if i != index_to_remove
]
# Convert string dates to datetime objects
start_date = pd.to_datetime(start_date).date() if start_date else None
end_date = pd.to_datetime(end_date).date() if end_date else None
try:
# Get filtered data
data = get_filtered_data(start_date, end_date)
# If there are still strategies left
if updated_selection:
# Recalculate backtest results for remaining strategies
result_dict = get_backtest_results(
mode='combined',
current_selection=updated_selection,
data=data,
click_data=None,
time_factor=float(time_factor),
commission=float(commission) / 100
)
# Create header with improved styling
header = html.Div([
html.Div("Combined Strategy Details",
style={'fontSize': '18px', 'fontWeight': 'bold', 'marginBottom': '8px'}),
html.Div([
html.Span("Parameters: ", style={'marginRight': '8px'}),
html.Div([
html.Div([
html.Span(f"({s['window']}, {s['threshold']:.2f})",
style={'marginRight': '4px'}),
html.Button(
"×",
id={'type': 'remove-strategy', 'index': i},
n_clicks=0,
style={
'border': 'none',
'background': 'transparent',
'color': '#666',
'fontSize': '18px',
'fontWeight': 'bold',
'cursor': 'pointer',
'padding': '2px 8px',
'verticalAlign': 'middle',
'lineHeight': '1',
'transition': 'color 0.2s',
'margin': '0 2px',
'borderRadius': '50%'
}
)
], style={
'display': 'inline-flex',
'alignItems': 'center',
'margin': '2px 4px',
'padding': '4px 8px',
'backgroundColor': '#f0f0f0',
'border': '1px solid #ddd',
'borderRadius': '16px',
'fontSize': '14px'
}) for i, s in enumerate(updated_selection)
], style={'display': 'inline-block'})
], style={'marginBottom': '8px'}),
html.Div(f"Period: {start_date} to {end_date}")
])
# Create figures
equity_fig = create_equity_figure(result_dict)
underwater_fig = create_underwater_figure(result_dict)
yearly_returns_fig = create_yearly_returns_figure(result_dict)
metrics = create_metrics_component(result_dict)
else:
# If no strategies left, return empty state
empty_fig = {
'data': [],
'layout': {
'title': {
'text': 'No data to display',
'y': 0.95,
'x': 0.5,
'xanchor': 'center',
'yanchor': 'top',
'font': dict(size=16)
},
'showlegend': False,
'height': 300,
'template': 'plotly_white',
'xaxis': {'showgrid': False},
'yaxis': {'showgrid': False},
'margin': dict(l=50, r=50, t=50, b=50)
}
}
header = html.Div([
html.Div("Strategy Parameters",
style={'fontSize': '18px', 'fontWeight': 'bold', 'marginBottom': '8px'}),
html.Div("No strategies selected. Click on the heatmap to select strategies.")
])
equity_fig = underwater_fig = yearly_returns_fig = empty_fig
metrics = create_metrics_component(None) # Will return empty white container
return (
updated_selection,
f"Selected strategies: {len(updated_selection)}",
equity_fig,
underwater_fig,
yearly_returns_fig,
header,
metrics
)
except Exception as e:
print(f"Error in remove_strategy callback: {str(e)}")
empty_fig = {
'data': [],
'layout': {
'title': 'Error occurred',
'showlegend': False
}
}
error_header = html.Div([
html.Div("Error",
style={'fontSize': '18px', 'fontWeight': 'bold', 'marginBottom': '8px'}),
html.Div(f"An error occurred: {str(e)}")
])
return (
updated_selection,
f"Selected strategies: {len(updated_selection)}",
empty_fig,
empty_fig,
empty_fig,
error_header,
create_metrics_component(None) # Will return empty white container
)

View File

@@ -0,0 +1,64 @@
# callbacks/strategy/update_callback.py
from dash import Input, Output, State
import dash
from .constants import STRATEGY_OUTPUTS
from .helpers import (
get_default_outputs,
handle_mode_change,
get_date_range,
get_filtered_data,
update_strategy_selection,
get_backtest_results,
create_output_components,
handle_error
)
def register_update_callback(app):
@app.callback(
STRATEGY_OUTPUTS,
[Input('combined-heatmap', 'clickData'),
Input('apply-date-range', 'n_clicks'),
Input('reset-date-range', 'n_clicks'),
Input('dashboard-mode', 'value'),
Input('strategy-clear-selection', 'n_clicks')],
[State('bt-end-date', 'date'),
State('time-factor', 'value'),
State('commission', 'value'),
State('date-range-picker', 'start_date'),
State('date-range-picker', 'end_date'),
State('selected-strategies', 'data')],
prevent_initial_call=True
)
def update_all_outputs(click_data, apply_clicks, reset_clicks, mode, clear_clicks,
bt_end_date, time_factor, commission,
start_date, end_date, current_selection):
"""Main callback function that updates all dashboard components."""
# Get trigger context
ctx = dash.callback_context
if not ctx.triggered:
return get_default_outputs()
trigger_id = ctx.triggered[0]['prop_id'].split('.')[0]
# Handle mode changes and clear selection
if trigger_id in ['dashboard-mode', 'strategy-clear-selection']:
return handle_mode_change(trigger_id, mode, current_selection)
try:
# Process date range and data
start_date, end_date = get_date_range(trigger_id, start_date, end_date)
data = get_filtered_data(start_date, end_date)
commission = commission / 100
# Update strategy selection and get results
current_selection = update_strategy_selection(mode, trigger_id, click_data, current_selection)
result_dict = get_backtest_results(mode, current_selection, data, click_data, time_factor, commission)
# Create and return components
return create_output_components(result_dict, mode, current_selection, start_date, end_date, click_data)
except ZeroDivisionError:
return handle_error("zero_division", start_date, end_date, current_selection, mode)
except Exception as e:
print(f"Error in strategy details update: {str(e)}")
return handle_error("general", start_date, end_date, current_selection, mode, str(e))

View File

@@ -0,0 +1,7 @@
# callbacks/strategy_callbacks.py
from .strategy.update_callback import register_update_callback
# from .strategy.remove_callback import register_remove_callback
def register_strategy_callbacks(app):
register_update_callback(app)
# register_remove_callback(app)

View File

@@ -0,0 +1,5 @@
# Configuration settings
DEFAULT_TIME_FACTOR = 8760
DEFAULT_COMMISSION = 0.06
DEFAULT_WINDOW_RANGE = (100, 1000, 100)
DEFAULT_THRESHOLD_RANGE = (0.0, 2.0, 0.2)

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,38 @@
from dash import html, dcc
def create_metric_card(label, value, bg_color):
return html.Div([
html.Div(label, style={
'fontSize': '13px',
'color': '#555',
'fontWeight': '500',
'marginBottom': '4px',
'whiteSpace': 'nowrap'
}),
html.Div(value, style={
'fontSize': '15px',
'fontWeight': 'bold',
'color': '#2c3e50'
})
], style={
'padding': '12px',
'backgroundColor': bg_color,
'borderRadius': '8px',
'boxShadow': '0 1px 3px rgba(0,0,0,0.1)',
'textAlign': 'center',
'minWidth': '150px'
})
def date_range_picker():
return html.Div([
html.Label("Select Date Range:", style={'marginRight': '10px'}),
dcc.DatePickerRange(
id='date-range-picker',
start_date=None,
end_date=None
),
html.Button('Apply', id='apply-date-range', n_clicks=0,
style={'marginLeft': '10px', 'backgroundColor': '#1f77b4', 'color': 'white', 'border': 'none', 'padding': '5px 15px', 'borderRadius': '4px'}),
html.Button('Reset', id='reset-date-range', n_clicks=0,
style={'marginLeft': '10px', 'backgroundColor': '#7f7f7f', 'color': 'white', 'border': 'none', 'padding': '5px 15px', 'borderRadius': '4px'})
], style={'marginBottom': '20px'})

View File

@@ -0,0 +1,432 @@
from dash import html, dcc
from layouts.components import create_metric_card
def create_layout(full_data):
return html.Div([
# Keep your existing stores
dcc.Store(id='backtest-results', storage_type='memory'),
dcc.Store(id='forwardtest-results', storage_type='memory'),
dcc.Store(id='current-params', storage_type='memory'),
# Add new store for selected strategies
dcc.Store(id='selected-strategies', data=[], storage_type='memory'),
html.Div([
html.H1("Backtest Strategy Dashboard",
style={'textAlign': 'center', 'marginBottom': '20px'}),
# Add the mode switch section here
html.Div([
html.Div([
html.Label("Dashboard Mode:", style={
'fontWeight': '500',
'color': '#2c3e50',
'marginRight': '10px',
'fontSize': '16px'
}),
dcc.RadioItems(
id='dashboard-mode',
options=[
{'label': 'Single Strategy Mode', 'value': 'single'},
{'label': 'Aggregative Mode', 'value': 'aggregative'}
],
value='single',
style={'display': 'inline-block'},
inputStyle={'marginRight': '5px'},
labelStyle={'marginRight': '20px', 'cursor': 'pointer'}
),
# Changed ID for the second clear selection button
html.Button(
'Clear Selection',
id='strategy-clear-selection',
style={
'marginLeft': '10px',
'padding': '5px 10px',
'backgroundColor': '#dc3545',
'color': 'white',
'border': 'none',
'borderRadius': '4px',
'cursor': 'pointer',
'display': 'none' # Initially hidden
}
),
html.Div(
id='selected-count',
style={
'marginLeft': '15px',
'display': 'inline-block',
'color': '#2c3e50'
}
)
], style={'display': 'flex', 'alignItems': 'center'})
], style={
'marginBottom': '20px',
'padding': '15px',
'backgroundColor': '#f8f9fa',
'borderRadius': '8px',
'border': '1px solid #e9ecef'
}),
# Parameters input section
html.Div([
# First row of inputs
# First row of inputs
html.Div([
html.Div([
html.Label("Backtest End Date:", style={
'fontWeight': '500',
'color': '#2c3e50',
'marginBottom': '5px',
'display': 'block'
}),
dcc.DatePickerSingle(
id='bt-end-date',
min_date_allowed=full_data.index[0],
max_date_allowed=full_data.index[-1],
initial_visible_month=full_data.index[0],
date=full_data.index[len(full_data) // 2],
display_format='YYYY-MM-DD',
style={'width': '150px'}
)
], style={'marginRight': '20px', 'display': 'inline-block'}),
html.Div([
html.Label("Time Factor:", style={
'fontWeight': '500',
'color': '#2c3e50',
'marginBottom': '5px',
'display': 'block'
}),
dcc.Input(
id='time-factor',
type='number',
value=8760,
style={
'width': '100px',
'height': '36px',
'padding': '8px',
'borderRadius': '4px',
'border': '1px solid #dcdfe6',
'fontSize': '14px'
}
)
], style={'marginRight': '20px', 'display': 'inline-block'}),
html.Div([
html.Label("Commission (%):", style={
'fontWeight': '500',
'color': '#2c3e50',
'marginBottom': '5px',
'display': 'block'
}),
dcc.Input(
id='commission',
type='number',
value=0.06,
step=0.01,
style={
'width': '80px',
'height': '36px',
'padding': '8px',
'borderRadius': '4px',
'border': '1px solid #dcdfe6',
'fontSize': '14px'
}
)
], style={'marginRight': '20px', 'display': 'inline-block'}),
html.Div([
html.Label("Heatmap Title:", style={
'fontWeight': '500',
'color': '#2c3e50',
'marginBottom': '5px',
'display': 'block'
}),
dcc.Input(
id='heatmap-title',
type='text',
placeholder='Enter heatmap title',
autoComplete='off',
style={
'width': '200px',
'height': '36px',
'padding': '8px',
'borderRadius': '4px',
'border': '1px solid #dcdfe6',
'fontSize': '14px'
}
)
], style={'display': 'inline-block'}),
], style={'marginBottom': '15px'}),
# Second row of inputs
html.Div([
html.Div([
html.Label("Window Range:", style={
'fontWeight': '500',
'color': '#2c3e50',
'marginBottom': '5px',
'display': 'block'
}),
html.Div([
dcc.Input(
id='window-start',
type='number',
value=100,
placeholder='Start',
style={
'width': '80px',
'height': '36px',
'padding': '8px',
'borderRadius': '4px',
'border': '1px solid #dcdfe6',
'fontSize': '14px',
'marginRight': '10px'
}
),
dcc.Input(
id='window-end',
type='number',
value=1000,
placeholder='End',
style={
'width': '80px',
'height': '36px',
'padding': '8px',
'borderRadius': '4px',
'border': '1px solid #dcdfe6',
'fontSize': '14px',
'marginRight': '10px'
}
),
dcc.Input(
id='window-step',
type='number',
value=100,
placeholder='Step',
style={
'width': '80px',
'height': '36px',
'padding': '8px',
'borderRadius': '4px',
'border': '1px solid #dcdfe6',
'fontSize': '14px'
}
),
], style={'display': 'flex'})
], style={'marginRight': '40px', 'display': 'inline-block'}),
html.Div([
html.Label("Threshold Range:", style={
'fontWeight': '500',
'color': '#2c3e50',
'marginBottom': '5px',
'display': 'block'
}),
html.Div([
dcc.Input(
id='threshold-start',
type='number',
value=0.0,
placeholder='Start',
style={
'width': '80px',
'height': '36px',
'padding': '8px',
'borderRadius': '4px',
'border': '1px solid #dcdfe6',
'fontSize': '14px',
'marginRight': '10px'
}
),
dcc.Input(
id='threshold-end',
type='number',
value=2.0,
placeholder='End',
style={
'width': '80px',
'height': '36px',
'padding': '8px',
'borderRadius': '4px',
'border': '1px solid #dcdfe6',
'fontSize': '14px',
'marginRight': '10px'
}
),
dcc.Input(
id='threshold-step',
type='number',
value=0.2,
placeholder='Step',
style={
'width': '80px',
'height': '36px',
'padding': '8px',
'borderRadius': '4px',
'border': '1px solid #dcdfe6',
'fontSize': '14px'
}
),
], style={'display': 'flex'})
], style={'display': 'inline-block'}),
], style={'marginBottom': '15px'}),
html.Button(
'Run Backtest',
id='run-backtest',
n_clicks=0,
style={
'width': '150px',
'height': '36px',
'marginTop': '10px',
'backgroundColor': '#1a73e8',
'color': 'white',
'border': 'none',
'borderRadius': '4px',
'cursor': 'pointer',
'fontSize': '14px',
'fontWeight': '500'
}
),
], style={
'padding': '20px',
'backgroundColor': 'white',
'borderRadius': '8px',
'marginBottom': '20px',
'boxShadow': '0 1px 3px rgba(0,0,0,0.1)'
}),
# Heatmap Container
html.Div([
dcc.Graph(id='combined-heatmap'),
], style={
'marginBottom': '30px',
'backgroundColor': 'white',
'borderRadius': '10px',
'padding': '15px',
'boxShadow': '0 2px 4px rgba(0,0,0,0.1)'
}),
# Strategy details section
html.Div([
html.H3("Strategy Details",
id='strategy-header',
style={'textAlign': 'center', 'marginTop': '30px', 'marginBottom': '20px'}),
# Date range picker section
html.Div([
html.Label("Select Date Range:", style={'fontWeight': 'bold', 'marginRight': '10px'}),
dcc.DatePickerRange(
id='date-range-picker',
min_date_allowed=full_data.index[0],
max_date_allowed=full_data.index[-1],
start_date=full_data.index[0],
end_date=full_data.index[-1],
display_format='YYYY-MM-DD'
),
html.Button(
'Apply',
id='apply-date-range',
style={
'marginLeft': '10px',
'backgroundColor': '#2c3e50',
'color': 'white',
'border': 'none',
'padding': '5px 15px',
'borderRadius': '5px',
'cursor': 'pointer'
}
),
html.Button(
'Reset',
id='reset-date-range',
style={
'marginLeft': '10px',
'backgroundColor': '#7f7f7f',
'color': 'white',
'border': 'none',
'padding': '5px 15px',
'borderRadius': '5px',
'cursor': 'pointer'
}
)
], style={
'marginBottom': '20px',
'display': 'flex',
'alignItems': 'center',
'justifyContent': 'center'
}),
# New flex container for metrics and plots
html.Div([
# Left side - Metrics and Yearly Returns
html.Div([
# Metrics
html.Div(id='strategy-metrics', style={
'display': 'grid',
'gridTemplateColumns': 'repeat(2, 1fr)',
'gap': '10px', # Reduced from 20px
'padding': '10px', # Reduced from 20px
'backgroundColor': '#f8f9fa',
'borderRadius': '8px', # Reduced from 10px
'boxShadow': '0 2px 4px rgba(0,0,0,0.1)',
'height': 'fit-content',
'marginBottom': '15px', # Reduced from 20px
'fontSize': '12px' # Added to reduce text size
}),
# Yearly Returns
html.Div([
dcc.Graph(id='yearly-returns-plot')
], style={
'backgroundColor': 'white',
'borderRadius': '8px', # Reduced from 10px
'padding': '10px', # Reduced from 15px
'boxShadow': '0 2px 4px rgba(0,0,0,0.1)',
'marginTop': '15px' # Reduced from 20px
})
], style={
'width': '30%',
'marginRight': '15px' # Reduced from 20px
}),
# Right side - Plots
html.Div([
# Equity curve
html.Div([
dcc.Graph(id='equity-curve')
], style={
'marginBottom': '20px',
'backgroundColor': 'white',
'borderRadius': '10px',
'padding': '15px',
'boxShadow': '0 2px 4px rgba(0,0,0,0.1)'
}),
# Underwater plot
html.Div([
dcc.Graph(id='underwater-plot')
], style={
'backgroundColor': 'white',
'borderRadius': '10px',
'padding': '15px',
'boxShadow': '0 2px 4px rgba(0,0,0,0.1)'
})
], style={
'width': '70%', # Adjust this value to change the width of the plots section
'display': 'flex',
'flexDirection': 'column'
})
], style={
'display': 'flex',
'marginTop': '20px'
})
])
], style={
'maxWidth': '1800px',
'margin': '0 auto',
'padding': '20px',
'backgroundColor': '#ffffff'
})
])

View File

@@ -0,0 +1,116 @@
import pandas as pd
import numpy as np
from utils.helpers import zscore
def backtest(df, window, threshold, time_factor, commission, mode):
df = df.copy()
df['O2C_chg'] = df['Close'] / df['Open'] - 1
factor = df.active_addresses
df['indicator'] = zscore(factor, window)
longCondition = df['indicator'].shift() > threshold
shortCondition = df['indicator'].shift() < -threshold
df['pos'] = np.where(longCondition, 1, np.where(shortCondition, -1, 0))
df['pnl'] = df['pos'] * df['O2C_chg'] - commission * abs(df['pos'].diff())
sharpe = df['pnl'].mean() / df['pnl'].std() * np.sqrt(time_factor)
if mode == 'heatmap':
return pd.Series([window, threshold, sharpe], index=['window', 'threshold', 'sharpe'])
elif mode == 'full_report':
ar = df.pnl.mean() * time_factor * 100
mdd = (df.pnl.cumsum() - df.pnl.cumsum().cummax()).min() * 100
calmar = ar / abs(mdd)
beta, _ = np.polyfit(df.Close.pct_change()[1:], df.pnl.dropna(), 1)
num_trades = int(
pd.concat([abs(df.pos.diff()), pd.Series(abs(df.pos).iloc[-1])]).fillna(df.pos.iloc[0]).cumsum().iloc[
-1] / 2)
long_duration = (len(df.pos[df.pos == 1]) / len(df.pos)) * 100
short_duration = (len(df.pos[df.pos == -1]) / len(df.pos)) * 100
win_rate = len(df.pnl[df.pnl > 0]) / len(df.pnl[df.pnl > 0] + df.pnl[df.pnl < 0]) * 100
avg_win = df.pnl[df.pnl > 0].mean() * 100
avg_loss = df.pnl[df.pnl < 0].mean() * 100
turnover_pct = (long_duration + short_duration) / num_trades
turnover = turnover_pct * len(df) * 0.01
result_dict = {
'Return (Ann.) [%]': ar,
'Sharpe Ratio': sharpe,
'Calmar Ratio': calmar,
'Max. Drawdown [%]': mdd,
'Beta': beta,
'Win Rate [%]': win_rate,
'Avg Win [%]': avg_win,
'Avg Loss [%]': avg_loss,
'Number of Trades': num_trades,
'Long Duration [%]': long_duration,
'Short Duration [%]': short_duration,
'Turnover [%]': turnover_pct,
'Turnover Period': turnover,
'Equity Curve': df.pnl.cumsum().tolist(),
'Benchmark': df.O2C_chg.cumsum().tolist(),
'Dates': df.index.tolist(),
'Returns': df.pnl.tolist(), # Added daily PnL
'Benchmark Returns': df.O2C_chg.tolist() # Added benchmark returns
}
return result_dict
def combine_strategies(df, strategies, time_factor, commission):
"""
Combine multiple strategies and calculate aggregate metrics.
Parameters:
-----------
df : pandas.DataFrame
Input data with OHLCV columns
strategies : list of dict
List of strategies with 'window' and 'threshold' parameters
time_factor : int
Time factor for annualization
commission : float
Commission rate
Returns:
--------
dict
Dictionary containing combined backtest results
"""
combined_pnl = pd.Series(0.0, index=df.index)
combined_benchmark = pd.Series(0.0, index=df.index)
# Combine strategies
for strategy in strategies:
result = backtest(df, strategy['window'], strategy['threshold'],
time_factor, commission, 'full_report')
combined_pnl += pd.Series(result['Returns'], index=pd.to_datetime(result['Dates']))
combined_benchmark += pd.Series(result['Benchmark Returns'], index=pd.to_datetime(result['Dates']))
# Average the returns
n_strategies = len(strategies)
combined_pnl = combined_pnl / n_strategies
combined_benchmark = combined_benchmark / n_strategies
# Calculate metrics
ar = combined_pnl.mean() * time_factor * 100
mdd = (combined_pnl.cumsum() - combined_pnl.cumsum().cummax()).min() * 100
calmar = ar / abs(mdd)
sharpe = combined_pnl.mean() / combined_pnl.std() * np.sqrt(time_factor)
beta, _ = np.polyfit(df.Close.pct_change()[1:], combined_pnl.dropna(), 1)
return {
'Returns': combined_pnl.values,
'Benchmark Returns': combined_benchmark.values,
'Dates': df.index,
'Equity Curve': combined_pnl.cumsum().values,
'Benchmark': combined_benchmark.cumsum().values,
'Return (Ann.) [%]': ar,
'Sharpe Ratio': sharpe,
'Calmar Ratio': calmar,
'Max. Drawdown [%]': mdd,
'Beta': beta
}

View File

@@ -0,0 +1,29 @@
import pandas as pd
import numpy as np
def load_data(file_path='data/data.csv'):
try:
df = pd.read_csv(file_path)
df['date'] = pd.to_datetime(df['date'])
df.set_index('date', inplace=True)
required_columns = ['Open', 'Close', 'active_addresses']
missing_columns = [col for col in required_columns if col not in df.columns]
if missing_columns:
raise ValueError(f"Missing required columns: {missing_columns}")
return df
except FileNotFoundError:
raise FileNotFoundError(f"{file_path} not found")
except Exception as e:
raise Exception(f"Error loading data: {str(e)}")
def get_default_date_range(df):
"""Get the earliest and latest dates from the dataframe"""
return df.index.min().strftime('%Y-%m-%d'), df.index.max().strftime('%Y-%m-%d')
def zscore(x, window):
return (x - x.rolling(window).mean()) / x.rolling(window).std()

View File

@@ -0,0 +1,10 @@
---
tags: python, dashboard
---
主要想 de 一個 bug
加整多幾個
第一功能就係新增一個 correlation heatmap,
另外就新增一個 export 功能,把我所選的數據 export 到 csv