├── .github ├── CODEOWNERS └── workflows │ ├── node.js.yml │ └── npm-publish.yml ├── .gitignore ├── .npmignore ├── src ├── Security │ ├── Security.js │ └── Security.test.js ├── Position │ ├── Position.test.js │ └── Position.js ├── MarketData │ ├── MarketData.js │ └── MarketData.test.js ├── index.test.js ├── index.js ├── Websocket │ ├── Websocket.js │ └── Websocket.test.js └── Portfolio │ ├── Portfolio.js │ └── Portfolio.test.js ├── package.json ├── LICENSE └── README.md /.github/CODEOWNERS: -------------------------------------------------------------------------------- 1 | * @KendelChopp 2 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | dist 2 | node_modules 3 | coverage 4 | .DS_STORE 5 | -------------------------------------------------------------------------------- /.npmignore: -------------------------------------------------------------------------------- 1 | dist 2 | node_modules 3 | coverage 4 | *.test.* 5 | .github 6 | .DS_STORE 7 | -------------------------------------------------------------------------------- /src/Security/Security.js: -------------------------------------------------------------------------------- 1 | /** 2 | * Underlying class tracking the price of a Security 3 | */ 4 | class Security { 5 | constructor(symbol, data) { 6 | this.symbol = symbol; 7 | this.price = 0; 8 | this.data = data; 9 | } 10 | } 11 | 12 | module.exports = Security; 13 | -------------------------------------------------------------------------------- /src/Position/Position.test.js: -------------------------------------------------------------------------------- 1 | const Position = require('./Position.js'); 2 | 3 | describe('Position', () => { 4 | let symbol, position; 5 | 6 | beforeEach(() => { 7 | symbol = 'AAPL'; 8 | position = new Position(symbol); 9 | }); 10 | 11 | test('sets the correct variables', () => { 12 | expect(position.symbol).toBe(symbol); 13 | expect(position.quantity).toBe(0); 14 | }); 15 | }); 16 | -------------------------------------------------------------------------------- /src/Position/Position.js: -------------------------------------------------------------------------------- 1 | /** 2 | * A position that has a symbol and some quantity 3 | */ 4 | class Position { 5 | /** 6 | * Constructor for building a Position 7 | * 8 | * @param {string} symbol - The symbol for the position 9 | * @param {number} quantity - The number open 10 | */ 11 | constructor(symbol, quantity=0) { 12 | this.symbol = symbol; 13 | this.quantity = quantity; 14 | } 15 | } 16 | 17 | module.exports = Position; 18 | -------------------------------------------------------------------------------- /src/Security/Security.test.js: -------------------------------------------------------------------------------- 1 | const Security = require('./Security.js'); 2 | 3 | describe('Security', () => { 4 | let symbol, data, security; 5 | 6 | beforeEach(() => { 7 | data = { some: 'data' }; 8 | symbol = 'AAPL'; 9 | security = new Security(symbol, data); 10 | }); 11 | 12 | test('sets the correct variables', () => { 13 | expect(security.symbol).toBe(symbol); 14 | expect(security.price).toBe(0); 15 | expect(security.data).toBe(data); 16 | }); 17 | }); 18 | -------------------------------------------------------------------------------- /.github/workflows/node.js.yml: -------------------------------------------------------------------------------- 1 | # This workflow will do a clean install of node dependencies, build the source code and run tests across different versions of node 2 | # For more information see: https://help.github.com/actions/language-and-framework-guides/using-nodejs-with-github-actions 3 | 4 | name: Node.js CI 5 | 6 | on: 7 | push: 8 | branches: [ master ] 9 | pull_request: 10 | branches: [ master ] 11 | 12 | jobs: 13 | build: 14 | 15 | runs-on: ubuntu-latest 16 | 17 | strategy: 18 | matrix: 19 | node-version: [12.x] 20 | 21 | steps: 22 | - uses: actions/checkout@v2 23 | - name: Use Node.js ${{ matrix.node-version }} 24 | uses: actions/setup-node@v1 25 | with: 26 | node-version: ${{ matrix.node-version }} 27 | - run: npm install 28 | - run: npm test 29 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "@kendelchopp/alpaca-js-backtesting", 3 | "version": "1.1.3", 4 | "description": "", 5 | "main": "src/index.js", 6 | "scripts": { 7 | "test": "jest --collect-coverage", 8 | "ci": "npm run test" 9 | }, 10 | "repository": { 11 | "type": "git", 12 | "url": "git+https://github.com/KendelChopp/alpaca-js-backtesting.git" 13 | }, 14 | "author": "Kendel Chopp", 15 | "license": "MIT", 16 | "keywords": [ 17 | "alpaca", 18 | "backtesting" 19 | ], 20 | "bugs": { 21 | "url": "https://github.com/KendelChopp/alpaca-js-backtesting/issues" 22 | }, 23 | "homepage": "https://github.com/KendelChopp/alpaca-js-backtesting#readme", 24 | "devDependencies": { 25 | "jest": "^26.4.2", 26 | "q": "^1.5.1" 27 | }, 28 | "dependencies": { 29 | "lodash": "^4.17.20" 30 | } 31 | } 32 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2020 Kendel Chopp 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /.github/workflows/npm-publish.yml: -------------------------------------------------------------------------------- 1 | # This workflow will run tests using node and then publish a package to GitHub Packages when a release is created 2 | # For more information see: https://help.github.com/actions/language-and-framework-guides/publishing-nodejs-packages 3 | 4 | name: Node.js Package 5 | 6 | on: 7 | release: 8 | types: [created] 9 | 10 | jobs: 11 | build: 12 | runs-on: ubuntu-latest 13 | steps: 14 | - uses: actions/checkout@v2 15 | - uses: actions/setup-node@v1 16 | with: 17 | node-version: 12 18 | - run: npm install 19 | - run: npm test 20 | 21 | publish-npm: 22 | needs: build 23 | runs-on: ubuntu-latest 24 | steps: 25 | - uses: actions/checkout@v2 26 | - uses: actions/setup-node@v1 27 | with: 28 | node-version: 12 29 | registry-url: https://registry.npmjs.org/ 30 | - run: npm install 31 | - run: npm publish 32 | env: 33 | NODE_AUTH_TOKEN: ${{secrets.npm_token}} 34 | 35 | publish-gpr: 36 | needs: build 37 | runs-on: ubuntu-latest 38 | steps: 39 | - uses: actions/checkout@v2 40 | - uses: actions/setup-node@v1 41 | with: 42 | node-version: 12 43 | registry-url: https://npm.pkg.github.com/ 44 | - run: npm install 45 | - run: npm publish 46 | env: 47 | NODE_AUTH_TOKEN: ${{secrets.GITHUB_TOKEN}} 48 | -------------------------------------------------------------------------------- /src/MarketData/MarketData.js: -------------------------------------------------------------------------------- 1 | const _ = require('lodash'); 2 | 3 | const Security = require('../Security/Security.js'); 4 | 5 | /** 6 | * Class tracking market data and time for all relevant securities 7 | */ 8 | class MarketData { 9 | /** 10 | * Initialize a new MarketData object 11 | */ 12 | constructor() { 13 | this.securities = {}; 14 | this.time = 0; 15 | this.maxTime = 0; 16 | } 17 | 18 | /** 19 | * Adds security with data from Alpaca data 20 | * 21 | * @param {string} symbol - the symbol for the security 22 | * @param {Object[]} data - The array of data points from Alpaca 23 | */ 24 | addSecurity(symbol, data) { 25 | // TODO: Convert data to a separate class to ensure it is structured 26 | this.securities[symbol] = new Security(symbol, data); 27 | this.maxTime = Math.max(data.length, this.maxTime); 28 | } 29 | 30 | /** 31 | * Simulates a minute by mapping the security's information for that minute and updating the 32 | * current price of all the securities 33 | * 34 | * @returns {Object[]} object containing subjects and data 35 | */ 36 | simulateMinute() { 37 | const validSecurities = _.filter(this.securities, (security) => Boolean(security.data[this.time])); 38 | const dataMap = _.map(validSecurities, (security) => { 39 | security.price = security.data[this.time].closePrice; 40 | return { 41 | subject: `AM.${security.symbol}`, 42 | data: { 43 | ...security.data[this.time], 44 | ev: 'AM', 45 | symbol: security.symbol 46 | } 47 | }; 48 | }); 49 | 50 | this.time++; 51 | return dataMap; 52 | } 53 | 54 | /** 55 | * Whether or not there is data for the simulation to continue 56 | * 57 | * @type {boolean} 58 | */ 59 | get hasData() { 60 | return this.time < this.maxTime; 61 | } 62 | 63 | /** 64 | * Gets the current price of a security based on the symbol 65 | * 66 | * @param {string} symbol - the symbol for the security 67 | * @returns {number} the value of the security 68 | */ 69 | getPrice(symbol) { 70 | const security = this.securities[symbol]; 71 | return security.price; 72 | } 73 | } 74 | 75 | module.exports = MarketData; 76 | -------------------------------------------------------------------------------- /src/index.test.js: -------------------------------------------------------------------------------- 1 | const Websocket = require('./Websocket/Websocket.js'); 2 | 3 | const Backtest = require('./index.js'); 4 | 5 | describe('Backtest', () => { 6 | let alpaca, startDate, endDate, backtest; 7 | 8 | beforeEach(() => { 9 | alpaca = { some: 'alpaca object' }; 10 | startDate = new Date(2020, 0, 1); 11 | endDate = new Date(2020, 1, 1); 12 | backtest = new Backtest({ alpaca, startDate, endDate }); 13 | }); 14 | 15 | test('sets up the websocket', () => { 16 | expect(backtest.data_ws).toBeInstanceOf(Websocket); 17 | }); 18 | 19 | describe('constructor', () => { 20 | describe('when no alpaca is passed', () => { 21 | test('throws an error', () => { 22 | expect(() => { 23 | new Backtest(); 24 | }).toThrow('Missing alpaca object'); 25 | }); 26 | }); 27 | 28 | describe('when no startDate is passed', () => { 29 | test('throws an error', () => { 30 | expect(() => { 31 | new Backtest({ alpaca }); 32 | }).toThrow('You must provide a start date'); 33 | }); 34 | }); 35 | 36 | describe('when no endDate is passed', () => { 37 | test('throws an error', () => { 38 | expect(() => { 39 | new Backtest({ alpaca, startDate }); 40 | }).toThrow('You must provide an end date'); 41 | }); 42 | }); 43 | }); 44 | 45 | describe('createOrder', () => { 46 | beforeEach(() => { 47 | jest.spyOn(backtest._portfolio, 'createOrder').mockImplementation(); 48 | }); 49 | 50 | test('defers to the portfolio create order', () => { 51 | const options = { some: 'options' }; 52 | backtest.createOrder(options); 53 | expect(backtest._portfolio.createOrder).toBeCalledWith(options); 54 | }); 55 | }); 56 | 57 | describe('getStats', () => { 58 | let stats; 59 | 60 | beforeEach(() => { 61 | stats = { some: 'stats' }; 62 | jest.spyOn(backtest._portfolio, 'getStats').mockReturnValue(stats); 63 | }); 64 | 65 | test('defers to the portfolio stats', () => { 66 | const backtestStats = backtest.getStats(); 67 | expect(backtest._portfolio.getStats).toBeCalled(); 68 | expect(backtestStats).toEqual(stats); 69 | }); 70 | }); 71 | }); 72 | -------------------------------------------------------------------------------- /src/index.js: -------------------------------------------------------------------------------- 1 | const MarketData = require('./MarketData/MarketData.js'); 2 | const Portfolio = require('./Portfolio/Portfolio.js'); 3 | const Websocket = require('./Websocket/Websocket.js'); 4 | 5 | /** 6 | * Main class defining the high level functions and members. Functions similarly to Alpaca 7 | */ 8 | class Backtest { 9 | /** 10 | * Constructor for the back tester 11 | * 12 | * @param {object} options - The options 13 | * @param {Alpaca} options.alpaca - Your initialized Alpaca API 14 | * @param {number} options.startValue - Optional starting value (defaults to $100,000) 15 | * @param {Date} options.startDate - a required start date to test historically with 16 | * @param {Date} options.endDate - a required end date to test historical data on 17 | */ 18 | constructor({ alpaca, startValue = 100000, startDate, endDate } = {}) { 19 | if (!alpaca) { 20 | throw new Error('Missing alpaca object'); 21 | } 22 | 23 | if (!startDate) { 24 | throw new Error('You must provide a start date'); 25 | } 26 | 27 | if (!endDate) { 28 | throw new Error('You must provide an end date'); 29 | } 30 | 31 | this._marketData = new MarketData(); 32 | this._portfolio = new Portfolio(startValue, this._marketData); 33 | this.data_ws = new Websocket(alpaca, this._marketData, startDate, endDate); 34 | } 35 | 36 | /** 37 | * Create an order through the portfolio 38 | * 39 | * @param {string} options.symbol - The symbol for the order 40 | * @param {number} options.qty - Quantity of the order 41 | * @param {string} options.side - 'buy' or 'sell' 42 | * @param {string} options.type - currently only supports 'market' 43 | * @param {string} options.time_in_force - not yet supported 44 | * @param {number} options.limit_price - not yet supported 45 | * @param {number} options.stop_price - not yet supported 46 | */ 47 | createOrder(options) { 48 | this._portfolio.createOrder(options); 49 | } 50 | 51 | /** 52 | * Get the portfolio stats including start value, end value, and the return on investiment (ROI) 53 | * 54 | * @returns {object} The statistics for the portfolio 55 | */ 56 | getStats() { 57 | return this._portfolio.getStats(); 58 | } 59 | }; 60 | 61 | module.exports = Backtest; 62 | -------------------------------------------------------------------------------- /src/Websocket/Websocket.js: -------------------------------------------------------------------------------- 1 | const _ = require('lodash'); 2 | 3 | /** 4 | * Websocket class mirroring Alpaca's and implementing its functionality for backtesting 5 | */ 6 | class Websocket { 7 | /** 8 | * Create a websocket with an API key and market data 9 | * 10 | * @param {Alpaca} alpaca - the Alpaca object used to read market data 11 | * @param {MarketData} marketData - the market data tracking the prices 12 | * @param {Date} start - The start date for testing the data 13 | * @param {Date} end - The end date for the testing data 14 | */ 15 | constructor(alpaca, marketData, start, end) { 16 | this._alpaca = alpaca; 17 | this._marketData = marketData; 18 | this._startDate = start; 19 | this._endDate = end; 20 | } 21 | 22 | /** 23 | * Sets up a callback for when a connection is established 24 | * 25 | * @param {Function} connectCallback - The callback after a connection is opened 26 | */ 27 | onConnect(connectCallback) { 28 | this.connectCallback = connectCallback; 29 | } 30 | 31 | /** 32 | * Sets up a callback for when a connection is disconnected or closed 33 | * 34 | * @param {Function} disconnectCallback - The callback after a connection is closed 35 | */ 36 | onDisconnect(disconnectCallback) { 37 | this.disconnectCallback = disconnectCallback; 38 | } 39 | 40 | /** 41 | * Sets up a callback for when an aggregate minute channel updates. This is usually where 42 | * opening and closing of positions will occurr 43 | * 44 | * @param {Function} stockAggMinCallback - The callback when receiving an AM channel update 45 | */ 46 | onStockAggMin(stockAggMinCallback) { 47 | this.stockAggMinCallback = stockAggMinCallback; 48 | } 49 | 50 | /** 51 | * Subscribe to some number of channels 52 | * Currently supports AM.* channels 53 | * 54 | * @param {string[]} channels - The channels to subscribe to 55 | */ 56 | subscribe(channels) { 57 | this.channels = channels; 58 | } 59 | 60 | /** 61 | * Reads in the historical data from alpaca using `alpaca.getBars` 62 | */ 63 | async loadData() { 64 | const rawChannels = _.map(this.channels, (channel) => { 65 | const isV1Minute = channel.startsWith('alpacadatav1/AM.'); 66 | const isMinute = channel.startsWith('AM.'); 67 | if (!isMinute && !isV1Minute) { 68 | throw new Error('Only minute aggregates are supported at this time.'); 69 | } 70 | 71 | return isMinute ? channel.substring(3) : channel.substring(16); 72 | }); 73 | 74 | const channelString = _.join(rawChannels, ','); 75 | 76 | const channelData = await this._alpaca.getBars( 77 | '1Min', 78 | channelString, 79 | { 80 | start: this._startDate, 81 | end: this._endDate 82 | } 83 | ); 84 | 85 | _.forEach(rawChannels, (channel) => { 86 | this._marketData.addSecurity(channel, channelData[channel]); 87 | }); 88 | } 89 | 90 | /** 91 | * Runs the simulation calling the stock aggregate minute callback every simulated minute with 92 | * the market data for that minute 93 | */ 94 | runSimulation() { 95 | if (!this.stockAggMinCallback) { 96 | return; 97 | } 98 | 99 | while (this._marketData.hasData) { 100 | const simulatedSecurities = this._marketData.simulateMinute(); 101 | _.forEach(simulatedSecurities, (security) => { 102 | this.stockAggMinCallback(security.subject, security.data); 103 | }); 104 | } 105 | } 106 | 107 | /** 108 | * Simulate connecting to the API, loads the data, and runs the simulation 109 | */ 110 | async connect() { 111 | if (this.connectCallback) { 112 | this.connectCallback(); 113 | } 114 | 115 | await this.loadData(); 116 | this.runSimulation(); 117 | 118 | if (this.disconnectCallback) { 119 | this.disconnectCallback(); 120 | } 121 | } 122 | } 123 | 124 | module.exports = Websocket; 125 | -------------------------------------------------------------------------------- /src/MarketData/MarketData.test.js: -------------------------------------------------------------------------------- 1 | const Security = require('../Security/Security.js'); 2 | 3 | const MarketData = require('./MarketData.js'); 4 | 5 | describe('MarketData', () => { 6 | let marketData; 7 | 8 | beforeEach(() => { 9 | marketData = new MarketData(); 10 | }); 11 | 12 | test('sets the correct defaults', () => { 13 | expect(marketData.maxTime).toBe(0); 14 | expect(marketData.time).toBe(0); 15 | expect(marketData.securities).toEqual({}); 16 | }); 17 | 18 | describe('addSecurity', () => { 19 | let data, symbol; 20 | 21 | beforeEach(() => { 22 | data = [{ some: 'data' }, { some: 'other data' }]; 23 | symbol = 'AAPL'; 24 | }); 25 | 26 | test('adds a new security', () => { 27 | marketData.addSecurity(symbol, data); 28 | const newSecurity = marketData.securities[symbol]; 29 | expect(newSecurity).toBeInstanceOf(Security); 30 | expect(newSecurity.data).toBe(data); 31 | expect(newSecurity.symbol).toBe(symbol); 32 | }); 33 | 34 | describe('when max time is less than data.length', () => { 35 | beforeEach(() => { 36 | marketData.maxTime = 0; 37 | }); 38 | 39 | test('sets maxTime to data.length', () => { 40 | marketData.addSecurity(symbol, data); 41 | expect(marketData.maxTime).toBe(data.length); 42 | }); 43 | }); 44 | 45 | describe('when max time is less than data.length', () => { 46 | let maxTime; 47 | 48 | beforeEach(() => { 49 | maxTime = 10; 50 | marketData.maxTime = maxTime; 51 | }); 52 | 53 | test('does not change maxTime', () => { 54 | marketData.addSecurity(symbol, data); 55 | expect(marketData.maxTime).toBe(maxTime); 56 | }); 57 | }); 58 | }); 59 | 60 | describe('simulateMinute', () => { 61 | let closePrice, validSecurity, invalidSecurity; 62 | 63 | beforeEach(() => { 64 | closePrice = 123; 65 | validSecurity = new Security('SPY', [{ closePrice }]); 66 | invalidSecurity = new Security('AAPL', []); 67 | 68 | marketData.securities = [validSecurity, invalidSecurity]; 69 | marketData.time = 0; 70 | }); 71 | 72 | test('updates the valid security price', () => { 73 | marketData.simulateMinute(); 74 | expect(validSecurity.price).toBe(closePrice); 75 | }); 76 | 77 | test('adds one to the current time', () => { 78 | marketData.simulateMinute(); 79 | expect(marketData.time).toBe(1); 80 | }); 81 | 82 | test('returns a map of the valid securities with subject and data', () => { 83 | const simulation = marketData.simulateMinute(); 84 | const expected = [{ 85 | subject: `AM.${validSecurity.symbol}`, 86 | data: { 87 | closePrice, 88 | ev: 'AM', 89 | symbol: validSecurity.symbol 90 | } 91 | }]; 92 | 93 | expect(simulation).toEqual(expected); 94 | }); 95 | }); 96 | 97 | describe('hasData', () => { 98 | describe('when time is less than maxTime', () => { 99 | test('returns true', () => { 100 | marketData.time = 0; 101 | marketData.maxTime = 1; 102 | expect(marketData.hasData).toBe(true); 103 | }); 104 | }); 105 | 106 | describe('when time is equal to maxTime', () => { 107 | test('returns false', () => { 108 | marketData.time = 1; 109 | marketData.maxTime = 1; 110 | expect(marketData.hasData).toBe(false); 111 | }); 112 | }); 113 | 114 | describe('when time is greater than maxTime', () => { 115 | test('returns false', () => { 116 | marketData.time = 2; 117 | marketData.maxTime = 1; 118 | expect(marketData.hasData).toBe(false); 119 | }); 120 | }); 121 | }); 122 | 123 | describe('getPrice', () => { 124 | let price, symbol; 125 | 126 | beforeEach(() => { 127 | symbol = 'AAPL'; 128 | price = 12; 129 | const security = new Security(symbol, []); 130 | security.price = price; 131 | marketData.securities = { [symbol]: security }; 132 | }); 133 | 134 | test('returns the price of the requested security', () => { 135 | expect(marketData.getPrice(symbol)).toBe(price); 136 | }); 137 | }); 138 | }); 139 | -------------------------------------------------------------------------------- /src/Portfolio/Portfolio.js: -------------------------------------------------------------------------------- 1 | const _ = require('lodash'); 2 | 3 | const Position = require('../Position/Position.js'); 4 | 5 | /** 6 | * A class representing cash and all of the positions taken 7 | */ 8 | class Portfolio { 9 | /** 10 | * Create a portfolio with some cash and market data 11 | * 12 | * @param {number} startValue - The amount of cash to start with 13 | * @param {MarketData} marketData - The market data to draw prices from 14 | */ 15 | constructor(startValue, marketData) { 16 | this.cash = startValue; 17 | this.startValue = startValue; 18 | this.positions = []; 19 | this.marketData = marketData; 20 | } 21 | 22 | /** 23 | * Gets a position from the current list of positions 24 | * 25 | * @param {string} symbol - The symbol for a given security 26 | * @returns {Position} The position represented by the symbol if it exists 27 | */ 28 | getPosition(symbol) { 29 | return _.find(this.positions, (position) => (position.symbol === symbol)); 30 | } 31 | 32 | /** 33 | * Finds a position in the user's list of positions or it creates a new one 34 | * 35 | * @param {string} symbol - the symbol for the position 36 | * @returns {Position} The new position or the one that already existed 37 | */ 38 | findOrCreatePosition(symbol) { 39 | let foundPosition = this.getPosition(symbol); 40 | if (!foundPosition) { 41 | foundPosition = new Position(symbol); 42 | this.positions.push(foundPosition); 43 | } 44 | 45 | return foundPosition; 46 | } 47 | 48 | /** 49 | * Create an order 50 | * 51 | * @param {string} options.symbol - The symbol for the order 52 | * @param {number} options.qty - Quantity of the order 53 | * @param {string} options.side - 'buy' or 'sell' 54 | * @param {string} options.type - currently only supports 'market' 55 | * @param {string} options.time_in_force - not yet supported 56 | * @param {number} options.limit_price - not yet supported 57 | * @param {number} options.stop_price - not yet supported 58 | */ 59 | createOrder(options) { 60 | if (!options.symbol) { 61 | throw new Error('No symbol provided for order.'); 62 | } 63 | if (!options.qty || options.qty < 1) { 64 | throw new Error('Quantity must be >= 1 to create an order.'); 65 | } 66 | if (!options.side || (options.side !== 'sell' && options.side !== 'buy')) { 67 | throw new Error('Side not provided correctly. Must be buy or sell.'); 68 | } 69 | 70 | const currentPrice = this.marketData.getPrice(options.symbol); 71 | 72 | if (options.side === 'sell') { 73 | const position = this.getPosition(options.symbol); 74 | if (position && position.quantity >= options.qty) { 75 | this.cash += options.qty * currentPrice; 76 | position.quantity -= options.qty; 77 | 78 | if (position.quantity == 0) { 79 | _.remove(this.positions, (position) => (position.quantity == 0)); 80 | } 81 | } else { 82 | // TODO: improve warning handling here 83 | console.warn('Attempted to sell more of a position than in portfolio'); 84 | } 85 | } else { 86 | if (this.cash < options.qty * currentPrice) { 87 | // TODO: improve warning handling here 88 | console.warn('Order not executed, not enough cash'); 89 | } else { 90 | const position = this.findOrCreatePosition(options.symbol); 91 | this.cash -= options.qty * currentPrice; 92 | position.quantity += options.qty; 93 | } 94 | } 95 | } 96 | 97 | /** 98 | * Sums up the cash and value of all positions in the account 99 | * 100 | * @returns {number} The total value of the Portfolio 101 | */ 102 | getValue() { 103 | const positionValue = _.sumBy(this.positions, (position) => { 104 | const price = this.marketData.getPrice(position.symbol); 105 | 106 | return price * position.quantity; 107 | }); 108 | 109 | return positionValue + this.cash; 110 | } 111 | 112 | /** 113 | * Get some simple stats on the porfolio: starting value, end value, and return on investment 114 | * 115 | * @returns {object} An object containing the statistics for the portfolio 116 | */ 117 | getStats() { 118 | const endValue = this.getValue(); 119 | const roi = (endValue / this.startValue) - 1; 120 | 121 | return { 122 | startValue: this.startValue, 123 | endValue, 124 | roi 125 | } 126 | } 127 | } 128 | 129 | module.exports = Portfolio; 130 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # @kendelchopp/alpaca-js-backtesting 2 | ## Disclaimer 3 | This package is in no way affiliated with Alpaca 4 | 5 | ## Getting Started 6 | ### Installing the package 7 | ``` 8 | $ npm install @kendelchopp/alpaca-js-backtesting 9 | ``` 10 | 11 | ### Writing the code 12 | 1. Initialize your Alpaca object with the keys 13 | 14 | ```JavaScript 15 | const Alpaca = require('@alpacahq/alpaca-trade-api'); 16 | 17 | const alpaca = new Alpaca({ 18 | keyId: process.env.API_KEY, 19 | secretKey: process.env.SECRET_API_KEY, 20 | paper: true, 21 | usePolygon: false 22 | }); 23 | ``` 24 | 25 | 2. Initialize your Backtest object 26 | 27 | **Note:** When passing in your alpaca object, you are giving this package the ability to use your keys to make API requests. This package will make API requests for the market data so that it can run the simulation. Be careful when sending out your alpaca object. 28 | 29 | ```JavaScript 30 | const Backtest = require('@kendelchopp/alpaca-js-backtesting'); 31 | 32 | const backtest = new Backtest({ 33 | alpaca, 34 | startDate: new Date(2020, 0, 1), 35 | endDate: new Date(2020, 1, 1) 36 | }); 37 | ``` 38 | 39 | 3. Run your algorithm replacing Alpaca/websockets accordingly. 40 | 41 | For example (commented out lines are the old lines to be replaced): 42 | ```JavaScript 43 | // const client = alpaca.data_ws; 44 | const client = backtest.data_ws; 45 | 46 | // alpaca.createOrder({...}) 47 | backtest.createOrder({...}) 48 | ``` 49 | 50 | 4. Add a disconnect handler to see how your algorithm performed 51 | 52 | ```JavaScript 53 | client.onDisconnect(() => { 54 | console.log(backtest.getStats()); 55 | }); 56 | ``` 57 | 58 | ## Alpaca Compatibility 59 | This table lists the version of @kendelchopp/alpaca-js-backtesting with the version of [alpaca-trade-api-js](https://github.com/alpacahq/alpaca-trade-api-js) it was tested with. Using other versions may require some syntax changes. 60 | 61 | | @kendelchopp/alpaca-js-backtesting | @alpacahq/alpaca-trade-api-js | 62 | | ---------------------------------- | ----------------------------- | 63 | | 1.0.0 | 1.4.1 | 64 | | 1.1.0 | 1.4.1 | 65 | | 1.1.1 | 1.4.1 | 66 | | 1.1.2 | 1.4.1 | 67 | | 1.1.3 | 1.4.1 | 68 | 69 | ## Relevant Functions 70 | ### Backtest#createOrder 71 | Creates an order using the same parameters as Alpaca#createOrder 72 | 73 | #### Example 74 | ```JavaScript 75 | backtest.createOrder({ 76 | symbol: 'SPY', 77 | qty: 300, 78 | side: 'sell', 79 | type: 'market', 80 | time_in_force: 'day' 81 | }) 82 | ``` 83 | 84 | ### Backtest#getStats 85 | Gets the stats for the portfolio including starting and ending value and ROI 86 | 87 | #### Example 88 | ```JavaScript 89 | console.log(backtest.getStats()); 90 | ``` 91 | 92 | ### Websocket#connect 93 | Simulates connecting to the API, loads the data, and runs the simulation 94 | 95 | #### Example 96 | ```JavaScript 97 | client.connect(); 98 | ``` 99 | 100 | ### Websocket#onConnect 101 | Runs a function when establishing the connection. When backtesting, you will usually just use this to subscribe to channels 102 | 103 | #### Example 104 | ```JavaScript 105 | client.onConnect(() => { 106 | client.subscribe(['alpacadatav1/AM.SPY']); 107 | }); 108 | ``` 109 | 110 | ### Websocket#onDisconnect 111 | Runs a function when the simulation is completed. You can print out the stats here. 112 | 113 | #### Example 114 | ```JavaScript 115 | client.onDisconnect(() => { 116 | console.log(backtest.getStats()); 117 | }); 118 | ``` 119 | 120 | ### Websocket#onStockAggMin 121 | This runs your function when a simulated minute occurrs and returns the relevant data. This is where your trading will likely take place. Subject will look something like `AM.SPY` and data will look like a standard bar data. 122 | 123 | #### Example 124 | ```JavaScript 125 | client.onStockAggMin((subject, data) => { 126 | if (data.closePrice < 500) { 127 | backtest.createOrder({ 128 | symbol: 'SPY', 129 | qty: 300, 130 | side: 'buy', 131 | type: 'market', 132 | time_in_force: 'day' 133 | }); 134 | 135 | console.log('Bought SPY'); 136 | } 137 | }); 138 | ``` 139 | 140 | ### Websocket#subscribe 141 | Subscribes to the channels listed. Currently, only aggregate minute channels are supported. 142 | 143 | #### Example 144 | ```JavaScript 145 | client.onConnect(() => { 146 | client.subscribe(['alpacadatav1/AM.SPY', 'alpacadatav1/AM.AAPL']); 147 | }); 148 | ``` 149 | -------------------------------------------------------------------------------- /src/Websocket/Websocket.test.js: -------------------------------------------------------------------------------- 1 | const _ = require('lodash'); 2 | const q = require('q'); 3 | 4 | const MarketData = require('../MarketData/MarketData.js'); 5 | 6 | const Websocket = require('./Websocket.js'); 7 | 8 | describe('Websocket', () => { 9 | let alpaca, marketData, start, end, websocket; 10 | 11 | beforeEach(() => { 12 | alpaca = { getBars: jest.fn() }; 13 | marketData = new MarketData(); 14 | start = new Date(2020, 6, 1); 15 | end = new Date(2020, 9, 1); 16 | 17 | websocket = new Websocket(alpaca, marketData, start, end); 18 | }); 19 | 20 | describe('onConnect', () => { 21 | test('sets the connect callback', () => { 22 | const someCallback = jest.fn(); 23 | websocket.onConnect(someCallback); 24 | expect(websocket.connectCallback).toBe(someCallback); 25 | }); 26 | }); 27 | 28 | describe('onDisconnect', () => { 29 | test('sets the disconnect callback', () => { 30 | const someCallback = jest.fn(); 31 | websocket.onDisconnect(someCallback); 32 | expect(websocket.disconnectCallback).toBe(someCallback); 33 | }); 34 | }); 35 | 36 | describe('onStockAggMin', () => { 37 | test('sets the stock agg min callback', () => { 38 | const someCallback = jest.fn(); 39 | websocket.onStockAggMin(someCallback); 40 | expect(websocket.stockAggMinCallback).toBe(someCallback); 41 | }); 42 | }); 43 | 44 | describe('subscribe', () => { 45 | test('sets the channels', () => { 46 | const channels = ['AM.SPY', 'AM.AAPL']; 47 | websocket.subscribe(channels); 48 | expect(websocket.channels).toBe(channels); 49 | }); 50 | }); 51 | 52 | describe('loadData', () => { 53 | describe('when there are only valid channels', () => { 54 | let barRequest, channels, channelString, rawChannels; 55 | 56 | beforeEach(() => { 57 | channels = ['AM.SPY', 'alpacadatav1/AM.AAPL']; 58 | rawChannels = ['SPY', 'AAPL']; 59 | channelString = 'SPY,AAPL'; 60 | websocket.channels = channels; 61 | 62 | barRequest = q.defer(); 63 | alpaca.getBars.mockReturnValue(barRequest.promise); 64 | }); 65 | 66 | test('calls alpaca.getBars with the correct arguments', () => { 67 | websocket.loadData(); 68 | expect(alpaca.getBars).toBeCalledWith( 69 | '1Min', 70 | channelString, 71 | { 72 | start, 73 | end 74 | } 75 | ); 76 | }); 77 | 78 | describe('after the bar request resolves', () => { 79 | let barData; 80 | 81 | beforeEach(() => { 82 | barData = { 83 | AAPL: { 84 | some: 'apple data' 85 | }, 86 | SPY: { 87 | some: 's&p data' 88 | } 89 | }; 90 | 91 | barRequest.resolve(barData); 92 | jest.spyOn(marketData, 'addSecurity'); 93 | }); 94 | 95 | test('calls add security with the correct data', async () => { 96 | await websocket.loadData(); 97 | _.forEach(rawChannels, (channel) => { 98 | expect(marketData.addSecurity).toBeCalledWith(channel, barData[channel]); 99 | }); 100 | }); 101 | }); 102 | }); 103 | 104 | describe('when there is an invalid channel', () => { 105 | test('throws an error', async () => { 106 | websocket.channels = ['AM.SPY', 'AS.AAPL']; 107 | try { 108 | await websocket.loadData() 109 | } catch (error) { 110 | expect(error.message).toBe('Only minute aggregates are supported at this time.'); 111 | } 112 | }); 113 | }); 114 | }); 115 | 116 | describe('runSimulation', () => { 117 | beforeEach(() => { 118 | jest.spyOn(marketData, 'simulateMinute').mockImplementation(); 119 | }); 120 | 121 | describe('when there is a stock agg min callback', () => { 122 | let callback, minuteCount, simulatedMinute; 123 | 124 | beforeEach(() => { 125 | callback = jest.fn(); 126 | websocket.stockAggMinCallback = callback; 127 | 128 | minuteCount = 0; 129 | jest.spyOn(marketData, 'hasData', 'get').mockImplementation(() => { 130 | return minuteCount++ < 1; 131 | }); 132 | 133 | simulatedMinute = [{ 134 | subject: 'AM.AAPL', 135 | data: { 136 | some: 'data' 137 | } 138 | }]; 139 | 140 | jest.spyOn(marketData, 'simulateMinute').mockReturnValue(simulatedMinute); 141 | }); 142 | 143 | test('calls the callback the correct number of times', () => { 144 | websocket.runSimulation(); 145 | expect(marketData.simulateMinute).toBeCalled(); 146 | expect(callback).toBeCalledWith(simulatedMinute[0].subject, simulatedMinute[0].data); 147 | }); 148 | }); 149 | 150 | describe('when there is not a stock agg min callback', () => { 151 | beforeEach(() => { 152 | websocket.stockAggMinCallback = null; 153 | }); 154 | 155 | test('is a no-op', () => { 156 | websocket.runSimulation(); 157 | expect(marketData.simulateMinute).not.toBeCalled(); 158 | }); 159 | }); 160 | }); 161 | 162 | describe('connect', () => { 163 | let dataRequest; 164 | 165 | beforeEach(() => { 166 | dataRequest = q.defer(); 167 | jest.spyOn(websocket, 'loadData').mockReturnValue(dataRequest.promise); 168 | jest.spyOn(websocket, 'runSimulation').mockImplementation(); 169 | }); 170 | 171 | describe('when there is a connect callback', () => { 172 | let callback; 173 | 174 | beforeEach(() => { 175 | callback = jest.fn(); 176 | websocket.connectCallback = callback; 177 | }); 178 | 179 | test('calls the callback', () => { 180 | websocket.connect(); 181 | expect(callback).toBeCalled(); 182 | }); 183 | }); 184 | 185 | test('loads the data', () => { 186 | websocket.connect(); 187 | expect(websocket.loadData).toBeCalled(); 188 | }); 189 | 190 | describe('after the data loads', () => { 191 | beforeEach(() => { 192 | dataRequest.resolve(); 193 | }); 194 | 195 | test('calls runSimulation', async () => { 196 | await websocket.connect(); 197 | expect(websocket.runSimulation).toBeCalled(); 198 | }); 199 | 200 | describe('when there is a disconnect callback', () => { 201 | let callback; 202 | 203 | beforeEach(() => { 204 | callback = jest.fn(); 205 | websocket.disconnectCallback = callback; 206 | }); 207 | 208 | test('calls the callback', async () => { 209 | await websocket.connect(); 210 | expect(callback).toBeCalled(); 211 | }); 212 | }); 213 | }); 214 | }); 215 | }); 216 | -------------------------------------------------------------------------------- /src/Portfolio/Portfolio.test.js: -------------------------------------------------------------------------------- 1 | const MarketData = require('../MarketData/MarketData.js'); 2 | const Position = require('../Position/Position.js'); 3 | 4 | const Portfolio = require('./Portfolio.js'); 5 | 6 | describe('Portfolio', () => { 7 | let portfolio, startValue, marketData; 8 | 9 | beforeEach(() => { 10 | startValue = 100; 11 | marketData = new MarketData(); 12 | 13 | portfolio = new Portfolio(startValue, marketData); 14 | }); 15 | 16 | test('sets the correct defaults', () => { 17 | expect(portfolio.cash).toBe(startValue); 18 | expect(portfolio.startValue).toBe(startValue); 19 | expect(portfolio.positions).toHaveLength(0); 20 | expect(portfolio.marketData).toBe(marketData); 21 | }); 22 | 23 | describe('getPosition', () => { 24 | let position, symbol; 25 | 26 | beforeEach(() => { 27 | symbol = 'AAPL'; 28 | position = new Position(symbol) 29 | portfolio.positions = [position]; 30 | }); 31 | 32 | describe('when the position exists', () => { 33 | test('returns the position', () => { 34 | expect(portfolio.getPosition(symbol)).toBe(position); 35 | }); 36 | }); 37 | 38 | describe('when the position does not exist', () => { 39 | test('returns undefined', () => { 40 | expect(portfolio.getPosition('nonsense')).toBeUndefined(); 41 | }); 42 | }); 43 | }); 44 | 45 | describe('findOrCreatePosition', () => { 46 | describe('when the position exists', () => { 47 | let position; 48 | 49 | beforeEach(() => { 50 | position = new Position('AAPL'); 51 | jest.spyOn(portfolio, 'getPosition').mockReturnValue(position); 52 | }); 53 | 54 | test('returns the found position', () => { 55 | expect(portfolio.findOrCreatePosition('AAPL')).toBe(position); 56 | }); 57 | }); 58 | 59 | describe('when the position does not exist', () => { 60 | beforeEach(() => { 61 | jest.spyOn(portfolio, 'getPosition').mockReturnValue(undefined); 62 | }); 63 | 64 | test('creates a new position and returns it', () => { 65 | const symbol = 'AAPL'; 66 | const newPosition = portfolio.findOrCreatePosition(symbol); 67 | const lastPosition = portfolio.positions[portfolio.positions.length - 1]; 68 | expect(lastPosition.symbol).toBe(symbol); 69 | expect(lastPosition).toBe(newPosition); 70 | expect(lastPosition).toBeInstanceOf(Position); 71 | }); 72 | }); 73 | }); 74 | 75 | describe('createOrder', () => { 76 | let options; 77 | 78 | beforeEach(() => { 79 | options = {}; 80 | }); 81 | 82 | const testThrowsError = (error) => { 83 | test('throws an error', () => { 84 | expect(() => portfolio.createOrder(options)).toThrow(error); 85 | }); 86 | }; 87 | 88 | describe('when symbol is provided', () => { 89 | let symbol; 90 | 91 | beforeEach(() => { 92 | symbol = 'AAPL'; 93 | options.symbol = symbol; 94 | }); 95 | 96 | describe('when quantity is supplied', () => { 97 | describe('when quantity is >= 1', () => { 98 | let quantity; 99 | 100 | beforeEach(() => { 101 | quantity = 2; 102 | options.qty = quantity; 103 | }); 104 | 105 | describe('when side is provided', () => { 106 | let currentPrice; 107 | 108 | beforeEach(() => { 109 | currentPrice = 10; 110 | 111 | jest.spyOn(console, 'warn').mockImplementation(); 112 | jest.spyOn(marketData, 'getPrice').mockReturnValue(currentPrice); 113 | }); 114 | 115 | const testGetsCurrentPrice = () => { 116 | test('gets the current price from market data', () => { 117 | portfolio.createOrder(options); 118 | expect(marketData.getPrice).toBeCalledWith(symbol); 119 | }); 120 | }; 121 | 122 | describe('when side is sell', () => { 123 | beforeEach(() => { 124 | options.side = 'sell'; 125 | jest.spyOn(portfolio, 'getPosition'); 126 | }); 127 | 128 | testGetsCurrentPrice(); 129 | 130 | test('gets the position', () => { 131 | portfolio.createOrder(options); 132 | expect(portfolio.getPosition).toBeCalledWith(symbol); 133 | }); 134 | 135 | describe('when the position exists', () => { 136 | let startingCash, position; 137 | 138 | beforeEach(() => { 139 | startingCash = 500; 140 | position = new Position(symbol); 141 | jest.spyOn(portfolio, 'getPosition').mockReturnValue(position); 142 | portfolio.positions = [position]; 143 | portfolio.cash = startingCash; 144 | }); 145 | 146 | describe('when the position quantity is greater than the options quantity', () => { 147 | beforeEach(() => { 148 | position.quantity = quantity + 1; 149 | }); 150 | 151 | test('adds the value to the cash', () => { 152 | portfolio.createOrder(options); 153 | expect(portfolio.cash).toBe(startingCash + quantity * currentPrice); 154 | }); 155 | 156 | test('subtracts the quantity from the position', () => { 157 | portfolio.createOrder(options); 158 | expect(position.quantity).toBe(1); 159 | }); 160 | }); 161 | 162 | describe('when the quantity is equal to the options quantity', () => { 163 | beforeEach(() => { 164 | position.quantity = quantity; 165 | }); 166 | 167 | test('adds the value to the cash', () => { 168 | portfolio.createOrder(options); 169 | expect(portfolio.cash).toBe(startingCash + quantity * currentPrice); 170 | }); 171 | 172 | test('subtracts the quantity from the position', () => { 173 | portfolio.createOrder(options); 174 | expect(position.quantity).toBe(0); 175 | }); 176 | 177 | test('removes it from the portfolio', () => { 178 | portfolio.createOrder(options); 179 | expect(portfolio.positions).toHaveLength(0); 180 | }); 181 | }); 182 | 183 | describe('when the quantity is less than the options quantity', () => { 184 | beforeEach(() => { 185 | position.quantity = quantity - 1; 186 | }); 187 | 188 | test('sends a warning message', () => { 189 | portfolio.createOrder(options); 190 | expect(console.warn).toBeCalledWith( 191 | 'Attempted to sell more of a position than in portfolio' 192 | ); 193 | }); 194 | }); 195 | }); 196 | 197 | describe('when the position does not exist', () => { 198 | beforeEach(() => { 199 | jest.spyOn(portfolio, 'getPosition').mockReturnValue(undefined); 200 | }); 201 | 202 | test('sends a warning message', () => { 203 | portfolio.createOrder(options); 204 | expect(console.warn).toBeCalledWith( 205 | 'Attempted to sell more of a position than in portfolio' 206 | ); 207 | }); 208 | }); 209 | }); 210 | 211 | describe('when side is buy', () => { 212 | beforeEach(() => { 213 | options.side = 'buy'; 214 | }); 215 | 216 | testGetsCurrentPrice(); 217 | 218 | describe('when there is enough cash', () => { 219 | let position, startingQuantity; 220 | 221 | beforeEach(() => { 222 | startingQuantity = 2; 223 | position = new Position('AAPL'); 224 | position.quantity = startingQuantity; 225 | portfolio.cash = quantity * currentPrice; 226 | 227 | jest.spyOn(portfolio, 'findOrCreatePosition').mockReturnValue(position); 228 | }); 229 | 230 | test('finds or creates the position', () => { 231 | portfolio.createOrder(options); 232 | expect(portfolio.findOrCreatePosition).toBeCalledWith(symbol); 233 | }); 234 | 235 | test('sets the quantity', () => { 236 | portfolio.createOrder(options); 237 | expect(position.quantity).toBe(startingQuantity + quantity); 238 | }); 239 | 240 | test('subtracts the cash', () => { 241 | portfolio.createOrder(options); 242 | expect(portfolio.cash).toBe(0); 243 | }); 244 | }); 245 | 246 | describe('when there is not enough cash', () => { 247 | beforeEach(() => { 248 | portfolio.cash = 0; 249 | }); 250 | 251 | test('sends a warning message', () => { 252 | portfolio.createOrder(options); 253 | expect(console.warn).toBeCalledWith('Order not executed, not enough cash'); 254 | }); 255 | }); 256 | }); 257 | 258 | describe('when side is neither buy nor sell', () => { 259 | beforeEach(() => { 260 | options.side = 'nonsense'; 261 | }); 262 | 263 | testThrowsError('Side not provided correctly. Must be buy or sell.'); 264 | }); 265 | }); 266 | 267 | describe('when side is not provided', () => { 268 | beforeEach(() => { 269 | delete options.side; 270 | }); 271 | 272 | testThrowsError('Side not provided correctly. Must be buy or sell.'); 273 | }); 274 | }); 275 | 276 | describe('when quantity is < 1', () => { 277 | beforeEach(() => { 278 | options.qty = -2; 279 | }); 280 | 281 | testThrowsError('Quantity must be >= 1 to create an order.'); 282 | }); 283 | }); 284 | 285 | describe('when quantity is not supplied', () => { 286 | beforeEach(() => { 287 | delete options.qty; 288 | }); 289 | 290 | testThrowsError('Quantity must be >= 1 to create an order.'); 291 | }); 292 | }); 293 | 294 | describe('when symbol is not provided', () => { 295 | beforeEach(() => { 296 | delete options.symbol; 297 | }); 298 | 299 | testThrowsError('No symbol provided for order.'); 300 | }); 301 | }); 302 | 303 | describe('getValue', () => { 304 | let cash, position, price; 305 | 306 | beforeEach(() => { 307 | cash = 150; 308 | price = 200; 309 | position = new Position('AAPL'); 310 | position.quantity = 2; 311 | portfolio.positions = [position]; 312 | portfolio.cash = cash; 313 | jest.spyOn(marketData, 'getPrice').mockReturnValue(price); 314 | }); 315 | 316 | test('gets the price of the position', () => { 317 | portfolio.getValue(); 318 | expect(marketData.getPrice).toBeCalledWith(position.symbol); 319 | }); 320 | 321 | test('adds up the values of the positions and cash', () => { 322 | expect(portfolio.getValue()).toBe(cash + position.quantity * price); 323 | }); 324 | }); 325 | 326 | describe('getStats', () => { 327 | let endValue, startValue; 328 | 329 | beforeEach(() => { 330 | endValue = 101; 331 | startValue = 100; 332 | jest.spyOn(portfolio, 'getValue').mockReturnValue(endValue); 333 | portfolio.startValue = startValue; 334 | }); 335 | 336 | test('returns the correct values', () => { 337 | expect(portfolio.getStats()).toEqual({ 338 | endValue, 339 | startValue, 340 | roi: (endValue / startValue) - 1 341 | }); 342 | }); 343 | }); 344 | }); 345 | --------------------------------------------------------------------------------