tradingview-screener 0.2.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,581 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "json"
4
+
5
+ module TradingviewScreener
6
+ # ActiveRecord-style immutable relation for TradingView scanner.
7
+ #
8
+ # Stock.where(is_primary: true)
9
+ # .where(close: 10..300)
10
+ # .where(close: gt(:SMA20))
11
+ # .order(premarket_volume: :desc)
12
+ # .limit(100)
13
+ # .load
14
+ class Relation
15
+ include Enumerable
16
+
17
+ SCAN_URL = "https://scanner.tradingview.com/%<market>s/scan"
18
+ DEFAULT_RANGE = [0, 50].freeze
19
+
20
+ STOCK_TYPE_FILTER2 = {
21
+ "operator" => "and",
22
+ "operands" => [
23
+ {
24
+ "operation" => {
25
+ "operator" => "or",
26
+ "operands" => [
27
+ {
28
+ "operation" => {
29
+ "operator" => "and",
30
+ "operands" => [
31
+ { "expression" => { "left" => "type", "operation" => "equal", "right" => "stock" } },
32
+ { "expression" => { "left" => "typespecs", "operation" => "has", "right" => ["common"] } }
33
+ ]
34
+ }
35
+ },
36
+ {
37
+ "operation" => {
38
+ "operator" => "and",
39
+ "operands" => [
40
+ { "expression" => { "left" => "type", "operation" => "equal", "right" => "stock" } },
41
+ { "expression" => { "left" => "typespecs", "operation" => "has", "right" => ["preferred"] } }
42
+ ]
43
+ }
44
+ },
45
+ {
46
+ "operation" => {
47
+ "operator" => "and",
48
+ "operands" => [
49
+ { "expression" => { "left" => "type", "operation" => "equal", "right" => "dr" } }
50
+ ]
51
+ }
52
+ },
53
+ {
54
+ "operation" => {
55
+ "operator" => "and",
56
+ "operands" => [
57
+ { "expression" => { "left" => "type", "operation" => "equal", "right" => "fund" } },
58
+ { "expression" => { "left" => "typespecs", "operation" => "has_none_of", "right" => %w[etf mutual closedend] } }
59
+ ]
60
+ }
61
+ }
62
+ ]
63
+ }
64
+ },
65
+ { "expression" => { "left" => "typespecs", "operation" => "has_none_of", "right" => ["pre-ipo"] } }
66
+ ]
67
+ }.freeze
68
+
69
+ attr_reader :klass
70
+
71
+ def initialize(klass = nil, values: nil)
72
+ @klass = klass
73
+ @values = values || default_values(klass)
74
+ @records = nil
75
+ @loaded = false
76
+ end
77
+
78
+ def self.from_payload(klass, payload)
79
+ payload = payload.to_h.transform_keys(&:to_s)
80
+ filters = Array(payload["filter"]).map { |item| item.to_h.transform_keys(&:to_s) }
81
+ sort = payload["sort"].to_h.transform_keys(&:to_s)
82
+ range = Array(payload["range"])
83
+ offset = range[0] ? Integer(range[0]) : 0
84
+ limit =
85
+ if range[1]
86
+ Integer(range[1]) - offset
87
+ end
88
+
89
+ markets = Array(payload["markets"]).map(&:to_s)
90
+ markets = ["america"] if markets.empty?
91
+ overrides = {
92
+ "where_values" => filters,
93
+ "markets" => markets,
94
+ "tickers" => Array(payload.dig("symbols", "tickers")),
95
+ "filter2" => payload["filter2"] || deep_dup_static(STOCK_TYPE_FILTER2),
96
+ "none_value" => false,
97
+ "offset_value" => offset
98
+ }
99
+ overrides["select_values"] = Array(payload["columns"]).map(&:to_s) if Array(payload["columns"]).any?
100
+ if sort["sortBy"] && !sort["sortBy"].to_s.empty?
101
+ overrides["order_values"] = [{
102
+ "sortBy" => sort["sortBy"].to_s,
103
+ "sortOrder" => (sort["sortOrder"] || "desc").to_s
104
+ }]
105
+ end
106
+ overrides["limit_value"] = limit if limit && limit.positive?
107
+
108
+ base = new(klass)
109
+ merged = base.send(:deep_dup, base.values)
110
+ overrides.each { |key, value| merged[key.to_sym] = value }
111
+ new(klass, values: merged)
112
+ end
113
+
114
+ def self.deep_dup_static(value)
115
+ case value
116
+ when Hash then value.each_with_object({}) { |(k, v), memo| memo[k] = deep_dup_static(v) }
117
+ when Array then value.map { |item| deep_dup_static(item) }
118
+ when String then value.dup
119
+ else value
120
+ end
121
+ end
122
+
123
+ def select(*columns)
124
+ spawn(select_values: columns.flatten.map { |c| field_name(c) })
125
+ end
126
+
127
+ # AR-like where:
128
+ # where(is_primary: true)
129
+ # where(close: 10..300)
130
+ # where(type: %w[stock fund])
131
+ # where(close: gt(:SMA20))
132
+ # where(Screener[:volume].gt(1_000_000))
133
+ # where(Screener[:close].gt(Screener[:SMA20]), is_primary: true)
134
+ def where(*predicates, **conditions)
135
+ filters = predicates.map { |item| normalize_predicate(item) }
136
+ conditions.each { |key, value| filters.concat(build_condition(key, value)) }
137
+ spawn(where_values: where_values + filters)
138
+ end
139
+
140
+ def rewhere(**conditions)
141
+ keys = conditions.keys.map { |key| field_name(key) }
142
+ kept = where_values.reject { |item| keys.include?(item["left"]) }
143
+ replacement = conditions.flat_map { |key, value| build_condition(key, value) }
144
+ spawn(where_values: kept + replacement)
145
+ end
146
+
147
+ def order(*args, **kwargs)
148
+ sorts = []
149
+ args.each do |arg|
150
+ case arg
151
+ when Hash
152
+ arg.each { |key, dir| sorts << sort_entry(key, dir) }
153
+ when String, Symbol
154
+ sorts << sort_entry(arg, :asc)
155
+ else
156
+ raise ArgumentError, "unsupported order value: #{arg.inspect}"
157
+ end
158
+ end
159
+ kwargs.each { |key, dir| sorts << sort_entry(key, dir) }
160
+ return self if sorts.empty?
161
+
162
+ spawn(order_values: sorts)
163
+ end
164
+
165
+ def limit(value)
166
+ spawn(limit_value: Integer(value))
167
+ end
168
+
169
+ def offset(value)
170
+ spawn(offset_value: Integer(value))
171
+ end
172
+
173
+ def market(*markets)
174
+ list = markets.flatten.compact.map(&:to_s)
175
+ spawn(markets: list)
176
+ end
177
+
178
+ def tickers(*tickers)
179
+ spawn(tickers: tickers.flatten.map(&:to_s), markets: [])
180
+ end
181
+
182
+ def except(*keys)
183
+ next_values = deep_dup(@values)
184
+ keys.map(&:to_sym).each do |key|
185
+ case key
186
+ when :where then next_values[:where_values] = []
187
+ when :order then next_values[:order_values] = []
188
+ when :select then next_values[:select_values] = default_select
189
+ when :limit then next_values[:limit_value] = DEFAULT_RANGE[1]
190
+ when :offset then next_values[:offset_value] = 0
191
+ else
192
+ raise ArgumentError, "unknown except key: #{key}"
193
+ end
194
+ end
195
+ self.class.new(klass, values: next_values)
196
+ end
197
+
198
+ def merge(other)
199
+ raise ArgumentError, "merge expects Relation" unless other.is_a?(Relation)
200
+
201
+ spawn(
202
+ select_values: other.values[:select_values] || select_values,
203
+ where_values: where_values + other.where_values,
204
+ order_values: (other.order_values.empty? ? order_values : other.order_values),
205
+ limit_value: other.values[:limit_value] || limit_value,
206
+ offset_value: other.values[:offset_value] || offset_value,
207
+ markets: other.values[:markets] || markets,
208
+ tickers: other.values[:tickers] || tickers_value,
209
+ filter2: other.values[:filter2] || filter2
210
+ )
211
+ end
212
+
213
+ def unscope(*args)
214
+ except(*args)
215
+ end
216
+
217
+ def none
218
+ spawn(none_value: true)
219
+ end
220
+
221
+ def load(client: nil, cookies: nil, headers: nil, timeout: 20, label_product: "screener-stock", proxy: nil)
222
+ return self if loaded?
223
+
224
+ if none_value?
225
+ @records = []
226
+ @total_count = 0
227
+ @raw = { "totalCount" => 0, "data" => [] }
228
+ else
229
+ raw = execute(
230
+ client: client,
231
+ cookies: cookies,
232
+ headers: headers,
233
+ timeout: timeout,
234
+ label_product: label_product,
235
+ proxy: proxy
236
+ )
237
+ @raw = raw
238
+ @total_count = raw["totalCount"].to_i
239
+ @records = map_rows(raw)
240
+ end
241
+ @loaded = true
242
+ self
243
+ end
244
+
245
+ def reload(**opts)
246
+ reset
247
+ load(**opts)
248
+ end
249
+
250
+ def reset
251
+ @loaded = false
252
+ @records = nil
253
+ @total_count = nil
254
+ @raw = nil
255
+ self
256
+ end
257
+
258
+ def loaded?
259
+ @loaded
260
+ end
261
+
262
+ def to_a(**opts)
263
+ load(**opts) unless loaded?
264
+ records.dup
265
+ end
266
+
267
+ def records
268
+ load unless loaded?
269
+ @records
270
+ end
271
+
272
+ def each(...)
273
+ records.each(...)
274
+ end
275
+
276
+ def size
277
+ records.size
278
+ end
279
+ alias length size
280
+
281
+ def empty?
282
+ size.zero?
283
+ end
284
+
285
+ def exists?(**opts)
286
+ if loaded?
287
+ !records.empty?
288
+ else
289
+ limit(1).load(**opts).size.positive?
290
+ end
291
+ end
292
+
293
+ def count(**opts)
294
+ if loaded?
295
+ total_count
296
+ else
297
+ load(**opts).total_count
298
+ end
299
+ end
300
+
301
+ def total_count
302
+ load unless loaded?
303
+ @total_count
304
+ end
305
+
306
+ def first(n = nil, **opts)
307
+ rel = loaded? ? self : limit(n || 1).load(**opts)
308
+ n ? rel.records.first(n) : rel.records.first
309
+ end
310
+
311
+ def last(n = nil, **opts)
312
+ rows = to_a(**opts)
313
+ n ? rows.last(n) : rows.last
314
+ end
315
+
316
+ def pluck(*columns, **opts)
317
+ cols = columns.flatten.map { |c| field_name(c) }
318
+ rel = select_values == cols ? self : select(*cols)
319
+ rel.to_a(**opts).map do |row|
320
+ if cols.size == 1
321
+ row[cols.first]
322
+ else
323
+ cols.map { |col| row[col] }
324
+ end
325
+ end
326
+ end
327
+
328
+ def pick(*columns, **opts)
329
+ limit(1).pluck(*columns, **opts).first
330
+ end
331
+
332
+ def ids(**opts)
333
+ load(**opts) unless loaded?
334
+ records.map { |row| row["ticker"] }
335
+ end
336
+
337
+ def tickers_list(**opts)
338
+ ids(**opts)
339
+ end
340
+
341
+ def symbols(**opts)
342
+ ids(**opts).map do |ticker|
343
+ exchange, symbol = ticker.to_s.split(":", 2)
344
+ {
345
+ "exchange" => exchange,
346
+ "symbol" => symbol,
347
+ "full_symbol" => ticker
348
+ }
349
+ end
350
+ end
351
+
352
+ def raw
353
+ load unless loaded?
354
+ @raw
355
+ end
356
+
357
+ def to_sql
358
+ # scanner has no SQL; show the request payload for debugging (AR-ish inspect helper)
359
+ JSON.pretty_generate(to_query)
360
+ end
361
+
362
+ def to_query
363
+ {
364
+ "markets" => markets,
365
+ "symbols" => symbols_payload,
366
+ "options" => { "lang" => "en" },
367
+ "columns" => select_values,
368
+ "filter" => where_values,
369
+ "filter2" => filter2,
370
+ "sort" => order_payload,
371
+ "range" => [offset_value, offset_value + limit_value],
372
+ "ignore_unknown_fields" => false
373
+ }.compact
374
+ end
375
+ alias to_h to_query
376
+
377
+ def scanner_url
378
+ market = markets.size == 1 ? markets.first : "global"
379
+ market = "global" if market.nil? || market.empty?
380
+ format(SCAN_URL, market: market)
381
+ end
382
+
383
+ def selected_columns
384
+ select_values
385
+ end
386
+
387
+ def inspect
388
+ if loaded?
389
+ entries = records.first(3).map { |row| row["ticker"] || row["name"] }
390
+ entries << "..." if records.size > 3
391
+ "#<#{self.class.name} [#{entries.join(', ')}] size=#{records.size} total=#{total_count}>"
392
+ else
393
+ "#<#{self.class.name} market=#{markets.inspect} where=#{where_values.size} order=#{order_values.inspect} limit=#{limit_value}>"
394
+ end
395
+ end
396
+
397
+ def ==(other)
398
+ other.is_a?(self.class) && other.to_query == to_query
399
+ end
400
+
401
+ def values
402
+ @values
403
+ end
404
+
405
+ def where_values
406
+ Array(@values[:where_values])
407
+ end
408
+
409
+ def order_values
410
+ Array(@values[:order_values])
411
+ end
412
+
413
+ def select_values
414
+ Array(@values[:select_values])
415
+ end
416
+
417
+ def limit_value
418
+ @values[:limit_value] || DEFAULT_RANGE[1]
419
+ end
420
+
421
+ def offset_value
422
+ @values[:offset_value] || 0
423
+ end
424
+
425
+ def markets
426
+ Array(@values[:markets])
427
+ end
428
+
429
+ def tickers_value
430
+ Array(@values[:tickers])
431
+ end
432
+
433
+ def filter2
434
+ @values[:filter2]
435
+ end
436
+
437
+ def none_value?
438
+ @values[:none_value] == true
439
+ end
440
+
441
+ private
442
+
443
+ def default_values(klass)
444
+ {
445
+ select_values: klass&.default_columns || default_select,
446
+ where_values: klass&.default_where || [],
447
+ order_values: klass&.default_order || [{ "sortBy" => "market_cap_basic", "sortOrder" => "desc" }],
448
+ limit_value: klass&.default_limit || DEFAULT_RANGE[1],
449
+ offset_value: 0,
450
+ markets: Array(klass&.default_market || "america"),
451
+ tickers: [],
452
+ filter2: deep_dup(klass&.default_filter2 || STOCK_TYPE_FILTER2),
453
+ none_value: false
454
+ }
455
+ end
456
+
457
+ def default_select
458
+ %w[
459
+ name close type typespecs pricescale minmov fractional minmove2 currency
460
+ change volume relative_volume_10d_calc market_cap_basic fundamental_currency_code
461
+ price_earnings_ttm earnings_per_share_diluted_ttm earnings_per_share_diluted_yoy_growth_ttm
462
+ dividends_yield_current sector.tr market sector AnalystRating AnalystRating.tr
463
+ ]
464
+ end
465
+
466
+ def spawn(**overrides)
467
+ self.class.new(klass, values: deep_dup(@values).merge(overrides))
468
+ end
469
+
470
+ def field_name(value)
471
+ case value
472
+ when Field then value.name
473
+ when Predicate then value.left
474
+ else value.to_s
475
+ end
476
+ end
477
+
478
+ def normalize_predicate(value)
479
+ case value
480
+ when Predicate then value.to_h
481
+ when Hash
482
+ if value.key?("left") || value.key?(:left)
483
+ stringify_predicate(value)
484
+ else
485
+ raise ArgumentError, "use where(key: value) for hash conditions, got #{value.inspect}"
486
+ end
487
+ else
488
+ raise ArgumentError, "unsupported where argument: #{value.inspect}"
489
+ end
490
+ end
491
+
492
+ def stringify_predicate(hash)
493
+ {
494
+ "left" => hash[:left] || hash["left"],
495
+ "operation" => hash[:operation] || hash["operation"],
496
+ "right" => hash.key?(:right) ? hash[:right] : hash["right"]
497
+ }
498
+ end
499
+
500
+ def build_condition(key, value)
501
+ name = field_name(key)
502
+ case value
503
+ when Predicates::Op
504
+ [{ "left" => name, "operation" => value.operation, "right" => unwrap_value(value.value) }]
505
+ when Predicate
506
+ [value.to_h.merge("left" => name)]
507
+ when Range
508
+ right = [value.begin, value.end]
509
+ right[1] = right[1].pred if value.exclude_end? && right[1].respond_to?(:pred)
510
+ [{ "left" => name, "operation" => "in_range", "right" => right }]
511
+ when Array
512
+ [{ "left" => name, "operation" => "in_range", "right" => value }]
513
+ when Field, Symbol
514
+ [{ "left" => name, "operation" => "equal", "right" => field_name(value) }]
515
+ else
516
+ [{ "left" => name, "operation" => "equal", "right" => value }]
517
+ end
518
+ end
519
+
520
+ def unwrap_value(value)
521
+ case value
522
+ when Field then value.name
523
+ when Symbol then value.to_s
524
+ else value
525
+ end
526
+ end
527
+
528
+ def sort_entry(key, dir)
529
+ direction =
530
+ case dir
531
+ when :asc, "asc", true then "asc"
532
+ when :desc, "desc", false then "desc"
533
+ else
534
+ raise ArgumentError, "order direction must be :asc or :desc, got #{dir.inspect}"
535
+ end
536
+ { "sortBy" => field_name(key), "sortOrder" => direction }
537
+ end
538
+
539
+ def order_payload
540
+ order_values.first || { "sortBy" => "market_cap_basic", "sortOrder" => "desc" }
541
+ end
542
+
543
+ def symbols_payload
544
+ payload = {}
545
+ payload["tickers"] = tickers_value if tickers_value.any?
546
+ payload
547
+ end
548
+
549
+ def execute(client:, cookies:, headers:, timeout:, label_product:, proxy: nil)
550
+ http = client || Client.new(
551
+ timeout: timeout,
552
+ cookies: cookies,
553
+ headers: headers,
554
+ label_product: label_product,
555
+ proxy: proxy
556
+ )
557
+ http.post(scanner_url, to_query)
558
+ end
559
+
560
+ def map_rows(raw)
561
+ columns = select_values
562
+ Array(raw["data"]).map do |item|
563
+ values = Array(item["d"])
564
+ row = {}
565
+ columns.each_with_index { |column, index| row[column] = values[index] }
566
+ # scanner primary key always comes from response key `s`
567
+ row["ticker"] = item["s"]
568
+ row
569
+ end
570
+ end
571
+
572
+ def deep_dup(value)
573
+ case value
574
+ when Hash then value.each_with_object({}) { |(k, v), memo| memo[k] = deep_dup(v) }
575
+ when Array then value.map { |item| deep_dup(item) }
576
+ when String then value.dup
577
+ else value
578
+ end
579
+ end
580
+ end
581
+ end