ig_markets 0.19 → 0.20

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,96 @@
1
+ module IGMarkets
2
+ module CLI
3
+ # This helper class supports the display of text in a fullscreen curses window.
4
+ #
5
+ # @private
6
+ class CursesWindow
7
+ # Initializes this fullscreen curses window.
8
+ def initialize
9
+ self.class.prepare
10
+
11
+ @window = Curses::Window.new 0, 0, 0, 0
12
+ @position = [0, 0]
13
+ end
14
+
15
+ # Clears the contents of this curses window and resets the cursor to the top left.
16
+ def clear
17
+ @window.clear
18
+ @position = [0, 0]
19
+ end
20
+
21
+ # Refreshes this curses window so its content is updated on the screen.
22
+ def refresh
23
+ @window.refresh
24
+ end
25
+
26
+ # Prints the specified lines in this fullscreen curses window.
27
+ #
28
+ # @param [Array<String>] lines The lines to print.
29
+ def print_lines(*lines)
30
+ change_foreground_color nil
31
+
32
+ lines.flatten.each do |line|
33
+ print_next_line_segment line until line.empty?
34
+
35
+ @position[0] += 1
36
+ @position[1] = 0
37
+ end
38
+ end
39
+
40
+ private
41
+
42
+ class << self
43
+ # Returns whether curses support is available. On Windows the 'curses' gem is optional and so this class may not
44
+ # be usable.
45
+ def available?
46
+ Kernel.require 'curses'
47
+ true
48
+ rescue LoadError
49
+ false
50
+ end
51
+
52
+ def prepare
53
+ raise IGMarketsError, 'curses gem is not installed' unless available?
54
+
55
+ return if @prepared
56
+
57
+ Curses.noecho
58
+ Curses.nonl
59
+ Curses.stdscr.nodelay = 1
60
+ Curses.init_screen
61
+ Curses.start_color
62
+
63
+ 8.times { |color| Curses.init_pair (30 + color), color, 0 }
64
+
65
+ @prepared = true
66
+ end
67
+ end
68
+
69
+ COLORIZE_REGEXP = /^\e\[0(?:;(\d+);49)?m/
70
+
71
+ def print_next_line_segment(line)
72
+ match = line.match COLORIZE_REGEXP
73
+
74
+ if match
75
+ change_foreground_color match.captures.first
76
+ line[/^[^m]+m/] = ''
77
+ else
78
+ print_character line[0]
79
+ line[0] = ''
80
+ end
81
+ end
82
+
83
+ def change_foreground_color(colorize_number)
84
+ colorize_number ||= 37
85
+
86
+ @window.attron Curses.color_pair(colorize_number.to_i) | Curses::A_NORMAL
87
+ end
88
+
89
+ def print_character(character)
90
+ @window.setpos @position[0], @position[1]
91
+ @window << character
92
+ @position[1] += 1
93
+ end
94
+ end
95
+ end
96
+ end
@@ -19,6 +19,9 @@ module IGMarkets
19
19
  desc 'sprints [SUBCOMAND=list ...]', 'Command for working with sprint market positions'
20
20
  subcommand 'sprints', Sprints
21
21
 
22
+ desc 'stream [SUBCOMAND=dashboard ...]', 'Command for working with display of streaming data'
23
+ subcommand 'stream', Stream
24
+
22
25
  desc 'watchlists [SUBCOMAND=list ...]', 'Command for working with watchlists'
23
26
  subcommand 'watchlists', Watchlists
24
27
 
@@ -17,6 +17,13 @@ module IGMarkets
17
17
  table.to_s
18
18
  end
19
19
 
20
+ # Returns the individual formatted lines that make up this table.
21
+ #
22
+ # @return [Array<String>]
23
+ def lines
24
+ to_s.split "\n"
25
+ end
26
+
20
27
  private
21
28
 
22
29
  def default_title
@@ -7,13 +7,14 @@ module IGMarkets
7
7
  attribute :status, Symbol, allowed_values: [:amended, :deleted, :fully_closed, :opened, :partially_closed]
8
8
  end
9
9
 
10
+ attribute :account_id
10
11
  attribute :affected_deals, AffectedDeal
11
12
  attribute :date, Time, format: '%FT%T.%L'
12
13
  attribute :deal_id
13
14
  attribute :deal_reference
14
15
  attribute :deal_status, Symbol, allowed_values: [:accepted, :fund_account, :rejected]
15
16
  attribute :direction, Symbol, allowed_values: [:buy, :sell]
16
- attribute :epic
17
+ attribute :epic, String, regex: Regex::EPIC
17
18
  attribute :expiry, Date, nil_if: %w(- DFB), format: ['%d-%b-%y', '%b-%y']
18
19
  attribute :guaranteed_stop, Boolean
19
20
  attribute :level, Float
@@ -9,6 +9,7 @@ module IGMarkets
9
9
  # - {#sprint_market_positions}
10
10
  # - {#watchlists}
11
11
  # - {#working_orders}
12
+ # - {#streaming}
12
13
  #
13
14
  # See `README.md` for examples.
14
15
  #
@@ -59,6 +60,11 @@ module IGMarkets
59
60
  # @return [WorkingOrderMethods]
60
61
  attr_reader :working_orders
61
62
 
63
+ # Methods for working with live streaming of IG Markets data.
64
+ #
65
+ # @return [StreamingMethods]
66
+ attr_reader :streaming
67
+
62
68
  def initialize
63
69
  @session = Session.new
64
70
 
@@ -69,6 +75,7 @@ module IGMarkets
69
75
  @sprint_market_positions = SprintMarketPositionMethods.new self
70
76
  @watchlists = WatchlistMethods.new self
71
77
  @working_orders = WorkingOrderMethods.new self
78
+ @streaming = StreamingMethods.new self
72
79
  end
73
80
 
74
81
  # Signs in to the IG Markets Dealing Platform, either the live platform or the demo platform.
@@ -93,6 +100,7 @@ module IGMarkets
93
100
 
94
101
  # Signs out of the IG Markets Dealing Platform, ending any current session.
95
102
  def sign_out
103
+ streaming.disconnect
96
104
  session.sign_out
97
105
  end
98
106
 
@@ -119,19 +127,6 @@ module IGMarkets
119
127
  instantiate_models Application, session.put('operations/application/disable')
120
128
  end
121
129
 
122
- # Creates a new Lightstreamer session instance from this dealing platform's {#session} that is ready to connect
123
- # and start streaming account and market data.
124
- #
125
- # @return [Lightstreamer::Session, nil] The new Lightstreamer session instance, or `nil` if there is no active
126
- # IG Markets session to connect through.
127
- def lightstreamer_session
128
- return nil unless session.alive?
129
-
130
- Lightstreamer::Session.new server_url: client_account_summary.lightstreamer_endpoint,
131
- username: client_account_summary.client_id,
132
- password: "CST-#{session.client_security_token}|XST-#{session.x_security_token}"
133
- end
134
-
135
130
  # This method is used to instantiate the various `Model` subclasses from data returned by the IG Markets API. It
136
131
  # recurses through arrays and sub-hashes present in `source`, instantiating the required models based on the types
137
132
  # of each attribute as defined on the models. All model instances returned by this method will have their
@@ -161,6 +156,18 @@ module IGMarkets
161
156
  end
162
157
  end
163
158
 
159
+ # This method is the same as {#instantiate_models} but takes an unparsed JSON string as its input.
160
+ #
161
+ # @param [Class] model_class The top-level model class to create from `json`.
162
+ # @param [String] json The JSON string to parse.
163
+ #
164
+ # @return [nil, `model_class`] The resulting instantiated model.
165
+ #
166
+ # @private
167
+ def instantiate_models_from_json(model_class, json)
168
+ instantiate_models model_class, ResponseParser.parse(JSON.parse(json))
169
+ end
170
+
164
171
  private
165
172
 
166
173
  # This method is a helper for {#instantiate_models} that prepares a source object for instantiation.
@@ -0,0 +1,173 @@
1
+ module IGMarkets
2
+ class DealingPlatform
3
+ # Provides methods for working with streaming of IG Markets data. Returned by {DealingPlatform#streaming}.
4
+ class StreamingMethods
5
+ # Initializes this class with the specified dealing platform.
6
+ #
7
+ # @param [DealingPlatform] dealing_platform The dealing platform.
8
+ def initialize(dealing_platform)
9
+ @dealing_platform = dealing_platform
10
+ @queue = Queue.new
11
+ @on_error_callbacks = []
12
+ end
13
+
14
+ # Connects the streaming session. Raises a `Lightstreamer::LightstreamerError` if an error occurs.
15
+ def connect
16
+ @lightstreamer.disconnect if @lightstreamer
17
+ @queue.clear
18
+
19
+ @lightstreamer = Lightstreamer::Session.new username: username, password: password, server_url: server_url
20
+ @lightstreamer.on_error { |error| @on_error_callbacks.each { |callback| callback.call error } }
21
+ @lightstreamer.connect
22
+ end
23
+
24
+ # Disconnects the streaming session.
25
+ def disconnect
26
+ @lightstreamer.disconnect if @lightstreamer
27
+ @lightstreamer = nil
28
+ end
29
+
30
+ # Adds the passed block to the list of callbacks that will be run when the streaming session encounters an error.
31
+ # The block will be called on a worker thread and so the code that is run by the block must be thread-safe.
32
+ # The argument passed to the block is `|error|`, which will be a `Lightstreamer::LightstreamerError` subclass
33
+ # detailing the error that occurred.
34
+ #
35
+ # @param [Proc] callback The callback that is to be run.
36
+ def on_error(&callback)
37
+ @on_error_callbacks << callback
38
+ end
39
+
40
+ # Creates a new subscription for balance updates on the specified account(s). The returned
41
+ # {Streaming::Subscription} must be passed to {#start_subscriptions} in order to actually start streaming its
42
+ # data.
43
+ #
44
+ # @param [Array<#account_id>, nil] accounts The accounts to create a subscription for. If this is `nil` then the
45
+ # new subscription will apply to all the accounts for the active client.
46
+ #
47
+ # @return [Streaming::Subscription]
48
+ def build_accounts_subscription(accounts = nil)
49
+ accounts ||= @dealing_platform.client_account_summary.accounts
50
+
51
+ items = Array(accounts).map { |account| "ACCOUNT:#{account.account_id}" }
52
+ fields = [:available_cash, :available_to_deal, :deposit, :equity, :equity_used, :funds, :margin, :margin_lr,
53
+ :margin_nlr, :pnl, :pnl_lr, :pnl_nlr]
54
+
55
+ build_subscription items: items, fields: fields, mode: :merge
56
+ end
57
+
58
+ # Creates a new Lightstreamer subscription for updates to the specified market(s). The returned
59
+ # {Streaming::Subscription} must be passed to {#start_subscriptions} in order to actually start streaming its
60
+ # data.
61
+ #
62
+ # @param [Array<String>] epics The EPICs of the markets to create a subscription for.
63
+ #
64
+ # @return [Streaming::Subscription]
65
+ def build_markets_subscription(epics)
66
+ items = Array(epics).map { |epic| "MARKET:#{epic}" }
67
+ fields = [:bid, :change, :change_pct, :high, :low, :market_delay, :market_state, :mid_open, :odds, :offer,
68
+ :strike_price, :update_time]
69
+
70
+ build_subscription items: items, fields: fields, mode: :merge
71
+ end
72
+
73
+ # Creates a new Lightstreamer subscription for trade, position and working order updates on the specified
74
+ # account(s). The returned {Streaming::Subscription} must be passed to {#start_subscriptions} in order to
75
+ # actually start streaming its data.
76
+ #
77
+ # @param [Array<#account_id>, nil] accounts The accounts to create a subscription for. If this is `nil` then the
78
+ # new subscription will apply to all the accounts for the active client.
79
+ #
80
+ # @return [Streaming::Subscription]
81
+ def build_trades_subscription(accounts = nil)
82
+ accounts ||= @dealing_platform.client_account_summary.accounts
83
+
84
+ items = Array(accounts).map { |account| "TRADE:#{account.account_id}" }
85
+ fields = [:confirms, :opu, :wou]
86
+
87
+ build_subscription items: items, fields: fields, mode: :distinct
88
+ end
89
+
90
+ # Creates a new Lightstreamer subscription for chart tick data for the specified EPICs. The returned
91
+ # {Streaming::Subscription} must be passed to {#start_subscriptions} in order to actually start streaming its
92
+ # data.
93
+ #
94
+ # @param [Array<String>] epics The EPICs of the markets to create a chart tick data subscription for.
95
+ #
96
+ # @return [Streaming::Subscription]
97
+ def build_chart_ticks_subscription(epics)
98
+ items = Array(epics).map { |epic| "CHART:#{epic}:TICK" }
99
+ fields = [:bid, :day_high, :day_low, :day_net_chg_mid, :day_open_mid, :day_perc_chg_mid, :ltp, :ltv, :ofr, :ttv,
100
+ :utm]
101
+
102
+ build_subscription items: items, fields: fields, mode: :distinct
103
+ end
104
+
105
+ # Creates a new Lightstreamer subscription for consolidated chart data for the specified EPIC and scale. The
106
+ # returned {Streaming::Subscription} must be passed to {#start_subscriptions} in order to actually start streaming
107
+ # its data.
108
+ #
109
+ # @param [String] epic The EPIC of the market to create a consolidated chart data subscription for.
110
+ # @param [:one_second, :one_minute, :five_minutes, :one_hour] scale The scale of the consolidated data.
111
+ #
112
+ # @return [Streaming::Subscription]
113
+ def build_consolidated_chart_data_subscription(epic, scale)
114
+ scale = { one_second: 'SECOND', one_minute: '1MINUTE', five_minutes: '5MINUTE', one_hour: 'HOUR' }.fetch scale
115
+ items = ["CHART:#{epic}:#{scale}"]
116
+
117
+ fields = [:bid_close, :bid_high, :bid_low, :bid_open, :cons_end, :cons_tick_count, :day_high, :day_low,
118
+ :day_net_chg_mid, :day_open_mid, :day_perc_chg_mid, :ltp_close, :ltp_high, :ltp_low, :ltp_open, :ltv,
119
+ :ofr_close, :ofr_high, :ofr_low, :ofr_open, :ttv, :utm]
120
+
121
+ build_subscription items: items, fields: fields, mode: :merge
122
+ end
123
+
124
+ # Starts streaming data from the passed Lightstreamer subscription(s). The return value indicates the error state,
125
+ # if any, for each subscription.
126
+ #
127
+ # @param [Array<Streaming::Subscription>] subscriptions
128
+ # @param [Hash] options The options to start the subscriptions with. See the documentation for
129
+ # `Lightstreamer::Subscription#start` for details of the accepted options.
130
+ #
131
+ # @return [Array<Lightstreamer::LightstreamerError, nil>] An array with one entry per subscription which indicates
132
+ # the error state returned for that subscription's start request, or `nil` if no error occurred.
133
+ def start_subscriptions(subscriptions, options = {})
134
+ lightstreamer_subscriptions = Array(subscriptions).compact.map(&:lightstreamer_subscription)
135
+
136
+ return if lightstreamer_subscriptions.empty?
137
+
138
+ @lightstreamer.start_subscriptions lightstreamer_subscriptions, options
139
+ end
140
+
141
+ # Stops streaming data for the specified subscription(s) and removes them from the streaming session.
142
+ #
143
+ # @param [Array<Lightstreamer::Subscription>] subscriptions The subscriptions to stop.
144
+ def remove_subscriptions(subscriptions)
145
+ lightstreamer_subscriptions = Array(subscriptions).compact.map(&:lightstreamer_subscription)
146
+
147
+ return if lightstreamer_subscriptions.empty?
148
+
149
+ @lightstreamer.remove_subscriptions lightstreamer_subscriptions
150
+ end
151
+
152
+ private
153
+
154
+ def username
155
+ @dealing_platform.client_account_summary.client_id
156
+ end
157
+
158
+ def password
159
+ "CST-#{@dealing_platform.session.client_security_token}|XST-#{@dealing_platform.session.x_security_token}"
160
+ end
161
+
162
+ def server_url
163
+ @dealing_platform.client_account_summary.lightstreamer_endpoint
164
+ end
165
+
166
+ def build_subscription(options)
167
+ subscription = @lightstreamer.build_subscription options
168
+
169
+ Streaming::Subscription.new @dealing_platform, subscription
170
+ end
171
+ end
172
+ end
173
+ end
@@ -20,6 +20,7 @@ module IGMarkets
20
20
 
21
21
  def typecaster_boolean(value, _options, _name)
22
22
  return value if [nil, true, false].include? value
23
+ return value == '1' if %w(0 1).include? value
23
24
 
24
25
  raise ArgumentError, "#{self}##{name}: invalid boolean value: #{value}"
25
26
  end
@@ -7,6 +7,7 @@ module IGMarkets
7
7
  attribute :created_date_utc, Time, format: '%FT%T'
8
8
  attribute :currency, String, regex: Regex::CURRENCY
9
9
  attribute :deal_id
10
+ attribute :deal_reference
10
11
  attribute :direction, Symbol, allowed_values: [:buy, :sell]
11
12
  attribute :level, Float
12
13
  attribute :limit_level, Float
@@ -0,0 +1,190 @@
1
+ module IGMarkets
2
+ module Streaming
3
+ # This class tracks the current state of a dealing platform's accounts, positions and working orders by subscribing
4
+ # to the relevant streaming data feeds supported by the IG Markets streaming API. This is much more efficient than
5
+ # making repeated calls to the REST API to get current account, position or working order details, and also avoids
6
+ # running into API traffic allowance limits.
7
+ class AccountState
8
+ # The current state of the accounts. The balances will be updated automatically as new streaming data arrives.
9
+ #
10
+ # @return [Array<Account>]
11
+ attr_accessor :accounts
12
+
13
+ # The current positions for this account. This set of positions will be updated automatically as new streaming
14
+ # data arrives.
15
+ #
16
+ # @return [Array<Position>]
17
+ attr_accessor :positions
18
+
19
+ # The current working orders for this account. This set of working orders will be updated automatically as new
20
+ # streaming data arrives.
21
+ #
22
+ # @return [Array<WorkingOrder>]
23
+ attr_accessor :working_orders
24
+
25
+ # Initializes this account state with the specified dealing platform.
26
+ #
27
+ # @param [DealingPlatform] dealing_platform The dealing platform.
28
+ def initialize(dealing_platform)
29
+ @dealing_platform = dealing_platform
30
+
31
+ @data_queue = Queue.new
32
+
33
+ @market_subscriptions_manager = MarketSubscriptionManager.new dealing_platform
34
+ @market_subscriptions_manager.on_data { |_data, merged_data| @data_queue << [:on_market_update, merged_data] }
35
+ end
36
+
37
+ # Starts all the relevant streaming subscriptions that are needed to keep this account state up to date using live
38
+ # streaming updates. After this account state has been started the {#process_queued_data} method must be called
39
+ # repeatedly to process the queue of streaming updates and apply them to {#accounts}, {#positions} and
40
+ # {#working_orders} as appropriate.
41
+ def start
42
+ start_accounts_subscription
43
+ start_trades_subscription
44
+
45
+ @accounts = @dealing_platform.account.all
46
+ @positions = @dealing_platform.positions.all
47
+ @working_orders = @dealing_platform.working_orders.all
48
+
49
+ @trades_subscription.unsilence
50
+
51
+ update_market_subscriptions
52
+ end
53
+
54
+ # Returns whether there is any data waiting to be processed by a call to {#process_queued_data}
55
+ #
56
+ # @return [Boolean]
57
+ def data_to_process?
58
+ !@data_queue.empty?
59
+ end
60
+
61
+ # Processes all queued data and updates {#accounts}, {#positions} and {#working_orders} accordingly. If there are
62
+ # no queued data updates then this method blocks until at least one data update is available.
63
+ def process_queued_data
64
+ loop do
65
+ send(*@data_queue.pop)
66
+
67
+ break if @data_queue.empty?
68
+ end
69
+
70
+ update_market_subscriptions
71
+ end
72
+
73
+ private
74
+
75
+ def start_accounts_subscription
76
+ @accounts_subscription = @dealing_platform.streaming.build_accounts_subscription
77
+
78
+ @accounts_subscription.on_data do |_data, merged_data|
79
+ @data_queue << [:on_account_update, merged_data]
80
+ end
81
+
82
+ @dealing_platform.streaming.start_subscriptions @accounts_subscription, snapshot: true
83
+ end
84
+
85
+ def start_trades_subscription
86
+ @trades_subscription = @dealing_platform.streaming.build_trades_subscription
87
+
88
+ @trades_subscription.on_data do |data|
89
+ @data_queue << [:on_position_update, data] if data.is_a? PositionUpdate
90
+ @data_queue << [:on_working_order_update, data] if data.is_a? WorkingOrderUpdate
91
+ end
92
+
93
+ @dealing_platform.streaming.start_subscriptions @trades_subscription, silent: true
94
+ end
95
+
96
+ def market_models
97
+ @positions + @working_orders
98
+ end
99
+
100
+ def update_market_subscriptions
101
+ @market_subscriptions_manager.epics = market_models.map { |model| model.market.epic }
102
+ end
103
+
104
+ def on_account_update(account_update)
105
+ @accounts.each do |account|
106
+ next unless account.account_id == account_update.account_id
107
+
108
+ account.balance.available = account_update.available_to_deal
109
+ account.balance.balance = account_update.equity
110
+ account.balance.deposit = account_update.margin
111
+ account.balance.profit_loss = account_update.pnl
112
+ end
113
+ end
114
+
115
+ def on_market_update(market_update)
116
+ market_models.each do |model|
117
+ next unless model.market.epic == market_update.epic
118
+
119
+ attributes_to_copy = { bid: :bid, high: :high, low: :low, net_change: :change, offer: :offer,
120
+ percentage_change: :change_pct }
121
+
122
+ attributes_to_copy.each do |target_attribute, update_attribute|
123
+ model.market.send "#{target_attribute}=", market_update.send(update_attribute)
124
+ end
125
+
126
+ model.market.market_status = convert_market_status market_update.market_state if market_update.market_state
127
+ end
128
+ end
129
+
130
+ def convert_market_status(status)
131
+ { auction: :on_auction, auction_no_edit: :on_auction_no_edits, closed: :closed, edit: :edits_only,
132
+ offline: :offline, suspended: :suspended, tradeable: :tradeable }.fetch status
133
+ end
134
+
135
+ def on_position_update(position_update)
136
+ send "on_position_#{position_update.status}", position_update
137
+ end
138
+
139
+ def on_position_open(position_update)
140
+ return if @positions.any? { |position| position.deal_id == position_update.deal_id }
141
+
142
+ new_position = @dealing_platform.positions[position_update.deal_id]
143
+
144
+ @positions << new_position if new_position
145
+ end
146
+
147
+ def on_position_updated(position_update)
148
+ @positions.each do |position|
149
+ next unless position.deal_id == position_update.deal_id
150
+
151
+ position.limit_level = position_update.limit_level
152
+ position.stop_level = position_update.stop_level
153
+ end
154
+ end
155
+
156
+ def on_position_deleted(position_update)
157
+ @positions.delete_if do |position|
158
+ position.deal_id == position_update.deal_id
159
+ end
160
+ end
161
+
162
+ def on_working_order_update(working_order_update)
163
+ send "on_working_order_#{working_order_update.status}", working_order_update
164
+ end
165
+
166
+ def on_working_order_open(working_order_update)
167
+ return if @working_orders.any? { |working_order| working_order.deal_id == working_order_update.deal_id }
168
+
169
+ new_working_order = @dealing_platform.working_orders[working_order_update.deal_id]
170
+
171
+ @working_orders << new_working_order if new_working_order
172
+ end
173
+
174
+ def on_working_order_updated(working_order_update)
175
+ @working_orders.each do |working_order|
176
+ next unless working_order.deal_id == working_order_update.deal_id
177
+
178
+ working_order.limit_distance = working_order_update.limit_distance
179
+ working_order.stop_distance = working_order_update.stop_distance
180
+ end
181
+ end
182
+
183
+ def on_working_order_deleted(working_order_update)
184
+ @working_orders.delete_if do |working_order|
185
+ working_order.deal_id == working_order_update.deal_id
186
+ end
187
+ end
188
+ end
189
+ end
190
+ end