oh 1.0.1
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.
- data/.autotest +23 -0
- data/History.txt +6 -0
- data/Manifest.txt +14 -0
- data/README.rdoc +67 -0
- data/Rakefile +12 -0
- data/bin/oh +57 -0
- data/lib/oh.rb +202 -0
- data/lib/ohbjects.rb +67 -0
- data/lib/ohbjects/account.rb +29 -0
- data/lib/ohbjects/options.rb +78 -0
- data/lib/ohbjects/quote.rb +35 -0
- data/test/test_oh.rb +166 -0
- data/test/test_ohbjects.rb +63 -0
- data/test/test_options.rb +69 -0
- metadata +133 -0
data/.autotest
ADDED
@@ -0,0 +1,23 @@
|
|
1
|
+
# -*- ruby -*-
|
2
|
+
|
3
|
+
require 'autotest/restart'
|
4
|
+
|
5
|
+
# Autotest.add_hook :initialize do |at|
|
6
|
+
# at.extra_files << "../some/external/dependency.rb"
|
7
|
+
#
|
8
|
+
# at.libs << ":../some/external"
|
9
|
+
#
|
10
|
+
# at.add_exception 'vendor'
|
11
|
+
#
|
12
|
+
# at.add_mapping(/dependency.rb/) do |f, _|
|
13
|
+
# at.files_matching(/test_.*rb$/)
|
14
|
+
# end
|
15
|
+
#
|
16
|
+
# %w(TestA TestB).each do |klass|
|
17
|
+
# at.extra_class_map[klass] = "test/test_misc.rb"
|
18
|
+
# end
|
19
|
+
# end
|
20
|
+
|
21
|
+
# Autotest.add_hook :run_command do |at|
|
22
|
+
# system "rake build"
|
23
|
+
# end
|
data/History.txt
ADDED
data/Manifest.txt
ADDED
data/README.rdoc
ADDED
@@ -0,0 +1,67 @@
|
|
1
|
+
= oh
|
2
|
+
|
3
|
+
* http://github.com/aasmith/oh
|
4
|
+
|
5
|
+
== Description
|
6
|
+
|
7
|
+
An API for OptionsHouse (http://optionshouse.com).
|
8
|
+
|
9
|
+
Currently provides a mechanism for pulling stock and option quotes.
|
10
|
+
|
11
|
+
== Synopsis
|
12
|
+
|
13
|
+
# The basic oh API. Calls to the API return parsed Nokogiri documents.
|
14
|
+
require 'oh'
|
15
|
+
|
16
|
+
# An object wrapper around the basic api. Marshals above responses into
|
17
|
+
# handy Ruby objects, defined in lib/ohbjects.
|
18
|
+
require 'ohbjects'
|
19
|
+
Ohbjects.activate
|
20
|
+
|
21
|
+
# You defined these vars somewhere else, right?
|
22
|
+
o = Oh.new(username, password)
|
23
|
+
|
24
|
+
# Use the virtual account so we don't accidentally spend all your
|
25
|
+
# hard-earned dollars on Frozen Pork Belly futures.
|
26
|
+
account = o.accounts.detect { |a| a.virtual? }
|
27
|
+
o.account_id = account.id
|
28
|
+
|
29
|
+
# Bask is the glory of knowing the latest price for the
|
30
|
+
# iPath Dow Jones-AIG Coffee Total Return Sub-Index ETN.
|
31
|
+
p o.quote("JO")
|
32
|
+
|
33
|
+
# Do something like this every 120 seconds or so, otherwise your
|
34
|
+
# token will expire.
|
35
|
+
o.keep_alive
|
36
|
+
|
37
|
+
== Requirements
|
38
|
+
|
39
|
+
* Nokogiri
|
40
|
+
* An OptionsHouse account
|
41
|
+
|
42
|
+
== Install
|
43
|
+
|
44
|
+
sudo gem install oh
|
45
|
+
|
46
|
+
== License
|
47
|
+
|
48
|
+
Copyright (c) 2010 Andrew A. Smith
|
49
|
+
|
50
|
+
Permission is hereby granted, free of charge, to any person obtaining
|
51
|
+
a copy of this software and associated documentation files (the
|
52
|
+
'Software'), to deal in the Software without restriction, including
|
53
|
+
without limitation the rights to use, copy, modify, merge, publish,
|
54
|
+
distribute, sublicense, and/or sell copies of the Software, and to
|
55
|
+
permit persons to whom the Software is furnished to do so, subject to
|
56
|
+
the following conditions:
|
57
|
+
|
58
|
+
The above copyright notice and this permission notice shall be
|
59
|
+
included in all copies or substantial portions of the Software.
|
60
|
+
|
61
|
+
THE SOFTWARE IS PROVIDED 'AS IS', WITHOUT WARRANTY OF ANY KIND,
|
62
|
+
EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF
|
63
|
+
MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT.
|
64
|
+
IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY
|
65
|
+
CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT,
|
66
|
+
TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE
|
67
|
+
SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
|
data/Rakefile
ADDED
data/bin/oh
ADDED
@@ -0,0 +1,57 @@
|
|
1
|
+
#!/usr/bin/env ruby
|
2
|
+
|
3
|
+
require 'oh'
|
4
|
+
require 'ohbjects'
|
5
|
+
Ohbjects.activate
|
6
|
+
|
7
|
+
username, *symbols = ARGV
|
8
|
+
|
9
|
+
abort "Usage: #{$0} [username] [symbol ...]" unless username and !symbols.empty?
|
10
|
+
|
11
|
+
begin
|
12
|
+
print "Password: "
|
13
|
+
system "stty -echo"
|
14
|
+
password = $stdin.gets.chomp
|
15
|
+
ensure
|
16
|
+
system "stty echo"
|
17
|
+
puts
|
18
|
+
end
|
19
|
+
|
20
|
+
o = Oh.new(username, password)
|
21
|
+
|
22
|
+
account = o.accounts.detect { |a| a.virtual? }
|
23
|
+
|
24
|
+
if account
|
25
|
+
puts "Using account #{account.name}"
|
26
|
+
else
|
27
|
+
abort "No virtual accounts found for #{username.inspect}"
|
28
|
+
end
|
29
|
+
|
30
|
+
o.account_id = account.id
|
31
|
+
|
32
|
+
Thread.abort_on_exception = true
|
33
|
+
|
34
|
+
auth_last_sent_at = Time.now
|
35
|
+
|
36
|
+
begin
|
37
|
+
puts "Started polling at #{st=Time.now}"
|
38
|
+
|
39
|
+
t = Thread.new do
|
40
|
+
loop do
|
41
|
+
symbols.each do |symbol|
|
42
|
+
p o.quote(symbol)
|
43
|
+
end
|
44
|
+
|
45
|
+
if Time.now - auth_last_sent_at > 120
|
46
|
+
o.keep_alive
|
47
|
+
auth_last_sent_at = Time.now
|
48
|
+
end
|
49
|
+
|
50
|
+
sleep 1
|
51
|
+
end
|
52
|
+
end
|
53
|
+
|
54
|
+
t.join
|
55
|
+
ensure
|
56
|
+
abort "Terminated at #{et=Time.now} after #{et-st} secs."
|
57
|
+
end
|
data/lib/oh.rb
ADDED
@@ -0,0 +1,202 @@
|
|
1
|
+
require 'net/http'
|
2
|
+
require 'net/https'
|
3
|
+
|
4
|
+
require 'stringio'
|
5
|
+
require 'zlib'
|
6
|
+
|
7
|
+
require 'rubygems'
|
8
|
+
require 'nokogiri'
|
9
|
+
|
10
|
+
class Oh
|
11
|
+
VERSION = '1.0.1'
|
12
|
+
|
13
|
+
HEADERS = {
|
14
|
+
"Host" => "www.optionshouse.com",
|
15
|
+
"User-Agent" => "Mozilla/5.0 (Windows; U; Windows NT 6.0; en-US; rv:1.9.2.12) Gecko/20101026 Firefox/3.6.12 (.NET CLR 3.5.30729)",
|
16
|
+
"Accept" => "text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8",
|
17
|
+
"Accept-Language" => "en-us,en;q=0.5",
|
18
|
+
"Accept-Encoding" => "gzip,deflate",
|
19
|
+
"Accept-Charset" => "ISO-8859-1,utf-8;q=0.7,*;q=0.7",
|
20
|
+
"Connection" => "keep-alive",
|
21
|
+
"Content-Type" => "text/xml; charset=UTF-8",
|
22
|
+
"Referer" => "https://www.optionshouse.com/tool/login/",
|
23
|
+
"Cookie" => "blackbird={pos:1,size:0,load:null,info:true,debug:true,warn:true,error:true,profile:true}",
|
24
|
+
"Pragma" => "no-cache",
|
25
|
+
"Cache-Control" => "no-cache",
|
26
|
+
}
|
27
|
+
|
28
|
+
attr_accessor :username, :password
|
29
|
+
attr_writer :account_id
|
30
|
+
|
31
|
+
def initialize(username, password)
|
32
|
+
self.username = username
|
33
|
+
self.password = password
|
34
|
+
end
|
35
|
+
|
36
|
+
def account_id
|
37
|
+
@account_id or raise "Account not set; call Oh#accounts() to get a list, then set using Oh#account_id=(id)."
|
38
|
+
end
|
39
|
+
|
40
|
+
def token
|
41
|
+
@token ||= login
|
42
|
+
end
|
43
|
+
|
44
|
+
def login
|
45
|
+
response = request_without_callbacks(message("auth.login",
|
46
|
+
:userName => username,
|
47
|
+
:password => password,
|
48
|
+
:organization => "OPHOUSE,KERSHNER",
|
49
|
+
:authToken => nil,
|
50
|
+
:validationText => ""))
|
51
|
+
|
52
|
+
access = response.search("//access").text
|
53
|
+
raise AuthError, "Access was #{access}" unless access == "granted"
|
54
|
+
|
55
|
+
ready = response.search("//requiresAccountCreation").text == "false"
|
56
|
+
raise AccountError, "Account is not active" unless ready
|
57
|
+
|
58
|
+
token = response.search("//authToken").text
|
59
|
+
raise AuthError, "No auth token was returned" if token.strip.empty? or token == "null"
|
60
|
+
|
61
|
+
token
|
62
|
+
end
|
63
|
+
|
64
|
+
def accounts
|
65
|
+
request(message_with_token("account.info"))
|
66
|
+
end
|
67
|
+
|
68
|
+
def quote(symbol)
|
69
|
+
request(messages(quote_messages(symbol)))
|
70
|
+
end
|
71
|
+
|
72
|
+
def option_chain(symbol)
|
73
|
+
request(chain_message(symbol))
|
74
|
+
end
|
75
|
+
|
76
|
+
def quote_with_chain(symbol)
|
77
|
+
request(messages(quote_messages(symbol), chain_message(symbol)))
|
78
|
+
end
|
79
|
+
|
80
|
+
# TODO: send this every 120 seconds?
|
81
|
+
def keep_alive
|
82
|
+
request(message_with_account("auth.keepAlive"))
|
83
|
+
end
|
84
|
+
|
85
|
+
def quote_messages(symbol)
|
86
|
+
[
|
87
|
+
message_with_account("view.quote",
|
88
|
+
:symbol => symbol,
|
89
|
+
:description => true,
|
90
|
+
:fundamentals => true),
|
91
|
+
message_with_token("echo", :symbol => symbol)
|
92
|
+
]
|
93
|
+
end
|
94
|
+
|
95
|
+
def chain_message(symbol)
|
96
|
+
message_with_account("view.chain",
|
97
|
+
:symbol => symbol,
|
98
|
+
:greeks => true,
|
99
|
+
:weeklies => true,
|
100
|
+
:quarterlies => true,
|
101
|
+
:quotesAfter => 0,
|
102
|
+
:ntm => 10, # near the money
|
103
|
+
:bs => true) # black-scholes?
|
104
|
+
end
|
105
|
+
|
106
|
+
def message_with_token(action, data = {})
|
107
|
+
message(action, {:authToken => token}.merge(data))
|
108
|
+
end
|
109
|
+
|
110
|
+
def message_with_account(action, data = {})
|
111
|
+
message_with_token(action, {:account => account_id}.merge(data))
|
112
|
+
end
|
113
|
+
|
114
|
+
def message(action, data = {})
|
115
|
+
chunks = []
|
116
|
+
|
117
|
+
chunks << "<EZMessage action='#{action}'>"
|
118
|
+
chunks << "<data>"
|
119
|
+
|
120
|
+
data.each do |key, value|
|
121
|
+
chunks << "<#{key}>#{value || "null"}</#{key}>"
|
122
|
+
end
|
123
|
+
|
124
|
+
chunks << "</data>"
|
125
|
+
chunks << "</EZMessage>"
|
126
|
+
|
127
|
+
chunks.join
|
128
|
+
end
|
129
|
+
|
130
|
+
def messages(*messages)
|
131
|
+
"<EZList>#{messages.flatten.join}</EZList>"
|
132
|
+
end
|
133
|
+
|
134
|
+
def request_without_callbacks(body)
|
135
|
+
request(body, false)
|
136
|
+
end
|
137
|
+
|
138
|
+
def request(body, with_callbacks = true)
|
139
|
+
path = "/m"
|
140
|
+
|
141
|
+
response = connection.post(path, body, HEADERS)
|
142
|
+
data = response.body
|
143
|
+
|
144
|
+
out = case response["content-encoding"]
|
145
|
+
when /gzip/ then Zlib::GzipReader.new(StringIO.new(data)).read
|
146
|
+
when /deflate/ then Zlib::Inflate.inflate(data)
|
147
|
+
else data
|
148
|
+
end
|
149
|
+
|
150
|
+
if $DEBUG
|
151
|
+
puts "Sent:"
|
152
|
+
puts body
|
153
|
+
puts "-" * 80
|
154
|
+
puts "Got"
|
155
|
+
p response.code
|
156
|
+
p response.message
|
157
|
+
|
158
|
+
response.each {|key, val| puts key + ' = ' + val}
|
159
|
+
|
160
|
+
puts out
|
161
|
+
puts "=" * 80
|
162
|
+
puts
|
163
|
+
end
|
164
|
+
|
165
|
+
result = begin
|
166
|
+
Nokogiri.parse(out, nil, nil, Nokogiri::XML::ParseOptions::STRICT)
|
167
|
+
rescue
|
168
|
+
warn "Unable to parse: #{out.inspect}"
|
169
|
+
raise
|
170
|
+
end
|
171
|
+
|
172
|
+
check_connection_status(result)
|
173
|
+
|
174
|
+
with_callbacks && respond_to?(:post_process_request) ?
|
175
|
+
post_process_request(result) :
|
176
|
+
result
|
177
|
+
end
|
178
|
+
|
179
|
+
def check_connection_status(doc)
|
180
|
+
if doc.at("//errors/access[text()='denied']")
|
181
|
+
raise AuthError, "Access denied, token has expired?"
|
182
|
+
end
|
183
|
+
end
|
184
|
+
|
185
|
+
def connection
|
186
|
+
return @client if defined? @client
|
187
|
+
|
188
|
+
client = Net::HTTP.new("www.optionshouse.com", 443)
|
189
|
+
client.use_ssl = true
|
190
|
+
|
191
|
+
if ENV["SSL_PATH"] && File.exist?(ENV["SSL_PATH"])
|
192
|
+
client.ca_path = ENV["SSL_PATH"]
|
193
|
+
client.verify_mode = OpenSSL::SSL::VERIFY_PEER
|
194
|
+
end
|
195
|
+
|
196
|
+
@client = client
|
197
|
+
end
|
198
|
+
|
199
|
+
AuthError = Class.new(StandardError)
|
200
|
+
AccountError = Class.new(StandardError)
|
201
|
+
|
202
|
+
end
|
data/lib/ohbjects.rb
ADDED
@@ -0,0 +1,67 @@
|
|
1
|
+
# Adds an object representation to Oh responses:
|
2
|
+
#
|
3
|
+
# require "ohbjects"
|
4
|
+
# Ohbjects.activate
|
5
|
+
#
|
6
|
+
# Then, any further calls to Oh methods that used to
|
7
|
+
# return an XML doc will now return objects such as
|
8
|
+
# Ohbjects::Call, Ohbjects::Put, etc.
|
9
|
+
#
|
10
|
+
module Ohbjects
|
11
|
+
REGISTRY = []
|
12
|
+
|
13
|
+
module Buildable
|
14
|
+
attr_reader :spec
|
15
|
+
|
16
|
+
def builds(css_or_xpath)
|
17
|
+
@spec = css_or_xpath
|
18
|
+
end
|
19
|
+
|
20
|
+
def build?(doc)
|
21
|
+
!doc.search(@spec).empty?
|
22
|
+
end
|
23
|
+
|
24
|
+
# Taken from active support.
|
25
|
+
def underscore(camel_cased_word)
|
26
|
+
camel_cased_word.to_s.
|
27
|
+
gsub(/([A-Z]+)([A-Z][a-z])/,'\1_\2').
|
28
|
+
gsub(/([a-z\d])([A-Z])/,'\1_\2').
|
29
|
+
tr("-", "_").
|
30
|
+
downcase
|
31
|
+
end
|
32
|
+
|
33
|
+
def self.extended(extender)
|
34
|
+
REGISTRY << extender
|
35
|
+
end
|
36
|
+
end
|
37
|
+
|
38
|
+
def objectify(doc)
|
39
|
+
qualified_builders =
|
40
|
+
REGISTRY.select { |builder| builder.build?(doc) }
|
41
|
+
|
42
|
+
objects = []
|
43
|
+
|
44
|
+
qualified_builders.each do |builder|
|
45
|
+
doc.search(builder.spec).each do |fragment|
|
46
|
+
objects.push(*builder.build(fragment))
|
47
|
+
end
|
48
|
+
end
|
49
|
+
|
50
|
+
objects
|
51
|
+
end
|
52
|
+
|
53
|
+
def post_process_request(result)
|
54
|
+
objectify(result)
|
55
|
+
end
|
56
|
+
|
57
|
+
class << self
|
58
|
+
def activate
|
59
|
+
Oh.send :include, self
|
60
|
+
end
|
61
|
+
end
|
62
|
+
end
|
63
|
+
|
64
|
+
Dir[File.join(File.dirname(__FILE__), 'ohbjects', '*.rb')].each do |fn|
|
65
|
+
require fn
|
66
|
+
end
|
67
|
+
|
@@ -0,0 +1,29 @@
|
|
1
|
+
module Ohbjects
|
2
|
+
class AccountBuilder
|
3
|
+
extend Buildable
|
4
|
+
|
5
|
+
# Not building anything real for now.
|
6
|
+
builds "//data/account[isVirtual='true']"
|
7
|
+
|
8
|
+
class << self
|
9
|
+
def build(fragment)
|
10
|
+
account = Account.new
|
11
|
+
|
12
|
+
account.id = fragment.at("accountId").text
|
13
|
+
account.name = fragment.at("accountName").text
|
14
|
+
account.virtual = fragment.at("isVirtual").text =~ /true/
|
15
|
+
account.display_id = fragment.at("account").text
|
16
|
+
|
17
|
+
account
|
18
|
+
end
|
19
|
+
end
|
20
|
+
end
|
21
|
+
|
22
|
+
class Account
|
23
|
+
attr_accessor :id, :display_id, :name, :virtual
|
24
|
+
|
25
|
+
def virtual?
|
26
|
+
virtual
|
27
|
+
end
|
28
|
+
end
|
29
|
+
end
|
@@ -0,0 +1,78 @@
|
|
1
|
+
module Ohbjects
|
2
|
+
class OptionBuilder
|
3
|
+
extend Buildable
|
4
|
+
|
5
|
+
builds "optionQuote"
|
6
|
+
|
7
|
+
METHOD_LOOKUP = {
|
8
|
+
:optionRoot => :root,
|
9
|
+
:strikePrice => :strike,
|
10
|
+
:expDate => :expires,
|
11
|
+
:bidSize => :bids,
|
12
|
+
:askSize => :asks
|
13
|
+
}
|
14
|
+
|
15
|
+
class << self
|
16
|
+
def build(fragment)
|
17
|
+
put, call = Put.new, Call.new
|
18
|
+
|
19
|
+
fragment.attributes.each do |key, attr|
|
20
|
+
match = key.match(/^(call|put)?(.*)$/)
|
21
|
+
|
22
|
+
option_type, method = match.captures
|
23
|
+
|
24
|
+
method = METHOD_LOOKUP[method.to_sym] || underscore(method)
|
25
|
+
setter = "#{method}="
|
26
|
+
|
27
|
+
value = attr.value
|
28
|
+
|
29
|
+
if option_type
|
30
|
+
option = option_type == "call" ? call : put
|
31
|
+
option.send(setter, value) if option.respond_to?(setter)
|
32
|
+
else
|
33
|
+
[put, call].each do |option|
|
34
|
+
option.send(setter, value) if option.respond_to?(setter)
|
35
|
+
end
|
36
|
+
end
|
37
|
+
end
|
38
|
+
|
39
|
+
[put, call]
|
40
|
+
end
|
41
|
+
end
|
42
|
+
end
|
43
|
+
|
44
|
+
class Option
|
45
|
+
attr_accessor :id, :key, :strike, :root,
|
46
|
+
:bid, :ask, :change, :volume, :open_interest,
|
47
|
+
:iv, :delta, :gamma, :theta, :vega,
|
48
|
+
:bids, :asks
|
49
|
+
|
50
|
+
attr_reader :expires
|
51
|
+
|
52
|
+
def initialize
|
53
|
+
if instance_of? Option
|
54
|
+
raise ArgumentError, "An option cannot be instantiated"
|
55
|
+
end
|
56
|
+
end
|
57
|
+
|
58
|
+
def expires=(date_string)
|
59
|
+
# Intentionally explict pattern to force
|
60
|
+
# parse errors should the format change.
|
61
|
+
@expires = Date.strptime(date_string, "%b %e, %Y")
|
62
|
+
end
|
63
|
+
|
64
|
+
def call?
|
65
|
+
Call === self
|
66
|
+
end
|
67
|
+
|
68
|
+
def put?
|
69
|
+
Put === self
|
70
|
+
end
|
71
|
+
end
|
72
|
+
|
73
|
+
class Put < Option
|
74
|
+
end
|
75
|
+
|
76
|
+
class Call < Option
|
77
|
+
end
|
78
|
+
end
|
@@ -0,0 +1,35 @@
|
|
1
|
+
module Ohbjects
|
2
|
+
class QuoteBuilder
|
3
|
+
extend Buildable
|
4
|
+
|
5
|
+
builds "//data/quote"
|
6
|
+
|
7
|
+
class << self
|
8
|
+
def build(fragment)
|
9
|
+
quote = Quote.new
|
10
|
+
|
11
|
+
fragment.attributes.each do |key, attr|
|
12
|
+
method = "#{underscore(key)}="
|
13
|
+
|
14
|
+
next unless quote.respond_to?(method)
|
15
|
+
|
16
|
+
quote.send(method, attr.value)
|
17
|
+
end
|
18
|
+
|
19
|
+
quote.description =
|
20
|
+
fragment.attributes["shortDescription"].value
|
21
|
+
|
22
|
+
quote
|
23
|
+
end
|
24
|
+
end
|
25
|
+
end
|
26
|
+
|
27
|
+
class Quote
|
28
|
+
attr_accessor :symbol, :bid, :ask, :volume, :prev_close, :last,
|
29
|
+
:open, :high, :low, :today_close, :description
|
30
|
+
end
|
31
|
+
|
32
|
+
# TODO: today_close will be 0 until today has actually closed.
|
33
|
+
# TODO: many more attributes
|
34
|
+
# TODO: field conversion
|
35
|
+
end
|
data/test/test_oh.rb
ADDED
@@ -0,0 +1,166 @@
|
|
1
|
+
require "test/unit"
|
2
|
+
require "flexmock/test_unit"
|
3
|
+
require "oh"
|
4
|
+
|
5
|
+
class TestOh < Test::Unit::TestCase
|
6
|
+
def setup
|
7
|
+
@oh = Oh.new("bob", "secret")
|
8
|
+
end
|
9
|
+
|
10
|
+
def mocked_response(data, headers = {})
|
11
|
+
response = headers.dup
|
12
|
+
|
13
|
+
class << response
|
14
|
+
attr_accessor :body
|
15
|
+
end
|
16
|
+
|
17
|
+
response.body = data
|
18
|
+
response
|
19
|
+
end
|
20
|
+
|
21
|
+
def test_request_doesnt_delegate_when_not_present
|
22
|
+
flexmock(Net::HTTP).new_instances.should_receive(
|
23
|
+
:post => mocked_response("<response></response>")
|
24
|
+
)
|
25
|
+
|
26
|
+
flexmock(@oh) do |m|
|
27
|
+
m.should_receive(:post_process_request).never
|
28
|
+
m.should_receive(:respond_to?).
|
29
|
+
with(:post_process_request).and_return(false)
|
30
|
+
end
|
31
|
+
|
32
|
+
@oh.request("<request></request>")
|
33
|
+
end
|
34
|
+
|
35
|
+
def test_request_doesnt_delegate_when_asked_not_to
|
36
|
+
flexmock(Net::HTTP).new_instances.should_receive(
|
37
|
+
:post => mocked_response("<response></response>")
|
38
|
+
)
|
39
|
+
|
40
|
+
flexmock(@oh) do |m|
|
41
|
+
m.should_receive(:post_process_request).never
|
42
|
+
m.should_receive(:respond_to?).
|
43
|
+
with(:post_process_request).and_return(true)
|
44
|
+
end
|
45
|
+
|
46
|
+
@oh.request("<request></request>", false)
|
47
|
+
end
|
48
|
+
|
49
|
+
def test_request_without_callbacks_calls_request_with_correct_args
|
50
|
+
flexmock(@oh).should_receive(:request).with("foo", false).once
|
51
|
+
|
52
|
+
@oh.request_without_callbacks("foo")
|
53
|
+
end
|
54
|
+
|
55
|
+
def test_request_delegates
|
56
|
+
flexmock(Net::HTTP).new_instances.should_receive(
|
57
|
+
:post => mocked_response("<response></response>")
|
58
|
+
)
|
59
|
+
|
60
|
+
flexmock(@oh) do |m|
|
61
|
+
m.should_receive(:post_process_request).once
|
62
|
+
m.should_receive(:respond_to?).
|
63
|
+
with(:post_process_request).and_return(true)
|
64
|
+
end
|
65
|
+
|
66
|
+
@oh.request("<request></request>")
|
67
|
+
end
|
68
|
+
|
69
|
+
def test_request_handles_gzip
|
70
|
+
io = StringIO.new
|
71
|
+
|
72
|
+
z = Zlib::GzipWriter.new(io)
|
73
|
+
z.write "<response></response>"
|
74
|
+
z.close
|
75
|
+
|
76
|
+
flexmock(Net::HTTP).new_instances.should_receive(
|
77
|
+
:post => mocked_response(
|
78
|
+
io.string,
|
79
|
+
"content-encoding" => "gzip"
|
80
|
+
))
|
81
|
+
|
82
|
+
doc = @oh.request("<request></request>")
|
83
|
+
|
84
|
+
assert_equal "response", doc.root.name,
|
85
|
+
"should have document with a root node of response"
|
86
|
+
end
|
87
|
+
|
88
|
+
def test_request_handles_deflate
|
89
|
+
flexmock(Net::HTTP).new_instances.should_receive(
|
90
|
+
:post => mocked_response(
|
91
|
+
Zlib::Deflate.deflate("<response></response>"),
|
92
|
+
"content-encoding" => "deflate"
|
93
|
+
))
|
94
|
+
|
95
|
+
doc = @oh.request("<request></request>")
|
96
|
+
|
97
|
+
assert_equal "response", doc.root.name,
|
98
|
+
"should have document with a root node of response"
|
99
|
+
end
|
100
|
+
|
101
|
+
def test_request_handles_plaintext
|
102
|
+
flexmock(Net::HTTP).new_instances.should_receive(
|
103
|
+
:post => mocked_response("<response></response>")
|
104
|
+
)
|
105
|
+
|
106
|
+
doc = @oh.request("<request></request>")
|
107
|
+
|
108
|
+
assert_equal "response", doc.root.name,
|
109
|
+
"should have document with a root node of response"
|
110
|
+
end
|
111
|
+
|
112
|
+
def test_request_raises_with_malformed_xml
|
113
|
+
flexmock(Net::HTTP).new_instances.should_receive(
|
114
|
+
:post => mocked_response("<invalid></xml>")
|
115
|
+
)
|
116
|
+
|
117
|
+
old_stderr = $stderr
|
118
|
+
$stderr = StringIO.new
|
119
|
+
|
120
|
+
assert_raise Nokogiri::XML::SyntaxError do
|
121
|
+
doc = @oh.request("<request></request>")
|
122
|
+
end
|
123
|
+
|
124
|
+
assert_match %r{Unable to parse}, $stderr.string
|
125
|
+
|
126
|
+
$stderr = old_stderr
|
127
|
+
end
|
128
|
+
|
129
|
+
def test_request_sends_consistent_header_and_path
|
130
|
+
m = flexmock("Net::HTTP instance")
|
131
|
+
m.should_ignore_missing
|
132
|
+
m.should_receive(:post).
|
133
|
+
with("/m", "<request></request>", Oh::HEADERS).
|
134
|
+
and_return(mocked_response("<response></response>")).
|
135
|
+
once
|
136
|
+
|
137
|
+
flexmock(Net::HTTP).should_receive(:new).once.and_return(m)
|
138
|
+
|
139
|
+
@oh.request("<request></request>")
|
140
|
+
end
|
141
|
+
|
142
|
+
def test_connection_reuses_single_client
|
143
|
+
assert_same @oh.connection, @oh.connection, "Should be cached"
|
144
|
+
end
|
145
|
+
|
146
|
+
def test_message
|
147
|
+
msg = @oh.message("test.action", :foo => nil, :bar => 2)
|
148
|
+
|
149
|
+
assert_match %r{<foo>null</foo>}, msg, "should convert nils to null"
|
150
|
+
assert_match %r{<bar>2</bar>}, msg, "should to_s all other objects"
|
151
|
+
end
|
152
|
+
|
153
|
+
def test_messages
|
154
|
+
messages = %w(<message_one> <message_two>)
|
155
|
+
nested_messages = [messages, messages]
|
156
|
+
|
157
|
+
assert_equal "<EZList>#{messages.join}</EZList>",
|
158
|
+
@oh.messages(messages)
|
159
|
+
|
160
|
+
assert_equal "<EZList>#{nested_messages.flatten.join}</EZList>",
|
161
|
+
@oh.messages(nested_messages), "should flatten nested messages"
|
162
|
+
end
|
163
|
+
|
164
|
+
def test_account_raises_when_not_set
|
165
|
+
end
|
166
|
+
end
|
@@ -0,0 +1,63 @@
|
|
1
|
+
require "test/unit"
|
2
|
+
require "flexmock/test_unit"
|
3
|
+
|
4
|
+
require "oh"
|
5
|
+
require "ohbjects"
|
6
|
+
|
7
|
+
class TestOhbjects < Test::Unit::TestCase
|
8
|
+
|
9
|
+
def test_objectify
|
10
|
+
o = Object.new
|
11
|
+
class << o
|
12
|
+
include Ohbjects
|
13
|
+
end
|
14
|
+
|
15
|
+
klass = Class.new
|
16
|
+
|
17
|
+
klass.instance_eval do
|
18
|
+
extend Ohbjects::Buildable
|
19
|
+
builds "foo"
|
20
|
+
end
|
21
|
+
|
22
|
+
flexmock(klass).should_receive(:build => "built-foo").once
|
23
|
+
|
24
|
+
Ohbjects::REGISTRY.clear
|
25
|
+
Ohbjects::REGISTRY << klass
|
26
|
+
|
27
|
+
output = o.objectify(Nokogiri("<doc><foo /></doc>"))
|
28
|
+
|
29
|
+
assert_equal %w(built-foo), output
|
30
|
+
end
|
31
|
+
|
32
|
+
def test_activate_includes_ohbjects_into_oh
|
33
|
+
flexmock(Oh).should_receive(:include).with(Ohbjects).once
|
34
|
+
|
35
|
+
Ohbjects.activate
|
36
|
+
end
|
37
|
+
|
38
|
+
def test_buildable_build?
|
39
|
+
klass = Class.new
|
40
|
+
|
41
|
+
klass.instance_eval do
|
42
|
+
extend Ohbjects::Buildable
|
43
|
+
builds "foo"
|
44
|
+
end
|
45
|
+
|
46
|
+
assert klass.build?(Nokogiri("<doc><foo></foo></doc>"))
|
47
|
+
assert !klass.build?(Nokogiri("<doc><bar></bar></doc>"))
|
48
|
+
end
|
49
|
+
|
50
|
+
def test_buildable_adds_to_registry
|
51
|
+
before = Ohbjects::REGISTRY.size
|
52
|
+
|
53
|
+
klass = Class.new
|
54
|
+
|
55
|
+
klass.instance_eval do
|
56
|
+
extend Ohbjects::Buildable
|
57
|
+
builds "foo"
|
58
|
+
end
|
59
|
+
|
60
|
+
assert_equal before + 1, Ohbjects::REGISTRY.size
|
61
|
+
assert_equal klass, Ohbjects::REGISTRY.pop
|
62
|
+
end
|
63
|
+
end
|
@@ -0,0 +1,69 @@
|
|
1
|
+
require "test/unit"
|
2
|
+
require "flexmock/test_unit"
|
3
|
+
|
4
|
+
require "oh"
|
5
|
+
require "ohbjects"
|
6
|
+
|
7
|
+
class TestOhbjects < Test::Unit::TestCase
|
8
|
+
|
9
|
+
def test_option_builder_claims_to_build_option_quote
|
10
|
+
assert Ohbjects::OptionBuilder.build?(Nokogiri(OPTION_QUOTE))
|
11
|
+
end
|
12
|
+
|
13
|
+
def test_option_builder_build
|
14
|
+
fragment = Nokogiri(OPTION_QUOTE).search(Ohbjects::OptionBuilder.spec).first
|
15
|
+
|
16
|
+
options = Ohbjects::OptionBuilder.build(fragment)
|
17
|
+
|
18
|
+
assert_equal 2, options.size, "Should have built two options"
|
19
|
+
|
20
|
+
assert_equal [Ohbjects::Put, Ohbjects::Call], options.map{|x|x.class},
|
21
|
+
"Should be a put and a call"
|
22
|
+
|
23
|
+
put = options.shift
|
24
|
+
call = options.shift
|
25
|
+
|
26
|
+
# TODO: assert individual fields
|
27
|
+
|
28
|
+
end
|
29
|
+
|
30
|
+
def test_expires_date_conversion
|
31
|
+
call = Ohbjects::Call.new
|
32
|
+
call.expires = "Jan 12, 2011"
|
33
|
+
|
34
|
+
assert_equal 12, call.expires.day
|
35
|
+
assert_equal 1, call.expires.month
|
36
|
+
assert_equal 2011, call.expires.year
|
37
|
+
|
38
|
+
assert_raise ArgumentError, "other formats should be invalid" do
|
39
|
+
call.expires = "2011-01-11"
|
40
|
+
end
|
41
|
+
end
|
42
|
+
|
43
|
+
def test_cannont_instantiate_options_directly
|
44
|
+
assert_nothing_raised "subclasses should be instantiable" do
|
45
|
+
assert_kind_of Ohbjects::Option, Ohbjects::Call.new
|
46
|
+
assert_kind_of Ohbjects::Option, Ohbjects::Put.new
|
47
|
+
end
|
48
|
+
|
49
|
+
ex = assert_raise ArgumentError do
|
50
|
+
Ohbjects::Option.new
|
51
|
+
end
|
52
|
+
|
53
|
+
assert_match %r{cannot be instantiated}, ex.message
|
54
|
+
end
|
55
|
+
|
56
|
+
OPTION_QUOTE = <<-XML
|
57
|
+
<optionQuote id="201012180000090000AA"
|
58
|
+
series="Dec 10 9.0" strikeString="9.0" optionRoot="AA"
|
59
|
+
expYear="40" expMonth="12" expDay="18" strikePrice="90000"
|
60
|
+
dte="14" expDate="Dec 18, 2010" callKey="AA:20101218:90000:C"
|
61
|
+
callBid="5.10" callAsk="5.20" callChange="0.07" callVolume="0"
|
62
|
+
callOpenInterest="38" putKey="AA:20101218:90000:P" putBid="0.00"
|
63
|
+
putAsk="0.01" putChange="0.00" putVolume="0" putOpenInterest="86"
|
64
|
+
callIv="29.1" callDelta="1" callGamma="0" callTheta="0"
|
65
|
+
callVega="0" putIv="32.2" putDelta="0" putGamma="0" putTheta="0"
|
66
|
+
putVega="0" callBidSize="821" callAskSize="1085" putBidSize="0"
|
67
|
+
putAskSize="245" trimSet="2"/>
|
68
|
+
XML
|
69
|
+
end
|
metadata
ADDED
@@ -0,0 +1,133 @@
|
|
1
|
+
--- !ruby/object:Gem::Specification
|
2
|
+
name: oh
|
3
|
+
version: !ruby/object:Gem::Version
|
4
|
+
hash: 21
|
5
|
+
prerelease: false
|
6
|
+
segments:
|
7
|
+
- 1
|
8
|
+
- 0
|
9
|
+
- 1
|
10
|
+
version: 1.0.1
|
11
|
+
platform: ruby
|
12
|
+
authors:
|
13
|
+
- Andrew A. Smith
|
14
|
+
autorequire:
|
15
|
+
bindir: bin
|
16
|
+
cert_chain: []
|
17
|
+
|
18
|
+
date: 2010-12-24 00:00:00 -08:00
|
19
|
+
default_executable:
|
20
|
+
dependencies:
|
21
|
+
- !ruby/object:Gem::Dependency
|
22
|
+
name: nokogiri
|
23
|
+
prerelease: false
|
24
|
+
requirement: &id001 !ruby/object:Gem::Requirement
|
25
|
+
none: false
|
26
|
+
requirements:
|
27
|
+
- - ">="
|
28
|
+
- !ruby/object:Gem::Version
|
29
|
+
hash: 3
|
30
|
+
segments:
|
31
|
+
- 0
|
32
|
+
version: "0"
|
33
|
+
type: :runtime
|
34
|
+
version_requirements: *id001
|
35
|
+
- !ruby/object:Gem::Dependency
|
36
|
+
name: rubyforge
|
37
|
+
prerelease: false
|
38
|
+
requirement: &id002 !ruby/object:Gem::Requirement
|
39
|
+
none: false
|
40
|
+
requirements:
|
41
|
+
- - ">="
|
42
|
+
- !ruby/object:Gem::Version
|
43
|
+
hash: 7
|
44
|
+
segments:
|
45
|
+
- 2
|
46
|
+
- 0
|
47
|
+
- 4
|
48
|
+
version: 2.0.4
|
49
|
+
type: :development
|
50
|
+
version_requirements: *id002
|
51
|
+
- !ruby/object:Gem::Dependency
|
52
|
+
name: hoe
|
53
|
+
prerelease: false
|
54
|
+
requirement: &id003 !ruby/object:Gem::Requirement
|
55
|
+
none: false
|
56
|
+
requirements:
|
57
|
+
- - ">="
|
58
|
+
- !ruby/object:Gem::Version
|
59
|
+
hash: 19
|
60
|
+
segments:
|
61
|
+
- 2
|
62
|
+
- 7
|
63
|
+
- 0
|
64
|
+
version: 2.7.0
|
65
|
+
type: :development
|
66
|
+
version_requirements: *id003
|
67
|
+
description: |-
|
68
|
+
An API for OptionsHouse (http://optionshouse.com).
|
69
|
+
|
70
|
+
Currently provides a mechanism for pulling stock and option quotes.
|
71
|
+
email:
|
72
|
+
- andy@tinnedfruit.org
|
73
|
+
executables:
|
74
|
+
- oh
|
75
|
+
extensions: []
|
76
|
+
|
77
|
+
extra_rdoc_files:
|
78
|
+
- History.txt
|
79
|
+
- Manifest.txt
|
80
|
+
files:
|
81
|
+
- .autotest
|
82
|
+
- History.txt
|
83
|
+
- Manifest.txt
|
84
|
+
- README.rdoc
|
85
|
+
- Rakefile
|
86
|
+
- bin/oh
|
87
|
+
- lib/oh.rb
|
88
|
+
- lib/ohbjects.rb
|
89
|
+
- lib/ohbjects/account.rb
|
90
|
+
- lib/ohbjects/options.rb
|
91
|
+
- lib/ohbjects/quote.rb
|
92
|
+
- test/test_oh.rb
|
93
|
+
- test/test_ohbjects.rb
|
94
|
+
- test/test_options.rb
|
95
|
+
has_rdoc: true
|
96
|
+
homepage: http://github.com/aasmith/oh
|
97
|
+
licenses: []
|
98
|
+
|
99
|
+
post_install_message:
|
100
|
+
rdoc_options:
|
101
|
+
- --main
|
102
|
+
- README.rdoc
|
103
|
+
require_paths:
|
104
|
+
- lib
|
105
|
+
required_ruby_version: !ruby/object:Gem::Requirement
|
106
|
+
none: false
|
107
|
+
requirements:
|
108
|
+
- - ">="
|
109
|
+
- !ruby/object:Gem::Version
|
110
|
+
hash: 3
|
111
|
+
segments:
|
112
|
+
- 0
|
113
|
+
version: "0"
|
114
|
+
required_rubygems_version: !ruby/object:Gem::Requirement
|
115
|
+
none: false
|
116
|
+
requirements:
|
117
|
+
- - ">="
|
118
|
+
- !ruby/object:Gem::Version
|
119
|
+
hash: 3
|
120
|
+
segments:
|
121
|
+
- 0
|
122
|
+
version: "0"
|
123
|
+
requirements: []
|
124
|
+
|
125
|
+
rubyforge_project: oh
|
126
|
+
rubygems_version: 1.3.7
|
127
|
+
signing_key:
|
128
|
+
specification_version: 3
|
129
|
+
summary: An API for OptionsHouse (http://optionshouse.com)
|
130
|
+
test_files:
|
131
|
+
- test/test_options.rb
|
132
|
+
- test/test_oh.rb
|
133
|
+
- test/test_ohbjects.rb
|