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.
@@ -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,5 @@
1
+ # frozen_string_literal: true
2
+
3
+ module PureGreeks
4
+ Greeks = Data.define(:delta, :gamma, :theta, :vega, :rho, :price, :model)
5
+ 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,17 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "distribution"
4
+
5
+ module PureGreeks
6
+ module Math
7
+ module Normal
8
+ def self.cdf(x)
9
+ Distribution::Normal.cdf(x)
10
+ end
11
+
12
+ def self.pdf(x)
13
+ Distribution::Normal.pdf(x)
14
+ end
15
+ end
16
+ end
17
+ 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
@@ -0,0 +1,5 @@
1
+ # frozen_string_literal: true
2
+
3
+ module PureGreeks
4
+ VERSION = "0.1.0"
5
+ end
@@ -0,0 +1,9 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "pure_greeks/version"
4
+ require "pure_greeks/errors"
5
+ require "pure_greeks/greeks"
6
+ require "pure_greeks/option"
7
+
8
+ module PureGreeks
9
+ end
@@ -0,0 +1,4 @@
1
+ module PureGreeks
2
+ VERSION: String
3
+ # See the writing guide of rbs: https://github.com/ruby/rbs#guides
4
+ end
@@ -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__