├── .nvmrc ├── .env.example ├── prisma ├── db │ └── backtest.db └── schema.prisma ├── _config.yml ├── .prettierrc ├── src ├── core │ ├── common.ts │ ├── historical-data │ │ ├── remove.ts │ │ ├── export-csv.ts │ │ ├── find.ts │ │ ├── import-csv.ts │ │ └── download.ts │ ├── results │ │ ├── find.ts │ │ ├── remove.ts │ │ └── save.ts │ ├── results-multi │ │ ├── find.ts │ │ ├── remove.ts │ │ └── save.ts │ └── strategies │ │ ├── find.ts │ │ ├── scan.ts │ │ └── run.ts ├── helpers │ ├── logger.ts │ ├── strategies.ts │ ├── error.ts │ ├── api.ts │ ├── prisma-results-multi.ts │ ├── prisma-strategies.ts │ ├── historical-data.ts │ ├── interfaces.ts │ ├── csv.ts │ ├── prisma-historical-data.ts │ ├── prisma-results.ts │ ├── run-strategy.ts │ ├── orders.ts │ └── parse.ts ├── strategies │ └── demo.ts └── demo.ts ├── .gitignore ├── types └── global.d.ts ├── tsconfig.json ├── docs ├── EXAMPLES.md ├── CONTRIBUTING.md ├── BASIC_USAGE.md ├── ADVANCED_USAGE.md ├── FAQ.md ├── STRATEGY.md └── QUICK_START.md ├── package.json ├── main.ts ├── LICENSE └── README.md /.nvmrc: -------------------------------------------------------------------------------- 1 | v22.8.0 2 | -------------------------------------------------------------------------------- /.env.example: -------------------------------------------------------------------------------- 1 | DATABASE_URL=file:./db/backtest.db 2 | FRAMEWORK_LOG_LEVEL=DEBUG -------------------------------------------------------------------------------- /prisma/db/backtest.db: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/backtestjs/framework/HEAD/prisma/db/backtest.db -------------------------------------------------------------------------------- /_config.yml: -------------------------------------------------------------------------------- 1 | remote_theme: pages-themes/hacker@v0.2.0 2 | plugins: 3 | - jekyll-remote-theme # add this line to the plugins list if you already have one 4 | -------------------------------------------------------------------------------- /.prettierrc: -------------------------------------------------------------------------------- 1 | { 2 | "printWidth": 120, 3 | "tabWidth": 2, 4 | "useTabs": false, 5 | "semi": false, 6 | "singleQuote": true, 7 | "bracketSpacing": true, 8 | "trailingComma": "none", 9 | "proseWrap": "preserve" 10 | } 11 | -------------------------------------------------------------------------------- /src/core/common.ts: -------------------------------------------------------------------------------- 1 | export function getIntervals() { 2 | return ['1m', '3m', '5m', '15m', '30m', '1h', '2h', '4h', '6h', '8h', '12h', '1d', '3d', '1w', '1M'] 3 | } 4 | 5 | export function isValidInterval(interval: string) { 6 | return getIntervals().includes(interval) 7 | } 8 | -------------------------------------------------------------------------------- /src/core/historical-data/remove.ts: -------------------------------------------------------------------------------- 1 | import { deleteCandles } from '../../helpers/prisma-historical-data' 2 | import { BacktestError, ErrorCode } from '../../helpers/error' 3 | 4 | export async function deleteHistoricalData(name: string): Promise { 5 | if (!name) { 6 | throw new BacktestError('Name is required', ErrorCode.MissingInput) 7 | } 8 | return deleteCandles(name) 9 | } 10 | -------------------------------------------------------------------------------- /src/core/historical-data/export-csv.ts: -------------------------------------------------------------------------------- 1 | import { exportCSV } from '../../helpers/csv' 2 | import { BacktestError, ErrorCode } from '../../helpers/error' 3 | 4 | export async function exportFileCSV(name: string, rootPath: string = './csv'): Promise { 5 | if (!name) { 6 | throw new BacktestError('Name is required', ErrorCode.MissingInput) 7 | } 8 | return exportCSV(name, rootPath) 9 | } 10 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | /prisma/migrations 2 | /node_modules 3 | /archived 4 | /tests 5 | /dist 6 | /esm 7 | /csv 8 | 9 | results-unsorted-multi.json 10 | results-stats-multi.json 11 | backtest.db-journal 12 | results-candles.json 13 | results-orders.json 14 | results-worths.json 15 | results-multi.json 16 | results-stats.json 17 | candleName.json 18 | backtest.db 19 | candles.json 20 | .DS_Store 21 | 22 | /**/strategies/test*.ts 23 | .env 24 | .npm 25 | .npmrc -------------------------------------------------------------------------------- /types/global.d.ts: -------------------------------------------------------------------------------- 1 | export { 2 | RunStrategy, 3 | BuySell, 4 | BuySellReal, 5 | GetCandles, 6 | Candle, 7 | MetaCandle, 8 | BTH, 9 | OrderBook, 10 | ImportCSV, 11 | StrategyResult, 12 | GetStrategyResult, 13 | StrategyResultMulti, 14 | Order, 15 | Worth, 16 | RunMetaData, 17 | StrategyMeta, 18 | LooseObject, 19 | ScanAction, 20 | AssetAmounts, 21 | RunStrategyResultMulti, 22 | RunStrategyResult 23 | } from '../src/helpers/interfaces' 24 | -------------------------------------------------------------------------------- /src/core/results/find.ts: -------------------------------------------------------------------------------- 1 | import { getAllStrategyResultNames, getAllStrategyResults, getResult } from '../../helpers/prisma-results' 2 | import { GetStrategyResult } from '../../helpers/interfaces' 3 | 4 | export async function findResultNames(): Promise { 5 | return (await getAllStrategyResultNames()).sort() 6 | } 7 | 8 | export async function findResults(): Promise { 9 | return getAllStrategyResults() 10 | } 11 | 12 | export { getResult } 13 | -------------------------------------------------------------------------------- /src/core/results-multi/find.ts: -------------------------------------------------------------------------------- 1 | import { getAllMultiResultNames, getAllMultiResults, getMultiResult } from '../../helpers/prisma-results-multi' 2 | import { StrategyResultMulti } from '../../helpers/interfaces' 3 | 4 | export async function findMultiResultNames(): Promise { 5 | return (await getAllMultiResultNames()).sort() 6 | } 7 | 8 | export async function findMultiResults(): Promise { 9 | return getAllMultiResults() 10 | } 11 | 12 | export { getMultiResult } 13 | -------------------------------------------------------------------------------- /src/core/strategies/find.ts: -------------------------------------------------------------------------------- 1 | import { getAllStrategies, getStrategy } from '../../helpers/prisma-strategies' 2 | import { StrategyMeta } from '../../helpers/interfaces' 3 | 4 | export async function findStrategyNames(): Promise { 5 | const strategies = await findStrategies() 6 | return strategies.map((strategy: StrategyMeta) => strategy.name).sort() 7 | } 8 | 9 | export async function findStrategies(): Promise { 10 | return getAllStrategies() 11 | } 12 | 13 | export async function findStrategy(name: string): Promise { 14 | return getStrategy(name) 15 | } 16 | -------------------------------------------------------------------------------- /src/core/results/remove.ts: -------------------------------------------------------------------------------- 1 | import { getAllStrategyResultNames, deleteStrategyResult } from '../../helpers/prisma-results' 2 | import { BacktestError, ErrorCode } from '../../helpers/error' 3 | 4 | export async function deleteResult(resultsName: string): Promise { 5 | if (!resultsName) { 6 | throw new BacktestError('Results name is required', ErrorCode.MissingInput) 7 | } 8 | 9 | const allResults: string[] = await getAllStrategyResultNames() 10 | if (!allResults.includes(resultsName)) { 11 | throw new BacktestError(`Results ${resultsName} not found`, ErrorCode.NotFound) 12 | } 13 | 14 | return deleteStrategyResult(resultsName) 15 | } 16 | -------------------------------------------------------------------------------- /src/core/results-multi/remove.ts: -------------------------------------------------------------------------------- 1 | import * as prismaResults from '../../helpers/prisma-results-multi' 2 | import { BacktestError, ErrorCode } from '../../helpers/error' 3 | 4 | export async function deleteMultiResult(resultsName: string): Promise { 5 | if (!resultsName) { 6 | throw new BacktestError('Results name is required', ErrorCode.MissingInput) 7 | } 8 | 9 | const allResults: string[] = await prismaResults.getAllMultiResultNames() 10 | if (!allResults.includes(resultsName)) { 11 | throw new BacktestError(`Results ${resultsName} not found`, ErrorCode.NotFound) 12 | } 13 | 14 | return prismaResults.deleteMultiResult(resultsName) 15 | } 16 | -------------------------------------------------------------------------------- /src/core/historical-data/find.ts: -------------------------------------------------------------------------------- 1 | import { getCandleMetaData, getAllCandleMetaData, getCandles } from '../../helpers/prisma-historical-data' 2 | import { MetaCandle } from '../../helpers/interfaces' 3 | 4 | export async function findHistoricalDataNames(): Promise { 5 | const historicalData: MetaCandle[] = await getAllCandleMetaData() 6 | return historicalData.map((data: MetaCandle) => data.name).sort() 7 | } 8 | 9 | export async function findHistoricalDataSets(): Promise { 10 | return getAllCandleMetaData() 11 | } 12 | 13 | export async function findHistoricalData(name: string): Promise { 14 | return getCandleMetaData(name) 15 | } 16 | 17 | export { getCandles } 18 | -------------------------------------------------------------------------------- /src/helpers/logger.ts: -------------------------------------------------------------------------------- 1 | export enum LogLevel { 2 | ERROR = 40, 3 | INFO = 30, 4 | DEBUG = 20, 5 | TRACE = 10 6 | } 7 | 8 | let currentLevel: number = LogLevel.ERROR 9 | try { 10 | currentLevel = LogLevel[process.env?.FRAMEWORK_LOG_LEVEL?.toUpperCase() || 'ERROR'] 11 | } catch (error) { 12 | currentLevel = LogLevel.ERROR 13 | } 14 | 15 | export function error(...args: any[]) { 16 | if (_shouldLog(LogLevel.ERROR)) { 17 | console.log('ERROR:', ...args) 18 | } 19 | } 20 | 21 | export function info(...args: any[]) { 22 | if (_shouldLog(LogLevel.INFO)) { 23 | console.log('INFO:', ...args) 24 | } 25 | } 26 | 27 | export function debug(...args: any[]) { 28 | if (_shouldLog(LogLevel.DEBUG)) { 29 | console.log('DEBUG:', ...args) 30 | } 31 | } 32 | 33 | export function trace(...args: any[]) { 34 | if (_shouldLog(LogLevel.TRACE)) { 35 | console.log('TRACE:', ...args) 36 | } 37 | } 38 | 39 | function _shouldLog(level: LogLevel): boolean { 40 | return level >= currentLevel 41 | } 42 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | // https://typestrong.org/ts-node/docs/configuration/ 2 | { 3 | "extends": "ts-node/node16/tsconfig.json", 4 | "ts-node": { 5 | "files": true, 6 | "compilerOptions": { 7 | "module": "commonjs" 8 | } 9 | }, 10 | "compilerOptions": { 11 | "module": "commonjs", 12 | "moduleResolution": "node", 13 | "declaration": true, 14 | "noImplicitAny": false, 15 | "noUnusedLocals": false, 16 | "removeComments": true, 17 | "noLib": false, 18 | "emitDecoratorMetadata": true, 19 | "experimentalDecorators": true, 20 | "useUnknownInCatchVariables": false, 21 | "target": "es6", 22 | "sourceMap": true, 23 | "allowJs": true, 24 | "outDir": "dist", 25 | "lib": ["es7"], 26 | "resolveJsonModule": true, 27 | "paths": { 28 | "@types": ["./types"] 29 | } 30 | }, 31 | "include": ["*.ts", "*.d.ts", "*.json", "src/**/*", "src/**/*.json", "types/**/*"], 32 | "exclude": ["node_modules", "test/**/*", "src/strategies/demo.ts"] 33 | } 34 | -------------------------------------------------------------------------------- /docs/EXAMPLES.md: -------------------------------------------------------------------------------- 1 | # Code Examples 2 | 3 | ## Moving Average Crossover 4 | 5 | ```typescript 6 | export async function runStrategy(bth: BTH) { 7 | const sma10 = await bth.getCandles('close', 10) 8 | const sma20 = await bth.getCandles('close', 20) 9 | 10 | if (sma10 > sma20) { 11 | await bth.buy() 12 | } else { 13 | await bth.sell() 14 | } 15 | } 16 | ``` 17 | 18 | ## RSI Strategy 19 | 20 | ```typescript 21 | export async function runStrategy(bth: BTH) { 22 | const closes = await bth.getCandles('close', 14) 23 | const rsi = calculateRSI(closes) 24 | 25 | if (rsi < 30) { 26 | await bth.buy() 27 | } else if (rsi > 70) { 28 | await bth.sell() 29 | } 30 | } 31 | ``` 32 | 33 | ## Multi-Timeframe Strategy 34 | 35 | ```typescript 36 | export async function runStrategy(bth: BTH) { 37 | if (bth.tradingCandle) { 38 | const trend = await bth.getCandles('close', 1, 0) 39 | if (trend > 0) await bth.buy() 40 | } else { 41 | // Support timeframe analysis 42 | const volume = await bth.getCandles('volume', 1) 43 | console.log('Volume:', volume) 44 | } 45 | } 46 | ``` 47 | -------------------------------------------------------------------------------- /src/core/results-multi/save.ts: -------------------------------------------------------------------------------- 1 | import { insertMultiResult, getAllMultiResultNames, deleteMultiResult } from '../../helpers/prisma-results-multi' 2 | import { StrategyResultMulti } from '../../helpers/interfaces' 3 | import { BacktestError, ErrorCode } from '../../helpers/error' 4 | import * as logger from '../../helpers/logger' 5 | 6 | export async function saveMultiResult(resultsName: string, results: StrategyResultMulti, override: boolean = false) { 7 | if (!resultsName) { 8 | throw new BacktestError('Results name is required', ErrorCode.MissingInput) 9 | } 10 | 11 | results.name = resultsName 12 | 13 | // Check if results already exist 14 | const allResults = await getAllMultiResultNames() 15 | if (allResults.includes(results.name)) { 16 | if (!override) { 17 | throw new BacktestError( 18 | `Results ${results.name} has saved results already. Use override option to rewrite them.`, 19 | ErrorCode.Conflict 20 | ) 21 | } 22 | 23 | // Delete already existing entry 24 | await deleteMultiResult(results.name) 25 | } 26 | 27 | // Save the results to the dB 28 | await insertMultiResult(results) 29 | logger.info(`Successfully saved trading results for ${results.name}`) 30 | return true 31 | } 32 | -------------------------------------------------------------------------------- /src/core/results/save.ts: -------------------------------------------------------------------------------- 1 | import { insertResult, getAllStrategyResultNames, deleteStrategyResult } from '../../helpers/prisma-results' 2 | import { StrategyResult } from '../../helpers/interfaces' 3 | import { BacktestError, ErrorCode } from '../../helpers/error' 4 | import * as logger from '../../helpers/logger' 5 | 6 | export async function saveResult( 7 | resultsName: string, 8 | results: StrategyResult, 9 | override: boolean = false 10 | ): Promise { 11 | if (!resultsName) { 12 | throw new BacktestError('Results name is required', ErrorCode.MissingInput) 13 | } 14 | 15 | results.name = resultsName 16 | 17 | // Check if results already exist 18 | const allResults: string[] = await getAllStrategyResultNames() 19 | if (allResults.includes(results.name)) { 20 | if (!override) { 21 | throw new BacktestError( 22 | `Results ${results.name} has saved results already. Use override option to rewrite them.`, 23 | ErrorCode.Conflict 24 | ) 25 | } 26 | 27 | // Delete already existing entry 28 | await deleteStrategyResult(results.name) 29 | } 30 | 31 | // Save the results to the dB 32 | await insertResult(results) 33 | logger.info(`Successfully saved trading results for ${results.name}`) 34 | return true 35 | } 36 | -------------------------------------------------------------------------------- /docs/CONTRIBUTING.md: -------------------------------------------------------------------------------- 1 | # How to contributing 2 | 3 | ## Getting Started 4 | 5 | 1. Fork the repository 6 | 2. Create a new branch 7 | 3. Make your changes 8 | 4. Submit a pull request 9 | 10 | ## Development Setup 11 | 12 | 1. Install dependencies: 13 | 14 | ```bash 15 | npm install 16 | ``` 17 | 18 | 2. Configure environment: 19 | 20 | ```bash 21 | cp .env.example .env 22 | ``` 23 | 24 | 3. Build the project: 25 | 26 | ```bash 27 | npm run build 28 | ``` 29 | 30 | 4. Run the project: 31 | 32 | ```bash 33 | npm run start # or "npm run dev" if you prefer 34 | ``` 35 | 36 | ## Code Standards 37 | 38 | - Write clean, documented code 39 | - Follow TypeScript best practices 40 | - Update documentation as needed 41 | 42 | ## Pull Request Process 43 | 44 | 1. Update documentation 45 | 2. Check if all works 46 | 3. Submit PR with clear description 47 | 48 | ## Bug Reports 49 | 50 | When reporting bugs, include: 51 | 52 | - Node.js version 53 | - Framework version 54 | - Additional npm used 55 | - Full error message 56 | - Minimal code example 57 | 58 | ## Feature Requests 59 | 60 | When requesting features: 61 | 62 | - Explain the use case 63 | - Provide sample code, if applicable. 64 | - Describe the expected behavior and 65 | - Indicate if you can contribute your time 66 | - Indicate if you can help fund development 67 | -------------------------------------------------------------------------------- /docs/BASIC_USAGE.md: -------------------------------------------------------------------------------- 1 | # Basic Usage 2 | 3 | ## Installation 4 | 5 | ```bash 6 | npm install @backtest/framework 7 | ``` 8 | 9 | ## Quick Start 10 | 11 | 1. Create a basic strategy: 12 | 13 | ```typescript 14 | import { BTH } from '@backtest/framework' 15 | 16 | export async function runStrategy(bth: BTH) { 17 | const closePrice = await bth.getCandles('close', 1) 18 | const sma20 = await bth.getCandles('close', 20, 0) 19 | 20 | if (closePrice > sma20) { 21 | await bth.buy() 22 | } else { 23 | await bth.sell() 24 | } 25 | } 26 | ``` 27 | 28 | 2. Configure environment: 29 | 30 | ```env 31 | DATABASE_URL=file:./db/backtest.db 32 | FRAMEWORK_LOG_LEVEL=ERROR 33 | ``` 34 | 35 | 3. Run the strategy: 36 | 37 | ```typescript 38 | import { runStrategy } from '@backtest/framework' 39 | 40 | const result = await runStrategy({ 41 | strategyName: 'simpleMovingAverage', 42 | historicalData: ['BTCEUR-1d'], 43 | params: { period: 20 }, 44 | startingAmount: 1000 45 | }) 46 | ``` 47 | 48 | ## Historical Data 49 | 50 | Import data from CSV or download from exchanges: 51 | 52 | ```typescript 53 | import { downloadHistoricalData, importFileCSV } from '@backtest/framework' 54 | 55 | // Download from exchange 56 | await downloadHistoricalData('BTCEUR', { 57 | interval: '1d', 58 | start: '2023-01-01', 59 | end: '2023-12-31' 60 | }) 61 | 62 | // Import from CSV 63 | await importFileCSV('./data/BTCEUR.csv', { 64 | symbol: 'BTCEUR', 65 | interval: '1d' 66 | }) 67 | ``` 68 | -------------------------------------------------------------------------------- /docs/ADVANCED_USAGE.md: -------------------------------------------------------------------------------- 1 | # Advanced Usage 2 | 3 | ## Multiple Data Intervals 4 | 5 | Use multiple timeframes in your strategies: 6 | 7 | ```typescript 8 | export async function runStrategy(bth: BTH) { 9 | // Main trading interval 10 | if (bth.tradingCandle) { 11 | const shortMA = await bth.getCandles('close', 10) 12 | const longMA = await bth.getCandles('close', 20) 13 | 14 | if (shortMA > longMA) { 15 | await bth.buy() 16 | } 17 | } 18 | // Support interval for confirmations 19 | else { 20 | const volume = await bth.getCandles('volume', 1) 21 | const avgVolume = await bth.getCandles('volume', 20) 22 | console.log('Volume analysis:', volume > avgVolume) 23 | } 24 | } 25 | ``` 26 | 27 | ## Parameter Optimization 28 | 29 | Test multiple parameter combinations: 30 | 31 | ```typescript 32 | const result = await runStrategy({ 33 | strategyName: 'maStrategy', 34 | historicalData: ['BTCEUR-1d'], 35 | params: { 36 | shortPeriod: [5, 10, 15], 37 | longPeriod: [20, 30, 40] 38 | } 39 | }) 40 | ``` 41 | 42 | ## Custom Indicators 43 | 44 | Implement custom technical indicators: 45 | 46 | ```typescript 47 | function calculateRSI(values: number[], period: number): number { 48 | // RSI implementation 49 | return rsiValue 50 | } 51 | 52 | export async function runStrategy(bth: BTH) { 53 | const closes = await bth.getCandles('close', 14) 54 | const rsi = calculateRSI(closes, 14) 55 | 56 | if (rsi < 30) await bth.buy() 57 | if (rsi > 70) await bth.sell() 58 | } 59 | ``` 60 | -------------------------------------------------------------------------------- /docs/FAQ.md: -------------------------------------------------------------------------------- 1 | # Frequently Asked Questions (FAQ) 2 | 3 | ## General Questions 4 | 5 | ### What is Backtest JS? 6 | 7 | A TypeScript/JavaScript framework for backtesting trading strategies with support for multiple data sources and strategy types. 8 | 9 | ### What are the minimum requirements? 10 | 11 | - Node.js 22.0.0+ 12 | - NPM 13 | - Basic TypeScript knowledge 14 | 15 | ### How do I get started? 16 | 17 | 1. Install via NPM 18 | 2. Set up environment 19 | 3. Create your first strategy 20 | 4. Run backtests 21 | 22 | ## Technical Questions 23 | 24 | ### How does error handling work? 25 | 26 | The framework provides built-in error handling through the `BacktestError` class with specific error codes. 27 | 28 | ### Can I use custom indicators? 29 | 30 | Yes, you can create custom indicators or use the built-in technical indicators library. 31 | 32 | ### How do I optimize performance? 33 | 34 | 1. Use appropriate timeframes 35 | 2. Implement efficient calculations 36 | 3. Utilize caching mechanisms 37 | 4. Batch process where possible 38 | 39 | ### What data formats are supported? 40 | 41 | - CSV files 42 | - Direct exchange downloads 43 | - Custom data sources 44 | 45 | ## Common Issues 46 | 47 | ### Database Connection 48 | 49 | Ensure DATABASE_URL is correctly set in your .env file. 50 | 51 | ### Memory Usage 52 | 53 | Monitor and optimize memory usage in your strategies. 54 | 55 | ### Strategy Execution 56 | 57 | Use proper error handling and logging in your strategies. 58 | 59 | For more details, see other documentation files. 60 | -------------------------------------------------------------------------------- /docs/STRATEGY.md: -------------------------------------------------------------------------------- 1 | # Strategy Development Guide 2 | 3 | ## Basic Structure 4 | 5 | Every strategy must export a `runStrategy` function: 6 | 7 | ```typescript 8 | import { BTH } from '@backtest/framework' 9 | 10 | export async function runStrategy(bth: BTH) { 11 | // Strategy logic here 12 | } 13 | ``` 14 | 15 | ## Available Methods 16 | 17 | ### Price Data 18 | 19 | ```typescript 20 | const close = await bth.getCandles('close', 1) // Latest close price 21 | const opens = await bth.getCandles('open', 10) // Last 10 open prices 22 | const highs = await bth.getCandles('high', 5, 2) // High prices from 5 to 2 candles ago 23 | ``` 24 | 25 | ### Trading Actions 26 | 27 | ```typescript 28 | // Basic orders 29 | await bth.buy() 30 | await bth.sell() 31 | 32 | // Advanced orders 33 | await bth.buy({ 34 | amount: 100, 35 | stopLoss: 9500, 36 | takeProfit: 11000 37 | }) 38 | ``` 39 | 40 | ## Strategy Parameters 41 | 42 | Define and use strategy parameters: 43 | 44 | ```typescript 45 | export async function runStrategy(bth: BTH) { 46 | const { shortPeriod, longPeriod } = bth.params 47 | const shortMA = await bth.getCandles('close', shortPeriod) 48 | const longMA = await bth.getCandles('close', longPeriod) 49 | } 50 | ``` 51 | 52 | ## Multiple Timeframes 53 | 54 | Handle different intervals: 55 | 56 | ```typescript 57 | export async function runStrategy(bth: BTH) { 58 | if (bth.tradingCandle) { 59 | // Main trading logic 60 | const price = await bth.getCandles('close', 1) 61 | } else { 62 | // Support timeframe analysis 63 | const volume = await bth.getCandles('volume', 1) 64 | } 65 | } 66 | ``` 67 | -------------------------------------------------------------------------------- /src/helpers/strategies.ts: -------------------------------------------------------------------------------- 1 | import * as logger from './logger' 2 | const path = require('path') 3 | const glob = require('glob') 4 | 5 | function _normalizePatterns(fileName: string, rootPath?: string): Array { 6 | // replaceAll is needed for windows 7 | return !!rootPath 8 | ? [path.join(path.resolve(rootPath), fileName).replaceAll('\\', '/')] 9 | : [ 10 | path.join(__dirname, '..', 'strategies', fileName).replaceAll('\\', '/'), 11 | path.join(path.resolve(process.cwd()), 'strategies', fileName).replaceAll('\\', '/'), 12 | path.join(path.resolve(process.cwd()), 'src', 'strategies', fileName).replaceAll('\\', '/') 13 | ] 14 | } 15 | 16 | export function getStrategy(strategyName: string, rootPath?: string) { 17 | let file: string | null = null 18 | const patterns = _normalizePatterns(`${strategyName}.{ts,js}`, rootPath) 19 | patterns.forEach((pattern) => { 20 | logger.trace(`Searching for strategy ${pattern}`) 21 | glob 22 | .sync(pattern) 23 | .filter((f: string) => path.basename(f, path.extname(f)) === strategyName && !f.endsWith('.d.ts')) 24 | .forEach((f: string) => { 25 | file = f 26 | }) 27 | }) 28 | return file 29 | } 30 | 31 | export function getStrategiesFrom(rootPath?: string) { 32 | const files: string[] = [] 33 | const patterns = _normalizePatterns(`*.{ts,js}`, rootPath) 34 | patterns.forEach((pattern) => { 35 | logger.trace(`Searching in ${pattern}`) 36 | glob 37 | .sync(pattern) 38 | .filter((f: string) => !f.endsWith('.d.ts')) 39 | .forEach((f: string) => { 40 | files.push(f) 41 | }) 42 | }) 43 | return files 44 | } 45 | -------------------------------------------------------------------------------- /src/strategies/demo.ts: -------------------------------------------------------------------------------- 1 | import { BTH } from '../helpers/interfaces' 2 | import * as indicator from 'technicalindicators' 3 | 4 | export const properties = { 5 | params: ['lowSMA', 'highSMA'], 6 | dynamicParams: false 7 | } 8 | 9 | export async function startCallback(historicalName: string) { 10 | console.log('called before runStrategy', historicalName) 11 | } 12 | 13 | export async function finishCallback(historicalName: string) { 14 | console.log('called after runStrategy', historicalName) 15 | } 16 | 17 | export async function runStrategy(bth: BTH) { 18 | if (bth.tradingCandle) { 19 | const lowSMAInput = bth.params.lowSMA 20 | const highSMAInput = bth.params.highSMA 21 | 22 | // Get last candles 23 | const lowSMACandles = await bth.getCandles('close', lowSMAInput, 0) 24 | const highSMACandles = await bth.getCandles('close', highSMAInput, 0) 25 | 26 | // Just for example, get last volume and last candle 27 | const lastVolume = await bth.getCandles('volume', 1) // Get current volume, like `bth.currentCandle.volume` 28 | const lastCandle = await bth.getCandles('candle', 1) // Get current candle, like `bth.currentCandle` 29 | 30 | // Calculate low and high SMA 31 | const lowSMAs = indicator.SMA.calculate({ period: lowSMAInput, values: lowSMACandles }) 32 | const highSMAs = indicator.SMA.calculate({ period: highSMAInput, values: highSMACandles }) 33 | 34 | const lowSMA = lowSMAs[lowSMAs.length - 1] 35 | const highSMA = highSMAs[highSMAs.length - 1] 36 | 37 | // Buy if lowSMA crosses over the highSMA 38 | if (lowSMA > highSMA) { 39 | await bth.buy() 40 | } 41 | 42 | // Sell if lowSMA crosses under the highSMA 43 | else { 44 | await bth.sell() 45 | } 46 | } else { 47 | // do something else 48 | } 49 | } 50 | -------------------------------------------------------------------------------- /src/helpers/error.ts: -------------------------------------------------------------------------------- 1 | export enum ErrorCode { 2 | NotFound = 'NOT_FOUND', // Something was not found 3 | ActionFailed = 'ACTION_FAILED', // Unable to complete action (general) 4 | 5 | // Input errors 6 | InvalidInput = 'INVALID_INPUT', // Invalid data or parameter 7 | MissingInput = 'MISSING_INPUT', // Missing data or parameter 8 | 9 | // Strategy and Trade errors 10 | StrategyError = 'STRATEGY_ERROR', // Strategy related error 11 | StrategyNotFound = 'STRATEGY_NOT_FOUND', // Strategy not found 12 | TradeNotProcessed = 'TRADE_NOT_PROCESSED', // No trade processed 13 | 14 | // File/API errors 15 | ExternalAPI = 'EXTERNAL_API_ERROR', // Issues accessing external API (e.g., Binance) 16 | InvalidPath = 'INVALID_PATH', // Path does not exist or is invalid 17 | ParseError = 'PARSE_ERROR', // Parse error (general) 18 | 19 | // Data handling errors 20 | Conflict = 'DATA_CONFLICT', // Data already present 21 | Access = 'DATA_ACCESS', // Unable to access data 22 | Insert = 'DATA_INSERT', // Unable to insert data 23 | Delete = 'DATA_DELETE', // Unable to delete data 24 | Update = 'DATA_UPDATE', // Unable to update data 25 | Retrieve = 'DATA_RETRIEVE' // Unable to retrieve data 26 | } 27 | 28 | export class BacktestError extends Error { 29 | code: ErrorCode 30 | 31 | constructor(message: string, code: ErrorCode) { 32 | super(`${message} (Code: ${code})`) 33 | this.code = code 34 | this.name = this.constructor.name 35 | 36 | Object.setPrototypeOf(this, BacktestError.prototype) 37 | 38 | if (Error.captureStackTrace) { 39 | Error.captureStackTrace(this, this.constructor) 40 | } 41 | } 42 | 43 | toJSON() { 44 | return { 45 | name: this.name, 46 | code: this.code, 47 | message: this.message?.replace(/\s*\(Code: [^)]+\)/, '') || undefined 48 | } 49 | } 50 | 51 | toString() { 52 | return `${this.name}: ${this.message})` 53 | } 54 | } 55 | -------------------------------------------------------------------------------- /src/core/historical-data/import-csv.ts: -------------------------------------------------------------------------------- 1 | import { getAllCandleMetaData } from '../../helpers/prisma-historical-data' 2 | import { getIntervals, isValidInterval } from '../common' 3 | import { importCSV } from '../../helpers/csv' 4 | 5 | import { MetaCandle } from '../../helpers/interfaces' 6 | import { BacktestError, ErrorCode } from '../../helpers/error' 7 | 8 | export async function importFileCSV(base: string, quote: string, interval: string, path: string): Promise { 9 | if (!base) { 10 | throw new BacktestError('Base name (ex: BTC in BTCUSDT or APPL in APPL/USD) is required', ErrorCode.MissingInput) 11 | } 12 | 13 | if (!quote) { 14 | throw new BacktestError('Quote name (ex: USDT in BTCUSDT or USD in APPL/USD) is required', ErrorCode.MissingInput) 15 | } 16 | 17 | if (!interval || !isValidInterval(interval)) { 18 | throw new BacktestError(`Interval is required. Use one of ${getIntervals().join(' ')}`, ErrorCode.MissingInput) 19 | } 20 | 21 | if (!path) { 22 | throw new BacktestError('Path to CSV file is required', ErrorCode.MissingInput) 23 | } 24 | 25 | // Get historical metadata 26 | const historicalDataSets: MetaCandle[] = await getAllCandleMetaData() 27 | const isHistoricalDataPresent = historicalDataSets.some( 28 | (meta: MetaCandle) => meta.name === `${base + quote}-${interval}` 29 | ) 30 | 31 | // Validate entry does not already exist 32 | if (isHistoricalDataPresent) { 33 | throw new BacktestError( 34 | `Historical data already found for ${base + quote} with ${interval} interval.`, 35 | ErrorCode.Conflict 36 | ) 37 | } 38 | 39 | let filePath = path?.trim() 40 | 41 | // Remove path surrounding quotes if they exist 42 | if ((filePath.startsWith(`"`) && filePath.endsWith(`"`)) || (filePath.startsWith(`'`) && filePath.endsWith(`'`))) { 43 | filePath = filePath.substring(1, filePath.length - 1) 44 | } 45 | 46 | // Try to import the CSV 47 | return importCSV({ interval, base: base.toUpperCase(), quote: quote.toUpperCase(), path: filePath }) 48 | } 49 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "@backtest/framework", 3 | "version": "1.1.18", 4 | "description": "Backtesting trading strategies in TypeScript / JavaScript", 5 | "main": "dist/main", 6 | "typings": "dist/main", 7 | "keywords": [ 8 | "backtesting", 9 | "backtest", 10 | "finance", 11 | "trading", 12 | "candles", 13 | "indicators", 14 | "multi value", 15 | "multi symbol", 16 | "framework" 17 | ], 18 | "scripts": { 19 | "align-db": "npx prisma validate && npx prisma generate && npx prisma db push", 20 | "build:esm": "tsc --target es2018 --outDir esm", 21 | "build:cjs": "tsc --target es2015 --module commonjs --outDir dist", 22 | "prebuild": "rm -rf dist esm", 23 | "build": "npm run build:esm && npm run build:cjs", 24 | "prestart": "npm run build", 25 | "start": "cd dist && node --env-file=../.env main.js", 26 | "dev": "node -r ts-node/register --env-file=.env main.ts", 27 | "predemo-js": "npm run build", 28 | "demo-js": "node --env-file=.env dist/src/demo.js", 29 | "demo": "node -r ts-node/register --env-file=.env src/demo.ts" 30 | }, 31 | "files": [ 32 | "dist", 33 | "esm", 34 | "prisma/schema.prisma" 35 | ], 36 | "pkg": { 37 | "assets": [ 38 | "node_modules/**/*" 39 | ] 40 | }, 41 | "author": "Backtet JS (https://backtestjs.github.io/framework)", 42 | "license": "Apache-2.0", 43 | "dependencies": { 44 | "@prisma/client": "^6.7.0", 45 | "axios": "^1.9.0", 46 | "csvtojson": "^2.0.10", 47 | "glob": "^11.0.2", 48 | "technicalindicators": "^3.1.0" 49 | }, 50 | "devDependencies": { 51 | "@types/node": "^22.15.17", 52 | "prisma": "^6.7.0", 53 | "ts-node": "^10.9.2", 54 | "typescript": "^5.8.3" 55 | }, 56 | "engines": { 57 | "node": ">=20.0.0" 58 | }, 59 | "homepage": "backtestjs.github.io/framework", 60 | "bugs": { 61 | "url": "https://github.com/backtestjs/framework/issues" 62 | }, 63 | "repository": { 64 | "type": "git", 65 | "url": "git+https://github.com/backtestjs/framework.git" 66 | } 67 | } 68 | -------------------------------------------------------------------------------- /main.ts: -------------------------------------------------------------------------------- 1 | export { 2 | getCandles, 3 | findHistoricalDataNames, 4 | findHistoricalDataSets, 5 | findHistoricalData 6 | } from './src/core/historical-data/find' 7 | 8 | export { downloadHistoricalData, getCandleStartDate } from './src/core/historical-data/download' 9 | export { deleteHistoricalData } from './src/core/historical-data/remove' 10 | export { importFileCSV } from './src/core/historical-data/import-csv' 11 | export { exportFileCSV } from './src/core/historical-data/export-csv' 12 | 13 | export { findResultNames, findResults, getResult } from './src/core/results/find' 14 | export { deleteResult } from './src/core/results/remove' 15 | export { saveResult } from './src/core/results/save' 16 | 17 | export { findMultiResultNames, findMultiResults, getMultiResult } from './src/core/results-multi/find' 18 | export { deleteMultiResult } from './src/core/results-multi/remove' 19 | export { saveMultiResult } from './src/core/results-multi/save' 20 | 21 | export { findStrategyNames, findStrategies, findStrategy } from './src/core/strategies/find' 22 | export { runStrategy } from './src/core/strategies/run' 23 | export { scanStrategies } from './src/core/strategies/scan' 24 | export { getIntervals, isValidInterval } from './src/core/common' 25 | 26 | export { BacktestError, ErrorCode } from './src/helpers/error' 27 | export { parseRunResultsStats } from './src/helpers/parse' 28 | 29 | export { 30 | RunStrategy, 31 | BuySell, 32 | BuySellReal, 33 | GetCandles, 34 | Candle, 35 | MetaCandle, 36 | BTH, 37 | OrderBook, 38 | ImportCSV, 39 | StrategyResult, 40 | GetStrategyResult, 41 | StrategyResultMulti, 42 | Order, 43 | Worth, 44 | RunMetaData, 45 | StrategyMeta, 46 | LooseObject 47 | } from './src/helpers/interfaces' 48 | 49 | const fs = require('fs') 50 | const path = require('path') 51 | import * as logger from './src/helpers/logger' 52 | 53 | export function printInfo() { 54 | const packageJsonPath = path.join(__dirname, 'package.json') 55 | const packageJson = JSON.parse(fs.readFileSync(packageJsonPath, 'utf8')) 56 | 57 | logger.info('Package: ' + packageJson?.name) 58 | logger.info('Version: ' + packageJson?.version) 59 | logger.info('Description: ' + packageJson?.description) 60 | logger.info('env.DATABASE_URL: ' + process.env.DATABASE_URL) 61 | logger.info('Database Url: ' + (process.env.DATABASE_URL || 'file:./db/backtest.db')) 62 | } 63 | -------------------------------------------------------------------------------- /src/helpers/api.ts: -------------------------------------------------------------------------------- 1 | import { GetCandles } from '../../types/global' 2 | import axios from 'axios' 3 | import { BacktestError, ErrorCode } from './error' 4 | import * as logger from './logger' 5 | 6 | // API Definitions 7 | const binanceUrl = 'http://api.binance.com' 8 | const versionAPI = 'v3' 9 | 10 | const endpointExchangeInfo = 'exchangeInfo' 11 | const endpointCandles = 'klines' 12 | 13 | async function _callBinanceAPI(endpoint: string, query: string, symbol: string): Promise { 14 | const url = `${binanceUrl}/api/${versionAPI}/${endpoint}?${query}` 15 | logger.trace(`Binance URL: ${url}`) 16 | 17 | try { 18 | // Call Binance API 19 | const results = await axios.get(url) 20 | return results.data 21 | } catch (error) { 22 | // Return error if it happens 23 | if (error?.response?.data?.code === -1121) { 24 | throw new BacktestError(`Symbol ${symbol} not found on Binance`, ErrorCode.ExternalAPI) 25 | } 26 | throw new BacktestError(`Problem accessing Binance with error ${error.toString() || error}`, ErrorCode.ExternalAPI) 27 | } 28 | } 29 | 30 | export async function getCandleStartDate(symbol: string): Promise { 31 | // Get lowest candles 32 | const candleStart = await getCandles({ symbol, interval: '1m', limit: 1, startTime: 0 }) 33 | 34 | // Return lowest candle closeTime 35 | return candleStart[0][0] 36 | } 37 | 38 | export async function getBaseQuote(symbol: string): Promise<{ base: any; quote: any }> { 39 | // Define symbol 40 | let query = `symbol=${symbol}` 41 | 42 | // Call Binance with symbol 43 | const baseQuote = await _callBinanceAPI(endpointExchangeInfo, query, symbol) 44 | 45 | // Parse and return base and quote 46 | return { base: baseQuote.symbols[0].baseAsset, quote: baseQuote.symbols[0].quoteAsset } 47 | } 48 | 49 | export async function getCandles(getCandlesParams: GetCandles): Promise { 50 | // Define the candle limit 51 | if (getCandlesParams.limit === undefined) getCandlesParams.limit = 1000 52 | 53 | // Define the query to get candles 54 | let query = `symbol=${getCandlesParams.symbol}&interval=${getCandlesParams.interval}&limit=${getCandlesParams.limit}` 55 | 56 | // Add start or end time if needed 57 | if (getCandlesParams.startTime !== undefined) query += `&startTime=${getCandlesParams.startTime}` 58 | if (getCandlesParams.endTime !== undefined) query += `&endTime=${getCandlesParams.endTime}` 59 | 60 | // Call and return the call to Binance 61 | return await _callBinanceAPI(endpointCandles, query, getCandlesParams.symbol) 62 | } 63 | -------------------------------------------------------------------------------- /docs/QUICK_START.md: -------------------------------------------------------------------------------- 1 | # Quick Start 2 | 3 | ## Installation 4 | 5 | To install the package in your project, use the following npm command: 6 | 7 | ```bash 8 | npm i @backtest/framework 9 | ``` 10 | 11 | ## Basic Strategy 12 | 13 | ```typescript 14 | // ./strategies/demo.ts 15 | import { BTH } from '@backtest/framework' 16 | 17 | export async function startCallback(historicalName: string) { 18 | console.log('called before runStrategy', historicalName) 19 | } 20 | 21 | export async function finishCallback(historicalName: string) { 22 | console.log('called after runStrategy', historicalName) 23 | } 24 | 25 | export async function runStrategy(bth: BTH) { 26 | const sma10 = await bth.getCandles('close', 10) 27 | const sma20 = await bth.getCandles('close', 20) 28 | 29 | if (sma10 > sma20) { 30 | await bth.buy() 31 | } else { 32 | await bth.sell() 33 | } 34 | } 35 | ``` 36 | 37 | ## How to use this package 38 | 39 | ```typescript 40 | import { 41 | parseRunResultsStats, 42 | findHistoricalData, 43 | findHistoricalDataNames, 44 | downloadHistoricalData, 45 | runStrategy, 46 | scanStrategies 47 | } from '@backtest/framework' 48 | 49 | async function main() { 50 | // historical data 51 | const startDate = new Date('2024-01-01').getTime() 52 | const endDate = new Date('2024-10-15').getTime() 53 | 54 | // analyzed period 55 | const startTime = new Date('2024-03-01').getTime() 56 | const endTime = new Date('2024-10-14').getTime() 57 | 58 | // check if already downloaded 59 | const found = await findHistoricalData('BTCEUR-8h') 60 | console.log('found:', found) 61 | 62 | if (!found) { 63 | // download data from public API binance 64 | const downloaded = await downloadHistoricalData('BTCEUR', { 65 | interval: '8h', 66 | startDate: startDate, 67 | endDate: endDate 68 | }) 69 | console.log('downloaded:', downloaded) 70 | } 71 | 72 | // check if now is present 73 | const allNames = await findHistoricalDataNames() 74 | console.log('allNames:', allNames) 75 | 76 | // scan strategies 77 | const scan = await scanStrategies() 78 | console.log('scan:', scan) 79 | 80 | // run strategy 81 | const runStrategyResult = await runStrategy({ 82 | strategyName: 'demo', 83 | historicalData: ['BTCEUR-8h'], 84 | params: {}, 85 | startingAmount: 1000, 86 | startTime: startTime, 87 | endTime: endTime 88 | }) 89 | console.log('runStrategyResult:', runStrategyResult.name) 90 | 91 | const parsed = await parseRunResultsStats(runStrategyResult) 92 | console.log('parsed:', parsed?.totals[0], parsed?.totals[1]) // just to show somethings (probably, you need to look parsed or strategyResult) 93 | } 94 | 95 | main() 96 | ``` 97 | -------------------------------------------------------------------------------- /src/core/strategies/scan.ts: -------------------------------------------------------------------------------- 1 | import { insertStrategy, updateStrategy, deleteStrategy, getAllStrategies } from '../../helpers/prisma-strategies' 2 | import { StrategyMeta, ScanAction } from '../../helpers/interfaces' 3 | import { getStrategiesFrom } from '../../helpers/strategies' 4 | import * as logger from '../../helpers/logger' 5 | 6 | const path = require('path') 7 | 8 | export async function scanStrategies(rootPath?: string): Promise { 9 | // Get strategies 10 | let strategies: StrategyMeta[] = await getAllStrategies() 11 | if (!strategies?.length) { 12 | strategies = [] 13 | } 14 | 15 | const files = getStrategiesFrom(rootPath) 16 | 17 | if (!files?.length) { 18 | logger.info('No files found to scan') 19 | return [] as ScanAction[] 20 | } 21 | 22 | const fileStrategies = files.map((file) => path.basename(file, path.extname(file))) 23 | const doneActions: ScanAction[] = [] 24 | 25 | for (const [index, strategyName] of fileStrategies.entries()) { 26 | const registeredStrategy = strategies.find(({ name }) => name === strategyName) 27 | const strategy = await import(files[index]) 28 | const strategyProperties = strategy.properties || {} 29 | 30 | const meta = { 31 | name: strategyName, 32 | params: strategyProperties.params || registeredStrategy?.params || [], 33 | dynamicParams: strategyProperties.dynamicParams || registeredStrategy?.dynamicParams || false, 34 | creationTime: registeredStrategy?.creationTime || new Date().getTime(), 35 | lastRunTime: registeredStrategy?.lastRunTime || 0 36 | } 37 | 38 | if (!!registeredStrategy?.name) { 39 | const action: ScanAction = { strategyName, action: 'update' } 40 | try { 41 | logger.info(`Update strategy ${strategyName}`) 42 | await updateStrategy(meta) 43 | action.error = false 44 | } catch (error) { 45 | action.error = true 46 | action.message = (error as Error).message 47 | } 48 | doneActions.push(action) 49 | } else { 50 | const action: ScanAction = { strategyName, action: 'insert' } 51 | try { 52 | logger.info(`Insert strategy ${strategyName}`) 53 | await insertStrategy(meta) 54 | action.error = false 55 | } catch (error) { 56 | action.error = true 57 | action.message = (error as Error).message 58 | } 59 | doneActions.push(action) 60 | } 61 | } 62 | 63 | for (const { name: strategyName } of strategies) { 64 | if (!fileStrategies.includes(strategyName)) { 65 | const action: ScanAction = { strategyName, action: 'delete' } 66 | try { 67 | logger.info(`Delete strategy ${strategyName}`) 68 | await deleteStrategy(strategyName) 69 | action.error = false 70 | } catch (error) { 71 | action.error = true 72 | action.message = (error as Error).message 73 | } 74 | doneActions.push(action) 75 | } 76 | } 77 | 78 | return doneActions 79 | } 80 | -------------------------------------------------------------------------------- /src/core/historical-data/download.ts: -------------------------------------------------------------------------------- 1 | import { saveHistoricalData } from '../../helpers/historical-data' 2 | import { getAllCandleMetaData } from '../../helpers/prisma-historical-data' 3 | import { getCandleStartDate } from '../../helpers/api' 4 | import { isValidInterval } from '../common' 5 | 6 | import { MetaCandle } from '../../helpers/interfaces' 7 | import { BacktestError, ErrorCode } from '../../helpers/error' 8 | import * as logger from '../../helpers/logger' 9 | import { dateToString } from '../../helpers/parse' 10 | 11 | export async function downloadHistoricalData( 12 | symbol: string, 13 | data: { 14 | interval: string 15 | startDate: number | string | Date 16 | endDate: number | string | Date 17 | downloadIsMandatory?: boolean 18 | } 19 | ): Promise { 20 | if (!symbol) { 21 | throw new BacktestError('Symbol is required', ErrorCode.MissingInput) 22 | } 23 | 24 | if (!data.interval) { 25 | throw new BacktestError('Interval is required', ErrorCode.MissingInput) 26 | } 27 | 28 | // Get historical metadata 29 | const historicalDataSets: MetaCandle[] = await getAllCandleMetaData() 30 | 31 | let symbolStartDate = await getCandleStartDate(symbol) 32 | if (symbolStartDate.error) { 33 | // Try to load USDT symbol if symbol is not found 34 | symbolStartDate = await getCandleStartDate(`${symbol}USDT`) 35 | if (!symbolStartDate.error) symbol = `${symbol}USDT` 36 | } 37 | 38 | if (symbolStartDate.error) { 39 | throw new BacktestError(`Symbol ${symbol} does not exist`, ErrorCode.NotFound) 40 | } 41 | 42 | const symbolStart = symbolStartDate.data 43 | 44 | if (!isValidInterval(data.interval)) { 45 | throw new BacktestError(`Interval ${data.interval} does not exist`, ErrorCode.NotFound) 46 | } 47 | 48 | const isSymbolPresent = historicalDataSets.some((meta: MetaCandle) => meta.name === `${symbol}-${data.interval}`) 49 | 50 | if (isSymbolPresent) { 51 | const message = `Symbol ${symbol} with interval ${data.interval} already exists.` 52 | if (data.downloadIsMandatory) { 53 | throw new BacktestError(message, ErrorCode.Conflict) 54 | } else { 55 | logger.info(message) 56 | return false 57 | } 58 | } 59 | 60 | const now = new Date().getTime() 61 | const startTime = new Date(data.startDate || symbolStart).getTime() 62 | const endTime = new Date(data.endDate || now).getTime() 63 | 64 | if (startTime < symbolStart || startTime > now) { 65 | throw new BacktestError( 66 | `Start date must be between ${dateToString(symbolStart)} and ${dateToString(now)}`, 67 | ErrorCode.InvalidInput 68 | ) 69 | } 70 | 71 | if (endTime > now || endTime <= startTime) { 72 | throw new BacktestError( 73 | `End date must be between ${dateToString(startTime)} and ${dateToString(now)}`, 74 | ErrorCode.InvalidInput 75 | ) 76 | } 77 | 78 | const objectGetHistoricalData = { 79 | symbol: symbol, 80 | interval: data.interval, 81 | startTime: startTime, 82 | endTime: endTime 83 | } 84 | 85 | // Get candles 86 | return saveHistoricalData(objectGetHistoricalData) 87 | } 88 | 89 | export { getCandleStartDate } 90 | -------------------------------------------------------------------------------- /src/helpers/prisma-results-multi.ts: -------------------------------------------------------------------------------- 1 | import { StrategyResultMulti } from '../helpers/interfaces' 2 | import { PrismaClient } from '@prisma/client' 3 | import { BacktestError, ErrorCode } from './error' 4 | import * as logger from './logger' 5 | 6 | const prisma = new PrismaClient({ 7 | datasources: { 8 | db: { 9 | url: process.env.DATABASE_URL || 'file:./db/backtest.db' 10 | } 11 | } 12 | }) 13 | 14 | export async function insertMultiResult(result: StrategyResultMulti): Promise { 15 | try { 16 | await prisma.strategyResultMulti.create({ 17 | data: { 18 | ...result, 19 | name: result.name || `${result.strategyName}-${new Date().getTime()}`, 20 | symbols: JSON.stringify(result.symbols), 21 | params: JSON.stringify(result.params), 22 | multiResults: JSON.stringify(result.multiResults), 23 | startTime: BigInt(result.startTime), 24 | endTime: BigInt(result.endTime) 25 | } 26 | }) 27 | logger.debug(`Successfully inserted multi value result: ${result.name}`) 28 | return true 29 | } catch (error) { 30 | throw new BacktestError(`Problem inserting result with error: ${error}`, ErrorCode.Insert) 31 | } 32 | } 33 | 34 | export async function getAllMultiResults(): Promise { 35 | try { 36 | // Get all the strategies names 37 | const strategyResults = await prisma.strategyResultMulti.findMany({ 38 | select: { name: true } 39 | }) 40 | 41 | const results: StrategyResultMulti[] = await Promise.all( 42 | strategyResults.map(async (result) => await getMultiResult(result.name)) 43 | ) 44 | return results 45 | } catch (error) { 46 | throw new BacktestError(`Problem getting results with error: ${error}`, ErrorCode.Retrieve) 47 | } 48 | } 49 | 50 | export async function getAllMultiResultNames(): Promise { 51 | try { 52 | // Get all the strategies names 53 | const strategyResults = await prisma.strategyResultMulti.findMany({ 54 | select: { name: true } 55 | }) 56 | 57 | const names = strategyResults.map((result) => result.name) 58 | return names 59 | } catch (error) { 60 | throw new BacktestError(`Problem getting results with error: ${error}`, ErrorCode.Retrieve) 61 | } 62 | } 63 | 64 | export async function getMultiResult(name: string): Promise { 65 | try { 66 | const result = await prisma.strategyResultMulti.findUnique({ 67 | where: { name } 68 | }) 69 | 70 | if (!result) { 71 | throw new BacktestError(`Failed to find multi value result named ${name}`, ErrorCode.NotFound) 72 | } 73 | 74 | // Parse the JSON strings back into objects 75 | const parsedResult: StrategyResultMulti = { 76 | ...result, 77 | symbols: JSON.parse(result.symbols), 78 | params: JSON.parse(result.params), 79 | multiResults: JSON.parse(result.multiResults), 80 | startTime: Number(result.startTime), 81 | endTime: Number(result.endTime) 82 | } 83 | 84 | return parsedResult 85 | } catch (error) { 86 | throw new BacktestError(`Failed to get result with error ${error}`, ErrorCode.Retrieve) 87 | } 88 | } 89 | 90 | export async function deleteMultiResult(name: string): Promise { 91 | try { 92 | await prisma.strategyResultMulti.delete({ 93 | where: { name } 94 | }) 95 | 96 | // Return successfully deleted 97 | logger.debug(`Successfully deleted ${name}`) 98 | return true 99 | } catch (error) { 100 | throw new BacktestError(`Failed to delete StrategyResult with name: ${name}. Error: ${error}`, ErrorCode.Delete) 101 | } 102 | } 103 | -------------------------------------------------------------------------------- /src/helpers/prisma-strategies.ts: -------------------------------------------------------------------------------- 1 | import { StrategyMeta } from '../helpers/interfaces' 2 | import { PrismaClient } from '@prisma/client' 3 | import { BacktestError, ErrorCode } from './error' 4 | import * as logger from './logger' 5 | 6 | const prisma = new PrismaClient({ 7 | datasources: { 8 | db: { 9 | url: process.env.DATABASE_URL || 'file:./db/backtest.db' 10 | } 11 | } 12 | }) 13 | 14 | export async function insertStrategy(strategy: StrategyMeta): Promise { 15 | try { 16 | // Insert a strategy 17 | await prisma.strategy.create({ 18 | data: { 19 | ...strategy, 20 | params: JSON.stringify(strategy.params), 21 | creationTime: BigInt(strategy.creationTime), 22 | lastRunTime: BigInt(strategy.lastRunTime) 23 | } 24 | }) 25 | logger.debug(`Successfully inserted strategy: ${strategy.name}`) 26 | return true 27 | } catch (error) { 28 | throw new BacktestError(`Problem inserting strategy with error: ${error}`, ErrorCode.Insert) 29 | } 30 | } 31 | 32 | export async function getAllStrategies(): Promise { 33 | try { 34 | // Get all the strategies 35 | const strategies = await prisma.strategy.findMany() 36 | const strategyMetas = strategies.map((strategy: any) => ({ 37 | ...strategy, 38 | params: JSON.parse(strategy.params), 39 | creationTime: Number(strategy.creationTime), 40 | lastRunTime: Number(strategy.lastRunTime) 41 | })) 42 | return strategyMetas 43 | } catch (error) { 44 | throw new BacktestError(`Problem getting all strategies with error: ${error}`, ErrorCode.Retrieve) 45 | } 46 | } 47 | 48 | export async function getStrategy(name: string): Promise { 49 | try { 50 | // Get a specific strategy 51 | const strategy = await prisma.strategy.findUnique({ where: { name } }) 52 | if (!strategy) { 53 | throw new BacktestError(`Strategy with name: ${name} not found`, ErrorCode.NotFound) 54 | } 55 | const strategyMeta = { 56 | ...strategy, 57 | params: JSON.parse(strategy.params), 58 | creationTime: Number(strategy.creationTime), 59 | lastRunTime: Number(strategy.lastRunTime) 60 | } 61 | logger.debug(`Found strategy: ${name}`) 62 | return strategyMeta 63 | } catch (error) { 64 | throw new BacktestError(`Problem getting strategy with error: ${error}`, ErrorCode.Retrieve) 65 | } 66 | } 67 | 68 | export async function updateLastRunTime(name: string, lastRunTime: number): Promise { 69 | try { 70 | // Update the strategies last run time 71 | const strategy = await prisma.strategy.update({ 72 | where: { name }, 73 | data: { lastRunTime: BigInt(lastRunTime) } 74 | }) 75 | logger.debug(`Successfully updated lastRunTime for strategy: ${strategy.name}`) 76 | return true 77 | } catch (error) { 78 | throw new BacktestError(`Problem updating lastRunTime with error: ${error}`, ErrorCode.Update) 79 | } 80 | } 81 | 82 | export async function deleteStrategy(name: string): Promise { 83 | try { 84 | // Delete a strategy 85 | await prisma.strategy.delete({ where: { name } }) 86 | logger.debug(`Successfully deleted strategy: ${name}`) 87 | return true 88 | } catch (error) { 89 | throw new BacktestError(`Problem deleting strategy with error: ${error}`, ErrorCode.Delete) 90 | } 91 | } 92 | 93 | export async function updateStrategy(strategy: StrategyMeta): Promise { 94 | try { 95 | // Insert a strategy 96 | await prisma.strategy.update({ 97 | where: { name: strategy.name }, 98 | data: { 99 | ...strategy, 100 | params: JSON.stringify(strategy.params), 101 | creationTime: BigInt(strategy.creationTime), 102 | lastRunTime: BigInt(strategy.lastRunTime) 103 | } 104 | }) 105 | logger.debug(`Successfully updated strategy: ${strategy.name}`) 106 | return true 107 | } catch (error) { 108 | throw new BacktestError(`Problem updating strategy with error: ${error}`, ErrorCode.Update) 109 | } 110 | } 111 | -------------------------------------------------------------------------------- /src/helpers/historical-data.ts: -------------------------------------------------------------------------------- 1 | import { insertCandles, updateCandlesAndMetaCandle } from './prisma-historical-data' 2 | import { GetCandles, Candle, MetaCandle } from '../helpers/interfaces' 3 | import { dateToString, parseCandles, removeUnusedCandles } from './parse' 4 | import { getCandles, getBaseQuote } from './api' 5 | import * as logger from './logger' 6 | 7 | async function _getParseSaveCandlesPrivate(runParams: GetCandles, newData: boolean): Promise { 8 | // Define function globals 9 | let finishedCandles = false 10 | let allCandles: Candle[] = [] 11 | const metaName = `${runParams.symbol}-${runParams.interval}` 12 | 13 | async function saveCandlesNew(saveCandles: Candle[]): Promise { 14 | // Get the base and quote of the symbol 15 | const baseQuote = await getBaseQuote(runParams.symbol) 16 | 17 | // Create and add meta data 18 | const meta = { 19 | name: metaName, 20 | symbol: runParams.symbol, 21 | interval: runParams.interval, 22 | base: baseQuote.base, 23 | quote: baseQuote.quote, 24 | startTime: saveCandles[0].closeTime, 25 | endTime: saveCandles[saveCandles.length - 1].closeTime, 26 | importedFromCSV: false, 27 | creationTime: new Date().getTime(), 28 | lastUpdatedTime: new Date().getTime() 29 | } 30 | 31 | // Insert candles and metaData into the DB 32 | return await insertCandles(meta, saveCandles) 33 | } 34 | 35 | async function saveCandlesUpdate(saveCandles: Candle[]): Promise { 36 | // Update candles and metaData 37 | await updateCandlesAndMetaCandle(metaName, saveCandles) 38 | return true 39 | } 40 | 41 | while (!finishedCandles) { 42 | // Call Binance for candles 43 | let candleRequest = await getCandles({ 44 | symbol: runParams.symbol, 45 | interval: runParams.interval, 46 | endTime: runParams.endTime 47 | }) 48 | 49 | logger.trace( 50 | `Fetched ${candleRequest?.length} for ${runParams.symbol} ${runParams.interval} (${ 51 | runParams.endTime ? dateToString(runParams.endTime) : null 52 | })` 53 | ) 54 | 55 | // Update the new end time 56 | runParams.endTime = candleRequest[0][6] 57 | 58 | // Check if required candle data is present 59 | if ((runParams.endTime ?? 0) < (runParams.startTime ?? 0) || candleRequest.length <= 1) { 60 | if (!(candleRequest.length <= 1)) 61 | candleRequest = await removeUnusedCandles(candleRequest, runParams.startTime ?? 0) 62 | finishedCandles = true 63 | } 64 | 65 | // Parse candle data 66 | let candles = await parseCandles(runParams.symbol, runParams.interval, candleRequest) 67 | allCandles = [...candles, ...allCandles] 68 | 69 | // Save to DB if >= 50k entries then delete all candles in memory and continue to get more candles 70 | if (allCandles.length >= 50000) { 71 | // Save the candles 72 | const saveCandlesResult = newData ? await saveCandlesNew(allCandles) : await saveCandlesUpdate(allCandles) 73 | if (saveCandlesResult) { 74 | logger.info( 75 | `Partial: Saved ${allCandles.length} candles for ${runParams.symbol} on the ${runParams.interval} interval` 76 | ) 77 | } 78 | 79 | // Mark that this is not a first entry 80 | newData = false 81 | 82 | // Delete all candles 83 | allCandles = [] 84 | } 85 | } 86 | 87 | // Save candles if more than 0 entries 88 | if (allCandles.length > 0) { 89 | const saveCandlesResult = newData ? await saveCandlesNew(allCandles) : await saveCandlesUpdate(allCandles) 90 | if (saveCandlesResult) { 91 | logger.info( 92 | `Partial: Saved ${allCandles.length} candles for ${runParams.symbol} on the ${runParams.interval} interval` 93 | ) 94 | } 95 | } 96 | 97 | // Return the candles 98 | return allCandles 99 | } 100 | 101 | export async function saveHistoricalData(runParams: GetCandles) { 102 | // Get, parse and save all needed candles 103 | const allCandlesResults = await _getParseSaveCandlesPrivate(runParams, true) 104 | if (allCandlesResults) { 105 | logger.info( 106 | `Saved ${allCandlesResults.length} candles for ${runParams.symbol} on the ${runParams.interval} interval` 107 | ) 108 | } 109 | 110 | // Return success message 111 | logger.info(`Successfully downloaded ${runParams.symbol} on the ${runParams.interval} interval`) 112 | return true 113 | } 114 | -------------------------------------------------------------------------------- /prisma/schema.prisma: -------------------------------------------------------------------------------- 1 | // This is your Prisma schema file, 2 | // learn more about it in the docs: https://pris.ly/d/prisma-schema 3 | 4 | generator client { 5 | provider = "prisma-client-js" 6 | } 7 | 8 | datasource db { 9 | provider = "sqlite" 10 | url = env("DATABASE_URL") 11 | } 12 | 13 | model MetaCandle { 14 | id Int @id @default(autoincrement()) 15 | name String @unique 16 | symbol String 17 | interval String 18 | base String 19 | quote String 20 | startTime BigInt 21 | endTime BigInt 22 | importedFromCSV Boolean 23 | creationTime BigInt 24 | lastUpdatedTime BigInt 25 | candles Candle[] 26 | } 27 | 28 | model Candle { 29 | id Int @id @default(autoincrement()) 30 | openTime BigInt 31 | open Float 32 | high Float 33 | low Float 34 | close Float 35 | volume Float 36 | closeTime BigInt 37 | assetVolume Float 38 | numberOfTrades Int 39 | metaCandleId Int 40 | metaCandle MetaCandle @relation(fields: [metaCandleId], references: [id]) 41 | } 42 | 43 | model Strategy { 44 | id Int @id @default(autoincrement()) 45 | name String @unique 46 | params String 47 | dynamicParams Boolean 48 | creationTime BigInt 49 | lastRunTime BigInt 50 | } 51 | 52 | model StrategyResult { 53 | id Int @id @default(autoincrement()) 54 | name String @unique 55 | historicalDataName String 56 | strategyName String 57 | params String 58 | startTime BigInt 59 | endTime BigInt 60 | txFee Int 61 | slippage Int 62 | startingAmount Float 63 | allOrders Order[] 64 | allWorths Worth[] 65 | runMetaData RunMetaData? 66 | runMetaDataId Int? @unique 67 | } 68 | 69 | model StrategyResultMulti { 70 | id Int @id @default(autoincrement()) 71 | name String @unique 72 | strategyName String 73 | symbols String 74 | permutationCount Int 75 | params String 76 | startTime BigInt 77 | endTime BigInt 78 | txFee Int 79 | slippage Int 80 | startingAmount Float 81 | multiResults String 82 | isMultiValue Boolean 83 | isMultiSymbol Boolean 84 | } 85 | 86 | model RunMetaData { 87 | id Int @id @default(autoincrement()) 88 | highestAmount Float 89 | highestAmountDate BigInt 90 | lowestAmount Float 91 | lowestAmountDate BigInt 92 | maxDrawdownAmount Float 93 | maxDrawdownAmountDates String 94 | maxDrawdownPercent Float 95 | maxDrawdownPercentDates String 96 | startingAssetAmount Float 97 | startingAssetAmountDate BigInt 98 | endingAssetAmount Float 99 | endingAssetAmountDate BigInt 100 | highestAssetAmount Float 101 | highestAssetAmountDate BigInt 102 | lowestAssetAmount Float 103 | lowestAssetAmountDate BigInt 104 | numberOfCandles Int 105 | numberOfCandlesInvested Int 106 | sharpeRatio Int 107 | StrategyResult StrategyResult @relation(fields: [StrategyResultId], references: [id]) 108 | StrategyResultId Int @unique 109 | } 110 | 111 | model Order { 112 | id Int @id @default(autoincrement()) 113 | type String 114 | position String 115 | note String 116 | price Float 117 | amount Float 118 | worth Float 119 | quoteAmount Float 120 | baseAmount Float 121 | borrowedBaseAmount Float 122 | profitAmount Float 123 | profitPercent Float 124 | time BigInt 125 | StrategyResultId Int 126 | StrategyResult StrategyResult @relation(fields: [StrategyResultId], references: [id], onDelete: Cascade) 127 | } 128 | 129 | model Worth { 130 | id Int @id @default(autoincrement()) 131 | close Float 132 | high Float 133 | low Float 134 | open Float 135 | time BigInt 136 | StrategyResultId Int 137 | StrategyResult StrategyResult @relation(fields: [StrategyResultId], references: [id], onDelete: Cascade) 138 | } 139 | -------------------------------------------------------------------------------- /src/helpers/interfaces.ts: -------------------------------------------------------------------------------- 1 | export interface RunStrategy { 2 | strategyName: string 3 | historicalData: string[] 4 | supportHistoricalData?: string[] 5 | startingAmount: number 6 | startTime: number 7 | endTime: number 8 | params: LooseObject 9 | percentFee?: number 10 | percentSlippage?: number 11 | rootPath?: string 12 | alwaysFreshLoad?: boolean 13 | } 14 | 15 | export interface BuySell { 16 | price?: number 17 | position?: string 18 | amount?: number | string 19 | baseAmount?: number 20 | stopLoss?: number 21 | takeProfit?: number 22 | percentFee?: number 23 | percentSlippage?: number 24 | note?: string 25 | } 26 | 27 | export interface BuySellReal { 28 | currentClose: number 29 | price?: number 30 | position?: string 31 | amount?: number | string 32 | baseAmount?: number 33 | percentFee?: number 34 | percentSlippage?: number 35 | date: number 36 | note?: string 37 | } 38 | 39 | export interface GetCandles { 40 | symbol: string 41 | interval: string 42 | startTime?: number 43 | endTime?: number 44 | limit?: number 45 | } 46 | 47 | export interface Candle { 48 | symbol: string 49 | interval: string 50 | openTime: number 51 | open: number 52 | high: number 53 | low: number 54 | close: number 55 | volume: number 56 | closeTime: number 57 | assetVolume: number 58 | numberOfTrades: number 59 | } 60 | 61 | export interface MetaCandle { 62 | name: string 63 | symbol: string 64 | interval: string 65 | base: string 66 | quote: string 67 | startTime: number 68 | endTime: number 69 | importedFromCSV: boolean 70 | creationTime: number 71 | lastUpdatedTime: number 72 | } 73 | 74 | export interface BTH { 75 | tradingInterval: string 76 | tradingCandle: boolean 77 | currentCandle: Candle 78 | params: LooseObject 79 | orderBook: OrderBook 80 | allOrders: Order[] 81 | buy: Function 82 | sell: Function 83 | getCandles: Function 84 | } 85 | 86 | export interface OrderBook { 87 | bought: boolean 88 | boughtLong: boolean 89 | boughtShort: boolean 90 | baseAmount: number 91 | quoteAmount: number 92 | borrowedBaseAmount: number 93 | limitAmount: number 94 | preBoughtQuoteAmount: number 95 | stopLoss: number | string 96 | takeProfit: number | string 97 | } 98 | 99 | export interface ImportCSV { 100 | interval: string 101 | base: string 102 | quote: string 103 | path: string 104 | } 105 | 106 | export interface StrategyResult { 107 | name: string 108 | historicalDataName: string 109 | strategyName: string 110 | params: LooseObject 111 | startTime: number 112 | endTime: number 113 | startingAmount: number 114 | txFee: number 115 | slippage: number 116 | runMetaData: RunMetaData 117 | allOrders: Order[] 118 | allWorths: Worth[] 119 | } 120 | 121 | export interface GetStrategyResult extends StrategyResult { 122 | candleMetaData: MetaCandle 123 | candles: Candle[] 124 | } 125 | 126 | export interface StrategyResultMulti { 127 | name: string 128 | symbols: string[] 129 | permutationCount: number 130 | strategyName: string 131 | params: LooseObject 132 | startTime: number 133 | endTime: number 134 | startingAmount: number 135 | txFee: number 136 | slippage: number 137 | multiResults: LooseObject[] 138 | isMultiValue: boolean 139 | isMultiSymbol: boolean 140 | } 141 | 142 | export interface Order { 143 | type: string 144 | position: string 145 | price: number 146 | amount: number 147 | worth: number 148 | quoteAmount: number 149 | baseAmount: number 150 | borrowedBaseAmount: number 151 | profitAmount: number 152 | profitPercent: number 153 | time: number 154 | note?: string 155 | } 156 | 157 | export interface Worth { 158 | close: number 159 | high: number 160 | low: number 161 | open: number 162 | time: number 163 | } 164 | 165 | export interface RunMetaData { 166 | highestAmount: number 167 | highestAmountDate: number 168 | lowestAmount: number 169 | lowestAmountDate: number 170 | maxDrawdownAmount: number 171 | maxDrawdownAmountDates: string 172 | maxDrawdownPercent: number 173 | maxDrawdownPercentDates: string 174 | startingAssetAmount: number 175 | startingAssetAmountDate: number 176 | endingAssetAmount: number 177 | endingAssetAmountDate: number 178 | highestAssetAmount: number 179 | highestAssetAmountDate: number 180 | lowestAssetAmount: number 181 | lowestAssetAmountDate: number 182 | numberOfCandles: number 183 | numberOfCandlesInvested: number 184 | sharpeRatio: number 185 | id?: number 186 | strategyResultId?: number 187 | } 188 | 189 | export interface StrategyMeta { 190 | name: string 191 | params: string[] 192 | dynamicParams: boolean 193 | creationTime: number 194 | lastRunTime: number 195 | } 196 | 197 | export interface LooseObject { 198 | [key: string]: any 199 | } 200 | 201 | export interface ScanAction { 202 | strategyName: string 203 | action: string 204 | error?: boolean 205 | message?: string 206 | } 207 | 208 | export interface AssetAmounts { 209 | startingAssetAmount: number 210 | endingAssetAmount: number 211 | highestAssetAmount: number 212 | highestAssetAmountDate: number 213 | lowestAssetAmount: number 214 | lowestAssetAmountDate: number 215 | numberOfCandles: number 216 | } 217 | 218 | export interface RunStrategyResultMulti { 219 | [key: string]: any 220 | symbol: string 221 | interval: string 222 | endAmount: number 223 | maxDrawdownAmount: number 224 | maxDrawdownPercent: number 225 | numberOfCandlesInvested: number 226 | sharpeRatio: number 227 | assetAmounts: AssetAmounts 228 | } 229 | 230 | export interface RunStrategyResult { 231 | runMetaData: RunMetaData 232 | allOrders: Order[] 233 | allWorths: Worth[] 234 | allCandles: Candle[] 235 | } 236 | -------------------------------------------------------------------------------- /src/helpers/csv.ts: -------------------------------------------------------------------------------- 1 | import { dateToString } from '../helpers/parse' 2 | import { LooseObject, ImportCSV, Candle } from '../helpers/interfaces' 3 | import { insertCandles, getCandles } from './prisma-historical-data' 4 | import { BacktestError, ErrorCode } from './error' 5 | import * as logger from './logger' 6 | import csvToJson from 'csvtojson' 7 | import * as path from 'path' 8 | import * as fs from 'fs' 9 | 10 | function _getNormalizedField(json: LooseObject, possibleFields: string[]): string | null { 11 | const normalizedFields: { [key: string]: string } = Object.keys(json).reduce( 12 | (acc: { [key: string]: string }, key) => { 13 | acc[key.toLowerCase()] = key 14 | return acc 15 | }, 16 | {} 17 | ) 18 | 19 | for (const field of possibleFields) { 20 | if (normalizedFields[field.toLowerCase()]) { 21 | return normalizedFields[field.toLowerCase()] 22 | } 23 | } 24 | return null 25 | } 26 | 27 | function _getFieldKeys(json: LooseObject, fields: { [key: string]: string[] }): { [key: string]: string } { 28 | const fieldKeys: { [key: string]: string } = {} 29 | for (const [key, possibleFields] of Object.entries(fields)) { 30 | const fieldKey = _getNormalizedField(json, possibleFields) 31 | if (fieldKey) { 32 | fieldKeys[key] = fieldKey 33 | } else { 34 | throw new BacktestError(`CSV does not have a valid ${possibleFields.join(', ')} field`, ErrorCode.InvalidInput) 35 | } 36 | } 37 | return fieldKeys 38 | } 39 | 40 | function _getOptionalFieldKeys(json: LooseObject, fields: { [key: string]: string[] }): { [key: string]: string } { 41 | const optionalFields: { [key: string]: string } = {} 42 | for (const [key, possibleFields] of Object.entries(fields)) { 43 | const fieldKey = _getNormalizedField(json, possibleFields) 44 | if (fieldKey) { 45 | optionalFields[key] = fieldKey 46 | } 47 | } 48 | return optionalFields 49 | } 50 | 51 | export async function importCSV(importCSVParams: ImportCSV) { 52 | let jsonCSV: LooseObject 53 | try { 54 | jsonCSV = await csvToJson().fromFile(importCSVParams.path) 55 | } catch (error) { 56 | throw new BacktestError(`Path ${importCSVParams.path} does not exist or is incorrect`, ErrorCode.InvalidPath) 57 | } 58 | 59 | const json = jsonCSV[0] 60 | 61 | const requiredFields = { 62 | closeTime: ['closeTime', 'date'], 63 | open: ['open'], 64 | close: ['close'], 65 | low: ['low'], 66 | high: ['high'] 67 | } 68 | 69 | const optionalFields = { 70 | openTime: ['openTime'], 71 | volume: ['volume'], 72 | assetVolume: ['assetVolume'], 73 | numberOfTrades: ['numberOfTrades'] 74 | } 75 | 76 | try { 77 | const fieldKeys = _getFieldKeys(json, requiredFields) 78 | const optionalFileds = _getOptionalFieldKeys(json, optionalFields) 79 | 80 | // Parse JSON for DB 81 | const jsonParsedCandles: Candle[] = jsonCSV.map((entry: LooseObject) => ({ 82 | openTime: optionalFileds.openTime ? new Date(+entry[optionalFileds.openTime]).getTime() : 0, 83 | open: +entry[fieldKeys.open], 84 | high: +entry[fieldKeys.high], 85 | low: +entry[fieldKeys.low], 86 | close: +entry[fieldKeys.close], 87 | volume: optionalFileds.volume ? +entry[optionalFileds.volume] : 0, 88 | closeTime: new Date(+entry[fieldKeys.closeTime]).getTime(), 89 | assetVolume: optionalFileds.assetVolume ? +entry[optionalFileds.assetVolume] : 0, 90 | numberOfTrades: optionalFileds.numberOfTrades ? +entry[optionalFileds.numberOfTrades] : 0 91 | })) 92 | 93 | // Create and add meta data 94 | const meta = { 95 | name: `${importCSVParams.base + importCSVParams.quote}-${importCSVParams.interval}`, 96 | symbol: importCSVParams.base + importCSVParams.quote, 97 | interval: importCSVParams.interval, 98 | base: importCSVParams.base, 99 | quote: importCSVParams.quote, 100 | startTime: jsonParsedCandles[0].closeTime, 101 | endTime: jsonParsedCandles[jsonParsedCandles.length - 1].closeTime, 102 | importedFromCSV: true, 103 | creationTime: new Date().getTime(), 104 | lastUpdatedTime: new Date().getTime() 105 | } 106 | 107 | // Insert candles into the DB 108 | const insertedCandles = await insertCandles(meta, jsonParsedCandles) 109 | 110 | // Return success 111 | logger.info( 112 | `Successfully imported ${importCSVParams.base + importCSVParams.quote} from ${dateToString( 113 | meta.startTime 114 | )} to ${dateToString(meta.endTime)}` 115 | ) 116 | return true 117 | } catch (error: any) { 118 | throw new BacktestError(error?.message || 'Generic error !?', ErrorCode.ActionFailed) 119 | } 120 | } 121 | 122 | export async function exportCSV(name: string, rootPath: string = './csv') { 123 | // Get candles 124 | const candles = await getCandles(name) 125 | if (!candles) { 126 | throw new BacktestError(`No candles found for name ${name}`, ErrorCode.NotFound) 127 | } 128 | 129 | // Get candles keys for the header row 130 | const keys = Object.keys(candles.candles[0]) 131 | 132 | // Create the header row 133 | const headerRow = keys.join(',') + '\n' 134 | 135 | // Create the data rows 136 | const dataRows = candles.candles 137 | .map((obj: LooseObject) => { 138 | const values = keys.map((key) => { 139 | const value = obj[key] 140 | return typeof value === 'string' ? `"${value}"` : value 141 | }) 142 | return values.join(',') 143 | }) 144 | .join('\n') 145 | 146 | // Check if the directory exists, and create it if it doesn't 147 | const dir = rootPath || './csv' 148 | if (!fs.existsSync(dir)) { 149 | fs.mkdirSync(dir) 150 | } 151 | 152 | // Write the file to csv folder 153 | const filePath = path.join(dir, `${name}.csv`) 154 | fs.writeFileSync(filePath, headerRow + dataRows) 155 | 156 | // Return success 157 | logger.info(`Successfully exported data to ./csv folder with name ${name}.csv`) 158 | return true 159 | } 160 | -------------------------------------------------------------------------------- /src/demo.ts: -------------------------------------------------------------------------------- 1 | import { 2 | parseRunResultsStats, 3 | findHistoricalData, 4 | findHistoricalDataSets, 5 | findHistoricalDataNames, 6 | downloadHistoricalData, 7 | importFileCSV, 8 | exportFileCSV, 9 | deleteHistoricalData, 10 | findResultNames, 11 | findResults, 12 | deleteResult, 13 | saveResult, 14 | findMultiResultNames, 15 | findMultiResults, 16 | deleteMultiResult, 17 | saveMultiResult, 18 | findStrategyNames, 19 | findStrategies, 20 | runStrategy, 21 | scanStrategies, 22 | printInfo 23 | } from '../main' 24 | 25 | import { StrategyResult, StrategyResultMulti } from './helpers/interfaces' 26 | import { BacktestError, ErrorCode } from './helpers/error' 27 | 28 | async function main() { 29 | printInfo() 30 | 31 | // historical data 32 | const startDate = new Date('2024-01-01').getTime() 33 | const endDate = new Date('2024-10-15').getTime() 34 | 35 | // analyzed period 36 | const startTime = new Date('2024-03-01').getTime() 37 | const endTime = new Date('2024-10-14').getTime() 38 | 39 | const found = await findHistoricalData('BTCEUR-8h') 40 | console.log('found:', found) 41 | 42 | if (found) { 43 | const deleted = await deleteHistoricalData('BTCEUR-8h') 44 | console.log('deleted:', deleted) 45 | } 46 | 47 | const downloaded2 = await downloadHistoricalData('BTCEUR', { 48 | interval: '1d', 49 | startDate: startDate, 50 | endDate: endDate 51 | }) 52 | console.log('downloaded2:', downloaded2) 53 | 54 | const downloaded1 = await downloadHistoricalData('BTCEUR', { 55 | interval: '1h', 56 | startDate: startDate, 57 | endDate: endDate 58 | }) 59 | console.log('downloaded1:', downloaded1) 60 | 61 | const downloaded = await downloadHistoricalData('BTCEUR', { 62 | interval: '8h', 63 | startDate: startDate, 64 | endDate: endDate 65 | }) 66 | console.log('downloaded:', downloaded) 67 | 68 | const exported = await exportFileCSV('BTCEUR-8h') 69 | console.log('exported:', exported) 70 | 71 | const allNames = await findHistoricalDataNames() 72 | console.log('allNames:', allNames) 73 | 74 | const allSets = await findHistoricalDataSets() 75 | console.log('allSets:', allSets.map(({ name }) => name).join(',')) 76 | 77 | const dataSet = await findHistoricalData('BTCEUR-8h') 78 | console.log('dataSet:', dataSet) 79 | 80 | const dataSet1 = await findHistoricalData('BTCEUR-1h') 81 | console.log('dataSet1:', dataSet1) 82 | 83 | const deleted = await deleteHistoricalData('BTCEUR-8h') 84 | console.log('deleted:', deleted) 85 | 86 | const imported = await importFileCSV('BTC', 'EUR', '8h', './csv/BTCEUR-8h.csv') 87 | console.log('imported:', imported) 88 | 89 | const dataSet2 = await findHistoricalData('BTCEUR-8h') 90 | console.log('dataSet2:', dataSet2) 91 | 92 | const scan = await scanStrategies() 93 | console.log('scan:', scan) 94 | 95 | const strategies = await findStrategies() 96 | console.log('strategies:', strategies) 97 | 98 | const strategiesNames = await findStrategyNames() 99 | console.log('strategiesNames:', strategiesNames) 100 | 101 | const runStrategyResult = await runStrategy({ 102 | strategyName: 'demo', 103 | historicalData: ['BTCEUR-1d'], 104 | params: { 105 | lowSMA: 10, 106 | highSMA: 50 107 | }, 108 | startingAmount: 1000, 109 | startTime: startTime, 110 | endTime: endTime 111 | }) 112 | console.log('runStrategyResult:', runStrategyResult.name) 113 | 114 | const parsed = await parseRunResultsStats(runStrategyResult) 115 | console.log('parsed:', parsed?.totals[0], parsed?.totals[1]) // just to show somethings (probably, you need to look parsed or strategyResult) 116 | 117 | const saved = await saveResult('demo-results', runStrategyResult as StrategyResult, true) 118 | console.log('saved:', saved) 119 | 120 | const resultsNames = await findResultNames() 121 | console.log('resultsNames:', resultsNames) 122 | 123 | const allResults = await findResults() 124 | console.log('allResults:', allResults.length) 125 | 126 | const deletedResults = await deleteResult('demo-results') 127 | console.log('deletedResults:', deletedResults) 128 | 129 | const runMultiStrategyResult = await runStrategy({ 130 | strategyName: 'demo', 131 | historicalData: ['BTCEUR-8h', 'BTCEUR-1h'], 132 | params: {}, 133 | startingAmount: 1000, 134 | startTime: startTime, 135 | endTime: endTime, 136 | percentFee: 0, 137 | percentSlippage: 0 138 | }) 139 | console.log('runMultiStrategyResult:', runMultiStrategyResult.name) 140 | 141 | const parsedMulti = await parseRunResultsStats(runMultiStrategyResult) 142 | console.log('parsedMulti:', parsedMulti?.totals[0], parsedMulti?.totals[1]) // just to show somethings (probably, you need to look parsed or strategyResult) 143 | 144 | const savedMulti = await saveMultiResult('demo-multi-results', runMultiStrategyResult as StrategyResultMulti) 145 | console.log('savedMulti:', savedMulti) 146 | 147 | const multiResultsNames = await findMultiResultNames() 148 | console.log('multiResultsNames:', multiResultsNames) 149 | 150 | const allMultiResults = await findMultiResults() 151 | console.log('allMultiResults:', allMultiResults.length) 152 | 153 | const deletedMultiResult = await deleteMultiResult('demo-multi-results') 154 | console.log('deletedMultiResult:', deletedMultiResult) 155 | 156 | const multiResultsNames2 = await findMultiResultNames() 157 | console.log('multiResultsNames2:', multiResultsNames2) 158 | 159 | const runAdvancedStrategyResult = await runStrategy({ 160 | strategyName: 'demo', 161 | historicalData: ['BTCEUR-1d', 'BTCEUR-8h'], 162 | supportHistoricalData: ['BTCEUR-1h', 'BTCEUR-8h'], 163 | startingAmount: 1000, 164 | startTime: startTime, 165 | endTime: endTime, 166 | params: { 167 | lowSMA: 10, 168 | highSMA: 50 169 | }, 170 | percentFee: 0, 171 | percentSlippage: 0, 172 | rootPath: undefined 173 | }) 174 | console.log('runStrategyResult:', runStrategyResult.name) 175 | 176 | const parsedAdvanced = await parseRunResultsStats(runAdvancedStrategyResult) 177 | console.log('parsedAdvanced:', parsedAdvanced.totals[0], parsedAdvanced.totals[1]) // just to show somethings (probably, you need to look parsed or strategyResult) 178 | } 179 | 180 | main() 181 | -------------------------------------------------------------------------------- /src/core/strategies/run.ts: -------------------------------------------------------------------------------- 1 | import { 2 | LooseObject, 3 | RunStrategyResult, 4 | RunStrategyResultMulti, 5 | MetaCandle, 6 | StrategyMeta, 7 | RunStrategy, 8 | GetStrategyResult, 9 | StrategyResultMulti 10 | } from '../../helpers/interfaces' 11 | 12 | import { getAllStrategies, getStrategy, updateLastRunTime } from '../../helpers/prisma-strategies' 13 | import { getAllCandleMetaData, getCandleMetaData } from '../../helpers/prisma-historical-data' 14 | import { run } from '../../helpers/run-strategy' 15 | import { BacktestError, ErrorCode } from '../../helpers/error' 16 | import { dateToString } from '../../helpers/parse' 17 | 18 | export async function runStrategy(options: RunStrategy) { 19 | if (!options) { 20 | throw new BacktestError('No options specified', ErrorCode.MissingInput) 21 | } 22 | if (!options.strategyName) { 23 | throw new BacktestError('Strategy name must be specified', ErrorCode.MissingInput) 24 | } 25 | if (!options.historicalData?.length) { 26 | throw new BacktestError('Historical data names must be specified', ErrorCode.MissingInput) 27 | } 28 | 29 | const data = { 30 | percentFee: 0, 31 | percentSlippage: 0, 32 | ...options 33 | } 34 | 35 | data.startingAmount = data.startingAmount || 1000 36 | 37 | // Create run params 38 | const runParams: RunStrategy = { 39 | strategyName: options.strategyName, 40 | historicalData: [], 41 | supportHistoricalData: options.supportHistoricalData || [], 42 | startingAmount: 0, 43 | startTime: 0, 44 | endTime: 0, 45 | params: {}, 46 | percentFee: 0, 47 | percentSlippage: 0, 48 | rootPath: options.rootPath 49 | } 50 | 51 | // Get all strategies 52 | const strategyMetaDatas = await getAllStrategies() 53 | if (!strategyMetaDatas?.length) { 54 | throw new BacktestError('There are no saved strategies', ErrorCode.StrategyNotFound) 55 | } 56 | 57 | const strategyToRun: StrategyMeta | undefined = strategyMetaDatas.find( 58 | (strategy: StrategyMeta) => strategy.name == options.strategyName 59 | ) 60 | if (!strategyToRun) { 61 | throw new BacktestError(`Strategy ${options.strategyName} not found`, ErrorCode.StrategyNotFound) 62 | } 63 | 64 | // Get all historical metaData 65 | let historicalDataSets: MetaCandle[] = await getAllCandleMetaData() 66 | if (!historicalDataSets?.length) { 67 | throw new BacktestError('There are no saved historical data', ErrorCode.NotFound) 68 | } 69 | 70 | historicalDataSets = historicalDataSets.filter((data: MetaCandle) => options.historicalData.includes(data.name)) 71 | if (historicalDataSets.length !== options.historicalData.length) { 72 | throw new BacktestError('Some historical data sets are missing or duplicated', ErrorCode.NotFound) 73 | } 74 | 75 | const names: string[] = historicalDataSets.map((data: MetaCandle) => data.name) 76 | runParams.historicalData.push(...names) 77 | 78 | // Define if running with multiple symbols 79 | const isMultiSymbol = runParams.historicalData.length > 1 80 | 81 | // Get candle metaData 82 | const firstHistoricalData = await getCandleMetaData(runParams.historicalData[0]) 83 | if (!firstHistoricalData) { 84 | throw new BacktestError('Historical data not found', ErrorCode.NotFound) 85 | } 86 | 87 | // Get stragegy 88 | const metaDataStrategy = await getStrategy(runParams.strategyName) 89 | if (!metaDataStrategy) { 90 | throw new BacktestError('Strategy not found', ErrorCode.StrategyNotFound) 91 | } 92 | 93 | let paramsCache: LooseObject = {} 94 | for (const param of Object.keys(data.params)) { 95 | if (!metaDataStrategy.params.find((p: string) => param == p)) { 96 | throw new BacktestError( 97 | `Input param ${param} does not exist in the strategy's properties`, 98 | ErrorCode.InvalidInput 99 | ) 100 | } 101 | 102 | let value = data.params[param] 103 | if (value === undefined || value === '') value = 0 104 | paramsCache[param] = isNaN(+value) ? value : +value 105 | } 106 | runParams.params = paramsCache 107 | 108 | // Set start and end time 109 | runParams.startTime = new Date(data.startTime || firstHistoricalData.startTime).getTime() 110 | runParams.endTime = new Date(data.endTime || firstHistoricalData.endTime).getTime() 111 | 112 | // Check if date is valid for all historical data 113 | for (const data of historicalDataSets) { 114 | if (runParams.startTime < data.startTime || runParams.startTime > data.endTime) { 115 | throw new BacktestError( 116 | `Start date must be between ${dateToString(data.startTime)} and ${dateToString(data.endTime)}`, 117 | ErrorCode.InvalidInput 118 | ) 119 | } 120 | 121 | if (runParams.endTime > data.endTime || runParams.endTime <= runParams.startTime) { 122 | throw new BacktestError( 123 | `End date must be between ${dateToString(runParams.startTime)} and ${dateToString(data.endTime)}`, 124 | ErrorCode.InvalidInput 125 | ) 126 | } 127 | } 128 | 129 | runParams.startingAmount = +data.startingAmount 130 | runParams.percentFee = +data.percentFee 131 | runParams.percentSlippage = +data.percentSlippage 132 | 133 | // Run strategy 134 | const strageyResults: RunStrategyResult | RunStrategyResultMulti[] = await run(runParams) 135 | if (!strageyResults) { 136 | throw new BacktestError('Strategy results not found', ErrorCode.NotFound) 137 | } 138 | 139 | // Update last run time 140 | await updateLastRunTime(runParams.strategyName, new Date().getTime()) 141 | 142 | const isRunStrategyResult = !Array.isArray(strageyResults) && typeof strageyResults?.runMetaData === 'object' 143 | if (!isRunStrategyResult || isMultiSymbol) { 144 | const permutations = strageyResults as RunStrategyResultMulti[] 145 | return { 146 | name: `${runParams.strategyName}-${firstHistoricalData.symbol}-multi`, 147 | strategyName: runParams.strategyName, 148 | symbols: runParams.historicalData, 149 | permutationCount: permutations.length, 150 | params: paramsCache, 151 | startTime: runParams.startTime, 152 | endTime: runParams.endTime, 153 | txFee: runParams.percentFee, 154 | slippage: runParams.percentSlippage, 155 | startingAmount: runParams.startingAmount, 156 | multiResults: permutations, 157 | isMultiValue: permutations !== undefined, 158 | isMultiSymbol: isMultiSymbol 159 | } as StrategyResultMulti 160 | } 161 | 162 | if (!strageyResults.allOrders?.length) { 163 | throw new BacktestError( 164 | 'Strategy did not perform any trades over the given time period', 165 | ErrorCode.TradeNotProcessed 166 | ) 167 | } 168 | 169 | return { 170 | name: `${runParams.strategyName}-${firstHistoricalData.name}`, 171 | historicalDataName: firstHistoricalData.name, 172 | candleMetaData: firstHistoricalData, 173 | candles: strageyResults.allCandles, 174 | strategyName: runParams.strategyName, 175 | params: runParams.params, 176 | startTime: runParams.startTime, 177 | endTime: runParams.endTime, 178 | startingAmount: runParams.startingAmount, 179 | txFee: runParams.percentFee, 180 | slippage: runParams.percentSlippage, 181 | runMetaData: strageyResults.runMetaData, 182 | allOrders: strageyResults.allOrders, 183 | allWorths: strageyResults.allWorths 184 | } as GetStrategyResult 185 | } 186 | -------------------------------------------------------------------------------- /src/helpers/prisma-historical-data.ts: -------------------------------------------------------------------------------- 1 | import { Candle, MetaCandle } from '../helpers/interfaces' 2 | import { PrismaClient } from '@prisma/client' 3 | import { BacktestError, ErrorCode } from './error' 4 | import * as logger from './logger' 5 | 6 | const prisma = new PrismaClient({ 7 | datasources: { 8 | db: { 9 | url: process.env.DATABASE_URL || 'file:./db/backtest.db' 10 | } 11 | } 12 | }) 13 | 14 | export async function insertCandles(metaCandle: MetaCandle, candles: Candle[]): Promise { 15 | try { 16 | // Write metaCandle and candles to the DB 17 | await prisma.metaCandle.create({ 18 | data: { 19 | ...metaCandle, 20 | startTime: BigInt(metaCandle.startTime), 21 | endTime: BigInt(metaCandle.endTime), 22 | creationTime: BigInt(metaCandle.creationTime), 23 | lastUpdatedTime: BigInt(metaCandle.lastUpdatedTime), 24 | candles: { 25 | create: candles.map((candle: Candle) => { 26 | const { symbol, interval, ...c } = candle 27 | return { 28 | ...c, 29 | openTime: BigInt(candle.openTime), 30 | closeTime: BigInt(candle.closeTime) 31 | } 32 | }) 33 | } 34 | } 35 | }) 36 | } catch (error) { 37 | logger.error(`Problem inserting ${metaCandle.name} into the database`) 38 | logger.error(error) 39 | 40 | throw new BacktestError( 41 | `Problem inserting ${metaCandle.name} into the database with error ${error}`, 42 | ErrorCode.Insert 43 | ) 44 | } 45 | 46 | logger.debug(`Successfully inserted ${metaCandle.name}`) 47 | return true 48 | } 49 | 50 | export async function getAllCandleMetaData(): Promise { 51 | try { 52 | // Get all the candles metaData 53 | const metaCandles = await prisma.metaCandle.findMany() 54 | const metaCandlesNumber = metaCandles.map((metaCandle: any) => { 55 | const { id, ...rest } = metaCandle 56 | return { 57 | ...rest, 58 | startTime: Number(rest.startTime), 59 | endTime: Number(rest.endTime), 60 | creationTime: Number(rest.creationTime), 61 | lastUpdatedTime: Number(rest.lastUpdatedTime) 62 | } 63 | }) 64 | return metaCandlesNumber 65 | } catch (error) { 66 | logger.error(`Problem getting all the candle metaData from database`) 67 | logger.error(error) 68 | 69 | throw new BacktestError(`Problem getting all the candle metaData with error ${error}`, ErrorCode.Retrieve) 70 | } 71 | } 72 | 73 | export async function getCandleMetaData(name: string): Promise { 74 | try { 75 | // Get just the candle metaData without the candles 76 | const metaCandles = await prisma.metaCandle.findMany({ 77 | where: { 78 | name: name 79 | } 80 | }) 81 | 82 | if (!metaCandles?.length) { 83 | return null 84 | } 85 | 86 | const metaCandle = metaCandles[0] 87 | const { id, ...rest } = metaCandle 88 | return { 89 | ...rest, 90 | startTime: Number(rest.startTime), 91 | endTime: Number(rest.endTime), 92 | creationTime: Number(rest.creationTime), 93 | lastUpdatedTime: Number(rest.lastUpdatedTime) 94 | } as MetaCandle 95 | } catch (error) { 96 | logger.error(`Problem getting the ${name} metaData from the database`) 97 | logger.error(error) 98 | 99 | throw new BacktestError(`Problem getting the ${name} metaData with error ${error}`, ErrorCode.Retrieve) 100 | } 101 | } 102 | 103 | export async function getCandles(name: string): Promise<{ metaCandles: MetaCandle[]; candles: Candle[] } | null> { 104 | try { 105 | // Get candles and candle metaData 106 | const metaCandles = await prisma.metaCandle.findMany({ 107 | where: { 108 | name: name 109 | }, 110 | include: { 111 | candles: true 112 | } 113 | }) 114 | 115 | if (metaCandles.length === 0) { 116 | return null 117 | } 118 | 119 | let candles: Candle[] = [] 120 | let metaCandlesNumber: MetaCandle[] = [] 121 | for (let metaCandle of metaCandles) { 122 | const retrievedCandles = metaCandle.candles.map((candle) => { 123 | // Convert bigInts to numbers and remove ids 124 | const { id, metaCandleId, ...rest } = candle 125 | return { 126 | symbol: metaCandle.symbol, 127 | interval: metaCandle.interval, 128 | ...rest, 129 | openTime: Number(rest.openTime), 130 | closeTime: Number(rest.closeTime) 131 | } 132 | }) 133 | candles = candles.concat(retrievedCandles) 134 | 135 | const { id, ...restMetaCandle } = metaCandle 136 | metaCandlesNumber.push({ 137 | ...restMetaCandle, 138 | startTime: Number(restMetaCandle.startTime), 139 | endTime: Number(restMetaCandle.endTime), 140 | creationTime: Number(restMetaCandle.creationTime), 141 | lastUpdatedTime: Number(restMetaCandle.lastUpdatedTime) 142 | }) 143 | } 144 | 145 | // Sort candles by closeTime 146 | candles.sort((a, b) => a.closeTime - b.closeTime) 147 | 148 | return { metaCandles: metaCandlesNumber, candles } 149 | } catch (error) { 150 | logger.error(`Problem getting the ${name} candles from the database`) 151 | logger.error(error) 152 | 153 | throw new BacktestError(`Problem getting the ${name} candles with error ${error}`, ErrorCode.Retrieve) 154 | } 155 | } 156 | 157 | export async function updateCandlesAndMetaCandle(name: string, newCandles: Candle[]): Promise { 158 | try { 159 | // Get existing metaCandle from database 160 | const existingMetaCandle = await prisma.metaCandle.findUnique({ 161 | where: { 162 | name: name 163 | } 164 | }) 165 | 166 | if (!existingMetaCandle) { 167 | throw new BacktestError(`No existing MetaCandle found for ${name}`, ErrorCode.NotFound) 168 | } 169 | 170 | // Compare start and end times between results times and candle times 171 | const newStartTime = Math.min(Number(existingMetaCandle.startTime), Number(newCandles[0].closeTime)) 172 | const newEndTime = Math.max(Number(existingMetaCandle.endTime), Number(newCandles[newCandles.length - 1].closeTime)) 173 | 174 | const updateMetaCandle = prisma.metaCandle.update({ 175 | where: { 176 | id: existingMetaCandle.id 177 | }, 178 | data: { 179 | startTime: BigInt(newStartTime), 180 | endTime: BigInt(newEndTime), 181 | lastUpdatedTime: BigInt(Date.now()) 182 | } 183 | }) 184 | 185 | const createCandles = newCandles.map((candle) => { 186 | const { symbol, interval, ...c } = candle 187 | return prisma.candle.create({ 188 | data: { 189 | ...c, 190 | openTime: BigInt(candle.openTime), 191 | closeTime: BigInt(candle.closeTime), 192 | metaCandleId: existingMetaCandle.id 193 | } 194 | }) 195 | }) 196 | 197 | await prisma.$transaction([updateMetaCandle, ...createCandles]) 198 | 199 | logger.debug(`${newCandles.length} candles updated successfully for ${name}`) 200 | return true 201 | } catch (error) { 202 | logger.error(`Problem updating ${name} into the database`) 203 | logger.error(error) 204 | 205 | throw new BacktestError(`Problem updating ${name} candles with error ${error}`, ErrorCode.Update) 206 | } 207 | } 208 | 209 | export async function deleteCandles(name: string): Promise { 210 | try { 211 | // Get the MetaCandle ID 212 | const metaCandle = await prisma.metaCandle.findUnique({ 213 | where: { 214 | name: name 215 | }, 216 | select: { 217 | id: true 218 | } 219 | }) 220 | 221 | if (!metaCandle) { 222 | throw new BacktestError(`MetaCandle and Candles for ${name} dont exist`, ErrorCode.NotFound) 223 | } 224 | 225 | // Delete all the candles 226 | await prisma.candle.deleteMany({ 227 | where: { 228 | metaCandleId: metaCandle.id 229 | } 230 | }) 231 | 232 | // Delete the MetaCandle 233 | await prisma.metaCandle.delete({ 234 | where: { 235 | id: metaCandle.id 236 | } 237 | }) 238 | 239 | logger.debug(`Successfully deleted ${name} candles`) 240 | return true 241 | } catch (error) { 242 | logger.error(`Problem deleting ${name} candels from the database`) 243 | logger.error(error) 244 | 245 | throw new BacktestError(`Error deleting MetaCandle and Candles for ${name} with error ${error}`, ErrorCode.Delete) 246 | } 247 | } 248 | -------------------------------------------------------------------------------- /src/helpers/prisma-results.ts: -------------------------------------------------------------------------------- 1 | import { StrategyResult, GetStrategyResult, RunMetaData } from '../helpers/interfaces' 2 | import { getCandles } from './prisma-historical-data' 3 | import { PrismaClient } from '@prisma/client' 4 | import { BacktestError, ErrorCode } from './error' 5 | import * as logger from './logger' 6 | 7 | const prisma = new PrismaClient({ 8 | datasources: { 9 | db: { 10 | url: process.env.DATABASE_URL || 'file:./db/backtest.db' 11 | } 12 | } 13 | }) 14 | 15 | export async function insertResult(result: StrategyResult): Promise { 16 | try { 17 | // Create StrategyResult with historicalDataName (without allOrders and allWorths) 18 | const strategyResult = await prisma.strategyResult.create({ 19 | data: { 20 | name: result.name, 21 | historicalDataName: result.historicalDataName, 22 | strategyName: result.strategyName, 23 | startTime: BigInt(result.startTime), 24 | endTime: BigInt(result.endTime), 25 | txFee: result.txFee, 26 | slippage: result.slippage, 27 | startingAmount: result.startingAmount, 28 | params: JSON.stringify(result.params) 29 | } 30 | }) 31 | 32 | // Create runMetaData with StrategyResultId 33 | const runMetaData = await prisma.runMetaData.create({ 34 | data: { 35 | ...result.runMetaData, 36 | highestAmountDate: BigInt(result.runMetaData.highestAmountDate), 37 | lowestAmountDate: BigInt(result.runMetaData.lowestAmountDate), 38 | startingAssetAmountDate: BigInt(result.runMetaData.startingAssetAmountDate), 39 | endingAssetAmountDate: BigInt(result.runMetaData.endingAssetAmountDate), 40 | highestAssetAmountDate: BigInt(result.runMetaData.highestAssetAmountDate), 41 | lowestAssetAmountDate: BigInt(result.runMetaData.lowestAssetAmountDate), 42 | StrategyResultId: strategyResult.id 43 | } 44 | }) 45 | 46 | const allOrders = result.allOrders.map((order) => ({ 47 | ...order, 48 | note: order.note || '', 49 | time: BigInt(order.time) 50 | })) 51 | 52 | const allWorths = result.allWorths.map((worth) => ({ 53 | ...worth, 54 | time: BigInt(worth.time) 55 | })) 56 | 57 | // Update StrategyResult with RunMetaDataId, allOrders, and allWorths 58 | await prisma.strategyResult.update({ 59 | where: { id: strategyResult.id }, 60 | data: { 61 | runMetaDataId: runMetaData.id, 62 | allOrders: { 63 | create: allOrders 64 | }, 65 | allWorths: { 66 | create: allWorths 67 | } 68 | } 69 | }) 70 | logger.debug(`Successfully inserted result: ${result.name}`) 71 | return true 72 | } catch (error) { 73 | throw new BacktestError(`Problem inserting result with error: ${error}`, ErrorCode.Insert) 74 | } 75 | } 76 | 77 | export async function getAllStrategyResults(): Promise { 78 | try { 79 | // Get all the strategies names 80 | const strategyResults = await prisma.strategyResult.findMany({ 81 | select: { name: true } 82 | }) 83 | 84 | const results: GetStrategyResult[] = await Promise.all( 85 | strategyResults.map(async (result) => await getResult(result.name)) 86 | ) 87 | return results 88 | } catch (error) { 89 | throw new BacktestError(`Problem getting results with error: ${error}`, ErrorCode.Retrieve) 90 | } 91 | } 92 | 93 | export async function getAllStrategyResultNames(): Promise { 94 | try { 95 | // Get all the strategies names 96 | const strategyResults = await prisma.strategyResult.findMany({ 97 | select: { name: true } 98 | }) 99 | 100 | const names = strategyResults.map((result) => result.name) 101 | return names 102 | } catch (error) { 103 | throw new BacktestError(`Problem getting results with error: ${error}`, ErrorCode.Retrieve) 104 | } 105 | } 106 | 107 | export async function getResult(name: string): Promise { 108 | try { 109 | // Get StrategyResult by name 110 | const strategyResult = await prisma.strategyResult.findUnique({ 111 | where: { name }, 112 | include: { 113 | runMetaData: true, 114 | allOrders: true, 115 | allWorths: true 116 | } 117 | }) 118 | 119 | if (!strategyResult) { 120 | throw new BacktestError(`StrategyResult with name ${name} does not exist`, ErrorCode.NotFound) 121 | } 122 | 123 | // Get Candles using historicalDataName 124 | const candlesResult = await getCandles(strategyResult.historicalDataName) 125 | if (!candlesResult) { 126 | throw new BacktestError(`Candles with name ${strategyResult.historicalDataName} not found`, ErrorCode.NotFound) 127 | } 128 | 129 | // Filter candles based on StrategyResult's startTime and endTime 130 | let filteredCandles = candlesResult.candles.filter( 131 | (candle) => 132 | candle.openTime >= Number(strategyResult.startTime) && candle.closeTime <= Number(strategyResult.endTime) 133 | ) 134 | 135 | // Convert BigInt to Number in allOrders and allWorths 136 | const allOrders = strategyResult.allOrders.map((order) => { 137 | const { id, StrategyResultId, ...rest } = order 138 | return { 139 | ...rest, 140 | time: Number(rest.time) 141 | } 142 | }) 143 | const allWorths = strategyResult.allWorths.map((worth) => { 144 | const { id, StrategyResultId, ...rest } = worth 145 | return { 146 | ...rest, 147 | time: Number(rest.time) 148 | } 149 | }) 150 | 151 | // Convert runMetaData 152 | if (strategyResult.runMetaData) { 153 | const { id, StrategyResultId, ...runMetaDataRest } = strategyResult.runMetaData 154 | const runMetaData: RunMetaData = { 155 | ...runMetaDataRest, 156 | highestAmountDate: Number(runMetaDataRest.highestAmountDate), 157 | lowestAmountDate: Number(runMetaDataRest.lowestAmountDate), 158 | highestAssetAmountDate: Number(runMetaDataRest.highestAssetAmountDate), 159 | lowestAssetAmountDate: Number(runMetaDataRest.lowestAssetAmountDate), 160 | startingAssetAmountDate: Number(runMetaDataRest.startingAssetAmountDate), 161 | endingAssetAmountDate: Number(runMetaDataRest.endingAssetAmountDate) 162 | } 163 | 164 | // Form the GetStrategyResult object 165 | const { id: strategyResultId, ...strategyResultRest } = strategyResult 166 | const getResult: GetStrategyResult = { 167 | ...strategyResultRest, 168 | startTime: Number(strategyResultRest.startTime), 169 | endTime: Number(strategyResultRest.endTime), 170 | params: JSON.parse(strategyResultRest.params), 171 | candleMetaData: candlesResult.metaCandles[0], 172 | candles: filteredCandles, 173 | allOrders, 174 | allWorths, 175 | runMetaData 176 | } 177 | 178 | return getResult 179 | } else { 180 | throw new BacktestError('Impossible to found runMetaData', ErrorCode.Retrieve) 181 | } 182 | } catch (error) { 183 | throw new BacktestError(`Failed to get result with error ${error}`, ErrorCode.Retrieve) 184 | } 185 | } 186 | 187 | export async function deleteStrategyResult(name: string): Promise { 188 | try { 189 | // Find the strategy result 190 | const strategyResult = await prisma.strategyResult.findUnique({ 191 | where: { name } 192 | }) 193 | 194 | if (!strategyResult) { 195 | throw new BacktestError(`StrategyResult with name ${name} does not exist`, ErrorCode.NotFound) 196 | } 197 | 198 | const strategyResultId = strategyResult.id 199 | 200 | try { 201 | // Delete related Order records 202 | await prisma.order.deleteMany({ 203 | where: { 204 | StrategyResultId: strategyResultId 205 | } 206 | }) 207 | } catch (error) { 208 | throw new BacktestError( 209 | `Failed to delete related Order records for StrategyResult with name: ${name}. Error: ${error}`, 210 | ErrorCode.Delete 211 | ) 212 | } 213 | 214 | try { 215 | // Delete related Worth records 216 | await prisma.worth.deleteMany({ 217 | where: { 218 | StrategyResultId: strategyResultId 219 | } 220 | }) 221 | } catch (error) { 222 | throw new BacktestError( 223 | `Failed to delete related Worth records for StrategyResult with name: ${name}. Error: ${error}`, 224 | ErrorCode.Delete 225 | ) 226 | } 227 | 228 | try { 229 | // Delete related RunMetaData records 230 | await prisma.runMetaData.deleteMany({ 231 | where: { 232 | StrategyResultId: strategyResultId 233 | } 234 | }) 235 | } catch (error) { 236 | throw new BacktestError( 237 | `Failed to delete related RunMetaData records for StrategyResult with name: ${name}. Error: ${error}`, 238 | ErrorCode.Delete 239 | ) 240 | } 241 | 242 | // Delete the strategy result 243 | await prisma.strategyResult.delete({ 244 | where: { id: strategyResultId } 245 | }) 246 | 247 | // Return successfully deleted 248 | logger.debug(`Successfully deleted ${name}`) 249 | return true 250 | } catch (error) { 251 | throw new BacktestError(`Failed to delete StrategyResult with name: ${name}. Error: ${error}`, ErrorCode.Delete) 252 | } 253 | } 254 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | Apache License 2 | Version 2.0, January 2004 3 | http://www.apache.org/licenses/ 4 | 5 | TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION 6 | 7 | 1. Definitions. 8 | 9 | "License" shall mean the terms and conditions for use, reproduction, 10 | and distribution as defined by Sections 1 through 9 of this document. 11 | 12 | "Licensor" shall mean the copyright owner or entity authorized by 13 | the copyright owner that is granting the License. 14 | 15 | "Legal Entity" shall mean the union of the acting entity and all 16 | other entities that control, are controlled by, or are under common 17 | control with that entity. For the purposes of this definition, 18 | "control" means (i) the power, direct or indirect, to cause the 19 | direction or management of such entity, whether by contract or 20 | otherwise, or (ii) ownership of fifty percent (50%) or more of the 21 | outstanding shares, or (iii) beneficial ownership of such entity. 22 | 23 | "You" (or "Your") shall mean an individual or Legal Entity 24 | exercising permissions granted by this License. 25 | 26 | "Source" form shall mean the preferred form for making modifications, 27 | including but not limited to software source code, documentation 28 | source, and configuration files. 29 | 30 | "Object" form shall mean any form resulting from mechanical 31 | transformation or translation of a Source form, including but 32 | not limited to compiled object code, generated documentation, 33 | and conversions to other media types. 34 | 35 | "Work" shall mean the work of authorship, whether in Source or 36 | Object form, made available under the License, as indicated by a 37 | copyright notice that is included in or attached to the work 38 | (an example is provided in the Appendix below). 39 | 40 | "Derivative Works" shall mean any work, whether in Source or Object 41 | form, that is based on (or derived from) the Work and for which the 42 | editorial revisions, annotations, elaborations, or other modifications 43 | represent, as a whole, an original work of authorship. For the purposes 44 | of this License, Derivative Works shall not include works that remain 45 | separable from, or merely link (or bind by name) to the interfaces of, 46 | the Work and Derivative Works thereof. 47 | 48 | "Contribution" shall mean any work of authorship, including 49 | the original version of the Work and any modifications or additions 50 | to that Work or Derivative Works thereof, that is intentionally 51 | submitted to Licensor for inclusion in the Work by the copyright owner 52 | or by an individual or Legal Entity authorized to submit on behalf of 53 | the copyright owner. For the purposes of this definition, "submitted" 54 | means any form of electronic, verbal, or written communication sent 55 | to the Licensor or its representatives, including but not limited to 56 | communication on electronic mailing lists, source code control systems, 57 | and issue tracking systems that are managed by, or on behalf of, the 58 | Licensor for the purpose of discussing and improving the Work, but 59 | excluding communication that is conspicuously marked or otherwise 60 | designated in writing by the copyright owner as "Not a Contribution." 61 | 62 | "Contributor" shall mean Licensor and any individual or Legal Entity 63 | on behalf of whom a Contribution has been received by Licensor and 64 | subsequently incorporated within the Work. 65 | 66 | 2. Grant of Copyright License. Subject to the terms and conditions of 67 | this License, each Contributor hereby grants to You a perpetual, 68 | worldwide, non-exclusive, no-charge, royalty-free, irrevocable 69 | copyright license to reproduce, prepare Derivative Works of, 70 | publicly display, publicly perform, sublicense, and distribute the 71 | Work and such Derivative Works in Source or Object form. 72 | 73 | 3. Grant of Patent License. Subject to the terms and conditions of 74 | this License, each Contributor hereby grants to You a perpetual, 75 | worldwide, non-exclusive, no-charge, royalty-free, irrevocable 76 | (except as stated in this section) patent license to make, have made, 77 | use, offer to sell, sell, import, and otherwise transfer the Work, 78 | where such license applies only to those patent claims licensable 79 | by such Contributor that are necessarily infringed by their 80 | Contribution(s) alone or by combination of their Contribution(s) 81 | with the Work to which such Contribution(s) was submitted. If You 82 | institute patent litigation against any entity (including a 83 | cross-claim or counterclaim in a lawsuit) alleging that the Work 84 | or a Contribution incorporated within the Work constitutes direct 85 | or contributory patent infringement, then any patent licenses 86 | granted to You under this License for that Work shall terminate 87 | as of the date such litigation is filed. 88 | 89 | 4. Redistribution. You may reproduce and distribute copies of the 90 | Work or Derivative Works thereof in any medium, with or without 91 | modifications, and in Source or Object form, provided that You 92 | meet the following conditions: 93 | 94 | (a) You must give any other recipients of the Work or 95 | Derivative Works a copy of this License; and 96 | 97 | (b) You must cause any modified files to carry prominent notices 98 | stating that You changed the files; and 99 | 100 | (c) You must retain, in the Source form of any Derivative Works 101 | that You distribute, all copyright, patent, trademark, and 102 | attribution notices from the Source form of the Work, 103 | excluding those notices that do not pertain to any part of 104 | the Derivative Works; and 105 | 106 | (d) If the Work includes a "NOTICE" text file as part of its 107 | distribution, then any Derivative Works that You distribute must 108 | include a readable copy of the attribution notices contained 109 | within such NOTICE file, excluding those notices that do not 110 | pertain to any part of the Derivative Works, in at least one 111 | of the following places: within a NOTICE text file distributed 112 | as part of the Derivative Works; within the Source form or 113 | documentation, if provided along with the Derivative Works; or, 114 | within a display generated by the Derivative Works, if and 115 | wherever such third-party notices normally appear. The contents 116 | of the NOTICE file are for informational purposes only and 117 | do not modify the License. You may add Your own attribution 118 | notices within Derivative Works that You distribute, alongside 119 | or as an addendum to the NOTICE text from the Work, provided 120 | that such additional attribution notices cannot be construed 121 | as modifying the License. 122 | 123 | You may add Your own copyright statement to Your modifications and 124 | may provide additional or different license terms and conditions 125 | for use, reproduction, or distribution of Your modifications, or 126 | for any such Derivative Works as a whole, provided Your use, 127 | reproduction, and distribution of the Work otherwise complies with 128 | the conditions stated in this License. 129 | 130 | 5. Submission of Contributions. Unless You explicitly state otherwise, 131 | any Contribution intentionally submitted for inclusion in the Work 132 | by You to the Licensor shall be under the terms and conditions of 133 | this License, without any additional terms or conditions. 134 | Notwithstanding the above, nothing herein shall supersede or modify 135 | the terms of any separate license agreement you may have executed 136 | with Licensor regarding such Contributions. 137 | 138 | 6. Trademarks. This License does not grant permission to use the trade 139 | names, trademarks, service marks, or product names of the Licensor, 140 | except as required for reasonable and customary use in describing the 141 | origin of the Work and reproducing the content of the NOTICE file. 142 | 143 | 7. Disclaimer of Warranty. Unless required by applicable law or 144 | agreed to in writing, Licensor provides the Work (and each 145 | Contributor provides its Contributions) on an "AS IS" BASIS, 146 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or 147 | implied, including, without limitation, any warranties or conditions 148 | of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A 149 | PARTICULAR PURPOSE. You are solely responsible for determining the 150 | appropriateness of using or redistributing the Work and assume any 151 | risks associated with Your exercise of permissions under this License. 152 | 153 | 8. Limitation of Liability. In no event and under no legal theory, 154 | whether in tort (including negligence), contract, or otherwise, 155 | unless required by applicable law (such as deliberate and grossly 156 | negligent acts) or agreed to in writing, shall any Contributor be 157 | liable to You for damages, including any direct, indirect, special, 158 | incidental, or consequential damages of any character arising as a 159 | result of this License or out of the use or inability to use the 160 | Work (including but not limited to damages for loss of goodwill, 161 | work stoppage, computer failure or malfunction, or any and all 162 | other commercial damages or losses), even if such Contributor 163 | has been advised of the possibility of such damages. 164 | 165 | 9. Accepting Warranty or Additional Liability. While redistributing 166 | the Work or Derivative Works thereof, You may choose to offer, 167 | and charge a fee for, acceptance of support, warranty, indemnity, 168 | or other liability obligations and/or rights consistent with this 169 | License. However, in accepting such obligations, You may act only 170 | on Your own behalf and on Your sole responsibility, not on behalf 171 | of any other Contributor, and only if You agree to indemnify, 172 | defend, and hold each Contributor harmless for any liability 173 | incurred by, or claims asserted against, such Contributor by reason 174 | of your accepting any such warranty or additional liability. 175 | 176 | END OF TERMS AND CONDITIONS 177 | 178 | APPENDIX: How to apply the Apache License to your work. 179 | 180 | To apply the Apache License to your work, attach the following 181 | boilerplate notice, with the fields enclosed by brackets "[]" 182 | replaced with your own identifying information. (Don't include 183 | the brackets!) The text should be enclosed in the appropriate 184 | comment syntax for the file format. We also recommend that a 185 | file or class name and description of purpose be included on the 186 | same "printed page" as the copyright notice for easier 187 | identification within third-party archives. 188 | 189 | Copyright 2024 Andrew Baronick 190 | 191 | Licensed under the Apache License, Version 2.0 (the "License"); 192 | you may not use this file except in compliance with the License. 193 | You may obtain a copy of the License at 194 | 195 | http://www.apache.org/licenses/LICENSE-2.0 196 | 197 | Unless required by applicable law or agreed to in writing, software 198 | distributed under the License is distributed on an "AS IS" BASIS, 199 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 200 | See the License for the specific language governing permissions and 201 | limitations under the License. 202 | -------------------------------------------------------------------------------- /src/helpers/run-strategy.ts: -------------------------------------------------------------------------------- 1 | import { 2 | MetaCandle, 3 | RunStrategy, 4 | RunStrategyResult, 5 | RunStrategyResultMulti, 6 | BuySell, 7 | Candle, 8 | Worth, 9 | AssetAmounts 10 | } from '../helpers/interfaces' 11 | 12 | import { realBuy, realSell, orderBook, allOrders, clearOrders, getCurrentWorth } from './orders' 13 | import { dateToString, getDiffInDays, round, generatePermutations, calculateSharpeRatio } from './parse' 14 | import { getCandles as getCandlesFromPrisma } from './prisma-historical-data' 15 | import { getStrategy } from './strategies' 16 | import { BacktestError, ErrorCode } from './error' 17 | import { getIntervals } from '../core/common' 18 | import * as logger from './logger' 19 | 20 | export async function run(runParams: RunStrategy): Promise { 21 | if (!runParams) { 22 | throw new BacktestError('No options specified', ErrorCode.MissingInput) 23 | } 24 | 25 | if (!runParams.strategyName) { 26 | throw new BacktestError('Strategy name must be specified', ErrorCode.MissingInput) 27 | } 28 | 29 | if (!runParams.historicalData?.length) { 30 | throw new BacktestError('Historical data names must be specified', ErrorCode.MissingInput) 31 | } 32 | 33 | const strategyFilePath = getStrategy(runParams.strategyName, runParams.rootPath) 34 | if (!strategyFilePath) { 35 | throw new BacktestError(`Strategy file ${runParams.strategyName}.ts not found.`, ErrorCode.StrategyNotFound) 36 | } 37 | 38 | // Delete the cached version of the strategy file so that it is always freshly loaded 39 | runParams.alwaysFreshLoad && delete require.cache[require.resolve(strategyFilePath)] 40 | const strategy = await import(strategyFilePath) 41 | const runStartTime = new Date().getTime() 42 | 43 | if (strategy?.runStrategy === undefined) { 44 | throw new BacktestError( 45 | `${runParams.strategyName} file does not have a function with the name of runStrategy.\nIt is mandatory to export a function with this name:\n\nexport async function runStrategy(bth: BTH) {}`, 46 | ErrorCode.StrategyError 47 | ) 48 | } 49 | 50 | let multiSymbol = runParams.historicalData.length > 1 51 | let multiParams = false 52 | let permutations = [{}] 53 | let permutationReturn: RunStrategyResultMulti[] = [] 54 | 55 | // Evaluate if is necessary add permutations 56 | if (Object.keys(runParams.params).length !== 0) { 57 | for (const key in runParams.params) { 58 | const paramsKey = runParams.params[key] 59 | if ( 60 | (typeof paramsKey === 'string' && paramsKey.includes(',')) || 61 | (Array.isArray(paramsKey) && paramsKey.length > 1) 62 | ) { 63 | logger.trace(`Found multiple values for ${key}`) 64 | multiParams = true 65 | permutations = generatePermutations(runParams.params) 66 | break 67 | } 68 | } 69 | } 70 | 71 | const supportCandles: Candle[] = [] 72 | const supportCandlesByInterval = {} as any 73 | 74 | const historicalNames = [...new Set(runParams.historicalData)] 75 | const supportHistoricalNames = [...new Set(runParams.supportHistoricalData)] 76 | 77 | // Preload all support historical data 78 | let basePair: string | undefined = undefined 79 | for (let supportCount = 0; supportCount < supportHistoricalNames.length; supportCount++) { 80 | const candlesRequest = await getCandlesFromPrisma(supportHistoricalNames[supportCount]) 81 | if (!candlesRequest) { 82 | throw new BacktestError(`Candles for ${supportHistoricalNames[supportCount]} not found`, ErrorCode.NotFound) 83 | } 84 | 85 | const candles: Candle[] = candlesRequest.candles 86 | const metaCandle: MetaCandle | null = candlesRequest.metaCandles?.[0] ?? null 87 | 88 | if (!metaCandle) { 89 | throw new BacktestError( 90 | `Historical data for ${supportHistoricalNames[supportCount]} not found`, 91 | ErrorCode.NotFound 92 | ) 93 | } 94 | 95 | if (!basePair) { 96 | basePair = metaCandle.symbol 97 | } else if (metaCandle.symbol !== basePair) { 98 | throw new BacktestError( 99 | `All symbols must have the same base pair. ${metaCandle.symbol} does not match ${basePair}`, 100 | ErrorCode.InvalidInput 101 | ) 102 | } 103 | 104 | supportCandlesByInterval[metaCandle.interval] = candles 105 | supportCandles.push(...candles) 106 | } 107 | 108 | // Loop and evaluate historical names 109 | for (let symbolCount = 0; symbolCount < historicalNames.length; symbolCount++) { 110 | const historicalName = historicalNames[symbolCount] 111 | 112 | const candlesRequest = await getCandlesFromPrisma(historicalName) 113 | if (!candlesRequest) { 114 | throw new BacktestError(`Candles for ${historicalName} not found`, ErrorCode.NotFound) 115 | } 116 | 117 | const candles: Candle[] = candlesRequest.candles 118 | const historicalData: MetaCandle | null = candlesRequest.metaCandles?.[0] ?? null 119 | 120 | if (!historicalData) { 121 | throw new BacktestError(`Historical data for ${historicalName} not found`, ErrorCode.NotFound) 122 | } 123 | 124 | if (!!basePair && basePair != historicalData.symbol) { 125 | throw new BacktestError( 126 | `All symbols must have the same base pair. ${historicalData.symbol} does not match ${basePair}`, 127 | ErrorCode.InvalidInput 128 | ) 129 | } 130 | 131 | if (strategy?.startCallback !== undefined) { 132 | await strategy?.startCallback(historicalName) 133 | } 134 | 135 | const tradingInterval = historicalData.interval 136 | 137 | for (let permutationCount = 0; permutationCount < permutations.length; permutationCount++) { 138 | if (multiParams) { 139 | runParams.params = permutations[permutationCount] 140 | } 141 | 142 | await _resetOrders(runParams) 143 | 144 | const allWorths: Worth[] = [] 145 | const tradableCandles = candles.filter( 146 | (c: Candle) => c.closeTime >= runParams.startTime && c.closeTime <= runParams.endTime 147 | ) 148 | 149 | const numberOfCandles = tradableCandles.length 150 | const firstCandle = tradableCandles[0] 151 | const lastCandle = tradableCandles[numberOfCandles - 1] 152 | const runMeta = _initializeRunMetaData(runParams, firstCandle, lastCandle, numberOfCandles) 153 | 154 | // Merge and filter by interval 155 | const allHistoricalData = Object.assign({}, { [historicalData.interval]: candles }, supportCandlesByInterval) 156 | const allCandles = _filterAndSortCandles(runParams, tradingInterval, candles, supportCandles) 157 | 158 | for (const currentCandle of allCandles) { 159 | let canBuySell = true 160 | const tradingCandle = currentCandle.interval === tradingInterval 161 | 162 | async function buy(buyParams?: BuySell) { 163 | return _buy(runParams, tradingCandle, canBuySell, currentCandle, buyParams) 164 | } 165 | 166 | async function sell(buyParams?: BuySell) { 167 | return _sell(runParams, tradingCandle, canBuySell, currentCandle, buyParams) 168 | } 169 | 170 | async function getCandles(type: keyof Candle | 'candle', start: number, end?: number) { 171 | return _getCandles(allHistoricalData, canBuySell, currentCandle, type, start, end) 172 | } 173 | 174 | if (tradingCandle) { 175 | const { open, high, low, close, closeTime } = currentCandle 176 | 177 | if (orderBook.stopLoss > 0) { 178 | if (orderBook.baseAmount > 0 && low <= orderBook.stopLoss) { 179 | await sell({ price: orderBook.stopLoss }) 180 | } else if (orderBook.borrowedBaseAmount > 0 && high >= orderBook.stopLoss) { 181 | await sell({ price: orderBook.stopLoss }) 182 | } 183 | } 184 | 185 | if (orderBook.takeProfit > 0) { 186 | if (orderBook.baseAmount > 0 && high >= orderBook.takeProfit) { 187 | await sell({ price: orderBook.takeProfit }) 188 | } else if (orderBook.borrowedBaseAmount > 0 && low <= orderBook.takeProfit) { 189 | await sell({ price: orderBook.takeProfit }) 190 | } 191 | } 192 | 193 | const worth = await getCurrentWorth(close, high, low, open) 194 | 195 | if (worth.low <= 0) { 196 | throw new BacktestError( 197 | `Your worth in this candle dropped to zero or below. It's recommended to manage shorts with stop losses. Lowest worth this candle: ${ 198 | worth.low 199 | }, Date: ${dateToString(closeTime)}`, 200 | ErrorCode.StrategyError 201 | ) 202 | } 203 | 204 | allWorths.push({ 205 | close: worth.close, 206 | high: worth.high, 207 | low: worth.low, 208 | open: worth.open, 209 | time: closeTime 210 | }) 211 | 212 | if (high > runMeta.highestAssetAmount) { 213 | runMeta.highestAssetAmount = high 214 | runMeta.highestAssetAmountDate = closeTime 215 | } 216 | 217 | if (low < runMeta.lowestAssetAmount) { 218 | runMeta.lowestAssetAmount = low 219 | runMeta.lowestAssetAmountDate = closeTime 220 | } 221 | 222 | if (worth.high > runMeta.highestAmount) { 223 | runMeta.highestAmount = worth.high 224 | runMeta.highestAmountDate = closeTime 225 | } 226 | 227 | if (worth.low < runMeta.lowestAmount) { 228 | runMeta.lowestAmount = worth.low 229 | runMeta.lowestAmountDate = closeTime 230 | 231 | if (runMeta.highestAmount - worth.low > runMeta.maxDrawdownAmount) { 232 | runMeta.maxDrawdownAmount = round(runMeta.highestAmount - worth.low) 233 | runMeta.maxDrawdownAmountDates = `${dateToString(runMeta.highestAmountDate)} - ${dateToString( 234 | closeTime 235 | )} : ${getDiffInDays(runMeta.highestAmountDate, closeTime)}` 236 | } 237 | 238 | const drawdownPercent = ((runMeta.highestAmount - worth.low) / runMeta.highestAmount) * 100 239 | if (drawdownPercent > runMeta.maxDrawdownPercent) { 240 | runMeta.maxDrawdownPercent = round(drawdownPercent) 241 | runMeta.maxDrawdownPercentDates = `${dateToString(runMeta.highestAmountDate)} - ${dateToString( 242 | closeTime 243 | )} : ${getDiffInDays(runMeta.highestAmountDate, closeTime)}` 244 | } 245 | } 246 | } 247 | 248 | try { 249 | await strategy.runStrategy({ 250 | tradingInterval, 251 | tradingCandle, 252 | currentCandle, 253 | params: runParams.params, 254 | orderBook, 255 | allOrders, 256 | buy, 257 | sell, 258 | getCandles 259 | }) 260 | } catch (error) { 261 | logger.error(error) 262 | throw new BacktestError( 263 | `Ran into an error running the strategy with error ${error.message || error}`, 264 | ErrorCode.StrategyError 265 | ) 266 | } 267 | 268 | if (tradingCandle) { 269 | if (orderBook.bought) { 270 | runMeta.numberOfCandlesInvested++ 271 | 272 | // Force a sell if we are on last tradable candle 273 | const currentCandles = allHistoricalData[currentCandle.interval] 274 | const lastCandle = currentCandles?.slice(-1)[0] 275 | if (lastCandle && lastCandle.closeTime === currentCandle.closeTime) { 276 | await sell() 277 | } 278 | } 279 | } 280 | } 281 | 282 | if (strategy?.finishCallback !== undefined) { 283 | await strategy?.finishCallback(historicalName) 284 | } 285 | 286 | runMeta.sharpeRatio = calculateSharpeRatio(allWorths) 287 | logger.debug(`Strategy ${runParams.strategyName} completed in ${Date.now() - runStartTime} ms`) 288 | 289 | if (multiParams || multiSymbol) { 290 | const assetAmounts: AssetAmounts = {} as AssetAmounts 291 | assetAmounts.startingAssetAmount = runMeta.startingAssetAmount 292 | assetAmounts.endingAssetAmount = runMeta.endingAssetAmount 293 | assetAmounts.highestAssetAmount = runMeta.highestAssetAmount 294 | assetAmounts.highestAssetAmountDate = runMeta.highestAssetAmountDate 295 | assetAmounts.lowestAssetAmount = runMeta.lowestAssetAmount 296 | assetAmounts.lowestAssetAmountDate = runMeta.lowestAssetAmountDate 297 | assetAmounts.numberOfCandles = numberOfCandles 298 | 299 | if (historicalData) { 300 | permutationReturn.push({ 301 | ...runParams.params, 302 | symbol: historicalData.symbol, 303 | interval: historicalData.interval, 304 | endAmount: allWorths[allWorths.length - 1].close, 305 | maxDrawdownAmount: runMeta.maxDrawdownAmount, 306 | maxDrawdownPercent: runMeta.maxDrawdownPercent, 307 | numberOfCandlesInvested: runMeta.numberOfCandlesInvested, 308 | sharpeRatio: runMeta.sharpeRatio, 309 | assetAmounts 310 | }) 311 | } 312 | } else { 313 | return { allOrders, runMetaData: runMeta, allWorths, allCandles: tradableCandles } as RunStrategyResult 314 | } 315 | } 316 | } 317 | 318 | return permutationReturn 319 | } 320 | 321 | async function _resetOrders(runParams: RunStrategy) { 322 | orderBook.bought = false 323 | orderBook.boughtLong = false 324 | orderBook.boughtShort = false 325 | orderBook.baseAmount = 0 326 | orderBook.quoteAmount = runParams.startingAmount 327 | orderBook.borrowedBaseAmount = 0 328 | orderBook.fakeQuoteAmount = runParams.startingAmount 329 | orderBook.preBoughtQuoteAmount = runParams.startingAmount 330 | orderBook.stopLoss = 0 331 | orderBook.takeProfit = 0 332 | await clearOrders() 333 | } 334 | 335 | function _initializeRunMetaData( 336 | runParams: RunStrategy, 337 | firstCandle: Candle, 338 | lastCandle: Candle, 339 | numberOfCandles: number 340 | ) { 341 | return { 342 | highestAmount: runParams.startingAmount, 343 | highestAmountDate: firstCandle.closeTime, 344 | lowestAmount: runParams.startingAmount, 345 | lowestAmountDate: firstCandle.closeTime, 346 | maxDrawdownAmount: 0, 347 | maxDrawdownAmountDates: '', 348 | maxDrawdownPercent: 0, 349 | maxDrawdownPercentDates: '', 350 | startingAssetAmount: firstCandle.close, 351 | startingAssetAmountDate: firstCandle.closeTime, 352 | endingAssetAmount: lastCandle.close, 353 | endingAssetAmountDate: lastCandle.closeTime, 354 | highestAssetAmount: firstCandle.high, 355 | highestAssetAmountDate: firstCandle.closeTime, 356 | lowestAssetAmount: firstCandle.low, 357 | lowestAssetAmountDate: firstCandle.closeTime, 358 | numberOfCandles: numberOfCandles, 359 | numberOfCandlesInvested: 0, 360 | sharpeRatio: 0 361 | } 362 | } 363 | 364 | function _filterAndSortCandles( 365 | runParams: RunStrategy, 366 | tradingInterval: string, 367 | candles: Candle[], 368 | supportCandles: Candle[] 369 | ) { 370 | const allCandles = [...candles, ...supportCandles.filter((c: Candle) => c.interval !== tradingInterval)].filter( 371 | (c: Candle) => c.closeTime >= runParams.startTime && c.closeTime <= runParams.endTime 372 | ) 373 | 374 | // Sorted by closeTime (from oldest) and then by interval length (from shortest) 375 | const intervalOrder = getIntervals() 376 | allCandles.sort((a, b) => { 377 | const byTime = a.closeTime - b.closeTime 378 | if (byTime !== 0) return byTime 379 | return intervalOrder.indexOf(a.interval) - intervalOrder.indexOf(b.interval) 380 | }) 381 | 382 | return allCandles 383 | } 384 | 385 | async function _buy( 386 | runParams: RunStrategy, 387 | tradingCandle: boolean, 388 | canBuySell: boolean, 389 | currentCandle: Candle, 390 | buyParams?: BuySell 391 | ) { 392 | if (!tradingCandle) { 393 | throw new BacktestError('Cannot buy on a support candle', ErrorCode.ActionFailed) 394 | } 395 | 396 | if (!canBuySell) { 397 | logger.trace('Buy blocked until highest needed candles are met') 398 | } else { 399 | buyParams = buyParams || {} 400 | buyParams.price = buyParams.price || currentCandle.close 401 | 402 | const buyParamsReal = { 403 | currentClose: currentCandle.close, 404 | percentFee: runParams.percentFee, 405 | percentSlippage: runParams.percentSlippage, 406 | date: currentCandle.closeTime, 407 | ...buyParams 408 | } 409 | 410 | if (orderBook.borrowedBaseAmount > 0 && orderBook.baseAmount > 0) { 411 | if (buyParams.stopLoss && buyParams.stopLoss > 0) { 412 | throw new BacktestError('Cannot define a stop loss if in a long and a short', ErrorCode.ActionFailed) 413 | } 414 | 415 | if (buyParams.takeProfit && buyParams.takeProfit > 0) { 416 | throw new BacktestError('Cannot define a take profit if in a long and a short', ErrorCode.ActionFailed) 417 | } 418 | } 419 | 420 | if (buyParams.stopLoss && buyParams.stopLoss > 0) orderBook.stopLoss = buyParams.stopLoss 421 | if (buyParams.takeProfit && buyParams.takeProfit > 0) orderBook.takeProfit = buyParams.takeProfit 422 | 423 | const buyResults = await realBuy(buyParamsReal) 424 | 425 | if (buyResults) { 426 | logger.trace(`Real buy performed`) 427 | } 428 | } 429 | } 430 | 431 | async function _sell( 432 | runParams: RunStrategy, 433 | tradingCandle: boolean, 434 | canBuySell: boolean, 435 | currentCandle: Candle, 436 | sellParams?: BuySell 437 | ) { 438 | if (!tradingCandle) { 439 | throw new BacktestError('Cannot sell on a support candle', ErrorCode.ActionFailed) 440 | } 441 | if (!canBuySell) { 442 | logger.trace('Sell blocked until highest needed candles are met') 443 | } else { 444 | sellParams = sellParams || {} 445 | sellParams.price = sellParams.price || currentCandle.close 446 | 447 | const sellParamsReal = { 448 | currentClose: currentCandle.close, 449 | percentFee: runParams.percentFee, 450 | percentSlippage: runParams.percentSlippage, 451 | date: currentCandle.closeTime, 452 | ...sellParams 453 | } 454 | 455 | const sellResults = await realSell(sellParamsReal) 456 | 457 | if (sellResults) { 458 | logger.trace(`Real sell performed`) 459 | } 460 | } 461 | } 462 | 463 | async function _getCandles( 464 | allHistoricalData: any, 465 | canBuySell: boolean, 466 | currentCandle: Candle, 467 | type: keyof Candle | 'candle', 468 | start: number, 469 | end?: number 470 | ) { 471 | const allCurrentCandles = allHistoricalData[currentCandle.interval] 472 | const isEndNull = end == null 473 | 474 | const currentCandleIndex = allCurrentCandles.findIndex((c: Candle) => c.closeTime === currentCandle.closeTime) 475 | if (currentCandleIndex < 0) { 476 | canBuySell = false 477 | throw new BacktestError('Impossible to found current candle', ErrorCode.ActionFailed) 478 | } 479 | 480 | const currentCandles = allCurrentCandles.slice(0, currentCandleIndex + 1) 481 | const currentCandleCount = currentCandles.length 482 | 483 | const fromIndex = currentCandleCount - start 484 | const toIndex = isEndNull ? fromIndex + 1 : currentCandleCount - end 485 | 486 | if (currentCandleCount === 0 || fromIndex < 0 || toIndex < 0 || fromIndex >= currentCandleCount) { 487 | canBuySell = false 488 | throw new BacktestError('Insufficient candles, choose another date or adjust the quantity', ErrorCode.ActionFailed) 489 | } 490 | 491 | const slicedCandles = currentCandles.slice(fromIndex, toIndex) 492 | const filteredCandles = type === 'candle' ? slicedCandles : slicedCandles.map((c: Candle) => c[type]) 493 | 494 | return isEndNull ? filteredCandles[0] : filteredCandles 495 | } 496 | -------------------------------------------------------------------------------- /src/helpers/orders.ts: -------------------------------------------------------------------------------- 1 | import { BuySellReal, Order } from '../helpers/interfaces' 2 | import { round } from './parse' 3 | import { BacktestError, ErrorCode } from './error' 4 | import * as logger from './logger' 5 | 6 | export const orderBook = { 7 | bought: false, 8 | boughtLong: false, 9 | boughtShort: false, 10 | baseAmount: 0, 11 | quoteAmount: 0, 12 | borrowedBaseAmount: 0, 13 | preBoughtQuoteAmount: 0, 14 | fakeQuoteAmount: 0, 15 | stopLoss: 0, 16 | takeProfit: 0 17 | } 18 | 19 | export let allOrders: Order[] = [] 20 | 21 | export async function clearOrders() { 22 | allOrders = [] 23 | } 24 | 25 | export async function getCurrentWorth(close: number, high?: number, low?: number, open?: number) { 26 | // Return quote amount if not bought in 27 | if (!orderBook.bought) 28 | return { 29 | close: round(orderBook.quoteAmount), 30 | high: round(orderBook.quoteAmount), 31 | low: round(orderBook.quoteAmount), 32 | open: round(orderBook.quoteAmount) 33 | } 34 | 35 | // Current worth 36 | let currentWorth = orderBook.fakeQuoteAmount 37 | currentWorth += orderBook.baseAmount * close 38 | currentWorth -= orderBook.borrowedBaseAmount * close 39 | 40 | // Get a value if bought in 41 | if (high !== undefined && low !== undefined && open !== undefined) { 42 | // Open worth 43 | let openWorth = orderBook.fakeQuoteAmount 44 | openWorth += orderBook.baseAmount * open 45 | openWorth -= orderBook.borrowedBaseAmount * open 46 | 47 | // Highest worth 48 | let highestWorth = orderBook.fakeQuoteAmount 49 | highestWorth += orderBook.baseAmount * high 50 | highestWorth -= orderBook.borrowedBaseAmount * high 51 | 52 | // Lowest worth 53 | let lowestWorth = orderBook.fakeQuoteAmount 54 | lowestWorth += orderBook.baseAmount * low 55 | lowestWorth -= orderBook.borrowedBaseAmount * low 56 | 57 | return { 58 | close: round(currentWorth), 59 | high: highestWorth >= lowestWorth ? round(highestWorth) : round(lowestWorth), 60 | low: highestWorth >= lowestWorth ? round(lowestWorth) : round(highestWorth), 61 | open: round(openWorth) 62 | } 63 | } 64 | return { close: round(currentWorth), high: round(currentWorth), low: round(currentWorth), open: round(currentWorth) } 65 | } 66 | 67 | export async function realBuy(buyParams: BuySellReal): Promise { 68 | if (orderBook.quoteAmount > 0) { 69 | // Remove possible undefineds 70 | buyParams.price = buyParams.price ?? 0 71 | buyParams.percentSlippage = buyParams.percentSlippage ?? 0 72 | buyParams.percentFee = buyParams.percentFee ?? 0 73 | 74 | // Define position if undefined 75 | if (buyParams.position === undefined) { 76 | buyParams.position = 'long' 77 | } 78 | 79 | // Adjust if there is slippage 80 | if (buyParams.position === 'long' && buyParams.percentSlippage > 0) { 81 | buyParams.price = buyParams.price * (1 + buyParams.percentSlippage / 100) 82 | } else if (buyParams.position === 'short' && buyParams.percentSlippage > 0) { 83 | buyParams.price = buyParams.price * (1 - buyParams.percentSlippage / 100) 84 | } 85 | 86 | // Define order entry 87 | const order: Order = { 88 | type: 'buy', 89 | position: 'long', 90 | price: buyParams.price, 91 | amount: 0, 92 | worth: 0, 93 | quoteAmount: 0, 94 | baseAmount: 0, 95 | borrowedBaseAmount: 0, 96 | profitAmount: 0, 97 | profitPercent: 0, 98 | time: buyParams.date, 99 | note: buyParams.note || '' 100 | } 101 | 102 | // Return error if sending amount and base amount 103 | if (buyParams.amount !== undefined && buyParams.baseAmount !== undefined) { 104 | throw new BacktestError( 105 | `Cannot send amount and base amount for a buy order, sent amount: ${buyParams.amount} and base amount: ${buyParams.baseAmount}`, 106 | ErrorCode.ActionFailed 107 | ) 108 | } 109 | // Define amount if undefined 110 | else if (buyParams.amount === undefined && buyParams.baseAmount === undefined) { 111 | buyParams.amount = orderBook.quoteAmount 112 | } 113 | // Convert base asset amount to quote if needed 114 | else if (buyParams.baseAmount !== undefined) { 115 | buyParams.amount = buyParams.baseAmount * buyParams.price 116 | } 117 | 118 | // Convert amount percentage to number if needed 119 | else if (typeof buyParams.amount === 'string') { 120 | if (buyParams.amount.includes('%')) { 121 | buyParams.amount = buyParams.amount.replace('%', '') 122 | } else { 123 | throw new BacktestError( 124 | `If sending a string for buy amount you must provide a % instead received ${buyParams.amount}`, 125 | ErrorCode.ActionFailed 126 | ) 127 | } 128 | if (typeof +buyParams.amount === 'number' && +buyParams.amount <= 100 && +buyParams.amount > 0) { 129 | buyParams.amount = orderBook.quoteAmount * (+buyParams.amount / 100) 130 | } else { 131 | throw new BacktestError( 132 | `Buy amount does not have a valid number or is not > 0 and <= 100, expected a valid number instead received ${buyParams.amount}`, 133 | ErrorCode.ActionFailed 134 | ) 135 | } 136 | } 137 | 138 | // Return if quote asset amount is 0 139 | if (typeof buyParams.amount === 'number' && buyParams.amount <= 0) { 140 | throw new BacktestError('Returning because there is no amount to buy', ErrorCode.ActionFailed) 141 | } 142 | 143 | // Handle if trying to buy more than have 144 | if (typeof buyParams.amount === 'number' && buyParams.amount > orderBook.quoteAmount) { 145 | buyParams.amount = orderBook.quoteAmount 146 | } 147 | 148 | // Make sure needed vars are numbers 149 | if (typeof buyParams.amount === 'number') { 150 | // Define amount after fee 151 | let amountAfterFee = buyParams.amount 152 | if (buyParams.percentFee > 0) amountAfterFee = buyParams.amount * (1 - buyParams.percentFee / 100) 153 | 154 | // Update bought or not 155 | if (!orderBook.bought) { 156 | orderBook.preBoughtQuoteAmount = orderBook.quoteAmount 157 | orderBook.bought = true 158 | } 159 | 160 | // Perform long buy 161 | if (buyParams.position === 'long') { 162 | // Buy calculations 163 | orderBook.baseAmount += amountAfterFee / buyParams.price 164 | orderBook.quoteAmount -= buyParams.amount 165 | orderBook.fakeQuoteAmount -= buyParams.amount 166 | } 167 | 168 | // Perform short buy 169 | else if (buyParams.position === 'short') { 170 | if (buyParams.percentFee > 0) amountAfterFee = buyParams.amount * (1 + buyParams.percentFee / 100) 171 | 172 | // Buy calculations 173 | orderBook.quoteAmount -= buyParams.amount 174 | orderBook.fakeQuoteAmount += buyParams.amount 175 | orderBook.borrowedBaseAmount += amountAfterFee / buyParams.price 176 | 177 | // Adjust order to short 178 | order.position = 'short' 179 | } 180 | 181 | orderBook.boughtLong = orderBook.baseAmount === 0 ? false : true 182 | orderBook.boughtShort = orderBook.borrowedBaseAmount === 0 ? false : true 183 | 184 | // Update quote and base amount in order 185 | order.quoteAmount = round(orderBook.quoteAmount) 186 | order.baseAmount = round(orderBook.baseAmount) 187 | order.borrowedBaseAmount = round(orderBook.borrowedBaseAmount) 188 | 189 | // Set and push in order 190 | order.amount = round(buyParams.amount) 191 | order.worth = (await getCurrentWorth(buyParams.currentClose)).close 192 | allOrders.push(order) 193 | 194 | // Return successfully bought message 195 | logger.trace(`Successfully bought amount of ${buyParams.amount}`) 196 | return true 197 | } else { 198 | // Return buy error 199 | throw new BacktestError( 200 | `Buy amount or symbol price does not have a valid number, expected a valid number instead received amount: ${buyParams.amount} and symbol price: ${buyParams.price}`, 201 | ErrorCode.ActionFailed 202 | ) 203 | } 204 | } 205 | 206 | return true 207 | } 208 | 209 | export async function realSell(sellParams: BuySellReal): Promise { 210 | // Dont sell if not bought in 211 | if (orderBook.bought) { 212 | // Remove possible undefineds 213 | sellParams.price = sellParams.price ?? 0 214 | sellParams.percentSlippage = sellParams.percentSlippage ?? 0 215 | sellParams.percentFee = sellParams.percentFee ?? 0 216 | 217 | // Define position if undefined 218 | if (sellParams.position === undefined) { 219 | if (orderBook.baseAmount > 0 && orderBook.borrowedBaseAmount > 0) sellParams.position = 'both' 220 | else if (orderBook.baseAmount > 0) sellParams.position = 'long' 221 | else if (orderBook.borrowedBaseAmount > 0) sellParams.position = 'short' 222 | } 223 | 224 | // Adjust if there is slippage 225 | if (sellParams.position === 'long' && sellParams.percentSlippage > 0) { 226 | sellParams.price = sellParams.price * (1 - sellParams.percentSlippage / 100) 227 | } else if (sellParams.position === 'short' && sellParams.percentSlippage > 0) { 228 | sellParams.price = sellParams.price * (1 + sellParams.percentSlippage / 100) 229 | } 230 | 231 | // Define order entry 232 | const order: Order = { 233 | type: 'sell', 234 | position: 'long', 235 | price: sellParams.price, 236 | amount: 0, 237 | worth: 0, 238 | quoteAmount: 0, 239 | baseAmount: 0, 240 | borrowedBaseAmount: 0, 241 | profitAmount: 0, 242 | profitPercent: 0, 243 | time: sellParams.date, 244 | note: sellParams.note || '' 245 | } 246 | 247 | // Return error if sending amount and base amount 248 | if (sellParams.amount !== undefined && sellParams.baseAmount !== undefined) { 249 | throw new BacktestError( 250 | `Cannot send amount and base amount for a sell order, sent amount: ${sellParams.amount} and base amount: ${sellParams.baseAmount}`, 251 | ErrorCode.ActionFailed 252 | ) 253 | } else if ( 254 | sellParams.position === 'both' && 255 | (sellParams.amount !== undefined || sellParams.baseAmount !== undefined) 256 | ) 257 | throw new BacktestError( 258 | `When selling both long and short you cannot send amount or base amount (in such case its sell all), sent amount: ${sellParams.amount} and base amount: ${sellParams.baseAmount}`, 259 | ErrorCode.ActionFailed 260 | ) 261 | 262 | // Sell on long position 263 | if (sellParams.position === 'long' || sellParams.position === 'both') { 264 | // Define amount if undefined 265 | if (sellParams.amount === undefined && sellParams.baseAmount === undefined) 266 | sellParams.baseAmount = orderBook.baseAmount 267 | // Define amount when not undefined but base amount is undefined 268 | else if (sellParams.amount !== undefined) { 269 | // Convert amount percentage to number if needed 270 | if (typeof sellParams.amount === 'string') { 271 | if (sellParams.amount.includes('%')) { 272 | sellParams.amount = sellParams.amount.replace('%', '') 273 | } else { 274 | throw new BacktestError( 275 | `If sending a string for sell amount you must provide a %, instead received ${sellParams.amount}`, 276 | ErrorCode.ActionFailed 277 | ) 278 | } 279 | if (typeof +sellParams.amount === 'number' && +sellParams.amount <= 100 && +sellParams.amount > 0) { 280 | sellParams.baseAmount = orderBook.baseAmount * (+sellParams.amount / 100) 281 | } else { 282 | throw new BacktestError( 283 | `Sell amount does not have a valid number or is not > 0 and <= 100, expected a valid number instead received ${sellParams.amount}`, 284 | ErrorCode.ActionFailed 285 | ) 286 | } 287 | } 288 | 289 | // Define base amount 290 | else if ( 291 | typeof sellParams.amount === 'number' && 292 | typeof sellParams.price === 'number' && 293 | sellParams.amount > 0 294 | ) { 295 | sellParams.baseAmount = sellParams.amount / sellParams.price 296 | } else { 297 | throw new BacktestError( 298 | `Sell amount must be more than 0 or symbol price does not have a valid number, instead received amount: ${sellParams.amount} and symbol price: ${sellParams.price}`, 299 | ErrorCode.ActionFailed 300 | ) 301 | } 302 | } 303 | 304 | // Return if nothing to sell 305 | if (typeof sellParams.baseAmount === 'number' && sellParams.baseAmount <= 0) { 306 | throw new BacktestError('Returning because there is no amount to sell', ErrorCode.ActionFailed) 307 | } 308 | 309 | // Make sure sell amount is not larger then amount to sell 310 | if (typeof sellParams.baseAmount === 'number' && sellParams.baseAmount > orderBook.baseAmount) { 311 | sellParams.baseAmount = orderBook.baseAmount 312 | } 313 | 314 | if (typeof sellParams.baseAmount === 'number' && typeof sellParams.price === 'number') { 315 | // Define amount after fee 316 | let amountAfterFee = sellParams.baseAmount 317 | 318 | // Perfrom sell math 319 | if (sellParams.percentFee > 0) amountAfterFee = sellParams.baseAmount * (1 - sellParams.percentFee / 100) 320 | orderBook.baseAmount -= sellParams.baseAmount 321 | orderBook.quoteAmount += amountAfterFee * sellParams.price 322 | orderBook.fakeQuoteAmount += amountAfterFee * sellParams.price 323 | 324 | // Set and push in order 325 | order.amount = round(sellParams.baseAmount * sellParams.price) 326 | } 327 | 328 | order.quoteAmount = round(orderBook.quoteAmount) 329 | order.baseAmount = round(orderBook.baseAmount) 330 | order.borrowedBaseAmount = round(orderBook.borrowedBaseAmount) 331 | 332 | // Update order book with percentage if completely sold 333 | if (orderBook.baseAmount === 0 && orderBook.borrowedBaseAmount === 0) { 334 | // Find percentage between preBought and sold 335 | const percentBetween = -( 336 | ((orderBook.preBoughtQuoteAmount - orderBook.quoteAmount) / orderBook.preBoughtQuoteAmount) * 337 | 100 338 | ) 339 | order.profitAmount = +-(orderBook.preBoughtQuoteAmount - orderBook.quoteAmount).toFixed(2) 340 | order.profitPercent = +percentBetween.toFixed(2) 341 | } 342 | 343 | order.worth = (await getCurrentWorth(sellParams.currentClose)).close 344 | 345 | //Push in long order 346 | allOrders.push(order) 347 | } 348 | 349 | // Define order entry for short 350 | const orderShort: Order = { 351 | type: 'sell', 352 | position: 'short', 353 | price: sellParams.price, 354 | amount: 0, 355 | worth: 0, 356 | quoteAmount: 0, 357 | baseAmount: 0, 358 | borrowedBaseAmount: 0, 359 | profitAmount: 0, 360 | profitPercent: 0, 361 | time: sellParams.date 362 | } 363 | 364 | // Sell on short position 365 | if (sellParams.position === 'short' || sellParams.position === 'both') { 366 | if (sellParams.position === 'both') { 367 | sellParams.amount = undefined 368 | sellParams.baseAmount = undefined 369 | } 370 | 371 | // Define amount if undefined 372 | if (sellParams.amount === undefined && sellParams.baseAmount === undefined) 373 | sellParams.baseAmount = orderBook.borrowedBaseAmount 374 | // Define amount when not undefined but base amount is undefined 375 | else if (sellParams.amount !== undefined) { 376 | // Convert amount percentage to number if needed 377 | if (typeof sellParams.amount === 'string') { 378 | if (sellParams.amount.includes('%')) { 379 | sellParams.amount = sellParams.amount.replace('%', '') 380 | } else { 381 | throw new BacktestError( 382 | `If sending a string for sell amount you must provide a %', instead received ${sellParams.amount}`, 383 | ErrorCode.ActionFailed 384 | ) 385 | } 386 | if (typeof +sellParams.amount === 'number' && +sellParams.amount <= 100 && +sellParams.amount > 0) { 387 | sellParams.baseAmount = orderBook.borrowedBaseAmount * (+sellParams.amount / 100) 388 | } else { 389 | throw new BacktestError( 390 | `Sell amount does not have a valid number or is not > 0 and <= 100, expected a valid number instead received ${sellParams.amount}`, 391 | ErrorCode.ActionFailed 392 | ) 393 | } 394 | } 395 | 396 | // Define base amount 397 | else if ( 398 | typeof sellParams.amount === 'number' && 399 | typeof sellParams.price === 'number' && 400 | sellParams.amount > 0 401 | ) { 402 | sellParams.baseAmount = sellParams.amount / sellParams.price 403 | } else { 404 | throw new BacktestError( 405 | `Sell amount must be more than 0 or symbol price does not have a valid number, instead received amount: ${sellParams.amount} and symbol price: ${sellParams.price}`, 406 | ErrorCode.ActionFailed 407 | ) 408 | } 409 | } 410 | 411 | // Return if nothing to sell 412 | if (typeof sellParams.baseAmount === 'number' && sellParams.baseAmount <= 0) { 413 | throw new BacktestError('Returning because there is no amount to sell', ErrorCode.ActionFailed) 414 | } 415 | 416 | // Make sure sell amount is not larger then amount to sell 417 | if (typeof sellParams.baseAmount === 'number' && sellParams.baseAmount > orderBook.borrowedBaseAmount) { 418 | sellParams.baseAmount = orderBook.borrowedBaseAmount 419 | } 420 | 421 | if (typeof sellParams.baseAmount === 'number' && typeof sellParams.price === 'number') { 422 | // Define amount after fee 423 | let amountAfterFee = sellParams.baseAmount 424 | 425 | // Perfrom sell math 426 | if (sellParams.percentFee > 0) amountAfterFee = sellParams.baseAmount * (1 - sellParams.percentFee / 100) 427 | const price = amountAfterFee * sellParams.price 428 | orderBook.borrowedBaseAmount -= sellParams.baseAmount 429 | orderBook.fakeQuoteAmount -= price 430 | if (orderBook.borrowedBaseAmount === 0) orderBook.quoteAmount = orderBook.fakeQuoteAmount 431 | else orderBook.quoteAmount += price 432 | 433 | // Set and push in order 434 | orderShort.amount = round(sellParams.baseAmount * sellParams.price) 435 | } 436 | 437 | // Update quote and base amount in order 438 | orderShort.quoteAmount = round(orderBook.quoteAmount) 439 | orderShort.baseAmount = round(orderBook.baseAmount) 440 | orderShort.borrowedBaseAmount = round(orderBook.borrowedBaseAmount) 441 | 442 | // Update order with percentage if completely sold 443 | if (orderBook.baseAmount === 0 && orderBook.borrowedBaseAmount === 0) { 444 | // Find percentage between preBought and sold 445 | const percentBetween = -( 446 | ((orderBook.preBoughtQuoteAmount - orderBook.quoteAmount) / orderBook.preBoughtQuoteAmount) * 447 | 100 448 | ) 449 | orderShort.profitAmount = +-(orderBook.preBoughtQuoteAmount - orderBook.quoteAmount).toFixed(2) 450 | orderShort.profitPercent = +percentBetween.toFixed(2) 451 | } 452 | 453 | // Push in order 454 | orderShort.worth = (await getCurrentWorth(sellParams.currentClose)).close 455 | allOrders.push(orderShort) 456 | } 457 | 458 | // Update if bought or not 459 | orderBook.boughtLong = orderBook.baseAmount === 0 ? false : true 460 | orderBook.boughtShort = orderBook.borrowedBaseAmount === 0 ? false : true 461 | orderBook.bought = orderBook.boughtLong || orderBook.boughtShort 462 | 463 | // Update pre bought amount if completely sold 464 | if (!orderBook.bought) orderBook.preBoughtQuoteAmount = orderBook.quoteAmount 465 | 466 | // Reset stop loss and take profit 467 | orderBook.stopLoss = 0 468 | orderBook.takeProfit = 0 469 | } 470 | 471 | return true 472 | } 473 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | ![GitHub](https://img.shields.io/github/license/backtestjs/framework) 2 | ![GitHub package.json version](https://img.shields.io/github/package-json/v/backtestjs/framework) 3 | [![npm](https://img.shields.io/badge/package-npm-white)](https://www.npmjs.com/package/@backtest/framework) 4 | 5 | # Backtest JS: Framework 6 | 7 | A comprehensive and user-friendly framework to fetch candle data, backtest any trading strategy and compare results. 8 | 9 | Enhance your trading strategies with Backtest, meticulously crafted for trading developers. Leverage the power of TypeScript to backtest your strategies with unmatched precision, efficiency, and flexibility. 10 | 11 | ## Key Features 🌟 12 | 13 | - **Intuitive Methods**: Utilizes intuitive methods for smooth and efficient operation 14 | - **Comprehensive Candle Data**: Access historical candle data from Binance or import CSV files 15 | - **Integrated Storage**: Store candle data, strategies, and results in SQLite storage 16 | - **Documentation**: Extensive guides and resources available 17 | 18 | ## Quick Start 19 | 20 | ### Installation 21 | 22 | To install the package in your project, use the following npm command: 23 | 24 | ```bash 25 | npm i @backtest/framework 26 | ``` 27 | 28 | ### Basic Strategy 29 | 30 | ```typescript 31 | import { BTH } from '@backtest/framework' 32 | 33 | export async function runStrategy(bth: BTH) { 34 | const sma10 = await bth.getCandles('close', 10) 35 | const sma20 = await bth.getCandles('close', 20) 36 | 37 | if (sma10 > sma20) { 38 | await bth.buy() 39 | } else { 40 | await bth.sell() 41 | } 42 | } 43 | ``` 44 | 45 | ### How to use this package 46 | 47 | You can incorporate this framework **directly** into your project by installing it as described above. 48 | 49 | Alternatively, you can clone the [quick-start](https://github.com/backtestjs/quick-start) repository, which will allow you to start writing your strategies without needing to set up a project from scratch. The project itself provides all the necessary instructions. 50 | 51 | If not, you can use the command line interface that will handle everything for you. In this case, we recommend checking out the specific project [@backtest/command-line](https://github.com/backtestjs/command-line). This way, you can easily navigate and use the command line interface without any confusion. 52 | 53 | ### File .env 54 | 55 | If you want to incorporate this framework into your project as a dependency, it is necessary to create a `.env` file to store environment variables. This file is not committed to the repository, so you can use it to store sensitive information. 56 | 57 | The `DATABASE_URL` variable **must be** inserted with the path to the target file. By default, SQLite is used, so it's not necessary to have an external database. For example, an absolute path like `DATABASE_URL=file:/Users/backtesjs/quick-start/db/backtest.db` is valid. 58 | 59 | ```env 60 | DATABASE_URL=file:/Users/backtesjs/quick-start/db/backtest.db 61 | FRAMEWORK_LOG_LEVEL=ERROR # trace, debug, info, error (default) 62 | ``` 63 | 64 | ## Documentation 65 | 66 | If you enjoy reading the code, you can find a comprehensive (and extensive) demonstration method [here](https://github.com/backtestjs/framework/blob/main/src/demo.ts) that showcases most of the available methods. Additionally, you'll see examples of how to run strategies (with or without support, for instance). 67 | 68 | In this README, you will find a comprehensive table that lists and describes all the methods available within the framework. Other documentation files are located in the `./docs` directory: 69 | 70 | - [Quick Start](docs/QUICK_START.md) 71 | - [Basic Usage](docs/BASIC_USAGE.md) 72 | - [Advanced Usage](docs/ADVANCED_USAGE.md) 73 | - [Strategy Guide](docs/STRATEGY.md) 74 | - [Contributing](docs/CONTRIBUTING.md) 75 | - [Examples](docs/EXAMPLES.md) 76 | - [FAQ](docs/FAQ.md) 77 | 78 | As on overview, some of the areas covered by these methods are: 79 | 80 | ### Historical data operations 81 | 82 | - Finding historical data by name. 83 | - Deleting historical data. 84 | - Downloading historical data for specific intervals and time periods. 85 | - Exporting historical data to a CSV file. 86 | - Importing historical data from a CSV file. 87 | 88 | ### Strategy operations 89 | 90 | - Scanning for available strategies. 91 | - Finding all available strategies and their names. 92 | - Running a strategy with specified parameters and historical data. 93 | - Parsing the results of running a strategy. 94 | - Saving the results of running a strategy. 95 | 96 | ### Results operations 97 | 98 | - Finding result names. 99 | - Finding all results. 100 | - Deleting a result. 101 | - Saving a result. 102 | 103 | ## Historical Candle Data 104 | 105 | Easily download candle data from Binance, no coding or API key required (thanks to Binance!). Alternatively, you can import historical data from a CSV file. Additionally, you can export your data to a CSV file for further analysis. 106 | 107 | ## Custom Strategies 108 | 109 | In addition to the demonstration strategies already present, you can create your own by adding a file under `src/strategies`. 110 | 111 | Use one of the existing files or the examples in this guide as a reference. Each file should contain a `runStrategy` method, and if it uses external or dynamic parameters, it should also include a properly filled-out `properties` structure. 112 | 113 | Whenever you create a new strategy, modify the `properties` structure of an existing one, or delete an existing strategy, you need to run the `scanStrategies` method. 114 | 115 | There’s no need to stop or restart the backtest process if it’s running, or to exit the program. The program will reload the contents of your file with each launch, as long as it’s synchronized. 116 | 117 | Using well-defined or dynamic parameters (instead of constants within your strategy) will allow you to run multiple tests simultaneously. 118 | 119 | ## Candle Data 120 | 121 | Each candle have the following information available: 122 | 123 | ```typescript 124 | export interface Candle { 125 | symbol: string 126 | interval: string 127 | openTime: number 128 | open: number 129 | high: number 130 | low: number 131 | close: number 132 | volume: number 133 | closeTime: number 134 | assetVolume: number 135 | numberOfTrades: number 136 | } 137 | ``` 138 | 139 | ## Buy and Sell 140 | 141 | It is possible to execute a buy or sell by following this: 142 | 143 | ```typescript 144 | export interface BuySell { 145 | price?: number // Price of which you will trade the asset 146 | position?: string // Can be "short" or "long" (long is the default) 147 | amount?: number | string // Amount of asset to buy can be number or string (string must include a %), f.e. 300 or "10%" 148 | baseAmount?: number // Trade the base amount to use for percentage calculations (total worth for baseAmount equals to amount) 149 | stopLoss?: number // Price of which a stop loss will trigger (all will be sold on the stop loss) 150 | takeProfit?: number // Price of which a take profit will trigger (all will be sold on the take profit price) 151 | percentFee?: number 152 | percentSlippage?: number // 153 | note?: string // Add a simple note to identify this trade 154 | } 155 | ``` 156 | 157 | **_Pay attention_**: follow these rules: 158 | 159 | - You CAN short and long at the same time but they need to be two seperate calls 160 | - If you try to buy or sell but you already bought or sold everything, the buy or sell will be skipped and not recorded 161 | - You cannot use “amount” and “baseAmount” params together 162 | - If in a short and a long you cannot use “amount” or “baseAmount” when selling without specifying a position. 163 | - You cannot use stopLoss if you long and short at the same time 164 | - You cannot use takeProfit if you long and short at the same time 165 | - Amount param can be a number or a string, if a string it must contain a percent sign “%” 166 | 167 | In particular, the buy signal: 168 | 169 | ```typescript 170 | bth.buy() 171 | 172 | /* or */ 173 | await bth.buy({ 174 | position: 'short', // or 'long' (default) 175 | amount: '10%', // or baseAmount 176 | note: 'a simple note here', 177 | stopLoss: stopLoss, 178 | percentSlippage: percentSlippage, 179 | percentFee: percentFee 180 | }) 181 | ``` 182 | 183 | while the sell signal: 184 | 185 | ```typescript 186 | bth.sell() 187 | 188 | /* or */ 189 | 190 | await bth.sell({ 191 | amount: 250, // or baseAmount 192 | note: 'a simple note here' 193 | }) 194 | ``` 195 | 196 | ## Examples: Buy and Sell 197 | 198 | ### Beginner: The simplest buy & sell 199 | 200 | ```typescript 201 | // Lets say you have $1000 and want to trade bitcoin 202 | // Put in a long order and buy all which is $1000 worth of bitcoin 203 | await buy() 204 | // Lets say you bought bitcoin and are now worth $1000 205 | // Put in a sell order and sell all which is $1000 worth of bitcoin 206 | await sell() 207 | ``` 208 | 209 | ### Beginner: How to specify amount 210 | 211 | ```typescript 212 | // Lets say you have $1000 and want to trade bitcoin 213 | // Put in a long order of $400 worth of bitcoin 214 | await buy({ amount: 400 }) 215 | // Same thing can be achieved here 216 | await buy({ amount: '40%' }) 217 | // Lets say you bought bitcoin and are now worth $1000 in bitcoin and put in a sell order of $400 worth of bitcoin 218 | await sell({ amount: 400 }) 219 | // Same thing can be achieved here 220 | await sell({ amount: '40%' }) 221 | ``` 222 | 223 | ### Regular: How to specify stop loss and take profit 224 | 225 | ```typescript 226 | // Lets say you have $1000 and want to trade bitcoin 227 | // Put a short order in with all which is $1000 and a stop loss at $24,000 228 | await buy({ position: "short", stopLoss: 24000 }) 229 | // The application is smart enough to know that it's a short and only sell if a candles high goes above $24,000 230 | // Lets say you bought bitcoin in a long and a short but only want to sell some of the shorted amount 231 | // Put in a sell order to sell 50% of the shorted amount 232 | await sell({ position: "short", amount "50%"}) 233 | ``` 234 | 235 | ### Regular: How to specify base amount 236 | 237 | ```typescript 238 | // Lets say you have $1000 and bitcoin is currently worth $2000 239 | // Put a long order in of .25 bitcoin which is $500 worth 240 | await buy({ baseAmount: 0.25 }) 241 | // This can also be achieved by doing 242 | await buy({ amount: 500 }) 243 | // You cannot use amount with baseAmount in the same buy / sell call 244 | // Lets say you bought bitcoin and are worth $1000 and bitcoin is worth $2000 245 | // Put a short order in of .25 bitcoin which is $500 worth 246 | await sell({ baseAmount: 0.25 }) 247 | // This can also be achieved by doing 248 | await sell({ amount: 500 }) 249 | ``` 250 | 251 | ### Advanced: How to place an order at a specific price 252 | 253 | ```typescript 254 | // Lets say you have $1000 and bitcoins close was $2100 but you had a trigger to buy at $2000 255 | // Put a long order in of $1000 worth but bitcoin at a price of $2000 256 | await buy({ price: 2000 }) 257 | // Lets say you bought and bitcoin is worth $2200 but you had a trigger to sell at $2100 258 | // Put a sell order in where bitcoin is worth $2100 259 | await sell({ price: 2100 }) 260 | ``` 261 | 262 | ## Write a Strategy 263 | 264 | When a strategy is executed, the `runStrategy` method has access to the `BTH` object, which contains useful information. For example, it provides methods (like: `getCandles`) to obtain ohlc data, calculate technical indicators, and manage trading positions. 265 | 266 | ### BTH and getCandles 267 | 268 | Below is the interface: 269 | 270 | ```typescript 271 | export interface BTH { 272 | tradingInterval: string // Trading interval 273 | tradingCandle: boolean // Indicates if this candle is tradable 274 | currentCandle: Candle // Current candle 275 | params: LooseObject // Strategy parameters 276 | orderBook: OrderBook // Order book 277 | allOrders: Order[] // All current orders 278 | buy: Function // Function to buy (long / short) 279 | sell: Function // Function to sell 280 | getCandles: Function // Function to obtain price data (see below) 281 | } 282 | ``` 283 | 284 | The `getCandles` function is an asynchronous function that returns an array of `Candle` objects. 285 | 286 | **Parameters:** 287 | 288 | - **type**: Specifies the type of data to return (a key of `Candle` or `'candle'` to return the entire `Candle` object). 289 | - **start**: Indicates the starting index from which to begin retrieving the candles. 290 | - **end** (optional): Indicates the ending index up to which to retrieve the candles. If not specified, the method uses only the `start`. 291 | 292 | The `getCandles` method can be used as follows: 293 | 294 | ```typescript 295 | const closes = await bth.getCandles('close', 10, 0) // last ten closes 296 | const open = await bth.getCandles('open', 0) // last open 297 | const candles = await bth.getCandles('candle', 5, 0) // last five candles 298 | ``` 299 | 300 | Details on the `start` and `end` parameters: 301 | 302 | - If `end` is not specified, the method will return the candle at index `candleIndex - start`. 303 | - If `end` is specified, the method will return the candles from index `candleIndex - end` to index `candleIndex - start`. 304 | 305 | Examples: 306 | 307 | - `getCandles('close', 5)` will return the close at index `candleIndex - 5`. 308 | - `getCandles('open', 10, 5)` will return the opens from index `candleIndex - 10` to index `candleIndex - 5`. 309 | - `getCandles('candle', 10)` will return only the 10th candle counted from the last candle (`candleIndex - 10`). 310 | - `getCandles('candle', 10, 0)` will return the last 10 candles. 311 | - `getCandles('candle', 10, 5)` will return candles from `candleIndex - 10` (inclusive) to `candleIndex - 5` (exclusive). 312 | - `getCandles('candle', 10, 1)` will return candles from `candleIndex - 10` (inclusive) to `candleIndex - 1` (exclusive, i.e., excluding the last one). 313 | 314 | ### How to run strategies 315 | 316 | When you want to execute a strategy, you need to call the `runStrategy` method. Remember to perform a `scanStrategies` if, for example, you have changed parameters or created a new strategy. 317 | 318 | ```typescript 319 | import { scanStrategies, runStrategy } from '@backtest/framework' 320 | 321 | const scan = await scanStrategies() 322 | console.log('Scan strategies:', scan) 323 | 324 | const runStrategyResult = await runStrategy({ 325 | strategyName: 'demo', // ./strategies/demo.ts 326 | historicalData: ['BTCEUR-1d'], 327 | params: { 328 | lowSMA: [10, 20], // or a single value eg. 20 329 | highSMA: [30, 40, 50] // or a single value eg. 50 330 | }, 331 | startingAmount: 1000, 332 | startTime: startTime, 333 | endTime: endTime 334 | }) 335 | console.log('runStrategyResult:', runStrategyResult.name) 336 | ``` 337 | 338 | When you run your strategy, you can provide multiple parameters. Below is the general structure: 339 | 340 | ```typescript 341 | export interface RunStrategy { 342 | strategyName: string // name of the strategy to run 343 | historicalData: string[] // symbols to use for trading (e.g. ['BTCEUR-8h', 'BTCEUR-1d']) 344 | supportHistoricalData?: string[] // symbols to use as support (e.g. ['BTCEUR-1h', 'BTCEUR-8h', 'BTCEUR-1d']) 345 | startingAmount: number // how much money to start with 346 | startTime: number // from which date start to evaluate yor strategy 347 | endTime: number // to which date evaluate your strategy 348 | params: LooseObject // parameters to use for the strategy, you can pass multiple value for each parameter 349 | percentFee?: number // 0.1 means 0.1% fee 350 | percentSlippage?: number // 0.6 means 0.6% slippage 351 | rootPath?: string // sometimes is useful specify a different path (uncommon case) 352 | alwaysFreshLoad?: boolean // if true the file of the strategy is always reloaded by scratch, the default is false 353 | } 354 | ``` 355 | 356 | **_Pay attention_**: If `alwaysFreshLoad` is set to `true`, it's important to note that you cannot use global variables in your strategy. As a result, you won't be able to take advantage of the benefits of using support historical data. 357 | 358 | **_Permutation_**: In case `params` contains **arrays of values** instead of **single values**, the system will create and execute the strategy on all resulting permutations. 359 | 360 | ## Examples: Strategies 361 | 362 | ### Beginner: The simplest strategy 363 | 364 | Below is an example of a simple 3 over 45 SMA strategy. You buy once the 3 crosses the 45 and sell otherwise. In this example, we don’t use the power of params. 365 | 366 | ```typescript 367 | import { BTH } from '@backtest/framework' 368 | import { indicatorSMA } from '../indicators/moving-averages' 369 | 370 | export async function runStrategy(bth: BTH) { 371 | const lowSMACandles = await bth.getCandles('close', 3, 0) 372 | const highSMACandles = await bth.getCandles('close', 45, 0) 373 | 374 | // Calculate low and high SMA 375 | const lowSMA = await indicatorSMA(lowSMACandles, 3) 376 | const highSMA = await indicatorSMA(highSMACandles, 45) 377 | 378 | // Buy if lowSMA crosses over the highSMA 379 | if (lowSMA > highSMA) { 380 | await bth.buy() 381 | } 382 | 383 | // Sell if lowSMA crosses under the highSMA 384 | else { 385 | await bth.sell() 386 | } 387 | } 388 | ``` 389 | 390 | **_Pay attention_**: hard-coded parameters will prevent you from running multiple tests simultaneously! 391 | 392 | ### Regular: the same strategy with parameters 393 | 394 | Below is an example of a simple SMA strategy like above but it’s not hard-coded to the 3 over 45. When you run the strategy through the CLI, you will be asked to provide a low and high SMA. You can even provide multiple lows and multiple highs, and all the variations will be tested in one run. 395 | 396 | ```typescript 397 | import { BTH } from '../core/interfaces' 398 | import { indicatorSMA } from '../indicators/moving-averages' 399 | 400 | export const properties = { 401 | params: ['lowSMA', 'highSMA'], 402 | dynamicParams: false 403 | } 404 | 405 | export async function runStrategy(bth: BTH) { 406 | const lowSMAInput = bth.params.lowSMA 407 | const highSMAInput = bth.params.highSMA 408 | 409 | // Get last candles 410 | const lowSMACandles = await bth.getCandles('close', lowSMAInput, 0) 411 | const highSMACandles = await bth.getCandles('close', highSMAInput, 0) 412 | 413 | // Calculate low and high SMA 414 | const lowSMA = await indicatorSMA(lowSMACandles, lowSMAInput) 415 | const highSMA = await indicatorSMA(highSMACandles, highSMAInput) 416 | 417 | // Buy if lowSMA crosses over the highSMA 418 | if (lowSMA > highSMA) { 419 | await bth.buy() 420 | } 421 | 422 | // Sell if lowSMA crosses under the highSMA 423 | else { 424 | await bth.sell() 425 | } 426 | } 427 | ``` 428 | 429 | **_Please note_**: If `lowSMA` and `highSMA` are provided as arrays in the `runStrategy` call, the strategy will be executed multiple times for all required permutations. 430 | 431 | ### Advanced: use of multiple historical data 432 | 433 | Your strategy can also use other intervals as a support, that is one or more intervals of the same symbol. This way, on the trading interval, you can execute buy/sell actions, while you can use the supports to perform statistics or validate any trading signals. 434 | 435 | ```typescript 436 | export async function runStrategy(bth: BTH) { 437 | if (bth.tradingCandle) { 438 | // For example, use BTCEUR-1d data to execute your trading strategy (buy, sell, etc.) 439 | } else { 440 | // do something else when BTCEUR-8h candle is closed 441 | } 442 | } 443 | ``` 444 | 445 | ### Advanced: use of start/finish callbacks 446 | 447 | You can use the `start` and `finish` callbacks to initialize and finalize your strategy. If you want use them, you need to export them from your strategy file. The `start` callback is called before the strategy is run, and the `finish` callback is called after the strategy is run. 448 | 449 | ```typescript 450 | export async function startCallback(historicalName: string) { 451 | console.log('called before runStrategy', historicalName) 452 | } 453 | 454 | export async function finishCallback(historicalName: string) { 455 | console.log('called after runStrategy', historicalName) 456 | } 457 | ``` 458 | 459 | ## Other ideas 460 | 461 | In the [discussions](https://github.com/backtestjs/framework/discussions) section of this repository, you can find other ideas or suggestions, for example: 462 | 463 | - [HowTo: Use Parameters with Permutations](https://github.com/backtestjs/framework/discussions/3) 464 | - [HowTo: Use Mistral AI with Backtest Framework](https://github.com/backtestjs/framework/discussions/1) 465 | 466 | If you have other ideas or requests, feel free to propose them. 467 | 468 | ## Backtesting Results 469 | 470 | Backtest not only delivers performance insights but also returns your strategy's effectiveness through comprehensive statistics. 471 | 472 | ## Import Candle Data from CSV 473 | 474 | Although there is an option to download data from **binance** for `crypto` assets there is no automatic download available for traditional symbols such as `apple` or `tesla` stock as well as forex symbols such as `usdyen`. 475 | 476 | This candle data can be downloaded from third party sites such as `yahoo finance` and can then be easily imported to the Backtest database to use with any strategy. 477 | 478 | ### How to prepare CSV file 479 | 480 | The CSV file **must** have the following fields: 481 | 482 | - Close time of the candle: closeTime or date 483 | - Open price of the candle: open 484 | - High price of the candle: high 485 | - Low price of the candle: low 486 | - Close price of the candle: close 487 | 488 | The CSV file can have the following **optional** fields: 489 | 490 | - Open time of the candle: openTime, openTime 491 | - Volume in the candle: volume 492 | - Asset volume of the candle: assetVolume 493 | - Number of trades done in the candle: numberOfTrades 494 | 495 | **_Pay attention_**: follow these rules: 496 | 497 | - Each field can be written without considering case sensitivity. 498 | - The order of the fields in the CSV file is not important. 499 | - Any additional fields will not cause an error but won't be added to the database. 500 | 501 | ## API Documentation 502 | 503 | The following table outlines the primary methods available within this framework. 504 | 505 | | Method | Description | 506 | | ----------------------- | ------------------------------------------------------------------------- | 507 | | deleteHistoricalData | Deletes historical data of a symbol and interval | 508 | | deleteMultiResult | Deletes the saved result of a multi-symbol execution | 509 | | deleteResult | Deletes the saved result of an execution | 510 | | downloadHistoricalData | Downloads historical data of a symbol and interval from Binance | 511 | | exportFileCSV | Exports historical data of a symbol and interval to a CSV file | 512 | | findHistoricalData | Returns the historical data of a symbol and interval | 513 | | findHistoricalDataNames | Returns the names of the saved historical data | 514 | | findHistoricalDataSets | Returns all saved historical data | 515 | | findMultiResultNames | Returns the names of the saved multi-symbol execution results | 516 | | findMultiResults | Returns the saved multi-symbol execution results | 517 | | findResultNames | Returns the names of the saved execution results | 518 | | findResults | Returns the saved execution results | 519 | | findStrategies | Returns the strategies saved in the database | 520 | | findStrategy | Returns the strategy by its name | 521 | | findStrategyNames | Returns the names of the strategies saved in the database | 522 | | getCandleStartDate | Returns the date of the first candle (1m) through Binance | 523 | | getCandles | Returns the candles of a symbol and interval | 524 | | getIntervals | Static list of usable intervals | 525 | | getMultiResult | Returns the saved result of a multi-symbol execution | 526 | | getResult | Returns the saved result of an execution | 527 | | importFileCSV | Imports historical data from a CSV file | 528 | | isValidInterval | Checks if an interval is valid (among those from getIntervals) | 529 | | parseRunResultsStats | Processes the results and returns an object with the statistics | 530 | | runStrategy | Runs a single strategy, multi-symbol with or without supporting intervals | 531 | | saveMultiResult | Saves the result of the previously executed strategy | 532 | | saveResult | Saves the result of the previously executed strategy | 533 | | scanStrategies | Rereads and updates the list of strategies and associated parameters | 534 | 535 | ## Prisma: Useful commands 536 | 537 | [Prisma](https://prisma.io) is a modern DB toolkit to query, migrate and model your database. 538 | 539 | In this project, Prisma is used with `SQLite` to avoid the need for installing other databases. If necessary, the database file can be deleted or updated. 540 | 541 | Below are some useful commands to run from the terminal/shell: 542 | 543 | - Run `npx prisma` to display the command line help; 544 | - Run `npx prisma validate` to validate the prisma.schema; 545 | - Run `npx prisma generate` to generate artifacts, such as the Prisma client; 546 | - Run `npx prisma db push` to push the Prisma schema state to the database. 547 | 548 | However, it's always recommended to refer to the official Prisma documentation for detailed information. 549 | 550 | ## Support the project 551 | 552 | This open-source project grows thanks to everyone's support. If you appreciate this work and want to keep it active, consider making a small donation. Even a small contribution, like the cost of a coffee ☕, can make a difference! 553 | 554 | ### Why Donate? 555 | 556 | - You support the continuous development and maintenance of the project. 557 | - You contribute to creating new features and improvements. 558 | 559 | ### How to Donate? 560 | 561 | You can make a donation through: 562 | 563 | **Lighjtning Network** 564 | roaringcent59@walletofsatoshi.com 565 | 566 | **Bitcoin address** 567 | [bc1qtly7cqy8zxzs79ksmdsfnz7hjyhhd3t2f9mvvj](https://www.blockchain.com/explorer/addresses/btc/bc1qtly7cqy8zxzs79ksmdsfnz7hjyhhd3t2f9mvvj) 568 | 569 | **Ethereum address** 570 | [0xa4A79Be4e7AE537Cb9ee65DB92E6368425b2d63D](https://etherscan.io/address/0xa4A79Be4e7AE537Cb9ee65DB92E6368425b2d63D) 571 | 572 | Thank you for your support! ❤️ 573 | -------------------------------------------------------------------------------- /src/helpers/parse.ts: -------------------------------------------------------------------------------- 1 | import { Candle, Order, LooseObject, StrategyResult, MetaCandle, StrategyResultMulti } from '../helpers/interfaces' 2 | import { getCandleMetaData } from './prisma-historical-data' 3 | import { BacktestError, ErrorCode } from './error' 4 | import * as logger from './logger' 5 | 6 | export function dateToString(date: Date | number | string) { 7 | return new Date(date).toLocaleString() 8 | } 9 | 10 | export function roundTo(number: number | undefined = 0, decimal: number = 2) { 11 | const factor = Math.pow(10, decimal) 12 | return Math.round((number + Number.EPSILON) * factor) / factor 13 | } 14 | 15 | export function round(numberToConvert: number) { 16 | // If the number is greater than or equal to 1, round to two decimal places 17 | if (Math.abs(numberToConvert) >= 1) { 18 | return +numberToConvert.toFixed(2) 19 | } 20 | 21 | // If the number is less than 1 22 | else { 23 | let strNum = numberToConvert.toFixed(20) 24 | let i = 0 25 | 26 | // Find the first non-zero digit 27 | while (strNum[i + 2] === '0') { 28 | i++ 29 | } 30 | 31 | // Extract and round the number up to three places after the first non-zero digit 32 | let rounded = parseFloat(strNum.slice(0, i + 2 + 3 + 1)) 33 | 34 | // Convert the rounded number back to a string and truncate to the required number of decimal places 35 | const strRounded = rounded.toString() 36 | 37 | // Return the rounded number 38 | return +strRounded.slice(0, i + 2 + 3) 39 | } 40 | } 41 | 42 | export async function parseCandles(symbol: string, interval: string, candles: Candle[]) { 43 | logger.debug(`Parsing ${candles?.length} candles for ${symbol} ${interval}`) 44 | 45 | // Remove most recent candle 46 | candles.pop() 47 | 48 | // Map candles to an object 49 | const candleObjects: Candle[] = candles.map((item: LooseObject) => ({ 50 | symbol: symbol, 51 | interval: interval, 52 | openTime: item[0], 53 | open: +item[1], 54 | high: +item[2], 55 | low: +item[3], 56 | close: +item[4], 57 | volume: +item[5], 58 | closeTime: item[6], 59 | assetVolume: +item[7], 60 | numberOfTrades: item[8] 61 | })) 62 | 63 | // Return candles 64 | return candleObjects 65 | } 66 | 67 | export async function removeUnusedCandles(candles: number[][], requiredTime: number) { 68 | // Remove unused candles that dont need to be saved to DB 69 | for (let i = 0; i < candles.length; i++) { 70 | if (candles[i][6] > requiredTime) return candles.splice(i) 71 | } 72 | } 73 | 74 | export function getDiffInDays(startDate: number, endDate: number) { 75 | // Define start and end times 76 | const startTime = new Date(startDate) 77 | const endTime = new Date(endDate) 78 | 79 | // Get diff in time 80 | const timeDiff = Math.abs(endTime.getTime() - startTime.getTime()) 81 | 82 | // Parse diff in time 83 | const days = Math.floor(timeDiff / (1000 * 60 * 60 * 24)) 84 | const hours = Math.floor((timeDiff / (1000 * 60 * 60)) % 24) 85 | const minutes = Math.floor((timeDiff / (1000 * 60)) % 60) 86 | const seconds = Math.floor((timeDiff / 1000) % 60) 87 | 88 | // Return the diff in time 89 | return `${days} days ${hours.toString().padStart(2, '0')}:${minutes.toString().padStart(2, '0')}:${seconds 90 | .toString() 91 | .padStart(2, '0')}` 92 | } 93 | 94 | function _getDiffInDaysPercentage(startDate: number, endDate: number, percentage: number) { 95 | // Define start and end times 96 | const startTime = new Date(startDate) 97 | const endTime = new Date(endDate) 98 | 99 | // Get diff in time 100 | const timeDiff = Math.abs(endTime.getTime() - startTime.getTime()) 101 | 102 | // Reduce by percentage 103 | const timeDiffReduced = timeDiff * percentage 104 | 105 | // Parse diff in time 106 | const days = Math.floor(timeDiffReduced / (1000 * 60 * 60 * 24)) 107 | const hours = Math.floor((timeDiffReduced / (1000 * 60 * 60)) % 24) 108 | const minutes = Math.floor((timeDiffReduced / (1000 * 60)) % 60) 109 | const seconds = Math.floor((timeDiffReduced / 1000) % 60) 110 | 111 | // Return the diff in time 112 | return `${days} days ${hours.toString().padStart(2, '0')}:${minutes.toString().padStart(2, '0')}:${seconds 113 | .toString() 114 | .padStart(2, '0')}` 115 | } 116 | 117 | export async function parseRunResultsStats(results: StrategyResult | StrategyResultMulti): Promise { 118 | const isSingle = 'allOrders' in results && results.allOrders.length > 0 119 | return isSingle 120 | ? _parseRunResultsStats(results as StrategyResult) 121 | : _parseRunResultsStatsMulti(results as StrategyResultMulti) 122 | } 123 | 124 | function _parseRunResults(runResults: Order[]) { 125 | // Build statistic results 126 | const parsedRunResults = { 127 | winningTradeAmount: 0, 128 | losingTradeAmount: 0, 129 | averageWinAmount: 0, 130 | averageLossAmount: 0, 131 | buyAmount: 0, 132 | sellAmount: 0, 133 | averageBuyAmount: 0, 134 | averageSellAmount: 0, 135 | highestTradeWin: 0, 136 | highestTradeWinDate: '', 137 | highestTradeLoss: 0, 138 | highestTradeLossDate: '', 139 | highestBuyAmount: 0, 140 | highestBuyAmountDate: '', 141 | highestSellAmount: 0, 142 | highestSellAmountDate: '', 143 | lowestBuyAmount: 0, 144 | lowestBuyAmountDate: '', 145 | lowestSellAmount: 0, 146 | lowestSellAmountDate: '', 147 | averageTradePercent: 0, 148 | winRatePercent: 0, 149 | lossRatePercent: 0, 150 | averageWinPercent: 0, 151 | averageLossPercent: 0, 152 | highestTradeWinPercentage: 0, 153 | highestTradeWinPercentageDate: '', 154 | highestTradeLossPercentage: 0, 155 | highestTradeLossPercentageDate: '' 156 | } 157 | for (let i = 0; i < runResults.length; i++) { 158 | if (runResults[i].profitAmount > 0) { 159 | parsedRunResults.winningTradeAmount++ 160 | parsedRunResults.averageWinAmount += runResults[i].profitAmount 161 | parsedRunResults.averageWinPercent += runResults[i].profitPercent 162 | if (runResults[i].profitPercent > parsedRunResults.highestTradeWinPercentage) { 163 | parsedRunResults.highestTradeWinPercentage = runResults[i].profitPercent 164 | parsedRunResults.highestTradeWinPercentageDate = dateToString(runResults[i].time) 165 | } 166 | } 167 | if (runResults[i].profitAmount < 0) { 168 | parsedRunResults.losingTradeAmount++ 169 | parsedRunResults.averageLossAmount += runResults[i].profitAmount 170 | parsedRunResults.averageLossPercent += runResults[i].profitPercent 171 | if (parsedRunResults.highestTradeLossPercentage === 0) { 172 | parsedRunResults.highestTradeLossPercentage = runResults[i].profitPercent 173 | parsedRunResults.highestTradeLossPercentageDate = dateToString(runResults[i].time) 174 | } 175 | if ( 176 | parsedRunResults.highestTradeLossPercentage !== 0 && 177 | runResults[i].profitPercent < parsedRunResults.highestTradeLossPercentage 178 | ) { 179 | parsedRunResults.highestTradeLossPercentage = runResults[i].profitPercent 180 | parsedRunResults.highestTradeLossPercentageDate = dateToString(runResults[i].time) 181 | } 182 | } 183 | if (runResults[i].type === 'buy') { 184 | parsedRunResults.buyAmount++ 185 | parsedRunResults.averageBuyAmount += runResults[i].amount 186 | } 187 | if (runResults[i].type === 'sell') { 188 | parsedRunResults.sellAmount++ 189 | parsedRunResults.averageSellAmount += runResults[i].amount 190 | } 191 | 192 | if (runResults[i].profitAmount > parsedRunResults.highestTradeWin) { 193 | parsedRunResults.highestTradeWin = runResults[i].profitAmount 194 | parsedRunResults.highestTradeWinDate = dateToString(runResults[i].time) 195 | } 196 | if (parsedRunResults.highestTradeLoss === 0 && runResults[i].profitAmount < 0) { 197 | parsedRunResults.highestTradeLoss = runResults[i].profitAmount 198 | parsedRunResults.highestTradeLossDate = dateToString(runResults[i].time) 199 | } 200 | if (parsedRunResults.highestTradeLoss !== 0 && runResults[i].profitAmount < parsedRunResults.highestTradeLoss) { 201 | parsedRunResults.highestTradeLoss = runResults[i].profitAmount 202 | parsedRunResults.highestTradeLossDate = dateToString(runResults[i].time) 203 | } 204 | if (runResults[i].type === 'buy' && runResults[i].amount > parsedRunResults.highestBuyAmount) { 205 | parsedRunResults.highestBuyAmount = runResults[i].amount 206 | parsedRunResults.highestBuyAmountDate = dateToString(runResults[i].time) 207 | } 208 | if (runResults[i].type === 'sell' && runResults[i].amount > parsedRunResults.highestSellAmount) { 209 | parsedRunResults.highestSellAmount = runResults[i].amount 210 | parsedRunResults.highestSellAmountDate = dateToString(runResults[i].time) 211 | } 212 | if (parsedRunResults.lowestBuyAmount === 0 && runResults[i].type === 'buy' && runResults[i].amount !== 0) { 213 | parsedRunResults.lowestBuyAmount = runResults[i].amount 214 | parsedRunResults.lowestBuyAmountDate = dateToString(runResults[i].time) 215 | } 216 | if ( 217 | parsedRunResults.lowestBuyAmount !== 0 && 218 | runResults[i].type === 'buy' && 219 | runResults[i].amount < parsedRunResults.lowestBuyAmount 220 | ) { 221 | parsedRunResults.lowestBuyAmount = runResults[i].amount 222 | parsedRunResults.lowestBuyAmountDate = dateToString(runResults[i].time) 223 | } 224 | if (parsedRunResults.lowestSellAmount === 0 && runResults[i].type === 'sell' && runResults[i].amount !== 0) { 225 | parsedRunResults.lowestSellAmount = runResults[i].amount 226 | parsedRunResults.lowestSellAmountDate = dateToString(runResults[i].time) 227 | } 228 | if ( 229 | parsedRunResults.lowestSellAmount !== 0 && 230 | runResults[i].type === 'sell' && 231 | runResults[i].amount < parsedRunResults.lowestSellAmount 232 | ) { 233 | parsedRunResults.lowestSellAmount = runResults[i].amount 234 | parsedRunResults.lowestSellAmountDate = dateToString(runResults[i].time) 235 | } 236 | } 237 | 238 | parsedRunResults.averageWinAmount /= parsedRunResults.winningTradeAmount 239 | parsedRunResults.averageLossAmount /= parsedRunResults.losingTradeAmount 240 | parsedRunResults.averageBuyAmount /= parsedRunResults.buyAmount 241 | parsedRunResults.averageSellAmount /= parsedRunResults.sellAmount 242 | 243 | parsedRunResults.averageTradePercent = +( 244 | ((parsedRunResults.winningTradeAmount + parsedRunResults.losingTradeAmount) / 245 | (parsedRunResults.averageWinPercent + parsedRunResults.averageLossAmount)) * 246 | 100 247 | ).toFixed(2) 248 | parsedRunResults.winRatePercent = +( 249 | (parsedRunResults.winningTradeAmount / (parsedRunResults.winningTradeAmount + parsedRunResults.losingTradeAmount)) * 250 | 100 251 | ).toFixed(2) 252 | parsedRunResults.lossRatePercent = +( 253 | (parsedRunResults.losingTradeAmount / (parsedRunResults.winningTradeAmount + parsedRunResults.losingTradeAmount)) * 254 | 100 255 | ).toFixed(2) 256 | parsedRunResults.averageWinPercent /= parsedRunResults.winningTradeAmount 257 | parsedRunResults.averageLossPercent /= parsedRunResults.losingTradeAmount 258 | 259 | // Return statistic results 260 | return parsedRunResults 261 | } 262 | 263 | async function _parseRunResultsStats(runResultsParams: StrategyResult) { 264 | // Parse the run results 265 | const runResultStats = _parseRunResults(runResultsParams.allOrders) 266 | 267 | // Define start and end times 268 | const startingDate = dateToString(runResultsParams.startTime) 269 | const endingDate = dateToString(runResultsParams.endTime) 270 | 271 | // Get candle metadata 272 | const historicalData: MetaCandle | null = await getCandleMetaData(runResultsParams.historicalDataName) 273 | if (!historicalData) { 274 | throw new BacktestError(`Problem getting the ${runResultsParams.historicalDataName} metaData`, ErrorCode.NotFound) 275 | } 276 | 277 | // Get diff in days of candles invested 278 | const diffInDaysCandlesInvestedPercentage = 279 | (runResultsParams.runMetaData.numberOfCandlesInvested / runResultsParams.runMetaData.numberOfCandles) * 100 280 | const diffInDaysCandlesInvested = _getDiffInDaysPercentage( 281 | runResultsParams.startTime, 282 | runResultsParams.endTime, 283 | diffInDaysCandlesInvestedPercentage / 100 284 | ) 285 | 286 | // Create total amounts 287 | const totals = [ 288 | { 289 | name: `Start ${historicalData.quote} Amount`, 290 | amount: runResultsParams.startingAmount, 291 | percent: '-', 292 | date: startingDate 293 | }, 294 | { 295 | name: `End ${historicalData.quote} Amount`, 296 | amount: runResultsParams.allOrders[runResultsParams.allOrders.length - 1].worth, 297 | percent: `${+( 298 | (runResultsParams.allOrders[runResultsParams.allOrders.length - 1].worth / runResultsParams.startingAmount) * 299 | 100 300 | ).toFixed(2)}%`, 301 | date: endingDate 302 | }, 303 | { 304 | name: `${ 305 | runResultsParams.startingAmount < runResultsParams.allOrders[runResultsParams.allOrders.length - 1].worth 306 | ? 'Won' 307 | : 'Loss' 308 | } ${historicalData.quote} Amount`, 309 | amount: 310 | runResultsParams.startingAmount < runResultsParams.allOrders[runResultsParams.allOrders.length - 1].worth 311 | ? round( 312 | runResultsParams.allOrders[runResultsParams.allOrders.length - 1].worth - runResultsParams.startingAmount 313 | ) 314 | : round( 315 | runResultsParams.startingAmount - runResultsParams.allOrders[runResultsParams.allOrders.length - 1].worth 316 | ), 317 | percent: `${-( 318 | ((runResultsParams.startingAmount - runResultsParams.allOrders[runResultsParams.allOrders.length - 1].worth) / 319 | runResultsParams.startingAmount) * 320 | 100 321 | ).toFixed(2)}%`, 322 | date: `Duration: ${getDiffInDays(runResultsParams.startTime, runResultsParams.endTime)}` 323 | }, 324 | { 325 | name: 'Sharpe Ratio', 326 | amount: 327 | runResultsParams.runMetaData.sharpeRatio === 10000 328 | ? 'Need > 1 Year' 329 | : roundTo(runResultsParams.runMetaData.sharpeRatio, 6), 330 | percent: '-', 331 | date: `Duration: ${getDiffInDays(runResultsParams.startTime, runResultsParams.endTime)}` 332 | }, 333 | { 334 | name: `Highest ${historicalData.quote} Amount`, 335 | amount: runResultsParams.runMetaData.highestAmount, 336 | percent: `${-( 337 | ((runResultsParams.startingAmount - runResultsParams.runMetaData.highestAmount) / 338 | runResultsParams.startingAmount) * 339 | 100 340 | ).toFixed(2)}%`, 341 | date: dateToString(runResultsParams.runMetaData.highestAmountDate) 342 | }, 343 | { 344 | name: `Lowest ${historicalData.quote} Amount`, 345 | amount: runResultsParams.runMetaData.lowestAmount, 346 | percent: `${-( 347 | ((runResultsParams.startingAmount - runResultsParams.runMetaData.lowestAmount) / 348 | runResultsParams.startingAmount) * 349 | 100 350 | ).toFixed(2)}%`, 351 | date: dateToString(runResultsParams.runMetaData.lowestAmountDate) 352 | }, 353 | { 354 | name: 'Max Drawdown Amount', 355 | amount: runResultsParams.runMetaData.maxDrawdownAmount, 356 | percent: '-', 357 | date: runResultsParams.runMetaData.maxDrawdownAmountDates 358 | }, 359 | { 360 | name: 'Max Drawdown %', 361 | amount: '-', 362 | percent: `${+-runResultsParams.runMetaData.maxDrawdownPercent}%`, 363 | date: runResultsParams.runMetaData.maxDrawdownPercentDates 364 | }, 365 | { 366 | name: 'Number Of Candles', 367 | amount: runResultsParams.runMetaData.numberOfCandles, 368 | percent: '-', 369 | date: `Duration: ${getDiffInDays(runResultsParams.startTime, runResultsParams.endTime)}` 370 | }, 371 | { 372 | name: 'Number Of Candles Invested', 373 | amount: runResultsParams.runMetaData.numberOfCandlesInvested, 374 | percent: `${diffInDaysCandlesInvestedPercentage.toFixed(2)}%`, 375 | date: `Duration: ${diffInDaysCandlesInvested}` 376 | } 377 | ] 378 | 379 | // Create trade win / loss amounts and percentages 380 | const trades = [ 381 | { 382 | name: 'Amount Of Winning Trades', 383 | amount: runResultStats.winningTradeAmount, 384 | percent: `${runResultStats.winRatePercent}%`, 385 | date: '-' 386 | }, 387 | { 388 | name: 'Amount Of Losing Trades', 389 | amount: runResultStats.losingTradeAmount, 390 | percent: `${runResultStats.lossRatePercent}%`, 391 | date: '-' 392 | }, 393 | { 394 | name: 'Average Wins', 395 | amount: round(runResultStats.averageWinAmount), 396 | percent: `${runResultStats.averageWinPercent.toFixed(2)}%`, 397 | date: '-' 398 | }, 399 | { 400 | name: 'Average Losses', 401 | amount: round(runResultStats.averageLossAmount), 402 | percent: `${runResultStats.averageLossPercent.toFixed(2)}%`, 403 | date: '-' 404 | }, 405 | { 406 | name: 'Highest Trade Win Amount', 407 | amount: runResultStats.highestTradeWin, 408 | percent: '-', 409 | date: runResultStats.highestTradeWinDate 410 | }, 411 | { 412 | name: 'Highest Trade Win %', 413 | amount: '-', 414 | percent: `${runResultStats.highestTradeWinPercentage}%`, 415 | date: runResultStats.highestTradeWinPercentageDate 416 | }, 417 | { 418 | name: 'Highest Trade Loss Amount', 419 | amount: runResultStats.highestTradeLoss, 420 | percent: '-', 421 | date: runResultStats.highestTradeLossDate 422 | }, 423 | { 424 | name: 'Highest Trade Loss %', 425 | amount: '-', 426 | percent: `${runResultStats.highestTradeLossPercentage}%`, 427 | date: runResultStats.highestTradeLossPercentageDate 428 | }, 429 | { 430 | name: 'Average Trade Result %', 431 | amount: '-', 432 | percent: `${runResultStats.averageTradePercent.toFixed(2)}%`, 433 | date: '-' 434 | } 435 | ] 436 | 437 | // Create trade buy / sell amounts 438 | const tradeBuySellAmounts = [ 439 | { name: 'Amount Of Buys', amount: runResultStats.buyAmount, date: '-' }, 440 | { name: 'Amount Of Sells', amount: runResultStats.sellAmount, date: '-' }, 441 | { name: 'Average Buy Amount', amount: round(runResultStats.averageBuyAmount), date: '-' }, 442 | { name: 'Average Sell Amount', amount: round(runResultStats.averageSellAmount), date: '-' }, 443 | { name: 'Highest Buy Amount', amount: runResultStats.highestBuyAmount, date: runResultStats.highestBuyAmountDate }, 444 | { 445 | name: 'Highest Sell Amount', 446 | amount: runResultStats.highestSellAmount, 447 | date: runResultStats.highestSellAmountDate 448 | }, 449 | { name: 'Lowest Buy Amount', amount: runResultStats.lowestBuyAmount, date: runResultStats.lowestBuyAmountDate }, 450 | { name: 'Lowest Sell Amount', amount: runResultStats.lowestSellAmount, date: runResultStats.lowestSellAmountDate } 451 | ] 452 | 453 | // Create total asset amounts / percentages 454 | const assetAmountsPercentages = [ 455 | { 456 | name: `Start ${historicalData.base} Amount`, 457 | amount: runResultsParams.runMetaData.startingAssetAmount, 458 | percent: '-', 459 | date: dateToString(runResultsParams.runMetaData.startingAssetAmountDate) 460 | }, 461 | { 462 | name: `End ${historicalData.base} Amount`, 463 | amount: runResultsParams.runMetaData.endingAssetAmount, 464 | percent: '-', 465 | date: dateToString(runResultsParams.runMetaData.endingAssetAmountDate) 466 | }, 467 | { 468 | name: `${historicalData.base} ${ 469 | runResultsParams.runMetaData.startingAssetAmount < runResultsParams.runMetaData.endingAssetAmount 470 | ? 'Went Up' 471 | : 'Went Down' 472 | }`, 473 | amount: 474 | runResultsParams.runMetaData.startingAssetAmount < runResultsParams.runMetaData.endingAssetAmount 475 | ? round(runResultsParams.runMetaData.endingAssetAmount - runResultsParams.runMetaData.startingAssetAmount) 476 | : round(runResultsParams.runMetaData.startingAssetAmount - runResultsParams.runMetaData.endingAssetAmount), 477 | percent: `${-( 478 | ((runResultsParams.runMetaData.startingAssetAmount - runResultsParams.runMetaData.endingAssetAmount) / 479 | runResultsParams.runMetaData.startingAssetAmount) * 480 | 100 481 | ).toFixed(2)}%`, 482 | date: `Duration: ${getDiffInDays( 483 | runResultsParams.runMetaData.startingAssetAmountDate, 484 | runResultsParams.runMetaData.endingAssetAmountDate 485 | )}` 486 | }, 487 | { 488 | name: `${historicalData.base} Highest`, 489 | amount: runResultsParams.runMetaData.highestAssetAmount, 490 | percent: `${-( 491 | ((runResultsParams.runMetaData.startingAssetAmount - runResultsParams.runMetaData.highestAssetAmount) / 492 | runResultsParams.runMetaData.startingAssetAmount) * 493 | 100 494 | ).toFixed(2)}%`, 495 | date: dateToString(runResultsParams.runMetaData.highestAssetAmountDate) 496 | }, 497 | { 498 | name: `${historicalData.base} Lowest`, 499 | amount: runResultsParams.runMetaData.lowestAssetAmount, 500 | percent: `${-( 501 | ((runResultsParams.runMetaData.startingAssetAmount - runResultsParams.runMetaData.lowestAssetAmount) / 502 | runResultsParams.runMetaData.startingAssetAmount) * 503 | 100 504 | ).toFixed(2)}%`, 505 | date: dateToString(runResultsParams.runMetaData.lowestAssetAmountDate) 506 | }, 507 | { 508 | name: `${historicalData.base} Lowest To Highest`, 509 | amount: runResultsParams.runMetaData.highestAssetAmount - runResultsParams.runMetaData.lowestAssetAmount, 510 | percent: `${-( 511 | ((runResultsParams.runMetaData.lowestAssetAmount - runResultsParams.runMetaData.highestAssetAmount) / 512 | runResultsParams.runMetaData.lowestAssetAmount) * 513 | 100 514 | ).toFixed(2)}%`, 515 | date: `Duration: ${getDiffInDays( 516 | runResultsParams.runMetaData.highestAssetAmountDate < runResultsParams.runMetaData.lowestAssetAmountDate 517 | ? runResultsParams.runMetaData.highestAssetAmountDate 518 | : runResultsParams.runMetaData.lowestAssetAmountDate, 519 | runResultsParams.runMetaData.highestAssetAmountDate < runResultsParams.runMetaData.lowestAssetAmountDate 520 | ? runResultsParams.runMetaData.lowestAssetAmountDate 521 | : runResultsParams.runMetaData.highestAssetAmountDate 522 | )}` 523 | } 524 | ] 525 | 526 | // Create param objects 527 | let paramsArray = Object.entries(runResultsParams.params).map(([key, value]) => ({ 528 | name: `Parameter - ${key}`, 529 | value: value 530 | })) 531 | 532 | // Create table for general data 533 | const generalData = [ 534 | { name: 'Strategy Name', value: runResultsParams.strategyName }, 535 | { name: 'Symbol', value: historicalData.symbol }, 536 | { name: 'Symbol Base', value: historicalData.base }, 537 | { name: 'Quote', value: historicalData.quote }, 538 | { name: 'Interval', value: historicalData.interval }, 539 | { name: 'Tax Fee (%)', value: runResultsParams.txFee }, 540 | { name: 'Slippage (%)', value: runResultsParams.slippage }, 541 | { name: 'Exported', value: dateToString(new Date()) } 542 | ] 543 | 544 | // Push params array into the general data 545 | generalData.splice(1, 0, ...paramsArray) 546 | 547 | // Return all the statistical results 548 | return { totals, assetAmountsPercentages, trades, tradeBuySellAmounts, generalData } 549 | } 550 | 551 | async function _parseRunResultsStatsMulti(runResultsParams: StrategyResultMulti) { 552 | if (!runResultsParams?.symbols?.length) { 553 | throw new BacktestError(`Symbols not specified`, ErrorCode.MissingInput) 554 | } 555 | 556 | // Get candle metadata 557 | const historicalData = await getCandleMetaData(runResultsParams.symbols[0]) 558 | if (!historicalData) { 559 | throw new BacktestError(`Problem getting the ${runResultsParams.symbols[0]} metaData`, ErrorCode.NotFound) 560 | } 561 | 562 | const multiSymbol = runResultsParams.isMultiSymbol 563 | const quoteName = multiSymbol ? 'MULTI' : historicalData.quote 564 | const assetAmounts = runResultsParams.multiResults[0].assetAmounts 565 | const totalDuration = `Duration: ${getDiffInDays(runResultsParams.startTime, runResultsParams.endTime)}` 566 | 567 | // Calculate the highest maxDrawdownAmount 568 | const highestDrawdownAmount = Math.max(...runResultsParams.multiResults.map((obj) => obj.maxDrawdownAmount)) 569 | 570 | // Calculate the highest maxDrawdownPercent 571 | const highestDrawdownPercent = Math.max(...runResultsParams.multiResults.map((obj) => obj.maxDrawdownPercent)) 572 | 573 | // Calculate the lowest maxDrawdownAmount 574 | const lowestDrawdownAmount = Math.min(...runResultsParams.multiResults.map((obj) => obj.maxDrawdownAmount)) 575 | 576 | // Calculate the lowest maxDrawdownPercent 577 | const lowestDrawdownPercent = Math.min(...runResultsParams.multiResults.map((obj) => obj.maxDrawdownPercent)) 578 | 579 | // Calculate the average drawdown amount 580 | const totalDrawdownAmount = runResultsParams.multiResults.reduce((acc, obj) => acc + obj.maxDrawdownAmount, 0) 581 | const averageDrawdownAmount = (totalDrawdownAmount / runResultsParams.multiResults.length).toFixed(2) 582 | 583 | // Calculate the average drawdown percent 584 | const totalDrawdownPercent = runResultsParams.multiResults.reduce((acc, obj) => acc + obj.maxDrawdownPercent, 0) 585 | const averageDrawdownPercent = (totalDrawdownPercent / runResultsParams.multiResults.length).toFixed(2) 586 | 587 | // Calculate the average endAmount 588 | const totalEndAmount = runResultsParams.multiResults.reduce((acc, obj) => acc + obj.endAmount, 0) 589 | const averageEndAmount = +(totalEndAmount / runResultsParams.multiResults.length).toFixed(2) 590 | 591 | // Calculate the highest endAmount 592 | const highestEndAmount = Math.max(...runResultsParams.multiResults.map((obj) => obj.endAmount)) 593 | 594 | // Calculate the lowest endAmount 595 | const lowestEndAmount = Math.min(...runResultsParams.multiResults.map((obj) => obj.endAmount)) 596 | 597 | // Create total amounts 598 | const totals = [ 599 | { 600 | name: `Start ${quoteName} Amount`, 601 | amount: runResultsParams.startingAmount, 602 | percent: '-', 603 | date: multiSymbol ? '-' : dateToString(runResultsParams.startTime) 604 | }, 605 | { 606 | name: 'Number Of Candles', 607 | amount: multiSymbol ? '-' : assetAmounts.numberOfCandles, 608 | percent: '-', 609 | date: multiSymbol ? '-' : totalDuration 610 | }, 611 | { 612 | name: `Average Ending ${quoteName} Amount`, 613 | amount: averageEndAmount, 614 | percent: `${-( 615 | ((runResultsParams.startingAmount - averageEndAmount) / runResultsParams.startingAmount) * 616 | 100 617 | ).toFixed(2)}%`, 618 | date: multiSymbol ? '-' : totalDuration 619 | }, 620 | { 621 | name: `Highest Ending ${quoteName} Amount`, 622 | amount: highestEndAmount, 623 | percent: `${-( 624 | ((runResultsParams.startingAmount - highestEndAmount) / runResultsParams.startingAmount) * 625 | 100 626 | ).toFixed(2)}%`, 627 | date: multiSymbol ? '-' : totalDuration 628 | }, 629 | { 630 | name: `Lowest Ending ${quoteName} Amount`, 631 | amount: lowestEndAmount, 632 | percent: `${-( 633 | ((runResultsParams.startingAmount - lowestEndAmount) / runResultsParams.startingAmount) * 634 | 100 635 | ).toFixed(2)}%`, 636 | date: multiSymbol ? '-' : totalDuration 637 | }, 638 | { 639 | name: 'Average Drawdown', 640 | amount: averageDrawdownAmount, 641 | percent: `${averageDrawdownPercent}%`, 642 | date: multiSymbol ? '-' : totalDuration 643 | }, 644 | { 645 | name: 'Highest Drawdown', 646 | amount: highestDrawdownAmount, 647 | percent: `${highestDrawdownPercent}%`, 648 | date: multiSymbol ? '-' : totalDuration 649 | }, 650 | { 651 | name: 'Lowest Drawdown', 652 | amount: lowestDrawdownAmount, 653 | percent: `${lowestDrawdownPercent.toFixed(2)}%`, 654 | date: multiSymbol ? '-' : totalDuration 655 | } 656 | ] 657 | 658 | // Create total asset amounts / percentages 659 | const assetAmountsPercentages = [ 660 | { 661 | name: `Start ${historicalData.base} Amount`, 662 | amount: assetAmounts.startingAssetAmount, 663 | percent: '-', 664 | date: dateToString(runResultsParams.startTime) 665 | }, 666 | { 667 | name: `End ${historicalData.base} Amount`, 668 | amount: assetAmounts.endingAssetAmount, 669 | percent: '-', 670 | date: dateToString(runResultsParams.endTime) 671 | }, 672 | { 673 | name: `${historicalData.base} ${ 674 | assetAmounts.startingAssetAmount < assetAmounts.endingAssetAmount ? 'Went Up' : 'Went Down' 675 | }`, 676 | amount: 677 | assetAmounts.startingAssetAmount < assetAmounts.endingAssetAmount 678 | ? round(assetAmounts.endingAssetAmount - assetAmounts.startingAssetAmount) 679 | : round(assetAmounts.startingAssetAmount - assetAmounts.endingAssetAmount), 680 | percent: `${-( 681 | ((assetAmounts.startingAssetAmount - assetAmounts.endingAssetAmount) / assetAmounts.startingAssetAmount) * 682 | 100 683 | ).toFixed(2)}%`, 684 | date: totalDuration 685 | }, 686 | { 687 | name: `${historicalData.base} Highest`, 688 | amount: assetAmounts.highestAssetAmount, 689 | percent: `${-( 690 | ((assetAmounts.startingAssetAmount - assetAmounts.highestAssetAmount) / assetAmounts.startingAssetAmount) * 691 | 100 692 | ).toFixed(2)}%`, 693 | date: dateToString(assetAmounts.highestAssetAmountDate) 694 | }, 695 | { 696 | name: `${historicalData.base} Lowest`, 697 | amount: assetAmounts.lowestAssetAmount, 698 | percent: `${-( 699 | ((assetAmounts.startingAssetAmount - assetAmounts.lowestAssetAmount) / assetAmounts.startingAssetAmount) * 700 | 100 701 | ).toFixed(2)}%`, 702 | date: dateToString(assetAmounts.lowestAssetAmountDate) 703 | }, 704 | { 705 | name: `${historicalData.base} Lowest To Highest`, 706 | amount: assetAmounts.highestAssetAmount - assetAmounts.lowestAssetAmount, 707 | percent: `${-( 708 | ((assetAmounts.lowestAssetAmount - assetAmounts.highestAssetAmount) / assetAmounts.lowestAssetAmount) * 709 | 100 710 | ).toFixed(2)}%`, 711 | date: `Duration: ${getDiffInDays( 712 | assetAmounts.highestAssetAmountDate < assetAmounts.lowestAssetAmountDate 713 | ? assetAmounts.highestAssetAmountDate 714 | : assetAmounts.lowestAssetAmountDate, 715 | assetAmounts.highestAssetAmountDate < assetAmounts.lowestAssetAmountDate 716 | ? assetAmounts.lowestAssetAmountDate 717 | : assetAmounts.highestAssetAmountDate 718 | )}` 719 | } 720 | ] 721 | 722 | // Create param objects 723 | let paramsArray = Object.entries(runResultsParams.params).map(([key, value]) => ({ 724 | name: `Parameter - ${key}`, 725 | value: value 726 | })) 727 | 728 | // Create table for general data 729 | let generalData 730 | if (multiSymbol) { 731 | generalData = [ 732 | { name: 'Strategy Name', value: runResultsParams.strategyName }, 733 | { name: 'Permutation Count', value: runResultsParams.permutationCount }, 734 | { name: 'Symbols', value: runResultsParams.symbols }, 735 | { name: 'Interval', value: historicalData.interval }, 736 | { name: 'TX Fee', value: runResultsParams.txFee }, 737 | { name: 'Slippage', value: runResultsParams.slippage } 738 | ] 739 | } else { 740 | generalData = [ 741 | { name: 'Strategy Name', value: runResultsParams.strategyName }, 742 | { name: 'Permutation Count', value: runResultsParams.permutationCount }, 743 | { name: 'Symbol', value: historicalData.symbol }, 744 | { name: 'Base', value: historicalData.base }, 745 | { name: 'Quote', value: historicalData.quote }, 746 | { name: 'Interval', value: historicalData.interval }, 747 | { name: 'TX Fee', value: runResultsParams.txFee }, 748 | { name: 'Slippage', value: runResultsParams.slippage } 749 | ] 750 | } 751 | 752 | // Push params array into the general data 753 | generalData.splice(1, 0, ...paramsArray) 754 | 755 | // Return all the statistical results 756 | return { totals, assetAmountsPercentages, generalData } 757 | } 758 | 759 | export function generatePermutations(params: LooseObject): any[] { 760 | // Convert the string values to arrays of numbers 761 | const processedParams: { [key: string]: number[] } = {} 762 | for (const key in params) { 763 | processedParams[key] = `${params[key]}`.split(',').map(Number) 764 | logger.trace(`Processed param ${key}: ${processedParams[key]}`) 765 | } 766 | 767 | // Helper function to generate all combinations 768 | function* cartesianProduct(arrays: number[][], index = 0): Generator { 769 | if (index === arrays.length) { 770 | yield [] 771 | return 772 | } 773 | 774 | for (const value of arrays[index]) { 775 | for (const rest of cartesianProduct(arrays, index + 1)) { 776 | yield [value, ...rest] 777 | } 778 | } 779 | } 780 | 781 | // Generate all combinations 782 | const keys = Object.keys(processedParams) 783 | const values = Object.values(processedParams) 784 | const permutations: any[] = [] 785 | 786 | for (const combination of cartesianProduct(values)) { 787 | const permutation: any = {} 788 | keys.forEach((key, idx) => { 789 | permutation[key] = combination[idx] 790 | }) 791 | permutations.push(permutation) 792 | } 793 | 794 | return permutations 795 | } 796 | 797 | export function calculateSharpeRatio(entries: LooseObject, riskFreeRateAnnual = 0.02) { 798 | if (entries.length < 2) { 799 | return 10000 // Not enough data 800 | } 801 | 802 | // Convert the first two timestamps to Date objects and calculate the interval in days 803 | const intervalMs = new Date(entries[1].time).getTime() - new Date(entries[0].time).getTime() 804 | const intervalDays = intervalMs / (24 * 60 * 60 * 1000) 805 | 806 | // Calculate the number of intervals in one year 807 | const intervalsPerYear = 365.25 / intervalDays 808 | 809 | const startTime = new Date(entries[0].time).getTime() 810 | const endTime = new Date(entries[entries.length - 1].time).getTime() 811 | 812 | // Ensure data covers at least one year 813 | if (endTime - startTime < 365.25 * 24 * 60 * 60 * 1000) { 814 | return 10000 // Not covering at least one year 815 | } 816 | 817 | let returns: number[] = [] 818 | for (let i = 1; i < entries.length; i++) { 819 | const returnVal = (entries[i].close - entries[i - 1].close) / entries[i - 1].close 820 | returns.push(returnVal) 821 | } 822 | 823 | // Convert the annual risk-free rate to the equivalent rate for the interval 824 | const riskFreeRateInterval = Math.pow(1 + riskFreeRateAnnual, intervalDays / 365.25) - 1 825 | 826 | let excessReturns = returns.map((r) => r - riskFreeRateInterval) 827 | const averageExcessReturn = excessReturns.reduce((a, b) => a + b, 0) / excessReturns.length 828 | const stdDevExcessReturn = Math.sqrt( 829 | excessReturns.reduce((sum, r) => sum + Math.pow(r - averageExcessReturn, 2), 0) / (excessReturns.length - 1) 830 | ) 831 | 832 | // Calculate Sharpe Ratio, annualized based on the interval 833 | return (averageExcessReturn / stdDevExcessReturn) * Math.sqrt(intervalsPerYear) 834 | } 835 | --------------------------------------------------------------------------------