diff --git a/dist/index.html b/dist/index.html index 8a046fa..ea3ebfa 100644 --- a/dist/index.html +++ b/dist/index.html @@ -1,120 +1,123 @@ - - Ada Trader - - - - - - -
-
-

Ada Trader

-
- -
- -
- -
-

Quotes

-
-
    -
-
+ + Ada Trader + + + + + + +
+
+

Ada Trader

+
+ +
+ +
+ +
+

Quotes

+
+
    +
-
-
+
-
-

Trade History

-
-
    -
-
-
+
+
+

Trade History

+
+
    +
+
+
+
+ +
-
- -
- -
-
-

Open Orders

-
-
    -
-
-
-
- -
-
-

Order Entry Form

-
- - - - - - - -
-
-
-
- -
+
+
+
+

Open Orders

+
+
    +
+
+
-
-
-
- -
-
- - - - - - +
+ + + + + + - - + + diff --git a/spec/models/order_spec.js b/spec/models/order_spec.js new file mode 100644 index 0000000..9cc8e8d --- /dev/null +++ b/spec/models/order_spec.js @@ -0,0 +1,89 @@ +import Order from 'models/order'; + +describe('Order spec', () => { + let buyOrder; + let sellOrder; + beforeEach(() => { + buyOrder = new Order({ + symbol: 'HELLO', + targetPrice: 90.00, + marketPrice: 100.00, + buy: true + }); + sellOrder = new Order({ + symbol: 'HELLO', + targetPrice: 100.00, + marketPrice: 90.00, + buy: false + }) + }); + + describe('Order validations', () => { + it('is not valid unless it has a symbol', () => { + + expect(buyOrder.isValid()).toBeTruthy(); + + expect(sellOrder.isValid()).toBeTruthy(); + + buyOrder.set('symbol', null); + + expect(buyOrder.isValid()).toBeFalsy(); + + sellOrder.set('symbol', null); + + expect(sellOrder.isValid()).toBeFalsy(); + }) + + it('is not valid unless it has a numerical targetPrice', () => { + expect(buyOrder.isValid()).toBeTruthy(); + + expect(sellOrder.isValid()).toBeTruthy(); + + const badTargetPrices = [null, 'strings', '.', '-1'] + + badTargetPrices.forEach(function(element) { + + buyOrder.set('targetPrice', element); + + sellOrder.set('targetPrice', element); + + expect(buyOrder.isValid()).toBeFalsy(); + + expect(sellOrder.isValid()).toBeFalsy(); + }) + }) + + it('must follow buy low, sell high', () => { + expect(buyOrder.isValid()).toBeTruthy(); + + expect(sellOrder.isValid()).toBeTruthy(); + + buyOrder.set('targetPrice', '110'); + + sellOrder.set('targetPrice', '80'); + + expect(buyOrder.isValid()).toBeFalsy(); + + expect(sellOrder.isValid()).toBeFalsy(); + + }) + }); + + describe('Execute order', () => { + it('triggers a "buy" event on itself if new buyOrder is valid', () => { + const changeInfo = {symbol: 'HELLO', buy: true, currentPrice: 80} + spyOn(buyOrder, "trigger") + buyOrder.executeOrder(changeInfo) + + expect(buyOrder.trigger).toHaveBeenCalledWith('buy', changeInfo); + }); + + it('triggers a "sell" event on itself if new sellOrder is valid', () => { + const changeInfo = {symbol: 'HELLO', buy: false, currentPrice: 110} + spyOn(sellOrder, "trigger") + sellOrder.executeOrder(changeInfo) + + expect(sellOrder.trigger).toHaveBeenCalledWith('sell', changeInfo); + }); + }); +}); diff --git a/src/app.js b/src/app.js index 03ec910..ecf0520 100644 --- a/src/app.js +++ b/src/app.js @@ -1,11 +1,22 @@ +// CSS import 'foundation-sites/dist/foundation.css'; import 'css/app.css'; +// Vendor Modules import $ from 'jquery'; +import _ from 'underscore'; -import Simulator from 'models/simulator'; -import QuoteList from 'collections/quote_list'; +// Models +import Simulator from './models/simulator'; +import QuoteList from './collections/quote_list'; +import OrderList from './collections/order_list'; +import Bus from './models/event_bus'; +// Views +import QuoteListView from './views/quote_list_view'; +import OrderListView from './views/order_list_view'; + +// Data const quoteData = [ { symbol: 'HUMOR', @@ -25,11 +36,37 @@ const quoteData = [ }, ]; + $(document).ready(function() { - const quotes = new QuoteList(quoteData); + + const quoteList = new QuoteList(quoteData); + + const orderList = new OrderList; + const simulator = new Simulator({ - quotes: quotes, + quotes: quoteList, }); + const bus = new Bus; + simulator.start(); + + const quoteListView = new QuoteListView({ + model: quoteList, + template: _.template($('#quote-template').html()), + tradeTemplate: _.template($('#trade-template').html()), + el: 'main', + orderList: orderList, + bus: bus + }); + + const orderListView = new OrderListView({ + model: orderList, + template: _.template($('#order-template').html()), + el: 'main', + quoteList: quoteList, + bus: bus + }); + + quoteListView.render(); }); diff --git a/src/collections/order_list.js b/src/collections/order_list.js new file mode 100644 index 0000000..6b77957 --- /dev/null +++ b/src/collections/order_list.js @@ -0,0 +1,8 @@ +import Backbone from 'backbone'; +import Order from '../models/order'; + +const OrderList = Backbone.Collection.extend({ + model: Order, +}); + +export default OrderList; diff --git a/src/collections/quote_list.js b/src/collections/quote_list.js index 8da08cb..bfa112d 100644 --- a/src/collections/quote_list.js +++ b/src/collections/quote_list.js @@ -1,5 +1,5 @@ import Backbone from 'backbone'; -import Quote from 'models/quote'; +import Quote from '../models/quote'; const QuoteList = Backbone.Collection.extend({ model: Quote, diff --git a/src/models/event_bus.js b/src/models/event_bus.js new file mode 100644 index 0000000..b95a159 --- /dev/null +++ b/src/models/event_bus.js @@ -0,0 +1,7 @@ +import Backbone from 'backbone'; + +const Bus = Backbone.Model.extend({ + +}); + +export default Bus; diff --git a/src/models/order.js b/src/models/order.js new file mode 100644 index 0000000..00f542e --- /dev/null +++ b/src/models/order.js @@ -0,0 +1,35 @@ +import Backbone from 'backbone'; + +const Order = Backbone.Model.extend({ + validate: function(attributes) { + const errors = {}; + if (!attributes.targetPrice) { + errors['Price'] = ['cannot be blank']; + } else if (isNaN(attributes.targetPrice)) { + errors['Price'] = ['must be a number - leave off $']; + } else if (attributes.targetPrice <= 0){ + errors['Price'] = ['must be greater than 0'] + } else if (attributes.buy && attributes.targetPrice > attributes.marketPrice) { + errors['Price'] = ['cannot be higher than market price for buy orders']; + } else if (!attributes.buy && attributes.targetPrice < attributes.marketPrice) { + errors['Price'] = ['cannot be lower than market price for sell orders']; + } else if (!attributes.symbol) { + errors['Symbol'] = ['cannot be blank']; + } + if (Object.keys(errors).length > 0) { + return errors; + } else { + return false; + } + }, + executeOrder: function(changeInfo) { + if (this.get('symbol') === changeInfo.symbol && this.get('buy') === true && changeInfo.currentPrice <= this.get('targetPrice')) { + this.trigger('buy', changeInfo); + } + if (this.get('symbol') === changeInfo.symbol && this.get('buy') === false && changeInfo.currentPrice >= this.get('targetPrice')) { + this.trigger('sell', changeInfo); + } + } +}); + +export default Order; diff --git a/src/models/quote.js b/src/models/quote.js index 4fbf466..63be309 100644 --- a/src/models/quote.js +++ b/src/models/quote.js @@ -7,11 +7,11 @@ const Quote = Backbone.Model.extend({ }, buy() { - // Implement this function to increase the price by $1.00 + return this.set('price', (this.get('price') + 1.00)); }, sell() { - // Implement this function to decrease the price by $1.00 + return this.set('price', (this.get('price') - 1.00)); }, }); diff --git a/src/views/order_list_view.js b/src/views/order_list_view.js new file mode 100644 index 0000000..97c52a4 --- /dev/null +++ b/src/views/order_list_view.js @@ -0,0 +1,55 @@ +import Backbone from 'backbone'; +import OrderView from './order_view'; +import Order from '../models/order'; + +const OrderListView = Backbone.View.extend({ + initialize(params) { + this.template = params.template; + this.listenTo(this.model, 'update', this.render); + this.quoteListView = params.quoteListView; + this.bus = params.bus; + this.quoteList = params.quoteList; + }, + render() { + this.$('#orders').empty(); + this.model.each((order) => { + const orderView = new OrderView({ + model: order, + template: this.template, + tagName: 'li', + className: 'order', + bus: this.bus + }); + this.$('#orders').append(orderView.render().$el); + }); + return this; + }, + events: { + 'click .btn-buy': 'addOrder', + 'click .btn-sell': 'addOrder' + }, + addOrder: function(event) { + event.preventDefault(); + const orderData = this.$(event.target).attr('class').includes('btn-buy') ? { buy: true } : {buy: false} + orderData['symbol'] = this.$('select :selected').text(); + const stringTargetPrice = this.$(`input[name=price-target]`).val(); + orderData['targetPrice'] = parseFloat(stringTargetPrice); + orderData['marketPrice'] = this.quoteList.where({symbol: orderData['symbol']})[0].attributes.price; + const newOrder = new Order(orderData); + if (newOrder.isValid()) { + this.model.add(newOrder); + newOrder.listenTo(this.bus, 'priceChange', newOrder.executeOrder); + this.$('.order-entry-form [name=price-target]').val(""); + this.$('.form-errors').empty(); + } else { + this.$('.form-errors').empty(); + for(let key in newOrder.validationError) { + newOrder.validationError[key].forEach((error) => { + this.$('.form-errors').append(`

${key}: ${error}

`); + }) + } + } + }, +}) + +export default OrderListView diff --git a/src/views/order_view.js b/src/views/order_view.js new file mode 100644 index 0000000..27926df --- /dev/null +++ b/src/views/order_view.js @@ -0,0 +1,30 @@ +import Backbone from 'backbone'; + +const OrderView = Backbone.View.extend({ + initialize(params) { + this.template = params.template; + this.bus = params.bus; + this.listenTo(this.model, 'change', this.render); + this.listenTo(this.model, 'buy', this.triggerBuy); + this.listenTo(this.model, 'sell', this.triggerSell); + }, + render() { + const compiledTemplate = this.template(this.model.toJSON()); + this.$el.html(compiledTemplate); + return this; + }, + events: { + 'click button.btn-cancel': 'deleteOrder', + }, + deleteOrder: function() { + this.model.destroy(); + }, + triggerBuy: function(changeInfo) { + this.bus.trigger('buyOrder', {quote: changeInfo, model: this.model}); + }, + triggerSell: function(changeInfo) { + this.bus.trigger('sellOrder', {quote: changeInfo, model: this.model}); + } +}) + +export default OrderView; diff --git a/src/views/quote_list_view.js b/src/views/quote_list_view.js new file mode 100644 index 0000000..f0527bf --- /dev/null +++ b/src/views/quote_list_view.js @@ -0,0 +1,50 @@ +import Backbone from 'backbone'; +import QuoteView from './quote_view'; +//import Quote from '../models/quote'; + +const QuoteListView = Backbone.View.extend({ + initialize(params) { + this.template = params.template; + this.tradeTemplate = params.tradeTemplate; + this.orderList = params.orderList; + this.listenTo(this.model, 'update', this.render); + this.bus = params.bus; + }, + render() { + this.$('#quotes').empty(); + this.$('select').empty(); + this.model.each((quote) => { + const quoteView = new QuoteView({ + model: quote, + template: this.template, + tradeTemplate: this.tradeTemplate, + bus: this.bus, + tagName: 'li', + className: 'quote', + }); + this.listenTo(quoteView, 'buy', this.displayBuy); + + this.listenTo(quoteView, 'sell', this.displaySell); + + this.listenTo(quoteView, 'priceChange', this.alertPriceChange); + + this.$('#quotes').append(quoteView.render().$el); + + this.$('select').append(``) + + + }); + return this; + }, + displayBuy(quoteView) { + this.$('#trades').prepend(this.tradeTemplate({buy: true, symbol: quoteView.model.get('symbol'), price: quoteView.model.get('price')})) + }, + displaySell(quoteView) { + this.$('#trades').prepend(this.tradeTemplate({buy: false, symbol: quoteView.model.get('symbol'), price: quoteView.model.get('price')})) + }, + alertPriceChange(changeInfo) { + this.trigger('priceChange', changeInfo); + } +}); + +export default QuoteListView; diff --git a/src/views/quote_view.js b/src/views/quote_view.js new file mode 100644 index 0000000..264c975 --- /dev/null +++ b/src/views/quote_view.js @@ -0,0 +1,49 @@ +import Backbone from 'backbone'; +//import Quote from '../models/quote'; + +const QuoteView = Backbone.View.extend({ + initialize(params) { + this.template = params.template; + this.bus = params.bus; + this.listenTo(this.model, 'change', this.render); + this.listenTo(this.model, 'change', this.alertPriceChange); + this.listenTo(this.bus, 'buyOrder', this.buyOrder); + this.listenTo(this.bus, 'sellOrder', this.sellOrder); + }, + render() { + const compiledTemplate = this.template(this.model.toJSON()); + this.$el.html(compiledTemplate); + return this; + }, + alertPriceChange() { + this.bus.trigger('priceChange', {symbol: this.model.get('symbol'), currentPrice: this.model.get('price')}); + }, + events: { + 'click button.btn-buy': 'buyQuote', + 'click button.btn-sell': 'sellQuote' + }, + buyQuote: function() { + this.trigger('buy', this); + this.model.buy(); + }, + sellQuote: function() { + this.trigger('sell', this); + this.model.sell(); + }, + buyOrder: function(changeInfo) { + if (this.model.get(`symbol`) === changeInfo.quote.symbol && this.model.get('price') <= changeInfo.quote.currentPrice) { + this.trigger('buy', this); + this.model.buy(); + changeInfo.model.destroy(); + } + }, + sellOrder: function(changeInfo) { + if (this.model.get(`symbol`) === changeInfo.quote.symbol && this.model.get('price') >= changeInfo.quote.currentPrice) { + this.trigger('sell', this); + this.model.sell(); + changeInfo.model.destroy(); + } + } +}) + +export default QuoteView;