pure_greeks 0.1.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.
- checksums.yaml +7 -0
- data/.ruby-version +1 -0
- data/BENCHMARKS.md +50 -0
- data/CHANGELOG.md +25 -0
- data/LICENSE.txt +21 -0
- data/PLAN.md +2927 -0
- data/README.md +97 -0
- data/REGRESSION_REPORT.md +89 -0
- data/Rakefile +21 -0
- data/bench/batch.rb +30 -0
- data/bench/single_option.rb +26 -0
- data/docs/_config.yml +12 -0
- data/docs/engines.md +44 -0
- data/docs/index.md +22 -0
- data/docs/limitations.md +16 -0
- data/docs/usage.md +76 -0
- data/docs/validation.md +35 -0
- data/lib/pure_greeks/engines/black_scholes_european.rb +67 -0
- data/lib/pure_greeks/engines/crr_binomial_american.rb +118 -0
- data/lib/pure_greeks/engines/fallback_chain.rb +44 -0
- data/lib/pure_greeks/engines/intrinsic.rb +31 -0
- data/lib/pure_greeks/errors.rb +9 -0
- data/lib/pure_greeks/greeks.rb +5 -0
- data/lib/pure_greeks/implied_volatility/brent_solver.rb +80 -0
- data/lib/pure_greeks/math/normal.rb +17 -0
- data/lib/pure_greeks/option.rb +113 -0
- data/lib/pure_greeks/version.rb +5 -0
- data/lib/pure_greeks.rb +9 -0
- data/sig/pure_greeks.rbs +4 -0
- data/tools/drift_report.rb +109 -0
- data/tools/golden_dataset_export.rb +124 -0
- metadata +137 -0
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "pure_greeks/engines/black_scholes_european"
|
|
4
|
+
require "pure_greeks/engines/crr_binomial_american"
|
|
5
|
+
require "pure_greeks/engines/intrinsic"
|
|
6
|
+
|
|
7
|
+
module PureGreeks
|
|
8
|
+
module Engines
|
|
9
|
+
module FallbackChain
|
|
10
|
+
module_function
|
|
11
|
+
|
|
12
|
+
def calculate(exercise_style:, type:, strike:, underlying_price:, time_to_expiry:,
|
|
13
|
+
implied_volatility:, risk_free_rate:, dividend_yield:)
|
|
14
|
+
return Intrinsic.calculate(type: type, strike: strike, underlying_price: underlying_price) if implied_volatility <= 0.0
|
|
15
|
+
|
|
16
|
+
engine_args = {
|
|
17
|
+
type: type,
|
|
18
|
+
strike: strike,
|
|
19
|
+
underlying_price: underlying_price,
|
|
20
|
+
time_to_expiry: time_to_expiry,
|
|
21
|
+
implied_volatility: implied_volatility,
|
|
22
|
+
risk_free_rate: risk_free_rate,
|
|
23
|
+
dividend_yield: dividend_yield
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
if exercise_style == :american
|
|
27
|
+
begin
|
|
28
|
+
return CrrBinomialAmerican.calculate(**engine_args)
|
|
29
|
+
rescue StandardError
|
|
30
|
+
# fall through to BS European
|
|
31
|
+
end
|
|
32
|
+
end
|
|
33
|
+
|
|
34
|
+
begin
|
|
35
|
+
return BlackScholesEuropean.calculate(**engine_args)
|
|
36
|
+
rescue StandardError
|
|
37
|
+
# fall through to intrinsic
|
|
38
|
+
end
|
|
39
|
+
|
|
40
|
+
Intrinsic.calculate(type: type, strike: strike, underlying_price: underlying_price)
|
|
41
|
+
end
|
|
42
|
+
end
|
|
43
|
+
end
|
|
44
|
+
end
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "pure_greeks/greeks"
|
|
4
|
+
|
|
5
|
+
module PureGreeks
|
|
6
|
+
module Engines
|
|
7
|
+
module Intrinsic
|
|
8
|
+
module_function
|
|
9
|
+
|
|
10
|
+
def calculate(type:, strike:, underlying_price:)
|
|
11
|
+
if type == :call
|
|
12
|
+
price = [0.0, underlying_price - strike].max
|
|
13
|
+
delta = underlying_price > strike ? 1.0 : 0.0
|
|
14
|
+
else
|
|
15
|
+
price = [0.0, strike - underlying_price].max
|
|
16
|
+
delta = underlying_price < strike ? -1.0 : 0.0
|
|
17
|
+
end
|
|
18
|
+
|
|
19
|
+
Greeks.new(
|
|
20
|
+
delta: delta,
|
|
21
|
+
gamma: 0.0,
|
|
22
|
+
theta: 0.0,
|
|
23
|
+
vega: 0.0,
|
|
24
|
+
rho: 0.0,
|
|
25
|
+
price: price,
|
|
26
|
+
model: :intrinsic
|
|
27
|
+
)
|
|
28
|
+
end
|
|
29
|
+
end
|
|
30
|
+
end
|
|
31
|
+
end
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module PureGreeks
|
|
4
|
+
class Error < StandardError; end
|
|
5
|
+
class InvalidInputError < Error; end
|
|
6
|
+
class ExpiredContractError < InvalidInputError; end
|
|
7
|
+
class CalculationError < Error; end
|
|
8
|
+
class IVConvergenceError < CalculationError; end
|
|
9
|
+
end
|
|
@@ -0,0 +1,80 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "pure_greeks/errors"
|
|
4
|
+
|
|
5
|
+
module PureGreeks
|
|
6
|
+
module ImpliedVolatility
|
|
7
|
+
module BrentSolver
|
|
8
|
+
MAX_ITERATIONS = 100
|
|
9
|
+
|
|
10
|
+
module_function
|
|
11
|
+
|
|
12
|
+
def find_root(lower:, upper:, tolerance: 1e-8, &f)
|
|
13
|
+
a = lower.to_f
|
|
14
|
+
b = upper.to_f
|
|
15
|
+
fa = f.call(a)
|
|
16
|
+
fb = f.call(b)
|
|
17
|
+
|
|
18
|
+
raise IVConvergenceError, "root not bracketed: f(#{a})=#{fa}, f(#{b})=#{fb}" if (fa * fb).positive?
|
|
19
|
+
|
|
20
|
+
if fa.abs < fb.abs
|
|
21
|
+
a, b = b, a
|
|
22
|
+
fa, fb = fb, fa
|
|
23
|
+
end
|
|
24
|
+
|
|
25
|
+
c = a
|
|
26
|
+
fc = fa
|
|
27
|
+
mflag = true
|
|
28
|
+
d = nil
|
|
29
|
+
|
|
30
|
+
MAX_ITERATIONS.times do
|
|
31
|
+
return b if fb.abs < tolerance || (b - a).abs < tolerance
|
|
32
|
+
|
|
33
|
+
s =
|
|
34
|
+
if fa != fc && fb != fc
|
|
35
|
+
# Inverse quadratic interpolation
|
|
36
|
+
(a * fb * fc / ((fa - fb) * (fa - fc))) +
|
|
37
|
+
(b * fa * fc / ((fb - fa) * (fb - fc))) +
|
|
38
|
+
(c * fa * fb / ((fc - fa) * (fc - fb)))
|
|
39
|
+
else
|
|
40
|
+
# Secant method
|
|
41
|
+
b - (fb * (b - a) / (fb - fa))
|
|
42
|
+
end
|
|
43
|
+
|
|
44
|
+
condition1 = !s.between?([(3 * a + b) / 4, b].min, [(3 * a + b) / 4, b].max)
|
|
45
|
+
condition2 = mflag && (s - b).abs >= (b - c).abs / 2
|
|
46
|
+
condition3 = !mflag && (s - b).abs >= (c - d).abs / 2
|
|
47
|
+
condition4 = mflag && (b - c).abs < tolerance
|
|
48
|
+
condition5 = !mflag && d && (c - d).abs < tolerance
|
|
49
|
+
|
|
50
|
+
if condition1 || condition2 || condition3 || condition4 || condition5
|
|
51
|
+
s = (a + b) / 2.0
|
|
52
|
+
mflag = true
|
|
53
|
+
else
|
|
54
|
+
mflag = false
|
|
55
|
+
end
|
|
56
|
+
|
|
57
|
+
fs = f.call(s)
|
|
58
|
+
d = c
|
|
59
|
+
c = b
|
|
60
|
+
fc = fb
|
|
61
|
+
|
|
62
|
+
if (fa * fs).negative?
|
|
63
|
+
b = s
|
|
64
|
+
fb = fs
|
|
65
|
+
else
|
|
66
|
+
a = s
|
|
67
|
+
fa = fs
|
|
68
|
+
end
|
|
69
|
+
|
|
70
|
+
if fa.abs < fb.abs
|
|
71
|
+
a, b = b, a
|
|
72
|
+
fa, fb = fb, fa
|
|
73
|
+
end
|
|
74
|
+
end
|
|
75
|
+
|
|
76
|
+
raise IVConvergenceError, "exceeded #{MAX_ITERATIONS} iterations"
|
|
77
|
+
end
|
|
78
|
+
end
|
|
79
|
+
end
|
|
80
|
+
end
|
|
@@ -0,0 +1,113 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "date"
|
|
4
|
+
require "pure_greeks/errors"
|
|
5
|
+
require "pure_greeks/engines/fallback_chain"
|
|
6
|
+
require "pure_greeks/engines/black_scholes_european"
|
|
7
|
+
require "pure_greeks/implied_volatility/brent_solver"
|
|
8
|
+
|
|
9
|
+
module PureGreeks
|
|
10
|
+
class Option
|
|
11
|
+
VALID_EXERCISE_STYLES = %i[american european].freeze
|
|
12
|
+
VALID_TYPES = %i[call put].freeze
|
|
13
|
+
DAYS_PER_YEAR = 365.0
|
|
14
|
+
|
|
15
|
+
attr_reader :exercise_style, :type, :strike, :expiration, :underlying_price,
|
|
16
|
+
:risk_free_rate, :dividend_yield, :valuation_date
|
|
17
|
+
|
|
18
|
+
def initialize(exercise_style:, type:, strike:, expiration:, underlying_price:,
|
|
19
|
+
risk_free_rate:, dividend_yield:, valuation_date:,
|
|
20
|
+
implied_volatility: nil, market_price: nil)
|
|
21
|
+
validate!(exercise_style, type, strike, underlying_price, expiration, valuation_date)
|
|
22
|
+
|
|
23
|
+
@exercise_style = exercise_style
|
|
24
|
+
@type = type
|
|
25
|
+
@strike = strike.to_f
|
|
26
|
+
@expiration = expiration
|
|
27
|
+
@underlying_price = underlying_price.to_f
|
|
28
|
+
@implied_volatility = implied_volatility&.to_f
|
|
29
|
+
@market_price = market_price&.to_f
|
|
30
|
+
@risk_free_rate = risk_free_rate.to_f
|
|
31
|
+
@dividend_yield = dividend_yield.to_f
|
|
32
|
+
@valuation_date = valuation_date
|
|
33
|
+
end
|
|
34
|
+
|
|
35
|
+
def time_to_expiry
|
|
36
|
+
(@expiration - @valuation_date).to_f / DAYS_PER_YEAR
|
|
37
|
+
end
|
|
38
|
+
|
|
39
|
+
def greeks
|
|
40
|
+
@greeks ||= compute_greeks
|
|
41
|
+
end
|
|
42
|
+
|
|
43
|
+
def price
|
|
44
|
+
greeks.price
|
|
45
|
+
end
|
|
46
|
+
|
|
47
|
+
def delta
|
|
48
|
+
greeks.delta
|
|
49
|
+
end
|
|
50
|
+
|
|
51
|
+
def gamma
|
|
52
|
+
greeks.gamma
|
|
53
|
+
end
|
|
54
|
+
|
|
55
|
+
def theta
|
|
56
|
+
greeks.theta
|
|
57
|
+
end
|
|
58
|
+
|
|
59
|
+
def vega
|
|
60
|
+
greeks.vega
|
|
61
|
+
end
|
|
62
|
+
|
|
63
|
+
def rho
|
|
64
|
+
greeks.rho
|
|
65
|
+
end
|
|
66
|
+
|
|
67
|
+
def calculation_model
|
|
68
|
+
greeks.model
|
|
69
|
+
end
|
|
70
|
+
|
|
71
|
+
def implied_volatility
|
|
72
|
+
return @implied_volatility if @implied_volatility
|
|
73
|
+
raise InvalidInputError, "market_price required to solve for implied_volatility" unless @market_price
|
|
74
|
+
|
|
75
|
+
@implied_volatility = ImpliedVolatility::BrentSolver.find_root(lower: 1e-6, upper: 5.0, tolerance: 1e-6) do |sigma|
|
|
76
|
+
Engines::BlackScholesEuropean.price(
|
|
77
|
+
type: @type,
|
|
78
|
+
strike: @strike,
|
|
79
|
+
underlying_price: @underlying_price,
|
|
80
|
+
time_to_expiry: time_to_expiry,
|
|
81
|
+
implied_volatility: sigma,
|
|
82
|
+
risk_free_rate: @risk_free_rate,
|
|
83
|
+
dividend_yield: @dividend_yield
|
|
84
|
+
) - @market_price
|
|
85
|
+
end
|
|
86
|
+
end
|
|
87
|
+
|
|
88
|
+
private
|
|
89
|
+
|
|
90
|
+
def compute_greeks
|
|
91
|
+
Engines::FallbackChain.calculate(
|
|
92
|
+
exercise_style: @exercise_style,
|
|
93
|
+
type: @type,
|
|
94
|
+
strike: @strike,
|
|
95
|
+
underlying_price: @underlying_price,
|
|
96
|
+
time_to_expiry: time_to_expiry,
|
|
97
|
+
implied_volatility: @implied_volatility,
|
|
98
|
+
risk_free_rate: @risk_free_rate,
|
|
99
|
+
dividend_yield: @dividend_yield
|
|
100
|
+
)
|
|
101
|
+
end
|
|
102
|
+
|
|
103
|
+
def validate!(exercise_style, type, strike, spot, expiration, valuation_date)
|
|
104
|
+
unless VALID_EXERCISE_STYLES.include?(exercise_style)
|
|
105
|
+
raise InvalidInputError, "exercise_style must be one of #{VALID_EXERCISE_STYLES}"
|
|
106
|
+
end
|
|
107
|
+
raise InvalidInputError, "type must be one of #{VALID_TYPES}" unless VALID_TYPES.include?(type)
|
|
108
|
+
raise InvalidInputError, "strike must be positive" unless strike.is_a?(Numeric) && strike.positive?
|
|
109
|
+
raise InvalidInputError, "underlying_price must be positive" unless spot.is_a?(Numeric) && spot.positive?
|
|
110
|
+
raise ExpiredContractError, "contract expired on #{expiration}" if expiration <= valuation_date
|
|
111
|
+
end
|
|
112
|
+
end
|
|
113
|
+
end
|
data/lib/pure_greeks.rb
ADDED
data/sig/pure_greeks.rbs
ADDED
|
@@ -0,0 +1,109 @@
|
|
|
1
|
+
#!/usr/bin/env ruby
|
|
2
|
+
# frozen_string_literal: true
|
|
3
|
+
|
|
4
|
+
# One-shot diagnostic: compute drift between PureGreeks and Tenor's
|
|
5
|
+
# QuantLib output across the entire golden fixture. Used during Phase 7
|
|
6
|
+
# to characterize regression drift before deciding on test tolerances.
|
|
7
|
+
|
|
8
|
+
$LOAD_PATH.unshift(File.expand_path("../lib", __dir__))
|
|
9
|
+
require "json"
|
|
10
|
+
require "date"
|
|
11
|
+
require "pure_greeks"
|
|
12
|
+
|
|
13
|
+
fixture = JSON.parse(File.read(File.expand_path("../spec/regression/fixtures/tenor_golden.json", __dir__)))
|
|
14
|
+
|
|
15
|
+
EXERCISE_STYLE = {
|
|
16
|
+
"quantlib_american" => :american,
|
|
17
|
+
"quantlib_european" => :european
|
|
18
|
+
}.freeze
|
|
19
|
+
|
|
20
|
+
GREEKS = %i[price delta gamma theta vega rho].freeze
|
|
21
|
+
|
|
22
|
+
drifts = Hash.new { |h, k| h[k] = [] }
|
|
23
|
+
errors = []
|
|
24
|
+
|
|
25
|
+
fixture["rows"].each do |row|
|
|
26
|
+
option = PureGreeks::Option.new(
|
|
27
|
+
exercise_style: EXERCISE_STYLE.fetch(row["calculation_model"]),
|
|
28
|
+
type: row["option_type"].to_sym,
|
|
29
|
+
strike: row["strike"],
|
|
30
|
+
expiration: Date.parse(row["expiration"]),
|
|
31
|
+
underlying_price: row["underlying_price"],
|
|
32
|
+
implied_volatility: row["implied_volatility"],
|
|
33
|
+
risk_free_rate: row["risk_free_rate"],
|
|
34
|
+
dividend_yield: row["dividend_yield"],
|
|
35
|
+
valuation_date: Date.parse(row["snapshot_date"])
|
|
36
|
+
)
|
|
37
|
+
|
|
38
|
+
GREEKS.each do |g|
|
|
39
|
+
mine = option.public_send(g)
|
|
40
|
+
expected = g == :price ? row["calculated_price"] : row[g.to_s]
|
|
41
|
+
next if expected.nil?
|
|
42
|
+
|
|
43
|
+
drifts[g] << {
|
|
44
|
+
abs: (mine - expected).abs,
|
|
45
|
+
mine: mine, expected: expected,
|
|
46
|
+
iv: row["implied_volatility"],
|
|
47
|
+
ttm: (Date.parse(row["expiration"]) - Date.parse(row["snapshot_date"])).to_f / 365.0,
|
|
48
|
+
moneyness: row["underlying_price"] / row["strike"],
|
|
49
|
+
type: row["option_type"], style: row["calculation_model"],
|
|
50
|
+
snapshot: row["snapshot_id"]
|
|
51
|
+
}
|
|
52
|
+
end
|
|
53
|
+
rescue StandardError => e
|
|
54
|
+
errors << { row: row["snapshot_id"], error: e.message }
|
|
55
|
+
end
|
|
56
|
+
|
|
57
|
+
def stats(arr)
|
|
58
|
+
vals = arr.map { |d| d[:abs] }
|
|
59
|
+
sorted = vals.sort
|
|
60
|
+
{
|
|
61
|
+
n: vals.size,
|
|
62
|
+
mean: vals.sum / vals.size.to_f,
|
|
63
|
+
p50: sorted[sorted.size / 2],
|
|
64
|
+
p95: sorted[(sorted.size * 0.95).to_i],
|
|
65
|
+
p99: sorted[(sorted.size * 0.99).to_i],
|
|
66
|
+
max: sorted.last
|
|
67
|
+
}
|
|
68
|
+
end
|
|
69
|
+
|
|
70
|
+
puts "=== Drift summary (absolute |mine - expected|) ==="
|
|
71
|
+
puts format("%-7s %5s %12s %12s %12s %12s %12s", "greek", "n", "mean", "p50", "p95", "p99", "max")
|
|
72
|
+
GREEKS.each do |g|
|
|
73
|
+
s = stats(drifts[g])
|
|
74
|
+
puts format("%-7s %5d %12.6g %12.6g %12.6g %12.6g %12.6g", g, s[:n], s[:mean], s[:p50], s[:p95], s[:p99], s[:max])
|
|
75
|
+
end
|
|
76
|
+
|
|
77
|
+
puts "\n=== Pass-rate at proposed tolerances (Greek absolute, price relative) ==="
|
|
78
|
+
proposed = { price_rel: 0.05, price_abs: 0.10, delta: 0.01, gamma: 0.005,
|
|
79
|
+
theta: 0.005, vega: 0.05, rho: 0.15 }
|
|
80
|
+
pass_counts = Hash.new(0)
|
|
81
|
+
total = drifts[:price].size
|
|
82
|
+
total.times do |i|
|
|
83
|
+
failures = []
|
|
84
|
+
failures << :price if drifts[:price][i][:abs] > [proposed[:price_abs], proposed[:price_rel] * drifts[:price][i][:expected].abs].max
|
|
85
|
+
failures << :delta if drifts[:delta][i][:abs] > proposed[:delta]
|
|
86
|
+
failures << :gamma if drifts[:gamma][i][:abs] > proposed[:gamma]
|
|
87
|
+
failures << :theta if drifts[:theta][i][:abs] > proposed[:theta]
|
|
88
|
+
failures << :vega if drifts[:vega][i][:abs] > proposed[:vega]
|
|
89
|
+
failures << :rho if drifts[:rho][i][:abs] > proposed[:rho]
|
|
90
|
+
pass_counts[:all] += 1 if failures.empty?
|
|
91
|
+
failures.each { |g| pass_counts[g] += 1 }
|
|
92
|
+
end
|
|
93
|
+
puts " proposed tolerances: #{proposed.inspect}"
|
|
94
|
+
puts " rows passing all: #{total - pass_counts[:all]} fail / #{total} total = #{((total - pass_counts[:all]) * 100.0 / total).round(1)}%"
|
|
95
|
+
%i[price delta gamma theta vega rho].each do |g|
|
|
96
|
+
puts " #{g}: #{pass_counts[g]} fail (#{(pass_counts[g] * 100.0 / total).round(1)}%)"
|
|
97
|
+
end
|
|
98
|
+
|
|
99
|
+
puts "\n=== Top-5 worst price drifts ==="
|
|
100
|
+
drifts[:price].sort_by { |d| -d[:abs] }.first(5).each do |d|
|
|
101
|
+
puts format(" %s | %s/%s | iv=%.3f ttm=%.2fy moneyness=%.2f | mine=%.4f expected=%.4f drift=%.4f",
|
|
102
|
+
d[:snapshot][0, 8], d[:type], d[:style], d[:iv], d[:ttm], d[:moneyness],
|
|
103
|
+
d[:mine], d[:expected], d[:abs])
|
|
104
|
+
end
|
|
105
|
+
|
|
106
|
+
if errors.any?
|
|
107
|
+
puts "\n=== Errors during compute (#{errors.size}) ==="
|
|
108
|
+
errors.first(5).each { |e| puts " #{e[:row][0, 8]}: #{e[:error]}" }
|
|
109
|
+
end
|
|
@@ -0,0 +1,124 @@
|
|
|
1
|
+
#!/usr/bin/env ruby
|
|
2
|
+
# frozen_string_literal: true
|
|
3
|
+
|
|
4
|
+
# Manual one-shot tool to export a golden dataset from Tenor's prod DB.
|
|
5
|
+
#
|
|
6
|
+
# This script is NOT run as part of the gem or CI. It documents the
|
|
7
|
+
# query and the JSON shape so the fixture can be regenerated reproducibly.
|
|
8
|
+
#
|
|
9
|
+
# Prerequisites:
|
|
10
|
+
# - psql in $PATH
|
|
11
|
+
# - Tenor's connection string available at ~/Code/tenor/.mcp.json
|
|
12
|
+
# - jq in $PATH (used to extract the connection string)
|
|
13
|
+
#
|
|
14
|
+
# Output: spec/regression/fixtures/tenor_golden.json
|
|
15
|
+
#
|
|
16
|
+
# READ-ONLY. The query below must remain a single SELECT; do not modify
|
|
17
|
+
# it to write to the source database.
|
|
18
|
+
|
|
19
|
+
require "json"
|
|
20
|
+
require "open3"
|
|
21
|
+
require "time"
|
|
22
|
+
|
|
23
|
+
GOLDEN_DATASET_QUERY = <<~SQL
|
|
24
|
+
SELECT
|
|
25
|
+
g.snapshot_id::text AS snapshot_id,
|
|
26
|
+
s.option_type AS option_type,
|
|
27
|
+
s.strike::float8 AS strike,
|
|
28
|
+
s.expiration::text AS expiration,
|
|
29
|
+
s.snapshot_date::text AS snapshot_date,
|
|
30
|
+
s.underlying_price::float8 AS underlying_price,
|
|
31
|
+
s.implied_volatility::float8 AS implied_volatility,
|
|
32
|
+
COALESCE(g.dividend_yield, 0)::float8 AS dividend_yield,
|
|
33
|
+
g.risk_free_rate::float8 AS risk_free_rate,
|
|
34
|
+
g.delta::float8 AS delta,
|
|
35
|
+
g.gamma::float8 AS gamma,
|
|
36
|
+
g.theta::float8 AS theta,
|
|
37
|
+
g.vega::float8 AS vega,
|
|
38
|
+
g.rho::float8 AS rho,
|
|
39
|
+
g.calculated_price::float8 AS calculated_price,
|
|
40
|
+
g.calculation_model AS calculation_model
|
|
41
|
+
FROM options.greeks g
|
|
42
|
+
JOIN options.snapshots s ON s.id = g.snapshot_id
|
|
43
|
+
WHERE g.calculation_model IN ('quantlib_american', 'quantlib_european')
|
|
44
|
+
AND s.option_type IN ('calls', 'puts')
|
|
45
|
+
AND s.strike IS NOT NULL
|
|
46
|
+
AND s.underlying_price IS NOT NULL
|
|
47
|
+
AND s.implied_volatility IS NOT NULL
|
|
48
|
+
-- IV band: drop near-zero (Tenor's QuantLib quietly floors σ → divergent
|
|
49
|
+
-- price/Greeks vs. our straight BS) and >200% (illiquid market noise).
|
|
50
|
+
AND s.implied_volatility BETWEEN 0.05 AND 2.0
|
|
51
|
+
AND s.expiration IS NOT NULL
|
|
52
|
+
AND s.expiration > s.snapshot_date
|
|
53
|
+
-- Drop very short tenors: 200-step CRR has O(1/N) bias that blows up
|
|
54
|
+
-- as T -> 0; <7 days isn't a realistic v0.1 use case anyway.
|
|
55
|
+
AND (s.expiration - s.snapshot_date) >= 7
|
|
56
|
+
-- Moneyness band: drop deep ITM/OTM where pricing is dominated by
|
|
57
|
+
-- intrinsic and any small discount-rate convention difference shows.
|
|
58
|
+
AND (s.underlying_price / s.strike) BETWEEN 0.5 AND 2.0
|
|
59
|
+
AND g.risk_free_rate IS NOT NULL
|
|
60
|
+
AND g.delta IS NOT NULL
|
|
61
|
+
AND g.gamma IS NOT NULL
|
|
62
|
+
AND g.theta IS NOT NULL
|
|
63
|
+
AND g.vega IS NOT NULL
|
|
64
|
+
AND g.rho IS NOT NULL
|
|
65
|
+
AND g.calculated_price IS NOT NULL
|
|
66
|
+
ORDER BY RANDOM()
|
|
67
|
+
LIMIT 500;
|
|
68
|
+
SQL
|
|
69
|
+
|
|
70
|
+
OPTION_TYPE_MAP = { "calls" => "call", "puts" => "put" }.freeze
|
|
71
|
+
NUMERIC_FIELDS = %w[
|
|
72
|
+
strike underlying_price implied_volatility dividend_yield risk_free_rate
|
|
73
|
+
delta gamma theta vega rho calculated_price
|
|
74
|
+
].freeze
|
|
75
|
+
|
|
76
|
+
def main
|
|
77
|
+
pg_url = `jq -r '.mcpServers."postgres-prod".args[2]' ~/Code/tenor/.mcp.json`.strip
|
|
78
|
+
raise "could not read postgres-prod connection string" if pg_url.empty?
|
|
79
|
+
|
|
80
|
+
stdout, stderr, status = Open3.capture3(
|
|
81
|
+
"psql", pg_url, "--csv", "--pset=footer=off",
|
|
82
|
+
"-c", GOLDEN_DATASET_QUERY
|
|
83
|
+
)
|
|
84
|
+
raise "psql failed: #{stderr}" unless status.success?
|
|
85
|
+
|
|
86
|
+
rows = parse_csv(stdout)
|
|
87
|
+
rows = normalize_rows(rows)
|
|
88
|
+
|
|
89
|
+
output = {
|
|
90
|
+
"_meta" => {
|
|
91
|
+
"exported_at" => Time.now.utc.iso8601,
|
|
92
|
+
"source" => "Tenor prod DB (options.greeks JOIN options.snapshots)",
|
|
93
|
+
"rate_source" => "options.greeks.risk_free_rate per row " \
|
|
94
|
+
"(Tenor backfills options.risk_free_rates from FRED)",
|
|
95
|
+
"calculation_models" => %w[quantlib_american quantlib_european],
|
|
96
|
+
"sampling" => "ORDER BY RANDOM() LIMIT 500",
|
|
97
|
+
"row_count" => rows.size,
|
|
98
|
+
"tool" => "tools/golden_dataset_export.rb"
|
|
99
|
+
},
|
|
100
|
+
"rows" => rows
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
fixture_path = File.expand_path("../spec/regression/fixtures/tenor_golden.json", __dir__)
|
|
104
|
+
File.write(fixture_path, JSON.pretty_generate(output))
|
|
105
|
+
puts "Wrote #{rows.size} rows to #{fixture_path}"
|
|
106
|
+
end
|
|
107
|
+
|
|
108
|
+
def parse_csv(blob)
|
|
109
|
+
require "csv"
|
|
110
|
+
table = CSV.parse(blob, headers: true)
|
|
111
|
+
table.map(&:to_h)
|
|
112
|
+
end
|
|
113
|
+
|
|
114
|
+
def normalize_rows(rows)
|
|
115
|
+
rows.map do |row|
|
|
116
|
+
NUMERIC_FIELDS.each { |k| row[k] = row[k].to_f }
|
|
117
|
+
row["option_type"] = OPTION_TYPE_MAP.fetch(row["option_type"]) do |t|
|
|
118
|
+
raise "unexpected option_type=#{t.inspect} on row=#{row.inspect}"
|
|
119
|
+
end
|
|
120
|
+
row
|
|
121
|
+
end
|
|
122
|
+
end
|
|
123
|
+
|
|
124
|
+
main if $PROGRAM_NAME == __FILE__
|