mirror of
https://github.com/c9s/bbgo.git
synced 2024-11-21 22:43:52 +00:00
Merge pull request #625 from c9s/feature/backtest-report
feature: web-based back-test report - add mantine UI framework
This commit is contained in:
commit
038781a094
|
@ -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;
|
||||
|
|
|
@ -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>;
|
||||
|
||||
|
||||
|
|
|
@ -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>
|
||||
|
|
|
@ -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",
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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>
|
||||
);
|
||||
|
|
|
@ -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>
|
||||
)
|
||||
|
|
|
@ -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;
|
||||
}
|
||||
|
|
|
@ -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
Loading…
Reference in New Issue
Block a user