schwab_rb 1.0.3 → 1.0.5
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.md +34 -0
- data/README.md +41 -0
- data/lib/schwab_rb/stream/base.rb +74 -7
- data/lib/schwab_rb/stream/client.rb +7 -0
- data/lib/schwab_rb/version.rb +1 -1
- metadata +2 -2
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 8d136dcb5cef454ed8cfaca6a8ad90a8ebf25971e283e4098f73dc765d06023c
|
|
4
|
+
data.tar.gz: 93bfb2468fcc4706b361bd987087256eed9fe84e6ef3486743659acd51e0de61
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: 3d684f28d255009c90b0fb0abd401f6c452532bdcdbb1ccbc42d6a33c893aef8f6821af278ffbd9f81792e53db7f511ce561c7d29ac80a5f1d682aded578f886
|
|
7
|
+
data.tar.gz: 2772b8c48ab306c3d821639bc68ac950bdd7b8b56995f13a55a495b4d60fa5b0f87e2114d471622e6ce2069a8d93a3d8837865f346f7545148cf5b129cb1a1d4
|
data/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,39 @@
|
|
|
1
1
|
## [Unreleased]
|
|
2
2
|
|
|
3
|
+
## [1.0.5] - 2026-10-19
|
|
4
|
+
|
|
5
|
+
### Added
|
|
6
|
+
- Sanitized stream subscription logging (`SUBS`, `ADD`, `UNSUBS`) at INFO level with service, command, request ID, and keys.
|
|
7
|
+
- Stream subscription acknowledgement response logging at INFO level with service, command, response code, message, and request ID.
|
|
8
|
+
|
|
9
|
+
### Security
|
|
10
|
+
- Redacted `Authorization` access tokens in WebSocket outbound debug logging.
|
|
11
|
+
|
|
12
|
+
## [1.0.4] - 2026-09-21
|
|
13
|
+
|
|
14
|
+
### Added
|
|
15
|
+
- Dynamic symbol unsubscription (`stream.unsub`) using Schwab `UNSUBS` command to remove symbols as market windows close without disconnecting.
|
|
16
|
+
- `stream.add` method (alias for `on`) for explicitly adding subscriptions to active streams.
|
|
17
|
+
|
|
18
|
+
### Fixed
|
|
19
|
+
- Treat `nil` socket read in `Base#run_receive_loop` as disconnect error instead of clean exit to prevent silent reconnect spin loops.
|
|
20
|
+
- Outbound WebSocket write synchronization using a Mutex to prevent framing issues when subscriptions change dynamically.
|
|
21
|
+
|
|
22
|
+
## [1.0.3] - 2026-09-19
|
|
23
|
+
|
|
24
|
+
### Fixed
|
|
25
|
+
- Prevent `load_token` from deleting database row on expired access token (#33).
|
|
26
|
+
|
|
27
|
+
## [1.0.2] - 2026-09-18
|
|
28
|
+
|
|
29
|
+
### Fixed
|
|
30
|
+
- Fix WebSocket framing to resolve stream login bad command formatting (#32).
|
|
31
|
+
|
|
32
|
+
## [1.0.1] - 2026-09-17
|
|
33
|
+
|
|
34
|
+
### Fixed
|
|
35
|
+
- Fix stream login authorization header formatting and STDOUT logger routing (#30).
|
|
36
|
+
|
|
3
37
|
## [1.0.0] - 2026-09-05
|
|
4
38
|
|
|
5
39
|
### Breaking Changes
|
data/README.md
CHANGED
|
@@ -164,6 +164,47 @@ price_history = client.get_price_history(
|
|
|
164
164
|
)
|
|
165
165
|
```
|
|
166
166
|
|
|
167
|
+
### Real-Time WebSocket Streaming
|
|
168
|
+
|
|
169
|
+
Stream Level 1 quotes, Level 2 order books, chart candles, and account activity over a single WebSocket connection:
|
|
170
|
+
|
|
171
|
+
```ruby
|
|
172
|
+
client = SchwabRb::Auth.init_client_easy(
|
|
173
|
+
ENV['SCHWAB_API_KEY'],
|
|
174
|
+
ENV['SCHWAB_APP_SECRET'],
|
|
175
|
+
ENV['APP_CALLBACK_URL']
|
|
176
|
+
)
|
|
177
|
+
|
|
178
|
+
stream = SchwabRb::Stream::Client.new(client)
|
|
179
|
+
|
|
180
|
+
# Subscribe to Level 1 Equities
|
|
181
|
+
stream.on(:level_one_equities, symbols: %w[SPY QQQ], fields: :all) do |event|
|
|
182
|
+
puts "Equity quote: #{event}"
|
|
183
|
+
end
|
|
184
|
+
|
|
185
|
+
# Subscribe to Level 1 Futures
|
|
186
|
+
stream.on(:level_one_futures, symbols: ["/ES"], fields: :all) do |event|
|
|
187
|
+
puts "Futures quote: #{event}"
|
|
188
|
+
end
|
|
189
|
+
|
|
190
|
+
# Subscribe to Level 2 Order Book
|
|
191
|
+
stream.on(:nyse_book, symbols: ["SPY"], fields: :all) do |event|
|
|
192
|
+
puts "Order book: #{event}"
|
|
193
|
+
end
|
|
194
|
+
|
|
195
|
+
# Start async in a background thread
|
|
196
|
+
stream.start_async
|
|
197
|
+
|
|
198
|
+
# Dynamically add symbols while streaming
|
|
199
|
+
stream.add(:level_one_equities, symbols: ["IWM"])
|
|
200
|
+
|
|
201
|
+
# Dynamically remove symbols when window closes without dropping stream
|
|
202
|
+
stream.unsub(:level_one_equities, symbols: ["SPY", "QQQ"])
|
|
203
|
+
|
|
204
|
+
# Stop streaming when finished
|
|
205
|
+
stream.stop
|
|
206
|
+
```
|
|
207
|
+
|
|
167
208
|
## Data Objects
|
|
168
209
|
|
|
169
210
|
The gem includes structured data objects for better handling of API responses. Most API methods support a `return_data_objects` parameter (defaults to `true`) which returns parsed Ruby objects instead of raw JSON responses:
|
|
@@ -27,13 +27,14 @@ module SchwabRb
|
|
|
27
27
|
@connect_time = nil
|
|
28
28
|
@backoff_time = INITIAL_BACKOFF
|
|
29
29
|
@should_run = false
|
|
30
|
+
@write_mutex = Mutex.new
|
|
30
31
|
end
|
|
31
32
|
|
|
32
33
|
def on(service_symbol, symbols: nil, fields: nil, &block)
|
|
33
34
|
service_name = Services.lookup(service_symbol)
|
|
34
35
|
resolved_fields = fields ? Fields.resolve(service_name, fields) : nil
|
|
35
36
|
|
|
36
|
-
handler = { symbols: Array(symbols), fields: resolved_fields, callback: block }
|
|
37
|
+
handler = { symbols: Array(symbols).map(&:to_s), fields: resolved_fields, callback: block }
|
|
37
38
|
@handlers[service_name] ||= []
|
|
38
39
|
@handlers[service_name] << handler
|
|
39
40
|
|
|
@@ -46,6 +47,37 @@ module SchwabRb
|
|
|
46
47
|
|
|
47
48
|
self
|
|
48
49
|
end
|
|
50
|
+
alias add on
|
|
51
|
+
|
|
52
|
+
def unsub(service_symbol, symbols: nil)
|
|
53
|
+
service_name = Services.lookup(service_symbol)
|
|
54
|
+
symbols_to_remove = Array(symbols).map(&:to_s)
|
|
55
|
+
return self if symbols_to_remove.empty?
|
|
56
|
+
|
|
57
|
+
if @subscriptions[service_name]
|
|
58
|
+
@subscriptions[service_name][:symbols] -= symbols_to_remove
|
|
59
|
+
@subscriptions.delete(service_name) if @subscriptions[service_name][:symbols].empty?
|
|
60
|
+
end
|
|
61
|
+
|
|
62
|
+
if @handlers[service_name]
|
|
63
|
+
@handlers[service_name].each do |h|
|
|
64
|
+
h[:symbols] -= symbols_to_remove
|
|
65
|
+
end
|
|
66
|
+
@handlers[service_name].reject! { |h| h[:symbols].empty? }
|
|
67
|
+
@handlers.delete(service_name) if @handlers[service_name].empty?
|
|
68
|
+
end
|
|
69
|
+
|
|
70
|
+
if @connected
|
|
71
|
+
request = @message_builder.build_request(
|
|
72
|
+
service_name, Commands::UNSUBS,
|
|
73
|
+
keys: symbols_to_remove
|
|
74
|
+
)
|
|
75
|
+
log_subscription_request(request)
|
|
76
|
+
send_message(@message_builder.wrap_requests(request))
|
|
77
|
+
end
|
|
78
|
+
|
|
79
|
+
self
|
|
80
|
+
end
|
|
49
81
|
|
|
50
82
|
def start
|
|
51
83
|
@should_run = true
|
|
@@ -64,7 +96,7 @@ module SchwabRb
|
|
|
64
96
|
|
|
65
97
|
def stop
|
|
66
98
|
@should_run = false
|
|
67
|
-
|
|
99
|
+
@connected = false
|
|
68
100
|
end
|
|
69
101
|
|
|
70
102
|
def connected?
|
|
@@ -145,6 +177,7 @@ module SchwabRb
|
|
|
145
177
|
keys: sub[:symbols],
|
|
146
178
|
fields: sub[:fields]
|
|
147
179
|
)
|
|
180
|
+
log_subscription_request(request)
|
|
148
181
|
send_message(@message_builder.wrap_requests(request))
|
|
149
182
|
end
|
|
150
183
|
end
|
|
@@ -155,13 +188,14 @@ module SchwabRb
|
|
|
155
188
|
keys: handler[:symbols],
|
|
156
189
|
fields: handler[:fields]
|
|
157
190
|
)
|
|
191
|
+
log_subscription_request(request)
|
|
158
192
|
send_message(@message_builder.wrap_requests(request))
|
|
159
193
|
end
|
|
160
194
|
|
|
161
195
|
def run_receive_loop
|
|
162
196
|
while @connected && @should_run
|
|
163
197
|
message = @connection.read
|
|
164
|
-
|
|
198
|
+
raise "Connection closed by remote host" if message.nil?
|
|
165
199
|
|
|
166
200
|
parsed = JSON.parse(message.to_str)
|
|
167
201
|
dispatch_message(parsed)
|
|
@@ -182,10 +216,36 @@ module SchwabRb
|
|
|
182
216
|
return unless parsed.key?("response")
|
|
183
217
|
|
|
184
218
|
parsed["response"].each do |resp|
|
|
185
|
-
|
|
219
|
+
log_stream_response(resp)
|
|
186
220
|
end
|
|
187
221
|
end
|
|
188
222
|
|
|
223
|
+
def log_subscription_request(request)
|
|
224
|
+
service = request["service"]
|
|
225
|
+
command = request["command"]
|
|
226
|
+
request_id = request["requestid"]
|
|
227
|
+
keys = request.dig("parameters", "keys")
|
|
228
|
+
SchwabRb::Logger.logger.info(
|
|
229
|
+
"Stream subscription request: service=#{service} command=#{command} " \
|
|
230
|
+
"request_id=#{request_id} keys=#{keys}"
|
|
231
|
+
)
|
|
232
|
+
end
|
|
233
|
+
|
|
234
|
+
def log_stream_response(resp)
|
|
235
|
+
service = resp["service"]
|
|
236
|
+
command = resp["command"]
|
|
237
|
+
request_id = resp["requestid"]
|
|
238
|
+
content = resp["content"] || {}
|
|
239
|
+
code = content["code"]
|
|
240
|
+
msg = content["msg"]
|
|
241
|
+
|
|
242
|
+
SchwabRb::Logger.logger.info(
|
|
243
|
+
"Stream response: service=#{service} command=#{command} " \
|
|
244
|
+
"code=#{code} msg=#{msg.inspect} request_id=#{request_id}"
|
|
245
|
+
)
|
|
246
|
+
SchwabRb::Logger.logger.debug("Raw stream response: #{resp}")
|
|
247
|
+
end
|
|
248
|
+
|
|
189
249
|
def handle_disconnect(error)
|
|
190
250
|
@connected = false
|
|
191
251
|
begin
|
|
@@ -221,9 +281,16 @@ module SchwabRb
|
|
|
221
281
|
end
|
|
222
282
|
|
|
223
283
|
def send_message(json_string)
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
284
|
+
@write_mutex.synchronize do
|
|
285
|
+
return unless @connection
|
|
286
|
+
|
|
287
|
+
if SchwabRb::Logger.logger.debug?
|
|
288
|
+
sanitized = json_string.gsub(/"Authorization"\s*:\s*"[^"]*"/, '"Authorization":"[REDACTED]"')
|
|
289
|
+
SchwabRb::Logger.logger.debug("Stream sending: #{sanitized}")
|
|
290
|
+
end
|
|
291
|
+
@connection.write(json_string)
|
|
292
|
+
@connection.flush
|
|
293
|
+
end
|
|
227
294
|
end
|
|
228
295
|
|
|
229
296
|
def merge_subscription(existing, handler)
|
|
@@ -15,6 +15,12 @@ module SchwabRb
|
|
|
15
15
|
@stream.on(service_symbol, symbols: symbols, fields: fields, &block)
|
|
16
16
|
self
|
|
17
17
|
end
|
|
18
|
+
alias add on
|
|
19
|
+
|
|
20
|
+
def unsub(service_symbol, symbols: nil)
|
|
21
|
+
@stream.unsub(service_symbol, symbols: symbols)
|
|
22
|
+
self
|
|
23
|
+
end
|
|
18
24
|
|
|
19
25
|
def start
|
|
20
26
|
@thread = Thread.new do
|
|
@@ -32,6 +38,7 @@ module SchwabRb
|
|
|
32
38
|
|
|
33
39
|
def stop
|
|
34
40
|
@stream.stop
|
|
41
|
+
@thread&.raise(Interrupt)
|
|
35
42
|
@thread&.join(5)
|
|
36
43
|
@thread = nil
|
|
37
44
|
end
|
data/lib/schwab_rb/version.rb
CHANGED
metadata
CHANGED
|
@@ -1,13 +1,13 @@
|
|
|
1
1
|
--- !ruby/object:Gem::Specification
|
|
2
2
|
name: schwab_rb
|
|
3
3
|
version: !ruby/object:Gem::Version
|
|
4
|
-
version: 1.0.
|
|
4
|
+
version: 1.0.5
|
|
5
5
|
platform: ruby
|
|
6
6
|
authors:
|
|
7
7
|
- Joseph Platta
|
|
8
8
|
bindir: exe
|
|
9
9
|
cert_chain: []
|
|
10
|
-
date: 2026-
|
|
10
|
+
date: 2026-10-19 00:00:00.000000000 Z
|
|
11
11
|
dependencies:
|
|
12
12
|
- !ruby/object:Gem::Dependency
|
|
13
13
|
name: async
|