penfold 1.0.0 → 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/Manifest.txt CHANGED
@@ -3,10 +3,16 @@ History.txt
3
3
  Manifest.txt
4
4
  README.rdoc
5
5
  Rakefile
6
+ bin/constituents
7
+ bin/optiondate
6
8
  bin/penfold
7
9
  bin/penfold-position
8
10
  bin/penfold-try
11
+ db/init.rb
12
+ db/slurp.rb
13
+ db/sqliterc
9
14
  lib/argument_processor.rb
15
+ lib/black_scholes.rb
10
16
  lib/commission.rb
11
17
  lib/core_ext.rb
12
18
  lib/covered_call_early_exit.rb
@@ -16,7 +22,13 @@ lib/covered_call_expiry_otm_exit.rb
16
22
  lib/covered_call_position.rb
17
23
  lib/market.rb
18
24
  lib/option.rb
25
+ lib/option_calendar.rb
19
26
  lib/penfold.rb
20
27
  lib/stock.rb
21
- portfolio.yml
28
+ portfolio.example.yml
29
+ symbols/README.txt
30
+ symbols/all-optionable.txt
31
+ symbols/etfs.txt
32
+ symbols/weeklys.txt
33
+ test/test_option_calendar.rb
22
34
  test/test_penfold.rb
data/Rakefile CHANGED
@@ -6,6 +6,11 @@ require 'hoe'
6
6
  Hoe.spec 'penfold' do |p|
7
7
  p.developer('Andrew A. Smith', 'andy@tinnedfruit.org')
8
8
  p.readme_file = "README.rdoc"
9
+ p.extra_deps.push *[
10
+ %w(nokogiri >=0),
11
+ %w(penfold >=0),
12
+ %w(net-http-persistent >=0)
13
+ ]
9
14
  end
10
15
 
11
16
  Hoe.add_include_dirs '.'
data/bin/constituents ADDED
@@ -0,0 +1,11 @@
1
+ #!/usr/bin/env ruby
2
+
3
+ abort "usage: #{File.basename($0)} [index]" unless ARGV[0]
4
+
5
+ LIB_DIR = File.expand_path(File.join(File.dirname(__FILE__), '..', 'lib'))
6
+ $LOAD_PATH << LIB_DIR
7
+
8
+ require 'lib/penfold'
9
+
10
+ puts "# Generated at #{Time.now}"
11
+ puts Market.constituents(ARGV[0]).join("\n")
data/bin/optiondate ADDED
@@ -0,0 +1,17 @@
1
+ #!/usr/bin/env ruby
2
+
3
+ # optiondate [num of days]
4
+
5
+ LIB_DIR = File.expand_path(File.join(File.dirname(__FILE__), '..', 'lib'))
6
+ $LOAD_PATH << LIB_DIR
7
+
8
+ require 'lib/option_calendar'
9
+
10
+ unless ARGV.size == 1 and ARGV.to_s =~ /^\d+$/
11
+ abort "usage: #{File.basename($0)} [num of days]"
12
+ end
13
+
14
+ expiry = OptionCalendar.nearest_expiration(Date.today, ARGV[0].to_i.days)
15
+
16
+ puts expiry.strftime("%y%m%d")
17
+
data/db/init.rb ADDED
@@ -0,0 +1,39 @@
1
+ require "rubygems"
2
+ require "sequel"
3
+
4
+ DB = Sequel.sqlite("options.sqlite")
5
+
6
+ DB.create_table :covered_calls do
7
+ primary_key :id
8
+
9
+ Date :quote_date, :null => false
10
+ Integer :days, :null => false
11
+
12
+ String :stock, :null => false
13
+ Float :stock_price, :null => false # last
14
+
15
+ String :option, :null => false
16
+ Float :option_price, :null => false # bid
17
+
18
+ Float :implied_volatility, :null => false
19
+ Float :prob_max_profit, :null => false
20
+ Float :prob_profit, :null => false
21
+
22
+ Float :downside_protection, :null => false
23
+
24
+ Float :period_return, :null => false
25
+ Float :annual_return, :null => false
26
+
27
+ Boolean :event, :null => false
28
+
29
+ String :label
30
+ String :mktcap
31
+ String :name
32
+
33
+ index :annual_return
34
+ index :downside_protection
35
+ index :prob_max_profit
36
+
37
+ unique [:quote_date, :stock, :option]
38
+ end
39
+
data/db/slurp.rb ADDED
@@ -0,0 +1,202 @@
1
+ require 'rubygems'
2
+ require 'sequel'
3
+ require 'penfold'
4
+
5
+ filename = ARGV[0]
6
+
7
+ symbols = if filename
8
+ File.read(filename).
9
+ split(/\n/).
10
+ map{|s| s.strip}.
11
+ reject{|s|s =~ /^#/}
12
+ else
13
+ %w(VZ C GE DVY PGR COCO BP RIG)
14
+ end
15
+
16
+ # series is the option symbol timestamp,
17
+ # such as 100821, 100918, etc.
18
+ series = ARGV[1]
19
+ expiry = Date.strptime series, "%y%m%d"
20
+
21
+ today = Date.today
22
+
23
+ quote_date = if [6,0].include?(today.wday)
24
+ # weekend, wind back to friday
25
+ today - (today.wday.zero? ? 2 : 1)
26
+ else
27
+ today
28
+ end
29
+
30
+ puts "Quote date is #{quote_date.inspect}"
31
+
32
+ INVESTMENT_AMOUNT = 10_000_00 # $10,000.00
33
+
34
+ # Do not reorder these, only append.
35
+ # DJI = 1
36
+ # OEX = 2
37
+ # SPX = 4
38
+ # ETFS = 8
39
+ # NONE = 16
40
+ INDEX_SYMBOLS = %w(DJI OEX SPX ETFS NONE)
41
+
42
+ DB = Sequel.sqlite("options.sqlite")
43
+ calls = DB[:covered_calls]
44
+
45
+ symbols.each do |symbol|
46
+ puts "== #{symbol} ====================="
47
+
48
+ begin
49
+ # Get a delayed quote, as option quotes are delayed.
50
+ stock_quote = Market.fetch(symbol, :try_rt => false, :extra => true)
51
+ rescue
52
+ puts "Error getting #{symbol}"
53
+ puts $!
54
+ next
55
+ end
56
+
57
+ stock = Stock.new(:symbol => symbol, :price => stock_quote.last)
58
+
59
+ num_shares = ((INVESTMENT_AMOUNT / stock.price) / 100.0).floor * 100
60
+
61
+ if num_shares.zero?
62
+ puts "Skipping because #{INVESTMENT_AMOUNT.to_money_s} will not buy any shares of #{symbol} @ #{stock.price.to_money_s}."
63
+ next
64
+ end
65
+
66
+ begin
67
+ itm_option_quotes = Market.chain(symbol, expiry)
68
+ rescue
69
+ puts "No options for #{symbol}"
70
+ puts $!
71
+ next
72
+ end
73
+
74
+ event = Market.event?(symbol, expiry)
75
+
76
+ begin
77
+ prices = Market.historical_prices(stock.symbol)
78
+ rescue
79
+ puts "No historical pricing available for #{symbol}"
80
+ puts $!
81
+ next
82
+ end
83
+
84
+
85
+
86
+ itm_options = itm_option_quotes.map do |strike, option_quote|
87
+ Call.new(
88
+ :symbol => option_quote.symbol,
89
+ :stock => stock,
90
+ :strike => strike,
91
+ :expires => expiry,
92
+ :price => option_quote.bid, # TODO: parametize
93
+ :current_date => quote_date
94
+ )
95
+ end
96
+
97
+ positions = itm_options.map do |option|
98
+ CoveredCallPosition.new(
99
+ :commission => "OptionsHouse",
100
+ :num_shares => num_shares,
101
+ :date_established => Date.today,
102
+ :option => option
103
+ )
104
+ end
105
+
106
+ # Get the IV for the nearest ot the money ITM option. If this comes back as
107
+ # N/A, then the option is probably too far from the money to have an IV.
108
+ # (example C @ 3.95, ITM option is at 3 strike with few days left)
109
+ nearest_atm_iv = positions.last.implied_volatility rescue 0.0
110
+
111
+ positions.each { |p| p.instance_variable_set "@iv", nearest_atm_iv }
112
+
113
+ positions.each do |position|
114
+ close = CoveredCallExit.new(
115
+ :opening_position => position,
116
+ :exit_date => Date.today + 99999,
117
+ :stock_price => stock.price,
118
+ :option_price => position.option.price
119
+ )
120
+
121
+ # calculate percentage above strike for year
122
+ strike = position.option.strike / 100.0
123
+ above = prices.select { |price| price >= strike }
124
+ percent_above = above.size / prices.size.to_f
125
+
126
+ summary = <<-SUMMARY
127
+ %s @ %s: %s @ %s IV %s
128
+ Prob. (Max) profit (%s) %s
129
+ Downside Protection %s
130
+ Return (period) ann (%s) %s %s
131
+ SUMMARY
132
+
133
+ r = close.annualized_return
134
+
135
+ if r > 0.12
136
+ puts summary % [
137
+ stock.symbol,
138
+ stock.price.to_money_s,
139
+ position.option.to_ticker_s,
140
+ position.option.price.to_money_s,
141
+
142
+ position.implied_volatility.to_percent_s(2),
143
+ position.probability_max_profit.to_percent_s(2),
144
+ position.probability_profit.to_percent_s(2),
145
+
146
+ position.downside_protection.to_percent_s(2),
147
+
148
+ close.period_return.to_percent_s(2),
149
+ r.to_percent_s(2),
150
+ event ? "EVENT IN DURATION" : ""
151
+ ]
152
+
153
+
154
+ flag = 1 << INDEX_SYMBOLS.index("NONE")
155
+
156
+ INDEX_SYMBOLS.each_with_index do |index_symbol, i|
157
+ next if index_symbol == "NONE"
158
+
159
+ member_of_index = if File.exists?(fn = "symbols/#{index_symbol.downcase}.txt")
160
+ File.read(fn).include?("\n#{stock.symbol}\n")
161
+ else
162
+ Market.member_of?(stock.symbol, index_symbol)
163
+ end
164
+
165
+ flag |= (1 << i) if member_of_index
166
+ end
167
+
168
+ mktcap = stock_quote.extra[:mktcap]
169
+
170
+ if mktcap
171
+ multiplier = mktcap.scan(/.$/).to_s
172
+ mktcap = mktcap.to_f
173
+ mktcap = multiplier == "B" ? mktcap * 1_000_000_000 : multiplier == "M" ? mktcap * 1_000_000 : nil
174
+ end
175
+
176
+ calls.insert(
177
+ :quote_date => quote_date,
178
+ :days => close.days_in_position,
179
+ :stock => stock.symbol,
180
+ :stock_price => stock.price / 100.0,
181
+ :option => position.option.to_ticker_s,
182
+ :option_price => position.option.price / 100.0,
183
+ :implied_volatility => position.implied_volatility * 100.0,
184
+ :prob_max_profit => position.probability_max_profit * 100.0,
185
+ :prob_profit => position.probability_profit * 100.0,
186
+ :downside_protection => position.downside_protection * 100.0,
187
+ :period_return => close.period_return * 100.0,
188
+ :annual_return => close.annualized_return * 100.0,
189
+ :event => event,
190
+ :label => File.split(filename).last,
191
+ :mktcap => mktcap,
192
+ :name => stock_quote.extra[:name],
193
+ :divyield => stock_quote.extra[:divyield],
194
+ :pe => stock_quote.extra[:pe],
195
+ :sector => stock_quote.extra[:sector],
196
+ :industry => stock_quote.extra[:industry],
197
+ :percent_above => percent_above * 100.0,
198
+ :memberships => flag
199
+ )
200
+ end
201
+ end
202
+ end
data/db/sqliterc ADDED
@@ -0,0 +1,4 @@
1
+ -- Place this in ~/.sqliterc
2
+ .headers on
3
+ .mode column
4
+ .width 5 10 4 5 7 24 6 6 6 6 6 6 6 6 16 10 40 5 7 10 25 covered_calls
@@ -0,0 +1,146 @@
1
+ # JavaScript adopted from Bernt Arne Odegaard's Financial Numerical Recipes
2
+ # http://finance.bi.no/~bernt/gcc_prog/algoritms/algoritms/algoritms.html
3
+ # by Steve Derezinski, CXWeb, Inc. http://www.cxweb.com
4
+ # Copyright (C) 1998 Steve Derezinski, Bernt Arne Odegaard
5
+ #
6
+ # This program is free software; you can redistribute it and/or
7
+ # modify it under the terms of the GNU General Public License
8
+ # as published by the Free Software Foundation.
9
+ #
10
+ # This program is distributed in the hope that it will be useful,
11
+ # but WITHOUT ANY WARRANTY; without even the implied warranty of
12
+ # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
13
+ # GNU General Public License for more details.
14
+ # http://www.fsf.org/copyleft/gpl.html
15
+
16
+ class BlackScholes
17
+ class << self
18
+ def ndist(z)
19
+ (1.0/(Math.sqrt(2*Math::PI)))*Math.exp(-0.5*z)
20
+ end
21
+
22
+ def n(z)
23
+ b1 = 0.31938153
24
+ b2 = -0.356563782
25
+ b3 = 1.781477937
26
+ b4 = -1.821255978
27
+ b5 = 1.330274429
28
+ p = 0.2316419
29
+ c2 = 0.3989423
30
+
31
+ a = z.abs
32
+
33
+ return 1.0 if a > 6.0
34
+
35
+ t = 1.0/(1.0+a*p)
36
+ b = c2*Math.exp((-z)*(z/2.0))
37
+ n = ((((b5*t+b4)*t+b3)*t+b2)*t+b1)*t
38
+ n = 1.0-b*n
39
+
40
+ n = 1.0 - n if z < 0.0
41
+
42
+ n
43
+ end
44
+
45
+ def black_scholes(call,s,x,r,v,t)
46
+ # call = Boolean (to calc call, call=True, put: call=false)
47
+ # s = stock prics, x = strike price, r = no-risk interest rate
48
+ # v = volitility (1 std dev of s for (1 yr? 1 month?, you pick)
49
+ # t = time to maturity
50
+
51
+ sqt = Math.sqrt(t)
52
+
53
+ d1 = (Math.log(s/x) + r*t)/(v*sqt) + 0.5*(v*sqt)
54
+ d2 = d1 - (v*sqt)
55
+
56
+ if call
57
+ delta = n(d1)
58
+ nd2 = n(d2)
59
+ else # put
60
+ delta = -n(-d1)
61
+ nd2 = -n(-d2)
62
+ end
63
+
64
+ ert = Math.exp(-r*t)
65
+ nd1 = ndist(d1)
66
+
67
+ gamma = nd1/(s*v*sqt)
68
+ vega = s*sqt*nd1
69
+ theta = -(s*v*nd1)/(2*sqt) - r*x*ert*nd2
70
+ rho = x*t*ert*nd2
71
+
72
+ s*delta-x*ert*nd2
73
+ end
74
+
75
+ def option_implied_volatility(call,s,x,r,t,o)
76
+ # call = Boolean (to calc call, call=True, put: call=false)
77
+ # s = stock prics, x = strike price, r = no-risk interest rate
78
+ # t = time to maturity
79
+ # o = option price
80
+
81
+ sqt = Math.sqrt(t)
82
+ accuracy = 0.0001
83
+
84
+ sigma = (o/s)/(0.398*sqt)
85
+
86
+ 100.times do
87
+ price = black_scholes(call,s,x,r,sigma,t)
88
+ diff = o-price
89
+
90
+ return sigma if diff.abs < accuracy
91
+
92
+ d1 = (Math.log(s/x) + r*t)/(sigma*sqt) + 0.5*sigma*sqt
93
+ vega = s*sqt*ndist(d1)
94
+ sigma = sigma+diff/vega
95
+ end
96
+
97
+ raise "Failed to converge"
98
+ end
99
+
100
+ def call_iv(s,x,r,t,o)
101
+ option_implied_volatility(true,s,x,r/100.0,t/365.0,o)
102
+ end
103
+
104
+ # Returns probability of occuring below and above target price.
105
+ def probability(price, target, days, volatility)
106
+ p = price.to_f
107
+ q = target.to_f
108
+ t = days / 365.0
109
+ v = volatility.to_f
110
+
111
+ vt = v*Math.sqrt(t)
112
+ lnpq = Math.log(q/p)
113
+
114
+ d1 = lnpq / vt
115
+
116
+ y = (1/(1+0.2316419*d1.abs)*100000).floor / 100000.0
117
+ z = (0.3989423*Math.exp(-((d1*d1)/2))*100000).floor / 100000.0
118
+
119
+ y5 = 1.330274*(y**5)
120
+ y4 = 1.821256*(y**4)
121
+ y3 = 1.781478*(y**3)
122
+ y2 = 0.356538*(y**2)
123
+ y1 = 0.3193815*y
124
+
125
+ x = 1-z*(y5-y4+y3-y2+y1)
126
+
127
+ x = (x*100000).floor / 100000.0
128
+
129
+ x = 1-x if d1 < 0
130
+
131
+ pbelow = (x*1000).floor / 10.0
132
+ pabove = ((1-x)*1000).floor / 10.0;
133
+
134
+ [pbelow/100,pabove/100];
135
+ end
136
+
137
+ def probability_above(price, target, days, volatility)
138
+ probability(price, target, days, volatility)[1]
139
+ end
140
+
141
+ def probability_below(price, target, days, volatility)
142
+ probability(price, target, days, volatility)[0]
143
+ end
144
+ end
145
+ end
146
+
data/lib/market.rb CHANGED
@@ -1,21 +1,27 @@
1
- require 'rubygems'
2
- require 'nokogiri'
3
- require 'open-uri'
4
1
  require 'date'
5
2
  require 'cgi'
6
3
 
4
+ require 'stringio'
5
+ require 'zlib'
6
+
7
+ require 'rubygems'
8
+ require 'nokogiri'
9
+ require 'net/http/persistent'
10
+
7
11
  class Market
8
12
  HEADERS = {
9
13
  "Accept" => "application/xml,application/xhtml+xml,text/html;q=0.9,text/plain;q=0.8,image/png,*/*;q=0.5",
10
14
  "Accept-Charset" => "ISO-8859-1,utf-8;q=0.7,*;q=0.3",
11
15
  "Accept-Language" => "en-US,en;q=0.8",
16
+ "Accept-Encoding" => "gzip,deflate",
12
17
  "Cache-Control" => "max-age=0",
13
- "Connection" => "keep-alive",
14
18
  "Host" => "finance.yahoo.com",
15
19
  "Referer" => "http://finance.yahoo.com/",
16
20
  "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
21
  }
18
22
 
23
+ @http = Net::HTTP::Persistent.new
24
+
19
25
  class Quote
20
26
  include ArgumentProcessor
21
27
 
@@ -38,12 +44,33 @@ class Market
38
44
  end
39
45
 
40
46
  class << self
47
+ def get(url)
48
+ uri = URI.parse(url)
49
+
50
+ request = Net::HTTP::Get.new uri.request_uri
51
+
52
+ HEADERS.each do |name, value|
53
+ request.add_field name, value
54
+ end
55
+
56
+ response = @http.request(uri, request)
57
+ data = response.body
58
+
59
+ out = case response["content-encoding"]
60
+ when /gzip/ then Zlib::GzipReader.new(StringIO.new(data)).read
61
+ when /deflate/ then Zlib::Inflate.inflate(data)
62
+ else data
63
+ end
64
+
65
+ out
66
+ end
67
+
41
68
  def fetch(ticker, opts = {})
42
69
  url = "http://finance.yahoo.com/q?s=%s" % ticker
43
70
  puts "Fetching #{url}..." if $VERBOSE
44
71
 
45
72
  doc = with_retry do
46
- Nokogiri::HTML.parse(open(url, HEADERS).read)
73
+ Nokogiri::HTML.parse(get(url))
47
74
  end
48
75
 
49
76
  # Realtime last is at yfs_l90_sym, use if exists
@@ -135,7 +162,7 @@ class Market
135
162
  puts "Fetching #{url}..." if $VERBOSE
136
163
 
137
164
  doc = with_retry do
138
- Nokogiri::HTML.parse(open(url, HEADERS).read)
165
+ Nokogiri::HTML.parse(get(url))
139
166
  end
140
167
 
141
168
  itm_call_data = doc.
@@ -169,7 +196,7 @@ class Market
169
196
  puts "Fetching #{url}..." if $VERBOSE
170
197
 
171
198
  doc = with_retry do
172
- Nokogiri::HTML.parse(open(url, HEADERS).read)
199
+ Nokogiri::HTML.parse(get(url))
173
200
  end
174
201
 
175
202
  return false if doc.text =~ /There is no Company Events data/
@@ -197,7 +224,7 @@ class Market
197
224
  puts "Fetching #{url}..." if $VERBOSE
198
225
 
199
226
  csv = with_retry do
200
- open(url, HEADERS).read
227
+ get(url)
201
228
  end
202
229
 
203
230
  # [newest, ..., oldest]
@@ -211,7 +238,7 @@ class Market
211
238
  puts "Fetching #{url}..." if $VERBOSE
212
239
 
213
240
  doc = with_retry do
214
- Nokogiri::HTML.parse(open(url, HEADERS).read)
241
+ Nokogiri::HTML.parse(get(url))
215
242
  end
216
243
 
217
244
  symbols = doc.at("#yfncsumtab").search("tr td:first-child.yfnc_tabledata1").map{ |td| td.text }
@@ -0,0 +1,56 @@
1
+ # Finds upcoming expiry dates for monthly equity options.
2
+ #
3
+ # Test data is the official CBOE expiration calendars for 2010/11.
4
+
5
+ require 'date'
6
+
7
+ require 'rubygems'
8
+ require 'active_support'
9
+
10
+ class OptionCalendar
11
+ class << self
12
+ FRIDAY = 5
13
+
14
+ def expiration_for(month)
15
+ start = month.beginning_of_month
16
+
17
+ expiry_week = start.advance(:days => 14)
18
+
19
+ expiry_week += 1.day while expiry_week.wday != FRIDAY
20
+
21
+ expiry_saturday = expiry_week + 1.day
22
+ end
23
+
24
+ def next_expiring_months(from = Date.today)
25
+ this_month = expiration_for(from)
26
+ next_month = expiration_for(from.next_month)
27
+
28
+ expirations = [
29
+ this_month,
30
+ next_month
31
+ ]
32
+
33
+ # If the first month has already expired, remove it, and add another month out
34
+ if from >= (this_month - 1.day)
35
+ expirations.shift
36
+ expirations << expiration_for(next_month.next_month)
37
+ end
38
+
39
+ expirations
40
+ end
41
+
42
+ def nearest_expiration(date, range = 3.days)
43
+ expiration_date = expiration_for(date + range)
44
+
45
+ if date > expiration_date
46
+ expiration_date = expiration_for(expiration_date.next_month)
47
+ end
48
+
49
+ if date >= expiration_date - range
50
+ expiration_for(expiration_date.next_month)
51
+ else
52
+ expiration_date
53
+ end
54
+ end
55
+ end
56
+ end
data/lib/penfold.rb CHANGED
@@ -16,5 +16,5 @@ require 'covered_call_expiry_otm_exit'
16
16
  require 'black_scholes'
17
17
 
18
18
  module Penfold
19
- VERSION = '1.0.0'
19
+ VERSION = '1.0.1'
20
20
  end
@@ -0,0 +1,16 @@
1
+ ---
2
+ example: !ruby/object:CoveredCallPosition
3
+ commission: !ruby/object:Commission
4
+ option_assignment: 500
5
+ option_entry: 850
6
+ option_entry_per_contract: 15
7
+ stock_entry: 295
8
+ num_shares: 500
9
+ option: !ruby/object:Call
10
+ expires: 2010-08-24
11
+ price: 600
12
+ stock: !ruby/object:Stock
13
+ price: 5000
14
+ symbol: XYZ
15
+ strike: 5000
16
+
@@ -0,0 +1,7 @@
1
+ When building symbol files, it can be useful to ensure that symbols from a given set of
2
+ files do not occur in more than one file. To print duplicates:
3
+
4
+ sort -o tmpA symbolsA.txt
5
+ sort -o tmpB symbolsB.txt
6
+ comm -12 tmpA tmpB
7
+