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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 51cb666266069aab4bab9aec3d7b8d283c122cde01e99dae36976aec36eef51a
4
- data.tar.gz: 44d23aad83b0096b1856b882604e298cadb15cc47f2cde51cd37c91bf32a8880
3
+ metadata.gz: 8d136dcb5cef454ed8cfaca6a8ad90a8ebf25971e283e4098f73dc765d06023c
4
+ data.tar.gz: 93bfb2468fcc4706b361bd987087256eed9fe84e6ef3486743659acd51e0de61
5
5
  SHA512:
6
- metadata.gz: d870b1e3f5dc36ae3f463b65d94c3a378ad59f9f4745c06b0cae5040a8e04ec126a384725846bec522dc94bb5bc22f1cba75e9e5a53e58d11f63e6216dd9633a
7
- data.tar.gz: 15df306b19a864cfdf158e8e4ae73c8831470375032b253a835b58bfaac5594afa3ae7ad52912f3448a665a16b7ac1b8132b03b5b8a040ca1580ca5510374c61
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
- disconnect
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
- break if message.nil?
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
- SchwabRb::Logger.logger.debug("Stream response: #{resp}")
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
- SchwabRb::Logger.logger.debug("Stream sending: #{json_string}")
225
- @connection.write(json_string)
226
- @connection.flush
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
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module SchwabRb
4
- VERSION = "1.0.3"
4
+ VERSION = "1.0.5"
5
5
  end
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.3
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-09-07 00:00:00.000000000 Z
10
+ date: 2026-10-19 00:00:00.000000000 Z
11
11
  dependencies:
12
12
  - !ruby/object:Gem::Dependency
13
13
  name: async