Source code for tradeexecutor.backtest.report

"""Create Jupyter Notebook based report.

Further reading

- `Export notebook HTML with embedded images <https://jupyter-contrib-nbextensions.readthedocs.io/en/latest/nbextensions/export_embedded/readme.html>`__
"""

import logging
import os.path
from pathlib import Path
from tempfile import NamedTemporaryFile
from typing import Optional

import nbformat
from bs4 import BeautifulSoup
from nbclient.exceptions import CellExecutionError
from nbconvert import HTMLExporter
from nbconvert.preprocessors import ExecutePreprocessor
from nbformat import NotebookNode

from tradeexecutor.state.state import State
from tradeexecutor.strategy.trading_strategy_universe import TradingStrategyUniverse
from tradingstrategy.client import BaseClient

from tradeexecutor.backtest.notebook import setup_charting_and_output
from tradeexecutor.strategy.strategy_module import StrategyModuleInformation


logger = logging.getLogger(__name__)



#: The default custom CSS overrides for the notebook HTML export
#:
DEFAULT_CUSTOM_CSS = """
/* trade-executor backtest report generator custom CSS */
body {
    padding: 0;   
}

.prompt {
    display: none !important;
}

#notebook {
    padding-top: 0 !important;
}

#notebook-container {
    padding: 0 !important;
    box-shadow: none;
    width: auto;
}

.code_cell {
    padding: 0;
}
"""


# Iframe height hack
# https://stackoverflow.com/a/44547866/315168
DEFAULT_CUSTOM_JS = """
console.log("Dynamic iframe resizer loaded");

function getDocHeight(doc) {
    // stackoverflow.com/questions/1145850/
    var body = doc.body, html = doc.documentElement;
    var height = Math.max( body.scrollHeight, body.offsetHeight, 
        html.clientHeight, html.scrollHeight, html.offsetHeight );
    return height;
}

window.addEventListener("load", function(){
    if(window.self === window.top) return; // if w.self === w.top, we are not in an iframe 
    send_height_to_parent_function = function(){
        //var height = document.getElementsByTagName("html")[0].clientHeight;
        //var height= document.getElementById('wrapper').offsetHeight;
        const height = getDocHeight(document);
        console.log("Sending height as " + height + "px");
        parent.postMessage({"iframeContentHeight" : height }, "*");
    }
    // send message to parent about height updates
    send_height_to_parent_function(); //whenever the page is loaded
    window.addEventListener("resize", send_height_to_parent_function); // whenever the page is resized
    var observer = new MutationObserver(send_height_to_parent_function);           // whenever DOM changes PT1
    var config = { attributes: true, childList: true, characterData: true, subtree:true}; // PT2
    observer.observe(window.document, config);                                            // PT3 
});
"""


class BacktestReportRunFailed(Exception):
    """Generating a backtest report failed.

    See the wrapped :py:class:`nbclient.exceptions.CellExecutionError` for more information.
    """


[docs]class BacktestReporter: """Shared between host environment and IPython report notebook. - A helper class to pass data in the notebook report generation context using temporary files. - Files are written by the host system after running the backtest - Files are passed as absolute paths in the first notebook cell that is modified before the notebook is executed """
[docs] def __init__(self, state: State, universe: TradingStrategyUniverse): """To write a report we need to inputs :param state: State that is the resulting trades that were made :param universe: Trading universe where the results where traded """ self.state = state self.universe = universe
def get_state(self) -> State: return self.state def get_universe(self) -> TradingStrategyUniverse: return self.universe
[docs] @classmethod def setup_report(cls, parameters) -> "BacktestReporter": """Set-up notebook side reporting. - Output formatting - Reading data from the host instance """ setup_charting_and_output() # By default matplotlib exports text as 2d curves to make sure # SVG renders correctly. However this will result to massive file sizes. # Here we hint matplotlib to export SVG labels as text. # Furthermore SVG labels do not show in the static HTML output otherwise. # https://stackoverflow.com/questions/34387893/output-matplotlib-figure-to-svg-with-text-as-text-not-curves # https://matplotlib.org/stable/users/explain/fonts.html#fonts-in-svg import matplotlib.pyplot as plt plt.rcParams['svg.fonttype'] = 'none' state_file = parameters["state_file"] universe_file = parameters["universe_file"] state = State.read_json_file(Path(state_file)) universe = TradingStrategyUniverse.read_pickle_dangerous(Path(universe_file)) return BacktestReporter( state=state, universe=universe, )
[docs]def export_backtest_report( state: State, universe: TradingStrategyUniverse, report_template: Path | None = None, output_notebook: Path | None = None, output_html: Path | None = None, show_code=False, custom_css: str | None=DEFAULT_CUSTOM_CSS, custom_js: str | None=DEFAULT_CUSTOM_JS, ) -> NotebookNode: """Creates the backtest visual report. - Opens a master template notebook - Injects the backtested state to this notebook by modifying the first cell of the notebook and writes a temporary state file path there - Runs the notebook - Writes the output notebook if specified - Writes the output HTML file if specified :param show_code: For the HTML report, should we hide the code cells. :param custom_css: CSS code to inject to the resulting HTML file to override styles. :param custom_js: JS code to inject to the resulting HTML file to support iframe embedding. :return: Returns the executed notebook contents :raise BacktestReportRunFailed: In the case the notebook had a run-time exception and Python code could not complete. """ assert isinstance(state, State), f"Expected State, got {state}" name = state.name logger.info("Creating backtest result report for %s", name) if report_template is None: report_template = Path(os.path.join(os.path.dirname(__file__), "backtest_report_template.ipynb")) assert report_template.exists(), f"Does not exist: {report_template}" # Pass over the state to the notebook as JSON file dump with NamedTemporaryFile(suffix='.json', prefix=os.path.basename(__file__)) as state_temp, \ NamedTemporaryFile(suffix='.pickle', prefix=os.path.basename(__file__)) as universe_temp: state_path = Path(state_temp.name).absolute() state.write_json_file(state_path) universe_path = Path(universe_temp.name).absolute() universe.write_pickle(universe_path) # https://nbconvert.readthedocs.io/en/latest/execute_api.html with open(report_template) as f: nb = nbformat.read(f, as_version=4) # Replace the first cell that allows us to pass parameters # See # - https://github.com/nteract/papermill/blob/main/papermill/parameterize.py # - https://github.com/takluyver/nbparameterise/blob/master/nbparameterise/code.py # for inspiration cell = nb.cells[0] assert cell.cell_type == "code", f"Assumed first cell is parameter cell, got {cell}" assert "parameters =" in cell.source, f"Did not see parameters = definition in the cell source: {cell.source}" cell.source = f"""parameters = {{ "state_file": "{state_path}", "universe_file": "{universe_path}", }} """ # Run the notebook ep = ExecutePreprocessor(timeout=600, kernel_name='python3') try: ep.preprocess(nb, {'metadata': {'path': '.'}}) except CellExecutionError as e: raise BacktestReportRunFailed(f"Could not run backtest reporter for {name}") from e # Write ipynb file that contains output cells created in place if output_notebook is not None: with open(output_notebook, 'w', encoding='utf-8') as f: nbformat.write(nb, f) # Write a static HTML file based on the notebook if output_html is not None: html_exporter = HTMLExporter( template_name='classic', embed_images=True, exclude_input=show_code is False, exclude_input_prompt=True, exclude_output_prompt=True, ) # Image are inlined in the output html_content, resources = html_exporter.from_notebook_node(nb) # Inject our custom css if custom_css is not None: html_content = _inject_custom_css_and_js(html_content, custom_css, custom_js) with open(output_html, 'w', encoding='utf-8') as f: f.write(html_content) logger.info("Wrote HTML report to %s, total %d bytes", output_html, len(html_content)) return nb
def _inject_custom_css_and_js(html: str, css_code: str, js_code: str) -> str: """Injects new <style> tag to HTML code. Use BeautifulSoup to parse HTMl, inject new <style> tag, reassemble. The resulting HTML looks like: .. code-block:: text <html> <head> ... <style id="trade-executor-css-inject"> ... """ assert css_code soup = BeautifulSoup(html, features='lxml') head = soup.head # Add a style tag class for better diagnostics tag = soup.new_tag('style', attrs={"id": "trade-executor-css-inject"}, type='text/css') head.append(tag) tag.append(css_code) tag = soup.new_tag('script', attrs={"id": "trade-executor-js-inject"}) head.append(tag) tag.append(js_code) return str(soup)