Class: TradingviewScreener::Relation

Inherits:
Object
  • Object
show all
Includes:
Enumerable
Defined in:
lib/tradingview_screener/relation.rb

Overview

ActiveRecord-style immutable relation for TradingView scanner.

Stock.where(is_primary: true)
   .where(close: 10..300)
   .where(close: gt(:SMA20))
   .order(premarket_volume: :desc)
   .limit(100)
   .load

Constant Summary collapse

SCAN_URL =
"https://scanner.tradingview.com/%<market>s/scan"
DEFAULT_RANGE =
[0, 50].freeze
STOCK_TYPE_FILTER2 =
{
  "operator" => "and",
  "operands" => [
    {
      "operation" => {
        "operator" => "or",
        "operands" => [
          {
            "operation" => {
              "operator" => "and",
              "operands" => [
                { "expression" => { "left" => "type", "operation" => "equal", "right" => "stock" } },
                { "expression" => { "left" => "typespecs", "operation" => "has", "right" => ["common"] } }
              ]
            }
          },
          {
            "operation" => {
              "operator" => "and",
              "operands" => [
                { "expression" => { "left" => "type", "operation" => "equal", "right" => "stock" } },
                { "expression" => { "left" => "typespecs", "operation" => "has", "right" => ["preferred"] } }
              ]
            }
          },
          {
            "operation" => {
              "operator" => "and",
              "operands" => [
                { "expression" => { "left" => "type", "operation" => "equal", "right" => "dr" } }
              ]
            }
          },
          {
            "operation" => {
              "operator" => "and",
              "operands" => [
                { "expression" => { "left" => "type", "operation" => "equal", "right" => "fund" } },
                { "expression" => { "left" => "typespecs", "operation" => "has_none_of", "right" => %w[etf mutual closedend] } }
              ]
            }
          }
        ]
      }
    },
    { "expression" => { "left" => "typespecs", "operation" => "has_none_of", "right" => ["pre-ipo"] } }
  ]
}.freeze

Instance Attribute Summary collapse

Class Method Summary collapse

Instance Method Summary collapse

Constructor Details

#initialize(klass = nil, values: nil) ⇒ Relation

Returns a new instance of Relation.



71
72
73
74
75
76
# File 'lib/tradingview_screener/relation.rb', line 71

def initialize(klass = nil, values: nil)
  @klass = klass
  @values = values || default_values(klass)
  @records = nil
  @loaded = false
end

Instance Attribute Details

#klassObject (readonly)

Returns the value of attribute klass.



69
70
71
# File 'lib/tradingview_screener/relation.rb', line 69

def klass
  @klass
end

Class Method Details

.deep_dup_static(value) ⇒ Object



114
115
116
117
118
119
120
121
# File 'lib/tradingview_screener/relation.rb', line 114

def self.deep_dup_static(value)
  case value
  when Hash then value.each_with_object({}) { |(k, v), memo| memo[k] = deep_dup_static(v) }
  when Array then value.map { |item| deep_dup_static(item) }
  when String then value.dup
  else value
  end
end

.from_payload(klass, payload) ⇒ Object



78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
# File 'lib/tradingview_screener/relation.rb', line 78

def self.from_payload(klass, payload)
  payload = payload.to_h.transform_keys(&:to_s)
  filters = Array(payload["filter"]).map { |item| item.to_h.transform_keys(&:to_s) }
  sort = payload["sort"].to_h.transform_keys(&:to_s)
  range = Array(payload["range"])
  offset = range[0] ? Integer(range[0]) : 0
  limit =
    if range[1]
      Integer(range[1]) - offset
    end

  markets = Array(payload["markets"]).map(&:to_s)
  markets = ["america"] if markets.empty?
  overrides = {
    "where_values" => filters,
    "markets" => markets,
    "tickers" => Array(payload.dig("symbols", "tickers")),
    "filter2" => payload["filter2"] || deep_dup_static(STOCK_TYPE_FILTER2),
    "none_value" => false,
    "offset_value" => offset
  }
  overrides["select_values"] = Array(payload["columns"]).map(&:to_s) if Array(payload["columns"]).any?
  if sort["sortBy"] && !sort["sortBy"].to_s.empty?
    overrides["order_values"] = [{
      "sortBy" => sort["sortBy"].to_s,
      "sortOrder" => (sort["sortOrder"] || "desc").to_s
    }]
  end
  overrides["limit_value"] = limit if limit && limit.positive?

  base = new(klass)
  merged = base.send(:deep_dup, base.values)
  overrides.each { |key, value| merged[key.to_sym] = value }
  new(klass, values: merged)
end

Instance Method Details

#==(other) ⇒ Object



397
398
399
# File 'lib/tradingview_screener/relation.rb', line 397

def ==(other)
  other.is_a?(self.class) && other.to_query == to_query
end

#count(**opts) ⇒ Object



293
294
295
296
297
298
299
# File 'lib/tradingview_screener/relation.rb', line 293

def count(**opts)
  if loaded?
    total_count
  else
    load(**opts).total_count
  end
end

#eachObject



272
273
274
# File 'lib/tradingview_screener/relation.rb', line 272

def each(...)
  records.each(...)
end

#empty?Boolean

Returns:

  • (Boolean)


281
282
283
# File 'lib/tradingview_screener/relation.rb', line 281

def empty?
  size.zero?
end

#except(*keys) ⇒ Object



182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
# File 'lib/tradingview_screener/relation.rb', line 182

def except(*keys)
  next_values = deep_dup(@values)
  keys.map(&:to_sym).each do |key|
    case key
    when :where then next_values[:where_values] = []
    when :order then next_values[:order_values] = []
    when :select then next_values[:select_values] = default_select
    when :limit then next_values[:limit_value] = DEFAULT_RANGE[1]
    when :offset then next_values[:offset_value] = 0
    else
      raise ArgumentError, "unknown except key: #{key}"
    end
  end
  self.class.new(klass, values: next_values)
end

#exists?(**opts) ⇒ Boolean

Returns:

  • (Boolean)


285
286
287
288
289
290
291
# File 'lib/tradingview_screener/relation.rb', line 285

def exists?(**opts)
  if loaded?
    !records.empty?
  else
    limit(1).load(**opts).size.positive?
  end
end

#filter2Object



433
434
435
# File 'lib/tradingview_screener/relation.rb', line 433

def filter2
  @values[:filter2]
end

#first(n = nil, **opts) ⇒ Object



306
307
308
309
# File 'lib/tradingview_screener/relation.rb', line 306

def first(n = nil, **opts)
  rel = loaded? ? self : limit(n || 1).load(**opts)
  n ? rel.records.first(n) : rel.records.first
end

#ids(**opts) ⇒ Object



332
333
334
335
# File 'lib/tradingview_screener/relation.rb', line 332

def ids(**opts)
  load(**opts) unless loaded?
  records.map { |row| row["ticker"] }
end

#inspectObject



387
388
389
390
391
392
393
394
395
# File 'lib/tradingview_screener/relation.rb', line 387

def inspect
  if loaded?
    entries = records.first(3).map { |row| row["ticker"] || row["name"] }
    entries << "..." if records.size > 3
    "#<#{self.class.name} [#{entries.join(', ')}] size=#{records.size} total=#{total_count}>"
  else
    "#<#{self.class.name} market=#{markets.inspect} where=#{where_values.size} order=#{order_values.inspect} limit=#{limit_value}>"
  end
end

#last(n = nil, **opts) ⇒ Object



311
312
313
314
# File 'lib/tradingview_screener/relation.rb', line 311

def last(n = nil, **opts)
  rows = to_a(**opts)
  n ? rows.last(n) : rows.last
end

#limit(value) ⇒ Object



165
166
167
# File 'lib/tradingview_screener/relation.rb', line 165

def limit(value)
  spawn(limit_value: Integer(value))
end

#limit_valueObject



417
418
419
# File 'lib/tradingview_screener/relation.rb', line 417

def limit_value
  @values[:limit_value] || DEFAULT_RANGE[1]
end

#load(client: nil, cookies: nil, headers: nil, timeout: 20, label_product: "screener-stock", proxy: nil) ⇒ Object



221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
# File 'lib/tradingview_screener/relation.rb', line 221

def load(client: nil, cookies: nil, headers: nil, timeout: 20, label_product: "screener-stock", proxy: nil)
  return self if loaded?

  if none_value?
    @records = []
    @total_count = 0
    @raw = { "totalCount" => 0, "data" => [] }
  else
    raw = execute(
      client: client,
      cookies: cookies,
      headers: headers,
      timeout: timeout,
      label_product: label_product,
      proxy: proxy
    )
    @raw = raw
    @total_count = raw["totalCount"].to_i
    @records = map_rows(raw)
  end
  @loaded = true
  self
end

#loaded?Boolean

Returns:

  • (Boolean)


258
259
260
# File 'lib/tradingview_screener/relation.rb', line 258

def loaded?
  @loaded
end

#market(*markets) ⇒ Object



173
174
175
176
# File 'lib/tradingview_screener/relation.rb', line 173

def market(*markets)
  list = markets.flatten.compact.map(&:to_s)
  spawn(markets: list)
end

#marketsObject



425
426
427
# File 'lib/tradingview_screener/relation.rb', line 425

def markets
  Array(@values[:markets])
end

#merge(other) ⇒ Object

Raises:

  • (ArgumentError)


198
199
200
201
202
203
204
205
206
207
208
209
210
211
# File 'lib/tradingview_screener/relation.rb', line 198

def merge(other)
  raise ArgumentError, "merge expects Relation" unless other.is_a?(Relation)

  spawn(
    select_values: other.values[:select_values] || select_values,
    where_values: where_values + other.where_values,
    order_values: (other.order_values.empty? ? order_values : other.order_values),
    limit_value: other.values[:limit_value] || limit_value,
    offset_value: other.values[:offset_value] || offset_value,
    markets: other.values[:markets] || markets,
    tickers: other.values[:tickers] || tickers_value,
    filter2: other.values[:filter2] || filter2
  )
end

#noneObject



217
218
219
# File 'lib/tradingview_screener/relation.rb', line 217

def none
  spawn(none_value: true)
end

#none_value?Boolean

Returns:

  • (Boolean)


437
438
439
# File 'lib/tradingview_screener/relation.rb', line 437

def none_value?
  @values[:none_value] == true
end

#offset(value) ⇒ Object



169
170
171
# File 'lib/tradingview_screener/relation.rb', line 169

def offset(value)
  spawn(offset_value: Integer(value))
end

#offset_valueObject



421
422
423
# File 'lib/tradingview_screener/relation.rb', line 421

def offset_value
  @values[:offset_value] || 0
end

#order(*args, **kwargs) ⇒ Object



147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
# File 'lib/tradingview_screener/relation.rb', line 147

def order(*args, **kwargs)
  sorts = []
  args.each do |arg|
    case arg
    when Hash
      arg.each { |key, dir| sorts << sort_entry(key, dir) }
    when String, Symbol
      sorts << sort_entry(arg, :asc)
    else
      raise ArgumentError, "unsupported order value: #{arg.inspect}"
    end
  end
  kwargs.each { |key, dir| sorts << sort_entry(key, dir) }
  return self if sorts.empty?

  spawn(order_values: sorts)
end

#order_valuesObject



409
410
411
# File 'lib/tradingview_screener/relation.rb', line 409

def order_values
  Array(@values[:order_values])
end

#pick(*columns, **opts) ⇒ Object



328
329
330
# File 'lib/tradingview_screener/relation.rb', line 328

def pick(*columns, **opts)
  limit(1).pluck(*columns, **opts).first
end

#pluck(*columns, **opts) ⇒ Object



316
317
318
319
320
321
322
323
324
325
326
# File 'lib/tradingview_screener/relation.rb', line 316

def pluck(*columns, **opts)
  cols = columns.flatten.map { |c| field_name(c) }
  rel = select_values == cols ? self : select(*cols)
  rel.to_a(**opts).map do |row|
    if cols.size == 1
      row[cols.first]
    else
      cols.map { |col| row[col] }
    end
  end
end

#rawObject



352
353
354
355
# File 'lib/tradingview_screener/relation.rb', line 352

def raw
  load unless loaded?
  @raw
end

#recordsObject



267
268
269
270
# File 'lib/tradingview_screener/relation.rb', line 267

def records
  load unless loaded?
  @records
end

#reload(**opts) ⇒ Object



245
246
247
248
# File 'lib/tradingview_screener/relation.rb', line 245

def reload(**opts)
  reset
  load(**opts)
end

#resetObject



250
251
252
253
254
255
256
# File 'lib/tradingview_screener/relation.rb', line 250

def reset
  @loaded = false
  @records = nil
  @total_count = nil
  @raw = nil
  self
end

#rewhere(**conditions) ⇒ Object



140
141
142
143
144
145
# File 'lib/tradingview_screener/relation.rb', line 140

def rewhere(**conditions)
  keys = conditions.keys.map { |key| field_name(key) }
  kept = where_values.reject { |item| keys.include?(item["left"]) }
  replacement = conditions.flat_map { |key, value| build_condition(key, value) }
  spawn(where_values: kept + replacement)
end

#scanner_urlObject



377
378
379
380
381
# File 'lib/tradingview_screener/relation.rb', line 377

def scanner_url
  market = markets.size == 1 ? markets.first : "global"
  market = "global" if market.nil? || market.empty?
  format(SCAN_URL, market: market)
end

#select(*columns) ⇒ Object



123
124
125
# File 'lib/tradingview_screener/relation.rb', line 123

def select(*columns)
  spawn(select_values: columns.flatten.map { |c| field_name(c) })
end

#select_valuesObject



413
414
415
# File 'lib/tradingview_screener/relation.rb', line 413

def select_values
  Array(@values[:select_values])
end

#selected_columnsObject



383
384
385
# File 'lib/tradingview_screener/relation.rb', line 383

def selected_columns
  select_values
end

#sizeObject Also known as: length



276
277
278
# File 'lib/tradingview_screener/relation.rb', line 276

def size
  records.size
end

#symbols(**opts) ⇒ Object



341
342
343
344
345
346
347
348
349
350
# File 'lib/tradingview_screener/relation.rb', line 341

def symbols(**opts)
  ids(**opts).map do |ticker|
    exchange, symbol = ticker.to_s.split(":", 2)
    {
      "exchange" => exchange,
      "symbol" => symbol,
      "full_symbol" => ticker
    }
  end
end

#tickers(*tickers) ⇒ Object



178
179
180
# File 'lib/tradingview_screener/relation.rb', line 178

def tickers(*tickers)
  spawn(tickers: tickers.flatten.map(&:to_s), markets: [])
end

#tickers_list(**opts) ⇒ Object



337
338
339
# File 'lib/tradingview_screener/relation.rb', line 337

def tickers_list(**opts)
  ids(**opts)
end

#tickers_valueObject



429
430
431
# File 'lib/tradingview_screener/relation.rb', line 429

def tickers_value
  Array(@values[:tickers])
end

#to_a(**opts) ⇒ Object



262
263
264
265
# File 'lib/tradingview_screener/relation.rb', line 262

def to_a(**opts)
  load(**opts) unless loaded?
  records.dup
end

#to_queryObject Also known as: to_h



362
363
364
365
366
367
368
369
370
371
372
373
374
# File 'lib/tradingview_screener/relation.rb', line 362

def to_query
  {
    "markets" => markets,
    "symbols" => symbols_payload,
    "options" => { "lang" => "en" },
    "columns" => select_values,
    "filter" => where_values,
    "filter2" => filter2,
    "sort" => order_payload,
    "range" => [offset_value, offset_value + limit_value],
    "ignore_unknown_fields" => false
  }.compact
end

#to_sqlObject



357
358
359
360
# File 'lib/tradingview_screener/relation.rb', line 357

def to_sql
  # scanner has no SQL; show the request payload for debugging (AR-ish inspect helper)
  JSON.pretty_generate(to_query)
end

#total_countObject



301
302
303
304
# File 'lib/tradingview_screener/relation.rb', line 301

def total_count
  load unless loaded?
  @total_count
end

#unscope(*args) ⇒ Object



213
214
215
# File 'lib/tradingview_screener/relation.rb', line 213

def unscope(*args)
  except(*args)
end

#valuesObject



401
402
403
# File 'lib/tradingview_screener/relation.rb', line 401

def values
  @values
end

#where(*predicates, **conditions) ⇒ Object

AR-like where:

where(is_primary: true)
where(close: 10..300)
where(type: %w[stock fund])
where(close: gt(:SMA20))
where(Screener[:volume].gt(1_000_000))
where(Screener[:close].gt(Screener[:SMA20]), is_primary: true)


134
135
136
137
138
# File 'lib/tradingview_screener/relation.rb', line 134

def where(*predicates, **conditions)
  filters = predicates.map { |item| normalize_predicate(item) }
  conditions.each { |key, value| filters.concat(build_condition(key, value)) }
  spawn(where_values: where_values + filters)
end

#where_valuesObject



405
406
407
# File 'lib/tradingview_screener/relation.rb', line 405

def where_values
  Array(@values[:where_values])
end