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.
- checksums.yaml +7 -0
- data/CHANGELOG.md +18 -0
- data/LICENSE.txt +21 -0
- data/README.md +179 -0
- data/lib/tradingview/screener.rb +4 -0
- data/lib/tradingview_screener/base.rb +291 -0
- data/lib/tradingview_screener/client.rb +206 -0
- data/lib/tradingview_screener/predicate.rb +107 -0
- data/lib/tradingview_screener/relation.rb +581 -0
- data/lib/tradingview_screener/screen_data.rb +343 -0
- data/lib/tradingview_screener/version.rb +5 -0
- data/lib/tradingview_screener.rb +31 -0
- metadata +116 -0
|
@@ -0,0 +1,206 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "json"
|
|
4
|
+
require "net/http"
|
|
5
|
+
require "uri"
|
|
6
|
+
|
|
7
|
+
module TradingviewScreener
|
|
8
|
+
class Error < StandardError; end
|
|
9
|
+
|
|
10
|
+
class RequestError < Error
|
|
11
|
+
attr_reader :status, :body
|
|
12
|
+
|
|
13
|
+
def initialize(message, status: nil, body: nil)
|
|
14
|
+
super(message)
|
|
15
|
+
@status = status
|
|
16
|
+
@body = body
|
|
17
|
+
end
|
|
18
|
+
end
|
|
19
|
+
|
|
20
|
+
class Client
|
|
21
|
+
DEFAULT_HEADERS = {
|
|
22
|
+
"accept" => "application/json, text/plain, */*; q=0.01",
|
|
23
|
+
"content-type" => "text/plain;charset=UTF-8",
|
|
24
|
+
"origin" => "https://www.tradingview.com",
|
|
25
|
+
"referer" => "https://www.tradingview.com/",
|
|
26
|
+
"user-agent" => "tradingview-screener-rb/#{VERSION}"
|
|
27
|
+
}.freeze
|
|
28
|
+
|
|
29
|
+
attr_reader :timeout, :cookies, :headers, :label_product, :proxy
|
|
30
|
+
|
|
31
|
+
def initialize(timeout: 20, cookies: nil, headers: nil, label_product: "screener-stock", proxy: nil)
|
|
32
|
+
@timeout = timeout
|
|
33
|
+
@cookies = normalize_cookies(cookies)
|
|
34
|
+
@headers = DEFAULT_HEADERS.merge(stringify_keys(headers || {}))
|
|
35
|
+
@label_product = label_product
|
|
36
|
+
@proxy = normalize_proxy(proxy)
|
|
37
|
+
end
|
|
38
|
+
|
|
39
|
+
def get(url)
|
|
40
|
+
uri = URI(url)
|
|
41
|
+
request = Net::HTTP::Get.new(uri)
|
|
42
|
+
headers.each { |key, value| request[key] = value }
|
|
43
|
+
# HTML pages prefer browser-like accept; keep caller override if provided.
|
|
44
|
+
cookie = cookie_header
|
|
45
|
+
request["cookie"] = cookie unless cookie.nil? || cookie.empty?
|
|
46
|
+
|
|
47
|
+
response = perform(uri, request)
|
|
48
|
+
body = response.body.to_s
|
|
49
|
+
unless response.is_a?(Net::HTTPSuccess)
|
|
50
|
+
raise RequestError.new(
|
|
51
|
+
"request failed: HTTP #{response.code} #{response.message}",
|
|
52
|
+
status: response.code.to_i,
|
|
53
|
+
body: body
|
|
54
|
+
)
|
|
55
|
+
end
|
|
56
|
+
body
|
|
57
|
+
end
|
|
58
|
+
|
|
59
|
+
def post(url, payload)
|
|
60
|
+
uri = build_uri(url)
|
|
61
|
+
request = Net::HTTP::Post.new(uri)
|
|
62
|
+
headers.each { |key, value| request[key] = value }
|
|
63
|
+
cookie = cookie_header
|
|
64
|
+
request["cookie"] = cookie unless cookie.nil? || cookie.empty?
|
|
65
|
+
request.body = JSON.generate(payload)
|
|
66
|
+
|
|
67
|
+
response = perform(uri, request)
|
|
68
|
+
body = response.body.to_s
|
|
69
|
+
unless response.is_a?(Net::HTTPSuccess)
|
|
70
|
+
raise RequestError.new(
|
|
71
|
+
"scanner request failed: HTTP #{response.code} #{response.message}",
|
|
72
|
+
status: response.code.to_i,
|
|
73
|
+
body: body
|
|
74
|
+
)
|
|
75
|
+
end
|
|
76
|
+
|
|
77
|
+
JSON.parse(body)
|
|
78
|
+
rescue JSON::ParserError => e
|
|
79
|
+
raise RequestError.new("invalid scanner JSON: #{e.message}", status: response&.code&.to_i, body: body)
|
|
80
|
+
end
|
|
81
|
+
|
|
82
|
+
def perform(uri, request)
|
|
83
|
+
if socks_proxy?
|
|
84
|
+
request_via_socks(uri, request)
|
|
85
|
+
else
|
|
86
|
+
build_http(uri).request(request)
|
|
87
|
+
end
|
|
88
|
+
end
|
|
89
|
+
|
|
90
|
+
private
|
|
91
|
+
|
|
92
|
+
def build_uri(url)
|
|
93
|
+
uri = URI(url)
|
|
94
|
+
if label_product && !label_product.empty? && !uri.query.to_s.include?("label-product=")
|
|
95
|
+
params = URI.decode_www_form(uri.query.to_s) + [["label-product", label_product]]
|
|
96
|
+
uri.query = URI.encode_www_form(params)
|
|
97
|
+
end
|
|
98
|
+
uri
|
|
99
|
+
end
|
|
100
|
+
|
|
101
|
+
def build_http(uri)
|
|
102
|
+
http =
|
|
103
|
+
if http_proxy?
|
|
104
|
+
Net::HTTP::Proxy(
|
|
105
|
+
proxy["host"],
|
|
106
|
+
proxy["port"],
|
|
107
|
+
proxy["username"],
|
|
108
|
+
proxy["password"]
|
|
109
|
+
).new(uri.host, uri.port)
|
|
110
|
+
else
|
|
111
|
+
Net::HTTP.new(uri.host, uri.port)
|
|
112
|
+
end
|
|
113
|
+
http.use_ssl = uri.scheme == "https"
|
|
114
|
+
http.open_timeout = timeout
|
|
115
|
+
http.read_timeout = timeout
|
|
116
|
+
http
|
|
117
|
+
end
|
|
118
|
+
|
|
119
|
+
def request_via_socks(uri, request)
|
|
120
|
+
require "socksify/http"
|
|
121
|
+
http = Net::HTTP.SOCKSProxy(proxy["host"], proxy["port"]).new(uri.host, uri.port)
|
|
122
|
+
http.use_ssl = uri.scheme == "https"
|
|
123
|
+
http.open_timeout = timeout
|
|
124
|
+
http.read_timeout = timeout
|
|
125
|
+
http.request(request)
|
|
126
|
+
rescue LoadError
|
|
127
|
+
raise Error, "socks proxy requires the socksify gem; add gem 'socksify' and bundle install"
|
|
128
|
+
end
|
|
129
|
+
|
|
130
|
+
def cookie_header
|
|
131
|
+
return if cookies.nil? || cookies.empty?
|
|
132
|
+
|
|
133
|
+
cookies.map { |key, value| "#{key}=#{value}" }.join("; ")
|
|
134
|
+
end
|
|
135
|
+
|
|
136
|
+
def normalize_cookies(value)
|
|
137
|
+
case value
|
|
138
|
+
when nil then {}
|
|
139
|
+
when Hash then stringify_keys(value)
|
|
140
|
+
when String
|
|
141
|
+
value.split(";").each_with_object({}) do |part, memo|
|
|
142
|
+
key, val = part.strip.split("=", 2)
|
|
143
|
+
next if key.nil? || key.empty? || val.nil?
|
|
144
|
+
|
|
145
|
+
memo[key] = val
|
|
146
|
+
end
|
|
147
|
+
else
|
|
148
|
+
raise ArgumentError, "cookies must be Hash or Cookie header String"
|
|
149
|
+
end
|
|
150
|
+
end
|
|
151
|
+
|
|
152
|
+
def normalize_proxy(value)
|
|
153
|
+
case value
|
|
154
|
+
when nil, ""
|
|
155
|
+
nil
|
|
156
|
+
when Hash
|
|
157
|
+
hash = stringify_keys(value)
|
|
158
|
+
scheme = (hash["scheme"] || hash["type"] || hash["value"] || "http").to_s.downcase
|
|
159
|
+
host = first_present(hash["host"], hash["hostname"], hash.dig("extra", "host"))
|
|
160
|
+
port = first_present(hash["port"], hash.dig("extra", "port"))
|
|
161
|
+
username = first_present(hash["username"], hash["user"], hash.dig("extra", "username"), hash.dig("extra", "id"))
|
|
162
|
+
password = first_present(hash["password"], hash["pass"], hash.dig("extra", "password"), hash.dig("extra", "secret"))
|
|
163
|
+
return nil if blank?(host) || blank?(port)
|
|
164
|
+
|
|
165
|
+
{
|
|
166
|
+
"scheme" => scheme,
|
|
167
|
+
"host" => host.to_s,
|
|
168
|
+
"port" => port.to_i,
|
|
169
|
+
"username" => username,
|
|
170
|
+
"password" => password
|
|
171
|
+
}.compact
|
|
172
|
+
when String
|
|
173
|
+
uri = URI.parse(value)
|
|
174
|
+
{
|
|
175
|
+
"scheme" => (blank?(uri.scheme) ? "http" : uri.scheme).to_s.downcase,
|
|
176
|
+
"host" => uri.host,
|
|
177
|
+
"port" => uri.port,
|
|
178
|
+
"username" => uri.user,
|
|
179
|
+
"password" => uri.password
|
|
180
|
+
}.compact
|
|
181
|
+
else
|
|
182
|
+
raise ArgumentError, "proxy must be Hash, URL String, or nil"
|
|
183
|
+
end
|
|
184
|
+
end
|
|
185
|
+
|
|
186
|
+
def http_proxy?
|
|
187
|
+
proxy && %w[http https].include?(proxy["scheme"].to_s)
|
|
188
|
+
end
|
|
189
|
+
|
|
190
|
+
def socks_proxy?
|
|
191
|
+
proxy && proxy["scheme"].to_s.start_with?("socks")
|
|
192
|
+
end
|
|
193
|
+
|
|
194
|
+
def stringify_keys(hash)
|
|
195
|
+
hash.each_with_object({}) { |(k, v), memo| memo[k.to_s] = v }
|
|
196
|
+
end
|
|
197
|
+
|
|
198
|
+
def blank?(value)
|
|
199
|
+
value.nil? || (value.respond_to?(:empty?) && value.empty?) || (value.is_a?(String) && value.strip.empty?)
|
|
200
|
+
end
|
|
201
|
+
|
|
202
|
+
def first_present(*values)
|
|
203
|
+
values.find { |value| !blank?(value) }
|
|
204
|
+
end
|
|
205
|
+
end
|
|
206
|
+
end
|
|
@@ -0,0 +1,107 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module TradingviewScreener
|
|
4
|
+
# Arel-like column node used inside where().
|
|
5
|
+
#
|
|
6
|
+
# Screener[:close].gt(Screener[:SMA20])
|
|
7
|
+
# Screener[:volume].gte(1_000_000)
|
|
8
|
+
# Screener[:close].between(10, 300)
|
|
9
|
+
class Predicate
|
|
10
|
+
attr_reader :left, :operation, :right
|
|
11
|
+
|
|
12
|
+
def initialize(left, operation, right = nil)
|
|
13
|
+
@left = left.to_s
|
|
14
|
+
@operation = operation.to_s
|
|
15
|
+
@right = unwrap(right)
|
|
16
|
+
end
|
|
17
|
+
|
|
18
|
+
def to_h
|
|
19
|
+
{ "left" => left, "operation" => operation, "right" => right }
|
|
20
|
+
end
|
|
21
|
+
|
|
22
|
+
def inspect
|
|
23
|
+
"#<#{self.class} #{left} #{operation} #{right.inspect}>"
|
|
24
|
+
end
|
|
25
|
+
|
|
26
|
+
private
|
|
27
|
+
|
|
28
|
+
def unwrap(value)
|
|
29
|
+
case value
|
|
30
|
+
when Predicate then value.left
|
|
31
|
+
when Field then value.name
|
|
32
|
+
when Symbol then value.to_s
|
|
33
|
+
else value
|
|
34
|
+
end
|
|
35
|
+
end
|
|
36
|
+
end
|
|
37
|
+
|
|
38
|
+
class Field
|
|
39
|
+
attr_reader :name
|
|
40
|
+
|
|
41
|
+
def initialize(name)
|
|
42
|
+
@name = name.to_s
|
|
43
|
+
end
|
|
44
|
+
|
|
45
|
+
def gt(other) = Predicate.new(name, "greater", other)
|
|
46
|
+
def gte(other) = Predicate.new(name, "egreater", other)
|
|
47
|
+
def lt(other) = Predicate.new(name, "less", other)
|
|
48
|
+
def lte(other) = Predicate.new(name, "eless", other)
|
|
49
|
+
def eq(other) = Predicate.new(name, "equal", other)
|
|
50
|
+
def not_eq(other) = Predicate.new(name, "nequal", other)
|
|
51
|
+
def between(min, max) = Predicate.new(name, "in_range", [unwrap(min), unwrap(max)])
|
|
52
|
+
def not_between(min, max) = Predicate.new(name, "not_in_range", [unwrap(min), unwrap(max)])
|
|
53
|
+
def in(values) = Predicate.new(name, "in_range", Array(values))
|
|
54
|
+
def not_in(values) = Predicate.new(name, "not_in_range", Array(values))
|
|
55
|
+
def has(values) = Predicate.new(name, "has", values)
|
|
56
|
+
def has_none_of(values) = Predicate.new(name, "has_none_of", values)
|
|
57
|
+
def matches(value) = Predicate.new(name, "match", value)
|
|
58
|
+
def does_not_match(value) = Predicate.new(name, "nmatch", value)
|
|
59
|
+
def blank = Predicate.new(name, "empty", nil)
|
|
60
|
+
def present = Predicate.new(name, "nempty", nil)
|
|
61
|
+
def crosses(other) = Predicate.new(name, "crosses", other)
|
|
62
|
+
def crosses_above(other) = Predicate.new(name, "crosses_above", other)
|
|
63
|
+
def crosses_below(other) = Predicate.new(name, "crosses_below", other)
|
|
64
|
+
|
|
65
|
+
def >(other) = gt(other)
|
|
66
|
+
def >=(other) = gte(other)
|
|
67
|
+
def <(other) = lt(other)
|
|
68
|
+
def <=(other) = lte(other)
|
|
69
|
+
def ==(other) = eq(other)
|
|
70
|
+
def !=(other) = not_eq(other)
|
|
71
|
+
|
|
72
|
+
def inspect
|
|
73
|
+
"#<#{self.class} #{name}>"
|
|
74
|
+
end
|
|
75
|
+
|
|
76
|
+
private
|
|
77
|
+
|
|
78
|
+
def unwrap(value)
|
|
79
|
+
value.is_a?(Field) ? value.name : value
|
|
80
|
+
end
|
|
81
|
+
end
|
|
82
|
+
|
|
83
|
+
# Operator wrappers for hash-style where:
|
|
84
|
+
# where(close: gt(:SMA20), volume: gte(1_000_000), price: 10..300)
|
|
85
|
+
module Predicates
|
|
86
|
+
module_function
|
|
87
|
+
|
|
88
|
+
def gt(value) = Op.new("greater", value)
|
|
89
|
+
def gte(value) = Op.new("egreater", value)
|
|
90
|
+
def lt(value) = Op.new("less", value)
|
|
91
|
+
def lte(value) = Op.new("eless", value)
|
|
92
|
+
def not_eq(value) = Op.new("nequal", value)
|
|
93
|
+
def has(value) = Op.new("has", value)
|
|
94
|
+
def has_none_of(value) = Op.new("has_none_of", value)
|
|
95
|
+
|
|
96
|
+
class Op
|
|
97
|
+
attr_reader :operation, :value
|
|
98
|
+
|
|
99
|
+
def initialize(operation, value)
|
|
100
|
+
@operation = operation
|
|
101
|
+
@value = value.is_a?(Field) || value.is_a?(Symbol) ? value.to_s.sub(/\A:/, "") : value
|
|
102
|
+
@value = value.name if value.is_a?(Field)
|
|
103
|
+
@value = value.to_s if value.is_a?(Symbol)
|
|
104
|
+
end
|
|
105
|
+
end
|
|
106
|
+
end
|
|
107
|
+
end
|