eodhd 0.19.13 → 0.20.1
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.
- checksums.yaml +4 -4
- data/CHANGELOG +20 -1
- data/eodhd.gemspec +2 -2
- data/lib/Eodhd/VERSION.rb +1 -1
- data/lib/Eodhd/WebSocketClient.rb +57 -12
- data/test/Eodhd/WebSocketClient_test.rb +105 -12
- data/test/gemspec_test.rb +5 -1
- metadata +6 -4
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 3af50c9ff84a7afb642fa1b4465e62cc15e4c856c6612feac2d88b36c47af1ce
|
|
4
|
+
data.tar.gz: fa1fb309d88c8e60d15b4c7d0c3cc9f2c4b4d08c292bac5afb945ca30b1834b7
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: 6af0d9a5bca09f6472617b523065ce3038d445b1e91727a2e91ae56f7cf20eeb58c9641fdb6a480febf20e27140f4edac61faf83048130d2071af2181277fba3
|
|
7
|
+
data.tar.gz: 5e27a2e9619255f57d93681cdc6723ff9662bff40a0cb0be87837f21967e0cacc34205fc4d6e5f43a327318ca1dd84b6344ee0728a2a2a4abfdd31396180e1d9
|
data/CHANGELOG
CHANGED
|
@@ -1,6 +1,25 @@
|
|
|
1
1
|
# eodhd/CHANGELOG
|
|
2
2
|
|
|
3
|
-
##
|
|
3
|
+
## 20261025
|
|
4
|
+
|
|
5
|
+
0.20.1: ~ eodhd.gemspec, eodhd.rb.gemspec: Write a custom spec.description, rather than repeating spec.summary.
|
|
6
|
+
|
|
7
|
+
1. ~ eodhd.gemspec, eodhd.rb.gemspec: ~ spec.description: It matched spec.summary, so gem build warned "description and summary are identical".
|
|
8
|
+
2. + test/gemspec_test.rb: Ensure that each gemspec's description differs from its summary.
|
|
9
|
+
3. ~ Eodhd::VERSION: /0.20.0/0.20.1/
|
|
10
|
+
|
|
11
|
+
0.20.0: Replace iodine with websocket-driver, so as WebSocketClient sends SNI.
|
|
12
|
+
|
|
13
|
+
1. ~ eodhd.gemspec and eodhd.rb.gemspec: spec.dependencies: - iodine, + websocket-driver. EODHD's WebSocket endpoints refuse a TLS handshake without SNI, which neither iodine 0.7.60 nor 0.8.2 sends, and 0.8.2 also leaves the API token's query string off the request.
|
|
14
|
+
2. + Eodhd::WebSocketClient::Connection, the URL and socket for WebSocket::Driver.
|
|
15
|
+
3. + Eodhd::WebSocketClient#tls_socket, #tls_context: naming the host (SNI) and verifying its certificate.
|
|
16
|
+
4. + Eodhd::WebSocketClient#websocket_driver
|
|
17
|
+
5. ~ Eodhd::WebSocketClient#run: reads into a WebSocket::Driver until it closes, or until EOFError, Errno::EPIPE or Errno::ECONNRESET. An exception from the consumer, or an Eodhd::Error for a frame which isn't JSON, now ends run, where iodine printed it and carried on.
|
|
18
|
+
6. ~ Eodhd::WebSocketClient::Handler#on_open: subscribes with WebSocket::Driver#text.
|
|
19
|
+
7. - Eodhd::WebSocketClient::Handler#on_close
|
|
20
|
+
8. ~ test/Eodhd/WebSocketClient_test.rb: #run against a server on a socket pair, rather than with Iodine stubbed; + #tls_socket against a local TLS server; + Handler#on_open.
|
|
21
|
+
9. ~ test/gemspec_test.rb: "declares its runtime dependencies": - iodine, + websocket-driver.
|
|
22
|
+
10. ~ Eodhd::VERSION: /0.19.13/0.20.0/
|
|
4
23
|
|
|
5
24
|
0.19.13: Fix WebSocketClient.us_trade, .us_quote, .forex and .crypto.
|
|
6
25
|
|
data/eodhd.gemspec
CHANGED
|
@@ -15,7 +15,7 @@ Gem::Specification.new do |spec|
|
|
|
15
15
|
spec.version = Eodhd::VERSION
|
|
16
16
|
|
|
17
17
|
spec.summary = "Access the eodhd.com API with Ruby."
|
|
18
|
-
spec.description = "
|
|
18
|
+
spec.description = "A Ruby client for the eodhd.com financial data API: exchanges, symbols, end-of-day and intraday prices, and fundamentals over HTTP, and real-time US stock trades and quotes, forex, and crypto over WebSockets."
|
|
19
19
|
|
|
20
20
|
spec.author = 'thoran'
|
|
21
21
|
spec.email = 'code@thoran.com'
|
|
@@ -38,8 +38,8 @@ Gem::Specification.new do |spec|
|
|
|
38
38
|
|
|
39
39
|
spec.dependencies = [
|
|
40
40
|
['http.rb', '>= 1.7.0'],
|
|
41
|
-
'iodine',
|
|
42
41
|
'logger',
|
|
42
|
+
'websocket-driver',
|
|
43
43
|
]
|
|
44
44
|
|
|
45
45
|
spec.development_dependencies = %w{
|
data/lib/Eodhd/VERSION.rb
CHANGED
|
@@ -7,9 +7,12 @@
|
|
|
7
7
|
# ii. US market quote data: wss://ws.eodhistoricaldata.com/ws/us-quote?api_token=XXX
|
|
8
8
|
# iii. Forex: wss://ws.eodhistoricaldata.com/ws/forex?api_token=XXX
|
|
9
9
|
# iv. Crypto: wss://ws.eodhistoricaldata.com/ws/crypto?api_token=XXX
|
|
10
|
+
# 2. The endpoints refuse a TLS handshake which doesn't name the host (SNI).
|
|
10
11
|
|
|
11
|
-
require 'iodine'
|
|
12
12
|
require 'json'
|
|
13
|
+
require 'openssl'
|
|
14
|
+
require 'socket'
|
|
15
|
+
require 'websocket/driver'
|
|
13
16
|
|
|
14
17
|
require_relative './DefaultLogger'
|
|
15
18
|
require_relative './Error'
|
|
@@ -17,21 +20,35 @@ require_relative './Error'
|
|
|
17
20
|
class Eodhd
|
|
18
21
|
class WebSocketClient
|
|
19
22
|
API_HOST = 'ws.eodhistoricaldata.com'
|
|
23
|
+
API_PORT = 443
|
|
24
|
+
READ_SIZE = 16384
|
|
25
|
+
|
|
26
|
+
# What WebSocket::Driver writes to: the URL it asks for and the socket to write to.
|
|
27
|
+
class Connection
|
|
28
|
+
attr_reader :url
|
|
29
|
+
|
|
30
|
+
def write(data)
|
|
31
|
+
@socket.write(data)
|
|
32
|
+
end
|
|
33
|
+
|
|
34
|
+
private
|
|
35
|
+
|
|
36
|
+
def initialize(url:, socket:)
|
|
37
|
+
@url = url
|
|
38
|
+
@socket = socket
|
|
39
|
+
end
|
|
40
|
+
end
|
|
20
41
|
|
|
21
42
|
class Handler
|
|
22
|
-
def on_open(
|
|
23
|
-
|
|
43
|
+
def on_open(driver)
|
|
44
|
+
driver.text({action: 'subscribe', symbols: @symbols}.to_json)
|
|
24
45
|
end
|
|
25
46
|
|
|
26
|
-
def on_message(
|
|
47
|
+
def on_message(driver, message)
|
|
27
48
|
dataframe = handle_response(message)
|
|
28
49
|
@consumer.call(dataframe) if @consumer
|
|
29
50
|
end
|
|
30
51
|
|
|
31
|
-
def on_close(connection)
|
|
32
|
-
Iodine.stop
|
|
33
|
-
end
|
|
34
|
-
|
|
35
52
|
private
|
|
36
53
|
|
|
37
54
|
def initialize(symbols:, consumer:, logger: nil)
|
|
@@ -94,11 +111,14 @@ class Eodhd
|
|
|
94
111
|
end
|
|
95
112
|
|
|
96
113
|
def run
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
114
|
+
socket = tls_socket
|
|
115
|
+
driver = websocket_driver(socket)
|
|
116
|
+
driver.start
|
|
117
|
+
driver.parse(socket.readpartial(READ_SIZE)) until driver.state == :closed
|
|
118
|
+
rescue EOFError, Errno::EPIPE, Errno::ECONNRESET, SystemExit, Interrupt
|
|
101
119
|
return
|
|
120
|
+
ensure
|
|
121
|
+
socket&.close
|
|
102
122
|
end
|
|
103
123
|
|
|
104
124
|
private
|
|
@@ -126,5 +146,30 @@ class Eodhd
|
|
|
126
146
|
def url
|
|
127
147
|
"wss://#{API_HOST}#{self.class.path_prefix}/#{@asset_class}?api_token=#{@api_token}"
|
|
128
148
|
end
|
|
149
|
+
|
|
150
|
+
def tls_context
|
|
151
|
+
context = OpenSSL::SSL::SSLContext.new
|
|
152
|
+
context.set_params(verify_mode: OpenSSL::SSL::VERIFY_PEER)
|
|
153
|
+
context
|
|
154
|
+
end
|
|
155
|
+
|
|
156
|
+
def tls_socket(host: API_HOST, port: API_PORT)
|
|
157
|
+
socket = OpenSSL::SSL::SSLSocket.new(TCPSocket.new(host, port), tls_context)
|
|
158
|
+
socket.hostname = host
|
|
159
|
+
socket.sync_close = true
|
|
160
|
+
socket.connect
|
|
161
|
+
socket
|
|
162
|
+
rescue OpenSSL::SSL::SSLError
|
|
163
|
+
socket.close
|
|
164
|
+
raise
|
|
165
|
+
end
|
|
166
|
+
|
|
167
|
+
def websocket_driver(socket)
|
|
168
|
+
driver = WebSocket::Driver.client(Connection.new(url: url, socket: socket))
|
|
169
|
+
handler = Handler.new(symbols: @symbols, consumer: @consumer, logger: @logger)
|
|
170
|
+
driver.on(:open){handler.on_open(driver)}
|
|
171
|
+
driver.on(:message){|event| handler.on_message(driver, event.data)}
|
|
172
|
+
driver
|
|
173
|
+
end
|
|
129
174
|
end
|
|
130
175
|
end
|
|
@@ -93,26 +93,119 @@ describe Eodhd::WebSocketClient do
|
|
|
93
93
|
end
|
|
94
94
|
|
|
95
95
|
describe "#run" do
|
|
96
|
-
|
|
97
|
-
|
|
96
|
+
# The server's half of the opening handshake (RFC 6455, 4.2.2), answering with the request line.
|
|
97
|
+
def accept_handshake(socket)
|
|
98
|
+
request = []
|
|
99
|
+
request << socket.gets.chomp until request.last == ''
|
|
100
|
+
key = request.grep(/\ASec-WebSocket-Key:/i).first.split(':', 2).last.strip
|
|
101
|
+
accept = [OpenSSL::Digest.digest('SHA1', key + '258EAFA5-E914-47DA-95CA-C5AB0DC85B11')].pack('m0')
|
|
102
|
+
socket.write("HTTP/1.1 101 Switching Protocols\r\nUpgrade: websocket\r\nConnection: Upgrade\r\nSec-WebSocket-Accept: #{accept}\r\n\r\n")
|
|
103
|
+
request.first
|
|
104
|
+
end
|
|
105
|
+
|
|
106
|
+
# A masked client frame of under 126 bytes.
|
|
107
|
+
def read_client_frame(socket)
|
|
108
|
+
length = socket.read(2).bytes.last & 0x7f
|
|
109
|
+
mask = socket.read(4).bytes
|
|
110
|
+
socket.read(length).bytes.each_with_index.map{|byte, i| byte ^ mask[i % 4]}.pack('C*')
|
|
111
|
+
end
|
|
98
112
|
|
|
99
|
-
|
|
100
|
-
|
|
113
|
+
def text_frame(text)
|
|
114
|
+
[0x81, text.bytesize].pack('CC') + text
|
|
115
|
+
end
|
|
116
|
+
|
|
117
|
+
def close_frame
|
|
118
|
+
[0x88, 2, 1000].pack('CCn')
|
|
119
|
+
end
|
|
101
120
|
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
121
|
+
it "subscribes, hands each message to the consumer, and returns once the server closes" do
|
|
122
|
+
received = []
|
|
123
|
+
client = Eodhd::WebSocketClient.new(api_token: 'api_token', asset_class: 'crypto', symbols: 'BTC-USD', consumer: ->(data){received << data})
|
|
124
|
+
client_socket, server_socket = UNIXSocket.pair
|
|
125
|
+
server = Thread.new do
|
|
126
|
+
request_line = accept_handshake(server_socket)
|
|
127
|
+
subscription = read_client_frame(server_socket)
|
|
128
|
+
server_socket.write(text_frame('{"s":"BTC-USD","p":"81430.24"}'))
|
|
129
|
+
server_socket.write(close_frame)
|
|
130
|
+
[request_line, subscription]
|
|
108
131
|
end
|
|
132
|
+
client.stub(:tls_socket, client_socket){client.run}
|
|
133
|
+
request_line, subscription = server.value
|
|
134
|
+
_(request_line).must_equal('GET /ws/crypto?api_token=api_token HTTP/1.1')
|
|
135
|
+
_(JSON.parse(subscription)).must_equal({'action' => 'subscribe', 'symbols' => 'BTC-USD'})
|
|
136
|
+
_(received).must_equal([{'s' => 'BTC-USD', 'p' => '81430.24'}])
|
|
137
|
+
end
|
|
109
138
|
|
|
110
|
-
|
|
111
|
-
|
|
139
|
+
it "returns when the server drops the connection" do
|
|
140
|
+
received = []
|
|
141
|
+
client = Eodhd::WebSocketClient.new(api_token: 'api_token', asset_class: 'crypto', symbols: 'BTC-USD', consumer: ->(data){received << data})
|
|
142
|
+
client_socket, server_socket = UNIXSocket.pair
|
|
143
|
+
server = Thread.new do
|
|
144
|
+
accept_handshake(server_socket)
|
|
145
|
+
server_socket.close
|
|
146
|
+
end
|
|
147
|
+
client.stub(:tls_socket, client_socket){client.run}
|
|
148
|
+
server.join
|
|
149
|
+
_(received).must_equal([])
|
|
150
|
+
end
|
|
151
|
+
end
|
|
152
|
+
|
|
153
|
+
describe "#tls_socket" do
|
|
154
|
+
def self_signed_certificate(key)
|
|
155
|
+
certificate = OpenSSL::X509::Certificate.new
|
|
156
|
+
certificate.version = 2
|
|
157
|
+
certificate.serial = 1
|
|
158
|
+
certificate.subject = certificate.issuer = OpenSSL::X509::Name.parse('/CN=localhost')
|
|
159
|
+
certificate.public_key = key.public_key
|
|
160
|
+
certificate.not_before = Time.now - 60
|
|
161
|
+
certificate.not_after = Time.now + 3600
|
|
162
|
+
certificate.sign(key, OpenSSL::Digest.new('SHA256'))
|
|
163
|
+
end
|
|
164
|
+
|
|
165
|
+
# Yields the port of a local TLS server with a self-signed certificate, and the host names its clients sent.
|
|
166
|
+
def with_tls_server
|
|
167
|
+
key = OpenSSL::PKey::RSA.new(2048)
|
|
168
|
+
context = OpenSSL::SSL::SSLContext.new
|
|
169
|
+
context.cert, context.key = self_signed_certificate(key), key
|
|
170
|
+
server_names = []
|
|
171
|
+
context.servername_cb = ->((socket, name)){server_names << name; nil}
|
|
172
|
+
tcp_server = TCPServer.new('localhost', 0)
|
|
173
|
+
ssl_server = OpenSSL::SSL::SSLServer.new(tcp_server, context)
|
|
174
|
+
server = Thread.new{ssl_server.accept rescue nil}
|
|
175
|
+
yield tcp_server.addr[1], server_names
|
|
176
|
+
ensure
|
|
177
|
+
server&.join(5)
|
|
178
|
+
ssl_server&.close
|
|
179
|
+
end
|
|
180
|
+
|
|
181
|
+
it "names the host in the TLS handshake (SNI)" do
|
|
182
|
+
client = Eodhd::WebSocketClient.new(api_token: api_token, asset_class: 'crypto', symbols: 'BTC-USD', consumer: consumer)
|
|
183
|
+
with_tls_server do |port, server_names|
|
|
184
|
+
_{client.send(:tls_socket, host: 'localhost', port: port)}.must_raise(OpenSSL::SSL::SSLError)
|
|
185
|
+
_(server_names).must_equal(['localhost'])
|
|
186
|
+
end
|
|
187
|
+
end
|
|
188
|
+
|
|
189
|
+
it "refuses a certificate it can't verify" do
|
|
190
|
+
client = Eodhd::WebSocketClient.new(api_token: api_token, asset_class: 'crypto', symbols: 'BTC-USD', consumer: consumer)
|
|
191
|
+
with_tls_server do |port, server_names|
|
|
192
|
+
error = _{client.send(:tls_socket, host: 'localhost', port: port)}.must_raise(OpenSSL::SSL::SSLError)
|
|
193
|
+
_(error.message).must_match(/certificate verify failed/)
|
|
194
|
+
end
|
|
112
195
|
end
|
|
113
196
|
end
|
|
114
197
|
|
|
115
198
|
describe "Handler" do
|
|
199
|
+
describe "#on_open" do
|
|
200
|
+
it "subscribes to its symbols" do
|
|
201
|
+
sent = []
|
|
202
|
+
driver = Struct.new(:sent){def text(message) = sent << message}.new(sent)
|
|
203
|
+
handler = Eodhd::WebSocketClient::Handler.new(symbols: 'AAPL,MSFT', consumer: consumer, logger: nil)
|
|
204
|
+
handler.on_open(driver)
|
|
205
|
+
_(sent.collect{|message| JSON.parse(message)}).must_equal([{'action' => 'subscribe', 'symbols' => 'AAPL,MSFT'}])
|
|
206
|
+
end
|
|
207
|
+
end
|
|
208
|
+
|
|
116
209
|
describe "#on_message" do
|
|
117
210
|
it "parses JSON and calls consumer" do
|
|
118
211
|
received_data = nil
|
data/test/gemspec_test.rb
CHANGED
|
@@ -14,6 +14,10 @@ require_relative './helper'
|
|
|
14
14
|
_(spec.name).must_equal(name)
|
|
15
15
|
end
|
|
16
16
|
|
|
17
|
+
it "has a description which says more than its summary" do
|
|
18
|
+
_(spec.description).wont_equal(spec.summary)
|
|
19
|
+
end
|
|
20
|
+
|
|
17
21
|
it "does not pin a date" do
|
|
18
22
|
_(spec.date).must_equal(Gem::Specification.new.date)
|
|
19
23
|
end
|
|
@@ -23,7 +27,7 @@ require_relative './helper'
|
|
|
23
27
|
end
|
|
24
28
|
|
|
25
29
|
it "declares its runtime dependencies" do
|
|
26
|
-
_(spec.runtime_dependencies.collect(&:name).sort).must_equal(%w{http.rb
|
|
30
|
+
_(spec.runtime_dependencies.collect(&:name).sort).must_equal(%w{http.rb logger websocket-driver})
|
|
27
31
|
end
|
|
28
32
|
|
|
29
33
|
it "requires http.rb 1.7.0 or later, whose load file is lib/http.rb" do
|
metadata
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
--- !ruby/object:Gem::Specification
|
|
2
2
|
name: eodhd
|
|
3
3
|
version: !ruby/object:Gem::Version
|
|
4
|
-
version: 0.
|
|
4
|
+
version: 0.20.1
|
|
5
5
|
platform: ruby
|
|
6
6
|
authors:
|
|
7
7
|
- thoran
|
|
@@ -24,7 +24,7 @@ dependencies:
|
|
|
24
24
|
- !ruby/object:Gem::Version
|
|
25
25
|
version: 1.7.0
|
|
26
26
|
- !ruby/object:Gem::Dependency
|
|
27
|
-
name:
|
|
27
|
+
name: logger
|
|
28
28
|
requirement: !ruby/object:Gem::Requirement
|
|
29
29
|
requirements:
|
|
30
30
|
- - ">="
|
|
@@ -38,7 +38,7 @@ dependencies:
|
|
|
38
38
|
- !ruby/object:Gem::Version
|
|
39
39
|
version: '0'
|
|
40
40
|
- !ruby/object:Gem::Dependency
|
|
41
|
-
name:
|
|
41
|
+
name: websocket-driver
|
|
42
42
|
requirement: !ruby/object:Gem::Requirement
|
|
43
43
|
requirements:
|
|
44
44
|
- - ">="
|
|
@@ -149,7 +149,9 @@ dependencies:
|
|
|
149
149
|
- - ">="
|
|
150
150
|
- !ruby/object:Gem::Version
|
|
151
151
|
version: '0'
|
|
152
|
-
description:
|
|
152
|
+
description: 'A Ruby client for the eodhd.com financial data API: exchanges, symbols,
|
|
153
|
+
end-of-day and intraday prices, and fundamentals over HTTP, and real-time US stock
|
|
154
|
+
trades and quotes, forex, and crypto over WebSockets.'
|
|
153
155
|
email: code@thoran.com
|
|
154
156
|
executables: []
|
|
155
157
|
extensions: []
|