Merge pull request #625 from c9s/feature/backtest-report

feature: web-based back-test report - add mantine UI framework
This commit is contained in:
Yo-An Lin 2022-05-19 02:57:08 +08:00 committed by GitHub
commit 038781a094
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
10 changed files with 827 additions and 539 deletions

View File

@ -1,9 +1,125 @@
import React, {useEffect, useState} from 'react';
import moment from 'moment';
import TradingViewChart from './TradingViewChart';
import {Container} from '@nextui-org/react';
import {ReportSummary} from "../types";
import {BalanceMap, ReportSummary} from "../types";
import {
Badge,
Container,
createStyles,
Grid,
Group,
Paper,
SimpleGrid,
Skeleton,
Table,
Text,
ThemeIcon,
Title
} from '@mantine/core';
import {ArrowDownRight, ArrowUpRight,} from 'tabler-icons-react';
const useStyles = createStyles((theme) => ({
root: {
paddingTop: theme.spacing.xl * 1.5,
paddingBottom: theme.spacing.xl * 1.5,
},
label: {
fontFamily: `Greycliff CF, ${theme.fontFamily}`,
},
}));
interface StatsGridIconsProps {
data: {
title: string;
value: string;
diff?: number
dir?: string;
desc?: string;
}[];
}
function StatsGridIcons({data}: StatsGridIconsProps) {
const {classes} = useStyles();
const stats = data.map((stat) => {
const DiffIcon = stat.diff && stat.diff > 0 ? ArrowUpRight : ArrowDownRight;
const DirIcon = stat.dir && stat.dir == "up" ? ArrowUpRight : ArrowDownRight;
return (
<Paper withBorder p="xs" radius="md" key={stat.title}>
<Group position="apart">
<div>
<Text
color="dimmed"
transform="uppercase"
weight={700}
size="xs"
className={classes.label}
>
{stat.title}
</Text>
<Text weight={700} size="xl">
{stat.value}
</Text>
</div>
{stat.dir ?
<ThemeIcon
color="gray"
variant="light"
sx={(theme) => ({color: stat.dir == "up" ? theme.colors.teal[6] : theme.colors.red[6]})}
size={38}
radius="md"
>
<DirIcon size={28}/>
</ThemeIcon>
: null}
{stat.diff ?
<ThemeIcon
color="gray"
variant="light"
sx={(theme) => ({color: stat.diff && stat.diff > 0 ? theme.colors.teal[6] : theme.colors.red[6]})}
size={38}
radius="md"
>
<DiffIcon size={28}/>
</ThemeIcon>
: null}
</Group>
{stat.diff ?
<Text color="dimmed" size="sm" mt="md">
<Text component="span" color={stat.diff && stat.diff > 0 ? 'teal' : 'red'} weight={700}>
{stat.diff}%
</Text>{' '}
{stat.diff && stat.diff > 0 ? 'increase' : 'decrease'} compared to last month
</Text> : null}
{stat.desc ? (
<Text color="dimmed" size="sm" mt="md">
{stat.desc}
</Text>
) : null}
</Paper>
);
});
return (
<div className={classes.root}>
<SimpleGrid cols={4} breakpoints={[{maxWidth: 'sm', cols: 1}]}>
{stats}
</SimpleGrid>
</div>
);
}
interface ReportDetailsProps {
basePath: string;
@ -20,6 +136,32 @@ const fetchReportSummary = (basePath: string, runID: string) => {
});
}
const skeleton = <Skeleton height={140} radius="md" animate={false}/>;
interface BalanceDetailsProps {
balances: BalanceMap;
}
const BalanceDetails = (props: BalanceDetailsProps) => {
const rows = Object.entries(props.balances).map(([k, v]) => {
return <tr key={k}>
<td>{k}</td>
<td>{v.available}</td>
</tr>;
});
return <Table>
<thead>
<tr>
<th>Currency</th>
<th>Balance</th>
</tr>
</thead>
<tbody>{rows}</tbody>
</Table>;
};
const ReportDetails = (props: ReportDetailsProps) => {
const [reportSummary, setReportSummary] = useState<ReportSummary>()
useEffect(() => {
@ -30,22 +172,78 @@ const ReportDetails = (props: ReportDetailsProps) => {
}, [props.runID])
if (!reportSummary) {
return <Container>
return <div>
<h2>Loading {props.runID}</h2>
</Container>;
</div>;
}
return <Container>
<h2>Back-test Run {props.runID}</h2>
<div>
{
reportSummary.symbols.map((symbol: string) => {
return <TradingViewChart basePath={props.basePath} runID={props.runID} reportSummary={reportSummary} symbol={symbol} intervals={["1m", "5m", "1h"]}/>
})
}
const totalProfit = Math.round(reportSummary.symbolReports.map((report) => report.pnl.profit).reduce((prev, cur) => prev + cur) * 100) / 100
const totalUnrealizedProfit = Math.round(reportSummary.symbolReports.map((report) => report.pnl.unrealizedProfit).reduce((prev, cur) => prev + cur) * 100) / 100
const totalTrades = reportSummary.symbolReports.map((report) => report.pnl.numTrades).reduce((prev, cur) => prev + cur)
</div>
</Container>;
const totalBuyVolume = reportSummary.symbolReports.map((report) => report.pnl.buyVolume).reduce((prev, cur) => prev + cur)
const totalSellVolume = reportSummary.symbolReports.map((report) => report.pnl.sellVolume).reduce((prev, cur) => prev + cur)
const volumeUnit = reportSummary.symbolReports.length == 1 ? reportSummary.symbolReports[0].market.baseCurrency : '';
// reportSummary.startTime
return <div>
<Container my="md" mx="xs">
<Title order={2}>RUN {props.runID}</Title>
<div>
{reportSummary.sessions.map((session) => <Badge key={session}>Exchange {session}</Badge>)}
{reportSummary.symbols.map((symbol) => <Badge key={symbol}>{symbol}</Badge>)}
<Badge>{reportSummary.startTime.toString()} ~ {reportSummary.endTime.toString()}</Badge>
<Badge>{
moment.duration((new Date(reportSummary.endTime)).getTime() - (new Date(reportSummary.startTime)).getTime()).humanize()
}</Badge>
</div>
<StatsGridIcons data={[
{title: "Profit", value: "$" + totalProfit.toString(), dir: totalProfit > 0 ? "up" : "down"},
{
title: "Unrealized Profit",
value: "$" + totalUnrealizedProfit.toString(),
dir: totalUnrealizedProfit > 0 ? "up" : "down"
},
{title: "Trades", value: totalTrades.toString()},
{title: "Buy Volume", value: totalBuyVolume.toString() + ` ${volumeUnit}`},
{title: "Sell Volume", value: totalSellVolume.toString() + ` ${volumeUnit}`},
]}/>
<Grid p={"xs"} mb={"lg"}>
<Grid.Col xs={6}>
<Title order={5}>Initial Total Balances</Title>
<BalanceDetails balances={reportSummary.initialTotalBalances}/>
</Grid.Col>
<Grid.Col xs={6}>
<Title order={5}>Final Total Balances</Title>
<BalanceDetails balances={reportSummary.finalTotalBalances}/>
</Grid.Col>
</Grid>
{
/*
<Grid>
<Grid.Col span={6}>
<Skeleton height={300} radius="md" animate={false}/>
</Grid.Col>
<Grid.Col xs={4}>{skeleton}</Grid.Col>
</Grid>
*/
}
<div>
{
reportSummary.symbols.map((symbol: string, i: number) => {
return <TradingViewChart key={i} basePath={props.basePath} runID={props.runID} reportSummary={reportSummary}
symbol={symbol} intervals={["1m", "5m", "1h"]}/>
})
}
</div>
</Container>
</div>;
};
export default ReportDetails;

View File

@ -1,5 +1,7 @@
import React, {useEffect, useState} from 'react';
import {Link} from '@nextui-org/react';
import {List, ThemeIcon} from '@mantine/core';
import {CircleCheck} from 'tabler-icons-react';
import {ReportEntry, ReportIndex} from '../types';
function fetchIndex(basePath: string, setter: (data: any) => void) {
@ -39,16 +41,35 @@ const ReportNavigator = (props: ReportNavigatorProps) => {
return <div>No back-test report data</div>
}
return <div>
{
reportIndex.runs.map((entry) => {
return <Link key={entry.id} onClick={() => {
if (props.onSelect) {
props.onSelect(entry);
}
}}>{entry.id}</Link>
})
}
return <div className={"report-navigator"}>
<List
spacing="xs"
size="xs"
center
icon={
<ThemeIcon color="teal" size={24} radius="xl">
<CircleCheck size={16}/>
</ThemeIcon>
}
>
{
reportIndex.runs.map((entry) => {
return <List.Item key={entry.id} onClick={() => {
if (props.onSelect) {
props.onSelect(entry);
}
}}>
<div style={{
"textOverflow": "ellipsis",
"overflow": "hidden",
"inlineSize": "190px",
}}>
{entry.id}
</div>
</List.Item>
})
}
</List>
</div>;

View File

@ -1,12 +1,10 @@
import React, {useEffect, useRef, useState} from 'react';
import {tsvParse} from "d3-dsv";
import { Button } from '@mantine/core';
// https://github.com/tradingview/lightweight-charts/issues/543
// const createChart = dynamic(() => import('lightweight-charts'));
import {createChart, CrosshairMode} from 'lightweight-charts';
import {Button} from "@nextui-org/react";
// const parseDate = timeParse("%Y-%m-%d");
const parseKline = () => {
return (d) => {
@ -426,15 +424,15 @@ const TradingViewChart = (props) => {
return (
<div>
<Button.Group>
<span>
{intervals.map((interval) => {
return <Button size="xs" key={interval} onPress={(e) => {
return <Button key={interval} compact onClick={(e) => {
setCurrentInterval(interval)
}}>
{interval}
</Button>
})}
</Button.Group>
</span>
<div ref={chartContainerRef} style={{'flex': 1, 'minHeight': 300}}>
</div>
</div>

View File

@ -9,14 +9,18 @@
"lint": "next lint"
},
"dependencies": {
"@nextui-org/react": "^1.0.0-beta.7",
"@mantine/core": "^4.2.5",
"@mantine/hooks": "^4.2.5",
"@mantine/next": "^4.2.5",
"d3-dsv": "^3.0.1",
"d3-format": "^3.1.0",
"d3-time-format": "^4.1.0",
"lightweight-charts": "^3.8.0",
"moment": "^2.29.3",
"next": "12.1.6",
"react": "18.1.0",
"react-dom": "18.1.0"
"react-dom": "18.1.0",
"tabler-icons-react": "^1.48.0"
},
"devDependencies": {
"@types/d3-dsv": "^3.0.0",

View File

@ -1,11 +1,26 @@
import '../styles/globals.css'
import type {AppProps} from 'next/app'
import {NextUIProvider} from '@nextui-org/react'
import Head from 'next/head';
import { MantineProvider } from '@mantine/core';
function MyApp({Component, pageProps}: AppProps) {
return <NextUIProvider>
<Component {...pageProps} />
</NextUIProvider>
return <>
<Head>
<title>Page title</title>
<meta name="viewport" content="minimum-scale=1, initial-scale=1, width=device-width" />
</Head>
<MantineProvider
withGlobalStyles
withNormalizeCSS
theme={{
/** Put your mantine theme override here */
colorScheme: 'light',
}}
>
<Component {...pageProps} />
</MantineProvider>
</>
}
export default MyApp

View File

@ -1,24 +1,41 @@
import Document, {DocumentContext, Head, Html, Main, NextScript} from 'next/document';
import {CssBaseline} from '@nextui-org/react';
// ----- mantine setup
import {createStylesServer, ServerStyles} from '@mantine/next';
import {DocumentInitialProps} from "next/dist/shared/lib/utils";
// const getInitialProps = createGetInitialProps();
const stylesServer = createStylesServer();
// -----
class MyDocument extends Document {
static async getInitialProps(ctx: DocumentContext) {
// this is for mantine
// static getInitialProps = getInitialProps;
static async getInitialProps(ctx: DocumentContext): Promise<DocumentInitialProps> {
const initialProps = await Document.getInitialProps(ctx);
// @ts-ignore
initialProps.styles = <>{initialProps.styles}</>;
return initialProps;
return {
...initialProps,
// use bracket [] instead of () to fix the type error
styles: [
<>
{initialProps.styles}
<ServerStyles html={initialProps.html} server={stylesServer}/>
</>
],
};
}
render() {
return (
<Html lang="en">
<Head>
{CssBaseline.flush()}
</Head>
<body>
<Main/>
<NextScript/>
<Main/>
<NextScript/>
</body>
</Html>
);

View File

@ -1,27 +1,48 @@
import type {NextPage} from 'next'
import Head from 'next/head'
import styles from '../styles/Home.module.css'
import { useRouter } from "next/router";
import {AppShell, Header, Navbar, Text} from '@mantine/core';
import ReportDetails from '../components/ReportDetails';
import ReportNavigator from '../components/ReportNavigator';
import {useState} from "react";
import {useEffect, useState} from "react";
const Home: NextPage = () => {
const [currentReport, setCurrentReport] = useState<any>();
const { query } = useRouter();
const basePath = query.basePath ? query.basePath as string : '/output';
return (
<div className={styles.container}>
<div>
<Head>
<title>Back-Test Report</title>
<title>BBGO Back-Test Report</title>
<meta name="description" content="Generated by create next app"/>
<link rel="icon" href="/favicon.ico"/>
</Head>
<main className={styles.main}>
<ReportNavigator onSelect={(reportEntry) => {
setCurrentReport(reportEntry)
}}/>
{
currentReport ? <ReportDetails basePath={'/output'} runID={currentReport.id}/> : null
}
<AppShell
padding="md"
navbar={<Navbar width={{base: 250}} height={500} p="xs">
<ReportNavigator onSelect={(reportEntry) => {
setCurrentReport(reportEntry)
}}/>
</Navbar>}
header={
<Header height={60} p="md">
<Text>BBGO Back-Test Report</Text>
</Header>
}
styles={(theme) => ({
main: {backgroundColor: theme.colorScheme === 'dark' ? theme.colors.dark[8] : theme.colors.gray[0]},
})}
>
{
currentReport ? <ReportDetails basePath={basePath} runID={currentReport.id}/> : null
}
</AppShell>
</main>
</div>
)

View File

@ -1,25 +1,3 @@
import { tsvParse, csvParse } from "d3-dsv";
import { timeParse } from "d3-time-format";
function parseData(parse) {
return function(d) {
d.date = parse(d.date);
d.open = +d.open;
d.high = +d.high;
d.low = +d.low;
d.close = +d.close;
d.volume = +d.volume;
return d;
};
}
const parseDate = timeParse("%Y-%m-%d");
export function getData() {
// original source: https://cdn.rawgit.com/rrag/react-stockcharts/master/docs/data/MSFT.tsv
const promiseMSFT = fetch("https://cdn.jsdelivr.net/gh/rrag/react-stockcharts@master/docs/data/MSFT.tsv")
.then(response => response.text())
.then(data => tsvParse(data, parseData(parseDate)))
return promiseMSFT;
}

View File

@ -1,10 +1,5 @@
.container {
padding: 0 2rem;
}
.main {
min-height: 100vh;
padding: 2rem 0;
}
.footer {

File diff suppressed because it is too large Load Diff