penfold 1.0.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,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
+