penfold 1.0.0 → 1.0.1

Sign up to get free protection for your applications and to get access to all the features.
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
+