first commit

This commit is contained in:
Francois Klieber committed 2021-06-06 20:25:14 +02:00
commit aa7869eb33
386 files changed
+69375

No files matched your search

+99
View File
@@ -0,0 +1,99 @@
<script>
import top from './components/layout/header.vue'
import bottom from './components/layout/footer.vue'
import modal from './components/layout/modal.vue'
export default {
name: 'app',
components: {
top,
bottom,
modal
}
}
</script>
<template lang='pug'>
#app
top
.fill
router-view.view
bottom
modal
</template>
<style>
#app {
display: flex;
min-height: 100vh;
flex-direction: column;
}
.fill {
flex: 1;
}
.text {
max-width: 500px;
}
input {
background: none;
margin-top: 0.5em;
}
.params {
min-height: 235px;
line-height: 1.3em;
}
.hr {
margin-top: 2rem;
margin-bottom: 2rem;
height: 10px;
background-color: rgba(250,250,250,.99);
}
.contain {
width: 900px;
margin-left: auto;
margin-right: auto;
}
.btn--primary {
display: inline-block;
margin-right: 12px;
margin-bottom: 12px;
height: 40px;
padding: 0 18px;
border-radius: 4px;
background-color: #3498db;
text-shadow: 0 1px 3px rgba(36,180,126,.4);
box-shadow: 0 4px 6px rgba(50,50,93,.11), 0 1px 3px rgba(0,0,0,.08);
color: #fff;
text-decoration: none;
line-height: 40px;
transition: transform 250ms;
}
.btn--primary:hover {
transform: translateY(-1px);
box-shadow: 0 7px 14px rgba(50,50,93,.1), 0 3px 6px rgba(0,0,0,.08);
background-color: #3498db;
color: #fff;
text-decoration: none;
}
.btn--primary:active,
.btn--primary:focus {
background-color: #3498db;
color: #fff;
text-decoration: none;
}
.btn--primary:active {
transform: translateY(1px);
}
</style>
@@ -0,0 +1,139 @@
<template lang='pug'>
div
dataset-picker.my2(v-on:dataset='updateDataset').contain
.hr
strat-picker.my2(v-on:stratConfig='updateStrat').contain
.hr
paper-trader(v-on:settings='updatePaperTrader').contain
.hr
</template>
<script>
import datasetPicker from '../global/configbuilder/datasetpicker.vue'
import stratPicker from '../global/configbuilder/stratpicker.vue'
import paperTrader from '../global/configbuilder/papertrader.vue'
import _ from 'lodash'
import { get } from '../../tools/ajax'
export default {
created: function() {
get('configPart/performanceAnalyzer', (error, response) => {
this.performanceAnalyzer = toml.parse(response.part);
this.performanceAnalyzer.enabled = true;
});
},
data: () => {
return {
dataset: {},
strat: {},
paperTrader: {},
performanceAnalyzer: {}
}
},
components: {
stratPicker,
datasetPicker,
paperTrader
},
computed: {
market: function() {
if(!this.dataset.exchange)
return {};
return {
exchange: this.dataset.exchange,
currency: this.dataset.currency,
asset: this.dataset.asset
}
},
range: function() {
if(!this.dataset.exchange)
return {};
return {
from: this.dataset.from,
to: this.dataset.to
}
},
config: function() {
let config = {};
Object.assign(
config,
{ watch: this.market },
{ paperTrader: this.paperTrader },
this.strat,
{
backtest: {
daterange: this.range
},
backtestResultExporter: {
enabled: true,
writeToDisk: false,
data: {
stratUpdates: false,
roundtrips: true,
stratCandles: true,
stratCandleProps: ['open'],
trades: true
}
}
},
{ performanceAnalyzer: this.performanceAnalyzer },
);
config.valid = this.validConfig(config);
config.backtestResultExporter.enabled = true;
return config;
}
},
methods: {
validConfig: function(config) {
if(!config.backtest)
return false;
if(!config.backtest.daterange)
return false;
if(_.isEmpty(config.backtest.daterange))
return false;
if(!config.watch)
return false;
if(!config.tradingAdvisor)
return false;
let strat = config.tradingAdvisor.method;
if(_.isEmpty(config[ strat ]))
return false;
if(config.tradingAdvisor) {
if(_.isNaN(config.tradingAdvisor.candleSize))
return false;
else if(config.tradingAdvisor.candleSize == 0)
return false;
}
return true;
},
updateDataset: function(set) {
this.dataset = set;
this.$emit('config', this.config);
},
updateStrat: function(sc) {
this.strat = sc;
this.$emit('config', this.config);
},
updatePaperTrader: function(pt) {
this.paperTrader = pt;
this.paperTrader.enabled = true;
this.$emit('config', this.config);
},
}
}
</script>
<style>
</style>
@@ -0,0 +1,55 @@
<template lang='pug'>
div
h2.contain Backtest
.hr
config-builder(v-on:config='check')
div(v-if='backtestable')
.txt--center
a.w100--s.my1.btn--primary(href='#', v-if='backtestState !== "fetching"', v-on:click.prevent='run') Backtest
div(v-if='backtestState === "fetching"').scan-btn
p Running backtest..
spinner
result(v-if='backtestResult && backtestState === "fetched"', :result='backtestResult')
</template>
<script>
import configBuilder from './backtestConfigBuilder.vue'
import result from './result/result.vue'
import { post } from '../../tools/ajax'
import spinner from '../global/blockSpinner.vue'
export default {
data: () => {
return {
backtestable: false,
backtestState: 'idle',
backtestResult: false,
config: false,
}
},
methods: {
check: function(config) {
// console.log('CHECK', config);
this.config = config;
if(!config.valid)
return this.backtestable = false;
this.backtestable = true;
},
run: function() {
this.backtestState = 'fetching';
post('backtest', this.config, (error, response) => {
this.backtestState = 'fetched';
this.backtestResult = response;
});
}
},
components: {
configBuilder,
result,
spinner
}
}
</script>
@@ -0,0 +1,104 @@
<template lang='pug'>
#chartWrapper(v-bind:class='{ clickable: !isClicked }')
.shield(v-on:click.prevent='click')
svg#chart(width='960', :height='height')
</template>
<script>
import chart from '../../../d3/chart4'
import { draw as drawMessage, clear as clearMessage } from '../../../d3/message'
const MIN_CANDLES = 4;
export default {
props: ['data', 'height'],
data: function() {
return {
isClicked: false
}
},
watch: {
data: function() { this.render() },
},
created: function() { setTimeout( this.render, 100) },
beforeDestroy: function() {
this.remove();
},
methods: {
click: function() {
this.isClicked = true;
},
render: function() {
this.remove();
if(_.size(this.data.candles) < MIN_CANDLES) {
drawMessage('Not enough data to spawn chart');
} else {
chart(this.data.candles, this.data.trades, this.height);
}
},
remove: function() {
d3.select('#chart').html('');
}
}
}
</script>
<style>
#chartWrapper.clickable {
position: relative;
}
#chartWrapper.clickable .shield {
cursor: zoom-in;
position: absolute;
top: 0;
bottom: 0;
left: 0;
right: 0;
background: grey;
opacity: 0.1;
}
#chart {
background-color: #eee;
width: 100%;
}
#chart circle {
clip-path: url(#clip);
}
#chart .zoom {
cursor: move;
fill: none;
pointer-events: all;
}
#chart .line {
fill: none;
stroke: steelblue;
stroke-width: 1.5px;
clip-path: url(#clip);
}
/*#chart .price.line {
stroke-width: 2.5px;
}*/
#chart circle.buy {
fill: #7FFF00;
}
#chart circle.sell {
fill: red;
}
</style>
@@ -0,0 +1,41 @@
<template lang='pug'>
div
.hr.contain
div.contain
h3 Backtest result
result-summary(:report='result.performanceReport')
.hr.contain
chart(:data='candles', height='500')
.hr.contain
roundtripTable(:roundtrips='result.roundtrips')
</template>
<script>
import resultSummary from './summary.vue'
import chart from './chartWrapper.vue'
import roundtripTable from './roundtripTable.vue'
export default {
props: ['result'],
data: () => {
return {}
},
methods: {},
components: {
roundtripTable,
resultSummary,
chart
},
computed: {
candles: function() {
return {
candles: this.result.stratCandles,
trades: this.result.trades
};
}
}
}
</script>
<style>
</style>
@@ -0,0 +1,93 @@
<template lang='pug'>
.contain.roundtrips
h2 Roundtrips
table(v-if='roundtrips.length')
thead
tr
th Entry at (UTC)
th Exit at (UTC)
th Exposure
th Entry balance
th Exit balance
th P&amp;L
th Profit
tr(v-for='rt in roundtrips')
td {{ fmt(rt.entryAt) }}
td {{ fmt(rt.exitAt) }}
td {{ diff(rt.duration) }}
td {{ round(rt.entryBalance) }}
td {{ round(rt.exitBalance) }}
template(v-if="Math.sign(rt.pnl)===-1")
td.loss {{ Math.sign(rt.pnl)*rt.pnl.toFixed(2) }}
td.loss {{ rt.profit.toFixed(2) }}%
template(v-else)
td.profit {{ rt.pnl.toFixed(2) }}
td.profit {{ rt.profit.toFixed(2) }}%
div(v-if='!roundtrips.length')
p Not enough data to display
</template>
<script>
import _ from 'lodash'
export default {
props: ['roundtrips'],
data: () => {
return {}
},
methods: {
diff: n => moment.duration(n).humanize(),
humanizeDuration: (n) => window.humanizeDuration(n),
fmt: date => {
// roundtrips coming out of a backtest
// are unix timestamp, live roundtrips
// are date strings.
// TODO: normalize
let mom;
if(_.isNumber(date)) {
mom = moment.unix(date);
} else {
mom = moment(date).utc();
}
return mom.utc().format('YYYY-MM-DD HH:mm');
},
round: n => (+n).toFixed(3),
},
}
</script>
<style>
.roundtrips {
margin-top: 50px;
margin-bottom: 50px;
}
.roundtrips table {
width: 100%;
}
.roundtrips table th,
.roundtrips table td {
border: 1px solid #c6cbd1;
padding: 8px 12px;
}
.roundtrips table td.loss {
color: red;
text-align: right;
}
.roundtrips table td.profit {
color: green;
text-align: right;
}
.roundtrips table tr:nth-child(2n) {
background-color: #f6f8fa;
}
</style>
@@ -0,0 +1,58 @@
<template lang='pug'>
div.contain
.grd-row.summary
.grd-row-col-3-6
table.p1
tr
th start time
td {{ report.startTime }}
tr
th end time
td {{ report.endTime }}
tr
th timespan
td {{ report.timespan }}
tr
th start price
td {{ round(report.startPrice) }} {{ report.currency }}
tr
th end price
td {{ round(report.endPrice) }} {{ report.currency }}
tr
th market
td {{ round(report.market) }}%
paperTradeSummary(:report='report')
</template>
<script>
import paperTradeSummary from '../../global/paperTradeSummary.vue'
export default {
props: ['report'],
components: {
paperTradeSummary
},
methods: {
round: n => (+n).toFixed(5)
},
computed: {
profitClass: function() {
if(this.report.relativeProfit > 0)
return 'profit'
else
return 'loss'
}
}
}
</script>
<style>
.summary td {
text-align: right;
}
</style>
@@ -0,0 +1,96 @@
<template lang='pug'>
.grd.contain
h3 Add an API key
p Make sure that the API key has the permissions to create and cancel orders and view balances.
.grd-row
.grd-row-col-3-6.mx1
h3 Exchange
exchange-picker(v-on:exchange='updateExchange', only-tradable='true')
.grd-row-col-3-6.mx1
h3 Credentials
template(v-for='cred in requires')
label {{ cred }}
input(v-model='credentials[cred]')
.txt--center
a.w100--s.my1.btn--primary(href='#', v-on:click.prevent='upload') Add
</template>
<script>
import exchangePicker from '../global/configbuilder/exchangepicker.vue'
import _ from 'lodash'
import { post } from '../../tools/ajax';
export default {
data: () => {
return {
exchange: false,
credentials: {}
}
},
components: {
exchangePicker
},
computed: {
apiKeySets: function() {
return this.$store.state.apiKeys;
},
exchanges: function() {
return this.$store.state.exchanges;
},
requires: function() {
if(!this.exchanges)
return [];
if(!this.exchange)
return [];
return this.exchanges[this.exchange].requires;
},
config: function() {
let config = {
exchange: this.exchange,
values: this.credentials
};
return config;
}
},
watch: {
credentials: function() {
this.emitConfig();
}
},
methods: {
updateExchange: function(exchange) {
this.credentials = {};
this.exchange = exchange;
this.emitConfig();
},
emitConfig: function() {
this.$emit('config', this.config);
},
upload: function() {
let exchange = this.config.exchange;
if(
this.exchanges &&
this.apiKeySets.includes(exchange) &&
!confirm(`You already have API keys for ${exchange} defined, do you want to overwrite them?`)
)
return;
post('addApiKey', this.config, (error, response) => {
if(error)
return alert(error);
this.credentials = {};
});
}
}
}
</script>
<style>
</style>
+58
View File
@@ -0,0 +1,58 @@
<template lang='pug'>
div.contain
h2 Config
.hr
h3 Available API keys
p(v-if='!apiKeySets.length')
em You don't have any API keys yet.
ul
li(v-for='exchange in apiKeySets') {{ exchange }} (
a(href='#', v-on:click.prevent='removeApiKey(exchange)') remove
| )
a.btn--primary(href='#', v-if='!addApiToggle', v-on:click.prevent='openAddApi') Add an API key
template(v-if='addApiToggle')
.hr
apiConfigBuilder
.hr
</template>
<script>
import apiConfigBuilder from './apiConfigBuilder.vue';
import { post } from '../../tools/ajax';
export default {
components: {
apiConfigBuilder
},
data: () => {
return {
addApiToggle: false,
}
},
methods: {
openAddApi: function() {
this.addApiToggle = true;
},
removeApiKey: function(exchange) {
if(!confirm('Are you sure you want to delete these API keys?'))
return;
post('removeApiKey', {exchange}, (error, response) => {
if(error)
return alert(error);
});
}
},
computed: {
apiKeySets: function() {
return this.$store.state.apiKeys
}
},
watch: {
apiKeySets: function() {
this.addApiToggle = false;
}
}
}
</script>
+103
View File
@@ -0,0 +1,103 @@
<template lang='pug'>
.contain
.text(v-html='intro')
.hr
h2 Available datasets
.txt--center.my2(v-if='datasetScanstate === "idle"')
a.w100--s.btn--primary.scan-btn(href='#', v-on:click.prevent='scan') Scan available data
.txt--center.my2(v-if='datasetScanstate === "scanning"')
spinner
.my2(v-if='datasetScanstate === "scanned"')
.bg--orange.p1.warning.my1(v-if='unscannableMakets.length')
p.clickable(v-if='!viewUnscannable', v-on:click.prevent='toggleUnscannable') Some markets were unscannable, click here for details.
template(v-if='viewUnscannable')
p Unable to find datasets in the following markets:
.mx2(v-for='market in unscannableMakets')
| - {{ market.exchange }}:{{ market.currency }}:{{ market.asset }}
template(v-if='datasets.length')
table.full.data
thead
tr
th exchange
th currency
th asset
th from
th to
th duration
tbody
tr(v-for='set in datasets')
td {{ set.exchange }}
td {{ set.currency }}
td {{ set.asset }}
td {{ fmt(set.from) }}
td {{ fmt(set.to) }}
td {{ humanizeDuration(set.to.diff(set.from)) }}
template(v-if='!datasets.length')
p It looks like you don't have any local data yet.
.my2
h2 Import more data
p.text You can easily import more market data directly from exchanges using the importer.
router-link.btn--primary(to='/data/importer') Go to the importer!
</template>
<script>
import spinner from '../global/blockSpinner.vue'
import marked from '../../tools/marked'
import dataset from '../global/mixins/dataset'
// global moment
// global humanizeDuration
let intro = marked(`
## Local data
Gekko needs local market data in order to backtest strategies. The local
data can also be used in a warmup period when running a strategy against a
live market.
`);
export default {
mixins: [ dataset ],
components: {
spinner
},
data: () => {
return {
intro,
viewUnscannable: false
}
},
methods: {
toggleUnscannable: function() { this.viewUnscannable = true },
humanizeDuration: (n) => window.humanizeDuration(n),
fmt: mom => mom.format('YYYY-MM-DD HH:mm'),
}
}
</script>
<style>
.clickable {
cursor: pointer;
}
table.full {
width: 100%;
}
table.full td {
padding: 0.5rem 0;
}
table.full.data th {
text-align: left;
padding: 0.5rem 0;
}
.warning p {
margin: 0;
padding: 0;
}
</style>
@@ -0,0 +1,65 @@
<template lang='pug'>
.grd.contain
.grd-row
.grd-row-col-3-6.mx1
h3 Market
market-picker(v-on:market='updateMarketConfig', only-importable='true')
.grd-row-col-3-6.mx1
range-creator(v-on:range='updateRange')
</template>
<script>
import marketPicker from '../../global/configbuilder/marketpicker.vue'
import rangeCreator from '../../global/configbuilder/rangecreator.vue'
import _ from 'lodash'
export default {
data: () => {
return {
market: {},
range: {}
}
},
components: {
marketPicker,
rangeCreator
},
computed: {
config: function() {
let config = {};
Object.assign(
config,
this.market,
{
importer: {
daterange: this.range
}
},
{
candleWriter: { enabled: true }
}
);
return config;
}
},
methods: {
updateMarketConfig: function(mc) {
this.market = mc;
this.emitConfig();
},
updateRange: function(range) {
this.range = range;
this.emitConfig();
},
emitConfig: function() {
this.$emit('config', this.config);
}
}
}
</script>
<style>
</style>
@@ -0,0 +1,90 @@
<template lang='pug'>
div.contain.my2
div.text(v-html='intro')
.hr
h3 Currently running imports
p(v-if='imports.length === 0') You currently don't have any imports running.
ul(v-if='imports.length')
li(v-for='_import in imports')
router-link(:to='"/data/importer/import/" + _import.id') {{ _import.watch.exchange }}:{{ _import.watch.currency }}/{{ _import.watch.asset }}
.hr
h3 Start a new import
import-config-builder(v-on:config='updateConfig')
.hr
.txt--center
a.w100--s.my1.btn--primary(href='#', v-on:click.prevent='run') Import
</template>
<script>
import { post } from '../../../tools/ajax'
import spinner from '../../global/blockSpinner.vue'
import importConfigBuilder from './importConfigBuilder.vue'
import marked from '../../../tools/marked'
let intro = marked(`
## Import data
The importer can download historical market data directly from the exchange.
`)
export default {
components: {
importConfigBuilder,
spinner
},
data: () => {
return {
intro,
config: {}
}
},
computed: {
imports: function() {
return this.$store.state.imports
}
},
methods: {
daysApart: function(range) {
let to = moment(range.to);
let from = moment(range.from);
return to.diff(from, 'days');
},
updateConfig: function(config) {
this.config = config;
},
run: function() {
let daysApart = this.daysApart(this.config.importer.daterange);
if(daysApart < 1)
return alert('You can only import at least one day of data..')
let exchange = this.$store.state.exchanges[this.config.watch.exchange];
if ("exchangeMaxHistoryAge" in exchange) {
if (moment(this.config.importer.daterange.from) < moment().subtract(exchange.exchangeMaxHistoryAge, "days")) {
return alert('Your date from is too old for ' + this.config.watch.exchange + '. It supports only the last ' + exchange.exchangeMaxHistoryAge + ' days..');
}
}
post('import', this.config, (error, response) => {
if(error)
return alert(error);
this.$store.commit('addImport', response);
this.$router.push({
path: `/data/importer/import/${response.id}`,
})
});
}
}
}
</script>
<style>
</style>
@@ -0,0 +1,110 @@
<template lang='pug'>
div.contain.my2
div(v-if='data && !data.done')
h2 Importing data..
.grd
.grd-row
.grd-row-col-2-6 <strong>Market:</strong>
.grd-row-col-4-6 {{ data.watch.exchange }}
.grd-row
.grd-row-col-2-6 <strong>Currency/Asset:</strong>
.grd-row-col-4-6 {{ data.watch.currency }}/{{ data.watch.asset }}
.grd
.grd-row
.grd-row-col-2-6 <strong>From:</strong>
.grd-row-col-4-6 {{ fmt(from) }}
.grd-row
.grd-row-col-2-6 <strong>To:</strong>
.grd-row-col-4-6 {{ fmt(to) }}
.grd-row(v-if='initialized')
.grd-row-col-2-6 <strong>Imported data until:</strong>
.grd-row-col-4-6 {{ fmt(latest) }}
.grd-row(v-if='initialized')
.grd-row-col-2-6 <strong>Remaining:</strong>
.grd-row-col-4-6 {{ fromEnd }}
spinner(v-if='!initialized')
.contain(v-if='initialized')
progressBar(:progress='progress')
p
em (you don't have to wait until the import is done,
| you can already start
router-link(to='/backtest') backtesting
| ).
div(v-if='data && data.done').txt--center
h2 Import done
p
| Go and
router-link(to='/backtest') backtest
| with your new data!
div(v-if='!data').txt--center
h2 ERROR: Unknown import
p
I don't know this import..
</template>
<script>
import _ from 'lodash';
import progressBar from '../../global/progressBar.vue'
import spinner from '../../global/blockSpinner.vue'
export default {
components: {
progressBar,
spinner
},
computed: {
data: function() {
return _.find(
this.$store.state.imports,
{ id: this.$route.params.id }
);
},
initialized: function() {
if(this.data && this.latest.isValid())
return true
},
latest: function() {
if(this.data)
return this.mom(this.data.latest);
},
fromEndMs: function() {
if(this.data)
return this.to.diff(this.latest);
},
fromEnd: function() {
if(!this.latest)
return 'LOADING'
return humanizeDuration(this.fromEndMs);
},
from: function() {
if(this.data)
return this.mom(this.data.from)
},
to: function() {
if(this.data)
return this.mom(this.data.to)
},
timespan: function() {
if(this.data)
return this.to.diff(this.from)
},
progress: function() {
if(!this.data)
return;
const current = this.timespan - this.fromEndMs;
return 100 * current / this.timespan;
}
},
methods: {
fmt: mom => { return mom.format('YYYY-MM-DD HH:mm:ss') },
mom: str => moment.utc(str)
}
}
</script>
<style>
</style>
@@ -0,0 +1,123 @@
<template lang='pug'>
.grd.contain
.grd-row
.grd-row-col-3-6.mx1
h3 Market
market-picker(v-on:market='updateMarketConfig', :only-tradable='isTradebot')
.grd-row-col-3-6.mx1
type-picker(v-on:type='updateType')
template(v-if='type !== "market watcher"')
.hr
strat-picker.contain.my2(v-on:stratConfig='updateStrat')
.hr(v-if='type === "paper trader"')
paper-trader(v-on:settings='updatePaperTrader', v-if='type === "paper trader"')
</template>
<script>
import marketPicker from '../global/configbuilder/marketpicker.vue'
import typePicker from '../global/configbuilder/typepicker.vue'
import stratPicker from '../global/configbuilder/stratpicker.vue'
import paperTrader from '../global/configbuilder/papertrader.vue'
import { get } from '../../tools/ajax'
import _ from 'lodash'
export default {
created: function() {
get('configPart/candleWriter', (error, response) => {
this.candleWriter = toml.parse(response.part);
});
get('configPart/performanceAnalyzer', (error, response) => {
this.performanceAnalyzer = toml.parse(response.part);
this.performanceAnalyzer.enabled = true;
});
},
data: () => {
return {
market: {},
range: {},
type: '',
strat: {},
paperTrader: {},
candleWriter: {},
performanceAnalyzer: {}
}
},
components: {
marketPicker,
typePicker,
stratPicker,
paperTrader
},
computed: {
isTradebot: function() {
return this.type === 'tradebot';
},
config: function() {
let config = {};
Object.assign(
config,
this.market,
this.strat,
{ paperTrader: this.paperTrader },
{ candleWriter: this.candleWriter },
{ type: this.type },
{ performanceAnalyzer: this.performanceAnalyzer }
);
if(this.isTradebot) {
delete config.paperTrader;
config.trader = { enabled: true }
}
config.valid = this.validConfig(config);
return config;
}
},
methods: {
validConfig: config => {
if(config.type === 'market watcher')
return true;
if(!config.tradingAdvisor)
return false;
if(_.isNaN(config.tradingAdvisor.candleSize))
return false;
else if(config.tradingAdvisor.candleSize == 0)
return false;
let strat = config.tradingAdvisor.method;
if(_.isEmpty(config[ strat ]))
return false;
return true;
},
updateMarketConfig: function(mc) {
this.market = mc;
this.emitConfig();
},
updateType: function(type) {
this.type = type;
this.emitConfig();
},
updateStrat: function(strat) {
this.strat = strat;
this.emitConfig();
},
updatePaperTrader: function(pt) {
this.paperTrader = pt;
this.paperTrader.enabled = true;
this.emitConfig();
},
emitConfig: function() {
this.$emit('config', this.config);
}
}
}
</script>
<style>
</style>
+143
View File
@@ -0,0 +1,143 @@
<template lang='pug'>
.contain.py2
h3 Market watchers
.text(v-if='!watchers.length')
p You don't have any market watchers.
table.full.clickable(v-if='watchers.length')
thead
tr
th exchange
th currency
th asset
th status
th started at
th last update
th duration
tbody
tr.clickable(v-for='gekko in watchers', v-on:click='$router.push({path: `/live-gekkos/${gekko.id}`})')
td {{ gekko.config.watch.exchange }}
td {{ gekko.config.watch.currency }}
td {{ gekko.config.watch.asset }}
td {{ status(gekko) }}
td
template(v-if='gekko.events.initial.candle') {{ fmt(gekko.events.initial.candle.start) }}
td
template(v-if='gekko.events.latest.candle') {{ fmt(gekko.events.latest.candle.start) }}
td
template(v-if='gekko.events.initial.candle && gekko.events.latest.candle') {{ timespan(gekko.events.latest.candle.start, gekko.events.initial.candle.start) }}
h3 Strat runners
.text(v-if='!stratrunners.length')
p You don't have any stratrunners.
table.full(v-if='stratrunners.length')
thead
tr
th exchange
th currency
th asset
th status
th duration
th strategy
th PnL
th type
th trades
tbody
tr.clickable(v-for='gekko in stratrunners', v-on:click='$router.push({path: `/live-gekkos/${gekko.id}`})')
td {{ gekko.config.watch.exchange }}
td {{ gekko.config.watch.currency }}
td {{ gekko.config.watch.asset }}
td {{ status(gekko) }}
td
template(v-if='gekko.events.initial.candle && gekko.events.latest.candle') {{ timespan(gekko.events.latest.candle.start, gekko.events.initial.candle.start) }}
td {{ gekko.config.tradingAdvisor.method }}
td
template(v-if='!report(gekko)') 0
template(v-if='report(gekko)') {{ round(report(gekko).profit) }} {{ report(gekko).currency }}
td {{ gekko.logType }}
td
template(v-if='!gekko.events.tradeCompleted') 0
template(v-if='gekko.events.tradeCompleted') {{ gekko.events.tradeCompleted.length }}
.hr
h2 Start a new live Gekko
router-link.btn--primary(to='/live-gekkos/new') Start a new live Gekko!
</template>
<script>
// global moment
// global humanizeDuration
export default {
created: function() {
this.timer = setInterval(() => {
this.now = moment();
}, 1000)
},
destroyed: function() {
clearTimeout(this.timer);
},
data: () => {
return {
timer: false,
now: moment()
}
},
computed: {
stratrunners: function() {
return _.values(this.$store.state.gekkos)
.concat(_.values(this.$store.state.archivedGekkos))
.filter(g => {
if(g.logType === 'papertrader')
return true;
if(g.logType === 'tradebot')
return true;
return false;
})
},
watchers: function() {
return _.values(this.$store.state.gekkos)
.concat(_.values(this.$store.state.archivedGekkos))
.filter(g => g.logType === 'watcher')
}
},
methods: {
humanizeDuration: (n) => window.humanizeDuration(n),
moment: mom => moment.utc(mom),
fmt: mom => moment.utc(mom).format('YYYY-MM-DD HH:mm'),
round: n => (+n).toFixed(3),
timespan: function(a, b) {
return this.humanizeDuration(this.moment(a).diff(this.moment(b)))
},
status: state => {
if(state.errored)
return 'errored';
if(state.stopped)
return 'stopped';
if(state.active)
return 'running';
console.log('unknown state:', state);
},
report: state => {
return _.get(state, 'events.latest.performanceReport');
}
}
}
</script>
<style>
table.clickable {
border-collapse: separate;
}
tr.clickable td:nth-child(1) {
padding-left: 5px;
}
tr.clickable {
cursor: pointer;
}
tr.clickable:hover {
background: rgba(216,216,216,.99);
}
</style>
+200
View File
@@ -0,0 +1,200 @@
<template lang='pug'>
div.contain.my2
h3 Start a new gekko
gekko-config-builder(v-on:config='updateConfig')
.hr
.txt--center(v-if='config.valid')
a.w100--s.my1.btn--primary(href='#', v-on:click.prevent='start', v-if="!pendingStratrunner") Start
spinner(v-if='pendingStratrunner')
</template>
<script>
import _ from 'lodash'
import Vue from 'vue'
import { post } from '../../tools/ajax'
import gekkoConfigBuilder from './gekkoConfigBuilder.vue'
import spinner from '../global/blockSpinner.vue'
export default {
components: {
gekkoConfigBuilder,
spinner
},
data: () => {
return {
pendingStratrunner: false,
config: {}
}
},
computed: {
gekkos: function() {
return this.$store.state.gekkos;
},
watchConfig: function() {
let raw = _.pick(this.config, 'watch', 'candleWriter');
let watchConfig = Vue.util.extend({}, raw);
watchConfig.type = 'market watcher';
watchConfig.mode = 'realtime';
return watchConfig;
},
requiredHistoricalData: function() {
if(!this.config.tradingAdvisor || !this.config.valid)
return;
let stratSettings = this.config.tradingAdvisor;
return stratSettings.candleSize * stratSettings.historySize;
},
gekkoConfig: function() {
var startAt;
if(!this.existingMarketWatcher)
return;
if(!this.requiredHistoricalData)
startAt = moment().utc().startOf('minute').format();
else {
// TODO: figure out whether we can stitch data
// without looking at the existing watcher
const optimal = moment().utc().startOf('minute')
.subtract(this.requiredHistoricalData, 'minutes')
.unix();
const available = moment
.utc(this.existingMarketWatcher.events.initial.candle.start)
.unix();
startAt = moment.unix(Math.max(optimal, available)).utc().format();
}
const gekkoConfig = Vue.util.extend({
market: {
type: 'leech',
from: startAt
},
mode: 'realtime'
}, this.config);
return gekkoConfig;
},
existingMarketWatcher: function() {
const market = Vue.util.extend({}, this.watchConfig.watch);
return _.find(this.gekkos, {config: {watch: market}});
},
exchange: function() {
return this.watchConfig.watch.exchange;
},
existingTradebot: function() {
return _.find(
this.gekkos,
g => {
if(g.logType === 'tradebot' && g.config.watch.exchange === this.exchange) {
return true;
}
return false;
}
);
},
availableApiKeys: function() {
return this.$store.state.apiKeys;
}
},
watch: {
// start the stratrunner
existingMarketWatcher: function(val, prev) {
if(!this.pendingStratrunner)
return;
const gekko = this.existingMarketWatcher;
if(gekko.events.latest.candle) {
this.pendingStratrunner = false;
this.startGekko((err, resp) => {
this.$router.push({
path: `/live-gekkos/${resp.id}`
});
});
}
}
},
methods: {
updateConfig: function(config) {
this.config = config;
},
start: function() {
// if the user starts a tradebot we do some
// checks first.
if(this.config.type === 'tradebot') {
if(this.existingTradebot) {
let str = 'You already have a tradebot running on this exchange';
str += ', you can only run one tradebot per exchange.';
return alert(str);
}
if(!this.availableApiKeys.includes(this.exchange))
return alert('Please first configure API keys for this exchange in the config page.')
}
// internally a live gekko consists of two parts:
//
// - a market watcher
// - a live gekko (strat runner + (paper) trader)
//
// however if the user selected type "market watcher"
// the second part won't be created
if(this.config.type === 'market watcher') {
// check if the specified market is already being watched
if(this.existingMarketWatcher) {
alert('This market is already being watched, redirecting you now...');
this.$router.push({
path: `/live-gekkos/${this.existingMarketWatcher.id}`
});
} else {
this.startWatcher((error, resp) => {
this.$router.push({
path: `/live-gekkos/${resp.id}`
});
});
}
} else {
if(this.existingMarketWatcher) {
// the specified market is already being watched,
// just start a gekko!
this.startGekko(this.routeToGekko);
} else {
// the specified market is not yet being watched,
// we need to create a watcher
this.startWatcher((err, resp) => {
this.pendingStratrunner = resp.id;
// now we just wait for the watcher to be properly initialized
// (see the `watch.existingMarketWatcher` method)
});
}
}
},
routeToGekko: function(err, resp) {
if(err || resp.error)
return console.error(err, resp.error);
this.$router.push({
path: `/live-gekkos/${resp.id}`
});
},
startWatcher: function(next) {
post('startGekko', this.watchConfig, next);
},
startGekko: function(next) {
post('startGekko', this.gekkoConfig, next);
}
}
}
</script>
<style>
</style>
@@ -0,0 +1,361 @@
<template lang='pug'>
div.my2
.contain(v-if='!data')
h1 Unknown Gekko instance
p Gekko doesn't know what gekko this is...
div(v-if='data')
h2.contain Gekko {{ type }}
div(v-if='isArchived', class='contain brdr--mid-gray p1 bg--orange')
| This is an archived Gekko, it is currently not running anymore.
div(v-if='data.errorMessage', class='contain brdr--mid-gray p1 bg--orange')
| This is Gekko crashed with the following error: {{ data.errorMessage }}
.grd.contain
.grd-row
.grd-row-col-3-6
h3 Market
.grd-row
.grd-row-col-3-6 Exchange
.grd-row-col-3-6 {{ config.watch.exchange }}
.grd-row
.grd-row-col-3-6 Currency
.grd-row-col-3-6 {{ config.watch.currency }}
.grd-row
.grd-row-col-3-6 Asset
.grd-row-col-3-6 {{ config.watch.asset }}
.grd-row
.grd-row-col-3-6 Type
.grd-row-col-3-6 {{ type }}
.grd-row-col-3-6
h3 Runtime
spinner(v-if='isLoading')
template(v-if='!isLoading')
.grd-row(v-if='initialEvents.candle')
.grd-row-col-2-6 Watching since
.grd-row-col-4-6 {{ fmt(initialEvents.candle.start) }}
.grd-row(v-if='latestEvents.candle')
.grd-row-col-2-6 Received data until
.grd-row-col-4-6 {{ fmt(latestEvents.candle.start) }}
.grd-row(v-if='latestEvents.candle')
.grd-row-col-2-6 Data spanning
.grd-row-col-4-6 {{ humanizeDuration(moment(latestEvents.candle.start).diff(moment(initialEvents.candle.start))) }}
template(v-if='isStratrunner')
.grd-row
.grd-row-col-2-6 Amount of trades
.grd-row-col-4-6 {{ trades.length }}
.grd-row
.grd-row-col-2-6 Candle size
.grd-row-col-4-6 {{ config.tradingAdvisor.candleSize }}
.grd-row
.grd-row-col-2-6 History size
.grd-row-col-4-6 {{ config.tradingAdvisor.historySize }}
div(v-if='warmupRemaining', class='contain brdr--mid-gray p1 bg--orange')
| This stratrunner is still warming up for the next
i {{ warmupRemaining.replace(',', ' and ') }}
| , it will not trade until it is warmed up.
.grd-row(v-if='isStratrunner')
.grd-row-col-3-6
h3 Strategy
.grd-row
.grd-row-col-3-6 Name
.grd-row-col-3-6
strong {{ stratName }}
| Parameters
pre {{ stratParams }}
.grd-row-col-3-6
h3 Profit report
template(v-if='!report')
p
em(v-if='isArchived') This Gekko never executed a trade..
em(v-if='!isArchived') Waiting for at least one trade..
template(v-if='report')
.grd-row
.grd-row-col-3-6 Start balance
.grd-row-col-3-6 {{ round(report.startBalance) }}
.grd-row
.grd-row-col-3-6 Current balance
.grd-row-col-3-6 {{ round(report.balance) }}
.grd-row
.grd-row-col-3-6 Market
.grd-row-col-3-6 {{round(report.market / 100 * report.startPrice)}} {{ config.watch.currency }} ({{ round(report.market) }} %)
.grd-row
.grd-row-col-3-6 Profit
.grd-row-col-3-6 {{ round(report.profit) }} {{ config.watch.currency }} ({{ round(report.relativeProfit) }} %)
.grd-row
.grd-row-col-3-6 Alpha
.grd-row-col-3-6 {{ round(report.alpha) }} {{ config.watch.currency }}
p(v-if='isStratrunner && !watcher && !isArchived') WARNING: stale gekko, not attached to a watcher, please report
a(href='https://github.com/askmike/gekko/issues') here
| .
p(v-if='!isArchived')
a(v-on:click='stopGekko', class='w100--s my1 btn--red') Stop Gekko
p(v-if='isArchived')
a(v-on:click='deleteGekko', class='w100--s my1 btn--red') Delete Gekko
p(v-if='isStratrunner && watcher && !isArchived')
em This gekko gets market data from
router-link(:to='"/live-gekkos/" + watcher.id') this market watcher
| .
template(v-if='!isLoading')
h3.contain Market graph
spinner(v-if='candleFetch === "fetching"')
template(v-if='candleFetch === "fetched"')
chart(:data='chartData', :height='300')
roundtrips(v-if='isStratrunner', :roundtrips='roundtrips')
</template>
<script>
import Vue from 'vue'
import _ from 'lodash'
import { post } from '../../tools/ajax'
import spinner from '../global/blockSpinner.vue'
import chart from '../backtester/result/chartWrapper.vue'
import roundtrips from '../backtester/result/roundtripTable.vue'
import paperTradeSummary from '../global/paperTradeSummary.vue'
// global moment
export default {
created: function() {
if(!this.isLoading)
this.getCandles();
},
components: {
spinner,
chart,
paperTradeSummary,
roundtrips
},
data: () => {
return {
candleFetch: 'idle',
candles: false
}
},
computed: {
id: function() {
return this.$route.params.id;
},
gekkos: function() {
return this.$store.state.gekkos;
},
archivedGekkos: function() {
return this.$store.state.archivedGekkos;
},
data: function() {
if(!this.gekkos)
return false;
if(_.has(this.gekkos, this.id))
return this.gekkos[this.id];
if(_.has(this.archivedGekkos, this.id))
return this.archivedGekkos[this.id];
return false;
},
config: function() {
return _.get(this, 'data.config');
},
latestEvents: function() {
return _.get(this, 'data.events.latest');
},
initialEvents: function() {
return _.get(this, 'data.events.initial');
},
trades: function() {
return _.get(this, 'data.events.tradeCompleted') || [];
},
roundtrips: function() {
return _.get(this, 'data.events.roundtrip') || [];
},
isLive: function() {
return _.has(this.gekkos, this.id);
},
type: function() {
return this.data.logType;
},
isStratrunner: function() {
return this.type !== 'watcher';
},
isArchived: function() {
return this.data.stopped;
},
warmupRemaining: function() {
if(!this.isStratrunner) {
return false;
}
if(this.isArchived) {
return false;
}
if(this.initialEvents.stratWarmupCompleted) {
return false;
}
if(!this.initialEvents.candle) {
return false;
}
const historySize = _.get(this.config, 'tradingAdvisor.historySize');
if(!historySize) {
return false;
}
const warmupTime = _.get(this.config, 'tradingAdvisor.candleSize') * historySize;
return humanizeDuration(
moment(this.initialEvents.candle.start).add(warmupTime, 'm').diff(moment()),
{ largest: 2 }
);
},
chartData: function() {
return {
candles: this.candles,
trades: this.trades
}
},
report: function() {
return _.get(this.latestEvents, 'performanceReport');
},
stratName: function() {
if(this.data)
return this.data.config.tradingAdvisor.method;
},
stratParams: function() {
if(!this.data)
return 'Loading...';
let stratParams = Vue.util.extend({}, this.data.config[this.stratName]);
delete stratParams.__empty;
if(_.isEmpty(stratParams))
return 'No parameters'
return JSON.stringify(stratParams, null, 4);
},
isLoading: function() {
if(!this.data)
return true;
if(!_.get(this.data, 'events.initial.candle'))
return true;
if(!_.get(this.data, 'events.latest.candle'))
return true;
return false;
},
watcher: function() {
if(!this.isStratrunner) {
return false;
}
let watch = Vue.util.extend({}, this.data.config.watch);
return _.find(this.gekkos, g => {
if(g.id === this.id)
return false;
return _.isEqual(watch, g.config.watch);
});
},
hasLeechers: function() {
if(this.isStratrunner) {
return false;
}
let watch = Vue.util.extend({}, this.data.config.watch);
return _.find(this.gekkos, g => {
if(g.id === this.id)
return false;
return _.isEqual(watch, g.config.watch);
});
}
},
watch: {
'data.events.latest.candle.start': function() {
setTimeout(this.getCandles, _.random(100, 2000));
}
},
methods: {
round: n => (+n).toFixed(5),
humanizeDuration: (n, x) => window.humanizeDuration(n, x),
moment: mom => moment.utc(mom),
fmt: mom => moment.utc(mom).format('YYYY-MM-DD HH:mm'),
getCandles: function() {
if(this.isLoading) {
return;
}
if(this.candleFetch === 'fetching') {
return;
}
this.candleFetch = 'fetching';
let to = this.data.events.latest.candle.start;
let from = this.data.events.initial.candle.start;
let candleSize = 1;
if(this.type !== 'watcher') {
candleSize = this.data.config.tradingAdvisor.candleSize;
}
let config = {
watch: this.data.config.watch,
daterange: {
to, from
},
candleSize
};
// We timeout because of 2 reasons:
// - In case we get a batch of candles we only fetch once
// - This way we give the db (mostly sqlite) some time to write
// the result before we query it.
setTimeout(() => {
post('getCandles', config, (err, res) => {
this.candleFetch = 'fetched';
if(!res || res.error || !_.isArray(res))
return console.log(res);
this.candles = res.map(c => {
c.start = moment.unix(c.start).utc().format();
return c;
});
})
}, _.random(150, 2500));
},
stopGekko: function() {
if(this.hasLeechers) {
return alert('This Gekko is fetching market data for multiple stratrunners, stop these first.');
}
if(!confirm('Are you sure you want to stop this Gekko?')) {
return;
}
post('stopGekko', { id: this.data.id }, (err, res) => {
console.log('stopped gekko');
});
},
deleteGekko: function() {
if(!this.isArchived) {
return alert('This Gekko is still running, stop it first!');
}
if(!confirm('Are you sure you want to delete this Gekko?')) {
return;
}
post('deleteGekko', { id: this.data.id }, (err, res) => {
this.$router.push({
path: `/live-gekkos/`
});
});
}
}
}
</script>
<style>
</style>
@@ -0,0 +1,153 @@
<template lang='pug'>
div.my2
.contain(v-if='!data')
h1 Unknown Watcher
p Gekko doesn't know what whatcher this is...
div(v-if='data')
h2.contain Market Watcher
.grd.contain
h3 Market
.grd-row
.grd-row-col-2-6 Exchange
.grd-row-col-4-6 {{ data.watch.exchange }}
.grd-row
.grd-row-col-2-6 Currency
.grd-row-col-4-6 {{ data.watch.currency }}
.grd-row
.grd-row-col-2-6 Asset
.grd-row-col-4-6 {{ data.watch.asset }}
h3 Statistics
spinner(v-if='isLoading')
template(v-if='!isLoading')
.grd-row(v-if='data.firstCandle')
.grd-row-col-2-6 Watching since
.grd-row-col-4-6 {{ fmt(data.firstCandle.start) }}
.grd-row(v-if='data.lastCandle')
.grd-row-col-2-6 Received data until
.grd-row-col-4-6 {{ fmt(data.lastCandle.start) }}
.grd-row(v-if='data.lastCandle && data.firstCandle')
.grd-row-col-2-6 Data spanning
.grd-row-col-4-6 {{ humanizeDuration(moment(data.lastCandle.start).diff(moment(data.firstCandle.start))) }}
template(v-if='!isLoading')
h3.contain Market graph
spinner(v-if='candleFetch === "fetching"')
template(v-if='candles.length')
chart(:data='chartData', :height='500')
</template>
<script>
import { post } from '../../tools/ajax'
import _ from 'lodash'
import spinner from '../global/blockSpinner.vue'
import Vue from 'vue'
import chart from '../backtester/result/chartWrapper.vue'
// global moment
export default {
created: function() {
if(!this.isLoading)
this.getCandles();
},
components: {
spinner,
chart
},
data: () => {
return {
candleFetch: 'idle',
candles: []
}
},
computed: {
watchers: function() {
return this.$store.state.watchers;
},
data: function() {
return _.find(this.watchers, {id: this.$route.params.id});
},
chartData: function() {
return {
candles: this.candles,
trades: []
}
},
isLoading: function() {
if(!this.data)
return true;
if(!_.isObject(this.data.firstCandle))
return true;
if(!_.isObject(this.data.lastCandle))
return true;
return false;
},
},
watch: {
'data.lastCandle.start': function() {
this.candleFetch = 'dirty';
},
data: function(val, prev) {
let complete = val && val.firstCandle && val.lastCandle;
if(!complete)
return;
if(this.candleFetch !== 'fetched' )
this.getCandles();
}
},
methods: {
humanizeDuration: (n) => window.humanizeDuration(n),
moment: mom => moment.utc(mom),
fmt: mom => moment.utc(mom).format('YYYY-MM-DD HH:mm'),
getCandles: function() {
this.candleFetch = 'fetching';
// up unto we have data
let to = moment.utc(
this.data.lastCandle.start
).unix();
// max 7 days of data
let from = Math.max(
moment.utc(this.data.firstCandle.start).unix(),
moment.utc(to).subtract(7, 'days').unix()
);
// TODO...
const diff = to - from;
let candleSize = 60;
if(diff < 60 * 60 * 24) // a day
if(diff < 60 * 60 * 12) // 3 hours
candleSize = 1;
else
candleSize = 5;
from = moment.unix(from).utc().format();
to = moment.unix(to).utc().format();
let config = {
watch: this.data.watch,
daterange: {
to, from
},
// hourly candles
candleSize
};
post('getCandles', config, (err, res) => {
this.candleFetch = 'fetched';
this.candles = res.map(c => {
c.start = moment.unix(c.start).utc().format();
return c;
});
})
}
}
}
</script>
<style>
</style>
@@ -0,0 +1,69 @@
// http://tobiasahlin.com/spinkit/
<template lang='pug'>
.spinner
.rect1
.rect2
.rect3
.rect4
</template>
<script>
export default {}
</script>
<style>
.spinner {
margin: 20px auto 100px auto;
width: 50px;
height: 40px;
text-align: center;
font-size: 10px;
}
.spinner > div {
background-color: #333;
height: 100%;
width: 6px;
display: inline-block;
margin-right: 4px;
-webkit-animation: sk-stretchdelay 1.2s infinite ease-in-out;
animation: sk-stretchdelay 1.2s infinite ease-in-out;
}
.spinner .rect2 {
-webkit-animation-delay: -1.1s;
animation-delay: -1.1s;
}
.spinner .rect3 {
-webkit-animation-delay: -1.0s;
animation-delay: -1.0s;
}
.spinner .rect4 {
-webkit-animation-delay: -0.9s;
animation-delay: -0.9s;
}
.spinner .rect5 {
-webkit-animation-delay: -0.8s;
animation-delay: -0.8s;
}
@-webkit-keyframes sk-stretchdelay {
0%, 40%, 100% { -webkit-transform: scaleY(0.4) }
20% { -webkit-transform: scaleY(1.0) }
}
@keyframes sk-stretchdelay {
0%, 40%, 100% {
transform: scaleY(0.4);
-webkit-transform: scaleY(0.4);
} 20% {
transform: scaleY(1.0);
-webkit-transform: scaleY(1.0);
}
}
</style>
@@ -0,0 +1,131 @@
<template lang='pug'>
div
h3 Select a dataset
.txt--center.my2(v-if='datasetScanstate === "idle"')
a.w100--s.btn--primary.scan-btn(href='#', v-on:click.prevent='scan') Scan available data
.txt--center.my2(v-if='datasetScanstate === "scanning"')
spinner
.my2(v-if='datasetScanstate === "scanned"')
div(v-if='datasets.length != 0')
table.full
thead
tr
th
th exchange
th currency
th asset
th from
th to
th duration
tbody
tr(v-for='(set, i) in datasets')
td.radio
input(type='radio', name='dataset', :value='i', v-model='setIndex', v-bind:id='set.id')
td
label(v-bind:for='set.id') {{ set.exchange }}
td
label(v-bind:for='set.id') {{ set.currency }}
td
label(v-bind:for='set.id') {{ set.asset }}
td
label(v-bind:for='set.id') {{ fmt(set.from) }}
td
label(v-bind:for='set.id') {{ fmt(set.to) }}
td
label(v-bind:for='set.id') {{ humanizeDuration(set.to.diff(set.from)) }}
a.btn--primary(href='#', v-on:click.prevent='openRange', v-if='!rangeVisible') Adjust range
template(v-if='rangeVisible')
div
label(for='customFrom') From:
input(v-model='customFrom')
div
label(for='customTo') To:
input(v-model='customTo')
em(v-else) No Data found
a(href='#/data/importer') Lets add some
</template>
<script>
import _ from 'lodash'
import Vue from 'vue'
import { post } from '../../../tools/ajax'
import spinner from '../../global/blockSpinner.vue'
import dataset from '../../global/mixins/dataset'
export default {
components: {
spinner
},
data: () => {
return {
setIndex: -1,
customTo: false,
customFrom: false,
rangeVisible: false,
set: false
};
},
mixins: [ dataset ],
methods: {
humanizeDuration: (n) => {
return window.humanizeDuration(n, {largest: 4});
},
fmt: mom => mom.utc().format('YYYY-MM-DD HH:mm'),
openRange: function() {
if(this.setIndex === -1)
return alert('Select a dataset to adjust range');
this.updateCustomRange();
this.rangeVisible = true;
},
updateCustomRange: function() {
this.customTo = this.fmt(this.set.to);
this.customFrom = this.fmt(this.set.from);
},
emitSet: function(val) {
if(!val)
return;
let set;
if(!this.customTo)
set = val;
else {
set = Vue.util.extend({}, val);
set.to = moment.utc(this.customTo, 'YYYY-MM-DD HH:mm').format();
set.from = moment.utc(this.customFrom, 'YYYY-MM-DD HH:mm').format();
}
this.$emit('dataset', set);
}
},
watch: {
setIndex: function() {
this.set = this.datasets[this.setIndex];
this.updateCustomRange();
this.emitSet(this.set);
},
customTo: function() { this.emitSet(this.set); },
customFrom: function() { this.emitSet(this.set); }
}
}
</script>
<style>
td.radio {
width: 45px;
}
td label{
display: inline;
font-size: 1em;
}
</style>
@@ -0,0 +1,65 @@
<template lang='pug'>
div
.mx1
label(for='exchange').wrapper Exchange:
.custom-select.button
select(v-model='exchange')
option(v-for='(market, e) in exchanges') {{ e }}
</template>
<script>
import _ from 'lodash'
import rangePicker from './rangepicker.vue'
import rangeCreator from './rangecreator.vue'
import { get } from '../../../tools/ajax'
export default {
props: ['onlyTradable', 'onlyImportable'],
data: () => {
return {
exchange: 'poloniex',
};
},
created: function() {
this.emitExchange();
},
computed: {
exchanges: function() {
let exchanges = Object.assign({}, this.$store.state.exchanges);
if(_.isEmpty(exchanges))
return false;
if(this.onlyTradable) {
_.each(exchanges, (e, name) => {
if(!e.tradable)
delete exchanges[name];
});
}
if(this.onlyImportable) {
_.each(exchanges, (e, name) => {
if(!e.importable)
delete exchanges[name];
});
}
return exchanges;
}
},
watch: {
exchanges: function() { this.emitExchange() },
exchange: function() { this.emitExchange() }
},
methods: {
emitExchange: function() {
this.$emit('exchange', this.exchange);
}
}
}
</script>
</style>
@@ -0,0 +1,102 @@
<template lang='pug'>
div
.mx1
label(for='exchange').wrapper Exchange:
.custom-select.button
select(v-model='exchange')
option(v-for='(market, e) in exchanges') {{ e }}
.grd-row
.grd-row-col-3-6.mx1
label(for='currency') Currency:
.custom-select.button
select(v-model='currency')
option(v-for='cur in currencies') {{ cur }}
.grd-row-col-3-6.mx1
label(for='asset') Asset:
.custom-select.button
select(v-model='asset')
option(v-for='asst in assets') {{ asst }}
</template>
<script>
import _ from 'lodash'
import rangePicker from './rangepicker.vue'
import rangeCreator from './rangecreator.vue'
import { get } from '../../../tools/ajax'
export default {
props: ['onlyTradable', 'onlyImportable'],
data: () => {
return {
// defaults:
exchange: 'poloniex',
currency: 'USDT',
asset: 'BTC',
};
},
created: function() {
this.emitConfig();
},
computed: {
exchanges: function() {
let exchanges = Object.assign({}, this.$store.state.exchanges);
if(_.isEmpty(exchanges))
return false;
if(this.onlyTradable) {
_.each(exchanges, (e, name) => {
if(!e.tradable)
delete exchanges[name];
});
}
if(this.onlyImportable) {
_.each(exchanges, (e, name) => {
if(!e.importable)
delete exchanges[name];
});
}
return exchanges;
},
markets: function() {
return this.exchanges ? this.exchanges[ this.exchange ] : null;
},
assets: function() {
return this.exchanges ? this.exchanges[this.exchange].markets[this.currency] : null;
},
currencies: function() {
return this.exchanges ? _.keys( this.exchanges[this.exchange].markets ) : null;
},
watchConfig: function() {
return {
watch: {
exchange: this.exchange,
currency: this.currency,
asset: this.asset,
}
}
}
},
watch: {
currency: function() { this.emitConfig() },
asset: function() { this.emitConfig() },
market: function() { this.emitConfig() },
exchanges: function() { this.emitConfig() },
exchange: function() { this.emitConfig() }
},
methods: {
emitConfig: function() {
this.$emit('market', this.watchConfig);
}
}
}
</script>
</style>
@@ -0,0 +1,72 @@
<template lang='pug'>
.grd
.px1
h3 Paper trader
a.btn--primary(href='#', v-on:click.prevent='switchToggle', v-if='toggle === "closed"') Change paper trader settings
template(v-if='toggle === "open"')
p Settings:
textarea.params(v-model='rawPaperTraderParams')
p.bg--red.p1(v-if='rawPaperTraderParamsError') {{ rawPaperTraderParamsError.message }}
</template>
<script>
import _ from 'lodash'
import { get } from '../../../tools/ajax'
export default {
created: function() {
get('configPart/paperTrader', (error, response) => {
this.rawPaperTraderParams = response.part;
});
},
data: () => {
return {
rawPaperTraderParams: '',
rawPaperTraderParamsError: false,
paperTraderParams: {},
toggle: 'closed'
};
},
watch: {
rawPaperTraderParams: function() { this.emitConfig() }
},
methods: {
switchToggle: function() {
if(this.toggle === 'open')
this.toggle = 'closed';
else
this.toggle = 'open';
},
emitConfig: function() {
this.parseParams();
this.$emit('settings', this.paperTraderParams);
},
parseParams: function() {
try {
this.paperTraderParams = toml.parse(this.rawPaperTraderParams);
this.paperTraderParams.reportRoundtrips = true;
this.rawPaperTraderParamsError = false;
} catch(e) {
this.rawPaperTraderParamsError = e;
this.paperTraderParams = {};
}
}
}
}
</script>
<style>
.align .custom-select select {
padding: 0.4em 1.2em .3em .8em;
}
.label-like {
display: block;
font-size: 0.9em;
color: #777;
}
.align {
padding-left: 1em;
}
</style>
@@ -0,0 +1,93 @@
<template lang='pug'>
div
h3 Daterange
div
label(for='from') From
input(v-model='from')
div
label(for='to') To
input(v-model='to')
</template>
<script>
import _ from 'lodash'
import { post } from '../../../tools/ajax'
// global moment
export default {
data: function() {
return {
from: '',
to: ''
}
},
created: function() {
let now = moment().startOf('minute');
let then = now.clone().subtract(3, 'months');
this.to = this.fmt(now);
this.from = this.fmt(then);
this.emitRange();
},
methods: {
fmtTs: (mom) => moment.unix(mom).utc(),
fmt: (mom) => mom.utc().format('YYYY-MM-DD HH:mm'),
emitRange: function() {
this.$emit('range', {
from: this.fmtTs(this.from),
to: this.fmtTs(this.to)
});
},
emitManualEntry: function() {
if(this.from.length < '4' || this.from.length < '4')
return this.$emit('range', {})
let from = moment.utc(this.from);
let to = moment.utc(this.to);
if(from.isValid() && to.isValid()) {
this.$emit('range', {
from: this.fmt(from),
to: this.fmt(to)
})
} else {
this.$emit('range', {})
}
}
},
watch: {
from: function() {
this.emitManualEntry();
},
to: function() {
this.emitManualEntry();
},
config: function() {
this.scanned = false;
},
tab: function() {
this.scanned = false;
this.$emit('range', {})
},
selectedRangeIndex: function() {
let selectedRange = this.ranges[this.selectedRangeIndex];
if(selectedRange)
this.emitRange(selectedRange);
}
}
}
</script>
<style>
.scan-btn {
margin-top: 80px;
margin-bottom: 30px;
}
.radio label {
margin-top: 0;
}
</style>
@@ -0,0 +1,138 @@
<template lang='pug'>
div
h3 Daterange
template(v-if='tab === "scan"')
.txt--center(v-if='!scanned')
a.w100--s.btn--primary.scan-btn(href='#', v-on:click.prevent='scan') Scan available data
.txt--center(v-if='scanned == "fetching"')
p.scan-btn Scanning..
template(v-if='scanned == true')
template(v-if='ranges.length === 0')
p
strong Unable to find any local data, do you have local data available for
| "{{ config.watch.exchange }}:{{ config.watch.currency }}/{{ config.watch.asset }}"?
template(v-else)
label(for='exchange').wrapper Run simulation over:
form.radio.grd
div.grd-row(v-for='(range, i) in ranges').m1
input.grd-row-col-1-6(type='radio', :value='i', v-model='selectedRangeIndex')
label.grd-row-col-5-6(:for='i') {{ printRange(range) }}
p
em
a(href='#', v-on:click.prevent='scan') rescan
p.txt--center
em
a(href='#', v-on:click.prevent='tab = "manual"') Or manually set a daterange
template(v-if='tab === "manual"')
div
label(for='from') From:
input(v-model='from')
div
label(for='to') To:
input(v-model='to')
p.txt--center
em
a(href='#', v-on:click.prevent='tab = "scan"') Or scan for a daterange
</template>
<script>
import _ from 'lodash'
import { post } from '../../../tools/ajax'
// global moment
export default {
props: ['config'],
data: () => {
return {
scanned: false, // 'fetching', true
ranges: [],
selectedRangeIndex: -1,
tab: 'scan',
from: '',
to: ''
}
},
methods: {
scan: function() {
this.scanned = 'fetching';
this.selectedRangeIndex = -1;
post('scan', this.config, (err, response) => {
this.scanned = true;
this.ranges = response;
this.selectedRangeIndex = 0;
});
},
printRange: function(range) {
let fmt = mom => mom.format('YYYY-MM-DD HH:mm')
let from = moment.unix(range.from);
let to = moment.unix(range.to);
let diff = moment.duration(to.diff(from)).humanize();
return `${fmt(from)} to ${fmt(to)} (${diff})`;
},
fmtTs: (mom) => moment.unix(mom).utc(),
fmt: (mom) => mom.utc().format(),
emitRange: function(range) {
this.$emit('range', {
from: this.fmtTs(range.from),
to: this.fmtTs(range.to)
});
},
emitManualEntry: function() {
if(this.from.length < '4' || this.from.length < '4')
// this cannot possibly be a valid date
return this.$emit('range', {})
let from = moment.utc(this.from);
let to = moment.utc(this.to);
if(from.isValid() && to.isValid()) {
this.$emit('range', {
from: this.fmt(from),
to: this.fmt(to)
})
} else {
this.$emit('range', {});
}
},
reset: function() {
this.scanned = false;
this.$emit('range', {})
}
},
watch: {
from: function() {
this.emitManualEntry();
},
to: function() {
this.emitManualEntry();
},
config: function() {
this.reset();
},
tab: function() {
this.reset();
},
selectedRangeIndex: function() {
let selectedRange = this.ranges[this.selectedRangeIndex];
if(selectedRange)
this.emitRange(selectedRange);
}
}
}
</script>
<style>
.scan-btn {
margin-top: 80px;
margin-bottom: 30px;
}
.radio label {
margin-top: 0;
}
</style>
@@ -0,0 +1,145 @@
<template lang='pug'>
.grd
.grd-row
.grd-row-col-3-6.px1
h3 Strategy
div
label(for='strat').wrapper Strategy:
.custom-select.button
select(v-model='strategy')
option(v-for='strat in strategies') {{ strat.name }}
div
label(for='candleSize') Candle Size
.grd-row
.grd-row-col-3-6
input(v-model='rawCandleSize')
.grd-row-col-3-6.align
.custom-select.button
select(v-model='candleSizeUnit')
option minutes
option hours
option days
div
label(for='historySize') Warmup period (in {{ rawCandleSize }} {{ singularCandleSizeUnit }} candles):
input(v-model='historySize')
em.label-like (will use {{ humanizeDuration(candleSize * historySize * 1000 * 60) }} of data as history)
.grd-row-col-3-6.px1
div
h3 Parameters
p {{ strategy }} Parameters:
textarea.params(v-model='rawStratParams')
p.bg--red.p1(v-if='rawStratParamsError') {{ rawStratParamsError.message }}
</template>
<script>
import _ from 'lodash'
import { get } from '../../../tools/ajax'
export default {
data: () => {
return {
strategies: [],
candleSizeUnit: 'hours',
rawCandleSize: 1,
strategy: 'MACD',
historySize: 10,
rawStratParams: '',
rawStratParamsError: false,
emptyStrat: false,
stratParams: {}
};
},
created: function () {
get('strategies', (err, data) => {
this.strategies = data;
_.each(this.strategies, function(s) {
s.empty = s.params === '';
});
this.rawStratParams = _.find(this.strategies, { name: this.strategy }).params;
this.emptyStrat = _.find(this.strategies, { name: this.strategy }).empty;
this.emitConfig();
});
},
watch: {
strategy: function(strat) {
strat = _.find(this.strategies, { name: strat });
this.rawStratParams = strat.params;
this.emptyStrat = strat.empty;
this.emitConfig();
},
candleSize: function() { this.emitConfig() },
historySize: function() { this.emitConfig() },
rawStratParams: function() { this.emitConfig() }
},
computed: {
candleSize: function() {
if(this.candleSizeUnit === 'minutes')
return this.rawCandleSize;
else if(this.candleSizeUnit === 'hours')
return this.rawCandleSize * 60;
else if(this.candleSizeUnit === 'days')
return this.rawCandleSize * 60 * 24;
},
singularCandleSizeUnit: function() {
// hours -> hour
return this.candleSizeUnit.slice(0, -1);
},
config: function() {
let config = {
tradingAdvisor: {
enabled: true,
method: this.strategy,
candleSize: +this.candleSize,
historySize: +this.historySize
}
}
if(this.emptyStrat)
config[this.strategy] = {__empty: true}
else
config[this.strategy] = this.stratParams;
return config;
}
},
methods: {
humanizeDuration: (n) => window.humanizeDuration(n),
emitConfig: function() {
this.parseParams();
this.$emit('stratConfig', this.config);
},
parseParams: function() {
try {
this.stratParams = toml.parse(this.rawStratParams);
this.rawStratParamsError = false;
} catch(e) {
this.rawStratParamsError = e;
this.stratParams = {};
}
}
}
}
</script>
<style>
.align .custom-select select {
padding: 0.4em 1.2em .3em .8em;
}
.label-like {
display: block;
font-size: 0.9em;
color: #777;
}
.align {
padding-left: 1em;
}
</style>
@@ -0,0 +1,48 @@
<template lang='pug'>
div
h3 Type
template
label(for='type').wrapper What do you want to do with gekko?
form.radio.grd
div.grd-row(v-for='(type, i) in types').m1
input.grd-row-col-1-6(type='radio', :value='i', v-model='selectedTypeIndex')
label.grd-row-col-5-6(:for='i') {{ type }}
</template>
<script>
export default {
created: function() {
this.emitType();
},
data: () => {
return {
types: ['paper trader', 'market watcher', 'tradebot'],
selectedTypeIndex: 0,
}
},
methods: {
emitType: function() {
this.$emit('type', this.type);
}
},
watch: {
type: function() {
this.emitType();
}
},
computed: {
type: function() {
return this.types[ this.selectedTypeIndex ];
}
}
}
</script>
<style>
.radio label {
margin-top: 0;
}
</style>
@@ -0,0 +1,60 @@
import { post } from '../../../tools/ajax'
var mixin = {
data: () => {
return {
datasets: [],
datasetScanstate: 'idle',
unscannableMakets: []
}
},
methods: {
scan: function() {
this.datasetScanstate = 'scanning';
post('scansets', {}, (error, response) => {
this.datasetScanstate = 'scanned';
this.unscannableMakets = response.errors;
let sets = [];
response.datasets.forEach(market => {
market.ranges.forEach((range, i) => {
sets.push({
exchange: market.exchange,
currency: market.currency,
asset: market.asset,
from: moment.unix(range.from).utc(),
to: moment.unix(range.to).utc(),
id: market.exchange + market.asset + market.currency + i
});
});
});
// for now, filter out sets smaller than 3 hours..
sets = sets.filter(set => {
if(set.to.diff(set.from, 'hours') > 2)
return true;
});
sets = sets.sort((a, b) => {
let adiff = a.to.diff(a.from);
let bdiff = b.to.diff(b.from);
if(adiff < bdiff)
return -1;
if(adiff > bdiff)
return 1;
return 0;
}).reverse();
this.datasets = sets;
})
}
}
}
export default mixin;
@@ -0,0 +1,64 @@
<template lang='pug'>
.grd-row-col-3-6
table.p1
tr
th amount of trades
td {{ report.trades }}
tr
th sharpe ratio
td {{ round2(report.sharpe) }}
tr
th start balance
td {{ round(report.startBalance) }} {{ report.currency }}
tr
th final balance
td {{ round(report.balance) }} {{ report.currency }}
tr
th simulated profit
.big.txt--right.price(:class='profitClass') {{ round(report.relativeProfit) }}%
</template>
<script>
export default {
props: ['report'],
methods: {
round2: n => (+n).toFixed(2),
round: n => (+n).toFixed(5)
},
computed: {
profitClass: function() {
if(this.report.relativeProfit > 0)
return 'profit'
else
return 'loss'
}
}
}
</script>
<style>
.summary td {
text-align: right;
}
.big {
font-size: 1.3em;
width: 80%;
}
.summary table {
width: 80%;
}
.price.profit {
color: #7FFF00;
}
.price.loss {
color: red;
}
</style>
@@ -0,0 +1,57 @@
// http://stackoverflow.com/questions/7190898/progress-bar-with-html-and-css
<template lang='pug'>
.progressbarWrapper(v-if='progress')
p <strong>{{ round(progress) }}%</strong>
.progressbar
div(:style='{width: progress + "%"}')
</template>
<script>
export default {
props: ['progress'],
methods: {
round: n => (+n).toFixed(2)
}
}
</script>
<style>
.progressbarWrapper p {
text-align: center;
font-size: 20px;
}
.progressbar {
background-color: rgba(216,216,216,.99);
border-radius: 13px; /* (height of inner div) / 2 + padding */
padding: 0px;
}
@keyframes shimmer{
0%{
background-position: 0px 0
}
100%{
background-position: 960px 0
}
}
.progressbar > div {
height: 20px;
border-radius: 10px;
/* Progress bar animation */
background-color: #FFA500;
animation-duration: 1500ms;
animation-fill-mode: forwards;
animation-iteration-count: infinite;
animation-name: shimmer;
animation-timing-function: linear;
background: #f6f7f8;
background: linear-gradient(to right, #FFA500 10%, #ffce77 25%, #FFA500 40%);
background-size: 960px 50px;
background-position: left;
position: relative;
}
</style>
+60
View File
@@ -0,0 +1,60 @@
import _ from 'lodash'
import Vue from 'vue'
import { wsPath } from '../../tools/api'
import initializeState from '../../store/init'
var socket = null;
export const bus = new Vue();
bus.$on('gekko_update', data => console.log(data))
bus.$on('import_update', data => console.log(data))
bus.$on('import_error', data => {
alert('IMPORT ERROR: ' + data.error);
});
const info = {
connected: false
}
export const connect = () => {
socket = new ReconnectingWebSocket(wsPath, null, { maxReconnectInterval: 4000 });
setTimeout(() => {
// in case we cannot connect
if(!info.connected) {
initializeState();
bus.$emit('WS_STATUS_CHANGE', info);
}
}, 500);
socket.onopen = () => {
if(info.connected)
return;
info.connected = true;
bus.$emit('WS_STATUS_CHANGE', info);
initializeState();
}
socket.onclose = () => {
if(!info.connected)
return;
info.connected = false;
bus.$emit('WS_STATUS_CHANGE', info);
}
socket.onerror = () => {
if(!info.connected)
return;
info.connected = false;
bus.$emit('WS_STATUS_CHANGE', info);
}
socket.onmessage = function(message) {
const payload = JSON.parse(message.data);
// console.log('ws message:', payload);
bus.$emit(payload.type, payload);
};
}
+23
View File
@@ -0,0 +1,23 @@
<template lang='pug'>
footer.p2.bg--off-white
.contain
p
em Use Gekko at your own risk.
p Using Gekko v{{ version.gekko }} and Gekko UI v{{ version.ui }}.
</template>
<script>
const gekkoPackage = require('../../../../../package.json');
const uiPackage = require('../../../package.json');
export default {
data: () => {
return {
version: {
gekko: gekkoPackage.version,
ui: uiPackage.version
}
}
}
}
</script>
+46
View File
@@ -0,0 +1,46 @@
<template lang='pug'>
div
#top
header.bg--off-white.grd
.contain.grd-row
h3.py1.px2.col-2 Gekko UI
nav.bg--light-gray
.menu.contain
router-link(to='/home').py1 Home
router-link(to='/live-gekkos').py1 Live Gekkos
router-link(to='/backtest').py1 Backtest
router-link(to='/data').py1 Local data
router-link(to='/config').py1 Config
a(href='https://gekko.wizb.it/docs/introduction/about_gekko.html', target='_blank').py1 Documentation
</template>
<script>
export default {}
</script>
<style>
.menu {
display: flex;
flex-direction: row;
margin-top: 0;
margin-bottom: 2rem;
}
.menu a {
flex: 1 1 100%;
display: block;
text-align: center;
text-decoration: none;
color: inherit;
}
.menu .router-link-active {
background-color: rgba(250,250,250,.99);
}
.menu a:hover {
text-decoration: underline;
}
</style>
+34
View File
@@ -0,0 +1,34 @@
<template lang='pug'>
section.contain.grd-row
.grd-row-col-3-6(v-html='left')
.grd-row-col-3-6.txt--center
img(src='static/gekko.jpg')
p
em The most valuable commodity I know of is information.
</template>
<script>
import marked from '../../tools/marked';
const left = marked(`
## Gekko
Gekko is a Bitcoin trading bot and backtesting platform that
connects to popular Bitcoin exchanges. It is written in javascript
and runs on nodejs.
[Find out more](https://gekko.wizb.it/).
*Gekko is 100% free (open source), if you paid for this you have been scammed.*
`);
export default {
data: () => {
return {
left
}
}
}
</script>
+80
View File
@@ -0,0 +1,80 @@
<template lang='pug'>
div(v-if='active')
#modal-background
#modal.modal
.modal-guts(v-html='content')
</template>
<script>
import marked from '../../tools/marked';
const messages = {
disconnected: marked(`
## Disconnected
Something happened to either Gekko or the connection.
Please check the terminal where Gekko is running or
your network connection.
*This message is shown when the UI is unable to open a websocket connection with the Gekko Server.*
`)
}
export default {
computed: {
active: function() {
return !this.$store.state.warnings.connected;
},
content: function() {
if(!this.$store.state.warnings.connected)
return messages.disconnected;
return '';
}
}
}
</script>
<style>
#modal-background {
position: fixed;
top: 0;
bottom: 0;
left: 0;
right: 0;
background-color: black;
opacity: 0.5
}
.modal {
position: fixed;
top: 50%;
left: 50%;
transform: translate(-50%, -50%);
width: 600px;
min-height: 300px;
background-color: white;
}
.modal-guts {
/* other stuff we already covered */
/* cover the modal */
position: absolute;
top: 0;
left: 0;
width: 100%;
height: 100%;
/* spacing as needed */
padding: 20px 50px 20px 20px;
/* let it scroll */
overflow: auto;
}
</style>
+149
View File
@@ -0,0 +1,149 @@
import _ from 'lodash'
// techanjs based cancle chart, unused at the moment
export default function(_data, _trades) {
let MAX_WIDTH = window.innerWidth - 20;
var margin = {top: 20, right: 20, bottom: 30, left: 50},
width = MAX_WIDTH - margin.left - margin.right,
height = 500 - margin.top - margin.bottom;
var x = techan.scale.financetime()
.range([0, width]);
var y = d3.scaleLinear()
.range([height, 0]);
var zoom = d3.zoom()
.on("zoom", zoomed);
var zoomableInit;
var candlestick = techan.plot.candlestick()
.xScale(x)
.yScale(y);
var tradearrow = techan.plot.tradearrow()
.xScale(x)
.yScale(y)
.orient(function(d) { return d.type.startsWith("buy") ? "up" : "down"; })
.on("mouseenter", enter)
.on("mouseout", out);
var xAxis = d3.axisBottom(x);
var yAxis = d3.axisLeft(y);
var svg = d3.select("#chart").append("svg")
.attr("width", width + margin.left + margin.right)
.attr("height", height + margin.top + margin.bottom)
.append("g")
.attr("transform", "translate(" + margin.left + "," + margin.top + ")");
svg.append("clipPath")
.attr("id", "clip")
.append("rect")
.attr("x", 0)
.attr("y", y(1))
.attr("width", width)
.attr("height", y(0) - y(1));
svg.append("g")
.attr("class", "candlestick")
.attr("clip-path", "url(#clip)");
svg.append("g")
.attr("class", "tradearrow")
.attr("clip-path", "url(#clip)");
svg.append("g")
.attr("class", "x axis")
.attr("transform", "translate(0," + height + ")");
svg.append("g")
.attr("class", "y axis")
.append("text")
.attr("transform", "rotate(-90)")
.attr("y", 6)
.attr("dy", ".71em")
.style("text-anchor", "end")
.text("Price ($)");
svg.append("rect")
.attr("class", "pane")
.attr("width", width)
.attr("height", height)
.call(zoom);
var accessor = candlestick.accessor();
const data = _data.map(function(d) {
return {
date: new Date(d.start),
open: +d.open,
high: +d.high,
low: +d.low,
close: +d.close,
volume: +d.volume
};
}).sort(function(a, b) { return d3.ascending(accessor.d(a), accessor.d(b)); });
const trades = _trades.map(function(t) {
let trade = _.pick(t, ['price']);
trade.quantity = 1;
trade.type = t.action;
trade.date = new Date(t.date);
return trade;
});
console.log(trades);
x.domain(data.map(accessor.d));
y.domain(techan.scale.plot.ohlc(data, accessor).domain());
svg.select("g.tradearrow").datum(trades);
svg.select("g.candlestick").datum(data);
draw();
// Associate the zoom with the scale after a domain has been applied
// Stash initial settings to store as baseline for zooming
zoomableInit = x.zoomable().clamp(false).copy();
function zoomed() {
var rescaledY = d3.event.transform.rescaleY(y);
yAxis.scale(rescaledY);
candlestick.yScale(rescaledY);
tradearrow.yScale(rescaledY);
// Emulates D3 behaviour, required for financetime due to secondary zoomable scale
x.zoomable().domain(d3.event.transform.rescaleX(zoomableInit).domain());
draw();
}
function draw() {
svg.select("g.tradearrow").call(tradearrow);
svg.select("g.candlestick").call(candlestick);
// using refresh method is more efficient as it does not perform any data joins
// Use this if underlying data is not changing
// svg.select("g.candlestick").call(candlestick.refresh);
svg.select("g.x.axis").call(xAxis);
svg.select("g.y.axis").call(yAxis)
}
function enter(d) {
valueText.style("display", "inline");
refreshText(d);
}
function out() {
valueText.style("display", "none");
}
function refreshText(d) {
valueText.text("Trade: " + dateFormat(d.date) + ", " + d.type + ", " + valueFormat(d.price));
}
};
+204
View File
@@ -0,0 +1,204 @@
import _ from 'lodash';
// global moment
export default function(_data, _trades, _height) {
const toDate = i => {
if(_.isNumber(i)) {
return moment.unix(i).utc().toDate();
} else {
return moment.utc(i).toDate();
}
}
const trades = _trades.map(t => {
return {
price: t.price,
date: toDate(t.date),
action: t.action
}
});
const data = _data.map(c => {
return {
price: c.open,
date: toDate(c.start)
}
});
var dates = data.map(c => +c.date);
var prices = data.map(c => +c.price)
var svg = d3.select("#chart");
svg.attr("width", window.innerWidth - 20);
var margin = {top: 20, right: 20, bottom: 110, left: 40};
var height = _height - margin.top - margin.bottom;
var margin2 = {top: _height - 70, right: 20, bottom: 30, left: 40};
var width = +svg.attr("width") - margin.left - margin.right;
var height2 = _height - margin2.top - margin2.bottom;
var x = d3.scaleUtc().range([0, width]),
x2 = d3.scaleUtc().range([0, width]),
y = d3.scaleLinear().range([height, 0]),
y2 = d3.scaleLinear().range([height2, 0]);
var xAxis = d3.axisBottom(x),
xAxis2 = d3.axisBottom(x2),
yAxis = d3.axisLeft(y).ticks(_height / 50);
var brush = d3.brushX()
.extent([[0, 0], [width, height2]])
.on("brush end", brushed);
var zoom = d3.zoom()
.scaleExtent([1, 100])
.translateExtent([[0, 0], [width, height]])
.extent([[0, 0], [width, height]])
.on("zoom", zoomed);
var line = d3.line()
.x(function(d) { return x(d.date); })
.y(function(d) { return y(d.price); });
var line2 = d3.line()
.x(function(d) { return x2(d.date); })
.y(function(d) { return y2(d.price); });
svg.append("defs").append("clipPath")
.attr("id", "clip")
.append("rect")
.attr("width", width)
.attr("height", height);
var focus = svg.append("g")
.attr("class", "focus")
.attr("transform", "translate(" + margin.left + "," + margin.top + ")");
var context = svg.append("g")
.attr("class", "context")
.attr("transform", "translate(" + margin2.left + "," + margin2.top + ")");
x.domain(d3.extent(data, function(d) { return d.date; }));
y.domain([
d3.min(prices) * 0.99,
d3.max(prices) * 1.01
]);
x2.domain(x.domain());
y2.domain(y.domain());
focus.append("path")
.datum(data)
.attr("class", "line price")
.attr("d", line);
focus.append("g")
.attr("class", "axis axis--x")
.attr("transform", "translate(0," + height + ")")
.call(xAxis);
focus.append("g")
.attr("class", "axis axis--y")
.call(yAxis);
context.append("path")
.datum(data)
.attr("class", "line")
.attr("d", line2);
context.append("g")
.attr("class", "axis axis--x")
.attr("transform", "translate(0," + height2 + ")")
.call(xAxis2);
var circles = svg
.append('g')
.attr("transform", "translate(" + margin.left + "," + margin.top + ")")
.selectAll("circle")
.data(trades)
.enter().append("circle")
.attr('class', function(d) { return d.action })
.attr("cx", function(d) { return x(d.date); })
.attr("cy", function(d) { return y(d.price); })
.attr('r', 5);
var brushCircles = context
.append('g')
// .attr("transform", "translate(" + margin.left + "," + margin.top + ")")
.selectAll("circle")
.data(trades)
.enter().append("circle")
.attr('class', function(d) { return d.action })
.attr("cx", function(d) { return x2(d.date); })
.attr("cy", function(d) { return y2(d.price); })
.attr('r', 3);
context.append("g")
.attr("class", "brush")
.call(brush)
.call(brush.move, x.range());
svg.append("rect")
.attr("class", "zoom")
.attr("width", width)
.attr("height", height)
.attr("transform", "translate(" + margin.left + "," + margin.top + ")")
.call(zoom);
function brushed() {
if (d3.event.sourceEvent && d3.event.sourceEvent.type === "zoom") return; // ignore brush-by-zoom
var s = d3.event.selection || x2.range();
x.domain(s.map(x2.invert, x2));
scaleY(x.domain());
svg.select(".axis--y")
.call(yAxis);
circles
.attr("cx", function(d) { return x(d.date); })
.attr("cy", function(d) { return y(d.price); })
focus.select(".line").attr("d", line);
focus.select(".axis--x").call(xAxis);
svg.select(".zoom").call(zoom.transform, d3.zoomIdentity
.scale(width / (s[1] - s[0]))
.translate(-s[0], 0));
}
function scaleY(domain) {
let [min, max] = domain;
let minIndex = _.sortedIndex(dates, min);
let maxIndex = _.sortedIndex(dates, max);
let set = prices.slice(minIndex, maxIndex);
y.domain([
d3.min(set) * 0.9995,
d3.max(set) * 1.0005
]);
}
function zoomed() {
if (d3.event.sourceEvent && d3.event.sourceEvent.type === "brush") return; // ignore zoom-by-brush
var t = d3.event.transform;
scaleY(t.rescaleX(x2).domain());
svg.select(".axis--y")
.call(yAxis);
x.domain(t.rescaleX(x2).domain());
focus.select(".line").attr("d", line);
circles
.attr("cx", function(d) { return x(d.date); })
.attr("cy", function(d) { return y(d.price); })
focus.select(".axis--x").call(xAxis);
context.select(".brush").call(brush.move, x.range().map(t.invertX, t));
}
}
+11
View File
@@ -0,0 +1,11 @@
export const draw = function(message) {
d3.select("#chart").append("text")
.attr('class', 'message')
.attr('x', 150)
.attr('y', 150)
.text(message);
}
export const clear = function() {
d3.select("#chart").find('text').remove();
}
+47
View File
@@ -0,0 +1,47 @@
import Vue from 'vue'
import App from './App.vue'
import VueRouter from 'vue-router'
Vue.use(VueRouter);
import store from './store'
import backtester from './components/backtester/backtester.vue'
import home from './components/layout/home.vue'
import data from './components/data/data.vue'
import importer from './components/data/import/importer.vue'
import singleImport from './components/data/import/single.vue'
import config from './components/config/config.vue'
import gekkoList from './components/gekko/list.vue'
import newGekko from './components/gekko/new.vue'
import singleGekko from './components/gekko/singleGekko.vue'
import { connect as connectWS } from './components/global/ws'
const router = new VueRouter({
mode: 'hash',
base: __dirname,
routes: [
{ path: '/', redirect: '/home' },
{ path: '/home', component: home },
{ path: '/backtest', component: backtester },
{ path: '/config', component: config },
{ path: '/data', component: data },
{ path: '/data/importer', component: importer },
{ path: '/data/importer/import/:id', component: singleImport },
{ path: '/live-gekkos', component: gekkoList },
{ path: '/live-gekkos/new', component: newGekko },
{ path: '/live-gekkos/:id', component: singleGekko },
]
});
// setup some stuff
connectWS();
new Vue({
router,
store,
el: '#app',
render: h => h(App)
})
+38
View File
@@ -0,0 +1,38 @@
import Vue from 'vue'
import Vuex from 'vuex'
import _ from 'lodash'
import * as importMutations from './modules/imports/mutations'
import * as gekkoMutations from './modules/gekkos/mutations'
import * as notificationMutations from './modules/notifications/mutations'
import * as configMutations from './modules/config/mutations'
Vue.use(Vuex);
const debug = process.env.NODE_ENV !== 'production'
let mutations = {};
_.merge(mutations, importMutations);
_.merge(mutations, gekkoMutations);
_.merge(mutations, notificationMutations);
_.merge(mutations, configMutations);
export default new Vuex.Store({
state: {
warnings: {
connected: true, // assume we will connect
},
imports: [],
gekkos: {},
archivedGekkos: {},
connection: {
disconnected: false,
reconnected: false
},
apiKeys: [],
exchanges: {}
},
mutations,
strict: debug
})
+14
View File
@@ -0,0 +1,14 @@
import Vue from 'vue'
import Vuex from 'vuex'
import syncImports from './modules/imports/sync'
import syncGekkos from './modules/gekkos/sync'
import syncNotifications from './modules/notifications/sync'
import syncConfig from './modules/config/sync'
export default function() {
syncImports();
syncGekkos();
syncNotifications();
syncConfig();
}
@@ -0,0 +1,11 @@
import Vue from 'vue'
export const syncApiKeys = (state, apiKeys) => {
Vue.set(state, 'apiKeys', apiKeys);
return state;
}
export const syncExchanges = (state, exchanges) => {
Vue.set(state, 'exchanges', exchanges);
return state;
}
+54
View File
@@ -0,0 +1,54 @@
import { get } from '../../../tools/ajax'
import store from '../../'
import { bus } from '../../../components/global/ws'
const transformMarkets = backendData => {
if(!backendData) {
return {};
}
var exchangesRaw = backendData;
var exchangesTemp = {};
exchangesRaw.forEach(e => {
exchangesTemp[e.slug] = exchangesTemp[e.slug] || {markets: {}};
e.markets.forEach( pair => {
let [ currency, asset ] = pair['pair'];
exchangesTemp[e.slug].markets[currency] = exchangesTemp[e.slug].markets[currency] || [];
exchangesTemp[e.slug].markets[currency].push( asset );
});
if ("exchangeMaxHistoryAge" in e) {
exchangesTemp[e.slug].exchangeMaxHistoryAge = e.exchangeMaxHistoryAge;
}
exchangesTemp[e.slug].importable = e.providesFullHistory ? true : false;
exchangesTemp[e.slug].tradable = e.tradable ? true : false;
exchangesTemp[e.slug].requires = e.requires;
});
return exchangesTemp;
}
const init = () => {
get('apiKeys', (err, resp) => {
store.commit('syncApiKeys', resp);
});
get('exchanges', (err, resp) => {
store.commit('syncExchanges', transformMarkets(resp));
})
}
const sync = () => {
bus.$on('apiKeys', data => {
store.commit('syncApiKeys', data.exchanges);
});
}
export default function() {
init();
sync();
}
@@ -0,0 +1,77 @@
import Vue from 'vue'
import _ from 'lodash';
const reduceState = require('../../../../../state/reduceState');
export const syncGekkos = (state, data) => {
if(!data) {
return state;
}
state.gekkos = data.live;
state.archivedGekkos = data.archive;
return state;
}
export const addGekko = (state, gekko) => {
state.gekkos = {
...state.gekkos,
[gekko.id]: gekko
}
return state;
}
export const updateGekko = (state, update) => {
if(!update.id || !_.has(state.gekkos, update.id)) {
return console.error('cannot update unknown gekko..');;
}
state.gekkos = {
...state.gekkos,
[update.id]: reduceState(state.gekkos[update.id], update.event)
}
return state;
}
export const archiveGekko = (state, id) => {
if(!_.has(state.gekkos, id)) {
return console.error('cannot archive unknown gekko..');
}
state.archivedGekkos = {
...state.archivedGekkos,
[id]: {
...state.gekkos[id],
stopped: true,
active: false
}
}
state.gekkos = _.omit(state.gekkos, id);
return state;
}
export const errorGekko = (state, data) => {
if(!_.has(state.gekkos, data.id)) {
return console.error('cannot error unknown gekko..');
}
state.gekkos = {
...state.gekkos,
[data.id]: {
...state.gekkos[data.id],
errored: true,
errorMessage: data.error
}
}
return state;
}
export const deleteGekko = (state, id) => {
if(!_.has(state.archivedGekkos, id)) {
return console.error('cannot delete unknown gekko..');
}
state.archivedGekkos = _.omit(state.archivedGekkos, id);
return state;
}
+28
View File
@@ -0,0 +1,28 @@
import { get } from '../../../tools/ajax'
import store from '../../'
import { bus } from '../../../components/global/ws'
import _ from 'lodash'
const init = () => {
get('gekkos', (err, resp) => {
const gekkos = resp;
store.commit('syncGekkos', gekkos);
});
}
const sync = () => {
bus.$on('gekko_new', data => store.commit('addGekko', data.state));
bus.$on('gekko_event', data => store.commit('updateGekko', data));
bus.$on('gekko_archived', data => store.commit('archiveGekko', data.id));
bus.$on('gekko_error', data => store.commit('errorGekko', data));
bus.$on('gekko_deleted', data => store.commit('deleteGekko', data.id));
// unused:
// bus.$on('gekko_stopped', data => store.commit('x', data.id));
// bus.$on('gekko_deleted', data => store.commit('x', data.id));
}
export default function() {
init();
sync();
}
@@ -0,0 +1,23 @@
import Vue from 'vue'
export const addImport = (state, imp) => {
state.imports.push(imp);
return state;
}
export const syncImports = (state, imports) => {
state.imports = imports;
return state;
}
export const updateImport = (state, update) => {
let index = state.imports.findIndex(i => i.id === update.import_id);
let item = state.imports[index];
if(!item)
return state;
let updated = Vue.util.extend(item, update.updates);
Vue.set(state.imports, index, updated);
return state;
}
+20
View File
@@ -0,0 +1,20 @@
import { get } from '../../../tools/ajax'
import store from '../../'
import { bus } from '../../../components/global/ws'
const init = () => {
get('imports', (err, resp) => {
store.commit('syncImports', resp);
});
}
const sync = () => {
bus.$on('import_update', data => {
store.commit('updateImport', data);
});
}
export default function() {
init();
sync();
}
Whitespace-only changes.
@@ -0,0 +1,7 @@
import Vue from 'vue'
import _ from 'lodash'
export const setGlobalWarning = (state, warning) => {
state.warnings[warning.key] = warning.value;
return state;
}
@@ -0,0 +1,15 @@
import store from '../../'
import { bus } from '../../../components/global/ws'
const init = () => {}
const sync = () => {
bus.$on('WS_STATUS_CHANGE', ws => {
return store.commit('setGlobalWarning', {key: 'connected', value: ws.connected});
});
}
export default function() {
init();
sync();
}
+30
View File
@@ -0,0 +1,30 @@
import superagent from 'superagent'
import noCache from 'superagent-no-cache'
import { restPath } from './api.js'
const processResponse = next => (err, res) => {
if(err)
return next(err);
if(!res.text)
return next('no data');
let data = JSON.parse(res.text);
next(false, data);
}
export const post = (to, data, next) => {
superagent
.post(restPath + to)
.use(noCache)
.send(data)
.end(processResponse(next));
}
export const get = (to, next) => {
superagent
.get(restPath + to)
.use(noCache)
.end(processResponse(next));
}
+28
View File
@@ -0,0 +1,28 @@
// global window.CONFIG
const config = window.CONFIG.ui;
const endpoint = `${config.host}${config.port === 80 ? '' : `:${config.port}`}${config.path}`;
let basePath, restPath, wsPath;
// rest API path
if(config.ssl) {
basePath = `https://${endpoint}`;
} else {
basePath = `http://${endpoint}`;
}
restPath = basePath + 'api/';
// ws API path
if(config.ssl) {
wsPath = `wss://${endpoint}api`;
} else {
wsPath = `ws://${endpoint}api`;
}
export {
wsPath,
restPath,
basePath
};
+24
View File
@@ -0,0 +1,24 @@
const marked = require('marked');
// add `target='_blank'` to outgoing links
// https://github.com/chjj/marked/pull/451#issuecomment-49976076
var myRenderer = new marked.Renderer();
myRenderer.link = function(href, title, text) {
var external, newWindow, out;
external = /^https?:\/\/.+$/.test(href);
newWindow = external || title === 'newWindow';
out = "<a href=\"" + href + "\"";
if (newWindow) {
out += ' target="_blank"';
}
if (title && title !== 'newWindow') {
out += " title=\"" + title + "\"";
}
return out += ">" + text + "</a>";
};
marked.setOptions({renderer: myRenderer});
export default marked;