penfold 1.0.0

Sign up to get free protection for your applications and to get access to all the features.
@@ -0,0 +1,26 @@
1
+ module CoveredCallEarlyExit
2
+ def proceeds
3
+ stock_sale - option_sale
4
+ end
5
+
6
+ def explain
7
+ template = <<-EOT
8
+ Early Exit
9
+ Stock Sale %s
10
+ - Call Buyback %s
11
+ = Net Proceeds %s (%s per share)
12
+ - Net Outlay %s
13
+ = Profit %s
14
+ EOT
15
+
16
+ template % [
17
+ stock_sale.to_money_s.rjust(12),
18
+ option_sale.to_money_s.rjust(12),
19
+ proceeds.to_money_s.rjust(12),
20
+ proceeds_per_share.to_money_s,
21
+ opening_position.net_outlay.to_money_s.rjust(12),
22
+ profit.to_money_s.rjust(12)
23
+ ]
24
+ end
25
+ end
26
+
@@ -0,0 +1,82 @@
1
+ class CoveredCallExit
2
+ include ArgumentProcessor
3
+
4
+ attr_reader :opening_position, :option
5
+
6
+ def initialize(args = {})
7
+ @opening_position = args[:opening_position]
8
+
9
+ @option = opening_position.option.dup
10
+ @option.stock = opening_position.stock.dup
11
+
12
+ stock.price = args[:stock_price] if args[:stock_price]
13
+ option.price = args[:option_price] if args[:option_price]
14
+ option.current_date = args[:exit_date] if args[:exit_date]
15
+
16
+ raise ArgumentError, "Stock does not match" unless stock == opening_position.stock
17
+ raise ArgumentError, "Option does not match" unless option == opening_position.option
18
+
19
+ extend exit_type
20
+ end
21
+
22
+ def exit_type
23
+ if option.expired? and option.in_the_money? then CoveredCallExpiryItmExit
24
+ elsif option.expired? and option.out_the_money? then CoveredCallExpiryOtmExit
25
+ else CoveredCallEarlyExit
26
+ end
27
+ end
28
+
29
+ def days_in_position
30
+ ([option.expires, option.current_date].min - opening_position.date_established).to_i
31
+ end
32
+
33
+ def annualized_return
34
+ #(1 + period_return) ** (1 / (opening_position.option.days_to_expiry/365.0)) - 1
35
+ (1 + period_return) ** (1 / (days_in_position/365.0)) - 1
36
+ end
37
+
38
+ def period_return
39
+ profit / opening_position.net_outlay.to_f
40
+ end
41
+
42
+ def profit
43
+ proceeds - opening_position.net_outlay
44
+ end
45
+
46
+ def proceeds
47
+ raise NotImplementedError
48
+ end
49
+
50
+ def proceeds_per_share
51
+ proceeds / num_shares
52
+ end
53
+
54
+ def stock
55
+ option.stock
56
+ end
57
+
58
+ def commission
59
+ opening_position.commission
60
+ end
61
+
62
+ def num_shares
63
+ opening_position.num_shares
64
+ end
65
+
66
+ def exit_date
67
+ opening_position.date_established + days_in_position
68
+ end
69
+
70
+ def exercise
71
+ (num_shares * option.strike) - commission.option_assignment
72
+ end
73
+
74
+ def stock_sale
75
+ (num_shares * stock.price) - commission.stock_entry
76
+ end
77
+
78
+ def option_sale
79
+ (num_shares * option.price) - commission.option_entry
80
+ end
81
+ end
82
+
@@ -0,0 +1,26 @@
1
+ module CoveredCallExpiryItmExit
2
+ def proceeds
3
+ exercise
4
+ end
5
+
6
+ def explain
7
+ template = <<-EOT
8
+ Call Expires ITM
9
+ Exercise %s (inc %s Assignment Fee)
10
+ = Net Proceeds %s (%s per share)
11
+ - Net Outlay %s
12
+ = Profit %s
13
+
14
+ EOT
15
+
16
+ template % [
17
+ exercise.to_money_s.rjust(12),
18
+ commission.option_assignment.to_money_s,
19
+ proceeds.to_money_s.rjust(12),
20
+ proceeds_per_share.to_money_s,
21
+ opening_position.net_outlay.to_money_s.rjust(12),
22
+ profit.to_money_s.rjust(12)
23
+ ]
24
+ end
25
+ end
26
+
@@ -0,0 +1,24 @@
1
+ module CoveredCallExpiryOtmExit
2
+ def proceeds
3
+ stock_sale
4
+ end
5
+
6
+ def explain
7
+ template = <<-EOT
8
+ Call Expires OTM
9
+ Stock Sale %s (%s per share)
10
+ - Net Outlay %s
11
+ = Profit %s
12
+
13
+
14
+ EOT
15
+
16
+ template % [
17
+ stock_sale.to_money_s.rjust(12),
18
+ proceeds_per_share.to_money_s,
19
+ opening_position.net_outlay.to_money_s.rjust(12),
20
+ profit.to_money_s.rjust(12)
21
+ ]
22
+ end
23
+ end
24
+
@@ -0,0 +1,86 @@
1
+ class CoveredCallPosition
2
+ include ArgumentProcessor
3
+
4
+ attr_accessor :num_shares, :date_established, :option
5
+
6
+ def initialize(args = {})
7
+ process_args(args)
8
+ end
9
+
10
+ def commission=(name)
11
+ @commission = name.to_s
12
+ end
13
+
14
+ def commission
15
+ @commission_instance ||= Commission.const_get(@commission).new(
16
+ :shares => num_shares, :contracts => num_shares / 100
17
+ )
18
+ end
19
+
20
+ undef num_shares=
21
+ def num_shares=(num)
22
+ raise ArgumentError, "Shares must be assigned in lots of 100." unless num % 100 == 0
23
+ @num_shares = num
24
+ end
25
+
26
+ def stock_total
27
+ num_shares * stock.price + commission.stock_entry
28
+ end
29
+
30
+ def call_sale
31
+ num_shares * option.price - commission.option_entry
32
+ end
33
+
34
+ def net_outlay
35
+ stock_total - call_sale
36
+ end
37
+
38
+ def net_per_share
39
+ net_outlay.to_f / num_shares
40
+ end
41
+
42
+ def downside_protection
43
+ (net_per_share.to_f - stock.price) / stock.price
44
+ end
45
+
46
+ def stock
47
+ option.stock
48
+ end
49
+
50
+ def implied_volatility
51
+ @iv ||= BlackScholes.call_iv(
52
+ stock.price / 100.0,
53
+ option.strike / 100.0,
54
+ 0.27, # TODO: risk-free rate
55
+ option.days_to_expiry(date_established),
56
+ option.price / 100.0
57
+ )
58
+ end
59
+
60
+ def probability_max_profit
61
+ BlackScholes.probability_above(
62
+ stock.price / 100.0,
63
+ option.strike / 100.0,
64
+ option.days_to_expiry(date_established),
65
+ implied_volatility
66
+ )
67
+ end
68
+
69
+ def probability_profit
70
+ BlackScholes.probability_above(
71
+ stock.price / 100.0,
72
+ net_per_share / 100.0,
73
+ option.days_to_expiry(date_established),
74
+ implied_volatility
75
+ )
76
+ end
77
+
78
+ # def exit(exit_date = Date.today + 1, stock_price = stock.price)
79
+ # CoveredCallExit.new(
80
+ # :opening_position => self,
81
+ # :stock_price => stock_price,
82
+ # :exit_date => exit_date
83
+ # )
84
+ # end
85
+ end
86
+
data/lib/market.rb ADDED
@@ -0,0 +1,254 @@
1
+ require 'rubygems'
2
+ require 'nokogiri'
3
+ require 'open-uri'
4
+ require 'date'
5
+ require 'cgi'
6
+
7
+ class Market
8
+ HEADERS = {
9
+ "Accept" => "application/xml,application/xhtml+xml,text/html;q=0.9,text/plain;q=0.8,image/png,*/*;q=0.5",
10
+ "Accept-Charset" => "ISO-8859-1,utf-8;q=0.7,*;q=0.3",
11
+ "Accept-Language" => "en-US,en;q=0.8",
12
+ "Cache-Control" => "max-age=0",
13
+ "Connection" => "keep-alive",
14
+ "Host" => "finance.yahoo.com",
15
+ "Referer" => "http://finance.yahoo.com/",
16
+ "User-Agent" => "Mozilla/5.0 (Windows; U; Windows NT 6.0; en-US) AppleWebKit/534.10 (KHTML, like Gecko) Chrome/8.0.552.215 Safari/534.10"
17
+ }
18
+
19
+ class Quote
20
+ include ArgumentProcessor
21
+
22
+ attr_accessor :symbol, :last, :bid, :ask, :extra
23
+
24
+ def initialize(args = {})
25
+ process_args(args)
26
+ self.extra = {}
27
+ end
28
+
29
+ def mid
30
+ @mid ||= [bid, ask].reduce(:+) / 2.0
31
+ end
32
+
33
+ def inspect
34
+ "<%s: L:%s B:%s A:%s>" % [
35
+ symbol, last.to_money_s, bid.to_money_s, ask.to_money_s
36
+ ]
37
+ end
38
+ end
39
+
40
+ class << self
41
+ def fetch(ticker, opts = {})
42
+ url = "http://finance.yahoo.com/q?s=%s" % ticker
43
+ puts "Fetching #{url}..." if $VERBOSE
44
+
45
+ doc = with_retry do
46
+ Nokogiri::HTML.parse(open(url, HEADERS).read)
47
+ end
48
+
49
+ # Realtime last is at yfs_l90_sym, use if exists
50
+ if opts[:try_rt]
51
+ last = (doc.at_css("#yfs_l90_#{ticker.downcase}").text.to_f * 100) rescue nil
52
+ end
53
+
54
+ last ||= (doc.at_css("#yfs_l10_#{ticker.downcase}").text.to_f * 100)
55
+ bid = (doc.at_css("#yfs_b00_#{ticker.downcase}").text.to_f * 100) rescue 0
56
+ ask = (doc.at_css("#yfs_a00_#{ticker.downcase}").text.to_f * 100) rescue 0
57
+
58
+ quote = Quote.new(
59
+ :symbol => ticker, :last => last, :bid => bid, :ask => ask)
60
+
61
+ if opts[:extra]
62
+ name = doc.at('h1').text.scan(/(.*) \([\w-]+\)$/).to_s # strip trailing (SYMBOL)
63
+ mktcap = doc.at_css("#yfs_j10_#{ticker.downcase}").text rescue nil
64
+
65
+ divyield = doc.at("#table2 .end td").text.scan(/\((.*)\)/).to_s.to_f rescue nil
66
+
67
+ begin
68
+ pe_row = doc.at("#table2 tr:nth-child(6)")
69
+ pe_label, pe_data = pe_row.search("th,td").map{|e|e.text}
70
+
71
+ unless pe_label =~ %r(P/E)
72
+ puts "P/E label mismatch"
73
+ pe_data = nil
74
+ end
75
+ rescue
76
+ # nothing
77
+ end
78
+
79
+ sector, industry = doc.search("#company_details a").map{|e|e.text} rescue nil
80
+
81
+ quote.extra = {
82
+ :name => name,
83
+ :mktcap => mktcap,
84
+ :divyield => divyield,
85
+ :pe => pe_data.to_f,
86
+ :sector => sector,
87
+ :industry => industry
88
+ }
89
+ end
90
+
91
+ puts quote.inspect if $VERBOSE
92
+
93
+ quote
94
+ end
95
+
96
+ require 'json'
97
+ def gchain(ticker, expiry)
98
+ url = "http://www.google.com/finance/option_chain?q=%s&expd=%s&expm=%s&expy=%s&output=json"
99
+ url = url % [ticker.gsub(/-/, "."), expiry.day, expiry.month, expiry.year]
100
+
101
+ puts "Fetching #{url}..." if $VERBOSE
102
+ doc = with_retry do
103
+ JSON.parse(
104
+ open(url).read.
105
+ gsub(/(\:"\w+):/, '\1'). # Remove extra colon that appears inside strings
106
+ gsub('\\', ""). # Remove slashes
107
+ gsub(/(['"])?(\w+)(['"])?:/m, '"\2":'), # Quote each key
108
+ :symbolize_names => true)
109
+ end
110
+
111
+ doc[:calls].map do |call|
112
+ quote = Quote.new(
113
+ :symbol => call[:s],
114
+ :last => call[:p].to_f * 100,
115
+ :bid => call[:b].to_f * 100,
116
+ :ask => call[:a].to_f * 100
117
+ )
118
+
119
+ unless quote.symbol =~ /^\w+\d+[CP]\d+$/
120
+ puts "Dropping bad option #{quote.symbol.inspect}"
121
+ next
122
+ end
123
+
124
+ unless quote.bid > 0 and quote.ask > 0
125
+ puts "Option #{quote.symbol.inspect} has no bid or ask"
126
+ next
127
+ end
128
+
129
+ [call[:strike].to_f * 100, quote]
130
+ end.compact
131
+ end
132
+
133
+ def chain(ticker, expiry)
134
+ url = "http://finance.yahoo.com/q/op?s=%s&m=%s" % [ticker, expiry.strftime("%Y-%m")]
135
+ puts "Fetching #{url}..." if $VERBOSE
136
+
137
+ doc = with_retry do
138
+ Nokogiri::HTML.parse(open(url, HEADERS).read)
139
+ end
140
+
141
+ itm_call_data = doc.
142
+ search("//table[@class='yfnc_datamodoutline1'][1]//td[@class='yfnc_h']").
143
+ map { |e| e.text }
144
+
145
+ rows = itm_call_data.in_groups_of(8)
146
+
147
+ rows.map do |row|
148
+ strike = row[0].to_f * 100
149
+ symbol = row[1]
150
+ last = row[2].to_f * 100
151
+ bid = row[4].to_f * 100
152
+ ask = row[5].to_f * 100
153
+
154
+ raise "Expected symbol, got #{symbol.inspect}" unless symbol =~ /^\w+\d+[CP]\d+$/
155
+
156
+ # Only pick symbols that have the correct expiry
157
+ # Occurs when multiple series appear for the same month (i.e. weeklys)
158
+ next unless symbol =~ /#{expiry.strftime("%y%m%d")}/
159
+
160
+ quote = Quote.new(
161
+ :symbol => symbol, :last => last, :bid => bid, :ask => ask)
162
+
163
+ [strike, quote]
164
+ end.compact
165
+ end
166
+
167
+ def event?(ticker, on_or_before_date)
168
+ url = "http://finance.yahoo.com/q/ce?s=%s" % ticker
169
+ puts "Fetching #{url}..." if $VERBOSE
170
+
171
+ doc = with_retry do
172
+ Nokogiri::HTML.parse(open(url, HEADERS).read)
173
+ end
174
+
175
+ return false if doc.text =~ /There is no Company Events data/
176
+
177
+ fragment = doc.
178
+ search("//table[@class='yfnc_datamodoutline1'][1]//td[@class='yfnc_tabledata1']")
179
+
180
+ return false if fragment.text =~ /No Upcoming Events/i
181
+
182
+ events = fragment.map{|e|e.text}.in_groups_of(3)
183
+
184
+ events.any? do |date, event, _|
185
+ event_date = Date.strptime date, "%d-%b-%y"
186
+
187
+ event_date <= on_or_before_date
188
+ end
189
+ end
190
+
191
+ def historical_prices(ticker, from = Date.today - 365)
192
+ to = Date.today
193
+
194
+ url = "http://ichart.finance.yahoo.com/table.csv?s=%s&a=%s&b=%s&c=%s&d=%se=%s&f=%sg=d&ignore=.csv"
195
+ url = url % [ticker, from.month - 1, from.day, from.year, to.month - 1, to.year, to.day]
196
+
197
+ puts "Fetching #{url}..." if $VERBOSE
198
+
199
+ csv = with_retry do
200
+ open(url, HEADERS).read
201
+ end
202
+
203
+ # [newest, ..., oldest]
204
+ csv.scan(/\d+\.\d+$/).map { |p| p.to_f }
205
+ end
206
+
207
+ def constituents(ticker, offset = 0, traverse = true)
208
+ ticker = "^#{ticker}" unless ticker =~ /^\^/
209
+
210
+ url = "http://finance.yahoo.com/q/cp?s=%s&c=%s" % [CGI.escape(ticker), offset]
211
+ puts "Fetching #{url}..." if $VERBOSE
212
+
213
+ doc = with_retry do
214
+ Nokogiri::HTML.parse(open(url, HEADERS).read)
215
+ end
216
+
217
+ symbols = doc.at("#yfncsumtab").search("tr td:first-child.yfnc_tabledata1").map{ |td| td.text }
218
+ next_link = doc.at("#yfncsumtab").at("//a[text()='Next']")
219
+
220
+ if next_link && traverse
221
+ return symbols + constituents(ticker, offset + 1)
222
+ end
223
+
224
+ return symbols
225
+ end
226
+
227
+ INDEXES = {}
228
+
229
+ def member_of?(ticker, index_ticker)
230
+ index_members = INDEXES[index_ticker] ||= constituents(index_ticker)
231
+
232
+ index_members.include?(ticker)
233
+ end
234
+
235
+ def with_retry(&block)
236
+ retries = 20
237
+
238
+ begin
239
+ timeout(5) do
240
+ block.call
241
+ end
242
+ rescue Exception
243
+ retries -= 1
244
+ unless retries.zero?
245
+ puts "Got error, retrying"
246
+ puts $!
247
+ retry
248
+ end
249
+ raise
250
+ end
251
+ end
252
+ end
253
+ end
254
+
data/lib/option.rb ADDED
@@ -0,0 +1,96 @@
1
+ class Option
2
+ include ArgumentProcessor
3
+
4
+ attr_accessor :symbol, :stock, :strike, :expires, :price, :current_date
5
+
6
+ def initialize(args = {})
7
+ raise ArgumentError, "An option cannot be instantiated" if instance_of? Option
8
+
9
+ @current_date = nil
10
+
11
+ process_args(args)
12
+ end
13
+
14
+ def days_to_expiry(opening_date = current_date)
15
+ [0, (expires - opening_date).to_i].max
16
+ end
17
+
18
+ def expired?
19
+ expires < current_date
20
+ end
21
+
22
+ undef current_date
23
+ def current_date
24
+ @current_date || Date.today
25
+ end
26
+
27
+ def call?
28
+ false
29
+ end
30
+
31
+ def put?
32
+ false
33
+ end
34
+
35
+ def in_the_money?
36
+ raise NotImplementedError
37
+ end
38
+
39
+ def out_the_money?
40
+ !in_the_money?
41
+ end
42
+
43
+ # A specific sub state of being out the money.
44
+ def at_the_money?
45
+ stock.price == strike
46
+ end
47
+
48
+ def to_s
49
+ [stock.symbol, float_if_needed(strike),
50
+ expires.strftime("%b %y").upcase, self.class.name.upcase].join(" ")
51
+ end
52
+
53
+ def to_ticker_s
54
+ return symbol if symbol
55
+
56
+ call_or_put = call? ? "C" : "P"
57
+
58
+ dollar = (strike.to_i / 100).to_s.rjust(5, "0")
59
+ decimal = (strike.to_i % 100).to_s.ljust(3, "0")
60
+
61
+ [stock.symbol, expires.strftime("%y%m%d"), call_or_put, dollar, decimal].join.upcase
62
+ end
63
+
64
+ def ==(other)
65
+ other.stock == stock &&
66
+ other.strike == strike &&
67
+ other.expires == expires
68
+ end
69
+
70
+ private
71
+
72
+ def float_if_needed(num)
73
+ num / 100.0 == num / 100 ? num / 100 : num / 100.0
74
+ end
75
+ end
76
+
77
+ class Call < Option
78
+ def call?
79
+ true
80
+ end
81
+
82
+ def in_the_money?
83
+ stock.price > strike
84
+ end
85
+ end
86
+
87
+ class Put < Option
88
+ def put?
89
+ true
90
+ end
91
+
92
+ def in_the_money?
93
+ stock.price < strike
94
+ end
95
+ end
96
+
data/lib/penfold.rb ADDED
@@ -0,0 +1,20 @@
1
+ require 'core_ext'
2
+ require 'argument_processor'
3
+
4
+ require 'stock'
5
+ require 'option'
6
+ require 'commission'
7
+
8
+ require 'market'
9
+
10
+ require 'covered_call_position'
11
+ require 'covered_call_exit'
12
+ require 'covered_call_early_exit'
13
+ require 'covered_call_expiry_itm_exit'
14
+ require 'covered_call_expiry_otm_exit'
15
+
16
+ require 'black_scholes'
17
+
18
+ module Penfold
19
+ VERSION = '1.0.0'
20
+ end
data/lib/stock.rb ADDED
@@ -0,0 +1,15 @@
1
+ class Stock
2
+ include ArgumentProcessor
3
+
4
+ attr_accessor :symbol, :price
5
+
6
+ def initialize(args = {})
7
+ process_args(args)
8
+ symbol.upcase! if symbol
9
+ end
10
+
11
+ def ==(other)
12
+ symbol == other.symbol
13
+ end
14
+ end
15
+