polygonio 0.1.0

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,140 @@
1
+ # frozen_string_literal: true
2
+
3
+ module PolygonClient
4
+ module Websocket
5
+ module Connection
6
+ attr_accessor :url
7
+
8
+ def self.connect(api_key, url, channels, args = {}, &block)
9
+ raise "no onmessage callback block given" unless block_given?
10
+
11
+ uri = URI.parse(url)
12
+ args[:api_key] = api_key
13
+ args[:channels] = channels
14
+ args[:on_message] = block
15
+ EM.connect(uri.host, 443, self, args) do |conn|
16
+ conn.url = url
17
+ end
18
+ end
19
+
20
+ def initialize(args)
21
+ @api_key = args.fetch(:api_key)
22
+ @channels = args.fetch(:channels)
23
+ @on_message = args.fetch(:on_message)
24
+ @debug = args.fetch(:debug) { false }
25
+ end
26
+
27
+ def connection_completed
28
+ @driver = WebSocket::Driver.client(self)
29
+ @driver.add_extension(PermessageDeflate)
30
+
31
+ uri = URI.parse(@url)
32
+ start_tls(sni_hostname: uri.host)
33
+
34
+ @driver.on(:open) do |_event|
35
+ msg = dump({ action: "auth", params: @api_key })
36
+ @driver.text(msg)
37
+ end
38
+
39
+ @driver.on(:message) do |msg|
40
+ p [:message, msg.data] if @debug
41
+
42
+ events = Oj.load(msg.data, symbol_keys: true).map do |event|
43
+ to_event(event)
44
+ end
45
+
46
+ status_events = events.select { |e| e.is_a? WebsocketEvent }
47
+ status_events.each do |event|
48
+ msg = handle_status_event(event)
49
+ @driver.text(msg) if msg
50
+ end
51
+
52
+ @on_message.call(events) if status_events.length.zero?
53
+ end
54
+
55
+ @driver.on(:close) { |event| finalize(event) }
56
+
57
+ @driver.start
58
+ end
59
+
60
+ def receive_data(data)
61
+ @driver.parse(data)
62
+ end
63
+
64
+ def write(data)
65
+ send_data(data)
66
+ end
67
+
68
+ def finalize(event)
69
+ p [:close, event.code, event.reason] if @debug
70
+ close_connection
71
+ end
72
+
73
+ def subscribe(channels)
74
+ dump({ action: "subscribe", params: channels })
75
+ end
76
+
77
+ private
78
+
79
+ def to_event(event)
80
+ case event.fetch(:ev)
81
+ when "status"
82
+ if event.fetch(:status) == "error" && event.fetch(:message) == "not authorized"
83
+ raise NotAuthorizedError, event.fetch(:message)
84
+ end
85
+
86
+ WebsocketEvent.new(event)
87
+ when "C"
88
+ ForexQuoteEvent.new(event)
89
+ when "CA"
90
+ ForexAggregateEvent.new(event)
91
+ when "XQ"
92
+ CryptoQuoteEvent.new(event)
93
+ when "XT"
94
+ CryptoTradeEvent.new(event)
95
+ when "XA"
96
+ CryptoAggregateEvent.new(event)
97
+ when "XS"
98
+ CryptoSipEvent.new(event)
99
+ when "XL2"
100
+ CryptoLevel2Event.new(event)
101
+ else
102
+ raise UnrecognizedEventError.new(event), "Unrecognized event with type: #{event.ev}"
103
+ end
104
+ end
105
+
106
+ # dump json
107
+ def dump(json)
108
+ Oj.dump(json, mode: :compat)
109
+ end
110
+
111
+ def handle_status_event(event)
112
+ case WebsocketEvent::Statuses[event.status]
113
+ when "auth_success"
114
+ subscribe(@channels)
115
+ when "auth_timeout"
116
+ raise AuthTimeoutError, event.message
117
+ end
118
+ end
119
+ end
120
+
121
+ class Client
122
+ BASE_URL = "wss://socket.polygon.io/"
123
+
124
+ def initialize(path, api_key, opts = {})
125
+ path = Types::Coercible::String.enum("stocks", "forex", "crypto")[path]
126
+
127
+ @api_key = api_key
128
+ @ws = nil
129
+ @opts = opts
130
+ @url = "#{BASE_URL}#{path}"
131
+ end
132
+
133
+ def subscribe(channels, &block)
134
+ EM.run do
135
+ Connection.connect(@api_key, @url, channels, @opts, &block)
136
+ end
137
+ end
138
+ end
139
+ end
140
+ end
@@ -0,0 +1,20 @@
1
+ # frozen_string_literal: true
2
+
3
+ module PolygonClient
4
+ module Websocket
5
+ module Errors
6
+ class PolygonWebsocketClientError < StandardError; end
7
+
8
+ class AuthTimeoutError < PolygonWebsocketClientError; end
9
+ class NotAuthorizedError < PolygonWebsocketClientError; end
10
+ class UnrecognizedEventError < PolygonWebsocketClientError
11
+ attr_reader :event
12
+
13
+ def initialize(message, event)
14
+ super(message)
15
+ @event = event
16
+ end
17
+ end
18
+ end
19
+ end
20
+ end
@@ -0,0 +1,101 @@
1
+ # frozen_string_literal: true
2
+
3
+ module PolygonClient
4
+ module Websocket
5
+ class WebsocketEvent < Dry::Struct
6
+ Statuses = Types::String.enum("connected", "successful", "auth_timeout", "auth_success", "success")
7
+
8
+ attribute :ev, Types::String
9
+ attribute :status, Statuses
10
+ attribute :message, Types::String
11
+ end
12
+
13
+ class ForexQuoteEvent < Dry::Struct
14
+ attribute :ev, Types::String.enum("C")
15
+ attribute :p, Types::String # Currency pair
16
+ attribute :x, Types::Integer # FX Exchange ID
17
+ attribute :a, Types::JSON::Decimal # Ask Price
18
+ attribute :b, Types::JSON::Decimal # Bid Price
19
+ attribute :t, Types::Integer # Quote Timestamp ( Unix MS )
20
+ end
21
+
22
+ class ForexAggregateEvent < Dry::Struct
23
+ attribute :ev, Types::String.enum("CA")
24
+ attribute :pair, Types::String
25
+ attribute :o, Types::JSON::Decimal # Open Price
26
+ attribute :c, Types::JSON::Decimal # Close Price
27
+ attribute :h, Types::JSON::Decimal # High Price
28
+ attribute :l, Types::JSON::Decimal # Low Price
29
+ attribute :v, Types::Integer # Volume ( Quotes during this duration )
30
+ attribute :s, Types::Integer # Tick Start Timestamp
31
+ end
32
+
33
+ class CryptoQuoteEvent < Dry::Struct
34
+ attribute :ev, Types::String.enum("XQ")
35
+ attribute :pair, Types::String
36
+ attribute :lp, Types::JSON::Decimal # Last Trade Price
37
+ attribute :ls, Types::JSON::Decimal # Last Trade Size
38
+ attribute :bp, Types::JSON::Decimal # Bid Price
39
+ attribute :bs, Types::JSON::Decimal # Bid Size
40
+ attribute :ap, Types::JSON::Decimal # Ask Price
41
+ attribute :as, Types::JSON::Decimal # Ask Size
42
+ attribute :t, Types::Integer # Exchange Timestamp Unix ( ms )
43
+ attribute :x, Types::Integer # Exchange ID
44
+ attribute :r, Types::Integer # Received @ Polygon Timestamp
45
+ end
46
+
47
+ class CryptoTradeEvent < Dry::Struct
48
+ attribute :ev, Types::String.enum("XT")
49
+ attribute :pair, Types::String
50
+ attribute :p, Types::JSON::Decimal # Price
51
+ attribute :t, Types::Integer # Timestamp Unix ( ms )
52
+ attribute :s, Types::JSON::Decimal # Size
53
+ attribute :c, Types::Array.of(Types::Integer) # Condition
54
+ attribute :i, Types::String # Trade ID ( Optional )
55
+ attribute :x, Types::Integer # Exchange ID
56
+ attribute :r, Types::Integer # Received @ Polygon Timestamp
57
+ end
58
+
59
+ class CryptoAggregateEvent < Dry::Struct
60
+ attribute :ev, Types::String.enum("XA")
61
+ attribute :pair, Types::String
62
+ attribute :o, Types::JSON::Decimal # Open Price
63
+ attribute? :ox, Types::Integer # Open Exchange
64
+ attribute :h, Types::JSON::Decimal # High Price
65
+ attribute? :hx, Types::Integer # High Exchange
66
+ attribute :l, Types::JSON::Decimal # Low Price
67
+ attribute? :lx, Types::Integer # Low Exchange
68
+ attribute :c, Types::JSON::Decimal # Close Price
69
+ attribute? :cx, Types::Integer # Close Exchange
70
+ attribute :v, Types::JSON::Decimal # Volume of Trades in Tick
71
+ attribute :s, Types::Integer # Tick Start Timestamp
72
+ attribute :e, Types::Integer # Tick End Timestamp
73
+ end
74
+
75
+ class CryptoSipEvent < Dry::Struct
76
+ attribute :ev, Types::String.enum("XS")
77
+ attribute :pair, Types::String
78
+ attribute :as, Types::JSON::Decimal # Ask Size
79
+ attribute :ap, Types::JSON::Decimal # Ask Price
80
+ attribute :ax, Types::Integer # Ask Exchange
81
+ attribute :bs, Types::JSON::Decimal # Bid Size
82
+ attribute :bp, Types::JSON::Decimal # Bid Price
83
+ attribute :bx, Types::Integer # Bid Exchange
84
+ attribute :t, Types::Integer # Tick Start Timestamp
85
+ end
86
+
87
+ class CryptoLevel2Event < Dry::Struct
88
+ attribute :ev, Types::String.enum("XL2")
89
+ attribute :pair, Types::String
90
+ attribute :b, Types::Array.of(Types::Array.of(Types::JSON::Decimal))
91
+ # Bid Prices ( 100 depth cap )
92
+ # [ Price, Size ]
93
+ attribute :a, Types::Array.of(Types::Array.of(Types::JSON::Decimal))
94
+ # Ask Prices ( 100 depth cap )
95
+ # [ Price, Size ]
96
+ attribute :t, Types::Integer # Timestamp Unix ( ms )
97
+ attribute :x, Types::Integer # Exchange ID
98
+ attribute :r, Types::Integer # Tick Received @ Polygon Timestamp
99
+ end
100
+ end
101
+ end
@@ -0,0 +1,5 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "websocket/client"
4
+ require_relative "websocket/errors"
5
+ require_relative "websocket/events"
@@ -0,0 +1,16 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "polygon_client/version"
4
+
5
+ require "eventmachine"
6
+ require "faraday"
7
+ require "faraday_middleware"
8
+ require "faraday_middleware/parse_oj"
9
+ require "dry-struct"
10
+ require "dry-types"
11
+ require "permessage_deflate"
12
+ require "websocket/driver"
13
+
14
+ require "polygon_client/types"
15
+ require "polygon_client/rest"
16
+ require "polygon_client/websocket"
@@ -0,0 +1,53 @@
1
+ # frozen_string_literal: true
2
+
3
+ lib = File.expand_path("lib", __dir__)
4
+ $LOAD_PATH.unshift(lib) unless $LOAD_PATH.include?(lib)
5
+ require "polygon_client/version"
6
+
7
+ Gem::Specification.new do |spec|
8
+ spec.name = "polygonio"
9
+ spec.version = PolygonClient::VERSION
10
+ spec.authors = ["Lance Carlson"]
11
+ spec.email = ["lancecarlson@gmail.com"]
12
+
13
+ spec.license = "MIT"
14
+
15
+ spec.summary = "Client library for polygon.io"
16
+ spec.description = "Client library for polygon.io's REST and Websocker API's"
17
+
18
+ spec.metadata = {
19
+ "homepage_uri" => "https://github.com/lancecarlson/polygon-client-rb",
20
+ "source_code_uri" => 'https://github.com/lancecarlson/polygon-client-rb',
21
+ "bug_tracker_uri" => 'https://github.com/lancecarlson/polygon-client-rb/issues'
22
+ }
23
+
24
+ # Specify which files should be added to the gem when it is released.
25
+ # The `git ls-files -z` loads the files in the RubyGem that have been added into git.
26
+ spec.files = Dir.chdir(File.expand_path(__dir__)) do
27
+ `git ls-files -z`.split("\x0").reject { |f| f.match(%r{^(test|spec|features)/}) }
28
+ end
29
+ spec.bindir = "exe"
30
+ spec.executables = spec.files.grep(%r{^exe/}) { |f| File.basename(f) }
31
+ spec.require_paths = ["lib"]
32
+
33
+ spec.add_runtime_dependency "activesupport", ["~> 6.0", ">= 6.0.2.2"]
34
+ spec.add_runtime_dependency "dry-struct", ["~> 1.2", ">= 1.2.0"]
35
+ spec.add_runtime_dependency "dry-types", ["~> 1.2", ">= 1.2.2"]
36
+ spec.add_runtime_dependency "eventmachine", ["~> 1.2", ">= 1.2.7"]
37
+ spec.add_runtime_dependency "faraday", ["~> 0.17", ">= 0.17.3"]
38
+ spec.add_runtime_dependency "faraday_middleware", ["~> 0.13", ">= 0.13.1"]
39
+ spec.add_runtime_dependency "faraday_middleware-parse_oj", ["~> 0.3", ">= 0.3.2"]
40
+ spec.add_runtime_dependency "oj", ["~> 3.10", ">= 3.10.1"]
41
+ spec.add_runtime_dependency "permessage_deflate", ["~> 0.1", ">= 0.1.4"]
42
+ spec.add_runtime_dependency "websocket-driver", ["~> 0.7", ">= 0.7.1"]
43
+
44
+ spec.add_development_dependency "bundler", "~> 2.0"
45
+ spec.add_development_dependency "bundler-audit", "~> 0.6"
46
+ spec.add_development_dependency "dotenv", "~> 2.7"
47
+ spec.add_development_dependency "faker", "~> 2.11"
48
+ spec.add_development_dependency "minitest", "~> 5.0"
49
+ spec.add_development_dependency "rake", "~> 13.0"
50
+ spec.add_development_dependency "rubocop", "~> 0.80"
51
+ spec.add_development_dependency "rubocop-performance", "~> 1.5"
52
+ spec.add_development_dependency "vcr", "~> 5.1"
53
+ end